1. 循环v-for的首次渲染和更新渲染

1.1. v-for的首次渲染

下面我们在静态视图的基础上加入v-for指令,并通过人肉单步调试的方式看看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>

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

当遇到v-for指令时代码会按如下流程执行:

  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和索引的关系,当发生更新渲染时则复用元素

  // 入参source为数组时,则遍历数组的每个元素,并以元素为基础创建独立的作用域
  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, // 元素
    index: number // 元素位于数组中的索引值
  ): 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
}

虽然已经将与首次渲染关联性不强的代码都删除,但由于更新渲染时会将运算结果和上一次渲染(当然包含首次渲染)版本进行对比(diff),所以无法做到完全干净地呈现仅与首次渲染相关的代码。

1.2. 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`,``等都会跟踪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
}

1.2.1. key元素复用算法详解

上述代码最难理解就是通过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`,``等都会跟踪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,因此不用移动元素

1.2.2. 和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次元素

1.3. 小结

影响DOM树结构的v-ifv-for的讲解到此为止,后面我们看看v-bind等指令是如何实现的吧!

Copyright © fsjohnhuang 2022 all right reserved,powered by GitbookFile Modify: 2022-04-26 09:46:33

results matching ""

    No results matching ""