Vue.js 服务器端渲染

注意:vue ssr 需要最低为如下版本的 Vue,以及以下 Library 支持

  • vue & vue-server-render 2.3.0+
  • vue-router 2.5.0+
  • vue-loader 12.0.0+ & vue-style-loader 3.0.0+

vue-server-renderervue 必须匹配版本

服务器端渲染(SSR)的一些权衡之处

  • 开发条件所限。浏览器特定的代码,只能在某些生命周期钩子函数(lifecycle hook)中使用;一些外部扩展库(external library)可能需要特殊处理,才能在服务器渲染应用程序中运行。
  • 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序(SPA)不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。
  • 更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源(CPU-intensive - CPU 密集),因此如果你预料在高流量环境(high traffic)下使用,请准备相应的服务器负载,并明智地采用缓存策略。

渲染步骤

  1. 用 Webpack 的 node 模式把整个应用单独打一个包,通过 vue-ssr-webpack-plugin 插件,server bundle 将生可传递到 bundle renderer 的特殊 JSON 文件。
  2. Node 环境下通过 vue-server-renderer 提供的名为 createBundleRenderer 这个 API 加载 server bundle 到 vm 上下文环境中。
  3. 应用在 server 内部启动 HTTP 请求抓取当前路由依赖的数据【非必要】
  4. 生成网页模板,嵌入 HTML 和 初始数据

vue-ssr-webpack-plugin

当我们使用 webpack 的按需代码分割(require.ensure 或 动态 import)功能的时候,生成的 server bundle 将会包含多个单独文件,这个插件能自动将这些文件打包成一个可传递给 bundle render 的 JSON 文件来简化工作流程。

基本用法

源码结构

这里使用的项目结构是基于 vue-cli 生成的使用 webpack 为打包工具的默认代码结构。

├── build
|   ├── build.js          
|   ├── check-version.js
|   ├── setup-dev-server.js
|   ├── utils.js
|   ├── vue-loader.conf.js
|   ├── webpack.base.conf.js
|   ├── webpack.client.conf.js
|   └── webpack.server.conf.js
├── config
│   ├── dev.env.js
│   ├── index.js
│   └── prod.env.js
├── src
│   ├── router
│   │    ├── index.js
│   │    └── routes.js
│   ├── entry-client.js
│   ├── entry-server.js
│   ├── index.template.html
│   └── main.js
├── server.js
└── package.json

服务端代码打包

首先需要为服务端打包新增一个 webpack 配置

webpack.server.conf.js

const webpack = require('webpack')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseWebpackConfig, {
  target: 'node', // 因为代码是为了在服务端运行打包,需要设置 target 为 node
  devtool: '#source-map',
  entry: './src/entry-server.js',
  output: {
    filename: '[name].js',
    libraryTarget: 'commonjs2' // commonjs 是 node 的模块规范
  },
  // https://webpack.js.org/configuration/externals/#externals
  // https://github.com/liady/webpack-node-externals
  externals: nodeExternals({
    // 不外部化 CSS 文件,以防我们需要从依赖引入
    whitelist: /\.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    new VueSSRServerPlugin()  // 生成的可传递给 bundle renderer 的 JSON 文件
  ]
})

避免状态单例

纯客户端的时候,每个客户端都有一个自己的上下文环境,不会有数据共享的情况。但是 Node.js 服务器是一个长期运行的进程,如果我们在多个请求之间使用一个共享的实例,它将在每个传入的请求之间共享存放在内存中的数据,很容易导致交叉请求状态污染(cross-request state pollution)。所以要为每个请求创建一个新的根 Vue 实例,需要暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例。

// main.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import { createRouter } from './router'
import Meta from 'vue-meta'

Vue.use(VueRouter)
// manage page meta info in vue component, support SSR
Vue.use(Meta)

export function createApp() {
  // create router instance
  const router = createRouter()

  // create the app instance
  const app = new Vue({
    el: '#app',
    router,
    render: h => h('div', { attrs: { id: 'app' }, [h('router-view')] })
  })
  return { app, router }
}

使用 vue-router 路由

使用官方提供的 vue-router 创建一个 router,前后端都将复用这个相同的路由配置。与 createApp 类似,我们也需要给每个请求创建一个新的 router 实例。

// router/index.js
import VueRouter from 'vue-router'
import routes from './routes'

