webpack4從零開始構建(一)
webpack4+React16項目構建(二)
webpack4功能配置劃分細化(三)
webpack4引入Ant Design和Typescript(四)
webpack4代碼去重,簡化信息和構建優化(五)
webpack4配置Vue版腳手架(六)javascript
服務器渲染 --- Vue+Koa從零搭建成功輸出頁面
服務器渲染 --- 數據預取和狀態
本文最終代碼倉庫在Vue-ssr-demo/demo1css
yarn add --dev vue-server-renderer koa Vue
這些是實現服務器渲染的關鍵庫,先安裝,而後建立一個server.js
建立Vue實例並輸出步驟:html
Koa實例
,接收請求返回數據Vue實例
vue-server-renderer
建立一個 Renderer 實例, 將 Vue 實例渲染爲字符串插入HtmlHtml
返回const Koa = require('koa') const Vue = require('Vue') const renderer = require('vue-server-renderer').createRenderer() // 建立Koa實例 const app = new Koa() app.use(async ctx => { // 建立Vue實例 const app = new Vue({ template: `<div>SSR_DEMO</div>` }) // 將 Vue 實例渲染爲字符串, 回調函數第一個參數是可能拋出的錯誤,第二個參數是渲染完畢的字符串. renderer.renderToString(app, (err, html) => { // 發生錯誤輸出500 if (err) { ctx.throw(500, 'Internal Server Error') return } // 響應返回html格式 ctx.body = (` <!DOCTYPE html> <html lang="en"> <head><title>demo</title></head> <body>${html}</body> </html> `) }) }).listen(3000); console.log('已創建鏈接,效果請看http://127.0.0.1:3000/');
保存以後,打開終端運行文件vue
node server.js // 已創建鏈接,效果請看http://127.0.0.1:3000/
打開瀏覽器訪問地址,輸出SSR_DEMO
文字咱們就算完成第一步了java
簡單搭建一個Vue+Webpack4的demo,大體目錄以下node
裏面東西不少,咱們不用一下都看完,先慢慢補起來,webpack4的基本配置就不說了,只說關鍵位置webpack
自定義的模塊簡化路徑git
const path = require("path"); // 建立 import 或 require 的別名,來確保模塊引入變得更簡單 module.exports = { "@": path.resolve(__dirname, "../src/"), IMG: path.resolve(__dirname, "../src/img"), ROUTER: path.resolve(__dirname, "../src/router"), VUEX: path.resolve(__dirname, "../src/vuex"), PAGE: path.resolve(__dirname, "../src/page"), CMT: path.resolve(__dirname, "../src/component"), };
渲染基本界面導航切換驗證github
<template> <div id="app"> <h2>歡迎來到SSR渲染頁面</h2> <router-link to="/view1">view1</router-link> <router-link to="/view2">view2</router-link> <router-view></router-view> </div> </template> <script> export default {}; </script>
因爲沒有動態更新,全部的生命週期鉤子函數中,只有 beforeCreate
和 created
會在服務器端渲染 (SSR) 過程當中被調用.這就是說任何其餘生命週期鉤子函數中的代碼,只會在客戶端執行.你應該避免在 beforeCreate
和 created
生命週期時產生全局反作用的代碼,例如定時器,由於沒法在beforeDestroy
或 destroyed
清除.web
下面的輸出用於測試
<template> <div> <p>Page1</p> </div> </template> <script> export default { created() { console.log('created') }, mounted() { console.log('mounted') }, }; </script>
避免使用特定平臺的 API,例如window
或 document
,這種僅瀏覽器可用的全局變量,則會在 Node.js 中執行時拋出錯誤,反之也是如此, 官方推薦方案:
<template> <div> <p>Page2</p> </div> </template> <script> export default { created() { try { console.log(window); } catch (err) { console.log(err); } }, }; </script>
使用history
模式方便服務器渲染.
路由作惰性加載,有助於減小瀏覽器在初始渲染中下載的資源體積
由於客戶端和服務端要共用同一份路由配置,因此不要直接導出實例,而是導出一個建立函數
import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) export default function createRouter () { return new Router({ // 要記得增長mode屬性,由於#後面的內容不會發送至服務器,服務器不知道請求的是哪個路由 mode: 'history', routes: [ { // 首頁 alias: '/', path: '/view1', component: () => import('../page/view1.vue') }, { path: '/view2', component: () => import('../page/view2.vue') }, { path: '*', redirect: '/view1' } ] }) }
服務端針對每一個請求都應該建立一個全新獨立的Vue實例,由於它們須要在服務器裏預先請求對應的數據,這樣能夠避免狀態污染
// app.js import Vue from 'vue' import App from './App.vue' import createRouter from './router' export default function createApp () { // 建立 router 實例 const router = createRouter() const app = new Vue({ // 注入 router 到根 Vue 實例 router, render: (h) => h(App) }) // 返回 app 和 router return { app, router } }
由於新版的HtmlWebpackPlugin
不支持html變量編譯,須要轉成ejs
模板,而後直接輸出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><%= htmlWebpackPlugin.options.title %></title> </head> <body> <div id="app"></div> </body> </html>
服務器渲染頁面模板,注意 <!--vue-ssr-outlet-->
註釋, 這裏將是應用程序 HTML 標記注入的地方,很重要!!!
裏面引入的變量htmlWebpackPlugin.options.files.js
後面再詳解
<!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><%= htmlWebpackPlugin.options.title %></title> </head> <body> <!--vue-ssr-outlet--> <script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script> </body> </html>
官方圖例
Server Bundle
和Client Bundle
上面說的混合靜態標記,由於服務器已經預先渲染好靜態HTMl給到客戶端,即Vue在瀏覽器接管由服務端發送的靜態HTML,使其變成由Vue管理的動態DOM過程.
客戶端會直接掛載到根元素
// 這裏假定 App.vue template 根元素的 `id="app"` app.$mount('#app')
而從服務端獲取到的HTML裏能夠看到該根元素多了特殊屬性
<div id="app" data-server-rendered="true">
這屬性是讓客戶端知道這部分HTML是有服務器渲染無需再執行,而是應該以激活模式進行掛載.
在沒有該屬性的狀況下也還能夠向 $mount
函數的 hydrating
參數位置傳入 true
,來強制使用激活模式(hydration):
// 強制使用應用程序的激活模式 app.$mount('#app', true)
在開發模式下,Vue 將推斷客戶端生成的虛擬 DOM 樹 (virtual DOM tree),是否與從服務器渲染的 DOM 結構 (DOM structure) 匹配。若是沒法匹配,它將退出混合模式,丟棄現有的 DOM 並從頭開始渲染。在生產模式下,此檢測會被跳過,以免性能損耗。
切記: 瀏覽器可能會更改的一些特殊的 HTML 結構
客戶端的入口文件只需建立應用程序,而且將其掛載到 DOM 中, 在路由完成初始導航時調用,這意味着它能夠解析全部的異步進入鉤子和路由初始化相關聯的異步組件,這能夠有效確保服務端渲染時服務端和客戶端輸出的一致.
import createApp from '../src/app' const { app, router } = createApp() // 路由完成初始導航時調用 router.onReady(() => { // 掛載App.vue模板中根元素 app.$mount('#app') })
服務器的入口文件作了如下幾個步驟:
getMatchedComponents
返回當前路由匹配的組件數組import createApp from '../src/app' export default (context) => { // 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise, // 以便服務器可以等待全部的內容在渲染前, // 就已經準備就緒. return new Promise((resolve, reject) => { const { app, router } = createApp() // 設置服務器端 router 的位置 router.push(context.url) // 等到 router 將可能的異步組件和鉤子函數解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents() // 匹配不到的路由,執行 reject 函數,並返回 404 if (!matchedComponents.length) { return reject({ code: 404 }) } // Promise 應該 resolve 應用程序實例,以便它能夠渲染 resolve(app) }, reject) }) }
經過使用 webpack 的自定義插件,server bundle將生成爲可傳遞到 bundle renderer 的特殊 JSON 文件,它相比直接打包成js有如下優點:
主要是設置client
和server
的全部關鍵配置了
const path = require('path') const isDev = process.env.NODE_ENV === 'DEV' const isProd = true || process.env.NODE_ENV === 'PROD' const isServer = process.env.NODE_ENV === 'SERVER' const client = { entry: { client: path.resolve(__dirname, '../entry/entry-client.js') }, output: { // 打包文件名 filename: 'bundle.client.js', // 輸出路徑 path: path.resolve(__dirname, '../dist/client'), // 資源請求路徑 publicPath: '/' }, htmlPluginOpt: { title: "瀏覽器渲染", // 本地模板文件的位置 template: path.resolve(__dirname, '../ejs/client.ejs'), // 輸出文件的文件名稱 filename: 'client.html' } } const server = { entry: { server: path.resolve(__dirname, '../entry/entry-server.js') }, output: { // 打包文件名 filename: 'bundle.server.js', // 輸出路徑 path: path.resolve(__dirname, '../dist/server'), // 資源請求路徑 publicPath: '/', // 導出的是 module.exports.default libraryTarget: 'commonjs2' }, htmlPluginOpt: { title: "服務端渲染", // 本地模板文件的位置 template: path.resolve(__dirname, '../ejs/server.ejs'), // 輸出文件的文件名稱 filename: 'server.html', // webpack的stats對象的assetsByChunkName屬性表明的值 files: { js: 'bundle.client.js' }, // 不容許注入 excludeChunks: ['server'] } } const title = 'test' module.exports = { isDev, isProd, isServer, client, server, title }
須要注意的是server.htmlPluginOpt
的配置,它控制模板禁止注入自己的chunk
,而後手動注入客戶端的bundle
,
客戶端執行入口,忽略一些webpack的配置,最終生成客戶端構建清單vue-ssr-client-manifest.json
文件
const path = require('path') const HtmlWebpackPlugin = require('html-webpack-plugin') const merge = require('webpack-merge') const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const common = require('./webpack.common.js') const dev_conf = require('./webpack.dev.js') const { client } = require('./env') module.exports = merge(common, dev_conf, { // 入口 entry: client.entry, // 輸出 output: client.output, plugins: [ // 生成客戶端構建清單 (client build manifest) // 默認文件名爲 `vue-ssr-client-manifest.json` new VueSSRClientPlugin(), new HtmlWebpackPlugin(client.htmlPluginOpt) ] })
服務端執行入口,跟客戶端相比有幾個不一樣
node
CommonJS
環境webpack-node-externals'
,將須要打包的模塊加入白名單vue-ssr-server-bundle.json
文件CommonsChunkPlugin
const path = require('path') const HtmlWebpackPlugin = require('html-webpack-plugin') const merge = require('webpack-merge') const nodeExternals = require('webpack-node-externals') const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') const common = require('./webpack.common.js') const dev_conf = require('./webpack.dev.js') const { server } = require('./env') module.exports = merge(common, dev_conf, { // 入口 entry: server.entry, // 輸出 output: server.output, // 對 bundle renderer 提供 source map 支持 devtool: 'source-map', // 這容許 webpack 以 Node 適用方式(Node-appropriate fashion)處理動態導入(dynamic import), // 而且還會在編譯 Vue 組件時, // 告知 `vue-loader` 輸送面向服務器代碼(server-oriented code)。 target: 'node', externals: nodeExternals({ // 不要外置化 webpack 須要處理的依賴模塊。 // 你能夠在這裏添加更多的文件類型。例如,未處理 *.vue 原始文件, // 你還應該將修改 `global`(例如 polyfill)的依賴模塊列入白名單 whitelist: /\.css$/ }), // 這是將服務器的整個輸出 // 構建爲單個 JSON 文件的插件。 // 默認文件名爲 `vue-ssr-server-bundle.json` plugins: [ new VueSSRServerPlugin(), new HtmlWebpackPlugin(server.htmlPluginOpt) ] })
在package.json
裏咱們配置幾個簡單的命令
"scripts": { "client": "cross-env NODE_ENV=PROD webpack --config ./config/webpack-client.js", "server": "cross-env NODE_ENV=PROD webpack --config ./config/webpack-server.js", "build": "yarn client && yarn server", "start": "node server", "rnm": "rimraf node_modules" },
運行命令,生成dist/client/vue-ssr-client-manifest.json
和dist/server/vue-ssr-server-bundle.json
yarn build
服務器官方教程選擇Express
,可是我以爲過重了,換成同個團隊開發的Koa
createBundleRenderer
建立一個 BundleRenderer 實例const path = require('path') const Router = require('koa-router') const router = new Router() const { createBundleRenderer } = require('vue-server-renderer') const { client, server } = require('../config/env') // 服務器 bundle const serverBundle = require(`${server.output.path}/vue-ssr-server-bundle.json`); // 客戶端清單, 自動推斷和注入資源預加載 / 數據預取指令(preload / prefetch directive),以及 css 連接 / script 標籤到所渲染的 HTML const clientManifest = require(`${client.output.path}/vue-ssr-client-manifest.json`); const template = require('fs').readFileSync(path.resolve(__dirname, '../dist/server/ssr.html'), 'utf-8'); const renderer = createBundleRenderer(serverBundle, { runInNewContext: false, // 推薦, 默認狀況下,對於每次渲染,bundle renderer 將建立一個新的 V8 上下文並從新執行整個 bundle template, clientManifest, // (可選)客戶端構建 manifest }); class Server { static async createHtml (ctx, next) { // 將 Vue 實例渲染爲字符串, 回調函數第一個參數是可能拋出的錯誤,第二個參數是渲染完畢的字符串。 try { const html = await renderer.renderToStream({ url: ctx.request.url }) ctx.status = 200 ctx.type = 'html' ctx.body = html } catch (err) { console.log('err: ', err) ctx.throw(500, 'Internal Server Error') } } } router.get('*', Server.createHtml) module.exports = router
const path = require('path') const Koa = require('koa') const logger = require('koa-logger') const serve = require('koa-static') const router = require('./router') // 建立Koa實例 const app = new Koa() app .use(logger()) .use(serve(path.resolve(__dirname, '../dist/client'))) .use(router.routes()) .use(router.allowedMethods()) .listen(3005) console.log('已創建鏈接,效果請看http://127.0.0.1:3005/')
運行文件啓動服務器便可查看效果
yarn start