跳转到内容

ESLint 自定义规则与插件开发实战 2026 | 代码质量工程化指南

ESLint 自定义规则与插件开发

ESLint 是前端代码质量保障的基石。当内置规则和社区插件无法满足团队需求时,自定义规则和插件就成为了必要的工程化手段。本文将从 AST 基础到插件发布,系统讲解 ESLint 自定义规则开发的全流程。


一、AST 抽象语法树基础

1.1 什么是 AST

AST(Abstract Syntax Tree)是源代码的树形结构表示。ESLint 通过分析 AST 来检测代码模式:

javascript
// 源代码
const x = 1 + 2

// AST 结构(简化)
{
  type: "Program",
  body: [{
    type: "VariableDeclaration",
    kind: "const",
    declarations: [{
      type: "VariableDeclarator",
      id: { type: "Identifier", name: "x" },
      init: {
        type: "BinaryExpression",
        operator: "+",
        left: { type: "Literal", value: 1 },
        right: { type: "Literal", value: 2 }
      }
    }]
  }]
}

1.2 AST 可视化工具

  • AST Explorer — 在线 AST 查看工具
  • 选择 JavaScript@typescript-eslint/parserespree

1.3 常用 AST 节点类型

节点类型说明示例
Identifier标识符foo, bar
Literal字面量1, "str", true
VariableDeclaration变量声明const x = 1
FunctionDeclaration函数声明function foo() {}
ArrowFunctionExpression箭头函数() => {}
CallExpression函数调用foo()
MemberExpression成员访问obj.prop
BinaryExpression二元表达式a + b
IfStatementif 语句if (true) {}
ImportDeclarationimport 语句import x from 'y'

二、ESLint 规则结构

2.1 规则基本结构

javascript
// rule.js
module.exports = {
  meta: {
    type: 'suggestion',  // problem | suggestion | layout
    docs: {
      description: '禁止使用 var 声明变量',
      category: 'Best Practices',
      recommended: true
    },
    fixable: 'code',  // code | whitespace | null
    schema: [          // 规则参数定义
      {
        type: 'object',
        properties: {
          preferConst: { type: 'boolean' }
        },
        additionalProperties: false
      }
    ],
    messages: {
      unexpected: "禁止使用 '{{type}}',请使用 'const' 或 'let'"
    }
  },

  create(context) {
    return {
      // AST 节点选择器:当遇到对应节点时触发
      VariableDeclaration(node) {
        if (node.kind === 'var') {
          context.report({
            node,
            messageId: 'unexpected',
            data: { type: node.kind },
            fix(fixer) {
              return fixer.replaceText(
                node.kind,
                node.declarations.every(d => 
                  !d.init || d.init.type === 'Literal'
                ) ? 'const' : 'let'
              )
            }
          })
        }
      }
    }
  }
}

2.2 meta 字段说明

javascript
meta: {
  // 规则类型
  type: 'problem',      // problem: 代码错误(必须修复)
                        // suggestion: 改进建议
                        // layout: 格式问题

  // 文档信息
  docs: {
    description: '规则描述',
    category: 'Possible Errors',
    recommended: true,
    url: 'https://github.com/your/repo/blob/main/docs/rules/your-rule.md'
  },

  // 是否可自动修复
  fixable: 'code',      // code: 可修复代码
                        // whitespace: 仅修复空白
                        // null: 不可修复

  // 是否有副作用(影响其他规则)
  hasSuggestions: true,  // 提供修复建议

  // 参数 Schema(JSON Schema 格式)
  schema: [
    {
      type: 'object',
      properties: {
        option1: { type: 'string', enum: ['a', 'b'] },
        option2: { type: 'number' }
      },
      required: ['option1'],
      additionalProperties: false
    }
  ],

  // 错误消息模板(支持占位符)
  messages: {
    errorId: "变量 '{{name}}' 不能使用下划线前缀",
    warningId: "建议重命名变量 '{{name}}'"
  },

  // 弃用标记
  deprecated: false,
  replacedBy: ['new-rule-name']
}

2.3 context 对象

javascript
create(context) {
  // context 提供的 API

  // 1. 获取配置选项
  const options = context.options[0] || {}
  const preferConst = options.preferConst !== false

  // 2. 获取文件信息
  const filename = context.getFilename()
  const sourceCode = context.getSourceCode()

  // 3. 获取源码
  const sourceText = sourceCode.getText()

  // 4. 报告错误
  context.report({
    node: someNode,
    messageId: 'errorId',
    data: { name: 'someVar' },
    loc: { line: 1, column: 1 },
    fix(fixer) {
      return fixer.replaceText(node, 'newCode')
    },
    suggest: [{
      desc: '使用 const 替代',
      fix(fixer) {
        return fixer.replaceText(node, 'const')
      }
    }]
  })

  // 5. 获取设置
  const settings = context.settings  // .eslintrc 中的 settings

  // 6. 获取解析器服务(TypeScript)
  const parserServices = context.parserServices

  return { /* AST 选择器 */ }
}

三、实战规则开发

3.1 规则:禁止 console.log

javascript
// rules/no-console-log.js
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description: '禁止使用 console.log',
      category: 'Best Practices',
      recommended: false
    },
    fixable: null,
    schema: [
      {
        type: 'object',
        properties: {
          allow: {
            type: 'array',
            items: { type: 'string', enum: ['warn', 'error', 'info'] }
          }
        },
        additionalProperties: false
      }
    ],
    messages: {
      unexpected: '不允许使用 console.{{method}}'
    }
  },

  create(context) {
    const options = context.options[0] || {}
    const allowed = options.allow || []

    return {
      // 匹配 console.xxx() 调用
      'CallExpression > MemberExpression'(node) {
        if (
          node.object.type === 'Identifier' &&
          node.object.name === 'console' &&
          node.property.type === 'Identifier'
        ) {
          const method = node.property.name

          if (!allowed.includes(method)) {
            context.report({
              node: node.parent,
              messageId: 'unexpected',
              data: { method }
            })
          }
        }
      }
    }
  }
}

3.2 规则:强制组件名大写

javascript
// rules/pascal-case-component.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: 'Vue 组件名必须使用 PascalCase',
      category: 'Vue'
    },
    fixable: 'code',
    schema: [],
    messages: {
      invalid: '组件名 "{{name}}" 必须使用 PascalCase'
    }
  },

  create(context) {
    return {
      // 检查 Vue 组件注册
      CallExpression(node) {
        if (
          node.callee.type === 'MemberExpression' &&
          node.callee.object.name === 'Vue' &&
          node.callee.property.name === 'component'
        ) {
          const nameArg = node.arguments[0]
          if (nameArg && nameArg.type === 'Literal' && typeof nameArg.value === 'string') {
            const name = nameArg.value
            if (!/^[A-Z][a-zA-Z0-9]*$/.test(name)) {
              context.report({
                node: nameArg,
                messageId: 'invalid',
                data: { name },
                fix(fixer) {
                  const pascalName = name
                    .split(/[-_]/)
                    .map(s => s.charAt(0).toUpperCase() + s.slice(1))
                    .join('')
                  return fixer.replaceText(nameArg, `'${pascalName}'`)
                }
              })
            }
          }
        }
      }
    }
  }
}

3.3 规则:禁止直接修改 props

javascript
// rules/no-mutating-props.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: '禁止在组件中直接修改 props',
      category: 'Vue'
    },
    fixable: null,
    schema: [],
    messages: {
      unexpected: '不允许直接修改 prop "{{name}}"'
    }
  },

  create(context) {
    // 获取 Vue 组件中的 props
    let props = new Set()

    return {
      // 收集 props 定义
      'Property[key.name="props"] > ObjectExpression > Property'(node) {
        if (node.key.type === 'Identifier') {
          props.add(node.key.name)
        }
      },

      // 检查赋值操作
      'AssignmentExpression'(node) {
        if (node.left.type === 'MemberExpression') {
          const obj = node.left.object
          if (
            obj.type === 'Identifier' &&
            (obj.name === 'props' || props.has(obj.name))
          ) {
            const propName = node.left.property.name
            context.report({
              node,
              messageId: 'unexpected',
              data: { name: propName }
            })
          }
        }
      }
    }
  }
}

3.4 规则:API 调用必须有错误处理

javascript
// rules/api-call-error-handling.js
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'async 函数中的 API 调用必须有错误处理',
      category: 'Best Practices'
    },
    fixable: null,
    schema: [{
      type: 'object',
      properties: {
        apiPatterns: {
          type: 'array',
          items: { type: 'string' }
        }
      },
      additionalProperties: false
    }],
    messages: {
      missingTryCatch: 'API 调用 "{{api}}" 必须包含在 try/catch 中',
      missingCatch: 'API 调用 "{{api}}" 的 catch 中必须有错误处理逻辑'
    }
  },

  create(context) {
    const options = context.options[0] || {}
    const apiPatterns = options.apiPatterns || ['fetch', 'axios', 'request']

    function isApiCall(node) {
      if (node.type === 'CallExpression') {
        const name = node.callee.type === 'Identifier'
          ? node.callee.name
          : node.callee.property?.name
        return apiPatterns.includes(name)
      }
      return false
    }

    function isInTryBlock(node) {
      let parent = node.parent
      while (parent) {
        if (parent.type === 'TryStatement') return true
        parent = parent.parent
      }
      return false
    }

    return {
      AwaitExpression(node) {
        if (isApiCall(node.argument)) {
          const apiName = node.argument.callee.property?.name || 
                          node.argument.callee.name
          
          if (!isInTryBlock(node)) {
            context.report({
              node,
              messageId: 'missingTryCatch',
              data: { api: apiName }
            })
          }
        }
      }
    }
  }
}

四、ESLint 插件结构

4.1 插件目录结构

eslint-plugin-my-rules/
├── package.json
├── index.js                    # 插件入口
├── rules/
│   ├── no-console-log.js
│   ├── pascal-case-component.js
│   ├── no-mutating-props.js
│   └── api-call-error-handling.js
├── configs/
│   ├── recommended.js          # 推荐配置
│   ├── strict.js               # 严格配置
│   └── vue.js                  # Vue 专用配置
├── tests/
│   └── rules/
│       ├── no-console-log.test.js
│       ├── pascal-case-component.test.js
│       └── ...
└── docs/
    └── rules/
        ├── no-console-log.md
        └── ...

4.2 插件入口文件

javascript
// index.js
const noConsoleLog = require('./rules/no-console-log')
const pascalCaseComponent = require('./rules/pascal-case-component')
const noMutatingProps = require('./rules/no-mutating-props')
const apiCallErrorHandling = require('./rules/api-call-error-handling')

module.exports = {
  meta: {
    name: 'eslint-plugin-my-rules',
    version: '1.0.0'
  },

  // 注册规则
  rules: {
    'no-console-log': noConsoleLog,
    'pascal-case-component': pascalCaseComponent,
    'no-mutating-props': noMutatingProps,
    'api-call-error-handling': apiCallErrorHandling
  },

  // 注册可共享配置
  configs: {
    recommended: require('./configs/recommended'),
    strict: require('./configs/strict'),
    vue: require('./configs/vue')
  }
}

4.3 配置文件

javascript
// configs/recommended.js
module.exports = {
  plugins: ['my-rules'],
  rules: {
    'my-rules/no-console-log': 'warn',
    'my-rules/pascal-case-component': 'error',
    'my-rules/no-mutating-props': 'error',
    'my-rules/api-call-error-handling': 'warn'
  }
}

