Lqs469.

Vue.js服务端渲染(SSR)不完全指北

What’s this?

SSR(Server-Side Rendering) -- 服务端渲染

服务端渲染是相对于客户端渲染而言的(Client Side Render), 它的渲染行为发生在服务器端, 渲染完成之后再将完整页面以 HTML 字符串的形式交给浏览器, 最后经过”注水”hydrate过程将一些事件绑定和 Vue 状态等注入到输出的静态的页面中, 由同步下发给浏览器的的 Vue bundle 接管状态, 继续处理接下来的交互逻辑. 这也是一种同构应用的实现(代码可以运行在客户端和服务端中).

When SSR?

那么 什么情况下该使用 SSR 方案呢 , 其实一句话总结下来就是展示型应用:

  1. 对 SEO 有需求. CSR 无法直接满足 SEO, 他需要切换成 SSR 或者借助 Prerendering 方案. Prerendering: 一种在服务端使用无头浏览器渲染出页面, 再输出静态页面的解决方案, 也能实现 SEO 需求, 好处是比较简单(通过 webpack 插件就可以实现)可以保持前端模块不需要 SSR 改造, 但是性能比 SSR 差不少.
  2. 对首屏渲染速度和性能有需求. 如果需要更早的将页面展现给用户而不是白屏的话, 最好的方案还是把渲染工作交给服务端, CSR(SPA)应用的更适合场景应该是中后台 web 应用, 一般有较多的交互逻辑和页面数据处理, 同时 CSR 会使用更多的内存, 对浏览器造成较大的压力.
  3. 过多依赖客户端环境的场景. 直接将渲染工作全部交给客户端和 JavaScript 来处理其实对于 web 应用来说是很脆弱的, 浏览器的情况千差万别, 网络环境也是无法预测, 即使做了再多的兼容工作, 也无法保证任何情况下都能完美展示. 有时候即使框架本身也不足以(不愿意)支持所有情况.
  4. 安全性考量. 对于有权限控制和内容限制的应用, 使用 SPA 的时候就要考虑很多安全性限制的问题, 对于应用结构的设计增加了不少复杂度.

Why SSR ?

在早期的 web 开发技术栈中, 实际上都是服务端渲染, 像是 Java, PHP, ROR, ASP.NET. 当前端 MVC 出现之后, 浏览器端渲染模型开始出现并且流行 — “开局一块 HTML 模板, 元素全靠 JS 加载”. 这种方式带来了比较快速的页面切换体验和极好的开发效率, 以及丰富的技术生态. 但是 CSR 也不是没有缺点, 缺点很明显而且不改变其核心思路的话没办法克服:

  1. SEO 不友好 首先因为开局服务器丢给浏览器的是一块空白(有标记)的 HTML 模板和一坨打包好的 JS 代码, 所以做 SEO 时候普通的爬虫没办法实抓取到真实的 web 内容, 虽然现代爬虫号称已经能够处理 CSR 页面, 但是处理成本过高等问题还是让 CSR 的 SEO 结果不尽如人意.

  2. 首屏加载速度 同样因为浏览器会在接受到完整的一坨 JS 代码之后才能执行他, 导致了首屏加载经历了: 解析 HTML(渲染 HTML 模板) -> 获取 JS -> 执行 JS -> JS 渲染页面 -> JS 处理数据相关逻辑 -> 页面加载完成. 这样一个过程之后才能完整呈献给用户, 速度自然就下来了, 更不用说网络因素和客户端环境因素对体验的影响. 即使有前端缓存的存在, 但是页面渲染过程仍然不会轻松.

  3. 鉴权等安全性功能实现起来较复杂 其次, 因为是服务端一股脑把打包好的 JS 代码交给用户, 所以如果在应用中有鉴权的逻辑, 就会牵扯到鉴权逻辑的设计. 这时候就要前后端合作来保证安全性, 复杂度增加.

