SSR搭建 四个角色
server服务器(server.js)+ server ssr返回纯html首页(对应entry-server.jsx)+ client hydrateRoot注水给html,把 Fiber 树挂接到现有 DOM 上(对应client.jsx),给 DOM 节点加上 React 的事件绑定、内部状态,使 DOM 变"活"。
流程:请求server服务器(server.js),返回首页内容(entry-server.jsx) ----> 首页下载脚本client.jsx,client.jsx执行hydrateRoot水合,react接管。
所以能看到四方文件【server.js】【entry-server.jsx】【client.jsx】
【App.jsx LazyComp.jsx】这两个属于公共的,服务端和客户端都用到。
sql
my-react-app
├── dist/
│ ├── server/
│ │ └── server.js ← 这个在 Node 跑,不会发给客户端 打包前是entry-server.jsx
│ └── client/
│ └── client.js ← 这个才会发给浏览器
├── server.js 和上面的server.js不同同一个,node server.js启动的是这个
在提供的例子中还有App.jsx LazyComp.jsx的打包没有在上方体现,简化逻辑,主要就是这3方。
App LazyComp在服务的和客户端都被使用属于公共的。
my-react-app
├── dist/
│ ├── server/
│ │ ├── assets/
│ │ │ └──LazyComp-B2_1O9qA.js
│ │ └── server.js
│ └── client/
│ ├── assets/
│ │ └──LazyComp-CFx86sQ6.js
│ └── client.js
├── server.js
App被打包到client.js内部
App被打包到server.js内部
打包了2份

