像素

vite 搭建服务端渲染(SSR)工程

SSR 概念

SSR(Server-side Rendering,服务器端渲染)是指将前端页面的渲染过程从浏览器端转移到服务器端,将页面的 HTML 代码在服务器端生成然后返回给客户端。

主要目的是提高页面的首屏渲染速度,因为浏览器端进行渲染需要下载 HTML、CSS、JavaScript 等文件,经过解析、编译、执行等环节,非常耗时,而服务器端渲染可以直接输出已经渲染好的 HTML,很快就能够呈现在客户端浏览器上。另一方面也能将完整的页面内容展现给搜索引擎的爬虫,利于 SEO。

SSR 中只能生成页面的内容和结构,并不能完成事件绑定,因此需要在浏览器中执行 CSR(客户端渲染) 的 JS 脚本,完成事件绑定,让页面拥有交互的能力,这个过程被称作 hydrate(水合)。

同样的像这种在服务端和客户端双端渲染的方式也叫做同构渲染

SSR 生命周期

SSR 的生命周期可以分为构建时和运行时

构建时:

  1. 解决模块加载问题,在原有的构建过程之外,需要加入 SSR 构建的过程 ,另外生成一份 CommonJS 格式的产物,使之能在 Node.js 正常加载。随着 Node.js 本身对 ESM 的支持越来越成熟,也可以复用前端 ESM 格式的代码,Vite 在开发阶段进行 SSR 构建也是这样的思路。

  2. 移除样式代码的引入,Node.js 并不能解析 CSS 的内容。但是 css module 例外:

import styles from "./index.module.css";
// 这里的 styles 是一个对象,如{ "container": "xxx" },而不是 CSS 代码
console.log(styles);
  1. 依赖外部化(external)。对于某些第三方依赖并不需要使用构建后的版本,而是直接从 node_modules 中读取,比如 react-dom,这样在 SSR 构建的过程中将不会构建这些依赖,从而极大程度上加速 SSR 的构建。

运行时:

  1. 加载 SSR 入口模块。确定 SSR 构建产物的入口,即组件的入口在哪里,并加载对应的模块。
  2. 进行数据预取.
  3. 渲染组件.
  4. HTML 拼接 拼接完整的 HTML 字符串,并将其作为响应返回给浏览器。

基于 Vite 搭建 SSR 项目

SSR 构建 API

  • 开发环境 vite.ssrLoadModule 传入入口模块路径
// 加载服务端入口模块
const xxx = await vite.ssrLoadModule("/src/entry-server.tsx");
  • 生产环境 Vite 会默认进行打包,通过 package.json 配置
{
  "build:ssr": "vite build --ssr 服务端入口路径"
}

项目骨架搭建

src 目录新建 entry-client.tsx 和 entry-server.tsx

// entry-client.ts
// 客户端入口文件
import React from "react";
import { hydrateRoot } from "react-dom/client";
import "../index.css";
import App from "./App";

// @ts-ignore
const data = window.__SSR_DATA__;
const root = document.getElementById("root") as Element;
hydrateRoot(
  root,
  <React.StrictMode>
    <App data={data} />
  </React.StrictMode>
);

// entry-server.ts
// 导出 SSR 组件入口
import App from "./App";
import "./index.css";

function ServerEntry(props: any) {
  return <App />;
}

export { ServerEntry };

SSR 运行时实现

以中间件的形式实现

async function createSsrMiddleware(app: Express): Promise<RequestHandler> {
  let vite: ViteDevServer | null = null;
  if (!isProd) {
    vite = await (
      await import("vite")
    ).createServer({
      root: process.cwd(),
      server: {
        middlewareMode: "ssr",
      },
    });
    // 注册 Vite Middlewares
    // 主要用来处理客户端资源
    app.use(vite.middlewares);
  }
  return async (req, res, next) => {
    // SSR 的逻辑
    // 1. 加载服务端入口模块
    // 2. 数据预取
    // 3. 「核心」渲染组件
    // 4. 拼接 HTML,返回响应
  };
}

async function createServer() {
  const app = express();
  // 加入 Vite SSR 中间件
  app.use(await createSsrMiddleware(app));

  app.listen(3000, () => {
    console.log("Node 服务器已启动~");
    console.log("http://localhost:3000");
  });
}

