模块四(一):搭建自己的SSR

前言:同构渲染是将服务器渲染和客户端渲染相结合的一种渲染方式,在服务端生成初始页面,提升首屏加载速度,并且有利于SEO;在客户端接管HTML,并且将静态HTML激活为数据绑定的动态HTML,为用户提供更流畅的交互服务,并且可以更流畅的实现路由跳转,无需刷新整个页面。
同构渲染可以使用NuxtJS实现,它非常的方便,开箱即用,给我们配置好了各种各样的配置,感兴趣的可以参考NuxtJS开发实例。然而,如果想要对于项目有更加灵活的控制,就可以使用Vue提供的 Vue SSR。使用 Vue SSR 的前提是具有 Node.js 和 webpack 的应用经验。
使用 Vue SSR 相比于使用 SPA 有利有弊,好处是
① 更好的 SEO
② 更快的首屏加载速度
坏处是
① 开发条件所限。浏览器端特定的代码只能在某些特定的生命周期钩子函数中使用,并且第三方库的使用可能会受限制。
② 涉及构建设置和部署的要求更多。需要更多的配置部署以及依赖于 Node Server 环境。
③ 更多的服务器端负载。在服务器端构建 html 会占用更多的CPU资源。
在选择 Vue SSR 开发项目之前,要考虑是否真正需要它,因为使用起来开发成本会增加很多。是否需要使用 Vue SSR 主要取决于初始加载的几百毫秒优化是否真正有必要。如果只是为了优化某一些页面,可以使用预渲染技术。使用 Vue SSR 意味着编写代码时要考虑代码的运行环境,有在服务器端运行的代码、服务器端和客户端都运行的通用代码以及只在客户端运行的代码。

(一)简单入门案例

服务器端渲染是在服务器端生成完整的 html 页面,返回给客户端,客户端直接渲染服务器端返回的 html 。首先介绍如何使用Vue 提供的服务端渲染工具生成一个Vue实例。

1、渲染一个Vue实例

① 创建一个项目根目录,进入项目根目录
npm init -y初始化项目
npm install vue@2.7 vue-server-renderer --save安装服务端渲染的依赖包和 Vue 库;注意指定Vue的版本,否则会下载最新版本,使用起来和Vue2语法不同
④ 在根目录下创建一个server.js,把官网的示例代码复制粘贴进去,稍作修改

// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')
const app = new Vue({
    template: `
      <div id="app">
      <h1>{{ message }}</h1>
      </div>`,
    data: {
        message: 'Hello World!'
    }
})

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

// 第 3 步:将 Vue 实例渲染为 HTML
// 参数一:Vue实例
// 参数二:回调函数
renderer.renderToString(app, (err, html) => {
    if (err) throw err
    console.log(html)
})

⑤ 控制台运行node server.js运行代码,查看输出
可以看到在Node环境中,vue-server-renderer 将Vue实例渲染成字符串,并且给根元素增加了data-server-rendered属性
在这里插入图片描述

2、结合到web服务中

接下来介绍如何将 Vue SSR 渲染之后的静态 HTML发送给Web服务器中,然后渲染到页面上。这里会使用到 express 框架。Express 是一个保持最小规模的灵活的 Node.js Web 应用程序开发框架,为 Web 和移动应用程序提供一组强大的功能。可以灵活处理 http 请求、路由、中间件、静态资源托管等业务场景。
① 安装 NodeJS 框架 npm install express --save
② 在 server.js 当中加载 express

// 加载express
const express = require('express')

③ 给服务配置路由,并且在访问服务的时候,创建 Vue 实例,并使用 vue-server-renderer 渲染成静态HTML。如果发生错误,就返回状态码500,并且返回错误信息;如果正确执行,就把 vue-server-renderer 生成的静态HTML返回给客户端

server.get('/',(req,res)=>{
    const app = new Vue({
        template: `<div>Hello World</div>`
    })

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

    // 第 3 步:将 Vue 实例渲染为 HTML
    renderer.renderToString(app, (err, html) => {
        if (err) {
            return res.status(500).end('服务器错误')
        }
        res.end(html)
    })
})

④ 启动服务并监听端口号

// 启动服务
server.listen(3000, ()=>{
    console.log('服务启动')
})

⑤ 使用nodemon server.js启动服务
nodemon 是一个 Node.js 应用的实时监视工具。它可以监视你的代码,如果发现有任何更改,它会自动重启你的应用程序,不必手动重新启动它。如果没安装的话,记得npm i nodemon安装一下。
启动服务之后,控制台会打印启动成功的消息:
在这里插入图片描述
访问 http://localhost:3000/,可以看到页面中展示 Vue SSR 生成的页面内容
在这里插入图片描述
还可以从网络的响应中看到,服务端响应的就是静态html
在这里插入图片描述
⑥ 把模板中的文本改成中文之后

const app = new Vue({
    template: `<div>哈哈哈</div>`
})

页面中会出现乱码
在这里插入图片描述
这是因为没有设置编码方式,解决方式有两种
1️⃣ 设置响应头的 content-type,告诉浏览器返回的内容是 html 格式的内容,是用utf8字符集编码的

 renderer.renderToString(app, (err, html) => {
     if (err) {
         return res.statue(500).end('服务器错误')
     }
     res.setHeader('Content-Type','text/html; charset=utf8')
     res.end(html)
 })

可以在响应头中看到content-type的设置
在这里插入图片描述
2️⃣ 给模板中加入meta标签
上面只是生成了一个div标签,即html片段,并不是一个完整的页面结构。返回的时候可以返回一个完整的页面结构,其中使用meta标签执行charset编码方式

res.end(`<!doctype html>
             <html class="en">
                 <head>
                 <meta charset="utf-8">
                 <meta name="viewport" content="width=device-width">
                 </head>
	              <body>
		              ${html}
	              </body>
	          </html>`)
          })
      })

此时返回的数据就具有完整的文档结构
在这里插入图片描述
通常为了确保正确,两种方式都进行配置是最好的。

3、使用页面模板

可以将页面基础结构的模板放在单独的文件中进行维护,vue-server-renderer 会自动将模板和 Vue 实例的template结合成一个完整的html
① 在根目录下创建index.template.html存放页面模板代码,其中,需要用 Vue 实例的template属性填充的地方,使用注释<!--vue-ssr-outlet-->占位,这句是固定的,不能修改格式。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <!--vue-ssr-outlet-->
</body>
</html>

② 创建 renderer 时传入页面模板的配置选项

const fs = require('fs')
// 第 2 步:创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer({
    template: fs.readFileSync('./index.template.html', 'utf-8')
})

③ 返回给客户端时,直接返回参数html即可

// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
    if (err) {
        return res.status(500).end('服务器错误')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
})
4、在模板中使用外部数据

renderer.renderToString()方法的第二个参数可以定义传递给模板的数据,在模板中使用插值表达式绑定即可。如果想给模板传递html标签,则模板内部要使用{{{}}},这样就不会进行解析,会直接原文输出
server.js

