Vue 3 设计与实现
第 10 章 组件系统
第 10 章 组件系统
本章要点
- 组件实例的完整数据结构:从 ComponentInternalInstance 的 70 多个字段到它们各自的职责
- 组件创建的全流程:从 createComponentInstance 到 setupComponent 再到 setupRenderEffect
- Props 系统的深层机制:声明、解析、校验、响应式代理的四阶段流水线
- Emit 事件系统的实现:命名规范化、验证、监听器查找的完整链路
- Slots 的编译与运行时协作:静态 slots、动态 slots、作用域 slots 的统一处理
- expose 的安全边界:如何控制组件的公共 API 表面
- 异步组件与 Suspense 的协作机制
在前面的章节中,我们花了大量篇幅讨论响应式系统和编译器。但 Vue 的核心抽象不是 ref,不是模板,而是组件。组件是 Vue 开发者日常工作的基本单元——你创建组件、组合组件、在组件之间传递数据。响应式系统和编译器都是为组件服务的基础设施。
本章将深入组件系统的内部机制。我们不是在讨论"如何使用组件",而是在追问"组件是如何被创建、初始化、更新和销毁的"。当你在模板中写下 <MyComponent :msg="hello" /> 时,背后到底发生了什么?一个看似简单的 tag,在 Vue 运行时里至少要经过 8 个不同文件里的 20+ 个函数——但每一个函数都有明确的职责,合起来组成了一条 "从声明到运行"的精密流水线。
组件为什么是前端框架的核心抽象
要真正理解 Vue 的组件系统,先得回答一个更前置的问题——"组件"到底解决了什么问题。
想象没有组件抽象的世界:HTML 是扁平的,所有交互都是全局脚本跨元素操作。jQuery 时代的网页就是这样,成百上千行的 $(selector).on('click', ...) 代码绞在一起,改一处、整页都在抖。这种结构的根本问题不是"代码量",而是"本地推理失败"——你要理解"点这个按钮会发生什么"必须扫描整份代码找到所有对这个按钮的引用,作用域上没有边界。
组件做的第一件事是给代码画边界:每个组件内部是一个自包含的盒子,有自己的模板、自己的状态、自己的逻辑。改动一个组件的内部不需要看其他组件——本地推理成为可能。这种"可本地推理的最小单元"是现代前端架构的基石。
组件做的第二件事是让复用可行。没有组件抽象,复用是靠"复制粘贴 + 改 ID"这种脆弱做法;有了组件抽象,同一段 UI + 逻辑封装一次,到处 <XxxComponent /> 复用。《React 19 内核探秘》第 1 章从 UI = f(state) 讲起——React 把"组件就是函数"这一点推得更彻底;Vue 选择了更克制的立场(单文件组件但模板受限),结果是不同风格、同一抽象核心。
组件做的第三件事是让协作变可组合。小组件组合成大组件、大组件组合成页面、页面组合成应用——这个自底向上的堆叠过程是大型前端应用能被多人团队共同维护的原因。没有组件边界,10 个人同时改一份代码只能互相踩脚。
本章如何组织
本章要讲清楚"组件的生命"——从模板里的 <MyComponent /> 开始,到组件真正挂载完毕、响应状态更新、最终卸载消失,整个过程中 Vue 做了什么。顺序是:
- 10.1 数据结构:组件实例是一个很大的对象,有 70 多个字段(rc.4:
ComponentInternalInstance自己声明 36 个,再加上只在GenericComponentInstance里声明、继承下来的 38 个,去重后共 74 个)。先熟悉这个对象,后面的函数讨论都围绕它展开。 - 10.2 创建流程:从 createComponentInstance 到 setupComponent 到 setupRenderEffect——组件从"一个 VNode"变成"一个活的 UI"的三步。
- 10.3-10.6 父子交互:Props 流入、Emit 流出、Slots 内容注入、expose 公开 API——这四件事构成了组件与外部的全部接口。
- 10.7 异步组件:基础之外的高级能力,Suspense 协作。
读的时候推荐时不时停下来,回顾一下"我写 Vue 组件时常做的那些操作,底层对应哪步"。这种"把经验和源码对上号"的过程最有价值。
跨专栏关联:本章和《React 19 内核探秘》专栏第 7 章(Hooks 的实现原理)可以对照读——Vue 的 ComponentInternalInstance 约等于 React 的 Fiber 节点 + Hook 链表。两个框架用不同的数据结构表达同一类信息:组件的身份、状态、渲染产物、父子关系。对比差异比死记细节更有启发。
10.1 组件实例的数据结构
接下来要看的是 Vue 运行时最重要的数据结构。它不像 VNode 那样显眼(用户写模板几乎感受不到它的存在),也不像响应式 API 那样性感(ref/reactive 是用户天天打交道的"魔术"入口),但整个 Vue 的组件运行时几乎都围绕这个对象转。理解了它、你就理解了 Vue 的运行时骨架;没理解它、后面每一节都会感到"字段从哪来的"、"这个属性什么时候被设"的空白。所以这一节是全章最应该慢读、边读边在脑子里画字段表的一节。
ComponentInternalInstance:组件的"身份证"
ComponentInternalInstance 是 Vue 组件运行时的核心数据结构——每一个活着的组件在内存里对应一个 ComponentInternalInstance 对象。你可以把它想成"组件的身份证 + 户口本 + 通行证"——身份标识、状态记录、渲染凭据全在里面。
理解这个对象的字段和分类是理解整个 Vue 组件系统的钥匙。后面所有关于组件生命周期、props 流入流出、emit 事件、slots 渲染的代码,都是在不同阶段读或写这个对象的某些字段。建议你读完本节后经常回来看这张字段分类表——它像一张"地图",让你不会在后面的复杂流程里迷路。
每个 Vue 组件在运行时都对应一个 ComponentInternalInstance 对象。这个对象是组件系统的核心数据结构,它承载了组件从诞生到销毁的全部状态:
// packages/runtime-core/src/component.ts:597(rc.4,节选;公共字段来自 GenericComponentInstance)
export interface ComponentInternalInstance extends GenericComponentInstance {
uid: number // 全局唯一 ID
type: ConcreteComponent // 组件定义(选项对象或 setup 函数)
parent: GenericComponentInstance | null // 父组件实例
root: GenericComponentInstance // 根组件实例
appContext: AppContext // 应用级上下文
// ---- VNode 相关 ----
vnode: VNode // 组件自身的 VNode
subTree: VNode // 组件渲染输出的 VNode 子树
next: VNode | null // 待更新的 VNode(父组件触发的更新)
// ---- 渲染相关 ----
render: InternalRenderFunction | null // 编译后的渲染函数
proxy: ComponentPublicInstance | null // 模板中的 `this` 代理
withProxy: ComponentPublicInstance | null // 运行时编译、用 with 块的渲染函数专用代理
// ---- 状态相关 ----
setupState: Data // setup() 返回的状态
props: Data // 解析后的 props
attrs: Data // 非 prop 的 attributes(透传)
slots: InternalSlots // 插槽
refs: Data // 模板 ref 引用
// ---- 副作用相关 ----
effect: ReactiveEffect // 组件的渲染 effect(SetupRenderEffect 实例)
scope: EffectScope // 组件的 effect 作用域
update: () => void // 强制重渲染:effect.run.bind(effect)
job: SchedulerJob // 排进调度器的 job:脏了才 run
ctx: Data // 渲染代理的目标对象(Options API 的 this)
data: Data // Options API 的 data()
accessCache: Data | null // 渲染代理的访问路径缓存
// ---- 生命周期 ----
isMounted: boolean
isUnmounted: boolean
isDeactivated: boolean
// ---- 生命周期钩子 ----
// 注意:源码里这组字段是**计算键**,写作 [LifecycleHooks.BEFORE_CREATE]?: LifecycleHook
// (enums.ts 里 BEFORE_CREATE = 'bc')。下面按枚举求值后的字面名列出,方便对照运行时
// 看到的实例对象;直接在 component.ts 里 grep `bc:` 只能搜到 createComponentInstance
// 里那份初值全为 null 的对象字面量(:826-833)。
bc: LifecycleHook // beforeCreate
c: LifecycleHook // created
bm: LifecycleHook // beforeMount
m: LifecycleHook // mounted
bu: LifecycleHook // beforeUpdate
u: LifecycleHook // updated
bum: LifecycleHook // beforeUnmount
um: LifecycleHook // unmounted
// ---- 其他 ----
emit: EmitFn // 事件发射函数
emitted: Record<string, boolean> | null
provides: Data // provide/inject 数据
exposed: Record<string, any> | null // expose 暴露的 API
exposeProxy: Record<string, any> | null
}
70 多个字段(rc.4 里 ComponentInternalInstance 自己声明 36 个,另有 38 个只在 GenericComponentInstance 里声明、靠继承得到(其中 14 个是生命周期钩子),去重后共 74 个——后者是 3.6 为了让 VDOM 实例和 Vapor 的 VaporComponentInstance 共享 props / emit / 生命周期钩子等公共字段而抽出来的基接口),每一个都有明确的职责。这个结构体就像一个生物细胞——外表是统一的组件接口,内部是精密协作的功能模块。
为什么需要这么多字段?
看 ComponentInternalInstance 第一反应都是"字段怎么这么多"。这不是过度设计,而是每一个字段对应一个真实职责——组件要完成的工作种类太多,每种工作都需要某个状态记录。
归纳一下这些字段的分类:
- 身份类(uid / type / root / parent / appContext)——"我是谁、我在哪"。uid 用于调度排序(
queueJob按 uid 让父先于子)和调试;parent/root 让组件能向上找祖先(比如错误沿parent链冒泡给 errorCaptured)。 - VNode 类(vnode / next / subTree)——"我对应的 VNode 和我渲染出来的 VNode"。vnode 是外层父组件给我的描述、subTree 是我自己渲染出来的树。
- 响应式状态类(props / data / setupState / ctx / attrs)——"我持有的所有响应式数据"。其中 attrs 存没声明的 prop、ctx 给 render 函数做 this。
- 生命周期类(isMounted / isUnmounted / bu / u / bm / m 等各种钩子数组)——"我处在生命的哪个阶段"。每个钩子是一个数组,同一钩子可以注册多次。
- 渲染类(render / update / effect / emitsOptions / inheritAttrs)——"我怎么渲染、谁触发我重跑"。
- 依赖注入类(
provides)——"向下提供什么"。实例上只有provides这一个字段(packages/runtime-core/src/component.ts:387),没有inject字段;inject是一个沿parent.provides查找的函数。
这 6 类加起来就是 70 多个字段。理解了分类之后再看源码,你会发现它们并不无序——每一类字段在生命周期的某个阶段集中被初始化、在另一阶段集中被使用。这种"字段很多但职责清晰"的设计在成熟框架里很常见——React 的 Fiber 节点也是同一种形态——react@19.0.0 的 packages/react-reconciler/src/ReactInternalTypes.js 里 Fiber 类型实测 34 个字段(其中 7 个是 _debug* 开发字段),同样按职责分组(实例身份、树结构、props / state、副作用标记、Profiler 计时)。
你可能会疑问:一个组件真的需要这么多状态吗?答案是肯定的,因为组件身兼数职:
graph TB
CI[ComponentInternalInstance]
CI --> A[渲染引擎]
CI --> B[状态容器]
CI --> C[通信枢纽]
CI --> D[生命周期管理器]
CI --> E[作用域边界]
A --> A1[render 函数]
A --> A2[subTree VNode]
A --> A3[渲染 effect]
B --> B1[setupState]
B --> B2[props]
B --> B3[attrs]
C --> C1[emit 事件]
C --> C2[slots 插槽]
C --> C3[provide/inject]
D --> D1[生命周期钩子数组]
D --> D2[isMounted/isUnmounted]
E --> E1[effectScope]
E --> E2[exposed API]
10.2 组件创建流程
组件创建是整个 Vue 运行时最密的一段代码——短短三个函数里,响应式系统、生命周期钩子、渲染函数、effect 调度都被串起来。读完这一节你会对"Vue 怎么从一个 VNode 派生出活的 UI"有完整的时序感。
作为一个心智索引,先把三个关键函数记在脑子里:
- createComponentInstance:分配对象、设好身份、把父组件的 appContext 继承过来。纯数据结构工作、没副作用。
- setupComponent:跑
setup()函数、解析 options API、装配 proxy。最有"魔法"的一步——所有 Composition API / Options API 分支都从这里走。 - setupRenderEffect:把 render 函数和 ReactiveEffect 绑在一起、跑第一次 render、把结果挂到真实 DOM。完成后组件"活了"。
这三步由 mountComponent 依次调用,顺序固定(async setup 时第三步推迟到 Suspense resolve 之后)。下面逐一拆开。
从 VNode 到组件实例
一个容易让人卡住的心智模型:"VNode"和"组件实例"不是一回事。
- VNode:一个描述——"这里应该有一个 Foo 组件、传这些 props、这些 slots"。VNode 是纯数据,无生命,每次 render 都被重新生成。
- 组件实例(ComponentInternalInstance):一个真实运行的组件——有 state、有 setup 产出、有 render effect、有 DOM 挂载关系。实例在挂载时创建、卸载时销毁、生命周期中持久存在。
从 VNode 到实例的转化,发生在 patch 函数碰到 shapeFlag & COMPONENT 时——patch 先用 isSameVNodeType(packages/runtime-core/src/vnode.ts:416,函数最后一行的判据就是 n1.type === n2.type && n1.key === n2.key;前面还有一段只在 __DEV__ 下生效的 HMR 分支,热更新过的组件会强制返回 false 以触发重载)确认新旧 VNode 是同一个节点,确认之后 updateComponent 把旧 VNode 上的实例直接搬到新 VNode:const instance = (n2.component = n1.component)!(packages/runtime-core/src/renderer.ts:1428)——实例被复用,被丢弃的只是旧 VNode 这个描述对象。这种"描述与运行解耦"的设计让你可以在 render 里大量生成 VNode 而不担心创建开销——VNode 本身是廉价对象,重的是实例生命周期。
当渲染器遇到一个组件类型的 VNode 时,会调用 mountComponent:
// packages/runtime-core/src/renderer.ts:1311(rc.4,大幅精简:略去 compat / HMR / DEV 计时,
// 以及 async setup 时交给 parentSuspense.registerDep() 的分支)
const mountComponent = (
initialVNode: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
optimized: boolean
) => {
// 第一步:创建组件实例
const instance: ComponentInternalInstance =
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense
))
// 第二步:初始化组件(处理 props、slots、执行 setup)
setupComponent(instance, false, optimized)
// 第三步:建立渲染 effect
setupRenderEffect(
instance,
initialVNode,
container,
anchor,
parentSuspense,
namespace,
optimized
)
}
三步曲,清晰明了。让我们逐一深入。
第一步:createComponentInstance
这一步是纯粹的对象初始化——分配一个 ComponentInternalInstance、把那 70 多个字段设上合理默认值。真正要特别关注的只有几点:
appContext的继承:从父组件(parent.appContext)或 root 应用(app.context)继承。这让组件能访问到全局注册的组件、directive、provide/inject 等——不需要父子之间用 props 一路向下传,框架级别的东西通过 appContext 共享。ctx的初始化:这是 render 函数里this那个代理背后的目标对象。Vue 2 时代这是 vm instance;Vue 3 保留了类似语义以兼容 Options API。provides的原型链继承(惰性分叉):子组件默认直接共享父组件的 provides 对象——provides: parent ? parent.provides : Object.create(appContext.provides)(packages/runtime-core/src/component.ts:782);只有它自己调provide()时才Object.create(parent.provides)分叉一次(packages/runtime-core/src/apiInject.ts:29-31,详见第 14 章 §14.2.1)。分叉之后 inject 查找沿原型链上溯,自然得到"子覆盖父"的语义。这是响应式之外、Vue 另一个用 JS 原生能力(原型链)实现框架能力的巧妙案例。
这一步不跑 setup、不跑 render——只是"准备好一个空壳子"。后两步才填充内容。
// packages/runtime-core/src/component.ts:753(rc.4,节选)
export function createComponentInstance(
vnode: VNode,
parent: ComponentInternalInstance | null,
suspense: SuspenseBoundary | null
): ComponentInternalInstance {
const type = vnode.type as ConcreteComponent
const appContext =
(parent ? parent.appContext : vnode.appContext) || emptyAppContext
const instance: ComponentInternalInstance = {
uid: uid++,
vnode,
type,
parent,
appContext,
root: null!, // 稍后设置
subTree: null!, // 首次渲染时设置
effect: null!, // setupRenderEffect 中设置
update: null!, // setupRenderEffect 中设置
job: null!, // setupRenderEffect 中设置
scope: new EffectScope(true /* detached */),
render: null,
proxy: null,
withProxy: null,
provides: parent ? parent.provides : Object.create(appContext.provides),
// 状态
ctx: EMPTY_OBJ,
data: EMPTY_OBJ,
setupState: EMPTY_OBJ,
props: EMPTY_OBJ,
attrs: EMPTY_OBJ,
slots: EMPTY_OBJ,
refs: EMPTY_OBJ,
// 生命周期标记
isMounted: false,
isUnmounted: false,
isDeactivated: false,
// 生命周期钩子
bc: null, c: null, bm: null, m: null,
bu: null, u: null, bum: null, um: null,
emit: null!, // 稍后设置
emitted: null,
exposed: null,
exposeProxy: null,
next: null,
}
// ctx:渲染代理的目标对象;生产环境就是 { _: instance }
instance.ctx = __DEV__ ? createDevRenderContext(instance) : { _: instance }
// 设置 root 引用
instance.root = parent ? parent.root : instance
// 创建 emit 函数
instance.emit = emit.bind(null, instance)
return instance
}
注意 provides 的初始化策略:有 parent 时先共享父对象引用,等到本组件真的调 provide() 才 Object.create(parent.provides) 分叉(见第 14 章 §14.2.1 的"延迟分叉")。分叉后通过原型链继承,子组件可以访问所有祖先组件提供的值,同时自己 provide 的值只影响后代。
第二步:setupComponent
setupComponent 是所有"组件变成活的"的魔法发生地。它做三件事:
- initProps + initSlots:把外部传入的 props 和 slots 放到 instance 上。
- 执行 setup()(如果有)或 处理 Options API:拿到组件内部的 state、方法、计算属性。
- finishComponentSetup:确保 render 函数存在(模板还没编译的在这里编译),把 instance.render 准备好。
这三件事做完,组件就从"只有外部数据"变成了"外部数据 + 内部状态 + 渲染函数"——一个完整的能自我渲染的单元。下一步的 setupRenderEffect 才能基于这些信息真正跑起来。
// packages/runtime-core/src/component.ts:877(rc.4,略去 SSR 状态标记 setInSSRSetupState)
export function setupComponent(
instance: ComponentInternalInstance,
isSSR = false,
optimized = false,
): Promise<void> | undefined {
const { props, children, vi } = instance.vnode
const isStateful = isStatefulComponent(instance)
if (vi) {
// Vapor 互操作:VDOM 组件被 Vapor 父组件挂载时,改用 Vapor 的 props/attrs 代理(第 9 章)
vi(instance)
} else {
// 1. 初始化 props
initProps(instance, props, isStateful, isSSR)
// 2. 初始化 slots
initSlots(instance, children, optimized || isSSR)
}
// 3. 如果是有状态组件,执行 setup
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
return setupResult
}
setupStatefulComponent:执行 setup 函数
这是全 Vue 运行时最核心的一段代码。所有 Composition API 的"魔法"都发生在这几行里——ref、reactive、onMounted、watch 能正常工作,是因为它们调用时能感知到"当前正在设置哪个组件"。这个"感知"就是通过 currentInstance 这个全局变量实现的:在调用 setup 前 setCurrentInstance、setup 跑完用 restoreCurrentInstance 还原——中间的任何 API 调用都能通过这个全局引用拿到"我的宿主组件是谁"。
这种"瞬态全局状态"的模式其实是 Hooks 时代前端框架的共同发明。React 的 useState / useEffect 能正常工作,底层靠的是全局的 dispatcher(React 18 叫 ReactCurrentDispatcher.current,React 19 并进了 ReactSharedInternals.H)——和 Vue 的 currentInstance 几乎是一一对应的。两个框架独立进化到同一个解决方案——这不是巧合,而是"无魔法的组件化"这个问题上一条很干净的解法。
// packages/runtime-core/src/component.ts:903(rc.4,去掉了 DEV 校验;async 分支精简)
function setupStatefulComponent(
instance: ComponentInternalInstance,
isSSR: boolean,
) {
const Component = instance.type as ComponentOptions
// 0. 创建渲染代理的访问缓存
instance.accessCache = Object.create(null)
// 1. 创建公共实例代理
// 这个代理就是模板中的 `this` 和 setup 中不应该直接使用的上下文
instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers)
// 2. 执行 setup
const { setup } = Component
if (setup) {
// 清空"当前活跃订阅者"——setup 里读到的响应式数据不应被任何 effect 收集
const prevSub = setActiveSub()
// 如果 setup 接受第二个参数,创建 setupContext
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)
// 设置当前实例(让 onMounted 等 API 知道它们属于哪个组件)
const prev = setCurrentInstance(instance)
// 执行 setup 函数
const setupResult = callWithErrorHandling(
setup,
instance,
ErrorCodes.SETUP_FUNCTION,
[
__DEV__ ? shallowReadonly(instance.props) : instance.props,
setupContext,
],
)
const isAsyncSetup = isPromise(setupResult)
setActiveSub(prevSub)
restoreCurrentInstance(prev)
if ((isAsyncSetup || instance.sp) && !isAsyncWrapper(instance)) {
// async setup / serverPrefetch:标记为 useId() 的异步边界
markAsyncBoundary(instance)
}
if (isAsyncSetup) {
if (isSSR) {
// SSR:返回 promise 让 server-renderer 等待
return setupResult.then(resolved => handleSetupResult(instance, resolved, isSSR))
} else if (__FEATURE_SUSPENSE__) {
// 客户端:把 promise 挂到 asyncDep 上,交给 <Suspense> 在 resolve 后重新进入
instance.asyncDep = setupResult
}
} else {
handleSetupResult(instance, setupResult, isSSR)
}
} else {
// 没有 setup,使用选项式 API
finishComponentSetup(instance, isSSR)
}
}
这里有几个关键细节:
setActiveSub():在执行 setup 期间把响应式系统的"当前订阅者"清空(3.5 及之前是pauseTracking()),防止 setup 里读到的响应式数据被外层某个 effect 意外收集setCurrentInstance():设置全局的"当前组件实例",让onMounted()等 Composition API 知道自己被注册到哪个组件;它返回上一个实例(连同上一个 effect scope),执行完用restoreCurrentInstance(prev)还原- setup 的 props 参数:在开发模式下是
shallowReadonly,防止用户意外修改 props - 异步 setup 不在这里
.then():客户端把 promise 存进instance.asyncDep,由mountComponent里的parentSuspense.registerDep()在 resolve 后调用handleSetupResult+setupRenderEffect(10.7 节)
handleSetupResult:处理 setup 的返回值
setup 函数的返回值有三种合法形态:一个对象(最常见)、一个函数(当 render 函数)、或 undefined。handleSetupResult 根据返回值类型分支处理。
最有意思的是"返回函数"这个分支——你可以在 setup 里直接返回一个 render 函数,绕过模板系统。这对于需要动态生成复杂 render 逻辑的场景(比如递归树组件、根据数据结构动态决定 DOM 结构)非常有用。React 的函数组件就是这种模式;Vue 提供这条路作为备选,既保持模板语法的友好性,又不挡住需要底层控制的开发者。
这种"API 给你默认的路,但也让你能绕过默认"的做法是 Vue 从 v2 一路走来的哲学——不强迫 opinion,但也不是完全无 opinion。这个专栏通篇都在讨论这种"设计空间里的平衡点"——Vue 的每一个 API 决策几乎都在"好用"和"可扩展"之间权衡。
// packages/runtime-core/src/component.ts:1011 起(__DEV__ 下的几处 warn 与
// devtools 钩子从略)
export function handleSetupResult(
instance: ComponentInternalInstance,
setupResult: unknown,
isSSR: boolean,
): void {
if (isFunction(setupResult)) {
// setup 返回了渲染函数
if (__SSR__ && (instance.type as ComponentOptions).__ssrInlineRender) {
// SFC inline 模式编译出的函数名是 ssrRender,要挂到 ssrRender 上(:1018-1021)
instance.ssrRender = setupResult
} else {
instance.render = setupResult as InternalRenderFunction
}
} else if (isObject(setupResult)) {
// setup 返回了状态对象
instance.setupState = proxyRefs(setupResult)
}
finishComponentSetup(instance, isSSR)
}
proxyRefs 是一个精巧的设计:它让模板中访问 ref 时不需要写 .value。当 setup 返回 { count: ref(0) } 时,模板中可以直接写 {{ count }} 而不是 {{ count.value }}。
第三步:setupRenderEffect
setupRenderEffect 是组件"活起来"的瞬间——在这一步之前,组件只是一个持有状态的数据结构;在这一步之后,它响应状态变化、它渲染 DOM、它有了生命。
实现上用到了 Vue 响应式的最底层机制:ReactiveEffect。render 函数被包装进一个 effect,这个 effect 在首次运行时订阅 render 中读取到的所有响应式属性;任何一个属性以后变化,effect 会被通知、重跑 render、生成新 subTree、和旧 subTree 做 diff。
这里的精妙在于——组件不需要主动声明"我依赖哪些响应式数据"。Vue 的响应式系统会在 render 执行过程中自动捕获依赖:读到了 state.count,就订阅 state 的 count 属性;读到了 props.msg,就订阅 props 的 msg 属性。这种"用即订阅"的自动依赖收集是 Vue 和 MobX 的共同杀手锏,React Hooks 反过来要求开发者手写依赖数组——两条路的哲学差异贯穿很多细节。本专栏第 4 章讲 track / trigger 的 4.3、4.4 两节是这块的前置阅读。
// packages/runtime-core/src/renderer.ts:1458(rc.4,大幅精简)
// 3.6 把组件渲染 effect 做成了 ReactiveEffect 的子类,componentUpdateFn 变成了它的 fn()
class SetupRenderEffect extends ReactiveEffect {
job: SchedulerJob
constructor(
private instance: ComponentInternalInstance,
private initialVNode: VNode,
private container: RendererElement,
private anchor: RendererNode | null,
private parentSuspense: SuspenseBoundary | null,
private namespace: ElementNamespace,
private optimized: boolean,
) {
super()
// 排进调度器的 job:只有脏了才 run
this.job = instance.job = () => {
if (this.dirty) {
this.run()
}
}
this.job.i = instance
}
// 依赖变化时被响应式系统调用:不是同步 run,而是 queueJob
notify(): void {
if (!(this.flags & EffectFlags.PAUSED)) {
const job = this.job
queueJob(job, job.i!.uid) // 以组件 uid 排序:父先于子
}
}
fn() {
const { instance, initialVNode, container, anchor, parentSuspense, namespace, optimized } = this
if (!instance.isMounted) {
// ---- 首次渲染 ----
const { bm, m } = instance
// 调用 beforeMount 钩子(源码前后各有 toggleRecurse(instance, false / true):
// 前置钩子里改状态不会让自己递归重排)
if (bm) invokeArrayFns(bm)
// 执行渲染函数,生成 VNode 子树
const subTree = (instance.subTree = renderComponentRoot(instance))
// 将 VNode 子树挂载到 DOM
patch(null, subTree, container, anchor, instance, parentSuspense, namespace)
// 设置组件根元素
initialVNode.el = subTree.el
// 调用 mounted 钩子(放入后置队列)
if (m) queuePostRenderEffect(m, undefined, parentSuspense)
instance.isMounted = true
} else {
// ---- 更新渲染 ----
let { next, bu, u, vnode } = instance
if (next) {
// 父组件触发的更新,需要更新 props/slots
next.el = vnode.el
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
// 调用 beforeUpdate 钩子(同样被 toggleRecurse(instance, false / true) 包住,
// 源码里这对调用把上面的 updateComponentPreRender 也包在内)
if (bu) invokeArrayFns(bu)
// 重新渲染
const nextTree = renderComponentRoot(instance)
const prevTree = instance.subTree
instance.subTree = nextTree
// Diff 并 Patch
patch(prevTree, nextTree,
hostParentNode(prevTree.el!)!,
getNextHostNode(prevTree),
instance, parentSuspense, namespace)
next.el = nextTree.el
// 调用 updated 钩子
if (u) queuePostRenderEffect(u, undefined, parentSuspense)
}
}
}
const setupRenderEffect: SetupRenderEffectFn = (
instance, initialVNode, container, anchor, parentSuspense, namespace, optimized,
) => {
// 创建响应式 effect
const effect = (instance.effect = new SetupRenderEffect(
instance, initialVNode, container, anchor, parentSuspense, namespace, optimized,
))
// instance.update:强制重渲染(forceUpdate / HMR 用)
instance.update = effect.run.bind(effect)
// 组件渲染 effect 允许递归更新(#1801, #2043)
toggleRecurse(instance, true)
// 首次执行
effect.run()
}
这是整个组件系统最关键的函数。它创建了一个 ReactiveEffect 的子类实例,把组件的渲染逻辑放进 fn()。当渲染函数中访问的响应式数据发生变化时,响应式系统调用 effect 的 notify(),后者把 instance.job 通过 queueJob 放入调度队列(按组件 uid 排序、同一 job 只入队一次),在下一个微任务中 flush;job 执行时先看 effect.dirty(Alien Signals 的 checkDirty(),第 6 章),确认依赖真的变了才 run()。3.5 的写法是 new ReactiveEffect(componentUpdateFn) + effect.scheduler = () => queueJob(job),3.6 改成子类重写 notify(),省掉了一层 scheduler 回调。顺带一提,第 9 章 Vapor 的 RenderEffect 是同一个模式的另一个子类——它的 notify() 多传了一个 order 参数用于同组件内排序。
10.3 Props 系统深度剖析
Props 是组件对外最重要的接口——父给子传数据、子不能修改、单向数据流。这个契约说起来简单,实现起来细节超多。
一个 :msg="hello" 背后会经过四个阶段:声明(组件 options 里的 props 定义)、解析(从 VNode 上把 raw props 取出来)、校验(类型、required、validator 检查)、响应式代理(包成 shallowReactive 供 render 读)。下面每一节覆盖一个阶段。读的时候有个心智框架:Vue 的 Props 系统和 TypeScript 的类型系统解决同一个问题在不同时机——TS 管编译时、Props 管运行时;两者在生产级代码里是互补而非替代。
顺便一提:<script setup> 里 defineProps<{...}>() 的类型声明用法(随 3.2 的 <script setup> 一起稳定,3.3 起支持引用外部导入的类型)正是"在编译时 + 运行时同时工作"的 sugar——TS 类型通过 Vue 编译器转成运行时 props 声明,一份代码同时满足两个系统。这种编译器承担"桥梁"角色的思路在现代框架里越来越常见。
Props 的四阶段处理
四阶段听起来复杂,但每一阶段都只做一件事:
- 声明阶段(编译时 + 首次挂载):Vue 从组件的 options 或
<script setup>的defineProps里读出"我接受哪些 prop、各自的类型"。这个信息在组件生命周期内不变。 - 解析阶段(每次挂载或更新):从外层 VNode 的 props 字段里把数据取出来。
- 校验阶段(开发模式):对照声明检查类型、required、自定义 validator(default 值和 Boolean 转换不在这一步,它们在
resolvePropValue里,生产环境照常执行)。生产模式这一步被完全跳过——检查只是开发时的辅助、不承担运行时语义。 - 代理阶段:把校验过的 props 包成 shallowReactive,子组件 render 时读到的是代理——享受响应式,但浅层不代理深层(因为 props 的约定就是"父给的原始对象不可变")。
理解这四个阶段是写正确 Vue 组件的基础。特别是第 3 步:很多人以为生产环境也有 prop 类型检查——不是的。这是设计上的显式权衡:生产要的是速度,开发要的是反馈;两者由同一份 API 分别负责。
flowchart LR
A[Props 声明] --> B[Props 解析]
B --> C[Props 校验]
C --> D[Props 代理]
A -.- A1["defineProps / props 选项"]
B -.- B1["从 VNode.props 中提取"]
C -.- C1["类型 / required / validator(仅开发)"]
D -.- D1["shallowReactive 包裹"]
initProps:Props 初始化
initProps 是"把原始 props 对象转换成可供组件使用的形态"。过程中它同时生成两个东西:props(声明过的) 和 attrs(没声明的)。
这个分拣机制非常关键——Vue 的模板里写 <Child foo="bar" />,如果 Child 在 props 里声明了 foo,这个值进 props;没声明的话进 attrs。attrs 会自动 fallthrough 到 Child 的根元素上(除非关闭 inheritAttrs)。这是 Vue 组件"自动透传"行为的根源——一个写惯 Vue 的人对这个行为视为理所当然,但它其实是 props 系统分拣 + fallthrough两步协作的结果。
这种设计的价值是让组件可以只声明自己关心的 props,其他的(尤其是原生 HTML 属性)自动透传到底层元素。写一个包装 <button> 的 <MyButton> 组件时,你不需要一个个声明 disabled、id、class、aria-* 这些原生属性——全部进入 attrs 自动透传。这把组件作者从"声明爆炸"里解放出来。
// packages/runtime-core/src/componentProps.ts:199(rc.4)
export function initProps(
instance: ComponentInternalInstance,
rawProps: Data | null,
isStateful: number, // shapeFlag & STATEFUL_COMPONENT 的按位结果
isSSR = false,
): void {
const props: Data = (instance.props = {})
const attrs: Data = createInternalObject() // 原型是内部标记,attrs 被再传给 h() 时能认出来先拷贝
instance.propsDefaults = Object.create(null)
// 解析 props 和 attrs
setFullProps(instance, rawProps, props, attrs)
// 确保声明的 props 都有值(即使是 undefined)
for (const key in instance.propsOptions[0]) {
if (!(key in props)) {
props[key] = undefined
}
}
// 校验 props(只在开发构建)
if (__DEV__) {
validateProps(rawProps || {}, props, instance.propsOptions[0]!)
}
if (isStateful) {
// 有状态组件:用 shallowReactive 包裹(SSR 下不需要响应式)
instance.props = isSSR ? props : shallowReactive(props)
} else {
if (!instance.type.props) {
// 函数式组件且未声明 props:props === attrs
instance.props = attrs
} else {
instance.props = props
}
}
instance.attrs = attrs
}
setFullProps:Props 与 Attrs 的分拣
分拣逻辑的核心:遍历 raw props 的每一个 key,检查是否在 propsOptions(组件声明的 props 定义)里——在就放 props、不在就放 attrs。
细节上有几个值得说的点:key 的归一化——父组件传 my-prop 或 myProp 都会归到同一个 key;事件监听器的特殊处理——onClick / onUpdate:xxx 这样的 key,如果组件声明了对应 emits 则匹配、否则按 attrs 处理(fallthrough 后自动绑到根元素);modifiers 的处理——组件 v-model 的修饰符(.trim、.number)以 modelModifiers / xxxModifiers 这样的普通 prop 传进来,emit 时再由 getModelModifiers 读取(见 10.4)。
读到这段代码时,你会理解为什么 Vue 的模板里不管父子组件都能写同一套 @event 语法——因为在 setFullProps 里,事件和普通 prop 走的是几乎一样的分拣路径。这种"统一抽象、分支处理"的代码读起来很紧凑,但需要你对上下文有完整理解——建议配合断点调试来看。
// packages/runtime-core/src/componentProps.ts:379(rc.4,去掉了 compat 分支)
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) {
// 跳过保留的 key
if (isReservedProp(key)) continue
const value = rawProps[key]
// 驼峰化
let camelKey: string
if (options && hasOwn(options, (camelKey = camelize(key)))) {
// 声明的 prop
if (!needCastKeys || !needCastKeys.includes(camelKey)) {
props[camelKey] = value
} else {
// 需要特殊处理的 prop(Boolean cast 等)
(rawCastValues || (rawCastValues = {}))[camelKey] = value
}
} else if (!isEmitListener(instance.emitsOptions, key)) {
// 非 prop 且非事件监听器 → 归入 attrs(保留原始大小写)
if (!(key in attrs) || value !== attrs[key]) {
attrs[key] = value
hasAttrsChanged = true
}
}
}
}
// 处理 Boolean cast 和 default 值
if (needCastKeys) {
const castValues = rawCastValues || EMPTY_OBJ
for (let i = 0; i < needCastKeys.length; i++) {
const key = needCastKeys[i]
props[key] = resolvePropValue(
options!,
key,
castValues[key],
instance,
baseResolveDefault,
!hasOwn(castValues, key) /* isAbsent */,
)
}
}
return hasAttrsChanged // 给 updateProps 用:attrs 变了要 trigger $attrs
}
这里的分拣逻辑是 Props 系统的核心:传入的每个属性要么是声明过的 prop,要么是 emits 里声明过的事件监听器(onXxx,既不进 props 也不进 attrs),要么是透传的 attr。三者互不交叉。
Props 更新
Props 的初次初始化发生在 mount 阶段,但组件存活期间父组件还会反复传新 props 进来——每次父 render 都会生成新的 VNode,其中的 props 可能变。Vue 需要把"新 props"合并到子组件的 instance.props 上。
这里有个非常微妙的设计:Vue 不是替换整个 props 对象,而是原地修改 instance.props 里的字段。为什么?因为 instance.props 是 shallowReactive 代理,setup 拿到的 props 参数、watch(() => props.x) 这类闭包早就捕获了这个对象的引用,替换整个对象会让它们全部和新数据失联;而原地修改某个字段,只有订阅了那个字段的 effect 被 trigger,其他 effect 保持原位。
这种"保持响应式身份稳定"的思路在 Vue 的各个角落都能看到——currentRoute 用 shallowRef 也有相通之处:每次导航整体替换的只是 .value,ref 容器本身不变,订阅也就不断。理解这种思路后,你会对"什么时候替换、什么时候原地改"这种看似微小的决策产生判断力——替换是快但破坏订阅;原地改是稳但代码啰嗦;两者的选择取决于响应式身份稳定性是否比代码简洁更重要。
当父组件重新渲染时,子组件的 props 可能发生变化:
// packages/runtime-core/src/componentProps.ts:246(rc.4,去掉了 compat / HMR 分支)
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 ((optimized || patchFlag > 0) && !(patchFlag & PatchFlags.FULL_PROPS)) {
if (patchFlag & PatchFlags.PROPS) {
// 编译器优化路径:key 集合没变,只检查 dynamicProps 里列出的动态 props
const propsToUpdate = instance.vnode.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
let key = propsToUpdate[i]
// 声明过的 emit 监听器不是 prop,跳过
if (isEmitListener(instance.emitsOptions, key)) continue
const value = rawProps![key]
if (options) {
// 初始化时已经分拣过,这里只需看 attrs 里有没有
if (hasOwn(attrs, key)) {
if (value !== attrs[key]) {
attrs[key] = value
hasAttrsChanged = true
}
} else {
const camelizedKey = camelize(key)
props[camelizedKey] = resolvePropValue(
options, camelizedKey, value, instance, baseResolveDefault,
)
}
} else if (value !== attrs[key]) {
attrs[key] = value
hasAttrsChanged = true
}
}
}
} else {
// 全量对比
if (setFullProps(instance, rawProps, props, attrs)) {
hasAttrsChanged = true
}
// 新 rawProps 里消失的 key:声明过的重置为默认值,没声明的从 props 删掉
for (const key in rawCurrentProps) {
if (!rawProps || (!hasOwn(rawProps, key) && !hasOwn(rawProps, hyphenate(key)))) {
if (options) {
if (rawPrevProps && (rawPrevProps[key] !== undefined || rawPrevProps[hyphenate(key)] !== undefined)) {
props[key] = resolvePropValue(options, key, undefined, instance, baseResolveDefault, true /* isAbsent */)
}
} else {
delete props[key]
}
}
}
// 清理多余的 attrs
if (attrs !== rawCurrentProps) {
for (const key in attrs) {
if (!rawProps || !hasOwn(rawProps, key)) {
delete attrs[key]
hasAttrsChanged = true
}
}
}
}
// attrs 变了:触发 $attrs 的依赖(它可能被用在 slots 里)
if (hasAttrsChanged) {
trigger(instance.attrs, TriggerOpTypes.SET, '')
}
if (__DEV__) {
validateProps(rawProps || {}, props, instance.propsOptions[0]!)
}
}
PatchFlags.PROPS 路径是编译器协作的典范:编译器在编译阶段就知道哪些 props 是动态的(通过 : 绑定),将它们记录在 VNode 的 dynamicProps 数组中并打上 PROPS 标志(第 8 章)。更新时只需检查这些动态 props,跳过静态的;一旦出现 v-bind="obj" 这类无法在编译期确定 key 集合的写法,编译器会改打 FULL_PROPS,运行时退回全量对比。
10.4 Emit 事件系统
和 Props 对称,Emit 是组件向外传递事件的机制——子组件 emit('update', value)、父组件 @update="handler" 接到。
Emit 的实现很巧——父组件的事件监听器其实是通过 props 形式传进来的:父模板里写 @update="handler" 等价于 :onUpdate="handler"——Vue 会把它放到子组件 VNode 的 props 里,key 加 on 前缀、首字母大写。子组件调 emit('update') 时,内部就是去 instance.vnode.props 里找 onUpdate 然后调用它。
理解这一点之后一大堆"事件怎么工作"的问题就都清楚了:为什么 emit 不需要显式订阅、为什么事件名会被 kebab-case / camelCase 归一化、为什么用 v-model 等价于同时传 prop 和 listener——都是 props 系统的自然延伸。这种"用已有机制表达新概念"的设计让 Vue 的运行时代码量很克制——不为事件系统额外维护一套订阅/发布基础设施,全部复用 props。
emit 函数的实现
emit 的核心实现就几十行代码,但每一行都在处理一个真实边界情况。看这段代码最有收获的方式是和"我用 emit 时踩过的坑"对上号——比如:
- 为什么
emit('my-event')和emit('myEvent')能被同一个@my-event监听?(内部做了 camelCase/kebab-case 归一化) - 为什么
emit('update:msg', v)是 v-model 的底层实现?(update: 前缀 + props key 是 v-model 的编译产物) - 为什么事件可以声明在 emits 里做验证?(emits option 作为 runtime 校验源)
这些"奇奇怪怪的行为"在看完实现后都变成"理所当然的结果"——这就是读源码的反直觉收获:理解了实现之后 API 看起来更合理、更少惊讶。
// packages/runtime-core/src/componentEmits.ts:114(rc.4,去掉了 compat / devtools 分支和开发期的大小写提示)
// 3.6 把 emit 拆成了两层:emit 只负责取 props,baseEmit 是 VDOM 与 Vapor 共用的核心
export function emit(
instance: ComponentInternalInstance,
event: string,
...rawArgs: any[]
): ComponentPublicInstance | null | undefined {
return baseEmit(
instance,
instance.vnode.props || EMPTY_OBJ,
defaultPropGetter, // (props, key) => props[key];Vapor 传的是能穿透 getter 的版本
event,
...rawArgs,
)
}
export function baseEmit(
instance: GenericComponentInstance,
props: Record<string, any>,
getter: (props: Record<string, any>, key: string) => unknown,
event: string,
...rawArgs: any[]
): ComponentPublicInstance | null | undefined {
// 已卸载的组件不发射事件
if (instance.isUnmounted) return
// 开发模式下的校验
if (__DEV__) {
const { emitsOptions, propsOptions } = instance
if (emitsOptions) {
if (!(event in emitsOptions)) {
// 警告:发射了既没在 emits 里声明、也没有对应 onXxx prop 的事件
if (!propsOptions || !propsOptions[0] || !(toHandlerKey(camelize(event)) in propsOptions[0])) {
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) && !validator(...rawArgs)) {
warn(`Invalid event arguments: event validation failed for event "${event}".`)
}
}
}
}
let args = rawArgs
// 处理 v-model 的 update:xxx 事件:应用 .trim / .number 修饰符
const isModelListener = event.startsWith('update:')
const modifiers = isModelListener && getModelModifiers(props, event.slice(7), getter)
if (modifiers) {
if (modifiers.trim) args = rawArgs.map(a => (isString(a) ? a.trim() : a))
if (modifiers.number) args = rawArgs.map(looseToNumber)
}
// 查找事件处理器
// event: 'click' → handler key: 'onClick'
// event: 'my-event' → 'onMy-event' 没有 → camelize 后 'onMyEvent'
// event: 'update:modelValue' → 'onUpdate:modelValue'
let handlerName
let handler =
getter(props, (handlerName = toHandlerKey(event))) ||
// 也尝试 camelCase 形式(#2249)
getter(props, (handlerName = toHandlerKey(camelize(event))))
// 只有 v-model 的 update:xxx 事件才会再试 kebab-case(父组件可能写的是 :model-value)
if (!handler && isModelListener) {
handler = getter(props, (handlerName = toHandlerKey(hyphenate(event))))
}
if (handler) {
callWithAsyncErrorHandling(handler, instance, ErrorCodes.COMPONENT_EVENT_HANDLER, args)
}
// 处理 once 修饰符
const onceHandler = getter(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)
}
}
emit 的实现看似简单,但隐藏了一个优雅的设计:事件监听器其实是 props 的一部分。当你在模板中写 @click="handler" 时,编译器将其转换为 { onClick: handler } 作为 VNode 的 props。这意味着事件系统不需要独立的注册/注销机制——它搭了 props 系统的便车。3.6 里 baseEmit 把"从哪里取 props"抽成了 getter 参数,于是第 9 章 Vapor 组件的 emit 也复用这同一段逻辑,只是 getter 换成能穿透 getter 型 RawProps 的版本。
10.5 Slots 系统
如果 Props 是"父给子传数据"、Emit 是"子给父发事件",Slots 就是"父给子传 UI 片段"。三者加起来构成组件的全部对外通信。
Slots 的实现比 Props 和 Emit 都复杂,因为它要处理"父组件能在子组件的渲染树里插入任意 VNode"这个强大但复杂的能力。想象一下:父渲染时 slot 内容在父的作用域里;子渲染时又要把这段 VNode 放到子的 DOM 里——跨越两个组件作用域,而且两边的响应式系统都要能正确订阅。下面的实现细节都在解决这个复杂度。
要点:Slot 不是简单的 VNode 快照,而是一个函数。父编译时把 slot 内容编译成一个返回 VNode 的函数,传给子;子 render 时调这个函数、拿到最新的 VNode。这个"函数而非静态值"的选择让作用域 slot 成为可能——函数可以接收子传回的参数,从而让父的 slot 内容能引用子的内部状态。没有这一层函数包装,<template #default="{ item }"> 这种作用域 slot 语法就根本不可能存在。
Slots 的三种形态
从编译器视角看,父组件传给子组件的 slot 内容分三种情况:静态 slots、动态 slots、作用域 slots。三种情况在运行时数据结构上完全统一(都是函数),差别只在生成函数的方式。这种"编译时分情况讨论、运行时单一抽象"的设计让 slots 的 runtime 代码保持极短,也让开发者不用心智切换(用起来就是一个 <slot> 标签、一个 template 块)。
// 示意:省略了 openBlock/createBlock、slot 函数外层的 withCtx() 包装;
// 动态 slot 实际还带 key,并给组件 VNode 打上 DYNAMIC_SLOTS patchFlag
// 1. 静态 slots(编译期确定的内容)
// <Child><span>hello</span></Child>
// 编译为:
createVNode(Child, null, {
default: () => [createVNode('span', null, 'hello')],
_: SlotFlags.STABLE // 标记为稳定 slots
})
// 2. 动态 slots(slot 的"集合"会变:slot 上有 v-if / v-for,或 slot 名是动态的)
// <Child><template v-if="ok" #header>Head</template></Child>
// 编译为:
createVNode(Child, null, createSlots({ _: SlotFlags.DYNAMIC }, [
ctx.ok ? { name: 'header', fn: () => [createTextVNode('Head')] } : undefined
]))
// 3. 作用域 slots —— 仍然是 STABLE:内容里有插值不影响"稳定性",
// STABLE 说的是 slot 的 key 集合不会变,内容变化由函数在调用时自然反映
// <Child v-slot="{ item }"><span>{{ item.name }}</span></Child>
// 编译为:
createVNode(Child, null, {
default: ({ item }) => [createVNode('span', null, item.name)],
_: SlotFlags.STABLE
})
// SlotFlags(packages/shared/src/slotFlags.ts):STABLE = 1, DYNAMIC = 2, FORWARDED = 3
// FORWARDED:子组件把自己收到的 <slot> 又转发进另一个组件的 slot,稳定性取决于父组件
initSlots:Slots 初始化
initSlots 和 initProps 对称——把外层 VNode 的 children 转换成子组件内部用的 slots 对象。但有一个决定性的不同:slots 的值是函数,不是数据。
这个函数形态的好处前面讲过:让子组件在合适的时机调用、每次调用都能拿到基于当前状态的最新 VNode。技术实现上,编译器看到父模板里的 <Child><template #default>内容</template></Child>,会生成形如 { default: () => [h('span', 内容)] } 的对象,作为 Child VNode 的 children 字段。Child 组件 initSlots 时把这个对象直接复制到 instance.slots。Child 的 render 里写 <slot> 时,编译成 renderSlot(slots, 'default'),内部就是调用 slots.default() 拿 VNode 数组。
一个容易忽视的细节:slot 函数在调用时是在子组件的渲染上下文里跑、但函数本身是父组件定义的。所以 slot 函数里访问的变量是父的 scope 里的,但函数的参数是子组件调用时传进来的。这就是作用域 slot 的底层——函数调用的经典闭包能力,被用来优雅地跨越两个组件的作用域边界。
// packages/runtime-core/src/componentSlots.ts:186(rc.4)
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) {
// 编译优化的 slots:直接把 slot 函数逐个赋到 instance.slots 上
assignSlots(slots, children as Slots, optimized)
// 保留编译器标记,但设为不可枚举
if (optimized) {
def(slots, '_', type, true)
}
} else {
// 手写渲染函数的 slots:逐个 normalize 成返回 VNode 数组的函数
normalizeObjectSlots(children as RawSlots, slots, instance)
}
} else if (children) {
// 只有默认 slot(纯文本或 VNode 数组)
normalizeVNodeSlots(instance, children)
}
}
Slots 的响应式更新
Slots 更新和 Props 更新遵循同样的"原地修改而非整体替换"原则——父组件 render 生成新的 slot 函数,子组件 updateSlots 时只替换 slots 对象里的函数引用。
但这里有个 Slots 独有的复杂度:slot 函数闭包引用的是父的响应式数据,却是在子组件 render 期间被调用的——所以这些读取被收集进子组件的渲染 effect(withCtx 只切换 currentRenderingInstance,不改当前订阅者)。这意味着父的数据变化会直接让子组件重渲染、slot 重新生成 VNode 再 diff(父组件自己的模板若没读这些数据,父并不重渲染)——这是"父改了、子 DOM 变了"现象的底层成因。
这种跨作用域的响应式是 Vue 的 slot 系统最让人又爱又困惑的特性之一。爱的是"用起来天然、父的数据直接在子的 DOM 上反映";困惑的是"为什么子明明没变、它里面的 slot 内容却重渲染了"——答案就是父的数据变了、slot 函数返回了新 VNode、子的 diff 看到不同于是更新。
Slots 的更新是组件系统中最微妙的部分之一。关键问题是:当父组件更新时,子组件的 slots 是否需要重新渲染?
// packages/runtime-core/src/componentSlots.ts:208(rc.4,去掉了 HMR 分支)
export const updateSlots = (
instance: ComponentInternalInstance,
children: VNodeNormalizedChildren,
optimized: boolean,
): 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) {
// 编译产物的 slots
if (optimized && type === SlotFlags.STABLE) {
// 编译产物且稳定:slot 的 key 集合不会变,无需更新,也不用检查过期 slot
// 这是一个关键优化
needDeletionCheck = false
} else {
// 编译产物但动态(slot 上有 v-if / v-for):更新引用,跳过 normalize
assignSlots(slots, children as Slots, optimized)
}
} else {
// 手写 slots:$stable 是手写 render 函数给出的"稳定"提示
needDeletionCheck = !(children as RawSlots).$stable
normalizeObjectSlots(children as RawSlots, slots, instance)
}
deletionComparisonTarget = children as RawSlots
} else if (children) {
// 直接传了非 slot 对象的 children
normalizeVNodeSlots(instance, children)
deletionComparisonTarget = { default: 1 }
}
// 清理多余的 slot
if (needDeletionCheck) {
for (const key in slots) {
if (!isInternalKey(key) && deletionComparisonTarget[key] == null) {
delete slots[key]
}
}
}
}
SlotFlags.STABLE 优化意味着:如果编译器能在编译期确认 slot 的集合不会变(没有 v-if / v-for 的 slot、没有动态 slot 名),就标记为 STABLE,运行时直接跳过对 instance.slots 的更新。注意它说的是"slot 函数的引用不需要换",不是"slot 内容不变"——slot 内容里的插值照常由子组件在调用 slot 函数时读到最新值。
10.6 expose:组件的公共 API 表面
expose 这个 API 很多人没用过,但它解决了一个组件设计里的深层问题——"父组件能看到子组件的哪些内部状态?"
默认情况下,<script setup> 中定义的所有变量对父组件都是不可见的——父组件即使拿到 ref(Child) 的实例,也访问不到子组件的内部状态。这是 Vue 3 和 Vue 2 的一个重要改变:封装默认更严。
但有时候你确实需要暴露——比如父想调用子的 focus() 方法、父想读子的某个状态做联动。这时候用 defineExpose({ focus, someState }) 显式声明"这些是我的公共 API"。没显式 expose 的东西,父想访问也访问不到。
这种"默认私有、显式暴露"的设计在软件工程里叫 information hiding——大名鼎鼎的 David Parnas 1972 年论文《On the Criteria to be Used in Decomposing Systems into Modules》里首次系统论述的核心原则。Vue 3 把这条原则带到组件系统里:你给父组件的 API 是什么,应该是你主动决策的事情,而不是"凡我声明的都自动公开"。这个改进是 Vue 3 相对 Vue 2 在组件封装性上的一次真正进步。
expose 的作用
expose 的机制上面解释过——把声明的字段放进一个特殊对象,父组件通过 ref 能访问到这个对象。实现细节上这个对象其实是个 Proxy,只透传 exposed 里的字段和 $el / $props 这类公共属性。
这种"用 Proxy 控制公共 API 表面"的技巧不只 Vue 在用。Node.js 的 module.exports、Deno 的 permission system、浏览器 WebView 的 postMessage 边界——都是用某种边界机制(模块作用域、权限沙箱、消息通道)把"内部可能很大但对外只暴露必要的"。这是软件工程里的基本型——信息隐藏是好架构的必然要求,而在 JavaScript 里,Proxy 是最自然的实现工具。
expose 允许组件作者明确控制哪些属性和方法可以通过模板 ref 被外部访问:
// packages/runtime-core/src/component.ts:1214(rc.4,生产分支)
export function createSetupContext(instance: ComponentInternalInstance): SetupContext {
return {
attrs: new Proxy(instance.attrs, attrsProxyHandlers) as Attrs, // 读 attrs 时 track $attrs
slots: instance.slots,
emit: instance.emit,
expose: exposed => expose(instance, exposed as any),
}
}
// VDOM 与 Vapor 共用:defineExpose() 编译后也是调它
export function expose(instance: GenericComponentInstance, exposed: Record<string, any>): void {
if (__DEV__ && instance.exposed) {
warn(`expose() should be called only once per setup().`)
}
instance.exposed = exposed || {}
}
expose 代理的实现
Proxy 的 get 拦截器里做两件事:首先检查访问的 key 是否在 exposed 对象里——在就 return 对应值,不在就只放行 $el / $props / $emit 这类 publicPropertiesMap 里的公共属性,其他一律 undefined;其次返回值是解包过的——被代理的目标本身就是 proxyRefs(markRaw(exposed)),内部写的是 ref(count),外部访问时直接拿到 value 而不是 ref 对象。
第二条"自动 unref"是 Vue 的一贯特性——template 里访问 ref 自动解包、reactive 对象属性访问 ref 自动解包、现在 expose 也一样。这种一致的 unref 语义让"你不需要在每个地方都区分是 ref 还是值"成为可能。这是 Vue 响应式 DX(开发体验)的关键设计——本专栏第 4 章 4.2 节(ref 为什么需要 .value)是这块的前置阅读。
// packages/runtime-core/src/component.ts:1277(rc.4;旧版本叫 getExposeProxy)
export function getComponentPublicInstance(
instance: GenericComponentInstance,
): ComponentPublicInstance | ComponentInternalInstance['exposed'] | null {
if (instance.exposed) {
return (
instance.exposeProxy ||
(instance.exposeProxy = new Proxy(proxyRefs(markRaw(instance.exposed)), {
get(target, key: string) {
if (key in target) {
return target[key]
} else {
const publicPropertiesMap = getPublicPropertiesMap()
if (key in publicPropertiesMap) {
return publicPropertiesMap[key](instance as ComponentInternalInstance)
}
}
},
has(target, key: string) {
const publicPropertiesMap = getPublicPropertiesMap()
return key in target || key in publicPropertiesMap
},
}))
)
} else {
// 没调用 expose:模板 ref 拿到的是完整的公共实例代理
return instance.proxy
}
}
当外部通过模板 ref 访问组件时,如果组件使用了 expose,得到的是 exposeProxy 而非完整的组件实例。这是一个重要的封装机制——组件内部状态不会泄露。
10.7 异步组件
异步组件解决的问题看起来简单:"我这个组件的代码不想和首屏一起下载,访问到它的时候再拉"。但"再拉"这个过程中要处理的边界情况超多——加载期间显示什么、加载失败怎么办、快速加载完要不要避免 loading 闪烁、多久算超时、多次访问要不要复用之前拉过的——每一个都值得专门设计。
本节讲 Vue 的异步组件 API 怎么把这些问题抽象成一个配置对象,让开发者"声明一下"就能得到生产级的异步加载体验。作为开胃菜,先思考一个问题:"异步组件"和"异步加载某个资源"有本质区别吗?有——组件是一棵树的局部,加载过程发生时它必须占位但不能占页面布局。这就把异步组件和"懒加载图片"分开了——图片可以先占位再加载完改 src,组件不行。下面的实现就是围绕这个约束展开的。
defineAsyncComponent 的实现
// packages/runtime-core/src/apiAsyncComponent.ts:200(rc.4,精简)
// 3.6 把"加载器"和"加载状态"抽成了两个可复用的函数,VDOM 的 defineAsyncComponent
// 和 Vapor 的 defineVaporAsyncComponent 共用它们
export function createAsyncComponentContext(source) {
if (isFunction(source)) {
source = { loader: source }
}
const { loader, onError: userOnError } = source
let pendingRequest: Promise<C> | null = null
let resolvedComp: C | undefined
// 重试计数
let retries = 0
const retry = () => {
retries++
pendingRequest = null
return load()
}
const load = (): Promise<C> => {
let thisRequest: Promise<C>
return (
pendingRequest ||
(thisRequest = pendingRequest = loader()
.catch(err => {
err = err instanceof Error ? err : new Error(String(err))
if (userOnError) {
// 用户自定义错误处理:把 retry / fail 交给用户决定
return new Promise((resolve, reject) => {
const userRetry = () => resolve(retry())
const userFail = () => reject(err)
userOnError(err, userRetry, userFail, retries + 1)
})
} else {
throw err
}
})
.then((comp: any) => {
if (thisRequest !== pendingRequest && pendingRequest) {
return pendingRequest
}
// 处理 ES module default export
if (comp && (comp.__esModule || comp[Symbol.toStringTag] === 'Module')) {
comp = comp.default
}
resolvedComp = comp
return comp
}))
)
}
return { load, source, getResolvedComp: () => resolvedComp, setPendingRequest: r => (pendingRequest = r) }
}
// loaded / error / delayed 三个 ref + delay / timeout 两个定时器
export const useAsyncComponentState = (delay, timeout, onError, instance = currentInstance) => {
const loaded = ref(false)
const error = ref()
const delayed = ref(!!delay)
if (delay) {
setTimeout(() => { if (!(instance && instance.isUnmounted)) delayed.value = false }, delay)
}
if (timeout != null) {
setTimeout(() => {
if (!loaded.value && !error.value) {
const err = new Error(`Async component timed out after ${timeout}ms.`)
onError(err)
error.value = err
}
}, timeout)
}
return { loaded, error, delayed }
}
export function defineAsyncComponent(source) {
const {
load,
getResolvedComp,
setPendingRequest,
source: { loadingComponent, errorComponent, delay, hydrate: hydrateStrategy, timeout, suspensible = true },
} = createAsyncComponentContext(source)
// 返回一个包装组件
return defineComponent({
name: 'AsyncComponentWrapper',
__asyncLoader: load,
__asyncHydrate(el, instance, hydrate) { /* 3.5 的 Lazy Hydration:按 hydrateStrategy 延迟激活 */ },
get __asyncResolved() { return getResolvedComp() },
setup() {
const instance = currentInstance!
markAsyncBoundary(instance)
// 如果已经解析过,直接渲染
let resolvedComp = getResolvedComp()
if (resolvedComp) {
return () => createInnerComp(resolvedComp!, instance)
}
const onError = (err: Error) => {
setPendingRequest(null)
handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER, !errorComponent)
}
// 在 <Suspense> 里(且 suspensible)或 SSR 时:把 promise 交出去,由 Suspense 管 fallback
if ((__FEATURE_SUSPENSE__ && suspensible && instance.suspense) || (__SSR__ && isInSSRComponentSetup)) {
return load()
.then(comp => () => createInnerComp(comp, instance))
.catch(err => {
onError(err)
return () => (errorComponent ? createVNode(errorComponent, { error: err }) : null)
})
}
// 自己管加载状态
const { loaded, error, delayed } = useAsyncComponentState(delay, timeout, onError, instance)
load()
.then(() => {
if (instance.isUnmounted) return
loaded.value = true
})
.catch(err => {
if (instance.isUnmounted) return
onError(err)
error.value = err
})
return () => {
resolvedComp = getResolvedComp()
if (loaded.value && resolvedComp) {
return createInnerComp(resolvedComp, instance)
} else if (error.value && errorComponent) {
return createVNode(errorComponent, { error: error.value })
} else if (loadingComponent && !delayed.value) {
return createInnerComp(loadingComponent, instance)
}
}
},
})
}
异步组件本质上是一个高阶组件模式:它返回一个同步的包装组件,内部管理加载状态的切换。一个值得留意的细节:3.5 的源码里有 delay = 200 的默认值(loading 组件默认延迟 200ms 出现以避免闪烁),rc.4 在把加载逻辑抽成 createAsyncComponentContext 时(#13059)没有保留这个默认——delay 不传就是 undefined,delayed 初值为 false,loading 组件会立刻显示。
10.8 组件的公共实例代理
PublicInstanceProxyHandlers
当你在模板中或选项式 API 中访问 this.xxx 时,实际上是在访问组件的公共实例代理:
// packages/runtime-core/src/componentPublicInstance.ts:422(rc.4,精简:略去 $attrs 追踪、CSS Modules、
// globalProperties、$ 前缀只读保护与 DEV 警告等分支)
export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
get({ _: instance }: ComponentRenderContext, key: string) {
const { ctx, setupState, data, props, accessCache, type, appContext } =
instance
// 缓存查找路径,避免重复判断($ 开头的是公共属性,走下面的 publicPropertiesMap)
if (key[0] !== '$') {
const n = accessCache![key]
if (n !== undefined) {
switch (n) {
case AccessTypes.SETUP:
return setupState[key]
case AccessTypes.DATA:
return data[key]
case AccessTypes.CONTEXT:
return ctx[key]
case AccessTypes.PROPS:
return props![key]
}
}
// 依次查找:setupState → data → props → ctx
if (hasSetupBinding(setupState, key)) {
accessCache![key] = AccessTypes.SETUP
return setupState[key]
} else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
accessCache![key] = AccessTypes.DATA
return data[key]
} else if (hasOwn(props, key)) {
accessCache![key] = AccessTypes.PROPS
return props![key]
} else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
accessCache![key] = AccessTypes.CONTEXT
return ctx[key]
}
}
// 公共属性:$el, $data, $props, $slots, $refs 等
const publicGetter = getPublicPropertiesMap()[key]
if (publicGetter) {
return publicGetter(instance)
}
},
set({ _: instance }: ComponentRenderContext, key: string, value: any) {
const { setupState, data, ctx } = instance
if (hasSetupBinding(setupState, key)) {
setupState[key] = value
return true
} else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
data[key] = value
return true
} else if (hasOwn(instance.props, key)) {
// 不允许修改 props
__DEV__ && warn(`Attempting to mutate prop "${key}".`)
return false
}
ctx[key] = value
return true
}
}
accessCache 是一个性能优化:第一次访问某个 key 时,记录它来自哪个源(setup/data/props/ctx),后续访问直接走缓存路径,避免重复的 hasOwn 检查。
10.9 组件更新与卸载
组件更新的触发
组件更新有两种触发方式:
// packages/runtime-core/src/renderer.ts:1427
// 1. 自身状态变化(子组件触发)
// setup 中的 ref 变化 → SetupRenderEffect.notify() → queueJob(instance.job, uid)
// → 微任务里 flush → job 检查 effect.dirty → effect.run()
// 2. 父组件传入新 props(父组件触发)
// 在 patch 阶段,如果子组件的 VNode props / slots 变化
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) {
// 异步 setup 还没 resolve:渲染 effect 还不存在,只更新 props / slots
updateComponentPreRender(instance, n2, optimized)
return
}
instance.next = n2
// 同步执行渲染 effect(父组件正在 patch,子组件必须在这一轮完成更新)
instance.effect.run()
} else {
// 无需更新:只复制属性
n2.el = n1.el
instance.vnode = n2
}
}
注意第二条路径是同步的:父组件 patch 到子组件时直接 instance.effect.run(),而不是再排一个 job——否则父组件这一轮 patch 结束后子组件的 DOM 还是旧的。3.4 及之前这里是 invalidateJob(instance.update); instance.update()(invalidateJob 在 3.5.0 移除);rc.4 的 scheduler 里已经没有 invalidateJob 了——排在队列里的 instance.job 执行时会先检查 this.dirty,刚被同步 run() 过的 effect 已经不脏,那个 job 自然成为空操作。
shouldUpdateComponent:是否需要更新
// packages/runtime-core/src/componentRenderUtils.ts:383(rc.4,去掉了 HMR 分支)
export function shouldUpdateComponent(
prevVNode: VNode,
nextVNode: VNode,
optimized?: boolean,
): boolean {
const { props: prevProps, children: prevChildren, component } = prevVNode
const { props: nextProps, children: nextChildren, patchFlag } = nextVNode
const emits = component!.emitsOptions
// 组件 VNode 上有运行时指令或 transition:强制更新
if (nextVNode.dirs || nextVNode.transition) {
return true
}
if (optimized && patchFlag >= 0) {
// 编译产物路径
if (patchFlag & PatchFlags.DYNAMIC_SLOTS) {
// slot 集合可能变了(v-if / v-for 的 slot)
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]
// 只比动态 props,且声明过的 emit 监听器不算
if (hasPropValueChanged(nextProps!, prevProps!, key) && !isEmitListener(emits, key)) {
return true
}
}
}
} else {
// 手写 render 函数路径:有 children 就强制更新(除非手动标了 $stable)
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
}
组件卸载
// packages/runtime-core/src/renderer.ts:2606(rc.4,去掉了 compat / devtools / HMR 分支,
// 以及异步激活组件在 effect 建立前被卸载时移除占位 DOM 的 else 分支)
const unmountComponent: UnmountComponentFn = (instance, parentSuspense, doRemove) => {
const { bum, scope, effect, subTree, um, m, a } = instance
// 还没来得及执行的 mounted / activated 钩子作废
invalidateMount(m)
invalidateMount(a)
// 调用 beforeUnmount 钩子
if (bum) invokeArrayFns(bum)
// 停止 scope 里的所有 effect(watch、computed 等)
scope.stop()
// effect 可能为 null:异步 setup 还没 resolve 就被卸载了
if (effect) {
// 停止渲染 effect,调度器不会再调用它
effect.stop()
// 递归卸载子树
unmount(subTree, instance, parentSuspense, doRemove)
}
// 调用 unmounted 钩子(post 队列,在 DOM 移除后)
if (um) {
queuePostRenderEffect(um, undefined, parentSuspense)
}
// 标记为已卸载
queuePostRenderEffect(() => (instance.isUnmounted = true), undefined, parentSuspense)
}
scope.stop() 是一个优雅的清理机制。在 setup 中创建的所有 effect(包括 watch、watchEffect、computed)都被收集在组件的 effectScope 中,一次性停止,无需逐个管理。渲染 effect 本身在 3.6 里是用 setCurrentScope(instance.scope) 建出来的,scope.stop() 已经会把它一起停掉,紧接着的 effect.stop() 是显式再保险一次(3.4 里这一步是 update.active = false)。
本章小结
组件系统是 Vue 框架的中枢神经。在本章中,我们完整追踪了组件从创建到销毁的全生命周期:
- 数据结构:
ComponentInternalInstance是一个 70 多个字段的复合结构,承载了渲染、状态、通信、生命周期管理的全部职责 - 创建流程:
createComponentInstance→setupComponent→setupRenderEffect三步曲 - Props 系统:声明 → 解析 → 校验 → 响应式代理的四阶段流水线,编译器通过
dynamicProps优化更新检查 - 事件系统:emit 巧妙地搭了 props 系统的便车,事件监听器本质上是
onXxx形式的 props - Slots 系统:通过
SlotFlags在编译期标记稳定性,运行时据此决定是否跳过更新 - expose 机制:通过 Proxy 精确控制组件暴露给外部的 API 表面
思考题
-
设计思考:为什么 Vue 3 选择用一个大的
ComponentInternalInstance对象而不是多个小对象来表示组件状态?这种设计在内存布局和 GC 方面有什么优劣? -
Props 性能:如果一个组件有 50 个 props,但只有 2 个是动态的,编译器的
dynamicProps优化能带来多大的性能提升?用 Big-O 分析两种路径的复杂度。 -
Slots 稳定性:在什么情况下,编译器无法将 slot 标记为
STABLE?请给出三个具体的模板示例。 -
expose 安全:如果组件 A expose 了一个 ref,组件 B 通过模板 ref 获取后修改了这个 ref 的值,这个修改会反映到组件 A 内部吗?为什么?
-
生命周期:
beforeMount和mounted的执行时机有什么区别?在嵌套组件中,父子组件的 mounted 钩子执行顺序是怎样的?为什么?