Vite SSR 解决方案

1,641 阅读10分钟

来自掘金小册 深入浅出 Vite 的学习实践与总结

概述

名称解释

  • CSR:客户端渲染(Client Side Render)
  • SSR:服务端渲染(Server Side Render))
  • SSG:静态站点生成(Static Site Generation)

SSR 所能解决的问题

  • 首屏加载速度比较慢:一般都是依赖于 JS 来渲染页面内容,那么就得等 JS 文件加载完毕,然后还要执行等等,都需要时间
  • SEO(搜索引擎优化)不友好的问题: 基于 SPA 的页面,返回的 HTML 基本上没有什么具体的内容可以让爬虫去处理,那么就会导致网站排名不行

SSR 的应用

  • 在服务端只能生成页面的内容,而无法完成事件的绑定,这个是需要去到浏览器中进行处理的,处理好后,页面就有了交互功能,这个过程被称为 hydrate(注水或者激活)
  • SSR + CSR hydrate 的应用被称为同构应用

SSR 生命周期

SSR 应用的两大声明周期为构建时运行时

构建时

首先是构建时需要做的事情:

  1. 解决模块加载问题。在原有的构建过程中加入 SSR 构建的过程,生成一份能在 NodeJS 中执行的产物(CommonJS)。

  1. 移除样式代码的引入语句。如果是在文件中直接引入的 CSS 模块,则服务端无法解析,除了 CSS Modules,因为它是一个映射对象。
  2. 依赖外部化(external) 。对于某些第三方的依赖可以直接从 node_modules 中读取,提高 SSR 的构建速度。

运行时

运行时可以拆分为几个固定的生命周期阶段:

  1. 加载 SSR 入口模块。获取组件入口的模块,进行加载,例如App.vue
  2. 进行数据预取。在服务端通过直接查询数据库或者调用接口获取页面所需的数据。
  3. 渲染组件。将第 1 步获取到的组件渲染成 HTML 字符串或者 Stream 流。
  4. HTML 拼接。将渲染好的组件内容拼接成完整的 HTML 字符串,返回给客户端进行展示。

搭建基础的 SSR 项目

代码仓库地址

使用 Vite 创建一个基础的项目,将 main.tsx 删除掉,分别创建出客户端与服务端的入口文件。

// entry-server.tsx

import App from './App';
import './index.css';

/**
*  服务端入口组件
*/
function serverEntry(params: User) {
  return <App data={params} />;
}

/**
* 页面初始化时需要的数据
*/
async function fetchData(): Promise<User> {
  return {
    name: '金小钗',
  };
}

export { serverEntry, fetchData };

entry-client.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { User } from './entry-server';

// @ts-ignore
// 服务端渲染时,注入的数据
const data = window.__SSR_DATA__ as User;

ReactDOM.hydrate(
  <React.StrictMode>
    <App data={data} />
  </React.StrictMode>,
  document.getElementById('root')
);

修改 index.html 文件内容,打上标志,用于服务端生成 HTML。

<html lang="en">
  <!-- 省略内容... -->
  <body>
    <div id="root"><!-- SSR_APP --></div>
    <script type="module" src="/src/entry-client.tsx"></script>
    <!-- SSR_DATA -->
  </body>
</html>

搭建后台服务,编写 SSR 处理逻辑:

// ssr-server/index.ts

