§ 1.5 · Section

事件驱动

Event-Driven · Who Is in Charge of the Flow

这是学界面编程最需要转的那个弯:写普通程序时,流程由你控制;写界面程序时,流程由用户控制。你不再是"指挥官",而变成了"值班员"——坐在那里等事情发生,事情来了就处理,没事就闲着。这个思维反转,比任何 API 都重要。

生活场景
🔔 前台服务员 vs 流水线工人

流水线工人的一天是写死的:装第一个零件、装第二个、拧螺丝、贴标签、下一件。顺序固定,从头跑到尾,跑完下班。
酒店前台的一天完全不同:他不知道接下来会发生什么。可能来客人办入住,可能电话响,可能有人来问路,可能什么都不发生、闲坐十分钟。
他的工作模式是:盯着,来事就处理,处理完继续盯着。而且有一条铁律——处理任何一件事都不能占太久,否则后面排队的客人就全被卡住了。

先把这一节的词全翻成人话

这一节的概念最抽象,所以术语必须先钉死。每个词后面那句括号才是你要记的东西。

命令式脚本 vs 事件驱动:根本区别在哪

先看一段最普通的脚本。它体现的是"命令式"思维:我说一步,机器做一步,做完就结束。

# 命令式脚本:控制权在我手上,从上到下跑完就退出

data  = read_file("orders.csv")      # 1. 读文件
clean = remove_duplicates(data)      # 2. 去重
total = sum(row.amount for row in clean)  # 3. 求和
print("总金额:", total)              # 4. 输出
# 程序结束 —— 谁也没法"插话"

这段代码的特点是:执行顺序在写代码的时候就完全确定了。你读代码,从第一行读到最后一行,就知道程序的一生。中途没有任何"外人"能插手。

现在看界面程序。它的结构完全变了:

# 事件驱动:我只登记"什么情况下做什么",然后把控制权交出去

button_save.on_click   = lambda: save_file()          # 登记:点保存 → 存盘
button_cancel.on_click = lambda: window.close()       # 登记:点取消 → 关窗
input_name.on_change   = lambda t: validate_name(t)   # 登记:改文字 → 校验
window.on_resize       = lambda w, h: relayout(w, h)  # 登记:窗口变了 → 重排

app.run()   # ← 关键的一行:控制权从此交给框架,它会一直循环等事件

差别在哪?代码的书写顺序,不再等于执行顺序

上面四行登记代码,谁先执行完全取决于用户先点了什么。用户可能先改名字、再点保存、再改窗口大小;也可能进来直接点取消,那么另外三个函数一次都不会执行。你在写代码的时候,根本不知道也不需要知道它们的执行顺序。

这带来一个非常重要的心态转变:你的代码从"一段流程"变成了"一堆规则"。你不再描述"先干什么后干什么",而是描述"如果发生 A,就做 X;如果发生 B,就做 Y"。程序的实际行为,是这些规则被用户以随机顺序触发出来的结果。

Analogy · 菜谱 vs 消防队值班表

命令式脚本像菜谱:热锅、下油、放葱姜、倒肉、翻炒三分钟、加盐、出锅。步骤固定,照做就行,顺序错了菜就毁了。
事件驱动像消防队的值班规程:接到火警 → 出车;接到猫上树 → 派云梯;接到误报 → 记录挂断。
没人能预知今天会来几个电话、什么顺序来。消防队的工作不是"跑一遍流程",而是保持待命,随时响应
而且值班规程里最重要的一条往往是:不许一个人占着总机不放——这就是后面要讲的"主线程不能阻塞"。

事件循环:整个界面世界的心跳

那么"等事件"这件事,在代码层面到底是怎么实现的?答案朴素到令人意外:一个永不结束的 while 循环

这个循环叫事件循环(Event Loop),也叫消息循环、主循环。你调用的 app.run()mainloop()app.exec(),进去以后就是它。所有 GUI 程序,从 1984 年的 Macintosh 到今天的浏览器和手机 App,骨架都是这个循环。

