## 十五、处理事件 > You have power over your mind—not outside events. Realize this, and you will find strength. > > Marcus Aurelius, Meditations 有些程序需要处理用户的直接输入,比如鼠标和键盘输入。这些输入的时间和顺序无法提前预知。处理这些输入需要的控制流方法与我们迄今为止所使用的方法不同。 ### 14.1 事件处理器 想象一下,有一个接口,若想知道键盘上是否有一个键是否被按下,唯一的方法是读取那个按键的当前状态。为了能够响应按键动作,你需要不断读取键盘状态,以在按键被释放之前捕捉到按下状态。这种方法在执行时间密集计算时非常危险,因为你可能错过按键事件。 这是原始机器上处理输入时的方法。有一种更进一步的方法,硬件或操作系统发现按键时间并将其放入队列中。程序可以周期性地检查队列,等待新事件并在发现事件时进行响应。 当然,程序必须记得监视队列,并经常做这种事,因为任何时候,按键被按下和程序发现事件之间都会使得软件反应迟钝。该方法被称为轮询。大多数程序员都会尽可能避免这种方法。 一种更好的机制是底层系统给予我们的代码在事件发生时响应的机会。浏览器实现了这种特性,支持我们将函数注册为特定事件的处理器。 ```html

Click this document to activate the handler.

``` addEventListener函数会注册第二个参数,当第一个参数描述的时间触发时,就会调用第二个参数。 ### 14.2 事件与DOM节点 每个浏览器事件处理器被注册在上下文中。当你如上所示调用addEventListener时,你可以在整个窗口上调用该方法,因为在浏览器中全局作用域等同于window对象。每个DOM元素都有其自己的addEventListener方法,支持在特定元素上监听事件。 ```html

No handler here.

``` 示例代码中将处理器附加到按钮节点上。因此,点击按钮时会触发并执行处理器,而点击文档的其他部分则没有反应。 为节点执行onclick属性也有类似效果。但一个节点只有一个onclick属性,因此只能使用这种方法为每个节点注册一个处理器。而addEventListener方法则允许你添加任意数量的方法,因此你不会无意间替换掉已经注册的处理器。 使用参数调用removeEventListener方法与addEventListener方式相似,可以用于移除处理器。 ```html ``` 我们需要给事件处理函数一个名称(比如once),才能反注册处理器函数,并将处理函数名称传递给addEventListener和removeEventListener。 ### 14.3 事件对象 虽然在前面示例中被我们忽略了,但我们要知道事件触发时会为事件处理函数传递一个参数:事件(Event)对象。例如,如果我们想知道哪个鼠标按键被按下,我们可以查看事件对象的which属性。 ```html ``` 存储在各种类型事件对象中的信息是有差别的。随后本章将会讨论许多类型的事件。对象的type属性一般持有一个字符串,表示事件(例如“click”和“mousedown”)。 ### 14.4 传播 若节点含有子节点,则在节点上注册的事件处理器也会接收到在孩子节点中发生的事件。若点击一个段落中的按钮,段落的事件处理器也会收到点击事件。 但若段落和按钮都有事件处理器,则先执行最特殊的事件处理器(按钮的事件处理器)。也就是说事件向外传播,从触发事件的节点到其父节点,最后直到文档根节点。最后,当某个特定节点上注册的所有事件处理器按其顺序全部执行完毕后,窗口对象的事件处理器才有机会响应事件。 事件处理器任何时候都可以调用事件对象的stopPropagation方法,阻止事件进一步传播。该方法有时很实用,例如,你将一个按钮放在另一个可点击元素中,但你不希望点击该按钮会激活外部元素的点击行为。 下面的示例代码将mousedown处理器注册到按钮和其外部的段落节点上。在按钮上点击鼠标右键,按钮的处理器会调用stopPropagation,组织段落上的事件处理器执行。当点击鼠标其他键时,两个处理器都会执行。 ```html

A paragraph with a .

