【從源碼分析】多是全網最實用的React Native異常解決方案【建議收藏】

前言

在作React Native混合開發時,生產環境有時會遇到打開RN(即React Native簡稱)應用白屏、RN頁面內操做閃退到native頁面或者直接致使APP Crash的狀況。經過分析APP日誌,發現緣由能夠歸類爲如下兩種:html

  1. js 層編譯運行時報錯。通常是因爲某些特殊的數據或情景緻使js執行報錯;
  2. js 轉譯 native UI 或與 native modules通訊時出現異常.

對於第一點,能夠很快地經過log追蹤到出現問題的js代碼並解決,可是對於第二點,每每是框架底層代碼執行報錯阻塞了UI渲染,報錯日誌信息沒法定位出哪裏出了問題,如:java

06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: com.facebook.react.common.c: Error: JS Functions are not convertible to dynamic
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: 
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: This error is located at:
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in u
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in Tile
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in Tile
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in TouchableWithoutFeedback
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in Unknown
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in h
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTScrollView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in u
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in v
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in f
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in h
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in AndroidHorizontalScrollContentView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in AndroidHorizontalScrollView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in u
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in v
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in f
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in n
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in inject-with-store(n)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in MobXProvider
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in I
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in RCTView
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     in c, stack:
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: <unknown>@-1
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@28:2227
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: <unknown>@19:1668
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Ci@89:62783
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: qi@89:66674
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: ea@89:69555
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: <unknown>@89:81296
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: unstable_runWithPriority@164:3238
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: ja@89:81253
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Oa@89:81007
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Wa@89:80310
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Aa@89:79323
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Ki@89:68624
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: Ki@-1
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: yt@89:21420
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: y@115:657
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: callTimers@115:2816
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@28:3311
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: <unknown>@28:822
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@28:2565
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@28:794
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime: value@-1
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.modules.core.ExceptionsManagerModule.showOrThrowError(ExceptionsManagerModule.java:54)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.modules.core.ExceptionsManagerModule.reportFatalException(ExceptionsManagerModule.java:38)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at java.lang.reflect.Method.invoke(Native Method)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.JavaMethodWrapper.invoke(JavaMethodWrapper.java:372)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.JavaModuleWrapper.invoke(JavaModuleWrapper.java:158)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.queue.NativeRunnable.run(Native Method)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at android.os.Handler.handleCallback(Handler.java:907)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at android.os.Handler.dispatchMessage(Handler.java:105)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage(MessageQueueThreadHandler.java:29)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at android.os.Looper.loop(Looper.java:216)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at com.facebook.react.bridge.queue.MessageQueueThreadImpl$4.run(MessageQueueThreadImpl.java:232)
06-17 17:59:49.482 10253 24147 24401 E AndroidRuntime:     at java.lang.Thread.run(Thread.java:784)

應用出現異常還不是最糟糕的,糟糕的是由於出現異常,帶給了用戶糟糕的體驗,儘管實際出現概率很是低。
咱們應該在出現異常時,經過降級UI(如web端常見的404頁面、"網絡開小差了,請稍後再試"彈窗)提示和安慰用戶,並引導用戶轉向正常頁面。
很遺憾,一般狀況下咱們如今並無這個主動權,一切異常處理都是由 React Native 框架本身完成的。所以,咱們要從React Native中接管異常處理權力來實現咱們本身的邏輯(相似 反轉控制反轉 思想)node

下面,將帶領你們一步步分析並實現。react

分析React Native 的紅屏/黃屏提示

不論是何種緣由致使RN應用異常,在開發模式環境(在發佈版 release/production中都是自動禁用的),默認狀況下都會以紅屏(red box)或黃屏(yellow box)方式全屏提示:android

請注意此文中,報錯和警告,都視爲異常

紅屏:
red box.png
黃屏:
yellow box2.pnges6

在官方描述中:web

### 紅屏錯誤

應用內的報錯會以全屏紅色顯示在應用中(調試模式下),咱們稱爲紅屏(red box)報錯。你可使用`console.error()`來手動觸發紅屏錯誤。

### 黃屏警告

應用內的警告會以全屏黃色顯示在應用中(調試模式下),咱們稱爲黃屏(yellow box)報錯。點擊警告能夠查看詳情或是忽略掉。和紅屏報警相似,你可使用`console.warn()`來手動觸發黃屏警告。

這2個全屏提示就是 React Native 對RN應用異常的處理。
那麼思路來了,咱們只須要找到 RN 彈出紅屏、黃屏的地方,並將之替換爲咱們本身的業務邏輯便可
示意圖以下:
接管RN異常處理邏輯.pngreact-native

OK,接下來咱們須要從源碼中去找到這個切入口,不要懼怕源碼,跟着個人思路,let's go!promise

從源碼上找出切入口

1.找出紅屏切入點

在上述紅屏圖片中,咱們經過 console.error('I am red box') 觸發了紅屏提示。在提示中打印出了錯誤棧追蹤信息:瀏覽器

console.error: "I am red box"
error
    
<unknown>
    C:\workspace\test_timer_picker\node_modules\react-native\Libraries\Renderer\oss\ReactFabric-prod.js:6808:9
_callTimer
    C:\workspace\test_timer_picker\node_modules\react-native\Libraries\Renderer\oss\ReactNativeRenderer-dev.js:8778:10
callTimers
    C:\workspace\test_timer_picker\node_modules\react-native\Libraries\Renderer\oss\ReactNativeRenderer-dev.js:9080:8
__callFunction
    
<unknown>
    
__guard
    C:\workspace\test_timer_picker\node_modules\react-native\Libraries\ART\ReactNativeART.js:169:9
callFunctionReturnFlushedQueue
    
callFunctionReturnFlushedQueue
    [native code]

其中,指出了錯誤出現的文件位置:

\node_modules\react-native\Libraries\Renderer\oss\ReactFabric-prod.js
\node_modules\react-native\Libraries\Renderer\oss\ReactNativeRenderer-dev.js
\node_modules\react-native\Libraries\ART\ReactNativeART.js

依次在這幾個文件中查詢 console.error,能夠在 ReactNativeRenderer-dev.js 文件中的showErrorDialog方法中找到這麼一段註釋:

ExceptionsManager.handleException(errorToHandle, false);
  // Return false here to prevent ReactFiberErrorLogger default behavior of
  // logging error details to console.error. Calls to console.error are
  // automatically routed to the native redbox controller, which we've already
  // done above by calling ExceptionsManager.

意思是「調用 console.error 會自動導航到 native 紅屏 controller」 ,再查看showErrorDialog方法的註釋:

/**
 * Intercept lifecycle errors and ensure they are shown with the correct stack
 * trace within the native redbox component.
 */
function showErrorDialog(capturedError) {/****/}

意思是「截獲生命週期錯誤,並確保在native redbox 組件中顯示正確的堆棧跟蹤」
Perfect,咱們根據錯誤棧信息一下找到了紅屏的緣由!
再仔細看這一句註釋:

//Calls to console.error are
  // automatically routed to the native redbox controller, which we've already
  // done above by calling ExceptionsManager.

「調用 console.error 會自動導航到 native 紅屏 controller的緣由,是咱們已經在上面調用了 ExceptionsManager」

那麼此時,咱們能夠想到,產生紅屏 === 由於 ExceptionsManager 作了什麼 咱們要作的是去將ExceptionsManager實現的邏輯替換成咱們本身的邏輯!

小提示: 源碼中仔細尋找 showErrorDialog()被調用的位置,你會找到 logCapturedError()以及更上層的 logError(),分析 logError(),你會發現,原來 React 中的 錯誤邊界能捕獲到組件渲染時錯誤也與之有關

ok,繼續看 ExceptionsManager.js,它的路徑爲:node_modules\react-native\Libraries\Core\ExceptionsManager.js,內容以下:

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 * @flow
 */

'use strict';

import type {ExtendedError} from 'parseErrorStack';

/**
 * Handles the developer-visible aspect of errors and exceptions
 */
let exceptionID = 0;
function reportException(e: ExtendedError, isFatal: boolean) {
  const {ExceptionsManager} = require('NativeModules');
  if (ExceptionsManager) {
    const parseErrorStack = require('parseErrorStack');
    const stack = parseErrorStack(e);
    const currentExceptionID = ++exceptionID;
    const message =
      e.jsEngine == null ? e.message : `${e.message}, js engine: ${e.jsEngine}`;
    if (isFatal) {
      ExceptionsManager.reportFatalException(
        message,
        stack,
        currentExceptionID,
      );
    } else {
      ExceptionsManager.reportSoftException(message, stack, currentExceptionID);
    }
    if (__DEV__) {
      const symbolicateStackTrace = require('symbolicateStackTrace');
      symbolicateStackTrace(stack)
        .then(prettyStack => {
          if (prettyStack) {
            ExceptionsManager.updateExceptionMessage(
              e.message,
              prettyStack,
              currentExceptionID,
            );
          } else {
            throw new Error('The stack is null');
          }
        })
        .catch(error =>
          console.warn('Unable to symbolicate stack trace: ' + error.message),
        );
    }
  }
}

declare var console: typeof console & {
  _errorOriginal: Function,
  reportErrorsAsExceptions: boolean,
};

/**
 * Logs exceptions to the (native) console and displays them
 */
function handleException(e: Error, isFatal: boolean) {
  // Workaround for reporting errors caused by `throw 'some string'`
  // Unfortunately there is no way to figure out the stacktrace in this
  // case, so if you ended up here trying to trace an error, look for
  // `throw '<error message>'` somewhere in your codebase.
  if (!e.message) {
    e = new Error(e);
  }
  if (console._errorOriginal) {
    console._errorOriginal(e.message);
  } else {
    console.error(e.message);
  }
  reportException(e, isFatal);
}

function reactConsoleErrorHandler() {
  console._errorOriginal.apply(console, arguments);
  if (!console.reportErrorsAsExceptions) {
    return;
  }

  if (arguments[0] && arguments[0].stack) {
    reportException(arguments[0], /* isFatal */ false);
  } else {
    const stringifySafe = require('stringifySafe');
    const str = Array.prototype.map.call(arguments, stringifySafe).join(', ');
    if (str.slice(0, 10) === '"Warning: ') {
      // React warnings use console.error so that a stack trace is shown, but
      // we don't (currently) want these to show a redbox
      // (Note: Logic duplicated in polyfills/console.js.)
      return;
    }
    const error: ExtendedError = new Error('console.error: ' + str);
    error.framesToPop = 1;
    reportException(error, /* isFatal */ false);
  }
}

/**
 * Shows a redbox with stacktrace for all console.error messages.  Disable by
 * setting `console.reportErrorsAsExceptions = false;` in your app.
 */
function installConsoleErrorReporter() {
  // Enable reportErrorsAsExceptions
  if (console._errorOriginal) {
    return; // already installed
  }
  // Flow doesn't like it when you set arbitrary values on a global object
  console._errorOriginal = console.error.bind(console);
  console.error = reactConsoleErrorHandler;
  if (console.reportErrorsAsExceptions === undefined) {
    // Individual apps can disable this
    // Flow doesn't like it when you set arbitrary values on a global object
    console.reportErrorsAsExceptions = true;
  }
}

module.exports = {handleException, installConsoleErrorReporter};

咱們經過語義良好的方法名以及清晰的註釋能夠了解到:
其暴露了2個方法:

  1. handleException —— 經過console.error() & reportException()處理凡是以throw '<error message>'方式拋出的異常;
  2. installConsoleErrorReporter —— 重載 console.error,只要是使用 console.error打印信息都會以「紅屏」的方式顯示錯誤堆棧信息。支持設置console.reportErrorsAsExceptions = false; 將此行爲關閉。

分析到這一步,能夠明顯地感受到,一切指向 console.error 方法!!

咱們繼續在 react native 源碼中進行查詢,找到installConsoleErrorReporter()方法在
node_modules\react-native\Libraries\Core\setUpErrorHandling.js 中被調用:

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow strict-local
 * @format
 */
'use strict';

/**
 * Sets up the console and exception handling (redbox) for React Native.
 * You can use this module directly, or just require InitializeCore.
 */
const ExceptionsManager = require('ExceptionsManager');
ExceptionsManager.installConsoleErrorReporter();

// Set up error handler
if (!global.__fbDisableExceptionsManager) {
  const handleError = (e, isFatal) => {
    try {
      ExceptionsManager.handleException(e, isFatal);
    } catch (ee) {
      console.log('Failed to print error: ', ee.message);
      throw e;
    }
  };

  const ErrorUtils = require('ErrorUtils');
  ErrorUtils.setGlobalHandler(handleError);
}

其註釋十分清晰地指出:「爲 React Native 設置 console 以及 異常處理(紅屏)」

其核心設置代碼是:

const ErrorUtils = require('ErrorUtils');
  ErrorUtils.setGlobalHandler(handleError); // 這就是咱們要找的切入點

這就是咱們要找的最終切入點,全部異常所有由ErrorUtils.setGlobalHandler的回調函數處理,只要將其設置爲咱們本身定義的回調函數就能從RN手中接過異常處理權了!!!
如:

global.ErrorUtils.setGlobalHandler(e=> {
      /*處理異常*/
      console.log('%c 處理異常 .....', 'font-size:12px;color:#869')
      console.log(e.message)
      // do something to handle exception
      //...
    })

Nice~,接下來咱們繼續尋找黃屏(yellow box)的緣由。


2.找出黃屏切入點

與紅屏報錯緣由不一樣,熟悉js開發的同窗應該知道,惟一能輸出警告信息的就是調用console.warn()。在上述的黃屏提示中,並無打印出棧追蹤信息,可是咱們能夠開啓debug模式(開發者菜單 -> Debug JS Remotely),能夠在控制檯看到更加詳細的棧追蹤信息:
yellow box stack.png

很明顯,黃屏提示是由YellowBox.js輸出的。
繼續查看 RN 源碼,找到其位置:node_modules\react-native\Libraries\YellowBox\YellowBox.js,內容以下:

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 * @format
 */

'use strict';

const React = require('React');

import type {Category} from 'YellowBoxCategory';
import type {Registry, Subscription} from 'YellowBoxRegistry';

type Props = $ReadOnly<{||}>;
type State = {|
  registry: ?Registry,
|};

let YellowBox;

/**
 * YellowBox displays warnings at the bottom of the screen.
 *
 * Warnings help guard against subtle yet significant issues that can impact the
 * quality of the app. This "in your face" style of warning allows developers to
 * notice and correct these issues as quickly as possible.
 *
 * YellowBox is only enabled in `__DEV__`. Set the following flag to disable it:
 *
 *   console.disableYellowBox = true;
 *
 * Ignore specific warnings by calling:
 *
 *   YellowBox.ignoreWarnings(['Warning: ...']);
 *
 * Strings supplied to `YellowBox.ignoreWarnings` only need to be a substring of
 * the ignored warning messages.
 */
if (__DEV__) {
  const Platform = require('Platform');
  const RCTLog = require('RCTLog');
  const YellowBoxList = require('YellowBoxList');
  const YellowBoxRegistry = require('YellowBoxRegistry');

  const {error, warn} = console;

  // eslint-disable-next-line no-shadow
  YellowBox = class YellowBox extends React.Component<Props, State> {
    static ignoreWarnings(patterns: $ReadOnlyArray<string>): void {
      YellowBoxRegistry.addIgnorePatterns(patterns);
    }

    static install(): void {
      (console: any).error = function(...args) {
        error.call(console, ...args);
        // Show YellowBox for the `warning` module.
        if (typeof args[0] === 'string' && args[0].startsWith('Warning: ')) {
          registerWarning(...args);
        }
      };

      (console: any).warn = function(...args) {
        warn.call(console, ...args);
        registerWarning(...args);
      };

      if ((console: any).disableYellowBox === true) {
        YellowBoxRegistry.setDisabled(true);
      }
      (Object.defineProperty: any)(console, 'disableYellowBox', {
        configurable: true,
        get: () => YellowBoxRegistry.isDisabled(),
        set: value => YellowBoxRegistry.setDisabled(value),
      });

      if (Platform.isTesting) {
        (console: any).disableYellowBox = true;
      }

      RCTLog.setWarningHandler((...args) => {
        registerWarning(...args);
      });
    }

    static uninstall(): void {
      (console: any).error = error;
      (console: any).warn = error;
      delete (console: any).disableYellowBox;
    }

    _subscription: ?Subscription;

    state = {
      registry: null,
    };

    render(): React.Node {
      // TODO: Ignore warnings that fire when rendering `YellowBox` itself.
      return this.state.registry == null ? null : (
        <YellowBoxList
          onDismiss={this._handleDismiss}
          onDismissAll={this._handleDismissAll}
          registry={this.state.registry}
        />
      );
    }

    componentDidMount(): void {
      this._subscription = YellowBoxRegistry.observe(registry => {
        this.setState({registry});
      });
    }

    componentWillUnmount(): void {
      if (this._subscription != null) {
        this._subscription.unsubscribe();
      }
    }

    _handleDismiss = (category: Category): void => {
      YellowBoxRegistry.delete(category);
    };

    _handleDismissAll(): void {
      YellowBoxRegistry.clear();
    }
  };

  const registerWarning = (...args): void => {
    YellowBoxRegistry.add({args, framesToPop: 2});
  };
} else {
  YellowBox = class extends React.Component<Props> {
    static ignoreWarnings(patterns: $ReadOnlyArray<string>): void {
      // Do nothing.
    }

    static install(): void {
      // Do nothing.
    }

    static uninstall(): void {
      // Do nothing.
    }

    render(): React.Node {
      return null;
    }
  };
}

module.exports = YellowBox;

它是一個 class 組件,大概邏輯是:「劫持宿主環境的console.warn,並將警告信息用原生 YellowBoxList渲染出來;同時也劫持console.error,將React環境中以error級別輸出的警告信息還原成warning級別的日誌(避免影響理解,這一點無需理會)」

這就是黃屏的切入點了,僅僅是將警告日誌以另外一種方式輸出而已,好像與咱們要作的事情無關,可是真的無關嗎?

時刻記住,應用的每個 error 和 warn 級別的日誌都不該該忽視,尤爲是warn級別的日誌!

讓咱們看下如下代碼:

// 模擬異步操做 多是請求、多是與native modules 方法通訊
  mockAsyncHandle = ()=>{
    return new Promise((resolve,reject)=>{
      // 執行異常
      throw new Error([1,2,3].toString())
    })
  }

  async componentDidMount(){
    const resp = await this.mockAsyncHandle() // 執行異常
    // 後續代碼不會再執行
    console.log(resp)
    // 使用 resp 去作業務處理,多是更新state 也多是某些操做的前提條件
    // ...
  }

這段代碼會觸發一個 yellow box 黃屏提示, warning 級別日誌以下:
unhandled Promise.png

有過Promise豐富使用經驗的同窗可能已經發現了,在這裏,throw new Error([1,2,3].toString()) 拋出的異常被吞掉了,代碼中依賴resp的邏輯所有會失敗,很是嚴重的異常!你可能想到鏈式調用Promise.prototye.catch()去處理拒絕狀態的Promise,可是假如catch處理函數中繼續拋出異常呢?這種現象在《你所不知道的JavaScript》書中被稱爲「絕望的陷阱」,與 try...catch 同樣,始終會吞掉最後的異常。

在 web 端,瀏覽器會自動追蹤內存使用狀況,經過垃圾回收機制處理這個 rejected Promise,而且提供unhandledrejection事件進行監聽。

那麼,在RN中,此類Promise異常怎麼處理呢?

