使用瀏覽器事件

moduzhang發表於2018-09-04

上篇文章學習瞭如何新增、刪除頁面內容,以及為頁面內容設定樣式。我們需要在 JS 檔案中編寫 JS 程式碼。但是如果我們在 JS 檔案中編寫所有程式碼,當我們載入頁面時,所有更改將立即執行。這篇文章將學習如何根據使用者的操作,執行操縱 DOM 的 JS 程式碼

接下來我們將學習:

  • 事件,什麼是事件
  • 迴應事件,如何監聽事件並在事件發生時做出迴應
  • 事件資料,掌握事件中所包含的資料
  • 停止事件,防止事件觸發多重反應
  • 事件生命週期,事件的生命週期階段
  • DOM 就緒狀態,事件可以知道 DOM 何時準備就緒,可以與之進行互動

簡介

檢視事件

event = 通知

monitorEvents() 函式會持續吐出發生在目標元素上的所有事件,直到時間終結…或者直到你重新整理頁面。另外,Chrome 瀏覽器也有提供一個 unmonitorEvents() 函式,它可以關閉目標元素的事件通知:

// 開始顯示 document 物件上的所有事件
monitorEvents(document);

// 關閉 document 物件上所有事件的顯示。
unmonitorEvents(document);

monitorEvents 只適用於開發/測試用途,而不應該用於生產程式碼。請檢視 Chrome DevTools 網站上的文件:monitorEvents 文件

迴應事件

事件目標

你還記得第一課所講的節點介面和元素介面嗎?你還記得,元素介面是節點介面的子代,因此繼承了節點的所有屬性和方法嗎?

其實,有一點我當時完全跳過了,留到現在才講。那就是,節點介面繼承自 EventTarget 介面

這裡寫圖片描述

所有節點和元素均繼承自 EventTarget 介面

根據 EventTarget 頁面 的解釋,EventTarget 是一個由可以接收事件的物件實現的介面,並且可以為它們建立監聽器。元素、文件和視窗是最常見的事件目標。

從上圖中可以看出,EventTarget 位於整個鏈條頂端。也就是說,它不會從任何其他介面繼承任何屬性或方法。相反,所有其他介面都繼承自它,因此包含它的屬性和方法。這意味著,以下每一項都是“事件目標”:

  • document 物件
  • 段落元素
  • 視訊元素
  • 等等

EventTarget 介面沒有任何屬性,而只有三個方法!這些方法是:

  • .addEventListener()
  • .removeEventListener()
  • .dispatchEvent()

新增事件監聽器

<event-target>.addEventListener(<event-to-listen-for>, <function-to-run-when-an-event-happens>);

可見,事件監聽器需要三個要素:

  • 事件目標 - 稱為目標(例如 document 物件、<p> 元素等)
  • 要監聽的事件型別 - 稱為型別(點選、雙擊、按下的鍵盤、上的按鍵、滾動滑鼠滾輪、提交表單等等)
  • 事件發生時執行的函式 - 稱為監聽器
// 目標是頁面上的第一個 <h1> 元素
const mainHeading = document.querySelector('h1');
/**
* 要監聽的事件型別是 “click” 事件
* 監聽器是一個將 “The heading was clicked!” 記錄到控制檯的函式
*/
mainHeading.addEventListener('click', function () {
  console.log('The heading was clicked!');
});

請檢視相關文件,以瞭解更多資訊:addEventListener 文件

向專案中新增事件監聽器

在瀏覽器的開發者工具中執行程式碼對於測試來說非常有用,但是這個事件監聽器只會持續到頁面被重新整理。與我們希望傳送給使用者的所有真實的 JavaScript 程式碼一樣,我們的事件監聽器程式碼也需要位於 JavaScript 檔案中。

示例:

<html>
    ...
    <body>
        ...
        <script src="app.js"></script>
    </body>
</html>
// app.js
document.addEventListener('click', function() {
    const mainHeading = document.querySelector('h1');
    mainHeading.style.backgroundColor = 'red';
});

要檢視所有可以監聽的事件的完整列表,請參見事件文件:事件列表。該列表列出了所有可以發生的不同的 DOM 事件,它們按照常見類別進行分類。

移除事件監聽器

JS中的物件相等性

相等性是大多數程式語言中的一個常見任務,但在 JavaScript 中,這可能有點棘手,因為 JavaScript 會進行所謂的“強制型別轉換”,即嘗試將所比較的專案轉換為相同的型別(例如字串、數字)。JavaScript 既有允許進行強制型別轉換 的雙等號 (==) 運算子,也有防止在比較時進行強制型別轉換的三等號 (===) 符號。

物件相等性,它包括物件、陣列和函式

{ name: 'Richard' } === { name: 'Richard' }
// 相等性測試結果是 false,兩個物件並不相等!