export function createRouter() {
  return new VueRouter({
    routes,
    mode: 'history',
    fallback: false,
    scrollBehavior (to, from, savedPosition) {
      if (savedPosition) {
        return savedPosition
      } else {
        return { x: 0, y: 0 }
      }
    }
  })
}

客户端 entry 只需创建应用程序,并且将其挂载到 DOM 中,

src/entry-client.js

import { createApp } from './main'
// client side bootstrap
const { app, router } = createApp()
app.$mount('#app')

这里需要注意的是,如果在 SSR 应用里使用了代码分割或惰性加载,则需要在启动渲染之前在服务器上解析所有的异步组件。Vue 提供异步组件,将其与 webpack 2 支持的按模块动态导入来进行代码分割相结合。

router/routes.js

export default [
  { path: '/', component: () => import(/* webpackChunkName: "home" */'../page/home.vue') }
]

build/webpack.client.conf.js

const webpackConfig = merge(baseWebpackConfig, {
  output: {
    // ...
    chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
  }
})

src/entry-client.js 修改为如下:

import { createApp } from './main'
// client side bootstrap
const { app, router } = createApp()
router.onReady(() => {
  app.$mount('#app')
})

src/entry-server.js

entry-server.js 中实现服务器端路由逻辑(server-side routing logic):

import { createApp } from './main'

export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  // 以便服务器能够等待所有的内容在渲染前,就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()
    // 设置服务端 router 的位置
    router.push(context.url)
    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      // Promise 应该 resolve 应用程序实例,以便它可以渲染
      resolve(app)
    }, reject)
  })
}

使用一个页面模板

在渲染 Vue 应用程序时,renderer 只从应用程序生成 HTML 标记(markup)。因此还需要一个额外的 HTML 页面包裹容器,来包裹生成的 HTML 标记。

为了简化,可以在创建 renderer 时提供一个页面模板文件,例如 index.template.html

<!Doctype html>
<html lang="zh">
  <head>
      <meta charset="utf-8">
    <!-- title -->
    <!-- meta -->
  </head>
  <body>
      <!--vue-ssr-outlet-->
  </body>
</html>

注意 <!--vue-ssr-outlet--> 注释的位置,就是 HTML 标记注入的地方。另外值得注意的是,为了实现不同路由对应不同的 title 和 keyword, <!-- title --><!-- meta --> 注释的位置,分别是渲染时 title 标记和 meta 标记注入的占位符。

服务端渲染

