从 Transition 到 Motion:前端动画学习笔记

从入门到放弃

纯CSS动画

简单模式,浏览器只靠 CSS 声明式规则就能渲染出动画效果。

基础过渡(transition)

transition 是最简单的 CSS 动画方式,用来在属性值变化时平滑过渡,能响应 CSS 属性的变化来源(伪类、class 切换、媒体查询、JS 动态修改等)。比如按钮悬停变色↓。

1
2
3
4
5
6
7
.button {
  background-color: #4cafef;
  transition: background-color 0.3s ease;
}
.button:hover {
  background-color: #2196f3;
}

完整语法

1
transition: <property> <duration> <timing-function> <delay>;
  • property:要过渡的属性,例如 opacity、background、transform
  • duration:动画持续的时间,例如 0.3s、500ms
  • timing-function:节奏曲线,例如linear、ease、cubic-bezier(x1, y1, x2, y2)
  • delay:延迟开始的时间

插播一段关于transform的介绍

因为它在动画里实在太常见了,transform 是专门用来做几何变换的属性, 提供:

  • 平移 translateX(50px)、translateY(-20px)、translate3d(10px, 20px, 30px)
  • 缩放 scale(1.5)、scaleX(2)、scaleY(0.5)、scale3d(1, 2, 1)
  • 旋转 rotate(45deg)、rotateX(30deg)、rotateY(180deg)、rotate3d(1, 1, 0, 45deg)
  • 倾斜 skewX(30deg)、skewY(15deg)

一个普通的旋转动画,可以用来“展开/收起”箭头之类的:

1
2
3
4
5
6
7
.icon {
  transform: rotate(0deg);
  transition: transform 0.4s ease-in-out;
}
.icon:hover {
  transform: rotate(180deg);
}

使用关键帧(@keyframes)

@keyframes 就是一个“动画时间轴的定义”,它本身不会让元素动起来,必须有一个 引用它的机制(animation) 才能生效。

完整语法

animation 相比 transition,可以不依赖事件自动播放,有@keyframes带来的多个中间状态,控制也更多样。

1
2
3
selector {
  animation: name duration timing-function delay iteration-count direction fill-mode play-state;
}
  • name:动画名称,对应 @keyframes 的定义
  • iteration-count:循环次数,例如 3, infinite
  • direction:循环方向,normal,reverse,alternate,alternate-reverse
  • fill-mode:动画执行前后,元素的样式:none,forwards,backwards,both

简单用例

一个滑动淡入淡出功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@keyframes move {
  0%   { transform: translateX(-200px); }
  100% { transform: translateX(0); }
}

@keyframes fade {
  0%   { opacity: 0; }
  100% { opacity: 1; }
}

.fade-in {
  animation: fade 1s ease forwards;
}

// 多个动画叠加飞入 + 淡入
.element {
  animation: move 2s ease-out, fade 2s ease-out;
}

与transform配合

  • transform:元素要变成 什么样子(例如:平移100px,缩放 1.2 倍)。
  • @keyframes:定义 什么时候变成这样子(0% 到 100% 之间变化规则)。
  • animation:让元素去执行 @keyframes 的时间轴。
1
2
3
4
5
6
7
8
9
@keyframes bounce {
  0%   { transform: translateY(0); }
  50%  { transform: translateY(-100px); }
  100% { transform: translateY(0); }
}

.ball {
  animation: bounce 1s ease infinite;
}

JS驱动的逐帧动画

这里能用的就不只是 CSS 属性了,适合复杂的交互场景。 requestAnimationFrame(callback):告诉浏览器 在下一次重绘之前 执行回调,并把当前的时间戳传进去。

  • 逐帧调用:浏览器大约每秒 60 次刷新屏幕(60Hz 显示器),每帧调用一次。
  • 与屏幕刷新同步:比 setTimeout更准确,不会掉帧或抖动。
  • 节能:页面不可见(后台标签页)时会自动暂停,避免浪费性能。

简单用例

在一段时间内(intervalTime),让 container 从 startX 平滑滚动到 endX。

可以用来做轮播图,进度条,UI过渡动画之类的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const startTime = Date.now()
const intervalTime = 500
const endTime = Date.now() + intervalTime

const tick = () => {
	const nowTime = Date.now()
	if(nowTime >= endTime) {
		container.scrollTo(endX, 0)
		return
	}

	const dx = ((nowTime - startTime) / intervalTime) * (endX - startX)
	container.scrollTo(startX + dx, 0)
	requestAnimationFrame(tick)      // 每一帧结束时调用 tick 函数
}

tick()

