阅读 153

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,因此不用移动元素

后续

和DOM节点增删相关的操作我们已经了解得差不多了,后面我们一起阅读关于事件绑定、属性和v-modal等指令的源码吧!


作者:肥仔John
链接:https://juejin.cn/post/7072299730805456927


文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