盐汽水的海洋

JavaScript 事件

avatar

盐汽水

目标:2–3 小时从零到能实战。
结构:概念 → 示例 → 练习 → 自测。内容聚焦 20% 关键知识覆盖 80% 常见场景。


一、核心概念与必须掌握的 API/配置(80/20 速览)

1) 核心概念(每条 ≤2 句)

  • 事件流(捕获 → 目标 → 冒泡):事件从外到内捕获,命中目标后转入冒泡阶段向外传播。用 event.eventPhase 可得阶段(1/2/3)。

  • 冒泡优先:绝大多数业务监听用冒泡阶段即可,便于事件委托与统一处理。仅在“必须先于子元素处理”的场景用捕获。

  • 事件委托:把很多子节点的事件交由父节点统一监听,动态节点也能被捕获。结合 event.target + closest() 做过滤。

  • 默认行为与可取消:许多事件有浏览器默认动作(链接跳转、表单提交、滚动)。event.cancelable 为真时可用 preventDefault() 取消。

  • 传播控制stopPropagation() 阻止继续传播;stopImmediatePropagation() 还会阻止同一元素上后续监听器。慎用,避免影响委托。

  • 事件对象:通用字段有 type/target/currentTarget/timeStamp/isTrusted/defaultPreventedevent.composedPath() 可查看实际传播路径(含 Shadow DOM)。

  • target vs currentTargettarget 是触发事件的最深节点,currentTarget 是当前正在执行回调的节点。优先基于 currentTarget 做相对查找。

  • 指针/鼠标/触摸:优先使用 Pointer Events 统一鼠标、触屏和手写笔;必要时用 CSS touch-action 控制浏览器手势。

  • 键盘输入:用 keydown/keyup 搭配 event.key(不要再用过时的 keyCode)。组合键看 ctrlKey/shiftKey/altKey/metaKey

  • 文本输入input 实时触发,change 在文本框上多在“提交/失焦”时触发;中文输入需关注 compositionstart/update/end

  • 表单提交:总在 <form> 上监听 submit 做校验并 preventDefault(),按钮用 type="submit" 以支持回车提交。

  • 加载与可见性DOMContentLoaded 表示 DOM 可操作,load 资源也就绪;visibilitychange 可在页面隐藏时暂停轮询/动画。

  • 性能:滚动/指针移动/输入是高频事件,应节流/防抖并尽量使用 {passive:true};回调内尽量只读状态、延后写 DOM。

  • 清理:组件销毁或页面离开前要移除监听。使用 AbortController 能一键取消成组监听。

2) 必学 API / 配置(每条 ≤2 句)

  • addEventListener(type, listener, options):第三参建议用对象写法,如 {capture:false, once:false, passive:false, signal}

  • removeEventListener(type, listener, options):移除时必须是同一函数引用;或用 AbortController 统一取消。

  • 监听选项capture 控制阶段,once 自动移除一次性监听,passive 禁用 preventDefault 以优化滚动,signal 绑定取消信号。

  • EventTargetwindow/document/Element 均实现此接口,是“能监听事件”的共同基类。

  • preventDefault() / defaultPrevented:取消默认行为并可检测是否已被取消。

  • stopPropagation() / stopImmediatePropagation():停止冒泡/本元素后续监听器,避免滥用。

  • event.target / event.currentTarget / event.composedPath():命中元素与传播路径定位的三件套。

  • dispatchEvent(evt) / new CustomEvent(name,{detail}):分发/监听自定义事件,业务数据放在 detail

  • 常见事件类型click/contextmenupointerdown/move/up/enter/leavekeydown/keyupinput/change/submitscroll/wheeltransitionend/animationend

  • 委托工具Element.matches(selector)Element.closest(selector) 是做事件过滤与上溯命中的关键。

  • 键盘细节event.key 表语义(如 "Enter"),event.code 表物理键位(如 "Enter"/"KeyA")。

  • 渲染协调:在高频回调中用 requestAnimationFrame 合批 DOM 写入,或自实现防抖/节流包裹 handler。

  • 浏览器策略:滚动相关监听默认使用 {passive:true};如需阻止触摸滚动,优先用 CSS touch-action 而不是在监听里 preventDefault

  • 生命周期事件beforeunload(离开确认)、visibilitychange(前后台切换);谨慎使用以免打扰用户。

    • *

