所謂服務端渲染就是將代碼的渲染交給服務器,服務器將渲染好的html字符串返回給客戶端,再由客戶端進行顯示。javascript
使用Vue的服務端渲染功能,須要引入Vue提供的服務端渲染模塊vue-server-renderer,其做用是建立一個渲染器,該渲染器能夠將Vue實例渲染成html字符串。css
用Koa來搭建一個web服務器來實現:
① 目錄結構
html
② 建立一個server.js 文件vue
const Koa = require("koa"); const Router = require("koa-router"); const fs = require("fs"); const app = new Koa(); // 建立服務器端app實例 const router = new Router(); // 建立服務器端路由 const Vue = require("vue"); const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊 const vm = new Vue({ // 建立Vue實例 data() { return {msg: "hello vm"} }, template: `<div>{{msg}}</div>` // 渲染器會將vue實例中的數據填入模板中並渲染成對應的html字符串 }); const template = fs.readFileSync("./server.template.html", "utf8"); // 讀取基本的html結構 const render = VueServerRender.createRenderer({ template }); // 建立渲染器並以server.template.html做爲html頁面的基本結構 router.get("/", async ctx => { // ctx.body = await render.renderToString(vm); ctx.body = await new Promise((resolve, reject) => { render.renderToString(vm, (err, html) => { // 將vm實例渲染成html並插入到server.template.html模板中 console.log(`${html}`); }); ); }); app.use(router.routes()); // 添加路由中間件 app.listen(3000, () => { console.log("node server listening on port 3000."); }); // 監聽3000端口
注意:java
<div data-server-rendered="true">hello vm</div>
上面初體驗中,咱們已經實現了一個簡單的Vue服務端渲染,可是咱們實際中Vue是一個很大的項目,裏面是包含了不少組件的大型應用,而不是像初體驗中的一個簡單的Vue實例,因此咱們必須引入一個Vue項目,包括Vue的入口文件main.js、App.vue、components、public/index.html等,如:
node
經過webpack來打包咱們的整個Vue項目,webpack將以Vue的根實例main.js做爲入口文件,打包出一個合併的最終的bundle.js和一個頁面入口index.html文件,該index.html文件引入bundle.js後就能加載整個Vue項目中的頁面以及頁面中的事件等等,這裏咱們的Vue項目是一個很簡單的模板項目,關鍵在於webpack的配置
// webpack.config.jswebpack
const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const VueLoader = require("vue-loader/lib/plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = { entry: resolve("./src/main.js"), // webpack 入口, 即Vue的入口文件main.js output: { filename: "bundle.js", // 打包後輸出的結果文件名 path: resolve("./dist") // 打包後輸出結果存放目錄 }, resolve: { extensions: [".js", ".vue"] // 沒有寫擴展名的時候,解析順序 }, module: { rules: [ { test: /\.js$/, use: { loader: "babel-loader", // 將全部的js文件經過babel-loader轉換爲ES5代碼 options: { presets: ["@babel/preset-env"] } }, exclude: /node_modules/ }, { test: /\.css$/, // 解析.vue文件中的css use: [ "vue-style-loader", "css-loader" ] }, { test: /\.vue$/, // 解析.vue文件,須要配合其中的插件進行使用 use: "vue-loader" } ] }, plugins: [ new VueLoader(), // 解析.vue文件的插件 new HtmlWebpackPlugin({ filename: 'index.html', // 打包後輸出的html文件名 template: resolve("./public/index.html") // 該模板文件在哪 }) ] }
打包輸出後的dist目錄中會出現兩個文件: bundle.js和index.html, 直接在本地點擊index.html文件便可執行並呈現整個Vue項目
① 在非服務端渲染的時候,咱們使用的打包入口文件是main.js,其主要就是建立了一個Vue實例,而且渲染App.vue,而後將渲染好的App.vue掛載到index.html文件#app元素中,可是咱們的服務端渲染是沒法mount的,也就是說沒法將渲染結果渲染到#app元素上,因此須要改造main.js文件web
// 改造後的main.js文件vue-router
import Vue from "vue"; import App from "./App"; /** 1. main.js在服務端渲染中的做用就是提供一個Vue項目的根實例,因此導出一個函數 2. 讓客戶端和服務端都能獲取到Vue項目的根實例,而後根據須要, 3. 客戶端經過手動調用$mount()進行掛載 4. */ export default () => { const app = new Vue({ render: h => h(App) }); return {app}; // 返回整個Vue根實例 }
② 新建兩個入口文件: client-entry.js 和 server-entry.js
// client-entry.jsvuex
import createApp from "./main"; const {app} = createApp(); // 獲取到Vue項目根實例 app.$mount("#app"); // 將根實例掛載到#app上
此時將webpack.config.js的入口文件改爲client-entry.js應該和以前是同樣的
// server-entry.js
import createApp from "./main"; /** * 服務端須要調用當前這個文件產生一個Vue項目的根實例 * 因爲服務端與客戶端是1對多的關係,因此不能每一個客戶端訪問都返回同一個Vue項目根實例 * 因此須要返回一個函數,該函數返回一個新的Vue項目根實例 * */ export default () => { const {app} = createApp(); // 獲取到Vue項目根實例 return app; }
爲何客戶端入口文件就不須要暴露一個一個函數?由於客戶端能夠被訪問屢次,即屢次執行,每次執行返回的都是一個新的Vue項目實例了。而服務器只會啓動一次,可是卻須要每次客戶端訪問都返回一個新的Vue項目實例,因此必須放到函數中
③ 拆分webapck.config.js, 將其分紅兩個配置文件,一樣一個用於客戶端,一個用於服務端打包
因爲客戶端和服務端的webpack配置文件有不少是相同的,因此能夠抽取出一個webpack.base.js
// webpack.base.js
const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const VueLoader = require("vue-loader/lib/plugin"); module.exports = { output: { filename: "[name].bundle.js", // 打包後輸出的結果文件名 path: resolve("./../dist/") // 打包後輸出結果存放目錄 }, resolve: { extensions: [".js", ".vue"] // 沒有寫擴展名的時候,解析順序 }, module: { rules: [ { test: /\.js$/, use: { loader: "babel-loader", // 將全部的js文件經過babel-loader轉換爲ES5代碼 options: { presets: ["@babel/preset-env"] } }, exclude: /node_modules/ }, { test: /\.css$/, // 解析.vue文件中的css use: [ "vue-style-loader", "css-loader" ] }, { test: /\.vue$/, // 解析.vue文件,須要配合其中的插件進行使用 use: "vue-loader" } ] }, plugins: [ new VueLoader(), // 解析.vue文件的插件 ] }
// webpack-client.js
const merge = require("webpack-merge"); const base = require("./webpack.base"); const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = merge(base, { entry: { client: resolve("./../src/client-entry.js"), // 給客戶端入口文件取名client,output的時候能夠獲取到該名字動態輸出 }, plugins: [ new HtmlWebpackPlugin({ filename: 'index.html', // 打包後輸出的html文件名 template: resolve("./../public/index.html") // 該模板文件在哪 }) ] });
// webpack-server.js
const merge = require("webpack-merge"); const base = require("./webpack.base"); const path = require("path"); const resolve = (dir) => { return path.resolve(__dirname, dir); } const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = merge(base, { entry: { server: resolve("./../src/server-entry.js"), // 給客戶端入口文件取名client,output的時候能夠獲取到該名字動態輸出 }, target: "node", // 給node使用 output: { libraryTarget: "commonjs2" // 把最終這個文件導出的結果放到module.exports上 }, plugins: [ new HtmlWebpackPlugin({ filename: 'index.server.html', // 打包後輸出的html文件名 template: resolve("./../public/index.server.html"), // 該模板文件在哪 excludeChunks: ["server"] // 排除某個模塊, 不讓打包輸出後的server.bundle.js文件引入到index.server.html文件中 }) ] });
服務端webpack配置文件比較特殊,在output的時候須要配置一個libraryTarget,由於默認webpack輸出的時候是將打包輸出結果放到一個匿名自執行函數中的,經過將libraryTarget設置爲commonjs2,就會將整個打包結果放到module.exports上;
服務端webpack打包後輸出的server.bundle.js文件不是直接引入到index.server.html文件中使用的,還須要通過處理渲染成html字符串才能插入到index.server.html文件中,因此打包輸出後,要在html-webpack-plugin中排除對該模塊的引用
因爲webpack配置文件被分割,因此啓動webapck-dev-server的時候須要指定配置文件,在package.json文件中添加腳本
"scripts": { "client:dev": "webpack-dev-server --config ./build/webpack.client.js --mode development", "client:build": "webpack --config ./build/webpack.client.js --mode development", "server:build": "webpack --config ./build/webpack.server.js --mode development" },
此時分別指向npm run client:build 和 npm run server:build便可在dist目錄下生成index.html、client.bundle.js, index.server.html、server.bundle.js,其中client.bundel.js被index.html引用,server.bundle.js沒有被index.server.html引入,index.server.html僅僅是拷貝到了dist目錄下,同時server.bundle.js的整個輸出結果是掛在module.exports下的
④ 將打包好的server.bundle.js交給服務器進行渲染並生成html字符串返回給客戶端,和以前初體驗同樣,建立一個web服務器,只不過,此次不是渲染一個簡單的Vue實例,而是渲染整個打包好的server.bundle.js
vue-server-renderer提供了兩種渲染方式:
// 獲取server.bundle.js中的Vue實例進行渲染 const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊 const template = fs.readFileSync("./server.template.html", "utf8"); // 讀取基本的html結構 const render = VueServerRender.createRenderer({ template }); // 建立渲染器並以server.template.html做爲html頁面的基本結構 router.get("/", async ctx => { const vm = require("./dist/server.bundle").default(); // 執行server.budle的default方法獲取Vue實例,每次請求獲取一個新的Vue實例 ctx.body = await new Promise((resolve, reject) => { render.renderToString(vm, (err, html) => { // 將vm實例渲染成html並插入到server.template.html模板中 if (err) reject(err); console.log(`${html}`); resolve(html); }); }); });
require server.bunlde.js以後調用default屬性獲取的方法, 其實就是server.entry.js中導出的方法,這個方法能夠接收路由參數,後面集成路由的時候會用到
// 直接渲染server.bundle.js const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊 // 讀取server.bundle.js中的內容,即文件中的字符串 const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8"); const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 讀取基本的html結構 const render = VueServerRender.createBundleRenderer(ServerBundle, { // 傳入server.bundle.js字符串建立渲染器 template }); router.get("/", async ctx => { ctx.body = await new Promise((resolve, reject) => { render.renderToString((err, html) => { // 將server.bundle.js渲染成html字符串 if (err) reject(err); resolve(html); }); }); });
render.renderToString()執行的時候內部也是要經過ServerBundle獲取到server.entry.js中導出的default()方法獲取到Vue項目實例進行渲染的,總之就是要獲取到Vue項目的實例進行渲染
重啓服務器,再次訪問,查看源碼,能夠看到頁面已經不是一個空的基礎頁面了,而是真實包含html內容的頁面,可是仍然存在一個問題,那就是以前的事件並不起做用了,由於服務器將sever.bundle.js渲染成的是html字符串返回給客戶端的,是不包含事件的,其中的事件執行函數在client.bundle.js中,因此咱們能夠在index.server.html文件中經過script標籤顯式地引入client.bundle.js,如:
<body> <!--vue-ssr-outlet--> <script src="client.bundle.js"></script> </body>
注意: 當訪問頁面的時候,就會向服務器請求client.bundle.js文件,因此服務器須要將client.bundle.js以靜態資源的方式發佈出去。
剛纔咱們是手動在index.server.html中經過script標籤引入client.bundle.js, 很是的不方便,vue-server-renderer給咱們提供了兩個插件,vue-server-renderer/client-plugin和vue-server-renderer/server-plugin,能夠在webpack配置文件中引入,那麼打包的時候,會分別生成兩個json文件,vue-ssr-client-manifest.json和vue-ssr-server-bundle.json,這兩個文件主要是生成客戶端和服務端bundle的對應關係,這樣就不須要咱們收到引入client.bundle.js了。
以前是經過讀取server.bundle.js的內容來渲染的,如今能夠直接requirevue-ssr-server-bundle.json文件便可,同時在渲染的時候再添加vue-ssr-client-manifest.json便可,如:
// 直接渲染server.bundle.js const VueServerRender = require("vue-server-renderer"); // 引入服務端渲染模塊 // 讀取server.bundle.js中的內容,即文件中的字符串 // const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8"); const ServerBundle = require("./dist/vue-ssr-server-bundle.json"); const clientManifest = require("./dist/vue-ssr-client-manifest.json"); const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 讀取基本的html結構 const render = VueServerRender.createBundleRenderer(ServerBundle, { // 傳入server.bundle.js字符串建立渲染器 template, clientManifest });
使用者兩個插件以後,就不會生成server.bundle.js文件了
要集成路由,那麼須要在Vue項目中加入路由功能,和客戶端路由配置同樣,只不過不是直接導出路由實例,而是和main.js同樣導出一個方法返回一個新的路由實例,如:
import Vue from "vue"; import VueRouter from "vue-router"; import Foo from "./components/Foo"; Vue.use(VueRouter); export default () => { // 導出函數返回路由實例 const router = new VueRouter({ mode: "history", routes: [ { path: "/", component: Foo }, { path: "/bar", component: () => import("./components/Bar.vue") } ] }); return router; }
而後在main.js中調用路由方法獲取路由實例並掛到Vue實例上,同時對外暴露,如:
export default () => { const router = createRouter(); const app = new Vue({ router, // 掛在路由實例到Vue實例上 render: h => h(App) }); return {app, router}; // 對外暴露路由實例 }
此時Vue項目已經實現路由功能,可是訪問的時候卻會報錯, The client-side rendered virtual DOM tree is not matching server-rendered content,即 客戶端和服務端渲染的頁面不一致,之因此出現這種狀況是由於, 客戶端加了路由功能進行了相應的路由跳轉,可是服務端沒有進行路由跳轉,因此頁面會不一致,解決方法就是, 服務器也要進行相應的路由跳轉
前面提到過createBundleRenderer()方法建立的渲染器在執行renderToString()方法的時候,能夠傳遞一個context上下文對象,能夠將客戶端的訪問url保存到context對象上,而這個context對象會傳到server.entry.js對外暴露函數中,而後在該函數中獲取路由進行相應跳轉便可,如:
// server.entry.js
export default (context) => { const {app, router} = createApp(); // 獲取到Vue項目根實例server console.log("至關於新建立了一個服務端"); router.push(context.url); // 在服務端進行路由跳轉 return app; }
此時再訪問頁面,就不會出現上述客戶端和服務端渲染頁面不一致的狀況了,可是還有一個問題,那就是咱們在瀏覽器中直接訪問路由路徑的時候,會提示404,由於咱們服務器並無配置相應的路由,因此客戶端定義的路由路徑,須要在服務器端進行相應的配置
還有就是異步組件渲染的問題,咱們如今的server.entry.js中是直接返回Vue實例的,同時在其中進行router跳轉,若是路由跳轉的那個是異步組件,可能還沒跳轉完成,就返回了Vue實例,而出現渲染異常的狀況,因此咱們要返回一個Promise對象,等路由跳轉完成後再返回Vue實例,如:
// 改造後的sever.entry.js
export default (context) => { return new Promise((resolve, reject) => { const {app, router} = createApp(); // 獲取到Vue項目根實例server router.push(context.url); router.onReady(() => { // 等路由跳轉完成 let matchs = router.getMatchedComponents(); if (matchs.length === 0) { reject({code: 404}); } resolve(app); }, reject); }); }
404頁面的處理,咱們能夠在router.onReady回調中進行處理,能夠根據路由匹配結果進行提示,若是路由匹配結果爲0,那麼就是沒有匹配成功則reject一個錯誤,服務器捕獲到錯誤後進行404提示便可
一樣,要集成Vuex,首先和客戶端渲染同樣,引入Vuex並建立store,只不過是對外暴露一個函數,而後在函數中返回新的store對象,如:
// store.js
import Vue from "vue"; import Vuex from "vuex"; Vue.use(Vuex); export default () => { const store = new Vuex.Store({ state: { name: "even" }, mutations: { changeName(state) { state.name = "lhb"; } }, actions: { changeName({commit}) { console.log("changeName action"); return new Promise((resolve, reject) => { setTimeout(() => { commit("changeName"); resolve(); }, 3000); }); } } }); return store; }
而後在main.js中引入並注入到Vue實例中,跟Vue根實例和路由同樣對外暴露。服務端渲染集成Vuex關鍵在於服務端渲染的時候執行mutaion或者action後,Vuex中數據僅在服務器端改變,因此須要將服務器端的狀態數據保存起來,實際上會保存到window對象的__INITIAL_STATE__屬性上,客戶端渲染的時候只須要從window.__INITIAL_STATE__數據中獲取到服務端Vuex的狀態而後進行替換便可。
① 在Foo.vue組件中添加一個asyncData()方法,用於派發action,如:
// Foo.vue
export default { asyncData(store) { // asyncData只在服務端執行 console.log("asyncData"); return store.dispatch('changeName'); } }
② 在server-entry.js中,若是匹配到了Foo.Vue組件,那麼執行該組件的asyncData()方法,此時服務器端的Vuex的狀態就會發生改變,如:
// server-entry.js
export default (context) => { return new Promise((resolve, reject) => { console.log(context.url); const {app, router, store} = createApp(); // 獲取到Vue項目根實例server router.push(context.url); router.onReady(() => { // 等路由跳轉完成 let matchs = router.getMatchedComponents(); Promise.all(matchs.map((component) => { if (component.asyncData) { // 若是匹配的組件中含有asyncData方法則執行 return component.asyncData(store); // 服務器端Vuex狀態會發生改變 } })).then(() => { console.log("success"); context.state = store.state; // 服務器端store狀態改變後將其掛載到context上,而後會掛載到window的__INITIAL_STATE__上 resolve(app); }); if (matchs.length === 0) { reject({code: 404}); } }, reject); }); }
將服務器Vuex狀態保存的時候,必須是保存到context的state屬性上,服務器端渲染完成後,會添加一個script標籤其中只有一行代碼,就是將服務器端Vuex狀態保存到window.__INITIAL_STATE__上
<script>window.__INITIAL_STATE__={"name":"lhb"}</script>
③ 接下來就是須要客戶端去取出window.__INITIAL_STATE__中的狀態數據並替換,在store.js中返回store對象前進行判斷,若是是客戶端執行Vuex,那麼取出window.__INITIAL_STATE__中的狀態數據並替換,如:
if(typeof window !== "undefined" && window.__INITIAL_STATE__) { // 若是是客戶端執行 store.replaceState(window.__INITIAL_STATE__); // 將服務器端store狀態替換掉客戶端狀態 } return store;
將Vuex中的數據顯示出來,此時再訪問Foo.vue就能夠看到name數據的變化了,咱們如今只有在進行服務器端渲染Foo.vue的時候纔會執行asyncData()方法,數據纔會發生變化,若是在客戶端進行渲染Foo.vue組,那麼不會執行asyncData(),因此能夠在Foo.vue組件mounted的時候派發一個相同的action進行數據改變便可
// Foo.vue
export default { mounted () { this.$store.dispatch("changeName"); } }