第四版现已推出。点击这里阅读

第 15 章处理事件

你拥有支配你思想的力量——而不是外部事件。认识到这一点,你就会找到力量。

马可·奥勒留,沉思录
Picture a Rube Goldberg machine

一些程序使用直接的用户输入,例如鼠标和键盘操作。这种类型的输入无法以良好的数据结构形式提供——它以逐片段的方式实时传入,并且程序需要对其进行即时响应。

事件处理程序

想象一个界面,你只能通过读取键盘上某个键的当前状态来判断它是否被按下。为了能够对按键做出反应,你必须不断读取键的状态,以便在它被释放之前捕获它。执行其他时间密集型计算将非常危险,因为你可能会错过按键。

一些原始机器确实以这种方式处理输入。从这一点出发,硬件或操作系统将注意到按键并将其放入队列中。程序然后可以定期检查队列中是否有新事件,并对发现的事件做出反应。

当然,它必须记住查看队列,并且要经常查看,因为在按键按下和程序注意到事件之间的任何时间都将导致软件感觉无响应。这种方法称为轮询。大多数程序员更愿意避免它。

更好的机制是让系统在事件发生时主动通知我们的代码。浏览器通过允许我们注册函数作为特定事件的处理程序来实现这一点。

<p>Click this document to activate the handler.</p>
<script>
  window.addEventListener("click", () => {
    console.log("You knocked?");
  });
</script>

window 绑定指的是浏览器提供的内置对象。它表示包含文档的浏览器窗口。调用其 addEventListener 方法会注册第二个参数,以便在第一个参数描述的事件发生时被调用。

事件和 DOM 节点

每个浏览器事件处理程序都在一个上下文中注册。在前面的示例中,我们调用了 window 对象上的 addEventListener 来为整个窗口注册一个处理程序。这种方法也可以在 DOM 元素和其他一些类型的对象上找到。事件监听器仅在事件发生在其注册的上下文中的对象上时才会被调用。

<button>Click me</button>
<p>No handler here.</p>
<script>
  let button = document.querySelector("button");
  button.addEventListener("click", () => {
    console.log("Button clicked.");
  });
</script>

该示例将处理程序附加到按钮节点。单击按钮会导致该处理程序运行,但单击文档的其余部分不会。

给一个节点设置 onclick 属性会产生类似的效果。这对大多数类型的事件都适用——你可以通过将名称为事件名称并在前面加上 on 的属性来附加一个处理程序。

但是一个节点只能有一个 onclick 属性,因此你只能通过这种方式为每个节点注册一个处理程序。addEventListener 方法允许你添加任意数量的处理程序,因此即使元素上已经存在另一个处理程序,添加处理程序也是安全的。

removeEventListener 方法使用与 addEventListener 相似的参数调用,用于移除处理程序。

<button>Act-once button</button>
<script>
  let button = document.querySelector("button");
  function once() {
    console.log("Done.");
    button.removeEventListener("click", once);
  }
  button.addEventListener("click", once);
</script>

传递给 removeEventListener 的函数必须与传递给 addEventListener 的函数值相同。因此,要取消注册一个处理程序,你需要给函数一个名称(在示例中为 once),以便能够将相同的函数值传递给这两个方法。

事件对象

虽然我们到目前为止一直忽略它,但事件处理程序函数会传递一个参数:事件对象。该对象包含有关事件的更多信息。例如,如果我们想知道按下了哪一个鼠标按钮,我们可以查看事件对象的 button 属性。

<button>Click me any way you want</button>
<script>
  let button = document.querySelector("button");
  button.addEventListener("mousedown", event => {
    if (event.button == 0) {
      console.log("Left button");
    } else if (event.button == 1) {
      console.log("Middle button");
    } else if (event.button == 2) {
      console.log("Right button");
    }
  });
</script>

存储在事件对象中的信息因事件类型而异。我们将在本章后面讨论不同的类型。该对象的 type 属性始终包含一个字符串,用于标识事件(例如 "click""mousedown")。

传播

对于大多数事件类型,注册在包含子节点的节点上的处理程序也会收到在子节点中发生的事件。如果单击段落中的按钮,段落上的事件处理程序也会看到单击事件。

