petite-vue-原始碼剖析-v-for重新渲染工作原理

^_^肥仔John發表於2022-03-10

在《petite-vue原始碼剖析-v-if和v-for的工作原理》我們瞭解到v-for在靜態檢視中的工作原理,而這裡我們將深入瞭解在更新渲染時v-for是如何運作的。

逐行解析

// 檔案 ./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
    }
    else {
      // 更新渲染邏輯!!
      // 根據key移除更新後不存在的元素
      for (let i = 0; i < blocks.length; i++) {
        if (!keyToIndexMap.has(blocks[i].key)) {
          blocks[i].remove()
        }
      }

      const nextBlocks: Block[] = []
      let i = childCtxs.length
      let nextBlock: Block | undefined
      let prevMovedBlock: Block | undefined
      while (i--) {
        const childCtx = childCtxs[i]
        const oldIndex = prevKeyToIndexMap.get(childCtx.key)
        let block
        if (oldIndex == null) {
          // 舊檢視中沒有該元素,因此建立一個新的塊物件
          block = mountBlock(childCtx, newBlock ? newBlock.el : anchor)
        }
        else {
          // 舊檢視中有該元素,元素複用
          block = blocks[oldIndex]
          // 更新作用域,由於元素下的`:value`,`{{value}}`等都會跟蹤scope對應屬性的變化,因此這裡只需要更新作用域上的屬性,即可觸發子元素的更新渲染
          Object.assign(block.ctx.scope, childCtx.scope)
          if (oldIndex != i) {
            // 元素在新舊檢視中的位置不同,需要移動
            if (
              blocks[oldIndex + 1] !== nextBlock ||
              prevMoveBlock === nextBlock
            ) {
              prevMovedBlock = block
              // anchor作為同級子元素的末尾
              block.insert(parent, nextBlock ? nextBlock.el : anchor)
            }
          }
        }
        nextBlocks.unshift(nextBlock = block)
      }
      blocks = nextBlocks
    }
  })

  return nextNode
}

難點突破

上述程式碼最難理解就是通過key複用元素那一段了

const nextBlocks: Block[] = []
let i = childCtxs.length
let nextBlock: Block | undefined
let prevMovedBlock: Block | undefined
while (i--) {
  const childCtx = childCtxs[i]
  const oldIndex = prevKeyToIndexMap.get(childCtx.key)
  let block
  if (oldIndex == null) {
    // 舊檢視中沒有該元素,因此建立一個新的塊物件
    block = mountBlock(childCtx, newBlock ? newBlock.el : anchor)
  }
  else {
    // 舊檢視中有該元素,元素複用
    block = blocks[oldIndex]
    // 更新作用域,由於元素下的`:value`,`{{value}}`等都會跟蹤scope對應屬性的變化,因此這裡只需要更新作用域上的屬性,即可觸發子元素的更新渲染
    Object.assign(block.ctx.scope, childCtx.scope)
    if (oldIndex != i) {
      // 元素在新舊檢視中的位置不同,需要移動
      if (
        /* blocks[oldIndex + 1] !== nextBlock 用於對重複鍵減少沒必要的移動(如舊檢視為1224,新檢視為1242)
         * prevMoveBlock === nextBlock 用於處理如舊檢視為123,新檢視為312時,blocks[oldIndex + 1] === nextBlock導致無法執行元素移動操作
         */
        blocks[oldIndex + 1] !== nextBlock || 
        prevMoveBlock === nextBlock
      ) {
        prevMovedBlock = block
        // anchor作為同級子元素的末尾
        block.insert(parent, nextBlock ? nextBlock.el : anchor)
      }
    }
  }
  nextBlocks.unshift(nextBlock = block)
}

我們可以通過示例通過人肉單步除錯理解

示例1

舊檢視(已渲染): 1,2,3
新檢視(待渲染): 3,2,1

  1. 迴圈第一輪

    childCtx.key = 1
    i = 2
    oldIndex = 0
    nextBlock = null
    prevMovedBlock = null
    

    prevMoveBlock === nextBlock
    於是將舊檢視的block移動到最後,檢視(已渲染): 2,3,1

  2. 迴圈第二輪

    childCtx.key = 2
    i = 1
    oldIndex = 1
    

    更新作用域

  3. 迴圈第三輪

    childCtx.key = 3
    i = 0
    oldIndex = 2
    nextBlock = block(.key=2)
    prevMovedBlock = block(.key=1)
    

    於是將舊檢視的block移動到nextBlock前,檢視(已渲染): 3,2,1

