阅读视图

发现新文章,点击刷新页面。

面试官:说说事件冒泡与委托?这是我见过最透彻的回答

在JavaScript的世界里,事件监听是我们与用户交互的基础。但你是否遇到过这样的困惑:为什么点击了子元素,父元素的点击事件也跟着触发了?或者,当列表里有1000个按钮时,如何优雅地处理点击而不让页面卡死?

今天,我们就从你提供的两段代码出发,深入剖析事件流、事件委托、stopPropagation,最后带你看看React是如何利用这些原理“秀操作”的。


一、事件的“旅行”:事件流与冒泡

首先,我们要建立一个核心概念:事件不仅仅是“发生”在某个元素上,它是一场“旅行”。

当一个点击事件发生时,浏览器内部会经历三个阶段,这就是事件流

  1. 捕获阶段:事件从document根节点出发,像水流一样层层向下渗透,直到目标元素。
  2. 目标阶段:事件到达了实际被点击的元素(event.target)。
  3. 冒泡阶段:事件从目标元素出发,反向冒泡,一层层向上传播回document

看个例子(基于你的2.html):

想象一个红色的盒子(parent)里装着一个蓝色的盒子(child)。

document.getElementById('parent').addEventListener('click', function() {
  console.log('parent click');
}, false) // 默认false,代表在冒泡阶段执行

document.getElementById('child').addEventListener('click', function() {
  console.log('child click');
}, false)

当你点击蓝色的child时,控制台会依次输出:child click -> parent click

这就是事件冒泡。事件首先在child上触发,然后“冒泡”到父级parent,甚至继续冒泡到body(你的代码里body上还有个alert('橘子'),所以最后还会弹窗)。

为什么要了解这个? 因为绝大多数时候,我们利用的就是这个“冒泡”机制。


二、性能救星:事件委托

回到你的1.html,假设你有一个包含100个<li>的列表。

❌ 传统做法(笨重):

const lis = document.querySelectorAll('#list li');
for (let i = 0; i < lis.length; i++) {
  lis[i].addEventListener('click', function() { ... })
}

这种做法的问题在于内存开销。100个监听器就是100份内存消耗。如果列表是动态生成的,你还得不断地去绑定新元素的事件,非常麻烦。

✅ 事件委托(优雅):
利用冒泡原理,我们只需要在父元素<ul>上绑定一个监听器,就能管理所有子元素!

document.getElementById('list').addEventListener('click', function(event) {
  // event.target 指向实际被点击的那个 li
  console.log(event.target, event.target.innerHTML);
});

这就像什么?
就像小区的门卫。你不需要给每家每户(li)都配一个保安,只需要在小区大门口(ul)安排一个保安。谁进来了(事件冒泡上来了),保安看一眼event.target(身份证),就知道是谁。

这样做的好处:

  1. 减少内存消耗:不管有多少个li,只需要一个监听器。
  2. 自动支持动态元素:如果你后来用JS往列表里加了一个新的<li>,它不需要重新绑定事件,点击它依然会冒泡到ul被处理。

三、掌控雷电:stopPropagation

有时候,我们不希望事件冒泡。比如在做一个模态框,点击遮罩层关闭,但点击内容区不想关闭。

这时就需要用到e.stopPropagation()

document.getElementById('child').addEventListener('click', function(event) {
  event.stopPropagation(); // 关键代码:在这里“截断”事件
  console.log('child click');
}, false)

加上这行代码后,点击child,事件处理完就结束了,不会继续向上传递给parent,也就不会触发parent的点击事件,更不会出现body上的alert('橘子')

注意: 还有一种情况是useCapture(捕获)。addEventListener的第三个参数默认为false(冒泡)。如果设为true,事件就会在捕获阶段(从上往下)被触发。这在某些特殊场景(如想要最早拦截事件)非常有用。


四、最佳实践:就近原则

在使用事件委托时,有一个“就近原则”。

虽然我们可以把事件委托给document(在根节点监听所有点击),但不建议这么做。

为什么?
如果委托给document,每次点击页面任何地方,事件都要冒泡到最顶层,浏览器需要遍历的路径最长,增加了判断成本。

建议:
委托给距离目标元素最近的父级。比如在ul上代理li,而不是在document上代理li。这样既享受了委托的性能红利,又控制了事件传播的范围。


五、进阶引申:React的合成事件

如果你学过React,你会发现React的事件系统正是基于这些原理构建的。

React并没有给每个DOM节点绑定原生的addEventListener。相反,React实现了一套**合成事件(SyntheticEvent)**系统。

它的核心原理就是:

  1. 全局委托:React 17及以后,将所有事件统一委托到了挂载容器的根节点(React 16及以前是document)。
  2. 统一分发:当原生事件冒泡到根节点时,React会捕获它,然后根据组件树的结构,手动分发给对应的组件事件处理函数。

这样做的好处:

  • 性能极致:无论你的应用有多少个按钮,原生监听器只有一个。
  • 跨浏览器兼容:React抹平了不同浏览器(如Chrome和Firefox)对事件对象实现的差异,让你在任何浏览器拿到的e对象都是一样的。

总结

  • 事件流:捕获 -> 目标 -> 冒泡。理解它是理解一切的基础。
  • 事件委托:利用冒泡,将监听器绑定在父元素上,通过event.target识别目标。省内存、支持动态DOM。
  • stopPropagation:阻止事件继续冒泡,防止父级元素“误触”。
  • React启示:现代框架的高性能,往往就建立在这些基础原理的巧妙运用之上。

下次再写列表循环绑定时,记得停下来想一想:能不能用事件委托优化一下?

面试官问SSE和WebSocket的区别?看这篇就够了(含心跳机制详解)

最近在复习计算机网络和 LLM 相关技术时,我突然意识到一个很有意思的现象:现在的 AI 聊天大多用的是 SSE,但提到真正的实时互动,还得看 WebSocket

为了搞懂这玩意儿,我手写了一个简易版的聊天室。今天就把我的学习笔记、代码实现,还有那些让人头秃的协议对比,一次性全掏出来!


🤔 为什么 HTTP 不适合聊天?

咱们先聊聊背景。作为前端,我们最熟悉的是 HTTP 协议。

HTTP 就像是一个“高冷”的客服

  • 你问一句(Request),它答一句(Response)。
  • 答完就挂电话(短连接),下次想问得重新拨号。

如果你想做一个聊天室,用 HTTP 怎么办?只能靠轮询

// 每隔 1 秒问一次服务器:“有新消息吗?有新消息吗?”
setInterval(() => {
  fetch('/api/messages').then(...)
}, 1000);

这太蠢了,对吧?性能差,延迟高,服务器都要被问烦了。

SSE 呢?SSE 适合 LLM 那种“流式输出”(我一次提问,它一直吐字),它是单向的。但聊天是双向的,我要发,你也要发。

所以,我们需要一个能建立“长连接”、双方都能主动说话的协议——WebSocket


💡 WebSocket:一次握手,终身相伴

WebSocket 是 HTML5 提供的一种在单个 TCP 连接上进行全双工通讯的协议。

  • 全双工:就像打电话,双方都可以同时说话,不需要等对方说完。
  • 长连接:一旦建立,除非主动断开,否则一直连着。

📝 核心代码逻辑拆解

这里我用 Koa + koa-websocket 来实现。为了让大家看得更清楚,我把代码拆成三个关键步骤来讲。

步骤一:搭建舞台(服务端初始化)

首先,我们需要让 Koa 具备处理 WebSocket 的能力,并准备一个“花名册”来记录所有连进来的用户。

javascript

编辑

const Koa = require('koa');
const websocket = require('koa-websocket');

// 1. 初始化 Koa 并赋予 WebSocket 能力
const app = websocket(new Koa());

// 2. 准备一个 Set 集合,用来存储所有连接的客户端
// 为什么用 Set?因为我们要保证连接对象的唯一性
const clients = new Set();

步骤二:派发请柬(处理 HTTP 请求)

WebSocket 连接通常是从一个网页开始的。所以,我们需要一个普通的 HTTP 中间件,返回给浏览器一个包含聊天界面的 HTML 页面。

javascript

编辑

