前端常见动画实现原理

开发H5活动页面是前端开发中一种比较常见的需求,这种页面业务逻辑较轻,重交互和展示,是一类非常有趣的需求。本文将整理H5开发中常见Web动画的基本原理和简单实现。

<!--more-->

1. 逐帧动画

参考

逐帧动画也叫定格动画,其原理是将每帧不同的图像连续播放,从而产生动画效果

最常见的逐帧动画大概就是gif图了,在Mac上使用“预览”工具打开一张gif图片,可以在左侧看见多张静态图片。

除了直接使用gif图片之外,前端也可以使用JS或者CSS来实现逐帧动画,大致步骤为

  • 提前做好每一帧的静态图片,为了减少网络请求,一般会使用精灵图,然后通过background-postion来控制动画的展示
  • 按顺序播放每一帧,形成动画效果

逐帧动画的缺点在于:需要UI提供每一帧的图片,效率、扩展性和网络性能都比较差。

CSS可以通过@keyframes来定义关键帧动画,每个关键帧有一个百分比值作为名称,代表在动画进行中,在哪个阶段触发这个帧所包含的样式。

同时CSS提供了animation-timing-function,用来控制两帧之前样式的改变速度,可以分为

  • cubic-bezier() 定时函数
  • steps() 函数

下面的例子展示了一个 flappy bird 的小鸟飞行动画,其核心实现依赖@keyframesstep()

  1. 准备下面这张模糊到不行的精灵图

  2. 定义三帧对应的css属性,主要是找到每一帧的背景偏移量

    .bird {
     position: relative;
     width: 46px;
     height: 31px;
     background-image: url("./bird.png");
     background-repeat: no-repeat;
    }
    .bird1 {
     background-position: 0 center;
    }
    .bird2 {
     background-position: -47px center;
    }
    .bird3 {
     background-position: -94px center;
    }
  3. 把上面的三帧放在动画中

    @keyframes fly-bird {
     0% {
         background-position: 0;
     }
    
     33.3333% {
         background-position: -47px;
     }
    
     66.6666% {
         background-position: -94px;
     }
    }
  4. 运行动画

    .bird-fly {
     animation: fly-bird 0.4s steps(1) infinite;
    }

需要理解的是animation-timing-function是作用于两个 keyframes 之间,而非整个动画,因此这里steps(1)的含义,指的是0~33.33%阶段只执行一次跳跃,而不是整个动画0~100%之间只执行一次跳跃,所以这里也可以换一种更简单的写法

@keyframes fly-bird2 {
    100% {
        background-position: -141px;
    }
}

.bird-fly {
    // 指定3帧,由step自己计算每一帧的偏移
    animation: fly-bird2 0.4s steps(3, end) infinite; 
}

2. SVG描边动画

SVG描边动画时SVG中最常用的一种动画形式,其核心原理是与两个属性有关

  • stroke-dasharray,主要用来绘制虚线,其值为x,y的形式,其中x表示实线的长度dash,y表示两根实线之间的间隔gap
  • stroke-dashoffset,用来定义dash线条开始的位置

描边动画的实现思路是,设置一个较大的dash和gap,然后通过设置较大的stroke-dashoffset在初始状态下隐藏整个实线,只看见空白部分,然后通过缩小stroke-dashoffset将实现部分逐渐展示,就形成了“描边”的动画效果。

需要注意的是:绘制的虚线是按照path路径进行绘制的,因此理论上可以是任意形状!

那么具体stroke-dashoffsetstroke-dasharray的初始值究竟应该为多少呢?只要设置成path的长度就可以了。

path的路径并不一定是规则的,所幸的是JavaScript提供了获取path路径长度的接口

let path = document.querySelector('path');
let length = path.getTotalLength(); // 将这个值作为dashoffset和dasharray的取值

下面展示了一个使用描边动画实现的对话框

@keyframes stroke {
    100% {
        stroke-dashoffset: 0;
    }
}

