ESLint 规则自定义:从零打造团队专属代码规范
每个团队都有自己的编码约定——禁止某些 API 使用、强制特定命名规范、限制某些模式的出现。当内置规则无法满足需求时,自定义规则就是你的秘密武器。本文将带你从 AST 基础到规则发布,完整掌握 ESLint 规则开发。
为什么需要自定义规则
内置规则的局限
ESLint 内置了 300+ 条规则,但它们都是通用的。团队往往有这些特殊需求:
// ❌ 禁止直接使用 console.log,必须用团队封装的 logger
console.log('debug info') // 应使用 logger.debug()
// ❌ 禁止在组件中直接调用 API
fetch('/api/users') // 应使用统一的请求层
// ❌ 特定目录下禁止导入某些模块
import dayjs from 'dayjs' // utils 目录应使用原生 Date
这些规则没有现成的方案,只能自己动手。
自定义规则的价值
| 价值 | 说明 |
|---|---|
| 强制执行约定 | 从"口头约定"变成"工具强制" |
| 降低 Review 成本 | 机器检查释放人力 |
| 新人快速上手 | 错误提示即是最好的文档 |
| 防止倒退 | 规则一旦建立,违规代码无法合入 |
AST:规则开发的基石
ESLint 不是简单的字符串匹配,而是基于 抽象语法树(AST) 进行分析。理解 AST 是编写规则的前提。
什么是 AST
AST 将代码解析为树形结构,每个节点代表一个语法单元:
// 源代码
const name = 'ESLint'
// 对应的 AST(简化版)
{
type: 'VariableDeclaration',
kind: 'const',
declarations: [{
type: 'VariableDeclarator',
id: {
type: 'Identifier',
name: 'name'
},
init: {
type: 'Literal',
value: 'ESLint'
}
}]
}
AST 探索工具
开发规则前,先用工具查看代码的 AST 结构:
- AST Explorer:https://astexplorer.net(设置 parser 为 @typescript-eslint/parser)
- ESLint Playground:https://eslint.org/play
// 在 AST Explorer 中输入这段代码
console.log('hello')
// 你会看到这样的结构
{
type: 'CallExpression',
callee: {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'console' },
property: { type: 'Identifier', name: 'log' }
},
arguments: [{ type: 'Literal', value: 'hello' }]
}
常见节点类型
| 节点类型 | 对应代码 |
|---|---|
Identifier | 变量名、函数名 |
Literal | 字符串、数字、布尔值 |
CallExpression | 函数调用 fn() |
MemberExpression | 属性访问 obj.prop |
ImportDeclaration | import 语句 |
ArrowFunctionExpression | 箭头函数 |
IfStatement | if 语句 |
第一个自定义规则
让我们从一个实际需求开始:禁止使用 console.log,必须使用团队的 logger。
规则结构
// rules/no-console-log.js
module.exports = {
meta: {
type: 'suggestion', // problem | suggestion | layout
docs: {
description: '禁止使用 console.log,请使用 logger',
category: 'Best Practices',
recommended: true
},
fixable: 'code', // 支持自动修复
schema: [], // 规则选项的 JSON Schema
messages: {
noConsoleLog: '禁止使用 console.log,请使用 logger.{{ method }}() 替代'
}
},
create(context) {
return {
// 访问者模式:当遇到 CallExpression 节点时执行
CallExpression(node) {
// 检查是否是 console.log 调用
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.name === 'console' &&
node.callee.property.name === 'log'
) {
context.report({
node,
messageId: 'noConsoleLog',
data: { method: 'debug' },
fix(fixer) {
// 自动修复:console.log -> logger.debug
return fixer.replaceText(node.callee, 'logger.debug')
}
})
}
}
}
}
}
配置使用
// eslint.config.js (Flat Config)
import noConsoleLog from './rules/no-console-log.js'
export default [
{
plugins: {
custom: {
rules: {
'no-console-log': noConsoleLog
}
}
},
rules: {
'custom/no-console-log': 'error'
}
}
]
进阶:复杂规则开发
场景:限制特定目录的导入
在 src/utils 目录下禁止导入第三方日期库,强制使用原生 API:
// rules/no-external-date-libs-in-utils.js
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'utils 目录下禁止导入第三方日期库'
},
messages: {
noExternalDateLib: 'utils 目录下请使用原生 Date API,禁止导入 {{ lib }}'
}
},
create(context) {
const forbiddenLibs = ['dayjs', 'moment', 'date-fns', 'luxon']
const filename = context.getFilename()
// 检查是否在 utils 目录
const isUtilsDir = filename.includes('/utils/') ||
filename.includes('\\utils\\')
if (!isUtilsDir) {
return {} // 不在 utils 目录,不检查
}
return {
ImportDeclaration(node) {
const importSource = node.source.value
for (const lib of forbiddenLibs) {
if (importSource === lib || importSource.startsWith(`${lib}/`)) {
context.report({
node,
messageId: 'noExternalDateLib',
data: { lib }
})
}
}
}
}
}
}
场景:强制 React 组件命名规范
确保组件文件名与默认导出名称一致:
// rules/consistent-component-naming.js
const path = require('path')
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: '组件文件名必须与默认导出名称一致'
},
messages: {
mismatch: '文件名 {{ filename }} 与组件名 {{ componentName }} 不一致'
}
},
create(context) {
const filename = context.getFilename()
const basename = path.basename(filename, path.extname(filename))
// 只检查组件文件(PascalCase 命名)
if (!/^[A-Z]/.test(basename)) {
return {}
}
return {
ExportDefaultDeclaration(node) {
let componentName = null
// export default function ComponentName() {}
if (node.declaration.type === 'FunctionDeclaration') {
componentName = node.declaration.id?.name
}
// export default ComponentName
if (node.declaration.type === 'Identifier') {
componentName = node.declaration.name
}
// const Component = () => {}; export default Component
if (componentName && componentName !== basename) {
context.report({
node,
messageId: 'mismatch',
data: { filename: basename, componentName }
})
}
}
}
}
}
使用选择器简化代码
ESLint 支持 CSS 风格的选择器语法:
// 传统方式
create(context) {
return {
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.name === 'console'
) {
// ...
}
}
}
}
// 选择器方式(更简洁)
create(context) {
return {
'CallExpression[callee.object.name="console"]'(node) {
context.report({ node, message: '禁止使用 console' })
}
}
}
常用选择器语法:
| 选择器 | 含义 |
|---|---|
CallExpression | 所有函数调用 |
CallExpression[callee.name="eval"] | eval() 调用 |
MemberExpression > Identifier | 成员表达式的子标识符 |
:matches(FunctionDeclaration, ArrowFunctionExpression) | 匹配多种节点 |
VariableDeclaration:exit | 离开节点时触发 |
规则测试
规则必须有完善的测试。ESLint 提供了 RuleTester 工具:
// tests/no-console-log.test.js
const { RuleTester } = require('eslint')
const rule = require('../rules/no-console-log')
const ruleTester = new RuleTester({
parserOptions: { ecmaVersion: 2022 }
})
ruleTester.run('no-console-log', rule, {
// 合法的代码
valid: [
'logger.debug("info")',
'logger.error("error")',
'console.error("error")', // 只禁止 log
'myConsole.log("ok")' // 不是 console 对象
],
// 违规的代码
invalid: [
{
code: 'console.log("hello")',
errors: [{ messageId: 'noConsoleLog' }],
output: 'logger.debug("hello")' // 自动修复后的结果
},
{
code: 'console.log(a, b, c)',
errors: [{ messageId: 'noConsoleLog' }]
}
]
})
测试 TypeScript 规则
const { RuleTester } = require('@typescript-eslint/rule-tester')
const ruleTester = new RuleTester({
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname
}
})
封装为 ESLint 插件
当规则数量增多,应该封装为独立插件便于复用:
// eslint-plugin-team/index.js
module.exports = {
meta: {
name: 'eslint-plugin-team',
version: '1.0.0'
},
rules: {
'no-console-log': require('./rules/no-console-log'),
'no-external-date-libs': require('./rules/no-external-date-libs'),
'consistent-component-naming': require('./rules/consistent-component-naming')
},
// 预设配置
configs: {
recommended: {
plugins: ['team'],
rules: {
'team/no-console-log': 'error',
'team/consistent-component-naming': 'warn'
}
}
}
}
目录结构
eslint-plugin-team/
├── package.json
├── index.js
├── rules/
│ ├── no-console-log.js
│ ├── no-external-date-libs.js
│ └── consistent-component-naming.js
└── tests/
├── no-console-log.test.js
└── ...
package.json
{
"name": "eslint-plugin-team",
"version": "1.0.0",
"main": "index.js",
"peerDependencies": {
"eslint": ">=8.0.0"
},
"devDependencies": {
"eslint": "^9.0.0"
}
}
实战:企业级规则集
禁止魔法数字
// rules/no-magic-numbers-extended.js
module.exports = {
meta: {
type: 'suggestion',
schema: [{
type: 'object',
properties: {
ignore: { type: 'array', items: { type: 'number' } },
ignoreArrayIndexes: { type: 'boolean' },
ignoreDefaultValues: { type: 'boolean' }
}
}],
messages: {
noMagicNumber: '避免使用魔法数字 {{ value }},请抽取为常量'
}
},
create(context) {
const options = context.options[0] || {}
const ignore = new Set(options.ignore || [0, 1, -1])
return {
Literal(node) {
if (typeof node.value !== 'number') return
if (ignore.has(node.value)) return
// 忽略数组索引 arr[0]
if (options.ignoreArrayIndexes &&
node.parent.type === 'MemberExpression' &&
node.parent.computed) {
return
}
// 忽略默认值
if (options.ignoreDefaultValues &&
node.parent.type === 'AssignmentPattern') {
return
}
context.report({
node,
messageId: 'noMagicNumber',
data: { value: node.value }
})
}
}
}
}
限制函数复杂度
// rules/max-function-complexity.js
module.exports = {
meta: {
type: 'suggestion',
schema: [{ type: 'integer', minimum: 1 }],
messages: {
tooComplex: '函数复杂度 {{ complexity }} 超过限制 {{ max }}'
}
},
create(context) {
const maxComplexity = context.options[0] || 10
function checkComplexity(node) {
let complexity = 1 // 基础复杂度
// 递归计算分支
function visit(n) {
switch (n.type) {
case 'IfStatement':
case 'ConditionalExpression':
case 'ForStatement':
case 'WhileStatement':
case 'DoWhileStatement':
case 'ForInStatement':
case 'ForOfStatement':
complexity++
break
case 'LogicalExpression':
if (n.operator === '&&' || n.operator === '||') {
complexity++
}
break
case 'SwitchCase':
if (n.test) complexity++ // default 不计
break
}
for (const key in n) {
if (n[key] && typeof n[key] === 'object') {
if (Array.isArray(n[key])) {
n[key].forEach(visit)
} else if (n[key].type) {
visit(n[key])
}
}
}
}
visit(node.body)
if (complexity > maxComplexity) {
context.report({
node,
messageId: 'tooComplex',
data: { complexity, max: maxComplexity }
})
}
}
return {
FunctionDeclaration: checkComplexity,
FunctionExpression: checkComplexity,
ArrowFunctionExpression: checkComplexity
}
}
}
调试技巧
使用 console 调试
开发规则时可以使用 console.log 查看节点结构:
create(context) {
return {
CallExpression(node) {
console.log(JSON.stringify(node, null, 2))
// 运行 eslint 时会输出节点信息
}
}
}
使用 VS Code 调试
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug ESLint Rule",
"program": "${workspaceFolder}/node_modules/.bin/eslint",
"args": ["--rulesdir", "./rules", "test-file.js"],
"cwd": "${workspaceFolder}"
}
]
}
总结
自定义 ESLint 规则的核心步骤:
- 明确需求:确定要检查什么,何时报错
- 分析 AST:用 AST Explorer 找到目标节点类型
- 编写规则:实现
meta和create方法 - 完善测试:覆盖正常和异常场景
- 封装发布:整理为插件便于复用
规则开发不难,难的是找到那些"值得做成规则"的约定。当你发现 Code Review 中反复指出同样的问题,那就是规则的最佳候选。