// 3. 处理普通 HTTP 请求:返回我们的聊天页面
app.use(async (ctx) => {
    ctx.body = `
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <title>WebSocket Chat</title>
    </head>
    <body>
       <div id="messages" style="height:300px;overflow-y:scroll; border:1px solid #ccc;"></div>
        <input type="text" id="messageInput" placeholder="输入消息..."/>
        <button onclick="sendMessage()">发送</button>
        <script>
            // 核心在这里:前端建立 WebSocket 连接
            // 注意协议是 ws:// 而不是 http://
            const ws = new WebSocket('ws://localhost:3000/ws');
            
            // 监听服务器发来的消息
            ws.onmessage = function(event){
                const messageDiv = document.getElementById('messages');
                messageDiv.innerHTML += '<div>' + event.data + '</div>';
            }
            
            function sendMessage(){
                const input = document.getElementById('messageInput');
                ws.send(input.value); // 发送消息
                input.value = '';
            }
        </script>
    </body>
    </html>
    `
})

步骤三:建立专线(处理 WebSocket 连接)

这是最关键的一步。当浏览器执行了 new WebSocket() 后,服务器会通过 app.ws.use 捕获到这个连接请求。

这里我们主要做三件事:登记用户监听消息广播消息

javascript

编辑

// 4. 处理 WebSocket 连接
app.ws.use(async (ctx) => {
    // A. 登记:将当前连接加入集合
    clients.add(ctx.websocket);
    console.log('当前在线人数:', clients.size);

    // B. 监听:当收到某人的消息时
    ctx.websocket.on('message', message => {
        // C. 广播:把这条消息发给“花名册”里的每一个人
        for(const client of clients){
            client.send(message.toString());
        }
    });

    // D. 离场:监听断开连接,把人从花名册里删掉
    ctx.websocket.on('close', () => {
        clients.delete(ctx.websocket);
        console.log('有人离开了...');
    });
})

app.listen(3000, () => {
    console.log('🚀 服务器启动,请访问 http://localhost:3000');
});

运行效果:
打开浏览器访问 localhost:3000,你可以打开好几个标签页,在一个标签页发消息,所有标签页都会实时收到消息!这就是广播

image.png


📊 一张表看懂 HTTP、SSE 与 WebSocket

为了面试(408 计算机网络)和工作,这三个协议的区别必须门儿清:

特性 HTTP SSE WebSocket
连接方式 短连接 (请求-响应) 长连接 (单向推送) 长连接 (双向通讯)
通讯方向 客户端发起 服务端 -> 客户端 客户端 <-> 服务端
适用场景 网页加载、API 请求 AI 流式输出、股票行情 聊天室、即时游戏、协作编辑
数据格式 文本/JSON/二进制 仅限文本 (text/event-stream) 二进制帧/文本帧
  • SSE:适合“我不动,你推给我”的场景(比如 LLM 打字机效果)。
  • WebSocket:适合“你一句我一句”的场景。

❤️ 心跳机制:长连接的“异地恋”哲学

既然 WebSocket 是长连接,那就面临一个现实问题:网络是不稳定的

路由器重启、手机进电梯、防火墙拦截……都可能导致连接“静默断开”。这时候,客户端以为连着,服务器以为断了,这就尴尬了。

怎么解决?—— 心跳机制

这就好比异地恋的情侣

你们不能一直打电话(开销太大),但必须定期确认对方还在。

  • 客户端:“宝,你在吗?”(Ping)
  • 服务端:“在呢,活着呢。”(Pong)

如果客户端发了 Ping,过了 30 秒还没收到 Pong,那就判定为“分手”(连接断开),然后触发重连机制

代码逻辑示意:

// 客户端
setInterval(() => {
    if(ws.readyState === WebSocket.OPEN){
        ws.send(JSON.stringify({type: 'ping'}));
    }
}, 30000); // 每30秒问候一次

// 服务端
ws.on('message', (msg) => {
    const data = JSON.parse(msg);
    if(data.type === 'ping'){
        ws.send(JSON.stringify({type: 'pong'})); // 秒回
    }
});

📌 总结

今天我们从 HTTP 的局限性出发,手搓了一个基于 Koa 的 WebSocket 聊天室,顺便复习了 SSE 和心跳机制。

划重点:

  1. HTTP 是“一问一答”,WebSocket 是“双向奔赴”。
  2. SSE 适合流式输出(AI),WebSocket 适合即时通讯(Chat)。
  3. 心跳机制是长连接保活的关键,防止“假死”连接。

希望这篇文章能帮你搞定 WebSocket!如果觉得有用,记得点个赞 👍,我们下期见!

❌