DOM 節點拖拉的好實踐
透過修改 transform 中的 translate,可以拖拉移動 DOM 節點,現今的模板語法,我們可以很 Programmatic 地把樣式值設定為變數,再交由組件自行渲染,或者我們可以使用 requestAnimationFrame,但事件一直進來、組件週期、重複的 raf …
寫在文前
許多工程師都很討厭寫樣式,尤其是值會動的。
「直觀的用程式語言的變數去操作樣式值」這種做法往往會帶來額外的效能負擔(可以參考上一篇《避免 Layout 帶來的痛 》),儘管在小組件、現代裝置並不是這麼明顯,但你仍然可以感覺到一絲的卡頓。
那麼我不想寫這麼骯髒的操作 style 字串的 code,我能否藉由框架來幫助我呢?
第一個發想 — 框架的data binding
我們都知道不少框架都有綁定資料跟模板,這一定程度地幫助我們避開用很hardcode的方式操作樣式:
Before:
const card = document.querySelector('card');card.style.height = '0px';
if(something) {
card.style.height = 87 + 'px';
}
After (styled):
const card = props => styled`
height: ${props=> props.something? 0 : 87}px
`
or literal template (lit-html):
const INITIAL = 0;
let currentHeight = INITIALif(something){
currentHeight = 87;
}html`
<style>
height: ${currentHeight}px
<style>
`
之類的
的確,在不是高頻率改動的特效上,我認為這是一個很棒的解法,如我副標題所說的 Programmatic 又不會骯髒。
但是,在設定 state 或是 data 後,再將其灌入模板上 render ,這條路上往往需要多一點時間處理,在實時的動畫中,將高頻率變動的值設定為 state 顯然不太理想。
第二個想法
對於動畫這種希望能敏銳反應的操作,我們直接在事件觸發時修改,聽起來滿理想的,其實就是回到以前在做法。
dom.addEventListener('mousemove',e=>{
const {offsetX, offsetY} = e;
dom.style.transform = `translate3d(${offsetX}px, ${offsetY}px) 0px`
})
不過如同上一篇所說的,如果操作 DOM 的數量太大又讀讀寫寫,這個 callback 會成為掉幀的原因,畢竟卡到 main thread:
dom.addEventListener('mousemove',e=>{
const {offsetX, offsetY} = e;
const height = dom.style.height;
dom.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0px)`
const width = anotherDOM.style.width
// blabla})
所以盡可能還是把它包在 requestAnimationFrame 裡頭,並且把不同的 update 拆開成不同 callback,透過 raf 讓這種 overhead 較大的操作異步化:
const update = ()=>{
//do_something... const update2 = ()=>{
//do another
} requestAnimationFrame(update2)}
requestAnimationFrame(update)
還能更好嗎?
我們都知道某些事件(尤其是scroll、xxxmove)會不間斷的來,我們的 callback queue 基本上排到滿出來,這樣往往鼠標已經移動到很遠了,還在服務很久以前排進 queue 裡的 callback…
第三個想法 — 為你的 callback 設計一個臨界區間
講起來有點抽象,直接上 code 吧:
dom.addEventListener('mousemove',e=>{
// do some heavy work
})
這個回呼函數在你鼠標移動的時候會爆幹排隊:
|口 口 口 cb cb cb cb cb … … cb| →
然而,同樣的 cb 都是在操作同樣的 style ,這會一定程度地導致鼠標明明已經移動到很遠了,怎麼 DOM 節點還沒跟過來,原因是前幾秒的 cb 根本還沒輪到,他在很晚之後才更新舊的 event property 到你眼前看到的 DOM。
如果再用 raf 對自己的 DOM 操作做優化,那個回呼 queue 會亂到飛天,你原本以為的下一幀更新,在 cb 擠在一起的時候,cb 延遲導致你的 raf 時程被拉得更遠,你以為的下一幀可能根本是很久以後。
let entryPoint = true;dom.addEventListener('mousemove', e => {
if (entryPoint) {
const { offsetX, offsetY } = e; entryPoint = false;
// mutexwindow.requestAnimationFrame(() => {
updateTransform(offsetX, offsetY);
entryPoint = true;
});}})
我們在 callback 裡頭使用一個叫做互斥鎖(Mutex)的東西,直到該 event callback 裡頭的 raf 解決完畢以前,都不再發起其他的 raf ,這有效的避免某些拖延我們 raf 的 event 發作。
由於我們在 callback 裡頭插入了會操作 io 的 console.log
他的卡頓比較起來會更加明顯。
以下為 demo:
如此一來…有做臨界區間的處理器則能在完整的 raf 操作完 DOM 表現後(1),再接續服務之後的 event (2),如此 (2) 進來的 event 拿到時間點(1)之後 move 的 event property。
沒有做臨界區間的處理器會發生:
連續兩個事件(1)(2)進來,就在(1)處理完之後(3),本以為要服務(3)的時間點,卻緩緩地將時間點(2)的值寫入。
尾聲
其實透過 requestAnimationFrame
去鎖定回呼函數是否服務的做法,就是一個設定 16 ms 的節流函數(但比你想象中的更活),我們可以設定的更活,或是搭配前面說的概念在 raf 裡頭再 raf 讓 main thread 不被卡到飛,最後再把臨界區間打開。
還有什麼可以讓動畫更柔順的方法嗎?
- 使用 translate3d 欺騙瀏覽器 (translate & translate3d速度比較)
- 透過 will-change 的樣式屬性告知瀏覽器哪些屬性將會變化
- 如果覺得操作 dom.style.transform 字串太髒,嘗試使用 literal template 將其操作封裝