Vue SSR 总结

1,828 阅读9分钟

是什么

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。 服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行

  • 官网文档:ssr.vuejs.org/
  • Vue SSR(Vue.js Server-Side Rendering) 是 Vue.js 官方提供的一个服务端渲染(同构应用)解 决方案
  • 使用它可以构建同构应用
  • 还是基于原有的 Vue.js 技术栈

使用场景

在对你的应用程序使用服务器端渲染 (SSR) 之前,你应该问的第一个问题是,是否真的需要它

技术层面:

  • 更快的首屏渲染速度
  • 更好的 SEO

业务层面:

  • 不适合管理系统
  • 适合门户资讯类网站,例如企业官网、知乎、简书等
  • 适合移动网站

如何实现 Vue SSR

  1. 基于 Vue SSR 官方文档提供的解决方案

官方方案具有更直接的控制应用程序的结构,更深入底层,更加灵活,同时在使用官方方案的过程中, 也会对Vue SSR有更加深入的了解。 该方式需要你熟悉 Vue.js 本身,并且具有 Node.js 和 webpack 的相当不错的应用经验。

  1. Nuxt.js 开发框架

NUXT提供了平滑的开箱即用的体验,它建立在同等的Vue技术栈之上,但抽象出很多模板,并提供了 一些额外的功能,例如静态站点生成。通过 Nuxt.js 可以快速的使用 Vue SSR 构建同构应用。

vue ssr 基本使用

渲染一个vue 实例

目标: 了解如何使用 VueSSR 将一个 Vue 实例渲染为 HTML 字符串

安装包

npm install vue vue-server-renderer

第 1 步:创建一个 Vue 实例

const Vue = require("vue");
const app = new Vue({
 template: `<div>{{ message }}</div>`,
 data: {
  message: "Hello World",
},
});

第 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>
});

data-server-rendered 是将来客户端渲染激活的一个入口

接下来我们要处理的是如何将结果发送给客户端浏览器

与服务器集成

在 Node.js 服务器中使用时相当简单直接,例如 Express。 首先安装 Express 到项目中:

npm i express
// 安装nodemon 实时监听node文件的变化
npm i nodemon -g

然后使用 Express 创建一个基本的 Web 服务:

const express = require("express");
const app = express();
app.get("/", (req, res) => {
 res.send("Hello World!");
});

启动 Web 服务:

nodemon index.js

在web服务中渲染Vue实例:

