JavaScript 自定义事件机制实现详解:on、fire、off 与 one

作者:像素漫游

时间:2026-10-22

浏览:151

本文深入解析如何在 JavaScript 中从零构建一套完整的自定义事件机制。详细阐述了事件绑定(on)、触发(fire)、取消绑定(off)及单次执行(one)的核心逻辑与代码实现。通过封装事件类、管理回调数组及处理执行上下文,帮助开发者在自定义控件中灵活插入个性化处理流程,提升代码的可扩展性与维护性。

文章目录

前言与事件机制概述

前言

事件机制为 Web 开发带来了极大的便利,允许开发者在任意时刻指定特定操作下执行的具体代码或行为。

例如,点击事件在用户鼠标按下时触发;keydown 和 keyup 事件分别对应键盘按下与弹起的时刻;此外,上传控件中通常包含文件加入前和上传完成等特定节点的事件。

由于在关键时机会有相应事件触发,我们可以为这些事件指定相应的处理函数,从而在原有业务流程中插入各种个性化操作与处理逻辑,使整体流程更加丰富和灵活。

诸如 click、blur、focus 等事件是 DOM 原生提供的,而许多第三方控件使用的事件(如上传控件的上传开始与完成事件)并非 DOM 原生支持。那么,这些非原生事件是如何实现的呢?

如果我们希望在自己开发的控件中加入类似的事件机制,又该如何实现?接下来我们将深入探讨其实现原理。

事件机制核心功能分析

事件应有的功能

在动手实现之前,我们需要首先分析事件机制应具备的基本功能。

简而言之,一个完整的事件机制必须提供以下核心功能:

  • 绑定事件
  • 触发事件
  • 取消绑定事件

前期准备

观察事件的一个关键特征:事件必然归属于某个对象。例如,focus 和 blur 事件属于可获取焦点的 DOM 元素,input 事件属于输入框,而上传开始与成功事件则属于上传组件。

这意味着事件并非独立存在,它需要一个载体。为了让事件拥有载体,一种简单的实现方案是将事件设计为一个基类,在需要事件功能的组件中继承该类即可。

我们将绑定、触发、取消绑定分别命名为 on、fire 和 off,基于此可以初步构建出事件类的骨架:


function CustomEvent() {
 this._events = {};
}
CustomEvent.prototype = {
 constructor: CustomEvent,
 // 绑定事件
 on: function () {
 },
 // 触发事件
 fire: function () {
 },
 // 取消绑定事件
 off: function () {
 }
};

事件绑定(on)实现原理

事件绑定

首先实现事件绑定功能。绑定操作必须明确指定事件类型以及对应的处理函数。

除此之外,还需要考虑什么?由于是自定义事件,我们无需像原生事件那样区分冒泡或捕获阶段,也不需要像 jQuery 那样指定触发元素。

然而,事件处理函数中的 this 通常指向当前实例,这在某些场景下可能不符合预期,因此我们需要支持重新指定事件处理函数运行时的上下文环境。

因此,确定事件绑定的三个关键参数分别为:事件类型、事件处理函数、以及事件处理函数的执行上下文。

事件绑定的核心逻辑其实很简单,即把相应的事件名称与处理函数记录下来。

我的实现逻辑如下:


{
 /**
  * 绑定事件
  * 
  * @param {String} type 事件类型
  * @param {Function} fn 事件处理函数
  * @param {Object} scope 要为事件处理函数绑定的执行上下文
  * @returns 当前实例对象
  */
 on: function (type, fn, scope) {
  if (type + '' !== type) {
   console && console.error && console.error('the first argument type is requird as string');
   return this;
  }
  if (typeof fn != 'function') {
   console && console.error && console.error('the second argument fn is requird as function');
   return this;
  }
  type = type.toLowerCase();

  if (!this._events[type]) {
   this._events[type] = [];
  }
  this._events[type].push(scope ? [fn, scope] : [fn]);
  return this;
 }
}

由于同一种事件类型可以绑定多个处理函数,且执行时需依次调用,因此存储各事件类型下的处理函数时使用的是数组结构。

事件触发(fire)与信息传递

事件触发

事件触发的基本功能是执行用户绑定的所有处理函数,因此只需在触发时检查是否存在指定的执行函数,若存在则调用即可。

此外,事件触发实质上是用户指定处理函数的执行过程,大量的个性化操作也在此处进行。因此,仅仅执行函数是不够的,还必须为当前函数提供必要的信息,例如点击事件中的被点击元素、键盘事件中的键码、上传事件中的文件信息等。

因此,在事件触发时,传递给事件处理函数的参数中必须包含当前事件的基本信息对象。

另外,通过用户在处理函数中的操作,可能需要调整后续流程,例如在 keydown 事件中禁止录入、在文件上传前取消上传或修改文件信息。因此,事件触发函数应当返回用户修改后的事件对象,以便后续处理。

我的实现逻辑如下:


{
 /**
  * 触发事件
  * 
  * @param {String} type 触发事件的名称
  * @param {Object} data 要额外传递的数据,事件处理函数参数如下
  * event = {
   // 事件类型
   type: type,
   // 绑定的源,始终为当前实例对象
   origin: this,
   // 事件处理函数中的执行上下文 为 this 或用户指定的上下文对象
   scope :this/scope
   // 其他数据 为fire时传递的数据
  }
  * @returns 事件对象
  */
 fire: function (type, data) {
  type = type.toLowerCase();
  var eventArr = this._events[type];
  var fn,
   // event = {
   //  // 事件类型
   //  type: type,
   //  // 绑定的源
   //  origin: this,
   //  // scope 为 this 或用户指定的上下文,
   //  // 其他数据 
   //  data: data,
   //  // 是否取消
   //  cancel: false
   // };
   // 上面对自定义参数的处理不便于使用 将相关属性直接合并到事件参数中
   event = $.extend({
    // 事件类型
    type: type,
    // 绑定的源
    origin: this,
    // scope 为 this 或用户指定的上下文,
    // 其他数据 
    // data: data,
    // 是否取消
    cancel: false
   }, data);
  if (!eventArr) {
   return event;
  }
  for (var i = 0, l = eventArr.length; i < l; ++i) {
   fn = eventArr[i][0];
   event.scope = eventArr[i][1] || this;
   fn.call(event.scope, event);
  }
  return event;
 }
}

在上述实现中,传递给事件处理函数的参数对象必定包含以下信息:

  • type:当前触发的事件类型
  • origin:当前事件绑定到的对象(即事件源)
  • scope:事件处理函数的执行上下文

此外,针对不同事件在触发时,还可以为此事件对象中注入各自特有的信息。

关于 Object.assign(target, ...sources) 是 ES6 中的一个方法,作用是将所有可枚举属性的值从一个或多个源对象复制到目标对象,并返回目标对象,其功能类似于大家熟知的 $.extend(target,..sources) 方法。

事件取消(off)与单次执行(one)

事件取消

事件取消的功能主要是移除已经绑定的事件处理函数。

实现逻辑如下:


{
 /**
  * 取消绑定一个事件
  * 
  * @param {String} type 取消绑定的事件名称
  * @param {Function} fn 要取消绑定的事件处理函数,不指定则移除当前事件类型下的全部处理函数
  * @returns 当前实例对象
  */
 off: function (type, fn) {
  type = type.toLowerCase();
  var eventArr = this._events[type];
  if (!eventArr || !eventArr.length) return this;
  if (!fn) {
   this._events[type] = eventArr = [];
  } else {
   for (var i = 0; i < eventArr.length; ++i) {
    if (fn === eventArr[i][0]) {
     eventArr.splice(i, 1);
     // 1、找到后不能立即 break 可能存在一个事件一个函数绑定多次的情况
     // 删除后数组改变,下一个仍然需要遍历处理!
     --i;
    }
   }
  }
  return this;
 }
}

此处的实现逻辑类似于原生事件取消绑定:如果指定了具体的事件处理函数,则移除该特定处理函数;如果省略了处理函数参数,则移除当前事件类型下的所有处理函数。

仅触发一次的事件

jQuery 中提供了一个 one 方法,所绑定的事件仅会执行一次。该方法在特定场景下非常有用,无需用户手动取消绑定。

这里的实现也非常简单,核心逻辑是在触发该事件时自动取消绑定。

实现逻辑如下:


{
 /**
  * 绑定一个只执行一次的事件
  * 
  * @param {String} type 事件类型
  * @param {Function} fn 事件处理函数
  * @param {Object} scope 要为事件处理函数绑定的执行上下文
  * @returns 当前实例对象
  */
 one: function (type, fn, scope) {
  var that = this;
  function nfn() {
   // 执行时 先取消绑定
   that.off(type, nfn);
   // 再执行函数
   fn.apply(scope || that, arguments);
  }
  this.on(type, nfn, scope);
  return this;
 }
}

其原理是不直接将用户指定的函数绑定上去,而是生成一个包装函数并绑定。该包装函数执行时会先执行取消绑定操作,然后再执行用户指定的原始处理函数。

基本雏形

至此,一套完整的事件机制雏形已经形成,完整代码示例如下:


function CustomEvent() {
 this._events = {};
}
CustomEvent.prototype = {
 constructor: CustomEvent,
 /**
  * 绑定事件
  * 
  * @param {String} type 事件类型
  * @param {Function} fn 事件处理函数
  * @param {Object} scope 要为事件处理函数绑定的执行上下文
  * @returns 当前实例对象
  */
 on: function (type, fn, scope) {
  if (type + '' !== type) {
   console && console.error && console.error('the first argument type is requird as string');
   return this;
  }
  if (typeof fn != 'function') {
   console && console.error && console.error('the second argument fn is requird as function');
   return this;
  }
  type = type.toLowerCase();

  if (!this._events[type]) {
   this._events[type] = [];
  }
  this._events[type].push(scope ? [fn, scope] : [fn]);

  return this;
 },
 /**
  * 触发事件
  * 
  * @param {String} type 触发事件的名称
  * @param {Anything} data 要额外传递的数据,事件处理函数参数如下
  * event = {
   // 事件类型
   type: type,
   // 绑定的源,始终为当前实例对象
   origin: this,
   // 事件处理函数中的执行上下文 为 this 或用户指定的上下文对象
   scope :this/scope
   // 其他数据 为fire时传递的数据
  }
  * @returns 事件对象
  */
 fire: function (type, data) {
  type = type.toLowerCase();
  var eventArr = this._events[type];
  var fn, scope,
   event = Object.assign({
    // 事件类型
    type: type,
    // 绑定的源
    origin: this,
    // scope 为 this 或用户指定的上下文,
    // 是否取消
    cancel: false
   }, data);

  if (!eventArr) return event;

  for (var i = 0, l = eventArr.length; i < l; ++i) {
   fn = eventArr[i][0];
   scope = eventArr[i][1];
   if (scope) {
    event.scope = scope;
    fn.call(scope, event);
   } else {
    event.scope = this;
    fn(event);
   }
  }
  return event;
 },
 /**
  * 取消绑定一个事件
  * 
  * @param {String} type 取消绑定的事件名称
  * @param {Function} fn 要取消绑定的事件处理函数,不指定则移除当前事件类型下的全部处理函数
  * @returns 当前实例对象
  */
 off: function (type, fn) {
  type = type.toLowerCase();
  var eventArr = this._events[type];
  if (!eventArr || !eventArr.length) return this;
  if (!fn) {
   this._events[type] = eventArr = [];
  } else {
   for (var i = 0; i < eventArr.length; ++i) {
    if (fn === eventArr[i][0]) {
     eventArr.splice(i, 1);
     // 1、找到后不能立即 break 可能存在一个事件一个函数绑定多次的情况
     // 删除后数组改变,下一个仍然需要遍历处理!
     --i;
    }
   }
  }
  return this;
 },
 /**
  * 绑定一个只执行一次的事件
  * 
  * @param {String} type 事件类型
  * @param {Function} fn 事件处理函数
  * @param {Object} scope 要为事件处理函数绑定的执行上下文
  * @returns 当前实例对象
  */
 one: function (type, fn, scope) {
  var that = this;

  function nfn() {
   // 执行时 先取消绑定
   that.off(type, nfn);
   // 再执行函数
   fn.apply(scope || that, arguments);
  }
  this.on(type, nfn, scope);
  return this;
 }
};

在自定义控件中的应用示例

在自己的控件中使用

上面我们已经实现了一套基础的事件机制,那么如何将其应用到自己的控件中呢?

例如,假设我们开发了一个日历控件,需要利用事件机制来扩展功能。


function Calendar() {
 // 加入事件机制的存储的对象
 this._event = {};
 // 日历的其他实现
}
Calendar.prototype = {
 constructor:Calendar,
 on:function () {},
 off:function () {},
 fire:function () {},
 one:function () {},
 // 日历的其他实现 。。。
}

以上伪代码仅作示意,只需让控件继承 on、off、fire、one 等方法即可。但必须保证存储事件的 _events 对象是直接挂载在实例上的,这一点在实现 JavaScript 继承时需特别注意,因为 JS 的继承方案多种多样。

上述代码为日历控件 Calendar 加入了事件机制,之后便可在 Calendar 内部直接使用。

例如,在日历开发过程中,我们在渲染日历单元格时触发 cellRender 事件。


// 每天渲染时发生 还未插入页面
var renderEvent = this.fire('cellRender', {
 // 当天的完整日期
 date: date.format('YYYY-MM-DD'),
 // 当天的iso星期
 isoWeekday: day,
 // 日历dom
 el: this.el,
 // 当前单元格
 tdEl: td,
 // 日期文本
 dateText: text.innerText,
 // 日期class
 dateCls: text.className,
 // 需要注入的额外的html
 extraHtml: '',
 isHeader: false
});

在该事件中,我们将当前渲染的日期、文本 class 等信息提供给用户,这样用户就可以绑定此事件,并在其中进行个性化的处理逻辑。

例如,在渲染时,如果是周末日期,则插入一个“假”标识,并将日期显示为红色。


var calendar = new Calendar();
calendar.on('cellRender', function (e) {
 if(e.isoWeekday > 5 ) {
  e.extraHtml = '<span>假</span>';
  e.dateCls += ' red';
 } 
});

在控件中使用事件机制,不仅可以简化开发流程,使逻辑控制更加清晰,还能为实际使用场景提供极其丰富的个性化操作空间,建议大家尝试使用。

总结

以上就是本文的全部内容,希望本文的内容对大家的学习或工作具有一定的参考价值。如果有疑问,欢迎留言交流,感谢大家对本站的支持。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章