petite-vue原始碼剖析-v-if和v-for的工作原理

肥仔John發表於2022-03-07

深入v-if的工作原理

<div v-scope="App"></div>

<script type="module">
  import { createApp } from 'https://unpkg.com/petite-vue?module'

  createApp({
    App: {
      $template: `
      <span v-if="status === 'offline'"> OFFLINE </span>
      <span v-else-if="status === 'UNKOWN'"> UNKOWN </span>
      <span v-else> ONLINE </span>
      `,
    }
    status: 'online'
  }).mount('[v-scope]')
</script>

人肉單步除錯:

  1. 呼叫createApp根據入參生成全域性作用域rootScope,建立根上下文rootCtx
  2. 呼叫mount<div v-scope="App"></div>構建根塊物件rootBlock,並將其作為模板執行解析處理;
  3. 解析時識別到v-scope屬性,以全域性作用域rootScope為基礎運算得到區域性作用域scope,並以根上下文rootCtx為藍本一同構建新的上下文ctx,用於子節點的解析和渲染;
  4. 獲取$template屬性值並生成HTML元素;
  5. 深度優先遍歷解析子節點(呼叫walkChildren);
  6. 解析<span v-if="status === 'offline'"> OFFLINE </span>

解析<span v-if="status === 'offline'"> OFFLINE </span>

書接上一回,我們繼續人肉單步除錯:

  1. 識別元素帶上v-if屬性,呼叫_if原指令對元素及兄弟元素進行解析;
  2. 將附帶v-if和跟緊其後的附帶v-else-ifv-else的元素轉化為邏輯分支記錄;
  3. 迴圈遍歷分支,併為邏輯運算結果為true的分支建立塊物件並銷燬原有分支的塊物件(首次渲染沒有原分支的塊物件),並提交渲染任務到非同步佇列。
// 檔案 ./src/walk.ts

// 為便於理解,我對程式碼進行了精簡
export const walk = (node: Node, ctx: Context): ChildNode | null | void {
  const type = node.nodeType
  if (type == 1) {
    // node為Element型別
    const el = node as Element

    let exp: string | null

    if ((exp = checkAttr(el, 'v-if'))) {
      return _if(el, exp, ctx) // 返回最近一個沒有`v-else-if`或`v-else`的兄弟節點
    }
  }
}
// 檔案 ./src/directives/if.ts

interface Branch {
  exp?: string | null // 該分支邏輯運算表示式
  el: Element // 該分支對應的模板元素,每次渲染時會以該元素為模板通過cloneNode複製一個例項插入到DOM樹中
}

export const _if = (el: Element, exp: string, ctx: Context) => {
  const parent = el.parentElement!
  /* 錨點元素,由於v-if、v-else-if和v-else標識的元素可能在某個狀態下都不位於DOM樹上,
   * 因此通過錨點元素標記插入點的位置資訊,當狀態發生變化時則可以將目標元素插入正確的位置。
   */
  const anchor = new Comment('v-if')
  parent.insertBefore(anchor, el)

  // 邏輯分支,並將v-if標識的元素作為第一個分支
  const branches: Branch[] = [
    {
      exp, 
      el
    }
  ]

  /* 定位v-else-if和v-else元素,並推入邏輯分支中
   * 這裡沒有控制v-else-if和v-else的出現順序,因此我們可以寫成
   * <span v-if="status=0"></span><span v-else></span><span v-else-if="status === 1"></span>
   * 但效果為變成<span v-if="status=0"></span><span v-else></span>,最後的分支永遠沒有機會匹配。
   */
  let elseEl: Element | null
  let elseExp: string | null
  while ((elseEl = el.nextElementSibling)) {
    elseExp = null
    if (
      checkAttr(elseEl, 'v-else') === '' ||
      (elseExp = checkAttr(elseEl, 'v-else-if'))
    ) {
      // 從線上模板移除分支節點
      parent.removeChild(elseEl)
      branches.push({ exp: elseExp, el: elseEl })
    }
    else {
      break
    }
  }

  // 儲存最近一個不帶`v-else`和`v-else-if`節點作為下一輪遍歷解析的模板節點
  const nextNode = el.nextSibling
  // 從線上模板移除帶`v-if`節點
  parent.removeChild(el)

  let block: Block | undefined // 當前邏輯運算結構為true的分支對應塊物件
  let activeBranchIndex: number = -1 // 當前邏輯運算結構為true的分支索引

  // 若狀態發生變化導致邏輯運算結構為true的分支索引發生變化,則需要銷燬原有分支對應塊物件(包含中止旗下的副作用函式監控狀態變化,執行指令的清理函式和遞迴觸發子塊物件的清理操作)
  const removeActiveBlock = () => {
    if (block) {
      // 重新插入錨點元素來定位插入點
      parent.insertBefore(anchor, block.el)
      block.remove()
      // 解除對已銷燬的塊物件的引用,讓GC回收對應的JavaScript物件和detached元素
      block = undefined
    }
  }

  // 向非同步任務對立壓入渲染任務,在本輪Event Loop的Micro Queue執行階段會執行一次
  ctx.effect(() => {
    for (let i = 0; i < branches.length; i++) {
      const { exp, el } = branches[i]
      if (!exp || evaluate(ctx.scope, exp)) {
        if (i !== activeBranchIndex) {
          removeActiveBlock()
          block = new Block(el, ctx)
          block.insert(parent, anchor)
          parent.removeChild(anchor)
          activeBranchIndex = i
        }
        return
      }
    }

    activeBranchIndex = -1
    removeActiveBlock()
  })

  return nextNode
}