其实在大多数场景下, 你都没必要使用 SPA 的方案(可以看看这篇文章 ). 那么既然某些场景下不适合使用 CSR 方案, 我们直接退回到以前的 web 开发方式就好了, 干嘛要去踩 SSR 新的坑呢? 那么对比传统的 web 应用, 使用框架 SSR 的好处有哪些呢:

  1. 对比传统方式, 首先最大的好处当然就是技术栈生态, Vue, React 等前端 MVC 给开发带来很大便利, 相应的生态也蓬勃发展, 配套的 UI 套件, 框架组件, 设计语言, API 设计很多程度上已经改变了如今的开发方式. 换句话说, 使用 jQuery 纯手撸的人原来越少了.

  2. 其次是同构带来的便利. 一套代码由两边的执行环境使用, 可以同时支持 SSR 和 CSR 的渲染, 当 SSR 失效的时候还可以降级为 CSR, 或者当服务器压力过大的时候主动降级以增强鲁棒性.

  3. 对技术人员的技术栈统一. SSR 还是使用的前端工程师常规熟悉的技术栈, 没有过大的技术门槛, 也没有太多的技术债, 更适合项目的可持续维护和前端团队的技术发展.

构建逻辑

在 Vue-SSR 构建过程中, 会将代码打包分成两个部分: 服务端 bundle; 客户端 bundle. Node.js 会处理服务端 bundle 用于 SSR, 客户端 bundle 会在用户请求时和已经由 SSR 渲染出的页面一起返回给用户, 然后在浏览器执行”注水”(hydrate), 接管 Vue 接下来的业务逻辑. 这里就会有一个问题, 服务端是如何将 store 状态交给客户端的呢, 因为整个构建流程是彼此独立的, 数据预取(在进入渲染页面之前获取到页面所需要的数据)之后交给了 store, 而注水过程怎么接收 store 数据? 其实中间有一个特殊的状态保存: window.__INITIAL_STATE__, 这个 state 会在服务端渲染执行context.state = store.state;的时候自动写入window中, 所以在客户端代码中就就可以直接通过store.replaceState()接收服务端预取的数据了.

构建逻辑示意图很经典, 如下:

SSR构建逻辑.png

所以 webpack 需要两个入口(服务端, 客户端):

entry-client.js: 客户端 entry 只需创建应用程序, 并且将其挂载到 DOM, 然后将 Store 状态同步给客户端 bundle:

import { createApp } from "../main";
let { app, store, router } = createApp();

// 同步store
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__);
}

router.onReady(() => {
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to);
    const prevMatched = router.getMatchedComponents(from);
    let diffed = false;
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = prevMatched[i] !== c);
    });

    const asyncDataHooks = activated.map((c) => c.asyncData).filter((_) => _);
    if (!asyncDataHooks.length) {
      return next();
    }

    Promise.all(asyncDataHooks.map((hook) => hook({ store, route: to })))
      .then(() => {
        console.log("client entry asyncData function emit");
        next();
      })
      .catch(next);
  });

  app.$mount("#app");
});

entry-server.js: 服务端入口需要处理路由, 并触发数据预取逻辑

import { createApp } from "../main";

export default (context) => {
  return new Promise((resolve, reject) => {
    let { app, router, store } = createApp();

    router.push(context.url);

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      // 对所有匹配的路由组件调用 `asyncData()`
      Promise.all(
        matchedComponents.map((Component) => {
          if (Component.asyncData) {
            return Component.asyncData({ store, route: router.currentRoute });
          }
        })
      )
        .then(() => {
          context.state = store.state;
          resolve(app);
        })
        .catch(reject);
    }, reject);
  });
};

具体实现

明确了选型原因和框架特性, 我们就要开始着手搭建一个 Vue-SSR 框架, 推荐文档: 官方出品的 SSR 指南 https://ssr.vuejs.org/ . 里面讲解了你所需要知道的全部名词解释和 API 介绍, 基本不需要再单独查资料了. 同时他给出了一个官方出品的 Demo, demo 的作者就是尤雨溪, 你想要参考的内容里面也基本都实现了一遍, 不过这个 Demo 是基于 Express 的, 改造成 Egg 还需要一些工作.

