引用下集團監控的 slogan:關注業務穩定性的人,運氣都不會太差~
不知從何時開始,前端白屏問題成爲一個很是廣泛的話題,'白屏' 甚至成爲了前端 bug 的代名詞:_喂,你的頁面白了。_並且,'白' 這一現象彷佛對於用戶體感上來講更增強,回憶起 windows 系統的崩潰 '藍屏':
能夠說是很是類似了,甚至能明白了白屏這個詞彙是如何統一出來的。那麼,體感如此強烈的現象勢必會給用戶帶來一些很差的影響,如何能儘早監聽,快速消除影響就顯得很重要了。javascript
不光光是白屏,白屏只是一種現象,咱們要作的是精細化的異常監控。異常監控各個公司確定都有本身的一套體系,集團也不例外,並且也足夠成熟。可是通用的方案總歸是有缺點的,若是對全部的異常都加以報警和監控,就沒法區分異常的嚴重等級,並作出相應的響應,因此在通用的監控體系下定製精細化的異常監控是很是有必要的。這就是本文討論白屏這一場景的緣由,我把這一場景的邊界圈定在了 「白屏」 這一現象。html
白屏大概可能的緣由有兩種:前端
這二者方向不一樣,資源錯誤影響面較多,且視狀況而定,故不在下面方案考慮範圍內。爲此,參考了網上的一些實踐加上本身的一些調研,大概總結出了一些方案:java
原理很簡單,在當前主流的 SPA 框架下,DOM 通常掛載在一個根節點之下(好比 <div id="root"></div>
)發生白屏後一般現象是根節點下全部 DOM 被卸載,該方案就是經過監聽全局的 onerror
事件,在異常發生時去檢測根節點下是否掛載 DOM,若無則證實白屏。
我認爲是很是簡單暴力且有效的方案。可是也有缺點:其一切創建在 **白屏 === 根節點下 DOM 被卸載**
成立的前提下,實際並不是如此好比一些微前端的框架,固然也有我後面要提到的方案,這個方案和我最終方案自然衝突。react
不瞭解的能夠看下文檔。
其本質是監聽 DOM 變化,並告訴你每次變化的 DOM 是被增長仍是刪除。爲其考慮了多種方案:算法
onerror
使用,相似第一個方案,但很快被我否決了,雖然其能夠很好的知道 DOM 改變的動向,但沒法和具體某個報錯聯繫起來,兩個都是事件監聽,二者是沒有必然聯繫的。一開始我認爲這就是最終答案,通過了漫長的內心鬥爭,最終仍是否認掉了。不過它給了一個比較好的監聽時機的選擇。數據庫
餓了麼的白屏監控方案,其原理是記錄頁面打開 4s 先後 html 長度變化,並將數據上傳到餓了麼自研的時序數據庫。若是一個頁面是穩定的,那麼頁面長度變化的分佈應該呈現「冪次分佈」曲線的形態,p十、p20 (排在文檔前 10%、20%)等數據線應該是平穩的,在必定的區間內波動,若是頁面出現異常,那麼曲線必定會出現掉底的狀況。windows
其餘都大同小樣,其實調研了一圈下來發現無非就是兩點api
監控時機:調研下來常見的就三種:數組
DOM 檢測:這個方案就不少了,除了上述的還能夠:
幾番嘗試下來幾乎沒有我想要的,其主要緣由是準確率 -- 這些方案都不能保證我監聽到的是白屏,單從理論的推導就說不通。他們都有一個共同點:監聽的是'白屏'這個現象,從現象去推導本質雖然能成功,可是不夠準確。因此我真正想要監聽的是形成白屏的本質。
那麼回到最開始,什麼是白屏?他是如何形成的?是由於錯誤致使的瀏覽器沒法渲染?不,在這個 spa 框架盛行的如今實際上的白屏是框架形成的,本質是因爲錯誤致使框架不知道怎麼渲染因此乾脆就不渲染。因爲咱們團隊 React 技術棧居多,咱們來看看 React 官網的一段話:
React 認爲把一個錯誤的 UI 保留比徹底移除它更糟糕。咱們不討論這個見解的正確與否,至少咱們知道了白屏的緣由:渲染過程的異常且咱們沒有捕獲異常並處理。
反觀目前的主流框架:咱們把 DOM 的操做託管給了框架,因此渲染的異常處理不一樣框架方法確定不同,這大概就是白屏監控難統一化產品化的緣由。但大體方向確定是同樣的。
那麼關於白屏我認爲能夠這麼定義:異常致使的渲染失敗。
那麼白屏的監控方案即:監控渲染異常。那麼對於 React 而言,答案就是: Error Boundaries
咱們能夠稱之爲錯誤邊界,錯誤邊界是什麼?它其實就是一個生命週期,用來監聽當前組件的 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; } }
一個有責任心的開發必定不會聽任錯誤的發生。錯誤邊界能夠包在任何位置並提供降級 UI,也就是說,一旦開發者'有責任心' 頁面就不會全白,這也是我以前說的方案一與之自然衝突且其餘方案不穩定的狀況。
那麼,在這同時咱們上報異常信息,這裏上報的異常必定會致使咱們定義的白屏,這一推導是 100% 正確的。
100% 這個詞或許不夠負責,接下來咱們來看看爲何我說這一推導是 100% 準確的:
咱們來簡單回顧下從代碼到展示頁面上 React 作了什麼。
我大體將其分爲幾個階段:render => 任務調度 => 任務循環 => 提交 => 展現
咱們舉一個簡單的例子來展現其整個過程(任務調度再也不本次討論範圍故不展現):
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 都轉成了 createElement
方法,執行它會獲得一個描述對象 ReactElement
大概長這樣子:
{ $$typeof: Symbol(react.element), key: null, props: {}, // createElement 第二個參數 注意 children 也在這裏,children 也會是一個 ReactElement 或 數組 type: 'h1' // createElement 的第一個參數,多是原生的節點字符串,也多是一個組件對象(Function、Class...) }
全部的節點包括原生的 <a></a>
、 <p></p>
都會建立一個 FiberNode
,他的結構大概長這樣:
FiberNode = { elementType: null, // 傳入 createElement 的第一個參數 key: null, type: HostRoot, // 節點類型(根節點、函數組件、類組件等等) return: null, // 父 FiberNode child: null, // 第一個子 FiberNode sibling: null, // 下一個兄弟 FiberNode flag: null, // 狀態標記 }
你能夠把它理解爲 Virtual Dom 只不過多了許多調度的東西。最開始咱們會爲根節點建立一個 FiberNodeRoot
若是有且僅有一個 ReactDOM.render
那麼他就是惟一的根,當前有且僅有一個 FiberNode
樹。
我只保留了一些渲染過程當中重要的字段,其餘還有不少用於調度、判斷的字段我這邊就不放出來了,有興趣自行了解
如今咱們要開始渲染頁面,是咱們剛纔的例子,執行 ReactDOM.render
。這裏咱們有個全局 workInProgress
對象標誌當前處理的 FiberNode
FiberNodeRoot
,他的結構就如上面所示,並將 workInProgress= FiberNodeRoot
。ReactDOM.render
方法的第一個參數,咱們獲得一個 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() }
該結構描述了 <App><Child /></App>
ReactElement
生成一個 FiberNode
並把 return 指向父 FiberNode
,最開始是咱們的根節點,並將 workInProgress = FiberNode
{ elementType: f App(), // type 就是 App 函數 key: null, type: FunctionComponent, // 函數組件類型 return: FiberNodeRoot, // 咱們的根節點 child: null, sibling: null, flags: null }
只要workInProgress
存在咱們就要處理其指向的 FiberNode
。節點類型有不少,處理方法也不太同樣,不過總體流程是相同的,咱們以當前函數式組件爲例子,直接執行 App(props)
方法,這裏有兩種狀況
ReactElement
對象,重複 3 - 4 的步驟。並將當前 節點的 child 指向子節點 CurrentFiberNode.child = ChildFiberNode
並將子節點的 return 指向當前節點 ChildFiberNode.return = CurrentFiberNode
Fragment
),此時咱們會獲得一個 ChildiFberNode
的數組。咱們循環他,每個節點執行 3 - 4 步驟。將當前節點的 child 指向第一個子節點 CurrentFiberNode.child = ChildFiberNodeList[0]
,同時每一個子節點的 sibling 指向其下一個子節點(若是有) ChildFiberNode[i].sibling = ChildFiberNode[i + 1]
,每一個子節點的 return 都指向當前節點 ChildFiberNode[i].return = CurrentFiberNode
若是無異常每一個節點都會被標記爲待佈局 FiberNode.flags = Placement
workInProgress
爲空。最終咱們能大概獲得這樣一個 FiberNode
樹:
FiberNodeRoot = { elementType: null, type: HostRoot, return: null, child: FiberNode<App>, sibling: null, flags: Placement, // 待佈局狀態 } FiberNode<App> { elementType: f App(), type: FunctionComponent, return: FiberNodeRoot, child: FiberNode<p>, sibling: null, flags: Placement // 待佈局狀態 } FiberNode<p> { elementType: 'p', type: HostComponent, return: FiberNode<App>, sibling: FiberNode<Child>, child: null, flags: Placement // 待佈局狀態 } FiberNode<Child> { elementType: f Child(), type: FunctionComponent, return: FiberNode<App>, child: null, flags: Placement // 待佈局狀態 }
提交階段簡單來說就是拿着這棵樹進行深度優先遍歷 child => sibling,放置 DOM 節點並調用生命週期。
那麼整個正常的渲染流程簡單來說就是這樣。接下來看看異常處理
剛剛咱們瞭解了正常的流程如今咱們製造一些錯誤並捕獲他:
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') );
執行步驟 4 的函數體是包裹在 try...catch
內的若是捕獲到了異常則會走異常的流程:
do { try { workLoopSync(); // 上述 步驟 4 break; } catch (thrownValue) { handleError(root, thrownValue); } } while (true);
執行步驟 4 時咱們調用 Child
方法因爲咱們加了個不存在的表達式 {a.a}
此時會拋出異常進入咱們的 handleError
流程此時咱們處理的目標是 FiberNode<Child>
,咱們來看看 handleError
:
function handleError(root, thrownValue): void { let erroredWork = workInProgress; // 當前處理的 FiberNode 也就是異常的 節點 throwException( root, // 咱們的根 FiberNode erroredWork.return, // 父節點 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
方法,核心兩件事:
FiberNode.flags = Incomplete
ClassComponent
)且的確處理了異常的(聲明瞭 getDerivedStateFromError
或 componentDidCatch
生命週期)節點,若是有,則將那個節點標誌爲待捕獲 workInProgress.flags |= ShouldCapture
,若是沒有則是根節點。completeUnitOfWork
方法也相似,從父節點開始冒泡,找到 ShouldCapture
標記的節點,若是有就標記爲已捕獲 DidCapture
,若是沒找到,則一路把全部的節點都標記爲 Incomplete
直到根節點,並把 workInProgress
指向當前捕獲的節點。
以後從當前捕獲的節點(也有可能沒捕獲是根節點)開始從新走流程,因爲其狀態 react 只會渲染其降級 UI,若是有 sibling 節點則會繼續走下面的流程。咱們看看上述例子最終獲得的 FiberNode
樹:
FiberNodeRoot = { elementType: null, type: HostRoot, return: null, child: FiberNode<App>, sibling: null, flags: Placement, // 待佈局狀態 } FiberNode<App> { elementType: f App(), type: FunctionComponent, return: FiberNodeRoot, child: FiberNode<p>, sibling: null, flags: Placement // 待佈局狀態 } FiberNode<p> { elementType: 'p', type: HostComponent, return: FiberNode<App>, sibling: FiberNode<ErrorBoundary>, child: null, flags: Placement // 待佈局狀態 } FiberNode<ErrorBoundary> { elementType: f ErrorBoundary(), type: ClassComponent, return: FiberNode<App>, child: null, flags: DidCapture // 已捕獲狀態 } FiberNode<h1> { elementType: f ErrorBoundary(), type: ClassComponent, return: FiberNode<ErrorBoundary>, child: null, flags: Placement // 待佈局狀態 }
若是沒有配置錯誤邊界那麼根節點下就沒有任何節點,天然沒法渲染出任何內容。
ok,相信到這裏你們應該清楚錯誤邊界的處理流程了,也應該能理解爲何我以前說由 ErrorBoundry
推導白屏是 100% 正確的。固然這個 100% 指的是由 ErrorBoundry
捕捉的異常基本上會致使白屏,並非指它能捕獲所有的白屏異常。如下場景也是他沒法捕獲的:
React SSR 設計使用流式傳輸,這意味着服務端在發送已經處理好的元素的同時,剩下的仍然在生成 HTML,也就是其父元素沒法捕獲子組件的錯誤並隱藏錯誤的組件。這種狀況彷佛只能將全部的 render 函數包裹 try...catch
,固然咱們能夠藉助 babel
或 TypeScript
來幫咱們簡單實現這一過程,其最終獲得的效果是和 ErrorBoundry
相似的。
而事件和異步則很巧,雖然說 ErrorBoundry
沒法捕獲他們之中的異常,不過其產生的異常也剛好不會形成白屏(若是是錯誤的設置狀態,間接致使了白屏,恰好仍是會被捕獲到)。這就在白屏監控的職責邊界以外了,須要別的精細化監控能力來處理它。
那麼最後總結下本文的出的幾個結論:
我對白屏的定義:異常致使的渲染失敗。
對應方案是:資源監聽 + 渲染流程監聽。
在目前 SPA 框架下白屏的監控須要針對場景作精細化的處理,這裏以 React 爲例子,經過監聽渲染過程異常可以很好的得到白屏的信息,同時能加強開發者對異常處理的重視。而其餘框架也會有相應的方法來處理這一現象。
固然這個方案也有弱點,因爲是從本質推導現象其實沒法 cover 全部的白屏的場景,好比我要搭配資源的監聽來處理資源異常致使的白屏。固然沒有一個方案是完美的,我這裏也是提供一個思路,歡迎你們一塊兒討論。
做者:ES2049 / 金城武
文章可隨意轉載,但請保留此原文連接。
很是歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj@alibaba-inc.com 。