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

嘿,各位 React 的“代码修理工”们!欢迎来到今天的“ESLint 地下城”深度探险。我是你们的向导,一个曾经因为 props 被改得面目全非而深夜痛哭的资深 React 开发者。

今天我们不聊 Redux 怎么连,也不聊 TypeScript 怎么玩,我们来聊点更“硬核”的。我们聊聊如何利用 ESLint 的魔法棒,给我们的 React 项目套上枷锁,强制执行那些该死的架构约束

你可能会问:“为什么要这么麻烦?代码跑通了不就行了?”

哈哈,天真!代码跑通了,就像一辆法拉利装上了拖拉机的引擎,跑是能跑,但那是灾难。架构约束就是那个装在法拉利引擎里的V8 核心控制器。没有它,你的项目迟早变成一团名为 Component.jsComponent.jsComponent.js 的屎山。

准备好了吗?我们要开始动手了。


第一部分:AST,那玩意儿到底是什么?

在我们要写规则之前,得先聊聊 ESLint 到底在做什么。很多新手觉得 ESLint 就是检查一下语法对不对,有没有分号。错!大错特错!

ESLint 是一个静态代码分析工具。它的核心魔法在于 AST,也就是抽象语法树

你可以把 AST 想象成一个乐高积木的说明书
你写的代码:

function add(a, b) { return a + b; }

在 ESLint 眼里,它不是字符串,而是一棵树:

  1. FunctionDeclaration(函数声明)是根节点。
  2. 下面挂着两个 Identifiera, b)作为参数。
  3. 还有一个 BlockStatement(函数体)。
  4. 里面有个 ReturnStatement(返回语句)。
  5. 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 就是你定义的参数列表。简单直接。这会强迫你的架构师(也就是你自己)去思考:这个组件是不是太臃肿了?是不是该把 agename 提取到 Context 里面去了?


第五部分:实战演练四——强制使用自定义 Hooks(魔法 Hook)

在 React 项目中,我们经常封装一些高级 Hooks。比如 useRequest(用于封装 fetch 请求,处理 loading 和 error),或者 useForm(用于表单管理)。

很多时候,大家为了图省事,直接在组件里写 useEffect(() => { fetch(...) }, [])。这导致代码里到处都是重复的逻辑,难以维护。

我们的目标是:禁止在组件内部直接使用 useEffect 配合 fetchaxios。你必须使用我们封装好的 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。如果是,我们看它的第二个参数(依赖数组),看数组里有没有 fetchaxios

这就像是一个严厉的教导主任,站在你旁边,一旦你拿起 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 规则,我们实际上是在编译时对代码进行架构审计。

  1. no-props-mutation: 保护了 React 的单一数据源原则。
  2. no-container-with-ui: 强制了组件解耦,让代码可读性提升 50%。
  3. max-props-per-component: 驱动了架构重构,让“上帝组件”无处遁形。
  4. no-raw-fetch: 推动了代码复用,让 Hooks 成为常态。

当你把这几条规则集成到 package.json 里,并且配置好 pre-commit 钩子,你会发现,你的代码库变得越来越干净,越来越像一个正规军。

下次当你想写那个 if (a === undefined) 或者那个 props.age = 10 的时候,编辑器会立刻给你一记耳光。你会感到一阵凉意,但你会感谢这个凉意的,因为这意味着你救了未来的自己。

现在,去写你的第一个规则吧!哪怕只是禁止使用 var 这种老古董。你的项目会感谢你的。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注