静态分析工具 ESLint 原理:AST 选择器与自定义 Rule 的编写

ESLint 原理详解:AST 选择器与自定义 Rule 的编写

各位同学、开发者朋友们,大家好!今天我们来深入探讨一个在现代 JavaScript 开发中不可或缺的工具——ESLint。你可能每天都在用它来检查代码风格、发现潜在 bug 或者强制团队规范。但你知道吗?它的核心机制其实非常优雅且强大:基于抽象语法树(AST)的静态分析。

本文将从底层原理出发,带你一步步理解 ESLint 是如何工作的,重点聚焦于两个关键部分:

  1. AST 选择器(Selector)机制
  2. 如何编写自定义规则(Custom Rule)

我们不会停留在“怎么用”,而是深入到“为什么这样设计”。全程使用真实代码示例,逻辑清晰,适合中级及以上水平的前端工程师阅读。


一、什么是 AST?为什么我们需要它?

1.1 AST 是什么?

抽象语法树(Abstract Syntax Tree, AST)是一种表示源代码结构的数据结构。比如这段简单的 JS 代码:

const x = 5 + 3;

它的 AST 表示如下(简化版):

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": { "type": "Identifier", "name": "x" },
          "init": {
            "type": "BinaryExpression",
            "operator": "+",
            "left": { "type": "Literal", "value": 5 },
            "right": { "type": "Literal", "value": 3 }
          }
        }
      ],
      "kind": "const"
    }
  ]
}

你可以看到,AST 把代码拆解成了一个个节点,每个节点都有类型(如 BinaryExpressionIdentifier),并带有属性(如 operatorvalue)。这正是 ESLint 能够进行语义分析的基础。

1.2 为什么需要 AST?

  • 结构化分析:不像正则表达式只能匹配字符串,AST 可以精确识别代码结构。
  • 跨语言支持:Babel、TypeScript、Flow 都基于 AST 构建,所以 ESLint 可以轻松扩展到多种语言。
  • 无副作用检测:可以判断某个变量是否被重复声明、是否未使用等,而不需要运行代码。

✅ 总结:AST 是 ESLint 的“大脑”,没有它就没有真正的静态分析能力。


二、ESLint 如何利用 AST 实现规则检查?

2.1 核心流程概览

ESLint 的工作流程分为三步:

步骤 描述
1️⃣ 解析 使用 espree(默认)或 acorn 将源码转为 AST
2️⃣ 遍历 使用 eslint-scopeestraverse 遍历 AST 节点
3️⃣ 检查 对每个节点调用注册的规则函数,决定是否报错

其中最关键的是第 2 步和第 3 步,它们共同构成了所谓的 “AST 选择器 + 规则逻辑” 模型。

2.2 AST 选择器(Selector)是什么?

在 ESLint 中,“选择器”不是 CSS 那种,而是用于匹配 AST 节点的一种模式语法。它允许你像写 CSS 一样描述你想监听的节点类型和层级关系。

例如:

// 匹配所有 const 声明的变量
'VariableDeclaration[kind="const"]'

或者更复杂的嵌套:

// 匹配函数内部的 let 声明
'FunctionDeclaration > VariableDeclaration[kind="let"]'

这种选择器语法来源于 estraverse 库,是 ESLint 内部使用的 AST 遍历引擎。

🧠 举个例子:查找所有 console.log 调用

module.exports = {
  create(context) {
    return {
      CallExpression(node) {
        if (node.callee.type === 'MemberExpression' &&
            node.callee.object.name === 'console' &&
            node.callee.property.name === 'log') {
          context.report({
            node,
            message: '禁止使用 console.log',
          });
        }
      }
    };
  }
};

这个规则本质上就是手动遍历 AST 并做条件判断,但如果你用选择器,会更简洁:

module.exports = {
  create(context) {
    return {
      // 使用 AST 选择器匹配
      'CallExpression[callee.object.name="console"][callee.property.name="log"]'(node) {
        context.report({
          node,
          message: '禁止使用 console.log',
        });
      }
    };
  }
};

💡 注意:选择器语法是 ESLint 特有的 DSL(领域特定语言),不是标准 JavaScript,但它非常直观。


三、如何编写自己的 ESLint Rule?

现在我们进入实战环节!我们将一起创建一个自定义规则:禁止在函数体中直接使用 eval()

3.1 创建规则文件结构

首先,在项目根目录下新建 .eslintrc.js 文件(如果还没的话):

// .eslintrc.js
module.exports = {
  parserOptions: {
    ecmaVersion: 2020,
  },
  rules: {
    'no-eval-in-function': 'error', // 引入我们自定义的规则
  },
};

然后创建规则文件:

// lib/rules/no-eval-in-function.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: '禁止在函数体内使用 eval',
      category: 'Best Practices',
      recommended: true,
    },
    schema: [], // 不需要额外配置参数
    messages: {
      noEvalInFunction: '函数体内不允许使用 eval',
    },
  },

  create(context) {
    return {
      // 方法一:使用 AST 选择器(推荐)
      'CallExpression[callee.name="eval"]'(node) {
        // 检查当前节点是否在 FunctionDeclaration 或 FunctionExpression 内部
        const parent = context.getScope().variableScope.block;
        if (parent.type === 'FunctionDeclaration' || parent.type === 'FunctionExpression') {
          context.report({
            node,
            messageId: 'noEvalInFunction',
          });
        }
      },

      // 方法二:手动遍历(不推荐,但有助于理解原理)
      // 'CallExpression'(node) {
      //   if (node.callee.type === 'Identifier' && node.callee.name === 'eval') {
      //     const scope = context.getScope();
      //     const funcNode = scope.variableScope.block;
      //     if (funcNode.type.startsWith('Function')) {
      //       context.report({
      //         node,
      //         messageId: 'noEvalInFunction',
      //       });
      //     }
      //   }
      // }
    };
  },
};

3.2 关键知识点解析

API 作用
context.report({ node, messageId }) 报告问题,node 是出错位置,messageId 对应 meta.messages
context.getScope() 获取当前作用域信息,可用于判断是否在函数内
scope.variableScope.block 返回当前作用域的块级节点(通常是函数体)

🔍 为什么我们要检查 scope.variableScope.block

因为 eval 是全局函数,但如果它出现在函数内部(比如 function foo() { eval(...) }),那说明开发者有意为之,可能是为了安全考虑,也可能存在安全隐患。通过判断其父级是否为函数,我们可以精准定位问题。


四、进阶技巧:如何让规则更智能?

上面的例子已经能正常工作了,但我们还可以让它更健壮、更灵活。

4.1 支持更多场景(箭头函数、IIFE)

有些开发者喜欢用 IIFE(立即执行函数表达式)或箭头函数:

(() => {
  eval("alert('hello')"); // ❌ 应该被拦截
})();

const fn = () => eval("foo"); // ❌ 同样应该拦截

我们的规则目前只处理 FunctionDeclaration,要兼容这些情况怎么办?

解决方案:统一检查 parent.type 是否属于函数类节点:

const isFunctionNode = (node) => {
  return ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type);
};

// 修改规则逻辑:
create(context) {
  return {
    'CallExpression[callee.name="eval"]'(node) {
      const scope = context.getScope();
      const block = scope.variableScope.block;

      if (isFunctionNode(block)) {
        context.report({
          node,
          messageId: 'noEvalInFunction',
        });
      }
    }
  };
}

✅ 现在这个规则就能覆盖所有常见函数形式!

4.2 添加自动修复功能(Fixer)

ESLint 还支持自动修复(auto-fix),让你一键解决问题。我们来试试给上面的规则加一个 fixer:

meta: {
  type: 'problem',
  docs: {
    description: '禁止在函数体内使用 eval',
    category: 'Best Practices',
    recommended: true,
  },
  schema: [],
  messages: {
    noEvalInFunction: '函数体内不允许使用 eval',
  },
  fixable: 'code', // 表示支持自动修复
},

create(context) {
  return {
    'CallExpression[callee.name="eval"]'(node) {
      const scope = context.getScope();
      const block = scope.variableScope.block;

      if (isFunctionNode(block)) {
        return context.report({
          node,
          messageId: 'noEvalInFunction',
          fix(fixer) {
            // 替换整个 call 表达式为注释掉的内容
            return fixer.replaceText(node, '// TODO: remove eval usage');
          }
        });
      }
    }
  };
}

💡 自动修复不是万能的,有时你需要手动重构代码。但对一些简单的问题(比如删除无效变量、格式调整)非常有用。


五、性能优化建议

虽然 ESLint 很强大,但在大型项目中可能会变慢。以下是一些优化策略:

优化方向 建议
避免不必要的遍历 使用选择器而非全量遍历,比如只监听 CallExpression 而不是所有节点
缓存 AST 结构 如果规则需要多次访问相同结构,提前缓存(如作用域链)
减少复杂度高的操作 不要在每次遍历时做大量字符串拼接或 DOM 操作
使用 context.getSourceCode().getText(node) 获取原始文本比直接读取 node.range 更高效

⚠️ 提醒:不要试图用 ESLint 做“动态分析”,它本质是静态的。比如不能检测某个变量是否会被赋值成 undefined —— 那得靠运行时才能知道。


六、总结:为什么掌握 ESLint 原理很重要?

场景 掌握原理的好处
团队协作 编写高质量、可维护的规则,提升整体代码质量
故障排查 当规则失效时,能快速定位是 AST 选择器问题还是逻辑错误
扩展能力 可以基于 ESLint 构建自己的 lint 工具链(如 React、Vue 专用规则)
学习其他工具 类似地,Prettier、TypeScript、Babel 都依赖 AST,理解后更容易上手

附录:常用 AST 节点类型速查表

节点类型 示例 用途
Identifier x 变量名
Literal 5, "hello" 字面量
BinaryExpression a + b 运算符表达式
CallExpression fn() 函数调用
VariableDeclaration const a = 1; 声明语句
FunctionDeclaration function foo() {} 函数定义
ArrowFunctionExpression () => {} 箭头函数
MemberExpression obj.prop 属性访问
Program 整个文件 根节点

最后一句话

学习 ESLint 的 AST 选择器和自定义规则,不只是为了写出更好的 lint 规则,更是为了理解现代前端工程化的底层逻辑。当你掌握了这套思维方式,你会发现,不仅是 ESLint,连 Babel 插件、TypeScript 类型检查、甚至 React 的 JSX Transform,背后都离不开 AST 的力量。

希望今天的分享对你有帮助!欢迎动手实践,把你的想法变成规则,让代码变得更干净、更安全!

👉 下一步建议:尝试用 ESLint 写一个规则,比如“禁止使用 var”、“强制函数参数命名规范”、“检查未使用的变量”。

祝你在编程路上越走越远!

发表回复

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