JS前端可视化canvas动画原理及其推导实现

作者:交互手记

时间:2026-10-25

浏览:171

本文深入解析Canvas动画的核心原理,指出动画本质是数值随时间的变化。通过对比requestAnimationFrame与定时器,论证以时间为维度的动画实现方式。详细推导了缓动函数的数学模型,从匀速到非线性运动,展示了如何构建通用的动画工具类,确保不同设备上的动画效果一致。

文章目录

前言

目前,fabric.js的雏形已经具备,虽然体量小巧,但功能完备。我们不仅能在画布上自由添加物体,还实现了点选和框选功能,并能对它们进行变换操作。然而,仅具备变换功能还不够灵活,若能让物体动起来,体验会更好。因此,本章引入动画主题,并解决其核心问题:如何保证在不同电脑上实现一致的动画效果。接下来,我们将直接开始编码实现。

尽管这是系列文章的一部分,但每个章节均可独立阅读,请放心阅读。

动画的本质

首先,我们看看在canvas库中调用动画的一般方式。例如,要让一个矩形动起来,大致用法如下:

rect.animate(
    { top: 50, left: 400, angle: 45 }, // 要动画的属性
    { duration: 1000, onChange: canvas.renderAll.bind(canvas) } // 动画执行时间和手动渲染
);

代码简单易懂。那么,动画的本质是什么?为什么我们能观察到动画效果?这其实是因为画布被不断重绘。只要重绘的频率足够高、速度足够快,利用人眼的视觉残留效应,就会形成连续的动画效果。

确实如此,但我们可以更具体地分析。画布为什么要重绘?是因为画布中某个物体的某个属性值发生了改变,我们需要更新画面以反映其位置或状态的变化。因此,物体状态值的改变才是动画的根本原因。

例如,一个物体需要在1秒内从left=100移动到left=200。只要不断修改left值并调用renderAll,就能看到物体从左向右移动。这很容易理解,但随之而来一个新问题:它应该如何移动?是匀速、加速、减速还是其他方式?这取决于left值的变化规律,具体由我们定义。

动画的实现

既然动画的本质是值的变化,那么这种变化受哪些因素影响?根据前面的例子,主要包含以下四个因素:

  • 初始值:startValue
  • 结束值:endValue
  • 值的变化时间:duration
  • 怎么变(匀速、缓动还是弹动):easing(一个熟悉的单词出现了)

显然,动画是一个通用概念,因此我们将其封装在Util工具类中。代码不多,直接参考即可:

interface IAnimationOption {
    /** 初始值 */
    startValue?: number;
    /** 最终值 */
    endValue?: number;
    /** 执行时间 */
    duration?: number;
    /** 缓动函数 */
    easing?: Function;
    /** 动画一开始的回调 */
    onStart?: Function;
    /** 属性值改变都会进行的回调 */
    onChange?: Function;
    /** 属性值变化完成进行的回调 */
    onComplete?: Function;
}
class Util {
    static animate(options: IAnimationOption) {
        window.requestAnimationFrame((timestamp: number) => { // requestAnimationFrame 会有个默认参数 timestamp,单位毫秒,表示开始去执行回调函数的时刻
            // 初始化一些变量
            let start = timestamp || +new Date(), // 开始时间
                duration = options.duration || 500, // 动画时间
                finish = start + duration, // 结束时间
                time, // 当前时间
                onChange = options.onChange || (() => {}), // 值改变进行的回调
                easing = options.easing || ((t, b, c, d) => -c * Math.cos((t / d) * (Math.PI / 2)) + c + b), // 缓动函数,不用管名字,简单理解为一个普通函数即可,它会返回一个数值
                startValue = options.startValue || 0, // 初始值
                endValue = options.endValue || 100, // 结束值
                byValue = options.byValue || endValue - startValue; // 值的变化范围
            function tick(ticktime: number) { // tick 的主要任务就是根据当前时间更新值
                time = ticktime || +new Date();
                let currentTime = time > finish ? duration : time - start; // 当前已经执行了多久时间(介于0~duration)
                onChange(easing(currentTime, startValue, byValue, duration)); // 根据当前时间和 easing 函数算出当前的动画值是多少,easing 理解成一个普通函数就行,它会返回一个值,就像这样:curVal = f(x) = easing(currentTime)
                if (time > finish) { // 动画结束
                    options.onComplete && options.onComplete(); // 动画完成的回调
                    return;
                }
                window.requestAnimationFrame(tick); // 循环调用 tick,不断更新值,从而形成了动画
            }
            options.onStart && options.onStart(); // 动画开始前的回调
            tick(start); // 开始动画
        });
    }
}