.chat_corner {
    animation: stroke 0.6s linear 0.3s forwards;
}

.chat-path {
    width: 110px;
    margin: -5px auto 0;
}

.chat-path .chat_corner {
    transform-origin: 50% 50%;
    stroke-dasharray: 641;
    stroke-dashoffset: 641;
}

完整源代码

3. canvas动画

canvas动画可以理解成JS版的逐帧动画,主要原理是在每一帧手动计算元素的状态并将其绘制到画布上。

3.1. 基本动画

下面的代码展示了使用canvas绘制一个一个运动的小球

let canvas = document.getElementById("myCanvas");
let ctx = canvas.getContext("2d");

let ball = {
    x: 0,
    y: 100,
    r: 10,
    dx: 2,
    rgbaColorArr: [111, 123, 222, 1],
    draw() {
        const {x, y, r, rgbaColorArr} = this
        ctx.beginPath()
        ctx.arc(x, y, r, 0, Math.PI * 2);
        ctx.closePath()
        ctx.fillStyle = `rgba(${rgbaColorArr.join(',')})`
        ctx.fill();
    },
    move() {
        this.x += this.dx
        this.draw()
    },
    animate() {
        const d = 1000 // 动画时长 1000ms
        const update = (t) => {
            // 清除画布
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            // 移动小球并重新绘制
            this.x += this.dx
            this.draw()

            if (t < d) {
                requestAnimationFrame(update);
            }
        }
        requestAnimationFrame(update);
    }
}

ball.animate()

代码比较简单,具体实现为

  • 通过requestAnimationFrame清除画布并绘制每一帧的内容
  • 在每一帧中,修改this.x横坐标的位置,然后调用ball.braw方法重新绘制小球

这样,肉眼就观察到了小球运动的画面。

从上面的代码可以看出,小球运动的关键是

this.x += this.dx

假设每一帧的间隔时间是相同的,那么我们看见的小球就会做匀速运动,

  • 第一帧小球的横坐标为x + dx
  • 第二帧小球的横坐标为x + 2*dx
  • ...

换个思路,我们只要控制了x变量的变化,就可以实现非匀速的动画效果,比如大名鼎鼎的tween.js

大部分缓动方法都包含四个参数

// t: 当前时间
// b: 开始的状态
// c: 变化的状态
// d: 从b变成b+c需要的时间
function linear(t, b, c, d) {
    return c * t / d + b
}
function easeIn(t, b, c, d) {
    return c * (t /= d) * t + b;
}

比如linear(1000, 10, 100, 2000),表示的含义为:一个从起始状态10到结束状态10+100、耗时2000ms的linear线性动画,在1000ms时对应的状态。需要注意td只要保证单位一致就行,不需要强制将其转换成秒或者毫秒

因此修改一下ball.animate方法中关于this.x的计算方式

let ball = {
    // ... 其他属性
    animate() {
        const d = 1000 // 运动时间1000ms
        const target = canvas.width - this.r * 2 // 目标位置

        const update = (t) => {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            // this.x += this.dx
            // this.x = linear(t, 0, target, d) 等价于上面的this.x += this.dx
            this.x = easeIn(t, 0, target, d) // 缓动函数easeIn
            this.draw()

            if (t < d) {
                requestAnimationFrame(update);
            }
        }
        requestAnimationFrame(update);
    }
}

就可以看见不同的运动效果了。牢记一点:我们改变的并不是动画运行的时间,而是根据时间函数计算小球在某一个时刻的状态,然后将它更新到画布上,这是很多动画的基础。

除了缓动函数之外,还可以使用贝塞尔曲线来计算元素的状态

参考

3.2. 粒子动画

粒子可以理解为画布的最小组成单位——像素,也可以某些面积比较小的点。单个粒子很难看出展示效果,一般会使用大量的粒子来描述一些不规则的物体,常用在游戏系统中,如火焰、烟花等。

参考

