Vue3 中 v-if 和 v-show 指令實現的原理 | 源碼解讀

前言

又回到了經典的一句話:「知其然,然後使其然」。相信你們對 Vue 提供 v-ifv-show 指令的使用以及對應場景應該都倒背如流了。可是,我想仍然會有不少同窗對於 v-ifv-show 指令實現的原理存在知識空白。javascript

因此,今天就讓咱們來一塊兒瞭解一番 v-ifv-show 指令實現的原理~前端

v-if

在以前 【Vue3 源碼解讀】從編譯過程,理解靜態節點提高 一文中,我給你們介紹了 Vue 3 的編譯過程,即一個模版會經歷 baseParsetransformgenerate 這三個過程,最後由 generate 生成能夠執行的代碼(render 函數)。vue

這裏,咱們就不從編譯過程開始講解 v-if 指令的 render 函數生成過程了,有興趣瞭解這個過程的同窗,能夠看我以前的文章從編譯過程,理解靜態節點提高java

咱們能夠直接在 Vue3 Template Explore 輸入一個使用 v-if 指令的栗子:node

<div v-if="visible"></div>
複製代碼

而後,由它編譯生成的 render 函數會是這樣:面試

render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_ctx.visible)
    ? (_openBlock(), _createBlock("div", { key: 0 }))
    : _createCommentVNode("v-if", true)
}
複製代碼

能夠看到,一個簡單的使用 v-if 指令的模版編譯生成的 render 函數最終會返回一個三目運算表達式。首先,讓咱們先來認識一下其中幾個變量和函數的意義:前端工程化

  • _ctx 當前組件實例的上下文,即 this
  • _openBlock()_createBlock() 用於構造 Block TreeBlock VNode,它們主要用於靶向更新過程
  • _createCommentVNode() 建立註釋節點的函數,一般用於佔位

顯然,若是當 visiblefalse 的時候,會在當前模版中建立一個註釋節點(也可稱爲佔位節點),反之則建立一個真實節點(即它本身)。例如當 visiblefalse 時渲染到頁面上會是這樣:數組

在 Vue 中不少地方都運用了註釋節點來做爲佔位節點,其目的是在不展現該元素的時候,標識其在頁面中的位置,以便在 patch 的時候將該元素放回該位置。微信

那麼,這個時候我想你們就會拋出一個疑問:當 visible 動態切換 truefalse 的這個過程(派發更新)究竟發生了什麼?markdown

派發更新時 patch,更新節點

若是不瞭解 Vue 3 派發更新和依賴收集過程的同窗,能夠看我以前的文章4k+ 字分析 Vue 3.0 響應式原理(依賴收集和派發更新)

在 Vue 3 中總共有四種指令:v-onv-modelv-showv-if。可是,實際上在源碼中,只針對前面三者進行了特殊處理,這能夠在 packages/runtime-dom/src/directives 目錄下的文件看出:

// packages/runtime-dom/src/directives
|-- driectives
    |-- vModel.ts       ## v-model 指令相關
    |-- vOn.ts          ## v-on 指令相關
    |-- vShow.ts        ## v-show 指令相關
複製代碼

而針對 v-if 指令是直接走派發更新過程時 patch 的邏輯。因爲 v-if 指令訂閱了 visible 變量,因此當 visible 變化的時候,則會觸發派發更新,即 Proxy 對象的 set 邏輯,最後會命中 componentEffect 的邏輯。

固然,咱們也能夠稱這個過程爲組件的更新過程

這裏,咱們來看一下 componentEffect 的定義(僞代碼):

// packages/runtime-core/src/renderer.ts
function componentEffect() {
    if (!instance.isMounted) {
    	....
    } else {
      	...
        const nextTree = renderComponentRoot(instance)
        const prevTree = instance.subTree
        instance.subTree = nextTree
        patch(
          prevTree,
          nextTree,
          hostParentNode(prevTree.el!)!,
          getNextHostNode(prevTree),
          instance,
          parentSuspense,
          isSVG
        )
        ...
      }
  }
}
複製代碼

