細談 vue - transition-group 篇

at_1發表於2021-09-09

本篇文章是細談 vue 系列的第四篇,按理說這篇文章是上篇 《細談 vue - transition 篇》中的一個單獨的大章節。然鵝,上篇文章篇幅過長,所以不得已將其單獨拎出來寫成一篇了。對該系列以前的文章感興趣的可以點選以下連結進行傳送

書接上文,上篇文章我們主要介紹了 <transition> 元件對 propsvnode hooks輸入 => 輸出 處理設計,它針對單一元素的 enter 以及 leave 階段進行了過渡效果的封裝處理,使得我們只需關注 cssjs 鉤子函式的業務實現即可。

但是我們在實際開發中,卻終究難逃多個元素都需要進行使用過渡效果進行展示,很顯然,<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>
複製程式碼

效果如下圖

細談 vue - transition-group 篇

接下來,我將帶著大家一起探究一下 <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 基本一致,只是多了一個 tagmoveClass 屬性,刪除了 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 子節點 enterleave 階段存在過渡動畫的時候,則會執行對應的過渡動畫
  • 隨即呼叫原生的 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 鉤子函式裡面,會先獲取上一次的子節點 prevChildrenmoveClass;隨後判斷 children 是否存在以及 children 是否 has move ,若 children 不存在,或者 children 沒有 move 狀態,那麼也沒有必要繼續進行 updatemove 過渡了,直接 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)
複製程式碼

三個函式的處理分別如下

  1. callPendingCbs():判斷每個節點前一幀的過渡動畫是否執行完畢,如果沒有執行完,則提前執行 _moveCb()_enterCb()
  2. recordPosition():記錄每個節點的新位置
  3. 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.movedtrue,則執行 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 等

  1. 新增或者刪除可見的DOM元素
  2. 元素位置改變
  3. 元素尺寸改變 —— 邊距、填充、邊框、寬度和高度
  4. 內容變化,比如使用者在 input 框中輸入文字,文字或者圖片大小改變而引起的計算值寬度和高度改變
  5. 頁面渲染初始化
  6. 瀏覽器視窗尺寸改變 —— resize 事件發生時
  7. 計算 offsetWidth 和 offsetHeight 屬性
  8. 設定 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,熱烈歡迎各位妹紙,漢紙踴躍加入

細談 vue - transition-group 篇

個人準備重新撿回自己的公眾號了,之後每週保證一篇高質量好文,感興趣的小夥伴可以關注一波。

細談 vue - transition-group 篇

相關文章