随着各大前端框架的诞生和演变,SPA
开始流行,单页面应用的优点在于能够不从新加载整个页面的状况下,经过ajax
和服务器通讯,实现整个Web
应用拒不更新,带来了极致的用户体验。然而,对于须要SEO
、追求极致的首屏性能的应用,前端渲染的SPA
是糟糕的。好在Vue 2.0
后是支持服务端渲染的,零零散散花费了两三周事件,经过改造现有项目,基本完成了在现有项目中实践了Vue
服务端渲染。javascript
关于Vue服务端渲染的原理、搭建,官方文档已经讲的比较详细了,所以,本文不是抄袭文档,而是文档的补充。特别是对于如何与现有项目进行很好的结合,仍是须要费很大功夫的。本文主要对我所在的项目中进行Vue
服务端渲染的改造过程进行阐述,加上一些我的的理解,做为分享与学习。html
本文主要分如下几个方面:前端
如何在基于Koa
的Web Server Frame
上配置服务端渲染?vue
Webpack
配置开发环境搭建java
如何对现有项目进行改造?webpack
在服务端用vue-router
分割代码;ios
Vue.js
是构建客户端应用程序的框架。默认状况下,能够在浏览器中输出Vue
组件,进行生成DOM
和操做DOM
。然而,也能够将同一个组件渲染为服务器端的HTML
字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上彻底可交互的应用程序。
上面这段话是源自Vue服务端渲染文档的解释,用通俗的话来讲,大概能够这么理解:git
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
安装:github
npm install vue vue-server-renderer --save
最简单的,首先渲染一个Vue
实例:web
// 第 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, });
然而在实际项目中,不止上述例子那么简单,须要考虑不少方面:路由、数据预取、组件化、全局状态等,因此服务端渲染不是只用一个简单的模板,而后加上使用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,若是有想交朋友、交流技术的小伙伴也欢迎添加个人微信~