obsidian-computer
前端识记mini-vue-reading

04-runtime-组件系统

第 4 章 · runtime-core(二):组件系统——instance、props、emit、slots、provide/inject

最后更新 2026年8月29日

第 4 章 · runtime-core(二):组件系统——instance、props、emit、slots、provide/inject

第 3 章在 mountComponent 里一笔带过了 createComponentInstance 和 setupComponent。本章把它们完全展开。 组件系统的阅读心法:组件的一切"能力"(this 上五花八门的东西、props、事件、插槽、注入),本质都是往 instance 这个大对象上初始化字段,再通过一层代理暴露出去。 逐个拆。


4.1 createComponentInstance + setupComponent:组件的出生流程

// component.ts
export function createComponentInstance(vnode, parent) {
  const instance = {
    vnode,                    // 自己对应的组件 vnode
    type: vnode.type,         // 用户写的组件选项 { setup, render }
    setupState: {},           // setup 返回的对象
    props: {},                // 声明并传入的属性
    slots: {},                // 插槽函数表
    provides: parent ? parent.provides : {},   // ★ 4.5 详解这行
    parent,                   // 父组件实例(组件树是靠它串起来的)
    subTree: null,            // 上一次 render 的产物(第 3/5 章用)
    isMounted: false,
    emit: () => {},
  }
  instance.emit = emit.bind(null, instance)    // ★ 4.3 详解
  return instance
}

export function setupComponent(instance) {
  initProps(instance, instance.vnode.props)    // 4.2
  initSlots(instance, instance.vnode.children) // 4.4
  setupStatefulComponent(instance)
}

function setupStatefulComponent(instance) {
  const Component = instance.type

  // ① this 的公共代理(4.6 详解)
  instance.proxy = new Proxy({ _: instance }, PublicInstanceProxyHandlers)

  const { setup } = Component
  if (setup) {
    setCurrentInstance(instance)               // ② getCurrentInstance 的实现
    const setupResult = setup(
      shallowReadonly(instance.props),         // ③ props 只读!
      { emit: instance.emit },                 //    setup 的第二个参数
    )
    setCurrentInstance(null)
    handleSetupResult(instance, setupResult)
  } else {
    finishComponentSetup(instance)
  }
}

function handleSetupResult(instance, setupResult) {
  // setup 也可以直接返回 render 函数,mini-vue 只处理返回对象的情况
  if (typeof setupResult === 'object') {
    instance.setupState = proxyRefs(setupResult)   // ④ 第 2 章的伏笔兑现
  }
  finishComponentSetup(instance)
}

function finishComponentSetup(instance) {
  instance.render = instance.type.render     // 有 compiler 时,这里会编译 template
}

逐段解释

instance 就是一个"大 context 对象"。 没有类、没有继承,组件实例只是一个字段齐全的普通对象,被从 mountComponent 一路往下传。大型代码库里"贯穿始终的 context 对象"是最值得先摸清的东西——把 instance 的字段表抄在纸上,后面每读一个功能就问"它动了 instance 的哪个字段",全章脉络自然浮现。

② setCurrentInstance:又是"全局变量 + 时间窗口"。 getCurrentInstance() 让 setup 里能拿到当前实例(组合式函数、provide/inject 都靠它)。实现和第 1 章 activeEffect 一模一样:setup 执行前把实例挂到模块级变量,执行完置回 null。为什么 getCurrentInstance 只能在 setup 里调用?因为窗口只在那几行代码间开着。 同一个模式第二次出现,你应该已经能秒认了。

(细节:源码里赋值收敛在 setCurrentInstance 函数里而不是直接 currentInstance = xxx——统一入口方便打断点、将来加逻辑。作者原话叫"给赋值留个抓手"。)

③ props 传进 setup 前包了 shallowReadonly:单向数据流不是文档约定,是 readonly 代理强制的——子组件改 props 直接吃警告。为什么是 shallow 而不是深 readonly?性能考量加上"深层对象一般是引用传递共享的",mini-vue 遵循 Vue 3 的选择。

