24.Vue Vapor 组件事件 emit 的实现

前言

我们很多时候子组件需要和父组件进行交互,在 Vue 中就设计了一个自定义事件系统,让父组件可以像监听原生 DOM 事件那样,可以通过 v-on 或 @ 来选择性地监听子组件上抛的事件。

例如:

html 复制代码
<child-component @update="handleUpdate"/>

然后如果你没有在使用 <script setup>,你可以通过 emits 选项定义组件会抛出的事件,然后可以从 setup() 函数的第二个参数,通过访问 setup 上下文对象上的 emit 函数进行触发具体的事件:

javascript 复制代码
export default {
  emits: ['update'],
  setup(props, context) {
    context.emit('update')
  }
}

那么就让我们在这一篇中具体深入地去了解组件事件 emit 的实现原理吧。

组件事件系统的本质

Vue 组件的事件系统的实现其实很复杂,但任何再复杂的功能,它都有一个基本的实现原理,我们只要掌握了这个基本实现原理,然后就可以更方便地深入了解了,甚至可以发散,举一反三,由点及面。目的就是让我们所学的知识进行串联起来,这样我们才能学习得更透切。Vue 组件事件系统更抽象地总结起来进行归纳的话就是,在组件实例对象上注册了一些函数,这些函数只有在子组件发送对应的信号的时候,相应的已经被注册了的函数才会执行。这个行为的本质就是发布订阅模式的应用,大家回想复习一下我们前面所学习的发布订阅模式,本质也还在一个对象上记录某些数据,然后可以在某些时刻进行触发它,通常记录数据的地方和触发的地方是解耦的。

那么我们知晓了组件事件系统的本质之后,我们再去实现就简单多了,首先上述前言中父组件在 Vue Vapor 中会被编译成如下代码:

javascript 复制代码
const n1 = createComponent(ChildComponent, {
  onUpdate: () => _ctx.handleUpdate
})

从发布订阅模式的角度来说,这相当于往组件实例对象上添加订阅者,从我们上一篇 props 的实现原理中可以知道,最终会被存储在组件实例对象的 rawProps 上,那么将来通过 emit 函数触发订阅者就是在组件实例对象的 rawProps 上找对应的订阅者函数进行调用即可。

我们从上文知道可以从 setup() 函数的第二个参数,通过访问 setup 上下文对象上的 emit 函数进行触发具体的订阅者也就是事件。我们通过前面的章节知道 setup 上下文对象是通过 createSetupContext 函数创建的,所以我们只需在 createSetupContext 函数进行迭代即可。代码迭代如下:

diff 复制代码
export function createSetupContext(instance) {
  return Object.freeze({
    get attrs() {
      return getAttrsProxy(instance)
    },
+    get emit() {
+      return (event, ...args) => {
+        // 将事件名转换成 onXXX,on 后面第一个字母需要转换成大写
+        const handleName = `on${event.charAt(0).toUpperCase() + event.slice(1)}`
+        // 在组件实例对象上的 rawProps 判断有没有存在对应的事件,有就执行返回对应的事件函数
+        const handler = instance.rawProps[handleName] && instance.rawProps[handleName]()
+        // 执行在父组件注册的事件函数
+        handler(...args)
+      }
+    }
  })
}

因为 emit 是一个函数,所以我们需要返回一个函数,然后将事件名转换成 onXXX 形式,且 on 后面第一个字母需要转换成大写,在组件实例对象上的 rawProps 判断有没有存在对应的事件,有就执行返回对应的事件函数,最后执行获取到在父组件注册的事件函数。

接着我们就可以测试了,在父组件添加订阅者如下:

javascript 复制代码
const App = {
    setup() {
        const count = ref(0)
        // 定义订阅者函数
        const handleUpdate = (data, data2) => {
            console.log('emit 触发成功', data, data2)
        }
        return { count, handleUpdate } 
    },
    render(_ctx) {
        // 省略...
        const n1 = createComponent(ChildComponent, {
            count: () => _ctx.count,
            // 添加订阅者
            onUpdate: () => _ctx.handleUpdate
        })
        insert(n1, el)
        return el
    }
}

在子组件中触发订阅者如下:

javascript 复制代码
const ChildComponent = {
    // 省略...
    setup(props, context) {
        // 触发订阅者
        context.emit('update', { name : 'Cobyte' }, '参数2')
        // 省略... 
    }
}

测试结果如下:

我们测试结果显示我们成功触发了在父组件注册的函数的执行。

