SSR你們確定都不陌生,經過服務端渲染,能夠優化SEO抓取,提高首頁加載速度等,我在學習SSR的時候,看過不少文章,有些對我有很大的啓發做用,有些就只是照搬官網文檔。經過幾天的學習,我對SSR有了一些瞭解,也從頭開始完整的配置出了SSR的開發環境,因此想經過這篇文章,總結一些經驗,同時但願可以對學習SSR的朋友起到一點幫助。javascript
我會經過五個步驟,一步步帶你完成SSR的配置:css
Ajax
初始化數據Ajax
初始化數據serverBundle
和clientManifest
進行優化Vue + VueRouter + Vuex
的SSR工程若是你如今對於我上面說的還不太瞭解,沒有關係,跟着我一步步向下走,最終你也能夠獨立配置一個SSR開發項目,全部源碼我會放到github上,你們能夠做爲參考。html
這個配置相信你們都會,就是基於weback + vue
的一個常規開發配置,這裏我會放一些關鍵代碼,完整代碼能夠去github查看。前端
- node_modules - components - Bar.vue - Foo.vue - App.vue - app.js - index.html - webpack.config.js - package.json - yarn.lock - postcss.config.js - .babelrc - .gitignore
import Vue from 'vue'; import App from './App.vue'; let app = new Vue({ el: '#app', render: h => h(App) });
<template> <div> <Foo></Foo> <Bar></Bar> </div> </template> <script> import Foo from './components/Foo.vue'; import Bar from './components/Bar.vue'; export default { components: { Foo, Bar } } </script>
<!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> </head> <body> <div id="app"></div> </body> </html>
<template> <div class="foo"> <h1>Foo Component</h1> </div> </template> <style> .foo { background: yellowgreen; } </style>
<template> <div class="bar"> <h1>Bar Component</h1> </div> </template> <style> .bar { background: bisque; } </style>
const path = require('path'); const VueLoaderPlugin = require('vue-loader/lib/plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); module.exports = { mode: 'development', entry: './app.js', output: { path: path.resolve(__dirname, 'dist'), filename: 'bundle.js' }, module: { rules: [ { test: /\.js$/, use: 'babel-loader' }, { test: /\.css$/, use: ['vue-style-loader', 'css-loader', 'postcss-loader'] // 若是須要單獨抽出CSS文件,用下面這個配置 // use: ExtractTextPlugin.extract({ // fallback: 'vue-style-loader', // use: [ // 'css-loader', // 'postcss-loader' // ] // }) }, { test: /\.(jpg|jpeg|png|gif|svg)$/, use: { loader: 'url-loader', options: { limit: 10000 // 10Kb } } }, { test: /\.vue$/, use: 'vue-loader' } ] }, plugins: [ new VueLoaderPlugin(), new HtmlWebpackPlugin({ template: './index.html' }), // 若是須要單獨抽出CSS文件,用下面這個配置 // new ExtractTextPlugin("styles.css") ] };
module.exports = { plugins: [ require('autoprefixer') ] };
{ "presets": [ "@babel/preset-env" ], "plugins": [ // 讓其支持動態路由的寫法 const Foo = () => import('../components/Foo.vue') "dynamic-import-webpack" ] }
{ "name": "01", "version": "1.0.0", "main": "index.js", "license": "MIT", "scripts": { "start": "yarn run dev", "dev": "webpack-dev-server", "build": "webpack" }, "dependencies": { "vue": "^2.5.17" }, "devDependencies": { "@babel/core": "^7.1.2", "@babel/preset-env": "^7.1.0", "babel-plugin-dynamic-import-webpack": "^1.1.0", "autoprefixer": "^9.1.5", "babel-loader": "^8.0.4", "css-loader": "^1.0.0", "extract-text-webpack-plugin": "^4.0.0-beta.0", "file-loader": "^2.0.0", "html-webpack-plugin": "^3.2.0", "postcss": "^7.0.5", "postcss-loader": "^3.0.0", "url-loader": "^1.1.1", "vue-loader": "^15.4.2", "vue-style-loader": "^4.1.2", "vue-template-compiler": "^2.5.17", "webpack": "^4.20.2", "webpack-cli": "^3.1.2", "webpack-dev-server": "^3.1.9" } }
yarn start
yarn run build
最終效果截圖:vue
完整代碼查看githubjava
Ajax
初始化數據服務端渲染SSR,相似於同構,最終要讓一份代碼既能夠在服務端運行,也能夠在客戶端運行。若是說在SSR的過程當中出現問題,還能夠回滾到純瀏覽器渲染,保證用戶正常看到頁面。node
那麼,順着這個思路,確定就會有兩個webpack
的入口文件,一個用於瀏覽器端渲染weboack.client.config.js
,一個用於服務端渲染webpack.server.config.js
,將它們的公有部分抽出來做爲webpack.base.cofig.js
,後續經過webpack-merge
進行合併。同時,也要有一個server
來提供http
服務,我這裏用的是koa
。webpack
咱們來看一下新的目錄結構:git
- node_modules - config // 新增 - webpack.base.config.js - webpack.client.config.js - webpack.server.config.js - src - components - Bar.vue - Foo.vue - App.vue - app.js - entry-client.js // 新增 - entry-server.js // 新增 - index.html - index.ssr.html // 新增 - package.json - yarn.lock - postcss.config.js - .babelrc - .gitignore
在純客戶端應用程序(client-only app)中,每一個用戶會在他們各自的瀏覽器中使用新的應用程序實例。對於服務器端渲染,咱們也但願如此:每一個請求應該都是全新的、獨立的應用程序實例,以便不會有交叉請求形成的狀態污染(cross-request state pollution)。github
因此,咱們要對app.js
作修改,將其包裝爲一個工廠函數,每次調用都會生成一個全新的根組件。
app.js
import Vue from 'vue'; import App from './App.vue'; export function createApp() { const app = new Vue({ render: h => h(App) }); return { app }; }
在瀏覽器端,咱們直接新建一個根組件,而後將其掛載就能夠了。
entry-client.js
import { createApp } from './app.js'; const { app } = createApp(); app.$mount('#app');
在服務器端,咱們就要返回一個函數,該函數的做用是接收一個context
參數,同時每次都返回一個新的根組件。這個context
在這裏咱們還不會用到,後續的步驟會用到它。
entry-server.js
import { createApp } from './app.js'; export default context => { const { app } = createApp(); return app; }
而後再來看一下index.ssr.html
index.ssr.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> </head> <body> <!--vue-ssr-outlet--> <script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script> </body> </html>
<!--vue-ssr-outlet-->
的做用是做爲一個佔位符,後續經過vue-server-renderer
插件,將服務器解析出的組件html
字符串插入到這裏。
<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
是爲了將webpack
經過webpack.client.config.js
打包出的文件放到這裏(這裏是爲了簡單演示,後續會有別的辦法來作這個事情)。
由於服務端吐出來的就是一個html
字符串,後續的Vue
相關的響應式、事件響應等等,都須要瀏覽器端來接管,因此就須要將爲瀏覽器端渲染打包的文件在這裏引入。
用官方的詞來講,叫客戶端激活(client-side hydration)。
所謂客戶端激活,指的是 Vue 在瀏覽器端接管由服務端發送的靜態 HTML,使其變爲由 Vue 管理的動態 DOM 的過程。
在 entry-client.js 中,咱們用下面這行掛載(mount)應用程序:
// 這裏假定 App.vue template 根元素的 `id="app"` app.$mount('#app')
因爲服務器已經渲染好了 HTML,咱們顯然無需將其丟棄再從新建立全部的 DOM 元素。相反,咱們須要"激活"這些靜態的 HTML,而後使他們成爲動態的(可以響應後續的數據變化)。
若是你檢查服務器渲染的輸出結果,你會注意到應用程序的根元素上添加了一個特殊的屬性:
<div id="app" data-server-rendered="true">
Vue
在瀏覽器端就依靠這個屬性將服務器吐出來的html
進行激活,咱們一會本身構建一下就能夠看到了。
接下來咱們看一下webpack
相關的配置:
webpack.base.config.js
const path = require('path'); const VueLoaderPlugin = require('vue-loader/lib/plugin'); module.exports = { mode: 'development', resolve: { extensions: ['.js', '.vue'] }, output: { path: path.resolve(__dirname, '../dist'), filename: '[name].bundle.js' }, module: { rules: [ { test: /\.vue$/, use: 'vue-loader' }, { test: /\.js$/, use: 'babel-loader' }, { test: /\.css$/, use: ['vue-style-loader', 'css-loader', 'postcss-loader'] }, { test: /\.(jpg|jpeg|png|gif|svg)$/, use: { loader: 'url-loader', options: { limit: 10000 // 10Kb } } } ] }, plugins: [ new VueLoaderPlugin() ] };
webpack.client.config.js
const path = require('path'); const merge = require('webpack-merge'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const base = require('./webpack.base.config'); module.exports = merge(base, { entry: { client: path.resolve(__dirname, '../src/entry-client.js') }, plugins: [ new HtmlWebpackPlugin({ template: path.resolve(__dirname, '../src/index.html'), filename: 'index.html' }) ] });
注意,這裏的入口文件變成了entry-client.js
,將其打包出的client.bundle.js
插入到index.html
中。
webpack.server.config.js
const path = require('path'); const merge = require('webpack-merge'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const base = require('./webpack.base.config'); module.exports = merge(base, { target: 'node', entry: { server: path.resolve(__dirname, '../src/entry-server.js') }, output: { libraryTarget: 'commonjs2' }, plugins: [ new HtmlWebpackPlugin({ template: path.resolve(__dirname, '../src/index.ssr.html'), filename: 'index.ssr.html', files: { js: 'client.bundle.js' }, excludeChunks: ['server'] }) ] });
這裏有幾個點須要注意一下:
entry-server.js
target
要設爲node
,同時,output
的libraryTarget
要設爲commonjs2
這裏關於HtmlWebpackPlugin
配置的意思是,不要在index.ssr.html
中引入打包出的server.bundle.js
,要引爲瀏覽器打包的client.bundle.js
,緣由前面說過了,是爲了讓Vue
能夠將服務器吐出來的html
進行激活,從而接管後續響應。
那麼打包出的server.bundle.js
在哪用呢?接着往下看就知道啦~~
package.json
{ "name": "01", "version": "1.0.0", "main": "index.js", "license": "MIT", "scripts": { "start": "yarn run dev", "dev": "webpack-dev-server", "build:client": "webpack --config config/webpack.client.config.js", "build:server": "webpack --config config/webpack.server.config.js" }, "dependencies": { "koa": "^2.5.3", "koa-router": "^7.4.0", "koa-static": "^5.0.0", "vue": "^2.5.17", "vue-server-renderer": "^2.5.17" }, "devDependencies": { "@babel/core": "^7.1.2", "@babel/preset-env": "^7.1.0", "autoprefixer": "^9.1.5", "babel-loader": "^8.0.4", "css-loader": "^1.0.0", "extract-text-webpack-plugin": "^4.0.0-beta.0", "file-loader": "^2.0.0", "html-webpack-plugin": "^3.2.0", "postcss": "^7.0.5", "postcss-loader": "^3.0.0", "style-loader": "^0.23.0", "url-loader": "^1.1.1", "vue-loader": "^15.4.2", "vue-style-loader": "^4.1.2", "vue-template-compiler": "^2.5.17", "webpack": "^4.20.2", "webpack-cli": "^3.1.2", "webpack-dev-server": "^3.1.9", "webpack-merge": "^4.1.4" } }
接下來咱們看server
端關於http
服務的代碼:
server/server.js
const Koa = require('koa'); const Router = require('koa-router'); const serve = require('koa-static'); const path = require('path'); const fs = require('fs'); const backendApp = new Koa(); const frontendApp = new Koa(); const backendRouter = new Router(); const frontendRouter = new Router(); const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8'); const renderer = require('vue-server-renderer').createBundleRenderer(bundle, { template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8') }); // 後端Server backendRouter.get('/index', (ctx, next) => { // 這裏用 renderToString 的 promise 返回的 html 有問題,沒有樣式 renderer.renderToString((err, html) => { if (err) { console.error(err); ctx.status = 500; ctx.body = '服務器內部錯誤'; } else { console.log(html); ctx.status = 200; ctx.body = html; } }); }); backendApp.use(serve(path.resolve(__dirname, '../dist'))); backendApp .use(backendRouter.routes()) .use(backendRouter.allowedMethods()); backendApp.listen(3000, () => { console.log('服務器端渲染地址: http://localhost:3000'); }); // 前端Server frontendRouter.get('/index', (ctx, next) => { let html = fs.readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf-8'); ctx.type = 'html'; ctx.status = 200; ctx.body = html; }); frontendApp.use(serve(path.resolve(__dirname, '../dist'))); frontendApp .use(frontendRouter.routes()) .use(frontendRouter.allowedMethods()); frontendApp.listen(3001, () => { console.log('瀏覽器端渲染地址: http://localhost:3001'); });
這裏對兩個端口進行監聽,3000端口是服務端渲染,3001端口是直接輸出index.html
,而後會在瀏覽器端走Vue
的那一套,主要是爲了和服務端渲染作對比使用。
這裏的關鍵代碼是如何在服務端去輸出html
`字符串。
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8'); const renderer = require('vue-server-renderer').createBundleRenderer(bundle, { template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8') });
能夠看到,server.bundle.js
在這裏被使用啦,由於它的入口是一個函數,接收context
做爲參數(非必傳),輸出一個根組件app
。
這裏咱們用到了vue-server-renderer
插件,它有兩個方法能夠作渲染,一個是createRenderer
,另外一個是createBundleRenderer
。
const { createRenderer } = require('vue-server-renderer') const renderer = createRenderer({ /* 選項 */ })
const { createBundleRenderer } = require('vue-server-renderer') const renderer = createBundleRenderer(serverBundle, { /* 選項 */ })
createRenderer
沒法接收爲服務端打包出的server.bundle.js
文件,因此這裏只能用createBundleRenderer
。
serverBundle
參數能夠是如下之一:
bundle
文件(.js
或 .json
)。必須以 /
開頭纔會被識別爲文件路徑。webpack + vue-server-renderer/server-plugin
生成的 bundle
對象。JavaScript
代碼字符串(不推薦)。這裏咱們引入的是.js文件,後續會介紹如何使用.json文件以及有什麼好處。
renderer.renderToString((err, html) => { if (err) { console.error(err); ctx.status = 500; ctx.body = '服務器內部錯誤'; } else { console.log(html); ctx.status = 200; ctx.body = html; } });
使用createRenderer
和createBundleRenderer
返回的renderer
函數包含兩個方法renderToString
和renderToStream
,咱們這裏用的是renderToString
成功後直接返回一個完整的字符串,renderToStream
返回的是一個Node
流。
renderToString
支持Promise
,可是我在使用Prmoise
形式的時候樣式會渲染不出來,暫時還不知道緣由,若是你們知道的話能夠給我留言啊。
配置基本就完成了,來看一下如何運行。
yarn run build:client // 打包瀏覽器端須要bundle yarn run build:server // 打包SSR須要bundle yarn start // 其實就是 node server/server.js,提供http服務
最終效果展現:
訪問http://localhost:3000/index
咱們看到了前面提過的data-server-rendered="true"
屬性,同時會加載client.bundle.js
文件,爲了讓Vue
在瀏覽器端作後續接管。
訪問http://localhost:3001/index
還和第一步實現的效果同樣,純瀏覽器渲染,這裏就不放截圖了。
完整代碼查看github
若是SSR須要初始化一些異步數據,那麼流程就會變得複雜一些。
咱們先提出幾個問題:
帶着問題咱們向下走,但願看完這篇文章的時候上面的問題你都找到了答案。
服務器端渲染和瀏覽器端渲染組件通過的生命週期是有區別的,在服務器端,只會經歷beforeCreate
和created
兩個生命週期。由於SSR服務器直接吐出html
字符串就行了,不會渲染DOM結構,因此不存在beforeMount
和mounted
的,也不會對其進行更新,因此也就不存在beforeUpdate
和updated
等。
咱們先來想一下,在純瀏覽器渲染的Vue
項目中,咱們是怎麼獲取異步數據並渲染到組件中的?通常是在created
或者mounted
生命週期裏發起異步請求,而後在成功回調裏執行this.data = xxx
,Vue
監聽到數據發生改變,走後面的Dom Diff
,打patch
,作DOM
更新。
那麼服務端渲染可不能夠也這麼作呢?答案是不行的。
mounted
裏確定不行,由於SSR
都沒有mounted
生命週期,因此在這裏確定不行。beforeCreate
裏發起異步請求是否能夠呢,也是不行的。由於請求是異步的,可能尚未等接口返回,服務端就已經把html
字符串拼接出來了。因此,參考一下官方文檔,咱們能夠獲得如下思路:
Vuex
的store
中。Vuex
將獲取到的數據注入到相應組件中。store
中的數據設置到window.__INITIAL_STATE__
屬性中。Vuex
將window.__INITIAL_STATE__
裏面的數據注入到相應組件中。正常狀況下,經過這幾個步驟,服務端吐出來的html
字符串相應組件的數據都是最新的,因此第4步並不會引發DOM
更新,但若是出了某些問題,吐出來的html
字符串沒有相應數據,Vue
也能夠在瀏覽器端經過`Vuex
注入數據,進行DOM
更新。
更新後的目錄結構:
- node_modules - config - webpack.base.config.js - webpack.client.config.js - webpack.server.config.js - src - components - Bar.vue - Foo.vue - store // 新增 store.js - App.vue - app.js - entry-client.js - entry-server.js - index.html - index.ssr.html - package.json - yarn.lock - postcss.config.js - .babelrc - .gitignore
先來看一下store.js
:
store/store.js
import Vue from 'vue'; import Vuex from 'vuex'; Vue.use(Vuex); const fetchBar = function() { return new Promise((resolve, reject) => { setTimeout(() => { resolve('bar 組件返回 ajax 數據'); }, 1000); }); }; function createStore() { const store = new Vuex.Store({ state: { bar: '' }, mutations: { 'SET_BAR'(state, data) { state.bar = data; } }, actions: { fetchBar({ commit }) { return fetchBar().then((data) => { commit('SET_BAR', data); }).catch((err) => { console.error(err); }) } } }); if (typeof window !== 'undefined' && window.__INITIAL_STATE__) { console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__); store.replaceState(window.__INITIAL_STATE__); } return store; } export default createStore; typeof window
若是不太瞭解Vuex
,能夠去Vuex官網先看一些基本概念。
這裏fetchBar
能夠當作是一個異步請求,這裏用setTimeout
模擬。在成功回調中commit
相應的mutation
進行狀態修改。
這裏有一段關鍵代碼:
if (typeof window !== 'undefined' && window.__INITIAL_STATE__) { console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__); store.replaceState(window.__INITIAL_STATE__); }
由於store.js
一樣也會被打包到服務器運行的server.bundle.js
中,因此運行環境不必定是瀏覽器,這裏須要對window
作判斷,防止報錯,同時若是有window.__INITIAL_STATE__
屬性,說明服務器已經把全部初始化須要的異步數據都獲取完成了,要對store
中的狀態作一個替換,保證統一。
components/Bar.vue
<template> <div class="bar"> <h1 @click="onHandleClick">Bar Component</h1> <h2>異步Ajax數據:</h2> <span>{{ msg }}</span> </div> </template> <script> const fetchInitialData = ({ store }) => { store.dispatch('fetchBar'); }; export default { asyncData: fetchInitialData, methods: { onHandleClick() { alert('bar'); } }, mounted() { // 由於服務端渲染只有 beforeCreate 和 created 兩個生命週期,不會走這裏 // 因此把調用 Ajax 初始化數據也寫在這裏,是爲了供單獨瀏覽器渲染使用 let store = this.$store; fetchInitialData({ store }); }, computed: { msg() { return this.$store.state.bar; } } } </script> <style> .bar { background: bisque; } </style>
這裏在Bar
組件的默認導出對象中增長了一個方法asyncData
,在該方法中會dispatch
相應的action
,進行異步數據獲取。
須要注意的是,我在mounted
中也寫了獲取數據的代碼,這是爲何呢? 由於想要作到同構,代碼單獨在瀏覽器端運行,也應該是沒有問題的,又因爲服務器沒有mounted
生命週期,因此我寫在這裏就能夠解決單獨在瀏覽器環境使用也能夠發起一樣的異步請求去初始化數據。
components/Foo.vue
<template> <div class="foo"> <h1 @click="onHandleClick">Foo Component</h1> </div> </template> <script> export default { methods: { onHandleClick() { alert('foo'); } }, } </script> <style> .foo { background: yellowgreen; } </style>
這裏我對兩個組件都添加了一個點擊事件,爲的是證實在服務器吐出首頁html
後,後續的步驟都會被瀏覽器端的Vue
接管,能夠正常執行後面的操做。
app.js
import Vue from 'vue'; import createStore from './store/store.js'; import App from './App.vue'; export function createApp() { const store = createStore(); const app = new Vue({ store, render: h => h(App) }); return { app, store, App }; }
在創建根組件的時候,要把Vuex的store
傳進去,同時要返回,後續會用到。
最後來看一下entry-server.js
,關鍵步驟在這裏:
entry-server.js
import { createApp } from './app.js'; export default context => { return new Promise((resolve, reject) => { const { app, store, App } = createApp(); let components = App.components; let asyncDataPromiseFns = []; Object.values(components).forEach(component => { if (component.asyncData) { asyncDataPromiseFns.push(component.asyncData({ store })); } }); Promise.all(asyncDataPromiseFns).then((result) => { // 當使用 template 時,context.state 將做爲 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中 context.state = store.state; console.log(222); console.log(store.state); console.log(context.state); console.log(context); resolve(app); }, reject); }); }
咱們經過導出的App
拿到了全部它下面的components
,而後遍歷,找出哪些component
有asyncData
方法,有的話調用並傳入store
,該方法會返回一個Promise
,咱們使用Promise.all
等全部的異步方法都成功返回,才resolve(app)
。
context.state = store.state
做用是,當使用createBundleRenderer
時,若是設置了template
選項,那麼會把context.state
的值做爲window.__INITIAL_STATE__
自動插入到模板html
中。
這裏須要你們多思考一下,弄清楚整個服務端渲染的邏輯。
如何運行:
yarn run build:client yarn run build:server yarn start
最終效果截圖:
服務端渲染:打開http://localhost:3000/index
能夠看到window.__INITIAL_STATE__
被自動插入了。
咱們來對比一下SSR
到底對加載性能有什麼影響吧。
服務端渲染時performance
截圖:
純瀏覽器端渲染時performance
截圖:
一樣都是在fast 3G
網絡模式下,純瀏覽器端渲染首屏加載花費時間2.9s,由於client.js
加載就花費了2.27s,由於沒有client.js
就沒有Vue
,也就沒有後面的東西了。
服務端渲染首屏時間花費0.8s,雖然client.js
加載扔花費2.27s
,可是首屏已經不須要它了,它是爲了讓Vue
在瀏覽器端進行後續接管。
從這咱們能夠真正的看到,服務端渲染對於提高首屏的響應速度是頗有做用的。
固然有的同窗可能會問,在服務端渲染獲取初始ajax
數據時,咱們還延時了1s,在這個時間用戶也是看不到頁面的。沒錯,接口的時間咱們沒法避免,就算是純瀏覽器渲染,首頁該調接口仍是得調,若是接口響應慢,那麼純瀏覽器渲染看到完整頁面的時間會更慢。
完整代碼查看github
前面咱們建立服務端renderer
的方法是:
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8'); const renderer = require('vue-server-renderer').createBundleRenderer(bundle, { template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8') });
serverBundle
咱們用的是打包出的server.bundle.js
文件。這樣作的話,在每次編輯過應用程序源代碼以後,都必須中止並重啓服務。這在開發過程當中會影響開發效率。此外,Node.js 自己不支持 source map。
vue-server-renderer
提供一個名爲 createBundleRenderer
的 API,用於處理此問題,經過使用 webpack
的自定義插件,server bundle
將生成爲可傳遞到 bundle renderer
的特殊 JSON
文件。所建立的 bundle renderer
,用法和普通 renderer
相同,可是 bundle renderer
提供如下優勢:
source map
支持(在 webpack
配置中使用 devtool: 'source-map'
)bundle
,而後從新建立 renderer
實例)CSS(critical CSS)
注入(在使用 *.vue
文件時):自動內聯在渲染過程當中用到的組件所需的CSS
。更多細節請查看 CSS
章節。clientManifest
進行資源注入:自動推斷出最佳的預加載(preload
)和預取(prefetch
)指令,以及初始渲染所需的代碼分割 chunk
。preload
和prefetch
有不瞭解的話能夠自行查一下它們的做用哈。
那麼咱們來修改webpack
配置:
webpack.client.config.js
const path = require('path'); const merge = require('webpack-merge'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const VueSSRClientPlugin = require('vue-server-renderer/client-plugin'); const base = require('./webpack.base.config'); module.exports = merge(base, { entry: { client: path.resolve(__dirname, '../src/entry-client.js') }, plugins: [ new VueSSRClientPlugin(), // 新增 new HtmlWebpackPlugin({ template: path.resolve(__dirname, '../src/index.html'), filename: 'index.html' }) ] });
webpack.server.config.js
const path = require('path'); const merge = require('webpack-merge'); const nodeExternals = require('webpack-node-externals'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); const base = require('./webpack.base.config'); module.exports = merge(base, { target: 'node', // 對 bundle renderer 提供 source map 支持 devtool: '#source-map', entry: { server: path.resolve(__dirname, '../src/entry-server.js') }, externals: [nodeExternals()], // 新增 output: { libraryTarget: 'commonjs2' }, plugins: [ new VueSSRServerPlugin(), // 這個要放到第一個寫,不然 CopyWebpackPlugin 不起做用,緣由還沒查清楚 new HtmlWebpackPlugin({ template: path.resolve(__dirname, '../src/index.ssr.html'), filename: 'index.ssr.html', files: { js: 'client.bundle.js' }, excludeChunks: ['server'] }) ] });
由於是服務端引用模塊,因此不須要打包node_modules
中的依賴,直接在代碼中require
引用就好,因此配置externals: [nodeExternals()]
。
兩個配置文件會分別生成vue-ssr-client-manifest.json
和vue-ssr-server-bundle.json
。做爲createBundleRenderer
的參數。
來看server.js
server.js
const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json')); const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json')); const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8'); const renderer = createBundleRenderer(serverBundle, { runInNewContext: false, template: template, clientManifest: clientManifest });
效果和第三步就是同樣的啦,就不截圖了,完整代碼查看github。
這裏和第四步不同的是引入了vue-router
,更接近於實際開發項目。
在src
下新增router
目錄。
router/index.js
import Vue from 'vue'; import Router from 'vue-router'; import Bar from '../components/Bar.vue'; Vue.use(Router); function createRouter() { const routes = [ { path: '/bar', component: Bar }, { path: '/foo', component: () => import('../components/Foo.vue') // 異步路由 } ]; const router = new Router({ mode: 'history', routes }); return router; } export default createRouter;
這裏咱們把Foo
組件做爲一個異步組件引入,作成按需加載。
在app.js
中引入router
,並導出:
app.js
import Vue from 'vue'; import createStore from './store/store.js'; import createRouter from './router'; import App from './App.vue'; export function createApp() { const store = createStore(); const router = createRouter(); const app = new Vue({ router, store, render: h => h(App) }); return { app, store, router, App }; }
修改App.vue
引入路由組件:
App.vue
<template> <div id="app"> <router-link to="/bar">Goto Bar</router-link> <router-link to="/foo">Goto Foo</router-link> <router-view></router-view> </div> </template> <script> export default { beforeCreate() { console.log('App.vue beforeCreate'); }, created() { console.log('App.vue created'); }, beforeMount() { console.log('App.vue beforeMount'); }, mounted() { console.log('App.vue mounted'); } } </script>
最重要的修改在entry-server.js
中,
entry-server.js
import { createApp } from './app.js'; export default context => { return new Promise((resolve, reject) => { const { app, store, router, App } = createApp(); router.push(context.url); router.onReady(() => { const matchedComponents = router.getMatchedComponents(); console.log(context.url) console.log(matchedComponents) if (!matchedComponents.length) { return reject({ code: 404 }); } Promise.all(matchedComponents.map(component => { if (component.asyncData) { return component.asyncData({ store }); } })).then(() => { // 當使用 template 時,context.state 將做爲 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中 context.state = store.state; // 返回根組件 resolve(app); }); }, reject); }); }
這裏前面提到的context
就起了大做用,它將用戶訪問的url
地址傳進來,供vue-router
使用。由於有異步組件,因此在router.onReady
的成功回調中,去找該url
路由所匹配到的組件,獲取異步數據那一套還和前面的同樣。
因而,咱們就完成了一個基本完整的基於Vue + VueRouter + Vuex
SSR配置,完成代碼查看github。
最終效果演示:
訪問http://localhost:3000/bar
:
完整代碼查看github
上面咱們經過五個步驟,完成了從純瀏覽器渲染到完整服務端渲染的同構,代碼既能夠運行在瀏覽器端,也能夠運行在服務器端。那麼,回過頭來咱們在看一下是否有優化的空間,又或者有哪些擴展的思考。
renderToString
方法,徹底生成html
後,纔會向客戶端返回,若是使用renderToStream
,應用bigpipe
技術能夠向瀏覽器持續不斷的返回一個流,那麼文件的加載瀏覽器能夠儘早的顯示一些東西出來。const stream = renderer.renderToStream(context)
返回的值是 Node.js stream
:
let html = '' stream.on('data', data => { html += data.toString() }) stream.on('end', () => { console.log(html) // 渲染完成 }) stream.on('error', err => { // handle error... })
在流式渲染模式下,當 renderer
遍歷虛擬 DOM
樹(virtual DOM tree
)時,會盡快發送數據。這意味着咱們能夠儘快得到"第一個 chunk
",並開始更快地將其發送給客戶端。
然而,當第一個數據 chunk
被髮出時,子組件甚至可能不被實例化,它們的生命週期鉤子也不會被調用。這意味着,若是子組件須要在其生命週期鉤子函數中,將數據附加到渲染上下文(render context
),當流(stream
)啓動時,這些數據將不可用。這是由於,大量上下文信息(context information
)(如頭信息(head information
)或內聯關鍵 CSS(inline critical CSS))
須要在應用程序標記(markup
)以前出現,咱們基本上必須等待流(stream
)完成後,才能開始使用這些上下文數據。
所以,若是你依賴由組件生命週期鉤子函數填充的上下文數據,則不建議使用流式傳輸模式。
webpack
優化webpack
優化又是一個大的話題了,這裏不展開討論,感興趣的同窗能夠自行查找一些資料,後續我也可能會專門寫一篇文章來說webpack
優化。
vuex
?答案是不用。Vuex
只是爲了幫助你實現一套數據存儲、更新、獲取的機制,入股你不用Vuex
,那麼你就必須本身想一套方案能夠將異步獲取到的數據存起來,而且在適當的時機將它注入到組件內,有一些文章提出了一些方案,我會放到參考文章裏,你們能夠閱讀一下。
SSR
就必定好?這個也是不必定的,任何技術都有使用場景。SSR
能夠幫助你提高首頁加載速度,優化搜索引擎SEO
,但同時因爲它須要在node
中渲染整套Vue
的模板,會佔用服務器負載,同時只會執行beforeCreate
和created
兩個生命週期,對於一些外部擴展庫須要作必定處理才能夠在SSR
中運行等等。
本文經過五個步驟,從純瀏覽器端渲染開始,到配置一個完整的基於Vue + vue-router + Vuex
的SSR環境,介紹了不少新的概念,也許你看完一遍不太理解,那麼結合着源碼,去本身手敲幾遍,而後再來看幾遍文章,相信你必定能夠掌握SSR
。
最後,本文全部源代碼都放在個人github上,若是對你有幫助的話,就來點一個贊吧~~