④ setupState 被 proxyRefs 包了:第 2 章写的 proxyRefs 在这行接上——所以 render/template 里访问 this.msg 不用 .value。


4.2 initProps + 公共代理:this.xxx 到底怎么找到值

initProps 在 mini-vue 里朴素到只有一行(真源码里这里有声明校验、默认值、驼峰转换等一大坨):

export function initProps(instance, rawProps) {
  instance.props = rawProps ?? {}
}

真正有内容的是公共实例代理——回答"this.msg(setup 返回的)、this.count(props 里的)、this.$el、this.$slots 为什么都能从同一个 this 上拿到":

// componentPublicInstance.ts
const publicPropertiesMap = {
  $el:    i => i.vnode.el,
  $slots: i => i.slots,
  $props: i => i.props,
}

export const PublicInstanceProxyHandlers = {
  get({ _: instance }, key) {
    const { setupState, props } = instance
    // 按优先级逐层查找:
    if (hasOwn(setupState, key)) return setupState[key]   // ① setup 返回值
    if (hasOwn(props, key))      return props[key]         // ② props
    const publicGetter = publicPropertiesMap[key]
    if (publicGetter) return publicGetter(instance)        // ③ $ 开头的公共属性
  },
}

三个点:

  • this 是假的。render 通过 render.call(proxy) 执行,this 根本不是实例本身,而是这个代理。所有"魔法感"来自 get 里这张优先级查找表。第 2 章的结论第三次应验:每个魔法对应一层代理;
  • publicPropertiesMap 用查表代替 if-else 链:新增 $data、$options 只需加一行表项。源码里"策略表 + 统一分发"是扩展点的标准形态,看到这种表就知道将来的扩展会往哪儿加;
  • 代理的 target 是 { _: instance } 而不是 instance 自己——handler 的 get 签名里用解构 { _: instance } 直接取出,纯粹是传递 instance 的一个惯用手法。

4.3 emit:所谓"事件",就是"调用父组件塞给你的回调"

失败用例

// 子组件
const Foo = {
  setup(props, { emit }) {
    const handleClick = () => emit('add-foo', 1, 2)
    return { handleClick }
  },
  render() { return h('button', { onClick: this.handleClick }, 'emit') },
}
// 父组件
const App = {
  render() {
    return h(Foo, { onAddFoo: (a, b) => console.log('onAddFoo', a, b) })
  },
}

关键观察:父组件监听事件的方式是传一个名为 onAddFoo 的 prop。(模板里的 @add-foo 编译后就是这个。)那么 emit 的实现呼之欲出——去 props 里按命名约定找函数,找到就调:

// componentEmit.ts
export function emit(instance, event, ...args) {
  const { props } = instance

  // add-foo → addFoo → AddFoo → onAddFoo
  const handlerName = toHandlerKey(camelize(event))
  const handler = props[handlerName]
  handler?.(...args)
}

// shared/index.ts
const camelize = str => str.replace(/-(\w)/g, (_, c) => c.toUpperCase())
const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1)
const toHandlerKey = str => (str ? 'on' + capitalize(str) : '')

逐段解释

事件系统被祛魅成 8 行:没有事件总线、没有发布订阅,父传回调、子查表调用,仅此而已。"组件通信"的两条腿——props 向下、emit 向上——本质是同一条腿:都是 props,一个传数据一个传函数。

emit.bind(null, instance) 那行(4.1 里):用户侧 API 是 emit('add-foo'),不带 instance;实现侧需要 instance 才能摸到 props。bind 预填参数,把"实现需要但用户不该关心的参数"提前固化——偏函数应用在源码里的典型用法。对比第 1 章 runner 的 bind(_effect)(绑 this)——bind 的两种用途在这个项目里各出现了一次。

命名转换链:camelize 处理模板里惯用的 kebab-case,toHandlerKey 补 on 前缀。为什么是 on 前缀?呼应第 3 章 patchProp 的 /^on[A-Z]/ 约定——框架内部约定的两端(写入端 patchProp,查找端 emit)在两个包里遥相呼应。读源码时找到一个约定的一端,一定要把另一端也找到,成对理解。


