小白学习vue服务端渲染(一)--从零搭建vue服务端架构

530 阅读30分钟

源码地址

github.com/fronted-exa…

前言

系列文章

服务端渲染

其实Vue的SSR(其实React也是一样)并不是传统的服务端渲染,而是一种全新的架构:同构渲染。那什么是传统的服务端渲染?又什么是同构渲染?

传统的服务端渲染

传统的服务端渲染这里用大白话说就是每一张的页面全部都是真实的。哎?什么是“每一张的页面全部都是真实的”? 这里我们先了解一下SPA(Single Page Application),也就是传统的Vue-cli搭建出来的项目,真正的html文件只有一个,其他页面的所有内容全部都是js来呈现的。 所以传统服务端渲染“每一张的页面全部都是真实的”这里就很好理解了,所有的页面切换都需要请求服务器,服务器吐出真正的html页面交由浏览器呈现。

同构渲染

同构渲染是一种糅合的传统的服务端渲染和SPA呈现方式的全新架构,真正的html页面,其实也是只有一张,当客户端去请求服务器时,服务器开始在本地启动SPA渲染出一张全新的页面,之后交给浏览器呈现。这样就能做到只有一张html但是能产生无数多的“真实页面 ”。但客户端交互怎么办?不要担心,服务端在本地启动SPA渲染时,已经将相关js的引用嵌入到了发送给客户端的html中,所以一旦将页面交给浏览器后,之后全部的操作,又回归到了SPA! 也就是说,只有打开网站的首屏(注意不是首页)是一张真实页面,之后其他的所有页面,又全是交由js呈现的!

使用场景

技术层面:

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

业务层面:

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

如何实现 Vue SSR

实现vue的服务端有两种方案:

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

2、使用Nuxt.js 开发框架

本博客使用的第一种方案。之所以选择自己手动搭建,是想要更深入的了解vue服务端渲染的原理。以及可以更灵活的扩展和优化项目。

渐进式实现vue-ssr

渲染一个 Vue 实例

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

首先,我们来学习一下服务端渲染中最基础的工作:模板渲染。 简单来说就是如何在服务端使用 Vue 的方式解析替换字符串。

  • 准备工作
# 创建文件夹,进入创建的文件夹内,初始化包管理文件 package.json
npm init -y # -y 表示快速初始,不经过一些问题的回答,直接走默认回答
# 安装 vue 及 vue-server-renderer 依赖包,这里使用的vue2
npm install vue@2 vue-server-renderer --save
  • 根目录下创建 server.js 文件,将 Vue 实例渲染成 HTML 字符串
// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')
const app = new Vue({
    template:`
        <div id="app">
            <h1>{{ message }}</h1>
        </div>`,
    data:{
        message:'vue实例'
    }
})

// 第 2 步:创建一个 renderer 渲染器
const renderer = require('vue-server-renderer').createRenderer()

// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
    if (err) throw err
    // html 就是渲染出来的结果字符串
    console.log(html); // <div id="app" data-server-rendered="true"><h1>拉勾教育</h1></div>
})

/* 在 2.5.0+,如果没有传入回调函数,则会返回 Promise:
renderer.renderToString(app)
    .then((html) => {
        console.log(html);
        })
    .catch((err) => {
        console.error(err);
        });
*/
  • 使用 node 运行 server.js
node server.js

执行结果如下: image.png

通过渲染结果可以看到模板根节点中添加了data-server-rendered="true"字段,主要用于将来客户端渲染激活接管的一个入口

与服务器集成

在 Node.js 服务器中使用时相当简单直接。我使用的是 Express,具体使用如下:

  • 首先,安装 Express 到项目中
npm install express@4.18.2 --save
  • 然后,使用 Express 创建一个基本的 Web 服务,在 Web 服务中渲染 Vue 实例 修改之后的server.js如下:
// server.js
const express = require('express')
const server = express()
const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer()

// 添加路由,访问根路由
server.get('/',(req, res)=>{
    const app = new Vue({
        template:`
            <div id="app">
                <h1>{{ message }}</h1>
            </div>`,
        data:{
            message:'vue实例'
        }
    })
    renderer.renderToString( app, (err,html) => {
        if(err){
            res.status(500).end(err)
        }
        // res.end(html)
        // 解决乱码:添加 html响应头或使用 meta 设置编码格式
        res.setHeader('Content-Type','text/html; charset=utf-8')
        res.end(`
        <!DOCTYPE html>
            <html>
            <head>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width,initial-scale=1.0">
                <meta name="renderer" content="webkit" />
                <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
            </head>
            <body>
             ${html}
            </body>
        </html>`)
    })
})
server.listen(3000, ()=>{
    console.log('server runing at port 3000');
})
  • 启动 Web 服务,nodemon 启动服务,可以实时监听,热更新:
node server.js

运行结果如下所示:

image.png

使用一个页面模板

将上面进一步优化,将模板提取为一个单独的页面

  • 根目录下创建一个页面模板 index.template.html,内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
    {{{ meta }}}
     <!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
    <title>{{ title }}</title>
</head>
<body>
    <!--vue-ssr-outlet-->
</body>
</html>

注意:上面的 注释语句<!--vue-ssr-outlet-->,是固定语法。会作为 Vue 实例 转换的 html 字符串存放的位置,类似于占位。vue-ssr-outlet 前后不可以有空格。

  • 在 server.js 中,创建 renderer 渲染器时,添加一个 template 参数,指定模板。修改后的server.js如下:
/**
* 服务端入口,仅运行于服务端 
*/
// 创建一个 express 的 server 实例
const express = require('express')
const server = express()
const fs = require('fs')
const Vue = require('vue')
const { createBundleRenderer } = require('vue-server-renderer')

const renderer = require('vue-server-renderer').createRenderer({
    // 添加template参数,读取文件是二进制形式,需要转码
    template: fs.readFileSync('./index.template.html','utf-8')
})

// 添加路由
server.get('/',(req, res)=>{
    const app = new Vue({
        template:`
            <div id="app">
                <h1>{{ message }}</h1>
                <h2>客户端动态交互</h2>
                <div>
                    <input v-model = "message">
                </div>
                <div>
                    <button @click="onClick">按钮</button>
                </div>
            </div>`,
        data:{
            message:'vue ssr'
        },
        methods:{
            onClick(){
                console.log('hello world');
            }
        }
    })
    
    renderer.renderToString( app, {
        // 在模板中使用外部数据(renderToString的第二个参数,可选)
        // 页面 title 将会是 "Hello"
        title:'Hello',
        // meta 标签也会注入
        meta: `<meta name="description" content="vue实例">`
    }, (err,html) => {
        if(err){
            res.status(500).end('Internal Server Error')
        }
        // 解决乱码:添加 html响应头或使用 meta 设置编码格式
        res.setHeader('Content-Type','text/html; charset=utf-8')
        // html 将是注入应用程序内容的完整页面
        res.end(html)
    })
})

server.listen(3000, () => {
    console.log('server running at port 3000');
})

运行server.js后如下图:

image.png

可以看到,可以通过传入一个"渲染上下文对象",作为 renderToString 函数的第二个参数,来提供插值数据,插入到模板中,但是服务端渲染只是把 vue 实例处理成纯静态的HTML字符串发送给客户端,在 vue 实例中,需要客户端动态交互的功能,本身并没有提供,例如<input v-model = "message"> input 标签通过 v-model 绑定数据 message,当input 发生改变的时候,message 也会改变,所有绑定message的位置也随之变化,通过测试发现改变 input 内容改变后 message 值并未发生变化。

