用Framer Motion打造丝滑动画的实战技巧
项目初期的技术选型
最近做完了一个企业级后台的营销页模块,需求是做一整套动态引导式交互流程,类似产品功能导览那种,带蒙层、高亮、自动滚动、动画提示。产品经理甩过来几个竞品视频,点名要那种“丝滑感”,不能生硬弹窗。
一开始想用 CSS transition 自己搞,但考虑到后续可能加更多交互动效(比如路径移动、视差滚动),加上团队里另一个前端提了一嘴 Framer Motion,说 React 里做动效它最省事,我就试了试。没想到这一试,直接踩进去了。
选它的原因也很实际:语法简洁、和 React 状态系统融合得自然、社区案例多。而且我们项目本来就是 React + TypeScript,不涉及迁移成本。官方文档看着也挺友好,就决定上了。
第一版实现:看起来很美
最初的方案特别理想化:每个引导步骤用一个 motion.div 包裹,通过控制 animate 和 initial 切换状态,配合 variants 做序列动画。代码写起来确实爽,几行就搞定一个淡入滑动效果:
import { motion } from 'framer-motion';
const GuideStep = ({ isActive }) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isActive ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
className="guide-step"
>
欢迎使用新建按钮!
</motion.div>
);
};
本地跑起来挺顺,切换流畅,我觉得这活儿三天能收工。结果一上测试环境,UI 同事一通操作,页面直接卡出 PPT。
又踩坑了,性能炸了
问题出现在同时激活多个 motion 组件的时候。我们的引导流程有 7 步,每步都要高亮不同 DOM 节点,还要动态计算位置加蒙层。我一开始是每个步骤都 render 一个完整的 motion 结构,即使没激活也挂在那,只是 opacity=0。Framer Motion 默认会注册所有动画实例,哪怕不可见。
结果就是:7 个 motion.div 同时在跑布局检测,再加上我们要监听 resize 和 scroll 来调整蒙层位置,useAnimationFrame 回调直接爆栈。Chrome Performance 面板一看,一帧里 layout 占了 40ms+,掉帧严重。
折腾了半天发现,不是 Framer Motion 不行,是我用法太 naive。官网有一句轻描淡写的提示:“避免不必要的 motion 组件挂载”,我当时没当回事。
拆解重来:按需挂载 + 手动控制
后来改了策略:只在当前步骤才 render 对应的 motion 元素,其他全用 null。配合一个全局的 currentStep 状态来控制:
const GuideOverlay = ({ steps, currentStep }) => {
const activeStep = steps[currentStep];
if (!activeStep) return null;
return (
<motion.div
key={currentStep}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="overlay-container"
style={{
position: 'fixed',
left: activeStep.x,
top: activeStep.y,
width: activeStep.width,
height: activeStep.height,
}}
>
<div className="highlight-mask" />
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2 }}
className="tooltip-bubble"
>
{activeStep.text}
</motion.div>
</motion.div>
);
};
这里关键点是加了 exit 和 key={currentStep},确保每次切换都会触发卸载,而不是留在内存里。虽然简单,但帧率立马从 30fps 拉回 60fps。
还有一个细节:我们用了 createPortal 把这个 overlay 挂到 body 下,避免被父容器的 overflow: hidden 截断。这点不说,很多人会在 modal 类场景里翻车。
最大的坑:滚动定位不准
更头疼的问题是:页面滚动后,高亮元素的位置算偏了。
一开始我用 getBoundingClientRect() 存坐标,但只要用户中途滚动,位置就错乱。尝试过每帧重新计算,但性能又崩了。后来改成在每次 currentStep 变化时,临时冻结页面滚动(通过 document.body.style.overflow = 'hidden'),然后强制 scrollIntoView 再取位置。
useEffect(() => {
if (activeStep?.targetSelector) {
const el = document.querySelector(activeStep.targetSelector);
if (el) {
// 滚动到元素可见区域
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 延迟取位置,等滚动结束
const timeoutId = setTimeout(() => {
const rect = el.getBoundingClientRect();
setTargetRect(rect);
}, 300);
return () => clearTimeout(timeoutId);
}
}
}, [currentStep]);
这里注意我踩过好几次坑:延迟时间不能写死 300ms,有些低端机滚动动画超慢。后来改成监听 scroll 事件 + 节流,在滚动停止后再取位置。代码变复杂了,但准确率上来了。
Touch 设备上的诡异行为
最后上线前 QA 提了个 bug:在 iPad 上,点击下一步按钮没反应,偶尔还会触发两次。
排查发现是 motion 组件默认会处理 tap 事件,但我们按钮本身也有 onClick,两个事件冲突了。解决方案是在按钮外层加 pointerEvents: 'none',内部文本再单独设回 auto,或者干脆不用 motion.button,只用 motion.div 包一层视觉效果。
还有一个问题是 iOS Safari 的 position: fixed 在键盘弹起时会错位,导致蒙层偏移。最终妥协方案是:输入框聚焦时不显示引导层,加个 isKeyboardOpen 检测(通过 viewport 高度变化判断),虽然不完美,但影响不大。
最终的解决方案
现在这套系统跑得还算稳。核心逻辑是:
- 每个步骤只渲染当前项,避免资源浪费
- 坐标计算放在
useEffect+ 节流中,依赖currentStep和resize/scroll事件 - 动画 duration 控制在 200–300ms,太长用户会不耐烦
- 关键操作按钮不包裹在
motion内,防止事件劫持
整体代码量不多,核心部分就两百行左右,维护起来也方便。唯一遗憾的是,退出动画偶尔会有残影,怀疑是 exit 动画和 unmount 时机不一致,但改了几次都没根治,目前通过加 key 强制刷新绕过去了。
回顾与反思
回过头看,Framer Motion 确实是个好工具,但它不是万能胶。你得清楚它在哪容易翻车:大量实例共存、频繁 re-render、DOM 位置动态变化。这些问题它不会主动帮你处理,得自己补逻辑。
另外建议:别一开始就上 variants 或 layout 属性,那些更适合小范围动效。我们最初想用 layout 实现自动位置过渡,结果性能直接拉垮,最后还是回归手动控制。
还有个小技巧:开发阶段打开 framer-motion/dev,它会打印性能警告,比如“你正在 animating 非 transform 属性”,这类提示真的救了我几次。
以上是我的项目经验,希望对你有帮助
这个功能最终上线后反馈还行,没收到明显卡顿投诉。虽然有些小瑕疵没彻底解决,但业务能接受。这种引导类交互本来就是辅助功能,没必要追求极致完美。
如果你也在用 Framer Motion 做复杂交互动效,建议早点压测真实设备,模拟弱网和低端机型。很多问题在 Mac Chrome 上根本看不出。
以上是我个人对这个动效系统的完整实现总结,有更优的实现方式欢迎评论区交流。这类问题踩一次够记一年。

暂无评论