// configs/strict.js
module.exports = {
  extends: ['./recommended'],
  rules: {
    'my-rules/no-console-log': 'error',
    'my-rules/api-call-error-handling': 'error'
  }
}

4.4 package.json

json
{
  "name": "eslint-plugin-my-rules",
  "version": "1.0.0",
  "description": "自定义 ESLint 规则插件",
  "main": "index.js",
  "peerDependencies": {
    "eslint": ">=8.0.0"
  },
  "devDependencies": {
    "eslint": "^8.57.0",
    "mocha": "^10.0.0"
  },
  "scripts": {
    "test": "mocha tests/**/*.test.js",
    "lint": "eslint ."
  },
  "keywords": ["eslint", "eslint-plugin", "vue", "typescript"]
}

五、规则测试

5.1 使用 RuleTester

javascript
// tests/rules/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: [
    'console.warn("warning")',
    'console.error("error")',
    {
      code: 'console.log("debug")',
      options: [{ allow: ['log'] }]
    },
    'Math.max(1, 2)'
  ],

  // 无效的代码(应该报错)
  invalid: [
    {
      code: 'console.log("hello")',
      errors: [{ messageId: 'unexpected', data: { method: 'log' } }]
    },
    {
      code: 'console.info("info")',
      options: [{ allow: ['warn', 'error'] }],
      errors: [{ messageId: 'unexpected', data: { method: 'info' } }]
    }
  ]
})

5.2 测试自动修复

javascript
ruleTester.run('pascal-case-component', rule, {
  valid: [
    'Vue.component("MyComponent", {})',
    'Vue.component("UserCard", {})'
  ],
  invalid: [
    {
      code: 'Vue.component("my-component", {})',
      output: 'Vue.component("MyComponent", {})',
      errors: [{ messageId: 'invalid', data: { name: 'my-component' } }]
    },
    {
      code: 'Vue.component("user_card", {})',
      output: 'Vue.component("UserCard", {})',
      errors: [{ messageId: 'invalid', data: { name: 'user_card' } }]
    }
  ]
})

5.3 运行测试

bash
# 运行所有测试
npm test

# 运行特定规则测试
npx mocha tests/rules/no-console-log.test.js

六、在项目中使用

6.1 ESLint 配置

javascript
// .eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    es2022: true,
    node: true
  },
  extends: [
    'eslint:recommended',
    'plugin:my-rules/recommended'  // 使用插件推荐配置
  ],
  plugins: ['my-rules'],
  rules: {
    // 单独配置规则
    'my-rules/no-console-log': ['error', { allow: ['warn', 'error'] }],
    'my-rules/pascal-case-component': 'error',
    'my-rules/no-mutating-props': 'error',
    'my-rules/api-call-error-handling': ['warn', {
      apiPatterns: ['fetch', 'axios', '$http']
    }]
  },
  settings: {
    // 插件可用的全局设置
    'my-rules': {
      vueVersion: 3
    }
  }
}

6.2 与 Vite 集成

typescript
// vite.config.ts
import { defineConfig } from 'vite'
import eslint from 'vite-plugin-eslint'

export default defineConfig({
  plugins: [
    eslint({
      fix: false,        // 是否自动修复
      cache: true,       // 启用缓存
      include: ['src/**/*.{js,ts,vue}'],
      exclude: ['node_modules', 'dist']
    })
  ]
})

6.3 本地开发链接

bash
# 在插件目录
npm link

# 在项目目录
npm link eslint-plugin-my-rules

# 测试完成后取消链接
npm unlink eslint-plugin-my-rules

七、TypeScript 规则开发

7.1 使用 @typescript-eslint/utils

typescript
// rules/no-explicit-any.ts
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'

const createRule = ESLintUtils.RuleCreator(
  name => `https://github.com/your/repo/blob/main/docs/rules/${name}.md`
)

