写点什么

用 JavaScript 实现时间轴与动画 - 前端组件化

用户头像
三钻
关注
发布于: 2021 年 04 月 05 日
用 JavaScript 实现时间轴与动画 - 前端组件化

上一篇文章《用 JSX 实现 Carousel 轮播组件》中,我们实现了一个 “基础” 的轮播组件。为什么我们叫它 “基础” 呢?因为其实它看起来已经可以满足我们轮播组件的功能,但是其实它还有很多缺陷我们是没有去完善的。


虽然我们已经在里面实现了两个功能,一是可以自动轮播,二是可以手势拖拽。但是其实它离一个真正意义上的可用程度还是有很远的距离的。


首先我们的自动轮播和拖拽是无法无缝连接的,也就是说当我们拖拽结束后,我们的轮播应该继续自动轮播的。这一点我们是还没有实现的。我们的拖拽本身也是有细节上的问题的,比方说它目前只支持鼠标的拖拽事件,并不支持触屏的拖拽,这个也是我们在网页研发过程中必须要去面对的问题。


第二我们动画是使用 CSS Animation 实现的,也不具备任何的自定义和相应变化的。


所以接下来我们来一起实现我们的动画库,但是实现动画库之前,我们需要拥有一个动画库中的时间轴库。这篇文章我们先来看看怎么去实现一个时间轴类,和一个基础的动画类来使用这个时间轴。



代码整理

首先我们发现之前写的 Carousel 组件的代码已经很复杂了,所以我们需要封装一下它,这里我们就把它独立放入一个 JavaScript 文件中。


在项目根目录,建立一个 carousel.js,然后把我们 main.js 中 carousel 组件相关的代码都移动到 carousel.js 中。


carousel.js 中只需要 import Component 即可,然后给我们的 Carousel 类加上 export。代码结构如下:


import { Component } from './framework.js';
export class Carousel extends Component {/** Carousel 里面的代码 */}
复制代码


最后我们在 main.js 中重新 import Carousel 组件即可。


import { Component, createElement } from './framework.js';import { Carousel } from './carousel.js';
let gallery = [ 'https://source.unsplash.com/Y8lCoTRgHPE/1600x900', 'https://source.unsplash.com/v7daTKlZzaw/1600x900', 'https://source.unsplash.com/DlkF4-dbCOU/1600x900', 'https://source.unsplash.com/8SQ6xjkxkCo/1600x900',];
let a = <Carousel src={gallery} />;
// document.body.appendChild(a);a.mountTo(document.body);
复制代码




整理好我们的代码,就可以开始写我们的时间轴库了。这个时间轴是我们动画库中的一部分,所以我们统一放入我们动画库的 JavaScript 文件中: animation.js


我们是需要用这个时间轴去实现我们后续的动画库的,而动画中就有一个非常关键的概念,就是 “


最基础的动画能力,就是每帧执行了一个事件。



JavaScript 中的 “帧”

因为我们需要有“帧”才能实现我们的动画,所以我们需要先去了解 JavaScript 中的几种处理帧的方案。


人眼能够识别的动画的一个最高频率就是 60 帧


有的同学可能有看过李安导演的电影。比如,《比利·林恩的中场战事》就是全世界第一个 120 帧拍摄和 120 帧播放的电影。


也是因为帧率翻倍,所以很多地方就会感到很丝滑。但是一般我们的游戏,包括我们的显示器,它们支持的都是 60 帧。虽然我们看显示器的设置中,可能会有 70、80 帧,但是一般软件都会与 60 帧对其。


如果我们 1000 毫秒(一秒)里面需要 60 帧的话,那是多少毫秒是一帧呢?也就是 ,所以 16 毫秒大概就是一帧的时间。


这个就是为什么我们一般都会用 16 毫秒作为一帧的时长。



实现“帧”的方法

接下来我们来分析一下,有哪些方法可以在 JavaScript 中实现 “帧”。

1. setInterval

第一种就是 setInterval,这个其实我们在写轮播图的时候就用过。让一个逻辑在每一帧中执行,就是这样的:


setInterval(() => {/** 一帧要发生的事情 */}, 16)
复制代码


这里设置的时间隔,就是 16 毫秒,一帧的时长。

2. setTimeout

我们也是可以使用 setTimeout 这个去重复处理一帧中的事件。但是因为 setTimeout 是只执行一次的。所以我们需要给它一个函数名,方便我们后面重复调用它。


一般这种用来作为动画中的一帧的 setTimeout,都会命名为 tick。因为 tick 在英文中,就是我们时钟秒针走了一秒时发出来的声音,后面也用这个声音作为一个单词,来表达走了一帧/一秒。


我们的使用方式就是定义一个 tick 函数,让它执行一个逻辑/事件。然后使用 setTimeout 来加入一个延迟 16 毫秒后再执行一次自己。


let tick = () => {  /** 我们的逻辑/事件 */    setTimout(tick, 16);}
复制代码