原因:服务端渲染出来的文件并没有客户端 js 相关内容,所以目前没有客户端交互的能力,如何实现呢?下面将详细介绍

构建同构渲染 - 构建流程

image.png

如上图:左边为应用的源代码 source,中间为 Webpack ,右边为 NodeServer 服务端。

在我们的应用当中,目前只有 server entry (服务端入口)来处理服务端渲染,如果我们希望服务端渲染的内容拥有客户端动态交互的能力的话,还需要有一个客户端脚本的入口client entry,它专门来处理客户端渲染,也就是去接管服务端渲染的内容把它激活为一个动态页面。

有了这两个入口之后需要 Webpack 对它们进行打包编译构建:对于 server entry 最终要打包为一个 ServerBundle 来做服务端渲染,同样的对于客户端入口 client entry 来说最终要打包为一个 ClientBundle 来接管服务端渲染好的静态页面对它进行激活。

这就是同构应用实现的一个基本流程。

构建同构渲染 - 源码结构

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

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

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

现在我们正在使用 webpack 来处理服务器和客户端的应用程序,大部分源码可以使用通用方式编写,可以使用 webpack 支持的所有功能。同时,在编写通用代码时,有一些事项要牢记在心。

创建src目录,项目中src目录的基本结构如下:

src
├── pages
│ ├── Home.vue
│ ├── About.vue
│ └── Post.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>
    <div>
      <button @click="onClick">点击测试</button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'App',
  data () {
    return {
      message: 'vue ssr'
    }
  },
  methods: {
    onClick () {
      console.log('Hello World!')
    }
  }
}
</script>

app.js

app.js 是我们应用程序的「通用 entry」。在纯客户端应用程序中,我们将在此文件中创建根 Vue 实例,并直接挂载到 DOM。但是,对于服务器端渲染(SSR),责任就会转移到纯客户端 client-entry.js 文件。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)
    })
    // 后期还有导出 router 、store实例
    return { app }
}

需要注意的是:避免状态单例。状态单例就是全局共用一个Vue实例。

编写纯客户端 (client-only) 代码时,我们习惯于每次在新的上下文中对代码进行取值。但是 Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。

如基本示例所示,我们为每个请求创建一个新的根 Vue 实例。这与每个用户在自己的浏览器中使用新应用程序的实例类似。如果我们在多个请求之间使用一个共享的实例,很容易导致交叉请求状态污染 (cross-request state pollution)。

因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序、router 和 store 实例。

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

// 服务端入口
import { createApp } from './app'

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

构建同构渲染 - 构建配置

安装依赖

  1. 安装生产依赖
npm i vue@2 vue-server-renderer express cross-env
说明
vueVue.js 核心库
vue-server-rendererVue 服务端渲染工具
express基于 Node 的 Web 服务框架
cross-env通过 npm scripts 设置跨平台环境变量,区分不同的打包环境,使用process.env.NODE_ENV获取当前的环境
  1. 安装开发依赖
npm i -D webpack@4 webpack-cli@4 webpack-merge webpack-node-externals @babel/core@7 @babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader@5.2.7 url-loader@4.1.1 file-loader rimraf@3.0.2 vue-loader@15 vue-template-compiler friendly-errors-webpack-plugin
说明
webpackwebpack 核心包
webpack-cliebpack 的命令行工具
webpack-mergewebpack 配置信息合并工具
webpack-node-externals排除 webpack 中的 Node 模块
rimraf基于 Node 封装的一个跨平台 rm -rf 工具
friendly-errors-webpack-plugin友好的 webpack 错误提示
@babel/core、@babel/plugin-transform-runtime、 @babel/preset-env、 babel-loaderBabel 相关工具(ES6转ES5)
vue-loader、 vue-template-compiler处理 .vue 资源
file-loader处理字体资源
css-loader处理 CSS 资源
url-loader处理图片资源

配置文件及打包命令

  • 初始化 webpack 打包配置文件,根目录下新建build文件夹。
build
├── webpack.base.config.js   # 公共配置
├── webpack.client.config.js # 客户端打包配置文件
└── webpack.server.config.js # 服务端打包配置文件

webpack.base.config.js内容如下:

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const isProd = process.env.NODE_ENV === 'production'

function resolve(dir) {
  return path.join(__dirname, '..', dir)
}

module.exports = {
  mode: isProd ? 'production' : 'development',
  context: path.resolve(__dirname, '../'),
  devtool: isProd ? 'source-map' : '#cheap-module-source-map',
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/dist/',
    // chunkhash 同属一个 chunk 中的文件修改了,文件名会发生变化
    // contenthash 只有文件自己的内容变化了,文件名才会变化
    filename: '[name].[contenthash].js',
    // 此选项给打包后的非入口js文件命名,与 SplitChunksPlugin 配合使用
    chunkFilename: '[name].[contenthash].js',
  },
  resolve: {
    extensions: ['.js', '.vue', '.json', '.css'],
    alias: {
      public: resolve('public'),
      '@': resolve('src'),
    },
  },
  module: {
    // https://juejin.cn/post/6844903689103081485
    // 使用 `mini-css-extract-plugin` 插件打包的的 `server bundle` 会使用到 document。
    // 由于 node 环境中不存在 document 对象,所以报错。
    // 解决方案:样式相关的 loader 不要放在 `webpack.base.config.js` 文件
    // 将其分拆到 `webpack.client.config.js` 和 `webpack.client.server.js` 文件
    // 其中 `mini-css-extract-plugin` 插件要放在 `webpack.client.config.js` 文件配置。
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false,
          },
        },
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
      },
      {
        test: /\.(png|svg|jpg|gif|ico)$/,
        use: ['file-loader'],
      },
      {
        test: /\.(woff|eot|ttf)\??.*$/,
        loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]',
      },
    ],
  },
  plugins: [new VueLoaderPlugin()],
}

webpack.client.config.js内容如下:

const webpack = require('webpack')
const base = require('./webpack.base.config')
const CompressionPlugin = require('compression-webpack-plugin')
const WebpackBar = require('webpackbar')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

const { merge } = require('webpack-merge')

const isProd = process.env.NODE_ENV === 'production'

const plugins = [
    new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(
            process.env.NODE_ENV || 'development'
        ),
        'process.env.VUE_ENV': '"client"'
    }),
    new VueSSRClientPlugin(),
    new MiniCssExtractPlugin({
        filename: 'style.css'
    })
]

if (isProd) {
    plugins.push(
        // 开启 gzip 压缩 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
        new CompressionPlugin(),
        // 该插件会根据模块的相对路径生成一个四位数的hash作为模块id, 用于生产环境。
        new webpack.HashedModuleIdsPlugin(),
        new WebpackBar(),
    )
}

const config = {
    entry: {
        app: './src/entry-client.js'
    },
    plugins,
    optimization: {
        runtimeChunk: {
            name: 'manifest'
        },
        splitChunks: {
            cacheGroups: {
                vendor: {
                    name: 'chunk-vendors',
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10,
                    chunks: 'initial',
                },
                common: {
                    name: 'chunk-common',
                    minChunks: 2,
                    priority: -20,
                    chunks: 'initial',
                    reuseExistingChunk: true
                }
            },
        }
    },
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                        options: {
                            // 解决 export 'default' (imported as 'mod') was not found
                            // 启用 CommonJS 语法
                            esModule: false,
                        },
                    },
                    'css-loader'
                ]
            }
        ]
    },
}