查看源碼node_modules\react-native\Libraries\Promise.js 可知,RN擴展了ES6 Promise :

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 * @flow
 */

'use strict';

const Promise = require('promise/setimmediate/es6-extensions');
require('promise/setimmediate/done');

Promise.prototype.finally = function(onSettled) {
  return this.then(onSettled, onSettled);
};

if (__DEV__) {
  /* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses an
   * error found when Flow v0.54 was deployed. To see the error delete this
   * comment and run Flow. */
  require('promise/setimmediate/rejection-tracking').enable({
    allRejections: true,
    onUnhandled: (id, error = {}) => {
      let message: string;
      let stack: ?string;

      const stringValue = Object.prototype.toString.call(error);
      if (stringValue === '[object Error]') {
        message = Error.prototype.toString.call(error);
        stack = error.stack;
      } else {
        /* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses
         * an error found when Flow v0.54 was deployed. To see the error delete
         * this comment and run Flow. */
        message = require('pretty-format')(error);
      }

      const warning =
        `Possible Unhandled Promise Rejection (id: ${id}):\n` +
        `${message}\n` +
        (stack == null ? '' : stack);
      console.warn(warning);
    },
    onHandled: id => {
      const warning =
        `Promise Rejection Handled (id: ${id})\n` +
        'This means you can ignore any previous messages of the form ' +
        `"Possible Unhandled Promise Rejection (id: ${id}):"`;
      console.warn(warning);
    },
  });
}

module.exports = Promise;

RN 默認在開發環境下,經過promise/setimmediate/rejection-tracking去追蹤 rejected 狀態的Promise,並提供了onUnhandled回調函數處理未進行處理的 rejected Promise,其執行時機能夠在rejection-tracking.js中源碼中找到:

//...
timeout: setTimeout(
    onUnhandled.bind(null, promise._51),
    // For reference errors and type errors, this almost always
    // means the programmer made a mistake, so log them after just
    // 100ms
    // otherwise, wait 2 seconds to see if they get handled
    matchWhitelist(err, DEFAULT_WHITELIST)
      ? 100
      : 2000
  ),
//...

與錯誤處理相似,咱們只需將 onUnhandled回調函數替換成咱們自定義的Promise 異常處理邏輯就能從RN手中接管Promise異常處理了!!!

OK,經過分析源碼,咱們已經理清思路並知道應該如何作了,接下來動手實現吧。

完美的解決方案

方案:錯誤邊界 + ErrorUtils + promise rejection tracking

在前言中有提到:

咱們應該在出現異常時,經過降級UI(如web端常見的404頁面、"網絡開小差了,請稍後再試"彈窗)提示安慰用戶,並引導用戶轉向正常頁面。

例以下面的提示(demo):
subUI.png

有 React 開發經驗的同窗應該知道,React 16+ 提供了一個方案:錯誤邊界(Error Boundaries),完美地契合了咱們邏輯上的要求。
官方demo以下:

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; 
  }
}

可是錯誤邊界有如下缺陷:

錯誤邊界沒法捕獲如下場景中產生的錯誤:

  • 事件處理(瞭解更多
  • 異步代碼(例如 setTimeoutrequestAnimationFrame 回調函數)
  • 服務端渲染(RN中能夠忽略此條)
  • 它自身拋出來的錯誤(並不是它的子組件)

很幸運,經過咱們上述源碼的分析,咱們能夠在錯誤邊界中經過global.ErrorUtils.setGlobalHandler(callback)註冊RN錯誤處理回調函數以及設置rejection-tracking.jsonUnhandled函數來處理未處理的 rejected Promise.

來看看修改後的最終代碼,升級版錯誤邊界:

import React from 'react'
import PropTypes from 'prop-types'

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

    global.ErrorUtils.setGlobalHandler(e=> {
      /*你的異常處理邏輯*/
      console.log('%c 處理異常 .....', 'font-size:12px;color:#869')
      console.log(e.message)
      this.setState({
        hasError: true
      })
    })
    require('promise/setimmediate/rejection-tracking').enable({
      allRejections: true,
      onUnhandled: (id, error = {}) => {
        let message
        let stack
  
        const stringValue = Object.prototype.toString.call(error);
        if (stringValue === '[object Error]') {
          message = Error.prototype.toString.call(error);
          stack = error.stack;
        } else {
          /* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses
           * an error found when Flow v0.54 was deployed. To see the error delete
           * this comment and run Flow. */
          message = require('pretty-format')(error);
        }
  
        const warning =
          `Possible Unhandled Promise Rejection (id: ${id}):\n` +
          `${message}\n` +
          (stack == null ? '' : stack);
        console.warn(warning);
        // 更新 state 使下一次渲染可以顯示降級後的 UI
        this.setState({
          hasError: true
        })
      },
      onHandled: id => {
        const warning =
          `Promise Rejection Handled (id: ${id})\n` +
          'This means you can ignore any previous messages of the form ' +
          `"Possible Unhandled Promise Rejection (id: ${id}):"`;
        console.warn(warning);
      },
    });
  }

  static propTypes={
    //自定義降級後的 UI
    errorPage:PropTypes.element,
    //能夠根據本身的實際業務需求再增長其餘屬性,好比配置開發模式下是否要關閉紅屏/黃屏顯示
  }

  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染可以顯示降級後的 UI
    return { hasError: true }
  }

  componentDidCatch(error, errorInfo) {
    // 你一樣能夠將錯誤日誌上報給服務器
    console.log(error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      // 你能夠自定義降級後的 UI 並渲染
      return this.props.errorPage? this.props.errorPage:<h1>Something went wrong.</h1>
    }

    return this.props.children
  }
}
export default ErrorBoundary

使用方式與錯誤邊界使用方式相同,在組件樹最頂層,即包裹根組件使用:

//ErrorPage 是你自定義的降級顯示UI
<ErrorBoundary errorPage={<ErrorPage/>}>
  <App/>
</ErrorBoundary>

ErrorPage 是你自定義的降級顯示UI

完美,自此,RN應用中所用的異常所有由咱們本身掌控處理了!快去項目中試試吧

附註

本文中的 React Native 源碼分析,皆來自於 0.59.9 版本,但我也查閱分析了最新的 0.62.2 版本源碼,除了部分文件內容有新增之外,本文涉及的 API 均未發生破壞性更改,請放心食用。

另外,有消息稱 React Native 架構重構將於2020年第4季度,也就是今年完成,架構演變以下:
rn 架構重構.png

圖片來源於 React Native maintainer——Lorenzo S.

但願到時 React Native 能帶給咱們更好的開發與使用體驗!

FAQ

最後,回答幾個你們可能有的疑問:

  1. 爲何不用 try...catch?
    答: 沒法肯定哪一個代碼塊會出現異常,大量使用try...catch 會存在性能問題,而且它只能捕獲同步代碼中的異常,對於異步代碼中可能出現的異常一籌莫展;另外它也存在 「絕望的陷阱」 這一問題。
  2. ErrorUtils 能捕獲異步的異常嗎?
    答:能夠。只要是RN應用內拋出的異常都會被 ErrorUtils 捕獲。
  3. ErrorUtils 爲何不能捕獲Promise中的異常?
    答:由於對於JSC來講,此時並無發生錯誤,固然沒法被捕獲。咱們所說的 Promise 異常,實際上是Promise 設計缺陷致使一個 rejected Promise 一直未被處理,表現爲:異常被吞掉了。所以咱們須要定義onUnhandled進行處理。
  4. 可使用function component 來編寫錯誤邊界嗎?
    答:不能夠。錯誤邊界只能是 Class 組件。若是你想把 ErrorUtils 與 Promise 異常處理從錯誤邊界中剝離出來放到其餘函數式組件中也是能夠的,可是從組件化設計的角度來看的話,不推薦這樣作。

聲明

原創分享不易,以爲對你有所幫助的話,歡迎點贊收藏。轉載需經本人贊成,並附上思否原文連接。謝謝!