☰
Radix Primitives `@radix-ui/react-presence` 演进全解:动画进出场原理解析与 1.1.4~1.1.10 版本变更深度剖析
2026/10/3 17:31:58 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】primitives

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos.

项目地址:https://gitcode.com/gh_mirrors/pr/primitives
点击查看免费下载

@radix-ui/react-presence是 Radix Primitives 内部承担"元素存在性管理"的核心工具组件:当present属性切换时,它通过读取元素 computed style 中的animation-name判断进出场动画,让"延迟卸载"与"动画完成后再卸载"成为可能。本文以本仓库 packages/react/presence/CHANGELOG.md 记载的 1.1.4~1.1.10 版本演进为主线,逐条还原每个变更背后的源码机制、问题根因与性能/兼容性考量,帮助你在理解 Radix 体系(Dialog、Popover、Menu、Toast 等均依赖它)的同时,掌握一套可在自己组件库中复用的"进出场动画 + 存在性状态机"工程方案。

先看懂 Presence 是什么:一个内部存在的工具

在阅读版本历史之前,先明确这个包在仓库中的定位。官方说明非常直白——packages/react/presence/README.md 写道:

This is an internal utility, not intended for public usage.

它并非面向终端用户的 API 组件,而是被上层组件广泛引用的内部依赖。仓库内大量组件包都声明了对它的依赖,例如 checkbox.tsx、collapsible.tsx、dialog.tsx、hover-card.tsx、menu.tsx、popover.tsx、toast.tsx 等。以 dialog.tsx 为例,实际用法是:

<Presence present={forceMount || context.open}> {/* 内容部分 */} </Presence>

即当 Dialog 打开/关闭状态变化时,Presence负责决定子树是立刻挂载、立刻卸载,还是"挂起卸载"等待退出动画播完。理解这一点,后续所有版本变更的意义就清晰了:这个包的任何改动,都会级联影响几乎全部 Radix 浮层类组件的行为、性能与 React 兼容性。

核心原理:三段式状态机驱动的存在性判定

presence.tsx 中Presence组件本身很薄,它从usePresence(present)拿到isPresent与ref,然后决定渲染方式:

  • 若children是函数,则强制挂载(forceMount),把{ present: isPresent }传回给调用方,由调用方自己决定渲染细节;
  • 否则取唯一子元素,合并ref后克隆,仅在isPresent为真时返回该元素,否则返回null。

真正的核心在 presence.tsx 中定义的有限状态机。其状态与转移关系为:

当前状态事件下一状态语义
mountedUNMOUNTunmounted无退出动画,直接卸载
mountedANIMATION_OUTunmountSuspended检测到退出动画开始,挂起卸载
unmountSuspendedMOUNTmounted动画期间再次进入,恢复挂载
unmountSuspendedANIMATION_ENDunmounted动画结束,真正卸载
unmountedMOUNTmounted重新挂载

最终isPresent = state === 'mounted' || state === 'unmountSuspended',即只要还处在退出动画播放期(unmountSuspended),元素就仍然存在于 DOM 中。

这套状态机由 use-state-machine.tsx 中的useStateMachine实现,底层只是用React.useReducer按事件查表转移:nextState = machine[state][event] ?? state。类型层面通过UnionToIntersection技巧从状态描述类型推导出合法事件集合,保证状态定义即类型约束。

关键判定:如何知道"退出动画开始了"?

浏览器并没有在动画开始瞬间就触发的事件——animationstart会在animation-delay耗尽后才触发,对退出动画而言往往太晚。因此 Radix 采用的方案是比较计算样式中的animation-name是否发生变化(见 presence.tsx 的注释):

  • present从true变false时,若当前animation-name为none或display为none,说明根本没有退出动画,立即UNMOUNT;
  • 否则对比"上一次记录的 animation-name"与"当前 animation-name",若确实不同(说明新动画已开始),进入ANIMATION_OUT;反之UNMOUNT。

随后在animationend/animationcancel事件上监听(presence.tsx),确认动画真正结束后才发送ANIMATION_END。这里还有一个细节:事件回调中用CSS.escape(event.animationName)与计算样式对比,以正确处理 keyframe 名称中含转义字符的情况(这正是 1.1.5 修复的内容,见下文)。

防止"动画结束后内容闪一下"

presence.tsx 的注释记录了一个微妙问题:React 18 并发模式下,ANIMATION_END对应的更新会在动画结束后一帧才应用,导致卸载瞬间内容闪现(flash)。修复方式是:当节点在退出动画期间时,把node.style.animationFillMode临时置为forwards,让节点保持在最后一帧关键帧样式,随后用setTimeout在节点有足够时间卸载后再还原 fill mode。注释中还提到旧实现曾用ReactDOM.flushSync强制同步刷新,但会导致节点在合成animationEnd事件派发前就被移出 DOM,使用户自带的动画结束回调不被触发,因此被弃用(对应 radix-ui/primitives#1849)。

版本演进全解读:从 1.1.4 到 1.1.10

以下逐条解读 CHANGELOG 中记录的每一次发布。

1.1.4:修复内存泄漏

Fix memory leak in Presence

CHANGELOG 没有展开细节,但结合源码可以推断泄漏点所在。在 presence.tsx 的useLayoutEffect中,每当node变化都会在节点上注册animationstart/animationcancel/animationend三个事件监听器,并在清理函数中removeEventListener与ownerWindow.clearTimeout(timeoutId)。1.1.4 修复的目标正是确保节点被移除、组件卸载或node引用变化时,这些监听器与挂起的setTimeout定时器被可靠清理,避免旧节点长期滞留于内存。

1.1.5:animationend处理转义字符

Ensured that theanimationendevent is handled correctly when the keyframe has escapable characters (#2763)

如前所述,事件对象中的event.animationName是未转义的 CSS 语法形式,而getComputedStyle返回的animationName是转义后的形式,两者直接比较会失败。修复即在 presence.tsx 中改用CSS.escape(event.animationName)后再做includes包含判断。如果你的动画 keyframe 名称中包含特殊字符(空格、连字符前缀、数字开头等),这一行就是正确判定"当前动画是否已结束"的关键。

1.1.6:修复 React 19 下的 "Maximum update depth exceeded" 无限循环

Fixed a "Maximum update depth exceeded" infinite loop in React 19 that could occur whenPresencewas given a child with an unstable ref.

这是整个版本历史中技术含量最高的一次修复。问题根因记录在 presence.tsx 的注释中:

React 19 中,如果回调 ref 的身份(identity)在两次渲染之间改变,React 会在每次 commit 时先以null分离旧 ref、再附加新 ref。Presence自身的 ref 会调用setNode(node)触发一次更新,而若传入子元素的消费者 ref 身份不稳定(例如内联箭头函数),该 ref 的分离/附加就会反复触发setNode更新,最终形成"最大更新深度超限"死循环。

解决方案是新增useStableComposedRefs:与常规的useComposedRefs不同,它保证返回的 ref 回调身份永远不变(React.useCallback(..., [])),同时把最新一组 refs 存入refsRef.current,在 attach/detach 时统一读取。由于Presence的 ref 不再因消费者 ref 变化而重建,React 19 不会反复分离/附加它,循环被打破;而最新消费者 ref 仍能在挂载/卸载时正确收到节点。

presence.test.tsx 为此提供了回归测试:使用"每次渲染都新建、且在 attach 时触发重渲染的内联回调 ref",断言渲染循环次数小于 25 且不抛异常。同文件还验证了稳定 ref 场景与"子节点 ref 能正确收到 DOM 节点"的转发行为。

同一版本还顺带补充了repository.directory到各package.json(即本包 package.json 中的directory: packages/react/presence),便于工具链在 monorepo 中定位源码目录。

1.1.7:性能优化——减少 FocusScope 与 Presence 中的强制 reflow

Improved performance by reducing forced reflow inFocusScopeandPresence

“强制 reflow”(forced reflow / forced synchronous layout)指在浏览器尚未完成上一次样式计算时就同步读取布局相关属性,迫使浏览器立即进行昂贵的同步样式重算。结合 presence.tsx 的当前实现可以还原当时的优化思路:

  • 在useLayoutEffect(布局阶段)中,当present变为true时,立刻读取"还算干净"的animation-name,存入mountAnimationNameRef;
  • 到后续的 passiveuseEffect中,React 兄弟 effect(如react-remove-scroll、DismissableLayer等)可能已经弄脏了 body 样式,此时不再重新读取实时 CSSStyleDeclaration,而是直接消费布局阶段缓存的动画名(对应注释中引用的 radix-ui/primitives#1634);
  • 同理,ref 回调在 commit 阶段触发、早于任何 passive effect,因此在 ref 回调中“提前”缓存mountAnimationNameRef.current,让后续 passive effect 跳过冗余的实时样式读取。

整体策略就是一句话:把昂贵的 getComputedStyle 读取尽可能前移到样式"干净"的时机并缓存,避免在样式已被污染的时机重复读取。这正是优化性能的典型手法,也解释了为何 presence.tsx 中同一个animation-name会在 ref 回调、布局阶段、passive 阶段三处流转缓存。

1.1.8:改善 tree-shaking,让打包器能丢弃未使用组件

Improved tree-shaking so bundlers can drop unused components. Component parts are now marked/* @__PURE__ */and use named render functions instead ofComponent.displayName = ...assignments, which previously prevented dead-code elimination with some bundlers.

打包优化层面的两个具体动作:

  1. 各组件部件改用具名渲染函数(named render functions)而非Component.displayName = ...赋值——后者在部分打包器(如某些 webpack 配置下的 Rollup 依赖)中会阻止死代码消除;
  2. 为纯函数表达式添加/* @__PURE__ */注释标记,让压缩器(terser/esbuild 等)确认调用无副作用后可安全移除。

配套地,package.json 中声明了"sideEffects": false,同样向打包器承诺"模块导入本身无副作用",从而允许更大胆的 tree-shaking。对使用者而言,这意味着最终 bundle 中未被引用到的 Radix 部件可以被可靠移除。

1.1.9:通过 CI 重新发布以附带 provenance 认证

Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation.

这一条属于供应链安全(supply chain security)变更:此前的版本是在 CI 之外手工发布,因而缺少 npm 的provenance(来源认证)证明——即"该包确实由特定 CI 流水线从特定仓库构建发布"的可验证声明。1.1.9 在 CI 流水线中重新发布了相同的代码,使每个包都附带认证。代码行为没有任何变化,纯粹是发布流程与可追溯性层面的改进。顺带将依赖@radix-ui/react-use-layout-effect更新到1.1.3。

1.1.10:回退破坏性变更,恢复 React Server Components 兼容性

Reverted breaking changes that caused compatibility issues with React Server Components. Updated dependencies:@radix-ui/react-use-layout-effect@1.1.4

这是最新的一个版本(当前 package.json 版本即为1.1.10),动作是回退了之前引入的、会破坏 React Server Components(RSC)兼容性的破坏性变更,同时把依赖升级到@radix-ui/react-use-layout-effect@1.1.4。

RSC 兼容性的技术支撑点在本包的入口 index.ts 的'use client'指令,以及 SSR 安全的布局效应。Presence 依赖的useLayoutEffect来自 use-layout-effect.tsx:在服务端(无document)时替换为 noop,从而规避 React 在服务端调用useLayoutEffect时的告警。回退破坏性变更意味着 1.1.10 重新校准了"SSR/RSC 兼容"与"客户端动画判定"之间的平衡,回归到此前稳定的行为基线。仓库的apps/ssr-testing应用(包含rsc、slot、portal等示例页)正是此类兼容性验证的实验场。

依赖关系:为什么这个包如此"牵一发而动全身"

从 package.json 可以看到其依赖与兼容边界:

  • 运行时依赖仅一个:@radix-ui/react-use-layout-effect(workspace:*,monorepo 内联依赖);
  • peerDependencies:react与react-dom均支持^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc——从 16.8(Hooks 引入)一路覆盖到 React 19,这也解释了为何 1.1.6 的 React 19 ref 稳定性修复、1.1.10 的 RSC 兼容回退如此重要;
  • 构建配置:source/main/module均指向./src/index.ts(源码即入口),发布时经radix-build构建为dist,产物同时提供 ESM 与 CJS 双格式。

上游组件(Dialog、Menu、Popover、Toast、Collapsible、Checkbox 等)均以workspace:*引用本包,因此任何一次 Presence 的发布都会波及整套 Radix 组件在性能、动画与 React 版本兼容性上的表现。这也是 CHANGELOG 中多次出现"同步更新依赖版本"的原因。

从测试看行为契约:一个可复用的验收清单

presence.test.tsx 用 vitest + testing-library 将 Presence 的行为契约固化下来,可作为理解(乃至在自己实现中复刻)其语义的权威参照:

  1. ref 稳定性(ref stability):稳定 ref 与不稳定回调 ref(内联 + attach 时触发重渲染)两种场景下,渲染次数均需小于 25 且不抛"更新深度超限"异常;子元素 ref 必须收到真实 DOM 节点。
  2. 挂载/卸载行为(mount/unmount behavior):present为true时渲染子元素;present变false且无退出动画时立即移除;false → true重新切换时能恢复挂载。
  3. 渲染函数子元素(render function children):以函数作为children时始终强制挂载(force-mount),元素不离开 DOM,仅通过present参数通知调用方切换内容(如visible/hidden文案);present为true时传入的present参数确实为true。

其中"函数子元素 = 强制挂载"的语义与 presence.tsx 中const forceMount = typeof children === 'function'的实现一一对应——当你需要"元素始终在 DOM 中、仅切换样式/内容"时(如某些保持可聚焦性或测量尺寸的场景),应优先考虑这种用法。

版本变更速查表

版本类型核心内容关键源码落点
1.1.4修复Presence 内存泄漏事件监听与定时器清理逻辑
1.1.5修复正确处理 keyframe 名含转义字符的animationendCSS.escape(event.animationName)比较
1.1.6修复React 19 下不稳定 ref 引发的无限循环useStableComposedRefs稳定回调身份
1.1.7性能减少强制 reflow布局阶段缓存 animation-name,passive effect 不再实时读取
1.1.8构建改善 tree-shaking/* @__PURE__ */+ 具名渲染函数 +sideEffects: false
1.1.9发布流程CI 重发布以附带 provenance 认证发布流水线(代码无变化)
1.1.10兼容性回退破坏性变更,恢复 RSC 兼容入口'use client'与 SSR 安全 useLayoutEffect

小结:从版本历史中可沉淀的工程经验

纵观 1.1.4~1.1.10 的演进,可以提炼出三条可复用的工程方法论:

  1. 动画存在性判定避免依赖"事件时机":由于animationstart在animation-delay后才触发、且缺少animationrun事件,Radix 采用"对比 computed style 中 animation-name 是否变化"的判定方式;遇到此类"事件迟到"问题时,优先考虑以样式快照变化作为判定信号。
  2. 性能优化的核心是"缓存干净样式":强制 reflow 的代价远高于一次缓存读取,把 getComputedStyle 的时机前移到 commit 阶段/布局阶段并缓存复用,是低风险高收益的通用优化手段。
  3. 框架升级期,ref 身份稳定性是关键契约:React 19 对回调 ref 的分离/附加更激进,任何"在 ref 中触发状态更新"的组件都必须保证 ref 回调身份稳定,否则极易踩中无限循环;这一点对所有自定义 Hook 组件都有普适参考价值。

若希望进一步研究,可以直接阅读本仓库的核心实现 presence.tsx、状态机工具 use-state-machine.tsx、回归测试 presence.test.tsx,以及实际消费方示例 dialog.tsx,形成"原理 → 验证 → 应用"的完整链路。

  • 前端
  • UI组件

【免费下载链接】primitives

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos.

项目地址:https://gitcode.com/gh_mirrors/pr/primitives
点击查看免费下载
上一篇:Guzzle 异常处理指南:PSR-18 合规下的异常选择决策树与最佳实践
下一篇:gix-config:纯 Rust 高性能 git-config 文件的读写库深度解析

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

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

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

立即咨询