if (isProd) {
    // 压缩 css
    config.optimization.minimizer = [
        new CssMinimizerPlugin(),
    ]
}

module.exports = merge(base, config)

webpack.server.config.js内容如下:

const webpack = require('webpack')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled.
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const WebpackBar = require('webpackbar')

const { merge } = require('webpack-merge')

const plugins = [
    new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
        'process.env.VUE_ENV': '"server"'
    }),
    new VueSSRServerPlugin()
]

if (process.env.NODE_ENV == 'production') {
    plugins.push(
        new WebpackBar()
    )
}

module.exports = merge(base, {
    target: 'node',
    devtool: '#source-map',
    entry: './src/entry-server.js',
    output: {
        filename: 'server-bundle.js',
        libraryTarget: 'commonjs2'
    },
    externals: nodeExternals({
        allowlist: /\.css$/ // 防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖
    }),
    plugins,
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    'vue-style-loader',
                    'css-loader'
                ]
            }
        ]
    },
})
  • 在 package.json 的 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

启动应用

修改server.js如下:

const Vue = require('vue')
const express = require('express')
const fs = require('fs')

const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
// 生成的客户端清单 (client manifest) 
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
// 创建一个渲染器
const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
    // 设置模板
    template,
    clientManifest
})

// 创建一个 experss 的 server 实例
const server = express()

// 开头的路径,需要与 output 中设置的 publicPath 保持一致
server.use('/dist', express.static('./dist'))

// 添加路由
server.get('/', (req, res) => {
    /**
     * @param {Vue} 
     * @param {Object} 
     * @param {Function} 
     */
    renderer.renderToString({
        // 配置传到模板中的数据
        title: 'vue ssr',
        meta: `<meta name="description" content="vue-SSR">`
    }, (err, html) => {
        if (err) {
            return res.status(500).end('Internal Server Error')
        }
        // html 就是渲染出来的结果字符串
        // 添加响应头,解决编码问题
        res.setHeader('Content-Type', 'text/html; charset=utf8')
        // 结合了模板的完整内容
        res.end(html)
    })
})

server.listen(3000, () => {
    console.log('server running at port 3000');
})

解析渲染流程

服务端渲染

服务端是如何渲染并输出 html 文件?服务端渲染主要结合 serverBundle 文件,具体的流程如下:

从路由(server.js)这里着手,当客户端请求进来时候会被服务端路由匹配到,并调用 renderer 渲染器的 renderToString 方法进行渲染,把一个 vue 实例渲染成 html 字符串发送给客户端,但是在 renderer.renderToString 方法中并没有看到 vue 实例,那么这里的 vue 实例是怎么获取到的?
通过代码可以看到 renderer 渲染器是通过 createBundleRenderer 方法创建出来,方法的第一个参数是 serverBundle 即打包生成的 vue-ssr-server-bundle.json 文件,文件的具体内容如下:

image.png

server Bundle 是 Vue SSR 构建的一个特殊的 JSON 文件,主要描述服务端打包的信息,包括如下内容:

  • entry:服务端打包的入口(server-bundle.js 在打包时候配置的文件名)
  • files:所有构建结果资源列表 (server-bundle.js: 即 entry-server.js 构建出来的结果文件)
  • maps:源代码 source map 信息,主要在开发调试中使用

那么 serverBundle 是如何被使用的?
render 在渲染的时候,会来加载 serverbundle 中的入口 entry,加载里面的代码并执行,就可以得到 entry-server.js 中创建的vue实例,把 vue 实例进行渲染,把渲染结果注入到 template 模板中(render的第二个参数),最后把数据发送到了客户端。

客户端渲染

客户端如何接管激活服务端渲染后的内容?
通过客户端构建打包出来的js脚本文件注入到页面(index.template.html)中,加载客户端脚本,但是我们在模板文件中并没有写这些代码,而是在构建结果输出到客户端页面中自动包含了这些脚本。

image.png

那么服务端是如何知道模板中需要引用加载客户端构建出来的结果呢?
通过代码可以看到,createBundleRenderer 中配置了参数 clientManifest 即 vue-ssr-client-manifest.json ,它是客户端打包资源的一个构建清单,清单中描述了客户端构建资源的相关信息,具体内容如下:

image.png

  • publicPath:访问静态资源的根相对路径,与 webpack 配置中的 publicPath 一致
  • all:客户端打包所有构建出来的静态资源名称
  • initial:页面初始化时需要加载的文件,会在页面加载时配置到 preload(提前加载) 中(在渲染的时候,会把 initial 中的资源通过 script 方式自动的注入到模板页面的<!--vue-ssr-outlet-->之后)
  • async:页面跳转时需要加载的异步资源文件,会在页面加载时配置到 prefetch(预判加载) 中
  • modules:项目的各个模块包含的文件的序号,对应 all 中文件的顺序,moduleIdentifier 和 all 数组中文件的映射关系(modules对象是我们查找文件引用的重要数据)
  • modules作用:当客户端在运行的时候,假如加载了一个模块,这个模块他用到那些资源,vue就会根据信息去加载这些资源

通过 script 方式引入到模板中的客户端打包的 app.js 文件是如何工作的?

参考官方文档:客户端激活 (client-side hydration)

服务端渲染好内容想要拥有动态交互能力,需要客户端激活,接管服务端渲染好的内容,使其变为由 Vue 管理的动态 DOM 的过程。

在 entry-client.js 中,我们用下面这行挂载(mount)应用程序:

// 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')

由于服务器已经渲染好了 HTML,客户端不会在进行重新渲染。而是需要”激活”这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化),这个过程也称为“注水”。

在浏览器中查看服务器渲染的输出结果,应用程序的根元素上添加了一个特殊的属性:

<div id="app" data-server-rendered="true">

data-server-rendered 特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载,而不是通过完整的渲染模式,需要注意在根组件的根元素中需要添加 id="app",否则应用程序将无法正常激活。

在没有 data-server-rendered 属性的元素上,还可以向 $mount 函数的 hydrating 参数位置传入 true,来强制使用激活模式(hydration):

// 强制使用应用程序的激活模式
app.$mount('#app', true)

在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配。如果匹配,就直接激活,无需重新渲染,如果无法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗

构建同构渲染 - 构建配置开发模式

我们现在已经实现同构应用的基本功能了,但是这对于一个完整的应用来说还远远不够,例如如何处理同构应用中的路由、如何在服务端渲染中进行数据预取等功能。这些功能我们都会去对它进行实现,但是在实现它们之前我们先优化之前的代码,解决每次写完代码,都要重新打包构建,重新启动 Web 服务,让其变成一个编写完代码后自动构建并重启web服务器,自动刷新页面,下面我们来修改下:

基本思路

  • 生产模式

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

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

配置启动脚本package.json

"start": "cross-env NODE_ENV=production node server.js",
"dev": "node server.js"

服务端配置 server.js

renderer 是通过打包的结果调用 createBundleRenderer 创建出来的。在生产模式下它是直接基于打包的结果创建的,但是在开发模式下 renderer 需要不断更新渲染。

/**
* 服务端入口,仅运行于服务端 
*/
const express = require('express')
// 创建一个 experss 的 server 实例
const server = express()
const fs = require('fs')
const { createBundleRenderer } = require('vue-server-renderer')
const setupDevServer = require('./build/setup-dev-server')

