1. <strong id="7actg"></strong>
    2. <table id="7actg"></table>

    3. <address id="7actg"></address>
      <address id="7actg"></address>
      1. <object id="7actg"><tt id="7actg"></tt></object>

        【總結】1098- 來自阿里巴巴體驗技術部的前端白屏方案分享

        共 10526字,需瀏覽 22分鐘

         ·

        2021-10-02 18:52


        引用下集團監(jiān)控的 slogan:關注業(yè)務穩(wěn)定性的人,運氣都不會太差~

        背景

        不知從什么時候開始,前端白屏問題成為一個非常普遍的話題,'白屏' 甚至成為了前端 bug 的代名詞:_喂,你的頁面白了。_而且,'白' 這一現(xiàn)象似乎對于用戶體感上來說更加強,回憶起 windows 系統(tǒng)的崩潰 '藍屏':

        可以說是非常相似了,甚至能明白了白屏這個詞匯是如何統(tǒng)一出來的。那么,體感如此強烈的現(xiàn)象勢必會給用戶帶來一些不好的影響,如何能盡早監(jiān)聽,快速消除影響就顯得很重要了。

        為什么單獨監(jiān)控白屏

        不光光是白屏,白屏只是一種現(xiàn)象,我們要做的是精細化的異常監(jiān)控。異常監(jiān)控各個公司肯定都有自己的一套體系,集團也不例外,而且也足夠成熟。但是通用的方案總歸是有缺點的,如果對所有的異常都加以報警和監(jiān)控,就無法區(qū)分異常的嚴重等級,并做出相應的響應,所以在通用的監(jiān)控體系下定制精細化的異常監(jiān)控是非常有必要的。這就是本文討論白屏這一場景的原因,我把這一場景的邊界圈定在了 “白屏” 這一現(xiàn)象。

        方案調研

        白屏大概可能的原因有兩種:

        1. js 執(zhí)行過程中的錯誤
        2. 資源錯誤

        這兩者方向不同,資源錯誤影響面較多,且視情況而定,故不在下面方案考慮范圍內。為此,參考了網上的一些實踐加上自己的一些調研,大概總結出了一些方案:

        一、onerror + DOM 檢測

        原理很簡單,在當前主流的 SPA 框架下,DOM 一般掛載在一個根節(jié)點之下(比如

        )發(fā)生白屏后通常現(xiàn)象是根節(jié)點下所有 DOM 被卸載,該方案就是通過監(jiān)聽全局的 onerror 事件,在異常發(fā)生時去檢測根節(jié)點下是否掛載 DOM,若無則證明白屏。我認為是非常簡單暴力且有效的方案。但是也有缺點:其一切建立在 白屏 === 根節(jié)點下 DOM 被卸載?成立的前提下,實際并非如此比如一些微前端的框架,當然也有我后面要提到的方案,這個方案和我最終方案天然沖突。

        二、Mutation Observer Api

        不了解的可以看下文檔[1]。其本質是監(jiān)聽 DOM 變化,并告訴你每次變化的 DOM 是被增加還是刪除。為其考慮了多種方案:

        1. 搭配 onerror 使用,類似第一個方案,但很快被我否決了,雖然其可以很好的知道 DOM 改變的動向,但無法和具體某個報錯聯(lián)系起來,兩個都是事件監(jiān)聽,兩者是沒有必然聯(lián)系的。
        2. 單獨使用判斷是否有大量 DOM 被卸載,缺點:白屏不一定是 DOM 被卸載,也有可能是壓根沒渲染,且正常情況也有可能大量 DOM 被卸載。完全走不通。
        3. 單獨使用其監(jiān)聽時機配合 DOM 檢測,其缺點和方案一一樣,而且我覺得不如方案一。因為它沒法和具體錯誤聯(lián)系起來,也就是沒法定位。當然我和其他團隊同學交流的時候他們給出了其他方向:通過追蹤用戶行為數(shù)據(jù)來定位問題,我覺得也是一種方法。

        一開始我認為這就是最終答案,經過了漫長的心里斗爭,最終還是否定掉了。不過它給了一個比較好的監(jiān)聽時機的選擇。

        三、餓了么-Emonitor 白屏監(jiān)控方案

        餓了么的白屏監(jiān)控方案,其原理是記錄頁面打開 4s 前后 html 長度變化,并將數(shù)據(jù)上傳到餓了么自研的時序數(shù)據(jù)庫。如果一個頁面是穩(wěn)定的,那么頁面長度變化的分布應該呈現(xiàn)「冪次分布」曲線的形態(tài),p10、p20 (排在文檔前 10%、20%)等數(shù)據(jù)線應該是平穩(wěn)的,在一定的區(qū)間內波動,如果頁面出現(xiàn)異常,那么曲線一定會出現(xiàn)掉底的情況。

        其他

        其他都大同小樣,其實調研了一圈下來發(fā)現(xiàn)無非就是兩點

        1. 監(jiān)控時機:調研下來常見的就三種:
        2. onerror
        3. mutation observer api
        4. 輪訓
        5. DOM 檢測:這個方案就很多了,除了上述的還可以:
        6. elementsFromPoint api 采樣
        7. 圖像識別
        8. 基于 DOM 的各種數(shù)據(jù)的各種算法識別
        9. ...

        改變方向

        幾番嘗試下來幾乎沒有我想要的,其主要原因是準確率 -- 這些方案都不能保證我監(jiān)聽到的是白屏,單從理論的推導就說不通。他們都有一個共同點:監(jiān)聽的是'白屏'這個現(xiàn)象,從現(xiàn)象去推導本質雖然能成功,但是不夠準確。所以我真正想要監(jiān)聽的是造成白屏的本質。

        那么回到最開始,什么是白屏?他是如何造成的?是因為錯誤導致的瀏覽器無法渲染?不,在這個 spa 框架盛行的現(xiàn)在實際上的白屏是框架造成的,本質是由于錯誤導致框架不知道怎么渲染所以干脆就不渲染。由于我們團隊 React 技術棧居多,我們來看看 React 官網的一段話[2]

        React 認為把一個錯誤的 UI 保留比完全移除它更糟糕。我們不討論這個看法的正確與否,至少我們知道了白屏的原因:渲染過程的異常且我們沒有捕獲異常并處理。

        反觀目前的主流框架:我們把 DOM 的操作托管給了框架,所以渲染的異常處理不同框架方法肯定不一樣,這大概就是白屏監(jiān)控難統(tǒng)一化產品化的原因。但大致方向肯定是一樣的。

        那么關于白屏我認為可以這么定義:異常導致的渲染失敗。

        那么白屏的監(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?使下一次渲染能夠顯示降級后的?UI
        ????return?{?hasError:?true?};
        ??}

        ??componentDidCatch(error,?errorInfo)?{
        ????//?我們可以將錯誤日志上報給服務器
        ????logErrorToMyService(error,?errorInfo);
        ??}

        ??render()?{
        ????if?(this.state.hasError)?{
        ??????//?我們可以自定義降級后的?UI?并渲染
        ??????return?<h1>Something?went?wrong.h1>;
        ????}

        ????return?this.props.children;?
        ??}
        }

        一個有責任心的開發(fā)一定不會放任錯誤的發(fā)生。錯誤邊界可以包在任何位置并提供降級 UI,也就是說,一旦開發(fā)者'有責任心' 頁面就不會全白,這也是我之前說的方案一與之天然沖突且其他方案不穩(wěn)定的情況。那么,在這同時我們上報異常信息,這里上報的異常一定會導致我們定義的白屏,這一推導是 100% 正確的。

        100% 這個詞或許不夠負責,接下來我們來看看為什么我說這一推導是 100% 準確的:

        React 渲染流程

        我們來簡單回顧下從代碼到展現(xiàn)頁面上 React 做了什么。我大致將其分為幾個階段:render => 任務調度 => 任務循環(huán) => 提交 => 展示 我們舉一個簡單的例子來展示其整個過程(任務調度不再本次討論范圍故不展示):

        const?App?=?({?children?})?=>?(
        ??<>
        ????<p>hellop>

        ????{?children?}
        ??
        );
        const?Child?=?()?=>?<p>I'm?childp>

        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 都轉成了 createElement 方法,執(zhí)行它會得到一個描述對象 ReactElement 大概長這樣子:

        {
        ????$$typeof:?Symbol(react.element),
        ??key:?null,
        ??props:?{},?//?createElement?第二個參數(shù)?注意?children?也在這里,children?也會是一個?ReactElement?或?數(shù)組
        ??type:?'h1'?//?createElement?的第一個參數(shù),可能是原生的節(jié)點字符串,也可能是一個組件對象(Function、Class...)
        }

        所有的節(jié)點包括原生的 、

        都會創(chuàng)建一個 FiberNode ,他的結構大概長這樣:

        FiberNode?=?{
        ????elementType:?null,?//?傳入?createElement?的第一個參數(shù)
        ??key:?null,
        ??type:?HostRoot,?//?節(jié)點類型(根節(jié)點、函數(shù)組件、類組件等等)
        ??return:?null,?//?父?FiberNode
        ??child:?null,?//?第一個子?FiberNode
        ??sibling:?null,?//?下一個兄弟?FiberNode
        ??flag:?null,?//?狀態(tài)標記
        }

        你可以把它理解為 Virtual Dom 只不過多了許多調度的東西。最開始我們會為根節(jié)點創(chuàng)建一個 FiberNodeRoot 如果有且僅有一個 ReactDOM.render 那么他就是唯一的根,當前有且僅有一個 FiberNode 樹。

        我只保留了一些渲染過程中重要的字段,其他還有很多用于調度、判斷的字段我這邊就不放出來了,有興趣自行了解

        render

        現(xiàn)在我們要開始渲染頁面,是我們剛才的例子,執(zhí)行 ReactDOM.render 。這里我們有個全局 workInProgress 對象標志當前處理的 FiberNode

        1. 首先我們?yōu)楦?jié)點初始化一個 FiberNodeRoot ,他的結構就如上面所示,并將 workInProgress= FiberNodeRoot。
        2. 接下來我們執(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()
        }

        該結構描述了

        1. 我們?yōu)?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
        }
        1. 只要workInProgress 存在我們就要處理其指向的 FiberNode 。節(jié)點類型有很多,處理方法也不太一樣,不過整體流程是相同的,我們以當前函數(shù)式組件為例子,直接執(zhí)行 App(props) 方法,這里有兩種情況
        2. 該組件 return 一個單一節(jié)點,也就是返回一個 ReactElement 對象,重復 3 - 4 的步驟。并將當前 節(jié)點的 child 指向子節(jié)點 CurrentFiberNode.child = ChildFiberNode 并將子節(jié)點的 return 指向當前節(jié)點 ChildFiberNode.return = CurrentFiberNode
        3. 該組件 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

        1. 重復步驟直到處理完全部節(jié)點 workInProgress 為空。

        最終我們能大概得到這樣一個 FiberNode 樹:

        FiberNodeRoot?=?{
        ??elementType:?null,
        ??type:?HostRoot,
        ??return:?null,
        ??child:?FiberNode,
        ??sibling:?null,
        ??flags:?Placement,?//?待布局狀態(tài)
        }

        FiberNode?{
        ??elementType:?f?App(),
        ??type:?FunctionComponent,
        ??return:?FiberNodeRoot,
        ??child:?FiberNode

        ,
        ??sibling:?null,
        ??flags:?Placement?//?待布局狀態(tài)
        }

        FiberNode

        ?{
        ??elementType:?'p',
        ??type:?HostComponent,
        ??return:?FiberNode,
        ??sibling:?FiberNode,
        ??child:?null,
        ??flags:?Placement?//?待布局狀態(tài)
        }

        FiberNode?{
        ??elementType:?f?Child(),
        ??type:?FunctionComponent,
        ??return:?FiberNode,
        ??child:?null,
        ??flags:?Placement?//?待布局狀態(tài)
        }

        提交階段

        提交階段簡單來講就是拿著這棵樹進行深度優(yōu)先遍歷 child => sibling,放置 DOM 節(jié)點并調用生命周期。

        那么整個正常的渲染流程簡單來講就是這樣。接下來看看異常處理

        錯誤邊界流程

        剛剛我們了解了正常的流程現(xiàn)在我們制造一些錯誤并捕獲他:

        const?App?=?({?children?})?=>?(
        ??<>
        ??<p>hellop>

        ??{?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 內的如果捕獲到了異常則會走異常的流程:

        do?{
        ??try?{
        ????workLoopSync();?//?上述?步驟?4
        ????break;
        ??}?catch?(thrownValue)?{
        ????handleError(root,?thrownValue);
        ??}
        }?while?(true);

        執(zhí)行步驟 4 時我們調用 Child 方法由于我們加了個不存在的表達式 {a.a} 此時會拋出異常進入我們的 handleError 流程此時我們處理的目標是 FiberNode ,我們來看看 handleError :

        function?handleError(root,?thrownValue):?void?{
        ??let?erroredWork?=?workInProgress;?//?當前處理的?FiberNode?也就是異常的?節(jié)點
        ??throwException(
        ????root,?//?我們的根?FiberNode
        ????erroredWork.return,?//?父節(jié)點
        ????erroredWork,
        ????thrownValue,?//?異常內容
        ??);
        ????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?retry
        ????????const?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 方法,核心兩件事:

        1. 將當前也就是出問題的節(jié)點狀態(tài)標志為未完成 FiberNode.flags = Incomplete
        2. 從父節(jié)點開始冒泡,向上尋找有能力處理異常( ClassComponent )且的確處理了異常的(聲明了 getDerivedStateFromErrorcomponentDidCatch 生命周期)節(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,
        ??sibling:?null,
        ??flags:?Placement,?//?待布局狀態(tài)
        }

        FiberNode?{
        ??elementType:?f?App(),
        ??type:?FunctionComponent,
        ??return:?FiberNodeRoot,
        ??child:?FiberNode

        ,
        ??sibling:?null,
        ??flags:?Placement?//?待布局狀態(tài)
        }

        FiberNode

        ?{
        ??elementType:?'p',
        ??type:?HostComponent,
        ??return:?FiberNode,
        ??sibling:?FiberNode,
        ??child:?null,
        ??flags:?Placement?//?待布局狀態(tài)
        }

        FiberNode?{
        ??elementType:?f?ErrorBoundary(),
        ??type:?ClassComponent,
        ??return:?FiberNode,
        ??child:?null,
        ??flags:?DidCapture?//?已捕獲狀態(tài)
        }

        FiberNode

        ?{
        ??elementType:?f?ErrorBoundary(),
        ??type:?ClassComponent,
        ??return:?FiberNode,
        ??child:?null,
        ??flags:?Placement?//?待布局狀態(tài)
        }

        如果沒有配置錯誤邊界那么根節(jié)點下就沒有任何節(jié)點,自然無法渲染出任何內容。

        ok,相信到這里大家應該清楚錯誤邊界的處理流程了,也應該能理解為什么我之前說由 ErrorBoundry 推導白屏是 100% 正確的。當然這個 100% 指的是由 ErrorBoundry 捕捉的異?;旧蠒е掳灼粒⒉皇侵杆懿东@全部的白屏異常。以下場景也是他無法捕獲的:

        • 事件處理
        • 異步代碼
        • SSR
        • 自身拋出來的錯誤

        React SSR 設計使用流式傳輸,這意味著服務端在發(fā)送已經處理好的元素的同時,剩下的仍然在生成 HTML,也就是其父元素無法捕獲子組件的錯誤并隱藏錯誤的組件。這種情況似乎只能將所有的 render 函數(shù)包裹 try...catch ,當然我們可以借助 babelTypeScript 來幫我們簡單實現(xiàn)這一過程,其最終得到的效果是和 ErrorBoundry 類似的。

        而事件和異步則很巧,雖說 ErrorBoundry 無法捕獲他們之中的異常,不過其產生的異常也恰好不會造成白屏(如果是錯誤的設置狀態(tài),間接導致了白屏,剛好還是會被捕獲到)。這就在白屏監(jiān)控的職責邊界之外了,需要別的精細化監(jiān)控能力來處理它。

        總結

        那么最后總結下本文的出的幾個結論:我對白屏的定義:異常導致的渲染失敗。對應方案是:資源監(jiān)聽 + 渲染流程監(jiān)聽。

        在目前 SPA 框架下白屏的監(jiān)控需要針對場景做精細化的處理,這里以 React 為例子,通過監(jiān)聽渲染過程異常能夠很好的獲得白屏的信息,同時能增強開發(fā)者對異常處理的重視。而其他框架也會有相應的方法來處理這一現(xiàn)象。

        當然這個方案也有弱點,由于是從本質推導現(xiàn)象其實無法 cover 所有的白屏的場景,比如我要搭配資源的監(jiān)聽來處理資源異常導致的白屏。當然沒有一個方案是完美的,我這里也是提供一個思路,歡迎大家一起討論。

        作者:ES2049 / 金城武

        https://zhuanlan.zhihu.com/p/383686310

        參考資料

        [1]

        文檔: https://link.zhihu.com/?target=https%3A//developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver

        [2]

        一段話: https://link.zhihu.com/?target=https%3A//zh-hans.reactjs.org/docs/error-boundaries.html%23new-behavior-for-uncaught-errors



        1. JavaScript 重溫系列(22篇全)
        2. ECMAScript 重溫系列(10篇全)
        3. JavaScript設計模式 重溫系列(9篇全)
        4.?正則 / 框架 / 算法等 重溫系列(16篇全)
        5.?Webpack4 入門(上)||?Webpack4 入門(下)
        6.?MobX 入門(上)?||??MobX 入門(下)
        7. 120+篇原創(chuàng)系列匯總

        回復“加群”與大佬們一起交流學習~

        點擊“閱讀原文”查看 120+ 篇原創(chuàng)文章

        瀏覽 33
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        1. <strong id="7actg"></strong>
        2. <table id="7actg"></table>

        3. <address id="7actg"></address>
          <address id="7actg"></address>
          1. <object id="7actg"><tt id="7actg"></tt></object>
            精品无码国产污污污免费网站 | 污网页| 老鸭窝在线观看视频 | 成人网站欧美 | 大胸喂奶三级在线观看 | 我想看操大逼的有没有免费毛片 | 91在线免费观看 | 美女脱个精光露出小网站 | 午夜性爱福利视频 | 亚洲自拍中文字幕 |