renderer.renderToString(app,{
    title:'Vue SSR',
    meta:`<meta name="description" content="今天是个好日子"></meta>`
}, (err, html) => {
    if (err) {
        return res.status(500).end('服务器错误')
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
})

index.template.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    {{{ meta }}}
    <title>{{title}}</title>
</head>
<body>
    <!--vue-ssr-outlet-->
</body>
</html>

(二)构建配置

Vue 项目需要处理 ES6 代码,以及处理 css 等资源以保证在旧版浏览器中程序也能正确运行,因此我们需要使用 webpack 进行打包;Node.js 环境是完全支持 ES6 语法的,但是 webpack 提供的很多 loader 在 Node.js 环境中是不生效的,所以服务端和客户端需要两套不同的配置和打包步骤。Vue SSR 官网给我们提供了一套可供参考的代码结构。

1、源码结构

源码结构可以参考官网推荐的源码结构,
① 创建目录src/App.vue,里面存放应用的模板

<template>
  <div id="app">{{ message }}</div>
</template>
<script>
export default {
  data() {
    return {
      message: '哈哈哈'
    }
  }
}
</script>

② 创建src/app.js,里面创建并导出一个创建 Vue 应用的工厂函数,这是为了每次请求都创建一个新的 Vue 实例,避免相互污染。Node.js 一旦进入一个进程,变量的状态会一直保存,所以多次请求会使用同一个 Vue 实例。所以这里要导出一个工厂函数,保证每次请求到的都是新的 Vue 实例。

/**
 * 同构应用通用的启动入口
 * */
import Vue from 'vue'
import App from './App.vue'

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

③ 创建客户端模块
src/entry-client.js

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

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

const { app } = createApp()

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

④ 服务端入口
src/entry-server.js

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

export default context => {
    const { app } = createApp()
    // 服务端路由处理、数据预取 ...
    return app
}

创建好源码结构后,需要使用 webpack 进行打包构建,服务器端代码需要打包成「服务器 bundle」,然后用于服务器端渲染(SSR),而客户端代码要打包成「客户端 bundle」,处理客户端渲染。

2、安装依赖

(1)安装生产依赖(这里有坑哦,其实应该安装最新版本的 vue ,否则的话就会遇到和本仙女一样的坑,真实记录一下,大家可以避坑)
npm i vue@2.7 vue-server-renderer express cross-env

说明
vueVue.js 核心库
vue-server-rendererVue 服务端渲染工具
express基于 Node 的 Web 服务框架
cross-env通过 npm scripts 设置跨平台环境变量,用于区分不同模式的打包环境:生产模式、开发模式

(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

说明
webpackwebpack 核心包
webpack-cliwebpack 的命令行工具
webpack-mergewebpack 配置信息合并工具;把服务端打包的配置文件和客户端打包的配置文件中的共同部分抽取出来进行合并
webpack-node-externals排除 webpack 中的 Node 模块,例如fs、http、path等不需要进行打包
rimraf基于 Node 封装的一个跨平台 rm -rf 工具;可以在命令行执行删除操作,主要用于删除之前的打包出来的dist
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处理图片资源

运行上述命令安装生产环境的依赖和开发环境的依赖。

3、webpack配置文件

初始化 webpack 打包配置文件
在根目录下新建 build 文件夹存放 webpack 配置文件
build
├── webpack.base.config.js # 公共配置
├── webpack.client.config.js # 客户端打包配置文件
└── webpack.server.config.js # 服务端打包配置文件
可以参考一下配置
webpack.base.config.js

/**
 * 公共配置
 */
// 处理.vue资源的插件
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)
// 环境变量中的NODE_ENV
const isProd = process.env.NODE_ENV === 'production'

module.exports = {
    mode: isProd ? 'production' : 'development',
    output: {
        path: resolve('../dist/'), // 打包结果输出到dist文件夹
        publicPath: '/dist/',  // 所有的打包结果在请求的时候都以 /dist 开头,避免和路由匹配规则冲突
        filename: '[name].[chunkhash].js'  // 文件名 + hash 生成文件名;一旦文件内容发生改变,生成的文件名也会发生变化,强制浏览器请求新的资源
    },
    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/preprocessors.html
            // 例如处理 Less 资源
            // {
                // test: /\.less$/,
                // use: [
                    // 'vue-style-loader',
                    // 'css-loader',
                    // 'less-loader'
                // ]
            // },
        ]
    },
    plugins: [
        new VueLoaderPlugin(),
        new FriendlyErrorsWebpackPlugin()
    ]
}

webpack.client.config.js

/**
 * 客户端打包配置
 */
// merge: 合并webpack配置信息
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' // 客户端打包入口 相对路径是相对于vue-ssr目录
    },
    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()
    ]
})

关于 chunk,参考解释

在 Web 开发中,Webpack 是一个常用的模块打包工具,用于将前端应用程序的源代码和资源文件打包成可在浏览器中运行的静态文件。Webpack 的核心概念之一就是「chunk」(代码块)。

一个「chunk」代表着一个独立的代码块,其中包含了一个或多个模块。Webpack 在打包过程中会根据配置和依赖关系将应用程序的代码拆分成多个不同的「chunk」。这些「chunk」可以理解为被划分的逻辑模块,它们可以被异步加载,从而实现按需加载和代码分割的功能。

Webpack 在打包过程中会根据不同的策略将代码拆分成多个「chunk」。常见的拆分策略包括:

入口起点拆分:Webpack 会根据入口文件(entry)的配置将代码拆分成多个「chunk」。
动态导入拆分:通过使用动态导入(dynamic import)语法,Webpack 可以根据模块的异步加载需求将代码拆分成多个「chunk」。
第三方库拆分:Webpack 可以将第三方库(例如常用的 UI 框架)单独打包成一个「chunk」,以便于缓存和复用。
拆分成的「chunk」可以通过异步加载的方式进行加载,从而提高应用程序的性能和加载速度。当浏览器需要某个模块时,它可以根据需要异步加载对应的「chunk」,而不是一次性加载整个应用程序的所有代码。

总结起来,Webpack 中的「chunk」是指将代码拆分成的独立模块,可以通过异步加载方式进行按需加载。这种代码拆分和按需加载的策略有助于提高应用程序的性能和加载速度。

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)。
    // 常见可选值:web(输出面向浏览器的代码)/node
    target: 'node',
    output: {
        filename: 'server-bundle.js',
        // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
        // 即 module.exports 导出;require 导入
        libraryTarget: 'commonjs2'
    },
    // 不打包 node_modules 第三方包,而是保留 require 方式直接加载
    externals: [nodeExternals({
        // 白名单中的资源依然正常打包
        allowlist: [/\.css$/]
    })],
    plugins: [
        // 这是将服务器的整个输出构建为单个 JSON 文件的插件。
        // 默认文件名为 `vue-ssr-server-bundle.json`
        new VueSSRServerPlugin()
    ]
})
4、配置构建命令以及打包命令

在学习完 webpack 的配置之后要学习打包命令的配置。在 package.json 的 scripts 中配置命令
cross-env NODE_ENV=product 用来设置环境变量,表示进行生产模式的打包; --config 用来指定配置文件;
rimraf dist 删除dist文件夹

"scripts": {
  "build:client": "cross-env NODE_ENV=product webpack --config build/webpack.client.config.js",
  "build:server": "cross-env NODE_ENV=product webpack --config build/webpack.server.config.js",
  "build": "rimraf dist && npm run build:client && npm run build:server"
},

运行npm run build:client,报错:
在这里插入图片描述
这个插件再下载一遍:yarn add -D friendly-errors-webpack-plugin,再运行构建命令
报错:
在这里插入图片描述
发现用yarn add一次安装所有的依赖好像有问题,列表中的好多依赖没有安装
另外就是版本问题,安装的Vue是2.7的,要是挨个都指定版本那也太麻烦了,还是把 Vue 改成最新版本的吧。
执行打包命令之后,可以看到已经打包出来了dist文件夹
在这里插入图片描述

5、启动应用

接下来介绍如何利用上述写好的基本项目代码启动同构应用
renderer 渲染器的改造
上述代码中,我们如果在服务端修改了代码,需要重启服务,修改才能生效。另外,Node.js 不支持 source map,代码出现问题时,看不出在源码中出现问题的位置 。为了更方便处理服务端的热更新,我们需要使用 createBundleRenderer API,所创建的 bundle renderer,用法和普通 renderer 相同,renderer 是将一个 Vue 实例转换成字符串;而 bundle renderer 是将 bundle 转换为 HTML字符串。 bundle renderer 有以下优点:
✳️ 内置的 source map 支持(在 webpack 配置中使用 devtool: ‘source-map’)
✳️ 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
✳️ 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。更多细节请查看 CSS 章节。
✳️ 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。