const isPro = process.env.NODE_ENV === 'production'
let renderer
let onReady

if (isPro) {
    const template = fs.readFileSync('./index.html', 'utf-8')
    // 生产模式,直接基于已构建好的包创建渲染器
    const serverBundle = require('./dist/vue-ssr-server-bundle.json')
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    // 创建一个渲染器
    renderer = createBundleRenderer(serverBundle, {
        template, // (可选) 设置页面模板
        clientManifest // (可选) 客户端构建
    })
} else {
    // 开发模式 --> 监视打包构建(客户端 + 服务端) --> 重新生成 Renderer 渲染器
    onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
        renderer = createBundleRenderer(serverBundle, {
            template, // (可选) 设置页面模板
            clientManifest // (可选) 客户端构建
        })
    })
}

// 开头的路径,需要与 output 中设置的 publicPath 保持一致
server.use('/dist', express.static('./dist'))

const render = async (req, res) =>{
    try {
        // renderToString支持promise
        const html = await renderer.renderToString({
            // 在模板中使用外部数据(可选第二个参数)
            title:'vue ssr',
            meta: `<meta name="description" content="vue-SSR">`,
        })
        res.setHeader('Content-Type','text/html; charset=utf-8')
        res.end(html)
    } catch (error) {
        res.status(500).end('Internal Server Error')
    }
}

// 添加路由
server.get('/', isPro
    ? render // 生产模式:使用构建好的包直接渲染
    : async (req, res) => {
        // 等待有了 Renderer 渲染器以后,调用 render 函数
        await onReady
        render(req, res)
    })

server.listen(3000, () => {
    console.log('server running at port 3000');
})

封装处理模块

build/setup-dev-server.js,开发模式,自动构建,更新 Renderer 渲染器。

const fs = require('fs')
const path = require('path')
const resolve = file => path.resolve(__dirname, file)
module.exports = (server, callback) => {
    let ready
    const onReady = new Promise(resolve => ready = resolve)

    // 监视构建 --> 更新 Renderer
    let template
    let serverBundle
    let clientManifest

    // update更新函数
    const update = ()=>{
        // 当资源都构建好后,在调用callback,重新生成 Renderer渲染器(server.js)
        if(template && serverBundle && clientManifest){
            // 调用callback,意味着开发模式下打包构建成功了,让Promise变为resolve状态,onReady的Promise也就成功了,在server.js中渲染时候(路由),如果是开发模式就直接onReady,调用render渲染
            ready()
            callback(serverBundle, template, clientManifest)
        }
    }
    update() // 初始调用

    // 处理模板文件:监视构建 template --> 调用 update --> 更新 Renderer 渲染器

    // 服务端监视打包:监视构建 serverBundle --> 调用 update --> 更新 Renderer 渲染器

    // 客户端构建:监视构建 clientManifest --> 调用 update --> 更新 Renderer 渲染器
    return onReady
}
处理模板文件(监视构建 template)

关于 Node 中的监视的问题:

  • fs.watch
  • fs.watchFile
  • 第三方包:chokidar

fs.watch、fs.watchFile不太好使用,推荐使用第三方包 chokidar 监听文件的变化,其内部也是封装了原生的监视模块,性能和功能上比原生好。

安装第三方包:chokidar

npm i -D chokidar

build/setup-dev-server.js,监视构建 template,更新 Renderer 渲染器。

// 处理模板文件:监视构建 template -> 调用 update -> 更新 Renderer 渲染器
const templatePath = resolve('../index.html')  // 获取模板文件路径
template = fs.readFileSync(templatePath, 'utf-8')  // 初始获取模板文件内容
update() // 初始化
// 监视资源变化,fs.watch、fs.watchFile不太好使用,推荐使用第三方包 chokidar 监听文件的变化
chokidar.watch(templatePath).on('change', (event, path) => {
    // 文件发生改变后重新获取文件内容,调用update函数更新 Renderer
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
});
服务端监视打包(监视构建 serverBundle)

build/setup-dev-server.js,监视构建 serverBundle,更新 Renderer 渲染器。

