第十三章 瀏覽器事件

bjsuo發表於2012-08-09

為了給網頁新增一些有趣的功能,僅僅去檢查修改文件通常是不夠的。我們需要檢測使用者正在做什麼,並且響應使用者的操作。如果做到這一點,需要用到:事件處理。按下鍵盤是事件,點選滑鼠是事件,滑鼠移動可以看作高階的事件。在第11章,我們給按鈕新增了一個onclick 屬性,目的就是當按鈕按下的時候做我們想要的事情。這就是一個簡單的事件處理器。
從根本上講,瀏覽器的事件機制是很簡單的。它是在指定的DOM 結點上為指定的事件註冊一個處理函式,當事件發生的時候,如果存在處理函式,則呼叫。對於一些事件,比如鍵盤按下,僅僅知道事件發生了是不夠的,還需要知道是哪個鍵被按下了。為了儲存事件資訊,每個事件都會生成一個事件物件,供呼叫函式使用。 雖然事件在任何時候都可能被觸發,但同一時刻不會同時執行兩個處理函式,意識到這一點是非常重要的。如果javascript程式碼正在執行,在執行結束前,瀏覽器不會呼叫其他的處理函式,這也適用於其他方式觸發的程式碼,比如setTimeout。用程式設計師的行話講:在瀏覽器中,javascript是單執行緒的,在某一時刻,不可能會有兩個執行緒執行。在大多數情況下,這是個好事。在同一時刻執行多段程式碼的話,經常會產生奇怪的結果。
一個事件發生後,如果沒有處理函式,會通過DOM樹向上傳遞。它的意思是,比如一段文字中有一個連結,連結相關處理函式首先會被呼叫。如果沒有其他的處理函式或者處理函式沒有指明事件已經處理結束,將會觸發父結點paragraph的處理函式。然後到document.body的處理。最後,如果沒有javascript處理這個事件,單擊連結就會跳轉。
正如你看到的那樣,事件是簡單的。唯一的難點,就是各瀏覽器之間對同一功能支援的或多或少,支援這個功能需要呼叫不同的介面,通常,相容性最差的是IE,它忽略了標準規範,而其他瀏覽器大都遵守規範。之後是opera,它不能正確地支援一些有用的事件,例如onunload 事件,它在離開頁面時觸發。另外對一些鍵盤事件有時會產生撲朔迷離的資訊。
處理一個事件,需要有四步:

  • 註冊處理函式
  • 得到事件物件
  • 從事件物件中得到有用資訊
  • 標識一個事件已經被處理
    對於所有主流的瀏覽器,他們的工作原理都是不同的。


    作為事件處理的實踐部分,我們開啟一個文件,它包含一個按鈕和一個文字框,在本章接下來的部分,保持開啟這個文件。

    attach(window.open("example_events.html"));

第一步,註冊處理函式,我們給一個元素設定onclick 事件,這是可以跨瀏覽器的,但這有一個缺陷——一個元素只能新增一個處理函式。大多數情況下,這已經足夠了,但在某些情況下,特別是多個程式之間協作的情況下(有可能已經新增了處理函式),這是有些讓人無奈的。
在IE中,給一個按鈕新增事件如下所示:

$("button").attachEvent("onclick", function(){print("Click!");});  

在其他的瀏覽器中,如下所示:

$("button").addEventListener("click", function(){print("Click!");},
                             false);  

注意到在第二種情況下沒有“on”,對addEventListener的第三個引數,如果是false,代表事件以普通的冒泡方式向DOM樹上傳遞,如果是 true,代表這個處理函式比當前元素下面的處理函式的權重要高。IE是不支援這個特性的,通常這也很少用。
我們可以把上面兩個不相容的模組封裝成為一個registerEventHandler 函式,它接收三個引數:第一個是要新增事件的DOM結點,第二個引數是事件型別名字,比如“onclick”或者"keypress",最後一個是處理函式。
決定呼叫哪個一個方法,要看方法自身的特性——DOM結點是否有attachEvent方法。可以假設這個方法是存在的,就可以直接判斷出當前瀏覽器是不是IE。如果新出現的瀏覽器使用IE核心,或者IE突然支援標準模型了,程式碼可以正常執行,當然這是不太可能的。但儘量用一些聰明的辦法來避免漏洞。

 function registerEventHandler(node, event, handler) {  
  if (typeof node.addEventListener == "function")  
    node.addEventListener(event, handler, false);  
  else  
    node.attachEvent("on" + event, handler);  
}

