plate 编辑器中的 Memoized 组件抽取:用早期返回避免不必要的重渲染计算
2026/9/14 3:53:56 网站建设 项目流程

plate 编辑器中的 Memoized 组件抽取:用早期返回避免不必要的重渲染计算

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

导读

本文讲解 Vercel React 性能实践规则集中的一条核心规则——"Extract to Memoized Components"(抽取为记忆化组件),其核心思路是:把昂贵的计算下沉到memo()包裹的子组件中,从而让父组件可以通过**早期返回(early return)**在计算发生之前提前退出渲染。本文不仅给出规则原文的反例与正例,还结合本仓库(plate,基于 Slate 的 React 富文本编辑器)的真实源码,展示React.memo在编辑器渲染管线中的落地方式,并说明当项目启用 React Compiler 后手工记忆化应该如何取舍。

规则出处与定位

本规则来自仓库中由 Vercel Engineering 维护的 React/Next.js 性能优化指南,作为 Agent/LLM 驱动的代码审查与重构 skill 存在:

  • 单条规则文件:rerender-memo.md
  • 规则清单与分类:SKILL.md
  • 全量编译版指南:AGENTS.md(本文对应其中 5.6 节)

该 skill 共包含 69 条规则,划分为 8 个按优先级排序的类别,本文主题属于第 5 类Re-render Optimization(重渲染优化),impact: MEDIUM,影响描述为 "enables early returns"。同类的姊妹规则还包括:

  • rerender-memo-with-default-value.md:把记忆化组件的非原始类型默认参数(数组、函数、对象)提升为模块级常量,避免每次渲染都新建实例导致memo()失效;
  • rerender-simple-expression-in-memo:简单原始表达式不要套useMemo
  • rerender-no-inline-components:不要在组件内部定义组件;
  • rerender-dependencies:effect 依赖尽量用原始类型。

规则核心:先退出,再计算

问题场景

假设一个Profile组件需要根据user计算并渲染头像。直接的做法是在组件体内用useMemo缓存头像:

function Profile({ user, loading }: Props) { const avatar = useMemo(() => { const id = computeAvatarId(user) return <Avatar id={id} /> }, [user]) if (loading) return <Skeleton /> return <div>{avatar}</div> }

这段代码有一个容易被忽略的性能问题:useMemo中的计算发生在 Profile 组件自身渲染期间。即使loading为 true、马上要返回<Skeleton />computeAvatarId(user)<Avatar>的创建仍然会被执行。useMemo只能帮你在依赖不变时跳过"重复计算",却无法阻止"本次渲染中、在早期返回之前"的计算发生。

换句话说:当loading为 true 时,头像计算被白做了,而它的结果根本不会被用到。

正确写法:抽取 + memo + 早期返回

把头像逻辑抽成一个用memo()包裹的独立组件UserAvatar,父组件先做loading判断、提前返回,只有在真正需要渲染头像时才挂载UserAvatar

const UserAvatar = memo(function UserAvatar({ user }: { user: User }) { const id = useMemo(() => computeAvatarId(user), [user]) return <Avatar id={id} /> }) function Profile({ user, loading }: Props) { if (loading) return <Skeleton /> return ( <div> <UserAvatar user={user} /> </div> ) }

两处关键改进:

  1. 早期返回前置if (loading) return <Skeleton />现在发生在任何头像计算之前。只要处于加载态,UserAvatar根本不会被渲染,computeAvatarId完全不会执行。
  2. 计算位置下移 + 记忆化computeAvatarId(user)被移入UserAvatar内部,并由memo()+useMemo双重保护。父组件Profile因其它状态(例如用户收藏、主题切换等)重渲染时,只要传给UserAvataruser引用没变,memo()会直接复用上一次的渲染结果,头像计算与<Avatar>的重新创建都会被跳过。

与 memo 失效陷阱的关联

值得注意:memo()依赖的是 props 的浅比较(Object.is)。如果调用方每次渲染都传入一个新对象字面量(<UserAvatar user={{ id: 'x' }} />),记忆化会立刻失效。这与本 skill 中的姊妹规则 rerender-memo-with-default-value.md 是同一类问题:memo(function UserAvatar({ onClick = () => {} })这种写法,因为每次渲染都会新建默认函数实例,导致未传onClick时记忆化依然被破坏;解法是把默认值抽成模块级常量const NOOP = () => {}。因此在应用本规则时,要一并检查"传入 memo 组件的 props 是否保持引用稳定"。

何时适用、何时不该用

memo()不是银弹,它也有成本:每次渲染都要做 props 浅比较。本规则特别适合以下场景:

  • 计算昂贵且渲染频繁:例如大型列表项、图表、代码高亮、头像/图片处理等;
  • 父组件高频重渲染,但子组件输入很少变化:此时浅比较的收益远大于开销;
  • 存在可提前返回的分支loading、空态、权限不足等场景,正是本文早期返回模式的用武之地。

反之,以下情况不必引入:

  • 子组件本身渲染开销极小(一个简单的<span>),浅比较成本可能反而更高;
  • 依赖项是每次都会新建的引用(对象/数组/函数),memo会形同虚设;
  • 项目已启用 React Compiler(见下节)。

关于 React Compiler 的特别说明

规则原文末尾有一条重要注释:如果项目启用了 React Compiler,手工的memo()useMemo()就不再必要——编译器会在构建期自动分析组件并注入等价的重渲染优化。

这一点在本仓库中有真实佐证:

  • 应用侧 apps/www/next.config.ts 配置了reactCompiler: !isDev,即生产构建开启 React Compiler、开发模式关闭;
  • 构建侧 tooling/config/tsdown.config.ts 为库打包注册了babel-plugin-react-compiler(target 18);
  • 根 package.json 声明了babel-plugin-react-compiler: 1.0.0依赖,且大量包(如 packages/core/package.json、packages/basic-nodes/package.json 等)都依赖react-compiler-runtime

也就是说,plate 这类同时面向库作者(tsdown 打包)与应用使用者(Next.js 生产构建)的仓库,两条路径都启用了 React Compiler。因此在这些场景中,规则强调的"手工 memo"应让位于编译器自动优化;而对于未开启编译器的项目(开发模式、CDN 直用的 UMD 构建、或未接入编译器的基础设施),手工memo()/useMemo()仍然是有效手段。

仓库源码印证:React.memo 在编辑器渲染管线中的实际用法

本仓库并非仅在"规范文档"层面提及记忆化,其核心包 packages/core 的静态渲染组件 PlateStatic.tsx 中就有真实的React.memo实践。

ElementStatic的写法(PlateStatic.tsx):

export const ElementStatic = React.memo( BaseElementStatic, (prev, next) => (prev.element === next.element || (prev.element._memo !== undefined && prev.element._memo === next.element._memo)) && isElementDecorationsEqual(prev.decorations, next.decorations) );

这段代码演示了React.memo的高级用法——自定义比较函数

  1. 引用相等优先prev.element === next.element直接命中时跳过重渲染;
  2. _memo标记快速路径:当 element 携带稳定的_memo标识且相等时同样跳过,这相当于在节点模型层面预置了"内容未变"的凭据;
  3. 装饰对比兜底:只有 decorations 也相等才真正跳过,避免漏掉编辑器装饰(如高亮、光标选区渲染)的变化。

同文件稍后还有LeafStatic = React.memo(BaseLeafStatic, ...)(PlateStatic.tsx),对叶子节点做类似的记忆化。这正对应了规则的核心语义:把昂贵的渲染工作下沉到 memo 化的组件边界,在编辑器这种"一次输入事件就会引发大范围候选重渲染"的场景里,用浅比较(或自定义比较)把未变化的节点挡在渲染管道之外。

从源码结构可以推断,ElementStatic/LeafStatic面向服务端/静态渲染场景(组件名含Static,且接收SlateRenderElementProps风格的渲染属性),说明 plate 有意将"静态渲染路径"与"交互编辑路径"分离,静态路径通过 memo 化实现可预测的渲染成本。

实战重构清单

把本规则落到自己的 React/Next.js 代码中,可按如下步骤执行:

  1. 定位热点:找出渲染开销大、且父组件存在loading/empty/disabled等条件分支的组件;
  2. 抽取子组件:将昂贵计算(computeAvatarId之类)连同其 JSX 输出移入独立子组件;
  3. 包裹 memo:对子组件使用React.memo,并审视 props 是否全部为稳定引用(必要时参照rerender-memo-with-default-value把默认值提为常量);
  4. 前置早期返回:父组件先判断分支条件并return,让子组件在不需要时根本不参与渲染;
  5. 检查编译器:若项目已启用 React Compiler(如本仓库的reactCompiler: !isDev),可移除冗余的手工 memo,交由编译器处理;
  6. 验证收益:用 React DevTools Profiler 对比重构前后的渲染次数与耗时,确认浅比较开销小于省下的计算量。

小结

"Extract to Memoized Components"的本质是把"计算"和"是否渲染"解耦:让父组件通过早期返回决定"要不要渲染",让 memo 化子组件决定"要不要重新计算"。这一模式在编辑器、表格、虚拟列表等高频渲染场景中尤其有价值。本仓库 PlateStatic.tsx 中带自定义比较函数的React.memo用法,是它在生产级代码中的直接体现;同时请记住规则最后的注脚——启用 React Compiler 时,把这件事交给编译器更省心。

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

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

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

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

立即咨询