Magren

Magren

Idealist & Garbage maker 🛸
twitter
jike

記一次Vue render的踩坑

前言#

在前幾天做自己的玩具的時候,想要實現類似 Element plus 的 Message 組件,一樣是通過方法調起組件,於是就用到了 render💻,但是在銷毀組件的時候遇到了一點小問題……

思路#

  • Message 組件,使用 transition 標籤控制其掛載以及銷毀動畫
  • message.ts,使用 createVNode 創建 VNode,使用 render 方法掛載到指定 div
  • setTimeout 倒計時設置銷毀該 Message 組件
import Message from './base-message.vue'
import { createVNode, render } from 'vue'

// DOM容器
const div = document.createElement('div')// 創建一個dom容器節點div
document.body.appendChild(div) // 容器追加到body中
div.setAttribute('id', 'message-container') // 為dom添加一個唯一標識類

let isShow = false

let timer: number  // 定時器標識
export default ( type:string, message:string ) => {
  if(isShow){
    return
  }
  const vnode = createVNode(Message, { type, message})
  render(vnode, div) // 將虛擬dom添加到div容器中
  
  isShow = true 

  clearTimeout(timer)
  timer = setTimeout(() => { //移除dom節點
    isShow = false
    const child = document.getElementById('base-message') //根據id獲取到message組件
    if(child){
      div.removeChild(child) //移除message組件
    }

  }, 3000)
}

這個時候問題就來了,在第一次渲染的時候,Message 組件確實展示出來了,在 3s 後也確實刪除掉了對應的 dom,但是後續都渲染不出來這個 message 組件🥲

过程#

從 render 開始打斷點,可以看到 Vue 提供的 render 方法,container 是我們傳入的要掛載的 div,vnode 是我們使用 createVNode 創建的虛擬 dom,也可以看到這個時候代碼走了 patch 方法。

const render: RootRenderFunction = (vnode, container, isSVG) => {
  if (vnode == null) {
    if (container._vnode) {
      unmount(container._vnode, null, null, true)
    }
  } else {
    patch(container._vnode || null, vnode, container, null, null, null, isSVG)
  }
  flushPreFlushCbs()
  flushPostFlushCbs()
  container._vnode = vnode
}

稍為看了下 patch 代碼,根據對 VNode 的 type 判斷以及斷點,可以看到我們的代碼走到了 processComponent 這個方法裡,並且傳了相關的參數

const patch: PatchFn = (
  n1, // 舊虛擬節點
  n2, // 新虛擬節點
  container,
  anchor = null, // 定位錨點DOM,用於往錨點前插入節點
  parentComponent = null,
  parentSuspense = null,
  isSVG = false,
  slotScopeIds = null,
  optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren // 是否啟用 diff 優化
) => {
  // 新舊虛擬節點相同,直接返回,不做 Diff 比較
  if (n1 === n2) {
    return
  }

  // patching & not same type, unmount old tree
  // 新舊虛擬節點不相同(key 和 type 不同),則卸載舊的虛擬節點及其子節點
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    // 卸載舊的虛擬節點及其子節點
    unmount(n1, parentComponent, parentSuspense, true)
    // 將 舊虛擬節點置為 null,保證後面走整個節點的 mount 邏輯
    n1 = null
  }

  // PatchFlags.BAIL 標誌用於指示應該退出 diff 優化
  if (n2.patchFlag === PatchFlags.BAIL) {
    // optimized 置為 false ,在後續的 Diff 過程中不會啟用 diff 優化
    optimized = false
    // 將新虛擬節點的動態子節點數組置為 null,則不會進行 diff 優化
    n2.dynamicChildren = null
  }

  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Text: // 處理文本
      processText(n1, n2, container, anchor)
      break
    case Comment: // 處理註釋
      processCommentNode(n1, n2, container, anchor)
      break
    case Static: // 處理靜態節點
      if (n1 == null) {
        // 掛載靜態節點
        mountStaticNode(n2, container, anchor, isSVG)
      } else if (__DEV__) {
        // 更新靜態節點
        patchStaticNode(n1, n2, container, isSVG)
      }
      break
    case Fragment: // 處理 Fragment 元素
      processFragment(
        n1,
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 處理 ELEMENT 類型的 DOM 元素
        processElement(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        // 處理組件
        processComponent(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (shapeFlag & ShapeFlags.TELEPORT) {
        // 處理 Teleport 組件
        // 調用 Teleport 組件內部的 process 函數,渲染 Teleport 組件的內容
        ;(type as typeof TeleportImpl).process(
          n1 as TeleportVNode,
          n2 as TeleportVNode,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized,
          internals
        )
      } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
        // 處理 Suspense 組件
        // 調用 Suspense 組件內部的 process 函數,渲染 Suspense 組件的內容
        ;(type as typeof SuspenseImpl).process(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized,
          internals
        )
      } else if (__DEV__) {
        warn('Invalid VNode type:', type, `(${typeof type})`)
      }
  }

  // set ref
  // 設置 ref 引用
  if (ref != null && parentComponent) {
    setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
  }
}

在這個方法裡邊,n1 是我們要掛載的 div 裡邊的 VNode,這也是我第一次掛載跟後續掛載組件的不同之處,在第一次掛載組件的時候,n1 是為 null 的,在後面我雖然刪除了組件,但是那也是直接刪除的真實 dom,並沒有刪掉裡邊的 VNode,這樣就導致直接走了 updateComponent 方法,即更新組件。在 updateComponent 方法裡邊,會根據新舊虛擬節點 VNode 上的屬性、指令、子節點等判斷是否需要更新組件,但無論怎樣,離我想要的掛載新 message 組件都相去甚遠😶‍🌫️

  const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    n2.slotScopeIds = slotScopeIds
    if (n1 == null) {
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) { // 首次掛載時 判斷當前要掛載的組件是否是 KeepAlive 組件
          // 激活組件,即將隱藏容器中移動到原容器中
        ;(parentComponent!.ctx as KeepAliveContext).activate(
          n2,
          container,
          anchor,
          isSVG,
          optimized
        )
      } else {
        // 不是 KeepAlive 組件,調用 mountComponent 掛載組件
        mountComponent(
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      }
    } else {
      updateComponent(n1, n2, optimized)
    }
  }

最後#

根據render方法裡也可以看到,如果需要卸載掉虛擬 dom,我們只需要把 vnode 參數傳 null 即可,然後讓虛擬 dom 去更新真實 dom,以此來卸載掉我們的 message 組件,而不是直接刪除真實 dom。

import Message from './base-message.vue'
import { createVNode, render } from 'vue'

// DOM容器
const div = document.createElement('div')// 創建一個dom容器節點div
document.body.appendChild(div) // 容器追加到body中
div.setAttribute('id', 'message-container') // 為dom添加一個唯一標識類

let isShow = false

let timer: number  // 定時器標識
export default ( type:string, message:string ) => {

  if(isShow){
    return
  }

  const vnode = createVNode(Message, { type, message})
  render(vnode, div) // 將虛擬dom添加到div容器中
  isShow = true 
  clearTimeout(timer)
  timer = setTimeout(() => { //移除dom節點
    isShow = false
    render(null, div)
  }, 3000)
}

雖然這個問題不是什麼大問題,直接參考 Element plus 的 Message 組件也可以發現問題在哪裡並直接做修改,但是自己還是挺喜歡瞎折騰這個過程並且在這之後自己可以有一個比較明確的結果和認識😌

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。