細談 vue - transition-group 篇

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

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

可是咱們在實際開發中,卻終究難逃多個元素都須要進行使用過渡效果進行展現,很顯然,<transition> 組件並不能實現個人業務需求。這個時候,vue 內部封裝了 <transition-group> 這麼一個內置組件來知足咱們的須要,它很好的幫助咱們實現了列表的過渡效果。html

1、舉個例子

老樣子,直接先上一個官方的例子前端

<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> 組件的設計java

2、transition-group 實現

<transition> 組件相比,<transition> 是一個抽象組件,且只對單個元素生效。而 <transition-group> 組件實現了列表的過渡,而且它會渲染一個真實的元素節點。node

但他們的設計理念倒是一致的,一樣會給咱們提供一個 props 和一系列鉤子函數給咱們當作 輸入 的接口,內部進行 輸入 => 輸出 的轉換或者說綁定處理瀏覽器

export default {
  props,
  beforeMount () {
    // ...
  },
  render (h: Function) {
    // ...
  },
  updated () {
    // ...
  },
  methods: {
    // ...
  }
}
複製代碼

一、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'
複製代碼

二、render

首先,咱們須要定義一系列變量,方便後續的操做markdown

  • 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)
複製代碼

三、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 屬性的值

四、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,熱烈歡迎各位妹紙,漢紙踊躍加入

相關文章
相關標籤/搜索