实现粒子动画大致可以分为下面步骤

  • 定义粒子,一个粒子的基本属性包括:位置、尺寸、颜色、速度、停留时长等
  • 生成粒子,可以直接遍历动态生成,也可以提前准备好粒子(如使用getImageData获取画布上某个区域的像素点)
  • 发射粒子,将粒子渲染在每一帧的画布上,这一步跟上面单个小球的动画展示很像,只是我们现在需要处理成千上万个粒子

一个粒子的基本属性包括:初始位置、终止位置、颜色和形状,此外还粒子往往还包括持续时间、延迟等等属性

3.2.1. 基础效果

下面展示了一个将从画布上获取像素点并展示基本的粒子效果

其核心代码为

const pos = {x: 50, y: 50, w: 100, h: 100}
const originX = pos.x + pos.w / 2
const originY = pos.y + pos.h + 200

// 使用一个数组保存生成的粒子
let particles = []
const reductionFactor = 5
for (let x = 0; x < pos.w; x += reductionFactor) {
    for (let y = 0; y < pos.h; y += reductionFactor) {
        let particle = {
            // 初始位置
            x0: originX,
            y0: originY,
            // 终止位置
            x1: x + pos.x,
            y1: y + pos.y,
            // 样式属性
            rgbaColorArr: randomColor(),
            currTime: 0, // 粒子已经运行的时间
            duration: 3000,
        };
        particles.push(particle);
    }
}

然后遍历列表,依次绘制每个粒子动画,

function easeInOutExpo(e, a, g, f) {
    return g * (-Math.pow(2, -10 * e / f) + 1) + a
}

let requestId

function renderParticles(t) {
    // 最后一个粒子
    const last = particles[particles.length - 1]
    if (last.duration < last.currTime) {
        cancelAnimationFrame(requestId)
        return
    }

    ctx.clearRect(0, 0, canvas.width, canvas.height);

    for (let p of particles) {
        const {duration, currTime} = p
        ctx.fillStyle = `rgba(${p.rgbaColorArr.join(',')})`
        if (currTime < duration) {
            let x = easeInOutExpo(currTime, p.x0, p.x1 - p.x0, duration);
            let y = easeInOutExpo(currTime, p.y0, p.y1 - p.y0, duration);
            ctx.fillRect(x, y, 1, 1)

        } else {
            ctx.fillRect(p.x1, p.y1, 1, 1)
        }
        p.currTime = t
    }

    requestId = requestAnimationFrame(renderParticles)
}

const animate = () => {
    requestId = requestAnimationFrame(renderParticles)
}

animate()

完整源代码

从动画效果可以看见,对单个粒子而言,其动画实现与上面“面积比较大的”小球并没有什么区别。但就整体而言,整个动画显得非常生硬。

3.2.2. 让粒子动画更真实

如果要让粒子动画显得真实,就不能让动画效果过于整体,要让每个粒子间隔启动,并交替执行动画,有两种实现间隔的方式

  • 让每一行粒子启动时间有规律错开
  • 每一行粒子之间启动时间随机的错开

粒子独立的颗粒感与整体的层次感是保证粒子动画真实的主要原因。

因此对代码稍作修改,添加一些随机参数控制

const frameTime = 16.66 // 假设一帧为16.66ms

for (let x = 0; x < pos.w; x += reductionFactor) {
    for (let y = 0; y < pos.h; y += reductionFactor) {
        let particle = {
            // ... 其他参数
            delay: y / 20 * frameTime, // 延迟启动的时间,按行来区分,突出层次感
            interval: parseInt(Math.random() * 10 * frameTime), // 每个粒子的启动间隔时间,随机1到10帧,突出粒子的颗粒感
        }
        particles.push(particle);
    }
}

然后再绘制粒子时,加入对delayinterval的处理