雖然兩個物件看起來完全一樣,但是也不表示它們完全相同,相同的資訊並不表示就完全相同。在使用 JS 物件和處理對等性時,我們需要思考它們是兩個不同的物件嗎?或者是引用同一物件的兩個不同名稱嗎?

var a = {
    myFunction: function quiz() { console.log('hi'); }
};
var b = {
    myFunction: function quiz() { console.log('hi'); }
};
// 相等性測試結果是 false,這兩個 myFunction 函式是不同的函式。它們看起來一樣,但卻是不同的實體。
function quiz() { ... }

var a = {
    myFunction: quiz
};
var b = {
    myFunction: quiz
}
// 相等性測試結果是 true,這兩個 myFunction 函式均指向同一個函式,也就是 quiz 函式。

為什麼我們要關心物件/函式的平等性呢?原因在於,.removeEventListener() 方法要求我們向其傳遞與傳遞給 .addEventListener() 的函式完全相同的監聽器函式

<event-target>.removeEventListener(<event-to-listen-for>, <function-to-remove>);

可見,事件監聽器需要三個要素:

  • 事件目標 - 稱為目標
  • 要監聽的事件型別 - 稱為型別
  • 要移除的函式 - 稱為監聽器

請記住,監聽器 函式必須是與 .addEventListener() 呼叫中使用的函式完全 相同的函式,而不僅僅是一個看起來相同的函式。

function myEventListeningFunction() {
    console.log('howdy');
}

// 為 點選 事件新增一個監聽器,來執行 `myEventListeningFunction` 函式
document.addEventListener('click', myEventListeningFunction);

// 立即移除 應該執行`myEventListeningFunction`函式的 點選 事件監聽器
document.removeEventListener('click', myEventListeningFunction);
  • 具有相同的目標
  • 具有相同的型別
  • 並傳遞完全相同的監聽器

不具備相等性時,即監聽器函式並未指向完全相同的函式。它不會移除監聽器:

// 為 點選 事件新增一個監聽器,來執行 `myEventListeningFunction` 函式
document.addEventListener('click', function myEventListeningFunction() {
    console.log('howdy');
});

// 立即移除 應該執行`myEventListeningFunction`函式的 點選 事件監聽器
document.removeEventListener('click', function myEventListeningFunction() {
    console.log('howdy');
});

MDN 上的 removeEventListener

事件的階段

事件的生命週期包括三個不同的階段,分別是:

  • 捕獲
  • 目標
  • 冒泡

而且,它們按照以上順序發生;首先是 捕獲 ,其次是 目標 ,再次是 冒泡 階段。

大多數事件處理器都在目標階段執行,例如當你將點選事件處理器附加到按鈕時。事件到達按鈕(即其目標),而在那裡只有一個處理器,因此事件處理器得以執行。

<html>
<body>
    <div>
        <p>
            <button>Dare to click me?</button>
        </p>
    </div>
</body>
</html>

點選事件會啟動整個流程,首先從捕獲階段開始,它從 HTML 元素開始,一直往下,抵達點選的元素 <button> 後,它會切換到新增目標階段,然後切換到冒泡階段並一直往上執行。


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

預設情況下,當僅使用兩個引數來呼叫 .addEventListener() 時,該方法會預設使用冒泡階段

document.addEventListener('click', function () {
   console.log('The document was clicked');
}, true);

但是用三個引數來呼叫,第三個引數為 true(意思是它應該在捕獲階段提早啟用監聽器)。

不同事件階段的事件處理程式設定過程說明:

這次對段落設定捕獲事件監聽器,對主體設定冒泡監聽器並對按鈕設定冒泡監聽器,關鍵區別是段落設為捕獲階段,而主體和按鈕預設為冒泡階段。

這裡寫圖片描述

當按鈕被點選時,流程從頂部開始並往下執行,抵達主體元素後,它不會執行該函式,因為我們依然處於捕獲階段。

這裡寫圖片描述

但當它抵達段落部分時,將執行監聽器函式。這是因為這個段落設定為了在捕獲階段執行。

這裡寫圖片描述

然後轉到按鈕部分,從捕獲階段切換到了目標冒泡階段,然後觸發監聽器。因為按鈕使用了預設設定,即在冒泡階段執行函式。

這裡寫圖片描述

然後沿著 HTML 步驟往回執行。抵達主體時,執行監聽器函式。然後轉到 HTML 元素並結束。

這裡寫圖片描述

事件物件

當事件發生時,瀏覽器包含一個事件物件。這只是一個常規的 JavaScript 物件,包含大量有關事件本身的資訊。根據 MDN,.addEventListener() 的監聽器函式,在發生指定型別的事件時,會收到一個通知(一個實現事件介面的物件)。

document.addEventListener('click', function (event) {  // ← 全新的 `event` 引數!
   console.log(event);
});

現在,當監聽器函式被呼叫時,它就可以儲存傳遞給它的事件資料了!

這裡寫圖片描述

預設操作

事件物件儲存了大量資訊,我們可以使用這些資料來做各種事情。不過,專業人員經常使用事件物件來做的一件事,就是阻止預設操作的發生

如果沒有事件物件,我們就只能任由預設操作發生。不過,事件物件上有一個 .preventDefault() 方法,處理器可以呼叫該方法來阻止預設操作發生!

const links = document.querySelector('a');
const thirdLink = links[2];

thirdLink.addEventListener('click', function (event) {
    event.preventDefault();
    console.log("Look, ma! We didn't navigate to a new page!");
});

MDN 上的事件

避免太多事件

重構事件監聽器的數量

var myCustomDiv = document.createElement('div');

for (var i = 1; i <= 200; i++) {
    var newElement = document.createElement('p');
    newElement.textContent = 'This is paragraph number ' + i;

    newElement.addEventListener('click', function respondToTheClick() {
        console.log('A paragraph was clicked.');
    });

    myCustomDiv.appendChild(newElement);
}

document.body.appendChild(myCustomDiv);

這裡寫圖片描述

var myCustomDiv = document.createElement('div');

function respondToTheClick() {
    console.log('A paragraph was clicked.');
}

for (var i = 1; i <= 200; i++) {
    var newElement = document.createElement('p');
    newElement.textContent = 'This is paragraph number ' + i;

    newElement.addEventListener('click', respondToTheClick);

    myCustomDiv.appendChild(newElement);
}

document.body.appendChild(myCustomDiv);

這裡寫圖片描述

var myCustomDiv = document.createElement('div');

function respondToTheClick() {
    console.log('A paragraph was clicked.');
}

for (var i = 1; i <= 200; i++) {
    var newElement = document.createElement('p');
    newElement.textContent = 'This is paragraph number ' + i;

    myCustomDiv.appendChild(newElement);
}

myCustomDiv.addEventListener('click', respondToTheClick);

document.body.appendChild(myCustomDiv);

這裡寫圖片描述

現在只有:

  • 一個事件監聽器
  • 一個監聽器函式

現在,瀏覽器無需在記憶體中儲存兩百個不同的事件監聽器和兩百個不同的監聽器函式。這大大提高了效能!

但是,如果你測試以上程式碼,就會注意到我們失去了對單個段落的訪問許可權。我們無法將特定的段落元素作為目標。那麼,我們如何將這個高效的程式碼與先前訪問單個段落專案的能力結合起來呢?

事件代理

事件物件有一個 .target 屬性。該屬性引用了事件的目標。還記得捕獲、目標和冒泡階段嗎?…它們現在也會派上用場!

假設你點選了一個段落元素。整個過程大致如下:

  • 段落元素被點選
  • 事件經歷捕獲階段
  • 事件達到目標
  • 事件切換到冒泡階段,並開始向上爬升 DOM 樹
  • 當它碰到 <div> 元素時,就會執行監聽器函式
  • 在監聽器函式中,event.target 是被點選的元素

因此,event.target 讓我們可以直接訪問被點選的段落元素。由於我們可以直接訪問該元素,因此我們可以訪問它的 .textContent修改它的樣式更新它所擁有的類——我們可以對它進行任何操作!

var myCustomDiv = document.createElement('div');

function respondToTheClick(evt) {
    console.log('A paragraph was clicked: ' + evt.target.textContent);
}

for (var i = 1; i <= 200; i++) {
    var newElement = document.createElement('p');
    newElement.textContent = 'This is paragraph number ' + i;

    myCustomDiv.appendChild(newElement);
}

document.body.appendChild(myCustomDiv);

myCustomDiv.addEventListener('click', respondToTheClick);

檢查事件代理中的節點型別

如果我們具有以下 HTML,會發生什麼情況:

<article id="content">
  <p>Brownie lollipop <span>carrot cake</span> gummies lemon drops sweet roll dessert tiramisu. Pudding muffin <span>cotton candy</span> croissant fruitcake tootsie roll. Jelly jujubes brownie. Marshmallow jujubes topping sugar plum jelly jujubes chocolate.</p>

  <p>Tart bonbon soufflé gummi bears. Donut marshmallow <span>gingerbread cupcake</span> macaroon jujubes muffin. Soufflé candy caramels tootsie roll powder sweet roll brownie <span>apple pie</span> gummies. Fruitcake danish chocolate tootsie roll macaroon.</p>
</article>
document.querySelector('#content').addEventListener('click', function (evt) {
    console.log('A span was clicked with text ' + evt.target.textContent);
});

