React 依赖项分析工具:请设计一个类似 eslint-plugin-react-hooks 的工具,其核心 AST 匹配算法该如何设计?

大家好,欢迎来到今天的“React 内部解剖课”。我是你们的向导,今天我们要干一件稍微有点“变态”的事情:我们要给 React 的 Hooks 也就是那些 useStateuseEffect 们戴上手铐,给它们制定一套严格的“行为准则”。

为什么?因为 React Hooks 是个很聪明的家伙,但如果你喂给它错误的逻辑,它就会给你吐出 Bug。我们要设计的这个工具,就是那个拿着鞭子的监工。

我们的目标是打造一个类似 eslint-plugin-react-hooks 的东西。核心是什么?是 AST(抽象语法树)匹配算法。别被这个词吓到了,AST 就是代码的“尸体解剖图”。在计算机眼里,代码不是一行行文字,而是一棵巨大的、长满节点的树。

好了,废话不多说,让我们开始解剖。


第一部分:AST 是什么?为什么我们需要它?

想象一下,你写了一行代码:
const x = 1 + 2;

在人类眼里,这是赋值。但在计算机眼里,这是树:

  • 根节点VariableDeclaration(变量声明)
    • 子节点VariableDeclarator(变量解释器)
      • 子节点Identifier(变量名 x)
      • 子节点BinaryExpression(二元运算)
        • 子节点NumericLiteral(数字 1)
        • 子节点BinaryExpression(加法)
          • 子节点NumericLiteral(数字 2)

我们的算法,就是在这棵树上爬来爬去,拿着放大镜找 Hook 的踪迹。

核心数据结构:Visitor 模式

我们要用的核心工具是 Babel 的 traverse。它是一个 Visitor,意思就是“访客”。你告诉它:“嘿,如果我在树上看到一个 CallExpression(函数调用),就跑过来告诉我。”

这是最基础的骨架:

// 这是一个非常简陋的 Hook 扫描仪
function scanHooks(ast) {
  const hooks = [];

  // traverse 是 Babel 提供的遍历工具
  traverse(ast, {
    // 当我们遇到一个函数调用表达式时
    CallExpression(path) {
      // 检查这个调用的名字是不是以 'use' 开头
      if (path.node.callee.type === 'Identifier' && 
          path.node.callee.name.startsWith('use')) {

        // 找到了!把它记录下来
        hooks.push({
          name: path.node.callee.name,
          node: path.node,
          // 还可以看看这个 Hook 调用了几个参数
          arguments: path.node.arguments.map(arg => arg.type)
        });
      }
    }
  });

  return hooks;
}

这段代码非常简单,但它已经能抓到大部分 Hook 了。但是,如果你只是抓到它们,那你的工具就像个只会数数的傻瓜。我们的目标是 分析 它们。


第二部分:依赖项分析——闭包的噩梦

这是最难的部分,也是最核心的部分。useEffect 有个依赖数组:[count, name]。你的算法必须能读懂这行代码,并告诉你:“嘿,这个 useEffect 里面用到了 count,但是数组里没写 count,警告!”

这涉及到 变量作用域 的分析。

1. 捕捉上下文

当一个 Hook 被调用时,它就像一个被扔进真空包装袋的汉堡。它需要知道它周围有哪些食材。在 AST 遍历中,我们维护一个 上下文栈

当遍历进入一个 FunctionDeclaration(函数声明)或 ArrowFunctionExpression(箭头函数)时,我们就进入了一个新的作用域。

const scopes = new Map(); // 存储每个函数的作用域信息

traverse(ast, {
  FunctionDeclaration(path) {
    const funcName = path.node.id ? path.node.id.name : 'anonymous';
    const scope = path.scope; // Babel 自带的作用域分析工具
    scopes.set(funcName, scope);
  }
});

2. 变量收集器

现在,当我们的 Visitor 看到某个 Hook(比如 useEffect)时,我们需要扫描它的 body(函数体),看看它到底引用了哪些变量。

这里有个技巧:闭包分析。如果 Hook 内部调用了另一个函数,那个函数内部引用的变量,也算 Hook 的依赖。

