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

不依赖框架的前端交互层:事件委托与模块化实践 示意图

为什么用事件委托

直接给每个按钮绑事件,有两个问题:元素是动态插入时绑不上,列表很长时绑定次数过多。委托把监听放在父级(通常是 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,图标按钮尤其需要。

小结

原生交互层的核心是三条:委托统一入口、标记保证幂等、容器限定作用域。掌握之后,即使项目后来引入框架,这部分逻辑也能平移过去。想验证复制、编码这类小工具的行为,可以直接用站内的编码与哈希工具对照结果。