Skip to content

实现 Props 与 Emit

本节对标 Vue 3 源码 @vue/runtime-core 中的 componentProps.tscomponentEmits.ts

Props 机制概览

Props 是父组件向子组件传递数据的通道。Vue 3 的 props 系统需要处理:

  1. 声明与过滤 — 区分哪些是 props,哪些是 attrs
  2. 响应式包装 — 用 shallowReactive 包装,使 props 变化能触发组件更新
  3. 只读保护 — 子组件不能直接修改 props
  4. 更新对比 — 父组件 re-render 时高效更新 props
父组件 h(Child, { msg: 'hi', class: 'red' })


      initProps → instance.props = { msg: 'hi' }
                → instance.attrs = { class: 'red' }

initProps —— Props 的初始化

对标 packages/runtime-core/src/componentProps.ts —— initProps

ts
// 初始化组件 props:将 VNode 上的原始 props 分离为 props 和 attrs
export function initProps(
  instance: ComponentInternalInstance,
  rawProps: Record<string, any> | null,  // VNode 上携带的原始属性(父组件传入的所有属性)
) {
  const props: Record<string, any> = {}   // 存放已声明的 props
  const attrs: Record<string, any> = {}   // 存放未声明的属性(会透传到根元素)

  // 获取组件选项中声明的 props 定义(如 { msg: String } 或 ['msg'])
  const declaredProps = instance.type.props || {}

  if (rawProps) {
    for (const key in rawProps) {
      const value = rawProps[key]
      // 如果属性在组件的 props 选项中声明了,或者是特殊属性 key/ref,则归入 props
      if (key in declaredProps || key === 'key' || key === 'ref') {
        props[key] = value
      } else {
        // 未声明的属性归入 attrs,后续会透传到组件根元素
        attrs[key] = value
      }
    }
  }

  // 用 shallowReactive 包装 props,使其具有浅层响应性
  // 当 props 的直接属性被修改时能触发依赖更新(如子组件的 render effect)
  instance.props = shallowReactive(props)
  // attrs 不需要响应式包装,因为 attrs 变化会走 updateProps 流程
  instance.attrs = attrs
}

props vs attrs 的区分

ts
const Child = {
  props: ['msg'],  // 声明接收 msg
  setup(props) {
    // props.msg → 可访问
  },
}

// 父组件
h(Child, { msg: 'hello', class: 'red', id: 'box' })
//              ↓              ↓            ↓
//          props.msg      attrs.class   attrs.id

规则很简单:

  • 在组件 props 选项中声明的 → 进入 instance.props
  • 未声明的 → 进入 instance.attrs(会透传到根元素)

为什么用 shallowReactive?

ts
instance.props = shallowReactive(props)
  • shallowReactive 而非 reactive — props 的值可能是复杂对象,不需要深层响应式(避免性能开销)
  • 响应式 — 当父组件更新 props 时,修改 instance.props.xxx 能触发子组件 render effect 重新执行
ts
// 子组件的 render effect 中访问了 props.msg
// 当 props.msg 变化时,effect 会重新执行 → 组件 re-render
function render() {
  return h('div', {}, this.msg)  // this.msg → proxy → instance.props.msg
}

props 选项的多种声明形式

Vue 3 支持数组和对象两种声明形式:

ts
// 数组形式
const Comp = {
  props: ['msg', 'count'],
}

// 对象形式(带类型/默认值/校验)
const Comp = {
  props: {
    msg: {
      type: String,
      required: true,
    },
    count: {
      type: Number,
      default: 0,
    },
  },
}

在 mini-vue 中,我们先处理简单的数组/对象形式,标准化为统一结构:

ts
// 标准化 props 选项:将数组形式和对象形式统一转为 { key: options } 格式
function normalizePropsOptions(comp: any): Record<string, any> {
  const raw = comp.props  // 获取组件原始的 props 选项
  const normalized: Record<string, any> = {}

  if (Array.isArray(raw)) {
    // 数组形式(如 ['msg', 'count']):每个元素作为 key,value 为空对象
    for (const key of raw) {
      normalized[key] = {}
    }
  } else if (raw) {
    // 对象形式(如 { msg: { type: String } }):直接拷贝
    for (const key in raw) {
      normalized[key] = raw[key]
    }
  }

  return normalized
}