我改了打包目录部分的配置,打包后的目录不一样了,不影响阅读
运行两遍react组件
在服务器端执行一遍react组件,生成html后返回纯html给客户端,完成首次渲染。
在客户端执行client.js进行水合,再次执行一遍react组件,创建fiber、挂接到dom、绑定事件等。
jsx
entry-server.jsx
const { pipe } = renderToPipeableStream(<App />, option)
注意这里的<App/>经过打包后是,
const { pipe } = renderToPipeableStream(/* @__PURE__ */ React.createElement(App, null), option)
服务端运行React.createElement得到App的html。
客户端会再运行一遍。
js
hydrateRoot(document.getElementById("root"), <App />);
这一遍叫水合。没有水合前是纯html的页面。水合后被React接管。
例如在后面的实践例子中,第8秒在vscode打印一次,第16秒在浏览器打印一次。
html的水合
变量internalInstanceKey,值类似于__reactFiberchytmrjmd38,__reactFiber+随机数。
每个真实dom都有__reactFiber$...属性,指向真实dom对应的fiber node。
如果真实dom没有__reactFiber$...,表示这个dom没有受到React的管理,例如SSR时服务端返回的html。
我简单的把"给dom添加__reactFiber$..属性"一起归到hydrateRoot()。(实际hydrateRoot没有做这件事。添加属性是触发任意事件的时候做的),有对应的fiber node, 这个dom就被React接管了。
水合前 只是简单的html 没有__reactFiber$...属性![]() |
水合后![]() |
---|
ssr核心概念
- 同构(Isomorphic / Universal Rendering)指 React 代码既能运行在服务端(Node.js)又能运行在客户端(浏览器)。其实就是在服务的执行一遍react组件。
- 注水 (Hydration)浏览器接收到服务端生成的 HTML 后,React 不会重新渲染 DOM ,而是"注水"------把 Fiber 树挂接到现有 DOM 上。给 DOM 节点加上 React 的事件绑定、内部状态,使 DOM 变"活"。
- 脱水 (Dehydration):在 React 18 里,Suspense 边界可以被"脱水" ,即保留 fallback 或 HTML 片段,但不立即 hydrate。等到用户交互或资源到达,再对局部进行 hydration(Selective Hydration)。
其实就是服务端执行一遍react组件返回html。
同构 是一个宏观目标 : 它的目标是让同一套代码既能在服务器上运行(生成 HTML),又能在客户端上运行(处理交互)。它描述的是一种架构模式。
脱水/注水 是一个微观实现 : 它是实现同构这个目标 所必须的技术手段。脱水和注水负责解决两个关键问题:
- 状态同步: 如何让服务器渲染时的状态(数据、路由等)能在客户端被完美地恢复?
- 性能优化: 如何在不重建整个 DOM 树的情况下,让静态 HTML 变得可交互?
路由管理
对于同构应用,路由也必须是同构的,即服务器和客户端使用同一套路由配置。
状态管理
状态管理也需要是同构的,以确保服务器和客户端的状态一致。
html
index.html
<body>
<div id="root">${content}</div>
<script>
window.__PRELOAD_STATE__=${JSON.stringify(preloadedState)} ---状态水合同构
</script>
<script src="client.js"></script>
</body>
client.jsx
// 创建store时,如果有window?.__PRELOAD_STATE__,就以它为初始state,否则为空对象{}
const store = createStoreInstance(window?.__PRELOAD_STATE__);
例子代码
jsx
// src/App.jsx
import React, { Suspense, lazy } from 'react';
const LazyComp = lazy(() => {
return new Promise(resolve => {
setTimeout(() => {
console.log('服务端和客户端都运行一次LazyComp');---运行两遍React组件:在vscode控制台打印一次,在浏览器控制台打印一次 第8秒在vscode打印,第16秒在浏览器打印
resolve(import('./LazyComp'));
}, 8000); // 模拟 8 秒延迟
});
});
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComp />
</Suspense>
);
}
export default App;
jsx
//client.jsx
import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App.jsx";
console.log(22222);
hydrateRoot(document.getElementById("root"), <App />);
jsx
// entry-server.jsx
import React from "react";
import { renderToPipeableStream } from "react-dom/server";
import App from "./App.jsx";
export function render(res) {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader("content-type", "text/html");
res.write(`<!DOCTYPE html>
<html>
<head>
<title>My React App</title>
<meta charset="UTF-8">
</head>
<body>
<div id="root">`);
console.log(222);
pipe(res);//如果把const {pipe}注释掉,会报错 pipe is not defined;
res.write(`</div>
</body>
<script type="module" src="/dist/client.js"></script>
</html>`);
},
});
}
js
// LazyComp.jsx
import React from 'react';
export default function LazyComp() {
return <h1>✅Hello !</h1>;
}
js
//server.js 在根目录!
import express from "express";
import { createServer as createViteServer } from "vite";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function start() {
const app = express();
// 1. 创建 Vite dev server (中间件模式)
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom"
});
app.use(vite.middlewares);
// 2. SSR 路由
app.get("/", async (req, res) => {
try {
// const { render } = await vite.ssrLoadModule("/src/entry-server.jsx");
const { render } = await vite.ssrLoadModule("/dist/server.js");
render(res);
} catch (e) {
vite.ssrFixStacktrace(e);
console.error(e);
res.status(500).end(e.message);
}
});
app.listen(3000, () => {
console.log("🚀 SSR server running at http://localhost:3000");
});
}
start();
js
// vite.config.js
import { defineConfig } from 'vite';
// import react from '@vitejs/plugin-react';
export default defineConfig({
// plugins: [react()],//导入React
define: {
"process.env.NODE_ENV": '"development"',
},
// minify: false,
build: {
ssr: true,//必须是true,是false打包后entry-server.jsx返回render不返回
// sourcemap: true, // 生成 client.js.map
rollupOptions: {
input: {
server: 'src/entry-server.jsx',
client: "src/client.jsx"
},
output: {
entryFileNames: "[name].js",
}
}
},
});
- 如果把const {pipe}注释掉,会报错 pipe is not defined;
- pipe的原理是res.write。内部借用TextEncoder.encodeInto()、unit8array最后res.write。
textEncoder.encodeInto()能把数据写入unit8array。
本例子的补充说明 vite index.html和打包
为了简化,在我们的例子中虽然存在main.jsx、index.html,但是没有用到index.html,而是在entry-server.jsx手写的html。
这样的缺点是不能利用vite打包自动注入index.html的内容功能。
pipe的原理
- completeWriting把loading...和✅Hello发送过去。原理就是res.write。res是express请求的response。
js
//node_modules/react-dom/cjs/react-dom-server.node.development.js
function completeWriting(destination) {//destination就是res
if (currentView && writtenBytes > 0) {
// console.log(destination.write(currentView.subarray(0, 64)));
// console.log(destination.write(currentView.subarray(0, 499)));
destination.write(currentView.subarray(0, writtenBytes));
}
currentView = null;
writtenBytes = 0;
destinationHasCapacity = true;
}
![]() |
![]() |
---|
- send$1把文件发送过去 这似乎是vite干的
js
//node_modules/vite/dist/node/chunks/dep-BcnkIxro.js
function send$1(req, res, content, type, options) {
const {
etag = getEtag(content, { weak: true }),
cacheControl = "no-cache",
headers,
map
} = options;
if (res.writableEnded) {
return;
}
if (req.headers["if-none-match"] === etag) {
res.statusCode = 304;
res.end();
return;
}
res.setHeader("Content-Type", alias[type] || type);
res.setHeader("Cache-Control", cacheControl);
res.setHeader("Etag", etag);
if (headers) {
for (const name in headers) {
res.setHeader(name, headers[name]);
}
}
if (map && "version" in map && map.mappings) {
if (type === "js" || type === "css") {
content = getCodeWithSourcemap(type, content.toString(), map);
}
} else if (type === "js" && (!map || map.mappings !== "")) {
const code = content.toString();
if (convertSourceMap.mapFileCommentRegex.test(code)) {
debug$3?.(`Skipped injecting fallback sourcemap for ${req.url}`);
} else {
const urlWithoutTimestamp = removeTimestampQuery(req.url);
const ms = new MagicString(code);
content = getCodeWithSourcemap(
type,
code,
ms.generateMap({
source: path$d.basename(urlWithoutTimestamp),
hires: "boundary",
includeContent: true
})
);
}
}
res.statusCode = 200;
res.end(content);
return;
}
![]() |
待处理 ![]() |
---|---|
状态码200 ![]() |
插入脚本片段
ssr用到了多个<script>,例如前面的插入client.js,例如前面的状态的注水插入的<script>。
这里的$RC函数

