基于 SSR 的預(yù)渲染首屏直出方案
Create React Doc 是一個(gè)使用 React 的 markdown 文檔站點(diǎn)生成工具。此前在 Create React Doc 中引入了預(yù)渲染技術(shù)來(lái)預(yù)先生成對(duì)應(yīng)路由的靜態(tài)頁(yè)面,以使基于其搭建的文檔站點(diǎn)能享用到 SEO(Search Engine Optimization) 同時(shí)加快了首屏訪問(wèn)加載。
新的挑戰(zhàn)
Create React Doc 使用預(yù)渲染技術(shù)獲取各頁(yè)面路由對(duì)應(yīng)的 DOM 結(jié)構(gòu)以生成對(duì)應(yīng)的 HTML 文件,并將靜態(tài)文件存放于 gh-pages 服務(wù)中(可自行選擇其它存儲(chǔ)服務(wù))從而達(dá)到加快首屏訪問(wèn)加載以及 SEO。見(jiàn)如下藍(lán)色線框流程圖部分:

下圖為 gp-pages 服務(wù)存放的靜態(tài)目錄文件:

在訪問(wèn) Create React Doc 創(chuàng)建的文檔時(shí),頁(yè)面渲染周期可分為首屏渲染階段、銜接階段、可交互階段。
首屏渲染階段: 以訪問(wèn)快速上手章節(jié)為例,當(dāng)用戶在瀏覽器輸入 http://muyunyun.cn/create-react-doc/290a4219/ 時(shí),gp-pages 服務(wù)會(huì)推送預(yù)先渲染好的頁(yè)面,此時(shí)用戶可以獲得十分快速的首屏體驗(yàn) ??。

不過(guò)需要指出的是,預(yù)渲染的頁(yè)面僅僅只是生成靜態(tài)的 HTML 頁(yè)面,因而在首屏渲染階段的頁(yè)面時(shí)用戶是無(wú)法交互的。
銜接階段: 銜接階段是首屏渲染階段與頁(yè)面可交互階段的中間態(tài)階段,在該階段執(zhí)行 JavaScript 邏輯,從而使頁(yè)面從不可交互到可交互。但是觀察發(fā)現(xiàn)從預(yù)渲染頁(yè)面到頁(yè)面可交互,出現(xiàn)了干擾體驗(yàn)的加載頁(yè),體驗(yàn)十分不好 ??。

不被期望的中間加載頁(yè)(見(jiàn)上圖)出現(xiàn)的原因?yàn)轭A(yù)渲染頁(yè)面與客戶端渲染頁(yè)面都使用了 ReactDom.render 并指定相同根路徑節(jié)點(diǎn)(這里為 root)進(jìn)行渲染。在訪問(wèn)首屏預(yù)渲染頁(yè)面之后,執(zhí)行 JavaScript 邏輯時(shí),React 會(huì)移除存量 HTML 結(jié)構(gòu),并基于 root 節(jié)點(diǎn)重新開(kāi)始渲染,因而必然會(huì)導(dǎo)致出現(xiàn)不被期望的加載頁(yè)或者頁(yè)面抖動(dòng)。
ReactDOM.render(
<RouterRoot />,
document.getElementById('root'),
)
可交互階段:該階段用戶可以與頁(yè)面進(jìn)行交互。比如點(diǎn)擊左側(cè)菜單按鈕可以展開(kāi)、收起等。

基于 SSR 的預(yù)渲染首屏直出方案
基于文檔站點(diǎn)大部分為靜態(tài)內(nèi)容,少部分為動(dòng)態(tài)可交互內(nèi)容。抽象出以下幾種可行性思路:
思路一:
調(diào)整交互布局,減少動(dòng)態(tài)節(jié)點(diǎn)的交互。比如使用面包屑組件與平鋪菜單結(jié)構(gòu)來(lái)替換多層級(jí)菜單,或者探尋更優(yōu)雅的 CSS 交互方案。思路二:
解耦靜態(tài)節(jié)點(diǎn)與動(dòng)態(tài)交互節(jié)點(diǎn)渲染的時(shí)機(jī)。預(yù)渲染時(shí)完成大部分靜態(tài)頁(yè)面的渲染,在銜接階段中完成動(dòng)態(tài)邏輯節(jié)點(diǎn)的執(zhí)行。偽代碼如下:
if (!ifProdRender) {
// 預(yù)渲染靜態(tài)節(jié)點(diǎn)
ReactDOM.render(
<QuietNode />,
document.getElementById('quietNode'),
)
} else {
// 銜接階段完成動(dòng)態(tài)交互節(jié)點(diǎn)的渲染
ReactDOM.render(
<DynamicNode />,
document.getElementById('dynamicNode'),
)
}
基于上述代碼,可實(shí)現(xiàn)靜態(tài)頁(yè)面節(jié)點(diǎn)與動(dòng)態(tài)交互節(jié)點(diǎn)的分開(kāi)渲染。但該方案的缺陷是靜態(tài)節(jié)點(diǎn)與動(dòng)態(tài)交互節(jié)點(diǎn)之間的聯(lián)系被完全割裂開(kāi),銜接階段渲染的節(jié)點(diǎn)不能影響到靜態(tài)頁(yè)面節(jié)點(diǎn),比如頁(yè)面布局、路由跳轉(zhuǎn)等。
- 思路三:
解耦靜態(tài)節(jié)點(diǎn)渲染與動(dòng)態(tài)交互生效的時(shí)機(jī),保證靜態(tài)節(jié)點(diǎn)與動(dòng)態(tài)交互節(jié)點(diǎn)渲染之間的聯(lián)系。在思路二基礎(chǔ)上,進(jìn)一步聯(lián)想到如果基于服務(wù)端渲染(在服務(wù)端首屏直出靜態(tài)頁(yè)面,在客戶端注水交互邏輯)不就可以完美支持靜態(tài)節(jié)點(diǎn)與動(dòng)態(tài)交互隔離執(zhí)行,同時(shí)保證銜接階段頁(yè)面不出現(xiàn)抖動(dòng)了么。只不過(guò)我們這里的服務(wù)端可以使用 gh-pages 服務(wù)來(lái)存放基于 SSR 提前預(yù)渲染好的節(jié)點(diǎn)。

根據(jù)環(huán)境執(zhí)行不同的渲染邏輯的代碼如下示意,完整改動(dòng)可見(jiàn) mr。
if (ifDev) {
// dev render
document.getElementById('root').innerHTML = ReactDOMServer.renderToString(<RouterRoot />)
ReactDOM.hydrate(
<RouterRoot />,
document.getElementById('root'),
)
} else if (ifPrerender) {
// prerender
document.getElementById('root').innerHTML = ReactDOMServer.renderToString(<RouterRoot />)
} else {
// prod render
ReactDOM.hydrate(
<RouterRoot />,
document.getElementById('root'),
)
}
至此在銜接階段中不友好的抖動(dòng)問(wèn)題(不被期望的加載頁(yè))得以解決,用戶在訪問(wèn)站點(diǎn)時(shí)不會(huì)再感受到由于頁(yè)面抖動(dòng)帶來(lái)不友好的體驗(yàn),同時(shí)從首屏渲染頁(yè)到頁(yè)面可交互的銜接也變得更為順滑。
小結(jié)
在靜態(tài)內(nèi)容為主的文檔站點(diǎn)中,除了首屏加載速度、SEO 之外,從首屏頁(yè)面(不可交互)到可交互階段的中間銜接態(tài)的體驗(yàn)也十分重要?;?React 技術(shù)生態(tài)前提下,本文給出了基于 SSR 的預(yù)渲染首屏直出的解法以相對(duì)完美地解決了銜接態(tài)出現(xiàn)的頁(yè)面抖動(dòng)問(wèn)題。在即將到來(lái)的 React 18 中,我們可以讓節(jié)點(diǎn)的交互更為即時(shí)地被響應(yīng),以更進(jìn)一步優(yōu)化用戶訪問(wèn)體驗(yàn),讓我們拭目以待吧。