updateProps —— 新旧 Props 对比更新

对标 packages/runtime-core/src/componentProps.ts —— updateProps

当父组件 re-render 时,子组件可能收到新的 props。需要对比新旧 props 并更新:

ts
// 更新 props:父组件 re-render 时,对比新旧 props 并更新到实例上
export function updateProps(
  instance: ComponentInternalInstance,
  rawNextProps: Record<string, any> | null,  // 新的 VNode 携带的 props
) {
  const { props, attrs } = instance  // 获取当前实例的 props 和 attrs(shallowReactive 对象)
  const rawPrevProps = instance.vnode.props || {}  // 旧 VNode 上的 props
  const declaredProps = instance.type.props || {}  // 组件声明的 props 选项

  if (rawNextProps) {
    for (const key in rawNextProps) {
      const next = rawNextProps[key]
      if (key in declaredProps) {
        // 已声明的 prop:只在值真正变化时才更新
        // 由于 props 是 shallowReactive 的,赋值会触发响应式更新
        if (props[key] !== next) {
          props[key] = next
        }
      } else {
        // 未声明的属性:更新到 attrs
        attrs[key] = next
      }
    }
  }

  // 删除不再存在的 props:旧 props 中有但新 props 中没有的 key 需要移除
  for (const key in props) {
    if (!rawNextProps || !(key in rawNextProps)) {
      delete props[key]
    }
  }

  // 删除不再存在的 attrs:同理处理 attrs 中过时的属性
  for (const key in attrs) {
    if (!rawNextProps || !(key in rawNextProps)) {
      delete attrs[key]
    }
  }
}

因为 instance.propsshallowReactive 的,当修改 props[key] = next 时,如果 render effect 中访问了这个 key,就会触发组件重渲染。

hasPropsChanged —— 快速判断 props 是否变化

ts
// 快速判断 props 是否发生变化:用于优化,避免不必要的 updateProps 调用
function hasPropsChanged(
  prevProps: Record<string, any>,
  nextProps: Record<string, any>,
): boolean {
  const nextKeys = Object.keys(nextProps)
  // 如果 key 的数量不同,说明 props 一定变化了(有新增或删除)
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true
  }
  // 逐个对比每个 key 的值是否相同(浅比较)
  for (const key of nextKeys) {
    if (nextProps[key] !== prevProps[key]) {
      return true
    }
  }
  return false  // 所有 key 和值都相同,props 没有变化
}

$props 的只读代理

在组件内部,this.$props 应该是只读的。Vue 3 使用 shallowReadonly 来保护 setup 函数接收到的 props 参数:

ts
// setupStatefulComponent 中
const setupResult = setup(
  shallowReadonly(instance.props),  // 只读代理
  { emit, slots, attrs },
)
ts
// shallowReadonly 的实现:创建一个只读的浅层代理,禁止修改顶层属性
export function shallowReadonly<T extends object>(target: T): Readonly<T> {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 特殊标记:标识该对象是只读代理(用于 isReadonly 检查)
      if (key === '__v_isReadonly') return true
      return Reflect.get(target, key, receiver)  // 正常返回属性值
    },
    set(target, key) {
      // 拦截写操作:不实际修改,而是发出警告(开发阶段帮助发现问题)
      console.warn(`Props are readonly. Cannot set "${String(key)}"`)
      return true  // 返回 true 避免 Proxy 抛出 TypeError
    },
  })
}

这样在 setup 中尝试修改 props 会收到警告:

ts
setup(props) {
  props.msg = 'changed' // ⚠️ Warning: Props are readonly
}

Emit 的实现

对标 packages/runtime-core/src/componentEmits.ts —— emit