createServer();

第一步,加载服务端入口模块

区分生产环境和开发环境,不同加载方式

async function loadSsrEntryModule(vite: ViteDevServer | null) {
  // 生产模式下直接 require 打包后的产物
  if (isProd) {
    const entryPath = path.join(cwd, "dist/server/entry-server.js");
    return require(entryPath);
  }
  // 开发环境下通过 vite.ssrLoadModule no-bundle 方式加载
  else {
    const entryPath = path.join(cwd, "src/entry-server.tsx");
    return vite!.ssrLoadModule(entryPath);
  }
}

const { ServerEntry } = await loadSsrEntryModule(vite);

第二步,数据预取

export async function fetchData() {
  //  数据获取逻辑
}

第三步,「核心」渲染组件

React SSR 服务端通过 renderToString 把组件树渲染成 html 字符串 第一步拿到了入口,创建组件 -> 渲染成 html 字符串

import { renderToString } from "react-dom/server";
// 创建组件同时 塞入预取的data
const appHtml = renderToString(React.createElement(ServerEntry, { data }));

第四步,拼接 html

  • html 中添加插槽 插入 html 字符串 中通过一个 script 插入之前预取的 data
// index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="root"><!-- SSR_APP --></div>
    <script type="module" src="/src/entry-client.tsx"></script>
    <!-- SSR_DATA -->
  </body>
</html>
function resolveTemplatePath() {
  return isProd
    ? path.join(cwd, "dist/client/index.html")
    : path.join(cwd, "index.html");
}

const templatePath = resolveTemplatePath();
let template = fs.readFileSync(templatePath, "utf-8");
// 开发模式下需要注入 HMR、环境变量相关的代码,因此需要调用 vite.transformIndexHtml
if (!isProd && vite) {
  template = await vite.transformIndexHtml(url, template);
}
const html = template
  .replace("<!-- SSR_APP -->", appHtml)
  // 注入数据标签,用于客户端 hydrate
  .replace(
    "<!-- SSR_DATA -->",
    `<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>`
  );

生产环境的静态资源处理

开发阶段的静态资源 Vite Dev Server 的中间件会处理,生产环境所有的资源都已经打包完成,需要启用单独的静态资源服务来承载这些资源

import serve from "serve-static";

async function createServer() {
  const app = express();
  // 加入 Vite SSR 中间件
  app.use(await createSsrMiddleware(app));
  // 注册中间件,生产环境端处理客户端资源
  if (isProd) {
    app.use(serve(path.join(cwd, "dist/client")));
  }
  app.listen(3003, () => {
    console.log("Node 服务器已启动~");
    console.log("http://localhost:3003");
  });
}

路由管理

通过 StaticRouter 配合 location 参数

// 导出 SSR 组件入口
import App from "./App";
import "../index.css";
import { StaticRouter } from "react-router-dom/server";
import Routers from "../routers";
function ServerEntry(props: any) {
  return <StaticRouter location={props.url}>{Routers}</StaticRouter>;
}

export { ServerEntry };

浏览器 API 兼容

通过 import.meta.env.SSR 内置的 vite 环境变量 判断是否在 ssr 环境

if (import.meta.env.SSR) {
  // 服务端执行的逻辑
} else {
  // 在此可以访问浏览器的 API
}

SSR 缓存

  1. 文件读取缓存
function createMemoryFsRead() {
  const fileContentMap = new Map();
  return async (filePath) => {
    const cacheResult = fileContentMap.get(filePath);
    if (cacheResult) {
      return cacheResult;
    }
    const fileContent = await fs.readFile(filePath);
    fileContentMap.set(filePath, fileContent);
    return fileContent;
  };
}

const memoryFsRead = createMemoryFsRead();
memoryFsRead("file1");
// 直接复用缓存
memoryFsRead("file1");
  1. 预取数据缓存
  2. HTML 渲染缓存

性能监控

通过 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"] });

// 接下来我们在 SSR 进行打点
// 以 renderToString  为例
performance.mark("render-start");
// renderToString 代码省略
performance.mark("render-end");
performance.measure("renderToString", "render-start", "render-end");