事件驱动
这是学界面编程最需要转的那个弯:写普通程序时,流程由你控制;写界面程序时,流程由用户控制。你不再是"指挥官",而变成了"值班员"——坐在那里等事情发生,事情来了就处理,没事就闲着。这个思维反转,比任何 API 都重要。
流水线工人的一天是写死的:装第一个零件、装第二个、拧螺丝、贴标签、下一件。顺序固定,从头跑到尾,跑完下班。
酒店前台的一天完全不同:他不知道接下来会发生什么。可能来客人办入住,可能电话响,可能有人来问路,可能什么都不发生、闲坐十分钟。
他的工作模式是:盯着,来事就处理,处理完继续盯着。而且有一条铁律——处理任何一件事都不能占太久,否则后面排队的客人就全被卡住了。
先把这一节的词全翻成人话
这一节的概念最抽象,所以术语必须先钉死。每个词后面那句括号才是你要记的东西。
- 事件驱动事件驱动(Event-Driven——程序不主动往前走,而是坐着等事情发生,来一件处理一件)。说白了就是酒店前台的工作模式:他不知道下一分钟会发生什么,只是坐在那儿盯着,来客人就办入住,电话响就接,没事就闲着。
- 事件事件(Event,一件"刚刚发生了什么"的通知,比如鼠标按下了、窗口大小变了、网络请求回来了)。它相当于递到前台窗口的一张单子:上面写着谁、什么时候、要办什么。
- 事件循环事件循环(Event Loop,一个永不结束的循环,反复做"取一件事、处理它、画一下屏幕")。听着玄,其实就是叫号机加办事员的那套流程:叫下一号、办、办完再叫下一号。
- 事件队列事件队列(Event Queue,还没被处理的事件排成的一条队)。就是取号机后面那排等着的人,先来先办。
- 回调函数回调函数(Callback,你事先写好、交出去、等时机到了由别人替你调用的一段代码)。好比你在餐厅门口留了手机号说"有位置了打给我"——号码交出去了,什么时候打不由你决定。
- 监听器监听器(Listener / Observer,登记了"我关心某件事"的那一方)。简单说就是订阅了小区通知群的人:物业发一条消息,所有订阅的人都收到,谁看不看是自己的事。
- 主线程 / UI 线程主线程(Main Thread / UI Thread,唯一被允许更新界面的那条执行线路)。它干的活儿相当于超市里唯一开着的那个收银台:所有人必须从它这儿过。
- 阻塞阻塞(Blocking,一段代码占着不放、后面的全等着)。就是那个用一袋硬币付款、慢慢数了八分钟的顾客。
- 异步异步(Asynchronous,发起一件要等的事之后立刻返回,等结果到了再来处理)。本质上就是去餐厅取号后先去逛街,号到了手机通知你,而不是傻站在门口。
- 并发并发(Concurrency,同时"在办"很多件事,但不一定同时"在动手")。后面有专门一节讲它和异步的区别。
命令式脚本 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"。程序的实际行为,是这些规则被用户以随机顺序触发出来的结果。
命令式脚本像菜谱:热锅、下油、放葱姜、倒肉、翻炒三分钟、加盐、出锅。步骤固定,照做就行,顺序错了菜就毁了。
事件驱动像消防队的值班规程:接到火警 → 出车;接到猫上树 → 派云梯;接到误报 → 记录挂断。
没人能预知今天会来几个电话、什么顺序来。消防队的工作不是"跑一遍流程",而是保持待命,随时响应。
而且值班规程里最重要的一条往往是:不许一个人占着总机不放——这就是后面要讲的"主线程不能阻塞"。
事件循环:整个界面世界的心跳
那么"等事件"这件事,在代码层面到底是怎么实现的?答案朴素到令人意外:一个永不结束的 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 | 按的是左键、中键还是右键 | 用哪只手、从哪个窗口递进来的 |
target 和 currentTarget 的区别是最经典的困惑点,用公文类比一句话就清楚了: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 毫秒。超过这个数,就会掉帧、就是"卡";超过几百毫秒,用户就明确感到"顿";超过几秒,就是"未响应"。
超市只开一个收银台(主线程),后面排着长队(事件队列)。
正常情况每个顾客十几秒结完,队伍流畅。
突然有个顾客要用一袋硬币付款,慢慢数了八分钟——他没做错什么,但整条队伍全部停滞。后面的人不是"被拒绝服务",而是"根本轮不到"。
正确的做法是:收银员把这位顾客请到旁边的服务台慢慢数(后台线程),自己继续给下一位结账;数完了服务台再来通知一声(回到主线程更新界面)。
这就是异步编程的全部思想。
常见事件类型:都有哪些"事"会来
事件的种类看着杂,其实按来源分四大类,认清来源就不会乱。
| 来源 | 典型事件 | 要注意的坑 |
|---|---|---|
| 鼠标 / 指针 | 按下 down、松开 up、点击 click、双击、移动 move、进入 enter、离开 leave、滚轮 wheel、拖拽 drag | move 触发极其频繁(每秒可上百次),里面绝不能做重活;click 其实是 down+up 的组合,顺序有讲究 |
| 键盘 | 按下 keydown、松开 keyup、字符输入 input / textInput | keydown 给的是"物理按键",输入中文时要靠 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 序合成、判断鼠标该给哪个窗口 │
├─────────────────────────────────────────────┤
│ 硬件与驱动:鼠标、键盘、触摸屏、显示器 │ ← 事件的源头在这里
└─────────────────────────────────────────────┘
看清这张图,三个常见困惑就解开了。
- 为什么
app.run()后面的代码不执行了因为那一行是把控制权交出去,事件循环进去就不再返回,直到程序退出。所以初始化的活儿必须写在它前面。好比开会时你把主持权交给主持人,之后就得等他叫你,不能自己接着往下讲。 - 为什么不同框架的事件名不一样,但脾气一样因为第二层的实现虽然各家不同(Qt 叫
exec()、Python 的 tkinter 叫mainloop()、浏览器你根本看不到它),但结构完全相同:取、分发、处理、重绘。学会一套,换框架只是换名字。 - 为什么有些事你想管也管不了比如"用户把窗口拖到第二块屏幕上"这件事,是第三层窗口系统在处理,你只能收到一个"位置变了"的通知,改不了它的行为。好比商场的中央空调开几度不是店铺能决定的,你只能收到"今天有点冷"的反馈然后自己加个暖风。
最后再补一个容易忽略的点:事件是有优先级和合并的。比如鼠标移动事件如果堆积了二十个,很多框架会只保留最新那一个——因为你只关心鼠标现在在哪,不关心它路上经过了哪些点。这个策略叫事件压缩。就像医院叫号,如果一个人挂了五个重复的号,护士会只留一个,剩下的作废。不这么做的话,界面一卡就会积压出一堆过期的移动事件,卡完之后鼠标还得"追"半天才追上你的手。
把这一整节压成一个画面,就是酒店前台的一整天。这个类比能装下本节几乎所有概念,所以值得完整讲一遍。
先看他的工作模式。前台不知道今天会发生什么,也不能提前排好流程。他只有一本《应对手册》,上面写着"有人来办入住 → 查房、登记、给卡""电话响 → 接起来问需求""有人来问路 → 指方向"。这本手册就是你注册的那一堆回调,而"坐在那儿盯着,来事就翻手册"这个动作就是事件循环。
再看队列。大堂里排着几个人,还有电话在响、还有客房打来的内线。这些都是事件队列。前台严格按顺序办,不允许后来的插队(除了少数标了"加急"的)。没人来的时候他不是原地转圈,而是靠着台子歇着——这就是队列空时程序休眠不耗 CPU。
然后看最要命的那条铁律。假设来了一位客人,要办一件极其复杂的事:改十天的预订、拆成三间房、还要一张能报销的发票、外加协调机场接送。这件事办四十分钟。这四十分钟里,后面所有人全都干等着,电话没人接,问路的没人答,大堂开始乱。这就是主线程被阻塞,也就是你在电脑上看到的"(未响应)"。客人没做错,前台也没做错——错在把一件长活儿放在了唯一的窗口上办。
正确做法有两种,正好对应异步和后台线程。如果这件事的耗时是"要等别的部门回话"(比如等机场接送公司回复),前台应该说"您先坐,我这边一有消息就叫您",然后继续接待下一位——这就是异步。如果这件事是"必须有人埋头算四十分钟",那就该请客户经理带客人去旁边的洽谈室处理,前台窗口腾出来——这就是后台线程。
最后连上防抖和节流。有位客人反复过来改需求,一分钟改了六次。前台聪明的做法不是每次都去系统里改一遍,而是说"您想清楚了我一次性录"——这就是防抖。而大堂那块显示屏上的房态信息,不管期间办了多少笔业务,都是每五分钟统一刷一次——这就是节流。
所以这一节真正要你转的那个弯,可以用一句话说完:你不再是那个安排全天流程的人,你是那个写《应对手册》的人;而手册里每一条的第一要求,永远是"别占着窗口太久"。
把三节内容串起来
到这里,第 1 章的骨架就完整了:GUI 提供了窗口、图标、菜单、指针这套视觉语言;控件 是搭建界面的可复用积木,并按树形嵌套组织;而事件驱动 是让这棵静态的树"活起来"的发动机。
三者的关系可以这样理解:控件树是身体,事件循环是心跳,你写的回调是反射。心跳不停,身体才有反应;而反射必须够快,快不了就得把活儿交给别人干——这就是异步存在的全部理由。
下一章开始,我们往下钻一层:这些控件最终是怎么变成屏幕上的像素的。
命令式脚本的控制权在你手里,从上到下跑完;事件驱动把控制权交给框架,你只登记"发生什么就做什么",代码顺序不等于执行顺序。
所有 GUI 程序的骨架都是一个事件循环:取事件 → 分发到控件 → 执行回调 → 批量重绘,无限循环;队列空时它休眠,所以不耗电。
回调与监听器让"事件"和"处理"彻底解耦,一个事件可以被多方独立关注。
最重要的实践底线是:主线程不能阻塞。回调超过 16.7ms 就掉帧,超过几秒就"未响应"。
因此等待型任务用异步,计算型任务用后台线程;但无论走哪条路,最终更新界面的必须是主线程。