Zapic's Blog
Vue 3 服务端渲染(SSR)终极优化指南
2023-03-30
教程
查看标签

最近在做的项目都需要做SSR优化搜索引擎索引和用户体验.
如果只需要优化SEO那就直接挂个白屏屏蔽掉没加载完但是有内容的丑陋网页就行了, 搜索引擎能够抓取数据, 用户以为还在加载.
但是如果需要优化用户体验, 那就需要下一些功夫了.
这篇教程基于没有使用任何SSR框架(比如nuxt/vite-ssr), 纯手搓的SSR服务端, 其他框架请自行迁移学习, 难度应该不大, 实在不行我们可以改源码(x).

0. 简单的SSR服务端

核心逻辑如下:

import { renderToString } from "@vue/server-render";

const initApp = await import("server/main.mjs");
const template = fs.readfileSync("index.html");

export async function render(url) {
  const { app, router } = await initApp();
  await router.push(url);
  await router.isReady();
  const html = renderToString(app);
  return template.replace("<!--ssr-outlet-->", html);
}

1. 存在的问题

1. SSR获取数据在客户端重复获取

使用SSR的其中一个目的是让搜索引擎可以索引到网页的内容, 因此数据获取的是非常重要的.但是渲染到HTML之后, 获取的数据不能传递到客户端, 客户端需要重新从接口请求这些数据. 重新请求会导致客户端最初渲染的内容跟服务端不一致(客户端最初渲染的内容不包括从接口获得的数据, 需要显示加载状态, 而服务端提供的html是已经获取到数据的状态), 导致页面内容大面积的抖动, 以及无意义的请求.
因此需要设法将数据传递到客户端.

2. CSS懒加载导致已有内容的页面没有样式

在没有SSR的情况下, 如果启用了CSS代码分割, CSS默认不会被加载, 而是由JS决定哪些CSS会与JS同时加载.
JS加载好时, CSS一般也加载完成了. 此时JS将页面内容挂载到DOM上, CSS已经加载完成, 赋予内容样式.
但是在SSR的情况下, HTML会被预先渲染好, 但此时CSS还没加载, 就会使无样式的内容渲染到屏幕上, 不仅非常丑, 而且等待CSS加载后, 元素被赋予样式, 页面布局可能会产生非常大的抖动.
比较简单的方法时在渲染时直接给body上一个display: none, 等待挂载时再移除, 不影响爬虫爬取内容, 但这抛弃了SSR带来的用户体验优化, 需要找到一个方法在不舍弃的用户体验的同时实现SSR.

2. 解决问题

1. 数据获取

统一包装API请求方法(大型项目应该都有做, 没做的赶紧做), 然后在服务端请求时将每个响应内容都存起来, 序列化后嵌入到HTML中:

const RequsetCaches = window.REQ_CACHE || {};
hash(str) {
 // ...
 // 为请求计算一个独立的hash
 // ...
 return hash
}
// ...
// API包装实现
getArticleData(id) {
 return get("/article/" + id);
}
get(url, options = {}) {
  if (client) {
    // 客户端
    const resp = RequestCaches[hash(url+json(options))]
    if (resp) {
       // 如果有缓存, 返回缓存, 判断是否需要清理缓存
       if (need_clean) delete RequestCaches[hash(url+json(options))]
       return resp;
    }
  }
  // 获取数据
  const res = fetch(url, opt);
  if (server) {
    // 缓存数据
    RequestCaches[hash(url+json(options))] = res.data;
  }
  return res.data;
}
// ...
// 渲染App
const html = renderToString(app);
return {
   html: html,
   RequestCache: RequestCache
}
// ...
// 响应, 发送到客户端
// !!! 不要直接使用JS的JSON序列化, 存在XSS风险 !!!
res.end(html.replace("state", json(RequestCache))) // <html><head><script>window.REQ_CACHE=<JSON ReqestCache>

2. Critical CSS

Critical CSS是一种做法, 把页面首屏需要的CSS预先直接嵌入到HTML中, 在CSS未加载完全时也能正确的展示页面.
Web.dev上对Critical CSS的介绍
一般的做法是在编译打包期将就关键的CSS提取出来, 但是这只适合SPA应用, 对于SSR应用, 页面路由都是动态的, 页面内容也是动态的, 不可能在编译期就把CSS提取出来, 因此我们需要找到一种办法动态提取CSS.
在选型后, 大部分的Critical CSS的提取器依赖无头浏览器来准确确定哪些样式需要被加载(只加载首屏上能看见的元素的CSS), 这开销非常大, 基本只适合在编译器处理.

Critters

来自谷歌实验室的Critters, 使用AST直接解析HTML所需的CSS, 不再依赖无头浏览器, 但是无法确定首屏上哪些元素可以看见, 只能将HTML内元素所需的样式全数嵌入, 但由于不依赖无头浏览器, 处理速度远高于其他实现, 开销很低, 几乎可以直接用于SSR.
NPM Critters

import { renderToString } from "@vue/server-render";
import Critters from "critters";

