Vue 3 设计与实现

第 10 章 组件系统

作者 杨艺韬 · 11,825 字

第 10 章 组件系统

本章要点

  • 组件实例的完整数据结构:从 ComponentInternalInstance 的 40+ 字段到它们各自的职责
  • 组件创建的全流程:从 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 源码解读》开篇讨论过同样的话题——React 把这一点作为”万物皆组件”的世界观推向极致;Vue 选择了更克制的立场(单文件组件但模板受限),结果是不同风格、同一抽象核心

组件做的第三件事是让协作变可组合。小组件组合成大组件、大组件组合成页面、页面组合成应用——这个自底向上的堆叠过程是大型前端应用能被多人团队共同维护的原因。没有组件边界,10 个人同时改一份代码只能互相踩脚。

本章如何组织

本章要讲清楚”组件的生命”——从模板里的 <MyComponent /> 开始,到组件真正挂载完毕、响应状态更新、最终卸载消失,整个过程中 Vue 做了什么。顺序是:

  1. 10.1 数据结构:组件实例是一个很大的对象,有 40+ 个字段。先熟悉这个对象,后面的函数讨论都围绕它展开。
  2. 10.2 创建流程:从 createComponentInstance 到 setupComponent 到 setupRenderEffect——组件从”一个 VNode”变成”一个活的 UI”的三步。
  3. 10.3-10.6 父子交互:Props 流入、Emit 流出、Slots 内容注入、expose 公开 API——这四件事构成了组件与外部的全部接口。
  4. 10.7 异步组件:基础之外的高级能力,Suspense 协作。

读的时候推荐时不时停下来,回顾一下”我写 Vue 组件时常做的那些操作,底层对应哪步”。这种”把经验和源码对上号”的过程最有价值。

丛书关联:本章和丛书卷《React 19 源码解读》第 4 章(Function Component 的 render cycle)可以对照读——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
export interface ComponentInternalInstance {
  uid: number                           // 全局唯一 ID
  type: ConcreteComponent               // 组件定义(选项对象或 setup 函数)
  parent: ComponentInternalInstance | null  // 父组件实例
  root: ComponentInternalInstance       // 根组件实例
  appContext: AppContext                 // 应用级上下文

  // ---- VNode 相关 ----
  vnode: VNode                          // 组件自身的 VNode
  subTree: VNode                        // 组件渲染输出的 VNode 子树
  next: VNode | null                    // 待更新的 VNode(父组件触发的更新)

  // ---- 渲染相关 ----
  render: InternalRenderFunction | null // 编译后的渲染函数
  proxy: ComponentPublicInstance | null // 模板中的 `this` 代理
  withProxy: ComponentPublicInstance | null // 带缓存的渲染代理

  // ---- 状态相关 ----
  setupState: Data                      // setup() 返回的状态
  props: Data                           // 解析后的 props
  attrs: Data                           // 非 prop 的 attributes(透传)
  slots: InternalSlots                  // 插槽
  refs: Data                            // 模板 ref 引用

  // ---- 副作用相关 ----
  effect: ReactiveEffect               // 组件的渲染 effect
  scope: EffectScope                    // 组件的 effect 作用域
  update: SchedulerJob                  // 组件更新函数

  // ---- 生命周期 ----
  isMounted: boolean
  isUnmounted: boolean
  isDeactivated: boolean

  // ---- 生命周期钩子 ----
  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
}

40 多个字段,每一个都有明确的职责。这个结构体就像一个生物细胞——外表是统一的组件接口,内部是精密协作的功能模块。

为什么需要这么多字段?

看 ComponentInternalInstance 第一反应都是”字段怎么这么多”。这不是过度设计,而是每一个字段对应一个真实职责——组件要完成的工作种类太多,每种工作都需要某个状态记录。

归纳一下这些字段的分类:

  1. 身份类(uid / type / root / parent / appContext)——“我是谁、我在哪”。uid 用于调试和 KeepAlive 的缓存键;parent/root 让组件能向上找祖先、向上 emit 事件。
  2. VNode 类(vnode / next / subTree)——“我对应的 VNode 和我渲染出来的 VNode”。vnode 是外层父组件给我的描述、subTree 是我自己渲染出来的树。
  3. 响应式状态类(props / data / setupState / ctx / attrs)——“我持有的所有响应式数据”。其中 attrs 存没声明的 prop、ctx 给 render 函数做 this。
  4. 生命周期类(isMounted / isUnmounted / bu / u / bm / m 等各种钩子数组)——“我处在生命的哪个阶段”。每个钩子是一个数组,同一钩子可以注册多次。
  5. 渲染类(render / update / effect / emitsOptions / inheritAttrs)——“我怎么渲染、谁触发我重跑”。
  6. 依赖注入类(provides / inject)——“向下提供什么、向上消费什么”。provides 是一个对象,inject 时沿 parent 链查找。