// 服务端监视打包:监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
const serverConfig = require('./webpack.server.config.js')
// 使用webpack构建的实例
const serverCompiler = webpack(serverConfig)
serverCompiler.watch({
    // 监视打包的可选配置参数
},(err, stats) => {
    if(err) throw err
    if(stats.hasErrors()) return
    // require 引入的文件有缓存,此处使用读取文件方式(fs读取物理磁盘文件)
    serverBundle = JSON.parse(fs.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8'))
    update()
})

开发环境频繁的磁盘读写,速度慢,将打包结果存储到内存中,提高速度,如何将打包结果存储到内存中?

将打包结果存储到内存中

webpack 默认会把构建结果存储到磁盘中,对于生产模式构建来说是没有问题的;但是我们在开发模 式中会频繁的修改代码触发构建,也就意味着要频繁的操作磁盘数据,而磁盘数据操作相对来说是比较慢的,所以我们有一种更好的方式,就是 把数据存储到内存中,这样可以极大的提高构建的速度。两种方式:

  • 方案一:自己配置 memfs
    memfs 是一个兼容 Node 中 fs 模块 API 的内存文件系统,通过它我们可以轻松的实现把 webpack 构建结果输出到内存中进行管理。
  1. 安装第三方包:memfs
npm install -D memfs
  1. build/setup-dev-server.js,监视构建 serverBundle,更新 Renderer 渲染器
const { createFsFromVolume, Volume } = require('memfs') 

// 自定义 webpack 把数据写入内存中
serverCompiler.outputFileSystem = createFsFromVolume(new Volume()) 
// memfs 模块去除了 join 方法,所以这里我们需要手动的给它提供 join 方法
serverCompiler.outputFileSystem.join = path.join.bind(path) serverCompiler.watch({
	// 监视构建的配置选项
}, (err, stats) => {
    // 每当构建成功,就会执行该回调函数  
    if (err) {
        throw err 
    }
    if (stats.hasErrors()) return                    
// 读取打包之后的最新结果 
    serverBundle = JSON.parse(
    	serverCompiler.outputFileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
    )
    // update 更新  
    update()
})

webpack-dev-middleware 作用是,以监听模式启动 webpack,将编译结果输出到内存中,然后将内存文件输出到 Express 服务中。

  1. 安装依赖:webpack-dev-middleware
npm i -D webpack-dev-middleware
  1. 配置到构建流程中,build/setup-dev-server.js
const devMiddleware = require('webpack-dev-middleware')
// 服务端监视打包:监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
const serverConfig = require('./webpack.server.config.js')
// 使用webpack构建的实例
const serverCompiler = webpack(serverConfig)
// 把数据存储到内存中,极大的提高构建的速度
// devMiddleware构建完成后不会退出,以监视模式监视资源的变动,从新打包构建和手动调用watch类似
const serverDevMiddleware = devMiddleware(serverCompiler, {
    // 配置 webpack-dev-middleware ^3.7.2 选项
    // 这里我建议不要配置,因为服务端打包日志被清除后,无法跟踪服务端的异常
    // logLevel: 'silent' // 关闭日志输出 ,由 FriendlyErrorsWebpackPlugin(webpack.base.config.js) 处理
})
// done表示当每次编译结束的时候都会触发钩子
// 参数1:插件名
serverCompiler.hooks.done.tap('server', ()=>{
    // 读取内存中的文件
    // serverDevMiddleware.fileSystem 获取到devMiddleware内部操作文件系统的对象,相当于nodejs中的fs
    serverBundle = JSON.parse(
        serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
    )
    update()
})
客户端构建(监视构建 clientManifest)

客户端打包和服务端打包类似,都是借助于 webpack-dev-middleware

const devMiddleware = require('webpack-dev-middleware')
// 客户端构建:监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
const clientConfig = require('./webpack.client.config.js')
// webpack构建实例
const clientCompiler = webpack(clientConfig)
const clientDevMiddleware = devMiddleware(clientCompiler, {
    // 重要!输出资源的访问路径前缀,应该和 客户端打包输出的 publicPath 一致
    publicPath:clientConfig.output.publicPath,
    logLevel: 'silent'
})
// 调用钩子,注册插件
clientCompiler.hooks.done.tap('client', ()=>{
     // fileSystem 获取内部操作文件系统的对象 类似 NodeJS 的 fs(操纵磁盘中的文件)
    clientManifest = JSON.parse(
        clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
    )
    update()
})
// 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
server.use(clientDevMiddleware)
热更新

热更新功能需要使用到 webpack-hot-middleware 工具包

  • 安装依赖
npm install --save-dev webpack-hot-middleware
  • build/setup-dev-server.js 配置热更新
const hotMiddleware = require("webpack-hot-middleware")
// 客户端构建:监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
const clientConfig = require('./webpack.client.config.js')
// ======================== 配置热更新 ======================== 
// 使用webpack-hot-middleware实现热更新(https://github.com/webpack-contrib/webpack-hot-middleware)
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
clientConfig.entry.app = [
    'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
    clientConfig.entry.app // 本来的脚本
]
clientConfig.output.filename = '[name].js' // 热更新模式下确保hash一致
server.use(hotMiddleware(clientCompiler, {
    log: false // 关闭热更新本身的日志输出
}));

工作原理:
中间件将自身安装为 webpack 插件,并侦听编译器事件。
每个连接的客户端都有一个 Server Sent Events 连接,服务器将在编译器事件上向连接的>客户端 发布通知。
MDN - 使用服务器发送事件
Server-Sent Events 教程
当客户端收到消息时,它将检查本地代码是否为最新。如果不是最新版本,它将触发 webpack 热更新模块重新加载。

至此,服务端的热更新和内存打包优化已经配置好了,全新的setup-dev-server.js内容如下:

const fs = require('fs')
const path = require('path')
const webpack = require('webpack')
const chokidar = require('chokidar')
const devMiddleware = require('webpack-dev-middleware')
const hotMiddleware = require('webpack-hot-middleware')
const resolve = (file) => path.resolve(__dirname, file)

const serverConfig = require('./webpack.server.config.js')
const clientConfig = require('./webpack.client.config.js')

module.exports = (server, callback) => {
  let ready
  const onReady = new Promise((resolve) => (ready = resolve))

  // 监视构建 --> 更新 Renderer
  let template
  let serverBundle
  let clientManifest

  // update更新函数
  const update = () => {
    // 当资源都构建好后,在调用callback,重新生成 Renderer渲染器(server.js)
    if (template && serverBundle && clientManifest) {
      // 调用callback,意味着开发模式下打包构建成功了,让Promise变为resolve状态,onReady的Promise也就成功了,在server.js中渲染时候(路由),如果是开发模式就直接onReady,调用render渲染
      ready()
      callback(serverBundle, template, clientManifest)
    }
  }

  //update() // 初始调用

  // 处理模板文件:监视构建 template -> 调用 update -> 更新 Renderer 渲染器
  const templatePath = resolve('../index.html') // 获取模板文件路径
  template = fs.readFileSync(templatePath, 'utf-8') // 初始获取模板文件内容
  //update() // 初始化
  // 监视资源变化,fs.watch、fs.watchFile不太好使用,推荐使用第三方包 chokidar 监听文件的变化
  chokidar.watch(templatePath).on('change', (event, path) => {
    // 文件发生改变后重新获取文件内容,调用update函数更新 Renderer
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
  })


  // 服务端监视打包:监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器
  // 使用webpack构建的实例
  const serverCompiler = webpack(serverConfig)
  // 把数据存储到内存中,极大的提高构建的速度
  // devMiddleware构建完成后不会退出,以监视模式监视资源的变动,从新打包构建和手动调用watch类似
  const serverDevMiddleware = devMiddleware(serverCompiler, {
    // 配置 webpack-dev-middleware ^3.7.2 选项
    // 这里我建议不要配置,因为服务端打包日志被清除后,无法跟踪服务端的异常
    // logLevel: 'silent' // 关闭日志输出 ,由 FriendlyErrorsWebpackPlugin(webpack.base.config.js) 处理
  })
  // done表示当每次编译结束的时候都会触发钩子
  // 参数1:插件名
  serverCompiler.hooks.done.tap('server', () => {
    // 读取内存中的文件
    // serverDevMiddleware.fileSystem 获取到devMiddleware内部操作文件系统的对象,相当于nodejs中的fs
    serverBundle = JSON.parse(
      serverDevMiddleware.fileSystem.readFileSync(
        resolve('../dist/vue-ssr-server-bundle.json'),
        'utf-8',
      ),
    )
    update()
  })

  
  // ======================== 配置热更新 ========================
  // 使用webpack-hot-middleware实现热更新(https://github.com/webpack-contrib/webpack-hot-middleware)
  clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
  clientConfig.entry.app = [
    'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
    clientConfig.entry.app, // 本来的脚本
  ]
  clientConfig.output.filename = '[name].js' // 热更新模式下确保hash一致

  // 客户端构建:监视构建 clientManifest -> 调用 update -> 更新 Renderer 渲染器
  // webpack构建实例
  const clientCompiler = webpack(clientConfig)
  const clientDevMiddleware = devMiddleware(clientCompiler, {
    // 重要!输出资源的访问路径前缀,应该和 客户端打包输出的 publicPath 一致
    publicPath: clientConfig.output.publicPath,
    logLevel: 'silent'
  })
  // 调用钩子,注册插件
  clientCompiler.hooks.done.tap('client', () => {
    // fileSystem 获取内部操作文件系统的对象 类似 NodeJS 的 fs(操纵磁盘中的文件)
    clientManifest = JSON.parse(
      clientDevMiddleware.fileSystem.readFileSync(
        resolve('../dist/vue-ssr-client-manifest.json'),
        'utf-8',
      ),
    )
    update()
  })

  server.use(
    hotMiddleware(clientCompiler, {
      log: false, // 关闭热更新本身的日志输出
    }),
  )
  
  // 重要!!!将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问
  server.use(clientDevMiddleware)

  // 客户端构建:监视构建 clientManifest --> 调用 update --> 更新 Renderer 渲染器
  return onReady
}

编写通用代码

到此为止我们终于把实现 Vue SSR 同构应用的基础环境搭建起来,虽然还有很多不足之处,但是也能满足我们当前的基本使用了。接下来把内容的核心转移到 Vue SSR 本身上,了解一下编写通用应用的注意事项,对于如何正确的使用 Vue SSR 是非常有帮助的。

在这些注意事项中,有些其实已经在前面的学习过程中了解过了,而有些还没有接触过,所以在这里通 过官方文档做一个简单的总结

服务器上的数据相应

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

// 导出一个工厂函数,用于创建新的应用程序、router、store实例
export function createApp () {
  // 创建实例的代码放在函数中,每次请求进来,或者有不同的用户调用函数创建一个独立的vue实例处理请求,不会出现类似于状态污染的问题
  const app = new Vue({
    // 根实例简单的渲染应用程序
    render: h => h(App)
  })
  return { app }
}

// 如果所有请求都共享了同一个vue实例,会造成状态的交叉污染
const app = new Vue({
  render: h => h(App)
})

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

组件生命周期钩子函数

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

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

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

通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像 window 或 document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。

对于共享于服务器和客户端,但用于不同平台 API 的任务(task),建议将平台特定实现包含在通用 API 中,或者使用为你执行此操作的 library。例如,axios 是一个 HTTP 客户端,可以向服务器和客户端都暴露相同的 API。

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

请注意,考虑到如果第三方 library 不是以上面的通用用法编写,则将其集成到服务器渲染的应用程序中,可能会很棘手。你可能要通过模拟 (mock) 一些全局变量来使其正常运行,但这只是 hack 的做法,并且可能会干扰到其他 library 的环境检测代码。

区分运行环境

webpack DefinePlugin

区分运行环境:

  • webpack.client.config.js中添加如下内容:
new webpack.DefinePlugin({
  'process.env.VUE_ENV': '"client"'
}),
  • webpack.server.config.js中添加如下内容:
new webpack.DefinePlugin({
  'process.env.VUE_ENV': '"server"'
}),

自定义指令

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

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

路由和代码分割

接下来我们来了解如何处理通用应用中的路由,和纯客户端的使用方式基本一致,使用vue-router,只需要在少许的位置做一些配置即可。通过查看文档来具体演示下:

配置VueRouter - router/index.js

  • 安装
npm i vue-router@3
  • 配置 VueRouter (路由规则)router/index.js
import Vue from 'vue'
import vueRouter from 'vue-router'
// webpack中配置@指向src
import Home from '@/pages/Home'

Vue.use(vueRouter)

export function createRouter () {
  return new vueRouter({
    // 同构应用不能使用 hash 路由,应该使用 history 模式,兼容前后端
    mode: 'history',
    routes: [
        {
            path: '/',
            name: 'Home',
            component: Home
        },{
            path: '/about',
            name: 'About',
             // 懒加载路由 按需加载,异步的
            component: ()=> import('@/pages/About')
        },{
            path: '/posts',
            name: 'Posts',
            component: ()=> import('@/pages/Posts')
        },{
            path: '*',
            name: 'error404',
            component: ()=> import('@/pages/404')
        }
    ]
  })
}

将路由注册到根实例

  • 注册路由 src/app.js
// 通用 entry
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'

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

适配服务端入口

  • entry-server.js
// 服务端启动入口
import { createApp } from './app'

// 官方示例
export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()

    // 设置服务器端 router 的位置(服务端渲染时候这个过程是看不到)
    router.push(context.url)

    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      /* 在router中有配置404情况,此处代码可以省略
      // 获取匹配路由的组件
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        // 这样处理就可以在server中获取到状态码,进行404的处理  
        return reject({ code: 404 })
      }*/
      
      // Promise 应该 resolve 应用程序实例,以便它可以渲染
      resolve(app)
    }, reject)
  })
}

