手搓 SSR Server:为 GraphQL 应用构建自定义服务端渲染
这篇文章的价值: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,它有两个核心优势:
- Time to First Byte 更短:shell 准备好就开始发送,不用等所有数据到齐
- 支持 Suspense:数据没准备好的部分先发 fallback,数据到了再流式补充
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 的每一个环节,但说实话,不推荐在生产中这么做。原因:
- 维护成本极高:React SSR 的内部行为在小版本更新中就可能变化,你需要持续跟进。Next.js 团队和 React 团队紧密协作来保证这些细节的正确性——你一个人跟不上。
- 缺失的功能太多:代码分割、预获取、ISR、静态生成、中间件、错误边界……一个生产级 SSR 框架需要的东西远不止"把组件渲染成 HTML"。
- Relay 的 SSR 支持本身就不完善:Relay 团队在 Meta 内部有自己的 SSR 基础设施,开源版本的 SSR 文档和示例都很少。你会花大量时间在源码里找答案。
但作为学习项目,它的价值很大:
- 你会真正理解
renderToPipeableStream做了什么,而不只是"它比 renderToString 快" - 你会理解 hydration 为什么会 mismatch,因为你亲手写了两端的渲染逻辑
- 你会理解 EntryPoint 模式为什么比
getServerSideProps更适合大型应用——数据依赖提升到路由层,解耦了数据获取和组件渲染 - 你会理解 CSS-in-JS 在 SSR 场景下的真正痛点
如果你也想试试
建议的步骤:
- 先用
renderToString+ Express 搭一个最简单的 SSR(不用 GraphQL,纯静态组件) - 换成
renderToPipeableStream,理解流式渲染和onShellReady - 加入 Suspense,让部分数据异步加载
- 接入 GraphQL 客户端(Apollo 比 Relay 容易上手),处理服务端数据预加载
- 实现客户端 hydration,确保两端渲染结果一致
- 处理 CSS(先从 vanilla CSS 开始,再尝试 CSS-in-JS)
每一步都会让你对 SSR 的理解更深一层。不要试图一步到位——那只会让你在 hydration mismatch 的错误信息里迷失。