本文深入解析如何在 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';
}
});
在控件中使用事件机制,不仅可以简化开发流程,使逻辑控制更加清晰,还能为实际使用场景提供极其丰富的个性化操作空间,建议大家尝试使用。
总结
以上就是本文的全部内容,希望本文的内容对大家的学习或工作具有一定的参考价值。如果有疑问,欢迎留言交流,感谢大家对本站的支持。