{T}

事件

JavaScriptHTML 之间的交互是通过事件实现的。事件 就是文档或浏览器窗口中发生的一些特定的交互。可以使用侦听器(或处理程序)来预订事件,以便事件发生时执行相应的代码

浏览器的事件系统相对比较复杂。尽管所有主要浏览器已经实现了“DOM2 级事件”,但这个规范本身并没有涵盖所有事件类型。浏览器对象模型(BOM)也支持一些事件,这些事件与文档对象模型(DOM)事件之间的关系并不十分清晰,因为 BOM 事件长期没有规范可以遵循

随着 DOM3 级的出现,增强后的 DOM 事件 API 变得更加繁琐。使用事件有时相对简单,有时则非常复杂,难易程度会因你的需求而不同

事件流

事件流描述的是从页面中接收事件的顺序。但有意思的是,IENetscape 开发团队居然提出差不多是完全相反的事件流的概念。IE 的事件流是事件冒泡流,而 Netscape Communicator 的事件流是事件捕获流

事件冒泡

IE 的事件流叫做事件冒泡 event bubbling,即事件开始时由最具体的元素接收,然后逐级向上传播到较为不具体的节点(文档)

html
<!-- HTML 结构省略,仅展示关键 JS 逻辑 -->

如果单击页面中的 div 元素, click 事件会按照如下顺序传播:div --> body --> html --> documentclick 事件沿 DOM 树向上传播,在每一级节点上都会发生,直至传播到 document 对象

事件捕获

事件捕获 event capture 的用意在于在事件到达预定目标之前截获它。仍以前面的 HTML 页面作为演示事件捕获的例子,那么单击 div 元素就会以下列顺序触发 click 事件:document --> html --> body --> div

在事件捕获过程中,document 对象首先接收到 click 事件,然后事件沿 DOM 树依次向下,一直传播到事件的实际目标,即 div 元素。捕获阶段常用于在事件到达目标前做权限校验、记录日志或提前拦截不希望继续向下的事件。

DOM 事件流

DOM2 级事件 规定的事件流包括三个阶段:事件捕获阶段、处于目标阶段和事件冒泡阶段。首先发生的是事件捕获,为截获事件提供机会。然后是实际的目标接收到事件。最后一个阶段是冒泡阶段,可以在这个阶段对事件做出响应

DOM 事件流图示

DOM 事件流中,实际的目标( div 元素)在捕获阶段不会接收到事件。这意味着在捕获阶段,事件从 document<html> 再到 <body> 后就停止了。下一个阶段是 处于目标 阶段,于是事件在 <div>上发生,并在事件处理中被看成冒泡阶段的一部分。然后,冒泡阶段发生,事件又传播回文档

事件阶段与 eventPhase

每个事件在触发时都包含一个 event.eventPhase 属性,用来标识当前所处的阶段:

  • 1 表示捕获阶段(从外向内)
  • 2 表示到达目标节点
  • 3 表示冒泡阶段(从内向外)

利用该属性可以在同一个监听函数中判断当前阶段,或仅在特定阶段执行逻辑。例如:

javascript
document.addEventListener(
  "click",
  (event) => {
    if (event.eventPhase === Event.CAPTURING_PHASE) {
      console.log("capturing")
    }
    if (event.eventPhase === Event.BUBBLING_PHASE) {
      console.log("bubbling")
    }
  },
  true // capture
)

事件目标节点会先以捕获阶段触发监听函数(如果注册在捕获阶段),随后以目标阶段触发,再进入冒泡阶段返回祖先节点。这也是同一个元素上既能监听捕获又能监听冒泡的原因。

事件对象

在触发 DOM 上的某个事件时,会产生一个事件对象 event。这个对象包含事件相关的所有信息:事件目标、类型、触发来源以及特定事件才具备的附加数据。鼠标事件会记录坐标,键盘事件会记录按键,指针事件还会携带压力与倾斜角度。现代浏览器都实现了 Event 接口,但各个 API 的默认值与细节仍可能有所差异。

Event 基本用法

Event 是浏览器事件系统的基础类型。绝大多数原生事件(例如 clicksubmitkeydown)都继承自 Event.prototype,而鼠标、键盘、指针等更具体的事件类型会在其之上扩展专属属性。

创建原生事件

javascript
const look = new Event("look", {
  bubbles: true,
  cancelable: false,
  composed: false
})

构造函数接受两个参数:

  • type:字符串,表示事件名称
  • options:可选配置对象,常用字段包括:
    • bubbles:事件是否冒泡,默认 false
    • cancelable:是否允许调用 preventDefault(),默认 false
    • composed:事件是否可以穿透 Shadow DOM,默认 false

注意: 如果不显式将 bubbles 设为 true,事件只能在捕获阶段触发监听函数,且不会冒泡到目标节点之外。

触发事件

创建事件后,可通过 dispatchEvent 主动分发:

javascript
const lookEvent = new Event("look", { bubbles: true })

document.addEventListener("look", () => {
  console.log("look fired")
})

