到底是谁点了按钮?

“别点!购物车还没算完——”警告晚了一步。结算窗当场消失,提交车撞开大门,整栋 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,再外面是 .dialogformbodyhtmldocumentwindow。每敲一下,对应的节点便亮起一盏灯。

“路都给你点亮了。” JavaScript 老大拉过椅子坐下,“跑。”

报警盒跑了个来回

红色报警盒不情不愿地爬到楼顶。

哨声响起。

它从 window 出发,沿着 documenthtmlbody 一层层往下冲。经过的节点纷纷探出脑袋,报警盒却只把记录单递给其中几位。

“我们提前登记过。” 一名监听器亮出凭证,“监听 click,而且把 capture 设成了 true。”

document.addEventListener("click", inspect, {
    capture: true
});

“方向反了吧?” 统计员问,“主人明明点在楼下。”

“捕获阶段!” 报警盒边跑边喊,“有人想在事件抵达目标前先看一眼,就把 capture 登记为 true。”

报警盒冲进 span.label,脑袋撞出一声闷响。

“到达目标!”

还没喘匀,回程哨又响了。

报警盒转身向上,一路经过 button.dialogformbody,最后跑回 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.labelcurrentTarget 却换了?”

“在眼前这棵普通 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 老大被数字埋到只剩一副眼镜露在外面。

“事件找对人了。”

“默认动作也拦住了。”

“为什么页面还是死了?”

执行大厅外,浏览器老大贴上一张新告示:

主线程今日不接客