嘿,各位 React 的“代码修理工”们!欢迎来到今天的“ESLint 地下城”深度探险。我是你们的向导,一个曾经因为 props 被改得面目全非而深夜痛哭的资深 React 开发者。
今天我们不聊 Redux 怎么连,也不聊 TypeScript 怎么玩,我们来聊点更“硬核”的。我们聊聊如何利用 ESLint 的魔法棒,给我们的 React 项目套上枷锁,强制执行那些该死的架构约束。
你可能会问:“为什么要这么麻烦?代码跑通了不就行了?”
哈哈,天真!代码跑通了,就像一辆法拉利装上了拖拉机的引擎,跑是能跑,但那是灾难。架构约束就是那个装在法拉利引擎里的V8 核心控制器。没有它,你的项目迟早变成一团名为 Component.js、Component.js、Component.js 的屎山。
准备好了吗?我们要开始动手了。
第一部分:AST,那玩意儿到底是什么?
在我们要写规则之前,得先聊聊 ESLint 到底在做什么。很多新手觉得 ESLint 就是检查一下语法对不对,有没有分号。错!大错特错!
ESLint 是一个静态代码分析工具。它的核心魔法在于 AST,也就是抽象语法树。
你可以把 AST 想象成一个乐高积木的说明书。
你写的代码:
function add(a, b) { return a + b; }
在 ESLint 眼里,它不是字符串,而是一棵树:
FunctionDeclaration(函数声明)是根节点。- 下面挂着两个
Identifier(a,b)作为参数。 - 还有一个
BlockStatement(函数体)。 - 里面有个
ReturnStatement(返回语句)。 ReturnStatement里面有个BinaryExpression(加法运算)。
当我们写自定义规则时,我们就是在遍历这棵树,看看哪个积木块放错了位置。比如,如果我们在树里找到了一个 AssignmentExpression(赋值表达式),而且左边是 props.something,那我们就大喊一声:“住手!你敢动 props?!”然后报错。
这就是我们要玩的游戏。
第二部分:实战演练一——禁止直接修改 Props
在 React 中,props 是只读的。这就像是你从老婆那里领了零花钱(props),你不能偷偷把你的零花钱存进银行,你得花掉它或者转给别人。但很多新手,或者写得太急的同事,喜欢这么干:
// 这里的代码简直是在犯罪
function UserProfile({ name, age }) {
age = age + 1; // 坏孩子!你会导致 React 组件无限重渲染!
return <div>{name} is {age}</div>;
}
虽然 React 15 还能忍,但到了 React 16+,这直接就是 Bug。为了防止这种“偷改家产”的行为,我们写一个规则。
规则代码:no-props-mutation
/**
* @fileoverview 禁止直接修改 props
* @author 代码警察
*/
module.exports = {
meta: {
type: "problem",
docs: {
description: "禁止直接修改 props,防止 React 重渲染地狱",
category: "Best Practices",
recommended: true,
},
schema: [], // No options
},
create(context) {
return {
// 监听所有的赋值表达式
AssignmentExpression(node) {
// 检查左边的对象是不是 'props'
const left = node.left;
// 必须是 MemberExpression (例如 props.x)
if (left.type === "MemberExpression") {
// 必须是计算属性访问 (例如 props['x']) 或者简单属性访问
if (
left.object.type === "Identifier" &&
left.object.name === "props" &&
!left.computed // 允许 props.x,不允许 props['x'] (虽然 props['x'] 也很少见)
) {
// 报错!
context.report({
node: node.left,
message: "禁止直接修改 props。请使用 useState 或其他状态管理。",
});
}
}
},
};
},
};
解析:
我们在 create 函数里返回了一个监听器。AssignmentExpression 就是赋值操作(=)。我们检查左边的对象是不是叫 props。如果是,就 context.report。这就完事了!现在,谁敢在代码里写 props.x = 1,编辑器就会像教导主任一样咆哮。
第三部分:实战演练二——强制“容器组件”与“展示组件”分离
这是一个经典的架构模式。在大型项目中,我们通常把“处理数据逻辑”的组件叫“容器组件”,把“只负责画 UI”的组件叫“展示组件”。
很多人写代码太懒,把逻辑和 UI 混在一起,结果一个文件 1000 行。
我们的目标是:如果这个文件里引入了 Redux 的 connect 或者 React Router 的 withRouter,那这个文件里就不允许出现 JSX 元素(JSXElement)。
规则代码:no-container-with-ui
/**
* @fileoverview 容器组件不应包含 UI
*/
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "强制容器组件与展示组件分离",
},
},
create(context) {
return {
// 监听 ImportDeclaration,看看有没有引入 Redux 或 Router
ImportDeclaration(node) {
const imports = node.specifiers
.filter(specifier => specifier.type === 'ImportSpecifier')
.map(specifier => specifier.local.name);
const isRedux = imports.includes('connect');
const isRouter = imports.includes('withRouter');
if (isRedux || isRouter) {
// 如果引入了这些,我们需要检查函数体里有没有 JSX
// 我们需要找到对应的函数声明或函数表达式
const parent = node.parent;
if (parent.type === 'FunctionDeclaration' || parent.type === 'FunctionExpression') {
// 深度遍历函数体,找 JSXElement
const hasJSX = checkNodeForJSX(parent.body);
if (hasJSX) {
context.report({
node: parent,
message: "容器组件(引入了 Redux/Router)不能包含 JSX。请拆分文件!",
});
}
}
}
},
};
},
};
// 辅助函数:递归检查 AST 节点中是否包含 JSXElement
function checkNodeForJSX(node) {
if (node.type === 'JSXElement') {
return true;
}
if (node.type === 'Program' || node.type === 'BlockStatement') {
for (const child of node.body) {
if (checkNodeForJSX(child)) return true;
}
}
return false;
}
解析:
这段代码稍微复杂一点。我们首先监听 ImportDeclaration。如果发现有人引入了 connect,我们就找到紧随其后的函数声明(FunctionDeclaration)。然后,我们写了一个递归函数 checkNodeForJSX,去扫描这个函数体。
一旦发现里面有 <div> 或者 <Button>,我们就报错。这就像是在你的代码里安装了一个红外线扫描仪,任何试图在容器组件里画图的行为都会被拦截。
第四部分:实战演练三——禁止“上帝组件”
一个组件如果有超过 5 个 props,它通常就已经在走下坡路了。当 props 超过 10 个的时候,这个组件基本上就是个“上帝组件”,谁都不敢动它,因为它太重了。
我们来写个规则:如果函数组件的参数超过 5 个,禁止编译。
规则代码:max-props-per-component
module.exports = {
meta: {
type: "error", // 错误级别,直接阻止编译
docs: {
description: "组件参数过多是架构腐烂的开始",
},
schema: [
{
type: "object",
properties: {
max: { type: "number", default: 5 },
},
additionalProperties: false,
},
],
},
create(context) {
const maxProps = context.options[0]?.max || 5;
return {
// 监听函数声明
FunctionDeclaration(node) {
// 检查是否是箭头函数
if (node.type === 'ArrowFunctionExpression') {
checkParams(node.params);
} else if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression') {
checkParams(node.params);
}
},
};
function checkParams(params) {
if (params.length > maxProps) {
context.report({
node: params[0], // 报错参数节点
message: `组件参数过多 (${params.length}个),超过了限制 ${maxProps}。请考虑使用 Context 或自定义 Hook 抽取!`,
});
}
}
},
};
解析:
这里我们引入了 schema,允许你在配置文件里自定义最大值。node.params 就是你定义的参数列表。简单直接。这会强迫你的架构师(也就是你自己)去思考:这个组件是不是太臃肿了?是不是该把 age 和 name 提取到 Context 里面去了?
第五部分:实战演练四——强制使用自定义 Hooks(魔法 Hook)
在 React 项目中,我们经常封装一些高级 Hooks。比如 useRequest(用于封装 fetch 请求,处理 loading 和 error),或者 useForm(用于表单管理)。
很多时候,大家为了图省事,直接在组件里写 useEffect(() => { fetch(...) }, [])。这导致代码里到处都是重复的逻辑,难以维护。
我们的目标是:禁止在组件内部直接使用 useEffect 配合 fetch 或 axios。你必须使用我们封装好的 useRequest。
规则代码:no-raw-fetch
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "强制使用 useRequest 替代原生的 fetch/axios",
},
},
create(context) {
return {
// 监听函数体
FunctionDeclaration(node) {
checkBody(node.body);
},
FunctionExpression(node) {
checkBody(node.body);
},
ArrowFunctionExpression(node) {
checkBody(node.body);
},
};
function checkBody(bodyNode) {
// 递归查找
const visitor = {
CallExpression(node) {
// 1. 检查是不是 useEffect
if (node.callee.name === 'useEffect') {
// 2. 检查 useEffect 的参数数组里有没有 fetch 或 axios
const deps = node.arguments[1]; // 第二个参数是依赖数组
if (deps && deps.elements) {
deps.elements.forEach(element => {
if (element.type === 'Identifier') {
const name = element.name;
if (name === 'fetch' || name === 'axios') {
context.report({
node: node,
message: `在 useEffect 中直接使用 ${name} 是反模式的。请使用封装好的 useRequest Hook。`,
});
}
}
});
}
}
},
};
function traverse(node) {
if (!node) return;
// 执行 visitor 里的检查
if (visitor[node.type]) {
visitor[node.type](node);
}
// 递归子节点
for (const key in node) {
if (node.hasOwnProperty(key) && typeof node[key] === 'object' && node[key] !== null) {
if (Array.isArray(node[key])) {
node[key].forEach(child => traverse(child));
} else {
traverse(node[key]);
}
}
}
}
traverse(bodyNode);
}
},
};
解析:
这个规则稍微有点“侵入性”,因为它要递归遍历函数体。我们监听 CallExpression,检查被调用者是不是 useEffect。如果是,我们看它的第二个参数(依赖数组),看数组里有没有 fetch 或 axios。
这就像是一个严厉的教导主任,站在你旁边,一旦你拿起 fetch,他就把你手里的书夺走,塞给你一本《useRequest 使用指南》。
第六部分:进阶技巧——基于文件名的架构检查
有时候,架构不仅仅是代码逻辑,还包括文件结构。
比如,我们规定:所有的“展示组件”必须放在 components/UI 目录下,所有的“页面组件”必须放在 pages 目录下。
我们可以写一个规则,检查导入路径。
规则代码:enforce-file-location
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "强制组件必须放在正确的目录结构中",
},
},
create(context) {
return {
ImportDeclaration(node) {
// 获取当前文件所在的目录
const currentFilePath = context.getFilename();
const currentDir = currentFilePath.substring(0, currentFilePath.lastIndexOf('/'));
// 获取导入的模块名(假设我们导入的是组件)
const importSource = node.source.value;
// 简单的逻辑:如果当前文件在 src 目录下,导入的组件不应该在 src 根目录
// 这是一个非常粗糙的例子,实际项目需要更复杂的路径解析
if (currentDir.includes('src/pages')) {
if (importSource.startsWith('./')) {
const importedPath = importSource.substring(2);
// 如果导入路径指向的是根目录组件,而不是子目录
if (!importedPath.includes('/')) {
context.report({
node: node.source,
message: "页面组件应该引用子目录中的组件,以保持目录结构清晰。",
});
}
}
}
}
};
},
};
解析:
这个规则利用了 context.getFilename()。它知道你当前正在编辑哪个文件。通过分析导入路径,它强制你遵守文件系统的约定。这虽然听起来像是 IDE 的功能,但把它固化在 ESLint 中,可以防止团队成员“懒”得去建文件夹。
第七部分:如何让规则“活”起来——调试与测试
写完规则很容易,但写对规则很难。AST 很复杂,稍微写错一个属性,规则就会失效。
1. 调试技巧
当你的规则报错时,你不知道为什么,怎么办?
ESLint 提供了一个超级好用的命令:
eslint --debug your-file.js
这会输出海量的日志。你可以看到 AST 的完整结构,看到 node.type 是什么。你可以复制日志里的 JSON 结构,去网上查文档,或者直接在控制台打印出来看。
2. 测试你的规则
不要手动测试!手动测试会累死你的。使用 eslint-rule-tester。
const RuleTester = require("eslint").RuleTester;
const rule = require("./my-rule");
const ruleTester = new RuleTester({
parserOptions: { ecmaVersion: 2018, sourceType: "module" },
});
ruleTester.run("my-rule", rule, {
valid: [
{
code: "const a = 1;", // 这段代码应该合法
},
],
invalid: [
{
code: "props.x = 1;", // 这段代码应该报错
errors: [{ message: "禁止直接修改 props" }],
},
],
});
写单元测试不仅能保证规则正确,还能防止你下次改代码时把规则改坏了。这叫“防御性编程”,只不过这次防御的是你自己。
第八部分:不要成为规则的奴隶——平衡的艺术
好了,我们讲了这么多,是不是觉得 ESLint 是个暴君?
千万别!自定义规则是为了自动化和一致性。
如果你发现你的规则报错率太高,导致团队开发效率下降,那就是规则写得太烂了。你需要调整 meta.type:
error: 禁止,必须修复。warning: 警告,可以不修,但最好修。suggestion: 建议。
不要把规则写成“屎山过滤器”,要把它写成“代码洁癖清洁工”。
第九部分:总结——构建你的“防御塔”
在 React 的世界里,我们用 Hooks 来管理状态,用 Redux/Context 来管理数据流。但代码的结构和组织方式,往往比数据流更难控制。
通过自定义 ESLint 规则,我们实际上是在编译时对代码进行架构审计。
no-props-mutation: 保护了 React 的单一数据源原则。no-container-with-ui: 强制了组件解耦,让代码可读性提升 50%。max-props-per-component: 驱动了架构重构,让“上帝组件”无处遁形。no-raw-fetch: 推动了代码复用,让 Hooks 成为常态。
当你把这几条规则集成到 package.json 里,并且配置好 pre-commit 钩子,你会发现,你的代码库变得越来越干净,越来越像一个正规军。
下次当你想写那个 if (a === undefined) 或者那个 props.age = 10 的时候,编辑器会立刻给你一记耳光。你会感到一阵凉意,但你会感谢这个凉意的,因为这意味着你救了未来的自己。
现在,去写你的第一个规则吧!哪怕只是禁止使用 var 这种老古董。你的项目会感谢你的。