document.dispatchEvent(lookEvent)
  • dispatchEvent 返回布尔值,表示事件是否被取消(若监听器调用了 preventDefault() 且事件可取消,则返回 false
  • 脚本触发的事件其 event.isTrustedfalse,表示非用户手动操作

捕获与冒泡示例

javascript
// HTML: <div><p>Hello</p></div>
const div = document.querySelector("div")
const p = document.querySelector("p")

function callback(event) {
  console.log("Tag:", event.currentTarget.tagName)
}

div.addEventListener("click", callback, false)

const click = new Event("click")
p.dispatchEvent(click) // 不会触发 callback,因为默认不冒泡

div.addEventListener("click", callback, true) // 捕获阶段监听
p.dispatchEvent(click) // 此时可在捕获阶段监听到

当事件在目标 div 上触发时,无论监听器处于捕获还是冒泡阶段都会执行,因为此时 div 即是事件目标。

与 CustomEvent 的对比

Event 只提供通用字段;若需要携带自定义数据,可使用 CustomEvent

javascript
const notify = new CustomEvent("user:login", {
  detail: { id: 1, name: "Alice" },
  bubbles: true,
  composed: true
})

document.body.addEventListener("user:login", (event) => {
  console.log(event.detail)
})

document.body.dispatchEvent(notify)

CustomEventdetail 字段可以携带任意 JSON 兼容的数据,常用于组件间通信。自定义事件默认不冒泡、不穿透 Shadow DOM,可根据需要设置。

旧式创建方式(了解即可)

旧代码中可能出现 document.createEvent("Event") 搭配 initEvent 的写法:

javascript
const legacy = document.createEvent("Event")
legacy.initEvent("legacy", true, false)

该 API 已被现代浏览器标记为不推荐使用,遇到旧代码时可替换为 new Event("legacy", { bubbles: true })

实例属性

DOM 合规的浏览器中,event 对象是传给事件处理程序的唯一参数。不管以哪种方式指定事件处理程序(DOM0DOM2),都会传入 event 对象。

event 对象包含与创建它的特定事件有关的属性和方法。触发的事件类型不一样,可用的属性和方法也不一样:

属性/方法类型说明
bubbles布尔值表示事件是否冒泡
cancelable布尔值表示事件是否可以取消默认行为
defaultPrevented布尔值表示是否已调用 preventDefault()
currentTarget节点事件当前正在通过的节点(监听器所在元素)
target节点原始触发事件的节点,不随传播改变
type字符串被触发的事件类型
eventPhase数字调用事件处理程序的阶段(捕获/目标/冒泡)
detail数字与事件相关的额外信息
preventDefault()方法取消事件的默认行为
stopPropagation()方法停止事件在传播层次的传播
stopImmediatePropagation()方法停止传播,并阻止同一元素上后续监听器执行

事件经过捕获与冒泡两个阶段,因此任意事件都有两个与之相关的节点:原始触发节点 event.target 与事件当前正在通过的节点 event.currentTarget

  • event.currentTarget 返回事件当前所在的节点,即当前正在执行监听函数的元素。随着事件传播会发生变化
  • event.target 返回原始触发事件的节点,不会随着传播改变
code
<!DOCTYPE html>
<html>
  <head>
    <title>DOM Event Object Example</title>
  </head>
  <body>
    <input type="button" id="myBtn" value="Click Me" />
    <script type="text/javascript">
      document.body.onclick = function (event) {
        alert(event.currentTarget === document.body) // true
        alert(this === document.body) // true
        alert(event.target === document.getElementById("myBtn")) // true
      }
    </script>
  </body>
</html>

type

在需要通过统一函数处理多个事件时,可以使用 event.type 区分逻辑:

html
<!DOCTYPE html>
<html>
  <head>
    <title>DOM Event Object Example</title>
  </head>
  <body>
    <input type="button" id="myBtn" value="Click Me" />
    <script type="text/javascript">
      let btn = document.getElementById("myBtn")
      var handler = function (event) {
        switch (event.type) {
          case "click":
            alert("Clicked")
            break
          case "mouseover":
            event.target.style.backgroundColor = "red"
            break
          case "mouseout":
            event.target.style.backgroundColor = ""
            break
        }
      }

      btn.onclick = handler
      btn.onmouseover = handler
      btn.onmouseout = handler
    </script>
  </body>
</html>

bubbles 与 eventPhase

event.bubbles 返回一个布尔值,表示事件是否会冒泡。除非显式声明,Event 构造函数生成的事件默认不冒泡。

event.eventPhase 返回一个整数常量,表示事件目前所处的阶段:

  • 0:事件尚未发生
  • 1:捕获阶段,从祖先节点向目标节点传播
  • 2:到达目标节点
  • 3:冒泡阶段,从目标节点向祖先节点反向传播

cancelable、cancelBubble 与 defaultPrevented

event.cancelable 表示事件是否可以取消;大多数浏览器的原生事件可以取消,但 Event 构造函数生成的事件默认不可取消。

javascript
var evt = new Event("foo")
evt.cancelable // false

event.cancelable === true 时,调用 event.preventDefault() 可以取消默认行为;否则调用无效,因此最好先判断。

javascript
function preventEvent(event) {
  if (event.cancelable) {
    event.preventDefault()
  } else {
    console.warn("This event couldn't be canceled.")
    console.dir(event)
  }
}

event.cancelBubble 设为 true 相当于执行 event.stopPropagation(),可以阻止事件传播。event.defaultPrevented 表示是否已经调用过 event.preventDefault()

javascript
if (event.defaultPrevented) {
  console.log("该事件已经取消了")
}

timeStamp

event.timeStamp 返回一个毫秒时间戳,表示事件发生的时间,通常相对于页面加载开始时计算:

javascript
var evt = new Event("foo")
evt.timeStamp // 3683.6999999995896

浏览器可能返回整数或高精度小数。下面的示例根据鼠标移动距离与触发时间计算速度:

javascript
var previousX
var previousY
var previousT

window.addEventListener("mousemove", function (event) {
  if (previousX !== undefined && previousY !== undefined && previousT !== undefined) {
    var deltaX = event.screenX - previousX
    var deltaY = event.screenY - previousY
    var deltaD = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2))

    var deltaT = event.timeStamp - previousT
    console.log((deltaD / deltaT) * 1000)
  }

  previousX = event.screenX
  previousY = event.screenY
  previousT = event.timeStamp
})

isTrusted

event.isTrusted 返回一个布尔值,表示事件是否由真实的用户行为产生。脚本构造的事件该属性为 false

javascript
var evt = new Event("foo")
evt.isTrusted // false

detail

只有浏览器的 UI 事件才具有 event.detail 属性。该属性返回一个数值,表示事件的附加信息,含义取决于事件类型。

javascript
// HTML 代码如下
// <p>Hello</p>
function giveDetails(e) {
  console.log(e.detail)
}

document.querySelector("p").onclick = giveDetails

对于 clickdblclickdetail 表示鼠标按下次数;对于滚轮事件则表示滚动距离。

实例方法

preventDefault

event.preventDefault() 取消浏览器对当前事件的默认行为。只有 cancelabletrue 的事件可被取消。

html
<!DOCTYPE html>
<html>
  <head>
    <title>DOM Event Object Example</title>
  </head>
  <body>
    <a href="http://www.nczonline.net/" id="myLink">Click Me</a>
    <p>This link will not navigate when clicked.</p>
    <script type="text/javascript">
      var link = document.getElementById("myLink")
      link.onclick = function (event) {
        event.preventDefault()
      }
    </script>
  </body>
</html>

浏览器默认行为示例:单击会选中单选框,若调用 preventDefault() 就无法选中:

javascript
// HTML 代码为
// <input type="checkbox" id="my-checkbox" />

var cb = document.getElementById("my-checkbox")

cb.addEventListener(
  "click",
  function (e) {
    e.preventDefault()
  },
  false
)

还可以利用该方法给文本输入框设置校验条件:

javascript
// HTML 代码为
// <input type="text" id="my-input" />

var input = document.getElementById("my-input")
input.addEventListener("keypress", checkName, false)

function checkName(e) {
  if (e.charCode < 97 || e.charCode > 122) {
    e.preventDefault()
  }
}

stopPropagation

event.stopPropagation() 用于立即停止事件在 DOM 层次中的传播,即取消进一步的捕获或冒泡。

javascript
var btn = document.getElementById("myBtn")

btn.onclick = function (event) {
  alert("Clicked")
  event.stopPropagation()
}

// body 的 click 方法不会执行
document.body.onclick = function (event) {
  alert("Body clicked")
}

事件对象的 eventPhase 属性可以用来确定事件当前位于哪个阶段:

  • 捕获阶段调用时 eventPhase === 1
  • 目标阶段调用时 eventPhase === 2
  • 冒泡阶段调用时 eventPhase === 3

eventPhase === 2 时,thistargetcurrentTarget 始终相等。event 对象仅在监听器执行期间存在,执行完成后会被销毁。