/**
 * 创建 vite ssr 中间件
 */
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
  let vite: ViteDevServer | null = null;

  if (!isProd) {
    // 处于开发环境时,创建 vite 开发服务器
    vite = await (
      await import('vite')
    ).createServer({
      root: process.cwd(),
      server: {
        middlewareMode: 'ssr',
      },
    });

    // 注册 Vite Middlewares,主要用来处理客户端资源
    app.use(vite.middlewares);
  }

  return async (req, res, next) => {
    try {
      const { originalUrl } = req;

      if (!matchPageUrl(originalUrl)) {
        // 当前请求的是静态资源,则不进行处理
        return next();
      }

      // 1.加载服务端入口组件模块
      const { serverEntry, fetchData } = await loadSsrEntryModule(vite);

      // 2.数据预取
      const data: User = await fetchData();

      // 3.渲染服务端组件,转换为 HTML 字符串
      const appHtml = renderToString(
        React.createElement(serverEntry, { data })
      );

      // 4.拼接完整的 HTML 字符串返回给客户端
      const templatePath = resolveTemplatePath();
      let template = fs.readFileSync(templatePath, 'utf-8');
      if (!isProd && vite) {
        // 当前环境为开发环境
        // 所以需要注入 HMR、环境变量相关的代码
        template = await vite.transformIndexHtml(originalUrl, template);
      }
      const html = template
        .replace('<!-- SSR_APP -->', appHtml)
        // 注入数据标签,用于客户端注水
        .replace(
          '<!-- SSR_DATA -->',
          `<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>`
        );

      res.status(200).setHeader('Content-Type', 'text/html').end(html);
    } catch (error: any) {
      vite?.ssrFixStacktrace(error);
      console.error(error);
      res.status(500).end(error.message);
    }
  };
}

async function createServer() {
  const app = express();

  // 加入 vite ssr 中间件
  app.use(await createSsrMiddleware(app));

  // 用于处理静态资源
  if (isProd) {
    // serveStatic 来自第三方包 serve-static
    app.use(serveStatic(path.join(process.cwd(), 'dist/client')));
  }

  app.listen(5004, () => {
    console.log('服务器已启动');
    console.log('http://localhost:5004');
  });
}

createServer();

ssr-server/util.ts

/**
 * 根据环境加载服务端入口模块
 * @param vite vite 开发服务器
 */
export async function loadSsrEntryModule(vite: ViteDevServer | null) {
  if (isProd) {
    // 生产环境:使用 require 加载打包好的模块
    const entryPath = path.join(cwd, 'dist/server/entry-server.js');
    return require(entryPath);
  } else {
    // 开发环境:使用 vite 开发服务器加载模块
    const entryPath = path.join(cwd, 'src/entry-server.tsx');
    return vite!.ssrLoadModule(entryPath);
  }
}

/**
 * 根据环境获取 index.html 的路径
 */
export function resolveTemplatePath() {
  return isProd
    ? path.join(cwd, 'dist/client/index.html')
    : path.join(cwd, 'index.html');
}

给项目添加一些脚本:

nodemon:监听文件变化然后自动重启服务器
esno:用于 Node 环境执行 TS 文件,底层使用 Esbuild 实现

// package.json
{
  "scripts": {
    // 开发环境
    "dev": "nodemon --watch src/ssr-server/index.ts --exec esno src/ssr-server/index.ts",
    // 编译客户端产物
    "build:client": "tsc && vite build --outDir dist/client",
    // 编译服务端产物
    "build:server": "tsc && vite build --ssr src/entry-server.tsx --outDir dist/server",
    // 预览生产环境产物的效果
    "preview": "cross-env NODE_ENV=production esno src/ssr-server/index.ts"
  }
}

运行npm run dev,访问服务端地址,可以看到返回的 HTML 内容如下:

总结

项目搭建思路整理

SSR 应用工程化问题

SSR 应用在实际的应用场景中还有不少工程化相关的问题需要注意,主要有:

  1. 路由管理
  2. 全局状态管理
  3. CSR 降级
  4. 浏览器 API 兼容
  5. 自定义
  6. 流式渲染(边渲染边响应)
  7. SSR 缓存
  8. 性能监控
  9. SSG/ISR/SPR

路由管理

