Bootstrap

搭建自己的 SSR

Study Notes

Vue SSR 介绍

SSR 官方文档

什么是服务器端渲染 (SSR)?

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

:::tip 官方介绍

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

服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行。

:::

使用场景

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

技术层面:

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

业务层面:

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

如何实现 Vue SSR

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

官方方案具有更直接的控制应用程序的结构,更深入底层,更加灵活,同时在使用官方方案的过程中,也会对 Vue SSR 有更加深入的了解。

该方式需要你熟悉 Vue.js 本身,并且具有 Node.js 和 webpack 的相当不错的应用经验。

Nuxt.js 开发框架

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

Vue SSR 基本使用

项目 demo

安装

yarn

yarn add vue vue-server-renderer

npm

npm i vue vue-server-renderer

:::warning 注意

  • 推荐使用 Node.js 版本 6+。
  • vue-server-renderer 和 vue 必须匹配版本。
  • vue-server-renderer 依赖一些 Node.js 原生模块,因此只能在 Node.js 中使用。我们可能会提供一个更简单的构建,可以在将来在其他「JavaScript 运行时(runtime)」运行。

:::

渲染一个 Vue 实例

server.js

/**
 * @author Wuner
 * @date 2020/9/8 16:13
 * @description
 */

const Vue = require('vue');

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

// 第 2 步:创建一个 Vue 实例
const app = new Vue({
  template: `<div>{{ message }}</div>`,
  data: {
    message: 'Hello World',
  },
});

// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
  if (err) throw err;
  console.log(html);
  // => <div data-server-rendered="true">Hello World</div>
});

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

与服务器集成

在 Node.js 服务器中使用时相当简单直接,例如 Express

安装 express

yarn
yarn add express
npm
npm i express

在 Web 服务中渲染 Vue 实例:

/**
 * @author Wuner
 * @date 2020/9/8 16:13
 * @description
 */

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

service.get('/', (req, res) => {
  // 第 3 步:创建一个 Vue 实例
  const app = new Vue({
    template: `
      <div>{{ message }}</div>`,
    data: {
      message: 'Hello World',
    },
  });

  // 第 4 步:将 Vue 实例渲染为 HTML
  renderer.renderToString(app, (err, html) => {
    // 异常时,抛500,返回错误信息,并阻止向下执行
    if (err) {
      res.status(500).end('Internal Server Error');
      return;
    }

    // 返回HTML
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `);
  });
});

// 绑定并监听指定主机和端口上的连接
service.listen(3000, () =>
  console.log(`service listening at http://localhost:3000`),
);

启动服务

node service.js

此时可以看到页面展示着 Hello World

不过使用中文时,会出现中文乱码

如何解决中文乱码

  • meta 设置编码字符集为 utf-8
// 返回HTML
res.end(`
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <title>Hello</title>
      <meta charset="UTF-8" />
    </head>
    <body>${html}</body>
  </html>
`);
  • 设置服务端响应头
// 设置响应头,解决中文乱码
res.setHeader('Content-Type', 'text/html;charset=utf8');

接单

小编承接外包,有意者可加
QQ:1944300940
在这里插入图片描述

微信号:wxid_g8o2y9ninzpp12
在这里插入图片描述

使用一个页面模板

当你在渲染 Vue 应用程序时,renderer 只从应用程序生成 HTML 标记 (markup)。在这个示例中,我们必须用一个额外的 HTML 页面包裹容器,来包裹生成的 HTML 标记。

为了简化这些,你可以直接在创建 renderer 时提供一个页面模板。多数时候,我们会将页面模板放在特有的文件中,例如 index.template.html:

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

::: warning 注意

<!--vue-ssr-outlet--> 注释 – 这里将是应用程序 HTML 标记注入的地方。

:::

然后,我们可以读取和传输文件到 Vue renderer 中:

const renderer = require('vue-server-renderer').createRenderer({
  template: require('fs').readFileSync('./index.template.html', 'utf-8'),
});

// 返回HTML, 该html的值 将是注入应用程序内容的完整页面
res.end(html);

模板插值

模板还支持简单插值。给定如下模板:

<html>
  <head>
    <!-- 使用双花括号(double-mustache)进行 HTML 转义插值(HTML-escaped interpolation) -->
    <title>{{ title }}</title>

    <!-- 使用三花括号(triple-mustache)进行 HTML 不转义插值(non-HTML-escaped interpolation) -->
    {{{ meta }}}
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

我们可以通过传入一个"渲染上下文对象",作为 renderToString 函数的第二个参数,来提供插值数据:

const context = {
  title: 'vue ssr demo',
  metas: `
        <meta name="keyword" content="vue,ssr">
        <meta name="description" content="vue srr demo">
    `,
};

// 第 4 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, context, (err, html) => {
  // 异常时,抛500,返回错误信息,并阻止向下执行
  if (err) {
    res.status(500).end('Internal Server Error');
    return;
  }

  // 返回HTML, 该html的值 将是注入应用程序内容的完整页面
  res.end(html);
});

也可以与 Vue 应用程序实例共享 context 对象,允许模板插值中的组件动态地注册数据。

此外,模板支持一些高级特性,例如:

  • 在使用 *.vue 组件时,自动注入「关键的 CSS(critical CSS)」;
  • 在使用 clientManifest 时,自动注入「资源链接(asset links)和资源预加载提示(resource hints)」;
  • 在嵌入 Vuex 状态进行客户端融合(client-side hydration)时,自动注入以及 XSS 防御。

完整实例代码

/**
 * @author Wuner
 * @date 2020/9/8 16:13
 * @description
 */

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

const context = {
  title: 'vue ssr demo',
  metas: `
        <meta name="keyword" content="vue,ssr">
        <meta name="description" content="vue srr demo">
    `,
};

service.get('/', (req, res) => {
  // 设置响应头,解决中文乱码
  res.setHeader('Content-Type', 'text/html;charset=utf8');

  // 第 3 步:创建一个 Vue 实例
  const app = new Vue({
    template: `
      <div>{{ message }}</div>`,
    data: {
      message: 'Hello World',
    },
  });

  // 第 4 步:将 Vue 实例渲染为 HTML
  renderer.renderToString(app, context, (err, html) => {
    // 异常时,抛500,返回错误信息,并阻止向下执行
    if (err) {
      res.status(500).end('Internal Server Error');
      return;
    }

    // 返回HTML, 该html的值 将是注入应用程序内容的完整页面
    res.end(html);
  });
});

// 绑定并监听指定主机和端口上的连接
service.listen(3000, () =>
  console.log(`service listening at http://localhost:3000`),
);

源码结构

项目 demo

介绍构建步骤

到目前为止,我们还没有讨论过如何将相同的 Vue 应用程序提供给客户端。为了做到这一点,我们需要使用 webpack 来打包我们的 Vue 应用程序。事实上,我们可能需要在服务器上使用 webpack 打包 Vue 应用程序,因为:

  • 通常 Vue 应用程序是由 webpack 和 vue-loader 构建,并且许多 webpack 特定功能不能直接在 Node.js 中运行(例如通过 file-loader 导入文件,通过 css-loader 导入 CSS)。

  • 尽管 Node.js 最新版本能够完全支持 ES2015 特性,我们还是需要转译客户端代码以适应老版浏览器。这也会涉及到构建步骤。

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

在这里插入图片描述

使用 webpack 的源码结构

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

一个基本项目可能像是这样:

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

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

/**
 * @author Wuner
 * @date 2020/9/8 17:51
 * @description
 */
import Vue from 'vue';
import App from './App.vue';

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

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

/**
 * @author Wuner
 * @date 2020/9/8 17:55
 * @description
 */
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)。

/**
 * @author Wuner
 * @date 2020/9/8 17:57
 * @description
 */
import { createApp } from './app';

export default (context) => {
  const { app } = createApp();
  return app;
};
server.js
/**
 * @author Wuner
 * @date 2020/9/8 16:13
 * @description
 */

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

const context = {
  title: 'vue ssr demo',
  metas: `
        <meta name="keyword" content="vue,ssr">
        <meta name="description" content="vue srr demo">
    `,
};

service.get('/', (req, res) => {
  // 设置响应头,解决中文乱码
  res.setHeader('Content-Type', 'text/html;charset=utf8');

  // 第 3 步:创建一个 Vue 实例
  const app = new Vue({
    template: `
      <div>{{ message }}</div>`,
    data: {
      message: 'Hello World',
    },
  });

  // 第 4 步:将 Vue 实例渲染为 HTML
  renderer.renderToString(app, context, (err, html) => {
    // 异常时,抛500,返回错误信息,并阻止向下执行
    if (err) {
      res.status(500).end('Internal Server Error');
      return;
    }

    // 返回HTML, 该html的值 将是注入应用程序内容的完整页面
    res.end(html);
  });
});