registerEventHandler($("button"), "click",  
                     function(){print("Click (2)");});

不用擔心這個名字太長,太笨,稍後,我們會在這個基礎上再進行一次封裝,那將會使用一個較短的名字。 我們也可以宣告一個registerEventHandler ,根據不同的瀏覽器賦予不同的函式,這樣只要判斷一次,效率更高,只是看起來有些奇怪。

if (typeof document.addEventListener == "function")
  var registerEventHandler = function(node, event, handler) {
    node.addEventListener(event, handler, false);
  };
else
  var registerEventHandler = function(node, event, handler) {
    node.attachEvent("on" + event, handler);
  };


移除事件跟新增事件非常相似,只不過使用的方法是detachEvent和removeEventListener ,請注意,移除事件需要訪問新增上去的處理函式。
function unregisterEventHandler(node, event, handler) {
  if (typeof node.removeEventListener == "function")
    node.removeEventListener(event, handler, false);
  else
    node.detachEvent("on" + event, handler);
}


事件處理函式可能會產生異常,但由於技術限制,不能在控制檯上捕捉到。從而被瀏覽器捕捉,它可能會在“錯誤控制檯”的某處隱藏,或者丟擲異常資訊。如果你寫了一個處理函式沒有得到期望的結果,可能的原因是由於錯誤而默默地中止執行了。
多數瀏覽器會向處理函式傳遞一個事件物件引數,IE會把儲存到一個叫做event的全域性變數中。當我們看javascript程式碼的時候,經常會遇到“event || window.event”這樣的程式碼,這是試圖訪問event 區域性變數,如果未定義則去訪問全域性的同名變數。
function showEvent(event) {
  show(event || window.event);
}

在輸入框中輸入一些字元,觀察這些物件,禁用後重試

 registerEventHandler($("textfield"), "keypress", showEvent);

當使用者點選滑鼠的時候會產生三個事件,首先,滑鼠鍵按下去的時刻產生mousedown,然後滑鼠鍵鬆開的時候產生mouseup,最後是click,它代表點選事件發生。當上述過程連續快速地發生兩次的話,會產生dblclick 事件(雙擊事件)。請注意,有可能會產生mousedown 和mouseup 事件——當滑鼠被按下去保持一會再鬆開。
當事件新增到目標後,比如按鈕,事實上你往往需要知道的是,結點本身已經被點選。另一方面,新增事件的結點如果有子結點,子結點的點選事件可以傳遞過來。你需要找到哪一個子結點被點選了。為了實現這個目的,事件物件有target或者srcElement屬性,這個根據瀏覽器不同而不同。
另一個有意思的是點選事件的座標資訊,事件物件包含clientX 和clientY兩個屬性,它分別代表滑鼠的x、y座標,在螢幕上,它的單位是畫素。文件是可以滾動的,所以通過這個座標並不能得知關於滑鼠經過頁面的哪部分。有些瀏覽器提供了pageX 和pageY屬性來實現這個功能,但其他的(猜是哪些)沒有。幸運的是,可以使用document.body.scrollLeft和document.body.scrollTop屬性來得到文件滾動了多少畫素。
給整個文件新增如下事件處理函式,攔截所有的滑鼠點選事件,並列印出一些資訊。

function reportClick(event) {
  event = event || window.event;
  var target = event.target || event.srcElement;
  var pageX = event.pageX, pageY = event.pageY;
  if (pageX == undefined) {
    pageX = event.clientX + document.body.scrollLeft;
    pageY = event.clientY + document.body.scrollTop;
  }

  print("Mouse clicked at ", pageX, ", ", pageY,
        ". Inside element:");
  show(target);
}

registerEventHandler(document, "click", reportClick);

然後把它解除安裝掉

unregisterEventHandler(document, "click", reportClick);

顯然地,是不是要在每一個簡單的事件處理函式中都要寫這些檢查和解決方法呢,在我們熟知這些不相容問題後,可以寫一個函式來通用化事件物件,來解決跨瀏覽器的問題。
有時候需要知道滑鼠的哪一個鍵被按下了,可以使用事件物件的which 和button 屬性。不幸的是,這是不可靠的——有些瀏覽器會假設滑鼠只有一個按鍵,也有些會認為在按下ctrl時右擊是點選。等等。
除了點選,有些對滑鼠移動感興趣,當滑鼠經過一個DOM結點時,會觸發mousemove 事件,當然還有mouseover 和mouseout,它們在滑鼠進入和離開結點時觸發。對於這最後一類的事件,target (或者srcElement)屬性指出了事件觸發的結點,relatedTarget (或toElement,或fromElement)屬性給出滑鼠從哪個結點來(mouseover事件)或離開哪個結點(mouseout)。
對於有子結點的元素,處理mouseover和mouseout 事件是比較棘手的,子結點觸發的事件也傳遞過來,所以在經過每一個子結點的時候,你還會看到一個mouseover 事件。可以用target 和relatedTarget 屬性來檢查(或忽略)這些事件。


當使用者按下鍵盤上每一個鍵的時候,會觸發三個事件,keydown、keyup和keypress。通常情況下,使用前兩個事件是為了確定哪個鍵被按下了,比如你需要在方向鍵按下的時候做處理。keypress,另一方面,它在你對輸入的字元感興趣的時候使用。原因是keyup 和keydown 事件往往沒有字元資訊,而在IE中,按下特殊字元鍵包括方向鍵,不會產生keypress 事件。
找到哪個鍵被按下需要很大的挑戰,對於keydown 和keyup 事件,事件物件包括一個keyCode 屬性,它包含一個數字。多數情況下,這些數字可以獨立於瀏覽器做為鍵的識別符號,找到鍵與數字的對應關係可以用下面的簡單實驗。
function printKeyCode(event) {
  event = event || window.event;
  print("Key ", event.keyCode, " was pressed.");
}

registerEventHandler($("textfield"), "keydown", printKeyCode);

unregisterEventHandler($("textfield"), "keydown", printKeyCode);

對於多數瀏覽器,一個簡單的程式碼對應鍵盤上的一個物理鍵。但在opera中,一些依賴於shift的鍵可能會產生不同的程式碼,更糟糕的是,一些鍵與shift同時按下時與其他的鍵程式碼相同——shift-9,這在鍵盤上是用來輸入括號,但它的程式碼與下方向鍵相同,這個很難區分。當你的程式中存在這種威脅時,通常的解決方法是忽略按下shift的事件。
可以用事件物件的shiftKey ,ctrlKey,altKey 屬性來判斷當鍵按下或者滑鼠點選的時候是否有shift,ctrl,alt鍵按下了。 對於keypress 事件,有時需要知道哪個鍵被按下了。事件物件包含一個charCode屬性,代表輸入字元的Unicode 值,這是一個字串,這個值也可以通過string的fromCharCode方法轉換得到。不幸的是,有些瀏覽器不支援這個屬性或者定義成0並把字元值儲存到keyCode 屬性。

function printCharacter(event) {
  event = event || window.event;
  var charCode = event.charCode;
  if (charCode == undefined || charCode === 0)
    charCode = event.keyCode;
  print("Character '", String.fromCharCode(charCode), "'");
}

registerEventHandler($("textfield"), "keypress", printCharacter);

unregisterEventHandler($("textfield"), "keypress", printCharacter);

事件處理程式可以結束一個事件,有兩個不同的方法。可以在父結點上定義處理函式來阻止從子結點上傳遞過來的事件,也可以阻止瀏覽器在執行標準的動作時產生事件。需要注意的是,瀏覽器並非總是按這個規則執行的——主要是預防保護一些熱鍵的預設行為。在許多瀏覽器上,實際上沒有正常執行這些鍵的效果。
在大多數瀏覽器上,阻止事件的傳播可以使用事件物件的stopPropagation 方法。避免預設行為使用preventDefault 方法。但是在IE中,實現這個功能分別是設定cancelBubble 為true和returnValue 為false。
這是本章中最後一個相容性判斷的程式碼塊,它的意思是,我們最後寫一個事件通用函式,並轉移到更有意思的事情上來。

function normaliseEvent(event) {
  if (!event.stopPropagation) {
    event.stopPropagation = function() {this.cancelBubble = true;};
    event.preventDefault = function() {this.returnValue = false;};
  }
  if (!event.stop) {
    event.stop = function() {
      this.stopPropagation();
      this.preventDefault();
    };
  }

  if (event.srcElement && !event.target)
    event.target = event.srcElement;
  if ((event.toElement || event.fromElement) && !event.relatedTarget)
    event.relatedTarget = event.toElement || event.fromElement;
  if (event.clientX != undefined && event.pageX == undefined) {
    event.pageX = event.clientX + document.body.scrollLeft;
    event.pageY = event.clientY + document.body.scrollTop;
  }
  if (event.type == "keypress") {
    if (event.charCode === 0 || event.charCode == undefined)
      event.character = String.fromCharCode(event.keyCode);
    else
      event.character = String.fromCharCode(event.charCode);
  }

  return event;
}

上述程式碼新增了一個stop方法,它取消了事件的傳遞以及預設行為,有些瀏覽器提供了,這種情況下可以先移除。
接下來,我們為registerEventHandler 和unregisterEventHandler寫一個更方便的封裝。

function addHandler(node, type, handler) {
  function wrapHandler(event) {
    handler(normaliseEvent(event || window.event));
  }
  registerEventHandler(node, type, wrapHandler);
  return {node: node, type: type, handler: wrapHandler};
}

function removeHandler(object) {
  unregisterEventHandler(object.node, object.type, object.handler);
}

var blockQ = addHandler($("textfield"), "keypress", function(event) {
  if (event.character.toLowerCase() == "q")
    event.stop();
});

新的addHandler 方法把給定的方法封裝成為一個處理函式,它可以儲存有正常化的事件物件,並有返回物件。在我們需要移除的時候,可以將返回物件傳入removeHandler 中。在文字框中輸入‘q’試試。

removeHandler(blockQ);

使用封裝的addHandler 和上節講到的dom函式,關於文件操作,我們能夠做一個更有挑戰的壯舉。作為練習,我們要做一個推箱子游戲,也許以前你沒有見過,這其實是一個經典遊戲。遊戲規則是:有一個由牆、空白區、一個或者多個出口的格子。在格子上,有一些箱子或者石頭和一個由玩家控制的小人。小人可以被在水平方向或者垂直方向推進空白區域中,推動周圍其它的巨石,讓後面的空白區域提供出來。遊戲目標就是移動指定數目的巨石到出口。
像第8章提到的玻璃容器一樣,遊戲級別可以用文字來表示,在example_events.html頁面中,有一個包含level 物件的sokobanLevels陣列,每個level 物件包括一個field屬性,它代表一個級別的文字描述。還有一個boulders屬性,它表示要推出去多少個巨石才能贏得遊戲。

show(sokobanLevels.length);
show(sokobanLevels[1].boulders);
forEach(sokobanLevels[1].field, print);

在這樣的級別下,用“#”代表牆、空格代表空白區域、0代表巨石、@代表開始位置、*代表出口。
但是,當我們玩遊戲的時候,並不想看到的是文字介面,所以可以往文件裡面放入一個表格。我寫了一個很小的樣式(sokoban.css,如果你好奇的話,它看起來就像這樣),這樣表格中的單元格就有一個固定的大小,把這個表格新增到文件中,給表格中所有的單元格都平鋪一個背景圖片,圖片表示該格子的功能(空、牆或者出口),為了顯示玩家和巨石的,在表格中新增圖片,圖片可以適當移動到不同的格子裡。

相關文章