ESLint 自定义规则实战:用 AST 分析封堵团队特有的代码坏味道
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 的自定义规则可以帮你自动化这些检查。
graph LR A[源代码] --> B[ESLint Parser] B --> C[AST 抽象语法树] C --> D[自定义规则 1<br/>禁止直接使用 fetch] C --> E[自定义规则 2<br/>强制组件目录结构] C --> F[自定义规则 3<br/>禁止特定 import] D --> G{匹配?} G -->|是| H[report + message] G -->|否| I[pass] E --> J{匹配?} E --> H E --> I F --> K{匹配?} F --> H F --> I style H fill:#ff6b6b,color:#fff style I fill:#51cf66,color:#fff本文将手把手教你用 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()
// 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:强制组件目录结构
// 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 来源
// 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中使用:
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 级别),不允许绕过。
少即是多。不要为所有事情写规则——只为那些"反复出现且能自动化检查"的模式写规则。