相信上面的注释已经解释得很清楚。但仍需重点强调两点:

  • 第一,为什么使用requestAnimationFrame这个API来完成动画?这是一个老生常谈的问题。因为setInterval和setTimeout不够精确,容易出现执行时机不准、页面切换后堆积执行、动画不流畅等问题。此外,它们并非专为动画设计(当然,如果不习惯使用requestAnimationFrame,也可以替换为setTimeout以便理解)。
  • 而requestAnimationFrame是按屏幕刷新率刷新的,跟随帧率运行期间,我们可以避免许多无用功,更好地知道绘制下一帧的最佳时机,从而保证流畅性。它们最主要的区别在于:
  • setInterval和setTimeout是主动告诉浏览器何时进行绘制;
  • 而requestAnimationFrame则是浏览器在认为可以绘制下一帧时通知我们(细细品味,其中的差异便显现出来)。

当然,我们不能像下面这样直接调用:

// 假设要从左到右运动
let left = 100;
function tick() {
    left++; // 更新值
    window.requestAnimationFrame(tick);
}
tick();

因为每个屏幕的刷新频率不同,如果像上面这样写,在某些电脑上动画会快一些,而在另一些电脑上则慢一些。此外,当页面切换到后台时,帧率会降低,导致各种问题。这显然不是我们期望的结果。

那么,应该怎么做呢?

我们应该以时间为维度来播放动画,因为时间对所有人来说流逝速度是一致的。因此,在动画开始时,需要记录开始时间start,之后动画的播放进度都以这个开始时间为基准。回顾刚才代码中计算当前动画执行时间的逻辑:

let currentTime = time > finish ? duration : time - start;

这是以start为基准的,这一点非常重要。

第二点涉及easing函数。虽然大家可能接触过,但仍有许多同学对此感到疑惑。接下来,我将专门讲解这方面的内容,例如:这个函数的作用、推导过程、最终结果,以及它与我们常说的缓动函数是否一致等。

动画的推导

在讲解onChange(easing(currentTime, startValue, byValue, duration))之前,我们先看看如何为每个物体添加动画方法。只需在物体基类中扩展即可,简单浏览一下:

class FabricObject { // 物体基类
    _animate(property, to, options: IAnimationOption = {}) { // 某个属性要变化到哪里
        options = Util.clone(options);
        let currentValue = this.get(property); // 获取初始值
        if (!options.from) options.from = currentValue; // 一般不传初始值的话就默认取当前属性值
        Util.animate({
            startValue: options.from,
            endValue: to,
            easing: options.easing, // 决定了值如何变化,常用的就缓动和弹动
            duration: options.duration,
            onChange: (value) => { // value 是 easing 函数的返回值,本质就是值的计算,value = easing()
                this.set(property, value); // 重新设置属性值
                options.onChange && options.onChange(); // 值改变之后,调用 onChange 回调就会重新渲染画布,数据和视图分开的优点又体现了出来
            },
            onComplete: () => {
                this.setCoords(); // 更新物体自身的一些坐标值等
                options.onComplete && options.onComplete(); // 动画结束的回调
            },
        });
    }
}

再次强调,动画的核心是值的变化。Util.animate中的easing函数实际上就是计算动画播放到(0, duration)中间某一时刻的值是多少,仅此而已。再简单说说easing函数,通常称为缓动函数。

它首先是一个函数,并返回一个数值,类似于y = f(x)。在我们的例子中,即value = easing(time, beginValue, changeValue, duration)。该函数有四个参数(当前时间、初始值、变化量=结束值-初始值、动画时间),返回的是当前时间点对应的值value。显然,后三个参数是已知且固定的,唯一变化的是当前时间,其取值范围是从0到duration。

执行动画时,实际上是改变当前时间,将当前时间代入easing函数,即可得到对应的value值。