3. requestAnimationFrame

最后现代浏览器支持了一个 requrestAnimationFrame(也叫 RAF)。这是在写动画时比较常用,它不需要去定义一帧的时长。


当我们申请浏览器执行下一帧的时候,就会执行传入 RAF 的 callback 函数。并且这个函数执行的时间是与浏览器的帧率是相关的。


所以,如果我们要做一些浏览器的降帧、降频的操作时,那么 RAF 就可以跟着浏览的帧率一起下降。


使用也是非常简单:


let tick = () => {  requestAnimationFrame(tick);}
复制代码




所以,一般最常用的就是这三种方案。如果我们的用户大部分都是使用现代浏览器的话,就推荐使用 requestAnimationFrame


“为什么不用 setInterval 呢”?因为 setInterval 比较不可控,浏览器到底会不会按照我们设置的 16 毫秒去执行呢?这个就不好说了。


还有一个就是,一旦我们这个 tick 写的不好,setInterval 就有可能发生积压。因为它是固定 16 毫秒循环执行的,所以 interval 之间是不会管上一个 interval 中的代码是否已经执行完,第二个 interval 的代码就会进入 interval 的队列。这个也是取决于浏览器的底层实现,每一个浏览器有可能选择不同的策略。


因为我们这里实现的动画库,不需要考虑到旧浏览器的兼容性。我们这里就选择使用 requestAnimationFrame。


接下来的时间轴库中,我们就会使用 requestAnimationFrame 来做一个自重复的操作。


这里还要提到一个和 requestAnimationFrame 对应的一个 cancelAnimationFrame。如果我们声明一个变量来储存 requestAnimationFrame,我们就可以传入这个变量到 cancelAnimationFrame 让这个动画停止。


let tick = () => {  let handler = requestAnimationFrame(tick);    cancelAnimationFrame(handler);}
复制代码


这样我们就可以避免一些资源的浪费。



实现 Timeline 时间轴

开头我们讲过,在做动画的时候,我们就需要把 tick 这个东西给包装成一个 Timeline


接下来我们就来一起实现这个 Timeline(时间轴) 类。正常来讲,我们一个 Timeline 只要 start(开始)就可以了,并不会有一个 stop(停止)的状态。因为一个时间轴,肯定是会一直播放到结束的,并没有中间停止这样的状态。


不过它是会有 pause(暂停) 和 resume(恢复)这种组合。而这一组状态也是 Timeline 中非常重要的功能。比如,我们写了一大堆的动画,我们就需要把它们都放到同一个动画 Timeline 里面去执行,而在执行的过程中,我可以让所有这些动画暂停和恢复播放。


另外就是这个 rate(播放速率),不过这个不是所有的时间线都会提供。rate 会有两种方法,一个是 set、一个是 get。因为播放的速率是会有一个倍数的,我们可以让动画快进、慢放都是可以的。


在设计这个动画库时,还有一个非常重要的概念,叫 reset(重启)。这个会把整个时间轴清理干净,这样我们就可以去复用一些时间线。


这个教程中实现的 set 和 get 的 rate 就不做了,因为这个是比较高级的时间线功能。如果我们要做这个就要讲很多相关的知识。但是 pauseresume 对于我们的 carousel(轮播图)是至关重要的,所以这里我们是一定要实现的。


讲了那么多,我们赶紧开工吧!~

实现 start 函数

在我们的 start 方法中,就会有一个启动 tick 的过程。这里我们会选择把这个 tick 变成一个私有的方法(把它藏起来)。不然的话,这个 tick 谁都可以调用,这样很容易就会被外部的使用者破坏掉整个 Timeline 类的状态体系。


那么我们怎么才能把 tick 完美的藏起来呢?我们会在 animation.js 这个文件的全局域中声明一个常量叫 TICK。并且用 Symbol 来创建一个 tick。这样除了在 animation.js 当中可以获取到我们的 tick 之外,其他任何地方都是无法获得 tick 这个 Symbol 的。


同理 tick 中的 requestAnimationFrame 也同样可以创建一个全局变量 TICK_HANDLER 来储存。这个变量也会使用一个 Symbol 来包裹起来,这样就可以限定只能在本文件中使用。


对 Symbol 不是很熟悉的同学,其实我们可以理解它为一种 “特殊字符”。就算我们把两个传入 Symbol 的 key 都叫 'tick',创建出来的两个值都会是不一样的。这个就是 Symbol 的一个特性。


其实我们之前的《前端进阶》的文章中也有详细讲过和使用过 Symbol。比如,我们使用过 Symbol 来代表 EOF(End Of File)文件结束符号。所以它作为对象的一个 key 并不是唯一的用法,Symbol 这种具有唯一特性,是它存在的一个意义。


有了这两个常量,我们就可以在 Timeline 类的构造函数中初始化 tick。


