在模板编译原理中,阐述到模板编译主要分为3个阶段,其中第2个阶段为 transfrom,主要是针对一些指令进行处理,但由于这部分逻辑特别复杂,没有从代码层面阐述。因此这篇博客借助模板编译工具从渲染函数的层面讲解大部分内置指令的作用。理解这篇博客需要对Vue渲染方法以及虚拟节点有一定的了解
1. v-text
这个指令的本质,就是生成了一个textContent属性,当执行render函数时,相应的dom元素会直接使用这个属性值作为文本展示。相关代码截图:
2. v-html
这个指令的实现方式和v-text一致。需要注意的是,v-text使用了toDisplayString方法对展示内容进行的转换,比如将对象转换为json字符串等。但v-html没有做任何处理,因此需要小心xss攻击。
3. v-show
v-show指令相对于v-text与v-html而言,要相对复杂,因为v-show是通过生命周期来实现的。
通过render函数可知,v-show指令最终使用了vShow对象来实现。接下来看看这个对象的源码:
export const vShow: ObjectDirective<VShowElement> = {
beforeMount(el, { value }, { transition }) {
// 挂载前 缓存display值
el._vod = el.style.display === 'none' ? '' : el.style.display
if (transition && value) {
// 如果有动画且展示,则执行beforeEnter动画
transition.beforeEnter(el)
} else {
// 设置显隐
setDisplay(el, value)
}
},
mounted(el, { value }, { transition }) {
if (transition && value) {
// 挂载后,有动画且展示则执行enter动画
transition.enter(el)
}
},
updated(el, { value, oldValue }, { transition }) {
// 更新时
if (!value === !oldValue) return
if (transition) {
// 有动画则执行相应动画且设置显隐
if (value) {
transition.beforeEnter(el)
setDisplay(el, true)
transition.enter(el)
} else {
transition.leave(el, () => {
setDisplay(el, false)
})
}
} else {
// 设置显隐
setDisplay(el, value)
}
},
beforeUnmount(el, { value }) {
setDisplay(el, value)
}
}
function setDisplay(el: VShowElement, value: unknown): void {
// 如果显示,则取缓存值,否则隐藏
el.style.display = value ? el._vod : 'none'
}
通过源码可知,v-show指令本质是在beforeMount以及updated生命周期钩子里对dom元素设置显示隐藏,通过改变display样式属性实现。
4. v-for
v-for在编译结束后,是通过使用renderList方法来实现的。不难猜测,这个方法的目的就是遍历得到一个子节点数组。源码分析:
export function renderList(
source: any,
renderItem: (...args: any[]) => VNodeChild,
cache?: any[],
index?: number
): VNodeChild[] {
let ret: VNodeChild[]
const cached = (cache && cache[index!]) as VNode[] | undefined
if (isArray(source) || isString(source)) {
// 数据源是数组或者字符串,直接遍历生成子节点数组
ret = new Array(source.length)
for (let i = 0, l = source.length; i < l; i++) {
ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
}
} else if (typeof source === 'number') {
if (__DEV__ && !Number.isInteger(source)) {
warn(`The v-for range expect an integer value but got ${source}.`)
}
// 数据源是数字,从1开始遍历
ret = new Array(source)
for (let i = 0; i < source; i++) {
ret[i] = renderItem(i + 1, i, undefined, cached && cached[i])
}
} else if (isObject(source)) {
// 数据源是对象
if (source[Symbol.iterator as any]) {
// 如果是一个可迭代对象,则执行默认的迭代方法
ret = Array.from(source as Iterable<any>, (item, i) =>
renderItem(item, i, undefined, cached && cached[i])
)
} else {
// 普通对象,则遍历所有的属性
const keys = Object.keys(source)
ret = new Array(keys.length)
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
ret[i] = renderItem(source[key], key, i, cached && cached[i])
}
}
} else {
ret = []
}
if (cache) {
cache[index!] = ret
}
return ret
}
简单而言,renderList方法就是针对不同的数据源进行了处理,最终遍历生成一个子节点数组。
5. v-if
v-if指令的表现与v-show相似,但v-if比v-show隐藏更加彻底。当不满足条件时,不会渲染对应的节点,而不是渲染后隐藏。
通过render函数可知,v-if会被编译为一个三元表达式,只有满足条件才会创建对应的节点,不满足则创建一个注释节点。因此当条件变化时,其作用的节点会被销毁或重构。
1). 为什么不推荐v-for与v-if同时使用?
官方文档上曾介绍过不推荐v-for与v-if同时使用,那么为什么呢?假如现在有一个需求,渲染一个列表,每一个列表项的显示隐藏根据每项的数据来确定。模板如下:
<div v-if="item.show" v-for="item in items"></div>
但遗憾的是,这种模板是错误的,虽然从想法上没问题。接下来从render函数来看看是为什么。
从render可知,item.show会从组件上实例查找,因为v-if的优先级比v-for更高,这样变化出现错误。此时只需要借助template标签来提升v-for的优先级即可。
可以看到,当模板改写后,则先渲染列表,然后再进行判断,此时便可以正确显示。
6. v-else和v-else-if
v-if,v-else,v-else-if是可以配套使用的。其本质和v-if一致,不做阐述。
7. v-on
v-on是用于事件监听的指令。由于在编写模板时,指令可以用于dom元素和组件上,因此编译结果也有差别。
1). dom元素
由于dom元素上的事件是通过浏览器触发,因此事件名称最好与浏览器上dom事件名称保持严格一致。
正常使用方式如下:
不推荐示例如下:
可以看出,当click首字母大写后,编译结果是完全不同的,虽然Vue在内部做了兼容处理,但不推荐。
注意:我使用的编译工具由于设置和版本问题,和生产版本的编译结果可能有些许不同,在有些通过vite创建的项目上@Click也会编译为onClick,而不是v-on:Click。
从源码简要分析实现原理:
export function patchEvent(
el: Element & { _vei?: Record<string, Invoker | undefined> },
rawName: string,
prevValue: EventValue | null,
nextValue: EventValue | null,
instance: ComponentInternalInstance | null = null
) {
// vei = vue event invokers
const invokers = el._vei || (el._vei = {})
const existingInvoker = invokers[rawName]
if (nextValue && existingInvoker) {
// patch
existingInvoker.value = nextValue
} else {
// 重点1:获取事件名称和修饰符
const [name, options] = parseName(rawName)
if (nextValue) {
// add
const invoker = (invokers[rawName] = createInvoker(nextValue, instance))
// 重点2:在dom元素上添加原生事件
addEventListener(el, name, invoker, options)
} else if (existingInvoker) {
// remove
removeEventListener(el, name, existingInvoker, options)
invokers[rawName] = undefined
}
}
}
const optionsModifierRE = /(?:Once|Passive|Capture)$/
function parseName(name: string): [string, EventListenerOptions | undefined] {
let options: EventListenerOptions | undefined
if (optionsModifierRE.test(name)) {
// 重点3:解析原生修饰符
options = {}
let m
while ((m = name.match(optionsModifierRE))) {
name = name.slice(0, name.length - m[0].length)
;(options as any)[m[0].toLowerCase()] = true
}
}
// 重点4:解析事件名 onClick -> click | on:click -> click
const event = name[2] === ':' ? name.slice(3) : hyphenate(name.slice(2)) // 首字母小写转换
return [event, options]
}
export function addEventListener(
el: Element,
event: string,
handler: EventListener,
options?: EventListenerOptions
) {
// dom元素添加事件
el.addEventListener(event, handler, options)
}
代码不难理解,重点就是针对dom元素上的v-on指令。先解析其事件名称和修饰符,比如onClick会解析成click,修饰符也是与dom事件的修饰符保持,只有Once,Passive,Capture。当解析完成后,直接将dom元素绑定上对应的事件,因此v-on指令的事件名称尽量与dom的事件名称保持严格一致。
2). 组件
当在一个组件上监听事件时,其事件的触发是开发者能够主动控制的,因此没有像dom元素那么严格的控制。还是和dom一样的示例,结果如下:
首字母小写:
首字母大写:
发现不论首字母大小写,最终首字母都会转换为大写。
分析关键源码:
let handlerName // 事件名称
// 从props获取事件
let handler =
props[(handlerName = toHandlerKey(event))] || // emit('click') -> onClick
props[(handlerName = toHandlerKey(camelize(event)))] // emit('click-btn') -> onClickBtn
if (handler) {
// 存在则直接触发事件
callWithAsyncErrorHandling(
handler,
instance,
ErrorCodes.COMPONENT_EVENT_HANDLER,
args
)
}
// 当存在修饰符once时,事件名称会新增后缀Once,前面handler无法获取到对应事件
const onceHandler = props[handlerName + `Once`]
if (onceHandler) {
if (!instance.emitted) {
instance.emitted = {} as Record<any, boolean>
} else if (instance.emitted[handlerName]) {
// 已经触发过,则不再触发
return
}
// 添加已触发标识
instance.emitted[handlerName] = true
// 触发事件
callWithAsyncErrorHandling(
onceHandler,
instance,
ErrorCodes.COMPONENT_EVENT_HANDLER,
args
)
}
// 首字母大写,添加on前缀
export const toHandlerKey = cacheStringFunction((str: string) =>
str ? `on${capitalize(str)}` : ``
)
// 短横线写法转驼峰写法
const camelizeRE = /-(\w)/g
export const camelize = cacheStringFunction((str: string): string => {
return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))
})
由于v-on指令本质是创建了一个属性传入子组件,因此当触发一个事件时,就从props中去查找这个事件并调用即可。你可能会有疑问,子组件并未声明对应的props来接收父组件的事件传入,这是因为事件被特殊处理了,即便不声明,也会保存到props中。对于once修饰符而言,事件名称在编译结束后会添加Once后缀,因此需要单独处理,且只能触发一次。。
8. v-slot
v-slot指令作用于组件上,下面简要介绍其作用原理。
从上图可知,当使用v-slot插槽时,本质是将插槽内的模板编译成为了一个“插槽对象”,其键值就是插槽的名称。这个对象的属性值都是一个方法,返回一个节点数组,这个节点数组最终会渲染到组件内相应的插槽区域。
上图是一个组件内使用插槽的模板,当使用插槽后,这些插槽相关的模板最终会编译为一个方法。这个方法会去调用编译后的“插槽对象”上的对应方法,获取子节点数组用于渲染。
9. v-pre
v-pre指令的作用就是跳过编译,输入即输出。
10. v-once
v-once的作用是只渲染一次,并且跳过更新。
这个编译的代码里面逻辑整体而言比较好理解。就是将有v-once指令的节点进行缓存,后续当响应式变化重新触发render时,不会重新创建节点,直接获取缓存的节点进行渲染。
这儿对setBlockTracking进行简单阐述。在Vue3的优化渲染中,有一部分虚拟节点被称为block节点,block节点存在一个属性叫dynamicChildren,表示为这个节点的动态子节点,当更新时,只需要去比较这些动态子节点的变化即可,因为静态节点是不会变化的。setBlockTracking(-1)的含义就是接下来创建的节点都被识别为静态节点,不被dynamicChildren属性保存。在这个示例中,v-once只渲染一次,完全可以视作为静态节点。
11. v-memo
v-memo指令的作用就是用于优化。一个元素或者组件依赖于一个数组data,如果data的所有元素在更新前后没有变化,则这个元素或组件不会更新。
编译后主要是有一个withMemo方法,简单看下withMemo的实现原理:
export function withMemo(
memo: any[], // 依赖数据
render: () => VNode<any, any>, // 创建子节点的函数
cache: any[], // 缓存
index: number, // 缓存索引,每使用一次**v-memo**就自增1
) {
const cached = cache[index] as VNode | undefined // 从缓存获取
if (cached && isMemoSame(cached, memo)) {
// 如果有缓存且前后一致,则直接返回缓存
return cached
}
// 获取子节点数组
const ret = render()
// shallow clone
// 绑定依赖数据
ret.memo = memo.slice()
// 缓存并返回
return (cache[index] = ret)
}
export function isMemoSame(cached: VNode, memo: any[]) {
const prev: any[] = cached.memo!
if (prev.length != memo.length) {
// 2次依赖数据长度不一致,则不相同
return false
}
for (let i = 0; i < prev.length; i++) {
if (hasChanged(prev[i], memo[i])) {
// 比较每个元素是否相同 Object.is来判断
return false
}
}
// make sure to let parent block track it when returning cached
if (isBlockTreeEnabled > 0 && currentBlock) {
currentBlock.push(cached)
}
return true
}
withMemo的实现比较简单。简单来说就是比较前后2次依赖数据是否一致,如果一致则直接返回缓存的子节点。当在更新时,如果前后子节点相同,则会直接跳过