// 使用async/await改造上述代码
export default async context => {
    const { app, router } = createApp()
    // 用于设置服务器端router的位置
    router.push(context.url)
    // this的指向router
    await new Promise(router.onReady.bind(router))
    // async对于非Promise的数据,会将他把装在Promise中,成功后返回对应的数据
    return app
}

服务端适配

  • 进行服务端 server 适配 server.js
/**
* 服务端入口,仅运行于服务端 
*/
// 创建一个 express 的 server 实例
const express = require('express')
const server = express()
const fs = require('fs')
const { createBundleRenderer } = require('vue-server-renderer')
const setupDevServer = require('./build/setup-dev-server')

const isPro = process.env.NODE_ENV === 'production'
let renderer
let onReady

if (isPro) {
    const template = fs.readFileSync('./index.html', 'utf-8')
    // 生产模式,直接基于已构建好的包创建渲染器
    const serverBundle = require('./dist/vue-ssr-server-bundle.json')
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    // 创建一个渲染器
    renderer = createBundleRenderer(serverBundle, {
        template, // (可选) 设置页面模板
        clientManifest // (可选) 客户端构建
    })
} else {
    // 开发模式 --> 监视打包构建(客户端 + 服务端) --> 重新生成 Renderer 渲染器
    onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
        renderer = createBundleRenderer(serverBundle, {
            template, // (可选) 设置页面模板
            clientManifest // (可选) 客户端构建
        })
    })
}

// 开头的路径,需要与 output 中设置的 publicPath 保持一致
server.use('/dist', express.static('./dist'))

const render = async (req, res) =>{
  try {
      // renderToString支持promise
      const html = await renderer.renderToString({
          // 在模板中使用外部数据(可选第二个参数)
          title:'vue ssr',
          meta: `<meta name="description" content="vue-SSR">`,
          // entry-server.js用于设置服务器端router的位置
          url: req.url
      })
      res.setHeader('Content-Type','text/html; charset=utf-8')
      res.end(html)
  } catch (error) {
    console.log('err: ', error)
      res.status(500).end('Internal Server Error')
  }
}

// 添加路由
// 服务端路由设置为 *,意味着所有的路由都会进入这里,不然会导致刷新页面,获取不到页面的bug
// 并且vue-router设置的404页面无法进入
server.get('*', isPro
    ? render // 生产模式:使用构建好的包直接渲染
    : async (req, res) => {
        // 等待有了 Renderer 渲染器以后,调用 render 函数
        await onReady
        render(req, res)
    })

server.listen(3000, () => {
    console.log('server running at port 3000');
})

适配客户端入口

路由器必须要提前解析路由配置中的异步组件,才能正确地调用组件中可能存在的路由钩子。

entry-client.js

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

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

const { app, router } = createApp()

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

配置路由出口

根组件中,设置路由的出口src/App.vue,如果没有路由出口的话,匹配到的路由组件就不知道要渲染到哪里。

<template>
    <div id="app">
        <ul>
            <li>
                <router-link to='/'>Home</router-link>
            </li>
            <li>
                <router-link to='/about'>About</router-link>
            </li>
            <li>
                <router-link to="/posts">Posts</router-link>
            </li>
        </ul>
        <!-- 路由出口 -->
        <router-view/>
    </div>
</template>

<script>
    export default {
        name: "App"
    };
</script>

到此位置路由相关配置已经完成,可以启动项目测试。

总结

测试路由导航,可以看到正常工作,那说明我们同构应用中的路由产生作用了。

现在我们的应用就非常的厉害了,当你首次访问页面的时候,它是通过服务端渲染出来的,服务端渲染拥有了更快的渲染速度以及更好的 SEO,当服务端把渲染的内容发送到客户端后,客户端通过 Vue 结合 Vue Router 把当前内容激活,摇身一变成为了一个客户端单页面 SPA 应用,之后的页面导航交互是通过客户端交互的方式处理,不需要重新刷新整个页面。这样我们的网站就既拥有了更好的渲染速度和SEO,也拥有了单页应用中更好的用户体验。

