Instatic 前端性能守则:React Compiler 全量启用下的记忆化治理与三个例外
2026/9/16 12:24:42 网站建设 项目流程

Instatic 前端性能守则:React Compiler 全量启用下的记忆化治理与三个例外

【免费下载链接】InstaticThe open-source alternative to Webflow, Framer and WordPress. Agentic self-hosted visual CMS outputting clean static pages. Users, roles, plugins, content, database, it's all there.项目地址: https://gitcode.com/GitHub_Trending/in/Instatic

React Compiler 在 Instatic 全站启用后,构建期自动记忆化取代了手工useMemo/useCallback/memo(),团队将"不写记忆化"固化为硬性规则,并只保留三条带注释的合法例外。本文以 docs/reference/react-compiler.md 为骨架,结合 vite.config.ts、eslint.config.js、react-doctor.config.json 及真实组件源码,讲清这条规则的来龙去脉:编译器如何接入、三条例外分别长什么样、门禁如何落地,以及你提交新代码时如何判断该不该保留一段手写记忆化。

React Compiler 在 Instatic 中的启用方式

Instatic 的前端构建链路是 Vite + Babel + Rolldown。React Compiler 通过@vitejs/plugin-react提供的预设接入,位于 vite.config.ts:

export default defineConfig({ plugins: [ largeBodyDevProxyPlugin(), publicSiteDevProxyPlugin(), react(), babel({ presets: [reactCompilerPreset()] }), ], // ... })

关键点在于没有显式传入compilationMode,而是使用预设默认的infer模式。配置文件中的大段注释记录了团队踩过的坑(vite.config.ts):

  • infer模式只编译"看起来像组件或 Hook"的函数:UpperCamelCase命名且返回 JSX 的组件、useFoo命名的 Hook;
  • 普通辅助函数不会被编译——包括路由层传给useSyncExternalStore的模块级browserSubscribe/getBrowserSnapshot,以及 Zustand 的 selector 箭头函数;
  • 此前曾尝试compilationMode: 'all'(连辅助函数一起编译),结果导致在非 Hook 代码中插入useMemoCache,破坏了 Rules-of-Hooks,产生报错后回退到预设默认值;
  • 项目状态层使用 Zustand + Mutative(zustand-mutative),create()setState((draft) => …)的 draft 只在回调期间存活,selector 在组件渲染期间读取到的都是create返回后的普通不可变对象,因此编译器 memo cache 持有的引用永远有效——"revoked proxy" 报错在 babel-plugin-react-compiler 1.0 GA 后也不再出现。

这段注释本身就是一份很好的"编译器接入避坑清单":如果你要在别的 Vite 项目里启用 React Compiler,默认infer模式是最安全的选择,避免对辅助函数过度编译

核心规则:不写useMemo、不写useCallback、不写memo()

React Compiler 会在构建期自动为每个组件和 Hook 做记忆化,因此手写记忆化属于"噪音"——它只增加代码杂乱度,不带来额外性能收益。项目规则(见 CLAUDE.md 与 docs/reference/react-compiler.md):

不要写useMemouseCallbackmemo()直接写普通的值、普通的函数、普通的组件——编译器会替你完成记忆化。新代码不得引入手工记忆化,存量手工记忆化正在被逐步移除。

同时明确划清一条边界,避免误伤:

useState(() => …)惰性初始化和useRef(…)不属于记忆化,任何时候都可以放心使用,不受本规则影响。

也就是说,"记忆化"特指useMemo/useCallback/memo()这三件套;惰性 state 初始值与 ref 是 React 的常规用法,不要因为"编译器会记忆化"而绕开它们。

三个合法例外:记忆化保留的唯一场景

规则并不是一刀切。恰好有三种情况手写记忆化合法存在,但前提是:保留它,并加一行注释说明原因,让下一个阅读者(以及 linter)知道这是有意为之。三种例外如下。

例外 1:函数出现在 Hook 依赖数组中

静态规则react-hooks/exhaustive-deps看不到编译器在运行期的记忆化结果,它仍然要求依赖数组中的每一项具有稳定引用。因此,作为useEffect/useMemo/useCallback依赖项的函数,需要用useCallback包裹(连同它依赖的传递闭包一起),才能保持bun run lint通过。

注意范围很窄:只有函数会触发这条规则。如果依赖数组里喂的是普通值,直接内联即可,不需要useMemo包一层。

例外 2:热点、列表渲染组件上的React.memo重渲染兜底

React.memo在 props 相等时跳过重渲染,这与编译器"组件内部记忆化"是两种不同的机制。对于在 O(N) 关键路径上被渲染 N 次的递归组件(比如画布上按节点递归渲染的树形渲染器),贸然摘掉React.memo并不等价于"编译器会处理"——没有运行期性能验证前,这是行为变更而非等价优化。这类场景很少见,必须写注释说明。

仓库中恰好有三处真实例子,注释都按规范标注了"Exception #2":

画布节点渲染器src/admin/pages/site/canvas/NodeRenderer.tsx:

// React Compiler exception #2: memo() re-render bailout on a hot, recursive // per-node canvas renderer (O(N) critical path) — kept intentionally. export const NodeRenderer = memo(function NodeRenderer({ nodeId }: NodeRendererProps) { // Per-node subscription — editing this node's props only re-renders THIS component. const node = useEditorStore((s) => selectActiveCanvasPage(s)?.nodes[nodeId] ?? null) // ... })

DOM 面板的树节点src/admin/pages/site/panels/DomPanel/TreeNode.tsx:

// React.memo re-render bailout — exception #2: hot, recursive per-node tree row // rendered for every node in the document; skipping equal-prop re-renders here is // an O(N) critical path the React Compiler's within-render memoization can't cover. export const TreeNode = memo(function TreeNode({ nodeId, depth, editable = true }: TreeNodeProps) {

Agent 面板的 Markdown 气泡src/admin/pages/site/panels/AgentPanel/AgentPanel.tsx:AI 流式输出时每个文本块都是一个气泡,未变化的块不应在每次流式增量时重新解析 Markdown:

// Exception #2: React.memo re-render bailout on a hot, list-rendered component // (one per text block, re-rendered on every streaming delta). const MarkdownTextBubble = memo(function MarkdownTextBubble({ text, isUser }: MarkdownTextBubbleProps) {

这三处的共同点:组件内部都通过 editor store 的细粒度 selector 订阅自己的数据("editing this node's props only re-renders THIS component"),memo承担的是"props 相等时跳过整棵子树重渲染"这一编译器覆盖不到的层面。如果你的组件不在这类 O(N) 热点路径上,就不属于例外 2。

例外 3:编译器 / linter 逼出来的逃逸口

例外 3 有两种子情况:

子情况 A:react-hooks/refs。渲染作用域内定义的事件处理器如果读写 ref(如someRef.current = …),写成裸函数会触发 "Cannot access refs during render",因为 linter 无法判断闭包是否只在事件时执行。用useCallback包裹即可满足规则。CanvasLiveSurface的指针处理器就是活例(src/admin/pages/site/canvas/CanvasLiveSurface.tsx):

// useCallback kept: react-hooks/refs escape hatch — dragRef.current is read/ // written in event handlers; a plain render-scoped function trips the // "ref access during render" lint rule. const handlePointerDown = useCallback( (side: 'left' | 'right') => (event: ReactPointerEvent<HTMLDivElement>) => { if (effectiveWidth === null || !activeBreakpoint) return dragRef.current = { startClientX: event.clientX, startWidth: effectiveWidth, side } event.currentTarget.setPointerCapture(event.pointerId) event.preventDefault() }, [effectiveWidth, activeBreakpoint], )

同类注释还出现在 src/admin/pages/dashboard/components/BlockLibrary.tsx 与 src/admin/spotlight/SpotlightRoot.tsx。

子情况 B:编译器本身无法编译某函数时。在函数体顶部加"use no memo"指令(或沿用eslint-disable react-compiler/react-compiler注释模式),并保留该函数需要的手写记忆化。仓库规则为这种逃生通道预留了明确位置(vite.config.ts 的注释同样提示了这一点);从源码搜索看,当前src/下还没有任何函数实际使用"use no memo",说明这一子情况在存量代码中尚未触发,属于"真遇到编译器能力边界时才启用"的兜底手段。

执行门禁:lint/CI 是权威闸门,react-doctor 只是建议

权威闸门:bun run lint/ CI

执行门禁是eslint-plugin-react-compiler+eslint-plugin-react-hooks,跑在bun run lint(package.json 中定义为eslint . --cache --cache-location .tmp-lint/eslint-cache)和 CI 里,这是唯一权威的校验点:

  • eslint-plugin-react-compiler标记出编译器不得不 bail out 的函数(Rules-of-React 违规、渲染期修改 state 等),把"构建期才暴露的编译失败"提前到 lint 期拦截。其配置位于 eslint.config.js 与reactCompiler.configs.recommended
  • react-hooks/exhaustive-depsreact-hooks/refs负责强制例外 1 和例外 3 的正确写法。

也就是说,eslint 同时充当两重角色:既拦"不该写的记忆化"(compiler 规则),又逼"必须保留的记忆化"按规范写(hooks 规则)。

建议层:react-doctor 降级为 warning

react-doctorreact-compiler-no-manual-memoization规则同样会标记手写记忆化,但它无法识别上面三个例外,会在每个合法例外处误报。因此它在 react-doctor.config.json 中被配置为 advisory warning,而不是 error 闸门:

"rules": { "react-doctor/react-compiler-no-manual-memoization": "warn" }

此外,react-doctor.config.json还做了两处配套设定,值得留意:

  • "categories": { "React Compiler": "warn" }:React Compiler 分类是编译器自身诊断(todo bailout、set-state-in-effect 等)的移植。这些是"编译器能力限制"而非代码缺陷,且真正的权威闸门(eslint-plugin-react-compiler)已在 lint/CI 中运行,所以把整个分类从 error 降级为 warn——保持可见但不阻塞
  • "failOn": "error"bun run doctor只在 error 级诊断时以非零码退出。降级之后剩余的 error 层是高信号问题(Security、Correctness 等),这让 doctor 成为可靠的回归闸门,而不是永远红在编译器限制上;
  • "ignore": { "files": ["examples/**"] }examples/是给插件作者参考的示例插件代码,非产品代码,被有意排除在健康分统计之外,保证分数反映src/server/scripts/的产品代码。

实际操作结论:任何在三个例外之外新增的useMemo/useCallback/memo()一律视为漂移(drift),应当移除;在例外之内保留的,必须带上说明注释,否则同样会被当作漂移处理。

给新代码作者的提交自查清单

综合上面所有规则,写一个新组件时按这个顺序自查:

  1. 默认写法:直接写普通值、普通函数、普通组件,把记忆化交给编译器;
  2. 依赖数组里有函数?useCallback包裹并注明例外 1;
  3. 组件在 O(N) 热点路径上被列表/递归渲染?确认摘掉React.memo是否行为等价,不等价则保留并注明例外 2;
  4. 事件处理器读写 ref 被react-hooks/refs拦截?useCallback包裹并注明例外 3;
  5. 编译器真的编不过?"use no memo"指令并保留必要的手写记忆化;
  6. 最后过一遍bun run lint:以 eslint 结果为准;bun run doctor的 warn 提示如果是例外场景,忽略即可,若是例外之外的新记忆化,移除它。

相关文档与配置索引

本规则的完整上下文分布在仓库以下位置:

  • docs/reference/react-compiler.md —— 本文的主体规则文档;
  • CLAUDE.md —— 面向 Agent 的规则摘要与直接指令("React Compiler and memoization"一节),并在 CLAUDE.md 的 UI 规范中再次强调"编译器已开启,禁止手写useMemo/useCallback/memo(三个例外见上述文档)";
  • vite.config.ts —— 编译器接入(reactCompilerPreset())及infer模式取舍的完整注释;
  • eslint.config.js ——eslint-plugin-react-compilerreact-hooks的 flat config 接入;
  • react-doctor.config.json ——react-compiler-no-manual-memoization降级为 warn 的完整理由说明;
  • 源码实例:NodeRenderer.tsx、TreeNode.tsx、AgentPanel.tsx(例外 2)、CanvasLiveSurface.tsx(例外 3)。

这套"编译器全量启用 + 三条带注释例外 + eslint 权威闸门 + doctor 建议层"的组合,本质是把性能工程从运行期手工优化前移到构建期自动优化,同时用 lint 保住唯一需要人工干预的三个角落。对任何打算在 Vite 项目中启用 React Compiler 的团队,Instatic 这套治理模板都值得直接借鉴。

【免费下载链接】InstaticThe open-source alternative to Webflow, Framer and WordPress. Agentic self-hosted visual CMS outputting clean static pages. Users, roles, plugins, content, database, it's all there.项目地址: https://gitcode.com/GitHub_Trending/in/Instatic

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询