
react-doctor 深度解析把「You Might Not Need an Effect」八条 ESLint 规则原生移植进 Oxlint 插件【免费下载链接】react-doctorYour agent writes bad React. This catches it项目地址: https://gitcode.com/GitHub_Trending/re/react-doctor在 React 项目中把可以派生的值存进 state、用 useEffect 做同步 是最常见的反模式。react-doctor 的 Oxlint 插件内建了八条专门打击这类问题的react-doctor/规则——它们是从 ESLint 社区插件eslint-plugin-react-you-might-not-need-an-effect逐条移植而来的原生 TypeScript 实现。本文以移植说明文档 SOURCE.md 为主线结合插件内的规则源码与工具函数讲清这八条规则的来龙去脉、移植时如何解决 oxlint 与 ESLint 之间的阻抗失配以及移植版相对上游刻意保留的五类已知分歧帮助读者既会用这些规则也理解它们为什么这样判定。一、移植背景从可选的 peer 依赖变成内建规则SOURCE.md 记录了这次移植的完整出处上游包eslint-plugin-react-you-might-not-need-an-effectMIT 协议Copyright © 2025 Nick van Dyke许可证全文保留在文档中移植时对应的上游 commit4c71faaa7623d2d5feb33983dc2ebcc08206bcc5版本0.10.1当时main分支的 HEAD移植内容上游插件的全套规则保留其 scope-aware 的引用追踪reference chasing与诊断消息风格移植后的规则 ID 映射如下全部落在oxlint-plugin-react-doctor包内本仓库规则 IDreact-doctor/id上游对应文件no-derived-statesrc/rules/no-derived-state.jsno-chain-state-updatessrc/rules/no-chain-state-updates.jsno-event-handlersrc/rules/no-event-handler.jsno-adjust-state-on-prop-changesrc/rules/no-adjust-state-on-prop-change.jsno-reset-all-state-on-prop-changesrc/rules/no-reset-all-state-on-prop-change.jsno-pass-live-state-to-parentsrc/rules/no-pass-live-state-to-parent.jsno-pass-data-to-parentsrc/rules/no-pass-data-to-parent.jsno-initialize-statesrc/rules/no-initialize-state.js为什么规则要挪到react-doctor/命名空间下。移植之前这八条规则是以effect/rule-id的形式启用的扫描时由核心包的插件解析器 plugin-resolution.ts 在用户项目里发现上游 ESLint 插件再借道加载。这意味着项目必须安装那个可选的 peer 依赖才能享受对应诊断。移植之后同样的规则语义直接内建于oxlint-plugin-react-doctor项目不再依赖任何外部插件。文档同时特意说明了一个容易混淆的点仓库中原本就存在的同主题规则——no-derived-state-effect、no-effect-chain、no-event-trigger-state、no-prop-callback-in-effect——全部保留。它们针对的是不同的代码形态、发出不同的诊断消息与移植进来的八条规则互补而非替代。八条移植规则与它们的测试用例实际位于 state-and-effects 规则目录例如 no-derived-state.ts、no-event-handler.ts、no-chain-state-updates.ts、no-adjust-state-on-prop-change.ts、no-pass-data-to-parent.ts。二、规则语义速览八条规则各打什么形态的代码上游插件的共同前提是 React 官方文档中 You might not need an effect 的论断一个useState的值如果是从 React 事件处理器写入的那么相关的派生/同步工作完全可以折叠进该处理器或者干脆在渲染时计算不需要 useEffect。八条规则从不同角度切这个主题no-derived-state把能从 props/state 派生的值复制进 state 并靠 effect 同步会多付一次渲染。规则源码 no-derived-state.ts 中severity 为warn诊断消息为Storing xxx in state when you can derive it from other values costs an extra render.见 源码 L38。它通过createComponentPropStackTracker同时覆盖渲染期写入与useEffect内的写入两条路径源码 L41-L75推荐做法是渲染时计算昂贵时用useMemo。no-initialize-state只在挂载时跑一次的空依赖useEffect里设置 state 初始值用户会先看到一次空值渲染。no-initialize-state.ts 精确识别useEffect第二个参数是空数组的挂载 effect消息为Your users see an extra render with empty xxx because a useEffect sets its starting value.推荐直接把初始值传给useState()SSR 水合场景则建议useSyncExternalStore()。no-event-handler在useEffect的if判定里引用 state ref、又触发副作用的模式——即伪造的事件处理器。no-adjust-state-on-prop-change/no-reset-all-state-on-prop-changeprop 变化时用 effect 调整/重置 state属于状态同步反模式。no-pass-live-state-to-parent/no-pass-data-to-parent把本可下沉/派生的数据经由 effect 抬升传给父组件。no-chain-state-updates在一个 effect 里链式触发多次 state 更新。各规则对应的回归测试如 no-derived-state.regressions.test.ts、no-event-handler.regressions.test.ts、no-chain-state-updates.regressions.test.ts守护着移植版与上游的行为一致性以及下文要讲的各种豁免分支。三、阻抗失配oxlint 插件里没有 ESLint 的 Scope Manager这是本次移植在工程上最有价值的部分。SOURCE.md 明确指出上游插件是 ESLint 原生的它靠context.sourceCode.getScope().references[]再沿ref.resolved.defs[].node.init/body递归地追每个值最终来自哪里即 upstream refs。而 oxlint 的 JS 插件不会把 ESLint 的 scope manager 交给 JS 规则——上游的核心机制在宿主里直接不可用。移植的解法是在 get-program-analysis.ts 中按 Program 节点惰性构建一个完整的 eslint-scope 分析并在整个文件内共享// HACK: WeakMap keyed on the live Program node so all 8 effect rules // share a single eslint-scope analysis per file. The analysis is built // lazily on first access from any rule. const programToAnalysis: WeakMapEsTreeNode, ProgramAnalysis new WeakMap();源码 L28-L59 展示了完整构建过程调用eslint-scope的analyze()配置ecmaVersion: 2024、sourceType: module用RUNTIME_VISITOR_KEYS作为childVisitorKeys并用 get-ast-child-keys.ts 作为 fallback 覆盖 Oxc 解析出的 TypeScript 专有节点类型随后把每个 scope 的references预建成referenceByIdentifierWeakMap供规则做 O(1) 查引用。ProgramAnalysis结构体包含scopeManager、scopeByNode和referenceByIdentifier三张索引被透传给 ast.ts 与 react.ts 中的 helper——例如getUpstreamRefs、getDownstreamRefs、isState、isProp、isStateSetterCall等正是这些 helper 复刻了上游追引用到源头的能力。另一个细节在 getScopeForNode它复刻上游context.sourceCode.getScope(node)的语义——返回包含该节点的最内层scope实现方式是找block.range严格包含节点 range 且尺寸最小的 scope尺寸比较用是为了在全局 scope 与模块 scope 共享同一 Program range 时让后创建即更内层的模块 scope 胜出从而保证模块级声明落在模块 scope 而非全局 scope。结果经 WeakMap 缓存同一文件内所有规则反复查询同一标识符都能直接命中。四、已知分歧移植版在五个地方刻意偏离上游SOURCE.md 的 Known divergences 一节是全文技术密度最高的部分移植不是逐字节照抄而是基于真实语料审计后有意识地修正上游的误报。逐条拆解如下。4.1 保留上游自己的 TODO 用例上游 parity 套件里有一个被标记todo: true的禁用用例Set derived state via identical intermediate setter移植版原样保留了该标记——既尊重上游现状又便于将来对齐。4.2 诊断消息从 messageId 模板到预替换字符串上游用messageId: avoidDerivedStatedata: { state: fullName }的形式由 ESLint 经meta.messages表展开oxlint 插件只能发出已替换好的字符串。文档说明多数替换后的文本与上游逐字节一致唯一例外是no-initialize-state——由于 oxlint 不暴露原始源码文本它改用了一个有界的 AST 字符串化器来拼出状态名。4.3no-derived-state的累加器豁免上游会报告形如setTotal((prev) prev count)的函数式更新器新值从自身参数算出。移植版刻意保持沉默从一个值生长出来的状态本质上是随时间累积的累加器选择历史、已读集合、累计总数按定义无法从当前 props/state 推导出来——恰好不满足该规则的前提。但仅靠 spread 的对象合并setForm((prev) ({ ...prev, field: derived }))仍然会被报告prev 只是被 spread 透传所有新信息都派生自 props/state。受此翻转影响的两个上游 invalid 用例no-derived-state.json中的 From props via callback setter 与syntax.json中的 Value-less useState在 parity 测试夹具中被标记todo: true。4.4no-adjust-state-on-prop-change的消息重写与严重级别上游以type: suggestion提供这条规则文案是缓和的 Avoid … Instead … 风格。移植版把它改写成本目录 error 级 effect 规则统一的错在哪 → 为什么 → 怎么修权威句式检测行为不变、仅诊断文案分歧。文档还交代了严重级别的历史该规则与本目录的 derived-state 家族在同一个 effect 上会共同触发、共享同一类误报模式不可推导的交互/环境/草稿/握手状态曾被短暂提升为error后来经语料审计降级回warn与家族其余成员一致。4.5 外部驱动 / 渲染期不可知状态的抑制你或许不需要一个 effect 的整个前提假设useState的值是从 React 事件处理器写入的因此工作可以折叠进处理器。当 state 是被命令式地驱动时前提失效上游也会误报。移植版引入两个共享判别器收窄了这个问题reads-post-mount-value.ts值来自 DOM / ref 的.current/ 浏览器全局window、matchMedia等时该值在渲染期根本产生不出来元素尚未挂载、SSR 下全局对象不存在所以no-derived-state、no-adjust-state-on-prop-change、no-event-handler、no-initialize-state跳过它。从源码可以看到其判定集合的精细度getBoundingClientRect、querySelector、matchMedia这类无歧义的 DOM API 名直接匹配而.current、offsetWidth、scrollTop这类有歧义的属性名普通数据对象也常见只有在接收者是 ref 形态时才计为挂载后读取document、window、localStorage等浏览器全局与useRef/createRef工厂调用也是判定输入。external-state.ts 中的 isExternallyDrivenState当某个 state 的 setter 只从延迟回调中被调用时就没有可折叠的 React 事件处理器了。源码 L12-L28 列出延迟 callee 清单setTimeout、setInterval、setImmediate、requestAnimationFrame、requestIdleCallback、queueMicrotask、addEventListener、addListener、subscribe、observe、watch、watchPosition、then、catch、finally、on、once。isDeferredCallbackPosition还覆盖了三种延迟位置延迟调用的参数槽、*Observer/Promise构造函数的参数以及赋值给on*事件属性el.onmessage expr——同时刻意不把配置对象里的onX:键当作延迟因为{ onDestroyed: handler }这类回调通常就发生在同步 React 路径上。命中该判别器时no-event-handler、no-pass-live-state-to-parent、no-prop-callback-in-effect、no-chain-state-updates跳过同目录的 no-cascading-set-state.ts 也相应地停止对延迟回调内的 setter 求和同步 IIFE /forEach形态的回调与setX(prev …)更新器仍计数。4.6 另两条细化豁免no-event-handler的仅 setter 后果跳过该规则报告useEffect中if判定里的 state ref但上游语料里每一个真阳性其if后果里都在跑非 setter 副作用submitData(...)、showNotification(...)——那正是建议要折叠进触发处理器的工作。如果后果只有setter / ref 簿记语句那是状态同步受控/非受控镜像if (valueProp ! undefined) setValue(valueProp)即 adjust-state-on-prop-change归专属的 state-sync 规则管。跳过这类if后受控/非受控输入模式真实语料来自 Innovaccer/lobe-ui/Victory 等仓库不再被误判为伪造事件处理器。no-derived-state的受控/非受控值镜像当 effect 的 setter 收到的是裸 prop 标识符且同一个 setter 还在别处被调用setInput(event.target.value)、onOpenChange处理器里的setUncontrolledOpen(nextOpen)说明 state 保存的是用户的实时编辑、只是重新同步到受控 prop——用useMemo推导会把用户编辑抹掉因此跳过由 is-controlled-prop-mirror.ts 实现。由于上游派生语料从未在镜像裸 prop 的同时写入同一 stateparity 得以保持全部 54 个 invalid 用例仍然全部触发包括参数是对象字面量而非裸 prop 的 dead-wrapper 双调用点夹具。五、验证路径与延伸阅读移植说明与许可证SOURCE.md 全文保留了上游 MIT 许可证原文以示归属是理解八条规则出处与分歧的第一入口。规则实现八条规则本体位于 state-and-effects 目录其中 no-pass-data-to-parent.ts约 1900 行与 no-reset-all-state-on-prop-change.ts约 1350 行是体量最大的两条no-adjust-state-on-prop-change.ts 中的writesPropDerivedValue则展示了如何借getUpstreamRefsisProp沿引用链判定写入值派生自 prop。共享工具层utils/effect 目录下的ast.ts、react.ts、external-state.ts、get-program-analysis.ts是八条规则的公共底座reads-post-mount-value.ts 则位于更上层的插件 utils供跨规则复用。回归测试每条规则都配有.test.ts与.regressions.test.ts如 no-initialize-state.regressions.test.ts、no-pass-live-state-to-parent.regressions.test.ts、no-pass-data-to-parent.regressions.test.ts锁定移植版相对上游的既有语义与新增豁免。加载机制移植前effect/rule-id的扫描时插件发现逻辑见 plugin-resolution.ts移植后规则随插件直接注册用户项目无需再装上游包。六、小结react-doctor 对eslint-plugin-react-you-might-not-need-an-effect的移植示范了一条完整的社区 ESLint 规则 → 原生 Oxlint 插件工程路径规则语义逐条对齐上游并保留 parity 夹具宿主缺失的 scope 分析用 eslint-scope 按 Program 惰性重建并全文件共享再用两份共享判别器挂载后读取、外部驱动状态加两条细化豁免系统性消解上游在命令式、受控/非受控场景下的误报且每一处偏离都留档在 SOURCE.md 的 Known divergences 中可审计。对使用者而言这八条react-doctor/规则现在开箱即用对维护者而言这份文档加配套测试构成了一张哪里与上游一致、哪里刻意不同、为什么的完整地图。【免费下载链接】react-doctorYour agent writes bad React. This catches it项目地址: https://gitcode.com/GitHub_Trending/re/react-doctor创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考