2026.01.27

组件事件机制 -> emit 方法的实现与组件通信

在之前的章节中,我们实现了通过父组件的 Props 传递,在更新的同时通知子组件更新。除了这种方式之外,子组件也可以通过触发事件来通知父组件,这正是 Vue 中经典的"子传父"通信模式。

在之前的章节中,我们实现了通过父组件的 Props 传递,在更新的同时通知子组件更新。除了这种方式之外,子组件也可以通过触发事件来通知父组件,这正是 Vue 中经典的"子传父"通信模式。

问题场景

让我们先通过一个示例来观察组件事件的场景:

<div id="app"></div>

<script type="module">
  import { h, createApp, ref } from '../dist/vue.esm.js'

  const Child = {
    props: ['age'],
    setup(props, { emit }) {
      return () => {
        return h('button', {
          onClick() {
            emit('foo', 1, 2, 3)
          }
        }, 'child component')
      }
    }
  }

  const Comp = {
    setup() {
      return () => {
        return h('div', [
          h('p', 'p'),
          h(Child, {
            onFoo(...args) {
              console.log('父组件传递的事件', args)
            }
          })
        ])
      }
    }
  }

  createApp(Comp).mount('#app')
</script>

核心问题:

  • 子组件通过 emit('foo', 1, 2, 3) 触发事件
  • 父组件通过 onFoo 监听事件并接收参数
  • 目前 emit 功能还未实现,需要我们来完成

实现 emit

1. 事件名转换规则

Vue 的事件处理采用如下命名约定:

  • 事件触发:使用 kebab-case,如 emit('foo')
  • 事件监听:使用 on 前缀 + 首字母大写,如 onFoo

因此,我们需要将事件名转换为对应的属性名:

/**
 * 将事件名转换为对应的属性名
 * @example 'foo' -> 'onFoo'
 */
function toEventName(event) {
  return `on${event[0].toUpperCase() + event.slice(1)}`
}

2. 基础 emit 实现

首先在 createSetupContext 中添加 emit 方法:

/**
 * 创建 setup 函数的上下文
 */
function createSetupContext(instance) {
  return {
    get attrs() {
      return instance.attrs
    },
    /**
     * 触发组件事件
     * @param event 事件名
     * @param args 传递给事件处理函数的参数
     */
    emit(event, ...args) {
      const eventName = toEventName(event)

      /**
       * 从 vnode.props 中获取对应的事件处理函数
       * 父组件通过 h(Child, { onFoo: handler }) 的方式传递事件
       */
      const handler = instance.vnode.props[eventName]

      if (isFunction(handler)) {
        handler(...args)
      }
    }
  }
}

设计要点:

  • instance.vnode.props:存储父组件传递的所有属性,包括事件处理函数
  • 事件处理函数通过 onFoo 的形式存在,需要先进行名称转换
  • 只有当 handler 是函数时才调用,避免错误

3. 抽离 emit 函数

为了兼容 Vue 2.x 的 $emit API,我们需要将 emit 逻辑抽离成独立函数,并挂载到组件实例上:

/**
 * 触发组件事件的核心实现
 * @param instance 组件实例
 * @param event 事件名
 * @param args 传递给事件处理函数的参数
 */
function emit(instance, event, ...args) {
  const eventName = toEventName(event)
  const handler = instance.vnode.props[eventName]

  if (isFunction(handler)) {
    handler(...args)
  }
}

/**
 * 创建 setup 函数的上下文
 */
function createSetupContext(instance) {
  return {
    get attrs() {
      return instance.attrs
    },
    emit(event, ...args) {
      emit(instance, event, ...args)
    }
  }
}

设计要点:

  • emit 函数独立于 createSetupContext,便于在多处复用
  • setup 中的 emit 方法内部调用核心的 emit 函数
  • 保持了代码的解耦和可维护性

4. 挂载到组件实例

为了支持组件实例访问 $emit,我们需要在创建组件实例时将 emit 挂载到实例上:

/**
 * 创建组件实例
 */
export function createComponentInstance(vnode) {
  const { type } = vnode
  const instance: any = {}

  instance.ctx = { _: instance }

  /**
   * 将 emit 挂载到实例上,供内部使用
   */
  instance.emit = (event, ...args) => emit(instance, event, ...args)

  return instance
}

5. 添加到 publicPropertiesMap

最后,将 $emit 添加到公共属性映射表,使其可以通过组件实例代理访问:

/**
 * 组件实例的公共属性映射
 * 通过代理机制,使组件实例可以访问这些属性
 */
const publicPropertiesMap = {
  $el: instance => instance.vnode.el,
  $attrs: instance => instance.attrs,
  $slots: instance => instance.slots,
  $refs: instance => instance.refs,
  $nextTick: (instance) => {
    return nextTick.bind(instance)
  },
  $forceUpdate: (instance) => {
    return () => instance.update()
  },
  /**
   * 暴露 $emit API
   * 在 Options API 中可以通过 this.$emit 触发事件
   */
  $emit: instance => instance.emit
}

总结

至此,我们完成了组件事件机制的完整实现:

1. 事件命名约定

  • 触发事件:kebab-case 形式,如 emit('foo')
  • 监听事件:on 前缀 + 首字母大写,如 onFoo

2. 核心实现

  • emit 函数:事件触发的核心逻辑,从 vnode.props 中获取并调用事件处理函数
  • createSetupContext:在 setup 中通过解构 { emit } 获取事件触发方法

3. 兼容性支持

  • emit 挂载到组件实例 instance.emit
  • 通过 publicPropertiesMap 暴露 $emit,支持 Options API

4. 工作流程

  1. 父组件通过 h(Child, { onFoo: handler }) 传递事件处理函数
  2. 处理函数存储在子组件的 vnode.props.onFoo
  3. 子组件通过 emit('foo', ...args) 触发事件
  4. emitfoo 转换为 onFoo,从 props 中获取并调用处理函数

这套机制实现了子组件向父组件的通信,是 Vue 组件通信体系的重要组成部分,与 Props 传递形成完整的父子组件通信方案。