``` 大多数事件对象都有target属性,指的是事件来源节点。你可以根据该属性防止无意中处理了传播自其他节点的事件。 我们也可以使用target属性来创建出特定类型事件的处理网络。例如,如果一个节点中包含了很长的按钮列表,比较方便的处理方式是在外部节点上注册一个点击事件处理器,并根据事件的target属性来区分用户按下了哪个按钮,而不是为每个按钮都注册独立的事件处理器。 ```html ``` ### 14.5 默认动作 大多数事件都有与其关联的默认动作。若点击链接,就会跳转到链接目标。若点击向下的箭头,浏览器会向下翻页。若右击鼠标,可以得到一个上下文菜单等。 对于大多数类型的事件,JavaScript事件处理器会在默认处理器执行前被调用。若事件处理器不希望执行默认行为(通常是因为已经处理了该事件),会调用preventDefault事件对象的方法。 你可以实现你自己的键盘快捷键或交互式菜单。你也可以干扰用户期望的行为。例如,这里实现一个无法跳转的链接。 ```html MDN ``` 除非你有非常充足的理由,否则不要这样做。由于人们都会使用你的网页,当其期望的行为被破坏时会非常不开心。 在有些浏览器中,你无法拦截某些事件。比如在Chrome中,关闭键盘快捷键(CTRL-W或COMMAND-W)无法由JavaScript处理。 ### 14.6 按键事件 当按下键盘上的按键时,浏览器会触发“keydown”事件。当松开按键时,会触发“keyup”事件。 ```html

This page turns violet when you hold the V key.

``` 尽管从keydown这个事件名上看应该是物理按键按下时触发,但当持续按下某个按键时,会循环触发该事件。有时,例如你想在按键按下时增加游戏属性,而在按键释放时减缓游戏属性,此时你要注意,不要在每次触发按键事件时都增加游戏属性,否则最后无意之间会得到巨大的值。 前面的示例查看了事件对象的keyCode属性。该属性标识了用户按下或释放了哪个按键。遗憾的是将数值按键代码转换成实际按键的方法并不总是显而易见的。 对于字母和数字键,其按键代码是与键盘上的字母(大写)或数字相关的Unicode字符。而字符串的charCodeAt方法则让我们可以找到字符对应的代码。 ```html console.log("Violet".charCodeAt(0)); // → 86 console.log("1".charCodeAt(0)); // → 49 ``` 其他的按键就不是那么可预见的了。寻找代码常常需要通过实验,注册一个按键事件处理器,记录下获得的按键代码然后按下你想了解的按键。 诸如shift、ctrl、alt和meta(Mac上的command)之类的修饰按键会像普通按键一样产生事件。但在查找组合键时,你也可以查看键盘和鼠标事件的shiftKey、ctrlKey、altKey和metaKey属性来判断这些键是否被按下。 ```html

Press Ctrl-Space to continue.

``` keydown和keyup事件告知我们按下的物理按键信息。但假如你想得到的是实际按下的文本该怎么办呢?从按键代码获取文本是很烦琐的。有另一个事件可以取而代之,该事件名为keypress,该事件在keydown之后触发(当按键按住不放时与keydown一样会反复触发),但只获得按键的输入。 ```html

Focus this page and type something.

