最近在給學生上課,上週六的第一堂課是關於 JavaScript 動畫的內容,其中包括一些簡單的動畫,比如勻速或者勻加/減速的運動,也包括複雜一些的組合動畫。而動畫的基本原理,在我之前的文章已經有了詳細的介紹。在這裡,我想談一談的是,我們可以如何針對現代瀏覽器設計更加簡單的 API,來實現動畫的序列播放。
基於 Promise 的動畫庫
所謂的動畫序列,也就是說可以在上一段動畫播放結束之後進行下一段動畫的播放,這樣可以方便用多段動畫實現各種不同的複雜效果。而我們不難想到,要實現這個目的,將動畫介面實現成 Promise 是一個非常好的方案:
上面這個例子,在支援 async/await 的現代瀏覽器中程式碼非常簡潔和優雅。如果要相容舊的瀏覽器,也並不複雜,只需要針對 es6-promise 做 polyfill 或引入第三方庫即可。再來看一個例子:
有了 Promise,像這樣的序列運動非常簡單。那麼要實現這個動畫庫,具體該怎麼做呢?
具體實現
其實整個庫實現起來並不複雜,只需要將基礎動畫封裝為 Promise 就可以了。
不過在這裡,為了相容老版本的瀏覽器,我們先對一些基礎函式進行封裝:
1 2 3 4 5 6 |
function nowtime(){ if(typeof performance !== 'undefined' && performance.now){ return performance.now(); } return Date.now ? Date.now() : (new Date()).getTime(); } |
我們說動畫是關於時間的函式,因此我們需要一個簡單的獲取時間功能。在新的 requestAnimationFrame 規範中,frame 回撥的引數 timestamp 是一個DOMHighResTimeStamp 物件,它比 Date 的計時要更精確(可以精確到納秒)。因此獲取時間我們優先使用 performance.now(),如果瀏覽器不支援 performance.now(),我們再降級使用 Date.now()。
接下來,我們對 requestAnimationFrame 進行 polyfill:
1 2 3 4 5 6 7 8 9 10 |
if(typeof global.requestAnimationFrame === 'undefined'){ global.requestAnimationFrame = function(callback){ return setTimeout(function(){ //polyfill callback.call(this, nowtime()); }, 1000/60); } global.cancelAnimationFrame = function(qId){ return clearTimeout(qId); } } |
然後,是具體的 Animator 實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
function Animator(duration, update, easing){ this.duration = duration; this.update = update; this.easing = easing; } Animator.prototype = { animate: function(){ var startTime = 0, duration = this.duration, update = this.update, easing = this.easing, self = this; return new Promise(function(resolve, reject){ var qId = 0; function step(timestamp){ startTime = startTime || timestamp; var p = Math.min(1.0, (timestamp - startTime) / duration); update.call(self, easing ? easing(p) : p, p); if(p < 1.0){ qId = requestAnimationFrame(step); }else{ resolve(self); } } self.cancel = function(){ cancelAnimationFrame(qId); update.call(self, 0, 0); reject('User canceled!'); } qId = requestAnimationFrame(step); }); }, ease: function(easing){ return new Animator(this.duration, this.update, easing); } }; module.exports = Animator; |
Animator 構造的時候可以傳三個引數,第一個是動畫的總時長,第二個是動畫每一幀的 update 事件,在這裡可以改變元素的屬性,從而實現動畫。update 事件回撥提供兩個引數,第一個是 ep,是經過 easing 之後的動畫程式,第二個是 p,是不經過 easing 的動畫程式,ep 和 p 的值都是從 0 開始,到 1 結束。(為什麼要使用 ep 和 p,在前一個動畫教程裡已經說明了。)
Animator 有一個 animate 的物件方法,它返回一個 promise,當動畫播放完成時,它的 promise 被 resolve,使用者還可以在 promise resolve 前呼叫 cancel 方法,這樣它的 promise 會被 reject。
於是這樣,很簡單地我們就通過將 animator 封裝為帶有返回 Promise 介面的方法,實現了動畫序列。它的實現雖然簡單,但功能卻是很強大的,用它實現的動畫程式碼也很優雅:
我們還提供了一個 ease 方法(0.2.0+版),能夠傳入新的 easing,並返回新的 Animator 物件,這樣我們就可以在原動畫的基礎上擴充套件我們的動畫效果:
用 CSS3 如何?
的確,許多動畫可以用 CSS3 來實現。不過 JavaScript 動畫與 CSS3 動畫有其不同的特點和使用場景。總體來說, CSS3 動畫適用於任何純展現效果的簡單動畫。雖然它也能提供基本的動畫組合方法(有 animationEnd 時間,但標準化較晚),但操作起來依然不方便,而且還需要 JavaScript 來控制。有些動畫庫用降級的方式,能採用 CSS3 動畫的採用 CSS3 動畫,不能的自動降級為 JavaScript 動畫,這不失為一種好方式,但也有利有弊。因為 CSS3 動畫是繫結為操作元素屬性的,而 JavaScript 更靈活一些。就像我們這個封裝的動畫庫,其實提供的是更底層的 API,操作的只是時間和進度,並沒有耦合任何元素、屬性或者其他展示類的東西,因此它完全可以用來操作 DOM、Canvas、SVG、音訊/視訊流甚至是其他非同步動作。另外,如果在動畫過程中需要有其他一些精細的動作處理,也還是應該使用 JavaScript 動畫而不是 CSS3 動畫。
總結
使用 Promise 實現的簡單動畫庫,能夠很好地執行組合的時序動畫,配合 async/await 程式碼實現簡潔且優雅,同時還具有非常好的擴充套件性,能夠組合出非常強大的動畫效果。我相信這將成為未來瀏覽器上 JavaScript 動畫的主要實現方式。
最後,可以訪問 GitHub repo 獲取最新程式碼。