const initApp = await import("server/main.mjs");
const template = fs.readfileSync("index.html");
const critters = new Critters({
  path: "dist/client/assets",
  publicPath: "assets",
  additionStyleSheets: [
    ...fs.readDirSync("dist/client/assets").filter(file => file.endsWith(".css"))
  ]
});

export async function render(url) {
  const { app, router } = await initApp();
  await router.push(url);
  await router.isReady();
  const html = renderToString(app);
  const result = await critters.process(template.replace("<!--ssr-outlet-->", html));
  return result;
}

但经过实践, Critters虽然很快, 但还不够快, 使得无法直接用于SSR期间提取内容.
即使不再使用无头浏览器, 对于一个小型应用(仅两千行的代码量), 处理一个单页仍然需要超过200ms(将页面渲染成HTML只需要50ms), 使得QPS只有个位数, 这显然对性能影响非常严重.
Critters没有过多的暴露API去控制他的处理流程, 故考虑直接修改Critters的源码, 使用patch-package来在外部保存对依赖的修改.
项目的编译目标是module, 故修改node_modules/critters/dist/critters.mjs

  1. 预先读取CSS, 减少读取压力, 并直接返回样式, 而不是嵌入后返回
    修改constructor, 读取所有的CSS到内存中, 之后不再从磁盘读取样式表.
    critters.mjs

    class Critters {
    constructor(options) {
      // ...
    
      this.logger = this.options.logger || createLogger(this.options.logLevel);
    
      // 读取样式表到内存
      this.sheets = [];
      this.options.additionalStylesheets.forEach((file) => {
     this.sheets.push({
     name: file,
     content: this.getCssAsset(file)
     })
      });
     // ...
    // 修改getCssAsset, 使其同步读取, 因为在constructor里无法async, 且之后不需要再次读取, 因此直接使用sync读取更好
    getCssAsset(href) {
      // ...
      let sheet;
    
      try {
     // 修改为readFileSync
     sheet = readFileSync(filename);
      } catch (e) {
     this.logger.warn(`Unable to locate stylesheet: ${filename}`);
      }
    // ....
    
    // 修改process方法直接返回提取后的样式, 而不是嵌入到html中再序列化成字符串, 开销较大
    async process(html) {
      // ...
      const document = createDocument(html);
      // 移除原有的嵌入CSS并处理的逻辑, 直接使用内存里读取好的CSS并处理
      const result = (await Promise.all(
     this.sheets.map(async (v) => {
     // 通过查看processStyle方法, 仅提供正常工作所需的属性, 而不使用virtual-dom的createElement创建元素
     // 可以避免修改DOM树
     const style = {
       $$name: v.name,
       textContent: v.content,
       parentNode: true,
       remove() {
         // 模拟remove方法, 在样式为空时Critters会移除这个DOM
         // 通过置空内容模拟移除
         style.textContent = "";
       }
     };
     await this.processStyle(style, document)
     // 直接返回样式字符串, 而不是嵌入到virtual-dom里
     return style.textContent;
     })
      )).join("");
      // 移除将style嵌入回virtual-dom的逻辑, 以及将virtual-dom序列化回html的过程, 加速处理
      // ...
      // ...
      const end = process.hrtime.bigint();
      this.logger.info('Time ' + parseFloat(end - start) / 1000000.0);
      // 直接返回CSS字符串
      return result;
    }

    render.mjs

    import { renderToString } from "@vue/server-render";
    import Critters from "critters";
    
    const initApp = await import("server/main.mjs");
    const template = fs.readfileSync("index.html");
    const critters = new Critters({
    path: "dist/client/assets",
    publicPath: "assets",
    additionStyleSheets: [
      ...fs.readDirSync("dist/client/assets").filter(file => file.endsWith(".css"))
    ]
    });
    
    export async function render(url) {
    const { app, router } = await initApp();
    await router.push(url);
    await router.isReady();
    const html = renderToString(app);
    const result = await critters.process(template.replace("<!--ssr-outlet-->", result));
    return result;
    }
  2. 实现样式缓存
    计算输入html的hash, 作为key将内容缓存, 然后再缓存已经提取出来的CSS, 提高QPS
    需要根据业务实际情况设计缓存.
    很简单, 不再赘述.

这样处理后, 单次无缓存提取处理所需时间只要将近150ms(渲染页面该需要50ms), 存在缓存就是普通的拼接字符串, 非常接近不处理直接渲染所需的时间.

这样处理后, 基本可以将Critters用于生产环境了.

3. 副作用

input在挂载后数据被清空

由于预先嵌入了样式, 输入框看起来就像已经加载好了, 用户可能会在里面输入数据.
但由于js没加载, 一旦js加载, 输入框被重新挂载, 用户输入的内容就会被清空.
很简单, 给所有input上一个pointer-events: none, 阻止用户与其交互, 再在挂载时移除.

.not-mount input {
  pointer-events: none;
}
// ...
app.mount("#app", true)
document.body.classList.remove("not-mount")