4.4 slots:children 的第三种形态——函数表

需求推导(三步演化,正好对应三个测试)

第一步:基础插槽。 父组件想把一段 vnode 塞进子组件指定的位置:

// 父:children 传 vnode
h(Foo, {}, h('p', {}, '123'))
// 子:在自己 render 里把它渲染出来
render() { return h('div', {}, [ordinaryContent, renderSlots(this.$slots)]) }

第二步:具名插槽。 多个位置 → children 传对象,key 是槽名:

h(Foo, {}, {
  header: h('p', {}, 'header'),
  footer: h('p', {}, 'footer'),
})
// 子:renderSlots(this.$slots, 'header')

第三步:作用域插槽。 父的插槽内容要用到子的数据(经典例子:列表组件把每项数据交回给父来决定怎么渲染)。数据在子那儿、渲染函数在父那儿——唯一解法:父传函数,子调用时把数据作为参数传回:

h(Foo, {}, {
  header: ({ age }) => h('p', {}, 'header ' + age),   // ★ 值变成函数
})
// 子:renderSlots(this.$slots, 'header', { age: 18 })  // 调用时喂数据

实现

// componentSlots.ts
export function initSlots(instance, children) {
  // 只有 SLOT_CHILDREN(组件 + 对象 children,第 3 章 createVNode 里打的标)才走这里
  if (instance.vnode.shapeFlag & ShapeFlags.SLOT_CHILDREN) {
    normalizeObjectSlots(children, instance.slots)
  }
}

function normalizeObjectSlots(children, slots) {
  for (const key in children) {
    const value = children[key]
    // 统一成:slots[key] = (props) => vnode数组
    slots[key] = props => normalizeSlotValue(value(props))
  }
}

function normalizeSlotValue(value) {
  return Array.isArray(value) ? value : [value]   // 单个 vnode 也包成数组
}
// helpers/renderSlots.ts
export function renderSlots(slots, name, props) {
  const slot = slots[name]
  if (slot && typeof slot === 'function') {
    // slot(props) 返回数组 → 必须包一层才能 patch → Fragment 避免多余 div
    return createVNode(Fragment, {}, slot(props))
  }
}

逐段解释

  • 存函数而不是存 vnode 是整个设计的支点:如果 initSlots 时就把 vnode 存好,子组件的数据永远喂不进去。推迟到 renderSlots 调用那一刻才执行函数,数据才有了注入的时机。"存值"升级成"存产值的函数",是所有惰性/参数化需求的通用解——第 2 章 computed 存 getter、第 6 章 transform 存退出函数,同一招;
  • normalize 层:用户可能传单个 vnode 或数组,normalizeSlotValue 统一成数组。入口归一化,内部只处理一种形态——和 h 的参数整形层一个道理,读时识别出来就可以快进;
  • Fragment 的诞生现场:slot(props) 返回数组,patch 只吃单 vnode,包 div 会污染 DOM——第 3 章预告的痛点在此兑现,Fragment 应运而生。

4.5 provide/inject:用原型链实现作用域链

失败用例(注意中间层的行为)

const Provider = {
  setup() { provide('foo', 'fooVal') },
  render() { return h(ProviderTwo) },
}
const ProviderTwo = {
  setup() {
    provide('foo', 'fooTwo')            // ★ 覆盖了 foo
    const foo = inject('foo')           // ★ 但自己 inject 到的还是父级的 'fooVal'
    return { foo }
  },
  render() { return h(Consumer) },
}
const Consumer = {
  setup() {
    const foo = inject('foo')           // 'fooTwo'(就近原则)
    const bar = inject('bar', 'default')// 没人 provide → 默认值
    return { foo, bar }
  },
}

难点提炼:① 跨任意层级查找;② 就近覆盖(Consumer 拿到 fooTwo);③ 覆盖不影响自己(ProviderTwo 自己 inject 拿到的是它父亲的 fooVal)。

天真实现会死在哪

