petite-vue源码剖析-v-if和v-for的工作原理
2022/3/7 17:15:21
本文主要是介绍petite-vue源码剖析-v-if和v-for的工作原理,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
深入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
在重新渲染时的工作原理,敬请期待:)
这篇关于petite-vue源码剖析-v-if和v-for的工作原理的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-16Vue3资料:新手入门必读教程
- 2024-11-16Vue3资料:新手入门全面指南
- 2024-11-16Vue资料:新手入门完全指南
- 2024-11-16Vue项目实战:新手入门指南
- 2024-11-16React Hooks之useEffect案例详解
- 2024-11-16useRef案例详解:React中的useRef使用教程
- 2024-11-16React Hooks之useState案例详解
- 2024-11-16Vue入门指南:从零开始搭建第一个Vue项目
- 2024-11-16Vue3学习:新手入门教程与实践指南
- 2024-11-16Vue3学习:从入门到初级实战教程