一个"简单需求"的七个坑
SayCraft 的核心体验有句内部黑话叫"过程有戏,成品干净"——会议进行时,AI 写出来的组件一个一个淡入画面,像搭积木一样看着产品长出来;归档回放直接展示最终成品,没有任何动效干扰。
实现方案很直觉:用 MutationObserver 监听 DOM 变化,新节点出现就给它加个淡入动画。听起来二十行代码能搞定的事,我踩了七个坑。
Bug 1:Observer 挂晚了
我在 createRoot().render() 之后才挂载 Observer。React 18 的初次 mount 是同步提交的,整棵 DOM 树在 render 调用结束时已经写入了 document。Observer 还没开始监听,第一屏全部节点就已经到位了。结果:首屏无动画,后续增量正常。修的时候把 Observer 提到 render 之前,问题消失。
Bug 2:StrictMode 的双重挂载
React StrictMode 在开发模式会把组件挂载两次。我的去重逻辑看到同一个组件出现第二次,判定"已经播过了",直接跳过。开发环境下一半组件没有动画,生产环境完全正常。这种"只在 dev 出现"的 bug 特别折磨人,因为你不确定是自己的问题还是环境的问题。
Bug 3:整页一起进来的真正原因
这个坑藏得最深。MutationObserver 的 mutation record 只记录最外层被操作的节点——React 做一次 appendChild 把整棵子树挂上去,record 里只有根节点,子孙节点不会单独出现。我一开始以为是 stagger 间隔太短导致"看起来同时出现",调了半天延迟参数,其实根因是我只遍历了 addedNodes 而没有递归它们的子树。加了 querySelectorAll 递归扫描之后,每个组件才真正独立淡入。
Bug 4:无障碍设置把动画干掉了
我加了 prefers-reduced-motion 的兜底,本意是尊重用户的无障碍偏好。但我的实现把 stagger delay 也一起归零了,等于所有节点瞬间出现、没有任何过渡。正确做法是减少运动幅度(去掉 translateY),但保留 opacity 渐变和时间间隔。
Bug 5:HMR 重挂载时白屏
去重用的 seenKeys 集合在 animationend 事件触发后才把节点标记为"已完成"。Vite HMR 触发模块热替换时,组件卸载再重挂载,动画还在进行中的节点既没完成旧动画也没开始新动画——直接白屏。改成节点进入 Observer 就立即标记,animationend 只负责清理 CSS class。
Bug 6:animation-fill-mode 的隐形代价
动画结束后我用 animation-fill-mode: both 保持最终状态,translateY(0) 看起来和"没有 transform"一样。但它不一样。只要 transform 属性存在(哪怕值是 none 的等价物),浏览器就会创建新的 stacking context。结果下拉菜单、模态框的 z-index 全乱了。动画结束后必须显式移除 transform 和 animation 属性。
Bug 7:Stagger 封顶的视觉断层
为了防止第 50 个组件等太久,我给 stagger delay 加了上限。比如最大 2 秒,每个间隔 100ms,那第 20 个之后所有节点的延迟都是 2 秒——前面错落有致,后面一堆东西同时弹出来。最后改成对数衰减曲线:前几个间隔大,越往后越密,视觉上像自然减速而不是突然撞墙。
去重机制的三次推翻
去重逻辑我换了三版。
第一版用 Set 存 stableKey,key 由 tag、class、文本内容、siblingIndex 拼接。看起来稳定,但 DOM 结构一变,siblingIndex 漂移,同一个节点算出不同的 key,动画重复播放。
第二版把 key 存进 sessionStorage 做持久化。刷新后所有节点都命中缓存,动画完全不播放了。用户刷新页面看到的是一个静止的界面,完全失去了"过程有戏"的感觉。
第三版用 WeakSet 直接存 HTMLElement 引用。节点身份天然唯一,不需要人工构造 key;DOM 节点被回收后 WeakSet 自动释放引用,不需要手动清理;刷新后 WeakSet 重建,动画正常播放。三个问题一次解决。有时候最简单的 API 就是最对的。
Live 和 Archive 的动效分离
会议进行时要动画,归档回放不要动画。我不想到处写 if/else 判断当前模式。
最后用了 import.meta.hot 做开关——不是用来判断 HMR,而是利用它在 Vite dev 模式存在、production build 被静态替换为 undefined 的特性。归档页面走的是生产构建,import.meta.hot 天然是 undefined,动效初始化代码被 minifier 整段消除。Live 预览跑在 Vite dev server 上,import.meta.hot 存在,动效正常启用。零运行时判断,零条件分支。
延迟参数的来回
stagger 间隔我调了七次:80ms 太快看不出层次,120ms 稍好但密集区域还是糊,400ms 第一次觉得"行了",500ms 想再舒服一点,1500ms 用户反馈"太慢了等不及",回到 1000ms 还是慢,再到 500ms。最终定在 live 400ms、replay 30ms。replay 的 30ms 本质上就是"快速重演一遍过程",给人一个"这是怎么来的"的印象,但不浪费时间。
这七个坑没有一个是 MutationObserver 文档会警告你的。它们全部来自 MutationObserver 和 React 渲染机制、CSS 层叠上下文、浏览器无障碍 API、Vite HMR 的交叉地带。每个单独看都不难修,组合在一起就是一周的工作量。
SayCraft 现在的淡入动效跑得很稳。如果你想看看"过程有戏,成品干净"到底是什么感觉,去 saycraft.ai 开个会议试试。