本篇文章是細談 vue 系列的第四篇,按理說這篇文章是上篇 《細談 vue - transition 篇》中的一個單獨的大章節。然鵝,上篇文章篇幅過長,所以不得已將其單獨拎出來寫成一篇了。對該系列以前的文章感興趣的可以點選以下連結進行傳送
書接上文,上篇文章我們主要介紹了 <transition>
元件對 props
和 vnode hooks
的 輸入 => 輸出
處理設計,它針對單一元素的 enter
以及 leave
階段進行了過渡效果的封裝處理,使得我們只需關注 css
和 js
鉤子函式的業務實現即可。
但是我們在實際開發中,卻終究難逃多個元素都需要進行使用過渡效果進行展示,很顯然,<transition>
元件並不能實現我的業務需求。這個時候,vue
內部封裝了 <transition-group>
這麼一個內建元件來滿足我們的需要,它很好的幫助我們實現了列表的過渡效果。
一、舉個例子
老樣子,直接先上一個官方的例子
<template>
<div id="list-demo">
<button v-on:click="add">Add</button>
<button v-on:click="remove">Remove</button>
<transition-group name="list" tag="p">
<span v-for="item in items" v-bind:key="item" class="list-item">
{{ item }}
</span>
</transition-group>
</div>
</template>
<script>
export default {
name: 'home',
data () {
return {
items: [1, 2, 3, 4, 5, 6, 7, 8, 9],
nextNum: 10
}
},
methods: {
randomIndex: function () {
return Math.floor(Math.random() * this.items.length)
},
add: function () {
this.items.splice(this.randomIndex(), 0, this.nextNum++)
},
remove: function () {
this.items.splice(this.randomIndex(), 1)
}
}
}
</script>
<style lang="scss">
.list-item {
display: inline-block;
margin-right: 10px;
}
.list-enter-active, .list-leave-active {
transition: all 1s;
}
.list-enter, .list-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>
複製程式碼
效果如下圖
接下來,我將帶著大家一起探究一下 <transition-group>
元件的設計
二、transition-group 實現
和 <transition>
元件相比,<transition>
是一個抽象元件,且只對單個元素生效。而 <transition-group>
元件實現了列表的過渡,並且它會渲染一個真實的元素節點。
但他們的設計理念卻是一致的,同樣會給我們提供一個 props
和一系列鉤子函式給我們當做 輸入
的介面,內部進行 輸入 => 輸出
的轉換或者說繫結處理
export default {
props,
beforeMount () {
// ...
},
render (h: Function) {
// ...
},
updated () {
// ...
},
methods: {
// ...
}
}
複製程式碼
1、props & other import
<transition-group>
的 props
和 <transition>
的props
基本一致,只是多了一個 tag
和 moveClass
屬性,刪除了 mode
屬性
// props
import { transitionProps, extractTransitionData } from './transition'
const props = extend({
tag: String,
moveClass: String
}, transitionProps)
delete props.mode
// other import
import { warn, extend } from 'core/util/index'
import { addClass, removeClass } from '../class-util'
import { setActiveInstance } from 'core/instance/lifecycle'
import {
hasTransition,
getTransitionInfo,
transitionEndEvent,
addTransitionClass,
removeTransitionClass
} from '../transition-util'
複製程式碼
2、render
首先,我們需要定義一系列變數,方便後續的操作
tag
:從上面設計的整體脈絡我們能看到,<transition-group>
並沒有abstract
屬性,即它將渲染一個真實節點,那麼節點tag
則是必須的,其預設值為span
。map
:建立一個空物件prevChildren
:用來儲存上一次的子節點rawChildren
:獲取<transition-group>
包裹的子節點children
:用來儲存當前的子節點transitionData
:獲取元件上的渲染資料
const tag: string = this.tag || this.$vnode.data.tag || 'span'
const map: Object = Object.create(null)
const prevChildren: Array<VNode> = this.prevChildren = this.children
const rawChildren: Array<VNode> = this.$slots.default || []
const children: Array<VNode> = this.children = []
const transitionData: Object = extractTransitionData(this)
複製程式碼
緊接著是對節點遍歷的操作,這裡主要對列表中每個節點進行過渡動畫的繫結
- 對
rawChildren
進行遍歷,並將每個vnode
節點取出; - 若節點存在含有 __vlist 字元的
key
,則將vnode
丟到children
中; - 隨即將提取出來的過渡資料
transitionData
新增到vnode.data.transition
上,這樣便能實現列表中單個元素的過渡動畫
for (let i = 0; i < rawChildren.length; i++) {
const c: VNode = rawChildren[i]
if (c.tag) {
if (c.key != null && String(c.key).indexOf('__vlist') !== 0) {
children.push(c)
map[c.key] = c
;(c.data || (c.data = {})).transition = transitionData
} else if (process.env.NODE_ENV !== 'production') {
const opts: ?VNodeComponentOptions = c.componentOptions
const name: string = opts ? (opts.Ctor.options.name || opts.tag || '') : c.tag
warn(`<transition-group> children must be keyed: <${name}>`)
}
}
}
複製程式碼
隨後對 prevChildren
進行處理
- 如果
prevChildren
存在,則對其進行遍歷,將transitionData
賦值給vnode.data.transition
,如此之後,當vnode
子節點enter
和leave
階段存在過渡動畫的時候,則會執行對應的過渡動畫 - 隨即呼叫原生的
getBoundingClientRect
獲取元素的位置資訊,將其記錄到vnode.data.pos
中 - 然後判斷
map
中是否存在vnode.key
,若存在,則將vnode
放到kept
中,否則丟到removed
佇列中 - 最後將渲染後的元素放到
this.kept
中,this.removed
則用來記錄被移除掉的節點
if (prevChildren) {
const kept: Array<VNode> = []
const removed: Array<VNode> = []
for (let i = 0; i < prevChildren.length; i++) {
const c: VNode = prevChildren[i]
c.data.transition = transitionData
c.data.pos = c.elm.getBoundingClientRect()
if (map[c.key]) {
kept.push(c)
} else {
removed.push(c)
}
}
this.kept = h(tag, null, kept)
this.removed = removed
}
複製程式碼
最後 <transition-group>
進行渲染
return h(tag, null, children)
複製程式碼
3、update & methods
上面我們已經在 render
階段對列表中的每個元素繫結好了 transition
相關的過渡效果,接下來就是每個元素動態變更時,整個列表進行 update
時候的動態過渡了。那具體這塊又是如何操作的呢?接下來我們就捋捋這塊的邏輯
i. 是否需要進行 move 過渡
- 首先在
update
鉤子函式裡面,會先獲取上一次的子節點prevChildren
和moveClass
;隨後判斷children
是否存在以及children
是否 has move ,若children
不存在,或者children
沒有move
狀態,那麼也沒有必要繼續進行update
的move
過渡了,直接return
即可
const children: Array<VNode> = this.prevChildren
const moveClass: string = this.moveClass || ((this.name || 'v') + '-move')
if (!children.length || !this.hasMove(children[0].elm, moveClass)) {
return
}
複製程式碼
hasMove()
:該方法主要用來判斷el
節點是否有move
的狀態。- 當前置
return
條件不符合的情況下,它會先克隆一個 DOM 節點,然後為了避免元素內部已經有了 css 過渡,所以會移除掉克隆節點上的所有的transitionClasses
- 緊接著,對克隆節點重新加上
moveClass
,並將其display
設為none
,然後新增到this.$el
上 - 接下來通過
getTransitionInfo
獲取它的transition
相關的資訊,然後從this.$el
上將其移除。這個時候我們已經獲取到了節點是否有transform
的資訊了
export const hasTransition = inBrowser && !isIE9
hasMove (el: any, moveClass: string): boolean {
// 若不在瀏覽器中,或者瀏覽器不支援 transition,直接返回 false 即可
if (!hasTransition) {
return false
}
// 若當前例項上下文的有 _hasMove,直接返回 _hasMove 的值即可
if (this._hasMove) {
return this._hasMove
}
const clone: HTMLElement = el.cloneNode()
if (el._transitionClasses) {
el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })
}
addClass(clone, moveClass)
clone.style.display = 'none'
this.$el.appendChild(clone)
const info: Object = getTransitionInfo(clone)
this.$el.removeChild(clone)
return (this._hasMove = info.hasTransform)
}
複製程式碼
ii. move 過渡實現
- 然後對子節點進行一波預處理,這裡對子節點的處理使用了三次迴圈,主要是為了避免每次迴圈對 DOM 的讀寫變的混亂,有助於防止佈局混亂
children.forEach(callPendingCbs)
children.forEach(recordPosition)
children.forEach(applyTranslation)
複製程式碼
三個函式的處理分別如下
callPendingCbs()
:判斷每個節點前一幀的過渡動畫是否執行完畢,如果沒有執行完,則提前執行_moveCb()
和_enterCb()
recordPosition()
:記錄每個節點的新位置applyTranslation()
:分別獲取節點新舊位置,並計算差值,若存在差值,則通過設定節點的transform
屬性將需要移動的節點位置偏移到之前的位置,為列表move
做準備
function callPendingCbs (c: VNode) {
if (c.elm._moveCb) {
c.elm._moveCb()
}
if (c.elm._enterCb) {
c.elm._enterCb()
}
}
function recordPosition (c: VNode) {
c.data.newPos = c.elm.getBoundingClientRect()
}
function applyTranslation (c: VNode) {
const oldPos = c.data.pos
const newPos = c.data.newPos
const dx = oldPos.left - newPos.left
const dy = oldPos.top - newPos.top
if (dx || dy) {
c.data.moved = true
const s = c.elm.style
s.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)`
s.transitionDuration = '0s'
}
}
複製程式碼
- 緊接著,對子元素進行遍歷實現
move
過渡。遍歷前會通過獲取document.body.offsetHeight
,從而發生計算,觸發迴流,讓瀏覽器進行重繪 - 然後開始對
children
進行遍歷,期間若vnode.data.moved
為true
,則執行addTransitionClass
為子節點加上moveClass
,並將其style.transform
屬性清空,由於我們在子節點預處理中已經將子節點偏移到了之前的舊位置,所以此時它會從舊位置過渡偏移到當前位置,這就是我們要的move
過渡的效果 - 最後會為節點加上
transitionend
過渡結束的監聽事件,在事件裡做一些清理的操作
this._reflow = document.body.offsetHeight
children.forEach((c: VNode) => {
if (c.data.moved) {
const el: any = c.elm
const s: any = el.style
addTransitionClass(el, moveClass)
s.transform = s.WebkitTransform = s.transitionDuration = ''
el.addEventListener(transitionEndEvent, el._moveCb = function cb (e) {
if (e && e.target !== el) {
return
}
if (!e || /transform$/.test(e.propertyName)) {
el.removeEventListener(transitionEndEvent, cb)
el._moveCb = null
removeTransitionClass(el, moveClass)
}
})
}
})
複製程式碼
注:瀏覽器迴流觸發條件我稍微做個總結,比如瀏覽器視窗改變、計算樣式、對 DOM 進行元素的新增或者刪除、改變元素 class 等
- 新增或者刪除可見的DOM元素
- 元素位置改變
- 元素尺寸改變 —— 邊距、填充、邊框、寬度和高度
- 內容變化,比如使用者在 input 框中輸入文字,文字或者圖片大小改變而引起的計算值寬度和高度改變
- 頁面渲染初始化
- 瀏覽器視窗尺寸改變 —— resize 事件發生時
- 計算 offsetWidth 和 offsetHeight 屬性
- 設定 style 屬性的值
4、beforeMount
由於 VDOM
在節點 diff
更新的時候是不能保證被移除元素它的一個相對位置。所以這裡需要在 beforeMount
鉤子函式裡面對 update
渲染邏輯重寫,來達到我們想要的效果
- 首先獲取例項本身的
update
方法,進行快取 - 從上面我們知道
this.kept
是快取的上次的節點,並且裡面的節點增加了一些transition
過渡屬性。這裡首先通過setActiveInstance
快取好當前例項,隨即對vnode
進行__patch__
操作並移除需要被移除掉的vnode
,然後執行restoreActiveInstance
將其例項指向恢復 - 隨後將
this.kept
賦值給this._vnode
,使其觸發過渡 - 最後執行快取的
update
渲染節點
beforeMount () {
const update = this._update
this._update = (vnode, hydrating) => {
const restoreActiveInstance = setActiveInstance(this)
// force removing pass
this.__patch__(
this._vnode,
this.kept,
false, // hydrating
true // removeOnly (!important, avoids unnecessary moves)
)
this._vnode = this.kept
restoreActiveInstance()
update.call(this, vnode, hydrating)
}
}
複製程式碼
setActiveInstance
export let activeInstance: any = null
export function setActiveInstance(vm: Component) {
const prevActiveInstance = activeInstance
activeInstance = vm
return () => {
activeInstance = prevActiveInstance
}
}
複製程式碼
最後
文章到這就已經差不多了,對 transition
相關的內建元件 <transition>
以及 <transition-group>
的解析也已經是結束了。不同的元件型別,一個抽象元件、一個則會渲染實際節點元素,想要做的事情卻是一樣的,初始化給使用者的 輸入
介面,輸入
後即可得到 輸出
的過渡效果。
前端交流群:731175396,熱烈歡迎各位妹紙,漢紙踴躍加入
個人準備重新撿回自己的公眾號了,之後每週保證一篇高質量好文,感興趣的小夥伴可以關注一波。