petite-vue-原始碼剖析-v-for重新渲染工作原理
阿新 • • 發佈:2022-03-07
在《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
等指令的原始碼吧!
歡迎新增我的公眾號一起深入探討技術手藝人的那些事!
如果您覺得本文的內容有趣就掃一下吧!捐贈互勉!