Vue-SSR + Egg.js, 在结构上跟普通的 Vue 服务端渲染也是一样的. 首次请求到达时, 由 Egg.js 处理, 在服务端执行渲染器逻辑并输出静态页面, 一共输出的还有客户端 bundle, 然后客户端 bundle 接管应用, 继续接下来的任务, 变成一个”普通”的 Vue 应用. 同时 Egg.js 还会处理来自页面的异步请求, 处理正常服务端的工作. 下面是应用整体结构的示意:

SSR应用整体结构.png

具体来说, SSR 的核心思路就是使用vue-server-renderer创建一个渲染器(renderer), 然后给这个渲染器传入 Vue 实例, 渲染器会得到 HTML 页面, 最后由 Egg.js 将 HTML 返回, 实现代码有些繁杂这里就不一一放出来了, 核心流程可以简化成:

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

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

// 第 3 步:将 Vue 实例渲染为 HTML
const html = await renderer.renderToString(app);

// 第 4 步, 返回 HTML
this.ctx.body = await this.ctx.renderString(html);

具体的实现代码可以在 k2 搭建的 SSR 框架中的app/controller/template.js文件中找到.

开发环境

SSR + Egg 的生产实现并不难, 但是支持 HMR 的开发环境搭建就稍麻烦些, 主要是devMiddleware, hotMiddleware和 Egg.js 配合使用. 原理是使用这些开发中间件监听并构建 Vue 文件后放在内存中, 再响应用户的请求.

具体来说, 在开发环境下, dev renderer 中会执行 dev-server 并监听 Vue 代码, 当 Vue 代码发生变化时更新渲染 renderer, 并返回给 Egg, Egg 会等待 dev-server 的返回后去执行输出. 因为 egg 的是基于 koa, 所以 dev Middleware 简单封装一下直接挂载 egg 中的 app 上就可以, 具体实现代码可以查看 k2 搭建的 SSR 框架中的build/setup-dev-server.js文件.

这里有个值得注意的地方就是hotMiddleware中间键在注册之后就会接管所有请求, 这时候本希望走到 Egg 的请求就会被指向 Vue, 这是不希望看到的, 所以需要在hotMiddleware中过滤一下:

// setup-dev-server.js
...
function hotFn(ctx, next) {
  // 过滤 HMR
  if (ctx.url.indexOf('webpack_hmr') < 0) {
    return next();
  }

  const stream = new PassThrough();
  ctx.body = stream;

  return hotMiddleware(
    ctx.req,
    {
      end: () => {},
      write: stream.write.bind(stream),
      writeHead: (state, headers) => {
        ctx.state = state;
        ctx.set(headers);
        console.log('hotMiddleware headers', state, headers);
      },
    },
    next
  );
}
app.use(hotFn);
...

SSR 优化

通过服务端渲染, 我们将应用从 IO 密集型转为了 CPU 密集型, CPU 的压力会在 QPS 爆发时剧增, 这时候就需要针对 SSR 进行一些优化, 常见的优化方式有:

缓存

一般分为页面级别缓存和组件级别缓存.

  • 页面级别缓存: 对于相同的页面的请求, 其内容也相同(不考虑个性化页面情况), 所以将路由与对应页面缓存下来可以很有效命中缓存, 降低性能开销.

    // 使用 LRU
    const microCache = LRU({
      max: 100,
      maxAge: 1000 // 重要提示:条目在 1 秒后过期。
    })
    
    ...
    // 命中缓存
    const hit = microCache.get(req.url)
    if (hit) {
      return res.end(hit)
    }
    ...
    
    // 缓存下来
    microCache.set(req.url, html)
    
  • 组件级别缓存: 组件缓存在组件渲染过程进行命中判断, 所以会影响组件渲染结果, 所以要确保组件不依赖上下文状态且无副作用, 换句话说缓存的是不会改变内容的展示型组件. 实现方法是使用vue-server-renderer内置的组件级别缓存配置参数, 在创建 renderer 时传入. 更多参数可以参考 具体缓存实现方式 .

    const renderer = createRenderer({
      cache: LRU({
        max: 10000,
        maxAge: ...
      })
    })
    

降级方案