初始化好 Tick 我们就可以在 start 函数中直接调用全局中的 TICK。这样我们 Timeline(时间线)中的时间就开始以 60 帧的播放率开始运行。


最后代码就是如下:


const TICK = Symbol('tick');const TICK_HANDLER = Symbol('tick-handler');
export class Timeline { constructor() { this[TICK] = () => { console.log('tick'); requestAnimationFrame(this[TICK]); }; } start() { this[TICK](); } pause() {} resume() {} reset() {}}
复制代码


完成到这一部分,我们就可以把这个 Timeline 类引入我们的 main.js 里面试试。


import { Timeline } from './animation.js';
let tl = new Timeline();
tl.start();
复制代码



Build 一下我们的代码,然后在浏览器运行,这时候就可以看到在 console 中,我们的 tick 是正常在运行了。这说明我们 Timeline 目前的逻辑是写对了。


到这里,我们实现了一个非常基本的时间线的操作。接下来我们来实现一个简单的 Animation(动画)类来测试我们的时间轴。

实现 Animation 类

接下来我们给 Tick 添加一个 animation(动画),并且执行这个动画。


我们做的这个时间轴,最终是需要用在我们的 Carousel(轮播图)的动画上的。而轮播图上的动画,我们称它为 “属性动画”


因为我们是把一个对象的某一个属性,从一个值变成量外一个值。


与属性动画相对的还有帧动画,也就是每一秒都来一张图片。讲到帧动画,我们应该都知道 “宫崎骏” 老师的动画,比如,经典的《龙猫》、《天空之城》等等。这些动画都是 “宫崎骏” 老师一张一张图画出来的,然后每一帧播放一张图片,在一个快速播放的过程,就会让我们看到图中的人和物在动了。比动漫时代更早的时候也已经有动画了,也就是我们古人所说的走马灯。


上面说到的动画,都不是通过属性来做的。但是我们在浏览器里面做的,大部分都是属性的动画。每个动画都会有一个初始属性值和终止属性值。


