捕獲 React 異常

此項目爲雲音樂營收組穩定性工程的前端部分,本文做者 章偉東,項目其餘參與者趙祥濤javascript

一個 bug 引起的血案

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

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 剛好也是一個變量,這是一個純粹的邏輯錯誤。java

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

ErrorBoundary 介紹

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

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 crashedgit

這個特性讓咱們眼前一亮,精神爲之振奮,彷彿在黑暗中看到了一絲亮光。可是通過研究發現,ErrorBoundary 只能捕獲子組件的 render 錯誤,有必定的侷限性,如下是沒法處理的狀況:github

  • 事件處理函數(好比 onClick,onMouseEnter)
  • 異步代碼(如 requestAnimationFrame,setTimeout,promise)
  • 服務端渲染
  • ErrorBoundary 組件自己的錯誤。

如何建立一個 ErrorBoundary 組件

只要在 React.Component 組件裏面添加 static getDerivedStateFromError() 或者 componentDidCatch() 便可。前者在錯誤發生時進行降級處理,後面一個函數主要是作日誌記錄,官方代碼 以下web

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。npm

至此一個 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

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

簡而言之分下面幾步:

  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

本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們

相關文章
相關標籤/搜索