返回首页

说说JavaScript中的事件模型?

问题解析

事件是 JavaScript 与 HTML 交互的核心机制。理解事件模型对于处理用户交互、优化页面性能至关重要。JavaScript 中的事件模型经历了从 DOM0 到 DOM2/3 的发展,同时还需要考虑 IE 的兼容性。

核心概念

事件流

事件流描述了页面接收事件的顺序。事件流分为三个阶段:

  1. 事件捕获阶段(Capture Phase):事件从文档根节点向下传播到目标元素
  2. 目标阶段(Target Phase):事件到达目标元素
  3. 事件冒泡阶段(Bubbling Phase):事件从目标元素向上冒泡到文档根节点
          捕获阶段              目标阶段              冒泡阶段
     ┌─────────────────────────────────────────────────────┐
     │                                                     │
   document                                              document
      │                                                    ▲
      │                                                    │
    html                                                  html
      │                                                    ▲
      │                                                    │
    body                                                  body
      │                                                    ▲
      │           ┌─────────────┐                         │
      └──────────→│  target     │─────────────────────────┘
                  │  (div)      │
                  └─────────────┘

详细解答

一、事件模型分类

1. DOM0 级事件模型(原始事件模型)

// 方式1:HTML 内联事件处理程序(不推荐)
// <button onclick="handleClick()">点击</button>

// 方式2:JavaScript 属性赋值
const btn = document.getElementById('btn');

// 绑定事件
btn.onclick = function() {
    console.log('按钮被点击');
    console.log(this);  // 指向触发事件的元素
};

// 移除事件
btn.onclick = null;

特点

  • 绑定速度快,跨浏览器兼容性好
  • 同一个事件只能绑定一个处理函数(后绑定的会覆盖先绑定的)
  • 只支持冒泡阶段
  • 事件处理函数中的 this 指向触发事件的元素

2. DOM2 级事件模型(标准事件模型)

const btn = document.getElementById('btn');

// 添加事件监听
btn.addEventListener('click', function(event) {
    console.log('点击事件1');
}, false);  // false 表示在冒泡阶段处理(默认)

// 同一个事件可以绑定多个处理函数
btn.addEventListener('click', function(event) {
    console.log('点击事件2');
}, false);

// 在捕获阶段处理事件
btn.addEventListener('click', function(event) {
    console.log('捕获阶段点击');
}, true);

// 移除事件监听
function handler() {
    console.log('可以被移除的处理函数');
}
btn.addEventListener('click', handler);
btn.removeEventListener('click', handler);

// 注意:移除时必须传入相同的函数引用

特点

  • 可以绑定多个事件处理函数
  • 可以指定在捕获或冒泡阶段处理
  • 更灵活,是推荐的方式

3. IE 事件模型(已过时,了解即可)

const btn = document.getElementById('btn');

// IE8 及以下
btn.attachEvent('onclick', function() {
    console.log('IE 点击事件');
});

// 移除事件
btn.detachEvent('onclick', handler);

// 注意:
// 1. 事件名需要加 'on' 前缀
// 2. 只支持冒泡阶段
// 3. 处理函数中的 this 指向 window

二、事件对象

document.getElementById('btn').addEventListener('click', function(event) {
    // event 就是事件对象

    // 常用属性
    console.log(event.type);      // 事件类型:"click"
    console.log(event.target);    // 触发事件的目标元素
    console.log(event.currentTarget);  // 当前绑定事件的元素
    console.log(event.bubbles);   // 事件是否冒泡
    console.log(event.cancelable); // 事件是否可以取消默认行为

    // 鼠标事件特有属性
    console.log(event.clientX);   // 鼠标在视口中的 X 坐标
    console.log(event.clientY);   // 鼠标在视口中的 Y 坐标
    console.log(event.pageX);     // 鼠标在页面中的 X 坐标(包含滚动)
    console.log(event.pageY);     // 鼠标在页面中的 Y 坐标
    console.log(event.button);    // 按下的鼠标按钮(0: 左键, 1: 中键, 2: 右键)

    // 键盘事件特有属性
    console.log(event.key);       // 按下的键名
    console.log(event.keyCode);   // 键码(已废弃)
    console.log(event.code);      // 物理键位
    console.log(event.ctrlKey);   // Ctrl 是否按下
    console.log(event.shiftKey);  // Shift 是否按下
    console.log(event.altKey);    // Alt 是否按下
});

三、事件传播控制

document.getElementById('btn').addEventListener('click', function(event) {
    // 阻止事件冒泡
    event.stopPropagation();

    // 阻止事件捕获和传播(彻底停止)
    event.stopImmediatePropagation();

    // 阻止默认行为
    event.preventDefault();

    // 检查默认行为是否被阻止
    console.log(event.defaultPrevented);
});

// 阻止默认行为的其他方式
// 1. return false(仅对 DOM0 级事件有效)
btn.onclick = function() {
    return false;  // 阻止默认行为 + 阻止冒泡
};

四、事件委托

// ❌ 不好的做法:为每个子元素绑定事件
const items = document.querySelectorAll('li');
items.forEach(item => {
    item.addEventListener('click', function() {
        console.log(this.textContent);
    });
});

// ✅ 好的做法:使用事件委托
const list = document.getElementById('list');
list.addEventListener('click', function(event) {
    // 检查点击的是否是 li 元素
    if (event.target.tagName === 'LI') {
        console.log(event.target.textContent);
    }

    // 或使用 closest 方法
    const li = event.target.closest('li');
    if (li && list.contains(li)) {
        console.log(li.textContent);
    }
});

深入理解

事件执行顺序示例

<div id="outer">
    <div id="inner">
        <button id="btn">点击</button>
    </div>
</div>

<script>
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
const btn = document.getElementById('btn');

// 捕获阶段
outer.addEventListener('click', () => console.log('outer 捕获'), true);
inner.addEventListener('click', () => console.log('inner 捕获'), true);
btn.addEventListener('click', () => console.log('btn 捕获'), true);

// 目标阶段(按绑定顺序执行)
btn.addEventListener('click', () => console.log('btn 目标1'), false);
btn.addEventListener('click', () => console.log('btn 目标2'), false);

// 冒泡阶段
btn.addEventListener('click', () => console.log('btn 冒泡'), false);
inner.addEventListener('click', () => console.log('inner 冒泡'), false);
outer.addEventListener('click', () => console.log('outer 冒泡'), false);

// 点击按钮后的输出顺序:
// outer 捕获
// inner 捕获
// btn 捕获
// btn 目标1
// btn 目标2
// btn 冒泡
// inner 冒泡
// outer 冒泡
</script>

自定义事件

// 创建自定义事件
const customEvent = new Event('myEvent', {
    bubbles: true,      // 是否冒泡
    cancelable: true    // 是否可以取消
});

// 创建带数据的自定义事件
const customEventWithData = new CustomEvent('userLogin', {
    bubbles: true,
    cancelable: true,
    detail: {           // 自定义数据
        username: 'Alice',
        timestamp: Date.now()
    }
});

// 监听自定义事件
document.addEventListener('userLogin', function(event) {
    console.log('用户登录:', event.detail.username);
});

// 触发自定义事件
document.dispatchEvent(customEventWithData);

事件循环与宏任务/微任务

console.log('1');

document.addEventListener('click', function() {
    console.log('click');
});

setTimeout(function() {
    console.log('timeout');
}, 0);

Promise.resolve().then(function() {
    console.log('promise');
});

console.log('2');

// 输出顺序:
// 1
// 2
// promise(微任务)
// timeout(宏任务)
// 如果点击:click(宏任务,在 timeout 之后触发)

最佳实践

1. 优先使用 addEventListener

// ✅ 推荐
btn.addEventListener('click', handler);

// ❌ 不推荐(会被覆盖)
btn.onclick = handler;

2. 使用事件委托处理大量元素

// 处理动态添加的元素时特别有用
document.getElementById('list').addEventListener('click', function(e) {
    if (e.target.matches('li.item')) {
        // 处理点击
    }
});

3. 及时移除不需要的事件监听

function handler() {
    // 处理逻辑
}

element.addEventListener('click', handler);

// 组件销毁时移除监听
element.removeEventListener('click', handler);

4. 使用 passive 事件监听器优化滚动性能

// 告诉浏览器不会调用 preventDefault()
window.addEventListener('scroll', handler, { passive: true });

// 适用于 touchstart, touchmove, wheel 等事件

5. 使用 once 选项自动移除监听

// 事件只触发一次,自动移除监听
element.addEventListener('click', handler, { once: true });

面试要点

  1. 什么是事件流?

    • 事件捕获阶段:从文档根节点向下到目标元素
    • 目标阶段:到达目标元素
    • 事件冒泡阶段:从目标元素向上到文档根节点
  2. DOM0、DOM2 事件模型的区别?

    • DOM0:属性赋值,只能绑定一个处理函数,只支持冒泡
    • DOM2:addEventListener,可绑定多个,可选择捕获/冒泡
  3. 事件委托是什么?有什么优点?

    • 将事件绑定到父元素,利用冒泡机制处理子元素事件
    • 优点:减少内存占用、动态添加的元素也能响应
  4. 如何阻止事件冒泡和默认行为?

    • 阻止冒泡:event.stopPropagation()
    • 阻止默认行为:event.preventDefault()
    • 两者都阻止:return false(DOM0 级)
  5. target 和 currentTarget 的区别?

    • target:触发事件的实际元素
    • currentTarget:绑定事件的元素
  6. 什么是自定义事件?

    • 使用 Event 或 CustomEvent 创建
    • 可以携带自定义数据
    • 通过 dispatchEvent 触发