``` 按键事件的起源DOM节点取决于按键按下时,焦点在哪个元素上。正常的节点无法拥有焦点(除非你赋予其tabindex属性),但诸如链接、按钮和表单字段之类的元素都可以拥有焦点。我们将在第18章中回过头来看表单字段。当任何元素都没有焦点时,document.body扮演了按键事件的目标节点。 ### 14.7 鼠标点击 点击鼠标按钮也会触发一系列事件。“mousedown”事件和“mouseup”事件类似于“keydown”和“keyup”事件,当鼠标按钮按下或释放时触发。当事件发生时,由鼠标指针下方的DOM节点触发事件。 在mouseup事件后,包含鼠标按下与释放的特定节点会触发“click”事件。例如,如果我在一个段落上按下鼠标,移动到另一个段落上释放鼠标,“click”事件会发生在包含这两个段落的元素上。 若两次点击事件触发时机接近,则在第二次点击事件之后,也会触发“dbclick”(双击,double-click)事件。 为了获得鼠标事件触发的精确信息,你可以查看事件中的pageX和pageY属性,包含了事件相对于文档左上角的坐标(以像素为单位)。 下面的代码实现了简单的绘图程序。每次点击文档时,会在鼠标指针下添加一个点。还有一个稍微优化的绘图程序,请参见第19章。 ```html ``` clientX和clientY属性类似于pageX和pageY属性,是相对于文档当前视图中左上角的坐标。当与getBoundingClientRect返回的坐标比较时(返回的也是相对于视口的坐标),该信息非常有用。 ### 14.8 鼠标移动 每次鼠标移动时都会触发“mousemove”事件。该事件可用于跟踪鼠标位置。当实现某些形式的鼠标拖拽功能时,该事件非常有用。 举一个例子,下面的程序展示一条栏,并设置一个事件处理器,当向左拖动这个栏时,会使其变窄,若向右拖动则变宽。 ```html

Drag the bar to change its width:

``` 请注意,mousemove处理器注册在窗口对象上。即使鼠标在改变窗口尺寸时在栏外侧移动,我们也想看到其尺寸变化,而当鼠标释放时,拖拽停止。 每当鼠标指针进入或离开节点时,都会触发mouseover和mouseout事件。这两个事件常用于创建悬浮效果,在鼠标移过特定元素时显示或加上特定样式。 遗憾的是创建特定效果并不是很简单,不仅仅是mouseover时触发效果,mouseout时效果结束。当鼠标从一个节点移动到其孩子节点上时,mouseout会触发其父亲节点,尽管鼠标没有移动到其范围之外。更糟糕的是这些事件会像其他事件一样扩散,因此当鼠标离开某个节点的子节点时,注册在该节点上的处理器也会收到mouseout事件。 为了解决此问题,我们可以使用创建事件对象的relatedTarget属性。在mouseover事件中,该属性指的是指针之间移动过的元素,而在mouseout中则是刚刚离开的元素。只有当relatedTarget属性在目标节点外时我们才启用浮动效果。只有这种情况,事件发生时确实是从元素外部移动到元素内部(或其他方式)。 ```html

Hover over this paragraph.

``` 函数isInside会沿着指定节点父节点链接寻找节点,直到到达文档顶部(当node为null时),或找到了目标父节点为止。 我们可以使用CSS的伪选择子(pseudoselector):hover来更轻松地实现同样的效果。但当浮动效果更加复杂(不只是改变目标节点效果)时,你必须使用mouseover和mouseout事件这种技巧。 ```html

Hover over this paragraph.

``` ### 14.9 滚动事件 每当元素滚动时,会触发scroll事件。该事件用处极多,比如知道用户当前查看的元素(禁用用户视线以外的动画,或向邪恶的指挥部发送监视报告),或展示一些滚动的迹象(通过高亮表格的部分内容,或显示页码)。 下面的示例在文档右下角绘制进度条,向下滚动时更新进度条: ```html

Scroll me...

``` 将元素的position属性指定为fixed时,其行为和absolute很像,但可以防止在文档滚动时期跟着文档一起滚动。其效果是使及进度条一直待在角落。进度条内还有其他元素,会随着当前进度改变尺寸。我们使用%,而非px作为宽度单位,这使得元素尺寸是相对于整个进度条来计算的。 innerHeight全局变量是窗口高度,我们必须要减去滚动条的高度。你点击文档底部的时候是无法继续滚动的(和innerHeight一样,还有innerWidth变量)。使用pageYOffset(当前滚动位置)除以最大滚动位置,并乘以100,就可以得到进度条长度。 调用滚动事件的preventDefault无法阻止滚动。实际上,事件处理器是在进行滚动之后才触发的。 ### 14.10 焦点事件 当元素获得焦点时,浏览器会触发其上的focus事件。当失去焦点时触发blur事件。 与前文讨论的事件不同,这两个事件不会传播。子元素获得或失去焦点时,不会激活父元素的处理器。 下面的示例中,文本域在拥有焦点时会显示帮助文本。 ```html