html
<!DOCTYPE html>
<html>
  <head>
    <title>DOM Event Object Example</title>
  </head>
  <body>
    <input type="button" value="Click Me" id="myBtn" />
    <script type="text/javascript">
      var btn = document.getElementById("myBtn")
      btn.onclick = function (event) {
        alert(event.eventPhase) // 2
      }

      document.body.addEventListener(
        "click",
        function (event) {
          alert(event.eventPhase) // 1
        },
        true
      )

      document.body.onclick = function (event) {
        alert(event.eventPhase) // 3
      }
    </script>
  </body>
</html>

stopImmediatePropagation

event.stopImmediatePropagation() 阻止同一个事件的其他监听函数被调用,比 stopPropagation() 更彻底。

javascript
function l1(e) {
  e.stopImmediatePropagation()
}

function l2(e) {
  console.log("hello world")
}

el.addEventListener("click", l1, false)
el.addEventListener("click", l2, false)

由于 l1 调用了 event.stopImmediatePropagation()l2 不会被调用。

composedPath

event.composedPath() 返回一个数组,成员是事件的最底层节点以及依次冒泡经过的所有上层节点。调试 Shadow DOM 相关事件时尤其有用。

javascript
// HTML 代码如下
// <div>
//   <p>Hello</p>
// </div>
var div = document.querySelector("div")
var p = document.querySelector("p")

div.addEventListener(
  "click",
  function (e) {
    console.log(e.composedPath())
  },
  false
)
// [p, div, body, html, document, Window]

event.composed 属性决定事件是否允许穿透 Shadow DOM 边界。默认情况下,许多原生事件的 composedtrue(如 click),而自定义事件的默认值为 false

javascript
const shadowRoot = host.attachShadow({ mode: "open" })
shadowRoot.innerHTML = `<button id="inner">click</button>`

shadowRoot.getElementById("inner").addEventListener("click", (event) => {
  console.log(event.composed, event.composedPath())
})
  • 当事件 composed === false 时,事件路径不会包含宿主节点,外部也无法监听到该事件
  • 若需要让自定义事件被父组件捕获,需要在构造函数中显式设置 { composed: true }

click 事件的最底层节点是 p,向上依次是 divbodyhtmldocumentWindow。在 Shadow DOM 场景下,若 composedtrue,路径中还会包含影子宿主节点。

在事件处理程序中访问事件对象

通过 DOM 属性(onclick 等)或 addEventListener 绑定监听器时,事件对象会作为第一个参数传入。若在 HTML 特性中直接编写代码,浏览器也会注入一个局部变量 event 指向事件对象,同时 this 指向事件的目标元素:

html
<input type="button" value="Click Me" onclick="console.log(event.type, this.value)" />

在现代开发中,更推荐显式声明事件参数,配合箭头函数或具名函数提高可读性:

javascript
const btn = document.getElementById("myBtn")

btn.addEventListener("click", (event) => {
  console.log(event.type) // "click"
  console.log(event.target.value)
})

事件处理程序

事件就是用户或浏览器自身执行的某种动作。诸如 clickloadmouseover,都是事件的名字。而响应某个事件的函数就叫做事件处理程序(或事件侦听器)。事件处理程序的名字以 on 开头,因此 click 事件的事件处理程序就是 onclickload 事件的事件处理程序就是 onload。为事件指定处理程序的方式有好几种

HTML 事件处理程序

某个元素支持的每种事件,都可以使用与相应事件处理程序同名的 HTML 特性来指定。这个特性的值应该是能够执行的 JavaScript 代码

html
<input type="button" value="Click Me" onclick="alert('Clicked')" />

由于这个值是 JavaScript,因此不能在其中使用未经转义的 HTML 语法字符,例如和号 &、双引号 ""、小于号 < 或大于号 >。为了避免使用 HTML 实体,这里使用了单引号。如果想要使用双引号,那么就要将代码改写成如下所示

javascript
<input type="button" value="Click Me" onclick='console.log("Clicked")' />

HTML 中定义的事件处理程序可以包含要执行的具体动作,也可以调用在页面其他地方定义的脚本:

html
<input type="button" value="Click Me" onclick="showMessage()" />

<script type="text/javascript">
  function showMessage() {
    alert("Hello world!")
  }
</script>

event 事件对象

这样指定事件处理程序具有一些独到之处:浏览器会创建一个封装着元素属性值的函数,并注入局部变量 event 指向事件对象,同时让 this 指向当前元素。若希望进一步了解不同写法如何获取事件对象及更多示例,可回到上文《事件对象》章节的“在事件处理程序中访问事件对象”小节。

常用属性与方法

事件对象实现了 Event 接口(或其子接口,比如 MouseEventKeyboardEvent)。常用属性、方法、对照表与注意事项已集中于《事件对象》章节中的“实例属性”“实例方法”小节,遇到具体需求可回查。这里要特别提醒的是:在监听器中返回 false 并不会自动阻止默认行为或冒泡,只有部分历史遗留 API(如 jQuery 事件封装)会这么处理,请优先使用 preventDefault()stopPropagation()

缺点

HTML 中指定事件处理程序有两个缺点

  • 时差问题

因为用户可能会在 HTML 元素一出现在页面上就触发相应的事件,但当时的事件处理程序有可能尚不具备执行条件。假设 showMessage() 函数是在按钮下方、页面的最底部定义的。如果用户在页面解析 showMessage() 函数之前就单击了按钮,就会引发错误。为此很多 HTML 事件处理程序都会被封装在 try-catch 块中,以便错误不会浮出水面

javascript
<input type="button" value="Click Me" onclick="try{showMessage();}catch(ex){}">
  • 代码紧耦合。通过 HTML 指定事件处理程序的最后一个缺点是 HTMLJavaScript 代码紧密耦合

DOM0 级事件处理程序

通过 JavaScript 指定事件处理程序的传统方式,就是将一个函数赋值给一个事件处理程序属性。每个元素(包括 windowdocument)都有自己的事件处理程序属性,这些属性通常全部小写,例如 onclick。将这种属性的值设置为一个函数,就可以指定事件处理程序

javascript
var btn = document.getElementById("myBtn")

btn.onclick = function () {
  alert("Clicked")
}

使用 DOM0 级方法指定的事件处理程序被认为是元素的方法。这时事件处理程序是在元素的作用域中运行,程序中的 this 引用当前元素

javascript
var btn = document.getElementById("myBtn")

btn.onclick = function () {
  alert(this.id) // "myBtn"
}

以这种方式添加的事件处理程序会在事件流的冒泡阶段被处理

通过将事件处理程序属性的值设置为 null,可以移除通过 DOM0 方式添加的事件处理程序,如下面的例子所示:

javascript
btn.onclick = null //删除事件处理程序

DOM2 级事件处理程序

DOM2 级事件 定义用于处理指定和删除事件处理程序的操作

  • addEventListener 绑定事件的监听函数
  • removeEventListener 移除事件的监听函数
  • dispatchEvent 触发事件

所有 DOM 节点中都包含这两个方法,并且都接受 3 个参数:要处理的事件名、作为事件处理程序的函数和一个布尔值

  • 最后这个布尔值参数如果是 true,表示在捕获阶段调用事件处理程序;
  • 如果是 false,表示在冒泡阶段调用事件处理程序