// 这是一个高级一点的 Visitor
const hookVisitors = {
  // 当我们遇到 useEffect(() => { ... }, []) 时
  CallExpression(path) {
    const calleeName = path.node.callee.name;
    if (calleeName === 'useEffect') {
      const [callbackNode, depsNode] = path.node.arguments;

      // 1. 分析回调函数
      if (callbackNode && callbackNode.type === 'ArrowFunctionExpression') {
        const dependencies = collectDependencies(callbackNode.body, path.scope);
        console.log(`useEffect 依赖了:`, dependencies);
      }

      // 2. 分析依赖数组
      if (depsNode && depsNode.type === 'ArrayExpression') {
        const arrayDeps = depsNode.elements.map(el => {
           // 这里需要把 AST 节点转成字符串,比如 'count' 或 '() => {}'
           return generate(el).code;
        });
        console.log('依赖数组声明了:', arrayDeps);
      }
    }
  }
};

// 辅助函数:递归收集函数体内用到的变量
function collectDependencies(bodyNode, currentScope) {
  const deps = new Set();
  traverse(bodyNode, {
    Identifier(path) {
      // 忽略 this
      if (path.isReferencedIdentifier()) {
        // 检查这个变量是否在当前作用域定义
        // 如果不在当前作用域,说明它是外部变量(比如 props)
        if (path.scope.hasOwnBinding(path.node.name)) {
          deps.add(path.node.name);
        }
      }
    }
  });
  return deps;
}

3. 陷阱:箭头函数里的变量

看这段代码:

useEffect(() => {
  console.log(count); // 这里用到了 count
}, []);

我们的 collectDependencies 会找到 count 吗?会的。因为箭头函数是一个独立的代码块,traverse 会深入进去。

但是,看这段代码:

const handleClick = () => {
  console.log(count);
};

useEffect(() => {
  handleClick();
}, []); // 错误!这里没警告,但 handleClick 内部用到了 count

这就麻烦了。handleClick 是在外部定义的。我们的算法需要做 变量逃逸分析。我们需要知道 handleClick 这个函数内部引用了什么。

这是一个深坑。你需要遍历 handleClick 的函数体,收集它的依赖,然后把那些依赖也加到 useEffect 的依赖数组里。

这就像是侦探破案,你必须在 Hook 调用之前,先去把它的“同伙”审问一遍。


第三部分:顺序规则——不要在条件语句里调用 Hook

React Hooks 有一个铁律:Hooks 的调用顺序必须在每次渲染时保持一致。

如果你写:

function Component() {
  if (condition) {
    useState(1); // 第一次渲染执行
  }
  useEffect(() => {}); // 第二次渲染不执行
  // 结果:第二次渲染少了一个 Hook,React 就会懵逼,不知道哪个 State 对应哪个 Hook。
}

我们的算法要怎么发现这个?

1. 追踪 Hook 的“指纹”

我们需要给每个 Hook 分配一个唯一的 ID,或者直接利用它在代码中的位置。

当遍历器进入一个函数体时,它会维护一个 hookStack

const hookStack = [];

traverse(ast, {
  FunctionDeclaration(path) {
    // 每次进入一个函数,重置栈
    // 注意:这里要处理递归函数,或者嵌套函数
    // 简单起见,我们假设每次渲染就是一个新的函数执行
    hookStack.length = 0; 
  },

  CallExpression(path) {
    const calleeName = path.node.callee.name;
    if (calleeName.startsWith('use')) {
      // 获取当前 Hook 在栈中的索引
      const hookIndex = hookStack.length;

      // 如果这个 Hook 之前出现过,那就报错
      // 我们怎么知道之前出现过?
      // 我们需要给 path.node 一个唯一的标记,或者检查它是否在同一个父作用域下被调用过多次
      // 更好的做法是:检查是否在同一个函数体内,被同一个变量名调用过两次

      // 这里有个简化逻辑:检查是否在同一个父级作用域块中被多次调用
      // 实际上,React 的规则是“同一个组件的渲染中,调用顺序必须一致”
      // 所以我们需要跨调用栈检查

      hookStack.push({
        name: calleeName,
        index: hookIndex,
        node: path.node
      });
    }
  }
});

2. 处理嵌套函数和条件语句

上面的逻辑太简单了,因为它无法处理嵌套函数。

function Component() {
  function inner() {
    useState(1); // 嵌套调用
  }
  inner();
}

React 的规则也禁止在嵌套函数中调用 Hook。我们的 AST 遍历器需要记录“深度”。

let currentDepth = 0;

traverse(ast, {
  // 进入函数声明,深度 +1
  FunctionDeclaration: { enter: () => currentDepth++ },
  FunctionDeclaration: { exit: () => currentDepth-- },
  // 进入箭头函数
  ArrowFunctionExpression: { enter: () => currentDepth++ },
  ArrowFunctionExpression: { exit: () => currentDepth-- },

  CallExpression(path) {
    if (currentDepth > 0) {
      // 如果当前深度大于0(说明在函数里),并且调用了 Hook
      // 这通常是非法的,除非你在自定义 Hook 里
      // 但对于普通组件,这就是违规的
      path.report({
        message: "Hooks should not be called inside nested functions."
      });
    }
  }
});

第四部分:高级模式——自定义 Hooks 和 HOCs

现在我们已经能抓到普通 Hook 了,但现实世界更复杂。你会看到 useCustomHook(),或者 withAuth(Component)(<App />)

1. 自定义 Hooks

自定义 Hook 只是名字以 use 开头,并且返回值通常是某个 Hook 的函数。我们的基础扫描器其实已经能识别它们了。

但是,我们要检查自定义 Hook 的内部逻辑吗?比如 useMyHook 内部调用了 useEffect,那么调用 useMyHook 的地方,也需要关注它的依赖。

这需要 调用图分析

// 假设我们扫描到了 useMyHook
const hookCall = path.node;

// 我们需要找到这个函数的定义
// path.scope.getBinding('useMyHook') 可以获取这个变量名绑定的所有声明
const binding = path.scope.getBinding('useMyHook');

if (binding) {
  // binding.path 是这个函数定义的 AST 节点
  const funcDef = binding.path.node;

  // 递归分析这个函数体!
  traverse(funcDef.body, {
    CallExpression(path) {
      // 如果发现它调用了 React Hooks
      if (path.node.callee.name.startsWith('use')) {
        // 报告!自定义 Hook 内部调用了 Hook,这通常是错误的(除非你写了文档说明)
        // 或者,我们需要把它的依赖传递给调用者
      }
    }
  });
}

2. HOCs(高阶组件)

withAuth(Component) 这种东西,把 React 组件包装了一下。你的算法怎么知道它里面调用了 useState

AST 告诉你:withAuth(Component) 是一个 CallExpression,它的参数是一个 Identifier。这个 Identifier 是一个函数组件。

所以,我们需要递归地分析参数。

traverse(ast, {
  CallExpression(path) {
    // 找到被包装的组件
    if (path.node.callee.name === 'withAuth') {
      const componentArg = path.node.arguments[0];

      // 如果参数是一个组件定义(比如一个函数声明)
      if (componentArg.type === 'FunctionDeclaration') {
        // 进入这个组件的函数体
        traverse(componentArg.body, {
          CallExpression(path) {
            // 扫描它里面的 Hooks
            if (path.node.callee.name.startsWith('use')) {
              path.report({ message: "Hooks inside HOC wrapped component might not trigger updates." });
            }
          }
        });
      }
    }
  }
});

第五部分:构建一个完整的插件——算法的整合

好了,现在我们有了所有碎片:识别 Hook、收集依赖、检查顺序、分析 HOC。现在把它们拼起来。

一个完整的 eslint-plugin-react-hooks 插件结构大概是这样的:

module.exports = {
  rules: {
    // 规则 1:依赖项检查
    'exhaustive-deps': {
      meta: {
        type: 'suggestion',
        docs: {
          description: 'Checks if dependencies of useEffect/useCallback are correct'
        }
      },
      create(context) {
        return {
          // 每次 FunctionDeclaration 进入时,初始化分析器
          FunctionDeclaration(path) {
            // 我们需要一个分析器实例
            const analyzer = new DependencyAnalyzer(context, path);

            // 开始遍历函数体
            traverse(path.node.body, {
              CallExpression(path) {
                analyzer.analyzeHookCall(path);
              }
            });
          }
        };
      }
    },

    // 规则 2:顺序检查
    'rules-of-hooks': {
      meta: { type: 'problem' },
      create(context) {
        return {
          Program() {
            // 这里通常用于全局检查,或者结合组件函数
          },
          FunctionDeclaration(path) {
            // 重置
          },
          CallExpression(path) {
             // 检查是否在条件语句中
             if (path.findParent(p => p.isIfStatement())) {
               path.report({ message: "Hooks should not be called inside conditions." });
             }
          }
        };
      }
    }
  }
};

DependencyAnalyzer 类的设计

这是核心引擎。它需要持有上下文,并且能够递归地查看变量。

class DependencyAnalyzer {
  constructor(context, functionPath) {
    this.context = context;
    this.scope = functionPath.scope;
    this.usedVariables = new Set();
  }

  analyzeHookCall(path) {
    const calleeName = path.node.callee.name;

    if (calleeName === 'useEffect' || calleeName === 'useCallback') {
      // 1. 提取依赖数组
      const depsArray = path.node.arguments[1];
      const declaredDeps = this.extractArrayElements(depsArray);

      // 2. 提取回调函数
      const callback = path.node.arguments[0];

      // 3. 收集回调函数实际用到的变量
      const actualDeps = this.collectDependenciesFromNode(callback.body);

      // 4. 对比
      const missing = this.findMissing(declaredDeps, actualDeps);

      if (missing.size > 0) {
        this.context.report({
          node: path.node,
          message: `Missing dependencies: ${Array.from(missing).join(', ')}`,
          // 还可以提供自动修复建议
          fix(fixer) {
             // 这部分需要生成代码字符串,比较复杂
             return fixer.insertTextAfter(path.node.arguments[1], `, ${Array.from(missing).join(', ')}`);
          }
        });
      }
    }
  }

  collectDependenciesFromNode(bodyNode) {
    const deps = new Set();
    traverse(bodyNode, {
      Identifier(path) {
        // 简单的变量收集
        // 实际上需要考虑闭包、解构、对象属性等复杂情况
        if (path.isReferencedIdentifier()) {
          if (this.scope.hasOwnBinding(path.node.name)) {
            deps.add(path.node.name);
          }
        }
      }
    });
    return deps;
  }

  extractArrayElements(arrayNode) {
    if (!arrayNode || arrayNode.type !== 'ArrayExpression') return new Set();
    return new Set(arrayNode.elements.map(el => generate(el).code));
  }
}

第六部分:那些“坑”——让算法崩溃的边界情况

作为专家,我得告诉你,理论很丰满,现实很骨感。你的算法会遇到很多变态情况。

1. 解构赋值

useEffect(() => {
  const { x, y } = obj; // 这里用到了 obj
}, []);

我们的 collectDependenciesFromNode 必须能识别 obj,并且不能只看到 x。AST 中,xyObjectPattern 下的 Property,它们引用的是 obj。我们需要通过 binding.referencePaths 找到它们引用的源头。

2. 动态属性

useEffect(() => {
  console.log(props[dataKey]);
}, []);

dataKey 是一个变量。但 props[dataKey] 是动态的。我们的静态分析器只能看到 dataKey,它不知道运行时 dataKey 会变成什么值。所以,在这种情况下,依赖数组里必须包含 dataKey

3. 高阶函数与闭包

const memoizedFn = useCallback(() => {
  setCount(count + 1);
}, []); // 这里错了,依赖了 setCount 和 count

这里 setCount 是从 props 来的,count 是 state。算法必须能识别出 setCountcount 的来源。这需要维护一个 变量来源图count -> Identifier -> VariableDeclarator -> useState

4. 自定义 Hook 的依赖传递

如果自定义 Hook 返回了一个新的函数,这个函数引用了外部变量,调用它的地方必须把这个变量加进去。


第七部分:性能优化——不要遍历整个项目

遍历 AST 是昂贵的。如果项目有 10 万行代码,每次保存都要遍历 10 万行吗?不。

我们需要 按需扫描

  1. 忽略 node_modules:这是废话,但必须做。
  2. 文件过滤:只扫描 .js, .jsx, .ts, .tsx
  3. 增量解析:Babel 支持。如果你只改了一行代码,只解析那一部分 AST。
  4. 白名单/黑名单:只扫描 src 目录。

AST 生成器的选择

不要自己手写解析器!用 Babel 或者 Espree

  • Babel:功能最全,生态最好,但重。
  • Espree:ESLint 用的底层解析器,轻量,速度快。

结语:算法的艺术

设计 eslint-plugin-react-hooks 这样的工具,本质上是在做 代码的语义分析。我们不仅仅是看代码长什么样(语法分析),我们是在看代码意味着什么(语义分析)。

你需要模拟运行时的环境(比如作用域、变量绑定),然后对代码进行静态检查。这就像是在不运行程序的情况下,预测程序会做什么。

虽然看起来很复杂,充满了各种边缘情况,但一旦你掌握了 AST 的魔法,你会发现 React 的内部逻辑就像一本打开的书。你不再是一个只会调 API 的“CRUD 工程师”,你是一个能够洞察代码灵魂的“架构师”。

所以,拿起你的 AST 镰刀,去收割那些混乱的代码吧!记住,好的工具能让你在写 Bug 之前先意识到自己在造 Bug。这就是技术的浪漫。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注