大家如果曾經接觸過 Vue, 那麼大抵會對其自帶的組件 TransitionGroup 有所了解。這篇文章便記錄了 TransitionGroup 中「移動動畫」的一些使用細節。
或許你對 TransitionGroup 的「移動動畫」還不太了解,那麼我在這裡淺淺地介紹一下。正常使用時,你需要為 .[name]-move
類提供一個過渡樣式,例如 transition: all 0.5s ease;
,這樣,當 TransitionGroup 內的元素位置變更後, Vue 會嘗試讓變動了位置的元素從老位置平滑過渡到新位置。當然,Vue 也支持新增元素和刪除元素的過渡效果,只需要為 [name]-enter-from
和 [name]-leave-to
類名提供樣式,這不是本文的重點,故不再詳細介紹。
曾經的我,也像大部分人那樣按部就班把樣式寫完,沒出過問題。
直到群裡有人告訴我:「試試給元素增加一個常駐的、帶有 !important
的 transition
樣式,會讓過渡失效」
我當場愣住😧,這在當時的我看來是一件很難理解的事情:本身過渡時 Vue 就會通過 [name]-move
為元素加上 transition
屬性,為什麼提前給元素加上一個優先級最高的 transition
屬性,過渡反而沒法生效了呢?
從源碼入手#
我們可以在 TransitionGroup.ts 閱讀與 TransitionGroup 相關的代碼內容
初始化階段#
不難發現,Vue 在渲染函數內,將子元素數組 children
賦值給 setup
函數作用域下的變量 prevChildren
(L113)。同時,通過一個 for 循環遍歷 prevChildren
(L135),將每個子元素的位置信息儲存於 positionMap
中 (舊位置)。
該階段中與我們討論內容相關的,便是這兩處內容。prevChildren
的賦值,使得 Vue 能夠在之後的 updated 生命週期中,得以取得子元素引用,方便進行相關操作。而 positionMap
,讓 Vue 有能力在之後的操作中,得到元素的原始位置。
此處的 positionMap
是一個 WeakMap,Vue 使用元素對象作為 key 值,能夠保證在元素被銷毀後,positionMap
中對應元素的位置信息被適時自動回收。
Updated 生命週期#
當 TransitionGroup 內的子元素發生變動後,會調用 onUpdated
註冊的回調函數,同時也是在這裡,Vue 完成了過渡所需要的大部分操作。
首先,Vue 通過一個 forEach,再次遍歷獲取了各個元素的位置信息,儲存到 newPositionMap
中 (L72)。此時,一個元素的新舊位置分別儲存在 newPositionMap
和 positionMap
中,我們需要做的,就是讓元素從舊位置平滑過渡到新位置。
接下來就是關鍵所在:既然是 updated 生命週期,此時元素們應該已經在新位置呆著了,又談何過渡?所以,我們要做的,並不是單純讓元素從舊位置過渡到新位置。而是將已經位於新位置的元素,重新放回舊位置,再讓其平滑返回到新位置,完成整個過渡過程。那么 Vue 是怎麼完成的呢?
L73的代碼是這樣的:
const movedChildren = prevChildren.filter(applyTranslation)
Vue 使用 applyTranslation
方法過濾出了需要移動的子元素數組 movedChildren
。通過觀察函數和變量的命名,我們幾乎可以肯定地說,在過濾的同時,Vue 還對元素進行了一些操作,而實際上也確實如此。
function applyTranslation(c: VNode): VNode | undefined {
const oldPos = positionMap.get(c)!
const newPos = newPositionMap.get(c)!
const dx = oldPos.left - newPos.left
const dy = oldPos.top - newPos.top
if (dx || dy) {
const s = (c.el as HTMLElement).style
s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`
s.transitionDuration = '0s'
return c
}
}
對於需要移動的元素,Vue 計算了新舊位置差值,使用 css 屬性 Transform
,將元素放回舊位置。特別關注一下 s.transitionDuration = '0s'
,~ 之後要考~。
但是實際上,此時的元素仍然沒有回到舊位置。瀏覽器會將樣式變動加入渲染隊列中,而不是立刻渲染。這裡涉及瀏覽器重排 (reflow) 的相關知識,可以搜索相關文章來進行閱讀。
為了保證元素立刻被放置到舊位置,在 L73 得到 movedPosition
後,Vue 執行了 forceReflow
方法 (L76),強制觸發重排。而 forceReflow
方法內容也很簡單 (Transition.ts#L461)
function forceReflow() {
return document.body.offsetHeight
}
這裡也是個前端小知識🤯,讀取文檔的 offsetHeight
或 offsetWidth
,也能觸發文檔重排。可以這麼理解:渲染隊列中存在改動而不進行重排直接獲取文檔寬度或高度,會導致拿到的元素寬高是過時的,所以瀏覽器在讀取前對文檔進行了重排。
之後的工作就很簡單了,只需要給元素加上 [name]-move
類名 (L81),然後去除之前添加的 transitionDuration
和 Transform
屬性,元素自然就能平滑返回到新位置啦~監聽 transitionend
事件 (L83-L93),做一些收尾工作(去除過渡相關類名等)
讀到這裡,我們已經能夠解決文章開頭的那個問題了。實現過渡效果,需要確保元素正位於舊位置。在 Vue 中,為了確保文檔重排後元素通過 Transform
放到了舊位置,Vue 將元素的過渡時間設置為 0s 並進行了一次強制重排。但是人為添加的高優先級 transition
屬性導致重排時元素沒法第一時間回到舊位置,也就沒有過渡效果了。
我也寫了一個小 demo,簡化了 TransitionGroup 中的無關代碼,感興趣可以看看 https://codepen.io/DaiDR/pen/VwdMRxa