instance.provides = { ...parent.provides }(每层拷贝):能实现①②,但每次组件创建都全量拷贝,且祖先后续新 provide 的值传不下来(拷贝是快照)。 instance.provides = parent.provides(直接共享引用):ProviderTwo 一覆盖 foo,全树的 foo 都被改了,②③全崩。

正解:Object.create

// apiInject.ts
export function provide(key, value) {
  const currentInstance = getCurrentInstance()
  if (!currentInstance) return

  let { provides } = currentInstance
  const parentProvides = currentInstance.parent?.provides

  // ★ 初始化时机检测:只在本组件第一次 provide 时执行
  if (provides === parentProvides) {
    provides = currentInstance.provides = Object.create(parentProvides)
  }
  provides[key] = value
}

export function inject(key, defaultValue?) {
  const currentInstance = getCurrentInstance()
  if (!currentInstance) return

  const parentProvides = currentInstance.parent.provides
  if (key in parentProvides) {          // in 会沿原型链查找!
    return parentProvides[key]
  }
  if (defaultValue !== undefined) {
    return typeof defaultValue === 'function' ? defaultValue() : defaultValue
  }
}

逐段解释

Object.create(parentProvides):让 JS 引擎替你实现作用域链。 每个组件的 provides 以父级 provides 为原型。于是:

  • provides[key] = value 写在自己身上(原型不受影响 → 解决③);
  • 读时沿原型链向上,自己没有就找父亲、爷爷……(解决①,且就近遮蔽天然成立 → 解决②);
  • 原型是引用不是拷贝,祖先后续新增的 provide 照样可见。

三个难题被语言机制一次性全解。识别"我的需求和某个语言内建机制同构",是高手和堆 if-else 的人的分水岭——变量作用域链、原型链、provide/inject 链,本是同一个抽象(带遮蔽的层级查找)。

provides === parentProvides 这个判等在检测什么:createComponentInstance 里初始化为 parent.provides(引用相等),意思是"我还没 provide 过,读的话直接透传父级"。第一次 provide 时才真正建立自己的原型链层。用"引用相等"编码"尚未初始化"这个状态,省掉一个布尔标志——精巧但阅读成本高,mini 源码里这行最值得写注释。

inject 查的是 parent.provides 而非自己的 provides——这就是 ProviderTwo 覆盖 foo 却 inject 到父级值的直接原因,也符合语义:inject 是"向祖先要",不包括自己。


4.6 本章总图:一个组件出生时 instance 各字段被谁填上

createComponentInstance
  ├─ props: {}        ←─ initProps(vnode.props 直接挂上)
  ├─ slots: {}        ←─ initSlots(children 归一化成函数表)
  ├─ emit             ←─ bind 预填 instance 的查表调用器
  ├─ provides         ←─ 初始 = parent.provides;首次 provide 时 Object.create 分层
  ├─ proxy            ←─ PublicInstanceProxyHandlers(setupState > props > $xxx)
  ├─ setupState       ←─ setup() 返回值,proxyRefs 包装
  └─ render           ←─ finishComponentSetup(组件选项里的 render)
                          ↓
                setupRenderEffect(第 3 章)拿着 render + proxy 开始干活

自测

  1. this.$el / this.msg(setup 返回)/ this.count(props)各自的查找路径是什么?this 到底是什么?
  2. emit('add-foo') 到 handler 被调用,中间经过哪几步字符串变换?这个约定的"另一端"在哪个文件?
  3. 插槽为什么必须存函数?作用域插槽的数据流方向画出来。
  4. {...parent.provides} 和 Object.create(parent.provides) 各死在哪个测试断言上?
  5. (手撕)实现 provide/inject 通过 4.5 的测试,不看书处理好"自己覆盖不影响自己"。

反向链接(1)

评论

关系图谱

00-总览与阅读路线01-reactivity-effect与依赖收集02-reactivity-reactive-ref-computed03-runtime-挂载流程04-runtime-组件系统05-runtime-更新与diff06-compiler-三段式07-方法论-如何啃大型开源项目README

On this page