捕獲 React 異常

雲音樂前端技術團隊發表於2020-01-14

此專案為雲音樂營收組穩定性工程的前端部分,本文作者 章偉東,專案其他參與者趙祥濤

一個 bug 引發的血案

韓國某著名男子天團之前在我們平臺上架了一張重磅數字專輯,本來是一件喜大普奔的好事,結果上架後投訴蜂擁而至。部分使用者反饋頁面開啟就崩潰,緊急排查後發現真凶就是下面這段程式碼。

  render() {
     const { data, isCreator, canSignOut, canSignIn } = this.props;
     const {  supportCard, creator, fansList, visitorId, memberCount } = data;
     let getUserIcon = (obj) => {
         if (obj.userType == 4) {
             return (<i className="icn u-svg u-svg-yyr_sml" />);
         } else if (obj.authStatus == 1) {
             return (<i className="icn u-svg u-svg-vip_sml" />);
         } else if (obj.expertTags && creator.expertTags.length > 0) {
             return (<i className="icn u-svg u-svg-daren_sml" />);
         }
         return null;
     };
     ...
  }
複製程式碼

這行 if (obj.expertTags && creator.expertTags.length ) 裡面的 creator 應該是 obj,由於手滑,不小心寫錯了。

對於上面這種情況,lint 工具無法檢測出來,因為 creator 恰好也是一個變數,這是一個純粹的邏輯錯誤。

後來我們緊急修復了這個 bug,一切趨於平靜。事情雖然到此為止,但是有個聲音一直在我心中迴響 如何避免這種事故再次發生。 對於這種錯誤,堵是堵不住的,那麼我們就應該思考設計一種兜底機制,能夠隔離這種錯誤,保證在頁面部分元件出錯的情況下,不影響整個頁面。

ErrorBoundary 介紹

從 React 16 開始,引入了 Error Boundaries 概念,它可以捕獲它的子元件中產生的錯誤,記錄錯誤日誌,並展示降級內容,具體 官網地址

Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed

這個特性讓我們眼前一亮,精神為之振奮,彷彿在黑暗中看到了一絲亮光。但是經過研究發現,ErrorBoundary 只能捕獲子元件的 render 錯誤,有一定的侷限性,以下是無法處理的情況:

如何建立一個 ErrorBoundary 元件

只要在 React.Component 元件裡面新增 static getDerivedStateFromError() 或者 componentDidCatch() 即可。前者在錯誤發生時進行降級處理,後面一個函式主要是做日誌記錄,官方程式碼 如下

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // You can also log the error to an error reporting service
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}
複製程式碼

可以看到 getDerivedStateFromError 捕獲子元件發生的錯誤,設定 hasError 變數,render 函式裡面根據變數的值顯示降級的ui。

至此一個 ErrorBoundary 元件已經定義好了,使用時只要包裹一個子元件即可,如下。

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>
複製程式碼

Error Boundaries 的普遍用法。

看到 Error Boundaries 的使用方法之後,大部分團隊的都會遵循官方的用法,寫一個 errorBoundaryHOC,然後包裹一下子元件。下面 scratch 工程的一個例子

export default errorBoundaryHOC('Blocks')(
    connect(
        mapStateToProps,
        mapDispatchToProps
    )(Blocks)
);
複製程式碼

其中 Blocks 是一個 UI 展示元件,errorBoundaryHOC 就是錯誤處理元件, 具體原始碼可以看 這裡

普遍用法的困境

上面的方法在 export 的時候包裹一個 errorBoundaryHOC。 對於新開發的程式碼,使用比較方便,但是對於已經存在的程式碼,會有比較大的問題。

因為 export 的格式有 多種

export class ClassName {...}
export { name1, name2, …, nameN };
export { variable1 as name1, variable2 as name2, …, nameN };
export * as name1 from複製程式碼

所以如果對原有程式碼用 errorBoundaryHOC 進行封裝,會改變原有的程式碼結構,如果要後續不再需要封裝刪除也很麻煩,方案實施成本高,非常棘手。

所以,我們在考慮是否有一種方法可以比較方便的處理上面的問題。

青銅時代 - BabelPlugin

在碰到上訴困境問題之後,我們的思路是:通過腳手架自動對子元件包裹錯誤處理元件。設計框架如下圖:

捕獲 React 異常