关于bundle 参考解释
在软件开发中,“bundle”(捆绑)一词通常用来描述将多个文件或资源组合在一起形成单个文件或包的过程。它旨在简化部署、分发和使用这些文件或资源的过程。
“Bundle” 可以指代不同类型的捆绑,具体取决于上下文和所涉及的技术栈。以下是一些常见的 bundle 类型:
🌀 前端资源捆绑:在前端开发中,“bundle” 通常指代将多个 JavaScript、CSS 和其他静态资源文件合并到一个或多个文件中的过程。这样做可以减少网络请求的数量,加快网页加载速度,并简化前端代码的部署和管理。常见的前端资源捆绑工具包括 webpack、Parcel 和 Rollup。
🌀 应用程序捆绑:在应用程序开发中,“bundle” 通常指将应用程序的源代码、依赖项和其他资源打包到一个或多个可执行文件或库中的过程。这样做可以简化应用程序的部署和分发,并提供更好的性能和安全性。常见的应用程序捆绑工具包括 webpack、Parcel、Browserify 和 Rollup。
🌀 代码库捆绑:在软件开发中,“bundle” 有时也用于指代将多个代码文件或模块打包成一个独立的、可重用的代码库或包的过程。这样做可以简化代码的共享和复用,并提供更好的封装性。常见的代码库捆绑工具包括 webpack 和 Rollup。
总的来说,“bundle” 是将多个文件或资源组合在一起形成单个文件或包的过程,旨在简化部署、分发和使用这些文件或资源的过程。具体的 bundle 类型和实现方式取决于所涉及的技术栈和开发场景。

根据上面的解释,webpack 打包将资源列表、文件内容都打包成一个文件,这个文件就叫 bundle 。createBundleRenderer 就是获取 webpack 打包生成的 bundle,生成一个 bundle renderer ,bundle renderer 可以根据 bundle 的配置和内容生成HTML字符串。

介绍一下 createBundleRenderer API的用法。在创建 renderer 的时候,之前使用的是 createRenderer() 方法,将其替换为 createBundleRenderer() 方法。createBundleRenderer()方法在使用的时候,
第一个参数要接受 server bundle,server bundle就是webpack对服务端代码打包后生成的结果,其中包括了服务端代码所依赖的资源列表,直接从 dist 目录下引入;bundle renderer 可以监听这个 server bundle 修改,从而更新生成的要传递给客户端的 HTML 字符串;
第二个参数接收选项对象,选项对象同样可以传入模板,还需要传入 clientManiFest ,它是webpack打包客户端代码生成的结果,里面存放着客户端代码的依赖列表等内容,这个数据也从 dist 目录下通过 require 引入
server.js

const template = fs.readFileSync('./index.template.html', 'utf-8');
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManiFest = require('./dist/vue-ssr-client-manifest.json')
// 第 2 步:创建一个 renderer
const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
    template,
    clientManiFest  // 客户端打包出来的资源构建清单
})

② 路由处理函数改造
在设置路由的地方,之前是通过 new Vue() 创建Vue实例的,现在我们在 src/entry-server.js 中写好了创建应用的工厂函数,直接把原来创建实例的代码删除掉。renderToString() 方法中也不需要传入app了, renderer 会根据 server bundle 中的配置内容 自动找 src/entry-server.js 并调用方法获取实例。
server.js

// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')

// 加载express
const express = require('express')

// 创建server实例
const server = express()

const fs = require('fs')
// 设置路由
server.get('/', (req, res) => {
    const template = fs.readFileSync('./index.template.html', 'utf-8');
    const serverBundle = require('./dist/vue-ssr-server-bundle.json')
    const clientManiFest = require('./dist/vue-ssr-client-manifest.json')
    // 第 2 步:创建一个 renderer
    const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
        template,
        clientManiFest  // 客户端打包出来的资源构建清单
    })

    // 第 3 步:将 Vue 实例渲染为 HTML
    // 这里的第一个参数 app 删除,renderer 会自动获取
    renderer.renderToString({
        title: 'Vue SSR',
        meta: `<meta name="description" content="今天是个好日子"></meta>`
    }, (err, html) => {
        if (err) {
            return res.status(500).end('服务器错误')
        }
        res.setHeader('Content-Type', 'text/html; charset=utf8')
        res.end(html)
    })
})

// 启动服务
server.listen(3000, () => {
    console.log('服务启动')
})

然后使用 nodemon server.js 启动服务,报了一个版本不匹配的错误:
在这里插入图片描述
vue 和 vue-server-renderer 版本必须相同,都则会报错
运行代码

yarn remove vue-server-renderer vue
yarn add vue@2.6.14 vue-server-renderer@2.6.14

重新安装
然后再nodemon server.js 启动服务端
报错:
render function or template not defined in component: anonymous
很坑。这个报错还是依赖的版本问题。看了源码好久看不出来,把教程里面的package.json文件复制过来,并且把 node_module 文件夹删除(一定要先手动删除),重新下载依赖,并且重新构建项目,能成功启动项目了。
package.json

{
  "name": "vue-ssr",
  "private": true,
  "version": "1.0.0",
  "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",
    "start": "cross-env NODE_ENV=production node server.js",
    "dev": "node server.js"
  },
  "dependencies": {
    "axios": "^0.19.2",
    "chokidar": "^3.4.0",
    "cross-env": "^7.0.2",
    "express": "^4.17.1",
    "nodemon": "^3.0.1",
    "vue": "^2.6.11",
    "vue-meta": "^2.4.0",
    "vue-router": "^3.3.4",
    "vue-server-renderer": "^2.6.11",
    "vuex": "^3.5.1"
  },
  "devDependencies": {
    "@babel/core": "^7.10.4",
    "@babel/plugin-transform-runtime": "^7.10.4",
    "@babel/preset-env": "^7.10.4",
    "babel-loader": "^8.1.0",
    "css-loader": "^3.6.0",
    "file-loader": "^6.0.0",
    "friendly-errors-webpack-plugin": "^1.7.0",
    "rimraf": "^3.0.2",
    "url-loader": "^4.1.0",
    "vue-loader": "^15.9.3",
    "vue-template-compiler": "^2.6.11",
    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.12",
    "webpack-dev-middleware": "^3.7.2",
    "webpack-hot-middleware": "^2.25.0",
    "webpack-merge": "^5.0.9",
    "webpack-node-externals": "^2.5.0"
  }
}

③ 处理静态资源的中间件
当我们请求服务器端资源的时候,实际上请求的是服务器上的静态资源文件,例如html、css、js等。例如输入localhost:3000,实际上是使用get 请求去请求服务器上的根目录下的index.html文件。那么服务器必然需要有路由配置,匹配请求路径和静态资源文件之间的关系。express.static() 就是这个作用,就是为所有的请求配置好路由,以便于可以正确的匹配静态资源文件。
处理静态资源文件需要用到以下两个方法:
🚺 server.use():
将指定的一个或多个中间件函数挂载到指定的路径:当请求的路径的基与路径匹配时,执行中间件函数。
第一个参数是文件路径的匹配目录,
第二个参数是中间件函数。
如果请求的是/dist目录下的文件,就使用 express.static('./dist') 返回的中间件函数进行处理
🚺 express.static('./dist'):
指定静态资源的存放目录,就是说如果请求的是localhost:3000/dist/app.js,那么就会去根目录下的dist/app.js路径找对应文件资源

// 加载静态资源的中间件
server.use('/dist', express.static('./dist'))
6、解析渲染过程

渲染过程分为两个部分
① 服务端渲染是如何输出网页内容
在这里插入图片描述
② 客户端渲染如何接管资源并激活网页
渲染的入口是 renderer.renderToString(),这个方法负责将Vue实例转换成html字符串,但是它并没有接收应用实例,那么renderer是如何自动获取 Vue 实例的呢?

renderer.renderToString((err, html) => {
    if (err) {
        return res.status(500).end(JSON.stringify(err.message))
    }
    res.setHeader('Content-Type', 'text/html; charset=utf8')
    res.end(html)
})