js
//node_modules/react-dom/cjs/react-dom-server.node.development.js
var completeBoundaryFunction = 'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}}';
var completeBoundaryScript1Full = stringToPrecomputedChunk(completeBoundaryFunction + ';$RC("');
function writeCompletedBoundaryInstruction(destination, responseState, boundaryID, contentSegmentID) {
writeChunk(destination, responseState.startInlineScript);
if (!responseState.sentCompleteBoundaryFunction) {
// The first time we write this, we'll need to include the full implementation.
responseState.sentCompleteBoundaryFunction = true;
writeChunk(destination, completeBoundaryScript1Full);
} else {
// Future calls can just reuse the same function.
writeChunk(destination, completeBoundaryScript1Partial);
}
if (boundaryID === null) {
throw new Error('An ID must have been assigned before we can complete the boundary.');
}
var formattedContentID = stringToChunk(contentSegmentID.toString(16));
writeChunk(destination, boundaryID);
writeChunk(destination, completeBoundaryScript2);
writeChunk(destination, responseState.segmentPrefix);
writeChunk(destination, formattedContentID);
return writeChunkAndReturn(destination, completeBoundaryScript3);
}
ai翻译:把变量命名成有意义的变量名,取消掉压缩
js
function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}};$RC("B:0","S:0")
js
function replaceSegmentIntoBoundary(boundaryId, segmentId) {
const boundaryNode = document.getElementById(boundaryId);
const segmentTemplate = document.getElementById(segmentId);
// 移除 <template> 包裹
segmentTemplate.parentNode.removeChild(segmentTemplate);
if (boundaryNode) {
let marker = boundaryNode.previousSibling; // boundary 前的注释节点
const parent = marker.parentNode;
let current = marker.nextSibling;
let depth = 0;
// 删除 boundary 占位内容 (直到 "/$" 结束标记)
do {
if (current && current.nodeType === 8) { // 注释节点
const comment = current.data;
if (comment === "/$") {
if (depth === 0) break;
else depth--;
} else if (comment === "$" || comment === "$?" || comment === "$!") {
depth++;
}
}
const next = current.nextSibling;
parent.removeChild(current);
current = next;
} while (current);
// 插入 segment 的内容
while (segmentTemplate.firstChild) {
parent.insertBefore(segmentTemplate.firstChild, current);
}
// 更新 marker,标记已完成
marker.data = "$";
if (marker._reactRetry) {
marker._reactRetry();
}
}
}
// 执行:把 S:0 (segment) 替换进 B:0 (boundary)
replaceSegmentIntoBoundary("B:0", "S:0");