1. 这个"闪现"长什么样:先复现,再谈原理
最近排查一个看着挺简单的问题:点击按钮打开弹窗,预期的是 300ms 渐入,实际体验却是弹窗"啪"地一下直接出现在屏幕上;偶尔更诡异,先正常闪了一下,然后又像没事人一样重新播放一遍入场动画。这种"闪现"在 React 项目里几乎每个人都遇到过,尤其是自己手写弹窗的时候。
我最初也以为只是 CSS 写错了,把transition、animation、z-index、position挨个查了一遍,结果发现样式全部正确,问题出在 React 的渲染时序和浏览器绘制机制之间。这篇文章把我排查的思路、踩过的坑、以及最终沉淀下来的封装方案整理出来,希望能让你下次遇到 Modal 闪现时,不再靠"加个 setTimeout 试试"这种玄学手段。
先看一个非常典型的失败写法,很多项目里就是这么写的:
function BadModal({ open, onClose }) { const [mounted, setMounted] = useState(false); const [visible, setVisible] = useState(false); useEffect(() => { if (open) { // 最常见的错误:把“出现”和“最终态”放在同一个 effect 里 setMounted(true); setVisible(true); } else { setVisible(false); setTimeout(() => setMounted(false), 300); } }, [open]); if (!mounted) return null; return ( <div className={visible ? "modal-mask modal-mask--show" : "modal-mask"}> <div className="modal-panel">弹窗内容</div> </div> ); }CSS 看起来也没问题:
.modal-mask { opacity: 0; transition: opacity 300ms ease; } .modal-mask--show { opacity: 1; }但实际点击按钮后,蒙层是瞬间以完整不透明度出现的,根本没有 300ms 的淡入过程。
我把这类闪现归纳成三种常见现象:
| 现象 | 直接原因 | 根因 |
|---|---|---|
| 点击后直接全量出现,完全没有入场动画 | transition的起点状态从未被绘制 | 初始帧被 React 的状态批处理合并掉了 |
| 弹窗先正常出现,随后闪一下又重新播放入场动画 | 组件中途被重新挂载 | 不稳定的key、或父组件身份变化导致 remount |
| 蒙层淡入正常,但面板内容在动画开始前"裸奔"了一帧 | 子元素样式与父级动画时序不一致 | CSS 加载/注入顺序,或动画 delay 期间 fill-mode 设置错误 |
下面先深入拆解第一种,也是最核心的一种。
2. 为什么弹窗会闪:浏览器"画帧"和 React"状态更新"的错位
要搞懂闪现,必须回到一个基本事实:CSStransition是一个"过程",它需要一个已经被浏览器绘制出来的起点状态。如果起点状态根本不存在,过渡自然无从谈起。
2.1 transition 的前提:一次"可观测到的状态变化"
transition不是专门给"元素第一次出现"设计的能力。它的工作机制是:浏览器先记录元素的旧样式值,然后在某个新的样式值到来时,发现这两个值不同,于是启动插值动画。
这里有三个关键条件:
- 旧样式值必须真实存在于元素的样式计算里;
- 新样式值要和旧值产生差异;
- 这两个值之间必须间隔一次style recalculation,让浏览器有机会"看到"变化。
如果元素是新建出来的,第一次样式计算就直接是opacity: 1,那浏览器没有任何"旧值"可参考,直接画最终状态。
所以当你在 React 里写:
useEffect(() => { setMounted(true); setVisible(true); }, [open]);mounted和visible两个状态被 React 18 自动批处理成一次 commit。也就是说,modal-mask这个 DOM 节点从诞生那一刻起,类名就是modal-mask modal-mask--show,opacity从 0 到 1 的过渡压根没有机会发生。
很多人在 React 17 或更早版本里没遇到这个问题,是因为旧版 React 在setTimeout、Promise 回调里不会自动批处理,setMounted(true)和setVisible(true)可能被拆成两次 commit,中间隔了一次浏览器绘制,于是transition偶尔能跑起来。这种感觉就是——同一个代码,有时候闪现,有时候正常。我在公司某个中后台项目里排查时,开发机上怎么试都正常,拉到低配笔记本上一测就闪,原因就是帧率低、绘制间隔长,批处理时序更明显。
2.2 React 的渲染周期:commit、useLayoutEffect、useEffect 和"绘制"的关系
要精确定位问题,需要把 React 一次状态更新的生命周期画出来。我习惯用下面这张时间线:
用户点击按钮 → React 执行 render(计算新的虚拟 DOM) → commit:把差异一次性写入真实 DOM → useLayoutEffect:同步执行,此时浏览器还没绘制 → 浏览器绘制当前帧(paint) → useEffect(passive effect):多数情况下在浏览器绘制之后执行注意两个关键点:
useLayoutEffect在浏览器绘制之前同步执行,所以它适合做"需要在绘制前完成"的 DOM 测量和样式修正;useEffect理论上在绘制之后执行,但这个"之后"只是一个大体保证。React 18 引入并发特性后,passive effect 的实际触发时机受调度影响,并不保证每次都在"下一次绘制之前"稳定落位。
更重要的是,React 的状态更新不是"每 setState 一次就重绘一次",而是"一次 commit 批量落地"。当你同时在 effect 里推两个 setState,浏览器那边可能只感知到一次 DOM 变化,中间没有独立的绘制帧。这就是闪现的底层机制。
2.3 为什么 CSS@keyframesanimation 不受影响
同样是"元素第一次出现马上播放动画",CSSanimation和transition的机制完全不同。
.modal-mask { animation: maskIn 240ms ease-out both; } @keyframes maskIn { from { opacity: 0; } to { opacity: 1; } }当.modal-mask被挂载到 DOM 时,浏览器解析到这个元素带animation属性,动画引擎会从@keyframes里的from状态开始播放。它不依赖"之前是否画过一帧",因为起始状态是动画自己定义的,而不是元素的静态样式。
这就是解决方案的钥匙:入场动画应该绑定"元素挂载"这个事件,而不是绑定"某个状态值的变化"。React 负责让元素在某一刻出现,浏览器负责在这一刻自动播放动画,两者的职责天然分离,不需要 JS 去协调绘制时机。
2.4 "闪一下又播一次"是怎么回事
还有一类闪现更迷惑:弹窗明明出现了,过一会儿突然闪一下,然后把入场动画重播了一遍。这种通常不是transition时序问题,而是组件被重新挂载了。
React 识别一个组件是否要重新创建,看的是它的类型和key。如果key变了,React 会销毁旧实例、创建新实例。新实例从零开始渲染,CSSanimation自然也跟着从头播放。
常见触发场景:
- 父组件用
key={Date.now()}或key={editingId}这种动态值来挂载弹窗; - 弹窗内部渲染的内容来自异步请求,数据返回后父组件 setState,导致弹窗的
key被重新计算; - React StrictMode 开发模式下,effect 会执行两次 setup/cleanup,如果 effect 里有"手动添加动画类名再移除"的逻辑,也会造成动画重播的错觉。
下面第三部分给出入场动画的具体修法。
3. 入场闪现的修法:让动画和"挂载"绑定,而不是和状态绑定
3.1 首选方案:直接用 CSS keyframes
对于绝大多数自研 Modal,入场动画用@keyframes就够了,完全不需要 JS 状态参与:
function GoodModal({ open, onClose }) { if (!open) return null; return ( <div className="modal-mask modal-mask--enter" onMouseDown={onClose}> <div className="modal-panel" onMouseDown={(e) => e.stopPropagation()}> 弹窗内容 </div> </div> ); }CSS:
.modal-mask--enter { animation: maskIn 200ms ease-out both; } .modal-mask--enter .modal-panel { animation: panelIn 260ms cubic-bezier(0.16, 1, 0.3, 1) 40ms backwards; } @keyframes maskIn { from { opacity: 0; } to { opacity: 1; } } @keyframes panelIn { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }几个要点:
both填充模式保证动画开始前、结束后都应用 keyframe 的边界值,避免动画结束瞬间跳回静态样式;- 面板的
backwards配合40msdelay,让面板先保持"透明+位移"的起始状态,等蒙层淡入了一小截再跟进,视觉上更柔和; - 直接在
open && <Modal/>这种条件渲染下使用,动画从元素挂载那一刻自动播放,不再依赖任何 JS 状态翻转。
这种做法最大的优点是可预测。不管 React 怎么批处理、浏览器怎么调度帧,入场动画都会从透明开始,不会被吞掉。
3.2 如果必须用 transition:双 requestAnimationFrame
有些场景你没法改用 keyframes,例如组件已经内置了基于状态的 transition 体系,或者你想让弹窗在所有浏览器行为上保持一致的过渡逻辑。这时候就要手动保证"起点状态被真实绘制过至少一帧"。
核心写法是双 rAF:
useEffect(() => { if (!open) return; setMounted(true); setVisible(false); let raf2; const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setVisible(true)); }); return () => { cancelAnimationFrame(raf1); if (raf2) cancelAnimationFrame(raf2); }; }, [open]);为什么是两次 rAF?
- 第一次
requestAnimationFrame注册的回调,会在浏览器下一次绘制之前执行。此时 DOM 里已经存在opacity: 0的起点状态,但浏览器可能还没来得及把这帧画出来; - 在第一次回调里再注册一次 rAF,等于告诉浏览器:"你先正常把当前这一帧画完,我下一帧再动手。"
- 第二次回调执行时,
opacity: 0的帧已经真实上屏,此时把状态翻到opacity: 1,浏览器才能从 0 过渡到 1。
虽然这个写法能解决问题,但它的本质是在跟浏览器的帧调度博弈,属于"兜底方案"。我的建议是:能用 keyframes 就别用 transition 做入场,过渡逻辑留给离场动画用。
3.3 两个隐蔽的注意事项:StrictMode 和"强制重建动画"
开发模式下 React StrictMode 会模拟组件卸载再挂载,effect 的 setup/cleanup 会执行两次。如果你的 effect 里写的是"先移除动画类、再添加动画类",第二次执行时类名没有变化,动画不会重新触发,这个还好;但如果 effect 里用了el.animate()这类命令式 API,第二次调用等于强制重新开始动画,视觉效果就是闪一下。
规避办法:命令式动画逻辑不要放在 effect 里,或者用 ref 记录是否已经执行过。但更本质的做法是回到 3.1,把动画声明放在 CSS 里,React 侧只管理"挂载还是不挂载"。
另外提醒一个常见误区:如果同一个元素已经带着--enter类,你想通过"先移除再添加同名类"来重播动画,需要强制浏览器 reflow(比如读取el.offsetWidth)。这属于 hack,别把它当常规手段。
4. 离场动不动就"消失得太突然":两阶段卸载
弹窗闪现不只发生在打开时,关闭时同样常见。最典型的是:点击关闭按钮,open变成 false,父组件里是{open && <Modal/>},React 直接把整个弹窗子树卸载掉——你精心写的淡出动画根本没机会执行,弹窗瞬间从屏幕消失。
4.1 为什么条件渲染做不了离场动画
离场动画的难点在于:你要先让元素"继续存在一段时间"去播完退场动画,之后才真正从 DOM 移除。但条件渲染的语义是"状态为 false 就立刻不存在",这两个需求天然冲突。
解决办法是把"是否存在"和"用户意图打开/关闭"拆成两个状态:
mounted:控制 DOM 是否还在;closing:控制是否正在播退场动画。
用户点击关闭时,closing先变成 true,元素继续留在 DOM 上并切换退场动画类;动画播完再用定时器把mounted置为 false。这就是两阶段卸载。
4.2 可控的两阶段卸载实现
const EXIT_MS = 300; function Modal({ open, onClose, title, children }) { const [mounted, setMounted] = useState(open); const [closing, setClosing] = useState(false); const timerRef = useRef(null); useEffect(() => { if (open) { clearTimeout(timerRef.current); setClosing(false); setMounted(true); } else { setClosing(true); timerRef.current = setTimeout(() => { setMounted(false); setClosing(false); }, EXIT_MS); } return () => clearTimeout(timerRef.current); }, [open]); if (!mounted) return null; return ( <div className={closing ? "modal-mask modal-mask--exit" : "modal-mask modal-mask--enter"} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }} > <div className="modal-panel" onMouseDown={(e) => e.stopPropagation()}> <button onClick={onClose}>关闭</button> {children} </div> </div> ); }CSS 侧增加退场动画:
.modal-mask--exit { animation: maskOut 240ms ease-in both; } .modal-mask--exit .modal-panel { animation: panelOut 240ms ease-in both; } @keyframes maskOut { from { opacity: 1; } to { opacity: 0; } } @keyframes panelOut { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(8px) scale(0.99); } }这里有个容易忽视的细节:EXIT_MS必须大于等于退场动画的总时长。如果弹窗里蒙层 200ms、面板 240ms,那EXIT_MS至少取 300ms 才保险。取少了,面板退到一半就被卸载,视觉上又是一闪。
4.3 用 animationend 还是 setTimeout
有些人倾向于监听animationend事件来卸载,这样时长不用写死。实际使用中我建议还是以 setTimeout 为主,理由有两个:
- 用户开启系统级"减少动态效果"时,浏览器可能不播动画,
animationend可能一直不触发,弹窗就会卡在 DOM 里; - 同时监听蒙层和面板两个元素的
animationend,还要判断是哪一层播完了,逻辑复杂度上去了。
用 setTimeout 的唯一要求是时长对齐,成本很低。
另一个高频坑是快速开合:用户刚点关闭,又立刻点打开,此时上一个退场定时器还挂着。如果不清理,到点后setMounted(false)会把正在打开的弹窗直接卸掉,看起来就是"闪没了一下又出来"。上面的代码里,open变为 true 的分支会先clearTimeout(timerRef.current),这个清理是必须的。
5. 比动画时序更隐蔽的闪现来源
前面几种都属于"动画机制没配对"。实际项目里,我还遇到过几类看起来像动画问题、其实另有原因的闪现。排查时如果动画写法怎么看都没错,就要往这几个方向看。
5.1 不稳定的 key:弹窗被偷偷重挂载
最迷惑的一次线上反馈是"弹窗偶尔闪一下,而且闪的形式是把入场动画从头播一遍"。最后定位到的问题非常朴素——某个页面对弹窗使用了动态 key:
// 错误示例:key 和正在编辑的数据绑定 <EditModal key={editingId} open={editingId !== null} data={currentRecord} />用户从一条记录切到另一条记录时,editingId变了,React 认为这是两个完全不同的弹窗组件,于是销毁旧的、创建新的。新组件挂载,入场动画重播,用户看到的就是"闪一下又播一遍"。
正确做法是保持弹窗自身的 key 稳定,把数据变化放在 props 里:
<EditModal key="edit-modal" open={editingId !== null} data={currentRecord} />排查这类问题有一个很实用的方法:在弹窗组件 render 里加一个计数器或者console.log,观察打开弹窗时组件是否被创建了两次。如果渲染日志出现"创建 → 销毁 → 再创建",几乎可以锁定是 key 或组件类型变化。
5.2 CSS-in-JS 和异步加载导致的样式晚到
如果弹窗样式是通过运行时 CSS-in-JS 注入、或者样式表跟着路由代码分割异步加载,可能出现弹窗 DOM 已经插入、但相关样式还没注册的情况。此时蒙层的opacity: 0可能还没生效,弹窗内容先以全量样式闪了一帧,然后样式注入,动画才从透明开始。
这种闪现不是 React 渲染时序的问题,而是样式到达时机的问题。方案几个:
- 弹窗这种全局组件,样式尽量走静态 CSS,别走运行时注入;
- 如果用 CSS-in-JS,确认库是否借助
useInsertionEffect在 DOM 变更前插入样式; - 动态加载的弹窗组件,先把样式 chunk 和组件 chunk 一起预加载,避免"组件到、样式未到"。
5.3 滚动锁定造成的页面跳闪
弹窗打开时通常要锁住背景滚动:document.body.style.overflow = 'hidden'。但这么做有一个副作用——滚动条消失,页面内容会往右"挪"几个像素。在窄屏或者内容本身就超宽时,用户会看到整个页面先跳一下,紧接着蒙层出现,很容易被误认为是弹窗闪现。
解法比较简单:
html { scrollbar-gutter: stable; }让滚动条位置始终预留,这样切换overflow时页面不会发生水平位移。
5.4 position: fixed 的包含块被祖先篡改
另一种"闪现"是弹窗先出现在错误位置,然后跳回正确位置。常见原因:祖先元素加了transform、filter、perspective或contain: paint,破坏了position: fixed的包含块语义,弹窗不再相对视口定位,而是相对某个祖先定位。
我在一个项目里见过弹窗的父容器因为要做滚动吸顶,加了transform: translateZ(0),结果弹窗出现在页面底部然后又跳回屏幕中间,看起来就是一次明显的闪跳。
解决方式是渲染到 body 下,与任何 transform 祖先隔离:
import { createPortal } from 'react-dom'; function Modal(props) { // ... return createPortal( <div className="modal-mask">...</div>, document.body ); }createPortal本身不会引发动画问题,它只是把 DOM 挂到 body 下,让弹窗的定位和堆叠上下文更可控。推荐所有全局弹窗都用它。
6. 沉淀一个开箱即用的 Modal 封装
把前面的经验整合起来,给出一个比较完整的封装。这个组件同时处理了:入场动画、离场动画、延迟卸载、portal、点击遮罩关闭、Escape 关闭、基础无障碍属性。
import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; // 必须大于等于任何退场动画的时长 const EXIT_MS = 300; function Modal({ open, onClose, title, children }) { const [mounted, setMounted] = useState(open); const [closing, setClosing] = useState(false); const timerRef = useRef(null); useEffect(() => { if (open) { clearTimeout(timerRef.current); setClosing(false); setMounted(true); } else { setClosing(true); timerRef.current = setTimeout(() => { setMounted(false); setClosing(false); }, EXIT_MS); } return () => clearTimeout(timerRef.current); }, [open]); useEffect(() => { if (!open) return; const handleKeyDown = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [open, onClose]); if (!mounted) return null; return createPortal( <div className={closing ? 'modal-mask modal-mask--exit' : 'modal-mask modal-mask--enter'} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }} > <div className="modal-panel" role="dialog" aria-modal="true" aria-labelledby="modal-title" > <div className="modal-panel__header"> <span id="modal-title">{title}</span> <button className="modal-close" onClick={onClose}> × </button> </div> <div className="modal-panel__body">{children}</div> </div> </div>, document.body ); } export default Modal;对应的配套 CSS:
.modal-mask { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.45); } .modal-mask--enter { animation: maskIn 200ms ease-out both; } .modal-mask--exit { animation: maskOut 240ms ease-in both; } .modal-mask--enter .modal-panel { animation: panelIn 260ms cubic-bezier(0.16, 1, 0.3, 1) 40ms backwards; } .modal-mask--exit .modal-panel { animation: panelOut 240ms ease-in both; } @keyframes maskIn { from { opacity: 0; } to { opacity: 1; } } @keyframes maskOut { from { opacity: 1; } to { opacity: 0; } } @keyframes panelIn { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes panelOut { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(8px) scale(0.99); } } .modal-panel { min-width: 320px; max-width: 90vw; background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 20px 60px rgba(15, 23, 42, 0.2); }使用方式:
function Page() { const [open, setOpen] = useState(false); return ( <section> <button onClick={() => setOpen(true)}>编辑资料</button> <Modal open={open} onClose={() => setOpen(false)} title="编辑资料"> <form>...</form> </Modal> </section> ); }这个封装的优势是把"闪现"最容易出问题的几个环节都封死了:入场动画不依赖状态翻转、离场动画有固定的卸载延迟、快速开合有定时器清理、定位问题用 portal 规避。
最后再讲一点个人体会。刚开始排查这个闪现问题时,我总在"加个 setTimeout 试试"和"换一种动画库试试"之间反复横跳。后来想明白了:动画和 React 生命周期本质上是两套时钟——React 的时钟关心状态提交,浏览器的时钟关心帧的绘制。写弹窗其实是把这两套时钟对齐的工作,而不是靠运气。把入场交给 CSSanimation、离场交给"延迟卸载 + 状态切换",这套组合我在几个不同技术栈的项目里都用过,没有再出现过那种让人摸不着头脑的闪现。如果你正被同样的现象困扰,建议按这篇文章的顺序重新梳理一遍自己的代码:先确认是不是条件渲染 + transition 的时序问题,再检查 key 和样式加载时机,最后看看定位和滚动锁,八成能定位到那个"元凶"。