// 绑定并监听指定主机和端口上的连接
service.listen(3000, () =>
  console.log(`service listening at http://localhost:3000`),
);

构建配置

项目 demo

安装依赖

安装生产依赖

yarn
yarn add vue vue-server-renderer express cross-env
npm
npm i vue vue-server-renderer express cross-env
说明
vueVue.js 核心库
vue-server-rendererVue 服务端渲染工具
express基于 Node 的 Web 服务框架
cross-env通过 npm scripts 设置跨平台环境变量

安装开发依赖

yarn
yarn add 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 -D
npm
npm i 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 -D
说明
webpackwebpack 核心包
webpack-cliwebpack 的命令行工具
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-loader
Babel 相关工具
vue-loader
vue-template-compiler
处理 .vue 资源
file-loader处理字体资源
css-loader处理 CSS 资源
url-loader处理图片资源

配置文件及打包命令

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

公共配置

webpack.base.config.js

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

客户端打包配置文件

webpack.client.config.js

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

服务端打包配置文件

webpack.server.config.js

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

配置打包命令

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"
  }
}

测试编译

编译服务端
yarn build:server
编译客户端
yarn build:client
同时编译服务端和客户端
yarn build

启动应用

service.js

/**
 * @author Wuner
 * @date 2020/9/8 16:13
 * @description
 */

const express = require('express');
const serverBundle = require('./dist/vue-ssr-server-bundle.json');
const template = require('fs').readFileSync('./index.template.html', 'utf-8');
const clientManifest = require('./dist/vue-ssr-client-manifest.json');
const { createBundleRenderer } = require('vue-server-renderer');

// 第 1 步:创建一个 renderer
const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext
  template, // (可选)页面模板
  clientManifest, // (可选)客户端构建 manifest
});
// 第2步:创建 service
const service = require('express')();

const context = {
  title: 'vue ssr demo',
  metas: `
        <meta name="keyword" content="vue,ssr">
        <meta name="description" content="vue srr demo">
    `,
};
service.use('/dist', express.static('./dist'));
service.get('/', (req, res) => {
  // 设置响应头,解决中文乱码
  res.setHeader('Content-Type', 'text/html;charset=utf8');

  // 第 3 步:将 Vue 实例渲染为 HTML
  // 这里的Vue实例,使用的是src/entry-server.js 中挂载的Vue实例
  // 这里无需传入Vue实例,因为在执行 bundle 时已经自动创建过。
  // 现在我们的服务器与应用程序已经解耦!
  renderer.renderToString(context, (err, html) => {
    // 异常时,抛500,返回错误信息,并阻止向下执行
    if (err) {
      console.log(err);
      res.status(500).end('Internal Server Error');
      return;
    }

    // 返回HTML, 该html的值 将是注入应用程序内容的完整页面
    res.end(html);
  });
});

// 绑定并监听指定主机和端口上的连接
service.listen(3000, () =>
  console.log(`service listening at http://localhost:3000`),
);

解析渲染流程

服务端渲染
  • renderer.renderToString 渲染了什么?

  • renderer 是如何拿到 entry-server 模块的?

    • const renderer = createBundleRenderer(serverBundle, { /* 选项 */ })
  • serverBundle(vue-ssr-server-bundle.json) 是 Vue SSR 构建的一个特殊的 JSON 文件

    • entry:入口

    • files:所有构建结果资源列表

    • maps:源代码 source map 信息

  • vue-ssr-server-bundle.json 就是通过 server.entry.js 构建出来的结果文件

  • 最终把渲染结果注入到模板中

客户端渲染
  • vue-ssr-client-manifest.json

  • publicPath:访问静态资源的根相对路径,与 webpack 配置中的 publicPath 一致

  • all:打包后的所有静态资源文件路径

  • initial:页面初始化时需要加载的文件,会在页面加载时配置到 preload 中

  • async:页面跳转时需要加载的文件,会在页面加载时配置到 prefetch 中

  • modules:项目的各个模块包含的文件的序号,对应 all 中文件的顺序;moduleIdentifier 和 和 all 数组中文件的映射关系(modules 对象是我们查找文件引用的重要数据)

