主题
实现 Props 与 Emit
本节对标 Vue 3 源码
@vue/runtime-core中的componentProps.ts、componentEmits.ts
Props 机制概览
Props 是父组件向子组件传递数据的通道。Vue 3 的 props 系统需要处理:
- 声明与过滤 — 区分哪些是 props,哪些是 attrs
- 响应式包装 — 用
shallowReactive包装,使 props 变化能触发组件更新 - 只读保护 — 子组件不能直接修改 props
- 更新对比 — 父组件 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.props 是 shallowReactive 的,当修改 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 3 | React |
|---|---|---|
| 父→子通信 | 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 本质上也是调用回调,但通过事件名转换(change → onChange)提供了更优雅的 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
})
})本节小结
- initProps — 根据组件
props选项区分 props 和 attrs,用shallowReactive包装 props - updateProps — 父组件 re-render 时对比新旧 props,按需更新触发子组件重渲染
- shallowReadonly — 保护 setup 中接收到的 props 不被直接修改
- emit — 通过事件名转换(
change→onChange)从vnode.props中查找并调用处理函数 - camelize + capitalize — 处理 kebab-case 事件名到 camelCase 的转换
- 单向数据流 — props 只读 + emit 通知,数据流向清晰
下一节实现 Slots 机制。