实现一个自动注入 ErrorBoundary 的 Babel 插件

37 阅读2分钟

实现一个自动注入 ErrorBoundary 的 Babel 插件

React 的 ErrorBoundary 很好用,但每个组件都手动包裹太繁琐。本文实现一个 Babel 插件,自动为组件注入错误边界。

痛点

React 16 引入了 ErrorBoundary,但使用起来很麻烦:

// 每个可能出错的组件都要手动包裹
<ErrorBoundary>
  <UserAvatar />
</ErrorBoundary>
<ErrorBoundary>
  <UserBio />
</ErrorBoundary>
<ErrorBoundary>
  <UserSettings />
</ErrorBoundary>

能不能自动化?

设计思路

通过 Babel 插件在编译时自动转换:

// 转换前
<UserBio />

// 转换后
const _ERROR_HOC_UserBio = withErrorHandler(UserBio);
<_ERROR_HOC_UserBio isCatchReactError />

核心步骤:

  1. 遍历 AST,找到所有 JSX 元素
  2. 过滤掉原生标签(div、span 等)
  3. 为自定义组件创建 HOC 包装
  4. 替换原 JSX 元素

实现

插件结构

module.exports = function ({ types: t }, config) {
  return {
    visitor: {
      Program(path) {
        // 在文件开头插入 import 语句
      },
      ReturnStatement(path) {
        // 遍历 return 中的 JSX
        path.traverse(customComponentVisitor);
      },
    },
  };
};

核心逻辑

const customComponentVisitor = {
  JSXElement(path) {
    const jsxName = path.node.openingElement.name.name;

    // 1. 过滤原生标签
    if (htmlTags.includes(jsxName)) return;

    // 2. 过滤已转换的组件
    if (jsxName.includes("_ERROR_HOC_")) return;

    // 3. 创建 HOC
    const HOC_NAME = createHoc(path, jsxName);

    // 4. 构建新的 JSX 元素
    const newJsx = t.JSXElement(
      t.JSXOpeningElement(t.JSXIdentifier(HOC_NAME), [
        t.jsxAttribute(t.jsxIdentifier("isCatchReactError")),
        ...path.node.openingElement.attributes,
      ]),
      t.JSXClosingElement(t.JSXIdentifier(HOC_NAME)),
      path.node.children
    );

    // 5. 替换
    path.replaceWith(newJsx);
  },
};

创建 HOC

const createHoc = (path, tagName) => {
  const hocName = `_ERROR_HOC_${tagName}`;
  
  // 找到组件所在作用域
  const scope = findJSXElementScope(path.scope, tagName);
  
  // 检查是否已转换
  if (scope.path.__transformInfo?.includes(tagName)) {
    return hocName;
  }

  // 生成 HOC 声明
  const hocNode = template.ast(
    `const ${hocName} = withErrorHandler(${tagName})`
  );

  // 插入到 return 语句前
  const body = scope.path.node.body;
  if (body.type === "BlockStatement") {
    body.body.splice(body.body.length - 1, 0, hocNode);
  }

  // 记录已转换
  scope.path.__transformInfo = scope.path.__transformInfo || [];
  scope.path.__transformInfo.push(tagName);

  return hocName;
};

作用域查找

const findJSXElementScope = (scope, variableName) => {
  if (scope.hasOwnBinding(variableName)) {
    return scope;
  }
  if (scope.parent) {
    return findJSXElementScope(scope.parent, variableName);
  }
  return null;
};

配置使用

webpack 配置

// webpack.config.js
{
  test: /\.(js|jsx|ts|tsx)$/,
  loader: "babel-loader",
  options: {
    plugins: [
      [
        require.resolve("./config/plugins/auto-log-plugin"),
        {
          imports: `import withErrorHandler from "@/components/hoc/withErrorHandler"`,
          errorHandleComponent: "withErrorHandler",
          ignore: ["WrappedComponent", "ReactErrorBoundary"],
          risks: "all",
        },
      ],
    ],
  },
}

配置项说明

选项类型默认值说明
importsstring-HOC 的 import 语句
errorHandleComponentstring-HOC 函数名
ignorestring[][]忽略的组件名列表
risks"all" | string[]"all"需要包裹的组件,"all" 表示全部

HOC 实现

// withErrorHandler.js
import { ErrorBoundary } from "react-error-boundary";

const ErrorFallback = ({ error, resetErrorBoundary }) => (
  <div role="alert">
    <h2>出错了</h2>
    <p>{error.message}</p>
    <button onClick={resetErrorBoundary}>重试</button>
  </div>
);

export default (WrappedComponent) => (props) => (
  <ErrorBoundary
    FallbackComponent={ErrorFallback}
    onError={(error) => console.error("Error:", error)}
  >
    <WrappedComponent {...props} />
  </ErrorBoundary>
);

转换示例

转换前

function UserInfo() {
  return (
    <div>
      <UserAvatar />
      <UserBio />
    </div>
  );
}

转换后

import withErrorHandler from "@/components/hoc/withErrorHandler";

function UserInfo() {
  const _ERROR_HOC_UserAvatar = withErrorHandler(UserAvatar);
  const _ERROR_HOC_UserBio = withErrorHandler(UserBio);
  
  return (
    <div>
      <_ERROR_HOC_UserAvatar isCatchReactError />
      <_ERROR_HOC_UserBio isCatchReactError />
    </div>
  );
}

测试效果

创建一个会随机抛错的组件:

// useErrorSimulator.js
const useErrorSimulator = ({ probability = 0.5, message = "Error" }) => {
  useEffect(() => {
    if (Math.random() < probability) {
      throw new Error(message);
    }
  }, []);
};

// UserBio.js
const UserBio = () => {
  useErrorSimulator({ probability: 0.5 });
  return <p>User biography...</p>;
};

运行后,UserBio 出错时会显示 ErrorFallback,而不会导致整个页面崩溃。

局限性

  1. 只处理渲染时错误:事件处理器中的错误需要 try-catch
  2. 性能开销:每个组件都包裹 HOC 会增加组件层级
  3. 调试困难:组件名变成 _ERROR_HOC_xxx,React DevTools 中不太直观

优化方向

  1. 按需包裹:通过 risks 配置只包裹高风险组件
  2. 生产环境关闭:开发环境启用,生产环境禁用
  3. 错误上报:集成 Sentry 等监控服务

总结

这个 Babel 插件展示了 AST 转换的强大能力。核心思路:

  1. 遍历 JSX 元素
  2. 过滤原生标签
  3. 创建 HOC 包装
  4. 替换原元素

完整代码见 react-debug/error_boundary 分支。


📦 项目地址:github.com/220529/reac…(error_boundary 分支)

系列文章:React 源码调试环境搭建