了解完动画的理论后,我们就可以开始实现这部分的逻辑。首先我们 Animation(动画)这部分的逻辑和 Timeline 也是相对独立的,所以这里我们可以把 Animation 单独封装成一个类。(我们后面的前端组件化的文章中还会再次强化动画库的功能。


export class Animation {  constructor() {}}
复制代码


首先创建一个 Animation(动画)我们需要以下参数:


  • object:被赋予动画的元素对象

  • property:被赋予动画变动的属性

  • startValue:动画起始值

  • endValue:动画终止值

  • duration:动画时长

  • timingFunction:动画与时间的曲线


这里我们需要注意的是,传入的 property(属性)一般来说都是带有一个单位的,比如:px(像素)。因为我们的 startValueendValue 一定是一个 JavaScript 里面的一个数值。那么如果我们想要一个完整的 Animation,我们还需要传入更多的参数。


但是这里我们就先不往后加,先实现一个简单的 Animation 。


初始化我们的 Animation 对象时,我们是需要把所有传入的参数都存储到这个对象的属性中,所以在 constructor 这里我们就要把所有传入的参数的原封不动的抄写一遍。


export class Animation {  constructor(object, property, startValue, endValue, duration, timingFunction) {    this.object = object;    this.property = property;    this.startValue = startValue;    this.endValue = endValue;    this.duration = duration;    this.timingFunction = timingFunction;  }}
复制代码


接下来我们需要一个执行 animation(动画)的函数,我们叫它为 exec、go 都是可以的,这里我们就用 run(运行)这个单词。个人觉得更加贴切这个函数的作用。


这个函数是需要接收一个 time(时间)参数,而这个是一个虚拟时间。如果我们用真实的时间其实我们根本不需要做一个 Timeline(时间轴)了。


有了这个时间,我们就可以根据这个时间计算当前动画的属性应该变化多少。要计算这个属性的变化,我们首先需要知道动画初始值到终止值的总变化区间。


公式:变化区间(range) = 终止值(endValue) - 初始值(startValue)


得到了 变换区间 后,我们就可以计算出每一帧这个动画要变化多少,这个公式就是这样的:


变化值 = 变化区间值(range) * 时间(time) / 动画时长(duration)


这里得到的变化值,会根据当前已经执行的时间与动画的总时长算出一个 progression(进度 %),然后用这个进度的百分比与变化区间,算出我们初始值到达当前进度的值的差值。这个差值就是我们的 变化值


这个变化值,就相等于我们 CSS animation 中的 linear 动画曲线。这动画曲线就是一条直线。这里我们先用这个实现我们的 Animation 类,就先不去处理我们的 timingFunction,后面我们再去处理这个动态的动画曲线。


有了这个变化值,我们就可以用 startValue(初始值)+ 变化值,得到当前进度对应的属性值。我们的代码就是这样实现的:


run(time) {    let range = this.endValue - this.startValue;    this.object[this.property] = this.startValue + (range * time) / this.duration;}
复制代码


这样 Animation 就可以运作的了。接下来我们把这个 Animation 添加到 Timeline 的 animation 队列里面,让它在队列中被执行。


我们上面说到,这个 Animation 中的 run 方法接收的 time(时间)是一个虚拟的时间。所以在 Timeline 中调用这个 run 方法的时候就要把一个虚拟时间传给 Animation,这样我们的动画就可以运作了。


好,这里我们要添加 animation 到 timeline 里面,首先我们就要有一个 animations 队列。这个我们就直接生成一个 animations Set。


这个与其他 Timeline 中的储存方式一样,我们建立一个全局的 ANIMATIONS 常量来储存,它的值就用 Symbol 包裹起来。这样就可以避免这个队列不小心被外部调用到了。


const ANIMATIONS = Symbol('animations');
复制代码


这个队列还需要在 Timeline 类构造的时候,就赋值一个空的 Set。


constructor() {  this[ANIMATIONS] = new Set();}
复制代码


有队列,那么我们必然就需要有一个加入队列的方法,所以我们在 Timeline 类中还要加入一个 add() 方法。实现逻辑如下:


add(animation) {  this[ANIMATIONS].add(animation);}
复制代码


我们要在 Timeline 中给 Animation 的 run 传一个当前已经执行了的时长。要计算这个时长的话,就要在 Timeline 开始的时候就记录好一个开始时间。然后每一个动画被触发的时候,用 当前时间 - Timeline 开始时间 才能获得当前已经运行了多久。


但是之前的 tick 是写在了 constructor 里面,Timeline 开始时间必然是放在 start 方法之中,所以为了能够更方便的可以获得这个时间,我们可以直接把 tick 声明放到 start 里面。


虽然说这个改动会让我们每次 Timeline 启动的时候,都会重新构建一个 tick 对象函数。但是这种方法会更便于快速实现这个功能,不过想要性能更好的同学也是可以优化这一个地方的。


移动完我们 tick 之后,我们就可以在 tick 里面加入调用 ANIMATIONS 队列的 animation(动画)了。因为一个 Timeline 里面可以有多个 animation,并且每一帧都会推动他们到下一个进度的属性状态。所以这里我们就用一个循环,然后调用一遍我们 ANIMATIONS 队列里面的所有的 animation 的 run 方法。


最后我们的代码就是这样的:


const TICK = Symbol('tick');const TICK_HANDLER = Symbol('tick-handler');const ANIMATIONS = Symbol('animations');
export class Timeline { constructor() { this[ANIMATIONS] = new Set(); } start() { let startTime = Date.now(); this[TICK] = () => { let t = Date.now() - startTime; for (let animation of this[ANIMATIONS]) { animation.run(t); } requestAnimationFrame(this[TICK]); }; this[TICK](); } pause() {} resume() {} reset() {} add(animation) { this[ANIMATIONS].add(animation); }}
export class Animation { constructor(object, property, startValue, endValue, duration, timingFunction) { this.object = object; this.property = property; this.startValue = startValue; this.endValue = endValue; this.duration = duration; this.timingFunction = timingFunction; }
run(time) { console.log(time); let range = this.endValue - this.startValue; this.object[this.property] = this.startValue + (range * time) / this.duration; }}
复制代码


我们在 animation 的 run 方法中,加入一个 console.log(time),方便我们调试。


最后我们在 main.js 中,把 animation 加到我们的 Timeline 中。


import { Component, createElement } from './framework.js';import { Carousel } from './carousel.js';import { Timeline, Animation } from './animation.js';
let gallery = [ 'https://source.unsplash.com/Y8lCoTRgHPE/1600x900', 'https://source.unsplash.com/v7daTKlZzaw/1600x900', 'https://source.unsplash.com/DlkF4-dbCOU/1600x900', 'https://source.unsplash.com/8SQ6xjkxkCo/1600x900',];
let a = <Carousel src={gallery} />;
// document.body.appendChild(a);a.mountTo(document.body);
let tl = new Timeline();// tl.add(new Animation({}, 'property', 0, 100, 1000, null));
tl.start();
复制代码



我们发现 Animation 确实可以运作了,时间也可以获得了。但是也发现了一个问题,Animation 一直在播放没有停止。


那么我们就要给它加入一个终止条件。我们这个条件判断应该放在执行 animation.run 之前,如果当前的时间已经超过了动画的时长。这个时候我们就需要停止执行动画了。


首先我们需要改造 start 函数中的 animation 循环调用,在执行 animation.run 之前加入一个条件判断。这里我们需要判断如果当前时间是否已经大于 animation 中的 duration 动画时长。如果成立动画就可以停止执行了,并且需要把这个 animation 移除 ANIMATIONS 队列。


export class Timeline {  constructor() {    this[ANIMATIONS] = new Set();  }  start() {    let startTime = Date.now();    this[TICK] = () => {      let t = Date.now() - startTime;      for (let animation of this[ANIMATIONS]) {        if (t > animation.duration) {          this[ANIMATIONS].delete(animation);        }        animation.run(t);      }      requestAnimationFrame(this[TICK]);    };    this[TICK]();  }  pause() {}  resume() {}  reset() {}  add(animation) {    this[ANIMATIONS].add(animation);  }}
复制代码


就这样我们就加入了停止条件了,并没有什么复杂的逻辑。最后我们在 main.js 中,改一下 Animation 的第一个参数。在传入的对象中加入一个 setter,这样我们就可以让我们的 animation 打印出时间。这样方便我们调试。


tl.add(  new Animation(    {      set a(a) {        console.log(a);      },    },    'property',    0,    100,    1000,    null  ));
复制代码



我们看到动画确实是停止了,但是还是有一个问题。我们设置的 duration 动画时长是到 1000 毫秒,但是这里最后一个是 1002,明显超出了我们的动画时长。


所以我们是需要在遇到动画结束条件的时候,需要给 animation 传入它的 duration(动画时长的值)。这里我们就应该这样写:


start() {    let startTime = Date.now();    this[TICK] = () => {      let t = Date.now() - startTime;      for (let animation of this[ANIMATIONS]) {        let t0 = t;        if (t > animation.duration) {          this[ANIMATIONS].delete(animation);          t0 = animation.duration;        }        animation.run(t0);      }      requestAnimationFrame(this[TICK]);    };    this[TICK]();  }  pause() {}  resume() {}  reset() {}  add(animation) {    this[ANIMATIONS].add(animation);  }}
复制代码



