在網(wǎng)頁(yè)端、移動(dòng)端H5、小程序等各個(gè)終端環(huán)境的前端開(kāi)發(fā)中隨處可見(jiàn)事件的運(yùn)用,可見(jiàn)事件機(jī)制的是前端這一塊的重中之重。經(jīng)過(guò)我研讀了大量博客文章以及開(kāi)源框架源碼后,這次算是對(duì)DOM事件機(jī)制有了更新更全面的的認(rèn)識(shí)。
下文主要通過(guò)一個(gè)例子帶你 分析DOM事件的傳遞、事件處理 ,然后在此基礎(chǔ)上再 深入總結(jié)對(duì)事件監(jiān)聽(tīng)的優(yōu)化方案 ,最終總結(jié)下DOM事件機(jī)制 在開(kāi)源框架中的實(shí)戰(zhàn)應(yīng)用 。
以下面這個(gè)普通的html文件為例
<!DOCTYPE html>
<html lang="en" onclick="handleClickHtml()">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body onclick="handleBodyClick()">
<div id="div1" onclick="handleClick2()">
<button id="button1" onclick="handleClick1()">handleClick1</button>
</div>
<script>
function handleClick1(e) {
console.log('click1')
}
function handleClick2(e) {
console.log('click2')
}
function handleBodyClick(e) {
console.log('body clicked')
}
function handleClickHtml(e) {
console.log('html clicked')
}
document.addEventListener('click', e => {
console.log('document clicked')
})
</script>
</body>
</html>
復(fù)制代碼
例子中給 document、html、body、div、button 都添加了點(diǎn)擊事件,其執(zhí)行結(jié)果如下:
由鼠標(biāo)、鍵盤(pán)、觸摸屏、窗口縮放、圖片加載、節(jié)點(diǎn)焦點(diǎn)變化等觸發(fā)事件,每個(gè)觸發(fā)的事件都有一個(gè)target——事件目標(biāo),根據(jù)target往html根節(jié)點(diǎn)遍歷就可以確定節(jié)點(diǎn)嵌套層級(jí)關(guān)系,從而確定此事件的傳遞路徑。
事件傳遞路徑由瀏覽器、webview來(lái)確定
一個(gè)事件來(lái)回走兩遍是便于事件響應(yīng)時(shí)機(jī)的控制(下文的事件冒泡與事件捕獲的應(yīng)用中有體現(xiàn)),同時(shí)也存在事件冒泡的瀏覽器歷史原因(與IE、Netscape有關(guān),不贅述)。
事件在從自上而下的傳遞過(guò)程中會(huì) 判斷當(dāng)前node節(jié)點(diǎn)是否綁定對(duì)應(yīng)事件的監(jiān)聽(tīng)器 ,若有則執(zhí)行響應(yīng)監(jiān)聽(tīng)器的事件處理程序(也就是綁定的事件要執(zhí)行的邏輯),這里的執(zhí)行就是所謂的事件響應(yīng)。
事件的響應(yīng)過(guò)于與事件的類(lèi)型是捕獲事件、冒泡事件有關(guān),同時(shí)是否組件冒泡還會(huì)影響該事件的后續(xù)傳遞過(guò)程,具體執(zhí)行流程總結(jié)成如下圖所示:
根據(jù)事件傳遞中的兩種傳遞路徑分別分為: 由上至下——事件捕獲階段,由下至上——事件冒泡階段 ,指的是一個(gè)事件由window(上圖并未標(biāo)出window)到target之間的傳遞的過(guò)程。
可以通過(guò)組織事件冒泡讓事件不再繼續(xù)完成由下至上的過(guò)程,這會(huì)讓target的父節(jié)點(diǎn)綁定冒泡階段的事件監(jiān)聽(tīng)器不響應(yīng)事件。
可以通過(guò)指定事件在捕獲階段執(zhí)行。
看下面這個(gè)例子,可以通過(guò) stopPropagation 來(lái)阻止事件繼續(xù)往上冒泡, window、document、html 上添加的點(diǎn)擊事件均不會(huì)生效
<!-- 省略了部分代碼 -->
<div id="div1">
<button id="button1">button</button>
</div>
<script>
var div1 = document.getElementById('div1')
var button1 = document.getElementById('button1')
div1.addEventListener('click', (e) => {
console.log(e.currentTarget)
}, false)
button1.addEventListener('click', (e) => {
console.log(e.currentTarget)
e.stopPropagation() // 關(guān)鍵代碼:組織了click事件繼續(xù)往上冒泡
}, false)
復(fù)制代碼
// 以下是監(jiān)聽(tīng)html、document的點(diǎn)擊事件 function handleClickHtml(e) { console.log('html clicked') } document.addEventListener('click', e => { console.log('document clicked') }) </script> 復(fù)制代碼
點(diǎn)擊button的打印結(jié)果如下:
addEventListener需要用attachEvent進(jìn)行兼容性處理,第三個(gè)參數(shù)默認(rèn)值為false表示在事件冒泡階段響應(yīng)事件
還是上面這個(gè)例子,只更改div1部分的事件監(jiān)聽(tīng),的第三個(gè)參數(shù)為true來(lái)開(kāi)啟事件捕獲
<!-- 省略了部分代碼 -->
<div id="div1">
<button id="button1">button</button>
</div>
<script>
var div1 = document.getElementById('div1')
var button1 = document.getElementById('button1')
div1.addEventListener('click', (e) => {
console.log(e.currentTarget)
}, true) // true:表示div1在事件的捕獲階段響應(yīng)事件
button1.addEventListener('click', (e) => {
console.log(e.currentTarget)
e.stopPropagation() // 組織事件冒泡
}, false)
復(fù)制代碼
// 以下是監(jiān)聽(tīng)html、document的點(diǎn)擊事件 function handleClickHtml(e) { console.log('html clicked') } document.addEventListener('click', e => { console.log('document clicked') }) </script> 復(fù)制代碼
點(diǎn)擊button的打印結(jié)果如下:
依次執(zhí)行了div1和button1綁定的點(diǎn)擊事件,注意這里兩者的 執(zhí)行順序 很關(guān)鍵。事件在捕獲階段遇到div1節(jié)點(diǎn)時(shí)執(zhí)行打印,然后在button處(處于目標(biāo)階段)執(zhí)行打印,然后事件被阻止冒泡不再往上傳遞。
考慮以下場(chǎng)景:
有個(gè)一個(gè)長(zhǎng)消息列表實(shí)時(shí)接受新的消息,滾動(dòng)到底部時(shí)加載更多消息,點(diǎn)擊消息可進(jìn)入回話(huà)窗口頁(yè)面,消息左滑消息此條消息
問(wèn)題:
抽象來(lái)看這個(gè)問(wèn)題就是如何高效的給大批量(甚至是無(wú)限量)節(jié)點(diǎn)添加事件,這里暫時(shí)不考慮vue、react等框架
每注冊(cè)一個(gè)事件監(jiān)聽(tīng)監(jiān)聽(tīng)都需要使用一定內(nèi)存,同時(shí)在dom節(jié)點(diǎn)多了之后事件經(jīng)過(guò)的捕獲、冒泡階段要處理事件傳遞也就慢了
父節(jié)點(diǎn)嵌套的子節(jié)點(diǎn)的觸發(fā)的事件會(huì)通過(guò)事件冒泡到達(dá)父節(jié)點(diǎn),事件處理程序不直接綁定到子節(jié)點(diǎn),統(tǒng)一由公共父節(jié)點(diǎn)進(jìn)行事件監(jiān)聽(tīng)。
e.target表示觸發(fā)事件的元素,通過(guò)e.target可以判斷具體響應(yīng)那個(gè)元素的事件 e.currentTarget表示事件綁定的元素,在事件委托情況下指向同一個(gè)元素
<!-- 省略了部分代碼 -->
<div id="div1">
<button id="button1">button</button>
</div>
<script>
document.addEventListener('click', e => {
// e.target:觸發(fā)事件的元素
// e.currentTarget:事件監(jiān)聽(tīng)器綁定的元素
// console.log(e.target, e.currentTarget)
if (e.target.id === 'button1') {
return console.log('button1 clicked')
}
if (e.target.id === 'div1') {
return console.log('div1 clicked')
}
if (e.target.tagName === 'HTML') {
return console.log('html clicked')
}
if (e.target.tagName === 'BODY') {
return cosnole.log('body clicked')
}
})
</script>
復(fù)制代碼
依次點(diǎn)擊 html、div1、button1 會(huì)依次打印結(jié)果:
上面事件委托可以解決大批量節(jié)點(diǎn)事件監(jiān)聽(tīng)的性能問(wèn)題,但是DOM節(jié)點(diǎn)量過(guò)大導(dǎo)致界面滾動(dòng)不流暢的問(wèn)題沒(méi)解決。
虛擬滾動(dòng)是指在列表滾動(dòng)事件中動(dòng)態(tài)計(jì)算出當(dāng)前窗口中能夠顯示的列表節(jié)點(diǎn),其前后窗口不顯示的節(jié)點(diǎn)各用一個(gè)div進(jìn)行占位處理,并調(diào)整占位div的樣式確模擬的列表樣式與原始列表無(wú)異處。
虛擬滾動(dòng)已經(jīng)是一個(gè)完美解決長(zhǎng)列表渲染性能的方案,針對(duì)react、vue均有對(duì)應(yīng)庫(kù)實(shí)現(xiàn),此處不再贅述
事件委托也叫事件代理,在react中的合成事件則是完全采用了事件代理
基于react16的 ant-design-pro 項(xiàng)目的事件綁定截圖如下:
直接通過(guò)onClick綁定的事件通過(guò)react處理最終會(huì)統(tǒng)一綁定到document上,正是使用的事件委托。
最新版微信小程序中綁定事件有兩種類(lèi)型: bind:methodName、catch:methodName、mut-bind:methodName、mut-catch:methodName ,其中區(qū)別最大的是前兩者。
methodName表示移動(dòng)端事件名,例如:tap、touchmove等
bind:methodName 采用冒泡綁定事件, catch:methodName **采用冒泡綁定事件并阻止冒泡
實(shí)現(xiàn)思路: 彈框采用一層遮罩層overlay遮蓋,遮罩層寬高設(shè)為窗口寬高同大小,采用 catch:touchmove 綁定該層的touchmove事件為空函數(shù)防止其冒泡 。
給overlay組件外層綁定touchmove為空函數(shù)并阻止冒泡:
// overlay/index.wxml
<van-transition
show="{{ show }}"
custom-class="van-overlay"
custom-style="z-index: {{ zIndex }}; {{ customStyle }}"
duration="{{ duration }}"
bind:tap="onClick"
catch:touchmove="noop"
/*
* 通過(guò)catch:touchmove來(lái)阻止冒泡至overlay層的事件繼續(xù)往上冒泡,
* 實(shí)現(xiàn)內(nèi)層嵌套的內(nèi)容可以響應(yīng)touchmove事件而外層(頁(yè)面)滾動(dòng)事件不響應(yīng)),
* 以此來(lái)解決滾動(dòng)事件穿透
*/
>
<slot></slot>
</van-transition>
復(fù)制代碼
overlay組件外層fixed定位固定寬高防止?jié)L動(dòng):
// overlay/index.less
.van-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
.theme(background-color, '@overlay-background-color');
}
復(fù)制代碼
實(shí)現(xiàn)思路:與vant-weapp小程序框架中相似, 彈框采用一層遮罩層overlay遮蓋,遮罩層寬高設(shè)為窗口寬高同大小,采用 @touchmove 綁定該層的touchmove事件,在事件中用event.stopPropagation阻止事件冒泡 。
給overlay組件外層綁定touchmove事件阻止冒泡:
return (
<transition name="van-fade">
<div
vShow={props.show}
style={style}
class={[bem(), props.className]}
onTouchmove={preventTouchMove}
{...inherit(ctx, true)}
/>
</transition>
);
復(fù)制代碼
const preventTouchMove(e: Event): void = () => { event.stopPropagation(); } <span>復(fù)制代碼</span>
overlay組件外層fixed定位固定寬高防止?jié)L動(dòng):
.van-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
.theme(background-color, '@overlay-background-color');
}
復(fù)制代碼