这 6 类加起来就是 40 多个字段。理解了分类之后再看源码,你会发现它们并不无序——每一类字段在生命周期的某个阶段集中被初始化、在另一阶段集中被使用。这种”字段很多但职责清晰”的设计在成熟框架里很常见——React 的 Fiber 节点同样 40+ 个字段,按 workInProgress / commit / cleanup 阶段分类。

你可能会疑问:一个组件真的需要这么多状态吗?答案是肯定的,因为组件身兼数职:

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 的三个阶段,执行顺序严格;任何一步出错都会中断挂载。下面逐一拆开。

从 VNode 到组件实例

一个容易让人卡住的心智模型:“VNode”和”组件实例”不是一回事

  • VNode:一个描述——“这里应该有一个 Foo 组件、传这些 props、这些 slots”。VNode 是纯数据,无生命,每次 render 都被重新生成。
  • 组件实例(ComponentInternalInstance):一个真实运行的组件——有 state、有 setup 产出、有 render effect、有 DOM 挂载关系。实例在挂载时创建、卸载时销毁、生命周期中持久存在。

从 VNode 到实例的转化,发生在 patch 函数碰到 shapeFlag & COMPONENT 时——Vue 不会复用旧 VNode 的实例(那是 VNode 的职责),而是通过 VNode.key + VNode.type 匹配到同一个实例做更新。这种”描述与运行解耦”的设计让你可以在 render 里大量生成 VNode 而不担心创建开销——VNode 本身是廉价对象,重的是实例生命周期。

当渲染器遇到一个组件类型的 VNode 时,会调用 mountComponent

// packages/runtime-core/src/renderer.ts
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)

  // 第三步:建立渲染 effect
  setupRenderEffect(
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    namespace,
    optimized
  )
}

三步曲,清晰明了。让我们逐一深入。

第一步:createComponentInstance

这一步是纯粹的对象初始化——分配一个 ComponentInternalInstance、把那 40+ 个字段设上合理默认值。真正要特别关注的只有几点:

  1. appContext 的继承:从父组件(parent.appContext)或 root 应用(app.context)继承。这让组件能访问到全局注册的组件、directive、provide/inject 等——不需要父子之间用 props 一路向下传,框架级别的东西通过 appContext 共享
  2. ctx 的初始化:这是给 render 函数用 this 访问的代理对象。Vue 2 时代这是 vm instance;Vue 3 保留了类似语义以兼容 Options API。
  3. provides 的原型链继承:子组件的 provides 用 Object.create(parent.provides)——这样 inject 查找时沿原型链上溯,自然得到”子覆盖父”的语义。这是响应式之外、Vue 另一个用 JS 原生能力(原型链)实现框架能力的巧妙案例。

这一步不跑 setup、不跑 render——只是”准备好一个空壳子”。后两步才填充内容。

// packages/runtime-core/src/component.ts
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 中设置
    scope: new EffectScope(true /* detached */),

    render: null,
    proxy: null,
    withProxy: null,

    provides: parent ? parent.provides : Object.create(appContext.provides),

    // 状态
    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,
  }

  // 设置 root 引用
  instance.root = parent ? parent.root : instance

  // 创建 emit 函数
  instance.emit = emit.bind(null, instance)

  return instance
}

注意 provides 的初始化策略:Object.create(parent.provides)。通过原型链继承,子组件可以访问所有祖先组件提供的值,同时自己 provide 的值只影响后代。

第二步:setupComponent

setupComponent 是所有”组件变成活的”的魔法发生地。它做三件事:

  1. initProps + initSlots:把外部传入的 props 和 slots 放到 instance 上。
  2. 执行 setup()(如果有)或 处理 Options API:拿到组件内部的 state、方法、计算属性。
  3. finishComponentSetup:确保 render 函数存在(模板还没编译的在这里编译),把 instance.render 准备好。

这三件事做完,组件就从”只有外部数据”变成了”外部数据 + 内部状态 + 渲染函数”——一个完整的能自我渲染的单元。下一步的 setupRenderEffect 才能基于这些信息真正跑起来。

// packages/runtime-core/src/component.ts
export function setupComponent(
  instance: ComponentInternalInstance,
  isSSR = false
): Promise<void> | void {
  const { props, children } = instance.vnode

  // 1. 初始化 props
  initProps(instance, props, isStatefulComponent(instance), isSSR)

  // 2. 初始化 slots
  initSlots(instance, children)

  // 3. 如果是有状态组件,执行 setup
  const setupResult = isStatefulComponent(instance)
    ? setupStatefulComponent(instance, isSSR)
    : undefined

  return setupResult
}

setupStatefulComponent:执行 setup 函数

