服务端渲染 Vue

在服务端使用 vue-server-renderer 渲染一个 Vue 实例并返回渲染后的页面。

  1. const express = require('express')
  2. const Vue = require('vue')
  3. const renderer = require('vue-server-renderer').createRenderer()
  4. const server = express()
  5. server.get('/', (req, res) => {
  6. const app = new Vue({
  7. template: `
  8. <div id="app">
  9. {{ message }}
  10. </div>`,
  11. data: {
  12. message: 'Hello World',
  13. },
  14. })
  15. renderer.renderToString(app, (err, html) => {
  16. if (err) {
  17. return res.status(500).end('Internal Server Error.')
  18. }
  19. res.setHeader('Content-Type', 'text/html; charset=utf8')
  20. res.end(`
  21. <!DOCTYPE html>
  22. <html lang="en">
  23. <head>
  24. <meta charset="UTF-8">
  25. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  26. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  27. <title>Document</title>
  28. </head>
  29. <body>
  30. ${html}
  31. </body>
  32. </html>
  33. `)
  34. })
  35. })
  36. server.listen(3000, () => {})

使用 HTML 模板并传入外部数据,生成标签和文本:
index.template.html:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  6. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  7. {{{ meta }}}
  8. <title>{{ title }}</title>
  9. </head>
  10. <body>
  11. <!--vue-ssr-outlet-->
  12. </body>
  13. </html>
  1. const express = require('express')
  2. const Vue = require('vue')
  3. const fs = require('fs')
  4. const template = fs.readFileSync('./index.template.html', 'utf-8')
  5. const renderer = require('vue-server-renderer').createRenderer({
  6. template
  7. })
  8. const server = express()
  9. server.get('/', (req, res) => {
  10. const app = new Vue({
  11. template: `
  12. <div id="app">
  13. {{ message }}
  14. </div>`,
  15. data: {
  16. message: 'Hello World',
  17. },
  18. })
  19. renderer.renderToString(app,
  20. {
  21. title: 'Hello World',
  22. meta: `
  23. <meta name="description" content="拉勾">
  24. `
  25. },
  26. (err, html) => {
  27. if (err) {
  28. return res.status(500).end('Internal Server Error.')
  29. }
  30. res.setHeader('Content-Type', 'text/html; charset=utf8')
  31. res.end(html)
  32. })
  33. })
  34. server.listen(3000, () => {})

Vue SSR

避免状态单例

纯客户端代码,每个请求共享一个 Vue 实例的状态,这很容易导致交叉请求状态污染。
要为每一个请求创建一个新的根 Vue 实例。因此,应该暴露一个可以重复执行的工厂函数。
同样的规则也适用于 router、store 和 event bus 实例,不应该直接从模块导出并将其导入到应用程序中,而是需要在工厂函数中创建一个新的实例,并从根 Vue 实例注入。

构建同构渲染

构建流程

搭建自己的 SSR - 图1

源码结构

我们需要使用 webpack 来打包我们的 Vue 应用程序。事实上,我们可能需要在服务器上使用 webpack 打包 Vue 应用程序,因为:

  • 通常 Vue 应用程序是由 webpack 和 vue-loader 构建,并且许多 webpack 特定功能不能直接在 Node.js 中运行(例如通过 file-loader 导入文件,通过 css-loader 导入 CSS)。
  • 尽管 Node.js 最新版本能够完全支持 ES2015 特性,我们还是需要转译客户端代码以适应老版浏览器。这也会涉及到构建步骤。

所以基本看法是,对于客户端应用程序和服务器应用程序,我们都要使用 webpack 打包 - 服务器需要 「服务器 bundle」然后用于服务器端渲染(SSR),而「客户端 bundle」会发送给浏览器,用于混合静态标记。

使用 webpack 的源码结构:

  1. src
  2. ├── components
  3. ├── Foo.vue
  4. ├── Bar.vue
  5. └── Baz.vue
  6. ├── App.vue
  7. ├── app.js # 通用 entry(universal entry)
  8. ├── entry-client.js # 仅运行于浏览器
  9. └── entry-server.js # 仅运行于服务器

App.vue

  1. <template>
  2. <!-- 客户端渲染的入口节点 -->
  3. <div id="app">
  4. <h1>拉勾教育</h1>
  5. </div>
  6. </template>
  7. <script>
  8. export default {
  9. name: 'App'
  10. }
  11. </script>
  12. <style>
  13. </style>
  14. <script>
  15. export default {
  16. name: 'App',
  17. }
  18. </script>
  19. <style scoped>
  20. </style>

app.js

app.js 是我们应用程序的「通用 entry」。在纯客户端应用程序中,我们将在此文件中创建根 Vue 实 例,并直接挂载到 DOM。但是,对于服务器端渲染(SSR),责任转移到纯客户端 entry 文件。 app.js 简单地使用 export 导出一个 createApp 函数:

  1. import Vue from 'vue'
  2. import App from './App.vue'
  3. // 导出一个工厂函数,用于创建新的
  4. // 应用程序、router 和 store 实例
  5. export function createApp () {
  6. const app = new Vue({
  7. // 根实例简单的渲染应用程序组件。
  8. render: h => h(App)
  9. })
  10. return { app }
  11. }

entry-client.js

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

  1. import { createApp } from './app'
  2. // 客户端特定引导逻辑……
  3. const { app } = createApp()
  4. // 这里假定 App.vue 模板中根元素具有 `id="app"`
  5. app.$mount('#app')

entry-server.js

服务器 entry 使用 default export 导出函数,并在每次渲染中重复调用此函数。此时,除了创建和返回应用程序实例之外,它不会做太多事情 - 但是稍后我们将在此执行服务器端路由匹配 (server-side route matching) 和数据预取逻辑 (data pre-fetching logic)。

  1. import { createApp } from './app'
  2. export default context => {
  3. const { app } = createApp()
  4. return app
  5. }

构建配置

安装依赖

生产依赖

vue Vue.js 核心库
vue-server-renderer Vue 服务端渲染工具
express 基于 Node 的 Web 服务框架
cross-env 通过 npm scripts 设置跨平台环境

开发依赖

webpack webpack 核心包
webpack-cli webpack 的命令行工具
webpack-merge webpack 配置信息合并工具
webpack-node-externals 排除 webpack 中的 Node 模块
rimraf 基于 Node 封装的一个跨平台 rm -rf 工具
friendly-errors-webpack-plugin 友好的 webpack 错误提示
@babel/core
@babel/plugin-transform-runtime
@babel/preset-env
babel-loader
Babel 相关工具
vue-loader
vue-template-compiler
处理 .vue 资源
file-loader 处理字体资源
css-loader 处理 CSS 资源
url-loader 处理图片资源

webpack 配置文件及打包命令

配置文件

  1. /**
  2. * 公共配置
  3. */
  4. const VueLoaderPlugin = require('vue-loader/lib/plugin')
  5. const path = require('path')
  6. const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
  7. const resolve = file => path.resolve(__dirname, file)
  8. const isProd = process.env.NODE_ENV === 'production'
  9. module.exports = {
  10. mode: isProd ? 'production' : 'development',
  11. output: {
  12. path: resolve('../dist/'),
  13. publicPath: '/dist/',
  14. filename: '[name].[chunkhash].js'
  15. },
  16. resolve: {
  17. alias: {
  18. // 路径别名,@ 指向 src
  19. '@': resolve('../src/')
  20. },
  21. // 可以省略的扩展名
  22. // 当省略扩展名的时候,按照从前往后的顺序依次解析
  23. extensions: ['.js', '.vue', '.json']
  24. },
  25. devtool: isProd ? 'source-map' : 'cheap-module-eval-source-map',
  26. module: {
  27. rules: [
  28. // 处理图片资源
  29. {
  30. test: /\.(png|jpg|gif)$/i,
  31. use: [
  32. {
  33. loader: 'url-loader',
  34. options: {
  35. limit: 8192,
  36. },
  37. },
  38. ],
  39. },
  40. // 处理字体资源
  41. {
  42. test: /\.(woff|woff2|eot|ttf|otf)$/,
  43. use: [
  44. 'file-loader',
  45. ],
  46. },
  47. // 处理 .vue 资源
  48. {
  49. test: /\.vue$/,
  50. loader: 'vue-loader'
  51. },
  52. // 处理 CSS 资源
  53. // 它会应用到普通的 `.css` 文件
  54. // 以及 `.vue` 文件中的 `<style>` 块
  55. {
  56. test: /\.css$/,
  57. use: [
  58. 'vue-style-loader',
  59. 'css-loader'
  60. ]
  61. },
  62. // CSS 预处理器,参考:https://vue-loader.vuejs.org/zh/guide/pre-processors.html
  63. // 例如处理 Less 资源
  64. // {
  65. // test: /\.less$/,
  66. // use: [
  67. // 'vue-style-loader',
  68. // 'css-loader',
  69. // 'less-loader'
  70. // ]
  71. // },
  72. ]
  73. },
  74. plugins: [
  75. new VueLoaderPlugin(),
  76. new FriendlyErrorsWebpackPlugin()
  77. ]
  78. }
  1. /**
  2. * 客户端打包配置
  3. */
  4. const { merge } = require('webpack-merge')
  5. const baseConfig = require('./webpack.base.config.js')
  6. const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
  7. module.exports = merge(baseConfig, {
  8. entry: {
  9. app: './src/entry-client.js'
  10. },
  11. module: {
  12. rules: [
  13. // ES6 转 ES5
  14. {
  15. test: /\.m?js$/,
  16. exclude: /(node_modules|bower_components)/,
  17. use: {
  18. loader: 'babel-loader',
  19. options: {
  20. presets: ['@babel/preset-env'],
  21. cacheDirectory: true,
  22. plugins: ['@babel/plugin-transform-runtime']
  23. }
  24. }
  25. },
  26. ]
  27. },
  28. // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,
  29. // 以便可以在之后正确注入异步 chunk。
  30. optimization: {
  31. splitChunks: {
  32. name: "manifest",
  33. minChunks: Infinity
  34. }
  35. },
  36. plugins: [
  37. // 此插件在输出目录中生成 `vue-ssr-client-manifest.json`。
  38. new VueSSRClientPlugin()
  39. ]
  40. })
  1. /**
  2. * 服务端打包配置
  3. */
  4. const { merge } = require('webpack-merge')
  5. const nodeExternals = require('webpack-node-externals')
  6. const baseConfig = require('./webpack.base.config.js')
  7. const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
  8. module.exports = merge(baseConfig, {
  9. // 将 entry 指向应用程序的 server entry 文件
  10. entry: './src/entry-server.js',
  11. // 这允许 webpack 以 Node 适用方式处理模块加载
  12. // 并且还会在编译 Vue 组件时,
  13. // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  14. target: 'node',
  15. output: {
  16. filename: 'server-bundle.js',
  17. // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
  18. libraryTarget: 'commonjs2'
  19. },
  20. // 不打包 node_modules 第三方包,而是保留 require 方式直接加载
  21. externals: [nodeExternals({
  22. // 白名单中的资源依然正常打包
  23. allowlist: [/\.css$/]
  24. })],
  25. plugins: [
  26. // 这是将服务器的整个输出构建为单个 JSON 文件的插件。
  27. // 默认文件名为 `vue-ssr-server-bundle.json`
  28. new VueSSRServerPlugin()
  29. ]
  30. })