簡而言之分下面幾步:

  1. 判斷是否是 React 16 版本

  2. 讀取配置檔案

  3. 檢測是否已經包裹了 ErrorBoundary 元件。 如果沒有,走 patch 流程。如果有,根據 force 標籤判斷是否重新包裹。

  4. 走包裹元件流程(圖中的 patch 流程):

    a. 先引入錯誤處理元件

    b. 對子元件用 ErrorBoundary 包裹

配置檔案如下(.catch-react-error-config.json):

{
  "sentinel": {
    "imports": "import ServerErrorBoundary from '$components/ServerErrorBoundary'",
    "errorHandleComponent": "ServerErrorBoundary",
    "filter": ["/actual/"]
  },
  "sourceDir": "test/fixtures/wrapCustomComponent"
}
複製程式碼

patch 前原始碼:

import React, { Component } from "react";

class App extends Component {
  render() {
    return <CustomComponent />;
  }
}
複製程式碼

讀取配置檔案 patch 之後的程式碼為:

//isCatchReactError
import ServerErrorBoundary from "$components/ServerErrorBoundary";
import React, { Component } from "react";

class App extends Component {
  render() {
    return (
      <ServerErrorBoundary isCatchReactError>
        {<CustomComponent />}
      </ServerErrorBoundary>
    );
  }
}
複製程式碼

可以看到頭部多了

import ServerErrorBoundary from '$components/ServerErrorBoundary'

然後整個元件也被 ServerErrorBoundary 包裹,isCatchReactError 用來標記位,主要是下次 patch 的時候根據這個標記位做對應的更新,防止被引入多次。

這個方案藉助了 babel plugin,在程式碼編譯階段自動匯入 ErrorBoundary 並批量元件包裹,核心程式碼:

const babelTemplate = require("@babel/template");
const t = require("babel-types");

const visitor = {
  Program: {
    // 在檔案頭部匯入 ErrorBoundary
    exit(path) {
      // string 程式碼轉換為 AST
      const impstm = template.default.ast(
        "import ErrorBoundary from '$components/ErrorBoundary'"
      );
      path.node.body.unshift(impstm);
    }
  },
  /**
   * 包裹 return jsxElement
   * @param {*} path
   */
  ReturnStatement(path) {
    const parentFunc = path.getFunctionParent();
    const oldJsx = path.node.argument;
    if (
      !oldJsx ||
      ((!parentFunc.node.key || parentFunc.node.key.name !== "render") &&
        oldJsx.type !== "JSXElement")
    ) {
      return;
    }

    // 建立被 ErrorBoundary 包裹之後的元件樹
    const openingElement = t.JSXOpeningElement(
      t.JSXIdentifier("ErrorBoundary")
    );
    const closingElement = t.JSXClosingElement(
      t.JSXIdentifier("ErrorBoundary")
    );
    const newJsx = t.JSXElement(openingElement, closingElement, oldJsx);

    // 插入新的 jxsElement, 並刪除舊的
    let newReturnStm = t.returnStatement(newJsx);
    path.remove();
    path.parent.body.push(newReturnStm);
  }
};
複製程式碼

此方案的核心是對子元件用自定義元件進行包裹,只不過這個自定義元件剛好是 ErrorBoundary。如果需要,自定義元件也可以是其他元件比如 log 等。

完整 GitHub 程式碼實現 這裡

雖然這種方式實現了錯誤的捕獲和兜底方案,但是非常複雜,用起來也麻煩,要配置 Webpack 和 .catch-react-error-config.json 還要執行腳手架,效果不令人滿意。

黃金時代 - Decorator

在上述方案出來之後,很長時間都找不到一個優雅的方案,要麼太難用(babelplugin), 要麼對於原始碼的改動太大(HOC), 能否有更優雅的實現。

於是就有了裝飾器 (Decorator) 的方案。

裝飾器方案的原始碼實現用了 TypeScript,使用的時候需要配合 Babel 的外掛轉為 ES 的版本,具體看下面的使用說明

TS 裡面提供了裝飾器工廠,類裝飾器,方法裝飾器,訪問器裝飾器,屬性裝飾器,引數裝飾器等多種方式,結合專案特點,我們用了類裝飾器。

類裝飾器介紹

類裝飾器在類宣告之前被宣告(緊靠著類宣告)。 類裝飾器應用於類建構函式,可以用來監視,修改或替換類定義。

下面是一個例子。

