React?Streaming?SSR原理示例深入解析
功能簡(jiǎn)介
React 18 提供了一種新的 SSR 渲染模式: Streaming SSR。通過 Streaming SSR,我們可以實(shí)現(xiàn)以下兩個(gè)功能:
- Streaming HTML:服務(wù)端可以分段傳輸 HTML 到瀏覽器,而不是像 React 18 以前一樣,需要等待服務(wù)端渲染完成整個(gè)頁面后才返回給瀏覽器。這樣,瀏覽器可以更快的啟動(dòng) HTML 的渲染,提高 FP、FCP 等性能指標(biāo)。
- Selective Hydration:在瀏覽器端 hydration 階段,可以只對(duì)已經(jīng)完成渲染的區(qū)域做 hydration,而不需要等待整個(gè)頁面渲染完成、所有組件的 JS bundle 加載完成,才能開始 hydration。這樣可以更早的對(duì)已經(jīng)完成渲染的區(qū)域做事件綁定,從而讓頁面獲得更好的可交互性。
基本原理
使用示例
React 官網(wǎng)給出的一個(gè)簡(jiǎn)單的使用示例(以 Node.js 環(huán)境下的 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 環(huán)境下實(shí)現(xiàn) Streaming SSR 的 API。
Streaming HTML
HTTP 支持以 stream 格式進(jìn)行數(shù)據(jù)傳輸。當(dāng) HTTP 的 Response header 設(shè)置 Transfer-Encoding: chunked 時(shí),服務(wù)器端就可以將 Response 分段返回。一個(gè)簡(jiǎn)單示例:
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>"); // 手動(dòng)設(shè)置延時(shí),讓分段顯示的效果更加明顯 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);
當(dāng)訪問 localhost:8080 時(shí),「First segment」 和 「Second segment」會(huì)分 2 次傳輸?shù)綖g覽器端,「First segment」先顯示到頁面上,2s 延遲后,「Second segment」再顯示到頁面上。
React 中的 Streaming HTML 要更加復(fù)雜。例如,對(duì)下面的 App 組件做 SSR:
//文件1: Content.js export default function Content() { return ( <div> This is content </div> ); } // 文件2:App.js 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 次訪問頁面時(shí),SSR 渲染的結(jié)果會(huì)分成 2 段傳輸,傳輸?shù)牡?1 段數(shù)據(jù),經(jīng)過格式化后,如下:
<!DOCTYPE html> <html> <head></head> <body> <div>App shell</div> <!--$?--> <template id="B:0"></template> <!--/$--> </body> </html>
其中 template 標(biāo)簽的用途是為后續(xù)傳輸?shù)?nbsp;Suspense 的 children 渲染結(jié)果占位,注釋 和 中間的內(nèi)容,表示是異步渲染出來的。
傳輸?shù)牡?2 段數(shù)據(jù),經(jīng)過格式化后,如下:
<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 的渲染結(jié)果,但是這個(gè) div 設(shè)置了 hidden 屬性。接下來的 $RC 函數(shù),會(huì)負(fù)責(zé)將這個(gè) div 插入到第 1 段數(shù)據(jù)中 template 標(biāo)簽所在的位置,同時(shí)刪除 template 標(biāo)簽。
總結(jié)一下
React Streaming SSR ,會(huì)先傳輸所有 以上層級(jí)的可以同步渲染得到的 html 結(jié)構(gòu),當(dāng) 內(nèi)的組件渲染完成后,會(huì)把這部分組件對(duì)應(yīng)的渲染結(jié)果,連同一個(gè) JS 函數(shù)再傳輸?shù)綖g覽器端,這個(gè) JS 函數(shù)會(huì)更新 dom ,得到最終的完整 HTML 結(jié)構(gòu)。
當(dāng)?shù)?2 次訪問頁面時(shí),html 結(jié)構(gòu)會(huì)一次性返回,而不會(huì)分成 2 次傳輸。這時(shí)候 組件為什么沒有將傳輸?shù)臄?shù)據(jù)分段呢?這是因?yàn)榈?1 次請(qǐng)求時(shí), Content 組件對(duì)應(yīng)的 JS 模塊在服務(wù)器端已經(jīng)被加載到模塊緩存中,再次請(qǐng)求時(shí),加載 Content組件是一個(gè)同步過程,所以整個(gè)渲染過程是同步的,不存在分段傳輸渲染結(jié)果的情況。由此可見,只有當(dāng) 的 children,需要被異步渲染時(shí),SSR 返回的 HTML 才會(huì)被分段傳輸。
除了動(dòng)態(tài)加載 JS 模塊(code splitting)會(huì)產(chǎn)生分段傳輸數(shù)據(jù)的效果外,組件內(nèi)獲取異步數(shù)據(jù)則是更加常見的適用 Streaming SSR 的場(chǎng)景。
我們將 Content 組件做改造,通過調(diào)用異步函數(shù) getData 獲取數(shù)據(jù):
let data; const getData = () => { if (!data) { data = new Promise((resolve) => { // 延遲 2s 返回?cái)?shù)據(jù) 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() { // 獲取異步數(shù)據(jù) const data = getData(); return <div>{data}</div>; }
這樣,Content 的內(nèi)容會(huì)延遲 2s,待獲取到 data 數(shù)據(jù)后傳輸?shù)綖g覽器顯示。示例代碼(codesandbox 最近升級(jí)了,在 html 的 head 里注入了會(huì)阻塞 DOM 渲染的 JS,導(dǎo)致 Streaming SSR 效果可能失效,可以把代碼復(fù)制到本地測(cè)試)。
注意:在數(shù)據(jù)未準(zhǔn)備好前,getData 必須 throw 一個(gè) promise,promise 會(huì)被 Suspense 組件捕獲,這樣才能保證 Streaming SSR 的順利執(zhí)行。
Selective Hydration
React 18 之前,SSR 實(shí)際上是不支持 code splitting 的,只能使用一些 workaround,常見的方式有:1. 對(duì)于需要 code splitting 的組件,不在服務(wù)端渲染,而是在瀏覽器端渲染;2. 提前將 code splitting 的 JS 寫到 html script 標(biāo)簽中,在客戶端等待所有的 JS 加載完成后再執(zhí)行 hydration。
這一點(diǎn) React Team 的 Dan 在 Suspense 的 RFC 中也有提及:
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.
當(dāng)前 Modern.js 對(duì)于這種情況的處理,采用的是第 2 種方式。Modern.js 利用 @loadable/component 在 SSR 階段,收集做了 code splitting 的組件的 JS bundle,然后把這些 JS bundle 添加到 html script 標(biāo)簽中,@loadable/component 提供了一個(gè) API loadableReady ,在等待 JS bundle 加載完成后,才執(zhí)行 hydration 。示意代碼如下:
loadableReady(function(){ hydrateRoot(root, <App/>) })
如果在沒有等待所有的 JS bundle 都加載完成,就開始 hydration,會(huì)出現(xiàn)什么問題呢?
考慮下面的例子,Content 組件做了 code splitting,如果在瀏覽端,在 Content 組件的 JS bundle 還未加載完成時(shí),就開始 hydration,hydration 得到的 HTML 結(jié)構(gòu)將缺少 Content 組件的內(nèi)容,而服務(wù)端 SSR 返回的結(jié)構(gòu)則是包含 Content 組件的,導(dǎo)致如下報(bào)錯(cuò):
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 節(jié)點(diǎn)時(shí)會(huì)跳出,而不會(huì)讓整個(gè) hydration 過程失敗。
Selective Hydration 還有另外一種使用場(chǎng)景:同步導(dǎo)入 Content 組件(不做 code splitting),但是需要注意 Content 組件內(nèi)仍然有異步的讀取數(shù)據(jù)操作(見上文代碼),另外增加一個(gè) SideBar 組件,用于驗(yàn)證事件綁定,代碼如下:
import {lazy, Suspense, useState} from 'react' // 同步導(dǎo)入 Content 組件 import Content from './Content'; const Sidebar = () => { const [color, setColor] = useState('black'); return ( <div className="home"> <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> ); }
訪問頁面時(shí),在渲染出 Content 組件前,Siderbar 就已經(jīng)可以交互了(點(diǎn)擊 change 按鈕,文字顏色會(huì)改變)。說明,雖然所有組件使用一個(gè) JS bundle 做 hydration,但是如果 Suspense 內(nèi)的組件沒有完成渲染,并不會(huì)影響其他已經(jīng)渲染出的組件做 hydration。示例代碼。
總結(jié)一 下,React 18 的 hydration 階段,當(dāng)渲染到 Suspense 組件時(shí),會(huì)根據(jù) Suspense 的 children 是否已經(jīng)渲染完成,而選擇是否繼續(xù)向子組件執(zhí)行 hydration。未渲染完成的組件待渲染完成后,會(huì)恢復(fù)執(zhí)行 hydration。 Suspense 的 children 異步渲染的兩種場(chǎng)景:1. children 組件做了 code splitting;2. children 組件中有異步操作。
降級(jí)邏輯
Streaming SSR 過程中,如果某個(gè) Suspense 的 children 渲染過程拋出異常,那么這個(gè) children 組件將降級(jí)到 CSR,即在瀏覽器端重新嘗試渲染。
例如,我們對(duì)前面使用的 Content 組件做改造,刻意在服務(wù)端 SSR 階段拋出異常:
export default function Content() { const _data = getData(); // 制造異常 if(typeof window === 'undefined'){ data = undefined throw Error('SSR Error') } return ( <div> {_data} </div> ); }
訪問頁面時(shí),Response 返回的第二段數(shù)據(jù),格式化后如下所示:
<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>
第二段數(shù)據(jù)中返回了 RX 函數(shù),而不是渲染正確情況下的 RX 函數(shù),而不是渲染正確情況下的 RX 函數(shù),而不是渲染正確情況下的 RC 函數(shù)。RX 會(huì)將渲染出錯(cuò)的Suspense在HTML中對(duì)應(yīng)的Comment標(biāo)簽 <!−−RX 會(huì)將渲染出錯(cuò)的 Suspense 在 HTML 中對(duì)應(yīng)的 Comment 標(biāo)簽 <!--RX 會(huì)將渲染出錯(cuò)的Suspense在HTML中對(duì)應(yīng)的Comment標(biāo)簽 <!−−?--> 修改為 ,表示這個(gè) Suspense 的 children 需要在瀏覽器端執(zhí)行降級(jí)渲染。當(dāng)執(zhí)行 $RX 時(shí),如果父組件已經(jīng)完成 hydration,會(huì)調(diào)用 Comment 節(jié)點(diǎn)上的 _reactRetry 方法,立即執(zhí)行對(duì)需要降級(jí)的組件的渲染;否則等待父組件執(zhí)行時(shí) hydration,再“順道”執(zhí)行渲染。
當(dāng) Suspense 的 children SSR 階段渲染失敗時(shí),可以在 renderToPipeableStream 的 onError 回調(diào)中執(zhí)行專門的邏輯處理,例如下面的例子中,會(huì)打印出錯(cuò)誤日志,并將響應(yīng)的狀態(tài)碼設(shè)置為 500。
如果還沒有渲染到任一 Suspense 組件時(shí),就發(fā)生了錯(cuò)誤,這意味著應(yīng)用對(duì)應(yīng)的整棵組件樹都沒有渲染成功,SSR 完全失敗,這個(gè)時(shí)候 onShellReady 不會(huì)被調(diào)用,onShellError 會(huì)調(diào)用,我們可以在 onShellError 中返回 CSR 使用的 HTML 模版,讓整個(gè)應(yīng)用完全降級(jí)到 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 模版,整棵組件樹降級(jí)到 CSR } } );
JS 和 CSS 設(shè)置
當(dāng)前,我們還沒有介紹如何在 Streaming SSR 中設(shè)置 JS 和 CSS 文件。有三種方式:
- 在 HTML 組件中設(shè)置示例如下:
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 等這些標(biāo)簽也通過 React 組件表示,這樣對(duì) JS 和 CSS 的設(shè)置,也可以在 JSX 中完成。示例中,通過 assets 屬性,設(shè)置 HTML 組件需要引人的 JS 和 CSS 文件。 SSR 階段時(shí),assets 一般是通過讀取 webpack 等構(gòu)建工具的構(gòu)建產(chǎn)物結(jié)果得到的,assets 還會(huì)寫入到一個(gè) script 的assetManifest 變量上, 這樣在 hydration 階段,App 組件可以通過 window.assetManifest 獲取到 assets 信息。
- 在返回第一段數(shù)據(jù)時(shí)添加這種方式下,html、head、body 等這些最外層標(biāo)簽,通過 HTML 模版注入到 Streaming SSR 返回的第一段數(shù)據(jù)中。 示例如下:
import { Transform } from 'stream'; // 代表傳輸?shù)牡谝欢螖?shù)據(jù) 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); }, } );
在構(gòu)建階段,將 HTML 所需的 JS 和 CSS 文件,構(gòu)建到 html 模版中。然后通過創(chuàng)建一個(gè) Transform 流,在傳輸?shù)谝欢螖?shù)據(jù)時(shí),將 headTpl 、tailTpl 的 html 模版數(shù)據(jù)添加到第一段數(shù)據(jù)的兩端。
- 通過參數(shù) bootstrapScripts 設(shè)置通過 renderToPipeableStream 的第二個(gè)參數(shù),設(shè)置 bootstrapScripts 的值,``bootstrapScripts` 的值為 HTML 所需的 JS 文件路徑。注意,這種方式不支持設(shè)置 CSS 文件。 示例如下:
const stream = renderToPipeableStream( <App />, { bootstrapScripts: ["main.js"], onShellReady() { res.setHeader('Content-type', 'text/html'); stream.pipe(res); }, } );
源碼解析
數(shù)據(jù)結(jié)構(gòu)
Streaming SSR 的實(shí)現(xiàn),主要涉及 Segment、Boundary、Task 和 Request 4種數(shù)據(jù)結(jié)構(gòu)。
Segment
代表 Streaming SSR 分段傳輸過程中的每段數(shù)據(jù)。
簡(jiǎn)化后的 Segment 類型及字段說明如下:
type Segment = { // segment 狀態(tài)。依次代表 pending、completed、flushed、aborted、errored status: 0 | 1 | 2 | 3 | 4, // 真正要傳輸?shù)綖g覽器端的數(shù)據(jù) chunks: Array<string | Uint8Array>, // 子級(jí) Segment,當(dāng)遇到 Suspense Boundary 時(shí)會(huì)創(chuàng)建新的 Segment, // 作為當(dāng)前 Segment 的子級(jí) Segment children: Array<Segment>, // 在父級(jí) Segment 的 chunks 中的位置索引,如果沒有父級(jí) Segment, 則為 0 index: number, // 如果這個(gè) Segment 代表 Suspense 組件的 fallback, // boundary 代表 Suspense 組件內(nèi)部真正內(nèi)容對(duì)應(yīng)的 Boundary boundary: null | SuspenseBoundary, };
- status
新建時(shí),狀態(tài)為 pending;當(dāng) Segment 已經(jīng)獲取到需要傳輸?shù)臄?shù)據(jù)時(shí),狀態(tài)為 completed;當(dāng) Segment 的數(shù)據(jù)已經(jīng)寫入到 HTTP Response 對(duì)象時(shí),狀態(tài)為 flushed。
- children
當(dāng) React 解析到 Suspense 組件時(shí),會(huì)創(chuàng)建新的 Segment,存儲(chǔ)到當(dāng)前 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 會(huì)創(chuàng)建 3 個(gè) Segment:
Segment 1 對(duì)應(yīng)的 DOM 結(jié)構(gòu)為:
<div> <div>App<div/> </div>
Segement 1 對(duì)應(yīng)所有 Suspense 組件之上的內(nèi)容,可以稱為 Root Segment
Segment 2 對(duì)應(yīng) Spinner 組件渲染出的內(nèi)容。同時(shí) Segment 2 會(huì)存儲(chǔ)到 Segment 1 的 children 屬性中。
Segment 3 對(duì)應(yīng) Suspense 組件的 children 渲染出的內(nèi)容。注意,因?yàn)楸?nbsp;Suspense 組件分割,Segment 3 的內(nèi)容和 Segment 1 、Segment 2 的內(nèi)容,在 HTTP 傳輸過程中,是分成 2 段傳輸?shù)模ㄒ灿锌赡苁窃?1 段中傳輸,后面會(huì)介紹),所以 Segment 3 并不會(huì)保存到 Segment 1 的 children中。
- index
繼續(xù)考慮上面的例子,Segment 1 chunks 保存的數(shù)組元素,我們做一下簡(jiǎn)化,用以下 3 個(gè)元素示意:
[0]: <div> [1]: <div>App</div> [2]: </div>
Segment 2 chunks 中的數(shù)據(jù),需要插入到 Segment 1 chunks 數(shù)組中的第 1 個(gè)元素之后的位置,才能保證傳輸?shù)?dom 結(jié)構(gòu)順序是正確的,所以這個(gè)例子中 index 等于 2 。
Boundary
SSR 邏輯分段的“分界線”,每個(gè) Suspense 組件對(duì)應(yīng) 1 個(gè) Suspense Boundary。
例如以下 App 組件有 2 個(gè) Suspense 組件,會(huì)創(chuàng)建 2 個(gè) Boundary,這 2 個(gè) Boundary 實(shí)際上將整個(gè)組件的解析過程分成了 3 部分,Boundary 1 以上的部分,我們也可以視做一個(gè) 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> ) }
簡(jiǎn)化后的 Boundary ( React 代碼中命名為 SuspenseBoundary)類型及字段說明如下:
type SuspenseBoundary = { // 當(dāng)前 boundary 范圍內(nèi)的 pending 狀態(tài)的 task 數(shù)量 pendingTasks: number, // 當(dāng)前 boundary 范圍內(nèi)的已完成渲染的 Segment completedSegments: Array<Segment>, };
Task
1 個(gè) Task 代表一個(gè)將組件樹渲染成 DOM 結(jié)構(gòu)的任務(wù)。一般情況下,一個(gè)應(yīng)用對(duì)應(yīng)一棵組件樹,似乎一個(gè)應(yīng)用只需要 1 個(gè) Task 即可。但是,因?yàn)?nbsp;Suspense 將組件樹分成了多個(gè)子組件樹,子組件樹可以是異步處理的,所以實(shí)際上會(huì)需要多個(gè) Task。
簡(jiǎn)化后的 Task 類型及字段說明如下:
type Task = { // Task 對(duì)應(yīng)的組件樹 node: ReactNodeList, // Task 對(duì)應(yīng)的 Boundary blockedBoundary: null | SuspenseBoundary, // Task 對(duì)應(yīng)的 Segment blockedSegment: Segment, // 后面介紹 ping: () => void, }
blockedBoundary 的值可以為 null 或 SuspenseBoundary。 null 表示 task 代表所有 Suspense 組件之上的組件樹的渲染任務(wù),即 root task;
SuspenseBoundary 表示 task 代表某個(gè) Suspense 組件內(nèi)的組件樹的異步渲染任務(wù)。
通過如下示例進(jìn)一步說明:
import { lazy } from 'react' const Content = lazy(() => import('./Content' )); function App = (props) => { return ( <div> <div>App</div> <Suspense fallback={<Spinner />}> <Content /> </Suspense> </div> ) }
在 SSR 渲染開始時(shí),會(huì)創(chuàng)建一個(gè) Task,代表 App 作為根節(jié)點(diǎn)的組件樹的渲染任務(wù)。這個(gè) Task 的 Boundary 為 Root Boundary,所以為 null。
如果是第一次請(qǐng)求,因?yàn)?nbsp;Content 組件做了 code splitting,所以 Content 組件代碼的加載是異步的。這時(shí)會(huì)再創(chuàng)建 2 個(gè) Task,一個(gè)為代表包裹 Content 組件的 React.lazy 為根節(jié)點(diǎn)的組件樹的渲染任務(wù);另一個(gè)為代表 Spinner 作為根節(jié)點(diǎn)的組件樹的渲染任務(wù)。
這種情況,SSR 渲染結(jié)果會(huì)分成 2 次傳輸。
如果不是第一次請(qǐng)求,這是 Content 模塊已經(jīng)被加載到緩存中,再次加載不存在異步問題。此時(shí),整個(gè)組件樹的渲染是一個(gè)同步過程,也不需要使用 fallback 組件 Spinner ,所以只需要一個(gè) Task 即可,即 App 作為根節(jié)點(diǎn)的 Task。
這種情況,SSR 渲染結(jié)果只需要 1 次傳輸。
Request
Request 是 SSR 邏輯中的最頂層對(duì)象。每 1 個(gè) SSR 請(qǐng)求,會(huì)生成一個(gè) Request 對(duì)象,存儲(chǔ)這次 SSR 過程所需要的 Task、Boundary、Segement 等相關(guān)信息,以及 SSR 過程中不同時(shí)機(jī)的回調(diào)函數(shù)(onShellReady ,onAllReady ,onShellError,onError )。
簡(jiǎn)化后的 Request 類型及字段說明如下:
type Request = { // 請(qǐng)求結(jié)果的輸出流,即 Response 對(duì)象 destination: null | Destination, // 所有未完成的 Task 數(shù)量,當(dāng)?shù)扔?0 時(shí),表示本次 SSR 完成,可以關(guān)閉 HTTP 連接 allPendingTasks: number, // Root Boundary 范圍內(nèi)的未完成的 Task 數(shù)量,當(dāng)?shù)扔?0 時(shí),Root Boundary 渲染完成 pendingRootTasks: number, // 等待執(zhí)行的 Task pingedTasks: Array<Task>, // 已完成的 Root Segment completedRootSegment: null | Segment, // 已完成的 Boundary completedBoundaries: Array<SuspenseBoundary>, // Root Boundary 渲染完成后的回調(diào) onShellReady: () => void, // Root Boundary 渲染過程中,出錯(cuò)的回調(diào) onShellError: (error: mixed) => void, // 所有 Boundary 都渲染完成,即 SSR 完成的回調(diào) onAllReady: () => void, // Root Boundary 渲染完成后,在后續(xù) Suspense Boundary 渲染過程中出錯(cuò)的回調(diào) onError: (error: mixed) => ?string, };
主要流程
renderToPipeableStream 涉及的關(guān)鍵函數(shù)調(diào)用過程如下圖所示:
renderToPipeableStream 的關(guān)鍵代碼如下:
function renderToPipeableStream( children: ReactNodeList, options?: Options, ): PipeableStream { // 創(chuàng)建請(qǐng)求對(duì)象 Request const request = createRequest(children, options); // 啟動(dòng)組件樹的渲染任務(wù) startWork(request); return { pipe<T: Writable>(destination: T): T { // 開始將渲染結(jié)果寫入輸出流 startFlowing(request, destination); return destination; }, abort(reason: mixed) { abort(request, reason); }, }; }
為了便于理解主干流程,本節(jié)列出的 React 源碼,做了大量刪減和微調(diào),并非完整源碼。
完整源碼請(qǐng)參考:ReactDOMFizzServerNode.js 、ReactFizzServer.js、 ReactServerStreamConfigNode.js 等文件。
分析上面的代碼調(diào)用過程,我們把 SSR 過程分為三個(gè)階段:
創(chuàng)建請(qǐng)求對(duì)象創(chuàng)建請(qǐng)求對(duì)象即創(chuàng)建 Request 數(shù)據(jù)結(jié)構(gòu),對(duì)應(yīng) createRequest ,主要邏輯為:a. 根據(jù)入?yún)?nbsp;options ,創(chuàng)建 request 對(duì)象,設(shè)置 onShellReady 、onAllReady 等回調(diào)函數(shù)b. 創(chuàng)建 root segment,關(guān)聯(lián)的 boundary 為 root boundary,即 nullc. 根據(jù)入?yún)?nbsp;children 和 root segment,創(chuàng)建 root taskd. 將 root task 保存到 request 的 pingedTasks 中,root task 將作為后續(xù)渲染操作的起點(diǎn)
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 創(chuàng)建,創(chuàng)建 task 時(shí),需要設(shè)置 task 關(guān)聯(lián)的待渲染的組件樹( node )、 Boundary( blockedBoundary ) 和 Segement ( blockedSegment ),同時(shí)還需要修改 request和 blockedBoundary 關(guān)聯(lián)的待完成的 task 數(shù)量。
createTask 簡(jiǎn)化后的代碼及注釋如下:
function createTask( request: Request, node: ReactNodeList, blockedBoundary: Root | SuspenseBoundary, blockedSegment: Segment, ): Task { // allPendingTasks 自增 1 request.allPendingTasks++; // 如果是 root boundary, pendingRootTasks 自增1; // 否則把對(duì)應(yīng) boundary 范圍里的 pendingTask 自增1 if (blockedBoundary === null) { request.pendingRootTasks++; } else { blockedBoundary.pendingTasks++; } // 創(chuàng)建 task,ping 的作用后續(xù)介紹 const task: Task = ({ node, ping: () => pingTask(request, task), blockedBoundary, blockedSegment, }: any); return task; }
2、啟動(dòng)渲染流程創(chuàng)建好 root task 后,就可以以 root task 作為起點(diǎn),啟動(dòng)組件的渲染流程了,對(duì)應(yīng) startWork 。
主要邏輯可以從 startWork 內(nèi)部調(diào)用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,對(duì)每一個(gè) task 執(zhí)行 retryTask 。retryTask 主要邏輯如下:
- 通過調(diào)用 renderNodeDestructive ,對(duì) task 包含的 React node 節(jié)點(diǎn)執(zhí)行渲染邏輯。
- 如果renderNodeDestructive 執(zhí)行過程中沒有拋出異常:a. 表示 task 關(guān)聯(lián)的渲染任務(wù)完成,將 task 關(guān)聯(lián)的 segment 狀態(tài)設(shè)置為完成狀態(tài)。b. 調(diào)用 finishedTask ,對(duì) request 上的 segment 信息做更新:如果是 root boundary 的task,將當(dāng)前 task 關(guān)聯(lián)的 segment 賦值給 request 的completedRootSegment ;如果是 suspense boundary,將當(dāng)前 task 關(guān)聯(lián)的 segment 添加到關(guān)聯(lián) boundary 的 completedSegments。注意,onShellReady 回調(diào)也是在這個(gè)函數(shù)中執(zhí)行的,當(dāng) root boundary 上的 task 都已經(jīng)執(zhí)行完成(request.pendingRootTasks === 0),就會(huì)調(diào)用onShellReady 。
- 如果renderNodeDestructive 執(zhí)行過程中拋出異常(主要針對(duì) throw promise 場(chǎng)景):a. 捕獲異常,如果是 promise-like 對(duì)象,在 promise resolve 后,把當(dāng)前 task 重新放到 request 的 pingedTask 中,等待重新執(zhí)行(調(diào)用 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 對(duì)應(yīng)函數(shù):() => pingTask(request, task),pingTask 實(shí)現(xiàn)如下:
function pingTask(request: Request, task: Task): void { const pingedTasks = request.pingedTasks; pingedTasks.push(task); scheduleWork(() => performWork(request)); }
renderNodeDestructive 對(duì) task 的 node 屬性代表的組件樹,做 深度優(yōu)先 遍歷,一邊將組件渲染為 dom 節(jié)點(diǎn),一邊將 dom 節(jié)點(diǎn)的信息存儲(chǔ)到 task 的 blockedSegment 屬性中。
Streaming SSR 實(shí)現(xiàn)的一個(gè)關(guān)鍵,是對(duì)Suspense組件的渲染邏輯。當(dāng) renderNodeDestructive 遍歷到 Suspense 組件時(shí),會(huì)調(diào)用renderSuspenseBoundary 執(zhí)行渲染邏輯。
renderSuspenseBoundary 的主要邏輯為:
- 針對(duì)解析到的 Suspense 組件,創(chuàng)建一個(gè)新的 Boundary:newBoundary
- 新建一個(gè) segment:boundarySegment, boundarySegment用于保存 Suspense 的 fallback 代表的內(nèi)容,所以boundarySegment 的 boundary 屬性值為 newBoundary 。同時(shí), boundarySegment 也會(huì)保存到當(dāng)前 task 的 blockedSegment的 children 屬性中(可參考介紹 Segment 數(shù)據(jù)結(jié)構(gòu)的例子)。
- 新建一個(gè) segment:contentRootSegment ,保存 Suspense組件的children 代表的內(nèi)容。
- 渲染 Suspense組件的children
- 如果渲染成功,說明 Suspense組件的 children沒有需要異步等待的內(nèi)容(渲染是同步完成的):a. 設(shè)置contentRootSegment 的狀態(tài)為 COMPLETEDb. 把 contentRootSegment存入newBoundary的completedSegments屬性中
- 如果渲染過程 throw promise,說明 Suspense的 children 有需要異步等待的內(nèi)容:a. 新建一個(gè) task,task 的blockedBoundary等于 newBoundaryb. 當(dāng) promise resolve 后,將 task 保存到 request 的 pingedTasks 中(通過 task 的ping屬性),等待下一個(gè)事件循環(huán)處理。c. 再新建一個(gè) task,代表Suspense 的 fallback 組件樹的渲染任務(wù), task 的blockedSegment 等于 boundarySegment,task 的blockedBoundary 等于調(diào)用 renderSuspenseBoundary時(shí)的 task.blockedBoundary (不是 newBoundary,是 newBoundary 上一層級(jí)的 boundary)d. 把 task 保存到 request的 pingedTasks 中,等待在 performWork 中處理
這段邏輯比較復(fù)雜,簡(jiǎn)單理解的話,在渲染過程中,每當(dāng)遇到 Suspense 組件,就會(huì)創(chuàng)建一個(gè)新的 Boundary,但新 Boundary 并不意味著一定要?jiǎng)?chuàng)建一個(gè)新的 Task,因?yàn)镾uspense組件內(nèi)元素的渲染不一定需要異步完成,只有存在 動(dòng)態(tài)導(dǎo)入組件(React.lazy)或獲取異步數(shù)據(jù)等情況,才會(huì)創(chuàng)建一個(gè)新的 Task,用以表示這個(gè)異步的渲染過程。
上面的過程還有 2 個(gè)注意點(diǎn):
- 步驟 6.a 中,新建的 task 不會(huì)立即放入 request的 pingedTasks 中,而是要等待代表異步任務(wù)的 promise resolve 后,才放入pingedTasks。所以pingedTasks ,實(shí)際上保存的是「沒有異步任務(wù)依賴」的 task,是可以同步完成組件渲染工作的 task。
- 步驟 5 中, 沒有 6.c 和 6.d 兩步, 因?yàn)槿绻?nbsp;Suspense 的 children 沒有需要異步等待的內(nèi)容,就不需要展示 fallback 內(nèi)容,自然也不需要新建一個(gè) task 負(fù)責(zé) fallback 組件樹的渲染任務(wù) 。
3、啟動(dòng)輸出流
renderToPipeableStream 返回 pipe 和 abort 2 個(gè)方法,分別用于向輸出流寫入組件樹的渲染結(jié)果,和終止本次 SSR 請(qǐng)求。這里我們主要分析向輸出流寫入組件樹的渲染結(jié)果。pipe 內(nèi)部調(diào)用startFlowing,startFlowing 調(diào)用 flushCompletedQueues,flushCompletedQueues顧名思義,會(huì)將已完成的組件樹的渲染信息,寫入到輸出流(Response)。
flushCompletedQueues 主要邏輯為:
- 檢測(cè) root boundary 范圍的 tasks 是否已經(jīng)渲染完成,如果是,則將對(duì)應(yīng)的 segments 寫入輸出流;如果否,則返回(因?yàn)樾枰WC寫入輸出流的第一段數(shù)據(jù),一定是 root boundary 范圍內(nèi)的組件的渲染結(jié)果)
- 檢查 suspense boundaries ,如果 suspense boundary 滿足條件:關(guān)聯(lián)的所有 task 都已經(jīng)完成, 則將 suspense boundary 的 segment 寫入輸出流,suspense boundary 的完整內(nèi)容在瀏覽器頁面處于可見狀態(tài)(不再顯示 suspense 的 fallback 內(nèi)容)。
- 繼續(xù)檢查 suspense boundaries,如果 suspense boundary 滿足條件:存在完成的 task,但不是所有 task 都完成,則將這些完成的 task 的 segment 寫入輸出流,但 suspense boundary 的完整內(nèi)容在瀏覽器頁面仍然處于隱藏狀態(tài)(包裹內(nèi)容的 div 此時(shí)還是 hidden 狀態(tài))。
- 如果所有 suspense boundaries 的關(guān)聯(lián)的 task 都已經(jīng)完成,說明本次 SSR 完成, 調(diào)用 close 結(jié)束請(qǐng)求。
flushCompletedQueues 簡(jiǎn)化后的代碼如下:
function flushCompletedQueues( request: Request, destination: Destination, ): void { // 1.開始:root boundary 寫入到輸出流 beginWriting(destination); let i; const completedRootSegment = request.completedRootSegment; if (completedRootSegment !== null) { // 將 root boundary 范圍內(nèi)的組件渲染結(jié)果寫入輸出流 if (request.pendingRootTasks === 0) { flushSegment(request, destination, completedRootSegment); request.completedRootSegment = null; writeCompletedRoot(destination, request.responseState); } else { // root boundary 范圍內(nèi),還存在沒有完成的 task,直接返回。 // 不需要繼續(xù)向下看 suspense boundary 是否完成 return; } } // 1.完成:root boundary 寫入到輸出流 completeWriting(destination); // 2.開始:suspense boundary(關(guān)聯(lián)的 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(關(guān)聯(lián)的 task 已全部完成)寫入到輸出流 completeWriting(destination); // 3.開始:suspense boundary(關(guān)聯(lián)的 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(關(guān)聯(lián)的 task 部分完成)寫入到輸出流 completeWriting(destination); if ( request.allPendingTasks === 0 && request.pingedTasks.length === 0 && request.clientRenderedBoundaries.length === 0 && request.completedBoundaries.length === 0 ) { // 所有渲染任務(wù)都已完成,關(guān)閉輸出流 close(destination); } }
上面的代碼中,一共有 3 組 beginWriting / completeWriting ,分別代表了 flushCompletedQueues 的前 3 步驟。
至此,我們就完成了 Streaming SSR 主要源碼實(shí)現(xiàn)的分析。
以上就是React Streaming SSR原理示例深入解析的詳細(xì)內(nèi)容,更多關(guān)于React Streaming SSR的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
React hooks如何清除定時(shí)器并驗(yàn)證效果
在React中,通過自定義Hook useTimeHook實(shí)現(xiàn)定時(shí)器的啟動(dòng)與清除,在App組件中使用Clock組件展示當(dāng)前時(shí)間,利用useEffect鉤子在組件掛載時(shí)啟動(dòng)定時(shí)器,同時(shí)確保組件卸載時(shí)清除定時(shí)器,避免內(nèi)存泄露,這種方式簡(jiǎn)化了狀態(tài)管理和副作用的處理2024-10-10React Native中NavigatorIOS組件的簡(jiǎn)單使用詳解
這篇文章主要介紹了React Native中NavigatorIOS組件的簡(jiǎn)單使用詳解,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-01-01react實(shí)現(xiàn)簡(jiǎn)單的拖拽功能
這篇文章主要為大家詳細(xì)介紹了react實(shí)現(xiàn)簡(jiǎn)單的拖拽功能,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-03-03React使用Electron開發(fā)桌面端的詳細(xì)流程步驟
React是一個(gè)流行的JavaScript庫,用于構(gòu)建Web應(yīng)用程序,結(jié)合Electron框架,可以輕松地將React應(yīng)用程序打包為桌面應(yīng)用程序,本文詳細(xì)介紹了使用React和Electron開發(fā)桌面應(yīng)用程序的步驟,需要的朋友可以參考下2023-06-06