Emit 是子组件向父组件通信的机制。子组件调用 emit('change', value),父组件通过 onChange 接收。

核心实现

ts
// emit 函数:子组件触发自定义事件,调用父组件传入的对应事件处理函数
export function emit(
  instance: ComponentInternalInstance,  // 当前组件实例(通过 bind 预绑定)
  event: string,                        // 事件名(如 'change'、'update-value')
  ...args: any[]                        // 传递给事件处理函数的参数
) {
  // 从 vnode.props 中获取所有属性(包括事件监听器 onXxx)
  const props = instance.vnode.props || {}

  // 将事件名转换为 handler 名:'change' → 'onChange'
  const handlerName = toHandlerKey(event)
  // 从 props 中查找对应的事件处理函数
  const handler = props[handlerName]

  if (handler) {
    handler(...args)  // 调用处理函数并传递所有参数
  }
}

// 将事件名转为事件处理器属性名:首字母大写并加上 'on' 前缀
function toHandlerKey(str: string): string {
  return str
    ? `on${str[0].toUpperCase()}${str.slice(1)}`  // 如 'change' → 'onChange'
    : ''
}

事件名转换规则

ts
toHandlerKey('change')        // → 'onChange'
toHandlerKey('update')        // → 'onUpdate'
toHandlerKey('click')         // → 'onClick'

对于 kebab-case 事件名(如 update-value),需要先转为 camelCase:

ts
// 增强版 toHandlerKey:支持 kebab-case 事件名(如 'update-value')
function toHandlerKey(str: string): string {
  return str
    ? `on${capitalize(camelize(str))}`  // 先转驼峰,再首字母大写,最后加 'on' 前缀
    : ''
}

// 将 kebab-case 字符串转为 camelCase:'update-value' → 'updateValue'
function camelize(str: string): string {
  // 正则匹配 -x 模式,将 x 转为大写(实现 kebab-case → camelCase)
  return str.replace(/-(\w)/g, (_, c: string) => (c ? c.toUpperCase() : ''))
}

// 首字母大写:'updateValue' → 'UpdateValue'
function capitalize(str: string): string {
  return str[0].toUpperCase() + str.slice(1)
}
ts
// kebab-case 事件名
toHandlerKey('update-value')
// camelize: 'updateValue'
// capitalize: 'UpdateValue'
// → 'onUpdateValue'

emit 的使用

ts
// 子组件:通过 emits 声明可触发的事件,通过 setup 的 context.emit 触发
const Child = {
  emits: ['change', 'update:modelValue'],  // 声明组件会触发的事件(用于文档和校验)
  setup(props, { emit }) {
    const handleClick = () => {
      emit('change', 'new value')        // 触发 change 事件,父组件的 onChange 会被调用
      emit('update:modelValue', 42)      // 触发 update:modelValue,用于 v-model 双向绑定
    }
    return { handleClick }
  },
  render() {
    return h('button', { onClick: this.handleClick }, 'click me')
  },
}

// 父组件:通过 props 传入事件处理函数(以 on 开头)
const Parent = {
  setup() {
    const onChange = (val: string) => console.log('changed:', val)
    const onUpdate = (val: number) => console.log('updated:', val)

    // onChange 对应子组件的 emit('change')
    // 'onUpdate:modelValue' 对应子组件的 emit('update:modelValue')
    return () => h(Child, {
      onChange,
      'onUpdate:modelValue': onUpdate,
    })
  },
}

emit 参数传递

emit 支持传递任意数量的参数,全部透传给父组件的事件处理函数:

ts
// 子组件
emit('change', arg1, arg2, arg3)

// 父组件
h(Child, {
  onChange: (arg1, arg2, arg3) => {
    // 所有参数都能收到
  },
})

emit 的绑定时机

createComponentInstance 中,emit 函数通过 bind 绑定了组件实例:

ts
export function createComponentInstance(vnode, parent) {
  const instance = { /* ... */ }

  // 使用 bind 将 emit 的第一个参数固定为当前 instance
  // 这样用户在 setup 中调用 emit('change') 时,内部实际执行的是 emit(instance, 'change')
  instance.emit = emit.bind(null, instance)

  return instance
}

这样在 setup 的 context 中使用时,不需要手动传入 instance:

ts
setup(props, { emit }) {
  emit('change', value) // 等同于 emit(instance, 'change', value)
}

emit 从 vnode.props 中查找的原因

注意 emit 是从 instance.vnode.props 中查找事件处理函数,而不是从 instance.props 中查找:

ts
const props = instance.vnode.props || {} // ← vnode.props

因为 instance.props 只包含在组件 props 选项中声明的属性,而事件监听器(onXxx)通常不会在 props 选项中声明。instance.vnode.props 包含父组件传入的所有属性,包括事件监听器。

h(Child, { msg: 'hi', onChange: handler })
                │              │
                ▼              ▼
        instance.props    仅在 vnode.props 中
        (如果声明了 msg)   (不在 instance.props 中)

设计分析

单向数据流

Props 的只读性保证了 Vue 的单向数据流

父组件 state → props → 子组件 render
                ↑ 只读
子组件 emit → 父组件 handler → 更新 state → 新 props → 子组件 re-render

子组件不能直接修改 props,只能通过 emit 通知父组件修改。这使得数据流向清晰可追踪。

v-model 的本质

v-model 其实是 props + emit 的语法糖:

html
<!-- v-model -->
<Child v-model="value" />

<!-- 等价于 -->
<Child :modelValue="value" @update:modelValue="val => value = val" />

h 函数中:

ts
h(Child, {
  modelValue: value.value,
  'onUpdate:modelValue': (val) => { value.value = val },
})

对比 React

维度Vue 3React
父→子通信props(声明式,区分 props/attrs)props(全部传入)
子→父通信emit 事件机制回调函数 props
props 只读shallowReadonly 运行时保护开发者约定 + Object.freeze
事件命名emit('change')onChange直接传 onChange 回调
attrs 透传$attrs 自动过滤已声明的 props无自动过滤,需手动 ...rest
双向绑定v-model = prop + emit 语法糖受控组件模式

React 没有 emit 的概念,子组件直接调用父组件传入的回调函数。Vue 的 emit 本质上也是调用回调,但通过事件名转换(changeonChange)提供了更优雅的 API。

测试用例

ts
describe('props', () => {
  // 测试 props 初始化:验证声明的 props 能正确传递到 setup 并渲染
  it('should init props', () => {
    const Comp = {
      props: ['msg'],  // 声明接收 msg prop
      setup(props: any) {
        return () => h('div', {}, props.msg)  // 在 render 中使用 props.msg
      },
    }

    const root = document.createElement('div')
    render(h(Comp, { msg: 'hello' }), root)  // 传入 msg='hello'
    // 断言 props.msg 正确渲染到 DOM
    expect(root.innerHTML).toBe('<div>hello</div>')
  })

  // 测试 props 和 attrs 的分离:已声明的属性进 props,未声明的进 attrs
  it('should separate props and attrs', () => {
    let propsReceived: any
    let attrsReceived: any

    const Comp = {
      props: ['msg'],  // 只声明了 msg
      setup(props: any, { attrs }: any) {
        propsReceived = props   // 捕获 setup 接收到的 props
        attrsReceived = attrs   // 捕获 setup 接收到的 attrs
        return () => h('div')
      },
    }

    // 传入 msg(已声明→props)和 class(未声明→attrs)
    render(h(Comp, { msg: 'hi', class: 'red' }), document.createElement('div'))
    expect(propsReceived.msg).toBe('hi')     // msg 在 props 中
    expect(attrsReceived.class).toBe('red')  // class 在 attrs 中
  })

  // 测试 props 更新:父组件数据变化后子组件的 props 应同步更新
  it('should update props', async () => {
    const Comp = {
      props: ['count'],
      setup(props: any) {
        return () => h('span', {}, String(props.count))
      },
    }

    const count = ref(0)  // 父组件的响应式数据
    const App = {
      setup() {
        // 将 count.value 作为 props 传给子组件
        return () => h(Comp, { count: count.value })
      },
    }

    const root = document.createElement('div')
    render(h(App), root)
    expect(root.innerHTML).toBe('<span>0</span>')  // 初始渲染

    count.value = 5        // 修改父组件数据
    await nextTick()       // 等待异步更新
    // 断言子组件的 props 已更新并重新渲染
    expect(root.innerHTML).toBe('<span>5</span>')
  })

  // 测试 props 只读保护:在 setup 中修改 props 应触发警告
  it('should warn on props mutation in setup', () => {
    // 监听 console.warn 来验证是否发出了警告
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})

    const Comp = {
      props: ['msg'],
      setup(props: any) {
        props.msg = 'changed'  // 尝试修改只读的 props,应触发警告
        return () => h('div')
      },
    }

    render(h(Comp, { msg: 'hello' }), document.createElement('div'))
    // 断言 console.warn 被调用(shallowReadonly 发出了警告)
    expect(warnSpy).toHaveBeenCalled()
    warnSpy.mockRestore()  // 恢复原始的 console.warn
  })
})

