MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

React Hooks 时代如何实现错误边界

2022-06-011.8k 阅读

什么是错误边界

在 React 应用的开发过程中,错误处理是一个至关重要的环节。错误边界(Error Boundaries)是 React 16 引入的一项新特性,它提供了一种优雅的方式来捕获 JavaScript 错误,这些错误发生在组件树的渲染、生命周期方法以及构造函数中。错误边界本质上是一种 React 组件,它能够“捕获”其子组件树中抛出的错误,记录这些错误,并展示一个备用 UI,而不是让整个应用崩溃。

错误边界的作用范围

错误边界只能捕获其子组件树中的错误,对于自身组件内部的错误,例如在 render 方法、生命周期方法以及构造函数中抛出的错误,它是无法捕获的。另外,异步代码(如 setTimeoutPromise 中的错误)、事件处理函数中的错误,错误边界同样无法捕获。这是因为这些错误发生在 React 的正常渲染流程之外。

错误边界的实现方式

在类组件时代,实现错误边界相对较为直观。我们通过在类组件中定义 componentDidCatch 生命周期方法来捕获子组件树中的错误。

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

  componentDidCatch(error, errorInfo) {
    // 记录错误信息,可发送到日志服务
    console.log('Error caught:', error, errorInfo);
    this.setState({ hasError: true });
  }

  render() {
    if (this.state.hasError) {
      // 返回备用 UI
      return <div>Something went wrong.</div>;
    }
    return this.props.children;
  }
}

在上述代码中,ErrorBoundary 组件通过 componentDidCatch 方法捕获子组件树中的错误,并更新自身的 hasError 状态。当 hasErrortrue 时,渲染备用 UI。

React Hooks 时代的挑战

随着 React Hooks 的引入,函数式编程在 React 开发中变得更加流行。然而,传统的基于类组件的错误边界实现方式在函数组件中无法直接应用,因为函数组件没有生命周期方法。这就给 React Hooks 时代的错误处理带来了新的挑战。

函数组件中无生命周期方法

在函数组件中,我们无法像类组件那样直接定义 componentDidCatch 方法。因此,需要寻找一种新的方式来实现类似的错误捕获功能。

状态管理与副作用处理

在函数组件中,状态管理和副作用处理依赖于 useStateuseEffect 等 Hook。当错误发生时,如何在这些 Hook 的使用过程中进行合理的错误处理,也是一个需要解决的问题。例如,在 useEffect 中发起网络请求,如果请求失败,我们需要一种机制来捕获这个错误并进行处理。

实现 React Hooks 时代的错误边界

使用自定义 Hook

我们可以通过自定义 Hook 来实现错误边界的功能。这种方式允许我们在函数组件中复用错误处理逻辑。

import { useState, useEffect, useCallback } from'react';

function useErrorBoundary() {
  const [hasError, setHasError] = useState(false);
  const [error, setError] = useState(null);
  const [errorInfo, setErrorInfo] = useState(null);

  const errorHandler = useCallback((e, errorInfo) => {
    setError(e);
    setErrorInfo(errorInfo);
    setHasError(true);
    // 记录错误信息,可发送到日志服务
    console.log('Error caught:', e, errorInfo);
  }, []);

  useEffect(() => {
    const originalError = window.onerror;
    window.onerror = (message, source, lineno, colno, error) => {
      errorHandler(error, { message, source, lineno, colno });
      return originalError && originalError(message, source, lineno, colno, error);
    };
    return () => {
      window.onerror = originalError;
    };
  }, [errorHandler]);

  return { hasError, error, errorInfo, errorHandler };
}

function MyComponent() {
  const { hasError, error, errorInfo } = useErrorBoundary();

  if (hasError) {
    return (
      <div>
        <p>Something went wrong.</p>
        <p>{error && error.message}</p>
        <pre>{errorInfo && JSON.stringify(errorInfo, null, 2)}</pre>
      </div>
    );
  }

  return (
    <div>
      {/* 正常组件内容 */}
    </div>
  );
}

在上述代码中,useErrorBoundary 自定义 Hook 通过 useState 来管理错误状态,通过 useEffect 来设置全局的 window.onerror 捕获错误。errorHandler 函数用于处理错误并更新状态。在 MyComponent 中,根据 hasError 状态来决定是否渲染备用 UI。

高阶组件(HOC)结合自定义 Hook

我们还可以通过高阶组件(HOC)来进一步封装错误处理逻辑,使得错误边界的使用更加灵活和可复用。

import { useState, useEffect, useCallback } from'react';

function useErrorBoundary() {
  const [hasError, setHasError] = useState(false);
  const [error, setError] = useState(null);
  const [errorInfo, setErrorInfo] = useState(null);

  const errorHandler = useCallback((e, errorInfo) => {
    setError(e);
    setErrorInfo(errorInfo);
    setHasError(true);
    // 记录错误信息,可发送到日志服务
    console.log('Error caught:', e, errorInfo);
  }, []);

  useEffect(() => {
    const originalError = window.onerror;
    window.onerror = (message, source, lineno, colno, error) => {
      errorHandler(error, { message, source, lineno, colno });
      return originalError && originalError(message, source, lineno, colno, error);
    };
    return () => {
      window.onerror = originalError;
    };
  }, [errorHandler]);

  return { hasError, error, errorInfo, errorHandler };
}

function withErrorBoundary(WrappedComponent) {
  return function ErrorBoundaryComponent(props) {
    const { hasError, error, errorInfo } = useErrorBoundary();

    if (hasError) {
      return (
        <div>
          <p>Something went wrong.</p>
          <p>{error && error.message}</p>
          <pre>{errorInfo && JSON.stringify(errorInfo, null, 2)}</pre>
        </div>
      );
    }

    return <WrappedComponent {...props} />;
  };
}

function MyComponent() {
  return (
    <div>
      {/* 正常组件内容 */}
    </div>
  );
}

const EnhancedComponent = withErrorBoundary(MyComponent);

在上述代码中,withErrorBoundary 是一个高阶组件,它接受一个组件 WrappedComponent 作为参数,并返回一个新的组件 ErrorBoundaryComponent。在 ErrorBoundaryComponent 中,使用 useErrorBoundary 自定义 Hook 来处理错误,并根据错误状态决定是否渲染备用 UI 或包裹的组件。

错误边界与异步操作

在实际应用中,异步操作是很常见的,例如网络请求。我们需要确保在异步操作发生错误时,错误边界能够正确捕获并处理这些错误。

import { useState, useEffect, useCallback } from'react';

function useErrorBoundary() {
  const [hasError, setHasError] = useState(false);
  const [error, setError] = useState(null);
  const [errorInfo, setErrorInfo] = useState(null);

  const errorHandler = useCallback((e, errorInfo) => {
    setError(e);
    setErrorInfo(errorInfo);
    setHasError(true);
    // 记录错误信息,可发送到日志服务
    console.log('Error caught:', e, errorInfo);
  }, []);

  useEffect(() => {
    const originalError = window.onerror;
    window.onerror = (message, source, lineno, colno, error) => {
      errorHandler(error, { message, source, lineno, colno });
      return originalError && originalError(message, source, lineno, colno, error);
    };
    return () => {
      window.onerror = originalError;
    };
  }, [errorHandler]);

  return { hasError, error, errorInfo, errorHandler };
}

function MyComponent() {
  const { hasError, error, errorInfo } = useErrorBoundary();
  const [data, setData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://example.com/api/data');
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const result = await response.json();
        setData(result);
      } catch (e) {
        errorHandler(e, { message: 'Error fetching data' });
      }
    };
    fetchData();
  }, [errorHandler]);

  if (hasError) {
    return (
      <div>
        <p>Something went wrong.</p>
        <p>{error && error.message}</p>
        <pre>{errorInfo && JSON.stringify(errorInfo, null, 2)}</pre>
      </div>
    );
  }

  return (
    <div>
      {data? (
        <pre>{JSON.stringify(data, null, 2)}</pre>
      ) : (
        <p>Loading...</p>
      )}
    </div>
  );
}

在上述代码中,MyComponent 组件通过 useEffect 发起网络请求。在 fetchData 函数中,使用 try...catch 块来捕获异步操作中的错误,并调用 errorHandler 来处理错误。这样,即使在异步操作中发生错误,错误边界也能正常捕获并展示备用 UI。

错误边界的嵌套与组合

在大型应用中,组件结构往往比较复杂,可能存在多个错误边界嵌套或组合的情况。

嵌套错误边界

当存在嵌套的错误边界时,错误会优先被内层的错误边界捕获。如果内层错误边界没有处理该错误,错误会继续向上冒泡,直到被外层的错误边界捕获。

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

  componentDidCatch(error, errorInfo) {
    console.log('Inner Error Boundary caught:', error, errorInfo);
    this.setState({ hasError: true });
  }

  render() {
    if (this.state.hasError) {
      return <div>Inner error occurred.</div>;
    }
    return this.props.children;
  }
}

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

  componentDidCatch(error, errorInfo) {
    console.log('Outer Error Boundary caught:', error, errorInfo);
    this.setState({ hasError: true });
  }

  render() {
    if (this.state.hasError) {
      return <div>Outer error occurred.</div>;
    }
    return this.props.children;
  }
}

function ErrorProneComponent() {
  throw new Error('Simulated error');
  return <div>Content</div>;
}

function App() {
  return (
    <OuterErrorBoundary>
      <InnerErrorBoundary>
        <ErrorProneComponent />
      </InnerErrorBoundary>
    </OuterErrorBoundary>
  );
}

在上述代码中,ErrorProneComponent 抛出一个错误。首先,内层的 InnerErrorBoundary 会尝试捕获该错误,如果它没有处理(这里实际上处理了),错误会继续向上冒泡到 OuterErrorBoundary

组合错误边界

在 React Hooks 时代,通过自定义 Hook 和高阶组件,我们可以更灵活地组合错误边界。例如,我们可以创建多个不同功能的自定义 Hook 来处理不同类型的错误,然后在组件中组合使用这些 Hook。

function useNetworkErrorBoundary() {
  const [hasNetworkError, setHasNetworkError] = useState(false);
  const [networkError, setNetworkError] = useState(null);

  const networkErrorHandler = useCallback((e) => {
    setNetworkError(e);
    setHasNetworkError(true);
    // 记录网络错误信息,可发送到日志服务
    console.log('Network Error caught:', e);
  }, []);

  return { hasNetworkError, networkError, networkErrorHandler };
}

function useBusinessLogicErrorBoundary() {
  const [hasBusinessError, setHasBusinessError] = useState(false);
  const [businessError, setBusinessError] = useState(null);

  const businessErrorHandler = useCallback((e) => {
    setBusinessError(e);
    setHasBusinessError(true);
    // 记录业务逻辑错误信息,可发送到日志服务
    console.log('Business Logic Error caught:', e);
  }, []);

  return { hasBusinessError, businessError, businessErrorHandler };
}

function MyComplexComponent() {
  const { hasNetworkError, networkError, networkErrorHandler } = useNetworkErrorBoundary();
  const { hasBusinessError, businessError, businessErrorHandler } = useBusinessLogicErrorBoundary();

  useEffect(() => {
    // 模拟网络请求
    const fetchData = async () => {
      try {
        const response = await fetch('https://example.com/api/data');
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const result = await response.json();
        // 模拟业务逻辑错误
        if (!result.success) {
          throw new Error('Business logic error');
        }
      } catch (e) {
        if (e.message.includes('Network')) {
          networkErrorHandler(e);
        } else {
          businessErrorHandler(e);
        }
      }
    };
    fetchData();
  }, [networkErrorHandler, businessErrorHandler]);

  if (hasNetworkError) {
    return <div>Network error occurred: {networkError.message}</div>;
  }

  if (hasBusinessError) {
    return <div>Business logic error occurred: {businessError.message}</div>;
  }

  return <div>Loading...</div>;
}

在上述代码中,MyComplexComponent 组件组合使用了 useNetworkErrorBoundaryuseBusinessLogicErrorBoundary 两个自定义 Hook 来分别处理网络错误和业务逻辑错误。通过这种方式,我们可以更细粒度地管理不同类型的错误。

错误边界与测试

在开发过程中,对错误边界进行测试是确保应用稳定性的重要环节。

单元测试错误边界

对于基于类组件的错误边界,可以使用 Jest 和 React Testing Library 来进行单元测试。

import React from'react';
import { render, screen } from '@testing-library/react';
import ErrorBoundary from './ErrorBoundary';

describe('ErrorBoundary', () => {
  it('should display fallback UI when an error occurs', () => {
    const FallbackComponent = () => <div>Error fallback</div>;
    const ErrorComponent = () => { throw new Error('Test error'); };

    render(
      <ErrorBoundary>
        <ErrorComponent />
      </ErrorBoundary>
    );

    expect(screen.getByText('Error fallback')).toBeInTheDocument();
  });
});

在上述代码中,我们通过 render 函数渲染包含错误边界和会抛出错误的组件。然后使用 screen.getByText 来验证备用 UI 是否正确显示。

测试 React Hooks 中的错误边界

对于使用自定义 Hook 实现的错误边界,同样可以使用 Jest 和 React Testing Library 进行测试。

import React from'react';
import { render, screen } from '@testing-library/react';
import { useErrorBoundary } from './useErrorBoundary';

describe('useErrorBoundary', () => {
  it('should handle errors and display fallback UI', () => {
    const ErrorComponent = () => {
      const { hasError } = useErrorBoundary();
      if (hasError) {
        return <div>Error fallback</div>;
      }
      throw new Error('Test error');
    };

    render(<ErrorComponent />);

    expect(screen.getByText('Error fallback')).toBeInTheDocument();
  });
});

在上述代码中,我们测试 useErrorBoundary 自定义 Hook 在组件中捕获错误并显示备用 UI 的功能。通过模拟错误的抛出,验证备用 UI 是否正确渲染。

常见问题与解决方法

错误边界未捕获到错误

  1. 原因:错误发生在错误边界的作用范围之外,例如异步代码、事件处理函数中。另外,错误可能是从自身组件内部抛出,而不是子组件树。
  2. 解决方法:对于异步代码中的错误,可以在异步操作的 try...catch 块中手动调用错误处理函数。对于事件处理函数中的错误,需要在事件处理函数内部进行错误捕获。例如:
function MyComponent() {
  const { errorHandler } = useErrorBoundary();

  const handleClick = () => {
    try {
      // 事件处理逻辑
      throw new Error('Event handler error');
    } catch (e) {
      errorHandler(e, { message: 'Error in event handler' });
    }
  };

  return <button onClick={handleClick}>Click me</button>;
}

备用 UI 显示异常

  1. 原因:可能是备用 UI 的渲染逻辑存在问题,例如状态更新不正确,或者备用 UI 中存在其他未处理的错误。
  2. 解决方法:仔细检查备用 UI 的渲染逻辑,确保状态更新正确。可以通过添加日志输出或使用调试工具来排查备用 UI 中的错误。例如:
function ErrorBoundaryComponent() {
  const { hasError, error, errorInfo } = useErrorBoundary();

  if (hasError) {
    try {
      // 备用 UI 渲染逻辑
      return (
        <div>
          <p>Error: {error.message}</p>
          <pre>{JSON.stringify(errorInfo, null, 2)}</pre>
        </div>
      );
    } catch (e) {
      console.log('Error in fallback UI:', e);
      return <div>An error occurred while rendering fallback UI.</div>;
    }
  }

  return <div>Normal content</div>;
}

错误边界与性能

  1. 原因:频繁的错误捕获和备用 UI 渲染可能会对性能产生一定影响,特别是在复杂组件树中。
  2. 解决方法:尽量减少不必要的错误抛出,优化代码逻辑。同时,可以通过 memoization 技术(如 React.memouseMemo)来避免备用 UI 的不必要渲染。例如:
function FallbackUI({ error, errorInfo }) {
  return (
    <div>
      <p>Error: {error.message}</p>
      <pre>{JSON.stringify(errorInfo, null, 2)}</pre>
    </div>
  );
}

const MemoizedFallbackUI = React.memo(FallbackUI);

function ErrorBoundaryComponent() {
  const { hasError, error, errorInfo } = useErrorBoundary();

  if (hasError) {
    return <MemoizedFallbackUI error={error} errorInfo={errorInfo} />;
  }

  return <div>Normal content</div>;
}

通过 React.memo 对备用 UI 组件进行包裹,可以避免在 props 没有变化时的不必要渲染,从而提升性能。

总结

在 React Hooks 时代实现错误边界,我们需要借助自定义 Hook 和高阶组件等技术来模拟类组件中错误边界的功能。通过合理地处理错误,包括异步操作中的错误、嵌套和组合错误边界,以及进行有效的测试,可以提高 React 应用的稳定性和用户体验。同时,注意解决常见问题,避免性能问题,确保错误边界在应用中发挥良好的作用。掌握这些技术和方法,能够让我们在 React 开发中更好地应对各种错误情况,构建健壮的前端应用。