# 一、开始

本文讲解了关于 SSR 的基本概念,以及将 CSR 项目转为 SSR 时遇到的一些问题,及其解决方法。在解决问题的过程中,分析了一下内在原因。

# 二、SSR原理

Vue 默认是一个单页应用(SPA,single page web application),以 CSR(Client Side Rendering)方式渲染页面,就是一开始只请求空壳的 HTML,数据请求和 Dom 渲染都是在浏览器端完成。

服务端渲染(SSR,Server Side Rendering),就是将组件或页面通过服务器生成 html 字符串,再发送到浏览器,最后将静态标记"混合"为客户端上的应用程序。

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

服务端渲染的优点主要是良好的 SEO 和减少首屏时间,缺点是服务器负载增加、学习和开发成本上升。

# 1. 构建流程

  1. 先把组件渲染成 htmlnodeserver 中返回,中间如果有请求数据则会请求数据。
  2. 请求到的数据会放到 window.__INITIAL_STATE__ 中。
  3. entry-client 打包的文件在 html 中,在浏览器渲染阶段会执行。
  4. 浏览器渲染时判断 window.__INITIAL_STATE__ 有数据的话,会拿出来用,不用再重复请求。

# 2. hydrate

把水理解为数据,hydrate 可以视为把字符串变成可用的组件渲染到浏览器。

  1. 服务端渲染:在服务端注入数据,构建出组件树,render 阶段。
  2. 序列化成 HTML:脱水,称为 dehydrate
  3. 客户端渲染:到达客户端后泡水,激活水流,称为 hydrate

# 3. 基本注意事项

  1. Vue、Vuex、Vue-Router 都要用工厂模式生成,防止多个用户之间的污染。

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

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

# 三、更多

上面介绍了 SSR 的一些基本概念和基本注意事项,在将 CSR 项目转 SSR 过程中,踩过一些坑,这里记录下。

# 1. 使用 lodash-es 时报错 Must use import to load ES Module

猜测报错的原因是 webpackimport 语句转为了 require

尝试在 webpackbabel-loader 配置中加上:

options: {
  presets: [
    [
      '@babel/preset-env',
      {
        modules: false,
      },
    ],
  ],
},

发现不起作用。

目前解决办法是,要么动态导入,要么将 lodash-es,改为 lodash

# 2. Koa 如何开启 https

为什么要开启 https,因为后台只接受 https 的请求。

开启 https 方式是利用 httpskoa-sslify 模块。

const Koa = require('koa');
const https = require('https');
const enforceHttps = require('koa-sslify').default;
const app = new Koa();
app.use(enforceHttps());

const options = {
  key: fs.readFileSync(path.resolve(__dirname, './ssl/private.key')),
  cert: fs.readFileSync(path.resolve(__dirname, './ssl/mydomain.crt')),
};

https.createServer(options, app.callback()).listen(port, () => {
  console.log(`应用实例,访问地址为 https://:localhost:${port}`);
});

上面的证书可以使用 openssl 生成自签名证书(免费,本地测试使用):

生成 private keycsr

openssl req -new -newkey rsa:2048 -nodes -out mydomain.csr -keyout private.key

利用上一步生成的 private.keymydomain.csr 生成自签名证书:

openssl x509 -req -days 365 -in mydomain.csr -signkey private.key -out mydomain.crt

打开网页,如果提示“Chrome浏览器出现:您目前无法访问 XX.XX.XX.XX,因为此网站发送了 Google Chrome 无法处理的杂乱凭据”,可以在当前页面顶层输入 thisisunsafe

# 3. Vue 全局 mixin

Vue.mixin 需在 Vue 实例化之前添加,举例:

Vue.mixin({
  methods: {
    testFn() {
      return '';
    },
  },
});

const app = new Vue({})

Vue.mixin({
  methods: {
    testFn2() {
      return '';
    },
  },
});

打印 app,会发现 app.testFn 存在,而 app.testFn2 不存在。

关于这个问题的内在原因,我在另一篇文章 (opens new window)有详细说明,使用的时候只要注意全局 mixin 要在实例化之前调用就行了。

# 4. 环境变量

由于不用 vue-cli 搭建,所以之前的 .env.local 中的 VUE_APP_DIR 等环境变量都要自己去取。

SSR 中的 node server 其实是充当中间层的作用,拿到用户的 cookie,带着它再去请求后台服务器。

cookie 注入到 global 的方法不再适用,因为会造成所有用户用同一个 cookie

可以将 cookie 放到 ssrcontext 中,在 entry-server.js 中调用 api.setCookies(context.cookies),发起请求的时候将 cookie 放在 axiosheader 中。

因为每一个到来的请求都会新建一个上下文,也就是每次都会调用 api.setCookies 方法,不会造成 cookie 污染。