看一下 renderer 的创建:

const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
    template,
    clientManifest  // 客户端打包出来的资源构建清单
})

createBundleRenderer() 的第一个参数 serverBundle 是从 ./dist/vue-ssr-server-bundle.json 文件引入的,这个文件是服务端打包出来的结果,里面描述了服务端打包的信息。

{
  "entry": "server-bundle.js", // 服务端打包之后的入口
  "files": {
    "server-bundle.js": "module.exports={//...}"    // 服务端入口文件代码直接输出并保存到这里,是对src/entry-server.js打包生成的结果
  },
  "maps": { // sourcemap相关信息,用于开发调试
    "server-bundle.js": {
      "version": 3,
      "sources": [],
      "mappings": "",
      "file": "server-bundle.js",
      "sourcesContent": [],
      "sourceRoot": ""
    }
  }
}

renderer 在渲染的时候,会加载 ./dist/vue-ssr-server-bundle.json 文件中定义的入口文件并执行其中代码,即 server-bundle.js 里面定义的代码,代码会被打包直接放在JSON文件里面,这里我省略了,因为代码太长。 然后把渲染的结果注入template模板中并发送给客户端。
另外,客户端要激活服务端返回回来的内容,就需要将客户端打包出来的脚本注入的页面当中。虽然在 tamplate 中并没有显式注入js脚本,但是在服务端返回的html文档中,是有脚本注入的
在这里插入图片描述
那么服务端是怎么自动找到脚本文件并注入到 tamplate 当中的呢?这就是 createBundleRenderer() 方法中,第二个参数配置项中的 clientManifest 起的作用。clientManifest 是引入的 dist/vue-ssr-client-manifest.json 文件,是客户端打包资源的构建清单,清单中就描述了客户端构建出来的资源的相关信息。

{
  "publicPath": "/dist/", // 与客户端配置的打包出口一致
  "all": [  // 客户端打包出来的所有资源文件名称
    "app.831a25e2cd5f80130e6f.js",
    "app.831a25e2cd5f80130e6f.js.map"
  ], 
  "initial": [  // renderer在渲染的时候就会把initial中的资源自动注入到模板页面的末尾处,通过script标签引入
    "app.831a25e2cd5f80130e6f.js"
  ],
  "async": [], // 存储异步资源的资源信息,例如异步组件、异步js模块
  "modules": {  // 针对原始模块做的依赖信息说明
    "7a80a878": [ // 模块标识
      0, // 依赖信息,指的是 all 里面的依赖资源的索引,0就表示app.js;
      1 // 1就表示app.js.map
    ]
  }
}

客户端在获取到服务器端返回的HTML之后,需要将静态的HTML激活为由Vue接管的动态HTML。客户端激活过程参考官方文档。服务端输出的HTML,其根节点即 id="app" 的节点上,会挂载一个属性data-server-rendered="true",这个属性就是让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。id是开发时需要开发者在模板组件中添加的:
App.vue

<div id="app">{{ message }}</div>

src/entry-client.js 文件里面,通过 id 名指定了应用的根节点 app.$mount('#app') 并挂载应用实例,如果要修改根节点的id,这里的代码要同步修改。
在开发模式下,Vue会推断客户端生成的虚拟DOM和服务器返回的DOM树的结构是否一致,如果不一致,将会丢弃服务器返回的虚拟DOM,重新进行渲染。
浏览器在渲染的时候可能会修改HTML结构以确保文档规范,例如,如果你的 table 标签中直接嵌套了 tr 标签,浏览器将会自动的增加 tbody 标签,因此在书写模板的时候,尽可能保证结构规范,保证服务端生成的DOM树和客户端生成的虚拟DOM树能够正确匹配。

(三)构建配置开发模式

目前打包的方式是,修改代码后需要重新执行 npm run build 执行构建,然后执行 nodemon server.js 启动服务。接下来要实现开发模式的构建,即修改代码立即自动构建、重启web服务并重新刷新浏览器页面内容。

1、基本思路

在开发环境中,我们想实现的功能是,修改代码之后,自动执行构建、自动重启web服务以及自动刷新浏览器页面。这个功能的实现主要依赖于:在代码修改的时候,要自动执行 require('vue-server-renderer').createBundleRenderer() 方法,根据最新打包结果生成新的renderer。但是这一流程是否进行,需要依赖于当前运行环境是不是开发环境,所以需要一个变量来判断当前环境是开发环境还是生产环境。
首先我们在 package.json 中创建两个构建命令,分别用来执行开发环境构建和生产环境构建:

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

在创建 renderer 的时候,先判断当前环境,如果是生产环境就直接根据打包生成的结果生成renderer,如果是开发环境,需要监听内容变化、自动执行构建并引入构建之后的结果。这个流程稍后再介绍,先熟悉基本思路

// 判断环境
const isProd = process.env.NODE_ENV === 'production'

let renderer
if (isProd) {
    // 直接基于打包后的结果生成并启动renderer
    const template = fs.readFileSync('./index.template.html', 'utf-8');
    const serverBundle = require('./dist/vue-ssr-server-bundle.json')
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    // 第 2 步:创建一个 renderer
    renderer = require('vue-server-renderer').createBundleRenderer(serverBundle, {
        template,
        clientManifest  // 客户端打包出来的资源构建清单
    })
} else {
    // 开发模式 -> 监视源代码的改动,自动打包构建 -> 重新生成 renderer
}

另外,在设置路由的时候,如果是生产环境,执行之前的代码,直接渲染模板是没有问题的;但如果是开发环境就有问题了,因为开发环境下的一套流程是很耗时间的,此时并不能确保 renderer 已经创建成功。所以开发环境要等待 renderer 创建成功后再执行渲染。

const render = (req, res) => {
    // 第 3 步:将 Vue 实例渲染为 HTML
    renderer.renderToString((err, html) => {
        if (err) {
            return res.status(500).end(JSON.stringify(err.message))
        }
        res.setHeader('Content-Type', 'text/html; charset=utf8')
        res.end(html)
    })
}

// 加载静态资源的中间件
server.use('/dist', express.static('./dist'))

// 设置路由
server.get('/', isProd
    ? render
    : (req, res) => {
        // 等待有了renderer再执行render
    })
2、提取处理模块

接下来要考虑的是,开发环境,【监视源代码的改动 -> 自动打包构建 -> 重新生成 renderer 】这个过程如何实现。将这个过程的实现封装成一个模块。
开发一个模块首先要考虑的是接收的参数和返回值。
模块在构建过程中,可能要给当前 server 实例注册一些插件或者获取 server 实例上的属性或方法,所以要接受 server 实例作为参数;还需要接收一个回调函数作为参数,这个回调函数的功能就是renderer 的创建,以便于模块在检测到代码打包构建完成之后,执行 renderer 的创建。
在访问路由的时候,需要检测 renderer 的创建完成时机,执行渲染,所以应该返回一个 Promise,在 renderer 的创建完毕后,改为成功状态。
由此可见,入参是server实例和回调函数;返回值是一个 Promise 对象。
这里对于 Promise 的作用有一些感悟,凡是一个异步方法,都可以返回 Promise ,先创建一个 Promise ,在异步操作执行完毕后,将 Promise 的状态改为 resolved。
先写出来该模块的结构:
build/setup-dev-server.js

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

    // 监视构建 -> 更新renderer -> 改为成功态

    return onReady
}

在执行该模块的时候,需要一个变量接收返回值,并且在访问路由时,需要等待模块返回的Promise的状态变为resolved,再执行渲染
server.js

// 复用的方法抽离出来
const {createBundleRenderer} = require('vue-server-renderer')
const setupDevServer = require('./build/setup-dev-server')

// 判断环境
const isProd = process.env.NODE_ENV === 'production'