function SelfDriving(constructorFunction: Function) {
    console.log('-- decorator function invoked --');
    constructorFunction.prototype.selfDrivable = true;
}

@SelfDriving
class Car {
    private _make: string;
    constructor(make: string) {
        this._make = make;
    }
}
let car: Car = new Car("Nissan");
console.log(car);
console.log(`selfDriving: ${car['selfDrivable']}`);
複製程式碼

output:

-- decorator function invoked --
Car { _make: 'Nissan' }
selfDriving: true
複製程式碼

上面程式碼先執行了 SelfDriving 函式,然後 car 也獲得了 selfDrivable 屬性。

可以看到 Decorator 本質上是一個函式,也可以用@+函式名裝飾在類,方法等其他地方。 裝飾器可以改變類定義,獲取動態資料等。

完整的 TS 教程 Decorator 請參照 官方教程

於是我們的錯誤捕獲方案設計如下

@catchreacterror()
class Test extends React.Component {
  render() {
    return <Button text="click me" />;
  }
}
複製程式碼

catchreacterror 函式的引數為 ErrorBoundary 元件,使用者可以使用自定義的 ErrorBoundary,如果不傳遞則使用預設的 DefaultErrorBoundary 元件;

catchreacterror 核心程式碼如下:

import React, { Component, forwardRef } from "react";

const catchreacterror = (Boundary = DefaultErrorBoundary) => InnerComponent => {
  class WrapperComponent extends Component {
    render() {
      const { forwardedRef } = this.props;
      return (
        <Boundary>
          <InnerComponent {...this.props} ref={forwardedRef} />
        </Boundary>
      );
    }
  }
};
複製程式碼

返回值為一個 HOC,使用 ErrorBoundary 包裹子元件。

增加服務端渲染錯誤捕獲

介紹 裡面提到,對於服務端渲染,官方的 ErrorBoundary 並沒有支援,所以對於 SSR 我們用 try/catch 做了包裹:

  1. 先判斷是否是服務端 is_server
function is_server() {
  return !(typeof window !== "undefined" && window.document);
}
複製程式碼
  1. 包裹
if (is_server()) {
  const originalRender = InnerComponent.prototype.render;

  InnerComponent.prototype.render = function() {
    try {
      return originalRender.apply(this, arguments);
    } catch (error) {
      console.error(error);
      return <div>Something is Wrong</div>;
    }
  };
}
複製程式碼

最後,就形成了 catch-react-error 這個庫,方便大家捕獲 React 錯誤。

catch-react-error 使用說明

1. 安裝 catch-react-error

npm install catch-react-error
複製程式碼

2. 安裝 ES7 Decorator babel plugin

npm install --save-dev @babel/plugin-proposal-decorators
npm install --save-dev @babel/plugin-proposal-class-properties

複製程式碼

新增 babel plugin

{
  "plugins": [
    ["@babel/plugin-proposal-decorators", { "legacy": true }],
    ["@babel/plugin-proposal-class-properties", { "loose": true }]
  ]
}
複製程式碼

3. 匯入 catch-react-error

import catchreacterror from "catch-react-error";
複製程式碼

4. 使用 @catchreacterror Decorator

@catchreacterror()
class Test extends React.Component {
  render() {
    return <Button text="click me" />;
  }
}
複製程式碼

catchreacterror 函式接受一個引數:ErrorBoundary(不提供則預設採用 DefaultErrorBoundary)

5. 使用 @catchreacterror 處理 FunctionComponent

上面是對於ClassComponent做的處理,但是有些人喜歡用函式元件,這裡也提供使用方法,如下。

const Content = (props, b, c) => {
  return <div>{props.x.length}</div>;
};

const SafeContent = catchreacterror(DefaultErrorBoundary)(Content);

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <h1>這是正常展示內容</h1>
      </header>
      <SafeContent/>
    </div>
  );
}

複製程式碼

6. 如何建立自己所需的 Custom Error Boundaries

參考上面 如何建立一個 ErrorBoundary 元件, 然後改為自己所需即可,比如在 componentDidCatch 裡面上報錯誤等。

完整的 GitHub 程式碼在此 catch-react-error

本文釋出自 網易雲音樂前端團隊,文章未經授權禁止任何形式的轉載。我們一直在招人,如果你恰好準備換工作,又恰好喜歡雲音樂,那就 加入我們

相關文章