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):
不要写
useMemo、useCallback、memo()。直接写普通的值、普通的函数、普通的组件——编译器会替你完成记忆化。新代码不得引入手工记忆化,存量手工记忆化正在被逐步移除。
同时明确划清一条边界,避免误伤:
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-deps和react-hooks/refs负责强制例外 1 和例外 3 的正确写法。
也就是说,eslint 同时充当两重角色:既拦"不该写的记忆化"(compiler 规则),又逼"必须保留的记忆化"按规范写(hooks 规则)。
建议层:react-doctor 降级为 warning
react-doctor的react-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),应当移除;在例外之内保留的,必须带上说明注释,否则同样会被当作漂移处理。
给新代码作者的提交自查清单
综合上面所有规则,写一个新组件时按这个顺序自查:
- 默认写法:直接写普通值、普通函数、普通组件,把记忆化交给编译器;
- 依赖数组里有函数?用
useCallback包裹并注明例外 1; - 组件在 O(N) 热点路径上被列表/递归渲染?确认摘掉
React.memo是否行为等价,不等价则保留并注明例外 2; - 事件处理器读写 ref 被
react-hooks/refs拦截?用useCallback包裹并注明例外 3; - 编译器真的编不过?加
"use no memo"指令并保留必要的手写记忆化; - 最后过一遍
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-compiler与react-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),仅供参考