可能有同学仍不理解这个缓动函数,主要是因为被上面的公式吓到了。公式都是推导后的简便写法,直接看式子很难理解,单凭公式在脑海中想象动画效果也不现实。因此,这里给大家简单推导一下这种式子的来源,以最简单的匀速运动为例,请看下图:

前言

上述过程显然无需复杂推导。下面我们来看另一个更通用的例子,首先随便选取一个函数y = x * x(其他函数也可以),并简单绘制其函数图像:

前言

绿色代表起点,即动画起始值;红色代表终点,即动画结束值。x轴表示动画时间,y轴表示当前的动画值。为了统一和方便,我们需要将时间换算成[0, 1]的范围,0代表起点,1代表终点,y轴代表的值同理。

因此,我们的起点和终点分别是(0,0)和(1,1)点。

(注意:虽然x和y的范围都是0到1,看起来是个正方形,但它们的单位或表达的含义不同,切勿混淆)。起点和终点是固定不变的,中间的曲线可以随意绘制。那么,如何将其写成一个缓动函数呢?

我们先看看x轴代表什么。x是一个取值范围从0到1的变量。我们的缓动函数有什么变量呢?只有currentTime,但currentTime的取值范围是从[0, duration]。因此,我们需要将其映射成[0, 1],实际上就是将currentTime除以duration,然后用currentTime/duration代替x。

那y呢?y是根据x计算出的值,代表当前时间点对应的值,即我们缓动函数的value值。它的取值范围在[startValue, startValue + byValue]之间,因此也需要将其变成[0, 1]。所以,value的值变为这样(value - startValue) / byValue。现在y值也有了,我们可以将它们直接代入y = x * x这个初始公式,如下所示:

① y = x * x 👇🏻 代入 x、y ② (value - startValue) / byValue = (currentTime / duration) * (currentTime / duration) 👇🏻 整理一下 ③ value = (currentTime / duration) * (currentTime / duration) * byValue + startValue 👇🏻 简化一下(简化英文单词而已😂) ④ value = (t, b, c, d) => ((t/d) * (t/d) * c + b)

这个效果其实就是easeInQuad先慢后快的缓入效果。其他函数的推导方式相同,只要你能写出公式。不过,即便知道如何推导,也很难有直观的效果。其实常见和常用的就那么几个,网上也有大量封装好的和演示,有个印象即可(例如可以搜索Tween.js)。

当然,你也可以通过函数图像简单猜测效果。具体方法是看每一点的斜率:斜率越趋近于水平,运动越慢;斜率越趋近于竖直,运动越快。如果你的函数曲线中有y值超出了1,说明中间点在某一时刻会超过终点;如果有y值小于0,说明有中间点在某一时刻会小于起始点。大致如此。

缓动函数有一个显著特点:起点和终点位置是确定的,中间位置你可以随意计算,可快可慢,可以超出终点,也可以小于起点。具体效果如何,你可以随便写个方程运行试试,然后再根据效果调试。相信你肯定见过下面这种类型的图:

前言

现在再看看,是否感到稍微亲切一点点?🥳

小结

本章主要讲解了canvas中动画的实现,其中最重要的一点是如何在不同帧率下实现相同的动画效果,即以时间为维度进行度量。用canvas制作游戏也是如此,时间每向前tick一次(滴答的意思,形象的说法,带有古老时钟的感觉),画布就会向前推进一次(重绘)。

此外,补充两个小点:

  • 通常情况下,动画的发生伴随着画布的重绘,但默认情况下fabric.js并不会自动帮我们重绘,需要手动调用(可查看开篇代码中onChange的回调写法)。这是因为如果画布中有很多物体在运动,默认自动重绘会降低性能。
  • 动画不仅可作用于位置,还可作用于各种属性,如透明度、颜色等。实际上,只要是数值,就可以进行动画。此外,得益于我们之前将数据和视图分离的架构,本章所做的一切仅仅是改变数据,不涉及画布绘制的内容。

这里是简版fabric.js的代码。

以上就是JS前端可视化canvas动画原理及其推导实现的详细内容,更多关于JS前端可视化canvas动画的资料请关注本站其它相关文章!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章