热更新构建

service.js

/**
 * @author Wuner
 * @date 2020/9/8 16:13
 * @description
 */

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

const createServer = require('./build/create-server');

// 第 1 步:创建一个 renderer
let renderer;
let onReady;
// 第2步:创建 service
const service = require('express')();

onReady = createServer(
  service,
  (serverBundle, options) =>
    (renderer = createBundleRenderer(serverBundle, options)),
);
const context = {
  title: 'vue ssr demo',
  metas: `
        <meta name="keyword" content="vue,ssr">
        <meta name="description" content="vue srr demo">
    `,
};

const render = (req, res) => {
  // 设置响应头,解决中文乱码
  res.setHeader('Content-Type', 'text/html;charset=utf8');

  // 第 3 步:将 Vue 实例渲染为 HTML
  // 这里的Vue实例,使用的是src/entry-server.js 中挂载的Vue实例
  // 这里无需传入Vue实例,因为在执行 bundle 时已经自动创建过。
  // 现在我们的服务器与应用程序已经解耦!
  renderer.renderToString(context, (err, html) => {
    // 异常时,抛500,返回错误信息,并阻止向下执行
    if (err) {
      console.error(err);
      res.status(500).end('Internal Server Error');
      return;
    }

    // 返回HTML, 该html的值 将是注入应用程序内容的完整页面
    res.end(html);
  });
};
service.use('/dist', express.static('./dist'));
service.get('/', async (req, res) => {
  await onReady;
  render(req, res);
});

// 绑定并监听指定主机和端口上的连接
service.listen(3000, () =>
  console.log(`service listening at http://localhost:3000`),
);

build/create-server.js

/**
 * @author Wuner
 * @date 2020/9/27 11:46
 * @description 异步创建
 */

const chokidar = require('chokidar');
const isProd = process.env.NODE_ENV === 'production';
const { readFileSync } = require('fs');
const path = require('path');
const webpack = require('webpack');
const resolve = (filePath) => path.join(__dirname, filePath);

module.exports = (server, callback) => {
  let ready;
  const onReady = new Promise((resolve) => (ready = resolve));
  let templatePath = path.resolve(__dirname, '../index.template.html');
  let serverBundlePath = path.resolve(
    __dirname,
    '../dist/vue-ssr-server-bundle.json',
  );
  let clientManifestPath = path.resolve(
    __dirname,
    '../dist/vue-ssr-client-manifest.json',
  );
  let serverBundle, template, clientManifest;

  const update = () => {
    // 构建完毕,通知 server 可以 render 渲染了
    if (serverBundle && template && clientManifest) {
      ready();
      // 更新 server 中的 Renderer
      callback(serverBundle, {
        runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext
        template, // (可选)页面模板
        clientManifest, // (可选)客户端构建 manifest
      });
    }
  };

  // 开发模式
  // 打包构建(客户端 + 服务端) -> 创建渲染器
  // 监视构建 template,调用 update -> 更新 Renderer

  // 监视构建 serverBundle,调用 update -> 更新 Renderer

  // 监视构建 clientManifest,调用 update -> 更新 Renderer

  return onReady;
};
更新模板
  • 可以使用 node 原生 api 监听文件变更
    • fs.watch
    • fs.watchFile
  • 可以使用第三方包,监听文件变更
    • chokidar

原生的 api 有点缺陷,这里我们使用第三方包

yarn add chokidar -D
// 监视构建 template,调用 update -> 更新 Renderer
template = readFileSync(templatePath, 'utf-8');
update();
chokidar.watch(templatePath).on('change', (event, path) => {
  template = readFileSync(templatePath, 'utf-8');
  console.log(event, path);
});
更新服务端打包
使用磁盘读写

使用磁盘读写时,在开发环境下,会时常更新文件,这将导致频繁读写磁盘,效率低下。

// 监视构建 serverBundle,调用 update -> 更新 Renderer
const serverConfig = require('./webpack.server.config');
const serverCompiler = webpack(serverConfig);
serverCompiler.watch({}, (err, status) => {
  if (err) throw err;
  if (status.hasErrors()) return false;
  // 这里不要使用require读取文件,require读取出来的内容会有缓存,失去了更新的意义
  const serverBundle = JSON.parse(
    readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8'),
  );
  update();
使用内存读写

默认情况下,webpack 使用普通文件系统来读取文件并将文件写入磁盘。但是,还可以使用不同类型的文件系统(内存(memory), webDAV 等)来更改输入或输出行为。为了实现这一点,可以改变 inputFileSystem 或 outputFileSystem。例如,可以使用 memfs 替换默认的 outputFileSystem,以将文件写入到内存中,而不是写入到磁盘。

const { createFsFromVolume, Volume } = require('memfs');
const webpack = require('webpack');

const fs = createFsFromVolume(new Volume());
const compiler = webpack({
  /* options */
});

compiler.outputFileSystem = fs;
compiler.run((err, stats) => {
  // Read the output later:
  const content = fs.readFileSync('...');
});

由于 memfs 配置相对麻烦,我们这里使用官方提供的 webpack-dev-middleware 插件

yarn add webpack-dev-middleware -D
// 内存读写
const { fileSystem } = middleware(serverCompiler, {
  logLevel: 'silent', // 关闭日志输出
});
serverCompiler.hooks.done.tap('serverCompiler', () => {
  serverBundle = JSON.parse(
    fileSystem.readFileSync(
      resolve('../dist/vue-ssr-server-bundle.json'),
      'utf-8',
    ),
  );
  update();
});
更新客户端打包
// 监视构建 clientManifest,调用 update -> 更新 Renderer
const clientConfig = require('./webpack.client.config');
const clientCompiler = webpack(clientConfig);
const clientMiddleware = middleware(clientCompiler, {
  publicPath: clientConfig.output.publicPath,
  logLevel: 'silent', // 关闭日志输出
});
clientCompiler.hooks.done.tap('clientCompiler', () => {
  clientManifest = JSON.parse(
    clientMiddleware.fileSystem.readFileSync(
      resolve('../dist/vue-ssr-client-manifest.json'),
      'utf-8',
    ),
  );
  update();
});

// !!!将 clientMiddleware挂载到express服务中,提供对其内部内存中的数据的访问
server.use(clientMiddleware);
热更新

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

yarn add webpack-hot-middleware -D
// 热更新配置
clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin());
clientConfig.entry.app = [
  'webpack-hot-middleware/client?reload=true&quiet=true', // 和服务端交互处理热更新一个客户端脚本
  clientConfig.entry.app,
];
clientConfig.output.filename = '[name].js';

server.use(
  hotMiddleware(clientCompiler, {
    log: false, // 关闭日志输出
  }),
);

工作原理:

  • 中间件将自身安装为 webpack 插件,并侦听编译器事件。
  • 每个连接的客户端都有一个 Server Sent Events 连接,服务器将在编译器事件上向连接的客户端发布通知。
  • 当客户端收到消息时,它将检查本地代码是否为最新。如果不是最新版本,它将触发 webpack 热模块重新加载。
完整配置

build/create-server.js

/**
 * @author Wuner
 * @date 2020/9/27 11:46
 * @description 异步创建
 */

const chokidar = require('chokidar');
const isProd = process.env.NODE_ENV === 'production';
const { readFileSync } = require('fs');
const path = require('path');
const webpack = require('webpack');
const resolve = (filePath) => path.join(__dirname, filePath);
const middleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');

module.exports = (server, callback) => {
  let ready;
  const onReady = new Promise((resolve) => (ready = resolve));
  let templatePath = path.resolve(__dirname, '../index.template.html');
  let serverBundlePath = path.resolve(
    __dirname,
    '../dist/vue-ssr-server-bundle.json',
  );
  let clientManifestPath = path.resolve(
    __dirname,
    '../dist/vue-ssr-client-manifest.json',
  );
  let serverBundle, template, clientManifest;

  const update = () => {
    // 构建完毕,通知 server 可以 render 渲染了
    if (serverBundle && template && clientManifest) {
      ready();
      // 更新 server 中的 Renderer
      callback(serverBundle, {
        runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext
        template, // (可选)页面模板
        clientManifest, // (可选)客户端构建 manifest
      });
    }
  };

  if (isProd) {
    serverBundle = require(serverBundlePath);
    template = readFileSync(templatePath, 'utf-8');
    clientManifest = require(clientManifestPath);
    // 生产模式,直接基于已构建好的包创建渲染器
    update();
  } else {
    // 开发模式
    // 打包构建(客户端 + 服务端) -> 创建渲染器
    // 监视构建 template,调用 update -> 更新 Renderer
    template = readFileSync(templatePath, 'utf-8');
    update();
    chokidar.watch(templatePath).on('change', (event, path) => {
      template = readFileSync(templatePath, 'utf-8');
      console.log(event, path);
    });
    // 监视构建 serverBundle,调用 update -> 更新 Renderer
    const serverConfig = require('./webpack.server.config');
    const serverCompiler = webpack(serverConfig);
    // 磁盘读写方式
    // serverCompiler.watch({}, (err, status) => {
    //   if (err) throw err;
    //   if (status.hasErrors()) return false;
    //   // 这里不要使用require读取文件,require读取出来的内容会有缓存,失去了更新的意义
    //   serverBundle = JSON.parse(
    //     readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8'),
    //   );
    //   console.log(serverBundle);
    //   update();
    // });

    // 内存读写
    const { fileSystem } = middleware(serverCompiler, {
      logLevel: 'silent', // 关闭日志输出
    });
    serverCompiler.hooks.done.tap('serverCompiler', () => {
      serverBundle = JSON.parse(
        fileSystem.readFileSync(
          resolve('../dist/vue-ssr-server-bundle.json'),
          'utf-8',
        ),
      );
      update();
    });

    // 监视构建 clientManifest,调用 update -> 更新 Renderer
    const clientConfig = require('./webpack.client.config');
    // 热更新配置
    clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin());
    clientConfig.entry.app = [
      'webpack-hot-middleware/client?reload=true&quiet=true', // 和服务端交互处理热更新一个客户端脚本
      clientConfig.entry.app,
    ];
    clientConfig.output.filename = '[name].js';

    const clientCompiler = webpack(clientConfig);
    const clientMiddleware = middleware(clientCompiler, {
      publicPath: clientConfig.output.publicPath,
      logLevel: 'silent', // 关闭日志输出
    });
    clientCompiler.hooks.done.tap('clientCompiler', () => {
      clientManifest = JSON.parse(
        clientMiddleware.fileSystem.readFileSync(
          resolve('../dist/vue-ssr-client-manifest.json'),
          'utf-8',
        ),
      );
      update();
    });

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

    // 热更新配置
    server.use(
      hotMiddleware(clientCompiler, {
        log: false, // 关闭日志输出
      }),
    );
  }

  return onReady;
};