let renderer
let onReady
if (isProd) {
    // 直接基于打包后的结果生成并启动renderer
    const template = fs.readFileSync('./index.template.html', 'utf-8');
    const serverBundle = require('./dist/vue-ssr-server-bundle.json')
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    // 第 2 步:创建一个 renderer
    renderer = createBundleRenderer(serverBundle, {
        template,
        clientManifest  // 客户端打包出来的资源构建清单
    })
} else {
    // 开发模式 -> 监视源代码的改动,自动打包构建 -> 重新生成 renderer
    // 获取server实例 开发模式下需要给server实例挂载中间件
    // setupDevServer()返回Promise,完成构建以后会变成成功状态
    onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
        renderer = createBundleRenderer(serverBundle, {
            template,
            clientManifest  // 客户端打包出来的资源构建清单
        })
    })
}

const render = (req, res) => {
    // 第 3 步:将 Vue 实例渲染为 HTML
    renderer.renderToString((err, html) => {
        if (err) {
            return res.status(500).end(JSON.stringify(err.message))
        }
        res.setHeader('Content-Type', 'text/html; charset=utf8')
        res.end(html)
    })
}

// 加载静态资源的中间件
server.use('/dist', express.static('./dist'))

// 设置路由
server.get('/', isProd
    ? render
    : async (req, res) => {
        // 等待有了renderer再执行render
        await onReady
        render()
    })
4、update()更新函数

创建一个update()方法,专门由于调用callback(),更新renderer,并且执行Promiseresolve(),以便于在访问路由的时候,可以监测到renderer构建成功的时机,并执行渲染。
创建renderer需要三个资源:serverBundle, template, clientManifest。所以在模块中,要创建三个变量,接收这三个资源。如果这三个资源都构建完毕,就执行callback(),创建renderer。并且,这三个资源其中的任何一个发生变化,都需要执行update()方法,重新更新renderer
build/setup-dev-server.js

module.exports = (server, callback) => {
    let ready  // 接收resolve函数
    const onReady = new Promise(r => {
        ready = r
    })

    // 监视构建 -> 更新renderer -> 改为成功态

    let template
    let serverBundle
    let clientManifest

    const update = () => {
        if (template && serverBundle && clientManifest) {
            callback(serverBundle, template, clientManifest)
            // 执行Promise的resolve
            ready()
        }
    }

    // 监视构建 template -> 调用 update -> 更新 renderer
    // 监视构建 serverBundle -> 调用 update -> 更新 renderer
    // 监视构建 clientManifest -> 调用 update -> 更新 renderer

    return onReady
}
5、处理 template

template 资源的原始文件是index.template.html。资源在初始化的时候要获取模板内容以及在原始文件代码更新的时候需要更新。获取文件内容使用Node.js提供的fs模块

const fs = require('fs')
const path = require('path')
// 监视构建 template -> 调用 update -> 更新renderer
const templatePath = path.resolve(__dirname, '../index.template.html')
template = fs.readFileSync(templatePath, 'utf-8')
update()

监听文件内容修改,原生的Node.js提供的有 fs.watch()fs.watchFile() 都可以实现,但是都有各自的限制。推荐使用一个第三方库chokibar,它是对fs.watch()fs.watchFile()等方法的封装,使用起来更简洁方便。
cnpm i chokibar 安装
const chokidar = require('chokidar') 引入
③ 使用

// on()的第一个参数是监听的修改的类型 change监听内容修改
chokidar.watch(templatePath).on('change', (event, path) => {
    console.log("template change");
});

先测试一下能否成功监听模板内容变化,在终端运行npm run dev 启动开发环境模式下的服务,并且修改index.template.html里面的内容,并保存,可以看到终端输出了:
在这里插入图片描述
所以此时已经能够正确监听模板内容变化了。
那么在监听到模板内容变化后,需要重新获取文件内容并且赋值给 template 变量,并且执行 update() 方法更新 renderer

chokidar.watch(templatePath).on('change', (event, path) => {
     template = fs.readFileSync(templatePath, 'utf-8')
     update()
 });
6、服务端监视打包

服务端监视打包就是监听server.js的修改,需要使用 webpack 提供的方法。
① 引入webpack const webpack = require('webpack')
② 封装文件路径的获取方法

const resolve = file => path.resolve(__dirname, file)

③ 引入服务端配置

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

④ 调用webpack()方法
webpack() 方法的返回值是一个 Webpack 编译器实例。Webpack 编译器实例提供了 watch() 方法,用于监视文件的变化并自动重新编译。当你调用 watch() 方法时,Webpack将会监听配置中指定的文件,并在文件发生变化时自动重新执行编译操作。

const serverCompiler = webpack(serverConfig)

⑤ 监听
打包成功以后,会生成dist/vue-ssr-server-bundle.json文件,这个文件的内容直接赋值给serverBundle,并且调用update()更新 renderer

serverCompiler.watch({}, (err, stats) => {
    if (err) throw err // webpack本身错误,例如配置写错
    if (stats.hasErrors()) return // 源代码中的错误
    // 获取打包后的资源
    // 不能使用require加载,因为require加载有缓存
    // 默认读取的是buffer二进制,要手动设置utf-8
    serverBundle = JSON.parse(fs.readFileSync('../dist/vue-ssr-server-bundle.json', 'utf-8'))
    update()
})
7、把数据写到内存中

webpack 打包构建会默认把构建结果存储到磁盘中进行读写操作,在开发模式下,频繁修改代码触发构建会频繁地读写磁盘数据,这个过程比较慢,所以最好将数据存放到内存中,读写会更快速。
webpack 提供了一个API custom-file-systems,用来自定义webpack的文件系统。
可以使用webpack提供的中间件 webpack-dev-middleware 进行配置,这个中间件会自动将打包结果存到内存中。
① 加载 webpack-dev-middleware

const devMiddleware = require('webpack-dev-middleware')

② 使用 webpack-dev-middleware
webpack-dev-middleware 会自动替我们进行打包构建,所以构建服务端就不需要手动调用 watch() 方法。中间件构建过程会打印很多日志,传递 logLevel: 'silent' 配置是阻止日志输出,因为我们的项目中已经使用了 friendly-errors-webpack-plugin 插件统一管理日志输出。webpack-dev-middleware 打包的结果不会存储到磁盘上,而是会存储到内存中。需要将serverCompiler.watch() 方法的执行注释掉。

// 监视构建 serverBundle -> 调用 update -> 更新renderer
const serverConfig = require('./webpack.server.config')
const serverCompiler = webpack(serverConfig)
devMiddleware(serverCompiler,{
    logLevel: 'silent', // 关闭日志输出
})

我们先把dist/vue-ssr-server-bundle.json文件删除,然后运行npm run dev,可以看到,并没有重新创建该文件,其实是在内存里创建了该文件,在项目目录中看不到。
我们还需要监视构建,当打包构建完成之后,需要获取内存中的vue-ssr-server-bundle.json文件 ,并且重新执行 update() 方法。获取vue-ssr-server-bundle.json文件就不能再使用fs模块读取磁盘内容了,需要通过 devMiddleware() 方法的返回值获取。监听构建完成事件需要给 serverCompiler 增加钩子函数的回调。done是构建完成的生命周期,tap是监听生命周期的方法,里面接受的第一个参数是一个标识符,没有特殊的作用;第二个参数是回调函数。serverDevMiddleware.fileSystem 就是内存中的文件系统,和 fs 模块的使用一样,里面的路径都不需要修改。

