Vue 3 核心源码深度剖析系列(v3.5.44)-第 13 章 运行时组件系统:实例、props、emit、slots 与生命周期

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。

这个"按顺序灌字段"的过程,就是本章要逐行走读的挂载链路。它有三个设计支点:

  1. 字段即协议 。instance 的每个字段不只是内存槽位,而是渲染器、调度器、devtools、生命周期系统共同遵守的协议。理解字段的写入时机,就理解了一半的更新流程。
  2. Proxy 即公共契约 。模板里的 this、$props、$attrs、$refs 全部来自 PublicInstanceProxyHandlers;它用一张 accessCache 做了手写散列,把"每次属性访问都要 hasOwnProperty 四次"降为摊还 O(1)。
  3. 编译期与运行期的接缝极窄 。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)

两个"立即赋值"值得强调:

  1. instance.root = parent ? parent.root : instance(component.ts:711)。这决定了 $root 与错误处理里的实例链终点。它必须在返回前完成,因为 mountComponent 后续会立刻调用 setupComponent,而用户 setup() 里可能通过 $root/provides 访问根。
  2. 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)

三个要点:

  1. 顺序是先 props/slots 后 setup 。因为 setup(props, ctx) 的第一个参数和 ctx.slots 依赖这两步的产物。反过来先跑 setup 是无意义的。
  2. isStatefulComponent 就是查 vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT(component.ts:805--809)。函数式组件没有 STATEful 位,所以直接跳过 setupStatefulComponent,由 renderComponentRoot 的函数式分支处理。
  3. 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)

四个要点:

  1. if (!instance.render) :如果 setup 已经返回了 render 函数,这里整块跳过。否则尝试 runtime compile(仅当构建包含编译器且非 SSR),最后兜底为 NOOP(NOOP 返回 undefined,会被 normalizeVNode 变成注释节点------这也是 dev 下那个"Component is missing template or render function"警告的触发条件)。
  2. installWithProxy :runtime-dom 注册编译器时(registerRuntimeCompiler,component.ts:990)会安装这个钩子;它检查 render._rc(isRuntimeCompiled),若是则用 RuntimeCompiledPublicInstanceProxyHandlers 建 withProxy------因为 with 块需要不同的 has trap(见 13.9)。
  3. applyOptions :整块包在暂停依赖收集 + setCurrentInstance 里,因为 Options API 的 data()/created/... 都可能访问 this 并注册副作用。
  4. 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):

sequenceDiagram participant P as patch / processComponent participant M as mountComponent participant CI as createComponentInstance participant S as setupComponent participant SS as setupStatefulComponent participant E as setupRenderEffect P->>M: n1==null → mountComponent(n2) M->>CI: createComponentInstance(vnode, parent, suspense) Note over CI: vnode.component = instance 同时写死 M->>S: setupComponent(instance, false, optimized) S->>S: initProps(props/attrs 分离) S->>S: initSlots(归一化 children) S->>SS: setupStatefulComponent SS->>SS: accessCache = Object.create(null) SS->>SS: proxy = new Proxy(ctx, handlers) SS->>SS: setup() 执行(pauseTracking) SS->>SS: handleSetupResult → setupState / render SS->>SS: finishComponentSetup → applyOptions M->>E: setupRenderEffect(asyncDep 为空) E->>E: new ReactiveEffect(componentUpdateFn) E->>E: update() → isMounted=false 分支 Note over E: bm → render → patch → m(queuePostRenderEffect)

用一张 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)

逐条解读:

  1. attrs = createInternalObject() :createInternalObject(internalObject.ts)返回一个 Object.create(null) 空对象(dev 下可能加原型校验)。用无原型对象是为了避免 attrs.constructor/toString 之类的原型串扰,也让 for...in 只遍历自有 key。
  2. instance.propsDefaults = Object.create(null) :一个全新的默认值缓存,供 resolvePropValue 惰性缓存 default 工厂结果。
  3. 补齐缺失 key :遍历 propsOptions[0] 的所有声明 key,props 里没有的补 undefined。目的是让 props 的形状稳定------渲染函数访问 props.foo 时不会因为"这次没传"得到与"传了 undefined"不同的行为,也让响应式系统能对固定 key 建立依赖。
  4. dev 校验 :validateProps 只在 dev 跑。
  5. instance.props = shallowReactive(props) :这是最重要的设计点,见 13.4.5。 SSR 时直接赋 props(服务端渲染不需要响应式)。
  6. 函数式组件特殊处理 :isStateful === 0 时,若组件没声明 props,则 props === attrs(同一对象);若声明了,props 用普通对象。原因是函数式组件没有公共代理,props 与 attrs 的分离对它有不同语义。
  7. 最后 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)。

三个理由:

  1. props 是"整体替换 + 逐 key 更新",不是深层可变 。父组件更新子组件时,运行时通过 updateProps 逐 key 写入 props;深层对象(如 props.user.name)的变更由父组件侧的响应式 驱动(父渲染时读取 user.name 会追踪依赖),子组件无需也不应对 props 深层建立响应式。若用 reactive,会为每个嵌套对象建 Proxy,既费内存又可能造成父/子双重追踪。
  2. props 顶层是响应式的,才能触发子组件渲染 。子组件渲染函数读取 props.foo 时,需要在 foo 变化时重新渲染,因此顶层必须是响应式。shallowReactive 正好提供"顶层 key 依赖追踪"。
  3. 只读语义 。虽然 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。

全量路径做了三件事:

  1. setFullProps 重算 props/attrs;
  2. 对 rawCurrentProps(旧的 props)中"新 rawProps 里已不存在"的 key:若声明了 options 且有旧值,则 resolvePropValue(..., undefined, isAbsent=true) 重置为默认值(关键:不是删除,而是回退默认值 ,因为 key 在 propsOptions 里恒存在);否则删除。
  3. 对 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
}

三个关键点:

  1. _n 幂等 :已经归一化过的槽(_n === true)直接返回,避免多层组件透传时重复包装(#5353)。
  2. dev 警告"在渲染函数外调用槽":这行警告检测"当前有实例但不在该槽的渲染上下文里",提示槽函数若在 render 外调用就不会追踪依赖。
  3. _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(重新包装)

四个分支对应四类场景,逐个解释:

  1. HMR :父组件被热更新,槽内容可能变了,即使标记为 STABLE 也要强制刷新,并 trigger(instance, SET, '$slots') 通知依赖 $slots 的渲染。
  2. optimized && STABLE :这是"零更新"快路径------编译器保证槽的依赖被槽自身完整捕获(父渲染时已经追踪),子组件无需更新槽,needDeletionCheck=false 也避免误删(因为"没更新"不等于"槽没了")。
  3. DYNAMIC / FORWARDED :只 assignSlots 更新槽函数引用,但跳过归一化(编译槽已归一化)。
  4. 手写 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()
}

四件事,顺序固定:

  1. 把 nextVNode.component 指回实例(父侧 clone 可能没带上);
  2. instance.vnode = nextVNode,instance.next = null(消费完毕);
  3. updateProps + updateSlots(用旧 props 作对比基准);
  4. 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 变化):

sequenceDiagram participant S as 父状态 (reactive) participant Q as scheduler queue participant PF as 父 componentUpdateFn participant R as patch / processComponent participant UC as updateComponent participant CF as 子 componentUpdateFn S->>Q: trigger → queueJob(父 job) Q->>PF: flush → 父 update() PF->>PF: renderComponentRoot(父) PF->>R: patch(prevTree, nextTree) R->>UC: 子 vnode n1→n2 → updateComponent UC->>UC: shouldUpdateComponent? 是 UC->>UC: instance.next = n2(子实例) UC->>CF: 子 instance.update() CF->>CF: updateComponentPreRender → updateProps/updateSlots → flushPreFlushCbs CF->>CF: bu → render(子) → patch(子 subTree) → u

注意最后一个细节:父驱动子更新时,子更新通常是"同步紧接"发生的 (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 ...`)
  }
}

四个设计点:

  1. target[type] || (target[type] = []):槽位是数组,支持多钩子(mixin 合并)。
  2. hook.__weh 缓存包裹函数 (with error handling):同一个 hook 被注入多次时复用同一个包裹函数,这样调度器能正确去重(注释明确说了 so the same hook can be properly deduped by the scheduler)。
  3. 包裹函数内部 pauseTracking → setCurrentInstance → callWithAsyncErrorHandling → reset → resetTracking :钩子执行期间要有实例上下文(this 能用、getCurrentInstance() 可用),同时暂停依赖收集(钩子可能被 effect 调用,不能污染)。
  4. 无实例时 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
  }
}

三个要点:

  1. 只要 instance.exposed 存在,就返回一个"受限 proxy" ,而不再是完整的 instance.proxy。expose()(组合式)或 Options 的 expose: ['a'] 都会设置 instance.exposed,于是父组件通过 ref 只能访问被显式暴露的内容 + $ 内建属性。
  2. markRaw 防止 exposed 对象被响应式包装;proxyRefs 让 expose({ foo: ref() }) 后父组件 ref.value.foo 直接拿到解包值。
  3. 该 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):函数式或对象式,逐 key provide()。
  • created(711--713)。
  • 生命周期注册(715--752):registerLifecycleHook(onBeforeMount, beforeMount) 等。注意 beforeDestroy/destroyed 只在 COMPAT 且软断言开启时注册为 unmount 钩子。
  • expose(754--767):数组形式,逐 key Object.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 主要做类型推断。
相关推荐
码银1 小时前
【web】基于HTML/CSS,JavaScript + Three.js做3D流程图
前端·css·html
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 17 章 运行时内幕(五):`runtime-dom`、自定义元素与内置组件
前端·vue.js·开源
Highcharts.js1 小时前
Highcharts React v5 正式发布:支持 ESM、更简洁的组件、更出色的开发体验
前端·react.js·前端框架·数据可视化·highcharts·前端可视化·esm
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 10 章 compiler-sfc(二)
前端·vue.js·开源
will_net1 小时前
Vue IIS部署 api 访问当前站点 代理跳转真实API 问题
前端·javascript·vue.js
weixin_449173651 小时前
react 的useEffect用法
前端·javascript·react.js
55873 生态系统1 小时前
九层之台,生态为体 —— 论 “55873 全域文明生态系统“ 与第 1 期 2000 多册开源大典
开源·55873全域文明生态体系·55873操作系统·全域文明生态系统·55873 数据开源大典
定栓1 小时前
Webpack迁到Vite第十节:vite.config.js 开发与构建流转全链路
vue.js·webpack·vite