1

前段时间弄了一个前后端分离的 vue-koa-demo,最近为这个项目提供了 Vue SSR 的支持。项目比较简单,所以转成 Vue SSR 成本还是不太大的,但是其中也踩了几个坑,在此记录一下。

一开始接触 Vue SSR 当然还是 官方文档官方案例 了,学习了解之后我们就可以自己照葫芦画瓢了。

Vue SSR 与 Vue

当我们使用 Vue 来编写我们的单页面应用的时候,我们所有的业务代码最后都会被 webpack 打包到 dist 目录下,当浏览器输入 URL 来向服务端请求页面的时候,我们的服务器都会返回 dist 下的 index.html 这个文件,但是打开这个文件我们就能发现,这个文件很简单里面都是各种文件链接,只有一个 <div id="app"></div> 这么一个内容。我们在浏览器里看到的丰富多彩内容,都是加载完 html 文档里的脚本文件后执行并渲染出来的。这样的页面需要等待脚本文件全部执行才可以展现给我们在网络较差(下载脚本慢)或运行速度慢(运行脚本慢)的设备上显示很慢;不利于 SEO,当然也不利于我们爬取数据(比如我之前爬取的 豆瓣的 2017 年的电影总结 爬完发现就返回了这么一个 div#app )。

而我们使用了 Vue SSR 就不一样了,服务器返回的 html 立马变得丰富了起来,服务器直接返回渲染好的 html。

ssr原理

上图来自 官方文档 。以下是我的理解:

在 Vue SSR 中,我们需要为 webpack 提供两个入口,分别打包两份代码,一份给服务器使用,一份给浏览器使用。服务器端的 bundle 的职责是当用户敲下一段 URL 后,需要匹配到该路由,找到对应的 Vue 组件(解释了为什么 Vue SSR 需要与 vue-router 配合使用),如果需要数据的话,还需要预先获取数据注入到组件中,最后通过 vue-server-renderer 来渲染出要返回给浏览器的 html;而浏览器端的 bundle 和之前的前端渲染打包类似,在服务器返回 html 后,由前端的 bundle 接管页面,使页面在 Vue 的管理之下,之后页面内的路由跳转就走前端路由了。

对项目进行 SSR 支持

对项目进行 SSR 支持一共分为以下几步:

  1. 修改 main.js,修改 router.js
  2. 增加客户端打包入口 entry-client.js 和服务端打包入口 entry-server.js
  3. 修改 webpack 配置,使其支持客户端打包 bundle 和服务端打包 bundle,支持开发环境和生产环境
  4. 修改服务端 app.js
  5. 修改 Vue 组件中的剩余 bug

修改main.jsrouter.js

当我们编写前端业务代码的时候,我们一般只在 src/main.js 中创建一个新的 Vue 实例就行,因为我们的代码在每个用户自己的浏览器中运行时都会新建一个 Vue 实例。而对 SSR 来说,如果创建一个单例,则这个单例就会在每个用户之间共享,那样就乱套了,所以需要为每个请求都创建一个 Vue 实例。同理 vue-router 的实例以及 vuex 的实例也是如此。

修改后,项目的 main.jsrouter.js 大致如下

// 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 在浏览器接管应用程序就可以了。

// 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)
  })
}

修改 webpack 配置

由于项目是之前用 vue-cli2 构建的,所以就在这个基础上进行修改即可。webpack.base.conf.js 不用动,作为我们的基本配置。

新增 webpack.client.conf.js 用于打包客户端 bundle

const 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 用来打包服务端 bundle

const 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.jswebpack.prod.conf.js 的插件,根据环境不同进行添加,此外最重要的是要添加 VueSSRClientPlugin 这个插件用于生成客户端 json 文件。

webpack.server.conf.js ,需要添加 VueSSRServerPlugin 插件。此外需要注意两点:

  1. target 由于 webpack 默认打包是在浏览器端运行,这里需要修改一下默认值
  2. output.libraryTarget 服务端代码是运行在 node 中的,node 的引用方式还是 commonjs 所以这里也需要改一下默认
  3. externals 服务器端不需要像浏览器端那样,把依赖的包全打进 bundle 里,服务器只需要在运行时获取就可以,所以这里需要把 node_modules 中的模块从打包 bundle 中排除出去。而服务端又不能处理 CSS 文件,所以 CSS 文件还是要打包进 bundle 中的。

提供开发模式热加载

如果在开发模式下,我们每次修改页面,都需要打包一次,再重启服务才能看到改动后的样子,实在不太方便。我们需要进一步配置 webpack 来提供开发模式的自动打包。

为此新建 build/setup-dev-server.js ,这个代码来源于 官方案例 功能在于提供开发模式下服务端的热加载。当重新打包完成后,更新服务器端 renderer,重新请求,就可以得到新的页面。我们原封不动拷贝下来就行。

但是这里需要注意的是:由于 webpack-dev-middlewarewebpack-hot-middleware 原来的代码是建立在 express 基础上的,并不能直接兼容 koa 所以我们要封装一层,我这里直接用 npm 上已有的 koa-webpack-dev-middlewarekoa-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

app.js 文件中,需要引入 vue-server-renderer 完成服务端返回的 html 的渲染。

这里需要注意一件事就是项目中使用的 vuevue-server-renderervue-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 中,beforeCreatecreate 钩子是在服务端渲染的过程中被调用的。

直接全局搜索,发现 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。

ssr-preview

成功渲染出 html 页面,到这里就成功地为 vue-koa-demo 添加了 SSR 支持~

最后附上 项目源码


breezymelon
132 声望3 粉丝