Plate UI 的 React 性能守则:Effect 逃生舱、渲染期派生与最小化编辑器状态订阅
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
Plate 的组件技能体系(plate-uiskill)中有一份专门约束 React 性能与 Effects 用法的规则文档 .agents/skills/plate-ui/rules/react-performance.md。它回答的是编辑器材 UI 开发中最常见的性能问题:什么时候该用useEffect、派生数据该在哪里计算、用户交互逻辑应该放在哪一层、以及订阅 Plate 编辑器状态时如何避免"每次光标移动都重读大块数据"。本文以这份规则文档为主体,逐条展开其判断标准与正反例,并结合 Plate 仓库中的真实源码(如useEditorSelector的实现与测试)说明这些规则在工程上如何落地。
规则的出处与适用前提
这份规则文档是 SKILL.md 定义的 Plate UI 组件编写规范中的一个子章节。SKILL.md 中"React Performance & Effects"一节的摘要与规则文档完全对应:
- 目标 React 版本不低于 19.2,不为 React 18 时代的限制保留兼容代码;
- Effects 是逃生舱,不是状态计算器;
- 派生数据在渲染期计算,除非在同步真实的外部系统;
- 交互逻辑放在事件处理器中,而不是监听状态的 Effect 里;
- 不在渲染输出确实不依赖的情况下订阅快速变化的编辑器状态;
- 不嵌套定义组件。
"React 19.2 基线"并非空话:仓库根目录 package.json 中声明的运行时依赖为react: 19.2.4、react-dom: 19.2.4(对应@types/react: 19.2.7),并且引入了babel-plugin-react-compiler: 1.0.0与eslint-plugin-react-hooks: 7.1.1。也就是说,规则文档中"Target React >=19.2"的基线与仓库实际依赖版本是严格一致的,写 Plate UI 代码时可以放心使用 React 19 的 Hook 语义,不必保留 React 18 时代的兼容分支(除非用户明确要求进行兼容性改造)。
配套的综合性参考文档 references/react.md 进一步展开了 Effects、派生状态、ref访问规则、useEffectEvent等内容,可作为规则文档的详细版索引;SKILL.md 的工作流也要求"在写任何 state/effect 之前先做 React 检查":能否在渲染期派生?是否应该留在事件处理器里?是否订阅了超出 UI 实际渲染需要的编辑器状态?
Effects 是逃生舱,不是状态搬运工
规则文档给出的判断标准非常明确:只有当需要同步一个外部系统时才使用 Effect,文档列举了四类合法场景:
- DOM 测量——例如在绘制后读取元素尺寸;
- 订阅——外部 store、浏览器事件、WebSocket 等;
- 命令式组件(imperative widgets)——第三方地图、播放器等需要命令式 API 的库;
- 因为"某内容被展示"而产生的分析/日志——如曝光埋点。
除此之外的用途都被禁止,尤其是"用 Effect 搬运本地渲染数据"(shuffle local render data)。配套参考文档 references/react.md 中的决策树把这条规则形式化了:需要转换/过滤/派生数据用于展示时,直接渲染期计算(不要useState+useEffect);需要同步外部系统时,Effect 内的setState才合法。其给出的理由值得记住:useState + useEffect派生数据的模式会造成一次携带过期数据的额外渲染,浪费算力,还可能引起视觉闪烁。
渲染期派生:先删掉 Effect,再看要不要 useMemo
规则文档给出的反例是典型的"派生数据存进 state":
const [isActive, setIsActive] = React.useState(false); React.useEffect(() => { setIsActive(selected && focused); }, [selected, focused]);正确写法是一行渲染期派生:
const isActive = selected && focused;规则文档对"要不要用useMemo"给出了一条实用判据:计算昂贵就用useMemo,计算廉价就直接算。这条判据在 Plate 的节点状态 Hook 里有现成的工程例证。以 useMediaState.ts 为例,它把useSelected()、useFocused()、useReadOnly()得到的布尔量与元素字段一起返回一小撮稳定事实,而对真正有成本的parseMediaUrl(url, { urlParsers })(按 URL 解析嵌入提供商)则包了React.useMemo,且依赖只取[urlParsers, url]——昂贵的部分被 memo,廉价的布尔量直接派生,与规则文档的分级策略完全吻合。该模式也被 component-audit.md 列为"good package extraction"的代表:包内 Hook 拥有真实的编辑器状态,App 层继续负责 shadcn 风格的组合与视觉。
需要补充的是:由于仓库启用了 React Compiler(根 package.json 中的babel-plugin-react-compiler),references/react.md 对手动 memo 的态度更严格——编译器会自动完成基于数据流分析的自动记忆化,useMemo/useCallback/React.memo只允许作为有注释说明理由的逃生舱(典型合法场景是稳定 Effect 依赖、对接对引用变化敏感的外部库、以及经 profile 验证的热点)。两条规则合起来可以概括为:默认不手动 memo;当确实昂贵时 memo,并说明理由。
事件处理器优先于 Effect
第二条高频错误是把"用户点了什么"的逻辑写在监听状态的 Effect 里。规则文档的反例:
React.useEffect(() => { if (open) { focusFirstItem(); } }, [open]);当open只会被本地用户操作改变时,这段逻辑属于交互,不属于同步。正确写法是把副作用放在状态变更的发起处:
const onOpenChange = (nextOpen: boolean) => { setOpen(nextOpen); if (nextOpen) focusFirstItem(); };这条规则在 Plate 的 toolbar 按钮模式里随处可见。看 useListToolbarButton.ts:pressed是渲染期订阅出来的只读事实,而真正的动作toggleList(editor, { listStyleType: nodeType })放在onClick处理器里执行;onMouseDown里只做e.preventDefault()防止按钮抢走编辑器焦点。SKILL.md 的 "Key Patterns" 中也把这种"只读派生事实 + 事件处理器里直接调editor.toggleBold()之类的命令"列为推荐模式:
const canToggleBold = bridgeState.canToggleBold; const onPress = () => editor.toggleBold(); return <Button disabled={!canToggleBold}>Bold</Button>;即:状态是事实,交互是动作,两者不要混在 Effect 里。配套参考文档还指出,当 Effect 里确实需要读取最新的非响应式值时,正确工具是useEffectEvent(非响应式逻辑可以读取最新 props/state 却不触发 Effect 重跑),而不是用eslint-disable压制依赖检查——references/react.md明确要求"绝不 suppress 依赖 linter"。
窄订阅:useEditorSelector 与"最小的诚实输出"
这是规则文档中与 Plate 编辑器特性结合最紧的一条:
当 UI 只需要一个很小的事实时,不要订阅宽泛的编辑器状态。
文档点名了三种坏模式:
- 只需要一个派生布尔量,却订阅原始 selection;
- 状态只在回调里用到,却仍然订阅;
- 为了一个微小的视觉提示,在每次光标移动时重读大块编辑器数据。
推荐做法是:
- 使用返回稳定布尔量或小切片的包/controller selector;
- 使用输出"最小且诚实"的
useEditorSelector; - 用本地派生布尔量代替原始状态对象。
"最小且诚实的输出"在源码层面有明确的实现支撑。useEditorSelector的实现在 packages/core/src/react/stores/plate/useEditorSelector.ts,其签名为:
export const useEditorSelector = <T, E extends PlateEditor = PlateEditor>( selector: (editor: E, prev?: T) => T, deps: React.DependencyList, { id, equalityFn = (a: T, b: T) => a === b }: UseEditorSelectorOptions<T> = {} ): T => { ... }三个参数各有明确的性能含义:
selector:每次编辑器变化时执行,返回值T就是组件真正需要的"事实"。selector 的第二个参数prev是上一次推导值,可用于廉价地判断"是否真的变了"(例如返回prev以跳过重算)。deps:selector 自身重建的依赖列表(实现里通过React.useMemo基于该列表重建 jotai 的selectAtom),避免每次渲染都创建新的 atom。equalityFn:决定"输出变化是否值得触发重渲染"的比较函数,默认严格相等。配合jotai-x的useStoreAtomValue与selectAtom,组件只对最终T的引用/相等性负责,而不是对原始编辑器状态的每次抖动负责。
这正是"窄订阅"的机制保证:selector 输出是什么,组件就只对什么负责。其测试 useEditorSelector.spec.tsx 用渲染计数直接验证了这一点:当editor.children被替换为新数组(编辑器状态变了)但 selector 输出editor.children.length仍为1且equalityFn判等时,渲染次数不增加;只有当派生值真正变为2时才多渲染一次。
实际包代码中的调用形态同样贴合"输出最小切片"的要求,useListToolbarButton.ts 中的例子:
const pressed = useEditorSelector( (editor) => someList(editor, nodeType), [nodeType] );selector 输出是一个布尔量,deps只有nodeType——工具栏按钮的"pressed"事实与编辑器其余状态彻底解耦。仓库内useEditorSelector的使用遍布packages/list、packages/table、packages/toc、packages/floating、packages/media等多个包(如 useTableMergeState.ts、useTocElement.ts),说明"controller/包 selector 返回稳定布尔量或小切片"是 Plate 各插件统一的订阅契约,而不是孤立约定。
不在组件体内定义组件
规则文档用 Toolbar 的例子说明这一条:
// 错误:每次 Toolbar 渲染都会创建全新的 Item 类型, // React 会将其视为新组件,导致子树卸载重建。 function Toolbar() { function Item() { return <Button>Bold</Button>; } return <Item />; }正确做法是把组件提升到模块作用域:
function ToolbarItem() { return <Button>Bold</Button>; } function Toolbar() { return <ToolbarItem />; }在编辑器这类渲染频繁的场景中,内联组件定义的危害会被放大:光标移动、选区变化都会触发重渲染,每次渲染都产生一个新的组件类型标识,React 无法复用子树的 fiber,等于每次都在做"卸载 + 挂载"。这与 SKILL.md 中 shadcn-proofing 的要求也一致——保持单文件可读、局部子部件(local subparts)用普通常量/闭包表达,而不是散落一堆会引发身份变化的内联组件。
memo 只在"付得起租金"时才用
规则文档的最后一节给出 memo 的使用门槛:
- 不要把简单表达式或廉价布尔量包进
useMemo; useMemo只在两种情况下使用:工作确实昂贵,或者它保护了一个有意义的子组件渲染边界;- "不要为了显得聪明而添加记忆化"(Do not add memoization just to feel clever)。
结合 references/react.md 中关于 React Compiler 的说明,这条规则在 Plate 仓库里有更完整的表述:写干净、地道的 React 代码,让编译器自动优化;手动useMemo/useCallback/React.memo只在三种情形下被接受——稳定 Effect 依赖、对接对引用变化敏感的外部库、以及经 profile 验证编译器不够用的热点,且必须用注释写明具体理由。也就是说,"pays rent"(付得起租金)是双重检验:既要有真实的昂贵计算或被保护的渲染边界,又要在启用编译器的前提下说清楚为什么自动优化不够。
落地清单
把规则文档的六条要点压缩成可执行的检查清单,供编写 Plate UI 组件前自查:
- 基线检查:代码是否使用了 React 18 兼容分支?基线是 React >=19.2(与根 package.json 的
react: 19.2.4一致),默认不加兼容代码。 - Effect 检查:这个 Effect 同步的是 DOM 测量、订阅、命令式组件还是曝光日志?否则删掉它。
- 派生检查:
setState的值能否由当前 props/state 直接算出?能则改成渲染期派生(如const isActive = selected && focused)。 - 交互检查:逻辑是否由用户的点击/输入/工具栏按钮触发?是则移进事件处理器(参考 useListToolbarButton.ts 的
onClick直接执行toggleList)。 - 订阅检查:
useEditorSelector的 selector 是否输出最小的事实(布尔量、小切片),equalityFn是否匹配输出类型,deps是否只含 selector 真正引用的输入? - 结构检查:组件定义是否都在模块作用域?
useMemo是否只包了昂贵的计算或有明确注释理由?
这套规则的价值在于把"React 性能优化"从模糊的直觉变成可审查的条款:每一个 Effect、每一次状态订阅、每一处 memo 都需要给出"外部系统""最小事实"或"昂贵计算"这样的具体理由,否则按默认写法(渲染期派生、事件处理器、提升组件定义)执行即可。配合 references/react.md 的决策树与 component-audit.md 中的仓库内正面范例(Media、TOC、Equation 的包提取),构成 Plate UI 表面从状态订阅到组件结构的完整性能约束体系。
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考