要在按钮上为 click 事件添加事件处理程序,这两个事件处理程序会按照添加它们的顺序触发,因此首先会显示元素的 ID,其次会显示 Hello world! 消息

javascript
var btn = document.getElementById("myBtn")

// 事件会在冒泡阶段被触发(因为最后一个参数是 false)
btn.addEventListener(
  "click",
  function () {
    alert(this.id)
  },
  false
)

btn.addEventListener(
  "click",
  function () {
    alert("Hello world!")
  },
  false
)

通过 addEventListener 添加的事件处理程序只能使用 removeEventListener 来移除。移除时传入的参数与添加处理程序时使用的参数相同。这也意味着通过 addEventListener 添加的匿名函数将无法移除

javascript
var btn = document.getElementById("myBtn")

btn.addEventListener(
  "click",
  function () {
    alert(this.id)
  },
  false
)

//这里省略了其他代码
btn.removeEventListener(
  "click",
  function () {
    //没有用!
    alert(this.id)
  },
  false
)

第二个参数与传入 addEventListener 中的那一个是完全不同的函数,需要将函数单独抽离出来:

javascript
var btn = document.getElementById("myBtn")

var handler = function () {
  alert(this.id)
}

btn.addEventListener("click", handler, false)

//这里省略了其他代码
btn.removeEventListener("click", handler, false) //有效!

第三个参数

第三个参数除了布尔值 useCapture,还可以是一个监听器配置对象,定制事件监听行为。该对象有以下属性:

  • capture:布尔值,如果设为 true,表示监听函数在捕获阶段触发,默认为 false,在冒泡阶段触发
  • once:布尔值,如果设为 true,表示监听函数执行一次就会自动移除,后面将不再监听该事件。该属性默认值为 false
  • passive:布尔值,设为 true 时,表示禁止监听函数调用 preventDefault() 方法。如果调用了,浏览器将忽略这个要求,并在控制台输出一条警告。该属性默认值为 false
  • signal:该属性的值为一个 AbortSignal 对象,为监听器设置了一个信号通道,用来在需要时发出信号,移除监听函数
javascript
element.addEventListener(
  "click",
  function (event) {
    // 只执行一次的代码
  },
  { once: true }
)

进阶使用

addEventListener 方法可以为针对当前对象的同一个事件,添加多个不同的监听函数。这些函数按照添加顺序触发,即先添加先触发。如果为同一个事件多次添加同一个监听函数,该函数只会执行一次,多余的添加将自动被去除(不必使用 removeEventListener 方法手动去除)

javascript
function hello() {
  console.log("Hello world")
}

document.addEventListener("click", hello, false)
document.addEventListener("click", hello, false)
// 执行上面代码,点击文档只会输出一行 Hello world

如果希望向监听函数传递参数,可以用匿名函数包装一下监听函数

javascript
function print(x) {
  console.log(x)
}

var el = document.getElementById("div1")
el.addEventListener(
  "click",
  function () {
    print("Hello")
  },
  false
)

监听函数内部的 this,指向当前事件所在的那个对象:

javascript
// HTML 代码如下
// <button id="btn" onclick="console.log(this.id)">点击</button>
var btn = document.getElementById("btn")
// 写法一
btn.onclick = function () {
  console.log(this.id)
}

// 写法二
btn.addEventListener(
  "click",
  function (e) {
    console.log(this.id)
  },
  false
)

控制默认行为与事件传播

开发中常见的控制逻辑包括阻止浏览器默认行为以及在合适的时机终止事件冒泡:

javascript
const link = document.querySelector("a.prevent")

link.addEventListener("click", (event) => {
  if (!link.href.includes("example.com")) {
    event.preventDefault() // 阻止跳转
  }
})

const dialog = document.querySelector(".dialog")

dialog.addEventListener("click", (event) => {
  event.stopPropagation() // 阻止事件冒泡到 document
})

document.addEventListener("click", () => {
  dialog.classList.add("hidden")
})

如果需要阻止当前节点上的其他监听器执行,可调用 stopImmediatePropagation()

javascript
const button = document.querySelector("button.action")

button.addEventListener("click", (event) => {
  event.stopImmediatePropagation()
  console.log("只会输出这一行")
})

button.addEventListener("click", () => {
  console.log("不会执行")
})

事件循环与事件队列

事件循环机制

浏览器的事件处理基于**事件循环(Event Loop)**机制。理解这一机制有助于编写高效的事件处理代码。

JavaScript 是单线程语言,所有任务分为两类:

  1. 同步任务:在主线程上排队执行的任务
  2. 异步任务:不进入主线程,而进入任务队列的任务

事件循环的执行流程:

code
┌───────────────────────┐
│  执行栈(Call Stack)   │
│  - 同步代码执行         │
└───────────┬───────────┘
            │
            ▼
┌───────────────────────┐
│  宏任务队列(Macrotask)│
│  - setTimeout          │
│  - setInterval         │
│  - I/O 操作            │
│  - UI 事件             │
└───────────┬───────────┘
            │
            ▼
┌───────────────────────┐
│  微任务队列(Microtask)│
│  - Promise.then        │
│  - MutationObserver    │
│  - queueMicrotask      │
└───────────┬───────────┘
            │
            └──────────► 返回执行栈

事件在事件循环中的位置

DOM 事件触发后,其处理流程如下:

  1. 事件触发:用户操作(如点击)触发事件
  2. 任务入队:事件处理程序作为宏任务进入宏任务队列
  3. 执行处理:当执行栈为空时,事件循环从队列中取出任务执行
javascript
console.log("1. 脚本开始")

document.addEventListener("click", () => {
  console.log("2. 点击事件处理")
})

console.log("3. 脚本结束")

// 点击按钮后的输出顺序:
// 1. 脚本开始
// 3. 脚本结束
// 2. 点击事件处理(事件处理程序在事件循环的下一个迭代中执行)

微任务与宏任务的交互

javascript
const button = document.querySelector("button")

button.addEventListener("click", () => {
  console.log("1. 点击事件")

  Promise.resolve().then(() => {
    console.log("2. 微任务")
  })

  console.log("3. 点击事件继续")
})

button.click()

// 输出顺序:
// 1. 点击事件
// 3. 点击事件继续
// 2. 微任务

关键点

  • 事件处理程序本身是宏任务
  • 处理程序内的 Promise.then() 是微任务,会在当前宏任务结束后立即执行
  • 理解这一机制有助于避免事件处理中的竞态条件

阻塞事件循环的风险

javascript
// ❌ 错误:长时间同步操作会阻塞事件循环
document.addEventListener("click", () => {
  const start = Date.now()
  while (Date.now() - start < 3000) {
    // 模拟耗时操作,阻塞 3 秒
  }
  console.log("处理完成")
})

// ✅ 正确:使用 requestIdleCallback 在空闲时分片处理,避免阻塞事件循环
let i = 0
function process() {
  const start = Date.now()
  while (Date.now() - start < 10 && i < 100000) {
    doTask(i)
    i++
  }
  if (i < 100000) {
    requestIdleCallback(process)  // 剩余任务继续在空闲时分片执行
  }
}
document.addEventListener("click", () => {
  requestIdleCallback(process)
})

事件处理的最佳时机

javascript
// 场景:需要在 DOM 更新后执行操作
button.addEventListener("click", () => {
  element.style.display = "block"

  // ❌ 错误:此时 DOM 还未重排,获取的尺寸不准确
  const height = element.offsetHeight

  // ✅ 正确:使用 requestAnimationFrame 在下一帧执行
  requestAnimationFrame(() => {
    const height = element.offsetHeight
    console.log("准确的元素高度:", height)
  })
})

内存与性能

事件委托

由于事件处理程序可以为现代 Web 应用程序提供交互能力,因此许多开发人员会不分青红皂白地向页面中添加大量的处理程序。在创建 GUI 的语言(如 C#)中,为 GUI 中的每个按钮添加一个 onclick 事件处理程序是司空见惯的事,而且这样做也不会导致什么问题

事件委托利用事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。例如 click 事件会一直冒泡到 document 层次。也就是说,可以为整个页面指定一个 onclick 事件处理程序,而不必给每个可单击的元素分别添加事件处理程序

html
<ul id="myLinks">
  <li id="goSomewhere">Go somewhere</li>
  <li id="doSomething">Do something</li>
  <li id="sayHi">Say hi</li>
</ul>
<script>
  const item1 = document.getElementById("goSomewhere")
  const item2 = document.getElementById("doSomething")
  const item3 = document.getElementById("sayHi")

  item1.addEventListener("click", () => {
    location.href = "https://www.example.com"
  })

  item2.addEventListener("click", () => {
    document.title = "I changed the document's title"
  })

  item3.addEventListener("click", () => {
    alert("hi")
  })
</script>

如果在一个复杂的 Web 应用程序中,对所有可单击的元素都采用这种方式,那么结果就会有数不清的代码用于添加事件处理程序。此时,可以利用事件委托技术解决这个问题。使用事件委托,只需在 DOM 树中尽量最高的层次上添加一个事件处理程序:

javascript
const list = document.getElementById("myLinks")

list.addEventListener("click", (event) => {
  const target = event.target.closest("li")
  if (!target) return

  switch (target.id) {
    case "doSomething":
      document.title = "I changed the document's title"
      break
    case "goSomewhere":
      location.href = "https://www.example.com"
      break
    case "sayHi":
      alert("hi")
      break
  }
})

这样做与采取传统的做法相比具有如下优点:

  • document 对象很快就可以访问,而且可以在页面生命周期的任何时点上为它添加事件处理程序无需等待 DOMContentLoadedload 事件)。换句话说,只要可单击的元素呈现在页面上,就可以立即具备适当的功能
  • 在页面中设置事件处理程序所需的时间更少。只添加一个事件处理程序所需的 DOM 引用更少,所花的时间也更少
  • 整个页面占用的内存空间更少,能够提升整体性能
  • 容易搭配 event.targetElement.closest() 做条件判断,兼容动态插入或移除子元素

移除事件处理程序

第一种情况就是从文档中移除带有事件处理程序的元素时。例如使用 removeChildreplaceChild 方法,但更多地是发生在使用 innerHTML 替换页面中某一部分的时候。

如果带有事件处理程序的元素被 innerHTML 删除了,那么原来添加到元素中的事件处理程序极有可能无法被当作垃圾回收

html
<div id="myDiv">
  <input type="button" value="Click Me" id="myBtn" />
</div>
<script type="text/javascript">
  var btn = document.getElementById("myBtn")
  btn.onclick = function () {
    //先执行某些操作
    document.getElementById("myDiv").innerHTML = "Processing..." //麻烦了!
  }
</script>

按钮被包含在 <div> 元素中。为避免双击,单击这个按钮时就将按钮移除并替换成一条消息;这是网站设计中非常流行的一种做法。但问题在于,当按钮被从页面中移除时,它还带着一个事件处理程序呢。

<div> 元素上设置 innerHTML 可以把按钮移走,但事件处理程序仍然与按钮保持着引用关系

html
<div id="myDiv">
  <input type="button" value="Click Me" id="myBtn" />
</div>
<script type="text/javascript">
  var btn = document.getElementById("myBtn")
  btn.onclick = function () {
    //先执行某些操作
    btn.onclick = null // 移除事件处理程序
    document.getElementById("myDiv").innerHTML = "Processing..."
  }
</script>

在设置 <div>innerHTML 属性之前,先移除按钮的事件处理程序。这样就确保内存可以被再次利用,而从 DOM 中移除按钮也做到了干净利索

使用 AbortController 移除监听器

DOM 标准为 addEventListener 增加了 signal 选项,配合 AbortController 可以在合适的时机批量移除监听器,而无需显式保存函数引用:

javascript
const controller = new AbortController()
const { signal } = controller

document.addEventListener(
  "click",
  () => {
    console.log("只要 signal 未被 abort,就会响应")
  },
  { signal }
)

setTimeout(() => {
  controller.abort() // 触发后自动移除监听器
}, 5000)

在组件化框架或模块化代码中,这种写法可减少手动调用 removeEventListener 的机会,同时降低内存泄漏风险。

自定义事件与模块通信

当需要在组件之间传递消息时,可利用 CustomEvent 创建自定义事件:

javascript
const bus = document.createElement("div")

function publish(payload) {
  const event = new CustomEvent("user:login", { detail: payload })
  bus.dispatchEvent(event)
}

bus.addEventListener("user:login", (event) => {
  console.log("收到登录信息", event.detail)
})

publish({ id: 1, name: "Alice" })
  • CustomEventdetail 属性用于承载自定义数据
  • 自定义事件同样遵循冒泡/捕获规则,可以指定 bubbles: true 让事件沿 DOM 向上传播
  • 如果需要跨组件、跨窗口通信,可结合 postMessageBroadcastChannel 等 API

调试事件的技巧

  • 使用浏览器开发者工具的 Event Listeners 面板,查看元素上注册的监听器并快速跳转到源代码
  • 启用 Chrome 的 Event Listener Breakpoints,在特定事件触发时中断脚本,方便排查逻辑
  • 在调试复杂冒泡关系时,可以临时输出 event.eventPhaseevent.targetevent.currentTarget 以确认事件流转顺序
  • 结合 oncepassivesignal 等参数的 Console 警告信息,可快速定位未按预期执行的监听器

性能优化进阶

为什么需要性能优化?

在深入具体优化技术之前,理解浏览器事件处理面临的性能挑战至关重要:

  1. 主线程阻塞:JavaScript 在主线程执行,长时间运行的事件处理程序会阻塞页面渲染和用户交互
  2. 高频事件scrollresizemousemove 等事件每秒可能触发数十次甚至上百次
  3. 布局抖动:事件处理程序中频繁读取布局属性会导致强制同步布局,严重影响性能
  4. 内存压力:大量事件监听器会增加内存占用,影响页面整体性能

浏览器渲染管线与事件处理

理解浏览器渲染管线有助于优化事件处理:

code
┌─────────────┐
│ JavaScript  │ ← 事件处理程序在这里执行
│   执行      │
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 样式计算    │ ← 计算应用的样式
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 布局        │ ← 计算元素位置和大小
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 绘制        │ ← 将元素绘制到图层
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 合成        │ ← 将图层合成到屏幕
└─────────────┘

关键优化原则

  • 避免在事件处理程序中触发布局回流(读取 offsetWidthscrollTop 等)
  • 将耗时操作移出主线程(使用 Web Worker)
  • 使用 requestAnimationFrame 将计算密集型操作与渲染同步

Passive Event Listeners 详解

passive: true 是一个性能优化选项,告诉浏览器监听器不会调用 preventDefault(),浏览器可以立即开始滚动而不用等待 JavaScript 执行完毕。

javascript
// ❌ 错误:默认情况下浏览器会等待 touchstart 监听器执行完才滚动
document.addEventListener("touchstart", (event) => {
  // 某些逻辑
})

// ✅ 正确:标记为 passive,浏览器可以立即滚动
document.addEventListener(
  "touchstart",
  (event) => {
    // 注意:这里调用 preventDefault() 会被忽略并产生警告
    console.log("触摸开始")
  },
  { passive: true }
)

// 常见需要 passive 的场景
window.addEventListener("scroll", handleScroll, { passive: true })
document.addEventListener("touchmove", handleTouchMove, { passive: true })
document.addEventListener("wheel", handleWheel, { passive: true })

⚠️ - touchstarttouchmove 在某些浏览器中默认 passive: true(通常在 windowdocument 上) - 如果需要阻止默认行为,必须显式设置 { passive: false }

  • 调用 event.preventDefault() 时,如果事件已被标记为 passive,控制台会发出警告
javascript
// 需要阻止默认行为的场景
document.addEventListener(
  "touchmove",
  (event) => {
    // 禁止页面滚动(例如自定义滚动容器)
    event.preventDefault()
  },
  { passive: false } // 必须显式设置为 false
)

节流与防抖的实际应用

节流(Throttle)

限制函数在一定时间内只执行一次,适合连续触发的事件:

javascript
function throttle(func, delay) {
  let lastCall = 0
  return function (...args) {
    const now = Date.now()
    if (now - lastCall >= delay) {
      lastCall = now
      func.apply(this, args)
    }
  }
}

// 使用场景:滚动事件、resize 事件
// 进阶:基于 requestAnimationFrame 的节流(保证跟随屏幕刷新率)
function throttleRAF(func) {
  let ticking = false
  return function (...args) {
    if (ticking) return
    ticking = true
    requestAnimationFrame(() => {
      func.apply(this, args)
      ticking = false
    })
  }
}

window.addEventListener("scroll", throttleRAF(updateUI), { passive: true })

防抖(Debounce)

延迟执行函数,只有在事件停止触发一段时间后才执行:

javascript
function debounce(func, delay) {
  let timer
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => func.apply(this, args), delay)
  }
}

// 使用场景:搜索框输入、窗口 resize
const handleSearch = debounce((query) => {
  console.log("搜索:", query)
  fetchSearchResults(query)
}, 300)

searchInput.addEventListener("input", (e) => {
  handleSearch(e.target.value)
})

节流 vs 防抖的选择

场景推荐方式原因
滚动加载节流保持一定频率检查,不会漏掉加载时机
搜索框防抖用户停止输入后再搜索,减少请求
窗口调整防抖调整结束后再计算布局
鼠标移动节流持续响应但限制频率
表单验证防抖输入完成后再验证

Intersection Observer 替代滚动监听

传统方式监听元素可见性需要持续计算元素位置,使用 IntersectionObserver 更加高效:

javascript
// ❌ 传统方式:性能较差
window.addEventListener("scroll", () => {
  const element = document.getElementById("target")
  const rect = element.getBoundingClientRect()
  const isVisible = rect.top < window.innerHeight && rect.bottom > 0

  if (isVisible) {
    console.log("元素可见")
  }
})

// ✅ 现代方式:性能更好
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log("元素可见", entry.target)
    }
  })
}, { threshold: 0.1 })

// 观察多个元素
document.querySelectorAll(".lazy-load").forEach((el) => {
  observer.observe(el)
})

ResizeObserver 替代 resize 事件

监听元素尺寸变化(而不是窗口尺寸):

javascript
// 监听元素尺寸变化
const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect
    console.log(`${entry.target.id} 尺寸变化:`, width, height)

    // 根据尺寸调整布局
    if (width < 600) {
      entry.target.classList.add("mobile-layout")
    } else {
      entry.target.classList.remove("mobile-layout")
    }
  }
})

// 观察元素
const container = document.getElementById("container")
resizeObserver.observe(container)

// 停止观察
// resizeObserver.unobserve(container)
// resizeObserver.disconnect()

性能监控

使用 Performance API 监控事件处理的性能:

javascript
// 监控长任务
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log("长任务:", entry)
    console.log("持续时间:", entry.duration)
    console.log("开始时间:", entry.startTime)
  }
})

observer.observe({ entryTypes: ["longtask"] })

// 测量事件处理时间
element.addEventListener("click", (event) => {
  performance.mark("click-start")

  // 处理逻辑
  handleButtonClick(event)

  performance.mark("click-end")
  performance.measure("click-handler", "click-start", "click-end")

  const measure = performance.getEntriesByName("click-handler")[0]
  console.log(`点击处理耗时: ${measure.duration}ms`)

  performance.clearMarks()
  performance.clearMeasures()
})

常见问题解答

Q1: 为什么事件委托中的 event.target 不正确?

问题:点击按钮内部的图标时,event.target 指向的是图标而不是按钮。

html
<ul id="menu">
  <li class="menu-item">
    <span class="icon">📝</span>
    编辑
  </li>
</ul>

<script>
  // ❌ 问题代码
  menu.addEventListener("click", (event) => {
    if (event.target.classList.contains("menu-item")) {
      // 点击图标时不会执行
      console.log("菜单项被点击")
    }
  })

  // ✅ 解决方案:使用 closest()
  menu.addEventListener("click", (event) => {
    const menuItem = event.target.closest(".menu-item")
    if (menuItem) {
      console.log("菜单项被点击:", menuItem)
    }
  })
</script>

Q2: 如何阻止事件冒泡但保留默认行为?

javascript
// event.stopPropagation() 只阻止冒泡,不影响默认行为
link.addEventListener("click", (event) => {
  event.stopPropagation() // 阻止冒泡
  // 链接仍然会跳转
})

// 完全阻止冒泡和默认行为
link.addEventListener("click", (event) => {
  event.stopPropagation()
  event.preventDefault()
})

Q3: 为什么 passive 事件中 preventDefault 不生效?

javascript
// ❌ 错误:passive 事件中调用 preventDefault 会被忽略
document.addEventListener(
  "touchmove",
  (event) => {
    event.preventDefault() // 控制台警告:Unable to preventDefault
  },
  { passive: true }
)

// ✅ 正确:需要阻止默认行为时,必须设置 passive: false
document.addEventListener(
  "touchmove",
  (event) => {
    event.preventDefault() // 现在可以正常工作
  },
  { passive: false }
)

Q4: 如何在事件处理程序中正确使用 this?

javascript
// DOM0 级事件:this 指向元素
element.onclick = function () {
  console.log(this === element) // true
}

// addEventListener:this 指向元素
element.addEventListener("click", function () {
  console.log(this === element) // true
})

// 箭头函数:this 指向外层作用域
element.addEventListener("click", () => {
  console.log(this === element) // false
})

// 解决方案:使用 event.currentTarget
element.addEventListener("click", (event) => {
  console.log(event.currentTarget === element) // true
})

// 或者使用 bind
element.addEventListener(
  "click",
  function () {
    console.log(this === element) // true
  }.bind(element)
)

Q5: 如何实现只触发一次的事件?

javascript
// 方式1:使用 once 选项(推荐)
element.addEventListener("click", handleClick, { once: true })

// 方式2:手动移除
function handleOnce(event) {
  console.log("只执行一次")
  event.target.removeEventListener("click", handleOnce)
}
element.addEventListener("click", handleOnce)

// 方式3:使用 AbortController
const controller = new AbortController()
element.addEventListener(
  "click",
  () => {
    console.log("只执行一次")
    controller.abort() // 自动移除
  },
  { signal: controller.signal }
)

Q6: 如何处理动态添加元素的事件?

javascript
// ❌ 错误:直接给动态元素添加事件(添加前不存在)
const newButton = document.createElement("button")
// newButton 还未添加到 DOM,无法直接添加事件

// ✅ 方式1:事件委托
document.addEventListener("click", (event) => {
  if (event.target.matches(".dynamic-button")) {
    console.log("动态按钮被点击")
  }
})

// ✅ 方式2:添加到 DOM 后再绑定
const button = document.createElement("button")
button.className = "dynamic-button"
button.textContent = "点击我"
document.body.appendChild(button)

button.addEventListener("click", () => {
  console.log("按钮被点击")
})

Q7: 如何正确移除匿名函数的监听器?

javascript
// ❌ 错误:无法移除匿名函数
element.addEventListener("click", () => {
  console.log("点击")
})
element.removeEventListener("click", () => {
  // 不同的函数引用,无法移除
})

// ✅ 方式1:使用具名函数
function handleClick() {
  console.log("点击")
}
element.addEventListener("click", handleClick)
element.removeEventListener("click", handleClick)

// ✅ 方式2:使用 AbortController
const controller = new AbortController()
element.addEventListener(
  "click",
  () => {
    console.log("点击")
  },
  { signal: controller.signal }
)

// 移除
controller.abort()

// ✅ 方式3:使用 once(如果只需执行一次)
element.addEventListener("click", () => console.log("点击"), { once: true })

Q8: 如何实现事件的命名空间?

javascript
// 问题:需要分组管理相关事件
class EventManager {
  constructor() {
    this.events = new Map()
  }

  // 添加事件到命名空间
  on(namespace, element, type, handler) {
    const key = `${namespace}:${type}`
    if (!this.events.has(key)) {
      this.events.set(key, [])
    }
    element.addEventListener(type, handler)
    this.events.get(key).push({ element, handler })
  }

  // 按命名空间移除事件
  off(namespace) {
    for (const [key, handlers] of this.events) {
      if (key.startsWith(`${namespace}:`)) {
        handlers.forEach(({ element, handler }) => {
          const type = key.split(':')[1]
          element.removeEventListener(type, handler)
        })
        this.events.delete(key)
      }
    }
  }
}

// 添加模态框相关的事件
const manager = new EventManager()
manager.on("modal", document, "click", handleModalClick)
manager.on("modal", document, "keydown", handleModalKeydown)

// 移除所有模态框相关事件
manager.off("modal")

Q9: 如何处理事件的竞态条件?

javascript
// 问题:快速点击导致多个请求同时发出
let isLoading = false
let controller = null

async function fetchData(id) {
  // 防止重复请求
  if (isLoading) return
  isLoading = true

  // 取消上一次未完成的请求,只保留最新结果
  if (controller) {
    controller.abort()
  }
  controller = new AbortController()

  try {
    const response = await fetch(`/api/item/${id}`, { signal: controller.signal })
    const data = await response.json()
    renderItem(data)
  } catch (error) {
    if (error.name !== "AbortError") {
      console.error(error)
    }
  } finally {
    isLoading = false
  }
}

Q10: 如何实现事件的优先级?

javascript
// 问题:需要控制多个监听器的执行顺序
class PriorityEventTarget {
  constructor() {
    this.listeners = new Map()
  }

  addEventListener(type, callback, options = {}) {
    const priority = options.priority || 0

    if (!this.listeners.has(type)) {
      this.listeners.set(type, [])
    }
    // 按优先级从高到低插入
    const list = this.listeners.get(type)
    const index = list.findIndex((item) => item.priority < priority)
    if (index === -1) {
      list.push({ callback, priority })
    } else {
      list.splice(index, 0, { callback, priority })
    }
  }

  // 触发事件,按优先级顺序执行监听器
  dispatch(type, event) {
    const list = this.listeners.get(type) || []
    for (const item of list) {
      item.callback.call(this, event)
    }
  }
}

// 用法
const target = new PriorityEventTarget()
target.addEventListener("click", () => console.log("高优先级"), { priority: 10 })

// 优先级 5:中间执行
target.addEventListener("click", () => console.log("中等优先级"), { priority: 5 })

// 输出顺序:高优先级 -> 中等优先级 -> 低优先级

实际应用场景

场景1:无限滚动加载

javascript
class InfiniteScroll {
  constructor(container, loadCallback) {
    this.container = container
    this.loadCallback = loadCallback
    this.isLoading = false
    this.observer = null

    this.init()
  }

  init() {
    // 使用 Intersection Observer 而非 scroll 事件
    const sentinel = document.createElement("div")
    this.container.appendChild(sentinel)
    this.observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && !this.isLoading) {
        this.loadMore()
      }
    })
    this.observer.observe(sentinel)
  }

  async loadMore() {
    this.isLoading = true
    const items = await this.loadCallback()
    // 渲染新数据
    items.forEach((item) => {
      const div = document.createElement("div")
      div.textContent = item
      this.container.appendChild(div)
    })
    this.isLoading = false
  }
}

// 使用示例
let currentPage = 1
const infiniteScroll = new InfiniteScroll(document.body, async () => {
  const response = await fetch("/api/items?page=" + currentPage)
  const items = await response.json()
  renderItems(items)
  currentPage++
})

场景2:拖拽排序

javascript
class DragSort {
  constructor(container) {
    this.container = container
    this.draggedItem = null
    this.placeholder = null

    this.init()
  }

  init() {
    this.container.addEventListener("dragstart", (e) => {
      this.draggedItem = e.target
      e.target.classList.add("dragging")
      e.dataTransfer.effectAllowed = "move"
    })

    this.container.addEventListener("dragover", (e) => {
      e.preventDefault()
    })

    this.container.addEventListener("drop", (e) => {
      e.preventDefault()
      if (!this.draggedItem) return
      const target = this.getDragAfterElement(e.clientY)
      if (target == null) {
        this.container.appendChild(this.draggedItem)
      } else {
        this.container.insertBefore(this.draggedItem, target)
      }
      this.draggedItem.classList.remove("dragging")
      this.draggedItem = null
    })
  }