for (let p of particles) {
    // 未到启动时间,则直接跳过
    if (p.currTime < p.delay) {
        p.currTime = t
        continue
    }

    const {duration, currTime, interval} = p
    ctx.fillStyle = `rgba(${p.rgbaColorArr.join(',')})`
    if (currTime < duration + interval) {
        // 引入interval控制每个粒子的启动间隔
        if (currTime >= interval) {
            let x = easeInOutExpo(currTime - interval, p.x0, p.x1 - p.x0, duration)
            let y = easeInOutExpo(currTime - interval, p.y0, p.y1 - p.y0, duration)
            ctx.fillRect(x, y, 1, 1)
        }
    } else {
        ctx.fillRect(p.x1, p.y1, 1, 1)
    }
    p.currTime = t
}

完整源代码

3.3. 物理效果

除了使用缓动函数和贝塞尔曲线来控制每一帧元素的状态之外,我们还可以模拟更真实的物理效果,如抛物线小球、自由落体、物体碰撞等

这里需要掌握更多的物理和数学知识,比如向量、数学矢量等,这里不再展开

4. FLIP实现DOM切换动画

FLIP stands for First, Last, Invert, Play.

参考

FLIP是一种实现DOM状态切换动画的方案

  • First: 元素的初始状态
  • Last: 元素的终止状态
  • Invert: 这是整个动画实现的核心思想,比如一个元素初始x位置是0,向右移动到了90px的位置,为了实现动画效果,我们可以将元素的初始状态设置成transform: translate
  • Play: 播放动画,即动画回到他本来的位置

由于DOM元素属性的变化会被浏览器收集起来并延迟到下一帧进行渲染,因此在某个时间段上,元素的DOM信息发生了变化、但浏览器还没有渲染,这样我们就能拿到元素初始和终止的两个状态,从而实现Invert

下面是在Vue中使用FLIP实现数组乱排的动画效果,核心代码是

function getPositionList(domList) {
  return domList.map((dom) => {
    const {left, top} = dom.getBoundingClientRect()
    return {left, top}
  })
}

this.list = shuffle(this.list)

const cellList = this.$refs.cell.slice() // 保存快照

// 获取初始状态
const originPositions = getPositionList(cellList)

this.$nextTick(() => {
    // 获取节点新的状态
    // 需要保证变化前后的DOM节点一致,因此v-for的key不能使用index
    const currentPositions = getPositionList(cellList)

    cellList.forEach((cell, index) => {
        let cur = currentPositions[index]
        let origin = originPositions[index]

        const invert = {
            left: origin.left - cur.left,
            top: origin.top - cur.top,
        }

        const keyframes = [
            {transform: `translate(${invert.left}px, ${invert.top}px)`},
            {transform: "translate(0)"},
        ]

        const options = {
            duration: 300,
            easing: "linear",
        }

        cell.animate(keyframes, options)
    })
})

在React中可以使用useLayoutEffect在DOM更新后执行逻辑。需要注意的是,FLIP的核心是获取到元素改变前后的状态,其前提是针对同一个元素,所以对于vnode而言,其key值要唯一,从而保证比较的是同一个DOM节点,而不是“就地复用”

完整源代码

5. lottie:抱住设计大腿

参考

Lottie是基于CALayer的动画, 所有的路径预先在AE中计算好, 转换为Json文件, 然后自动转换为Layer的动画,

因此我们只需要抱住设计的大腿就好了,对开发来说工作量很少~

开发接入步骤:

  • 导出lottie-web库,导入动画JSON文件
  • lottie.loadAnimation运行文件

lottie可以运行在iOS、Android甚至于Flutter等多个平台,当需要一些比较复杂的动画时,去求求设计大佬吧

6. 小结

本文整理了几种常见的web动画开发思路

  • 逐帧动画,最常见的动画形式
  • 描边动画,使用SVG实现的path动画效果
  • 粒子动画,使用canvas实现酷炫的粒子效果
  • FLIP动画,主要用于DOM切换等效果

实现动画,除了解其原理之外,还需要调试动画的运行时间、切换速度等参数,这需要大量的经验和经历,大概这也算做是前端的一个乐趣吧。

使用Vue3封装一些有用的组合API