示例2 - 存在重複鍵

舊檢視(已渲染): 1,2,2,4
新檢視(待渲染): 1,2,4,2

此時prevKeyToIndexMap.get(2)返回2,而位於索引為1的2的資訊被後者覆蓋了。

  1. 迴圈第一輪

    childCtx.key = 2
    i = 3
    oldIndex = 2
    nextBlock = null
    prevMovedBlock = null
    

    於是將舊檢視的block移動到最後,檢視(已渲染): 1,2,4,2

  2. 迴圈第二輪

    childCtx.key = 4
    i = 2
    oldIndex = 3
    nextBlock = block(.key=2)
    prevMovedBlock = block(.key=2)
    

    於是將舊檢視的block移動到nextBlock前,檢視(已渲染): 1,2,4,2

  3. 迴圈第三輪

    childCtx.key = 2
    i = 1
    oldIndex = 2
    nextBlock = block(.key=4)
    prevMovedBlock = block(.key=4)
    

    由於blocks[oldIndex+1] === nextBlock,因此不用移動元素

  4. 迴圈第四輪

childCtx.key = 1
i = 0
oldIndex = 0

由於i === oldIndex,因此不用移動元素

和React通過key複用元素的區別?

React通過key複用元素是採取如下演算法

  1. 第一次遍歷新舊元素(左到右)
    1. 若key不同即跳出遍歷,進入第二輪遍歷
      • 此時通過變數lastPlacedIndex記錄最後一個key匹配的舊元素位置用於控制舊元素移動
    2. 若key相同但元素型別不同,則建立新元素替換掉舊元素
  2. 遍歷剩下未遍歷的舊元素 - 以舊元素.key為鍵,舊元素為值通過Map儲存
  3. 第二次遍歷剩下未遍歷的新元素(左到右)
    1. 從Map查詢是否存在的舊元素,若沒有則建立新元素
    2. 若存在則按如下規則操作:
      • 若從Map查詢的舊元素的位置大於lastPlacedIndex則將舊元素的位置賦值給lastPlacedIndex,若元素型別相同則複用舊元素,否則建立新元素替換掉舊元素
      • 若從Map查詢的舊元素的位置小於lastPlacedIndex則表示舊元素向右移動,若元素型別相同則複用舊元素,否則建立新元素替換掉舊元素(lastPlacedIndex的值保持不變)
  4. 最後剩下未遍歷的舊元素將被刪除

第二次遍歷時移動判斷是,假定lastPlacedIndex左側的舊元素已經和新元素匹配且已排序,若發現舊元素的位置小於lastPlacedIndex,則表示lastPlacedIndex左側有異類必須向右挪動。

petite-vue的演算法是

  1. 每次渲染時都會生成以元素.key為鍵,元素為值通過Map儲存,並通過prevKeyToIndexMap保留指向上一次渲染的Map
  2. 遍歷舊元素,通過當前Map篩選出當前渲染中將被移除的元素,並注意移除
  3. 遍歷新元素(右到左)
    1. 若key相同則複用
    2. 若key不同則通過舊Map尋找舊元素,並插入最右最近一個已處理的元素前面

它們的差別

  1. petite-vue無法處理key相同但元素型別不同的情況(應該說不用處理比較適合),而React可以

    // petite-vue
    createApp({
      App: {
        // 根本沒有可能key相同而元素型別不同嘛
        $template: `
        <div v-for="item in items" :key="item.id"></div>
        `
      }
    })
    
    // React
    function App() {
      const items = [...]
      return (
        items.map(item => {
          if (item.type === 'span') {
            return (<span key={item.id}></span>)
          }
          else {
            return (<div key={item.id}></div>)
          }
        })
      )
    }
    
  2. 由於petite-vue對重複key進行優化,而React會對重複key執行同樣的判斷和操作

  3. petite-vue是即時移動元素,而React是運算後再移動元素,並且對於舊檢視為123,新檢視為312而言,petite-vue將移動3次元素,而React僅移動2次元素

後續

和DOM節點增刪相關的操作我們已經瞭解得差不多了,後面我們一起閱讀關於事件繫結、屬性和v-modal等指令的原始碼吧!

相關文章