时间:2023-07-02 15:18:01 | 来源:网站运营
时间:2023-07-02 15:18:01 来源:网站运营
用 JavaScript 实现时间轴与动画 - 前端组件化:上一篇文章《用 JSX 实现 Carousel 轮播组件》中,我们实现了一个 “基础” 的轮播组件。为什么我们叫它 “基础” 呢?因为其实它看起来已经可以满足我们轮播组件的功能,但是其实它还有很多缺陷我们是没有去完善的。CSS Animation
实现的,也不具备任何的自定义和相应变化的。carousel.js
,然后把我们 main.js
中 carousel 组件相关的代码都移动到 carousel.js 中。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);
animation.js
。!! 最基础的动画能力,就是每帧执行了一个事件。
!! 这个就是为什么我们一般都会用 16 毫秒作为一帧的时长。
setInterval
,这个其实我们在写轮播图的时候就用过。让一个逻辑在每一帧中执行,就是这样的:setInterval(() => {/** 一帧要发生的事情 */}, 16)
这里设置的时间隔,就是 16 毫秒,一帧的时长。!! 一般这种用来作为动画中的一帧的 setTimeout,都会命名为 tick
。因为 tick 在英文中,就是我们时钟秒针走了一秒时发出来的声音,后面也用这个声音作为一个单词,来表达走了一帧/一秒。
我们的使用方式就是定义一个 tick 函数,让它执行一个逻辑/事件。然后使用 setTimeout 来加入一个延迟 16 毫秒后再执行一次自己。let tick = () => { /** 我们的逻辑/事件 */ setTimout(tick, 16);}
requrestAnimationFrame
(也叫 RAF)。这是在写动画时比较常用,它不需要去定义一帧的时长。let tick = () => { requestAnimationFrame(tick);}
requestAnimationFrame
。!! 因为我们这里实现的动画库,不需要考虑到旧浏览器的兼容性。我们这里就选择使用 requestAnimationFrame。接下来的时间轴库中,我们就会使用 requestAnimationFrame 来做一个自重复的操作。
let tick = () => { let handler = requestAnimationFrame(tick); cancelAnimationFrame(handler);}
这样我们就可以避免一些资源的浪费。tick
这个东西给包装成一个 Timeline
。start
(开始)就可以了,并不会有一个 stop
(停止)的状态。因为一个时间轴,肯定是会一直播放到结束的,并没有中间停止这样的状态。pause
(暂停) 和 resume
(恢复)这种组合。而这一组状态也是 Timeline 中非常重要的功能。比如,我们写了一大堆的动画,我们就需要把它们都放到同一个动画 Timeline 里面去执行,而在执行的过程中,我可以让所有这些动画暂停和恢复播放。rate
(播放速率),不过这个不是所有的时间线都会提供。rate 会有两种方法,一个是 set
、一个是 get
。因为播放的速率是会有一个倍数的,我们可以让动画快进、慢放都是可以的。reset
(重启)。这个会把整个时间轴清理干净,这样我们就可以去复用一些时间线。pause
和 resume
对于我们的 carousel(轮播图)是至关重要的,所以这里我们是一定要实现的。tick
的过程。这里我们会选择把这个 tick 变成一个私有的方法(把它藏起来)。不然的话,这个 tick 谁都可以调用,这样很容易就会被外部的使用者破坏掉整个 Timeline 类的状态体系。TICK
。并且用 Symbol
来创建一个 tick。这样除了在 animation.js 当中可以获取到我们的 tick 之外,其他任何地方都是无法获得 tick 这个 Symbol 的。TICK_HANDLER
来储存。这个变量也会使用一个 Symbol 来包裹起来,这样就可以限定只能在本文件中使用。!! 对 Symbol 不是很熟悉的同学,其实我们可以理解它为一种 “特殊字符”。就算我们把两个传入 Symbol 的 key 都叫 'tick',创建出来的两个值都会是不一样的。这个就是 Symbol 的一个特性。有了这两个常量,我们就可以在 Timeline 类的构造函数中初始化 tick。
!! 其实我们之前的《前端进阶》的文章中也有详细讲过和使用过 Symbol。比如,我们使用过 Symbol 来代表 EOF(End Of File)文件结束符号。所以它作为对象的一个 key 并不是唯一的用法,Symbol 这种具有唯一特性,是它存在的一个意义。
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 目前的逻辑是写对了。export class Animation { constructor() {}}
首先创建一个 Animation(动画)我们需要以下参数:object
:被赋予动画的元素对象property
:被赋予动画变动的属性startValue
:动画起始值endValue
:动画终止值duration
:动画时长timingFunction
:动画与时间的曲线px
(像素)。因为我们的 startValue
和 endValue
一定是一个 JavaScript 里面的一个数值。那么如果我们想要一个完整的 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; }}
接下来我们需要一个执行 animation(动画)的函数,我们叫它为 exec、go 都是可以的,这里我们就用 run
(运行)这个单词。个人觉得更加贴切这个函数的作用。time
(时间)参数,而这个是一个虚拟时间。如果我们用真实的时间其实我们根本不需要做一个 Timeline(时间轴)了。!! 公式:变化区间(range) = 终止值(endValue) - 初始值(startValue)
得到了 变换区间
后,我们就可以计算出每一帧这个动画要变化多少,这个公式就是这样的:!! 变化值 = 变化区间值(range) * 时间(time) / 动画时长(duration)
这里得到的变化值,会根据当前已经执行的时间与动画的总时长算出一个 progression
(进度 %),然后用这个进度的百分比与变化区间,算出我们初始值到达当前进度的值的差值。这个差值就是我们的 变化值
。linear
动画曲线。这动画曲线就是一条直线。这里我们先用这个实现我们的 Animation
类,就先不去处理我们的 timingFunction
,后面我们再去处理这个动态的动画曲线。run(time) { let range = this.endValue - this.startValue; this.object[this.property] = this.startValue + (range * time) / this.duration;}
这样 Animation 就可以运作的了。接下来我们把这个 Animation 添加到 Timeline 的 animation 队列里面,让它在队列中被执行。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 里面。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)
,方便我们调试。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 一直在播放没有停止。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,明显超出了我们的动画时长。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 的能力就建立起来了。add()
方法中,添加 animation 到队列的时候,给它添加一个 delay。t
开始时间和 t0
其实不一定一致的。因为我们的 startTime 是可以根据 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
参数,并且存储到类的属性对象当中。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 开始时间
当前时间 - 动画的开始时间
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]();}
这样 Timeline 就支持随时给它加入一个 animation 动画。为了方便我们测试这个新的功能,我们把 tl
和 animation
都挂载在 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(恢复) 的能力。requestAnimationFrame
。TICK_HANDLER
吗?这个常量就是用来存储我们当前 tick 的事件的。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(重启)就比较复杂了。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 对应的值。 v => `translate(${$v}px)`;
最后我们的代码就是这样的:tl.add( new Animation( document.querySelector('#el').style, 'transform', 0, 100, 1000, 0, null, v => `translate(${v}px)` ));
这部分调整好之后,我们需要去到 animation.js 中去做对应的调整。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 ); }}
最后效果如下: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()
方法。<!-- 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 的话,重新开始动画的盒子,并没有在原来暂停的位置开始继续播放动画。而是跳到了后面。暂停的开始时间
和暂停时间
给记录下来。PAUSE_START
和 PAUSE_TIME
两个常量来保存他们。const PAUSE_START = Symbol('pause-start');const PAUSE_TIME = Symbol('pause-time');
接下来就是在我们暂停的时候记录一下当时的时间:pause() { this[PAUSE_START] = Date.now(); cancelAnimationFrame(this[TICK_HANDLER]);}
其实我们记录暂停的开始时间是为了什么呢?就是为了在我们继续播放动画的时候,知道我们当下距离开始暂停的时候的时间相差了多久。t(动画开始时间)- 暂停时长 = 当前动画应该继续播放的时间
。Date.now() - PAUSE_START
就能得到暂停动画到现在的总时长。!! 这里有一个点,需要我们注意的。我们的动画可能会出现多次暂停,并且多次的续播。那么这样的话,如果我们每次都使用这个公式计算出新的暂停时长,然后覆盖 PAUSE_TIME 的值,其实是不正确的。所以我们赋值给 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); }}
我们运行一下代码看看是否正确:!! 我是来自《技术银河》的三钻,一位正在重塑知识的技术人。下期再见。
!!
主题 Github 地址:https://github.com/auroral-ui/hexo-theme-aurora
主题使用文档:https://aurora.tridiamond.tech/zh/
关键词:实现