这是全 Vue 运行时最核心的一段代码。所有 Composition API 的”魔法”都发生在这几行里——refreactiveonMountedwatch 能正常工作,是因为它们调用时能感知到”当前正在设置哪个组件”。这个”感知”就是通过 currentInstance 这个全局变量实现的:在调用 setup 前 setCurrentInstance、setup 跑完 unsetCurrentInstance——中间的任何 API 调用都能通过这个全局引用拿到”我的宿主组件是谁”。

这种”瞬态全局状态”的模式其实是 Hooks 时代前端框架的共同发明。React 的 useState / useEffect 能正常工作,底层靠的是 ReactCurrentDispatcher.current——和 Vue 的 currentInstance 几乎是一一对应的。两个框架独立进化到同一个解决方案——这不是巧合,而是**“无魔法的组件化”这个问题只有这一条最干净的解法**。

function setupStatefulComponent(
  instance: ComponentInternalInstance,
  isSSR: boolean
) {
  const Component = instance.type as ComponentOptions

  // 创建渲染代理的缓存
  instance.accessCache = Object.create(null)

  // 创建公共实例代理
  // 这个代理就是模板中的 `this` 和 setup 中不应该直接使用的上下文
  instance.proxy = markRaw(
    new Proxy(instance.ctx, PublicInstanceProxyHandlers)
  )

  // 执行 setup
  const { setup } = Component
  if (setup) {
    // 如果 setup 接受参数,创建 setupContext
    const setupContext = (instance.setupContext =
      setup.length > 1 ? createSetupContext(instance) : null)

    // 设置当前实例(让 onMounted 等 API 知道它们属于哪个组件)
    setCurrentInstance(instance)
    pauseTracking()

    // 执行 setup 函数
    const setupResult = callWithErrorHandling(
      setup,
      instance,
      ErrorCodes.SETUP_FUNCTION,
      [
        __DEV__ ? shallowReadonly(instance.props) : instance.props,
        setupContext
      ]
    )

    resetTracking()
    unsetCurrentInstance()

    // 处理 setup 的返回值
    if (isPromise(setupResult)) {
      // 异步 setup——交给 Suspense 处理
      setupResult.then(
        result => handleSetupResult(instance, result, isSSR),
        err => handleError(err, instance, ErrorCodes.SETUP_FUNCTION)
      )
      return setupResult
    } else {
      handleSetupResult(instance, setupResult, isSSR)
    }
  } else {
    // 没有 setup,使用选项式 API
    finishComponentSetup(instance, isSSR)
  }
}

这里有几个关键细节:

  1. pauseTracking():在执行 setup 期间暂停响应式追踪,防止 setup 函数本身被当作一个 effect 追踪
  2. setCurrentInstance():设置全局的”当前组件实例”,让 onMounted() 等 Composition API 知道自己被注册到哪个组件
  3. setup 的 props 参数:在开发模式下是 shallowReadonly,防止用户意外修改 props

handleSetupResult:处理 setup 的返回值

setup 函数的返回值有三种合法形态:一个对象(最常见)、一个函数(当 render 函数)、或 undefined。handleSetupResult 根据返回值类型分支处理。

最有意思的是”返回函数”这个分支——你可以在 setup 里直接返回一个 render 函数,绕过模板系统。这对于需要动态生成复杂 render 逻辑的场景(比如递归树组件、根据数据结构动态决定 DOM 结构)非常有用。React 的函数组件从第一天就是这种模式;Vue 提供这条路作为备选,既保持模板语法的友好性,又不挡住需要底层控制的开发者。

这种”API 给你默认的路,但也让你能绕过默认”的做法是 Vue 从 v2 一路走来的哲学——不强迫 opinion,但也不是完全无 opinion。丛书卷《Vue 3 设计与实现》通篇都在讨论这种”设计空间里的平衡点”——Vue 的每一个 API 决策几乎都在”好用”和”可扩展”之间权衡。