核心思路就是当 CPU 使用率过高的时候即使切换到 CSR 模式. 可以结合 Egg 提供的 schedule 能力, 在启动时执行一个定时任务, 监控 CPU 使用率, 当大于阈值时切换到 CSR 模式. 而 Egg 也提供了单核 schedule 能力, 这样可以将定时任务的性能损耗降到很小; 或者在渲染执行时计时, 如果超时则自动返回 CSR bundle, 降级为 CSR 应用, 这样虽然能临时解决 CPU 高开销无法及时响应的问题, 但用户体验并没有什么实质性改良.

要注意的坑

Vue-SSR 的坑还是不少的, 特别是和 Egg.js 结合时, 经过了一段比较蛋疼的踩坑阶段, 现在终于稳定下来支撑业务. 这里把开发期间遇到的问题总结一下:

  1. 生命周期不同. 这个问题最为明显, 在 CSR 和 SSR 中生命周期钩子是不同的. SSR 中只有beforeCreatecreated会被执行. 而在 CSR 中所有周期都会再执行一遍. 另外需要注意的是, 在服务端代码中不要写有全局副作用的代码, 例如写了 setInterval而不清除它. 因为在 SSR 周期没有beforeDestory阶段, 所以以往 CSR 中销毁页面前清除副作用的方法就没法继续使用了, 而此时的setInterval 就会被永远不会清除了!

  2. 因为我们采用同构的目的是写一份尽量通用的代码, 让它运行在两端. 所以我们需要对不同端的运行环境特别熟悉才行, Node.js 端是没有浏览器对象的, 所以window, document, DOM 操作都没法执行. 同理, 浏览器端是没有process对象的. 他们各自的 API 实现也有差别, 这点需要特别留意. 比较麻烦的就是第三方库的引入, 有时候你并不知道引入的库能不能完全运行在 Node 端/浏览器端. 如果它只能运行在纯浏览器环境, 那可以在created阶段之后引入和执行来避开 Node.js 下执行.

  3. 避免单例. 在 CSR 中, 每次我们打开页面都是从服务端下载代码(或缓存), 然后创建一个全局的根 Vue 实例. 但在 SSR 中情况有所变化, 因为服务端会一直运行, 如果一直用同一个全局的 Vue 实例, 就会导致每次客户端的请求到指向了同一个根 Vue 实例, 就有可能造成状态污染. 所以这里要使用工厂函数在每次请求到来时, 新建一个 Vue 实例, 执行逻辑返回结果. 同样的, StoreRouter也要这样处理:

    // main.js
    import Vue from 'vue';
    import App from './App.vue';
    import { createStore } from './store';
    import { createRouter } from './router';
    
    export function createApp() {
      const store = createStore();
      const router = createRouter();
    
      const app = new Vue({
        router,
        store,
        render: /h/ => h(App),
      });
    
      return { app, router, store };
    }
    
    // router.js
    import Vue from 'vue';
    import Router from 'vue-router';
    import Home from './views/Home';
    import About from './views/About';
    
    Vue.use(Router);
    
    export function createRouter() {
      return new Router({
        mode: 'history',
        routes: [
          {
            path: '/',
            component: Home,
          },
          {
            path: '/about',
            component: About,
          },
        ],
      });
    }
    
  4. 数据预取问题. 在业务中经常会遇到需要我们输出的页面是带有动态数据的, 也就是在渲染页面的时候先异步取一次数据, 然后将返回的数据放入页面中, 最后输出给用户. 其实处理这个逻辑的原理很简单, 就是在服务端整个请求周期中预取数据再注入最后页面就行. 为了尽早的发出请求, 可以在路由模块中执行数据预取逻辑, 在实例化之前就执行异步请求, 然后放入Vuex, 然后交给 Vue 页面去渲染. 这块的代码比较分散, 可以看这里的实现

    值得注意的是使用v-html注入动态获取的 HTML 内容的时候. 如果 HTML 内容有<script>所包含的 JS 代码, 会发现script中的事件绑定失效. 其实在这里页面被渲染了两次, 第一次是发生在 SSR 直接交给浏览器的时候: 此时<script>完整被渲染在浏览器里, 其内容正常执行, 事件绑定也正常的绑定在了当时的 DOM 元素上. 而第二次渲染时, 走的是 CSR: 在这时由于是以v-html的方式来渲染替换 HTML, 但是v-html实质上是innerHTML操作, 这样 <script>虽然会被替换上去, 但是其中的内容不会执行(innerHTML为安全性考虑而设计). 所以经过这样两次渲染之后, 此时的 DOM 元素是第二次渲染时得到的, 而正常执行过的 JS 的事件绑定是绑在在第一次渲染出来的 DOM 元素上, 这样就出现了虽然 DOM 存在. 但是无法触发该 DOM 上的事件的情况.

    解决方法: 将获取到的 HTML 内容进行匹配, 剔出<script>内容, 无论第一次或第二次渲染, 只将内容交给v-html页面, 然后单独在生命周期updated(页面已将 HTML 内容渲染完成)中将<script>创建出来, 添加到页面里自动执行, 实现绑定. 并在下一次页面重渲染(可能是页面跳转来到)时判断<script>是否存在, 如果真则先删去再添加, 这样避免添加多余的<script>块.

    而页面为什么会渲染两次呢?

    这是由于 Vue SSR 在初始页面渲染完成后会有一次hydration过程, 在这个过程中会照常执行流程mounted等生命周期. 此时会有一个自动判定, 判定是否此时的组件渲染出的内容与 SSR 渲染得到的内容一致, 如果出现不一致就会单独执行一次额外的 CSR, 以达到页面被能正确地渲染. 而因为我们使用了v-html, 这个过程只有在 CSR 时才会被执行, 所以导致了两次渲染出来的内容不一致, 触发了 Vue SSR 的”补偿渲染机制”, 进而执行了第二次渲染.

    image.png

  5. Cookie 传递. 用户的请求会先在服务端被处理, 此时会带客户端的 cookie, 但如果在服务端中 Egg.js 发出异步请求, 这个异步请求中并不带客户端的 cookie, 所以如果异步请求需要带携带用户信息的 cookie, 此时需要将上下文中的 cookie 加入 Egg 的异步请求 headers 中. 同时返回给客户端时如果涉及到有关 cookie 的操作也要同步处理一下, 例如用户注销:

    const res = await this.ctx.curl("/logout", {
      method: "POST",
      dataType: "json",
      headers: {
        // 传递 cookie
        cookie: this.ctx.request.header.cookie
      }
    });
    
    if (res) {
      // 处理客户端 cookie
      this.ctx.cookies.set("passport_login", null, {
        domain: ".XXX.com"
      });
    }
    