我们小结一下,我们在本节中通过发布订阅模式的角度去理解 Vue 的组件事件系统,即 emit 的实现原理,并且初步实现了。同时也验证了我们前面所说的,判断一种设计模式,不应从代码组织结构,而是从代码的行为意图进行判断。所以这也是为什么 Vue 组件中发射事件的函数名称叫 emit,跟一些发布订阅库的触发订阅者的函数名称是一样的缘故,这也是 API 命名的一个规范技巧,要见名知意。同时我们既加深了对发布订阅模式的理解,又理解了 Vue 组件事件系统的实现原理,而且通过这种场景化去学习开发模式,相信掌握得更彻底,也让我们的知识更全面,更立体。

封装 emit 函数

emit 函数中其实还有很多功能需要实现,比如事件校验、v-model 在组件上的处理,所以我们可以把 emit 的实现封装到外部的一个函数中去。所以我们新建一个 runtime-vapor\src\componentEmits.js 的文件,封装代码如下:

javascript 复制代码
export function emit(
  instance,
  event,
  ...rawArgs
) {
    // 将事件名转换成 onXXX,on 后面第一个字母需要转换成大写
    const handleName = `on${event.charAt(0).toUpperCase() + event.slice(1)}`
    // 在组件实例对象上的 rawProps 判断有没有存在对应的事件,有就执行返回对应的事件函数
    const handler = instance.rawProps[handleName] && instance.rawProps[handleName]()
    // 执行在父组件注册的事件函数
    handler(...rawArgs)
}

然后 createSetupContext 函数迭代如下:

javascript 复制代码
export function createSetupContext(instance) {
  return Object.freeze({
    // 省略...
    get emit() {
      return (event, ...args) => {
        emit(instance, event, ...args)
      }
    }
  })
}

这样我们就完成了我们 emit 函数的代码封装与重构。

通过函数柯里化给 emit 设置默认参数

我们知道在 Vue3 中还可以直接在模板中通过 $emit 方法进行调用,使用示例代码如下:

html 复制代码
<template>
    <button @click="$emit('update')">更新</button>
</template>

$emit 方法主要是通过组件代理对象上的 emit 方法来实现的,我们后续在讲解组件代理对象再详细进行解读,本质是可以独立调用 emit 方法进行使用。而我们目前调用 emit 方法的话,需要传递一个组件实例对象,而使用 $emit 方法是不需要传递组件实例对象的。我们从原来 emit 方法的参数,我们可以发现一个特点,也就是 emit 方法每一次调用的第一个参数都是组件实例对象,也就是不变的,那么从冗余的角度来看,这个是可以被优化的,我们可以把不变的参数固定下来,变动的参数抽象到一个函数中,由用户传入。那么这个很明显就是函数柯里化所做的事情。

我们来看看函数柯里化是什么。

函数柯里化是一种将多参数的函数转换成单参数形式的技术,它通过创建一个新的函数,该函数接收一部分参数,并返回另一个函数。这个过程可以持续进行,直到所有参数都被接收并处理。柯里化函数的主要作用包括参数复用、提前返回和延迟执行。

所以我们可以创建组件实例对象的时候进行 emit 函数的参数复用设置,主要是通过函数柯里化,迭代代码如下:

javascript 复制代码
export const createComponentInstance = (
  component,
  rawProps
) => {
  // 省略...
  // 创建柯里化函数
  const createEmit = (instance) => {
    return (event, ...args) => {
      emit(instance, event, ...args)
    }
  }
  instance.emit = createEmit(instance)
  return instance
}

这样我们就可以在 createSetupContext 函数中进行以下调用了。

diff 复制代码
export function createSetupContext(instance) {
  return Object.freeze({
    // 省略...
    get emit() {
      return (event, ...args) => {
-        emit(instance, event, ...args)
+        instance.emit(event, ...args)
      }
    }
  })
}

我们可以看到通过函数柯里化我们实现相同参数的复用,然后方便了用户的使用,这样将来我们在模板中直接通过 $emit 方法也可以使用了,不再需要传递组件实例对象了。

而我们上面实现函数柯里化的方式还是麻烦了一点,其实我们可以直接使用 JavaScript 的原生 bind 方法实现函数柯里化。代码修改如下:

javascript 复制代码
export const createComponentInstance = (
  component,
  rawProps
) => {
  // 省略...
  // 通过函数柯里化实现参数复用,以方便用户的使用
  instance.emit = emit.bind(null, instance)
  return instance
}

我们可以看到使用 bind 方法可以更简洁地实现了函数柯里化,因为 bind 方法是函数柯里化的经典场景。下面是一个手写一个简单的 bind 实现:

javascript 复制代码
// ctx 就是传入的参数用来改变 this 指向的,如果没有就默认写的是 window
Function.prototype.myBind = function (ctx = window, ...args) {
    // this 就是调用的那个函数
    const self = this
    return function bound(...newArgs) {
        // 在再次执行的的时候去改变 this 的指向
        return self.apply(ctx, [...args, ...newArgs])
    }
}

bind 和 call、apply 方法的区别是它不会立即执行,它是返回一个改变了 this 指向的函数,在绑定的时候可以传参,之后执行的时候,还可以继续传参数数,这个特征行为就是一个典型的函数柯里化的场景了。

小结一下,本小节我们实现了源码中通过 bind 方法挂载 emit 函数到组件实例对象上的功能,同时还详情深入地去剖析了背后涉及的原理知识,让我们所学的知识进行串联起来,这样记忆得更深刻。

事件声明与校验

和对 props 添加类型校验的方式类似,在组件上注册的事件函数也可以添加校验,以达到在开发阶段就检测到用户注册的事件函数是我们期望的。如果你显式地使用了 setup 函数而不是 <script setup>,你可以通过 emits 选项来定义声明它要触发的事件,可以以两种形式声明触发的事件:

  1. 使用字符串数组的简易形式。
javascript 复制代码
export default {
  emits: ['update'],
  setup(props, context) {
    context.emit('update')
  }
}
  1. 使用对象的完整形式。该对象的每个属性键是事件的名称,值是 null 或一个验证函数。
javascript 复制代码
export default {
  emits: {
    update: (payload: { name }) => {
      // 执行运行时校验
      return payload.name.length > 0
    }
  },
  setup(props, context) {
    context.emit('update')
  }
}

那么跟组件的 props 配置项一样,组件的 emits 配置项也需要在组件实例初始化的进行标准化设置。

javascript 复制代码
export const createComponentInstance = (
  component,
  rawProps
) => {
  const instance = {
    // 省略...
    emitsOptions: normalizeEmitsOptions(component),
  }
  // 省略...
}

我们可以看到在创建组件实例的函数中会通过 normalizeEmitsOptions 函数进行标准化设置组件的 emits 配置。

javascript 复制代码
// 规范化(或标准化)一个组件的 emits 选项
export function normalizeEmitsOptions(
  comp,
) {
  // 先尝试从传入的组件对象(comp)中获取 emits 属性,并将其存储在 raw 变量中
  const raw = comp.emits
  // 如果 raw 不存在(即组件没有定义 emits 属性),函数直接返回null
  if (!raw) return null
  // 创建一个空对象 normalized,用于存储规范化后的 emits 数据
  let normalized = {}
  // 如果raw是一个数组
  if (isArray(raw)) {
    // 遍历数组中的每个元素(这里假设每个元素都是一个字符串,即事件名称)
    raw.forEach(key => (normalized[key] = null))
  } else {
    // 如果raw不是一个数组则使用Object.assign方法将raw对象中的所有属性复制到normalized对象中。这意味着如果raw对象中的属性是事件验证器(即返回布尔值的函数或对象),它们将直接复制到normalized对象中
    Object.assign(normalized, raw)
  }
  // 返回标准化后的对象
  return normalized
}

这个函数的主要目的是确保无论组件的 emits 选项是以数组还是对象的形式提供的,它都能被转换为一个具有相同结构的对象,其中事件名称作为键,值可以是 null(表示没有验证器)或事件验证器。

我们标准化完 emits 配置项后,我们就要在 emit 函数中进行相关校验,emit 代码迭代如下:

javascript 复制代码
const __DEV__ = true // 模拟开发环境
export function emit(
  instance,
  event,
  ...rawArgs
) {
    // 在开发环境中,代码会执行检查,而在生产环境中,为了性能考虑,这些检查会被省略。
    if (__DEV__) {
        const {
            emitsOptions,
            propsOptions,
        } = instance
        // 如果 emitsOptions 存在(即组件定义了 emits 选项),则继续检查。
        if (emitsOptions) {
            // 使用 in 运算符检查事件名 event 是否在 emitsOptions 中。
            if (!(event in emitsOptions)) {
                // 如果不在,并且 propsOptions 也不存在或 event 不在 propsOptions 中,则输出一个警告,说明组件发射了一个事件,但该事件既没有在 emits 选项中声明,也没有作为一个 prop 声明。
                if (!propsOptions || !(event in propsOptions)) {
                    console.warn(
                        `Component emitted event "${event}" but it is neither declared in ` +
                        `the emits option nor as an "${event}" prop.`,
                    )
                }
            } else {
                // 如果事件在 emitsOptions 中存在,那么检查是否有为该事件定义的验证器(validator)。  
                const validator = emitsOptions[event]
                if (typeof validator === 'function') {
                    // 验证器是一个函数,则使用该函数来验证 `rawArgs`(可能是传递给事件的原始参数)
                    const isValid = validator(...rawArgs)
                    if (!isValid) {
                        // 验证失败,则输出一个警告,说明事件参数无效,事件验证失败。
                        console.warn(
                        `Invalid event arguments: event validation failed for event "${event}".`,
                        )
                    }
                }
            }
        }
    }
    // 省略...
}