打包命令

  1. {
  2. "scripts": {
  3. "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
  4. "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
  5. "build": "rimraf dist && npm run build:client && npm run build:server"
  6. }
  7. }

启动应用

server.js:

  1. const Vue = require('vue')
  2. const express = require('express')
  3. const fs = require('fs')
  4. const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  5. const template = fs.readFileSync('./index.template.html', 'utf-8')
  6. const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  7. //
  8. const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
  9. template,
  10. clientManifest
  11. })
  12. const server = express()
  13. // 开放 dist 目录,不然生成的<script src="/dist/app.4740724b6845d0d348e0.js" defer>标签无法定位 dist 目录
  14. server.use('/dist', express.static('./dist'))
  15. server.get('/', (req, res) => {
  16. renderer.renderToString({
  17. title: '拉勾教育',
  18. meta: `
  19. <meta name="description" content="拉勾教育">
  20. `
  21. }, (err, html) => {
  22. if (err) {
  23. return res.status(500).end('Internal Server Error.')
  24. }
  25. res.setHeader('Content-Type', 'text/html; charset=utf8')
  26. res.end(html)
  27. })
  28. })
  29. server.listen(3000, () => {
  30. console.log('server running at port 3000.')
  31. })

解析渲染流程

服务端渲染

  • renderer.renderToString 渲染了什么?
  • renderer 是如何拿到 entry-server 模块的?
    • createBundleRenderer 中的 serverBundle
  • serverBundle 是 Vue SSR 构建的一个特殊的 JSON 文件
    • entry:入口
    • files:所有构建结果资源列表
    • maps:源代码 source map 信息
  • server-bundle.js 就是通过 server.entry.js 构建出来的结果文件
  • 最终把渲染结果注入到模板中

客户端渲染

  • vue-ssr-client-manifest.json
    • publicPath:访问静态资源的根相对路径,与 webpack 配置中的 publicPath 一致
    • all:打包后的所有静态资源文件路径
    • initial:页面初始化时需要加载的文件,会在页面加载时配置到 preload 中
    • async:页面跳转时需要加载的文件,会在页面加载时配置到 prefetch 中
    • modules:项目的各个模块包含的文件的序号,对应 all 中文件的顺序;moduleIdentifier和 all数组中文件的映射关系(modules对象是我们查找文件引用的重要数据)

构建开发模式

现在已经实现同构应用的基本功能了,但是这对于一个完整的应用来说还远远不够,例如如何处理同构应用中的路由、如何在服务端渲染中进行数据预取等功能。这些功能我们都会去对它进行实现,但 是在实现它们之前我们要先来解决一个关于打包的问题:每次写完代码,都要重新打包构建、重新启动 Web 服务,很麻烦。
所以下面我们来实现项目中的开发模式构建,也就是我们希望能够实现: 写完代码,自动构建;自动重启 Web 服务;自动刷新页面内容。

