React SSR 技术实现原理

著有《React 源码》《React 用到的一些算法》《javascript地月星》等多个专栏。欢迎关注。

文章不好写,要是有帮助别忘了点赞,收藏~ 你的鼓励是我继续挖干货的的动力🔥。

另外,本文为原创内容,商业转载请联系作者获得授权,非商业转载需注明出处,感谢理解~

第一部分

之前写过一篇React SSR 设计原理阅读量不高,感觉写的还可以,所以重新整理和增加了一些内容,再发一遍。末尾的"总结"非常值得看一下,感觉没有人把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】。

其中【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在服务的和客户端都被使用属于公共的。

我改了打包目录部分的配置,打包后的目录不一样了,不影响阅读

运行两遍React组件

在服务器端执行一遍React组件,生成html后返回纯html给客户端,完成首次渲染。

在客户端执行client.js进行水合,再次执行一遍React组件,创建Fiber、挂接到DOM、绑定事件等。

jsx 复制代码
entry-server.jsx
const { pipe } = renderToPipeableStream(<App />, {...pipe(res)...})

注意这里的<App/>经过打包后是,
const { pipe } = renderToPipeableStream(/* @__PURE__ */ React.createElement(App, null), {...pipe(res)...})
服务端运行React.createElement得到App的html。

明显已经运行了一遍React.createElement(App, null),纯App的html。
下面在客户端还会运行一遍。

返回App的纯html,通过pipe能获取,在option中注入。

客户端会再运行一遍。

js 复制代码
hydrateRoot(document.getElementById("root"), <App />);
打包后:
hydrateRoot(document.getElementById("root"), /* @__PURE__ */ React.createElement(App, null));

这一遍叫水合。没有水合前是纯html的页面。水合后被React接管。

例如在后面的实践例子中,第8秒在vscode打印一次,第16秒在浏览器打印一次。

HTML的水合

水合就是给服务端的返回的HTML的DOM添加internalInstanceKey,能找到它的Fiber。

变量internalInstanceKey,值类似于__reactFiberchytmrjmd38,__reactFiber+随机数。

每个真实DOM都有__reactFiber$...属性,指向真实DOM对应的Fiber Node。

如果真实DOM没有__reactFiber$...表示这个DOM没有受到React的管理,例如SSR时服务端返回的HTML。

我简单的把"给DOM添加__reactFiber$..属性"一起归到hydrateRoot()。(实际hydrateRoot没有做这件事。添加属性是触发任意事件的时候做的),有对应的Fiber Node,这个DOM就被React接管了。

水合前 只是简单的html 没有__reactFiber$...属性 水合后 多了__reactFiber$...属性

注释节点

SSR生成的HTML带有注释

html 复制代码
<div>
  <!-- $? -->  有internalInstanceKey、受到管理
  <div>        没有internalInstanceKey、没有受到管理
    Loading...
  </div>
  <!-- /$ -->
</div>
html 复制代码
<div>
  <!-- $ -->  有internalInstanceKey、受到管理
  <h1>        没有internalInstanceKey、没有受到管理
    Hello World!
  </h1>
  <!-- /$ -->
</div>

React借助注释节点,快速找到SSR内容。如果没有注释节点,无法分辨SSR内容或者要从整个页面全部节点中一个一个查找。

React中专门查找注释节点的函数getParentSuspenseInstance:

html 复制代码
<!--$-->                    ← Suspense A 开始 (pending)  找到这个节点
  <div>Content A</div>      注释和内容是兄弟节点
  <!--$-->                  ← Suspense B 开始 (pending)
    <span>嵌套</span>
  <!--/$-->                 ← Suspense B 结束
  <p id="target">Target</p>  ← targetInstance  从这里开始