下面我們看看子塊物件的建構函式和insertremove方法

// 檔案 ./src/block.ts

export class Block {
  constuctor(template: Element, parentCtx: Context, isRoot = false) {
    if (isRoot) {
      // ...
    }
    else {
      // 以v-if、v-else-if和v-else分支的元素作為模板建立元素例項
      this.template = template.cloneNode(true) as Element
    }

    if (isRoot) {
      // ...
    }
    else {
      this.parentCtx = parentCtx
      parentCtx.blocks.push(this)
      this.ctx = createContext(parentCtx)
    }
  }
  // 由於當前示例沒有用到<template>元素,因此我對程式碼進行了刪減
  insert(parent: Element, anchor: Node | null = null) {
    parent.insertBefore(this.template, anchor)
  }

  // 由於當前示例沒有用到<template>元素,因此我對程式碼進行了刪減
  remove() {
    if (this.parentCtx) {
      // TODO: function `remove` is located at @vue/shared
      remove(this.parentCtx.blocks, this)
    }
    // 移除當前塊物件的根節點,其子孫節點都一併被移除
    this.template.parentNode!.removeChild(this.template) 
    this.teardown()
  }

  teardown() {
    // 先遞迴呼叫子塊物件的清理方法
    this.ctx.blocks.forEach(child => {
      child.teardown()
    })
    // 包含中止副作用函式監控狀態變化
    this.ctx.effects.forEach(stop)
    // 執行指令的清理函式
    this.ctx.cleanups.forEach(fn => fn())
  }
}

深入v-for的工作原理

<div v-scope="App"></div>

<script type="module">
  import { createApp } from 'https://unpkg.com/petite-vue?module'

  createApp({
    App: {
      $template: `
      <select>
        <option v-for="val of values" v-key="val">
          I'm the one of options
        </option>
      </select>
      `,
    }
    values: [1,2,3]
  }).mount('[v-scope]')
</script>

人肉單步除錯:

  1. 呼叫createApp根據入參生成全域性作用域rootScope,建立根上下文rootCtx
  2. 呼叫mount<div v-scope="App"></div>構建根塊物件rootBlock,並將其作為模板執行解析處理;
  3. 解析時識別到v-scope屬性,以全域性作用域rootScope為基礎運算得到區域性作用域scope,並以根上下文rootCtx為藍本一同構建新的上下文ctx,用於子節點的解析和渲染;
  4. 獲取$template屬性值並生成HTML元素;
  5. 深度優先遍歷解析子節點(呼叫walkChildren);
  6. 解析<option v-for="val in values" v-key="val">I'm the one of options</option>

解析<option v-for="val in values" v-key="val">I'm the one of options</option>

書接上一回,我們繼續人肉單步除錯:

  1. 識別元素帶上v-for屬性,呼叫_for原指令對該元素解析;
  2. 通過正規表示式提取v-for中集合和集合元素的表示式字串,和key的表示式字串;
  3. 基於每個集合元素建立獨立作用域,並建立獨立的塊物件渲染元素。
// 檔案 ./src/walk.ts

// 為便於理解,我對程式碼進行了精簡
export const walk = (node: Node, ctx: Context): ChildNode | null | void {
  const type = node.nodeType
  if (type == 1) {
    // node為Element型別
    const el = node as Element

    let exp: string | null

    if ((exp = checkAttr(el, 'v-for'))) {
      return _for(el, exp, ctx) // 返回最近一個沒有`v-else-if`或`v-else`的兄弟節點
    }
  }
}
// 檔案 ./src/directives/for.ts

/* [\s\S]*表示識別空格字元和非空格字元若干個,預設為貪婪模式,即 `(item, index) in value` 就會匹配整個字串。
 * 修改為[\s\S]*?則為懶惰模式,即`(item, index) in value`只會匹配`(item, index)`
 */
const forAliasRE = /([\s\S]*?)\s+(?:in)\s+([\s\S]*?)/
// 用於移除`(item, index)`中的`(`和`)`
const stripParentRE= /^\(|\)$/g
// 用於匹配`item, index`中的`, index`,那麼就可以抽取出value和index來獨立處理
const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/

