React Streaming SSR 原理解析
功能簡介
React 18 提供了一種新的 SSR 渲染模式: Streaming SSR。通過 Streaming SSR,我們可以實現以下兩個功能:
-
Streaming HTML:服務端可以分段傳輸 HTML 到瀏覽器,而不是像 React 18 以前一樣,需要等待服務端渲染完成整個頁面後才返回給瀏覽器。這樣,瀏覽器可以更快的啓動 HTML 的渲染,提高 FP、FCP 等性能指標。
-
Selective Hydration:在瀏覽器端 hydration 階段,可以只對已經完成渲染的區域做 hydration,而不需要等待整個頁面渲染完成、所有組件的 JS bundle 加載完成,才能開始 hydration。這樣可以更早的對已經完成渲染的區域做事件綁定,從而讓頁面獲得更好的可交互性。
基本原理
使用示例
React 官網給出的一個簡單的使用示例:renderToPipeableStream[1](以 Node.js 環境下的 API 爲例)如下:
let didError = false;
const stream = renderToPipeableStream(
<App />,
{
bootstrapScripts: ["main.js"],
onShellReady() {
// The content above all Suspense boundaries is ready.
// If something errored before we started streaming,
// we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
stream.pipe(res);
},
onShellError(error) {
// Something errored before we could complete the shell
// so we emit an alternative shell.
res.statusCode = 500;
res.send('<!doctype html><p>Loading...</p><script src="clientrender.js"></script>');
},
onAllReady() {
// stream.pipe(res);
},
onError(err) {
didError = true;
console.error(err);
}
}
);
renderToPipeableStream
是在 Node.js 環境下實現 Streaming SSR 的 API。
Streaming HTML
HTTP 支持以 stream 格式進行數據傳輸。當 HTTP 的 Response header 設置 Transfer-Encoding: chunked
時,服務器端就可以將 Response 分段返回。一個簡單示例(simple-stream-demo-hb1qb1[2]):
const http = require("http");
const url = require("url");
const sleep = (ms) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
const server = http.createServer(async (req, res) => {
const { pathname } = url.parse(req.url);
if (pathname === "/") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.setHeader("Transfer-Encoding", "chunked");
res.write("<html><body><div>First segment</div>");
// 手動設置延時,讓分段顯示的效果更加明顯
await sleep(2000);
res.write("<div>Second segment</div></body></html>");
res.end();
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("okay");
});
server.listen(8080);
當訪問 localhost:8080 時,「First segment」 和 「Second segment」會分 2 次傳輸到瀏覽器端,「First segment」先顯示到頁面上,2s 延遲後,「Second segment」再顯示到頁面上。
React 中的 Streaming HTML 要更加複雜。例如,對下面的 App 組件做 SSR:
import { Suspense, lazy } from "react";
const Content = lazy(() => import("./Content"));
export default function App() {
return (
<html>
<head></head>
<body>
<div>App shell</div>
<Suspense>
<Content />
</Suspense>
</body>
</html>
);
}
第 1 次訪問頁面時,SSR 渲染的結果會分成 2 段傳輸,傳輸的第 1 段數據,經過格式化後,如下:
<!DOCTYPE html>
<html>
<head></head>
<body>
<div>App shell</div>
<!--$?-->
<template id="B:0"></template>
<!--/$-->
</body>
</html>
其中 template
標籤的用途是爲後續傳輸的 Suspense
的 children 渲染結果佔位,註釋<!--$?-->
和<!--/$-->
中間的內容,表示是異步渲染出來的。
傳輸的第 2 段數據,經過格式化後,如下:
<div hidden id="S:0">
<div> This is content </div>
</div>
<script>
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")
</script>
id="S:0"
的 div
正是 Suspense
的 children 的渲染結果,但是這個 div
設置了 hidden 屬性。接下來的 $RC
函數,會負責將這個 div
插入到第 1 段數據中 template
標籤所在的位置,同時刪除 template
標籤。
總結一下,React Streaming SSR,會先傳輸所有 <Suspense>
以上層級的可以同步渲染得到的 html 結構,當 <Suspense>
內的組件渲染完成後,會把這部分組件對應的渲染結果,連同一個 JS 函數再傳輸到瀏覽器端,這個 JS 函數會更新 dom,得到最終的完整 HTML 結構。
當第 2 次訪問頁面時,html 結構會一次性返回,而不會分成 2 次傳輸。這時候 <Suspense>
組件爲什麼沒有將傳輸的數據分段呢?這是因爲第 1 次請求時,Content
組件對應的 JS 模塊在服務器端已經被加載到模塊緩存中,再次請求時,加載 Content
組件是一個同步過程,所以整個渲染過程是同步的,不存在分段傳輸渲染結果的情況。由此可見,只有當 <Suspense>
的 children,需要被異步渲染時,SSR 返回的 HTML 纔會被分段傳輸。
除了動態加載 JS 模塊(code splitting)會產生分段傳輸數據的效果外,組件內獲取異步數據則是更加常見的適用 Streaming SSR 的場景。
我們將 Content
組件做改造,通過調用異步函數 getData
獲取數據:
let data;
const getData = () => {
if (!data) {
data = new Promise((resolve) => {
// 延遲 2s 返回數據
setTimeout(() => {
data = "content from remote";
resolve();
}, 2000);
});
throw data;
}
// promise-like
if (data && data.then) {
throw data;
}
const result = data;
data = undefined;
return result;
};
export default function Content() {
// 獲取異步數據
const data = getData();
return <div>{data}</div>;
}
這樣,Content
的內容會延遲 2s,待獲取到 data
數據後傳輸到瀏覽器顯示。示例代碼:react-streaming-ssr-demo-wivuxi[3](codesandbox 最近升級了,在 html 的 head 裏注入了會阻塞 DOM 渲染的 JS,導致 Streaming SSR 效果可能失效,可以把代碼複製到本地測試)。
注意:在數據未準備好前,
getData
必須 throw 一個 promise,promise 會被Suspense
組件捕獲,這樣才能保證 Streaming SSR 的順利執行。
Selective Hydration
React 18 之前,SSR 實際上是不支持 code splitting 的,只能使用一些 workaround,常見的方式有:1. 對於需要 code splitting 的組件,不在服務端渲染,而是在瀏覽器端渲染;2. 提前將 code splitting 的 JS 寫到 html script 標籤中,在客戶端等待所有的 JS 加載完成後再執行 hydration。
這一點 React Team 的 Dan 在 Suspense 的 RFC[4] 中也有提及:
To the best of our knowledge, even popular workarounds forced you to choose between either opting out of SSR for code-split components or hydrating them after all their code loads, somewhat defeating the purpose of code splitting.
當前 Modern.js 對於這種情況的處理,採用的是第 2 種方式。Modern.js 利用 @loadable/component[5] 在 SSR 階段,收集做了 code splitting 的組件的 JS bundle,然後把這些 JS bundle 添加到 html script 標籤中,@loadable/component 提供了一個 API loadableReady
,在等待 JS bundle 加載完成後,才執行 hydration 。示意代碼如下:
loadableReady(function(){
hydrateRoot(root, <App/>)
})
如果在沒有等待所有的 JS bundle 都加載完成,就開始 hydration,會出現什麼問題呢?
考慮下面的例子,Content
組件做了 code splitting,如果在瀏覽端,在 Content
組件的 JS bundle 還未加載完成時,就開始 hydration,hydration 得到的 HTML 結構將缺少 Content
組件的內容,而服務端 SSR 返回的結構則是包含 Content
組件的,導致如下報錯:
Hydration failed because the initial UI does not match what was rendered on the server.
import loadable from '@loadable/component'
const Content = loadable(() => import("./Content"));
export default function App() {
return (
<html>
<head></head>
<body>
<div>App shell</div>
<Content />
</body>
</html>
);
}
把上面的代碼,用 React 18 的 lazy 和 Suspense 改寫,就可以支持 Selective Hydration,使得 SSR 真正支持 code splitting:
import {lazy, Suspense} from 'react'
const Content = lazy(() => import("./Content"));
export default function App() {
return (
<html>
<head></head>
<body>
<div>App shell</div>
<Suspense>
<Content />
</Suspense>
</body>
</html>
);
}
如果 Content
組件的 JS bundle 還沒有加載完成,在 hydration 階段,渲染到 Suspense 節點時會跳出,而不會讓整個 hydration 過程失敗。
Selective Hydration 還有另外一種使用場景:同步導入 Content
組件(不做 code splitting),但是需要注意 Content
組件內仍然有異步的讀取數據操作(見上文代碼),另外增加一個 SideBar 組件,用於驗證事件綁定,代碼如下:
import {lazy, Suspense, useState} from 'react'
// 同步導入 Content 組件
import Content from './Content';
const Sidebar = () => {
const [color, setColor] = useState('black');
return (
<div class>
<div style={{ color }}>Siderbar</div>
<button
onClick={() => {
setColor(color === 'black' ? 'red' : 'black');
}}
>
change
</button>
</div>
);
};
export default function App() {
return (
<html>
<head></head>
<body>
<div>App shell</div>
<Sidebar />
<Suspense>
<Content />
</Suspense>
</body>
</html>
);
}
訪問頁面時,在渲染出 Content
組件前,Siderbar
就已經可以交互了(點擊 change 按鈕,文字顏色會改變)。說明,雖然所有組件使用一個 JS bundle 做 hydration,但是如果 Suspense 內的組件沒有完成渲染,並不會影響其他已經渲染出的組件做 hydration。
總結一下,React 18 的 hydration 階段,當渲染到 Suspense 組件時,會根據 Suspense 的 children 是否已經渲染完成,而選擇是否繼續向子組件執行 hydration。未渲染完成的組件待渲染完成後,會恢復執行 hydration。 Suspense 的 children 異步渲染的兩種場景:1. children 組件做了 code splitting;2. children 組件中有異步操作。
降級邏輯
Streaming SSR 過程中,如果某個 Suspense 的 children 渲染過程拋出異常,那麼這個 children 組件將降級到 CSR,即在瀏覽器端重新嘗試渲染。
例如,我們對前面使用的 Content
組件做改造,刻意在服務端 SSR 階段拋出異常:
export default function Content() {
const _data = getData();
// 製造異常
if(typeof window === 'undefined'){
data = undefined
throw Error('SSR Error')
}
return (
<div>
{_data}
</div>
);
}
訪問頁面時,Response 返回的第二段數據,格式化後如下所示:
<script>
function $RX(b, c, d, e) {
var a = document.getElementById(b);
b = a.previousSibling;
b.data = "$!";
a = a.dataset;
c && (a.dgst = c);
d && (a.msg = d);
e && (a.stck = e);
b._reactRetry && b._reactRetry()
};
$RX("B:0", "", "SSR Error", "\n at Content\n at Lazy\n at Content\n at Lazy\n at Suspense\n at body\n at html\n at App\n at DataProvider (/Users/bytedance/work/examples/stream-ssr-demo/src/data.js:18:23)")
</script>
第二段數據中返回了 $RX
函數,而不是渲染正確情況下的 $RC
函數。$RX
會將渲染出錯的 Suspense 在 HTML 中對應的 Comment 標籤<!--$?-->
修改爲<!--$!-->
,表示這個 Suspense 的 children 需要在瀏覽器端執行降級渲染。當執行 $RX
時,如果父組件已經完成 hydration,會調用 Comment 節點上的 _reactRetry
方法,立即執行對需要降級的組件的渲染;否則等待父組件執行時 hydration,再 “順道” 執行渲染。
當 Suspense 的 children SSR 階段渲染失敗時,可以在 renderToPipeableStream
的 onError
回調中執行專門的邏輯處理,例如下面的例子中,會打印出錯誤日誌,並將響應的狀態碼設置爲 500。
如果還沒有渲染到任一 Suspense
組件時,就發生了錯誤,這意味着應用對應的整棵組件樹都沒有渲染成功,SSR 完全失敗,這個時候 onShellReady
不會被調用,onShellError
會調用,我們可以在 onShellError
中返回 CSR 使用的 HTML 模版,讓整個應用完全降級到 CSR 。
let didError = false;
const stream = renderToPipeableStream(
<App assets={assets} />,
{
onShellReady() {
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader("Content-type", "text/html");
stream.pipe(res);
},
onError(x) {
didError = true;
console.error(x);
},
onShellError(x) {
didError = true;
res.send(<html>...</html>)//返回 CSR 使用的 HTML 模版,整棵組件樹降級到 CSR
}
}
);
JS 和 CSS 設置
當前,我們還沒有介紹如何在 Streaming SSR 中設置 JS 和 CSS 文件。有三種方式:
- 在 HTML 組件中設置
示例如下:
function Html({ assets, children, title }) {
return (
<html>
<head>
<title>{title}</title>
<link rel="stylesheet" href={assets["main.css"]} />
<script src={assets["main.js"]}></script>
</head>
<body>
<noscript
dangerouslySetInnerHTML={{
__html: `<b>Enable JavaScript to run this app.</b>`
}}
/>
{children}
<script
dangerouslySetInnerHTML={{
__html: `assetManifest = ${JSON.stringify(assets)};`
}}
/>
</body>
</html>
);
}
function App({assets}) {
return (
<Html assets={assets} title="Hello">
{/* other components */}
</Html>
);
}
hydrateRoot(document, <App assets={window.assetManifest} />);
我們將 html
、head
、body
等這些標籤也通過 React 組件表示,這樣對 JS 和 CSS 的設置,也可以在 JSX 中完成。示例中,通過 assets
屬性,設置 HTML
組件需要引人的 JS 和 CSS 文件。 SSR 階段時,assets
一般是通過讀取 webpack 等構建工具的構建產物結果得到的, assets
還會寫入到一個 script 的 assetManifest
變量上, 這樣在 hydration 階段, App
組件可以通過 window.assetManifest
獲取到 assets
信息。
2. 在返回第一段數據時添加
這種方式下,html
、head
、body
等這些最外層標籤,通過 HTML 模版注入到 Streaming SSR 返回的第一段數據中。
示例如下:
import { Transform } from 'stream';
// 代表傳輸的第一段數據
let isShellStream = true;
const injectTemplateTransform = new Transform({
transform(chunk, _encoding, callback) {
if (isShellStream) {
// headTpl 代表 <html><head>...</head><body><div id='root'> 部分的模版
// tailTpl 代表 </div></body></html> 部分的模版
this.push(`${headTpl}${chunk.toString()}${tailTpl}`));
isShellStream = false;
} else {
this.push(chunk);
}
callback();
},
});
const stream = renderToPipeableStream(
<App />,
{
onShellReady() {
res.setHeader('Content-type', 'text/html');
stream.pipe(injectTemplateTransform).pipe(res);
},
}
);
在構建階段,將 HTML 所需的 JS 和 CSS 文件,構建到 html 模版中。然後通過創建一個 Transform 流,在傳輸第一段數據時,將 headTpl
、tailTpl
的 html 模版數據添加到第一段數據的兩端。
- 通過參數
bootstrapScripts
設置
通過 renderToPipeableStream
的第二個參數,設置 bootstrapScripts
的值,bootstrapScripts
的值爲 HTML 所需的 JS 文件路徑。
注意,這種方式不支持設置 CSS 文件。
示例如下:
const stream = renderToPipeableStream(
<App />,
{
bootstrapScripts: ["main.js"],
onShellReady() {
res.setHeader('Content-type', 'text/html');
stream.pipe(res);
},
}
);
源碼解析
數據結構
Streaming SSR 的實現,主要涉及 Segment、Boundary、Task 和 Request 4 種數據結構。
Segment
代表 Streaming SSR 分段傳輸過程中的每段數據。
簡化後的 Segment 類型及字段說明如下:
type Segment = {
// segment 狀態。依次代表 pending、completed、flushed、aborted、errored
status: 0 | 1 | 2 | 3 | 4,
// 真正要傳輸到瀏覽器端的數據
chunks: Array<string | Uint8Array>,
// 子級 Segment,當遇到 Suspense Boundary 時會創建新的 Segment,
// 作爲當前 Segment 的子級 Segment
children: Array<Segment>,
// 在父級 Segment 的 chunks 中的位置索引,如果沒有父級 Segment, 則爲 0
index: number,
// 如果這個 Segment 代表 Suspense 組件的 fallback,
// boundary 代表 Suspense 組件內部真正內容對應的 Boundary
boundary: null | SuspenseBoundary,
};
- status
Segment 新建時,狀態爲 pending;當 Segment 已經獲取到需要傳輸的數據時,狀態爲
completed;當 Segment 的數據已經寫入到 HTTP Response 對象時,狀態爲 flushed。
- children
當 React 解析到 Suspense
組件時,會創建新的 Segment,存儲到當前 Segment 的 children 中。
例如以下 App
組件:
import { lazy } from 'react'
const Content = lazy(() => import('./Content' ));
function App = (props) => {
return (
<div>
<div>App</div>
<Suspense fallback={<Spinner />}>
<Content />
</Suspense>
</div>
)
}
React 會創建 3 個 Segment:
Segment 1 對應的 DOM 結構爲:
<div>
<div>App<div/>
</div>
Segement 1 對應所有
Suspense
組件之上的內容,可以稱爲 Root Segment
Segment 2 對應 Spinner
組件渲染出的內容。同時 Segment 2 會存儲到 Segment 1 的 children
屬性中。
Segment 3 對應 Suspense
組件的 children 渲染出的內容。注意,因爲被 Suspense
組件分割,Segment 3 的內容和 Segment 1 、Segment 2 的內容,在 HTTP 傳輸過程中,是分成 2 段傳輸的(也有可能是在 1 段中傳輸,後面會介紹),所以 Segment 3 並不會保存到 Segment 1 的 children
中。
- index
繼續考慮上面的例子,Segment 1 chunks
保存的數組元素,我們做一下簡化,用以下 3 個元素示意:
Segment 2 chunks
中的數據,需要插入到 Segment 1 chunks
數組中的第 1 個元素之後的位置,才能保證傳輸的 dom 結構順序是正確的,所以這個例子中 index
等於 2 。
Boundary
SSR 邏輯分段的 “分界線”,每個 Suspense
組件對應 1 個 Suspense Boundary。
例如以下 App
組件有 2 個 Suspense
組件,會創建 2 個 Boundary,這 2 個 Boundary 實際上將整個組件的解析過程分成了 3 部分,Boundary 1 以上的部分,我們也可以視做一個 Boundary,稱爲 Root Boundary。
import { lazy } from 'react'
const Content = lazy(() => import('./Content' ));
const Comments = lazy(() => import('./Comments' ));
function App = (props) => {
return (
<div>
<div>App<div/>
{/* Boundary 1 */}
<Suspense fallback={<Spinner />}>
<Content />
{/* Boundary 2 */}
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</Suspense>
</div>
)
}
簡化後的 Boundary ( React 代碼中命名爲 SuspenseBoundary
)類型及字段說明如下:
type SuspenseBoundary = {
// 當前 boundary 範圍內的 pending 狀態的 task 數量
pendingTasks: number,
// 當前 boundary 範圍內的已完成渲染的 Segment
completedSegments: Array<Segment>,
};
Task
1 個 Task 代表一個將組件樹渲染成 DOM 結構的任務。一般情況下,一個應用對應一棵組件樹,似乎一個應用只需要 1 個 Task 即可。但是,因爲 Suspense
將組件樹分成了多個子組件樹,子組件樹可以是異步處理的,所以實際上會需要多個 Task。
簡化後的 Task 類型及字段說明如下:
type Task = {
// Task 對應的組件樹
node: ReactNodeList,
// Task 對應的 Boundary
blockedBoundary: null | SuspenseBoundary,
// Task 對應的 Segment
blockedSegment: Segment,
// 後面介紹
ping: () => void,
}
blockedBoundary
的值可以爲 null
或 SuspenseBoundary
。 null
表示 task 代表所有 Suspense
組件之上的組件樹的渲染任務,即 root task;SuspenseBoundary
表示 task 代表某個 Suspense
組件內的組件樹的異步渲染任務。
通過如下示例進一步說明:
import { lazy } from 'react'
const Content = lazy(() => import('./Content' ));
function App = (props) => {
return (
<div>
<div>App</div>
<Suspense fallback={<Spinner />}>
<Content />
</Suspense>
</div>
)
}
在 SSR 渲染開始時,會創建一個 Task,代表 App
作爲根節點的組件樹的渲染任務。這個 Task 的 Boundary 爲 Root Boundary,所以爲 null。
如果是第一次請求,因爲 Content
組件做了 code splitting,所以 Content
組件代碼的加載是異步的。這時會再創建 2 個 Task,一個爲代表包裹 Content
組件的 React.lazy
爲根節點的組件樹的渲染任務;另一個爲代表 Spinner
作爲根節點的組件樹的渲染任務。
這種情況,SSR 渲染結果會分成 2 次傳輸。
如果不是第一次請求,這是 Content
模塊已經被加載到緩存中,再次加載不存在異步問題。此時,整個組件樹的渲染是一個同步過程,也不需要使用 fallback 組件 Spinner
,所以只需要一個 Task 即可,即 App
作爲根節點的 Task。
這種情況,SSR 渲染結果只需要 1 次傳輸。
Request
Request 是 SSR 邏輯中的最頂層對象。每 1 個 SSR 請求,會生成一個 Request 對象,存儲這次 SSR 過程所需要的 Task、Boundary、Segement 等相關信息,以及 SSR 過程中不同時機的回調函數(onShellReady
,onAllReady
,onShellError
,onError
)。
簡化後的 Request 類型及字段說明如下:
type Request = {
// 請求結果的輸出流,即 Response 對象
destination: null | Destination,
// 所有未完成的 Task 數量,當等於 0 時,表示本次 SSR 完成,可以關閉 HTTP 連接
allPendingTasks: number,
// Root Boundary 範圍內的未完成的 Task 數量,當等於 0 時,Root Boundary 渲染完成
pendingRootTasks: number,
// 等待執行的 Task
pingedTasks: Array<Task>,
// 已完成的 Root Segment
completedRootSegment: null | Segment,
// 已完成的 Boundary
completedBoundaries: Array<SuspenseBoundary>,
// Root Boundary 渲染完成後的回調
onShellReady: () => void,
// Root Boundary 渲染過程中,出錯的回調
onShellError: (error: mixed) => void,
// 所有 Boundary 都渲染完成,即 SSR 完成的回調
onAllReady: () => void,
// Root Boundary 渲染完成後,在後續 Suspense Boundary 渲染過程中出錯的回調
onError: (error: mixed) => ?string,
};
主要流程
renderToPipeableStream
涉及的關鍵函數調用過程如下圖所示:
renderToPipeableStream
的關鍵代碼如下:
function renderToPipeableStream(
children: ReactNodeList,
options?: Options,
): PipeableStream {
// 創建請求對象 Request
const request = createRequest(children, options);
// 啓動組件樹的渲染任務
startWork(request);
return {
pipe<T: Writable>(destination: T): T {
// 開始將渲染結果寫入輸出流
startFlowing(request, destination);
return destination;
},
abort(reason: mixed) {
abort(request, reason);
},
};
}
爲了便於理解主幹流程,本節列出的 React 源碼,做了大量刪減和微調,並非完整源碼。
完整源碼請參考:ReactDOMFizzServerNode.js[6] 、ReactFizzServer.js[7]、 ReactServerStreamConfigNode.js[8] 等文件。
分析上面的代碼調用過程,我們把 SSR 過程分爲三個階段:
1. 創建請求對象
創建請求對象即創建 Request 數據結構,對應 createRequest
,主要邏輯爲:
-
根據入參
options
,創建 request 對象,設置onShellReady
、onAllReady
等回調函數。 -
創建 root segment,關聯的 boundary 爲 root boundary,即 null。
-
根據入參
children
和 root segment,創建 root task。 -
將 root task 保存到 request 的
pingedTasks
中,root task 將作爲後續渲染操作的起點。
createRequest
簡化後的代碼如下:
export function createRequest(
children: ReactNodeList,
options?: Options,
): Request {
const pingedTasks = [];
const request = {
// 初始化 request
};
// This segment represents the root fallback.
const rootSegment = {
status: PENDING,
index: 0,
chunks: [],
children: [],
};
const rootTask = createTask(
request,
children,
null,
rootSegment
);
pingedTasks.push(rootTask);
return request;
}
Root task 由 createTask
創建,創建 task 時,需要設置 task 關聯的待渲染的組件樹(node
)、 Boundary(blockedBoundary
) 和 Segement (blockedSegment
),同時還需要修改 request
和 blockedBoundary
關聯的待完成的 task 數量。
createTask
簡化後的代碼及註釋如下:
function createTask(
request: Request,
node: ReactNodeList,
blockedBoundary: Root | SuspenseBoundary,
blockedSegment: Segment,
): Task {
// allPendingTasks 自增 1
request.allPendingTasks++;
// 如果是 root boundary, pendingRootTasks 自增1;
// 否則把對應 boundary 範圍裏的 pendingTask 自增1
if (blockedBoundary === null) {
request.pendingRootTasks++;
} else {
blockedBoundary.pendingTasks++;
}
// 創建 task,ping 的作用後續介紹
const task: Task = ({
node,
ping: () => pingTask(request, task),
blockedBoundary,
blockedSegment,
}: any);
return task;
}
2. 啓動渲染流程
創建好 root task 後,就可以以 root task 作爲起點,啓動組件的渲染流程了,對應 startWork
。
主要邏輯可以從 startWork
內部調用 performWork
開始看:
export function performWork(request: Request): void {
const pingedTasks = request.pingedTasks;
let i;
for (i = 0; i < pingedTasks.length; i++) {
const task = pingedTasks[i];
retryTask(request, task);
}
pingedTasks.splice(0, i);
if (request.destination !== null) {
flushCompletedQueues(request, request.destination);
}
}
performWork
遍歷 request
的 pingedTasks
,對每一個 task 執行 retryTask
。retryTask
主要邏輯如下:
-
通過調用
renderNodeDestructive
,對 task 包含的 React node 節點執行渲染邏輯。 -
如果
renderNodeDestructive
執行過程中沒有拋出異常:
a. 表示 task 關聯的渲染任務完成,將 task 關聯的 segment 狀態設置爲完成狀態。
b. 調用finishedTask
,對request
上的 segment 信息做更新:如果是 root boundary 的 task,將當前 task 關聯的 segment 賦值給request
的completedRootSegment
;如果是 suspense boundary,將當前 task 關聯的 segment 添加到關聯 boundary 的completedSegments
。注意,onShellReady
回調也是在這個函數中執行的,當 root boundary 上的 task 都已經執行完成(request.pendingRootTasks === 0
),就會調用onShellReady
。 -
如果
renderNodeDestructive
執行過程中拋出異常(主要針對 throw promise 場景):
a. 捕獲異常,如果是 promise-like 對象,在 promise resolve 後,把當前 task 重新放到request
的pingedTask
中,等待重新執行(調用performWork
)。
retryTask
主要代碼如下:
function retryTask(request: Request, task: Task): void {
const segment = task.blockedSegment;
try {
renderNodeDestructive(request, task, task.node);
segment.status = COMPLETED;
finishedTask(request, task.blockedBoundary, segment);
} catch (x) {
resetHooksState();
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
// Something suspended again, let's pick it back up later.
const ping = task.ping;
x.then(ping, ping);
}
}
}
3.a 步驟中,需要依賴 12 行的 task.ping
把 task 重新放回 request
的 pingedTasks
。 task.ping
對應函數:() => pingTask(request, task)
,pingTask
實現如下:
function pingTask(request: Request, task: Task): void {
const pingedTasks = request.pingedTasks;
pingedTasks.push(task);
scheduleWork(() => performWork(request));
}
renderNodeDestructive
對 task 的 node
屬性代表的組件樹,做深度優先遍歷,一邊將組件渲染爲 dom 節點,一邊將 dom 節點的信息存儲到 task 的 blockedSegment
屬性中。
Streaming SSR 實現的一個關鍵,是對 Suspense
組件的渲染邏輯。當 renderNodeDestructive
遍歷到 Suspense
組件時,會調用 renderSuspenseBoundary
執行渲染邏輯。
renderSuspenseBoundary
的主要邏輯爲:
-
針對解析到的
Suspense
組件,創建一個新的 Boundary:newBoundary
-
新建一個 segment:
boundarySegment
,boundarySegment
用於保存Suspense
的 fallback 代表的內容,所以boundarySegment
的boundary
屬性值爲newBoundary
。同時,boundarySegment
也會保存到當前 task 的blockedSegment
的children
屬性中(可參考介紹 Segment 數據結構的例子)。 -
新建一個 segment:
contentRootSegment
,保存Suspense
組件的children
代表的內容。 -
渲染
Suspense
組件的children
-
如果渲染成功,說明
Suspense
組件的children
沒有需要異步等待的內容(渲染是同步完成的):a. 設置
contentRootSegment
的狀態爲 COMPLETED
b. 把contentRootSegment
存入newBoundary
的completedSegments
屬性中 -
如果渲染過程 throw promise,說明
Suspense
的children
有需要異步等待的內容:a. 新建一個 task,task 的
blockedBoundary
等於newBoundary
b. 當 promise resolve 後,將 task 保存到request
的pingedTasks
中(通過 task 的ping
屬性),等待下一個事件循環處理。
c. 再新建一個 task,代表Suspense
的 fallback 組件樹的渲染任務, task 的blockedSegment
等於boundarySegment
,task 的blockedBoundary
等於調用renderSuspenseBoundary
時的task.blockedBoundary
(不是newBoundary
,是newBoundary
上一層級的 boundary)
d. 把 task 保存到request
的pingedTasks
中,等待在performWork
中處理
這段邏輯比較複雜,簡單理解的話,在渲染過程中,每當遇到 Suspense
組件,就會創建一個新的 Boundary,但新 Boundary 並不意味着一定要創建一個新的 Task,因爲 Suspense
組件內元素的渲染不一定需要異步完成,只有存在 動態導入組件(React.lazy)或獲取異步數據等情況,纔會創建一個新的 Task,用以表示這個異步的渲染過程。
上面的過程還有 2 個注意點:
-
步驟 6.a 中,新建的 task 不會立即放入
request
的pingedTasks
中,而是要等待代表異步任務的 promise resolve 後,才放入pingedTasks
。所以pingedTasks
,實際上保存的是「沒有異步任務依賴」的 task,是可以同步完成組件渲染工作的 task。 -
步驟 5 中, 沒有 6.c 和 6.d 兩步, 因爲如果
Suspense
的children
沒有需要異步等待的內容,就不需要展示 fallback 內容,自然也不需要新建一個 task 負責 fallback 組件樹的渲染任務 。
3. 啓動輸出流
renderToPipeableStream
返回 pipe
和 abort
2 個方法,分別用於向輸出流寫入組件樹的渲染結果,和終止本次 SSR 請求。這裏我們主要分析向輸出流寫入組件樹的渲染結果。pipe
內部調用startFlowing
,startFlowing
調用 flushCompletedQueues
,flushCompletedQueues
顧名思義,會將已完成的組件樹的渲染信息,寫入到輸出流(Response)。
flushCompletedQueues
主要邏輯爲:
-
檢測 root boundary 範圍的 tasks 是否已經渲染完成,如果是,則將對應的 segments 寫入輸出流;如果否,則返回(因爲需要保證寫入輸出流的第一段數據,一定是 root boundary 範圍內的組件的渲染結果)
-
檢查 suspense boundaries ,如果 suspense boundary 滿足條件:關聯的所有 task 都已經完成, 則將 suspense boundary 的 segment 寫入輸出流,suspense boundary 的完整內容在瀏覽器頁面處於可見狀態(不再顯示 suspense 的 fallback 內容)。
-
繼續檢查 suspense boundaries,如果 suspense boundary 滿足條件:存在完成的 task,但不是所有 task 都完成,則將這些完成的 task 的 segment 寫入輸出流,但 suspense boundary 的完整內容在瀏覽器頁面仍然處於隱藏狀態(包裹內容的 div 此時還是 hidden 狀態)。
-
如果所有 suspense boundaries 的關聯的 task 都已經完成,說明本次 SSR 完成, 調用
close
結束請求。
flushCompletedQueues
簡化後的代碼如下:
function flushCompletedQueues(
request: Request,
destination: Destination,
): void {
// 1.開始:root boundary 寫入到輸出流
beginWriting(destination);
let i;
const completedRootSegment = request.completedRootSegment;
if (completedRootSegment !== null) {
// 將 root boundary 範圍內的組件渲染結果寫入輸出流
if (request.pendingRootTasks === 0) {
flushSegment(request, destination, completedRootSegment);
request.completedRootSegment = null;
writeCompletedRoot(destination, request.responseState);
} else {
// root boundary 範圍內,還存在沒有完成的 task,直接返回。
// 不需要繼續向下看 suspense boundary 是否完成
return;
}
}
// 1.完成:root boundary 寫入到輸出流
completeWriting(destination);
// 2.開始:suspense boundary(關聯的 task 已全部完成)寫入到輸出流
beginWriting(destination);
const completedBoundaries = request.completedBoundaries;
for (i = 0; i < completedBoundaries.length; i++) {
const boundary = completedBoundaries[i];
if (!flushCompletedBoundary(request, destination, boundary)) {
request.destination = null;
i++;
completedBoundaries.splice(0, i);
return;
}
}
completedBoundaries.splice(0, i);
// 2.完成:suspense boundary(關聯的 task 已全部完成)寫入到輸出流
completeWriting(destination);
// 3.開始:suspense boundary(關聯的 task 部分完成)寫入到輸出流
beginWriting(destination);
const partialBoundaries = request.partialBoundaries;
for (i = 0; i < partialBoundaries.length; i++) {
const boundary = partialBoundaries[i];
if (!flushPartialBoundary(request, destination, boundary)) {
request.destination = null;
i++;
partialBoundaries.splice(0, i);
return;
}
}
partialBoundaries.splice(0, i);
// 3.完成:suspense boundary(關聯的 task 部分完成)寫入到輸出流
completeWriting(destination);
if (
request.allPendingTasks === 0 &&
request.pingedTasks.length === 0 &&
request.clientRenderedBoundaries.length === 0 &&
request.completedBoundaries.length === 0
) {
// 所有渲染任務都已完成,關閉輸出流
close(destination);
}
}
上面的代碼中,一共有 3 組 beginWriting
/completeWriting
,分別代表了flushCompletedQueues
的前 3 步驟。
至此,我們就完成了 Streaming SSR 主要源碼實現的分析。
參考資料
[1]
renderToPipeableStream: https://reactjs.org/docs/react-dom-server.html#rendertopipeablestream
[2]
simple-stream-demo-hb1qb1: https://codesandbox.io/s/simple-stream-demo-hb1qb1
[3]
react-streaming-ssr-demo-wivuxi: https://codesandbox.io/s/react-streaming-ssr-demo-wivuxi
[4]
RFC: https://github.com/reactwg/react-18/discussions/37
[5]
@loadable/component: https://loadable-components.com/
[6]
ReactDOMFizzServerNode.js: https://github.com/facebook/react/blob/main/packages/react-dom/src/server/ReactDOMFizzServerNode.js
[7]
ReactFizzServer.js: https://github.com/facebook/react/blob/main/packages/react-server/src/ReactFizzServer.js
[8]
ReactServerStreamConfigNode.js: https://github.com/facebook/react/blob/main/packages/react-server/src/ReactServerStreamConfigNode.js
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/GVts2QW3H_aTrB9anGwl5g