VueJS源碼學習——元素在插入和移出 dom 時的過渡邏輯

src/transition

原文地址
項目地址css

關於 vue 中使用 transition 效果,官網上的解釋以下:vue

With Vue.js’ transition system you can apply automatic transition effects when elements are inserted into or removed from the DOM. Vue.js will automatically add/remove CSS classes at appropriate times to trigger CSS transitions or animations for you, and you can also provide JavaScript hook functions to perform custom DOM manipulations during the transition.git

當元素插入到 DOM 樹或者從 DOM 樹中移除的時候, transition 屬性提供變換的效果,可使用 css 來定義變化效果,也可使用 JS 來定義github

src/transition/index.js

import {
  before,
  remove,
  transitionEndEvent
} from '../util/index'

/**
 * Append with transition.
 *
 * @param {Element} el
 * @param {Element} target
 * @param {Vue} vm
 * @param {Function} [cb]
 */

export function appendWithTransition (el, target, vm, cb) {
  applyTransition(el, 1, function () {
    target.appendChild(el)
  }, vm, cb)
}
...

首先第一個函數是將元素插入 DOM, 函數實現調用了 applyTransition, 實現代碼以下:瀏覽器

/**
 * Apply transitions with an operation callback.
 *
 * @param {Element} el
 * @param {Number} direction
 *                  1: enter
 *                 -1: leave
 * @param {Function} op - the actual DOM operation
 * @param {Vue} vm
 * @param {Function} [cb]
 */

export function applyTransition (el, direction, op, vm, cb) {
  var transition = el.__v_trans
  if (
    !transition ||
    // skip if there are no js hooks and CSS transition is
    // not supported
    (!transition.hooks && !transitionEndEvent) ||
    // skip transitions for initial compile
    !vm._isCompiled ||
    // if the vm is being manipulated by a parent directive
    // during the parent's compilation phase, skip the
    // animation.
    (vm.$parent && !vm.$parent._isCompiled)
  ) {
    op()
    if (cb) cb()
    return
  }
  var action = direction > 0 ? 'enter' : 'leave'
  transition[action](op, cb)
}

寫的好的代碼就是文檔,從註釋和命名上就能很好的理解這個函數的做用, el 是要操做的元素, direction 表明是插入仍是刪除, op 表明具體的操做方法函數, vm 從以前的代碼或者官方文檔能夠知道指 vue 實例對象, cb 是回調函數app

vue 將解析後的transition做爲 DOM 元素的屬性 __v_trans ,這樣每次操做 DOM 的時候都會作如下判斷:異步

  • 若是元素沒有被定義了 transitionasync

  • 若是元素沒有 jshook 且 css transition 的定義不支持ide

  • 若是元素尚未編譯完成函數

  • 若是元素有父元素且父元素沒有編譯完成

存在以上其中一種狀況的話則直接執行操做方法 op 而不作變化,不然執行:

var action = direction > 0 ? 'enter' : 'leave'
transition[action](op, cb)

除了添加,還有插入和刪除兩個操做方法:

export function beforeWithTransition (el, target, vm, cb) {
  applyTransition(el, 1, function () {
    before(el, target)
  }, vm, cb)
}

export function removeWithTransition (el, vm, cb) {
  applyTransition(el, -1, function () {
    remove(el)
  }, vm, cb)
}

那麼 transitoin 即 el.__v_trans 是怎麼實現的,這個還得繼續深挖

src/transition/queue.js

import { nextTick } from '../util/index'

let queue = []
let queued = false

/**
 * Push a job into the queue.
 *
 * @param {Function} job
 */

export function pushJob (job) {
  queue.push(job)
  if (!queued) {
    queued = true
    nextTick(flush)
  }
}

/**
 * Flush the queue, and do one forced reflow before
 * triggering transitions.
 */

function flush () {
  // Force layout
  var f = document.documentElement.offsetHeight
  for (var i = 0; i < queue.length; i++) {
    queue[i]()
  }
  queue = []
  queued = false
  // dummy return, so js linters don't complain about
  // unused variable f
  return f
}

這是 transition 三個文件中的第二個,從字面量上理解是一個隊列,從代碼上看實現的是一個任務隊列,每當調用 pushJob 的時候,都會往任務隊列 queue 裏面推一個任務,而且有一個標識queued, 若是爲 false 則會在 nextTick 的時候將 queued 置爲 true同時調用 flush 方法,這個方法會執行全部在任務隊列 queue 的方法,並將 queued 置爲 false

還記得 nextTick 的實現嗎?實如今 src/util/env 中:

/**
 * Defer a task to execute it asynchronously. Ideally this
 * should be executed as a microtask, so we leverage
 * MutationObserver if it's available, and fallback to
 * setTimeout(0).
 *
 * @param {Function} cb
 * @param {Object} ctx
 */

export const nextTick = (function () {
  var callbacks = []
  var pending = false
  var timerFunc
  function nextTickHandler () {
    pending = false
    var copies = callbacks.slice(0)
    callbacks = []
    for (var i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }
  /* istanbul ignore if */
  if (typeof MutationObserver !== 'undefined') {
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(counter)
    observer.observe(textNode, {
      characterData: true
    })
    timerFunc = function () {
      counter = (counter + 1) % 2
      textNode.data = counter
    }
  } else {
    timerFunc = setTimeout
  }
  return function (cb, ctx) {
    var func = ctx
      ? function () { cb.call(ctx) }
      : cb
    callbacks.push(func)
    if (pending) return
    pending = true
    timerFunc(nextTickHandler, 0)
  }
})()

官網的解釋以下

Defer the callback to be executed after the next DOM update cycle. Use it immediately after you’ve changed some data to wait for the DOM update.

即在下一次 DOM 更新循環中執行回調,用在你須要等待 DOM 節點更新後才能執行的狀況,實現的簡單方法是利用 setTimeout 函數,咱們知道 setTimeout 方法會將回調函數放入時間隊列裏,並在計時結束後放到事件隊列裏執行,從而實現異步執行的功能,固然尤大隻把這種狀況做爲備用選擇,而採用模擬DOM建立並利用觀察者MutationObserver監聽其更新來實現:

var observer = new MutationObserver(nextTickHandler) // 建立一個觀察者
var textNode = document.createTextNode(counter) // 建立一個文本節點
observer.observe(textNode, { // 監聽 textNode 的 characterData 是否爲 true
  characterData: true
})
timerFunc = function () { // 每次調用 nextTick,都會調用timerFunc從而再次更新文本節點的值
  counter = (counter + 1) % 2 // 值一直在0和1中切換,有變化且不重複
  textNode.data = counter
}

不瞭解MutationObserver 和 characterData 的能夠參考MDN的解釋: MutaitionObserver
& CharacterData

mutationObserver 例子

flush 函數聲明變量f: var f = document.documentElement.offsetHeight 從註釋上看應該是強制DOM更新,由於調用offsetHeight的時候會讓瀏覽器從新計算出文檔的滾動高度的緣故吧

src/transition/transition.js

transition 實現了元素過渡變換的邏輯和狀態,Transition 的原型包含了 enter, enterNextTick, enterDone, leave, leaveNextTick, leaveDone 這幾個狀態,以 enter 爲例子:

/**
 * Start an entering transition.
 *
 * 1. enter transition triggered
 * 2. call beforeEnter hook
 * 3. add enter class
 * 4. insert/show element
 * 5. call enter hook (with possible explicit js callback)
 * 6. reflow
 * 7. based on transition type:
 *    - transition:
 *        remove class now, wait for transitionend,
 *        then done if there's no explicit js callback.
 *    - animation:
 *        wait for animationend, remove class,
 *        then done if there's no explicit js callback.
 *    - no css transition:
 *        done now if there's no explicit js callback.
 * 8. wait for either done or js callback, then call
 *    afterEnter hook.
 *
 * @param {Function} op - insert/show the element
 * @param {Function} [cb]
 */

p.enter = function (op, cb) {
  this.cancelPending()
  this.callHook('beforeEnter')
  this.cb = cb
  addClass(this.el, this.enterClass)
  op()
  this.entered = false
  this.callHookWithCb('enter')
  if (this.entered) {
    return // user called done synchronously.
  }
  this.cancel = this.hooks && this.hooks.enterCancelled
  pushJob(this.enterNextTick)
}

cancelPending 只有在 enterleave 裏被調用了,實現以下:

/**
 * Cancel any pending callbacks from a previously running
 * but not finished transition.
 */

p.cancelPending = function () {
  this.op = this.cb = null
  var hasPending = false
  if (this.pendingCssCb) {
    hasPending = true
    off(this.el, this.pendingCssEvent, this.pendingCssCb)
    this.pendingCssEvent = this.pendingCssCb = null
  }
  if (this.pendingJsCb) {
    hasPending = true
    this.pendingJsCb.cancel()
    this.pendingJsCb = null
  }
  if (hasPending) {
    removeClass(this.el, this.enterClass)
    removeClass(this.el, this.leaveClass)
  }
  if (this.cancel) {
    this.cancel.call(this.vm, this.el)
    this.cancel = null
  }
}

調用 cancelPending 取消以前的正在運行的或者等待運行的 js 或 css 變換事件和類名,而後觸發腳本 beforeEnter, 添加 enterClass 類名,執行具體的元素插入操做,將 entered 置爲 false,由於此時尚未完成插入操做,而後執行 callHookWithCb,最後肯定 this.cancel 的值以及進入下一步操做 enterNextTick, 最後操做爲 enterDone

/**
 * The "cleanup" phase of an entering transition.
 */

p.enterDone = function () {
  this.entered = true
  this.cancel = this.pendingJsCb = null
  removeClass(this.el, this.enterClass)
  this.callHook('afterEnter')
  if (this.cb) this.cb()
}
相關文章
相關標籤/搜索