本文为原创文章,未获授权禁止转载,侵权必究!
本篇是 Vue3 源码解析系列第 10 篇,关注专栏
前言
前面两篇我们分析了 render
函数是如何对 DOM
进行渲染、更新、删除的。除此之外,HTML
标签属性、DOM
属性,比如 type
、value
等,以及 Style
样式、Event
事件的挂载及更新,Vue
是如何处理的呢?那么本篇就来对这些情况一探究竟。
案例一
首先引入 h
、 render
两个函数,设置 vnode
为 textarea
类型,并设置了 class
、type
、value
三个 props
属性,最后通过 render
函数渲染。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="../../../dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const { h, render } = Vue
const vnode = h(
'textarea',
{
class: 'test-class',
type: 'text',
value: 'textarea value'
},
)
render(vnode, document.querySelector('#app'))
</script>
</body>
</html>
渲染结果:
render HTML 标签属性和 DOM 属性
我们知道 render
函数属性的挂载更新执行的是 patchProp
方法,该方法定义在 packages/runtime-dom/src/patchProp.ts
文件中:
export const patchProp: DOMRendererOptions['patchProp'] = (
el,
key,
prevValue,
nextValue,
isSVG = false,
prevChildren,
parentComponent,
parentSuspense,
unmountChildren
) => {
if (key === 'class') {
// runtime-dom/src/modules/class.ts 中
patchClass(el, nextValue, isSVG)
} else if (key === 'style') {
patchStyle(el, prevValue, nextValue)
} else if (isOn(key)) {
// ignore v-model listeners
// 是否为 v-model事件 onUpdate:
if (!isModelListener(key)) {
patchEvent(el, key, prevValue, nextValue, parentComponent)
}
} else if (
key[0] === '.'
? ((key = key.slice(1)), true)
: key[0] === '^'
? ((key = key.slice(1)), false)
: shouldSetAsProp(el, key, nextValue, isSVG)
) {
patchDOMProp(
el,
key,
nextValue,
prevChildren,
parentComponent,
parentSuspense,
unmountChildren
)
} else {
// special case for <input v-model type="checkbox"> with
// :true-value & :false-value
// store value as dom properties since non-string values will be
// stringified.
if (key === 'true-value') {
;(el as any)._trueValue = nextValue
} else if (key === 'false-value') {
;(el as any)._falseValue = nextValue
}
patchAttr(el, key, nextValue, isSVG, parentComponent)
}
}
根据案例一,当前设置了 class
、type
、value
三个属性,那么 patchProp
方法则会调用三次。第一次 class
属性执行的是 patchClass
方法,前面我们已经分析过,该方法实际是执行 el.className
对其赋值。第二次 type
属性,根据判断执行 patchAttr
方法,该方法定义在 packages/runtime-dom/src/modules/attrs.ts
文件中:
export function patchAttr(
el: Element,
key: string,
value: any,
isSVG: boolean,
instance?: ComponentInternalInstance | null
) {
// 是否为 svg
if (isSVG && key.startsWith('xlink:')) {
if (value == null) {
el.removeAttributeNS(xlinkNS, key.slice(6, key.length))
} else {
el.setAttributeNS(xlinkNS, key, value)
}
} else {
if (__COMPAT__ && compatCoerceAttr(el, key, value, instance)) {
return
}
// note we are only checking boolean attributes that don't have a
// corresponding dom prop of the same name here.
const isBoolean = isSpecialBooleanAttr(key)
if (value == null || (isBoolean && !includeBooleanAttr(value))) {
el.removeAttribute(key)
} else {
el.setAttribute(key, isBoolean ? '' : value)
}
}
}
根据判断执行 el.setAttribute
来设置 type
属性的值,此时 el
就包含了 class
、 type
两个属性:
第三次 value
属性,这里我们要关注下这么一段判断逻辑:
else if (
key[0] === '.'
? ((key = key.slice(1)), true)
: key[0] === '^'
? ((key = key.slice(1)), false)
: shouldSetAsProp(el, key, nextValue, isSVG)
)
再看下 shouldSetAsProp
方法:
function shouldSetAsProp(
el: Element,
key: string,
value: unknown,
isSVG: boolean
) {
if (isSVG) {
// most keys must be set as attribute on svg elements to work
// ...except innerHTML & textContent
if (key === 'innerHTML' || key === 'textContent') {
return true
}
// or native onclick with function values
if (key in el && nativeOnRE.test(key) && isFunction(value)) {
return true
}
return false
}
// these are enumerated attrs, however their corresponding DOM properties
// are actually booleans - this leads to setting it with a string "false"
// value leading it to be coerced to `true`, so we need to always treat
// them as attributes.
// Note that `contentEditable` doesn't have this problem: its DOM
// property is also enumerated string values.
if (key === 'spellcheck' || key === 'draggable' || key === 'translate') {
return false
}
// #1787, #2840 form property on form elements is readonly and must be set as
// attribute.
if (key === 'form') {
return false
}
// #1526 <input list> must be set as attribute
if (key === 'list' && el.tagName === 'INPUT') {
return false
}
// #2766 <textarea type> must be set as attribute
if (key === 'type' && el.tagName === 'TEXTAREA') {
return false
}
// native onclick with string value, must be set as attribute
if (nativeOnRE.test(key) && isString(value)) {
return false
}
return key in el
}
该方法主要根据 key
判断是否为 DOM
的属性,我们调试看下:
之后执行 patchDOMProp
方法,该方法定义在 packages/runtime-dom/src/modules/props.ts
文件中:
export function patchDOMProp(
el: any,
key: string,
value: any,
// the following args are passed only due to potential innerHTML/textContent
// overriding existing VNodes, in which case the old tree must be properly
// unmounted.
prevChildren: any,
parentComponent: any,
parentSuspense: any,
unmountChildren: any
) {
if (key === 'innerHTML' || key === 'textContent') {
if (prevChildren) {
unmountChildren(prevChildren, parentComponent, parentSuspense)
}
el[key] = value == null ? '' : value
return
}
if (
key === 'value' &&
el.tagName !== 'PROGRESS' &&
// custom elements may use _value internally
!el.tagName.includes('-')
) {
// store value as _value as well since
// non-string values will be stringified.
el._value = value
const newValue = value == null ? '' : value
if (
el.value !== newValue ||
// #4956: always set for OPTION elements because its value falls back to
// textContent if no value attribute is present. And setting .value for
// OPTION has no side effect
el.tagName === 'OPTION'
) {
el.value = newValue
}
if (value == null) {
el.removeAttribute(key)
}
return
}
根据判断执行 el.value = newValue
来设置 DOM
的 value
值,最后执行 insert
方法挂载 DOM
,此时页面呈现:
我们可以看到,设置属性值有的通过 setAttribute
方法,有的通过 .className
、.value
等方式,那这个有什么区别呢?这里就需要了解下 HTML 标签属性 和 DOM 属性,大家可以点击跳转对应的文档进行查看。el.setAttribute
是设置指定元素上的某个属性,而 dom.xx
是直接修改指定对象的属性。
案例二
首先引入 h
、 render
两个函数,先渲染 vnode1
元素,style
样式设置为 color: 'red'
,两秒后更新为相同类型和文本的 vnode2
元素,style
样式设置为 fontSize: '32px'
。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="../../../dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const { h, render } = Vue
const vnode1 = h(
'div',
{
style: {
color: 'red'
}
},
'hello world'
)
render(vnode1, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h(
'div',
{
style: {
fontSize: '32px'
}
},
'hello world'
)
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
</body>
</html>
render Style 属性
上面讲解到属性的挂载更新都会执行 patchProp
方法,根据判断:
else if (key === 'style') {
patchStyle(el, prevValue, nextValue)
}
执行 patchStyle
方法,它定义在 packages/runtime-dom/src/modules/style.ts
文件中:
export function patchStyle(el: Element, prev: Style, next: Style) {
const style = (el as HTMLElement).style // 从 el 获取 style 属性
const isCssString = isString(next) // next { color: red } vue 对其增强 可接收 数组 对象 字符串
if (next && !isCssString) {
for (const key in next) {
setStyle(style, key, next[key])
}
// 该逻辑为了处理更新时 旧的有没存在 做删除处理
if (prev && !isString(prev)) {
for (const key in prev) {
if (next[key] == null) {
setStyle(style, key, '')
}
}
}
} else {
const currentDisplay = style.display
if (isCssString) {
if (prev !== next) {
style.cssText = next as string
}
} else if (prev) {
el.removeAttribute('style')
}
// indicates that the `display` of the element is controlled by `v-show`,
// so we always keep the current `display` value regardless of the `style`
// value, thus handing over control to `v-show`.
if ('_vod' in el) {
style.display = currentDisplay
}
}
}
该方法先从 el
元素中获取 style
属性,然后判断新节点的属性是否为字符串,当前传入的属性为 { color: 'red' }
,所以为 false
。由于 Vue
对 props
属性增强,可以接收对象、数组、字符串等类型。根据判断遍历执行 setStyle
方法:
function setStyle(
style: CSSStyleDeclaration,
name: string,
val: string | string[]
) {
if (isArray(val)) {
val.forEach(v => setStyle(style, name, v)) // 递归处理 当前 val 为 red
} else {
if (val == null) val = ''
if (name.startsWith('--')) {
// custom property definition
style.setProperty(name, val)
} else {
const prefixed = autoPrefix(style, name) // 缓存处理
if (importantRE.test(val)) {
// !important
style.setProperty(
hyphenate(prefixed),
val.replace(importantRE, ''),
'important'
)
} else {
style[prefixed as any] = val // 即 style.color = red
}
}
}
}
当前 name
为 color
,val
值为 red
,之后执行 const prefixed = autoPrefix(style, name)
逻辑,我们再看下 autoPrefix
方法:
const prefixes = ['Webkit', 'Moz', 'ms']
const prefixCache: Record<string, string> = {}
function autoPrefix(style: CSSStyleDeclaration, rawName: string): string {
const cached = prefixCache[rawName]
if (cached) {
return cached
}
let name = camelize(rawName)
if (name !== 'filter' && name in style) {
return (prefixCache[rawName] = name)
}
name = capitalize(name)
for (let i = 0; i < prefixes.length; i++) {
const prefixed = prefixes[i] + name
if (prefixed in style) {
return (prefixCache[rawName] = prefixed)
}
}
return rawName
}
const camelizeRE = /-(\w)/g
/**
* @private
*/
export const camelize = cacheStringFunction((str: string): string => {
return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))
})
该方法主要做了 缓存处理
以及通过 camelize
方法将 style
属性带有 -
的转换为驼峰形式,比如 font-size
转换为 fontSize
,最后返回处理后的 name
。
之后执行 style[prefixed as any] = val
即 style['color'] = 'red'
设置样式,render
函数执行完毕,页面呈现:
两秒后更新节点,重新修改 style
样式,又会再次触发 patchStyle
方法,其中有这么一段逻辑:
export function patchStyle(el: Element, prev: Style, next: Style) {
const style = (el as HTMLElement).style // 从 el 获取 style 属性
const isCssString = isString(next) // next { color: red } vue 对其增强 可接收 数组 对象 字符串
if (next && !isCssString) {
for (const key in next) {
setStyle(style, key, next[key])
}
// 该逻辑为了处理更新时 旧的有没存在 做删除处理
if (prev && !isString(prev)) {
for (const key in prev) {
if (next[key] == null) {
setStyle(style, key, '')
}
}
}
} else {
// 省略
}
}
先遍历执行 setStyle
方法设置属性 fontSize:'32px'
:
之后执行 if (prev && !isString(prev))
判断逻辑,由于存在旧节点,且旧节点 style
中的 color
属性不存在新节点的 style
中,则执行 setStyle
方法对 color
属性做清空处理,最终页面呈现:
案例三
首先引入 h
、 render
两个函数,先渲染类型为 button
的 vnode1
元素,添加 onClick
点击事件,两秒后更新渲染相同类型的 vnode2
元素,事件修改为 onDblclick
双击事件。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="../../../dist/vue.global.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const { h, render } = Vue
const vnode1 = h(
'button',
{
onClick() {
alert('点击')
}
},
'点击'
)
render(vnode1, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h(
'button',
{
onDblclick() {
alert('双击')
}
},
'双击'
)
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
</body>
</html>
render Event 事件
我们知道属性事件的挂载更新都会执行 patchProp
方法,根据判断:
else if (isOn(key)) {
// ignore v-model listeners
// 是否为 v-model事件 onUpdate:
if (!isModelListener(key)) {
patchEvent(el, key, prevValue, nextValue, parentComponent)
}
}
isModelListener
方法判断是否为 v-model
双向绑定事件:
export const isModelListener = (key: string) => key.startsWith('onUpdate:')
之后执行 patchEvent
方法,该方法定义在 packages/runtime-dom/src/modules/events.ts
文件中:
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 {
// 拆解为小写 是为了 addEventListener 添加事件中 type 是小写
const [name, options] = parseName(rawName)
if (nextValue) {
// add 事件进行缓存 invokers[rawName]
const invoker = (invokers[rawName] = createInvoker(nextValue, instance))
addEventListener(el, name, invoker, options) // 添加事件
} else if (existingInvoker) {
// remove
removeEventListener(el, name, existingInvoker, options)
invokers[rawName] = undefined
}
}
}
这里引入了 vei
缓存概念,我们稍后再来分析。第一次渲染,缓存不存在,执行 else
逻辑,之后对 rawName
进行拆解,当前 rawName
为 onClick
,再看下 parseName
方法:
const optionsModifierRE = /(?:Once|Passive|Capture)$/
function parseName(name: string): [string, EventListenerOptions | undefined] {
let options: EventListenerOptions | undefined
if (optionsModifierRE.test(name)) {
options = {}
let m
while ((m = name.match(optionsModifierRE))) {
name = name.slice(0, name.length - m[0].length)
;(options as any)[m[0].toLowerCase()] = true
}
}
return [hyphenate(name.slice(2)), options]
}
const hyphenateRE = /\B([A-Z])/g
/**
* @private
*/
export const hyphenate = cacheStringFunction((str: string) =>
str.replace(hyphenateRE, '-$1').toLowerCase()
)
该方法主要是对 事件名
前两位进行截取,并将剩余的转为小写,即 onClick
拆解为 click
,因为 addEventListener
注册事件中 type
参数对大小写敏感,一般为小写。
之后执行 invokers[rawName] = createInvoker()
来缓存事件,我们再看下 createInvoker
方法:
function createInvoker(
initialValue: EventValue,
instance: ComponentInternalInstance | null
) {
const invoker: Invoker = (e: Event) => {
// async edge case #6566: inner click event triggers patch, event handler
// attached to outer element during patch, and triggered again. This
// happens because browsers fire microtask ticks between event propagation.
// the solution is simple: we save the timestamp when a handler is attached,
// and the handler would only fire if the event passed to it was fired
// AFTER it was attached.
const timeStamp = e.timeStamp || _getNow()
if (skipTimestampCheck || timeStamp >= invoker.attached - 1) {
// 点击后触发该事件 本质上还是触发事件,只是做了 try catch 错误拦截
callWithAsyncErrorHandling(
patchStopImmediatePropagation(e, invoker.value),
instance,
ErrorCodes.NATIVE_EVENT_HANDLER,
[e]
)
}
}
// 为传入的点击函数 onClick() { alert('点击')} 相当于 invoker.value被触发 即点击行为被触发
invoker.value = initialValue
invoker.attached = getNow()
return invoker // 返回 invoker 函数
}
该方法创建了一个 invoker
函数,并将传入的函数即 onClick() { alert('点击') }
赋值给了 invoker.value
上,之后执行 addEventListener
方法注册事件:
export function addEventListener(
el: Element,
event: string,
handler: EventListener,
options?: EventListenerOptions
) {
// 这里的 handler 为 invoker 函数,并没有把真正事件传入,而是通过 invoker.value 来触发
el.addEventListener(event, handler, options)
}
此时 el._vei
和 invokers
对象中就存在 onClick
注册事件的缓存:
按钮渲染完毕,点击触发 createInvoker
方法中 invoker
函数的 callWithAsyncErrorHandling
方法:
该方法我们在前面的文章中也提到过,主要是对事件执行 try catch
错误的拦截:
export function callWithAsyncErrorHandling(
fn: Function | Function[],
instance: ComponentInternalInstance | null,
type: ErrorTypes,
args?: unknown[]
): any[] {
if (isFunction(fn)) {
const res = callWithErrorHandling(fn, instance, type, args)
if (res && isPromise(res)) {
res.catch(err => {
handleError(err, instance, type)
})
}
return res
}
const values = []
for (let i = 0; i < fn.length; i++) {
values.push(callWithAsyncErrorHandling(fn[i], instance, type, args))
}
return values
}
export function callWithErrorHandling(
fn: Function,
instance: ComponentInternalInstance | null,
type: ErrorTypes,
args?: unknown[]
) {
let res
// 统一处理监听 错误
try {
res = args ? fn(...args) : fn()
} catch (err) {
handleError(err, instance, type)
}
return res
}
我们再看下 patchStopImmediatePropagation
方法,这里将 invoker.value
参数传入,最终又返回了该方法,即 onClick() { alert('点击') }
:
function patchStopImmediatePropagation(
e: Event,
value: EventValue
): EventValue {
if (isArray(value)) {
const originalStop = e.stopImmediatePropagation
e.stopImmediatePropagation = () => {
originalStop.call(e)
;(e as any)._stopped = true
}
return value.map(fn => (e: Event) => !(e as any)._stopped && fn && fn(e))
} else {
return value
}
}
综上,我们可以理解为事件的触发,实际执行的是 invoker.value
,即传入的事件 onClick() { alert('点击') }
。
那 Vue
为什么要这么处理呢?和 el._vei
有什么关联呢?在讲解之前,我们先看下事件的更新。
根据案例三,两秒后重新更新节点事件,执行 patchEvent
方法。我们发现这里和 class
、style
有点区别,没有卸载事件,那么在哪里触发的呢?我们回过来再看下 renderer.ts
文件中 patchProps
方法:
const patchProps = (
el: RendererElement,
vnode: VNode,
oldProps: Data,
newProps: Data,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean
) => {
if (oldProps !== newProps) {
for (const key in newProps) {
// empty string is not valid prop
if (isReservedProp(key)) continue
const next = newProps[key]
const prev = oldProps[key]
// defer patching value
if (next !== prev && key !== 'value') {
hostPatchProp(
el,
key,
prev,
next,
isSVG,
vnode.children as VNode[],
parentComponent,
parentSuspense,
unmountChildren
)
}
}
if (oldProps !== EMPTY_OBJ) {
for (const key in oldProps) {
if (!isReservedProp(key) && !(key in newProps)) {
hostPatchProp(
el,
key,
oldProps[key],
null,
isSVG,
vnode.children as VNode[],
parentComponent,
parentSuspense,
unmountChildren
)
}
}
}
if ('value' in newProps) {
hostPatchProp(el, 'value', oldProps.value, newProps.value)
}
}
}
根据判断 if (oldProps !== EMPTY_OBJ)
会再次触发 patchProp
方法,也就是我们说的卸载行为,之后执行 patchEvent
方法:
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 {
// 拆解为小写 是为了 addEventListener 添加事件中 type 是小写
const [name, options] = parseName(rawName)
if (nextValue) {
// add 事件进行缓存 invokers[rawName]
const invoker = (invokers[rawName] = createInvoker(nextValue, instance))
addEventListener(el, name, invoker, options) // 添加事件
} else if (existingInvoker) {
// remove
removeEventListener(el, name, existingInvoker, options)
invokers[rawName] = undefined
}
}
}
此时 el._vei
存在 onClick
、onDblClick
两个事件缓存:
当前旧节点的事件为 onClick
,由于此时 existingInvoker
存在且 nextValue
不存在,走 else if (existingInvoker)
判断逻辑,执行 removeEventListener
移除旧节点 onClick
事件,并将缓存事件对应的值清空 invokers[rawName] = undefined
,至此事件更新完毕。
render 缓存事件
上面我们还遗留了一个问题,事件的触发实际执行的是 invoker.value
,为什么不是直接执行 invoker
函数呢?我们先看下正常情况下是如何添加和移除事件的:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<button>点击</button>
<script>
// 获取 button 元素
const btnEle = document.querySelector('button')
// 声明 invoker 函数
const invoker = () => {
alert('hello')
}
// 注册 click 事件
btnEle.addEventListener('click', invoker)
// 两秒后更新事件
setTimeout(() => {
// 移除 click 事件
btnEle.removeEventListener('click', invoker)
// 注册新事件
btnEle.addEventListener('click', () => {
alert('你好')
})
}, 2000)
</script>
</body>
</html>
再看下使用 value
形式添加和移除事件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<button>点击</button>
<script>
// 获取 button 元素
const btnEle = document.querySelector('button')
// 声明 invoker 函数 调用执行 invoker.value
const invoker = () => {
invoker.value()
}
// value 赋值事件
invoker.value = () => {
alert('hello')
}
// 注册事件
btnEle.addEventListener('click', invoker)
// 两秒后重新修改 value 值
setTimeout(() => {
invoker.value = () => {
alert('你好')
}
}, 2000)
</script>
</body>
</html>
我们知道添加移除事件都是比较耗性能的,而 vei
对象的作用通过调用和修改 value
属性值从而提升性能,它充当着一个事件缓存器。
总结
- 属性和事件的挂载更新都会执行
patchProp
方法。 class
属性的挂载更新执行的是patchClass
方法。style
属性的挂载更新执行的是patchStyle
方法。HTML 标签
属性的挂载更新执行的是patchAttr
方法。DOM
属性的挂载更新执行的是patchDOMProp
方法。Event 事件
的挂载更新执行的是patchEvent
方法。Event 事件
的触发实际执行的是invoker.value
。vei
对象充当着一个事件缓存器,提升性能的作用。
Vue3 源码实现
Vue3 源码解析系列
- Vue3源码解析之 源码调试
- Vue3源码解析之 reactive
- Vue3源码解析之 ref
- Vue3源码解析之 computed
- Vue3源码解析之 watch
- Vue3源码解析之 runtime
- Vue3源码解析之 h
- Vue3源码解析之 render(一)
- Vue3源码解析之 render(二)
- Vue3源码解析之 render(三)
- Vue3源码解析之 render(四)
- Vue3源码解析之 render component(一)
- Vue3源码解析之 render component(二)
- Vue3源码解析之 render component(三)
- Vue3源码解析之 render component(四)
- Vue3源码解析之 render component(五)
- Vue3源码解析之 diff(一)
- Vue3源码解析之 diff(二)
- Vue3源码解析之 compiler(一)
- Vue3源码解析之 compiler(二)
- Vue3源码解析之 compiler(三)
- Vue3源码解析之 createApp