假设现在已经经过编译,得到了由 vue-ssr-webpack-plugin 生成的可传递给 bundle renderer 的 JSON 文件,在 server.js 中通过 require 引入使用。这样创建的 bundle renderer,有什么好处呢?

  • 内置 source map 支持(在 webpack 配置中使用 devtool: 'source-map'
  • 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
  • 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的 CSS
  • 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。

server.js

const fs = require('fs')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')

const resolve = file => path.resolve(__dirname, file)
const isProd = process.env.NODE_ENV === 'production'
const app = express()

const serverInfo =
  `express/${require('express/package.json').version} ` +
  `vue-server-renderer/${require('vue-server-renderer/package.json').version}`

function createRenderer(bundle, options) {
  return createBundleRenderer(bundle, Object.assign(options, {
    basedir: resolve('./dist'),
    runInNewContext: false
  }))
}

let renderer
let readyPromise
const templatePath = resolve('./src/index.template.html')
if (isProd) {
  // 生成环境:用 server bundle 和 template 创建渲染器
  const template = fs.readFileSync(templatePath, 'utf-8')
  const bundle = require('./dist/vue-ssr-server-bundle.json')
  // client manifest 是可选项,但是它能让 renderer 自动推断需要 preload/prefetch 的链接
  // 并且将任何渲染期间的异步代码添加为 <script> 标签,避免请求瀑布流
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  renderer = createRenderer(bundle, {
    template,
    clientManifest
  })
} else {
  // 开发环境:启动 dev server 并监控代码的热加载
  // 并为 bundle 和 模板更新创建一个渲染器
  readyPromise = require('./build/setup-dev-server')(
      app,
    templatePath,
    (bundle, options) => {
      renderer = createRenderer(bundle, options)
    }
  )
}

function render (req, res) {
  const s = Date.now()

  res.setHeader("Content-Type", "text/html")
  res.setHeader("Server", serverInfo)

  const handleError = err => {
    if (err.url) {
      res.redirect(err.url)
    } else if(err.code === 404) {
      res.redirect(err.url)
    } else {
      res.status(500).send('500 | Internal Server Error')
      console.error(`error during render : ${req.url}`)
      console.error(err.stack)
    }
  }
  const context = {
    url: req.url
  }
  // 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
  renderer.renderToString(context, (err, html) => {
    // 处理异常
    if (err) {
      return handleError(err)
    }
    res.send(html)
    if (!isProd) {
      console.log(`whole request: ${Date.now() - s}ms`)
    }
  })
}

app.get('*', isProd ? render : (req, res) => {
  readyPromise.then(() => render(req, res))
})

const port = 8000
app.listen(port, function (err) {
  if (err) {
    console.error(err)
    process.exit(1)
  }
  console.log(`server listen on http://localhost:${port}`)
})

到这里服务端渲染脚本已经实现了基本的功能。

动态 HEAD 管理

HTML Head 的管理是 SPA 里常见的问题,在 SSR 中我们同样也要处理这个问题。一个比较方便的解决办法是使用 vue-mate,它能同时支持客户端和服务端渲染。假设 Example.vue 是一个需要单独设置 title 和 meta 的页面

Vue.use 引入 vue-meta,合适的位置是与 vue-router 放在放在一起

src/main.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Meta from 'vue-meta'

Vue.use(Router)
Vue.use(Meta)

*.vue 文件中添加 metaInfo

src/pages/Example.vue

<template>
...
</template>

<script>
export default {
  metaInfo: {
    title: 'My Example App', // 设置标题
    titleTemplate: '%s - Yay!', // 标题现在变成了 "My Example App - Yay!"
    htmlAttrs: { l{ charset: 'utf-8' },ang: 'en' },
    meta: {
        { name: 'keywords', content: 'keywords', id: 'kw' },
      { name: 'description', content: 'description', id: 'desc' }
      }
  }
}
</script>

还需要修改 server 的入口文件,在 server.js 注入 meta 信息前将 $meta 暴露给 bundle Renderer

src/entry-server.js

import { createApp } from './main'

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()
    // ...
    const meta = app.$meta()
    context.meta = meta
    router.onReady(() => {
    // ...
    }, reject) 
  })
}

最后在渲染时将 index.template.html 里的占位符替换为 $meta 输出的信息。

server.js

function render(req, res) {
  // ...
  renderer.renderToString(context, (err, html) => {
    if (err) {
      return handleError(err)
    }
    // 替换占位符为 meta 信息
    const { title, meta } = context.meta.inject()
    const replacedHtml = html.replace('<!-- title -->', title.text())
      .replace('<!-- meta -->', meta.text())
    res.send(replacedHtml)
    if (!isProd) {
      console.log(`whole request: ${Date.now() - s}ms`)
    }
  })
}

HTML API 报错

我们知道现在这套代码将同时运行在浏览器端和服务端,但是在服务端,是无法访问到浏览器端提供的 API,例如windowdocument,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是一样的。

对于仅浏览器可用的 API,通常做法是在「纯客户端(client-only)」的生命周期钩子函数中惰性访问(lazily access)它们。

但是如果你有一些第三方的 Library,用到了这些 API,那么问题就会有点棘手,但是我们依然可以通过 Mock 全局变量来让他正常运行。

server.js

// ...
// 为服务端模拟了部分 HTML API
const html = '<!doctype html><html><body></body></html>'
require('jsdom-global')(html, {
  url: isProd ? 'https://domain.com' : 'http://localhost:8000'
  // 由于模拟了 document,需要为 document 设置默认路由
})
// 接下来可以在代码中访问使用 document,window
// ...

我们知道现在这套代码将同时运行在浏览器端和服务端,但是在服务端,是无法访问到浏览器端提供的 API,例如 windowdocument,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是一样的。

对于仅浏览器可用的 API,通常做法是在「纯客户端(client-only)」的生命周期钩子函数中惰性访问(lazily access)它们。

但是如果你有一些第三方的 Library,用到了这些 API,那么问题就会有点棘手,但是我们依然可以通过 Mock 全局变量来让他正常运行。

server.js

// ...
// 为服务端模拟了部分 HTML API
const html = '<!doctype html><html><body></body></html>'
require('jsdom-global')(html, {
  url: isProd ? 'https://domain.com' : 'http://localhost:8000'
  // 由于模拟了 document,需要为 document 设置默认路由
})
// 接下来可以在代码中访问使用 document,window
// ...

results matching ""

    No results matching ""