ESLint 规则自定义:从零打造团队专属代码规范

HTMLPAGE 团队
18 分钟阅读

深入讲解 ESLint 自定义规则的开发流程,包括 AST 解析、规则编写、测试验证和发布共享,帮助团队构建个性化的代码质量保障体系。

#ESLint #代码规范 #AST #工程化 #自动化

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 中输入这段代码
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
ImportDeclarationimport 语句
ArrowFunctionExpression箭头函数
IfStatementif 语句

第一个自定义规则

让我们从一个实际需求开始:禁止使用 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 规则的核心步骤:

  1. 明确需求:确定要检查什么,何时报错
  2. 分析 AST:用 AST Explorer 找到目标节点类型
  3. 编写规则:实现 metacreate 方法
  4. 完善测试:覆盖正常和异常场景
  5. 封装发布:整理为插件便于复用

规则开发不难,难的是找到那些"值得做成规则"的约定。当你发现 Code Review 中反复指出同样的问题,那就是规则的最佳候选。

延伸阅读