除此之外,我们在路由中配置的异步组件(也叫路由懒加载)也是非常有意义,这些异步组件会被分割为独立的 chunk(也就是单独的文件),只有在需要的时候才会进行加载。这样就能够避免在初始渲染的时候客户端加载的脚本过大导致激活速度变慢的问题。

首次这个词需要注意,下面会演示这个问题。

运行项目,验证路由。来看一下在运行期间这些chunk文件是如何加载的。

image.png

我们会发现除了 app 主资源外,其它的资源也被下载下来了,这里可能会有个疑问:不是应该在需要的时候才加载吗?为什么一上来就加载了?

原因是在页面的头部中的带有 preload 和 prefetch 的 link 标签。

image.png

我们期望客户端 JavaScript 脚本尽快加载尽早的接管服务端渲染的内容,让其拥有动态交互能力,但是如果把这些资源以 script 标签替代 link 标签这里这里的话,浏览器会去下载它,然后执行里面的代码,这个过程会阻塞页面的渲染。所以看到真正的 script 标签是在页面的底部的。

<link rel="preload" href="/dist/app.js" as="script">
<link rel="prefetch" href="/dist/0.js">
<link rel="prefetch" href="/dist/1.js">
<link rel="prefetch" href="/dist/2.js">

这部分代码是告诉浏览器可以去预加载这个资源。但是不要执行里面的代码,也不要影响网页的正常渲染。直到遇到真正的 script 标签加载该资源的时候才会去执行里面的代码,这个时候可能已经预加载好了,直接使用就可以了,如果没有加载好,也不会造成重复加载,所以不用担心这个问题。

  • prefetch 资源是加载下一个页面可能用到的资源,浏览器会在空闲的时候对其进行加载,所以它并不一定会把资源加载出来
  • preload 当前页面一定会用到的资源,对其进行预加载。所以当我们去访问 about 页面的时候,它的资源是通过 prefetch 预取过来的(prefetch不稳定,页面的js可能是其加载好的),提高了客户端页面导航的响应速度。

管理页面 Head

无论是服务端渲染还是客户端渲染,它们都使用的同一个页面模板。页面中的 body 是动态渲染出来的,但是页面的 head 是写死的,如果我们希望不同的页面可以拥有自己的 head 内容,例如页面的 title、meta 等内容,如何处理呢? 下面我们来了解一下如何让不同的页面来定制自己的 head 头部内容。

官方文档这里专门描述了关于页面 Head 的处理,相对于来讲更原生一些,使用比较麻烦,有兴趣的同学可以了解一下。

我这里主要给大家介绍一个第三方解决方案:vue-meta

Vue Meta 是一个支持 SSR 的第三方 Vue.js 插件,可让你轻松的实现不同页面的 head 内容管理。 使用它的方式非常简单,而只需在页面组件中使用 metaInfo 属性配置页面的 head 内容即可。

使用 vue-meta 进行配置具体步骤如下:

  • 安装 vue-meta 依赖
npm i -D vue-meta
  • 在通用入口 app.js 中通过插件的方式将 vue-meta 注册到 Vue 中。
import VueMeta from 'vue-meta'
// 注册插件
Vue.use(VueMeta)

Vue.mixin({
  metaInfo:{
    // 混入选项 %s 为用户自定义数据
    titleTemplate:'%s - vue SSR'
  }
})
  • 在服务端渲染入口 src/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 // 将meta放到html模板中
 ...
}
  • 在模板页面index.html中注入 meta 信息
<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>
  • 组件(src/pages/Home.vue) 中,配置 metaInfo
export default {
  name: 'HomePage',
  metaInfo: {
    title: '首页'
  }
}

页面显示效果如下所示:

image.png

数据预取和状态管理

接下来我们来了解一下服务端渲染中的数据预取和状态管理。官方文档中的描述比较枯燥,无法在很短的时间内搞清楚它到底要做什么,所以我们这里通过一个实际的业务需求来引入这个话题。
我们的需求就是:

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

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

思路分析

image.png

也就是说我们要在服务端获取异步接口数据(url:https://cnodejs.org/api/v1/topics),交给 Vue 组件去渲染。我们首先想到的是在组件的生命周期钩子中请求获取数据渲染页面,既在组件中添加生命周期钩子beforeCreate 和 created(服务端渲染仅支持这两个钩子函数的调用)。

那么下一个问题是如何在服务端发送请求?
依然使用 axios,axios 既可以运行在客户端也可以运行在服务端,因为它对不同的环境做了适配处理,在客户端是基于浏览器的 XMLHttpRequest 请求对象,在服务端是基于 Node.js 中的 http 模块实现,无论是底层是什么,上层的使用方式都是一样的,具体代码如下:

src/pages/Post.vue:

<script>
import axios from 'axios'

export default {
  name: 'PostList',
  metaInfo: {
    title: 'Posts'
  },
  data () {
    return {
      posts: []
    }
  }, 
 // beforeCreate是获取不到data中的数据,所以将请求放到created中
 // 服务端渲染只支持 beforeCreate 和 created,不会等待 beforeCreate 和 created 中的异步操作,这种做法在服务端渲染中是不会工作的
  async created () {
    console.log('Posts Created Start')
    const { data } = await axios({
      method: 'GET',
      url: 'https://cnodejs.org/api/v1/topics'
    })
    // 不支持响应式数据
    this.posts = data.data
    console.log('Posts Created End')
  }
}
</script>

上述代码的这种做法只会在客户端工作,在服务端渲染中是不会工作的,原因是因为:服务端渲染期间确实会调用created生命周期钩子函数,但是他不会等待其中的异步操作,即便是可以获取到接口返回的异步数据,服务端也不支持这种响应式的操作。

我们可以来验证下,上述代码的运行结果如下:

image.png

在浏览中展示的内容并不是通过服务端渲染过来,服务端返回的内容没有列表数据,列表的数据是通过后期客户端方式加载进来的

image.png

这里可能会有个疑问,created 是不是在服务端没有调用呢?

image.png

通过打印日志可以看到,created 在服务端确实被调用执行了,只是没有作用。

接下来的问题就是:如果我们希望服务端渲染的时候就拿到数据,渲染后的结果就是完整的页面该怎么实现呢?

官方文档给出的解决办法:为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或“状态容器(state container)”中。首先,在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,我们将在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置(inline)状态。

简单来说:它的核心思路就是把在服务端渲染期间获取的数据存储到外部容器 Vuex 中,然后把容器中的数据同步到客户端,这样就保持了前后端渲染的数据状态同步,避免了客户端重新渲染的问题,所以接下来要做的第一件事就是基于 Vuex 创建容器。

数据预取

  1. 通过 Vuex 创建容器实例,并挂载到 Vue 根实例
  • 安装 vuex
npm install vuex@3
  • 创建 Vuex 容器 store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'

Vue.use(Vuex)

export const createStore =  () =>{
    return new Vuex.Store({
        state:{
            // 避免交叉污染
            posts:[]
        },
        actions: {
         // 在服务端渲染期间务必让 action 返回一个 Promise
         // async 默认返回 Promise
          async getPosts({commit}){
              const { data } = await axios.get('https://cnodejs.org/api/v1/topics')
              commit('setPost', data.data)
            }
        },
        mutations: {
            setPost(state , data){
                state.posts = data
            }
        }
    })
}
  • 在 app.js 中将 Vuex 容器挂载到 Vue 根实例
// 通用 entry
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store/index'

// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
  const store = createStore ()
  const app = new Vue({
    // 注入router到根vue实例
    router,
    // 把容器挂载到 Vue 根实例中
    store,
    // 根实例简单的渲染应用程序组件。
    render: h => h(App)
  })
  // 返回app和router,store实例
  return { app, router, store}
}
  1. 在组件中使用 serverPrefetch 触发容器中的 action
<template>
  <div>
    <h1>Post List</h1>
    <ul>
      <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
    </ul>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState(['posts'])
  },
  // Vue SSR 特殊为服务端渲染提供的一个生命周期钩子函数(渲染之前调用)
  serverPrefetch () {
    // 发起 action,返回 Promise
    // this.$store.dispatch('getPosts')
    return this.getPosts()
  },
  methods: {
    ...mapActions(['getPosts'])
  }
}
</script>

运行结果如下:

image.png

通运行结果可以看出服务端渲染的页面中存在列表数据,但是浏览器中却没有展示出来,原因是因为此时我们拿到的数据只是存储在了服务端的 Vuex 容器当中了,并没有把数据同步到客户端的 Vuex 容器当中,导致两个端数据不同步,合并失败从而使得客户端重新渲染。

将预取数据同步到客户端

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

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

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

具体实现如下:

  • src/entry-server.js

服务端渲染的入口中在router 将可能的异步组件和钩子函数解析完之后增加代码

const { app, router,store } = createApp()
 // 服务端渲染完毕后被调用,当被调用时候也就可以拿到服务端渲染好的容器状态数据
context.rendered = () => {
// Renderer 会把 context.state 数据对象内联到页面模板中
// 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
// 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    context.state = store.state
}

这步之后,我们所设置的容器 state 被内联到了模板中,通过这种方式传递给客户端

image.png

  1. 在客户端渲染入口中把服务端传递过来的状态数据填充到客户端 Vuex 容器中
  • src/entry-client.js
// 客户端启动入口 
import { createApp } from './app'  
// 客户端特定引导逻辑……  
const { app, router, store} = createApp()  

if (window.__INITIAL_STATE__) {     
    // 替换容器的数据状态     
    store.replaceState(window.__INITIAL_STATE__)   
} 
...

最终运行结果:

image.png

服务端渲染好内容后,客户端不在重新渲染,而是接管服务器渲染好的动态的内容交互,客户端通过服务端传递进来的 window.__INITIAL__STATE 将数据同步到客户端的 Vuex 中,数据状态和服务端保持一致,那么也可以在客户端修改数据内容。

备注:本教程服务端数据获取与渲染的实现是基于在浏览器地址栏输入对应的路由地址进行测试的。

正如备注所言,这里出现的两个问题:

  • 服务端渲染只有首次,即只有第一次打开网页的时候,会进行服务端渲染。这就导致了只有第一次服务端渲染获取的数据会通过store同步到客户端。
  • 由于只有第一次渲染会进行服务端渲染,导致network的网页数据一直都是首次渲染的数据,不会更新。

第一个问题的解决方案是,在客户端入口(entry-client.js)文件中配置一个方法,方法名我命名成了asyncData,通过Vue.mixin混入到整个项目,页面挂载前和路由更新时,去进行store更新。同时在服务端入口文件配置当服务端获取完数据后,再写入到服务端的store中(服务端其实和客户端共用一个store配置,只不过在客户端渲染的时候进行了替换)

具体实现代码如下: entry-client.js

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

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

const { app, router, store } = createApp()

Vue.mixin({
  // 组件挂载前
  beforeMount() {
      const { asyncData } = this.$options
      if (asyncData) {
          // 将获取数据操作分配给 promise
          // 以便在组件中,我们可以在数据准备就绪后
          // 通过运行 `this.dataPromise.then(...)` 来执行其他任务
          this.dataPromise = asyncData({
              store: this.$store,
              route: this.$route
          })
      }
  },

  // 路由更新前
  beforeRouteUpdate(to, from, next) {
      const { asyncData } = this.$options
      if (asyncData) {
          asyncData({
              store: this.$store,
              route: to
          }).then(next).catch(next)
      } else {
          next()
      }
  }
})

if (window.__INITIAL_STATE__) {
  // 将window.__INITIAL_STATE__的数据替换到客户端的store中
  store.replaceState(window.__INITIAL_STATE__)
}

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

entry-server.js:

// entry-server.js
import { createApp } from './app'

// 使用async/await改造上述代码
export default async (context) => {
  const { app, router, store } = createApp()
  const meta = app.$meta()
  // 用于设置服务器端router的位置
  router.push(context.url)
  context.meta = meta

  // this的指向router
  await new Promise(router.onReady.bind(router))

  const matchedComponents = router.getMatchedComponents()

  // 这里等待所有匹配组件的asyncData执行完成
  await Promise.all(
    matchedComponents.map((component) => {
      if (component.asyncData) {
        return component.asyncData({
          store,
          route: router.currentRoute,
        })
      }
    })
  )

  context.rendered = () => {
    // Renderer 会把 context.state 数据对象内联到页面模板中
    // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
    // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    context.state = store.state
  }
  // async对于非Promise的数据,会将他把装在Promise中,成功后返回对应的数据
  return app
}

在组件Post.vue中使用:

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

<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState(['posts'])
  },
  asyncData ({ store, route }) {
    // 这里必须使用方法注入的store
    return store.dispatch('getPosts')
  },
  // Vue SSR 特殊为服务端渲染提供的一个生命周期钩子函数(渲染之前调用)
  // serverPrefetch () {
  //   // 发起 action,返回 Promise
  //   // this.$store.dispatch('getPosts')
  //   console.log('serverPrefetch')
  //   return this.getPosts()
  // },
  methods: {
    ...mapActions(['getPosts'])
  }
}
</script>

这个解决办法,是没法观察到network的实时更新的,因为这里并没有在路由跳转的时候,进行服务端渲染,只是进行了数据的更新。

第二种解决办法,是使用原生js的window.location.href进行路由跳转,这样每次路由跳转都会进行服务端跳转了,使用示例:

<template>
    <div id="app">
        <ul>
            <li>
                <router-link to='/'>Home</router-link>
            </li>
            <li>
                <router-link to='/about'>About</router-link>
            </li>
            <li>
                <span @click="toPost">Posts</span>
                <!-- <router-link to="/posts">Posts</router-link> -->
            </li>
        </ul>
        <!-- 路由出口 -->
        <router-view></router-view>
    </div>
</template>

<script>
    export default {
      name: "App",
      methods: {
        toPost () {
          location.href = '/posts' // 这里的地址可以直接写vue-router配置的path
        }
      }
    };
</script>

设置favicon.ico

默认情况下,不配置favicon.ico,但服务端会报个404页面,为避免,我们需要设置。

先安装依赖serve-favicon:

npm i serve-favicon

在根目录下新建static目录,放置favicon。然后在server.js进行配置:

server.use(favicon('./static/favicon.ico'))

重启项目,就能看到网站已经有了图标。

image.png

该方式设置图标的缺陷是,当项目是采用nginx代理时,图标会丢失,因为nginx代理不会去加载对应的非express的静态文件。

解决办法是:先用express.static去将打包后的dist设置成express的静态资源,然后将图标打包进dist,再在html文件中使用如下,将图标引入:

<link  rel="shortcut icon" href="/dist/static/favicon.ico" type="image/x-icon">