实现一个 JS 动画模块

Clloz · · 1,244次浏览 ·

前言

前端的动画可以用 CSS 来实现,但是如果我们希望管理多个元素的动画进行,支持暂停和继续。那么我们可以用 JS 来实现。

功能分析

CSS 实现动画是用 keyframe 定义关键帧,然后用 animation 属性对关键帧的过渡进行配置,其中比较常用的几个属性是 animation-na'me, animation-delay, animation-duration, animation-timing-function(详细内容参考使用 CSS 动画 – MDN)。

使用 JavaScript 来实现动画就是根据时间计算出对应时间点的元素样式。用 JavaScript 的好处是我们能够将动画的逻辑抽象出来,能够同时管理各种需要进行动画元素,并且我们能够对元素的动画进行更精确的控制,精确到帧。

从分析中我们可以得出,我们用 JavaScript 实现动画的核心就是对时间的把控,我们要明确每一帧元素应该处于什么样的状态。浏览器中一帧是 16ms (一秒钟 60 帧),我们要实现对每一帧的控制可以使用的几个方法是 setIntervalsetTimeoutrequestAnimationFrame。本文我们用 requestAnimationFrame 来实现。requestAnimationFrameAPI 参考 window.requestAnimationRequest

动画类

首先要设计一个动画类,这个动画类主要是对元素和动画属性的对象进行初始化,同时根据时间计算当前元素的样式。代码如下:

class Animation {
    constructor(obj, prop, startVal, endVal, duration, delay, timeFunc, template) {
        this.obj = obj;
        this.prop = prop;
        this.startVal = startVal;
        this.endVal = endVal;
        this.duration = duration;
        this.delay = delay;
        this.timeFunc = timeFunc;
        this.template = template;
    }
    trans(time) {
        console.log(time);
        let range = this.endVal - this.startVal;
        let progress = this.timeFunc(time / this.duration);
        this.obj[this.prop] = this.template(this.startVal + range * progress);
    }
}

参数中的 obj 是表示要应用动画的元素,prop 是要进行动画的属性,timeFunc 是和 css 中的 animation-timing-function 类似,template 是为了应对不同的 CSS 属性的不同格式,比如 transform 属性。

timeline类

我们要对动画实现 startpauseresume 等功能,需要一个 timeline 对时间进行管理。我们的 Animation 是根据时间计算样式的,这个时间是一个相对时间。比如 pause 功能,我们可以在用户点击 pause 按钮后记录时间,然后在用户点击 resume 按钮后计算出暂停的时间,在返回给 Animation.trans() 方法的时间中减去这个暂停的时间就能够让元素继续暂停之前的状态进行动画。

对于不同的元素的动画我们用 Set 进行管理,当有新的需要动画的元素加入时我们将 newAnimation 对象存入 Set,当动画完成在从 Set 中删除。

整个的逻辑还是比较简单的,直接看代码吧.

const TICK = Symbol('tick');
const TICK_HANDLER = Symbol('tick handler');
const ANIMATIONS = Symbol('animations');
const MOVETIME = Symbol('movetime');
const PAUSE_START = Symbol('pause start');
const PAUSE_TIME = Symbol('pause time');

export class Timeline {
    constructor() {
        this.state = 'inited';
        this[ANIMATIONS] = new Set();
        this[MOVETIME] = new Map();
    }
    start() {
        if (this.state !== 'inited') return;
        this.state = 'started';
        let startTime = Date.now();
        this[PAUSE_TIME] = 0;
        console.log(startTime);
        this[TICK] = () => {
            let now = Date.now();
            for (let animation of this[ANIMATIONS]) {
                let t;
                if (this[MOVETIME].get(animation) < startTime) {
                    t = now - startTime - this[PAUSE_TIME] - animation.delay;
                } else {
                    t = now - this[MOVETIME].get(animation) - this[PAUSE_TIME] - animation.delay;
                }
                if (t > animation.duration) {
                    this[ANIMATIONS].delete(animation);
                    t = animation.duration;
                }
                if (t > 0) animation.trans(t);
            }
            this[TICK_HANDLER] = requestAnimationFrame(this[TICK]);
        };
        this[TICK]();
    }
    pause() {
        if (this.state !== 'started') return;
        this.state = 'paused';
        this[PAUSE_START] = Date.now();
        cancelAnimationFrame(this[TICK_HANDLER]);
    }
    resume() {
        if (this.state !== 'paused') return;
        this.state = 'started';
        this[PAUSE_TIME] += Date.now() - this[PAUSE_START];
        this[TICK]();
    }
    reset() {
        this.pause();
        this.state = 'inited';
        // let startTime = Date.now();
        this[ANIMATIONS] = new Set();
        this[MOVETIME] = new Map();
        this[TICK_HANDLER] = null;
        this[PAUSE_START] = 0;
    }
    add(animation, startTime) {
        if (arguments.length < 2) {
            startTime = Date.now();
        }
        this[ANIMATIONS].add(animation);
        this[MOVETIME].set(animation, startTime);
    }
}

一些模块中私有的属性,我用 Symbol 来生成,这样在模块文件外这些属性是不会被访问到的(目前 ES6 的静态属性支持还不好)。对于 delay 的处理其实和 pause 的逻辑也一样,我们记录 start 开始的时间,只有等到时间超过 delay 才会调用。注意我们的时间计算是从 start 开始一直到结束的,所以每次的 pause 的时间都需要累加到 pause time 中。

时间的前进我们利用 requestAnimationFrame 的回调函数来递归调用我们封装的函数实现。

最后我们可以为动画加上贝塞尔曲线的支持。

export function cubicBezier(p1x, p1y, p2x, p2y) {
    const ZERO_LIMIT = 1e-6;
    const ax = 3 * p1x - 3 * p2x + 1;
    const bx = 3 * p2x - 6 * p1x;
    const cx = 3 * p1x;

    const ay = 3 * p1y - 3 * p2y + 1;
    const by = 3 * p2y - 6 * p1y;
    const cy = 3 * p1y;
    function sampleCurveDerivativeX(t) {
        return (3 * ax * t + 2 * bx) * t + cx;
    }
    function sampleCurveX(t) {
        return ((ax * t + bx) * t + cx) * t;
    }
    function sampleCurveY(t) {
        return ((ay * t + by) * t + cy) * t;
    }
    function solveCurveX(x) {
        var t2 = x;
        var derivative;
        var x2;
        for (let i = 0; i < 8; i++) {
            x2 = sampleCurveX(t2) - x;
            if (Math.abs(x2) < ZERO_LIMIT) {
                return t2;
            }
            derivative = sampleCurveDerivativeX(t2);
            if (Math.abs(derivative) < ZERO_LIMIT) {
                break;
            }
            t2 -= x2 / derivative;
        }
        var t1 = 1;
        var t0 = 0;
        t2 = x;
        while (t1 > t0) {
            x2 = sampleCurveX(t2) - x;
            if (Math.abs(x2) < ZERO_LIMIT) {
                return t2;
            }
            if (x2 > 0) {
                t1 = t2;
            } else {
                t0 = t2;
            }
            t2 = (t1 + t0) / 2;
        }
        return t2;
    }
    function solve(x) {
        return sampleCurveY(solveCurveX(x));
    }
    return solve;
}

export let ease = cubicBezier(0.25, 0.1, 0.25, 1);
export let easeIn = cubicBezier(0.42, 0, 1, 1);
export let easeOut = cubicBezier(0, 0, 0.58, 1);
export let easeInOut = cubicBezier(0.42, 0, 0.58, 1);

效果查看:效果Demo

代码地址:Github


Clloz

人生をやり直す

发表评论

电子邮件地址不会被公开。 必填项已用*标注

我不是机器人*

 

00:00/00:00