本章包括以下内容:
Web应用的生命周期步骤
- 从HTML代码到Web页面的处理过程
- JavaScript代码的执行顺序
- 与事件交互
- 事件循环 我们对JavaScript的探索从客户端Web应用开始,其代码也在浏览器提供的引擎上执行。为了打好后续对JavaScript语言和浏览器平台的学习基础,首先我们要理解Web应用的生命周期,尤其要理解JavaScript代码执行在生命周期的所有环节。
本章会完整探索客户端Web应用程序的生命周期,从页面请求开始,到用户不同种类的交互,最后至页面被关闭。首先我们来看看页面是如何从HTML代码建立的。然后我们将集中探讨JavaScript代码的执行,它给我们的页面提供了大量交互。最后我们会看看为了响应用户的动作,事件是如何被处理的。在这一系列过程中,我们将探索很多Web应用的基础概念,例如DOM(Web页面的一种结构化表示方式)和事件循环(它决定了应用如何处理事件)。让我们开始学习吧!
你知道吗?
- 浏览器是否总是会根据给定的HTML来渲染页面呢?
- Web应用一次能处理多少个事件?
- 为什么浏览器使用事件队列来处理事件?
2.1 生命周期概览
典型客户端Web应用的生命周期从用户在浏览器地址栏输入一串URL,或单击一个链接开始。例如,我们想去Google的主页查找一个术语。首先我们输入了URL,www.google.com,其过程如图2.1所示。
图2.1 客户端Web应用的周期从用户指定某个网站地址(或单击某个链接)开始, 由两个步骤组成:页面构建和事件处理
从用户的角度来说,浏览器构建了发送至服务器(序号2)的请求,该服务器处理了请求(序号3)并形成了一个通常由HTML、CSS和JavaScript代码所组成的响应。当浏览器接收了响应(序号4)时,我们的客户端应用开始了它的生命周期。 由于客户端Web应用是图形用户界面(GUI)应用,其生命周期与其他的GUI应用相似(例如标准的桌面应用或移动应用),其执行步骤如下所示:
1.页面构建——创建用户界面;
2.事件处理——进入循环(序号5)从而等待事件(序号6)的发生,发生后调用事件处理器。
应用的生命周期随着用户关掉或离开页面(序号7)而结束。现在让我们一起看一个简单的示例程序:每当用户移动鼠标或单击页面就会显示一条消息。本章会始终使用这个示例,如清单2.1所示。
2.2 页面构建阶段
当Web应用能被展示或交互之前,其页面必须根据服务器获取的响应(通常是HTML、CSS和JavaScript代码)来构建。页面构建阶段的目标是建立Web应用的UI,其主要包括两个步骤:
1.解析HTML代码并构建文档对象模型 (DOM);
2.执行JavaScript代码。
步骤1会在浏览器处理HTML节点的过程中执行,步骤二会在HTML解析到一种特殊节点——脚本节点(包含或引用JavaScript代码的节点)时执行。页面构建阶段中,这两个步骤会交替执行多次,如图2.3所示。
2.2.1 HTML解析和DOM构建
页面构建阶段始于浏览器接收HTML代码时,该阶段为浏览器构建页面UI的基础。通过解析收到的HTML代码,构建一个个HTML元素,构建DOM。在这种对HTML结构化表示的形式中,每个HTML元素都被当作一个节点。如图2.4所示,直到遇到第一个脚本元素,示例页面都在构建DOM。
注意图2.4中的节点是如何组织的,除了第一个节点——html根节点(序号1)以外,所有节点都只有一个父节点。例如,head节点(序号2)父节点为html节点(序号1)。同时,一个节点可以有任意数量的子节点。例如,html节点(序号1)有两个孩子节点:head节点(序号2)和body节点。同一个元素的孩子节点被称作兄弟节点。(head节点和body节点是兄弟节点)尽管DOM是根据HTML来创建的,两者紧密联系,但需要强调的是,它们两者并不相同。你可以把HTML代码看作浏览器页面UI构建初始DOM的蓝图。为了正确构建每个DOM,浏览器还会修复它在蓝图中发现的问题。让我们看下面的示例,如图2.5所示。
图2.5展示了一个简单的错误HTML代码示例,页面中的head元素中错误地包含了一个paragraph元素。head元素的一般用途是展示页面的总体信息,例如,页面标题、字符编码和外部样式脚本,而不是用于类似本例中的定义页面内容。故而这里出现了错误,浏览器静默修复错误,将段落元素放入了理应放置页面内容的body元素中,构造了正确的DOM(如图2.5右侧)。
HTML规范和DOM规范 当前HTML的版本是HTML5, 可以通过 html.spec.whatwg.org/ 查看当前版本中有哪些可用特性。你若需要更易读的文档,我们向你推荐Mozilla的HTML5指南,可通过https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5 查看。
而另一方面,DOM的发展则相对缓慢。当前的DOM版本是DOM3,可以通过 dom.spec.whatwg.org/ 查看该标准。同样,Mozilla也为DOM提供了一份报告,可以通过https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model 进行查看。
在页面构建阶段,浏览器会遇到特殊类型的HTML元素——脚本元素,该元素用于包括JavaScript代码。每当解析到脚本元素时,浏览器就会停止从HTML构建DOM,并开始执行JavaScript代码。
2.2.2 执行JavaScript代码
所有包含在脚本元素中的JavaScript代码由浏览器的JavaScript引擎执行,例如,Firefox的Spidermonkey引擎, Chrome 和 Opera 的 V8引擎和Edge的(IE的)Chakra引擎。由于代码的主要目的是提供动态页面,故而浏览器通过全局对象提供了一个API 使JavaScript引擎可以与之交互并改变页面内容。
JavaScript中的全局对象 浏览器暴露给JavaScript 引擎的主要全局对象是window对象,它代表了包含着一个页面的窗口。window对象是获取所有其他全局对象、全局变量(甚至包含用户定义对象)和浏览器API的访问途径。全局window对象最重要的属性是document,它代表了当前页面的DOM。通过使用这个对象,JavaScript代码就能在任何程度上改变DOM,包括修改或移除现存的节点,以及创建和插入新的节点。
让我们看看清单2.1中所示的代码片段:
var first = document.getElementById("first");
复制代码
这个示例中使用全局document对象来通过ID选择一个元素,然后将该元素赋值给变量first。随后我们就能在该元素上用JavaScript代码来对其作各种操作,例如改变其文字内容,修改其属性,动态创建和增加新孩子节点,甚至可以从DOM上将该元素移除。
浏览器API 本书自始至终都会描述一系列浏览器内置对象和函数(例如,window和document)。不过很遗憾,浏览器所支持的全部特性已经超出本书探讨JavaScript的范围。幸好Mozilla为我们提供支持,通过https://developer.mozilla.org/en-US/docs/Web/API,你可以查找到WebAPI接口的当前状态。
对浏览器提供的基本全局对象有了基本了解后,我们可以开始看看JavaScript代码中两种不同类型的定义方式。
JavaScript代码的不同类型
我们已能大致区分出两种不同类型的JavaScript代码:全局代码和函数代码。清单2.2会帮你理解这两种类型代码的不同。
清单2.2 JavaScript全局代码和函数代码
<script>
function addMessage(element, message){
var messageElement = document.createElement("li");
messageElement.textContent = message; ⇽--- 函数代码指的是包含在函数中的代码
element.appendChild(messageElement);
}
var first = document.getElementById("first");
addMessage(first, "Page loading"); ⇽--- 全局代码指的是位于函数之外的代码
</script>
复制代码
这两类代码的主要不同是它们的位置:包含在函数内的代码叫作函数代码,而在所有函数以外的代码叫作全局代码。
这两种代码在执行中也有不同(随后你将能看到一些其他的不同,尤其在第5章中)。全局代码由JavaScript引擎(后续会做更多解释)以一种直接的方式自动执行,每当遇到这样的代码就一行接一行地执行。例如,在清单2.2中,定义addMessage函数的全局代码片段使用内置方法getElementById来获取ID为first的元素,然后再调用addMessage函数,如图2.6所示,每当遇到这些代码就会一个个执行。
反过来,若想执行函数代码,则必须被其他代码调用:既可以是全局代码(例如,由于全局代码的执行过程中执行了addMessage函数代码,所以addMessage函数被调用),也可以是其他函数,还可以由浏览器调用(后续会做更多解释)。 在页面构建阶段执行JavaScript代码 当浏览器在页面构建阶段遇到了脚本节点,它会停止HTML到DOM的构建,转而开始执行JavaScript代码,也就是执行包含在脚本元素的全局JavaScript 代码 (以及由全局代码执行中调用的函数代码)。让我们看看清单2.1中的示例。
图2.7显示了在全局JavaScript代码被执行后DOM的状态。让我们仔细看看这个执行过程。首先定义了一个addMessage函数:
function addMessage(element, message){
var messageElement = document.createElement("li");
messageElement.textContent = message;
element.appendChild(messageElement);
}
复制代码
然后通过全局document对象上的getElementById方法从DOM上获取了一个元素:
var first = document.getElementById("first");
复制代码
这段代码后紧跟着对函数addMessage 的调用:
addMessage(first, "Page loading");
复制代码
这条代码创建了一个新的li元素,然后修改了其中的文字内容,最后将其插入 DOM中。
这个例子中,JavaScript通过创建一个新元素并将其插入DOM节点修改了当前的DOM结构。一般来说,JavaScript 代码能够在任何程度上修改DOM结构:它能创建新的节点或移除现有DOM节点。但它依然不能做某些事情,例如选择和修改还没被创建的节点。这就是为什么要把script元素放在页面底部的原因。如此一来,我们就不必担心是否某个HTML元素已经加载为DOM。
一旦JavaScript引擎执行到了脚本元素中(如图2.7中的addMessage函数返回)JavaScript代码的最后一行,浏览器就退出了JavaScript执行模式,并继续余下的HTML构建为DOM节点。在这期间,如果浏览器再次遇到脚本元素,那么从HTML到DOM的构建再次暂停,JavaScript运行环境开始执行余下的JavaScript代码。需要重点注意:JavaScript应用在此时依然会保持着全局状态。所有在某个JavaScript代码执行期间用户创建的全局变量都能正常地被其他脚本元素中的JavaScript代码所访问到。其原因在于全局window对象会存在于整个页面的生存期之间,在它上面存储着所有的JavaScript变量。只要还有没处理完的HTML元素和没执行完的JavaScript代码,下面两个步骤都会一直交替执行。
1.将HTML构建为DOM。
2.执行JavaScript代码。
最后,当浏览器处理完所有HTML元素后,页面构建阶段就结束了。随后浏览器就会进入应用生命周期的第二部分:事件处理。
2.3 事件处理
客户端Web 应用是一种GUI应用,也就是说这种应用会对不同类型的事件作响应,如鼠标移动、单击和键盘按压等。因此,在页面构建阶段执行的JavaScript代码,除了会影响全局应用状态和修改DOM外,还会注册事件监听器(或处理器)。这类监听器会在事件发生时,由浏览器调用执行。有了这些事件处理器,我们的应用也就有了交互能力。在详细探讨注册事件处理器之前,让我们先从头到尾看一遍事件处理器的总体 思想。
2.3.1 事件处理器概览
浏览器执行环境的核心思想基于:同一时刻只能执行一个代码片段,即所谓的单线程执行模型。想象一下在银行柜台前排队,每个人进入一支队伍等待叫号并“处理”。但JavaScript则只开启了一个营业柜台!每当轮到某个顾客时(某个事件),只能处理该位顾客。
你所需要的仅仅是一个在营业柜台(所有人都在这个柜台排队!)的职员为你处理工作,帮你订制全年的财务计划。当一个事件抵达后,浏览器需要执行相应的事件处理函数。这里不保证用户总会极富耐心地等待很长时间,直到下一个事件触发。所以,浏览器需要一种方式来跟踪已经发生但尚未处理的事件。为实现这个目标,浏览器使用了事件队列,如图2.8所示。
所有已生成的事件(无论是用户生成的,例如鼠标移动或键盘按压,还是服务器生成的,例如Ajax事件)都会放在同一个事件队列中,以它们被浏览器检测到的顺序排列。如图2.8的中部所示,事件处理的过程可以描述为一个简单的流程图。
- 浏览器检查事件队列头;
- 如果浏览器没有在队列中检测到事件,则继续检查;
- 如果浏览器在队列头中检测到了事件,则取出该事件并执行相应的事件处理器(如果存在)。在这个过程中,余下的事件在事件队列中耐心等待,直到轮到它们被处理。
由于一次只能处理一个事件,所以我们必须格外注意处理所有事件的总时间。执行需要花费大量时间执行的事件处理函数会导致Web应用无响应!(如果听起来还不太明确,不要担心,第13章中我们还会学习事件循环,再看看它是如何损害Web应用在感受上的性能的)。
图2.8 客户端Web应用的周期从用户指定某个网站地址(或单击某个链接)开始。 由两个步骤组成:页面构建和事件处理
重点注意浏览器在这个过程中的机制,其放置事件的队列是在页面构建阶段和事件处理阶段以外的。这个过程对于决定事件何时发生并将其推入事件队列很重要,这个过程不会参与事件处理线程。
事件是异步的
事件可能会以难以预计的时间和顺序发生(强制用户以某个顺序按键或单击是非常奇怪的)。我们对事件的处理,以及处理函数的调用是异步的。如下类型的事件会在其他类型事件中发生。
浏览器事件,例如当页面加载完成后或无法加载时; 网络事件,例如来自服务器的响应(Ajax事件和服务器端事件); 用户事件,例如鼠标单击、鼠标移动和键盘事件; 计时器事件,当timeout时间到期或又触发了一次时间间隔。 Web应用的JavaScript代码中,大部分内容都是对上述事件的处理!
事件处理的概念是Web应用的核心,你在本书中的例子会反复看到:代码的提前建立是为了在之后的某个时间点执行。除了全局代码,页面中的大部分代码都将作为某个事件的结果执行。
在事件能被处理之前,代码必须要告知浏览器我们要处理特定事件。接下来看看如何注册事件处理器。 ####2.3.2 注册事件处理器 前面已经讲过了,事件处理器是当某个特定事件发生后我们希望执行的函数。为了达到这个目标,我们必须告知浏览器我们要处理哪个事件。这个过程叫作注册事件处理器。在客户端Web应用中,有两种方式注册事件。
- 通过把函数赋给某个特殊属性;
- 通过使用内置addEventListener方法。 例如,编写如下代码,将一个函数赋值给window对象上的某个特定属性onload:
window.onload = function(){};
复制代码
通过这种方式,事件处理器就会注册到load事件上(当DOM已经就绪并全部构建完成,就会触发这个事件)。(如果你对赋值操作符右边的记法有些困惑,不要担心,随后的章节中我们会细致地讲述函数)类似,如果我们想要为在文档中body元素的单击事件注册处理器,我们可以输入下述代码:
document.body.onclick = function(){};
复制代码
把函数赋值给特殊属性是一种简单而直接的注册事件处理器方式。但是,我们并不推荐你使用这种方式来注册事件处理器,这是因为这种做法会带来缺点:对于某个事件只能注册一个事件处理器。也就是说,一不小心就会将上一个事件处理器改写掉。幸运的是,还有一种替代方案:addEventListener方法让我们能够注册尽可能多的事件,只要我们需要。如下清单使用了清单2.1中的示例,向你展示这种便捷的用法。
清单2.3 注册事件处理器
<script>
document.body.addEventListener("mousemove", function() { ⇽--- 为mousemove事件注册处理器
var second = document.getElementById("second");
addMessage(second, "Event: mousemove");
});
document.body.addEventListener("click", function(){ ⇽--- 为click事件注册处理器
var second = document.getElementById("second");
addMessage(second, "Event: click");
});
</script>
复制代码
本例中使用了某个HTML元素上的内置的方法addEventListener,并在函数中指定了事件的类型(mousemove事件或click)和事件的处理器。这意味着当鼠标在页面上移动后,浏览器会调用该函数添加一条消息到ID为second的list元素上,"Event: mousemove"(类似,当body被单击时,"Event: click"也会被添加到同样的元素上)。 现在你学习了如何创建事件处理器,让我们回忆下前面看到的简单流程图,然后仔细看看事件是如何被处理的。
2.3.3 处理事件
事件处理背后的主要思想是:当事件发生时,浏览器调用相应的事件处理器。如前面提到的,由于单线程执行模型,所以同一时刻只能处理一个事件。任何后面的事件都只能在当前事件处理器完全结束执行后才能被处理!
让我们回到清单2.1中的应用。图2.9展示了在用户快速移动和单击鼠标时的执行情况。
让我们看看这里发生了什么。为了响应用户的动作,浏览器把鼠标移动和单击事件以它们发生的次序放入事件队列:第一个是鼠标移动事件,第二个是单击事件序号1。
在事件处理阶段中,事件循环会检查队列,其发现队列的前面有一个鼠标移动事件,然后执行了相应的事件处理器序号2。当鼠标移动事件处理器处理完毕后,轮到了等待在队列中的单击事件。当鼠标移动事件处理器函数的最后一行代码执行完毕后,JavaScript引擎退出事件处理器函数,鼠标移动事件完整地处理了序号3,事件循环再次检查队列。这一次,在队列的最前面,事件循环发现了鼠标单击事件并处理了该事件。一旦单击处理器执行完成,队列中不再有新的事件,事件循环就会继续循环,等待处理新到来的事件。这个循环会一直执行到用户关闭了Web应用。
图2.9 两个事件——鼠标移动和单击中的事件处理阶段示例
现在我们有了个总体的认识,理解了事件处理阶段的所有步骤。让我们看看这个过程是如何影响DOM的(如图2.10所示)。执行鼠标移动处理器时会选择第二个列表元素,其ID为second。
图2.10 当鼠标移动和鼠标点击事件都处理完成后,实例应用的DOM树结构
然后通过使用addMessage,使用文字“Event: mousemove”添加了一个新的列表项元素序号1。一旦鼠标移动处理器结束后,事件循环执行单击事件处理器,从而创建了另一个列表元素序号2,并附加在ID为second的第二个列表元素后。
对Web应用客户端的生命周期有了清晰的理解后,本书的下一部分,我们会开始聚焦于JavaScript语言,理清函数的来龙去脉。
2.4 小结
- 浏览器接收的HTML代码用作创建DOM的蓝图,它是客户端Web应用结构的内部展示阶段。
- 我们使用JavaScript代码来动态地修改DOM以便给Web应用带来动态行为。
- 客户端Web应用的执行分为两个阶段。
- 页面构建代码是用于创建DOM的,而全局JavaScript代码是遇到script节点时执行的。在这个执行过程中,JavaScript代码能够以任意程度改变当前的DOM,还能够注册事件处理器——事件处理器是一种函数,当某个特定事件(例如,一次鼠标单击或键盘按压)发生后会被执行。注册事件处理器很容易:使用内置的addEventListener方法。
- 事件处理——在同一时刻,只能处理多个不同事件中的一个,处理顺序是事件生成的顺序。事件处理阶段大量依赖事件队列,所有的事件都以其出现的顺序存储在事件队列中。事件循环会检查事件队列的队头,如果检测到了一个事件,那么相应的事件处理器就会被调用。
2.5 练习
1.客户端Web应用的两个生命周期阶段是什么?
2.相比将事件处理器赋值给某个特定元素的属性上,使用addEventListener方法来注册事件处理器的优势是什么?
3.JavaScript引擎在同一时刻能处理多少个事件?
4.事件队列中的事件是以什么顺序处理的?