export default createRule({
  name: 'no-explicit-any',
  meta: {
    type: 'problem',
    docs: {
      description: '禁止使用 any 类型',
      recommended: 'error'
    },
    fixable: 'code',
    schema: [],
    messages: {
      unexpected: '禁止使用 any 类型,请使用 unknown 替代'
    }
  },
  defaultOptions: [],
  create(context) {
    return {
      TSAnyKeyword(node: TSESTree.TSAnyKeyword) {
        context.report({
          node,
          messageId: 'unexpected',
          fix(fixer) {
            return fixer.replaceText(node, 'unknown')
          }
        })
      }
    }
  }
})

7.2 类型感知规则

typescript
// rules/no-floating-promises.ts
import { ESLintUtils } from '@typescript-eslint/utils'

const createRule = ESLintUtils.RuleCreator(
  name => `https://github.com/your/repo/blob/main/docs/rules/${name}.md`
)

export default createRule({
  name: 'no-floating-promises',
  meta: {
    type: 'problem',
    docs: { description: 'Promise 必须被处理' },
    schema: [],
    messages: {
      floating: 'Promise 必须被 await、catch 或 void 处理'
    }
  },
  defaultOptions: [],
  create(context) {
    const services = ESLintUtils.getParserServices(context)
    const checker = services.program.getTypeChecker()

    return {
      ExpressionStatement(node) {
        const tsNode = services.esTreeNodeToTSNodeMap.get(node.expression)
        const type = checker.getTypeAtLocation(tsNode)

        if (type.symbol?.name === 'Promise') {
          context.report({
            node,
            messageId: 'floating'
          })
        }
      }
    }
  }
})

八、插件发布

8.1 发布到 npm

bash
# 登录 npm
npm login

# 发布
npm publish

# 发布 beta 版本
npm publish --tag beta

8.2 发布前检查

bash
# 检查 package.json
npm pack --dry-run

# 检查 .npmignore
# node_modules
# tests
# .git
# *.md(docs 目录除外)

九、最佳实践

9.1 规则设计原则

  1. 单一职责:每条规则只检测一个问题
  2. 性能优先:避免深度遍历 AST
  3. 可修复:尽可能提供 fix 函数
  4. 消息清晰:错误信息要明确可操作
  5. 测试完整:覆盖有效和无效用例

9.2 常用 AST 选择器

javascript
// ESLint 支持的选择器语法(类似 CSS)
{
  // 所有函数声明
  'FunctionDeclaration'(node) {},

  // 名为 foo 的函数
  'FunctionDeclaration[id.name="foo"]'(node) {},

  // 异步函数
  'FunctionDeclaration[async=true]'(node) {},

  // 赋值表达式的左侧
  'AssignmentExpression > MemberExpression.left'(node) {},

  // 子选择器
  'VariableDeclaration VariableDeclarator'(node) {},

  // 组合选择器
  'IfStatement, WhileStatement, ForStatement'(node) {},
}

9.3 性能优化技巧

javascript
create(context) {
  // ✅ 使用选择器精确匹配,避免遍历所有节点
  return {
    'CallExpression[callee.property.name="forEach"]'(node) {
      // 只处理 forEach 调用
    }
  }

  // ❌ 避免在所有节点上做过滤
  // return {
  //   '*'(node) {
  //     if (isWhatWeWant(node)) { ... }
  //   }
  // }
}

十、总结

  • ✅ 理解 AST 抽象语法树与常用节点类型
  • ✅ 掌握 ESLint 规则结构与 meta 配置
  • ✅ 实战 4 条自定义规则(console、组件命名、props、API 错误处理)
  • ✅ 完整插件结构(入口、配置、测试、文档)
  • ✅ 使用 RuleTester 编写规则测试
  • ✅ 在项目中集成自定义插件(ESLint 配置 + Vite 集成)
  • ✅ TypeScript 规则开发(类型感知规则)
  • ✅ 插件发布到 npm
  • ✅ 规则设计最佳实践与性能优化

自定义 ESLint 规则是代码质量工程化的高级技能,它能将团队的代码规范自动化,从源头保障代码质量。


相关阅读: