Another RayJune

JS标准参考教程小记

根据ruanyf大大的Javascript标准参考教程做的笔记,谨以此来自我查询、复习所用。

导论

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

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

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

JavaScript 的核心语法部分相当精简,只包括两个部分:

基本的语法构造(比如操作符、控制结构、语句)和 标准库(就是一系列具有各种功能的对象比如ArrayDateMath等)。

除此之外,各种宿主环境提供额外的 API(即只能在该环境使用的接口),以便 JavaScript 调用。以浏览器为例,它提供的额外 API 可以分成三大类。

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

ECMAScript和JavaScript的关系是,前者是后者的规格,后者是前者的一种实现。在日常场合,这两个词是可以互换的。

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

性能优势

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

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

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

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

(2)支持编译运行。

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

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

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

语法

基本语法

语句

1 + 3叫做 表达式(expression),指一个 为了得到返回值的计算式语句 和表达式的区别在于,前者 主要为了进行某种操作,一般情况下不需要返回值;后者则是为了得到返回值,一定会返回一个值。

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

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

1
2
1 + 3;
'abc';

变量(var)

变量是 对值的 **引用**使用变量 等同于 引用一个值。每一个变量都有一个变量名。比如

1
var a = 1;

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

如果 只是声明变量而没有赋值,则该变量的值是undefined。undefined是一个JavaScript关键字,表示“无定义”。

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

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

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

严格地说,var a = 1 与 a = 1,这两条语句的效果不完全一样,主要体现在delete命令无法删除前者。不过,绝大多数情况下,这种差异是可以忽略的。

JavaScript 是一种 动态类型语言 ,也就是说, 变量的类型没有限制 ,可以赋予各种类型的值。

变量提升

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已声明,但还未赋值。

请注意, 变量提升只对var命令声明的变量有效 ,如果一个变量不是用var命令声明的,就不会发生变量提升。

Hoisting 的 作用范围是随着函数作用域 的。我理解在这里尚未讲到函数作用域,不过可以提一句提醒读者注意,然后链接至作用域的部分进一步探讨;

  1. “Hoisting 只对 var 声明的变量有效”,不尽然如此。变量声明的提升并非 hoisting 的全部,JavaScript 有四种让声明在作用域内获得提升的途径(按优先级):

-语言定义的声明,如 this 和 arguments。你不能在作用域内重新定义叫做 this 的变量,是因为 this 是语言自动定义的声明,并且它的优先级最高,也就是被 Hoisting 到最顶上了,没人能覆盖它

-形式参数。虽然我们无需用 var 来修饰形式参数,但是形式参数的确也是变量,并且被自动提升到次高的优先级

-函数声明。除了 var 以外,function declaration 也可以定义新的命名,并且同样会被 hoisting 至作用域顶部,仅次于前两者

-最后,是本文提到的常规变量,也就是 var 声明的变量

标志符

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

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

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

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

1
var 临时变量 = 1;

JS的保留字也不可以作为标志符,另外,还有三个词虽然不是保留字,但是因为具有特别含义,也不应该用作标识符:InfinityNaNundefined

注释

和C++一样, // 单行注释 // 多行注释

此外,由于历史上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。

条件语句

if 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

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

1
2
3
4
5
6
7
8
9
var x = 1;
switch (x) {
case true:
console.log('x发生类型转换');
default:
console.log('x没有发生类型转换');
}
// x没有发生类型转换

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

标签(label)

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

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

标签通常与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语句后面不使用标签,则只能跳出内层循环,进入下一次的外层循环。

数据类型

概述

JavaScript 的数据类型,共有六种。(ES6 又新增了第七种 Symbol 类型的值,本教程不涉及。)

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

通常,我们将 数值、字符串、布尔值 称为原始类型(primitive type)的值,即它们是最基本的数据类型,不能再细分了。

而将对象称为合成类型(complex type)的值,因为一个对象往往是多个原始类型的值的合成,可以看作是一个存放各种值的容器。至于undefinednull,一般将它们看成两个特殊值。

对象又可以分成三个子类型。

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

狭义的对象和数组是两种不同的数据组合方式,而函数其实是处理数据的方法。JavaScript 把函数当成一种数据类型,可以像其他类型的数据一样,进行赋值和传递,这为编程带来了很大的灵活性,体现了JavaScript作为“函数式语言”的本质。

这里需要明确的是,JavaScript的所有数据,都可以视为广义的对象。不仅数组和函数属于对象,就连原始类型的数据(数值、字符串、布尔值)也可以用对象方式调用。为了避免混淆,此后除非特别声明,本教程的”对象“都特指狭义的对象。

判断类型

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

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

typeof X or typeof ()

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

从上面代码可以看到,空数组([])的类型也是object,这表示在JavaScript内部,数组本质上只是一种特殊的对象

既然typeof对数组(array)和对象(object)的显示结果都是object,那么怎么区分它们呢?instanceof运算符可以做到。

1
2
3
4
5
var o = {};
var a = [];
o instanceof Array // false
a instanceof Array // true

null和undefined

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

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

目前nullundefined基本是同义的,只有一些细微的差别。

null的特殊之处在于,JavaScript 把它包含在对象类型(object)之中

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
// 变量声明了,但没有赋值
var i;
i // undefined
// 调用函数时,应该提供的参数没有提供,该参数等于undefined
function f(x) {
return x;
}
f() // undefined
// 对象没有赋值的属性
var o = new Object();
o.p // undefined

布尔值

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

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

而布尔值往往用于程序流程的控制。空数组([])和空对象({})对应的布尔值,都是true

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

1
2
3
4
5
6
7
8
9
10
11
12
13
if ([]) {
console.log(true);
}
if (NaN) {
console.log(true);
}
else {console.log(false};
// 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位:指数部分
第13位到第64位:小数部分(即有效数字)
符号位决定了一个数的正负,指数部分决定了数值的大小,小数部分决定了数值的精度。

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

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

用离散的数据模型去模拟实际上无穷的实数集合,误差是永远无法避免的

数值范围

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

如果指数部分等于或超过最大正值1024,JavaScript 会返回Infinity(关于Infinity的介绍参见下文),这称为“正向溢出”;如果等于或超过最小负值-1023(即非常接近0),JavaScript 会直接把这个数转为0,这称为“负向溢出”。

科学记数法

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

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

1234567890123456789012
// 1.2345678901234568e+21

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

// 小数点后紧跟6个以上的零(包括6个),
// 就自动转为科学计数法
0.0000003 // 3e-7

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

数值的进制

默认情况下,JavaScript 内部会自动将八进制、十六进制、二进制转为十进制。

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

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

1
2
0888 // 888
0777 // 511

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

特殊数值

正零和负零

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

在JavaScript内部,实际上存在2个0:一个是+0,一个是-0。它们是等价的。

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,这两者是不相等的。

NaN

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'

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

1
NaN === NaN // false

NaN在布尔运算时被当作false

1
Boolean(NaN) // false

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

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

判断NaN的方法

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

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

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

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

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

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

Infinity(无穷)

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

1
2
3
4
5
6
7
// 场景一
Math.pow(2, Math.pow(2, 100))
// Infinity
// 场景二
0 / 0 // NaN
1 / 0 // Infinity

上面代码中,第一个场景是一个表达式的计算结果太大,超出了JavaScript能够表示的范围,因此返回Infinity

第二个场景是0除以0会得到NaN,而非0数值除以0,会返回Infinity

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

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

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

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

(3)isFinite函数

isFinite函数返回一个布尔值,检查某个值是不是正常数值,而不是Infinity

1
2
3
4
isFinite(Infinity) // false
isFinite(-1) // true
isFinite(true) // true
isFinite(NaN) // false

上面代码表示,如果对NaN使用isFinite函数,也返回false,表示NaN不是一个正常值。

与数值相关的全局方法

parseInt()

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

parseInt(‘123’) // 123
如果字符串头部有空格,空格会被自动去除。

parseInt(‘ 81’) // 81
如果parseInt的参数不是字符串,则会先转为字符串再转换。

parseInt(1.23) // 1
// 等同于
parseInt(‘1.23’) // 1
字符串转为整数的时候,是一个个字符依次转换,如果遇到不能转为数字的字符,就不再进行下去,返回已经转好的部分。

parseInt(‘8a’) // 8
parseInt(‘12**’) // 12
parseInt(‘12.34’) // 12
parseInt(‘15e2’) // 15
parseInt(‘15px’) // 15
上面代码中,parseInt的参数都是字符串,结果只返回字符串头部可以转为数字的部分。

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

parseInt(‘abc’) // NaN
parseInt(‘.3’) // NaN

parseInt的返回值只有两种可能,不是一个十进制整数,就是NaN。

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

parseInt(‘0x10’) // 16
如果字符串以0开头,将其按照10进制解析。

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

(2)进制转换

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

parseInt(‘1000’) // 1000
// 等同于
parseInt(‘1000’, 10) // 1000
下面是转换指定进制的数的例子。

parseInt(‘1000’, 2) // 8
parseInt(‘1000’, 6) // 216

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

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

parseInt(‘10’, 37) // NaN
parseInt(‘10’, 1) // NaN
parseInt(‘10’, 0) // 10
parseInt(‘10’, null) // 10
parseInt(‘10’, undefined) // 10

如果字符串包含对于指定进制无意义的字符,则 从最高位开始,只返回可以转换的数值 。如果最高位无法转换,则直接返回NaN。
parseInt(‘1546’, 2) // 1
parseInt(‘546’, 2) // NaN
上面代码中,对于二进制来说,1是有意义的字符,5、4、6都是无意义的字符,所以第一行返回1,第二行返回NaN。

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

1
2
3
4
parseInt(0x11, 36) // 43
// 等同于
parseInt(String(0x11), 36)
parseInt('17', 36)

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

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

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

parseInt(‘011’, 2) // 3
上面代码中,第一行的011会被先转为字符串9,因为9不是二进制的有效字符,所以返回NaN。第二行的字符串011,会被当作二进制处理,返回3。

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

parseFloat()

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

parseFloat(‘3.14’) // 3.14
如果字符串符合科学计数法,则会进行相应的转换

parseFloat(‘314e-2’) // 3.14
parseFloat(‘0.0314E+2’) // 3.14
如果字符串包含不能转为浮点数的字符,则不再进行往后转换,返回已经转好的部分。

parseFloat(‘3.14more non-digit characters’) // 3.14
parseFloat方法会自动过滤字符串前导的空格。

parseFloat(‘\t\v\r12.34\n ‘) // 12.34
如果参数不是字符串,或者字符串的第一个字符不能转化为浮点数,则返回NaN。

parseFloat([]) // NaN
parseFloat(‘FF2’) // NaN
parseFloat(‘’) // NaN
上面代码中,尤其值得注意, parseFloat会将空字符串转为NaN

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

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

字符串

定义

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

1
2
'abc'
"abc"

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

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

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

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

1
'Did she say \'Hello\'?'

由于HTML语言的属性值使用双引号,所以很多项目约定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
'\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

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

字符串也无法直接使用数组的方法,必须通过call方法间接使用。

1
2
3
4
5
var s = 'hello';
s.join(' ') // TypeError: s.join is not a function
Array.prototype.join.call(s, ' ') // "h e l l o"

上面代码中,如果直接对字符串使用数组的join方法,会报错不存在该方法。但是,可以通过call方法,间接对字符串使用join方法。

由于字符串是 **只读的** ,那些会改变原数组的方法,比如push()sort()reverse()splice()都对字符串无效,只有将字符串显式转为数组后才能使用

legth属性

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属性无法改变,但是不会报错。

Unicode字符集

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会自动识别。

对象(object)

概述

对象(object)是JavaScript的核心概念,也是最重要的数据类型。JavaScript的所有数据都可以被视为对象。

简单说,所谓对象,就 是一种无序的数据集合由若干个“键值对”(key-value)构成

1
2
3
var o = {
p: 'Hello World'
};

生成方法

对象的生成方法,通常有三种方法。除了像上面那样直接使用大括号生成({}),还可以用new命令生成一个Object对象的实例,或者使用Object.create方法生成。

1
2
3
var o1 = {};
var o2 = new Object();
var o3 = Object.create(Object.prototype);

上面三行语句是等价的。一般来说,第一种采用大括号的写法比较简洁,第二种采用构造函数的写法清晰地表示了意图,第三种写法一般用在需要 对象继承的场合

对象的所有键名都是字符串,所以加不加引号都可以。上面的代码也可以写成下面这样。

1
2
3
var o = {
'p': 'Hello World'
};

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

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

1
2
3
4
5
var o = {
'1p': "Hello World",
'h w': "Hello World",
'p+q': "Hello World"
};

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

注意,JavaScript的保留字可以不加引号当作键名

1
2
3
4
var obj = {
for: 1,
class: 2
};

属性

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

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

上面的对象就有一个方法p,它就是一个函数。

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

1
2
3
4
var o = {
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
({ foo: 123})

这种差异在eval语句中反映得最明显。

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

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

属性的操作

读取属性

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

请注意, 如果使用方括号运算符,键名必须放在引号里面,否则会被当作变量处理。但是,数字键可以不加引号,因为会被当作字符串处理

1
2
3
4
5
6
var o = {
0.7: 'Hello World'
};
o['0.7'] // "Hello World"
o[0.7] // "Hello World"

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

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

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

1
2
3
4
obj.0xFF
// SyntaxError: Unexpected token
obj[0xFF]
// true

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

检查变量是否声明

如果读取一个不存在的键,会返回undefined,而不是报错。可以利用这一点,来检查一个全局变量是否被声明。

1
2
3
4
5
// 检查a变量是否被声明
if (a) {...} // 报错
if (window.a) {...} // 不报错
if (window['a']) {...} // 不报错

上面的后二种写法之所以不报错,是因为在浏览器环境,所有全局变量都是window对象的属性。window.a的含义就是读取window对象的a属性,如果该属性不存在,就返回undefined,并不会报错。

需要注意的是,后二种写法有漏洞,如果a属性是一个空字符串(或其他对应的布尔值为false的情况),则无法起到检查变量是否声明的作用。正确的做法是可以采用下面的写法。

1
2
3
4
5
if ('a' in window) {
// 变量 a 声明过
} else {
// 变量 a 未声明
}

属性的赋值

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

1
2
o.p = 'abc';
o['p'] = 'abc';

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

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

1
2
3
4
5
6
var o = { p: 1 };
// 等价于
var o = {};
o.p = 1;

查看所有属性 Object.keys

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

1
2
3
4
5
6
7
var o = {
key1: 1,
key2: 2
};
Object.keys(o);
// ['key1', 'key2']

delete命令

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

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

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

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

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

上面代码中,o对象并没有p属性,但是delete命令照样返回true。因此,不能根据delete命令的结果,认定某个属性是存在的,只能保证读取这个属性肯定得到undefined

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

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

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

另外,需要注意的是,delete命令只能删除对象本身的属性,无法删除继承的属性

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

上面代码中,toString是对象o继承的属性,虽然delete命令返回true,但该属性并没有被删除,依然存在。

最后,delete命令不能删除var命令声明的变量,只能用来删除属性

1
2
3
var p = 1;
delete p // false
delete window.p // false

上面命令中,pvar命令声明的变量,delete命令无法删除它,返回false。因为var声明的全局变量都是顶层对象的属性,而且默认不得删除

in运算符

in运算符用于检查对象是否包含某个属性(注意,检查的是键名,不是键值),如果包含就返回true,否则返回false

1
2
var o = { p: 1 };
'p' in o // true

在JavaScript语言中,所有全局变量都是顶层对象(浏览器的顶层对象就是window对象)的属性,因此可以用in运算符判断,一个全局变量是否存在

1
2
3
4
5
6
7
8
9
10
// 假设变量x未定义
// 写法一:报错
if (x) { return 1; }
// 写法二:不正确
if (window.x) { return 1; }
// 写法三:正确
if ('x' in window) { return 1; }

上面三种写法之中,如果x不存在,第一种写法会报错;如果x的值对应布尔值false(比如x等于空字符串),第二种写法无法得到正确结果;只有第三种写法,才能正确判断变量x是否存在

in运算符的一个问题是,它 不能识别对象继承的属性

1
2
3
4
var o = new Object();
o.hasOwnProperty('toString') // false
'toString' in o // true

上面代码中,toString方法不是对象o自身的属性,而是继承的属性,hasOwnProperty方法可以说明这一点。但是,in运算符不能识别,对继承的属性也返回true

for…in循环

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

1
2
3
4
5
6
7
8
var o = {a: 1, b: 2, c: 3};
for (var i in o) {
console.log(o[i]);
}
// 1
// 2
// 3

下面是一个使用for...in循环,提取对象属性的例子。

1
2
3
4
5
6
7
8
9
var obj = {
x: 1,
y: 2
};
var props = [];
var i = 0;
for (props[i++] in obj);
props // ['x', 'y']

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

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

如果只想遍历对象本身的属性,可以使用hasOwnProperty方法,在循环内部判断一下是不是自身的属性。

1
2
3
4
5
6
for (var key in person) {
if (person.hasOwnProperty(key)) {
console.log(key);
}
}
// name

对象person其实还有其他继承的属性,比如toString。

1
2
person.toString()
// "[object Object]"

这个toString属性不会被for…in循环遍历到,因为它默认设置为“不可遍历”。

一般情况下,都是 只想遍历对象自身的属性 ,所以不推荐使用for...in循环

with语句

不要使用with语句

  • strict模式下直接报错
  • 性能降低:使用了with关键字后,JS引擎无法对这段代码进行优化。使用with关键字对性能的影响还有一点就是js压缩工具,它无法对这段代码进行压缩,这也是影响性能的一个因素。
  • 语义不明,调试困难

数组

定义

数组(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"

数组的特殊性体现在,它的键名是按次序排列的一组整数(0,1,2…)。

1
2
3
4
var arr = ['a', 'b', 'c'];
Object.keys(arr)
// ["0", "1", "2"]

上面代码中,Object.keys方法返回数组的所有键名。可以看到数组的键名就是整数0、1、2。

由于数组成员的键名是固定的,因此数组不用为每个元素指定键名,而对象的每个成员都必须指定键名。

JavaScript语言规定,对象的键名一律为字符串,所以,数组的键名其实也是字符串。之所以可以用数值读取,是因为非字符串的键名会被转为字符串。

1
2
3
4
var arr = ['a', 'b', 'c'];
arr['0'] // 'a'
arr[0] // 'a'

上面代码分别用数值和字符串作为键名,结果都能读取数组。原因是数值键名被自动转为了字符串。

需要注意的是,这一条在赋值时也成立。如果一个值可以被转换为整数,则以该值为键名,等于以对应的整数为键名。

1
2
3
4
5
6
7
var a = [];
a['1000'] = 'abc';
a[1000] // 'abc'
a[1.00] = 6;
a[1] // 6

上面代码表明,由于字符串“1000”和浮点数1.00都可以转换为整数,所以视同为整数键赋值。

上一节说过,对象有两种读取成员的方法:“点”结构(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个(2的32次方- 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方法,也可以用来遍历数组。

1
2
3
4
var colors = ['red', 'green', 'blue'];
colors.forEach(function (color) {
console.log(color);
});

数组的空位

当数组的某个位置是空元素,即两个逗号之间没有任何值,我们称该数组存在空位(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,所以遍历不会跳过。

函数

函数就是一段可以 反复调用的代码块。函数还能接受输入的参数,不同的参数会返回不同的值。而JavaScript有三种方法,可以声明一个函数。

函数的声明

(1)function命令

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

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

(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
11
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
9
var foo = new Function(
'return "hello world"'
);
// 等同于
function foo() {
return 'hello world';
}

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

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

函数的重复声明

如果同一个函数被多次声明,后面的声明就会覆盖前面的声明。。而且,由于 函数名的提升 (参见下文),前一次声明在任何时候都是无效的,这一点要特别注意。

return & 递归

函数体内部的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引擎将函数名视同变量名,所以采用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

不能在条件语句声明函数

根据ECMAScript的规范, **不得在非函数的代码块中声明函数** ,最常见的情况就是if和try语句。

1
2
3
4
5
6
7
8
9
if (foo) {
function x() {}
}
try {
function x() {}
} catch(e) {
console.log(e);
}

上面代码分别在if代码块和try代码块中声明了两个函数,按照语言规范,这是不合法的。但是,实际情况是各家浏览器往往并不报错,能够运行。

但是由于存在函数名的提升,所以在条件语句中声明函数,可能是无效的,这是非常容易出错的地方。

1
2
3
4
5
if (false) {
function f() {}
}
f() // 不报错

上面代码的原始意图是不声明函数f,但是由于f的提升,导致if语句无效,所以上面的代码不会报错。

要达到在条件语句中定义函数的目的,只有使用函数表达式。

1
2
3
4
5
if (false) {
var f = function () {};
}
f() // undefined

函数的属性和方法

name属性

name属性返回紧跟在function关键字之后的那个函数名。

1
2
3
4
5
6
7
8
function f1() {}
f1.name // 'f1'
var f2 = function () {};
f2.name // ''
var f3 = function myName() {};
f3.name // 'myName'

上面代码中,函数的name属性总是返回紧跟在function关键字之后的那个函数名。对于f2来说,返回空字符串,匿名函数的name属性总是为空字符串;对于f3来说,返回函数表达式的名字(真正的函数名还是f3myName这个名字只在函数体内部可用)。

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();
// }

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

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

函数作用域

定义

作用域(scope)指的是变量存在的范围。Javascript只有两种作用域:一种是全局作用域,变量在整个程序中一直存在,所有地方都可以读取;另一种是函数作用域,变量只在函数内部存在。

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

1
2
3
4
5
function f(){
var v = 1;
}
v // ReferenceError: v is not defined

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

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

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

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

函数内部的变量提升

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

1
2
3
4
5
function foo(x) {
if (x > 100) {
var tmp = x - 100;
}
}

上面的代码等同于

1
2
3
4
5
6
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。正是这种机制,构成了下文要讲解的“闭包”现象。

参数

定义

函数运行的时候,有时需要提供外部数据,不同的外部数据会得到不同的结果,这种外部数据就叫参数。

函数的参数是可以省略的,会爆出undefined的错误。但没有办法只省略靠前的参数,而保留靠后的参数。如果一定要省略靠前的参数,只有显式传入undefined

1
2
3
4
5
6
function f(a, b) {
return a;
}
f( , 1) // SyntaxError: Unexpected token ,(…)
f(undefined, 1) // undefined

上面代码中,如果省略第一个参数,就会报错。

默认值

通过下面的方法,可以为函数的参数设置默认值。

1
2
3
4
5
6
7
function f(a){
a = a || 1;
return a;
}
f('') // 1
f(0) // 1

上面代码的||表示“或运算”,即如果a有值,则返回a,否则返回事先设定的默认值(上例为1)。

这种写法会对a进行一次布尔运算,只有为true时,才会返回a。可是,除了undefined以外,0、空字符、null等的布尔值也是false。也就是说,在上面的函数中,不能让a等于0或空字符串,否则在明明有参数的情况下,也会返回默认值。

为了避免这个问题,可以采用下面更精确的写法。

1
2
3
4
5
6
7
8
function f(a) {
(a !== undefined && a !== null) ? a = a : a = 1;
return a;
}
f() // 1
f('') // ""
f(0) // 0

上面代码中,函数f的参数是空字符或0,都不会触发参数的默认值。

传递方式

函数参数如果是原始类型的值(数值、字符串、布尔值),传递方式是传值传递(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存在一个赋值关系

1
2
// 函数f内部
o = obj;

上面代码中,对o的修改都会反映在obj身上。但是,如果对o赋予一个新的值,就等于切断了oobj的联系,导致此后的修改都不会影响到obj了。

某些情况下,如果需要对某个原始类型的变量,获取传址传递的效果,可以将它写成全局对象的属性。

1
2
3
4
5
6
7
8
var a = 1;
function f(p) {
window[p] = 2;
}
f('a');
a // 2

上面代码中,变量a本来是传值传递,但是写成window对象的属性,就达到了传址传递的效果。

注意:此时的传入参数为f(‘a’)而不是f(a),如果是f(a)同样无法修改原值

同名参数

如果有同名的参数,则取最后出现的那个值。

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对象

由于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
8
var f = function(a, b) {
arguments[0] = 3;
arguments[1] = 2;
return a + b;
}
f(1, 1)
// 5

最重要arguments.length:可以通过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);
// or
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(one) {
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)

通常情况下,只对匿名函数使用这种“立即执行的函数表达式”。它的目的有两个:一是不必为函数命名,避免了污染全局变量;二是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命令

val命令的作用是,将字符串当作语句执行

1
2
eval('var a = 1;');
a // 1

上面代码将字符串当作语句运行,生成了变量a

放在eval中的字符串,应该有独自存在的意义,不能用来与eval以外的命令配合使用。举例来说,下面的代码将会报错。

1
eval('return;');

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方法。

avaScript引擎内部,eval实际上是一个引用,默认调用一个内部方法。这使得eval的使用分成两种情况,一种是像上面这样的调用eval(expression),这叫做“直接使用”,这种情况下eval的作用域就是当前作用域。除此之外的调用方法,都叫“间接调用”,此时eval的作用域总是全局作用域

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为全局变量。

运算符

加法运算符

加法运算符(+)是最常见的运算符之一,但是使用规则却相对复杂。因为在JavaScript语言里面,这个运算符可以完成两种运算,既可以处理算术的加法,也可以用作字符串连接,它们都写成+。

它的算法步骤如下。

  1. 如果运算子是对象,先自动转成原始类型的值(即先执行该对象的valueOf方法,如果结果还不是原始类型的值,再执行toString方法;如果对象是Date实例,则先执行toString方法)。
  2. 两个运算子都是原始类型的值以后,只要有一个运算子是字符串,则两个运算子都转为字符串,执行字符串连接运算。
  3. 否则,两个运算子都转为数值,执行加法运算。

加法运算符以外的其他算术运算符(比如减法、除法和乘法),都不会发生重载。它们的规则是:所有运算子一律转为数值,再进行相应的数学运算。

1
2
3
1 - '2' // -1
1 * '2' // 2
1 / '2' // 0.5

上面代码中,减法、除法和乘法运算符,都是将字符串自动转为数值,然后再运算。

由于加法运算符与其他算术运算符的这种差异,会导致一些意想不到的结果,计算时要小心。

1
2
3
var now = new Date();
typeof (now + 1) // "string"
typeof (now - 1) // "number"

上面代码中,now是一个Date对象的实例。加法运算时,得到的是一个字符串;减法运算时,得到却是一个数值。

严格相等运算符

JavaScript提供两个相等运算符:==和===。

简单说,它们的区别是相等运算符(==)比较两个值是否相等,严格相等运算符(===)比较它们是否为“同一个值”。如果两个值不是同一类型,严格相等运算符(===)直接返回false,而相等运算符(==)会将它们转化成同一个类型,再用严格相等运算符进行比较。

对于两个对象的比较,严格相等运算符比较的是地址,而大于或小于运算符比较的是值。

1
2
3
new Date() > new Date() // false
new Date() < new Date() // false
new Date() === new Date() // false

上面的三个表达式,前两个比较的是值,最后一个比较的是地址,所以都返回false。

undefined和null与自身严格相等。

由于变量声明后默认值是undefined,因此两个只声明未赋值的变量是相等的。

1
2
3
var v1;
var v2;
v1 === v2 // true

相等运算符的缺点(==)

相等运算符隐藏的类型转换,会带来一些违反直觉的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'' == '0' // false
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

上面这些表达式都很容易出错,因此不要使用相等运算符(==),最好只使用严格相等运算符(===)。

取反运算符(!)

取反运算符形式上是一个感叹号,用于将布尔值变为相反值,即true变成false,false变成true。

1
2
!true // false
!false // true

对于非布尔值的数据,取反运算符会自动将其转为布尔值。规则是,以下六个值取反后为true,其他值取反后都为false。

  • undefined
  • null
  • false
  • 0(包括+0和-0)
  • NaN
  • 空字符串(’’)

这意味着,取反运算符有转换数据类型的作用。不管什么类型的值,经过取反运算后,都变成了布尔值。

如果对一个值连续做两次取反运算,等于将其转为对应的布尔值,与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结构。

1
2
3
4
5
6
7
if (i) {
doSomething();
}
// 等价于
i && doSomething();

且运算符可以多个连用,这时返回第一个布尔值为false的表达式的值

1
2
true && 'foo' && '' && 4 && 'foo' && true
// ''

或运算符(||)

或运算符(||)的运算规则是:如果第一个运算子的布尔值为true,则返回第一个运算子的值,且不再对第二个运算子求值;如果第一个运算子的布尔值为false,则返回第二个运算子的值。

或运算符可以多个连用,这时返回第一个布尔值为true的表达式的值

1
2
false || 0 || '' || 4 || 'foo' || true
// 4

上面代码中第一个布尔值为true的表达式是第四个表达式,所以得到数值4。

或运算符常用于为一个变量设置默认值。

1
2
3
4
5
6
7
8
function saveText(text) {
text = text || '';
// ...
}
// 或者写成
saveText(this.text || '')

三元条件运算符( ? : )

通常来说,三元条件表达式与if...else语句具有同样表达效果,前者可以表达的,后者也能表达。但是两者具有一个重大差别,if...else是语句,没有返回值;三元条件表达式是表达式,具有返回值。所以,在需要返回值的场合,只能使用三元条件表达式,而不能使用if..else

1
console.log(true ? 'T' : 'F');

上面代码中,console.log方法的参数必须是一个表达式,这时就只能使用三元条件表达式。如果要用if...else语句,就必须改变整个代码写法了。

位运算

异或运算( ^ )

“异或运算”在两个二进制位不同时返回1相同时返回0

1
0 ^ 3 // 3

上面表达式中,0的二进制形式是003的二进制形式是11,它们每一个二进制位都不同,所以得到11(即3)。

“异或运算”有一个特殊运用,连续对两个数a和b进行三次异或运算,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

这是互换两个变量的值的最快方法。

异或运算也可以用来取整。

12.9 ^ 0 // 12

其他运算符

void运算符

void运算符的作用是执行一个表达式,然后不返回任何值,或者说返回undefined

1
2
void 0 // undefined
void(0) // undefined

上面是void运算符的两种写法,都正确。建议采用后一种形式,即总是使用括号。因为void运算符的优先性很高,如果不使用括号,容易造成错误的结果。比如,void 4 + 7实际上等同于(void 4) + 7

1
<a href="http://example.com" onclick="f();">文字</a>

上面代码有一个问题,函数f必须返回false,或者说onclick事件必须返回false,否则会引起浏览器跳转到example.com

void运算符可以取代上面两种写法。

1
<a href="javascript: void(f())">文字</a>

下面的代码会提交表单,但是不会产生页面跳转。

1
2
<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

上面代码中,逗号运算符返回后一个表达式的值

运算顺序

左结合与右结合

对于优先级别相同的运算符,大多数情况,计算顺序总是从左到右,这叫做运算符的“左结合”(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));

上面的两行代码,各有三个等号运算符和三个三元运算符,都是先计算最右边的那个运算符。

数据类型转换

定义

JavaScript 是一种动态类型语言,变量没有类型限制,可以随时赋予任意值。

1
var x = y ? 1 : 'a';

上面代码中,变量x到底是数值还是字符串,取决于另一个变量y的值。只有在代码运行时,才可能知道x的类型。

虽然变量没有类型,但是数据本身和各种运算符是有类型的。如果运算符发现,数据的类型与预期不符,就会自动转换类型。比如,减法运算符预期两侧的运算子应该是数值,如果不是,就会自动将它们转为数值。

1
'4' - '3' // 1

上面代码中,虽然是两个字符串相减,但是依然会得到结果1,原因就在于JavaScript将它们自动转为了数值。

强制转换

强制转换主要指使用Number、String和Boolean三个构造函数,手动将各种类型的值,转换成数字、字符串或者布尔值。

Number

原始类型的值

原始类型的值主要是字符串、布尔值、undefined和null,它们都能被Number转成数值或NaN。

Number函数将字符串转为数值,要比parseInt函数严格很多。基本上,只要有一个字符无法转成数值,整个字符串就会被转为NaN

parseInt(‘42 cats’) // 42
Number(‘42 cats’) // NaN
上面代码中,parseInt逐个解析字符,而Number函数整体转换字符串的类型。

另外,Number函数会自动过滤一个字符串前导和后缀的空格。

1
Number('\t\v\r12.34\n') // 12.34
对象的转换规则

简单的规则是,Number方法的参数是对象时,将返回NaN,除非是包含单个数值的数组。

1
2
3
Number({a: 1}) // NaN
Number([1, 2, 3]) // NaN
Number([5]) // 5

之所以会这样,是因为Number背后的转换规则比较复杂。

  1. 调用对象自身的valueOf方法。如果返回原始类型的值,则直接对该值使用Number函数,不再进行后续步骤。
  2. 如果valueOf方法返回的还是对象,则改为调用对象自身的toString方法。如果toString方法返回原始类型的值,则对该值使用Number函数,不再进行后续步骤。
  3. 如果toString方法返回的是对象,就报错。

String()

使用String函数,可以将任意类型的值转化成字符串。转换规则如下。

原始类型值的转换规则
  • 数值:转为相应的字符串。
  • 字符串:转换后还是原来的值。
  • 布尔值: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"
对象的转换规则

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方法返回的是对象,就报错。

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

注意所有对象(包括空对象)的转换结果都是true,甚至连false对应的布尔对象new Boolean(false)也是true。

自动转换

遇到以下三种情况时,JavaScript会自动转换数据类型,即转换是自动完成的,对用户不可见。

1
2
3
4
5
6
7
8
9
10
11
// 1. 不同类型的数据互相运算
123 + 'abc' // "123abc"
// 2. 对非布尔值类型的数据求布尔值
if ('abc') {
console.log('hello')
} // "hello"
// 3. 对非数值类型的数据使用一元运算符(即“+”和“-”)
+ {foo: 'bar'} // NaN
- [1, 2, 3] // NaN

自动转换的规则是这样的:预期什么类型的值,就调用该类型的转换函数。比如,某个位置预期为字符串,就调用String函数进行转换。如果该位置即可以是字符串,也可能是数值,那么默认转为数值。

由于自动转换具有不确定性,而且不易除错,建议在预期为布尔值、数值、字符串的地方,全部使用Boolean、Number和String函数进行显式转换

错误处理机制

Error对象

JavaScript解析或执行时,一旦发生错误,引擎就会抛出一个错误对象。JavaScript原生提供一个Error构造函数,所有抛出的错误都是这个构造函数的实例。

1
2
var err = new Error('出错了');
err.message // "出错了"

上面代码中,我们调用Error构造函数,生成一个err实例。

Error构造函数接受一个参数,表示错误提示,可以从实例的message属性读到这个参数。

代码解析或运行时发生错误,JavaScript引擎就会自动产生、并抛出一个Error对象的实例,然后整个程序就中断在发生错误的地方,不再往下执行。

根据语言标准,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函数,最后是在函数的运行环境中。

JS原生错误类型

Error对象是最一般的错误类型,在它的基础上,JavaScript还定义了其他6种错误,也就是说,存在Error的6个派生对象。

(1)SyntaxError

SyntaxError是解析代码时发生的语法错误

1
2
3
4
5
// 变量名错误
var 1a;
// 缺少括号
console.log 'hello');

(2)ReferenceError

ReferenceError是 引用一个不存在的变量时发生的错误

1
2
unknownVariable
// ReferenceError: unknownVariable is not defined

另一种触发场景是,将一个值分配给无法分配的对象,比如对函数的运行结果或者this赋值

1
2
3
4
5
console.log() = 1
// ReferenceError: Invalid left-hand side in assignment
this = 1
// ReferenceError: Invalid left-hand side in assignment

上面代码对函数console.log的运行结果和this赋值,结果都引发了ReferenceError错误。

(3)RangeError

RangeErro是 ·当一个值超出有效范围时发生的错误 。主要有几种情况,一是 数组长度为负数 ,二是 Number对象的方法参数超出范围 ,以及 函数堆栈超过最大值

1
2
3
4
5
new Array(-1)
// RangeError: Invalid array length
(1234).toExponential(21)
// RangeError: toExponential() argument must be between 0 and 20

(4)TypeError

TypeError是 变量或参数不是预期类型时发生的错误 。比如,对字符串、布尔值、数值等原始类型的值使用new命令,就会抛出这种错误,因为new命令的参数应该是一个构造函数。

1
2
3
4
5
6
new 123
//TypeError: number is not a func
var obj = {};
obj.unknownMethod()
// TypeError: obj.unknownMethod is not a function

上面代码的第二种情况,调用对象不存在的方法,会抛出TypeError错误。

(5)URIError

URIError是 URI相关函数的参数不正确时抛出的错误 ,主要涉及encodeURI()decodeURI()encodeURIComponent()decodeURIComponent()escape()unescape()这六个函数。

1
2
decodeURI('%2')
// URIError: URI malformed

(6)EvalError (几乎不存在了)

eval函数没有被正确执行时,会抛出EvalError错误。该错误类型已经不再在ES5中出现了,只是为了保证与以前代码兼容,才继续保留。

以上这6种派生错误,连同原始的Error对象,都是构造函数。开发者可以使用它们,人为生成错误对象的实例。

1
2
3
new Error('出错了!');
new RangeError('出错了,变量超出有效范围!');
new TypeError('出错了,变量类型无效!');

上面代码新建错误对象的实例,实质就是手动抛出错误。可以看到,错误对象的构造函数接受一个参数,代表错误提示信息(message)。

自定义错误

除了JavaScript内建的7种错误对象,还可以定义自己的错误对象。

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
5
6
7
8
9
10
11
// 抛出一个字符串
throw "Error!";
// 抛出一个数值
throw 42;
// 抛出一个布尔值
throw true;
// 抛出一个对象
throw {toString: function() { return "Error!"; } };

上面代码表示,throw可以接受各种值作为参数。JavaScript引擎一旦遇到throw语句,就会停止执行后面的语句,并将throw语句的参数值,返回给用户。

如果只是简单的错误,返回一条出错信息就可以了,但是如果遇到复杂的情况,就需要在出错以后进一步处理。这时最好的做法是使用throw语句手动抛出一个Error对象。

1
throw new Error('出错了!');

上面语句新建一个Error对象,然后将这个对象抛出,整个程序就会中断在这个地方。

throw语句还可以抛出用户自定义的错误。

1
2
3
4
5
6
7
8
9
10
function UserError(message) {
this.message = message || "默认信息";
this.name = "UserError";
}
UserError.prototype.toString = function (){
return this.name + ': "' + this.message + '"';
}
throw new UserError("出错了!");

可以通过自定义一个assert函数,规范化throw抛出的信息。

1
2
3
4
function assert(expression, message) {
if (!expression)
throw {name: 'Assertion Exception', message: message};
}

上面代码定义了一个assert函数,它接受一个表达式和一个字符串作为参数。一旦表达式不为真,就抛出指定的字符串。它的用法如下。

1
assert(typeof myVar != 'undefined', 'myVar is undefined!');

console对象的assert方法,与上面函数的工作机制一模一样,所以可以直接使用。

1
console.assert(typeof myVar != 'undefined', 'myVar is undefined!');

try…catch语句

为了对错误进行处理,需要使用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代码块抛出的值。
catch代码块捕获错误之后,程序不会中断,会按照正常流程继续执行下去

try…catch结构是JavaScript语言受到Java语言影响的一个明显的例子。这种结构多多少少是对结构化编程原则一种破坏,处理不当就会变成类似goto语句的效果,应该谨慎使用

try...catch结构允许在最后添加一个finally代码块,表示不管是否出现错误,都必需在最后运行的语句。

1
2
3
4
5
6
7
8
9
10
11
12
function cleansUp() {
try {
throw new Error('出错了……');
console.log('此行不会执行');
} finally {
console.log('完成清理工作');
}
}
cleansUp()
// 完成清理工作
// Error: 出错了……

上面代码中,由于没有catch语句块,所以错误没有捕获。执行finally代码块以后,程序就中断在错误抛出的地方。

即使有return语句在前,finally代码块依然会得到执行,且在其执行完毕后,才会显示return语句的值。return语句的执行是排在finally代码之前,只是等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语句。

编程风格

好的编程风格有助于写出质量更高、错误更少、更易于维护的程序。

所以,”编程风格”的选择不应该基于个人爱好、熟悉程度、打字量等因素,而要考虑如何 尽量使代码清晰易读、减少出错 。你选择的,不是你喜欢的风格,而是一种能够 清晰表达你的意图的风格 。这一点,对于JavaScript这种语法自由度很高的语言尤其重要。

必须牢记的一点是,如果你选定了一种“编程风格”,就应该坚持遵守, 切忌多种风格混用 。如果你加入他人的项目,就应该遵守现有的风格。

缩进

tab || 空格 二选一

区块

区块起首的大括号的位置,有许多不同的写法。最流行的有两种。一种是起首的大括号另起一行:

1
2
3
4
block
{
// ...
}

另一种是起首的大括号跟在关键字的后面。

1
2
3
block {
// ...
}

一般来说,这两种写法都可以接受。但是,JavaScript要使用后一种,因为JavaScript会自动添加句末的分号,导致一些难以察觉的错误。

1
2
3
4
return
{
key: value
};

// 相当于

1
2
3
4
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

我们可以用空格,区分这两种不同的括号。

  • 表示函数调用时,函数名与左括号之间没有空格。
  • 表示函数定义时,函数名与左括号之间没有空格。
  • 其他情况时,前面位置的语法元素与左括号之间,都有一个空格。

行尾的分号

使用分号的情况

需要注意的是do...while循环是有分号的。

1
2
3
do {
a--;
} while(a > 0); // 分号不能省略

但是函数表达式仍然要使用分号。

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

不使用分号的情况 (使用也不会错)

  • for和while循环
1
2
3
4
5
for ( ; ; ) {
} // 没有分号
while (true) {
} // 没有分号

需要注意的是do...while循环是有分号的

1
2
3
do {
a--;
} while(a > 0); // 分号不能省略
  • 分支语句:if,switch,try
1
2
3
4
5
6
7
8
9
if (true) {
} // 没有分号
switch () {
} // 没有分号
try {
} catch {
} // 没有分号
  • 函数的声明语句
1
2
function f() {
} // 没有分号

但是 函数表达式仍然要使用分号

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

全局变量

JavaScript最大的语法缺点,可能就是全局变量对于任何一个代码块,都是可读可写。这对代码的模块化和重复使用,非常不利。

因此,避免使用全局变量。 如果不得不使用,用大写字母表示变量名,比如UPPER_CASE。

变量声明

JavaScript会自动将变量声明”提升”(hoist)到代码块(block)的头部。

1
2
3
4
5
6
7
8
9
10
if (!o) {
var o = {};
}
// 等同于
var o;
if (!o) {
o = {};
}

为了避免可能出现的问题,最好 把变量声明都放在代码块的头部

1
2
3
4
5
6
7
8
9
10
for (var i = 0; i < 10; i++) {
// ...
}
// 写成
var i;
for (i = 0; i < 10; i++) {
// ...
}

另外,所有函数都应该在使用之前定义,函数内部的变量声明,都应该放在函数的头部

new命令

JavaScript使用new命令,从构造函数生成一个新对象。

1
var o = new myObject();

上面这种做法的问题是,一旦你忘了加上newmyObject()内部的this关键字就会指向全局对象,导致所有绑定在this上面的变量,都变成全局变量。

因此,建议使用Object.create()命令,替代new命令。如果不得不使用new,为了防止出错,最好在视觉上把构造函数与其他函数区分开来。比如,构造函数的函数名,采用首字母大写(InitialCap),其他函数名一律首字母小写。

With语句 (不要使用就好了)

相等和严格相等 (选择严格相等)

用 += -= 替代 ++ –

标准库

Object对象

概述

JavaScript 原生提供Object对象(注意起首的O是大写),所有其他对象都继承自这个对象。Object本身也是一个构造函数,可以直接通过它来生成新对象。

1
var obj = new Object();

Object作为构造函数使用时,可以接受一个参数。如果该参数是一个对象,则直接返回这个对象;如果是一个原始类型的值,则返回该值对应的包装对象。

1
2
3
4
5
6
var o1 = {a: 1};
var o2 = new Object(o1);
o1 === o2 // true
new Object(123) instanceof Number
// true

注意,通过new Object()的写法生成新对象,与字面量的写法o = {}是**等价**的。

与其他构造函数一样,如果要在Object对象上面部署一个方法,有两种做法

(1)部署在Object对象本身

比如,在Object对象上面定义一个print方法,显示其他对象的内容。

1
2
3
4
5
6
Object.print = function(o){ console.log(o) };
var o = new Object();
Object.print(o)
// Object

(2)部署在Object.prototype对象

所有构造函数都有一个prototype属性,指向一个原型对象。凡是定义在Object.prototype对象上面的属性和方法,将被所有实例对象共享

1
2
3
4
5
Object.prototype.print = function(){ console.log(this)};
var o = new Object();
o.print() // Object

上面代码在Object.prototype定义了一个print方法,然后生成一个Object的实例o。o直接继承了Object.prototype的属性和方法,可以在自身调用它们,也就是说,o对象的print方法实质上是调用Object.prototype.print方法。。

可以看到,尽管上面两种写法的print方法功能相同,但是用法是不一样的,因此必须区分“构造函数的方法”和“实例对象的方法”。

Object()

Object本身当作工具方法使用时,可以将任意值转为对象。这个方法常用于保证某个值一定是对象。

如果参数是原始类型的值,Object方法返回对应的包装对象的实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Object() // 返回一个空对象
Object() instanceof Object // true
Object(undefined) // 返回一个空对象
Object(undefined) instanceof Object // true
Object(null) // 返回一个空对象
Object(null) instanceof Object // true
Object(1) // 等同于 new Number(1)
Object(1) instanceof Object // true
Object(1) instanceof Number // true
Object('foo') // 等同于 new String('foo')
Object('foo') instanceof Object // true
Object('foo') instanceof String // true
Object(true) // 等同于 new Boolean(true)
Object(true) instanceof Object // true
Object(true) instanceof Boolean // true

上面代码表示Object函数可以将各种值转为对应的构造函数生成的对象。

如果Object方法的参数是一个对象,它总是返回原对象。

1
2
3
4
5
6
7
8
9
10
11
var arr = [];
Object(arr) // 返回原数组
Object(arr) === arr // true
var obj = {};
Object(obj) // 返回原对象
Object(obj) === obj // true
var fn = function () {};
Object(fn) // 返回原函数
Object(fn) === fn // true

利用这一点,可以写一个判断变量是否为对象的函数。

1
2
3
4
5
6
function isObject(value) {
return value === Object(value);
}
isObject([]) // true
isObject(true) // false

Object 对象的静态方法

所谓“静态方法”,是指部署在Object对象自身的方法。(即不用new一个对象,就可以直接调用的方法)

Object.keys(),Object.getOwnPropertyNames()

Object.keys方法和Object.getOwnPropertyNames方法很相似,一般用来遍历对象的属性。它们的参数都是一个对象,都返回一个数组,该数组的成员都是对象自身的(而不是继承的)所有属性名。它们的区别在于,Object.keys方法只返回 可枚举的属性(关于可枚举性的详细解释见后文),Object.getOwnPropertyNames方法还返回 不可枚举的属性名

1
2
3
4
5
6
7
var a = ["Hello", "World"];
Object.keys(a)
// ["0", "1"]
Object.getOwnPropertyNames(a)
// ["0", "1", "length"]

上面代码中,数组的length属性是不可枚举的属性,所以只出现在Object.getOwnPropertyNames方法的返回结果中。

由于JavaScript没有提供 计算对象属性个数的方法 ,所以可以用这两个方法代替。

Object.keys(o).length
Object.getOwnPropertyNames(o).length

一般情况下,几乎总是使用Object.keys方法,遍历数组的属性

其他方法

(1)对象属性模型的相关方法

  • Object.getOwnPropertyDescriptor():获取某个属性的attributes对象。
  • Object.defineProperty():通过attributes对象,定义某个属性。
  • Object.defineProperties():通过attributes对象,定义多个属性。
  • Object.getOwnPropertyNames():返回直接定义在某个对象上面的全部属性的名称。

(2)控制对象状态的方法

  • Object.preventExtensions():防止对象扩展。
  • Object.isExtensible():判断对象是否可扩展。
  • Object.seal():禁止对象配置。
  • Object.isSealed():判断一个对象是否可配置。
  • Object.freeze():冻结一个对象。
  • Object.isFrozen():判断一个对象是否被冻结。

(3)原型链相关方法

  • Object.create():该方法可以指定原型对象和属性,返回一个新的对象。
  • Object.getPrototypeOf():获取对象的Prototype对象。

Object对象的实例方法(prototype上的方法)

一览

除了Object对象本身的方法,还有不少方法是部署在Object.prototype对象上的,所有Object的实例对象都继承了这些方法。

Object实例对象的方法,主要有以下六个。

  • valueOf():返回当前对象对应的值。
  • toString():返回当前对象对应的字符串形式。
  • toLocaleString():返回当前对象对应的本地字符串形式。
  • hasOwnProperty():判断某个属性是否为当前对象自身的属性,还是继承自原型对象的属性。
  • isPrototypeOf():判断当前对象是否为另一个对象的原型。
  • propertyIsEnumerable():判断某个属性是否可枚举。

Object.prototype.valueOf()

valueOf方法的作用是返回一个对象的“值”,默认情况下返回对象本身

1
2
var o = new Object();
o.valueOf() === o // true

上面代码比较o.valueOf()与o本身,两者是一样的。

valueOf方法的主要用途是,JavaScript自动类型转换时会默认调用这个方法。

1
2
var o = new Object();
1 + o // "1[object Object]"

上面代码将对象o与数字1相加,这时JavaScript就会默认调用valueOf()方法。所以,如果自定义valueOf方法,就可以得到想要的结果。

1
2
3
4
5
6
var o = new Object();
o.valueOf = function (){
return 2;
};
1 + o // 3

上面代码自定义了o对象的valueOf方法,于是1 + o就得到了3。这种方法就相当于用o.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 o = new Object();
o.toString = function () {
return 'hello';
};
o + ' ' + '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)"

toString()的应用:判断数据类型

Object.prototype.toString方法返回对象的类型字符串,因此可以用来判断一个值的类型。

1
2
var o = {};
o.toString() // "[object Object]"

上面代码调用空对象的toString方法,结果返回一个字符串object Object,其中第二个Object表示该值的构造函数。这是一个十分有用的判断数据类型的方法。

实例对象可能会自定义toString方法,覆盖掉Object.prototype.toString方法通过函数的call方法,可以在任意值上调用Object.prototype.toString方法,帮助我们判断这个值的类型

Object.prototype.toString.call(value)
不同数据类型的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
['Null',
'Undefined',
'Object',
'Array',
'String',
'Number',
'Boolean',
'Function',
'RegExp',
'NaN',
'Infinite'
].forEach(function (t) {
type['is' + t] = function (o) {
return type(o) === t.toLowerCase();
};
});
type.isObject({}) // true
type.isNumber(NaN) // true
type.isRegExp(/abc/) // true

Array 数组对象

构造函数

Array是JavaScript的内置对象,同时也是一个构造函数,可以用它生成新的数组。

1
2
3
var arr = new Array(2);
arr.length // 2
arr // [ undefined x 2 ]

Array作为构造函数,行为很不一致。因此,不建议使用它生成新数组,直接使用数组字面量是更好的做法。

1
2
3
4
5
// bad
var arr = new Array(1, 2);
// good
var arr = [1, 2];

Array.isArray()

Array.isArray方法用来判断一个值是否为数组。它可以弥补typeof运算符的不足。

1
2
3
4
var a = [1, 2, 3];
typeof a // "object"
Array.isArray(a) // true

上面代码中,typeof运算符只能显示数组的类型是Object,而Array.isArray方法可以对数组返回true。

Array实例的方法

valueOf(),toString()

valueOf方法返回数组本身。

var a = [1, 2, 3];
a.valueOf() // [1, 2, 3]
toString方法返回数组的字符串形式。

var a = [1, 2, 3];
a.toString() // “1,2,3”

var a = [1, 2, 3, [4, 5, 6]];
a.toString() // “1,2,3,4,5,6”

push() 在最后增加元素

push方法用于在数组的 末端 添加一个或多个元素,并返回添加新元素后的数组长度。注意,该方法会改变原数组。

如果需要合并两个数组,可以这样写。

1
2
3
4
5
6
7
8
9
10
11
var a = [1, 2, 3];
var b = [4, 5, 6];
Array.prototype.push.apply(a, b)
// 或者
a.push.apply(a, b)
// 上面两种写法等同于
a.push(4, 5, 6)
a // [1, 2, 3, 4, 5, 6]

pop() 删除最后的元素

pop方法用于删除数组的最后一个元素,并返回该元素。注意,该方法会改变原数组。

1
2
3
4
5
6
7
var a = ['a', 'b', 'c'];
a.pop() // 'c'
a // ['a', 'b']
//对空数组使用pop方法,不会报错,而是返回undefined。
[].pop() // undefined

push和pop结合使用,就构成了“后进先出”的栈结构(stack)。

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"

如果数组成员是undefined或null或空位,会被转成空字符串。

1
2
3
4
5
[undefined, null].join('#')
// '#'
['a',, 'b'].join('-')
// 'a--b'

通过call方法,这个方法也可以用于字符串。

1
2
3
4
5
6
7
Array.prototype.join.call('hello', '-')
// "h-e-l-l-o"
join方法也可以用于类似数组的对象。
var obj = { 0: 'a', 1: 'b', length: 2 };
Array.prototype.join.call(obj, '-')
// 'a-b'

concat() 多个数组的合并

concat方法用于多个数组的合并。它将新数组的成员,添加到原数组的尾部,然后返回一个新数组,原数组不变。

1
2
3
4
5
['hello'].concat(['world'])
// ["hello", "world"]
['hello'].concat(['world'], ['!'])
// ["hello", "world", "!"]

除了接受数组作为参数,concat也可以接受其他类型的值作为参数。它们会作为新的元素,添加数组尾部。

1
2
3
4
5
6
[1, 2, 3].concat(4, 5, 6)
// [1, 2, 3, 4, 5, 6]
// 等同于
[1, 2, 3].concat(4, [5, 6])
[1, 2, 3].concat([4], [5, 6])

事实上,只要原数组的成员中包含对象,concat方法不管有没有参数,总是返回该对象的引用

concat方法也可以用于将对象合并为数组,但是必须借助call方法。

1
2
3
4
5
6
7
8
[].concat.call({a: 1}, {b: 2})
// [{ a: 1 }, { b: 2 }]
[].concat.call({a: 1}, [2])
// [{a: 1}, 2]
[2].concat({a: 1})
// [2, {a: 1}]

shift() 删除第一个元素

shift方法用于删除数组的第一个元素并返回该元素。注意,该方法会改变原数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = ['a', 'b', 'c'];
a.shift() // 'a'
a // ['b', 'c']
shift方法可以遍历并清空一个数组。
var list = [1, 2, 3, 4, 5, 6];
var item;
while (item = list.shift()) {
console.log(item);
}
list // []

push和shift结合使用,就构成了“先进先出”的队列结构(queue)。

unshift() 添加第一个元素

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' ]

reverse() 颠倒元素顺序

reverse方法用于颠倒数组中元素的顺序,返回改变后的数组。注意,该方法将改变原数组。

1
2
3
4
var a = ['a', 'b', 'c'];
a.reverse() // ["c", "b", "a"]
a // ["c", "b", "a"]

slice() 提取+返回新数组

slice方法用于提取原数组的一部分,返回一个新数组,原数组不变。

它的第一个参数为起始位置(从0开始),第二个参数为终止位置(但该位置的元素本身不包括在内)。如果省略第二个参数,则一直返回到原数组的最后一个成员。

1
2
3
4
5
6
7
8
9
10
11
// 格式
arr.slice(start_index, upto_index);
// 用法
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方法用于删除原数组的一部分成员并可以在被删除的位置添加入新的数组成员,返回值是被删除的元素。注意,该方法会改变原数组

splice的第一个参数是删除的起始位置,第二个参数是被删除的元素个数。如果后面还有更多的参数,则表示这些就是要被插入数组的新元素

1
2
3
4
5
6
7
// 格式
arr.splice(index, count_to_remove, addElement1, addElement2, ...);
// 用法
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方法按照自定义方式排序,可以传入一个函数作为参数,表示按照自定义方法进行排序。该函数本身又接受两个参数,表示进行比较的两个元素。如果返回值大于0,表示第一个元素排在第二个元素后面;其他情况下,都是第一个元素排在第二个元素前面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[10111, 1101, 111].sort(function (a, b) {
return a - b;
})
// [111, 1101, 10111]
[
{ 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数组的所有成员都加上1,组成一个新数组返回,原数组没有变化。

map方法接受一个函数作为参数。该函数调用时,map方法会将其传入三个参数,分别是当前成员、当前位置和数组本身。

map方法不仅可以用于数组,还可以用于字符串,用来遍历字符串的每个字符。但是,不能直接使用,而要通过函数的call方法间接使用,或者先将字符串转为数组,然后使用。

1
2
3
4
5
6
7
8
9
10
var upper = function (x) {
return x.toUpperCase();
};
[].map.call('abc', upper)
// [ 'A', 'B', 'C' ]
// 或者
'abc'.split('').map(upper)
// [ 'A', 'B', 'C' ]

其他类似数组的对象(比如document.querySelectorAll方法返回DOM节点集合),也可以用上面的方法遍历。

如果数组有空位,map方法的回调函数在这个位置不会执行,会跳过数组的空位。

1
2
3
4
5
var f = function(n){ return n + 1 };
[1, undefined, 2].map(f) // [2, NaN, 3]
[1, null, 2].map(f) // [2, 1, 3]
[1, , 2].map(f) // [2, , 3]

上面代码中,map方法不会跳过undefined和null,但是会跳过空位

forEach()

forEach方法与map方法很相似,也是遍历数组的所有成员,执行某种操作,但是forEach方法一般不返回值,只用来操作数据。如果需要有返回值,一般使用map方法。

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遍历数组 不是为了得到返回值,而是为了在屏幕输出内容 ,所以应该使用forEach方法,而不是map方法,虽然后者也可以实现同样目的。

注意,forEach方法无法中断执行,总是会将所有成员遍历完。如果希望符合某种条件时,就中断遍历,要使用for循环。

forEach方法会跳过数组的空位。

forEach方法也可以用于类似数组的对象和字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var obj = {
0: 1,
a: 'hello',
length: 1
}
Array.prototype.forEach.call(obj, function (elem, i) {
console.log( i + ':' + elem);
});
// 0:1
var str = 'hello';
Array.prototype.forEach.call(str, function (elem, i) {
console.log( i + ':' + elem);
});
// 0:h
// 1:e
// 2:l
// 3:l
// 4:o

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
8
9
10
11
12
13
var Obj = function () {
this.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, new Obj())
// [8, 4, 9]

上面代码中,测试函数myFilter内部有this对象,它可以被filter方法的第二个参数绑定。上例中,myFilter的this绑定了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

上面代码表示,如果存在大于等于3的数组成员,就返回true。

every方法则是所有数组成员的返回值都是true才返回true,否则false。

1
2
3
4
5
var arr = [1, 2, 3, 4, 5];
arr.every(function (elem, index, arr) {
return elem >= 3;
});
// false

上面代码表示,只有所有数组成员大于等于3,才返回true。

注意,对于空数组,some方法返回false,every方法返回true,回调函数都不会执行。

1
2
3
4
function isEven(x) { return x % 2 === 0 }
[].some(isEven) // false
[].every(isEven) // true

some和every方法还可以接受第二个参数,用来绑定函数中的this关键字。

reduce(),reduceRight()

reduce方法和reduceRight方法 依次处理数组的每个成员最终累计为一个值

它们的差别是,reduce是从左到右处理(从第一个成员到最后一个成员),reduceRight则是从右到左(从最后一个成员到第一个成员),其他完全一样。

这两个方法的第一个参数都是一个函数。该函数接受以下四个参数。

  1. 累积变量,默认为数组的第一个成员
  2. 当前变量,默认为数组的第二个成员
  3. 当前位置(从0开始)
  4. 原数组

这四个参数之中,只有前两个是必须的,后两个则是可选的。

下面的例子求数组成员之和。

1
2
3
4
5
6
7
8
9
[1, 2, 3, 4, 5].reduce(function(x, y){
console.log(x, y)
return x + y;
});
// 1 2
// 3 3
// 6 4
// 10 5
//最后结果:15

上面代码中,第一轮执行,x是数组的第一个成员,y是数组的第二个成员。从第二轮开始,x为上一轮的返回值,y为当前数组成员,直到遍历完所有成员,返回最后一轮计算后的x。

利用reduce方法,可以写一个数组求和的sum方法。

1
2
3
4
5
6
7
8
Array.prototype.sum = function (){
return this.reduce(function (partial, value) {
return partial + value;
})
};
[3, 4, 5, 6, 10].sum()
// 28

如果要对累积变量指定初值,可以把它放在reduce方法和reduceRight方法的 第二个参数

1
2
3
4
[1, 2, 3, 4, 5].reduce(function(x, y){
return x + y;
}, 10);
// 25

上面代码指定参数x的初值为10,所以数组从10开始累加,最终结果为25。注意,这时y是从数组的第一个成员开始遍历。

第二个参数相当于设定了默认值,处理空数组时尤其有用。

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方法会报错。这时,加上第二个参数,就能保证总是会返回一个值。

由于reduce方法依次处理每个元素,所以实际上还可以用它来搜索某个元素。比如,下面代码是找出长度最长的数组元素。

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"

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
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(alert);
// 弹出tom@example.com

上面代码中,先产生一个所有Email地址组成的数组,然后再过滤出以t开头的Email地址。

包装对象和Boolean对象

包装对象的定义

有人说,JavaScript语言“一切皆对象”,数组和函数本质上都是对象,就连三种原始类型的值——数值、字符串、布尔值——在一定条件下,也会自动转为对象,也就是原始类型的“包装对象”。

所谓“包装对象”,就是分别与数值、字符串、布尔值相对应的Number、String、Boolean三个原生对象。这三个原生对象可以把原始类型的值变成(包装成)对象

1
2
3
var v1 = new Number(123);
var v2 = new String('abc');
var v3 = new Boolean(true);

上面代码根据原始类型的值,生成了三个对象,与原始值的类型不同。这用typeof运算符就可以看出来。

1
2
3
4
5
6
7
typeof v1 // "object"
typeof v2 // "object"
typeof v3 // "object"
v1 === 123 // false
v2 === 'abc' // false
v3 === true // false

JavaScript设计包装对象的最大目的,首先是使得JavaScript的“对象”涵盖所有的值。其次,使得原始类型的值可以方便地调用特定方法。

Number、String和Boolean如果不作为构造函数调用(即调用时不加new),常常用于将任意类型的值转为数值、字符串和布尔值。

1
2
3
Number(123) // 123
String('abc') // "abc"
Boolean(true) // true

总之,这三个对象作为构造函数使用(带有new)时,可以将原始类型的值转为对象作为普通函数使用时(不带有new),可以将任意类型的值,转为原始类型的值

包装对象实例的方法

包装对象实例可以使用Object对象提供的原生方法,主要是valueOf方法和toString方法。

valueOf方法返回包装对象实例对应原始类型的值

1
2
3
new Number(123).valueOf() // 123
new String("abc").valueOf() // "abc"
new Boolean("true").valueOf() // true

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属性、还有一个内部属性[[PrimitiveValue]]保存字符串的原始值。这个[[PrimitiveValue]]内部属性,外部是无法调用,仅供ValueOf或toString这样的方法内部调用。

这个临时对象是只读的,无法修改。所以,字符串无法添加新属性

1
2
3
var s = 'Hello World';
s.x = 123;
s.x // undefined

上面代码为字符串s添加了一个x属性,结果无效,总是返回undefined。

另一方面,调用结束后,临时对象会自动销毁。这意味着,下一次调用字符串的属性时,实际是调用一个新生成的对象,而不是上一次调用时生成的那个对象,所以取不到赋值在上一个对象的属性。如果想要为字符串添加属性,只有在它的原型对象String.prototype上定义。

如果包装对象与原始类型值进行混合运算,包装对象会转化为原始类型(实际是调用自身的valueOf方法)。

1
2
new Number(123) + 123 // 246
new String('abc') + 'abc' // "abcabc"

自定义方法

三种包装对象还可以在原型上添加自定义方法和属性,供原始类型的值直接调用。

比如,我们可以新增一个double方法,使得字符串和数字翻倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
String.prototype.double = function () {
return this.valueOf() + this.valueOf();
};
'abc'.double()
// abcabc
Number.prototype.double = function () {
return this.valueOf() + this.valueOf();
};
(123).double()
// 246

上面代码在123外面必须要加上圆括号,否则后面的点运算符(.)会被解释成小数点。

但是,这种自定义方法和属性的机制,只能定义在包装对象的原型上,如果直接对原始类型的变量添加属性,则无效。

1
2
3
4
var s = 'abc';
s.p = 123;
s.p // undefined

上面代码直接对字符串abc添加属性,结果无效。主要原因是上面说的,这里的包装对象是自动生成的,赋值后自动销毁,所以最后一行实际上调用的是一个新的包装对象。

Boolean对象

Boolean对象是JavaScript的三个包装对象之一。作为构造函数,它主要用于生成布尔值的包装对象的实例。

1
2
3
4
var b = new Boolean(true);
typeof b // "object"
b.valueOf() // true

上面代码的变量b是一个Boolean对象的实例,它的类型是对象,值为布尔值true。这种写法太繁琐,几乎无人使用,直接对变量赋值更简单清晰。

1
var b = 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
!!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转为数值1。

Number对象的属性

  • Number.POSITIVE_INFINITY:正的无限,指向Infinity。
  • Number.NEGATIVE_INFINITY:负的无限,指向-Infinity。
  • Number.NaN:表示非数值,指向NaN。
  • Number.MAX_VALUE:表示最大的正数,相应的,最小的负数为-Number.MAX_VALUE。
  • Number.MIN_VALUE:表示最小的正数(即最接近0的正数,在64位浮点数体系中为5e-324),相应的,最接近0的负数为-Number.MIN_VALUE。
  • Number.MAX_SAFE_INTEGER:表示能够精确表示的最大整数,即9007199254740991。
  • Number.MIN_SAFE_INTEGER:表示能够精确表示的最小整数,即-9007199254740991。

Number 对象实例的方法

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"

将其他进制的数,转回十进制,需要使用parseInt方法。

自定义方法

需要注意的是,数值的自定义方法,只能定义在它的原型对象Number.prototype上面,数值本身是无法自定义属性的。

与其他对象一样,Number.prototype对象上面可以自定义方法,被Number的实例继承。

1
2
3
Number.prototype.add = function (x) {
return this + x;
};

上面代码为Number对象实例定义了一个add方法。

在数值上调用某个方法,数值会自动转为Number的实例对象,所以就得到了下面的结果。

1
(8).add(2) // 10

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是对象,所以有自己的方法,valueOf方法返回的就是它所包装的那个字符串。

实际上,字符串的包装对象是一个类似数组的对象(即很像数组,但是实质上不是数组)。

1
2
new String("abc")
// String {0: "a", 1: "b", 2: "c", length: 3}

除了用作构造函数,String对象还可以当作工具方法使用,将任意类型的值转为字符串。

1
2
String(true) // "true"
String(5) // "5"

上面代码将布尔值ture和数值5,分别转换为字符串。

实例对象的属性和方法

length属性

length属性返回字符串的长度。

1
'abc'.length // 3

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) // ""

charCodeAt()

charCodeAt方法返回 给定位置字符的Unicode码点(十进制表示),相当于String.fromCharCode()的逆操作。

1
'abc'.charCodeAt(1) // 98

上面代码中,abc的1号位置的字符是b,它的Unicode码点是98。

如果没有任何参数,charCodeAt返回首字符的Unicode码点。

1
'abc'.charCodeAt() // 97

上面代码中,首字符a的Unicode编号是97。

需要注意的是,charCodeAt方法返回的Unicode码点不大于65536(0xFFFF),也就是说,只返回两个字节的字符的码点。如果遇到Unicode码点大于65536的字符,必需连续使用两次charCodeAt,不仅读入charCodeAt(i),还要读入charCodeAt(i+1),将两个16字节放在一起,才能得到准确的字符。

如果参数为负数,或大于等于字符串的长度,charCodeAt返回NaN

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方法将参数先转成字符串再连接,所以返回的是一个三个字符的字符串。作为对比,加号运算符在两个运算数都是数值时,不会转换类型,所以返回的是一个两个字符的字符串。

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) // ""

substr() 第二个参数是子字符串的长度

substr方法用于从原字符串取出子字符串并返回,不改变原字符串。

substr方法的第一个参数是子字符串的开始位置,第二个参数是子字符串的长度

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,所以返回空字符串。

indexOf(),lastIndexOf()

这两个方法用于确定一个字符串在另一个字符串中的位置,都返回一个整数,表示匹配开始的位置。如果返回-1,就表示不匹配。两者的区别在于,indexOf从字符串头部开始匹配,lastIndexOf从尾部开始匹配

1
2
3
4
'hello world'.indexOf('o') // 4
'JavaScript'.indexOf('script') // -1
'hello world'.lastIndexOf('o') // 7

它们还可以接受第二个参数,对于indexOf方法,第二个参数表示从该位置开始向后匹配;对于lastIndexOf,第二个参数表示从该位置起向前匹配

1
2
'hello world'.indexOf('o', 6) // 7
'hello world'.lastIndexOf('o', 6) // 4

trim() 去除字符串两端的空格,返回一个新字符串,不改变原字符串。

trim方法用于去除字符串两端的空格,返回一个新字符串,不改变原字符串。

1
2
' hello world '.trim()
// "hello world"

该方法去除的不仅是空格,还包括制表符(\t、\v)、换行符(\n)和回车符(\r)

1
'\r\nabc \t'.trim() // 'abc'

toLowerCase(),toUpperCase()

toLowerCase方法用于将一个字符串全部转为小写,toUpperCase则是全部转为大写。它们都返回一个新字符串,不改变原字符串。

1
2
3
4
5
'Hello World'.toLowerCase()
// "hello world"
'Hello World'.toUpperCase()
// "HELLO WORLD"

这个方法也可以 将布尔值或数组 转为大写字符串,但是需要通过call方法使用。

1
2
3
4
String.prototype.toUpperCase.call(true)
// 'TRUE'
String.prototype.toUpperCase.call(['a', 'b', 'c'])
// 'A,B,C'

localeCompare() 比较两个字符串

localeCompare方法用于比较两个字符串。它返回一个整数,如果小于0,表示第一个字符串小于第二个字符串;如果等于0,表示两者相等;如果大于0,表示第一个字符串大于第二个字符串。

1
2
3
4
5
'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较大。

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方法还可以使用正则表达式作为参数,详见《正则表达式》一节。

search方法的用法等同于match,但是返回值为匹配的第一个位置。如果没有找到匹配,则返回-1。

1
'cat, bat, sat, fat'.search('at') // 1

search方法还可以使用正则表达式作为参数,详见《正则表达式》一节。

replace()

replace方法用于替换匹配的子字符串,一般情况下 只替换第一个匹配(除非使用带有g修饰符的正则表达式)。

1
'aaa'.replace('a', 'b') // "baa"

replace方法还可以使用正则表达式作为参数。

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方法还可以使用正则表达式作为参数。

RegExp对象 正则表达式

概述

正则表达式(regular expression)是一种表达文本模式(即字符串结构)的方法,有点像字符串的模板,常常用作按照“给定模式”匹配文本的工具。比如,正则表达式给出一个 Email 地址的模式,然后用它来确定一个字符串是否为 Email 地址。

新建正则表达式有两种方法。一种是使用字面量以斜杠表示开始和结束

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。

这两种写法——字面量和构造函数——在运行时有一个细微的区别。采用字面量的写法,正则对象在代码载入时(即编译时)生成;采用构造函数的方法,正则对象在代码运行时生成。考虑到书写的便利和直观,实际应用中,基本上都采用 **字面量** 的写法。

正则对象生成以后,有两种使用方式:

正则对象的方法:将字符串作为参数,比如regex.test(string)
字符串对象的方法:将正则对象作为参数,比如string.match(regex)

正则对象的属性和方法

属性

正则对象的属性分成两类。

一类是修饰符相关,返回一个布尔值,表示对应的修饰符是否设置。

  • ignoreCase:返回一个布尔值,表示是否设置了i修饰符,该属性只读。
  • global:返回一个布尔值,表示是否设置了g修饰符,该属性只读。
  • multiline:返回一个布尔值,表示是否设置了m修饰符,该属性只读。
  • var r = /abc/igm;
1
2
3
r.ignoreCase // true
r.global // true
r.multiline // true

另一类是与修饰符无关的属性,主要是下面两个。

  • lastIndex:返回下一次开始搜索的位置。该属性可读写,但是只在设置了g修饰符时有意义
  • source:返回正则表达式的字符串形式(不包括反斜杠),该属性只读。
1
2
3
var r = /abc/igm;
r.lastIndex // 0
r.source // "abc"

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
var r = /x/g;
var s = '_x_x';
r.lastIndex = 4;
r.test(s) // false

上面代码指定从字符串的第五个位置开始搜索,这个位置是没有字符的,所以返回false。

lastIndex属性只对同一个正则表达式有效,所以下面这样写是错误的。

1
2
var count = 0;
while (/a/g.test('babaa')) count++;

上面代码会导致无限循环,因为while循环的每次匹配条件都是一个新的正则表达式,导致lastIndex属性总是等于0。

如果正则模式是一个空字符串,则匹配所有字符串。

1
2
new RegExp('').test('abc')
// true

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方法的返回数组还包含以下两个属性:

  1. input:整个原字符串。
  2. 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 r = /a(b+)a/g;
var a1 = r.exec('_abbba_aba_');
a1 // ['abbba', 'bbb']
a1.index // 1
r.lastIndex // 6
var a2 = r.exec('_abbba_aba_');
a2 // ['aba', 'b']
a2.index // 7
r.lastIndex // 10
var a3 = r.exec('_abbba_aba_');
a3 // null
a3.index // TypeError: Cannot read property 'index' of null
r.lastIndex // 0
var a4 = r.exec('_abbba_aba_');
a4 // ['abbba', 'bbb']
a4.index // 1
r.lastIndex // 6

上面代码连续用了四次exec方法,前三次都是从上一次匹配结束的位置向后匹配。当第三次匹配结束以后,整个字符串已经到达尾部,正则对象的lastIndex属性重置为0,意味着第四次匹配将从头开始

利用g修饰符允许多次匹配的特点,可以用一个循环完成全部匹配

1
2
3
4
5
6
7
8
9
10
var r = /a(b+)a/g;
var s = '_abbba_aba_';
while(true) {
var match = r.exec(s);
if (!match) break;
console.log(match[1]);
}
// bbb
// b

正则对象的lastIndex属性不仅可读,还可写。一旦手动设置了lastIndex的值,就会从指定位置开始匹配。但是,这只在设置了g修饰符的情况下,才会有效

1
2
3
4
5
6
var r = /a/;
r.lastIndex = 7; // 无效
var match = r.exec('xaxa');
match.index // 1
r.lastIndex // 7

上面代码设置了lastIndex属性,但是因为正则表达式没有g修饰符,所以是无效的。每次匹配都是从字符串的头部开始。

如果有g修饰符,lastIndex属性就会生效。

1
2
3
4
5
6
var r = /a/g;
r.lastIndex = 2;
var match = r.exec('xaxa');
match.index // 3
r.lastIndex // 4

上面代码中,lastIndex属性指定从字符的第三个位置开始匹配。成功后,下一次匹配就是从第五个位置开始。

如果正则对象是一个空字符串,则exec方法会匹配成功,但返回的也是空字符串。

字符串对象的方法

简介

字符串对象的方法之中,有4种与正则对象有关。

  • match():返回一个数组,成员是所有匹配的子字符串
  • search():按照给定的正则表达式进行搜索,返回一个整数,表示匹配开始的位置
  • replace():按照给定的正则表达式进行替换,返回替换后的字符串。
  • 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属性是无效的。

字符串对象的search方法,返回第一个满足条件的匹配结果在整个字符串中的位置。如果没有任何匹配,则返回-1。

1
2
'_x_x'.search(/x/)
// 1

上面代码中,第一个匹配结果出现在字符串的1号位置。

该方法会忽略g修饰符。

1
2
3
var r = /x/g;
r.lastIndex = 2; // 无效
'_x_x'.search(r) // 1

上面代码中,正则表达式使用g修饰符之后,使用lastIndex属性指定开始匹配的位置,结果无效,还是从字符串的第一个字符开始匹配。

String.prototype.replace()

字符串对象的replace方法可以替换匹配的值。它接受两个参数,第一个是搜索模式,第二个是替换的内容

1
str.replace(search, replacement)

搜索模式如果不加g修饰符,就替换第一个匹配成功的值,否则替换所有匹配成功的值

1
2
3
4
5
6
7
8
9
10
11
'aaa'.replace('a', 'b') // "baa"
'aaa'.replace(/a/, 'b') // "baa"
'aaa'.replace(/a/g, 'b') // "bbb"
上面代码中,最后一个正则表达式使用了g修饰符,导致所有的b都被替换掉了。
replace方法的一个应用,就是消除字符串首尾两端的空格。
var str = ' #id div.class ';
str.replace(/^\s+|\s+$/g, '')
// "#id div.class"

replace方法的第二个参数可以使用美元符号$,用来指代所替换的内容。

  • $& 指代匹配的子字符串。
  • $` 指代匹配结果前面的文本。
  • $’ 指代匹配结果后面的文本。
  • $n 指代匹配成功的第n组内容,n是从1开始的自然数。
  • $$ 指代美元符号$。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
'hello world'.replace(/(\w+)\s(\w+)/, '$2 $1')
// "world hello"
'abc'.replace('b', '[$`-$&-$\']')
// "a[a-b-c]c"
replace方法的第二个参数还可以是一个函数,将每一个匹配内容替换为函数返回值。
'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
var prices = {
'pr_1': '$1.99',
'pr_2': '$9.99',
'pr_3': '$5.00'
};
var template = '/* ... */'; // 这里可以放网页模块字符串
template.replace(
/(<span id=")(.*?)(">)(<\/span>)/g,
function(match, $1, $2, $3, $4){
return $1 + $2 + $3 + prices[$2] + $4;
}
);

上面代码的捕捉模式中,有四个括号,所以会产生四个组匹配,在匹配函数中用$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
'aaa*a*'.split(/(a*)/)
// [ '', 'aaa', '*', 'a', '*' ]

上面代码的正则表达式使用了括号,第一个组匹配是“aaa”,第二个组匹配是“a”,它们都作为数组成员返回。

匹配规则

字面量字符和元字符

大部分字符在正则表达式中,就是字面的含义,比如/a/匹配a,/b/匹配b。如果在正则表达式之中,某个字符只表示它字面的含义(就像前面的a和b),那么它们就叫做“字面量字符”(literal characters)。

1
/dog/.test("old dog") // true

上面代码中正则表达式的dog,就是字面量字符,所以/dog/匹配“old dog”,因为它就表示“d”、“o”、“g”三个字母连在一起。

除了字面量字符以外,还有一部分字符有特殊含义,不代表字面的意思。它们叫做“元字符”(metacharacters),主要有以下几个。

(1)点字符(.)

点字符(.)匹配除回车(\r)、换行(\n) 、行分隔符(\u2028)和段分隔符(\u2029)以外的所有字符。

/c.t/
上面代码中,c.t匹配c和t之间包含任意一个字符的情况,只要这三个字符在同一行,比如cat、c2t、c-t等等,但是不匹配coot。

(2)位置字符

位置字符用来提示字符所处的位置,主要有两个字符。

1
2
3
4
5
6
7
8
9
10
11
//^ 表示字符串的开始位置
//$ 表示字符串的结束位置
// 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表示匹配cat或dog。

1
2
3
4
5
6
7
8
9
10
11
/11|22/.test('911') // true
上面代码中,正则表达式指定必须匹配1122
多个选择符可以联合使用。
// 匹配fred、barney、betty之中的一个
/fred|barney|betty/
选择符会包括它前后的多个字符,比如/ab|cd/指的是匹配ab或者cd,而不是指匹配b或者c。如果想修改这个行为,可以使用圆括号。
/a( |\t)b/.test('a\tb') // true
上面代码指的是,a和b之间有一个空格或者一个制表符。

转义符

正则表达式中那些有特殊含义的字符,如果要匹配它们本身,就需要在它们前面要加上反斜杠。比如要匹配加号,就要写成+。

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] 表示x、y、z之中任选一个匹配。

1
2
/[abc]/.test('hello world') // false
/[abc]/.test('apple') // true

上面代码表示,字符串“hello world”不包含a、b、c这三个字母中的任一个,而字符串“apple”包含字母a。

有两个字符在字符类中有特殊含义。

(1)脱字符(^)

如果方括号内的第一个字符是[^],则表示除了字符类之中的字符,其他字符都可以匹配。比如,[^xyz]表示除了x、y、z之外都可以匹配

1
2
/[^abc]/.test('hello world') // true
/[^abc]/.test('bbc') // false

上面代码表示,字符串“hello world”不包含字母a、b、c中的任一个,所以返回true;字符串“bbc”不包含a、b、c以外的字母,所以返回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],不代表1到31,只代表1到3

注意,字符类的连字符必须在头尾两个字符中间,才有特殊含义,否则就是字面含义。比如,[-9]就表示匹配连字符和9,而不是匹配0到9。

连字符还可以用来指定Unicode字符的范围。

1
2
3
4
var str = "\u0130\u0131\u0132";
/[\u0128-\
uFFFF]/.test(str)
// true

另外,不要过分使用连字符,设定一个很大的范围,否则很可能选中意料之外的字符。最典型的例子就是[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
5
6
7
8
var html = "<b>Hello</b>\n<i>world!</i>";
/[\S\s]*/.exec(html)[0]
// "<b>Hello</b>\n<i>world!</i>"
// 另一种写法(用到了非捕获组)
/(?:.|\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+?/,这时就改为非贪婪模式,一旦条件满足,就不再往下匹配。

除了非贪婪模式的加号,还有非贪婪模式的星号(*)。

*?:表示某个模式出现0次或多次,匹配时采用非贪婪模式。
+?:表示某个模式出现1次或多次,匹配时采用非贪婪模式。

修饰符 g/i/m 全局/忽略/多行

修饰符(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只能处在字符串的开始处。

组匹配

(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
3
var m = 'abcabc'.match(/(.)b(.)/g);
m
// ['abc', 'abc']

上面代码使用带g修饰符的正则表达式,结果match方法只捕获了匹配整个表达式的部分

在正则表达式内部,可以用\n引用括号匹配的内容,n是从1开始的自然数,表示对应顺序的括号。

1
2
/(.)b(.)\1b\2/.test("abcabc")
// true

上面的代码中,\1表示前一个括号匹配的内容(即“a”),\2表示第二个括号匹配的内容(即“b”)。

下面是另外一个例子。

1
2
3
4
/y(..)(.)\2\1/.test('yabccab') // true
括号还可以嵌套。
/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)

x(?=y)称为先行断言(Positive look-ahead),x只有在y前面才匹配,y不会被计入返回结果。比如,要匹配后面跟着百分号的数字,可以写成/\d+(?=%)/。

“先行断言”中,括号里的部分是不会返回的。

1
2
var m = 'abc'.match(/b(?=c)/);
m // ["b"]

上面的代码使用了先行断言,b在c前面所以被匹配,但是括号对应的c不会被返回。

再看一个例子。

1
/Jack (?=Sprat|Frost)/.test('Jack Frost') // true

(4)先行否定断言 x(?!y)

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不会被返回。

JSON对象

JSON格式

JSON格式(JavaScript Object Notation的缩写)是一种用于数据交换的文本格式,目的是取代繁琐笨重的XML格式。

相比XML格式,JSON格式有两个显著的优点:

  1. 书写简单,一目了然;
  2. 符合JavaScript原生语法,可以由解释引擎直接处理,不用另外添加解析代码。

所以,JSON迅速被接受,已经成为各大网站交换数据的标准格式,并被写入ECMAScript 5,成为标准的一部分。

简单说,每个JSON对象,就是一个值。要么是简单类型的值,要么是复合类型的值,但是只能是一个值,不能是两个或更多的值。这就是说,每个JSON文档只能包含一个值。

JSON对值的类型和格式有严格的规定。

复合类型的值只能是数组或对象,不能是函数、正则表达式对象、日期对象。

简单类型的值只有四种:字符串、数值(必须以十进制表示)、布尔值和null(不能使用NaN, Infinity, -Infinity和undefined)。

字符串必须使用双引号表示,不能使用单引号。

对象的键名必须放在双引号里面。

数组或对象最后一个成员的后面,不能加逗号

以下是合格的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;
}
} // 不能使用函数和日期对象

需要注意的是,空数组和空对象都是合格的JSON值null本身也是一个合格的JSON值

ES5新增了 JSON对象 ,用来 处理JSON格式数据 。它有两个方法:JSON.stringify()和JSON.parse()。

JSON.stringify()

基本用法

JSON.stringify方法用于将一个值转为字符串。该字符串符合 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 引擎知道,foo是一个字符串,而不是一个变量名。

如果原始对象中,有一个成员的值是undefined、函数或 XML 对象,这个成员会被过滤

1
2
3
4
5
6
var obj = {
a: undefined,
b: function () {}
};
JSON.stringify(obj) // "{}"

上面代码中,对象obj的a属性是undefined,而b属性是一个函数,结果都被JSON.stringify过滤。

如果数组的成员是undefined、函数或 XML 对象,则这些值被转成null

1
2
var arr = [undefined, function () {}];
JSON.stringify(arr) // "[null,null]"

上面代码中,数组arr的成员是undefined和函数,它们都被转成了null。

正则对象会被转成空对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
JSON.stringify(/foo/) // "{}"
JSON.stringify方法会忽略对象的不可遍历属性。
var obj = {};
Object.defineProperties(obj, {
'foo': {
value: 1,
enumerable: true
},
'bar': {
value: 2,
enumerable: false
}
});
JSON.stringify(obj); // "{"foo":1}"

上面代码中,bar是obj对象的不可遍历属性,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方法的第二个参数指定,只转prop1和prop2两个属性。

这个类似“白名单”的数组,只对 对象 的属性有效,对数组无效。

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函数处理三次。第一次键名为空,键值是整个对象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
JSON.stringify({ p1: 1, p2: 2 }, null, 2);
/*
"{
"p1": 1,
"p2": 2
}"
*/
1
2
3
4
5
6
7
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
18
19
var user = {
firstName: '三',
lastName: '张',
get fullName(){
return this.lastName + this.firstName;
},
toJSON: function () {
var data = {
firstName: this.firstName,
lastName: this.lastName
};
return data;
}
};
JSON.stringify(user)
// "{"firstName":"三","lastName":"张"}"

上面代码中,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一旦发现处理的是data对象实例,就会自动调用这个实例对象的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.stingify方法处理。

JSON.parse() 将JSON字符串转化成对象

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代码块中。

JSON.parse方法可以接受一个处理函数,用法与JSON.stringify方法类似。

1
2
3
4
5
6
7
8
9
10
11
12
function f(key, value) {
if (key === ''){
return value;
}
if (key === 'a') {
return value + 10;
}
}
var o = JSON.parse('{"a":1,"b":2}', f);
o.a // 11
o.b // undefined

console对象

简介

console对象是JavaScript的原生对象,它有点像Unix系统的标准输出stdout和标准错误stderr,可以输出各种信息到控制台,并且还提供了很多额外的有用方法。

它的常见用途有两个。

  1. 调试程序,显示网页代码运行时的错误信息。
  2. 提供了一个命令行接口,用来与网页代码互动。

浏览器实现

Console面板基本上就是一个命令行窗口,你可以在提示符下,键入各种命令。

console对象的方法

log(),info(),debug()

如果第一个参数是格式字符串(使用了格式占位符),console.log方法将依次用后面的参数替换占位符,然后再进行输出。

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
5
console.log({foo: 'bar'})
// Object {foo: "bar"}
console.log(Date)
// function Date() { [native code] }

上面代码输出Date对象的值,结果为一个构造函数。

console.info()和console.debug()都是console.log方法别名用法完全一样。只不过console.info方法会在输出信息的前面,加上一个蓝色图标。

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方法,可以在显示结果添加当前时间。

warn(),error()

warn方法和error方法也是在控制台输出信息,它们与log方法的不同之处在于,warn方法输出信息时,在最前面加一个黄色三角,表示警告;error方法输出信息时,在最前面加一个红色的叉,表示出错,同时会显示错误发生的堆栈。其他方面都一样。

1
2
3
4
5
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)。

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);

复合型数据转为表格显示的条件是,必须拥有主键。对于数组来说,主键就是数字键。对于对象来说,主键就是它的最外层键。

1
2
3
4
5
6
var languages = {
csharp: { name: "C#", paradigm: "object-oriented" },
fsharp: { name: "F#", paradigm: "functional" }
};
console.table(languages);

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执行了一次。

time(),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窗口会显示“计时器名称: 所耗费的时间”。

trace(),clear()

console.trace方法显示当前执行的代码在堆栈中的调用路径。

1
2
3
4
5
6
console.trace()
// console.trace()
// (anonymous function)
// InjectedScript._evaluateOn
// InjectedScript._evaluateAndWrap
// InjectedScript.evaluate

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元素。

$x(“//p[a]”)
上面代码返回所有包含a元素的p元素。

(6)inspect(object)

inspect(object)方法打开相关面板,并选中相应的元素:DOM元素在Elements面板中显示,JavaScript对象在Profiles面板中显示。

(7)getEventListeners(object)

getEventListeners(object)方法返回一个对象,该对象的成员为登记了回调函数的各种事件(比如click或keydown),每个事件对应一个数组,数组的成员为该事件的回调函数。

(8)keys(object),values(object)

keys(object)方法返回一个数组,包含特定对象的所有键名。

values(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方法用于停止监听。

monitorEvents(window, “resize”);
monitorEvents(window, [“resize”, “scroll”])
上面代码分别表示单个事件和多个事件的监听方法。

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”
monitorEvents($(“#msg”), “key”);
上面代码表示监听所有key大类的事件。

(10)profile([name]),profileEnd()

profile方法用于启动一个特定名称的CPU性能测试,profileEnd方法用于结束该性能测试。

profile(‘My profile’)
profileEnd(‘My profile’)
(11)其他方法

命令行API还提供以下方法。

clear():清除控制台的历史。
copy(object):复制特定DOM元素到剪贴板。
dir(object):显示特定对象的所有属性,是console.dir方法的别名。
dirxml(object):显示特定对象的XML形式,是console.dirxml方法的别名。

属性描述对象

概述

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 o = { p: 'a' };
Object.getOwnPropertyDescriptor(o, 'p')
// Object { value: "a",
// writable: true,
// enumerable: true,
// configurable: true
// }

上面代码表示,使用Object.getOwnPropertyDescriptor方法,读取o对象的p属性的属性描述对象。

Object.defineProperty(),Object.defineProperties()

Object.defineProperty方法允许通过定义属性描述对象,来定义或修改一个属性,然后返回修改后的对象。它的格式如下。

Object.defineProperty(object, propertyName, attributesObject)
上面代码中,Object.defineProperty方法接受三个参数,第一个是属性所在的对象,第二个是属性名(它应该是一个字符串),第三个是属性的描述对象。比如,新建一个o对象,并定义它的p属性,写法如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var o = Object.defineProperty({}, 'p', {
value: 123,
writable: false,
enumerable: true,
configurable: false
});
o.p
// 123
o.p = 246;
o.p
// 123
// 因为writable为false,所以无法改变该属性的值

如果属性已经存在,Object.defineProperty方法相当于更新该属性的属性描述对象。

需要注意的是,Object.defineProperty方法和后面的Object.defineProperties方法,都有 性能损耗 ,会拖慢执行速度,不宜大量使用

如果一次性定义或修改多个属性,可以使用Object.defineProperties方法。

1
2
3
4
5
6
7
8
9
10
11
12
var o = Object.defineProperties({}, {
p1: { value: 123, enumerable: true },
p2: { value: 'abc', enumerable: true },
p3: { get: function () { return this.p1 + this.p2 },
enumerable:true,
configurable:true
}
});
o.p1 // 123
o.p2 // "abc"
o.p3 // "123abc"

上面代码中的p3属性,定义了取值函数get。这时需要注意的是,一旦定义了取值函数get(或存值函数set),就不能将writable设为true,或者同时定义value属性,会报错。

1
2
3
4
5
6
7
8
var o = {};
Object.defineProperty(o, 'p', {
value: 123,
get: function() { return 456; }
});
// TypeError: Invalid property.
// A property cannot both have accessors and be writable or have a value,

上面代码同时定义了get属性和value属性,结果就报错。

Object.defineProperty()和Object.defineProperties()的第三个参数,是一个属性对象。它的writable、configurable、enumerable这三个属性的默认值都为false

1
2
3
4
5
6
7
8
9
var obj = {};
Object.defineProperty(obj, 'foo', { configurable: true });
Object.getOwnPropertyDescriptor(obj, 'foo')
// {
// value: undefined,
// writable: false,
// enumerable: false,
// configurable: true
// }

上面代码中,定义obj对象的foo属性时,只定义了可配置性configurable为true。结果,其他元属性都是默认值。

writable属性为false,表示对应的属性的值将不得改写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var o = {};
Object.defineProperty(o, 'p', {
value: "bar"
});
o.p // bar
o.p = 'foobar';
o.p // bar
Object.defineProperty(o, 'p', {
value: 'foobar',
});
// TypeError: Cannot redefine property: p

上面代码由于writable属性默认为false,导致无法对p属性重新赋值,但是不会报错(严格模式下会报错)。不过,如果再一次使用Object.defineProperty方法对value属性赋值,就会报错。

configurable属性为false,将无法删除该属性,也无法修改attributes对象(value属性除外)。

1
2
3
4
5
6
7
8
var o = {};
Object.defineProperty(o, 'p', {
value: 'bar',
});
delete o.p
o.p // "bar"

上面代码中,由于configurable属性默认为false,导致无法删除某个属性。

enumerable属性为false,表示对应的属性不会出现在for…in循环和Object.keys方法中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var o = {
p1: 10,
p2: 13,
};
Object.defineProperty(o, 'p3', {
value: 3,
});
for (var i in o) {
console.log(i, o[i]);
}
// p1 10
// p2 13

上面代码中,p3属性是用Object.defineProperty方法定义的,由于enumerable属性默认为false,所以不出现在for…in循环中。

元属性

属性描述对象属性,被称为“元属性”,因为它可以看作是控制属性的属性

可枚举性(enumerable)

JavaScript的最初版本,in 运算符和基于它的for…in循环,会遍历对象实例的所有属性,包括继承的属性。

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

上面代码中,toString不是obj对象自身的属性,但是in运算符也返回true,导致被for…in循环遍历,这显然不太合理。后来就引入了“可枚举性”这个概念,只有可枚举的属性,才会被for...in循环遍历,同时还规定 原生继承的属性都是不可枚举的 ,这样就保证了for…in循环的可用性。

可枚举性(enumerable)用来控制所描述的属性,是否将被包括在for…in循环之中。具体来说,如果一个属性的enumerable为false,下面三个操作不会取到该属性。

  • for..in循环
  • Object.keys方法
  • JSON.stringify方法

因此,enumerable可以用来设置“秘密”属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var o = {a: 1, b: 2};
o.c = 3;
Object.defineProperty(o, 'd', {
value: 4,
enumerable: false
});
o.d // 4
for (var key in o) {
console.log(o[key]);
}
// 1
// 2
// 3
Object.keys(o) // ["a", "b", "c"]
JSON.stringify(o) // "{a:1, b:2, c:3}"

上面代码中,d属性的enumerable为false,所以一般的遍历操作都无法获取该属性,使得它有点像“秘密”属性, 但不是真正的私有属性,还是可以直接获取它的值

基本上,JavaScript 原生提供的属性都是不可枚举 的,用户 自定义的属性都是可枚举的

与枚举性相关的几个操作的区别的是:

  • for…in循环 包括继承自原型对象的属性
  • Object.keys方法 只返回对象本身的属性
  • 如果需要获取对象自身的所有属性,不管是否可枚举,可以使用Object.getOwnPropertyNames方法

考虑到JSON.stringify方法会排除enumerable为false的值,有时可以利用这一点,为对象添加注释信息(即无法解析的信息)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var car = {
id: 123,
color: 'red',
ownerId: 12
};
var owner = {
id: 12,
name: 'Jack'
};
Object.defineProperty(car, 'ownerInfo', {
value: owner,
enumerable: false
});
car.ownerInfo
// {id: 12, name: "Jack"}
JSON.stringify(car)
// "{"id": 123,"color": "red","ownerId": 12}"

上面代码中,owner对象作为注释部分,加入car对象。由于ownerInfo属性不可枚举,所以JSON.stringify方法最后输出car对象时,会忽略ownerInfo属性。

这提示我们,如果你 不愿意某些属性出现在JSON输出之中 ,可以把它的enumerable属性设为false。

可配置性(configurable)

可配置性(configurable)决定了 是否可以修改属性描述对象 。也就是说,当 configurable为false 的时候,value、writable、enumerable和configurable不能被修改了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var o = Object.defineProperty({}, 'p', {
value: 1,
writable: false,
enumerable: false,
configurable: false
});
Object.defineProperty(o,'p', {value: 2})
// TypeError: Cannot redefine property: p
Object.defineProperty(o,'p', {writable: true})
// TypeError: Cannot redefine property: p
Object.defineProperty(o,'p', {enumerable: true})
// TypeError: Cannot redefine property: p
Object.defineProperties(o,'p',{configurable: true})
// TypeError: Cannot redefine property: p

上面代码首先定义对象o,并且定义o的属性p的configurable为false。然后,逐一改动value、writable、enumerable、configurable,结果都报错。

需要注意的是,writable只有在从false改为true会报错,从true改为false则是允许的

1
2
3
4
5
6
7
var o = Object.defineProperty({}, 'p', {
writable: true,
configurable: false
});
Object.defineProperty(o,'p', {writable: false})
// 修改成功

至于value,只要writable和configurable有一个为true,就允许改动

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 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})
// 修改成功
另外,configurable为false时,直接对该属性赋值,不报错,但不会成功。
var o = Object.defineProperty({}, 'p', {
value: 1,
configurable: false
});
o.p = 2;
o.p // 1

上面代码中,o对象的p属性是不可配置的,对它赋值是不会生效的。

可配置性决定了一个变量是否可以被删除(delete)。

1
2
3
4
5
6
7
8
9
10
var o = Object.defineProperties({}, {
p1: { value: 1, configurable: true },
p2: { value: 2, configurable: false }
});
delete o.p1 // true
delete o.p2 // false
o.p1 // undefined
o.p2 // 2

上面代码中的对象o有两个属性,p1是可配置的,p2是不可配置的。结果,p2就无法删除。

需要注意的是,当使用var命令声明变量时,变量的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
29
30
31
32
var a1 = 1;
Object.getOwnPropertyDescriptor(this,'a1')
// Object {
// value: 1,
// writable: true,
// enumerable: true,
// configurable: false
// }
而不使用var命令声明变量时(或者使用属性赋值的方式声明变量),变量的可配置性为true
a2 = 1;
Object.getOwnPropertyDescriptor(this,'a2')
// Object {
// value: 1,
// writable: true,
// enumerable: true,
// configurable: true
// }
// 或者写成
window.a3 = 1;
Object.getOwnPropertyDescriptor(window, 'a3')
// Object {
// value: 1,
// writable: true,
// enumerable: true,
// configurable: true
// }

上面代码中的this.a3 = 1与a3 = 1是等价的写法。window指的是浏览器的顶层对象。

这种差异意味着,如果一个变量是使用var命令生成的,就无法用delete命令删除。也就是说,delete只能删除对象的属性

1
2
3
4
5
6
7
8
var a1 = 1;
a2 = 1;
delete a1 // false
delete a2 // true
a1 // 1
a2 // ReferenceError: a2 is not defined

可写性(writable)

可写性(writable)决定了属性的值(value)是否可以被改变。

1
2
3
4
5
6
7
8
9
10
var o = {};
Object.defineProperty(o, 'a', {
value: 37,
writable: false
});
o.a // 37
o.a = 25;
o.a // 37

上面代码将o对象的a属性可写性设为false,然后改变这个属性的值,就不会有任何效果。

注意,正常模式下,对可写性为false的属性赋值不会报错,只会默默失败。但是,严格模式下会报错,即使是对a属性重新赋予一个同样的值。

关于可写性,还有一种特殊情况。就是 如果原型对象的某个属性的可写性为false,那么派生对象将无法自定义这个属性

1
2
3
4
5
6
7
8
9
var proto = Object.defineProperty({}, 'foo', {
value: 'a',
writable: false
});
var o = Object.create(proto);
o.foo = 'b';
o.foo // 'a'

上面代码中,对象proto的foo属性不可写,结果proto的派生对象o,也不可以再自定义这个属性了。在严格模式下,这样做还会抛出一个错误。但是,有一个规避方法,就是通过 覆盖属性描述对象,绕过这个限制原因是这种情况下,原型链会被完全忽视

1
2
3
4
5
Object.defineProperty(o, 'foo', {
value: 'b'
});
o.foo // 'b'

Object.getOwnPropertyNames()

Object.getOwnPropertyNames方法返回直接定义在某个对象上面的 全部属性的名称 ,而 不管该属性是否可枚举

1
2
3
4
5
6
7
var o = Object.defineProperties({}, {
p1: { value: 1, enumerable: true },
p2: { value: 2, enumerable: false }
});
Object.getOwnPropertyNames(o)
// ["p1", "p2"]

一般来说,系统原生的属性(即非用户自定义的属性)都是不可枚举的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 比如,数组实例自带length属性是不可枚举的
Object.keys([]) // []
Object.getOwnPropertyNames([]) // [ 'length' ]
// Object.prototype对象的自带属性也都是不可枚举的
Object.keys(Object.prototype) // []
Object.getOwnPropertyNames(Object.prototype)
// ['hasOwnProperty',
// 'valueOf',
// 'constructor',
// 'toLocaleString',
// 'isPrototypeOf',
// 'propertyIsEnumerable',
// 'toString']

上面代码可以看到,数组的实例对象([])没有可枚举属性,不可枚举属性有length;Object.prototype对象也没有可枚举属性,但是有不少不可枚举属性。

Object.prototype.propertyIsEnumerable()

对象实例的propertyIsEnumerable方法用来 判断一个属性是否可枚举

1
2
3
4
5
var o = {};
o.p = 123;
o.propertyIsEnumerable('p') // true
o.propertyIsEnumerable('toString') // false

上面代码中,用户自定义的p属性是可枚举的,而 继承自原型对象 的toString属性是不可枚举的。

存取器(accessor)

除了直接定义以外 , 属性还可以用存取器(accessor)定义。其中,存值函数称为setter,使用set命令;取值函数称为getter,使用get命令。

存取器提供的是 虚拟属性 ,即该属性的值不是实际存在的,而是 每次读取时计算生成的 。利用这个功能,可以实现许多高级特性 ,比如每个属性禁止赋值。

1
2
3
4
5
6
7
8
var o = {
get p() {
return 'getter';
},
set p(value) {
console.log('setter: ' + value);
}
};

上面代码中,o对象内部的get和set命令,分别定义了p属性的取值函数和存值函数。定义了这两个函数之后,对p属性取值时,取值函数会自动调用;对p属性赋值时,存值函数会自动调用。

1
2
o.p // "getter"
o.p = 123 // "setter: 123"

注意, 取值函数Getter不能接受参数,存值函数Setter只能接受一个参数(即属性的值)。另外,对象也 不能有与取值函数同名的属性 。比如,上面的对象o设置了取值函数p以后,就不能再另外定义一个p属性。

存取器往往用于,属性的值需要依赖对象内部数据 的场合。

1
2
3
4
5
6
7
8
9
10
11
12
13
var o ={
$n : 5,
get next() { return this.$n++ },
set next(n) {
if (n >= this.$n) this.$n = n;
else throw '新的值必须大于当前值';
}
};
o.next // 5
o.next = 10;
o.next // 10

上面代码中,next属性的存值函数和取值函数,都依赖于对内部属性$n的操作。

存取器也可以通过 Object.defineProperty 定义。

1
2
3
4
5
6
7
8
9
10
var d = new Date();
Object.defineProperty(d, 'month', {
get: function () {
return d.getMonth();
},
set: function (v) {
d.setMonth(v);
}
});

上面代码为Date的实例对象d,定义了一个可读写的month属性。

存取器也可以使用Object.create方法定义。

1
2
3
4
5
6
7
8
9
10
var o = Object.create(Object.prototype, {
foo: {
get: function () {
return 'getter';
},
set: function (value) {
console.log('setter: '+value);
}
}
});

如果使用上面这种写法,属性foo必须定义一个属性描述对象。该对象的get和set属性,分别是foo的取值函数和存值函数。

利用存取器,可以实现数据对象与DOM对象的双向绑定

1
2
3
4
5
6
7
8
9
Object.defineProperty(user, 'name', {
get: function () {
return document.getElementById('foo').value;
},
set: function (newValue) {
document.getElementById('foo').value = newValue;
},
configurable: true
});

上面代码使用存取函数,将DOM对象foo与数据对象user的name属性,实现了绑定。两者之中只要有一个对象发生变化,就能在另一个对象上实时反映出来。

对象的拷贝

有时,我们需要将一个对象的所有属性,拷贝到另一个对象。ES5没有提供这个方法,必须自己实现。

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
var extend = function (to, from) {
for (var property in from) {
Object.defineProperty(
to,
property,
Object.getOwnPropertyDescriptor(from, property)
);
}
return to;
}
extend({}, { get a(){ return 1 } })
// { get a(){ return 1 } })

这段代码还是有问题,拷贝某些属性时会失效。

1
2
3
extend(document.body.style, {
backgroundColor: "red"
});

上面代码的目的是,设置document.body.style.backgroundColor属性为red,但是实际上网页的背景色并不会变红。但是,如果用第一种简单拷贝的方法,反而能够达到目的。这提示我们,可以把两种方法结合起来,对于简单属性,就直接拷贝,对于那些通过属性描述对象设置的属性,则使用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) {
var descriptor = Object.getOwnPropertyDescriptor(from, property);
if (descriptor && ( !descriptor.writable
|| !descriptor.configurable
|| !descriptor.enumerable
|| descriptor.get
|| descriptor.set)) {
Object.defineProperty(to, property, descriptor);
} else {
to[property] = from[property];
}
}
}

上面的这段代码,可以很好地拷贝对象所有可遍历(enumerable)的属性。

控制对象状态

JavaScript提供了三种方法,精确控制一个对象的读写状态,防止对象被改变。最弱一层的保护是Object.preventExtensions,其次是Object.seal,最强的Object.freeze

面向对象编程

构造函数与new命令

对象是什么

面向对象编程它将真实世界各种复杂的关系,抽象为一个个对象,然后由对象之间的分工与合作,完成对真实世界的模拟。

每一个对象都是功能中心,具有明确分工,可以完成接受信息、处理数据、发出信息等任务。因此,面向对象编程具有灵活代码可复用高度模块化等特点,容易维护和开发,比起由一系列函数或指令组成的传统的过程式编程(procedural programming),更适合多人合作的大型软件项目。

那么,“对象”(object)到底是什么?

我们从两个层次来理解。

(1)对象是单个实物的抽象

一本书、一辆汽车、一个人都可以是对象,一个数据库、一张网页、一个与远程服务器的连接也可以是对象。当实物被抽象成对象,实物之间的关系就变成了对象之间的关系,从而就可以模拟现实情况,针对对象进行编程。

(2)对象是一个容器,封装了属性(property)和方法(method)

属性是对象的状态,方法是对象的行为(完成某种任务)。比如,我们可以把动物抽象为animal对象,使用“属性”记录具体是那一种动物,使用“方法”表示动物的某种行为(奔跑、捕猎、休息等等)。

构造函数

典型的面向对象编程语言(比如 C++ 和 Java),存在“类”(class)这个概念。所谓“类”就是对象的模板,对象就是“类”的实例。但是,JavaScript 语言的对象体系,不是基于“类”的,而是基于 构造函数(constructor)和 原型链(prototype)。

JavaScript 语言使用构造函数(constructor)作为对象的模板。所谓”构造函数”,就是专门用来生成对象的函数。它提供模板,描述对象的基本结构。一个构造函数,可以生成多个对象,这些对象都有相同的结构。

构造函数的写法就是一个普通的函数,但是有自己的特征和用法。

var Vehicle = function () {
this.price = 1000;
};
上面代码中,Vehicle就是构造函数,它提供模板,用来生成实例对象。为了与普通函数区别,构造函数名字的 第一个字母通常大写

构造函数的特点有两个。

  1. 函数体内部使用了this关键字,代表了所要生成的对象实例。
  2. 生成对象的时候,必需用new命令,调用Vehicle函数。

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
var v = new Vehicle();
var v = new Vehicle;

一个很自然的问题是,如果忘了使用new命令,直接调用构造函数会发生什么事

这种情况下,构造函数就变成了普通函数,并不会生成实例对象。而且由于后面会说到的原因,this这时代表全局对象,将造成一些意想不到的结果。

1
2
3
4
5
6
7
8
9
10
var Vehicle = function (){
this.price = 1000;
};
var v = Vehicle();
v.price
// Uncaught TypeError: Cannot read property 'price' of undefined
price
// 1000

上面代码中,调用Vehicle构造函数时,忘了加上new命令。结果,price属性变成了全局变量,而变量v变成了undefined。

因此,应该非常小心,避免出现不使用new命令、直接调用构造函数的情况。为了保证构造函数必须与new命令一起使用,一个解决办法是,在构造函数内部使用严格模式,即第一行加上use strict

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

上面代码中,构造函数Vehicle的return语句返回一个数值。这时,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

上面代码中,构造函数Vehicle的return语句,返回的是一个新对象。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, /* 构造函数参数 */ param1) {
// 将 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 张三.

上面代码中,对象person1是person2的模板,后者继承了前者的属性和方法。

this关键字

涵义

总结一下,JavaScript 语言之中,一切皆对象,运行环境也是对象,所以函数都是在某个对象之中运行,this就是这个对象(环境)。这本来并不会让用户糊涂,但是 JavaScript 支持运行环境动态切换,也就是说,this的指向是动态的,没有办法事先确定到底指向哪个对象,这才是最让初学者感到困惑的地方。

如果一个函数在全局环境中运行,那么this就是指顶层对象(浏览器中为window对象)。

1
2
3
4
5
function f() {
return this;
}
f() === window // true

上面代码中,函数f在全局环境运行,它内部的this就指向顶层对象window。

可以近似地认为,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() // "姓名:李四"

使用场合

this的使用可以分成以下几个场合。

(1)全局环境

在全局环境使用this,它指的就是顶层对象window。

1
2
3
4
5
this === window // true
function f() {
console.log(this === window); // true
}

上面代码说明,不管是不是在函数内部,只要是在全局环境下运行,this就是指顶层对象window。

(2)构造函数

构造函数中的this,指的是实例对象

1
2
3
4
5
6
7
var Obj = function (p) {
this.p = p;
};
Obj.prototype.m = function() {
return this.p;
};

上面代码定义了一个构造函数Obj。由于this指向实例对象,所以在构造函数内部定义this.p,就相当于定义实例对象有一个p属性;然后m方法可以返回这个p属性。

1
2
3
4
var o = new Obj('Hello World!');
o.p // "Hello World!"
o.m() // "Hello World!"

(3)对象的方法

当 A 对象的方法被赋予 B 对象,该方法中的this就从指向 A 对象变成了指向 B 对象。所以要特别小心,将某个对象的方法赋值给另一个对象,会改变this的指向。

请看下面的代码。

1
2
3
4
5
6
7
var obj ={
foo: function () {
console.log(this);
}
};
obj.foo() // obj

上面代码中,obj.foo方法执行时,它内部的this指向obj。

但是,只有这一种用法(直接在obj对象上调用foo方法),this指向obj;其他用法时,this都指向代码块当前所在对象(浏览器为window对象)。

1
2
3
4
5
6
7
8
// 情况一
(obj.foo = obj.foo)() // window
// 情况二
(false || obj.foo)() // window
// 情况三
(1, obj.foo)() // window

上面代码中,obj.foo先运算再执行,即使值根本没有变化,this也不再指向obj了。这是因为这时它就脱离了运行环境obj,而是在全局环境执行。

可以这样理解,在 JavaScript 引擎内部,obj和obj.foo储存在两个内存地址,简称为M1和M2。只有obj.foo()这样调用时,是从M1调用M2,因此this指向obj。但是,上面三种情况,都是直接取出M2进行运算,然后就在全局环境执行运算结果(还是M2),因此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
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

使用注意点

(1)避免多层 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,结果运行后,第一层指向该对象,第二层指向全局对象。实际执行的是下面的代码。

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指向顶层对象,就会报错。

(2)避免数组处理方法中的this

数组的 map和foreach 方法,允许提供一个函数作为参数。这个函数内部不应该使用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不指向外部,而指向顶层对象

解决这个问题的一种方法,是使用中间变量。

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

(3)避免回调函数中的this

回调函数中的this往往会改变指向,最好避免使用。

1
2
3
4
5
6
7
var o = new Object();
o.f = function () {
console.log(this === o);
}
o.f() // true

上面代码表示,如果调用o对象的f方法,其中的this就是指向o对象。

但是,如果将f方法指定给某个按钮的click事件,this的指向就变了。

1
$('#button').on('click', o.f);

点击按钮以后,控制台会显示false。原因是此时this不再指向o对象,而是指向按钮的DOM对象,因为f方法是在按钮对象的环境中被调用的。这种细微的差别,很容易在编程中忽视,导致难以察觉的错误。

为了解决这个问题,可以采用下面的一些方法 对this进行绑定 ,也就是使得this固定指向某个对象,减少不确定性。

绑定 this 的方法

this的动态切换,固然为JavaScript创造了巨大的灵活性,但也使得编程变得困难和模糊。有时,需要把this固定下来,避免出现意想不到的情况。JavaScript提供了call、apply、bind这三个方法,来切换/固定this的指向。

function.prototype.call()

函数实例的call方法,可以指定函数内部this的指向(即函数执行时所在的作用域),然后在所指定的作用域中,调用该函数。

1
2
3
4
5
6
7
8
var obj = {};
var f = function () {
return this;
};
f() === this // true
f.call(obj) === obj // true

上面代码中,在全局环境运行函数f时,this指向全局环境;call方法可以改变this的指向,指定this指向对象obj,然后在对象obj的作用域中运行函数f。

call方法的参数,应该是一个对象。如果参数为空、null和undefined,则默认传入全局对象。

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方法没有参数,或者参数为null或undefined,则等同于指向全局对象。

如果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方法还可以接受多个参数

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绑定当前环境(对象),并且参数为1和2,因此函数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

上面代码中,hasOwnProperty是obj对象继承的方法,如果这个方法一旦被覆盖,就不会得到正确结果。call方法可以解决这个问题,它将hasOwnProperty方法的原始定义放到obj对象上执行,这样无论obj上有没有同名方法,都不会影响结果。

function.prototype.apply()

apply方法的作用与call方法类似,也是改变this指向,然后再调用该函数。唯一的区别就是,它接收一个数组作为函数执行时的参数,使用格式如下。

1
func.apply(thisValue, [arg1, arg2, ...])

apply方法的第一个参数也是this所要指向的那个对象,如果设为null或undefined,则等同于指定全局对象。第二个参数则是一个数组,该数组的所有成员依次作为参数,传入原函数。原函数的参数,在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
3
4
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

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方法可以解决这个问题,让log方法绑定console对象。

1
2
var print = d.getTime.bind(d);
print() // 1481869925657

上面代码中,bind方法将getTime方法内部的this绑定到d对象,这时就可以安全地将这个方法赋值给其他变量了。

bind比call方法和apply方法更进一步的是,除了绑定this以外,还可以绑定原函数的参数。

1
2
3
4
5
6
7
8
9
10
11
12
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)

上面代码中,bind方法除了绑定this对象,还将add函数的第一个参数x绑定成5,然后返回一个新函数newAdd,这个函数只要再接受一个参数y就能运行了。

如果bind方法的第一个参数是null或undefined,等于将this绑定到全局对象,函数运行时this指向顶层对象(在浏览器中为window)。

bind方法有一些使用注意点。

(1)每一次返回一个新函数

bind方法每运行一次,就返回一个新函数,这会产生一些问题。比如,监听事件的时候,不能写成下面这样。

element.addEventListener(‘click’, o.m.bind(o));
上面代码中,click事件绑定bind方法生成的一个匿名函数。这样会导致无法取消绑定,所以,下面的代码是无效的。

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的方法直接当作回调函数。

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)
// TypeError: Cannot read property 'count' of undefined

上面代码中,counter.inc方法被当作回调函数,传入了callIt,调用时其内部的this指向callIt运行时所在的对象,即顶层对象window,所以得不到预想结果。注意,上面的counter.inc方法内部使用了严格模式,在该模式下,this指向顶层对象时会报错,一般模式不会。

解决方法就是使用bind方法,将counter.inc绑定counter。

1
2
callIt(counter.inc.bind(counter));
counter.count // 1

还有一种情况比较隐蔽,就是某些数组方法可以接受一个函数当作参数。这些函数内部的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.times的this是指向obj的,这个没有问题。但是,forEach方法的回调函数内部的this.name却是指向全局对象,导致没有办法取到值。稍微改动一下,就可以看得更清楚。

1
2
3
4
5
obj.print = function () {
this.times.forEach(function (n) {
console.log(this === window);
});
};
1
2
3
4
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()
// 张三
// 张三
// 张三

prototype 对象

大部分面向对象的编程语言,都是以“类”(class)作为对象体系的语法基础。JavaScript 语言不是如此,它的面向对象编程基于“原型对象”。

概述

构造函数的缺点

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属性,所有实例对象都会生成这两个属性。但是,这样做是对系统资源的浪费,因为同一个构造函数的对象实例之间,无法共享属性。

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('mew, mew, mew...');
};
}
var cat1 = new Cat('大毛', '白色');
var cat2 = new Cat('二毛', '黑色');
cat1.meow === cat2.meow
// false

上面代码中,cat1和cat2是同一个构造函数的实例。但是,它们的meow方法是不一样的,就是说 每新建一个实例,就会新建一个meow方法 。这既没有必要,又浪费系统资源,因为所有meow方法都是同样的行为,完全应该共享。

prototype 属性的作用

总结一下,原型对象的作用,就是定义所有实例对象共享的属性和方法。这也是它被称为原型对象的原因,而实例对象可以视作从原型对象衍生出来的子对象。

avaScript 的每个对象都继承另一个对象,后者称为“原型”(prototype)对象。只有null除外,它没有自己的原型对象。

原型对象上的 所有属性和方法,都能被派生对象共享 。这就是 JavaScript 继承机制的基本设计。

通过构造函数生成实例对象时,会自动为实例对象分配原型对象。每一个构造函数都有一个prototype属性,这个属性就是实例对象的原型对象。

1
2
3
4
5
6
7
8
9
10
11
function Animal (name) {
this.name = name;
}
Animal.prototype.color = 'white';
var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');
cat1.color // 'white'
cat2.color // 'white'

上面代码中,构造函数Animal的prototype对象,就是实例对象cat1和cat2的原型对象。在原型对象上添加一个color属性。结果,实例对象都能读取该属性。

原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。

1
2
3
Animal.prototype.walk = function () {
console.log(this.name + ' is walking');
};

上面代码中,Animal.prototype对象上面定义了一个walk方法,这个方法将可以在所有Animal实例对象上面调用。

由于 JavaScript 的所有对象都有构造函数(只有null除外),而所有构造函数都有prototype属性(其实是所有函数都有prototype属性),所以所有对象都有自己的原型对象。

原型链

对象的属性和方法,有可能是定义在自身,也有可能是定义在它的原型对象。由于原型本身也是对象,又有自己的原型,所以形成了一条原型链(prototype chain)。比如:a对象是b对象的原型,b对象是c对象的原型,以此类推。

如果一层层地上溯,所有对象的原型最终都可以上溯到Object.prototype,即Object构造函数的prototype属性指向的那个对象。那么,Object.prototype对象有没有它的原型呢?回答可以是有的,就是没有任何属性和方法的null对象,而null对象没有自己的原型。

1
2
Object.getPrototypeOf(Object.prototype)
// null

上面代码表示,Object.prototype对象的原型是null,由于null没有任何属性,所以原型链到此为止。

“原型链”的作用是,读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype还是找不到,则返回undefined。

如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)。

需要注意的是,一级级向上,在原型链寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。

举例来说,如果让某个函数的prototype属性指向一个数组,就意味着该函数可以当作数组的构造函数,因为它生成的实例对象都可以通过prototype属性调用数组方法。

1
2
3
4
5
6
7
8
9
10
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表达式,我们知道instanceof运算符用来比较一个对象是否为某个构造函数的实例,最后一行就表示mine为Array的实例。

constructor 属性

prototype对象有一个constructor属性,默认指向prototype对象所在的构造函数

1
2
3
4
function P() {}
P.prototype.constructor === P
// true

由于constructor属性定义在prototype对象上面,意味着可以被所有实例对象继承。

1
2
3
4
5
6
7
8
9
10
11
function P() {}
var p = new P();
p.constructor
// function P() {}
p.constructor === P.prototype.constructor
// true
p.hasOwnProperty('constructor')
// false

上面代码中,p是构造函数P的实例对象,但是p自身没有contructor属性,该属性其实是读取原型链上面的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();
};

由于constructor属性是一种原型对象与构造函数的关联关系,所以修改原型对象的时候,务必要小心。

1
2
3
4
5
6
7
function A() {}
var a = new A();
a instanceof A // true
function B() {}
A.prototype = B.prototype;
a instanceof A // false

上面代码中,a是A的实例。修改了A.prototype以后,constructor属性的指向就变了,导致instanceof运算符失真。

所以,修改原型对象时,一般要同时校正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 (...) { ... };

上面代码中,避免完全覆盖掉原来的prototype属性,要么将constructor属性重新指向原来的构造函数,要么只在原型对象上添加方法,这样可以保证instanceof运算符不会失真。

此外,通过 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运算符的左边是实例对象,右边是构造函数。它会检查右边构建函数的原型对象,是否在 左边对象的原型链 上。因此,下面两种写法是等价的。

1
2
3
v instanceof Vehicle
// 等同于
Vehicle.prototype.isPrototypeOf(v)

由于instanceof对整个原型链上的对象都有效,因此同一个实例对象,可能会对多个构造函数都返回true。

1
2
3
var d = new Date();
d instanceof Date // true
d instanceof Object // true

上面代码中,d同时是Date和Object的实例,因此对这两个构造函数都返回true。

instanceof的原理是检查原型链,对于那些不存在原型链的对象,就无法判断。

1
Object.create(null) instanceof Object // false

上面代码中,Object.create(null)返回的新对象的原型是null,即不存在原型,因此instanceof就认为该对象不是Object的实例。

除了上面这种继承null的特殊情况,JavaScript 之中,只要是对象,就有对应的构造函数。因此,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。

此外,对于undefined和null,instanceOf运算符总是返回 false

1
2
undefined instanceof Object // false
null instanceof Object // false

利用instanceof运算符,还可以巧妙地解决,调用构造函数时,忘了加new命令的问题。

1
2
3
4
5
6
7
8
9
function Fubar (foo, bar) {
if (this instanceof Fubar) {
this._foo = foo;
this._bar = bar;
}
else {
return new Fubar(foo, bar);
}
}

上面代码使用instanceof运算符,在函数体内部判断this关键字是否为构造函数Fubar的实例。如果不是,就表明忘了加new命令。

Object.getPrototypeOf()

Object.getPrototypeOf方法返回一个对象的原型。这是获取原型对象的标准方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 空对象的原型是Object.prototype
Object.getPrototypeOf({}) === Object.prototype
// true
// 函数的原型是Function.prototype
function f() {}
Object.getPrototypeOf(f) === Function.prototype
// true
// f 为 F 的实例对象,则 f 的原型是 F.prototype
var f = new F();
Object.getPrototypeOf(f) === F.prototype
// true

Object.setPrototypeOf()

Object.setPrototypeOf方法可以为现有对象设置原型,返回一个新对象。

Object.setPrototypeOf方法接受两个参数,第一个是现有对象,第二个是原型对象。

1
2
3
4
5
6
var a = {x: 1};
var b = Object.setPrototypeOf({}, a);
// 等同于
// var b = {__proto__: a};
b.x // 1

上面代码中,b对象是Object.setPrototypeOf方法返回的一个新对象。该对象本身为空、原型为a对象,所以b对象可以拿到a对象的所有属性和方法。b对象本身并没有x属性,但是 JavaScript 引擎找到它的原型对象a,然后读取a的x属性。

new命令通过构造函数新建实例对象,实质就是将实例对象的原型,指向构造函数的prototype属性,然后在实例对象上执行构造函数。

1
2
3
4
5
6
7
8
9
var F = function () {
this.foo = 'bar';
};
var f = new F();
// 等同于
var f = Object.setPrototypeOf({}, F.prototype);
F.call(f);

Object.create()

生成实例对象的常用方法,就是使用new命令,让构造函数返回一个实例。但是很多时候,只能拿到一个实例对象,它可能根本不是由构建函数生成的,那么能不能 从一个实例对象,生成另一个实例对象 呢?

JavaScript 提供了Object.create方法,用来满足这种需求。该方法接受一个对象作为参数,然后 以它为原型,返回一个实例对象 。该实例完全继承继承原型对象的属性。

1
2
3
4
5
6
7
8
9
10
11
// 原型对象
var A = {
print: function () {
console.log('hello');
}
};
// 实例对象
var B = Object.create(A);
B.print() // hello
B.print === A.print // true

上面代码中,Object.create方法以A对象为原型,生成了B对象。B继承了A的所有属性和方法。这段代码等同于下面的代码。

1
2
3
4
5
6
7
8
9
var A = function () {};
A.prototype = {
print: function () {
console.log('hello');
}
};
var B = new A();
B.print === A.prototype.print // true

实际上,Object.create方法可以用下面的代码代替。如果老式浏览器不支持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();

如果想要生成一个不继承任何属性(比如没有toString和valueOf方法)的对象,可以将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
5
6
7
8
9
10
11
12
13
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方法生成的新对象,动态继承了原型。在原型上添加或修改任何方法,会立刻反映在新对象之上。
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

上面代码表明,只要某个对象处在原型链上,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 一般不用

获取原型对象方法的比较

推荐使用第三种Object.getPrototypeOf方法,获取原型对象。

var o = new Object();
Object.getPrototypeOf(o) === Object.prototype

Object 对象与继承

通过原型链,对象的属性分成两种:

  • 自身的属性
  • 继承的属性。

JavaScript 语言在Object对象上面,提供了很多相关方法,来处理这两种不同的属性。

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) // []

Object.prototype.hasOwnProperty()

对象实例的hasOwnProperty方法 返回一个布尔值 ,用于判断某个属性定义在对象自身,还是定义在原型链上。

1
2
3
4
5
Date.hasOwnProperty('length')
// true
Date.hasOwnProperty('toString')
// false

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
var o1 = {p1: 123};
var o2 = Object.create(o1, {
p2: { value: "abc", enumerable: true }
});
for (p in o2) {console.info(p);}
// p2
// p1

为了在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
inheritedPropertyNames(Date)

对象的拷贝

如果要拷贝一个对象,需要做到下面两件事情。

  1. 确保拷贝后的对象,与原对象具有同样的prototype原型对象。
  2. 确保拷贝后的对象,与原对象具有同样的属性。

下面就是根据上面两点,编写的对象拷贝的函数。

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;
}

面向对象编程的模式

构造函数的继承

举例来说,下面是一个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
5
var rect = new Rectangle();
rect.move(1, 1) // 'Shape moved.'
rect instanceof Rectangle // true
rect instanceof Shape // true

上面代码中,子类是整体继承父类。有时只需要单个方法的继承,这时可以采用下面的写法。

1
2
3
4
ClassB.prototype.print = function() {
ClassA.prototype.print.call(this);
// some code
}

上面代码中,子类B的print方法先调用父类A的print方法,再部署自己的代码。这就等于继承了父类A的print方法。

语法专题

单线程模型

含义

单线程模型指的是,JavaScript只在一个线程上运行。也就是说,JavaScript同时只能执行一个任务,其他任务都必须在后面排队等待。

注意,JavaScript只在一个线程上运行,不代表JavaScript引擎只有一个线程。事实上,JavaScript引擎有多个线程,单个脚本只能在一个线程上运行,其他线程都是在后台配合。

JavaScript之所以采用单线程,而不是多线程,跟历史有关系。JavaScript从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

单线程模型带来了一些问题,主要是新的任务被加在队列的尾部,只有前面的所有任务运行结束,才会轮到它执行。如果有一个任务特别耗时,后面的任务都会停在那里等待,造成浏览器失去响应,又称“假死”。为了避免“假死”,当某个操作在一定时间后仍无法结束,浏览器就会跳出提示框,询问用户是否要强行停止脚本运行。

如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript语言的设计者意识到,这时CPU完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是JavaScript内部采用的Event Loop机制。

消息队列

JavaScript运行时,除了一个运行线程,引擎还提供一个消息队列(message queue),里面是各种需要当前程序处理的消息。新的消息进入队列的时候,会自动排在队列的尾端。

运行线程只要发现消息队列不为空,就会取出排在第一位的那个消息,执行它对应的回调函数。等到执行完,再取出排在第二位的消息,不断循环,直到消息队列变空为止。

每条消息与一个回调函数相联系,也就是说,程序只要收到这条消息,就会执行对应的函数。另一方面,进入消息队列的消息,必须有对应的回调函数。否则这个消息就会遗失,不会进入消息队列。举例来说,鼠标点击就会产生一条消息,报告click事件发生了。如果没有回调函数,这个消息就遗失了。如果有回调函数,这个消息进入消息队列。等到程序收到这个消息,就会执行click事件的回调函数。

另一种情况是setTimeout会在指定时间向消息队列添加一条消息。如果消息队列之中,此时没有其他消息,这条消息会立即得到处理;否则,这条消息会不得不等到其他消息处理完,才会得到处理。因此,setTimeout指定的执行时间,只是一个最早可能发生的时间,并不能保证一定会在那个时间发生。

一旦当前执行栈空了,消息队列就会取出排在第一位的那条消息,传入程序。程序开始执行对应的回调函数,等到执行完,再处理下一条消息。

Event Loop

这部分看图理解会比较好图文地址

单线程模型虽然对JavaScript构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果部署得好,JavaScript程序是不会出现堵塞的,这就是为什么node.js平台可以用很少的资源,应付大流量访问的原因。

如果有大量的异步任务(实际情况就是这样),它们会在“消息队列”中产生大量的消息。这些消息排成队,等候进入主线程。本质上,“消息队列”就是一个“先进先出”的数据结构。比如,点击鼠标就产生一系列消息(各种事件),mousedown事件排在mouseup事件前面,mouseup事件又排在click事件的前面。

定时器

JavaScript提供定时执行代码的功能,叫做定时器(timer),主要由setTimeout()和setInterval()这两个函数来完成。它们向任务队列添加定时任务。

Promise对象

Promise是JavaScript 异步操作解决方案 。介绍 Promise 之前,先对异步操作做一个详细介绍。

JavaScript的异步执行

概述

Javascript 语言的执行环境是“单线程”(single thread)。所谓“单线程”,就是指一次只能完成一件任务。如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务。

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段 JavaScript 代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

JavaScript 语言本身并不慢,慢的是读写外部数据,比如等待 Ajax 请求返回结果。这个时候,如果对方服务器迟迟没有响应,或者网络不通畅,就会导致脚本的长时间停滞。

为了解决这个问题,Javascript 语言将任务的执行模式分成两种:同步(Synchronous)和异步(Asynchronous)。“同步模式”就是传统做法,后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的。这往往用于一些简单的、快速的、不涉及 IO 读写的操作。

“异步模式”则完全不同,每一个任务分成两段,第一段代码包含对外部数据的请求,第二段代码被写成一个回调函数,包含了对外部数据的处理。第一段代码执行完,不是立刻执行第二段代码,而是将程序的执行权交给第二个任务。等到外部数据返回了,再由系统通知执行第二段代码。所以,程序的执行顺序与任务的排列顺序是不一致的、异步的。

以下总结了”异步模式”编程的几种方法,理解它们可以让你写出结构更合理、性能更出色、维护更方便的 JavaScript 程序。

回调函数

回调函数是异步编程最基本的方法。

假定有两个函数f1和f2,后者必须等到前者执行完成,才能执行。这时,可以考虑改写f1,把f2写成f1的回调函数。

1
2
3
4
5
6
function f1(callback) {
// f1 的代码
// f1 执行完成后,调用回调函数
callback();
}

执行代码就变成下面这样。

1
f1(f2);

回调函数的优点是简单、容易理解和部署,缺点是 不利于代码的阅读和维护 ,各个部分之间 高度耦合 (Coupling),使得程序结构混乱、流程难以追踪(尤其是回调函数嵌套的情况),而且每个任务只能指定一个回调函数。

事件驱动

另一种思路是采用 事件驱动模式 。任务的执行不取决于代码的顺序,而 取决于某个事件是否发生

还是以f1和f2为例。首先,为f1绑定一个事件(这里采用的jQuery的写法)。

1
2
3
4
5
6
7
8
9
f1.on('done', f2);
上面这行代码的意思是,当f1发生done事件,就执行f2。然后,对f1进行改写:
function f1(){
setTimeout(function () {
// f1的任务代码
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
2
3
4
5
6
7
8
9
jQuery.subscribe("done", f2);
然后,f1进行如下改写:
function f1(){
setTimeout(function () {
// f1的任务代码
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秒才能完成,然后再调用回调函数。

如果有6个这样的异步任务,需要全部完成后,才能执行下一步的final函数。

1
2
3
function final(value) {
console.log('完成: ', value);
}

请问应该如何安排操作流程?

1
2
3
4
5
6
7
8
9
10
11
async(1, function(value){
async(value, function(value){
async(value, function(value){
async(value, function(value){
async(value, function(value){
async(value, final);
});
});
});
});
});

上面代码采用6个回调函数的嵌套,不仅写起来麻烦,容易出错,而且难以维护。

串行执行

我们可以编写一个流程控制函数,让它来控制异步任务,一个任务完成以后,再执行另一个。这就叫串行执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
function series(item) {
if(item) {
async( item, function(result) {
results.push(result);
return series(items.shift());
});
} else {
return final(results);
}
}
series(items.shift());

上面代码中,函数series就是串行函数,它会依次执行异步任务,所有任务都完成后,才会执行final函数。items数组保存每一个异步任务的参数,results数组保存每一个异步任务的运行结果。

并行执行

流程控制函数也可以是并行执行,即所有异步任务同时执行,等到全部完成以后,才执行final函数。

1
2
3
4
5
6
7
8
9
10
11
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
items.forEach(function(item) {
async(item, function(result){
results.push(result);
if(results.length == items.length) {
final(results);
}
})
});

上面代码中,forEach方法会同时发起6个异步任务,等到它们全部完成以后,才会执行final函数。

并行执行的好处是 效率较高 ,比起串行执行一次只能执行一个任务,较为节约时间。但是问题在于如果并行的任务较多,很容易耗尽系统资源,拖慢运行速度。因此有了第三种流程控制方式。

并行与串行的结合

所谓并行与串行的结合,就是设置一个门槛,每次最多只能并行执行n个异步任务。这样就避免了过分占用系统资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
var running = 0;
var limit = 2;
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函数。

Promise对象

简介

Promise 对象是 CommonJS 工作组提出的一种规范,目的是为异步操作提供统一接口。

那么,什么是Promises?

首先,它是一个对象,也就是说与其他JavaScript对象的用法,没有什么两样;其次,它起到代理作用(proxy),充当异步操作与回调函数之间的中介。它使得异步操作具备同步操作的接口,使得程序具备正常的同步运行的流程,回调函数不必再一层层嵌套。

简单说,它的思想是,每一个 异步任务立刻返回一个Promise对象 ,由于是立刻返回,所以可以采用同步操作的流程。这个Promises对象有一个then方法,允许指定回调函数,在异步任务完成后调用。

比如,异步操作f1返回一个Promise对象,它的回调函数f2写法如下。

(new Promise(f1)).then(f2);
这种写法对于多层嵌套的回调函数尤其方便。

// 传统写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
step1(function (value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
// ...
});
});
});
});
// Promises的写法
(new Promise(step1))
.then(step2)
.then(step3)
.then(step4);

从上面代码可以看到,采用Promises接口以后,程序流程变得非常清楚,十分易读

注意,为了便于理解,上面代码的Promise对象的生成格式,做了简化,真正的语法请参照下文。

总的来说,传统的回调函数写法使得代码混成一团,变得横向发展而不是向下发展。Promises规范就是为了解决这个问题而提出的,目标是使用正常的程序流程(同步),来处理异步操作。它先返回一个Promise对象,后面的操作以同步的方式,寄存在这个对象上面。等到异步操作有了结果,再执行前期寄放在它上面的其他操作。

Promises原本只是社区提出的一个构想,一些外部函数库率先实现了这个功能。ECMAScript 6将其写入语言标准,因此目前JavaScript语言原生支持Promise对象。

Promise接口

前面说过,Promise接口的基本思想是, 异步任务返回一个Promise对象

Promise对象只有三种状态。

  1. 异步操作“未完成”(pending)
  2. 异步操作“已完成”(resolved,又称fulfilled)
  3. 异步操作“失败”(rejected)

这三种的状态的变化途径只有两种。

  1. 异步操作从“未完成”到“已完成”
  2. 异步操作从“未完成”到“失败”。

这种变化只能发生一次,一旦当前状态变为“已完成”或“失败”,就意味着不会再有新的状态变化了。因此,Promise对象的最终结果只有两种。

  1. 异步操作成功,Promise对象传回一个值,状态变为resolved。
  2. 异步操作失败,Promise对象抛出一个错误,状态变为rejected。

Promise对象使用then方法添加回调函数。then方法可以接受两个回调函数,第一个是异步操作成功时(变为resolved状态)时的回调函数,第二个是异步操作失败(变为rejected)时的回调函数(可以省略)。一旦状态改变,就调用相应的回调函数。

1
2
3
4
5
// po是一个Promise对象
po.then(
console.log,
console.error
);

上面代码中,Promise对象po使用then方法绑定两个回调函数:操作成功时的回调函数console.log,操作失败时的回调函数console.error(可以省略)。这两个函数都接受异步操作传回的值作为参数。

then方法可以链式使用。

1
2
3
4
5
6
7
8
po
.then(step1)
.then(step2)
.then(step3)
.then(
console.log,
console.error
);

上面代码中,po的状态一旦变为resolved,就依次调用后面每一个then指定的回调函数,每一步都必须等到前一步完成,才会执行。最后一个then方法的回调函数console.log和console.error,用法上有一点重要的区别。console.log只显示回调函数step3的返回值,而console.error可以显示step1、step2、step3之中任意一个发生的错误。也就是说,假定step1操作失败,抛出一个错误,这时step2和step3都不会再执行了(因为它们是操作成功的回调函数,而不是操作失败的回调函数)。Promises对象开始寻找,接下来第一个操作失败时的回调函数,在上面代码中是console.error。这就是说,Promises对象的错误有传递性。

从同步的角度看,上面的代码大致等同于下面的形式。

1
2
3
4
5
6
7
8
try {
var v1 = step1(po);
var v2 = step2(v1);
var v3 = step3(v2);
console.log(v3);
} catch (error) {
console.error(error);
}

Promise对象的生成

ES6提供了 原生的Promise构造函数 ,用来生成Promise实例。

下面代码创造了一个Promise实例。

1
2
3
4
5
6
7
8
9
var promise = new Promise(function(resolve, reject) {
// 异步操作的代码
if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
});

Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由JavaScript引擎提供,不用自己部署。

resolve函数的作用是,将Promise对象的状态从“未完成”变为“成功”(即从Pending变为Resolved),在异步操作成功时调用,并将 异步操作 的结果,作为参数传递出去;reject函数的作用是,将Promise对象的状态从“未完成”变为“失败”(即从Pending变为Rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

Promise实例生成以后,可以用then方法分别指定Resolved状态和Reject状态的回调函数。

1
2
3
4
5
po.then(function(value) {
// success
}, function(value) {
// failure
});

Promise的应用

加载图片

我们可以把图片的加载写成一个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;
});
};

Ajax操作

Ajax操作是典型的异步操作,传统上往往写成下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function search(term, onload, onerror) {
var xhr, results, url;
url = 'http://example.com/search?q=' + term;
xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function (e) {
if (this.status === 200) {
results = JSON.parse(this.responseText);
onload(results);
}
};
xhr.onerror = function (e) {
onerror(e);
};
xhr.send();
}
search("Hello World", console.log, console.error);

如果使用Promise对象,就可以写成下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function search(term) {
var url = 'http://example.com/search?q=' + term;
var xhr = new XMLHttpRequest();
var result;
var p = new Promise(function (resolve, reject) {
xhr.open('GET', url, true);
xhr.onload = function (e) {
if (this.status === 200) {
result = JSON.parse(this.responseText);
resolve(result);
}
};
xhr.onerror = function (e) {
reject(e);
};
xhr.send();
});
return p;
}
search("Hello World").then(console.log, console.error);

加载图片的例子,也可以用Ajax操作完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function imgLoad(url) {
return new Promise(function(resolve, reject) {
var request = new XMLHttpRequest();
request.open('GET', url);
request.responseType = 'blob';
request.onload = function() {
if (request.status === 200) {
resolve(request.response);
} else {
reject(new Error('图片加载失败:' + request.statusText));
}
};
request.onerror = function() {
reject(new Error('发生网络错误'));
};
request.send();
});
}

小结

Promise对象的优点在于, 让回调函数变成了规范的链式写法程序流程可以看得很清楚。它的一整套接口,可以实现许多强大的功能,比如为多个异步操作部署一个回调函数、为多个回调函数中抛出的错误统一指定处理方法等等。

而且,它还有一个前面三种方法都没有的好处:如果一个任务已经完成,再添加回调函数,该回调函数会立即执行。所以,你不用担心是否错过了某个事件或信号。这种方法的缺点就是,编写和理解都相对比较难。

严格模式

创设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
// 方式一
function f1(str){
'use strict';
return eval(str);
}
f1('undeclared_variable = 1'); // 报错
1
2
3
4
5
// 方式二
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
13
14
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语法。

非函数代码块不得声明函数
JavaScript的新版本ES6会引入“块级作用域”。为了与新版本接轨,严格模式只允许在全局作用域或函数作用域声明函数。也就是说,不允许在非函数的代码块内声明函数。

‘use strict’;
if (true) {
function f1() { } // 语法错误
}

for (var i = 0; i < 5; i++) {
function f2() { } // 语法错误
}
上面代码在if代码块和for代码块中声明了函数,在严格模式下都会报错。

保留字
为了向将来JavaScript的新版本过渡,严格模式新增了一些保留字:implements, interface, let, package, private, protected, public, static, yield。

使用这些词作为变量名将会报错。

function package(protected) { // 语法错误
‘use strict’;
var implements; // 语法错误
}
此外,ECMAscript第五版本身还规定了另一些保留字(class, enum, export, extends, import, super),以及各大浏览器自行增加的const保留字,也是不能作为变量名的。

DOM模型

概述

基本概念

DOM

DOM是JavaScript操作网页的接口,全称为“文档对象模型”(Document Object Model)。它的作用是 将网页转为一个JavaScript对象 ,从而可以用脚本 进行各种操作(比如增删内容)。

浏览器会根据DOM模型,将结构化文档(比如HTML和XML)解析成一系列的节点,再由这些节点组成一个树状结构(DOM Tree)。所有的节点和最终的树状结构,都有规范的对外接口。所以,DOM可以理解成网页的编程接口。DOM有自己的国际标准,目前的通用版本是DOM 3,下一代版本DOM 4正在拟定中。

严格地说,DOM不属于JavaScript,但是操作DOM是JavaScript最常见的任务,而JavaScript也是最常用于DOM操作的语言。本章介绍的就是JavaScript对DOM标准的实现和用法。

节点

DOM的 最小组成单位叫做节点 (node)。文档的树形结构(DOM树),就是由各种不同类型的节点组成。每个节点可以看作是文档树的一片叶子。

节点的类型有七种。

  1. Document:整个文档树的顶层节点
  2. DocumentType:doctype标签(比如<!DOCTYPE html>)
  3. Element:网页的各种HTML标签(比如、等)
  4. Attribute:网页元素的属性(比如class=”right”)
  5. Text:标签之间或标签包含的文本
  6. Comment:注释
  7. DocumentFragment:文档的片段

这七种节点都属于 浏览器原生提供的节点对象派生对象 ,具有一些共同的属性和方法。

节点树

一个文档的所有节点,按照所在的层级,可以抽象成一种树状结构。这种树状结构就是DOM。

最顶层的节点就是document节点,它代表了整个文档。文档里面最高一层的HTML标签,一般是,它构成树结构的根节点(root node),其他HTML标签节点都是它的下级。

除了根节点以外,其他节点对于周围的节点都存在三种关系。

  • 父节点关系(parentNode):直接的那个上级节点
  • 子节点关系(childNodes):直接的下级节点
  • 同级节点关系(sibling):拥有同一个父节点的节点

DOM提供操作接口,用来获取三种关系的节点。其中,子节点接口包括firstChild(第一个子节点)和lastChild(最后一个子节点)等属性,同级节点接口包括nextSibling(紧邻在后的那个同级节点)和previousSibling(紧邻在前的那个同级节点)属性。

特征相关的属性

Node.textContent

Node.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>';

上面代码在插入文本时,会将

标签解释为文本,而不会当作标签处理。

对于Text节点和Comment节点,该属性的值与nodeValue属性相同。对于其他类型的节点,该属性会将每个子节点的内容连接在一起返回,但是不包括Comment节点。如果一个节点没有子节点,则返回空字符串。

document节点和doctype节点的textContent属性为null。如果要读取整个文档的内容,可以使用document.documentElement.textContent

Node.baseURI

Node.baseURI属性返回一个字符串,表示 当前网页的绝对路径。如果无法取到这个值,则返回null。浏览器根据这个属性,计算网页上的相对路径的URL。该属性为只读。

1
2
3
4
// 当前网页的网址为
// http://www.example.com/index.html
document.baseURI
// "http://www.example.com/index.html"

不同节点都可以调用这个属性(比如document.baseURI和element.baseURI),通常它们的值是相同的。

该属性的值一般由当前网址的URL(即window.location属性)决定,但是可以使用HTML的<base>标签,改变该属性的值

1
2
<base href="http://www.example.com/page.html">
<base target="_blank" href="http://www.example.com/page.html">

设置了以后,baseURI属性就返回标签设置的值。

相关节点的属性

以下属性返回当前节点的相关节点。

Node.ownerDocument

Node.ownerDocument属性返回当前节点所在的 顶层文档对象 ,即document对象。

1
2
var d = p.ownerDocument;
d === document // true

document对象本身的ownerDocument属性,返回null。

Node.nextSibling

Node.nextSibling属性返回紧跟在 当前节点后面的第一个同级节点 。如果当前节点后面没有同级节点,则返回null。注意,该属性还包括 文本节点和评论节点 。因此如果当前节点后面有空格,该属性会返回一个文本节点,内容为空格。

1
2
3
4
5
6
7
8
var el = document.getElementById('div-01').firstChild;
var i = 1;
while (el) {
console.log(i + '. ' + el.nodeName);
el = el.nextSibling;
i++;
}

上面代码遍历div-01节点的所有子节点。

下面两个表达式指向同一个节点。

1
2
document.childNodes[0].childNodes[1]
document.firstChild.firstChild.nextSibling

Node.previousSibling

previousSibling属性返回当前节点前面的、距离最近的一个同级节点。如果当前节点前面没有同级节点,则返回null。

1
// <a><b1 id="b1"/><b2 id="b2"/></a>
1
2
document.getElementById("b1").previousSibling // null
document.getElementById("b2").previousSibling.id // "b1"

对于当前节点前面有空格,则previousSibling属性会返回一个内容为空格的文本节点。

Node.parentNode

parentNode属性返回当前节点的父节点。对于一个节点来说,它的父节点只可能是三种类型:

  1. element节点
  2. document节点
  3. documentfragment节点。

下面代码是如何从父节点移除指定节点。

1
2
3
if (node.parentNode) {
node.parentNode.removeChild(node);
}

对于document节点和documentfragment节点,它们的父节点都是null。另外,对于那些 生成后还没插入DOM树的节点,父节点也是null

Node.parentElement

parentElement属性返回当前节点的父Element节点。如果当前节点没有父节点,或者父节点类型不是Element节点,则返回null。

1
2
3
if (node.parentElement) {
node.parentElement.style.color = "red";
}

上面代码设置指定节点的父Element节点的CSS属性。

在IE浏览器中,只有Element节点才有该属性,其他浏览器则是所有类型的节点都有该属性。

Node.childNodes

childNodes属性返回一个NodeList集合,成员包括当前节点的所有子节点。注意,除了HTML元素节点,该属性返回的还包括Text节点和Comment节点。如果当前节点不包括任何子节点,则返回一个空的NodeList集合。由于NodeList对象是一个动态集合,一旦子节点发生变化,立刻会反映在返回结果之中。

1
var ulElementChildNodes = document.querySelector('ul').childNodes;

Node.firstChild,Node.lastChild

firstChild属性返回当前节点的第一个子节点,如果当前节点没有子节点,则返回null(注意,不是undefined)。

1
2
3
4
5
6
7
<p id="para-01"><span>First span</span></p>
<script type="text/javascript">
console.log(
document.getElementById('para-01').firstChild.nodeName
) // "span"
</script>

上面代码中,p元素的第一个子节点是span元素。

注意,firstChild返回的除了HTML元素子节点,还可能是文本节点或评论节点。

1
2
3
4
5
6
7
8
9
<p id="para-01">
<span>First span</span>
</p>
<script type="text/javascript">
console.log(
document.getElementById('para-01').firstChild.nodeName
) // "#text"
</script>

上面代码中,p元素与span元素之间有空白字符,这导致firstChild返回的是文本节点。

Node.lastChild属性返回当前节点的最后一个子节点,如果当前节点没有子节点,则返回null。

节点对象的方法

Node.appendChild()

Node.appendChild方法接受一个节点对象作为参数,将其作为 最后一个子节点 ,插入当前节点。

1
2
var p = document.createElement('p');
document.body.appendChild(p);

如果参数节点是DOM中已经存在的节点,appendChild方法会将其 从原来的位置,移动到新位置

Node.hasChildNodes()

Node.hasChildNodes方法返回一个布尔值,表示当前节点是否有子节点。

1
2
3
4
5
var foo = document.getElementById("foo");
if (foo.hasChildNodes()) {
foo.removeChild(foo.childNodes[0]);
}

上面代码表示,如果foo节点有子节点,就移除第一个子节点。

hasChildNodes方法结合firstChild属性和nextSibling属性,可以遍历当前节点的所有后代节点。

1
2
3
4
5
6
7
8
function DOMComb(parent, callback) {
if (parent.hasChildNodes()) {
for (var node = parent.firstChild; node; node = node.nextSibling) {
DOMComb(node, callback);
}
}
callback.call(parent);
}

上面代码的DOMComb函数的第一个参数是某个指定的节点,第二个参数是回调函数。这个回调函数会依次作用于指定节点,以及指定节点的所有后代节点。

1
2
3
4
5
6
7
function printContent() {
if (this.nodeValue) {
console.log(this.nodeValue);
}
}
DOMComb(document.body, printContent);

Node.insertBefore()

Node.insertBefore方法用于 将某个节点插入当前节点的指定位置 。它接受两个参数,第一个参数是所要插入的节点,第二个参数是当前节点的一个子节点,新的节点将插在这个节点的前面。该方法返回被插入的新节点。

1
2
3
4
5
6
var text1 = document.createTextNode('1');
var li = document.createElement('li');
li.appendChild(text1);
var ul = document.querySelector('ul');
ul.insertBefore(li, ul.firstChild);

上面代码使用当前节点的firstChild属性,在

    节点的最前面插入一个新建的
  • 节点,新节点变成第一个子节点。

parentElement.insertBefore(newElement, parentElement.firstChild);
上面代码中,如果当前节点没有任何子节点,parentElement.firstChild会返回null,则新节点会成为当前节点的唯一子节点。

如果insertBefore方法的第二个参数为null,则新节点将插在当前节点的最后位置,即变成最后一个子节点。

注意,如果所要插入的节点是当前DOM现有的节点,则该节点将从原有的位置移除,插入新的位置。

由于不存在insertAfter方法,如果要插在当前节点的某个子节点后面,可以用insertBefore方法结合nextSibling属性模拟。

1
parentDiv.insertBefore(s1, s2.nextSibling);

上面代码可以将s1节点,插在s2节点的后面。如果s2是当前节点的最后一个子节点,则s2.nextSibling返回null,这时s1节点会插在当前节点的最后,变成当前节点的最后一个子节点,等于紧跟在s2的后面。

Node.removeChild()

Node.removeChild方法接受一个子节点作为参数,用于从当前节点移除该子节点。它返回被移除的子节点

1
2
var divA = document.getElementById('A');
divA.parentNode.removeChild(divA);

上面代码是如何移除一个指定节点。

注意,这个方法是 在父节点上调用的 ,不是在被移除的节点上调用的。

下面是如何移除当前节点的所有子节点。

1
2
3
4
var element = document.getElementById('top');
while (element.firstChild) {
element.removeChild(element.firstChild);
}

被移除的节点依然存在于内存之中,但不再是DOM的一部分。所以,一个节点移除以后,依然可以使用它,比如插入到另一个节点下面。

Node.replaceChild()

Node.replaceChild方法用于将一个新的节点,替换当前节点的某一个子节点。它接受两个参数,第一个参数是用来替换的新节点,第二个参数将要被替换走的子节点。它返回被替换走的那个节点。

1
replacedNode = parentNode.replaceChild(newChild, oldChild);

下面是一个例子。

1
2
3
4
var divA = document.getElementById('A');
var newSpan = document.createElement('span');
newSpan.textContent = 'Hello World!';
divA.parentNode.replaceChild(newSpan, divA);

上面代码是如何替换指定节点。

Node.normalize()

normailize方法用于 清理当前节点内部的所有Text节点 。它会 去除空的文本节点,并且将毗邻的文本节点合并成一个

1
2
3
4
5
6
7
8
9
10
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节点有两个Text子节点。使用normalize方法之后,两个Text子节点被合并成一个。

该方法是Text.splitText的逆方法,

NodeList对象,HTMLCollection对象

ParentNode接口,ChildNode接口

不同的节点除了继承Node接口以外,还会继承其他接口。ParentNode接口用于 获取当前节点的Element子节点 ,ChildNode接口用于 处理当前节点的子节点 (包含但不限于Element子节点)。

ParentNode接口

ParentNode接口用于获取Element子节点。Element节点、Document节点和DocumentFragment节点,部署了ParentNode接口。凡是这三类节点,都具有以下四个属性,用于获取Element子节点。

(1)children

children属性返回一个动态的HTMLCollection集合,由当前节点的所有Element子节点组成。

下面代码遍历指定节点的所有Element子节点。

1
2
3
4
5
if (el.children.length) {
for (var i = 0; i < el.children.length; i++) {
// ...
}
}

(2)firstElementChild

firstElementChild属性返回当前节点的第一个Element子节点,如果不存在任何Element子节点,则返回null。

1
2
document.firstElementChild.nodeName
// "HTML"

上面代码中,document节点的第一个Element子节点是。

(3)lastElementChild

lastElementChild属性返回当前节点的最后一个Element子节点,如果不存在任何Element子节点,则返回null。

1
2
document.lastElementChild.nodeName
// "HTML"

上面代码中,document节点的最后一个Element子节点是。

(4)childElementCount

childElementCount属性返回当前节点的所有Element子节点的数目。

ChildNode 接口

ChildNode接口用于处理子节点(包含但不限于Element子节点)。Element节点、DocumentType节点和CharacterData接口,部署了ChildNode接口。凡是这三类节点(接口),都可以使用下面四个方法。

(1)remove()

remove方法用于移除当前节点。

1
el.remove()

上面方法在DOM中移除了el节点。注意,调用这个方法的节点,是被移除的节点本身,而不是它的父节点。

(2)before()

before方法用于在当前节点的前面,插入一个同级节点。如果参数是节点对象,插入DOM的就是该节点对象;如果参数是文本,插入DOM的就是参数对应的文本节点。

(3)after()

after方法用于在当前节点的后面,插入一个同级节点。如果参数是节点对象,插入DOM的就是该节点对象;如果参数是文本,插入DOM的就是参数对应的文本节点。

(4)replaceWith()

replaceWith方法使用参数指定的节点,替换当前节点。如果参数是节点对象,替换当前节点的就是该节点对象;如果参数是文本,替换当前节点的就是参数对应的文本节点。

Element对象

Element对象对应网页的HTML标签元素。每一个HTML标签元素,在DOM树上都会转化成一个Element节点对象(以下简称元素节点)。

元素节点的nodeType属性都是1,但是不同HTML标签生成的元素节点是不一样的。JavaScript内部使用不同的构造函数,生成不同的Element节点,比如标签的节点对象由HTMLAnchorElement()构造函数生成,

特征相关的属性

以下属性与元素特点 本身的特征 相关。

Element.attributes

Element.attributes属性返回一个类似数组的对象,成员是当前元素节点的所有属性节点,详见本章《属性的操作》一节。

Element.id,Element.tagName

Element.id属性返回指定元素的id属性,该属性可读写。

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"

Element.innerHTML

Element.innerHTML属性返回 该元素包含的 HTML 代码。该属性可读写,常用来设置某个节点的内容。

如果将innerHTML属性设为空,等于删除所有它包含的所有节点

1
el.innerHTML = '';

上面代码等于将el节点变成了一个空节点,el原来包含的节点被全部删除。

注意,如果文本节点中包含&、小于号(<)和大于号(>),innerHTML属性会将它们转为实体形式&、<、>。

1
2
3
// HTML代码如下 <p id="para"> 5 > 3 </p>
document.getElementById('para').innerHTML
// 5 &gt; 3

如果插入的文本包含 HTML 标签,会被解析成为节点对象插入 DOM。注意,如果文本之中含有“;
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
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// HTML代码如下
// <div id="d"><p>Hello</p></div>
d = document.getElementById('d');
d.outerHTML
// '<div id="d"><p>Hello</p></div>'
outerHTML属性是可读写的,对它进行赋值,等于替换掉当前元素。
// HTML代码如下
// <div id="container"><div id="d">Hello</div></div>
container = document.getElementById('container');
d = document.getElementById("d");
container.firstChild.nodeName // "DIV"
d.nodeName // "DIV"
d.outerHTML = '<p>Hello</p>';
container.firstChild.nodeName // "P"
d.nodeName // "DIV"

上面代码中,outerHTML属性重新赋值以后,内层的div元素就不存在了,被p元素替换了。但是,变量d依然指向原来的div元素,这表示被替换的DIV元素还存在于内存中。

Element.className,Element.classList

className属性用来读写当前元素节点的class属性。它的值是一个字符串,每个class之间用空格分割。

classList属性则返回一个类似数组的对象,当前元素节点的每个class就是这个对象的一个成员。


上面这个div元素的节点对象的className属性和classList属性,分别如下。

1
2
3
4
5
6
7
8
9
10
document.getElementById('myDiv').className
// "one two three"
document.getElementById('myDiv').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的列表转为字符串。
myDiv.classList.add(‘myCssClass’);
myDiv.classList.add(‘foo’, ‘bar’);
myDiv.classList.remove(‘myCssClass’);
myDiv.classList.toggle(‘myCssClass’); // 如果myCssClass不存在就加入,否则移除
myDiv.classList.contains(‘myCssClass’); // 返回 true 或者 false
myDiv.classList.item(0); // 返回第一个Class
myDiv.classList.toString();
下面比较一下,className和classList在添加和删除某个类时的写法。

1
2
3
4
5
6
7
8
// 添加class
document.getElementById('foo').className += 'bold';
document.getElementById('foo').classList.add('bold');
// 删除class
document.getElementById('foo').classList.remove('bold');
document.getElementById('foo').className =
document.getElementById('foo').className.replace(/^bold$/, '');

toggle方法可以接受一个布尔值,作为第二个参数。如果为true,则添加该属性;如果为false,则去除该属性。

1
2
3
4
5
6
7
8
9
el.classList.toggle('abc', boolValue);
// 等同于
if (boolValue){
el.classList.add('abc');
} else {
el.classList.remove('abc');
}

盒状模型相关属性

Element.clientHeight,Element.clientWidth

Element.clientHeight属性返回元素节点可见部分的高度,Element.clientWidth属性返回元素节点可见部分的宽度。所谓“可见部分”,指的是不包括溢出(overflow)的大小,只返回该元素在容器中占据的大小,对于有滚动条的元素来说,它们等于滚动条围起来的区域大小。这两个属性的值包括Padding、但不包括滚动条、边框和Margin,单位为像素。这两个属性可以计算得到,等于元素的CSS高度(或宽度)加上CSS的Padding,减去滚动条(如果存在)。

对于整张网页来说,当前可见高度(即视口高度)要从document.documentElement对象(即节点)上获取,等同于window.innerHeight属性减去水平滚动条的高度。没有滚动条时,这两个值是相等的;有滚动条时,前者小于后者。

var rootElement = document.documentElement;

// 没有水平滚动条时
rootElement.clientHeight === window.innerHeight // true

// 没有垂直滚动条时
rootElement.clientWidth === window.innerWidth // true
注意,这里不能用document.body.clientHeight或document.body.clientWidth,因为document.body返回节点,与视口大小是无关的。

Element.clientLeft,Element.clientTop

Element.clientLeft属性等于元素节点左边框(left border)的宽度,Element.clientTop属性等于网页元素顶部边框的宽度,单位为像素。

这两个属性包括滚动条的宽度,但不包括Margin和Padding。不过,一般来说,除非排版方向是从右到左,且发生元素高度溢出,否则不可能存在左侧滚动条,亦不可能存在顶部的滚动条。

如果元素的显示设为display: inline,它的clientLeft属性一律为0,不管是否存在左边框。

Element.scrollHeight,Element.scrollWidth
Element.scrollHeight属性返回某个网页元素的总高度,Element.scrollWidth属性返回总宽度,可以理解成元素在垂直和水平两个方向上可以滚动的距离。它们都包括由于溢出容器而无法显示在网页上的那部分高度或宽度。这两个属性是只读属性。

它们返回的是整个元素的高度或宽度,包括由于存在滚动条而不可见的部分。默认情况下,它们包括Padding,但不包括Border和Margin。

整张网页的总高度可以从document.documentElement或document.body上读取。

document.documentElement.scrollHeight

如果内容正好适合它的容器,没有溢出,那么Element.scrollHeight和Element.clientHeight是相等的,scrollWidth属性与clientWidth属性是相等的。如果存在溢出,那么scrollHeight属性大于clientHeight属性,scrollWidth属性大于clientWidth属性。

存在溢出时,当滚动条滚动到内容底部时,下面的表达式为true。

element.scrollHeight - element.scrollTop === element.clientHeight
如果滚动条没有滚动到内容底部,上面的表达式为false。这个特性结合onscroll事件,可以判断用户是否滚动到了指定元素的底部,比如向用户展示某个内容区块时,判断用户是否滚动到了区块的底部。

1
2
3
4
5
6
7
8
9
10
var rules = document.getElementById('rules');
rules.onscroll = checking;
function checking(){
if (this.scrollHeight - this.scrollTop === this.clientHeight) {
console.log('谢谢阅读');
} else {
console.log('您还未读完');
}
}

Element.scrollLeft,Element.scrollTop

Element.scrollLeft属性表示网页元素的水平滚动条向右侧滚动的像素数量,Element.scrollTop属性表示网页元素的垂直滚动条向下滚动的像素数量。对于那些没有滚动条的网页元素,这两个属性总是等于0。

如果要查看整张网页的水平的和垂直的滚动距离,要从document.documentElement元素上读取。

document.documentElement.scrollLeft
document.documentElement.scrollTop
这两个属性都可读写,设置该属性的值,会导致浏览器将指定元素自动滚动到相应的位置。

Element.offsetHeight,Element.offsetWidth
Element.offsetHeight属性返回元素的垂直高度,Element.offsetWidth属性返回水平宽度。offsetHeight可以理解成元素左下角距离左上角的位移,offsetWidth是元素右上角距离左上角的位移。它们的单位为像素,都是只读。

这两个属性值包括Padding和Border、以及滚动条。这也意味着,如果不存在内容溢出,Element.offsetHeight只比Element.clientHeight多了边框的高度。

整张网页的高度,可以在document.documentElement和document.body上读取。

1
2
3
4
5
6
7
8
// 网页总高度
document.documentElement.offsetHeight
document.body.offsetHeight
// 网页总宽度
document.documentElement.offsetWidth
document.body.offsetWidth
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操作》一节。

总结

整张网页的高度和宽度,可以从document.documentElement(即元素)或元素上读取。

// 网页总高度
document.documentElement.offsetHeight
document.documentElement.scrollHeight
document.body.offsetHeight
document.body.scrollHeight

// 网页总宽度
document.documentElement.offsetWidth
document.documentElement.scrollWidth
document.body.offsetWidth
document.body.scrollWidth
由于和的宽度可能设得不一样,因此从上取值会更保险一点。

视口的高度和宽度(包括滚动条),有两种方法可以获得。

// 视口高度
window.innerHeight // 包括滚动条
document.documentElement.clientHeight // 不包括滚动条

// 视口宽度
window.innerWidth // 包括滚动条
document.documentElement.clientWidth // 不包括滚动条
某个网页元素距离视口左上角的坐标,使用Element.getBoundingClientRect方法读取。

// 网页元素左上角的视口横坐标
Element.getBoundingClientRect().left

// 网页元素左上角的视口纵坐标
Element.getBoundingClientRect().top
某个网页元素距离网页左上角的坐标,使用视口坐标加上网页滚动距离。

// 网页元素左上角的网页横坐标
Element.getBoundingClientRect().left + document.documentElement.scrollLeft

// 网页元素左上角的网页纵坐标
Element.getBoundingClientRect().top + document.documentElement.scrollTop
网页目前滚动的距离,可以从document.documentElement节点上得到。

// 网页滚动的水平距离
document.documentElement.scrollLeft

// 网页滚动的垂直距离
document.documentElement.scrollTop
网页元素本身的高度和宽度(不含overflow溢出的部分),通过offsetHeight和offsetWidth属性(包括Padding和Border)或Element.getBoundingClientRect方法获取。

// 网页元素的高度
Element.offsetHeight

// 网页元素的宽度
Element.offsetWidth

相关节点的属性

以下属性返回元素节点的相关节点。

Element.children,Element.childElementCount

Element.children属性返回一个HTMLCollection对象,包括当前元素节点的所有子元素。它是一个类似数组的动态对象(实时反映网页元素的变化)。如果当前元素没有子元素,则返回的对象包含零个成员。

1
2
3
4
5
6
7
// para是一个p元素节点
if (para.children.length) {
var children = para.children;
for (var i = 0; i < children.length; i++) {
// ...
}
}

这个属性与Node.childNodes属性的区别是,它 只包括HTML元素类型的子节点 ,不包括其他类型的子节点。

Element.childElementCount属性返回当前元素节点包含的子HTML元素节点的个数,与Element.children.length的值相同。注意,该属性只计算HTML元素类型的子节点。

Element.firstElementChild,Element.lastElementChild
Element.firstElementChild属性返回第一个HTML元素类型的子节点,Element.lastElementChild返回最后一个HTML元素类型的子节点。

如果没有HTML类型的子节点,这两个属性返回null。

Element.nextElementSibling,Element.previousElementSibling

Element.nextElementSibling属性返回当前HTML元素节点的后一个同级HTML元素节点,如果没有则返回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属性返回当前HTML元素节点的前一个同级HTML元素节点,如果没有则返回null。

Element.offsetParent
Element.offsetParent属性返回当前HTML元素的最靠近的、并且CSS的position属性不等于static的父元素。如果某个元素的所有上层节点都将position属性设为static,则Element.offsetParent属性指向元素。

该属性主要用于确定子元素的位置偏移,是Element.offsetTop和Element.offsetLeft的计算基准。

属性相关的方法

元素节点提供以下四个方法,用来操作HTML标签的属性。

  • Element.getAttribute():读取指定属性
  • Element.setAttribute():设置指定属性
  • Element.hasAttribute():返回一个布尔值,表示当前元素节点是否有指定的属性
  • Element.removeAttribute():移除指定属性

这些属性的详细介绍,参见本章的《属性的操作》一节。

查找相关的方法

以下四个方法用来查找与当前元素节点相关的节点。这四个方法也部署在document对象上,用法完全一致。

  • Element.querySelector()
  • Element.querySelectorAll()
  • Element.getElementsByTagName()
  • Element.getElementsByClassName()

上面四个方法只返回Element子节点,因此可以采用链式写法。

1
2
3
document
.getElementById('header')
.getElementsByClassName('a')

Element.querySelector()

Element.querySelector方法接受 CSS选择器作为参数 ,返回父元素的第一个匹配的子元素。

1
2
var content = document.getElementById('content');
var el = content.querySelector('p');

上面代码返回content节点的第一个p元素。

需要注意的是,浏览器执行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.getElementsByClassName()

Element.getElementsByClassName方法返回一个HTMLCollection对象,成员是当前元素节点的所有匹配指定class的子元素。该方法与document.getElementsByClassName方法的用法类似,只是搜索范围不是整个文档,而是当前元素节点。

element.getElementsByClassName(‘red test’);
注意,该方法的参数大小写敏感

Element.getElementsByTagName()

Element.getElementsByTagName方法返回一个HTMLCollection对象,成员是当前元素节点的所有匹配指定标签名的子元素。该方法与document.getElementsByClassName方法的用法类似,只是搜索范围不是整个文档,而是 当前元素节点

1
2
var table = document.getElementById('forecast-table');
var cells = table.getElementsByTagName('td');

注意,该方法的参数是大小写不敏感的。

Element.match()

Element.match方法返回一个布尔值,表示当前元素是否匹配给定的CSS选择器。

1
2
3
if (el.matches('.someClass')) {
console.log('Match!');
}

该方法带有浏览器前缀,下面的函数可以兼容不同的浏览器,并且在浏览器不支持时,自行部署这个功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function matchesSelector(el, selector) {
var p = Element.prototype;
var f = p.matches
|| p.webkitMatchesSelector
|| p.mozMatchesSelector
|| p.msMatchesSelector
|| function(s) {
return [].indexOf.call(document.querySelectorAll(s), this) !== -1;
};
return f.call(el, selector);
}
// 用法
matchesSelector(
document.getElementById('myDiv'),
'div.someSelector[some-attribute=true]'
)

事件相关的方法

以下三个方法与Element节点的事件相关。这些方法都继承自EventTarget接口,详见本章的《Event对象》一节。

  • 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盒状模型提供的所有信息。

var rect = obj.getBoundingClientRect();
上面代码中,getBoundingClientRect方法返回的rect对象,具有以下属性(全部为只读)。

x:元素左上角相对于视口的横坐标
left:元素左上角相对于视口的横坐标,与x属性相等
right:元素右边界相对于视口的横坐标(等于x加上width)
width:元素宽度(等于right减去left)
y:元素顶部相对于视口的纵坐标
top:元素顶部相对于视口的纵坐标,与y属性相等
bottom:元素底部相对于视口的纵坐标
height:元素高度(等于y加上height)
由于元素相对于视口(viewport)的位置,会随着页面滚动变化,因此表示位置的四个属性值,都不是固定不变的。如果想得到绝对位置,可以将left属性加上window.scrollX,top属性加上window.scrollY。

注意,getBoundingClientRect方法的所有属性,都把边框(border属性)算作元素的一部分。也就是说,都是从边框外缘的各个点来计算。因此,width和height包括了元素本身 + padding + border。

Element.getClientRects()

Element.getClientRects方法返回一个类似数组的对象,里面是当前元素在页面上形成的所有矩形。每个矩形都有bottom、height、left、right、top和width六个属性,表示它们相对于视口的四个坐标,以及本身的高度和宽度。

对于盒状元素(比如

),该方法返回的对象中只有该元素一个成员。对于行内元素(比如span、a、em),该方法返回的对象有多少个成员,取决于该元素在页面上占据多少行。这是它和Element.getBoundingClientRect()方法的主要区别,对于行内元素,后者总是返回一个矩形区域,前者可能返回多个矩形区域,所以方法名中的Rect用的是复数。


Hello World
Hello World
Hello World

上面代码是一个行内元素,如果它在页面上占据三行,getClientRects方法返回的对象就有三个成员,如果它在页面上占据一行,getClientRects方法返回的对象就只有一个成员。

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
这个方法主要用于判断行内元素是否换行,以及行内元素的每一行的位置偏移。

Element.insertAdjacentHTML()

Element.insertAdjacentHTML方法解析HTML字符串,然后将生成的节点插入DOM树的指定位置。

element.insertAdjacentHTML(position, text);
该方法接受两个参数,第一个是指定位置,第二个是待解析的字符串。

指定位置共有四个。

beforebegin:在当前元素节点的前面。
afterbegin:在当前元素节点的里面,插在它的第一个子元素之前。
beforeend:在当前元素节点的里面,插在它的最后一个子元素之后。
afterend:在当前元素节点的后面。
// 原来的HTML代码:

one

var d1 = document.getElementById(‘one’);
d1.insertAdjacentHTML(‘afterend’, ‘
two
‘);
// 现在的HTML代码:
//
one
two

该方法不是彻底置换现有的DOM结构,这使得它的执行速度比innerHTML操作快得多。

Element.remove()

Element.remove方法用于将当前元素节点从DOM树删除。

var el = document.getElementById(‘div-01’);
el.remove();

Element.focus()

Element.focus方法用于将当前页面的焦点,转移到指定元素上。

document.getElementById(‘my-span’).focus();

属性的操作

HTML元素包括标签名和若干个键值对,这个键值对就称为“属性”(attribute)。

1
2
3
<a id="test" href="http://www.example.com">
链接
</a>

上面代码中,a元素包括两个属性:id属性和href属性

在DOM中,属性本身是一个对象(Attr对象),但是实际上,这个对象极少使用。一般都是通过 元素节点对象 (HTMlElement对象)来操作属性。本节介绍如何操作这些属性。

Element.attributes属性

HTML元素对象有一个attributes属性,返回一个 类似数组的动态对象 ,成员是该元素标签的 所有属性节点对象 ,属性的 实时变化 都会反映在这个节点对象上。其他类型的节点对象,虽然也有attributes属性,但是返回的都是null,因此可以把这个属性视为元素对象独有的。

1
2
3
document.body.attributes[0]
document.body.attributes.bgcolor
document.body.attributes['ONLOAD']

注意,上面代码中,第一行attributes[0]返回的是属性节点对象,后两行都返回属性值。

属性节点对象有name和value属性,对应该属性的属性名和属性值,等同于nodeName属性和nodeValue属性。

1
2
3
// HTML代码为
// <div id="mydiv">
var n = document.getElementById('mydiv');
1
2
3
4
5
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
var para = document.getElementsByTagName('p')[0];
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.value = output;
} else {
result.value = 'No attributes to show';
}

元素节点对象的属性

HTML元素节点的标准属性(即在标准中定义的属性),会自动成为元素节点对象的属性。

1
2
3
var a = document.getElementById('test');
a.id // "test"
a.href // "http://www.example.com/"

上面代码中,a元素标签的 属性id和href自动成为 节点对象的属性。

这些属性都是可写的。

1
2
var img = document.getElementById('myImage');
img.src = 'http://www.example.com/image.jpg';

上面的写法,会立刻替换掉img对象的src属性,即会显示另外一张图片。

这样修改HTML属性,常常用于添加表单的属性。

1
2
3
var f = document.forms[0];
f.action = 'submit.php';
f.method = 'POST';

上面代码为表单添加提交网址和提交方法。

注意,这种用法虽然可以读写HTML属性,但是无法删除属性,delete运算符在这里不会生效

HTML元素的属性名是大小写不敏感的但是JavaScript对象的属性名是大小写敏感的。转换规则是,转为JavaScript属性名时,一律采用小写。如果属性名包括多个单词,则采用骆驼拼写法,即从第二个单词开始,每个单词的首字母采用大写,比如onClick。

有些HTML属性名是JavaScript的保留字,转为JavaScript属性时,必须改名。主要是以下两个。

  1. for属性改为htmlFor
  2. class属性改为className

另外,HTML属性值一般都是字符串,但是JavaScript属性会自动转换类型。比如,将字符串true转为布尔值,将onClick的值转为一个函数,将style属性的值转为一个CSSStyleDeclaration对象。

属性操作的标准方法

概述

元素节点提供四个方法,用来操作属性。

  • getAttribute()
  • setAttribute()
  • hasAttribute()
  • removeAttribute()

其中,前两个读写属性的方法,与前一部分HTML标签对象的属性读写,有三点差异。

(1)适用性

getAttribute()和setAttribute()对所有属性(包括用户自定义的属性)都适用;HTML标签对象的属性,只适用于标准属性。

(2)返回值

getAttribute()只返回字符串,不会返回其他类型的值。HTML标签对象的属性会返回各种类型的值,包括字符串、数值、布尔值或对象。

(3)属性名

这些方法只接受属性的标准名称,不用改写保留字,比如for和class都可以直接使用。另外,这些方法对于属性名是大小写不敏感的。

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.setAttribute()

Element.setAttribute方法用于为当前元素节点新增属性。如果同名属性已存在,则相当于编辑已存在的属性。

1
2
var d = document.getElementById('d1');
d.setAttribute('align', 'center');

下面是对img元素的src属性赋值的例子。

1
2
3
var myImage = document.querySelector('img');
myImage.setAttribute('src', 'path/to/example.png');
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.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元素的属性不符合标准,导致网页的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-后面的属性名有限制,只能包含字母、数字、连词线(-)、点(.)、冒号(:)和下划线(_)。而且,属性名 不应该使用A到Z的大写字母,比如不能有data-helloWorld这样的属性名,而要写成data-hello-world。

转成dataset的键名时,连词线后面如果跟着一个小写字母,那么连词线会被移除,该小写字母转为大写字母,其他字符不变。反过来,dataset的键名转成属性名时,所有大写字母都会被转成连词线+该字母的小写形式,其他字符不变。比如,dataset.helloWorld会转成data-hello-world。

Text节点和DocumentFragment节点

事件模型

事件是 一种异步编程的实现方式本质上是程序各个组成部分之间的通信。DOM支持大量的事件,本节介绍DOM的事件编程。

EventTarget接口

DOM的事件操作(监听和触发),都定义在 EventTarget 接口。Element节点、document节点和window对象,都部署了这个接口。此外,XMLHttpRequest、AudioNode、AudioContext等浏览器内置对象,也部署了这个接口。

该接口就是三个方法。

  • addEventListener:绑定事件的监听函数
  • removeEventListener:移除事件的监听函数
  • dispatchEvent:触发事件

addEventListener()

addEventListener方法用于在当前节点或对象上,定义一个特定事件的监听函数。

1
2
3
4
5
6
// 使用格式
target.addEventListener(type, listener[, useCapture]);
// 实例
window.addEventListener('load', function () {...}, false);
request.addEventListener('readystatechange', function () {...}, false);

addEventListener方法接受三个参数。

  1. type:事件名称,大小写敏感。
  2. listener:监听函数。事件发生时,会调用该监听函数。
  3. 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);

上面代码中,addEventListener方法为button元素节点,绑定click事件的监听函数hello,该函数只在冒泡阶段触发。

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传递了一个参数。

removeEventListener()

removeEventListener方法用来 移除addEventListener方法添加的事件监听函数

1
2
div.addEventListener('click', listener, false);
div.removeEventListener('click', listener, false);

removeEventListener方法的参数,与addEventListener方法完全一致。它的第一个参数“事件类型”,大小写敏感。

注意,removeEventListener方法移除的监听函数,必须与对应的addEventListener方法的参数完全一致,而且 必须在同一个元素节点 ,否则无效。

dispatchEvent()

dispatchEvent方法在当前节点上 触发指定事件 ,从而触发监听函数的执行。该方法返回一个布尔值,只要有一个监听函数调用了Event.preventDefault(),则返回值为false,否则为true

1
target.dispatchEvent(event)

dispatchEvent方法的参数是一个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
7
var canceled = !cb.dispatchEvent(event);
if (canceled) {
console.log('事件取消');
} else {
console.log('事件未取消');
}
}

监听函数

监听函数(listener)是事件发生时,程序所要执行的函数。它是事件驱动编程模式的主要编程方式。

DOM提供三种方法,可以用来为事件绑定监听函数。

HTML标签的on-属性

HTML语言允许在元素标签的属性中,直接定义某些事件的监听代码。

1
2
<body onload="doSomething()">
<div onclick="console.log('触发事件')">

上面代码为body节点的load事件、div节点的click事件,指定了监听函数。

使用这个方法指定的监听函数,只会在冒泡阶段触发。

注意,使用这种方法时,on-属性的值是将会执行的代码,而不是一个函数。

1
2
3
4
5
<!-- 正确 -->
<body onload="doSomething()">
<!-- 错误 -->
<body onload="doSomething">

一旦指定的事件发生,on-属性的值是原样传入JavaScript引擎执行。因此如果要执行函数,不要忘记加上一对圆括号。

另外,Element元素节点的setAttribute方法,其实设置的也是这种效果。

1
el.setAttribute('onclick', 'doSomething()');

Element节点的事件属性

Element节点对象有事件属性,同样可以指定监听函数。

1
2
3
4
5
window.onload = doSomething;
div.onclick = function(event){
console.log('触发事件');
};

使用这个方法指定的监听函数,只会在冒泡阶段触发。

最终选择

addEventListener是推荐的指定监听函数的方法。它有如下优点:

  • 可以针对同一个事件,添加多个监听函数。
  • 能够指定在哪个阶段(捕获阶段还是冒泡阶段)触发回监听函数。
  • 除了DOM节点,还可以部署在window、XMLHttpRequest等对象上面,等于统一了整个JavaScript的监听函数接口。

this对象

总结一下,以下写法的this对象都指向Element节点。

1
2
3
element.onclick = print
element.addEventListener('click', print, false)
element.onclick = function () {console.log(this.id);}
1
<element onclick="console.log(this.id)">

以下写法的this对象,都指向全局对象。

1
2
3
// JavaScript代码
element.onclick = function (){ doSomething() };
element.setAttribute('onclick', 'doSomething()');
1
<element onclick="doSomething()">

事件的传播

Event对象

event.preventDefault()
preventDefault方法取消浏览器对当前事件的默认行为,比如点击链接后,浏览器跳转到指定页面,或者按一下空格键,页面向下滚动一段距离。该方法生效的前提是,事件的cancelable属性为true,如果为false,则调用该方法没有任何效果。

该方法不会阻止事件的进一步传播(stopPropagation方法可用于这个目的)。只要在事件的传播过程中(捕获阶段、目标阶段、冒泡阶段皆可),使用了preventDefault方法,该事件的默认方法就不会执行。

// HTML代码为
//

var cb = document.getElementById(‘my-checkbox’);

cb.addEventListener(
‘click’,
function (e){ e.preventDefault(); },
false
);
上面代码为点击单选框的事件,设置监听函数,取消默认行为。由于浏览器的默认行为是选中单选框,所以这段代码会导致无法选中单选框。

利用这个方法,可以为文本输入框设置校验条件。如果用户的输入不符合条件,就无法将字符输入文本框。

function checkName(e) {
if (e.charCode < 97 || e.charCode > 122) {
e.preventDefault();
}
}
上面函数设为文本框的keypress监听函数后,将只能输入小写字母,否则输入事件的默认事件(写入文本框)将被取消。

如果监听函数最后返回布尔值false(即return false),浏览器也不会触发默认行为,与preventDefault方法有等同效果。

在关闭网页前提醒

1
2
3
4
5
6
window.addEventListener('beforeunload', function (e) {
var confirmationMessage = '确认关闭窗口?';
e.returnValue = confirmationMessage;
return confirmationMessage;
});

CSS操作

CSS与JavaScript是两个有着明确分工的领域,前者负责页面的视觉效果,后者负责与用户的行为互动。但是,它们毕竟同属网页开发的前端,因此不可避免有着交叉和互相配合。本节介绍如果通过JavaScript操作CSS。

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对象

每一个网页元素对应一个DOM节点对象。这个对象的style属性可以直接操作,用来读写行内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属性的值是一个对象(简称style对象)。这个对象所包含的属性与CSS规则一一对应,但是名字需要改写,比如 background-color写成backgroundColor。改写的规则是 将横杠从CSS属性名中去除,然后将横杠后的第一个字母大写如果CSS属性名是JavaScript保留字,则规则名之前需要加上字符串css,比如float写成cssFloat

注意,style对象的属性值都是字符串,设置时必须包括单位,但是不含规则结尾的分号。比如,divStyle.width不能写为100,而要写为100px。

下面是一个例子,通过监听事件,改写网页元素的CSS样式。

1
2
3
4
5
6
7
8
var docStyle = document.documentElement.style;
var someElement = document.querySelector(...);
document.addEventListener('mousemove', function (e) {
someElement.style.transform =
'translateX(' + e.clientX + 'px)' +
'translateY(' + e.clientY + 'px)';
});

cssText属性

元素节点对象的style对象,有一个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属性名

setProperty(),getPropertyValue(),removeProperty()

Style对象的以下三个方法,用来读写行内CSS规则。

  • setProperty(propertyName,value):设置某个CSS属性。
  • getPropertyValue(propertyName):读取某个CSS属性。
  • removeProperty(propertyName):删除某个CSS属性。

这三个方法的第一个参数,都是CSS属性名,且 不用改写连词线

1
2
3
4
5
var divStyle = document.querySelector('div').style;
divStyle.setProperty('background-color','red');
divStyle.getPropertyValue('background-color');
divStyle.removeProperty('background-color');

CSS伪元素

CSS伪元素是 通过CSS向DOM添加的元素,主要方法是通过:before和:after选择器生成伪元素,然后 用content属性指定伪元素的内容

以如下HTML代码为例。

1
<div id="test">Test content</div>

CSS添加伪元素的写法如下。

1
2
3
4
#test:before {
content: 'Before ';
color: #FF0;
}

DOM节点的 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;

此外,也可以使用window.getComputedStyle对象的getPropertyValue方法,获取伪元素的属性。

1
2
3
4
var result = window.getComputedStyle(test, ':before')
.getPropertyValue('content');
var color = window.getComputedStyle(test, ':before')
.getPropertyValue('color');

浏览器环境

浏览器环境概述

JavaScript代码嵌入网页的方法

  • 由于上面的代码,浏览器不会执行,也不会显示它的内容,因为不认识它的type属性。但是,这个如果脚本文件使用了`非英语字符,还应该注明编码`。所加载的脚本必须是`纯的JavaScript代码` ,不能有HTML代码和

    为了防止攻击者篡改外部脚本,script标签允许设置一个integrity属性,写入该外部脚本的Hash签名,用来验证脚本的一致性。

    上面代码中,script标签有一个integrity属性,指定了外部脚本/assets/application.js的SHA265签名。一旦有人改了这个脚本,导致SHA265签名不匹配,浏览器就会拒绝加载。 #### 事件属性 某些HTML元素的事件属性(比如onclick和onmouseover),可以写入JavaScript代码。当指定事件发生时,就会调用这些代码。
    上面的事件属性代码只有一个语句。如果有多个语句,用分号分隔即可。 #### URL协议 URL支持javascript:协议,调用这个URL时,就会执行JavaScript代码。
    1
    <a href="javascript:alert('Hello')"></a>
    浏览器的地址栏也可以执行javascipt:协议。将javascript:alert('Hello')放入地址栏,按回车键,就会跳出提示框。 如果JavaScript代码返回一个字符串,浏览器就会新建一个文档,展示这个字符串的内容,原有文档的内容都会消失。
    1
    2
    3
    <a href="javascript:new Date().toLocaleTimeString();">
    What time is it?
    </a>
    上面代码中,用户点击链接以后,会打开一个新文档,里面有当前时间。 如果返回的不是字符串,那么浏览器不会新建文档,也不会跳转。
    1
    2
    3
    <a href="javascript:console.log(new Date().toLocaleTimeString())">
    What time is it?
    </a>
    上面代码中,用户点击链接后,网页不会跳转,只会在控制台显示当前时间。 javascript:协议的常见用途是书签脚本Bookmarklet。由于浏览器的书签保存的是一个网址,所以javascript:网址也可以保存在里面,用户选择这个书签的时候,就会在当前页面执行这个脚本。为了防止书签替换掉当前文档,可以在脚本最后返回void 0。 ### script标签 #### 工作原理 浏览器加载JavaScript脚本,主要通过

    上面的example.js默认就是采用HTTP协议下载,如果要采用HTTPS协议下载,必需写明(假定服务器支持)。

    但是有时我们会希望,根据页面本身的协议来决定加载协议,这时可以采用下面的写法。

    浏览器的组成

    浏览器的核心是两部分:

    • 渲染引擎
    • JavaScript解释器(又称JavaScript引擎)。

    渲染引擎

    渲染引擎的主要作用是,将网页代码渲染为用户视觉可以感知的平面文档。

    不同的浏览器有不同的渲染引擎。

    • Firefox:Gecko引擎
    • Safari:WebKit引擎
    • Chrome:Blink引擎(基于Webkit)
    • 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)效果、页面滚动、在输入框中输入文本、改变窗口大小等等。

    重流和重绘并不一定一起发生,重流(reflow)必然导致重绘(repaint),重绘不一定需要重流。比如改变元素颜色,只会导致重绘,而不会导致重流;改变元素的布局,则会导致重绘和重流。

    大多数情况下,浏览器会智能判断,将重流和重绘只限制到相关的子树上面,最小化所耗费的代价,而不会全局重新生成网页。

    作为开发者,应该 尽量设法降低重绘的次数和成本 。比如,尽量不要变动高层的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信息
    • 不要一项一项地改变样式,而是使用CSS class一次性改变样式
    • 使用document fragment操作DOM
    • 动画时使用absolute定位或fixed定位,这样可以减少对其他元素的影响
    • 只在必要时才显示元素
    • 使用window.requestAnimationFrame(),因为它可以把代码推迟到下一次重流时执行,而不是立即要求页面重流
    • 使用虚拟DOM(virtual 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)。通常,一个程序被经常用到的,只是其中一小部分代码,有了缓存的编译结果,整个程序的运行速度就会显著提升。不同的浏览器有不同的编译策略。有的浏览器只编译最经常用到的部分,比如循环的部分;有的浏览器索性省略了字节码的翻译步骤,直接编译成机器码,比如chrome浏览器的V8引擎

    字节码不能直接运行,而是运行在一个虚拟机(Virtual Machine)之上,一般也把虚拟机称为JavaScript引擎。因为JavaScript运行时未必有字节码,所以JavaScript虚拟机并不完全基于字节码,而是部分基于源码,即只要有可能,就通过JIT(just in time)编译器直接把源码编译成机器码运行,省略字节码步骤。这一点与其他采用虚拟机(比如Java)的语言不尽相同。这样做的目的,是为了 尽可能地优化代码、提高性能 。下面是目前最常见的一些JavaScript虚拟机:

    • Chakra(Microsoft Internet Explorer)
    • Nitro/JavaScript Core (Safari)
    • Carakan (Opera)
    • SpiderMonkey (Firefox)
    • V8 (Chrome, Chromium)

    window对象

    概述

    在浏览器中,window对象(注意,w为小写)指当前的浏览器窗口。它也是所有对象的顶层对象。

    “顶层对象”指的是最高一层的对象,所有其他对象都是它的下属。JavaScript规定,浏览器环境的所有全局变量,都是window对象的属性。

    1
    2
    var a = 1;
    window.a // 1

    上面代码中,变量a是一个全局变量,但是实质上它是window对象的属性。声明一个全局变量,就是为window对象的同名属性赋值。

    从语言设计的角度看,所有变量都是window对象的属性,其实不是很合理。因为window对象有自己的实体含义,不适合当作最高一层的顶层对象。这个设计失误与JavaScript语言匆忙的设计过程有关,最早的设想是语言内置的对象越少越好,这样可以提高浏览器的性能。因此,语言设计者Brendan Eich就把window对象当作顶层对象,所有未声明就赋值的变量都自动变成window对象的属性。这种设计使得编译阶段无法检测出未声明变量,但到了今天已经没有办法纠正了。

    alert(),prompt(),confirm()

    alert()、prompt()、confirm()都是浏览器与用户互动的全局方法。它们会弹出不同的对话框,要求用户做出回应。

    需要注意的是,alert()、prompt()、confirm()这三个方法弹出的对话框,都是浏览器统一规定的式样,是无法定制的

    alert方法弹出的对话框,只有一个“确定”按钮,往往用来通知用户某些信息。

    1
    2
    3
    4
    5
    // 格式
    alert(message);
    // 实例
    alert('Hello World');

    用户只有点击“确定”按钮,对话框才会消失。在对话框弹出期间,浏览器窗口处于冻结状态,如果不点“确定”按钮,用户什么也干不了。

    alert方法的参数只能是字符串,没法使用CSS样式,但是可以用\n指定换行

    1
    alert('本条提示\n分成两行');

    prompt方法弹出的对话框,在提示文字的下方,还有一个输入框,要求用户输入信息,并有“确定”和“取消”两个按钮。它往往用来 获取用户输入的数据

    1
    2
    3
    4
    5
    // 格式
    var result = prompt(text[, default]);
    // 实例
    var result = prompt('您的年龄?', 25)

    上面代码会跳出一个对话框,文字提示为“您的年龄?”,要求用户在对话框中输入自己的年龄(默认显示25)。

    prompt方法的返回值是一个字符串(有可能为空)或者null,具体分成三种情况。

    1. 用户输入信息,并点击“确定”,则用户输入的信息就是返回值。
    2. 用户没有输入信息,直接点击“确定”,则输入框的默认值就是返回值。
    3. 用户点击了“取消”(或者按了Esc按钮),则返回值是null。

    prompt方法的第二个参数是可选的,但是如果不提供的话,IE浏览器会在输入框中显示undefined。因此,最好总是提供第二个参数,作为输入框的默认值。

    confirm方法弹出的对话框,除了提示信息之外,只有“确定”和“取消”两个按钮,往往用来征询用户的意见。

    1
    2
    3
    4
    5
    // 格式
    var result = confirm(message);
    // 实例
    var result = confirm("你最近好吗?");

    上面代码弹出一个对话框,上面只有一行文字“你最近好吗?”,用户选择点击“确定”或“取消”。

    confirm方法返回一个布尔值,如果用户点击“确定”,则返回true;如果用户点击“取消”,则返回false。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var okay = confirm('Please confirm this message.');
    if (okay) {
    // 用户按下“确定”
    } else {
    // 用户按下“取消”
    }
    confirm的一个用途是,当用户离开当前页面时,弹出一个对话框,问用户是否真的要离开。
    window.onunload = function() {
    return confirm('你确定要离开当面页面吗?');
    }

    这三个方法都具有堵塞效应,一旦弹出对话框,整个页面就是暂停执行,等待用户做出反应。

    history对象

    概述

    浏览器窗口有一个history对象,用来保存浏览历史

    如果当前窗口先后访问了三个网址,那么history对象就包括三项,history.length属性等于3。

    history.length // 3

    history对象提供了一系列方法,允许在浏览历史之间移动。

    • back():移动到上一个访问页面,等同于浏览器的后退键。
    • forward():移动到下一个访问页面,等同于浏览器的前进键。
    • go():接受一个整数作为参数,移动到该整数指定的页面,比如go(1)相当于forward(),go(-1)相当于back()。
    1
    2
    3
    history.back();
    history.forward();
    history.go(-2);

    如果移动的位置超出了访问历史的边界,以上三个方法并不报错,而是默默的失败。

    history.go(0)相当于刷新当前页面。

    常见的“返回上一页”链接,代码如下。

    1
    2
    3
    ('backLink').onclick = function () {
    window.history.back();
    }

    注意,返回上一页时,页面通常是从 浏览器缓存 之中加载,而不是重新要求服务器发送新的网页。

    Web Storage:浏览器端数据储存机制

    概述

    这个API的作用是,使得网页可以在浏览器端储存数据。它分成两类:sessionStorage和localStorage。

    sessionStorage保存的数据用于浏览器的一次会话,当会话结束(通常是该窗口关闭),数据被清空;localStorage保存的数据长期存在,下一次访问该网站的时候,网页可以直接读取以前保存的数据。除了保存期限的长短不同,这两个对象的属性和方法完全一样。

    它们很像cookie机制的强化版,能够动用大得多的存储空间。目前,每个域名的存储上限视浏览器而定,Chrome是2.5MB,Firefox和Opera是5MB,IE是10MB。其中,Firefox的存储空间由一级域名决定,而其他浏览器没有这个限制。也就是说,在Firefox中,a.example.com和b.example.com共享5MB的存储空间。另外,与Cookie一样,它们也受同域限制。某个网页存入的数据,只有同域下的网页才能读取。

    通过检查window对象是否包含sessionStorage和localStorage属性,可以确定浏览器是否支持这两个对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function checkStorageSupport() {
    // sessionStorage
    if (window.sessionStorage) {
    return true;
    } else {
    return false;
    }
    // localStorage
    if (window.localStorage) {
    return true;
    } else {
    return false;
    }
    }

    操作方法

    存入/读取数据

    sessionStorage和localStorage保存的数据,都以“键值对”的形式存在。也就是说,每一项数据都有一个键名和对应的值。所有的数据都是以文本格式保存。

    存入数据使用setItem方法。它接受两个参数,第一个是键名,第二个是保存的数据。

    1
    2
    3
    sessionStorage.setItem("key","value");
    localStorage.setItem("key","value");

    读取数据使用getItem方法。它只有一个参数,就是键名。

    1
    2
    3
    var valueSession = sessionStorage.getItem("key");
    var valueLocal = localStorage.getItem("key");

    清除数据

    removeItem方法用于清除某个键名对应的数据。

    1
    2
    3
    sessionStorage.removeItem('key');
    localStorage.removeItem('key');

    clear方法用于清除所有保存的数据。

    1
    2
    sessionStorage.clear();
    localStorage.clear();

    遍历操作

    利用length属性和key方法,可以遍历所有的键。

    1
    2
    3
    for(var i = 0; i < localStorage.length; i++){
    console.log(localStorage.key(i));
    }

    其中的key方法,根据位置(从0开始)获得键值。

    localStorage.key(1);

    storage事件

    当储存的数据发生变化时,会触发storage事件。我们可以指定这个事件的回调函数。

    window.addEventListener(“storage”,onStorageChange);
    回调函数接受一个event对象作为参数。这个event对象的key属性,保存发生变化的键名。

    1
    2
    3
    function onStorageChange(e) {
    console.log(e.key);
    }

    除了key属性,event对象的属性还有三个:

    • oldValue:更新前的值。如果该键为新增加,则这个属性为null。
    • newValue:更新后的值。如果该键被删除,则这个属性为null。
    • url:原始触发storage事件的那个网页的网址。
    • 值得特别注意的是,该事件不在导致数据变化的当前页面触发。如果浏览器同时打开一个域名下面的多个页面,当其中的一个页面改变sessionStorage或localStorage的数据时,其他所有页面的storage事件会被触发,而原始页面并不触发storage事件。可以通过这种机制,实现多个窗口之间的通信。所有浏览器之中,只有IE浏览器除外,它会在所有页面触发storage事件。

    同源政策

    浏览器安全的基石是“同源政策”(same-origin policy)。很多开发者都知道这一点,但了解得不全面。

    接下来详细介绍“同源政策”的各个方面,以及如何规避它。

    概述

    含义

    最初,它的含义是指,A 网页设置的 Cookie,B 网页不能打开,除非这两个网页“同源”。所谓“同源”指的是”三个相同“。

    1. 协议相同
    2. 域名相同
    3. 端口相同

    举例来说,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,会发生什么?

    很显然,如果 Cookie 包含隐私(比如存款总额),这些信息就会泄漏。更可怕的是,Cookie 往往用来保存用户的登录状态,如果用户没有退出登录,其他网站就可以冒充用户,为所欲为。因为浏览器同时还规定,提交表单不受同源政策的限制。

    由此可见,“同源政策”是必需的,否则 Cookie 可以共享,互联网就毫无安全可言了。

    限制范围

    随着互联网的发展,“同源政策”越来越严格。目前,如果非同源,共有三种行为受到限制。

    (1) Cookie、LocalStorage 和 IndexedDB 无法读取。

    (2) DOM 无法获得。

    (3) AJAX 请求无效(可以发送,但浏览器会拒绝接受响应)。

    虽然这些限制是必要的,但是有时很不方便,合理的用途也受到影响。下面,我将详细介绍,如何规避上面三种限制。

    Cookie 是 服务器写入浏览器的一小段信息 ,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置document.domain共享 Cookie。

    举例来说,A网页是http://w1.example.com/a.html,B网页是http://w2.example.com/b.html,那么只要 设置相同的document.domain ,两个网页就可以共享Cookie。

    document.domain = ‘example.com’;
    现在,A 网页通过脚本设置一个 Cookie。

    document.cookie = “test1=hello”;
    B 网页就可以读到这个 Cookie。

    var allCookie = document.cookie;

    注意,这种方法只 适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexedDB 无法通过这种方法,规避同源政策,而要使用下文介绍的PostMessage API。

    另外,服务器也可以在设置 Cookie 的时候,指定 Cookie 的所属域名为一级域名,比如.example.com。

    Set-Cookie: key=value; domain=.example.com; path=/
    这样的话,二级域名和三级域名不用做任何设置,都可以读取这个Cookie。

    iframe

    iframe元素可以在当前网页之中,嵌入其他网页。每个iframe元素形成自己的窗口,即有自己的window对象。iframe窗口之中的脚本,可以获得父窗口和子窗口。但是,只有在同源的情况下,父窗口和子窗口才能通信;如果跨域,就无法拿到对方的DOM。

    比如,父窗口运行下面的命令,如果iframe窗口不是同源,就会报错。

    1
    2
    document.getElementById("myIFrame").contentWindow.document
    // Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.

    上面命令中,父窗口想获取子窗口的DOM,因为跨域导致报错。

    反之亦然,子窗口获取主窗口的DOM也会报错。

    window.parent.document.body
    // 报错

    这种情况不仅适用于iframe窗口,还适用于window.open方法打开的窗口,只要跨域,父窗口与子窗口之间就无法通信

    如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain属性,就可以规避同源政策,拿到DOM。

    对于完全不同源的网站,目前有两种方法,可以解决跨域窗口的通信问题。

    1. 片段识别符(fragment identifier)
    2. 跨文档通信API(Cross-document messaging)

    片段识别符

文章标题:JS标准参考教程小记

文章作者:RayJune

时间地点:下午3:56,于知名2-201自习教室

原始链接:http://rayjune.xyz/2017/08/05/javascript-normal-note/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。