在《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
示例2 – 存在重复键
旧视图(已渲染): 1,2,2,4
新视图(待渲染): 1,2,4,2
此时prevKeyToIndexMap.get(2)返回2,而位于索引为1的2的信息被后者覆盖了。
childCtx
.key
= 1
i
= 0
oldIndex
= 0
由于i === oldIndex,因此不用移动元素
和React通过key复用元素的区别?
React通过key复用元素是采取如下算法
第一次遍历新旧元素(左到右) 若key不同即跳出遍历,进入第二轮遍历 此时通过变量lastPlacedIndex记录最后一个key匹配的旧元素位置用于控制旧元素移动若key相同但元素类型不同,则创建新元素替换掉旧元素遍历剩下未遍历的旧元素 – 以旧元素.key为键,旧元素为值通过Map存储第二次遍历剩下未遍历的新元素(左到右) 从Map查找是否存在的旧元素,若没有则创建新元素若存在则按如下规则操作: 若从Map查找的旧元素的位置大于lastPlacedIndex则将旧元素的位置赋值给lastPlacedIndex,若元素类型相同则复用旧元素,否则创建新元素替换掉旧元素若从Map查找的旧元素的位置小于lastPlacedIndex则表示旧元素向右移动,若元素类型相同则复用旧元素,否则创建新元素替换掉旧元素(lastPlacedIndex的值保持不变)最后剩下未遍历的旧元素将被删除
第二次遍历时移动判断是,假定lastPlacedIndex左侧的旧元素已经和新元素匹配且已排序,若发现旧元素的位置小于lastPlacedIndex,则表示lastPlacedIndex左侧有异类必须向右挪动。
而petite-vue的算法是
每次渲染时都会生成以元素.key为键,元素为值通过Map存储,并通过prevKeyToIndexMap保留指向上一次渲染的Map遍历旧元素,通过当前Map筛选出当前渲染中将被移除的元素,并注意移除遍历新元素(右到左) 若key相同则复用若key不同则通过旧Map寻找旧元素,并插入最右最近一个已处理的元素前面
它们的差别
后续
和DOM节点增删相关的操作我们已经了解得差不多了,后面我们一起阅读关于事件绑定、属性和v-modal等指令的源码吧!
尊重原创,转载请注明来自:https://cloud.tencent.com/developer/article/1997339 肥仔John