這樣做是可以的,但有一個重要缺陷。當任何一個段落元素被點選時,監聽器函式仍會觸發!換句話說,這個監聽器函式並沒有驗證事件目標是否確實是一個 <span> 元素。讓我們將這個檢查新增上去:

document.querySelector('#content').addEventListener('click', function (evt) {
    if (evt.nodeName === 'SPAN') {  // ← 驗證目標是我們需要的元素
        console.log('A span was clicked with text ' + evt.target.textContent);
    }
});

每個元素都從節點介面繼承屬性。從節點介面繼承的屬性之一就是 .nodeName。我們可以使用這個屬性來驗證目標元素確實是我們正在查詢的元素。當一個 <span> 元素被點選時,它將有一個 .nodeName 屬性為“SPAN”,因此檢查將通過,並且該訊息將會被記錄。但是,如果一個 <p> 元素被點選,它將有一個 .nodeName 屬性為“P”,因此檢查將失敗,並且該訊息將不會被記錄。

注意,.nodeName 屬性將返回一個大寫字串,而不是一個小寫字串。因此,當你執行檢查時,請確保,檢查大寫字母,或者將 .nodeName 轉換為小寫。

// 用大寫字母檢查
if (evt.nodeName === 'SPAN') {
    console.log('A span was clicked with text ' + evt.target.textContent);
}

// 將 nodeName 轉換為小寫
if (evt.nodeName.toLowerCase() === 'span') {
    console.log('A span was clicked with text ' + evt.target.textContent);
}

DOM 何時準備就緒

DOM 是增量式構建的

當 HTML 被接收、轉換為令牌並構建文件物件模型時,這是一個連續的過程。當解析器到達一個 <script> 標籤時,它必須等待下載指令碼檔案並執行該 JavaScript 程式碼。這是一個要點,也是 JavaScript 檔案位置之所以十分重要的關鍵!

<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="stylesheet" href="/css/styles.css" />
  <script>
    document.querySelector('footer').style.backgroundColor = 'purple';
  </script>

請注意,我們目前所獲得的程式碼底部是一個 <script> 檔案。這是使用內聯 JavaScript,而非指向外部檔案的。內聯檔案的執行速度會更快,因為瀏覽器不必再發出網路請求來獲取 JavaScript 檔案。但是,這個內聯版本和 HTML 連結到外部 JavaScript 檔案的結果將完全相同。

問題出在 .querySelector() 方法。當它執行時…所構建的文件物件模型中尚沒有可供選擇的 <footer> 元素!因此,它不會返回 DOM 元素,而是會返回 null。這將導致一個錯誤,因為它相當於執行以下程式碼:

null.style.backgroundColor = 'purple';

由於 null 並沒有 .style 屬性,因此我們的錯誤就出現了。我們將 JavaScript 檔案移到了頁面底部。想一想為什麼這樣做可以解決問題。答案是,如果 DOM 是連續構建的,那麼將 JavaScript 程式碼移到頁面的最底部,則當 JavaScript 程式碼執行的時候,所有 DOM 元素都已經存在了!

不過,一個 替代 解決方案則是使用瀏覽器事件!

使用 DOMContentLoaded 事件

當文件物件模型被完全載入時,瀏覽器將觸發一個事件。這個事件被稱為 DOMContentLoaded 事件,我們可以使用監聽任何其他事件的方式來監聽這個事件:

document.addEventListener('DOMContentLoaded', function () {
    console.log('the DOM is ready to be interacted with!');
});

如果你去檢視別人的程式碼,你可能會發現,他們的程式碼監聽的是正在使用的 load 事件(例如 document.onload(...))。load 會比 DOMContentLoaded 更晚觸發——load 會等到所有影象、樣式表等載入完畢(HTML 引用的所有東西)。很多年長的開發者會使用 load 來代替 DOMContentLoaded,因為後者不被最早的瀏覽器支援。但是,如果你需要檢測程式碼的執行時間,通常 DOMContentLoaded 是更好的選擇。

僅僅因為你可以使用 DOMContentLoaded 事件在 <head> 中編寫 JavaScript 程式碼,並不意味著你就應該這樣做。因為這樣做的話,我們必須編寫更多程式碼(所有事件監聽器之類),而更多程式碼通常並不總是最好的辦法。相反,更好的選擇是將程式碼移到 HTML 檔案底部,放在結束 </body> 標籤之前。

什麼時候應該使用這個技能呢?由於 <head> 中的 JavaScript 程式碼會在 <body> 中的 JavaScript 程式碼之前執行,因此如果你確實有 JavaScript 程式碼需要儘快執行,則可以將該程式碼放在 <head> 中,並將其包裹在一個 DOMContentLoaded 事件監聽器中。這樣,它既可以儘早執行,又不會在 DOM 尚未準備就緒的時候過早執行。

MDN 上的 DOMContentLoaded 事件文件

相關文章