返回博客

把 Framer Motion 搬进微信小程序:三个让动画哑掉的反直觉坑

设计师交付了一份用 React + Framer Motion 搭的高保真原型:数字滚动增长、Emoji 周期摆动、点击卡片有弹性缩放。产品体验极好。然后我开始把它迁移到微信小程序原生实现——没有 requestAnimationFrame,没有 hooks,只有 WXSS @keyframessetInterval

结果动画一个都没出来。页面一片静止。

现象

三处动效全部失效:

  1. Emoji 摆动:@keyframes 写好了,挂上去,没有任何动画。
  2. 卡片点击反馈::active 触发了(背景色变了),但 scalerotate 完全不动。
  3. 数字滚动:setIntervalsetData 频繁触发,页面卡,但数字跳跃而非流畅计数。

每个问题都单独沉默地失败,没有任何报错。

根因

坑一:<text> 是 inline,transform 对 inline 元素无效

WXML 里的 <text> 默认渲染为 inline 元素,就像 HTML 的 <span>。CSS transform 对 inline 元素不生效——这是 CSS 规范行为,不是小程序 bug。

坑:transform 挂在 text 上静默失效

Emoji 往往写在 <text> 里。给它加 animation: emojiWobble 1.5s infinite 后,动画类确实被挂上了,但 transform: rotate() 对 inline 元素没有渲染效果,所以 Emoji 纹丝不动,控制台零报错。

坑二::active<navigator> 上不响应

微信小程序 :active 伪类可以作用于 <view>,但不响应 <navigator> 元素。如果卡片外层套了 <navigator> 做跳转,:active { transform: scale(0.88) } 永远不会被触发。需要改用 hover-class 属性。

坑三:countUp 的 setData 节拍太密,导致桥调用堆积

useCountUp(target, 1200ms) 的每帧更新直接搬过来,用 setInterval(fn, 16) 模拟 60fps,1200ms 内产生 75 次 setData。小程序的 JS → Renderer 通信桥在频繁 setData 下会堆积,表现为数字跳跃感强、页面其他元素短暂卡顿。

正解

修 Emoji 动画:加 display: inline-block

/* WXSS */
.emoji-wobble {
  display: inline-block; /* 关键:让 transform 生效 */
  animation: emojiWobble 1.5s ease-in-out 1s infinite;
}
@keyframes emojiWobble {
  0%, 100% { transform: rotate(0deg); }
  15%  { transform: rotate(14deg); }
  35%  { transform: rotate(-8deg); }
  55%  { transform: rotate(14deg); }
  75%  { transform: rotate(0deg); }
}

bounce 版同理:

.emoji-bounce {
  display: inline-block;
  animation: emojiBounce 2s ease-in-out infinite;
}
@keyframes emojiBounce {
  0%, 100% { transform: translateY(0); }
  50%       { transform: translateY(-6rpx); }
}
正解:inline 元素必须先设 display: inline-block

所有需要 transform<text> 或其他 inline 元素,一律加 display: inline-block,这是让 CSS transform 生效的前提。

修点击反馈:<view>:active<navigator>hover-class

/* view 卡片 */
.card {
  transition: transform 0.2s ease;
}
.card:active {
  transform: scale(0.88) rotate(-2deg);
}
<!-- navigator 包裹时改用 hover-class -->
<navigator url="/pages/detail/index"
           hover-class="card-pressed"
           hover-stay-time="150">
  <view class="card">...</view>
</navigator>
.card-pressed {
  transform: scale(0.88) rotate(-2deg);
}

修 countUp:降到 30 步,走 easeOutCubic

_countUp(fields, duration) {
  var that = this
  duration = duration || 1200
  var steps = 30  // 每步 ~40ms,比 60fps 稀疏 10 倍,setData 成本可控
  var interval = duration / steps
  var step = 0
  var targets = {}
  for (var k in fields) targets[k] = fields[k]
  if (that._countUpTimer) clearInterval(that._countUpTimer)
  that._countUpTimer = setInterval(function () {
    step++
    var progress = Math.min(step / steps, 1)
    var eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
    var update = {}
    for (var k in targets) update[k] = Math.round(eased * targets[k])
    that.setData(update)
    if (progress >= 1) {
      clearInterval(that._countUpTimer)
      that._countUpTimer = null
    }
  }, interval)
},

30 步在视觉上与 60fps 无明显区别(人眼 countUp 感知靠加速度曲线,不靠帧率),但 setData 次数降至原来的 40%,桥调用压力显著减轻。

补充:多字段合并进一次 setData

如果页面同时有积分和金币两个数字需要滚动,把它们合并进同一个 setData({ displayPoint, displayCoin }) 调用,而不是分两个 setInterval,可以再减一半桥调用。

别忘了在 onHide / onUnload 清掉 timer,否则页面切走后定时器还在悄悄 setData:

onUnload() {
  if (this._countUpTimer) {
    clearInterval(this._countUpTimer)
    this._countUpTimer = null
  }
},

一句话外卖

微信小程序里 <text> 是 inline,CSS transform 对它静默无效;:active<navigator> 也静默无效——凡是动画哑掉零报错,先查元素的 display 和标签类型。