export function handleSetupResult(
  instance: ComponentInternalInstance,
  setupResult: unknown,
  isSSR: boolean
) {
  if (isFunction(setupResult)) {
    // setup 返回了渲染函数
    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 反过来要求开发者手写依赖数组——两条路的哲学差异贯穿很多细节。丛书卷《Vue 3 设计与实现》第 5 章讲 track/trigger 的那一章是这块的前置阅读。

// packages/runtime-core/src/renderer.ts
const setupRenderEffect = (
  instance: ComponentInternalInstance,
  initialVNode: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  optimized: boolean
) => {
  const componentUpdateFn = () => {
    if (!instance.isMounted) {
      // ---- 首次渲染 ----
      const { bm, m } = instance

      // 调用 beforeMount 钩子
      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, 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 钩子
      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, parentSuspense)
    }
  }

  // 创建响应式 effect
  const effect = (instance.effect = new ReactiveEffect(
    componentUpdateFn,
    NOOP,
    () => queueJob(update),  // scheduler:将更新放入队列
    instance.scope
  ))

  const update: SchedulerJob = (instance.update = () => {
    if (effect.dirty) {
      effect.run()
    }
  })
  update.id = instance.uid

  // 首次执行
  update()
}

这是整个组件系统最关键的函数。它创建了一个 ReactiveEffect,将组件的渲染函数包裹其中。当渲染函数中访问的响应式数据发生变化时,effect 的 scheduler 会将更新任务放入调度队列,在下一个微任务中执行。

10.3 Props 系统深度剖析

Props 是组件对外最重要的接口——父给子传数据、子不能修改、单向数据流。这个契约说起来简单,实现起来细节超多。

一个 :msg="hello" 背后会经过四个阶段声明(组件 options 里的 props 定义)、解析(从 VNode 上把 raw props 取出来)、校验(类型、required、default、validator 检查)、响应式代理(包成 shallowReactive 供 render 读)。下面每一节覆盖一个阶段。读的时候有个心智框架:Vue 的 Props 系统和 TypeScript 的类型系统解决同一个问题在不同时机——TS 管编译时、Props 管运行时;两者在生产级代码里是互补而非替代

顺便一提:Vue 3.3 引入的 defineProps<{...}>() 泛型用法正是”在编译时 + 运行时同时工作”的 sugar——TS 类型通过 Vue 编译器转成运行时 props 声明,一份代码同时满足两个系统。这种编译器承担”桥梁”角色的思路在现代框架里越来越常见,丛书卷《Vite 设计与实现》讲 SFC 插件时深入讲过。

Props 的四阶段处理

四阶段听起来复杂,但每一阶段都只做一件事:

  1. 声明阶段(编译时 + 首次挂载):Vue 从组件的 options 或 <script setup>defineProps 里读出”我接受哪些 prop、各自的类型”。这个信息在组件生命周期内不变。
  2. 解析阶段(每次挂载或更新):从外层 VNode 的 props 字段里把数据取出来。
  3. 校验阶段(开发模式):对照声明检查类型、required、default 值、自定义 validator。生产模式这一步被完全跳过——检查只是开发时的辅助、不承担运行时语义
  4. 代理阶段:把校验过的 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["类型检查 + default"]
    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> 组件时,你不需要一个个声明 disabledidclassaria-* 这些原生属性——全部进入 attrs 自动透传。这把组件作者从”声明爆炸”里解放出来。

// packages/runtime-core/src/componentProps.ts
export function initProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  isStateful: boolean,
  isSSR = false
) {
  const props: Data = {}
  const attrs: Data = {}
  def(attrs, InternalObjectKey, 1)  // 标记为内部对象

  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)
  }

  if (isStateful) {
    // 有状态组件:用 shallowReactive 包裹
    instance.props = isSSR ? props : shallowReactive(props)
  } else {
    // 函数式组件
    if (!instance.type.props) {
      instance.props = attrs
    } else {
      instance.props = props
    }
  }

  instance.attrs = attrs
}

setFullProps:Props 与 Attrs 的分拣

分拣逻辑的核心:遍历 raw props 的每一个 key,检查是否在 propsOptions(组件声明的 props 定义)里——在就放 props、不在就放 attrs。

细节上有几个值得说的点:key 的归一化——父组件传 my-propmyProp 都会归到同一个 key;事件监听器的特殊处理——onClick / onUpdate:xxx 这样的 key,如果组件声明了对应 emits 则匹配、否则按 attrs 处理(fallthrough 后自动绑到根元素);modifiers 的处理——v-model 的修饰符(.trim、.lazy)通过专门字段传递。

读到这段代码时,你会理解为什么 Vue 的模板里不管父子组件都能写同一套 @event 语法——因为在 setFullProps 里,事件和普通 prop 走的是几乎一样的分拣路径。这种”统一抽象、分支处理”的代码读起来很紧凑,但需要你对上下文有完整理解——建议配合断点调试来看。

function setFullProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  props: Data,
  attrs: Data
) {
  const [options, needCastKeys] = instance.propsOptions

  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
        }
      }
    }
  }

  // 处理 Boolean cast 和 default 值
  if (needCastKeys) {
    for (const key of needCastKeys) {
      let opt = options![key]
      props[key] = resolvePropValue(
        opt,
        rawCastValues && rawCastValues[key],
        key,
        instance
      )
    }
  }
}

这里的分拣逻辑是 Props 系统的核心:传入的每个属性要么是声明过的 prop,要么是事件监听器(onXxx),要么是透传的 attr。三者互不交叉。

Props 更新

Props 的初次初始化发生在 mount 阶段,但组件存活期间父组件还会反复传新 props 进来——每次父 render 都会生成新的 VNode,其中的 props 可能变。Vue 需要把”新 props”合并到子组件的 instance.props 上。

这里有个非常微妙的设计:Vue 不是替换整个 props 对象,而是原地修改 instance.props 里的字段。为什么?因为 instance.props 是 shallowReactive 代理,替换整个对象会让所有订阅它的 effect 都失效(需要重新订阅);而原地修改某个字段,只有订阅了那个字段的 effect 被 trigger,其他 effect 保持原位。

这种”保持响应式身份稳定”的思路在 Vue 的各个角落都能看到——currentRoute 用 shallowRef 也是同一哲学。理解这种思路后,你会对”什么时候替换、什么时候原地改”这种看似微小的决策产生判断力——替换是快但破坏订阅;原地改是稳但代码啰嗦;两者的选择取决于响应式身份稳定性是否比代码简洁更重要

当父组件重新渲染时,子组件的 props 可能发生变化:

// packages/runtime-core/src/componentProps.ts
export function updateProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  rawPrevProps: Data | null,
  optimized: boolean
) {
  const { props, attrs } = instance
  const oldAttrs = { ...attrs }

  if (optimized) {
    // 编译器优化路径:只检查动态 props
    const dynamicProps = instance.vnode.dynamicProps!
    for (let i = 0; i < dynamicProps.length; i++) {
      const key = dynamicProps[i]
      const value = rawProps![key]
      if (options) {
        if (hasOwn(attrs, key)) {
          if (value !== attrs[key]) {
            attrs[key] = value
          }
        } else {
          const camelizedKey = camelize(key)
          props[camelizedKey] = resolvePropValue(
            options[camelizedKey],
            value,
            camelizedKey,
            instance
          )
        }
      }
    }
  } else {
    // 全量对比
    setFullProps(instance, rawProps, props, attrs)
    // 清理多余的 attrs
    for (const key in attrs) {
      if (!rawProps || !hasOwn(rawProps, key)) {
        delete attrs[key]
      }
    }
  }
}

optimized 路径是编译器协作的典范:编译器在编译阶段就知道哪些 props 是动态的(通过 : 绑定),将它们记录在 dynamicProps 数组中。更新时只需检查这些动态 props,跳过静态的。

10.4 Emit 事件系统

和 Props 对称,Emit 是组件向外传递事件的机制——子组件 emit('update', value)、父组件 @update="handler" 接到。

Emit 的实现很巧——父组件的事件监听器其实是通过 props 形式传进来的:父模板里写 @update="handler" 等价于 :onUpdate="handler"——Vue 会把它放到子组件的 props 对象里,key 加 on 前缀、首字母大写。子组件调 emit(‘update’) 时,内部就是去 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 校验源)
  • 为什么 emit 的返回值是 any,其实很多时候是调用结果的数组?(Vue 3.5+ 开始支持,每个匹配的 listener 的返回值汇总)

这些”奇奇怪怪的行为”在看完实现后都变成”理所当然的结果”——这就是读源码的反直觉收获:理解了实现之后 API 看起来更合理、更少惊讶

// packages/runtime-core/src/componentEmits.ts
export function emit(
  instance: ComponentInternalInstance,
  event: string,
  ...rawArgs: any[]
) {
  // 已卸载的组件不发射事件
  if (instance.isUnmounted) return

  const props = instance.vnode.props || EMPTY_OBJ

  // 开发模式下的校验
  if (__DEV__) {
    const { emitsOptions, propsOptions: [propsOptions] } = instance
    if (emitsOptions) {
      if (!(event in emitsOptions)) {
        // 警告:发射了未声明的事件
        if (!propsOptions || !(toHandlerKey(event) in propsOptions)) {
          warn(`Component emitted event "${event}" but it is not declared`)
        }
      } else {
        // 如果有验证函数,执行验证
        const validator = emitsOptions[event]
        if (isFunction(validator)) {
          const isValid = validator(...rawArgs)
          if (!isValid) {
            warn(`Invalid event arguments for "${event}"`)
          }
        }
      }
    }
  }

  let args = rawArgs

  // 处理 v-model 的 update:xxx 事件
  const isModelListener = event.startsWith('update:')

  // 查找事件处理器
  // event: 'click' → handler key: 'onClick'
  // event: 'update:modelValue' → handler key: 'onUpdate:modelValue'
  let handlerName = toHandlerKey(event)
  let handler = props[handlerName]

  // 如果没找到,尝试 kebab-case
  if (!handler) {
    handler = props[handlerName = toHandlerKey(hyphenate(event))]
  }

  // 如果还没找到,尝试 camelCase
  if (!handler) {
    handler = props[toHandlerKey(camelize(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
    )
  }
}

emit 的实现看似简单,但隐藏了一个优雅的设计:事件监听器其实是 props 的一部分。当你在模板中写 @click="handler" 时,编译器将其转换为 { onClick: handler } 作为 VNode 的 props。这意味着事件系统不需要独立的注册/注销机制——它搭了 props 系统的便车。

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> 分三种情况:默认 slot、命名 slot、作用域 slot。三种情况在运行时数据结构上完全统一(都是函数),差别只在生成函数的方式。这种”编译时分情况讨论、运行时单一抽象”的设计让 slots 的 runtime 代码保持极短,也让开发者不用心智切换(用起来就是一个 <slot> 标签、一个 template 块)。

// 1. 静态 slots(编译期确定的内容)
// <Child><span>hello</span></Child>
// 编译为:
createVNode(Child, null, {
  default: () => [createVNode('span', null, 'hello')],
  _: SlotFlags.STABLE  // 标记为稳定 slots
})

// 2. 动态 slots(内容依赖响应式状态)
// <Child><span>{{ msg }}</span></Child>
// 编译为:
createVNode(Child, null, {
  default: () => [createVNode('span', null, ctx.msg)],
  _: SlotFlags.DYNAMIC  // 标记为动态 slots
})

// 3. 作用域 slots
// <Child v-slot="{ item }"><span>{{ item.name }}</span></Child>
// 编译为:
createVNode(Child, null, {
  default: ({ item }) => [createVNode('span', null, item.name)],
  _: SlotFlags.DYNAMIC
})

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 里的,但函数的参数可以是子 emit 出来的。这就是作用域 slot 的底层——函数调用的经典闭包能力,被用来优雅地跨越两个组件的作用域边界。

// packages/runtime-core/src/componentSlots.ts
export function initSlots(
  instance: ComponentInternalInstance,
  children: VNodeNormalizedChildren
) {
  if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
    const type = (children as RawSlots)._
    if (type) {
      // 编译优化的 slots
      instance.slots = toRaw(children as InternalSlots)
      // 标记为不可枚举,避免在 attrs 中出现
      def(children as InternalSlots, '_', type, true)
    } else {
      // 手写渲染函数的 slots
      normalizeObjectSlots(
        children as RawSlots,
        (instance.slots = {}),
        instance
      )
    }
  } else if (children) {
    // 只有默认 slot(纯文本或 VNode 数组)
    normalizeVNodeSlots(instance, children)
  }
}

Slots 的响应式更新

Slots 更新和 Props 更新遵循同样的”原地修改而非整体替换”原则——父组件 render 生成新的 slot 函数,子组件 updateSlots 时只替换 slots 对象里的函数引用。

但这里有个 Slots 独有的复杂度:slot 的内容在父 render 阶段已经闭包捕获了父的响应式数据——也就是说 slot 函数访问的变量会订阅父的响应式系统。子组件调用 slot 函数时拿到的 VNode,背后的数据订阅是父的。这意味着父的数据变化会导致 slot 重新生成 VNode 从而导致子组件的 diff——这是”父改了、子 DOM 变了”现象的底层成因。

这种跨作用域的响应式是 Vue 的 slot 系统最让人又爱又困惑的特性之一。爱的是”用起来天然、父的数据直接在子的 DOM 上反映”;困惑的是”为什么子明明没变、它里面的 slot 内容却重渲染了”——答案就是父的数据变了、slot 函数返回了新 VNode、子的 diff 看到不同于是更新。

Slots 的更新是组件系统中最微妙的部分之一。关键问题是:当父组件更新时,子组件的 slots 是否需要重新渲染?

// packages/runtime-core/src/componentSlots.ts
export function updateSlots(
  instance: ComponentInternalInstance,
  children: VNodeNormalizedChildren,
  optimized: boolean
) {
  const { slots } = instance

  if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
    const type = (children as RawSlots)._
    if (type === SlotFlags.STABLE) {
      // 稳定 slots:无需更新
      // 这是一个关键优化——如果 slot 内容是静态的,跳过更新
      return
    }

    // 动态 slots 或手写 slots:需要更新
    for (const key in children as RawSlots) {
      if (key === '_') continue
      ;(slots as any)[key] = (children as RawSlots)[key]
    }

    // 清理多余的 slot
    for (const key in slots) {
      if (!(key in (children as RawSlots))) {
        delete slots[key]
      }
    }
  }
}

SlotFlags.STABLE 优化意味着:如果编译器能在编译期确认 slot 内容不包含任何动态绑定,就标记为 STABLE,运行时直接跳过更新。

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,拦截访问、按 expose 配置决定透传哪些字段。

这种”用 Proxy 控制公共 API 表面”的技巧不只 Vue 在用。Node.js 的 module.exports、Deno 的 permission system、浏览器 WebView 的 postMessage 边界——都是用某种 proxy/沙箱机制把”内部可能很大但对外只暴露必要的”。这是软件工程里的基本型——信息隐藏是好架构的必然要求,而在动态语言(JavaScript / Python)里,Proxy 是最自然的实现工具。

expose 允许组件作者明确控制哪些属性和方法可以通过模板 ref 被外部访问:

