一个监听器,取代每个按钮一个客户端组件
当你给一个组件加上点击埋点的那一刻,它就变成了一个会下发 JavaScript 的客户端组件。在整个站点上重复这么做,光是埋点就会让你的打包体积膨胀。本文要讲的是另一种方案——它其实一直就躺在平台里:一个委托监听器、声明式的 data 属性,以及始终保持纯服务端渲染 HTML 的组件。
有这样一个场景,几乎在每个代码库里都会反复上演。产品经理让你统计某个按钮的点击。这个按钮本是一个漂亮、静态、服务端渲染的组件。于是你加上一个 onClick 处理函数。而为了拥有 onClick 处理函数,这个组件现在必须在浏览器里运行——于是你加上 "use client"(或者搬出一个 hook,或者套一层 wrapper)。组件开始 hydrate,开始下发 JavaScript。而它做这一切,并不是为了在客户端做什么,只是为了向你的分析工具轻声报一句话。
现在把这乘以大型站点上每一个被埋点的按钮、卡片、链接和标签页。你的 JavaScript 包里会有相当一部分,纯粹只是为了上报点击而存在。本文要讲的正是那个替代方案——它从上世纪 90 年代起就待在浏览器里,不需要任何框架:事件委托。一个小小的监听器盯住整个页面;你的组件则重新变回纯 HTML。
你不假思索就加上的那个处理函数
这个下意识的动作看起来人畜无害。你有一个链接,想知道它什么时候被点击,于是给它挂上一个处理函数:
"use client"; // ← the moment analytics arrives, so does this line
import { track } from "@/lib/analytics";
export function CtaButton({ href, label, place }: Props) {
return (
<a
href={href}
onClick={() =>
track("cta_clicked", { place, label })
}
>
{label}
</a>
);
}仅仅一行埋点,整个组件的性质就变了。它不再是服务端可以流式输出、发完即忘的静态标记——它成了一座交互孤岛,客户端必须先下载、解析、hydrate,那个 onClick 才会存在。框架无从判断这个处理函数只是发了就不管的遥测;在它看来,这个组件需要在客户端保持存活。
这个下意识的动作带来的代价,会在整个代码库里不断累积:
- 每一个被埋点的组件都变成一处 hydration 边界——为了某件根本不会改变 UI 的事,去下载、解析并执行 JavaScript。
- 埋点逻辑散落在成百上千个组件里,每个都各自导入分析 SDK,于是 SDK 最终出现在许多个包里。
- 分析库在关键路径上加载,与用户真正在等待的渲染争抢资源。
- "我们上报了哪些事件,又是从哪儿上报的?"变成了一场考古——答案分散在整棵组件树里。
为什么它感觉很对
有必要诚实地聊聊我们为什么这么做。就近放置(co-location)是一个货真价实的好直觉:点击,和你想为这次点击记录的东西,就待在同一个地方,所以把埋点调用直接写在那儿读起来顺理成章,也容易理解。框架还在强化这一点——onClick 属性是响应点击最显而易见、也有文档背书的方式,只不过它顺带把 hydration 也一并拖了进来。
但把埋点的意图就近放置,并不要求把执行埋点的代码也就近放置。你完全可以把声明留在元素旁边——"这个按钮是一次 CTA 点击"——而真正的监听发生在别处。浏览器一直都能做到这件事;只是自从组件让逐元素挂处理函数变得像是免费的之后,我们就不再去用它了。
事件会冒泡——这就是全部诀窍
当你点击一个元素时,浏览器并不只在那个元素上触发一个事件。事件会沿着 DOM 树向上传播——从该元素,到它的父节点,再到父节点的父节点,一路直到 document。这就是冒泡,它意味着顶层的一个监听器就能观测到其下所有元素的点击。你不需要每个按钮一个监听器;你只需要一个监听器盯住整个文档,并在每次点击时算出到底点中了什么。
做这件事的工具是 Element.closest()。从点击落点出发——那可能是嵌在你链接里的一个图标或一个 <span>——closest() 会向上查找,直到找到一个匹配某个选择器的祖先元素。让它去找最近的、带有埋点标记的元素,你拿回来的就是被点击的那个确切按钮,无论指针正下方是什么。这一个原语就取代了每个组件里的处理函数。
契约是一个 data 属性
如果由一个全局监听器来处理点击,那么组件唯一的职责就是声明什么该被埋点——写在标记里,让服务端来渲染。HTML 早就有了这套机制:data-* 属性。按钮描述自己,不下发任何行为:
// No "use client". No handler. No hooks. Pure server-rendered HTML.
export function CtaButton({ href, label, place }: Props) {
return (
<a
href={href}
data-track="cta_clicked"
data-place={place}
data-label={label}
>
{label}
</a>
);
}没有 "use client",没有处理函数,没有导入的 SDK——没有任何东西需要客户端去 hydrate。服务端流式输出纯 HTML,埋点意图作为属性一并随行。真正到达浏览器的,是这样的内容:
<!-- What the server sends. The browser needs nothing else to make it work. -->
<a href="/pricing" data-track="cta_clicked" data-place="hero" data-label="Start free">
Start free
</a>整个页面只用一个监听器
现在行为恰好只存在于一个地方。document 上的单个点击监听器,从被点击的元素上读出标记并上报它。组件的 data-track 成为事件名;其余的 data-* 属性成为负载——dataset API 会把它们作为一个普通对象交给你:
// The entire client-side cost of analytics for the whole site.
function handleClick(e: MouseEvent) {
// Find the nearest tracked element from wherever the click landed —
// works even if the user clicked an icon or <span> inside the link.
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>("[data-track]");
if (!el) return;
const { track: event, ...data } = el.dataset;
send(event!, data);
}
export function registerTracking() {
document.addEventListener("click", handleClick);
}你在应用启动时、hydration 之前注册它一次——于是从第一帧绘制起它就在盯着了。这个调用放在哪里取决于你的技术栈,但它始终只是一个调用:
import { registerTracking } from "./track-delegation";
// Next.js: instrumentation-client.ts · Vite/SPA: main.ts · plain HTML: a <script>.
// One call, once, for the entire application.
registerTracking();只在真实点击发生时才加载 SDK
这里还藏着一个微妙的第二重收益。监听器本身极小——寥寥几行,没有任何依赖。分析真正沉重的部分是 SDK,而有了委托,你在页面加载期间就不再需要它了。你可以把它的导入推迟到第一次点击真正发生时:
// The listener ships ~1 KB. The heavy SDK is pulled only when a real
// click happens — never during page load.
async function send(event: string, data: Record<string, string | undefined>) {
const { track } = await import("@/lib/analytics"); // code-split, on demand
track(event, data);
}如今分析厂商那几十 KB 已经彻底离开了关键路径。埋点不再有任何东西与首屏渲染争抢资源;SDK 会在用户第一次交互的那一刻,惰性地、按需地到来——而对于没点击就跳出的用户,它根本不会加载。
会咬人的那些细节
点击委托很容易做对 90%,然后把高级用户坑了。普通的左键点击归你处理,但 Cmd/Ctrl/Shift + 点击,或者中键点击,是用户在请浏览器把链接在新标签页打开或下载下来。如果你的监听器无条件地调用 preventDefault(),你就悄无声息地劫持了这份意图。对带修饰键的点击和非主键点击尽早退出,让锚点原生的 href 去干它的活:
function handleClick(e: MouseEvent) {
// Cmd/Ctrl/Shift/Alt-click and middle-click express a native intent
// (open in new tab, download). Let the browser handle those untouched.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
// ...find [data-track], send event
}第二个陷阱是时机。如果点击导致页面跳走,卸载可能会中断一个仍在途中的分析请求——于是你最想统计的那些点击,恰恰最容易丢失。navigator.sendBeacon 正是为此而生:它把一小份负载交给浏览器,让浏览器在后台投递,即便导航发生也能存活:
function send(event: string, data: Record<string, string | undefined>) {
// A click that unloads the page can abort an in-flight fetch. sendBeacon
// hands the request to the browser to deliver even as navigation happens.
navigator.sendBeacon(
"/track",
JSON.stringify({ event, ...data }),
);
}对于由客户端路由处理的内部链接,以上这些都不适用——没有卸载,所以普通请求就够了,委托监听器与路由器安然共处。beacon 只对那些引发真正整页导航的链接才重要。搞清楚你的哪些链接分别属于哪一类,才是全部的微妙所在。
监听器绝不能弄坏链接。尊重带修饰键的点击,导航时使用 beacon,并把分析严格当作尽力而为——一次埋点失败对用户应当是无感的,绝不能变成一个失灵的按钮。
超越点击
点击是最常见的情形,但这个模式可以推广到任何委托监听器能观测到的事件。举例来说,一个原生的 <details> 折叠面板,零 JavaScript 就能开合——而你依然能统计人们打开它的频率,因为 toggle 事件同样可以委托。唯一的小别扭:toggle 不冒泡,所以你要在捕获阶段监听:
// The same pattern, a different event. A native <details> accordion needs
// zero JavaScript to open — and one delegated listener to be measurable.
document.addEventListener("toggle", (e) => {
const el = e.target as HTMLElement;
if (el.matches("[data-track-open]") && (el as HTMLDetailsElement).open) {
send(el.dataset.trackOpen!, { ...el.dataset });
}
}, true); // capture: the toggle event does not bubble表单提交、可见性变化、媒体播放——同一套声明式的形态都适用。组件在标记里声明什么值得记录;一小撮全局监听器负责记录。你页面的交互表面,和你页面的可观测性,从此不再是同一回事。
为什么这件事如今更重要
事件委托已有数十年历史,很长一段时间里它只是个锦上添花的东西——一种给列表挂一个监听器、而非挂上一千个的办法。到了服务端组件的时代,它的分量更重了。当默认就是一个在服务端渲染、不下发任何 JavaScript 的组件时,单单一个 onClick 就不再是可以忽略的误差:它是那条把整块区块从静态 HTML 翻转成一座 hydrate 客户端孤岛的分界线。
委托正是让你得以守住那个默认值的东西。分析——历来是让一个展示型组件"不得不"成为客户端组件的最常见理由之一——不再强行划出那道边界。整块只需被埋点、无需交互的区块,可以保持服务端渲染,什么都不下发。观测你 UI 的成本,就此与 hydrate 它的成本解耦。
委托 vs 逐组件,说句实话
| 关注点 | 每个组件里都有处理函数 | 一个委托监听器 |
|---|---|---|
| 每个被埋点元素的 JavaScript | 会下发(hydration 边界) | 没有——纯服务端 HTML |
| 分析 SDK | 在许多个包里被导入 | 只在首次点击时惰性加载一次 |
| 是否在关键路径上 | 是 | 否 |
| 埋点逻辑存放于何处 | 散落在整棵树里 | 一个文件 |
| 审计所有事件 | grep 整个代码库 | 读那些标记 / 一个处理函数 |
| 对后来新增的元素是否生效 | 各自都要一个处理函数 | 自动生效(委托) |
| 与框架的耦合 | 绑定到组件生命周期 | 纯 DOM——与框架无关 |
委托诚实的代价,是一点点纪律:契约如今是字符串化类型(stringly-typed)的属性,而非有类型的函数调用,所以 data-track 名字里的一个拼写错误会悄无声息地失败,而不是在编译期报错。一个从有类型的参数构建这些属性的小助手函数,能把大部分安全性买回来,而一个类型良好的监听器,要盯着它保持正确,其面积远小于散落在成百上千个文件里的处理函数。
这一切都不新鲜
如果这让你觉得眼熟,那是应该的。这正是标签管理器和自动采集(autocapture)分析一直以来的工作方式。Google Tag Manager、Segment、PostHog、Heap——在底层,它们挂上几个全局监听器,从被点击的元素上读取属性(或推断选择器)。你几乎可以肯定早就把这个模式送上了生产;只不过你把它交给了一个第三方脚本去掌管。
刻意自己来做,图的是掌控力和体量。大约三十行代码,就能给你同样的委托机制,而无需在关键路径上放一个厂商脚本,无需让不透明的自动采集触发你并不打算要的事件,还带来一份你能读、能加类型、能测试的 data 属性契约。你保留了让自动采集流行起来的那份易用性,却甩掉了它的税负。
要点
- 给一个展示型组件加上埋点用的
onClick,会悄无声息地把它升格成一个下发 JavaScript 的客户端组件。 - 你不需要每个元素一个处理函数——点击会冒泡到
document,而closest("[data-track]")能精确还原出被点击的是什么。 - 让组件用
data-*属性去声明埋点,并把行为放在一个于启动时注册一次的全局监听器里。 - 在监听器内部惰性导入分析 SDK,使它远离关键路径,也绝不会为不交互的用户加载。
- 尊重带修饰键 / 非主键的点击,导航时使用
sendBeacon,并让分析尽力而为,这样它永远弄不坏一个链接。 - 在服务端组件的时代,委托正是那个阻止分析把静态区块变成 hydrate 孤岛的东西。
这里没有任何一块是稀奇玩意儿:事件冒泡、closest()、data-* 属性、一个惰性的 import(),还有 sendBeacon,全都是平淡无奇、支持良好的平台特性。真正的转变在于你把行为放在哪里。别再随每个组件都发一个点击处理函数,改为整个页面只发一个监听器,让你的组件重新变回它们最擅长渲染的东西——HTML。