  getDragAfterElement(y) {
    const items = [...this.container.querySelectorAll("li:not(.dragging)")]
    return items.reduce((closest, child) => {
      const box = child.getBoundingClientRect()
      const offset = y - box.top - box.height / 2
      if (offset < 0 && offset > closest.offset) {
        return { offset, element: child }
      }
      return closest
    }, { offset: Number.NEGATIVE_INFINITY }).element
  }
}

// 使用示例
const dragSort = new DragSort(document.querySelector("#item-list"))

场景3:表单实时验证

javascript
class FormValidator {
  constructor(form) {
    this.form = form
    this.errors = new Map()
    this.debounceTimers = new Map()

    this.init()
  }

  init() {
    // 使用事件委托处理所有输入
    this.form.addEventListener("input", (e) => {
      const field = e.target
      if (!field.name) return
      this.validateField(field)
    })
  }

  validateField(field) {
    // 每个字段的校验规则
    const rules = {
      email: (v) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) || "邮箱格式不正确",
      required: (v) => v.trim() !== "" || "该字段必填",
    }
    const value = field.value
    let error = null
    if (rules[field.dataset.rule]) {
      const result = rules[field.dataset.rule](value)
      if (result !== true) error = result
    }
    this.errors.set(field.name, error)
    this.showError(field, error)
  }

  showError(field, error) {
    const tip = field.nextElementSibling
    if (tip && tip.classList.contains("error-tip")) {
      tip.textContent = error || ""
      tip.style.color = error ? "red" : "transparent"
    }
  }
}

// 使用示例
const validator = new FormValidator(document.querySelector("#my-form"))

场景4:键盘快捷键管理

javascript
class KeyboardShortcuts {
  constructor() {
    this.shortcuts = new Map()
    this.init()
  }

  init() {
    document.addEventListener("keydown", this.handleKeyDown.bind(this))
  }

  // 注册快捷键
  register(combination, callback, options = {}) {
    this.shortcuts.set(combination, { callback, ...options })
  }

  // 解析并匹配按键组合
  handleKeyDown(e) {
    const parts = []
    if (e.ctrlKey) parts.push("Ctrl")
    if (e.metaKey) parts.push("Cmd")
    if (e.shiftKey) parts.push("Shift")
    if (e.altKey) parts.push("Alt")
    parts.push(e.key === " " ? "Space" : e.key)
    const combo = parts.join("+")

    const item = this.shortcuts.get(combo)
    if (item) {
      if (item.preventDefault !== false) e.preventDefault()
      item.callback(e)
    }
  }
}

// 使用示例
const shortcuts = new KeyboardShortcuts()
shortcuts.register("Cmd+K", () => {
  console.log("全局搜索")
}, { description: "全局搜索" })

shortcuts.register("Escape", () => {
  console.log("关闭对话框")
}, { description: "关闭对话框", preventDefault: false })

最佳实践总结

事件监听

  1. 优先使用 addEventListener 而非 DOM0 级事件:可以添加多个监听器,更容易管理
  2. 合理使用事件委托:减少监听器数量,支持动态元素
  3. 及时移除不再需要的监听器:防止内存泄漏
  4. 使用 once 选项:对于只需执行一次的事件
  5. 使用 signal 选项:便于批量管理监听器

性能优化

  1. 滚动/触摸事件使用 passive: true:提升滚动性能
  2. 高频事件使用节流或防抖:减少计算次数
  3. 使用 requestAnimationFrame:与浏览器渲染同步
  4. IntersectionObserver 替代滚动监听:更高效的可见性检测
  5. 避免在事件处理程序中进行大量计算:使用 Web Worker

代码组织

  1. 统一管理事件监听器:便于维护和调试
  2. 使用自定义事件解耦组件:提高代码可维护性
  3. 添加适当的注释:说明事件用途和触发条件
  4. 使用 TypeScript 或 JSDoc:提供类型提示
javascript
// 推荐的事件管理模式
class Component {
  constructor(element) {
    this.element = element
    this.controller = new AbortController()
    this.bindEvents()
  }

  bindEvents() {
    const { signal } = this.controller

    this.element.addEventListener("click", this.handleClick.bind(this), {
      signal,
    })
    this.element.addEventListener("keydown", this.handleKeydown.bind(this), {
      signal,
    })
  }

  handleClick(e) {
    // 处理点击
  }

  handleKeydown(e) {
    // 处理键盘
  }

  destroy() {
    // 统一移除所有监听器
    this.controller.abort()
  }
}

术语表

本节统一文档中使用的关键术语,确保概念清晰一致。

核心术语

术语英文说明相关概念
事件Event用户或浏览器执行的某种动作,如点击、输入、滚动等事件对象、事件类型
事件目标Event Target触发事件的 DOM 元素event.target
事件处理程序Event Handler响应事件的函数事件监听器、回调函数
事件监听器Event Listener同事件处理程序,通过 addEventListener 注册的函数处理程序
事件流Event Flow事件在 DOM 树中传播的路径和顺序捕获、冒泡
事件对象Event Object包含事件相关信息的对象,如触发元素、鼠标位置等event 参数

事件流相关术语

术语英文说明代码表示
事件捕获Event Capturing事件从最外层节点向目标节点传播的过程capture: true
事件冒泡Event Bubbling事件从目标节点向最外层节点传播的过程capture: false
目标阶段Target Phase事件到达目标节点的阶段eventPhase === 2
事件传播Event Propagation事件在 DOM 树中的传播过程stopPropagation()

性能优化术语

术语英文说明使用场景
节流Throttle限制函数在一定时间内只执行一次滚动、resize
防抖Debounce延迟执行函数,只在事件停止触发后执行搜索框、表单验证
被动监听器Passive Listener告诉浏览器监听器不会取消默认行为,可以立即滚动{ passive: true }
事件委托Event Delegation利用事件冒泡,在父元素上统一处理子元素事件列表、动态内容

DOM 级别术语

术语说明主要特性
DOM0 级事件早期的事件处理方式onclick 属性,无法添加多个监听器
DOM2 级事件现代事件处理标准addEventListenerremoveEventListener
DOM3 级事件DOM2 的扩展更多种类的事件,增强的事件对象

常见易混淆术语

  1. 事件处理程序 vs 事件监听器

    • 两者含义相同,都是指响应事件的函数
    • "处理程序"更多用于 HTML 属性(如 onclick
    • "监听器"更多用于 addEventListener 注册的函数
  2. event.target vs event.currentTarget

    • event.target:原始触发事件的元素(不会改变)
    • event.currentTarget:当前正在处理事件的元素(会随事件传播改变)
  3. preventDefault() vs stopPropagation()

    • preventDefault():阻止浏览器默认行为(如链接跳转)
    • stopPropagation():阻止事件继续传播(冒泡或捕获)

参考资源

官方规范

MDN 文档

性能优化资源

浏览器兼容性

相关章节


文档版本:v2.0
最后更新:2026-02-12
维护者:前端技术文档组