// packages/runtime-core/src/component.ts
function createSetupContext(instance: ComponentInternalInstance) {
  const expose: SetupContext['expose'] = (exposed) => {
    if (__DEV__ && instance.exposed) {
      warn('expose() should be called only once per setup().')
    }
    instance.exposed = exposed || {}
  }

  return {
    attrs: instance.attrs,
    slots: instance.slots,
    emit: instance.emit,
    expose
  }
}

expose 代理的实现

Proxy 的 get 拦截器里做两件事:首先检查访问的 key 是否在 expose 白名单里——在就 return 对应值,不在就 return undefined(或 warning);其次把返回值用 unref 解包——因为内部写的是 ref(count),外部访问时期望直接拿到 value 而不是 ref 对象。

第二条”自动 unref”是 Vue 的一贯特性——template 里访问 ref 自动解包、reactive 对象属性访问 ref 自动解包、现在 expose 也一样。这种一致的 unref 语义让”你不需要在每个地方都区分是 ref 还是值”成为可能。这是 Vue 响应式 DX(开发体验)的关键设计——丛书卷《Vue 3 设计与实现》第 6 章有专门讨论。

// packages/runtime-core/src/component.ts
export function getExposeProxy(instance: ComponentInternalInstance) {
  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 if (key in publicPropertiesMap) {
              return publicPropertiesMap[key](instance)
            }
          },
          has(target, key: string) {
            return key in target || key in publicPropertiesMap
          }
        }
      ))
    )
  }
}

当外部通过模板 ref 访问组件时,如果组件使用了 expose,得到的是 exposeProxy 而非完整的组件实例。这是一个重要的封装机制——组件内部状态不会泄露。

10.7 异步组件

异步组件解决的问题看起来简单:“我这个组件的代码不想和首屏一起下载,访问到它的时候再拉”。但”再拉”这个过程中要处理的边界情况超多——加载期间显示什么、加载失败怎么办、快速加载完要不要避免 loading 闪烁、多久算超时、多次访问要不要复用之前拉过的——每一个都值得专门设计。

本节讲 Vue 的异步组件 API 怎么把这些问题抽象成一个配置对象,让开发者”声明一下”就能得到生产级的异步加载体验。作为开胃菜,先思考一个问题:“异步组件”和”异步加载某个资源”有本质区别吗?有——组件是一棵树的局部,加载过程发生时它必须占位但不能占页面布局。这就把异步组件和”懒加载图片”分开了——图片可以先占位再加载完改 src,组件不行。下面的实现就是围绕这个约束展开的。

defineAsyncComponent 的实现