这样我们初步的 Timeline 和 Animation 的能力就建立起来了。



设计时间线的更新

接下来我们就给这个 Timeline 加入更多的功能,让我们 Animation 这个库变成真正的可用 。


在 CSS Animation 动画中,我们知道它有一个 duration(动画时长),其实同时还会有一个 delay(动画延迟时间)。


那么首先我们先来尝试添加这个功能。

添加 Delay 属性支持

在开发当中,当我们要去给原有的库添加功能。我们首先要考虑的是 “找到一个合理的地方去添加这个功能”。


其实直观的来说,我们第一感觉是会想把这个 delay 放入 Animation 类当中,毕竟这个功能属于动画的一部分。但是这里有一个更好的思路,就是把 delay 放到 Timeline 里面。


我们可以这么理解,一个动画的开始时间、终止时间、时间的控制,都是 Timeline 时间轴的相关事务,其实与 Animation 关注的是有区别的。而 Animation 我觉得更多是关注动画的效果,运行等事务。


所以 delay 放在 Timeline 显然是更加合适的。


在 Timeline 的 add() 方法中,添加 animation 到队列的时候,给它添加一个 delay。


在添加 delay 这个逻辑的同时,我们还可以处理掉一个问题。就是当我们在添加 animation 动画到队列的时候,可能 Timeline 已经在执行了。这样其实我们加入动画的时候,我们动画的开始时间是不对的。


另外还有一个问题,就是在 start 方法中,我们的 t 开始时间和 t0 其实不一定一致的。因为我们的 startTime 是可以根据 delay 被手动定义的。所以这一个值也是需要我们重新去编写一下逻辑的。


好,那么在实现我们的 delay 功能的同时,我们就可以把这两个因素都涵盖进去。


首先我们来加入一个 delay 参数:


export class Animation {  constructor(object, property, startValue, endValue, duration, delay, timingFunction) {    this.object = object;    this.property = property;    this.startValue = startValue;    this.endValue = endValue;    this.duration = duration;    this.timingFunction = timingFunction;    this.delay = delay;  }
run(time) { console.log(time); let range = this.endValue - this.startValue; this.object[this.property] = this.startValue + (range * time) / this.duration; }}
复制代码


这里无非就是给 constructor 中,加入一个 delay 参数,并且存储到类的属性对象当中。


因为每一个加入 Timeline 队列的 Animation 动画都可能有不一样的 delay,也就是说有不一样的开始动画的时间。所以我们需要在 Timeline 类中的 constructor 下建立一个 START_TIMES 存储空间,把我们所有 Animation 对应的开始时间都存储起来。


// 顶部追加声明const START_TIMES = Symbol('start-times');
// Timeline 的 constructor 中初始化export class Timeline { constructor() { this[ANIMATIONS] = new Set(); this[START_TIMES] = new Map(); } //... 省略代码 ... }
复制代码


然后在 Timeline 加入动画的 add 方法中,把动画的开始时间加入到 START_TIMES 数据里面。如果使用者没有给 add 方法传入 startTime 参数,那么我们需要给它一个默认值为 Date.now()


add(animation, startTime) {  if (arguments.length < 2) startTime = Date.now();  this[ANIMATIONS].add(animation);  this[START_TIMES].set(animation, startTime);}
复制代码


接下来我们就可以去改造开始时间的逻辑:


  • 第一种情况: 如果我们动画的开始时间是小于,Timeline 的开始时间的,那么我们当前动画的时间进度就是 当前时间 - Timeline 开始时间

  • 第二种情况: 动画的开始时间大于 Timeline 的开始时间,那么当前动画的时间进度就是 当前时间 - 动画的开始时间


代码实现如下:


start() {  let startTime = Date.now();  this[TICK] = () => {    let now = Date.now();    for (let animation of this[ANIMATIONS]) {      let t;
if (this[START_TIMES].get(animation) < startTime) { t = now - startTime; } else { t = now - this[START_TIMES].get(animation); }
if (t > animation.duration) { this[ANIMATIONS].delete(animation); t = animation.duration; } animation.run(t); } requestAnimationFrame(this[TICK]); }; this[TICK]();}
复制代码


这样 Timline 就支持随时给它加入一个 animation 动画。为了方便我们测试这个新的功能,我们把 tlanimation 都挂载在 window 上。


这里我们就改动一下 main.js 中的代码:


let tl = new Timeline();
window.tl = tl;window.animation = new Animation( { set a(a) { console.log(a); }, }, 'property', 0, 100, 1000, null);
tl.start();
复制代码


我们重新 webpack 打包后,就可以在 console 里面执行以下命令来给 Timeline 加入一个动画:


tl.add(animation);
复制代码



好,这个就是 Timeline 更新的设计。但是写到这里,我们其实还没有去让 delay 这个参数的值去让动画被延迟。


其实这里无非就在 t 的计算中,最后减去 animation.delay 即可。


if (this[START_TIMES].get(animation) < startTime) {  t = now - startTime - animation.delay;} else {  t = now - this[START_TIMES].get(animation) - animation.delay;}
复制代码


但是我们需要注意一种特殊情况,如果我们 t - 延迟时间 得出的时间是小于 0 的话,那么代表我们的动画还没有到达需要执行的时间,只有 t > 0 才需要执行动画。所以最后在执行动画的逻辑上,加入一个判断。


if (t > 0) animation.run(t);
复制代码


那么接下来我们来尝试实现它的 pause(暂停) 和 resume(恢复) 的能力。



实现暂停和重启功能

首先我们来尝试加入暂停的功能。

实现 Pause

要给 Timeline 实现 Pause 的能力,首先我们就要把 tick 给 cancel 掉。也就是让我们 Timline 的时间停止,如果一个钟或者手表的秒针不再动了,那么时间自然就停止了。


要取消掉 tick ,首先我们要知道触发的这个 tick 在运作的是什么。毋庸置疑,就是我们的 requestAnimationFrame


还记得我们一开始声明的 TICK_HANDLER 吗?这个常量就是用来存储我们当前 tick 的事件的。


所以第一步就是用 TICK_HANDLER 来储存我们的 requestAnimationFrame。tick 的启动是在我们 Timeline 类中的 start 方法中启动的,所以这里我们需要改动 start 方法中的 requestAnimationFrame


start() {let startTime = Date.now();  this[TICK] = () => {    let now = Date.now();    for (let animation of this[ANIMATIONS]) {      let t;
if (this[START_TIMES].get(animation) < startTime) { t = now - startTime - animation.delay; } else { t = now - this[START_TIMES].get(animation) - animation.delay; }
if (t > animation.duration) { this[ANIMATIONS].delete(animation); t = animation.duration; } if (t > 0) animation.run(t); } this[TICK_HANDLER] = requestAnimationFrame(this[TICK]); }; this[TICK]();}
复制代码


然后我们在 pause() 方法中调用以下 cancelAnimationFrame


pause() {  cancelAnimationFrame(this[TICK_HANDLER]);}
复制代码


Pause(暂停) 还是比较简单的,但是 resume(重启)就比较复杂了。

实现 Resume

那么实现 resume 的第一步必然就是重新启动 tick。但是 tick 中的 t(动画开始时间)肯定是不对的,所以我们要想办法去处理 pause 当中的逻辑。


在实现 Resume 之前,我们需要弄一点 DOM 的东西来测试它。所以我们先建立一个新的 HTML,在里面建立一个 div 元素。


<!-- 新建立一个 animation.html (放在 dist 文件夹里面) -->
<style>.box { width: 100px; height: 100px; background-color: aqua;}</style>
<body> <div class="box"></div> <script src="./main.js"></script></body>
复制代码


然后我们也不用 main.js 了,另外建立一个 animation-demo.js 来实现我们的动画调用。这样我们就不需要和我们的 carousel 混搅在一起了。


// 在根目录建立一个 `animation-demo.js`import { Timeline, Animation } from './animation.js';
let tl = new Timeline();
tl.start();tl.add( new Animation( { set a(a) { console.log(a); }, }, 'property', 0, 100, 1000, null ));
复制代码


因为我们修改了我们页面使用的 js 入口文件。所以这里我们需要去 webpack.config.js 把 entry 改为 animation-demo.js