在 SPA 场景下,不同的前端框架都有对应的路由解决方案。但路由方案在 SSR 过程中所执行的功能是差不多的:

  1. 告诉框架现在渲染哪个路由
    1. Vue 使用router.push确定渲染的路由
    2. React 通过 StaticRouter 配合 location 参数来确定渲染的路由
  1. 设置 base 前缀。规定路径的前缀,如 vue-router 中 base 参数、react-router 中 StaticRouter 组件的 basename

全局状态管理

全局状态管理(VueX、Pinia、Redux)接入 SSR 的思路大概是这样:在预取数据阶段初始化服务端的store,将一部获取的数据存入store中,然后在拼接 HTML 时将数据从 store 中取出放到数据 script 标签中,最后在客户端 hydrate 的时候通过 window 访问到预取数据。

  • 不同的请求需要分别创建各自的store,避免造成全局状态污染问题

CSR 降级

在某些比较极端的情况下,需要降级到 CSR,主要包括以下场景:

  1. 服务端预取数据失败,降级到客户端获取数据
// 服务端如果没有注入到数据,则需要自己发起请求去获取数据
const data = window.__SSR_DATA__ ?? await fetchData();

ReactDOM.hydrate(
  <React.StrictMode>
  <App data={data} />
    </React.StrictMode>,
  document.getElementById('root')
);

  1. 服务器出现异常,需要返回兜底的 CSR 模板,完全降级为 CSR
async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
  return async (req, res, next) => {
    try {
      // SSR 处理逻辑
    } catch(e: any) {
      vite?.ssrFixStacktrace(e);
      console.error(e);
      // 在这里返回浏览器 CSR 模板内容
    }
  }
}

  1. 本地开发调试,有时需要跳过 SSR,仅进行 CSR

通过在 URL 上面加参数,来强制跳过 SSR:

async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
  return async (req, res, next) => {
    try {
      if (req.query?.csr) {
        // 响应 CSR 模板内容
        return;
      }
      // SSR 处理逻辑
    } catch(e) {
    }
  }
}

浏览器 API 兼容

在服务端中无法使用windowdocument等一些 API,所以需要规避这些 API 的使用或注入 polyfill

  1. 通过 VIte 内置变量import.meta.env.SSR进行判断是否存在 SSR 环境
if (import.meta.env.SSR) {
  // 处在服务端环境,需要规避浏览器 API 的使用
} else {
  // 处在客户端环境,可以访问浏览器的 API
}

  1. 使用 jsdom 注入 polyfill
const jsdom = require('jsdom');
const { window } = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
const { document } = window;
// 挂载到 node 全局
global.window = window;
global.document = document;

自定义

根据组件状态动态修改<head>中的内容,React 提供了 react-helmet,Vue 则提供了 vue-meta 来解决这个问题。

react-helmet 的使用如下:

// App.tsx
    
// 省略了一些代码...
import { Helmet } from 'react-helmet';

function App() {
  return (
    <Helmet>
      <title>{name}的页面</title>
    </Helmet>
  );
}

index.html

<html lang="en">
  <head>
    <!-- SSR_APP_TITLE -->
  </head>
</html>

ssr-server/index.ts

import Helmet from 'react-helmet';

async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
  return async (req, res, next) => {
    try {
      // 生成 <head> 标签的内容
      const helmet = Helmet.renderStatic();
      // 在拼接完整 HTML 内容的地方进行处理即可
      const html = template
      .replace('<!-- SSR_APP_TITLE -->', helmet.title.toString())
    } catch () {}
  };
}

现在就可以根据组件状态动态修改<title>了:

流式渲染

流式渲染是指页面边渲染边响应,而不是等整个组件树渲染完毕之后再响应,这样可以让响应提前到达客户端,提升了首屏加载速度。

React 提供了 rendertonodestream官方文档显示已弃用),Vue 则提供了 renderToNodeStream

大概使用方式:

import { renderToNodeStream } from 'react-dom/server';

// 返回一个 Nodejs 的 Stream 对象
const stream = renderToNodeStream(element);
let html = ''

stream.on('data', data => {
  html += data.toString()
  // 发送响应
})

stream.on('end', () => {
  console.log(html) // 渲染完成
  // 发送响应
})

stream.on('error', err => {
  // 错误处理
})

流式渲染是存在限制的,如果需要在 HTML 中填入一些与组件状态相关的内容,则不能使用流式渲染。例如上面提到的<head>内容自定义,即便在渲染组件的时候收集到了<head>信息,但在流式渲染中,此时 HTML 的<head>部分已经发送给浏览器了,而这部分响应内容已经无法更改,因此 react-helmet 在 SSR 过程中将会失效。

SSR 缓存

SSR 是一种典型的 CPU 密集型操作,为了尽可能降低线上机器的负载,所以需要缓存。缓存的内容可以分为这么几个部分:

  1. 文件读取缓存。将磁盘的 IO 操作结果缓存起来,尽量减少重复读取磁盘的操作。
  2. 预取数据缓存。对于某些实时性不高的接口数据,采取缓存的策略,在下次相同的请求进来时复用之前预取数据的结果,减少预取数据过程的各种 IO 消耗,也可以一定程度上减少首屏时间。
  3. HTML 渲染缓存。将拼接好的 HTML 内容进行缓存,减少组件渲染成 HTML 等相关的操作,提高服务器性能。

缓存位置选择:

  1. 服务器内存。如果是放到内存中,需要考虑缓存淘汰机制,防止内存过大导致服务宕机,一个典型的缓存淘汰方案是 lru-cache (基于 LRU 算法)。
  2. Redis。NoSQL 数据库。
  3. CDN 服务。将页面内容缓存到 CDN 服务上,减少消费源服务器的资源,相关文章:juejin.cn/post/688788…

Vue 中另外实现了组件级别的缓存,这部分缓存一般放在内存中,可以实现更细粒度的 SSR 缓存。

性能监控

搭建一个完整的性能监控机制,用于发现和排查 SSR 应用的性能问题,性能数据的一些通用指标如下:

  • SSR 产物加载时间
  • 数据预取的时间
  • 组件渲染的时间
  • 服务端接受请求到响应的完整时间
  • SSR 缓存命中情况
  • SSR 成功率、错误日志

使用perf_hooks完成数据采集:

import { performance, PerformanceObserver } from 'perf_hooks';

// 初始化监听器逻辑,用于性能监控
const perfObserver = new PerformanceObserver((items) => {
  items.getEntries().forEach((entry) => {
    console.log('[performance]', entry.name, entry.duration.toFixed(2), 'ms');
  });
  performance.clearMarks();
});
perfObserver.observe({ entryTypes: ['measure'] });

async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
  return async (req, res, next) => {
    try {

      //* 3.渲染服务端组件,转换为 HTML 字符串
      performance.mark('render-start');
      const appHtml = renderToString(
        React.createElement(serverEntry, { data })
      );
      performance.mark('render-end');
      performance.measure('renderToString', 'render-start', 'render-end');
    } catch () {}
  };
}

访问页面后,控制台输出:

[performance] renderToString 8.27 ms
[performance] renderToString 6.59 ms

SSG/ISR/SPR

某些博客、文档等页面都是静态的,不设计数据动态变化,所以在构建时就将 HTML 文件都打包好,部署即可,这就是 SSG。

SSG 与 SSR 最大的区别就是产出 HTML 的时间点从运行时变成了构建时,但核心的生命周期流程并没有变化:

SPR(Serverless Pre Render)与 ISR(Incremental Site Rendering)都是基于 SSR 和 SSG 进行延伸的一种解决方案。

  • SPR:把 SSR 的服务部署到 Serverless(FaaS) 环境中,实现服务器实例的自动扩缩容,降低服务器运维的成本
  • ISR:增量站点渲染,将一部分的 SSG 逻辑从构建时搬到了 SSR 运行时,解决的是大量页面 SSG 构建耗时长的问题