总结

在实现一个展示型 web 应用, 或者对首屏性能要求较高的场景(例如官网, 技术文档). SSR 都是一个很有效且值得投入的技术方案, 一方面享受了框架带来的便利和生态福利, 一方面也兼顾了性能和可扩展性, 对团队的横向探索和技术栈统一也很有好处. 得益于 Vue(或 React)和 Egg.js 生态的优势, 丰富的第三方库和繁荣生态支持, 让开发者有更多的选择和优化空间. 同时开发效率也更适合现代 web 应用的开发节奏, 不需要很高的技术门槛, 就可以上手开发并且支撑大部分的业务场景. 同时 Egg.js 框架的成熟也让服务端的能力更为强大, 对于安全性和稳定性的设计让我们在处理复杂场景中更加得心应手.

当然 SSR 中的坑也不少, 主要是服务端环境下的 API 规则的不同和一些数据和逻辑处理的约定(例如生命周期, 环境变量, 数据预取), 但只要注意规避这些问题, 在 SSR 的开发中还是比较顺滑的. 以目前的 API 设计来说, SPA 的代码无法直接放到服务端就能跑(除非实现 CSR 应用时就考虑了 SSR), 其中想改造的话成本可能还不小. 但是一个新应用想要设计为 SSR 方案, 基本上开发与维护成本和一个 SPA 应用差不多, 所以在业务开发中是一个较为可靠的技术方案.