隨着各大前端框架的誕生和演變,SPA
開始流行,單頁面應用的優點在於能夠不從新加載整個頁面的狀況下,經過ajax
和服務器通訊,實現整個Web
應用拒不更新,帶來了極致的用戶體驗。然而,對於須要SEO
、追求極致的首屏性能的應用,前端渲染的SPA
是糟糕的。好在Vue 2.0
後是支持服務端渲染的,零零散散花費了兩三週事件,經過改造現有項目,基本完成了在現有項目中實踐了Vue
服務端渲染。javascript
關於Vue服務端渲染的原理、搭建,官方文檔已經講的比較詳細了,所以,本文不是抄襲文檔,而是文檔的補充。特別是對於如何與現有項目進行很好的結合,仍是須要費很大功夫的。本文主要對我所在的項目中進行Vue
服務端渲染的改造過程進行闡述,加上一些我的的理解,做爲分享與學習。html
本文主要分如下幾個方面:前端
Koa
的Web Server Frame
上配置服務端渲染?
Webpack
配置vue-router
分割代碼;
Vue.js
是構建客戶端應用程序的框架。默認狀況下,能夠在瀏覽器中輸出Vue
組件,進行生成DOM
和操做DOM
。然而,也能夠將同一個組件渲染爲服務器端的HTML
字符串,將它們直接發送到瀏覽器,最後將這些靜態標記"激活"爲客戶端上徹底可交互的應用程序。vue
上面這段話是源自Vue服務端渲染文檔的解釋,用通俗的話來講,大概能夠這麼理解:java
HTML
字符串,客戶端接收到對應的HTML
字符串,能當即渲染DOM
,最高效的首屏耗時。此外,因爲服務端直接生成了對應的HTML
字符串,對SEO
也很是友好;Vue
及對應庫運行在服務端,此時,Web Server Frame
其實是做爲代理服務器去訪問接口服務器來預拉取數據,從而將拉取到的數據做爲Vue
組件的初始狀態。DOM
。在Web Server Frame
做爲代理服務器去訪問接口服務器來預拉取數據後,這是服務端初始化組件須要用到的數據,此後,組件的beforeCreate
和created
生命週期會在服務端調用,初始化對應的組件後,Vue
啓用虛擬DOM
造成初始化的HTML
字符串。以後,交由客戶端託管。實現先後端同構應用。Koa
的Web Server Frame
上配置服務端渲染?須要用到Vue
服務端渲染對應庫vue-server-renderer
,經過npm
安裝:webpack
npm install vue vue-server-renderer --save
複製代碼
最簡單的,首先渲染一個Vue
實例:ios
// 第 1 步:建立一個 Vue 實例
const Vue = require('vue');
const app = new Vue({
template: `<div>Hello World</div>`
});
// 第 2 步:建立一個 renderer
const renderer = require('vue-server-renderer').createRenderer();
// 第 3 步:將 Vue 實例渲染爲 HTML
renderer.renderToString(app, (err, html) => {
if (err) {
throw err;
}
console.log(html);
// => <div data-server-rendered="true">Hello World</div>
});
複製代碼
與服務器集成:git
module.exports = async function(ctx) {
ctx.status = 200;
let html = '';
try {
// ...
html = await renderer.renderToString(app, ctx);
} catch (err) {
ctx.logger('Vue SSR Render error', JSON.stringify(err));
html = await ctx.getErrorPage(err); // 渲染出錯的頁面
}
ctx.body = html;
}
複製代碼
使用頁面模板:github
當你在渲染Vue
應用程序時,renderer
只從應用程序生成HTML
標記。在這個示例中,咱們必須用一個額外的HTML
頁面包裹容器,來包裹生成的HTML
標記。web
爲了簡化這些,你能夠直接在建立renderer
時提供一個頁面模板。多數時候,咱們會將頁面模板放在特有的文件中:
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
複製代碼
而後,咱們能夠讀取和傳輸文件到Vue renderer
中:
const tpl = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf-8');
const renderer = vssr.createRenderer({
template: tpl,
});
複製代碼
然而在實際項目中,不止上述例子那麼簡單,須要考慮不少方面:路由、數據預取、組件化、全局狀態等,因此服務端渲染不是隻用一個簡單的模板,而後加上使用vue-server-renderer
完成的,以下面的示意圖所示:
如示意圖所示,通常的Vue
服務端渲染項目,有兩個項目入口文件,分別爲entry-client.js
和entry-server.js
,一個僅運行在客戶端,一個僅運行在服務端,通過Webpack
打包後,會生成兩個Bundle
,服務端的Bundle
會用於在服務端使用虛擬DOM
生成應用程序的「快照」,客戶端的Bundle
會在瀏覽器執行。
所以,咱們須要兩個Webpack
配置,分別命名爲webpack.client.config.js
和webpack.server.config.js
,分別用於生成客戶端Bundle
與服務端Bundle
,分別命名爲vue-ssr-client-manifest.json
與vue-ssr-server-bundle.json
,關於如何配置,Vue
官方有相關示例vue-hackernews-2.0
我所在的項目使用Koa
做爲Web Server Frame
,項目使用koa-webpack進行開發環境的構建。若是是在產品環境下,會生成vue-ssr-client-manifest.json
與vue-ssr-server-bundle.json
,包含對應的Bundle
,提供客戶端和服務端引用,而在開發環境下,通常狀況下放在內存中。使用memory-fs
模塊進行讀取。
const fs = require('fs')
const path = require( 'path' );
const webpack = require( 'webpack' );
const koaWpDevMiddleware = require( 'koa-webpack' );
const MFS = require('memory-fs');
const appSSR = require('./../../app.ssr.js');
let wpConfig;
let clientConfig, serverConfig;
let wpCompiler;
let clientCompiler, serverCompiler;
let clientManifest;
let bundle;
// 生成服務端bundle的webpack配置
if ((fs.existsSync(path.resolve(cwd,'webpack.server.config.js')))) {
serverConfig = require(path.resolve(cwd, 'webpack.server.config.js'));
serverCompiler = webpack( serverConfig );
}
// 生成客戶端clientManifest的webpack配置
if ((fs.existsSync(path.resolve(cwd,'webpack.client.config.js')))) {
clientConfig = require(path.resolve(cwd, 'webpack.client.config.js'));
clientCompiler = webpack(clientConfig);
}
if (serverCompiler && clientCompiler) {
let publicPath = clientCompiler.output && clientCompiler.output.publicPath;
const koaDevMiddleware = await koaWpDevMiddleware({
compiler: clientCompiler,
devMiddleware: {
publicPath,
serverSideRender: true
},
});
app.use(koaDevMiddleware);
// 服務端渲染生成clientManifest
app.use(async (ctx, next) => {
const stats = ctx.state.webpackStats.toJson();
const assetsByChunkName = stats.assetsByChunkName;
stats.errors.forEach(err => console.error(err));
stats.warnings.forEach(err => console.warn(err));
if (stats.errors.length) {
console.error(stats.errors);
return;
}
// 生成的clientManifest放到appSSR模塊,應用程序能夠直接讀取
let fileSystem = koaDevMiddleware.devMiddleware.fileSystem;
clientManifest = JSON.parse(fileSystem.readFileSync(path.resolve(cwd,'./dist/vue-ssr-client-manifest.json'), 'utf-8'));
appSSR.clientManifest = clientManifest;
await next();
});
// 服務端渲染的server bundle 存儲到內存裏
const mfs = new MFS();
serverCompiler.outputFileSystem = mfs;
serverCompiler.watch({}, (err, stats) => {
if (err) {
throw err;
}
stats = stats.toJson();
if (stats.errors.length) {
console.error(stats.errors);
return;
}
// 生成的bundle放到appSSR模塊,應用程序能夠直接讀取
bundle = JSON.parse(mfs.readFileSync(path.resolve(cwd,'./dist/vue-ssr-server-bundle.json'), 'utf-8'));
appSSR.bundle = bundle;
});
}
複製代碼
產品環境下,打包後的客戶端和服務端的Bundle
會存儲爲vue-ssr-client-manifest.json
與vue-ssr-server-bundle.json
,經過文件流模塊fs
讀取便可,但在開發環境下,我建立了一個appSSR
模塊,在發生代碼更改時,會觸發Webpack
熱更新,appSSR
對應的bundle
也會更新,appSSR
模塊代碼以下所示:
let clientManifest;
let bundle;
const appSSR = {
get bundle() {
return bundle;
},
set bundle(val) {
bundle = val;
},
get clientManifest() {
return clientManifest;
},
set clientManifest(val) {
clientManifest = val;
}
};
module.exports = appSSR;
複製代碼
經過引入appSSR
模塊,在開發環境下,就能夠拿到clientManifest
和ssrBundle
,項目的渲染中間件以下:
const fs = require('fs');
const path = require('path');
const ejs = require('ejs');
const vue = require('vue');
const vssr = require('vue-server-renderer');
const createBundleRenderer = vssr.createBundleRenderer;
const dirname = process.cwd();
const env = process.env.RUN_ENVIRONMENT;
let bundle;
let clientManifest;
if (env === 'development') {
// 開發環境下,經過appSSR模塊,拿到clientManifest和ssrBundle
let appSSR = require('./../../core/app.ssr.js');
bundle = appSSR.bundle;
clientManifest = appSSR.clientManifest;
} else {
bundle = JSON.parse(fs.readFileSync(path.resolve(__dirname, './dist/vue-ssr-server-bundle.json'), 'utf-8'));
clientManifest = JSON.parse(fs.readFileSync(path.resolve(__dirname, './dist/vue-ssr-client-manifest.json'), 'utf-8'));
}
module.exports = async function(ctx) {
ctx.status = 200;
let html;
let context = await ctx.getTplContext();
ctx.logger('進入SSR,context爲: ', JSON.stringify(context));
const tpl = fs.readFileSync(path.resolve(__dirname, './newTemplate.html'), 'utf-8');
const renderer = createBundleRenderer(bundle, {
runInNewContext: false,
template: tpl, // (可選)頁面模板
clientManifest: clientManifest // (可選)客戶端構建 manifest
});
ctx.logger('createBundleRenderer renderer:', JSON.stringify(renderer));
try {
html = await renderer.renderToString({
...context,
url: context.CTX.url,
});
} catch(err) {
ctx.logger('SSR renderToString 失敗: ', JSON.stringify(err));
console.error(err);
}
ctx.body = html;
};
複製代碼
使用Webpack
來處理服務器和客戶端的應用程序,大部分源碼可使用通用方式編寫,可使用Webpack
支持的全部功能。
一個基本項目可能像是這樣:
src
├── components
│ ├── Foo.vue
│ ├── Bar.vue
│ └── Baz.vue
├── frame
│ ├── app.js # 通用 entry(universal entry)
│ ├── entry-client.js # 僅運行於瀏覽器
│ ├── entry-server.js # 僅運行於服務器
│ └── index.vue # 項目入口組件
├── pages
├── routers
└── store
複製代碼
app.js
是咱們應用程序的「通用entry
」。在純客戶端應用程序中,咱們將在此文件中建立根Vue
實例,並直接掛載到DOM
。可是,對於服務器端渲染(SSR
),責任轉移到純客戶端entry
文件。app.js
簡單地使用export
導出一個createApp
函數:
import Router from '~ut/router';
import { sync } from 'vuex-router-sync';
import Vue from 'vue';
import { createStore } from './../store';
import Frame from './index.vue';
import myRouter from './../routers/myRouter';
function createVueInstance(routes, ctx) {
const router = Router({
base: '/base',
mode: 'history',
routes: [routes],
});
const store = createStore({ ctx });
// 把路由注入到vuex中
sync(store, router);
const app = new Vue({
router,
render: function(h) {
return h(Frame);
},
store,
});
return { app, router, store };
}
module.exports = function createApp(ctx) {
return createVueInstance(myRouter, ctx);
}
複製代碼
注:在我所在的項目中,須要動態判斷是否須要註冊
DicomView
,只有在客戶端才初始化DicomView
,因爲Node.js
環境沒有window
對象,對於代碼運行環境的判斷,能夠經過typeof window === 'undefined'
來進行判斷。
如Vue SSR
文檔所述:
當編寫純客戶端 (client-only) 代碼時,咱們習慣於每次在新的上下文中對代碼進行取值。可是,Node.js 服務器是一個長期運行的進程。當咱們的代碼進入該進程時,它將進行一次取值並留存在內存中。這意味着若是建立一個單例對象,它將在每一個傳入的請求之間共享。如基本示例所示,咱們爲每一個請求建立一個新的根 Vue 實例。這與每一個用戶在本身的瀏覽器中使用新應用程序的實例相似。若是咱們在多個請求之間使用一個共享的實例,很容易致使交叉請求狀態污染 (cross-request state pollution)。所以,咱們不該該直接建立一個應用程序實例,而是應該暴露一個能夠重複執行的工廠函數,爲每一個請求建立新的應用程序實例。一樣的規則也適用於 router、store 和 event bus 實例。你不該該直接從模塊導出並將其導入到應用程序中,而是須要在 createApp 中建立一個新的實例,並從根 Vue 實例注入。
如上代碼所述,createApp
方法經過返回一個返回值建立Vue
實例的對象的函數調用,在函數createVueInstance
中,爲每個請求建立了Vue
,Vue Router
,Vuex
實例。並暴露給entry-client
和entry-server
模塊。
在客戶端entry-client.js
只需建立應用程序,而且將其掛載到DOM
中:
import { createApp } from './app';
// 客戶端特定引導邏輯……
const { app } = createApp();
// 這裏假定 App.vue 模板中根元素具備 `id="app"`
app.$mount('#app');
複製代碼
服務端entry-server.js
使用default export
導出函數,並在每次渲染中重複調用此函數。此時,除了建立和返回應用程序實例以外,它不會作太多事情 - 可是稍後咱們將在此執行服務器端路由匹配和數據預取邏輯:
import { createApp } from './app';
export default context => {
const { app } = createApp();
return app;
}
複製代碼
vue-router
分割代碼與Vue
實例同樣,也須要建立單例的vueRouter
對象。對於每一個請求,都須要建立一個新的vueRouter
實例:
function createVueInstance(routes, ctx) {
const router = Router({
base: '/base',
mode: 'history',
routes: [routes],
});
const store = createStore({ ctx });
// 把路由注入到vuex中
sync(store, router);
const app = new Vue({
router,
render: function(h) {
return h(Frame);
},
store,
});
return { app, router, store };
}
複製代碼
同時,須要在entry-server.js
中實現服務器端路由邏輯,使用router.getMatchedComponents
方法獲取到當前路由匹配的組件,若是當前路由沒有匹配到相應的組件,則reject
到404
頁面,不然resolve
整個app
,用於Vue
渲染虛擬DOM
,並使用對應模板生成對應的HTML
字符串。
const createApp = require('./app');
module.exports = context => {
return new Promise((resolve, reject) => {
// ...
// 設置服務器端 router 的位置
router.push(context.url);
// 等到 router 將可能的異步組件和鉤子函數解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,執行 reject 函數,並返回 404
if (!matchedComponents.length) {
return reject('匹配不到的路由,執行 reject 函數,並返回 404');
}
// Promise 應該 resolve 應用程序實例,以便它能夠渲染
resolve(app);
}, reject);
});
}
複製代碼
在Vue
服務端渲染,本質上是在渲染咱們應用程序的"快照",因此若是應用程序依賴於一些異步數據,那麼在開始渲染過程以前,須要先預取和解析好這些數據。服務端Web Server Frame
做爲代理服務器,在服務端對接口服務發起請求,並將數據拼裝到全局Vuex
狀態中。
另外一個須要關注的問題是在客戶端,在掛載到客戶端應用程序以前,須要獲取到與服務器端應用程序徹底相同的數據 - 不然,客戶端應用程序會由於使用與服務器端應用程序不一樣的狀態,而後致使混合失敗。
目前較好的解決方案是,給路由匹配的一級子組件一個asyncData
,在asyncData
方法中,dispatch
對應的action
。asyncData
是咱們約定的函數名,表示渲染組件須要預先執行它獲取初始數據,它返回一個Promise
,以便咱們在後端渲染的時候能夠知道何時該操做完成。注意,因爲此函數會在組件實例化以前調用,因此它沒法訪問this
。須要將store
和路由信息做爲參數傳遞進去:
舉個例子:
<!-- Lung.vue -->
<template>
<div></div>
</template>
<script> export default { // ... async asyncData({ store, route }) { return Promise.all([ store.dispatch('getA'), store.dispatch('myModule/getB', { root:true }), store.dispatch('myModule/getC', { root:true }), store.dispatch('myModule/getD', { root:true }), ]); }, // ... } </script>
複製代碼
在entry-server.js
中,咱們能夠經過路由得到與router.getMatchedComponents()
相匹配的組件,若是組件暴露出asyncData
,咱們就調用這個方法。而後咱們須要將解析完成的狀態,附加到渲染上下文中。
const createApp = require('./app');
module.exports = context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp(context);
// 針對沒有Vue router 的Vue實例,在項目中爲列表頁,直接resolve app
if (!router) {
resolve(app);
}
// 設置服務器端 router 的位置
router.push(context.url.replace('/base', ''));
// 等到 router 將可能的異步組件和鉤子函數解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,執行 reject 函數,並返回 404
if (!matchedComponents.length) {
return reject('匹配不到的路由,執行 reject 函數,並返回 404');
}
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);
}, reject);
});
}
複製代碼
當服務端使用模板進行渲染時,context.state
將做爲window.__INITIAL_STATE__
狀態,自動嵌入到最終的HTML
中。而在客戶端,在掛載到應用程序以前,store
就應該獲取到狀態,最終咱們的entry-client.js
被改造爲以下所示:
import createApp from './app';
const { app, router, store } = createApp();
// 客戶端把初始化的store替換爲window.__INITIAL_STATE__
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
}
if (router) {
router.onReady(() => {
app.$mount('#app')
});
} else {
app.$mount('#app');
}
複製代碼
至此,基本的代碼改造也已經完成了,下面說的是一些常見問題的解決方案:
window
、location
對象:對於舊項目遷移到SSR
確定會經歷的問題,通常爲在項目入口處或是created
、beforeCreate
生命週期使用了DOM
操做,或是獲取了location
對象,通用的解決方案通常爲判斷執行環境,經過typeof window
是否爲'undefined'
,若是遇到必須使用location
對象的地方用於獲取url
中的相關參數,在ctx
對象中也能夠找到對應參數。
vue-router
報錯Uncaught TypeError: _Vue.extend is not _Vue function
,沒有找到_Vue
實例的問題:經過查看Vue-router
源碼發現沒有手動調用Vue.use(Vue-Router);
。沒有調用Vue.use(Vue-Router);
在瀏覽器端沒有出現問題,但在服務端就會出現問題。對應的Vue-router
源碼所示:
VueRouter.prototype.init = function init (app /* Vue component instance */) {
var this$1 = this;
process.env.NODE_ENV !== 'production' && assert(
install.installed,
"not installed. Make sure to call `Vue.use(VueRouter)` " +
"before creating root instance."
);
// ...
}
複製代碼
hash
路由的參數因爲hash
路由的參數,會致使vue-router
不起效果,對於使用了vue-router
的先後端同構應用,必須換爲history
路由。
cookie
的問題:因爲客戶端每次請求都會對應地把cookie
帶給接口側,而服務端Web Server Frame
做爲代理服務器,並不會每次維持cookie
,因此須要咱們手動把 cookie
透傳給接口側,經常使用的解決方案是,將ctx
掛載到全局狀態中,當發起異步請求時,手動帶上cookie
,以下代碼所示:
// createStore.js
// 在建立全局狀態的函數`createStore`時,將`ctx`掛載到全局狀態
export function createStore({ ctx }) {
return new Vuex.Store({
state: {
...state,
ctx,
},
getters,
actions,
mutations,
modules: {
// ...
},
plugins: debug ? [createLogger()] : [],
});
}
複製代碼
當發起異步請求時,手動帶上cookie
,項目中使用的是Axios
:
// actions.js
// ...
const actions = {
async getUserInfo({ commit, state }) {
let requestParams = {
params: {
random: tool.createRandomString(8, true),
},
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
};
// 手動帶上cookie
if (state.ctx.request.headers.cookie) {
requestParams.headers.Cookie = state.ctx.request.headers.cookie;
}
// ...
let res = await Axios.get(`${requestUrlOrigin}${url.GET_A}`, requestParams);
commit(globalTypes.SET_A, {
res: res.data,
});
}
};
// ...
複製代碼
connect ECONNREFUSED 127.0.0.1:80
的問題緣由是改造以前,使用客戶端渲染時,使用了devServer.proxy
代理配置來解決跨域問題,而服務端做爲代理服務器對接口發起異步請求時,不會讀取對應的webpack
配置,對於服務端而言會對應請求當前域下的對應path
下的接口。
解決方案爲去除webpack
的devServer.proxy
配置,對於接口請求帶上對應的origin
便可:
const requestUrlOrigin = requestUrlOrigin = state.ctx.URL.origin;
const res = await Axios.get(`${requestUrlOrigin}${url.GET_A}`, requestParams);
複製代碼
vue-router
配置項有base
參數時,初始化時匹配不到對應路由的問題在官方示例中的entry-server.js
:
// entry-server.js
import { createApp } from './app';
export default context => {
// 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise,
// 以便服務器可以等待全部的內容在渲染前,
// 就已經準備就緒。
return new Promise((resolve, reject) => {
const { app, router } = createApp();
// 設置服務器端 router 的位置
router.push(context.url);
// ...
});
}
複製代碼
緣由是設置服務器端router
的位置時,context.url
爲訪問頁面的url
,並帶上了base
,在router.push
時應該去除base
,以下所示:
router.push(context.url.replace('/base', ''));
複製代碼
本文爲筆者經過對現有項目進行改造,給現有項目加上Vue
服務端渲染的實踐過程的總結。
首先闡述了什麼是Vue
服務端渲染,其目的、本質及原理,經過在服務端使用Vue
的虛擬DOM
,造成初始化的HTML
字符串,即應用程序的「快照」。帶來極大的性能優點,包括SEO
優點和首屏渲染的極速體驗。以後闡述了Vue
服務端渲染的基本用法,即兩個入口、兩個webpack
配置,分別做用於客戶端和服務端,分別生成vue-ssr-client-manifest.json
與vue-ssr-server-bundle.json
做爲打包結果。最後經過對現有項目的改造過程,包括對路由進行改造、數據預獲取和狀態初始化,並解釋了在Vue
服務端渲染項目改造過程當中的常見問題,幫助咱們進行現有項目往Vue
服務端渲染的遷移。
文章最後,打個廣告:騰訊醫療部門招前端工程師啦,HC無限多,社招、校招都可內推。若是有想來騰訊的小夥伴,能夠添加個人微信:xingbofeng001,若是有想交朋友、交流技術的小夥伴也歡迎添加個人微信~