手撕代码之事件委托

0 阅读8分钟

一、题目

请补全 JavaScript 代码,要求如下:

  1. ul 标签添加点击事件
  2. 当点击某 li 标签时,该标签内容拼接 . 符号。如:某 li 标签被点击时,该标签内容为 ..

注意: 必须使用 DOM0 级标准事件(onclick

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        /* 填写样式 */
    </style>
</head>
<body>
    <ul>
        <li>.</li>
        <li>.</li>
        <li>.</li>
    </ul>
    <!-- 填写标签 -->
    <script type="text/javascript">
        // 填写JavaScript
        document.querySelector('ul').onclick = event => {

        }
    </script>
</body>
</html>

二、思路与笔记

1. 事件委托的核心概念

事件冒泡

提到事件委托,首先会先引入一个 事件冒泡 的概念。

事件冒泡 可以去读一读 MDN 的文档,当然在 React 等前端框架的基础下我认为这个可以暂且不提。

核心思想说,事件冒泡在描述一个浏览器对于嵌套元素的事件是如何处理的。

在子元素上面增加事件处理器时,事件会一层层往父级元素冒泡,父级的父级……

MDN 这里有原话是:

在这种情况下:

  • 最先触发按钮上的单击事件
  • 然后是按钮的父元素(<div> 元素)
  • 然后是 <div> 的父元素(<body> 元素)

我们可以这样描述:事件从被点击的最里面的元素 冒泡 而出。

这种行为可能是有用的,也可能引起意想不到的问题。在接下来的章节中,我们将看到它引起的一个问题,并找到解决方案。

对于一些功能我们并不希望冒泡的,会有 stopPropagation(),当然在今天的手撕代码里并不是重点。

事件委托

事件委托

事件冒泡可以实现 事件委托

在这种做法中,当我们想在用户与大量的子元素中的任何一个互动时运行一些代码时,我们在它们的父元素上设置事件监听器,让发生在它们身上的事件冒泡到它们的父元素上,而不必在每个子元素上单独设置事件监听器。

事件委托就是“自己不处理,让祖先元素代为处理”。

事件委托 = 把监听器挂到父级,利用冒泡 + event.target 来统一处理子元素事件。

事件对象中的 target 属性指向 实际触发事件的元素(不是绑定事件的元素)。

具体来说:

你不必给每个子元素单独绑定事件监听器,而是把监听器绑定在它们的 父元素(或更高层祖先) 上。

当子元素上的事件(比如点击)触发后,事件会沿着 DOM 树 向上冒泡,被父元素捕获并执行对应的处理函数。

示例1

假设有一个 <ul> 列表,里面有 1000 个 <li>

<ul id="list">
    <li>项目 1</li>
    <li>项目 2</li>
    <li>项目 3</li>
    ……
</ul>

不用事件委托的做法:

const items = document.querySelectorAll('#list li');
items.forEach(item => {
    item.addEventListener('click', () => {
        console.log('点击了', item.textContent);
    });
});

缺点:

  • 如果动态新增 <li>,新增的项不会有点击事件(除非重新绑定)
  • 性能差(1000 个监听器)

用事件委托的做法:

const parent = document.getElementById('list');
parent.addEventListener('click', (event) => {
    // event.target 是真正被点击的元素
    if (event.target.tagName === 'LI') {
        console.log('点击了', event.target.textContent);
    }
});

优点:

  • 只有 一个 监听器,性能好
  • 动态新增的子元素 自动具备 点击响应
  • 代码更简洁
示例2

场景:一个待办事项列表,我只关心“项目 3”是否被点击

<ul id="list">
    <li id="item-1">项目 1</li>
    <li id="item-2">项目 2</li>
    <li id="item-3">项目 3</li>
    <li id="item-4">项目 4</li>
</ul>

情况1:直接监听(不是事件委托)

const specificLi = document.getElementById('item-3');
specificLi.addEventListener('click', () => {
    console.log('点击了第3项');
});

特征:

  • 监听器直接挂在 item-3 这个 <li>
  • 不依赖冒泡机制
  • 如果 item-3 被删除再重新添加,需要重新绑定
  • 只监听这一个元素

情况2:事件委托(只关心特定子元素)

const parent = document.getElementById('list');
parent.addEventListener('click', (event) => {
    const li = event.target.closest('li');
    if (li && li.id === 'item-3') {
        console.log('点击了第3项');
    }
});

特征:

  • 监听器挂在父元素 <ul>
  • 依赖冒泡机制:子元素点击 → 事件冒泡到 <ul> → 执行回调
  • 即使 item-3 被删除后重新动态添加,仍然自动有效
  • 一个监听器覆盖了所有子元素,但通过条件过滤只处理 item-3

很多人误以为事件委托只能用来批量处理所有子元素(比如给所有 <li> 加点击)。

实际上,事件委托的核心是利用冒泡 + 祖先监听,至于处理哪些子元素,由条件判断灵活控制

// 可以处理特定子元素
if (li.id === 'item-3') { ... }

// 可以处理某一类子元素
if (li.classList.contains('important')) { ... }

// 可以处理所有子元素(最常用)
if (li) { ... }

Event 接口

Event 接口表示在 EventTarget 上出现的事件。

一些事件是由用户触发的,例如鼠标或键盘事件;或者由 API 生成以表示异步任务的进度。事件也可以通过编程方式触发,例如对元素调用 HTMLElement.click() 方法,或者定义一些自定义事件,再使用 EventTarget.dispatchEvent() 方法将自定义事件派发往指定的目标(target)。

有许多不同类型的事件,其中一些使用基于 Event 主接口的其他接口。Event 本身包含适用于所有事件的属性和方法。

很多 DOM 元素可以被设计接收(或者监听)这些事件,并且执行代码去响应(或者处理)它们。通过 EventTarget.addEventListener() 方法可以将事件处理器绑定到不同的 HTML 元素上(比如 <button><div><span> 等等)。这种方式基本替换了老版本中使用 HTML 事件处理器属性的方式。此外,在正确添加后,还可以使用 removeEventListener() 方法移除这些事件处理器。

备注: 一个元素可以绑定多个事件处理器,甚至是对于完全相同的事件。尤其是相互独立的代码模块出于不同的目的附加事件处理器。(比如,一个网页同时有着广告模块和统计模块同时监听视频播放。)

当有很多嵌套的元素,每个元素都有着自己的事件处理器,事件处理过程会变得非常复杂。尤其当一个父元素和子元素绑定完全相同的事件时,因为结构上的重叠,事件在技术层面发生在两个元素中,触发的顺序取决于每个处理器的事件冒泡的设置。


2. Event.target 属性

Event 接口的 target 只读属性是对事件被分派到的对象的引用。当事件处理器在事件的冒泡或捕获阶段被调用时,它与 Event.currentTarget 不同。

event.target 属性可以用于实现 事件委托

  • event.target实际被点击的那个元素,不是绑定事件的元素。
  • ul.onclick,点击 li 时,event.target 就是那个 li,不是 ul。

在 JS 里,对象传递的就是引用(内存地址的拷贝),你可以直接修改它。

MDN 中有这个示例代码:

// 创建列表
const ul = document.createElement("ul");
document.body.appendChild(ul);

const li1 = document.createElement("li");
const li2 = document.createElement("li");
ul.appendChild(li1);
ul.appendChild(li2);

function hide(evt) {
    // evt.target 指向被点击的 <li> 元素
    // 这与 evt.currentTarget 不同,后者在这个上下文中将指向父级 <ul>
    evt.target.style.visibility = "hidden";
}

// 将监听器附加到列表上
// 点击每个 <li> 时都会触发
ul.addEventListener("click", hide, false);

3. Node.textContent 属性

Node 接口的 textContent 属性表示一个节点及其后代的文本内容。

textContent 的 MDN 文档,它 既可以读,也可以写

innerHTML 的区别

正如其名称,Element.innerHTML 返回 HTML。通常,为了在元素中检索或写入文本,人们使用 innerHTML。但是,textContent 通常具有更好的性能,因为文本不会被解析为 HTML。

此外,使用 textContent 可以防止 XSS 攻击

textContent 获取的是元素内的纯文本内容 string,会过滤掉所有 HTML 标签,只返回文字部分。设置时,内容会作为普通文本插入,不会被解析成 HTML 标签。


4. Element.tagName 属性

注意 tagName 获取当前元素标签名。

if (被点击的元素是 li) {
    获取当前文本内容
    新内容 = 当前内容 + "."
    把新内容设置回去
}

自己乱写了一波

if (event.target.tagName === 'li') {
    let text = event.target.texContent ;
    let newText = text + '.';
    // 不知道怎么设置回去
    event.target.textContent = newText;
}

注意这个不知道怎么设置回去就是依赖 event.target.textContent 是可以读也可以写的。

语法

elementName = element.tagName
  • elementName 是一个字符串,包含了 element 元素的标签名。

备注

在 XML(或者其他基于 XML 的语言,比如 XHTML, xul)文档中,tagName 的值会保留原始的大小写。

比如 span 会返回 SPAN,那在判定的时候要写成大写的。

在 HTML 文档中,tagName 会返回其大写形式。对于元素节点来说,tagName 属性的值和 nodeName 属性的值是相同的。


三、解法

1. 思路推导

根据以上笔记,解题思路如下:

  • 使用 DOM0 级事件 onclick 给 ul 绑定点击事件
  • 通过 event.target 获取实际点击的元素
  • 通过 tagName 判断点击的是否为 li 元素
  • 通过 textContent 读取并修改 li 的内容

2. 最终代码

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        /* 填写样式 */
    </style>
</head>
<body>
    <ul>
        <li>.</li>
        <li>.</li>
        <li>.</li>
    </ul>
    <!-- 填写标签 -->
    <script type="text/javascript">
        document.querySelector('ul').onclick = event => {
            let eventName = event.target.tagName;
            if (eventName === 'LI') {
                let eventText = event.target.textContent;
                event.target.textContent = eventText + '.';
            }
        }
    </script>
</body>
</html>

四、小结

还是比较简单的,第一次手撕有点没有习惯这个写法,具体思路很清晰,多查查 API,多看 MDN 的示例。

参考链接