js 事件,事件流和事件委托

事件

JavaScript与HTML之间的交互是通过事件实现的。什么是事件?事件就是文档或浏览器窗口中发生的一些特定的交互瞬间。可以使用侦听器或处理程序来预定事件,以便事件发生时执行相应的代码。这种在传统软件工程中被称为观察员模式的模型,支持页面的行为(JavaScript代码)与页面的外观(HTML和CSS代码)之间的松散耦合。事件最早是在IE3和Netscape Navigator 2中出现的,当时是作为分担服务器运算负载的一种手段。

DOM0级事件处理程序

通过JavaScript指定事件处理程序的传统方式,就是将一个函数赋值给一个事件处理程序属性。这种为事件处理程序复制的方法实在第四代Web浏览器中出现的,而且至今任然为所有现代浏览器所支持。原因一是简单,二是具有跨浏览器的优势。使用方式如下:

// 直接在HTML代码中添加事件处理程序
<button id="btn" type="button" onclick="print()"></button>

const btn = document.getElementById('btn');
// 添加事件处理程序
btn.onclick = print;
function print() {
    console.log('click');
}

// 删除事件处理程序
btn.onclick = null;
复制代码

DOM2级事件处理程序

DOM2级事件定义了两个方法,用于处理指定和删除事件处理程序的操作:addEventListener()removeEventListener()。所有DOM节点都包含这两个方法,并且它们都接受3个参数:要处理的事件名,作为事件处理程序的函数和一个布尔值。最后这个布尔值参数如果是true,表示在捕获阶段调用事件按处理程序;如果是false,表示在冒泡阶段调用事件处理程序。默认false。使用方式如下

const btn = document.getElementById('btn');
// 添加事件处理程序
btn.addEventListener('click', print, false);
// 删除事件处理程序
btn.removeEventListener('click', print, false);
function print() {
    console.log('click');
}
复制代码

IE事件处理程序

IE实现了与DOM中类似的两个方法:attachEvent()和detachEvent()。这两个方法接受相同的两个参数:事件处理程序名称与事件处理程序函数。由于IE8及更早版本只支持事件冒泡,所以通过attachEvent()添加的事件处理程序都会被添加到冒泡阶段。使用方法如下:

var btn = document.getElementById('btn');
// 添加事件处理程序
btn.attachEvent('onclick', print);
// 删除事件处理程序
btn.detachEvent('onclick', print);
function print() {
    console.log('click');
}
复制代码

事件流

当浏览器发展到第四代时(IE4及Netscape Communicator4),浏览器开发团队遇到了一个很有意思的问题:页面的哪一部分会拥有某个特定的事件?要明白这个问题问的是什么,可以想象画在一张纸上的一组同心圆。如果你把手指放在圆心上,那么你的手指指向的不是一个圆,而是纸上的所有圆。两家公司的浏览器开发团队在看待浏览器事件方面还是一致的。如果你单击了某个按钮,他们都认为单击事件不仅仅发生在按钮上。换句话说,在单击按钮的同时,你也单击了按钮的容器元素,甚至也单击了整个页面。事件流描述的是从页面中接收事件的顺序。但有意思的是,IE和Netscape开发团队居然提出了截然相反的事件流概念。IE的事件流是事件冒泡流,而Netscape Communicator的事件流是事件捕获流。结合MDN官方示意图方便理解。

eventflow.png

由上图可知,DOM2级事件规定的事件流包括三个阶段,分别是:

  1. 捕获阶段:事件对象通过目标的祖先从Window传播到目标的父级。此阶段也称为捕获阶段。
  2. 目标阶段:事件对象到达事件对象的事件目标。此阶段也称为在目标阶段。如果事件类型表明该事件没有冒泡,则该事件对象将在此阶段完成后停止。
  3. 冒泡阶段:事件对象以相反的顺序通过目标的祖先传播,从目标的父级开始,到Window结束。这个阶段也称为冒泡阶段。

事件委托

由于事件处理程序可以为现代Web应用程序提供交互能力,因此许多开发人员会不分青红皂白的向页面中添加大量的事件处理程序。试想一下,如果一个列表包含成百上千条列表项,我们给每一条列表项都添加一个事件处理程序。这将会导致“事件处理程序过多”的问题。对于此类问题,解决方案就是事件委托事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。举个例子:

<ul id="list">
    <li class="item" data-id="0">列表项1</li>
    <!-- ... -->
    <li class="item" data-id="99">列表项100</li>
