webpack4+koa2+vue 實現伺服器端渲染(詳解)
閱讀目錄
- 一:什麼是伺服器端渲染?什麼是客戶端渲染?他們的優缺點?
- 二:瞭解 vue-server-renderer 的作用及基本語法。
- 三:與伺服器整合
- 四:伺服器渲染搭建 4.1 為每個請求建立一個新的根vue實列 4.2 使用vue-router路由實現和程式碼分割 4.3 開發環境配置 4.4 資料預獲取和狀態 4.5 頁面注入不同的Head 4.6 頁面級別的快取
一:什麼是伺服器端渲染?什麼是客戶端渲染?他們的優缺點?
1. 伺服器端渲染及客戶端渲染。
在網際網路早期,前端頁面都是一些簡單的頁面,那麼前端頁面都是後端將html拼接好,然後將它返回給前端完整的html檔案。瀏覽器拿到這個html檔案之後就可以直接顯示了,這就是我們所謂的伺服器端渲染。比如典型的 java + velocity。node + jade 進行html模板拼接及渲染。velocity語法在後端編寫完成後,後端會重新編譯後,將一些vm頁面的變數編譯成真正值的時候,把html頁面返回給瀏覽器,瀏覽器就能直接解析和顯示出來了。這種模式就是伺服器端渲染。而隨著前端頁面複雜性越來越高,前端就不僅僅是頁面展現了,還有可能需要新增更多複雜功能的元件。及2005年前後,ajax興起,就逐漸出現前端這個行業,前後端分離就變得越來越重要。因此這個時候後端它就不提供完整的html頁面,而是提供一些api介面, 返回一些json資料,我們前端拿到該json資料之後再使用html對資料進行拼接,然後展現在瀏覽器上。
2. 伺服器端渲染和客戶端渲染的區別?
伺服器端渲染和客戶端的渲染的本質區別是誰來渲染html頁面,如果html頁面在伺服器端那邊拼接完成後,那麼它就是伺服器端渲染,而如果是前端做的html拼接及渲染的話,那麼它就屬於客戶端渲染的。
3. 伺服器端渲染的優點和缺點?
優點:
1. 有利於SEO搜尋引擎,後端直接返回html檔案,爬蟲可以獲取到資訊。
2. 前端耗時少,首屏效能更好,因此頁面是伺服器端輸出的,前端不需要通過ajax去動態載入。
3. 不需要佔用客戶端的資源,因為解析html模板的工作是交給伺服器端完成的,客戶端只需要解析標準的html頁面即可。這樣客戶端佔用的資源會變少。
缺點:
1. 不利於前後端分離,開發效率比較低。比如我們前端需要編寫 velocity語法,如果對該語法不熟悉的話,還需要去學習下,並且編寫完成後,還需要呼叫後端的變數,把變數輸出到html對應位置上,編寫完成後,要在html模板中加入一些資原始檔路徑,所有工作完成後,把html模板交給後端,後端再對該模板進行伺服器端編譯操作。那麼等以後維護的時候,我們前端需要在某塊html中插入其他的東西,由於之前編寫的頁面沒有對應的標識,比如id等,那麼我們現在又需要去修改vm模板頁面等等這樣的事情。也就是說工作效率非常低。維護不方便。
4. 客戶端渲染的優點和缺點?
優點:
1. 前後端分離,前端只專注於前端UI開發,後端專注於API開發。
2. 使用者體驗更好,比如我們前端頁面可以做成spa頁面。體驗可以更接近原生的app.
缺點:
1. 不利於SEO,因為html頁面都是通過js+dom非同步動態拼接載入的,當使用爬蟲獲取的時候,由於js非同步載入,所以獲取抓取不到內容的。或者說,爬蟲無法對JS爬取的能力。
2. 前端耗時多,響應比較慢,因為html模板頁面放在前端去通過dom去拼接及載入,需要額外的耗時。沒有伺服器端渲染快。
5. 何時使用伺服器端渲染、何時場景使用客戶端渲染呢?
對於我們常見的後端系統頁面,互動性強,不需要考慮SEO搜尋引擎的,我們只需要客戶端渲染就好,而對於一些企業型網站,沒有很多複雜的互動型功能,並且需要很好的SEO(因為人家通過百度可以搜尋到你的官網到),因此我們需要伺服器端渲染。另外還需要考慮的是,比如App裡面的功能,首頁效能很重要,比如淘寶官網等這些都需要做伺服器渲染的。伺服器渲染對於SEO及效能是非常友好的。
因此為了實現伺服器端渲染的模式,我們的vue2.0 和 react就加入了伺服器端渲染的方式,下面我們這邊先來看看vue如何實現伺服器端渲染的。
使用客戶端的渲染,就有如下圖所示:頁面上有一個id為app的標籤,然後下面就是由js動態渲染的。如下基本結構:
然後我們可以看下網路頁面返回渲染的html程式碼如下所示:
如上就是由客戶端渲染的方式。
我們再來了解下伺服器端渲染是什麼樣的?
我們可以看下 https://cn.vuejs.org/ 這個官網,然後我們右鍵檢視原始碼,可以看到它不是客戶端渲染的,而是伺服器端渲染的,如下圖所示:
我們再接著可以看下網路請求,伺服器端返回的html文件資訊如下,可以看到是伺服器端渲染的,因為html內容都是伺服器端拼接完成後返回到客戶端的。如下圖所示:
二:瞭解 vue-server-renderer 的作用及基本語法。
在瞭解vue伺服器端渲染之前,我們先來了解vue中一個外掛vue-server-renderer的基本用法及作用。
該軟體包的作用是:vue2.0提供在node.js 伺服器端呈現的。
我們需要使用該 vue-server-renderer 包,我們需要在我們專案中安裝該包。使用命令如下:
npm install --save vue-server-renderer vue
API
1. createRenderer()
該方法是建立一個renderer實列。如下程式碼:
const renderer = require('vue-server-renderer').createRenderer();
2. renderer.renderToString(vm, cb);
該方法的作用是:將Vue實列呈現為字串。該方法的回撥函式是一個標準的Node.js回撥,它接收錯誤作為第一個引數。如下程式碼:
// renderer.js 程式碼如下: const Vue = require('vue'); // 建立渲染器 const renderer = require('vue-server-renderer').createRenderer(); const app = new Vue({ template: `<div>Hello World</div>` }); // 生成預渲染的HTML字串. 如果沒有傳入回撥函式,則會返回 promise,如下程式碼 renderer.renderToString(app).then(html => { console.log(html); // 輸出:<div data-server-rendered="true">Hello World</div> }).catch(err => { console.log(err); }); // 當然我們也可以使用另外一種方式渲染,傳入回撥函式, // 其實和上面的結果一樣,只是兩種不同的方式而已 renderer.renderToString(app, (err, html) => { if (err) { throw err; return; } console.log(html) // => <div data-server-rendered="true">Hello World</div> })
如上程式碼,我們儲存為 renderer.js 後,我們使用命令列中,執行 node renderer.js 後,輸出如下所示:
如上我們可以看到,在我們div中有一個特殊的屬性 data-server-rendered,該屬性的作用是告訴VUE這是伺服器渲染的元素。並且應該以啟用的模式進行掛載。
3. createBundleRenderer(code, [rendererOptions])
Vue SSR依賴包 vue-server-render, 它的呼叫支援有2種格式,createRenderer() 和 createBundleRenderer(), 那麼createRenderer()是以vue元件為入口的,而 createBundleRenderer() 以打包後的JS檔案或json檔案為入口的。所以createBundleRenderer()的作用和 createRenderer() 作用是一樣的,無非就是支援的入口檔案不一樣而已;我們可以簡單的使用 createBundleRenderer該方法來做個demo如下:
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer; // 絕對檔案路徑 let renderer = createBundleRenderer('./package.json'); console.log(renderer);
我們把該js儲存為 renderer.js, 然後我們在node中執行該js檔案。node renderer.js 後看到該方法也同樣有 renderToString() 和 renderToStream() 兩個方法。如下圖所示:
回到頂部三:與伺服器整合
從上面的知識學習,我們瞭解到要伺服器端渲染,我們需要用到 vue-server-renderer 元件包。該包的基本的作用是拿到vue實列並渲染成html結構。
因此我們需要在我們專案的根目錄下新建一個叫app.js ,然後程式碼如下:
const Vue = require('vue'); const Koa = require('koa'); const Router = require('koa-router'); const renderer = require('vue-server-renderer').createRenderer(); // 1. 建立koa koa-router實列 const app = new Koa(); const router = new Router(); // 2. 路由中介軟體 router.get('*', async(ctx, next) => { // 建立vue實列 const app = new Vue({ data: { url: ctx.url }, template: `<div>訪問的URL是:{{url}}</div>` }) try { // vue 實列轉換成字串 const html = await renderer.renderToString(app); ctx.status = 200; ctx.body = ` <!DOCTYPE html> <html> <head><title>vue伺服器渲染元件</title></head> <body>${html}</body> </html> ` } catch(e) { console.log(e); ctx.status = 500; ctx.body = '伺服器錯誤'; } }); // 載入路由元件 app .use(router.routes()) .use(router.allowedMethods()); // 啟動服務 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
因此當我們訪問頁面的時候,比如訪問:http://localhost:3000/xx 的時候,就可以看到如下所示:
如上就是一個簡單伺服器端渲染的簡單頁面了,為了簡化頁面程式碼,我們可以把上面的html程式碼抽離出來成一個 index.template.html, 程式碼如下:
<!DOCTYPE html> <html> <head> <!-- 三花括號不會進行html轉義 --> {{{ meta }}} <title>{{title}}</title> </head> <body> <!--vue-ssr-outlet--> </body> </html>
現在我們再來改下 app.js 程式碼,我們可以通過node中的 fs模組讀取 index.template.html 頁面程式碼進去,如下所示的程式碼:
const Vue = require('vue'); const Koa = require('koa'); const Router = require('koa-router'); const renderer = require('vue-server-renderer').createRenderer({ // 讀取傳入的template引數 template: require('fs').readFileSync('./index.template.html', 'utf-8') }); // 1. 建立koa koa-router實列 const app = new Koa(); const router = new Router(); // 2. 路由中介軟體 router.get('*', async(ctx, next) => { // 建立vue實列 const app = new Vue({ data: { url: ctx.url }, template: `<div>訪問的URL是:{{url}}</div>` }); const context = { title: 'vue伺服器渲染元件', meta: ` <meta charset="utf-8"> <meta name="" content="vue伺服器渲染元件"> ` }; try { // 傳入context 渲染上下文物件 const html = await renderer.renderToString(app, context); ctx.status = 200; ctx.body = html; } catch (e) { ctx.status = 500; ctx.body = '伺服器錯誤'; } }); // 載入路由元件 app .use(router.routes()) .use(router.allowedMethods()); // 啟動服務 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
然後我們繼續執行 node app.js ,然後我們訪問 http://localhost:3000/xx1 可以看到如下資訊,如下所示:
也是可以訪問的。
注意:html中必須包含 <!--vue-ssr-outlet--> ,renderer.renderToString函式把這行程式碼替換成HTML. 我之前以為這只是一個註釋,然後隨便寫一個註釋上去,結果執行命令報錯,改成這個 <!--vue-ssr-outlet--> 就可以了,因此這個的作用就是當做佔位符,等 renderer.renderToString函式 真正渲染成html後,會把內容插入到該地方來。
回到頂部4.1 為每個請求建立一個新的根vue實列
在vue伺服器渲染之前,我們需要了解如下:
元件生命週期鉤子函式
伺服器渲染過程中,只會呼叫 beforeCreate 和 created兩個生命週期函式。其他的生命週期函式只會在客戶端呼叫。
因此在created生命週期函式中不要使用的不能銷燬的變數存在。比如常見的 setTimeout, setInterval 等這些。並且window,document這些也不能在該兩個生命週期中使用,因為node中並沒有這兩個東西,因此如果在伺服器端執行的話,也會發生報錯的。但是我們可以使用 axios來發請求的。因為它在伺服器端和客戶端都暴露了相同的API。但是瀏覽器原生的XHR在node中也是不支援的。
官方的SSR-demo
我們現在需要把上面的實列一步步分開做demo。那麼假如我們現在的專案目錄架構是如下:
|---- ssr-demo1 | |--- src | | |--- app.js # 為每個請求建立一個新的根vue實列 | | |--- index.template.html | |--- .babelrc # 處理 ES6 的語法 | |--- .gitignore # github上排除一些檔案 | |--- server.js # 服務相關的程式碼 | |--- package.json # 依賴的包檔案
app.js 程式碼如下:
const Vue = require('vue'); module.exports = function createApp (ctx) { return new Vue({ data: { url: ctx.url }, template: `<div>訪問的URL是:{{url}}</div>` }) }
它的作用是避免狀態單列,單列模式看我這篇文章(https://www.cnblogs.com/tugenhua0707/p/4660236.html#_labe4). 單列模式最大的特點是 單例模式只會建立一個例項,且僅有一個例項。但是我們Node.js 伺服器是一個長期執行的程序,當我們執行到該程序的時候,它會將進行一次取值並且留在記憶體當中,如果我們用單列模式來建立物件的話,那麼它的實列,會讓每個請求之間會發生共享。也就是說實列發生共享了,那麼這樣很容易導致每個實列中的狀態值會發生混亂。因此我們這邊把app.js程式碼抽離一份出來,就是需要為每個請求建立一個新的實列。因此我們會把上面的demo程式碼分成兩部分。
server.js 程式碼如下:
const Vue = require('vue'); const Koa = require('koa'); const Router = require('koa-router'); const renderer = require('vue-server-renderer').createRenderer({ // 讀取傳入的template引數 template: require('fs').readFileSync('./src/index.template.html', 'utf-8') }); // 1. 建立koa koa-router實列 const app = new Koa(); const router = new Router(); // 引入 app.js const createApp = require('./src/app'); // 2. 路由中介軟體 router.get('*', async(ctx, next) => { // 建立vue實列 const app = createApp(ctx); const context = { title: 'vue伺服器渲染元件', meta: ` <meta charset="utf-8"> <meta name="" content="vue伺服器渲染元件"> ` }; try { // 傳入context 渲染上下文物件 const html = await renderer.renderToString(app, context); ctx.status = 200; ctx.body = html; } catch (e) { ctx.status = 500; ctx.body = '伺服器錯誤'; } }); // 載入路由元件 app .use(router.routes()) .use(router.allowedMethods()); // 啟動服務 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
如上server.js 程式碼會引用 app.js,如程式碼:const createApp = require('./src/app'); 然後在 router.get('*', async(ctx, next) => {}) 裡面都會呼叫下 const app = createApp(ctx); 這句程式碼,建立一個新的實列。
注意:下面講解的 router 和 store 也會是這樣做的。
src/index.template.html 程式碼如下:
<!DOCTYPE html> <html> <head> <!-- 三花括號不會進行html轉義 --> {{{ meta }}} <title>{{title}}</title> </head> <body> <!--vue-ssr-outlet--> </body> </html>
package.json 程式碼如下:
{ "name": "ssr-demo1", "version": "1.0.0", "description": "", "main": "server.js", "scripts": {}, "author": "", "license": "ISC", "dependencies": { "fs": "0.0.1-security", "koa": "^2.7.0", "koa-router": "^7.4.0", "vue": "^2.6.10", "vue-server-renderer": "^2.6.10" } }
當我們執行 node server.js 的時候,會啟動3000 埠,當我們訪問 http://localhost:3000/xxx,一樣會看到如下資訊:如下所示:
github原始碼檢視(ssr-demo1)
回到頂部4.2 使用vue-router路由實現和程式碼分割
如上demo實列,我們只是使用 node server.js 執行伺服器端的啟動程式,然後進行伺服器端渲染頁面,但是我們並沒有將相同的vue程式碼提供給客戶端,因此我們要實現這一點的話,我們需要在專案中引用我們的webpack來打包我們的應用程式。
並且我們還需要在專案中引入前端路由來實現這麼一個功能,因此我們專案中整個目錄架構可能是如下這樣的:
|----- ssr-demo2 | |--- build | | |--- webpack.base.conf.js # webpack 基本配置 | | |--- webpack.client.conf.js # 客戶端打包配置 | | |--- webpack.server.conf.js # 伺服器端打包配置 | |--- src | | |--- assets # 存放css,圖片的目錄資料夾 | | |--- components # 存放所有的vue頁面,當然我們這邊也可以新建資料夾分模組 | | | |--- home.vue | | | |--- item.vue | | |--- app.js # 建立每一個實列檔案 | | |--- App.vue | | |--- entry-client.js # 掛載客戶端應用程式 | | |--- entry-server.js # 掛載伺服器端應用程式 | | |--- index.template.html # 頁面模板html檔案 | | |--- router.js # 所有的路由 | |--- .babelrc # 支援es6 | |--- .gitignore # 排除github上的一些檔案 | |--- server.js # 啟動服務程式 | |--- package.json # 所有的依賴包
注意:這邊會參看下官網的demo程式碼,但是會盡量一步步更詳細講解,使大家更好的理解。
src/App.vue 程式碼如下所示:
<style lang="stylus"> h1 color red font-size 22px </style> <template> <div id="app"> <router-view></router-view> <h1>{{ msg }}</h1> <input type="text" v-model="msg" /> </div> </template> <script type="text/javascript"> export default { name: 'app', data() { return { msg: '歡迎光臨vue.js App' } } } </script>
src/app.js
如上我們知道,app.js 最主要做的事情就是 為每個vue創造一個新的實列,在該專案中,我們希望建立vue實列後,並且把它掛載到DOM上。因此我們這邊先簡單的使用 export 匯出一個 createApp函式。基本程式碼如下:
import Vue from 'vue'; import App from './App.vue'; // 匯出函式,用於建立新的應用程式 export function createApp () { const app = new Vue({ // 根據實列簡單的渲染應用程式元件 render: h => h(App) }); return { app }; }
src/entry-client.js
該檔案的作用是建立應用程式,並且將其掛載到DOM中,目前基本程式碼如下:
import { createApp } from './app'; const { app } = createApp(); // 假設 App.vue 模板中根元素 id = 'app' app.$mount('#app');
如上可以看到,我們之前掛載元素是如下這種方式實現的,如下程式碼所示:
new Vue(Vue.util.extend({ router, store }, App)).$mount('#app');
現在呢?無非就是把他們分成兩塊,第一塊是 src/app.js 程式碼例項化一個vue物件,然後返回例項化物件後的物件,然後在src/entry-client.js 檔案裡面實現 app物件掛載到 id 為 'app' 這個元素上。
src/entry-server.js
import { createApp } from './app'; export default context => { const { app } = createApp(); return app; }
如上是伺服器端的程式碼,它的作用是 匯出函式,並且建立vue實現,並且返回該實列後的物件。如上程式碼所示。但是在每次渲染中會重複呼叫此函式。
src/router.js
在上面的server.js 程式碼中會有這麼一段 router.get('*', async(ctx, next) => {}) 程式碼,它的含義是接收任意的URL,這就允許我們將訪問的URL傳遞到我們的VUE應用程式中。然後會對客戶端和服務端複用相同的路由配置。因此我們現在需要使用vue-router. router.js 檔案也和app.js一樣,需要為每個請求建立一個新的 Router的實列。所以我們的router.js 也需要匯出一個函式,比如叫 createRouter函式吧。因此router.js 程式碼如下所示:
// router.js import Vue from 'vue'; import Router from 'vue-router'; Vue.use(Router); export function createRouter () { return new Router({ mode: 'history', routes: [ { path: '/home', component: resolve => require(['./components/home'], resolve) }, { path: '/item', component: resolve => require(['./components/item'], resolve) }, { path: '*', redirect: '/home' } ] }); }
然後我們這邊需要在 src/app.js 程式碼裡面把 router 引用進去,因此我們的app.js 程式碼需要更新程式碼變成如下:
import Vue from 'vue'; import App from './App.vue'; // 引入 router import { createRouter } from './router'; // 匯出函式,用於建立新的應用程式 export function createApp () { // 建立 router的實列 const router = createRouter(); const app = new Vue({ // 注入 router 到 根 vue實列中 router, // 根實列簡單的渲染應用程式元件 render: h => h(App) }); return { app, router }; }
更新 entry-server.js
現在我們需要在 src/entry-server.js 中需要實現伺服器端的路由邏輯。更新後的程式碼變成如下:
import { createApp } from './app'; export default context => { /* const { app } = createApp(); return app; */ /* 由於 路由鉤子函式或元件 有可能是非同步的,比如 同步的路由是這樣引入 import Foo from './Foo.vue' 但是非同步的路由是這樣引入的: { path: '/index', component: resolve => require(['./views/index'], resolve) } 如上是 require動態載入進來的,因此我們這邊需要返回一個promise物件。以便伺服器能夠等待所有的內容在渲染前 就已經準備好就緒。 */ return new Promise((resolve, reject) => { const { app, router } = createApp(); // 設定伺服器端 router的位置 router.push(context.url); /* router.onReady() 等到router將可能的非同步元件或非同步鉤子函式解析完成,在執行,就好比我們js中的 window.onload = function(){} 這樣的。 官網的解釋:該方法把一個回撥排隊,在路由完成初始導航時呼叫,這意味著它可以解析所有的非同步進入鉤子和 路由初始化相關聯的非同步元件。 這可以有效確保服務端渲染時服務端和客戶端輸出的一致。 */ router.onReady(() => { /* getMatchedComponents()方法的含義是: 返回目標位置或是當前路由匹配的元件陣列 (是陣列的定義/構造類,不是例項)。 通常在服務端渲染的資料預載入時使用。 有關 Router的實列方法含義可以看官網:https://router.vuejs.org/zh/api/#router-forward */ const matchedComponents = router.getMatchedComponents(); // 如果匹配不到路由的話,執行 reject函式,並且返回404 if (!matchedComponents.length) { return reject({ code: 404 }); } // 正常的情況 resolve(app); }, reject); }).catch(new Function()); }
src/entry-client.js
由於路由有可能是非同步元件或路由鉤子,因此在 src/entry-client.js 中掛載元素之前也需要 呼叫 router.onReady.因此程式碼需要改成如下所示:
import { createApp } from './app'; const { app, router } = createApp(); // App.vue 模板中根元素 id = 'app' router.onReady(() => { app.$mount('#app'); });
webpack 配置
如上基本的配置完成後,我們現在需要來配置webpack打包配置,這邊我們使用三個webpack的配置檔案,其中 webpack.base.config.js 是基本的配置檔案,該配置檔案主要是js的入口檔案和打包後的目錄檔案,及通用的rules。
webpack.client.config.js 是打包客戶端的vue檔案。webpack.server.config.js 是打包伺服器端的檔案。
因此webpack.base.config.js 基本配置程式碼如下:
const path = require('path') // vue-loader v15版本需要引入此外掛 const VueLoaderPlugin = require('vue-loader/lib/plugin') // 用於返回檔案相對於根目錄的絕對路徑 const resolve = dir => path.posix.join(__dirname, '..', dir) module.exports = { // 入口暫定客戶端入口,服務端配置需要更改它 entry: resolve('src/entry-client.js'), // 生成檔案路徑、名字、引入公共路徑 output: { path: resolve('dist'), filename: '[name].js', publicPath: '/' }, resolve: { // 對於.js、.vue引入不需要寫字尾 extensions: ['.js', '.vue'], // 引入components、assets可以簡寫,可根據需要自行更改 alias: { 'components': resolve('src/components'), 'assets': resolve('src/assets') } }, module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', options: { // 配置哪些引入路徑按照模組方式查詢 transformAssetUrls: { video: ['src', 'poster'], source: 'src', img: 'src', image: 'xlink:href' } } }, { test: /\.js$/, // 利用babel-loader編譯js,使用更高的特性,排除npm下載的.vue元件 loader: 'babel-loader', exclude: file => ( /node_modules/.test(file) && !/\.vue\.js/.test(file) ) }, { test: /\.(png|jpe?g|gif|svg)$/, // 處理圖片 use: [ { loader: 'url-loader', options: { limit: 10000, name: 'static/img/[name].[hash:7].[ext]' } } ] }, { test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, // 處理字型 loader: 'url-loader', options: { limit: 10000, name: 'static/fonts/[name].[hash:7].[ext]' } } ] }, plugins: [ new VueLoaderPlugin() ] }
然後我們再進行對 webpack.client.config.js 程式碼進行配置,該配置主要對客戶端程式碼進行打包,並且它通過 webpack-merge 外掛來對 webpack.base.config.js 程式碼配置進行合併。webpack.client.config.js 基本程式碼配置如下:
const path = require('path') const webpack = require('webpack') const merge = require('webpack-merge') const baseWebpackConfig = require('./webpack.base.config.js') // css樣式提取單獨檔案 const MiniCssExtractPlugin = require('mini-css-extract-plugin') // 服務端渲染用到的外掛、預設生成JSON檔案(vue-ssr-client-manifest.json) const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') module.exports = merge(baseWebpackConfig, { mode: 'production', output: { // chunkhash是根據內容生成的hash, 易於快取, // 開發環境不需要生成hash,目前先不考慮開發環境,後面詳細介紹 filename: 'static/js/[name].[chunkhash].js', chunkFilename: 'static/js/[id].[chunkhash].js' }, module: { rules: [ { test: /\.styl(us)?$/, // 利用mini-css-extract-plugin提取css, 開發環境也不是必須 use: [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader'] }, ] }, devtool: false, plugins: [ // webpack4.0版本以上採用MiniCssExtractPlugin 而不使用extract-text-webpack-plugin new MiniCssExtractPlugin({ filename: 'static/css/[name].[contenthash].css', chunkFilename: 'static/css/[name].[contenthash].css' }), // 當vendor模組不再改變時, 根據模組的相對路徑生成一個四位數的hash作為模組id new webpack.HashedModuleIdsPlugin(), new VueSSRClientPlugin() ] })
webpack配置完成後,我們需要在package.json定義命令來配置webpack打包命令,如下配置:
"scripts": { "build:client": "webpack --config ./build/webpack.client.config.js" },
如上配置完成後,我們在命令列中,執行 npm run build:client 命令即可進行打包,當命令執行打包完成後,我們會發現我們專案的根目錄中多了一個dist資料夾。除了一些css或js檔案外,我們還可以看到dist資料夾下多了一個 vue-ssr-client-manifest.json 檔案。它的作用是用於客戶端渲染的json檔案。它預設生成的檔名就叫這個名字。
如下所示:
如上,客戶端渲染的json檔案已經生成了,我們現在需要生成伺服器端渲染的檔案,因此我們現在需要編寫我們伺服器端的webpack.server.config.js 檔案。我們也想打包生成 vue-ssr-server-bundle.json. 伺服器端渲染的檔案預設也叫這個名字。因此配置程式碼需要編寫成如下:
const path = require('path'); const webpack = require('webpack'); const merge = require('webpack-merge'); const nodeExternals = require('webpack-node-externals'); const baseConfig = require('./webpack.base.config'); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); module.exports = merge(baseConfig, { entry: path.resolve(__dirname, '../src/entry-server.js'), /* 允許webpack以Node適用方式(Node-appropriate fashion)處理動態匯入(dynamic import), 編譯vue元件時,告知 vue-loader 輸送面向伺服器程式碼 */ target: 'node', devtool: 'source-map', // 此處告知 server bundle 使用 Node 風格匯出模組(Node-style exports) output: { libraryTarget: 'commonjs2', filename: '[name].server.js' }, /* 伺服器端也需要編譯樣式,不能使用 mini-css-extract-plugin 外掛 ,因為該外掛會使用document,但是伺服器端並沒有document, 因此會導致打包報錯,我們可以如下的issues: https://github.com/webpack-contrib/mini-css-extract-plugin/issues/48#issuecomment-375288454 */ module: { rules: [ { test: /\.styl(us)?$/, use: ['css-loader/locals', 'stylus-loader'] } ] }, // https://webpack.js.org/configuration/externals/#function // https://github.com/liady/webpack-node-externals // 外接化應用程式依賴模組。可以使伺服器構建速度更快, // 並生成較小的 bundle 檔案。 externals: nodeExternals({ // 不要外接化 webpack 需要處理的依賴模組。 // 你可以在這裡新增更多的檔案型別。例如,未處理 *.vue 原始檔案, // 你還應該將修改 `global`(例如 polyfill)的依賴模組列入白名單 whitelist: /\.css$/ }), // 這是將伺服器的整個輸出 // 構建為單個 JSON 檔案的外掛。 // 預設檔名為 `vue-ssr-server-bundle.json` plugins: [ new webpack.DefinePlugin({ 'process.env.VUE_ENV': '"server"' }), new VueSSRServerPlugin() ] });
然後我們需要在package.json 再加上伺服器端打包命令,因此scripts配置程式碼如下:
"scripts": { "build:server": "webpack --config ./build/webpack.server.config.js", "build:client": "webpack --config ./build/webpack.client.config.js" },
因此當我們再執行 npm run build:server 命令的時候,我們就可以在dist目錄下生成 渲染伺服器端的json檔案了,如下所示:
如上,兩個檔案通過打包生成完成後,我們現在可以來編寫 server.js 來實現整個伺服器端渲染的流程了。
我們在server.js 中需要引入我們剛剛打包完的客戶端的 vue-ssr-client-manifest.json 檔案 和 伺服器端渲染的vue-ssr-server-bundle.json 檔案,及 html模板 作為引數傳入 到 createBundleRenderer 函式中。因此server.js 程式碼改成如下:
const Vue = require('vue'); const Koa = require('koa'); const Router = require('koa-router'); const send = require('koa-send'); // 引入客戶端,服務端生成的json檔案, html 模板檔案 const serverBundle = require('./dist/vue-ssr-server-bundle.json'); const clientManifest = require('./dist/vue-ssr-client-manifest.json'); let renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, { runInNewContext: false, // 推薦 template: require('fs').readFileSync('./src/index.template.html', 'utf-8'), // 頁面模板 clientManifest // 客戶端構建 manifest }); // 1. 建立koa koa-router實列 const app = new Koa(); const router = new Router(); const render = async (ctx, next) => { ctx.set('Content-Type', 'text/html') const handleError = err => { if (err.code === 404) { ctx.status = 404 ctx.body = '404 Page Not Found' } else { ctx.status = 500 ctx.body = '500 Internal Server Error' console.error(`error during render : ${ctx.url}`) console.error(err.stack) } } const context = { url: ctx.url, title: 'vue伺服器渲染元件', meta: ` <meta charset="utf-8"> <meta name="" content="vue伺服器渲染元件"> ` } try { const html = await renderer.renderToString(context); ctx.status = 200 ctx.body = html; } catch(err) { handleError(err); } next(); } // 設定靜態資原始檔 router.get('/static/*', async(ctx, next) => { await send(ctx, ctx.path, { root: __dirname + '/./dist' }); }); router.get('*', render); // 載入路由元件 app .use(router.routes()) .use(router.allowedMethods()); // 啟動服務 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
因此我們需要在package.json 加上 dev 命令,如下所示:
"scripts": { "build:server": "webpack --config ./build/webpack.server.config.js", "build:client": "webpack --config ./build/webpack.client.config.js", "dev": "node server.js" }
然後我們在命令列控制檯中 執行 npm run dev 命令後,就可以啟動3000服務了。然後我們來訪問下 http://localhost:3000/home 頁面就可以看到頁面了。在檢視效果之前,我們還是要看看 home 和 item 路由頁面哦,如下:
src/components/home.vue 程式碼如下:
<template> <h1>home</h1> </template> <script> export default { name: "home", data(){ return{ } } } </script> <style scoped> </style>
src/components/item.vue 程式碼如下:
<template> <h1>item</h1> </template> <script> export default { name: "item", data(){ return{ } } } </script> <style scoped> </style>
然後我們訪問 http://localhost:3000/home 頁面的時候,如下所示:
當我們訪問 http://localhost:3000/item 頁面的時候,如下所示:
我們可以看到 我們的 src/App.vue 頁面如下:
<style lang="stylus"> h1 color red font-size 22px </style> <template> <div id="app"> <router-view></router-view> <h1>{{ msg }}</h1> <input type="text" v-model="msg" /> </div> </template> <script type="text/javascript"> export default { name: 'app', data() { return { msg: '歡迎光臨vue.js App' } } } </script>
src/index.template.html 模板頁面如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>{{ title }}</title> </head> <body> <div id="app"> <!--vue-ssr-outlet--> </div> </body> </html>
對比上面的圖可以看到,我們的App.vue 入口檔案的頁面內容會插入到我們的模板頁面 src/index.template.html 中的<!--vue-ssr-outlet--> 這個佔位符中去。然後對應的路由頁面就會插入到 src/App.vue 中的 <router-view> 這個位置上了。並且如上圖可以看到,我們的dist中的css,js資原始檔會動態的渲染到頁面上去。
github原始碼檢視(ssr-demo2)
回到頂部4.3 開發環境配置
我們如上程式碼是先改完vue程式碼後,先執行 npm run build:client 命令先打包客戶端的程式碼,然後執行 npm run build:server 命令打包伺服器端的程式碼,然後再就是 執行 npm run dev 命令啟動 node 服務,並且每次改完程式碼都要重複該操作,並且在開發環境裡面,這樣操作很煩很煩,因此我們現在需要弄一個開發環境,也就是說當我們修改了vue程式碼的時候,我們希望能自動打包客戶端和伺服器端程式碼,並且能重新進行 BundleRenderr.renderToString()方法。並且能重新啟動 server.js 程式碼中的服務。因此我們現在需要更改server.js程式碼:
首先我們來設定下是否是開發環境還是正式環境。因此在我們的package.json 打包配置程式碼變成如下:
"scripts": { "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js", "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js", "dev": "node server.js", "build": "npm run build:client && npm run build:server", "start": "cross-env NODE_ENV=production node server.js" }
我們在 start 命令 和 build命令中增加 cross-env NODE_ENV=production 這樣的配置程式碼,說明是正式環境下的。想要了解 webpack之process.env.NODE_ENV, 請看這篇文章。
然後當我們在命令打包中執行 npm run dev 後,就會打包開發環境,然後我們修改任何一個vue元件的話,或者 html檔案的話,它都會自動打包生成客戶端和伺服器端的json檔案,然後會進行自動編譯,打包完成後,我們只要重新整理下頁面即可生效。當我們執行npm run start 的時候,它就會在正式環境進行打包了,當我們執行 npm run build 後,它會重新進行打包客戶端和伺服器端的用於伺服器端渲染的json檔案的程式碼。
package.json配置完成後,我們現在需要在 src/server.js 伺服器端程式碼中區分下是 開發環境還是正式環境,現在 server.js 程式碼改成如下:
src/server.js 程式碼
const Vue = require('vue'); const Koa = require('koa'); const path = require('path'); const Router = require('koa-router'); const send = require('koa-send'); const { createBundleRenderer } = require('vue-server-renderer'); // 動態監聽檔案發生改變的配置檔案 const devConfig = require('./build/dev.config.js'); // 設定renderer為全域性變數,根據環境變數賦值 let renderer; // 1. 建立koa koa-router實列 const app = new Koa(); const router = new Router(); // 下面我們根據環境變數來生成不同的 BundleRenderer 實列 if (process.env.NODE_ENV === 'production') { // 正式環境 const template = require('fs').readFileSync('./src/index.template.html', 'utf-8'); // 引入客戶端,服務端生成的json檔案 const serverBundle = require('./dist/vue-ssr-server-bundle.json'); const clientManifest = require('./dist/vue-ssr-client-manifest.json'); renderer = createBundleRenderer(serverBundle, { runInNewContext: false, // 推薦 template: template, // 頁面模板 clientManifest // 客戶端構建 manifest }); // 設定靜態資原始檔 router.get('/static/*', async(ctx, next) => { await send(ctx, ctx.path, { root: __dirname + '/./dist' }); }); } else { // 開發環境 const template = path.resolve(__dirname, './src/index.template.html'); devConfig(app, template, (bundle, options) => { console.log('開發環境重新打包......'); const option = Object.assign({ runInNewContext: false // 推薦 }, options); renderer = createBundleRenderer(bundle, option); }); } const render = async (ctx, next) => { ctx.set('Content-Type', 'text/html'); const handleError = err => { if (err.code === 404) { ctx.status = 404 ctx.body = '404 Page Not Found' } else { ctx.status = 500 ctx.body = '500 Internal Server Error' console.error(`error during render : ${ctx.url}`) console.error(err.stack) } } const context = { url: ctx.url, title: 'vue伺服器渲染元件', meta: ` <meta charset="utf-8"> <meta name="" content="vue伺服器渲染元件"> ` } try { const html = await renderer.renderToString(context); ctx.status = 200 ctx.body = html; } catch(err) { handleError(err); } next(); } router.get('*', render); // 載入路由元件 app .use(router.routes()) .use(router.allowedMethods()); // 啟動服務 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
如上就是 server.js 程式碼,我們使用了 如程式碼:if (process.env.NODE_ENV === 'production') {} 來區分是正式環境還是開發環境,如果是正式環境的話,還是和之前一樣編寫程式碼,如下所示:
// 下面我們根據環境變數來生成不同的 BundleRenderer 實列 if (process.env.NODE_ENV === 'production') { // 正式環境 const template = require('fs').readFileSync('./src/index.template.html', 'utf-8'); // 引入客戶端,服務端生成的json檔案 const serverBundle = require('./dist/vue-ssr-server-bundle.json'); const clientManifest = require('./dist/vue-ssr-client-manifest.json'); renderer = createBundleRenderer(serverBundle, { runInNewContext: false, // 推薦 template: template, // 頁面模板 clientManifest // 客戶端構建 manifest }); // 設定靜態資原始檔 router.get('/static/*', async(ctx, next) => { await send(ctx, ctx.path, { root: __dirname + '/./dist' }); }); }
否則的話,就是開發環境,開發環境配置程式碼變成如下:
// 開發環境 // 動態監聽檔案發生改變的配置檔案 const devConfig = require('./build/dev.config.js'); const template = path.resolve(__dirname, './src/index.template.html'); devConfig(app, template, (bundle, options) => { console.log('開發環境重新打包......'); const option = Object.assign({ runInNewContext: false // 推薦 }, options); renderer = createBundleRenderer(bundle, option); });
因此在開發環境下,我們引入了一個 build/dev.config.js檔案。該檔案是針對開發環境而做的配置,它的作用是nodeAPI構建webpack配置,並且做到監聽檔案。我們可以通過在server.js中傳遞個回撥函式來做重新生成BundleRenderer例項的操作。而接受的引數就是倆個新生成的JSON檔案。因此 build/dev.config.js 程式碼配置如下:
build/dev.config.js 所有程式碼如下:
const fs = require('fs') const path = require('path') // memory-fs可以使webpack將檔案寫入到記憶體中,而不是寫入到磁碟。 const MFS = require('memory-fs') const webpack = require('webpack') // 監聽檔案變化,相容性更好(比fs.watch、fs.watchFile、fsevents) const chokidar = require('chokidar') const clientConfig = require('./webpack.client.config'); const serverConfig = require('./webpack.server.config'); // webpack熱載入需要 const webpackDevMiddleware = require('koa-webpack-dev-middleware') // 配合熱載入實現模組熱替換 const webpackHotMiddleware = require('koa-webpack-hot-middleware') // 讀取vue-ssr-webpack-plugin生成的檔案 const readFile = (fs, file) => { try { return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8'); } catch (e) { console.log('讀取檔案錯誤:', e); } } module.exports = function devConfig(app, templatePath, cb) { let bundle let template let clientManifest // 監聽改變後更新函式 const update = () => { if (bundle && clientManifest) { cb(bundle, { template, clientManifest }) } }; // 監聽html模板改變、需手動重新整理 template = fs.readFileSync(templatePath, 'utf-8'); chokidar.watch(templatePath).on('change', () => { template = fs.readFileSync(templatePath, 'utf-8'); update(); }); // 修改webpack入口配合模組熱替換使用 clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app] // 編譯clinetWebpack 插入Koa中介軟體 const clientCompiler = webpack(clientConfig) const devMiddleware = webpackDevMiddleware(clientCompiler, { publicPath: clientConfig.output.publicPath, noInfo: true }) app.use(devMiddleware) clientCompiler.plugin('done', stats => { stats = stats.toJson() stats.errors.forEach(err => console.error(err)) stats.warnings.forEach(err => console.warn(err)) if (stats.errors.length) return clientManifest = JSON.parse(readFile( devMiddleware.fileSystem, 'vue-ssr-client-manifest.json' )) update(); }) // 插入Koa中介軟體(模組熱替換) app.use(webpackHotMiddleware(clientCompiler)) const serverCompiler = webpack(serverConfig) const mfs = new MFS(); serverCompiler.outputFileSystem = mfs serverCompiler.watch({}, (err, stats) => { if (err) throw err stats = stats.toJson() if (stats.errors.length) return // vue-ssr-webpack-plugin 生成的bundle bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) update() }); }
如上配置程式碼用到了 koa-webpack-dev-middleware 該外掛,該外掛的作用是:通過傳入webpack編譯好的compiler實現熱載入,也就是說可以監聽檔案的變化,從而進行重新整理網頁。koa-webpack-hot-middleware 該外掛的作用是:實現模組熱替換操作,熱模組替換在該基礎上做到不需要重新整理頁面。因此通過該兩個外掛,當我們就可以做到監聽檔案的變化,並且檔案變化後不會自動重新整理頁面,但是當檔案編譯完成後,我們需要手動重新整理頁面,內容才會得到更新。
在build/webpack.base.config.js 和 build/webpack.client.config.js 中需要判斷是否是開發環境和正式環境的配置:
build/webpack.base.config.js 配置程式碼如下:
// 是否是生產環境 const isProd = process.env.NODE_ENV === 'production'; module.exports = { // 判斷是開發環境還是正式環境 devtool: isProd ? false : 'cheap-module-eval-source-map', }
如上 開發環境devtool我們可以使用cheap-module-eval-source-map編譯會更快,css樣式沒有必要打包單獨檔案。使用vue-style-loader做處理就好,並且因為開發環境需要模組熱過載,所以不提取檔案是必要的。開發環境可以做更友好的錯誤提示。
build/webpack.client.config.js 配置程式碼如下:
// 是否是生產環境 const isProd = process.env.NODE_ENV === 'production'; module.exports = merge(baseWebpackConfig, { mode: process.env.NODE_ENV || 'development', module: { rules: [ { test: /\.styl(us)?$/, // 利用mini-css-extract-plugin提取css, 開發環境也不是必須 // use: [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader'] // 開發環境不需要提取css單獨檔案 use: isProd ? [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader'] : ['vue-style-loader', 'css-loader', 'stylus-loader'] }, ] }, });
當我們在node命令中 執行npm run dev 後,我們修改任何一個vue檔案後,然後命令會重新進行打包,如下所示:
如上就是我們所有處理開發環境和正式環境的配置程式碼。
github原始碼檢視(ssr-demo3)
回到頂部4.4 資料預獲取和狀態
1. 資料預取儲存容器
官網介紹請看這裡
在伺服器端渲染(SSR)期間,比如說我們的應用程式有非同步請求,在伺服器端渲染之前,我們希望先返回非同步資料後,我們再進行SSR渲染,因此我們需要的是先預取和解析好這些資料。
並且在客戶端,在掛載(mount)到客戶端應用程式之前,需要獲取到與伺服器端應用程式完全相同的資料。否則的話,客戶端應用程式會因為使用與伺服器端應用程式不同的狀態。會導致混合失敗。
因此為了解決上面的兩個問題,我們需要把專門的資料放置到預取儲存容器或狀態容器中,因此store就這樣產生了。我們可以把資料放在全域性變數state中。並且,我們將在html中序列化和內聯預置狀態,這樣,在掛載到客戶端應用程式之前,可以直接從store獲取到內聯預置狀態。
因此我們需要在我們專案 src/store 中新建 store資料夾。因此我們專案的目錄架構就變成如下這個樣子了。如下所示:
|----- ssr-demo4 | |--- build | | |--- webpack.base.conf.js # webpack 基本配置 | | |--- webpack.client.conf.js # 客戶端打包配置 | | |--- webpack.server.conf.js # 伺服器端打包配置 | |--- src | | |--- assets # 存放css,圖片的目錄資料夾 | | |--- components # 存放所有的vue頁面,當然我們這邊也可以新建資料夾分模組 | | | |--- home.vue | | | |--- item.vue | | |--- app.js # 建立每一個實列檔案 | | |--- App.vue | | |--- entry-client.js # 掛載客戶端應用程式 | | |--- entry-server.js # 掛載伺服器端應用程式 | | |--- index.template.html # 頁面模板html檔案 | | |--- router.js # 所有的路由 | | |--- store # 存放所有的全域性狀態 | | | |-- index.js | | |--- api | | | |-- index.js | |--- .babelrc # 支援es6 | |--- .gitignore # 排除github上的一些檔案 | |--- server.js # 啟動服務程式 | |--- package.json # 所有的依賴包
如上目錄架構,我們新增了兩個目錄,一個是 src/store 另一個是 src/api.
我們按照官網步驟來編寫程式碼,我們在 src/store/index.js 檔案裡面編寫一些程式碼來模擬一些資料。比如如下程式碼:
import Vue from 'vue'; import Vuex from 'vuex'; Vue.use(vuex); // 假定我們有一個可以返回 Promise 的 import { fetchItem } from '../api/index'; export function createStore() { return new Vuex.Store({ state: { items: {} }, actions: { fetchItem({ commit }, id) { // `store.dispatch()` 會返回 Promise, // 以便我們能夠知道資料在何時更新 return fetchItem(id).then(item => { commit('setItem', { id, item }); }); } }, mutations: { setItem(state, { id, item }) { Vue.set(state.items, id, item); } } }); }
src/api/index.js 程式碼假如是如下這個樣子:
export function fetchItem(id) { return Promise.resolve({ text: 'kongzhi' }) }
然後我們的 src/app.js 程式碼需要更新成如下這個樣子:
import Vue from 'vue'; import App from './App.vue'; // 引入 router import { createRouter } from './router'; // 引入store import { createStore } from './store/index'; import { sync } from 'vuex-router-sync'; // 匯出函式,用於建立新的應用程式 export function createApp () { // 建立 router的實列 const router = createRouter(); // 建立 store 的實列 const store = createStore(); // 同步路由狀態 (route state) 到 store sync(store, router); const app = new Vue({ // 注入 router 到 根 vue實列中 router, store, // 根實列簡單的渲染應用程式元件 render: h => h(App) }); // 暴露 app, router, store return { app, router, store }; }
如上配置完成後,我們需要在什麼地方使用 dispatch來觸發action程式碼呢?
按照官網說的,我們需要通過訪問路由,來決定獲取哪部分資料,這也決定了哪些元件需要被渲染。因此我們在元件 Item.vue 路由元件上暴露了一個自定義靜態函式 asyncData.
注意:asyncData函式會在元件例項化之前被呼叫。因此不能使用this,需要將store和路由資訊作為引數傳遞進去。
因此 src/components/item.vue 程式碼變成如下:
<template> <h1>{{item.title}}</h1> </template> <script> export default { asyncData ({ store, route }) { // 觸發action程式碼,會返回 Promise return store.dispatch('fetchItem', route.params.id); }, computed: { // 從 store 的 state物件中獲取item item() { return this.$store.state.items[this.$route.params.id] } } } </script>
2. 伺服器端資料預取
伺服器端預取的原理是:在 entry-server.js中,我們可以通過路由獲得與 router.getMatchedComponents() 相匹配的元件,該方法是獲取到所有的元件,然後我們遍歷該所有匹配到的元件。如果元件暴露出 asyncData 的話,我們就呼叫該方法。並將我們的state掛載到context上下文中。vue-server-renderer 會將state序列化 window.__INITAL_STATE__. 這樣,entry-client.js客戶端就可以替換state,實現同步。
因此我們的 src/entry-server.js 程式碼改成如下:
import { createApp } from './app'; export default context => { /* const { app } = createApp(); return app; */ /* 由於 路由鉤子函式或元件 有可能是非同步的,比如 同步的路由是這樣引入 import Foo from './Foo.vue' 但是非同步的路由是這樣引入的: { path: '/index', component: resolve => require(['./views/index'], resolve) } 如上是 require動態載入進來的,因此我們這邊需要返回一個promise物件。以便伺服器能夠等待所有的內容在渲染前 就已經準備好就緒。 */ return new Promise((resolve, reject) => { const { app, router, store } = createApp(); // 設定伺服器端 router的位置 router.push(context.url); /* router.onReady() 等到router將可能的非同步元件或非同步鉤子函式解析完成,在執行,就好比我們js中的 window.onload = function(){} 這樣的。 官網的解釋:該方法把一個回撥排隊,在路由完成初始導航時呼叫,這意味著它可以解析所有的非同步進入鉤子和 路由初始化相關聯的非同步元件。 這可以有效確保服務端渲染時服務端和客戶端輸出的一致。 */ router.onReady(() => { /* getMatchedComponents()方法的含義是: 返回目標位置或是當前路由匹配的元件陣列 (是陣列的定義/構造類,不是例項)。 通常在服務端渲染的資料預載入時使用。 有關 Router的實列方法含義可以看官網:https://router.vuejs.org/zh/api/#router-forward */ const matchedComponents = router.getMatchedComponents(); // 如果匹配不到路由的話,執行 reject函式,並且返回404 if (!matchedComponents.length) { return reject({ code: 404 }); } // 對所有匹配的路由元件 呼叫 'asyncData()' Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute }); } })).then(() => { // 在所有預取鉤子(preFetch hook) resolve 後, // 我們的 store 現在已經填充入渲染應用程式所需的狀態。 // 當我們將狀態附加到上下文, // 並且 `template` 選項用於 renderer 時, // 狀態將自動序列化為 `window.__INITIAL_STATE__`,並注入 HTML。 context.state = store.state resolve(app); }).catch(reject) // 正常的情況 // resolve(app); }, reject); }).catch(new Function()); }
如上官網程式碼,當我們使用 template 時,context.state 將作為 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中。而在客戶端,在掛載到應用程式之前,store 就應該獲取到狀態:
因此我們的 entry-client.js 程式碼先變成這樣。如下所示:
import { createApp } from './app'; const { app, router, store } = createApp(); if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } // App.vue 模板中根元素 id = 'app' router.onReady(() => { app.$mount('#app'); });
3. 客戶端資料預取
在客戶端,處理資料預取有2種方式:分別是:在路由導航之前解析資料 和 匹配要渲染的檢視後,再獲取資料。
1. 在路由導航之前解析資料 (根據官網介紹)
在這種方式下,應用程式會在所需要的資料全部解析完成後,再傳入資料並處理當前的檢視。它的優點是:可以直接在資料準備就緒時,傳入資料到檢視渲染完整的內容。但是如果資料預取需要很長時間的話,那麼使用者在當前檢視會感受到 "明顯示卡頓"。因此,如果我們使用這種方式預取資料的話,我們可以使用一個菊花載入icon,等所有資料預取完成後,再把該菊花消失掉。
為了實現這種方式,我們可以通過檢查匹配的元件,並且在全域性路由鉤子函式中執行 asyncData 函式,來在客戶端實現此策略。
因此我們的 src/entry-client.js 程式碼更新變成如下:
import { createApp } from './app'; const { app, router, store } = createApp(); if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } router.onReady(() => { // 新增路由鉤子,用於處理 asyncData // 在初始路由 resolve 後執行 // 以便我們不會二次預取已有的資料 // 使用 router.beforeResolve(), 確保所有的非同步元件都 resolve router.beforeResolve((to, from, next) => { const matched = router.getMatchedComponents(to); const prevMatched = router.getMatchedComponents(from); // 我們只關心非預渲染的元件 // 所有我們需要對比他們,找出兩個品牌列表的差異元件 let diffed = false const activated = matched.filter((c, i) => { return diffed || (diffed = (prevMatched[i] !== c)) }) if (!