能夠看到,當組件還沒掛載時,即第一次觸發派發更新會命中 !instance.isMounted 的邏輯。而對於咱們這個栗子,則會命中 else 的邏輯,即組件更新,主要會作三件事:

  • 獲取當前組件對應的組件樹 nextTree 和以前的組件樹 prevTree
  • 更新當前組件實例 instance 的組件樹 subTreenextTree
  • patch 新舊組件樹 prevTreenextTree,若是存在 dynamicChildren,即 Block Tree,則會命中靶向更新的邏輯,顯然咱們此時知足條件

注:組件樹則指的是該組件對應的 VNode Tree。

小結

整體來看,v-if 指令的實現較爲簡單,基於數據驅動的理念,當 v-if 指令對應的 valuefalse 的時候會預先建立一個註釋節點在該位置,而後在 value 發生變化時,命中派發更新的邏輯,對新舊組件樹進行 patch,從而完成使用 v-if 指令元素的動態顯示隱藏。

下面,咱們來看一下 v-show 指令的實現~

v-show

一樣地,對於 v-show 指令,咱們在 Vue 3 在線模版編譯平臺輸入這樣一個栗子:

<div v-show="visible"></div>
複製代碼

那麼,由它編譯生成的 render 函數:

render(_ctx, _cache, $props, $setup, $data, $options) {
  return _withDirectives((_openBlock(), _createBlock("div", null, null, 512 /* NEED_PATCH */)), 
  [
    [_vShow, _ctx.visible]
  ])
}
複製代碼

此時,這個栗子在 visiblefalse 時,渲染到頁面上的 HTML:

從上面的 render 函數能夠看出,不一樣於 v-if 的三目運算符表達式,v-showrender 函數返回的是 _withDirectives() 函數的執行。

前面,咱們已經簡單介紹了 _openBlock()_createBlock() 函數。那麼,除開這二者,接下來咱們逐點分析一下這個 render 函數,首當其衝的是 _vShow

vShow 在生命週期中改變 display 屬性

_vShow 在源碼中則對應着 vShow,它被定義在 packages/runtime-dom/src/directives/vShow。它的職責是對 v-show 指令進行特殊處理,主要表如今 beforeMountmountedupdatedbeforeUnMount 這四個生命週期中:

// packages/runtime-dom/src/directives/vShow.ts
export const vShow: ObjectDirective<VShowElement> = {
  beforeMount(el, { value }, { transition }) {
    el._vod = el.style.display === 'none' ? '' : el.style.display
    if (transition && value) {
      // 處理 tansition 邏輯
      ...
    } else {
      setDisplay(el, value)
    }
  },
  mounted(el, { value }, { transition }) {
    if (transition && value) {
      // 處理 tansition 邏輯
      ...
    }
  },
  updated(el, { value, oldValue }, { transition }) {
    if (!value === !oldValue) return
    if (transition) {
      // 處理 tansition 邏輯
      ...
    } else {
      setDisplay(el, value)
    }
  },
  beforeUnmount(el, { value }) {
    setDisplay(el, value)
  }
}
複製代碼

對於 v-show 指令會處理兩個邏輯:普通 v-showtransition 時的 v-show 狀況。一般狀況下咱們只是使用 v-show 指令,命中的就是前者

這裏咱們只對普通 v-show 狀況展開分析。

普通 v-show 狀況,都是調用的 setDisplay() 函數,以及會傳入兩個變量:

  • el 當前使用 v-show 指令的真實元素
  • v-show 指令對應的 value 的值

接着,咱們來看一下 setDisplay() 函數的定義:

function setDisplay(el: VShowElement, value: unknown): void {
  el.style.display = value ? el._vod : 'none'
}
複製代碼