// 创建Vue实例
const Vue = require('vue') 
// 创建一个renderer
const renderer = require('vue-server-renderer').createRenderer()
const express = require('express')
const server = express()
server.get('/', (req, res) => {
  const app = new Vue({
    template: `
      <div id='app'>{{ message }}</div>
    `,
    data: {
      message: '拉勾教育'
    }
  })
  // 将vue实例传入Redenrer
  renderer.renderToString(app, (err, html) => {
    if(err) {
      return res.status(500).end('Internal Serve Error')
    }
    // console.log(html)
    // 响应头中添加响应内容类型以及编码
    res.setHeader('Content-Type', 'text/html;charset=utf8')
    // 使用页面模板
    res.end(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      ${html}
    </body>
    </html>
    `)
    // res.end(html)
  })
})
// 启动服务
server.listen(3000, () => {
  console.log('后端服务启动成功')
})

上面的代码中页面模板,我们可以抽取出来,放到一个单独的文件中进行管理和维护

在项目根文件下新建一个文件,index.template.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <!--vue-ssr-outlet-->
</body>

</html>

vue-ssr-outlet,这断注释比较特殊

  • 用来占位的一个标记,会由具体的内容来替换
  • 左右不能有空格

renderer渲染器读取模板文件

// 引入文件操作模块
const fs = requeire('fs')
// 创建一个renderer
// 读取页面模板
const renderer = require('vue-server-renderer').createRenderer({
  // 需要指定编码, 否则会报错 “TypeError: template.head is not a function”
  template: fs.readFileSync('./index.template.html', 'utf-8')
})

renderToString的参数接收的html,模板文件中占位标记处被替换成Vue 实例中的模板

  // 将vue实例传入Redenrer
  renderer.renderToString(app, (err, html) => {
    if(err) {
      return res.status(500).end('Internal Serve Error')
    }
    // console.log(html)
    // 响应头中添加响应内容类型以及编码
    res.setHeader('Content-Type', 'text/html;charset=utf8')
    // 使用页面模板
    res.end(html)
    // res.end(html)
  })

页面模板使用来自外部的数据,我们的做法是:

renderer.renderToString() ,第二个参数传入模板的数据

  // 将vue实例传入Redenrer
  // 第二个参数是传入模板的数据
  renderer.renderToString(app, {title: '造梦工厂'},(err, html) => {
    if(err) {
      return res.status(500).end('Internal Serve Error')
    }
    // console.log(html)
    // 响应头中添加响应内容类型以及编码
    res.setHeader('Content-Type', 'text/html;charset=utf8')
    // 使用页面模板
    res.end(html)
    // res.end(html)
  })

页面模板文件中使用外部传入的数据

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- 使用外部传入的数据 -->
  <title>{{title}}</title>
</head>

<body>
  <!--vue-ssr-outlet-->
</body>

</html>

结果可见: 外部传入的数据生效了

以html标签的形式给模板传入数据:

renderer.renderToString(app, {
    title: '造梦工厂',
    //  是一个html标签
    meta: `<meta name="description" content="IT 社会大学,高薪造梦摇篮">`
  },(err, html) => {
    if(err) {
      return res.status(500).end('Internal Serve Error')
    }
    // console.log(html)
    // 响应头中添加响应内容类型以及编码
    res.setHeader('Content-Type', 'text/html;charset=utf8')
    // 使用页面模板
    res.end(html)
    // res.end(html)
  })

页面模板文件中需要使用如下形式的写法,才能按照原有的html 语法输出,否则是以字符串的形式原样输出

// 错误使用
{{ meta }}

{{{ meta }}}

目标结果:

构建同构渲染

前置案例

我们创建一个案例,在服务端进行渲染,并使用v-model进行双向绑定,给按钮绑定了click事件

// ----02-server.js-----
// 创建Vue实例
const Vue = require('vue') 
// 引入文件操作模块
const fs = require('fs')
// 创建一个renderer
// 读取页面模板
const renderer = require('vue-server-renderer').createRenderer({
  template: fs.readFileSync('./index.template.html', 'utf-8')
})
const express = require('express')
const server = express()
server.get('/', (req, res) => {
  const app = new Vue({
    template: `
      <div id='app'>
      <h1>{{ message }}</h1>
      <h2>客户端动态交互</h2>
      <div>
        <input v-model="message"/>
      </div>
      <button @click="onClick">点击测试</button>
      </div>
    `,
    data: {
      message: '拉勾教育'
    },
    methods: {
      onClick () {
        console.log('Hello World!')
      }
    }
  })
  // 将vue实例传入Redenrer
  // 第二个参数是传入模板的数据
  renderer.renderToString(app, {
    title: '造梦工厂',
    //  是一个html标签
    meta: `<meta name="description" content="IT 社会大学,高薪造梦摇篮">`
  },(err, html) => {
    if(err) {
      return res.status(500).end('Internal Serve Error')
    }
    // console.log(html)
    // 响应头中添加响应内容类型以及编码
    res.setHeader('Content-Type', 'text/html;charset=utf8')
    // 使用页面模板
    res.end(html)
    
  })
})
// 启动服务
server.listen(5000, () => {
  console.log('后端服务启动成功,端口号5000')
})

运行这个服务,结果显示: 动态操作:

  • 在输入框中输入,目前结果并没有实现发生双向绑定
  • 点击按钮没有触发事件

以上结果说明目前在客户端只是渲染了服务端返回的html,客户端动态交互的功能并没有提供

构建流程

源码结构

一个项目可能的基本结构

src
├── components
│  ├── Foo.vue
│  ├── Bar.vue
│  └── Baz.vue
├── App.vue
├── app.js # 通用 entry(universal entry)
├── entry-client.js # 仅运行于浏览器
└── entry-server.js # 仅运行于服务器

APP.vue

<template>
  <div id='app'>
      <h1>{{ message }}</h1>
      <h2>客户端动态交互</h2>
      <div>
        <input v-model="message"/>
      </div>
      <button @click="onClick">点击测试</button>
      </div>
</template>

<script>
export default {
 data () {
      return { message: '拉勾教育'}
    },
methods: {
  onClick () {
    console.log('Hello World!')
  }
}
}
</script>

app.js

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

import Vue from 'vue'
import App from './App.vue'

// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
  const app = new Vue({
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  return { app }
}

entry-client.js

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

import { createApp } from './app'

// 客户端特定引导逻辑……

const { app } = createApp()

// 这里假定 App.vue 模板中根元素具有 `id="app"`
app.$mount('#app')

entry-server.js

服务器 entry 使用 default export 导出函数,并在每次渲染中重复调用此函数。此时,除了创建和返回应用程序实例之外,它不会做太多事情

import { createApp } from './app'

export default context => {
  const { app } = createApp()
  return app
}

构建配置

安装依赖

  1. 安装生产依赖
npm i vue vue-server-renderer express cross-env

2. 安装开发依赖

npm i -D webpack webpack-cli webpack-merge webpack-node-externals @babel/core
@babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader url-
loader file-loader rimraf vue-loader vue-template-compiler friendly-errors-
webpack-plugin

配置文件及打包

  1. 初始化 webpack 打包配置文件
build
├── webpack.base.config.js # 公共配置
├── webpack.client.config.js # 客户端打包配置文件
└── webpack.server.config.js # 服务端打包配置文件

webpack.base.config.js

/**
* 公共配置
*/
const VueLoaderPlugin = require('vue-loader/lib/plugin')
const path = require('path')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
const resolve = file => path.resolve(__dirname, file)
const isProd = process.env.NODE_ENV === 'production'
module.exports = {
 mode: isProd ? 'production' : 'development',
 output: {
  // 打包结果输出路径
  path: resolve('../dist/'),
  publicPath: '/dist/',
  filename: '[name].[chunkhash].js'
},
 resolve: {
  alias: {
   // 路径别名,@ 指向 src
   '@': resolve('../src/')
 },
  // 可以省略的扩展名
  // 当省略扩展名的时候,按照从前往后的顺序依次解析
  extensions: ['.js', '.vue', '.json']
},
 devtool: isProd ? 'source-map' : 'cheap-module-eval-source-map',
 module: {
  rules: [
   // 处理图片资源
  {
    test: /\.(png|jpg|gif)$/i,
    use: [
    {
      loader: 'url-loader',
      options: {
       limit: 8192,
     },
    },
   ],
  },
   // 处理字体资源
  {
    test: /\.(woff|woff2|eot|ttf|otf)$/,
    use: [
     'file-loader',
   ],
  },
   // 处理 .vue 资源
  {
    test: /\.vue$/,
    loader: 'vue-loader'
  },
   // 处理 CSS 资源
   // 它会应用到普通的 `.css` 文件
   // 以及 `.vue` 文件中的 `<style>` 块
  {
    test: /\.css$/,
    use: [
     'vue-style-loader',
     'css-loader'
   ]
  },
  
   // CSS 预处理器,参考:https://vue-loader.vuejs.org/zh/guide/pre-processors.html
   // 例如处理 Less 资源
   // {
   //  test: /\.less$/,
   //  use: [
   //   'vue-style-loader',
   //   'css-loader',
   //   'less-loader'
   //  ]
   // },
 ]
},
 plugins: [
  new VueLoaderPlugin(),
  new FriendlyErrorsWebpackPlugin()
]
}

webpack.client.config.js

/**
* 客户端打包配置
*/
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
module.exports = merge(baseConfig, {
 entry: {
  app: './src/entry-client.js'
},
 module: {
  rules: [
   // ES6 转 ES5
  {
    test: /\.m?js$/,
    exclude: /(node_modules|bower_components)/,
    use: {
     loader: 'babel-loader',
     options: {
      presets: ['@babel/preset-env'],
      cacheDirectory: true,
      plugins: ['@babel/plugin-transform-runtime']
    }
   }
  },
 ]
},
 // 重要信息:这将 webpack 运行时分离到一个引导 chunk 中,
 // 以便可以在之后正确注入异步 chunk。
 optimization: {
  splitChunks: {
   name: "manifest",
   minChunks: Infinity
 }
},
 plugins: [
  // 此插件在输出目录中生成 `vue-ssr-client-manifest.json`。
  new VueSSRClientPlugin()
]
})

webpack.server.config.js

/**
* 服务端打包配置
*/
const { merge } = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(baseConfig, {
 // 将 entry 指向应用程序的 server entry 文件
 entry: './src/entry-server.js',
 // 这允许 webpack 以 Node 适用方式处理模块加载
 // 并且还会在编译 Vue 组件时,
 // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
 target: 'node',
 output: {
  filename: 'server-bundle.js',
  // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
  libraryTarget: 'commonjs2'
},
 // 不打包 node_modules 第三方包,而是保留 require 方式直接加载
 externals: [nodeExternals({
  // 白名单中的资源依然正常打包
  allowlist: [/\.css$/]
})],
 plugins: [
  // 这是将服务器的整个输出构建为单个 JSON 文件的插件。
  // 默认文件名为 `vue-ssr-server-bundle.json`
 new VueSSRServerPlugin()
]
})
  1. 在 npm scripts 中配置打包命令
"scripts": {
 "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
 "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
 "build": "rimraf dist && npm run build:client && npm run build:server"
},

运行测试:

npm run build:client
npm run build:server
npm run build

由于安装webpack的时候默认安装了当前的最新版本 webpack5 的 版本与vue-server-renderer兼容性的问题,会遇到如下的问题: 此时,只有将 webpack 的版本降低来处理这个问题 webpack 的版本:

启动应用

创建server.js:

// ----02-server.js-----
// 创建Vue实例
const Vue = require('vue') 
// 引入文件操作模块
const fs = require('fs')
// 引入bundleRenderer
const { createBundleRenderer } = require('vue-server-renderer')

const serverBundle = require('./dist/vue-ssr-server-bundle.json')
// 模板
const template = fs.readFileSync('./index.template.html', 'utf-8')
// 客户端资源清单
const clientManifest = require('./dist/vue-ssr-client-manifest.json')

const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest
})
const express = require('express')
const server = express()
// 使用express 的中间件,对外开启 /dist 的请求
server.use('/dist', express.static('./dist'))
server.get('/', (req, res) => {
  renderer.renderToString({ // 传入模板的数据
    title: '造梦工厂',
    //  是一个html标签
    meta: `<meta name="description" content="IT 社会大学,高薪造梦摇篮">`
  },(err, html) => {
    if(err) {
      return res.status(500).end('Internal Serve Error')
    }
    // console.log(html)
    // 响应头中添加响应内容类型以及编码
    res.setHeader('Content-Type', 'text/html;charset=utf8')
    // 使用页面模板
    res.end(html)
    // res.end(html)
  })
})
// 启动服务
server.listen(5000, () => {
  console.log('后端服务启动成功,端口号5000')
})

注意点:

使用express 的中间件,对外开启 /dist 的请求,若是没有开启,则访问不到以/dist开头的请求

运行启动应用:

nodemon ./server.js

运行结果:

我们的应用是一个同构应用,不仅拥有首屏渲染直出,而且还拥有了客户端交互的能力

解析渲染流程

到目前为止我们的引用已经具备了首屏渲染直出和客户端的交互能力,那么它到底是怎么实现这个功能呢?接下来我们一起通过以下两个层面来探讨:

  1. 服务端渲染,是如何来渲染生成html页面?
  2. 客户端渲染,是如何接管服务端的html,并且激活交互能力?

服务端渲染,是如何来渲染生成html页面

  • 客户端发起请求,匹配到后端对应的接口
  • renderer.renderToString 根据将要生成的页面模板,渲染Vue实例
server.get('/', (req, res) => {
  renderer.renderToString({ // 传入模板的数据
    title: '造梦工厂',
    //  是一个html标签
    meta: `<meta name="description" content="IT 社会大学,高薪造梦摇篮">`
  },(err, html) => {
    if(err) {
      return res.status(500).end('Internal Serve Error')
    }
    // console.log(html)
    // 响应头中添加响应内容类型以及编码
    res.setHeader('Content-Type', 'text/html;charset=utf8')
    // 使用页面模板
    res.end(html)
    // res.end(html)
  })
})
  • 上面代码中并没有出现和Vue实例相关的代码,那么Vue实例在哪里呢?

    • redenrer 是由createBundleRenderer 对 serverBundle 处理生成得到
    const renderer = createBundleRenderer(serverBundle, {
      template,
      clientManifest
    })
    
    • serverBundle引用了服务端打包生成的结果
    const serverBundle = require('./dist/vue-ssr-server-bundle.json')
    
    • vue-ssr-server-bundle.json 这个文件是webpack 通过 VueSSRServerPlugin 这个插件将打包结果转换成json的格式

    • server-bundle.js 是entry-server.js 打包的结果

    • entry-server.js 导入了app.js

    • app.js 中创建了Vue 实例

    • App.vue 中是要渲染到页面上的模板

到此,我们已经从客户端的请求开始,分析了一遍后端的渲染过程,那么接下来我们来看看客户端的吧

客户端渲染,是如何接管服务端的html,并且激活交互能力

首先我们来看下服务端返回给客户端的数据:

在这里,自动注入了客户端打包结果文件了。但是又是怎么知道这里要注入客户端打包的结果呢?

我们来看代码:

const renderer = createBundleRenderer(serverBundle, {
  template,
  clientManifest
})
  • 成渲染器的时候,传入了clientManifest

  • clientManifest 是通过引入vue-ssr-client-manifest.json 得到的一份客户端资源清单

  • vue-ssr-client-manifest.json 是客户端打包结果的JSON 文件

    • publicPath 对应的是在webpack.base.config.js 中配置的output.publicPath 。表示所有的资源请求都要加上前缀"/dist"
    • 客户端打包构建出来的所有的资源文件名称
    • initial 就是指定在服务端返回的html 的后面加入的客户端脚本文件
    • async 异步加载的异步组件,异步模块等异步信息就会存放在这里
    • modules 对原始模块的依赖信息的说明,将模块的名称转换成了hash,0 和 1 表示该模块引用的资源的清单,对应的是 all 中所在的索引值

到此,虽然已经完成了客户端脚本的注入了,但是还需要对其进行客户端激活。参考 官方文档

构建开发模式

我们现在已经实现同构应用的基本功能了,但是这对于一个完整的应用来说还远远不够,例如如何处理 同构应用中的路由、如何在服务端渲染中进行数据预取等功能。这些功能我们都会去对它进行实现,但 是在实现它们之前我们要先来解决一些关于打包的问题:

  • 每次写完代码,都要重新打包构建
  • 重新启动 Web 服务

所以下面我们来实现项目中的开发模式构建,也就是我们希望能够实现:

  • 写完代码,自动构建
  • 自动重启 Web 服务
  • 自动刷新页面内容

基本思路

  • 生产模式

    • npm run build 构建
    • node server.js 启动应用
  • 开发模式

    • 监视代码变动自动构建,热更新等功能
    • node server.js 启动应用

启动脚本:

"scripts": {
 ...
 // 启动开发服务
 "dev": "node server.js",
 // 启动生产服务
 "start": "cross-env NODE_ENV=production && node server.js"
}

区分模式

由于在生产模式下,我们可以直接使用打包生成的结果,而开发模式下需要去监听每次文件变化后新的构建结果。 因此,我们需要根据当前运行模式,来做不同的处理,

//  运行环境
const isProd = process.env.NODE_ENV === 'production'
let renderer
if (isProd) {
  // 引用了服务端打包生成的结果
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  // 模板
  const template = fs.readFileSync('./index.template.html', 'utf-8')
  // 客户端资源清单
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  renderer = createBundleRenderer(serverBundle, {
    template,
    clientManifest
  })
} else {
  // 开发模式-》监听打包构建 -》重新生成渲染器Renderer

}

当用户请求过来的时候,对应的路由接口处理函数,对应修改为:

const render = (req, res) => {
  renderer.renderToString({ // 传入模板的数据
    title: '造梦工厂',
    //  是一个html标签
    meta: `<meta name="description" content="IT 社会大学,高薪造梦摇篮">`
  },(err, html) => {
    if(err) {
      return res.status(500).end('Internal Serve Error')
    }
    // console.log(html)
    // 响应头中添加响应内容类型以及编码
    res.setHeader('Content-Type', 'text/html;charset=utf8')
    // 使用页面模板
    res.end(html)
    // res.end(html)
  })
}

server.get('/', isProd
 // 开发模式的渲染器Rederer已经生成,可以直接渲染
  ? render
  : (req, res) => {
    // TODO: 开发模式要等到生成渲染器之后再进行渲染
    render(req, res)
  } )

构建配置-基本思路

我们按照 开发模式-》监听打包构建 -》重新生成渲染器Renderer 现在需要处理处理监听打包构建

基本思路

我们期望有需要一个setupDevServer 函数在重新打包构建之后,生成渲染器Renderer

setupDevServer(server, (serverBundle, template, clientManifest) => {
    renderer = createBundleRenderer(serverBundle, {
      template,
      clientManifest
    })
  })
  • 第一参数server 是用于处理中间件
  • 第二个参数是一个回调函数,当监听到新发起的打包构建完成之后会被调用生成渲染器Rederer

生成渲染器之后,我们需要对要返回客户端的内容进行渲染了,那么路由中的接口处理函数要怎么知道已经生成渲染器了呢?我们可以借助 Promise 对象来实现:

// 此处声明一个Promise对象
let onReady
// setupDevServer 返回Promise对象
  onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
    renderer = createBundleRenderer(serverBundle, {
      template,
      clientManifest
    })
  })

使用async/await,异步方式等待渲染器生成之后,进行渲染

const render = (req, res) => {
  renderer.renderToString({ // 传入模板的数据
    title: '造梦工厂',
    //  是一个html标签
    meta: `<meta name="description" content="IT 社会大学,高薪造梦摇篮">`
  },(err, html) => {
    if(err) {
      return res.status(500).end('Internal Serve Error')
    }
    // console.log(html)
    // 响应头中添加响应内容类型以及编码
    res.setHeader('Content-Type', 'text/html;charset=utf8')
    // 使用页面模板
    res.end(html)
  })
}
server.get('/', isProd
  // 开发模式的渲染器Rederer已经生成,可以直接渲染
  ? render
  : async (req, res) => {
    // TODO: 开发模式要等到生成渲染器之后再进行渲染
    await onReady
    render(req, res)
  } )

上面代码段中,第一个render 在生产模式下被调用,直接传入 res, req 两个参数。而第二个render函数,外面包了一层,因此需要传入res, req 两个参数

setupDevServer的具体实现

上面我们只是根据我们的目的,假设了setupDevServer 的存在,接下来我们来具体实现一下。 在vue-ssr/build 下新建一个文件 setup-dev-server.js

module.exports = (server, cb) => {
 let onReady = new Promise()
//   监听打包 =》 获取打包结果 =》 调用回调cb => 更新Renderer 渲染器

 return onReady
}

update更新函数

我们定义一个update函数,用来调用回调更新Renderer渲染器

module.exports = (server, cb) => {
 let ready
 let onReady = new Promise(r => ready = r)
//  监听打包 =》 获取打包结果 =》 调用回调cb,更新Renderer 渲染器
let serverBundle, template, clientManifest
// 定义一个更新函数,当监听的文件都重新构建完之后调用回调函数cb
const update = () => {
  if (template && serverBundle && clientManifest) {
     // 调用promise的resolve() 
    ready() 
    cb(serverBundle, template, clientManifest)
  }
 
}
// 监视template =》 调用update =》 更新Renderer渲染器
// 监视serverBundle =》 调用update =》 更新Renderer渲染器
// 监视clientmanifest =》 调用update =》 更新Renderer渲染器
 return onReady
}

监听template构建

需要安装一个第三方模块chokidar,用于监听文件的变化,参考官方文档

npm install chokidar

代码实现:

// 监视template =》 调用update =》 更新Renderer渲染器
// 读取template 模板文件
const templatePath = path.resolve(__dirname, '../index.template.html')
template = fs.readFileSync(templatePath, 'utf-8')
update()
// 监听tempalte的变化
// 原本可以使用fs.watch或 fs.watchFile ,但推荐使用使用chokidar 
chokidar.watch(templatePath).on('change', () => {
  template = fs.readFileSync(templatePath, 'utf-8')
  console.log('tempalte change')
  update()
})

执行脚本命令 npm run dev,然后修改模板文件,结果我们发现,确实可以实时监听到模板文件的变化了

监听服务端构建

// 监视serverBundle =》 调用update =》 更新Renderer渲染器
const serverConfig = require('./webpack.server.config')
const serverCompiler =  webpack(serverConfig)
// 监听server 配置文件的变化,第一个参数不可省略
serverCompiler.watch({},(error, stats) => {
  if(error) throw error  // 配置文件出现错误
  if (stats.hasErrors()) return // 代码中出现错误,直接中断
 
  // 读取改变后的构建生成文件,使用require 会有缓存
  serverBundle = JSON.parse(fs.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'),'utf-8'))
  update()
  console.log(serverBundle)
})

构建结果写入内存

webpack 在打包构建的时候默认会把结果文件存储到本地磁盘中,在生产模式下当然是没问题的。但是在开发模式下,我们要频繁的去读取磁盘中的数据,而磁盘中的数据相对来说是比较慢的。因此,在开发模式中,我们可以把构建结果放到内存中,来进行读写的操作,这样的话就可以极大的提高构建的速度

webpack给我们提供了以下的方式来实现我们的这个目的

  • 可以使用 memory-fs 替换默认的 outputFileSystem,以将文件写入到内存中。参考官网
  • 还可以使用 webpack-dev-middleware 这个中间件 官方文档

接下来我们使用webpack-dev-middleware 来演示:

  • 安装依赖
npm install webpack-dev-middleware --save-dev

代码实现:

// 
const middleware = require('webpack-dev-middleware');
// 监视serverBundle =》 调用update =》 更新Renderer渲染器
const serverConfig = require('./webpack.server.config')
const serverCompiler =  webpack(serverConfig)
// 监听配置文件
const devServerMiddleware = middleware(serverCompiler, {
  logLevel: 'silent' ,// 关闭默认的日志,由FriendlyErrorsWebpackPlugin输出日志
})
// 构建结束后,读取内存中的构建结果
serverCompiler.hooks.done.tap('server', () => {
  serverBundle = JSON.parse(
    devServerMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'),'utf-8')
  )
  update()
  console.log(serverBundle)
})

监听客户端构建

const clientConfig = require('./webpack.client.config')
const clientCompiler = webpack(clientConfig)
const clientMiddleWare = middleware(clientCompiler, {
  logLevel: 'silent',
})
clientCompiler.hooks.done.tap('client', () => {
  clientManifest = JSON.parse(
    clientMiddleWare.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'),'utf-8')
  )
  update()
  console.log(clientManifest)
})

到此,我们已经完成了需要的页面模板,服务端配置、客户端配置三个文件的监听构建了,我们启动应用 可以看到正常访问了。我们修改一下App.vue

结果可以看见,在没有重新启动服务的情况下,刷新页面,页面也随之更新了。但是,还需要手动,还是LOW ,接下来我们来添加热更新的功能

此时,我们尝试着更改输入框中的内容:

我们可以发现控制台中出现404报错,客户端的双向绑定没有生效

// 重要!! 将clientMiddleWare 挂载到express服务中,提供对其内部中内存数据的访问
server.use(clientMiddleWare)

热更新 webpack-hot-middleware

在这里我们借助webpack-hot-middleware 这个模块来实现热更新,具体的使用参考官网

  • 将这个插件添加到webpack中去
// 添加插件
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
  • webpack-hot-middleware/client 将这个添加到打包的入口数组中
clientConfig.entry.app = [
  'webpack-hot-middleware/client', // 和服务端一起处理热更新的一个客户端的脚本
  clientConfig.entry.app
]

现在启动,发现报错了:

原因是,热更新模式下输出的文件名称要保持不变,因此不能使用hash来命名输出文件

解决方法:

clientConfig.output.filename = '[ name ].js' // 热更新模式下输出的文件名称要保持不变,因此不能使用hash来命名输出文件

再次启动服务,多了一个 _webpack.hmr, 说明我们已经成功配置了热更新了

在更新之后输出日志,我们可以关闭

关闭方法:添加 ?quiet=true

clientConfig.entry.app = [
  'webpack-hot-middleware/client?quiet=true&&reload=true', // 和服务端一起处理热更新的一个客户端的脚本
  clientConfig.entry.app
]

编写通用代码

前面都只是基础的配置, 现在我们要开始真正的编写SSR渲染通用代码了,不过在编写通用代码前,由于我们做的是同构应用,既有服务端,又有客户端,它们分别运行在node 和 浏览器平台上,而不同的平台有各自的API。 因此,我们需要了解一下相关的约束条件。 具体可以参考官网-编写通用代码

服务端上的数据响应

  • 服务端上的数据都是提前预取的,开始渲染时程序已经完成状态的解析
  • 服务端数据无需对其进行响应式处理

组件生命钩子函数

  • 在服务端只有beforeCreate 和 created 会被调用,其他的只能在客户端被调用

路由处理配置VueRouter

安装路由模块vue-router:

npm i vue-router

向Vue中注册vue-router,对外暴露创建VueRouter 对象 的方法

// src/router/index.js

import Vue from 'vue'
import Router from 'vue-router'
import Home from '../pages/Home'
Vue.use(Router)

export function createRouter () {
  return new Router({
    mode: 'history', // 服务端只能使用history模式
    routes: [
      {
        path: '',
        component: Home
      }, 
      {
        path: '/about',
        component: () => import('../pages/About')
      },
      {
        path: '*',
        component: () => import('../pages/404')
      }
    ]
  })
}

在程序的通用入口文件中,将VueRouter 注入到Vue 实例中。要记得将router暴露给外部!!!

entry-server.js 中实现服务端路由逻辑 ,官网给出的示例:

我们基于官网的例子,使用async/await 的方式,简化代码:

// 使用async/await
export default async context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,
    // 就已经准备就绪。
    const { app, router } = createApp()
    console.log(context.url)
    // 设置服务器端 router 的位置
    router.push(context.url)

    
    // new Promise(( resolve, reject) => {
    //   router.onReady(resolve, reject)
    // })
    // 上面代码直接修改为
    // 由于router.onReady() 完全接收了外层传入的参数
    // new Promise(router.onReady)  onReady 内部的this会被修改,改造一下
    // 等到 router 将可能的异步组件和钩子函数解析完
    await new Promise(router.onReady.bind(router))

    return app

}

在这里我们设置了服务器端router的位置

// 设置服务器端 router 的位置
    router.push(context.url)

那我们也需要在server.js 对应着设置context.url 的值

除了这里需要修改,我们还需要将服务端路由设置为 *,意味着所有的路由都能进入这里

到这里我们的路由配置已经完成了,我们修改一下我们得App.vue 页面示例代码:

<template>
  <div id='app'>
      <h1>{{ message }}</h1>
      <router-link :to="'/'" >Home</router-link>
      <router-link :to="'/about'" >About</router-link>
      <!--路由出口--->
      <router-view></router-view>
  </div>
</template>

<script>
export default {
 data () {
      return { message: '拉勾互联网大学'}
    },
methods: {
  onClick () {
    console.log('Hello World!')
  }
}
}
</script>

<style>

</style>

我们以开发模式运行脚本npm run dev,启动应用

页面也正常显示出来了,我们F12打开谷歌浏览器开发人员工具,打开Element面板,观察图中指定的位置

接着我们再打开Network 网络面板

我们可以发现:

  • js 文件通过使用link 的方式预加载的,网络面板中也确实看到这三个文件的资源请求
  • 只有采用 rel= preload 的app.js ,被浏览器导入使用

这时候,你心中肯定充满了疑问,

  • 同时下载这么多js 文件,不是会造成界面渲染的阻塞,造成卡顿 答案肯定是不会的,那是为什么呢?我们点击网页中的 About:

此时,页面切换到About 页面,在head 中 1.js也被导入进来了。因此我们知道了,js 等资源虽然被提前请求下载下来了,但是除了app.js 程序主入口被导入了使用,其他的并没有,只有在路由切换到对应的模块的时候,才会从缓存中去读取导入,不会造成页面渲染的阻塞

管理Head

在模板文件index.template.html 中的body是动态传入的,而要怎么实现让不同的页面来定制属于自己的head 中的内容呢? 在官网中提供Head的管理方法,可以移步Vue官网查看。 官网提供的方法配置比较繁琐,在这里推荐vue-meta。接下来,我们一起来使用一下这个模块 安装模块:

npm install vue-meta --save

在通用入口app.js 注册vue-meta到Vue实例中,并配置公用的头部

import VueMeta from 'vue-meta'

Vue.use(VueMeta)
// 当在页面提供标题之后,会在 %s 那边渲染出来
Vue.mixin({
  metaInfo: {
    titleTemplate: '%s-天天撸码'
  }
})

在entry-server.js中向上下文中添加vue-meta

export default async context => {
    const { app, router } = createApp()
    const meta = app.$meta()
    
    // 设置服务器端 router 的位置
    router.push(context.url)
    context.meta = meta

    // 等到 router 将可能的异步组件和钩子函数解析完
    await new Promise(router.onReady.bind(router))

    return app

}

在页面模板文件index.template.html中注入元数据信息

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- 使用外部传入的数据 -->
  {{{ meta.inject().title.text() }}}
  {{{ meta.inject().meta.text() }}}
</head>

在首页页面中配置title:

name: 'Home',
  metaInfo: {
    title: '首页'
  }
  

启动应用,观察结果:

我们可以看到结果是我们想要的了 在上面的示例中我们只用到了titleTemplate,我们还可以查阅vue-meta的文档,使用其它更多的属性。具体链接可查阅文档

数据预期和状态

场景导入

官方文档中的描述比较枯燥,无法在很短的时间内搞清楚它到底要做什么,所以我们这里通过一个实际 的业务需求来引入这个话题。 我们的需求就是:

  • 已知有一个数据接口,接口返回一个文章列表数据
  • 我们想要通过服务端渲染的方式来把异步接口数据渲染到页面中

这个需求看起来是不是很简单呢?无非就是在页面发请求拿数据,然后在模板中遍历出来,如果是纯客 户端渲染的话确实就是这样的,但是想要通过服务端渲染的方式来处理的话就比较麻烦了。 无论如何,我们都要来尝试一下:

也就是说我们要在服务端获取异步接口数据,交给 Vue 组件去渲染。 我们首先想到的肯定是在组件的生命周期钩子中请求获取数据渲染页面,那我们可以顺着这个思路来试 一下。 在组件中添加生命周期钩子,beforeCreate 和 created,服务端渲染仅支持这两个钩子函数的调用。 然后下一个问题是如何在服务端发送请求?依然使用 axios,axios 既可以运行在客户端也可以运行在 服务端,因为它对不同的环境做了适配处理,在客户端是基于浏览器的 XMLHttpRequest 请求对象, 在服务端是基于 Node.js 中的 http 模块实现,无论是底层是什么,上层的使用方式都是一样的。

我们准备了一个演示的例子,异步请求realworld 的接口的文章数据

<template>
  <div>
    <h1>Post List</h1>
    <ul> 
      <li v-for="item in posts" :key="item.slug" >
        {{ item.title }}
      </li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios'
export default {
  name: 'Post',
  data () {
    return {
      posts:[]
    }
    
  },
  // 服务端渲染
//   只支持 beforeCreate 和 created
//   不会等待 beforeCreate 和 created 中的异步操作
//   不支持响应式数据
async created () {
   const { data } = await axios({
    method: 'GET',
    url: 'https://conduit.productionready.io/api/articles'
   })
   this.posts = data.articles
}
}
</script>

从结果中可以看到,虽然渲染出了文章列表了,但是在服务端返回的数据中,并没有文章的列表,页面上的文章列表的数据是客户端渲染出来的

数据存储容器

接下来我们就按照官方文档给出的参考来把服务端渲染中的数据预取以及状态管理来处理一下。 通过官方文档我们可以看到,它的核心思路就是把在服务端渲染期间获取的数据存储到 Vuex 容器中, 然后把容器中的数据同步到客户端,这样就保持了前后端渲染的数据状态同步,避免了客户端重新渲染 的问题。

安装Vuex

npm i vuex

创建 Vuex 容器:

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
Vue.use(Vuex)
export const createStore = () => {
 return new Vuex.Store({
  state: {
   posts: [] // 文章列表
 },
  mutations: {
   // 修改容器状态
   setPosts (state, data) {
    state.posts = data
  }
 },
  actions: {
   async getPosts ({ commit }) {
    const { data } = await axios({
     method: 'GET',
     url: 'https://cnodejs.org/api/v1/topics'
   })
    commit('setPosts', data.data)
  }
 }
})
}

在通用应用入口中将 Vuex 容器挂载到 Vue 根实例:

/**
 * 应用程序的通用入口
 */
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
import VueMeta from 'vue-meta'

Vue.use(VueMeta)
// 当在页面提供标题之后,会在 %s 那边渲染出来
Vue.mixin({
  metaInfo: {
    titleTemplate: '%s-天天撸码'
  }
})
// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
  const router = createRouter()
  const store = createStore()
  const app = new Vue({
    // 注入到Vue 根实例上
    router,
    // 把容器挂载到Vue 根实例上
    store,
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  // 返回 app store 和 router
  return { app, router, store }
}

在组件中使用 serverPrefetch 触发容器中的 action

<template>
  <div>
    <h1>Post List</h1>
    <ul> 
      <li v-for="item in posts" :key="item.slug" >
        {{ item.title }}
      </li>
    </ul>
  </div>
</template>

<script>
// import axios from 'axios'
import { mapState, mapActions} from 'vuex'
export default {
  name: 'Post',
  data () {
    return {
      // posts:[]
    }
  },
  computed: {
    ...mapState(['posts'])
  },
  // 服务端预渲染的生命周期钩子函数,数据获取完成后才会渲染
  serverPrefetch () {
    return this.getPosts()
  },
  methods: {
    ...mapActions(['getPosts'])
  }
  
 }
}
</script>

这次结果是:

文章列表会闪一下又消失不见,打开控制台的网络面板可以看见服务端已经完成数据的预取并返回给客户端,可是容器中的状态此时还没有同步给客户端。接下来处理这个问题

在服务端渲染应用入口中将容器状态序列化到页面中:

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

  • 将容器中的 state 转为 JSON 格式字符串
  • 生成代码: window.__INITIAL__STATE = 容器状态 语句插入模板页面中
  • 【客户端通过 window.__INITIAL__STATE 获取该数据】
context.rendered = () => {
 // 在应用渲染完成以后,服务端 Vuex 容器中已经填充了状态数据
 // 这里手动的把容器中的状态数据放到 context 上下文中
 // Renderer 在渲染页面模板的时候会把 state 序列化为字符串串内联到页面中
 // window.__INITIAL_STATE__ = store.state
 context.state = store.state
}

在客户端渲染入口中把服务端传递过来的状态数据填充到客户端 Vuex 容器中

/**
 * 客户端入口
 */
import { createApp } from './app'

// 客户端特定引导逻辑……

const { app, router, store } = createApp()
// 如果当前页面中有 __INITIAL_STATE__ 数据,则直接将其填充到客户端容器中
if (window.__INITIAL_STATE__) {
   // We initialize the store state with the data injected from the server
   store.replaceState(window.__INITIAL_STATE__)
  }
router.onReady(() => {
  // 这里假定 App.vue 模板中根元素具有 `id="app"`
  app.$mount('#app')
})

结果: