ESLint 原理详解:AST 选择器与自定义 Rule 的编写
各位同学、开发者朋友们,大家好!今天我们来深入探讨一个在现代 JavaScript 开发中不可或缺的工具——ESLint。你可能每天都在用它来检查代码风格、发现潜在 bug 或者强制团队规范。但你知道吗?它的核心机制其实非常优雅且强大:基于抽象语法树(AST)的静态分析。
本文将从底层原理出发,带你一步步理解 ESLint 是如何工作的,重点聚焦于两个关键部分:
- AST 选择器(Selector)机制
- 如何编写自定义规则(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 把代码拆解成了一个个节点,每个节点都有类型(如 BinaryExpression、Identifier),并带有属性(如 operator、value)。这正是 ESLint 能够进行语义分析的基础。
1.2 为什么需要 AST?
- 结构化分析:不像正则表达式只能匹配字符串,AST 可以精确识别代码结构。
- 跨语言支持:Babel、TypeScript、Flow 都基于 AST 构建,所以 ESLint 可以轻松扩展到多种语言。
- 无副作用检测:可以判断某个变量是否被重复声明、是否未使用等,而不需要运行代码。
✅ 总结:AST 是 ESLint 的“大脑”,没有它就没有真正的静态分析能力。
二、ESLint 如何利用 AST 实现规则检查?
2.1 核心流程概览
ESLint 的工作流程分为三步:
| 步骤 | 描述 |
|---|---|
| 1️⃣ 解析 | 使用 espree(默认)或 acorn 将源码转为 AST |
| 2️⃣ 遍历 | 使用 eslint-scope 和 estraverse 遍历 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”、“强制函数参数命名规范”、“检查未使用的变量”。
祝你在编程路上越走越远!