閱讀目錄javascript
一:什麼是服務器端渲染?什麼是客戶端渲染?他們的優缺點?css
1. 服務器端渲染及客戶端渲染。html
在互聯網早期,前端頁面都是一些簡單的頁面,那麼前端頁面都是後端將html拼接好,而後將它返回給前端完整的html文件。瀏覽器拿到這個html文件以後就能夠直接顯示了,這就是咱們所謂的服務器端渲染。好比典型的 java + velocity。node + jade 進行html模板拼接及渲染。velocity語法在後端編寫完成後,後端會從新編譯後,將一些vm頁面的變量編譯成真正值的時候,把html頁面返回給瀏覽器,瀏覽器就能直接解析和顯示出來了。這種模式就是服務器端渲染。而隨着前端頁面複雜性愈來愈高,前端就不只僅是頁面展示了,還有可能須要添加更多複雜功能的組件。及2005年先後,ajax興起,就逐漸出現前端這個行業,先後端分離就變得愈來愈重要。所以這個時候後端它就不提供完整的html頁面,而是提供一些api接口, 返回一些json數據,咱們前端拿到該json數據以後再使用html對數據進行拼接,而後展示在瀏覽器上。
那麼這種方式就是客戶端渲染了,所以這樣咱們前端就不須要去編寫velocity語法,前端能夠專一於UI的開發。後端專一於邏輯的開發。前端
2. 服務器端渲染和客戶端渲染的區別?vue
服務器端渲染和客戶端的渲染的本質區別是誰來渲染html頁面,若是html頁面在服務器端那邊拼接完成後,那麼它就是服務器端渲染,而若是是前端作的html拼接及渲染的話,那麼它就屬於客戶端渲染的。java
3. 服務器端渲染的優勢和缺點?node
優勢:
1. 有利於SEO搜索引擎,後端直接返回html文件,爬蟲能夠獲取到信息。
2. 前端耗時少,首屏性能更好,所以頁面是服務器端輸出的,前端不須要經過ajax去動態加載。
3. 不須要佔用客戶端的資源,由於解析html模板的工做是交給服務器端完成的,客戶端只須要解析標準的html頁面便可。這樣客戶端佔用的資源會變少。
4. 後端生成靜態文件,即生成緩存片斷,這樣就能夠減小數據庫查詢的時間。react
缺點:
1. 不利於先後端分離,開發效率比較低。好比咱們前端須要編寫 velocity語法,若是對該語法不熟悉的話,還須要去學習下,而且編寫完成後,還須要調用後端的變量,把變量輸出到html對應位置上,編寫完成後,要在html模板中加入一些資源文件路徑,全部工做完成後,把html模板交給後端,後端再對該模板進行服務器端編譯操做。那麼等之後維護的時候,咱們前端須要在某塊html中插入其餘的東西,因爲以前編寫的頁面沒有對應的標識,好比id等,那麼咱們如今又須要去修改vm模板頁面等等這樣的事情。也就是說工做效率很是低。維護不方便。webpack
4. 客戶端渲染的優勢和缺點?ios
優勢:
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,同樣會看到以下信息:以下所示:
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資源文件會動態的渲染到頁面上去。
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文件後,而後命令會從新進行打包,以下所示:
如上就是咱們全部處理開發環境和正式環境的配置代碼。
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 (!activated.length) { return next() } // 這裏若是有加載指示器 (loading indicator),就觸發 Promise.all(activated.map(c => { if (c.asyncData) { return c.asyncData({ store, route: to }) } })).then(() => { // 中止加載指示器(loading indicator) next() }).catch(next) }); app.$mount('#app') });
2. 匹配渲染的視圖後,再獲取數據。
根據官網介紹:該方式是將客戶端數據預取,放在視圖組件的 beforeMount 函數中。當路由導航被觸發時,咱們能夠當即切換視圖,所以應用程序具備更快的響應速度。可是,傳入視圖在渲染時不會有完整的可用數據。所以,對於使用此策略的每一個視圖組件,都須要具備條件的加載狀態。所以這能夠經過純客戶端的全局mixin來實現,所以 src/entry-client.js 代碼更新成以下所示:
import { createApp } from './app'; import Vue from 'vue'; Vue.mixin({ beforeRouteUpdate (to, from, next) { const { asyncData } = this.$options; if (asyncData) { asyncData({ store: this.$store, route: to }).then(next).catch(next) } else { next(); } } }) 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 (!activated.length) { return next() } // 這裏若是有加載指示器 (loading indicator),就觸發 Promise.all(activated.map(c => { if (c.asyncData) { return c.asyncData({ store, route: to }) } })).then(() => { // 中止加載指示器(loading indicator) next() }).catch(next) }); app.$mount('#app') });
在上面全部配置完成後,咱們再來看看 item.vue 代碼改爲以下來簡單測試下,以下代碼所示:
<template> <div>item頁 請求數據結果:{{ item.name.text }}</div> </template> <script> export default { name: "item", asyncData ({ store, route }) { // 觸發action代碼,會返回 Promise return store.dispatch('fetchItem', 'name'); }, computed: { // 從 store 的 state 對象中的獲取 item。 item () { console.log(this.$store.state); return this.$store.state.items; } } } </script> <style scoped> </style>
而後咱們訪問 http://localhost:3000/item 就能夠看到 數據能從 store中獲取到了。以下所示:
如上咱們能夠看到 console.log(this.$store.state); 會打印兩個對象,一個是items, 另外一個是 route。
頁面渲染出的html代碼以下:
4.5 頁面注入不一樣的Head
官方文檔(https://ssr.vuejs.org/zh/guide/head.html)
在如上服務器端渲染的時候,咱們會根據不一樣的頁面會有不一樣的meta或title。所以咱們須要注入不一樣的Head內容, 咱們按照官方
文檔來實現一個簡單的title注入。如何作呢?
1. 咱們須要在咱們的template模塊中定義 <title>{{ title }}</title>, 它的基本原理和數據預取是相似的。
所以咱們項目中的 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>
注意:
1. 使用雙花括號(double-mustache)進行 HTML 轉義插值(HTML-escaped interpolation),以免 XSS 攻擊。
2. 應該在建立 context 對象時提供一個默認標題,以防在渲染過程當中組件沒有設置標題。
咱們按照官網來作下demo,所以咱們須要在 src/mixins 下 新建 title-mixins.js,所以咱們項目的結構目錄變成以下:
|----- ssr-demo5 | |--- 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 | | |---- mixins | | | |--- title-mixins.js # 管理title | |--- .babelrc # 支持es6 | |--- .gitignore # 排除github上的一些文件 | |--- server.js # 啓動服務程序 | |--- package.json # 全部的依賴包
src/mixins/title-mixins.js 代碼以下:
function getTitle (vm) { // 組件能夠提供一個 `title` 選項 // 此選項能夠是一個字符串或函數 const { title } = vm.$options; if (title) { return typeof title === 'function' ? title.call(vm) : title; } else { return 'Vue SSR Demo'; } } const serverTitleMixin = { created () { const title = getTitle(this); if (title && this.$ssrContext) { this.$ssrContext.title = title; } } }; const clientTitleMixin = { mounted () { const title = getTitle(this); if (title) { document.title = title; } } }; // 咱們能夠經過 'webpack.DefinePlugin' 注入 'VUE_ENV' export default process.env.VUE_ENV === 'server' ? serverTitleMixin : clientTitleMixin;
build/webpack.server.config.js 配置代碼以下:
plugins: [ // 定義全局變量 new webpack.DefinePlugin({ 'process.env.VUE_ENV': '"server"' }) ]
src/components/item.vue 代碼改爲以下:
<template> <div>item頁 請求數據結果:{{ item.name.text }}</div> </template> <script> import titleMixin from '../mixins/title-mixins.js'; export default { name: "item", mixins: [titleMixin], title() { return 'item頁面'; }, asyncData ({ store, route }) { // 觸發action代碼,會返回 Promise return store.dispatch('fetchItem', 'name'); }, computed: { // 從 store 的 state 對象中的獲取 item。 item () { console.log(this.$store.state); return this.$store.state.items; } } } </script> <style scoped> </style>
而後咱們從新打包,訪問:http://localhost:3000/item 能夠看到以下頁面:
src/components/home.vue 代碼改爲以下:
<template> <h1>home222</h1> </template> <script> import titleMixin from '../mixins/title-mixins.js'; export default { name: "home", mixins: [titleMixin], title() { return 'Home頁面'; }, data(){ return{ } } } </script> <style scoped> </style>
而後咱們訪問 http://localhost:3000/home 的時候,能夠看到以下頁面:
4.6 頁面級別的緩存
緩存相關的,能夠看官網這裏
緩存(官網介紹):雖然vue的服務器端渲染很是快,可是因爲建立組件實列和虛擬DOM節點的開銷,沒法與純基於字符串拼接
的模板性能至關。所以咱們須要使用緩存策略,能夠極大的提升響應時間且能減小服務器的負載。
1. 頁面級別緩存
緩存,咱們可使用 micro-caching的緩存策略,來大幅提升應用程序處理高流量的能力。通常狀況下須要在nginx服務器配置完成的,可是在這邊咱們能夠在Node.js中實現。
所以咱們這邊須要在 server.js 添加官方網站代碼,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 LRU = require('lru-cache'); const { createBundleRenderer } = require('vue-server-renderer'); // 動態監聽文件發生改變的配置文件 const devConfig = require('./build/dev.config.js'); // 緩存 const microCache = new LRU({ max: 100, maxAge: 1000 * 60 // 在1分鐘後過時 }); const isCacheable = ctx => { // 假如 item 頁面進行緩存 if (ctx.url === '/item') { return true; } return false; }; // 設置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服務器渲染組件"> ` } // 判斷是否可緩存,可緩存,且緩存中有的話,直接把緩存中返回 const cacheable = isCacheable(ctx); if (cacheable) { const hit = microCache.get(ctx.url); if (hit) { console.log('從緩存中取', hit); return ctx.body = hit; } } try { const html = await renderer.renderToString(context); ctx.body = html; if (cacheable) { console.log('設置緩存:', ctx.url); microCache.set(ctx.url, html); } } catch(err) { console.log(err); handleError(err); } next(); } router.get('*', render); // 加載路由組件 app .use(router.routes()) .use(router.allowedMethods()); // 啓動服務 app.listen(3000, () => { console.log(`server started at localhost:3000`); });
咱們運行代碼,進入 http://localhost:3000/item 頁面刷新,查看命令行,能夠看到,第一次進入 item頁面提示設置了緩存,1分鐘內不管咱們怎麼刷新頁面,都是拿到緩存的數據。以下所示:
組件級別的緩存也能夠查看官網的demo
頁面級別的緩存能夠查看github(ssr-demo6)