ESLint 自定義規則與插件開發實戰 2026 | 代碼質量工程化指南

ESLint 是前端代碼質量保障的基石。當內置規則和社區插件無法滿足團隊需求時,自定義規則和插件就成為了必要的工程化手段。本文將從 AST 基礎到插件發佈,系統講解 ESLint 自定義規則開發的全流程。
一、AST 抽象語法樹基礎
1.1 什麼是 AST
AST(Abstract Syntax Tree)是源代碼的樹形結構表示。ESLint 通過分析 AST 來檢測代碼模式:
// 源代碼
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/parser或espree
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 |
IfStatement | if 語句 | if (true) {} |
ImportDeclaration | import 語句 | import x from 'y' |
二、ESLint 規則結構
2.1 規則基本結構
// 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 字段說明
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 對象
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
// 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 規則:強制組件名大寫
// 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
// 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 調用必須有錯誤處理
// 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 插件入口文件
// 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 配置文件
// 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
{
"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
// 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 測試自動修復
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 運行測試
# 運行所有測試
npm test
# 運行特定規則測試
npx mocha tests/rules/no-console-log.test.js六、在項目中使用
6.1 ESLint 配置
// .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 集成
// 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 本地開發鏈接
# 在插件目錄
npm link
# 在項目目錄
npm link eslint-plugin-my-rules
# 測試完成後取消鏈接
npm unlink eslint-plugin-my-rules七、TypeScript 規則開發
7.1 使用 @typescript-eslint/utils
// 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 類型感知規則
// 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
# 登錄 npm
npm login
# 發佈
npm publish
# 發佈 beta 版本
npm publish --tag beta8.2 發佈前檢查
# 檢查 package.json
npm pack --dry-run
# 檢查 .npmignore
# node_modules
# tests
# .git
# *.md(docs 目錄除外)九、最佳實踐
9.1 規則設計原則
- 單一職責:每條規則只檢測一個問題
- 性能優先:避免深度遍歷 AST
- 可修復:儘可能提供
fix函數 - 消息清晰:錯誤信息要明確可操作
- 測試完整:覆蓋有效和無效用例
9.2 常用 AST 選擇器
// 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 性能優化技巧
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 規則是代碼質量工程化的高級技能,它能將團隊的代碼規範自動化,從源頭保障代碼質量。
相關閱讀: