到底是谁点了按钮?
“别点!购物车还没算完——”警告晚了一步。结算窗当场消失,提交车撞开大门,整栋 DOM 楼却都说自己只是“听见有人点了按钮”。这口锅,到底该谁背?
结算窗没了
本文承接上一篇:《TCP 老司机遭遇新对手》。
HTTP/3 的新线路刚跑完第一趟,屏幕外的主人便打开了一家购物网站。
商品图片一张接一张亮起。购物车里躺着三件东西:键盘、咖啡杯,以及一顶主人半夜看了十分钟、至今没想明白为什么会放进去的渔夫帽。
结算窗口弹了出来。
JavaScript 老大端着咖啡巡场,顺手看了一眼订单。
“等等,优惠券还没——”
屏幕外的手指已经落下。
“咔哒!”
结算按钮猛地一缩:“谁点我!”
一只通红的 click 报警盒从按钮中央弹了出来。
按钮旁的统计灯也亮了起来。
统计员按下计数器:“点击一次。”
紧接着,结算面板大喊:“收到点击,关窗!”
表单车库亮起绿灯:“收到提交指令,发车!”
“轰——”
结算窗被藏到了屏幕后面,表单仍留在 DOM 里。一辆装着半成品订单的提交车撞开出口,拖着没算完的优惠券冲向服务器。
JavaScript 老大只来得及抓住车尾。领带在风里绷成一条直线,咖啡则非常有经验地提前扣在了杯盖里。
“停!这单还不能发!”
提交车头也不回:“手续齐全,默认发车!”
“谁批的手续?”
“按钮。”
按钮在远处急得跳脚:“我只喊了一声有人点我!”
结算面板从屏幕边缘探出半张脸:“报警盒自己跑过来的!我……我只是照监听器关了窗。”
统计员抱紧计数器:“我真只记了一次。”
JavaScript 老大被提交车拖过半条街,终于在出口前一头撞上栏杆。
栏杆没事。
老大有点事。
全楼都说听见了
十分钟后,事故调查会在 DOM 工地召开。
HTML 解析器把扳手往桌上一拍:“先说清楚,谁碰了按钮?”
“屏幕外的主人。” 按钮抢答。
“谁关了结算窗?”
结算面板举起一根手指:“我。但点击确实经过了我。”
“谁让提交车发车?”
表单咳了一声:“这件事……稍后解释。”
JavaScript 老大盯着一屋子嫌疑人:“一个手指,只点了一个地方。为什么你们全收到了?”
没人说话。
角落里,那只红色报警盒悄悄往门外挪。
“站住。”
报警盒僵在原地。
老大掀开盒盖,里面是一张 Event 记录单。
“把刚才走过的路,再走一遍。”
报警盒疯狂摇头。
HTML 解析器已经把结算窗口的 DOM 图纸铺开:
<form id="checkout-form">
<section class="dialog">
<button type="submit">
<span class="label">确认下单</span>
</button>
</section>
</form>
“主人点中的不是整个按钮。” HTML 解析器把扳手压在最里面,“是按钮里的这几个字,span.label。”
HTML 解析器拿扳手沿图纸往外敲:span.label 外面是 button,再外面是 .dialog、form、body、html、document 和 window。每敲一下,对应的节点便亮起一盏灯。
“路都给你点亮了。” JavaScript 老大拉过椅子坐下,“跑。”
报警盒跑了个来回
红色报警盒不情不愿地爬到楼顶。
哨声响起。
它从 window 出发,沿着 document、html、body 一层层往下冲。经过的节点纷纷探出脑袋,报警盒却只把记录单递给其中几位。
“我们提前登记过。” 一名监听器亮出凭证,“监听 click,而且把 capture 设成了 true。”
document.addEventListener("click", inspect, {
capture: true
});
“方向反了吧?” 统计员问,“主人明明点在楼下。”
“捕获阶段!” 报警盒边跑边喊,“有人想在事件抵达目标前先看一眼,就把 capture 登记为 true。”
报警盒冲进 span.label,脑袋撞出一声闷响。
“到达目标!”
还没喘匀,回程哨又响了。
报警盒转身向上,一路经过 button、.dialog、form、body,最后跑回 window。
“这一程叫冒泡。” JavaScript 老大看着报警盒往回跑,“在常见写法里,addEventListener() 默认登记的监听器就在这一路接单。”
结算面板立即替自己辩护:“看吧!我在按钮外面,click 回程时经过我,我才关窗的。”
“经过你,就非得关窗?” 老大问。
“我的监听器写着:收到点击就关。”
“谁写的?”
所有人一起看向屏幕外。
空气突然变得很讲礼貌。
老大在白幕上画了一只向下的箭头,又画了一只向上的箭头。
“去程捕获,抵达目标,回程冒泡。” 老大敲了敲白幕,“这一趟里的监听器同步执行。不是每经过一层 DOM,就单独往事件大厅扔一个新任务。”
按钮举手:“所有事件都会冒泡吗?”
“不会。” 报警盒赶紧翻出记录单上的 bubbles,“得看具体事件。别拿 click 的路线套遍所有事件。”
JavaScript 老大点点头:“路线清楚了。现在抓关窗的真凶。”
结算面板脸色一白。
路过也要背锅?
事故监听器被投到白幕上:
const dialog = document.querySelector(".dialog");
dialog.addEventListener("click", (event) => {
hideDialog();
});
“果然是你。” 按钮指向结算面板。
“等等,先别急着定罪!” 结算面板摊开记录单,“让按钮和我各报一次,看看这两张工牌到底写了谁。”
JavaScript 老大把同一个记录函数分别登记在按钮和结算面板上,随后示意报警盒再跑一遍。
报警盒经过按钮时,第一张工牌写着:
target: span.label
currentTarget: button
经过结算面板时,第二张变成:
target: span.label
currentTarget: section.dialog
JavaScript 老大把两次记录并排放好:“同一次点击,为什么 target 一直是 span.label,currentTarget 却换了?”
“在眼前这棵普通 DOM 树里,target 记的是最初命中的对象。” 报警盒指着 span.label,“这趟路上一直是它。”
“currentTarget 呢?”
“当前正在执行监听器的对象。轮到按钮就是按钮,轮到面板就是面板。”
结算面板赶紧接话:“所以点击经过我,不代表主人点的就是我!”
“那你还关窗?”
“监听器没问目标,拿到报警盒就关……”
真凶找到了。
不是冒泡,也不是报警盒。
是那段见谁都关门的处理函数。
HTML 解析器把修正后的代码钉上白幕:
dialog.addEventListener("click", (event) => {
if (event.target === event.currentTarget) {
hideDialog();
}
});
“只有直接点到面板自身,才把它当成关闭意图。” JavaScript 老大说,“点到里面的按钮,target 就不是面板。”
结算面板盯着自己的监听器,没敢再辩解。
JavaScript 老大正要宣布结案,按钮却指了指窗外:“关窗的问题解决了,可那辆半成品订单车怎么办?”
话音刚落,隧道另一头便响起一阵刺耳的喇叭声。
老大按错了闸
服务器把提交车原路退了回来,车头还贴着一张投诉单。
JavaScript 老大脸一沉:“再演一次。这回我亲自拦。”
主人第二次点下“确认下单”。
红色报警盒从楼顶俯冲,抵达 span.label,再掉头向上。
刚经过按钮,老大一掌拍下红色闸门:
button.addEventListener("click", (event) => {
event.stopPropagation();
});
button.addEventListener("click", () => {
console.log("检查按钮自身是否仍会响应");
});
“咔!”
报警盒被挡在按钮门口。结算面板、表单和更上层的冒泡监听器再也收不到它。
JavaScript 老大拍了拍手上的灰:“这次总清净了。”
按钮旁边,刚登记的校验监听器举起号码牌:“那该我了?”
“怎么还有?”
“我也登记在按钮上,而且排在它后面。它只是不让报警盒继续去别的节点,又没赶我走。”
老大翻开规则,脸色更沉了。
stopPropagation() 会阻止事件继续前往后面的节点,却不会赶走同一节点上尚未执行的监听器。如果连这些监听器也不想放行,就得调用 stopImmediatePropagation()。
“名字这么长,脾气一定很差。” 按钮小声说。
“少认识它一点,对大家都好。”
就在这时,表单车库再次亮起绿灯。
“轰隆隆——”
提交车又冲了出来。
JavaScript 老大的笑容凝固了。
“报警盒都停了,你为什么还能发车?”
“你停的是传播。” 提交车从老大身旁呼啸而过,“我走的是默认动作!”
老大扑上车尾:“这两个不归一个闸门管?”
“不——归——”
声音和车一起消失在隧道里。
停了,但没完全停
JavaScript 老大第二次被拖回调查会时,白衬衫已经变成了灰衬衫。
表单终于说出了刚才没说完的话。
“type="submit" 的按钮被激活后,浏览器原本就准备让所属表单提交。那是默认动作。没有成功取消,车库当然发车。”
“所以 stopPropagation() 只管报警盒去不去别的节点?”
“对。”
“不管提交车?”
“不管。”
“你为什么不早说?”
“你刚才抓着车就出去了。”
老大深吸一口气,把监听器从按钮搬到表单的 submit 事件上:
const form = document.querySelector("#checkout-form");
form.addEventListener("submit", (event) => {
event.preventDefault();
applyCoupon();
submitWithJavaScript();
});
第三次演练开始。
主人点击按钮。click 完成自己的分派,按钮的激活行为准备提交表单。新的 submit 事件刚拿到发车单,JavaScript 老大便按下另一只闸门。
“preventDefault()!”
提交车在出口前急刹。轮胎擦出两道火光,车头离栏杆只剩一指宽。
按钮目瞪口呆:“这次真停了。”
“当然。” 老大扶着腰,“preventDefault() 取消的是事件关联的默认动作,不负责停止传播;stopPropagation() 阻止的是传播,不负责取消默认动作。”
“一个管报警路线,一个管浏览器原本要做的事。”
“终于说了句能听懂的。”
表单又递来一张小纸条:“也别见到默认动作就乱拦。先看 event.cancelable,只有允许取消的事件才可能成功。”
一名挂着 passive: true 承诺牌的监听器立刻后退半步:“别看我。我早就保证过不拦默认动作。”
优惠券终于算完。JavaScript 老大检查订单后,亲手放行新的请求。
“这回总算结束了。”
商品列表那边忽然传来一阵细碎的咳嗽声。
一百多个“加入购物车”按钮排成队,手里全举着监听器登记表。
“老大,我们还没登记。”
只留一个接线员
JavaScript 老大望向队尾。
队伍拐过两个货架,又从另一扇门绕了回来。
“怎么这么多?”
“每张商品卡片都有加入购物车按钮。”
“后面还会动态加入新的商品。”
那顶刚结完账的渔夫帽又从商品列表里探出头:“还有我!万一主人想再买一顶呢?”
老大拿起第一份登记表,又放了回去。
“不逐个装监听器了。”
加购按钮们顿时乱成一团。
“那主人点到我们怎么办?”
“让 click 往上走。”
JavaScript 老大只在共同的清单节点上留了一名接线员:
const list = document.querySelector("#product-list");
list.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
}
const button = event.target.closest("button.add");
if (!button || !list.contains(button)) {
return;
}
addItem(button.closest("li").dataset.id);
});
主人试着点了一下渔夫帽旁的购物车图标。
图标被命中,click 一路冒到商品列表。接线员从 target 出发,向上找到最近的 button.add,确认按钮确实属于这张列表,随后把第二顶渔夫帽送进了购物车。
两顶渔夫帽在购物车里碰了碰帽檐:“果然是真爱。”
没人回答。
“这叫事件委托。” 接线员关上门,“会冒泡的事件来到共同祖先,再由这里判断真正来自哪里。以后动态加入的商品,也不用提前逐个登记。”
“那所有事件都能这么办?” 一名按钮问。
“不是所有事件都会冒泡,也不是看见一个 target 就能随便处理。” 接线员晃了晃检查单,“来源和边界都得确认。否则隔壁清单冒来的事件,也可能被抓错。”
JavaScript 老大满意地点点头。
从误触、关窗、错误提交,到如今一个接线员管住整张商品列表,这场事故总算有了结论。
老大重新倒了一杯咖啡。
整个页面都点不动了
“最后试一次。”
加购接线员忙了一阵,把主人选中的五百件商品陆续送进购物车。主人随即点下“一键计算优惠”。
JavaScript 老大准确拿到了计算任务。
一只比执行大厅大门还宽的计算箱被推进来,上面塞满价格、折扣、积分和满减规则。
“这个可能要算一小会儿。” 按钮说。
“一小会儿是多久?”
“不久。”
三秒过去。
调用栈的盘子一动不动。
门外,新的点击排起了队。动画场记员举着场记板疯狂拍门,像素工地的工人也开始踹墙。
“先让我画一帧!”
“主人又点了三次!”
“滚动条卡住了!”
JavaScript 老大被数字埋到只剩一副眼镜露在外面。
“事件找对人了。”
“默认动作也拦住了。”
“为什么页面还是死了?”
执行大厅外,浏览器老大贴上一张新告示:
主线程今日不接客