module.exports = {  entry: './animation-demo.js',  mode: 'development',  devServer: {    contentBase: './dist',  },  module: {    rules: [      {        test: /\.js$/,        use: {          loader: 'babel-loader',          options: {            presets: ['@babel/preset-env'],            plugins: [['@babel/plugin-transform-react-jsx', { pragma: 'createElement' }]],          },        },      },    ],  },};
复制代码



目前我们的 JavaScript 中是一个模拟的动画输出。接下来我们尝试给动画可以操纵一个元素的能力。


我们先给元素加一个 id="el",方便我们在脚本中获取到这个元素。


<div class="box" id="el"></div>
复制代码


然后我们就可以对这个原形进行动画的操作了。首先我们需要回到 animation-demo.js,把 Animation 实例化的第一个参数改为 document.querySelector('#el').style


然后第二个参数的属性就改为 "transform"。但是这里要注意,后面的开始时间和结束时间是无法用于 transform 这个属性的。


所以我们需要有一个转换的 template(模版),通过使用这个模版来转换时间成 transform 对应的值。


这里的 template 值就直接写成一个函数:


 v => `translate(${$v}px)`;
复制代码


最后我们的代码就是这样的:


tl.add(  new Animation(    document.querySelector('#el').style,    'transform',    0,    100,    1000,    0,    null,    v => `translate(${v}px)`  ));
复制代码


这部分调整好之后,我们需要去到 animation.js 中去做对应的调整。


首先是给 Animation 类的 constructor 加入 template 参数的接收。与其他属性一样,在 constructor 中只是做一个存储的操作。


然后在 Animation 中的 run 方法,在 this.object[this.property] 这里面的值就应该调用 template 方法来生成属性值。而不是之前那样直接赋值给某一个属性了。


export class Animation {  constructor(    object,     property,    startValue,    endValue,    duration,    delay,    timingFunction,    template  ) {    this.object = object;    this.property = property;    this.startValue = startValue;    this.endValue = endValue;    this.duration = duration;    this.timingFunction = timingFunction;    this.delay = delay;    this.template = template;  }
run(time) { let range = this.endValue - this.startValue; this.object[this.property] = this.template( this.startValue + (range * time) / this.duration ); }}
复制代码


最后效果如下:



我们发现,已经可以用我们的 Animation 库来控制元素的动画了。


首先我们来调整一下这些动画的参数,让开始到结束位置改为 0 到 500,然后动画的时间长改为 2000 毫秒。这样的设置,有利于我们调试后面的功能。


tl.add(  new Animation(    document.querySelector('#el').style,    'transform',    0,    500,    2000,    0,    null,    v => `translate(${v}px)`  ));
复制代码


好,接下来我们一起去加一个 Pause 按钮。


<body>  <div class="box" id="el"></div>  <button id="pause-btn">Pause</button>  <script src="./main.js"></script></body>
复制代码


然后我们回到 animation-demo.js 里面去绑定这个元素。并且让他执行我们 Timeline 中的 pause 方法。


document.querySelector('#pause-btn').addEventListener(    'click',    () => tl.pause());
复制代码



我们可以看到,现在 pause 功能是可以的了,但是我们应该怎么去让这个动画继续播下去呢?也就是要实现一个 resume 的功能。


在实现这个 resume 功能的逻辑之前,我们先用同样的方式建立一个 resume 的按钮。并且让这个按钮调用我们 Timeline 里面的 resume() 方法。


<!-- animation.html -->
<body> <div class="box" id="el"></div> <button id="pause-btn">Pause</button> <button id="resume-btn">Resume</button> <script src="./main.js"></script></body>
复制代码


// animation-demo.js 中加入 resume 按钮事件绑定。
document.querySelector('#resume-btn').addEventListener( 'click', () => tl.resume());
复制代码


根据我们上面讲到的逻辑,resume 最基本的理解,就是重新启动我们的 tick。那么我们就试试直接在 resume 方法中执行 this[TICK]() 会怎么样。


resume() {  this[TICK]();}
复制代码



在动画中,我们可以看到,如果我们直接在 resume 中执行 tick 的话,重新开始动画的盒子,并没有在原来暂停的位置开始继续播放动画。而是跳到了后面。


很显然,在我们点击 resume 的时候,我们的动画并没有记住我们暂停时候的位置。所以在我们动画暂停的同时,我们需要把 暂停的开始时间暂停时间给记录下来。


这两个变量因为是需要在 Animation 类中使用的,所以这里要把它们定义在全局作用域之中。那么我们就用 PAUSE_STARTPAUSE_TIME 两个常量来保存他们。


const PAUSE_START = Symbol('pause-start');const PAUSE_TIME = Symbol('pause-time');
复制代码


接下来就是在我们暂停的时候记录一下当时的时间:


pause() {  this[PAUSE_START] = Date.now();  cancelAnimationFrame(this[TICK_HANDLER]);}
复制代码