const serverDevMiddleware = devMiddleware(serverCompiler,{
    logLevel: 'silent', // 关闭日志输出
})
serverCompiler.hooks.done.tap('server',()=>{
    serverBundle = JSON.parse(serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json', 'utf-8')))
    update()
})
8、客户端监视打包

客户端监视打包和服务端监视打包的思路是一样的,同样是使用中间件 devMiddleware,我们只需要仿照上面服务端监视打包的代码写就可以了。和服务端监视打包不同的是,还有一个特殊的配置需要做,就是 publicPath,这个配置是指定中间件绑定的公共路径,也就是客户端请求时配置的路由前缀,当访问这个前缀下的路由时,中间件便会生效。

// 监视构建 clientManifest -> 调用 update -> 更新renderer
const clientConfig = require('./webpack.client.config')
const clientCompiler = webpack(clientConfig)
const clientDevMiddleware = devMiddleware(clientCompiler,{
    publicPath: clientConfig.output.publicPath,
    logLevel: 'silent', // 关闭日志输出
})
clientCompiler.hooks.done.tap('client',()=>{
    clientManifest = JSON.parse(clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json', 'utf-8')))
    update()
})

以上代码,就实现了自动监听代码修改,生成 renderer 的过程。回到server.js中,当setupDevServer()返回的 Promise 对象 onReady 的状态变为 resolved 之后,需要执行 render() 方法,将 bundle renderer 转换为HTML字符串
server.js

// 设置路由
server.get('/', isProd
    ? render
    : async (req, res) => {
        console.log('111')
        // 等待有了renderer再执行render
        await onReady
        console.log('222')
        render(req, res)
    })

写到这里我们可以测试一下,在命令行运行 npm run dev,并且访问 localhost:3000。看一下网络请求,发现有一个 404 错误❌!找不到这个js文件。这是为什么呢?
在这里插入图片描述
我们看一下 localhost 的返回值,这个 app.js 是在里面的,为什么加载不了呢?
在这里插入图片描述
思考一下,我们处理客户端打包构建时,使用到了 devMiddleware 中间件,把打包后的资源放在了内存中,而在 server.js 中,我们配置了处理静态资源的中间件: server.use('/dist', express.static('./dist')),这里 express.static() 处理的是物理磁盘中的文件,也就是服务端接收到来自客户端的请求的时候,会去物理磁盘上找文件,但是我们把打包结果放在了内存中,所以就找不到打包生成的js文件了。
解决办法就是将 clientDevMiddleware 挂载到 Express 服务中,提供对其内部内存中数据的访问,Express 在找文件的时候就会尝试去内存中找文件。在客户端打包构建的最后增加一句代码

server.use(clientDevMiddleware)
9、热更新

上面过程我们已经实现了打包构建的自动化,但是在每次修改完Vue实例中的代码之后,还需要刷新浏览器才能看到最新效果。webpack提供了热更新的中间件webpack-hot-middleware,热更新可以在打包之后自动更新网页中内容。
由于只有在开发环境下才需要热更新,所以以下的配置都在 setup-dev-server.js 中进行,不会修改webpack配置文件。根据github的示例,
① 安装 npm install --save-dev webpack-hot-middleware
② 增加插件
③ 将 'webpack-hot-middleware/client' 路径增加到出口 entry 列表;之前配置的是字符串,需要改为数组
④ 额外的一点需要注意,我们配置的打包出口文件是用hash命名的:filename: '[name].[chunkhash].js' ,但是热更新插件要求打包出口文件的名字要保持不变,这样才能自动找到打包结果并更新页面,因此,还需要修改打包出口文件名的命名规则
⑤ 给 server 注册中间件
build/setup-dev-server.js


const hotMiddleware = require("webpack-hot-middleware")

// 监视构建 clientManifest -> 调用 update -> 更新renderer
const clientConfig = require('./webpack.client.config')
// 新增插件
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
// 增加一个打包出口
// quiet=true 表示阻止控制台打印日志
// reload=true 表示如果页面加载失败则自动重新加载
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 // 关闭它本身的日志输出
}))
server.use(clientDevMiddleware)

运行 npm run dev 启动项目,并且访问3000端口,在网络请求中可以看到一个 hotMiddleware 创建的请求
在这里插入图片描述
它的工作原理是在开发服务器和浏览器之间建立一个WebSocket连接。当开发服务器检测到代码更改时,它会通过WebSocket将更新的模块信息发送到浏览器。浏览器接收到更新的模块后,会使用热模块替换技术将新模块应用到正在运行的应用程序中,从而实现实时的模块替换。

此时,修改src/App.vue中的代码,并且保存,浏览器无需刷新,其中的内容就会自动更新。

(四)路由处理

先回顾一下SPA应用中的路由使用的步骤,此处就是简单回顾一下,可以看官网的入门示例快速回顾一下:
① 在首页模板中需要有<router-link to='/home'> 路由跳转链接,to 属性可以是字符串,指向路由的地址,也可以是一个对象,其中有 name 路由名称、query 路径参数等属性
模版中还需要有<router-view>路由占位符;
这两个标签没有也能实现路由跳转,没有的话,路由的跳转就需要使用编程式导航:this.$router.push('/home')
② 需要有一个路由配置数组,通常定义为 routes,其中定义了路径 path 和组件 component 之间的对应关系
③ 创建路由实例,并且将上面定义的路由配置routes 传递进去

const router = VueRouter.createRouter({
  routes, // `routes: routes` 的缩写
})

④ 将路由实例挂载到应用实例上 app.use(router)

1、配置 VueRouter

VueRouter 和 Vue一样,要配置成一个函数的形式,每次访问都要返回一个新的 VueRouter 实例。首先我们先来配置 VueRouter,在 src 目录下新建 pages 目录和 router 目录:
在这里插入图片描述
pages 目录存放路由对应的页面,router 目录新建 index.js 进行路由配置
关于路由模式mode,在SPA项目中,我们通常配置成hash路由。
Hash路由使用URL中的哈希(#)符号来管理导航,在路由跳转时不会像服务器发送请求,其浏览器兼容性比较好;History路由是Web的History API 实现的,通过修改URL的路径部分来表示不同的页面或状态,而不是使用哈希片段,每次路由跳转都会将相应的URL添加到浏览器的历史记录中,可以通过前进或后退按钮进行导航,并且每次路由跳转都会向服务器发送HTTP请求。
因此在服务器端配置路由表只能使用History路由。
只有首页页面需要提前加载,其他的页面都可以使用懒加载的形式:component:()=>import('@/pages/Abput'),当访问这个路由的时候再加载这个页面

import Vue from 'vue'
import VueRouter from "vue-router";
import Home from "../pages/Home";
// 注册路由
Vue.use(VueRouter);
export const createRouter = () => {
    const router = new VueRouter({
        mode: 'history', // 大多数服务端不接受hash路由
        routes:[
            {
                path:'/',
                name:'home',
                component: Home
            },
            {
                path:'/about',
                name:'about',
                // 异步懒加载
                component:()=>import('@/pages/Abput')
            },
            {
                path:'*',
                name:'error404',
                // 异步懒加载
                component:()=>import('@/pages/404')
            }
        ]
    })
    return router
}
2、将路由注册到Vue实例

src/app.js 中引入创建路由实例的方法,并且创建路由,把路由挂载到 App 根应用实例上。导出应用实例的时候,把路由一并导出,这样就可以在其他文件中获取路由,便于操作路由。
src/app.js

/**
 * 同构应用通用的启动入口
 * */
import Vue from 'vue'
import App from './App.vue'
import {createRouter} from "./router";
// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
    const router = createRouter()
    const app = new Vue({
        router, // 把路由挂载到 Vue 根实例
        // 根实例简单的渲染应用程序组件。
        render: h => h(App)
    })
    return { app, router }
}

3、服务器端路由逻辑

实现服务器端路由逻辑可以参考官网代码,直接将官网代码复制粘贴到项目中,其中的关于匹配不上路由的处理我已经去掉了,因为我们已经处理好了404的匹配逻辑
src/entry-server.js

// 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(() => {
      // Promise 应该 resolve 应用程序实例,以便它可以渲染
      resolve(app)
    }, reject)
  })
}

