15 mins
事件 Event

从认识事件开始,介绍事件相关的知识

认识事件h2

Web页面需要经常和用户之间进行交互,而交互得过程中我们可能想要捕获这个交互得过程

例如,用户点击了某个页面、用户在输入狂里输入了某个文本,用户鼠标经过了某个位置

浏览器需要搭建一条js代码和事件之间的桥梁,当某个事件发生时,让js可以相应(执行某个函数),所以我们需要针对事件编写处理程序(handler)

那么事件如何监听呢

  • 方式一:直接在 HTML 标签上通过 on+事件名 属性写 JS 代码(或调用函数),是最早期的写法,几乎不推荐使用。
<!-- 写法1:直接写代码 -->
<button onclick="alert('你点击了按钮')">点击我</button>
<!-- 写法2:调用外部函数 -->
<button onclick="handleClick()">点击调用函数</button>
<script>
function handleClick() {
console.log('按钮被点击了')
}
</script>
  • 方式二:DOM 属性监听(onXXX 方式)

通过 JS 获取 DOM 元素,给元素的 on+事件名 属性赋值为处理函数,是比内联更优的写法,但仍有局限性。

<button id="btn">点击我</button>
<script>
// 1. 获取DOM元素
const btn = document.getElementById('btn')
// 2. 绑定事件处理程序
btn.onclick = function () {
console.log('按钮被点击(DOM属性方式)')
console.log('当前元素:', this) // this 指向触发事件的元素(btn)
}
// 3. 覆盖事件(注意:多次赋值会覆盖)
btn.onclick = function () {
console.log('新的点击处理程序(覆盖了之前的)')
}
// 4. 移除事件:赋值为 null
// btn.onclick = null;
</script>
  • 方式三:通过EventTarget中得adddEventListener来监听

EventTarget 是 DOM 元素的基类,所有元素都继承了 addEventListener 方法,用于灵活绑定事件处理程序,是现代 Web 开发的标准写法。

元素.addEventListener(事件名, 处理函数, [配置项]);

  • 事件名:比如click、mouseover、mousemove
  • 处理函数:事件触发时执行的函数(箭头函数的 this 指向全局,普通函数指向元素)。
  • 配置项:可选,布尔值(true= 捕获阶段触发false= 冒泡阶段触发默认 false)或对象(如 { once: true } 仅触发一次)。

事件流h2

Event flow指的是浏览器处理DOM事件时,事件在页面中传播的完整路径和阶段。

当用户触发一个事件(比如点击按钮),事件并非只作用在目标元素上,而是会从最外层的根节点(如document)开始,经过一系列传播过程,最终到达目标元素,再反向回到根节点 —— 这个 “传播轨迹” 就是事件流。

事件流的本质是:DOM 事件不是孤立触发的,而是沿着 DOM 树在不同元素间传播的过程。

<head>
<style>
.box {
width: 200px;
height: 200px;
background-color: orange;
display: flex;
justify-content: center;
align-items: center;
}
.box span {
width: 100px;
height: 100px;
background-color: red;
}
</style>
</head>
<body>
<div class="box">
<span></span>
</div>
<script>
var spanELem = document.querySelector('span')
var divELem = document.querySelector('div')
var bodyElem = document.querySelector('body')
spanELem.onclick = function () {
console.log('spanELem click')
}
divELem.onclick = function () {
console.log('divELem click')
}
bodyElem.onclick = function () {
console.log('bodyElem click')
}
</script>
</body>

当点击最内层红色块

点击div部分橘色块

点击最外层无色块

根据上述实验,我们可以发现默认情况下,事件是从最内层依次向外传递得顺序,这个过程就称之为事件冒泡(Event Bubble)

对于事件冒泡,不管是普通的onclick,还是addEventListener,默认都是冒泡

但事实上,还有一种监听事件流得方式是从外往内层,这种称之为事件捕获(Event Capture)

想要设置为事件捕获,需要在addEventListener中添加一个参数capture,值为true,上文也提到过了

<head>
<style>
.box {
width: 200px;
height: 200px;
background-color: orange;
display: flex;
justify-content: center;
align-items: center;
}
.box span {
width: 100px;
height: 100px;
background-color: red;
}
</style>
</head>
<body>
<div class="box">
<span></span>
</div>
<script>
var spanELem = document.querySelector('span')
var divELem = document.querySelector('div')
var bodyElem = document.querySelector('body')
spanELem.addEventListener(
'click',
function () {
console.log('spanELem click')
},
true
)
divELem.addEventListener(
'click',
function () {
console.log('divELem click')
},
true
)
bodyElem.addEventListener(
'click',
function () {
console.log('bodyElem click')
},
true
)
</script>
</body>

传进去一个event参数,通过event.eventPhase获取阶段

返回值对应阶段说明
1CAPTURING_PHASE事件从外到内传播的阶段
2AT_TARGET事件命中实际触发的元素
3BUBBLING_PHASE事件从内到外回溯的阶段

下面的例子我们给每个元素(body/div/span)都绑了两次点击事件:

第一次:addEventListener(..., true) → 捕获阶段触发(对应 eventPhase=1

第二次:addEventListener(..., false)(默认)→ 冒泡阶段触发(对应 eventPhase=3

<head>
<style>
.box {
width: 200px;
height: 200px;
background-color: orange;
display: flex;
justify-content: center;
align-items: center;
}
.box span {
width: 100px;
height: 100px;
background-color: red;
}
</style>
</head>
<body>
<div class="box">
<span></span>
</div>
<script>
var spanELem = document.querySelector('span')
var divELem = document.querySelector('div')
var bodyElem = document.querySelector('body')
spanELem.addEventListener(
'click',
function (event) {
console.log('spanELem click')
console.log(event.eventPhase, event.target, event.currentTarget)
},
true
)
divELem.addEventListener(
'click',
function (event) {
console.log('divELem click')
console.log(event.eventPhase, event.target, event.currentTarget)
},
true
)
bodyElem.addEventListener(
'click',
function (event) {
console.log('bodyElem click')
console.log(event.eventPhase, event.target, event.currentTarget)
},
true
)
spanELem.addEventListener('click', function (event) {
console.log('spanELem click')
console.log(event.eventPhase, event.target, event.currentTarget)
})
divELem.addEventListener('click', function (event) {
console.log('divELem click')
console.log(event.eventPhase, event.target, event.currentTarget)
})
bodyElem.addEventListener('click', function (event) {
console.log('bodyElem click')
console.log(event.eventPhase, event.target, event.currentTarget)
})
</script>
</body>

从结果来看,event.currentTarget是当前触发得元素,而event.target是实际触动的元素。我们实际点击的是span标签,所以event.target一直都是span标签,而event.currentTarget会一直改变。

从上述例子也可以看出,事件流的执行顺序是浏览器内置的规则,捕获阶段天然在冒泡阶段之前触发。

控制台输出eventPhase值对应阶段 & 逻辑
bodyElem click 11捕获阶段:事件从外到内,先触发body的捕获事件
divELem click 11捕获阶段:继续向内,触发div的捕获事件
spanELem click 22目标阶段:事件命中span,触发span的捕获事件(目标阶段eventPhase=2)
spanELem click 22目标阶段:触发span的冒泡事件(目标阶段不分捕获 / 冒泡,eventPhase都是2)
divELem click 33冒泡阶段:事件从内到外,触发div的冒泡事件
bodyElem click 33冒泡阶段:继续向外,触发body的冒泡事件

两个方法:

  • stopPropagation():阻止事件冒泡或者捕获
  • preventDefault():阻止默认行为

preventDefault() 只是阻止默认行为,不会阻止事件的捕获/冒泡阶段(如果想阻止事件传播,要用 event.stopPropagation())。

<body>
<div class="user-record"></div>
<form class="form-of-user">
<textarea id="user-prompt"></textarea>
<input type="text" id="user-name" />
<button type="submit" id="user-submit">submit</button>
</form>
<script>
var userRecord = document.querySelector('.user-record')
var submitBtn = document.querySelector('#user-submit')
submitBtn.onclick = function (e) {
// 阻止表单默认提交(页面不刷新)
e.preventDefault()
var userNameValue = document.querySelector('#user-name').value
var userRecordValue = document.querySelector('#user-prompt').value
var newRecord = document.createElement('div')
newRecord.textContent = userNameValue + ': ' + userRecordValue
if (userNameValue && userRecordValue) {
userRecord.prepend(newRecord)
}
}
</script>
</body>

上述示例中,我们给submit按钮绑定了click事件,点击按钮时,会触发表单的默认提交行为,即页面刷新。为了阻止默认行为,我们给click事件绑定了e.preventDefault()方法。

EventTargeth2

我们已经知道所有的节点、元素都继承自 EventTarget

事实上,window 也继承自 EventTarget

那么这个 EventTarget 是什么呢?

是一个 DOM 接口,主要用于添加、删除、派发 Event 事件

常见的方法:

  • addEventListener:注册某个事件类型以及事件处理函数
  • removeEventListener:删除某个事件类型以及事件处理函数
  • dispatchEvent:派发某个事件类型到 EventTarget 上
<body>
<button class="btn-1">点老子</button>
<script>
// 现在有个需求 一开始需要监听这个按钮的点击,过5s后,将这个事件监听移除掉
var btnElem = document.querySelector('.btn-1')
btnElem.addEventListener('click', function () {
console.log('监听到按钮的点击')
})
setTimeout(function () {
btnElem.removeEventListener('click', function () {
console.log('监听到按钮的点击')
})
}, 5000)
</script>
</body>

事实上,这个移除不了,会一直监听,为什么?

因为removeEventListener的第二个参数需要和 addEventListener的第二个参数是同一个函数,下面这一版才是正解

<body>
<button class="btn-1">点老子</button>
<script>
// 现在有个需求 一开始需要监听这个按钮的点击,过5s后,将这个事件监听移除掉
var btnElem = document.querySelector('.btn-1')
var func = function () {
console.log('监听到按钮的点击')
}
btnElem.addEventListener('click', func)
setTimeout(function () {
btnElem.removeEventListener('click', func)
}, 5000)
</script>
</body>

派发事件h2

dispatchEvent 方法用于派发事件,这个方法接收一个参数,这个参数是一个 Event 对象,这个对象可以自己创建

window.addEventListener('Arthur', function () {
console.log('Hello...yes, speaking...')
})
var event = new Event('Arthur')
setTimeout(function () {
window.dispatchEvent(event)
}, 5000)

事件委托h2

假设有一个页面,页面上有多个按钮,点击按钮后,会触发一个事件,但是这个事件需要绑定在多个按钮上,那么有没有一种方法,可以只绑定一个事件,然后通过事件委托,来处理多个按钮的事件呢?

其实就是使用上文我们提到的事件冒泡,子类节点点击,冒泡到父类节点,父元素可以监听到子元素的点击,并且可以通过event.target获取到当前监听的元素

下面有个案例:一个ul中存放多个li,点击某一个li,会变成红色

原始的思路是,监听每一个li的点击,并且做出回应

<head>
<style>
.active {
color: red;
}
</style>
</head>
<body>
<ul>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
<li>7</li>
</ul>
<script>
// 原始做法
var liElems = document.querySelectorAll('li')
for (var liElem of liElems) {
;(function (item) {
item.addEventListener('click', function () {
item.classList.add('active')
})
})(liElem)
}
// 或者使用 let liElem of liElems 块级作用域
</script>
</body>

这种思路还有一些别的实现方式,比如使用监听currentTarget,或者用this

<head>
<style>
.active {
color: red;
}
</style>
</head>
<body>
<ul>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
<li>7</li>
</ul>
<script>
// 原始做法
var liElems = document.querySelectorAll('li')
for (var liElem of liElems) {
liElem.addEventListener('click', function (event) {
event.currentTarget.classList.add('active')
// this.classList.add('active') // 默认绑定为 event.currentTarget
})
}
</script>
</body>

但是不管是什么实现方式,只要用了循环,就意味着为多个元素都绑定了一个函数,每个函数操作都是一样的,很冗余

事件委托模式:在ul中监听,并且通过event.target判断点击的是哪一个li,然后做出回应

<head>
<style>
.active {
color: red;
}
</style>
</head>
<body>
<ul>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
<li>7</li>
</ul>
<script>
// 使用事件委托,交给父元素监听并处理
var ulElem = document.querySelector('ul')
ulElem.addEventListener('click', function (event) {
var target = event.target
target.classList.add('active')
})
</script>
</body>

现在有个新的需求,点击某一个,这一个变红,但是其他变红的恢复原状

<head>
<style>
.active {
color: red;
}
</style>
</head>
<body>
<ul>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
<li>7</li>
</ul>
<script>
var ulElem = document.querySelector('ul')
ulElem.addEventListener('click', function (event) {
// 新需求,点击某一个,这一个变红,但是其他变红的恢复原状
// 方法一:遍历所有子元素,判断是否包含active,有则移除
// for(var i = 0; i < ulElem.children.length; i++){
// if(ulElem.children[i].classList.contains("active")){
// ulElem.children[i].classList.remove("active")
// }
// }
// 方法二:使用querySelectorAll直接找到,然后移除active
// var activeElem = document.querySelectorAll('.active')
// activeElem.forEach(function (item) {
// item.classList.remove('active')
// })
// 方法三:这里因为我们已知每次最多有一个变红,所以可以直接querySelector(".active")
activeElem = document.querySelector('.active')
activeElem && activeElem.classList.remove('active')
var target = event.target
target.classList.add('active')
})
</script>
</body>

但是现在还有一个问题,那就如果我们点击ul,那么所有的li都会变红,这显然是不符合要求的

最简单的思考,if(event.target.tagName!=="UL")我们套一层逻辑,如果点击的元素不是ul,那么就返回

var ulElem = document.querySelector('ul')
ulElem.addEventListener('click', function (event) {
// 希望点击ul不要触发
if (event.target.tagName !== 'UL') {
activeElem = document.querySelector('.active')
activeElem && activeElem.classList.remove('active')
var target = event.target
target.classList.add('active')
}
})

但是这样太依赖于tagName,如果以后tagName被改变,那么代码就会出错

var ulElem = document.querySelector('ul')
var activeElem = null
ulElem.addEventListener('click', function (event) {
// 如果存在activeElem并且不是ulElem,则移除active样式
if (activeElem && event.target !== ulElem) {
activeElem.classList.remove('active')
}
// 如果不是ulElem,则添加active样式
if (event.target !== ulElem) {
event.target.classList.add('active')
}
// 给activeElem重新赋值,保存当前点击的元素
activeElem = event.target
})

另一个案例,现在有三个按钮,我希望我点击哪个,就console.log哪个的值

<body>
<div class="box">
<button>移除</button>
<button>新增</button>
<button>搜索</button>
</div>
<script>
var boxElem = document.querySelector('.box')
boxElem.addEventListener('click', function (event) {
if (event.target != boxElem) {
if (event.target.textContent === '移除') {
console.log('移除')
} else if (event.target.textContent === '新增') {
console.log('新增')
} else if (event.target.textContent === '搜索') {
console.log('搜索')
}
}
})
</script>
</body>

或者我们可以为三个button加上data-*,然后根据*来输出

<body>
<div class="box">
<button data-action="remove">移除</button>
<button data-action="add">新增</button>
<button data-action="search">搜索</button>
</div>
<script>
var boxElem = document.querySelector('.box')
boxElem.addEventListener('click', function (event) {
var btnElem = event.target
var action = btnElem.dataset.action
switch (action) {
case 'remove':
console.log('移除')
break
case 'add':
console.log('新增')
break
case 'search':
console.log('搜索')
break
}
})
</script>
</body>

Comments