其实我们记录暂停的开始时间是为了什么呢?就是为了在我们继续播放动画的时候,知道我们当下距离开始暂停的时候的时间相差了多久。


刚刚我们在动画里看到的现象是什么?就是我们重新启动 tick 的时候,动画的开始时间使用了当前的时间。这里说到的 “当前” 时间,就是 Timeline 已经跑到了哪里。显然这个开始时间是不正确的。


如果我们在暂停的时候,记录了那一刻的时间。然后在点击 resume 的时候计算暂停开始到点击 resume 时的时长。这样我们就可以用 tick 中的 t(动画开始时间)- 暂停时长 = 当前动画应该继续播放的时间


使用这个算法,我们就可以让我们的动画,精确的在原来暂停的位置继续开始播放了。


接下来,我们来看看代码的逻辑怎么实现:


刚刚我们已将在暂停的时候加入到时间记录的逻辑里,接下来我们要记录一个暂停时长。在记录暂停时长之前,我们需要一个地方给这个值赋予一个初始值为 0 。


最好的地方就是在 Timeline 开始的时候就赋予这个默认值。我们的 PAUSE_TIME 有了初始值之后,我们在执行 resume 的时候,就可以用 Date.now() - PAUSE_START 就能得到暂停动画到现在的总时长。


这里有一个点,需要我们注意的。我们的动画可能会出现多次暂停,并且多次的续播。那么这样的话,如果我们每次都使用这个公式计算出新的暂停时长,然后覆盖 PAUSE_TIME 的值,其实是不正确的。


因为我们的 Timeline 一旦开启是不会停止的,时间一直都在流逝。如果我们每次都只是计算当前的暂停时长,回退的时间其实是不对的。而正确的方式是,每次暂停时都需要去叠加上一次暂停过的时长。这样最后回退的时间才是准确的。


所以我们赋值给 PAUSE_TIME 的时候是使用 +=,而不是覆盖赋值。


最后我们改造好的 Timeline 就是这样的:


export class Timeline {  constructor() {    this[ANIMATIONS] = new Set();    this[START_TIMES] = new Map();  }  start() {    let startTime = Date.now();    this[PAUSE_TIME] = 0;    this[TICK] = () => {      let now = Date.now();      for (let animation of this[ANIMATIONS]) {        let t;
if (this[START_TIMES].get(animation) < startTime) { t = now - startTime - animation.delay - this[PAUSE_TIME]; } else { t = now - this[START_TIMES].get(animation) - animation.delay - this[PAUSE_TIME]; }
if (t > animation.duration) { this[ANIMATIONS].delete(animation); t = animation.duration; } if (t > 0) animation.run(t); } this[TICK_HANDLER] = requestAnimationFrame(this[TICK]); }; this[TICK](); } pause() { this[PAUSE_START] = Date.now(); cancelAnimationFrame(this[TICK_HANDLER]); } resume() { this[PAUSE_TIME] += Date.now() - this[PAUSE_START]; this[TICK](); } reset() {} add(animation, startTime) { if (arguments.length < 2) startTime = Date.now(); this[ANIMATIONS].add(animation); this[START_TIMES].set(animation, startTime); }}
复制代码


我们运行一下代码看看是否正确:



这样我们就完成了 Pause 和 Resume 两个功能了。



这里我们就实现了一个可用的 Timeline 时间轴,下一篇文章我们重点去加强动画库的功能。


我是来自《技术银河》的三钻,一位正在重塑知识的技术人。下期再见。



开源项目推荐

Hexo Theme Aurora



最近博主在全面投入开发一个可以 “迈向未来的” Hexo 主题,以极光为主题的博客主题。


如果你是一个开发者,做一个个人博客也是你简历上的一个亮光点。而如果你有一个超级炫酷的博客,那就更加是亮上加亮了,简直就闪闪发光。


如果喜欢这个主题,可以在 Github 上给我点个 🌟 让彼此都发光吧~


主题 Github 地址:https://github.com/auroral-ui/hexo-theme-aurora

主题使用文档:https://aurora.tridiamond.tech/zh/





博主开始在 B 站直播学习,欢迎过来《直播间》一起学习。


我们在这里互相监督,互相鼓励,互相努力走上人生学习之路,让学习改变我们生活!


学习的路上,很枯燥,很寂寞,但是希望这样可以给我们彼此带来多一点陪伴,多一点鼓励。我们一起加油吧! (๑ •̀ㅂ•́)و




发布于: 2021 年 04 月 05 日阅读数: 100
用户头像

三钻

关注

微信公众号《技术银河》 2020.05.18 加入

起步于PHP,一入前端深似海,最后爱上了前端。Vue、React使用者。专于Web、移动端开发。特别关注产品和UI设计。专心、专注、专研,与同学们一起终身学习。

评论

发布
暂无评论
用 JavaScript 实现时间轴与动画 - 前端组件化