
其实没那么难。去年我帮一个10人前端团队梳理代码规范,从零开始写了5个自定义规则,上线3个月后,代码审查时关于“风格问题”的讨论减少了60%,业务相关的低级bug也少了近一半。今天我就把这套“笨办法”拆解开,你跟着做,就算没学过AST,也能写出能用的规则。
自定义ESLint规则的基础流程:从0到1写规则的5个步骤
别被“自定义”三个字唬住,ESLint规则本质上就是“检查代码结构的函数”。你只需要告诉它“什么代码是错的”,以及“错了怎么提示”。我把整个流程 成了5步,每步都有踩过的坑和对应的解决办法,你照着做就能少走弯路。
第一步:明确规则要解决的“具体问题”
写规则的第一步不是翻文档,而是拿张纸写下“这个规则要禁止什么、允许什么”。比如你发现团队里总有人在循环里用setState(React里可能导致性能问题),那规则目标就是“禁止在for循环、forEach回调中调用setState”。
这里有个关键:规则要越具体越好。我第一次写规则时,想做个“禁止复杂条件判断”的规则,结果因为“复杂”没有量化标准(到底多少层if算复杂?),写出来后天天误报,最后不得不废弃。后来学乖了,每次都先和团队确认“违规示例”和“合规示例”,比如:
// 违规示例(for循环里调用setState)
for (let i = 0; i < list.length; i++) {
this.setState({ count: i });
}
// 合规示例(收集后批量更新)
const newCount = list.length;
this.setState({ count: newCount });
把这些例子贴在规则文档里,后面写代码和测试都会更清晰。
第二步:理解AST:用“代码积木”的视角看规则
ESLint检查代码的核心是AST(抽象语法树)——简单说,就是把代码拆成一堆“积木”(节点),每个积木都有自己的类型和属性。比如const a = 1
会被拆成VariableDeclaration
(变量声明)节点,里面包含declarations
属性,对应a = 1
这个赋值表达式。
你不需要记住所有节点类型,用对工具就行。我常用的是AST Explorer,把代码粘贴进去,就能实时看到AST结构。比如想检查“for循环里的setState”,可以在左侧输入代码,右侧找到CallExpression
(函数调用)节点,看看它的callee
(调用对象)是不是this.setState
,再看它的父节点是不是ForStatement
(for循环)或ForEachStatement
(forEach循环)。
举个实际例子:之前我要检查“禁止在if条件里直接写数字”(比如if (status === 1)
,应该用常量if (status === STATUS.SUCCESS)
),就是在AST Explorer里发现,数字字面量对应的节点类型是Literal
,且value
是数字。于是规则的核心逻辑就变成:“找到所有if条件里的Literal节点,如果value是数字且没被变量包裹,就报错”。
第三步:写规则代码:核心是“选择器+校验逻辑”
ESLint规则是一个JS对象,最关键的两个属性是meta
(规则元信息,比如是否修复、分类)和create
(规则逻辑函数)。create
函数里需要用“选择器”找到目标AST节点,再写校验逻辑。
先看一个最简单的规则模板(检查“禁止用var声明变量”):
module.exports = {
meta: {
type: "problem", // 问题类型:problem/ suggestion/ layout
docs: { description: "禁止使用var声明变量" },
fixable: "code", // 是否支持自动修复
schema: [] // 规则参数,没有就留空
},
create(context) {
return {
// 选择器:匹配所有VariableDeclaration节点
VariableDeclaration(node) {
if (node.kind === "var") { // 校验逻辑:如果声明方式是var
context.report({ // 报错
node,
message: "请使用let或const代替var",
fix(fixer) { // 自动修复:把var替换成let
return fixer.replaceText(node, node.kind === "var" ? "let" + node.sourceCode.text.slice(3) node.sourceCode.text);
}
});
}
}
};
}
};
这里的“选择器”就像CSS选择器,帮你定位节点。常用的选择器有:
VariableDeclaration
) IfStatement > BlockStatement
,if语句里的代码块) CallExpression[callee.name='setState']
,调用setState的函数) 我第一次写选择器时踩过坑:想匹配“forEach回调里的setState”,写成了ForEachStatement CallExpression[callee.name='setState']
,结果把所有setState都匹配上了。后来才发现需要用ForEachStatement > BlockStatement > CallExpression
,明确节点的层级关系。这种时候 先用AST Explorer的“选择器测试”功能验证,再写进代码。
第四步:测试用例:避免“改了东墙塌西墙”
写完规则一定要写测试,否则下次改代码可能把之前的逻辑弄坏。ESLint用Mocha+Chai做测试,测试文件放在tests/lib/rules/规则名.js
,格式很固定:
const rule = require("../../../lib/rules/your-rule-name");
const RuleTester = require("eslint").RuleTester;
const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2020 } });
ruleTester.run("your-rule-name", rule, {
valid: [ // 合法代码
"const a = 1;",
"let b = 2;"
],
invalid: [ // 不合法代码,包含错误信息和修复后结果
{
code: "var c = 3;",
errors: [{ message: "请使用let或const代替var" }],
output: "let c = 3;" // 修复后的代码
}
]
});
测试用例要尽量覆盖边界情况。比如“禁止数字字面量”的规则,要测试if (a === 1)
(报错)、if (a === NUM)
(不报错,NUM是常量)、const b = 1 + 2
(是否报错?取决于规则是否允许表达式里的数字)。我之前漏测了“数字作为对象属性”的情况(比如const obj = { type: 1 }
),上线后被同事提醒才补了测试,所以 你写测试时,把“可能合法”和“可能不合法”的情况都列出来。
第五步:本地调试:用link命令实时测试规则
规则写好了,怎么在自己项目里试?别直接发布npm包,用npm link
把本地规则链接到项目里。具体步骤:
npm link
,生成全局链接 npm link 你的规则包名
.eslintrc.js
里添加规则:rules: { "你的规则包名/规则名": "error" }
调试时推荐用eslint fix debug 目标文件.js
,debug
会输出ESLint的检查过程,帮你定位“为什么没匹配到节点”。我之前写“检查hooks依赖数组”的规则时,发现总匹配不到useEffect,后来在debug日志里看到,原来项目用了import { useEffect as useMyEffect }
的别名,导致选择器CallExpression[callee.name='useEffect']
失效,最后改成匹配callee.property.name
才解决。
实战场景:3个高频需求的完整落地案例
光说流程太空泛,咱们结合实际场景看。我选了3个团队最常用的规则类型,从“需求分析”到“规则代码”再到“效果验证”,一步一步拆解开。每个案例都附了可直接复用的代码片段,你改改参数就能用。
案例1:React项目:强制useEffect依赖数组完整(解决“数据不更新”问题)
需求背景
:团队里常有人写useEffect(() => { setData(list) }, [])
,漏写了依赖list
,导致list变化时effect不执行,数据不更新。想让ESLint自动检查:“如果effect函数里用到了某个变量,就必须出现在依赖数组里”。 实现思路:
useEffect
调用(节点类型CallExpression
,callee.name
是useEffect
) context.getScope().variables
) 核心代码片段
:
// 选择器:匹配useEffect调用
"CallExpression[callee.name='useEffect']"(node) {
const effectCallback = node.arguments[0]; // 第一个参数:回调函数
const depsArray = node.arguments[1]; // 第二个参数:依赖数组
if (!depsArray || depsArray.type !== "ArrayExpression") return; // 没传依赖数组(默认监听所有)
// 获取回调函数里用到的变量
const usedVariables = context.getScope(effectCallback).variables.map(v => v.name);
// 获取依赖数组里的变量名
const depsVariables = depsArray.elements.map(el => el.type === "Identifier" ? el.name null).filter(Boolean);
// 检查是否有遗漏的依赖
const missingDeps = usedVariables.filter(v => !depsVariables.includes(v));
if (missingDeps.length > 0) {
context.report({
node: depsArray,
message: useEffect依赖数组遗漏变量:${missingDeps.join(', ')}
});
}
}
效果
:这个规则上线后,我们在业务项目里跑了一次全量检查,发现了12处漏写依赖的地方,其中3处已经导致过线上bug。后来团队新人写useEffect时,再也没犯过类似错误——因为ESLint会直接标红提示。
案例2:Vue项目:强制组件文件名与组件名一致(解决“找文件难”问题)
需求背景
:Vue项目里,有人把组件文件命名为user-info.vue
,但组件名写成UserInfo
;有人写成UserInfo.vue
,组件名却是userInfo
。导致找组件时要猜文件名,效率很低。想强制“文件名(kebab-case)转大驼峰后,必须和组件名一致”(如user-info.vue
→ 组件名UserInfo
)。 实现思路:
context.getFilename()
) user-info.vue
→ UserInfo
) export default
对象,获取name
属性 name
属性,不一致则报错 核心代码片段
:
// 选择器:匹配export default对象
"ExportDefaultDeclaration"(node) {
const filename = context.getFilename();
if (!filename.endsWith(".vue")) return; // 只检查.vue文件
// 提取文件名并转大驼峰:user-info.vue → UserInfo
const fileName = filename.split("/").pop().replace(".vue", "");
const componentNameExpected = fileName.replace(/-(w)/g, (_, c) => c.toUpperCase()).replace(/^w/, c => c.toUpperCase());
// 获取组件name属性
const componentOptions = node.declaration;
if (componentOptions.type !== "ObjectExpression") return; // 不是对象导出
const nameProperty = componentOptions.properties.find(p =>
p.type === "Property" && p.key.name === "name" && p.value.type === "Literal"
);
if (!nameProperty) { // 没写name属性也报错
context.report({ node, message: "Vue组件必须声明name属性" });
return;
}
const componentNameActual = nameProperty.value.value;
if (componentNameActual !== componentNameExpected) {
context.report({
node: nameProperty,
message: 组件名必须为${componentNameExpected}(与文件名${fileName}.vue对应)
});
}
}
效果
:这个规则在团队推行后,新人接手项目时,通过“组件名搜文件名”的准确率从原来的50%提升到了95%。有次产品经理问“用户列表组件在哪”,新人直接搜UserList.vue
就找到了,不用再翻文件夹问人。
案例3:通用场景:工具函数必须写JSDoc注释(解决“新人看不懂”问题)
需求背景
:工具函数文件utils/date.js
里,函数formatDate
没写注释,新人用的时候不知道参数格式(formatDate('2023-10-01')
还是formatDate(new Date())
),导致传错参数。想强制“所有工具函数必须写JSDoc,包含@param和@returns”。 实现思路:
ExportNamedDeclaration
节点里的函数声明) context.getCommentsBefore(node)
) @param
(每个参数对应一个)和@returns
规则效果验证
:
我在团队推行这个规则后,配合VSCode的“自动生成JSDoc”插件(快捷键/** + Enter
),工具函数的注释覆盖率从30%提升到了90%。新人上手时,对着注释就能用函数,问“这个参数是什么意思”的问题减少了70%。
最后想说,自定义ESLint规则不是“炫技”,而是“用工具解决重复劳动”。你不用一开始就追求完美,先从团队最痛的1-2个问题入手,写个简单规则跑起来,再慢慢迭代。比如我第一次写的规则只有50行代码,却解决了团队80%的var声明问题。
如果你按照这些步骤试了,遇到“AST节点匹配不上”或“调试没反应”的问题,欢迎在评论区留言具体场景,我帮你看看怎么调整。记住,好的代码规范不是“约束”,而是让团队把精力从“争论格式”转向“解决业务问题”——这才是自定义规则的真正价值。
不用非得先啃AST那堆理论知识,真的。我见过不少前端同学一开始就抱着《AST语法树完全指南》啃,结果看了两天就劝退了——其实完全没必要。你想想,咱们写规则又不是要开发编译器,就是想让ESLint帮咱们抓点代码里的小毛病,重点是“解决问题”,不是“成为AST专家”。
去年我带一个实习生写“禁止在循环里用setState”的规则,他一开始也纠结“要不要先学AST节点类型”,我直接让他打开AST Explorer(就是那个在线工具,网址记不住没关系,搜“AST Explorer”第一个就是),把违规代码粘进去:for(let i=0;i<10;i++){this.setState({a:i})}
。右边立马就显示出代码的AST结构了,他一眼就看到了最外层是ForStatement节点,里面嵌套着CallExpression节点,而CallExpression的callee属性正好是this.setState。我跟他说:“你看,这不就找到了吗?规则要做的就是‘当ForStatement里有callee是setState的CallExpression时,就报错’,根本不用管AST的底层原理。”后来他照着这个思路,半小时就写出了第一版能用的规则,比死磕理论快多了。
你刚开始用工具的时候,可能会犯“选择器太笼统”的错。比如想匹配“函数里的console.log”,直接写CallExpression[callee.object.name=’console’],结果发现全局的console.log也被匹配上了。这时候别慌,在AST Explorer里多试几次——点一下右边的节点,左边代码里对应的部分会高亮,你就能看清节点之间的层级关系。比如函数里的console.log,它的父节点是FunctionBody,那选择器就改成FunctionBody CallExpression[callee.object.name=’console’],这样就能精准匹配函数内部的调用了。工具用熟了,你甚至不用刻意记节点类型,写得多了自然就知道“变量声明对应VariableDeclaration”“if语句对应IfStatement”,就像咱们写CSS不用刻意记所有选择器,用多了自然就会了。
自定义ESLint规则需要先学AST吗?
不需要系统学习AST,掌握基础工具即可。你可以用AST Explorer(https://astexplorer.net/)实时查看代码对应的AST结构,重点关注节点类型和关键属性(如函数调用的callee、变量声明的kind)。刚开始写规则时,直接复制目标代码的节点选择器,再根据需求调整判断逻辑,就能满足大部分简单场景。
如何判断一个问题是否需要自定义ESLint规则?
符合3个条件就值得做:
自定义规则写好后不生效,可能是什么原因?
常见原因有3个:
规则写好后,如何在团队中推广落地?
分3步推进:
所有自定义规则都需要支持自动修复吗?
不一定,视规则类型而定。优先给“格式类问题”(如文件名与组件名不一致)添加自动修复,这类问题修复逻辑简单(替换文本即可),能显著提升效率;而“业务逻辑类问题”(如漏写hooks依赖) 只提示不自动修复,避免机器修复可能引入的风险(比如依赖数组自动补全可能漏填间接依赖)。刚开始写规则时,可先实现“报错”功能,后续再迭代“修复”逻辑。