setDisplay() 函數正如它自己命名的語意同樣,是經過改變該元素的 CSS 屬性 display 的值來動態的控制 v-show 綁定的元素的顯示或隱藏。

而且,我想你們可能注意到了,當 valuetrue 的時候,display 是等於的 el.vod,而 el.vod 則等於這個真實元素的 CSS display 屬性(默認狀況下爲空)。因此,當 v-show 對應的 valuetrue 的時候,元素顯示與否是取決於它自己的 CSS display 屬性。

其實,到這裏 v-show 指令的本質在源碼中的體現已經出來了。可是,仍然會留有一些疑問,例如 withDirectives 作了什麼?vShow 在生命週期中對 v-show 指令的處理又是如何運用的?

withDirectives 在 VNode 上增長 dir 屬性

withDirectives() 顧名思義和指令相關,即在 Vue 3 中和指令相關的元素,最後生成的 render 函數都會調用 withDirectives() 處理指令相關的邏輯,vShow 的邏輯做爲 dir 屬性添加VNode 上。

withDirectives() 函數的定義:

// packages/runtime-core/src/directives.ts
export function withDirectives<T extends VNode>( vnode: T, directives: DirectiveArguments ): T {
  const internalInstance = currentRenderingInstance
  if (internalInstance === null) {
    __DEV__ && warn(`withDirectives can only be used inside render functions.`)
    return vnode
  }
  const instance = internalInstance.proxy
  const bindings: DirectiveBinding[] = vnode.dirs || (vnode.dirs = [])
  for (let i = 0; i < directives.length; i++) {
    let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]
    if (isFunction(dir)) {
      ...
    }
    bindings.push({
      dir,
      instance,
      value,
      oldValue: void 0,
      arg,
      modifiers
    })
  }
  return vnode
}
複製代碼

首先,withDirectives() 會獲取當前渲染實例處理邊緣條件,即若是在 render 函數外面使用 withDirectives() 則會拋出異常:

"withDirectives can only be used inside render functions."

而後,在 vnode 上綁定 dirs 屬性,而且遍歷傳入的 directives 數組,而對於咱們這個栗子 directives 就是:

[
  [_vShow, _ctx.visible]
]
複製代碼

顯然此時只會迭代一次(數組長度爲 1)。而且從 render 傳入的 參數能夠知道,從 directives 上解構出的 dir 指的是 _vShow,即咱們上面介紹的 vShow。因爲 vShow 是一個對象,因此會從新構造(bindings.push())一個 dirVNode.dir

VNode.dir 的做用體如今 vShow 在生命週期改變元素的 CSS display 屬性,而這些生命週期會做爲派發更新的結束回調被調用

接下來,咱們一塊兒來看看其中的調用細節~

派發更新時 patch,註冊 postRenderEffect 事件

相信你們應該都知道 Vue 3 提出了 patchFlag 的概念,其用來針對不一樣的場景來執行對應的 patch 邏輯。那麼,對於上面這個栗子,咱們會命中 patchElement 的邏輯。

而對於 v-show 之類的指令來講,因爲 Vnode.dir 上綁定了處理元素 CSS display 屬性的相關邏輯( vShow 定義好的生命週期處理)。因此,此時 patchElement() 中會爲註冊一個 postRenderEffect 事件。

// packages/runtime-core/src/renderer.ts
const patchElement = ( n1: VNode, n2: VNode, parentComponent: ComponentInternalInstance | null, parentSuspense: SuspenseBoundary | null, isSVG: boolean, optimized: boolean ) => {
    ...
    // 此時 dirs 是存在的
    if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
      // 註冊 postRenderEffect 事件
      queuePostRenderEffect(() => {
        vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
        dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated')
      }, parentSuspense)
    }
    ...
  }
複製代碼

