事件模型

September 25, 2022

網頁應用程式本身就是事件驅動,透過使用者的操作或系統的事件,在適當的時候作些事情。

基本事件模型

在事件標準化之前,就存在於各瀏覽器的一個事件模型,稱為基本事件模型(Basic Event Model),在基本事件模型中,要在某個事件發生時,呼叫指定的函式,是將函式指定給某個特性。

例如,要在網頁文件準備好,所有資源都載入後作些事情,可以註冊 windowload 事件,方式就是將函式指定給 window.onload 特性,這在之前的範例中看過:

window.onload = function() {
    // onload 事件發生時要作的事...
};

例如,要在按鈕的 click 事件發生時作些事,可以指定函式給按鈕元素的 onclick 特性:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>
    
    <button id="btn1">按鈕一</button><br>
    <button id="btn2">按鈕二</button><br>
    <div id="console"></div>
    
<script type="text/javascript">
    function handler() {
        document.getElementById('console').innerHTML 
            = `Who's clicked: ${this.id}`;
    }
    document.getElementById('btn1').onclick = handler;
    document.getElementById('btn2').onclick = handler;
</script>

</body>
</html>

按我觀看結果

在上例中,用 handler 函式註冊了兩個按鈕的 click 事件,事件在元素上觸發而呼叫函式時,this 就會設定為當時觸發事件的元素。

在這個例子中,當你按下按鈕時而呼叫函式時,this 就參考至當時按下的按鈕。像以上的作法,稱之為傳統模型(Traditional model)或傳統註冊模型(Traditional registration model)。

在標籤的屬性上撰寫 JavaScript 作為觸發事件時要執行的程式,這樣的作法稱為行內模型(Inline model)或行內註冊模型(Inline registration model)。例如:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <script type="text/javascript">
        function handle(elem) {
            document.getElementById('console').innerHTML 
                = `Who's clicked: ${elem.id}`;
        }
    </script>  
</head>
<body>
    
    <button id="btn1" onclick="handle(this);">按鈕一</button><br>
    <button id="btn2" onclick="handle(this);">按鈕二</button><br>
    <div id="console"></div>

</body>
</html>

按我觀看結果

在上例中,並非直接指定 click 事件的處理器函式,事實上,在標籤上指定程式碼的作法,會自動建立匿名函式,也就是說,上例相當於:

document.getElementById('btn1').onclick = function() {
    handle(this);
};

你指定的程式碼,會成為匿名函式的本體內容,這也說明了上例中,this 其實就是指觸發事件時的元素。

有些事件觸發時會有預設的動作,例如表單的 submit 事件預設會發送表單,超鏈結的 click 事件預設會連結至指定網頁。在基本事件模型中,若要取消預設事件動作,可以讓事件處理器傳回 false。例如:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>

    <form name="form1" action="fake.do">
        輸入資料:<input name="data"><br>
        <button type="submit">送出</button>
    </form>  

<script type="text/javascript">
    document.form1.onsubmit = function() {
        if(this.data.value.length === 0) {
            return false;
        }
    };
</script>  
    
</body>
</html>

按我觀看結果

在上例中,按下按鈕會觸發表單的 submit 事件,你檢查欄位是否有填值,沒有的話就在事件處理器中傳回 false,這會取消表單的發送,而這就是表單驗證的基本作法。若是使用行內模型。就會看到這種作法:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <script type="text/javascript">
        function validate(form) {
            if(form.data.value.length === 0) {
                return false;
            }
            return true;
        }
    </script>
</head>
<body>

    <form name="form1" action="fake.do" onsubmit="return validate(this);">
        輸入資料:<input name="data"><br>
        <button type="submit">送出</button>
    </form>    
    
</body>
</html>

按此觀看成果

要在使用者離開頁面時,做一個簡單的確認,若使用行內模型,可以如下:

<a href="https://openhome.cc" 
    onclick="return confirm('要離開了嗎?');">首頁</a>

事件不一定要由使用者的操作觸發,也可以直接呼叫方法來觸發事件。例如可以呼叫表單元素的 submit 方法,如果在載入頁面時,想要讓第一個輸入欄位取得焦點,可以呼叫輸入欄位元素的 focus 方法等。

例如,如果某個輸入欄位 iduser,則可以如下在頁面資源全部載入後,讓該欄位取得焦點:

window.onload = function() {
    document.getElementById('test').focus();
};

如果使用箭號函式來做為事件處理器,必須注意的是,箭號函式的 this 是根據語彙環境,而不是像 function 根據呼叫者是誰來決定。

DOM Level 2 事件模型

基本事件模型的缺點之一,就是只能註冊一個事件處理器,如果你想註冊多個事件處理器,那麼類似以下的方式是行不通的:

window.onload = function() {
    // 處理器一
};

window.onload = function() {
    // 處理器二
};

在上例中,第二個函式實例會成為 onload 參考的對象,而第一個函式實例就不再有用了。

如果想在基本事件處理中,在事件發生時處理時呼叫兩個以上的函式,必須透過設計的方式來達到,最簡單的方式之一就是…

function handler1() {
}

function handler2() {
}

window.onload = function() {
    handler1();
    handler2();
};

這類設計方式不好管理。事件模型在 DOM Level 2 時獲得標準化,又稱為標準事件模型,DOM Level 2 事件模型允許一次註冊兩個以上的事件,在 Internet Explorer 9 之後,DOM Level 2 事件模型也得到比較好的支援了。

在 DOM Level 2 事件模型中,要註冊事件,必須使用 addEventListener 方法。舉個例子來說,若要以 DOM Level 2 事件模型實現基本事件模型中第一個範例,可以如下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>

    <button id="btn1">按鈕一</button><br>
    <button id="btn2">按鈕二</button><br>
    <div id="console"></div>

<script type="text/javascript">
    function handler() {
        document.getElementById('console').innerHTML 
            = `Who's clicked: ${this.id}`;
    }
    document.getElementById('btn1')
            .addEventListener('click', handler, false);
    document.getElementById('btn2')
            .addEventListener('click', handler, false);
</script>  
    
</body>
</html>

按我觀看結果

addEventListener 的第一個參數指出要註冊的事件,不需要 'on' 開頭,第二個參數是事件處理器,第三個參數為 false 時,表示這是個事件浮昇處理器。

在上例中,於事件處理器中使用 this 取得目前觸發事件的元素,雖然目前多數遵守 DOM Level 2 的瀏覽器都會如此實作,不過這並不是 DOM Level 2 標準的規範,在 DOM Level 2 的標準中,可以從 Event 實例的 currentTarget 特性來取得目前觸發事件的元素。

如果使用箭號函式來做為事件處理器,必須特別注意的是,箭號函式的 this 是根據語彙環境,而不是像 function 根據呼叫者是誰來決定。

在 DOM Level 2 事件模型下,Event 會作為事件處理器的第一個參數傳入,有些事件有預設的動作,若要停止預設動作,在 DOM Level 2 事件模型下,必須呼叫 EventpreventDefault 方法(而不是像基本事件模型那樣,從事件處理器中傳回 false)。例如可改寫基本事件模型中第三個表單驗證範例:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>

    <form name="form1" action="fake.do">
        輸入資料:<input name="data"><br>
        <button type="submit">送出</button>
    </form>  

<script type="text/javascript">
    document.form1.addEventListener('submit', event => {
        if(event.currentTarget.data.value.length === 0) {
            event.preventDefault();
        }
    }, false);
</script>  
    
</body>
</html>

按我觀看結果

若要移除事件處理器,則可以使用 removeEventListener,第一個參數指定事件類型,第二個參數是當初註冊的函式實例,第三個參數指出要移除捕捉階段(true)或浮昇階段(false)的處理器。

事件傳播

在事件發生時,會有個 Event 實例收集事件的相關資訊,在遵守標準的瀏覽器上,Event 實例會作為事件處理器的的第一個參數,若要取得操作的目標物件,可以透過 Event 實例的 target 特性。

那麼操作的目標物件是指什麼呢?如果在按鈕上點選,那麼按鈕就是操作的目標物件,在基本事件模型有說明過,觸發事件時,事件處理器的 this 會設定為當時的元素,那麼為何還要有特性指出操作目標物件?

事實上,基本事件模型中,操作時若發生事件,並事件不僅停於操作的元素,還會從操作的元素往外傳播,若外層元素亦有設定對應的事件處理器,亦會呼叫事件處理器,這可以用下面的範例來示範:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body id="bodyId">
    
    <div id="divId">
        <button id="btnId">按我</button>            
    </div>
    <span id="console"></span>

<script type="text/javascript">
    function handler(event) {
        let target = event.target;
        document.getElementById('console').innerHTML += 
            `<br><b>this.id:</b> ${this.id}, <b>target.id:</b> ${target.id}`;
    }

    document.getElementById('bodyId').onclick = handler;
    document.getElementById('divId').onclick = handler;
    document.getElementById('btnId').onclick = handler;
</script>  
</body>
</html>

按我觀看結果

在上例中,按鈕是包括在 <div> 中,而 <body><div> 的外層元素,三者都設定了事件處理器。如果試著按下按鈕,則會看到結果如下:

this.id: btnId, target.id: btnId
this.id: divId, target.id: btnId
this.id: bodyId, target.id: btnId 

不僅按鈕的事件處理器被呼叫,外層 <div><body> 也依序被呼叫,這樣的行為叫作事件氣泡傳播(Event Bubbling),事件傳播至元素並呼叫事件處理器時,this 就設定為該元素,這可以從 this.id 的顯示結果觀察到,並注意到,由於操作時按下的是按鈕,所以操作目標元素就是按鈕,這可以由 target.id 觀察到。

事件氣泡傳播可以善用。例如在〈修改 DOM 樹〉中動態新增圖片的例子,每建立一個新的 <img>,就設定該 <img>click 事件處理器,以便在點選圖片時自動移除圖片。若利用事件氣泡傳播,可以只在 <div> 上設定一次事件處理器,完成相同的結果。例如:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body>
    
    <input id="src" type="text"><button id="add">新增圖片</button>
    <div id="images"></div>

<script type="text/javascript">
    let images = document.getElementById('images');
    images.onclick = function(event) {
        this.removeChild(event.target);
    };
    document.getElementById('add').onclick = function() {
        let img = document.createElement('img');
        img.src = document.getElementById('src').value;
        images.appendChild(img);
    };
</script>  

    </body>
</html>

按我觀看結果

如果想要停止事件傳播,在遵守標準的瀏覽器上,必須呼叫 EventstopPropagation 方法。例如要將第一個範例停止目標元素外的事件傳播,可以如下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body id="bodyId">
    
    <div id="divId">
        <button id="btnId">按我</button>            
    </div>
    <span id="console"></span>

<script type="text/javascript">
    function handler(event) {
        let target = event.target;
        document.getElementById('console').innerHTML += 
            `<br><b>this.id:</b> ${this.id}, <b>target.id:</b> ${target.id}`;
        event.stopPropagation();
    }

    document.getElementById('bodyId').onclick = handler;
    document.getElementById('divId').onclick = handler;
    document.getElementById('btnId').onclick = handler;
</script>  
</body>
</html>

按我觀看結果

在 DOM Level 2 事件模型中,事件會歷經兩個傳播階段,當事件發生時,會先從 document 往內傳播至操作目標元素,這個階段稱之為捕捉階段(Capturing phase),接著事件再從操作目標元素往外傳播至 document,這個階段稱之為氣泡階段(Bubbling phase)。

addEventListener 方法的第三個參數若為 true,表示事件處理器將作為捕捉階段處理器,若為 false 則為氣泡階段處理器。

例如,可改寫第一個範例,同時設定兩個階段的處理器來觀察事件:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body id="bodyId">
    <div id="divId">
        <button id="btnId">按我</button>
    </div>
    <span id="console"></span>

<script type="text/javascript">
    function handler(event) {
        let currentTarget = event.currentTarget;
        let target = event.target;
        document.getElementById('console').innerHTML += 
            `<br><b>currentTarget.id:</b> ${currentTarget.id}, <b>target.id:</b> ${target.id}`;
    }

    document.getElementById('bodyId').addEventListener('click', handler, true);
    document.getElementById('bodyId').addEventListener('click', handler, false);

    document.getElementById('divId').addEventListener('click', handler, true);
    document.getElementById('divId').addEventListener('click', handler, false);

    document.getElementById('btnId').addEventListener('click', handler, true);
    document.getElementById('btnId').addEventListener('click', handler, false);
</script>  
</body>
</html>

按我觀看結果

操作的目標元素,可以使用 Eventtarget 特性取得。如果按下按鈕,會發現以下的結果,可發現事件先從外往內,再從內往外傳播:

currentTarget.id: bodyId, target.id: btnId
currentTarget.id: divId, target.id: btnId
currentTarget.id: btnId, target.id: btnId
currentTarget.id: btnId, target.id: btnId
currentTarget.id: divId, target.id: btnId
currentTarget.id: bodyId, target.id: btnId

如果想要停止事件傳播,也是呼叫 EventstopPropagation 方法。例如上一個範例若僅註冊浮昇處理器,要停止目標元素外的事件傳播,可以如下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
</head>
<body id="bodyId">
    <div id="divId">
        <button id="btnId">按我</button>
    </div>
    <span id="console"></span>

<script type="text/javascript">
    function handler(event) {
        let currentTarget = event.currentTarget;
        let target = event.target;
        document.getElementById('console').innerHTML += 
            `<br><b>currentTarget.id:</b> ${currentTarget.id}, <b>target.id:</b> ${target.id}`;
        event.stopPropagation();
    }

    document.getElementById('bodyId').addEventListener('click', handler, false);
    document.getElementById('divId').addEventListener('click', handler, false);
    document.getElementById('btnId').addEventListener('click', handler, false);
</script>  
</body>
</html>

按我觀看結果

分享到 LinkedIn 分享到 Facebook 分享到 Twitter