• petite-vue-源码剖析-v-for重新渲染工作原理


    在《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等指令的源码吧!
    尊重原创,转载请注明来自:https://www.cnblogs.com/fsjohnhuang/p/15989941.html 肥仔John

  • 相关阅读:
    go入门学习笔记
    设计模式之模板方法模式详解(上)
    Redis两种持久化方案RDB 和 AOF
    thinkphp6 自定义命令行command使用
    阿里云服务 安装 Node.js
    怎么用一个二维码展示多个内容?二维码汇总一个的方法
    如何用 Kubernetes 自定义资源?
    STM32单片机OLED贪吃蛇游戏记分计时
    LeetCode 第299次周赛 第4题 从树中删除边的最小分数
    千兆光模块和万兆光模块已经过时了吗?
  • 原文地址:https://www.cnblogs.com/fsjohnhuang/p/15989941.html