DOM 節點拖拉的好實踐

透過修改 transform 中的 translate,可以拖拉移動 DOM 節點,現今的模板語法,我們可以很 Programmatic 地把樣式值設定為變數,再交由組件自行渲染,或者我們可以使用 requestAnimationFrame,但事件一直進來、組件週期、重複的 raf …

realdennis
7 min readAug 7, 2019

寫在文前

許多工程師都很討厭寫樣式,尤其是值會動的。

「直觀的用程式語言的變數去操作樣式值」這種做法往往會帶來額外的效能負擔(可以參考上一篇《避免 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 = INITIAL
if(something){
currentHeight = 87;
}
html`
<style>
height: ${currentHeight}px
<style>
`

之類的

的確,在不是高頻率改動的特效上,我認為這是一個很棒的解法,如我副標題所說的 Programmatic 又不會骯髒。

Component lifecycle in Vue & React

但是,在設定 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;
// mutex
window.requestAnimationFrame(() => {
updateTransform(offsetX, offsetY);
entryPoint = true;
});
}})

我們在 callback 裡頭使用一個叫做互斥鎖(Mutex)的東西,直到該 event callback 裡頭的 raf 解決完畢以前,都不再發起其他的 raf ,這有效的避免某些拖延我們 raf 的 event 發作。

由於我們在 callback 裡頭插入了會操作 io 的 console.log 他的卡頓比較起來會更加明顯。

以下為 demo:

觀察兩者的 console

如此一來…有做臨界區間的處理器則能在完整的 raf 操作完 DOM 表現後(1),再接續服務之後的 event (2),如此 (2) 進來的 event 拿到時間點(1)之後 move 的 event property。

沒有做臨界區間的處理器會發生:

連續兩個事件(1)(2)進來,就在(1)處理完之後(3),本以為要服務(3)的時間點,卻緩緩地將時間點(2)的值寫入。

尾聲

其實透過 requestAnimationFrame 去鎖定回呼函數是否服務的做法,就是一個設定 16 ms 的節流函數(但比你想象中的更活),我們可以設定的更活,或是搭配前面說的概念在 raf 裡頭再 raf 讓 main thread 不被卡到飛,最後再把臨界區間打開。

還有什麼可以讓動畫更柔順的方法嗎?

  1. 使用 translate3d 欺騙瀏覽器 (translate & translate3d速度比較)
  2. 透過 will-change 的樣式屬性告知瀏覽器哪些屬性將會變化
  3. 如果覺得操作 dom.style.transform 字串太髒,嘗試使用 literal template 將其操作封裝

--

--

No responses yet