處理事件

你能掌控自己的內(nèi)心,而非外在事件。認(rèn)識(shí)到這一點(diǎn),你便會(huì)找到力量。

——馬可·奧勒留,《沉思錄》

(插圖展示了一臺(tái)魯布·戈德堡機(jī)械,包含球體、蹺蹺板、剪刀和錘子,它們通過連鎖反應(yīng)相互作用,最終點(diǎn)亮燈泡。)

有些程序需要處理用戶的直接輸入,比如鼠標(biāo)和鍵盤操作。這類輸入無法提前以規(guī)整的數(shù)據(jù)結(jié)構(gòu)準(zhǔn)備好——它們是實(shí)時(shí)逐段產(chǎn)生的,程序必須在事件發(fā)生時(shí)做出響應(yīng)。

事件處理器

想象這樣一種界面:要知道鍵盤上某個(gè)鍵是否被按下,只能讀取該鍵的當(dāng)前狀態(tài)。為了能對按鍵做出反應(yīng),你必須不斷讀取鍵的狀態(tài),才能在它被釋放前捕捉到按下的動(dòng)作。這種情況下,執(zhí)行任何耗時(shí)的計(jì)算都很危險(xiǎn),因?yàn)榭赡軙?huì)錯(cuò)過按鍵事件。

一些原始設(shè)備就是這樣處理輸入的。更先進(jìn)的方式是讓硬件或操作系統(tǒng)監(jiān)測到按鍵事件,并將其放入隊(duì)列。程序可以定期檢查隊(duì)列中的新事件并做出響應(yīng)。

當(dāng)然,程序必須記得去查看隊(duì)列,而且要頻繁查看——因?yàn)閺陌存I按下到程序檢測到事件之間的任何延遲,都會(huì)讓軟件顯得反應(yīng)遲鈍。這種方式稱為“輪詢”,大多數(shù)程序員都傾向于避免使用。

更好的機(jī)制是讓系統(tǒng)在事件發(fā)生時(shí)主動(dòng)通知代碼。瀏覽器通過允許我們將函數(shù)注冊為特定事件的處理器來實(shí)現(xiàn)這一點(diǎn)。

<p>點(diǎn)擊文檔激活處理器。</p>
<script>
  window.addEventListener("click", () => {
    console.log("有人敲門?");
  });
</script>

window 是瀏覽器提供的一個(gè)內(nèi)置對象,代表包含文檔的瀏覽器窗口。調(diào)用它的 addEventListener 方法,會(huì)注冊第二個(gè)參數(shù)(函數(shù)),使其在第一個(gè)參數(shù)描述的事件發(fā)生時(shí)被調(diào)用。

事件與 DOM 節(jié)點(diǎn)

每個(gè)瀏覽器事件處理器都注冊在特定的上下文中。在前面的例子中,我們在 window 對象上調(diào)用 addEventListener,為整個(gè)窗口注冊了處理器。DOM 元素和其他一些類型的對象也有這個(gè)方法。事件監(jiān)聽器只會(huì)在其注冊的對象所關(guān)聯(lián)的上下文中發(fā)生事件時(shí)被調(diào)用。

<button>點(diǎn)擊我</button>
<p>這里沒有處理器。</p>
<script>
  let button = document.querySelector("button");
  button.addEventListener("click", () => {
    console.log("按鈕被點(diǎn)擊了。");
  });
</script>

這個(gè)例子將處理器附加到了按鈕節(jié)點(diǎn)上。點(diǎn)擊按鈕會(huì)觸發(fā)該處理器,但點(diǎn)擊文檔的其他部分則不會(huì)。

給節(jié)點(diǎn)設(shè)置 onclick 屬性也能達(dá)到類似效果。對于大多數(shù)類型的事件,都可以通過“on + 事件名”的屬性來附加處理器。

但一個(gè)節(jié)點(diǎn)只能有一個(gè) onclick 屬性,因此這種方式每個(gè)節(jié)點(diǎn)只能注冊一個(gè)處理器。而 addEventListener 方法允許添加任意數(shù)量的處理器——即使元素上已經(jīng)有其他處理器,再添加新的也沒問題。

removeEventListener 方法的參數(shù)與 addEventListener 類似,用于移除已注冊的處理器。

<button>一次性按鈕</button>
<script>
  let button = document.querySelector("button");
  function once() {
    console.log("完成。");
    button.removeEventListener("click", once);
  }
  button.addEventListener("click", once);
</script>

傳給 removeEventListener 的函數(shù)必須和傳給 addEventListener 的是同一個(gè)函數(shù)。當(dāng)需要注銷處理器時(shí),最好給處理器函數(shù)命名(如例子中的 once),以便能將同一個(gè)函數(shù)傳給這兩個(gè)方法。

事件對象

雖然我們之前忽略了,但事件處理器函數(shù)會(huì)接收一個(gè)參數(shù):事件對象。這個(gè)對象包含了關(guān)于事件的額外信息。例如,如果想知道按下的是鼠標(biāo)哪個(gè)按鈕,可以查看事件對象的 button 屬性。