// 事件循环的本质(所有 GUI 框架的核心,简化版)

while (程序还没被要求退出) {

    event = 事件队列.取出下一个();     // ① 取事件(队列空就在这儿睡着,不耗 CPU)

    if (event == null) continue;

    target = 根据坐标或焦点.找到目标控件(event);   // ② 分发 Dispatch

    target.处理(event);              // ③ 处理 Handle —— 你写的回调在这里被调用

    if (有控件被标记为"脏了") {
        重新布局();
        重新绘制();                   // ④ 重绘 Repaint
        提交给屏幕();
    }
}

四个步骤,逐个说清楚。

① 取事件。操作系统会把键盘、鼠标、触摸、窗口变化等硬件和系统消息,塞进属于你这个程序的一个队列里。循环每一轮从队头拿一个。注意:队列空的时候,循环不是在疯狂空转,而是被操作系统挂起休眠——所以一个没人操作的窗口,CPU 占用是 0%。这也是为什么你的编辑器开着一整天也不烫手。

② 分发。拿到一个"鼠标在 (420, 310) 处按下",框架得回答:这个坐标落在谁身上?于是它顺着上一节讲的控件树从根往下找,找到最深的那个覆盖该坐标的控件。键盘事件不看坐标,看焦点——谁持有焦点就给谁。

③ 处理。目标控件先自己处理(比如按钮把自己画成按下状态),然后调用你注册的回调。如果它不处理或者处理完不拦着,事件还会顺着树往上冒泡给父控件,直到有人"消费"掉它。

④ 重绘。回调可能改了状态(文字变了、颜色变了、多了一行)。框架不会立刻重画——那太浪费了。它只是给受影响的控件打个"脏"标记,等这一轮事件处理完,一次性把所有脏区域重新布局、绘制、送屏。这叫批量重绘,是性能的关键。

然后回到 ①,继续。这个循环每秒转几十到几百轮,界面的"活着"就是它在转

把事件循环搬进办事大厅:取号排队处理

上面那段伪代码有点干。我们把它整个搬进一间政务服务大厅,你会发现每一行都能对上一个现实动作。

代码里的那一步大厅里对应什么关键细节
事件被塞进队列市民在门口取号机取一张号,然后坐着等取号是操作系统干的,不是你的程序干的。你只管从队头拿
事件队列.取出下一个()办事员按叫号器:"请 A032 号到 3 号窗口"严格按先来后到。没人排队时,办事员是趴着睡,不是空转打转
分发到目标控件看单子上写的业务类型,分给对应的科室鼠标事件按"点在哪儿"分,键盘事件按"焦点在谁手上"分
target.处理(event)那个科室真正把这件事办掉——盖章、录入、打凭证这一步是同步的:这个人没办完,办事员绝不叫下一号
批量重绘这一轮办完后,统一更新大厅那块显示屏不是每办一件就刷一次屏,而是攒一攒一次性刷。这是性能的关键

这个类比里有三个细节,恰好对应三个初学者最容易搞错的点,值得逐个说清。

第一,队列空的时候,程序不烧 CPU。很多人以为 while (true) 就意味着 CPU 一直被占满。其实不是:取事件那一步在队列空时会被操作系统挂起休眠,直到有新事件才被唤醒。换成大白话:办事员没人来的时候是趴着睡,不是原地跑圈。所以你的编辑器开一整天不动它,CPU 占用是 0%,笔记本也不烫。

第二,一次只办一件,没有插队。大厅里那个办事员是单人,所以第 4 行的"办掉"必须办完才叫下一号。这就是后面那节"主线程不能阻塞"的全部原因——不是因为程序脆弱,是因为窗口只有一个。

第三,显示屏是攒着刷的。假设一轮里同时发生了"文字变了、颜色变了、多了一行"三件事。框架不会画三遍,而是给这几个地方打上"待更新"的标记,等这一轮事件全处理完,一次性重画。好比办事大厅不会每办完一个人就重印一次公告板,而是每隔一小会儿统一更新一次。这个技巧的学名叫批量重绘,省下的开销非常可观。

事件的三个阶段:公文层层下发再层层上报

现在讲一个几乎所有人第一次都会绕晕的机制:一个点击事件,从屏幕传到你的代码,中间要走三个阶段

先说为什么需要三个阶段。上一节讲过界面是一棵控件树:窗口里套着面板,面板里套着卡片,卡片里套着按钮。现在你点了那个按钮——问题是,除了按钮本身,外面那三层要不要知道这件事?

答案往往是"要"。比如点卡片里任意位置都该算点了整张卡片;比如点了页面上任何地方,弹出的下拉菜单都该收起来。所以事件不能只送给最里面那个控件,得让整条路径上的人都有机会知道。于是有了这套三段式:

阶段方向大白话公文类比
捕获(Capture)从最外层往里从窗口 → 面板 → 卡片 → 按钮,一层层往下问"你要不要先管一下这件事"公文层层下发:总部收到文件,转给分公司,分公司转给部门,部门转给小组
目标(Target)停在被点中的那个真正被点中的那个按钮,它是这件事的主角文件到了具体经办人手里,他是真正要干活的人
冒泡(Bubble)从里面往外从按钮 → 卡片 → 面板 → 窗口,一层层往上报"这件事发生过了"办完层层上报:经办人办完汇报给小组,小组报部门,部门报总部
你点了最里面那个按钮,事件走的完整路线:

  window                ① 捕获 ↓                 ⑦ 冒泡 ↑
    └── panel           ② 捕获 ↓                 ⑥ 冒泡 ↑
          └── card      ③ 捕获 ↓                 ⑤ 冒泡 ↑
                └── button        ④ 到达目标(Target)

顺序:window → panel → card → button → card → panel → window
        └────── 往下发文 ──────┘   └───── 往上汇报 ─────┘

为什么"冒泡"这个词?因为它就像一杯水里从底下冒起来的气泡:从最深处生成,一路往上,最后到水面。事件也是从最深的那个控件出发,一层层往外浮。

实际开发中,百分之九十的时候你只用得到冒泡阶段,捕获阶段是留给"我要在事情办成之前先拦一下"的特殊场合。这也符合公文类比:下发阶段各级领导通常只是转发,真正的动作发生在经办人手里;但如果某一级觉得"这事儿不能往下发",他也有权在下发阶段就截住。

顺带解释两个非常容易混的方法名:

这两件事完全独立,很多 bug 就来自把它们搞混:阻止冒泡管的是"事件还往不往上走",阻止默认行为管的是"系统自带的那个动作做不做"。一个管路线,一个管动作。

事件对象里装了什么:单子上写着的那些字段

事件不只是"发生了"这三个字,它是一张填好了内容的单子。你的回调函数拿到的那个参数,就是这张单子。看看上面写了些什么:

字段装的是什么对应单子上哪一栏
type事件类型,比如 click、keydown、resize业务类型:办居住证还是办社保
target真正被点中的那个最里层控件具体经办人是谁
currentTarget当前正在处理这个事件的那一层(会随冒泡变化)这张单子此刻在谁手上
clientX / clientY鼠标在窗口里的坐标发生地点的精确位置
key / code按了哪个键(前者是字符,后者是物理位置)如果是键盘事件,按的是哪个键
ctrlKey / shiftKey / altKey点击的同时有没有按住这些修饰键附加条件:加急、代办、免费办
timeStamp发生的时刻受理时间,用来算双击间隔或防抖
button按的是左键、中键还是右键用哪只手、从哪个窗口递进来的

targetcurrentTarget 的区别是最经典的困惑点,用公文类比一句话就清楚了:target 是"这事儿是谁引起的"(固定不变),currentTarget 是"这张单子现在在谁手上"(一路往上传,一路在变)。

举一个具体场景说明它为什么有用。你有一个列表,一百行,每行都能点。笨办法是给一百行各挂一个监听器;聪明办法是只在整个列表上挂一个,事件冒泡上来时,看 target 是哪一行,就知道用户点了第几行。这个技巧后面有一节专门讲,叫事件委托。

回调函数与监听器:把代码"寄存"起来

事件驱动能成立,靠的是一个语言机制:函数本身可以被当成数据传来传去

你写 button.onClick = saveFile,注意这里 saveFile 后面没有括号——你不是在"调用"它,而是把这个函数本身交给按钮保管。按钮把它存起来,等到真的被点击的那一刻,才由框架替你调用。这种"我交出去、别人在合适的时机替我调用"的函数,就叫回调函数(Callback)

监听器(Listener / Observer)是回调的组织形式。它解决了一个实际问题:一个事件可能有多个人关心。比如"文档被修改了"这件事,标题栏想加个星号、自动保存想重置计时器、撤销栈想记一笔——三个互不相干的模块。于是框架允许挂多个回调,事件发生时按顺序全都通知一遍。

// 一个事件挂多个监听器,互不干扰

doc.addEventListener('change', () => titleBar.showModifiedMark());
doc.addEventListener('change', () => autoSave.resetTimer());
doc.addEventListener('change', () => undoStack.push(doc.snapshot()));

// 不再关心时,可以摘掉(这一步新手常忘,是内存泄漏的常见原因)
doc.removeEventListener('change', someHandler);

这套机制的妙处是解耦:文档模块完全不知道标题栏、自动保存、撤销栈的存在,它只负责喊一声"我变了"。谁想听谁自己来登记。这让各个模块可以独立开发、独立删除,互不牵连。这个模式在设计模式里叫观察者模式,是整个事件驱动世界的理论底座。

主线程不能阻塞:为什么界面会"未响应"

现在讲本节最有实用价值的一段。你一定见过这个场景:点了个按钮,窗口整个白掉、变灰、标题栏出现"(未响应)"、鼠标转圈、点哪儿都没反应。

这是怎么造成的?回到事件循环那段代码看第 ③ 步——target.处理(event)。这一行是同步调用:你的回调不返回,循环就走不到下一轮。

假设你在按钮回调里写了一个耗时 8 秒的操作(下载文件、读一个巨大的 Excel、跑一段复杂计算):

// ❌ 灾难写法:把主线程占死 8 秒
button.onClick = () => {
    const data = downloadHugeFile();   // 同步等待,8 秒不返回
    render(data);
};

在这 8 秒里,事件循环卡在第 ③ 步动不了。后果是连锁的:

所以有一条铁律:主线程(UI 线程)上的每个回调,都必须在十几毫秒内返回。为什么是十几毫秒?因为屏幕通常每秒刷新 60 次,每帧的预算只有 16.7 毫秒。超过这个数,就会掉帧、就是"卡";超过几百毫秒,用户就明确感到"顿";超过几秒,就是"未响应"。

Analogy · 收银台与那个掏零钱的顾客

超市只开一个收银台(主线程),后面排着长队(事件队列)。
正常情况每个顾客十几秒结完,队伍流畅。
突然有个顾客要用一袋硬币付款,慢慢数了八分钟——他没做错什么,但整条队伍全部停滞。后面的人不是"被拒绝服务",而是"根本轮不到"。
正确的做法是:收银员把这位顾客请到旁边的服务台慢慢数(后台线程),自己继续给下一位结账;数完了服务台再来通知一声(回到主线程更新界面)。
这就是异步编程的全部思想。

常见事件类型:都有哪些"事"会来

事件的种类看着杂,其实按来源分四大类,认清来源就不会乱。