以上是 Promise 的形式,返回了一个 Promise,其中的逻辑是,首先创建 app 实例和 router 实例,然后执行 router.push(),将传进来的参数的 url 增加到路由表中,这个具体的用法后续会讲到;最后是 router.onReady() 事件,它是路由初始化事件,里面的两个参数分别是初始化成功的回调和初始化失败的回调,当路由初始化成功后,Promise 的状态改为 resolved
所以整一个 Promise 的作用就是要等 router.onReady() 事件执行成功。可以将其改写为 async/await 形式,看起来更加的清晰。

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

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

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

    // 等到 router 将可能的异步组件和钩子函数解析完
    await new Promise(router.onReady.bind(router))
    return app
}
4、服务端server适配

上面服务器端的路由配置需要接受一个 context 对象,里面包含 url 属性。接下来我们要处理一下传递的 context 参数。context 参数是从哪里传进来的呢?我们要回到 server.js 文件,其中我们配置了路由

server.get('/', isProd
    ? render
    : async (req, res) => {
        // 等待有了renderer再执行render
        await onReady
        render(req, res)
    })

这里的意思是如果路由匹配到 /,就执行 render() 方法。在 render() 方法中,执行了renderer.renderToString() 方法,这个方法的第一个参数是传递给模板的参数,在模版中可以使用插值表达式使用这里传递的参数

const render = (req, res) => {
    // 第 3 步:将 Vue 实例渲染为 HTML
    renderer.renderToString({
        title:'Vue SSR'
    },(err, html) => {
        if (err) {
            return res.status(500).end(JSON.stringify(err.message))
        }
        res.setHeader('Content-Type', 'text/html; charset=utf8')
        res.end(html)
    })
}

看一下 renderToString() 方法的定义
在这里插入图片描述

可以看出第一个参数接收的是 context 上下文对象,我们扒一下源码的执行,源码会有点难看懂,全局搜一下这个方法,找到的结果有很多,怎么确定是我们要找的方法呢?首先我们要找的是函数的定义而非函数的调用,发现函数定义后,顺着代码往上找,发现下面这个函数是在 createBundleRendererCreator() 方法里面,显然这个函数是用来创建 bundle renderer 的,就是我们要找的目标
在这里插入图片描述
这个函数的关键在于调用了 run() 方法,并且把 context 参数传递了进去
在这里插入图片描述
关于 run() 方法,是调用 createBundleRunner() 方法的返回值
在这里插入图片描述
看一下第一个参数 entry 是怎么来的
在这里插入图片描述
bundle 就是 createBundleRenderer() 方法传递进来的第一个参数
在这里插入图片描述
就是 serverBundle
在这里插入图片描述
entry 是打包结果中的 server-bundle.jsserver-bundle.js 里的代码就是 entry-server.js 入口文件中的代码打包生成的结果
在这里插入图片描述
接下来我们看一下 run() 方法里面做了什么。 run() 方法由下面的一个高阶函数返回,其中通过 if...else... 分为两个分支,我们只看其中的一种情况。接收一个参数,就是 context 参数,我们主要看 context 参数会被用来做什么
在这里插入图片描述
关键就是这句

const res = evaluate(entry, createSandbox(userContext));

evaluate() 又是 compileModule() 高阶函数的返回值,这个函数的代码有点长,我只列出有用的部分,主要看传进来的上下文参数。可以看出,上下文参数传递给了 script.runInNewContext() 方法。

function compileModule(files, basedir, runInNewContext) {
    function evaluateModule(filename, sandbox, evaluatedFiles = {}) { 
    	// ...
        // sandbox:包含用户传进来的context数据
        const compiledWrapper = runInNewContext === false
            ? script.runInThisContext()
            : script.runInNewContext(sandbox);
        compiledWrapper.call(m.exports, m.exports, r, m);
        // ...
        return res;
    }
    return evaluateModule;
}

script 对象:创建了一个脚本对象
在这里插入图片描述

script.runInNewContext(sandbox) :在指定的上下文运行JavaScript代码。

因此,传入的上下文对象,会作为 entry-server.js 中的代码执行时的上下文,也就是说,entry-server.js 导出的函数,其接受的上下文对象,就是 renderer.renderToString() 方法的第一个参数。
entry-server.js 中,回顾一下代码:

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