编写通用代码

在进一步介绍之前,让我们花点时间来讨论编写"通用"代码时的约束条件 - 即运行在服务器和客户端的代码。由于用例和平台 API 的差异,当运行在不同环境中时,我们的代码将不会完全相同。所以这里我们将会阐述你需要理解的关键事项。

服务器上的数据响应

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

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

组件生命周期钩子函数

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

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

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

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

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

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

:::warning

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

:::

区分运行环境

参考:

  • https://webpack.js.org/plugins/define-plugin/
new webpack.DefinePlugin({
  'process.client': true,
  'process.server': false,
});

自定义指令

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

  1. 推荐使用组件作为抽象机制,并运行在「虚拟 DOM 层级(Virtual-DOM level)」(例如,使用渲染函数(render function))。

  2. 如果你有一个自定义指令,但是不是很容易替换为组件,则可以在创建服务器 renderer 时,使用 directives 选项所提供"服务器端版本(server-side version)"。

路由和代码分割

项目 demo

接下来我们来了解一下如何处理通用应用中的路由。

官方文档给出的解决方案肯定还是使用 vue-router,整体使用方式和纯客户端的使用方式基本一致,只需要在少许的位置做一些配置就可以了。文档中已经把配置的方式描述的很清楚了,建议大家认真看一下文档,下面我把具体的实现来演示一下。

安装依赖

yarn add vue-router

src/router/index.js

/**
 * ..author Wuner
 * ..date 2020/10/28 8:18
 * ..description 路由
 */
import Vue from 'vue';
import VueRouter from 'vue-router';
import Home from '../view/Home';

Vue.use(VueRouter);