进阶版:加了自定义的ease缓动函数 ,加了Promise可以在动画完成时触发后续逻辑,加了边界检测。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const startTime = performance.now()  // 比Date.now()精度高,更适合测量代码耗时、驱动动画
const startLocation = container.scrollLeft
const offset = endX - startX
new Promise(resolve =>
requestAnimationFrame(function step(currentTime) {
  const timeElapsed = currentTime - startTime
  const progress = Math.abs(options.duration ? Math.min(timeElapsed / options.duration, 1) : 1)
  container.scrollLeft = Math.floor(startLocation + offset * ease(progress))
  const clientWidth = container.clientWidth
  const reachRight = container.scrollLeft + clientWidth >= container.scrollWidth
  const reachLeft = container.scrollLeft <= 0 && offset < 0
  if (progress === 1 || reachLeft || reachRight)
	return resolve(offset) // 调用 resolve(offset) 后,Promise 状态变为 fulfilled

  requestAnimationFrame(step) // 没结束就继续下一帧
}),
)

动画库

大多是基于 requestAnimationFrame 的封装,做风格统一调用方便的动画。

记录一下之前看过的 Motion 库。

抄文档和代码时发出疑问:这大部分看着都是在设置CSS动画,那它是JS动画库吗?是。并且提供了类似写 CSS 的 API。区别和优点可查阅:你还需要 Framer Motion 吗?

性能优化这一块,不是很懂,先用上以后慢慢研究吧…

一段简单用例

比如说,依然是一个滑动淡入淡出的装饰器,但是Motion实现版:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    <motion.div
      className={......}
      initial={{
        opacity: 0,
        transform: 'translateX(20px)',
      }}
      animate={{ opacity: 1, transform: 'translateX(0px)' }}
      exit={{ opacity: 0, transform: 'translateX(-20px)' }}
      transition={{ duration: 0.3, ease: [0.42, 0.0, 0.58, 1.0] }}
    >
      {children}
    </motion.div>

总的来说样式也是只用到了opacity和transform,看起来和CSS动画没什么差别。

但是考虑生命周期:initial → animate,animate → exit,就不一样了。

如果组件卸载是常有之事,比如做条件渲染,React 默认会立即卸载 <motion.div>,而Motion的<AnimatePresence> 会在条件变为 false 时先拦截这个卸载,先跑 animate → exit 动画,跑完再从 DOM 移除,这是CSS不好处理的~

配上 AnimatePresence 做条件渲染的版本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<AnimatePresence mode="wait"> 
  {open && (
    <motion.div
      key="panel"
      initial={{ opacity: 0, x: 20 }}
      animate={{ opacity: 1, x: 0 }}
      exit={{ opacity: 0, x: -20 }}
      transition={{ duration: 0.3, ease: [0.42,0,0.58,1] }}
    />
  )}
</AnimatePresence>

mode可选值:

  • sync 进场动画和出场动画同时执行,新旧元素有重叠
  • wait 等到旧元素完全消失,再执行进场动画
  • popLayout 同时执行动画,但新元素直接顶替旧元素的位置

一段复杂用例

适合图标、Logo 动效、数据路径变形的动画,使用Motion自带的创建和组合运动值的钩子:

useMotionValue:一个可订阅的运动值,可以用useMotionValue手动创建。类似于 React state,但是值变化时,绑定到它的组件会直接更新样式属性(JS → DOM),不经过 React diff。

useTransform:用来把一个 MotionValue 的 输入范围 → 输出范围 做映射,返回值映射到这个输出范围里:useTransform(监控谁, 当监控的值在[这些范围]时, 返回[这些对应的值])

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 三个路径的连续变形
const path1 = "M10,10 H30 V30 H10 Z" // 正方形
const path2 = "M20,10 L30,20 L20,30 L10,20 Z" // 菱形  
const path3 = "M20,10 A10,10 0 1,1 20,30 A10,10 0 1,1 20,10 Z" // 圆形
// 驱动动画的进度值,范围 0 → 1
const progress = useMotionValue(0)
useEffect(() => {
    const control = animate(progress, 1, { duration: 0.3, ease: "linear" })
    return () => controls.stop()
  }, [])
// 会根据 `progress` 在 [0, 0.5, 1] 三个区间内插值
const path = useTransform(
	progress,
	[0, 0.5, 1], // 输入区间
	[path1, path2, path3], // 输出路径
	{
	  mixer: (a, b) => interpolate(a, b, { maxSegmentLength: 0.1 }) // 自定义插值器
	}
)

return (
	<motion.svg viewBox="0 0 40 40" width={120} height={120}>
      <motion.path d={path} fill="currentColor" />
    </motion.svg>
)

在 SVG 里,折线图、面积图、饼图、雷达图等等,本质上都是由 路径 (path) 描述出来的。

mixer 是一个函数,用来生成“两个值 a 和 b 之间的插值器”。interpolate 来自 Flubber 库,它能算出 a 和 b 之间的平滑形变。

用例中每当 progress 改变,Motion 自动更新 d,路径就会平滑地在形状之间过渡。

结果:

  • progress=0:显示正方形
  • progress=0.25:正在逐渐收缩成菱形
  • progress=0.5:完全是菱形
  • progress=0.75:菱形逐渐圆润化
  • progress=1:完全是圆形
发表了9篇文章 · 总计27.20k字
Built with Hugo
主题 StackJimmy 设计