来源典型事件要注意的坑
鼠标 / 指针按下 down、松开 up、点击 click、双击、移动 move、进入 enter、离开 leave、滚轮 wheel、拖拽 dragmove 触发极其频繁(每秒可上百次),里面绝不能做重活;click 其实是 down+up 的组合,顺序有讲究
键盘按下 keydown、松开 keyup、字符输入 input / textInputkeydown 给的是"物理按键",输入中文时要靠 input 事件才能拿到最终文字——输入法有个"合成中"的中间态
窗口 / 系统尺寸变化 resize、移动 move、最小化、获得/失去焦点、关闭请求 close、系统主题切换、休眠唤醒resize 在拖动边框时会连续触发几百次,必须做防抖,否则每次都重排会卡死
定时器 / 异步完成超时 timeout、周期 interval、动画帧 requestAnimationFrame、网络请求返回、文件读写完成定时器只保证"不早于",不保证"准时";如果主线程正忙,它会迟到

另外还有一类容易被忽略的:自定义事件。框架允许你自己定义"购物车已更新"、"登录状态变化"这类业务事件,走同一套监听器机制。这让事件驱动不只是"处理用户输入"的工具,而成为整个程序内部模块通信的骨架。

异步与后台线程:为什么它们是必需品

既然主线程不能占,耗时的活儿放哪?两条路。

第一条:异步(Async)。适用于"等待型"任务——网络请求、读写文件、等定时器。这类任务的特点是 CPU 其实没在算,只是在等外部返回。异步的做法是:发起请求后立刻返回,把"结果回来以后干什么"登记成一个回调。等结果真的到了,操作系统往事件队列里塞一个"完成"事件,事件循环取到它,再执行你的回调。

// ✅ 异步写法:发起后立刻返回,不占主线程

button.onClick = async () => {
    spinner.show();                          // 立刻给反馈(反馈类控件的价值)
    try {
        const data = await fetchHugeFile();  // 这里"让出"主线程,循环继续转
        render(data);                        // 结果回来后,自动回到主线程继续
    } finally {
        spinner.hide();
    }
};

关键在 await 那一行:它不是"停在这里等",而是"把函数剩下的部分登记成回调,然后返回,让事件循环去干别的"。所以在等待的这段时间里,界面依然能滚动、能响应、动画照转。这就是所谓的非阻塞

第二条:后台线程(Worker Thread)。适用于"计算型"任务——图片处理、数据压缩、大规模排序、模型推理。这类任务 CPU 是真的在满负荷算,光靠异步没用,必须换个 CPU 核心去跑。做法是把任务丢给一个独立线程,算完了通过消息通知主线程。

但这里有一条几乎所有 GUI 框架都强制的铁律只有主线程可以碰界面。后台线程算完了,不能自己去改控件的文字或颜色,必须把结果"投递"回主线程,由主线程更新。违反这条规则,轻则界面画错,重则直接崩溃。

为什么这么严格?因为界面的状态是一棵共享的树,如果两个线程同时改它,就会出现"一个线程正在遍历这棵树画画,另一个线程把节点删了"这种灾难。框架的解决办法很干脆:不给你并发的机会——所有界面修改都排成一队,交给主线程串行执行。

异步和并发不是一回事:一个厨师还是三个厨师

这两个词几乎所有人都混用,但它们回答的是两个不同的问题。用厨房来分最清楚。

异步(Asynchronous,发起一件要等的事之后不傻等,先去干别的)。它的本质是一个人,通过"不傻等"来提高利用率

想象一下厨房里只有一个厨师。他要做三道菜:一锅需要炖 40 分钟的汤、一盘炒青菜、一碗凉面。笨办法(同步)是:先把汤炖上,然后守着锅盯 40 分钟,炖好了再去炒菜、再去做凉面。全程一小时。异步办法是:汤炖上,定个闹钟,转身去炒青菜、拌凉面,闹钟响了再回去处理汤。全程四十分钟出头。

注意关键点:厨师还是一个,他的手也还是一双。省下的时间不是来自"同时炒两个锅",而是来自"炖汤那 40 分钟里他没有站着发呆"。这就是异步:把等待的空档利用起来。

并发(Concurrency,同时有多件事在办)与并行(Parallelism,同一时刻真的有多件事在动手)。区别是:

概念厨房里对应技术上对应能省什么
同步阻塞一个厨师,守着汤锅站 40 分钟主线程被一个耗时操作占死什么都省不了,还把界面卡死了
异步一个厨师,炖汤时去炒别的菜发起请求后立刻返回,结果到了再回调省掉等待的空档。适合网络、读文件
并发一个厨师同时"在做"三道菜(来回切换)单线程上有很多个异步任务在推进看起来同时在办,其实靠快速切换
并行三个厨师,三个灶,真的同时炒多线程 / 多核,或后台 Worker 线程省掉计算时间。适合图片处理、大量排序

为什么必须分清?因为用错了工具,一点效果都没有。

假设你的按钮回调里要做一件事:把一张一亿像素的图片做模糊处理,纯计算,要算 8 秒。你把它改成异步的 async / await 写法,界面会不会不卡?不会。因为异步只能省掉"等"的时间,而这里根本没有等——CPU 在实打实地算。好比一个厨师要手工剁八分钟肉馅,你让他"剁的时候顺便去炒菜",他做不到,他的手就一双。这时候唯一的办法是找第二个厨师(后台线程)。

反过来,如果你的任务是"从网络下载一个文件,等 8 秒",那就完全不需要开新线程。因为这 8 秒里 CPU 是闲着的,只是在等对方回话。好比等外卖送到,你不需要雇第二个人陪着一起等。

所以判断标准一句话:在等别人,用异步;在自己算,用后台线程。看一眼任务管理器就知道该用哪个——如果这 8 秒里 CPU 占用接近满,那是在算;如果几乎为 0,那是在等。

防抖与节流:电梯等人 和 公交发车间隔

有一类事件触发得极其频繁:鼠标移动每秒上百次、拖动窗口边框每秒几百次、在搜索框里打字每敲一个字母一次。如果每次都老老实实处理,界面必卡。

解决办法有两个,名字很唬人,但原理各对应一个日常场景。

防抖(Debounce——事件不停地来,就一直往后推;等它彻底安静下来一小会儿,才真正执行一次)。

就像电梯等人:门要关了,又有人跑过来按开门键,于是重新等三秒;又有人来,再重新等三秒。只要还有人不断进来,电梯就一直不走;直到三秒内没人再来,门才关,电梯才开动。

典型用法:搜索框的自动联想。用户打"北京天气"四个字要敲十几下,你不该发十几次网络请求。用防抖包一下,就变成"用户停下手 300 毫秒后,只发一次请求"。还有窗口大小变化:用户拖着边框来回调,中间那几百次都不用管,只在他松手停下后重新排一次版。

节流(Throttle——事件来得再密,也按固定间隔执行,中间的忽略掉)。

好比公交车的发车间隔:规定每 5 分钟发一班。这 5 分钟里站台上来了两个人还是两百个人,车都不会提前开;到点就发一班,不到点就等着。保证的是"频率上限",而不是"等安静"。

典型用法:监听页面滚动。滚动事件每秒能触发上百次,但你只需要每 100 毫秒判断一次"要不要显示返回顶部按钮"。还有拖拽时的实时预览:每 16 毫秒更新一次画面就够了(那正好是一帧),更密没有意义,因为屏幕也刷不出来。

对比防抖 Debounce节流 Throttle
生活模型电梯等人:有人来就重新等公交发车:到点就走,人多人少不管
触发时机事件停下来之后才执行一次事件持续期间就按固定频率执行
如果事件一直不停永远不执行(电梯永远不走)照常按间隔执行(车照发)
适合什么只关心"最终结果":搜索联想、窗口调完大小、表单校验需要"过程中也有反馈":滚动、拖拽、实时进度
// 防抖:等安静了才干活(电梯等人)
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);                       // 又有人来了,把上次的等待作废
    timer = setTimeout(() => fn(...args), delay);  // 重新开始等
  };
}

// 节流:到点就干活,中间的忽略(公交发车)
function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last < interval) return;         // 还没到发车时间,这一位不上车
    last = now;
    fn(...args);
  };
}

// 用法
searchBox.oninput = debounce(e => sendSearchRequest(e.target.value), 300);
window.onscroll   = throttle(() => updateBackToTopButton(), 100);

选哪个?给一句判断:只要最后那一次结果,用防抖;过程中也得有反应,用节流。搜索联想只要用户打完的那一串,用防抖;滚动条位置指示器必须一路跟着动,用节流。

事件委托:一个楼长收全楼的快递

还剩一个非常实用的技巧,它是"事件冒泡"这个机制最漂亮的应用。

问题场景:你有一个待办清单,一千条,每条右边有个删除按钮。笨办法是给一千个按钮各挂一个监听器。后果有三个:注册这一千个监听器本身要花时间和内存;新增一条时得记着给它也挂上;删掉一条时得记着把监听器摘掉,忘了就是内存泄漏。

事件委托(Event Delegation——不给每个子元素挂监听器,只在它们共同的父容器上挂一个,靠事件冒泡上来时看 target 判断具体是谁)。

它干的活儿相当于小区门口的快递代收点。不需要给一千户人家各装一个门铃和收件箱,只在门口设一个代收点:所有快递都送到这儿,工作人员看包裹上写的门牌号(也就是 target),再决定通知谁。新搬来一户不用改造任何设施,搬走一户也不用拆什么,代收点的逻辑一行不动。

// ❌ 笨办法:一千个监听器
document.querySelectorAll('.todo-item .delete-btn').forEach(btn => {
  btn.addEventListener('click', handleDelete);
});
// 问题:新增的条目没有监听器;删除的条目忘了摘监听器会泄漏

// ✅ 事件委托:整个列表只挂一个
todoList.addEventListener('click', function (e) {
  const btn = e.target.closest('.delete-btn');   // 看看点的是不是某个删除按钮
  if (!btn) return;                              // 点在别处,不管
  const item = btn.closest('.todo-item');
  deleteTodo(item.dataset.id);                   // 从被点中的那一条上取 id
});
// 好处:新增一万条也不用改代码,一个监听器全包

这里 e.target 就是上面那节讲过的"这事儿是谁引起的",而挂监听器的那个列表容器是 e.currentTarget两者不是同一个东西,这正是事件委托能成立的全部前提。

顺带说一个反例,帮你知道边界:不是所有事件都会冒泡。比如 focus(获得焦点)和 blur(失去焦点)这两个默认就不冒泡,所以不能直接用委托(需要用它们的冒泡版本 focusin / focusout)。好比有些挂号快递必须本人签收,代收点不能替签。用之前查一下这个事件冒不冒泡,是个好习惯。

事件循环在 GUI 框架里的位置

最后把镜头拉远,看看这个循环在整台机器里坐在哪一层。因为这决定了你写代码时哪些事管得着、哪些事管不着。

┌─────────────────────────────────────────────┐
│  你的代码:只写"发生什么 → 做什么"的规则       │  ← 你在这里
│  btn.onClick = save;  input.onChange = check;│
├─────────────────────────────────────────────┤
│  框架的事件循环(Qt/GTK/浏览器/Flutter)      │  ← 你调 app.run() 就进了这里
│  取事件 → 找目标控件 → 调你的回调 → 批量重绘  │     进去之后再也不出来
├─────────────────────────────────────────────┤
│  操作系统的窗口系统(DWM/Wayland/Quartz)     │  ← 它把硬件消息塞进你的队列
│  分配画布、按 Z 序合成、判断鼠标该给哪个窗口   │
├─────────────────────────────────────────────┤
│  硬件与驱动:鼠标、键盘、触摸屏、显示器        │  ← 事件的源头在这里
└─────────────────────────────────────────────┘

看清这张图,三个常见困惑就解开了。

