likes
comments
collection
share

深入源码,剖析 React 是如何做错误处理的

作者站长头像
站长
· 阅读数 6

DEV 模式下的错误处理

在 React 源码中,有段注释详细说明了 React 在 DEV 模式下是如何做错误处理的

// packages/shared/invokeGuardedCallbackImpl.js

if (__DEV__) {
  // In DEV mode, we swap out invokeGuardedCallback for a special version
  // that plays more nicely with the browser's DevTools. The idea is to preserve
  // "Pause on exceptions" behavior. Because React wraps all user-provided
  // functions in invokeGuardedCallback, and the production version of
  // invokeGuardedCallback uses a try-catch, all user exceptions are treated
  // like caught exceptions, and the DevTools won't pause unless the developer
  // takes the extra step of enabling pause on caught exceptions. This is
  // unintuitive, though, because even though React has caught the error, from
  // the developer's perspective, the error is uncaught.
  //
  // To preserve the expected "Pause on exceptions" behavior, we don't use a
  // try-catch in DEV. Instead, we synchronously dispatch a fake event to a fake
  // DOM node, and call the user-provided callback from inside an event handler
  // for that fake event. If the callback throws, the error is "captured" using
  // a global event handler. But because the error happens in a different
  // event loop context, it does not interrupt the normal program flow.
  // Effectively, this gives us try-catch behavior without actually using
  // try-catch. Neat!
}

本文的所有 React 源码均出自 React v17.0.2

这段注释主要有两层意思:

  1. 在 DEV 模式下,React 为了保持浏览器的 Pause on exceptions 行为,没有使用真实的 try-catch ,而是使用模拟的 try-catch 来处理异常,这样做的目的是 React 希望在开发(DEV)模式下,防止吞没用户代码的异常,从而方便用户排查代码中的异常

  2. React 通过派发(dispatchEvent)自定义事件(custom event),在派发自定义事件前会先创建 1 个自定义的 DOM 节点来监听该自定义事件,然后结合全局的错误事件监听(windowerror 事件)来模拟 try-catch 。当用户传进来的回调函数发生异常时,该异常会被全局错误事件处理函数“捕获”,因此它不会中断正常的程序流程,这与真实的 try-catch 的行为一致。

看到这里,也许有读者会有疑问,什么是浏览器的 Pause on exceptions 行为?

浏览器的 Pause on exceptions 行为其实就是浏览器在发生异常的代码处自动打断点的行为,可以方便用户找到发生了异常的代码。个人觉得浏览器 DevTools 的功能是非常有用的,特别是排查线上 bug 的时候。

深入源码,剖析 React 是如何做错误处理的

深入源码,剖析 React 是如何做错误处理的

React 在 DEV 模式下,通过自己模拟的 try-catch ,不仅捕获了异常,同时也保持了浏览器的 Pause on exceptions 行为,从而方便了用户开发的时候排查业务代码中的异常,这是个极好的设计👍 。我们在给他人提供工具库的时候,也可以借鉴这样的设计。

React 模拟 try-catch 的实现在 packages/shared/invokeGuardedCallbackImpl.js 文件中。

// packages/shared/invokeGuardedCallbackImpl.js

// 创建自定义的 DOM 节点
const fakeNode = document.createElement('react');
// 创建自定义事件
const evt = document.createEvent('Event');
const evtType = `react-${name ? name : 'invokeguardedcallback'}`;

// 注册全局异常监听器(window 的 error 事件),
// 捕获用户传入的回调函数发生的异常
window.addEventListener('error', handleWindowError);
// 注册自定义事件监听器,在自定义事件中调用用户提供的回调函数
fakeNode.addEventListener(evtType, callCallback, false);

// 初始化自定义事件
evt.initEvent(evtType, false, false);
// 派发自定义事件
fakeNode.dispatchEvent(evt);

// 移除全局异常监听器
window.removeEventListener('error', handleWindowError);

用户提供的所有回调函数都会包裹到 callCallback 中,然后传入到 invokeGuardedCallback 函数中执行。

在 DEV 模式下,React 会同步派发自定义事件(fake event),然后将用户传入的回调函数在自定义事件中执行,如果用户传入的回调函数发生异常,则会被全局错误事件(windowerror 事件)处理函数捕获。这样就实现了用户代码异常的捕获,但是却没有使用实际的 try-catch ,同时也保留了浏览器的 Pause on exceptions 的预期行为。

总之,要保留浏览器的 Pause on exceptions 预期行为,就要避免使用 try-catch 捕获错误。因此 React 使用自定义事件、自定义 DOM 和全局错误事件监听(windowerror)模拟了 tr-catch 异常捕获的机制。

全局错误处理函数 handleWindowError

全局错误处理函数 handleWindowError 的作用是捕获被抛出的异常,并记录相关信息。

// packages/shared/invokeGuardedCallbackImpl.js

let error;

let didSetError = false;
let isCrossOriginError = false;

function handleWindowError(event) {
  error = event.error;
  didSetError = true;
  // 判断是否为跨域异常
  if (error === null && event.colno === 0 && event.lineno === 0) {
    isCrossOriginError = true;
  }
  if (event.defaultPrevented) {
    if (error != null && typeof error === 'object') {
      try {
        error._suppressLogging = true;
      } catch (inner) {
        // Ignore.
      }
    }
  }
}
  • error ,用于保存被抛出的异常

  • didSetError ,用于记录全局错误处理函数(handleWindowError)是否调用过

  • isCrossOriginError ,用于记录是否为跨域异常

  • event.colno ,获取异常发生的列号(数字)

  • event.lineno,获取异常发生的行号(数字)

有关 event 对象更多的详细信息可查阅 Window: error event

error 为 null ,并且错误发生的列号和行号为 0 ,说明发生的是跨域异常。这种判断是否为跨域异常的方案在以后的开发中可以借鉴过来。

如果是跨域异常,浏览器会掩盖该异常的细节,React 无法记录到原始的错误信息,这是浏览器采用的安全防御措施,防止敏感信息泄漏导致的。具体可见 React 官网 ,因此 React 只会简单地告知用户,发生了跨域异常:

// packages/shared/invokeGuardedCallbackImpl.js

if (didCall && didError) {
  if (!didSetError) {
    // 省略其他代码
  } else if (isCrossOriginError) {
    // 告知用户发生了跨域异常
    error = new Error(
      "A cross-origin error was thrown. React doesn't have access to " +
        'the actual error object in development. ' +
        'See https://reactjs.org/link/crossorigin-error for more information.',
    );
  }
  // 记录异常信息
  this.onError(error);
}
  • event.defaultPrevented ,判断当前事件是否调用了 event.preventDefault() 方法。具体可见 event.defaultPrevented

window 对象的错误事件处理程序使用 e.preventDefault() 会阻止浏览器输出错误信息。如下面这个例子:

<button onclick="testAdd()">测试按钮</button>
function testAdd() {
  console.error('testAdd  ', data)
}

window.addEventListener('error', e => {
  console.error('错误信息 ', e)
});

当 window 的 error 事件(全局错误事件)没有调用 preventDefault 时,浏览器会正常输出错误信息。

深入源码,剖析 React 是如何做错误处理的

window.addEventListener('error', e => {
  e.preventDefault()
  console.error('错误信息 ', e)
});

当 window 的 error 事件(全局错误事件)调用 preventDefault 时,浏览器就不会输出错误信息了。

深入源码,剖析 React 是如何做错误处理的

React 的错误处理机制为了与浏览器此行为保持一致,在 error 对象中添加自定义的 _suppressLogging 属性,用于阻止输出错误信息。具有可见这个 Pr

// packages/shared/invokeGuardedCallbackImpl.js

if (event.defaultPrevented) {
  if (error != null && typeof error === 'object') {
    try {
      error._suppressLogging = true;
    } catch (inner) {
      // Ignore.
    }
  }
}

通过全局搜索 _suppressLogging 属性,也可以发现当 _suppressLogging 为 true 的时候,React 不会输出错误信息。

深入源码,剖析 React 是如何做错误处理的

执行用户传入的回调函数 (callCallback)

React 会将用户传入的回调函数包裹到 callCallback 函数中,最终 React 会派发自定义事件,执行 callCallback 函数。

fakeNode.addEventListener(evtType, callCallback, false);
evt.initEvent(evtType, false, false);
// 派发自定义事件,执行 callCallback
fakeNode.dispatchEvent(evt);

执行用户传入的回调函数(func)会先移除自定义事件(fake event)的监听,防止事件冲突。

invokeGuardedCallbackImpl = function invokeGuardedCallbackDev<
  A,
  B,
  C,
  D,
  E,
  F,
  Context,
>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,
  context: Context,
  a: A,
  b: B,
  c: C,
  d: D,
  e: E,
  f: F,  
) {
  // 省略其他代码

  // 记录是否调用了用户传入的回调函数(func)
  let didCall = false;
  // 记录执行用户传入的回调函数是否有异常
  let didError = true;

  // 缓存 window 的 event 对象
  const windowEvent = window.event;

  function restoreAfterDispatch() {
    // 在调用用户传入的回调函数(func)前会移除自定义事件(evtType)
    // 的监听,从而避免在嵌套的 invokeGuardedCallback 调用中发生冲突。
    // 否则,一个嵌套调用将触发任何更高层的自定义事件处理程序的调用在堆栈中,
    // 从而产生函数调用冲突。
    fakeNode.removeEventListener(evtType, callCallback, false);

    // 做浏览器兼容处理,因为 IE10 及以下的版本
    // 在严格模式下给 window.event 赋值会引发 "Member not found" 的错误,
    // 同时低版本的 Firefox 浏览器不支持 window 的 event 对象
    if (
      typeof window.event !== 'undefined' &&
      window.hasOwnProperty('event')
    ) {
      window.event = windowEvent;
    }
  }

  // 排除 name 、func、context,获取用户传入回调的入参
  const funcArgs = Array.prototype.slice.call(arguments, 3);
  function callCallback() {
    didCall = true;
    // 在执行用户传入的回调函数前,会先移除自定义事件的监听,避免调用冲突
    restoreAfterDispatch();
    // 调用用户传入的回调函数
    func.apply(context, funcArgs);
    // 代码能执行到这里,说明用户传入的回调函数没有异常,将 didError 置为 false
    didError = false;
  }
  // 自定义事件的名称
  const evtType = `react-${name ? name : 'invokeguardedcallback'}`;
}

invokeGuardedCallbackDev 函数入参的解释如下:

  • name ,用于创建自定义事件的名称,方便打印日志与调试。

  • func,用户传入的回调函数,即后续要执行的函数

  • context,用户传入的回调函数的执行上下文,即 this

  • 其他参数(a, b, c, d, e, f),是用户传入的回调函数的入参,会通过 arguments 对象获取,传入用户的回调函数中。

PROD 模式下的错误处理

// packages\shared\invokeGuardedCallbackImpl.js

// 封装 try catch ,处理生产环境的错误
function invokeGuardedCallbackProd<A, B, C, D, E, F, Context>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,
  context: Context,
  a: A,
  b: B,
  c: C,
  d: D,
  e: E,
  f: F,
) {
  const funcArgs = Array.prototype.slice.call(arguments, 3);
  try {
    func.apply(context, funcArgs);
  } catch (error) {
    this.onError(error);
  }
}

onError 函数来自 React 中定义的 reporter 对象

// packages/shared/ReactErrorUtils.js

const reporter = {
  onError(error: mixed) {
    hasError = true;
    caughtError = error;
  },
};

在 invokeGuardedCallback 函数中,会使用 apply 将 reporter 对象作为 invokeGuardedCallbackImpl 函数的 this

// packages/shared/ReactErrorUtils.js

/**
 * Call a function while guarding against errors that happens within it.
 * Returns an error if it throws, otherwise null.
 *
 * In production, this is implemented using a try-catch. The reason we don't
 * use a try-catch directly is so that we can swap out a different
 * implementation in DEV mode.
 *
 * @param {String} name of the guard to use for logging or debugging
 * @param {Function} func The function to invoke
 * @param {*} context The context to use when calling the function
 * @param {...*} args Arguments for function
 */
export function invokeGuardedCallback<A, B, C, D, E, F, Context>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,
  context: Context,
  a: A,
  b: B,
  c: C,
  d: D,
  e: E,
  f: F,
): void {
  hasError = false;
  caughtError = null;
  // 使用 apply 设置 invokeGuardedCallbackImpl 函数的 this 为 reporter 对象 
  invokeGuardedCallbackImpl.apply(reporter, arguments);
}

在 PROD 模式下 invokeGuardedCallbackImpl 是 invokeGuardedCallbackProd 函数,在 DEV 模式下是 invokeGuardedCallbackDev 函数

// packages/shared/invokeGuardedCallbackImpl.js

let invokeGuardedCallbackImpl = invokeGuardedCallbackProd;

if (__DEV__) {
  // 省略其他代码
  invokeGuardedCallbackImpl = function invokeGuardedCallbackDev<
    A,
    B,
    C,
    D,
    E,
    F,
    Context,
  >(
    name: string | null,
    func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,
    context: Context,
    a: A,
    b: B,
    c: C,
    d: D,
    e: E,
    f: F,
  ) {
    // 省略其他代码
  }
}

错误边界

React 的错误处理,肯定不能不说错误边界

错误边界是为了避免部分 UI 的 JavaScript 错误导致整个应用奔溃的问题而提出来的。错误边界是 React 的 class 组件,如果一个 class 组件中定义了 static getDerivedStateFromError()componentDidCatch(),那它就变成了一个错误边界。它可以捕获发生在其子组件树任何位置的 JavaScript 错误,并打印这些错误,同时展示降级 UI ,而并不会渲染那些发生崩溃的子组件树。错误边界可以捕获发生在整个子组件树的渲染期间生命周期方法以及构造函数中的错误。

当然错误边界也是有局限性的,错误边界无法捕获事件处理异步代码(例如 setTimeoutrequestAnimationFrame 回调函数)、服务端渲染它自身抛出来的错误(并非它的子组件)场景中产生的错误。所以,虽然 React 为我们的应用的稳定与健壮提供了各种保障,但是关键还得靠开发者自身也要写出稳定与健壮的代码。

throwException 函数中的 while 循环负责往上找错误边界( ErrorBoundary )组件。

// packages/react-reconciler/src/ReactFiberThrow.new.js

function throwException(
  root: FiberRoot,
  returnFiber: Fiber,
  sourceFiber: Fiber,
  value: mixed,
  rootRenderLanes: Lanes,
) {
  // 省略其他代码
  do {
    switch (workInProgress.tag) {
      // 省略其他代码
      case ClassComponent:
        const errorInfo = value;
        const ctor = workInProgress.type;
        const instance = workInProgress.stateNode;
        // 判断是否为 ErrorBoundary 组件
        if (
          (workInProgress.flags & DidCapture) === NoFlags &&
          (typeof ctor.getDerivedStateFromError === 'function' ||
            (instance !== null &&
              typeof instance.componentDidCatch === 'function' &&
              !isAlreadyFailedLegacyErrorBoundary(instance)))
        ) {
          workInProgress.flags |= ShouldCapture;
          const lane = pickArbitraryLane(rootRenderLanes);
          workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
          // Schedule the error boundary to re-render using updated state
          const update = createClassErrorUpdate(
            workInProgress,
            errorInfo,
            lane,
          );
          enqueueCapturedUpdate(workInProgress, update);
          return;
        }
        break;
      default:
        break;
    }
  } while (workInProgress !== null);
}

其中的判断条件,即为用于判断当前的 class 组件是否为错误边界组件。

// 判断是否定义了 static getDerivedStateFromError() 生命周期
typeof ctor.getDerivedStateFromError === 'function'
// 判断是否定义了 componentDidCatch() 生命周期
typeof instance.componentDidCatch === 'function'

React 的错误处理方式与 Vue3 错误处理的对比方式

React 的 PROD 模式的错误处理方式与 Vue3 的错误处理方式是一致的,即都是对 try catch 的封装。但是在 DEV 模式下,React 通过自己模拟的 try catch 在处理错误的同时,保持了浏览器 Pause on exceptions 的行为,但是 Vue3 采用的仍然是 try catch,因此没有保持浏览器 Pause on exceptions 的行为,从调试的方便程度来说,React 这一点比 Vue 考虑的更加周到。

看下面这个 Vue3 的例子,在浏览器中勾选 Source 面板中的 Pause on uncaught exceptions ,代码不会在发生异常处停下来。而 React 在 DEV 模式下使用的是模拟的 try catch ,因此会直接在用户的错误代码处停下来。

<script src="../../dist/vue.global.js"></script>

<div id="app">
  <div>数量 {{ count }}</div>
  <button @click="onAdd">增加</button>
</div>

<script>
Vue.createApp({
  setup () {
    const count = Vue.ref(0)
    function onAdd() {
      console.log("错误测试 ====== ", {
        a
      })
      count.value = count.value + 1
    }      
    return {
      count,
      onAdd
    }
  }
}).mount('#app')
</script>

深入源码,剖析 React 是如何做错误处理的

React 在 DEV 模式下,使用模拟的 try catch 处理错误,,在浏览器中勾选 Source 面板中的 Pause on uncaught exceptions 后,会直接在错误发生的代码处停下来。

深入源码,剖析 React 是如何做错误处理的

import React, { useState } from 'react';

function BuggyCounter() {
  const [count, setCount] = useState(0);
  const obj = {}
  const handleClick = () => {
    console.error('handleClick  == ', obj.person.name)
    setCount(count + 1)
  }
  if (count === 5) {
    throw new Error('I crashed!');
  }
  return <h1 onClick={handleClick}>{count}</h1>;
}

export default BuggyCounter;

不过,Vue3 为了不吞没用户代码的异常,会将捕获的到错误抛出。React 在 DEV 模式下由于没有使用 try catch ,代码发生错误时,浏览器会自动输出该错误信息,所以不需要手动抛出错误。

// packages/runtime-core/src/errorHandling.ts

function logError(
  err: unknown,
  type: ErrorTypes,
  contextVNode: VNode | null,
  throwInDev = true
) {
  if (__DEV__) {
    // 省略其他代码
    if (throwInDev) {
      // 将捕获到的错误抛出,避免吞没用户代码中的异常
      throw err
    } else if (!__TEST__) {
      console.error(err)
    }
  } else {
    // recover in prod to reduce the impact on end-user
    console.error(err)
  }
}

上面代码摘自 Vue.js 3.2.45

不过在事件处理函数、使用错误边界的情况下,React 会将用户代码中的错误重新抛出。在事件处理函数中,会重新抛出第一个错误

👇下面是在事件处理函数发生错误的例子

import { useEffect } from 'react';
import './App.css';

function App() {
  const obj = {}
  return (
    <div
      className="App"
      onClick={() => {
        console.log('b  ==  ', obj.person.name)
      }}
    >
      <div
        onClick={() => {
          console.log('a  ==  ', obj.person.age)
        }}
      >
        click me
      </div>
    </div>
  );
}

export default App;

深入源码,剖析 React 是如何做错误处理的

深入源码,剖析 React 是如何做错误处理的

👇下面是使用错误捕获的例子

App.js

import React from 'react';
import ErrorBoundary from './ErrorBoundary';
import BuggyCounter from './BuggyCounter';

import './App.css';

function App() {
  return (
  <ErrorBoundary>
    <BuggyCounter />
  </ErrorBoundary>
  )
}

export default App;

BuggyCounter.js

import React, { useState } from 'react';

function BuggyCounter() {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1)
  }
  if (count === 5) {
    throw new Error('I crashed!');
  }
  return <h1 onClick={handleClick}>{count}</h1>;
}

export default BuggyCounter;

ErrorBoundary.js

import React from 'react';

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

  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染能够显示降级后的 UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 你同样可以将错误日志上报给服务器
    console.error('componentDidCatch  == ', {
      error,
      errorInfo
    })
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children; 
  }
}

深入源码,剖析 React 是如何做错误处理的

如果错误边界中定义了 componentDidCatch 生命周期,则会将 React 重新抛出的错误捕获起来。

总结

React 在 DEV 模式下,为了保持浏览器的 Pause on exceptions 行为,让用户更好地排查代码中的错误,特意使用自定义事件和全局的错误事件监听模拟了 try catch 来处理错误,可以看出 React 团队为了开发者更好地调试代码,可谓煞费苦心。在 PROD 模式则是直接封装 try catch ,同时为了防止吞没用户代码中的错误,会将捕获到的错误重新抛出。

Vue3 无论在 DEV 模式还是在 PROD 模式都是直接封装了 try catch 。因此,从方便用户调试这点来说,React 是比 Vue 做得要好的。同时 Vue3 为了避免吞没用户代码的错误,也会将捕获到的错误抛出。可以看出,提供给用户使用的工具库不能吞没用户代码的错误是行业共识。

React 和 Vue3 都提供了让用户处理错误的全局 API ,React 中是错误边界,Vue3 中是 errorHandler

同时,React 和 Vue3 都提供了捕获组件错误的生命周期钩子函数,React 中是 componentDidCatch() ,Vue3 中是 onErrorCaptured()

通过对 React 、Vue3 错误处理相关的源码的学习,我们可以借鉴其中的一些设计思想,完善我们自己开发的工具库的错误处理方式:

  1. 在开发环境(DEV 模式)下,可以通过自定义事件和全局事件监听的方式来模拟 try catch ,在处理错误的同时保持浏览器的 Pause on exceptions 行为

  2. 工具库不应该吞没用户代码的错误

  3. 在生产环境(PROD 模式)下,可以直接封装 try catch 来你捕获用户代码的错误,但是为了不吞没用户代码的错误,应该将捕获的错误抛出

  4. 可以提供全局的 API ,让用户可以注册自定义的错误处理函数,方便用户处理或上报应用中的错误

参考

  1. 【React 源码系列】全网最详细的 React 异常捕获及处理机制

  2. 不用try catch,如何机智的捕获错误

  3. React中的错误处理

转载自:https://juejin.cn/post/7335359194751254567
评论
请登录