一个内容站点的交互通常不复杂:切标签、复制、下载、表单校验。为这些引入前端框架反而增加维护成本。用原生模块加上事件委托,几十行代码就能覆盖,还不用担心元素被重新渲染后事件丢失。

为什么用事件委托
直接给每个按钮绑事件,有两个问题:元素是动态插入时绑不上,列表很长时绑定次数过多。委托把监听放在父级(通常是 document),靠事件冒泡统一处理。
document.addEventListener('click', (event) => {
const button = event.target.closest('[data-copy]');
if (! button) {
return;
}
copyText(button.dataset.copy);
});
closest 会从点击目标向上找最近的匹配元素,所以按钮内部的图标、文字被点到也能正确识别。这种写法对「后来才插入的节点」天然有效。
幂等初始化
模块可能被多次加载(例如页面局部刷新后重新执行脚本),重复绑定会让一次点击触发两次。用一个标记位解决:
let bound = false;
export function initTabs() {
if (bound) {
return;
}
bound = true;
document.addEventListener('click', (event) => {
const tab = event.target.closest('[data-tab]');
if (! tab) {
return;
}
switchTab(tab);
});
}
这类守卫在「一个页面加载多个模块」时尤其重要:每个模块只管自己的选择器,互不干扰。
作用域要收窄
委托在 document 上,最容易出的问题是「A 模块的点击影响了 B 模块的界面」。解决办法是给面板容器加标记,切换时只处理自己作用域内的元素:
const container = tab.closest('[data-tool]') ?? document;
container.querySelectorAll('[data-panel]').forEach((panel) => {
panel.classList.toggle('hidden', panel.dataset.panel !== tab.dataset.tab);
});
先找最近的容器,找不到才退回 document。这样即使一个页面里有多个同类组件,也不会互相误伤。
模块之间用自定义事件通信
模块直接互相调用会形成依赖网。让模块只发事件、不关心谁在听,耦合成都会低很多:
// 发送方
document.dispatchEvent(new CustomEvent('image:loaded', {
detail: { width, height },
}));
// 接收方
document.addEventListener('image:loaded', (event) => {
console.log(event.detail.width);
});
事件名建议带命名空间(模块:动作),方便搜索与排查。传数据统一放在 detail 里,不要往事件对象上挂自定义属性。
性能与可访问性
- 滚动、
touchmove这类高频事件加{ passive: true },避免阻塞滚动; - 需要读取布局的计算放在
requestAnimationFrame里,减少重复回流; - 用
button而不是给div加点击事件,键盘与读屏才能正常使用; - 动态生成的内容补上
aria-label,图标按钮尤其需要。
小结
原生交互层的核心是三条:委托统一入口、标记保证幂等、容器限定作用域。掌握之后,即使项目后来引入框架,这部分逻辑也能平移过去。想验证复制、编码这类小工具的行为,可以直接用站内的编码与哈希工具对照结果。