最后再补一个容易忽略的点:事件是有优先级和合并的。比如鼠标移动事件如果堆积了二十个,很多框架会只保留最新那一个——因为你只关心鼠标现在在哪,不关心它路上经过了哪些点。这个策略叫事件压缩。就像医院叫号,如果一个人挂了五个重复的号,护士会只留一个,剩下的作废。不这么做的话,界面一卡就会积压出一堆过期的移动事件,卡完之后鼠标还得"追"半天才追上你的手。

Analogy · 酒店前台的一整天

把这一整节压成一个画面,就是酒店前台的一整天。这个类比能装下本节几乎所有概念,所以值得完整讲一遍。

先看他的工作模式。前台不知道今天会发生什么,也不能提前排好流程。他只有一本《应对手册》,上面写着"有人来办入住 → 查房、登记、给卡""电话响 → 接起来问需求""有人来问路 → 指方向"。这本手册就是你注册的那一堆回调,而"坐在那儿盯着,来事就翻手册"这个动作就是事件循环。

再看队列。大堂里排着几个人,还有电话在响、还有客房打来的内线。这些都是事件队列。前台严格按顺序办,不允许后来的插队(除了少数标了"加急"的)。没人来的时候他不是原地转圈,而是靠着台子歇着——这就是队列空时程序休眠不耗 CPU。

然后看最要命的那条铁律。假设来了一位客人,要办一件极其复杂的事:改十天的预订、拆成三间房、还要一张能报销的发票、外加协调机场接送。这件事办四十分钟。这四十分钟里,后面所有人全都干等着,电话没人接,问路的没人答,大堂开始乱。这就是主线程被阻塞,也就是你在电脑上看到的"(未响应)"。客人没做错,前台也没做错——错在把一件长活儿放在了唯一的窗口上办。

正确做法有两种,正好对应异步和后台线程。如果这件事的耗时是"要等别的部门回话"(比如等机场接送公司回复),前台应该说"您先坐,我这边一有消息就叫您",然后继续接待下一位——这就是异步。如果这件事是"必须有人埋头算四十分钟",那就该请客户经理带客人去旁边的洽谈室处理,前台窗口腾出来——这就是后台线程

最后连上防抖和节流。有位客人反复过来改需求,一分钟改了六次。前台聪明的做法不是每次都去系统里改一遍,而是说"您想清楚了我一次性录"——这就是防抖。而大堂那块显示屏上的房态信息,不管期间办了多少笔业务,都是每五分钟统一刷一次——这就是节流

所以这一节真正要你转的那个弯,可以用一句话说完:你不再是那个安排全天流程的人,你是那个写《应对手册》的人;而手册里每一条的第一要求,永远是"别占着窗口太久"。

把三节内容串起来

到这里,第 1 章的骨架就完整了:GUI 提供了窗口、图标、菜单、指针这套视觉语言;控件 是搭建界面的可复用积木,并按树形嵌套组织;而事件驱动 是让这棵静态的树"活起来"的发动机。

三者的关系可以这样理解:控件树是身体,事件循环是心跳,你写的回调是反射。心跳不停,身体才有反应;而反射必须够快,快不了就得把活儿交给别人干——这就是异步存在的全部理由。

下一章开始,我们往下钻一层:这些控件最终是怎么变成屏幕上的像素的。

Recap · 收束

命令式脚本的控制权在你手里,从上到下跑完;事件驱动把控制权交给框架,你只登记"发生什么就做什么",代码顺序不等于执行顺序。
所有 GUI 程序的骨架都是一个事件循环:取事件 → 分发到控件 → 执行回调 → 批量重绘,无限循环;队列空时它休眠,所以不耗电。
回调与监听器让"事件"和"处理"彻底解耦,一个事件可以被多方独立关注。
最重要的实践底线是:主线程不能阻塞。回调超过 16.7ms 就掉帧,超过几秒就"未响应"。
因此等待型任务用异步,计算型任务用后台线程;但无论走哪条路,最终更新界面的必须是主线程

☰ 主页
学海无涯 · 界面篇 · § 1.5