但是,如果段落和按钮都具有处理程序,那么更具体的处理程序(按钮上的那个)将优先运行。事件被认为是向外传播的,从事件发生的节点到该节点的父节点,再到文档的根节点。最后,在特定节点上注册的所有处理程序都已完成其操作后,注册在整个窗口上的处理程序将有机会响应事件。

在任何时候,事件处理程序都可以调用事件对象上的 stopPropagation 方法来阻止更高级别的处理程序接收事件。当例如你有一个按钮位于另一个可点击元素内,并且你不想让单击按钮激活外部元素的单击行为时,这将很有用。

以下示例在按钮和周围的段落上都注册了 "mousedown" 处理程序。当用右键单击时,按钮的处理程序将调用 stopPropagation,这将阻止段落上的处理程序运行。当用另一个鼠标按钮单击按钮时,两个处理程序都将运行。

<p>A paragraph with a <button>button</button>.</p>
<script>
  let para = document.querySelector("p");
  let button = document.querySelector("button");
  para.addEventListener("mousedown", () => {
    console.log("Handler for paragraph.");
  });
  button.addEventListener("mousedown", event => {
    console.log("Handler for button.");
    if (event.button == 2) event.stopPropagation();
  });
</script>

大多数事件对象都有一个 target 属性,它指的是事件起源的节点。你可以使用该属性来确保你不会意外处理从你不希望处理的节点传播上来的事件。

你也可以使用 target 属性来广泛地捕获特定类型的事件。例如,如果你有一个包含很长按钮列表的节点,那么在外部节点上注册一个单击处理程序,并让它使用 target 属性来确定是否单击了一个按钮,可能比在所有按钮上分别注册处理程序更方便。

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

默认操作

许多事件都有一个与之关联的默认操作。如果你单击链接,你将被带到链接的目标页面。如果你按下向下箭头,浏览器将向下滚动页面。如果你右键单击,你将获得一个上下文菜单。等等。

对于大多数类型的事件,JavaScript 事件处理程序在默认行为发生之前被调用。如果处理程序不希望这种正常行为发生,通常是因为它已经处理了事件,它可以在事件对象上调用 preventDefault 方法。

这可以用来实现你自己的键盘快捷键或上下文菜单。它也可以用来令人讨厌地干扰用户期望的行为。例如,这里有一个无法访问的链接

<a href="https://mdn.org.cn/">MDN</a>
<script>
  let link = document.querySelector("a");
  link.addEventListener("click", event => {
    console.log("Nope.");
    event.preventDefault();
  });
</script>

除非你有充分的理由,否则不要这样做。当你破坏了用户的预期行为时,这会让使用你页面的用户感到不愉快。

根据浏览器的不同,某些事件根本无法拦截。例如,在 Chrome 中,用于关闭当前选项卡的键盘快捷键(control-W 或 command-W)无法由 JavaScript 处理。

按键事件

当按下键盘上的一个键时,你的浏览器会触发 "keydown" 事件。当它被释放时,你将获得 "keyup" 事件。

<p>This page turns violet when you hold the V key.</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" 并非只在物理按下键时触发。当按下并保持一个键时,每当键重复时,事件都会再次触发。有时你必须小心这一点。例如,如果你在按下键时将按钮添加到 DOM 中,并在释放键时将其移除,那么当长时间按住键时,你可能会意外地添加数百个按钮。

该示例查看了事件对象的 key 属性,以查看事件是关于哪个键的。该属性包含一个字符串,对于大多数键,该字符串对应于按下该键时将键入的内容。对于诸如 enter 之类的特殊键,它包含一个命名键的字符串(在这种情况下为 "Enter")。如果你在按下键时按住 shift,这也会影响键的名称——"v" 变为 "V""1" 可能变为 "!",如果这是按下 shift-1 在你的键盘上产生的结果。

修饰键(例如 shiftcontrolaltmeta(在 Mac 上为 command))会像普通键一样生成按键事件。但是,当查找按键组合时,你还可以通过查看键盘和鼠标事件的 shiftKeyctrlKeyaltKeymetaKey 属性来确定这些键是否被按住。

