深入v-if
的工作原理
<div v-scope="App"></div>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
createApp({
App: {
$template: `
<span v-if="status === 'offline'"> OFFLINE </span>
<span v-else-if="status === 'UNKOWN'"> UNKOWN </span>
<span v-else> ONLINE </span>
`,
}
status: 'online'
}).mount('[v-scope]')
</script>
人肉單步調試:
- 調用
createApp
根據入參生成全局作用域rootScope
,創建根上下文rootCtx
; - 調用
mount
爲<div v-scope="App"></div>
構建根塊對象rootBlock
,並將其作爲模板執行解析處理; - 解析時識別到
v-scope
屬性,以全局作用域rootScope
爲基礎運算得到局部作用域scope
,並以根上下文rootCtx
爲藍本一同構建新的上下文ctx
,用於子節點的解析和渲染; - 獲取
$template
屬性值並生成HTML元素; - 深度優先遍歷解析子節點(調用
walkChildren
); - 解析
<span v-if="status === 'offline'"> OFFLINE </span>
解析<span v-if="status === 'offline'"> OFFLINE </span>
書接上一回,我們繼續人肉單步調試:
- 識別元素帶上
v-if
屬性,調用_if
原指令對元素及兄弟元素進行解析; - 將附帶
v-if
和跟緊其後的附帶v-else-if
和v-else
的元素轉化爲邏輯分支記錄; - 循環遍歷分支,併爲邏輯運算結果爲
true
的分支創建塊對象並銷燬原有分支的塊對象(首次渲染沒有原分支的塊對象),並提交渲染任務到異步隊列。
// 文件 ./src/walk.ts
// 爲便於理解,我對代碼進行了精簡
export const walk = (node: Node, ctx: Context): ChildNode | null | void {
const type = node.nodeType
if (type == 1) {
// node爲Element類型
const el = node as Element
let exp: string | null
if ((exp = checkAttr(el, 'v-if'))) {
return _if(el, exp, ctx) // 返回最近一個沒有`v-else-if`或`v-else`的兄弟節點
}
}
}
// 文件 ./src/directives/if.ts
interface Branch {
exp?: string | null // 該分支邏輯運算表達式
el: Element // 該分支對應的模板元素,每次渲染時會以該元素爲模板通過cloneNode複製一個實例插入到DOM樹中
}
export const _if = (el: Element, exp: string, ctx: Context) => {
const parent = el.parentElement!
/* 錨點元素,由於v-if、v-else-if和v-else標識的元素可能在某個狀態下都不位於DOM樹上,
* 因此通過錨點元素標記插入點的位置信息,當狀態發生變化時則可以將目標元素插入正確的位置。
*/
const anchor = new Comment('v-if')
parent.insertBefore(anchor, el)
// 邏輯分支,並將v-if標識的元素作爲第一個分支
const branches: Branch[] = [
{
exp,
el
}
]
/* 定位v-else-if和v-else元素,並推入邏輯分支中
* 這裏沒有控制v-else-if和v-else的出現順序,因此我們可以寫成
* <span v-if="status=0"></span><span v-else></span><span v-else-if="status === 1"></span>
* 但效果爲變成<span v-if="status=0"></span><span v-else></span>,最後的分支永遠沒有機會匹配。
*/
let elseEl: Element | null
let elseExp: string | null
while ((elseEl = el.nextElementSibling)) {
elseExp = null
if (
checkAttr(elseEl, 'v-else') === '' ||
(elseExp = checkAttr(elseEl, 'v-else-if'))
) {
// 從在線模板移除分支節點
parent.removeChild(elseEl)
branches.push({ exp: elseExp, el: elseEl })
}
else {
break
}
}
// 保存最近一個不帶`v-else`和`v-else-if`節點作爲下一輪遍歷解析的模板節點
const nextNode = el.nextSibling
// 從在線模板移除帶`v-if`節點
parent.removeChild(el)
let block: Block | undefined // 當前邏輯運算結構爲true的分支對應塊對象
let activeBranchIndex: number = -1 // 當前邏輯運算結構爲true的分支索引
// 若狀態發生變化導致邏輯運算結構爲true的分支索引發生變化,則需要銷燬原有分支對應塊對象(包含中止旗下的副作用函數監控狀態變化,執行指令的清理函數和遞歸觸發子塊對象的清理操作)
const removeActiveBlock = () => {
if (block) {
// 重新插入錨點元素來定位插入點
parent.insertBefore(anchor, block.el)
block.remove()
// 解除對已銷燬的塊對象的引用,讓GC回收對應的JavaScript對象和detached元素
block = undefined
}
}
// 向異步任務對立壓入渲染任務,在本輪Event Loop的Micro Queue執行階段會執行一次
ctx.effect(() => {
for (let i = 0; i < branches.length; i++) {
const { exp, el } = branches[i]
if (!exp || evaluate(ctx.scope, exp)) {
if (i !== activeBranchIndex) {
removeActiveBlock()
block = new Block(el, ctx)
block.insert(parent, anchor)
parent.removeChild(anchor)
activeBranchIndex = i
}
return
}
}
activeBranchIndex = -1
removeActiveBlock()
})
return nextNode
}
下面我們看看子塊對象的構造函數和insert
、remove
方法
// 文件 ./src/block.ts
export class Block {
constuctor(template: Element, parentCtx: Context, isRoot = false) {
if (isRoot) {
// ...
}
else {
// 以v-if、v-else-if和v-else分支的元素作爲模板創建元素實例
this.template = template.cloneNode(true) as Element
}
if (isRoot) {
// ...
}
else {
this.parentCtx = parentCtx
parentCtx.blocks.push(this)
this.ctx = createContext(parentCtx)
}
}
// 由於當前示例沒有用到<template>元素,因此我對代碼進行了刪減
insert(parent: Element, anchor: Node | null = null) {
parent.insertBefore(this.template, anchor)
}
// 由於當前示例沒有用到<template>元素,因此我對代碼進行了刪減
remove() {
if (this.parentCtx) {
// TODO: function `remove` is located at @vue/shared
remove(this.parentCtx.blocks, this)
}
// 移除當前塊對象的根節點,其子孫節點都一併被移除
this.template.parentNode!.removeChild(this.template)
this.teardown()
}
teardown() {
// 先遞歸調用子塊對象的清理方法
this.ctx.blocks.forEach(child => {
child.teardown()
})
// 包含中止副作用函數監控狀態變化
this.ctx.effects.forEach(stop)
// 執行指令的清理函數
this.ctx.cleanups.forEach(fn => fn())
}
}
深入v-for
的工作原理
<div v-scope="App"></div>
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
createApp({
App: {
$template: `
<select>
<option v-for="val of values" v-key="val">
I'm the one of options
</option>
</select>
`,
}
values: [1,2,3]
}).mount('[v-scope]')
</script>
人肉單步調試:
- 調用
createApp
根據入參生成全局作用域rootScope
,創建根上下文rootCtx
; - 調用
mount
爲<div v-scope="App"></div>
構建根塊對象rootBlock
,並將其作爲模板執行解析處理; - 解析時識別到
v-scope
屬性,以全局作用域rootScope
爲基礎運算得到局部作用域scope
,並以根上下文rootCtx
爲藍本一同構建新的上下文ctx
,用於子節點的解析和渲染; - 獲取
$template
屬性值並生成HTML元素; - 深度優先遍歷解析子節點(調用
walkChildren
); - 解析
<option v-for="val in values" v-key="val">I'm the one of options</option>
解析<option v-for="val in values" v-key="val">I'm the one of options</option>
書接上一回,我們繼續人肉單步調試:
- 識別元素帶上
v-for
屬性,調用_for
原指令對該元素解析; - 通過正則表達式提取
v-for
中集合和集合元素的表達式字符串,和key
的表達式字符串; - 基於每個集合元素創建獨立作用域,並創建獨立的塊對象渲染元素。
// 文件 ./src/walk.ts
// 爲便於理解,我對代碼進行了精簡
export const walk = (node: Node, ctx: Context): ChildNode | null | void {
const type = node.nodeType
if (type == 1) {
// node爲Element類型
const el = node as Element
let exp: string | null
if ((exp = checkAttr(el, 'v-for'))) {
return _for(el, exp, ctx) // 返回最近一個沒有`v-else-if`或`v-else`的兄弟節點
}
}
}
// 文件 ./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
}
// 由於我們示例只研究靜態視圖,因此重新渲染的代碼,我們後面再深入瞭解吧
})
return nextNode
}
總結
我們看到在v-if
和v-for
的解析過程中都會生成塊對象,而且是v-if
的每個分支都對應一個塊對象,而v-for
則是每個子元素都對應一個塊對象。其實塊對象不單單是管控DOM操作的單元,而且它是用於表示樹結構不穩定的部分。如節點的增加和刪除,將導致樹結構的不穩定,把這些不穩定的部分打包成獨立的塊對象,並封裝各自構建和刪除時執行資源回收等操作,這樣不僅提高代碼的可讀性也提高程序的運行效率。
v-if
的首次渲染和重新渲染採用同一套邏輯,但v-for
在重新渲染時會採用key
複用元素從而提高效率,可以重新渲染時的算法會複製不少。下一篇我們將深入瞭解v-for
在重新渲染時的工作原理,敬請期待:)