export const createRouter = () => {
  return new VueRouter({
    mode: 'history', // 兼容前后端
    routes: [
      {
        path: '/',
        name: 'home',
        component: Home,
      },
      {
        path: '/about',
        name: 'about',
        component: () => import('../view/About'), // 路由懒加载
      },
      {
        path: '*',
        name: 'error404',
        component: () => import('../view/404'),
      },
    ],
  });
};

src/app.js

/**
 * @author Wuner
 * @date 2020/9/8 17:51
 * @description
 */
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 };
}

src/entry-server.js

/**
 * @author Wuner
 * @date 2020/9/8 17:57
 * @description 服务端配置
 */
import { createApp } from './app';

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

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

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

  return app;
};

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

service.js

/**
 * @author Wuner
 * @date 2020/9/8 16:13
 * @description
 */

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

const createServer = require('./build/create-server');

// 第 1 步:创建一个 renderer
let renderer;
let onReady;
// 第2步:创建 service
const service = require('express')();

onReady = createServer(
  service,
  (serverBundle, options) =>
    (renderer = createBundleRenderer(serverBundle, options)),
);
const context = {
  title: 'vue ssr demo',
  metas: `
        <meta name="keyword" content="vue,ssr">
        <meta name="description" content="vue srr demo">
    `,
};

const render = async (req, res) => {
  // 设置响应头,解决中文乱码
  res.setHeader('Content-Type', 'text/html;charset=utf8');

  // 第 3 步:将 Vue 实例渲染为 HTML
  // 这里的Vue实例,使用的是src/entry-server.js 中挂载的Vue实例
  // 这里无需传入Vue实例,因为在执行 bundle 时已经自动创建过。
  // 现在我们的服务器与应用程序已经解耦!
  context.url = req.url; // 将url传递给 entry-server.js
  try {
    const html = await renderer.renderToString(context);
    // 返回HTML, 该html的值 将是注入应用程序内容的完整页面
    res.end(html);
  } catch (err) {
    // 异常时,抛500,返回错误信息,并阻止向下执行
    res.status(500).end('Internal Server Error');
  }
};

// express.static 处理的是物理磁盘中的资源文件
service.use('/dist', express.static('./dist'));
// 服务端路由设置为 *,意味着所有的路由都会进入这里
service.get('*', async (req, res) => {
  await onReady;
  render(req, res);
});

// 绑定并监听指定主机和端口上的连接
service.listen(3000, () =>
  console.log(`service listening at http://localhost:3000`),
);

src/entry-client.js

/**
 * @author Wuner
 * @date 2020/9/8 17:55
 * @description 客户端配置
 */
import { createApp } from './app';

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

const { app, router } = createApp();

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

App.vue

最后要在 App.vue 根组件中来设置路由的出口,因为没有路由出口的话,匹配到的路由组件就不知道要渲染到哪里。

<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 />
  </div>
</template>

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

配置好出口以后,启动应用:

yarn start:dev

启动成功,访问页面。

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

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

除此之外,我们在路由中配置的异步组件(也叫路由懒加载)也是非常有意义,它们会被分割为独立的 chunk(也就是单独的文件),只有在需要的时候才会进行加载。这样就能够避免在初始渲染的时候客户端加载的脚本过大导致激活速度变慢的问题。关于它也可以来验证一下,通过 npm run build 打包构建,我们发现它们确实被分割成了独立的 chunk。然后再来看一下在运行期间这些 chunk 文件是如何加载的。

你会发现除了 app 主资源外,其它的资源也被下载下来了,你是不是要想说:不是应该在需要的时候才加载吗?为什么一上来就加载了。

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

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

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

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

好了,关于同构应用中路由的处理,以及代码分割功能就介绍到这里。

管理页面 Head

项目 demo

无论是服务端渲染还是客户端渲染,它们都使用的同一个页面模板。

页面中的 body 是动态渲染出来的,但是页面的 head 是写死的,也就说我们希望不同的页面可以拥有自己的 head 内容,例如页面的 title、meta 等内容,所以下面我们来了解一下如何让不同的页面来定制自己的 head 头部内容。

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

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

Vue Meta 是一个支持 SSR 的第三方 Vue.js 插件,可让你轻松的实现不同页面的 head 内容管理。

使用它的方式非常简单,而只需在页面组件中使用 metaInfo 属性配置页面的 head 内容即可。

安装依赖

yarn add vue-meta -D

src/app.js

在通用入口中通过插件的方式将 vue-meta 注册到 Vue 中。

