前段时间弄了一个先后端分离的 vue-koa-demo,最近为这个项目提供了 Vue SSR 的支持。项目比较简单,因此转成 Vue SSR 成本仍是不太大的,可是其中也踩了几个坑,在此记录一下。javascript
一开始接触 Vue SSR 固然仍是 官方文档 和 官方案例 了,学习了解以后咱们就能够本身照葫芦画瓢了。css
当咱们使用 Vue 来编写咱们的单页面应用的时候,咱们全部的业务代码最后都会被 webpack 打包到 dist
目录下,当浏览器输入 URL 来向服务端请求页面的时候,咱们的服务器都会返回 dist
下的 index.html
这个文件,可是打开这个文件咱们就能发现,这个文件很简单里面都是各类文件连接,只有一个 <div id="app"></div>
这么一个内容。咱们在浏览器里看到的丰富多彩内容,都是加载完 html 文档里的脚本文件后执行并渲染出来的。这样的页面须要等待脚本文件所有执行才能够展示给咱们在网络较差(下载脚本慢)或运行速度慢(运行脚本慢)的设备上显示很慢;不利于 SEO,固然也不利于咱们爬取数据(好比我以前爬取的 豆瓣的 2017 年的电影总结 爬完发现就返回了这么一个 div#app
)。html
而咱们使用了 Vue SSR 就不同了,服务器返回的 html 立马变得丰富了起来,服务器直接返回渲染好的 html。前端
上图来自 官方文档 。如下是个人理解:vue
在 Vue SSR 中,咱们须要为 webpack 提供两个入口,分别打包两份代码,一份给服务器使用,一份给浏览器使用。服务器端的 bundle 的职责是当用户敲下一段 URL 后,须要匹配到该路由,找到对应的 Vue 组件(解释了为何 Vue SSR 须要与 vue-router 配合使用),若是须要数据的话,还须要预先获取数据注入到组件中,最后经过 vue-server-renderer 来渲染出要返回给浏览器的 html;而浏览器端的 bundle 和以前的前端渲染打包相似,在服务器返回 html 后,由前端的 bundle 接管页面,使页面在 Vue 的管理之下,以后页面内的路由跳转就走前端路由了。java
对项目进行 SSR 支持一共分为如下几步:node
main.js
,修改 router.js
entry-client.js
和服务端打包入口 entry-server.js
main.js
和 router.js
当咱们编写前端业务代码的时候,咱们通常只在 src/main.js
中建立一个新的 Vue 实例就行,由于咱们的代码在每一个用户本身的浏览器中运行时都会新建一个 Vue 实例。而对 SSR 来讲,若是建立一个单例,则这个单例就会在每一个用户之间共享,那样就乱套了,因此须要为每一个请求都建立一个 Vue 实例。同理 vue-router 的实例以及 vuex 的实例也是如此。webpack
修改后,项目的 main.js
和 router.js
大体以下ios
// main.js import Vue from 'vue' import App from './App' import { createRouter } from './router' export function createApp () { const router = createRouter() const app = new Vue({ router, render: h => h(App) }) return { app, router } } // router.js import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) export function createRouter () { return new Router({ mode: 'history', routes: [ { path: '/', name: 'todo', component: resolve => require(['@/components/TodoList'], resolve) }, { path: '/login', name: 'login', component: resolve => require(['@/components/Login'], resolve) }, { path: '/register', name: 'register', component: resolve => require(['@/components/Register'], resolve) }, { path: '/todo', name: 'todoList', component: resolve => require(['@/components/TodoList'], resolve) }, { path: '/detail/:todoId', name: 'detail', component: resolve => require(['@/components/Detail'], resolve) } ] }) }
entry-client.js
和服务端打包入口 entry-server.js
客户端的入口文件比较简单,只要建立 Vue 实例,并挂载应用程序来使 Vue 在浏览器接管应用程序就能够了。git
// entry-clent.js import { createApp } from './main' const { app, router } = createApp() router.onReady(() => { app.$mount('#app') })
服务端的入口文件稍微复杂点,它页须要建立 Vue 实例,以后根据 URL 和 vue-router 中定义的路由要寻找须要渲染的组件。若是须要数据预取的话获取响应数据注入到实例中,因为 vue-koa-demo 比较简单,没有须要这一步,因此只完成了匹配组件。
// entry-server.js import { createApp } from './main' export default context => { return new Promise((resolve, reject) => { const { app, router } = createApp() router.push(context.url) router.onReady(() => { const matchedComponents = router.getMatchedComponents() if (!matchedComponents.length) { return reject(new Error({ code: 404 })) } resolve(app) }, reject) }) }
因为项目是以前用 vue-cli2 构建的,因此就在这个基础上进行修改便可。webpack.base.conf.js
不用动,做为咱们的基本配置。
webpack.client.conf.js
用于打包客户端 bundleconst path = require('path') const webpack = require('webpack') const merge = require('webpack-merge') const baseConfig = require('./webpack.base.conf') const CopyWebpackPlugin = require('copy-webpack-plugin') const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const ExtractTextPlugin = require('extract-text-webpack-plugin') const UglifyJsPlugin = require('uglifyjs-webpack-plugin') const utils = require('./utils') const config = require('../config') const isProd = process.env.NODE_ENV === 'production' const resolve = p => path.resolve(__dirname, p) const plugins = isProd ? [ new UglifyJsPlugin({ uglifyOptions: { compress: { warnings: false } }, sourceMap: config.build.productionSourceMap, parallel: true }), new ExtractTextPlugin({ filename: utils.assetsPath('css/[name].[contenthash].css'), allChunks: true }), new CopyWebpackPlugin([ { from: path.resolve(__dirname, '../static'), to: config.build.assetsSubDirectory, ignore: ['.*'] } ]) ] : [ new webpack.HotModuleReplacementPlugin() ] module.exports = merge(baseConfig, { entry: resolve('../src/entry-client.js'), externals: ['axios'], plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development') }), new webpack.NamedModulesPlugin(), new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', minChunks (module) { return ( module.resource && /\.js$/.test(module.resource) && module.resource.indexOf( path.join(__dirname, '../node_modules') ) === 0 ) } }), new webpack.optimize.CommonsChunkPlugin({ name: 'app', async: 'vendor-async', children: true, minChunks: 3 }), new webpack.optimize.CommonsChunkPlugin({ name: 'manifest', minChunks: Infinity }), ...plugins, new VueSSRClientPlugin() ] })
webpack.server.conf.js
用来打包服务端 bundleconst path = require('path') const merge = require('webpack-merge') const nodeExternals = require('webpack-node-externals') const baseConfig = require('./webpack.base.conf.js') const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') const ExtractTextPlugin = require('extract-text-webpack-plugin') const utils = require('./utils') const resolve = p => path.resolve(__dirname, p) const config = merge(baseConfig, { entry: resolve('../src/entry-server.js'), target: 'node', devtool: 'source-map', output: { filename: 'server-bundle.js', libraryTarget: 'commonjs2' }, externals: [nodeExternals({ whitelist: /\.css$/ })], plugins: [ new ExtractTextPlugin({ filename: utils.assetsPath('css/[name].[hash:8].css') }), new VueSSRServerPlugin() ] }) module.exports = config
解释一下,webpack.client.conf.js
和以前的打包文件很是类似,不一样之处就在于整合了以前 webpack.dev.conf.js
和 webpack.prod.conf.js
的插件,根据环境不一样进行添加,此外最重要的是要添加 VueSSRClientPlugin
这个插件用于生成客户端 json
文件。
webpack.server.conf.js
,须要添加 VueSSRServerPlugin
插件。此外须要注意两点:
target
因为 webpack 默认打包是在浏览器端运行,这里须要修改一下默认值output.libraryTarget
服务端代码是运行在 node 中的,node 的引用方式仍是 commonjs 因此这里也须要改一下默认externals
服务器端不须要像浏览器端那样,把依赖的包全打进 bundle 里,服务器只须要在运行时获取就能够,因此这里须要把 node_modules
中的模块从打包 bundle 中排除出去。而服务端又不能处理 CSS 文件,因此 CSS 文件仍是要打包进 bundle 中的。若是在开发模式下,咱们每次修改页面,都须要打包一次,再重启服务才能看到改动后的样子,实在不太方便。咱们须要进一步配置 webpack 来提供开发模式的自动打包。
为此新建 build/setup-dev-server.js
,这个代码来源于 官方案例 功能在于提供开发模式下服务端的热加载。当从新打包完成后,更新服务器端 renderer,从新请求,就能够获得新的页面。咱们原封不动拷贝下来就行。
可是这里须要注意的是:因为 webpack-dev-middleware
和 webpack-hot-middleware
原来的代码是创建在 express
基础上的,并不能直接兼容 koa
因此咱们要封装一层,我这里直接用 npm 上已有的 koa-webpack-dev-middleware
和 koa-webpack-hot-middleware
而且配合 koa-convert
完成了功能。
const fs = require('fs') const path = require('path') const MFS = require('memory-fs') const webpack = require('webpack') const chokidar = require('chokidar') const convert = require('koa-convert') const clientConfig = require('./webpack.client.conf') const serverConfig = require('./webpack.server.conf') const readFile = (fs, file) => { try { return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8') } catch (e) {} } module.exports = function setupDevServer (app, templatePath, cb) { let bundle let template let clientManifest let ready const readyPromise = new Promise(r => { ready = r }) const update = () => { if (bundle && clientManifest) { ready() cb(bundle, { template, clientManifest }) } } // read template from disk and watch template = fs.readFileSync(templatePath, 'utf-8') chokidar.watch(templatePath).on('change', () => { template = fs.readFileSync(templatePath, 'utf-8') console.log('index.html template updated.') update() }) // modify client config to work with hot middleware clientConfig.entry.app = ['webpack-hot-middleware/client?noInfo=true&reload=true', clientConfig.entry.app] clientConfig.output.filename = '[name].js' clientConfig.plugins.push( new webpack.NoEmitOnErrorsPlugin() ) // dev middleware const clientCompiler = webpack(clientConfig) const devMiddleware = convert(require('koa-webpack-dev-middleware')(clientCompiler, { publicPath: clientConfig.output.publicPath, noInfo: true })) app.use(devMiddleware) clientCompiler.plugin('done', stats => { stats = stats.toJson() stats.errors.forEach(err => console.error(err)) stats.warnings.forEach(err => console.warn(err)) if (stats.errors.length) return clientManifest = JSON.parse(readFile( devMiddleware.fileSystem, 'vue-ssr-client-manifest.json' )) update() }) // hot middleware app.use(convert(require('koa-webpack-hot-middleware')(clientCompiler))) // watch and update server renderer const serverCompiler = webpack(serverConfig) const mfs = new MFS() serverCompiler.outputFileSystem = mfs serverCompiler.watch({}, (err, stats) => { if (err) throw err stats = stats.toJson() if (stats.errors.length) return // read bundle generated by vue-ssr-webpack-plugin bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json')) update() }) return readyPromise }
在 app.js
文件中,须要引入 vue-server-renderer
完成服务端返回的 html 的渲染。
这里须要注意一件事就是项目中使用的 vue
、 vue-server-renderer
和 vue-template-compiler
三个模块的版本须要一致,不然会报错。
此外还须要引入刚刚的 setup-dev-server
在开发模式下使用。
const fs = require('fs') const path = require('path') const Koa = require('koa') const json = require('koa-json') const bodyparser = require('koa-bodyparser') const onerror = require('koa-onerror') const logger = require('koa-logger') const KoaRouter = require('koa-router') const session = require('koa-session') const { createBundleRenderer } = require('vue-server-renderer') const devServerSetup = require('../build/setup-dev-server') const isProd = process.env.NODE_ENV === 'production' // 判断环境 const resolve = file => path.resolve(__dirname, file) const app = new Koa() const router = new KoaRouter() const index = require('./routes/index') // api 路由 app.keys = ['vue koa todo demo'] const CONFIG = { // koa-session 配置 key: 'koa:todo', maxAge: 86400000, overwrite: true, httpOnly: true, signed: true, rolling: false, renew: false } let renderer let readyPromise const templatePath = resolve('../index.html') // 服务端渲染模板 // 生成 server renderer function createRenderer (bundle, options) { return createBundleRenderer(bundle, Object.assign(options, { runInNewContext: false })) } // 生产模式下直接引用打包出来的 bundle,构造 server renderer // 开发模式下开启 devServer,在每次修改后,返回新的 server renderer,以返回修改后的正确的 html if (isProd) { const template = fs.readFileSync(templatePath, 'utf-8') const bundle = require('../dist/vue-ssr-server-bundle.json') const clientManifest = require('../dist/vue-ssr-client-manifest.json') renderer = createRenderer(bundle, { template, clientManifest }) } else { readyPromise = devServerSetup( app, templatePath, (bundle, options) => { renderer = createRenderer(bundle, options) } ) } // 渲染函数,调用 server renderer 方法进行渲染 function render (context) { return new Promise((resolve, reject) => { renderer.renderToString(context, (err, html) => { err ? reject(err) : resolve(html) }) }) } // 开发模式下中间件 const devMiddleware = async (ctx, next) => { const context = { url: ctx.url } await readyPromise try { const html = await render(context) ctx.body = html } catch (err) { await next() } } // 生产模式下中间件 const prodMiddleware = async (ctx, next) => { const context = { url: ctx.url } try { const html = await render(context) ctx.body = html } catch (err) { await next() } } // error handler onerror(app) app.use(session(CONFIG, app)) // middlewares app.use(bodyparser({ enableTypes: ['json', 'form', 'text'] })) app.use(json()) app.use(logger()) // logger app.use(async (ctx, next) => { const start = new Date() await next() const ms = new Date() - start console.log(`${ctx.method} ${ctx.url} - ${ms}ms`) }) router.get('*', isProd ? prodMiddleware : devMiddleware) // 先注册 api 路由 // 其次注册 SSR 渲染路由 // SSR 渲染路由 404,继续走 static 路由,若是 static 404 则返回 404 app.use(index.routes(), index.allowedMethods()) app.use(router.routes(), router.allowedMethods()) app.use(require('koa-static')(resolve('../dist'))) // error-handling app.on('error', (err, ctx) => { console.error('server error', err, ctx) }) module.exports = app
npm script
"scripts": { "dev": "nodemon server/bin/www", "start": "cross-env NODE_ENV=production node server/bin/www", "build": "rimraf dist && npm run build:server && npm run build:client", "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.conf.js --progress --hide-modules", "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.conf.js --progress --hide-modules" },
dev
:开发模式start
:打包后启动build
:打包客户端与服务端build:client
:仅打包客户端build:server
:仅打包服务端咱们执行 npm run build && npm start
打开浏览器端口尝试运行,发现报了错。
sessionstorage not defined
这个和官方文档里的 window not defined
属于同类错误,缘由就是咱们须要编写通用代码。sessionStorage
属于浏览器特定平台的 API 内容,在服务器端跑固然行不通。所以,在 SSR 项目中,若是用到浏览器端特定的 API ,咱们须要保证这些 API 只在浏览器的生命周期钩子函数中才调用。而在 Vue SSR 中,beforeCreate
和 create
钩子是在服务端渲染的过程当中被调用的。
直接全局搜索,发现 sessionStorage
出现了两类地方:
// 1. created 钩子中 { created () { if (sessionStorage.username) { /* do something */ } } } // 2. data 中 { data () { return { username: sessionStorage.username || '' } } }
统一转换:
// 对于 1 { mounted () { if (sessionStorage.username) { /* do something */ } } } // 对于 2 { mounted () { this.username = sessionStorage.username || '' } }
修改后,打包成功后再次访问,发现页面 OK。
成功渲染出 html 页面,到这里就成功地为 vue-koa-demo 添加了 SSR 支持~
最后附上 项目源码