React SSR 技术解读

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),又能在客户端上运行(处理交互)。它描述的是一种架构模式

脱水/注水 是一个微观实现 : 它是实现同构这个目标 所必须的技术手段。脱水和注水负责解决两个关键问题:

  1. 状态同步: 如何让服务器渲染时的状态(数据、路由等)能在客户端被完美地恢复?
  2. 性能优化: 如何在不重建整个 DOM 树的情况下,让静态 HTML 变得可交互?

路由管理

对于同构应用,路由也必须是同构的,即服务器和客户端使用同一套路由配置。

状态管理

状态管理也需要是同构的,以确保服务器和客户端的状态一致。

参考这篇 ssr

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");
相关推荐
我是日安2 小时前
从零到一打造 Vue3 响应式系统 Day 5 - 核心概念:单向链表、双向链表
前端·vue.js
遂心_2 小时前
React中的onChange事件:从原理到实践的全方位解析
前端·javascript·react.js
GHOME2 小时前
原型链的原貌
前端·javascript·面试
阳焰觅鱼2 小时前
react动画
前端
bug_kada2 小时前
Flex布局/弹性布局(面试篇)
前端·面试
元元不圆2 小时前
JSP环境部署
前端
槿泽2 小时前
Vue集成Electron目前最新版本
前端·vue.js·electron
luckyCover2 小时前
带你一起攻克js之原型到原型链~
前端·javascript
麦当_2 小时前
SwipeMultiContainer 滑动切换容器算法指南
前端·javascript·算法