二、典型应用场景与设计取舍

  • 大量动态列表点击处理:委托 vs 逐个绑定

    • 委托:只绑一次,O(1) 内存,支持动态增删;但易被子元素的 stopPropagation 干扰。

    • 逐个绑定:逻辑直观但 O(n) 监听且需要清理;适合节点数很少且生命周期明确的场景。

  • 滚动相关交互:passive:true vs 可取消

    • 被动监听:保证滚动流畅,但无法 preventDefault();适合统计/懒加载。

    • 可取消监听:能拦截滚动,但可能卡顿;若要禁用双指缩放/下拉刷新,优先 CSS touch-action

  • 指针事件 vs 鼠标+触摸双栈

    • Pointer:一套事件适配鼠标/触屏/手写笔,带 pointerId;推荐默认方案。

    • Mouse/Touch:历史兼容用,需自己做合流与去抖;除非要兼容极旧设备,否则不建议。

  • 表单处理:表单级监听 vs 输入级监听

    • 表单级(submit:集中校验/收集/提交;配合原生可访问性和回车提交。

    • 输入级(input/change:做实时校验提示;但最终提交逻辑仍应放在 submit

  • 捕获阶段拦截 vs 冒泡阶段处理

    • 捕获:需要“抢先”处理或做“外部点击关闭弹层”且可能被子元素阻止时使用。

    • 冒泡:默认选择,逻辑更简单,易于委托和排查。

  • 清理方式:removeEventListener vs AbortController

    • remove:需要保留回调引用,易遗漏。

    • AbortController:集中取消一组监听,代码更健壮;推荐在组件卸载时使用。

    • *

三、从零开始的最小可运行示例(含注释与步骤)

运行步骤

  1. 新建文件 events-demo.html,将下方完整代码粘贴保存。

  2. 用任意现代浏览器直接打开文件。

  3. 按页面上的说明点击/滚动/提交表单,观察右侧日志与不同阶段效果。

完整代码(单文件可运行)

<!doctype html>
<meta charset="utf-8" />
<title>JS 事件 80/20 示例</title>
<style>
  body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; line-height: 1.5; }
  h2 { margin-top: 32px; }
  .box { border: 2px solid #ccc; padding: 12px; margin: 8px 0; }
  #outer { padding: 16px; background: #f6f6ff; }
  #inner { padding: 16px; background: #e8f8ff; cursor: pointer; }
  #log { height: 180px; overflow: auto; background: #0b1021; color: #e6e6e6; padding: 8px; }
  #list li { display: flex; align-items: center; gap: 8px; padding: 6px 0; }
  #list li.mark { background: #fff8e1; }
  #scrollBox { height: 120px; overflow: auto; border: 1px dashed #aaa; padding: 8px; }
  .row { display: flex; gap: 16px; align-items: start; }
  .hint { color: #666; font-size: 12px; }
  button { cursor: pointer; }
</style>

<body>
  <h1>JavaScript 事件最小实践</h1>

  <div class="row">
    <div style="flex:2">
      <h2>1) 捕获 vs 冒泡(点击 inner 查看日志)</h2>
      <label><input id="chkStop" type="checkbox"> 在 inner 上阻止冒泡</label>
      <div id="outer" class="box">outer
        <div id="inner" class="box">inner(点我)</div>
      </div>

      <h2>2) 事件委托(列表增删/标记)</h2>
      <div class="box">
        <button id="addItem">新增一行</button>
        <ul id="list" class="box" aria-label="items"></ul>
        <div class="hint">父节点 #list 上只绑定一个 click 监听;子节点按钮通过选择器命中。</div>
      </div>

      <h2>3) 表单默认行为(阻止提交/链接跳转)</h2>
      <form id="signup" class="box">
        <label>邮箱:<input name="email" placeholder="you@example.com" required></label>
        <button type="submit">提交</button>
        <a id="demoLink" href="https://example.com" target="_blank" rel="noreferrer">演示链接</a>
        <label><input id="chkPreventLink" type="checkbox"> 拦截链接的默认跳转</label>
      </form>

      <h2>4) 高频事件与 passive(滚动我)</h2>
      <div id="scrollBox">
        <p>这是一个可滚动容器。向下滚动观察日志;监听使用了 { passive:true }。</p>
        <p>被动监听能保持滚动流畅,但你不能在回调里 preventDefault()。</p>
        <p>……(填充文字)……</p>
        <p>……(填充文字)……</p>
        <p>……(填充文字)……</p>
        <p>……(填充文字)……</p>
      </div>

      <h2>5) 自定义事件 + 统一清理</h2>
      <div class="box">
        <button id="startOnce">只监听一次的按钮</button>
        <button id="startGroup">开始一组高频监听</button>
        <button id="stopGroup">统一移除(AbortController)</button>
      </div>
    </div>

    <div style="flex:1">
      <h2>日志</h2>
      <pre id="log"></pre>
      <div class="hint">可清空:双击此日志区域。</div>
    </div>
  </div>

<script>
  // --- 简易日志工具 ---
  const logEl = document.getElementById('log');
  const log = (...args) => { logEl.textContent += args.join(' ') + '\n'; logEl.scrollTop = logEl.scrollHeight; };
  logEl.addEventListener('dblclick', () => logEl.textContent = '');

  // --- 1) 捕获 vs 冒泡演示 ---
  const outer = document.getElementById('outer');
  const inner = document.getElementById('inner');
  const chkStop = document.getElementById('chkStop');

  // 捕获阶段监听(在 outer 上)
  outer.addEventListener('click', (e) => {
    log(`[capture] current=${e.currentTarget.id}, target=${e.target.id}, phase=${e.eventPhase}`);
  }, { capture: true });

  // 冒泡阶段监听(在 outer 上)
  outer.addEventListener('click', (e) => {
    log(`[bubble ] current=${e.currentTarget.id}, target=${e.target.id}, phase=${e.eventPhase}`);
  });

  // 在 inner 上可选择阻止冒泡
  inner.addEventListener('click', (e) => {
    if (chkStop.checked) {
      e.stopPropagation();
      log('inner: stopPropagation() 已调用');
    } else {
      log('inner: 冒泡未阻止');
    }
  });

  // --- 2) 列表的事件委托 ---
  const list = document.getElementById('list');
  const addBtn = document.getElementById('addItem');
  let seq = 1;

  addBtn.addEventListener('click', () => {
    const li = document.createElement('li');
    li.innerHTML = `
      <span>Item #${seq++}</span>
      <button class="markBtn">标记</button>
      <button class="removeBtn">删除</button>
    `;
    list.appendChild(li);

    // 分发一个自定义事件,通知“有新项添加”
    list.dispatchEvent(new CustomEvent('app:item-added', {
      bubbles: true, detail: { text: li.textContent.trim() }
    }));
  });

  // 父节点上一个监听处理所有子按钮
  list.addEventListener('click', (e) => {
    const li = e.target.closest('li');
    if (!li || !list.contains(li)) return; // 只处理列表内部
    if (e.target.matches('.removeBtn')) {
      li.remove();
      log('委托: 删除了一行');
    } else if (e.target.matches('.markBtn')) {
      li.classList.toggle('mark');
      log('委托: 切换标记状态');
    }
  });

  // 监听自定义事件
  document.addEventListener('app:item-added', (e) => {
    log(`自定义事件: 新增 -> ${e.detail.text}`);
  });

  // --- 3) 表单默认行为 ---
  const form = document.getElementById('signup');
  const chkPreventLink = document.getElementById('chkPreventLink');
  const demoLink = document.getElementById('demoLink');

  form.addEventListener('submit', (e) => {
    e.preventDefault(); // 取消默认提交
    const data = new FormData(form);
    const email = data.get('email');
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
      log('表单: 邮箱格式不正确');
      return;
    }
    log(`表单: 校验通过,准备提交 -> ${email}`);
    // 这里可发起 fetch(...) 自行提交
  });

  // 可选:拦截 a 链接的默认跳转
  demoLink.addEventListener('click', (e) => {
    if (chkPreventLink.checked) {
      e.preventDefault();
      log('链接: 已阻止默认跳转');
    } else {
      log('链接: 允许默认跳转(将在新标签页打开)');
    }
  });

  // --- 4) 高频事件 + passive ---
  const scrollBox = document.getElementById('scrollBox');
  let lastScrollRAF = 0;
  // wheel 使用 passive 能保证滚动流畅(注意此时不能 preventDefault)
  scrollBox.addEventListener('wheel', (e) => {
    // 仅记录,不做繁重工作
    if (!lastScrollRAF) {
      lastScrollRAF = requestAnimationFrame(() => {
        lastScrollRAF = 0;
        log(`滚动: wheel @ ${Math.round(scrollBox.scrollTop)} (passive=${true})`);
      });
    }
  }, { passive: true });

  scrollBox.addEventListener('scroll', () => {
    // 也可以监听 scroll(不可取消),同样保持轻量
  });

  // --- 5) once + AbortController 统一清理 ---
  const startOnce = document.getElementById('startOnce');
  startOnce.addEventListener('click', () => {
    document.body.addEventListener('click', () => {
      log('once: 这个提示只会出现一次(下一次点击 body 不再触发)');
    }, { once: true });
    log('已安装一个 once 监听在 body 上');
  });

  let controller = null;
  const startGroup = document.getElementById('startGroup');
  const stopGroup = document.getElementById('stopGroup');

  startGroup.addEventListener('click', () => {
    if (controller) controller.abort(); // 先清旧的
    controller = new AbortController();
    const { signal } = controller;

    // 绑定一组高频监听,全部挂到同一个 signal 上
    window.addEventListener('pointermove', onPointerMove, { signal });
    window.addEventListener('keydown', onKeyDown, { signal });
    log('已开始一组监听(pointermove/keydown)');
  });

  stopGroup.addEventListener('click', () => {
    if (controller) {
      controller.abort(); // 一键移除这一组
      controller = null;
      log('已通过 AbortController 统一移除监听');
    }
  });

  let pmRAF = 0;
  function onPointerMove(e) {
    if (!pmRAF) {
      pmRAF = requestAnimationFrame(() => {
        pmRAF = 0;
        log(`pointermove: (${e.clientX}, ${e.clientY})`);
      });
    }
  }
  function onKeyDown(e) {
    if (e.key === 'Escape') log('keydown: ESC');
    if (e.ctrlKey && e.key === 'k') {
      e.preventDefault(); // 这里是可取消事件
      log('keydown: Ctrl+K(已阻止默认行为,如浏览器内置搜索)');
    }
  }
</script>
</body>

四、常见坑与调试技巧

常见坑

  1. 用错 target/currentTarget:委托时应基于 event.target 做命中,再用 closest() 锁定业务元素;不要指望 this

  2. 移除监听失败removeEventListener 需要相同的函数引用,匿名函数无法移除;推荐 AbortController

  3. stopPropagation 误用:它只影响传播,不会阻止默认;要阻止默认请 preventDefault

  4. passive:true 下调用 preventDefault 无效:会被浏览器忽略且控制台告警;需要阻止触摸滚动时用 CSS touch-action

  5. input vs change 混用:文本框的 change 多在失焦触发;复选框的 change 会立即触发。

  6. 键盘值兼容:跨布局键位用 event.code,跨语言字符用 event.key;不要再使用 keyCode

  7. focus/blur 不冒泡:需要委托时使用 focusin/focusout

  8. mouseenter/mouseleave 不冒泡:委托要用 mouseover/mouseout 并结合 relatedTarget 判断。

  9. 高频回调做重活:滚动/指针移动里做 DOM 写入/布局查询会卡顿;用 requestAnimationFrame 合批或防抖/节流。

  10. 重复绑定导致多次触发:渲染多次挂载时要在卸载阶段清理,或用 { once:true } 避免重复。

调试技巧

  • Chrome DevTools → Elements → Event Listeners:查看某元素绑定了哪些监听器、在第几行代码绑定。

  • Sources → Event Listener Breakpoints:打断点到特定类型(如鼠标、键盘、动画)事件上。

  • 控制台命令monitorEvents(node, 'click') / unmonitorEvents(node) 监控事件;getEventListeners(node) 查看已绑监听。

  • 定位传播路径:在回调里打印 event.composedPath() 观察捕获/冒泡经过的节点。

  • 区分用户触发event.isTrusted 为真表示用户真实操作,伪造事件将为假。

  • 性能分析:Performance 面板录制滚动/输入,检查长任务;回调内用 performance.mark() 打点。

    • *

五、分层练习清单(每级 3 项)

入门(巩固 API 使用)

  1. 计数器按钮:点击 +1 按钮更新文本;要求:用冒泡监听父容器,按钮数量可动态增。

  2. 表单拦截:登录表单在 submit 里校验非空和邮箱格式;要求:通过/失败分别展示提示。

  3. Tab 切换:委托在容器上处理 tab 标题点击,切换面板显示;要求:仅用一个监听器。

巩固(处理高频/组合输入)

  1. 键盘快捷键:实现 Ctrl+K 打开搜索框、Esc 关闭;要求:阻止默认、兼容 Mac(metaKey)。

  2. 滚动懒加载:滚动容器触底时加载更多;要求:{passive:true} + 防抖/节流。

  3. 自定义事件总线:用 CustomEvent 实现“发布订阅”,组件 A 发 app:notify,组件 B 响应;要求:事件名统一前缀。

进阶(复杂交互与清理)

  1. 可拖拽排序:用 Pointer Events 实现列表拖拽重排;要求:pointerId 追踪,touch-action: none

  2. 外部点击关闭弹层:用捕获阶段监听文档 click,判断 composedPath() 是否包含弹层;要求:内部点击不关闭。

  3. 统一资源清理:组件装载时绑 5 个监听,卸载时用 AbortController 一键清理;要求:无内存泄漏。


六、自测清单 + 闭卷题

自测清单(完成即合格)

  • 我能解释事件的三个阶段以及默认为何用冒泡。

  • 我会用 addEventListener 的对象选项,并说出 capture/once/passive/signal 的作用。

  • 我能用委托在父节点处理子项的点击,并用 closest() 做过滤。

  • 我知道何时该 preventDefault,何时该 stopPropagation,以及两者的区别。

  • 我能正确区分 targetcurrentTarget,并打印 composedPath()

  • 我会用 Pointer Events 统一鼠标/触摸并设置合适的 touch-action

  • 我能写一个 submit 监听做表单校验,并在回车时也能提交。

  • 我会在滚动/指针移动中做节流/防抖或 requestAnimationFrame 合批。

  • 我能用 AbortController 统一移除一组监听。

  • 我知道调试事件的常用工具:Event Listeners 面板、Event Listener Breakpoints、monitorEvents()

闭卷题(含答案)

  1. 选择题:在父元素 ul 上用委托监听 click,点击子元素按钮时,下面打印哪个更稳定?
    A. event.targetid B. event.currentTargetid C. this.id(取决于绑定方式)
    标准答案:B。委托逻辑基于父元素处理,currentTarget 永远指向正在执行回调的节点(即父元素)。

  2. 判断题:给滚动容器绑定 addEventListener('wheel', handler, { passive:true }) 后,handler 里仍可 preventDefault() 阻止滚动。
    标准答案:错。passive:true 会让浏览器忽略 preventDefault(),目的在于保证滚动流畅。

  3. 简答题inputchange 在文本输入框上的触发时机有什么区别?复选框呢?
    标准答案:文本框 input 每次内容变化就触发,change 通常在失焦或提交时触发;复选框的 change 在选中状态改变的瞬间就触发。


附:团队内部规范(可直接采纳)

  • 一律使用 addEventListener,禁用内联 on* 属性与 onclick=

  • 默认绑定在冒泡阶段,只有确有需要时使用捕获。

  • 所有滚动/触摸相关监听默认 { passive:true },如需阻止滚动用 CSS touch-action

  • 列表/表格等大量重复元素必须使用事件委托。

  • 组件装载时统一创建 AbortController,卸载时 abort()

  • 自定义事件名统一前缀,如 app:*;事件负载统一放在 detail

  • 高频回调内禁止直接修改布局,使用 requestAnimationFrame 或节流/防抖。

  • 键盘快捷键需同时考虑 Windows(Ctrl)与 Mac(Meta)按键。

    • *

官方文档速读路径(关键词可直接检索)

  • MDN Web DocsEventTarget.addEventListener / removeEventListenerEvent / CustomEventPointerEventKeyboardEventInputEventFocus Eventspassive listeners

  • WHATWG DOM Standard:Events, Event Phases, Event Path。

  • Pointer Events Level 2:统一指针模型与 touch-action

  • UI Events:键盘/输入相关语义。

按本指南完成一套示例与练习,你已具备应对 80% 前端日常“事件”场景的能力。祝编码顺利!


闽ICP备2021014815号-2
powered by emlog sitemap