// packages/runtime-core/src/apiAsyncComponent.ts
export function defineAsyncComponent(
  source: AsyncComponentLoader | AsyncComponentOptions
): Component {
  if (isFunction(source)) {
    source = { loader: source }
  }

  const {
    loader,
    loadingComponent,
    errorComponent,
    delay = 200,
    timeout,
    suspensible = true,
    onError: userOnError
  } = source

  let pendingRequest: Promise<ConcreteComponent> | null = null
  let resolvedComp: ConcreteComponent | undefined

  // 重试计数
  let retries = 0
  const retry = () => {
    retries++
    pendingRequest = null
    return load()
  }

  const load = (): Promise<ConcreteComponent> => {
    let thisRequest: Promise<ConcreteComponent>

    return (
      pendingRequest ||
      (thisRequest = pendingRequest = loader()
        .catch(err => {
          err = err instanceof Error ? err : new Error(String(err))
          if (userOnError) {
            // 用户自定义错误处理
            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 defineComponent({
    name: 'AsyncComponentWrapper',
    __asyncLoader: load,

    setup() {
      const instance = currentInstance!

      // 如果已经解析过,直接渲染
      if (resolvedComp) {
        return () => createInnerComp(resolvedComp!, instance)
      }

      const loaded = ref(false)
      const error = ref<Error>()
      const delayed = ref(!!delay)

      if (delay) {
        setTimeout(() => { delayed.value = false }, delay)
      }

      if (timeout != null) {
        setTimeout(() => {
          if (!loaded.value && !error.value) {
            const err = new Error(`Async component timed out after ${timeout}ms.`)
            error.value = err
          }
        }, timeout)
      }

      load()
        .then(() => { loaded.value = true })
        .catch(err => { error.value = err })

      return () => {
        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 createVNode(loadingComponent)
        }
      }
    }
  })
}

异步组件本质上是一个高阶组件模式:它返回一个同步的包装组件,内部管理加载状态的切换。

10.8 组件的公共实例代理

PublicInstanceProxyHandlers

当你在模板中或选项式 API 中访问 this.xxx 时,实际上是在访问组件的公共实例代理:

// packages/runtime-core/src/componentPublicInstance.ts
export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
  get({ _: instance }: ComponentRenderContext, key: string) {
    const { ctx, setupState, data, props, accessCache, type, appContext } =
      instance

    // 缓存查找路径,避免重复判断
    if (key !== '$') {
      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 (
        (normalizedProps = instance.propsOptions[0]) &&
        hasOwn(normalizedProps, 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 = publicPropertiesMap[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 组件更新与卸载

组件更新的触发

组件更新有两种触发方式:

// 1. 自身状态变化(子组件触发)
// 当 setup 中的 ref 变化时,通过 renderEffect 的 scheduler 触发
const update = () => {
  if (effect.dirty) {
    effect.run()  // 执行 componentUpdateFn
  }
}

// 2. 父组件传入新 props(父组件触发)
// 在 patch 阶段,如果子组件的 VNode props 变化
const updateComponent = (n1: VNode, n2: VNode) => {
  const instance = (n2.component = n1.component)!
  if (shouldUpdateComponent(n1, n2, optimized)) {
    instance.next = n2
    invalidateJob(instance.update)  // 取消已排队的更新
    instance.update()               // 立即触发更新
  } else {
    n2.el = n1.el
    instance.vnode = n2
  }
}

shouldUpdateComponent:是否需要更新

export function shouldUpdateComponent(
  prevVNode: VNode,
  nextVNode: VNode,
  optimized?: boolean
): boolean {
  const { props: prevProps, children: prevChildren } = prevVNode
  const { props: nextProps, children: nextChildren, patchFlag } = nextVNode

  // 有动态 slots 的组件总是需要更新
  if (prevChildren || nextChildren) {
    if (!nextChildren || !(nextChildren as any).$stable) {
      return true
    }
  }

  if (optimized && patchFlag >= 0) {
    if (patchFlag & PatchFlags.DYNAMIC_SLOTS) {
      return true
    }
    if (patchFlag & PatchFlags.FULL_PROPS) {
      return hasPropsChanged(prevProps, nextProps!)
    } else if (patchFlag & PatchFlags.PROPS) {
      const dynamicProps = nextVNode.dynamicProps!
      for (let i = 0; i < dynamicProps.length; i++) {
        const key = dynamicProps[i]
        if (nextProps![key] !== prevProps![key]) {
          return true
        }
      }
    }
  }

  return false
}

组件卸载

const unmountComponent = (
  instance: ComponentInternalInstance,
  parentSuspense: SuspenseBoundary | null
) => {
  const { bum, scope, update, subTree, um } = instance

  // 调用 beforeUnmount 钩子
  if (bum) invokeArrayFns(bum)

  // 停止所有 effect(包括 watch、computed、渲染 effect)
  scope.stop()

  // 取消排队的更新
  if (update) {
    update.active = false
    // 递归卸载子树
    unmount(subTree, instance, parentSuspense)
  }

  // 调用 unmounted 钩子(异步,在 DOM 移除后)
  if (um) {
    queuePostRenderEffect(um, parentSuspense)
  }

  // 标记为已卸载
  queuePostRenderEffect(() => {
    instance.isUnmounted = true
  }, parentSuspense)
}

scope.stop() 是一个优雅的清理机制。在 setup 中创建的所有 effect(包括 watchwatchEffectcomputed)都被收集在组件的 effectScope 中,一次性停止,无需逐个管理。


本章小结

组件系统是 Vue 框架的中枢神经。在本章中,我们完整追踪了组件从创建到销毁的全生命周期:

  1. 数据结构ComponentInternalInstance 是一个 40+ 字段的复合结构,承载了渲染、状态、通信、生命周期管理的全部职责
  2. 创建流程createComponentInstancesetupComponentsetupRenderEffect 三步曲
  3. Props 系统:声明 → 解析 → 校验 → 响应式代理的四阶段流水线,编译器通过 dynamicProps 优化更新检查
  4. 事件系统:emit 巧妙地搭了 props 系统的便车,事件监听器本质上是 onXxx 形式的 props
  5. Slots 系统:通过 SlotFlags 在编译期标记稳定性,运行时据此决定是否跳过更新
  6. expose 机制:通过 Proxy 精确控制组件暴露给外部的 API 表面

思考题

  1. 设计思考:为什么 Vue 3 选择用一个大的 ComponentInternalInstance 对象而不是多个小对象来表示组件状态?这种设计在内存布局和 GC 方面有什么优劣?

  2. Props 性能:如果一个组件有 50 个 props,但只有 2 个是动态的,编译器的 dynamicProps 优化能带来多大的性能提升?用 Big-O 分析两种路径的复杂度。

  3. Slots 稳定性:在什么情况下,编译器无法将 slot 标记为 STABLE?请给出三个具体的模板示例。

  4. expose 安全:如果组件 A expose 了一个 ref,组件 B 通过模板 ref 获取后修改了这个 ref 的值,这个修改会反映到组件 A 内部吗?为什么?

  5. 生命周期beforeMountmounted 的执行时机有什么区别?在嵌套组件中,父子组件的 mounted 钩子执行顺序是怎样的?为什么?