
上面这段话是源自Vue服务端渲染文档的解释,用通俗的话来说,大概可以这么理解:
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安装:
npm install vue vue-server-renderer --save
最简单的,首先渲染一个Vue实例:
// 第 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>
});与服务器集成:
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;
}使用页面模板:
当你在渲染Vue应用程序时,renderer只从应用程序生成HTML标记。在这个示例中,我们必须用一个额外的HTML页面包裹容器,来包裹生成的HTML标记。
为了简化这些,你可以直接在创建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,
});Webpack配置
然而在实际项目中,不止上述例子那么简单,需要考虑很多方面:路由、数据预取、组件化、全局状态等,所以服务端渲染不是只用一个简单的模板,然后加上使用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文档所述:
如上代码所述,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服务端渲染的迁移。