<button>用任何方式點(diǎn)擊我</button>
<script>
  let button = document.querySelector("button");
  button.addEventListener("mousedown", event => {
    if (event.button == 0) {
      console.log("左鍵");
    } else if (event.button == 1) {
      console.log("中鍵");
    } else if (event.button == 2) {
      console.log("右鍵");
    }
  });
</script>

事件對象中存儲(chǔ)的信息因事件類型而異(本章后面會(huì)討論不同類型的事件)。對象的 type 屬性始終包含一個(gè)標(biāo)識(shí)事件的字符串(如 "click" 或 "mousedown")。

事件冒泡

對于大多數(shù)事件類型,注冊在包含子節(jié)點(diǎn)的父節(jié)點(diǎn)上的處理器,也會(huì)接收到發(fā)生在子節(jié)點(diǎn)上的事件。如果段落中有一個(gè)按鈕被點(diǎn)擊,段落上的事件處理器也會(huì)收到點(diǎn)擊事件。

但如果段落和按鈕都有處理器,更具體的處理器——即按鈕上的那個(gè)——會(huì)先執(zhí)行。事件會(huì)從發(fā)生的節(jié)點(diǎn)“冒泡”到其父節(jié)點(diǎn),再到文檔的根節(jié)點(diǎn)。最后,在特定節(jié)點(diǎn)上的所有處理器都執(zhí)行完畢后,注冊在整個(gè)窗口上的處理器才有機(jī)會(huì)響應(yīng)事件。

在任何時(shí)候,事件處理器都可以調(diào)用事件對象的 stopPropagation 方法,阻止事件繼續(xù)向上傳播。例如,當(dāng)按鈕位于另一個(gè)可點(diǎn)擊元素內(nèi)部時(shí),若不希望按鈕的點(diǎn)擊觸發(fā)外部元素的點(diǎn)擊行為,這就很有用。

下面的例子在按鈕和包含它的段落上都注冊了 "mousedown" 處理器。當(dāng)右鍵點(diǎn)擊按鈕時(shí),按鈕的處理器會(huì)調(diào)用 stopPropagation,阻止段落的處理器執(zhí)行。當(dāng)用其他鼠標(biāo)按鈕點(diǎn)擊時(shí),兩個(gè)處理器都會(huì)運(yùn)行。

<p>包含 <button>按鈕</button> 的段落。</p>
<script>
  let para = document.querySelector("p");
  let button = document.querySelector("button");
  para.addEventListener("mousedown", () => {
    console.log("段落的處理器。");
  });
  button.addEventListener("mousedown", event => {
    console.log("按鈕的處理器。");
    if (event.button == 2) event.stopPropagation();
  });
</script>

大多數(shù)事件對象都有一個(gè) target 屬性,指向事件發(fā)生的原始節(jié)點(diǎn)。可以用這個(gè)屬性來確保不會(huì)意外處理從不想處理的節(jié)點(diǎn)冒泡上來的事件。

也可以利用 target 屬性來廣泛捕捉特定類型的事件。例如,如果有一個(gè)包含大量按鈕的節(jié)點(diǎn),在外部節(jié)點(diǎn)上注冊一個(gè)點(diǎn)擊處理器,通過 target 屬性判斷是否點(diǎn)擊了按鈕,可能比在所有按鈕上分別注冊處理器更方便。

<button>A</button>
<button>B</button>
<button>C</button>
<script>
  document.body.addEventListener("click", event => {
    if (event.target.nodeName == "BUTTON") {
      console.log("點(diǎn)擊了", event.target.textContent);
    }
  });
</script>

默認(rèn)行為

許多事件都有默認(rèn)行為。點(diǎn)擊鏈接會(huì)跳轉(zhuǎn)到鏈接的目標(biāo)地址;按下向下箭頭,瀏覽器會(huì)向下滾動(dòng)頁面;右鍵點(diǎn)擊會(huì)顯示上下文菜單,等等。

對于大多數(shù)類型的事件,JavaScript 事件處理器會(huì)在默認(rèn)行為發(fā)生前被調(diào)用。如果處理器不希望執(zhí)行默認(rèn)行為(通常是因?yàn)樗呀?jīng)處理了事件),可以調(diào)用事件對象的 preventDefault 方法。

這可以用來實(shí)現(xiàn)自定義鍵盤快捷鍵或上下文菜單。但也可能被用來惡意干擾用戶預(yù)期的行為。例如,下面是一個(gè)無法跳轉(zhuǎn)的鏈接:

<a >MDN</a>
<script>
  let link = document.querySelector("a");
  link.addEventListener("click", event => {
    console.log("不行哦。");
    event.preventDefault();
  });
</script>

除非有非常充分的理由,否則不要這樣做。破壞預(yù)期的行為會(huì)讓使用頁面的用戶感到不適。

不同瀏覽器對某些事件的攔截能力不同。例如,在 Chrome 中,關(guān)閉當(dāng)前標(biāo)簽頁的鍵盤快捷鍵(ctrl-W 或 command-W)無法被 JavaScript 處理。

鍵盤事件

按下鍵盤上的鍵時(shí),瀏覽器會(huì)觸發(fā) "keydown" 事件;釋放鍵時(shí),會(huì)觸發(fā) "keyup" 事件。

<p>按住 V 鍵時(shí),頁面會(huì)變成紫色。</p>
<script>
  window.addEventListener("keydown", event => {
    if (event.key == "v") {
      document.body.style.background = "violet";
    }
  });
  window.addEventListener("keyup", event => {
    if (event.key == "v") {
      document.body.style.background = "";
    }
  });
</script>

盡管名字是 "keydown",但它不僅在鍵被物理按下時(shí)觸發(fā)。當(dāng)按鍵被按住時(shí),事件會(huì)在每次按鍵重復(fù)時(shí)再次觸發(fā)。有時(shí)需要注意這一點(diǎn)。例如,如果在按鍵按下時(shí)添加一個(gè)按鈕,釋放時(shí)移除,那么按住鍵不放可能會(huì)意外添加成百上千個(gè)按鈕。

上面的例子通過事件對象的 key 屬性來判斷事件對應(yīng)的鍵。這個(gè)屬性的值是一個(gè)字符串,對于大多數(shù)鍵,對應(yīng)按下該鍵會(huì)輸入的字符。對于特殊鍵(如回車),它的值是鍵的名稱(這里是 "Enter")。如果按住 shift 鍵的同時(shí)按下某個(gè)鍵,可能會(huì)影響鍵的名稱——"v" 會(huì)變成 "V","1" 可能會(huì)變成 "!"(如果在你的鍵盤上,shift+1 會(huì)輸入 "!" 的話)。

shift、ctrl、alt 和 meta(Mac 上的 command 鍵)等修飾鍵,和普通鍵一樣會(huì)產(chǎn)生鍵盤事件。在處理組合鍵時(shí),可以通過鍵盤和鼠標(biāo)事件的 shiftKey、ctrlKey、altKeymetaKey 屬性,判斷這些鍵是否被按住。

<p>按 Control+空格 繼續(xù)。</p>
<script>
  window.addEventListener("keydown", event => {
    if (event.key == " " && event.ctrlKey) {
      console.log("繼續(xù)!");
    }
  });
</script>

鍵盤事件的起源節(jié)點(diǎn)取決于按鍵按下時(shí)獲得焦點(diǎn)的元素。大多數(shù)節(jié)點(diǎn)只有設(shè)置了 tabindex 屬性才能獲得焦點(diǎn),但鏈接、按鈕和表單字段等可以。第 18 章會(huì)討論表單字段。當(dāng)沒有特定元素獲得焦點(diǎn)時(shí),document.body 會(huì)作為鍵盤事件的目標(biāo)節(jié)點(diǎn)。

當(dāng)用戶輸入文本時(shí),用鍵盤事件來判斷輸入的內(nèi)容是有問題的。有些平臺(tái),尤其是安卓手機(jī)上的虛擬鍵盤,不會(huì)觸發(fā)鍵盤事件。即使使用傳統(tǒng)鍵盤,某些類型的文本輸入也不會(huì)直接對應(yīng)按鍵,例如輸入法編輯器(IME)軟件——供那些文字無法直接通過鍵盤輸入的用戶使用,多個(gè)按鍵會(huì)組合成一個(gè)字符。

要監(jiān)測用戶輸入的內(nèi)容,<input><textarea> 等可輸入元素會(huì)在用戶修改內(nèi)容時(shí)觸發(fā) "input" 事件。要獲取實(shí)際輸入的內(nèi)容,最好直接從獲得焦點(diǎn)的字段中讀取,這在第 18 章會(huì)討論。

指針事件

目前有兩種廣泛使用的屏幕指向方式:鼠標(biāo)(包括觸摸板、軌跡球等類似設(shè)備)和觸摸屏。它們會(huì)產(chǎn)生不同類型的事件。

鼠標(biāo)點(diǎn)擊

按下鼠標(biāo)按鈕會(huì)觸發(fā)一系列事件。"mousedown" 和 "mouseup" 事件類似于 "keydown" 和 "keyup",在按鈕按下和釋放時(shí)觸發(fā)。這些事件發(fā)生在鼠標(biāo)指針正下方的 DOM 節(jié)點(diǎn)上。

"mouseup" 事件之后,會(huì)在同時(shí)包含按下和釋放按鈕動(dòng)作的最具體節(jié)點(diǎn)上觸發(fā) "click" 事件。例如,如果在一個(gè)段落上按下鼠標(biāo)按鈕,然后將指針移到另一個(gè)段落上釋放,"click" 事件會(huì)發(fā)生在包含這兩個(gè)段落的元素上。

如果兩次點(diǎn)擊間隔很近,會(huì)在第二次 "click" 事件之后觸發(fā) "dblclick"(雙擊)事件。

要獲取鼠標(biāo)事件發(fā)生的精確位置,可以查看事件的 clientXclientY 屬性,它們包含事件相對于窗口左上角的坐標(biāo)(以像素為單位);或者 pageXpageY,它們相對于整個(gè)文檔的左上角(當(dāng)窗口滾動(dòng)時(shí),這可能與前者不同)。

下面的程序?qū)崿F(xiàn)了一個(gè)簡單的繪圖應(yīng)用。每次點(diǎn)擊文檔,都會(huì)在鼠標(biāo)指針下方添加一個(gè)點(diǎn)。

<style>
  body {
    height: 200px;
    background: beige;
  }
  .dot {
    height: 8px; width: 8px;
    border-radius: 4px; /* 圓角 */
    background: teal;
    position: absolute;
  }
</style>
<script>
  window.addEventListener("click", event => {
    let dot = document.createElement("div");
    dot.className = "dot";
    dot.style.left = (event.pageX - 4) + "px";
    dot.style.top = (event.pageY - 4) + "px";
    document.body.appendChild(dot);
  });
</script>

第 19 章會(huì)創(chuàng)建一個(gè)更完善的繪圖應(yīng)用。

鼠標(biāo)移動(dòng)

每次鼠標(biāo)指針移動(dòng)時(shí),都會(huì)觸發(fā) "mousemove" 事件。這個(gè)事件可用于跟蹤鼠標(biāo)位置,在實(shí)現(xiàn)鼠標(biāo)拖動(dòng)功能時(shí)特別有用。

例如,下面的程序顯示一個(gè)條,并設(shè)置了事件處理器,使得在條上左右拖動(dòng)可以改變其寬度:

<p>拖動(dòng)條改變其寬度:</p>
<div style="background: orange; width: 60px; height: 20px">
</div>
<script>
  let lastX; // 跟蹤上一次觀察到的鼠標(biāo) X 坐標(biāo)
  let bar = document.querySelector("div");
  bar.addEventListener("mousedown", event => {
    if (event.button == 0) {
      lastX = event.clientX;
      window.addEventListener("mousemove", moved);
      event.preventDefault(); // 阻止選中
    }
  });

  function moved(event) {
    if (event.buttons == 0) {
      window.removeEventListener("mousemove", moved);
    } else {
      let dist = event.clientX - lastX;
      let newWidth = Math.max(10, bar.offsetWidth + dist);
      bar.style.width = newWidth + "px";
      lastX = event.clientX;
    }
  }
</script>

注意,"mousemove" 處理器注冊在整個(gè)窗口上。即使調(diào)整大小時(shí)鼠標(biāo)移出了條,只要按鈕還按著,我們?nèi)匀幌M滤拇笮 ?/p>

當(dāng)鼠標(biāo)按鈕釋放時(shí),必須停止調(diào)整條的大小。可以通過 buttons 屬性(注意復(fù)數(shù)形式)來判斷當(dāng)前按下的按鈕——當(dāng)它為 0 時(shí),沒有按鈕按下。當(dāng)有按鈕按下時(shí),buttons 屬性的值是這些按鈕代碼的總和——左鍵是 1,右鍵是 2,中鍵是 4。例如,同時(shí)按住左鍵和右鍵時(shí),buttons 的值是 3。

注意,這些代碼的順序與 button 屬性不同,在 button 中,中鍵在右鍵之前。如前所述,瀏覽器的編程接口并不總是保持一致性。

觸摸事件

我們使用的圖形瀏覽器最初是為鼠標(biāo)界面設(shè)計(jì)的,當(dāng)時(shí)觸摸屏還很少見。為了讓網(wǎng)頁在早期觸摸屏手機(jī)上“能用”,這些設(shè)備的瀏覽器在一定程度上會(huì)把觸摸事件模擬成鼠標(biāo)事件。點(diǎn)擊屏幕會(huì)觸發(fā) "mousedown"、"mouseup" 和 "click" 事件。

但這種模擬并不完善。觸摸屏的工作方式與鼠標(biāo)不同:它沒有多個(gè)按鈕,無法在手指離開屏幕時(shí)跟蹤位置(以模擬 "mousemove"),而且允許多個(gè)手指同時(shí)觸摸屏幕。

鼠標(biāo)事件只能應(yīng)對簡單的觸摸交互——如果給按鈕添加 "click" 處理器,觸摸用戶仍然可以使用它。但像前面例子中可調(diào)整大小的條,在觸摸屏上就無法工作。

觸摸交互會(huì)觸發(fā)特定的事件類型。當(dāng)手指開始觸摸屏幕時(shí),會(huì)觸發(fā) "touchstart" 事件;觸摸時(shí)移動(dòng)手指,會(huì)觸發(fā) "touchmove" 事件;最后,當(dāng)手指離開屏幕時(shí),會(huì)觸發(fā) "touchend" 事件。

