手把手实现丝滑过渡动画的实战技巧

巧玲 前端 阅读 2,215
赞 13 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这个项目是个内部用的内容管理后台,主要功能是编辑和预览页面。客户提了个需求:希望在切换不同组件面板的时候有点“动效”,别那么生硬。最开始我其实挺抗拒的,毕竟后台系统搞那么多花里胡哨干嘛?但转念一想,用户每天要点几十次面板切换,如果每次都是“啪”一下跳过去,确实容易眼晕。

手把手实现丝滑过渡动画的实战技巧

所以最后还是决定加过渡动画。本来想直接上 React Transition Group 或者 Framer Motion,但考虑到项目本身没引入任何动画库,为了这点动效引入一个大包,性价比太低。后来就定了个原则:纯 CSS 实现,零依赖,能省则省。

动手试试:从 collapse 开始

第一个想到的就是 height: 0height: auto 的过渡。结果刚写完就发现问题了——CSS 不支持 height: auto 的过渡。这个坑我居然又踩了一遍,明明去年就在另一个项目里栽过。

于是改用 max-height 过渡。设个很大的值,比如 999px,视觉上看起来就像自动撑开了。

核心代码其实很简单:

.collapse {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease;
}

.collapse.open {
  max-height: 999px;
}

配合 JS 切换 class 就行了:

const panel = document.querySelector('.collapse');
panel.classList.toggle('open');

看起来没问题,跑起来也流畅。但很快发现两个问题:

  • 当内容特别少(比如只有一行)时,max-height 999px 显得有点“虚胖”,虽然不影响功能,但强迫症看着难受
  • 更麻烦的是,如果内容动态变化(比如加载远程数据),高度可能超过 999px,那就被截断了

一开始我试着用 JS 动态计算真实高度再设置 transition,结果动画直接卡成幻灯片——因为浏览器要重排重绘,性能拉胯。

最大的坑:展开收起抖动与布局偏移

真正让我折腾了两天的,是另一个问题:**当多个折叠面板堆在一起时,展开一个,下面的元素会“闪一下”**。不是动画不连贯,而是整个页面轻微跳动,像是重排了两次。

查了半天发现是 overflow: hidden 在某些情况下会导致渲染层合成异常,尤其在 Safari 上特别明显。有次我甚至怀疑是字体加载导致的 reflow,结果排除了一圈都不是。

最后定位到是 transitiontransform 没对齐的问题。父容器用了 transform: translateZ(0) 触发硬件加速,子元素又在做高度变化,GPU 和主线程打架了。

解决方案很土:放弃 max-height,改用 opacity + padding 控制。虽然不能“从无到有”的展开,但至少不会闪了。

.fade-panel {
  opacity: 0;
  padding: 0 1rem;
  pointer-events: none;
  transition: opacity 0.3s ease, padding 0.3s ease;
}

.fade-panel.visible {
  opacity: 1;
  padding: 1rem;
  pointer-events: all;
}

这里注意我踩过好几次坑:pointer-events: none 是必须的,不然收起状态下你还能点到里面的按钮,玄学 bug。

另一个需求:拖拽排序的反馈动画

后来产品经理又加了个需求:组件可以拖拽排序,移动时要有“滑入”效果。这个没法用纯 CSS 解决了,得靠 JS 控制位移。

我用的是原生 drag/drop API,监听 dragover 来更新位置。关键是在拖拽过程中给目标位置插入一个占位元素,同时让被拖的元素脱离文档流。

为了让交换看起来顺滑,我给每个 item 加了 transform 过渡:

.sortable-item {
  transition: transform 0.2s ease;
}

然后在 JS 中动态添加偏移:

function applyTransform(element, deltaX, deltaY) {
  element.style.transform = translate(${deltaX}px, ${deltaY}px);
}

初版上线后发现一个问题:**快速拖拽时,动画会累积延迟**。原因是每次 dragover 都触发一次样式更新,频率太高,浏览器处理不过来。

后来加上了节流:

let ticking = false;
function updatePosition() {
  // 更新位置逻辑
  ticking = false;
}

element.addEventListener('dragover', () => {
  if (!ticking) {
    requestAnimationFrame(updatePosition);
    ticking = true;
  }
});

这下顺滑多了。亲测有效,CPU 占用从 40%+ 掉到了 15% 左右。

移动端适配问题

本以为 PC 端搞定了就完事了,结果测试在 iPad 上一试,动画卡得像 PPT。排查发现是 transform: translate() 在 iOS Safari 上对 flex 布局的嵌套容器特别不友好。

最后的妥协方案是:检测是否为移动端,如果是,则关闭 transform 过渡,只保留 opacity 变化。

const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile) {
  document.body.classList.add('no-transform');
}

然后在 CSS 里覆盖过渡效果:

.no-transform .sortable-item {
  transition: opacity 0.2s ease;
  transform: none !important;
}

虽然体验降级了,但至少不会卡死。这个方案不是最优的,但最简单,上线前没时间折腾 Web Animations API 了。

最终的解决方案

总结下来,现在的方案是混合策略:

  • 普通折叠面板用 opacity + padding 过渡,稳定不抖
  • 重要且内容固定的区域才用 max-height,上限设到 2000px 防超限
  • 拖拽排序用 transform + 节流,移动端自动降级
  • 所有动画 duration 控制在 200-300ms 之间,太快看不清,太慢让人急

另外加了个全局开关,可以通过 URL 参数 ?noanim=1 关闭所有动画,方便测试或给受不了动画的用户。

回顾与反思

回过头看,这个动效需求其实不该一开始就全铺开。如果先做个 MVP,只在一两个模块试点,可能能早一点暴露性能问题。

还有就是,我高估了现代浏览器对 CSS 动画的兼容性。特别是 Safari 对 will-changetransform 的处理,简直反人类。有次我加了 will-change: transform,结果内存占用飙升,删掉才恢复正常。

目前还遗留一个小问题:在快速连续展开/收起多个面板时,偶尔会出现动画未完成就被打断的情况,导致视觉错乱。暂时没解决,但出现频率很低,产品也接受了。

还有一个优化点没做:应该根据设备性能动态调整动画质量。比如通过 JavaScript 检测设备是否“高性能”,再决定是否开启复杂动画。类似 prefers-reduced-motion 的思路,但现在懒得起手改了。

以上是我的项目经验,希望对你有帮助

这个技巧的拓展用法还有很多,比如结合 Intersection Observer 实现滚动入场动效,后续可能会继续分享。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,我现在也正想找更好的方案替换掉那个 max-height 的脏补丁。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论