ESLint 自定义规则实战:用 AST 分析封堵团队特有的代码坏味道
一、通用 Lint 规则的盲区:为什么团队特有的"坏习惯"永远被漏掉
ESLint 有数百条内置规则和社区插件。no-unused-vars、no-console、prefer-const 这些通用规则覆盖了 80% 的常见问题。但每个团队都有自己特有的"代码坏味道",这些是通用规则永远无法覆盖的。
比如:你的团队约定 API 请求必须使用封装好的 request() 方法,不能直接用 fetch()。因为 request() 内置了认证 Token 注入、错误处理和日志上报。新人入职后可能不知道这个约定,直接用 fetch() 调接口。
又比如:你的团队规定组件文件夹结构必须是 ComponentName/index.tsx + ComponentName/style.module.css + ComponentName/types.ts。如果有人在组件目录下直接创建 ComponentName.tsx,应该被 lint 拦下来。
这些不是 bug,不会导致程序崩溃。它们是"代码坏味道"------不会立即造成问题,但长期积累会导致代码库一致性崩溃。ESLint 的自定义规则可以帮你自动化这些检查。
本文将手把手教你用 AST 分析编写 ESLint 自定义规则,覆盖三种真实场景。
二、ESLint 规则的底层机制:AST 遍历与 Context Report
ESLint 规则的本质是AST 遍历器。ESLint 使用 espree 将源代码解析为 AST(抽象语法树),规则的每个方法会在遍历到对应节点类型时被调用。
一个 ESLint 规则的生命周期:
- ESLint 读取源代码文件
- espree 将代码解析为 AST
- 遍历 AST 的每个节点
- 对于每个节点,调用匹配的规则方法(如
CallExpression、ImportDeclaration) - 规则方法检查节点是否符合预期
- 如果不符合,调用
context.report()报告问题
理解 AST 结构是编写规则的关键。推荐使用 AST Explorer 来可视化任意代码的 AST 结构。
三、三个实战规则:从简单到复杂
规则 1:禁止直接使用 fetch()
javascript
// eslint-local-rules/no-direct-fetch.js
module.exports = {
meta: {
type: 'problem',
docs: {
description: '禁止直接使用 fetch(),请使用封装的 request() 方法',
recommended: true,
},
messages: {
noDirectFetch: '禁止直接使用 fetch()。请使用封装的 {{replacement}} 方法以确保统一的认证和错误处理。',
},
schema: [
{
type: 'object',
properties: {
replacement: { type: 'string' },
},
additionalProperties: false,
},
],
},
create(context) {
const options = context.options[0] || {};
const replacement = options.replacement || 'request';
return {
CallExpression(node) {
// 检查是否是 fetch(...) 调用
if (node.callee.type === 'Identifier' && node.callee.name === 'fetch') {
context.report({
node,
messageId: 'noDirectFetch',
data: { replacement },
});
}
// 也检查 window.fetch(...) 和 global.fetch(...)
if (node.callee.type === 'MemberExpression') {
const obj = node.callee.object;
const prop = node.callee.property;
if (
(obj.name === 'window' || obj.name === 'global') &&
prop.name === 'fetch'
) {
context.report({
node,
messageId: 'noDirectFetch',
data: { replacement },
});
}
}
},
};
},
};
规则 2:强制组件目录结构
javascript
// eslint-local-rules/component-directory-structure.js
const path = require('path');
module.exports = {
meta: {
type: 'problem',
docs: {
description: '组件必须放在 ComponentName/index.tsx 目录下',
recommended: true,
},
messages: {
invalidStructure: '组件文件应位于 {{componentName}}/index.tsx 目录下,而不是 {{currentPath}}。',
},
},
create(context) {
const filename = context.getFilename();
const dir = path.dirname(filename);
const ext = path.extname(filename);
// 只检查 .tsx 和 .jsx 文件
if (!['.tsx', '.jsx'].includes(ext)) return {};
// 检查是否在 components 目录下
if (!dir.includes('components')) return {};
const basename = path.basename(filename, ext);
const parentDir = path.basename(dir);
// 如果文件名和父目录名不同,且文件不是 index.tsx
if (basename !== 'index' && basename !== parentDir) {
// 检查是否存在同名目录
const componentName = basename;
context.report({
loc: { line: 1, column: 0 },
messageId: 'invalidStructure',
data: {
componentName,
currentPath: filename,
},
});
}
return {};
},
};
规则 3:禁止特定的 import 来源
javascript
// eslint-local-rules/no-restricted-imports-extended.js
module.exports = {
meta: {
type: 'problem',
docs: {
description: '禁止从特定模块导入(支持动态匹配)',
recommended: true,
},
messages: {
restrictedImport: '禁止从 {{source}} 导入 {{name}}。原因: {{reason}}',
},
schema: [
{
type: 'array',
items: {
type: 'object',
properties: {
module: { type: 'string' },
names: { type: 'array', items: { type: 'string' } },
message: { type: 'string' },
},
required: ['module'],
additionalProperties: false,
},
},
],
},
create(context) {
const restrictions = context.options[0] || [];
return {
ImportDeclaration(node) {
const source = node.source.value;
for (const rule of restrictions) {
// 支持正则匹配模块名
const pattern = new RegExp(rule.module);
if (!pattern.test(source)) continue;
for (const specifier of node.specifiers) {
const name = specifier.imported?.name || specifier.local.name;
// 如果指定了 names,只检查列出的名称
if (rule.names && !rule.names.includes(name)) continue;
context.report({
node: specifier,
messageId: 'restrictedImport',
data: {
source,
name,
reason: rule.message || '请使用团队规定的替代方案。',
},
});
}
}
},
};
},
};
在 .eslintrc.js 中使用:
javascript
module.exports = {
plugins: ['local-rules'],
rules: {
'local-rules/no-direct-fetch': ['error', { replacement: 'request' }],
'local-rules/component-directory-structure': 'error',
'local-rules/no-restricted-imports-extended': ['error', [
{
module: 'lodash',
message: '请使用 lodash-es 以支持 Tree Shaking。',
},
{
module: 'moment',
message: '请使用 dayjs 替代 moment。',
},
{
module: 'axios',
names: ['default'],
message: '请使用封装的 apiClient 替代直接导入 axios。',
},
]],
},
};
四、自定义规则的维护成本
性能影响:每个自定义规则都会增加 ESLint 的运行时间。如果规则在每个文件上做了大量 I/O 操作(如读取文件系统),会严重影响 lint 速度。建议为 I/O 操作添加缓存。
AST 兼容性:ESLint 的 AST 格式稳定,但如果你使用了 TypeScript 或 JSX,需要确保 parser 配置支持相应的语法。
规则的粒度:不要为一次性的事件编写规则。好的规则应该覆盖"可重复出现的模式"。
不适用场景:
- 代码风格偏好:如"用单引号还是双引号"------用 Prettier 处理
- 对运行时逻辑的检查:如"这个 API 应该带 debounce"------这是 Code Review 的职责
- 复杂的跨文件分析:ESLint 单文件规则的限制让它不适合跨文件检查
五、总结
ESLint 自定义规则的核心价值是将团队的隐性约定转为显性约束。当"每个人都应该知道"变成一个报错提示时,新人不再需要靠口口相传学习规范。
落地路径:先记录团队中反复在 Code Review 中被指出的问题(持续 2 周);找出其中有固定模式的 3 个问题;用 AST Explorer 分析代码结构后编写自定义规则;最后在 CI 中强制执行(error 级别),不允许绕过。
少即是多。不要为所有事情写规则------只为那些"反复出现且能自动化检查"的模式写规则。