由于許多觸摸屏可以同時(shí)檢測多個(gè)手指,這些事件不會(huì)只關(guān)聯(lián)一組坐標(biāo)。相反,它們的事件對象有一個(gè) touches 屬性,包含類數(shù)組的點(diǎn)對象,每個(gè)點(diǎn)都有自己的 clientX、clientYpageXpageY 屬性。

可以這樣做:在每個(gè)觸摸點(diǎn)周圍顯示紅色圓圈:

<style>
  dot { position: absolute; display: block;
        border: 2px solid red; border-radius: 50px;
        height: 100px; width: 100px; }
</style>
<p>觸摸此頁面</p>
<script>
  function update(event) {
    // 移除現(xiàn)有所有 dot 元素
    for (let dot; dot = document.querySelector("dot");) {
      dot.remove();
    }
    // 為每個(gè)觸摸點(diǎn)創(chuàng)建新 dot
    for (let i = 0; i < event.touches.length; i++) {
      let {pageX, pageY} = event.touches[i];
      let dot = document.createElement("dot");
      dot.style.left = (pageX - 50) + "px";
      dot.style.top = (pageY - 50) + "px";
      document.body.appendChild(dot);
    }
  }
  window.addEventListener("touchstart", update);
  window.addEventListener("touchmove", update);
  window.addEventListener("touchend", update);
</script>

在觸摸事件處理器中,通常需要調(diào)用 preventDefault 來覆蓋瀏覽器的默認(rèn)行為(可能包括滑動(dòng)頁面),并防止觸發(fā)可能已注冊的鼠標(biāo)事件。

滾動(dòng)事件

元素被滾動(dòng)時(shí),會(huì)在該元素上觸發(fā) "scroll" 事件。這有多種用途,例如知道用戶當(dāng)前正在查看的內(nèi)容(用于禁用屏幕外動(dòng)畫,或向“邪惡總部”發(fā)送監(jiān)控報(bào)告),或顯示進(jìn)度指示(如高亮目錄的一部分或顯示頁碼)
下面的例子在文檔上方繪制了一個(gè)進(jìn)度條,并在向下滾動(dòng)時(shí)更新其填充程度:

<style>
  #progress {
    border-bottom: 2px solid blue;
    width: 0;
    position: fixed;
    top: 0; left: 0;
  }
</style>
<div id="progress"></div>
<script>
  // 創(chuàng)建一些內(nèi)容
  document.body.appendChild(document.createTextNode(
    "supercalifragilisticexpialidocious ".repeat(1000)));

  let bar = document.querySelector("#progress");
  window.addEventListener("scroll", () => {
    let max = document.body.scrollHeight - innerHeight;
    bar.style.width = `${(pageYOffset / max) * 100}%`;
  });
</script>

給元素設(shè)置 position: fixed(固定定位)的效果類似于絕對定位,但它會(huì)阻止元素隨文檔其余部分一起滾動(dòng)。這樣我們的進(jìn)度條就會(huì)保持在頂部。通過改變其寬度來指示當(dāng)前進(jìn)度。設(shè)置寬度時(shí)使用 % 而不是 px 作為單位,使元素大小相對于頁面寬度。

全局的 innerHeight 變量提供窗口的高度,我們必須從總可滾動(dòng)高度中減去這個(gè)值——當(dāng)滾動(dòng)到文檔底部時(shí),就無法再繼續(xù)滾動(dòng)了。還有 innerWidth 表示窗口寬度。用當(dāng)前滾動(dòng)位置 pageYOffset 除以最大滾動(dòng)位置,再乘以 100,就得到了進(jìn)度條的百分比。

在滾動(dòng)事件上調(diào)用 preventDefault 無法阻止?jié)L動(dòng)發(fā)生。實(shí)際上,事件處理器只有在滾動(dòng)發(fā)生后才會(huì)被調(diào)用。

焦點(diǎn)事件

當(dāng)元素獲得焦點(diǎn)時(shí),瀏覽器會(huì)在該元素上觸發(fā) "focus" 事件;當(dāng)元素失去焦點(diǎn)時(shí),會(huì)觸發(fā) "blur" 事件。

與前面討論的事件不同,這兩個(gè)事件不會(huì)冒泡。子元素獲得或失去焦點(diǎn)時(shí),父元素上的處理器不會(huì)收到通知。

下面的例子會(huì)為當(dāng)前獲得焦點(diǎn)的文本字段顯示幫助文本:

<p>姓名:<input type="text" data-help="您的全名"></p>
<p>年齡:<input type="text" data-help="您的年齡(歲)"></p>
<p id="help"></p>

<script>
  let help = document.querySelector("#help");
  let fields = document.querySelectorAll("input");
  for (let field of Array.from(fields)) {
    field.addEventListener("focus", event => {
      let text = event.target.getAttribute("data-help");
      help.textContent = text;
    });
    field.addEventListener("blur", event => {
      help.textContent = "";
    });
  }
