白屏度量 阿里是怎麼監控前端白屏的?

引用下集團監控的 slogan:關注業務穩定性的人,運氣都不會太差~

背景

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

可以說是非常相似了,甚至能明白了白屏這個詞彙是如何統一出來的。那麼,體感如此強烈的現象勢必會給用戶帶來一些不好的影響,如何能儘早監聽,快速消除影響就顯得很重要了。

爲什麼單獨監控白屏

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

方案調研

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

  1. js 執行過程中的錯誤

  2. 資源錯誤

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

一、onerror + DOM 檢測

原理很簡單,在當前主流的 SPA 框架下,DOM 一般掛載在一個根節點之下(比如 <div></div> )發生白屏後通常現象是根節點下所有 DOM 被卸載,該方案就是通過監聽全局的 onerror 事件,在異常發生時去檢測根節點下是否掛載 DOM,若無則證明白屏。我認爲是非常簡單暴力且有效的方案。但是也有缺點:其一切建立在 白屏 === 根節點下 DOM 被卸載 成立的前提下,實際並非如此比如一些微前端的框架,當然也有我後面要提到的方案,這個方案和我最終方案天然衝突。

二、Mutation Observer Api

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

  1. 搭配 onerror 使用,類似第一個方案,但很快被我否決了,雖然其可以很好的知道 DOM 改變的動向,但無法和具體某個報錯聯繫起來,兩個都是事件監聽,兩者是沒有必然聯繫的。

  2. 單獨使用判斷是否有大量 DOM 被卸載,缺點:白屏不一定是 DOM 被卸載,也有可能是壓根沒渲染,且正常情況也有可能大量 DOM 被卸載。完全走不通。

  3. 單獨使用其監聽時機配合 DOM 檢測,其缺點和方案一一樣,而且我覺得不如方案一。因爲它沒法和具體錯誤聯繫起來,也就是沒法定位。當然我和其他團隊同學交流的時候他們給出了其他方向:通過追蹤用戶行爲數據來定位問題,我覺得也是一種方法。

一開始我認爲這就是最終答案,經過了漫長的心裏鬥爭,最終還是否定掉了。不過它給了一個比較好的監聽時機的選擇。

三、餓了麼 - Emonitor 白屏監控方案

餓了麼的白屏監控方案,其原理是記錄頁面打開 4s 前後 html 長度變化,並將數據上傳到餓了麼自研的時序數據庫。如果一個頁面是穩定的,那麼頁面長度變化的分佈應該呈現「冪次分佈」曲線的形態,p10、p20 (排在文檔前 10%、20%)等數據線應該是平穩的,在一定的區間內波動,如果頁面出現異常,那麼曲線一定會出現掉底的情況。

其他

其他都大同小樣,其實調研了一圈下來發現無非就是兩點

  1. 監控時機:調研下來常見的就三種:

  2. onerror

  3. mutation observer api

  4. 輪訓

  5. DOM 檢測:這個方案就很多了,除了上述的還可以:

  6. elementsFromPoint api 採樣

  7. 圖像識別

  8. 基於 DOM 的各種數據的各種算法識別

  9. ...

改變方向

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

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

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

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

那麼關於白屏我認爲可以這麼定義:異常導致的渲染失敗

那麼白屏的監控方案即:監控渲染異常。那麼對於 React 而言,答案就是:Error Boundaries

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 渲染流程

我們來簡單回顧下從代碼到展現頁面上 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 樹。

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

render

現在我們要開始渲染頁面,是我們剛纔的例子,執行 ReactDOM.render 。這裏我們有個全局 workInProgress 對象標誌當前處理的 FiberNode

  1. 首先我們爲根節點初始化一個 FiberNodeRoot ,他的結構就如上面所示,並將 workInProgress= FiberNodeRoot

  2. 接下來我們執行 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>

  1. 我們爲 ReactElement 生成一個 FiberNode 並把 return 指向父 FiberNode ,最開始是我們的根節點,並將 workInProgress = FiberNode
{
  elementType: f App(), // type 就是 App 函數
  key: null,
  type: FunctionComponent, // 函數組件類型
  return: FiberNodeRoot, // 我們的根節點
  child: null,
  sibling: null,
  flags: null
}
  1. 只要workInProgress 存在我們就要處理其指向的 FiberNode 。節點類型有很多,處理方法也不太一樣,不過整體流程是相同的,我們以當前函數式組件爲例子,直接執行 App(props) 方法,這裏有兩種情況

  2. 該組件 return 一個單一節點,也就是返回一個 ReactElement 對象,重複 3 - 4 的步驟。並將當前 節點的 child 指向子節點 CurrentFiberNode.child = ChildFiberNode 並將子節點的 return 指向當前節點 ChildFiberNode.return = CurrentFiberNode

  3. 該組件 return 多個節點(數組或者 Fragment ),此時我們會得到一個 ChildiFberNode 的數組。我們循環他,每一個節點執行 3 - 4 步驟。將當前節點的 child 指向第一個子節點 CurrentFiberNode.child = ChildFiberNodeList[0] ,同時每個子節點的 sibling 指向其下一個子節點(如果有) ChildFiberNode[i].sibling = ChildFiberNode[i + 1] ,每個子節點的 return 都指向當前節點 ChildFiberNode[i].return = CurrentFiberNode

如果無異常每個節點都會被標記爲待佈局 FiberNode.flags = Placement

  1. 重複步驟直到處理完全部節點 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 方法,核心兩件事:

  1. 將當前也就是出問題的節點狀態標誌爲未完成 FiberNode.flags = Incomplete

  2. 從父節點開始冒泡,向上尋找有能力處理異常( 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 / 金城武

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/kP2uwtjh3f8n3uMNKyc__A