export default async context => {
    const {app, router} = createApp()

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

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

需要将上下文对象中的 url 路径加入到路由中
因此,renderer.renderToString() 方法的第一个参数需要加上 url ,即 req 请求实例的 url ,顺便改为 async/await 形式。

const render = async (req, res) => {
    try {
        // 第 3 步:将 Vue 实例渲染为 HTML
        // 执行renderToString的时候,就会调用服务端入口entry-server.js中的
        // 方法,将第一个参数传递给entry-server中的方法
        // 并返回一个app实例
        // 因此这里的第一个参数就是entry-server中方法的入参
        const html = await renderer.renderToString({
            title: 'Vue SSR',
            url: req.url
        })
        res.setHeader('Content-Type', 'text/html; charset=utf8')
        res.end(html)
    } catch (err) {
        res.status(500).end(JSON.stringify(err.message))
    }
}
5、适配客户端入口

根据官网文档,在entry-client.js 客户端入口中,需要在挂载 app 之前调用 router.onReady(),确保路由解析完毕再执行渲染方法。
entry-client.js

import { createApp } from './app'

// 创建应用实例
const { app, router } = createApp()

router.onReady(() => {
    app.$mount('#app')
})
6、路由出口

App.vue 中配置 <router-link> 标签和 <router-view> 标签

<template>
  <div id="app">
    <ul>
      <li>
        <router-link to="/">Home</router-link>
      </li>
      <li>
        <router-link to="/about">About</router-link>
      </li>
    </ul>
    <router-view></router-view>
  </div>
</template>
<script>
</script>

此时就可以实现路由跳转的功能了
运行 npm run dev方法,并且访问localhost:3000`
在这里插入图片描述

(五)HEAD管理

现如今,所有页面的HEAD内容都是在模版文件中写死的
index.template.html

<head>
    <meta charset="UTF-8">
    <title>{{title}}</title>
</head>

Vue SSR 提供了一种解决方案,可以给每个页面定制自己的 HEAD 头部内容。有兴趣的同学可以参考官网文档:HEAD管理。只不过这种方式略显复杂,这里介绍一个第三方库 vue-meta 同样可以实现为每个页面进行 HEAD 头部定制。使用起来非常的简单,可以参考官方文档:vue-meta
① 安装 vue-meta : npm i vue-meta
② 以插件方式注册
src/app.js 应用通用入口

import VueMeta from "vue-meta";
Vue.use(VueMeta)

③ 提供默认 title 属性和 title 模版
通过混入的方式,提供一个配置项 metaInfo,如果页面没有设置 metaInfo.title ,会使用默认的 title;如果设置了 metaInfo.title ,会将设置的数据拼接到模板中的占位符的位置

Vue.mixin(({
    metaInfo: {
        title: '默认的title',
        titleTemplate: '%s - 哈哈哈哈'
    }
}))

针对服务端渲染的配置
server.js 中需要获取 meta 信息,挂载到上下文对象上。

// 获取 meta 配置信息
const meta = app.$meta()

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

context.meta = meta;

将 meta 数据注入到页面 HEAD 当中
在有页面模版文件的情况下,只需要在 <head> 标签中填充

<head>
    <meta charset="UTF-8">
    <title>{{title}}</title>
    {{{ meta.inject().title.text() }}}
    {{{ meta.inject().meta.text() }}}
</head>

⑥ 使用
在组件中使用,例如 HOME 组件,只需要在组件的实例中提供 mateInfo 数据

<template>
  <h1>HOME</h1>
</template>
<script>
export default {
  name: 'HomePage',
  metaInfo:{
    title:'首页'
  }
}
</script>

启动项目之后,点击首页理由
在这里插入图片描述
首页路由应用了自己提供的数据,再看一下 about 路由
在这里插入图片描述
是我们配置的默认的 title
很多属性都可以这样配置,具体用法可以参考 metainfo配置

(六)数据预取与状态

我们使用服务端渲染的一个原因就是可以在页面渲染之前、在服务端渲染期间获取页面初始渲染需要的数据,从而优化首屏加载效果。在服务端渲染期间执行的生命周期是 beforeCeate()created(),获取数据的操作通常是在 created() 生命周期中执行的HTTP请求方法,因为只有在 created() 生命周期中才能获取到 data 数据。在客户端渲染中,我们通常在获取数据之后,通过 this.data.list = res.data 类似的代码,给 this.data 中的字段赋值。但是在服务端渲染期间,是不能通过这样的代码给 this.data 进行赋值操作的,因为此时的 this 上下文对象并不指向应用实例。
Vue SSR 为我们提供了解决方案。即将服务端获取数据的过程放在视图组件之外,在渲染之前就进行数据预取,需要使用到 Vuex 。官网也给我们提供了一系列的示例代码。
① 创建 store/index.js
为了避免多个应用共用同一个 store 容器,需要将容器定义为函数的形式,同样其中的 state 数据也定义成函数的形式
将需要预取的数据初始化到 state 中;修改 state 数据的方法存放到 mutations 中;获取数据的异步请求放在 actions 中;
src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export const createStore = () => {
    return new Vuex.Store({
        // 需要预取的数据放在state中
        // 使用函数返回的方式,避免单例,数据污染
        state: () => ({
            posts: []
        }),
        mutations: {
            setPosts(state, data) {
                state.posts = data
            }
        },
        actions: {
            // 在服务端渲染期间务必让 action 返回一个 Promise
            // actions 中的方法第一个参数默认是上下文对象
            // async 函数默认返回 Promise
            async getPosts({commit}) {
                const {data} = await new Promise((resolve, reject) => {
                    resolve({
                        status: 200,
                        data: [
                            {title: '背影', author: '朱自清'},
                            {title: '匆匆', author: '朱自清'},
                        ]
                    })
                })
                commit('setPosts', data)
            }

        }
    })
}

② 将容器挂载到应用实例上
src/app.js

/**
 * 同构应用通用的启动入口
 * */
import Vue from 'vue'
import App from './App.vue'
import {createRouter} from "./router";
import VueMeta from "vue-meta";
import { createStore } from "./store";

Vue.use(VueMeta)
Vue.mixin({
    metaInfo: {
        title: '默认的title',
        titleTemplate: '%s - 哈哈哈哈'
    }
})
// 导出一个工厂函数,用于创建新的
// 应用程序、router 和 store 实例
export function createApp () {
    const router = createRouter()
    const store = createStore()
    const app = new Vue({
        router, // 把路由挂载到 Vue 根实例
        store,
        // 根实例简单的渲染应用程序组件。
        render: h => h(App)
    })
    return { app, router, store }
}

③ 在需要使用数据的组件中获取数据
serverPrefetch() 是 Vue SSR 提供的服务端预取数据的生命周期,执行实际在 created() 生命周期之后
在这里插入图片描述
规定这个生命周期函数专门用于发送 axios 请求,返回一个 Promise ,会自动解析 Promise 中 resolve 的数据,并且注入到当前组件中;但是这里并不需要 serverPrefetch() 返回数据,只需要调用 store.actions 中的 getPosts() 方法,获取数据之后赋值给 state 中的对应属性
在需要服务端数据的组件中:
🌐 模版中需要绑定数据
🌐 引入 mapState, mapActions 方法,用于将 store 中的数据映射到当前组件中
🌐 serverPrefetch() 生命周期中发送 axios,获取数据并且赋值给 store.state
🌐 在计算属性中通过 mapState() 挂载当前组件需要展示的数据

<template>
  <div>
    <h1>HOME</h1>
    <ul>
      <li v-for="(post, index) in posts" :key="index">
        {{ post.title }}
      </li>
    </ul>
  </div>

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

export default {
  name: 'HomePage',
  metaInfo: {
    title: '首页'
  },
  data() {
    return {}
  },
  computed: {
    ...mapState(['posts'])
  },
  // vue ssr 特殊为服务端渲染提供的钩子函数
  serverPrefetch() {
    // 规则要求这个方法内部需要发起 axios 返回 Promise
    return this.getPosts()
  },
  methods: {
    ...mapActions(['getPosts'])
  }
}
</script>

④ 将预取数据同步到客户端
上述我们已经在服务端预取数据,启动项目、打开页面看一下数据有没有正常展示
服务端返回的页面中,数据已经正确渲染了
在这里插入图片描述
但是在页面中,数据只是闪了一下又消失了,并且控制台还报出了一个错误
在这里插入图片描述
这是由于,在开发模式下客户端渲染时,Vue会推断客户端生成的虚拟DOM和服务器返回的DOM树的结构是否一致,如果不一致,将会丢弃服务器返回的虚拟DOM,重新进行渲染。此时,只是在服务端获取了数据,但是没有同步到客户端,所以客户端渲染出来的虚拟DOM是没有数据的,Vue 发现客户端虚拟DOM树和服务端返回的DOM树出现冲突,就会重新渲染。
解决这个问题就需要将服务端预取的数据同步到客户端
① 将 state 数据发送给客户端
entry-server.js 中,在路由解析完毕异步组件之后,给上下文对象挂在 renderer() 方法,这个方法再服务端渲染完毕以后会被调用。在方法的内部,将 state 数据挂载到上下文对象 context 上,renderer 会将 context.state 数据对象内联到页面模版当中,在客户端就可以解析出来
另外,顺便将文件中的异步逻辑改为 async/await 形式

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

export default async context => {
    // ...

    context.meta = meta;
    // 等待 router 把可能得异步组件和钩子函数解析完
    await new Promise(router.onReady.bind(router))
    // 服务端渲染完毕以后会调用 context.rendered
    context.rendered = () => {
        // Renderer 会把 context.state 数据对象内联到页面模版中
        // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = ${context.state}
        // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端的容器 store 中
        context.state = store.state
    }
    return app
}

此时看一下浏览器中的效果,服务端渲染的结果中包含 context.state 数据
在这里插入图片描述
② 在客户端解析 context.state
src/entry-client.js
引入 store 容器,const { app, router, store } = createApp()
先不做处理,先看一下客户端的 store 容器,
其中的 state.posts 是空数组
在这里插入图片描述
判断一下 window.__INITIAL_STATE__ 对象如果存在的话,就调用 store.replaceState() 方法,替换容器中的 state 数据

if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}

此时页面就可以正常展示数据了
在这里插入图片描述

(七)总结

本篇主要记录了 Vue SSR 的学习过程。Vue SSR 是基于 Vue 的同构渲染的方案,需要应用到 Node.js 的一些 API 和 webpack 的一些配置。学下来感觉比较复杂,一是 Node.js 的很多 API 以及操作文件系统的思想不太熟悉,一是渲染过程比较复杂,代码分为好几部分,有的代码在服务端运行,有的代码在客户端运行;另外就是 webpack 的方法配置平时用的不多,所以也比较生疏。
使用 Vue SSR 的好处是初始页面渲染的会更快,有利于SEO,但是真正学完知道,开发成本真的高。
在 Node 环境下,只要应用启动了,所有请求返回的都是同一个应用,为了避免单例模式造成应用不刷新,Vue 实例要使用函数返回的方式,每次请求都返回一个新的 Vue 实例,VueRouter、Vuex 实例也类似。
客户端和服务端都需要创建一个应用实例,以及VueRouter实例、容器store实例,还需要一个模版文件。
服务端会渲染出一个 DOM 树,这个过程需要依赖 webpack 配置,webpack 将服务端代码打包成一个 json 文件。Vue SSR基于打包结果,生成一个 renderer 渲染器,渲染器将 Vue 应用转换为 html 字符串,返回给客户端。
开发环境下,客户端也会根据应用、路由、数据生成虚拟 DOM 树,浏览器在渲染过程中会判断客户端生成的虚拟 DOM 树和服务端返回的 DOM 树结构是否一致,如果不一致会根据客户端的虚拟 DOM 树重新渲染 DOM 。
Vue SSR 还提供了可以为每一个页面定制 head 标签的方法。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值