type KeyToIndexMap = Map<any, number>

// 為便於理解,我們假設只接受`v-for="val in values"`的形式,並且所有入參都是有效的,對入參有效性、解構等程式碼進行了刪減
export const _for = (el: Element, exp: string, ctx: Context) => {
  // 通過正規表示式抽取表示式字串中`in`兩側的子表示式字串
  const inMatch = exp.match(forAliasRE)

  // 儲存下一輪遍歷解析的模板節點
  const nextNode = el.nextSibling

  // 插入錨點,並將帶`v-for`的元素從DOM樹移除
  const parent = el.parentElement!
  const anchor = new Text('')
  parent.insertBefore(anchor, el)
  parent.removeChild(el)

  const sourceExp = inMatch[2].trim() // 獲取`(item, index) in value`中`value`
  let valueExp = inMatch[1].trim().replace(stripParentRE, '').trim() // 獲取`(item, index) in value`中`item, index`
  let indexExp: string | undefined

  let keyAttr = 'key'
  let keyExp = 
    el.getAttribute(keyAttr) ||
    el.getAttribute(keyAttr = ':key') ||
    el.getAttribute(keyAttr = 'v-bind:key')
  if (keyExp) {
    el.removeAttribute(keyExp)
    // 將表示式序列化,如`value`序列化為`"value"`,這樣就不會參與後面的表示式運算
    if (keyAttr === 'key') keyExp = JSON.stringify(keyExp)
  }

  let match
  if (match = valueExp.match(forIteratorRE)) {
    valueExp = valueExp.replace(forIteratorRE, '').trim() // 獲取`item, index`中的item
    indexExp = match[1].trim()  // 獲取`item, index`中的index
  }

  let mounted = false // false表示首次渲染,true表示重新渲染
  let blocks: Block[]
  let childCtxs: Context[]
  let keyToIndexMap: KeyToIndexMap // 用於記錄key和索引的關係,當發生重新渲染時則複用元素

  const createChildContexts = (source: unknown): [Context[], KeyToIndexMap] => {
    const map: KeyToIndexMap = new Map()
    const ctxs: Context[] = []

    if (isArray(source)) {
      for (let i = 0; i < source.length; i++) {
        ctxs.push(createChildContext(map, source[i], i))
      }
    }  

    return [ctxs, map]
  }

  // 以集合元素為基礎建立獨立的作用域
  const createChildContext = (
    map: KeyToIndexMap,
    value: any, // the item of collection
    index: number // the index of item of collection
  ): Context => {
    const data: any = {}
    data[valueExp] = value
    indexExp && (data[indexExp] = index)
    // 為每個子元素建立獨立的作用域
    const childCtx = createScopedContext(ctx, data)
    // key表示式在對應子元素的作用域下運算
    const key = keyExp ? evaluate(childCtx.scope, keyExp) : index
    map.set(key, index)
    childCtx.key = key

    return childCtx
  }

  // 為每個子元素建立塊物件
  const mountBlock = (ctx: Conext, ref: Node) => {
    const block = new Block(el, ctx)
    block.key = ctx.key
    block.insert(parent, ref)
    return block
  }

  ctx.effect(() => {
    const source = evaluate(ctx.scope, sourceExp) // 運算出`(item, index) in items`中items的真實值
    const prevKeyToIndexMap = keyToIndexMap
    // 生成新的作用域,並計算`key`,`:key`或`v-bind:key`
    ;[childCtxs, keyToIndexMap] = createChildContexts(source)
    if (!mounted) {
      // 為每個子元素建立塊物件,解析子元素的子孫元素後插入DOM樹
      blocks = childCtxs.map(s => mountBlock(s, anchor))
      mounted = true
    }
    // 由於我們示例只研究靜態檢視,因此重新渲染的程式碼,我們後面再深入瞭解吧
  })

  return nextNode
}

總結

我們看到在v-ifv-for的解析過程中都會生成塊物件,而且是v-if的每個分支都對應一個塊物件,而v-for則是每個子元素都對應一個塊物件。其實塊物件不單單是管控DOM操作的單元,而且它是用於表示樹結構不穩定的部分。如節點的增加和刪除,將導致樹結構的不穩定,把這些不穩定的部分打包成獨立的塊物件,並封裝各自構建和刪除時執行資源回收等操作,這樣不僅提高程式碼的可讀性也提高程式的執行效率。

v-if的首次渲染和重新渲染採用同一套邏輯,但v-for在重新渲染時會採用key複用元素從而提高效率,可以重新渲染時的演算法會複製不少。下一篇我們將深入瞭解v-for在重新渲染時的工作原理,敬請期待:)

相關文章