import VueMeta from 'vue-meta';

Vue.use(VueMeta);

Vue.mixin({
  metaInfo: {
    titleTemplate: '%s - vue-ssr-demo',
  },
});

src/entry-server.js

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

context.meta = app.$meta();

index.template.html

在模板页面中注入 meta 信息

{{{ meta.inject().title.text() }}}
{{{ meta.inject().meta.text() }}}

配置 Vue

直接在组件中使用即可

src/view/Home.vue

metaInfo: { title: '首页', }

src/view/About.vue

metaInfo: { title: '关于', }

关于使用 vue-meta 管理页面 Head 我们就介绍这些,大家可以根据自己的需求,多查阅文档,灵活使用即可。

数据预取和状态

项目 demo

接下来我们来了解一下服务端渲染中的数据预取和状态管理。

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

我们的需求就是:

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

无论如何,我们都要来尝试一下:

在这里插入图片描述

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

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

在组件中添加生命周期钩子,beforeCreate 和 created,服务端渲染仅支持这两个钩子函数的调用。

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

// 服务端渲染
  //     只支持 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')
  }

接下来我们就按照官方文档 给出的参考来把服务端渲染中的数据预取以及状态管理来处理一下。

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

所以接下来要做的第一件事儿就是把 Vuex 容器创建出来。

安装依赖

yarn add axios vuex

模拟接口

service.js

service.post('/topics', (req, res) => {
  res.send({
    data: [
      {
        id: 1,
        title: '1',
      },
      {
        id: 2,
        title: '2',
      },
    ],
  });
});

创建 Vuex 容器

src/store/index.js

/**
 * @author Wuner
 * @date 2020/10/28 15:20
 * @description
 */
import Vue from 'vue';
import Vuex from 'vuex';
import axios from 'axios';

Vue.use(Vuex);

export const createStore = () => {
  return new Vuex.Store({
    state: () => ({
      posts: [],
    }),

    mutations: {
      setPosts(state, data) {
        state.posts = data;
      },
    },

    actions: {
      // 在服务端渲染期间务必让 action 返回一个 Promise
      async getPosts({ commit }) {
        // return new Promise()
        const { data } = await axios.post('http://localhost:3000/topics');
        commit('setPosts', data.data);
      },
    },
  });
};

挂载

在通用应用入口中将 Vuex 容器挂载到 Vue 根实例
src/app.js

/**
 * @author Wuner
 * @date 2020/9/8 17:51
 * @description
 */
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: {
    titleTemplate: '%s - vue-ssr-demo',
  },
});

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

调用 action

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

src/view/Posts.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 axios from 'axios'
import { mapState, mapActions } from 'vuex';

export default {
  name: 'PostList',
  metaInfo: {
    title: 'Posts',
  },
  data() {
    return {
      // posts: []
    };
  },
  computed: {
    ...mapState(['posts']),
  },
  // 客户端更新
  mounted() {
    if (!this.posts.length) {
      this.$store.dispatch('getPosts');
    }
  },
  beforeRouteLeave(to, from, next) {
    this.$store.commit('setPosts', []);
    next();
  },

  // Vue SSR 特殊为服务端渲染提供的一个生命周期钩子函数
  serverPrefetch() {
    // 发起 action,返回 Promise
    // this.$store.dispatch('getPosts')
    return this.getPosts();
  },
  methods: {
    ...mapActions(['getPosts']),
  },
  // 服务端渲染
  //     只支持 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>

服务端配置

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

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

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

src/entry-server.js

/**
 * @author Wuner
 * @date 2020/9/8 17:57
 * @description 服务端配置
 */
import { createApp } from './app';

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

  // 设置服务器端 router 的位置
  router.push(context.url);
  context.meta = app.$meta();

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

  context.rendered = () => {
    // Renderer 会把 context.state 数据对象内联到页面模板中
    // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
    // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    context.state = store.state;
  };

  return app;
};

客户端配置

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

src/entry-client.js

/**
 * @author Wuner
 * @date 2020/9/8 17:55
 * @description 客户端配置
 */
import { createApp } from './app';

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

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

// 如果当前页面中有 __INITIAL_STATE__ 数据,则直接将其填充到客户端容器中
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__);
}

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

下一篇——静态站点生成(SSG)——Gridsome

;