React 静态分析增强:利用自定义 ESLint 规则强制执行 React 项目内的特定架构约束

张开发
2026/4/19 6:48:00 15 分钟阅读

分享文章

React 静态分析增强:利用自定义 ESLint 规则强制执行 React 项目内的特定架构约束
嘿各位 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函数声明是根节点。下面挂着两个Identifiera,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-componentmodule.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-fetchmodule.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-locationmodule.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.typeerror: 禁止必须修复。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这种老古董。你的项目会感谢你的。

更多文章