Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

# 构建鲁棒性:自定义 Eslint 规则增强 React 错误边界在前端应用中的控制力度 #5

Open
csu-feizao opened this issue Dec 21, 2023 · 0 comments

Comments

@csu-feizao
Copy link
Owner

构建鲁棒性:自定义 Eslint 规则增强 React 错误边界在前端应用中的控制力度

背景

在大型前端应用中,对于后端返回的非预期数据,常常出现难以预料的错误。前段时间我们出现了一次 p1 级别的故障,某个功能页面崩溃无法展示,经过排查发现是接口中的一个 string 类型字段返回了非预期的 null,而前端在代码中使用了字符串的替换函数 xxx.replace,导致代码报错。这种情况不仅严重影响了用户体验,还会影响打工人的绩效(money)。经过复盘,我们可以采取一些技术手段,提高系统的鲁棒性和稳定性,从而降低再次出现 p0p1 级别故障出现的概率。

错误边界

错误边界是 React 官方提供的一种错误处理的解决方案,它可以在组件树的某个层级捕获子组件的 JS 异常,并能够上报这些异常,同时展示降级 UI,从而有效地防止整个应用的崩溃。不过错误边界只能在类组件中使用,并且大部分情况遇到错误时处理逻辑都是一致的,因此常常会被封装成一个独立的组件来使用,例如:

class ErrorBoundary extends React.Component {
  state = {
    hasError: false,
  };
  getDerivedStateFromError(error) {
    return { hasError: true };
  }
  componentDidCatch(error, info) {
    reportError(error, info);
  }
  render() {
    const { fallback, children } = this.props;
    const { hasError } = this.state;
    return <>{hasError ? fallback : children}</>;
  }
}

不过单单封装组件并不够,因为这样使用只能捕获到 jsx 中子组件的异常,不能捕获当前组件本身的异常,当前组件的异常需要到父组件中去处理。而在父组件中处理时,如果是直接在 jsx 根节点捕获,例如下面代码,如果组件 A 出现异常,会导致组件 BC 都无法展示,扩大了异常的范围,这并不是我们所期望的:

<ErrorBoundary>
  <A />
  <B />
  <C />
</ErrorBoundary>

为了不扩大范围,代码则会变成下面这样,相信作为开发人员的你肯定无法忍受到处都是这样的代码:

<>
  <ErrorBoundary>
    <A />
  </ErrorBoundary>
  <ErrorBoundary>
    <B />
  </ErrorBoundary>
  <ErrorBoundary>
    <C />
  </ErrorBoundary>
</>

因此,我们可以对 ErrorBoundary 组件再简单封装一层高阶组件(hoc),可以分别对 ABC 三个子组件进行包裹:

// hoc
function errorBoundary(Component, fallback) {
  function Wrapper({ forwardedRef, ...others }) {
    return (
      <ErrorBoundary fallback={fallback}>
        <Component ref={forwardedRef} {...others} />
      </ErrorBoundary>
    );
  }
  return React.forwardRef((props, ref) => {
    return <Wrapper {...props} forwardedRef={ref} />;
  });
}

// A.tsx
function A() {
	...
}
export default errorBoundary(A)

捕获粒度

利用错误边界可以将错误尽可能地控制在较小的范围,但是这个粒度也不是越细越好。粒度太粗了,会导致比较大的模块出现崩溃,影响用户体验,例如我们之前采用页面组件级别的粒度,就导致了整个页面崩溃,产生 p1 级别故障。粒度太细了,则会导致组件树层级过深,影响页面性能,造成页面卡顿。那么谁来决定哪些组件需要捕获,哪些不需要呢?

Eslint 静态检查

对于上面的问题,我们认为应该由开发者和代码审核人员共同决定。这就需要提示开发者和审核者哪些组件用了错误边界,或者哪些组件没用。为了达到这个目的,我们可以编写一个 eslint 的自定义规则来检查代码,在提交代码和 review 时进行提醒。

实现

整体的思路是判断当前模块代码中有哪些 React 组件,遍历该组件以及该组件被引用的所有上层组件、高阶组件,判断是否有被 errorBoundary 高阶组件包裹。

判断 React 组件

React 组件大致有四种声明方式:

  1. 类声明,例如 class A extends Component {},对应的 ast 节点是 ClassDeclaration
  2. 函数声明,例如 function A() {},对应的 ast 节点是 FunctionDeclaration
  3. 函数表达式,例如 const A = function() {},对应的 ast 节点是 FunctionExpression
  4. 匿名函数表达式,例如 const A = () => {},对应的 ast 节点是 ArrowFunctionExpression

但不是所有的类声明、函数声明、函数表达式都是 React 组件,也有可能是普通的类或函数。这里实现的思路是通过一个标志变量,在进入JSXElement 节点时标记为 true。在每个声明节点时初始化设为 fasle,如果在节点内部含有 JSXElement 节点,则在声明节点出来时,这个标记就会为 true,那么它就是一个 React 组件。当然由于规范组件必须是大写字母开头,因此如果节点名是小写时,可以直接跳过。

create(context) {
    // 判断是否包含 JSXElement
    let hasJSXElement = false

    function componentIn(context) {
      const scope = context.getScope()
      // 只考虑在【模块作用域】上声明的类或函数
      if (scope.upper?.type !== 'module') {
        return
      }
      hasJSXElement = false
    }

    function componentOut(node, context, hasJSXElement) {
      const scope = context.getScope()
      // 只考虑在【模块作用域】上声明的类或函数
      if (scope.upper?.type !== 'module') {
        return
      }
      if (!hasJSXElement) {
        return
      }
      // 省略后续代码...
    }
    return {
      ClassDeclaration() {
        componentIn(context)
      },
      'ClassDeclaration:exit'(node) {
        componentOut(node, context, hasJSXElement)
      },
      FunctionDeclaration() {
        componentIn(context)
      },
      'FunctionDeclaration:exit'(node) {
        componentOut(node, context, hasJSXElement)
      },
      FunctionExpression() {
        componentIn(context)
      },
      'FunctionExpression:exit'(node) {
        componentOut(node, context, hasJSXElement)
      },
      ArrowFunctionExpression() {
        componentIn(context)
      },
      'ArrowFunctionExpression:exit'(node) {
        componentOut(node, context, hasJSXElement)
      },
      JSXElement() {
        hasJSXElement = true
      }
    }
  }

当然,如果是一些纯逻辑组件,也有可能返回值是 null,没有 JSXElement,这里我们倾向于在这些逻辑组件的父组件上进行错误捕获,因此跳过这种场景。

遍历引用节点

拿到这些类或函数组件节点后,我们可以遍历该节点的所有引用节点,判断是否有被 errorBoundary 包裹,若没有包裹则进行提醒:

function componentOut(node, context, hasJSXElement) {
  // 省略前置处理代码...
  // 记录类或函数声明的在【模块作用域】中的所有引用节点
  const references = node.id
    ? getDeclaredReferences(scope.upper, node.id.name)
    : [];
  // 判断这些引用节点是否有 errorBoundary 包裹
  const hasSome =
    hasErrorBoundary(node) || references.some((ref) => hasErrorBoundary(ref));
  if (!hasSome) {
    context.report({
      node,
      messageId: "withErrorBoundary",
      data: {
        name: node.id?.name || "匿名",
      },
    });
  }
}

function getDeclaredReferences(scope, name) {
  const references = [];
  scope.references.forEach((reference) => {
    if (reference.identifier?.name === name) {
      references.push(reference.identifier);
    }
  });
  return references;
}

判断是否被包裹

function hasErrorBoundary(node) {
  while (node.parent) {
    if (
      node.parent.type === "CallExpression" &&
      node.parent.callee.name === "errorBoundary"
    ) {
      return true;
    }
    node = node.parent;
  }
  return false;
}

接入 lintstaged

首先在 eslint 配置文件中加入规则。注意我们这里是为了提醒,并不是所有组件都需要包裹,不可直接报错,因此规则值使用 1

{
  "rules": {
    "component-with-errorboundary": 1
  }
}

然后在 lintstaged 配置文件中的 eslint 命令加上规则路径参数 --rulesdir ${errorBoundaryCheckRulePath} 既可。

至此,每当开发人员提交 commit 时,如果有没被错误边界包裹的组件,就会进行提醒,效果如下:

errorboundary

集成 CI 脚本

这里使用 node 命令脚本,接收 jenkins 调用时传递的文件路径列表,读取每个文件的源码字符串,传递给 eslint 进行检查,将检查的结果输出到日志中。最后 jenkins 将日志读取出来放在 pr 的页面进行展示。具体代码没有多少学习的价值,这里就不列了。

结语

通过自定义 eslint 规则,我们成功让开发者有意识地将错误的影响范围缩小,提高了系统的鲁棒性,有效降低了 p0p1 级别故障的概率。希望本次经验分享也能够对大家有所启发。

前文

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant