img

书名:JavaScript高级程序设计(第3版)

作者:Nicholas C.Zakas

译者:李松峰, 曹力

ISBN:978-7-115-27579-0

本书由北京图灵文化发展有限公司发行数字版。版权所有,侵权必究。

您购买的图灵电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。



目录

版权声明

前言

第1章 JavaScript简介

1.1 JavaScript简史

1.2 JavaScript实现

1.3 JavaScript版本

1.4 小结

第2章 在HTML中使用JavaScript

2.1 <script>元素

2.2 嵌入代码与外部文件

2.3 文档模式

2.4 <noscript>元素

2.5 小结

第3章 基本概念

3.1 语法

3.2 关键字和保留字

3.3 变量

3.4 数据类型

3.5 操作符

3.6 语句

3.7 函数

3.8 小结

第4章 变量、作用域和内存问题

4.1 基本类型和引用类型的值

4.2 执行环境及作用域

4.3 垃圾收集

4.4 小结

第5章 引用类型

5.1 Object类型

5.2 Array类型

5.3 Date类型

5.4 RegExp类型

5.5 Function类型

5.6 基本包装类型

5.7 单体内置对象

5.8 小结

第6章 面向对象的程序设计

6.1 理解对象

6.2 创建对象

6.3 继承

6.4 小结

第7章 函数表达式

7.1 递归

7.2 闭包

7.3 模仿块级作用域

7.4 私有变量

7.5 小结

第8章 BOM

8.1 window对象

8.2 location对象

8.3 navigator对象

8.4 screen对象

8.5 history对象

8.6 小结

第9章 客户端检测

9.1 能力检测

9.2 怪癖检测

9.3 用户代理检测

9.3.1 用户代理字符串的历史

9.3.2 用户代理字符串检测技术

9.3.3 完整的代码

9.3.4 使用方法

9.4 小结

第10章 DOM

10.1 节点层次

10.1.1 Node类型

10.1.2 Document类型

10.1.3 Element类型

10.1.4 Text类型

10.1.5 Comment类型

10.1.6 CDATASection类型

10.1.7 DocumentType类型

10.1.8 DocumentFragment类型

10.1.9 Attr类型

10.2 DOM操作技术

10.3 小结

第11章 DOM扩展

11.1 选择符API

11.2 元素遍历

11.3 HTML5

11.4 专有扩展

11.5 小结

第12章 DOM2和DOM3

12.1 DOM变化

12.2 样式

12.3 遍历

12.4 范围

12.5 小结

第13章 事件

13.1 事件流

13.2 事件处理程序

13.3 事件对象

13.4 事件类型

13.4.1 UI事件

13.4.2 焦点事件

13.4.3 鼠标与滚轮事件

13.4.4 键盘与文本事件

13.4.5 复合事件

13.4.6 变动事件

13.4.7 HTML5事件

13.4.8 设备事件

13.4.9 触摸与手势事件

13.5 内存和性能

13.6 模拟事件

13.7 小结

第14章 表单脚本

14.1 表单的基础知识

14.2 文本框脚本

14.3 选择框脚本

14.4 表单序列化

14.5 富文本编辑

14.6 小结

第15章 使用Canvas绘图

15.1 基本用法

15.2 2D上下文

15.3 WebGL

15.4 小结

第16章 HTML5脚本编程

16.1 跨文档消息传递

16.2 原生拖放

16.3 媒体元素

16.4 历史状态管理

16.5 小结

第17章 错误处理与调试

17.1 浏览器报告的错误

17.2 错误处理

17.3 调试技术

17.4 常见的IE错误

17.5 小结

第18章 JavaScript与XML

18.1 浏览器对XML DOM的支持

18.2 浏览器对XPath的支持

18.3 浏览器对XSLT的支持

18.4 小结

第19章 E4X

19.1 E4X的类型

19.2 一般用法

19.3 其他变化

19.4 全面启用E4X

19.5 小结

第20章 JSON

20.1 语法

20.2 解析与序列化

20.3 小结

第21章 Ajax与Comet

21.1 XMLHttpRequest对象

21.2 XMLHttpRequest 2级

21.3 进度事件

21.4 跨源资源共享

21.5 其他跨域技术

21.6 安全

21.7 小结

第22章 高级技巧

22.1 高级函数

22.2 防篡改对象

22.3 高级定时器

22.4 自定义事件

22.5 拖放

22.6 小结

第23章 离线应用与客户端存储

23.1 离线检测

23.2 应用缓存

23.3 数据存储

23.3.1 Cookie

23.3.2 IE用户数据

23.3.3 Web存储机制

23.3.4 IndexedDB

23.4 小结

第24章 最佳实践

24.1 可维护性

24.2 性能

24.3 部署

24.4 小结

第25章 新兴的API

25.1 requestAnimationFrame()

25.2 Page Visibility API

25.3 Geolocation API

25.4 File API

25.5 Web计时

25.6 Web Workers

25.7 小结

附录A ECMAScript Harmony

附录B 严格模式

附录C JavaScript库

附录D JavaScript工具

   


版权声明

Original edition, entitled Professional JavaScript for Web Developers 3rd Edition , by Nicholas C. Zakas,ISBN 978-1-118-02669-4, published by John Wiley & Sons, Inc.

Copyright ©2012 by John Wiley & Sons, Inc.,All rights reserved.This translation published under License.

Translation edition published by POSTS & TELECOM PRESS Copyright ©2012.

Copies of this book sold without a Wiley sticker on the cover are unauthorized and illegal.

本书简体中文版由John Wiley & Sons, Inc.授权人民邮电出版社独家出版。

本书封底贴有John Wiley & Sons, Inc.激光防伪标签,无标签者不得销售。

版权所有,侵权必究。


20多年的职业生涯,我也长出了白头发。回首往事,曾经对我的职业道路产生过重要影响的技术和人历历在目。如果让我只说一种技术,一种对我产生了最大正面影响的技术,那么就是JavaScript。说实话,我也并非一直都是JavaScript的信徒。跟许多人一样,我以前也把它当作一门玩具语言,认为它只能用来做一些旋转的横幅广告,或者在页面中添加一些有意思的交互效果作为装饰。我原来是做服务器端开发的,我们都对这种玩具语言不感冒,该死的!可是,后来Ajax出现了。

永远也忘不了当时无孔不入的Ajax,大家都认为它是一种非常酷、非常新,同时极具创造性的技术。我也开始了解它,阅读相关资料。知道这门曾被我嗤之以鼻的玩具语言如今被每一位专业Web开发人员津津乐道之后,我感到很震惊。突然,我的看法就转变了。随着探索Ajax的继续深入,我认识到JavaScript的强大威力,急切地想了解它能提供的所有“法宝”。于是,我全身心地投入到学习JavaScript之中,不仅努力学习这门语言,还加入了jQuery项目团队,专门从事客户端开发。我的日子过得很爽。

对JavaScript了解得越深,接触的开发人员就越多,其中不乏今天在我眼里依然是巨星和导师级的人物。尼古拉斯·泽卡斯(本书作者)就是这样一位开发人员。我一直记得在读本书第2版时心中油然而生的喜悦之情,虽然我也有多年的积累,但仍然从中学到了很多新东西。这本书实实在在、深入浅出,读来就好像尼古拉斯对不同层次的读者都了如指掌,所以他的风格才那么贴切自然。对于技术书来说,这是非常突出的一个特色。多数作者都想靠坚深的技术给人留下印象,但这本书不同。所以,它很快就成为了我案头必备的书,我也会向那些有志全面掌握JavaScritp的开发人员推荐这本书。我希望每个人对这本书都能有跟我一样的体会,认识到它的价值所在。

后来,在一次jQuery大会上,我荣幸地见到了尼古拉斯本人。站在我面前的是一位世界顶级的JavaScript开发人员,而且正负责世界上最重要的一个Web站点(雅虎)。尼古拉斯是我见过的最随和的人之一。真的,见到他的时候我有一种追星族的幻觉。但他就是那么一个活生生的人,一个想帮助开发人员成就梦想的人。不仅他的书改变了我对JavaScript的认识,而且尼古拉斯这个人,也让我愿意接近,愿意了解。

听说尼古拉斯要请我作序,我激动得不知道说什么才好。在此,我代表大牛来为本书暖场。这个序也是他本人有多么令人景仰的一个明证。不过,更重要的是,这也给了我一个机会,让我能跟大家分享自己为什么觉得这本书如此重要。我看过很多JavaScript图书,的确也有很多令人叹服的佳作。但在我看来,这本书为读者成为全方位的JavaScript高手提供了“一揽子方案”。

这本书从介绍表达式和变量声明开始,平滑地过渡到了闭包、面向对象开发等高级主题。与那些把大量篇幅花在讲解背景知识上的书,以及那些让人感觉好像是要使用JavaScript开发导弹制导系统的书相比,这本书让人感觉细致周到、亲切自然。这是一本写给“普通人”的书,它能让你编写出引以为荣的代码,构建出令人叫绝的网站。

雷·邦戈(Rey Bango)

微软公司高级布道师,jQuery项目团队核心成员


前言

献给我的父母,是他们永远给我支持和鼓励。

从驱动全球商业、贸易及管理领域不计其数的复杂应用程序的角度来看,说JavaScript已经成为当今世界上最流行的编程语言一点儿都不为过。

JavaScript是基于Java的一种非常松散的面向对象语言,也是Web开发中极受欢迎的一门语言。JavaScript,尽管它的语法和编程风格与Java都很相似,但它却不是Java的“轻量级”版本。JavaScript是一种全新的动态语言,它植根于全球数亿网民都在使用的Web浏览器之中,致力于增强网站和Web应用程序的交互性。

在本书中,我们将对JavaScript追根溯源,从它在最早的Netscape浏览器中诞生谈起,一直谈到今天的它对DOM和Ajax的强大支持。读者将通过本书掌握如何运用和扩展这门语言,从而更好地满足自己的需求,以及如何实现客户端与服务器的无缝通信,而又不必求助于Java或隐藏的网页框架(frame元素)。一言以蔽之,本书将教会你在面对各种常见的Web开发问题时,如何拿出自己的JavaScript解决方案。

本书读者对象

本书将下列三类人员作为目标读者:

  1. 熟悉面向对象编程、经验丰富而又打算学习JavaScript的开发人员,JavaScript毕竟与Java、C++等传统OO语言存在着诸多联系;

  2. 有意提升自己网站和Web应用程序易用性的Web开发人员;

  3. 希望全面深入地理解这门语言的初级JavaScript开发人员。

此外,本书也适合熟悉下列相关技术的读者阅读:

  1. Java

  2. PHP

  3. ASP.NET

  4. HTML

  5. CSS

  6. XML

本书不适合没有计算机基础知识的初学者,也不适合只想为网站添加简单交互功能的读者。建议这些朋友学习阅读Beginning JavaScript, 3rd Edition (Wiley, 2007)一书1

1 本书中文版《JavaScript入门经典(第3版)》已经由清华大学出版社出版。——译者注(以下脚注如无特殊说明,均为译者注)

本书内容

本书提供了JavaScript开发人员必须掌握的内容,全面涵盖了JavaScript的各种高级、有用的特性。

本书首先介绍了JavaScript的起源及其发展现状,随后讨论了构成JavaScript实现的各个组成部分,重点讲解了ECMAScript和DOM标准。此外,还对不同Web浏览器的JavaScript实现之间存在的差异,给出了相应的说明。

在此基础上,本书从讲解JavaScript的基本概念入手,探讨了JavaScript面向对象程序设计和继承的方式,以及如何在HTML等标记语言中使用它。在深入剖析了事件和事件处理之后,又解释了各种浏览器检测技术。本书还探讨了HTML5、Selectors API和File API等一系列新API。

本书最后一部分专门讨论了高级主题,涉及性能和内存优化、最佳实践以及对JavaScript未来的展望。

本书结构

本书共25章,各章简介如下。

第1章“JavaScript简介” ,讲述了JavaScript的起源:因何而生,如何发展,现状如何。涉及的概念主要有JavaScript与ECMAScript之间的关系、DOM(Document Object Model,文档对象模型)、BOM(Browser Object Model,浏览器对象模型)。此外,还将讨论ECMA(European Computer Manufacturer's Association,欧洲计算机制造商协会)和W3C(World Wide Web Consortium,万维网联盟)制定的一些相关标准。

第2章“在HTML中使用JavaScript” ,介绍了如何在HTML中使用JavaScript创建动态网页。这一章不仅展示了在网页中嵌入JavaScript的各种方式,还讨论了JavaScript内容类型(content-type)及其与<script> 元素的关系。

第3章“基本概念” ,讨论了JavaScript语言的基本概念,包括语法和流控制语句。这一章也分析了JavaScript与其他基于C的语言在语法上的相同和不同之处,还介绍了与内置操作符有关的类型转换问题。

第4章“变量、作用域和内存问题” ,探讨了JavaScript如何处理其松散类型的变量。这一章还讨论了原始值和引用值之间的差别,以及与变量有关的执行环境的相应内容。最后,通过介绍JavaScript的垃圾收集机制,解释了变量在退出作用域时释放其内存的问题。

第5章“引用类型” ,详尽介绍了JavaScript内置的所有引用类型,如ObjectArray 。这一章对ECMA-262规范中描述的每一种引用类型既做了理论上的阐释,又从浏览器实现的角度给出了 介绍。

第6章“面向对象的程序设计” ,讲述了在JavaScript中如何实现面向对象的程序设计。由于JavaScript没有类的概念,因此这一章从对象创建和继承的层面上展示了一些流行的技术。此外,这一章还讲解了函数原型的概念,并对函数原型与整个面向对象方法的关系进行了探讨。

第7章“函数表达式” ,集中介绍了JavaScript中最为强大的一个特性——函数表达式。相关的内容涉及闭包、this 对象的角色、模块模式和创建私有对象成员等。

第8章“BOM” ,介绍BOM(Browser Object Model,浏览器对象模型),即负责处理与浏览器自身有关的交互操作的对象集合。这一章全面介绍了每一个BOM对象,包括windowdocumentlocationnavigatorscreen

第9章“客户端检测” ,讨论了检测客户端机器及其支持特性的各种手段,包括特性检测及用户代理字符串检测的不同技术。这一章还就每种手段的优缺点及适用情形给出了详细说明。

第10章“DOM” ,介绍DOM(Document Object Model,文档对象模型),即DOM1规定的JavaScript中的DOM对象。这一章也简要介绍了XML及其与DOM的关系,为深入探讨所有DOM规范及其定义的操作网页的方式奠定了基础。

第11章“DOM扩展” ,介绍了其他API以及浏览器本身为DOM添加的各种功能。涉及内容包括Selectors API、Element Traversal API和HTML5扩展。

第12章“DOM2和DOM3” ,在前两章的基础上继续探讨了DOM2和DOM3中新增的DOM属性、方法和对象。这一章还讨论了IE与其他浏览器的兼容性问题。

第13章“事件” ,解释了JavaScript中事件的本质,对遗留机制的支持,以及DOM对事件机制的重新定义。这一章讨论了多种设备,包括Wii和iPhone。

第14章“表单脚本” ,讲述如何使用JavaScript增强表单的交互性,突破浏览器的局限性。这一章的讨论主要围绕单个表单元素如文本框、选择框,以及围绕数据验证和操作展开。

第15章“使用Canvas绘图” ,讨论了<canvas> 标签以及如何通过它来动态绘图。不仅涵盖2D上下文,也将讨论WebGL(3D)上下文,可以为创建动画和游戏夯实基础。

第16章“HTML5脚本编程” ,介绍了HTML5规定的JavaScript API,涉及跨文档传递消息、拖放API和以编程方式控制<audio><video> 元素,以及管理历史状态。

第17章“错误处理与调试” ,讨论浏览器如何处理JavaScript代码错误,并展示了一些处理错误的方式。这一章针对每种浏览器分别讨论了相应的调试工具和技术,还给出了简化调试工作的建议。

第18章“JavaScript与XML” ,展示了JavaScript中用于读取和操作XML(eXtensible Markup Language,可扩展标记语言)的特性。这一章分析了不同浏览器提供的XML支持和对象的差异,给出了编写跨浏览器代码的简易方法。此外,这一章还介绍了用于在客户端转换XML数据的XSLT(eXtensible Stylesheet Language Transformations,可扩展样式表语言转换)技术。

第19章“E4X” ,讨论了E4X(ECMAScript for XML,ECMAScript中的XML扩展);设计E4X的出发点是简化XML处理任务。这一章探讨了在处理XML时,使用E4X与使用DOM相比有哪些 优势。

第20章“JSON” ,介绍了作为XML替代格式的JSON,包含浏览器原生支持的JSON解析和序列化,以及使用JSON时要注意的安全问题。

第21章“Ajax与Comet” ,讲解了常用的Ajax技术,包括使用XMLHttpRequest 对象及CORS(Cross-Origin Resource Sharing,跨来源资源共享)API实现跨域Ajax通信。这一章展示了浏览器在实现与支持方面存在的差异,同时也给出了一些使用建议。

第22章“高级技巧” ,深入讲解了一些JavaScript中较复杂的模式,包括函数科里化(currying)、部分函数应用和动态函数。这一章还讨论了如何创建自定义的事件框架和使用ECMAScript 5创建防篡改对象。

第23章“离线应用与客户端存储” ,讨论了如何检测应用离线以及在客户端机器中存储数据的各种技术。先从受到最广泛支持的特性cookie谈起,继而介绍了新兴的客户端存储技术,如Web Storage和IndexedDB。

第24章“最佳实践” ,探讨了在企业级环境中使用JavaScript的各种方式。其中,着眼于提高可维护性的内容包括编码技巧、格式化和通用编程实践。这一章还介绍了改善代码执行性能及速度优化的一些技术。最后讨论了部署问题,包括如何创建构建过程。

第25章“新兴的API” ,介绍了为增强浏览器中的JavaScript而创建的新API。虽然这些API还没有得到完整或全面的支持,但它们已经崭露头角,有些浏览器也已经部分地实现了这些API。这一章的内容主要是Web计时和文件API。

使用示例

要运行本书中的示例,需要安装下列软件:

完整的示例源代码可以从http://www.wrox.com/ 中下载(下载步骤见“源代码”一节)2

2 读者也可以在图灵社区(http://www.ituring.com.cn/ )本书的页面中免费注册下载。

排版约定

为了让读者更好地理解本书内容,同时把握住全书的重点,本书将采用以下排版约定。

这种带警告图标的方框样式,表示与上下文相关的重要的、需要牢记的内容。

 

这种带钢笔图标的方框样式,表示与上下文相关的说明、提示、技巧、窍门和背景知识。

正文中的样式说明如下。

  1. 新术语及重要的词汇在首次出现时使用加粗字体 以示强调;

  2. 表示键盘命令组合的方式是Ctrl+A;

  3. 正文中的代码使用等宽字体,如persistence.properties

  4. 代码有两种样式:

    var obj = new Object();         // 大多数示例代码都没有加粗
    
    
    
    var obj = new Object();        // 加粗的代码表示在上下文中特别重要
    
    
    

源代码

在学习本书示例代码时,可以手工敲入所有代码,也可以使用随书的源代码文件。本书所有源代码都可以到www.wrox.com 中下载。登录该站点后,先找到本书(通过搜索或者图书列表),打开本书页面后,单击其中的Download Code链接,就可以下载本书的源代码了3 。对于包含在下载文件中的源代码,书中会添加以下图标:

3 翻译本书时,wrox.com中下载本书代码的短地址为:http://tinyurl.com/projs-3nd-code

本书代码示例旁边会附有文件名,从中可以找到对应的代码片段。文件名的格式如下:

代码片段所在的文件名

由于很多书的书名看起来类似,所以更好的方式是通过书的ISBN来搜索它。本书原版的ISBN是978-1-118-02669-4。

下载完代码后,请使用解压缩软件将其解压缩。此外,读者也可以登录Wrox代码下载主页www.wrox.com/dynamic/books/download.aspx ,查找并下载本书及其他Wrox图书的示例代码。

勘误信息 4

4 您也可以登录图灵社区(http://www.ituring.com.cn/ ),在本书页面中提交您发现的错误。

我们尽最大努力确保正文和代码没有错误。可是,金无足赤,错误在所难免。如果读者发现我们书中的任何错误,例如错别字或代码片段无法运行等,希望您能及时给我们反馈。您提交的勘误不仅能让其他读者受益,而且也能帮助我们进一步提高图书质量。

本书原版的勘误页面位于www.wrox.com 中,登录该站点后可以通过搜索或查询图书列表找到本书页面,然后单击页面中的Errata(勘误)链接。然后可以看到其他读者已经提交并由Wrox的编辑发布的勘误信息。另外,在www.wrox.com/misc-pages/booklist.shtml 页面中也可以找到本书及勘误页面的链接。

如果读者在本书勘误页面中没有发现“你的”错误,麻烦打开www.wrox.com/contact/techsupport.shtml 页面,填写其中的表单并将错误发送给我们。我们会认真核对您提交的错误,如果错误确实存在,我们将把它补充到本书勘误页面中。同时,也将根据您提供的信息对本书后续版本加以改正。

p2p.wrox.com

如果您想与本书作者或者其他读者沟通,请加入P2P论坛(p2p.wrox.com )。该论坛是基于Web的系统,您可以在其中发表与Wrox图书及相关技术有关的帖子,并同其他读者或者技术用户交流。论坛提供了一个订阅功能,您可以选择当发表您感兴趣的帖子时通过邮件通知您。Wrox的作者、编辑、其他行业的专家以及与您正在读同一本书的读者都会出现在这个论坛中。

http://p2p.wrox.com 中,有很多论坛不仅对您理解本书有帮助,而且还会对开发应用程序有帮助。要加入这个论坛,请按下面几个步骤进行:

  1. 登录到p2p.wrox.com ,单击Register(注册)链接;

  2. 阅读使用条款并单击 Agree(同意);

  3. 完成必填信息和您愿意提供的可选信息,然后单击 Submit(提交);

  4. 随后,您会收到一封电子邮件,其中包含如何验证账号和完成注册过程的信息。

如果不加入P2P论坛,虽然也可以阅读其中的帖子,但却不能发表帖子,只有注册后才能发表。

在加入论坛后,既可以发表新帖子也可以回复其他用户的帖子。可以在任何时间上网浏览论坛中的帖子。如果希望将某个论坛中的新帖子通过电子邮件发送给您,请在论坛列表中单击与论坛名相关的Subscribe to this Forum(订阅这个论坛)图标。

如果想了解有关如何使用Wrox P2P的更多信息,请阅读包含论坛规则、P2P及Wrox图书常见问题的P2P FAQ;要阅读 FAQ,可以在任何P2P页面中单击FAQ链接。

致谢

虽然作者的名字被印在了封面上,但一个人是不可能完成这本书的,我想感谢与出版本书有关的一些人。

首先,感谢John Wiley & Sons继续给我写作的机会。当时,出版本书第1版时,他们是唯一愿意承担风险的一家出版社。对此,我将永远铭记于心。

感谢John Wiley & Sons的编辑人员,特别是Kevin Kent和John Peloquin,他们卓有成效的工作使我保持了坦诚直率的风格,也解决了我在写作期间不断变更内容的问题。

还要感谢对本书草稿给出反馈意见的所有人:Rob Friesel、Sergey Ilinsky、Dan Kielp、Peter-Paul Koch、Jeremy McPeak、Alex Petrescu、Dmitry Soshnikov和Juriy “Kangax” Zaytsev。你们的宝贵意见让我自己都为本书感到骄傲。

我想特别感谢Brendan Eich,感谢他纠正了第1章中有关JavaScript历史的细节问题。

最后,当然也是非常重要的,感谢Rey Bango为本书作序。很高兴在与Rey通过网络认识几年之后,终于在2010年有缘相见。他是这个行业里真正出色的人,我非常荣幸能请到他为本书作序。

关于技术编辑

John Peloquin是一位有十多年JavaScript经验的前端工程师,开发过各种规模的应用。John拥有加州大学伯克利分校的数学学士学位,目前在一家致力于卫生保健的创业公司担任开发主管。在编辑本书之前,John编辑过Jeremy McPeak的JavaScript 24-Hour Trainer (Wiley,2010)。编写代码和收集勘误之余,John经常沉迷于数学、哲学和魔术。


第1章 JavaScript简介

本章内容

JavaScript诞生于1995年。当时,它的主要目的是处理以前由服务器端语言(如Perl)负责的一些输入验证操作。在JavaScript问世之前,必须把表单数据发送到服务器端才能确定用户是否没有填写某个必填域,是否输入了无效的值。Netscape Navigator希望通过JavaScript来解决这个问题。在人们普遍使用电话拔号上网的年代,能够在客户端完成一些基本的验证任务绝对是令人兴奋的。毕竟,拨号上网的速度之慢,导致了与服务器的每一次数据交换事实上都成了对人们耐心的一次考验。

自此以后,JavaScript逐渐成为市面上常见浏览器必备的一项特色功能。如今,JavaScript的用途早已不再局限于简单的数据验证,而是具备了与浏览器窗口及其内容等几乎所有方面交互的能力。今天的JavaScript已经成为一门功能全面的编程语言,能够处理复杂的计算和交互,拥有了闭包、匿名(lamda,拉姆达)函数,甚至元编程等特性。作为Web的一个重要组成部分,JavaScript的重要性是不言而喻的,就连手机浏览器,甚至那些专为残障人士设计的浏览器等非常规浏览器都支持它。当然,微软的例子更为典型。虽然有自己的客户端脚本语言VBScript,但微软仍然在Internet Explorer的早期版本中加入了自己的JavaScript实现1

1 对IE而言,当我们提到JavaScript时,实际上就是指IE对JavaScript(ECMAScript)的实现——JScript。最早的JScript基于Netscape JavaScript 1.0开发,于1996年8月随同Internet Explorer 3.0发布。

JavaScript从一个简单的输入验证器发展成为一门强大的编程语言,完全出乎人们的意料。应该说,它既是一门非常简单的语言,又是一门非常复杂的语言。说它简单,是因为学会使用它只需片刻功夫;而说它复杂,是因为要真正掌握它则需要数年时间。要想全面理解和掌握JavaScript,关键在于弄清楚它的本质、历史和局限性。

在Web日益流行的同时,人们对客户端脚本语言的需求也越来越强烈。那个时候,绝大多数因特网用户都使用速度仅为28.8kbit/s的“猫”(调制解调器)上网,但网页的大小和复杂性却不断增加。为完成简单的表单验证而频繁地与服务器交换数据只会加重用户的负担。想象一下:用户填写完一个表单,单击“提交”按钮,然后等待30秒钟,最终服务器返回消息说有一个必填字段没有填好……当时走在技术革新最前沿的Netscape公司,决定着手开发一种客户端语言,用来处理这种简单的验证。

当时就职于Netscape公司的布兰登·艾奇(Brendan Eich),开始着手为计划于1995年2月发布的Netscape Navigator 2开发一种名为LiveScript的脚本语言——该语言将同时在浏览器和服务器中使用(它在服务器上的名字叫LiveWire)。为了赶在发布日期前完成LiveScript的开发,Netscape与Sun公司建立了一个开发联盟。在Netscape Navigator 2正式发布前夕,Netscape为了搭上媒体热炒Java的顺风车,临时把LiveScript改名为JavaScript。

由于JavaScript 1.0获得了巨大成功,Netscape随即在Netscape Navigator 3中又发布了JavaScript 1.1。Web虽然羽翼未丰,但用户关注度却屡创新高。在这样的背景下,Netscape把自己定位为市场领袖型公司。与此同时,微软决定向与Navigator竞争的自家产品Internet Explorer浏览器投入更多资源。Netscape Navigator 3发布后不久,微软就在其Internet Explorer 3中加入了名为JScript的JavaScript实现(命名为JScript是为了避开与Netscape有关的授权问题)。以现在的眼光来看,微软1996年8月为进入Web浏览器领域而实施的这个重大举措,是导致Netscape日后蒙羞的一个标志性事件。然而,这个重大举措同时也标志着JavaScript作为一门语言,其开发向前迈进了一大步。

微软推出其JavaScript实现意味着有了3个不同的JavaScript版本:Netscape Navigator中的JavaScript、Internet Explorer中的Jscript和ScriptEase中的CEnvi。与C及其他编程语言不同,当时还没有标准规定JavaScript的语法和特性,3个不同版本并存的局面已经完全暴露了这个问题。随着业界担心的日益加剧,JavaScript的标准化问题被提上了议事日程。

1997年,以JavaScript 1.1为蓝本的建议被提交给了欧洲计算机制造商协会(Ecma,European Computer Manufacturers Association)。该协会指定39号技术委员会(TC39,Technical Committee #39)负责“标准化一种通用、跨平台、供应商中立的脚本语言的语法和语义”(http://www.ecma-international.org/memento/TC39.htm )。TC39由来自Netscape、Sun、微软、Borland及其他关注脚本语言发展的公司的程序员组成,他们经过数月的努力完成了ECMA-262——定义一种名为ECMAScript(发音为“ek-ma-script”)的新脚本语言的标准。

第二年,ISO/IEC(International Organization for Standardization and International Electrotechnical Commission,国标标准化组织和国际电工委员会)也采用了ECMAScript作为标准(即ISO/IEC-16262)。自此以后,浏览器开发商就开始致力于将ECMAScript作为各自JavaScript实现的基础,也在不同程度上取得了成功。

虽然JavaScript和ECMAScript通常都被人们用来表达相同的含义,但JavaScript的含义却比ECMA-262中规定的要多得多。没错,一个完整的JavaScript实现应该由下列三个不同的部分组成(见图1-1)。

图 1-1

1.2.1 ECMAScript

由ECMA-262定义的ECMAScript与Web浏览器没有依赖关系。实际上,这门语言本身并不包含输入和输出定义。ECMA-262定义的只是这门语言的基础,而在此基础之上可以构建更完善的脚本语言。我们常见的Web浏览器只是ECMAScript实现可能的宿主环境 之一。宿主环境不仅提供基本的ECMAScript实现,同时也会提供该语言的扩展,以便语言与环境之间对接交互。而这些扩展——如DOM,则利用ECMAScript的核心类型和语法提供更多更具体的功能,以便实现针对环境的操作。前面介绍过的Node以及众所周知的Adobe Flash也都是宿主环境。

既然ECMA-262标准没有参照Web浏览器,那它都规定了些什么内容呢?大致说来,它规定了这门语言的下列组成部分:

ECMAScript就是对实现该标准规定的各个方面内容的语言的描述。JavaScript实现了ECMAScript,Adobe ActionScript同样也实现了ECMAScript。

1. ECMAScript的版本

ECMAScript的不同版本又称为版次,以第x 版表示(意即描述特定实现的ECMA-262规范的第x 个版本)。ECMA-262的最近一版是第5版,发布于2009年。而ECMA-262的第1版本质上与Netscape的JavaScript 1.1相同——只不过删除了所有针对浏览器的代码并作了一些较小的改动:ECMA-262要求支持Unicode标准(从而支持多语言开发),而且对象也变成了平台无关的(Netscape JavaScript 1.1的对象在不同平台中的实现不一样,例如Date 对象)。这也是JavaScript 1.1和1.2与ECMA-262第1版不一致的主要原因。

ECMA-262第2版主要是编辑加工的结果。这一版中内容的更新是为了与ISO/IEC-16262保持严格一致,没有作任何新增、修改或删节处理。因此,一般不使用第2版来衡量ECMAScript实现的兼容性。

ECMA-262第3版才是对该标准第一次真正的修改。修改的内容涉及字符串处理、错误定义和数值输出。这一版还新增了对正则表达式、新控制语句、try-catch 异常处理的支持,并围绕标准的国际化做出了一些小的修改。从各方面综合来看,第3版标志着ECMAScript成为了一门真正的编程语言。

ECMA-262第4版对这门语言进行了一次全面的检核修订。由于JavaScript在Web上日益流行,开发人员纷纷建议修订ECMAScript,以使其能够满足不断增长的Web开发需求。作为回应,ECMA TC39重新召集相关人员共同谋划这门语言的未来。结果,出台后的标准几乎在第3版基础上完全定义了一门新语言。第4版不仅包含了强类型变量、新语句和新数据结构、真正的类和经典继承,还定义了与数据交互的新方式。

与此同时,TC39下属的一个小组也提出了一个名为ECMAScript 3.1的替代性建议,该建议只对这门语言进行了较少的改进。这个小组认为第4版给这门语言带来的跨越太大了。因此,该小组建议对这门语言进行小幅修订,能够在现有JavaScript引擎基础上实现。最终,ES3.1附属委员会获得的支持超过了TC39,ECMAS-262第4版在正式发布前被放弃。

ECMAScript 3.1成为ECMA-262第5版,并于2009年12月3日正式发布。第5版力求澄清第3版中已知的歧义并增添了新的功能。新功能包括原生JSON对象(用于解析和序列化JSON数据)、继承的方法和高级属性定义,另外还包含一种严格模式,对ECMAScript引擎解释和执行代码进行了补充说明。

2. 什么是ECMAScript兼容

ECMA-262给出了ECMAScript兼容的定义。要想成为ECMAScript的实现,则该实现必须做到:

此外,兼容的实现还可以进行下列扩展。

上述要求为兼容实现的开发人员基于ECMAScript开发一门新语言提供了广阔的空间和极大的灵活性,这也从另一个侧面说明了ECMAScript受开发人员欢迎的原因。

3. Web浏览器对ECMAScript的支持

1996年,Netscape Navigator 3捆绑发布了JavaScript 1.1。而相同的JavaScript 1.1设计规范随后作为对新标准(ECMA-262)的建议被提交给Ecma。伴随着JavaScript的迅速走红,Netscape豪情满怀地着手开发JavaScript 1.2。然而,问题是Ecma当时还没有接受Netscape的建议。

Netscape Navigator 3发布后不久,微软也推出了Internet Explorer 3。微软在IE的这一版中捆绑了JScript 1.0,很多人都认为JScript 1.0与JavaScript 1.1应该是一样的。但是,由于没有文档依据,加之不适当的特性模仿,JScript 1.0还是很难与JavaScript 1.1相提并论。

1997年,内置JavaScript 1.2的Netscape Navigator 4发布;而到这一年年底,ECMA-262第1版也被接受并实现了标准化。结果,虽然ECMAScript被认为是基于JavaScript 1.1制定的,但JavaScript 1.2与ECMAScript的第1版并不兼容。

JScript的升级版是Internet Explorer 4中内置的JScript 3.0(随同微软IIS 3.0发布的JScript 2.0从来也没有移植到浏览器中)。微软通过媒体大肆宣传JScript 3.0是世界上第一个ECMA兼容的脚本语言,但当时的ECMA-262尚未定稿。于是,JScript 3.0与JavaScript 1.2都遭遇了相同的尴尬局面——谁都没有按照最终的ECMAScript标准来实现。

Netscape决定更新其JavaScript实现,即在Netscape Navigator 4.06中发布JavaScript 1.3,从而做到了与ECMA-262的第一个版本完全兼容。在JavaScript 1.3中,Netscape增加了对Unicode标准的支持,并在保留JavaScript 1.2新增特性的同时实现了所有对象的平台中立化。

在Netscape以Mozilla项目的名义开放其源代码时,预期JavaScript 1.4将随同Netscape Navigator 5一道发布。然而,一个激进的决定,彻底重新设计Netscape代码,打乱了原有计划。后来,JavaScript 1.4只发布了针对Netscape Enterprise Server的服务器版,而没有内置于Web浏览器中。

到了2008年,五大主流Web浏览器(IE、Firefox、Safari、Chrome和Opera)全部做到了与ECMA-262兼容。IE8是第一个着手实现ECMA-262第5版的浏览器,并在IE9中提供了完整的支持。Firefox 4也紧随其后做到兼容。下表列出了ECMAScript受主流Web浏览器支持的情况。

浏 览 器 ECMAScript兼容性 浏 览 器 ECMAScript兼容性
Netscape Navigator 2 Opera 6~7.1 第2版
Netscape Navigator 3 Opera 7.2+ 第3版
Netscape Navigator 4~4.05 Safari 1~2.0.x 第3版*
Netscape Navigator 4.06~4.79 第1版 Safari 3.x 第3版
Netscape 6+(Mozilla 0.6.0+) 第3版 Safari 4.x~5.x 第5版*
IE3 Chrome 1+ 第3版
IE4 Firefox 1~2 第3版
IE5 第1版 Firefox 3.0.x 第3版
IE5.5~IE7 第3版 Firefox 3.5~3.6 第5版*
IE8 第5版* Firefox 4.0 + 第5版
IE9+ 第5版

* 不完全兼容的实现

1.2.2 文档对象模型(DOM)

文档对象模型(DOM,Document Object Model)是针对XML但经过扩展用于HTML的应用程序编程接口(API,Application Programming Interface)。DOM把整个页面映射为一个多层节点结构。HTML或XML页面中的每个组成部分都是某种类型的节点,这些节点又包含着不同类型的数据。看下面这个HTML页面:

<html>
    <head> 
        <title>Sample Page</title>
    </head>
    <body>
        <p>Hello World!</p>
    </body>
</html>


在DOM中,这个页面可以通过见图1-2所示的分层节点图表示。

通过DOM创建的这个表示文档的树形图,开发人员获得了控制页面内容和结构的主动权。借助DOM提供的API,开发人员可以轻松自如地删除、添加、替换或修改任何节点。

1. 为什么要使用DOM

在Internet Explorer 4和Netscape Navigator 4分别支持的不同形式的DHTML(Dynamic HTML)基础上,开发人员首次无需重新加载网页,就可以修改其外观和内容了。然而,DHTML在给Web技术发展带来巨大进步的同时,也带来了巨大的问题。由于Netscape和微软在开发DHTML方面各持己见,过去那个只编写一个HTML页面就能够在任何浏览器中运行的时代结束了。

对开发人员而言,如果想继续保持Web跨平台的天性,就必须额外多做一些工作。而人们真正担心的是,如果不对Netscapet和微软加以控制,Web开发领域就会出现技术上两强割据,浏览器互不兼容的局面。此时,负责制定Web通信标准的W3C(World Wide Web Consortium,万维网联盟)开始着手规划DOM。

图 1-2

2. DOM级别

DOM1级(DOM Level 1)于1998年10月成为W3C的推荐标准。DOM1级由两个模块组成:DOM核心(DOM Core)和DOM HTML。其中,DOM核心规定的是如何映射基于XML的文档结构,以便简化对文档中任意部分的访问和操作。DOM HTML模块则在DOM核心的基础上加以扩展,添加了针对HTML的对象和方法。

请读者注意,DOM并不只是针对JavaScript的,很多别的语言也都实现了DOM。不过,在Web浏览器中,基于ECMAScript实现的DOM的确已经成为JavaScript这门语言的一个重要组成部分。

如果说DOM1级的目标主要是映射文档的结构,那么DOM2级的目标就要宽泛多了。DOM2级在原来DOM的基础上又扩充了(DHTML一直都支持的)鼠标和用户界面事件、范围、遍历(迭代DOM文档的方法)等细分模块,而且通过对象接口增加了对CSS(Cascading Style Sheets,层叠样式表)的支持。DOM1级中的DOM核心模块也经过扩展开始支持XML命名空间。

DOM2级引入了下列新模块,也给出了众多新类型和新接口的定义。

DOM3级则进一步扩展了DOM,引入了以统一方式加载和保存文档的方法——在DOM加载和保存(DOM Load and Save)模块中定义;新增了验证文档的方法——在DOM验证(DOM Validation)模块中定义。DOM3级也对DOM核心进行了扩展,开始支持XML 1.0规范,涉及XML Infoset、XPath和XML Base。

在阅读DOM标准的时候,读者可能会看到DOM0级(DOM Level 0)的字眼。实际上,DOM0级标准是不存在的;所谓DOM0级只是DOM历史坐标中的一个参照点而已。具体说来,DOM0级指的是Internet Explorer 4.0和Netscape Navigator 4.0最初支持的DHTML。

3. 其他DOM标准

除了DOM核心和DOM HTML接口之外,另外几种语言还发布了只针对自己的DOM标准。下面列出的语言都是基于XML的,每种语言的DOM标准都添加了与特定语言相关的新方法和新接口:

还有一些语言也开发了自己的DOM实现,例如Mozilla的XUL(XML User Interface Language,XML用户界面语言)。但是,只有上面列出的几种语言是W3C的推荐标准。

4. Web浏览器对DOM的支持

在DOM标准出现了一段时间之后,Web浏览器才开始实现它。微软在IE5中首次尝试实现DOM,但直到IE5.5才算是真正支持DOM1级。在随后的IE6和IE7中,微软都没有引入新的DOM功能,而到了IE8才对以前DOM实现中的bug进行了修复。

Netscape直到Netscape 6(Mozilla 0.6.0)才开始支持DOM。在Netscape 7之后,Mozilla把开发重心转向了Firefox浏览器。Firefox 3完全支持DOM1级,几乎完全支持DOM2级,甚至还支持DOM3级的一部分。(Mozilla开发团队的目标是构建与标准100%兼容的浏览器,而他们的努力也得到了回报。)

目前,支持DOM已经成为浏览器开发商的首要目标,主流浏览器每次发布新版本都会改进对DOM的支持。下表列出了主流浏览器对DOM标准的支持情况。

浏 览 器 DOM兼容性
Netscape Navigator 1. ~ 4.x
Netscape 6+ (Mozilla 0.6.0+) 1级、2级(几乎全部)、3级(部分)
IE2~IE4.x
IE5 1级(最小限度)
IE5.5~IE8 1级(几乎全部)
IE9+ 1级、2级、3级
Opera 1~6
Opera 7~8.x 1级(几乎全部)、2级(部分)
Opera 9~9.9 1级、2级(几乎全部)、3级(部分)
Opera 10+ 1级、2级、3级(部分)
Safari 1.0.x 1级
Safari 2+ 1级、2级(部分)
Chrome 1+ 1级、2级(部分)
Firefox 1+ 1级、2级(几乎全部)、3级(部分)

1.2.3 浏览器对象模型(BOM)

Internet Explorer 3和Netscape Navigator 3有一个共同的特色,那就是支持可以访问和操作浏览器窗口的浏览器对象模型(BOM,Browser Object Model)。开发人员使用BOM可以控制浏览器显示的页面以外的部分。而BOM真正与众不同的地方(也是经常会导致问题的地方),还是它作为JavaScript实现的一部分但却没有相关的标准。这个问题在HTML5中得到了解决,HTML5致力于把很多BOM功能写入正式规范。HTML5发布后,很多关于BOM的困惑烟消云散。

从根本上讲,BOM只处理浏览器窗口和框架;但人们习惯上也把所有针对浏览器的JavaScript扩展算作BOM的一部分。下面就是一些这样的扩展:

由于没有BOM标准可以遵循,因此每个浏览器都有自己的实现。虽然也存在一些事实标准,例如要有window 对象和navigator 对象等,但每个浏览器都会为这两个对象乃至其他对象定义自己的属性和方法。现在有了HTML5,BOM实现的细节有望朝着兼容性越来越高的方向发展。第8章将深入讨论BOM。

作为Netscape“继承人”的Mozilla公司,是目前唯一还在沿用最初的JavaScript版本编号序列的浏览器开发商。在Netscape将源代码提交给开源的Mozilla项目的时候,JavaScript在浏览器中的最后一个版本号是1.3。(如前所述,1.4版是只针对服务器的实现。)后来,随着Mozilla基金会继续开发JavaScript,添加新的特性、关键字和语法,JavaScript的版本号继续递增。下表列出了Netscape/Mozilla浏览器中JavaScript版本号的递增过程:

浏 览 器 JavaScript版本 浏 览 器 JavaScript版本
Netscape Navigator 2 1.0 Firefox 1.5 1.6
Netscape Navigator 3 1.1 Firefox 2 1.7
Netscape Navigator 4 1.2 Firefox 3 1.8
Netscape Navigator 4.06 1.3 Firefox 3.5 1.8.1
Netscape 6+(Mozilla 0.6.0+) 1.5 Firefox 3.6 1.8.2
Firefox 1 1.5

实际上,上表中的编号方案源自Firefox 4将内置JavaScript 2.0这一共识。因此,2.0版之前每个递增的版本号,表示的是相应实现与JavaScript 2.0开发目标还有多大的距离。虽然原计划是这样,但JavaScript的这种发展速度让这个计划成为不再可行。目前,JavaScript 2.0还没有目标实现。

请注意,只有Netscape/Mozilla浏览器才遵循这种编号模式。例如,IE的JScript就采用了另一种版本命名方案。换句话说,JScript的版本号与上表中JavaScript的版本号之间不存在任何对应关系。而且,大多数浏览器在提及对JavaScript的支持情况时,一般都以ECMAScript兼容性和对DOM的支持情况为准。

JavaScript是一种专为与网页交互而设计的脚本语言,由下列三个不同的部分组成:

JavaScript的这三个组成部分,在当前五个主要浏览器(IE、Firefox、Chrome、Safari和Opera)中都得到了不同程度的支持。其中,所有浏览器对ECMAScript第3版的支持大体上都还不错,而对ECMAScript 5的支持程度越来越高,但对DOM的支持则彼此相差比较多。对HTML5已经正式纳入标准的BOM来说,尽管各浏览器都实现了某些众所周知的共同特性,但其他特性还是会因浏览器而异。


第2章 在HTML中使用JavaScript

本章内容

只要一提到把JavaScript放到网页中,就不得不涉及Web的核心语言——HTML。在当初开发JavaScript的时候,Netscape要解决的一个重要问题就是如何做到让JavaScript既能与HTML页面共存,又不影响那些页面在其他浏览器中的呈现效果。经过尝试、纠错和争论,最终的决定就是为Web增加统一的脚本支持。而Web诞生早期的很多做法也都保留了下来,并被正式纳入HTML规范当中。

向HTML页面中插入JavaScript的主要方法,就是使用<script> 元素。这个元素由Netscape创造并在Netscape Navigator 2中首先实现。后来,这个元素被加入到正式的HTML规范中。HTML 4.01为<script> 定义了下列6个属性。

使用<script> 元素的方式有两种:直接在页面中嵌入JavaScript代码和包含外部JavaScript文件。

在使用<script> 元素嵌入JavaScript代码时,只须为<script> 指定type 属性。然后,像下面这样把JavaScript代码直接放在元素内部即可:

<script type="text/javascript">
    function sayHi(){ 
        alert("Hi!");
    }
</script>


包含在<script> 元素内部的JavaScript代码将被从上至下依次解释。就拿前面这个例子来说,解释器会解释到一个函数的定义,然后将该定义保存在自己的环境当中。在解释器对<script> 元素内部的所有代码求值完毕以前,页面中的其余内容都不会被浏览器加载或显示。

在使用<script> 嵌入JavaScript代码时,记住不要在代码中的任何地方出现"</script>" 字符串。例如,浏览器在加载下面所示的代码时就会产生一个错误:

<script type="text/javascript">
    function sayScript(){
        alert("</script>");


    }

因为按照解析嵌入式代码的规则,当浏览器遇到字符串"</script>" 时,就会认为那是结束的</script> 标签。而通过把这个字符串分隔为两部分可以解决这个问题,例如:

<script type="text/javascript">
    function sayScript(){
        alert("<\/script>");


    }

像这样分成两部分来写就不会造成浏览器的误解,因而也就不会导致错误了。

如果要通过<script> 元素来包含外部JavaScript文件,那么src 属性就是必需的。这个属性的值是一个指向外部JavaScript文件的链接,例如:

<script type="text/javascript" src="example.js"></script>


在这个例子中,外部文件example.js 将被加载到当前页面中。外部文件只须包含通常要放在开始的<script> 和结束的</script> 之间的那些JavaScript代码即可。与解析嵌入式JavaScript代码一样,在解析外部JavaScript文件(包括下载该文件)时,页面的处理也会暂时停止。如果是在XHTML文档中,也可以省略前面示例代码中结束的</script> 标签,例如:

<script type="text/javascript" src="example.js" />


但是,不能在HTML文档使用这种语法。原因是这种语法不符合HTML规范,而且也得不到某些浏览器(尤其是IE)的正确解析。

按照惯例,外部JavaScript文件带有.js扩展名。但这个扩展名不是必需的,因为浏览器不会检查包含JavaScript的文件的扩展名。这样一来,使用JSP、PHP或其他服务器端语言动态生成JavaScript代码也就成为了可能。但是,服务器通常还是需要看扩展名决定为响应应用哪种MIME类型。如果不使用.js扩展名,请确保服务器能返回正确的MIME类型。

需要注意的是,带有src 属性的<script> 元素不应该在其<script></script> 标签之间再包含额外的JavaScript代码。如果包含了嵌入的代码,则只会下载并执行脚本文件,嵌入的代码会被忽略。

另外,通过<script> 元素的src 属性还可以包含来自外部域的JavaScript文件。这一点既使<script> 元素倍显强大,又让它备受争议。在这一点上,<script><img> 元素非常相似,即它的src属性可以是指向当前HTML页面所在域之外的某个域中的URL,例如:

<script type="text/javascript" src="http://www.somewhere.com/afile.js"></script>


这样,位于外部域中的代码也会被加载和解析,就像这些代码位于加载它们的页面中一样。利用这一点就可以在必要时通过不同的域来提供JavaScript文件。不过,在访问自己不能控制的服务器上的JavaScript文件时则要多加小心。如果不幸遇到了怀有恶意的程序员,那他们随时都可能替换该文件中的代码。因此,如果想包含来自不同域的代码,则要么你是那个域的所有者,要么那个域的所有者值得信赖。

无论如何包含代码,只要不存在deferasync 属性,浏览器都会按照<script> 元素在页面中出现的先后顺序对它们依次进行解析。换句话说,在第一个<script> 元素包含的代码解析完成后,第二个<script> 包含的代码才会被解析,然后才是第三个、第四个……

2.1.1 标签的位置

按照惯例,所有<script> 元素都应该放在页面的<head> 元素中,例如:

<!DOCTYPE html>
<html>
  <head>
    <title>Example HTML Page</title>
    <script type="text/javascript" src="example1.js"></script>
    <script type="text/javascript" src="example2.js"></script>
  </head>
  <body>
    <!-- 这里放内容 -->
  </body>
</html>


这种做法的目的就是把所有外部文件(包括CSS文件和JavaScript文件)的引用都放在相同的地方。可是,在文档的<head> 元素中包含所有JavaScript文件,意味着必须等到全部JavaScript代码都被下载、解析和执行完成以后,才能开始呈现页面的内容(浏览器在遇到<body> 标签时才开始呈现内容)。对于那些需要很多JavaScript代码的页面来说,这无疑会导致浏览器在呈现页面时出现明显的延迟,而延迟期间的浏览器窗口中将是一片空白。为了避免这个问题,现代Web应用程序一般都把全部JavaScript引用放在<body> 元素中,放在页面的内容后面,如下例所示:

<!DOCTYPE html>
<html>
  <head>
    <title>Example HTML Page</title>
  </head>
  <body>
    <!-- 这里放内容 -->
    <script type="text/javascript" src="example1.js"></script>


    <script type="text/javascript" src="example2.js"></script>


  </body>
</html>

这样,在解析包含的JavaScript代码之前,页面的内容将完全呈现在浏览器中。而用户也会因为浏览器窗口显示空白页面的时间缩短而感到打开页面的速度加快了。

2.1.2 延迟脚本

HTML 4.01为<script> 标签定义了defer 属性。这个属性的用途是表明脚本在执行时不会影响页面的构造。也就是说,脚本会被延迟到整个页面都解析完毕后再运行。因此,在<script> 元素中设置defer 属性,相当于告诉浏览器立即下载,但延迟执行。

<!DOCTYPE html>
<html>
  <head>
    <title>Example HTML Page</title>
    <script type="text/javascript" defer="defer" src="example1.js"></script>


    <script type="text/javascript" defer="defer" src="example2.js"></script>


  </head>
  <body>
    <!-- 这里放内容 -->
  </body>
</html>

在这个例子中,虽然我们把<script> 元素放在了文档的<head> 元素中,但其中包含的脚本将延迟到浏览器遇到</html> 标签后再执行。HTML5规范要求脚本按照它们出现的先后顺序执行,因此第一个延迟脚本会先于第二个延迟脚本执行,而这两个脚本会先于DOMContentLoaded 事件(详见第13章)执行。在现实当中,延迟脚本并不一定会按照顺序执行,也不一定会在DOMContentLoaded 事件触发前执行,因此最好只包含一个延迟脚本。

前面提到过,defer 属性只适用于外部脚本文件。这一点在HTML5中已经明确规定,因此支持HTML5的实现会忽略给嵌入脚本设置的defer 属性。IE4~IE7还支持对嵌入脚本的defer 属性,但IE8及之后版本则完全支持HTML5规定的行为。

IE4、Firefox 3.5、Safari 5和Chrome是最早支持defer 属性的浏览器。其他浏览器会忽略这个属性,像平常一样处理脚本。为此,把延迟脚本放在页面底部仍然是最佳选择。

在XHTML文档中,要把defer 属性设置为defer="defer"

2.1.3 异步脚本

HTML5为<script> 元素定义了async 属性。这个属性与defer 属性类似,都用于改变处理脚本的行为。同样与defer 类似,async 只适用于外部脚本文件,并告诉浏览器立即下载文件。但与defer 不同的是,标记为async 的脚本并不保证按照指定它们的先后顺序执行。例如:

<!DOCTYPE html>
<html>  
  <head>
    <title>Example HTML Page</title>
    <script type="text/javascript" async src="example1.js"></script>


    <script type="text/javascript" async src="example2.js"></script>


  </head>
  <body>
    <!-- 这里放内容 -->
  </body>
</html>

在以上代码中,第二个脚本文件可能会在第一个脚本文件之前执行。因此,确保两者之间互不依赖非常重要。指定async 属性的目的是不让页面等待两个脚本下载和执行,从而异步加载页面其他内容。为此,建议异步脚本不要在加载期间修改DOM。

异步脚本一定会在页面的load 事件前执行,但可能会在DOMContentLoaded 事件触发之前或之后执行。支持异步脚本的浏览器有Firefox 3.6、Safari 5和Chrome。

在XHTML文档中,要把async 属性设置为async="async"

2.1.4 在XHTML中的用法 1

1 HTML5正快速地被前端开发人员采用,建议读者在学习和开发中遵循HTML5标准,本节内容可以跳过。

可扩展超文本标记语言,即XHTML(Extensible HyperText Markup Language),是将HTML作为XML的应用而重新定义的一个标准。编写XHTML代码的规则要比编写HTML严格得多,而且直接影响能否在嵌入JavaScript代码时使用<script/> 标签。以下面的代码块为例,虽然它们在HTML中是有效的,但在XHTML中则是无效的。

<script type="text/javascript">
    function compare(a, b) {
        if (a < b) {
            alert("A is less than B");
        } else if (a > b) {
            alert("A is greater than B");
        } else {
            alert("A is equal to B");
        }
    }
</script>


在HTML中,有特殊的规则用以确定<script> 元素中的哪些内容可以被解析,但这些特殊的规则在XHTML中不适用。这里比较语句a < b 中的小于号(<)在XHTML中将被当作开始一个新标签来解析。但是作为标签来讲,小于号后面不能跟空格,因此就会导致语法错误。

避免在XHTML中出现类似语法错误的方法有两个。一是用相应的HTML实体(&lt; )替换代码中所有的小于号(<),替换后的代码类似如下所示:

<script type="text/javascript">
    function compare(a, b) {
        if (a &lt; b) {


            alert("A is less than B");
        } else if (a > b) {
            alert("A is greater than B");
        } else {
            alert("A is equal to B");
        }
    }
</script>

虽然这样可以让代码在XHTML中正常运行,但却导致代码不好理解了。为此,我们可以考虑采用另一个方法。

保证让相同代码在XHTML中正常运行的第二个方法,就是用一个CData片段来包含JavaScript代码。在XHTML(XML)中,CData片段是文档中的一个特殊区域,这个区域中可以包含不需要解析的任意格式的文本内容。因此,在CData片段中就可以使用任意字符——小于号当然也没有问题,而且不会导致语法错误。引入CData片段后的JavaScript代码块如下所示:

<script type="text/javascript"><![CDATA[


    function compare(a, b) {
        if (a < b) {
            alert("A is less than B");
        } else if (a > b) {
            alert("A is greater than B");
        } else {
            alert("A is equal to B");
        }
    }
]]></script>


在兼容XHTML的浏览器中,这个方法可以解决问题。但实际上,还有不少浏览器不兼容XHTML,因而不支持CData片段。怎么办呢?再使用JavaScript注释将CData标记注释掉就可以了:

<script type="text/javascript">
//<![CDATA[


    function compare(a, b) {
        if (a < b) {
            alert("A is less than B");
        } else if (a > b) {
            alert("A is greater than B");
        } else {
            alert("A is equal to B");
        }
    }
//]]>


</script>

这种格式在所有现代浏览器中都可以正常使用。虽然有几分hack的味道,但它能通过XHTML验证,而且对XHTML之前的浏览器也会平稳退化。

在将页面的MIME类型指定为"application/xhtml+xml" 的情况下会触发XHTML模式。并不是所有浏览器都支持以这种方式提供XHTML文档。

2.1.5 不推荐使用的语法

在最早引入<script> 元素的时候,该元素与传统HTML的解析规则是有冲突的。由于要对这个元素应用特殊的解析规则,因此在那些不支持JavaScript的浏览器(最典型的是Mosaic)中就会导致问题。具体来说,不支持JavaScript的浏览器会把<script> 元素的内容直接输出到页面中,因而会破坏页面的布局和外观。

Netscape与Mosaic协商并提出了一个解决方案,让不支持<script> 元素的浏览器能够隐藏嵌入的JavaScript代码。这个方案就是把JavaScript代码包含在一个HTML注释中,像下面这样:

<script><!--


    function sayHi(){
        alert("Hi!");
    }
//--></script>


给脚本加上HTML注释后,Mosaic等浏览器就会忽略<script> 标签中的内容;而那些支持JavaScript的浏览器在遇到这种情况时,则必须进一步确认其中是否包含需要解析的JavaScript代码。

虽然这种注释JavaScript代码的格式得到了所有浏览器的认可,也能被正确解释,但由于所有浏览器都已经支持JavaScript,因此也就没有必要再使用这种格式了。在XHTML模式下,因为脚本包含在XML注释中,所以脚本会被忽略。

在HTML中嵌入JavaScript代码虽然没有问题,但一般认为最好的做法还是尽可能使用外部文件来包含JavaScript代码。不过,并不存在必须使用外部文件的硬性规定,但支持使用外部文件的人多会强调如下优点。

IE5.5引入了文档模式的概念,而这个概念是通过使用文档类型(doctype)切换实现的。最初的两种文档模式是:混杂模式 (quirks mode)2标准模式 (standards mode)。混杂模式会让IE的行为与(包含非标准特性的)IE5相同,而标准模式则让IE的行为更接近标准行为。虽然这两种模式主要影响CSS内容的呈现,但在某些情况下也会影响到JavaScript的解释执行。本书将在必要时再讨论这些因文档模式而影响JavaScript执行的情况。

2 这里quirks mode的译法源自Firefox 3.5.5中文版。

在IE引入文档模式的概念后,其他浏览器也纷纷效仿。在此之后,IE又提出一种所谓的准标准模式 (almost standards mode)。这种模式下的浏览器特性有很多都是符合标准的,但也不尽然。不标准的地方主要体现在处理图片间隙的时候(在表格中使用图片时问题最明显)。

如果在文档开始处没有发现文档类型声明,则所有浏览器都会默认开启混杂模式。但采用混杂模式不是什么值得推荐的做法,因为不同浏览器在这种模式下的行为差异非常大,如果不使用某些hack技术,跨浏览器的行为根本就没有一致性可言。

对于标准模式,可以通过使用下面任何一种文档类型来开启:

<!-- HTML 4.01 严格型 -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
"http://www.w3.org/TR/html4/strict.dtd">

<!-- XHTML 1.0 严格型 -->
<!DOCTYPE html PUBLIC
"-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">

<!-- HTML 5 -->
<!DOCTYPE html>


而对于准标准模式,则可以通过使用过渡型(transitional)或框架集型(frameset)文档类型来触发,如下所示:

<!-- HTML 4.01 过渡型 -->
<!DOCTYPE HTML PUBLIC
"-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">

<!-- HTML 4.01 框架集型 -->
<!DOCTYPE HTML PUBLIC
"-//W3C//DTD HTML 4.01 Frameset//EN"
"http://www.w3.org/TR/html4/frameset.dtd">

<!-- XHTML 1.0 过渡型 -->
<!DOCTYPE html PUBLIC
"-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<!-- XHTML 1.0 框架集型 -->
< !DOCTYPE html PUBLIC
"-//W3C//DTD XHTML 1.0 Frameset//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">


准标准模式与标准模式非常接近,它们的差异几乎可以忽略不计。因此,当有人提到“标准模式”时,有可能是指这两种模式中的任何一种。而且,检测文档模式(本书后面将会讨论)时也不会发现什么不同。本书后面提到标准模式时,指的是除混杂模式之外的其他模式。

早期浏览器都面临一个特殊的问题,即当浏览器不支持JavaScript时如何让页面平稳地退化。对这个问题的最终解决方案就是创造一个<noscript> 元素,用以在不支持JavaScript的浏览器中显示替代的内容。这个元素可以包含能够出现在文档<body> 中的任何HTML元素——<script> 元素除外。包含在<noscript> 元素中的内容只有在下列情况下才会显示出来:

符合上述任何一个条件,浏览器都会显示<noscript> 中的内容。而在除此之外的其他情况下,浏览器不会呈现<noscript> 中的内容。

请看下面这个简单的例子:

<html> 
  <head>
    <title>Example HTML Page</title>
    <script type="text/javascript" defer="defer" src="example1.js"></script>
    <script type="text/javascript" defer="defer" src="example2.js"></script>
  </head>
  <body>
    <noscript>


      <p>本页面需要浏览器支持(启用)JavaScript。


    </noscript>


  </body>
</html>

这个页面会在脚本无效的情况下向用户显示一条消息。而在启用了脚本的浏览器中,用户永远也不会看到它——尽管它是页面的一部分。

把JavaScript插入到HTML页面中要使用<script> 元素。使用这个元素可以把JavaScript嵌入到HTML页面中,让脚本与标记混合在一起;也可以包含外部的JavaScript文件。而我们需要注意的地方有:

另外,使用<noscript> 元素可以指定在不支持脚本的浏览器中显示的替代内容。但在启用了脚本的情况下,浏览器不会显示<noscript> 元素中的任何内容。


第3章 基本概念

本章内容

任何语言的核心都必然会描述这门语言最基本的工作原理。而描述的内容通常都要涉及这门语言的语法、操作符、数据类型、内置功能等用于构建复杂解决方案的基本概念。如前所述,ECMA-262通过叫做ECMAScript的“伪语言”为我们描述了JavaScript的所有这些基本概念。

目前,ECMA-262第3版中定义的ECMAScript是各浏览器实现最多的一个版本。ECMA-262第5版是浏览器接下来实现的版本,但截止到2011年底,还没有浏览器完全实现了这个版本。为此,本章将主要按照第3版定义的ECMAScript介绍这门语言的基本概念,并就第5版的变化给出说明。

ECMAScript的语法大量借鉴了C及其他类C语言(如Java和Perl)的语法。因此,熟悉这些语言的开发人员在接受ECMAScript更加宽松的语法时,一定会有一种轻松自在的感觉。

3.1.1 区分大小写

要理解的第一个概念就是ECMAScript中的一切(变量、函数名和操作符)都区分大小写。这也就意味着,变量名test 和变量名Test 分别表示两个不同的变量,而函数名不能使用typeof ,因为它是一个关键字(3.2节介绍关键字),但typeOf 则完全可以是一个有效的函数名。

3.1.2 标识符

所谓标识符 ,就是指变量、函数、属性的名字,或者函数的参数。标识符可以是按照下列格式规则组合起来的一或多个字符:

标识符中的字母也可以包含扩展的ASCII或Unicode字母字符(如À和Æ),但我们不推荐这样做。

按照惯例,ECMAScript标识符采用驼峰大小写格式,也就是第一个字母小写,剩下的每个有意义的单词的首字母大写,例如:

firstSecond 
myCar
doSomethingImportant


虽然没有谁强制要求必须采用这种格式,但为了与ECMAScript内置的函数和对象命名格式保持一致,可以将其当作一种最佳实践。

不能把关键字、保留字、 true false null 用作标识符。3.2节将介绍更多相关内容。

3.1.3 注释

ECMAScript使用C风格的注释,包括单行注释和块级注释。单行注释以两个斜杠开头,如下所示:

// 单行注释


块级注释以一个斜杠和一个星号(/* )开头,以一个星号和一个斜杠(*/ )结尾,如下所示:

/*
 *  这是一个多行
 *  (块级)注释
 */


虽然上面注释中的第二和第三行都以一个星号开头,但这不是必需的。之所以添加那两个星号,纯粹是为了提高注释的可读性(这种格式在企业级应用程序中极其常见)。

3.1.4 严格模式

ECMAScript 5引入了严格模式(strict mode)的概念。严格模式是为JavaScript定义了一种不同的解析与执行模型。在严格模式下,ECMAScript 3中的一些不确定的行为将得到处理,而且对某些不安全的操作也会抛出错误。要在整个脚本中启用严格模式,可以在顶部添加如下代码:

"use strict";


这行代码看起来像是字符串,而且也没有赋值给任何变量,但其实它是一个编译指示(pragma),用于告诉支持的JavaScript引擎切换到严格模式。这是为不破坏ECMAScript 3语法而特意选定的语法。

在函数内部的上方包含这条编译指示,也可以指定函数在严格模式下执行:

function doSomething(){
    "use strict"; 
    //函数体
}


严格模式下,JavaScript的执行结果会有很大不同,因此本书将会随时指出严格模式下的区别。支持严格模式的浏览器包括IE10+、Firefox 4+、Safari 5.1+、Opera 12+和Chrome。

3.1.5 语句

ECMAScript中的语句以一个分号结尾;如果省略分号,则由解析器确定语句的结尾,如下例所示:

var sum = a + b                 // 即使没有分号也是有效的语句——不推荐
var diff = a - b;               // 有效的语句——推荐


虽然语句结尾的分号不是必需的,但我们建议任何时候都不要省略它。因为加上这个分号可以避免很多错误(例如不完整的输入),开发人员也可以放心地通过删除多余的空格来压缩ECMAScript代码(代码行结尾处没有分号会导致压缩错误)。另外,加上分号也会在某些情况下增进代码的性能,因为这样解析器就不必再花时间推测应该在哪里插入分号了。

可以使用C风格的语法把多条语句组合到一个代码块中,即代码块以左花括号({ )开头,以右花括号(} )结尾:

if (test){
    test = false;
    alert(test);
}


虽然条件控制语句(如if 语句)只在执行多条语句的情况下才要求使用代码块,但最佳实践是始终在控制语句中使用代码块——即使代码块中只有一条语句,例如:

if (test)
    alert(test);       // 有效但容易出错,不要使用

if (test){             // 推荐使用
    alert(test);
}


在控制语句中使用代码块可以让编码意图更加清晰,而且也能降低修改代码时出错的几率。

ECMA-262描述了一组具有特定用途的关键字 ,这些关键字可用于表示控制语句的开始或结束,或者用于执行特定操作等。按照规则,关键字也是语言保留的,不能用作标识符。以下就是ECMAScript的全部关键字(带* 号上标的是第5版新增的关键字):

break         do           instanceof        typeof
case          else         new               var
catch         finally      return            void
continue      for          switch            while
debugger*     function     this              with
default       if           throw
delete        in           try


ECMA-262还描述了另外一组不能用作标识符的保留字 。尽管保留字在这门语言中还没有任何特定的用途,但它们有可能在将来被用作关键字。以下是ECMA-262第3版定义的全部保留字:

abstract      enum            int            short
boolean       export          interface      static
byte          extends         long           super
char          final           native         synchronized
class         float           package        throws
const         goto            private        transient
debugger      implements      protected      volatile
double        import          public


第5版把在非严格模式下运行时的保留字缩减为下列这些:

class          enum          extends         super
const          export        import


在严格模式下,第5版还对以下保留字施加了限制:

implements     package       public
interface      private       static
let            protected     yield


注意,letyield 是第5版新增的保留字;其他保留字都是第3版定义的。为了最大程度地保证兼容性,建议读者将第3版定义的保留字外加letyield 作为编程时的参考。

在实现ECMAScript 3的JavaScript引擎中使用关键字作标识符,会导致“Identifier Expected”错误。而使用保留字作标识符可能会也可能不会导致相同的错误,具体取决于特定的引擎。

第5版对使用关键字和保留字的规则进行了少许修改。关键字和保留字虽然仍然不能作为标识符使用,但现在可以用作对象的属性名。一般来说,最好都不要使用关键字和保留字作为标识符和属性名,以便与将来的ECMAScript版本兼容。

除了上面列出的保留字和关键字,ECMA-262第5版对evalarguments 还施加了限制。在严格模式下,这两个名字也不能作为标识符或属性名,否则会抛出错误。

ECMAScript的变量是松散类型的,所谓松散类型就是可以用来保存任何类型的数据。换句话说,每个变量仅仅是一个用于保存值的占位符而已。定义变量时要使用var 操作符(注意var 是一个关键字),后跟变量名(即一个标识符),如下所示:

var message;


这行代码定义了一个名为message 的变量,该变量可以用来保存任何值(像这样未经过初始化的变量,会保存一个特殊的值——undefined ,相关内容将在3.4节讨论)。ECMAScript也支持直接初始化变量,因此在定义变量的同时就可以设置变量的值,如下所示:

var message = "hi";


在此,变量message 中保存了一个字符串值"hi" 。像这样初始化变量并不会把它标记为字符串类型;初始化的过程就是给变量赋一个值那么简单。因此,可以在修改变量值的同时修改值的类型,如下所示:

var message = "hi";
message = 100;         // 有效,但不推荐


在这个例子中,变量message 一开始保存了一个字符串值"hi" ,然后该值又被一个数字值100取代。虽然我们不建议修改变量所保存值的类型,但这种操作在ECMAScript中完全有效。

有一点必须注意,即使用var 操作符定义的变量将成为定义该变量的作用域中的局部变量。也就是说,如果在函数中使用var 定义一个变量,那么这个变量在函数退出后就会被销毁,例如:

function test(){
    var message = "hi"; // 局部变量
}
test();
alert(message); // 错误!


这里,变量message 是在函数中使用var 定义的。当函数被调用时,就会创建该变量并为其赋值。而在此之后,这个变量又会立即被销毁,因此例子中的下一行代码就会导致错误。不过,可以像下面这样省略var 操作符,从而创建一个全局变量:

function test(){
    message = "hi"; // 全局变量


}
test();
alert(message); // "hi"

这个例子省略了var 操作符,因而message 就成了全局变量。这样,只要调用过一次test() 函数,这个变量就有了定义,就可以在函数外部的任何地方被访问到。

虽然省略 var 操作符可以定义全局变量,但这也不是我们推荐的做法。因为在局部作用域中定义的全局变量很难维护,而且如果有意地忽略了 var 操作符,也会由于相应变量不会马上就有定义而导致不必要的混乱。给未经声明的变量赋值在严格模式下会导致抛出ReferenceError 错误。

可以使用一条语句定义多个变量,只要像下面这样把每个变量(初始化或不初始化均可)用逗号分隔开即可:

var message = "hi",
    found = false,
    age = 29;


这个例子定义并初始化了3个变量。同样由于ECMAScript是松散类型的,因而使用不同类型初始化变量的操作可以放在一条语句中来完成。虽然代码里的换行和变量缩进不是必需的,但这样做可以提高可读性。

在严格模式下,不能定义名为evalarguments 的变量,否则会导致语法错误。

ECMAScript中有5种简单数据类型(也称为基本数据类型):UndefinedNullBooleanNumberString 。还有1种复杂数据类型——ObjectObject 本质上是由一组无序的名值对组成的。ECMAScript不支持任何创建自定义类型的机制,而所有值最终都将是上述6种数据类型之一。乍一看,好像只有6种数据类型不足以表示所有数据;但是,由于ECMAScript数据类型具有动态性,因此的确没有再定义其他数据类型的必要了。

3.4.1 typeof 操作符

鉴于ECMAScript是松散类型的,因此需要有一种手段来检测给定变量的数据类型——typeof 就是负责提供这方面信息的操作符。对一个值使用typeof 操作符可能返回下列某个字符串:

下面是几个使用typeof 操作符的例子:

var message = "some string";
alert(typeof message);     // "string"
alert(typeof(message));    // "string"
alert(typeof 95);          // "number"


TypeofExample01.htm

这几个例子说明,typeof 操作符的操作数可以是变量(message ),也可以是数值字面量。注意,typeof 是一个操作符而不是函数,因此例子中的圆括号尽管可以使用,但不是必需的。

有些时候,typeof 操作符会返回一些令人迷惑但技术上却正确的值。比如,调用typeof null 会返回"object" ,因为特殊值null 被认为是一个空的对象引用。Safari 5及之前版本、Chrome 7及之前版本在对正则表达式调用typeof 操作符时会返回"function" ,而其他浏览器在这种情况下会返回"object"

从技术角度讲,函数在ECMAScript中是对象,不是一种数据类型。然而,函数也确实有一些特殊的属性,因此通过 typeof 操作符来区分函数和其他对象是有必要的。

3.4.2 Undefined 类型

Undefined 类型只有一个值,即特殊的undefined 。在使用var 声明变量但未对其加以初始化时,这个变量的值就是undefined ,例如:

var message;
alert(message == undefined); //true


UndefinedExample01.htm

这个例子只声明了变量message ,但未对其进行初始化。比较这个变量与undefined 字面量,结果表明它们是相等的。这个例子与下面的例子是等价的:

var message = undefined;


alert(message == undefined); //true

UndefinedExample02.htm

这个例子使用undefined 值显式初始化了变量message 。但我们没有必要这么做,因为未经初始化的值默认就会取得undefined 值。

一般而言,不存在需要显式地把一个变量设置为 undefined 值的情况。字面值 undefined 的主要目的是用于比较,而ECMA-262第3版之前的版本中并没有规定这个值。第3版引入这个值是为了正式区分空对象指针与未经初始化的变量。

不过,包含undefined 值的变量与尚未定义的变量还是不一样的。看看下面这个例子:

var message; // 这个变量声明之后默认取得了undefined值

// 下面这个变量并没有声明
// var age

alert(message);     // "undefined"
alert(age);         // 产生错误


UndefinedExample03.htm

运行以上代码,第一个警告框会显示变量message 的值,即"undefined" 。而第二个警告框——由于传递给alert() 函数的是尚未声明的变量age ——则会导致一个错误。对于尚未声明过的变量,只能执行一项操作,即使用typeof 操作符检测其数据类型(对未经声明的变量调用delete 不会导致错误,但这样做没什么实际意义,而且在严格模式下确实会导致错误)。

然而,令人困惑的是:对未初始化的变量执行typeof 操作符会返回undefined 值,而对未声明的变量执行typeof 操作符同样也会返回undefined 值。来看下面的例子:

var message; // 这个变量声明之后默认取得了undefined值

// 下面这个变量并没有声明
// var age

alert(typeof message);     // "undefined"


alert(typeof age);         // "undefined"


UndefinedExample04.htm

结果表明,对未初始化和未声明的变量执行typeof 操作符都返回了undefined 值;这个结果有其逻辑上的合理性。因为虽然这两种变量从技术角度看有本质区别,但实际上无论对哪种变量也不可能执行真正的操作。

即便未初始化的变量会自动被赋予 undefined 值,但显式地初始化变量依然是明智的选择。如果能够做到这一点,那么当 typeof 操作符返回 "undefined" 值时,我们就知道被检测的变量还没有被声明,而不是尚未初始化。

3.4.3 Null 类型

Null 类型是第二个只有一个值的数据类型,这个特殊的值是null 。从逻辑角度来看,null 值表示一个空对象指针,而这也正是使用typeof 操作符检测null 值时会返回"object" 的原因,如下面的例子所示:

var car = null;
alert(typeof car);     // "object"


NullExample01.htm

如果定义的变量准备在将来用于保存对象,那么最好将该变量初始化为null 而不是其他值。这样一来,只要直接检查null 值就可以知道相应的变量是否已经保存了一个对象的引用了,如下面的例子所示:

if (car != null){
    // 对car对象执行某些操作
}


实际上,undefined 值是派生自null 值的,因此ECMA-262规定对它们的相等性测试要返回true

alert(null == undefined);    //true


NullExample02.htm

这里,位于nullundefined 之间的相等操作符(== )总是返回true ,不过要注意的是,这个操作符出于比较的目的会转换其操作数(本章后面将详细介绍相关内容)。

尽管nullundefined 有这样的关系,但它们的用途完全不同。如前所述,无论在什么情况下都没有必要把一个变量的值显式地设置为undefined ,可是同样的规则对null 却不适用。换句话说,只要意在保存对象的变量还没有真正保存对象,就应该明确地让该变量保存null 值。这样做不仅可以体现null 作为空对象指针的惯例,而且也有助于进一步区分nullundefined

3.4.4 Boolean 类型

Boolean 类型是ECMAScript中使用得最多的一种类型,该类型只有两个字面值:truefalse 。这两个值与数字值不是一回事,因此true 不一定等于1,而false 也不一定等于0。以下是为变量赋Boolean 类型值的例子:

var found = true;
var lost = false;


需要注意的是,Boolean 类型的字面值truefalse 是区分大小写的。也就是说,TrueFalse (以及其他的混合大小写形式)都不是Boolean 值,只是标识符。

虽然Boolean 类型的字面值只有两个,但ECMAScript中所有类型的值都有与这两个Boolean 值等价的值。要将一个值转换为其对应的Boolean 值,可以调用转型函数Boolean() ,如下例所示:

var message = "Hello world!";
var messageAsBoolean = Boolean(message);


BooleanExample01.htm

在这个例子中,字符串message 被转换成了一个Boolean 值,该值被保存在messageAsBoolean 变量中。可以对任何数据类型的值调用Boolean() 函数,而且总会返回一个Boolean 值。至于返回的这个值是true 还是false ,取决于要转换值的数据类型及其实际值。下表给出了各种数据类型及其对应的转换规则。

数据类型 转换为true的值 转换为false的值
Boolean true false
String 任何非空字符串 "" (空字符串)
Number 任何非零数字值(包括无穷大) 0和NaN (参见本章后面有关NaN的内容)
Object 任何对象 bull
Undefined n/a1 undefined

1 n/a(或N/A),是not applicable的缩写,意思是“不适用”。

这些转换规则对理解流控制语句(如if 语句)自动执行相应的Boolean 转换非常重要,请看下面的代码:

var message = "Hello world!";
if (message){
    alert("Value is true");
}


BooleanExample02.htm

运行这个示例,就会显示一个警告框,因为字符串message 被自动转换成了对应的Boolean 值(true )。由于存在这种自动执行的Boolean 转换,因此确切地知道在流控制语句中使用的是什么变量至关重要。错误地使用一个对象而不是一个Boolean 值,就有可能彻底改变应用程序的流程。

3.4.5 Number 类型

Number 类型应该是ECMAScript中最令人关注的数据类型了,这种类型使用IEEE754格式来表示整数和浮点数值(浮点数值在某些语言中也被称为双精度数值)。为支持各种数值类型,ECMA-262定义了不同的数值字面量格式。

最基本的数值字面量格式是十进制整数,十进制整数可以像下面这样直接在代码中输入:

var intNum = 55;                // 整数


除了以十进制表示外,整数还可以通过八进制(以8为基数)或十六进制(以16为基数)的字面值来表示。其中,八进制字面值的第一位必须是零(0),然后是八进制数字序列(0~7)。如果字面值中的数值超出了范围,那么前导零将被忽略,后面的数值将被当作十进制数值解析。请看下面的例子:

var octalNum1 = 070;            // 八进制的56
var octalNum2 = 079;            // 无效的八进制数值——解析为79
var octalNum3 = 08;             // 无效的八进制数值——解析为8


八进制字面量在严格模式下是无效的,会导致支持的JavaScript引擎抛出错误。

十六进制字面值的前两位必须是0x,后跟任何十六进制数字(0~9及A~F)。其中,字母A~F可以大写,也可以小写。如下面的例子所示:

var hexNum1 = 0xA;              // 十六进制的10
var hexNum2 = 0x1f;             // 十六进制的31


在进行算术计算时,所有以八进制和十六进制表示的数值最终都将被转换成十进制数值。

鉴于JavaScript中保存数值的方式,可以保存正零(+0)和负零(-0)。正零和负零被认为相等,但为了读者更好地理解上下文,这里特别做此说明。

1. 浮点数值

所谓浮点数值,就是该数值中必须包含一个小数点,并且小数点后面必须至少有一位数字。虽然小数点前面可以没有整数,但我们不推荐这种写法。以下是浮点数值的几个例子:

var floatNum1 = 1.1;
var floatNum2 = 0.1;
var floatNum3 = .1;             // 有效,但不推荐


由于保存浮点数值需要的内存空间是保存整数值的两倍,因此ECMAScript会不失时机地将浮点数值转换为整数值。显然,如果小数点后面没有跟任何数字,那么这个数值就可以作为整数值来保存。同样地,如果浮点数值本身表示的就是一个整数(如1.0),那么该值也会被转换为整数,如下面的例子所示:

var floatNum1 = 1.;             // 小数点后面没有数字——解析为1
var floatNum2 = 10.0;           // 整数——解析为10


对于那些极大或极小的数值,可以用e表示法(即科学计数法)表示的浮点数值表示。用e表示法表示的数值等于e前面的数值乘以10的指数次幂。ECMAScript中e表示法的格式也是如此,即前面是一个数值(可以是整数也可以是浮点数),中间是一个大写或小写的字母E,后面是10的幂中的指数,该幂值将用来与前面的数相乘。下面是一个使用e表示法表示数值的例子:

var floatNum = 3.125e7;         // 等于31250000


在这个例子中,使用e表示法表示的变量floatNum 的形式虽然简洁,但它的实际值则是31250000。在此,e表示法的实际含义就是“3.125乘以107”。

也可以使用e表示法表示极小的数值,如0.00000000000000003,这个数值可以使用更简洁的3e-17表示。在默认情况下,ECMASctipt会将那些小数点后面带有6个零以上的浮点数值转换为以e表示法表示的数值(例如,0.0000003会被转换成3e-7)。

浮点数值的最高精度是17位小数,但在进行算术计算时其精确度远远不如整数。例如,0.1加0.2的结果不是0.3,而是0.30000000000000004。这个小小的舍入误差会导致无法测试特定的浮点数值。例如:

if (a + b == 0.3){          // 不要做这样的测试!
    alert("You got 0.3.");
}


在这个例子中,我们测试的是两个数的和是不是等于0.3。如果这两个数是0.05和0.25,或者是0.15和0.15都不会有问题。而如前所述,如果这两个数是0.1和0.2,那么测试将无法通过。因此,永远不要测试某个特定的浮点数值。

关于浮点数值计算会产生舍入误差的问题,有一点需要明确:这是使用基于IEEE754数值的浮点计算的通病,ECMAScript并非独此一家;其他使用相同数值格式的语言也存在这个问题。

2. 数值范围

由于内存的限制,ECMAScript并不能保存世界上所有的数值。ECMAScript能够表示的最小数值保存在Number.MIN_VALUE 中——在大多数浏览器中,这个值是5e-324;能够表示的最大数值保存在Number.MAX_VALUE 中——在大多数浏览器中,这个值是1.7976931348623157e+308。如果某次计算的结果得到了一个超出JavaScript数值范围的值,那么这个数值将被自动转换成特殊的Infinity 值。具体来说,如果这个数值是负数,则会被转换成-Infinity (负无穷),如果这个数值是正数,则会被转换成Infinity (正无穷)。

如上所述,如果某次计算返回了正或负的Infinity 值,那么该值将无法继续参与下一次的计算,因为Infinity 不是能够参与计算的数值。要想确定一个数值是不是有穷的(换句话说,是不是位于最小和最大的数值之间),可以使用isFinite() 函数。这个函数在参数位于最小与最大数值之间时会返回true ,如下面的例子所示:

var result = Number.MAX_VALUE + Number.MAX_VALUE;
alert(isFinite(result));   //false


尽管在计算中很少出现某些值超出表示范围的情况,但在执行极小或极大数值的计算时,检测监控这些值是可能的,也是必需的。

访问 Number.NEGATIVE_INFINITY Number.POSITIVE_INFINITY 也可以得到负和正 Infinity 的值。可以想见,这两个属性中分别保存着 -Infinity Infinity

3. NaN

NaN ,即非数值(Not a Number)是一个特殊的数值,这个数值用于表示一个本来要返回数值的操作数未返回数值的情况(这样就不会抛出错误了)。例如,在其他编程语言中,任何数值除以0都会导致错误,从而停止代码执行。但在ECMAScript中,任何数值除以0会返回NaN ,因此不会影响其他代码的执行。

NaN 本身有两个非同寻常的特点。首先,任何涉及NaN 的操作(例如NaN /10)都会返回NaN ,这个特点在多步计算中有可能导致问题。其次,NaN 与任何值都不相等,包括NaN 本身。例如,下面的代码会返回false

alert(NaN == NaN);      //false


针对NaN 的这两个特点,ECMAScript定义了isNaN() 函数。这个函数接受一个参数,该参数可以是任何类型,而函数会帮我们确定这个参数是否“不是数值”。isNaN() 在接收到一个值之后,会尝试将这个值转换为数值。某些不是数值的值会直接转换为数值,例如字符串"10"Boolean 值。而任何不能被转换为数值的值都会导致这个函数返回true 。请看下面的例子:

alert(isNaN(NaN));              //true
alert(isNaN(10));               //false(10是一个数值)
alert(isNaN("10"));             //false(可以被转换成数值10)
alert(isNaN("blue"));           //true(不能转换成数值)
alert(isNaN(true));             //false(可以被转换成数值1)


NumberExample03.htm

这个例子测试了5个不同的值。测试的第一个值是NaN 本身,结果当然会返回true 。然后分别测试了数值10和字符串"10" ,结果这两个测试都返回了false ,因为前者本身就是数值,而后者可以被转换成数值。但是,字符串"blue" 不能被转换成数值,因此函数返回了true 。由于Booleantrue 可以转换成数值1,因此函数返回false

尽管有点儿不可思议,但 isNaN() 确实也适用于对象。在基于对象调用 isNaN() 函数时,会首先调用对象的 valueOf() 方法,然后确定该方法返回的值是否可以转换为数值。如果不能,则基于这个返回值再调用 toString() 方法,再测试返回值。而这个过程也是ECMAScript中内置函数和操作符的一般执行流程,更详细的内容请参见3.5节。

4. 数值转换

有3个函数可以把非数值转换为数值:Number()parseInt()parseFloat() 。第一个函数,即转型函数Number() 可以用于任何数据类型,而另两个函数则专门用于把字符串转换成数值。这3个函数对于同样的输入会有返回不同的结果。

Number() 函数的转换规则如下。

根据这么多的规则使用Number() 把各种数据类型转换为数值确实有点复杂。下面还是给出几个具体的例子吧。

var num1 = Number("Hello world!");      //NaN
var num2 = Number("");                  //0
var num3 = Number("000011");            //11
var num4 = Number(true);                //1


NumberExample04.htm

首先,字符串"Hello world!" 会被转换为NaN ,因为其中不包含任何有意义的数字值。空字符串会被转换为0。字符串"000011" 会被转换为11,因为忽略了其前导的零。最后,true 值被转换为1。

一元加操作符(3.5.1节将介绍)的操作与Number() 函数相同。

由于Number() 函数在转换字符串时比较复杂而且不够合理,因此在处理整数的时候更常用的是parseInt() 函数。parseInt() 函数在转换字符串时,更多的是看其是否符合数值模式。它会忽略字符串前面的空格,直至找到第一个非空格字符。如果第一个字符不是数字字符或者负号,parseInt() 就会返回NaN ;也就是说,用parseInt() 转换空字符串会返回NaNNumber() 对空字符返回0)。如果第一个字符是数字字符,parseInt() 会继续解析第二个字符,直到解析完所有后续字符或者遇到了一个非数字字符。例如,"1234blue" 会被转换为1234,因为"blue" 会被完全忽略。类似地,"22.5" 会被转换为22,因为小数点并不是有效的数字字符。

如果字符串中的第一个字符是数字字符,parseInt() 也能够识别出各种整数格式(即前面讨论的十进制、八进制和十六进制数)。也就是说,如果字符串以"0x" 开头且后跟数字字符,就会将其当作一个十六进制整数;如果字符串以"0" 开头且后跟数字字符,则会将其当作一个八进制数来解析。

为了更好地理解parseInt() 函数的转换规则,下面给出一些例子:

var num1 = parseInt("1234blue");        // 1234
var num2 = parseInt("");                // NaN
var num3 = parseInt("0xA");             // 10(十六进制数)
var num4 = parseInt(22.5);              // 22
var num5 = parseInt("070");             // 56(八进制数)
var num6 = parseInt("70");              // 70(十进制数)
var num7 = parseInt("0xf");             // 15(十六进制数)


NumberExample05.htm

在使用parseInt() 解析像八进制字面量的字符串时,ECMAScript 3和5存在分歧。例如:

//ECMAScript 3认为是56(八进制),ECMAScript 5认为是0(十进制)
var num = parseInt("070");


在ECMAScript 3 JavaScript引擎中,"070" 被当成八进制字面量,因此转换后的值是十进制的56。而在ECMAScript 5 JavaScript引擎中,parseInt() 已经不具有解析八进制值的能力,因此前导的零会被认为无效,从而将这个值当成"0" ,结果就得到十进制的0。在ECMAScript 5中,即使是在严格模式下也会如此。

为了消除在使用parseInt() 函数时可能导致的上述困惑,可以为这个函数提供第二个参数:转换时使用的基数(即多少进制)。如果知道要解析的值是十六进制格式的字符串,那么指定基数16作为第二个参数,可以保证得到正确的结果,例如:

var num = parseInt("0xAF", 16);         //175


实际上,如果指定了16作为第二个参数,字符串可以不带前面的"0x" ,如下所示:

var num1 = parseInt("AF", 16);          //175
var num2 = parseInt("AF");              //NaN


NumberExample06.htm

这个例子中的第一个转换成功了,而第二个则失败了。差别在于第一个转换传入了基数,明确告诉parseInt() 要解析一个十六进制格式的字符串;而第二个转换发现第一个字符不是数字字符,因此就自动终止了。

指定基数会影响到转换的输出结果。例如:

var num1 = parseInt("10", 2);           //2   (按二进制解析)
var num2 = parseInt("10", 8);           //8   (按八进制解析)
var num3 = parseInt("10", 10);          //10  (按十进制解析)
var num4 = parseInt("10", 16);          //16  (按十六进制解析)


NumberExample07.htm

不指定基数意味着让parseInt() 决定如何解析输入的字符串,因此为了避免错误的解析,我们建议无论在什么情况下都明确指定基数。

多数情况下,我们要解析的都是十进制数值,因此始终将10作为第二个参数是非常必要的。

parseInt() 函数类似,parseFloat() 也是从第一个字符(位置0)开始解析每个字符。而且也是一直解析到字符串末尾,或者解析到遇见一个无效的浮点数字字符为止。也就是说,字符串中的第一个小数点是有效的,而第二个小数点就是无效的了,因此它后面的字符串将被忽略。举例来说,"22.34.5" 将会被转换为22.34。

除了第一个小数点有效之外,parseFloat()parseInt() 的第二个区别在于它始终都会忽略前导的零。parseFloat() 可以识别前面讨论过的所有浮点数值格式,也包括十进制整数格式。但十六进制格式的字符串则始终会被转换成0。由于parseFloat() 只解析十进制值,因此它没有用第二个参数指定基数的用法。最后还要注意一点:如果字符串包含的是一个可解析为整数的数(没有小数点,或者小数点后都是零),parseFloat() 会返回整数。以下是使用parseFloat() 转换数值的几个典型示例。

var num1 = parseFloat("1234blue");              //1234 (整数)
var num2 = parseFloat("0xA");                   //0
var num3 = parseFloat("22.5");                  //22.5
var num4 = parseFloat("22.34.5");               //22.34
var num5 = parseFloat("0908.5");                //908.5
var num6 = parseFloat("3.125e7");               //31250000


NumberExample08.htm

3.4.6 String 类型

String 类型用于表示由零或多个16位Unicode字符组成的字符序列,即字符串。字符串可以由双引号(")或单引号(')表示,因此下面两种字符串的写法都是有效的:

var firstName = "Nicholas";
var lastName = 'Zakas';


与PHP中的双引号和单引号会影响对字符串的解释方式不同,ECMAScript中的这两种语法形式没有什么区别。用双引号表示的字符串和用单引号表示的字符串完全相同。不过,以双引号开头的字符串也必须以双引号结尾,而以单引号开头的字符串必须以单引号结尾。例如,下面这种字符串表示法会导致语法错误:

var firstName = 'Nicholas"; // 语法错误(左右引号必须匹配)


1. 字符字面量

String 数据类型包含一些特殊的字符字面量,也叫转义序列,用于表示非打印字符,或者具有其他用途的字符。这些字符字面量如下表所示:

字 面 量 含  义
\n 换行
\t 制表
\b 空格
\r 回车
\f 进纸
\\ 斜杠
\' 单引号(' ),在用单引号表示的字符串中使用。例如:'He said, \'hey.\''
\" 双引号(" ),在用双引号表示的字符串中使用。例如:"He said, \"hey.\""
\xnn 以十六进制代码 nn 表示的一个字符(其中 n 为0~F)。例如,\x41 表示"A"
\unnnn 以十六进制代码 nnnn 表示的一个Unicode字符(其中 n 为0~F)。例如,\u03a3表示希腊字符Σ

这些字符字面量可以出现在字符串中的任意位置,而且也将被作为一个字符来解析,如下面的例子所示:

var text = "This is the letter sigma: \u03a3.";


这个例子中的变量text 有28个字符,其中6个字符长的转义序列表示1个字符。

任何字符串的长度都可以通过访问其length 属性取得,例如:

alert(text.length); // 输出28


这个属性返回了这个字符串中16位字符的数目。如果字符串中包含双字节字符,那么length 属性可能不会精确地返回字符串中的字符数目。

2. 字符串的特点

ECMAScript中的字符串是不可变的,也就是说,字符串一旦创建,它们的值就不能改变。要改变某个变量保存的字符串,首先要销毁原来的字符串,然后再用另一个包含新值的字符串填充该变量,例如:

var lang = "Java";
lang = lang + "Script";


以上示例中的变量lang 开始时包含字符串"Java" 。而第二行代码把lang 的值重新定义为"Java""Script" 的组合,即"JavaScript" 。实现这个操作的过程如下:首先创建一个能容纳10个字符的新字符串,然后在这个字符串中填充"Java""Script" ,最后一步是销毁原来的字符串"Java" 和字符串"Script" ,因为这两个字符串已经没用了。这个过程是在后台发生的,而这也是在某些旧版本的浏览器(例如版本低于1.0的Firefox、IE6等)中拼接字符串时速度很慢的原因所在。但这些浏览器后来的版本已经解决了这个低效率问题。

3. 转换为字符串

要把一个值转换为一个字符串有两种方式。第一种是使用几乎每个值都有的toString() 方法(第5章将讨论这个方法的特点)。这个方法唯一要做的就是返回相应值的字符串表现。来看下面的例子:

var age = 11;
var ageAsString = age.toString();         // 字符串"11"
var found = true;
var foundAsString = found.toString();     // 字符串"true"


StringExample01.htm

数值、布尔值、对象和字符串值(没错,每个字符串也都有一个toString() 方法,该方法返回字符串的一个副本)都有toString() 方法。但nullundefined 值没有这个方法。

多数情况下,调用toString() 方法不必传递参数。但是,在调用数值的toString() 方法时,可以传递一个参数:输出数值的基数。默认情况下,toString() 方法以十进制格式返回数值的字符串表示。而通过传递基数,toString() 可以输出以二进制、八进制、十六进制,乃至其他任意有效进制格式表示的字符串值。下面给出几个例子:

var num = 10;
alert(num.toString());          // "10"
alert(num.toString(2));         // "1010"
alert(num.toString(8));         // "12"
alert(num.toString(10));        // "10"
alert(num.toString(16));        // "a"


StringExample02.htm

通过这个例子可以看出,通过指定基数,toString() 方法会改变输出的值。而数值10根据基数的不同,可以在输出时被转换为不同的数值格式。注意,默认的(没有参数的)输出值与指定基数10时的输出值相同。

在不知道要转换的值是不是nullundefined 的情况下,还可以使用转型函数String() ,这个函数能够将任何类型的值转换为字符串。String() 函数遵循下列转换规则:

下面再看几个例子:

var value1 = 10;
var value2 = true;
var value3 = null;
var value4;

alert(String(value1));     // "10"
alert(String(value2));     // "true"
alert(String(value3));     // "null"
alert(String(value4));     // "undefined"


StringExample03.htm

这里先后转换了4个值:数值、布尔值、nullundefined 。数值和布尔值的转换结果与调用toString() 方法得到的结果相同。因为nullundefined 没有toString() 方法,所以String() 函数就返回了这两个值的字面量。

要把某个值转换为字符串,可以使用加号操作符(3.5节讨论)把它与一个字符串("" )加在一起。

3.4.7 Object 类型

ECMAScript中的对象其实就是一组数据和功能的集合。对象可以通过执行new 操作符后跟要创建的对象类型的名称来创建。而创建Object 类型的实例并为其添加属性和(或)方法,就可以创建自定义对象,如下所示:

var o = new Object();


这个语法与Java中创建对象的语法相似;但在ECMAScript中,如果不给构造函数传递参数,则可以省略后面的那一对圆括号。也就是说,在像前面这个示例一样不传递参数的情况下,完全可以省略那对圆括号(但这不是推荐的做法):

var o = new Object; // 有效,但不推荐省略圆括号


仅仅创建Object 的实例并没有什么用处,但关键是要理解一个重要的思想:即在ECMAScript中,(就像Java中的java.lang.Object 对象一样)Object 类型是所有它的实例的基础。换句话说,Object 类型所具有的任何属性和方法也同样存在于更具体的对象中。

Object 的每个实例都具有下列属性和方法。

相同。

由于在ECMAScript中Object 是所有对象的基础,因此所有对象都具有这些基本的属性和方法。第5章和第6章将详细介绍Object 与其他对象的关系。

从技术角度讲,ECMA-262中对象的行为不一定适用于JavaScript中的其他对象。浏览器环境中的对象,比如BOM和DOM中的对象,都属于宿主对象,因为它们是由宿主实现提供和定义的。ECMA-262不负责定义宿主对象,因此宿主对象可能会也可能不会继承 Object

ECMA-262描述了一组用于操作数据值的操作符 ,包括算术操作符(如加号和减号)、位操作符、关系操作符和相等操作符。ECMAScript操作符的与众不同之处在于,它们能够适用于很多值,例如字符串、数字值、布尔值,甚至对象。不过,在应用于对象时,相应的操作符通常都会调用对象的valueOf() 和(或)toString() 方法,以便取得可以操作的值。

3.5.1 一元操作符

只能操作一个值的操作符叫做一元操作符 。一元操作符是ECMAScript中最简单的操作符。

1. 递增和递减操作符

递增和递减操作符直接借鉴自C,而且各有两个版本:前置型和后置型。顾名思义,前置型应该位于要操作的变量之前,而后置型则应该位于要操作的变量之后。因此,在使用前置递增操作符给一个数值加1时,要把两个加号(++)放在这个数值变量前面,如下所示:

var age = 29;
++age;


在这个例子中,前置递增操作符把age 的值变成了30(为29加上了1)。实际上,执行这个前置递增操作与执行以下操作的效果相同:

var age = 29;
age = age + 1;


执行前置递减操作的方法也类似,结果会从一个数值中减去1。使用前置递减操作符时,要把两个减号(-- )放在相应变量的前面,如下所示:

var age = 29;
--age;


这样,age 变量的值就减少为28(从29中减去了1)。

执行前置递增和递减操作时,变量的值都是在语句被求值以前改变的。(在计算机科学领域,这种情况通常被称作副效应 。)请看下面这个例子。

var age = 29;
var anotherAge = --age + 2;

alert(age);             // 输出28
alert(anotherAge);      // 输出30


IncrementDecrementExample01.htm

这个例子中变量anotherAge 的初始值等于变量age 的值前置递减之后加2。由于先执行了减法操作,age 的值变成了28,所以再加上2的结果就是30。

由于前置递增和递减操作与执行语句的优先级相等,因此整个语句会从左至右被求值。再看一个例子:

var num1 = 2;
var num2 = 20;
var num3 = --num1 + num2;       // 等于21
var num4 = num1 + num2;         // 等于21


IncrementDecrementExample02.htm

在这里,num3 之所以等于21是因为num1 先减去了1才与num2 相加。而变量num4 也等于21是因为相应的加法操作使用了num1 减去1之后的值。

后置型递增和递减操作符的语法不变(仍然分别是++-- ),只不过要放在变量的后面而不是前面。后置递增和递减与前置递增和递减有一个非常重要的区别,即递增和递减操作是在包含它们的语句被求值之后才执行的。这个区别在某些情况下不是什么问题,例如:

var age = 29;
age++;


把递增操作符放在变量后面并不会改变语句的结果,因为递增是这条语句的唯一操作。但是,当语句中还包含其他操作时,上述区别就会非常明显了。请看下面的例子:

var num1 = 2;
var num2 = 20;
var num3 = num1-- + num2;       // 等于22


var num4 = num1 + num2;         // 等于21

IncrementDecrementExample03.htm

这里仅仅将前置递减改成了后置递减,就立即可以看到差别。在前面使用前置递减的例子中,num3num4 最后都等于21。而在这个例子中,num3 等于22,num4 等于21。差别的根源在于,这里在计算num3 时使用了num1 的原始值(2)完成了加法计算,而num4 则使用了递减后的值(1)。

所有这4个操作符对任何值都适用,也就是它们不仅适用于整数,还可以用于字符串、布尔值、浮点数值和对象。在应用于不同的值时,递减和递减操作符遵循下列规则。

以下示例展示了上面的一些规则:

var s1 = "2";
var s2 = "z";
var b = false;
var f = 1.1;
var o = { 
    valueOf: function() {
        return -1;
    }
};

s1++;     // 值变成数值3
s2++;     // 值变成NaN
b++;      // 值变成数值1
f--;      // 值变成0.10000000000000009(由于浮点舍入错误所致)
o--;      // 值变成数值-2


IncrementDecrementExample04.htm

2. 一元加和减操作符

绝大多数开发人员对一元加和减操作符都不会陌生,而且这两个ECMAScript操作符的作用与数学书上讲的完全一样。一元加操作符以一个加号(+)表示,放在数值前面,对数值不会产生任何影响,如下面的例子所示:

var num = 25;
num = +num;     // 仍然是25


不过,在对非数值应用一元加操作符时,该操作符会像Number() 转型函数一样对这个值执行转换。换句话说,布尔值falsetrue 将被转换为0和1,字符串值会被按照一组特殊的规则进行解析,而对象是先调用它们的valueOf() 和(或)toString() 方法,再转换得到的值。

下面的例子展示了对不同数据类型应用一元加操作符的结果:

var s1 = "01";
var s2 = "1.1";
var s3 = "z";
var b = false;
var f = 1.1;
var o = {
    valueOf: function() {
        return -1;
    }
};

s1 = +s1;       // 值变成数值1
s2 = +s2;       // 值变成数值1.1
s3 = +s3;       // 值变成NaN
b = +b;         // 值变成数值0
f = +f;         // 值未变,仍然是1.1
o = +o;         // 值变成数值-1


UnaryPlusMinusExample01.htm

一元减操作符主要用于表示负数,例如将1转换成-1。下面的例子演示了这个简单的转换过程:

var num = 25;
num = -num;     // 变成了-25


在将一元减操作符应用于数值时,该值会变成负数(如上面的例子所示)。而当应用于非数值时,一元减操作符遵循与一元加操作符相同的规则,最后再将得到的数值转换为负数,如下面的例子所示:

var s1 = "01";
var s2 = "1.1";
var s3 = "z";
var b = false;
var f = 1.1;
var o = {
    valueOf: function() {
        return -1;
    }
};

s1 = -s1;         // 值变成了数值-1
s2 = -s2;         // 值变成了数值-1.1
s3 = -s3;         // 值变成了NaN
b = -b;           // 值变成了数值0
f = -f;           // 变成了-1.1
o = -o;           // 值变成了数值1


UnaryPlusMinusExample02.htm

一元加和减操作符主要用于基本的算术运算,也可以像前面示例所展示的一样用于转换数据类型。

3.5.2 位操作符

位操作符用于在最基本的层次上,即按内存中表示数值的位来操作数值。ECMAScript中的所有数值都以IEEE-754 64位格式存储,但位操作符并不直接操作64位的值。而是先将64位的值转换成32位的整数,然后执行操作,最后再将结果转换回64位。对于开发人员来说,由于64位存储格式是透明的,因此整个过程就像是只存在32位的整数一样。

对于有符号的整数,32位中的前31位用于表示整数的值。第32位用于表示数值的符号:0表示正数,1表示负数。这个表示符号的位叫做符号位 ,符号位的值决定了其他位数值的格式。其中,正数以纯二进制格式存储,31位中的每一位都表示2的幂。第一位(叫做位0)表示20 ,第二位表示21 ,以此类推。没有用到的位以0填充,即忽略不计。例如,数值18的二进制表示是00000000000000000000000000010010,或者更简洁的10010。这是5个有效位,这5位本身就决定了实际的值(如图3-1所示)。

图 3-1

负数同样以二进制码存储,但使用的格式是二进制补码 。计算一个数值的二进制补码,需要经过下列3个步骤:

  1. 求这个数值绝对值的二进制码(例如,要求-18的二进制补码,先求18的二进制码);

  2. 求二进制反码,即将0替换为1,将1替换为0;

  3. 得到的二进制反码加1。

要根据这3个步骤求得-18的二进制码,首先就要求得18的二进制码,即:

0000 0000 0000 0000 0000 0000 0001 0010


然后,求其二进制反码,即0和1互换:

1111 1111 1111 1111 1111 1111 1110 1101


最后,二进制反码加1:

1111 1111 1111 1111 1111 1111 1110 1101
                                      1
---------------------------------------
1111 1111 1111 1111 1111 1111 1110 1110


这样,就求得了-18的二进制表示,即11111111111111111111111111101110。要注意的是,在处理有符号整数时,是不能访问位31的。

ECMAScript会尽力向我们隐藏所有这些信息。换句话说,在以二进制字符串形式输出一个负数时,我们看到的只是这个负数绝对值的二进制码前面加上了一个负号。如下面的例子所示:

var num = -18;
alert(num.toString(2));    // "-10010"


要把数值-18转换成二进制字符串时,得到的结果是"-10010" 。这说明转换过程理解了二进制补码并将其以更合乎逻辑的形式展示了出来。

默认情况下,ECMAScript中的所有整数都是有符号整数。不过,当然也存在无符号整数。对于无符号整数来说,第32位不再表示符号,因为无符号整数只能是正数。而且,无符号整数的值可以更大,因为多出的一位不再表示符号,可以用来表示数值。

在ECMAScript中,当对数值应用位操作符时,后台会发生如下转换过程:64位的数值被转换成32位数值,然后执行位操作,最后再将32位的结果转换回64位数值。这样,表面上看起来就好像是在操作32位数值,就跟在其他语言中以类似方式执行二进制操作一样。但这个转换过程也导致了一个严重的副效应,即在对特殊的NaNInfinity 值应用位操作时,这两个值都会被当成0来处理。

如果对非数值应用位操作符,会先使用Number() 函数将该值转换为一个数值(自动完成),然后再应用位操作。得到的结果将是一个数值。

1. 按位非(NOT)

按位非操作符由一个波浪线(~)表示,执行按位非的结果就是返回数值的反码。按位非是ECMAScript操作符中少数几个与二进制计算有关的操作符之一。下面看一个例子:

var num1 = 25;                  // 二进制00000000000000000000000000011001
var num2 = ~num1;               // 二进制11111111111111111111111111100110
alert(num2);                    // -26


BitwiseNotExample01.htm

这里,对25执行按位非操作,结果得到了-26。这也验证了按位非操作的本质:操作数的负值减1。因此,下面的代码也能得到相同的结果:

var num1 = 25;
var num2 = -num1 - 1;
alert(num2);            // "-26"


虽然以上代码也能返回同样的结果,但由于按位非是在数值表示的最底层执行操作,因此速度更快。

2. 按位与(AND)

按位与操作符由一个和号字符(& )表示,它有两个操作符数。从本质上讲,按位与操作就是将两个数值的每一位对齐,然后根据下表中的规则,对相同位置上的两个数执行AND操作:

第一个数值的位 第二个数值的位 结  果
1 1 1
1 0 0
0 1 0
0 0 0

简而言之,按位与操作只在两个数值的对应位都是1时才返回1,任何一位是0,结果都是0。

下面看一个对25和3执行按位与操作的例子:

var result = 25 & 3;
alert(result);     //1


BitwiseAndExample01.htm

可见,对25和3执行按位与操作的结果是1。为什么呢?请看其底层操作:

25  = 0000 0000 0000 0000 0000 0000 0001 1001
 3  = 0000 0000 0000 0000 0000 0000 0000 0011
---------------------------------------------
AND = 0000 0000 0000 0000 0000 0000 0000 0001


原来,25和3的二进制码对应位上只有一位同时是1,而其他位的结果自然都是0,因此最终结果等于1。

3. 按位或(OR)

按位或操作符由一个竖线符号(|)表示,同样也有两个操作数。按位或操作遵循下面这个真值表。

第一个数值的位 第二个数值的位 结  果
1 1 1
1 0 1
0 1 1
0 0 0

由此可见,按位或操作在有一个位是1的情况下就返回1,而只有在两个位都是0的情况下才返回0。

如果在前面按位与的例子中对25和3执行按位或操作,则代码如下所示:

var result = 25 | 3;
alert(result);       //27


BitwiseOrExample01.htm

25与3按位或的结果是27:

25 = 0000 0000 0000 0000 0000 0000 0001 1001
3  = 0000 0000 0000 0000 0000 0000 0000 0011
--------------------------------------------
OR = 0000 0000 0000 0000 0000 0000 0001 1011


这两个数值的都包含4个1,因此可以把每个1直接放到结果中。二进制码11011等于十进制值27。

4. 按位异或(XOR)

按位异或操作符由一个插入符号(^)表示,也有两个操作数。以下是按位异或的真值表。

第一个数值的位 第二个数值的位 结  果
1 1 0
1 0 1
0 1 1
0 0 0

按位异或与按位或的不同之处在于,这个操作在两个数值对应位上只有一个1时才返回1,如果对应的两位都是1或都是0,则返回0。

对25和3执行按位异或操作的代码如下所示:

var result = 25 ^ 3;
alert(result);    //26


BitwiseXorExample01.htm

25与3按位异或的结果是26,其底层操作如下所示:

25  = 0000 0000 0000 0000 0000 0000 0001 1001
 3  = 0000 0000 0000 0000 0000 0000 0000 0011
---------------------------------------------
XOR = 0000 0000 0000 0000 0000 0000 0001 1010


这两个数值都包含4个1,但第一位上则都是1,因此结果的第一位变成了0。而其他位上的1在另一个数值中都没有对应的1,可以直接放到结果中。二进制码11010等于十进制值26(注意这个结果比执行按位或时小1)。

5. 左移

左移操作符由两个小于号(<<)表示,这个操作符会将数值的所有位向左移动指定的位数。例如,如果将数值2(二进制码为10)向左移动5位,结果就是64(二进制码为1000000),代码如下所示:

var oldValue = 2;                       // 等于二进制的10
var newValue = oldValue << 5;           // 等于二进制的1000000,十进制的64


LeftShiftExample01.htm

注意,在向左移位后,原数值的右侧多出了5个空位。左移操作会以0来填充这些空位,以便得到的结果是一个完整的32位二进制数(见图3-2)。

图 3-2

注意,左移不会影响操作数的符号位。换句话说,如果将-2向左移动5位,结果将是-64,而非64。

6. 有符号的右移

有符号的右移操作符由两个大于号(>>)表示,这个操作符会将数值向右移动,但保留符号位(即正负号标记)。有符号的右移操作与左移操作恰好相反,即如果将64向右移动5位,结果将变回2:

var oldValue = 64;                      // 等于二进制的1000000
var newValue = oldValue >> 5;           // 等于二进制的10 ,即十进制的2


SignedRightShiftExample01.htm

同样,在移位过程中,原数值中也会出现空位。只不过这次的空位出现在原数值的左侧、符号位的右侧(见图3-3)。而此时ECMAScript会用符号位的值来填充所有空位,以便得到一个完整的值。

图 3-3

7. 无符号右移

无符号右移操作符由3个大于号(>>>)表示,这个操作符会将数值的所有32位都向右移动。对正数来说,无符号右移的结果与有符号右移相同。仍以前面有符号右移的代码为例,如果将64无符号右移5位,结果仍然还是2:

var oldValue = 64;                      // 等于二进制的1000000
var newValue = oldValue >>> 5;          // 等于二进制的10 ,即十进制的2


UnsignedRightShiftExample01.htm

但是对负数来说,情况就不一样了。首先,无符号右移是以0来填充空位,而不是像有符号右移那样以符号位的值来填充空位。所以,对正数的无符号右移与有符号右移结果相同,但对负数的结果就不一样了。其次,无符号右移操作符会把负数的二进制码当成正数的二进制码。而且,由于负数以其绝对值的二进制补码形式表示,因此就会导致无符号右移后的结果非常之大,如下面的例子所示:

var oldValue = -64;                     // 等于二进制的11111111111111111111111111000000


var newValue = oldValue >>> 5;          // 等于十进制的134217726

UnsignedRightShiftExample02.htm

这里,当对-64执行无符号右移5位的操作后,得到的结果是134217726。之所以结果如此之大,是因为-64的二进制码为11111111111111111111111111000000,而且无符号右移操作会把这个二进制码当成正数的二进制码,换算成十进制就是4294967232。如果把这个值右移5位,结果就变成了00000111111111111111111111111110,即十进制的134217726。

3.5.3 布尔操作符

在一门编程语言中,布尔操作符的重要性堪比相等操作符。如果没有测试两个值关系的能力,那么诸如if...else 和循环之类的语句就不会有用武之地了。布尔操作符一共有3个:非(NOT)、与(AND)和或(OR)。

1. 逻辑非

逻辑非操作符由一个叹号(!)表示,可以应用于ECMAScript中的任何值。无论这个值是什么数据类型,这个操作符都会返回一个布尔值。逻辑非操作符首先会将它的操作数转换为一个布尔值,然后再对其求反。也就是说,逻辑非操作符遵循下列规则:

下面几个例子展示了应用上述规则的结果:

alert(!false);          // true
alert(!"blue");         // false
alert(!0);              // true
alert(!NaN);            // true
alert(!"");             // true
alert(!12345);          // false


LogicalNotExample01.htm

逻辑非操作符也可以用于将一个值转换为与其对应的布尔值。而同时使用两个逻辑非操作符,实际上就会模拟Boolean() 转型函数的行为。其中,第一个逻辑非操作会基于无论什么操作数返回一个布尔值,而第二个逻辑非操作则对该布尔值求反,于是就得到了这个值真正对应的布尔值。当然,最终结果与对这个值使用Boolean() 函数相同,如下面的例子所示:

alert(!!"blue");        //true
alert(!!0);             //false
alert(!!NaN);           //false
alert(!!"");            //false
alert(!!12345);         //true


LogicalNotExample02.htm

2. 逻辑与

逻辑与操作符由两个和号(&& )表示,有两个操作数,如下面的例子所示:

var result = true && false;


逻辑与的真值表如下:

第一个操作数 第二个操作数 结  果
true true true
true false false
false true false
false false false

逻辑与操作可以应用于任何类型的操作数,而不仅仅是布尔值。在有一个操作数不是布尔值的情况下,逻辑与操作就不一定返回布尔值;此时,它遵循下列规则:

逻辑与操作属于短路操作,即如果第一个操作数能够决定结果,那么就不会再对第二个操作数求值。对于逻辑与操作而言,如果第一个操作数是false ,则无论第二个操作数是什么值,结果都不再可能是true 了。来看下面的例子:

var found = true;
var result = (found && someUndefinedVariable);    // 这里会发生错误
alert(result);    // 这一行不会执行


LogicalAndExample01.htm

在上面的代码中,当执行逻辑与操作时会发生错误,因为变量someUndefinedVariable 没有声明。由于变量found 的值是true ,所以逻辑与操作符会继续对变量someUndefinedVariable 求值。但someUndefinedVariable 尚未定义,因此就会导致错误。这说明不能在逻辑与操作中使用未定义的值。如果像下面这个例中一样,将found 的值设置为false ,就不会发生错误了:

var found = false;


var result = (found && someUndefinedVariable);     // 不会发生错误
alert(result);    // 会执行("false")

LogicalAndExample02.htm

在这个例子中,警告框会显示出来。无论变量someUndefinedVariable 有没有定义,也永远不会对它求值,因为第一个操作数的值是false 。而这也就意味着逻辑与操作的结果必定是false ,根本用不着再对&& 右侧的操作数求值了。在使用逻辑与操作符时要始终铭记它是一个短路操作符。

3. 逻辑或

逻辑或操作符由两个竖线符号(|| )表示,有两个操作数,如下面的例子所示:

var result = true || false;


逻辑或的真值表如下:

第一个操作数 第二个操作数 结  果
True true true
True false true
false true true
false false false

与逻辑与操作相似,如果有一个操作数不是布尔值,逻辑或也不一定返回布尔值;此时,它遵循下列规则:

与逻辑与操作符相似,逻辑或操作符也是短路操作符。也就是说,如果第一个操作数的求值结果为true ,就不会对第二个操作数求值了。下面看一个例子:

var found = true;
var result = (found || someUndefinedVariable);     // 不会发生错误
alert(result);    // 会执行("true")


LogicalOrExample01.htm

这个例子跟前面的例子一样,变量someUndefinedVariable 也没有定义。但是,由于变量found 的值是true ,而变量someUndefinedVariable 永远不会被求值,因此结果就会输出"true" 。如果像下面这个例子一样,把found 的值改为false ,就会导致错误:

var found = false;


var result = (found || someUndefinedVariable);     // 这里会发生错误
alert(result);    // 这一行不会执行

LogicalOrExample02.htm

我们可以利用逻辑或的这一行为来避免为变量赋nullundefined 值。例如:

var myObject = preferredObject || backupObject;


在这个例子中,变量myObject 将被赋予等号后面两个值中的一个。变量preferredObject 中包含优先赋给变量myObject 的值,变量backupObject 负责在preferredObject 中不包含有效值的情况下提供后备值。如果preferredObject 的值不是null ,那么它的值将被赋给myObject ;如果是null ,则将backupObject 的值赋给myObject 。ECMAScript程序的赋值语句经常会使用这种模式,本书也将采用这种模式。

3.5.4 乘性操作符

ECMAScript定义了3个乘性操作符:乘法、除法和求模。这些操作符与Java、C或者Perl中的相应操作符用途类似,只不过在操作数为非数值的情况下会执行自动的类型转换。如果参与乘法计算的某个操作数不是数值,后台会先使用Number() 转型函数将其转换为数值。也就是说,空字符串将被当作0,布尔值true 将被当作1。

1. 乘法

乘法操作符由一个星号(* )表示,用于计算两个数值的乘积。其语法类似于C,如下面的例子所示:

var result = 34 * 56;


在处理特殊值的情况下,乘法操作符遵循下列特殊的规则:

规则。

2. 除法

除法操作符由一个斜线符号(/)表示,执行第二个操作数除第一个操作数的计算,如下面的例子所示:

var result = 66 / 11;

与乘法操作符类似,除法操作符对特殊的值也有特殊的处理规则。这些规则如下:

3. 求模

求模(余数)操作符由一个百分号(% )表示,用法如下:

var result = 26 % 5;     // 等于1

与另外两个乘性操作符类似,求模操作符会遵循下列特殊规则来处理特殊的值:

3.5.5 加性操作符

加法和减法这两个加性操作符应该说是编程语言中最简单的算术操作符了。但是在ECMAScript中,这两个操作符却都有一系列的特殊行为。与乘性操作符类似,加性操作符也会在后台转换不同的数据类型。然而,对于加性操作符而言,相应的转换规则还稍微有点复杂。

1. 加法

加法操作符(+)的用法如下所示:

var result = 1 + 2;


如果两个操作符都是数值,执行常规的加法计算,然后根据下列规则返回结果:

不过,如果有一个操作数是字符串,那么就要应用如下规则:

如果有一个操作数是对象、数值或布尔值,则调用它们的toString() 方法取得相应的字符串值,然后再应用前面关于字符串的规则。对于undefinednull ,则分别调用String() 函数并取得字符串"undefined""null"

下面来举几个例子:

var result1 = 5 + 5;            // 两个数值相加
alert(result1);                 // 10

var result2 = 5 + "5";          // 一个数值和一个字符串相加
alert(result2);                 // "55"


AddExample01.htm

以上代码演示了加法操作符在两种模式下的差别。第一行代码演示了正常的情况,即5+5等于10(数值)。但是,如果将一个操作数改为字符串"5" ,结果就变成了"55" (字符串值),因为第一个操作数也被转换成了"5"

忽视加法操作中的数据类型是ECMAScript编程中最常见的一个错误。再来看一个例子:

var num1 = 5;
var num2 = 10;
var message = "The sum of 5 and 10 is " + num1 + num2;
alert(message);    // "The sum of 5 and 10 is 510"


AddExample02.htm

在这个例子中,变量message 的值是执行两个加法操作之后的结果。有人可能以为最后得到的字符串是"The sum of 5 and 10 is 15" ,但实际的结果却是"The sum of 5 and 10 is 510" 。之所以会这样,是因为每个加法操作是独立执行的。第一个加法操作将一个字符串和一个数值(5)拼接了起来,结果是一个字符串。而第二个加法操作又用这个字符串去加另一个数值(10),当然也会得到一个字符串。如果想先对数值执行算术计算,然后再将结果与字符串拼接起来,应该像下面这样使用圆括号:

var num1 = 5;
var num2 = 10;
var message = "The sum of 5 and 10 is " + (num1 + num2);


alert(message);    //"The sum of 5 and 10 is 15"

AddExample03.htm

在这个例子中,一对圆括号把两个数值变量括在了一起,这样就会告诉解析器先计算其结果,然后再将结果与字符串拼接起来。因此,就得到了结果"The sum of 5 and 10 is 15"

2. 减法

减法操作符(-)是另一个极为常用的操作符,其用法如下所示:

var result = 2 - 1;


与加法操作符类似,ECMAScript中的减法操作符在处理各种数据类型转换时,同样需要遵循一些特殊规则,如下所示:

下面几个例子展示了上面的规则:

var result1 = 5 - true;         // 4,因为true被转换成了1
var result2 = NaN - 1;          // NaN
var result3 = 5 - 3;            // 2
var result4 = 5 - "";           // 5,因为"" 被转换成了0
var result5 = 5 - "2";          // 3,因为"2"被转换成了2
var result6 = 5 - null;         // 5,因为null被转换成了0


SubtractExample01.htm

3.5.6 关系操作符

小于(<)、大于(>)、小于等于(<=)和大于等于(>=)这几个关系操作符用于对两个值进行比较,比较的规则与我们在数学课上所学的一样。这几个操作符都返回一个布尔值,如下面的例子所示:

var result1 = 5 > 3;         //true
var result2 = 5 < 3;         //false


RelationalOperatorsExample01.htm 中包含本节所有的代码片段

与ECMAScript中的其他操作符一样,当关系操作符的操作数使用了非数值时,也要进行数据转换或完成某些奇怪的操作。以下就是相应的规则。

在使用关系操作符比较两个字符串时,会执行一种奇怪的操作。很多人都会认为,在比较字符串值时,小于的意思是“在字母表中的位置靠前”,而大于则意味着“在字母表中的位置靠后”,但实际上完全不是那么回事。在比较字符串时,实际比较的是两个字符串中对应位置的每个字符的字符编码值。经过这么一番比较之后,再返回一个布尔值。由于大写字母的字符编码全部小于小写字母的字符编码,因此我们就会看到如下所示的奇怪现象:

var result = "Brick" < "alphabet";    //true


在这个例子中,字符串"Brick" 被认为小于字符串"alphabet" 。原因是字母B的字符编码为66,而字母a的字符编码是97。如果要真正按字母表顺序比较字符串,就必须把两个操作数转换为相同的大小写形式(全部大写或全部小写),然后再执行比较,如下所示:

var result = "Brick".toLowerCase() < "alphabet".toLowerCase(); //false


通过将两个操作数都转换为小写形式,就可以得出"alphabet" 按字母表顺序排在"Brick" 之前的正确判断了。

另一种奇怪的现象发生在比较两个数字字符串的情况下,比如下面这个例子:

var result = "23" < "3";    //true


确实,当比较字符串"23" 是否小于"3" 时,结果居然是true 。这是因为两个操作数都是字符串,而字符串比较的是字符编码("2" 的字符编码是50,而"3" 的字符编码是51)。不过,如果像下面例子中一样,将一个操作数改为数值,比较的结果就正常了:

var result = "23" < 3;    //false


此时,字符串"23" 会被转换成数值23,然后再与3进行比较,因此就会得到合理的结果。在比较数值和字符串时,字符串都会被转换成数值,然后再以数值方式与另一个数值比较。当然,这个规则对前面的例子是适用的。可是,如果那个字符串不能被转换成一个合理的数值呢?比如:

var result = "a" < 3;    // false,因为"a"被转换成了NaN


由于字母"a" 不能转换成合理的数值,因此就被转换成了NaN 。根据规则,任何操作数与NaN 进行关系比较,结果都是false 。于是,就出现了下面这个有意思的现象:

var result1 = NaN < 3;    //false
var result2 = NaN >= 3;   //false


按照常理,如果一个值不小于另一个值,则一定是大于或等于那个值。然而,在与NaN 进行比较时,这两个比较操作的结果都返回了false

3.5.7 相等操作符

确定两个变量是否相等是编程中的一个非常重要的操作。在比较字符串、数值和布尔值的相等性时,问题还比较简单。但在涉及到对象的比较时,问题就变得复杂了。最早的ECMAScript中的相等和不等操作符会在执行比较之前,先将对象转换成相似的类型。后来,有人提出了这种转换到底是否合理的质疑。最后,ECMAScript的解决方案就是提供两组操作符:相等不相等 ——先转换再比较,全等不全等 ——仅比较而不转换。

1. 相等和不相等

ECMAScript中的相等操作符由两个等于号(== )表示,如果两个操作数相等,则返回true 。而不相等操作符由叹号后跟等于号(!= )表示,如果两个操作数不相等,则返回true 。这两个操作符都会先转换操作数(通常称为强制转型 ),然后再比较它们的相等性。

在转换不同的数据类型时,相等和不相等操作符遵循下列基本规则:

这两个操作符在进行比较时则要遵循下列规则。

下表列出了一些特殊情况及比较结果:

表 达 式 表 达 式
null == undefined true true == 1 true
"NaN" == NaN false true == 2 false
5 == NaN false undefined == 0 false
NaN == NaN false null == 0 false
NaN != NaN true "5"==5 true
false == 0 true

2. 全等和不全等

除了在比较之前不转换操作数之外,全等和不全等操作符与相等和不相等操作符没有什么区别。全等操作符由3个等于号(=== )表示,它只在两个操作数未经转换就相等的情况下返回true ,如下面的例子所示:

var result1 = ("55" == 55);     //true,因为转换后相等
var result2 = ("55" === 55);    //false,因为不同的数据类型不相等


EqualityOperatorsExample02.htm

在这个例子中,第一个比较使用的是相等操作符比较字符串"55" 和数值55,结果返回了true 。如前所述,这是因为字符串"55" 先被转换成了数值55,然后再与另一个数值55进行比较。第二个比较使用了全等操作符以不转换数值的方式比较同样的字符串和值。在不转换的情况下,字符串当然不等于数值,因此结果就是false

不全等操作符由一个叹号后跟两个等于号(!== )表示,它在两个操作数未经转换就不相等的情况下返回true 。例如:

var result1 = ("55" != 55);     //false,因为转换后相等
var result2 = ("55" !== 55);    //true,因为不同的数据类型不相等


EqualityOperatorsExample03.htm

在这个例子中,第一个比较使用了不相等操作符,而该操作符会将字符串"55" 转换成55,结果就与第二个操作数(也是55)相等了。而由于这两个操作数被认为相等,因此就返回了false 。第二个比较使用了不全等操作符。假如我们这样想:字符串55与数值55不相同吗?,那么答案一定是:是的(true )。

记住:null == undefined 会返回true ,因为它们是类似的值;但null === undefined 会返回false ,因为它们是不同类型的值。

由于相等和不相等操作符存在类型转换问题,而为了保持代码中数据类型的完整性,我们推荐使用全等和不全等操作符。

3.5.8 条件操作符

条件操作符应该算是ECMAScript中最灵活的一种操作符了,而且它遵循与Java中的条件操作符相同的语法形式,如下面的例子所示:

variable = boolean_expression ? true_value : false_value;


本质上,这行代码的含义就是基于对boolean_expression 求值的结果,决定给变量variable 赋什么值。如果求值结果为true ,则给变量variabletrue_value 值;如果求值结果为false ,则给变量variablefalse_value 值。再看一个例子:

var max = (num1 > num2) ? num1 : num2;


在这个例子中,max 中将会保存一个最大的值。这个表达式的意思是:如果num1 大于num2 (关系表达式返回true ),则将num1 的值赋给max ;如果num1 小于或等于num2 (关系表达式返回false ),则将num2 的值赋给max

3.5.9 赋值操作符

简单的赋值操作符由等于号(= )表示,其作用就是把右侧的值赋给左侧的变量,如下面的例子所示:

var num = 10;


如果在等于号(= )前面再添加乘性操作符、加性操作符或位操作符,就可以完成复合赋值操作。这种复合赋值操作相当于是对下面常规表达式的简写形式:

var num = 10;
num = num + 10;


其中的第二行代码可以用一个复合赋值来代替:

var num = 10;
num += 10;


每个主要算术操作符(以及个别的其他操作符)都有对应的复合赋值操作符。这些操作符如下所示:

设计这些操作符的主要目的就是简化赋值操作。使用它们不会带来任何性能的提升。

3.5.10 逗号操作符

使用逗号操作符可以在一条语句中执行多个操作,如下面的例子所示:

var num1=1, num2=2, num3=3;


逗号操作符多用于声明多个变量;但除此之外,逗号操作符还可以用于赋值。在用于赋值时,逗号操作符总会返回表达式中的最后一项,如下面的例子所示:

var num = (5, 1, 4, 8, 0); // num的值为0


由于0是表达式中的最后一项,因此num 的值就是0。虽然逗号的这种使用方式并不常见,但这个例子可以帮我们理解逗号的这种行为。

ECMA-262规定了一组语句(也称为流控制语句)。从本质上看,语句定义了ECMAScript中的主要语法,语句通常使用一或多个关键字来完成给定任务。语句可以很简单,例如通知函数退出;也可以比较复杂,例如指定重复执行某个命令的次数。

3.6.1 if 语句

大多数编程语言中最为常用的一个语句就是if 语句。以下是if 语句的语法:

if (condition) statement1 else statement2


其中的condition(条件)可以是任意表达式;而且对这个表达式求值的结果不一定是布尔值。ECMAScript会自动调用Boolean() 转换函数将这个表达式的结果转换为一个布尔值。如果对condition求值的结果是true ,则执行statement1(语句1),如果对condition求值的结果是false ,则执行statement2(语句2)。而且这两个语句既可以是一行代码,也可以是一个代码块(以一对花括号括起来的多行代码)。请看下面的例子。

if (i > 25)
    alert("Greater than 25.");                  // 单行语句
else {
    alert("Less than or equal to 25.");         // 代码块中的语句
}


IfStatementExample01.htm

不过,业界普遍推崇的最佳实践是始终使用代码块,即使要执行的只有一行代码。因为这样可以消除人们的误解,否则可能让人分不清在不同条件下要执行哪些语句。

另外,也可以像下面这样把整个if 语句写在一行代码中:

if (condition1) statement1 else if (condition2) statement2 else statement3


但我们推荐的做法则是像下面这样:

if (i > 25) {
    alert("Greater than 25.");
} else if (i < 0) {
    alert("Less than 0.");
} else {
    alert("Between 0 and 25, inclusive.");
}


IfStatementExample02.htm

3.6.2 do-while 语句

do-while 语句是一种后测试循环语句,即只有在循环体中的代码执行之后,才会测试出口条件。换句话说,在对条件表达式求值之前,循环体内的代码至少会被执行一次。以下是do-while 语句的 语法:

do {
    statement
} while (expression);


下面是一个示例:

var i = 0;
do {
   i += 2;
} while (i < 10);

alert(i);


DoWhileStatementExample01.htm

在这个例子中,只要变量i 的值小于10,循环就会一直继续下去。而且变量i 的值最初为0,每次循环都会递增2。

do-while 这种后测试循环语句最常用于循环体中的代码至少要被执行一次的情形。

3.6.3 while 语句

while 语句属于前测试循环语句,也就是说,在循环体内的代码被执行之前,就会对出口条件求值。因引,循环体内的代码有可能永远不会被执行。以下是while 语句的语法:

while(expression) statement


下面是一个示例:

var i = 0;
while (i < 10) {
    i += 2;
}


WhileStatementExample01.htm

在这个例子中,变量i 开始时的值为0,每次循环都会递增2。而只要i 的值小于10,循环就会继续下去。

3.6.4 for 语句

for 语句也是一种前测试循环语句,但它具有在执行循环之前初始化变量和定义循环后要执行的代码的能力。以下是for 语句的语法:

for (initialization; expression; post-loop-expression) statement


下面是一个示例:

var count = 10;
for (var i = 0; i < count; i++){
    alert(i);
}


ForStatementExample01.htm

以上代码定义了变量i 的初始值为0。只有当条件表达式(i<count )返回true 的情况下才会进入for 循环,因此也有可能不会执行循环体中的代码。如果执行了循环体中的代码,则一定会对循环后的表达式(i++ )求值,即递增i 的值。这个for 循环语句与下面的while 语句的功能相同:

var count = 10;
var i = 0;
while (i < count){
    alert(i);
    i++;
}


使用while 循环做不到的,使用for 循环同样也做不到。也就是说,for 循环只是把与循环有关的代码集中在了一个位置。

有必要指出的是,在for 循环的变量初始化表达式中,也可以不使用var 关键字。该变量的初始化可以在外部执行,例如:

var count = 10;
var i;


for (i = 0; i < count; i++){


    alert(i);
}

ForStatementExample02.htm

以上代码与在循环初始化表达式中声明变量的效果是一样的。由于ECMAScript中不存在块级作用域(第4章将进一步讨论这一点),因此在循环内部定义的变量也可以在外部访问到。例如:

var count = 10;
for (var i = 0; i < count; i++){
    alert(i);
}
alert(i);    //10


ForStatementExample03.htm

在这个例子中,会有一个警告框显示循环完成后变量i 的值,这个值是10。这是因为,即使i 是在循环内部定义的一个变量,但在循环外部仍然可以访问到它。

此外,for 语句中的初始化表达式、控制表达式和循环后表达式都是可选的。将这两个表达式全部省略,就会创建一个无限循环,例如:

for (;;) {     // 无限循环
    doSomething();
}


而只给出控制表达式实际上就把for 循环转换成了while 循环,例如:

var count = 10;
var i = 0;
for (; i < count; ){
    alert(i);
    i++;
}


ForStatementExample04.htm

由于for 语句存在极大的灵活性,因此它也是ECMAScript中最常用的一个语句。

3.6.5 for-in 语句

for-in 语句是一种精准的迭代语句,可以用来枚举对象的属性。以下是for-in 语句的语法:

for (property in expression) statement


下面是一个示例:

for (var propName in window) {
     document.write(propName);
}


ForInStatementExample01.htm

在这个例子中,我们使用for-in 循环来显示了BOM中window 对象的所有属性。每次执行循环时,都会将window 对象中存在的一个属性名赋值给变量propName 。这个过程会一直持续到对象中的所有属性都被枚举一遍为止。与for 语句类似,这里控制语句中的var 操作符也不是必需的。但是,为了保证使用局部变量,我们推荐上面例子中的这种做法。

ECMAScript对象的属性没有顺序。因此,通过for-in 循环输出的属性名的顺序是不可预测的。具体来讲,所有属性都会被返回一次,但返回的先后次序可能会因浏览器而异。

但是,如果表示要迭代的对象的变量值为nullundefinedfor-in 语句会抛出错误。ECMAScript 5更正了这一行为;对这种情况不再抛出错误,而只是不执行循环体。为了保证最大限度的兼容性,建议在使用for-in 循环之前,先检测确认该对象的值不是nullundefined

Safari 3以前版本的 for-in 语句中存在一个bug,该bug会导致某些属性被返回两次。

3.6.6 label 语句

使用label 语句可以在代码中添加标签,以便将来使用。以下是label 语句的语法:

label: statement


下面是一个示例:

start: for (var i=0; i < count; i++) {
    alert(i); 
}


这个例子中定义的start 标签可以在将来由breakcontinue 语句引用。加标签的语句一般都要与for 语句等循环语句配合使用。

3.6.7 breakcontinue 语句

breakcontinue 语句用于在循环中精确地控制代码的执行。其中,break 语句会立即退出循环,强制继续执行循环后面的语句。而continue 语句虽然也是立即退出循环,但退出循环后会从循环的顶部继续执行。请看下面的例子:

var num = 0;

for (var i=1; i < 10; i++) {
    if (i % 5 == 0) {
       break;
    }
    num++;
}

alert(num);    //4


BreakStatementExample01.htm

这个例子中的for 循环会将变量i 由1递增至10。在循环体内,有一个if 语句检查i 的值是否可以被5整除(使用求模操作符)。如果是,则执行break 语句退出循环。另一方面,变量num 从0开始,用于记录循环执行的次数。在执行break 语句之后,要执行的下一行代码是alert() 函数,结果显示4。也就是说,在变量i 等于5时,循环总共执行了4次;而break 语句的执行,导致了循环在num 再次递增之前就退出了。如果在这里把break 替换为continue 的话,则可以看到另一种结果:

var num = 0;

for (var i=1; i < 10; i++) {
    if (i % 5 == 0) {
        continue;


    }
    num++;
}

alert(num);    //8

ContinueStatementExample01.htm

例子的结果显示8,也就是循环总共执行了8次。当变量i 等于5时,循环会在num 再次递增之前退出,但接下来执行的是下一次循环,即i 的值等于6的循环。于是,循环又继续执行,直到i 等于10时自然结束。而num 的最终值之所以是8,是因为continue 语句导致它少递增了一次。

breakcontinue 语句都可以与label 语句联合使用,从而返回代码中特定的位置。这种联合使用的情况多发生在循环嵌套的情况下,如下面的例子所示:

var num = 0;

outermost:
for (var i=0; i < 10; i++) {
     for (var j=0; j < 10; j++) {
        if (i == 5 && j == 5) {
            break outermost;
        }
        num++;
    }
}

alert(num);    //55


BreakStatementExample02.htm

在这个例子中,outermost 标签表示外部的for 语句。如果每个循环正常执行10次,则num++ 语句就会正常执行100次。换句话说,如果两个循环都自然结束,num 的值应该是100。但内部循环中的break 语句带了一个参数:要返回到的标签。添加这个标签的结果将导致break 语句不仅会退出内部的for语句(即使用变量j 的循环),而且也会退出外部的for 语句(即使用变量i 的循环)。为此,当变量ij 都等于5时,num 的值正好是55。同样,continue 语句也可以像这样与label 语句联用,如下面的例子所示:

var num = 0;

outermost:
for (var i=0; i < 10; i++) {
    for (var j=0; j < 10; j++) {
        if (i == 5 && j == 5) {
            continue outermost;


        }
        num++;
    }
}

alert(num);    //95

ContinueStatementExample02.htm

在这种情况下,continue 语句会强制继续执行循环——退出内部循环,执行外部循环。当j 是5时,continue 语句执行,而这也就意味着内部循环少执行了5次,因此num 的结果是95。

虽然联用breakcontinuelabel 语句能够执行复杂的操作,但如果使用过度,也会给调试带来麻烦。在此,我们建议如果使用label 语句,一定要使用描述性的标签,同时不要嵌套过多的循环。

3.6.8 with 语句

with 语句的作用是将代码的作用域设置到一个特定的对象中。with 语句的语法如下:

with (expression) statement;


定义with 语句的目的主要是为了简化多次编写同一个对象的工作,如下面的例子所示:

var qs = location.search.substring(1);
var hostName = location.hostname;
var url = location.href;


上面几行代码都包含location 对象。如果使用with 语句,可以把上面的代码改写成如下所示:

with(location){
    var qs = search.substring(1);
    var hostName = hostname;
    var url = href;
}


WithStatementExample01.htm

在这个重写后的例子中,使用with 语句关联了location 对象。这意味着在with 语句的代码块内部,每个变量首先被认为是一个局部变量,而如果在局部环境中找不到该变量的定义,就会查询location 对象中是否有同名的属性。如果发现了同名属性,则以location 对象属性的值作为变量的值。

严格模式下不允许使用with 语句,否则将视为语法错误。

由于大量使用 with 语句会导致性能下降,同时也会给调试代码造成困难,因此在开发大型应用程序时,不建议使用 with 语句。

3.6.9 switch 语句

switch 语句与if 语句的关系最为密切,而且也是在其他语言中普遍使用的一种流控制语句。ECMAScript中switch 语句的语法与其他基于C的语言非常接近,如下所示:

switch (expression) {
  case value: statement
    break;
  case value: statement
    break;
  case value: statement
    break;
  case value: statement
    break;
  default: statement
}


switch 语句中的每一种情形(case)的含义是:“如果表达式等于这个值(value),则执行后面的语句(statement)”。而break 关键字会导致代码执行流跳出switch 语句。如果省略break 关键字,就会导致执行完当前case后,继续执行下一个case。最后的default 关键字则用于在表达式不匹配前面任何一种情形的时候,执行机动代码(因此,也相当于一个else 语句)。

从根本上讲,switch 语句就是为了让开发人员免于编写像下面这样的代码:

if (i == 25){
  alert("25");
} else if (i == 35) {
  alert("35");
} else if (i == 45) {
  alert("45");
} else {
  alert("Other");
}


而与此等价的switch 语句如下所示:

switch (i) {
    case 25: 
        alert("25");
        break;
    case 35: 
        alert("35");
        break;
    case 45: 
        alert("45");
        break;
    default: 
        alert("Other");
}


SwitchStatementExample01.htm

通过为每个case后面都添加一个break 语句,就可以避免同时执行多个case代码的情况。假如确实需要混合几种情形,不要忘了在代码中添加注释,说明你是有意省略了break 关键字,如下所示:

switch (i) {
    case 25: 
        /* 合并两种情形 */


    case 35: 
        alert("25 or 35");
        break;
    case 45: 
        alert("45");
        break;
    default: 
        alert("Other");
}

SwitchStatementExample02.htm

虽然ECMAScript中的switch 语句借鉴自其他语言,但这个语句也有自己的特色。首先,可以在switch 语句中使用任何数据类型(在很多其他语言中只能使用数值),无论是字符串,还是对象都没有问题。其次,每个case的值不一定是常量,可以是变量,甚至是表达式。请看下面这个例子:

switch ("hello world") {
    case "hello" + " world": 
        alert("Greeting was found.");
        break;
    case "goodbye": 
        alert("Closing was found.");
        break;
    default: 
        alert("Unexpected message was found.");
}


SwitchStatementExample03.htm

在这个例子中,switch 语句使用的就是字符串。其中,第一种情形实际上是一个对字符串拼接操作求值的表达式。由于这个字符串拼接表达式的结果与switch 的参数相等,因此结果就会显示"Greeting was found." 。而且,使用表达式作为case值还可以实现下列操作:

var num = 25;
switch (true) {
    case num < 0: 
        alert("Less than 0.");
        break;
    case num >= 0 && num <= 10: 
        alert("Between 0 and 10.");
        break;
    case num > 10 && num <= 20: 
        alert("Between 10 and 20.");
        break;
    default: 
        alert("More than 20.");
}


SwitchStatementExample04.htm

这个例子首先在switch 语句外面声明了变量num 。而之所以给switch 语句传递表达式true ,是因为每个case值都可以返回一个布尔值。这样,每个case按照顺序被求值,直到找到匹配的值或者遇到default 语句为止(这正是这个例子的最终结果)。

switch 语句在比较值时使用的是全等操作符,因此不会发生类型转换(例如,字符串"10" 不等于数值10)。

函数对任何语言来说都是一个核心的概念。通过函数可以封装任意多条语句,而且可以在任何地方、任何时候调用执行。ECMAScript中的函数使用function 关键字来声明,后跟一组参数以及函数体。函数的基本语法如下所示:

function functionName(arg0, arg1,...,argN) {
    statements
}


以下是一个函数示例:

function sayHi(name, message) {
    alert("Hello " + name + "," + message);
}


FunctionExample01.htm

这个函数可以通过其函数名来调用,后面还要加上一对圆括号和参数(圆括号中的参数如果有多个,可以用逗号隔开)。调用sayHi() 函数的代码如下所示:

sayHi("Nicholas", "how are you today?");


这个函数的输出结果是"Hello Nicholas,how are you today?" 。函数中定义中的命名参数namemessage 被用作了字符串拼接的两个操作数,而结果最终通过警告框显示了出来。

ECMAScript中的函数在定义时不必指定是否返回值。实际上,任何函数在任何时候都可以通过return 语句后跟要返回的值来实现返回值。请看下面的例子:

function sum(num1, num2) {
    return num1 + num2;
}


FunctionExample02.htm

这个sum() 函数的作用是把两个值加起来返回一个结果。我们注意到,除了return 语句之外,没有任何声明表示该函数会返回一个值。调用这个函数的示例代码如下:

var result = sum(5, 10);


这个函数会在执行完return 语句之后停止并立即退出。因此,位于return 语句之后的任何代码都永远不会执行。例如:

function sum(num1, num2) {
    return num1 + num2;
    alert("Hello world");    // 永远不会执行


}

在这个例子中,由于调用alert() 函数的语句位于return 语句之后,因此永远不会显示警告框。

当然,一个函数中也可以包含多个return 语句,如下面这个例子中所示:

function diff(num1, num2) {
    if (num1 < num2) {
        return num2 - num1;
    } else {
        return num1 - num2;
    }
}


FunctionExample03.htm

这个例子中定义的diff() 函数用于计算两个数值的差。如果第一个数比第二个小,则用第二个数减第一个数;否则,用第一个数减第二个数。代码中的两个分支都具有自己的return 语句,分别用于执行正确的计算。

另外,return 语句也可以不带有任何返回值。在这种情况下,函数在停止执行后将返回undefined 值。这种用法一般用在需要提前停止函数执行而又不需要返回值的情况下。比如在下面这个例子中,就不会显示警告框:

function sayHi(name, message) {
    return;


    alert("Hello " + name + "," + message);    //永远不会调用
}

FunctionExample04.htm

推荐的做法是要么让函数始终都返回一个值,要么永远都不要返回值。否则,如果函数有时候返回值,有时候有不返回值,会给调试代码带来不便。

严格模式对函数有一些限制:

如果发生以上情况,就会导致语法错误,代码无法执行。

3.7.1 理解参数

ECMAScript函数的参数与大多数其他语言中函数的参数有所不同。ECMAScript函数不介意传递进来多少个参数,也不在乎传进来参数是什么数据类型。也就是说,即便你定义的函数只接收两个参数,在调用这个函数时也未必一定要传递两个参数。可以传递一个、三个甚至不传递参数,而解析器永远不会有什么怨言。之所以会这样,原因是ECMAScript中的参数在内部是用一个数组来表示的。函数接收到的始终都是这个数组,而不关心数组中包含哪些参数(如果有参数的话)。如果这个数组中不包含任何元素,无所谓;如果包含多个元素,也没有问题。实际上,在函数体内可以通过arguments 对象来访问这个参数数组,从而获取传递给函数的每一个参数。

其实,arguments 对象只是与数组类似(它并不是Array 的实例),因为可以使用方括号语法访问它的每一个元素(即第一个元素是arguments[0] ,第二个元素是argumetns[1] ,以此类推),使用length 属性来确定传递进来多少个参数。在前面的例子中,sayHi() 函数的第一个参数的名字叫name ,而该参数的值也可以通过访问arguments[0] 来获取。因此,那个函数也可以像下面这样重写,即不显式地使用命名参数:

function sayHi() {
    alert("Hello " + arguments[0] + "," + arguments[1]);
}


FunctionExample05.htm

这个重写后的函数中不包含命名的参数。虽然没有使用namemessage 标识符,但函数的功能依旧。这个事实说明了ECMAScript函数的一个重要特点:命名的参数只提供便利,但不是必需的。另外,在命名参数方面,其他语言可能需要事先创建一个函数签名,而将来的调用必须与该签名一致。但在ECMAScript中,没有这些条条框框,解析器不会验证命名参数。

通过访问arguments 对象的length 属性可以获知有多少个参数传递给了函数。下面这个函数会在每次被调用时,输出传入其中的参数个数:

function howManyArgs() {
    alert(arguments.length);
}

howManyArgs("string", 45);  //2
howManyArgs();              //0
howManyArgs(12);            //1


FunctionExample06.htm

执行以上代码会依次出现3个警告框,分别显示2、0和1。由此可见,开发人员可以利用这一点让函数能够接收任意个参数并分别实现适当的功能。请看下面的例子:

function doAdd() {
    if(arguments.length == 1) {
        alert(arguments[0] + 10);
    } else if (arguments.length == 2) {
        alert(arguments[0] + arguments[1]);
    }
}

doAdd(10);         //20
doAdd(30, 20);     //50


FunctionExample07.htm

函数doAdd() 会在只有一个参数的情况下给该参数加上10;如果是两个参数,则将那个参数简单相加并返回结果。因此,doAdd(10) 会返回20,而doAdd(30,20) 则返回50。虽然这个特性算不上完美的重载,但也足够弥补ECMAScript的这一缺憾了。

另一个与参数相关的重要方面,就是arguments 对象可以与命名参数一起使用,如下面的例子所示:

function doAdd(num1, num2) {
    if(arguments.length == 1) {


       alert(num1 + 10);
    } else if (arguments.length == 2) {


        alert(arguments[0] + num2);
    }


}

FunctionExample08.htm

在重写后的这个doAdd() 函数中,两个命名参数都与arguments 对象一起使用。由于num1 的值与arguments[0] 的值相同,因此它们可以互换使用(当然,num2arguments[1] 也是如此)。

关于arguments 的行为,还有一点比较有意思。那就是它的值永远与对应命名参数的值保持同步。例如:

function doAdd(num1, num2) {
    arguments[1] = 10;    
    alert(arguments[0] + num2);
}


FunctionExample09.htm

每次执行这个doAdd() 函数都会重写第二个参数,将第二个参数的值修改为10。因为arguments 对象中的值会自动反映到对应的命名参数,所以修改arguments[1] ,也就修改了num2 ,结果它们的值都会变成10。不过,这并不是说读取这两个值会访问相同的内存空间;它们的内存空间是独立的,但它们的值会同步。但这种影响是单向的:修改命名参数不会改变arguments 中对应的值。另外还要记住,如果只传入了一个参数,那么为arguments[1] 设置的值不会反应到命名参数中。这是因为arguments 对象的长度是由传入的参数个数决定的,不是由定义函数时的命名参数的个数决定的。

关于参数还要记住最后一点:没有传递值的命名参数将自动被赋予undefined 值。这就跟定义了变量但又没有初始化一样。例如,如果只给doAdd() 函数传递了一个参数,则num2 中就会保存undefined 值。

严格模式对如何使用argumetns 对象做出了一些限制。首先,像前面例子中那样的赋值会变得无效。也就是说,即使把arguments[1] 设置为10num2 的值仍然还是undefined 。其次,重写arguments 的值会导致语法错误(代码将不会执行)。

ECMAScript中的所有参数传递的都是值,不可能通过引用传递参数。

3.7.2 没有重载

ECMAScript函数不能像传统意义上那样实现重载。而在其他语言(如Java)中,可以为一个函数编写两个定义,只要这两个定义的签名(接受的参数的类型和数量)不同即可。如前所述,ECMAScirpt函数没有签名,因为其参数是由包含零或多个值的数组来表示的。而没有函数签名,真正的重载是不可能做到的。

如果在ECMAScript中定义了两个名字相同的函数,则该名字只属于后定义的函数。请看下面的例子:

function addSomeNumber(num){
    return num + 100;
}

function addSomeNumber(num) {
    return num + 200;
}

var result = addSomeNumber(100);    //300


FunctionExample10.htm

在此,函数addSomeNumber() 被定义了两次。第一个版本给参数加100,而第二个版本给参数加200。由于后定义的函数覆盖了先定义的函数,因此当在最后一行代码中调用这个函数时,返回的结果就是300。

如前所述,通过检查传入函数中参数的类型和数量并作出不同的反应,可以模仿方法的重载。

JavaScript的核心语言特性在ECMA-262中是以名为ECMAScript的伪语言的形式来定义的。ECMAScript中包含了所有基本的语法、操作符、数据类型以及完成基本的计算任务所必需的对象,但没有对取得输入和产生输出的机制作出规定。理解ECMAScript及其纷繁复杂的各种细节,是理解其在Web浏览器中的实现——JavaScript的关键。目前大多数实现所遵循的都是ECMA-262第3版,但很多也已经着手开始实现第5版了。以下简要总结了ECMAscript中基本的要素。

ECMAScript中的函数与其他语言中的函数有诸多不同之处。


第4章 变量、作用域和内存问题

本章内容

按照ECMA-262的定义,JavaScript的变量与其他语言的变量有很大区别。JavaScript变量松散类型的本质,决定了它只是在特定时间用于保存特定值的一个名字而已。由于不存在定义某个变量必须要保存何种数据类型值的规则,变量的值及其数据类型可以在脚本的生命周期内改变。尽管从某种角度看,这可能是一个既有趣又强大,同时又容易出问题的特性,但JavaScript变量实际的复杂程度还远不止如此。

ECMAScript变量可能包含两种不同数据类型的值:基本类型值和引用类型值。基本类型值 指的是简单的数据段,而引用类型值 指那些可能由多个值构成的对象。

在将一个值赋给变量时,解析器必须确定这个值是基本类型值还是引用类型值。第3章讨论了5种基本数据类型:UndefinedNullBooleanNumberString 。这5种基本数据类型是按值访问的,因为可以操作保存在变量中的实际的值。

引用类型的值是保存在内存中的对象。与其他语言不同,JavaScript不允许直接访问内存中的位置,也就是说不能直接操作对象的内存空间。在操作对象时,实际上是在操作对象的引用而不是实际的对象。为此,引用类型的值是按引用访问的。

在很多语言中,字符串以对象的形式来表示,因此被认为是引用类型的。ECMAScript放弃了这一传统。

4.1.1 动态的属性

定义基本类型值和引用类型值的方式是类似的:创建一个变量并为该变量赋值。但是,当这个值保存到变量中以后,对不同类型值可以执行的操作则大相径庭。对于引用类型的值,我们可以为其添加属性和方法,也可以改变和删除其属性和方法。请看下面的例子:

var person = new Object();
person.name = "Nicholas";
alert(person.name);       //"Nicholas"


DynamicPropertiesExample01.htm

以上代码创建了一个对象并将其保存在了变量person 中。然后,我们为该对象添加了一个名为name 的属性,并将字符串值"Nicholas" 赋给了这个属性。紧接着,又通过alert() 函数访问了这个新属性。如果对象不被销毁或者这个属性不被删除,则这个属性将一直存在。

但是,我们不能给基本类型的值添加属性,尽管这样做不会导致任何错误。比如:

var name = "Nicholas";
name.age = 27;
alert(name.age);      //undefined


DynamicPropertiesExample02.htm

在这个例子中,我们为字符串name 定义了一个名为age 的属性,并为该属性赋值27。但在下一行访问这个属性时,发现该属性不见了。这说明只能给引用类型值动态地添加属性,以便将来使用。

4.1.2 复制变量值

除了保存的方式不同之外,在从一个变量向另一个变量复制基本类型值和引用类型值时,也存在不同。如果从一个变量向另一个变量复制基本类型的值,会在变量对象上创建一个新值,然后把该值复制到为新变量分配的位置上。来看一个例子:

var num1 = 5;
var num2 = num1;


在此,num1 中保存的值是5。当使用num1 的值来初始化num2 时,num2 中也保存了值5。但num2 中的5与num1 中的5是完全独立的,该值只是num1 中5的一个副本。此后,这两个变量可以参与任何操作而不会相互影响。图4-1形象地展示了复制基本类型值的过程。

图 4-1

当从一个变量向另一个变量复制引用类型的值时,同样也会将存储在变量对象中的值复制一份放到为新变量分配的空间中。不同的是,这个值的副本实际上是一个指针,而这个指针指向存储在堆中的一个对象。复制操作结束后,两个变量实际上将引用同一个对象。因此,改变其中一个变量,就会影响另一个变量,如下面的例子所示:

var obj1 = new Object();
var obj2 = obj1;
obj1.name = "Nicholas";
alert(obj2.name);  //"Nicholas"


首先,变量obj1 保存了一个对象的新实例。然后,这个值被复制到了obj2 中;换句话说,obj1obj2 都指向同一个对象。这样,当为obj1 添加name 属性后,可以通过obj2 来访问这个属性,因为这两个变量引用的都是同一个对象。图4-2展示了保存在变量对象中的变量和保存在堆中的对象之间的这种关系。

图 4-2

4.1.3 传递参数

ECMAScript中所有函数的参数都是按值传递的。也就是说,把函数外部的值复制给函数内部的参数,就和把值从一个变量复制到另一个变量一样。基本类型值的传递如同基本类型变量的复制一样,而引用类型值的传递,则如同引用类型变量的复制一样。有不少开发人员在这一点上可能会感到困惑,因为访问变量有按值和按引用两种方式,而参数只能按值传递。

在向参数传递基本类型的值时,被传递的值会被复制给一个局部变量(即命名参数,或者用ECMAScript的概念来说,就是arguments 对象中的一个元素)。在向参数传递引用类型的值时,会把这个值在内存中的地址复制给一个局部变量,因此这个局部变量的变化会反映在函数的外部。请看下面这个例子:

function addTen(num) {
    num += 10;
    return num;
}

var count = 20;
var result = addTen(count);
alert(count);    //20,没有变化
alert(result);   //30


FunctionArgumentsExample01.htm

这里的函数addTen() 有一个参数num ,而参数实际上是函数的局部变量。在调用这个函数时,变量count 作为参数被传递给函数,这个变量的值是20。于是,数值20被复制给参数num 以便在addTen() 中使用。在函数内部,参数num 的值被加上了10,但这一变化不会影响函数外部的count 变量。参数num 与变量count 互不相识,它们仅仅是具有相同的值。假如num 是按引用传递的话,那么变量count 的值也将变成30,从而反映函数内部的修改。当然,使用数值等基本类型值来说明按值传递参数比较简单,但如果使用对象,那问题就不怎么好理解了。再举一个例子:

function setName(obj) {
    obj.name = "Nicholas";
}

var person = new Object();
setName(person);
alert(person.name);    //"Nicholas"


FunctionArgumentsExample02.htm

以上代码中创建一个对象,并将其保存在了变量person 中。然后,这个对象被传递到setName() 函数中之后就被复制给了obj 。在这个函数内部,objperson 引用的是同一个对象。换句话说,即使这个对象是按值传递的,obj 也会按引用来访问同一个对象。于是,当在函数内部为obj 添加name 属性后,函数外部的person 也将有所反映;因为person 指向的对象在堆内存中只有一个,而且是全局对象。有很多开发人员错误地认为:在局部作用域中修改的对象会在全局作用域中反映出来,就说明参数是按引用传递的。为了证明对象是按值传递的,我们再看一看下面这个经过修改的例子:

function setName(obj) {
    obj.name = "Nicholas";
    obj = new Object();


    obj.name = "Greg";


}

var person = new Object();
setName(person);
alert(person.name);    //"Nicholas"

这个例子与前一个例子的唯一区别,就是在setName() 函数中添加了两行代码:一行代码为obj 重新定义了一个对象,另一行代码为该对象定义了一个带有不同值的name 属性。在把person 传递给setName() 后,其name 属性被设置为"Nicholas" 。然后,又将一个新对象赋给变量obj ,同时将其name 属性设置为"Greg" 。如果person 是按引用传递的,那么person 就会自动被修改为指向其name 属性值为"Greg" 的新对象。但是,当接下来再访问person.name 时,显示的值仍然是"Nicholas" 。这说明即使在函数内部修改了参数的值,但原始的引用仍然保持未变。实际上,当在函数内部重写obj 时,这个变量引用的就是一个局部对象了。而这个局部对象会在函数执行完毕后立即被销毁。

可以把ECMAScript函数的参数想象成局部变量。

4.1.4 检测类型

要检测一个变量是不是基本数据类型?第3章介绍的typeof 操作符是最佳的工具。说得更具体一点,typeof 操作符是确定一个变量是字符串、数值、布尔值,还是undefined 的最佳工具。如果变量的值是一个对象或null ,则typeof 操作符会像下面例子中所示的那样返回"object"

var s = "Nicholas";
var b = true;
var i = 22;
var u;
var n = null;
var o = new Object();

alert(typeof s);   //string
alert(typeof i);   //number
alert(typeof b);   //boolean
alert(typeof u);   //undefined
alert(typeof n);   //object
alert(typeof o);   //object


DeterminingTypeExample01.htm

虽然在检测基本数据类型时typeof 是非常得力的助手,但在检测引用类型的值时,这个操作符的用处不大。通常,我们并不是想知道某个值是对象,而是想知道它是什么类型的对象。为此,ECMAScript提供了instanceof 操作符,其语法如下所示:

result = variable instanceof constructor


如果变量是给定引用类型(根据它的原型链来识别;第6章将介绍原型链)的实例,那么instanceof 操作符就会返回true 。请看下面的例子:

alert(person instanceof Object);     // 变量person是Object吗?
alert(colors instanceof Array);      // 变量colors是Array吗?
alert(pattern instanceof RegExp);    // 变量pattern是RegExp吗?


根据规定,所有引用类型的值都是Object 的实例。因此,在检测一个引用类型值和Object 构造函数时,instanceof 操作符始终会返回true 。当然,如果使用instanceof 操作符检测基本类型的值,则该操作符始终会返回false ,因为基本类型不是对象。

使用 typeof 操作符检测函数时,该操作符会返回 "function" 。在Safari 5及之前版本和Chrome 7及之前版本中使用 typeof 检测正则表达式时,由于规范的原因,这个操作符也返回 "function" 。ECMA-262规定任何在内部实现 [[Call]] 方法的对象都应该在应用 typeof 操作符时返回 "function" 。由于上述浏览器中的正则表达式也实现了这个方法,因此对正则表达式应用 typeof 会返回 "function" 。在IE和Firefox中,对正则表达式应用 typeof 会返回"object "

执行环境(execution context,为简单起见,有时也称为“环境 ”)是JavaScript中最为重要的一个概念。执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象 (variable object),环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

全局执行环境是最外围的一个执行环境。根据ECMAScript实现所在的宿主环境不同,表示执行环境的对象也不一样。在Web浏览器中,全局执行环境被认为是window 对象(第7章将详细讨论),因此所有全局变量和函数都是作为window 对象的属性和方法创建的。某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁(全局执行环境直到应用程序退 出——例如关闭网页或浏览器——时才会被销毁)。

每个函数都有自己的执行环境 。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。ECMAScript程序中的执行流正是由这个方便的机制控制着。

当代码在一个环境中执行时,会创建变量对象的一个作用域链 (scope chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象 (activation object)作为变量对象。活动对象在最开始时只包含一个变量,即arguments 对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直至找到标识符为止(如果找不到标识符,通常会导致错误发生)。

请看下面的示例代码:

var color = "blue";

function changeColor(){
    if (color === "blue"){
        color = "red";
    } else {
        color = "blue";
    }
}

changeColor();

alert("Color is now " + color);


ExecutionContextExample01.htm

在这个简单的例子中,函数changeColor() 的作用域链包含两个对象:它自己的变量对象(其中定义着arguments 对象)和全局环境的变量对象。可以在函数内部访问变量color ,就是因为可以在这个作用域链中找到它。

此外,在局部作用域中定义的变量可以在局部环境中与全局变量互换使用,如下面这个例子所示:

var color = "blue";

function changeColor(){
    var anotherColor = "red";

    function swapColors(){
        var tempColor = anotherColor;
        anotherColor = color;
        color = tempColor;

        // 这里可以访问color、anotherColor和tempColor
    }

    // 这里可以访问color和anotherColor,但不能访问tempColor
    swapColors();
}

// 这里只能访问color
changeColor();


以上代码共涉及3个执行环境:全局环境、changeColor() 的局部环境和swapColors() 的局部环境。全局环境中有一个变量color 和一个函数changeColor()changeColor() 的局部环境中有一个名为anotherColor 的变量和一个名为swapColors() 的函数,但它也可以访问全局环境中的变量colorswapColors() 的局部环境中有一个变量tempColor ,该变量只能在这个环境中访问到。无论全局环境还是changeColor() 的局部环境都无权访问tempColor 。然而,在swapColors() 内部则可以访问其他两个环境中的所有变量,因为那两个环境是它的父执行环境。图4-3形象地展示了前面这个例子的作用域链。

图 4-3

图4-3中的矩形表示特定的执行环境。其中,内部环境可以通过作用域链访问所有的外部环境,但外部环境不能访问内部环境中的任何变量和函数。这些环境之间的联系是线性、有次序的。每个环境都可以向上搜索作用域链,以查询变量和函数名;但任何环境都不能通过向下搜索作用域链而进入另一个执行环境。对于这个例子中的swapColors() 而言,其作用域链中包含3个对象:swapColors() 的变量对象、changeColor() 的变量对象和全局变量对象。swapColors() 的局部环境开始时会先在自己的变量对象中搜索变量和函数名,如果搜索不到则再搜索上一级作用域链。changeColor() 的作用域链中只包含两个对象:它自己的变量对象和全局变量对象。这也就是说,它不能访问swapColors() 的 环境。

函数参数也被当作变量来对待,因此其访问规则与执行环境中的其他变量相同。

4.2.1 延长作用域链

虽然执行环境的类型总共只有两种——全局和局部(函数),但还是有其他办法来延长作用域链。这么说是因为有些语句可以在作用域链的前端临时增加一个变量对象,该变量对象会在代码执行后被移除。在两种情况下会发生这种现象。具体来说,就是当执行流进入下列任何一个语句时,作用域链就会得到加长:

这两个语句都会在作用域链的前端添加一个变量对象。对with 语句来说,会将指定的对象添加到作用域链中。对catch 语句来说,会创建一个新的变量对象,其中包含的是被抛出的错误对象的声明。下面看一个例子。

function buildUrl() {
    var qs = "?debug=true";

    with(location){
        var url = href + qs;
    }

    return url;
}


ExecutionContextExample03.htm

在此,with 语句接收的是location 对象,因此其变量对象中就包含了location 对象的所有属性和方法,而这个变量对象被添加到了作用域链的前端。buildUrl() 函数中定义了一个变量qs 。当在with 语句中引用变量href 时(实际引用的是location.href ),可以在当前执行环境的变量对象中找到。当引用变量qs 时,引用的则是在buildUrl() 中定义的那个变量,而该变量位于函数环境的变量对象中。至于with 语句内部,则定义了一个名为url 的变量,因而url 就成了函数执行环境的一部分,所以可以作为函数的值被返回。

在IE8及之前版本的JavaScript实现中,存在一个与标准不一致的地方,即在 catch 语句中捕获的错误对象会被添加到执行环境的变量对象,而不是 catch 语句的变量对象中。换句话说,即使是在 catch 块的外部也可以访问到错误对象。IE9修复了这个问题。

4.2.2 没有块级作用域

JavaScript没有块级作用域经常会导致理解上的困惑。在其他类C的语言中,由花括号封闭的代码块都有自己的作用域(如果用ECMAScript的话来讲,就是它们自己的执行环境),因而支持根据条件来定义变量。例如,下面的代码在JavaScript中并不会得到想象中的结果:

if (true) {
    var color = "blue";
}

alert(color);    //"blue"


这里是在一个if 语句中定义了变量color 。如果是在C、C++或Java中,color 会在if 语句执行完毕后被销毁。但在JavaScript中,if 语句中的变量声明会将变量添加到当前的执行环境(在这里是全局环境)中。在使用for 语句时尤其要牢记这一差异,例如:

for (var i=0; i < 10; i++){
    doSomething(i);
}

alert(i);      //10


对于有块级作用域的语言来说,for 语句初始化变量的表达式所定义的变量,只会存在于循环的环境之中。而对于JavaScript来说,由for 语句创建的变量i 即使在for 循环执行结束后,也依旧会存在于循环外部的执行环境中。

1. 声明变量

使用var 声明的变量会自动被添加到最接近的环境中。在函数内部,最接近的环境就是函数的局部环境;在with 语句中,最接近的环境是函数环境。如果初始化变量时没有使用var 声明,该变量会自动被添加到全局环境。如下所示:

function add(num1, num2) {
    var sum = num1 + num2;
    return sum;
}

var result = add(10, 20);    //30
alert(sum);                  //由于sum不是有效的变量,因此会导致错误


ExecutionContextExample04.htm

以上代码中的函数add() 定义了一个名为sum 的局部变量,该变量包含加法操作的结果。虽然结果值从函数中返回了,但变量sum 在函数外部是访问不到的。如果省略这个例子中的var 关键字,那么当add() 执行完毕后,sum 也将可以访问到:

function add(num1, num2) {
    sum = num1 + num2;


    return sum;
}

var result = add(10, 20);    //30
alert(sum);                  //30

ExecutionContextExample05.htm

这个例子中的变量sum 在被初始化赋值时没有使用var 关键字。于是,当调用完add() 之后,添加到全局环境中的变量sum 将继续存在;即使函数已经执行完毕,后面的代码依旧可以访问它。

在编写JavaScript代码的过程中,不声明而直接初始化变量是一个常见的错误做法,因为这样可能会导致意外。我们建议在初始化变量之前,一定要先声明,这样就可以避免类似问题。在严格模式下,初始化未经声明的变量会导致错误。

2. 查询标识符

当在某个环境中为了读取或写入而引用一个标识符时,必须通过搜索来确定该标识符实际代表什么。搜索过程从作用域链的前端开始,向上逐级查询与给定名字匹配的标识符。如果在局部环境中找到了该标识符,搜索过程停止,变量就绪。如果在局部环境中没有找到该变量名,则继续沿作用域链向上搜索。搜索过程将一直追溯到全局环境的变量对象。如果在全局环境中也没有找到这个标识符,则意味着该变量尚未声明。

通过下面这个示例,可以理解查询标识符的过程:

var color = "blue";

function getColor(){
    return color;
}

alert(getColor());  //"blue"


ExecutionContextExample06.htm

调用本例中的函数getColor() 时会引用变量color 。为了确定变量color 的值,将开始一个两步的搜索过程。首先,搜索getColor() 的变量对象,查找其中是否包含一个名为color 的标识符。在没有找到的情况下,搜索继续到下一个变量对象(全局环境的变量对象),然后在那里找到了名为color 的标识符。因为搜索到了定义这个变量的变量对象,搜索过程宣告结束。图4-4形象地展示了上述搜索过程。

图 4-4

在这个搜索过程中,如果存在一个局部的变量的定义,则搜索会自动停止,不再进入另一个变量对象。换句话说,如果局部环境中存在着同名标识符,就不会使用位于父环境中的标识符,如下面的例子所示:

var color = "blue";

function getColor(){
    var color = "red";


    return color;
}

alert(getColor());  //"red"

ExecutionContextExample07.htm

修改后的代码在getColor() 函数中声明了一个名为color 的局部变量。调用函数时,该变量就会被声明。而当函数中的第二行代码执行时,意味着必须找到并返回变量color 的值。搜索过程首先从局部环境中开始,而且在这里发现了一个名为color 的变量,其值为"red" 。因为变量已经找到了,所以搜索即行停止,return 语句就使用这个局部变量,并为函数会返回"red" 。也就是说,任何位于局部变量color 的声明之后的代码,如果不使用window.color 都无法访问全局color 变量。如果有一个操作数是对象,而另一个不是,就会在对象上调用valueOf() 方法以取得基本类型的值,以便根据前面的规则进行比较。

变量查询也不是没有代价的。很明显,访问局部变量要比访问全局变量更快,因为不用向上搜索作用域链。JavaScript引擎在优化标识符查询方面做得不错,因此这个差别在将来恐怕就可以忽略不计了。

JavaScript具有自动垃圾收集机制,也就是说,执行环境会负责管理代码执行过程中使用的内存。而在C和C++之类的语言中,开发人员的一项基本任务就是手工跟踪内存的使用情况,这是造成许多问题的一个根源。在编写JavaScript程序时,开发人员不用再关心内存使用问题,所需内存的分配以及无用内存的回收完全实现了自动管理。这种垃圾收集机制的原理其实很简单:找出那些不再继续使用的变量,然后释放其占用的内存。为此,垃圾收集器会按照固定的时间间隔(或代码执行中预定的收集时间),周期性地执行这一操作。

下面我们来分析一下函数中局部变量的正常生命周期。局部变量只在函数执行的过程中存在。而在这个过程中,会为局部变量在栈(或堆)内存上分配相应的空间,以便存储它们的值。然后在函数中使用这些变量,直至函数执行结束。此时,局部变量就没有存在的必要了,因此可以释放它们的内存以供将来使用。在这种情况下,很容易判断变量是否还有存在的必要;但并非所有情况下都这么容易就能得出结论。垃圾收集器必须跟踪哪个变量有用哪个变量没用,对于不再有用的变量打上标记,以备将来收回其占用的内存。用于标识无用变量的策略可能会因实现而异,但具体到浏览器中的实现,则通常有两个策略。

4.3.1 标记清除

JavaScript中最常用的垃圾收集方式是标记清除 (mark-and-sweep)。当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。

可以使用任何方式来标记变量。比如,可以通过翻转某个特殊的位来记录一个变量何时进入环境,或者使用一个“进入环境的”变量列表及一个“离开环境的”变量列表来跟踪哪个变量发生了变化。说到底,如何标记变量其实并不重要,关键在于采取什么策略。

垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾收集器完成内存清除 工作,销毁那些带标记的值并回收它们所占用的内存空间。

到2008年为止,IE、Firefox、Opera、Chrome和Safari的JavaScript实现使用的都是标记清除式的垃圾收集策略(或类似的策略),只不过垃圾收集的时间间隔互有不同。

4.3.2 引用计数

另一种不太常见的垃圾收集策略叫做引用计数 (reference counting)。引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减1。当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为零的值所占用的内存。

Netscape Navigator 3.0是最早使用引用计数策略的浏览器,但很快它就遇到了一个严重的问题:循环引用。循环引用 指的是对象A中包含一个指向对象B的指针,而对象B中也包含一个指向对象A的引用。请看下面这个例子:

function problem(){
    var objectA = new Object();
    var objectB = new Object();

    objectA.someOtherObject = objectB;
    objectB.anotherObject = objectA;
}


在这个例子中,objectAobjectB 通过各自的属性相互引用;也就是说,这两个对象的引用次数都是2。在采用标记清除策略的实现中,由于函数执行之后,这两个对象都离开了作用域,因此这种相互引用不是个问题。但在采用引用计数策略的实现中,当函数执行完毕后,objectAobjectB 还将继续存在,因为它们的引用次数永远不会是0。假如这个函数被重复多次调用,就会导致大量内存得不到回收。为此,Netscape在Navigator 4.0中放弃了引用计数方式,转而采用标记清除来实现其垃圾收集机制。可是,引用计数导致的麻烦并未就此终结。

我们知道,IE中有一部分对象并不是原生JavaScript对象。例如,其BOM和DOM中的对象就是使用C++以COM(Component Object Model,组件对象模型)对象的形式实现的,而COM对象的垃圾收集机制采用的就是引用计数策略。因此,即使IE的JavaScript引擎是使用标记清除策略来实现的,但JavaScript访问的COM对象依然是基于引用计数策略的。换句话说,只要在IE中涉及COM对象,就会存在循环引用的问题。下面这个简单的例子,展示了使用COM对象导致的循环引用问题:

var element = document.getElementById("some_element");
var myObject = new Object();
myObject.element = element;
element.someObject = myObject;


这个例子在一个DOM元素(element )与一个原生JavaScript对象(myObject )之间创建了循环引用。其中,变量myObject 有一个名为element 的属性指向element 对象;而变量element 也有一个属性名叫someObject 回指myObject 。由于存在这个循环引用,即使将例子中的DOM从页面中移除,它也永远不会被回收。

为了避免类似这样的循环引用问题,最好是在不使用它们的时候手工断开原生JavaScript对象与DOM元素之间的连接。例如,可以使用下面的代码消除前面例子创建的循环引用:

myObject.element = null;
element.someObject = null;


将变量设置为null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。

为了解决上述问题,IE9把BOM和DOM对象都转换成了真正的JavaScript对象。这样,就避免了两种垃圾收集算法并存导致的问题,也消除了常见的内存泄漏现象。

导致循环引用的情况不止这些,其他一些情况将在本书中陆续介绍。

4.3.3 性能问题

垃圾收集器是周期性运行的,而且如果为变量分配的内存数量很可观,那么回收工作量也是相当大的。在这种情况下,确定垃圾收集的时间间隔是一个非常重要的问题。说到垃圾收集器多长时间运行一次,不禁让人联想到IE因此而声名狼藉的性能问题。IE的垃圾收集器是根据内存分配量运行的,具体一点说就是256个变量、4096个对象(或数组)字面量和数组元素(slot)或者64KB的字符串。达到上述任何一个临界值,垃圾收集器就会运行。这种实现方式的问题在于,如果一个脚本中包含那么多变量,那么该脚本很可能会在其生命周期中一直保有那么多的变量。而这样一来,垃圾收集器就不得不频繁地运行。结果,由此引发的严重性能问题促使IE7重写了其垃圾收集例程。

随着IE7的发布,其JavaScript引擎的垃圾收集例程改变了工作方式:触发垃圾收集的变量分配、字面量和(或)数组元素的临界值被调整为动态修正。IE7中的各项临界值在初始时与IE6相等。如果垃圾收集例程回收的内存分配量低于15%,则变量、字面量和(或)数组元素的临界值就会加倍。如果例程回收了85%的内存分配量,则将各种临界值重置回默认值。这一看似简单的调整,极大地提升了IE在运行包含大量JavaScript的页面时的性能。

事实上,在有的浏览器中可以触发垃圾收集过程,但我们不建议读者这样做。在IE中,调用 window.CollectGarbage() 方法会立即执行垃圾收集。在Opera 7及更高版本中,调用 window.opera.collect() 也会启动垃圾收集例程。

4.3.4 管理内存

使用具备垃圾收集机制的语言编写程序,开发人员一般不必操心内存管理的问题。但是,JavaScript在进行内存管理及垃圾收集时面临的问题还是有点与众不同。其中最主要的一个问题,就是分配给Web浏览器的可用内存数量通常要比分配给桌面应用程序的少。这样做的目的主要是出于安全方面的考虑,目的是防止运行JavaScript的网页耗尽全部系统内存而导致系统崩溃。内存限制问题不仅会影响给变量分配内存,同时还会影响调用栈以及在一个线程中能够同时执行的语句数量。

因此,确保占用最少的内存可以让页面获得更好的性能。而优化内存占用的最佳方式,就是为执行中的代码只保存必要的数据。一旦数据不再有用,最好通过将其值设置为null 来释放其引用——这个做法叫做解除引用 (dereferencing)。这一做法适用于大多数全局变量和全局对象的属性。局部变量会在它们离开执行环境时自动被解除引用,如下面这个例子所示:

function createPerson(name){
    var localPerson = new Object();
    localPerson.name = name;
    return localPerson;
}

var globalPerson = createPerson("Nicholas");

// 手工解除globalPerson的引用

globalPerson = null;


在这个例子中,变量globalPerson 取得了createPerson() 函数返回的值。在createPerson() 函数内部,我们创建了一个对象并将其赋给局部变量localPerson ,然后又为该对象添加了一个名为name 的属性。最后,当调用这个函数时,localPerson 以函数值的形式返回并赋给全局变量globalPerson 。由于localPersoncreatePerson() 函数执行完毕后就离开了其执行环境,因此无需我们显式地去为它解除引用。但是对于全局变量globalPerson 而言,则需要我们在不使用它的时候手工为它解除引用,这也正是上面例子中最后一行代码的目的。

不过,解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。

JavaScript变量可以用来保存两种类型的值:基本类型值和引用类型值。基本类型的值源自以下5种基本数据类型:UndefinedNullBooleanNumberString 。基本类型值和引用类型值具有以下特点:

所有变量(包括基本类型和引用类型)都存在于一个执行环境(也称为作用域)当中,这个执行环境决定了变量的生命周期,以及哪一部分代码可以访问其中的变量。以下是关于执行环境的几点总结:

JavaScript是一门具有自动垃圾收集机制的编程语言,开发人员不必关心内存分配和回收问题。可以对JavaScript的垃圾收集例程作如下总结。


第5章 引用类型

本章内容

引用类型的值(对象)是引用类型 的一个实例。在ECMAScript中,引用类型 是一种数据结构,用于将数据和功能组织在一起。它也常被称为 ,但这种称呼并不妥当。尽管ECMAScript从技术上讲是一门面向对象的语言,但它不具备传统的面向对象语言所支持的类和接口等基本结构。引用类型有时候也被称为对象定义 ,因为它们描述的是一类对象所具有的属性和方法。

虽然引用类型与类看起来相似,但它们并不是相同的概念。为避免混淆,本书将不使用类这个概念。

如前所述,对象是某个特定引用类型的实例 。新对象是使用new 操作符后跟一个构造函数 来创建的。构造函数本身就是一个函数,只不过该函数是出于创建新对象的目的而定义的。请看下面这行代码:

var person = new Object(); 


这行代码创建了Object 引用类型的一个新实例,然后把该实例保存在了变量person 中。使用的构造函数是Object ,它只为新对象定义了默认的属性和方法。ECMAScript提供了很多原生引用类型(例如Object ),以便开发人员用以实现常见的计算任务。

到目前为止,我们看到的大多数引用类型值都是Object 类型的实例;而且,Object 也是ECMAScript中使用最多的一个类型。虽然Object 的实例不具备多少功能,但对于在应用程序中存储和传输数据而言,它们确实是非常理想的选择。

创建Object 实例的方式有两种。第一种是使用new 操作符后跟Object 构造函数,如下所示:

var person = new Object();
person.name = "Nicholas";
person.age = 29;


ObjectTypeExample01.htm

另一种方式是使用对象字面量 表示法。对象字面量是对象定义的一种简写形式,目的在于简化创建包含大量属性的对象的过程。下面这个例子就使用了对象字面量语法定义了与前面那个例子中相同的person 对象:

var person = {
    name : "Nicholas",
    age : 29
};


ObjectTypeExample02.htm

在这个例子中,左边的花括号({ )表示对象字面量的开始,因为它出现在了表达式上下文(expression context)中。ECMAScript中的表达式上下文指的是能够返回一个值(表达式)。赋值操作符表示后面是一个值,所以左花括号在这里表示一个表达式的开始。同样的花括号,如果出现在一个语句上下文(statement context)中,例如跟在if 语句条件的后面,则表示一个语句块的开始。

然后,我们定义了name 属性,之后是一个冒号,再后面是这个属性的值。在对象字面量中,使用逗号来分隔不同的属性,因此"Nicholas" 后面是一个逗号。但是,在age 属性的值29的后面不能添加逗号,因为age 是这个对象的最后一个属性。在最后一个属性后面添加逗号,会在IE7及更早版本和Opera中导致错误。

在使用对象字面量语法时,属性名也可以使用字符串,如下面这个例子所示。

var person = {
    "name" : "Nicholas",


    "age" : 29,


    5 : true


};

这个例子会创建一个对象,包含三个属性:nameage5 。但这里的数值属性名会自动转换为字符串。

另外,使用对象字面量语法时,如果留空其花括号,则可以定义只包含默认属性和方法的对象,如下所示:

var person = {};         //与new Object()相同


person.name = "Nicholas";
person.age = 29;

这个例子与本节前面的例子是等价的,只不过看起来似乎有点奇怪。关于对象字面量语法,我们推荐只在考虑对象属性名的可读性时使用。

在通过对象字面量定义对象时,实际上不会调用Object 构造函数(Firefox 2及更早版本会调用Object 构造函数;但Firefox 3之后就不会了)。

虽然可以使用前面介绍的任何一种方法来定义对象,但开发人员更青睐对象字面量语法,因为这种语法要求的代码量少,而且能够给人封装数据的感觉。实际上,对象字面量也是向函数传递大量可选参数的首选方式,例如:

function displayInfo(args) {
    var output = "";

    if (typeof args.name == "string"){
        output += "Name: " + args.name + "\n";
    }

    if (typeof args.age == "number") {
        output += "Age: " + args.age + "\n";
    }

    alert(output);
}

displayInfo({ 
    name: "Nicholas", 
    age: 29
});

displayInfo({
    name: "Greg"
});


ObjectTypeExample04.htm

在这个例子中,函数displayInfo() 接受一个名为args 的参数。这个参数可能带有一个名为nameage 的属性,也可能这两个属性都有或者都没有。在这个函数内部,我们通过typeof 操作符来检测每个属性是否存在,然后再基于相应的属性来构建一条要显示的消息。然后,我们调用了两次这个函数,每次都使用一个对象字面量来指定不同的数据。这两次调用传递的参数虽然不同,但函数都能正常执行。

这种传递参数的模式最适合需要向函数传入大量可选参数的情形。一般来讲,命名参数虽然容易处理,但在有多个可选参数的情况下就会显示不够灵活。最好的做法是对那些必需值使用命名参数,而使用对象字面量来封装多个可选参数。

一般来说,访问对象属性时使用的都是点表示法,这也是很多面向对象语言中通用的语法。不过,在JavaScript也可以使用方括号表示法来访问对象的属性。在使用方括号语法时,应该将要访问的属性以字符串的形式放在方括号中,如下面的例子所示。

alert(person["name"]);          //"Nicholas"
alert(person.name);             //"Nicholas"


从功能上看,这两种访问对象属性的方法没有任何区别。但方括号语法的主要优点是可以通过变量来访问属性,例如:

var propertyName = "name";
alert(person[propertyName]);    //"Nicholas"


如果属性名中包含会导致语法错误的字符,或者属性名使用的是关键字或保留字,也可以使用方括号表示法。例如:

person["first name"] = "Nicholas";


由于"first name" 中包含一个空格,所以不能使用点表示法来访问它。然而,属性名中是可以包含非字母非数字的,这时候就可以使用方括号表示法来访问它们。

通常,除非必须使用变量来访问属性,否则我们建议使用点表示法。

除了Object 之外,Array 类型恐怕是ECMAScript中最常用的类型了。而且,ECMAScript中的数组与其他多数语言中的数组有着相当大的区别。虽然ECMAScript数组与其他语言中的数组都是数据的有序列表,但与其他语言不同的是,ECMAScript数组的每一项可以保存任何类型的数据。也就是说,可以用数组的第一个位置来保存字符串,用第二位置来保存数值,用第三个位置来保存对象,以此类推。而且,ECMAScript数组的大小是可以动态调整的,即可以随着数据的添加自动增长以容纳新增数据。

创建数组的基本方式有两种。第一种是使用Array 构造函数,如下面的代码所示。

var colors = new Array();


如果预先知道数组要保存的项目数量,也可以给构造函数传递该数量,而该数量会自动变成length 属性的值。例如,下面的代码将创建length 值为20的数组。

var colors = new Array(20);


也可以向Array 构造函数传递数组中应该包含的项。以下代码创建了一个包含3个字符串值的数组:

var colors = new Array("red", "blue", "green");


当然,给构造函数传递一个值也可以创建数组。但这时候问题就复杂一点了,因为如果传递的是数值,则会按照该数值创建包含给定项数的数组;而如果传递的是其他类型的参数,则会创建包含那个值的只有一项的数组。下面就两个例子:

var colors = new Array(3);           // 创建一个包含3项的数组
var names = new Array("Greg");       // 创建一个包含1项,即字符串"Greg"的数组


ArrayTypeExample01.htm

另外,在使用Array 构造函数时也可以省略new 操作符。如下面的例子所示,省略new 操作符的结果相同:

var colors = Array(3);               // 创建一个包含3项的数组
var names = Array("Greg");           // 创建一个包含1项,即字符串"Greg"的数组


创建数组的第二种基本方式是使用数组字面量表示法。数组字面量由一对包含数组项的方括号表示,多个数组项之间以逗号隔开,如下所示:

var colors = ["red", "blue", "green"];   // 创建一个包含3个字符串的数组
var names = [];                          // 创建一个空数组
var values = [1,2,];                     // 不要这样!这样会创建一个包含2或3项的数组
var options = [,,,,,];                   // 不要这样!这样会创建一个包含5或6项的数组


ArrayTypeExample02.htm

以上代码的第一行创建了一个包含3个字符串的数组。第二行使用一对空方括号创建了一个空数组。第三行展示了在数组字面量的最后一项添加逗号的结果:在IE中,values 会成为一个包含3个项且每项的值分别为1、2和undefined 的数组;在其他浏览器中,values 会成为一个包含2项且值分别为1和2的数组。原因是IE8及之前版本中的ECMAScript实现在数组字面量方面存在bug。由于这个bug导致的另一种情况如最后一行代码所示,该行代码可能会创建包含5项的数组(在IE9+、Firefox、Opera、Safari和Chrome中),也可能会创建包含6项的数组(在IE8及更早版本中)。在像这种省略值的情况下,每一项都将获得undefined 值;这个结果与调用Array 构造函数时传递项数在逻辑上是相同的。但是由于IE的实现与其他浏览器不一致,因此我们强烈建议不要使用这种语法。

与对象一样,在使用数组字面量表示法时,也不会调用Array 构造函数(Firefox 3及更早版本除外)。

在读取和设置数组的值时,要使用方括号并提供相应值的基于0的数字索引,如下所示:

var colors = ["red", "blue", "green"];    // 定义一个字符串数组
alert(colors[0]);                         // 显示第一项
colors[2] = "black";                      // 修改第三项
colors[3] = "brown";                      // 新增第四项


方括号中的索引表示要访问的值。如果索引小于数组中的项数,则返回对应项的值,就像这个例子中的colors[0] 会显示"red" 一样。设置数组的值也使用相同的语法,但会替换指定位置的值。如果设置某个值的索引超过了数组现有项数,如这个例子中的colors[3] 所示,数组就会自动增加到该索引值加1的长度(就这个例子而言,索引是3,因此数组长度就是4)。

数组的项数保存在其length 属性中,这个属性始终会返回0或更大的值,如下面这个例子所示:

var colors = ["red", "blue", "green"];       // 创建一个包含3个字符串的数组
var names = [];                              // 创建一个空数组

alert(colors.length);            //3


alert(names.length);             //0


数组的length 属性很有特点——它不是只读的。因此,通过设置这个属性,可以从数组的末尾移除项或向数组中添加新项。请看下面的例子:

var colors = ["red", "blue", "green"];     // 创建一个包含3个字符串的数组
colors.length = 2;


alert(colors[2]);                 //undefined


ArrayTypeExample03.htm

这个例子中的数组colors 一开始有3个值。将其length 属性设置为2会移除最后一项(位置为2的那一项),结果再访问colors[2] 就会显示undefined 了。如果将其length 属性设置为大于数组项数的值,则新增的每一项都会取得undefined 值,如下所示:

var colors = ["red", "blue", "green"];    // 创建一个包含3个字符串的数组
colors.length = 4;


alert(colors[3]);                 //undefined


ArrayTypeExample04.htm

在此,虽然colors 数组包含3个项,但把它的length 属性设置成了4。这个数组不存在位置3,所以访问这个位置的值就得到了特殊值undefined

利用length 属性也可以方便地在数组末尾添加新项,如下所示:

var colors = ["red", "blue", "green"];        // 创建一个包含3个字符串的数组
colors[colors.length] = "black";                   //(在位置3)添加一种颜色


colors[colors.length] = "brown";                   //(在位置4)再添加一种颜色


ArrayTypeExample05.htm

由于数组最后一项的索引始终是length-1 ,因此下一个新项的位置就是length 。每当在数组末尾添加一项后,其length 属性都会自动更新以反应这一变化。换句话说,上面例子第二行中的colors[colors.length] 为位置3添加了一个值,最后一行的colors[colors.length] 则为位置4添加了一个值。当把一个值放在超出当前数组大小的位置上时,数组就会重新计算其长度值,即长度值等于最后一项的索引加1,如下面的例子所示:

var colors = ["red", "blue", "green"];         // 创建一个包含3个字符串的数组
colors[99] = "black";                          // (在位置99)添加一种颜色


alert(colors.length);  // 100


ArrayTypeExample06.htm

在这个例子中,我们向colors 数组的位置99插入了一个值,结果数组新长度(length )就是100(99+1)。而位置3到位置98实际上都是不存在的,所以访问它们都将返回undefined

数组最多可以包含4 294 967 295个项,这几乎已经能够满足任何编程需求了。如果想添加的项数超过这个上限值,就会发生异常。而创建一个初始大小与这个上限值接近的数组,则可能会导致运行时间超长的脚本错误。

5.2.1 检测数组

自从ECMAScript 3做出规定以后,就出现了确定某个对象是不是数组的经典问题。对于一个网页,或者一个全局作用域而言,使用instanceof 操作符就能得到满意的结果:

if (value instanceof Array){ 
    //对数组执行某些操作
}


instanceof 操作符的问题在于,它假定单一的全局执行环境。如果网页中包含多个框架,那实际上就存在两个以上不同的全局执行环境,从而存在两个以上不同版本的Array 构造函数。如果你从一个框架向另一个框架传入一个数组,那么传入的数组与在第二个框架中原生创建的数组分别具有各自不同的构造函数。

为了解决这个问题,ECMAScript 5新增了Array.isArray() 方法。这个方法的目的是最终确定某个值到底是不是数组,而不管它是在哪个全局执行环境中创建的。这个方法的用法如下。

if (Array.isArray(value)){
    //对数组执行某些操作
}


支持Array.isArray() 方法的浏览器有IE9+、Firefox 4+、Safari 5+、Opera 10.5+和Chrome。要在尚未实现这个方法中的浏览器中准确检测数组,请参考22.1.1节。

5.2.2 转换方法

如前所述,所有对象都具有toLocaleString()toString()valueOf() 方法。其中,调用数组的toString()valueOf() 方法会返回相同的值,即由数组中每个值的字符串形式拼接而成的一个以逗号分隔的字符串。实际上,为了创建这个字符串会调用数组每一项的toString() 方法。来看下面这个例子。

var colors = ["red", "blue", "green"];      // 创建一个包含3个字符串的数组
alert(colors.toString());     // red,blue,green


alert(colors.valueOf());      // red,blue,green


alert(colors);                // red,blue,green


ArrayTypeExample07.htm

在这里,我们首先显式地调用了toString()valueOf() 方法,以便返回数组的字符串表示,每个值的字符串表示拼接成了一个字符串,中间以逗号分隔。最后一行代码直接将数组传递给了alert() 。由于alert() 要接收字符串参数,所以它会在后台调用toString() 方法,由此会得到与直接调用toString() 方法相同的结果。

另外,toLocaleString() 方法经常也会返回与toString()valueOf() 方法相同的值,但也不总是如此。当调用数组的toLocaleString() 方法时,它也会创建一个数组值的以逗号分隔的字符串。而与前两个方法唯一的不同之处在于,这一次为了取得每一项的值,调用的是每一项的toLocaleString() 方法,而不是toString() 方法。请看下面这个例子。

var person1 = {
    toLocaleString : function () {
        return "Nikolaos";
    },

    toString : function() {
        return "Nicholas";
    }
};

var person2 = {
    toLocaleString : function () {
        return "Grigorios";
    },

    toString : function() {
        return "Greg";
    }
};

var people = [person1, person2];
alert(people);                           //Nicholas,Greg
alert(people.toString());                //Nicholas,Greg
alert(people.toLocaleString());          //Nikolaos,Grigorios


ArrayTypeExample08.htm

我们在这里定义了两个对象:person1person2 。而且还分别为每个对象定义了一个toString() 方法和一个toLocaleString() 方法,这两个方法返回不同的值。然后,创建一个包含前面定义的两个对象的数组。在将数组传递给alert() 时,输出结果是"Nicholas,Greg" ,因为调用了数组每一项的toString() 方法(同样,这与下一行显式调用toString() 方法得到的结果相同)。而当调用数组的toLocaleString() 方法时,输出结果是"Nikolaos,Grigorios" ,原因是调用了数组每一项的toLocaleString() 方法。

数组继承的toLocaleString()toString()valueOf() 方法,在默认情况下都会以逗号分隔的字符串的形式返回数组项。而如果使用join() 方法,则可以使用不同的分隔符来构建这个字符串。join() 方法只接收一个参数,即用作分隔符的字符串,然后返回包含所有数组项的字符串。请看下面的例子:

var colors = ["red", "green", "blue"];
alert(colors.join(","));       //red,green,blue
alert(colors.join("||"));      //red||green||blue


ArrayTypeJoinExample01.htm

在这里,我们使用join() 方法重现了toString() 方法的输出。在传递逗号的情况下,得到了以逗号分隔的数组值。而在最后一行代码中,我们传递了双竖线符号,结果就得到了字符串"red||green||blue" 。如果不给join() 方法传入任何值,或者给它传入undefined ,则使用逗号作为分隔符。IE7及更早版本会错误的使用字符串"undefined" 作为分隔符。

如果数组中的某一项的值是null 或者undefined ,那么该值在join()toLocale-String()toString()valueOf() 方法返回的结果中以空字符串表示。

5.2.3 栈方法

ECMAScript数组也提供了一种让数组的行为类似于其他数据结构的方法。具体说来,数组可以表现得就像栈一样,后者是一种可以限制插入和删除项的数据结构。栈是一种LIFO(Last-In-First-Out,后进先出)的数据结构,也就是最新添加的项最早被移除。而栈中项的插入(叫做推入 )和移除(叫做弹出 ),只发生在一个位置——栈的顶部。ECMAScript为数组专门提供了push()pop() 方法,以便实现类似栈的行为。

push() 方法可以接收任意数量的参数,把它们逐个添加到数组末尾,并返回修改后数组的长度。而pop() 方法则从数组末尾移除最后一项,减少数组的length 值,然后返回移除的项。请看下面的例子:

var colors = new Array();                       // 创建一个数组
var count = colors.push("red", "green");        // 推入两项
alert(count);    //2

count = colors.push("black");                   // 推入另一项
alert(count);     //3

var item = colors.pop();                        // 取得最后一项
alert(item);      //"black"
alert(colors.length);   //2


ArrayTypeExample09.htm

以上代码中的数组可以看成是栈(代码本身没有任何区别,而push()pop() 都是数组默认的方法)。首先,我们使用push() 将两个字符串推入数组的末尾,并将返回的结果保存在变量count 中(值为2)。然后,再推入一个值,而结果仍然保存在count 中。因为此时数组中包含3项,所以push() 返回3。在调用pop() 时,它会返回数组的最后一项,即字符串"black" 。此后,数组中仅剩两项。

可以将栈方法与其他数组方法连用,像下面这个例子一样。

var colors = ["red", "blue"];
colors.push("brown");                // 添加另一项
colors[3] = "black";                 // 添加一项
alert(colors.length);      // 4

var item = colors.pop();             // 取得最后一项
alert(item);  //"black"


ArrayTypeExample10.htm

在此,我们首先用两个值来初始化一个数组。然后,使用push() 添加第三个值,再通过直接在位置3上赋值来添加第四个值。而在调用pop() 时,该方法返回了字符串"black" ,即最后一个添加到数组的值。

5.2.4 队列方法

栈数据结构的访问规则是LIFO(后进先出),而队列数据结构的访问规则是FIFO(First-In-First-Out,先进先出)。队列在列表的末端添加项,从列表的前端移除项。由于push() 是向数组末端添加项的方法,因此要模拟队列只需一个从数组前端取得项的方法。实现这一操作的数组方法就是shift() ,它能够移除数组中的第一个项并返回该项,同时将数组长度减1。结合使用shift()push() 方法,可以像使用队列一样使用数组。

var colors = new Array();                   //创建一个数组
var count = colors.push("red", "green");    //推入两项
alert(count);    //2

count = colors.push("black");               //推入另一项
alert(count);     //3

var item = colors.shift();                  //取得第一项


alert(item);      //"red"
alert(colors.length); //2

ArrayTypeExample11.htm

这个例子首先使用push() 方法创建了一个包含3种颜色名称的数组。代码中加粗的那一行使用shift() 方法从数组中取得了第一项,即"red" 。在移除第一项之后,"green" 就变成了第一项,而"black" 则变成了第二项,数组也只包含两项了。

ECMAScript还为数组提供了一个unshift() 方法。顾名思义,unshift()shift() 的用途相反:它能在数组前端添加任意个项并返回新数组的长度。因此,同时使用unshift()pop() 方法,可以从相反的方向来模拟队列,即在数组的前端添加项,从数组末端移除项,如下面的例子所示。

var colors = new Array();                       //创建一个数组
var count = colors.unshift("red", "green");     //推入两项


alert(count);   //2

count = colors.unshift("black");                //推入另一项


alert(count);   //3

var item = colors.pop();                        //取得最后一项


alert(item);    //"green"
alert(colors.length); //2

ArrayTypeExample12.htm

这个例子创建了一个数组并使用unshift() 方法先后推入了3个值。首先是"red""green" ,然后是"black" ,数组中各项的顺序为"black""red""green" 。在调用pop() 方法时,移除并返回的是最后一项,即"green"

IE7及更早版本对JavaScript的实现中存在一个偏差,其unshift() 方法总是返回undefined 而不是数组的新长度。IE8在非兼容模式下会返回正确的长度值。

5.2.5 重排序方法

数组中已经存在两个可以直接用来重排序的方法:reverse()sort() 。有读者可能猜到了,reverse() 方法会对反转数组项的顺序。请看下面这个例子。

var values = [1, 2, 3, 4, 5];
values.reverse();
alert(values);        //5,4,3,2,1


ArrayTypeExample13.htm

这里数组的初始值及顺序是1、2、3、4、5。而调用数组的reverse() 方法后,其值的顺序变成了5、4、3、2、1。这个方法的作用相当直观明了,但不够灵活,因此才有了sort() 方法。

在默认情况下,sort() 方法按升序排列数组项——即最小的值位于最前面,最大的值排在最后面。为了实现排序,sort() 方法会调用每个数组项的toString() 转型方法,然后比较得到的字符串,以确定如何排序。即使数组中的每一项都是数值,sort() 方法比较的也是字符串,如下所示。

var values = [0, 1, 5, 10, 15];
values.sort();
alert(values);     //0,1,10,15,5


ArrayTypeExample14.htm

可见,即使例子中值的顺序没有问题,但sort() 方法也会根据测试字符串的结果改变原来的顺序。因为数值5虽然小于10,但在进行字符串比较时,"10" 则位于"5" 的前面,于是数组的顺序就被修改了。不用说,这种排序方式在很多情况下都不是最佳方案。因此sort() 方法可以接收一个比较函数作为参数,以便我们指定哪个值位于哪个值的前面。

比较函数接收两个参数,如果第一个参数应该位于第二个之前则返回一个负数,如果两个参数相等则返回0,如果第一个参数应该位于第二个之后则返回一个正数。以下就是一个简单的比较函数:

function compare(value1, value2) {
    if (value1 < value2) {
        return -1;
    } else if (value1 > value2) {
        return 1;
    } else {
        return 0;
    }
}


ArrayTypeExample15.htm

这个比较函数可以适用于大多数数据类型,只要将其作为参数传递给sort() 方法即可,如下面这个例子所示。

var values = [0, 1, 5, 10, 15];
values.sort(compare);


alert(values);   //0,1,5,10,15

在将比较函数传递到sort() 方法之后,数值仍然保持了正确的升序。当然,也可以通过比较函数产生降序排序的结果,只要交换比较函数返回的值即可。

function compare(value1, value2) {
    if (value1 < value2) {
        return 1;


    } else if (value1 > value2) {
        return -1;


    } else {
        return 0;
    }
}

var values = [0, 1, 5, 10, 15];
values.sort(compare);
alert(values);    // 15,10,5,1,0

ArrayTypeExample16.htm

在这个修改后的例子中,比较函数在第一个值应该位于第二个之后的情况下返回1,而在第一个值应该在第二个之前的情况下返回?1。交换返回值的意思是让更大的值排位更靠前,也就是对数组按照降序排序。当然,如果只想反转数组原来的顺序,使用reverse() 方法要更快一些。

reverse()sort() 方法的返回值是经过排序之后的数组。

对于数值类型或者其valueOf() 方法会返回数值类型的对象类型,可以使用一个更简单的比较函数。这个函数只要用第二个值减第一个值即可1

1 如果想要按照升级排序,则compare() 函数中的return 语句应该返回value2–value1

function compare(value1, value2){
    return value2 - value1;
}


由于比较函数通过返回一个小于零、等于零或大于零的值来影响排序结果,因此减法操作就可以适当地处理所有这些情况。

5.2.6 操作方法

ECMAScript为操作已经包含在数组中的项提供了很多方法。其中,concat() 方法可以基于当前数组中的所有项创建一个新数组。具体来说,这个方法会先创建当前数组一个副本,然后将接收到的参数添加到这个副本的末尾,最后返回新构建的数组。在没有给concat() 方法传递参数的情况下,它只是复制当前数组并返回副本。如果传递给concat() 方法的是一或多个数组,则该方法会将这些数组中的每一项都添加到结果数组中。如果传递的值不是数组,这些值就会被简单地添加到结果数组的末尾。下面来看一个例子。

var colors = ["red", "green", "blue"];
var colors2 = colors.concat("yellow", ["black", "brown"]);

alert(colors);     //red,green,blue
alert(colors2);    //red,green,blue,yellow,black,brown


ArrayTypeConcatExample01.htm

以上代码开始定义了一个包含3个值的数组colors 。然后,基于colors 调用了concat() 方法,并传入字符串"yellow" 和一个包含"black""brown" 的数组。最终,结果数组colors2 中包含了"red""green""blue""yellow""black""brown" 。至于原来的数组colors ,其值仍然保持不变。

下一个方法是slice() ,它能够基于当前数组中的一或多个项创建一个新数组。slice() 方法可以接受一或两个参数,即要返回项的起始和结束位置。在只有一个参数的情况下,slice() 方法返回从该参数指定位置开始到当前数组末尾的所有项。如果有两个参数,该方法返回起始和结束位置之间的项——但不包括结束位置的项。注意,slice() 方法不会影响原始数组。请看下面的例子。

var colors = ["red", "green", "blue", "yellow", "purple"];
var colors2 = colors.slice(1);
var colors3 = colors.slice(1,4);

alert(colors2);   //green,blue,yellow,purple
alert(colors3);   //green,blue,yellow


ArrayTypeSliceExample01.htm

在这个例子中,开始定义的数组colors 包含5项。调用slice() 并传入1会得到一个包含4项的新数组;因为是从位置1开始复制,所以会包含"green" 而不会包含"red" 。这个新数组colors2 中包含的是"green""blue""yellow""purple" 。接着,我们再次调用slice() 并传入了1和4,表示复制从位置1开始,到位置3结束。结果数组colors3 中包含了"green""blue""yellow"

如果slice() 方法的参数中有一个负数,则用数组长度加上该数来确定相应的位置。例如,在一个包含5项的数组上调用slice(-2,-1) 与调用slice(3,4) 得到的结果相同。如果结束位置小于起始位置,则返回空数组。

下面我们来介绍splice() 方法,这个方法恐怕要算是最强大的数组方法了,它有很多种用法。splice() 的主要用途是向数组的中部插入项,但使用这种方法的方式则有如下3种。

splice() 方法始终都会返回一个数组,该数组中包含从原始数组中删除的项(如果没有删除任何项,则返回一个空数组)。下面的代码展示了上述3种使用splice() 方法的方式。

var colors = ["red", "green", "blue"];
var removed = colors.splice(0,1);                    // 删除第一项
alert(colors);     // green,blue
alert(removed);    // red,返回的数组中只包含一项

removed = colors.splice(1, 0, "yellow", "orange");   // 从位置1开始插入两项
alert(colors);     // green,yellow,orange,blue
alert(removed);    // 返回的是一个空数组

removed = colors.splice(1, 1, "red", "purple");      // 插入两项,删除一项
alert(colors);     // green,red,purple,orange,blue
alert(removed);    // yellow,返回的数组中只包含一项


ArrayTypeSpliceExample01.htm

上面的例子首先定义了一个包含3项的数组colors 。第一次调用splice() 方法只是删除了这个数组的第一项,之后colors 还包含"green""blue" 两项。第二次调用splice() 方法时在位置1插入了两项,结果colors 中包含"green""yellow""orange""blue" 。这一次操作没有删除项,因此返回了一个空数组。最后一次调用splice() 方法删除了位置1处的一项,然后又插入了"red""purple" 。在完成以上操作之后,数组colors 中包含的是"green""red""purple""orange""blue"

5.2.7 位置方法

ECMAScript 5为数组实例添加了两个位置方法:indexOf()lastIndexOf() 。这两个方法都接收两个参数:要查找的项和(可选的)表示查找起点位置的索引。其中,indexOf() 方法从数组的开头(位置0)开始向后查找,lastIndexOf() 方法则从数组的末尾开始向前查找。

这两个方法都返回要查找的项在数组中的位置,或者在没找到的情况下返回?1。在比较第一个参数与数组中的每一项时,会使用全等操作符;也就是说,要求查找的项必须严格相等(就像使用===一样)。以下是几个例子。

var numbers = [1,2,3,4,5,4,3,2,1];

alert(numbers.indexOf(4));        //3
alert(numbers.lastIndexOf(4));    //5

alert(numbers.indexOf(4, 4));     //5
alert(numbers.lastIndexOf(4, 4)); //3

var person = { name: "Nicholas" };
var people = [{ name: "Nicholas" }];

var morePeople = [person];

alert(people.indexOf(person));     //-1
alert(morePeople.indexOf(person)); //0


ArrayIndexOfExample01.htm

使用indexOf()lastIndexOf() 方法查找特定项在数组中的位置非常简单,支持它们的浏览器包括IE9+、Firefox 2+、Safari 3+、Opera 9.5+和Chrome。

5.2.8 迭代方法

ECMAScript 5为数组定义了5个迭代方法。每个方法都接收两个参数:要在每一项上运行的函数和(可选的)运行该函数的作用域对象——影响this 的值。传入这些方法中的函数会接收三个参数:数组项的值、该项在数组中的位置和数组对象本身。根据使用的方法不同,这个函数执行后的返回值可能会也可能不会影响访问的返回值。以下是这5个迭代方法的作用。

以上方法都不会修改数组中的包含的值。

在这些方法中,最相似的是every()some() ,它们都用于查询数组中的项是否满足某个条件。对every() 来说,传入的函数必须对每一项都返回true ,这个方法才返回true ;否则,它就返回false 。而some() 方法则是只要传入的函数对数组中的某一项返回true ,就会返回true 。请看以下例子。

var numbers = [1,2,3,4,5,4,3,2,1];

var everyResult = numbers.every(function(item, index, array){
    return (item > 2); 
});

alert(everyResult);     //false

var someResult = numbers.some(function(item, index, array){
    return (item > 2);
});

alert(someResult);      //true


ArrayEveryAndSomeExample01.htm

以上代码调用了every()some() ,传入的函数只要给定项大于2就会返回true 。对于every() ,它返回的是false ,因为只有部分数组项符合条件。对于some() ,结果就是true ,因为至少有一项是大于2的。

下面再看一看filter() 函数,它利用指定的函数确定是否在返回的数组中包含的某一项。例如,要返回一个所有数值都大于2的数组,可以使用以下代码。

var numbers = [1,2,3,4,5,4,3,2,1];

var filterResult = numbers.filter(function(item, index, array){
    return (item > 2);
});

alert(filterResult);       //[3,4,5,4,3]


ArrayFilterExample01.htm

这里,通过调用filter() 方法创建并返回了包含3、4、5、4、3的数组,因为传入的函数对它们每一项都返回true 。这个方法对查询符合某些条件的所有数组项非常有用。

map() 也返回一个数组,而这个数组的每一项都是在原始数组中的对应项上运行传入函数的结果。例如,可以给数组中的每一项乘以2,然后返回这些乘积组成的数组,如下所示。

var numbers = [1,2,3,4,5,4,3,2,1];

var mapResult = numbers.map(function(item, index, array){
    return item * 2;
});

alert(mapResult);   //[2,4,6,8,10,8,6,4,2]


ArrayMapExample01.htm

以上代码返回的数组中包含给每个数乘以2之后的结果。这个方法适合创建包含的项与另一个数组一一对应的数组。

最后一个方法是forEach() ,它只是对数组中的每一项运行传入的函数。这个方法没有返回值,本质上与使用for 循环迭代数组一样。来看一个例子。

var numbers = [1,2,3,4,5,4,3,2,1];

numbers.forEach(function(item, index, array){
    //执行某些操作 
});


这些数组方法通过执行不同的操作,可以大大方便处理数组的任务。支持这些迭代方法的浏览器有IE9+、Firefox 2+、Safari 3+、Opera 9.5+和Chrome。

5.2.9 缩小方法

ECMAScript 5还新增了两个缩小数组的方法:reduce()reduceRight() 。这两个方法都会迭代数组的所有项,然后构建一个最终返回的值。其中,reduce() 方法从数组的第一项开始,逐个遍历到最后。而reduceRight() 则从数组的最后一项开始,向前遍历到第一项。

这两个方法都接收两个参数:一个在每一项上调用的函数和(可选的)作为缩小基础的初始值。传给reduce()reduceRight() 的函数接收4个参数:前一个值、当前值、项的索引和数组对象。这个函数返回的任何值都会作为第一个参数自动传给下一项。第一次迭代发生在数组的第二项上,因此第一个参数是数组的第一项,第二个参数就是数组的第二项。

使用reduce() 方法可以执行求数组中所有值之和的操作,比如:

var values = [1,2,3,4,5];
var sum = values.reduce(function(prev, cur, index, array){
    return prev + cur; 
});
alert(sum); //15


ArrayReductionExample01.htm

第一次执行回调函数,prev 是1,cur 是2。第二次,prev 是3(1加2的结果),cur 是3(数组的第三项)。这个过程会持续到把数组中的每一项都访问一遍,最后返回结果。

reduceRight() 的作用类似,只不过方向相反而已。来看下面这个例子。

var values = [1,2,3,4,5];
var sum = values.reduceRight(function(prev, cur, index, array){
    return prev + cur;
});
alert(sum); //15


在这个例子中,第一次执行回调函数,prev 是5,cur 是4。当然,最终结果相同,因为执行的都是简单相加的操作。

使用reduce() 还是reduceRight() ,主要取决于要从哪头开始遍历数组。除此之外,它们完全相同。

支持这两个缩小函数的浏览器有IE9+、Firefox 3+、Safari 4+、Opera 10.5和Chrome。

ECMAScript中的Date 类型是在早期Java中的java.util.Date 类基础上构建的。为此,Date 类型使用自UTC(Coordinated Universal Time,国际协调时间)1970年1月1日午夜(零时)开始经过的毫秒数来保存日期。在使用这种数据存储格式的条件下,Date 类型保存的日期能够精确到1970年1月1日之前或之后的285 616年。

要创建一个日期对象,使用new 操作符和Date 构造函数即可,如下所示。

var now = new Date();


DateTypeExample01.htm

在调用Date 构造函数而不传递参数的情况下,新创建的对象自动获得当前日期和时间。如果想根据特定的日期和时间创建日期对象,必须传入表示该日期的毫秒数(即从UTC时间1970年1月1日午夜起至该日期止经过的毫秒数)。为了简化这一计算过程,ECMAScript提供了两个方法:Date.parse()Date.UTC()

其中,Date.parse() 方法接收一个表示日期的字符串参数,然后尝试根据这个字符串返回相应日期的毫秒数。ECMA-262没有定义Date.parse() 应该支持哪种日期格式,因此这个方法的行为因实现而异,而且通常是因地区而异。将地区设置为美国的浏览器通常都接受下列日期格式:

例如,要为2004年5月25日创建一个日期对象,可以使用下面的代码:

var someDate = new Date(Date.parse("May 25, 2004"));


DateTypeExample01.htm

如果传入Date.parse() 方法的字符串不能表示日期,那么它会返回NaN 。实际上,如果直接将表示日期的字符串传递给Date 构造函数,也会在后台调用Date.parse() 。换句话说,下面的代码与前面的例子是等价的:

var someDate = new Date("May 25, 2004");


这行代码将会得到与前面相同的日期对象。

日期对象及其在不同浏览器中的实现有许多奇怪的行为。其中有一种倾向是将超出范围的值替换成当前的值,以便生成输出。例如,在解析"January 32, 2007" 时,有的浏览器会将其解释为"February 1, 2007" 。而Opera则倾向于插入当前月份的当前日期,返回"January 当前日期,2007" 。也就是说,如果在2007年9月21日运行前面的代码,将会得到"January 21, 2007" (都是21日)。

Date.UTC() 方法同样也返回表示日期的毫秒数,但它与Date.parse() 在构建值时使用不同的信息。Date.UTC() 的参数分别是年份、基于0的月份(一月是0,二月是1,以此类推)、月中的哪一天(1到31)、小时数(0到23)、分钟、秒以及毫秒数。在这些参数中,只有前两个参数(年和月)是必需的。如果没有提供月中的天数,则假设天数为1;如果省略其他参数,则统统假设为0。以下是两个使用Date.UTC() 方法的例子:

// GMT时间2000年1月1日午夜零时
var y2k = new Date(Date.UTC(2000, 0));

// GMT时间2005年5月5日下午5:55:55
var allFives = new Date(Date.UTC(2005, 4, 5, 17, 55, 55));


DateTypeUTCExample01.htm

这个例子创建了两个日期对象。第一个对象表示GMT时间2000年1月1日午夜零时,传入的值一个是表示年份的2000,一个是表示月份的0(即一月份)。因为其他参数是自动填充的(即月中的天数为1,其他所有参数均为0),所以结果就是该月第一天的午夜零时。第二个对象表示GMT时间2005年5月5日下午5:55:55,即使日期和时间中只包含5,也需要传入不一样的参数:月份必须是4(因为月份是基于0的)、小时必须设置为17(因为小时以0到23表示),剩下的参数就很直观了。

如同模仿Date.parse() 一样,Date 构造函数也会模仿Date.UTC() ,但有一点明显不同:日期和时间都基于本地时区而非GMT来创建。不过,Date 构造函数接收的参数仍然与Date.UTC() 相同。因此,如果第一个参数是数值,Date 构造函数就会假设该值是日期中的年份,而第二个参数是月份,以此类推。据此,可以将前面的例子重写如下。

// 本地时间2000年1月1日午夜零时
var y2k = new Date(2000, 0);

// 本地时间2005年5月5日下午5:55:55
var allFives = new Date(2005, 4, 5, 17, 55, 55);


DateTypeConstructorExample01.htm

以上代码创建了与前面例子中相同的两个日期对象,只不过这次的日期都是基于系统设置的本地时区创建的。

ECMAScript 5添加了Data.now() 方法,返回表示调用这个方法时的日期和时间的毫秒数。这个方法简化了使用Data 对象分析代码的工作。例如:

//取得开始时间
var start = Date.now();

//调用函数
doSomething();

//取得停止时间
var stop = Date.now(),
    result = stop – start;


支持Data.now() 方法的浏览器包括IE9+、Firefox 3+、Safari 3+、Opera 10.5和Chrome。在不支持它的浏览器中,使用+操作符把Data 对象转换成字符串,也可以达到同样的目的。

//取得开始时间
var start = +new Date();



//调用函数
doSomething();
//取得停止时间
var stop = +new Date(),


    result = stop – start;

5.3.1 继承的方法

与其他引用类型一样,Date 类型也重写了toLocaleString()toString()valueOf() 方法;但这些方法返回的值与其他类型中的方法不同。Date 类型的toLocaleString() 方法会按照与浏览器设置的地区相适应的格式返回日期和时间。这大致意味着时间格式中会包含AM或PM,但不会包含时区信息(当然,具体的格式会因浏览器而异)。而toString() 方法则通常返回带有时区信息的日期和时间,其中时间一般以军用时间(即小时的范围是0到23)表示。下面给出了在不同浏览器中调用toLocaleString()toString() 方法,输出PST(Pacific Standard Time,太平洋标准时间)时间2007年2月1日午夜零时的结果。

Internet Explorer 8

toLocaleString() — Thursday, February 01, 2007 12:00:00 AM
toString() — Thu Feb 1 00:00:00 PST 2007


Firefox 3.5

toLocaleString() — Thursday, February 01, 2007 12:00:00 AM
toString() — Thu Feb 01 2007 00:00:00 GMT-0800 (Pacific Standard Time)


Safari 4

toLocaleString() — Thursday, February 01, 2007 00:00:00
toString() — Thu Feb 01 2007 00:00:00 GMT-0800 (Pacific Standard Time)


Chrome 4

toLocaleString() — Thu Feb 01 2007 00:00:00 GMT-0800 (Pacific Standard Time)
toString() — Thu Feb 01 2007 00:00:00 GMT-0800 (Pacific Standard Time)


Opera 10

toLocaleString() — 2/1/2007 12:00:00 AM
toString() — Thu, 01 Feb 2007 00:00:00 GMT-0800


显然,这两个方法在不同的浏览器中返回的日期和时间格式可谓大相径庭。事实上, toLocaleString()toString() 的这一差别仅在调试代码时比较有用,而在显示日期和时间时没有什么价值。

至于Date 类型的valueOf() 方法,则根本不返回字符串,而是返回日期的毫秒表示。因此,可以方便使用比较操作符(小于或大于)来比较日期值。请看下面的例子。

var date1 = new Date(2007, 0, 1);                    //"January 1, 2007"
var date2 = new Date(2007, 1, 1);                    //"February 1, 2007"

alert(date1 < date2); //true
alert(date1 > date2); //false


DateTypeValueOfExample01.htm

从逻辑上讲,2007年1月1日要早于2007年2月1日,此时如果我们说前者小于后者比较符合常理。而表示2007年1月1日的毫秒值小于表示2007年2月1日的毫秒值,因此在首先使用小于操作符比较日期时,返回的结果是true 。这样,就为我们比较日期提供了极大方便。

5.3.2 日期格式化方法

Date 类型还有一些专门用于将日期格式化为字符串的方法,这些方法如下。

toLocaleString()toString() 方法一样,以上这些字符串格式方法的输出也是因浏览器而异的,因此没有哪一个方法能够用来在用户界面中显示一致的日期信息。

除了前面介绍的方法之外,还有一个名叫toGMTString() 的方法,这是一个与toUTCString() 等价的方法,其存在目的在于确保向后兼容。不过,ECMAScript推荐现在编写的代码一律使用toUTCString() 方法。

5.3.3 日期/时间组件方法

到目前为止,剩下还未介绍的Date 类型的方法(如下表所示),都是直接取得和设置日期值中特定部分的方法了。需要注意的是,UTC日期指的是在没有时区偏差的情况下(将日期转换为GMT时间)的日期值。

方  法 说  明
getTime() 返回表示日期的毫秒数;与valueOf() 方法返回的值相同
setTime(毫秒) 以毫秒数设置日期,会改变整个日期
getFullYear() 取得4位数的年份(如2007而非仅07)
getUTCFullYear() 返回UTC日期的4位数年份
setFullYear(年) 设置日期的年份。传入的年份值必须是4位数字(如2007而非仅07)
setUTCFullYear(年) 设置UTC日期的年份。传入的年份值必须是4位数字(如2007而非仅07)
getMonth() 返回日期中的月份,其中0表示一月,11表示十二月
getUTCMonth() 返回UTC日期中的月份,其中0表示一月,11表示十二月
setMonth(月) 设置日期的月份。传入的月份值必须大于0,超过11则增加年份
setUTCMonth(月) 设置UTC日期的月份。传入的月份值必须大于0,超过11则增加年份
getDate() 返回日期月份中的天数(1到31)
getUTCDate() 返回UTC日期月份中的天数(1到31)
setDate(日) 设置日期月份中的天数。如果传入的值超过了该月中应有的天数,则增加月份
setUTCDate(日) 设置UTC日期月份中的天数。如果传入的值超过了该月中应有的天数,则增加月份
getDay() 返回日期中星期的星期几(其中0表示星期日,6表示星期六)
getUTCDay() 返回UTC日期中星期的星期几(其中0表示星期日,6表示星期六)
getHours() 返回日期中的小时数(0到23)
getUTCHours() 返回UTC日期中的小时数(0到23)
setHours(时) 设置日期中的小时数。传入的值超过了23则增加月份中的天数
setUTCHours(时) 设置UTC日期中的小时数。传入的值超过了23则增加月份中的天数
getMinutes() 返回日期中的分钟数(0到59)
getUTCMinutes() 返回UTC日期中的分钟数(0到59)
setMinutes(分) 设置日期中的分钟数。传入的值超过59则增加小时数
setUTCMinutes(分) 设置UTC日期中的分钟数。传入的值超过59则增加小时数
getSeconds() 返回日期中的秒数(0到59)
getUTCSeconds() 返回UTC日期中的秒数(0到59)
setSeconds(秒) 设置日期中的秒数。传入的值超过了59会增加分钟数
setUTCSeconds(秒) 设置UTC日期中的秒数。传入的值超过了59会增加分钟数
getMilliseconds() 返回日期中的毫秒数
getUTCMilliseconds() 返回UTC日期中的毫秒数
setMilliseconds(毫秒) 设置日期中的毫秒数
setUTCMilliseconds(毫秒) 设置UTC日期中的毫秒数
getTimezoneOffset() 返回本地时间与UTC时间相差的分钟数。例如,美国东部标准时间返回300。在某地进入夏令时的情况下,这个值会有所变化

ECMAScript通过RegExp 类型来支持正则表达式。使用下面类似Perl的语法,就可以创建一个正则表达式。

var expression = / pattern

 / flags

 ;

其中的模式(pattern)部分可以是任何简单或复杂的正则表达式,可以包含字符类、限定符、分组、向前查找以及反向引用。每个正则表达式都可带有一或多个标志(flags),用以标明正则表达式的行为。正则表达式的匹配模式支持下列3个标志。

因此,一个正则表达式就是一个模式与上述3个标志的组合体。不同组合产生不同结果,如下面的例子所示。

/*
 * 匹配字符串中所有"at"的实例
 */
var pattern1 = /at/g;

/*
 * 匹配第一个"bat"或"cat",不区分大小写
 */
var pattern2 = /[bc]at/i;

/*
 * 匹配所有以"at"结尾的3个字符的组合,不区分大小写
 */
var pattern3 = /.at/gi;


与其他语言中的正则表达式类似,模式中使用的所有元字符 都必须转义。正则表达式中的元字符包括:

( [ { \ ^ $ | ) ? * + .]}


这些元字符在正则表达式中都有一或多种特殊用途,因此如果想要匹配字符串中包含的这些字符,就必须对它们进行转义。下面给出几个例子。

/*
 * 匹配第一个"bat"或"cat",不区分大小写
 */
var pattern1 = /[bc]at/i;

/*
 * 匹配第一个" [bc]at",不区分大小写
 */
var pattern2 = /\[bc\]at/i;

/*
 * 匹配所有以"at"结尾的3个字符的组合,不区分大小写
 */
var pattern3 = /.at/gi;

/*
 * 匹配所有".at",不区分大小写
 */
var pattern4 = /\.at/gi;


在上面的例子中,pattern1 匹配第一个"bat" 或"cat" ,不区分大小写。而要想直接匹配"[bc]at" 的话,就需要像定义pattern2 一样,对其中的两个方括号进行转义。对于pattern3 来说,句点表示位于"at" 之前的任意一个可以构成匹配项的字符。但如果想匹配".at" ,则必须对句点本身进行转义,如pattern4 所示。

前面举的这些例子都是以字面量形式来定义的正则表达式。另一种创建正则表达式的方式是使用RegExp 构造函数,它接收两个参数:一个是要匹配的字符串模式,另一个是可选的标志字符串。可以使用字面量定义的任何表达式,都可以使用构造函数来定义,如下面的例子所示。

/*
 * 匹配第一个"bat"或"cat",不区分大小写
 */
var pattern1 = /[bc]at/i;

/*
 * 与pattern1相同,只不过是使用构造函数创建的
 */
var pattern2 = new RegExp("[bc]at", "i");


在此,pattern1pattern2 是两个完全等价的正则表达式。要注意的是,传递给RegExp 构造函数的两个参数都是字符串(不能把正则表达式字面量传递给RegExp 构造函数)。由于RegExp 构造函数的模式参数是字符串,所以在某些情况下要对字符进行双重转义。所有元字符都必须双重转义,那些已经转义过的字符也是如此,例如\n (字符\ 在字符串中通常被转义为\\ ,而在正则表达式字符串中就会变成\\\\ )。下表给出了一些模式,左边是这些模式的字面量形式,右边是使用RegExp 构造函数定义相同模式时使用的字符串。

字面量模式 等价的字符串
/\[bc\]at/ "\\[bc\\]at"
/\.at/ "\\.at"
/name\/age / "name\\/age"
/\d.\d{1,2}/ "\\d.\\d{1,2}"
/\w\\hello\\123/ "\\w\\\\hello\\\\123"

使用正则表达式字面量和使用RegExp 构造函数创建的正则表达式不一样。在ECMAScript 3中,正则表达式字面量始终会共享同一个RegExp 实例,而使用构造函数创建的每一个新RegExp 实例都是一个新实例。来看下面的例子。

var re = null,
    i; 

for (i=0; i < 10; i++){
    re = /cat/g;
    re.test("catastrophe");
}

for (i=0; i < 10; i++){
    re = new RegExp("cat", "g");
    re.test("catastrophe");
}


在第一个循环中,即使是循环体中指定的,但实际上只为/cat/ 创建了一个RegExp 实例。由于实例属性(下一节介绍实例属性)不会重置,所以在循环中再次调用test() 方法会失败。这是因为第一次调用test() 找到了"cat" ,但第二次调用是从索引为3的字符(上一次匹配的末尾)开始的,所以就找不到它了。由于会测试到字符串末尾,所以下一次再调用test() 就又从开头开始了。

第二个循环使用RegExp 构造函数在每次循环中创建正则表达式。因为每次迭代都会创建一个新的RegExp 实例,所以每次调用test() 都会返回true

ECMAScript 5明确规定,使用正则表达式字面量必须像直接调用RegExp 构造函数一样,每次都创建新的RegExp实例。IE9+、Firefox 4+和Chrome都据此做出了修改。

5.4.1 RegExp 实例属性

RegExp 的每个实例都具有下列属性,通过这些属性可以取得有关模式的各种信息。

通过这些属性可以获知一个正则表达式的各方面信息,但却没有多大用处,因为这些信息全都包含在模式声明中。例如:

var pattern1 = /\[bc\]at/i;

alert(pattern1.global);         //false
alert(pattern1.ignoreCase);     //true
alert(pattern1.multiline);      //false
alert(pattern1.lastIndex);      //0
alert(pattern1.source);         //"\[bc\]at"

var pattern2 = new RegExp("\\[bc\\]at", "i");

alert(pattern2.global);         //false
alert(pattern2.ignoreCase);     //true
alert(pattern2.multiline);      //false
alert(pattern2.lastIndex);      //0
alert(pattern2.source);         //"\[bc\]at"


RegExpInstancePropertiesExample01.htm

我们注意到,尽管第一个模式使用的是字面量,第二个模式使用了RegExp 构造函数,但它们的source 属性是相同的。可见,source 属性保存的是规范形式的字符串,即字面量形式所用的字符串。

5.4.2 RegExp 实例方法

RegExp 对象的主要方法是exec() ,该方法是专门为捕获组而设计的。exec() 接受一个参数,即要应用模式的字符串,然后返回包含第一个匹配项信息的数组;或者在没有匹配项的情况下返回null 。返回的数组虽然是Array 的实例,但包含两个额外的属性:indexinput 。其中,index 表示匹配项在字符串中的位置,而input 表示应用正则表达式的字符串。在数组中,第一项是与整个模式匹配的字符串,其他项是与模式中的捕获组匹配的字符串(如果模式中没有捕获组,则该数组只包含一项)。请看下面的例子。

var text = "mom and dad and baby";
var pattern = /mom( and dad( and baby)?)?/gi;

var matches = pattern.exec(text);
alert(matches.index);     // 0
alert(matches.input);     // "mom and dad and baby"
alert(matches[0]);        // "mom and dad and baby"
alert(matches[1]);        // " and dad and baby"
alert(matches[2]);        // " and baby"


RegExpExecExample01.htm

这个例子中的模式包含两个捕获组。最内部的捕获组匹配"and baby" ,而包含它的捕获组匹配"and dad" 或者"and dad and baby" 。当把字符串传入exec() 方法中之后,发现了一个匹配项。因为整个字符串本身与模式匹配,所以返回的数组matchsindex 属性值为0。数组中的第一项是匹配的整个字符串,第二项包含与第一个捕获组匹配的内容,第三项包含与第二个捕获组匹配的内容。

对于exec() 方法而言,即使在模式中设置了全局标志(g ),它每次也只会返回一个匹配项。在不设置全局标志的情况下,在同一个字符串上多次调用exec() 将始终返回第一个匹配项的信息。而在设置全局标志的情况下,每次调用exec() 则都会在字符串中继续查找新匹配项,如下面的例子所示。

var text = "cat, bat, sat, fat";
var pattern1 = /.at/;

var matches = pattern1.exec(text);
alert(matches.index);        //0
alert(matches[0]);           //cat
alert(pattern1.lastIndex);   //0

matches = pattern1.exec(text);
alert(matches.index);        //0
alert(matches[0]);           //cat
alert(pattern1.lastIndex);   //0

var pattern2 = /.at/g;

var matches = pattern2.exec(text);
alert(matches.index);        //0
alert(matches[0]);           //cat
alert(pattern2.lastIndex);   //0

matches = pattern2.exec(text);
alert(matches.index);        //5
alert(matches[0]);           //bat
alert(pattern2.lastIndex);   //8


RegExpExecExample02.htm

这个例子中的第一个模式pattern1 不是全局模式,因此每次调用exec() 返回的都是第一个匹配项("cat" )。而第二个模式pattern2 是全局模式,因此每次调用exec() 都会返回字符串中的下一个匹配项,直至搜索到字符串末尾为止。此外,还应该注意模式的lastIndex 属性的变化情况。在全局匹配模式下,lastIndex 的值在每次调用exec() 后都会增加,而在非全局模式下则始终保持不变。

IE的JavaScript实现在lastIndex 属性上存在偏差,即使在非全局模式下,lastIndex 属性每次也会变化。

正则表达式的第二个方法是test() ,它接受一个字符串参数。在模式与该参数匹配的情况下返回true ;否则,返回false 。在只想知道目标字符串与某个模式是否匹配,但不需要知道其文本内容的情况下,使用这个方法非常方便。因此,test() 方法经常被用在if 语句中,如下面的例子所示。

var text = "000-00-0000";
var pattern = /\d{3}-\d{2}-\d{4}/;

if (pattern.test(text)){
    alert("The pattern was matched.");
}


在这个例子中,我们使用正则表达式来测试了一个数字序列。如果输入的文本与模式匹配,则显示一条消息。这种用法经常出现在验证用户输入的情况下,因为我们只想知道输入是不是有效,至于它为什么无效就无关紧要了。

RegExp 实例继承的toLocaleString()toString() 方法都会返回正则表达式的字面量,与创建正则表达式的方式无关。例如:

var pattern = new RegExp("\\[bc\\]at", "gi");
alert(pattern.toString());             // /\[bc\]at/gi
alert(pattern.toLocaleString());       // /\[bc\]at/gi


RegExpToStringExample01.htm

即使上例中的模式是通过调用RegExp 构造函数创建的,但toLocaleString()toString() 方法仍然会像它是以字面量形式创建的一样显示其字符串表示。

正则表达式的valueOf() 方法返回正则表达式本身。

5.4.3 RegExp 构造函数属性

RegExp 构造函数包含一些属性(这些属性在其他语言中被看成是静态属性)。这些属性适用于作用域中的所有正则表达式,并且基于所执行的最近一次正则表达式操作而变化。关于这些属性的另一个独特之处,就是可以通过两种方式访问它们。换句话说,这些属性分别有一个长属性名和一个短属性名(Opera是例外,它不支持短属性名)。下表列出了RegExp 构造函数的属性。

长属性名 短属性名 说  明
input $_ 最近一次要匹配的字符串。Opera未实现此属性
lastMatch $& 最近一次的匹配项。Opera未实现此属性
lastParen $+ 最近一次匹配的捕获组。Opera未实现此属性
leftContext $` input 字符串中lastMatch 之前的文本
multiline $* 布尔值,表示是否所有表达式都使用多行模式。IE和Opera未实现此属性
rightContext $' Input 字符串中lastMatch 之后的文本

使用这些属性可以从exec()test() 执行的操作中提取出更具体的信息。请看下面的例子。

var text = "this has been a short summer";
var pattern = /(.)hort/g;

/*
 * 注意:Opera不支持input、lastMatch、lastParen和multiline属性
 * Internet Explorer不支持multiline属性
 */
if (pattern.test(text)){
    alert(RegExp.input);                // this has been a short summer
    alert(RegExp.leftContext);          // this has been a
    alert(RegExp.rightContext);         // summer
    alert(RegExp.lastMatch);            // short
    alert(RegExp.lastParen);            // s
    alert(RegExp.multiline);            // false
}


RegExpConstructorPropertiesExample01.htm

以上代码创建了一个模式,匹配任何一个字符后跟hort ,而且把第一个字符放在了一个捕获组中。RegExp 构造函数的各个属性返回了下列值:

如前所述,例子使用的长属性名都可以用相应的短属性名来代替。只不过,由于这些短属性名大都不是有效的ECMAScript标识符,因此必须通过方括号语法来访问它们,如下所示。

var text = "this has been a short summer";
var pattern = /(.)hort/g;

/*
 * 注意:Opera不支持input、lastMatch、lastParen和multiline属性
 * Internet Explorer不支持multiline属性
 */
if (pattern.test(text)){
    alert(RegExp.$_);                   // this has been a short summer


    alert(RegExp["$`"]);                // this has been a


    alert(RegExp["$'"]);                // summer


    alert(RegExp["$&"]);                // short


    alert(RegExp["$+"]);                // s


    alert(RegExp["$*"]);                // false


}

RegExpConstructorPropertiesExample02.htm

除了上面介绍的几个属性之外,还有多达9个用于存储捕获组的构造函数属性。访问这些属性的语法是RegExp.$1RegExp.$2 ...RegExp.$9 ,分别用于存储第一、第二……第九个匹配的捕获组。在调用exec()test() 方法时,这些属性会被自动填充。然后,我们就可以像下面这样来使用它们。

var text = "this has been a short summer";
var pattern = /(..)or(.)/g;

if (pattern.test(text)){
    alert(RegExp.$1);       //sh
    alert(RegExp.$2);       //t
}


RegExpConstructorPropertiesExample03.htm

这里创建了一个包含两个捕获组的模式,并用该模式测试了一个字符串。即使test() 方法只返回一个布尔值,但RegExp 构造函数的属性$1$2 也会被匹配相应捕获组的字符串自动填充。

5.4.4 模式的局限性

尽管ECMAScript中的正则表达式功能还是比较完备的,但仍然缺少某些语言(特别是Perl)所支持的高级正则表达式特性。下面列出了ECMAScript正则表达式不支持的特性(要了解更多相关信息,请访问www.regular-expressions.info )。

即使存在这些限制,ECMAScript正则表达式仍然是非常强大的,能够帮我们完成绝大多数模式匹配任务。

说起来ECMAScript中什么最有意思,我想那莫过于函数了——而有意思的根源,则在于函数实际上是对象。每个函数都是Function 类型的实例,而且都与其他引用类型一样具有属性和方法。由于函数是对象,因此函数名实际上也是一个指向函数对象的指针,不会与某个函数绑定。函数通常是使用函数声明语法定义的,如下面的例子所示。

function sum (num1, num2) {
    return num1 + num2;
}


这与下面使用函数表达式定义函数的方式几乎相差无几。

var sum = function(num1, num2){
    return num1 + num2;
};


以上代码定义了变量sum 并将其初始化为一个函数。有读者可能会注意到,function 关键字后面没有函数名。这是因为在使用函数表达式定义函数时,没有必要使用函数名——通过变量sum 即可以引用函数。另外,还要注意函数末尾有一个分号,就像声明其他变量时一样。

最后一种定义函数的方式是使用Function 构造函数。Function 构造函数可以接收任意数量的参数,但最后一个参数始终都被看成是函数体,而前面的参数则枚举出了新函数的参数。来看下面的例子:

var sum = new Function("num1", "num2", "return num1 + num2"); // 不推荐


从技术角度讲,这是一个函数表达式。但是,我们不推荐读者使用这种方法定义函数,因为这种语法会导致解析两次代码(第一次是解析常规ECMAScript代码,第二次是解析传入构造函数中的字符串),从而影响性能。不过,这种语法对于理解“函数是对象,函数名是指针”的概念倒是非常直观的。

由于函数名仅仅是指向函数的指针,因此函数名与包含对象指针的其他变量没有什么不同。换句话说,一个函数可能会有多个名字,如下面的例子所示。

function sum(num1, num2){
    return num1 + num2;
}
alert(sum(10,10));        //20

var anotherSum = sum;
alert(anotherSum(10,10)); //20

sum = null;
alert(anotherSum(10,10)); //20


FunctionTypeExample01.htm

以上代码首先定义了一个名为sum() 的函数,用于求两个值的和。然后,又声明了变量anotherSum ,并将其设置为与sum 相等(将sum 的值赋给anotherSum )。注意,使用不带圆括号的函数名是访问函数指针,而非调用函数。此时,anotherSumsum 就都指向了同一个函数,因此anotherSum() 也可以被调用并返回结果。即使将sum 设置为null ,让它与函数“断绝关系”,但仍然可以正常调用anotherSum()

5.5.1 没有重载(深入理解)

将函数名想象为指针,也有助于理解为什么ECMAScript中没有函数重载的概念。以下是曾在第3章使用过的例子。

function addSomeNumber(num){
    return num + 100;
}

function addSomeNumber(num) {
    return num + 200;
}

var result = addSomeNumber(100); //300


显然,这个例子中声明了两个同名函数,而结果则是后面的函数覆盖了前面的函数。以上代码实际上与下面的代码没有什么区别。

var addSomeNumber = function (num){


    return num + 100;
};

addSomeNumber = function (num) {


    return num + 200;
};

var result = addSomeNumber(100); //300

通过观察重写之后的代码,很容易看清楚到底是怎么回事儿——在创建第二个函数时,实际上覆盖了引用第一个函数的变量addSomeNumber

5.5.2 函数声明与函数表达式

本节到目前为止,我们一直没有对函数声明和函数表达式加以区别。而实际上,解析器在向执行环境中加载数据时,对函数声明和函数表达式并非一视同仁。解析器会率先读取函数声明,并使其在执行任何代码之前可用(可以访问);至于函数表达式,则必须等到解析器执行到它所在的代码行,才会真正被解释执行。请看下面的例子。

alert(sum(10,10));
function sum(num1, num2){
    return num1 + num2;
}


FunctionDeclarationExample01.htm

以上代码完全可以正常运行。因为在代码开始执行之前,解析器就已经通过一个名为函数声明提升(function declaration hoisting)的过程,读取并将函数声明添加到执行环境中。对代码求值时,JavaScript引擎在第一遍会声明函数并将它们放到源代码树的顶部。所以,即使声明函数的代码在调用它的代码后面,JavaScript引擎也能把函数声明提升到顶部。如果像下面例子所示的,把上面的函数声明改为等价的函数表达式,就会在执行期间导致错误。

alert(sum(10,10));
var sum = function(num1, num2){


    return num1 + num2;
};

FunctionInitializationExample01.htm

以上代码之所以会在运行期间产生错误,原因在于函数位于一个初始化语句中,而不是一个函数声明。换句话说,在执行到函数所在的语句之前,变量sum 中不会保存有对函数的引用;而且,由于第一行代码就会导致“unexpected identifier”(意外标识符)错误,实际上也不会执行到下一行。

除了什么时候可以通过变量访问函数这一点区别之外,函数声明与函数表达式的语法其实是等价的。

也可以同时使用函数声明和函数表达式,例如var sum = function sum(){} 。不过,这种语法在Safari中会导致错误。

5.5.3 作为值的函数

因为ECMAScript中的函数名本身就是变量,所以函数也可以作为值来使用。也就是说,不仅可以像传递参数一样把一个函数传递给另一个函数,而且可以将一个函数作为另一个函数的结果返回。来看一看下面的函数。

function callSomeFunction(someFunction, someArgument){
    return someFunction(someArgument);
}


这个函数接受两个参数。第一个参数应该是一个函数,第二个参数应该是要传递给该函数的一个值。然后,就可以像下面的例子一样传递函数了。

function add10(num){
    return num + 10;
}

var result1 = callSomeFunction(add10, 10);
alert(result1);   //20

function getGreeting(name){
    return "Hello, " + name;
}

var result2 = callSomeFunction(getGreeting, "Nicholas");
alert(result2);   //"Hello, Nicholas"


FunctionAsAnArgumentExample01.htm

这里的callSomeFunction() 函数是通用的,即无论第一个参数中传递进来的是什么函数,它都会返回执行第一个参数后的结果。还记得吧,要访问函数的指针而不执行函数的话,必须去掉函数名后面的那对圆括号。因此上面例子中传递给callSomeFunction() 的是add10getGreeting ,而不是执行它们之后的结果。

当然,可以从一个函数中返回另一个函数,而且这也是极为有用的一种技术。例如,假设有一个对象数组,我们想要根据某个对象属性对数组进行排序。而传递给数组sort() 方法的比较函数要接收两个参数,即要比较的值。可是,我们需要一种方式来指明按照哪个属性来排序。要解决这个问题,可以定义一个函数,它接收一个属性名,然后根据这个属性名来创建一个比较函数,下面就是这个函数的定义。

function createComparisonFunction(propertyName) {

    return function(object1, object2){
        var value1 = object1[propertyName];
        var value2 = object2[propertyName];

        if (value1 < value2){
            return -1;
        } else if (value1 > value2){
            return 1;
        } else {
            return 0;
        }
    };
}


FunctionReturningFunctionExample01.htm

这个函数定义看起来有点复杂,但实际上无非就是在一个函数中嵌套了另一个函数,而且内部函数前面加了一个return 操作符。在内部函数接收到propertyName 参数后,它会使用方括号表示法来取得给定属性的值。取得了想要的属性值之后,定义比较函数就非常简单了。上面这个函数可以像在下面例子中这样使用。

var data = [{name: "Zachary", age: 28}, {name: "Nicholas", age: 29}];

data.sort(createComparisonFunction("name"));
alert(data[0].name);  //Nicholas

data.sort(createComparisonFunction("age"));
alert(data[0].name);  //Zachary


这里,我们创建了一个包含两个对象的数组data 。其中,每个对象都包含一个name 属性和一个age 属性。在默认情况下,sort() 方法会调用每个对象的toString() 方法以确定它们的次序;但得到的结果往往并不符合人类的思维习惯。因此,我们调用createComparisonFunction("name") 方法创建了一个比较函数,以便按照每个对象的name 属性值进行排序。而结果排在前面的第一项是name"Nicholas"age 是29的对象。然后,我们又使用了createComparisonFunction("age") 返回的比较函数,这次是按照对象的age 属性排序。得到的结果是name 值为"Zachary"age 值是28的对象排在了第一位。

5.5.4 函数内部属性

在函数内部,有两个特殊的对象:argumentsthis 。其中,arguments 在第3章曾经介绍过,它是一个类数组对象,包含着传入函数中的所有参数。虽然arguments 的主要用途是保存函数参数,但这个对象还有一个名叫callee 的属性,该属性是一个指针,指向拥有这个arguments 对象的函数。请看下面这个非常经典的阶乘函数。

function factorial(num){
    if (num <=1) {
        return 1;
    } else {
        return num * factorial(num-1)
    }
}


定义阶乘函数一般都要用到递归算法;如上面的代码所示,在函数有名字,而且名字以后也不会变的情况下,这样定义没有问题。但问题是这个函数的执行与函数名factorial 紧紧耦合在了一起。为了消除这种紧密耦合的现象,可以像下面这样使用arguments.callee

function factorial(num){
    if (num <=1) {
        return 1;
    } else {
        return num * arguments.callee(num-1)


    }
}

FunctionTypeArgumentsExample01.htm

在这个重写后的factorial() 函数的函数体内,没有再引用函数名factorial 。这样,无论引用函数时使用的是什么名字,都可以保证正常完成递归调用。例如:

var trueFactorial = factorial;

factorial = function(){
    return 0;
};

alert(trueFactorial(5));     //120
alert(factorial(5));         //0


在此,变量trueFactorial 获得了factorial 的值,实际上是在另一个位置上保存了一个函数的指针。然后,我们又将一个简单地返回0的函数赋值给factorial 变量。如果像原来的factorial() 那样不使用arguments.callee ,调用trueFactorial() 就会返回0。可是,在解除了函数体内的代码与函数名的耦合状态之后,trueFactorial() 仍然能够正常地计算阶乘;至于factorial() ,它现在只是一个返回0的函数。

函数内部的另一个特殊对象是this ,其行为与Java和C#中的this 大致类似。换句话说,this 引用的是函数据以执行的环境对象——或者也可以说是this 值(当在网页的全局作用域中调用函数时,this 对象引用的就是window )。来看下面的例子。

window.color = "red";
var o = { color: "blue" };

function sayColor(){
    alert(this.color);
}

sayColor();     //"red"

o.sayColor = sayColor;
o.sayColor();   //"blue"


FunctionTypeThisExample01.htm

上面这个函数sayColor() 是在全局作用域中定义的,它引用了this 对象。由于在调用函数之前,this 的值并不确定,因此this 可能会在代码执行过程中引用不同的对象。当在全局作用域中调用sayColor() 时,this 引用的是全局对象window ;换句话说,对this.color 求值会转换成对window.color 求值,于是结果就返回了"red" 。而当把这个函数赋给对象o 并调用o.sayColor() 时,this 引用的是对象o,因此对this.color 求值会转换成对o.color 求值,结果就返回了"blue"

请读者一定要牢记,函数的名字仅仅是一个包含指针的变量而已。因此,即使是在不同的环境中执行,全局的sayColor() 函数与o.sayColor() 指向的仍然是同一个函数。

ECMAScript 5也规范化了另一个函数对象的属性:caller 。除了Opera的早期版本不支持,其他浏览器都支持这个ECMAScript 3并没有定义的属性。这个属性中保存着调用当前函数的函数的引用,如果是在全局作用域中调用当前函数,它的值为null 。例如:

function outer(){
    inner(); 
}

function inner(){
    alert(inner.caller);
}

outer();


FunctionTypeArgumentsCallerExample01.htm

以上代码会导致警告框中显示outer() 函数的源代码。因为outer() 调用了inter() ,所以inner.caller 就指向outer() 。为了实现更松散的耦合,也可以通过arguments.callee.caller 来访问相同的信息。

function outer(){
    inner();
}

function inner(){
    alert(arguments.callee.caller);


} 

outer();

FunctionTypeArgumentsCallerExample02.htm

IE、Firefox、Chrome和Safari的所有版本以及Opera 9.6都支持caller 属性。

当函数在严格模式下运行时,访问arguments.callee 会导致错误。ECMAScript 5还定义了arguments.caller 属性,但在严格模式下访问它也会导致错误,而在非严格模式下这个属性始终是undefined 。定义这个属性是为了分清arguments.caller 和函数的caller 属性。以上变化都是为了加强这门语言的安全性,这样第三方代码就不能在相同的环境里窥视其他代码了。

严格模式还有一个限制:不能为函数的caller 属性赋值,否则会导致错误。

5.5.5 函数属性和方法

前面曾经提到过,ECMAScript中的函数是对象,因此函数也有属性和方法。每个函数都包含两个属性:lengthprototype 。其中,length 属性表示函数希望接收的命名参数的个数,如下面的例子所示。

function sayName(name){
    alert(name);
}      

function sum(num1, num2){
    return num1 + num2;
}

function sayHi(){
    alert("hi");
}

alert(sayName.length);      //1
alert(sum.length);          //2
alert(sayHi.length);        //0


FunctionTypeLengthPropertyExample01.htm

以上代码定义了3个函数,但每个函数接收的命名参数个数不同。首先,sayName() 函数定义了一个参数,因此其length 属性的值为1。类似地,sum() 函数定义了两个参数,结果其length 属性中保存的值为2。而sayHi() 没有命名参数,所以其length 值为0。

在ECMAScript核心所定义的全部属性中,最耐人寻味的就要数prototype 属性了。对于ECMAScript中的引用类型而言,prototype 是保存它们所有实例方法的真正所在。换句话说,诸如toString()valueOf() 等方法实际上都保存在prototype 名下,只不过是通过各自对象的实例访问罢了。在创建自定义引用类型以及实现继承时,prototype 属性的作用是极为重要的(第6章将详细介绍)。在ECMAScript 5中,prototype 属性是不可枚举的,因此使用for-in 无法发现。

每个函数都包含两个非继承而来的方法:apply()call() 。这两个方法的用途都是在特定的作用域中调用函数,实际上等于设置函数体内this 对象的值。首先,apply() 方法接收两个参数:一个是在其中运行函数的作用域,另一个是参数数组。其中,第二个参数可以是Array 的实例,也可以是arguments 对象。例如:

function sum(num1, num2){
    return num1 + num2;
}

function callSum1(num1, num2){
    return sum.apply(this, arguments);        // 传入arguments对象
}

function callSum2(num1, num2){
    return sum.apply(this, [num1, num2]);    // 传入数组
}

alert(callSum1(10,10));   //20
alert(callSum2(10,10));   //20


FunctionTypeApplyMethodExample01.htm

在上面这个例子中,callSum1() 在执行sum() 函数时传入了this 作为this 值(因为是在全局作用域中调用的,所以传入的就是window 对象)和arguments 对象。而callSum2 同样也调用了sum() 函数,但它传入的则是this 和一个参数数组。这两个函数都会正常执行并返回正确的结果。

在严格模式下,未指定环境对象而调用函数,则this 值不会转型为window 。除非明确把函数添加到某个对象或者调用apply()call() ,否则this 值将是undefined

call() 方法与apply() 方法的作用相同,它们的区别仅在于接收参数的方式不同。对于call() 方法而言,第一个参数是this 值没有变化,变化的是其余参数都直接传递给函数。换句话说,在使用call() 方法时,传递给函数的参数必须逐个列举出来,如下面的例子所示。

function sum(num1, num2){
    return num1 + num2;
}

function callSum(num1, num2){
    return sum.call(this, num1, num2);


}

alert(callSum(10,10));   //20

FunctionTypeCallMethodExample01.htm

在使用call() 方法的情况下,callSum() 必须明确地传入每一个参数。结果与使用apply() 没有什么不同。至于是使用apply() 还是call() ,完全取决于你采取哪种给函数传递参数的方式最方便。如果你打算直接传入arguments 对象,或者包含函数中先接收到的也是一个数组,那么使用apply() 肯定更方便;否则,选择call() 可能更合适。(在不给函数传递参数的情况下,使用哪个方法都无所谓。)

事实上,传递参数并非apply()call() 真正的用武之地;它们真正强大的地方是能够扩充函数赖以运行的作用域。下面来看一个例子。

window.color = "red";
var o = { color: "blue" };

function sayColor(){
    alert(this.color);
}

sayColor();                //red

sayColor.call(this);       //red


sayColor.call(window);     //red


sayColor.call(o);          //blue


FunctionTypeCallExample01.htm

这个例子是在前面说明this 对象的示例基础上修改而成的。这一次,sayColor() 也是作为全局函数定义的,而且当在全局作用域中调用它时,它确实会显示"red" ——因为对this.color 的求值会转换成对window.color 的求值。而sayColor.call(this)sayColor.call(window) ,则是两种显式地在全局作用域中调用函数的方式,结果当然都会显示"red" 。但是,当运行sayColor.call(o) 时,函数的执行环境就不一样了,因为此时函数体内的this 对象指向了o ,于是结果显示的是"blue"

使用call() (或apply() )来扩充作用域的最大好处,就是对象不需要与方法有任何耦合关系。在前面例子的第一个版本中,我们是先将sayColor() 函数放到了对象o 中,然后再通过o 来调用它的;而在这里重写的例子中,就不需要先前那个多余的步骤了。

ECMAScript 5还定义了一个方法:bind() 。这个方法会创建一个函数的实例,其this 值会被绑定到传给bind() 函数的值。例如:

window.color = "red";
var o = { color: "blue" };

function sayColor(){
    alert(this.color);
} 
var objectSayColor = sayColor.bind(o);


objectSayColor();    //blue


FunctionTypeBindMethodExample01.htm

在这里,sayColor() 调用bind() 并传入对象o,创建了objectSayColor() 函数。objectSayColor() 函数的this 值等于o ,因此即使是在全局作用域中调用这个函数,也会看到"blue" 。这种技巧的优点请参考第22章。

支持bind() 方法的浏览器有IE9+、Firefox 4+、Safari 5.1+、Opera 12+和Chrome。

每个函数继承的toLocaleString()toString() 方法始终都返回函数的代码。返回代码的格式则因浏览器而异——有的返回的代码与源代码中的函数代码一样,而有的则返回函数代码的内部表示,即由解析器删除了注释并对某些代码作了改动后的代码。由于存在这些差异,我们无法根据这两个方法返回的结果来实现任何重要功能;不过,这些信息在调试代码时倒是很有用。另外一个继承的valueOf() 方法同样也只返回函数代码。

为了便于操作基本类型值,ECMAScript还提供了3个特殊的引用类型:BooleanNumberString 。这些类型与本章介绍的其他引用类型相似,但同时也具有与各自的基本类型相应的特殊行为。实际上,每当读取一个基本类型值的时候,后台就会创建一个对应的基本包装类型的对象,从而让我们能够调用一些方法来操作这些数据。来看下面的例子。

var s1 = "some text";
var s2 = s1.substring(2);


这个例子中的变量s1 包含一个字符串,字符串当然是基本类型值。而下一行调用了s1substring() 方法,并将返回的结果保存在了s2 中。我们知道,基本类型值不是对象,因而从逻辑上讲它们不应该有方法(尽管如我们所愿,它们确实有方法)。其实,为了让我们实现这种直观的操作,后台已经自动完成了一系列的处理。当第二行代码访问s1 时,访问过程处于一种读取模式,也就是要从内存中读取这个字符串的值。而在读取模式中访问字符串时,后台都会自动完成下列处理。

  1. 创建String 类型的一个实例;

  2. 在实例上调用指定的方法;

  3. 销毁这个实例。

可以将以上三个步骤想象成是执行了下列ECMAScript代码。

var s1 = new String("some text");
var s2 = s1.substring(2);
s1 = null;


经过此番处理,基本的字符串值就变得跟对象一样了。而且,上面这三个步骤也分别适用于BooleanNumber 类型对应的布尔值和数字值。

引用类型与基本包装类型的主要区别就是对象的生存期。使用new 操作符创建的引用类型的实例,在执行流离开当前作用域之前都一直保存在内存中。而自动创建的基本包装类型的对象,则只存在于一行代码的执行瞬间,然后立即被销毁。这意味着我们不能在运行时为基本类型值添加属性和方法。来看下面的例子:

var s1 = "some text";
s1.color = "red";
alert(s1.color);   //undefined


在此,第二行代码试图为字符串s1 添加一个color 属性。但是,当第三行代码再次访问s1 时,其color 属性不见了。问题的原因就是第二行创建的String 对象在执行第三行代码时已经被销毁了。第三行代码又创建自己的String 对象,而该对象没有color 属性。

当然,可以显式地调用BooleanNumberString 来创建基本包装类型的对象。不过,应该在绝对必要的情况下再这样做,因为这种做法很容易让人分不清自己是在处理基本类型还是引用类型的值。对基本包装类型的实例调用typeof 会返回"object" ,而且所有基本包装类型的对象都会被转换为布尔值true

Object 构造函数也会像工厂方法一样,根据传入值的类型返回相应基本包装类型的实例。例如:

var obj = new Object("some text");
alert(obj instanceof String);   //true


把字符串传给Object 构造函数,就会创建String 的实例;而传入数值参数会得到Number 的实例,传入布尔值参数就会得到Boolean 的实例。

要注意的是,使用new 调用基本包装类型的构造函数,与直接调用同名的转型函数是不一样的。 例如:

var value = "25";
var number = Number(value);  //转型函数
alert(typeof number);        //"number"

var obj = new Number(value); //构造函数
alert(typeof obj);           //"object"


在这个例子中,变量number 中保存的是基本类型的值25,而变量obj 中保存的是Number 的实例。要了解有关转型函数的更多信息,请参考第3章。

尽管我们不建议显式地创建基本包装类型的对象,但它们操作基本类型值的能力还是相当重要的。而每个基本包装类型都提供了操作相应值的便捷方法。

5.6.1 Boolean 类型

Boolean 类型是与布尔值对应的引用类型。要创建Boolean 对象,可以像下面这样调用Boolean 构造函数并传入truefalse 值。

var booleanObject = new Boolean(true);


Boolean 类型的实例重写了valueOf() 方法,返回基本类型值truefalse ;重写了toString() 方法,返回字符串"true""false" 。可是,Boolean 对象在ECMAScript中的用处不大,因为它经常会造成人们的误解。其中最常见的问题就是在布尔表达式中使用Boolean 对象,例如:

var falseObject = new Boolean(false);
var result = falseObject && true;
alert(result);  //true

var falseValue = false;
result = falseValue && true;
alert(result);  //false


BooleanTypeExample01.htm

在这个例子中,我们使用false 值创建了一个Boolean 对象。然后,将这个对象与基本类型值true 构成了逻辑与表达式。在布尔运算中,false && true 等于false 。可是,示例中的这行代码是对falseObject 而不是对它的值(false )进行求值。前面讨论过,布尔表达式中的所有对象都会被转换为true ,因此falseObject 对象在布尔表达式中代表的是true 。结果,true && true 当然就等于true 了。

基本类型与引用类型的布尔值还有两个区别。首先,typeof 操作符对基本类型返回"boolean" ,而对引用类型返回"object" 。其次,由于Boolean 对象是Boolean 类型的实例,所以使用instanceof 操作符测试Boolean 对象会返回true ,而测试基本类型的布尔值则返回false 。例如:

alert(typeof falseObject);   //object
alert(typeof falseValue);    //boolean
alert(falseObject instanceof Boolean);  //true
alert(falseValue instanceof Boolean);   //false


理解基本类型的布尔值与Boolean 对象之间的区别非常重要——当然,我们的建议是永远不要使用Boolean 对象。

5.6.2 Number 类型

Number 是与数字值对应的引用类型。要创建Number 对象,可以在调用Number 构造函数时向其中传递相应的数值。下面是一个例子。

var numberObject = new Number(10);


NumberTypeExample01.htm

Boolean 类型一样,Number 类型也重写了valueOf()toLocaleString()toString() 方法。重写后的valueOf() 方法返回对象表示的基本类型的数值,另外两个方法则返回字符串形式的数值。我们在第3章还介绍过,可以为toString() 方法传递一个表示基数的参数,告诉它返回几进制数值的字符串形式,如下面的例子所示。

var num = 10;
alert(num.toString());      //"10"
alert(num.toString(2));     //"1010"
alert(num.toString(8));     //"12"
alert(num.toString(10));    //"10"
alert(num.toString(16));    //"a"


NumberTypeExample01.htm

除了继承的方法之外,Number 类型还提供了一些用于将数值格式化为字符串的方法。

其中,toFixed() 方法会按照指定的小数位返回数值的字符串表示,例如:

var num = 10;
alert(num.toFixed(2));     //"10.00"


NumberTypeExample01.htm

这里给toFixed() 方法传入了数值2 ,意思是显示几位小数。于是,这个方法返回了"10.00" ,即以0填补了必要的小数位。如果数值本身包含的小数位比指定的还多,那么接近指定的最大小数位的值就会舍入,如下面的例子所示。

var num = 10.005;
alert(num.toFixed(2));     //"10.01"


能够自动舍入的特性,使得toFixed() 方法很适合处理货币值。但需要注意的是,不同浏览器给这个方法设定的舍入规则可能会有所不同。在给toFixed() 传入0的情况下,IE8及之前版本不能正确舍入范围在{(?0.94,?0.5],[0.5,0.94)}之间的值。对于这个范围内的值,IE会返回0,而不是?1或1;其他浏览器都能返回正确的值。IE9修复了这个问题。

toFixed() 方法可以表示带有0到20个小数位的数值。但这只是标准实现的范围,有些浏览器也可能支持更多位数。

另外可用于格式化数值的方法是toExponential() ,该方法返回以指数表示法(也称e表示法)表示的数值的字符串形式。与toFixed() 一样,toExponential() 也接收一个参数,而且该参数同样也是指定输出结果中的小数位数。看下面的例子。

var num = 10;
alert(num.toExponential(1));     //"1.0e+1"


以上代码输出了"1.0e+1" ;不过,这么小的数值一般不必使用e表示法。如果你想得到表示某个数值的最合适的格式,就应该使用toPrecision() 方法。

对于一个数值来说,toPrecision() 方法可能会返回固定大小(fixed)格式,也可能返回指数(exponential)格式;具体规则是看哪种格式最合适。这个方法接收一个参数,即表示数值的所有数字的位数(不包括指数部分)。请看下面的例子。

var num = 99;
alert(num.toPrecision(1));     //"1e+2"


alert(num.toPrecision(2));     //"99"


alert(num.toPrecision(3));     //"99.0"


NumberTypeExample01.htm

以上代码首先完成的任务是以一位数来表示99,结果是"1e+2" ,即100。因为一位数无法准确地表示99,因此toPrecision() 就将它向上舍入为100,这样就可以使用一位数来表示它了。而接下来的用两位数表示99,当然还是"99" 。最后,在想以三位数表示99时,toPrecision() 方法返回了"99.0" 。实际上,toPrecision() 会根据要处理的数值决定到底是调用toFixed() 还是调用toExponential() 。而这三个方法都可以通过向上或向下舍入,做到以最准确的形式来表示带有正确小数位的值。

toPrecision() 方法可以表现1到21位小数。某些浏览器支持的范围更大,但这是典型实现的范围。

Boolean 对象类似,Number 对象也以后台方式为数值提供了重要的功能。但与此同时,我们仍然不建议直接实例化Number 类型,而原因与显式创建Boolean 对象一样。具体来讲,就是在使用typeofinstanceof 操作符测试基本类型数值与引用类型数值时,得到的结果完全不同,如下面的例子所示。

var numberObject = new Number(10);
var numberValue = 10;
alert(typeof numberObject);   //"object"
alert(typeof numberValue);    //"number"
alert(numberObject instanceof Number);  //true
alert(numberValue instanceof Number);   //false


在使用typeof 操作符测试基本类型数值时,始终会返回"number" ,而在测试Number 对象时,则会返回"object" 。类似地,Number 对象是Number 类型的实例,而基本类型的数值则不是。

5.6.3 String 类型

String 类型是字符串的对象包装类型,可以像下面这样使用String 构造函数来创建。

var stringObject = new String("hello world");

StringTypeExample01.htm

String 对象的方法也可以在所有基本的字符串值中访问到。其中,继承的valueOf()toLocaleString()toString() 方法,都返回对象所表示的基本字符串值。

String 类型的每个实例都有一个length 属性,表示字符串中包含多个字符。来看下面的例子。

var stringValue = "hello world";
alert(stringValue.length);     //"11"


这个例子输出了字符串"hello world" 中的字符数量,即"11" 。应该注意的是,即使字符串中包含双字节字符(不是占一个字节的ASCII字符),每个字符也仍然算一个字符。

String 类型提供了很多方法,用于辅助完成对ECMAScript中字符串的解析和操作。

1. 字符方法

两个用于访问字符串中特定字符的方法是:charAt()charCodeAt() 。这两个方法都接收一个参数,即基于0的字符位置。其中,charAt() 方法以单字符字符串的形式返回给定位置的那个字符(ECMAScript中没有字符类型)。例如:

var stringValue = "hello world";
alert(stringValue.charAt(1));   //"e"


字符串"hello world" 位置1处的字符是"e" ,因此调用charAt(1) 就返回了"e" 。如果你想得到的不是字符而是字符编码,那么就要像下面这样使用charCodeAt() 了。

var stringValue = "hello world";
alert(stringValue.charCodeAt(1));   //输出"101"


这个例子输出的是"101" ,也就是小写字母"e" 的字符编码。

ECMAScript 5还定义了另一个访问个别字符的方法。在支持浏览器中,可以使用方括号加数字索引来访问字符串中的特定字符,如下面的例子所示。

var stringValue = "hello world";
alert(stringValue[1]);   //"e"


使用方括号表示法访问个别字符的语法得到了IE8及Firefox、Safari、Chrome和Opera所有版本的支持。如果是在IE7及更早版本中使用这种语法,会返回undefined 值(尽管根本不是特殊的undefined 值)。

2. 字符串操作方法

下面介绍与操作字符串有关的几个方法。第一个就是concat() ,用于将一或多个字符串拼接起来,返回拼接得到的新字符串。先来看一个例子。

var stringValue = "hello ";
var result = stringValue.concat("world");
alert(result);             //"hello world"
alert(stringValue);        //"hello"


在这个例子中,通过stringValue 调用concat() 方法返回的结果是"hello world" ——但stringValue 的值则保持不变。实际上,concat() 方法可以接受任意多个参数,也就是说可以通过它拼接任意多个字符串。再看一个例子:

var stringValue = "hello ";
var result = stringValue.concat("world", "!");



alert(result);             //"hello world!"
alert(stringValue);        //"hello"

这个例子将"world""!" 拼接到了"hello" 的末尾。虽然concat() 是专门用来拼接字符串的方法,但实践中使用更多的还是加号操作符(+)。而且,使用加号操作符在大多数情况下都比使用concat() 方法要简便易行(特别是在拼接多个字符串的情况下)。

ECMAScript还提供了三个基于子字符串创建新字符串的方法:slice()substr()substring() 。这三个方法都会返回被操作字符串的一个子字符串,而且也都接受一或两个参数。第一个参数指定子字符串的开始位置,第二个参数(在指定的情况下)表示子字符串到哪里结束。具体来说,slice()substring() 的第二个参数指定的是子字符串最后一个字符后面的位置。而substr() 的第二个参数指定的则是返回的字符个数。如果没有给这些方法传递第二个参数,则将字符串的长度作为结束位置。与concat() 方法一样,slice()substr()substring() 也不会修改字符串本身的值——它们只是返回一个基本类型的字符串值,对原始字符串没有任何影响。请看下面的例子。

var stringValue = "hello world";
alert(stringValue.slice(3));            //"lo world"


alert(stringValue.substring(3));        //"lo world"


alert(stringValue.substr(3));           //"lo world"


alert(stringValue.slice(3, 7));         //"lo w"


alert(stringValue.substring(3,7));      //"lo w"


alert(stringValue.substr(3, 7));        //"lo worl"


StringTypeManipulationMethodsExample01.htm

这个例子比较了以相同方式调用slice()substr()substring() 得到的结果,而且多数情况下的结果是相同的。在只指定一个参数3的情况下,这三个方法都返回"lo world" ,因为"hello" 中的第二个"l" 处于位置3。而在指定两个参数3和7的情况下,slice()substring() 返回"lo w""world" 中的"o" 处于位置7,因此结果中不包含"o" ),但substr() 返回"lo worl" ,因为它的第二个参数指定的是要返回的字符个数。

在传递给这些方法的参数是负值的情况下,它们的行为就不尽相同了。其中,slice() 方法会将传入的负值与字符串的长度相加,substr() 方法将负的第一个参数加上字符串的长度,而将负的第二个参数转换为0。最后,substring() 方法会把所有负值参数都转换为0。下面来看例子。

var stringValue = "hello world";
alert(stringValue.slice(-3));           //"rld"


alert(stringValue.substring(-3));       //"hello world"


alert(stringValue.substr(-3));          //"rld"


alert(stringValue.slice(3, -4));        //"lo w"


alert(stringValue.substring(3, -4));    //"hel"


alert(stringValue.substr(3, -4));       //""(空字符串)


StringTypeManipulationMethodsExample01.htm

这个例子清晰地展示了上述三个方法之间的不同行为。在给slice()substr() 传递一个负值参数时,它们的行为相同。这是因为-3 会被转换为8 (字符串长度加参数11+(-3)=8),实际上相当于调用了slice(8)substr(8) 。但substring() 方法则返回了全部字符串,因为它将-3 转换成了0。

IE的JavaScript实现在处理向substr() 方法传递负值的情况时存在问题,它会返回原始的字符串。IE9修复了这个问题。

当第二个参数是负值时,这三个方法的行为各不相同。slice() 方法会把第二个参数转换为7,这就相当于调用了slice(3,7) ,因此返回"lo w"substring() 方法会把第二个参数转换为0,使调用变成了substring(3,0) ,而由于这个方法会将较小的数作为开始位置,将较大的数作为结束位置,因此最终相当于调用了substring(0,3)substr() 也会将第二个参数转换为0,这也就意味着返回包含零个字符的字符串,也就是一个空字符串。

3. 字符串位置方法

有两个可以从字符串中查找子字符串的方法:indexOf()lastIndexOf() 。这两个方法都是从一个字符串中搜索给定的子字符串,然后返子字符串的位置(如果没有找到该子字符串,则返回-1 )。这两个方法的区别在于:indexOf() 方法从字符串的开头向后搜索子字符串,而lastIndexOf() 方法是从字符串的末尾向前搜索子字符串。还是来看一个例子吧。

var stringValue = "hello world";
alert(stringValue.indexOf("o"));             //4


alert(stringValue.lastIndexOf("o"));         //7


StringTypeLocationMethodsExample01.htm

子字符串"o" 第一次出现的位置是4,即"hello" 中的"o" ;最后一次出现的位置是7,即"world" 中的"o" 。如果"o" 在这个字符串中仅出现了一次,那么indexOf()lastIndexOf() 会返回相同的位置值。

这两个方法都可以接收可选的第二个参数,表示从字符串中的哪个位置开始搜索。换句话说,indexOf() 会从该参数指定的位置向后搜索,忽略该位置之前的所有字符;而lastIndexOf() 则会从指定的位置向前搜索,忽略该位置之后的所有字符。看下面的例子。

var stringValue = "hello world";
alert(stringValue.indexOf("o", 6));          //7


alert(stringValue.lastIndexOf("o", 6));      //4


在将第二个参数6传递给这两个方法之后,得到了与前面例子相反的结果。这一次,由于indexOf() 是从位置6(字母"w" )开始向后搜索,结果在位置7找到了"o" ,因此它返回7。而lastIndexOf() 是从位置6开始向前搜索。结果找到了"hello" 中的"o" ,因此它返回4。在使用第二个参数的情况下,可以通过循环调用indexOf()lastIndexOf() 来找到所有匹配的子字符串,如下面的例子所示:

var stringValue = "Lorem ipsum dolor sit amet, consectetur adipisicing elit";
var positions = new Array();
var pos = stringValue.indexOf("e");

while(pos > -1){
    positions.push(pos);
    pos = stringValue.indexOf("e", pos + 1);
}

alert(positions);    //"3,24,32,35,52"


StringTypeLocationMethodsExample02.htm

这个例子通过不断增加indexOf() 方法开始查找的位置,遍历了一个长字符串。在循环之外,首先找到了"e" 在字符串中的初始位置;而进入循环后,则每次都给indexOf() 传递上一次的位置加1。这样,就确保了每次新搜索都从上一次找到的子字符串的后面开始。每次搜索返回的位置依次被保存在数组positions 中,以便将来使用。

4. trim() 方法

ECMAScript 5为所有字符串定义了trim() 方法。这个方法会创建一个字符串的副本,删除前置及后缀的所有空格,然后返回结果。例如:

var stringValue = "   hello world   ";
var trimmedStringValue = stringValue.trim();
alert(stringValue);            //"   hello world   "
alert(trimmedStringValue);     //"hello world" 


由于trim() 返回的是字符串的副本,所以原始字符串中的前置及后缀空格会保持不变。支持这个方法的浏览器有IE9+、Firefox 3.5+、Safari 5+、Opera 10.5+和Chrome。此外,Firefox 3.5+、Safari 5+和Chrome 8+还支持非标准的trimLeft()trimRight() 方法,分别用于删除字符串开头和末尾的空格。

5. 字符串大小写转换方法

接下来我们要介绍的是一组与大小写转换有关的方法。ECMAScript中涉及字符串大小写转换的方法有4个:toLowerCase()toLocaleLowerCase()toUpperCase()toLocaleUpperCase() 。其中,toLowerCase()toUpperCase() 是两个经典的方法,借鉴自java.lang.String 中的同名方法。而toLocaleLowerCase()toLocaleUpperCase() 方法则是针对特定地区的实现。对有些地区来说,针对地区的方法与其通用方法得到的结果相同,但少数语言(如土耳其语)会为Unicode大小写转换应用特殊的规则,这时候就必须使用针对地区的方法来保证实现正确的转换。以下是几个例子。

var stringValue = "hello world";
alert(stringValue.toLocaleUpperCase());  //"HELLO WORLD"


alert(stringValue.toUpperCase());        //"HELLO WORLD"


alert(stringValue.toLocaleLowerCase());  //"hello world"


alert(stringValue.toLowerCase());        //"hello world"


StringTypeCaseMethodExample01.htm

以上代码调用的toLocaleUpperCase()toUpperCase() 都返回了"HELLO WORLD" ,就像调用toLocaleLowerCase()toLowerCase() 都返回"hello world" 一样。一般来说,在不知道自己的代码将在哪种语言环境中运行的情况下,还是使用针对地区的方法更稳妥一些。

6. 字符串的模式匹配方法

String 类型定义了几个用于在字符串中匹配模式的方法。第一个方法就是match() ,在字符串上调用这个方法,本质上与调用RegExpexec() 方法相同。match() 方法只接受一个参数,要么是一个正则表达式,要么是一个RegExp 对象。来看下面的例子。

var text = "cat, bat, sat, fat"; 
var pattern = /.at/;

//与pattern.exec(text)相同
var matches = text.match(pattern);
alert(matches.index);               //0
alert(matches[0]);                  //"cat"
alert(pattern.lastIndex);           //0


StringTypePatternMatchingExample01.htm

本例中的match() 方法返回了一个数组;如果是调用RegExp 对象的exec() 方法并传递本例中的字符串作为参数,那么也会得到与此相同的数组:数组的第一项是与整个模式匹配的字符串,之后的每一项(如果有)保存着与正则表达式中的捕获组匹配的字符串。

另一个用于查找模式的方法是search() 。这个方法的唯一参数与match() 方法的参数相同:由字符串或RegExp 对象指定的一个正则表达式。search() 方法返回字符串中第一个匹配项的索引;如果没有找到匹配项,则返回-1 。而且,search() 方法始终是从字符串开头向后查找模式。看下面的例子。

var text = "cat, bat, sat, fat"; 
var pos = text.search(/at/);


alert(pos);   //1


StringTypePatternMatchingExample01.htm

这个例子中的search() 方法返回1 ,即"at" 在字符串中第一次出现的位置。

为了简化替换子字符串的操作,ECMAScript提供了replace() 方法。这个方法接受两个参数:第一个参数可以是一个RegExp 对象或者一个字符串(这个字符串不会被转换成正则表达式),第二个参数可以是一个字符串或者一个函数。如果第一个参数是字符串,那么只会替换第一个子字符串。要想替换所有子字符串,唯一的办法就是提供一个正则表达式,而且要指定全局(g )标志,如下所示。

var text = "cat, bat, sat, fat"; 
var result = text.replace("at", "ond");


alert(result);    //"cond, bat, sat, fat"



result = text.replace(/at/g, "ond");


alert(result);    //"cond, bond, sond, fond"


StringTypePatternMatchingExample01.htm

在这个例子中,首先传入replace() 方法的是字符串"at" 和替换用的字符串"ond" 。替换的结果是把"cat" 变成了"cond" ,但字符串中的其他字符并没有受到影响。然后,通过将第一个参数修改为带有全局标志的正则表达式,就将全部"at" 都替换成了"ond"

如果第二个参数是字符串,那么还可以使用一些特殊的字符序列,将正则表达式操作得到的值插入到结果字符串中。下表列出了ECMAScript提供的这些特殊的字符序列。

字符序列 替换文本
$$ $
$& 匹配整个模式的子字符串。与RegExp.lastMatch 的值相同
$' 匹配的子字符串之前的子字符串。与RegExp.leftContext 的值相同
$` 匹配的子字符串之后的子字符串。与RegExp.rightContext 的值相同
$n 匹配第n个捕获组的子字符串,其中n等于0~9。例如,$1 是匹配第一个捕获组的子字符串,$2 是匹配第二个捕获组的子字符串,以此类推。如果正则表达式中没有定义捕获组,则使用空字符串
$nn 匹配第nn个捕获组的子字符串,其中nn等于01~99。例如,$01 是匹配第一个捕获组的子字符串,$02 是匹配第二个捕获组的子字符串,以此类推。如果正则表达式中没有定义捕获组,则使用空字符串

通过这些特殊的字符序列,可以使用最近一次匹配结果中的内容,如下面的例子所示。

var text = "cat, bat, sat, fat"; 
result = text.replace(/(.at)/g, "word ($1)");


alert(result);    //word (cat), word (bat), word (sat), word (fat)


StringTypePatternMatchingExample01.htm

在此,每个以"at" 结尾的单词都被替换了,替换结果是"word" 后跟一对圆括号,而圆括号中是被字符序列$1 所替换的单词。

replace() 方法的第二个参数也可以是一个函数。在只有一个匹配项(即与模式匹配的字符串)的情况下,会向这个函数传递3个参数:模式的匹配项、模式匹配项在字符串中的位置和原始字符串。在正则表达式中定义了多个捕获组的情况下,传递给函数的参数依次是模式的匹配项、第一个捕获组的匹配项、第二个捕获组的匹配项……,但最后两个参数仍然分别是模式的匹配项在字符串中的位置和原始字符串。这个函数应该返回一个字符串,表示应该被替换的匹配项使用函数作为replace() 方法的第二个参数可以实现更加精细的替换操作,请看下面这个例子。

function htmlEscape(text){
    return text.replace(/[<>"&]/g, function(match, pos, originalText){
        switch(match){
            case "<":
                return "&lt;";
            case ">":
                return "&gt;";
            case "&":
                return "&amp;";
            case "\"":
                return "&quot;";
        }             
    });
}

alert(htmlEscape("<p class=\"greeting\">Hello world!</p>")); 
//&lt;p class=&quot;greeting&quot;&gt;Hello world!&lt;/p&gt;


StringTypePatternMatchingExample01.htm

这里,我们为插入HTML代码定义了函数htmlEscape() ,这个函数能够转义4个字符:小于号、大于号、和号以及双引号。实现这种转义的最简单方式,就是使用正则表达式查找这几个字符,然后定义一个能够针对每个匹配的字符返回特定HTML实体的函数。

最后一个与模式匹配有关的方法是split() ,这个方法可以基于指定的分隔符将一个字符串分割成多个子字符串,并将结果放在一个数组中。分隔符可以是字符串,也可以是一个RegExp 对象(这个方法不会将字符串看成正则表达式)。split() 方法可以接受可选的第二个参数,用于指定数组的大小,以便确保返回的数组不会超过既定大小。请看下面的例子。

var colorText = "red,blue,green,yellow";
var colors1 = colorText.split(",");          //["red", "blue", "green", "yellow"]
var colors2 = colorText.split(",", 2);       //["red", "blue"]
var colors3 = colorText.split(/[^\,]+/);     //["", ",", ",", ",", ""]


StringTypePatternMatchingExample01.htm

在这个例子中,colorText 是逗号分隔的颜色名字符串。基于该字符串调用split(",") 会得到一个包含其中颜色名的数组,用于分割字符串的分隔符是逗号。为了将数组截短,让它只包含两项,可以为split() 方法传递第二个参数2。最后,通过使用正则表达式,还可以取得包含逗号字符的数组。需要注意的是,在最后一次调用split() 返回的数组中,第一项和最后一项是两个空字符串。之所以会这样,是因为通过正则表达式指定的分隔符出现在了字符串的开头(即子字符串"red" )和末尾(即子字符串"yellow" )。

split() 中正则表达式的支持因浏览器而异。尽管对于简单的模式没有什么差别,但对于未发现匹配项以及带有捕获组的模式,匹配的行为就不大相同了。以下是几种常见的差别。

在正则表达式中使用捕获组时还有其他微妙的差别。在使用这种正则表达式时,一定要在各种浏览器下多做一些测试。

要了解关于split() 方法以及捕获组的跨浏览器问题的更多讨论,请参考Steven Levithan的文章“JavaScript split bugs:Fixed!”(http://blog.stevenlevithan.com/archives/cross-browser-split )。

7. localeCompare() 方法

与操作字符串有关的最后一个方法是localeCompare() ,这个方法比较两个字符串,并返回下列值中的一个:

下面是几个例子。

var stringValue = "yellow";       
alert(stringValue.localeCompare("brick"));         //1
alert(stringValue.localeCompare("yellow"));        //0
alert(stringValue.localeCompare("zoo"));           //-1


StringTypeLocaleCompareExample01.htm

这个例子比较了字符串"yellow" 和另外几个值:"brick""yellow""zoo" 。因为"brick" 在字母表中排在"yellow" 之前,所以localeCompare() 返回了1 ;而"yellow" 等于"yellow" ,所以localeCompare() 返回了0 ;最后,"zoo" 在字母表中排在"yellow" 后面,所以localeCompare() 返回了-1 。再强调一次,因为localeCompare() 返回的数值取决于实现,所以最好是像下面例子所示的这样使用这个方法。

function determineOrder(value) {
    var result = stringValue.localeCompare(value);
    if (result < 0){
        alert("The string 'yellow' comes before the string '" + value + "'.");
    } else if (result > 0) {
        alert("The string 'yellow' comes after the string '" + value + "'.");
    } else {
        alert("The string 'yellow' is equal to the string '" + value + "'.");
    }
}

determineOrder("brick");
determineOrder("yellow");
determineOrder("zoo");


StringTypeLocaleCompareExample01.htm

使用这种结构,就可以确保自己的代码在任何实现中都可以正确地运行了。

localeCompare() 方法比较与众不同的地方,就是实现所支持的地区(国家和语言)决定了这个方法的行为。比如,美国以英语作为ECMAScript实现的标准语言,因此localeCompare() 就是区分大小写的,于是大写字母在字母表中排在小写字母前头就成为了一项决定性的比较规则。不过,在其他地区恐怕就不是这种情况了。

8. fromCharCode() 方法

另外,String 构造函数本身还有一个静态方法:fromCharCode() 。这个方法的任务是接收一或多个字符编码,然后将它们转换成一个字符串。从本质上来看,这个方法与实例方法charCodeAt() 执行的是相反的操作。来看一个例子:

alert(String.fromCharCode(104, 101, 108, 108, 111)); //"hello"


StringTypeFromCharCodeExample01.htm

在这里,我们给fromCharCode() 传递的是字符串"hello" 中每个字母的字符编码。

9. HTML方法

早期的Web浏览器提供商觉察到了使用JavaScript动态格式化HTML的需求。于是,这些提供商就扩展了标准,实现了一些专门用于简化常见HTML格式化任务的方法。下表列出了这些HTML方法。不过,需要请读者注意的是,应该尽量不使用这些方法,因为它们创建的标记通常无法表达语义。

方  法 输出结果
anchor(name ) <a name= "name ">string </a>
big() <big>string </big>
bold() <b>string </b>
fixed() <tt>string </tt>
fontcolor(color ) <font color="color ">string </font>
fontsize(size ) <font size="size ">string </font>
italics() <i>string </i>
link(url) <a href="url ">string </a>
small() <small>string </small>
strike() <strike>string </strike>
sub() <sub>string </sub>
sup() <sup>string </sup>

ECMA-262对内置对象的定义是:“由ECMAScript实现提供的、不依赖于宿主环境的对象,这些对象在ECMAScript程序执行之前就已经存在了。”意思就是说,开发人员不必显式地实例化内置对象,因为它们已经实例化了。前面我们已经介绍了大多数内置对象,例如ObjectArrayString 。ECMA-262还定义了两个单体内置对象:GlobalMath

5.7.1 Global 对象

Global (全局)对象可以说是ECMAScript中最特别的一个对象了,因为不管你从什么角度上看,这个对象都是不存在的。ECMAScript中的Global 对象在某种意义上是作为一个终极的“兜底儿对象”来定义的。换句话说,不属于任何其他对象的属性和方法,最终都是它的属性和方法。事实上,没有全局变量或全局函数;所有在全局作用域中定义的属性和函数,都是Global 对象的属性。本书前面介绍过的那些函数,诸如isNaN()isFinite()parseInt() 以及parseFloat() ,实际上全都是Global 对象的方法。除此之外,Global 对象还包含其他一些方法。

1. URI编码方法

Global 对象的encodeURI()encodeURIComponent() 方法可以对URI(Uniform Resource Identifiers,通用资源标识符)进行编码,以便发送给浏览器。有效的URI中不能包含某些字符,例如空格。而这两个URI编码方法就可以对URI进行编码,它们用特殊的UTF-8编码替换所有无效的字符,从而让浏览器能够接受和理解。

其中,encodeURI() 主要用于整个URI(例如,http://www.wrox.com/illegalvalue.htm ),而encodeURIComponent() 主要用于对URI中的某一段(例如前面URI中的illegal value.htm )进行编码。它们的主要区别在于,encodeURI() 不会对本身属于URI的特殊字符进行编码,例如冒号、正斜杠、问号和井字号;而encodeURIComponent() 则会对它发现的任何非标准字符进行编码。来看下面的例子。

var uri = "http://www.wrox.com/illegal value.htm#start";

//"http://www.wrox.com/illegal%20value.htm#start"
alert(encodeURI(uri));

//"http%3A%2F%2Fwww.wrox.com%2Fillegal%20value.htm%23start"
alert(encodeURIComponent(uri));


GlobalObjectURIEncodingExample01.htm

使用encodeURI() 编码后的结果是除了空格之外的其他字符都原封不动,只有空格被替换成了%20 。而encodeURIComponent() 方法则会使用对应的编码替换所有非字母数字字符。这也正是可以对整个URI使用encodeURI() ,而只能对附加在现有URI后面的字符串使用encodeURIComponent() 的原因所在。

一般来说,我们使用encodeURIComponent() 方法的时候要比使用encodeURI() 更多,因为在实践中更常见的是对查询字符串参数而不是对基础URI进行编码。

encodeURI()encodeURIComponent() 方法对应的两个方法分别是decodeURI()decodeURIComponent() 。其中,decodeURI() 只能对使用encodeURI() 替换的字符进行解码。例如,它可将%20 替换成一个空格,但不会对%23 作任何处理,因为%23 表示井字号(# ),而井字号不是使用encodeURI() 替换的。同样地,decodeURIComponent() 能够解码使用encodeURIComponent() 编码的所有字符,即它可以解码任何特殊字符的编码。来看下面的例子:

var uri = "http%3A%2F%2Fwww.wrox.com%2Fillegal%20value.htm%23start";

//http%3A%2F%2Fwww.wrox.com%2Fillegal value.htm%23start
alert(decodeURI(uri));

//http://www.wrox.com/illegal value.htm#start
alert(decodeURIComponent(uri));


GlobalObjectURIDecodingExample01.htm

这里,变量uri 包含着一个由encodeURIComponent() 编码的字符串。在第一次调用decodeURI() 输出的结果中,只有%20 被替换成了空格。而在第二次调用decodeURIComponent() 输出的结果中,所有特殊字符的编码都被替换成了原来的字符,得到了一个未经转义的字符串(但这个字符串并不是一个有效的URI)。

URI方法encodeURI()encodeURIComponent()decodeURI()decodeURIComponent() 用于替代已经被ECMA-262第3版废弃的escape()unescape() 方法。URI方法能够编码所有Unicode字符,而原来的方法只能正确地编码ASCII字符。因此在开发实践中,特别是在产品级的代码中,一定要使用URI方法,不要使用escape()unescape() 方法。

2. eval() 方法

现在,我们介绍最后一个——大概也是整个ECMAScript语言中最强大的一个方法:eval()eval() 方法就像是一个完整的ECMAScript解析器,它只接受一个参数,即要执行的ECMAScript(或JavaScript)字符串。看下面的例子:

eval("alert('hi')");


这行代码的作用等价于下面这行代码:

alert("hi");


当解析器发现代码中调用eval() 方法时,它会将传入的参数当作实际的ECMAScript语句来解析,然后把执行结果插入到原位置。通过eval() 执行的代码被认为是包含该次调用的执行环境的一部分,因此被执行的代码具有与该执行环境相同的作用域链。这意味着通过eval() 执行的代码可以引用在包含环境中定义的变量,举个例子:

var msg = "hello world";
eval("alert(msg)");    //"hello world"


可见,变量msg 是在eval() 调用的环境之外定义的,但其中调用的alert() 仍然能够显示"hello world" 。这是因为上面第二行代码最终被替换成了一行真正的代码。同样地,我们也可以在eval() 调用中定义一个函数,然后再在该调用的外部代码中引用这个函数:

eval("function sayHi() { alert('hi'); }");
sayHi();


显然,函数sayHi() 是在eval() 内部定义的。但由于对eval() 的调用最终会被替换成定义函数的实际代码,因此可以在下一行调用sayHi() 。对于变量也一样:

eval("var msg = 'hello world'; ");
alert(msg);     //"hello world"


eval() 中创建的任何变量或函数都不会被提升,因为在解析代码的时候,它们被包含在一个字符串中;它们只在eval() 执行的时候创建。

严格模式下,在外部访问不到eval() 中创建的任何变量或函数,因此前面两个例子都会导致错误。同样,在严格模式下,为eval 赋值也会导致错误:

"use strict";
eval = "hi";   //causes error


能够解释代码字符串的能力非常强大,但也非常危险。因此在使用eval() 时必须极为谨慎,特别是在用它执行用户输入数据的情况下。否则,可能会有恶意用户输入威胁你的站点或应用程序安全的代码(即所谓的代码注入)。

3. Global 对象的属性

Global 对象还包含一些属性,其中一部分属性已经在本书前面介绍过了。例如,特殊的值undefinedNaN 以及Infinity 都是Global 对象的属性。此外,所有原生引用类型的构造函数,像ObjectFunction ,也都是Global 对象的属性。下表列出了Global 对象的所有属性。

属  性 说  明 属  性 说  明
undefined 特殊值undefined Date 构造函数Date
NaN 特殊值NaN RegExp 构造函数RegExp
Infinity 特殊值Infinity Error 构造函数Error
Object 构造函数Object EvalError 构造函数EvalError
Array 构造函数Array RangeError 构造函数RangeError
Function 构造函数Function ReferenceError 构造函数ReferenceError
Boolean 构造函数Boolean SyntaxError 构造函数SyntaxError
String 构造函数String TypeError 构造函数TypeError
Number 构造函数Number URIError 构造函数URIError

ECMAScript 5明确禁止给undefinedNaNInfinity 赋值,这样做即使在非严格模式下也会导致错误。

4. window 对象

ECMAScript虽然没有指出如何直接访问Global 对象,但Web浏览器都是将这个全局对象作为window 对象的一部分加以实现的。因此,在全局作用域中声明的所有变量和函数,就都成为了window 对象的属性。来看下面的例子。

var color = "red";

function sayColor(){
    alert(window.color);
}

window.sayColor();  //"red"


GlobalObjectWindowExample01.htm

这里定义了一个名为color 的全局变量和一个名为sayColor() 的全局函数。在sayColor() 内部,我们通过window.color 来访问color 变量,以说明全局变量是window 对象的属性。然后,又使用window.sayColor() 来直接通过window 对象调用这个函数,结果显示在了警告框中。

JavaScript中的window 对象除了扮演ECMAScript规定的Global 对象的角色外,还承担了很多别的任务。第8章在讨论浏览器对象模型时将详细介绍window 对象。

另一种取得Global 对象的方法是使用以下代码:

var global = function(){
    return this; 
}();


以上代码创建了一个立即调用的函数表达式,返回this 的值。如前所述,在没有给函数明确指定this 值的情况下(无论是通过将函数添加为对象的方法,还是通过调用call()apply() ),this 值等于Global 对象。而像这样通过简单地返回this 来取得Global 对象,在任何执行环境下都是可行的。第7章将深入讨论函数表达式。

5.7.2 Math 对象

ECMAScript还为保存数学公式和信息提供了一个公共位置,即Math 对象。与我们在JavaScript直接编写的计算功能相比,Math 对象提供的计算功能执行起来要快得多。Math 对象中还提供了辅助完成这些计算的属性和方法。

1. Math 对象的属性

Math 对象包含的属性大都是数学计算中可能会用到的一些特殊值。下表列出了这些属性。

属  性 说  明
Math.E 自然对数的底数,即常量e 的值
Math.LN10 10的自然对数
Math.LN2 2的自然对数
Math.LOG2E 以2为底e 的对数
Math.LOG10E 以10为底e 的对数
Math.PI π的值
Math.SQRT1_2 1/2的平方根(即2的平方根的倒数)
Math.SQRT2 2的平方根

虽然讨论这些值的含义和用途超出了本书范围,但你确实可以随时使用它们。

2. min()max() 方法

Math 对象还包含许多方法,用于辅助完成简单和复杂的数学计算。

其中,min()max() 方法用于确定一组数值中的最小值和最大值。这两个方法都可以接收任意多个数值参数,如下面的例子所示。

var max = Math.max(3, 54, 32, 16);
alert(max);    //54

var min = Math.min(3, 54, 32, 16);
alert(min);    //3


MathObjectMinMaxExample01.htm

对于3、54、32和16,Math.max() 返回54,而Math.min() 返回3。这两个方法经常用于避免多余的循环和在if 语句中确定一组数的最大值。

要找到数组中的最大或最小值,可以像下面这样使用apply() 方法。

var values = [1, 2, 3, 4, 5, 6, 7, 8];
var max = Math.max.apply(Math, values);


这个技巧的关键是把Math 对象作为apply() 的第一个参数,从而正确地设置this 值。然后,可以将任何数组作为第二个参数。

3. 舍入方法

下面来介绍将小数值舍入为整数的几个方法:Math.ceil()Math.floor()Math.round() 。这三个方法分别遵循下列舍入规则:

下面是使用这些方法的示例:

alert(Math.ceil(25.9));     //26
alert(Math.ceil(25.5));     //26
alert(Math.ceil(25.1));     //26

alert(Math.round(25.9));    //26
alert(Math.round(25.5));    //26
alert(Math.round(25.1));    //25

alert(Math.floor(25.9));    //25
alert(Math.floor(25.5));    //25
alert(Math.floor(25.1));    //25


MathObjectRoundingExample01.htm

对于所有介于25和26(不包括26)之间的数值,Math.ceil() 始终返回26,因为它执行的是向上舍入。Math.round() 方法只在数值大于等于25.5时返回26;否则返回25。最后,Math.floor() 对所有介于25和26(不包括26)之间的数值都返回25。

4. random() 方法

Math.random() 方法返回介于0和1之间一个随机数,不包括0和1。对于某些站点来说,这个方法非常实用,因为可以利用它来随机显示一些名人名言和新闻事件。套用下面的公式,就可以利用Math.random() 从某个整数范围内随机选择一个值。

值 = Math.floor(Math.random() * 可能值的总数 + 第一个可能的值)


公式中用到了Math.floor() 方法,这是因为Math.random() 总返回一个小数值。而用这个小数值乘以一个整数,然后再加上一个整数,最终结果仍然还是一个小数。举例来说,如果你想选择一个1到10之间的数值,可以像下面这样编写代码:

var num = Math.floor(Math.random() * 10 + 1);


MathObjectRandomExample01.htm

总共有10个可能的值(1到10),而第一个可能的值是1。而如果想要选择一个介于2到10之间的值,就应该将上面的代码改成这样:

var num = Math.floor(Math.random() * 9 + 2);


MathObjectRandomExample02.htm

从2数到10要数9个数,因此可能值的总数就是9,而第一个可能的值就是2。多数情况下,其实都可以通过一个函数来计算可能值的总数和第一个可能的值,例如:

function selectFrom(lowerValue, upperValue) {
    var choices = upperValue - lowerValue + 1;
    return Math.floor(Math.random() * choices + lowerValue);
}

var num = selectFrom(2, 10);
alert(num);   // 介于2和10之间(包括2和10)的一个数值


MathObjectRandomExample03.htm

函数selectFrom() 接受两个参数:应该返回的最小值和最大值。而用最大值减最小值再加1得到了可能值的总数,然后它又把这些数值套用到了前面的公式中。这样,通过调用selectFrom(2,10) 就可以得到一个介于2和10之间(包括2和10)的数值了。利用这个函数,可以方便地从数组中随机取出一项,例如:

var colors = ["red", "green", "blue", "yellow", "black", "purple", "brown"];
var color = colors[selectFrom(0, colors.length-1)];
alert(color);  // 可能是数组中包含的任何一个字符串


MathObjectRandomExample03.htm

在这个例子中,传递给selectFrom() 的第二个参数是数组的长度减1,也就是数组中最后一项的位置。

5. 其他方法

Math 对象中还包含其他一些与完成各种简单或复杂计算有关的方法,但详细讨论其中每一个方法的细节及适用情形超出了本书的范围。下面我们就给出一个表格,其中列出了这些没有介绍到的Math 对象的方法。

方  法 说  明 方  法 说  明
Math.abs(num ) 返回 num 的绝对值 Math.asin(x ) 返回 x 的反正弦值
Math.exp(num ) 返回 Math.E num 次幂 Math.atan(x ) 返回 x 的反正切值
Math.log(num ) 返回 num 的自然对数 Math.atan2(y,x ) 返回 y/x 的反正切值
Math.pow(num,power ) 返回 num power 次幂 Math.cos(x ) 返回 x 的余弦值
Math.sqrt(num ) 返回 num 的平方根 Math.sin(x ) 返回 x 的正弦值
Math.acos(x ) 返回 x 的反余弦值 Math.tan(x ) 返回 x 的正切值

虽然ECMA-262规定了这些方法,但不同实现可能会对这些方法采用不同的算法。毕竟,计算某个值的正弦、余弦和正切的方式多种多样。也正因为如此,这些方法在不同的实现中可能会有不同的精度。

对象在JavaScript中被称为引用类型的值,而且有一些内置的引用类型可以用来创建特定的对象,现简要总结如下:

函数实际上是Function 类型的实例,因此函数也是对象;而这一点正是JavaScript最有特色的地方。由于函数是对象,所以函数也拥有方法,可以用来增强其行为。

因为有了基本包装类型,所以JavaScript中的基本类型值可以被当作对象来访问。三种基本包装类型分别是:BooleanNumberString 。以下是它们共同的特征:

在所有代码执行之前,作用域中就已经存在两个内置对象:GlobalMath 。在大多数ECMAScript实现中都不能直接访问Global 对象;不过,Web浏览器实现了承担该角色的window 对象。全局变量和函数都是Global 对象的属性。Math 对象提供了很多属性和方法,用于辅助完成复杂的数学计算任务。


第6章 面向对象的程序设计

本章内容

面向对象(Object-Oriented,OO)的语言有一个标志,那就是它们都有类的概念,而通过类可以创建任意多个具有相同属性和方法的对象。前面提到过,ECMAScript中没有类的概念,因此它的对象也与基于类的语言中的对象有所不同。

ECMA-262把对象定义为:“无序属性的集合,其属性可以包含基本值、对象或者函数。”严格来讲,这就相当于说对象是一组没有特定顺序的值。对象的每个属性或方法都有一个名字,而每个名字都映射到一个值。正因为这样(以及其他将要讨论的原因),我们可以把ECMAScript的对象想象成散列表:无非就是一组名值对,其中值可以是数据或函数。

每个对象都是基于一个引用类型创建的,这个引用类型可以是第5章讨论的原生类型,也可以是开发人员定义的类型。

上一章曾经介绍过,创建自定义对象的最简单方式就是创建一个Object 的实例,然后再为它添加属性和方法,如下所示。

var person = new Object();
person.name = "Nicholas";
person.age = 29; 
person.job = "Software Engineer";

person.sayName = function(){
    alert(this.name); 
};


CreatingObjectsExample01.htm

上面的例子创建了一个名为person 的对象,并为它添加了三个属性(nameagejob )和一个方法(sayName() )。其中,sayName() 方法用于显示this.name (将被解析为person.name )的值。早期的JavaScript开发人员经常使用这个模式创建新对象。几年后,对象字面量成为创建这种对象的首选模式。前面的例子用对象字面量语法可以写成这样:

var person = {
    name: "Nicholas", 
    age: 29,
    job: "Software Engineer",

    sayName: function(){
        alert(this.name);
    }
};


这个例子中的person 对象与前面例子中的person 对象是一样的,都有相同的属性和方法。这些属性在创建时都带有一些特征值(characteristic),JavaScript通过这些特征值来定义它们的行为。

6.1.1 属性类型

ECMA-262第5版在定义只有内部才用的特性(attribute)时,描述了属性(property)的各种特征。ECMA-262定义这些特性是为了实现JavaScript引擎用的,因此在JavaScript中不能直接访问它们。为了表示特性是内部值,该规范把它们放在了两对儿方括号中,例如[[Enumerable]] 。尽管ECMA-262第3版的定义有些不同,但本书只参考第5版的描述。

ECMAScript中有两种属性:数据属性和访问器属性。

1. 数据属性

数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有4个描述其行为的特性。

对于像前面例子中那样直接在对象上定义的属性,它们的[[Configurable]][[Enumerable]][[Writable]] 特性都被设置为true ,而[[Value]] 特性被设置为指定的值。例如:

var person = {
    name: "Nicholas"
};


这里创建了一个名为name 的属性,为它指定的值是"Nicholas" 。也就是说,[[Value]] 特性将被设置为"Nicholas" ,而对这个值的任何修改都将反映在这个位置。

要修改属性默认的特性,必须使用ECMAScript 5的Object.defineProperty() 方法。这个方法接收三个参数:属性所在的对象、属性的名字和一个描述符对象。其中,描述符(descriptor)对象的属性必须是:configurableenumerablewritablevalue 。设置其中的一或多个值,可以修改对应的特性值。例如:

var person = {};
Object.defineProperty(person, "name", {
    writable: false,
    value: "Nicholas" 
});

alert(person.name);    //"Nicholas"
person.name = "Greg";
alert(person.name);    //"Nicholas"


DataPropertiesExample01.htm

这个例子创建了一个名为name 的属性,它的值"Nicholas" 是只读的。这个属性的值是不可修改的,如果尝试为它指定新值,则在非严格模式下,赋值操作将被忽略;在严格模式下,赋值操作将会导致抛出错误。

类似的规则也适用于不可配置的属性。例如:

var person = {};
Object.defineProperty(person, "name", {
    configurable: false,
    value: "Nicholas"
});

alert(person.name);    //"Nicholas"
delete person.name;
alert(person.name);    //"Nicholas"


DataPropertiesExample02.htm

configurable 设置为false ,表示不能从对象中删除属性。如果对这个属性调用delete ,则在非严格模式下什么也不会发生,而在严格模式下会导致错误。而且,一旦把属性定义为不可配置的,就不能再把它变回可配置了。此时,再调用Object.defineProperty() 方法修改除writable 之外的特性,都会导致错误:

var person = {};
Object.defineProperty(person, "name", {
    configurable: false,
    value: "Nicholas"
});

//抛出错误
Object.defineProperty(person, "name", {
    configurable: true,


    value: "Nicholas"
});

DataPropertiesExample03.htm

也就是说,可以多次调用Object.defineProperty() 方法修改同一个属性,但在把configurable 特性设置为false 之后就会有限制了。

在调用Object.defineProperty() 方法时,如果不指定,configurableenumerablewritable 特性的默认值都是false 。多数情况下,可能都没有必要利用Object.defineProperty() 方法提供的这些高级功能。不过,理解这些概念对理解JavaScript对象却非常有用。

IE8是第一个实现Object.defineProperty() 方法的浏览器版本。然而,这个版本的实现存在诸多限制:只能在DOM对象上使用这个方法,而且只能创建访问器属性。由于实现不彻底,建议读者不要在IE8中使用Object.defineProperty() 方法。

2. 访问器属性

访问器属性不包含数据值;它们包含一对儿getter和setter函数(不过,这两个函数都不是必需的)。在读取访问器属性时,会调用getter函数,这个函数负责返回有效的值;在写入访问器属性时,会调用setter函数并传入新值,这个函数负责决定如何处理数据。访问器属性有如下4个特性。

访问器属性不能直接定义,必须使用Object.defineProperty() 来定义。请看下面的例子。

var book = {
    _year: 2004, 
    edition: 1
};

Object.defineProperty(book, "year", {
    get: function(){
        return this._year;
    },
    set: function(newValue){

        if (newValue > 2004) {
            this._year = newValue;
            this.edition += newValue - 2004;
        }
    }
});

book.year = 2005;
alert(book.edition);  //2


AccessorPropertiesExample01.htm

以上代码创建了一个book 对象,并给它定义两个默认的属性:_yearedition_year 前面的下划线是一种常用的记号,用于表示只能通过对象方法访问的属性。而访问器属性year 则包含一个getter函数和一个setter函数。getter函数返回_year 的值,setter函数通过计算来确定正确的版本。因此,把year 属性修改为2005会导致_year 变成 2005,而edition 变为2。这是使用访问器属性的常见方式,即设置一个属性的值会导致其他属性发生变化。

不一定非要同时指定getter和setter。只指定getter意味着属性是不能写,尝试写入属性会被忽略。在严格模式下,尝试写入只指定了getter函数的属性会抛出错误。类似地,没有指定setter函数的属性也不能读,否则在非严格模式下会返回undefined ,而在严格模式下会抛出错误。

支持ECMAScript 5的这个方法的浏览器有IE9+(IE8只是部分实现)、Firefox 4+、Safari 5+、Opera 12+和Chrome。在这个方法之前,要创建访问器属性,一般都使用两个非标准的方法:__defineGetter__()__defineSetter__() 。这两个方法最初是由Firefox引入的,后来Safari 3、Chrome 1和Opera 9.5也给出了相同的实现。使用这两个遗留的方法,可以像下面这样重写前面的例子。

var book = {
    _year: 2004,
    edition: 1
};

//定义访问器的旧有方法


book.__defi neGetter__("year", function(){


    return this._year;


});



book.__defineSetter__("year", function(newValue){


    if (newValue > 2004) {


        this._year = newValue;


        this.edition += newValue - 2004;


    }


});



book.year = 2005;
alert(book.edition);  //2

AccessorPropertiesExample02.htm

在不支持Object.defineProperty() 方法的浏览器中不能修改[[Configurable]][[Enumerable]]

6.1.2 定义多个属性

由于为对象定义多个属性的可能性很大,ECMAScript 5又定义了一个Object.defineProperties() 方法。利用这个方法可以通过描述符一次定义多个属性。这个方法接收两个对象参数:第一个对象是要添加和修改其属性的对象,第二个对象的属性与第一个对象中要添加或修改的属性一一对应。例如:

var book = {};

Object.defineProperties(book, {
    _year: {
        value: 2004
    },

    edition: {
        value: 1
    },

    year: {
        get: function(){
            return this._year;
        },

        set: function(newValue){
            if (newValue > 2004) {
                this._year = newValue;
                this.edition += newValue - 2004;
            }
        }
    }
});


MultiplePropertiesExample01.htm

以上代码在book 对外上定义了两个数据属性(_yearedition )和一个访问器属性(year )。最终的对象与上一节中定义的对象相同。唯一的区别是这里的属性都是在同一时间创建的。

支持Object.defineProperties() 方法的浏览器有IE9+、Firefox 4+、Safari 5+、Opera 12+和Chrome。

6.1.3 读取属性的特性

使用ECMAScript 5的Object.getOwnPropertyDescriptor() 方法,可以取得给定属性的描述符。这个方法接收两个参数:属性所在的对象和要读取其描述符的属性名称。返回值是一个对象,如果是访问器属性,这个对象的属性有configurableenumerablegetset ;如果是数据属性,这个对象的属性有configurableenumerablewritablevalue 。例如:

var book = {};

Object.defineProperties(book, {
    _year: { 
        value: 2004
    },

    edition: {
        value: 1
    },

    year: {
        get: function(){
            return this._year;
        },

        set: function(newValue){
            if (newValue > 2004) {
                this._year = newValue;
                this.edition += newValue - 2004;
            }
        }
    }
});

var descriptor = Object.getOwnPropertyDescriptor(book, "_year");


alert(descriptor.value);         //2004


alert(descriptor.confi gurable); //false


alert(typeof descriptor.get);    //"undefined"



var descriptor = Object.getOwnPropertyDescriptor(book, "year");


alert(descriptor.value);        //undefined


alert(descriptor.enumerable);   //false


alert(typeof descriptor.get);   //"function"


GetPropertyDescriptorExample01.htm

对于数据属性_yearvalue 等于最初的值,configurablefalse ,而get 等于undefined 。对于访问器属性yearvalue 等于undefinedenumerablefalse ,而get 是一个指向getter函数的指针。

在JavaScript中,可以针对任何对象——包括 DOM和BOM对象,使用Object.getOwnPropertyDescriptor() 方法。支持这个方法的浏览器有IE9+、Firefox 4+、Safari 5+、Opera 12+和Chrome。

虽然Object 构造函数或对象字面量都可以用来创建单个对象,但这些方式有个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。为解决这个问题,人们开始使用工厂模式的一种变体。

6.2.1 工厂模式

工厂模式是软件工程领域一种广为人知的设计模式,这种模式抽象了创建具体对象的过程(本书后面还将讨论其他设计模式及其在JavaScript中的实现)。考虑到在ECMAScript中无法创建类,开发人员就发明了一种函数,用函数来封装以特定接口创建对象的细节,如下面的例子所示。

function createPerson(name, age, job){
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){
        alert(this.name);
    };    
    return o;
}

var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");


FactoryPatternExample01.htm

函数createPerson() 能够根据接受的参数来构建一个包含所有必要信息的Person 对象。可以无数次地调用这个函数,而每次它都会返回一个包含三个属性一个方法的对象。工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型)。随着JavaScript的发展,又一个新模式出现了。

6.2.2 构造函数模式

前几章介绍过,ECMAScript中的构造函数可用来创建特定类型的对象。像ObjectArray 这样的原生构造函数,在运行时会自动出现在执行环境中。此外,也可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。例如,可以使用构造函数模式将前面的例子重写如下。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function(){
        alert(this.name);
    };    
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");


ConstructorPatternExample01.htm

在这个例子中,Person() 函数取代了createPerson() 函数。我们注意到,Person() 中的代码除了与createPerson() 中相同的部分外,还存在以下不同之处:

此外,还应该注意到函数名Person 使用的是大写字母P 。按照惯例,构造函数始终都应该以一个大写字母开头,而非构造函数则应该以一个小写字母开头。这个做法借鉴自其他OO语言,主要是为了区别于ECMAScript中的其他函数;因为构造函数本身也是函数,只不过可以用来创建对象而已。

要创建Person 的新实例,必须使用new 操作符。以这种方式调用构造函数实际上会经历以下4个步骤:

  1. 创建一个新对象;

  2. 将构造函数的作用域赋给新对象(因此this 就指向了这个新对象);

  3. 执行构造函数中的代码(为这个新对象添加属性);

  4. 返回新对象。

在前面例子的最后,person1person2 分别保存着Person 的一个不同的实例。这两个对象都有一个constructor (构造函数)属性,该属性指向Person ,如下所示。

alert(person1.constructor == Person);  //true
alert(person2.constructor == Person);  //true


对象的constructor 属性最初是用来标识对象类型的。但是,提到检测对象类型,还是instanceof 操作符要更可靠一些。我们在这个例子中创建的所有对象既是Object 的实例,同时也是Person 的实例,这一点通过instanceof 操作符可以得到验证。

alert(person1 instanceof Object);  //true
alert(person1 instanceof Person);  //true
alert(person2 instanceof Object);  //true
alert(person2 instanceof Person);  //true


创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型;而这正是构造函数模式胜过工厂模式的地方。在这个例子中,person1person2 之所以同时是Object 的实例,是因为所有对象均继承自Object (详细内容稍后讨论)。

以这种方式定义的构造函数是定义在Global 对象(在浏览器中是window 对象)中的。第8章将详细讨论浏览器对象模型(BOM)。

1. 将构造函数当作函数

构造函数与其他函数的唯一区别,就在于调用它们的方式不同。不过,构造函数毕竟也是函数,不存在定义构造函数的特殊语法。任何函数,只要通过new 操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过new 操作符来调用,那它跟普通函数也不会有什么两样。例如,前面例子中定义的Person() 函数可以通过下列任何一种方式来调用。

// 当作构造函数使用
var person = new Person("Nicholas", 29, "Software Engineer");
person.sayName(); //"Nicholas"

// 作为普通函数调用
Person("Greg", 27, "Doctor"); // 添加到window
window.sayName(); //"Greg"

// 在另一个对象的作用域中调用
var o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); //"Kristen"


ConstructorPatternExample02.htm

这个例子中的前两行代码展示了构造函数的典型用法,即使用new 操作符来创建一个新对象。接下来的两行代码展示了不使用new 操作符调用Person() 会出现什么结果:属性和方法都被添加给window 对象了。有读者可能还记得,当在全局作用域中调用一个函数时,this 对象总是指向Global 对象(在浏览器中就是window 对象)。因此,在调用完函数之后,可以通过window 对象来调用sayName() 方法,并且还返回了"Greg" 。最后,也可以使用call() (或者apply() )在某个特殊对象的作用域中调用Person() 函数。这里是在对象o 的作用域中调用的,因此调用后o 就拥有了所有属性和sayName() 方法。

2. 构造函数的问题

构造函数模式虽然好用,但也并非没有缺点。使用构造函数的主要问题,就是每个方法都要在每个实例上重新创建一遍。在前面的例子中,person1person2 都有一个名为sayName() 的方法,但那两个方法不是同一个Function 的实例。不要忘了——ECMAScript中的函数是对象,因此每定义一个函数,也就是实例化了一个对象。从逻辑角度讲,此时的构造函数也可以这样定义。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = new Function("alert(this.name)"); // 与声明函数在逻辑上是等价的


}

从这个角度上来看构造函数,更容易明白每个Person 实例都包含一个不同的Function 实例(以显示name 属性)的本质。说明白些,以这种方式创建函数,会导致不同的作用域链和标识符解析,但创建Function 新实例的机制仍然是相同的。因此,不同实例上的同名函数是不相等的,以下代码可以证明这一点。

alert(person1.sayName == person2.sayName);  //false


然而,创建两个完成同样任务的Function 实例的确没有必要;况且有this 对象在,根本不用在执行代码前就把函数绑定到特定对象上面。因此,大可像下面这样,通过把函数定义转移到构造函数外部来解决这个问题。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = sayName;


}

function sayName(){


    alert(this.name);


}



var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

ConstructorPatternExample03.htm

在这个例子中,我们把sayName() 函数的定义转移到了构造函数外部。而在构造函数内部,我们将sayName 属性设置成等于全局的sayName 函数。这样一来,由于sayName 包含的是一个指向函数的指针,因此person1person2 对象就共享了在全局作用域中定义的同一个sayName() 函数。这样做确实解决了两个函数做同一件事的问题,可是新问题又来了:在全局作用域中定义的函数实际上只能被某个对象调用,这让全局作用域有点名不副实。而更让人无法接受的是:如果对象需要定义很多方法,那么就要定义很多个全局函数,于是我们这个自定义的引用类型就丝毫没有封装性可言了。好在,这些问题可以通过使用原型模式来解决。

6.2.3 原型模式

我们创建的每个函数都有一个prototype (原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,那么prototype 就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换句话说,不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中,如下面的例子所示。

function Person(){
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
    alert(this.name);
};

var person1 = new Person();
person1.sayName();   //"Nicholas"

var person2 = new Person();
person2.sayName();   //"Nicholas"

alert(person1.sayName == person2.sayName);  //true


PrototypePatternExample01.htm

在此,我们将sayName() 方法和所有属性直接添加到了Personprototype 属性中,构造函数变成了空函数。即使如此,也仍然可以通过调用构造函数来创建新对象,而且新对象还会具有相同的属性和方法。但与构造函数模式不同的是,新对象的这些属性和方法是由所有实例共享的。换句话说,person1person2 访问的都是同一组属性和同一个sayName() 函数。要理解原型模式的工作原理,必须先理解ECMAScript中原型对象的性质。

1. 理解原型对象

无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个prototype 属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个constructor (构造函数)属性,这个属性包含一个指向prototype 属性所在函数的指针。就拿前面的例子来说,Person.prototype. constructor 指向Person 。而通过这个构造函数,我们还可继续为原型对象添加其他属性和方法。

创建了自定义的构造函数之后,其原型对象默认只会取得constructor 属性;至于其他方法,则都是从Object 继承而来的。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。ECMA-262第5版中管这个指针叫[[Prototype]] 。虽然在脚本中没有标准的方式访问[[Prototype]] ,但Firefox、Safari和Chrome在每个对象上都支持一个属性__proto__ ;而在其他实现中,这个属性对脚本则是完全不可见的。不过,要明确的真正重要的一点就是,这个连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。

以前面使用Person 构造函数和Person.prototype 创建实例的代码为例,图6-1展示了各个对象之间的关系。

图 6-1

图6-1展示了Person 构造函数、Person 的原型属性以及Person 现有的两个实例之间的关系。在此,Person.prototype 指向了原型对象,而Person.prototype.constructor 又指回了Person 。原型对象中除了包含constructor 属性之外,还包括后来添加的其他属性。Person 的每个实例——person1person2 都包含一个内部属性,该属性仅仅指向了Person.prototype ;换句话说,它们与构造函数没有直接的关系。此外,要格外注意的是,虽然这两个实例都不包含属性和方法,但我们却可以调用person1.sayName() 。这是通过查找对象属性的过程来实现的。

虽然在所有实现中都无法访问到[[Prototype]] ,但可以通过isPrototypeOf() 方法来确定对象之间是否存在这种关系。从本质上讲,如果[[Prototype]] 指向调用isPrototypeOf() 方法的对象(Person.prototype ),那么这个方法就返回true ,如下所示:

alert(Person.prototype.isPrototypeOf(person1));  //true
alert(Person.prototype.isPrototypeOf(person2));  //true


这里,我们用原型对象的isPrototypeOf() 方法测试了person1person2 。因为它们内部都有一个指向Person.prototype 的指针,因此都返回了true

ECMAScript 5增加了一个新方法,叫Object.getPrototypeOf() ,在所有支持的实现中,这个方法返回[[Prototype]] 的值。例如:

alert(Object.getPrototypeOf(person1) == Person.prototype); //true
alert(Object.getPrototypeOf(person1).name); //"Nicholas"


这里的第一行代码只是确定Object.getPrototypeOf() 返回的对象实际就是这个对象的原型。第二行代码取得了原型对象中name 属性的值,也就是"Nicholas" 。使用Object.getPrototypeOf() 可以方便地取得一个对象的原型,而这在利用原型实现继承(本章稍后会讨论)的情况下是非常重要的。支持这个方法的浏览器有IE9+、Firefox 3.5+、Safari 5+、Opera 12+和Chrome。

每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例本身开始。如果在实例中找到了具有给定名字的属性,则返回该属性的值;如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性。如果在原型对象中找到了这个属性,则返回该属性的值。也就是说,在我们调用person1.sayName() 的时候,会先后执行两次搜索。首先,解析器会问:“实例person1sayName 属性吗?”答:“没有。”然后,它继续搜索,再问:“person1 的原型有sayName 属性吗?”答:“有。”于是,它就读取那个保存在原型对象中的函数。当我们调用person2.sayName() 时,将会重现相同的搜索过程,得到相同的结果。而这正是多个对象实例共享原型所保存的属性和方法的基本原理。

前面提到过,原型最初只包含constructor 属性,而该属性也是共享的,因此可以通过对象实例访问。

虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与实例原型中的一个属性同名,那我们就在实例中创建该属性,该属性将会屏蔽原型中的那个属性。来看下面的例子。

function Person(){
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
    alert(this.name);
};

var person1 = new Person();
var person2 = new Person();

person1.name = "Greg";


alert(person1.name);     //"Greg"——来自实例


alert(person2.name);     //"Nicholas"——来自原型


PrototypePatternExample02.htm

在这个例子中,person1name 被一个新值给屏蔽了。但无论访问person1.name 还是访问person2.name 都能够正常地返回值,即分别是"Greg" (来自对象实例)和"Nicholas" (来自原型)。当在alert() 中访问person1.name 时,需要读取它的值,因此就会在这个实例上搜索一个名为name 的属性。这个属性确实存在,于是就返回它的值而不必再搜索原型了。当以同样的方式访问person2. name 时,并没有在实例上发现该属性,因此就会继续搜索原型,结果在那里找到了name 属性。

当为对象实例添加一个属性时,这个属性就会屏蔽 原型对象中保存的同名属性;换句话说,添加这个属性只会阻止我们访问原型中的那个属性,但不会修改那个属性。即使将这个属性设置为null ,也只会在实例中设置这个属性,而不会恢复其指向原型的连接。不过,使用delete 操作符则可以完全删除实例属性,从而让我们能够重新访问原型中的属性,如下所示。

function Person(){
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
    alert(this.name);
};

var person1 = new Person();
var person2 = new Person();

person1.name = "Greg";
alert(person1.name);     //"Greg"——来自实例
alert(person2.name);     //"Nicholas"——来自原型

delete person1.name;


alert(person1.name);     //"Nicholas"——来自原型


PrototypePatternExample03.htm

在这个修改后的例子中,我们使用delete 操作符删除了person1.name ,之前它保存的"Greg" 值屏蔽了同名的原型属性。把它删除以后,就恢复了对原型中name 属性的连接。因此,接下来再调用person1.name 时,返回的就是原型中name 属性的值了。

使用hasOwnProperty() 方法可以检测一个属性是存在于实例中,还是存在于原型中。这个方法(不要忘了它是从Object 继承来的)只在给定属性存在于对象实例中时,才会返回true 。来看下面这个例子。

function Person(){
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
    alert(this.name);
};

var person1 = new Person();
var person2 = new Person();

alert(person1.hasOwnProperty("name"));  //false



person1.name = "Greg";
alert(person1.name);     //"Greg"——来自实例
alert(person1.hasOwnProperty("name"));  //true



alert(person2.name);     //"Nicholas"——来自原型
alert(person2.hasOwnProperty("name"));  //false



delete person1.name;
alert(person1.name);     //"Nicholas"——来自原型
alert(person1.hasOwnProperty("name"));  //false


通过使用hasOwnProperty() 方法,什么时候访问的是实例属性,什么时候访问的是原型属性就一清二楚了。调用person1.hasOwnProperty( "name") 时,只有当person1 重写name 属性后才会返回true ,因为只有这时候name 才是一个实例属性,而非原型属性。图6-2展示了上面例子在不同情况下的实现与原型的关系(为了简单起见,图中省略了与Person 构造函数的关系)。

图 6-2

ECMAScript 5的Object.getOwnPropertyDescriptor() 方法只能用于实例属性,要取得原型属性的描述符,必须直接在原型对象上调用Object.getOwnPropertyDescriptor() 方法。

2. 原型与in 操作符

有两种方式使用in 操作符:单独使用和在for-in 循环中使用。在单独使用时,in 操作符会在通过对象能够访问给定属性时返回true ,无论该属性存在于实例中还是原型中。看一看下面的例子。

function Person(){
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
    alert(this.name);
};

var person1 = new Person();
var person2 = new Person();

alert(person1.hasOwnProperty("name"));  //false
alert("name" in person1);  //true



person1.name = "Greg";
alert(person1.name);   //"Greg" ——来自实例
alert(person1.hasOwnProperty("name"));  //true
alert("name" in person1);  //true



alert(person2.name);   //"Nicholas" ——来自原型
alert(person2.hasOwnProperty("name"));  //false
alert("name" in person2);  //true



delete person1.name;
alert(person1.name);   //"Nicholas" ——来自原型
alert(person1.hasOwnProperty("name"));  //false
alert("name" in person1);  //true


PrototypePatternExample04.htm

在以上代码执行的整个过程中,name 属性要么是直接在对象上访问到的,要么是通过原型访问到的。因此,调用"name" in person1 始终都返回true ,无论该属性存在于实例中还是存在于原型中。同时使用hasOwnProperty() 方法和in 操作符,就可以确定该属性到底是存在于对象中,还是存在于原型中,如下所示。

function hasPrototypeProperty(object, name){
    return !object.hasOwnProperty(name) && (name in object);
}


由于in 操作符只要通过对象能够访问到属性就返回truehasOwnProperty() 只在属性存在于实例中时才返回true ,因此只要in 操作符返回truehasOwnProperty() 返回false ,就可以确定属性是原型中的属性。下面来看一看上面定义的函数hasPrototypeProperty() 的用法。

function Person(){
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
    alert(this.name);
};

var person = new Person();        
alert(hasPrototypeProperty(person, "name"));  //true



person.name = "Greg";
alert(hasPrototypeProperty(person, "name"));  //false


PrototypePatternExample05.htm

在这里,name 属性先是存在于原型中,因此hasPrototypeProperty() 返回true 。当在实例中重写name 属性后,该属性就存在于实例中了,因此hasPrototypeProperty() 返回false 。即使原型中仍然有name 属性,但由于现在实例中也有了这个属性,因此原型中的name 属性就用不到了。

在使用for-in 循环时,返回的是所有能够通过对象访问的、可枚举的(enumerated)属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。屏蔽了原型中不可枚举属性(即将[[Enumerable]] 标记的属性)的实例属性也会在for-in 循环中返回,因为根据规定,所有开发人员定义的属性都是可枚举的——只有在IE8及更早版本中例外。

IE早期版本的实现中存在一个bug,即屏蔽不可枚举属性的实例属性不会出现在for-in 循环中。例如:

var o = {
    toString : function(){
        return "My Object";
    }
};

for (var prop in o){
    if (prop == "toString"){
        alert("Found toString");    //在IE中不会显示
    }
}


PrototypePatternExample06.htm

当以上代码运行时,应该会显示一个警告框,表明找到了toString() 方法。这里的对象o 定义了一个名为toString() 的方法,该方法屏蔽了原型中(不可枚举)的toString() 方法。在IE中,由于其实现认为原型的toString() 方法被打上了[[Enumerable]] 标记就应该跳过该属性,结果我们就不会看到警告框。该bug 会影响默认不可枚举的所有属性和方法,包括:hasOwnProperty()propertyIsEnumerable()toLocaleString()toString()valueOf() 。ECMAScript 5也将constructorprototype 属性的[[Enumerable]] 特性设置为false ,但并不是所有浏览器都照此实现。

要取得对象上所有可枚举的实例属性,可以使用ECMAScript 5的Object.keys() 方法。这个方法接收一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。例如:

function Person(){
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
    alert(this.name); 
};

var keys = Object.keys(Person.prototype);


alert(keys);       //"name,age,job,sayName"



var p1 = new Person();


p1.name = "Rob";


p1.age = 31;


var p1keys = Object.keys(p1);


alert(p1keys);    //"name,age"


ObjectKeysExample01.htm

这里,变量keys 中将保存一个数组,数组中是字符串"name""age""job""sayName" 。这个顺序也是它们在for-in 循环中出现的顺序。如果是通过Person 的实例调用,则Object.keys() 返回的数组只包含"name""age" 这两个实例属性。

如果你想要得到所有实例属性,无论它是否可枚举,都可以使用Object.getOwnPropertyNames() 方法。

var keys = Object.getOwnPropertyNames(Person.prototype);


alert(keys);    //"constructor,name,age,job,sayName"


ObjectPropertyNamesExample01.htm

注意结果中包含了不可枚举的constructor 属性。Object.keys()Object.getOwnPropertyNames() 方法都可以用来替代for-in 循环。支持这两个方法的浏览器有IE9+、Firefox 4+、Safari 5+、Opera 12+和Chrome。

3. 更简单的原型语法

读者大概注意到了,前面例子中每添加一个属性和方法就要敲一遍Person.prototype 。为减少不必要的输入,也为了从视觉上更好地封装原型的功能,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象,如下面的例子所示。

function Person(){
}

Person.prototype = {


    name : "Nicholas",


    age : 29,


    job: "Software Engineer",


    sayName : function () {


        alert(this.name);


    }


};


PrototypePatternExample07.htm

在上面的代码中,我们将Person.prototype 设置为等于一个以对象字面量形式创建的新对象。最终结果相同,但有一个例外:constructor 属性不再指向Person 了。前面曾经介绍过,每创建一个函数,就会同时创建它的prototype 对象,这个对象也会自动获得constructor 属性。而我们在这里使用的语法,本质上完全重写了默认的prototype 对象,因此constructor 属性也就变成了新对象的constructor 属性(指向Object 构造函数),不再指向Person 函数。此时,尽管instanceof 操作符还能返回正确的结果,但通过constructor 已经无法确定对象的类型了,如下所示。

var friend = new Person();

alert(friend instanceof Object);        //true
alert(friend instanceof Person);        //true
alert(friend.constructor == Person);    //false
alert(friend.constructor == Object);    //true


PrototypePatternExample07.htm

在此,用instanceof 操作符测试ObjectPerson 仍然返回true ,但constructor 属性则等于Object 而不等于Person 了。如果constructor 的值真的很重要,可以像下面这样特意将它设置回适当的值。

function Person(){
}

Person.prototype = {
    constructor : Person,


    name : "Nicholas",
    age : 29,
    job: "Software Engineer",
    sayName : function () {
        alert(this.name);
    }
};

PrototypePatternExample07.htm

以上代码特意包含了一个constructor 属性,并将它的值设置为Person ,从而确保了通过该属性能够访问到适当的值。

注意,以这种方式重设constructor 属性会导致它的[[Enumerable]] 特性被设置为true。默认情况下,原生的constructor 属性是不可枚举的,因此如果你使用兼容ECMAScript 5的JavaScript引擎,可以试一试Object.defineProperty()

function Person(){
}

Person.prototype = {
    name : "Nicholas",
    age : 29,
    job : "Software Engineer",
    sayName : function () {
        alert(this.name);
    }
}; 
//重设构造函数,只适用于ECMAScript 5兼容的浏览器


Object.defineProperty(Person.prototype, "constructor", {


    enumerable: false,


    value: Person


});


4. 原型的动态性

由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来——即使是先创建了实例后修改原型也照样如此。请看下面的例子。

var friend = new Person();

Person.prototype.sayHi = function(){
    alert("hi");
};

friend.sayHi();   //"hi"(没有问题!)


PrototypePatternExample09.htm

以上代码先创建了Person 的一个实例,并将其保存在person 中。然后,下一条语句在Person.prototype 中添加了一个方法sayHi() 。即使person 实例是在添加新方法之前创建的,但它仍然可以访问这个新方法。其原因可以归结为实例与原型之间的松散连接关系。当我们调用person.sayHi() 时,首先会在实例中搜索名为sayHi 的属性,在没找到的情况下,会继续搜索原型。因为实例与原型之间的连接只不过是一个指针,而非一个副本,因此就可以在原型中找到新的sayHi 属性并返回保存在那里的函数。

尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重写整个原型对象,那么情况就不一样了。我们知道,调用构造函数时会为实例添加一个指向最初原型的[[Prototype]] 指针,而把原型修改为另外一个对象就等于切断了构造函数与最初原型之间的联系。请记住:实例中的指针仅指向原型,而不指向构造函数。看下面的例子。

function Person(){
}

var friend = new Person();

Person.prototype = {
    constructor: Person,
    name : "Nicholas",
    age : 29,
    job : "Software Engineer",
    sayName : function () {
        alert(this.name);
    }
};

friend.sayName();   //error


PrototypePatternExample10.htm

在这个例子中,我们先创建了Person 的一个实例,然后又重写了其原型对象。然后在调用friend.sayName() 时发生了错误,因为friend 指向的原型中不包含以该名字命名的属性。图6-3展示了这个过程的内幕。

图 6-3

从图6-3可以看出,重写原型对象切断了现有原型与任何之前已经存在的对象实例之间的联系;它们引用的仍然是最初的原型。

5. 原生对象的原型

原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。所有原生引用类型(ObjectArrayString ,等等)都在其构造函数的原型上定义了方法。例如,在Array.prototype 中可以找到sort() 方法,而在String.prototype 中可以找到substring() 方法,如下所示。

alert(typeof Array.prototype.sort);              //"function"
alert(typeof String.prototype.substring);        //"function"


通过原生对象的原型,不仅可以取得所有默认方法的引用,而且也可以定义新方法。可以像修改自定义对象的原型一样修改原生对象的原型,因此可以随时添加方法。下面的代码就给基本包装类型String 添加了一个名为startsWith() 的方法。

String.prototype.startsWith = function (text) {
    return this.indexOf(text) == 0;
};

var msg = "Hello world!";
alert(msg.startsWith("Hello"));   //true


PrototypePatternExample11.htm

这里新定义的startsWith() 方法会在传入的文本位于一个字符串开始时返回true 。既然方法被添加给了String.prototype ,那么当前环境中的所有字符串就都可以调用它。由于msg 是字符串,而且后台会调用String 基本包装函数创建这个字符串,因此通过mgs 就可以调用startsWith() 方法。

尽管可以这样做,但我们不推荐在产品化的程序中修改原生对象的原型。如果因某个实现中缺少某个方法,就在原生对象的原型中添加这个方法,那么当在另一个支持该方法的实现中运行代码时,就可能会导致命名冲突。而且,这样做也可能会意外地重写原生方法。

6. 原型对象的问题

原型模式也不是没有缺点。首先,它省略了为构造函数传递初始化参数这一环节,结果所有实例在默认情况下都将取得相同的属性值。虽然这会在某种程度上带来一些不方便,但还不是原型的最大问题。原型模式的最大问题是由其共享的本性所导致的。

原型中所有属性是被很多实例共享的,这种共享对于函数非常合适。对于那些包含基本值的属性倒也说得过去,毕竟(如前面的例子所示),通过在实例上添加一个同名属性,可以隐藏原型中的对应属性。然而,对于包含引用类型值的属性来说,问题就比较突出了。来看下面的例子。

function Person(){
}

Person.prototype = {
    constructor: Person,
    name : "Nicholas",
    age : 29,
    job : "Software Engineer",
    friends : ["Shelby", "Court"],


    sayName : function () {
        alert(this.name);
    }
};

var person1 = new Person();
var person2 = new Person();

person1.friends.push("Van");



alert(person1.friends);    //"Shelby,Court,Van"


alert(person2.friends);    //"Shelby,Court,Van"


alert(person1.friends === person2.friends);  //true


PrototypePatternExample12.htm

在此,Person.prototype 对象有一个名为friends 的属性,该属性包含一个字符串数组。然后,创建了Person 的两个实例。接着,修改了person1.friends 引用的数组,向数组中添加了一个字符串。由于friends 数组存在于Person.prototype 而非person1 中,所以刚刚提到的修改也会通过person2.friends (与person1.friends 指向同一个数组)反映出来。假如我们的初衷就是像这样在所有实例中共享一个数组,那么对这个结果我没有话可说。可是,实例一般都是要有属于自己的全部属性的。而这个问题正是我们很少看到有人单独使用原型模式的原因所在。

6.2.4 组合使用构造函数模式和原型模式

创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混成模式还支持向构造函数传递参数;可谓是集两种模式之长。下面的代码重写了前面的例子。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["Shelby", "Court"];
}

Person.prototype = {
    constructor : Person,
    sayName : function(){
        alert(this.name);
    }
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

person1.friends.push("Van");
alert(person1.friends);    //"Shelby,Count,Van"
alert(person2.friends);    //"Shelby,Count"
alert(person1.friends === person2.friends);    //false
alert(person1.sayName === person2.sayName);    //true


HybridPatternExample01.htm

在这个例子中,实例属性都是在构造函数中定义的,而由所有实例共享的属性constructor 和方法sayName() 则是在原型中定义的。而修改了person1.friends (向其中添加一个新字符串),并不会影响到person2.friends ,因为它们分别引用了不同的数组。

这种构造函数与原型混成的模式,是目前在ECMAScript中使用最广泛、认同度最高的一种创建自定义类型的方法。可以说,这是用来定义引用类型的一种默认模式。

6.2.5 动态原型模式

有其他OO语言经验的开发人员在看到独立的构造函数和原型时,很可能会感到非常困惑。动态原型模式正是致力于解决这个问题的一个方案,它把所有信息都封装在了构造函数中,而通过在构造函数中初始化原型(仅在必要的情况下),又保持了同时使用构造函数和原型的优点。换句话说,可以通过检查某个应该存在的方法是否有效,来决定是否需要初始化原型。来看一个例子。

function Person(name, age, job){

    //属性
    this.name = name;
    this.age = age;
    this.job = job;

    //方法


    if (typeof this.sayName != "function"){



        Person.prototype.sayName = function(){


            alert(this.name);


        };



    }


}

var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName();

DynamicPrototypeExample01.htm

注意构造函数代码中加背景的部分。这里只在sayName() 方法不存在的情况下,才会将它添加到原型中。这段代码只会在初次调用构造函数时才会执行。此后,原型已经完成初始化,不需要再做什么修改了。不过要记住,这里对原型所做的修改,能够立即在所有实例中得到反映。因此,这种方法确实可以说非常完美。其中,if 语句检查的可以是初始化之后应该存在的任何属性或方法——不必用一大堆if 语句检查每个属性和每个方法;只要检查其中一个即可。对于采用这种模式创建的对象,还可以使用instanceof 操作符确定它的类型。

使用动态原型模式时,不能使用对象字面量重写原型。前面已经解释过了,如果在已经创建了实例的情况下重写原型,那么就会切断现有实例与新原型之间的联系。

6.2.6 寄生构造函数模式

通常,在前述的几种模式都不适用的情况下,可以使用寄生(parasitic)构造函数模式。这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后再返回新创建的对象;但从表面上看,这个函数又很像是典型的构造函数。下面是一个例子。

function Person(name, age, job){
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){
        alert(this.name);
    };    
    return o;
}

var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName();  //"Nicholas"


HybridFactoryPatternExample01.htm

在这个例子中,Person 函数创建了一个新对象,并以相应的属性和方法初始化该对象,然后又返回了这个对象。除了使用new 操作符并把使用的包装函数叫做构造函数之外,这个模式跟工厂模式其实是一模一样的。构造函数在不返回值的情况下,默认会返回新对象实例。而通过在构造函数的末尾添加一个return 语句,可以重写调用构造函数时返回的值。

这个模式可以在特殊的情况下用来为对象创建构造函数。假设我们想创建一个具有额外方法的特殊数组。由于不能直接修改Array 构造函数,因此可以使用这个模式。

function SpecialArray(){

    //创建数组
    var values = new Array();

    //添加值
    values.push.apply(values, arguments);

    //添加方法
    values.toPipedString = function(){
        return this.join("|");
    };

    //返回数组
    return values;
}

var colors = new SpecialArray("red", "blue", "green");
alert(colors.toPipedString()); //"red|blue|green"


HybridFactoryPatternExample02.htm

在这个例子中,我们创建了一个名叫SpecialArray 的构造函数。在这个函数内部,首先创建了一个数组,然后push() 方法(用构造函数接收到的所有参数)初始化了数组的值。随后,又给数组实例添加了一个toPipedString() 方法,该方法返回以竖线分割的数组值。最后,将数组以函数值的形式返回。接着,我们调用了SpecialArray 构造函数,向其中传入了用于初始化数组的值,此后又调用了toPipedString() 方法。

关于寄生构造函数模式,有一点需要说明:首先,返回的对象与构造函数或者与构造函数的原型属性之间没有关系;也就是说,构造函数返回的对象与在构造函数外部创建的对象没有什么不同。为此,不能依赖instanceof 操作符来确定对象类型。由于存在上述问题,我们建议在可以使用其他模式的情况下,不要使用这种模式。

6.2.7 稳妥构造函数模式

道格拉斯·克罗克福德(Douglas Crockford)发明了JavaScript中的稳妥对象 (durable objects)这个概念。所谓稳妥对象,指的是没有公共属性,而且其方法也不引用this 的对象。稳妥对象最适合在一些安全的环境中(这些环境中会禁止使用thisnew ),或者在防止数据被其他应用程序(如Mashup程序)改动时使用。稳妥构造函数遵循与寄生构造函数类似的模式,但有两点不同:一是新创建对象的实例方法不引用this ;二是不使用new 操作符调用构造函数。按照稳妥构造函数的要求,可以将前面的Person 构造函数重写如下。

function Person(name, age, job){

    //创建要返回的对象
    var o = new Object();

    //可以在这里定义私有变量和函数

    //添加方法
    o.sayName = function(){
        alert(name);
    };    

    //返回对象
    return o;
}


注意,在以这种模式创建的对象中,除了使用sayName() 方法之外,没有其他办法访问name 的值。可以像下面使用稳妥的Person 构造函数。

var friend = Person("Nicholas", 29, "Software Engineer");
friend.sayName();  //"Nicholas"


这样,变量person 中保存的是一个稳妥对象,而除了调用sayName() 方法外,没有别的方式可以访问其数据成员。即使有其他代码会给这个对象添加方法或数据成员,但也不可能有别的办法访问传入到构造函数中的原始数据。稳妥构造函数模式提供的这种安全性,使得它非常适合在某些安全执行环境——例如,ADsafe(www.adsafe.org )和Caja(http://code.google.com/p/google-caja/ )提供的环境——下使用。

与寄生构造函数模式类似,使用稳妥构造函数模式创建的对象与构造函数之间也没有什么关系,因此instanceof 操作符对这种对象也没有意义。

继承是OO语言中的一个最为人津津乐道的概念。许多OO语言都支持两种继承方式:接口继承和实现继承。接口继承只继承方法签名,而实现继承则继承实际的方法。如前所述,由于函数没有签名,在ECMAScript中无法实现接口继承。ECMAScript只支持实现继承,而且其实现继承主要是依靠原型链来实现的。

6.3.1 原型链

ECMAScript中描述了原型链 的概念,并将原型链作为实现继承的主要方法。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。简单回顾一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么,假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条。这就是所谓原型链的基本概念。

实现原型链有一种基本模式,其代码大致如下。

function SuperType(){
    this.property = true;
}
SuperType.prototype.getSuperValue = function(){
    return this.property;
};

function SubType(){
    this.subproperty = false;
}

//继承了SuperType
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function (){
    return this.subproperty;
};

var instance = new SubType();
alert(instance.getSuperValue());      //true


PrototypeChainingExample01.htm

以上代码定义了两个类型:SuperTypeSubType 。每个类型分别有一个属性和一个方法。它们的主要区别是SubType 继承了SuperType ,而继承是通过创建SuperType 的实例,并将该实例赋给SubType.prototype 实现的。实现的本质是重写原型对象,代之以一个新类型的实例。换句话说,原来存在于SuperType 的实例中的所有属性和方法,现在也存在于SubType.prototype 中了。在确立了继承关系之后,我们给SubType.prototype 添加了一个方法,这样就在继承了SuperType 的属性和方法的基础上又添加了一个新方法。这个例子中的实例以及构造函数和原型之间的关系如图6-4所示。

图 6-4

在上面的代码中,我们没有使用SubType 默认提供的原型,而是给它换了一个新原型;这个新原型就是SuperType 的实例。于是,新原型不仅具有作为一个SuperType 的实例所拥有的全部属性和方法,而且其内部还有一个指针,指向了SuperType 的原型。最终结果就是这样的:instance 指向SubType 的原型,SubType 的原型又指向SuperType 的原型。getSuperValue() 方法仍然还在SuperType.prototype 中,但property 则位于SubType.prototype 中。这是因为property 是一个实例属性,而getSuperValue() 则是一个原型方法。既然SubType.prototype 现在是SuperType 的实例,那么property 当然就位于该实例中了。此外,要注意instance.constructor 现在指向的是SuperType ,这是因为原来SubType.prototype 中的constructor 被重写了的缘故1

1 实际上,不是SubType 的原型的constructor 属性被重写了,而是SubType 的原型指向了另一个对象——SuperType 的原型,而这个原型对象的constructor 属性指向的是SuperType

通过实现原型链,本质上扩展了本章前面介绍的原型搜索机制。读者大概还记得,当以读取模式访问一个实例属性时,首先会在实例中搜索该属性。如果没有找到该属性,则会继续搜索实例的原型。在通过原型链实现继承的情况下,搜索过程就得以沿着原型链继续向上。就拿上面的例子来说,调用instance.getSuperValue() 会经历三个搜索步骤:1)搜索实例;2)搜索SubType.prototype ;3)搜索SuperType.prototype ,最后一步才会找到该方法。在找不到属性或方法的情况下,搜索过程总是要一环一环地前行到原型链末端才会停下来。

1. 别忘记默认的原型

事实上,前面例子中展示的原型链还少一环。我们知道,所有引用类型默认都继承了Object ,而这个继承也是通过原型链实现的。大家要记住,所有函数的默认原型都是Object 的实例,因此默认原型都会包含一个内部指针,指向Object.prototype 。这也正是所有自定义类型都会继承toString()valueOf() 等默认方法的根本原因。所以,我们说上面例子展示的原型链中还应该包括另外一个继承层次。图6-5为我们展示了该例子中完整的原型链。

图 6-5

一句话,SubType 继承了SuperType ,而SuperType 了继承Object 。当调用instance.toString() 时,实际上调用的是保存在Object.prototype 中的那个方法。

2. 确定原型和实例的关系

可以通过两种方式来确定原型和实例之间的关系。第一种方式是使用instanceof 操作符,只要用这个操作符来测试实例与原型链中出现过的构造函数,结果就会返回true 。以下几行代码就说明了这一点。

alert(instance instanceof Object);           //true
alert(instance instanceof SuperType);        //true
alert(instance instanceof SubType);          //true


PrototypeChainingExample01.htm

由于原型链的关系,我们可以说instanceObjectSuperTypeSubType 中任何一个类型的实例。因此,测试这三个构造函数的结果都返回了true

第二种方式是使用isPrototypeOf() 方法。同样,只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型,因此isPrototypeOf() 方法也会返回true ,如下所示。

alert(Object.prototype.isPrototypeOf(instance));           //true
alert(SuperType.prototype.isPrototypeOf(instance));        //true
alert(SubType.prototype.isPrototypeOf(instance));          //true


PrototypeChainingExample01.htm

3. 谨慎地定义方法

子类型有时候需要重写超类型中的某个方法,或者需要添加超类型中不存在的某个方法。但不管怎样,给原型添加方法的代码一定要放在替换原型的语句之后。来看下面的例子。

function SuperType(){
    this.property = true;
}

SuperType.prototype.getSuperValue = function(){
    return this.property;
};

function SubType(){
    this.subproperty = false;
}

//继承了SuperType
SubType.prototype = new SuperType();

//添加新方法


SubType.prototype.getSubValue = function (){


    return this.subproperty;


};



//重写超类型中的方法


SubType.prototype.getSuperValue = function (){


    return false;


};



var instance = new SubType();
alert(instance.getSuperValue());   //false

PrototypeChainingExample02.htm

在以上代码中,加粗的部分是两个方法的定义。第一个方法getSubValue() 被添加到了SubType 中。第二个方法getSuperValue() 是原型链中已经存在的一个方法,但重写这个方法将会屏蔽原来的那个方法。换句话说,当通过SubType 的实例调用getSuperValue() 时,调用的就是这个重新定义的方法;但通过SuperType 的实例调用getSuperValue() 时,还会继续调用原来的那个方法。这里要格外注意的是,必须在用SuperType 的实例替换原型之后,再定义这两个方法。

还有一点需要提醒读者,即在通过原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做就会重写原型链,如下面的例子所示。

function SuperType(){
    this.property = true;
}

SuperType.prototype.getSuperValue = function(){
    return this.property;
};

function SubType(){
    this.subproperty = false;
}

//继承了SuperType
SubType.prototype = new SuperType();

//使用字面量添加新方法,会导致上一行代码无效


SubType.prototype = {


    getSubValue : function (){


        return this.subproperty;


    },



    someOtherMethod : function (){


        return false;


    }


};



var instance = new SubType();
alert(instance.getSuperValue());   //error!


PrototypeChainingExample03.htm

以上代码展示了刚刚把SuperType 的实例赋值给原型,紧接着又将原型替换成一个对象字面量而导致的问题。由于现在的原型包含的是一个Object 的实例,而非SuperType 的实例,因此我们设想中的原型链已经被切断——SubTypeSuperType 之间已经没有关系了。

4. 原型链的问题

原型链虽然很强大,可以用它来实现继承,但它也存在一些问题。其中,最主要的问题来自包含引用类型值的原型。想必大家还记得,我们前面介绍过包含引用类型值的原型属性会被所有实例共享;而这也正是为什么要在构造函数中,而不是在原型对象中定义属性的原因。在通过原型来实现继承时,原型实际上会变成另一个类型的实例。于是,原先的实例属性也就顺理成章地变成了现在的原型属性了。下列代码可以用来说明这个问题。

function SuperType(){
    this.colors = ["red", "blue", "green"];
}

function SubType(){            
}

//继承了SuperType
SubType.prototype = new SuperType();

var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors);        //"red,blue,green,black"

var instance2 = new SubType();
alert(instance2.colors);        //"red,blue,green,black"


PrototypeChainingExample04.htm

这个例子中的SuperType 构造函数定义了一个colors 属性,该属性包含一个数组(引用类型值)。SuperType 的每个实例都会有各自包含自己数组的colors 属性。当SubType 通过原型链继承了SuperType 之后,SubType.prototype 就变成了SuperType 的一个实例,因此它也拥有了一个它自己的colors 属性——就跟专门创建了一个SubType.prototype.colors 属性一样。但结果是什么呢?结果是SubType 的所有实例都会共享这一个colors 属性。而我们对instance1.colors 的修改能够通过instance2.colors 反映出来,就已经充分证实了这一点。

原型链的第二个问题是:在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上,应该说是没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。有鉴于此,再加上前面刚刚讨论过的由于原型中包含引用类型值所带来的问题,实践中很少会单独使用原型链。

6.3.2 借用构造函数

在解决原型中包含引用类型值所带来问题的过程中,开发人员开始使用一种叫做借用构造函数 (constructor stealing)的技术(有时候也叫做伪造对象或经典继承)。这种技术的基本思想相当简单,即在子类型构造函数的内部调用超类型构造函数。别忘了,函数只不过是在特定环境中执行代码的对象,因此通过使用apply()call() 方法也可以在(将来)新创建的对象上执行构造函数,如下所示:

function SuperType(){
    this.colors = ["red", "blue", "green"];
}

function SubType(){  
    //继承了SuperType


    SuperType.call(this);


}

var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors);    //"red,blue,green,black"

var instance2 = new SubType();
alert(instance2.colors);    //"red,blue,green"

ConstructorStealingExample01.htm

代码中加背景的那一行代码“借调”了超类型的构造函数。通过使用call() 方法(或apply() 方法也可以),我们实际上是在(未来将要)新创建的SubType 实例的环境下调用了SuperType 构造函数。这样一来,就会在新SubType 对象上执行SuperType() 函数中定义的所有对象初始化代码。结果,SubType 的每个实例就都会具有自己的colors 属性的副本了。

1. 传递参数

相对于原型链而言,借用构造函数有一个很大的优势,即可以在子类型构造函数中向超类型构造函数传递参数。看下面这个例子。

function SuperType(name){
    this.name = name;
}

function SubType(){  
    //继承了SuperType,同时还传递了参数
    SuperType.call(this, "Nicholas");

    //实例属性
    this.age = 29;
}

var instance = new SubType();
alert(instance.name);    //"Nicholas";
alert(instance.age);     //29


ConstructorStealingExample02.htm

以上代码中的SuperType 只接受一个参数name ,该参数会直接赋给一个属性。在SubType 构造函数内部调用SuperType 构造函数时,实际上是为SubType 的实例设置了name 属性。为了确保SuperType 构造函数不会重写子类型的属性,可以在调用超类型构造函数后,再添加应该在子类型中定义的属性。

2. 借用构造函数的问题

如果仅仅是借用构造函数,那么也将无法避免构造函数模式存在的问题——方法都在构造函数中定义,因此函数复用就无从谈起了。而且,在超类型的原型中定义的方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。考虑到这些问题,借用构造函数的技术也是很少单独使用的。

6.3.3 组合继承

组合继承(combination inheritance),有时候也叫做伪经典继承,指的是将原型链和借用构造函数的技术组合到一块,从而发挥二者之长的一种继承模式。其背后的思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数复用,又能够保证每个实例都有它自己的属性。下面来看一个例子。

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    alert(this.name);
};

function SubType(name, age){  

    //继承属性
    SuperType.call(this, name);

    this.age = age;
}

//继承方法
SubType.prototype = new SuperType();

SubType.prototype.sayAge = function(){
    alert(this.age);
};

var instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
alert(instance1.colors);      //"red,blue,green,black"
instance1.sayName();          //"Nicholas";
instance1.sayAge();           //29

var instance2 = new SubType("Greg", 27);
alert(instance2.colors);      //"red,blue,green"
instance2.sayName();          //"Greg";
instance2.sayAge();           //27


CombinationInheritanceExample01.htm

在这个例子中,SuperType 构造函数定义了两个属性:namecolorsSuperType 的原型定义了一个方法sayName()SubType 构造函数在调用SuperType 构造函数时传入了name 参数,紧接着又定义了它自己的属性age 。然后,将SuperType 的实例赋值给SubType 的原型,然后又在该新原型上定义了方法sayAge() 。这样一来,就可以让两个不同的SubType 实例既分别拥有自己属性——包括colors 属性,又可以使用相同的方法了。

组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为JavaScript中最常用的继承模式。而且,instanceofisPrototypeOf() 也能够用于识别基于组合继承创建的对象。

6.3.4 原型式继承

道格拉斯·克罗克福德在2006年写了一篇文章,题为Prototypal Inheritance in JavaScript (JavaScript中的原型式继承)。在这篇文章中,他介绍了一种实现继承的方法,这种方法并没有使用严格意义上的构造函数。他的想法是借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。为了达到这个目的,他给出了如下函数。

function object(o){
    function F(){}
    F.prototype = o;
    return new F();
}


object() 函数内部,先创建了一个临时性的构造函数,然后将传入的对象作为这个构造函数的原型,最后返回了这个临时类型的一个新实例。从本质上讲,object() 对传入其中的对象执行了一次浅复制。来看下面的例子。

var person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

var yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

alert(person.friends);   //"Shelby,Court,Van,Rob,Barbie"


PrototypalInheritanceExample01.htm

克罗克福德主张的这种原型式继承,要求你必须有一个对象可以作为另一个对象的基础。如果有这么一个对象的话,可以把它传递给object() 函数,然后再根据具体需求对得到的对象加以修改即可。在这个例子中,可以作为另一个对象基础的是person 对象,于是我们把它传入到object() 函数中,然后该函数就会返回一个新对象。这个新对象将person 作为原型,所以它的原型中就包含一个基本类型值属性和一个引用类型值属性。这意味着person.friends 不仅属于person 所有,而且也会被anotherPerson 以及yetAnotherPerson 共享。实际上,这就相当于又创建了person 对象的两个副本。

ECMAScript 5通过新增Object.create() 方法规范化了原型式继承。这个方法接收两个参数:一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。在传入一个参数的情况下,Object.create()object() 方法的行为相同。

var person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = Object.create(person);


anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

var yetAnotherPerson = Object.create(person);


yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

alert(person.friends); //"Shelby,Court,Van,Rob,Barbie"

PrototypalInheritanceExample02.htm

Object.create() 方法的第二个参数与Object.defineProperties() 方法的第二个参数格式相同:每个属性都是通过自己的描述符定义的。以这种方式指定的任何属性都会覆盖原型对象上的同名属性。例如:

var person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = Object.create(person, {


    name: {


        value: "Greg"


    }


});



alert(anotherPerson.name); //"Greg"

PrototypalInheritanceExample03.htm

支持Object.create() 方法的浏览器有IE9+、Firefox 4+、Safari 5+、Opera 12+和Chrome。

在没有必要兴师动众地创建构造函数,而只想让一个对象与另一个对象保持类似的情况下,原型式继承是完全可以胜任的。不过别忘了,包含引用类型值的属性始终都会共享相应的值,就像使用原型模式一样。

6.3.5 寄生式继承

寄生式(parasitic)继承是与原型式继承紧密相关的一种思路,并且同样也是由克罗克福德推而广之的。寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真地是它做了所有工作一样返回对象。以下代码示范了寄生式继承模式。

function createAnother(original){
    var clone = object(original);    //通过调用函数创建一个新对象
    clone.sayHi = function(){        //以某种方式来增强这个对象
        alert("hi");
    };
    return clone;                    //返回这个对象
}


在这个例子中,createAnother() 函数接收了一个参数,也就是将要作为新对象基础的对象。然后,把这个对象(original )传递给object() 函数,将返回的结果赋值给clone 。再为clone 对象添加一个新方法sayHi() ,最后返回clone 对象。可以像下面这样来使用createAnother() 函数:

var person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = createAnother(person);
anotherPerson.sayHi(); //"hi"


这个例子中的代码基于person 返回了一个新对象——anotherPerson 。新对象不仅具有person 的所有属性和方法,而且还有自己的sayHi() 方法。

在主要考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。前面示范继承模式时使用的object() 函数不是必需的;任何能够返回新对象的函数都适用于此模式。

使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率;这一点与构造函数模式类似。

6.3.6 寄生组合式继承

前面说过,组合继承是JavaScript最常用的继承模式;不过,它也有自己的不足。组合继承最大的问题就是无论什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数内部。没错,子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时重写这些属性。再来看一看下面组合继承的例子。

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    alert(this.name);
};

function SubType(name, age){  
    SuperType.call(this, name);         //第二次调用SuperType()



    this.age = age;
}

SubType.prototype = new SuperType();    //第一次调用SuperType()


SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
    alert(this.age);
};

加粗字体的行中是调用SuperType 构造函数的代码。在第一次调用SuperType 构造函数时,SubType.prototype 会得到两个属性:namecolors ;它们都是SuperType 的实例属性,只不过现在位于SubType 的原型中。当调用SubType 构造函数时,又会调用一次SuperType 构造函数,这一次又在新对象上创建了实例属性namecolors 。于是,这两个属性就屏蔽了原型中的两个同名属性。图6-6展示了上述过程。

如图6-6所示,有两组namecolors 属性:一组在实例上,一组在SubType 原型中。这就是调用两次SuperType 构造函数的结果。好在我们已经找到了解决这个问题方法——寄生组合式继承。

所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。寄生组合式继承的基本模式如下所示。

function inheritPrototype(subType, superType){
    var prototype = object(superType.prototype);       //创建对象
    prototype.constructor = subType;                   //增强对象
    subType.prototype = prototype;                     //指定对象
}


这个示例中的inheritPrototype() 函数实现了寄生组合式继承的最简单形式。这个函数接收两个参数:子类型构造函数和超类型构造函数。在函数内部,第一步是创建超类型原型的一个副本。第二步是为创建的副本添加constructor 属性,从而弥补因重写原型而失去的默认的constructor 属性。最后一步,将新创建的对象(即副本)赋值给子类型的原型。这样,我们就可以用调用inheritPrototype() 函数的语句,去替换前面例子中为子类型原型赋值的语句了,例如:

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    alert(this.name);
};

function SubType(name, age){  
    SuperType.call(this, name);

    this.age = age;
}

inheritPrototype(SubType, SuperType);



SubType.prototype.sayAge = function(){
    alert(this.age);
};

ParasiticCombinationInheritanceExample01.htm

图 6-6

这个例子的高效率体现在它只调用了一次SuperType 构造函数,并且因此避免了在SubType.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用instanceofisPrototypeOf() 。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

YUI的YAHOO.lang.extend() 方法采用了寄生组合继承,从而让这种模式首次出现在了一个应用非常广泛的JavaScript库中。要了解有关YUI的更多信息,请访问http://developer.yahoo.com/yui/

ECMAScript支持面向对象(OO)编程,但不使用类或者接口。对象可以在代码执行过程中创建和增强,因此具有动态性而非严格定义的实体。在没有类的情况下,可以采用下列模式创建对象。

JavaScript主要通过原型链实现继承。原型链的构建是通过将一个类型的实例赋值给另一个构造函数的原型实现的。这样,子类型就能够访问超类型的所有属性和方法,这一点与基于类的继承很相似。原型链的问题是对象实例共享所有继承的属性和方法,因此不适宜单独使用。解决这个问题的技术是借用构造函数,即在子类型构造函数的内部调用超类型构造函数。这样就可以做到每个实例都具有自己的属性,同时还能保证只使用构造函数模式来定义类型。使用最多的继承模式是组合继承,这种模式使用原型链继承共享的属性和方法,而通过借用构造函数继承实例属性。

此外,还存在下列可供选择的继承模式。


第7章 函数表达式

本章内容

函数表达式是JavaScript中的一个既强大又容易令人困惑的特性。第5章曾介绍过,定义函数的方式有两种:一种是函数声明,另一种就是函数表达式。函数声明的语法是这样的。

function functionName(arg0, arg1, arg2) {
    //函数体
}


首先是function 关键字,然后是函数的名字,这就是指定函数名的方式。Firefox、Safari、Chrome和Opera都给函数定义了一个非标准的name 属性,通过这个属性可以访问到给函数指定的名字。这个属性的值永远等于跟在function 关键字后面的标识符。

//只在Firefox、Safari、Chrome和Opera有效
alert(functionName.name);  //"functionName"


FunctionNameExample01.htm

关于函数声明,它的一个重要特征就是函数声明提升 (function declaration hoisting),意思是在执行代码之前会先读取函数声明。这就意味着可以把函数声明放在调用它的语句后面。

sayHi();
function sayHi(){
    alert("Hi!"); 
}


FunctionDeclarationHoisting01.htm

这个例子不会抛出错误,因为在代码执行之前会先读取函数声明。

第二种创建函数的方式是使用函数表达式。函数表达式有几种不同的语法形式。下面是最常见的一种形式。

var functionName = function(arg0, arg1, arg2){
    //函数体 
};


这种形式看起来好像是常规的变量赋值语句,即创建一个函数并将它赋值给变量functionName 。这种情况下创建的函数叫做匿名函数 (anonymous function),因为function 关键字后面没有标识符。(匿名函数有时候也叫拉姆达函数 。)匿名函数的name 属性是空字符串。

函数表达式与其他表达式一样,在使用前必须先赋值。以下代码会导致错误。

sayHi();    //错误:函数还不存在
var sayHi = function(){
    alert("Hi!");
};


理解函数提升的关键,就是理解函数声明与函数表达式之间的区别。例如,执行以下代码的结果可能会让人意想不到。

//不要这样做! 
if(condition){
    function sayHi(){
        alert("Hi!");
    }
} else {
    function sayHi(){
        alert("Yo!");
    }
} 


FunctionDeclarationsErrorExample01.htm

表面上看,以上代码表示在conditiontrue 时,使用一个sayHi() 的定义;否则,就使用另一个定义。实际上,这在ECMAScript中属于无效语法,JavaScript引擎会尝试修正错误,将其转换为合理的状态。但问题是浏览器尝试修正错误的做法并不一致。大多数浏览器会返回第二个声明,忽略condition ;Firefox会在conditiontrue 时返回第一个声明。因此这种使用方式很危险,不应该出现在你的代码中。不过,如果是使用函数表达式,那就没有什么问题了。

//可以这样做
var sayHi;

if(condition){ 
    sayHi = function(){
        alert("Hi!");
    };
} else {
    sayHi = function(){
        alert("Yo!");
    };
}


这个例子不会有什么意外,不同的函数会根据condition 被赋值给sayHi

能够创建函数再赋值给变量,也就能够把函数作为其他函数的值返回。还记得第5章中的那个createComparisonFunction() 函数吗:

function createComparisonFunction(propertyName) {

    return function(object1, object2){
        var value1 = object1[propertyName];
        var value2 = object2[propertyName];

        if (value1 < value2){
            return -1;
        } else if (value1 > value2){
            return 1;
        } else {
            return 0;
        }
    };
}


createComparisonFunction() 就返回了一个匿名函数。返回的函数可能会被赋值给一个变量,或者以其他方式被调用;不过,在createComparisonFunction() 函数内部,它是匿名的。在把函数当成值来使用的情况下,都可以使用匿名函数。不过,这并不是匿名函数唯一的用途。

递归函数是在一个函数通过名字调用自身的情况下构成的,如下所示。

function factorial(num){
    if (num <= 1){
        return 1;
    } else {
        return num * factorial(num-1);
    }
}


RecursionExample01.htm

这是一个经典的递归阶乘函数。虽然这个函数表面看来没什么问题,但下面的代码却可能导致它出错。

var anotherFactorial = factorial;
factorial = null;
alert(anotherFactorial(4)); //出错!


RecursionExample01.htm

以上代码先把factorial() 函数保存在变量anotherFactorial 中,然后将factorial 变量设置为null ,结果指向原始函数的引用只剩下一个。但在接下来调用anotherFactorial() 时,由于必须执行factorial() ,而factorial 已经不再是函数,所以就会导致错误。在这种情况下,使用arguments.callee 可以解决这个问题。

我们知道,arguments.callee 是一个指向正在执行的函数的指针,因此可以用它来实现对函数的递归调用,例如:

function factorial(num){
    if (num <= 1){
        return 1;
    } else {
        return num * arguments.callee(num-1);


    }
}

RecursionExample02.htm

加粗的代码显示,通过使用arguments.callee 代替函数名,可以确保无论怎样调用函数都不会出问题。因此,在编写递归函数时,使用arguments.callee 总比使用函数名更保险。

但在严格模式下,不能通过脚本访问arguments.callee ,访问这个属性会导致错误。不过,可以使用命名函数表达式来达成相同的结果。例如:

var factorial = (function f(num){
    if (num <= 1){ 
        return 1;
    } else {
        return num * f(num-1);


    }
});

以上代码创建了一个名为f() 的命名函数表达式,然后将它赋值给变量factorial 。即便把函数赋值给了另一个变量,函数的名字f 仍然有效,所以递归调用照样能正确完成。这种方式在严格模式和非严格模式下都行得通。

有不少开发人员总是搞不清匿名函数闭包 这两个概念,因此经常混用。闭包 是指有权访问另一个函数作用域中的变量的函数。创建闭包的常见方式,就是在一个函数内部创建另一个函数,仍以前面的createComparisonFunction() 函数为例,注意加粗的代码。

function createComparisonFunction(propertyName) {

    return function(object1, object2){
        var value1 = object1[propertyName];


        var value2 = object2[propertyName];



        if (value1 < value2){
            return -1;
        } else if (value1 > value2){
            return 1;
        } else {
            return 0;
        }
    };
}

在这个例子中,突出的那两行代码是内部函数(一个匿名函数)中的代码,这两行代码访问了外部函数中的变量propertyName 。即使这个内部函数被返回了,而且是在其他地方被调用了,但它仍然可以访问变量propertyName 。之所以还能够访问这个变量,是因为内部函数的作用域链中包含createComparisonFunction() 的作用域。要彻底搞清楚其中的细节,必须从理解函数第一次被调用的时候都会发生什么入手。

第4章介绍了作用域链的概念。而有关如何创建作用域链以及作用域链有什么作用的细节,对彻底理解闭包至关重要。当某个函数第一次被调用时,会创建一个执行环境(execution context)及相应的作用域链,并把作用域链赋值给一个特殊的内部属性(即[[Scope]] )。然后,使用thisarguments 和其他命名参数的值来初始化函数的活动对象(activation object)。但在作用域链中,外部函数的活动对象始终处于第二位,外部函数的外部函数的活动对象处于第三位,……直至作为作用域链终点的全局执行环境。

在函数执行过程中,为读取和写入变量的值,就需要在作用域链中查找变量。来看下面的例子。

function compare(value1, value2){
    if (value1 < value2){
        return -1;
    } else if (value1 > value2){
        return 1;
    } else {
        return 0;
    }
}

var result = compare(5, 10);


以上代码先定义了compare() 函数,然后又在全局作用域中调用了它。当第一次调用compare() 时,会创建一个包含thisargumentsvalue1value2 的活动对象。全局执行环境的变量对象(包含thisresultcompare )在compare() 执行环境的作用域链中则处于第二位。图7-1展示了包含上述关系的compare() 函数执行时的作用域链。

图 7-1

后台的每个执行环境都有一个表示变量的对象——变量对象。全局环境的变量对象始终存在,而像compare() 函数这样的局部环境的变量对象,则只在函数执行的过程中存在。在创建compare() 函数时,会创建一个预先包含全局变量对象的作用域链,这个作用域链被保存在内部的[[Scope]] 属性中。当调用compare() 函数时,会为函数创建一个执行环境,然后通过复制函数的[[Scope]] 属性中的对象构建起执行环境的作用域链。此后,又有一个活动对象(在此作为变量对象使用)被创建并被推入执行环境作用域链的前端。对于这个例子中compare() 函数的执行环境而言,其作用域链中包含两个变量对象:本地活动对象和全局变量对象。显然,作用域链本质上是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。

无论什么时候在函数中访问一个变量时,就会从作用域链中搜索具有相应名字的变量。一般来讲,当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象)。但是,闭包的情况又有所不同。

在另一个函数内部定义的函数会将包含函数(即外部函数)的活动对象添加到它的作用域链中。因此,在createComparisonFunction() 函数内部定义的匿名函数的作用域链中,实际上将会包含外部函数createComparisonFunction() 的活动对象。图7-2展示了当下列代码执行时,包含函数与内部匿名函数的作用域链。

var compare = createComparisonFunction("name");
var result = compare({ name: "Nicholas" }, { name: "Greg" });


在匿名函数从createComparisonFunction() 中被返回后,它的作用域链被初始化为包含createComparisonFunction() 函数的活动对象和全局变量对象。这样,匿名函数就可以访问在createComparisonFunction() 中定义的所有变量。更为重要的是,createComparisonFunction() 函数在执行完毕后,其活动对象也不会被销毁,因为匿名函数的作用域链仍然在引用这个活动对象。换句话说,当createComparisonFunction() 函数返回后,其执行环境的作用域链会被销毁,但它的活动对象仍然会留在内存中;直到匿名函数被销毁后,createComparisonFunction() 的活动对象才会被销毁,例如:

//创建函数
var compareNames = createComparisonFunction("name");

//调用函数
var result = compareNames({ name: "Nicholas" }, { name: "Greg" });

//解除对匿名函数的引用(以便释放内存)
compareNames = null;


首先,创建的比较函数被保存在变量compareNames 中。而通过将compareNames 设置为等于null 解除该函数的引用,就等于通知垃圾回收例程将其清除。随着匿名函数的作用域链被销毁,其他作用域(除了全局作用域)也都可以安全地销毁了。图7-2展示了调用compareNames() 的过程中产生的作用域链之间的关系。

图 7-2

由于闭包会携带包含它的函数的作用域,因此会比其他函数占用更多的内存。过度使用闭包可能会导致内存占用过多,我们建议读者只在绝对必要时再考虑使用闭包。虽然像V8等优化后的JavaScript引擎会尝试回收被闭包占用的内存,但请大家还是要慎重使用闭包。

7.2.1 闭包与变量

作用域链的这种配置机制引出了一个值得注意的副作用,即闭包只能取得包含函数中任何变量的最后一个值。别忘了闭包所保存的是整个变量对象,而不是某个特殊的变量。下面这个例子可以清晰地说明这个问题。

function createFunctions(){
    var result = new Array();

    for (var i=0; i < 10; i++){
        result[i] = function(){
            return i;
        };
    }

    return result;
} 


ClosureExample01.htm

这个函数会返回一个函数数组。表面上看,似乎每个函数都应该返自己的索引值,即位置0的函数返回0,位置1的函数返回1,以此类推。但实际上,每个函数都返回10。因为每个函数的作用域链中都保存着createFunctions() 函数的活动对象,所以它们引用的都是同一个变量i 。当createFunctions() 函数返回后,变量i 的值是10,此时每个函数都引用着保存变量i 的同一个变量对象,所以在每个函数内部i 的值都是10。但是,我们可以通过创建另一个匿名函数强制让闭包的行为符合预期,如下所示。

function createFunctions(){
    var result = new Array();

    for (var i=0; i < 10; i++){
        result[i] = function(num){


            return function(){


                return num;


            };


        }(i);


    }



    return result;
}

ClosureExample02.htm

在重写了前面的createFunctions() 函数后,每个函数就会返回各自不同的索引值了。在这个版本中,我们没有直接把闭包赋值给数组,而是定义了一个匿名函数,并将立即执行该匿名函数的结果赋给数组。这里的匿名函数有一个参数num ,也就是最终的函数要返回的值。在调用每个匿名函数时,我们传入了变量i 。由于函数参数是按值传递的,所以就会将变量i 的当前值复制给参数num 。而在这个匿名函数内部,又创建并返回了一个访问num 的闭包。这样一来,result 数组中的每个函数都有自己num 变量的一个副本,因此就可以返回各自不同的数值了。

7.2.2 关于this 对象

在闭包中使用this 对象也可能会导致一些问题。我们知道,this 对象是在运行时基于函数的执行环境绑定的:在全局函数中,this 等于window ,而当函数被作为某个对象的方法调用时,this 等于那个对象。不过,匿名函数的执行环境具有全局性,因此其this 对象通常指向window 1 。但有时候由于编写闭包的方式不同,这一点可能不会那么明显。下面来看一个例子。

1 当然,在通过call()apply() 改变函数执行环境的情况下,this 就会指向其他对象。

var name = "The Window";

var object = {
    name : "My Object",

    getNameFunc : function(){
        return function(){
            return this.name;
        };
    }
};

alert(object.getNameFunc()());  //"The Window"(在非严格模式下)


ThisObjectExample01.htm

以上代码先创建了一个全局变量name ,又创建了一个包含name 属性的对象。这个对象还包含一个方法——getNameFunc() ,它返回一个匿名函数,而匿名函数又返回this.name 。由于getNameFunc() 返回一个函数,因此调用object.getNameFunc()() 就会立即调用它返回的函数,结果就是返回一个字符串。然而,这个例子返回的字符串是"The Window" ,即全局name 变量的值。为什么匿名函数没有取得其包含作用域(或外部作用域)的this 对象呢?

前面曾经提到过,每个函数在被调用时,其活动对象都会自动取得两个特殊变量:thisarguments 。内部函数在搜索这两个变量时,只会搜索到其活动对象为止,因此永远不可能直接访问外部函数中的这两个变量(这一点通过图7-2可以看得更清楚)。不过,把外部作用域中的this 对象保存在一个闭包能够访问到的变量里,就可以让闭包访问该对象了,如下所示。

var name = "The Window";

var object = {
    name : "My Object",

    getNameFunc : function(){
        var that = this;


        return function(){
            return that.name;


        };
    }
};

alert(object.getNameFunc()());  //"My Object"

ThisObjectExample02.htm

代码中突出的行展示了这个例子与前一个例子之间的不同之处。在定义匿名函数之前,我们把this 对象赋值给了一个名叫that 的变量。而在定义了闭包之后,闭包也可以访问这个变量,因为它是我们在包含函数中特意声名的一个变量。即使在函数返回之后,that 也仍然引用着object ,所以调用object.getNameFunc()() 就返回了"My Object"

thisarguments 也存在同样的问题。如果想访问作用域中的arguments 对象,必须将对该对象的引用保存到另一个闭包能够访问的变量中。

在几种特殊情况下,this 的值可能会意外地改变。比如,下面的代码是修改前面例子的结果。

var name = "The Window";

var object = {
    name : "My Object",

    getName: function(){


        return this.name;


    }


};

这里的getName() 方法只简单地返回this.name 的值。以下是几种调用object.getName() 的方式以及各自的结果。

object.getName();   //"My Object"
(object.getName)(); //"My Object"
(object.getName = object.getName)(); //"The Window",在非严格模式下


ThisObjectExample03.htm

第一行代码跟平常一样调用了object.getName() ,返回的是"My Object" ,因为this.name 就是object.name 。第二行代码在调用这个方法前先给它加上了括号。虽然加上括号之后,就好像只是在引用一个函数,但this 的值得到了维持,因为object.getName(object.getName) 的定义是相同的。第三行代码先执行了一条赋值语句,然后再调用赋值后的结果。因为这个赋值表达式的值是函数本身,所以this 的值不能得到维持,结果就返回了"The Window"

当然,你不大可能会像第二行和第三行代码一样调用这个方法。不过,这个例子有助于说明即使是语法的细微变化,都有可能意外改变this 的值。

7.2.3 内存泄漏

由于IE9之前的版本对JScript对象和COM对象使用不同的垃圾收集例程(第4章曾经讨论过),因此闭包在IE的这些版本中会导致一些特殊的问题。具体来说,如果闭包的作用域链中保存着一个HTML元素,那么就意味着该元素将无法被销毁。来看下面的例子。

function assignHandler(){
    var element = document.getElementById("someElement");
    element.onclick = function(){
        alert(element.id);
    };
}


以上代码创建了一个作为element 元素事件处理程序的闭包,而这个闭包则又创建了一个循环引用(事件将在第13章讨论)。由于匿名函数保存了一个对assignHandler() 的活动对象的引用,因此就会导致无法减少element 的引用数。只要匿名函数存在,element 的引用数至少也是1,因此它所占用的内存就永远不会被回收。不过,这个问题可以通过稍微改写一下代码来解决,如下所示。

function assignHandler(){
    var element = document.getElementById("someElement");
    var id = element.id;



    element.onclick = function(){
        alert(id);


    };

    element = null;


}

在上面的代码中,通过把element.id 的一个副本保存在一个变量中,并且在闭包中引用该变量消除了循环引用。但仅仅做到这一步,还是不能解决内存泄漏的问题。必须要记住:闭包会引用包含函数的整个活动对象,而其中包含着element 。即使闭包不直接引用element ,包含函数的活动对象中也仍然会保存一个引用。因此,有必要把element 变量设置为null 。这样就能够解除对DOM对象的引用,顺利地减少其引用数,确保正常回收其占用的内存。

如前所述,JavaScript没有块级作用域的概念。这意味着在块语句中定义的变量,实际上是在包含函数中而非语句中创建的,来看下面的例子。

function outputNumbers(count){
    for (var i=0; i < count; i++){
        alert(i);
    }
    alert(i);   //计数
}


BlockScopeExample01.htm

这个函数中定义了一个for 循环,而变量i 的初始值被设置为0。在Java、C++等语言中,变量i 只会在for 循环的语句块中有定义,循环一旦结束,变量i 就会被销毁。可是在JavaScrip中,变量i 是定义在ouputNumbers() 的活动对象中的,因此从它有定义开始,就可以在函数内部随处访问它。即使像下面这样错误地重新声明同一个变量,也不会改变它的值。

function outputNumbers(count){
    for (var i=0; i < count; i++){
        alert(i);
    }

    var i;         //重新声明变量


    alert(i);      //计数
}

BlockScopeExample02.htm

JavaScript从来不会告诉你是否多次声明了同一个变量;遇到这种情况,它只会对后续的声明视而不见(不过,它会执行后续声明中的变量初始化)。匿名函数可以用来模仿块级作用域并避免这个问题。

用作块级作用域(通常称为私有作用域 )的匿名函数的语法如下所示。

(function(){
    //这里是块级作用域
})();


以上代码定义并立即调用了一个匿名函数。将函数声明包含在一对圆括号中,表示它实际上是一个函数表达式。而紧随其后的另一对圆括号会立即调用这个函数。如果有读者感觉这种语法不太好理解,可以再看看下面这个例子。

var count = 5;
outputNumbers(count);


这里初始化了变量count ,将其值设置为5。当然,这里的变量是没有必要的,因为可以把值直接传给函数。为了让代码更简洁,我们在调用函数时用5来代替变量count ,如下所示。

outputNumbers(5);


这样做之所以可行,是因为变量只不过是值的另一种表现形式,因此用实际的值替换变量没有问题。再看下面的例子。

var someFunction = function(){
    //这里是块级作用域
};
someFunction();


这个例子先定义了一个函数,然后立即调用了它。定义函数的方式是创建一个匿名函数,并把匿名函数赋值给变量someFunction 。而调用函数的方式是在函数名称后面添加一对圆括号,即someFunction() 。通过前面的例子我们知道,可以使用实际的值来取代变量count ,那在这里是不是也可以用函数的值直接取代函数名呢? 然而,下面的代码却会导致错误。

function(){
    //这里是块级作用域
}();    //出错!


这段代码会导致语法错误,是因为JavaScript将function 关键字当作一个函数声明的开始,而函数声明后面不能跟圆括号。然而,函数表达式 的后面可以跟圆括号。要将函数声明转换成函数表达式,只要像下面这样给它加上一对圆括号即可。

(function(){
    //这里是块级作用域
})();


无论在什么地方,只要临时需要一些变量,就可以使用私有作用域,例如:

function outputNumbers(count){


    (function () {
        for (var i=0; i < count; i++){
            alert(i);
        }
    })();



    alert(i);   //导致一个错误!
}

BlockScopeExample03.htm

在这个重写后的outputNumbers() 函数中,我们在for 循环外部插入了一个私有作用域。在匿名函数中定义的任何变量,都会在执行结束时被销毁。因此,变量i 只能在循环中使用,使用后即被销毁。而在私有作用域中能够访问变量count ,是因为这个匿名函数是一个闭包,它能够访问包含作用域中的所有变量。

这种技术经常在全局作用域中被用在函数外部,从而限制向全局作用域中添加过多的变量和函数。一般来说,我们都应该尽量少向全局作用域中添加变量和函数。在一个由很多开发人员共同参与的大型应用程序中,过多的全局变量和函数很容易导致命名冲突。而通过创建私有作用域,每个开发人员既可以使用自己的变量,又不必担心搞乱全局作用域。例如:

(function(){

    var now = new Date();
    if (now.getMonth() == 0 && now.getDate() == 1){
        alert("Happy new year!");
    }

})();


把上面这段代码放在全局作用域中,可以用来确定哪一天是1月1日;如果到了这一天,就会向用户显示一条祝贺新年的消息。其中的变量now 现在是匿名函数中的局部变量,而我们不必在全局作用域中创建它。

这种做法可以减少闭包占用的内存问题,因为没有指向匿名函数的引用。只要函数执行完毕,就可以立即销毁其作用域链了。

严格来讲,JavaScript中没有私有成员的概念;所有对象属性都是公有的。不过,倒是有一个私有变量的概念。任何在函数中定义的变量,都可以认为是私有变量,因为不能在函数的外部访问这些变量。私有变量包括函数的参数、局部变量和在函数内部定义的其他函数。来看下面的例子:

function add(num1, num2){
    var sum = num1 + num2;
    return sum;
}


在这个函数内部,有3个私有变量:num1num2sum 。在函数内部可以访问这几个变量,但在函数外部则不能访问它们。如果在这个函数内部创建一个闭包,那么闭包通过自己的作用域链也可以访问这些变量。而利用这一点,就可以创建用于访问私有变量的公有方法。

我们把有权访问私有变量和私有函数的公有方法称为特权方法 (privileged method)。有两种在对象上创建特权方法的方式。第一种是在构造函数中定义特权方法,基本模式如下。

function MyObject(){

    //私有变量和私有函数
    var privateVariable = 10;

    function privateFunction(){
        return false;
    }

    //特权方法
    this.publicMethod = function (){
        privateVariable++;
        return privateFunction();
    };
}


这个模式在构造函数内部定义了所有私有变量和函数。然后,又继续创建了能够访问这些私有成员的特权方法。能够在构造函数中定义特权方法,是因为特权方法作为闭包有权访问在构造函数中定义的所有变量和函数。对这个例子而言,变量privateVariable 和函数privateFunction() 只能通过特权方法publicMethod() 来访问。在创建MyObject 的实例后,除了使用publicMethod() 这一个途径外,没有任何办法可以直接访问privateVariableprivateFunction()

利用私有和特权成员,可以隐藏那些不应该被直接修改的数据,例如:

function Person(name){

    this.getName = function(){
        return name;
    };

    this.setName = function (value) {
        name = value;
    };
}

var person = new Person("Nicholas");
alert(person.getName());   //"Nicholas"
person.setName("Greg");
alert(person.getName());   //"Greg"


PrivilegedMethodExample01.htm

以上代码的构造函数中定义了两个特权方法:getName()setName() 。这两个方法都可以在构造函数外部使用,而且都有权访问私有变量name 。但在Person 构造函数外部,没有任何办法访问name 。由于这两个方法是在构造函数内部定义的,它们作为闭包能够通过作用域链访问name 。私有变量namePerson 的每一个实例中都不相同,因为每次调用构造函数都会重新创建这两个方法。不过,在构造函数中定义特权方法也有一个缺点,那就是你必须使用构造函数模式来达到这个目的。第6章曾经讨论过,构造函数模式的缺点是针对每个实例都会创建同样一组新方法,而使用静态私有变量来实现特权方法就可以避免这个问题。

7.4.1 静态私有变量

通过在私有作用域中定义私有变量或函数,同样也可以创建特权方法,其基本模式如下所示。

(function(){

    //私有变量和私有函数
    var privateVariable = 10;

    function privateFunction(){
        return false;
    }

    //构造函数
    MyObject = function(){
    };

    //公有/特权方法
    MyObject.prototype.publicMethod = function(){
        privateVariable++;
        return privateFunction();
    };

})();


这个模式创建了一个私有作用域,并在其中封装了一个构造函数及相应的方法。在私有作用域中,首先定义了私有变量和私有函数,然后又定义了构造函数及其公有方法。公有方法是在原型上定义的,这一点体现了典型的原型模式。需要注意的是,这个模式在定义构造函数时并没有使用函数声明,而是使用了函数表达式。函数声明只能创建局部函数,但那并不是我们想要的。出于同样的原因,我们也没有在声明MyObject 时使用var 关键字。记住:初始化未经声明的变量,总是会创建一个全局变量。因此,MyObject 就成了一个全局变量,能够在私有作用域之外被访问到。但也要知道,在严格模式下给未经声明的变量赋值会导致错误。

这个模式与在构造函数中定义特权方法的主要区别,就在于私有变量和函数是由实例共享的。由于特权方法是在原型上定义的,因此所有实例都使用同一个函数。而这个特权方法,作为一个闭包,总是保存着对包含作用域的引用。来看一看下面的代码。

(function(){

    var name = "";

    Person = function(value){
        name = value;
    };

    Person.prototype.getName = function(){
        return name;
    };

    Person.prototype.setName = function (value){
        name = value;
    };
})();

var person1 = new Person("Nicholas");
alert(person1.getName());  //"Nicholas"
person1.setName("Greg");
alert(person1.getName());  //"Greg"

var person2 = new Person("Michael");
alert(person1.getName()); //"Michael"
alert(person2.getName()); //"Michael"


PrivilegedMethodExample02.htm

这个例子中的Person 构造函数与getName()setName() 方法一样,都有权访问私有变量name 。在这种模式下,变量name 就变成了一个静态的、由所有实例共享的属性。也就是说,在一个实例上调用setName() 会影响所有实例。而调用setName() 或新建一个Person 实例都会赋予name 属性一个新值。结果就是所有实例都会返回相同的值。

以这种方式创建静态私有变量会因为使用原型而增进代码复用,但每个实例都没有自己的私有变量。到底是使用实例变量,还是静态私有变量,最终还是要视你的具体需求而定。

多查找作用域链中的一个层次,就会在一定程度上影响查找速度。而这正是使用闭包和私有变量的一个显明的不足之处。

7.4.2 模块模式

前面的模式是用于为自定义类型创建私有变量和特权方法的。而道格拉斯所说的模块模式(module pattern)则是为单例创建私有变量和特权方法。所谓单例(singleton),指的就是只有一个实例的对象。按照惯例,JavaScript是以对象字面量的方式来创建单例对象的。

var singleton = {
    name : value,
    method : function () {
        //这里是方法的代码
    }
};


模块模式通过为单例添加私有变量和特权方法能够使其得到增强,其语法形式如下:

var singleton = function(){

    //私有变量和私有函数
    var privateVariable = 10;

    function privateFunction(){
        return false;
    }

    //特权/公有方法和属性
    return {

        publicProperty: true,

        publicMethod : function(){
            privateVariable++;
            return privateFunction();
        }

    };
}();


这个模块模式使用了一个返回对象的匿名函数。在这个匿名函数内部,首先定义了私有变量和函数。然后,将一个对象字面量作为函数的值返回。返回的对象字面量中只包含可以公开的属性和方法。由于这个对象是在匿名函数内部定义的,因此它的公有方法有权访问私有变量和函数。从本质上来讲,这个对象字面量定义的是单例的公共接口。这种模式在需要对单例进行某些初始化,同时又需要维护其私有变量时是非常有用的,例如:

var application = function(){

    //私有变量和函数
    var components = new Array();

    //初始化
    components.push(new BaseComponent());

    //公共
    return {
        getComponentCount : function(){
            return components.length;
        },

        registerComponent : function(component){
            if (typeof component == "object"){
                components.push(component);
            }
        }
    };
}();


ModulePatternExample01.htm

在Web应用程序中,经常需要使用一个单例来管理应用程序级的信息。这个简单的例子创建了一个用于管理组件的application 对象。在创建这个对象的过程中,首先声明了一个私有的components 数组,并向数组中添加了一个BaseComponent 的新实例(在这里不需要关心BaseComponent 的代码,我们只是用它来展示初始化操作)。而返回对象的getComponentCount()registerComponent() 方法,都是有权访问数组components 的特权方法。前者只是返回已注册的组件数目,后者用于注册新组件。

简言之,如果必须创建一个对象并以某些数据对其进行初始化,同时还要公开一些能够访问这些私有数据的方法,那么就可以使用模块模式。以这种模式创建的每个单例都是Object 的实例,因为最终要通过一个对象字面量来表示它。事实上,这也没有什么;毕竟,单例通常都是作为全局对象存在的,我们不会将它传递给一个函数。因此,也就没有什么必要使用instanceof 操作符来检查其对象类型了。

7.4.3 增强的模块模式

有人进一步改进了模块模式,即在返回对象之前加入对其增强的代码。这种增强的模块模式适合那些单例必须是某种类型的实例,同时还必须添加某些属性和(或)方法对其加以增强的情况。来看下面的例子。

var singleton = function(){

    //私有变量和私有函数
    var privateVariable = 10;

    function privateFunction(){
        return false;
    }

    //创建对象
    var object = new CustomType();

    //添加特权/公有属性和方法
    object.publicProperty = true;

    object.publicMethod = function(){
        privateVariable++;
        return privateFunction();
    };

    //返回这个对象
    return object;
}();


如果前面演示模块模式的例子中的application 对象必须是BaseComponent 的实例,那么就可以使用以下代码。

var application = function(){

    //私有变量和函数
    var components = new Array();

    //初始化
    components.push(new BaseComponent());

    //创建application的一个局部副本
    var app = new BaseComponent();

    //公共接口
    app.getComponentCount = function(){
        return components.length;
    };

    app.registerComponent = function(component){
        if (typeof component == "object"){
            components.push(component);
        }
    };

    //返回这个副本
    return app;
}();


ModuleAugmentationPatternExample01.htm

在这个重写后的应用程序(application)单例中,首先也是像前面例子中一样定义了私有变量。主要的不同之处在于命名变量app 的创建过程,因为它必须是BaseComponent 的实例。这个实例实际上是application 对象的局部变量版。此后,我们又为app 对象添加了能够访问私有变量的公有方法。最后一步是返回app 对象,结果仍然是将它赋值给全局变量application

在JavaScript编程中,函数表达式是一种非常有用的技术。使用函数表达式可以无须对函数命名,从而实现动态编程。匿名函数,也称为拉姆达函数,是一种使用JavaScript函数的强大方式。以下总结了函数表达式的特点。

当在函数内部定义了其他函数时,就创建了闭包。闭包有权访问包含函数内部的所有变量,原理如下。

使用闭包可以在JavaScript中模仿块级作用域(JavaScript本身没有块级作用域的概念),要点如下。

闭包还可以用于在对象中创建私有变量,相关概念和要点如下。

JavaScript中的函数表达式和闭包都是极其有用的特性,利用它们可以实现很多功能。不过,因为创建闭包必须维护额外的作用域,所以过度使用它们可能会占用大量内存。


第8章 BOM

本章内容

ECMAScript是JavaScript的核心,但如果要在Web中使用JavaScript,那么BOM(浏览器对象模型)则无疑才是真正的核心。BOM提供了很多对象,用于访问浏览器的功能,这些功能与任何网页内容无关。多年来,缺少事实上的规范导致BOM既有意思又有问题,因为浏览器提供商会按照各自的想法随意去扩展它。于是,浏览器之间共有的对象就成为了事实上的标准。这些对象在浏览器中得以存在,很大程度上是由于它们提供了与浏览器的互操作性。W3C为了把浏览器中JavaScript最基本的部分标准化,已经将BOM的主要方面纳入了HTML5的规范中。

BOM的核心对象是window ,它表示浏览器的一个实例。在浏览器中,window 对象有双重角色,它既是通过JavaScript访问浏览器窗口的一个接口,又是ECMAScript规定的Global 对象。这意味着在网页中定义的任何一个对象、变量和函数,都以window 作为其Global 对象,因此有权访问parseInt() 等方法。

8.1.1 全局作用域

由于window 对象同时扮演着ECMAScript中Global 对象的角色,因此所有在全局作用域中声明的变量、函数都会变成window 对象的属性和方法。来看下面的例子。

var age = 29;
function sayAge(){
    alert(this.age);
}

alert(window.age);      //29
sayAge();               //29
window.sayAge();        //29


我们在全局作用域中定义了一个变量age 和一个函数sayAge() ,它们被自动归在了window 对象名下。于是,可以通过window.age 访问变量age ,可以通过window.sayAge() 访问函数sayAge() 。由于sayAge() 存在于全局作用域中,因此this.age 被映射到window.age ,最终显示的仍然是正确的结果。

抛开全局变量会成为window 对象的属性不谈,定义全局变量与在window 对象上直接定义属性还是有一点差别:全局变量不能通过delete 操作符删除,而直接在window 对象上的定义的属性可以。例如:

var age = 29;
window.color = "red";

//在IE < 9 时抛出错误,在其他所有浏览器中都返回false 
delete window.age;

//在IE < 9 时抛出错误,在其他所有浏览器中都返回true
delete window.color; //returns true

alert(window.age);   //29
alert(window.color); //undefined


DeleteOperatorExample01.htm

刚才使用var 语句添加的window 属性有一个名为[[Configurable]] 的特性,这个特性的值被设置为false ,因此这样定义的属性不可以通过delete 操作符删除。IE8及更早版本在遇到使用delete 删除window 属性的语句时,不管该属性最初是如何创建的,都会抛出错误,以示警告。IE9及更高版本不会抛出错误。

另外,还要记住一件事:尝试访问未声明的变量会抛出错误,但是通过查询window 对象,可以知道某个可能未声明的变量是否存在。例如:

//这里会抛出错误,因为oldValue未定义
var newValue = oldValue;

//这里不会抛出错误,因为这是一次属性查询
//newValue的值是undefined
var newValue = window.oldValue;


本章后面将要讨论的很多全局JavaScript对象(如locationnavigator )实际上都是window 对象的属性。

Windows Mobile平台的IE浏览器不允许通过window.property = value 之类的形式,直接在window 对象上创建新的属性或方法。可是,在全局作用域中声明的所有变量和函数,照样会变成window 对象的成员。

8.1.2 窗口关系及框架

如果页面中包含框架,则每个框架都拥有自己的window 对象,并且保存在frames 集合中。在frames 集合中,可以通过数值索引(从0开始,从左至右,从上到下)或者框架名称来访问相应的window 对象。每个window 对象都有一个name 属性,其中包含框架的名称。下面是一个包含框架的页面:

<html>
    <head>
        <title>Frameset Example</title>
    </head>
    <frameset rows="160,*">
        <frame src="frame.htm" name="topFrame">
        <frameset cols="50%,50%">
            <frame src="anotherframe.htm" name="leftFrame">
            <frame src="yetanotherframe.htm" name="rightFrame">
        </frameset>
    </frameset>
</html>


FramesetExample01.htm

以上代码创建了一个框架集,其中一个框架居上,两个框架居下。对这个例子而言,可以通过window.frames[0] 或者window.frames["topFrame"] 来引用上方的框架。不过,恐怕你最好使用top 而非window 来引用这些框架(例如,通过top.frames[0] )。

我们知道,top 对象始终指向最高(最外)层的框架,也就是浏览器窗口。使用它可以确保在一个框架中正确地访问另一个框架。因为对于在一个框架中编写的任何代码来说,其中的window 对象指向的都是那个框架的特定实例,而非最高层的框架。图8-1展示了在最高层窗口中,通过代码来访问前面例子中每个框架的不同方式。

图 8-1

top 相对的另一个window 对象是parent 。顾名思义,parent (父)对象始终指向当前框架的直接上层框架。在某些情况下,parent 有可能等于top ;但在没有框架的情况下,parent 一定等于top (此时它们都等于window )。再看下面的例子。

<html>
    <head>
        <title>Frameset Example</title>
    </head>
    <frameset rows="100,*">
        <frame src="frame.htm" name="topFrame">
        <frameset cols="50%,50%">
            <frame src="anotherframe.htm" name="leftFrame">
            <frame src="anotherframeset.htm" name="rightFrame">


        </frameset>
    </frameset>
</html>

frameset1.htm

这个框架集中的一个框架包含了另一个框架集,该框架集的代码如下所示。

<html>
    <head>
        <title>Frameset Example</title>
    </head>
    <frameset cols="50%,50%">
        <frame src="red.htm" name="redFrame">
        <frame src="blue.htm" name="blueFrame">
    </frameset>
</html>


anotherframeset.htm

浏览器在加载完第一个框架集以后,会继续将第二个框架集加载到rightFrame 中。如果代码位于redFrame (或blueFrame )中,那么parent 对象指向的就是rightFrame 。可是,如果代码位于topFrame 中,则parent 指向的是top ,因为topFrame 的直接上层框架就是最外层框架。图8-2展示了在将前面例子加载到浏览器之后,不同window 对象的值。

图 8-2

注意,除非最高层窗口是通过window.open() 打开的(本章后面将会讨论),否则其window 对象的name 属性不会包含任何值。

与框架有关的最后一个对象是self ,它始终指向window ;实际上,selfwindow 对象可以互换使用。引入self 对象的目的只是为了与topparent 对象对应起来,因此它不格外包含其他值。

所有这些对象都是window 对象的属性,可以通过window.parentwindow.top 等形式来访问。同时,这也意味着可以将不同层次的window 对象连缀起来,例如window.parent.parent.frames[0]

在使用框架的情况下,浏览器中会存在多个Global 对象。在每个框架中定义的全局变量会自动成为框架中window 对象的属性。由于每个window 对象都包含原生类型的构造函数,因此每个框架都有一套自己的构造函数,这些构造函数一一对应,但并不相等。例如,top.Object 并不等于top.frames[0].Object 。这个问题会影响到对跨框架传递的对象使用instanceof 操作符。

8.1.3 窗口位置

用来确定和修改window 对象位置的属性和方法有很多。IE、Safari、Opera和Chrome都提供了screenLeftscreenTop 属性,分别用于表示窗口相对于屏幕左边和上边的位置。Firefox则在screenXscreenY 属性中提供相同的窗口位置信息,Safari和Chrome也同时支持这两个属性。Opera虽然也支持screenXscreenY 属性,但与screenLeftscreenTop 属性并不对应,因此建议大家不要在Opera中使用它们。使用下列代码可以跨浏览器取得窗口左边和上边的位置。

var leftPos = (typeof window.screenLeft == "number") ?
                  window.screenLeft : window.screenX;
var topPos = (typeof window.screenTop == "number") ?
                  window.screenTop : window.screenY;


WindowPositionExample01.htm

这个例子运用二元操作符首先确定screenLeftscreenTop 属性是否存在,如果是(在IE、Safari、Opera和Chrome中),则取得这两个属性的值。如果不存在(在Firefox中),则取得screenXscreenY 的值。

在使用这些值的过程中,还必须注意一些小问题。在IE、Opera和Chrome中,screenLeft和screenTop中保存的是从屏幕左边和上边到由window 对象表示的页面可见区域的距离。换句话说,如果window 对象是最外层对象,而且浏览器窗口紧贴屏幕最上端——即y 轴坐标为0,那么screenTop 的值就是位于页面可见区域上方的浏览器工具栏的像素高度。但是,在Firefox和Safari中,screenYscreenTop 中保存的是整个浏览器窗口相对于屏幕的坐标值,即在窗口的y 轴坐标为0时返回0。

更让人捉摸不透是,Firefox、Safari和Chrome始终返回页面中每个框架的top.screenXtop.screenY 值。即使在页面由于被设置了外边距而发生偏移的情况下,相对于window 对象使用screenXscreenY 每次也都会返回相同的值。而IE和Opera则会给出框架相对于屏幕边界的精确坐标值。

最终结果,就是无法在跨浏览器的条件下取得窗口左边和上边的精确坐标值。然而,使用moveTo()moveBy() 方法倒是有可能将窗口精确地移动到一个新位置。这两个方法都接收两个参数,其中moveTo() 接收的是新位置的xy 坐标值,而moveBy() 接收的是在水平和垂直方向上移动的像素数。下面来看几个例子:

//将窗口移动到屏幕左上角
window.moveTo(0,0);

//将窗向下移动100像素
window.moveBy(0,100);

//将窗口移动到(200,300)
window.moveTo(200,300);

//将窗口向左移动50像素
window.moveBy(-50,0);


需要注意的是,这两个方法可能会被浏览器禁用;而且,在Opera和IE 7(及更高版本)中默认就是禁用的。另外,这两个方法都不适用于框架,只能对最外层的window 对象使用。

8.1.4 窗口大小

跨浏览器确定一个窗口的大小不是一件简单的事。IE9+、Firefox、Safari、Opera和Chrome均为此提供了4个属性:innerWidthinnerHeightouterWidthouterHeight 。在IE9+、Safari和Firefox中,outerWidthouterHeight 返回浏览器窗口本身的尺寸(无论是从最外层的window 对象还是从某个框架访问)。在Opera中,这两个属性的值表示页面视图容器1 的大小。而innerWidthinnerHeight 则表示该容器中页面视图区的大小(减去边框宽度)。在Chrome中,outerWidthouterHeightinnerWidthinnerHeight 返回相同的值,即视口(viewport)大小而非浏览器窗口大小。

1 这里所谓的“页面视图容器”指的是Opera中单个标签页对应的浏览器窗口。

IE8及更早版本没有提供取得当前浏览器窗口尺寸的属性;不过,它通过DOM提供了页面可见区域的相关信息。

在IE、Firefox、Safari、Opera和Chrome中,document.documentElement.clientWidthdocument.documentElement.clientHeight 中保存了页面视口的信息。在IE6中,这些属性必须在标准模式下才有效;如果是混杂模式,就必须通过document.body.clientWidthdocument.body. clientHeight 取得相同信息。而对于混杂模式下的Chrome,则无论通过document.documentElement 还是document.body 中的clientWidthclientHeight 属性,都可以取得视口的大小。

虽然最终无法确定浏览器窗口本身的大小,但却可以取得页面视口的大小,如下所示。

var pageWidth = window.innerWidth,
    pageHeight = window.innerHeight;

if (typeof pageWidth != "number"){
    if (document.compatMode == "CSS1Compat"){
        pageWidth = document.documentElement.clientWidth;
        pageHeight = document.documentElement.clientHeight;
    } else {
        pageWidth = document.body.clientWidth;
        pageHeight = document.body.clientHeight;
    }
}


WindowSizeExample01.htm

在以上代码中,我们首先将window.innerWidthwindow.innerHeight 的值分别赋给了pageWidthpageHeight 。然后检查pageWidth 中保存的是不是一个数值;如果不是,则通过检查document.compatMode (这个属性将在第10章全面讨论)来确定页面是否处于标准模式。如果是,则分别使用document.documentElement.clientWidthdocument.documentElement.clientHeight 的值。否则,就使用document.body.clientWidthdocument.body.clientHeight 的值。

对于移动设备,window.innerWidthwindow.innerHeight 保存着可见视口,也就是屏幕上可见页面区域的大小。移动IE浏览器不支持这些属性,但通过document.documentElement.clientWidthdocument.documentElement.clientHeihgt 提供了相同的信息。随着页面的缩放,这些值也会相应变化。

在其他移动浏览器中,document.documentElement 度量的是布局视口,即渲染后页面的实际大小(与可见视口不同,可见视口只是整个页面中的一小部分)。移动IE浏览器把布局视口的信息保存在document.body.clientWidthdocument.body.clientHeight 中。这些值不会随着页面缩放变化。

由于与桌面浏览器间存在这些差异,最好是先检测一下用户是否在使用移动设备,然后再决定使用哪个属性。

有关移动设备视口的话题比较复杂,有很多非常规的情形,也有各种各样的建议。移动开发咨询师Peter-Paul Koch记述了他对这个问题的研究:http://t.cn/zOZs0Tz 。如果你在做移动Web开发,推荐你读一读这篇文章。

另外,使用resizeTo()resizeBy() 方法可以调整浏览器窗口的大小。这两个方法都接收两个参数,其中resizeTo() 接收浏览器窗口的新宽度和新高度,而resizeBy() 接收新窗口与原窗口的宽度和高度之差。来看下面的例子。

//调整到100×100
window.resizeTo(100, 100);

//调整到200×150
window.resizeBy(100, 50);

//调整到 300×300
window.resizeTo(300, 300);


需要注意的是,这两个方法与移动窗口位置的方法类似,也有可能被浏览器禁用;而且,在Opera和IE7(及更高版本)中默认就是禁用的。另外,这两个方法同样不适用于框架,而只能对最外层的window 对象使用。

8.1.5 导航和打开窗口

使用window.open() 方法既可以导航到一个特定的URL,也可以打开一个新的浏览器窗口。这个方法可以接收4个参数:要加载的URL、窗口目标、一个特性字符串以及一个表示新页面是否取代浏览器历史记录中当前加载页面的布尔值。通常只须传递第一个参数,最后一个参数只在不打开新窗口的情况下使用。

如果为window.open() 传递了第二个参数,而且该参数是已有窗口或框架的名称,那么就会在具有该名称的窗口或框架中加载第一个参数指定的URL。看下面的例子。

//等同于< a href="http://www.wrox.com" target="topFrame"></a>
window.open("http://www.wrox.com/", topFrame");


调用这行代码,就如同用户单击了href 属性为http://www.wrox.com/target 属性为"topFrame" 的链接。如果有一个名叫"topFrame" 的窗口或者框架,就会在该窗口或框架加载这个URL;否则,就会创建一个新窗口并将其命名为"topFrame" 。此外,第二个参数也可以是下列任何一个特殊的窗口名称:_self_parent_top_blank

1. 弹出窗口

如果给window.open() 传递的第二个参数并不是一个已经存在的窗口或框架,那么该方法就会根据在第三个参数位置上传入的字符串创建一个新窗口或新标签页。如果没有传入第三个参数,那么就会打开一个带有全部默认设置(工具栏、地址栏和状态栏等)的新浏览器窗口(或者打开一个新标签页——根据浏览器设置)。在不打开新窗口的情况下,会忽略第三个参数。

第三个参数是一个逗号分隔的设置字符串,表示在新窗口中都显示哪些特性。下表列出了可以出现在这个字符串中的设置选项。

设  置 说  明
fullscreen yesno 表示浏览器窗口是否最大化。仅限IE
height 数值 表示新窗口的高度。不能小于100
left 数值 表示新窗口的左坐标。不能是负值
location yesno 表示是否在浏览器窗口中显示地址栏。不同浏览器的默认值不同。如果设置为no ,地址栏可能会隐藏,也可能会被禁用(取决于浏览器)
menubar yesno 表示是否在浏览器窗口中显示菜单栏。默认值为no
resizable yesno 表示是否可以通过拖动浏览器窗口的边框改变其大小。默认值为no
scrollbars yesno 表示如果内容在视口中显示不下,是否允许滚动。默认值为no
status yesno 表示是否在浏览器窗口中显示状态栏。默认值为no
toolbar yesno 表示是否在浏览器窗口中显示工具栏。默认值为no
top 数值 表示新窗口的上坐标。不能是负值
width 数值 表示新窗口的宽度。不能小于100

表中所列的部分或全部设置选项,都可以通过逗号分隔的名值对列表来指定。其中,名值对以等号表示(注意,整个特性字符串中不允许出现空格),如下面的例子所示。

window.open("http://www.wrox.com/","wroxWindow",
            "height=400,width=400,top=10,left=10,resizable=yes");


这行代码会打开一个新的可以调整大小的窗口,窗口初始大小为400×400像素,并且距屏幕上沿和左边各10像素。

window.open() 方法会返回一个指向新窗口的引用。引用的对象与其他window 对象大致相似,但我们可以对其进行更多控制。例如,有些浏览器在默认情况下可能不允许我们针对主浏览器窗口调整大小或移动位置,但却允许我们针对通过window.open() 创建的窗口调整大小或移动位置。通过这个返回的对象,可以像操作其他窗口一样操作新打开的窗口,如下所示。

var wroxWin = window.open("http://www.wrox.com/","wroxWindow",
                          "height=400,width=400,top=10,left=10,resizable=yes");

//调整大小
wroxWin.resizeTo(500,500);

//移动位置
wroxWin.moveTo(100,100);


调用close() 方法还可以关闭新打开的窗口。

wroxWin.close();


但是,这个方法仅适用于通过window.open() 打开的弹出窗口。对于浏览器的主窗口,如果没有得到用户的允许是不能关闭它的。不过,弹出窗口倒是可以调用top.close() 在不经用户允许的情况下关闭自己。弹出窗口关闭之后,窗口的引用仍然还在,但除了像下面这样检测其closed 属性之外,已经没有其他用处了。

wroxWin.close();
alert(wroxWin.closed);  //true


新创建的window 对象有一个opener 属性,其中保存着打开它的原始窗口对象。这个属性只在弹出窗口中的最外层window 对象(top )中有定义,而且指向调用window.open() 的窗口或框架。例如:

var wroxWin = window.open("http://www.wrox.com/","wroxWindow",
                          "height=400,width=400,top=10,left=10,resizable=yes");

alert(wroxWin.opener == window);   //true


虽然弹出窗口中有一个指针指向打开它的原始窗口,但原始窗口中并没有这样的指针指向弹出窗口。窗口并不跟踪记录它们打开的弹出窗口,因此我们只能在必要的时候自己来手动实现跟踪。

有些浏览器(如IE8和Chrome)会在独立的进程中运行每个标签页。当一个标签页打开另一个标签页时,如果两个window 对象之间需要彼此通信,那么新标签页就不能运行在独立的进程中。在Chrome中,将新创建的标签页的opener 属性设置为null ,即表示在单独的进程中运行新标签页,如下所示。

var wroxWin = window.open("http://www.wrox.com/","wroxWindow",
                          "height=400,width=400,top=10,left=10,resizable=yes");

wroxWin.opener = null;


opener 属性设置为null 就是告诉浏览器新创建的标签页不需要与打开它的标签页通信,因此可以在独立的进程中运行。标签页之间的联系一旦切断,将没有办法恢复。

2. 安全限制

曾经有一段时间,广告商在网上使用弹出窗口达到了肆无忌惮的程度。他们经常把弹出窗口打扮成系统对话框的模样,引诱用户去点击其中的广告。由于看起来像是系统对话框,一般用户很难分辨是真是假。为了解决这个问题,有些浏览器开始在弹出窗口配置方面增加限制。

Windows XP SP2中的IE6对弹出窗口施加了多方面的安全限制,包括不允许在屏幕之外创建弹出窗口、不允许将弹出窗口移动到屏幕以外、不允许关闭状态栏等。IE7则增加了更多的安全限制,如不允许关闭地址栏、默认情况下不允许移动弹出窗口或调整其大小。Firefox 1从一开始就不支持修改状态栏,因此无论给window.open() 传入什么样的特性字符串,弹出窗口中都会无一例外地显示状态栏。后来的Firefox 3又强制始终在弹出窗口中显示地址栏。Opera只会在主浏览器窗口中打开弹出窗口,但不允许它们出现在可能与系统对话框混淆的地方。

此外,有的浏览器只根据用户操作来创建弹出窗口。这样一来,在页面尚未加载完成时调用window.open() 的语句根本不会执行,而且还可能会将错误消息显示给用户。换句话说,只能通过单击或者击键来打开弹出窗口。

对于那些不是用户有意打开的弹出窗口,Chrome采取了不同的处理方式。它不会像其他浏览器那样简单地屏蔽这些弹出窗口,而是只显示它们的标题栏,并把它们放在浏览器窗口的右下角。

在打开计算机硬盘中的网页时,IE会解除对弹出窗口的某些限制。但是在服务器上执行这些代码会受到对弹出窗口的限制。

3. 弹出窗口屏蔽程序

大多数浏览器都内置有弹出窗口屏蔽程序,而没有内置此类程序的浏览器,也可以安装Yahoo! Toolbar等带有内置屏蔽程序的实用工具。结果就是用户可以将绝大多数不想看到弹出窗口屏蔽掉。于是,在弹出窗口被屏蔽时,就应该考虑两种可能性。如果是浏览器内置的屏蔽程序阻止的弹出窗口,那么window.open() 很可能会返回null 。此时,只要检测这个返回的值就可以确定弹出窗口是否被屏蔽了,如下面的例子所示。

var wroxWin = window.open("http://www.wrox.com", "_blank");
if (wroxWin == null){
    alert("The popup was blocked!");
}


如果是浏览器扩展或其他程序阻止的弹出窗口,那么window.open() 通常会抛出一个错误。因此,要想准确地检测出弹出窗口是否被屏蔽,必须在检测返回值的同时,将对window.open() 的调用封装在一个try-catch 块中,如下所示。

var blocked = false;



try {


    var wroxWin = window.open("http://www.wrox.com", "_blank");
    if (wroxWin == null){
        blocked = true;


    }
} catch (ex){


    blocked = true;


}



if (blocked){


    alert("The popup was blocked!");


}


PopupBlockerExample01.htm

在任何情况下,以上代码都可以检测出调用window.open() 打开的弹出窗口是不是被屏蔽了。但要注意的是,检测弹出窗口是否被屏蔽只是一方面,它并不会阻止浏览器显示与被屏蔽的弹出窗口有关的消息。

8.1.6 间歇调用和超时调用

JavaScript是单线程语言,但它允许通过设置超时值和间歇时间值来调度代码在特定的时刻执行。前者是在指定的时间过后执行代码,而后者则是每隔指定的时间就执行一次代码。

超时调用需要使用window 对象的setTimeout() 方法,它接受两个参数:要执行的代码和以毫秒表示的时间(即在执行代码前需要等待多少毫秒)。其中,第一个参数可以是一个包含JavaScript代码的字符串(就和在eval() 函数中使用的字符串一样),也可以是一个函数。例如,下面对setTimeout() 的两次调用都会在一秒钟后显示一个警告框。

//不建议传递字符串!
setTimeout("alert('Hello world!') ", 1000);

//推荐的调用方式
setTimeout(function() { 
    alert("Hello world!"); 
}, 1000);


TimeoutExample01.htm

虽然这两种调用方式都没有问题,但由于传递字符串可能导致性能损失,因此不建议以字符串作为第一个参数。

第二个参数是一个表示等待多长时间的毫秒数,但经过该时间后指定的代码不一定会执行。JavaScript是一个单线程序的解释器,因此一定时间内只能执行一段代码。为了控制要执行的代码,就有一个JavaScript任务队列。这些任务会按照将它们添加到队列的顺序执行。setTimeout() 的第二个参数告诉JavaScript再过多长时间把当前任务添加到队列中。如果队列是空的,那么添加的代码会立即执行;如果队列不是空的,那么它就要等前面的代码执行完了以后再执行。

调用setTimeout() 之后,该方法会返回一个数值ID,表示超时调用。这个超时调用ID是计划执行代码的唯一标识符,可以通过它来取消超时调用。要取消尚未执行的超时调用计划,可以调用clearTimeout() 方法并将相应的超时调用ID作为参数传递给它,如下所示。

//设置超时调用
var timeoutId = setTimeout(function() {
    alert("Hello world!");
}, 1000);

//注意:把它取消
clearTimeout(timeoutId);


TimeoutExample02.htm

只要是在指定的时间尚未过去之前调用clearTimeout() ,就可以完全取消超时调用。前面的代码在设置超时调用之后马上又调用了clearTimeout() ,结果就跟什么也没有发生一样。

超时调用的代码都是在全局作用域中执行的,因此函数中this 的值在非严格模式下指向window 对象,在严格模式下是undefined

间歇调用与超时调用类似,只不过它会按照指定的时间间隔重复执行代码,直至间歇调用被取消或者页面被卸载。设置间歇调用的方法是setInterval() ,它接受的参数与setTimeout() 相同:要执行的代码(字符串或函数)和每次执行之前需要等待的毫秒数。下面来看一个例子。

//不建议传递字符串!
setInterval ("alert('Hello world!') ", 10000);

//推荐的调用方式
setInterval (function() { 
    alert("Hello world!"); 
}, 10000);


IntervalExample01.htm

调用setInterval() 方法同样也会返回一个间歇调用ID,该ID可用于在将来某个时刻取消间歇调用。要取消尚未执行的间歇调用,可以使用clearInterval() 方法并传入相应的间歇调用ID。取消间歇调用的重要性要远远高于取消超时调用,因为在不加干涉的情况下,间歇调用将会一直执行到页面卸载。以下是一个常见的使用间歇调用的例子。

var num = 0;
var max = 10;
var intervalId = null;

function incrementNumber() {
    num++;

    //如果执行次数达到了max设定的值,则取消后续尚未执行的调用
    if (num == max) {
        clearInterval(intervalId);
        alert("Done");
    }
}

intervalId = setInterval(incrementNumber, 500);


IntervalExample02.htm

在这个例子中,变量num 每半秒钟递增一次,当递增到最大值时就会取消先前设定的间歇调用。这个模式也可以使用超时调用来实现,如下所示。

var num = 0;
var max = 10;

function incrementNumber() {
    num++;

    //如果执行次数未达到max设定的值,则设置另一次超时调用


    if (num < max) {


        setTimeout(incrementNumber, 500);


    } else {


        alert("Done");


    }


}

setTimeout(incrementNumber, 500);


TimeoutExample03.htm

可见,在使用超时调用时,没有必要跟踪超时调用ID,因为每次执行代码之后,如果不再设置另一次超时调用,调用就会自行停止。一般认为,使用超时调用来模拟间歇调用的是一种最佳模式。在开发环境下,很少使用真正的间歇调用,原因是后一个间歇调用可能会在前一个间歇调用结束之前启动。而像前面示例中那样使用超时调用,则完全可以避免这一点。所以,最好不要使用间歇调用。

8.1.7 系统对话框

浏览器通过alert()confirm()prompt() 方法可以调用系统对话框向用户显示消息。系统对话框与在浏览器中显示的网页没有关系,也不包含HTML。它们的外观由操作系统及(或)浏览器设置决定,而不是由CSS决定。此外,通过这几个方法打开的对话框都是同步和模态的。也就是说,显示这些对话框的时候代码会停止执行,而关掉这些对话框后代码又会恢复执行。

本书各章经常会用到alert() 方法,这个方法接受一个字符串并将其显示给用户。具体来说,调用alert() 方法的结果就是向用户显示一个系统对话框,其中包含指定的文本和一个OK(“确定”)按钮。例如,alert("Hello world!") 会在Windows XP系统的IE中生成如图8-3所示的对话框。

通常使用alert() 生成的“警告”对话框向用户显示一些他们无法控制的消息,例如错误消息。而用户只能在看完消息后关闭对话框。

第二种对话框是调用confirm() 方法生成的。从向用户显示消息的方面来看,这种“确认”对话框很像是一个“警告”对话框。但二者的主要区别在于“确认”对话框除了显示OK按钮外,还会显示一个Cancel(“取消”)按钮,两个按钮可以让用户决定是否执行给定的操作。例如,confirm("Are you sure?") 会显示如图8-4所示的确认对话框。

图 8-3 图 8-4

为了确定用户是单击了OK还是Cancel,可以检查confirm() 方法返回的布尔值:true 表示单击了OK,false 表示单击了Cancel或单击了右上角的X按钮。确认对话框的典型用法如下。

if (confirm("Are you sure?")) {
    alert("I'm so glad you're sure! ");
} else {
    alert("I'm sorry to hear you're not sure. ");
}


在这个例子中,第一行代码(if条件语句)会向用户显示一个确认对话框。如果用户单击了OK,则通过一个警告框向用户显示消息I’m so glad you’ re sure! 。如果用户单击的是Cancel按钮,则通过警告框显示I’m sorry to hear you’re not sure.。这种模式经常在用户想要执行删除操作的时候使用,例如删除电子邮件。

最后一种对话框是通过调用prompt() 方法生成的,这是一个“提示”框,用于提示用户输入一些文本。提示框中除了显示OK和Cancel按钮之外,还会显示一个文本输入域,以供用户在其中输入内容。prompt() 方法接受两个参数:要显示给用户的文本提示和文本输入域的默认值(可以是一个空字符串)。调用prompt("What's your name?","Michael") 会得到如图8-5所示的对话框。

图 8-5

如果用户单击了OK按钮,则prompt() 返回文本输入域的值;如果用户单击了Cancel或没有单击OK而是通过其他方式关闭了对话框,则该方法返回null 。下面是一个例子。

var result = prompt("What is your name? ", "");
if (result !== null) {
    alert("Welcome, " + result);
}


综上所述,这些系统对话框很适合向用户显示消息并请用户作出决定。由于不涉及HTML、CSS或JavaScript,因此它们是增强Web应用程序的一种便捷方式。

除了上述三种对话框之外,Google Chrome浏览器还引入了一种新特性。如果当前脚本在执行过程中会打开两个或多个对话框,那么从第二个对话框开始,每个对话框中都会显示一个复选框,以便用户阻止后续的对话框显示,除非用户刷新页面(见图8-6)。

图 8-6

如果用户勾选了其中的复选框,并且关闭了对话框,那么除非用户刷新页面,所有后续的系统对话框(包括警告框、确认框和提示框)都会被屏蔽。Chrome没有就对话框是否显示向开发人员提供任何信息。由于浏览器会在空闲时重置对话框计数器,因此如果两次独立的用户操作分别打开两个警告框,那么这两个警告框中都不会显示复选框。而如果是同一次用户操作会生成两个警告框,那么第二个警告框中就会显示复选框。这个新特性出现以后,IE9和Firefox 4也实现了它。

还有两个可以通过JavaScript打开的对话框,即“查找”和“打印”。这两个对话框都是异步显示的,能够将控制权立即交还给脚本。这两个对话框与用户通过浏览器菜单的“查找”和“打印”命令打开的对话框相同。而在JavaScript中则可以像下面这样通过window 对象的find()print() 方法打开它们。

//显示“打印”对话框
window.print();

//显示“查找”对话框
window.find();


这两个方法同样不会就用户在对话框中的操作给出任何信息,因此它们的用处有限。另外,既然这两个对话框是异步显示的,那么Chrome的对话框计数器就不会将它们计算在内,所以它们也不会受用户禁用后续对话框显示的影响。

location 是最有用的BOM对象之一,它提供了与当前窗口中加载的文档有关的信息,还提供了一些导航功能。事实上,location 对象是很特别的一个对象,因为它既是window 对象的属性,也是document 对象的属性;换句话说,window.locationdocument.location 引用的是同一个对象。location 对象的用处不只表现在它保存着当前文档的信息,还表现在它将URL解析为独立的片段,让开发人员可以通过不同的属性访问这些片段。下表列出了location 对象的所有属性(注:省略了每个属性前面的location 前缀)。

属 性 名 例  子 说  明
hash "#contents" 返回URL中的hash (#号后跟零或多个字符),如果URL中不包含散列,则返回空字符串
host "www.wrox.com:80" 返回服务器名称和端口号(如果有)
hostname "www.wrox.com" 返回不带端口号的服务器名称
href "http:/www.wrox.com" 返回当前加载页面的完整URL。而location 对象的toString() 方法也返回这个值
pathname "/WileyCDA/" 返回URL中的目录和(或)文件名
port "8080" 返回URL中指定的端口号。如果URL中不包含端口号,则这个属性返回空字符串
protocol "http:" 返回页面使用的协议。通常是http:https:
search "?q=javascript" 返回URL的查询字符串。这个字符串以问号开头

8.2.1 查询字符串参数

虽然通过上面的属性可以访问到location 对象的大多数信息,但其中访问URL包含的查询字符串的属性并不方便。尽管location.search 返回从问号到URL末尾的所有内容,但却没有办法逐个访问其中的每个查询字符串参数。为此,可以像下面这样创建一个函数,用以解析查询字符串,然后返回包含所有参数的一个对象:

function getQueryStringArgs(){

    //取得查询字符串并去掉开头的问号
    var qs = (location.search.length > 0 ? location.search.substring(1) : ""),

    //保存数据的对象
    args = {},

    //取得每一项
    items = qs.length ? qs.split("&") : [],
    item = null,
        name = null,
        value = null,

        //在for循环中使用
        i = 0,
        len = items.length;

    //逐个将每一项添加到args对象中
    for (i=0; i < len; i++){
        item = items[i].split("=");
        name = decodeURIComponent(item[0]);
        value = decodeURIComponent(item[1]);

        if (name.length) {
            args[name] = value;
        }
    }

    return args;
}


LocationExample01.htm

这个函数的第一步是先去掉查询字符串开头的问号。当然,前提是location.search 中必须要包含一或多个字符。然后,所有参数将被保存在args 对象中,该对象以字面量形式创建。接下来,根据和号(&)来分割查询字符串,并返回name=value 格式的字符串数组。下面的for 循环会迭代这个数组,然后再根据等于号分割每一项,从而返回第一项为参数名,第二项为参数值的数组。再使用decodeURIComponent() 分别解码namevalue (因为查询字符串应该是被编码过的)。最后,将name 作为args 对象的属性,将value 作为相应属性的值。下面给出了使用这个函数的示例。

//假设查询字符串是?q=javascript&num=10

var args = getQueryStringArgs();

alert(args["q"]);   //"javascript"
alert(args["num"]); //"10"


可见,每个查询字符串参数都成了返回对象的属性。这样就极大地方便了对每个参数的访问。

8.2.2 位置操作

使用location 对象可以通过很多方式来改变浏览器的位置。首先,也是最常用的方式,就是使用assign() 方法并为其传递一个URL,如下所示。

location.assign("http://www.wrox.com");


这样,就可以立即打开新URL并在浏览器的历史记录中生成一条记录。如果是将location.hrefwindow.location 设置为一个URL值,也会以该值调用assign() 方法。例如,下列两行代码与显式调用assign() 方法的效果完全一样。

window.location = "http://www.wrox.com";
location.href = "http://www.wrox.com";


在这些改变浏览器位置的方法中,最常用的是设置location.href 属性。

另外,修改location 对象的其他属性也可以改变当前加载的页面。下面的例子展示了通过将hashsearchhostnamepathnameport 属性设置为新值来改变URL。

//假设初始URL为http://www.wrox.com/WileyCDA/

//将URL修改为"http://www.wrox.com/WileyCDA/#section1"
location.hash = "#section1";

//将URL修改为"http://www.wrox.com/WileyCDA/?q=javascript"
location.search = "?q=javascript";

//将URL修改为"http://www.yahoo.com/WileyCDA/"
location.hostname = "www.yahoo.com";

//将URL修改为"http://www.yahoo.com/mydir/"
location.pathname = "mydir";

//将URL修改为"http://www.yahoo.com:8080/WileyCDA/"
location.port = 8080;


每次修改location 的属性(hash 除外),页面都会以新URL重新加载。

在IE8、Firefox 1、Safari 2+、Opera 9+和Chrome中,修改hash 的值会在浏览器的历史记录中生成一条新记录。在IE的早期版本中,hash 属性不会在用户单击“后退”和“前进”按钮时被更新,而只会在用户单击包含hash 的URL时才会被更新。

当通过上述任何一种方式修改URL之后,浏览器的历史记录中就会生成一条新记录,因此用户通过单击“后退”按钮都会导航到前一个页面。要禁用这种行为,可以使用replace() 方法。这个方法只接受一个参数,即要导航到的URL;结果虽然会导致浏览器位置改变,但不会在历史记录中生成新记录。在调用replace() 方法之后,用户不能回到前一个页面,来看下面的例子:

<!DOCTYPE html>
<html>
<head>
    <title>You won't be able to get back here</title>
</head>
    <body>
    <p>Enjoy this page for a second, because you won't be coming back here.</p>
    <script type="text/javascript">
        setTimeout(function () {
            location.replace("http://www.wrox.com/");
        }, 1000);
    </script>
</body>
</html>


LocationReplaceExample01.htm

如果将这个页面加载到浏览器中,浏览器就会在1秒钟后重新定向到www.wrox.com 。然后,“后退”按钮将处于禁用状态,如果不重新输入完整的URL,则无法返回示例页面。

与位置有关的最后一个方法是reload() ,作用是重新加载当前显示的页面。如果调用reload() 时不传递任何参数,页面就会以最有效的方式重新加载。也就是说,如果页面自上次请求以来并没有改变过,页面就会从浏览器缓存中重新加载。如果要强制从服务器重新加载,则需要像下面这样为该方法传递参数true

location.reload();        //重新加载(有可能从缓存中加载)
location.reload(true);    //重新加载(从服务器重新加载)


位于reload() 调用之后的代码可能会也可能不会执行,这要取决于网络延迟或系统资源等因素。为此,最好将reload() 放在代码的最后一行。

最早由Netscape Navigator 2.0引入的navigator 对象,现在已经成为识别客户端浏览器的事实标准。虽然其他浏览器也通过其他方式提供了相同或相似的信息(例如,IE中的window.clientInformation 和Opera中的window.opera ),但navigator 对象却是所有支持JavaScript的浏览器所共有的。与其他BOM对象的情况一样,每个浏览器中的navigator 对象也都有一套自己的属性。下表列出了存在于所有浏览器中的属性和方法,以及支持它们的浏览器版本。

属性或方法 说  明 IE Firefox Safari/
Chrome
Opera
appCodeName 浏览器的名称。通常都是Mozilla ,即使在非Mozilla浏览器中也是如此 3.0+ 1.0+ 1.0+ 7.0+
appMinorVersion 次版本信息 4.0+ 9.5+
appName 完整的浏览器名称 3.0+ 1.0+ 1.0+ 7.0+
appVersion 浏览器的版本。一般不与实际的浏览器版本对应 3.0+ 1.0+ 1.0+ 7.0+
buildID 浏览器编译版本 2.0+
cookieEnabled 表示cookie是否启用 4.0+ 1.0+ 1.0+ 7.0+
cpuClass 客户端计算机中使用的CPU类型(x86、68K、Alpha、PPC或Other) 4.0+
javaEnabled() 表示当前浏览器中是否启用了Java 4.0+ 1.0+ 1.0+ 7.0+
language 浏览器的主语言 1.0+ 1.0+ 7.0+
mimeTypes 在浏览器中注册的MIME类型数组 4.0+ 1.0+ 1.0+ 7.0+
onLine 表示浏览器是否连接到了因特网 4.0+ 1.0+ 9.5+
opsProfile 似乎早就不用了。查不到相关文档 4.0+
oscpu 客户端计算机的操作系统或使用的CPU 1.0+
Platform 浏览器所在的系统平台 4.0+ 1.0+ 1.0+ 7.0+
plugins 浏览器中安装的插件信息的数组 4.0+ 1.0+ 1.0+ 7.0+
preference() 设置用户的首选项 1.5+
product 产品名称(如 Gecko) 1.0+ 1.0+
productSub 关于产品的次要信息(如Gecko 的版本) 1.0+ 1.0+
register-
ContentHandler()
针对特定的MIME类型将一个站点注册为处理程序 2.0+
register-
ProtocolHandler()
针对特定的协议将一个站点注册为处理程序 2.0
securityPolicy 已经废弃。安全策略的名称。为了与Netscape Navigator 4向后兼容而保留下来 1.0+
systemLanguage 操作系统的语言 4.0+
taintEnabled() 已经废弃。表示是否允许变量被修改(taint)。为了与Netscape Navigator 3向后兼容而保留下来 4.0+ 1.0+ 7.0+
userAgent 浏览器的用户代理字符串 3.0+ 1.0+ 1.0+ 7.0+
userLanguage 操作系统的默认语言 4.0+ 7.0+
userProfile 借以访问用户个人信息的对象 4.0+
vendor 浏览器的品牌 1.0+ 1.0+
vendorSub 有关供应商的次要信息 1.0+ 1.0+

表中的这些navigator 对象的属性通常用于检测显示网页的浏览器类型(第9章会详细讨论)。

8.3.1 检测插件

检测浏览器中是否安装了特定的插件是一种最常见的检测例程。对于非IE浏览器,可以使用plugins 数组来达到这个目的。该数组中的每一项都包含下列属性。

一般来说,name 属性中会包含检测插件必需的所有信息,但有时候也不完全如此。在检测插件时,需要像下面这样循环迭代每个插件并将插件的name 与给定的名字进行比较。

//检测插件(在IE中无效)
function hasPlugin(name){
    name = name.toLowerCase();
    for (var i=0; i < navigator.plugins.length; i++){
        if (navigator. plugins [i].name.toLowerCase().indexOf(name) > -1){
            return true;
        }
    }

    return false;
}

//检测Flash
alert(hasPlugin("Flash"));

//检测QuickTime
alert(hasPlugin("QuickTime"));


PluginDetectionExample01.htm

这个hasPlugin() 函数接受一个参数:要检测的插件名。第一步是将传入的名称转换为小写形式,以便于比较。然后,迭代plugins 数组,通过indexOf() 检测每个name 属性,以确定传入的名称是否出现在字符串的某个地方。比较的字符串都使用小写形式可以避免因大小写不一致导致的错误。而传入的参数应该尽可能具体,以避免混淆。应该说,像FlashQuickTime 这样的字符串就比较具体了,不容易导致混淆。在Firefox、Safari、Opera和Chrome中可以使用这种方法来检测插件。

每个插件对象本身也是一个MimeType 对象的数组,这些对象可以通过方括号语法来访问。每个MimeType 对象有4个属性:包含MIME类型描述的description 、回指插件对象的enabledPlugin 、表示与MIME类型对应的文件扩展名的字符串suffixes (以逗号分隔)和表示完整MIME类型字符串的type

检测IE中的插件比较麻烦,因为IE不支持Netscape式的插件。在IE 中检测插件的唯一方式就是使用专有的ActiveXObject 类型,并尝试创建一个特定插件的实例。IE是以COM对象的方式实现插件的,而COM对象使用唯一标识符来标识。因此,要想检查特定的插件,就必须知道其COM标识符。例如,Flash的标识符是ShockwaveFlash.ShockwaveFlash 。知道唯一标识符之后,就可以编写类似下面的函数来检测IE中是否安装相应插件了。

//检测IE中的插件
function hasIEPlugin(name){
    try {
        new ActiveXObject(name);
        return true;
    } catch (ex){
        return false;
    }
}

//检测Flash
alert(hasIEPlugin("ShockwaveFlash.ShockwaveFlash"));

//检测QuickTime
alert(hasIEPlugin("QuickTime.QuickTime"));


PluginDetectionExample02.htm

在这个例子中,函数hasIEPlugin() 只接收一个COM标识符作为参数。在函数内部,首先会尝试创建一个COM对象的实例。之所以要在try-catch 语句中进行实例化,是因为创建未知COM对象会导致抛出错误。这样,如果实例化成功,则函数返回true ;否则,如果抛出了错误,则执行catch 块,结果就会返回false 。例子最后检测IE中是否安装了Flash和QuickTime插件。

鉴于检测这两种插件的方法差别太大,因此典型的做法是针对每个插件分别创建检测函数,而不是使用前面介绍的通用检测方法。来看下面的例子。

//检测所有浏览器中的Flash
function hasFlash(){
    var result = hasPlugin("Flash");
    if (!result){
        result = hasIEPlugin("ShockwaveFlash.ShockwaveFlash");
    }
    return result;
}

//检测所有浏览器中的QuickTime
function hasQuickTime(){
    var result = hasPlugin("QuickTime");
    if (!result){
        result = hasIEPlugin("QuickTime.QuickTime");
    }
    return result;
}

//检测Flash
alert(hasFlash());

//检测QuickTime
alert(hasQuickTime());


PluginDetectionExample03.htm

上面代码中定义了两个函数:hasFlash()hasQuickTime() 。每个函数都是先尝试使用不针对IE的插件检测方法。如果返回了false (在IE中会这样),那么再使用针对IE的插件检测方法。如果IE的插件检测方法再返回false ,则整个方法也将返回false 。只要任何一次检测返回true ,整个方法都会返回true

plugins 集合有一个名叫refresh() 的方法,用于刷新plugins 以反映最新安装的插件。这个方法接收一个参数:表示是否应该重新加载页面的一个布尔值。如果将这个值设置为true ,则会重新加载包含插件的所有页面;否则,只更新plugins 集合,不重新加载页面。

8.3.2 注册处理程序

Firefox 2为navigator 对象新增了registerContentHandler()registerProtocolHandler() 方法(这两个方法是在HTML5中定义的,相关内容将在第22章讨论)。这两个方法可以让一个站点指明它可以处理特定类型的信息。随着RSS阅读器和在线电子邮件程序的兴起,注册处理程序就为像使用桌面应用程序一样默认使用这些在线应用程序提供了一种方式。

其中,registerContentHandler() 方法接收三个参数:要处理的MIME类型、可以处理该MIME类型的页面的URL以及应用程序的名称。举个例子,要将一个站点注册为处理RSS源的处理程序,可以使用如下代码。

navigator.registerContentHandler("application/rss+xml",
    "http://www.somereader.com?feed=%s", "Some Reader");


第一个参数是RSS源的MIME类型。第二个参数是应该接收RSS源URL的URL,其中的%s 表示RSS源URL,由浏览器自动插入。当下一次请求RSS源时,浏览器就会打开指定的URL,而相应的Web应用程序将以适当方式来处理该请求。

Firefox 4及之前版本只允许在registerContentHandler() 方法中使用三个MIME类型:application/rss+xmlapplication/atom+xmlapplication/vnd.mozilla.maybe. feed 。这三个MIME类型的作用都一样,即为RSS或ATOM新闻源(feed)注册处理程序。

类似的调用方式也适用于registerProtocolHandler() 方法,它也接收三个参数:要处理的协议(例如,mailtoftp )、处理该协议的页面的URL和应用程序的名称。例如,要想将一个应用程序注册为默认的邮件客户端,可以使用如下代码。

navigator.registerProtocolHandler("mailto",
    "http://www.somemailclient.com?cmd=%s", "Some Mail Client");


这个例子注册了一个mailto 协议的处理程序,该程序指向一个基于Web的电子邮件客户端。同样,第二个参数仍然是处理相应请求的URL,而%s 则表示原始的请求。

Firefox 2虽然实现了registerProtocolHandler() ,但该方法还不能用。Firefox 3完整实现这个方法。

JavaScript中有几个对象在编程中用处不大,而screen 对象就是其中之一。screen 对象基本上只用来表明客户端的能力,其中包括浏览器窗口外部的显示器的信息,如像素宽度和高度等。每个浏览器中的screen 对象都包含着各不相同的属性,下表列出了所有属性及支持相应属性的浏览器。

属  性 说  明 IE Firefox Safari/
Chrome
Opera
availHeight 屏幕的像素高度减系统部件高度之后的值(只读)
availLeft 未被系统部件占用的最左侧的像素值(只读)
availTop 未被系统部件占用的最上方的像素值(只读)
availWidth 屏幕的像素宽度减系统部件宽度之后的值(只读)
bufferDepth 读、写用于呈现屏外位图的位数
colorDepth 用于表现颜色的位数;多数系统都是32(只读)
deviceXDPI 屏幕实际的水平DPI(只读)
deviceYDPI 屏幕实际的垂直DPI(只读)
fontSmooth-
ingEnabled
表示是否启用了字体平滑(只读)
height 屏幕的像素高度
left 当前屏幕距左边的像素距离
logicalXDPI 屏幕逻辑的水平DPI(只读)
logicalYDPI 屏幕逻辑的垂直DPI(只读)
pixelDepth 屏幕的位深(只读)
top 当前屏幕距上边的像素距离
updateInterval 读、写以毫秒表示的屏幕刷新时间间隔
width 屏幕的像素宽度

这些信息经常集中出现在测定客户端能力的站点跟踪工具中,但通常不会用于影响功能。不过,有时候也可能会用到其中的信息来调整浏览器窗口大小,使其占据屏幕的可用空间,例如:

window.resizeTo(screen.availWidth, screen.availHeight);


前面曾经提到过,许多浏览器都会禁用调整浏览器窗口大小的能力,因此上面这行代码不一定在所有环境下都有效。

涉及移动设备的屏幕大小时,情况有点不一样。运行iOS的设备始终会像是把设备竖着拿在手里一样,因此返回的值是768×1024。而Android设备则会相应调用screen.widthscreen.height 的值。

history 对象保存着用户上网的历史记录,从窗口被打开的那一刻算起。因为historywindow 对象的属性,因此每个浏览器窗口、每个标签页乃至每个框架,都有自己的history 对象与特定的window 对象关联。出于安全方面的考虑,开发人员无法得知用户浏览过的URL。不过,借由用户访问过的页面列表,同样可以在不知道实际URL的情况下实现后退和前进。

使用go() 方法可以在用户的历史记录中任意跳转,可以向后也可以向前。这个方法接受一个参数,表示向后或向前跳转的页面数的一个整数值。负数表示向后跳转(类似于单击浏览器的“后退”按钮),正数表示向前跳转(类似于单击浏览器的“前进”按钮)。来看下面的例子。

//后退一页
history.go(-1);

//前进一页
history.go(1);

//前进两页
history.go(2);


也可以给go() 方法传递一个字符串参数,此时浏览器会跳转到历史记录中包含该字符串的第一个位置——可能后退,也可能前进,具体要看哪个位置最近。如果历史记录中不包含该字符串,那么这个方法什么也不做,例如:

//跳转到最近的wrox.com页面
history.go("wrox.com");

//跳转到最近的nczonline.net页面
history.go("nczonline.net");


另外,还可以使用两个简写方法back()forward() 来代替go() 。顾名思义,这两个方法可以模仿浏览器的“后退”和“前进”按钮。

//后退一页
history.back();

//前进一页
history.forward();


除了上述几个方法外,history 对象还有一个length 属性,保存着历史记录的数量。这个数量包括所有历史记录,即所有向后和向前的记录。对于加载到窗口、标签页或框架中的第一个页面而言,history.length 等于0。通过像下面这样测试该属性的值,可以确定用户是否一开始就打开了你的页面。

if (history.length == 0){
    //这应该是用户打开窗口后的第一个页面
}


虽然history 并不常用,但在创建自定义的“后退”和“前进”按钮,以及检测当前页面是不是用户历史记录中的第一个页面时,还是必须使用它。

当页面的URL改变时,就会生成一条历史记录。在IE8及更高版本、Opera、Firefox、Safari 3及更高版本以及Chrome中,这里所说的改变包括URL中hash 的变化(因此,设置location.hash 会在这些浏览器中生成一条新的历史记录)。

浏览器对象模型(BOM)以window 对象为依托,表示浏览器窗口以及页面可见区域。同时,window 对象还是ECMAScript中的Global 对象,因而所有全局变量和函数都是它的属性,且所有原生的构造函数及其他函数也都存在于它的命名空间下。本章讨论了下列BOM的组成部分。

BOM中还有两个对象:screenhistory ,但它们的功能有限。screen 对象中保存着与客户端显示器有关的信息,这些信息一般只用于站点分析。history 对象为访问浏览器的历史记录开了一个小缝隙,开发人员可以据此判断历史记录的数量,也可以在历史记录中向后或向前导航到任意页面。


第9章 客户端检测

本章内容

浏览器提供商虽然在实现公共接口方面投入了很多精力,但结果仍然是每一种浏览器都有各自的长处,也都有各自的缺点。即使是那些跨平台的浏览器,虽然从技术上看版本相同,也照样存在不一致性问题。面对普遍存在的不一致性问题,开发人员要么采取迁就各方的“最小公分母”策略,要么(也是更常见的)就得利用各种客户端检测方法,来突破或者规避种种局限性。

迄今为止,客户端检测仍然是Web开发领域中一个饱受争议的话题。一谈到这个话题,人们总会不约而同地提到浏览器应该支持一组最常用的公共功能。在理想状态下,确实应该如此。但是,在现实当中,浏览器之间的差异以及不同浏览器的“怪癖”(quirk),多得简直不胜枚举。因此,客户端检测除了是一种补救措施之外,更是一种行之有效的开发策略。

检测Web客户端的手段很多,而且各有利弊。但最重要的还是要知道,不到万不得已,就不要使用客户端检测。只要能找到更通用的方法,就应该优先采用更通用的方法。一言以蔽之,先设计最通用的方案,然后再使用特定于浏览器的技术增强该方案。

最常用也最为人们广泛接受的客户端检测形式是能力检测 (又称特性检测)。能力检测的目标不是识别特定的浏览器,而是识别浏览器的能力。采用这种方式不必顾及特定的浏览器如何如何,只要确定浏览器支持特定的能力,就可以给出解决方案。能力检测的基本模式如下:

if (object.propertyInQuestion){
    //使用object.propertyInQuestion
}


举例来说,IE5.0之前的版本不支持document.getElementsById() 这个DOM方法。尽管可以使用非标准的document.all 属性实现相同的目的,但IE的早期版本中确实不存在document.getElementsById() 。于是,也就有了类似下面的能力检测代码:

function getElement(id){
    if (document.getElementById){
        return document.getElementById(id);
    } else if (document.all){
        return document.all[id];
    } else {
        throw new Error("No way to retrieve element!");
    }
}


这里的getElement() 函数的用途是返回具有给定ID的元素。因为document.getElementById() 是实现这一目的的标准方式,所以一开始就测试了这个方法。如果该函数存在(不是未定义),则使用该函数。否则,就要继续检测document.all 是否存在,如果是,则使用它。如果上述两个特性都不存在(很有可能),则创建并抛出错误,表示这个函数无法使用。

要理解能力检测,首先必须理解两个重要的概念。如前所述,第一个概念就是先检测达成目的的最常用的特性。对前面的例子来说,就是要先检测document.getElementById() ,后检测document.all 。先检测最常用的特性可以保证代码最优化,因为在多数情况下都可以避免测试多个条件。

第二个重要的概念就是必须测试实际要用到的特性。一个特性存在,不一定意味着另一个特性也存在。来看一个例子:

function getWindowWidth(){
    if (document.all){   //假设是IE
        return document.documentElement.clientWidth; //错误的用法!!!
    } else {
        return window.innerWidth;
    }
}


这是一个错误使用能力检测的例子。getWindowWidth() 函数首先检查document.all 是否存在,如果是则返回document.documentElement.clientWidth 。第8章曾经讨论过,IE8及之前版本确实不支持window.innerWidth 属性。但问题是document.all 存在也不一定表示浏览器就是IE。实际上,也可能是Opera;Opera支持document.all ,也支持window.innerWidth

9.1.1 更可靠的能力检测

能力检测对于想知道某个特性是否会按照适当方式行事(而不仅仅是某个特性存在)非常有用。上一节中的例子利用类型转换来确定某个对象成员是否存在,但这样你还是不知道该成员是不是你想要的。来看下面的函数,它用来确定一个对象是否支持排序。

//不要这样做!这不是能力检测——只检测了是否存在相应的方法
function isSortable(object){
    return !!object.sort;
} 


这个函数通过检测对象是否存在sort() 方法,来确定对象是否支持排序。问题是,任何包含sort 属性的对象也会返回true

var result = isSortable({ sort: true });


检测某个属性是否存在并不能确定对象是否支持排序。更好的方式是检测sort 是不是一个函数。

//这样更好:检查sort是不是函数
function isSortable(object){ 
    return typeof object.sort == "function";


}

这里的typeof 操作符用于确定sort 的确是一个函数,因此可以调用它对数据进行排序。

在可能的情况下,要尽量使用typeof 进行能力检测。特别是,宿主对象没有义务让typeof 返回合理的值。最令人发指的事儿就发生在IE中。大多数浏览器在检测到document.createElement() 存在时,都会返回true

 //在IE8及之前版本中不行 
function hasCreateElement(){
    return typeof document.createElement == "function";
}


在IE8及之前版本中,这个函数返回false ,因为typeof document.createElement 返回的是"object" ,而不是"function" 。如前所述,DOM对象是宿主对象,IE及更早版本中的宿主对象是通过COM而非JScript实现的。因此,document.createElement() 函数确实是一个COM对象,所以typeof 才会返回"object" 。IE9纠正了这个问题,对所有DOM方法都返回"function"

关于typeof的行为不标准,IE中还可以举出例子来。ActiveX对象(只有IE支持)与其他对象的行为差异很大。例如,不使用typeof 测试某个属性会导致错误,如下所示。

//在IE中会导致错误
var xhr = new ActiveXObject("Microsoft.XMLHttp");
if (xhr.open){    //这里会发生错误
    //执行操作
} 


像这样直接把函数作为属性访问会导致JavaScript错误。使用typeof 操作符会更靠谱一点,但IE对typeof xhr.open 会返回"unknown" 。这就意味着,在浏览器环境下测试任何对象的某个特性是否存在,要使用下面这个函数。

//作者:Peter Michaux
function isHostMethod(object, property) {
  var t = typeof object[property];
  return t=='function' ||
         (!!(t=='object' && object[property])) ||
          t=='unknown';
}


可以像下面这样使用这个函数:

result = isHostMethod(xhr, "open");     //true
result = isHostMethod(xhr, "foo");      //false


目前使用isHostMethod() 方法还是比较可靠的,因为它考虑到了浏览器的怪异行为。不过也要注意,宿主对象没有义务保持目前的实现方式不变,也不一定会模仿已有宿主对象的行为。所以,这个函数——以及其他类似函数,都不能百分之百地保证永远可靠。作为开发人员,必须对自己要使用某个功能的风险作出理性的估计。

要想深入了解围绕JavaScript中能力检测的一些观点,请参考Peter Michaux的文章“Feature Detection: State of the Art Browser Scripting”,网址为http://peter.michaux.ca/articles/feature-detection-state-of-the-art-browser-scripting

9.1.2 能力检测,不是浏览器检测

检测某个或某几个特性并不能够确定浏览器。下面给出的这段代码(或与之差不多的代码)可以在许多网站中看到,这种“浏览器检测”代码就是错误地依赖能力检测的典型示例。

//错误!还不够具体
var isFirefox = !!(navigator.vendor && navigator.vendorSub);

//错误!假设过头了
var isIE = !!(document.all && document.uniqueID);


这两行代码代表了对能力检测的典型误用。以前,确实可以通过检测navigator.vendornavigator.vendorSub 来确定Firefox浏览器。但是,Safari也依葫芦画瓢地实现了相同的属性。于是,这段代码就会导致人们作出错误的判断。为检测IE,代码测试了document.alldocument.uniqueID 。这就相当于假设IE将来的版本中仍然会继续存在这两个属性,同时还假设其他浏览器都不会实现这两个属性。最后,这两个检测都使用了双逻辑非操作符来得到布尔值(比先存储后访问的效果更好)。

实际上,根据浏览器不同将能力组合起来是更可取的方式。如果你知道自己的应用程序需要使用某些特定的浏览器特性,那么最好是一次性检测所有相关特性,而不要分别检测。看下面的例子。

//确定浏览器是否支持Netscape风格的插件
var hasNSPlugins = !!(navigator.plugins && navigator.plugins.length);

//确定浏览器是否具有DOM1级规定的能力
var hasDOM1 = !!(document.getElementById && document.createElement &&
               document.getElementsByTagName);


CapabilitiesDetectionExample01.htm

以上例子展示了两个检测:一个检测浏览器是否支持Netscapte风格的插件;另一个检测浏览器是否具备DOM1级所规定的能力。得到的布尔值可以在以后继续使用,从而节省重新检测能力的时间。

在实际开发中,应该将能力检测作为确定下一步解决方案的依据,而不是用它来判断用户使用的是什么浏览器。

与能力检测类似,怪癖检测 (quirks detection)的目标是识别浏览器的特殊行为。但与能力检测确认浏览器支持什么能力不同,怪癖检测是想要知道浏览器存在什么缺陷(“怪癖”也就是bug)。这通常需要运行一小段代码,以确定某一特性不能正常工作。例如,IE8及更早版本中存在一个bug,即如果某个实例属性与标记为[[DontEnum]] 的某个原型属性同名,那么该实例属性将不会出现在fon-in 循环当中。可以使用如下代码来检测这种“怪癖”。

var hasDontEnumQuirk = function(){

    var o = { toString : function(){} };
    for (var prop in o){
        if (prop == "toString"){
            return false;
        }
    }

    return true;
}();


QuirksDetectionExample01.htm

以上代码通过一个匿名函数来测试该“怪癖”,函数中创建了一个带有toString() 方法的对象。在正确的ECMAScript实现中,toString 应该在for-in 循环中作为属性返回。

另一个经常需要检测的“怪癖”是Safari 3以前版本会枚举被隐藏的属性。可以用下面的函数来检测该“怪癖”。

var hasEnumShadowsQuirk = function(){

    var o = { toString : function(){} };
    var count = 0;
    for (var prop in o){
        if (prop == "toString"){
            count++;
        }
    }

    return (count > 1);
}();


QuirksDetectionExample01.htm

如果浏览器存在这个bug,那么使用for-in 循环枚举带有自定义的toString() 方法的对象,就会返回两个toString 的实例。

一般来说,“怪癖”都是个别浏览器所独有的,而且通常被归为bug。在相关浏览器的新版本中,这些问题可能会也可能不会被修复。由于检测“怪癖”涉及运行代码,因此我们建议仅检测那些对你有直接影响的“怪癖”,而且最好在脚本一开始就执行此类检测,以便尽早解决问题。

第三种,也是争议最大的一种客户端检测技术叫做用户代理检测 。用户代理检测通过检测用户代理字符串来确定实际使用的浏览器。在每一次HTTP请求过程中,用户代理字符串是作为响应首部发送的,而且该字符串可以通过JavaScript的navigator.userAgent 属性访问。在服务器端,通过检测用户代理字符串来确定用户使用的浏览器是一种常用而且广为接受的做法。而在客户端,用户代理检测一般被当作一种万不得已才用的做法,其优先级排在能力检测和(或)怪癖检测之后。

提到与用户代理字符串有关的争议,就不得不提到电子欺骗 (spoofing)。所谓电子欺骗,就是指浏览器通过在自己的用户代理字符串加入一些错误或误导性信息,来达到欺骗服务器的目的。要弄清楚这个问题的来龙去脉,必须从Web问世初期用户代理字符串的发展讲起。

HTTP规范(包括1.0和1.1版)明确规定,浏览器应该发送简短的用户代理字符串,指明浏览器的名称和版本号。RFC 2616(即HTTP 1.1协议规范)是这样描述用户代理字符串的:

“产品标识符常用于通信应用程序标识自身,由软件名和版本组成。使用产品标识符的大多数领域也允许列出作为应用程序主要部分的子产品,由空格分隔。按照惯例,产品要按照相应的重要程度依次列出,以便标识应用程序。”

上述规范进一步规定,用户代理字符串应该以一组产品的形式给出,字符串格式为:标识符/产品版本号。但是,现实中的用户代理字符串则绝没有如此简单。

1. 早期的浏览器

1993年,美国NCSA(National Center for Supercomputing Applications,国家超级计算机中心)发布了世界上第一款Web浏览器Mosaic。这款浏览器的用户代理字符串非常简单,类似如下所示。

Mosaic/0.9


尽管这个字符串在不同操作系统和不同平台下会有所变化,但其基本格式还是简单明了的。正斜杠前面的文本表示产品名称(有时候会出现NCSA Mosaic或其他类似字样),而斜杠后面的文本是产品的版本号。

Netscape Communications公司介入浏览器开发领域后,遂将自己产品的代号定名为Mozilla(Mosaic Killer的简写,意即Mosaic杀手)。该公司第一个公开发行版,Netscape Navigator 2的用户代理字符串具有如下格式。

Mozilla/版本号 [语言] (平台; 加密类型)


Netscape在坚持将产品名和版本号作为用户代理字符串开头的基础上,又在后面依次添加了下列信息。

典型的Netscape Navigator 2的用户代理字符串如下所示。

Mozilla/2.02 [fr] (WinNT; I)


这个字符串表示浏览器是Netscape Navigator 2.02,为法语国家编译,运行在Windows NT平台下,加密类型为40位。那个时候,通过用户代理字符串中的产品名称,至少还能够轻易地确定用户使用的是什么浏览器。

2. Netscape Navigator 3和Internet Explorer 3

1996年,Netscape Navigator 3发布,随即超越Mosaic成为当时最流行的Web浏览器。而用户代理字符串只作了一些小的改变,删除了语言标记,同时允许添加操作系统或系统使用的CPU等可选信息。于是,格式变成如下所示。

Mozilla/版本号 (平台; 加密类型 [; 操作系统或CPU说明])


运行在Windows系统下的Netscape Navigator 3的用户代理字符串大致如下。

Mozilla/3.0 (Win95; U)


这个字符串表示Netscape Navigator 3运行在Windows 95中,采用了128位加密技术。可见,在Windows系统中,字符串中的操作系统或CPU说明被省略了。

Netscape Navigator 3发布后不久,微软也发布了其第一款赢得用户广泛认可的Web浏览器,即Internet Explorer 3。由于Netscape浏览器在当时占绝对市场份额,许多服务器在提供网页之前都要专门检测该浏览器。如果用户通过IE打不开相关网页,那么这个新生的浏览器很可能就会夭折。于是,微软决定将IE的用户代理字符串修改成兼容Netscape的形式,结果如下:

Mozilla/2.0 (compatible; MSIE 版本号; 操作系统)


例如,Windows 95平台下的Internet Explorer 3.02带有如下用户代理字符串:

Mozilla/2.0 (compatible; MSIE 3.02; Windows 95)


由于当时的大多数浏览器嗅探程序只检测用户代理字符串中的产品名称部分,结果IE就成功地将自己标识为Mozilla,从而伪装成Netscape Navigator。微软的这一做法招致了很多批评,因为它违反了浏览器标识的惯例。更不规范的是,IE将真正的浏览器版本号插入到了字符串的中间。

字符串中另外一个有趣的地方是标识符Mozilla 2.0(而不是3.0)。毕竟,当时的主流版本是3.0,改成3.0应该对微软更有利才对。但真正的谜底到现在还没有揭开——但很可能只是人为疏忽所致。

3. Netscape Communicator 4和IE4~IE8

1997年8月,Netscapte Communicator 4发布(这一版将浏览器名字中的Navigator换成了Commu- nicator)。Netscape继续遵循了第3版时的用户代理字符串格式:

Mozilla/版本号 (平台; 加密类型 [; 操作系统或CPU说明])


因此,Windows 98平台中第4版的用户代理字符串如下所示:

Mozilla/4.0 (Win98; I)


Netscape在发布补丁时,子版本号也会相应提高,用户代理字符串如下面的4.79版所示:

Mozilla/4.79 (Win98; I)


但是,微软在发布Internet Explorer 4时,顺便将用户代理字符串修改成了如下格式:

Mozilla/4.0 (compatible; MSIE 版本号; 操作系统)


换句话说,对于Windows 98中运行的IE4而言,其用户代理字符串为:

Mozilla/4.0 (compatible; MSIE 4.0; Windows 98)


经过此番修改,Mozilla版本号就与实际的IE版本号一致了,为识别它们的第四代浏览器提供了方便。但令人遗憾的是,两者的一致性仅限于这一个版本。在Internet Explorer 4.5发布时(只针对Macs),虽然Mozilla版本号还是4,但IE版本号则改成了如下所示:

Mozilla/4.0 (compatible; MSIE 4.5; Mac_PowerPC)


此后,IE的版本一直到7都沿袭了这个模式:

Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)


而IE8的用户代理字符串中添加了呈现引擎(Trident)的版本号:

Mozilla/4.0 (compatible; MSIE 版本号; 操作系统; Trident/Trident版本号)


例如:

Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0)


这个新增的Trident记号是为了让开发人员知道IE8是不是在兼容模式下运行。如果是,则MSIE的版本号会变成7,但Trident及版本号还会留在用户代码字符串中:

Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Trident/4.0) 


增加这个记号有助于分辨浏览器到底是IE7(没有Trident记号),还是运行在兼容模式下的IE8。

IE9对字符串格式做了一点调整。Mozilla版本号增加到了5.0,而Trident的版本号也升到了5.0。IE9默认的用户代理字符串如下:

Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)


如果IE9运行在兼容模式下,字符串中的Mozilla版本号和MSIE版本号会恢复旧的值,但Trident的版本号仍然是5.0。例如,下面就是IE9运行在IE7兼容模式下的用户代理字符串:

Mozilla/4.0

 (compatible; MSIE 7.0

; Windows NT 6.1; Trident/5.0)

所有这些变化都是为了确保过去的用户代理检测脚本能够继续发挥作用,同时还能给新脚本提供更丰富的信息。

4. Gecko

Gecko是Firefox的呈现引擎。当初的Gecko是作为通用Mozilla浏览器的一部分开发的,而第一个采用Gecko引擎的浏览器是Netscape 6。为Netscape 6编写的一份规范中规定了未来版本中用户代理字符串的构成。这个新格式与4.x版本中相对简单的字符串相比,有着非常大的区别,如下所示:

Mozilla/Mozilla版本号 (平台; 加密类型; 操作系统或CPU; 语言; 预先发行版本) 
     Gecko/Gecko版本号 应用程序或产品/应用程序或产品版本号


这个明显复杂了很多的用户代理字符串中蕴含很多新想法。下表列出了字符串中各项的用意。

字符串项 必需吗 说  明
Mozilla版本号 Mozilla的版本号
平台 浏览器运行的平台。可能的值包括Windows、Mac和X11(指Unix的X窗口系统)
加密类型 加密技术的类型:U表示128位、I表示40位、N表示未加密
操作系统或CPU 浏览器运行的操作系统或计算机系统使用的CPU。在Windows平台中,这一项指Windows的版本(如WinNT、Win95,等等)。如果平台是Macintosh,这一项指CPU(针对PowerPC的68K、PPC,或MacIntel)。如果平台是X11,这一项是Unix操作系统的名称,与使用Unix命令uname–sm 得到的名称相同
语言 浏览器设计时所针对的目标用户语言
预先发行版本 最初用于表示Mozilla的预先发行版本,现在则用来表示Gecko呈现引擎的版本号
Gecko版本号 Gecko呈现引擎的版本号,但由yyyymmdd格式的日期表示
应用程序或产品 使用Gecko的产品名。可能是Netscape、Firefox等
应用程序或产品版本号 应用程序或产品的版本号;用于区分Mozilla版本号和Gecko版本号

为了帮助读者更好地理解Gecko的用户代理字符串,下面我们来看几个从基于Gecko的浏览器中取得的字符串。

Windows XP下的Netscape 6.21:

Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:0.9.4) Gecko/20011128 Netscape6/6.2.1


Linux下的SeaMonkey 1.1a:

Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1b2) Gecko/20060823 SeaMonkey/1.1a


Windows XP下的Firefox 2.0.0.11:

Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.11) Gecko/20071127 Firefox/2.0.0.11


Mac OS X下的Camino 1.5.1:

Mozilla/5.0 (Macintosh; U; Intel Mac OS X; en; rv:1.8.1.6) Gecko/20070809 Camino/1.5.1


以上这些用户代理字符串都取自基于Gecko的浏览器(只是版本有所不同)。很多时候,检测特定的浏览器还不如搞清楚它是否基于Gecko更重要。每个字符串中的Mozilla版本都是5.0,自从第一个基于Gecko的浏览器发布时修改成这个样子,至今就没有改变过;而且,看起来以后似乎也不会有什么变化。

随着Firefox 4发布,Mozilla简化了这个用户代理字符串。主要改变包括以下几方面。

最后,Firefox 4用户代理字符串变成了下面这个样子:

Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox 4.0.1


5. WebKit

2003年,Apple公司宣布要发布自己的Web浏览器,名字定为Safari。Safari的呈现引擎叫WebKit,是Linux平台中Konqueror浏览器的呈现引擎KHTML的一个分支。几年后,WebKit独立出来成为了一个开源项目,专注于呈现引擎的开发。

这款新浏览器和呈现引擎的开发人员也遇到了与Internet Explorer 3.0类似的问题:如何确保这款浏览器不被流行的站点拒之门外?答案就是向用户代理字符串中放入足够多的信息,以便站点能够信任它与其他流行的浏览器是兼容的。于是,WebKit的用户代理字符串就具备了如下格式:

Mozilla/5.0 (平台; 加密类型; 操作系统或CPU; 语言) AppleWebKit/AppleWebKit版本号 
     (KHTML, like Gecko) Safari/Safari

版本号

以下就是一个示例:

Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en) AppleWebKit/124 (KHTML, like Gecko) 
     Safari/125.1


显然,这又是一个很长的用户代理字符串。其中不仅包含了Apple WebKit的版本号,也包含了Safari的版本号。出于兼容性的考虑,有关人员很快就决定了将Safari标识为Mozilla。至今,基于WebKit的所有浏览器都将自己标识为Mozilla 5.0,与基于Gecko的浏览器完全一样。但Safari的版本号则通常是浏览器的编译版本号,不一定与发布时的版本号对应。换句话说,虽然Safari 1.25的用户代理字符串中包含数字125.1,但两者却不一一对应。

Safari预发行1.0版用户代理字符串中最耐人寻味,也是最饱受诟病的部分就是字符串"(KHTML,like Gecko)" 。Apple因此收到许多开发人员的反馈,他们认为这个字符串明显是在欺骗客户端和服务器,实际上是想让它们把Safari当成Gecko(好像光添加Mozilla/5.0 还嫌不够)。Apple的回应与微软在IE的用户代理字符串遭到责难时如出一辙:Safari与Mozilla兼容,因此网站不应该将Safari用户拒之门外,否则用户就会认为自己的浏览器不受支持。

到了Safari 3.0发布时,其用户代理字符串又稍微变长了一点。下面这个新增的Version记号一直到现在都被用来标识Safari实际的版本号:

Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en) AppleWebKit/522.15.5 (KHTML, like 
     Gecko) Version/3.0.3

 Safari/522.15.5

需要注意的是,这个变化只在Safari中有,在WebKit中没有。换句话说,其他基于WebKit的浏览器可能没有这个变化。一般来说,确定浏览器是否基于WebKit要比确定它是不是Safari更有价值,就像针对Gecko一样。

6. Konqueror

与KDE Linux集成的Konqueror,是一款基于KHTML开源呈现引擎的浏览器。尽管Konqueror只能在Linux中使用,但它也有数量可观的用户。为确保最大限度的兼容性,Konqueror效仿IE选择了如下用户代理字符串格式:

Mozilla/5.0 (compatible; Konqueror/ 版本号; 操作系统或CPU )


不过,为了与WebKit的用户代理字符串的变化保持一致,Konqueror 3.2又有了变化,以如下格式将自己标识为KHTML:

Mozilla/5.0 (compatible; Konqueror/ 版本号; 操作系统或CPU) KHTML/ KHTML版本号 (like Gecko)


下面是一个例子:

Mozilla/5.0 (compatible; Konqueror/3.5; SunOS) KHTML/3.5.0 (like Gecko)


其中,Konqueror与KHTML的版本号比较一致,即使有差别也很小,例如Konqueror 3.5使用KHTML 3.5.1。

7. Chrome

谷歌公司的Chrome浏览器以WebKit作为呈现引擎,但使用了不同的JavaScript引擎。在Chrome 0.2这个最初的 beta版中,用户代理字符串完全取自WebKit,只添加了一段表示Chrome版本号的信息,格式如下:

Mozilla/5.0 ( 平台; 加密类型; 操作系统或CPU; 语言) AppleWebKit/AppleWebKit版本号 (KHTML, 
     like Gecko) Chrome/ Chrome版本号 Safari/ Safari版本


Chrome 7的完整的用户代理字符串如下:

Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/534.7 (KHTML, 
     like Gecko) Chrome/7.0.517.44 Safari/534.7


其中,WebKit版本与Safari版本看起来似乎始终会保持一致,尽管没有十分的把握。

8. Opera

仅就用户代理字符串而言,Opera应该是最有争议的一款浏览器了。Opera默认的用户代理字符串是所有现代浏览器中最合理的——正确地标识了自身及其版本号。在Opera 8.0之前,其用户代理字符串采用如下格式:

Opera/ 版本号 (操作系统或CPU; 加密类型) [语言]


Windows XP中的Opera 7.54会显示下面的用户代理字符串:

Opera/7.54 (Windows NT 5.1; U) [en]


Opera 8发布后,用户代理字符串的“语言”部分被移到圆括号内,以便更好地与其他浏览器匹配,如下所示:

Opera/ 版本号 (操作系统或CPU; 加密类型; 语言)


Windows XP中的Opera 8会显示下面的用户代理字符串:

Opera/8.0 (Windows NT 5.1; U; en)


默认情况下,Opera会以上面这种简单的格式返回一个用户代理字符串。目前来看,Opera也是主要浏览器中唯一一个使用产品名和版本号来完全彻底地标识自身的浏览器。可是,与其他浏览器一样,Opera在使用自己的用户代理字符串时也遇到了问题。即使技术上正确,但因特网上仍然有不少浏览器嗅探代码,只钟情于报告Mozilla产品名的那些用户代理字符串。另外还有相当数量的代码则只对IE或Gecko感兴趣。Opera没有选择通过修改自身的用户代理字符串来迷惑嗅探代码,而是干脆选择通过修改自身的用户代理字符串将自身标识为一个完全不同的浏览器。

Opera 9以后,出现了两种修改用户代理字符串的方式。一种方式是将自身标识为另外一个浏览器,如Firefox或者IE。在这种方式下,用户代理字符串就如同Firefox或IE的用户代理字符串一样,只不过末尾追加了字符串Opera 及Opera的版本号。下面是一个例子:

Mozilla/5.0 (Windows NT 5.1; U; en; rv:1.8.1) Gecko/20061208 Firefox/2.0.0 Opera 9.50

Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; en) Opera 9.50


第一个字符串将Opera 9.5标识为Firefox 2,同时带有Opera版本信息。第二个字符串将Opera 9.5标识为IE6,也包含了Opera版本信息。这两个用户代理字符串可以通过针对Firefox或IE的大多数测试,不过还是为识别Opera留下了余地。

Opera标识自身的另一种方式,就是把自己装扮成Firefox或IE。在这种隐瞒真实身份的情况下,用户代理字符串实际上与其他浏览器返回的相同——既没有Opera 字样,也不包含Opera版本信息。换句话说,在启用了身份隐瞒功能的情况下,无法将Opera和其他浏览器区别开来。另外,由于Opera喜欢在不告知用户的情况下针对站点来设置用户代理字符串,因此问题就更复杂化了。例如,打开My Yahoo!站点(http://my.yahoo.com )会自动导致Opera将自己装扮成Firefox。如此一来,要想识别Opera就难上加难了。

在Opera 7以前的版本中,Opera会解析Windows操作系统字符串的含义。例如,Windows NT 5.1实际上就是Windows XP,因此Opera会在用户代理字符串中包含Windows XP而非Windows NT 5.1。为了与其他浏览器更兼容,Opera 7开始包含正式的操作系统版本,而非解析后的版本。

Opera 10对代理字符串进行了修改。现在的格式是:

Opera/9.80 (操作系统或CPU; 加密类型; 语言) Presto/Presto版本号 Version/版本号


注意,初始的版本号Opera/9.80是固定不变的。实际并没有Opera 9.8,但工程师们担心写得不好的浏览器嗅探脚本会将Opera/10.0错误的解释为Opera 1,而不是Opera 10。因此,Opera 10又增加了Presto记号(Presto是Opera的呈现引擎)和Version记号,后者用以保存实际的版本号。以下是Windows7中Opera 10.63的用户代理字符串:

Opera/9.80 (Windows NT 6.1; U; en) Presto/2.6.30 Version/10.63


9. iOS和Android

移动操作系统iOS和Android默认的浏览器都基于WebKit,而且都像它们的桌面版一样,共享相同的基本用户代理字符串格式。iOS设备的基本格式如下:

Mozilla/5.0 (平台; 加密类型; 操作系统或CPU like Mac OS X; 语言)
     AppleWebKit/AppleWebKit版本号 (KHTML, like Gecko) Version/浏览器版本号
     Mobile/移动版本号 Safari/Safari版本号


注意用于辅助确定Mac操作系统的"like Mac OS X" 和额外的Mobile记号。一般来说,Mobile记号的版本号(移动版本号)没什么用,主要是用来确定WebKit是移动版,而非桌面版。而平台则可能是"iPhone""iPod""iPad" 。例如:

Mozilla/5.0 (iPhone; U; CPU iPhone OS 3_0 like Mac OS X; en-us)
    AppleWebKit/528.18 (KHTML, like Gecko) Version/4.0 Mobile/7A341 Safari/528.16


在iOS 3之前,用户代理字符串中不会出现操作系统版本号。

Android浏览器中的默认格式与iOS的格式相似,没有移动版本号(但有Mobile记号)。例如:

Mozilla/5.0 (Linux; U; Android 2.2; en-us; Nexus One Build/FRF91)
    AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1


这是Google Nexus One手机的用户代理字符串。不过,其他Android设备的模式也一样。

考虑到历史原因以及现代浏览器中用户代理字符串的使用方式,通过用户代理字符串来检测特定的浏览器并不是一件轻松的事。因此,首先要确定的往往是你需要多么具体的浏览器信息。一般情况下,知道呈现引擎和最低限度的版本就足以决定正确的操作方法了。例如,我们不推荐使用下列代码:

if (isIE6 || isIE7) { //不推荐!!!
    //代码
}


这个例子是想要在浏览器为IE6或IE7时执行相应代码。这种代码其实是很脆弱的,因为它要依据特定的版本来决定做什么。如果是IE8怎么办呢?只要IE有新版本出来,就必须更新这些代码。不过,像下面这样使用相对版本号则可以避免此问题:

if (ieVer >=6){
    //代码
}


这个例子首先检测IE的版本号是否至少等于6,如果是则执行相应操作。这样就可以确保相应的代码将来照样能够起作用。我们下面的浏览器检测脚本就将本着这种思路来编写。

1. 识别呈现引擎

如前所述,确切知道浏览器的名字和版本号不如确切知道它使用的是什么呈现引擎。如果Firefox、Camino和Netscape都使用相同版本的Gecko,那它们一定支持相同的特性。类似地,不管是什么浏览器,只要它跟Safari 3使用的是同一个版本的WebKit,那么该浏览器也就跟Safari 3具备同样的功能。因此,我们要编写的脚本将主要检测五大呈现引擎:IE、Gecko、WebKit、KHTML和Opera。

为了不在全局作用域中添加多余的变量,我们将使用模块增强模式来封装检测脚本。检测脚本的基本代码结构如下所示:

var client = function(){

    var engine = {            

    //呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        //具体的版本号
        ver: null  
    };

    //在此检测呈现引擎、平台和设备

    return {
        engine : engine
    };
}();


这里声明了一个名为client 的全局变量,用于保存相关信息。匿名函数内部定义了一个局部变量engine ,它是一个包含默认设置的对象字面量。在这个对象字面量中,每个呈现引擎都对应着一个属性,属性的值默认为0。如果检测到了哪个呈现引擎,那么就以浮点数值形式将该引擎的版本号写入相应的属性。而呈现引擎的完整版本(是一个字符串),则被写入ver 属性。作这样的区分可以支持像下面这样编写代码:

if (client.engine.ie) { //如果是IE,client.ie的值应该大于0
    //针对IE的代码
} else if (client.engine.gecko > 1.5){
    if (client.engine.ver == "1.8.1"){
        //针对这个版本执行某些操作
    }
}


在检测到一个呈现引擎之后,其client.engine 中对应的属性将被设置为一个大于0的值,该值可以转换成布尔值true 。这样,就可以在if 语句中检测相应的属性,以确定当前使用的呈现引擎,连具体的版本号都不必考虑。鉴于每个属性都包含一个浮点数值,因此有可能丢失某些版本信息。例如,将字符串"1.8.1" 传入parseFloat() 后会得到数值1.8。不过,在必要的时候可以检测ver 属性,该属性中会保存完整的版本信息。

要正确地识别呈现引擎,关键是检测顺序要正确。由于用户代理字符串存在诸多不一致的地方,如果检测顺序不对,很可能会导致检测结果不正确。为此,第一步就是识别Opera,因为它的用户代理字符串有可能完全模仿其他浏览器。我们不相信Opera,是因为(任何情况下)其用户代理字符串(都)不会将自己标识为Opera。

要识别Opera,必须得检测window.opera 对象。Opera 5及更高版本中都有这个对象,用以保存与浏览器相关的标识信息以及与浏览器直接交互。在Opera 7.6及更高版本中,调用version() 方法可以返回一个表示浏览器版本的字符串,而这也是确定Opera版本号的最佳方式。要检测更早版本的Opera,可以直接检查用户代理字符串,因为那些版本还不支持隐瞒身份。不过,2007底Opera的最高版本已经是9.5了,所以不太可能有人还在使用7.6之前的版本。那么,检测呈现引擎代码的第一步,就是编写如下代码:

if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver);
}


这里,将版本的字符串表示保存在了engine.ver 中,将浮点数值表示的版本保存在了engine.opera 中。如果浏览器是Opera,测试window.opera 就会返回true ;否则,就要看看是其他的什么浏览器了。

应该放在第二位检测的呈现引擎是WebKit。因为WebKit的用户代理字符串中包含"Gecko""KHTML" 这两个子字符串,所以如果首先检测它们,很可能会得出错误的结论。

不过,WebKit的用户代理字符串中的"AppleWebKit" 是独一无二的,因此检测这个字符串最合适。下面就是检测该字符串的示例代码:

var ua = navigator.userAgent;



if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver); 
} else if (/AppleWebKit\/(\S+)/.test(ua)){


    engine.ver = RegExp["$1"];


    engine.webkit = parseFloat(engine.ver);


}


代码首先将用户代理字符串保存在变量ua 中。然后通过正则表达式来测试其中是否包含字符串"AppleWebKit" ,并使用捕获组来取得版本号。由于实际的版本号中可能会包含数字、小数点和字母,所以捕获组中使用了表示非空格的特殊字符(\S )。用户代理字符串中的版本号与下一部分的分隔符是一个空格,因此这个模式可以保证捕获所有版本信息。test() 方法基于用户代理字符串运行正则表达式。如果返回true ,就将捕获的版本号保存在engine.ver 中,而将版本号的浮点表示保存在engine.webkit 中。WebKit版本与Safari版本的详细对应情况如下表所示。

Safari版本号 最低限度的WebKit版本号 Safari版本号 最低限度的WebKit版本号
1.0至1.0.2 85.7 1.3 312.1
1.0.3 85.8.2 1.3.1 312.5
1.1至1.1.1 100 1.3.2 312.8
1.2.2 125.2 2.0 412
1.2.3 125.4 2.0.1 412.7
1.2.4 125.5.5 2.0.2 416.11
2.0.3 417.9 3.0.4 523.10
2.0.4 418.8 3.1 525

有时候,Safari版本并不会与WebKit版本严格地一一对应,也可能会存在某些小版本上的差异。这个表中只是列出了最可能的WebKit版本,但不保证精确。

接下来要测试的呈现引擎是KHTML。同样,KHTML的用户代理字符串中也包含"Gecko" ,因此在排除KHTML之前,我们无法准确检测基于Gecko的浏览器。KHTML的版本号与WebKit的版本号在用户代理字符串中的格式差不多,因此可以使用类似的正则表达式。此外,由于Konqueror 3.1及更早版本中不包含KHTML的版本,故而就要使用Konqueror的版本来代替。下面就是相应的检测代码。

var ua = navigator.userAgent;

if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver); 
} else if (/AppleWebKit\/(\S+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.webkit = parseFloat(engine.ver);
} else if (/KHTML\/(\S+)/.test(ua) || /Konqueror\/([^;]+)/.test(ua)){


    engine.ver = RegExp["$1"];


    engine.khtml = parseFloat(engine.ver);


}


与前面一样,由于KHTML的版本号与后继的标记之间有一个空格,因此仍然要使用特殊的非空格字符来取得与版本有关的所有字符。然后,将字符串形式的版本信息保存在engine.ver 中,将浮点数值形式的版本保存在engin.khtml 中。如果KHTML不在用户代理字符串中,那么就要匹配Konqueror后跟一个斜杠,再后跟不包含分号的所有字符。

在排除了WebKit和KHTML之后,就可以准确地检测Gecko了。但是,在用户代理字符串中,Gecko的版本号不会出现在字符串"Gecko" 的后面,而是会出现在字符串"rv:" 的后面。这样,我们就必须使用一个比前面复杂一些的正则表达式,如下所示。

var ua = navigator.userAgent;

if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver); 
} else if (/AppleWebKit\/(\S+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.webkit = parseFloat(engine.ver);
} else if (/KHTML\/(\S+)/.test(ua)) {
    engine.ver = RegExp["$1"];
    engine.khtml = parseFloat(engine.ver); 
} else if (/rv:([^\)]+)\) Gecko\/\d{8}/.test(ua)){


    engine.ver = RegExp["$1"];


    engine.gecko = parseFloat(engine.ver);


}


Gecko的版本号位于字符串"rv:" 与一个闭括号之间,因此为了提取出这个版本号,正则表达式要查找所有不是闭括号的字符,还要查找字符串"Gecko/" 后跟8个数字。如果上述模式匹配,就提取出版本号并将其保存在相应的属性中。Gecko版本号与Firefox版本号的对应关系如下表所示。

Firefox版本号 最低限度的Gecko版本号 Firefox版本号 最低限度的Gecko版本号
1.0 1.7.5 3.5 1.9.1
1.5 1.8.0 3.6 1.9.2
2.0 1.8.1 4.0 2.0.0
3.0 1.9.0

与Safari跟WebKit一样,Firefox与Gecko的版本号也不一定严格对应。

最后一个要检测的呈现引擎就是IE了。IE的版本号位于字符串"MSIE" 的后面、一个分号的前面,因此相应的正则表达式非常简单,如下所示:

var ua = navigator.userAgent;

if (window.opera){
    engine.ver = window.opera.version();
    engine.opera = parseFloat(engine.ver);
} else if (/AppleWebKit\/(\S+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.webkit = parseFloat(engine.ver);
} else if (/KHTML\/(\S+)/.test(ua)) {
    engine.ver = RegExp["$1"];
    engine.khtml = parseFloat(engine.ver);
} else if (/rv:([^\)]+)\) Gecko\/\d{8}/.test(ua)){    
    engine.ver = RegExp["$1"];
    engine.gecko = parseFloat(engine.ver);
} else if (/MSIE ([^;]+)/.test(ua)){


     engine.ver = RegExp["$1"];


     engine.ie = parseFloat(engine.ver);


}


以上呈现引擎检测脚本的最后一部分,就是在正则表达式中使用取反的字符类来取得不是分号的所有字符。IE通常会保证以标准浮点数值形式给出其版本号,但有时候也不一定。因此,取反的字符类[^;] 可以确保取得多个小数点以及任何可能的字符。

2. 识别浏览器

大多数情况下,识别了浏览器的呈现引擎就足以为我们采取正确的操作提供依据了。可是,只有呈现引擎还不能说明存在所需的JavaScript功能。苹果公司的Safari浏览器和谷歌公司的Chrome浏览器都使用WebKit作为呈现引擎,但它们的JavaScript引擎却不一样。在这两款浏览器中,client.webkit 都会返回非0值,但仅知道这一点恐怕还不够。对于它们,有必要像下面这样为client 对象再添加一些新的属性。

var client = function(){

    var engine = {

        //呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        //具体的版本
        ver: null
    };

    var browser = {



        //浏览器


        ie: 0,


        firefox: 0,


        safari: 0,


        konq: 0,


        opera: 0,


        chrome: 0,



        //具体的版本


        ver: null


    };



    //在此检测呈现引擎、平台和设备

    return {
        engine: engine,
        browser: browser


    };

}();

代码中又添加了私有变量browser ,用于保存每个主要浏览器的属性。与engine 变量一样,除了当前使用的浏览器,其他属性的值将保持为0;如果是当前使用的浏览器,则这个属性中保存的是浮点数值形式的版本号。同样,ver 属性中在必要时将会包含字符串形式的浏览器完整版本号。由于大多数浏览器与其呈现引擎密切相关,所以下面示例中检测浏览器的代码与检测呈现引擎的代码是混合在一起的。

//检测呈现引擎及浏览器
var ua = navigator.userAgent;    
if (window.opera){
    engine.ver = browser.ver = window.opera.version();


    engine.opera = browser.opera = parseFloat(engine.ver);


} else if (/AppleWebKit\/(\S+)/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.webkit = parseFloat(engine.ver);

    //确定是Chrome还是Safari


    if (/Chrome\/(\S+)/.test(ua)){


        browser.ver = RegExp["$1"];


        browser.chrome = parseFloat(browser.ver);


    } else if (/Version\/(\S+)/.test(ua)){


        browser.ver = RegExp["$1"];


        browser.safari = parseFloat(browser.ver);


    } else {


        //近似地确定版本号


        var safariVersion = 1;


        if (engine.webkit 


            safariVersion = 1;


        } else if (engine.webkit 


            safariVersion = 1.2;


        } else if (engine.webkit 


            safariVersion = 1.3;


        } else {


            safariVersion = 2;


        }



        browser.safari = browser.ver = safariVersion;


    }
} else if (/KHTML\/(\S+)/.test(ua) || /Konqueror\/([^;]+)/.test(ua)){
    engine.ver = browser.ver = RegExp["$1"];


    engine.khtml = browser.konq = parseFloat(engine.ver);


} else if (/rv:([^\)]+)\) Gecko\/\d{8}/.test(ua)){
    engine.ver = RegExp["$1"];
    engine.gecko = parseFloat(engine.ver);

    //确定是不是Firefox


    if (/Firefox\/(\S+)/.test(ua)){


        browser.ver = RegExp["$1"];


        browser.firefox = parseFloat(browser.ver);


    }


} else if (/MSIE ([^;]+)/.test(ua)){
    engine.ver = browser.ver = RegExp["$1"];


    engine.ie = browser.ie = parseFloat(engine.ver);


}

对Opera和IE而言,browser 对象中的值等于engine 对象中的值。对Konqueror而言,browser.konqbrowser.ver 属性分别等于engine.khtmlengine.ver 属性。

为了检测Chrome和Safari,我们在检测引擎的代码中添加了if语句。提取Chrome的版本号时,需要查找字符串"Chrome/" 并取得该字符串后面的数值。而提取Safari的版本号时,则需要查找字符串"Version/" 并取得其后的数值。由于这种方式仅适用于Safari 3及更高版本,因此需要一些备用的代码,将WebKit的版本号近似地映射为Safari的版本号(参见上一小节中的表格)。

在检测Firefox的版本时,首先要找到字符串"Firefox/" ,然后提取出该字符串后面的数值(即版本号)。当然,只有呈现引擎被判别为Gecko时才会这样做。

有了上面这些代码之后,我们就可以编写下面的逻辑。

if (client.engine.webkit) { //if it’s WebKit
    if (client.browser.chrome){
        //执行针对Chrome的代码
    } else if (client.browser.safari){
        //执行针对Safari的代码
    }
} else if (client.engine.gecko){
    if (client.browser.firefox){
        //执行针对Firefox的代码
    } else {
        //执行针对其他Gecko浏览器的代码
    }
}


3. 识别平台

很多时候,只要知道呈现引擎就足以编写出适当的代码了。但在某些条件下,平台可能是必须关注的问题。那些具有各种平台版本的浏览器(如Safari、Firefox和Opera)在不同的平台下可能会有不同的问题。目前的三大主流平台是Windows、Mac和Unix(包括各种Linux)。为了检测这些平台,还需要像下面这样再添加一个新对象。

var client = function(){

    var engine = {

        //呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        //具体的版本号
        ver: null
    };

    var browser = {

        //浏览器
        ie: 0,
        firefox: 0,
        safari: 0,
        konq: 0,
        opera: 0,
        chrome: 0,

        //具体的版本号
        ver: null
    };

    var system = {


        win: false,


        mac: false,


        x11: false


    };



    //在此检测呈现引擎、平台和设备

    return {
        engine: engine,
        browser: browser,
        system: system


    };

}();

显然,上面的代码中又添加了一个包含3个属性的新变量system 。其中,win 属性表示是否为Windows平台,mac 表示Mac,而x11 表示Unix。与呈现引擎不同,在不能访问操作系统或版本的情况下,平台信息通常是很有限的。对这三个平台而言,浏览器一般只报告Windows版本。为此,新变量system 的每个属性最初都保存着布尔值false ,而不是像呈现引擎属性那样保存着数字值。

在确定平台时,检测navigator.platform 要比检测用户代理字符串更简单,后者在不同浏览器中会给出不同的平台信息。而navigator.platform 属性可能的值包括"Win32""Win64""MacPPC""MacIntel""X11""Linux i686" ,这些值在不同的浏览器中都是一致的。检测平台的代码非常直观,如下所示:

var p = navigator.platform;
system.win = p.indexOf("Win") == 0;
system.mac = p.indexOf("Mac") == 0;
system.x11 = (p.indexOf("X11") == 0)    || (p.indexOf("Linux") == 0);


以上代码使用indexOf() 方法来查找平台字符串的开始位置。虽然"Win32" 是当前浏览器唯一支持的Windows字符串,但随着向64位Windows架构的迁移,将来很可能会出现"Win64" 平台信息值。为了对此有所准备,检测平台的代码中查找的只是字符串"Win" 的开始位置。而检测Mac平台的方式也类似,同样是考虑到了MacPPCMacIntel 。在检测Unix时,则同时检查了字符串"X11""Linux" 在平台字符串中的开始位置,从而确保了代码能够向前兼容其他变体。

Gecko的早期版本在所有Windows平台中都返回字符串"Windows" ,在所有Mac平台中则都返回字符串"Macintosh" 。不过,这都是Firefox 1发布以前的事了,Firefox 1确定了navigator.platform 的值。

4. 识别Windows操作系统

在Windows平台下,还可以从用户代理字符串中进一步取得具体的操作系统信息。在Windows XP之前,Windows有两种版本,分别针对家庭用户和商业用户。针对家庭用户的版本分别是Windows 95、98和Windows ME。而针对商业用户的版本则一直叫做Window NT,最后由于市场原因改名为Windows 2000。这两个产品线后来又合并成一个由Windows NT发展而来的公共的代码基,代表产品就是Windows XP。随后,微软在Windows XP基础上又构建了Windows Vista。

只有了解这些信息,才能搞清楚用户代理字符串中Windows操作系统的具体版本。下表列出了不同浏览器在表示不同的Windows操作系统时给出的不同字符串。

Windows版本 IE 4+ Gecko Opera < 7 Opera 7+ WebKit
95 "Windows 95" "Win95" "Windows 95" "Windows 95" n/a
98 "Windows 98" "Win98" "Windows 98" "Windows 98" n/a
NT 4.0 "Windows NT" "WinNT4.0" "Windows NT 4.0" "Windows NT 4.0" n/a
2000 "Windows NT 5.0" "Windows NT 5.0" "Windows 2000" "Windows NT 5.0" n/a
ME "Win 9x 4.90" "Win 9x 4.90" "Windows ME" "Win 9x 4.90" n/a
XP "Windows NT 5.1" "Windows NT 5.1" "Windows XP" "Windows NT 5.1" "Windows NT 5.1"
Vista "Windows NT 6.0" "Windows NT 6.0" n/a "Windows NT 6.0" "Windows NT 6.0"
7 "Windows NT 6.1" "Windows NT 6.1" n/a "Windows NT 6.1" "Windows NT 6.1"

由于用户代理字符串中的Windows操作系统版本表示方法各异,因此检测代码并不十分直观。好在,从Windows 2000开始,表示操作系统的字符串大部分都还相同,只有版本号有变化。为了检测不同的Windows操作系统,必须要使用正则表达式。由于使用Opera 7之前版本的用户已经不多了,因此我们可以忽略这部分浏览器。

第一步就是匹配Windows 95和Windows 98这两个字符串。对这两个字符串,只有Gecko与其他浏览器不同,即没有"dows" ,而且"Win" 与版本号之间没有空格。要匹配这个模式,可以使用下面这个简单的正则表达式。

/Win(?:dows )?([^do]{2})/


这个正则表达式中的捕获组会返回操作系统的版本。由于版本可能是任何两个字符编码(例如95、98、9x、NT、ME及XP),因此要使用两个非空格字符。

Gecko在表示Windows NT时会在末尾添加"4.0" ,与其查找实际的字符串,不如像下面这样查找小数值更合适。

/Win(?:dows )?([^do]{2})(\d+\.\d+)?

/

这样,正则表达式中就包含了第二个捕获组,用于取得NT的版本号。由于该版本号对于Windows 95和Windows 98而言是不存在的,所以必须设置为可选。这个模式与Opera表示Windows NT的字符串之间唯一的区别,就是"NT""4.0" 之间的空格,这在模式中很容易添加。

/Win(?:dows )?([^do]{2})\s?

(\d+\.\d+)?/

经过一番修改之后,这个正则表达式也可以成功地匹配Windows ME、Windows XP和Windows Vista的字符串了。具体来说,第一个捕获组将会匹配95、98、9x、NT、ME或XP。第二个捕获组则只针对Windows ME及所有Windows NT的变体。这个信息可以作为具体的操作系统信息保存在system.win 属性中,如下所示。

if (system.win){
    if (/Win(?:dows )?([^do]{2})\s?(\d+\.\d+)?/.test(ua)){
        if (RegExp["$1"] == "NT"){
            switch(RegExp["$2"]){
                case "5.0":
                    system.win = "2000";
                    break;
                case "5.1":
                    system.win = "XP";
                    break;
                case "6.0":
                    system.win = "Vista";
                    break;
                case "6.1":
                    system.win = "7";
                    break;
                default:
                    system.win = "NT";
                    break;                
            }                            
        } else if (RegExp["$1"] == "9x"){
            system.win = "ME";
        } else {
            system.win = RegExp["$1"];
        }
    }
}


如果system.win 的值为true ,那么就使用这个正则表达式从用户代理字符串中提取具体的信息。鉴于Windows将来的某个版本也许不能使用这个方法来检测,所以第一步应该先检测用户代理字符串是否与这个模式匹配。在模式匹配的情况下,第一个捕获组中可能会包含"95""98""9x""NT" 。如果这个值是"NT" ,可以将system.win 设置为相应操作系统的字符串;如果是"9x",那么system.win就要设置成"ME" ;如果是其他值,则将所捕获的值直接赋给system.win 。有了这些检测平台的代码后,我们就可以编写如下代码。

if (client.system.win){
    if (client.system.win == "XP") {
        //说明是XP
    } else if (client.system.win == "Vista"){
        //说明是Vista
    }
}


由于非空字符串会转换为布尔值true ,因此可以将client.system.win 作为布尔值用在if 语句中。而在需要更多有关操作系统的信息时,则可以使用其中保存的字符串值。

5. 识别移动设备

2006年到2007年,移动设备中Web浏览器的应用呈爆炸性增长。四大主要浏览器都推出了手机版和在其他设备中运行的版本。要检测相应的设备,第一步是为要检测的所有移动设备添加属性,如下所示。

var client = function(){

    var engine = {

        //呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        //具体的版本号
        ver: null
    };

    var browser = {

        //浏览器
        ie: 0,
        firefox: 0,
        safari: 0,
        konq: 0,
        opera: 0,
        chrome: 0,

        //具体的版本号
        ver: null
    };

    var system = {
        win: false,
        mac: false,
        x11: false,

        //移动设备


        iphone: false,


        ipod: false,


        ipad: false,


        ios: false,


        android: false,


        nokiaN: false,


        winMobile: false

    };

    //在此检测呈现引擎、平台和设备

    return {
        engine: engine,
        browser: browser,
        system: system
    };

}();

然后,通常简单地检测字符串"iPhone""iPod""iPad" ,就可以分别设置相应属性的值了。

system.iphone = ua.indexOf("iPhone") > -1;
system.ipod = ua.indexOf("iPod") > -1;
system.ipod = ua.indexOf("iPad") > -1;


除了知道iOS设备,最好还能知道iOS的版本号。在iOS 3之前,用户代理字符串中只包含"CPU like Mac OS" ,后来iPhone中又改成"CPU iPhone OS 3_0 like Mac OS X" ,iPad中又改成"CPU OS 3_2 like Mac OS X" 。也就是说,检测iOS需要正则表达式反映这些变化。

//检测iOS版本
if (system.mac && ua.indexOf("Mobile") > -1){
    if (/CPU (?:iPhone )?OS (\d+_\d+)/.test(ua)){
        system.ios = parseFloat(RegExp.$1.replace("_", "."));
    } else {
        system.ios = 2; //不能真正检测出来,所以只能猜测
    } 
}


检查系统是不是Mac OS、字符串中是否存在"Mobile" ,可以保证无论是什么版本,system.ios 中都不会是0。然后,再使用正则表达式确定是否存在iOS的版本号。如果有,将system.ios 设置为表示版本号的浮点值;否则,将版本设置为2。(因为没有办法确定到底是什么版本,所以设置为更早的版本比较稳妥。)

检测Android操作系统也很简单,也就是搜索字符串"Android" 并取得紧随其后的版本号。

//检测Android版本
if (/Android (\d+\.\d+)/.test(ua)){ 
    system.android = parseFloat(RegExp.$1);
}


由于所有版本的Android都有版本值,因此这个正则表达式可以精确地检测所有版本,并将system.android 设置为正确的值。

诺基亚N系列手机使用的也是WebKit,其用户代理字符串与其他基于WebKit的手机很相似,例如:

Mozilla/5.0 (SymbianOS/9.2; U; Series60/3.1 NokiaN95/11.0.026; Profile MIDP-2.0
     Configuration/CLDC-1.1) AppleWebKit/413 (KHTML, like Gecko) Safari/413


虽然诺基亚N系列手机在用户代理字符串中声称使用的是"Safari" ,但实际上并不是Safari,尽管确实是基于WebKit引擎。只要像下面检测一下用户代理字符串中是否存在"NokiaN" ,就足以确定是不是该系列的手机了。

system.nokiaN = ua.indexOf("NokiaN") > -1;


在了解这些设备信息的基础上,就可以通过下列代码来确定用户使用的是什么设备中的WebKit来访问网页:

if (client.engine.webkit){
    if (client.system. iOS){
        //iOS手机的内容
    } else if (client.system.android){
        //Android手机的内容
    } else if (client.system.nokiaN){
        //诺基亚手机的内容
    }
}


最后一种主要的移动设备平台是Windows Mobile(也称为Windows CE),用于Pocket PC和Smartphone中。由于从技术上说这些平台都属于Windows平台,因此Windows平台和操作系统都会返回正确的值。对于Windows Mobile 5.0及以前版本,这两种设备的用户代理字符串非常相似,如下所示:

Mozilla/4.0 (compatible; MSIE 4.01; Windows CE; PPC; 240x320)
Mozilla/4.0 (compatible; MSIE 4.01; Windows CE; Smartphone; 176x220)


第一个来自Pocket PC中的移动Internet Explorer 4.01,第二个来自Smartphone中的同一个浏览器。当Windows操作系统检测脚本检测这两个字符串时,system.win 将被设置为"CE" ,因此在检测Windows Mobile时可以使用这个值:

system.winMobile = (system.win == "CE");


不建议测试字符串中的"PPC""Smartphone" ,因为在Windows Mobile 5.0以后版本的浏览器中,这些记号已经被移除了。不过,一般情况下,只知道某个设备使用的是Windows Mobile也就足够了。

Windows Phone 7的用户代理字符串稍有改进,基本格式如下:

Mozilla/4.0 (compatible; MSIE 7.0; Windows Phone OS 7.0; Trident/3.1; IEMobile/7.0)
    Asus;Galaxy6 


其中,Windows操作符的标识符与已往完全不同,因此在这个用户代理中client.system.win 等于"Ph" 。从中可以取得有关系统的更多信息:

//windows mobile
if (system.win == "CE"){
    system.winMobile = system.win;
} else if (system.win == "Ph"){
    if(/Windows Phone OS (\d+.\d+)/.test(ua)){;
        system.win = "Phone";
        system.winMobile = parseFloat(RegExp["$1"]);
    }
}


如果system.win 的值是"CE" ,就说明是老版本的Windows Mobile,因此system.winMobile 会被设置为相同的值(只能知道这个信息)。如果system.win 的值是"Ph" ,那么这个设备就可能是Windows Phone 7或更新版本。因此就用正则表达式来测试格式并提取版本号,将system.win 的值重置为"Phone" ,而将system.winMobile 设置为版本号。

6. 识别游戏系统

除了移动设备之外,视频游戏系统中的Web浏览器也开始日益普及。任天堂Wii和Playstation 3或者内置Web浏览器,或者提供了浏览器下载。Wii中的浏览器实际上是定制版的Opera,是专门为Wii Remote设计的。Playstation的浏览器是自己开发的,没有基于前面提到的任何呈现引擎。这两个浏览器中的用户代理字符串如下所示:

Opera/9.10 (Nintendo Wii;U; ; 1621; en)
Mozilla/5.0 (PLAYSTATION 3; 2.00)


第一个字符串来自运行在Wii中的Opera,它忠实地继承了Opera最初的用户代理字符串格式(Wii上的Opera不具备隐瞒身份的能力)。第二个字符串来自Playstation3,虽然它为了兼容性而将自己标识为Mozilla 5.0,但并没有给出太多信息。而且,设备名称居然全部使用了大写字母,让人觉得很奇怪;强烈希望将来的版本能够改变这种情况。

在检测这些设备以前,我们必须先为client.system 中添加适当的属性,如下所示:

var client = function(){

    var engine = {

        //呈现引擎
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        //具体的版本号
        ver: null
    };

    var browser = {

        //浏览器
        ie: 0,
        firefox: 0,
        safari: 0,
        konq: 0,
        opera: 0,
        chrome: 0,

        //具体的版本号
        ver: null
    };

    var system = {
        win: false,
        mac: false,
        x11: false,

        //移动设备
        iphone: false,
        ipod: false,
        ipad: false,
        ios: false,
        android: false,
        nokiaN: false,
        winMobile: false,
        //游戏系统


        wii: false,


        ps: false


    };

    //在此检测呈现引擎、平台和设备

    return {
        engine: engine,
        browser: browser,
        system: system
    };

}();

检测前述游戏系统的代码如下:

system.wii = ua.indexOf("Wii") > -1;
system.ps = /playstation/i.test(ua);


对于Wii,只要检测字符串"Wii" 就够了,而其他代码将发现这是一个Opera浏览器,并将正确的版本号保存在client.browser.opera 中。对于Playstation,我们则使用正则表达式来以不区分大小写的方式测试用户代理字符串。

以下是完整的用户代理字符串检测脚本,包括检测呈现引擎、平台、Windows操作系统、移动设备和游戏系统。

var client = function(){

    //呈现引擎
    var engine = {
        ie: 0,
        gecko: 0,
        webkit: 0,
        khtml: 0,
        opera: 0,

        //完整的版本号
        ver: null  
    };

    //浏览器
    var browser = {

        //主要浏览器
        ie: 0,
        firefox: 0,
        safari: 0,
        konq: 0,
        opera: 0,
        chrome: 0,

        //具体的版本号
        ver: null
    };


    //平台、设备和操作系统
    var system = {
        win: false,
        mac: false,
        x11: false,

        //移动设备
        iphone: false,
        ipod: false,
        ipad: false,
        ios: false,
        android: false,
        nokiaN: false,
        winMobile: false,

        //游戏系统
        wii: false,
        ps: false 
    };    

    //检测呈现引擎和浏览器
    var ua = navigator.userAgent;    
    if (window.opera){
        engine.ver = browser.ver = window.opera.version();
        engine.opera = browser.opera = parseFloat(engine.ver);
    } else if (/AppleWebKit\/(\S+)/.test(ua)){
        engine.ver = RegExp["$1"];
        engine.webkit = parseFloat(engine.ver);

        //确定是Chrome还是Safari
        if (/Chrome\/(\S+)/.test(ua)){
            browser.ver = RegExp["$1"];
            browser.chrome = parseFloat(browser.ver);
        } else if (/Version\/(\S+)/.test(ua)){
            browser.ver = RegExp["$1"];
            browser.safari = parseFloat(browser.ver);
        } else {
            //近似地确定版本号
            var safariVersion = 1;
            if (engine.webkit < 100){
                safariVersion = 1;
            } else if (engine.webkit < 312){
                safariVersion = 1.2;
            } else if (engine.webkit < 412){
                safariVersion = 1.3;
            } else {
                safariVersion = 2;
            }   

            browser.safari = browser.ver = safariVersion;        
        }
    } else if (/KHTML\/(\S+)/.test(ua) || /Konqueror\/([^;]+)/.test(ua)){
        engine.ver = browser.ver = RegExp["$1"];
        engine.khtml = browser.konq = parseFloat(engine.ver);
    } else if (/rv:([^\)]+)\) Gecko\/\d{8}/.test(ua)){    
        engine.ver = RegExp["$1"];
        engine.gecko = parseFloat(engine.ver);

        //确定是不是Firefox
        if (/Firefox\/(\S+)/.test(ua)){
            browser.ver = RegExp["$1"];
            browser.firefox = parseFloat(browser.ver);
        }
    } else if (/MSIE ([^;]+)/.test(ua)){    
        engine.ver = browser.ver = RegExp["$1"];
        engine.ie = browser.ie = parseFloat(engine.ver);
    }

    //检测浏览器
    browser.ie = engine.ie;
    browser.opera = engine.opera;


    //检测平台
    var p = navigator.platform;
    system.win = p.indexOf("Win") == 0;
    system.mac = p.indexOf("Mac") == 0;
    system.x11 = (p == "X11") || (p.indexOf("Linux") == 0);

    //检测Windows操作系统
    if (system.win){
        if (/Win(?:dows )?([^do]{2})\s?(\d+\.\d+)?/.test(ua)){
            if (RegExp["$1"] == "NT"){
                switch(RegExp["$2"]){
                    case "5.0":
                        system.win = "2000";
                        break;
                    case "5.1":
                        system.win = "XP";
                        break;
                    case "6.0":
                        system.win = "Vista";
                        break;
                    case "6.1":
                        system.win = "7";
                        break;
                    default:
                        system.win = "NT";
                        break;                
                }                            
            } else if (RegExp["$1"] == "9x"){
                system.win = "ME";
            } else {
                system.win = RegExp["$1"];
            }
        }
    }

    //移动设备
   system.iphone = ua.indexOf("iPhone") > -1;
   system.ipod = ua.indexOf("iPod") > -1;
   system.ipad = ua.indexOf("iPad") > -1;
   system.nokiaN = ua.indexOf("NokiaN") > -1;

   //windows mobile
   if (system.win == "CE"){
       system.winMobile = system.win;
   } else if (system.win == "Ph"){
       if(/Windows Phone OS (\d+.\d+)/.test(ua)){;
          system.win = "Phone";
          system.winMobile = parseFloat(RegExp["$1"]);
       }
   }

   //检测iOS版本
   if (system.mac && ua.indexOf("Mobile") > -1){
       if (/CPU (?:iPhone )?OS (\d+_\d+)/.test(ua)){
           system.ios = parseFloat(RegExp.$1.replace("_", "."));
       } else {
          system.ios = 2; //不能真正检测出来,所以只能猜测
       }
   }

   //检测Android版本
   if (/Android (\d+\.\d+)/.test(ua)){
        system.android = parseFloat(RegExp.$1);
   }

    //游戏系统
    system.wii = ua.indexOf("Wii") > -1;
    system.ps = /playstation/i.test(ua);

    //返回这些对象
    return {
        engine:     engine,
        browser:    browser,
        system:     system        
    };

}();


client.js

我们在前面已经强调过了,用户代理检测是客户端检测的最后一个选择。只要可能,都应该优先采用能力检测和怪癖检测。用户代理检测一般适用于下列情形。

客户端检测是JavaScript开发中最具争议的一个话题。由于浏览器间存在差别,通常需要根据不同浏览器的能力分别编写不同的代码。有不少客户端检测方法,但下列是最经常使用的。

在决定使用哪种客户端检测方法时,一般应优先考虑使用能力检测。怪癖检测是确定应该如何处理代码的第二选择。而用户代理检测则是客户端检测的最后一种方案,因为这种方法对用户代理字符串具有很强的依赖性。


第10章 DOM

本章内容

DOM(文档对象模型)是针对HTML和XML文档的一个API(应用程序编程接口)。DOM描绘了一个层次化的节点树,允许开发人员添加、移除和修改页面的某一部分。DOM脱胎于Netscape及微软公司创始的DHTML(动态HTML),但现在它已经成为表现和操作页面标记的真正的跨平台、语言中立的方式。

1998年10月DOM1级规范成为W3C的推荐标准,为基本的文档结构及查询提供了接口。本章主要讨论与浏览器中的HTML页面相关的DOM1级的特性和应用,以及JavaScript对DOM1级的实现。IE、Firefox、Safari、Chrome和Opera都非常完善地实现了DOM。

注意,IE中的所有DOM对象都是以COM对象的形式实现的。这意味着IE中的DOM对象与原生JavaScript对象的行为或活动特点并不一致。本章将较多地谈及这些差异。

DOM可以将任何HTML或XML文档描绘成一个由多层节点构成的结构。节点分为几种不同的类型,每种类型分别表示文档中不同的信息及(或)标记。每个节点都拥有各自的特点、数据和方法,另外也与其他节点存在某种关系。节点之间的关系构成了层次,而所有页面标记则表现为一个以特定节点为根节点的树形结构。以下面的HTML为例:

<html>
    <head>
        <title>Sample Page</title>
    </head>
    <body>
        <p>Hello World!</p>
    </body>
</html>


可以将这个简单的HTML文档表示为一个层次结构,如图10-1所示。

文档节点是每个文档的根节点。在这个例子中,文档节点只有一个子节点,即<html> 元素,我们称之为文档元素 。文档元素是文档的最外层元素,文档中的其他所有元素都包含在文档元素中。每个文档只能有一个文档元素。在HTML页面中,文档元素始终都是<html> 元素。在XML中,没有预定义的元素,因此任何元素都可能成为文档元素。

每一段标记都可以通过树中的一个节点来表示:HTML元素通过元素节点表示,特性(attribute)通过特性节点表示,文档类型通过文档类型节点表示,而注释则通过注释节点表示。总共有12种节点类型,这些类型都继承自一个基类型。

图 10-1

DOM1级定义了一个Node 接口,该接口将由DOM中的所有节点类型实现。这个Node 接口在JavaScript中是作为Node 类型实现的;除了IE之外,在其他所有浏览器中都可以访问到这个类型。JavaScript中的所有节点类型都继承自Node 类型,因此所有节点类型都共享着相同的基本属性和方法。

每个节点都有一个nodeType 属性,用于表明节点的类型。节点类型由在Node 类型中定义的下列12个数值常量来表示,任何节点类型必居其一:

通过比较上面这些常量,可以很容易地确定节点的类型,例如:

if (someNode.nodeType == Node.ELEMENT_NODE){   //在IE中无效
    alert("Node is an element.");
}


这个例子比较了someNode.nodeTypeNode.ELEMENT_NODE 常量。如果二者相等,则意味着someNode 确实是一个元素。然而,由于IE没有公开Node 类型的构造函数,因此上面的代码在IE中会导致错误。为了确保跨浏览器兼容,最好还是将nodeType 属性与数字值进行比较,如下所示:

if (someNode.nodeType == 1){    //适用于所有浏览器


    alert("Node is an element.");
}

并不是所有节点类型都受到Web浏览器的支持。开发人员最常用的就是元素和文本节点。本章后面将详细讨论每个节点类型的受支持情况及使用方法。

1. nodeNamenodeValue 属性

要了解节点的具体信息,可以使用nodeNamenodeValue 这两个属性。这两个属性的值完全取决于节点的类型。在使用这两个值以前,最好是像下面这样先检测一下节点的类型。

if (someNode.nodeType == 1){
    value = someNode.nodeName;    //nodeName的值是元素的标签名
}


在这个例子中,首先检查节点类型,看它是不是一个元素。如果是,则取得并保存nodeName 的值。对于元素节点,nodeName 中保存的始终都是元素的标签名,而nodeValue 的值则始终为null

2. 节点关系

文档中所有的节点之间都存在这样或那样的关系。节点间的各种关系可以用传统的家族关系来描述,相当于把文档树比喻成家谱。在HTML中,可以将<body> 元素看成是<html> 元素的子元素;相应地,也就可以将<html> 元素看成是<body> 元素的父元素。而<head> 元素,则可以看成是<body> 元素的同胞元素,因为它们都是同一个父元素<html> 的直接子元素。

每个节点都有一个childNodes 属性,其中保存着一个NodeList 对象。NodeList 是一种类数组对象,用于保存一组有序的节点,可以通过位置来访问这些节点。请注意,虽然可以通过方括号语法来访问NodeList 的值,而且这个对象也有length 属性,但它并不是Array 的实例。NodeList 对象的独特之处在于,它实际上是基于DOM结构动态执行查询的结果,因此DOM结构的变化能够自动反映在NodeList 对象中。我们常说,NodeList 是有生命、有呼吸的对象,而不是在我们第一次访问它们的某个瞬间拍摄下来的一张快照。

下面的例子展示了如何访问保存在NodeList 中的节点——可以通过方括号,也可以使用item() 方法。

var firstChild = someNode.childNodes[0];
var secondChild = someNode.childNodes.item(1);
var count = someNode.childNodes.length;


无论使用方括号还是使用item() 方法都没有问题,但使用方括号语法看起来与访问数组相似,因此颇受一些开发人员的青睐。另外,要注意length 属性表示的是访问NodeList 的那一刻,其中包含的节点数量。我们在本书前面介绍过,对arguments 对象使用Array.prototype.slice() 方法可以将其转换为数组。而采用同样的方法,也可以将NodeList 对象转换为数组。来看下面的例子:

//在IE8及之前版本中无效
var arrayOfNodes = Array.prototype.slice.call(someNode.childNodes,0);


除IE8及更早版本之外,这行代码能在任何浏览器中运行。由于IE8及更早版本将NodeList 实现为一个COM对象,而我们不能像使用JScript对象那样使用这种对象,因此上面的代码会导致错误。要想在IE中将NodeList 转换为数组,必须手动枚举所有成员。下列代码在所有浏览器中都可以运行:

function convertToArray(nodes){
    var array = null;
    try {
        array = Array.prototype.slice.call(nodes, 0); //针对非IE浏览器
    } catch (ex) {
        array = new Array();
        for (var i=0, len=nodes.length; i < len; i++){
            array.push(nodes[i]);
        }
    }

    return array;
}


这个convertToArray() 函数首先尝试了创建数组的最简单方式。如果导致了错误(说明是在IE8及更早版本中),则通过try-catch 块来捕获错误,然后手动创建数组。这是另一种检测怪癖的形式。

每个节点都有一个parentNode 属性,该属性指向文档树中的父节点。包含在childNodes 列表中的所有节点都具有相同的父节点,因此它们的parentNode 属性都指向同一个节点。此外,包含在childNodes 列表中的每个节点相互之间都是同胞节点。通过使用列表中每个节点的previousSiblingnextSibling 属性,可以访问同一列表中的其他节点。列表中第一个节点的previousSibling 属性值为null ,而列表中最后一个节点的nextSibling 属性的值同样也为null ,如下面的例子所示:

if (someNode.nextSibling === null){
    alert("Last node in the parent’s childNodes list.");
} else if (someNode.previousSibling === null){
    alert("First node in the parent’s childNodes list.");
}


当然,如果列表中只有一个节点,那么该节点的nextSiblingpreviousSibling 都为null

父节点与其第一个和最后一个子节点之间也存在特殊关系。父节点的firstChildlastChild 属性分别指向其childNodes 列表中的第一个和最后一个节点。其中,someNode.firstChild 的值始终等于someNode.childNodes[0] ,而someNode.lastChild 的值始终等于someNode.childNodes [someNode.childNodes.length-1] 。在只有一个子节点的情况下,firstChildlastChild 指向同一个节点。如果没有子节点,那么firstChildlastChild 的值均为null 。明确这些关系能够对我们查找和访问文档结构中的节点提供极大的便利。图10-2形象地展示了上述关系。

图 10-2

在反映这些关系的所有属性当中,childNodes 属性与其他属性相比更方便一些,因为只须使用简单的关系指针,就可以通过它访问文档树中的任何节点。另外,hasChildNodes() 也是一个非常有用的方法,这个方法在节点包含一或多个子节点的情况下返回true ;应该说,这是比查询childNodes 列表的length 属性更简单的方法。

所有节点都有的最后一个属性是ownerDocument ,该属性指向表示整个文档的文档节点。这种关系表示的是任何节点都属于它所在的文档,任何节点都不能同时存在于两个或更多个文档中。通过这个属性,我们可以不必在节点层次中通过层层回溯到达顶端,而是可以直接访问文档节点。

虽然所有节点类型都继承自Node ,但并不是每种节点都有子节点。本章后面将会讨论不同节点类型之间的差异。

3. 操作节点

因为关系指针都是只读的,所以DOM提供了一些操作节点的方法。其中,最常用的方法是appendChild() ,用于向childNodes 列表的末尾添加一个节点。添加节点后,childNodes 的新增节点、父节点及以前的最后一个子节点的关系指针都会相应地得到更新。更新完成后,appendChild() 返回新增的节点。来看下面的例子:

var returnedNode = someNode.appendChild(newNode);
alert(returnedNode == newNode);         //true
alert(someNode.lastChild == newNode);   //true


如果传入到appendChild() 中的节点已经是文档的一部分了,那结果就是将该节点从原来的位置转移到新位置。即使可以将DOM树看成是由一系列指针连接起来的,但任何DOM节点也不能同时出现在文档中的多个位置上。因此,如果在调用appendChild() 时传入了父节点的第一个子节点,那么该节点就会成为父节点的最后一个子节点,如下面的例子所示。

//someNode有多个子节点
var returnedNode = someNode.appendChild(someNode.firstChild);
alert(returnedNode == someNode.firstChild);      //false
alert(returnedNode == someNode.lastChild);       //true


如果需要把节点放在childNodes 列表中某个特定的位置上,而不是放在末尾,那么可以使用insertBefore() 方法。这个方法接受两个参数:要插入的节点和作为参照的节点。插入节点后,被插入的节点会变成参照节点的前一个同胞节点(previousSibling ),同时被方法返回。如果参照节点是null ,则insertBefore()appendChild() 执行相同的操作,如下面的例子所示。

//插入后成为最后一个子节点
returnedNode = someNode.insertBefore(newNode, null);
alert(newNode == someNode.lastChild);   //true

//插入后成为第一个子节点
var returnedNode = someNode.insertBefore(newNode, someNode.firstChild);
alert(returnedNode == newNode);         //true
alert(newNode == someNode.firstChild);  //true

//插入到最后一个子节点前面
returnedNode = someNode.insertBefore(newNode, someNode.lastChild);
alert(newNode == someNode.childNodes[someNode.childNodes.length-2]); //true


前面介绍的appendChild()insertBefore() 方法都只插入节点,不会移除节点。而下面要介绍的replaceChild() 方法接受的两个参数是:要插入的节点和要替换的节点。要替换的节点将由这个方法返回并从文档树中被移除,同时由要插入的节点占据其位置。来看下面的例子。

//替换第一个子节点
var returnedNode = someNode.replaceChild(newNode, someNode.firstChild);

//替换最后一个子节点
returnedNode = someNode.replaceChild(newNode, someNode.lastChild);


在使用replaceChild() 插入一个节点时,该节点的所有关系指针都会从被它替换的节点复制过来。尽管从技术上讲,被替换的节点仍然还在文档中,但它在文档中已经没有了自己的位置。

如果只想移除而非替换节点,可以使用removeChild() 方法。这个方法接受一个参数,即要移除的节点。被移除的节点将成为方法的返回值,如下面的例子所示。

//移除第一个子节点
var formerFirstChild = someNode.removeChild(someNode.firstChild);

//移除最后一个子节点
var formerLastChild = someNode.removeChild(someNode.lastChild);


与使用replaceChild() 方法一样,通过removeChild() 移除的节点仍然为文档所有,只不过在文档中已经没有了自己的位置。

前面介绍的四个方法操作的都是某个节点的子节点,也就是说,要使用这几个方法必须先取得父节点(使用parentNode 属性)。另外,并不是所有类型的节点都有子节点,如果在不支持子节点的节点上调用了这些方法,将会导致错误发生。

4. 其他方法

有两个方法是所有类型的节点都有的。第一个就是cloneNode() ,用于创建调用这个方法的节点的一个完全相同的副本。cloneNode() 方法接受一个布尔值参数,表示是否执行深复制。在参数为true 的情况下,执行深复制,也就是复制节点及其整个子节点树;在参数为false 的情况下,执行浅复制,即只复制节点本身。复制后返回的节点副本属于文档所有,但并没有为它指定父节点。因此,这个节点副本就成为了一个“孤儿”,除非通过appendChild()insertBefore()replaceChild() 将它添加到文档中。例如,假设有下面的HTML代码。

<ul>
    <li>item 1</li>
    <li>item 2</li>
    <li>item 3</li>
</ul>


如果我们已经将<ul> 元素的引用保存在了变量myList 中,那么通常下列代码就可以看出使用cloneNode() 方法的两种模式。

var deepList = myList.cloneNode(true);
alert(deepList.childNodes.length);      //3(IE < 9)或7(其他浏览器)

var shallowList = myList.cloneNode(false);
alert(shallowList.childNodes.length);   //0


在这个例子中,deepList 中保存着一个对myList 执行深复制得到的副本。因此,deepList 中包含3个列表项,每个列表项中都包含文本。而变量shallowList 中保存着对myList 执行浅复制得到的副本,因此它不包含子节点。deepList.childNodes.length 中的差异主要是因为IE8及更早版本与其他浏览器处理空白字符的方式不一样。IE9之前的版本不会为空白符创建节点。

cloneNode() 方法不会复制添加到DOM节点中的JavaScript属性,例如事件处理程序等。这个方法只复制特性、(在明确指定的情况下也复制)子节点,其他一切都不会复制。IE在此存在一个bug,即它会复制事件处理程序,所以我们建议在复制之前最好先移除事件处理程序。

我们要介绍的最后一个方法是normalize() ,这个方法唯一的作用就是处理文档树中的文本节点。由于解析器的实现或DOM操作等原因,可能会出现文本节点不包含文本,或者接连出现两个文本节点的情况。当在某个节点上调用这个方法时,就会在该节点的后代节点中查找上述两种情况。如果找到了空文本节点,则删除它;如果找到相邻的文本节点,则将它们合并为一个文本节点。本章后面还将进一步讨论这个方法。

JavaScript通过Document 类型表示文档。在浏览器中,document 对象是HTMLDocument (继承自Document 类型)的一个实例,表示整个HTML页面。而且,document 对象是window 对象的一个属性,因此可以将其作为全局对象来访问。Document 节点具有下列特征:

Document 类型可以表示HTML页面或者其他基于XML的文档。不过,最常见的应用还是作为HTMLDocument 实例的document 对象。通过这个文档对象,不仅可以取得与页面有关的信息,而且还能操作页面的外观及其底层结构。

在Firefox、Safari、Chrome和Opera中,可以通过脚本访问Document 类型的构造函数和原型。但在所有浏览器中都可以访问HTMLDocument 类型的构造函数和原型,包括IE8及后续版本。

1. 文档的子节点

虽然DOM标准规定Document 节点的子节点可以是DocumentTypeElementProcessingInstructionComment ,但还有两个内置的访问其子节点的快捷方式。第一个就是documentElement 属性,该属性始终指向HTML页面中的<html> 元素。另一个就是通过childNodes 列表访问文档元素,但通过documentElement 属性则能更快捷、更直接地访问该元素。以下面这个简单的页面为例。

<html>
    <body>

    </body>
</html>


这个页面在经过浏览器解析后,其文档中只包含一个子节点,即<html> 元素。可以通过documentElementchildNodes 列表来访问这个元素,如下所示。

var html = document.documentElement;        //取得对<html>的引用
alert(html === document.childNodes[0]);     //true
alert(html === document.firstChild);        //true


这个例子说明,documentElementfirstChildchildNodes[0] 的值相同,都指向<html> 元素。

作为HTMLDocument 的实例,document 对象还有一个body 属性,直接指向<body> 元素。因为开发人员经常要使用这个元素,所以document.body 在JavaScript代码中出现的频率非常高,其用法如下。

var body = document.body;    //取得对<body>的引用


所有浏览器都支持document.documentElementdocument.body 属性。

Document 另一个可能的子节点是DocumentType 。通常将<!DOCTYPE> 标签看成一个与文档其他部分不同的实体,可以通过doctype 属性(在浏览器中是document.doctype )来访问它的信息。

var doctype = document.doctype;     //取得对<!DOCTYPE>的引用


浏览器对document.doctype 的支持差别很大,可以给出如下总结。

由于浏览器对document.doctype 的支持不一致,因此这个属性的用处很有限。

从技术上说,出现在<html> 元素外部的注释应该算是文档的子节点。然而,不同的浏览器在是否解析这些注释以及能否正确处理它们等方面,也存在很大差异。以下面简单的HTML页面为例。

<!--第一条注释 -->
<html>
    <body>

    </body>
</html>
<!--第二条注释 -->


看起来这个页面应该有3个子节点:注释、<html> 元素、注释。从逻辑上讲,我们会认为document.childNodes 中应该包含与这3个节点对应的3项。但是,现实中的浏览器在处理位于<html> 外部的注释方面存在如下差异。

同样,浏览器间的这种不一致性也导致了位于<html> 元素外部的注释没有什么用处。

多数情况下,我们都用不着在document 对象上调用appendChild()removeChild()replaceChild() 方法,因为文档类型(如果存在的话)是只读的,而且它只能有一个元素子节点(该节点通常早就已经存在了)。

2. 文档信息

作为HTMLDocument 的一个实例,document 对象还有一些标准的Document 对象所没有的属性。这些属性提供了document 对象所表现的网页的一些信息。其中第一个属性就是title ,包含着<title> 元素中的文本——显示在浏览器窗口的标题栏或标签页上。通过这个属性可以取得当前页面的标题,也可以修改当前页面的标题并反映在浏览器的标题栏中。修改title 属性的值不会改变<title> 元素。来看下面的例子。

//取得文档标题
var originalTitle = document.title;

//设置文档标题
document.title = "New page title";


接下来要介绍的3个属性都与对网页的请求有关,它们是URLdomainreferrerURL 属性中包含页面完整的URL(即地址栏中显示的URL),domain 属性中只包含页面的域名,而referrer 属性中则保存着链接到当前页面的那个页面的URL。在没有来源页面的情况下,referrer 属性中可能会包含空字符串。所有这些信息都存在于请求的HTTP头部,只不过是通过这些属性让我们能够在JavaScrip中访问它们而已,如下面的例子所示。

//取得完整的URL
var url = document.URL;

//取得域名
var domain = document.domain;

//取得来源页面的URL
var referrer = document.referrer;


URLdomain 属性是相互关联的。例如,如果document.URL 等于http://www.wrox.com/WileyCDA/ , 那么document.domain 就等于www.wrox.com

在这3个属性中,只有domain 是可以设置的。但由于安全方面的限制,也并非可以给domain 设置任何值。如果URL中包含一个子域名,例如p2p.wrox.com,那么就只能将domain 设置为"wrox.com" (URL中包含"www" ,如www.wrox.com 时,也是如此)。不能将这个属性设置为URL中不包含的域,如下面的例子所示。

//假设页面来自p2p.wrox.com域

document.domain = "wrox.com";           // 成功

document.domain = "nczonline.net";      // 出错!


当页面中包含来自其他子域的框架或内嵌框架时,能够设置document.domain 就非常方便了。由于跨域安全限制,来自不同子域的页面无法通过JavaScript通信。而通过将每个页面的document.domain 设置为相同的值,这些页面就可以互相访问对方包含的JavaScript对象了。例如,假设有一个页面加载自www.wrox.com ,其中包含一个内嵌框架,框架内的页面加载自p2p.wrox.com。由于document.domain 字符串不一样,内外两个页面之间无法相互访问对方的JavaScript对象。但如果将这两个页面的document.domain 值都设置为"wrox.com" ,它们之间就可以通信了。

浏览器对domain 属性还有一个限制,即如果域名一开始是“松散的”(loose),那么不能将它再设置为“紧绷的”(tight)。换句话说,在将document.domain 设置为"wrox.com" 之后,就不能再将其设置回"p2p.wrox.com" ,否则将会导致错误,如下面的例子所示。

//假设页面来自于p2p.wrox.com域

document.domain = "wrox.com";         //松散的(成功)

document.domain = "p2p.wrox.com";     //紧绷的(出错!)


所有浏览器中都存在这个限制,但IE8是实现这一限制的最早的IE版本。

3. 查找元素

说到最常见的DOM应用,恐怕就要数取得特定的某个或某组元素的引用,然后再执行一些操作了。取得元素的操作可以使用document 对象的几个方法来完成。其中,Document 类型为此提供了两个方法:getElementById()getElementsByTagName()

第一个方法,getElementById() ,接收一个参数:要取得的元素的ID。如果找到相应的元素则返回该元素,如果不存在带有相应ID的元素,则返回null 。注意,这里的ID必须与页面中元素的id 特性(attribute)严格匹配,包括大小写。以下面的元素为例。

<div id="myDiv">Some text</div>


可以使用下面的代码取得这个元素:

var div = document.getElementById("myDiv");        //取得<div>元素的引用


但是,下面的代码在除IE7及更早版本之外的所有浏览器中都将返回null

var div = document.getElementById("mydiv");        //无效的ID(在IE7及更早版本中可以)


IE8及较低版本不区分ID的大小写,因此"myDiv""mydiv" 会被当作相同的元素ID。

如果页面中多个元素的ID值相同,getElementById() 只返回文档中第一次出现的元素。IE7及较低版本还为此方法添加了一个有意思的“怪癖”:name 特性与给定ID匹配的表单元素(<input><textarea><button><select> )也会被该方法返回。如果有哪个表单元素的name 特性等于指定的ID,而且该元素在文档中位于带有给定ID的元素前面,那么IE就会返回那个表单元素。来看下面的例子。

<input type="text" name="myElement" value="Text field">
<div id="myElement">A div</div>


基于这段HTML代码,在IE7中调用document.getElementById("myElement ") ,结果会返回<input>