這裏咱們簡單分析一下 queuePostRenderEffect()invokeDirectiveHook() 函數:

  • queuePostRenderEffect()postRenderEffect 事件註冊是經過 queuePostRenderEffect() 函數完成的,由於 effect 都是維護在一個隊列中(爲了保持 effect 的有序),這裏是 pendingPostFlushCbs,因此對於 postRenderEffect 也是同樣的會被進隊

  • invokeDirectiveHook(),因爲 vShow 封裝了對元素 CSS display 屬性的處理,因此 invokeDirective() 的本職是調用指令相關的生命週期處理。而且,須要注意的是此時是更新邏輯,因此只會調用 vShow 中定義好的 update 生命週期

flushJobs 的結束(finally)調用 postRenderEffect

到這裏,咱們已經圍繞 v-Show 介紹完了 vShowwithDirectivespostRenderEffect 等概念。可是,萬事具有隻欠東風,還缺乏一個調用 postRenderEffect 事件的時機,即處理 pendingPostFlushCbs 隊列的時機。

在 Vue 3 中 effect 至關於 Vue 2.x 的 watch。雖然變了個命名,可是仍然保持着同樣的調用方式,都是調用的 run() 函數,而後由 flushJobs() 執行 effect 隊列。而調用 postRenderEffect 事件的時機則是在執行隊列的結束

flushJobs() 函數的定義:

// packages/runtime-core/src/scheduler.ts
function flushJobs(seen?: CountMap) {
  isFlushPending = false
  isFlushing = true
  if (__DEV__) {
    seen = seen || new Map()
  }
  flushPreFlushCbs(seen)
  // 對 effect 進行排序
  queue.sort((a, b) => getId(a!) - getId(b!))
  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      // 執行渲染 effect
      const job = queue[flushIndex]
      if (job) {
        ...
      }
    }
  } finally {
    ...
    // postRenderEffect 事件的執行時機
    flushPostFlushCbs(seen)
    ...
  }
}
複製代碼

flushJobs() 函數中會執行三種 effect 隊列,分別是 preRenderEffectrenderEffectpostRenderEffect,它們各自對應 flushPreFlushCbs()queueflushPostFlushCbs

那麼,顯然 postRenderEffect 事件的調用時機是在 flushPostFlushCbs()。而 flushPostFlushCbs() 內部則會遍歷 pendingPostFlushCbs 隊列,即執行以前在 patchElement 時註冊的 postRenderEffect 事件,本質上就是執行

updated(el, { value, oldValue }, { transition }) {
  if (!value === !oldValue) return
  if (transition) {
    ...
  } else {
    // 改變元素的 CSS display 屬性
    setDisplay(el, value)
  }
},
複製代碼

小結

相比較 v-if 簡單幹脆地經過 patch 直接更新元素,v-show 的處理就略顯複雜。這裏咱們從新梳理一下整個過程:

  • 首先,由 widthDirectives 來生成最終的 VNode。它會給 VNode 上綁定 dir 屬性,即 vShow 定義的在生命週期中對元素 CSS display 屬性的處理
  • 其次,在 patchElement 的階段,會註冊 postRenderEffect 事件,用於調用 vShow 定義的 update 生命週期處理 CSS display 屬性的邏輯
  • 最後,在派發更新的結束,調用 postRenderEffect 事件,即執行 vShow 定義的 update 生命週期,更改元素的 CSS display 屬性

結語

v-ifv-show 實現的原理,你能夠用一兩句話歸納,也能夠用一大堆話歸納。若是牽扯到面試場景下,我更欣賞後者,由於這說明你研究的夠深以及理解能力夠強。而且,當你瞭解一個指令的處理過程後,對於其餘指令 v-onv-model 的處理,相信也能夠很容易地得出結論。最後,若是文中存在表達不當或錯誤的地方,歡迎各位同窗提 Issue~

我是五柳,喜歡創新、搗鼓源碼,專一於 Vue3 源碼、Vite 源碼、前端工程化等技術分享,歡迎關注個人微信公衆號:Code center

相關文章
相關標籤/搜索