基本思路

  • 生产模式
    • npm run build
    • 构建 node server.js
    • 启动应用
  • 开发模式
    • 监视代码变动自动构建,热更新等功能
    • node server.js 启动应用

新增脚本:

  1. {
  2. "scripts": {
  3. "start": "cross-env NODE_ENV=production node server.js",
  4. "dev": "node server.js"
  5. }
  6. }

服务端配置

server.js 根据传入的NODE_ENV参数,将生产模式和开发模式区分开。

  1. /**
  2. * 服务端入口,仅运行于服务端
  3. */
  4. const Vue = require('vue')
  5. const express = require('express')
  6. const fs = require('fs')
  7. const { createBundleRenderer } = require('vue-server-renderer')
  8. const setupDevServer = require('./build/setup-dev-server')
  9. const server = express()
  10. server.use('/dist', express.static('./dist'))
  11. const isProd = process.env.NODE_ENV === 'production'
  12. let renderer
  13. let onReady
  14. // 生产模式,直接基于已构建好的包创建渲染器
  15. if (isProd) {
  16. const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  17. const template = fs.readFileSync('./index.template.html', 'utf-8')
  18. const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  19. renderer = createBundleRenderer(serverBundle, {
  20. template,
  21. clientManifest
  22. })
  23. } else {
  24. // 开发模式 -> 监视打包构建 -> 重新生成 Renderer 渲染器
  25. onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
  26. renderer = createBundleRenderer(serverBundle, {
  27. template,
  28. clientManifest
  29. })
  30. })
  31. }
  32. const render = (req, res) => {
  33. renderer.renderToString({
  34. title: '拉勾教育',
  35. meta: `
  36. <meta name="description" content="拉勾教育">
  37. `
  38. }, (err, html) => {
  39. if (err) {
  40. return res.status(500).end('Internal Server Error.')
  41. }
  42. res.setHeader('Content-Type', 'text/html; charset=utf8')
  43. res.end(html)
  44. })
  45. }
  46. server.get('/',
  47. isProd ?
  48. render :
  49. async (req, res) => {
  50. await onReady
  51. render(req, res)
  52. }
  53. )
  54. server.listen(3000, () => {
  55. console.log('server running at port 3000.')
  56. })

封装处理模块

在 build 文件夹创建一个模块setup-dev-server,专门处理开发模式下服务端的打包。

  1. const chokidar = require('chokidar') // 监听文件变化的模块
  2. const fs = require('fs')
  3. const path = require('path')
  4. const webpack = require('webpack')
  5. const devMiddleware = require('webpack-dev-middleware')
  6. const hotMiddleware = require('webpack-hot-middleware')
  7. const resolve = file => path.resolve(__dirname, file)
  8. module.exports = (server, callback) => {
  9. let serverBundle
  10. let template
  11. let clientManifest
  12. let ready
  13. const onReady = new Promise(r => ready = r)
  14. /**
  15. * 如果文件构建完成,调用回调函数,也就是 createBundleRenderer
  16. */
  17. const update = () => {
  18. if (serverBundle && template && clientManifest) {
  19. ready()
  20. callback(serverBundle, template, clientManifest)
  21. }
  22. }
  23. const templatePath = resolve('../index.template.html')
  24. template = fs.readFileSync(templatePath, 'utf-8')
  25. update()
  26. chokidar.watch(templatePath).on('change', () => {
  27. template = fs.readFileSync(templatePath, 'utf-8')
  28. update()
  29. })
  30. const serverConfig = require('./webpack.server.config')
  31. const serverCompiler = webpack(serverConfig)
  32. serverCompiler.watch({}, (err, stats) => {
  33. if (err) throw err
  34. if (stats.hasErrors()) return
  35. serverBundle = JSON.parse(
  36. fs.readFileSync('./dist/vue-ssr-server-bundle.json', 'utf-8')
  37. )
  38. update()
  39. })
  40. return onReady
  41. }

