第 14 章处理事件
一些程序需要直接的 用户输入,例如鼠标和键盘交互。这种输入的时机和顺序无法事先预测。这就要求采用与我们迄今为止使用的不同的控制流方法。
事件处理程序
想象一下,在一个界面中,唯一判断键盘上某个键是否被按下的方法是读取该键的当前状态。为了能够对按键做出反应,你必须不断读取键的状态,以便在它再次释放之前捕获它。执行其他时间密集型计算将非常危险,因为你可能会错过按键。
这就是这种输入在原始机器上是如何处理的。一个进步是让硬件或操作系统注意到按键并将它放入队列。然后,程序可以定期检查队列中是否有新事件,并对它所发现的事件做出反应。
当然,它必须记住去看队列,并且要经常去看,因为按键被按下到程序注意到事件之间的任何时间都会导致软件感觉反应迟钝。这种方法称为轮询。大多数程序员只要有可能就会避免它。
更好的机制是让底层系统在我们代码中提供一个机会来响应事件的发生。浏览器通过允许我们注册函数作为特定事件的处理程序来做到这一点。
<p>Click this document to activate the handler.</p> <script> addEventListener("click", function() { console.log("You clicked!"); }); </script>
addEventListener
函数注册其第二个参数,以便在第一个参数描述的事件发生时被调用。
事件和 DOM 节点
每个浏览器事件处理程序都在一个上下文中注册。当你像前面那样调用 addEventListener
时,你是在它作为整个窗口的方法进行调用的,因为在浏览器中,全局作用域等同于 window
对象。每个 DOM 元素都有自己的 addEventListener
方法,它允许你专门监听该元素。
<button>Click me</button> <p>No handler here.</p> <script> var button = document.querySelector("button"); button.addEventListener("click", function() { console.log("Button clicked."); }); </script>
这个例子将一个处理程序附加到按钮节点。因此,对按钮的点击会导致该处理程序运行,而对文档其他部分的点击不会导致该处理程序运行。
为节点提供一个 onclick
属性具有类似的效果。但一个节点只有一个 onclick
属性,因此你只能通过这种方式为每个节点注册一个处理程序。addEventListener
方法允许你添加任意数量的处理程序,因此你不会意外地替换已经注册的处理程序。
removeEventListener
方法,用类似于 addEventListener
的参数调用,会移除一个处理程序。
<button>Act-once button</button> <script> var button = document.querySelector("button"); function once() { console.log("Done."); button.removeEventListener("click", once); } button.addEventListener("click", once); </script>
为了能够注销一个处理程序函数,我们给它一个名字(比如 once
),以便我们可以将它传递给 addEventListener
和 removeEventListener
。
事件对象
虽然我们在前面的例子中忽略了它,但事件处理程序函数传递了一个参数:事件对象。这个对象给我们提供了有关事件的更多信息。例如,如果我们想知道按下了哪个鼠标按钮,我们可以查看事件对象的 which
属性。
<button>Click me any way you want</button> <script> var button = document.querySelector("button"); button.addEventListener("mousedown", function(event) { if (event.which == 1) console.log("Left button"); else if (event.which == 2) console.log("Middle button"); else if (event.which == 3) console.log("Right button"); }); </script>
存储在事件对象中的信息因事件类型而异。我们将在本章后面讨论各种类型。对象的 type
属性始终保存一个标识事件的字符串(例如 "click"
或 "mousedown"
)。
传播
注册在有子节点的节点上的事件处理程序也会收到发生在子节点中的某些事件。如果段落内的按钮被点击,段落的事件处理程序也会收到点击事件。
但如果段落和按钮都具有处理程序,则更具体的处理程序(按钮上的处理程序)会先得到处理。事件被称为向外传播,从它发生的节点到该节点的父节点,一直传播到文档的根节点。最后,在注册在特定节点上的所有处理程序都得到了处理后,注册在整个窗口上的处理程序将有机会响应事件。
在任何时候,事件处理程序都可以调用事件对象上的 stopPropagation
方法来阻止“更上层”的处理程序接收事件。这在以下情况下很有用:例如,你有一个按钮位于另一个可点击元素内,而你不希望点击按钮激活外部元素的点击行为。
以下示例在按钮和周围的段落上都注册了 "mousedown"
处理程序。当用鼠标右键点击时,按钮的处理程序会调用 stopPropagation
,这将阻止段落的处理程序运行。当用另一个鼠标按钮点击按钮时,两个处理程序都会运行。
<p>A paragraph with a <button>button</button>.</p> <script> var para = document.querySelector("p"); var button = document.querySelector("button"); para.addEventListener("mousedown", function() { console.log("Handler for paragraph."); }); button.addEventListener("mousedown", function(event) { console.log("Handler for button."); if (event.which == 3) event.stopPropagation(); }); </script>
大多数事件对象都有一个 target
属性,它指向事件起源的节点。你可以使用此属性来确保你没有意外地处理从你不想处理的节点传播上来的事件。
也可以使用 target
属性为特定类型的事件设置一个广泛的网络。例如,如果你有一个包含一个长按钮列表的节点,注册在外部节点上的单个点击处理程序,并让它使用 target
属性来判断是否点击了按钮,可能比在所有按钮上注册单独的处理程序更方便。
<button>A</button> <button>B</button> <button>C</button> <script> document.body.addEventListener("click", function(event) { if (event.target.nodeName == "BUTTON") console.log("Clicked", event.target.textContent); }); </script>
默认操作
许多事件都有一个与之关联的默认操作。如果你点击链接,你将被带到链接的目标。如果你按下向下箭头,浏览器将向下滚动页面。如果你右键点击,你将得到一个上下文菜单。等等。
对于大多数类型的事件,JavaScript 事件处理程序是在执行默认行为之前被调用的。如果处理程序不希望正常行为发生(通常是因为它已经处理了事件),它可以在事件对象上调用 preventDefault
方法。
这可以用来实现你自己的键盘快捷键或上下文菜单。它也可以用来以令人讨厌的方式干扰用户期望的行为。例如,这里有一个无法访问的链接
<a href="https://mdn.org.cn/">MDN</a> <script> var link = document.querySelector("a"); link.addEventListener("click", function(event) { console.log("Nope."); event.preventDefault(); }); </script>
除非你有充分的理由,否则尽量不要这样做。对于使用你网页的人来说,当他们期望的行为被打破时,会感到很不愉快。
根据浏览器的不同,某些事件无法被拦截。例如,在 Chrome 中,无法通过 JavaScript 处理关闭当前标签页的键盘快捷键(Ctrl-W 或 Command-W)。
按键事件
当键盘上的某个键被按下时,你的浏览器会触发 "keydown"
事件。当它被释放时,会触发 "keyup"
事件。
<p>This page turns violet when you hold the V key.</p> <script> addEventListener("keydown", function(event) { if (event.keyCode == 86) document.body.style.background = "violet"; }); addEventListener("keyup", function(event) { if (event.keyCode == 86) document.body.style.background = ""; }); </script>
尽管它的名字是 "keydown"
,但它不仅在物理按键被按下时才会触发。当一个键被按下并保持按下时,每次键重复时,事件都会再次触发。有时——例如,如果你想在箭头键被按下时增加游戏角色的加速,并在键被释放时再次减少它——你必须小心不要每次键重复时都再次增加它,否则你最终会得到意外的巨大值。
前面的例子查看了事件对象的 keyCode
属性。这就是你如何识别哪个键被按下或释放。不幸的是,要将数字按键代码转换为实际按键并不总是那么容易。
对于字母和数字键,关联的按键代码将是与按键上印刷的(大写)字母或数字相关的 Unicode 字符代码。字符串上的 charCodeAt
方法为我们提供了一种查找此代码的方法。
console.log("Violet".charCodeAt(0)); // → 86 console.log("1".charCodeAt(0)); // → 49
其他键的按键代码不太容易预测。找到你需要的代码的最佳方法通常是通过试验——注册一个按键事件处理程序,记录它获得的按键代码,然后按下你感兴趣的键。
Shift、Ctrl、Alt 和 Meta(Mac 上的 Command)之类的修饰键会像普通键一样生成按键事件。但是,在查找按键组合时,你也可以通过查看键盘和鼠标事件的 shiftKey
、ctrlKey
、altKey
和 metaKey
属性来判断这些键是否被按下。
<p>Press Ctrl-Space to continue.</p> <script> addEventListener("keydown", function(event) { if (event.keyCode == 32 && event.ctrlKey) console.log("Continuing!"); }); </script>
"keydown"
和 "keyup"
事件提供有关被按下的物理键的信息。但是,如果你对实际输入的文本感兴趣怎么办?从按键代码中获取该文本很麻烦。相反,还有一个事件 "keypress"
,它在 "keydown"
之后立即触发(并且在键被按下时与 "keydown"
一起重复),但只针对产生字符输入的键。事件对象中的 charCode
属性包含一个可以解释为 Unicode 字符代码的代码。我们可以使用 String.fromCharCode
函数将此代码转换为实际的单字符字符串。
<p>Focus this page and type something.</p> <script> addEventListener("keypress", function(event) { console.log(String.fromCharCode(event.charCode)); }); </script>
按键事件起源的 DOM 节点取决于按下键时具有焦点的元素。普通节点不能拥有焦点(除非你给它们一个 tabindex
属性),但链接、按钮和表单字段等可以。我们将在第 18 章中回到表单字段。当没有任何东西具有焦点时,document.body
充当按键事件的目标节点。
鼠标点击
按下鼠标按钮也会导致一些事件触发。"mousedown"
和 "mouseup"
事件类似于 "keydown"
和 "keyup"
,它们分别在按钮被按下和释放时触发。这些事件将发生在鼠标指针下方立即的 DOM 节点上。
在 "mouseup"
事件之后,会触发 "click"
事件,该事件发生在包含按钮按下和释放的两个节点中最具体的节点上。例如,如果我在一个段落上按下鼠标按钮,然后将指针移动到另一个段落并释放鼠标按钮,"click"
事件将发生在包含这两个段落的元素上。
如果两次点击紧密相连,也会触发 "dblclick"
(双击)事件,在第二次点击事件之后。
要获取有关鼠标事件发生位置的精确信息,可以查看它的 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> addEventListener("click", function(event) { var 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>
clientX
和 clientY
属性类似于 pageX
和 pageY
,但它们是相对于当前滚动到视图中的文档部分而言的。当将鼠标坐标与 getBoundingClientRect
返回的坐标进行比较时,这些属性很有用,因为 getBoundingClientRect
也返回视口相关的坐标。
鼠标移动
每次鼠标指针移动时,都会触发一个 "mousemove"
事件。此事件可用于跟踪鼠标的位置。当实现某种形式的鼠标拖动功能时,这很有用。
例如,以下程序显示一个条形,并设置事件处理程序,以便向左或向右拖动该条形会使其变窄或变宽。
<p>Drag the bar to change its width:</p> <div style="background: orange; width: 60px; height: 20px"> </div> <script> var lastX; // Tracks the last observed mouse X position var rect = document.querySelector("div"); rect.addEventListener("mousedown", function(event) { if (event.which == 1) { lastX = event.pageX; addEventListener("mousemove", moved); event.preventDefault(); // Prevent selection } }); function buttonPressed(event) { if (event.buttons == null) return event.which != 0; else return event.buttons != 0; } function moved(event) { if (!buttonPressed(event)) { removeEventListener("mousemove", moved); } else { var dist = event.pageX - lastX; var newWidth = Math.max(10, rect.offsetWidth + dist); rect.style.width = newWidth + "px"; lastX = event.pageX; } } </script>
请注意,"mousemove"
处理程序是在整个窗口上注册的。即使鼠标在调整大小过程中移出条形,我们仍然希望更新其大小并在释放鼠标时停止拖动。
当释放鼠标按钮时,我们必须停止调整条形的大小。不幸的是,并非所有浏览器都会为 "mousemove"
事件提供有意义的 which
属性。有一个名为 buttons
的标准属性,它提供类似的信息,但它也不被所有浏览器支持。幸运的是,所有主流浏览器都支持 buttons
或 which
,因此示例中的 buttonPressed
函数首先尝试 buttons
,如果不可用,则回退到 which
。
每当鼠标指针进入或离开节点时,都会触发 "mouseover"
或 "mouseout"
事件。这两个事件可用于创建悬停效果,例如在鼠标悬停在特定元素上时显示或设置某个内容的样式。
不幸的是,创建这样的效果并不像在 "mouseover"
上开始效果并在 "mouseout"
上结束效果那样简单。当鼠标从一个节点移动到其子节点之一时,"mouseout"
会在父节点上触发,尽管鼠标实际上并没有离开节点的范围。更糟糕的是,这些事件像其他事件一样传播,因此当鼠标离开注册了处理程序的节点的子节点之一时,您还会收到 "mouseout"
事件。
为了解决这个问题,我们可以使用这些事件创建的事件对象的 relatedTarget
属性。它告诉我们在 "mouseover"
的情况下,指针之前悬停在哪个元素上,以及在 "mouseout"
的情况下,指针将要悬停在哪个元素上。我们只想在 relatedTarget
在我们的目标节点外部时更改悬停效果。只有在这种情况下,此事件才真正表示从节点外部到内部(反之亦然)的跨越。
<p>Hover over this <strong>paragraph</strong>.</p> <script> var para = document.querySelector("p"); function isInside(node, target) { for (; node != null; node = node.parentNode) if (node == target) return true; } para.addEventListener("mouseover", function(event) { if (!isInside(event.relatedTarget, para)) para.style.color = "red"; }); para.addEventListener("mouseout", function(event) { if (!isInside(event.relatedTarget, para)) para.style.color = ""; }); </script>
isInside
函数沿着给定节点的父链接向上遍历,直到它到达文档顶部(当 node
变成 null 时)或找到我们正在寻找的父节点。
我应该补充一点,这样的悬停效果可以使用 CSS 伪选择器 :hover
更轻松地实现,如下一个示例所示。但是,当您的悬停效果涉及比更改目标节点上的样式更复杂的操作时,您必须使用 "mouseover"
和 "mouseout"
事件的技巧。
<style> p:hover { color: red } </style> <p>Hover over this <strong>paragraph</strong>.</p>
滚动事件
每当滚动元素时,都会在其上触发 "scroll"
事件。这有各种用途,例如了解用户当前正在查看的内容(用于禁用屏幕外动画或向您的邪恶总部发送间谍报告)或显示某种进度指示(通过突出显示目录的一部分或显示页码)。
以下示例在文档的右上角绘制一个进度条,并随着向下滚动而更新进度条以填充。
<style> .progress { border: 1px solid blue; width: 100px; position: fixed; top: 10px; right: 10px; } .progress > div { height: 12px; background: blue; width: 0%; } body { height: 2000px; } </style> <div class="progress"><div></div></div> <p>Scroll me...</p> <script> var bar = document.querySelector(".progress div"); addEventListener("scroll", function() { var max = document.body.scrollHeight - innerHeight; var percent = (pageYOffset / max) * 100; bar.style.width = percent + "%"; }); </script>
将元素的 position
设置为 fixed
与 absolute
位置非常相似,但也会阻止它随着文档的其余部分一起滚动。这样,我们的进度条会保持在它的角落。在进度条内,还有一个元素,它的尺寸会根据当前进度进行调整。我们使用 %
而不是 px
作为单位来设置宽度,以便元素的大小相对于整个条形进行调整。
全局 innerHeight
变量提供窗口的高度,我们必须从总滚动高度中减去这个高度——当您到达文档底部时,您无法继续滚动。(还有与 innerHeight
相对应的 innerWidth
。)通过将 pageYOffset
(当前滚动位置)除以最大滚动位置,再乘以 100,我们可以得到进度条的百分比。
在滚动事件上调用 preventDefault
不会阻止滚动发生。实际上,事件处理程序只在滚动发生之后才会被调用。
焦点事件
当元素获得焦点时,浏览器会在其上触发 "focus"
事件。当它失去焦点时,会触发 "blur"
事件。
与前面讨论的事件不同,这两个事件不会传播。当子元素获得或失去焦点时,父元素上的处理程序不会收到通知。
<p>Name: <input type="text" data-help="Your full name"></p> <p>Age: <input type="text" data-help="Age in years"></p> <p id="help"></p> <script> var help = document.querySelector("#help"); var fields = document.querySelectorAll("input"); for (var i = 0; i < fields.length; i++) { fields[i].addEventListener("focus", function(event) { var text = event.target.getAttribute("data-help"); help.textContent = text; }); fields[i].addEventListener("blur", function(event) { help.textContent = ""; }); } </script>
当用户从显示文档的浏览器标签或窗口移动到另一个或从另一个移动回来时,窗口对象会收到 "focus"
和 "blur"
事件。
加载事件
当页面加载完成时,"load"
事件会在窗口和文档主体对象上触发。这通常用于安排需要整个文档构建完成的初始化操作。请记住,<script>
标签的内容会在遇到该标签时立即运行。这通常过早了,例如当脚本需要对出现在 <script>
标签之后的文档部分执行某些操作时。
加载外部文件的图像和脚本标签等元素也有 "load"
事件,用于指示它们引用的文件已加载。与焦点相关的事件一样,加载事件不会传播。
当关闭页面或从页面导航到其他页面(例如通过点击链接)时,会触发 "beforeunload"
事件。此事件的主要用途是防止用户意外地关闭文档而丢失工作。与您可能预期的不同,阻止页面卸载不是通过 preventDefault
方法完成的。相反,它是通过从处理程序返回一个字符串完成的。该字符串将在一个对话框中使用,该对话框会询问用户是否要停留在页面上或离开页面。这种机制确保用户能够离开页面,即使页面正在运行恶意脚本,这些脚本更希望让用户永远停留在页面上,以便迫使他们查看可疑的减肥广告。
脚本执行时间线
有各种原因会导致脚本开始执行。读取 <script>
标签就是其中之一。事件触发也是另一个原因。第 13 章讨论了 requestAnimationFrame
函数,该函数将函数安排在下次页面重绘之前调用。这是脚本开始运行的另一种方式。
重要的是要理解,即使事件可以随时触发,单个文档中的两个脚本也永远不会在同一时刻运行。如果一个脚本正在运行,则事件处理程序和以其他方式安排的代码片段必须等待它们的回合。这就是为什么当脚本长时间运行时文档会冻结的原因。浏览器无法对文档中的点击和其他事件做出反应,因为它无法在当前脚本完成运行之前运行事件处理程序。
一些编程环境确实允许同时运行多个执行线程。同时执行多项操作可以用来使程序更快。但是,当您有多个参与者在同一时间触碰系统的相同部分时,思考程序至少会变得复杂一个数量级。
JavaScript 程序一次只做一件事这一事实使我们的生活更轻松。对于您真正想要在后台执行一些耗时操作而不冻结页面的情况,浏览器提供了一种称为Web Worker 的机制。Worker 是一个独立的 JavaScript 环境,它与文档的主要程序并行运行,并且只能通过发送和接收消息与之通信。
假设我们在名为 code/squareworker.js
的文件中包含以下代码。
addEventListener("message", function(event) { postMessage(event.data * event.data); });
想象一下,对数字进行平方是一个繁重的、长时间运行的计算,我们希望在后台线程中执行。这段代码生成一个 worker,向它发送一些消息,并输出响应。
var squareWorker = new Worker("code/squareworker.js"); squareWorker.addEventListener("message", function(event) { console.log("The worker responded:", event.data); }); squareWorker.postMessage(10); squareWorker.postMessage(24);
postMessage
函数发送一条消息,这将导致接收器中触发 "message"
事件。创建 worker 的脚本通过 Worker
对象发送和接收消息,而 worker 通过直接在它的全局作用域上发送和监听来与创建它的脚本进行通信——这是一个新的全局作用域,不与原始脚本共享。
设置计时器
setTimeout
函数类似于 requestAnimationFrame
。它安排另一个函数稍后调用。但它不是在下次重绘时调用该函数,而是等待给定的毫秒数。此页面在两秒后从蓝色变为黄色。
<script> document.body.style.background = "blue"; setTimeout(function() { document.body.style.background = "yellow"; }, 2000); </script>
有时您需要取消已安排的函数。这可以通过存储 setTimeout
返回的值并在其上调用 clearTimeout
来完成。
var bombTimer = setTimeout(function() { console.log("BOOM!"); }, 500); if (Math.random() < 0.5) { // 50% chance console.log("Defused."); clearTimeout(bombTimer); }
cancelAnimationFrame
函数的工作方式与 clearTimeout
相同——在 requestAnimationFrame
返回的值上调用它将取消该帧(假设它还没有被调用)。
类似的一组函数,setInterval
和 clearInterval
用于设置每 X 毫秒重复一次的计时器。
var ticks = 0; var clock = setInterval(function() { console.log("tick", ticks++); if (ticks == 10) { clearInterval(clock); console.log("stop."); } }, 200);
防抖动
某些类型的事件有可能快速触发,并且可能连续多次触发(例如,"mousemove"
和 "scroll"
事件)。在处理此类事件时,必须注意不要执行任何过于耗时的操作,否则您的处理程序将占用太多时间,以至于与文档的交互开始变得缓慢和卡顿。
如果您确实需要在这样的处理程序中执行一些非平凡的操作,可以使用 setTimeout
来确保您不会过于频繁地执行它。这通常被称为对事件进行防抖。有几种略微不同的方法可以做到这一点。
在第一个示例中,我们希望在用户键入内容时执行某些操作,但我们不希望对每个键事件立即执行该操作。当他们快速键入时,我们只想等到出现暂停。我们不立即在事件处理程序中执行操作,而是设置一个超时。我们还清除上一个超时(如果有),这样当事件彼此靠近(比我们的超时延迟更近)时,上一个事件的超时将被取消。
<textarea>Type something here...</textarea> <script> var textarea = document.querySelector("textarea"); var timeout; textarea.addEventListener("keydown", function() { clearTimeout(timeout); timeout = setTimeout(function() { console.log("You stopped typing."); }, 500); }); </script>
对 clearTimeout
传递一个未定义的值或在已触发的超时上调用它没有任何效果。因此,我们不必担心何时调用它,我们只需对每个事件都这样做。
如果我们希望将响应间隔至少一定时间,但希望在事件系列期间触发它们,而不仅仅是在事件系列之后,我们可以使用略微不同的模式。例如,我们可能希望通过显示鼠标的当前坐标来响应 "mousemove"
事件,但仅每 250 毫秒执行一次。
<script> function displayCoords(event) { document.body.textContent = "Mouse at " + event.pageX + ", " + event.pageY; } var scheduled = false, lastEvent; addEventListener("mousemove", function(event) { lastEvent = event; if (!scheduled) { scheduled = true; setTimeout(function() { scheduled = false; displayCoords(lastEvent); }, 250); } }); </script>
总结
事件处理程序使我们能够检测和对我们无法直接控制的事件做出反应。addEventListener
方法用于注册这样的处理程序。
每个事件都有一个类型("keydown"
、"focus"
等)来识别它。大多数事件在特定 DOM 元素上调用,然后传播到该元素的祖先,从而允许与这些元素关联的处理程序处理它们。
当调用事件处理程序时,会向其传递一个事件对象,其中包含有关该事件的更多信息。此对象还具有允许我们停止进一步传播(stopPropagation
)和阻止浏览器对事件的默认处理(preventDefault
)的方法。
按下键会触发 "keydown"
、"keypress"
和 "keyup"
事件。按下鼠标按钮会触发 "mousedown"
、"mouseup"
和 "click"
事件。移动鼠标会触发 "mousemove"
以及可能的 "mouseenter"
和 "mouseout"
事件。
可以使用 "scroll"
事件检测滚动,并可以使用 "focus"
和 "blur"
事件检测焦点变化。当文档加载完毕时,会在窗口上触发 "load"
事件。
一次只能运行一段 JavaScript 程序。因此,事件处理程序和其他计划的脚本必须等到其他脚本完成才能轮到它们。
练习
审查键盘
在 1928 年至 2013 年期间,土耳其法律禁止在官方文件中使用字母 Q、W 和 X。这是抑制库尔德文化更广泛行动的一部分——这些字母出现在库尔德人使用的语言中,但在伊斯坦布尔土耳其语中没有出现。
作为一项用技术做荒谬事情的练习,我请您编程一个文本字段(一个 <input type="text">
标签),这些字母不能键入到其中。
<input type="text"> <script> var field = document.querySelector("input"); // Your code here. </script>
鼠标轨迹
在 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 来循环遍历它们。然后,可以使用余数运算符(% 10
)获得有效的数组索引,以在给定事件期间选择要定位的元素。
另一个有趣的效应可以通过模拟一个简单的物理系统来实现。仅使用 "mousemove"
事件来更新跟踪鼠标位置的一对变量。然后使用 requestAnimationFrame
来模拟轨迹元素被吸引到鼠标指针的位置。在每个动画步骤中,根据它们相对于指针的位置(以及可选的每个元素存储的速度)更新它们的位置。想出一个好方法来做到这一点就取决于您了。
选项卡
选项卡式界面是一种常见的模式。它允许您通过选择从元素上方“突出”的多个选项卡来选择一个界面面板。
在本练习中,您将实现一个简单的选项卡式界面。编写一个函数 asTabs
,它接受一个 DOM 节点,并创建一个选项卡式界面,显示该节点的子元素。它应该在节点的顶部插入一个 <button>
元素列表,每个子元素一个,包含从子元素的 data-tabname
属性中检索到的文本。除了一个之外,所有原始子元素都应该被隐藏(赋予 display
样式为 none
),并且当前可见的节点可以通过点击按钮来选择。
<div id="wrapper"> <div data-tabname="one">Tab one</div> <div data-tabname="two">Tab two</div> <div data-tabname="three">Tab three</div> </div> <script> function asTabs(node) { // Your code here. } asTabs(document.querySelector("#wrapper")); </script>
您可能会遇到的一个陷阱是,您不能直接使用节点的 childNodes
属性作为选项卡节点的集合。首先,当您添加按钮时,它们也会成为子节点,并最终出现在此对象中,因为它是动态的。其次,为节点之间的空白创建的文本节点也位于其中,并且不应该获得自己的选项卡。
要解决这个问题,首先要构建一个包含包装器中所有 nodeType
为 1 的子元素的真实数组。
在按钮上注册事件处理程序时,处理程序函数需要知道哪个选项卡元素与按钮相关联。如果它们是在普通循环中创建的,您可以在函数内部访问循环索引变量,但它不会给您正确的数字,因为该变量会被循环进一步改变。
一个简单的解决方法是使用 forEach
方法,并在传递给 forEach
的函数内部创建处理程序函数。循环索引(作为第二个参数传递给该函数)将成为一个普通的局部变量,并且不会被进一步的迭代覆盖。