// api.js
export default {
  api: null,
  cookies: '',
  setCookies(value = '') {
    this.cookies = value;
    this.api = axios.create({
      baseURL: config.baseURL,
      withCredentials: true,
      headers: {
        cookie: value,
      },
      timeout: config.timeout,
    });
  },
  post(url, data) {
    if (!this.api) this.setCookies();

    return new Promise((resolve, reject) => {
      this.api({
        method: 'post',
        url,
        data,
        headers: {
          'Content-Type': 'application/json',
        },
      }).then((res) => {
        resolve(res);
      })
        .catch((err) => {
          reject(err);
        });
    });
  },
};
// entry-server.js

export default function (context) {
  return new Promise((resolve, reject) => {
    const s = isDev && Date.now();
    const { app, router, store } = createApp();

    const { url } = context;
    const { fullPath } = router.resolve(url).route;

    if (fullPath !== url) {
      return reject({ url: fullPath });
    }
    router.push(url);

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();
      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      // 重点是这里
      api.setCookies(context.cookies);

      Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
        store,
        route: router.currentRoute,
      }))).then(() => {
        context.state = store.state;
        resolve(app);
      })
        .catch(reject);
    }, reject);
  });
};

# 6. window/document 的兼容

对于 window/documet/navigotor/location 等变量,如果必须暴露在 node 环境中,要想不报错的话,需要加如下的判断:

if (typeof window !== 'undefined') {
  // do something
}

注意,if (typeof window !== 'undefined') 不会报错,而 if (window && window.xx) 会报错。

# 7. Vant 按需加载

vant ssr 不支持 es,需要从 lib 中引入

import List from 'vant/lib/list';
import Toast from 'vant/lib/toast';

import 'vant/lib/index.css';

不能这样:

import { List } from 'vant'

# 8. 应用实例

服务端的 app 和客户端的 app 不是同一个,它们是完全隔离的。服务端的 app 是 NodeJS 生成的,客户端的是浏览器加载包含 Vue 的 JS 文件,然后实例化的,它们环境不一样,产物也不一样。

另外,SSR 服务端的 Vue 引入的是 CJS 版本,客户端引入的是 ESM 版本。 原因在于服务端的 webpack 配置了 target:node。这点在调试的时候要注意。

// node_modules/vue/package.json
{
  "main": "dist/vue.runtime.common.js",
  "module": "dist/vue.runtime.esm.js",
}

# 9. Express 相关

# (1)app.use

app.use 是用来给 path 注册中间函数的,这个 path 默认是 ’/’,也就是默认会处理用户的任何 url 请求。

注意,当设置了某一个 path 的时候,app.use 会同时处理 path 下的子路径:比如设置 path’/hello’,当请求路径为 ’/hello/’’/hello/nihao’’/hello/nihao/1’ 时,也会交给中间函数处理。

# (2)express.static()

作用是提供静态资源文件(图片、csss 文件、javascript 文件),比如:

app.use(express.static('public'));

express 会在静态资源目录下查找文件,所以不需要把静态目录 public 作为 url 的一部分。可以用以下方式加载 public 目录下的文件:

http://localhost:3000/hello.html
http://localhost:3000/images/1.jpg
http://localhost:3000/css/style.css
http://localhost:3000/js/index.js

可以多次使用 express.static 中间件来添加多个静态资源目录,这时 express 将会按照设置静态资源目录的顺序来查找静态资源文件:

app.use(express.static('public'));
app.use(express.static('files'));

为了给静态资源文件创建一个虚拟的文件前缀(实际上文件系统中并不存在) ,可以使用 express.static 函数指定一个虚拟的静态目录,就像下面这样:

app.use('/static', express.static('public'));

现在可以使用 /static 作为前缀来加载 public 文件夹下的文件了:

http://localhost:3000/static/hello.html
http://localhost:3000/static/images/1.jpg
http://localhost:3000/static/css/style.css
http://localhost:3000/static/js/index.js

然而,你提供给 express.static 函数的路径是一个相对 node 进程启动位置的相对路径。如果你在其他的文件夹中启动 express app,更稳妥的方式是使用静态资源文件夹的绝对路径:

app.use('/static', express.static(__dirname + '/public'));

# 四、总结

将 CSR 项目做 SSR 项目,比直接新建一个 SSR 项目要复杂一些,因为既要搭架子,又要将之前代码改造成兼容的,中间会遇到各种各样的问题,有些比较零碎。在解决问题过程中,最重要的应该是分析问题的本质。

# 五、相关资料

  1. webpack4、Koa配置Vue服务器端渲染(SSR) (opens new window)
  2. Must use import to load ES Module (opens new window)
  3. 将koa的node服务升级到https (opens new window)
  4. 使用nodejs搭建HTTPS server (opens new window)
  5. Chrome浏览器出现:您目前无法访问 XX.XX.XX.XX (opens new window)
  6. 再说 Vue SSR 的 Cookies 问题 (opens new window)
  7. 最后说 Vue2 SSR 的 Cookies 问题 (opens new window)
  8. 如何理解 SSR 中的 hydrate (opens new window)
  9. vue-ssr-renderer-demo (opens new window)
  10. Vue SSR 组件加载 (opens new window)
  11. lodash-es 最小化引入 (opens new window)