時間:2023-07-02 15:18:01 | 來源:網(wǎng)站運營
時間:2023-07-02 15:18:01 來源:網(wǎng)站運營
用 JavaScript 實現(xiàn)時間軸與動畫 - 前端組件化:上一篇文章《用 JSX 實現(xiàn) Carousel 輪播組件》中,我們實現(xiàn)了一個 “基礎(chǔ)” 的輪播組件。為什么我們叫它 “基礎(chǔ)” 呢?因為其實它看起來已經(jīng)可以滿足我們輪播組件的功能,但是其實它還有很多缺陷我們是沒有去完善的。CSS Animation
實現(xiàn)的,也不具備任何的自定義和相應(yīng)變化的。carousel.js
,然后把我們 main.js
中 carousel 組件相關(guān)的代碼都移動到 carousel.js 中。Component
即可,然后給我們的 Carousel 類加上 export。代碼結(jié)構(gòu)如下: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
。!! 最基礎(chǔ)的動畫能力,就是每幀執(zhí)行了一個事件。
!! 這個就是為什么我們一般都會用 16 毫秒作為一幀的時長。
setInterval
,這個其實我們在寫輪播圖的時候就用過。讓一個邏輯在每一幀中執(zhí)行,就是這樣的:setInterval(() => {/** 一幀要發(fā)生的事情 */}, 16)
這里設(shè)置的時間隔,就是 16 毫秒,一幀的時長。!! 一般這種用來作為動畫中的一幀的 setTimeout,都會命名為 tick
。因為 tick 在英文中,就是我們時鐘秒針走了一秒時發(fā)出來的聲音,后面也用這個聲音作為一個單詞,來表達走了一幀/一秒。
我們的使用方式就是定義一個 tick 函數(shù),讓它執(zhí)行一個邏輯/事件。然后使用 setTimeout 來加入一個延遲 16 毫秒后再執(zhí)行一次自己。let tick = () => { /** 我們的邏輯/事件 */ setTimout(tick, 16);}
requrestAnimationFrame
(也叫 RAF)。這是在寫動畫時比較常用,它不需要去定義一幀的時長。let tick = () => { requestAnimationFrame(tick);}
requestAnimationFrame
。!! 因為我們這里實現(xiàn)的動畫庫,不需要考慮到舊瀏覽器的兼容性。我們這里就選擇使用 requestAnimationFrame。接下來的時間軸庫中,我們就會使用 requestAnimationFrame 來做一個自重復(fù)的操作。
let tick = () => { let handler = requestAnimationFrame(tick); cancelAnimationFrame(handler);}
這樣我們就可以避免一些資源的浪費。tick
這個東西給包裝成一個 Timeline
。start
(開始)就可以了,并不會有一個 stop
(停止)的狀態(tài)。因為一個時間軸,肯定是會一直播放到結(jié)束的,并沒有中間停止這樣的狀態(tài)。pause
(暫停) 和 resume
(恢復(fù))這種組合。而這一組狀態(tài)也是 Timeline 中非常重要的功能。比如,我們寫了一大堆的動畫,我們就需要把它們都放到同一個動畫 Timeline 里面去執(zhí)行,而在執(zhí)行的過程中,我可以讓所有這些動畫暫停和恢復(fù)播放。rate
(播放速率),不過這個不是所有的時間線都會提供。rate 會有兩種方法,一個是 set
、一個是 get
。因為播放的速率是會有一個倍數(shù)的,我們可以讓動畫快進、慢放都是可以的。reset
(重啟)。這個會把整個時間軸清理干凈,這樣我們就可以去復(fù)用一些時間線。pause
和 resume
對于我們的 carousel(輪播圖)是至關(guān)重要的,所以這里我們是一定要實現(xiàn)的。tick
的過程。這里我們會選擇把這個 tick 變成一個私有的方法(把它藏起來)。不然的話,這個 tick 誰都可以調(diào)用,這樣很容易就會被外部的使用者破壞掉整個 Timeline 類的狀態(tài)體系。TICK
。并且用 Symbol
來創(chuàng)建一個 tick。這樣除了在 animation.js 當(dāng)中可以獲取到我們的 tick 之外,其他任何地方都是無法獲得 tick 這個 Symbol 的。TICK_HANDLER
來儲存。這個變量也會使用一個 Symbol 來包裹起來,這樣就可以限定只能在本文件中使用。!! 對 Symbol 不是很熟悉的同學(xué),其實我們可以理解它為一種 “特殊字符”。就算我們把兩個傳入 Symbol 的 key 都叫 'tick',創(chuàng)建出來的兩個值都會是不一樣的。這個就是 Symbol 的一個特性。有了這兩個常量,我們就可以在 Timeline 類的構(gòu)造函數(shù)中初始化 tick。
!! 其實我們之前的《前端進階》的文章中也有詳細講過和使用過 Symbol。比如,我們使用過 Symbol 來代表 EOF(End Of File)文件結(jié)束符號。所以它作為對象的一個 key 并不是唯一的用法,Symbol 這種具有唯一特性,是它存在的一個意義。
start
函數(shù)中直接調(diào)用全局中的 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() {}}
首先創(chuàng)建一個 Animation(動畫)我們需要以下參數(shù):object
:被賦予動畫的元素對象property
:被賦予動畫變動的屬性startValue
:動畫起始值endValue
:動畫終止值duration
:動畫時長timingFunction
:動畫與時間的曲線px
(像素)。因為我們的 startValue
和 endValue
一定是一個 JavaScript 里面的一個數(shù)值。那么如果我們想要一個完整的 Animation,我們還需要傳入更多的參數(shù)。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; }}
接下來我們需要一個執(zhí)行 animation(動畫)的函數(shù),我們叫它為 exec、go 都是可以的,這里我們就用 run
(運行)這個單詞。個人覺得更加貼切這個函數(shù)的作用。time
(時間)參數(shù),而這個是一個虛擬時間。如果我們用真實的時間其實我們根本不需要做一個 Timeline(時間軸)了。!! 公式:變化區(qū)間(range) = 終止值(endValue) - 初始值(startValue)
得到了 變換區(qū)間
后,我們就可以計算出每一幀這個動畫要變化多少,這個公式就是這樣的:!! 變化值 = 變化區(qū)間值(range) * 時間(time) / 動畫時長(duration)
這里得到的變化值,會根據(jù)當(dāng)前已經(jīng)執(zhí)行的時間與動畫的總時長算出一個 progression
(進度 %),然后用這個進度的百分比與變化區(qū)間,算出我們初始值到達當(dāng)前進度的值的差值。這個差值就是我們的 變化值
。linear
動畫曲線。這動畫曲線就是一條直線。這里我們先用這個實現(xiàn)我們的 Animation
類,就先不去處理我們的 timingFunction
,后面我們再去處理這個動態(tài)的動畫曲線。run(time) { let range = this.endValue - this.startValue; this.object[this.property] = this.startValue + (range * time) / this.duration;}
這樣 Animation 就可以運作的了。接下來我們把這個 Animation 添加到 Timeline 的 animation 隊列里面,讓它在隊列中被執(zhí)行。const ANIMATIONS = Symbol('animations');
這個隊列還需要在 Timeline 類構(gòu)造的時候,就賦值一個空的 Set。constructor() { this[ANIMATIONS] = new Set();}
有隊列,那么我們必然就需要有一個加入隊列的方法,所以我們在 Timeline 類中還要加入一個 add()
方法。實現(xiàn)邏輯如下:add(animation) { this[ANIMATIONS].add(animation);}
我們要在 Timeline 中給 Animation 的 run
傳一個當(dāng)前已經(jīng)執(zhí)行了的時長。要計算這個時長的話,就要在 Timeline 開始的時候就記錄好一個開始時間。然后每一個動畫被觸發(fā)的時候,用 當(dāng)前時間 - Timeline 開始時間
才能獲得當(dāng)前已經(jīng)運行了多久。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)
,方便我們調(diào)試。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();
我們發(fā)現(xiàn) Animation 確實可以運作了,時間也可以獲得了。但是也發(fā)現(xiàn)了一個問題,Animation 一直在播放沒有停止。start
函數(shù)中的 animation 循環(huán)調(diào)用,在執(zhí)行 animation.run 之前加入一個條件判斷。這里我們需要判斷如果當(dāng)前時間是否已經(jīng)大于 animation 中的 duration 動畫時長。如果成立動畫就可以停止執(zhí)行了,并且需要把這個 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); }}
就這樣我們就加入了停止條件了,并沒有什么復(fù)雜的邏輯。最后我們在 main.js 中,改一下 Animation 的第一個參數(shù)。在傳入的對象中加入一個 setter,這樣我們就可以讓我們的 animation 打印出時間。這樣方便我們調(diào)試。tl.add( new Animation( { set a(a) { console.log(a); }, }, 'property', 0, 100, 1000, null ));
我們看到動畫確實是停止了,但是還是有一個問題。我們設(shè)置的 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 是可以根據(jù) 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
參數(shù),并且存儲到類的屬性對象當(dāng)中。START_TIMES
存儲空間,把我們所有 Animation 對應(yīng)的開始時間都存儲起來。// 頂部追加聲明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 數(shù)據(jù)里面。如果使用者沒有給 add 方法傳入 startTime 參數(shù),那么我們需要給它一個默認值為 Date.now()
。add(animation, startTime) { if (arguments.length < 2) startTime = Date.now(); this[ANIMATIONS].add(animation); this[START_TIMES].set(animation, startTime);}
接下來我們就可以去改造開始時間的邏輯:當(dāng)前時間 - Timeline 開始時間
當(dāng)前時間 - 動畫的開始時間
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 里面執(zhí)行以下命令來給 Timeline 加入一個動畫:tl.add(animation);
好,這個就是 Timeline 更新的設(shè)計。但是寫到這里,我們其實還沒有去讓 delay 這個參數(shù)的值去讓動畫被延遲。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 的話,那么代表我們的動畫還沒有到達需要執(zhí)行的時間,只有 t > 0 才需要執(zhí)行動畫。所以最后在執(zhí)行動畫的邏輯上,加入一個判斷。if (t > 0) animation.run(t);
那么接下來我們來嘗試實現(xiàn)它的 pause(暫停) 和 resume(恢復(fù)) 的能力。requestAnimationFrame
。TICK_HANDLER
嗎?這個常量就是用來存儲我們當(dāng)前 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()
方法中調(diào)用以下 cancelAnimationFrame
。pause() { cancelAnimationFrame(this[TICK_HANDLER]);}
Pause(暫停) 還是比較簡單的,但是 resume(重啟)就比較復(fù)雜了。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
來實現(xiàn)我們的動畫調(diào)用。這樣我們就不需要和我們的 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 實例化的第一個參數(shù)改為 document.querySelector('#el').style
。"transform"
。但是這里要注意,后面的開始時間和結(jié)束時間是無法用于 transform 這個屬性的。template
(模版),通過使用這個模版來轉(zhuǎn)換時間成 transform 對應(yīng)的值。 v => `translate(${$v}px)`;
最后我們的代碼就是這樣的:tl.add( new Animation( document.querySelector('#el').style, 'transform', 0, 100, 1000, 0, null, v => `translate(${v}px)` ));
這部分調(diào)整好之后,我們需要去到 animation.js 中去做對應(yīng)的調(diào)整。this.object[this.property]
這里面的值就應(yīng)該調(diào)用 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 里面去綁定這個元素。并且讓他執(zhí)行我們 Timeline 中的 pause 方法。document.querySelector('#pause-btn').addEventListener( 'click', () => tl.pause());
我們可以看到,現(xiàn)在 pause 功能是可以的了,但是我們應(yīng)該怎么去讓這個動畫繼續(xù)播下去呢?也就是要實現(xiàn)一個 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());
根據(jù)我們上面講到的邏輯,resume 最基本的理解,就是重新啟動我們的 tick。那么我們就試試直接在 resume 方法中執(zhí)行 this[TICK]()
會怎么樣。resume() { this[TICK]();}
在動畫中,我們可以看到,如果我們直接在 resume 中執(zhí)行 tick 的話,重新開始動畫的盒子,并沒有在原來暫停的位置開始繼續(xù)播放動畫。而是跳到了后面。暫停的開始時間
和暫停時間
給記錄下來。PAUSE_START
和 PAUSE_TIME
兩個常量來保存他們。const PAUSE_START = Symbol('pause-start');const PAUSE_TIME = Symbol('pause-time');
接下來就是在我們暫停的時候記錄一下當(dāng)時的時間:pause() { this[PAUSE_START] = Date.now(); cancelAnimationFrame(this[TICK_HANDLER]);}
其實我們記錄暫停的開始時間是為了什么呢?就是為了在我們繼續(xù)播放動畫的時候,知道我們當(dāng)下距離開始暫停的時候的時間相差了多久。t(動畫開始時間)- 暫停時長 = 當(dāng)前動畫應(yīng)該繼續(xù)播放的時間
。Date.now() - PAUSE_START
就能得到暫停動畫到現(xiàn)在的總時長。!! 這里有一個點,需要我們注意的。我們的動畫可能會出現(xiàn)多次暫停,并且多次的續(xù)播。那么這樣的話,如果我們每次都使用這個公式計算出新的暫停時長,然后覆蓋 PAUSE_TIME 的值,其實是不正確的。所以我們賦值給 PAUSE_TIME 的時候是使用
!! 因為我們的 Timeline 一旦開啟是不會停止的,時間一直都在流逝。如果我們每次都只是計算當(dāng)前的暫停時長,回退的時間其實是不對的。而正確的方式是,每次暫停時都需要去疊加上一次暫停過的時長。這樣最后回退的時間才是準(zhǔn)確的。
+=
,而不是覆蓋賦值。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); }}
我們運行一下代碼看看是否正確:!! 我是來自《技術(shù)銀河》的三鉆,一位正在重塑知識的技術(shù)人。下期再見。
!!
主題 Github 地址:https://github.com/auroral-ui/hexo-theme-aurora
主題使用文檔:https://aurora.tridiamond.tech/zh/
關(guān)鍵詞:實現(xiàn)
微信公眾號
版權(quán)所有? 億企邦 1997-2025 保留一切法律許可權(quán)利。