🥭 React SSR 技术实现原理

428 阅读5分钟

著有《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】这两个属于公共的,服务端和客户端都用到。

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、绑定事件等。

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 中注入。

客户端会再运行一遍。

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

React.createElement(App, null)再次运行。

这一遍叫水合。没有水合前是纯 html 的页面。水合后被 React 接管。
例如在后面的实践例子中,第8秒在 vscode 打印一次,第16秒在浏览器打印一次。

HTML的水合

水合可以简单理解为给服务端的返回的 HTML 的 DOM 添加 internalInstanceKey,能找到它的 Fiber。

变量 internalInstanceKey,值类似于 __reactFiber$chytmrjmd38,__reactFiber$+随机数。
每个真实 DOM 都有 __reactFiber$...属性,指向真实 DOM 对应的 Fiber Node。
如果真实 DOM 没有 __reactFiber$...表示这个 DOM 没有受到 React 的管理,例如 SSR 时服务端返回的 HTML。
我简单的把“给DOM添加__reactFiber$..属性”一起归到 hydrateRoot()。(实际 hydrateRoot 没有做这件事。添加属性是触发任意事件的时候做的),有对应的 Fiber Node,这个 DOM 就被 React 接管了。

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

完整的水合过程 还包括对比 服务端HTML 和 客户端渲染的HTML 是否相同等过程。

注释节点

  • 会发现 SSR 生成的 HTML 中出现注释节点,它们是 Suspense 边界。同样是 SSR 的,非 Suspense 内容不在注释节点里面。
<App>
  <Header />        // 不在 Suspense 内
  <Suspense fallback={<Loading />}>
    <Main />
  </Suspense>
</App>

服务端输出:都是 SSR 内容,但是非 Suspense 内容不在注释节点里面。

<div id="root">
  <header>...</header>        <!-- Header,SSR 不在注释节点里面-->
  <!--$-->                    <!-- Suspense start -->
    <main>...</main>          <!-- 或 fallback -->
  <!--/$-->
</div>
  • 只介绍 Suspense 的 SSR

脱水的HTML:

<div>
  <!-- $ -->  DOM实例有internalInstanceKey、受到React管理
  <h1>        DOM实例没有internalInstanceKey、没有受到React管理
    Hello World!
  </h1>
  <!-- /$ -->
</div>

注释 DOM 节点的 __reactFiber$...属性指向 Suspense Fiber。

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

<!--$-->                    ← Suspense A 开始 (pending)  找到这个节点
  <div>Content A</div>      注释和内容是兄弟节点
  <!--$-->                  ← Suspense B 开始 (pending)
    <span>嵌套</span>
  <!--/$-->                 ← Suspense B 结束
  <p id="target">Target</p>  ← targetInstance  从这里开始
<!--/$-->                   ← Suspense A 结束
//返回\<!-- \$ -->等的注释DOM节点。
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,返回的是注释DOM节点
        } else {
          depth--; // 退出一层嵌套
        }
      } else if (data === SUSPENSE_END_DATA) { // /$
        depth++; // 遇到结束,说明我们“进入”了一个更外层的 Suspense 范围
      }
    }

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

  return null;
}

第二部分

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(注水)

第三部分 进阶

实践例子

console.log('服务端和客户端都运行一次LazyComp');运行两遍React组件:在vscode控制台打印一次,在浏览器控制台打印一次 第8秒在vscode打印,第16秒在浏览器打印。

App.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;

client.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 />);

entry-server.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>`);
    },
  });
}

LazyComp.jsx

// LazyComp.jsx
import React from 'react';

export default function LazyComp() {
  return <h1>✅Hello !</h1>;
}

server.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();

vite.config.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。
//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干的
//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技术的另一个特性。

SSR 多次用到了插入脚本片段的技术,也就是插入<script>,作为整个 SSR 技术实现的一部分。

  • 例如状态管理,在服务端,正常创建了Store:
//服务端
import store from "./store";
const preloadedState = store.getState();

给HTML文件插入脚本:

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

客户端就能获取状态:

client.js
// 创建store时,如果有window?.__PRELOAD_STATE__,就以它为初始state,否则为空对象{}
const store = createStoreInstance(window?.__PRELOAD_STATE__);
  • 例如前面的实践例子,在首页HTML的末尾插入<script type="module" src="/dist/client.js"></script>(看下图👇)。例如这里的 $RC 函数:

//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);
}

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

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

总结

SSR由四个角色组成,分别是:服务端和客户端公共的文件、HTTP服务器、客户端文件和服务端文件。
React组件创建了两遍,renderToPipeableStream+公共文件创建一遍,hydrateRoot+公共文件创建第二遍(完全没有用到createRoot)。

水合不过是把Fiber节点关联到DOM,所谓“关联”就是通过DOM能找到Fiber。
水合后,在事件发生时,就能被找到对应的Fiber「收集事件」

SSR技术的实现设计不过是 HTML文件 + 插入脚本
第一步,运行HTTP服务器,renderToPipeableStream + 公共文件创建纯HTML文件
第二步,插入水合脚本client.js(客户端文件,里面是hydrateRoot())
第三步,插入替换脚本$RC。 HTTP服务器返回这个HTML文件,浏览器显示页面,执行脚本。
往后,再要其他功能,例如路由的管理、状态的管理,不过也是在HTML上插入新的脚本(不是全部都要插入新脚本,因为它们在React脚本已经包含了内容了)。