总结

至此,我们深入剖析了 Vue Vapor 组件事件系统(emit)的核心实现原理,并将其与发布订阅模式相结合,让抽象的设计模式落地为具体的代码实践。回顾整篇文章,我们可以梳理出以下几个关键要点:

  1. 本质与设计模式

    组件事件系统的本质是发布订阅模式 在 Vue 中的典型应用。父组件通过模板编译将事件处理函数注册到子组件的 rawProps 中(以 onXxx 形式存储),子组件则通过 emit 函数按事件名查找并执行对应的处理函数。这一过程完全解耦了事件的注册方与触发方,符合发布订阅模式的核心思想。

  2. 核心实现流程

    • 编译阶段 :父组件模板中的 @update 会被编译为 onUpdate 属性,作为普通 prop 传入子组件实例的 rawProps

    • 运行时阶段

      • setup 上下文中提供 emit 函数,该函数接收事件名和参数,内部将事件名转换为 onXxx 格式。
      • instance.rawProps 中获取对应的处理函数并执行,同时传入参数。
    • 封装与柯里化 :通过 emit.bind(null, instance) 将组件实例固定为第一个参数,形成柯里化函数,方便在模板中通过 $emit 直接调用,且无需显式传递实例。

  3. 事件声明与校验(开发环境)

    • 组件通过 emits 选项声明所触发的事件,支持数组(简易)和对象(带验证器)两种形式。

    • emit 执行时,若处于开发环境,会进行双重校验:

      • 事件是否已在 emits 中声明(否则抛出警告,提示事件未声明或非 prop)。
      • 若声明了验证器函数,则校验传入参数是否满足条件,失败时给出警告。
        这有助于开发阶段尽早发现错误,提升代码健壮性。
  4. 对开发者学习的价值

    • 通过将设计模式与框架源码结合,我们不仅理解了 emit 的实现细节,更学会了如何从行为意图出发识别设计模式,而非机械记忆代码结构。
    • 柯里化、bind 的应用、开发环境条件编译等技巧,展示了 Vue 在性能与开发体验之间的权衡,也为我们编写高质量库代码提供了参考。

Vue 组件事件系统虽看似简单,但背后蕴含了工程化、设计模式、性能优化等多方面的智慧。掌握其原理,不仅有助于日常开发中更自信地使用 emit,也为深入阅读 Vue 源码乃至构建自己的响应式框架奠定了坚实基础。未来我们还可以继续探索 v-model 在组件上的语法糖实现、事件修饰符等高级特性,它们都建立在本篇所讲的基础之上。希望通过本篇文章,你能对 Vue 的事件通信机制有更立体、更透彻的认识。

相关推荐
Flynt2 小时前
TypeScript 5.8 让我少踩了一个大坑,顺手再聊聊 --erasableSyntaxOnly
前端·javascript·typescript
用户2930750976692 小时前
# 从零开始,打造 AI 产品的第一个关键体验——流式输出
前端·vue.js
小杍随笔2 小时前
【 .npmrc 终极配置指南:统一管理 npm/pnpm 缓存与全局目录,告别磁盘混乱】
前端·缓存·npm
默_笙2 小时前
👍 手写一个 MCP 文件读取服务:原来 AI 能"读"我的文件,全靠这个协议
前端·javascript
饮茶三千2 小时前
基于 WangEditor 的富文本编辑器定制实践
前端·vue.js·设计模式
kyle~2 小时前
前端 --- Toast 轻量级弱反馈提示组件
前端
玉宇夕落2 小时前
Vue3 + 流式输出学习
前端
努力成为AK大王2 小时前
CSS 入门完整笔记
前端·css
微笑挖矿2 小时前
大模型流式渲染:为什么"逐字显示"远比你想的复杂
前端