</ul>

// 传统实现方式
const list = document.querySelectorAll('.item');
// 循环为每一个列表项添加事件处理程序
for (const item of list) {
    item.addEventListener('click', function (e) {
        console.log(this.dataset.id);
    });
}

// 使用事件委托
const ul = document.querySelector('#list');
// 将事件处理程序添加到父级元素上
ul.addEventListener('click', function (e) {
    if(e.target.tagName === 'LI'){
        console.log(e.target.dataset.id);
    }
});
复制代码

以上就是最简单的事件委托方式,通过将事件处理程序添加到祖先元素上以减少不必要的内存开销。但有的小伙伴就要说了,你这个dom层级太单一了,如果我dom层级嵌套的比较深。例如:li标签包含一个div标签,而div标签中又包含span标签。这种情况怎么办呢?别急,面对这种情况,我们要找到问题的核心。认真思考一下不难发现,本质上就是判断触发事件的元素是否是绑定事件元素本身或者其子元素。怎么样?这样一分析,问题是不是就迎刃而解了。判断元素之间的包含关系将用到Node.contains()。具体实现如下:

<ul id="list">
    <li class="item" data-id="0">
        <div>
            <span>列表项1</span>
        </div>
    </li>
    <!-- ... -->
    <li class="item" data-id="99">
        <div>
            <span>列表项100</span>
        </div>
    </li>
</ul>

// 获取dom
const ul = document.querySelector('#list');
// 将事件处理程序添加到父级元素上
ul.addEventListener('click', function (e) {
    if (e.target === this) return;
    let target = e.target;
    // 递归找到列表项
    while (true) {
        if (target.parentNode === this) break;
        target = target.parentNode;
    }
    // 判断包含关系
    if (target.contains(e.target)) {
        console.log(target.dataset.id);
    }
});
复制代码

那又有小伙伴说了,我想像jQuery的on方法那样,支持传入多个选择器,该如何实现呢?其实也不难的,只需要用到
Element.matches() 判断元素是否被指定的选择器字符串选择。

实现

纸上得来终觉浅,绝知此事要躬行。

话不多说,直接上代码。

<ul id="list">
    <li class="item">
        <div>
            <span>白月魁</span>
            <button class="follow" type="button">关注</button>
        </div>
        <p>随便写点内容...</p>
    </li>
    <!-- ... -->
    <li class="item">
        <div>
            <span>冉冰</span>
            <button class="follow" type="button">关注</button>
        </div>
        <p>随便写点内容...</p>
    </li>
</ul>

// 获取dom
const ul = document.querySelector('#list');
// 将事件处理程序添加到父级元素上
ul.addEventListener('click', delegate('li.item,button.follow', function (e) {
    if (e.triggerTarget.className === 'follow') {
        // 点击关注按钮
        // 阻止冒泡,不执行父级元素事件
        e.stopPropagation();
    } else if (e.triggerTarget.className === 'item') {
        // 点击列表项
    }
    console.log(e.triggerTarget);
}));

/**
 * 事件委托
 * @param selector string
 * @param func function
 * @returns
 */
function delegate(selector, func) {
    return function (event) {
        if (event.target === this) return;
        let target = event.target;
        const selectorList = selector.split(',');
        // 递归找到列表项
        while (true) {
            for (let i = 0; i < selectorList.length; i++) {
                // 判断元素是否被指定的选择器字符串选择
                if (target.matches(selectorList[i])) {
                    // 给event对象添加triggerTarget属性,表示触发事件的元素
                    event.triggerTarget = target;
                    // 删除已匹配的选择器,减少不必要的循环
                    selectorList.splice(i, 1);
                    // this指向事件委托元素
                    func.call(this, event);
                    break;
                }
            }
            target = target.parentNode;
            // 阻止冒泡
            if (this === target || event.cancelBubble) break;
        }
    }
}
复制代码

一个简单的Demo效果图。点击点赞,评论,转发按钮分别执行对应事件,点击其他元素则响应列表项点击事件。

GIF 2021-06-23 16-10-32.gif

写在最后

文章部分概念的解释摘抄自《JavaScript高级程序设计(第3版)》。如果大家对这一块的知识想更深入的了解和系统性的学习,就去翻翻这本书吧。

再说最后一句:文章动图使用的是GifCam制作的。软件很小巧,只有1.58MB。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享