跳轉到內容

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 規則是代碼質量工程化的高級技能,它能將團隊的代碼規範自動化,從源頭保障代碼質量。


相關閱讀:

最後更新於: