阿里解決「前端白屏」的方案

作者 | ES2049 / 金城武
原文 | https://zhuanlan.zhihu.com/p/399348866
背景


可以說是非常相似了,甚至能明白了白屏這個詞匯是如何統(tǒng)一出來的。那么,體感如此強烈的現(xiàn)象勢必會給用戶帶來一些不好的影響,如何能盡早監(jiān)聽,快速消除影響就顯得很重要了。
為什么單獨監(jiān)控白屏
不光光是白屏,白屏只是一種現(xiàn)象,我們要做的是精細化的異常監(jiān)控。異常監(jiān)控各個公司肯定都有自己的一套體系,集團也不例外,而且也足夠成熟。但是通用的方案總歸是有缺點的。
如果對所有的異常都加以報警和監(jiān)控,就無法區(qū)分異常的嚴重等級,并做出相應(yīng)的響應(yīng),所以在通用的監(jiān)控體系下定制精細化的異常監(jiān)控是非常有必要的。
這就是本文討論白屏這一場景的原因,我把這一場景的邊界圈定在了 “白屏” 這一現(xiàn)象。
方案調(diào)研
白屏大概可能的原因有兩種:
js 執(zhí)行過程中的錯誤
資源錯誤
這兩者方向不同,資源錯誤影響面較多,且視情況而定,故不在下面方案考慮范圍內(nèi)。為此,參考了網(wǎng)上的一些實踐加上自己的一些調(diào)研,大概總結(jié)出了一些方案:
一、onerror + DOM 檢測
原理很簡單,在當前主流的 SPA 框架下,DOM 一般掛載在一個根節(jié)點之下(比如 <div id="root"></div> )發(fā)生白屏后通?,F(xiàn)象是根節(jié)點下所有 DOM 被卸載,該方案就是通過監(jiān)聽全局的 onerror 事件,在異常發(fā)生時去檢測根節(jié)點下是否掛載 DOM,若無則證明白屏。
我認為是非常簡單暴力且有效的方案。
但是也有缺點: 其一切建立在 白屏 === 根節(jié)點下 DOM 被卸載 成立的前提下 ,實際并非如此比如一些微前端的框架,當然也有我后面要提到的方案,這個方案和我最終方案天然沖突。
二、Mutation Observer Api
不了解的可以看下文檔[1]。其本質(zhì)是監(jiān)聽 DOM 變化,并告訴你每次變化的 DOM 是被增加還是刪除。為其考慮了多種方案:
onerror
一開始我認為這就是最終答案,經(jīng)過了漫長的心里斗爭,最終還是否定掉了。不過它給了一個比較好的監(jiān)聽時機的選擇。
三、餓了么-Emonitor 白屏監(jiān)控方案
餓了么的白屏監(jiān)控方案,其原理是記錄頁面打開 4s 前后 html 長度變化,并將數(shù)據(jù)上傳到餓了么自研的時序數(shù)據(jù)庫。
如果一個頁面是穩(wěn)定的,那么頁面長度變化的分布應(yīng)該呈現(xiàn)「冪次分布」曲線的形態(tài),p10、p20 (排在文檔前 10%、20%)等數(shù)據(jù)線應(yīng)該是平穩(wěn)的,在一定的區(qū)間內(nèi)波動,如果頁面出現(xiàn)異常,那么曲線一定會出現(xiàn)掉底的情況。
其他
其他都大同小樣,其實調(diào)研了一圈下來發(fā)現(xiàn)無非就是兩點
監(jiān)控時機 :調(diào)研下來常見的就三種:
onerror
mutation observer api
輪訓(xùn)
DOM 檢測 :這個方案就很多了,除了上述的還可以:
elementsFromPoint api 采樣
圖像識別
基于 DOM 的各種數(shù)據(jù)的各種算法識別
...
改變方向
幾番嘗試下來幾乎沒有我想要的,其主要原因是準確率 -- 這些方案都不能保證我監(jiān)聽到的是白屏,單從理論的推導(dǎo)就說不通。
他們都有一個共同點:監(jiān)聽的是'白屏'這個現(xiàn)象,從現(xiàn)象去推導(dǎo)本質(zhì)雖然能成功,但是不夠準確。
所以我真正想要監(jiān)聽的是造成白屏的本質(zhì)。
那么回到最開始,什么是白屏?他是如何造成的?是因為錯誤導(dǎo)致的瀏覽器無法渲染?不,在這個 spa 框架盛行的現(xiàn)在實際上的白屏是框架造成的,本質(zhì)是由于錯誤導(dǎo)致框架不知道怎么渲染所以干脆就不渲染。
由于我們團隊 react 技術(shù)棧居多,我們來看看 react 官網(wǎng)的一段話[2]:

React 認為把一個錯誤的 UI 保留比完全移除它更糟糕。我們不討論這個看法的正確與否,至少我們知道了白屏的原因:渲染過程的異常且我們沒有捕獲異常并處理。
反觀目前的主流框架:我們把 DOM 的操作托管給了框架,所以渲染的異常處理不同框架方法肯定不一樣,這大概就是白屏監(jiān)控難統(tǒng)一化產(chǎn)品化的原因。
但大致方向肯定是一樣的。
那么關(guān)于白屏我認為可以這么定義: 異常導(dǎo)致的渲染失敗 。
那么白屏的監(jiān)控方案即: 監(jiān)控渲染異常 。
那么對于 React 而言,答案就是:Error Boundaries
Error Boundaries
我們可以稱之為錯誤邊界,錯誤邊界是什么?它其實就是一個生命周期,用來監(jiān)聽當前組件的 children 渲染過程中的錯誤,并可以返回一個 降級的 UI 來渲染:
class ErrorBoundary extends React.Component {constructor(props) {super(props);this.state = { hasError: false };}static getDerivedStateFromError(error) {// 更新 state 使下一次渲染能夠顯示降級后的 UIreturn { hasError: true };}componentDidCatch(error, errorInfo) {// 我們可以將錯誤日志上報給服務(wù)器logErrorToMyService(error, errorInfo);}render() {if (this.state.hasError) {// 我們可以自定義降級后的 UI 并渲染return <h1>Something went wrong.</h1>;}return this.props.children;}}
一個有責(zé)任心的開發(fā)一定不會放任錯誤的發(fā)生。
錯誤邊界可以包在任何位置并提供降級 UI,也就是說,一旦開發(fā)者'有責(zé)任心' 頁面就不會全白,這也是我之前說的方案一與之天然沖突且其他方案不穩(wěn)定的情況。
那么,在這同時我們上報異常信息,這里上報的異常一定會導(dǎo)致我們 定義的白屏 ,這一推導(dǎo)是 100% 正確的。
100% 這個詞或許不夠負責(zé),接下來我們來看看為什么我說這一推導(dǎo)是 100% 準確的:
React 渲染流程
我們來簡單回顧下從代碼到展現(xiàn)頁面上 React 做了什么。我大致將其分為幾個階段:render => 任務(wù)調(diào)度 => 任務(wù)循環(huán) => 提交 => 展示 我們舉一個簡單的例子來展示其整個過程(任務(wù)調(diào)度不再本次討論范圍故不展示):
const App = ({ children }) => (<><p>hello</p>{ children }</>);const Child = () => <p>I'm child</p>const a = ReactDOM.render(<App><Child/></App>,document.getElementById('root'));
準備
首先瀏覽器是不認識我們的 jsx 語法的,所以我們通過 babel 編譯大概能得到下面的代碼:
var App = function App(_ref2) {var children = _ref2.children;return React.createElement("p", null, "hello"), children);};var Child = function Child() {return React.createElement("p", null, "I'm child");};ReactDOM.render(React.createElement(App, null, React.createElement(Child, null)), document.getElementById('root'));
babel 插件將所有的 jsx 都轉(zhuǎn)成了 createElement 方法,執(zhí)行它會得到一個描述對象 ReactElement 大概長這樣子:
{$$typeof: Symbol(react.element),key: null,props: {}, // createElement 第二個參數(shù) 注意 children 也在這里,children 也會是一個 ReactElement 或 數(shù)組type: 'h1' // createElement 的第一個參數(shù),可能是原生的節(jié)點字符串,也可能是一個組件對象(Function、Class...)}
所有的節(jié)點包括原生的 <a></a> 、 <p></p> 都會創(chuàng)建一個 FiberNode ,他的結(jié)構(gòu)大概長這樣:
FiberNode = {elementType: null, // 傳入 createElement 的第一個參數(shù)key: null,type: HostRoot, // 節(jié)點類型(根節(jié)點、函數(shù)組件、類組件等等)return: null, // 父 FiberNodechild: null, // 第一個子 FiberNodesibling: null, // 下一個兄弟 FiberNodeflag: null, // 狀態(tài)標記}
你可以把它理解為 Virtual Dom 只不過多了許多調(diào)度的東西。最開始我們會為根節(jié)點創(chuàng)建一個 FiberNodeRoot 如果有且僅有一個 ReactDOM.render 那么他就是唯一的根,當前有且僅有一個 FiberNode 樹。
我只保留了一些渲染過程中重要的字段,其他還有很多用于調(diào)度、判斷的字段我這邊就不放出來了,有興趣自行了解
render
現(xiàn)在我們要開始渲染頁面,是我們剛才的例子,執(zhí)行 ReactDOM.render 。這里我們有個全局 workInProgress 對象標志當前處理的 FiberNode
首先我們?yōu)楦?jié)點初始化一個 FiberNodeRoot ,他的結(jié)構(gòu)就如上面所示,并將 workInProgress= FiberNodeRoot 。
接下來我們執(zhí)行 ReactDOM.render 方法的第一個參數(shù),我們得到一個 ReactElement :
ReactElement = {$$typeof: Symbol(react.element),key: null,props: {children: {$$typeof: Symbol(react.element),key: null,props: {},ref: null,type: ? Child(),}}ref: null,type: f App()}
該結(jié)構(gòu)描述了 <App><Child /></App>
我們?yōu)?nbsp;ReactElement 生成一個 FiberNode 并把 return 指向父 FiberNode ,最開始是我們的根節(jié)點,并將 workInProgress = FiberNode
{elementType: f App(), // type 就是 App 函數(shù)key: null,type: FunctionComponent, // 函數(shù)組件類型return: FiberNodeRoot, // 我們的根節(jié)點child: null,sibling: null,flags: null}
只要 workInProgress 存在我們就要處理其指向的 FiberNode 。節(jié)點類型有很多,處理方法也不太一樣,不過整體流程是相同的,我們以當前函數(shù)式組件為例子,直接執(zhí)行 App(props) 方法,這里有兩種情況
該組件 return 一個單一節(jié)點,也就是返回一個 ReactElement 對象,重復(fù) 3 - 4 的步驟。并將當前 節(jié)點的 child 指向子節(jié)點 CurrentFiberNode.child = ChildFiberNode 并將子節(jié)點的 return 指向當前節(jié)點 ChildFiberNode.return = CurrentFiberNode
該組件 return 多個節(jié)點(數(shù)組或者 Fragment ),此時我們會得到一個 ChildiFberNode 的數(shù)組。我們循環(huán)他,每一個節(jié)點執(zhí)行 3 - 4 步驟。將當前節(jié)點的 child 指向第一個子節(jié)點 CurrentFiberNode.child = ChildFiberNodeList[0] ,同時每個子節(jié)點的 sibling 指向其下一個子節(jié)點(如果有) ChildFiberNode[i].sibling = ChildFiberNode[i + 1] ,每個子節(jié)點的 return 都指向當前節(jié)點 ChildFiberNode[i].return = CurrentFiberNode
如果無異常每個節(jié)點都會被標記為待布局 FiberNode.flags = Placement
重復(fù)步驟直到處理完全部節(jié)點 workInProgress 為空。
最終我們能大概得到這樣一個 FiberNode 樹:
FiberNodeRoot = {elementType: null,type: HostRoot,return: null,child: FiberNode<App>,sibling: null,flags: Placement, // 待布局狀態(tài)}FiberNode<App> {elementType: f App(),type: FunctionComponent,return: FiberNodeRoot,child: FiberNode<p>,sibling: null,flags: Placement // 待布局狀態(tài)}FiberNode<p> {elementType: 'p',type: HostComponent,return: FiberNode<App>,sibling: FiberNode<Child>,child: null,flags: Placement // 待布局狀態(tài)}FiberNode<Child> {elementType: f Child(),type: FunctionComponent,return: FiberNode<App>,child: null,flags: Placement // 待布局狀態(tài)}
提交階段
提交階段簡單來講就是拿著這棵樹進行深度優(yōu)先遍歷 child => sibling,放置 DOM 節(jié)點并調(diào)用生命周期。
那么整個正常的渲染流程簡單來講就是這樣。接下來看看異常處理
錯誤邊界流程
剛剛我們了解了正常的流程現(xiàn)在我們制造一些錯誤并捕獲他:
const App = ({ children }) => (<><p>hello</p>{ children }</>);const Child = () => <p>I'm child {a.a}</p>const a = ReactDOM.render(<App><ErrorBoundary><Child/></ErrorBoundary></App>,document.getElementById('root'));
執(zhí)行步驟 4 的函數(shù)體是包裹在 try...catch 內(nèi)的如果捕獲到了異常則會走異常的流程:
do {try {workLoopSync(); // 上述 步驟 4break;} catch (thrownValue) {handleError(root, thrownValue);}} while (true);
執(zhí)行步驟 4 時我們調(diào)用 Child 方法由于我們加了個不存在的表達式 {a.a} 此時會拋出異常進入我們的 handleError 流程此時我們處理的目標是 FiberNode<Child> ,我們來看看 handleError :
function handleError(root, thrownValue): void {let erroredWork = workInProgress; // 當前處理的 FiberNode 也就是異常的 節(jié)點throwException(root, // 我們的根 FiberNodeerroredWork.return, // 父節(jié)點erroredWork,thrownValue, // 異常內(nèi)容);completeUnitOfWork(erroredWork);}function throwException(root: FiberRoot,returnFiber: Fiber,sourceFiber: Fiber,value: mixed,) {// The source fiber did not complete.sourceFiber.flags |= Incomplete;let workInProgress = returnFiber;do {switch (workInProgress.tag) {case HostRoot: {workInProgress.flags |= ShouldCapture;return;}case ClassComponent:// Capture and retryconst ctor = workInProgress.type;const instance = workInProgress.stateNode;if ((workInProgress.flags & DidCapture) === NoFlags &&(typeof ctor.getDerivedStateFromError === 'function' ||(instance !== null &&typeof instance.componentDidCatch === 'function' &&!isAlreadyFailedLegacyErrorBoundary(instance)))) {workInProgress.flags |= ShouldCapture;return;}break;default:break;}workInProgress = workInProgress.return;} while (workInProgress !== null);}
代碼過長截取一部分 先看 throwException 方法,核心兩件事:
將當前也就是出問題的節(jié)點狀態(tài)標志為未完成 FiberNode.flags = Incomplete
從父節(jié)點開始冒泡,向上尋找有能力處理異常( ClassComponent )且的確處理了異常的(聲明了 getDerivedStateFromError 或 componentDidCatch 生命周期)節(jié)點,如果有,則將那個節(jié)點標志為待捕獲 workInProgress.flags |= ShouldCapture ,如果沒有則是根節(jié)點。
completeUnitOfWork 方法也類似,從父節(jié)點開始冒泡,找到 ShouldCapture 標記的節(jié)點,如果有就標記為已捕獲 DidCapture ,如果沒找到,則一路把所有的節(jié)點都標記為 Incomplete 直到根節(jié)點,并把 workInProgress 指向當前捕獲的節(jié)點。
之后從當前捕獲的節(jié)點(也有可能沒捕獲是根節(jié)點)開始重新走流程,由于其狀態(tài) react 只會渲染其降級 UI,如果有 sibling 節(jié)點則會繼續(xù)走下面的流程。我們看看上述例子最終得到的 FiberNode 樹:
FiberNodeRoot = {elementType: null,type: HostRoot,return: null,child: FiberNode<App>,sibling: null,flags: Placement, // 待布局狀態(tài)}FiberNode<App> {elementType: f App(),type: FunctionComponent,return: FiberNodeRoot,child: FiberNode<p>,sibling: null,flags: Placement // 待布局狀態(tài)}FiberNode<p> {elementType: 'p',type: HostComponent,return: FiberNode<App>,sibling: FiberNode<ErrorBoundary>,child: null,flags: Placement // 待布局狀態(tài)}FiberNode<ErrorBoundary> {elementType: f ErrorBoundary(),type: ClassComponent,return: FiberNode<App>,child: null,flags: DidCapture // 已捕獲狀態(tài)}FiberNode<h1> {elementType: f ErrorBoundary(),type: ClassComponent,return: FiberNode<ErrorBoundary>,child: null,flags: Placement // 待布局狀態(tài)}
如果沒有配置錯誤邊界那么根節(jié)點下就沒有任何節(jié)點,自然無法渲染出任何內(nèi)容。
ok,相信到這里大家應(yīng)該清楚錯誤邊界的處理流程了,也應(yīng)該能理解為什么我之前說由 ErrorBoundry 推導(dǎo)白屏是 100% 正確的。
當然這個 100% 指的是由 ErrorBoundry 捕捉的異常基本上會導(dǎo)致白屏,并不是指它能捕獲全部的白屏異常。以下場景也是他無法捕獲的:
事件處理
異步代碼
SSR
自身拋出來的錯誤
React SSR 設(shè)計使用流式傳輸,這意味著服務(wù)端在發(fā)送已經(jīng)處理好的元素的同時,剩下的仍然在生成 html,也就是其父元素?zé)o法捕獲子組件的錯誤并隱藏錯誤的組件。
這種情況似乎只能將所有的 render 函數(shù)包裹 try...catch ,當然我們可以借助 babel 或 TypeScript 來幫我們簡單實現(xiàn)這一過程,其最終得到的效果是和 ErrorBoundry 類似的。
而事件和異步則很巧,雖說 ErrorBoundry 無法捕獲他們之中的異常,不過其產(chǎn)生的異常也恰好不會造成白屏(如果是錯誤的設(shè)置狀態(tài),間接導(dǎo)致了白屏,剛好還是會被捕獲到)。
這就在白屏監(jiān)控的職責(zé)邊界之外了,需要別的精細化監(jiān)控能力來處理它。
總結(jié)
那么最后總結(jié)下本文的出的幾個結(jié)論:我對白屏的定義: 異常導(dǎo)致的渲染失敗 。對應(yīng)方案是: 資源監(jiān)聽 + 渲染流程監(jiān)聽 。
在目前 SPA 框架下白屏的監(jiān)控需要針對場景做精細化的處理,這里以 React 為例子,通過監(jiān)聽渲染過程異常能夠很好的獲得白屏的信息,同時能增強開發(fā)者對異常處理的重視。而其他框架也會有相應(yīng)的方法來處理這一現(xiàn)象。
當然這個方案也有弱點,由于是從本質(zhì)推導(dǎo)現(xiàn)象其實無法 cover 所有的白屏的場景,比如我要搭配資源的監(jiān)聽來處理資源異常導(dǎo)致的白屏。
當然沒有一個方案是完美的,我這里也是提供一個思路,歡迎大家一起討論。
感謝你的閱讀。
學(xué)習(xí)更多技能
請點擊下方公眾號
![]()

