Vue 3 核心源码深度剖析系列(v3.5.44)-第 13 章 运行时组件系统:实例、props、emit、slots 与生命周期
本章对应
packages/runtime-core/src/component.ts(1222 行)、componentProps.ts(769 行)、componentEmits.ts(290 行)、componentSlots.ts(251 行)、componentPublicInstance.ts(714 行)、componentRenderContext.ts(125 行)、componentOptions.ts(1327 行)、componentRenderUtils.ts(499 行)、apiLifecycle.ts(116 行)、apiSetupHelpers.ts(573 行)、apiAsyncComponent.ts(286 行)、apiDefineComponent.ts(315 行),以及renderer.ts中的mountComponent(1222--1305)、setupRenderEffect(1343--1651)、updateComponent(1307--1341)、updateComponentPreRender(1653--1670)。所有行号对应vuejs/core@3.5.44。读完本章你将能回答:
ComponentInternalInstance那 60 多个字段各自承担什么职责,哪些是"内部状态"?mountComponent → setupComponent → setupStatefulComponent → finishComponentSetup这条链路里,instance.proxy与vnode.component分别在什么时刻被写死?setup()的四个门面(attrs/slots/emit/expose)是怎么造出来的、返回值如何分派到setupState或render?props 为什么必须shallowReactive?emit的onClick究竟是怎么与emits: { click }对上的?编译期 slots 的$stable/_/_ctx三个魔法字段如何在运行时决定要不要更新?next机制如何在"父组件更新子组件 props"时不丢掉自身状态更新的顺序?this.msg的查找优先级表长什么样?异步组件的状态机到底是什么形状?
导语:组件是"运行时的最小可复用单元",而实例是它的全部状态
如果说第 11、12 章讲的是"渲染器如何把 VNode 变成 DOM",那么本章讲的是"渲染器 patch 到一个组件 VNode 时,究竟触碰了哪些状态机"。这里有一句贯穿全章的判断:Vue 3 的组件不是 class,而是一个由普通对象 ComponentInternalInstance 承载的"状态集合",外加一层由 Proxy 提供的公共视图。 框架从不 new 一个组件类;它只是 createComponentInstance 造一个字典对象,然后按固定顺序往里灌字段:先 props/attrs/slots,再 setup 返回值,再 Options API 的 data/computed/methods/watch,最后给它挂一个渲染 effect。
这个"按顺序灌字段"的过程,就是本章要逐行走读的挂载链路。它有三个设计支点:
- 字段即协议 。
instance的每个字段不只是内存槽位,而是渲染器、调度器、devtools、生命周期系统共同遵守的协议。理解字段的写入时机,就理解了一半的更新流程。 - Proxy 即公共契约 。模板里的
this、$props、$attrs、$refs全部来自PublicInstanceProxyHandlers;它用一张accessCache做了手写散列,把"每次属性访问都要hasOwnProperty四次"降为摊还 O(1)。 - 编译期与运行期的接缝极窄 。props 的布尔转换、slots 的
$stable、emit 的onXxx匹配、props destructure的toRef,都是"编译器写下一个约定、运行时按约定读"的窄接口。守住这些接缝,组件系统就能在保持声明式语义的同时把开销压到最低。
本章结构:13.1 逐字段拆解实例数据结构;13.2 挂载流程四步曲;13.3 setup 与四个门面;13.4 props 体系;13.5 emit 体系;13.6 slots 体系;13.7 组件更新与 next;13.8 生命周期注册与触发时序;13.9 PublicInstance proxy;13.10 Options API 兼容层;13.11 异步组件状态机;13.12 函数式组件与 defineComponent;13.13 实战;13.14 要点回顾。
13.1 组件实例数据结构:ComponentInternalInstance
13.1.1 一切从 createComponentInstance 开始
实例不是被"构造"出来的,而是被"就地拼装"出来的。看 component.ts:618:
ts
export function createComponentInstance(
vnode: VNode,
parent: ComponentInternalInstance | null,
suspense: SuspenseBoundary | null,
): ComponentInternalInstance {
const type = vnode.type as ConcreteComponent
// inherit parent app context - or - if root, adopt from root vnode
const appContext =
(parent ? parent.appContext : vnode.appContext) || emptyAppContext
const instance: ComponentInternalInstance = {
uid: uid++,
vnode,
type,
parent,
appContext,
root: null!, // to be immediately set
next: null,
subTree: null!, // will be set synchronously right after creation
...
注意这里大量使用了 null! 与 EMPTY_OBJ:root、subTree、effect、update、job 都是"占位 null,稍后同步赋值"(源码注释明确写了 will be set synchronously right after creation)。而 ctx/data/props/attrs/slots/refs/setupState 全部初始化为 EMPTY_OBJ(一个冻结的空对象),这是一个精心设计的哨兵:后续判断"某个状态是否已被初始化"只需 state !== EMPTY_OBJ,无需额外的布尔字段。这一点在 PublicInstanceProxyHandlers.get(componentPublicInstance.ts:486)里被反复使用。
type 与 uid 在创建时就固定:uid 来自模块级自增计数器(component.ts:612),是调度器排序、devtools 标识、instance.uid 排序都依赖的稳定身份。
13.1.2 字段全景:按职责分组的 62 个字段
下面把 ComponentInternalInstance 接口(component.ts:326--608)的全部字段按职责分组。"内部"列标记的是那些不属于公共 API、仅供框架内部使用的字段------这正是本章 13.1 标题里"哪些是内部状态"的答案。
A. 身份与归属
| 字段 | 类型 | 写入时机 | 职责 | 是否内部 |
|---|---|---|---|---|
uid |
number |
创建时自增 | 稳定唯一 ID;调度器排序、devtools | 半公开($ 上可读) |
type |
ConcreteComponent |
创建时取自 vnode.type |
组件定义(options 对象或函数) | 是 |
parent |
`ComponentInternalInstance | null` | 创建时(mountComponent 传入) |
父实例;provide/错误捕获/inject 沿此链 |
root |
ComponentInternalInstance |
创建后立刻 parent ? parent.root : instance(component.ts:711) |
应用的根实例 | 是 |
appContext |
AppContext |
创建时继承或取 vnode.appContext |
全局配置、组件/指令注册表、provides 根、缓存 | 是 |
vnode |
VNode |
创建时;更新时被 updateComponentPreRender 替换 |
当前在父树中的组件 VNode | 是 |
next |
`VNode | null` | 更新时由 updateComponent 写入 |
待处理的"新 vnode",父驱动的更新入口 |
subTree |
VNode |
首次挂载 / 每次渲染更新 | 组件自身渲染出的 vdom 树根 | 是 |
B. 渲染控制与响应式
| 字段 | 类型 | 写入时机 | 职责 | 是否内部 |
|---|---|---|---|---|
effect |
ReactiveEffect |
setupRenderEffect(renderer.ts:1628) |
渲染副作用,收集渲染依赖 | 是 |
update |
() => void |
effect.run.bind(effect)(renderer.ts:1631) |
强制更新入口 | 是 |
job |
SchedulerJob |
effect.runIfDirty.bind(effect)(renderer.ts:1632) |
调度器任务,带 i(instance)、id(uid) |
是 |
scope |
EffectScope |
创建时 new EffectScope(true)(detached,component.ts:640) |
收敛 watch/computed 等副作用,卸载时统一 stop | 是 |
render |
`InternalRenderFunction | null` | setup 返回函数 / finishComponentSetup / applyOptions |
组件的渲染函数 |
ssrRender |
`Function | null` | setup 返回(__ssrInlineRender)或 SFC 编译注入 |
SSR 渲染函数 |
provides |
Data |
创建时 parent ? parent.provides : Object.create(appContext.provides) |
向后代提供的数据(原型链继承) | 是 |
ids |
[string, number, number] |
创建时继承 parent | useId() 边界前缀与序号 |
是 |
accessCache |
`Data | null` | setupStatefulComponent 写 Object.create(null)(component.ts:864) |
proxy 属性访问类型缓存 |
renderCache |
`(Function | VNode | undefined)[]` | 创建时 [] |
C. 解析后的资产与选项
| 字段 | 类型 | 写入时机 | 职责 | 是否内部 |
|---|---|---|---|---|
components |
`Record<string, ConcreteComponent> | null` | applyOptions(仅含 mixins/extends 时) |
本地组件注册表 |
directives |
`Record<string, Directive> | null` | applyOptions |
本地指令注册表 |
filters |
Record<string, Function> |
v2 compat only | v2 过滤器 | 是 |
propsOptions |
NormalizedPropsOptions |
创建时 normalizePropsOptions(component.ts:657) |
归一化后的 props 声明 | 是 |
emitsOptions |
`ObjectEmitsOptions | null` | 创建时 normalizeEmitsOptions(component.ts:658) |
归一化后的 emits 声明 |
inheritAttrs |
`boolean | undefined` | 创建时 type.inheritAttrs,applyOptions 可覆盖 |
是否自动透传 attrs |
ce / isCE / ceReload |
自定义元素相关 | custom element 场景 | defineCustomElement 集成 |
是 |
resolvedOptions |
MergedComponentOptions |
v2 compat | 缓存合并后的 $options |
是 |
D. 公共视图与状态
| 字段 | 类型 | 写入时机 | 职责 | 是否内部 |
|---|---|---|---|---|
proxy |
`ComponentPublicInstance | null` | setupStatefulComponent(component.ts:866) |
this 的代理 |
exposed |
`Record<string, any> | null` | expose() / applyOptions 的 expose 数组 |
显式暴露面 |
exposeProxy |
`Record<string, any> | null` | getComponentPublicInstance 惰性创建 |
包了 proxyRefs 的暴露代理 |
withProxy |
`ComponentPublicInstance | null` | runtime-compiled render 时(registerRuntimeCompiler) |
with 块专用代理 |
ctx |
Data |
创建时(dev 走 createDevRenderContext,prod { _: instance }) |
公共代理的 target | 是 |
data |
Data |
applyOptions 的 reactive(data) |
Options API 的 data | 半公开 |
props |
Data |
initProps 的 shallowReactive(props) |
已解析 props | 半公开 |
attrs |
Data |
initProps |
未声明的透传属性 | 半公开 |
slots |
InternalSlots |
initSlots |
归一化后的插槽 | 半公开 |
refs |
Data |
模板 ref 填充时 | $refs |
半公开 |
emit |
EmitFn |
创建时 emit.bind(null, instance)(component.ts:712) |
触发事件 | 半公开 |
emitted |
`Record<string, boolean> | null` | emit 首次遇到 .once 时 |
.once 去重记录 |
propsDefaults |
Data |
resolvePropValue 惰性缓存工厂结果 |
缓存 prop 默认值工厂结果,避免 watcher 误触发 | 是 |
setupState |
Data |
handleSetupResult 的 proxyRefs(setupResult) |
setup 返回的对象 | 是 |
devtoolsRawSetupState |
any |
dev/prod-devtools 下等于原始返回值 | devtools 访问非代理状态 | 是 |
setupContext |
`SetupContext | null` | setupStatefulComponent |
缓存的四门面 |
E. Suspense / 异步
| 字段 | 类型 | 写入时机 | 职责 |
|---|---|---|---|
suspense |
`SuspenseBoundary | null` | mountComponent 传入 |
suspenseId |
number |
创建时 suspense ? suspense.pendingId : 0 |
用于注册/结算时的批次校验 |
asyncDep |
`Promise |
null` | async setup 返回 Promise 时 |
asyncResolved |
boolean |
Suspense 结算时 | 异步是否已结算(避免重复更新) |
F. 生命周期钩子槽位(LifecycleHooks 枚举键)
bc/c/bm/m/bu/u/um/bum/da/a/rtg/rtc/ec/sp 共 14 个(component.ts:526--578)。它们是数组 (LifecycleHook 类型即 (TFn & SchedulerJob)[] | null),不是单个函数------因为 mixin 会把多个同名钩子合并成数组。字段短名与枚举的对应关系见 13.8。
G. 缓存与 HOC
| 字段 | 类型 | 职责 |
|---|---|---|
f |
() => void |
缓存绑定过的 $forceUpdate(componentPublicInstance.ts:417) |
n |
() => Promise<void> |
缓存绑定过 proxy 的 $nextTick(componentPublicInstance.ts:422) |
ut |
(vars?) => void |
更新内含 Teleport 的 CSS 变量 |
getCssVars |
() => Record<string, unknown> |
dev:style v-bind 水合校验 |
13.1.3 从创建函数读"写入顺序"
把 createComponentInstance 的字段赋值顺序与上表对照,可以画出一条清晰的"创建期流水线":
typescript
uid → vnode → type → parent → appContext
│
├─ provides(原型链继承 parent.provides 或 appContext.provides)
├─ ids(继承 parent.ids)
└─ propsOptions / emitsOptions(归一化,带缓存)
subscribe: root ← parent ? parent.root : instance
emit ← emit.bind(null, instance)
两个"立即赋值"值得强调:
instance.root = parent ? parent.root : instance(component.ts:711)。这决定了$root与错误处理里的实例链终点。它必须在返回前完成,因为mountComponent后续会立刻调用setupComponent,而用户setup()里可能通过$root/provides访问根。instance.emit = emit.bind(null, instance)(component.ts:712)。emit需要一个"预绑定 instance 的一元函数",这样instance.emit天然可以当成ctx.emit直接传给用户,无需再包裹闭包。
vnode.ce(custom element 回调)在创建末尾被调用(component.ts:715),这是自定义元素在实例创建完成后插入自己钩子的唯一缝隙。
13.1.4 EMPTY_OBJ 哨兵与 accessCache 的性能动机
EMPTY_OBJ 不是随手写的默认值。考虑一次模板渲染里 {{ msg }} 的求值:模板编译产物会访问 _ctx.msg,走 PublicInstanceProxyHandlers.get。若没有 accessCache,每次访问都要依次判断:
ts
hasSetupBinding(setupState, key) // hasOwn(setupState, key)
data !== EMPTY_OBJ && hasOwn(data, key)
hasOwn(props, key)
ctx !== EMPTY_OBJ && hasOwn(ctx, key)
四次 hasOwn。组件渲染是热点中的热点,Vue 团队用一张 accessCache(Object.create(null))把"key → 访问类型"的映射记下来(AccessTypes.OTHER/SETUP/DATA/PROPS/CONTEXT),第二次访问同一 key 直接 switch 命中(componentPublicInstance.ts:470--482)。而 data !== EMPTY_OBJ 这一句,正是靠 EMPTY_OBJ 哨兵免去一个布尔判断------如果 data 还停在 EMPTY_OBJ,说明 Options API 的 data() 从未执行,跳过 hasOwn。
同样地,shouldCacheAccess(componentOptions.ts:518)是一个模块级开关:applyOptions 开始时置 false,data 初始化完成后置 true。在"state 尚未就绪"的窗口里,把 AccessTypes.OTHER 写进缓存是危险的(因为它可能稍后变成 SETUP/PROPS),所以只有在 shouldCacheAccess 为真(或非 Options API 构建)时才缓存 OTHER(componentPublicInstance.ts:499)。
13.1.5 哪些字段"不属于公共 API"
把上表里标记"是"的字段收敛成结论:除 $ 属性映射表暴露的那些($el/$data/$props/$attrs/$slots/$refs/$parent/$root/$emit/$options/$forceUpdate/$nextTick/$watch)之外,实例上的绝大多数字段都是内部状态。
需要特别澄清三个常被误解的:
ctx是"公共代理的 target",不是"用户状态"。用户通过this.x = 1写的值落在ctx上;Options API 的 methods 也挂在ctx上。因此ctx同时扮演"用户自定义属性容器"和"渲染上下文后端"。proxy是渲染函数this的默认值;withProxy只在 runtime-compiled(含with块)时启用,二者共用同一份ctx。exposed与exposeProxy是expose()的产物。getComponentPublicInstance(component.ts:1198)优先返回exposeProxy,否则返回proxy。这是<script setup>默认"封闭"的实现基础:父组件拿到的ref指向的是exposeProxy,只能看到expose()里声明过的东西。
13.2 组件挂载流程:从 patch 到 setupComponent
13.2.1 入口:processComponent 的两条分支
渲染器 patch 到一个组件 VNode 时,进入 processComponent(renderer.ts:1185)。它的结构极简------只看旧节点是否存在,决定挂载还是更新:
ts
const processComponent = (
n1: VNode | null,
n2: VNode,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
optimized,
) => {
n2.suspense = parentSuspense
if (n1 == null) {
...
mountComponent(n2, container, anchor, parentComponent, parentSuspense, namespace, optimized)
} else {
updateComponent(n1, n2, optimized)
}
}
关键在 mountComponent 的第一行(renderer.ts:1235--1241):
ts
const compatMountInstance =
__COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
const instance: ComponentInternalInstance =
compatMountInstance ||
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense,
))
这里有本章第一个必须记牢的事实:vnode.component 的写入时机,是"实例被创建"的同一表达式内 。也就是说,只要 mountComponent 开始执行,vnode.component 就已经指向实例。这不是随手写的,而是为了:
- 卸载路径(
renderer.ts:2228的unmountComponent(vnode.component!, ...))能从 vnode 直接反查实例; shouldUpdateComponent(componentRenderUtils.ts:379)能从prevVNode.component拿到emitsOptions;- Suspense 的
registerDep、updateHOCHostEl等都能沿 vnode ↔ instance 双向走。
在 compat 构建里,isCompatRoot && initialVNode.component 表示 2.x 兼容层可能已经预创建过实例,此时复用它。
13.2.2 挂载四步曲
mountComponent 的骨架(renderer.ts:1222--1305)可以概括为:
scss
① registerHMR / 注入 keep-alive context / 开启性能测量 (dev/特殊路径)
② setupComponent(instance, false, optimized) ← 解析 props/slots 并执行 setup
③ 判定 asyncDep ← async setup 交给 Suspense
④ setupRenderEffect(...) ← 建渲染 effect 并首次运行
第二步是核心。注意 setupComponent(instance, isSSR, optimized) 传的 optimized 来自父组件的优化标记(父是 block 树编译产物时 optimized=true),它会一路透传到 initSlots/updateSlots,决定"编译期 slots 是否要复制 _ 编译器标记"。第三步只在 __FEATURE_SUSPENSE__ 且 instance.asyncDep 非空时走"注册依赖 + 放占位注释节点"路径(renderer.ts:1273--1288);否则直接 setupRenderEffect。
13.2.3 setupComponent:先 props/slots,再 setup
ts
export function setupComponent(
instance: ComponentInternalInstance,
isSSR = false,
optimized = false,
): Promise<void> | undefined {
isSSR && setInSSRSetupState(isSSR)
const { props, children } = instance.vnode
const isStateful = isStatefulComponent(instance)
initProps(instance, props, isStateful, isSSR)
initSlots(instance, children, optimized || isSSR)
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
isSSR && setInSSRSetupState(false)
return setupResult
}
(component.ts:813--831)
三个要点:
- 顺序是先 props/slots 后 setup 。因为
setup(props, ctx)的第一个参数和ctx.slots依赖这两步的产物。反过来先跑 setup 是无意义的。 isStatefulComponent就是查vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT(component.ts:805--809)。函数式组件没有STATEful位,所以直接跳过setupStatefulComponent,由renderComponentRoot的函数式分支处理。isSSR && setInSSRSetupState(true)开启一个全局标记,作用见 13.8:SSR 下createHook会"吃掉落"非serverPrefetch的钩子注册。注意它在返回前被置回false;异步 setup 情况下,SSR 路径会在.then回调里再次置true(component.ts:900--907)。
setupComponent 的返回值是 Promise<void> | undefined:只有 SSR + async setup 才返回 Promise(setupStatefulComponent 的 return setupResult.then(...)),供 server-renderer await。
13.2.4 setupStatefulComponent 的十步
这是挂载流程里最长的一段(component.ts:833--936)。按执行顺序拆成十步:
步骤 0:dev 校验组件名/局部组件/指令名/compilerOptions (component.ts:839--862)。全部包裹在 __DEV__ 里,生产构建中整块消失。
步骤 1:建 accessCache
ts
instance.accessCache = Object.create(null)
步骤 2:建公共代理
ts
instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers)
if (__DEV__) {
exposePropsOnRenderContext(instance)
}
这里是本章第二个必须记牢的事实:instance.proxy 在 setup() 之前就被创建。 为什么?因为 Options API 的 beforeCreate/data()/methods 都需要 this,而 this === instance.proxy;同时渲染函数 render.call(proxy, ...) 也需要 proxy 已就绪。dev 下额外调用 exposePropsOnRenderContext,把 props 的每个 key 以 getter 形式定义到 ctx 上(componentPublicInstance.ts:713),让控制台里 vm.xxx 与 this.xxx 表现一致。
步骤 3:执行 setup(若存在)
ts
const { setup } = Component
if (setup) {
pauseTracking()
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)
const reset = setCurrentInstance(instance)
const setupResult = callWithErrorHandling(
setup, instance, ErrorCodes.SETUP_FUNCTION,
[
__DEV__ ? shallowReadonly(instance.props) : instance.props,
setupContext,
],
)
...
细节极多,值得逐条拆:
pauseTracking()/resetTracking():在 setup 前后暂停依赖收集。原因见 13.3:setup 期间不应处于任何渲染 effect 的依赖收集上下文里(setup本身不是 effect)。setup.length > 1:只有在声明了第二个参数时才创建setupContext,否则省下一个 Proxy/闭包。setCurrentInstance(instance):把实例设为全局currentInstance,并instance.scope.on()------这是getCurrentInstance()、inject()、生命周期注册能工作的前提。返回的reset用于恢复。__DEV__ ? shallowReadonly(instance.props) : instance.props:开发期传入只读 props 代理,防止用户在 setup 中误改 props;生产环境直接传,省一层 Proxy。
步骤 4:判定异步 setup
ts
const isAsyncSetup = isPromise(setupResult)
resetTracking()
reset()
if ((isAsyncSetup || instance.sp) && !isAsyncWrapper(instance)) {
markAsyncBoundary(instance) // for useId()
}
instance.sp 是 serverPrefetch 钩子槽。markAsyncBoundary 让 useId() 在异步边界处采用不同前缀策略。
步骤 5:异步 setup 的三条分叉
ts
if (isAsyncSetup) {
setupResult.then(unsetCurrentInstance, unsetCurrentInstance)
if (isSSR) {
return setupResult.then(resolvedResult => {
setInSSRSetupState(true)
try { handleSetupResult(instance, resolvedResult, isSSR) }
finally { setInSSRSetupState(false) }
}).catch(e => handleError(e, instance, ErrorCodes.SETUP_FUNCTION))
} else if (__FEATURE_SUSPENSE__) {
instance.asyncDep = setupResult
if (__DEV__ && !instance.suspense) { warn(...) }
} else if (__DEV__) {
warn(`setup() returned a Promise, but ... does not support it yet.`)
}
}
.then(unsetCurrentInstance, unsetCurrentInstance):无论 resolve/reject,都要清掉全局currentInstance,避免泄漏到后续微任务。- SSR:返回 Promise 供 server-renderer 等待,resolve 后在恢复
isInSSRComponentSetup的状态下 继续handleSetupResult。 - 客户端 + Suspense:把 Promise 存进
instance.asyncDep,在此 bail out ------后续由 Suspense 在依赖 resolve 后重新进入setupRenderEffect。若父树里没有<Suspense>,dev 下给出明确警告(这正是"async setup 必须套 Suspense"报错的来源)。
步骤 6:同步 setup 直接 handleSetupResult (component.ts:931)。
步骤 7:没有 setup(纯 Options API)直接 finishComponentSetup (component.ts:934)。
13.2.5 handleSetupResult:返回值分派
ts
export function handleSetupResult(instance, setupResult, isSSR): void {
if (isFunction(setupResult)) {
if (__SSR__ && instance.type.__ssrInlineRender) {
instance.ssrRender = setupResult
} else {
instance.render = setupResult as InternalRenderFunction
}
} else if (isObject(setupResult)) {
if (__DEV__ && isVNode(setupResult)) { warn(`setup() should not return VNodes directly ...`) }
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
instance.devtoolsRawSetupState = setupResult
}
instance.setupState = proxyRefs(setupResult)
if (__DEV__) { exposeSetupStateOnRenderContext(instance) }
} else if (__DEV__ && setupResult !== undefined) {
warn(`setup() should return an object. Received: ...`)
}
finishComponentSetup(instance, isSSR)
}
(component.ts:938--976)
返回值分派规则可以列成一张表:
setup() 返回值 |
处理 | 结果字段 |
|---|---|---|
| 函数 | 作为 render 函数(SSR 内联时作为 ssrRender) |
instance.render / instance.ssrRender |
| 对象 | proxyRefs(...) 自动解包 ref |
instance.setupState |
| VNode | dev 警告"不要直接返回 VNode" | 走对象分支(VNode 是 object) |
undefined |
不警告 | 无 |
| 其它(string/number/null) | dev 警告 | 无 |
这里 proxyRefs(@vue/reactivity)是"setup 返回值自动解包"的实现:访问 setupState.count 时,若 count 是 ref 则自动返回 .value。模板里写的 {{ count }} 无需 .value,靠的就是这一层。
exposeSetupStateOnRenderContext(dev only,componentPublicInstance.ts:733)把 setupState 的每个 key 以 getter 定义到 ctx,并且跳过 __isScriptSetup 为真的对象 ------这是 <script setup> 与普通 setup() 在 devtools/调试体验上的分野:<script setup> 的绑定不挂到 ctx,只能通过 proxy 的 SETUP 分支访问。
13.2.6 finishComponentSetup:补齐 render 与 Options API
ts
export function finishComponentSetup(instance, isSSR, skipOptions?): void {
const Component = instance.type as ComponentOptions
...
// template / render function normalization;可能已被 setup() 设置
if (!instance.render) {
if (!isSSR && compile && !Component.render) {
const template = ... Component.template || resolveMergedOptions(instance).template
if (template) { Component.render = compile(template, finalCompilerOptions) }
}
instance.render = (Component.render || NOOP) as InternalRenderFunction
if (installWithProxy) { installWithProxy(instance) }
}
// support for 2.x options
if (__FEATURE_OPTIONS_API__ && !(__COMPAT__ && skipOptions)) {
const reset = setCurrentInstance(instance)
pauseTracking()
try { applyOptions(instance) } finally { resetTracking(); reset() }
}
if (__DEV__ && !Component.render && instance.render === NOOP && !isSSR) { warn(...) }
}
(component.ts:1002--1104)
四个要点:
if (!instance.render):如果 setup 已经返回了 render 函数,这里整块跳过。否则尝试 runtime compile(仅当构建包含编译器且非 SSR),最后兜底为NOOP(NOOP返回undefined,会被normalizeVNode变成注释节点------这也是 dev 下那个"Component is missing template or render function"警告的触发条件)。installWithProxy:runtime-dom 注册编译器时(registerRuntimeCompiler,component.ts:990)会安装这个钩子;它检查render._rc(isRuntimeCompiled),若是则用RuntimeCompiledPublicInstanceProxyHandlers建withProxy------因为with块需要不同的hastrap(见 13.9)。applyOptions:整块包在暂停依赖收集 +setCurrentInstance里,因为 Options API 的data()/created/...都可能访问this并注册副作用。skipOptions参数:v2 compat 专用,检测到 v2 兼容配置时跳过 Options API 处理,避免双份执行。
13.2.7 组件钩子:为什么找不到 installComponentHooks
很多基于旧版 Vue 3 的教程会讲"createVNode 时 installComponentHooks 把组件钩子装到 vnode.componentHooks 上"。在 3.5.44 中这套机制已经不存在 :packages/runtime-core/src 下已搜不到 installComponentHooks 与 componentHooks。
取而代之的是:组件钩子(onVnodeBeforeMount / onVnodeMounted / onVnodeBeforeUpdate / onVnodeUpdated / onVnodeBeforeUnmount / onVnodeUnmounted)只是普通 vnode props(见 vnode.ts:122--135 的 VNodeProps),由 setupRenderEffect 中的 componentUpdateFn 直接内联调用:
ts
// beforeMount hook
if (bm) { invokeArrayFns(bm) }
// onVnodeBeforeMount
if (!isAsyncWrapperVNode &&
(vnodeHook = props && props.onVnodeBeforeMount)) {
invokeVNodeHook(vnodeHook, parent, initialVNode)
}
(renderer.ts:1360--1370)
这个演进消除了"每个组件 vnode 都要额外挂一个 hooks 对象/函数"的开销,也让 vnode props 成为唯一的钩子载体。读者若对照旧版源码,请以 3.5.x 为准。
13.2.8 挂载全时序图
把上述步骤串成一张时序图(客户端、同步 setup、无 Suspense):
用一张 ASCII 图对照"同步 setup"与"异步 setup":
arduino
同步 setup:
mountComponent → setupComponent → setupStatefulComponent
→ setup() → handleSetupResult → finishComponentSetup → setupRenderEffect → update()
异步 setup(客户端):
mountComponent → setupComponent → setupStatefulComponent
→ setup() returns Promise → instance.asyncDep = p → bail out(返回)
→ parentSuspense.registerDep(instance, setupRenderEffect)
→ 放占位注释节点 placeholder
... promise resolve ... → Suspense 重新调用 setupRenderEffect
异步 setup(SSR):
mountComponent → setupComponent → setupStatefulComponent
→ setup() returns Promise → 返回 promise
→ server-renderer await → .then 内 setInSSRSetupState(true)
→ handleSetupResult → finishComponentSetup
13.3 setup 执行:四个门面与错误边界
13.3.1 createSetupContext:attrs / slots / emit / expose
setup() 的第二个参数是 SetupContext,它由 createSetupContext(component.ts:1141--1196)按需创建。核心在于四个门面全都是"惰性 + 受控"的:
ts
export function createSetupContext(
instance: ComponentInternalInstance,
): SetupContext {
const expose: SetupContext['expose'] = exposed => {
if (__DEV__) {
if (instance.exposed) {
warn(`expose() should be called only once per setup().`)
}
if (exposed != null) {
let exposedType: string = typeof exposed
if (exposedType === 'object') {
if (isArray(exposed)) { exposedType = 'array' }
else if (isRef(exposed)) { exposedType = 'ref' }
}
if (exposedType !== 'object') {
warn(`expose() should be passed a plain object, received ${exposedType}.`)
}
}
}
instance.exposed = exposed || {}
}
if (__DEV__) {
let attrsProxy: Attrs
let slotsProxy: Slots
return Object.freeze({
get attrs() {
return attrsProxy || (attrsProxy = new Proxy(instance.attrs, attrsProxyHandlers) as Attrs)
},
get slots() {
return slotsProxy || (slotsProxy = getSlotsProxy(instance))
},
get emit() {
return (event: string, ...args: any[]) => instance.emit(event, ...args)
},
expose,
})
} else {
return {
attrs: new Proxy(instance.attrs, attrsProxyHandlers) as Attrs,
slots: instance.slots,
emit: instance.emit,
expose,
}
}
}
四个门面的差异用一个表说清:
| 门面 | dev 行为 | prod 行为 | 设计动机 |
|---|---|---|---|
attrs |
惰性建 Proxy(instance.attrs, attrsProxyHandlers) |
立即建同样 Proxy | dev 版本额外 markAttrsAccessed() + set/delete 警告;prod 只做 track |
slots |
惰性建 getSlotsProxy(track(instance, GET, '$slots')) |
直接 instance.slots |
dev 下让 $slots 访问可被 HMR 追踪;prod 无需包装 |
emit |
getter 每次返回新闭包 (event, ...args) => instance.emit(...) |
直接 instance.emit |
dev 下闭包允许 test-utils 覆盖 instance.emit;prod 复用绑定函数 |
expose |
同一实现(`instance.exposed = exposed | {}`) |
几个易错点:
- dev 用
Object.freeze+ getter 。注释写明原因:libs like test-utils overwrite instance properties (overwrites should not be done in prod)。getter 让 attrs/slots/emit 都能反映对 instance 的覆盖。 expose(exposed)里exposed || {}:即使传null/undefined,也会把instance.exposed置为{}(空对象而非null),这样getComponentPublicInstance的if (instance.exposed)判定成立,从而返回exposeProxy而不是 完整proxy。这是<script setup>"默认封闭"的关键:编译器总会注入expose()(哪怕是空调用),使组件默认只暴露空集合。attrs是只读的 。attrsProxyHandlers(component.ts:1106--1127)在 dev 下 set/deleteProperty 都发警告并返回 false;get 则markAttrsAccessed()+track(target, GET, '')------后者让"渲染中是否读取过$attrs"能被依赖系统追踪,用于 fallthrough 警告(componentRenderUtils.ts:180)与更新触发。
13.3.2 setup() 返回值分派回顾
13.2.5 已列出返回值表。这里补一个"何时 render 会被设置、何时不会"的精确判定树:
ini
setup() 返回
├─ 函数
│ ├─ __SSR__ && type.__ssrInlineRender → instance.ssrRender = fn
│ └─ 否则 → instance.render = fn
├─ 对象(含 VNode,dev 警告)
│ ├─ dev/prod-devtools → devtoolsRawSetupState = 原对象
│ ├─ setupState = proxyRefs(原对象)
│ └─ dev → exposeSetupStateOnRenderContext
├─ undefined → 无操作
└─ 其它 → dev 警告
↓
finishComponentSetup:仅在 instance.render 仍为空时才补齐 render
关键点:对象分支不会设置 render ,因此 finishComponentSetup 会用 Component.render(编译注入)或 runtime compile 的 template 补齐。这就是"setup 返回对象 + template 编译产物"组合的运行时基础。
13.3.3 setup 出错处理与 callWithErrorHandling
setup 调用被包在 callWithErrorHandling(setup, instance, ErrorCodes.SETUP_FUNCTION, args)(component.ts:877)里:
ts
export function callWithErrorHandling(
fn: Function,
instance: ComponentInternalInstance | null | undefined,
type: ErrorTypes,
args?: unknown[],
): any {
try {
return args ? fn(...args) : fn()
} catch (err) {
handleError(err, instance, type)
}
}
(errorHandling.ts:70--81)
handleError(errorHandling.ts:112--155)的算法是沿父链向上找 errorCaptured:
kotlin
handleError(err, instance, type):
1. cur = instance.parent
2. while cur:
hooks = cur.ec
若存在:逐个调用 hook(err, exposedInstance, errorInfo)
一旦有 hook 返回 false → 立即 return(错误被"吞掉")
cur = cur.parent
3. 若 appContext.config.errorHandler 存在:
在 pauseTracking/resetTracking 间调用 errorHandler(err, exposedInstance, errorInfo)
return
4. 兜底 logError:
dev:pushWarningContext → warn("Unhandled error during ...") → 默认 throw(throwInDev)
prod:throwUnhandledErrorInProduction ? throw : console.error
几个设计细节:
- 从
instance.parent开始,不含自身 。errorCaptured是"捕获子组件错误"的钩子,因此自己的错误不会被自己的errorCaptured捕获。 exposedInstance = instance.proxy:传给钩子的第二参数是公共代理,保持与 Vue 2.x 一致。- 生产环境错误信息是 URL :
https://vuejs.org/error-reference/#runtime-${type},省去 dev 字符串体积。 throwInDev默认 true :dev 下未处理的错误直接抛出,让开发者立刻看到;但 async 组件加载路径传!errorComponent(apiAsyncComponent.ts:178),即"用户提供了 errorComponent 时不要 throw",让 UI 能优雅降级。
13.3.4 setCurrentInstance 与 EffectScope 的耦合
setCurrentInstance(component.ts:777--785)不只设一个全局变量,它还操作 instance.scope.on():
ts
export const setCurrentInstance = (instance: ComponentInternalInstance) => {
const prev = currentInstance
internalSetCurrentInstance(instance)
instance.scope.on()
return (): void => {
instance.scope.off()
internalSetCurrentInstance(prev)
}
}
export const unsetCurrentInstance = (): void => {
currentInstance && currentInstance.scope.off()
internalSetCurrentInstance(null)
}
EffectScope.on()/off() 是引用计数式的"激活/停用"。在 setCurrentInstance 期间创建的任何 watch/computed/effect 都会被归入 instance.scope,卸载时一次性 stop(),无需用户手动清理。这就是为什么在 setup 里调 watch() 不用返回 stop handle------组件卸载会自动停。 反之,在 withAsyncContext 的异步续体里,清理逻辑(apiSetupHelpers.ts:548--554)会小心地 scope.off() 以平衡引用计数并清空 currentInstance,避免并发异步 setup 之间的实例串味。
13.4 props 体系:归一化、分离、校验与更新
13.4.1 initProps:一次把 props 与 attrs 分开
ts
export function initProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
isStateful: number, // result of bitwise flag comparison
isSSR = false,
): void {
const props: Data = {}
const attrs: Data = createInternalObject()
instance.propsDefaults = Object.create(null)
setFullProps(instance, rawProps, props, attrs)
// ensure all declared prop keys are present
for (const key in instance.propsOptions[0]) {
if (!(key in props)) {
props[key] = undefined
}
}
// validation
if (__DEV__) {
validateProps(rawProps || {}, props, instance)
}
if (isStateful) {
instance.props = isSSR ? props : shallowReactive(props)
} else {
if (!instance.type.props) {
instance.props = attrs // functional w/ optional props, props === attrs
} else {
instance.props = props // functional w/ declared props
}
}
instance.attrs = attrs
}
(componentProps.ts:199--237)
逐条解读:
attrs = createInternalObject():createInternalObject(internalObject.ts)返回一个Object.create(null)空对象(dev 下可能加原型校验)。用无原型对象是为了避免attrs.constructor/toString之类的原型串扰,也让for...in只遍历自有 key。instance.propsDefaults = Object.create(null):一个全新的默认值缓存,供resolvePropValue惰性缓存 default 工厂结果。- 补齐缺失 key :遍历
propsOptions[0]的所有声明 key,props里没有的补undefined。目的是让props的形状稳定------渲染函数访问props.foo时不会因为"这次没传"得到与"传了 undefined"不同的行为,也让响应式系统能对固定 key 建立依赖。 - dev 校验 :
validateProps只在 dev 跑。 instance.props = shallowReactive(props):这是最重要的设计点,见 13.4.5。 SSR 时直接赋props(服务端渲染不需要响应式)。- 函数式组件特殊处理 :
isStateful === 0时,若组件没声明props,则props === attrs(同一对象);若声明了,props用普通对象。原因是函数式组件没有公共代理,props与attrs的分离对它有不同语义。 - 最后
instance.attrs = attrs。
13.4.2 normalizePropsOptions:把声明归一成 [NormalizedProps, needCastKeys]
归一化发生在实例创建时 (component.ts:657),并且带缓存:appContext.propsCache(WeakMap)。
ts
export type NormalizedProps = Record<string, NormalizedProp>
export type NormalizedPropsOptions = [NormalizedProps, string[]] | []
返回结构是"二元组":[归一化 props 表, 需要做值转换的 key 列表]。为什么要后者?因为只有布尔 prop 和带 default 的 prop 需要额外处理(shouldCast/shouldCastTrue/默认值),把它们的 key 单独收集成 needCastKeys,setFullProps 就可以在遍历 rawProps 时跳过它们、最后统一 resolvePropValue,从而避免在热路径里对每个 key 都查一遍。
normalizePropsOptions(componentProps.ts:515--621)的算法:
ini
1. 查缓存,命中即返回
2. 若支持 Options API 且 comp 是对象:
- 归并 appContext.mixins 的 props(asMixin=false 时)
- 归并 comp.extends 的 props
- 归并 comp.mixins 的 props
(每次递归调用自己,asMixin=true → 走 mixinPropsCache)
3. raw = comp.props
若 raw 与 hasExtends 都空:
- 若 comp 是对象 → 缓存 EMPTY_ARR,返回 []
- 否则返回 EMPTY_ARR
4. 数组语法:raw.forEach → camelize(key) → validatePropName → normalized[key] = EMPTY_OBJ
5. 对象语法:for key in raw:
- normalizedKey = camelize(key)
- validatePropName(normalizedKey)
- opt = raw[key]
- prop = isArray(opt) || isFunction(opt) ? { type: opt } : extend({}, opt) ← 拷贝一份,防污染
- 计算 shouldCast / shouldCastTrue(见下)
- prop[BooleanFlags.shouldCast] = shouldCast
prop[BooleanFlags.shouldCastTrue] = shouldCastTrue
- if (shouldCast || hasOwn(prop, 'default')) needCastKeys.push(normalizedKey)
6. res = [normalized, needCastKeys];若 comp 是对象则写缓存
布尔转换的判定逻辑 (componentProps.ts:585--604)是本节的硬核:
ts
if (isArray(propType)) {
for (let index = 0; index < propType.length; ++index) {
const type = propType[index]
const typeName = isFunction(type) && type.name
if (typeName === 'Boolean') { shouldCast = true; break }
else if (typeName === 'String') { shouldCastTrue = false }
}
} else {
shouldCast = isFunction(propType) && propType.name === 'Boolean'
}
含义:
type: Boolean→shouldCast = true:<Comp visible />(值为'')或isAbsent时都要转成布尔。type: [String, Boolean]→ 遍历时先遇到String,shouldCastTrue = false;再遇到Boolean,shouldCast = true。于是"空字符串"不 转true,而是按 String 处理。这解释了那句注释:<Comp checked="">在[String, Boolean]下是字符串"",在[Boolean, String]下是布尔true。- 用
type.name === 'Boolean'而非type === Boolean:注释说明是为了跨 vm/iframe 也能识别(getType同理,componentProps.ts:635)。 extend({}, opt):因为要往 prop 上写BooleanFlags数字键,不能污染用户传入的 options 对象(否则第二次归一化会看到已存在的shouldCast)。这也是缓存成立的前提。
| 声明 | shouldCast | shouldCastTrue | <Comp x /> |
<Comp x="" /> |
不传 x |
|---|---|---|---|---|---|
type: Boolean |
true | true | true |
true |
false |
[String, Boolean] |
true | false | true |
'' |
false |
[Boolean, String] |
true | true | true |
true |
false |
type: String |
false | --- | '' |
'' |
undefined |
{ type: Boolean, default: true } |
true | true | true |
true |
true(default) |
13.4.3 setFullProps:props 与 attrs 的分离算法
ts
function setFullProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
props: Data,
attrs: Data,
) {
const [options, needCastKeys] = instance.propsOptions
let hasAttrsChanged = false
let rawCastValues: Data | undefined
if (rawProps) {
for (let key in rawProps) {
if (isReservedProp(key)) { continue } // key / ref 等保留字跳过
...
const value = rawProps[key]
let camelKey
if (options && hasOwn(options, (camelKey = camelize(key)))) {
if (!needCastKeys || !needCastKeys.includes(camelKey)) {
props[camelKey] = value
} else {
;(rawCastValues || (rawCastValues = {}))[camelKey] = value
}
} else if (!isEmitListener(instance.emitsOptions, key)) {
// 非声明 prop 且非 emit 监听器 → attrs,保留原始 key 大小写
if (!(key in attrs) || value !== attrs[key]) {
attrs[key] = value
hasAttrsChanged = true
}
}
}
}
if (needCastKeys) {
const rawCurrentProps = toRaw(props)
const castValues = rawCastValues || EMPTY_OBJ
for (let i = 0; i < needCastKeys.length; i++) {
const key = needCastKeys[i]
props[key] = resolvePropValue(
options!, rawCurrentProps, key, castValues[key], instance,
!hasOwn(castValues, key),
)
}
}
return hasAttrsChanged
}
(componentProps.ts:380--455)
分离规则可以精确表述为三分支:
vbnet
for key in rawProps:
├─ isReservedProp(key) → 丢弃(key/ref 等)
├─ declared prop (camelize 后命中) → 进 props
│ ├─ 不在 needCastKeys → 直接赋值(快路径)
│ └─ 在 needCastKeys → 暂存 rawCastValues,稍后 resolvePropValue
└─ 否则
├─ isEmitListener(emitsOptions, key) → 丢弃(它是事件监听器)
└─ 其余 → 进 attrs(保留原 key 大小写)
三个易错点:
camelize(key)只在查声明时用 。写入 attrs 时保留原始大小写 (componentProps.ts:421--422注释:Make sure to preserve original key casing),否则data-foo会变成dataFoo透传到 DOM。isReservedProp:@vue/shared里定义为key,ref,ref_for,ref_key等。它们由 vnode 本身消费,不该进 props/attrs。hasAttrsChanged只在 attrs 值真正变化时置 true (!(key in attrs) || value !== attrs[key])。这个返回值决定updateProps是否trigger(instance.attrs, SET, ''),避免无谓的下游重渲染。
13.4.4 resolvePropValue:默认值与布尔转换
ts
function resolvePropValue(options, props, key, value, instance, isAbsent) {
const opt = options[key]
if (opt != null) {
const hasDefault = hasOwn(opt, 'default')
// default values
if (hasDefault && value === undefined) {
const defaultValue = opt.default
if (opt.type !== Function && !opt.skipFactory && isFunction(defaultValue)) {
const { propsDefaults } = instance
if (key in propsDefaults) {
value = propsDefaults[key]
} else {
const reset = setCurrentInstance(instance)
value = propsDefaults[key] = defaultValue.call(
__COMPAT__ && isCompatEnabled(DeprecationTypes.PROPS_DEFAULT_THIS, instance)
? createPropsDefaultThis(instance, props, key) : null,
props,
)
reset()
}
} else {
value = defaultValue
}
if (instance.ce) { instance.ce._setProp(key, value) } // #9006
}
// boolean casting
if (opt[BooleanFlags.shouldCast]) {
if (isAbsent && !hasDefault) {
value = false
} else if (opt[BooleanFlags.shouldCastTrue] && (value === '' || value === hyphenate(key))) {
value = true
}
}
}
return value
}
(componentProps.ts:457--511)
规则细读:
- default 只在该 key 的值为
undefined时生效 (value === undefined)。传null不触发 default------这是与 Vue 2 一致、也最常被误解的一点。 - 工厂 vs 字面量 :
default是函数且type !== Function且未skipFactory时,按工厂调用(defaultValue.call(null, props));否则当字面量。skipFactory由mergeDefaults在__skip_xxx场景设置(apiSetupHelpers.ts:462)。type: Function时,函数 default 本身就是要返回的函数,不调用。 propsDefaults缓存 :工厂结果被缓存,使得同一 props 对象下多次resolvePropValue(例如组件更新时 props 被重新计算)得到同一个默认值对象,避免 default 工厂每次返回新对象导致的 watcher 误触发。注释明确写了动机:caching the value returned from props default factory functions to avoid unnecessary watcher trigger。setCurrentInstance(instance)包裹 :default 工厂里可能用getCurrentInstance()/inject(),因此要建立实例上下文,调用后reset()。- 布尔转换 :
isAbsent && !hasDefault→false;shouldCastTrue && (value === '' || value === hyphenate(key))→true。第二个条件覆盖了"在 DOM 里写<Comp checked="checked">会被 HTML 解析为空字符串或同名"的情况。
13.4.5 为什么 props 必须是 shallowReactive
这是面试高频问题,源码给出了答案的线索:instance.props = isSSR ? props : shallowReactive(props)(componentProps.ts:226)。
三个理由:
- props 是"整体替换 + 逐 key 更新",不是深层可变 。父组件更新子组件时,运行时通过
updateProps逐 key 写入props;深层对象(如props.user.name)的变更由父组件侧的响应式 驱动(父渲染时读取user.name会追踪依赖),子组件无需也不应对 props 深层建立响应式。若用reactive,会为每个嵌套对象建 Proxy,既费内存又可能造成父/子双重追踪。 props顶层是响应式的,才能触发子组件渲染 。子组件渲染函数读取props.foo时,需要在foo变化时重新渲染,因此顶层必须是响应式。shallowReactive正好提供"顶层 key 依赖追踪"。- 只读语义 。虽然
shallowReactive本身可写,但 dev 下传给setup()的是shallowReadonly(props),且PublicInstanceProxyHandlers.set对hasOwn(instance.props, key)直接警告并返回 false(componentPublicInstance.ts:589--591,Attempting to mutate prop "...")------写 props 在运行期被显式禁止。
再补一个隐秘细节:updateProps 里大量用 toRaw(props)(componentProps.ts:257、439)。因为 props 是 shallowReactive 代理,读写会走 Proxy;用 raw 引用可以绕过 Proxy 直接比较值,既提速又避免"读 raw 触发 track"。
13.4.6 validateProps:类型/必填校验矩阵(dev only)
validateProps(componentProps.ts:658--677)遍历所有声明 key,调用 validateProp:
ts
function validateProp(name, value, prop, props, isAbsent) {
const { type, required, validator, skipCheck } = prop
if (required && isAbsent) { warn('Missing required prop: "' + name + '"'); return }
if (value == null && !required) { return }
if (type != null && type !== true && !skipCheck) {
// 多类型任一匹配即可
...
if (!isValid) { warn(getInvalidTypeMessage(name, value, expectedTypes)); return }
}
if (validator && !validator(value, props)) {
warn('Invalid prop: custom validator check failed for prop "' + name + '".')
}
}
校验优先级与短路规则:
| 顺序 | 条件 | 行为 |
|---|---|---|
| 1 | required && isAbsent |
警告 Missing required;不再做类型校验 |
| 2 | value == null && !required |
放过(undefined/null 对非必填合法) |
| 3 | type != null && type !== true && !skipCheck |
类型匹配;多类型任一命中即通过 |
| 4 | validator 存在且返回 false |
警告自定义校验失败 |
assertType(componentProps.ts:733--759)的匹配矩阵:
expectedType |
判定 |
|---|---|
'null'(type === null) |
value === null |
'String'/'Number'/'Boolean'/'Function'/'Symbol'/'BigInt' |
typeof value === expectedType.toLowerCase();若为 'object' 再尝试 value instanceof type(原始包装对象) |
'Object' |
isObject(value) |
'Array' |
isArray(value) |
| 其它构造器 | value instanceof type |
isAbsent 的判定 (componentPublicInstance 无关,在 componentProps.ts:665):
ts
const camelizePropsKey = Object.keys(rawProps).map(key => camelize(key))
...
validateProp(key, resolvedValues[key], opt, ..., !camelizePropsKey.includes(key))
即"原始 props 里(camelize 后)没有这个 key"才算 absent。这样 required 的判定能正确处理 kebab-case 传入。
getInvalidTypeMessage(componentProps.ts:764--796)会尽量给出可读信息:Expected String with value "x", got Number with value 1.。它用 styleValue(Symbol 转字符串、String 加引号、Number 数字化)与 isExplicable/isCoercible 决定是否附上具体值。
13.4.7 updateProps:三条更新路径
ts
export function updateProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
rawPrevProps: Data | null,
optimized: boolean,
): void {
const {
props, attrs,
vnode: { patchFlag },
} = instance
const rawCurrentProps = toRaw(props)
const [options] = instance.propsOptions
let hasAttrsChanged = false
if (
// always force full diff in dev
!(__DEV__ && isInHmrContext(instance)) &&
(optimized || patchFlag > 0) &&
!(patchFlag & PatchFlags.FULL_PROPS)
) {
if (patchFlag & PatchFlags.PROPS) {
const propsToUpdate = instance.vnode.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
let key = propsToUpdate[i]
if (isEmitListener(instance.emitsOptions, key)) { continue }
const value = rawProps![key]
if (options) {
if (hasOwn(attrs, key)) {
if (value !== attrs[key]) { attrs[key] = value; hasAttrsChanged = true }
} else {
const camelizedKey = camelize(key)
props[camelizedKey] = resolvePropValue(options, rawCurrentProps, camelizedKey, value, instance, false)
}
} else {
if (value !== attrs[key]) { attrs[key] = value; hasAttrsChanged = true }
}
}
}
} else {
// full props update
if (setFullProps(instance, rawProps, props, attrs)) { hasAttrsChanged = true }
// 清理旧 key
let kebabKey: string
for (const key in rawCurrentProps) {
if (!rawProps ||
(!hasOwn(rawProps, key) &&
((kebabKey = hyphenate(key)) === key || !hasOwn(rawProps, kebabKey)))) {
if (options) {
if (rawPrevProps && (rawPrevProps[key] !== undefined || rawPrevProps[kebabKey!] !== undefined)) {
props[key] = resolvePropValue(options, rawCurrentProps, key, undefined, instance, true)
}
} else {
delete props[key]
}
}
}
if (attrs !== rawCurrentProps) {
for (const key in attrs) {
if (!rawProps || (!hasOwn(rawProps, key) && (!__COMPAT__ || !hasOwn(rawProps, key + 'Native')))) {
delete attrs[key]; hasAttrsChanged = true
}
}
}
}
if (hasAttrsChanged) { trigger(instance.attrs, TriggerOpTypes.SET, '') }
if (__DEV__) { validateProps(rawProps || {}, props, instance) }
}
(componentProps.ts:246--378)
三条路径的触发条件:
arduino
if (dev && HMR 上下文) → 全量(Optimized 关闭)
else if (optimized || patchFlag > 0) → 编译期优化路径
├─ patchFlag & FULL_PROPS → 全量
└─ patchFlag & PROPS → 只遍历 dynamicProps
else → 手动 render:全量
PatchFlags.PROPS 路径 (编译期 props 无 key 增减)只读 vnode.dynamicProps 里列出的 key,逐个按"是 attrs 还是 prop"更新。这里有个精妙的假设:注释说 attr / props separation was done on init and will be consistent in this code path------即"初始化时的分离结果在 PROPS 路径里保持成立",所以只需 hasOwn(attrs, key) 判断即可,无需再查 propsOptions。
全量路径做了三件事:
setFullProps重算 props/attrs;- 对
rawCurrentProps(旧的 props)中"新 rawProps 里已不存在"的 key:若声明了 options 且有旧值,则resolvePropValue(..., undefined, isAbsent=true)重置为默认值(关键:不是删除,而是回退默认值 ,因为 key 在propsOptions里恒存在);否则删除。 - 对 attrs 中"新 rawProps 里已不存在"的 key删除。
kebab-case 兜底 :(kebabKey = hyphenate(key)) 那一段处理了"旧 props 是 fooBar、新 props 用 foo-bar 传"的情况,避免误判为删除。
最后 trigger(instance.attrs, SET, '') :注释说 trigger updates for $attrs in case it's used in component slots。因为 slots 里的 v-bind="$attrs" 可能在子渲染中读取 attrs;attrs 顶层的"通配 track"(component.ts:1109 的 track(target, GET, ''))让这个 trigger 能命中。
13.4.8 props 解构与 toRef:编译期与运行期的配合
<script setup> 里 const { foo } = defineProps() 的"解构后仍保持响应式",是靠编译器改写 + 运行时 helper 完成的:
- 编译器把对
foo的引用改写为__props.foo(响应式访问),或在props destructure开启时用toRef语义; - 运行期提供
createPropsRestProxy(props, excludedKeys)(apiSetupHelpers.ts:488--502):为...rest场景建一个"惰性转发"的代理,每个 key 用 getter 转发到原 props。这样 rest 对象既不含被解构的 key,又保持响应性; mergeDefaults(apiSetupHelpers.ts:443)与mergeModels(apiSetupHelpers.ts:474)是编译器 hint helper:前者把withDefaults转换为运行时 props 声明(含skipFactory处理),后者合并defineModel产生的 props/emits。
运行时的关键约束是:props 对象是 shallowReactive 的,所以解构出来的基本类型值会丢失响应性 (值被拷走),而 toRef(props, key) 保留的是"到 props 顶层 key 的引用"。这解释了为何 Vue 编译期要对解构做特殊改写:不是为了方便,而是为了在"值语义"与"响应式引用语义"之间架桥。
13.5 emit 体系:从 emits 声明到 onXxx 匹配
13.5.1 normalizeEmitsOptions:归一为对象
emits 可以是数组或对象,归一化后统一为 ObjectEmitsOptions(Record<string, Fn | null | any[]>)。normalizeEmitsOptions(componentEmits.ts:238--291)与 props 归一化结构同构:
markdown
1. 查 appContext.emitsCache / mixinEmitsCache
2. 支持 Options API 时归并 appContext.mixins / comp.extends / comp.mixins
3. raw 与 hasExtends 皆空 → 缓存 null,返回 null
4. 数组语法:raw.forEach(key => normalized[key] = null)
对象语法:extend(normalized, raw)
5. 缓存(comp 是对象时)并返回
关键差异:
- 返回
null而非[]。没有 emits 声明时返回null,后续所有isEmitListener(emitsOptions, key)都能因!options立刻返回 false,省一次isOn。 - 数组语法把每个 event 映射为
null,表示"声明了此事件但不做参数校验"。对象语法保留值:若值是函数则作为 validator。
13.5.2 emit 函数:一次调用里的全部逻辑
instance.emit = emit.bind(null, instance),因此 emit(event, ...args) 的实现如下(componentEmits.ts:113--235):
ts
export function emit(
instance: ComponentInternalInstance,
event: string,
...rawArgs: any[]
): ComponentPublicInstance | null | undefined {
if (instance.isUnmounted) return
const props = instance.vnode.props || EMPTY_OBJ
// ---- dev:事件声明校验 + validator ----
if (__DEV__) {
const { emitsOptions, propsOptions: [propsOptions] } = instance
if (emitsOptions) {
if (!(event in emitsOptions) && !(__COMPAT__ && (event.startsWith('hook:') || ...))) {
if (!propsOptions || !(toHandlerKey(camelize(event)) in propsOptions)) {
warn(`Component emitted event "${event}" but it is neither declared in ` +
`the emits option nor as an "${toHandlerKey(camelize(event))}" prop.`)
}
} else {
const validator = emitsOptions[event]
if (isFunction(validator)) {
const isValid = validator(...rawArgs)
if (!isValid) { warn(`Invalid event arguments: event validation failed for event "${event}".`) }
}
}
}
}
// ---- v-model 修饰符 ----
let args = rawArgs
const isCompatModelListener = __COMPAT__ && compatModelEventPrefix + event in props
const isModelListener = isCompatModelListener || event.startsWith('update:')
const modifiers = isCompatModelListener
? props.modelModifiers
: isModelListener && getModelModifiers(props, event.slice(7))
if (modifiers) {
if (modifiers.trim) { args = rawArgs.map(a => (isString(a) ? a.trim() : a)) }
if (modifiers.number) { args = args.map(looseToNumber) }
}
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { devtoolsComponentEmit(instance, event, args) }
// ---- dev:大小写提示 ----
if (__DEV__) {
const lowerCaseEvent = event.toLowerCase()
if (lowerCaseEvent !== event && props[toHandlerKey(lowerCaseEvent)]) {
warn(`Event "${lowerCaseEvent}" is emitted ... but the handler is registered for "${event}" ...`)
}
}
// ---- 找 handler:驼峰/连字符三重查找 ----
let handlerName
let handler =
props[(handlerName = toHandlerKey(event))] ||
props[(handlerName = toHandlerKey(camelize(event)))]
if (!handler && isModelListener) {
handler = props[(handlerName = toHandlerKey(hyphenate(event)))]
}
if (handler) {
callWithAsyncErrorHandling(handler, instance, ErrorCodes.COMPONENT_EVENT_HANDLER, args)
}
// ---- .once ----
const onceHandler = props[handlerName + `Once`]
if (onceHandler) {
if (!instance.emitted) { instance.emitted = {} }
else if (instance.emitted[handlerName]) { return }
instance.emitted[handlerName] = true
callWithAsyncErrorHandling(onceHandler, instance, ErrorCodes.COMPONENT_EVENT_HANDLER, args)
}
...
}
按阶段分解:
| 阶段 | 行为 | 生产构建是否保留 |
|---|---|---|
| 卸载短路 | if (instance.isUnmounted) return |
是 |
| 声明校验 | 未声明且非 onXxx prop → 警告 |
否(dev) |
| validator | 声明值是函数则调用,返回 false 警告 | 否(dev) |
| v-model 修饰符 | trim/number 对 args 做变换 |
是 |
| devtools | devtoolsComponentEmit |
dev / prod-devtools |
| 大小写提示 | 事件名有大小写但只注册了全小写 → 警告 | 否(dev) |
| handler 查找 | onXxx → onCamelized → (model)onKebab |
是 |
| 调用 | callWithAsyncErrorHandling(..., COMPONENT_EVENT_HANDLER, args) |
是 |
.once |
handlerName + 'Once' 查找,去重后调用 |
是 |
toHandlerKey 的三重查找 是本节的算法重点。设事件名 event:
scss
props['on' + capitalize(event)] // "click" → "onClick"
props['on' + capitalize(camelize(event))] // "my-event" → "onMyEvent"
(仅 update:xxx)props['on' + capitalize(hyphenate(event))] // kebab → "onUpdate:my-event"
为什么需要三重?因为事件名可能来自 emit('my-event')、模板里 @my-event 或 @myEvent、以及 kebab-case props 传递。这三条覆盖了框架内所有合法的监听写法。
13.5.3 .once 的去重与 emitted
.once 的语义是"只触发一次"。实现用 instance.emitted 记录已调用过的 handlerName:
ts
if (!instance.emitted) { instance.emitted = {} }
else if (instance.emitted[handlerName]) { return }
instance.emitted[handlerName] = true
注意 return 会提前结束整个 emit (连 compat 后续处理也不走)。这是刻意的:一旦 once 处理过,本次 emit 就该收尾。
13.5.4 isEmitListener:onXxx 与声明事件的匹配
这是 props/attrs 分离与更新判定都要用的核心谓词(componentEmits.ts:296--316):
ts
export function isEmitListener(
options: ObjectEmitsOptions | null,
key: string,
): boolean {
if (!options || !isOn(key)) { return false }
if (__COMPAT__ && key.startsWith(compatModelEventPrefix)) { return true }
key = key.slice(2) // 去掉 "on"
key = key === 'Once' ? key : key.replace(/Once$/, '') // #8342
return (
hasOwn(options, key[0].toLowerCase() + key.slice(1)) || // 首字母小写
hasOwn(options, hyphenate(key)) || // 连字符
hasOwn(options, key) // 原样
)
}
算法:onClick → 去 on 得 Click → 依次尝试 click、click(hyphenate 后同)、Click。因此 emits: { click: null } 能匹配 onClick/onclick 两种 props 写法。
#8342 的处理很精细:onOnce 应被当成事件 once(key.slice(2) 得 'Once',特判保留),而 onOnceOnce 要去掉尾部的 Once 得到 Once → 事件 once。否则 .once 修饰符生成的 onXxxOnce 会被误判成事件 xxxOnce。
13.5.5 emitsOptions 在 props/attrs 分离中的角色
回顾 setFullProps:落到 else if (!isEmitListener(instance.emitsOptions, key)) 才进 attrs。因此声明了 emits: ['click'] 后,传入的 onClick 不会 进 attrs 透传到根元素------这是"声明 emits 可以阻止事件监听器被当 attrs 透传"的实现。
同理 updateProps 的 PROPS 路径里 if (isEmitListener(instance.emitsOptions, key)) { continue }:事件监听器的变化不参与 props 更新,而是由 vnode props 的 handler 变化在父侧触发子更新(见 13.7 的 hasPropsChanged,它对 emit listener 的差异会跳过)。
13.6 slots 体系:编译提示与运行时归一化
13.6.1 initSlots:两条主路径
ts
export const initSlots = (
instance: ComponentInternalInstance,
children: VNodeNormalizedChildren,
optimized: boolean,
): void => {
const slots = (instance.slots = createInternalObject())
if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
const type = (children as RawSlots)._
if (type) {
assignSlots(slots, children as Slots, optimized)
// make compiler marker non-enumerable
if (optimized) { def(slots, '_', type, true) }
} else {
normalizeObjectSlots(children as RawSlots, slots, instance)
}
} else if (children) {
normalizeVNodeSlots(instance, children)
}
}
(componentSlots.ts:185--205)
分派条件:
arduino
vnode.shapeFlag & SLOTS_CHILDREN ?
├─ children._ 存在(编译器生成的 slots 对象)→ assignSlots(直接引用,不归一化)
└─ 否则(手写 render 的 slots 对象)→ normalizeObjectSlots(每个 slot 包装 withCtx)
: children 存在(直接传 VNode/文本)→ normalizeVNodeSlots(包成 default slot)
为什么"编译器生成的 slots"要区别对待 :编译器已经用 withCtx 包好了槽函数(vSlot.ts 生成的 withCtx(() => [...])),并给每个槽标了 _c=true、给 slots 对象标了 _ = SlotFlags.XXX。运行时若再走 normalizeSlot,就是重复包装。因此 assignSlots(componentSlots.ts:169--183)只做"按 key 复制",并有一条 #2893 的特殊处理:
ts
const assignSlots = (slots, children, optimized) => {
for (const key in children) {
// #2893
// when rendering the optimized slots by manually written render function,
// do not copy the `slots._` compiler flag so that `renderSlot` creates
// slot Fragment with BAIL patchFlag to force full updates
if (optimized || !isInternalKey(key)) {
slots[key] = children[key]
}
}
}
isInternalKey(componentSlots.ts:84)判定 _/_ctx/$stable 三个保留 key。当 optimized 为假(手写 render),不复制 _ 标记,从而让 renderSlot 走 PatchFlags.BAIL(全量 diff)------因为手写 render 的槽未必遵守编译器的稳定性假设。
def(slots, '_', type, true)(componentSlots.ts:197)把 _ 定义为不可枚举属性,避免它出现在 v-for="... in slots" 之类的遍历里,同时保留 slots._ 的读取能力。
13.6.2 normalizeSlot 家族
normalizeSlotValue (componentSlots.ts:87--90):把一个值归一成 VNode[]------数组则逐项 normalizeVNode,否则整体 normalizeVNode 后包成单元素数组。
normalizeSlot (componentSlots.ts:92--119):把"用户函数"包成带上下文的槽函数。
ts
const normalizeSlot = (key, rawSlot, ctx): Slot => {
if ((rawSlot as any)._n) { return rawSlot as Slot } // already normalized - #5353
const normalized = withCtx((...args: any[]) => {
if (__DEV__ && currentInstance &&
!(ctx === null && currentRenderingInstance) &&
!(ctx && ctx.root !== currentInstance.root)) {
warn(`Slot "${key}" invoked outside of the render function: ...`)
}
return normalizeSlotValue(rawSlot(...args))
}, ctx) as Slot
;(normalized as ContextualRenderFn)._c = false // NOT a compiled slot
return normalized
}
三个关键点:
_n幂等 :已经归一化过的槽(_n === true)直接返回,避免多层组件透传时重复包装(#5353)。- dev 警告"在渲染函数外调用槽":这行警告检测"当前有实例但不在该槽的渲染上下文里",提示槽函数若在 render 外调用就不会追踪依赖。
_c = false:包裹后显式标记为"非编译槽"。因为withCtx默认置_c = true(见 13.6.3),而手工槽不是编译器产物,必须改回 false,否则renderSlot会把它当编译槽做块追踪优化。
normalizeObjectSlots (componentSlots.ts:121--149):遍历 rawSlots 的非内部 key:
ts
for (const key in rawSlots) {
if (isInternalKey(key)) continue
const value = rawSlots[key]
if (isFunction(value)) {
slots[key] = normalizeSlot(key, value, ctx) // 函数槽
} else if (value != null) {
if (__DEV__) { warn(`Non-function value encountered for slot "${key}". Prefer function slots ...`) }
const normalized = normalizeSlotValue(value)
slots[key] = () => normalized // 值槽:包成常量函数
}
}
ctx = rawSlots._ctx:这是"槽的拥有者实例",在父组件 normalizeChildren 时挂上,供 withCtx 恢复渲染上下文。
normalizeVNodeSlots (componentSlots.ts:151--167):当子组件收到的是直接 children(非对象),把整块 children 归一后作为 default:
ts
const normalized = normalizeSlotValue(children)
instance.slots.default = () => normalized
dev 下若 !isKeepAlive(instance.vnode) 则警告"default 槽用了非函数值"。KeepAlive 例外是因为它故意用 VNode 形式传 children。
13.6.3 withCtx:槽函数的上下文封装
withCtx(componentRenderContext.ts:70--125)是槽机制的核心。它把槽函数包一层"进入渲染上下文 + 块追踪控制":
ts
export function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot?): Function {
if (!ctx) return fn
if ((fn as ContextualRenderFn)._n) { return fn }
const renderFnWithContext: ContextualRenderFn = (...args: any[]) => {
if (renderFnWithContext._d) { setBlockTracking(-1) }
const prevInstance = setCurrentRenderingInstance(ctx)
const prevStackSize = blockStack.length
let res
try {
res = fn(...args)
} finally {
for (let i = blockStack.length; i > prevStackSize; i--) closeBlock()
setCurrentRenderingInstance(prevInstance)
if (renderFnWithContext._d) { setBlockTracking(1) }
}
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { devtoolsComponentUpdated(ctx) }
return res
}
renderFnWithContext._n = true // already normalized
renderFnWithContext._c = true // compiled
renderFnWithContext._d = true // disable block tracking by default
if (__COMPAT__ && isNonScopedSlot) { renderFnWithContext._ns = true }
return renderFnWithContext
}
五个 flag(ContextualRenderFn 类型,componentRenderContext.ts:58--64):
| flag | 含义 | 动机 |
|---|---|---|
_n |
already normalized | 幂等,防重复包装 |
_c |
compiled slot | vnode.normalizeChildren 据此设置 SLOTS_CHILDREN 的稳定性标记 |
_d |
disableTracking | 默认禁用块追踪,防止在模板表达式里手工调用编译槽打乱块树(#1745) |
_ns |
non-scoped slot | v2 compat 区分作用域/普通槽 |
(renderSlot 中临时翻 _d = false) |
--- | 渲染 <slot/> 时是模板驱动,可安全启用块追踪 |
为什么默认禁用块追踪 :假设模板里写了 {{ $slots.default() }},这会在渲染表达式中直接调用槽函数;若槽函数内部 openBlock 开了块,会污染外层正在构建的块树。withCtx 因此默认 _d = true,在进入前 setBlockTracking(-1)(关),退出后 +1(开)。而 renderSlot 明确知道自己在渲染 <slot/>,所以临时把 _d 翻成 false(renderSlot.ts:85--87、118--120)------这样编译槽在被 <slot/> 调用时才启用块追踪。
closeBlock 清理 (componentRenderContext.ts:99):注释 #15070 说明------若槽函数在块中途抛错,会留下"悬空的块",需要 for (let i = blockStack.length; i > prevStackSize; i--) closeBlock() 把它们收掉,否则后续创建的所有 vnode 都会被记进一个错误的块。
13.6.4 $stable / _ / _ctx:三个编译魔法字段
RawSlots 类型(componentSlots.ts:63--82)定义了三个内部 key:
| 字段 | 类型 | 谁写 | 作用 |
|---|---|---|---|
_ |
SlotFlags |
编译器(vSlot.ts:351--362) |
标记整套 slots 的稳定性(STABLE/DYNAMIC/FORWARDED) |
$stable |
boolean |
手写 render 时可显式传 | 提示"这次 slots 内容没变,不必强制子更新" |
_ctx |
ComponentInternalInstance |
normalizeChildren 时挂上 |
槽函数的拥有者实例,供 withCtx 恢复上下文 |
编译器生成的三档 SlotFlags(@vue/shared 的 slotFlags.ts)语义:
STABLE = 1:槽只引用槽 props 或自身上下文状态,能完整捕获依赖 → 父组件不必强制子更新。DYNAMIC = 2:槽引用了作用域变量(v-for项、外层 slot prop)或有条件结构(v-if/v-for)→ 父必须强制子更新。FORWARDED = 3:<slot/>被透传给子组件,是否更新取决于父自身收到的槽类型------这个必须在运行时(normalizeChildren)再细化。
vSlot.ts:351--362 的判定:
ts
const slotFlag = ... hasScopeRef(node, context.identifiers) ? SlotFlags.DYNAMIC
: isSlotOutlet(node) ? SlotFlags.FORWARDED
: SlotFlags.STABLE
13.6.5 updateSlots:稳定性判定与陈旧槽清理
ts
export const updateSlots = (instance, children, optimized): void => {
const { vnode, slots } = instance
let needDeletionCheck = true
let deletionComparisonTarget = EMPTY_OBJ
if (vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
const type = (children as RawSlots)._
if (type) {
// compiled slots
if (__DEV__ && isHmrUpdating) {
assignSlots(slots, children as Slots, optimized)
trigger(instance, TriggerOpTypes.SET, '$slots')
} else if (optimized && type === SlotFlags.STABLE) {
// compiled AND stable → 不更新,且跳过陈旧槽删除
needDeletionCheck = false
} else {
assignSlots(slots, children as Slots, optimized)
}
} else {
needDeletionCheck = !(children as RawSlots).$stable
normalizeObjectSlots(children as RawSlots, slots, instance)
}
deletionComparisonTarget = children as RawSlots
} else if (children) {
normalizeVNodeSlots(instance, children)
deletionComparisonTarget = { default: 1 }
}
// delete stale slots
if (needDeletionCheck) {
for (const key in slots) {
if (!isInternalKey(key) && deletionComparisonTarget[key] == null) {
delete slots[key]
}
}
}
}
(componentSlots.ts:207--251)
稳定性判定(题目所说的 isSlotStable 在此版本体现为条件分支):
ini
type(编译标记)存在 ?
├─ dev && HMR 更新 → 强制 assignSlots + trigger '$slots'(内容可能已变)
├─ optimized && type === STABLE → 完全不更新(needDeletionCheck = false)
└─ 其它(DYNAMIC/FORWARDED/非 optimized)→ assignSlots 更新
: 手写 slots 对象
├─ needDeletionCheck = !$stable ← $stable 为真则跳过删除
└─ normalizeObjectSlots(重新包装)
四个分支对应四类场景,逐个解释:
- HMR :父组件被热更新,槽内容可能变了,即使标记为 STABLE 也要强制刷新,并
trigger(instance, SET, '$slots')通知依赖$slots的渲染。 optimized && STABLE:这是"零更新"快路径------编译器保证槽的依赖被槽自身完整捕获(父渲染时已经追踪),子组件无需更新槽,needDeletionCheck=false也避免误删(因为"没更新"不等于"槽没了")。- DYNAMIC / FORWARDED :只
assignSlots更新槽函数引用,但跳过归一化(编译槽已归一化)。 - 手写 slots :
$stable是用户显式承诺"内容没变";为真则连陈旧槽删除都跳过(因为"没变"意味着删除逻辑没必要)。
陈旧槽删除 :遍历现有 slots,凡是不在 deletionComparisonTarget 里的非内部 key 都被删。这处理了"父组件这次不再传某具名槽"的情况,避免子组件继续渲染一个已废弃的槽。
13.6.6 dynamic slots 与 createSlots
当 slots 对象本身是动态的(如 v-for 动态具名槽),编译器会调用 createSlots helper 生成"动态槽对象",并给它附 _ = SlotFlags.DYNAMIC 或为 v-if 分支附 branch key。renderSlot(helpers/renderSlot.ts:93--110)在构建 Fragment 时按 slots._ === SlotFlags.STABLE 决定 patchFlag:
ts
validSlotContent && (slots as RawSlots)._ === SlotFlags.STABLE
? PatchFlags.STABLE_FRAGMENT
: PatchFlags.BAIL
- 稳定槽 →
STABLE_FRAGMENT(1 << 6):子节点顺序稳定,patch 时无需做带 key 的 diff。 - 非稳定 →
BAIL:强制走全量 diff,保证动态槽内容正确。
shouldUpdateComponent 里 PatchFlags.DYNAMIC_SLOTS(1 << 10)会在父更新时让 if (patchFlag & PatchFlags.DYNAMIC_SLOTS) return true------这就是"动态槽每次父更新都强制子更新"的判定入口(见 13.7)。
13.6.7 $slots 与 $scopedSlots 的差异
Vue 3 没有 $scopedSlots。publicPropertiesMap 只映射了 $slots(componentPublicInstance.ts:410)。在 Vue 3 里所有槽默认都是"可接收 slot props 的函数",作用域槽与非作用域槽的差别只体现在 withCtx 的 _ns flag(v2 compat)与编译期判定上。
在 v2 兼容构建里,$scopedSlots 由 compat 层补上:它会把 $slots 的每个槽包装成"旧式作用域槽函数",并保留 this.$scopedSlots.default({...}) 的调用形态。原生 Vue 3 代码不应依赖 $scopedSlots。
总结三者的关系:
| 概念 | Vue 3 呈现 | 说明 |
|---|---|---|
$slots |
instance.slots(dev 下 shallowReadonly) |
统一的槽集合,每个值是函数 |
$scopedSlots |
不存在(compat 构建补齐) | v2 遗留 |
slots.default |
一个函数,可传 slot props | 默认槽 |
withProxy/withCtx |
上下文封装 | 保证槽内 this/依赖追踪正确 |
13.7 组件更新:next 机制与 shouldUpdateComponent
13.7.1 updateComponent:三种结果
父组件重新渲染后拿到新的子组件 vnode n2,进入 updateComponent(renderer.ts:1307--1341):
ts
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
const instance = (n2.component = n1.component)!
if (shouldUpdateComponent(n1, n2, optimized)) {
if (__FEATURE_SUSPENSE__ && instance.asyncDep && !instance.asyncResolved) {
// async & still pending - just update props and slots
n2.el = n1.el
n2.placeholder = n1.placeholder
updateComponentPreRender(instance, n2, optimized)
return
} else {
// normal update
instance.next = n2 // ← 挂起新 vnode
instance.update() // ← 触发渲染 effect
}
} else {
// no update needed. just copy over properties
n2.el = n1.el
n2.placeholder = n1.placeholder
instance.vnode = n2
}
}
三种结果:
| 条件 | 行为 |
|---|---|
| 需要更新 & 普通组件 | instance.next = n2 + instance.update() |
| 需要更新 & 异步未结算 | 只 updateComponentPreRender(同步 props/slots),不触发渲染 |
| 不需要更新 | 只复制 el/placeholder 并改指 instance.vnode = n2 |
第三分支很重要:即使子组件不更新,也要把实例的 vnode 指针换到新 vnode ,否则后续卸载会挂到旧 vnode 上、el 引用也会陈旧。注释也说明了这一点(对比 13.1 提到的 createVNode 里"component 节点总要进 block"的原因)。
13.7.2 shouldUpdateComponent:判定全表
shouldUpdateComponent(componentRenderUtils.ts:374--440)决定"父更新是否传导到子":
ts
export function shouldUpdateComponent(prevVNode, nextVNode, optimized?): boolean {
const { props: prevProps, children: prevChildren, component } = prevVNode
const { props: nextProps, children: nextChildren, patchFlag } = nextVNode
const emits = component!.emitsOptions
if (__DEV__ && (prevChildren || nextChildren) && isHmrUpdating) { return true } // HMR 强制
if (nextVNode.dirs || nextVNode.transition) { return true } // 指令/过渡强制
if (optimized && patchFlag >= 0) {
if (patchFlag & PatchFlags.DYNAMIC_SLOTS) { return true } // 动态槽强制
if (patchFlag & PatchFlags.FULL_PROPS) {
if (!prevProps) { return !!nextProps }
return hasPropsChanged(prevProps, nextProps!, emits)
} else if (patchFlag & PatchFlags.PROPS) {
const dynamicProps = nextVNode.dynamicProps!
for (let i = 0; i < dynamicProps.length; i++) {
const key = dynamicProps[i]
if (hasPropValueChanged(nextProps!, prevProps!, key) && !isEmitListener(emits, key)) {
return true
}
}
}
} else {
// 手写 render:任何 children 变化都强制更新
if (prevChildren || nextChildren) {
if (!nextChildren || !(nextChildren as any).$stable) { return true }
}
if (prevProps === nextProps) { return false }
if (!prevProps) { return !!nextProps }
if (!nextProps) { return true }
return hasPropsChanged(prevProps, nextProps, emits)
}
return false
}
判定表:
| 情形 | 结果 | 依据 |
|---|---|---|
| HMR 下有 children | true |
槽内容可能变 |
nextVNode.dirs 或 transition |
true |
指令/过渡需在子侧处理 |
DYNAMIC_SLOTS |
true |
动态槽 |
FULL_PROPS 且 prevProps 空 |
!!nextProps |
有 props 就更新 |
FULL_PROPS |
hasPropsChanged(...) |
逐 key 比较 |
PROPS 且 dynamicProps 有值变化(非 emit) |
true |
只比列出的 key |
手写 render + 有 children + 非 $stable |
true |
无法判断,保守更新 |
hasPropsChanged(componentRenderUtils.ts:442--462):先比 key 数量,再逐 key hasPropValueChanged,且跳过 emit 监听器 (因为监听器变化不该让子组件 rerender------它只影响事件触发,emit 时从 vnode.props 直接查)。hasPropValueChanged(464--475):style 对象用 looseEqual 深比较,其余用 !==。
13.7.3 next 机制:为什么不能直接 update()
instance.next 是"父驱动的待处理 vnode"。它解决的核心问题是状态更新与 props 更新的次序/丢失:
- 若父更新时直接
instance.update()而不保存n2,则componentUpdateFn会用instance.vnode(旧的)作为 next,props 不会更新。 update()可能是异步批处理的 (scheduler 队列)。若不记录n2,在 effect 真正跑之前父可能又产生了一个新 vnode,旧的那次就丢了。
因此 updateComponent 存 instance.next = n2,随后 effect 运行时在 componentUpdateFn 的更新分支消费它:
ts
let { next, bu, u, parent, vnode } = instance
...
let originNext = next
toggleRecurse(instance, false)
if (next) {
next.el = vnode.el
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode // 自身状态更新:next 为空,用 vnode
}
(renderer.ts:1503--1542)
关键点:
originNext:更新渲染后若originNext === null,说明是"自身状态触发的更新"(而非父驱动),此时要updateHOCHostEl(instance, nextTree.el)更新 HOC(高阶组件)的宿主 el------因为 HOC 的subTree指向子组件 vnode。toggleRecurse(instance, false/true):在生命周期钩子之前禁用 effect 递归,避免钩子里改状态导致无限更新。next === null时next = vnode:让后面的next.props.onVnodeBeforeUpdate与next.el = nextTree.el使用当前 vnode。
updateComponentPreRender(renderer.ts:1653--1670)是"消费 next"的函数:
ts
const updateComponentPreRender = (instance, nextVNode, optimized) => {
nextVNode.component = instance
const prevProps = instance.vnode.props
instance.vnode = nextVNode
instance.next = null
updateProps(instance, nextVNode.props, prevProps, optimized)
updateSlots(instance, nextVNode.children, optimized)
pauseTracking()
flushPreFlushCbs(instance) // 冲刷 pre-flush watcher
resetTracking()
}
四件事,顺序固定:
- 把
nextVNode.component指回实例(父侧 clone 可能没带上); instance.vnode = nextVNode,instance.next = null(消费完毕);updateProps+updateSlots(用旧 props 作对比基准);flushPreFlushCbs(instance):props 更新可能触发了pre类型的 watcher;在渲染前先把它们冲刷掉,保证渲染看到的是"最新一致状态"。pauseTracking避免冲刷过程中的读取污染渲染依赖。
13.7.4 自身状态更新 vs 父驱动更新
componentUpdateFn 用 next 是否为 null 区分两条更新来源:
vbscript
父驱动更新(next 非空):
processComponent → updateComponent → shouldUpdateComponent? → instance.next = n2; instance.update()
→ componentUpdateFn: next 非空 → updateComponentPreRender → bu → render → patch → u
自身状态更新(next 为空):
响应式 set → scheduler queueJob → componentUpdateFn
→ next 为空 → next = vnode → bu → render → patch → u
两条路径在 componentUpdateFn 后半段合并。差异只有:父驱动会先 updateProps/updateSlots(并 flushPreFlushCbs),自身更新不需要。
13.7.5 渲染 effect、job.id 与调度
setupRenderEffect 最后建 effect(renderer.ts:1626--1650):
ts
instance.scope.on()
const effect = (instance.effect = new ReactiveEffect(componentUpdateFn))
instance.scope.off()
const update = (instance.update = effect.run.bind(effect))
const job: SchedulerJob = (instance.job = effect.runIfDirty.bind(effect))
job.i = instance
job.id = instance.uid
effect.scheduler = () => queueJob(job)
toggleRecurse(instance, true)
if (__DEV__) {
effect.onTrack = instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0
effect.onTrigger = instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
}
update()
要点:
effect.runIfDirty:调度器任务只在"dirty"时才真正执行(job带i/id两个附加字段)。job.id = instance.uid:调度器按 uid 排序,保证父组件先于子组件更新(父的 uid 更小),从而避免"子先更新、父后更新导致子重复更新"。effect.onTrack/onTrigger:把rtc(onRenderTracked)与rtg(onRenderTriggered)挂到 effect 上------这两个钩子必须在这里挂 ,因为 effect 创建时钩子可能还没注册(applyOptions在finishComponentSetup里已跑,但setup()里的onRenderTracked是在 setup 时注册的,早于setupRenderEffect)。挂载发生在首次update()之前,所以两种注册方式都能生效。instance.scope.on()/off()包裹new ReactiveEffect:让渲染 effect 归属组件 scope;组件卸载scope.stop()时渲染 effect 也被停。
13.7.6 子组件渲染触发链
一个完整链路(父组件状态变化导致子组件 props 变化):
注意最后一个细节:父驱动子更新时,子更新通常是"同步紧接"发生的 (instance.update() 直接调用),因为 instance.update 是 effect.run.bind(effect)(强制跑),而非排进队列。但要小心:若子组件的 update 被 toggleRecurse 或调度器拦截,实际执行时机可能延后;Vue 通过 next 保存了 n2,即使延后也不会丢 props。
13.8 生命周期:注册、时序与 SSR 差异
13.8.1 injectHook:钩子的统一装填器
组合式 API(onMounted 等)与 Options API(mounted 等)最终都走 injectHook(apiLifecycle.ts:20--64):
ts
export function injectHook(
type: LifecycleHooks,
hook: Function & { __weh?: Function },
target: ComponentInternalInstance | null = currentInstance,
prepend: boolean = false,
): Function | undefined {
if (target) {
const hooks = target[type] || (target[type] = [])
const wrappedHook =
hook.__weh ||
(hook.__weh = (...args: unknown[]) => {
pauseTracking()
const reset = setCurrentInstance(target)
const res = callWithAsyncErrorHandling(hook, target, type, args)
reset()
resetTracking()
return res
})
if (prepend) { hooks.unshift(wrappedHook) } else { hooks.push(wrappedHook) }
return wrappedHook
} else if (__DEV__) {
const apiName = toHandlerKey(ErrorTypeStrings[type].replace(/ hook$/, ''))
warn(`${apiName} is called when there is no active component instance ...`)
}
}
四个设计点:
target[type] || (target[type] = []):槽位是数组,支持多钩子(mixin 合并)。hook.__weh缓存包裹函数 (with error handling):同一个 hook 被注入多次时复用同一个包裹函数,这样调度器能正确去重(注释明确说了so the same hook can be properly deduped by the scheduler)。- 包裹函数内部
pauseTracking → setCurrentInstance → callWithAsyncErrorHandling → reset → resetTracking:钩子执行期间要有实例上下文(this能用、getCurrentInstance()可用),同时暂停依赖收集(钩子可能被 effect 调用,不能污染)。 - 无实例时 dev 警告:并在 Suspense 构建下追加提示"async setup 里要在第一个 await 前注册钩子"。
createHook(apiLifecycle.ts:66--79)是"组合式 API 注册器"的工厂:
ts
const createHook = <T extends Function = () => any>(lifecycle: LifecycleHooks) =>
(hook: T, target: ComponentInternalInstance | null = currentInstance): void => {
// post-create lifecycle registrations are noops during SSR (except for serverPrefetch)
if (!isInSSRComponentSetup || lifecycle === LifecycleHooks.SERVER_PREFETCH) {
injectHook(lifecycle, (...args: unknown[]) => hook(...args), target)
}
}
13.8.2 组合式 vs Options API 注册差异
| 维度 | 组合式(apiLifecycle) |
Options API(componentOptions.applyOptions) |
|---|---|---|
| 注册入口 | onXxx(hook) → createHook → injectHook |
registerLifecycleHook(onXxx, hook) → injectHook |
this |
无(箭头调用 hook(...args)) |
hook.bind(publicThis) |
| 多钩子 | 多次调用即多次 push | 合并后可能是数组,isArray 时逐个 bind+register |
| 时机 | setup() 执行期间 | applyOptions 内(finishComponentSetup 阶段) |
| SSR | createHook 直接吃掉(除 serverPrefetch) |
同左(injectHook 前也会被 SSR 标记影响吗?不------applyOptions 的 registerLifecycleHook 直接调 injectHook,因此 SSR 下 Options 钩子仍会注册,但 server-renderer 只调用 ssrRender,多数钩子不会触发) |
applyOptions 的注册段(componentOptions.ts:715--737):
ts
function registerLifecycleHook(register: Function, hook?: Function | Function[]) {
if (isArray(hook)) {
hook.forEach(_hook => register(_hook.bind(publicThis)))
} else if (hook) {
register(hook.bind(publicThis))
}
}
registerLifecycleHook(onBeforeMount, beforeMount)
registerLifecycleHook(onMounted, mounted)
registerLifecycleHook(onBeforeUpdate, beforeUpdate)
registerLifecycleHook(onUpdated, updated)
registerLifecycleHook(onActivated, activated)
registerLifecycleHook(onDeactivated, deactivated)
registerLifecycleHook(onErrorCaptured, errorCaptured)
registerLifecycleHook(onRenderTracked, renderTracked)
registerLifecycleHook(onRenderTriggered, renderTriggered)
registerLifecycleHook(onBeforeUnmount, beforeUnmount)
registerLifecycleHook(onUnmounted, unmounted)
registerLifecycleHook(onServerPrefetch, serverPrefetch)
关键在于:Options API 的钩子最终也变成组合式钩子 (通过 onXxx 注册),因此两者共享同一套"数组槽 + __weh 包裹"机制。这正是"用组合式 API 重写 Options API"得以共存的实现基础。
13.8.3 钩子槽位与枚举映射
LifecycleHooks 枚举(enums.ts)与实例短名字段一一对应:
| 枚举 | 短名 | 组合式 API | Options API | 触发点 |
|---|---|---|---|---|
BEFORE_CREATE |
bc |
(无) | beforeCreate |
applyOptions 开头 |
CREATED |
c |
(无) | created |
applyOptions 中段 |
BEFORE_MOUNT |
bm |
onBeforeMount |
beforeMount |
componentUpdateFn 首次、patch 前 |
MOUNTED |
m |
onMounted |
mounted |
首次 patch 后 queuePostRenderEffect |
BEFORE_UPDATE |
bu |
onBeforeUpdate |
beforeUpdate |
更新渲染前 |
UPDATED |
u |
onUpdated |
updated |
更新 patch 后 queuePostRenderEffect |
BEFORE_UNMOUNT |
bum |
onBeforeUnmount |
beforeUnmount |
卸载前 |
UNMOUNTED |
um |
onUnmounted |
unmounted |
卸载后 queuePostRenderEffect |
RENDER_TRACKED |
rtc |
onRenderTracked |
renderTracked |
effect.onTrack |
RENDER_TRIGGERED |
rtg |
onRenderTriggered |
renderTriggered |
effect.onTrigger |
ACTIVATED |
a |
onActivated |
activated |
KeepAlive 激活 |
DEACTIVATED |
da |
onDeactivated |
deactivated |
KeepAlive 失活 |
ERROR_CAPTURED |
ec |
onErrorCaptured |
errorCaptured |
handleError |
SERVER_PREFETCH |
sp |
onServerPrefetch |
serverPrefetch |
SSR |
bc 与 c 没有组合式对应(在 setup 里已经"创建完成"了)。onActivated/onDeactivated 由 components/KeepAlive 导出(apiLifecycle.ts:18 的 export { onActivated, onDeactivated })。
13.8.4 生命周期全时序表
以"挂载 → props 更新 → 卸载"为主线(同步 setup,客户端):
| 阶段 | 钩子/动作 | 触发位置 | 同步/异步 |
|---|---|---|---|
| 实例创建 | --- | createComponentInstance |
同步 |
| beforeCreate | bc |
applyOptions 首 |
同步 |
| inject/methods/data | --- | applyOptions |
同步 |
| created | c |
applyOptions 中段 |
同步 |
| 注册后续钩子 | --- | applyOptions 末 |
同步 |
| 建 effect | --- | setupRenderEffect |
同步 |
| beforeMount | bm + onVnodeBeforeMount |
首次 componentUpdateFn patch 前 |
同步 |
| render + patch | renderComponentRoot + patch |
--- | 同步 |
| mounted | m + onVnodeMounted |
patch 后 queuePostRenderEffect |
异步(post flush) |
| activated | a(若 KeepAlive 根) |
首次渲染后 | 异步(post) |
| --- 更新 --- | |||
| beforeUpdate | bu + onVnodeBeforeUpdate |
更新渲染前 | 同步 |
| render + patch | --- | --- | 同步 |
| updated | u + onVnodeUpdated |
patch 后 | 异步(post) |
| --- 卸载 --- | |||
| beforeUnmount | bum |
unmountComponent 前 |
同步 |
| 组 stop 停止 scope | --- | unmountComponent |
同步 |
| unmounted | um + onVnodeUnmounted |
卸载后 | 异步(post) |
关键结论:mounted/updated/unmounted/activated/deactivated 是 post-flush(异步),beforeMount/beforeUpdate/beforeUnmount/created/beforeCreate 是同步。 前者用 queuePostRenderEffect(保证整个树的 DOM 都已 patch 完成),后者在渲染流程中直接 invokeArrayFns。这解释了"在 mounted 里能取到完整子 DOM,但在 beforeMount 里不能"。
13.8.5 SSR 下的生命周期差异
createHook吃掉非serverPrefetch钩子 :SSR 时isInSSRComponentSetup为真,onMounted/onBeforeMount/...的注册是 noop,避免服务端执行"只对客户端有意义"的钩子。serverPrefetch:SSR 时唯一保留的"注入型"钩子,在组件渲染前被 server-renderer 调用(可返回 Promise)。applyOptions里的 Options 钩子仍会注册,但它们对应的 DOM/更新时机在 SSR 不会发生,所以"注册了但不触发"。__SSR__ && serverPrefetch→markAsyncBoundary(instance)(componentOptions.ts:789--791)。
13.8.6 onErrorCaptured / onRenderTrack / onRenderTrigger
onErrorCaptured:直接injectHook(ERROR_CAPTURED, hook, target)(apiLifecycle.ts:111--116),不走createHook(因此在 SSR 下也会注册)。它注入到instance.ec,由handleError沿父链调用,返回false可阻止错误继续向上传播。onRenderTracked/onRenderTriggered:走createHook,注入到instance.rtc/rtg,在setupRenderEffect里挂为effect.onTrack/effect.onTrigger(仅在__DEV__生效):
ts
effect.onTrack = instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0
effect.onTrigger = instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
参数是 DebuggerEvent(含 effect、target、key、type 等),是调试依赖收集/触发的最直接工具。注意它们只在 dev 生效 (if (__DEV__) 包裹),生产构建里 onTrack/onTrigger 永远 undefined。
13.9 PublicInstance proxy:this 之上的一切查找
模板里写的 this.msg、msg、$attrs.foo、$emit('x'),最终都落到一个由 PublicInstanceProxyHandlers 驱动的 Proxy 上。这个 proxy 是"渲染上下文"(render context),它是 Vue 3 把"组合式状态 + Options API 状态 + props + 公共属性"在同一个命名空间里冒名顶替的机制。本节把它拆开来看。
13.9.1 渲染上下文的三层结构
一个组件实例对外表现出的"this",其实是三层叠加:
kotlin
instance.ctx(普通对象,承载 methods / computed / data 的 defineProperty)
↑ ctx 的封装
instance.proxy(= new Proxy(ctx, PublicInstanceProxyHandlers),代理 target 是 ctx)
↑ 传递给 render、传给 setup 的 this、暴露给模板
setupState / props / data / attrs / slots / refs(另行持有,由 handlers 分流读写)
在 createComponentInstance 里,proxy 的创建表达式如下(packages/runtime-core/src/component.ts:49--52):
ts
instance.ctx = { _: instance }
instance.root = parent ? parent.root : instance
instance.emit = emit.bind(null, instance)
真正的 proxy 在 setupStatefulComponent 里赋值(component.ts:466--472):
ts
instance.proxy = new Proxy(
instance.ctx,
(__FEATURE_OPTIONS_API__
? PublicInstanceProxyHandlers
: RuntimeCompiledPublicInstanceProxyHandlers) as ProxyHandler<any>,
)
选择条件:FEATURE_OPTIONS_API 被裁剪时启用 RuntimeCompiledPublicInstanceProxyHandlers (模板 with 块需要 Symbol.unscopables 快路径),否则用通用版;后者 has 会拒绝 _ 前缀。
代理的 target 是 ctx 而不是实例本身,这点很关键:ctx 是个普通对象,读写它不会触发 reactivity 的 get 陷阱;而 setupState、props、data 都是响应式对象,get 陷阱把它们转发出去时天然产生依赖收集。ctx._ 这个字段则让 handler 能反查回 instance------所有 handler 的第一行都是 { _: instance } = target。
13.9.2 publicPropertiesMap:$ 开头的内建属性
所有 $ 前缀的内建属性集中注册在 publicPropertiesMap(packages/runtime-core/src/componentPublicInstance.ts:401--424),它是一个以 null 为原型的对象,避免 toString 之类的原型方法被误命中:
| key | 值表达式 | 说明 |
|----------------|---------------------------------------------------|-----------------------------|-------------------------------------|---------------------|
| $ | i => i | 暴露内部实例(调试用) |
| $el | dev: getDevRootFragmentEl(i);prod: i.vnode.el | 根 DOM;根是 Fragment 时取首个真实元素 |
| $data | i => i.data | Options API 的 data |
| $props | dev: shallowReadonly(i.props) | props 的只读视图 |
| $attrs | dev: shallowReadonly(i.attrs) | 透传属性只读视图 |
| $slots | i.slots 的只读视图 | 插槽 |
| $refs | i.refs 的只读视图 | 模板 ref 集合 |
| $parent | getPublicInstance(i.parent) | 父组件公共实例(可能为 null) |
| $root | getPublicInstance(i.root) | 应用根实例 |
| $host | i.ce | 自定义元素宿主(ce 存在时) |
| $emit | i.emit | 触发事件 |
| $options | resolveMergedOptions(i) | 合并后的选项对象 |
| $forceUpdate | `i.f | | (i.f = () => queueJob(i.update))` | 懒创建,把渲染 effect 塞进队列 |
| $nextTick | `i.n | | (i.n = nextTick.bind(i.proxy))` | 懒创建 |
| $watch | instanceWatch.bind(i) | Options API 的 this.$watch |
这里有两个值得学习的工程细节:$forceUpdate / $nextTick 不是 在实例创建时就算好的,而是"首次访问才创建并缓存在 i.f / i.n 上",属于典型的惰性求值(lazy init),避免每个实例都产生闭包开销。
$data / $props / $attrs / $slots / $refs 在 dev 下被 shallowReadonly 包一层 ,目的是防止开发者在渲染期无意修改它们;prod 下直接返回原对象以省掉包装成本。这解释了为什么"生产环境改了 this.$props 居然生效"这类只在 prod 出现的怪异行为------不要依赖它。
13.9.3 get 陷阱:一条精确的查找优先级链
get 是整个 proxy 最热的路径,每次模板取变量都会经过它。它的设计目标是"把多次 hasOwn 探测的结果记忆下来",靠的就是实例上的 accessCache(component.ts:113,{ [key: string]: AccessTypes },null 原型)。AccessTypes 枚举定义在 componentPublicInstance.ts:430--436:
ts
enum AccessTypes { OTHER, SETUP, DATA, PROPS, CONTEXT }
AccessTypes.OTHER = 0------这点很重要,0 是 falsy,所以 has() 里 accessCache[key] || ... 对 OTHER 会继续往下判断。get 的完整判定顺序如下(componentPublicInstance.ts:449--563):
| 顺序 | 判定条件 | 结果 | 是否写缓存 |
|---|---|---|---|
| 0 | key === ReactiveFlags.SKIP |
true |
否 |
| 1 | __DEV__ && key === '__isVue' |
true(供调试器识别) |
否 |
| 1' | key[0] !== '$' 且 accessCache[key] 已存在 |
按缓存类型直取 | 否(已缓存) |
| 2 | hasSetupBinding(setupState, key) |
setupState[key] |
SETUP |
| 3 | data !== EMPTY_OBJ && hasOwn(data, key) |
data[key] |
DATA |
| 4 | hasOwn(props, key) |
props[key] |
PROPS |
| 5 | ctx !== EMPTY_OBJ && hasOwn(ctx, key) |
ctx[key] |
CONTEXT |
| 6 | 裁剪 OPTIONS_API 或 shouldCacheAccess 为真 |
undefined | OTHER |
| 7 | publicPropertiesMap[key] |
执行 getter;$attrs 会 track,dev 下 $slots 会 track(instance)(HMR 用) |
--- |
| 8 | type.__cssModules[key] |
CSS Modules 注入值 | --- |
| 9 | ctx 里以 $/_ 开头但 hasOwn 为真 |
ctx[key] |
CONTEXT |
| 10 | appContext.config.globalProperties[key] |
全局属性 | --- |
| 11 | dev 且当前正在渲染 | 发出"属性未定义"警告 | --- |
由此可以总结出 this.msg 的实际优先级:
shell
setupState(setup 返回 / <script setup> 顶层绑定)
> data
> props
> ctx(methods / computed / inject 的 defineProperty / 手动挂载的 $xxx)
> publicPropertiesMap($ 内建)
> __cssModules
> globalProperties
注意第 2 步的 hasSetupBinding(componentPublicInstance.ts:446--447):
ts
const hasSetupBinding = (state: Data, key: string) =>
state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key)
这里有一个容易忽略的细节:当 setupState.__isScriptSetup 为真时,hasSetupBinding 返回 false 。原因是 <script setup> 的状态对象被 proxyRefs 包裹,其顶层绑定没有可靠地与用户声明的 ctx 合并,开发者又常写同名变量,Vue 选择让 script-setup 的自定义 ctx 属性优先(避免 ref 自动解包语义被覆盖时产生歧义)。换言之,<script setup> 下 ctx 上的同名属性优先于同名 setup 绑定(仅在 __isScriptSetup 为真时)。
第 6 步的 shouldCacheAccess 是模块级全局变量 (componentOptions.ts:518),默认 true;applyOptions 先置 false(526)、data 初始化后再置回 true(657)。初始化期间属性仍在被定义故不缓存,完成后即用缓存把热路径的多次 hasOwn 换成一次属性读取。
第 11 步的 dev 警告就是模板里打错变量名时看到的 Property "xxx" was accessed during render but is not defined on instance.;__v 开头的 key 被排除(componentPublicInstance.ts:547),否则内部 __v_isRef 探测会死循环告警。
13.9.4 set 陷阱:写入的分支与"只读"语义
set(componentPublicInstance.ts:566--612)的分支顺序与 get 部分对称,但语义更严格:
| 顺序 | 条件 | 行为 |
|---|---|---|
| 1 | hasSetupBinding(setupState, key) |
setupState[key] = value,成功 |
| 2 | __DEV__ && setupState.__isScriptSetup && hasOwn(setupState, key) |
警告"不能在 Options API 里修改 script setup 绑定",返回 false |
| 3 | data 中存在 |
data[key] = value,成功 |
| 4 | props 中存在 |
警告"Props are readonly",返回 false |
| 5 | key[0] === '$' && key.slice(1) in instance |
警告 $ 前缀保留且只读,返回 false |
| 6 | 否则 | dev 下若 key 在 globalProperties 里则用 defineProperty(可枚举),否则直接 ctx[key] = value,返回 true |
这里解释了两个常见现象:
- 改
this.$props.x报 readonly :因为key[0] === '$'且'props' in instance为真(instance 确有props字段),进入第 5 步拒绝。 - 写未声明的属性 :会落到
ctx上,这在渲染函数里写this.foo = 1是不可靠的 ------ctx不是响应式的,不会触发重渲染,而且下一轮 render 会重新读它。正确做法是把可变状态放进setup返回的reactive/ref。
第 2 步排在 data 之前,意味着即使 key 同时存在于 data 与 __isScriptSetup 的 setupState,也会优先命中"禁止从 Options API 改写 script-setup 绑定"的警告。
13.9.5 has 与 defineProperty
has(componentPublicInstance.ts:614--634)是一个纯布尔判定,用于 'x' in this,依次检查 accessCache[key](OTHER 为 0,falsy)、data(key 不以 $ 开头)、hasSetupBinding(setupState)、props、ctx、publicPropertiesMap、globalProperties、__cssModules。它在模板编译成 with 块时的 in 检查里被频繁调用;RuntimeCompiledPublicInstanceProxyHandlers.has(componentPublicInstance.ts:670--679)把结果进一步收紧为 key[0] !== '_' && !isGloballyAllowed(key),也就是模板里不能访问内部字段 ,_ 前缀会被判为不存在(dev 下若确实在真实 handler 里存在会额外告警)。
defineProperty(componentPublicInstance.ts:636--648)处理一种微妙场景:如果有人对 proxy 对象做 Object.defineProperty(this, 'foo', { get }),需要清掉 accessCache[key](置 0),因为 accessCache 只记"类目",getter 型属性必须重新走完整判定(#5417)。若 descriptor 是 value 形式,则转发到 set。最后调用 Reflect.defineProperty 记录到 ctx。
易错点 :
ownKeys在 dev 且非测试时被覆写为一个"打印警告"的陷阱(componentPublicInstance.ts:651--658)。这是官方刻意为之------依赖枚举实例 key 的应用逻辑在生产构建里会因为 dev/prod 行为差异而崩溃。不要写Object.keys(this)之类的代码。
13.9.6 expose 与 getExposeProxy:ref 拿到的东西为什么变了
ref 绑定到组件时,模板 ref 得到的值由 getComponentPublicInstance 决定(component.ts:1198--1220):
ts
export function getComponentPublicInstance(instance: ComponentInternalInstance) {
if (instance.exposed) {
return (
instance.exposeProxy ||
(instance.exposeProxy = new Proxy(proxyRefs(markRaw(instance.exposed)), {
get: (target, key) =>
key in target ? target[key] : publicPropertiesMap[key]?.(instance),
has: (target, key) => key in target || key in publicPropertiesMap,
}))
)
} else {
return instance.proxy
}
}
三个要点:
- 只要
instance.exposed存在,就返回一个"受限 proxy" ,而不再是完整的instance.proxy。expose()(组合式)或 Options 的expose: ['a']都会设置instance.exposed,于是父组件通过 ref 只能访问被显式暴露的内容 +$内建属性。 markRaw防止 exposed 对象被响应式包装;proxyRefs让expose({ foo: ref() })后父组件ref.value.foo直接拿到解包值。- 该 proxy 会缓存到
instance.exposeProxy,保证多次ref拿到同一个对象引用 (否则ref每次更新都产生新身份,Watch 与===比较会出问题)。
这也解释了 setup 里 getCurrentInstance().exposed 与 defineExpose 的关系:defineExpose 编译成 expose({...}),createSetupContext().expose 会 markRaw 后写入 instance.exposed(见 13.3.1)。没有调用 expose 的组件,ref 拿到的是完整 proxy ,能访问到所有 ctx 属性------这就是为什么有些第三方库能在父组件里直接对子组件 this 为所欲为。
13.10 Options API 兼容层:applyOptions 与 mergeOptions
Options API 在 3.x 里是运行时的兼容层 而非语法糖:它把 data/computed/methods/watch/lifecycle 等选项"翻译"成实例上的字段与 effect 注册。全部逻辑集中在 componentOptions.ts 的 applyOptions(520--792)与合并策略系列(927--1125)。
13.10.1 触发时机
finishComponentSetup 的末尾(component.ts:485--522)会检查 __FEATURE_OPTIONS_API__ 与 instance.type:
ts
if (__FEATURE_OPTIONS_API__) {
instance.renderCache /* ... */
// 若 setup() 未返回 render 且类型带 Options,则走 applyOptions
}
更精确地说,在 setupStatefulComponent/handleSetupResult 之后调用 applyOptions(instance),它会先 resolveMergedOptions(instance),把 type 上的选项与 mixins/extends 合并成最终 options(并缓存到 instance.type.__merged)。合并发生在 applyOptions 内部而不是创建实例时 ,这是为了支持 mixin 的 beforeCreate 修改选项(#2791)。
13.10.2 初始化顺序:一个被反复强调的"Vue 2 一致性"
applyOptions 的开头注释(componentOptions.ts:579--585)直接给出了顺序:
kotlin
props(在函数外已完成)
→ inject
→ methods
→ data(延迟,因为它依赖 this 访问)
→ computed
→ watch(延迟,依赖 this)
→ provide
→ created
→ 注册各生命周期钩子
→ expose 处理
→ render / inheritAttrs / components / directives 回填
代码里对应的关键动作:
shouldCacheAccess = false(526)------初始化期间不缓存 proxy 访问,因为属性在动态定义。callHook(options.beforeCreate, ...)(530--532)------最先行,早于 inject/methods。resolveInjections(injectOptions, ctx, checkDuplicateProperties)(587--589)。methods循环(591--618):每个方法methodHandler.bind(publicThis)写入ctx;dev 下用Object.defineProperty(可写可配置),prod 直接赋值。data处理(620--654):必须是函数 (否则 dev 警告),dataOptions.call(publicThis, publicThis)返回对象后instance.data = reactive(data);dev 下还会把每个非保留前缀的 key 用 getter/setter 挂到ctx(get: () => data[key]、set: NOOP),这是为了让 devtools 与模板with块能"看到" data。shouldCacheAccess = true(657)------状态初始化完成,开始缓存。computed(659--694):用computed({ get, set })创建,再defineProperty(ctx, key, { get: () => c.value, set: v => (c.value = v) })。注意set在 dev 缺失 setter 时会告警"readonly"。watch(696--700):createWatcher(watchOptions[key], ctx, publicThis, key)------见 13.10.3。provide(702--709):函数式或对象式,逐 keyprovide()。created(711--713)。- 生命周期注册(715--752):
registerLifecycleHook(onBeforeMount, beforeMount)等。注意beforeDestroy/destroyed只在 COMPAT 且软断言开启时注册为 unmount 钩子。 expose(754--767):数组形式,逐 keyObject.defineProperty(exposed, key, { get: () => publicThis[key], set: val => publicThis[key] = val });空数组则设置instance.exposed = {}(表示"什么都不暴露",此时 ref 拿到空对象而非完整 proxy)。- 末尾回填(769--791):若
render && instance.render === NOOP才覆盖(避免 mixin 覆盖组件自身的 render);inheritAttrs、components、directives写回实例;__SSR__ && serverPrefetch时markAsyncBoundary(instance)。
instance.render === NOOP这个判断 很微妙:createComponentInstance时 render 被初始化为NOOP,如果setup()返回了 render 函数(handleSetupResult已赋值),这里就不会被 Options 的render覆盖。这保证了组合式优先于 Options 的 render。
13.10.3 created 之前的 data 延迟与"this 可用性"
注释强调 data/watch "deferred since they rely on this access":instance.proxy 在 setupStatefulComponent 里才创建、applyOptions 在其后执行,所以 beforeCreate 里访问 this.data 拿不到 (尚未 reactive 化),而 created 里可以。
13.10.4 resolveMergedOptions 与 mergeOptions
resolveMergedOptions(componentOptions.ts:927--967)把 instance.type 与 mixins/extends 合成最终对象并缓存:
ts
export function resolveMergedOptions(instance) {
const base = instance.type as ComponentOptions
const { mixins, extends: extendsOptions } = base
const { mixins: globalMixins, optionsCache, config: { optionMergeStrategies } } = instance.appContext
const mergedOptions = {}
const globalMixinsLen = globalMixins.length
let cache = ...
if (cache) return cache
// 递归展开 extends 与 mixins(globalMixins 优先顺序在 mixins 之前)
...
}
合并的优先级顺序(从低到高):extends < mixins < 自身(globalMixins 在 mixins 之前、extends 之后)。
mergeOptions(969--1003)对每个 key 取 internalOptionMergeStrats[key] || defaultStrat,defaultStrat 就是"子选项有就用子选项,否则父选项"。各合并策略一览(1041--1125):
| 选项 | 策略 | 语义 |
|---|---|---|
data |
mergeDataFn |
子 data 优先,同名 key 用子;父独有 key 保留;返回合并对象 |
provide |
mergeDataFn |
同 data,对象合并 |
lifecycle(各钩子) |
mergeAsArray |
归一为数组,父在前子在后(父先执行) |
components/directives/filters |
mergeObjectOptions |
对象浅合并 |
watch |
mergeWatchOptions |
createWatcher 里归并回调为数组一起触发 |
props/emits/inject |
mergeEmitsOrPropsOptions → normalize 归一后合并 |
归一到对象/数组统一处理 |
methods/computed |
mergeObjectOptions |
方法/计算属性浅合并(同名以子级覆盖,实际是对象展开) |
| 其他 | defaultStrat |
子有取子,否则取父 |
关于生命周期数组的执行顺序 :mergeAsArray 保证父级(extends/mixins)在前、子级在后。这跟直觉的"覆盖"不同------mixin 的钩子不是被覆盖而是全部执行,顺序为 mixin → 组件自身。理解这点对排查"mixin 的 mounted 与组件 mounted 谁先跑"至关重要。
13.10.5 createWatcher 与 $watch 的统一
Options API 的 watch 与实例方法 this.$watch 最终都汇聚到 createWatcher(componentOptions.ts:849--920)。Options 形式每个 key 生成一个 watcher,回调统一放进 instance.ctx 上对应 key 的两个数组(ctx[_w {key}`]` / `_w{key}_immediate之类,dev 下另有once/deep标记)。而$watch走instanceWatch(同文件),它把一个 instance级 watch 转成对instance.proxy` 上属性路径的 watch。
因此 Options API 里同名 watcher 的回调实际是数组 ,createWatcher 会合并后一次性调用。
13.11 异步组件:defineAsyncComponent 的状态机
defineAsyncComponent(loader, options)(packages/runtime-core/src/apiAsyncComponent.ts:48--270)返回的并不是组件本身,而是一个包装组件 (AsyncComponentWrapper):它的 setup() 内部维护一个小状态机,加载完成后把真正的组件交给内部渲染。判断函数 isAsyncWrapper(vnode)(apiAsyncComponent.ts:44--45)只看 vnode.type.__asyncLoader 是否存在。
13.11.1 状态机与字段
setup() 中维护的响应式字段与流转如下:
| 状态 | 条件 | 渲染结果 |
|---|---|---|
| idle / 已同步命中 | __asyncResolved 已存在 |
直接返回已缓存组件(resolvedComp.value) |
| pending | !loaded && !error |
loadingComponent(可用 delay 延迟显示,默认 200ms)否则渲染注释占位 |
| loaded | load() resolve |
内部真实组件 innerComp |
| error | load() reject/timeout |
errorComponent(有则渲染并可 retry)否则抛出错误交给上层 |
lua
idle ── load() 返回 Promise ─▶ pending ── resolve ─▶ loaded
│
└── reject / timeout ─▶ error ── retry ──▶ pending
关键字段:loaded、error、loading、resolvedComp、pendingRequest。delay(默认 200ms)抑制 loading 闪烁,timeout 超时后置 error;retry() 清 pendingRequest 后重新 load(),onError 把错误冒泡给 Suspense。
13.11.2 Suspense 集成与 __asyncLoader
当 suspensible !== false 且 instance.parent && instance.parent.suspense 时,setup() 不渲染占位符而返回一个 Promise (即"注册依赖"给 Suspense),由 Suspense 统一决定显示 fallback 的时机。包装组件上的三个内部标记:__asyncLoader(暴露给 Suspense 触发加载)、__asyncResolved(getter,返回 resolvedComp)、__asyncHydrate(hydration 用,delay 场景下先 hydrate loading)。createInnerComp(apiAsyncComponent.ts:272--286)把 resolvedComp 与原 vnode 的 props/children 重新拼成一个 vnode。
13.12 函数式组件与 defineComponent
函数式组件 在 Vue 3 里就是一个普通函数 (props, ctx) => VNode,没有实例状态、没有生命周期。renderer 通过 isFunction(type) 判定并直接 type(props, setupContext)(ShapeFlags.FUNCTIONAL_COMPONENT);ctx 提供 attrs/slots/emit/expose,但没有 instance.proxy。所以函数式组件无法用 ref 拿到实例内部状态,也无法注册钩子------这正是"为什么函数式组件拿不到 $el"的根源。
defineComponent (packages/runtime-core/src/apiDefineComponent.ts:303--315)几乎纯粹是类型工具,运行时实现只有一行:
ts
export const defineComponent = ((
options: any,
extraOptions?: ComponentOptions,
) =>
isFunction(options)
? extend({ name: options.name }, extraOptions, { setup: options })
: options) as DefineComponent
它的作用是为 Options 对象/函数提供完整的 TS 类型推断(props、emits、slots、this),并支持 defineComponent(fn) 简写(把函数当作 setup)。运行时它不改变行为。
13.13 实战:打印组件实例字段随生命周期变化
把本章链路串起来的最小可运行示例。它演示 props/attrs 分离、subTree/vnode.el 的出现时机、expose 对 ref 的影响:
js
import { createApp, defineComponent, getCurrentInstance, ref, h,
onBeforeMount, onMounted, onUpdated, watchEffect } from 'vue'
const Child = {
name: 'Child',
props: { msg: String },
setup(props, { attrs, expose }) {
const inst = getCurrentInstance()
const count = ref(0)
expose({ count }) // 父 ref 将得到受限 proxy
onBeforeMount(() => console.log('beforeMount subTree=', !!inst.subTree))
onMounted(() => console.log('mounted el=', inst.vnode.el.tagName,
'subTree=', inst.subTree.type))
onUpdated(() => console.log('updated count=', count.value))
// 打印实例字段:注意哪些在挂载后才存在
watchEffect(() => console.log('render 执行, props=', JSON.stringify(props),
'attrs=', Object.keys(attrs)))
return () => h('button', { onClick: () => count.value++ }, props.msg + count.value)
},
}
const App = {
setup() {
const childRef = ref(null)
const inst = getCurrentInstance()
onMounted(() => console.log('父拿到子暴露的 count=', childRef.value.count))
return () => h(Child, { msg: '点击 ', id: 'c', ref: childRef })
},
}
createApp(App).mount('#app')
观察要点:props 只含声明的 msg,id 落进 attrs;vnode.el 到 mounted 才可用而 subTree 在 beforeMount 已有;ref 只拿到 { count } 加 $ 内建属性------因为调用了 expose。
13.14 要点回顾
- 实例结构 :
ComponentInternalInstance按职责分组;proxy是ctx之上的一层代理,EMPTY_OBJ作哨兵。 - 挂载流程 :
mountComponent → setupComponent(initProps/initSlots)→ setupStatefulComponent(建 proxy、调setup)→ handleSetupResult/finishComponentSetup(补 render、applyOptions);vnode.component在挂载早期写入。 - setup / 错误 :
createSetupContext提供attrs/slots/emit/expose;返回对象进setupState(proxyRefs),返回函数作 render;异常走callWithErrorHandling → handleError,沿父链errorCaptured冒泡。 - props :
normalizePropsOptions输出[NormalizedProps, needCastKeys];setFullProps分离 props/attrs;updateProps按patchFlag走 PROPS/FULL_PROPS/手动 diff;props 用shallowReactive。 - emit :
emit创建时bind;isEmitListener做 camelize/hyphenate/原样三重匹配;.once由emitted去重。 - slots :
initSlots分数组子节点/对象 slots 两条路径;withCtx设_n/_c/_d/_ns;SlotFlags决定父是否强制子更新。 - 更新 :
shouldUpdateComponent综合 props/slots/dirs/PatchFlags/needsFullDiff;父驱动走instance.next+updateComponentPreRender;job.id = instance.uid保证父先于子。 - 生命周期 :
injectHook用__weh缓存;SSR 下多数钩子短路;onErrorCaptured/onRenderTrack/onRenderTrigger仅 dev。 - proxy :
get按setupState > data > props > ctx > $内建 > cssModules > globalProperties,accessCache记忆;有expose时返回受限exposeProxy。 - Options API :
applyOptions顺序inject → methods → data → computed → watch → provide → created → 钩子;mergeOptions让 mixin 钩子全部执行、父级在前。 - 异步 / 函数式 :异步组件维护 pending/loaded/error 状态机,
suspensible时把 Promise 交给 Suspense;函数式组件是无实例的(props, ctx) => VNode,defineComponent主要做类型推断。