describe('emit', () => {
  // 测试基本的 emit 功能:子组件触发事件,父组件的处理函数被调用
  it('should emit event', () => {
    const handler = vi.fn()  // 创建 mock 函数来追踪调用

    const Comp = {
      setup(_: any, { emit }: any) {
        emit('change', 'value1')  // 在 setup 中触发 change 事件
        return () => h('div')
      },
    }

    // onChange 对应 emit('change')
    render(h(Comp, { onChange: handler }), document.createElement('div'))
    // 断言处理函数被调用,且参数正确
    expect(handler).toHaveBeenCalledWith('value1')
  })

  // 测试 emit 多参数传递:所有参数都应透传给处理函数
  it('should emit with multiple args', () => {
    const handler = vi.fn()

    const Comp = {
      setup(_: any, { emit }: any) {
        emit('change', 'a', 'b', 'c')  // 传递多个参数
        return () => h('div')
      },
    }

    render(h(Comp, { onChange: handler }), document.createElement('div'))
    // 断言所有三个参数都被正确传递
    expect(handler).toHaveBeenCalledWith('a', 'b', 'c')
  })

  // 测试 kebab-case 事件名:'update-value' 应匹配 onUpdateValue
  it('should handle kebab-case event name', () => {
    const handler = vi.fn()

    const Comp = {
      setup(_: any, { emit }: any) {
        emit('update-value', 42)  // 使用 kebab-case 事件名
        return () => h('div')
      },
    }

    // 父组件用 camelCase 的 onUpdateValue 接收
    render(h(Comp, { onUpdateValue: handler }), document.createElement('div'))
    expect(handler).toHaveBeenCalledWith(42)
  })

  // 测试 emit 无对应处理函数时不应抛错
  it('should not throw when emit has no handler', () => {
    const Comp = {
      setup(_: any, { emit }: any) {
        // 触发事件但父组件没有传入对应的处理函数,不应抛出异常
        expect(() => emit('change')).not.toThrow()
        return () => h('div')
      },
    }

    render(h(Comp, {}), document.createElement('div'))  // 没有传入 onChange
  })
})

本节小结

  1. initProps — 根据组件 props 选项区分 props 和 attrs,用 shallowReactive 包装 props
  2. updateProps — 父组件 re-render 时对比新旧 props,按需更新触发子组件重渲染
  3. shallowReadonly — 保护 setup 中接收到的 props 不被直接修改
  4. emit — 通过事件名转换(changeonChange)从 vnode.props 中查找并调用处理函数
  5. camelize + capitalize — 处理 kebab-case 事件名到 camelCase 的转换
  6. 单向数据流 — props 只读 + emit 通知,数据流向清晰

下一节实现 Slots 机制。

用心学习,用代码说话 💻