<p>Press Control-Space to continue.</p>
<script>
  window.addEventListener("keydown", event => {
    if (event.key == " " && event.ctrlKey) {
      console.log("Continuing!");
    }
  });
</script>

按键事件起源的 DOM 节点取决于按下键时具有焦点的元素。大多数节点无法获得焦点,除非你给它们一个 tabindex 属性,但诸如链接、按钮和表单字段之类的元素可以获得焦点。我们将在第 18 章中回到表单字段。当没有任何特定内容获得焦点时,document.body 充当按键事件的目标节点。

当用户正在键入文本时,使用按键事件来确定正在键入的内容存在问题。一些平台,最显著的是 Android 手机上的虚拟键盘,不会触发按键事件。但是即使你拥有传统的键盘,某些类型的文本输入也不会直接与按键匹配,例如由使用其脚本不适合键盘的人员使用的输入法编辑器 (IME) 软件,其中多个按键组合在一起创建字符。

当用户输入内容时,可以输入内容的元素(例如 `<input>` 和 `<textarea>` 标签)会触发 `“input”` 事件。要获取用户输入的实际内容,最好直接从聚焦的字段中读取。第 18 章 将介绍如何实现。

指针事件

目前,在屏幕上指向事物有两种广泛使用的方法:鼠标(包括充当鼠标功能的设备,如触控板和轨迹球)和触摸屏。它们会产生不同类型的事件。

鼠标点击

按下鼠标按钮会导致触发多个事件。`“mousedown”` 和 `“mouseup”` 事件类似于 `“keydown”` 和 `“keyup”`,它们分别在按钮按下和释放时触发。这些事件发生在事件发生时鼠标指针正下方,紧挨着的 DOM 节点上。

在 `“mouseup”` 事件之后,`“click”` 事件会在包含按钮按下和释放位置的最具体节点上触发。例如,如果我在一个段落上按下鼠标按钮,然后将指针移动到另一个段落并释放按钮,则 `“click”` 事件将在包含这两个段落的元素上发生。

如果两次点击非常接近,则在第二次点击事件之后还会触发 `“dblclick”`(双击)事件。

要获取鼠标事件发生的精确位置信息,可以查看它的 `clientX` 和 `clientY` 属性,它们包含事件相对于窗口左上角的坐标(以像素为单位),或者查看 `pageX` 和 `pageY` 属性,它们相对于整个文档的左上角(当窗口滚动时,它们可能不同)。

以下实现了一个简单的绘图程序。每次点击文档,它会在鼠标指针下方添加一个点。参见 第 19 章,了解一个更复杂的绘图程序。

<style>
  body {
    height: 200px;
    background: beige;
  }
  .dot {
    height: 8px; width: 8px;
    border-radius: 4px; /* rounds corners */
    background: blue;
    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>

鼠标移动

每次鼠标指针移动时,都会触发 `“mousemove”` 事件。该事件可用于跟踪鼠标位置。在实现某种形式的鼠标拖动功能时,这通常很有用。

例如,以下程序显示一个条形,并设置事件处理程序,以便在该条形上向左或向右拖动会使其变窄或变宽

<p>Drag the bar to change its width:</p>
<div style="background: orange; width: 60px; height: 20px">
</div>
<script>
  let lastX; // Tracks the last observed mouse X position
  let bar = document.querySelector("div");
  bar.addEventListener("mousedown", event => {
    if (event.button == 0) {
      lastX = event.clientX;
      window.addEventListener("mousemove", moved);
      event.preventDefault(); // Prevent selection
    }
  });

  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”` 处理程序是在整个窗口上注册的。即使在调整大小过程中鼠标移出了条形,只要按钮被按住,我们仍然希望更新它的尺寸。

当鼠标按钮被释放时,我们必须停止调整条形的尺寸。为此,我们可以使用 `buttons` 属性(注意复数),它告诉我们当前按下的按钮。当值为零时,表示没有按钮按下。当按钮被按住时,它的值是这些按钮代码的总和——左按钮的代码为 1,右按钮的代码为 2,中间按钮的代码为 4。例如,同时按住左右按钮时,`buttons` 的值为 3。

请注意,这些代码的顺序与 `button` 中使用的顺序不同,在 `button` 中,中间按钮在右按钮之前。如前所述,一致性并非浏览器编程接口的强项。

触摸事件

我们使用的图形浏览器风格是在鼠标界面设计的基础上设计的,那时触摸屏还很少见。为了使 Web 在早期触摸屏手机上“正常工作”,这些设备的浏览器在一定程度上将触摸事件伪装成鼠标事件。如果你点击屏幕,你会收到 `“mousedown”`、`“mouseup”` 和 `“click”` 事件。

但是,这种错觉并不十分稳固。触摸屏的工作原理与鼠标不同:它没有多个按钮,当手指没有接触屏幕时,你无法跟踪手指(模拟 `“mousemove”`),它允许同时使用多个手指接触屏幕。

鼠标事件仅在简单的情况下涵盖触摸交互——如果你在按钮上添加 `“click”` 处理程序,触摸用户仍然可以使用它。但像前面的可调整大小条形这样的东西在触摸屏上无法使用。

触摸交互会触发特定的事件类型。当手指开始接触屏幕时,你将收到 `“touchstart”` 事件。当手指在接触屏幕时移动时,会触发 `“touchmove”` 事件。最后,当手指停止接触屏幕时,你将看到 `“touchend”` 事件。

由于许多触摸屏可以同时检测多个手指,因此这些事件没有与它们关联的单个坐标集。相反,它们的事件对象具有一个 `touches` 属性,该属性保存一个类似数组的对象,其中包含多个点,每个点都有自己的 `clientX`、`clientY`、`pageX` 和 `pageY` 属性。

你可以执行类似以下操作,在每个接触屏幕的手指周围显示红色圆圈

<style>
  dot { position: absolute; display: block;
        border: 2px solid red; border-radius: 50px;
        height: 100px; width: 100px; }
</style>
<p>Touch this page</p>
<script>
  function update(event) {
    for (let dot; dot = document.querySelector("dot");) {
      dot.remove();
    }
    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>

你通常需要在触摸事件处理程序中调用 `preventDefault`,以覆盖浏览器的默认行为(可能包括在滑动时滚动页面),并防止触发鼠标事件,你可能也为其编写了处理程序。

滚动事件

每当元素滚动时,都会在该元素上触发 `“scroll”` 事件。这有各种用途,例如了解用户当前正在查看的内容(用于禁用屏幕外动画或向你的邪恶总部发送间谍报告)或显示一些进度指示(通过突出显示目录的一部分或显示页码)。

以下示例在文档上方绘制一个进度条,并随着向下滚动更新进度条,使其充满。

<style>
  #progress {
    border-bottom: 2px solid blue;
    width: 0;
    position: fixed;
    top: 0; left: 0;
  }
</style>
<div id="progress"></div>
<script>
  // Create some content
  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>

为元素设置 `position` 为 `fixed` 的效果与设置 `absolute` 位置类似,但还会阻止元素随文档一起滚动。这样,我们的进度条会始终保持在顶部。它的宽度会发生变化,以指示当前进度。在设置宽度时,我们使用 `%` 而不是 `px` 作为单位,以便元素的大小相对于页面宽度进行缩放。

全局 `innerHeight` 绑定提供了窗口的高度,我们需要从总的可滚动高度中减去它——当你到达文档底部时,你无法继续滚动。窗口宽度也有一个 `innerWidth`。通过将 `pageYOffset`(当前滚动位置)除以最大滚动位置并乘以 100,我们就可以得到进度条的百分比。

在滚动事件上调用 `preventDefault` 不会阻止滚动发生。实际上,事件处理程序只在滚动发生 *之后* 被调用。

焦点事件

当元素获得焦点时,浏览器会在该元素上触发 `“focus”` 事件。当元素失去焦点时,该元素会收到 `“blur”` 事件。

一些事件,如这两个事件和 `“scroll”` 事件,不会传播。父元素上的处理程序不会在子元素获得或失去焦点时收到通知。

以下示例显示当前具有焦点的文本字段的帮助文本

<p>Name: <input type="text" data-help="Your full name"></p>
<p>Age: <input type="text" data-help="Your age in years"></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>

当用户从显示文档的浏览器选项卡或窗口中移动或移动到该窗口时,窗口对象会收到 `“focus”` 和 `“blur”` 事件。

加载事件

当页面完成加载时,`“load”` 事件会在窗口和文档正文对象上触发。这通常用于安排需要整个文档构建的初始化操作。请记住,`<script>` 标签的内容会在遇到该标签时立即执行。这可能太早了,例如当脚本需要对 `<script>` 标签之后出现的文档部分执行某些操作时。

加载外部文件的元素(如图像和脚本标签)也有一个 `“load”` 事件,它表示它们引用的文件已加载。与焦点相关事件一样,加载事件不会传播。

当页面关闭或导航到其他页面时(例如,通过点击链接),会触发 `“beforeunload”` 事件。此事件的主要用途是防止用户在关闭文档时意外丢失工作。如果你阻止了此事件的默认行为 *并且* 将事件对象的 `returnValue` 属性设置为字符串,浏览器会向用户显示一个对话框,询问他们是否真的要离开页面。该对话框可能包含你的字符串,但由于某些恶意网站试图利用这些对话框混淆用户,让他们留在页面上查看可疑的减肥广告,因此大多数浏览器不再显示它们。

事件和事件循环

在事件循环的上下文中,如 第 11 章 中所述,浏览器事件处理程序的行为与其他异步通知类似。它们在事件发生时被调度,但必须等待其他正在运行的脚本完成才能获得运行的机会。

事件只有在没有其他任务运行时才能被处理,这意味着如果事件循环被其他工作占用,任何与页面的交互(通过事件发生)都将被延迟,直到有时间处理它。因此,如果你调度了太多工作,无论是长时间运行的事件处理程序还是大量短时间运行的事件处理程序,页面都会变得缓慢且难以使用。

对于你确实希望在后台执行一些耗时操作而不会冻结页面的情况,浏览器提供了名为 *Web Workers* 的功能。Worker 是一个与主脚本一起运行的 JavaScript 进程,具有自己的时间线。

想象一下,对一个数字进行平方运算是一个繁重且耗时的计算任务,我们希望在一个单独的线程中执行它。我们可以创建一个名为code/squareworker.js的文件,它可以响应消息,计算平方并发送消息回复。

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

为了避免多个线程访问同一数据的麻烦,工作线程不与主脚本的环境共享其全局作用域或任何其他数据。相反,您必须通过相互发送消息来与它们进行通信。

这段代码会生成一个运行该脚本的工作线程,向它发送一些消息,并输出响应。

let squareWorker = new Worker("code/squareworker.js");
squareWorker.addEventListener("message", event => {
  console.log("The worker responded:", event.data);
});
squareWorker.postMessage(10);
squareWorker.postMessage(24);

postMessage函数会发送一条消息,这将导致接收者触发一个"message"事件。创建工作线程的脚本通过Worker对象发送和接收消息,而工作线程通过直接在全局作用域中发送和监听消息来与创建它的脚本进行通信。只有可以表示为 JSON 的值可以作为消息发送——另一方将收到它们的副本,而不是值本身。

计时器

我们在第 11 章中看到了setTimeout函数。它会安排另一个函数在给定的毫秒数后被调用。

有时您需要取消已安排的函数。这可以通过存储setTimeout返回的值并调用clearTimeout来完成。

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

if (Math.random() < 0.5) { // 50% chance
  console.log("Defused.");
  clearTimeout(bombTimer);
}

cancelAnimationFrame函数的工作方式与clearTimeout相同——调用它在requestAnimationFrame返回的值上将取消该帧(假设它还没有被调用)。

类似的一组函数,setIntervalclearInterval,用于设置应该X毫秒重复一次的计时器。

let ticks = 0;
let clock = setInterval(() => {
  console.log("tick", ticks++);
  if (ticks == 10) {
    clearInterval(clock);
    console.log("stop.");
  }
}, 200);

防抖

某些类型的事件有可能快速触发,连续触发多次(例如,"mousemove""scroll"事件)。在处理此类事件时,您必须小心不要执行任何过于耗时的操作,否则您的处理程序会占用太多时间,导致与文档的交互变得缓慢。

如果确实需要在这样的处理程序中执行一些重要的操作,可以使用setTimeout来确保不要过频繁地执行它。这通常被称为防抖事件。对此有几种稍微不同的方法。

在第一个示例中,我们希望在用户键入了一些内容时做出反应,但我们不想对每个输入事件都立即做出反应。当他们快速键入时,我们只想等到暂停发生。我们没有在事件处理程序中立即执行操作,而是设置了一个超时。我们还会清除之前的超时(如果有),这样当事件紧密相继发生(比我们的超时延迟更近)时,之前事件的超时将被取消。

<textarea>Type something here...</textarea>
<script>
  let textarea = document.querySelector("textarea");
  let timeout;
  textarea.addEventListener("input", () => {
    clearTimeout(timeout);
    timeout = setTimeout(() => console.log("Typed!"), 500);
  });
</script>

clearTimeout赋予一个未定义的值或在已触发的超时上调用它不会有任何影响。因此,我们不必担心何时调用它,我们只需对每个事件都调用它即可。

如果我们想要间隔响应,使它们至少相隔一定时间,但希望在事件系列期间触发它们,而不仅仅是在之后,我们可以使用稍微不同的模式。例如,我们可能希望通过显示鼠标的当前坐标来响应"mousemove"事件,但每 250 毫秒才响应一次。

<script>
  let scheduled = null;
  window.addEventListener("mousemove", event => {
    if (!scheduled) {
      setTimeout(() => {
        document.body.textContent =
          `Mouse at ${scheduled.pageX}, ${scheduled.pageY}`;
        scheduled = null;
      }, 250);
    }
    scheduled = event;
  });
</script>

总结

事件处理程序使我们能够检测和响应发生在我们网页上的事件。addEventListener方法用于注册这样的处理程序。

每个事件都有一个类型("keydown""focus"等),用于标识它。大多数事件在特定 DOM 元素上调用,然后传播到该元素的祖先,从而允许与这些元素关联的处理程序处理它们。

当调用事件处理程序时,它会传递一个事件对象,其中包含有关事件的附加信息。此对象还具有允许我们停止进一步传播(stopPropagation)和阻止浏览器对事件的默认处理(preventDefault)的方法。

按下某个键会触发"keydown""keyup"事件。按下鼠标按钮会触发"mousedown""mouseup""click"事件。移动鼠标会触发"mousemove"事件。触摸屏交互将导致"touchstart""touchmove""touchend"事件。

滚动可以通过"scroll"事件检测到,焦点变化可以通过"focus""blur"事件检测到。当文档完成加载时,窗口会触发"load"事件。

练习

气球

编写一个页面,显示一个气球(使用气球表情符号,🎈)。当您按下向上箭头时,它应该膨胀(增长)10%,当您按下向下箭头时,它应该缩小(缩小)10%。

您可以通过在它的父元素上设置font-size CSS 属性(style.fontSize)来控制文本的大小(表情符号是文本)。请记住在值中包含一个单位——例如,像素(10px)。

箭头键的键名是"ArrowUp""ArrowDown"。确保这些键只改变气球,而不会滚动页面。

当这部分工作完成后,添加一个功能,如果您将气球吹到超过某个大小,它就会爆炸。在这种情况下,爆炸意味着它被替换为一个💥表情符号,并且事件处理程序被删除(因此您无法再膨胀或缩小爆炸)。

<p>🎈</p>

<script>
  // Your code here
</script>

您需要注册一个"keydown"事件的处理程序,并查看event.key以确定向上或向下箭头键是否被按下。

当前大小可以保存在一个绑定中,这样您就可以根据它来确定新的大小。定义一个更新大小的函数将很有帮助——包括 DOM 中的气球的绑定和样式——这样您就可以从事件处理程序中调用它,并且可能在启动时调用一次,以设置初始大小。

您可以通过用另一个文本节点(使用replaceChild)替换文本节点,或通过将父节点的textContent属性设置为一个新字符串,将气球更改为爆炸。

鼠标轨迹

在 JavaScript 的早期,也就是华丽的首页和大量动画图像的黄金时代,人们想出了用这种语言实现一些真正鼓舞人心的方法。

其中之一就是鼠标轨迹——一系列元素,当您将鼠标指针移过页面时,这些元素会跟随鼠标指针移动。

在本练习中,我希望您实现一个鼠标轨迹。使用绝对定位的<div>元素,这些元素具有固定大小和背景颜色(参考代码,在“鼠标点击”部分查看示例)。创建一批这样的元素,当鼠标移动时,在鼠标指针的轨迹上显示它们。

这里有各种可能的方法。您可以根据自己的喜好使您的解决方案尽可能简单或复杂。一个简单的入门解决方案是保持固定数量的轨迹元素,并在每次"mousemove"事件发生时循环遍历它们,将下一个元素移动到鼠标的当前位置。

<style>
  .trail { /* className for the trail elements */
    position: absolute;
    height: 6px; width: 6px;
    border-radius: 3px;
    background: teal;
  }
  body {
    height: 300px;
  }
</style>

<script>
  // Your code here.
</script>

最好使用循环来创建元素。将它们附加到文档中以使它们显示出来。为了能够稍后访问它们以更改它们的位置,您需要将这些元素存储在一个数组中。

循环遍历它们可以通过保持一个计数器变量并在每次"mousemove"事件触发时为它加 1 来实现。然后可以使用余数运算符(% elements.length)来获取一个有效的数组索引,以便在给定事件中选择要定位的元素。

另一种有趣的效果可以通过模拟一个简单的物理系统来实现。仅使用"mousemove"事件来更新一对跟踪鼠标位置的绑定。然后使用requestAnimationFrame来模拟轨迹元素被吸引到鼠标指针的位置。在每个动画步骤中,根据它们相对于指针的位置(以及可选的每个元素存储的速度)更新它们的位置。如何找到一个好方法取决于您。

标签

选项卡面板广泛应用于用户界面。它们允许您通过从突出显示在元素上方的多个标签中选择一个来选择一个界面面板。

在本练习中,您需要实现一个简单的选项卡界面。编写一个函数 asTabs,该函数接受一个 DOM 节点,并创建一个选项卡界面,显示该节点的子元素。它应该在节点的顶部插入一个 <button> 元素列表,每个子元素对应一个按钮,按钮文本从子元素的 data-tabname 属性中获取。除一个外,所有原始子元素都应隐藏(设置 display 样式为 none)。可以通过点击按钮选择当前可见的节点。

当它正常工作时,将其扩展以对当前选定选项卡的按钮进行样式设置,以便直观地显示哪个选项卡被选中。

<tab-panel>
  <div data-tabname="one">Tab one</div>
  <div data-tabname="two">Tab two</div>
  <div data-tabname="three">Tab three</div>
</tab-panel>
<script>
  function asTabs(node) {
    // Your code here.
  }
  asTabs(document.querySelector("tab-panel"));
</script>

您可能会遇到的一个陷阱是,您不能直接使用节点的 childNodes 属性作为选项卡节点的集合。一方面,当您添加按钮时,它们也会成为子节点,并最终出现在此对象中,因为它是实时数据结构。另一方面,为节点之间的空白创建的文本节点也位于 childNodes 中,但它们不应该拥有自己的选项卡。您可以使用 children 而不是 childNodes 来忽略文本节点。

您可以先构建一个选项卡数组,以便您可以轻松访问它们。为了实现按钮的样式设置,您可以存储包含选项卡面板及其按钮的对象。

我建议为更改选项卡编写一个单独的函数。您可以存储先前选定的选项卡,并仅更改隐藏该选项卡并显示新选项卡所需的样式,或者您可以在每次选择新选项卡时更新所有选项卡的样式。

您可能希望立即调用此函数,以便界面以第一个选项卡可见的方式启动。