</script>

當(dāng)用戶切換到或離開顯示該文檔的瀏覽器標(biāo)簽頁或窗口時(shí),window 對象會(huì)收到 "focus" 和 "blur" 事件。

加載事件

頁面加載完成后,windowdocument.body 對象會(huì)觸發(fā) "load" 事件。這通常用于安排需要整個(gè)文檔構(gòu)建完成后才能執(zhí)行的初始化操作。記住,<script> 標(biāo)簽的內(nèi)容在遇到該標(biāo)簽時(shí)會(huì)立即運(yùn)行。這可能太早了,例如當(dāng)腳本需要操作出現(xiàn)在 <script> 標(biāo)簽之后的文檔部分時(shí)。

像圖片和加載外部文件的腳本標(biāo)簽這類元素,也有 "load" 事件,用于指示它們引用的文件已加載完成。與焦點(diǎn)相關(guān)事件一樣,加載事件不會(huì)冒泡。

當(dāng)關(guān)閉頁面或?qū)Ш诫x開(例如點(diǎn)擊鏈接)時(shí),會(huì)觸發(fā) "beforeunload" 事件。這個(gè)事件的主要用途是防止用戶意外關(guān)閉文檔而丟失工作內(nèi)容。如果在該事件上阻止默認(rèn)行為,并將事件對象的 returnValue 屬性設(shè)置為一個(gè)字符串,瀏覽器會(huì)顯示一個(gè)對話框,詢問用戶是否真的要離開頁面。對話框可能會(huì)包含你設(shè)置的字符串,但由于一些惡意網(wǎng)站試圖利用這些對話框誤導(dǎo)用戶停留在頁面上查看可疑的減肥廣告,大多數(shù)瀏覽器已不再顯示這些自定義文本。

事件與事件循環(huán)

正如第 11 章所討論的,在事件循環(huán)的語境中,瀏覽器事件處理器的行為類似于其他異步通知。它們在事件發(fā)生時(shí)被調(diào)度,但必須等待其他正在運(yùn)行的腳本完成后才有機(jī)會(huì)執(zhí)行。

事件只能在沒有其他腳本運(yùn)行時(shí)才能被處理,這意味著如果事件循環(huán)被其他工作占用,任何與頁面的交互(通過事件發(fā)生)都會(huì)被延遲,直到有時(shí)間處理它們。因此,如果安排了太多工作(無論是長時(shí)間運(yùn)行的事件處理器,還是大量短時(shí)間運(yùn)行的處理器),頁面會(huì)變得緩慢且難以使用。

對于確實(shí)需要在后臺(tái)執(zhí)行耗時(shí)操作而又不凍結(jié)頁面的情況,瀏覽器提供了一種稱為 Web Worker 的機(jī)制。Worker 是一個(gè)與主腳本并行運(yùn)行的 JavaScript 進(jìn)程,擁有自己的時(shí)間線。

假設(shè)計(jì)算一個(gè)數(shù)的平方是一項(xiàng)繁重、耗時(shí)的計(jì)算,我們希望在單獨(dú)的線程中執(zhí)行。可以編寫一個(gè)名為 code/squareworker.js 的文件,它通過計(jì)算平方并發(fā)送消息來響應(yīng)消息:

addEventListener("message", event => {
  postMessage(event.data * event.data);
});

為了避免多個(gè)線程操作相同數(shù)據(jù)帶來的問題,Worker 不會(huì)與主腳本環(huán)境共享其全局作用域或任何其他數(shù)據(jù)。相反,必須通過來回發(fā)送消息進(jìn)行通信。

下面的代碼會(huì)創(chuàng)建一個(gè)運(yùn)行該腳本的 Worker,向它發(fā)送一些消息,并輸出響應(yīng):

let squareWorker = new Worker("code/squareworker.js");
squareWorker.addEventListener("message", event => {
  console.log("Worker 響應(yīng):", event.data);
});
squareWorker.postMessage(10);
squareWorker.postMessage(24);

postMessage 函數(shù)發(fā)送消息,這會(huì)導(dǎo)致接收方觸發(fā) "message" 事件。創(chuàng)建 Worker 的腳本通過 Worker 對象發(fā)送和接收消息,而 Worker 則通過在其全局作用域上直接發(fā)送和監(jiān)聽來與創(chuàng)建它的腳本通信。只有能表示為 JSON 的值才能作為消息發(fā)送——接收方會(huì)收到它們的副本,而不是值本身。

定時(shí)器

第 11 章中介紹的 setTimeout 函數(shù)用于安排另一個(gè)函數(shù)在指定毫秒數(shù)后被調(diào)用。有時(shí)需要取消已安排的函數(shù),可以通過保存 setTimeout 返回的值并在其上調(diào)用 clearTimeout 來實(shí)現(xiàn)。

let bombTimer = setTimeout(() => {
  console.log("BOOM!");
}, 500);