Name:

Age:

``` 当用户从浏览器标签或窗口移开时,窗口对象会收到focus事件,当移动到标签或窗口上时,则收到blur事件。 ### 14.11 加载事件 当界面结束装载时,会触发窗口对象和文档body对象的“load”事件。该事件通常用于在当整个文档构建完成时,进行初始化。请记住<script>标签的内容是一遇到就执行的。很多时候这样执行得太早,比如有时脚本需要处理在<script>标签后出现的内容。 诸如image或script这类会装载外部文件的标签都有load事件,指示其引用文件装载完毕。类似于焦点事件,装载事件是不会传播的。 当页面关闭或跳转(比如跳转到一个链接)时,会触发beforeunload事件。该事件用于防止用户突然关闭文档而丢失工作结果。你无法使用preventDefault方法阻止页面卸载。该处理器通过返回字符串来完成该功能。该字符串会用在询问用户是否关闭页面的对话框中。该机制确保用户可以离开页面,即使是那些想要保持页面,让用户看广告的恶意脚本也无能为力。 ### 14.12 脚本执行时间线 有许多事件会触发脚本开始执行,读取<script>标签就是其中一个,事件触发是另一种。第13章讨论了requestAnimationFrame函数,该函数用于在下一次页面重绘之前调用某个函数。这是启动脚本运行的另一方法。 需要重点理解的是即使任何时候都可以触发事件,但同一文档中无法同时执行两个脚本。若一个脚本已经在运行,事件处理器和使用其他方法调度的代码会使该脚本等待执行。这就是长时间执行脚本时文档无法响应的原因。此时浏览器无法回应点击或其他文档内事件,因为其无法在当前脚本完成运行之前去执行时间处理器。 许多程序设计环境都支持在同一时间执行多线程。同时执行许多任务可以使得程序运行更快。但当多个线程同一时间访问系统中同一部分时,构思一个程序就比原来难很多了。 JavaScript程序同时只能做一件事情让事情变得更简单。若读者想将耗时操作放到后台进行,同时防止页面无响应,可以使用浏览器提供的Web Worker。这个工作单元是独立的JavaScript环境,可以独立于文档主程序运行,而且只能和其发送接收消息。 假设我们有以下代码,名为code/squareworker.js。 ```js addEventListener("message", function(event) { postMessage(event.data * event.data); }); ``` 想象一下,计算数字平方的任务是艰巨耗时的计算任务,我们想要将其放在后台线程执行。该代码会产生一个工作单元,相继发送一些消息,并输出响应。 ```js 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则直接向其全局作用域(新的全局作用域,与初始脚本不同)发送消息,或监听其消息。 ### 14.13 设置定时器 函数setTimeout类似于requestAnimationFrame。该函数随后会调用另一个函数。但不是下次重绘时,而是在等待指定毫秒数之后调用函数。该页面会在两秒之后从蓝色变为黄色。 ```html ``` 有时读者需要取消调度的函数。可以存储setTimeout的返回值,并将作为参数调用clearTimeout。 ```html 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这种相似的函数,用于设置计时器,每隔一定毫秒数重复执行一次。 ```html var ticks = 0; var clock = setInterval(function() { console.log("tick", ticks++); if (ticks == 10) { clearInterval(clock); console.log("stop."); } }, 200); ``` ### 14.14 降频 某些类型的事件可能会连续、迅速触发多次(例如mousemove和scroll事件)。处理这类事件时,你必须小心谨慎,防止处理任务耗时过长,否则处理器会占据过多事件,导致用户与文档交互变得非常慢且卡顿。 若你需要在这类处理器中编写一些重要任务,可以使用setTimeout来确保不会频繁进行这些任务。我们通常称之为“事件降频(Debounce)”。有许多方法可以完成该任务。 在第一个示例中,当用户输入某些字符时,我们想要完成某些任务,但我们不想在每个按键事件中立即处理该任务。当用户输入过快时,我们希望暂停一下然后进行处理。我们不是立即在事件处理器中执行动作,而是设置一个定时器。我们也会清除上一次的定时器(如果有),因此当两个事件触发间隔过短(比定时器延时短),就会取消上一次事件设置的定时器。 ```html ``` 将undefined传递给clearTimeout或在一个已结束的定时器上调用clearTimeout是没有效果的。因此,我们不需要关心何时调用该方法,只需要每个事件中都这样做即可。 如果我们想要保证每次响应之间至少间隔一段时间,但不希望每次事件发生时都重置定时器,而是在一连串事件连续发生时能够定时触发响应,那么我们可以使用一个略有区别的方法来解决问题。例如,我们想要响应“mousemove”事件来显示当前鼠标坐标,但频率只有250ms。 ```html ``` ### 14.15 本章小结 事件处理器可以检测并响应事件,而不需要直接控制事件。addEventListener方法用于注册处理器。 每个事件都有标识事件的类型(keydown、focus等)。大多数方法都会在特定DOM元素上调用,接着向其父代节点传播,允许每个父代元素的处理器都能处理这些事件。 JavaScript调用事件处理器时,会传递一个包含事件额外信息的事件对象。该对象也有方法支持停止进一步传播(stopPropagation),也支持阻止浏览器执行事件的默认处理器(preventDefault)。 按下键盘按键时会触发keydown、keypress和keyup事件。按下鼠标按钮时,会触发mousedown、mouseup和click事件。移动鼠标会触发mousemove事件,也可能触发mouseenter和mouseout事件。 我们可以通过scroll事件监测滚动行为,可以通过focus和blur事件监控焦点改变。当文档完成加载后,会触发窗口的load事件。 同时只能执行一段JavaScript程序。因此,事件处理器和其他调度脚本需要等待脚本结束,才能得到机会执行。 ### 14.16 习题 #### 14.16.1 少了几个按键的键盘 在1928年和1933年之间,土耳其法律禁止在官方文档中使用Q、W和X。这是用于扼杀库尔德的文化大量措施中的一部分。这些字母在库尔德人中使用,但伊斯坦布尔的土耳其人不使用。 作为一个练习,通过技术来实现这种阉割键盘按键的功能,编写一个文本域(使用<input type="text"标签),使得用户无法输入这些字母。 (不用处理复制粘贴,以及其他可能的漏洞。) ```html ``` #### 14.16.2 鼠标轨迹 在JavaScript早期,有许多主页都会在页面上使用大量的动画,人们想出了许多该语言的创造性用法。 其中一种用途是“鼠标踪迹”,也就是沿着鼠标指针在页面上滑动的轨迹画出一连串图像。 在本习题中实现鼠标轨迹的功能。使用绝对定位、固定尺寸的<div>元素,背景为黑色(请参考鼠标点击一节中的示例)。创建一系列此类元素,当鼠标移动时,伴随鼠标指针显示它们。 有许多方案可以实现我们所需的功能。你可以根据你的需要实现简单的或复杂的方法。简单的解决方案是保存固定鼠标的轨迹元素并循环使用它们,每次mousemove事件触发时将下一个元素移动到鼠标当前位置。 ```html ``` #### 14.16.3 选项卡界面 选项卡界面是常见的设计模型。选项卡支持用户通过选择一系列元素上突出的标签来选择一个面板。 本习题要求实现一个简单的选项卡界面。编写asTabs函数,接受一个DOM节点并创建选项卡界面来展现该节点的子元素。该函数应该在顶层节点中插入大量<button>元素,与每个孩子元素一一对应,按钮文本从孩子的data-tabname中获取。除了显示一个初始孩子节点,其他孩子节点都应该隐藏(将display样式设置成none),并通过点击按钮来选择当前显示的节点。 当该函数工作时,扩展该函数,支持在当前激活按钮上使用不同的样式。 ```html
Tab one
Tab two
Tab three
```