<!--/$-->                   ← Suspense A 结束
js 复制代码
function getParentSuspenseInstance(targetInstance) {
  var node = targetInstance.previousSibling; 
  var depth = 0;

  while (node) {
    if (node.nodeType === COMMENT_NODE) {
      var data = node.data;//node是`<!--$-->`, `<!--$!-->`, `<!--$?-->`, `<!--/$-->` 。data是`$``$!``$?``/$`。

      if (data === SUSPENSE_START_DATA || // $
          data === SUSPENSE_FALLBACK_START_DATA ||  // $!
          data === SUSPENSE_PENDING_START_DATA) { // $?
        // 遇到 Suspense 开始
        if (depth === 0) {
          return node; // 找到了最外层未闭合的 Suspense,返回
        } else {
          depth--; // 退出一层嵌套
        }
      } else if (data === SUSPENSE_END_DATA) { // /$
        depth++; // 遇到结束,说明我们"进入"了一个更外层的 Suspense 范围
      }
    }

    node = node.previousSibling; //从后往前找
  }

  return null;
}

返回<!-- $ -->等的DOM。

第二部分

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。

同构是一个宏观目标: 它的目标是让同一套代码既能在服务器上运行(生成 HTML),又能在客户端上运行(处理交互)。它描述的是一种架构模式

脱水/注水是一个微观实现: 它是实现同构这个目标 所必须的技术手段

其实就是服务端执行一遍React组件返回HTML。(服务端渲染)

HTML上的DOM找不到Fiber。(脱水的)

把Fiber关联到DOM,DOM能找到自己的Fiber(注水)

第三部分

实践例子

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) {//currentView就是unit8Array
    // 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>,作为整个SSR技术实现的一部分。例如前面的插入client.js,例如前面的状态的注水,还有这里的$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);
}

把变量命名成有意义的变量名,取消掉压缩在看一下:

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; //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");
B:0 S:0

总结

SSR由四个角色组成,分别是:服务端和客户端公共的文件、HTTP服务器、客户端文件和服务端文件。

React组件创建了两遍,renderToPipeableStream+公共文件创建一遍,hydrateRoot+公共文件创建第二遍(完全没有用到createRoot)。

水合不过是把Fiber节点关联到DOM,所谓"关联"就是通过DOM能找到Fiber。

SSR技术的实现设计不过是 HTML文件 + 插入脚本

第一步,运行HTTP服务器,renderToPipeableStream + 公共文件 创建纯HTML文件

第二步,插入水合脚本client.js(客户端文件,里面是hydrateRoot())

第三步,插入替换脚本$RC。 HTTP服务器返回这个HTML文件,浏览器显示页面,执行脚本。

往后,再要其他功能,例如路由的管理、状态的管理,不过也是在HTML上插入新的脚本(不是全部都要插入新脚本)。

以状态管理为例,在服务端,正常创建了Store:

js 复制代码
//服务端
import store from "./store";
const preloadedState = store.getState();

给HTML文件插入脚本:

html 复制代码
index.html
<body>
  <div id="root">${content}</div>
  <script> //插入
    window.__PRELOAD_STATE__=${JSON.stringify(preloadedState)}//这样就被添加到了window
  </script>
  <script src="client.js"></script>
</body>

客户端就能获取状态:

js 复制代码
client.js
// 创建store时,如果有window?.__PRELOAD_STATE__,就以它为初始state,否则为空对象{}
const store = createStoreInstance(window?.__PRELOAD_STATE__);
相关推荐
盘古开天16662 小时前
深度强化学习算法详解:从理论到实践
算法
Mr.H01273 小时前
快速排序的常见构思
数据结构·算法
mit6.8243 小时前
背包dp|格雷码
算法
rit84324993 小时前
基于MATLAB的PCA+SVM人脸识别系统实现
人工智能·算法
RTC老炮3 小时前
webrtc降噪-NoiseEstimator类源码分析与算法原理
算法·webrtc
不当菜鸡的程序媛4 小时前
Flow Matching|什么是“预测速度场 vt=ε−x”?
人工智能·算法·机器学习
sali-tec5 小时前
C# 基于halcon的视觉工作流-章58-输出点云图
开发语言·人工智能·算法·计算机视觉·c#
_OP_CHEN5 小时前
算法基础篇:(四)基础算法之前缀和
c++·算法·前缀和·蓝桥杯·acm·icpc·算法竞赛
_OP_CHEN5 小时前
算法基础篇:(五)基础算法之差分——以“空间”换“时间”
c++·算法·acm·icpc·算法竞赛·差分算法·差分与前缀和