if (Math.random() < 0.5) { // 50% 的概率
  console.log("已拆除。");
  clearTimeout(bombTimer);
}

cancelAnimationFrame 函數(shù)的工作方式與 clearTimeout 類似。在 requestAnimationFrame 返回的值上調(diào)用它會(huì)取消該幀(假設(shè)尚未調(diào)用)。

另一組類似的函數(shù) setIntervalclearInterval 用于設(shè)置每隔 X 毫秒重復(fù)執(zhí)行的定時(shí)器。

let ticks = 0;
let clock = setInterval(() => {
  console.log("滴答", ticks++);
  if (ticks == 10) {
    clearInterval(clock);
    console.log("停止。");
  }
}, 200);

防抖

有些類型的事件可能會(huì)連續(xù)快速觸發(fā)多次,例如 "mousemove" 和 "scroll" 事件。處理這類事件時(shí),必須注意不要執(zhí)行太耗時(shí)的操作,否則處理器會(huì)占用太多時(shí)間,導(dǎo)致與文檔的交互變得緩慢。

如果確實(shí)需要在這類處理器中執(zhí)行一些重要操作,可以使用 setTimeout 來確保不會(huì)執(zhí)行得太頻繁。這通常稱為事件防抖,有幾種略有不同的實(shí)現(xiàn)方式。

例如,假設(shè)我們想在用戶輸入內(nèi)容時(shí)做出反應(yīng),但不希望對每個(gè)輸入事件都立即響應(yīng)。當(dāng)用戶快速輸入時(shí),我們只想等待輸入暫停后再處理。這時(shí)不要在事件處理器中立即執(zhí)行操作,而是設(shè)置一個(gè)定時(shí)器。同時(shí)清除之前的定時(shí)器(如果有),這樣當(dāng)事件密集發(fā)生時(shí)(間隔小于定時(shí)器延遲),前一個(gè)事件的定時(shí)器會(huì)被取消。

<textarea>在這里輸入內(nèi)容...</textarea>
<script>
  let textarea = document.querySelector("textarea");
  let timeout;
  textarea.addEventListener("input", () => {
    clearTimeout(timeout);
    timeout = setTimeout(() => console.log("已輸入!"), 500);
  });
</script>

clearTimeout 傳遞 undefined 值,或?qū)σ延|發(fā)的定時(shí)器調(diào)用它,都不會(huì)產(chǎn)生任何效果。因此,不必?fù)?dān)心調(diào)用時(shí)機(jī),可以簡單地對每個(gè)事件都調(diào)用它。

如果希望響應(yīng)間隔至少保持一定時(shí)間,且在一系列事件發(fā)生期間也要觸發(fā)(而不僅僅是在事件之后),可以使用稍微不同的模式。例如,我們可能希望響應(yīng) "mousemove" 事件以顯示鼠標(biāo)的當(dāng)前坐標(biāo),但每 250 毫秒才更新一次。

<script>
  let scheduled = null;
  window.addEventListener("mousemove", event => {
    if (!scheduled) {
      setTimeout(() => {
        document.body.textContent =
          `鼠標(biāo)位置:${scheduled.pageX}, ${scheduled.pageY}`;
        scheduled = null;
      }, 250);
    }
    scheduled = event;
  });
</script>

總結(jié)

事件處理器使我們能夠檢測并響應(yīng)網(wǎng)頁中發(fā)生的事件。addEventListener 方法用于注冊這類處理器。

每個(gè)事件都有一個(gè)類型(如 "keydown"、"focus" 等)來標(biāo)識(shí)它。大多數(shù)事件在特定的 DOM 元素上觸發(fā),然后冒泡到該元素的祖先,允許與這些元素關(guān)聯(lián)的處理器處理它們。

調(diào)用事件處理器時(shí),會(huì)向其傳遞一個(gè)事件對象,包含關(guān)于事件的額外信息。該對象還有一些方法,允許我們停止進(jìn)一步的傳播(stopPropagation)和阻止瀏覽器對事件的默認(rèn)處理(preventDefault)。

按下鍵會(huì)觸發(fā) "keydown" 和 "keyup" 事件;按下鼠標(biāo)按鈕會(huì)觸發(fā) "mousedown"、"mouseup" 和 "click" 事件;移動(dòng)鼠標(biāo)會(huì)觸發(fā) "mousemove" 事件;觸摸屏交互會(huì)產(chǎn)生 "touchstart"、"touchmove" 和 "touchend" 事件。

滾動(dòng)可以通過 "scroll" 事件檢測,焦點(diǎn)變化可以通過 "focus" 和 "blur" 事件檢測。文檔加載完成后,window 會(huì)觸發(fā) "load" 事件。

練習(xí)

氣球

編寫一個(gè)頁面,顯示一個(gè)氣球(使用氣球表情 ??)。按下上箭頭時(shí),氣球應(yīng)膨脹(變大)10%;按下下箭頭時(shí),應(yīng)收縮(變?。?0%。

可以通過設(shè)置父元素的 font-size CSS 屬性(style.fontSize)來控制文本(表情也是文本)的大小。記住在值中包含單位,例如像素(10px)。

箭頭鍵的鍵名是 "ArrowUp" 和 "ArrowDown"。確保按鍵只改變氣球,而不會(huì)導(dǎo)致頁面滾動(dòng)。

實(shí)現(xiàn)后,再添加一個(gè)功能:如果氣球膨脹超過一定大小,就會(huì)“爆炸”。這里的爆炸是指氣球被替換為 ?? 表情,并且移除事件處理器(這樣就不能再對爆炸效果進(jìn)行膨脹或收縮操作了)。

<p>??</p>

<script>
  // 你的代碼在這里
</script>

(顯示提示...)

鼠標(biāo)軌跡

在 JavaScript 的早期,那時(shí)花哨的主頁上滿是動(dòng)畫圖片,人們想出了一些非常有創(chuàng)意的用法。其中之一就是鼠標(biāo)軌跡——一系列元素會(huì)隨著鼠標(biāo)指針在頁面上移動(dòng)而跟隨。

在這個(gè)練習(xí)中,需要實(shí)現(xiàn)一個(gè)鼠標(biāo)軌跡。使用絕對定位的 <div> 元素,設(shè)置固定大小和背景顏色(參考“鼠標(biāo)點(diǎn)擊”部分的代碼示例)。創(chuàng)建多個(gè)這樣的元素,當(dāng)鼠標(biāo)移動(dòng)時(shí),在鼠標(biāo)指針經(jīng)過的路徑上顯示它們。

有多種實(shí)現(xiàn)方法,可以根據(jù)需要使軌跡簡單或復(fù)雜。一個(gè)簡單的解決方案是:保留固定數(shù)量的軌跡元素,循環(huán)使用它們,每次 "mousemove" 事件發(fā)生時(shí),將下一個(gè)元素移動(dòng)到鼠標(biāo)當(dāng)前位置。

<style>
  .trail { /* 軌跡元素的類名 */
    position: absolute;
    height: 6px; width: 6px;
    border-radius: 3px;
    background: teal;
  }
  body {
    height: 300px;
  }
</style>

<script>
  // 你的代碼在這里
</script>

(顯示提示...)

標(biāo)簽頁

標(biāo)簽式面板在用戶界面中很常見。它們允許通過從“突出”在元素上方的多個(gè)標(biāo)簽中選擇,來切換顯示的界面面板。

實(shí)現(xiàn)一個(gè)簡單的標(biāo)簽式界面。編寫一個(gè)函數(shù) asTabs,它接收一個(gè) DOM 節(jié)點(diǎn),并創(chuàng)建一個(gè)標(biāo)簽式界面來顯示該節(jié)點(diǎn)的子元素。函數(shù)應(yīng)在節(jié)點(diǎn)頂部插入一組 <button> 元素,每個(gè)子元素對應(yīng)一個(gè)按鈕,按鈕文本從子元素的 data-tabname 屬性中獲取。除一個(gè)子元素外,所有原始子元素都應(yīng)隱藏(設(shè)置 display: none 樣式)。點(diǎn)擊按鈕可以選擇當(dāng)前顯示的節(jié)點(diǎn)。

實(shí)現(xiàn)后,進(jìn)一步擴(kuò)展功能:為當(dāng)前選中標(biāo)簽的按鈕設(shè)置不同樣式,以便清楚地顯示哪個(gè)標(biāo)簽是選中狀態(tài)。

<tab-panel>
  <div data-tabname="one">標(biāo)簽一</div>
  <div data-tabname="two">標(biāo)簽二</div>
  <div data-tabname="three">標(biāo)簽三</div>
</tab-panel>
<script>
  function asTabs(node) {
    // 你的代碼在這里
  }
  asTabs(document.querySelector("tab-panel"));
</script>

(顯示提示...)

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 十五、處理事件 原文:Handling Events譯者:飛龍協(xié)議:CC BY-NC-SA 4.0自豪地采用谷歌翻...
    布客飛龍閱讀 567評論 0 7
  • 事件函數(shù)列表blur() 元素失去焦點(diǎn)focus() 元素獲得焦點(diǎn)change() 表單元素的值發(fā)生變化click...
    風(fēng)中丶凌亂閱讀 306評論 0 0
  • 要做出人們想要的東西,您必須傾聽客戶的意見。顧客可能并不總是對的,但在路易斯的面包店,每位員工都知道他們必須始終傾...
    啟辰閱讀 244評論 0 0
  • 用 React 元素處理事件和在DOM元素里處理事件非常相似。有一些語法差異: React 事件使用駝峰式命名,而...
    天天luck閱讀 981評論 0 0
  • 使用React元素處理事件和在DOM元素上處理事件非常相似。有一些語法差異: React事件使用駝峰式命名,而非小...
    莫銘閱讀 270評論 0 0

友情鏈接更多精彩內(nèi)容