In "petite-vue source code analysis - the working principle of v-if and v-for", we learned about the working principle of v-for
in the static view, and here we will take a deeper look at the update rendering v-for
how it works.
parsing line by line
// 文件 ./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
}
Difficulty breakthrough
The most difficult thing to understand about the above code is through the key
that multiplexes the element.
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)
}
We can understand it through human single-step debugging through examples
Example 1
old view (rendered): 1,2,3
New view (to be rendered): 3,2,1
first round of the cycle
childCtx.key = 1 i = 2 oldIndex = 0 nextBlock = null prevMovedBlock = null
ie
prevMoveBlock === nextBlock
So move the block of the old view to the end, view (rendered): 2,3,1Second round of the cycle
childCtx.key = 2 i = 1 oldIndex = 1
update scope
Third round of the cycle
childCtx.key = 3 i = 0 oldIndex = 2 nextBlock = block(.key=2) prevMovedBlock = block(.key=1)
So move the block of the old view to the front of nextBlock, view (rendered): 3,2,1
Example 2 - There are duplicate keys
old view (rendered): 1,2,2,4
New view (to be rendered): 1,2,4,2
At this time prevKeyToIndexMap.get(2)
return 2
, and the information at index 2 of 1 is covered by the latter.
first round of the cycle
childCtx.key = 2 i = 3 oldIndex = 2 nextBlock = null prevMovedBlock = null
So move the block of the old view to the end, view (rendered): 1,2,4,2
Second round of the cycle
childCtx.key = 4 i = 2 oldIndex = 3 nextBlock = block(.key=2) prevMovedBlock = block(.key=2)
So move the block of the old view to the front of nextBlock, view (rendered): 1,2,4,2
Third round of the cycle
childCtx.key = 2 i = 1 oldIndex = 2 nextBlock = block(.key=4) prevMovedBlock = block(.key=4)
Since
blocks[oldIndex+1] === nextBlock
, there is no need to move the elementFourth round of the cycle
childCtx.key = 1 i = 0 oldIndex = 0
Since
i === oldIndex
, there is no need to move the element
The difference between multiplexing elements and React through key
?
React passes key
multiplexing elements is based on the following algorithm
First traverse old and new elements ( left to right )
If the key is different, it will jump out of the traversal and enter the second round of traversal
- At this time, the position of the old element matched by the variable
lastPlacedIndex
records the lastkey
is used to control the movement of the old element
- At this time, the position of the old element matched by the variable
- If the key is the same but the element type is different, create a new element to replace the old element
- Traverse the remaining untraversed old elements - use
旧元素.key
as the key and旧元素
as the value to store through Map The second pass leaves the new elements untraversed ( left to right )
- Find the old element from the Map, if not, create a new element
If it exists, operate according to the following rules:
- If the position of the old element found from the Map is greater than
lastPlacedIndex
, then assign the position of the old element tolastPlacedIndex
, if the element type is the same, reuse the old element, otherwise create a new element to replace the old one element - If the position of the old element found from the Map is less than
lastPlacedIndex
, it means that the old element moves to the right, if the element type is the same, the old element is reused, otherwise a new element is created to replace the old element (lastPlacedIndex
the value remains unchanged)
- If the position of the old element found from the Map is greater than
- Last remaining untraversed old elements will be removed
The movement judgment in the second traversal is, assuming that the old element on the left of lastPlacedIndex
has been matched with the new element and has been sorted, if the position of the old element is found to be less than lastPlacedIndex
, it means lastPlacedIndex
There are aliens on the left and must be moved to the right.
And petite-vue's algorithm is
- Each time it is rendered, it will be generated with
元素.key
as the key,元素
as the value and stored in the Map, and through theprevKeyToIndexMap
reserved to point to the Map of the previous rendering - Traverse old elements, filter out the elements that will be removed in the current rendering through the current Map, and pay attention to removing
Iterate over new elements ( right to left )
- reuse if the key is the same
- If the key is different, find the old element through the old Map, and insert it in front of the most recent processed element on the right
their difference
petite-vue can't handle the case where the key is the same but the element type is different (it should be said that it is more suitable to not handle it), while React can
// 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>) } }) ) }
- Since petite-vue optimizes duplicate keys, React performs the same judgment and operations on duplicate keys
- petite-vue is to move elements instantly, while React is to move elements after operation, and for the old view is
123
, the new view is312
, petite-vue will move 3 times element, while React only moves the element 2 times
follow-up
We have already understood the operations related to the addition and deletion of DOM nodes. Later, let's read the source code of instructions such as event binding, attributes and v-modal
!
"Anatomy of Petite-Vue Source Code" booklet
"Petite-Vue Source Code Analysis" combines examples to interpret the source code line by line from online rendering, responsive system and sandbox model, and also makes a detailed analysis of the SMI optimization dependency cleaning algorithm using the JS engine in the responsive system. It is definitely an excellent stepping stone before getting started with Vue3 source code. If you like it, remember to forward it and appreciate it!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。