开发模式下,如果还频繁地在本地磁盘上生成文件,会影响速度,最好是把打包结果存储在内存中。使用 webpack 提供的中间件webpack-dev-middleware就可以实现内存读写。
因此,处理 serverBundle 的地方可以修改:

  1. const devMiddleware = require('webpack-dev-middleware')
  2. // 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
  3. const serverConfig = require('./webpack.server.config')
  4. const serverCompiler = webpack(serverConfig)
  5. const serverDevMiddleware = devMiddleware(serverCompiler, {
  6. logLevel: 'silent', // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  7. })
  8. // 注册 done 钩子事件,在内存中读取文件
  9. serverCompiler.hooks.done.tap('server', () => {
  10. serverBundle = JSON.parse(
  11. serverDevMiddleware.fileSystem.readFileSync('../dist/vue-ssr-server-bundle.json', 'utf-8')
  12. )
  13. update()
  14. })

处理客户端的地方,要使用热更新插件开启热更新,并且需要把入口文件修改为数组,传入和服务端交互处理热更新的脚本。最后给 express 提供客户端内存数据的访问。

  1. const devMiddleware = require('webpack-dev-middleware')
  2. const hotMiddleware = require('webpack-hot-middleware')
  3. // 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
  4. const clientConfig = require('./webpack.client.config')
  5. clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin()) // 注册热更新插件
  6. clientConfig.entry.app = [
  7. 'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
  8. clientConfig.entry.app
  9. ]
  10. clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash
  11. const clientCompiler = webpack(clientConfig)
  12. const clientDevMiddleware = devMiddleware(clientCompiler, {
  13. publicPath: clientConfig.output.publicPath,
  14. logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  15. })
  16. clientCompiler.hooks.done.tap('client', () => {
  17. clientManifest = JSON.parse(
  18. clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
  19. )
  20. update()
  21. })
  22. server.use(hotMiddleware(clientCompiler, {
  23. log: false // 关闭它本身的日志输出
  24. }))
  25. // 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
  26. server.use(clientDevMiddleware)

处理模块整体:

  1. const fs = require('fs')
  2. const path = require('path')
  3. const chokidar = require('chokidar') // 监听文件变化的模块
  4. const webpack = require('webpack')
  5. const devMiddleware = require('webpack-dev-middleware')
  6. const hotMiddleware = require('webpack-hot-middleware')
  7. const resolve = file => path.resolve(__dirname, file)
  8. module.exports = (server, callback) => {
  9. let ready
  10. const onReady = new Promise(r => ready = r)
  11. let template
  12. let serverBundle
  13. let clientManifest
  14. const update = () => {
  15. if (template && serverBundle && clientManifest) {
  16. ready()
  17. callback(serverBundle, template, clientManifest)
  18. }
  19. }
  20. const templatePath = resolve('../index.template.html')
  21. template = fs.readFileSync(templatePath, 'utf-8')
  22. update()
  23. chokidar.watch(templatePath).on('change', () => {
  24. template = fs.readFileSync(templatePath, 'utf-8')
  25. update()
  26. })
  27. // 监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
  28. const serverConfig = require('./webpack.server.config')
  29. const serverCompiler = webpack(serverConfig)
  30. const serverDevMiddleware = devMiddleware(serverCompiler, {
  31. logLevel: 'silent', // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  32. })
  33. // 注册 done 钩子事件,在内存中读取文件
  34. serverCompiler.hooks.done.tap('server', () => {
  35. serverBundle = JSON.parse(
  36. serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
  37. )
  38. update()
  39. })
  40. // 监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
  41. const clientConfig = require('./webpack.client.config')
  42. clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin()) // 注册热更新插件
  43. clientConfig.entry.app = [
  44. 'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
  45. clientConfig.entry.app
  46. ]
  47. clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash
  48. const clientCompiler = webpack(clientConfig)
  49. const clientDevMiddleware = devMiddleware(clientCompiler, {
  50. publicPath: clientConfig.output.publicPath,
  51. logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
  52. })
  53. clientCompiler.hooks.done.tap('client', () => {
  54. clientManifest = JSON.parse(
  55. clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
  56. )
  57. update()
  58. })
  59. server.use(hotMiddleware(clientCompiler, {
  60. log: false // 关闭它本身的日志输出
  61. }))
  62. // 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
  63. server.use(clientDevMiddleware)
  64. return onReady
  65. }

编写通用代码

编写”通用”代码时的约束条件 - 即运行在服务器和客户端的代码。由于用例和平台 API 的差异,当运行在不同环境中时,我们的代码将不会完全相同。所以这里我们将会阐述你需要理解的关键事项。

服务器上的数据响应

在纯客户端应用程序 (client-only app) 中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此:每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染 (cross-request state pollution)。

因为实际的渲染过程需要确定性,所以我们也将在服务器上“预取”数据 (“pre-fetching” data) - 这意味着在我们开始渲染时,我们的应用程序就已经解析完成其状态。也就是说,将数据进行响应式的过程在服务器上是多余的,所以默认情况下禁用。禁用响应式数据,还可以避免将「数据」转换为「响应式对象」的性能开销。

组件生命周期钩子函数

由于没有动态更新,所有的生命周期钩子函数中,只有 beforeCreatecreated 会在服务器端渲染 (SSR) 过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMountmounted),只会在客户端执行。

此外还需要注意的是,你应该避免在 beforeCreatecreated 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer。在纯客户端 (client-side only) 的代码中,我们可以设置一个 timer,然后在 beforeDestroy 或 destroyed 生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。为了避免这种情况,请将副作用代码移动到 beforeMountmounted 生命周期中。

访问特定平台(Platform-Specific) API

通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像windowdocument,这种
仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。
对于共享于服务器和客户端,但用于不同平台 API 的任务(task),建议将平台特定实现包含在通用 API
中,或者使用为你执行此操作的 library。例如,axios 是一个 HTTP 客户端,可以向服务器和客户端都
暴露相同的 API。
对于仅浏览器可用的 API,通常方式是,在「纯客户端 (client-only)」的生命周期钩子函数中惰性访问
(lazily access) 它们。
请注意,考虑到如果第三方 library 不是以上面的通用用法编写,则将其集成到服务器渲染的应用程序
中,可能会很棘手。你可能要通过模拟 (mock) 一些全局变量来使其正常运行,但这只是 hack 的做
法,并且可能会干扰到其他 library 的环境检测代码。

自定义组件

大多数自定义指令直接操作 DOM,因此会在服务器端渲染 (SSR) 过程中导致错误。有两种方法可以解决这个问题:

  • 推荐使用组件作为抽象机制,并运行在「虚拟 DOM 层级(Virtual-DOM level)」(例如,使用渲染函数(render function))。
  • 如果你有一个自定义指令,但是不是很容易替换为组件,则可以在创建服务器 renderer 时,使用 directives 选项所提供”服务器端版本(server-side version)”。

路由和代码分割

接下来我们来了解一下如何处理通用应用中的路由。
官方文档给出的解决方案肯定还是使用 vue-router,整体使用方式和纯客户端的使用方式基本一致,只需要在少许的位置做一些配置就可以了。

router 配置

  1. 安装了vue-router后,配置路由规则。

/src/router/index.js:

  1. /**
  2. * 路由模块
  3. */
  4. import Vue from 'vue'
  5. import VueRouter from 'vue-router'
  6. import Home from '@/pages/Home'
  7. Vue.use(VueRouter)
  8. export function createRouter() {
  9. const router = new VueRouter({
  10. mode: 'history', // 同构应用不能使用 hash 路由,应该使用 history 模式
  11. routes: [
  12. {
  13. path: '/',
  14. name: 'home',
  15. component: Home
  16. },
  17. {
  18. path: '/about',
  19. name: 'about',
  20. component: () => import('@/pages/About')
  21. },
  22. {
  23. path: '*',
  24. name: '404',
  25. component: () => import('@/pages/404')
  26. }
  27. ]
  28. })
  29. return router
  30. }
  1. 将路由挂载到 Vue 根实例。

/src/app.js:

  1. import Vue from 'vue'
  2. import App from './App.vue'
  3. import { createRouter } from './router'
  4. import VueMeta from 'vue-meta'
  5. Vue.use(VueMeta)
  6. Vue.mixin({
  7. metaInfo: {
  8. titleTemplate: '%s - 拉勾教育'
  9. }
  10. })
  11. // 导出一个工厂函数,用于创建新的
  12. // 应用程序、router 和 store 实例
  13. export function createApp () {
  14. // 创建 router 实例
  15. const router = createRouter()
  16. const app = new Vue({
  17. // 注入 router 到根 Vue 实例
  18. router,
  19. // 根实例简单的渲染应用程序组件。
  20. render: h => h(App)
  21. })
  22. return { app, router }
  23. }

适配服务端入口

/src/entry-server.js:

  1. /**
  2. * 服务端启动入口
  3. */
  4. import { createApp } from './app'
  5. export default async context => {
  6. const { app, router } = createApp()
  7. // set server-side router's location
  8. router.push(context.url)
  9. // wait until router has resolved possible async components and hooks
  10. await new Promise(router.onReady.bind(router))
  11. // const matchedComponents = router.getMatchedComponents()
  12. return app
  13. }

通用应用 web 服务启动脚本
server.js:

  1. const render = async (req, res) => {
  2. const context = {
  3. url: req.url,
  4. meta: '',
  5. title: '拉勾教育'
  6. }
  7. try {
  8. const html = await renderer.renderToString(context)
  9. res.end(html)
  10. } catch (err) {
  11. res.status(500).end('Internal Server Error')
  12. }
  13. }
  14. server.get('*', isProd
  15. ? render
  16. : async (req, res) => {
  17. // 等待有了 Renderer 渲染器以后,调用 render 进行渲染
  18. await onReady
  19. render(req, res)
  20. }
  21. )

适配客户端入口

  1. /**
  2. * 客户端入口
  3. */
  4. import { createApp } from './app'
  5. // 客户端特定引导逻辑……
  6. const { app, router } = createApp()
  7. // 等到 router 将可能的异步组件和钩子函数解析完
  8. router.onReady(() => {
  9. app.$mount('#app')
  10. })

设置路由出口

App.vue:

  1. <template>
  2. <div id="app">
  3. <ul>
  4. <li>
  5. <router-link to="/">Home</router-link>
  6. </li>
  7. <li>
  8. <router-link to="/about">About</router-link>
  9. </li>
  10. </ul>
  11. <!-- 路由出口 -->
  12. <router-view />
  13. </div>
  14. </template>
  15. <script>
  16. export default {
  17. name: "App",
  18. }
  19. </script>
  20. <style></style>

管理页面 Head 内容

无论是服务端渲染还是客户端渲染,它们都使用的同一个页面模板。
页面中的 body 是动态渲染出来的,但是页面的 head 是写死的,也就说我们希望不同的页面可以拥有自己的 head 内容,例如页面的 title、meta 等内容,所以下面我们来了解一下如何让不同的页面来定制自己的 head 头部内容。
官方文档 这里专门描述了关于页面 Head 的处理,相对于来讲更原生一些,使用比较麻烦。所以这里使用第三方包[vue-meta](https://github.com/nuxt/vue-meta)
Vue Meta 是一个支持 SSR 的第三方 Vue.js 插件,可让你轻松的实现不同页面的 head 内容管理。使用它的方式非常简单,而只需在页面组件中使用 metaInfo 属性配置页面的 head 内容即可。

  1. <template>
  2. ...
  3. </template>
  4. <script>
  5. export default {
  6. metaInfo: {
  7. title: 'My Example App',
  8. titleTemplate: '%s - Yay!',
  9. htmlAttrs: {
  10. lang: 'en',
  11. amp: true
  12. }
  13. }
  14. }
  15. </script>

页面渲染的效果:

  1. <html lang="en" amp>
  2. <head>
  3. <title>My Example App - Yay!</title>
  4. ...
  5. </head>

引入

npm 安装后,在通用入口文件 app.js 中,通过插件的方式注册到 Vue 中。

  1. import VueMeta from 'vue-meta'
  2. Vue.use(VueMeta)
  3. Vue.mixin({
  4. metaInfo: {
  5. titleTemplate: '%s - 拉勾教育' // title 模板
  6. }
  7. })

然后在服务端渲染入口模块中适配 vue-meta:

  1. // entry-server.js
  2. import { createApp } from './app'
  3. export default async context => {
  4. // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  5. // 以便服务器能够等待所有的内容在渲染前,
  6. // 就已经准备就绪。
  7. const { app, router } = createApp()
  8. const meta = app.$meta()
  9. // 设置服务器端 router 的位置
  10. router.push(context.url)
  11. context.meta = meta
  12. // 等到 router 将可能的异步组件和钩子函数解析完
  13. await new Promise(router.onReady.bind(router))
  14. return app
  15. }

最后在模板 html 中注入 meta 信息:

  1. <head>
  2. {{{ meta.inject().title.text() }}}
  3. {{{ meta.inject().meta.text() }}}
  4. </head>

使用

页面组件中设置 metaInfo 对象即可。

  1. <template>
  2. <div>
  3. <h1>HomePage</h1>
  4. </div>
  5. </template>
  6. <script>
  7. export default {
  8. name: 'HomePage',
  9. metaInfo: {
  10. title: '首页'
  11. }
  12. }
  13. </script>
  14. <style scoped>
  15. </style>

数据预取和状态管理

有一个需求:已知有一个数据接口,接口返回一个文章列表数据,我们想要通过服务端渲染的方式来把异步接口数据渲染到页面中。
这个需求看起来很简单,无非就是在页面发请求拿数据,然后在模板中遍历出来,如果是纯客户端渲染的话确实就是这样的,但是想要通过服务端渲染的方式来处理的话就比较麻烦了。
image.png

也就是说要在服务端获取异步接口数据,交给 Vue 组件去渲染。

我们首先想到的肯定是在组件的生命周期钩子中请求获取数据渲染页面,那我们可以顺着这个思路来试一下。

在组件中添加生命周期钩子,beforeCreatecreated,服务端渲染仅支持这两个钩子函数的调用。 然后下一个问题是如何在服务端发送请求?依然使用 axios,axios 既可以运行在客户端也可以运行在服务端,因为它对不同的环境做了适配处理,在客户端是基于浏览器的 XMLHttpRequest 请求对象, 在服务端是基于 Node.js 中的 http 模块实现,无论是底层是什么,上层的使用方式都是一样的。

  1. async created () {
  2. console.log('Posts Created Start')
  3. const { data } = await axios({
  4. method: 'GET',
  5. url: 'https://cnodejs.org/api/v1/topics'
  6. })
  7. this.posts = data.data
  8. console.log('Posts Created End')
  9. }

上面这段代码不会在服务端渲染页面,虽然是在created钩子中调用的,但是服务端不会等待异步操作。也不支持响应式操作,之前已经说过,服务端开始渲染时,我们的应用程序就已经解析完成其状态。也就是说,将数据进行响应式的过程在服务端上是多余的,所以默认情况下禁用。

通过官方文档我们可以看到,官方解决数据预取和状态的核心思路就是把在服务端渲染期间获取的数据存储到 Vuex 容器中, 然后把容器中的数据同步到客户端,这样就保持了前后端渲染的数据状态同步,避免了客户端重新渲染的问题。

首先做的就是安装 Vuex,并且写一个创建 store 的工厂函数。
/src/store/index.js:

  1. import Vue from 'vue'
  2. import Vuex from 'vuex'
  3. import axios from 'axios'
  4. Vue.use(Vuex)
  5. export const createStore = () => {
  6. return new Vuex.Store({
  7. state: () => ({
  8. posts: [] // 文章列表
  9. }),
  10. mutations: {
  11. setPosts (state, data) {
  12. state.posts = data
  13. }
  14. },
  15. actions: {
  16. //
  17. async getPosts ({ commit }) {
  18. const { data } = await axios.get('https://cnodejs.org/api/v1/topics')
  19. commit('setPosts', data.data)
  20. }
  21. }
  22. })
  23. }

接下来将 store 挂载到 Vue 根实例。
/src/app.js:

  1. import Vue from 'vue'
  2. import App from './App.vue'
  3. import { createRouter } from './router'
  4. import VueMeta from 'vue-meta'
  5. import { createStore } from './store'
  6. Vue.use(VueMeta)
  7. Vue.mixin({
  8. metaInfo: {
  9. titleTemplate: '%s - 拉勾教育'
  10. }
  11. })
  12. // 导出一个工厂函数,用于创建新的
  13. // 应用程序、router 和 store 实例
  14. export function createApp () {
  15. // 创建 router 实例
  16. const router = createRouter()
  17. const store = createStore()
  18. const app = new Vue({
  19. // 注入 router 到根 Vue 实例
  20. router,
  21. store, // 把容器挂载到 Vue 根实例中
  22. // 根实例简单的渲染应用程序组件。
  23. render: h => h(App)
  24. })
  25. return { app, router, store }
  26. }

然后在组件中使用 serverPrefetch钩子触发获取文章的 action。serverPrefetch是 Vue SSR 提供的一个特殊的生命周期函数。

  1. <template>
  2. <div>
  3. <h1>Post List</h1>
  4. <ul>
  5. <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
  6. </ul>
  7. </div>
  8. </template>
  9. <script>
  10. import { mapState, mapActions } from 'vuex'
  11. export default {
  12. name: 'PostList',
  13. metaInfo: {
  14. title: 'Posts'
  15. },
  16. data () {
  17. return {
  18. // posts: []
  19. }
  20. },
  21. computed: {
  22. ...mapState(['posts'])
  23. },
  24. // Vue SSR 特殊为服务端渲染提供的一个生命周期钩子函数
  25. serverPrefetch () {
  26. // 发起 action,返回 Promise
  27. // this.$store.dispatch('getPosts')
  28. return this.getPosts()
  29. },
  30. methods: {
  31. ...mapActions(['getPosts'])
  32. },
  33. }
  34. </script>
  35. <style>
  36. </style>

接下来要做的就是把在服务端渲染期间所获取填充到容器中的数据同步到客户端容器中,从而避免两个端状态不一致导致客户端重新渲染的问题。

  • 将容器中的 state 转为 JSON 格式字符串
  • 自动生成代码: window.INITIALSTATE = 容器状态 语句插入模板页面中
  • 客户端通过 window.INITIALSTATE 获取该数据

src/entry-server.js

  1. const { app, router, store } = createApp()
  2. context.rendered = () => {
  3. // 在应用渲染完成以后,服务端 Vuex 容器中已经填充了状态数据
  4. // 这里手动的把容器中的状态数据放到 context 上下文中
  5. // Renderer 在渲染页面模板的时候会把 state 序列化为字符串串内联到页面中
  6. // window.__INITIAL_STATE__ = store.state
  7. context.state = store.state
  8. }

最后在客户端渲染入口中把服务端传递过来的状态数据填充到客户端 Vuex 容器中。
src/entry-client.js:

  1. import { createApp } from './app'
  2. const { app, router, store } = createApp()
  3. if (window.__INITIAL_STATE__) {
  4. store.replaceState(window.__INITIAL_STATE__)
  5. }
  6. router.onReady(() => {
  7. app.$mount('#app')
  8. })