手搓 SSR Server:为 GraphQL 应用构建自定义服务端渲染

The Great Wave off Kanagawa by Katsushika Hokusai
葛饰北斋,《神奈川冲浪里》, c. 1831 — 汹涌的浪潮,如同流式渲染中数据的奔涌
这篇文章的价值:Next.js 很好,但如果你的技术栈是 Relay + GraphQL,它的 SSR 模型和 Relay 的 EntryPoint 数据预加载哲学有冲突。这篇文章记录了从零手搓一个 SSR Server 的过程:路由匹配、数据预加载、流式渲染、Hydration,以及如何让 Relay 的 EntryPoint 模式在服务端正确工作。适合需要深度定制 SSR 方案的团队参考。

背景

Next.js 是好东西,但它和 Relay(Meta 的 GraphQL 客户端)的集成一直不太顺畅。Relay 有自己的一套数据预加载哲学——EntryPoint 模式,它希望在路由匹配时就开始 fetch 数据,而不是等组件渲染时才发请求。这和 Next.js 的 getServerSideProps / Server Components 的模型有微妙的冲突。

于是我决定自己写一个 SSR server,专门为 Relay + Fluent UI 的技术栈定制。这篇文章记录这个过程中的关键步骤和踩过的坑。

总体架构

浏览器请求 GET /pages/admin/42?sort=name
    ↓
Express Server
    ↓
路由匹配 (ssrrouter.lookup)
    ↓ 返回 entrypoint 名称 + 参数绑定
EntryPoint 解析
    ↓ 根据名称找到 EntryPoint 配置
GraphQL 数据预加载 (Relay loadQuery)
    ↓
React renderToPipeableStream (流式 SSR)
    ↓
HTML 流式响应 → 浏览器
    ↓
hydrateRoot (客户端接管)

第一步:定义 EntryPoint 接口

EntryPoint 是整个架构的核心抽象。每个"页面"不是一个 React 组件,而是一个 EntryPoint 对象,它描述了:渲染什么组件、需要什么参数、需要预加载什么数据。

// EntryPoint.tsx
interface EntryPoint<TParams> {
  root: ComponentType<PreloadedProps>;
  getParams: (path: string) => TParams;
  getPreloadedProps: (params: TParams) => PreloadedProps;
}

interface PreloadedProps {
  queries: {
    [key: string]: {
      query: GraphQLTaggedNode;
      variables: Variables;
    };
  };
}

一个具体的 EntryPoint 长这样:

// App.entrypoint.tsx
const AppEntryPoint: EntryPoint<Params> = {
  root: App,
  getParams: (path: string): Params => {
    return { limit: 10 };
  },
  getPreloadedProps: (params: Params): PreloadedProps => {
    return {
      queries: {
        preloadedQuery: {
          query: AppQuery,        // Relay GraphQL query
          variables: {},
        },
      },
    };
  },
};

这个设计来自 Relay 团队的 EntryPoint pattern。关键思想是:数据依赖声明在路由层,而不是组件层。路由匹配的瞬间就知道需要 fetch 什么数据,不需要等 React 渲染树 build 完。

第二步:路由匹配

路由配置把 URL 模式映射到 EntryPoint:

// routes.ts
export const ssrRouterConfig = [
  "route /      AppEntrypoint",
  "route /admin AdminEntrypoint",
  "route /admin/:id(int) AppEntrypoint",
];

export const ssrEntrypointConfig = new Map<string, EntryPoint<any>>([
  ["AppEntrypoint", AppEntryPoint],
  ["AdminEntrypoint", AdminEntryPoint],
]);

请求进来时,先做路由匹配,再查找对应的 EntryPoint:

const path = req.path.substring(6);  // 去掉 /pages 前缀
const entrypointRoute = ssrrouter.lookup(path);
const entrypoint =
  ssrEntrypointConfig.get(entrypointRoute?.entrypoint ?? "") ??
  FallbackEntryPoint;

第三步:GraphQL 数据预加载

这是 SSR 最关键的一步。拿到 EntryPoint 后,调用 getPreloadedProps 获取所有需要的 GraphQL query,然后用 Relay 的 loadQuery 在服务端发起请求:

const { root: Root, getPreloadedProps, getParams } = entrypoint;
const params = getParams(path);
const { queries } = getPreloadedProps(params);

const preloadedData = Object.keys(queries).reduce((acc, key) => {
  acc[key] = loadQuery(
    RelayEnvironment,    // 服务端的 Relay Environment
    queries[key].query,
    queries[key].variables
  );
  return acc;
}, {});

loadQuery 会立即开始 fetch,返回一个引用。当 React 渲染到需要这个数据的组件时,如果数据已经到了就直接用,没到就 suspend(配合 Suspense)。

第四步:流式 SSR 渲染

React 18 引入了 renderToPipeableStream,支持流式 HTML 输出。相比老的 renderToString,它有两个核心优势:

const { pipe, abort } = renderToPipeableStream(
  <RelayEnvironmentProvider environment={RelayEnvironment}>
    <Suspense fallback={<div>...</div>}>
      <RendererProvider renderer={renderer}>
        <SSRProvider>
          <FluentProvider theme={webLightTheme}>
            <Root queries={preloadedData} />
          </FluentProvider>
        </SSRProvider>
      </RendererProvider>
    </Suspense>
  </RelayEnvironmentProvider>,
  {
    bootstrapScripts: ["/entrypoint.client.js"],
    onShellReady() {
      res.setHeader("content-type", "text/html");
      res.write(`<!DOCTYPE html>
<html>
<head>...styles...</head>
<body>
  <div id="root">`);
      pipe(res);        // 把 React 渲染流接到 HTTP 响应
      res.write(`</div>
</body>
</html>`);
    },
    onShellError(error) {
      res.statusCode = 500;
      res.send("Something went wrong");
    },
  }
);

注意 onShellReady 回调——它在 React 渲染完同步部分(shell)时触发。此时先写 HTML 头部,然后 pipe(res) 把渲染流接到 HTTP 响应流。Suspense boundary 内的异步数据会在后续以 <script> 标签的形式流式注入。

第五步:Fluent UI 的 SSR 样式处理

Fluent UI(微软的 React 组件库)使用 CSS-in-JS,SSR 时需要特殊处理,否则页面会闪烁(FOUC):

const renderer = createDOMRenderer();
const style = renderToStaticMarkup(
  <>{renderToStyleElements(renderer)}</>
);

先创建一个 DOM renderer,渲染时收集所有用到的样式,然后把样式内联到 HTML 的 <head> 里。客户端 hydrate 时会复用这些样式,避免重复注入。

第六步:客户端 Hydration

客户端需要做和服务端完全一样的路由匹配和数据预加载,但有一个关键区别——fetchPolicy: "store-only"

// Entrypoint.client.tsx
ssrrouter.createRouter(ssrRouterConfig);  // 同一套路由

const path = window.location.pathname.substring(6);
const entrypointRoute = ssrrouter.lookup(path);
const entrypoint = ssrEntrypointConfig.get(entrypointRoute?.entrypoint ?? "");
const { root: Root, getPreloadedProps, getParams } = entrypoint;
const params = getParams(path);
const { queries } = getPreloadedProps(params);

const preloadedData = Object.keys(queries).reduce((acc, key) => {
  acc[key] = loadQuery(
    RelayEnvironment,
    queries[key].query,
    queries[key].variables,
    { fetchPolicy: "store-only" }  // 只从 store 读,不重新 fetch
  );
  return acc;
}, {});

hydrateRoot(
  document.getElementById("root"),
  <RelayEnvironmentProvider environment={RelayEnvironment}>
    <Root queries={preloadedData} />
  </RelayEnvironmentProvider>
);

store-only 意味着客户端不会重新发 GraphQL 请求,而是使用服务端已经获取并通过 HTML 传递过来的数据。这避免了 hydration 时的重复请求。

主要挑战

1. Relay Environment 的服务端/客户端差异

Relay 的 Environment 封装了网络层。服务端可以直接调用 GraphQL server(甚至直接调函数),客户端需要走 HTTP。两边的 Environment 配置完全不同,但用的是同一套 query。如何让服务端 fetch 的数据无缝传递给客户端是最大的挑战。

解决方案:服务端 fetch 数据后,Relay 的 store 会被序列化到 HTML 里(React 18 的流式注入机制会处理这个),客户端 hydrate 时用 store-only 读取。

2. 流式渲染和 Suspense 的时序问题

renderToPipeableStream 是异步的。onShellReady 触发时,只有同步部分渲染完了。如果所有数据都在 Suspense boundary 后面,shell 可能几乎是空的。需要仔细设计哪些内容在 Suspense 外(立即渲染),哪些在 Suspense 内(可以延迟)。

3. CSS-in-JS 和流式渲染的冲突

Fluent UI 的样式是在渲染过程中动态生成的。但流式渲染意味着 <head> 在渲染开始前就要发送。解决方案是先做一次"预渲染"收集样式,再做真正的流式渲染。这意味着组件树实际上被渲染了两次——一次为了样式,一次为了 HTML。

4. 路由逻辑的同构

服务端和客户端必须用完全相同的路由匹配逻辑,否则 hydration 会失败(客户端渲染的结果和服务端不一致)。这就是为什么把路由器编译成 JS——确保同一份代码在两端运行。

5. 请求中断处理

用户可能在页面加载完之前就离开了。流式渲染时必须监听请求关闭事件,及时 abort 渲染:

req.on("close", () => {
  abort();
});

不处理的话,服务端会继续渲染一个没人要的页面,浪费资源。

回头来看

这个项目让我深入理解了 SSR 的每一个环节,但说实话,不推荐在生产中这么做。原因:

但作为学习项目,它的价值很大:

如果你也想试试

建议的步骤:

  1. 先用 renderToString + Express 搭一个最简单的 SSR(不用 GraphQL,纯静态组件)
  2. 换成 renderToPipeableStream,理解流式渲染和 onShellReady
  3. 加入 Suspense,让部分数据异步加载
  4. 接入 GraphQL 客户端(Apollo 比 Relay 容易上手),处理服务端数据预加载
  5. 实现客户端 hydration,确保两端渲染结果一致
  6. 处理 CSS(先从 vanilla CSS 开始,再尝试 CSS-in-JS)

每一步都会让你对 SSR 的理解更深一层。不要试图一步到位——那只会让你在 hydration mismatch 的错误信息里迷失。