函數語言程式設計能幹什麼(二)-- 用 Rx.js 寫個拋物線動畫
昨天在掘金看到一篇文章,內容是用原生 JS 寫拋物線動畫。看完覺得挺有趣,很適合用 Rx.js 來重現,於是有了這篇文章。
本文預設你已經掌握了 Rx.js 的基本概念和操作。若你還沒掌握,推薦先看一些入門資料。
動畫的本質就是頁面元素隨著時間的持續,在特定時間點改變自身在頁面中的座標位置。這個很適合用響應式程式設計中“流”的概念來表達。我們需要將動畫的持續時間(本文只考慮時間限定的情況)內根據瀏覽器requestAnimateFrame
API 所允許的時間點對映成一個個節點,然後在這一個個節點中改變物體的位置。這個關鍵一步做好了,剩下的諸如easing
曲線和加速度等都好解決了。
來看怎麼解決第一個問題。先上程式碼:
// 首先我就把所有 Observable 和操作符匯入了,接下來就省略了 import { interval, animationFrameScheduler, fromEvent, defer, merge } from "rxjs"; import { map, takeWhile, tap, flatMap } from "rxjs/operators"; function duration(ms) { return defer(() => { const start = Date.now(); return interval(0, animationFrameScheduler).pipe( map(() => (Date.now() - start) / ms), takeWhile(n => n <= 1) ); }); } 複製程式碼
defer
的作用是,只有當被訂閱時,它才會根據提供給它的 Observable 工廠函式,生成新的 Observable。這樣做的目的是,duration
需要為每一個訂閱者提供新的 Observable。等下會看到它會在不同的地方被訂閱。
首先在defer
裡面的 Observable 工廠函式前面記錄當前時間戳。接下來下一行,interval
的作用是相隔指定時間段,釋放一個行為(這個比較抽象,可以理解成告訴管道的下一個接收者要開始做事了)。interval
接受兩個引數,第二個引數是 Scheduler。預設的 Scheduler 是async
,這裡我們需要提供animationFrameScheduler
。這樣做的意思是,告訴interval
每隔 0s 釋放一次行為,這個行為由animationFrameScheduler
調控。事實上後者不會真的每 0s 就釋放一次,而是會通過requestAnimationFrame
來獲取瀏覽器的空閒時間(下一幀渲染之前),只有當瀏覽器有空了才會響應interval
的指令。
然後接下來進入管道,第一個map
意思是,把interval
的指令對映成一個時間比例,該時間比例由當前時間,減去interval
生成之前的時間,然後除以總時間,得到的是當前時間點佔總時間長的比率。takeWhile
指定一旦這個時間比例超過 1,就把 Observable 停掉。舉個例子,本來指定了 3 秒,但是時間過了 4 秒,4/3 就大於 1 了,超過了動畫指定時長。
最重要的部分就處理完了。
接下來計算每個時間點物體應該移動的距離:
const distance = d => t => d * t; 複製程式碼
引數 d 指的是總距離,t 指的是時間比率,就是我們在上一步算出來的。兩者相乘就是每個時間點物體移動的距離了。注意,函數語言程式設計裡面的函式都要柯里化(回撥函式不一定)。這樣做的好處等下會看到。
然後取到 DOM 上的目標元素,對其進行位移:
const targetDiv = document.querySelector(".target"); const moveRight$ = duration(2000).pipe( map(distance(1000)), tap(x => (targetDiv.style.left = x + "px")) ); const moveDown$ = duration(2000).pipe( map(distance(700)), tap(y => (targetDiv.style.top = y + "px")) ); 複製程式碼
這裡寫了兩個流,分別是右移和下移,右移 1000px, 下移 700px。注意到我們把總距離傳給distance
函式後,它會返回新的函式,等著管道上游給它傳時間比例 t,這就是柯里化的作用。
然後我們把兩個流合併,就可以讓物體同時右移和下移,也就是讓它走對角線。
merge(moveRight$, moveDown$).subscribe() 複製程式碼
動畫的第一階段寫完了,此時目標物體會從左上角到右下角做勻速直線運動。接下來我們要加上拋物線軌跡和重力加速度效果。
思考一下,拋物線的軌跡是水平移動和垂直移動速度不一致導致的,而加速度是由兩者的速率變化導致的。前者可以用兩者的函式關係來體現,後者可以用兩者各自的easing
函式來體現。我查了一下主流的easing
函式,仿寫了兩個。
第一個是easeInQuad
:
const easeInQuad = t => t * t; 複製程式碼
第二個是easeInQuint
:
const easeInQuint = t => t * t * t * t * t * t; 複製程式碼
可以看出兩者的函式關係是y = Math.pow(x, 3)
,剛好是個拋物線。若想定製加速度和拋物線軌跡,也可以自己寫。
接下來只用把interval
裡面的時間比例應用於各自的easing
函式就行了。然後再加個按鈕,只有點選按鈕後,動畫才開始。
一步到位完整程式碼:
const targetDiv = document.querySelector(".target"); const startBtn = document.querySelector("#start"); const startClick$ = fromEvent(startBtn, "click"); const easeInQuad = t => t * t; const easeInQuint = t => t * t * t * t * t * t; function duration(ms) { return defer(() => { const start = Date.now(); return interval(0, animationFrameScheduler).pipe( map(() => (Date.now() - start) / ms), takeWhile(n => n <= 1) ); }); } const distance = d => t => d * t; const moveDown$ = duration(1500).pipe( map(easeInQuint), map(distance(700)), tap(y => (targetDiv.style.top = y + "px")) ); const moveRight$ = duration(1500).pipe( map(easeInQuad), map(distance(1000)), tap(x => (targetDiv.style.left = x + "px")) ); startClick$.pipe( flatMap(() => merge(moveRight$, moveDown$)) ).subscribe() 複製程式碼
線上效果在這裡