在《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
-
循環第一輪
childCtx.key = 1 i = 2 oldIndex = 0 nextBlock = null prevMovedBlock = null
即
prevMoveBlock === nextBlock
於是將舊視圖的block移動到最後,視圖(已渲染): 2,3,1 -
循環第二輪
childCtx.key = 2 i = 1 oldIndex = 1
更新作用域
-
循環第三輪
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的信息被後者覆蓋了。
-
循環第一輪
childCtx.key = 2 i = 3 oldIndex = 2 nextBlock = null prevMovedBlock = null
於是將舊視圖的block移動到最後,視圖(已渲染): 1,2,4,2
-
循環第二輪
childCtx.key = 4 i = 2 oldIndex = 3 nextBlock = block(.key=2) prevMovedBlock = block(.key=2)
於是將舊視圖的block移動到nextBlock前,視圖(已渲染): 1,2,4,2
-
循環第三輪
childCtx.key = 2 i = 1 oldIndex = 2 nextBlock = block(.key=4) prevMovedBlock = block(.key=4)
由於
blocks[oldIndex+1] === nextBlock
,因此不用移動元素 -
循環第四輪
childCtx.key = 1
i = 0
oldIndex = 0
由於i === oldIndex
,因此不用移動元素
後續
和DOM節點增刪相關的操作我們已經瞭解得差不多了,後面我們一起閱讀關於事件綁定、屬性和v-modal
等指令的源碼吧!