《JavaScript 权威指南》学习笔记

概述

  • 表达式:只是计算出一个值,但并不做任何操作,如 (1 + 2) * 3
  • 语句:不包含值(或者说不关心所包含的值),用于改变程序的运行状态,如 a = b - c * 2

词法结构

字符集

JavaScript 是用 Unicode 字符集编写的,这个字符集支持地球上几乎所有在用的语言。

区分大小写

var a = 0;
var A = 1;
a === A;    // => false
1
2
3

由于 JavaScript 区分大小写,而 HTML 又不区分大小写,许多客户端 JavaScript 对象和属性与它们所表示的 HTML 标签和属性同名。所以在 HTML 中,这些标签和属性可以随便用大小写,但在 JavaScript 中必须用小写。

<!-- HTML,这里不管是写成 onClick 还是 onclick 都是 OK 的 -->
<a onClick="hello"></a>
1
2
// JavaScript
node.addEventListener('onclick', function() {
  // some code
});
1
2
3
4

空格、换行符和格式控制符

JavaScript 会忽略程序中标识 ( token ) 之间的空格,并且在大多数情况下会忽略换行符。所以可以在代码中用缩进来美化代码的格式,增强可读性。

Unicode 转义序列

在某些老旧的硬件或软件中,无法显示或输入 Unicode 中的部分字符。因此 JavaScript 定义了 Unicode 转义序列,用 6 个 ASCII 字符来代表任意的 16 位 Unicode 内码:\uxxxx,以 \u 为前缀,后跟 4 个十六进制数。

"café" === "caf\u00e9" // => true: \u00e9 的含义见下面“字符串”一节中的“字符集和内码”这一小节
1

标准化

虽然常规的 Unicode 字符和 Unicode 转义序列这两种编码的显示结果是相同的,但它们的二进制编码是不一样的。Unicode 标准为所有字符定义了一个首选的编码格式,并且给出了一个标准化的处理方式,把文本转换为适合比较的标准格式。JavaScript 会认为它正在解析的程序代码就已经是这种标准格式,因此就不会再对其做标准化处理了。

"caf\u00e9".normalize() // => "café": 返回标准化的 Unicode 字符串
1

注释

除了不能嵌套书写,其它方式都 OK。

//  单行注释
/* 注释段 */ // 另一个注释段

/*
* 多行注释
*/
1
2
3
4
5
6

直接量

直接量,就是程序中直接使用的数据值。

12 // 数字
1.2 // 小数
"hello js" // 字符串
'hi' // 也是字符串
true // 布尔值
/javascript/gi // 正则表达式直接量
null // 空
[1, 2, 3] // 数组
a = { x: 1, y: 2 }; // 对象
1
2
3
4
5
6
7
8
9

标识符和保留字

JavaScript 标识符必须以字母、下划线(_)或美元符号($)开始,后续的字符可以是前面三者和数字。为什么数字不能在标识符开头?因为这样便于区分标识符和数字。

虽然一般都只用 ASCII 字母和数字来命名标识符,但标识符中完全可以出现 Unicode 字符集中的字母和数字。

// 下面的都是合法的标识符
i
my_variable_name
v8
_dummy
$str
sí
π
1
2
3
4
5
6
7
8

保留字

// 以下是各类保留字
break
null
/* 未来版本的 ES 中会用到 */
const
super
/* 在严格模式下是保留字 */
let
yield
arguments
eval
/* Java 的关键字 */
abstract
private
/* 全局变量和函数 */
Infinity
eval
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

可选的分号

JavaScript 只在缺少了分号就无法正确解析代码的时候,才会填补分号。

var a
a
=
3
console.log(a)
/* JavaScript 会识别为:var a; a = 3; console.log(a); */
1
2
3
4
5
6

如果语句以 ([/+- 开始,那么就有很大可能会和前一条语句合在一起解析。

var y = x + f
(a+b).toString()
1
2

上面的代码就会被解析为:var y = x + f(a+b).toString()

当前语句和下一行语句无法合并解析的话,JavaScript 就会在当前语句后面填补分号。

只有两种例外,第一种例外:如果 returnbreakcontinue 后面直接换行了,JavaScript 就会在换行处填补分号。

return
true;
1
2

就会被解析成:return; true;

第二种例外:如果 ++-- 运算符单独在一行上,就会和下一行合在一起解析。

x
++
y
1
2
3

就会被解析成:x; ++y;

类型、值和变量

概述

JavaScript 的数据类型分为两类:原始类型和对象类型。

原始类型包括数字、字符串、布尔值、null 和 undefined,除此之外的就都是对象了。

对象本质上就是属性名(key)和属性值(value)之间的映射表。

// 普通对象
Person: {
    "name": "Henry",
    "age": 28
}

// 数组对象
num = [1, 1, 2, 3, 5, 7];

// 函数对象
function add(a, b) {
    return a + b;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

需要注意的是,JavaScript 中的函数都是真值(TODO: 这个知识点如何应用?),另外 JavaScript 程序可以把函数当作普通对象一样对待。

用函数来初始化(用 new 运算符)一个新建对象的话,这个函数就叫构造函数。每个构造函数都定义了一类(class)对象——由构造函数初始化的对象所组成的集合。可以把类看成是对象类型的子类型(TODO: 如何理解这个子类型?)。

JavaScript 语言的核心部分还定义了三种常用的类(除了上面说到的数组类和函数类)。

// 日期类
var date = new Date();

// 正则类
var pattern = /s$/;

// 错误类
var err = new Error();
1
2
3
4
5
6
7
8

JavaScript 是面向对象语言。

sort(a); // 结构化编程语言,只能这样对数组排序
a.sort(); // 面向对象语言,调用数组的方法即可
1
2

下面是可以拥有方法的数据类型,可以看到,只有 null 和 undefined 不能拥有方法。

// 对象类型
Person.talk()

// 数字类型
(1.23e+20).toFixed(2)

// 字符串类型
'123-234-345'.split('-')

// 布尔类型
true.toString()
1
2
3
4
5
6
7
8
9
10
11

JavaScript 的类型除了可以分为原始类型和对象类型,还可以分为可以拥有方法的类型和不能拥有方法的类型,另外还可以分为可变类型和不可变类型。可变类型的值是可以修改的,对象和数组就是可变类型;而数字、字符串、布尔值、null 和 undefined 都是不可变类型。

JavaScript 中的变量是无类型的(untyped),可以被赋予任何类型的值,同一个变量也可以被重新赋予不同类型的值。

数字

在 JavaScript 中,所有的数字都是以浮点数的值来表示的。JavaScript 采用 IEEE 754 标准定义的 64 位浮点数格式来表示数字。

按照 JavaScript 的数字格式,能表示的整数范围为:-(2^53) ~ 2^53,包含边界值。其实 JavaScript 也能表示这个范围之外的整数,只不过超出这个范围的话,低位数字的精度就不准确了。不过在数组索引、位运算之类的操作中,JavaScript 是基于 32 位整数进行运算的。

整型直接量

0
0xff // 十六进制 <-> 255(十进制)
0377 // 八进制 <-> 255(十进制) 在ES6的严格模式下是禁止的
1
2
3

浮点型直接量

3.14
.12
6.07e23
1.2E-15
1
2
3
4

算术运算

// Math 对象的函数和常量
Math.pow(2, 53)
Math.PI
Math.random()
1
2
3
4

加餐:四则运算和比较。参考链接:

上溢(overflow,超出上限)的结果为 +/- Infinity,下溢(underflow,超出下限)的结果为 +/- 0,被零整除为 +/- Infinity 或者非数字值 NaN(0/0)。

// Infinity 和 NaN: 运算出现特殊值的情况

// Infinity
1 / 0
Number.POSITIVE_INFINITY
Number.MAX_VALUE + 1E300

// -Infinity
-1 / 0
Number.NEGATIVE_INFINITY
-1 / Number.MIN_VALUE

// NaN
0 / 0
Infinity / Infinity
Math.sqrt(-1, 2)
1 * 'a'
Number.NaN

// +/-0
Number.MIN_VALUE / 2 // => 0
-Number.MIN_VALUE / 2 // => -0
1 / Infinity // => 0
-1 / Infinity // => -0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

二进制浮点数和四舍五入错误

(.3 - .2) !== (.2 - .1) // => true: 因为 JS 中的浮点数只是对应实数的近似表示
(.3 - .2) - (.2 - .1) < Number.EPSILON  // => true
1
2

解决方法:对于精度要求高的场合,可用大整数进行运算,记得保证最终值的小数点位数不要错就可以。

加餐:特殊值的判断。

NaN != NaN // => true: 只能通过该等式或 Number.isNaN() 函数判断是否为 NaN
isFinite(x) // 只有在参数为 NaN、Infinity 或 -Infinity 时才为 false
1 / 0 !== 1 / -0 // 只有在这个时候,0 和 -0 才不相等
1
2
3

日期和时间

var then = new Date(2011, 0, 1, 17, 10, 30);
var now = new Date();
var elapsed = now - then; // 单位为毫秒
now.getFullYear() // => 2017
// 为什么月份从 0 开始,而天数从 1 开始?按欧美的星期记法,每周也是从 0,即周日开始?
// https://stackoverflow.com/a/15799570/2667665
now.getMonth() // => 8: 从 0 开始的月份,而不是从 1
now.getDate() // => 19: 从 1 开始的天数
now.getDay() // => 2: 周一至周六分别为 1~6,周日为 0
1
2
3
4
5
6
7
8
9

文本

字符集和内码

不影响基本使用,暂时不用深究这一部分。

JavaScript 采用 UTF-16 编码的 Unicode 字符集,每个字符均用无符号的 16 位值表示。 因为有很多符号要表示,所以 Unicode 是分区定义的,每个区也称为一个平面(plane),可存放 65536(2^16)个字符。 最前面的 65536 个字符位称为基本平面(BMP),它的码点范围为:0 ~ 2^16 - 1,对应的 16 进制就是 U+0000 ~ U+FFFF。所有最常见的字符都在这个平面,这也是 Unicode 最先定义和公布的一个平面。 剩下的字符都放在辅助平面(SMP),码点范围从 U+010000 开始。

U+0000 // => null: 0000 为码点(code point),也是该字符的编号
U+597D // => '好'
1
2

参考资料:Unicode与JavaScript详解

注意:JavaScript 中并没有表示单个字符的“字符型”,只有字符串这一种类型。

字符串直接量

"" // => 空字符串: 包含 0 个字符
'test'
"3.14"
'name="myForm"' // 由单引号定界的字符串,里面可以包含双引号,但不能再包含单引号了,否则将以内部出现的第一个单引号作为字符串的结束位置
"It\"s my life" // 由单引号/双引号定界的字符串,如果内部必须包含相同的引号,则内部包含的引号左侧一定要加上转义字符 \
"This string\n has two lines" // 字符串内可以包含换行符 \n
1
2
3
4
5
6
// 在 ES5 中,下面几行字符串实际输出时只有一行,行末加反斜线是为了标记下一行还是该字符串的内容,并不是换行,\n 才是换行
"one\
long\
line"
1
2
3
4

当 JavaScript 代码和 HTML 代码混在一起的时候,它俩最好用不同的引号风格,比如 JavaScript 用单引号,HTML 用双引号:

<button onclick="alert('Thank you')">Click Me<button></button>
1

转义字符

反斜线(\)符号后面加一个字符,该字符就不再表示它们的字面含义了。所有的转义字符及其含义如下表所示。

转义字符 含义
\o NULL字符(\u0000)
\b 退格符(\u0008)
\t 水平制表符(\u0009)
\n 换行符(\u000A)
\v 垂直制表符(\u000B)
\f 换页符(\u000C)
\r 回车符(\u000D)
\" 双引号(\u0022)
\' 撇号或单引号(\u0027)
\\ 反斜线(\u005C)
\xXX 由两位十六进制数 XX 指定的 Latin-1 字符
\xXXXX 由四位十六进制数 XXXX 指定的 Unicode 字符

注意:只要反斜线(\)出现在上表中字符之外的地方,则都忽略该反斜线,比如 \## 相同。

字符串的使用

msg = 'Hello' + ', ' + 'world' // 字符串直接量的拼接
greeting = msg + ' ' + name // 字符串直接量和变量的拼接
s.length // 字符串的长度
var s = 'hello, world' // 定义字符串
s.charAt(0) // => 'h': 第一个字符
s.charAt(s.length - 1) // => 'd': 最后一个字符
s.substring(1,4) // => 'ell': 索引在 1~3 之间的字符,即第 2~4 个字符
s.slice(-3) // => 'rld': 最后 3 个字符
s.indexOf('l') // => 2: 字符 l 第一次出现时的游标
s.indexOf('l', 3) // => 3: 从索引值 3 开始第一次出现字符 l 的位置
s.lastIndexOf('l') // => 10: 字符 l 最后一次出现时的索引
s.split(', ') // 用 ', ' 逗号加空格将字符串分割成数组
s.replace('l', 'L') // => 'heLlo, world': 替换字符串中首个小写字符 l 为大写字符 L
s.toUpperCase() // => 'HELLO, WORLD': 字符串中所有字母变为大写
1
2
3
4
5
6
7
8
9
10
11
12
13
14

注意:前面说过,字符串是不可变类型,所以 replace()toUpperCase() 这样的方法返回的是新字符串,原字符串不会发生变化,除非用 str = str.toUpperCase() 这样的方法对字符串进行重新赋值。

在 ES5 中,字符串可以当做只读数组来使用,就是说可以用访问数组元素的方式来访问字符串中的单个字符:

s = 'hello, world';
s[0] // => 'h'
s[s.length - 1] // => 'd'
1
2
3

模式匹配

JavaScript 定义了 RegExp() 构造函数,用来创建 “表示文本匹配模式” 的对象,这些模式称为 “正则表达式(regular expression)”。

RegExp 并不是 JavaScript 的基本类型,它和 Date 类型一样,只是一种具有实用 API 的特殊对象。

String 和 RegExp 对象均定义了利用正则表达式进行模式匹配和查找/替换的函数。

RegExp 也有直接量写法,可以直接在 JavaScript 程序中使用。在两条斜线 // 之间的文本构成了一个正则表达式直接量,第二条斜线之后还可以跟随一个或多个字母,用来修饰匹配模式的含义。

/^HTML/ // 匹配以 HTML 开始的字符串
/[1-9][0-9]*/ // 匹配一个非零数字,后跟任意个任意数字
/\bjavascript\b/i // 匹配单词 'javascript',忽略大小写。\b 用于匹配一个词的边界,所有非罗马字母、数字或者下划线的字符,都是一个词的边界
1
2
3

RegExp 对象定义了很多有用的方法,字符串同样具有可以接收 RegExp 参数的方法。

var text = 'testing: 1, 2, 3'; // 定义用于演示文本匹配的字符串
var pattern = /\d+/g // 匹配包含至少一个数字的实例
pattern.test(text) // => true: 匹配成功,pattern.test(text) 测试字符串 text 是否匹配 pattern 这个模式
text.search(pattern) // => 9: 首次匹配成功的字符串中第一个字符的索引
text.match(pattern) // => ['1', '2', '3']: 所有匹配成功的内容组成的数组
text.replace(pattern, '#') // => 'testing: #, #, #': 将所有匹配成功的内容换成 replace() 方法中第二个参数的值
text.split(/\D+/) // => ['', '1', '2', '3']: TODO: 为什么截取出来的数组,第一个元素是空字符串?text.match(/\D+/) 得到的结果也只是 'testing: `,并不包含后面的 `, `
1
2
3
4
5
6
7

布尔值

用途:通常用于 JavaScript 的控制结构中。

仅有的几个会被转换成 false 的假值(可用 undefined ? true : false 进行判断,不能用 undefined == false 进行判断):

undefined
null
0
-0
NaN
'' // 空字符串
1
2
3
4
5
6

布尔运算符

a && b // a 和 b 均为真值时,表达式才为真
a || b // a 或 b 至少有一个为真值时,表达式就为真
1
2

null 和 undefined

null undefined
表示数字、字符串和对象是“无值”的。 表示变量没有初始化。
如果查询对象属性或数组元素的值时返回 undefined,就说明这个属性或者元素不存在。
无返回值的函数也会返回 undefined。
引用未提供实参的函数形参的值也会得到 undefined。
适合表示程序级的、正常的或在意料之中的值的空缺。 适合表示系统级的、出乎意料的或类似错误的值的空缺。
  • 相等判断运算符 == 认为两者是相等的,需要用严格相等运算符 === 才能判断出两者是不相等的。
  • 两者都不包含任何属性和方法,使用 .[] 来存取他们的成员或方法时,都会产生一个类型错误:TypeError: Cannot read property 'toString' of null/undefined
  • 如果要赋值给变量或者属性,或者作为参数传入函数,建议用 null。
typeof null // => 'object'
typeof undefined // => 'undefined'
1
2

全局对象

当 JavaScript 解释器启动时,或者 Web 浏览器加载新页面时,就会创建一个新的全局对象,并给它一组已定义的初始属性:

  • 全局属性,比如 undefined、Infinity 和 NaN
  • 全局函数,比如 isNaN()、parseInt() 和 eval()
  • 构造函数,比如 Date()、RegExp()、String()、Object() 和 Array()
  • 全局对象,比如 Math 和 JSON

全局对象的初始属性并不是保留字,但应该当做保留字来对待。

在代码的最顶层,可以用 JavaScript 关键字 this 来引用全局对象:

var global = this; // 定义一个引用全局对象的全局变量
1

对于客户端 JavaScript,在其表示的浏览器窗口中的所有 JavaScript 代码中,Window 对象充当了全局对象。这个全局 Window 对象有一个属性 window 引用全局对象自身,这个属性可以代替 this 来引用全局对象。Window 对象不只是定义了核心的全局属性,还针对 Web 浏览器和客户端 JavaScript 定义了一小部分的其它全局属性。

var global = window;
global.Infinity; // => Infinity
global.isNaN(1); // => false
global.Date(); // => "Fri Sep 22 2017 23:46:10 GMT+0800 (CST)"
global.Math.random() // => 0.26739690031767305
1
2
3
4
5

初次创建时,全局对象会定义 JavaScript 中所有的预定义全局值,用户自定义的全局值也包含在其中。如果代码中声明了一个全局变量,那么这个全局变量就是全局对象的一个属性。

包装对象

在 JavaScript 中,一般只有对象才有属性和/或方法(方法是不是也可以看作属性的一种?)。但为什么原始类型的字符串、数字和布尔值也有属性和方法呢?

var s = 'hello world'; // 定义字符串
var word = s.substring(s.indexOf(' ') + 1, s.length); // 使用字符串的属性
1
2

因为只要引用了字符串 s 的属性,JavaScript 就会将字符串的,通过调用 new String(s) 的方式转换成对象,这个由值转换而来的对象,继承了字符串的方法,并被用来处理属性的引用。一旦属性引用结束,这个新创建的对象就会被销毁。

看看下面的代码,思考一下它的执行结果:

var s = 'test'; // 创建一个字符串
s.len = 4; // 给它设置一个属性
var t = s.len; // 查询这个属性
1
2
3

运行这段代码时,最后得到的 t 的值是 undefined。为什么会这样?原因在第二行代码中。第二行代码创建了一个临时的字符串对象,并给其 len 属性赋值 4,随即就销毁了这个对象。所以原始的字符串 s 其实并没有 len 这个属性。第三行代码通过原始的字符串值创建一个新字符串对象,并尝试读取其 len 属性,而这个属性其实并不存在,结果自然是 undefined 了。

上面的代码说明,在读取字符串、数字和布尔值的属性或方法时,这些类型表现的像对象一样。但不要试图给其属性赋值:修改只是发生在临时对象身上,并且这个临时对象是立即销毁的。

综上所述,存取字符串、数字或布尔值的属性时,所创建的临时对象为包装对象,它只是偶尔用来区分字符串/数字/布尔类型的值和对象的。

上面的代码隐式创建了包装对象,也可以通过 String()、Number() 或 Boolean() 构造函数来显式创建包装对象:

var s = 'test', n = 1, b = true; // 分别创建一个字符串、数字和布尔值
var S = new String(s); // 一个字符串对象
var N = new Number(n); // 一个数值对象
var B = new Boolean(b); // 一个布尔对象
S === s; // => false: s 是一个字符串,而 S 则是一个字符串对象,注意两者的区别
1
2
3
4
5

不可变的原始值和可变的对象引用

不可变类型

JavaScript 中的原始值(undefined、null、布尔值、数字和字符串)与对象(包括数组和函数)的根本区别就是:原始值是不可更改的,任何方法都无法更改/突变(mutate)一个原始值。

改变数字或者布尔值的说法本身就说不通,而对字符串来说,每次修改后的字符串就已经不是之前的字符串了。

原始值的比较是的比较:只有它们的值相等时,两个原始值才相等。

var s = 'hello'; // 定义一个字符串
s.toUpperCase(); // => 'HELLO': 虽然返回了大写的字符串,但原来的字符串 s 并没有被改变
s // => 'hello'
1
2
3

可变类型(对象引用)

对象和原始值不同,首先,它们是可变的——也就是说它们的值是可以修改的:

var o = { x: 1 }; // 定义一个对象
o.x = 2; // 修改对象属性值来更改对象
o.y = 3; // 增加对象属性值来更改对象
1
2
3

比较两个对象

比较两个对象并不是比较他们的值,而是比较两个对象的引用。两个属性及值完全相同的对象,也是可以不相等的;各个元素完全相等的两个数组也是可以不相等的。

var o = { x: 1 }, p = { x: 1 }; // 具有相同属性的两个对象
o === p // => false: 两个单独的对象永不相等
var a = [], b = []; // 两个单独的空数组
a === b // => false: 两个单独的数组永不相等
1
2
3
4

可变类型的复制

对象通常被称为“引用类型”,以和 JavaScript 的基本类型相区分。因为对象的值都是引用,所以比较对象就是比较引用:只有引用了同一个基对象时,两个对象才相等。

var a = []; // 定义一个引用了空数组的变量 a
var b = a; // 变量 b 引用同一个数组
b[0] = 1; // 通过变量 b 来修改引用的数组
a[0] // => 1: 变量 a 也会被修改
a === b // => true: a 和 b 引用的是同一个数组,当然相等
1
2
3
4
5

由上面的代码可以看到,把对象赋值给变量时,只是把对象的“引用”赋过去了,并没有把对象再复制一份。如果用这种方式把一个对象赋值给多个变量,那么任意一个变量修改了对象,其它变量都会受影响。

如果想要像不可变类型那样,每个对象变量都拥有自己的“值”,那就要像下面的例子一样,把对象的每个属性(数组的每个元素)显式地复制一份。

var a = { x: 1, y: 2 }; // 待复制的对象
var b = {}; // 将要复制到的空对象
for (var i in a) { b[i] = a[i]; } // 遍历 a 并将其属性复制到 b 中

var a = ['a', 'b', 'c']; // 待复制的数组
var b = []; // 将要复制到的空目标数组
for (var i = 0; i < a.length; i++) { b[i] = a[i]; } // 遍历 a 并将其元素值复制到 b 中
1
2
3
4
5
6
7

类型转换

特殊值

以下仅列出几种特殊情况:

  1. 对应的布尔值为 false 的值:undefined、null、""(空字符串)、0、-0、NaN
  2. 对应的数字为 0 的值:null、""(空字符串)
  3. 对应的数字为 NaN 的值:undefined、"one"、['a']、function(){}(任意函数)
  4. 对应的对象会抛出异常的值:undefined、null(均会 throws TypeError,比如调用 toString() 方法时)

参考资料:

显式类型转换

可以使用各种类型的构造函数进行显式转换。

Number('3') // => 3
String(false) // => 'false': 使用 false.toString() 也有同样效果
Boolean([]) // => true: 空数组为 true,空对象也是如此,这是个知识点
Object(3) // => 结果等同于 new Number(3)
1
2
3
4

注意:只有 null 和 undefined 没有 toString() 方法,其它值调用该方法的执行结果,一般和 String() 方法的返回结果是一样的。

对 null 和 undefined 使用 Object() 函数不会抛出异常,只是返回一个新建的空对象。但是用其它方式转换的话则可能会抛出类型错误(TypeError)。

进制转换

toString() 方法可以将数字转换为指定的进制。

var n = 17;
binarl_string = n.toString(2); // => '10001'
octal_string = '0' + n.toString(8); // => '021'
hex_string = '0x' + n.toString(16); // => '0x11'
special_string = 'xx' + n.toString(7); // => 'xx23'
1
2
3
4
5

设置小数点及有效数字位数

toFixed() 不使用科学记数法。

var n = 123456.789;
n.toFixed(0); // => '123457'
n.toFixed(2); // => '123456.79'
n.toFixed(5); // => '123456.78900'
1
2
3
4

toExponential() 始终将数字转换为科学记数法,小数点前只有一个数字。

n.toExponential(1); // => '1.2e+5'
n.toExponential(5); // => '1.23457e+5'
1
2

toPrecision() 则根据设置的有效位数,来决定是使用普通记数法还是科学记数法。

n.toPrecision(4); // => '1.235e+5'
n.toPrecision(7); // => '123456.8'
n.toPrecision(10); // => '123456.7890'
1
2
3

解析字符串中的数字

Number() 可将字符串中的数字解析成整数或浮点数直接量,但只按十进制转换,并且字符串尾部不能有非法字符——只能有数字或空格。

parseInt()parseFloat() 函数则更加灵活。parseInt() 用于解析字符串中的数字并转换成整数,但是数字之前只能有空格或者正/负号,如果有其它字符,就都会返回 NaN

parseInt('3 a'); // => 3
parseInt('  +3'); // => 3
parseInt('0x3'); // => 3
parseInt('-3.2'); // => -3
parseInt('  -+3'); // => NaN
parseInt('.3'); // => NaN
parseInt('$3'); // => NaN
parseInt('~3'); // => NaN
parseInt('a3'); // => NaN
1
2
3
4
5
6
7
8
9

parseInt() 还可接收第二个参数,用于指定第一个参数的进制。如果第一个参数中的部分数字不属于第二个参数指定的进制,那么就直接忽略。

parseInt('10', 2); // => 2
parseInt('112', 2); // => 3
parseInt('211', 2); // => NaN
parseInt('077', 8); // => 63
1
2
3
4

parseFloat()parseInt() 类似,除了不接受用于指定进制的第二个参数之外,其它方面和 parseInt() 都相同。

parseFloat('3.14 ab 2.13'); // => 3.14
parseFloat('0x112', 16); // => 0
1
2

运算符的隐式类型转换

1 + '2' // => '12': 有字符串则转换成字符串
+'1' // => 1: 等于 Number('1'),一元 + 将操作数转换为数字
!!null // => false: 可用于特殊类型的快速判断,一元 ! 将操作数转换为布尔值并取反
1
2
3

对象到原始值的转换

到布尔值的转换

所有的对象(数组和函数也是对象)转换为布尔值后都为 true,包装对象也是如此:new Boolean(false) 并不是原始值,而是一个对象,转换为布尔值后也为 true。

Boolean({ x: 1 }) // => true
Boolean([]) // => true
Boolean(() => { console.log('123'); }) // => true
1
2
3

到字符串的转换

转换规则只适用于本地对象(native object),宿主对象(如浏览器定义的对象)则根据各自规定的规则进行转换。

注意:对象和函数在转换成字符串的时候,要先用圆括号括起来,再调用 toString() 方法,数组类、正则类和日期类则不需要。当然了,如果不确定什么时候该用括号,那就一直都用括号就好了。

  • 数组类返回的是用逗号分隔的各元素。
  • 函数类返回的是函数定义的源码字符串。
  • 正则类返回的是正则字符串直接量。
  • 日期类返回的是方便阅读的日期。
({ x: 1, y: 2}).toString() // => "[object Object]"
[1, 2, 3, 4].toString() // => "1,2,3,4"
(function f(x) { x; }).toString() // => "function f(x) { x; }"
/\d+/g.toString() // => "/\d+/g"
new Date().toString() // => "Tue Oct 10 2017 14:53:49 GMT+0800 (中国标准时间)"
1
2
3
4
5

细节探究:在 JavaScript 中,对象到字符串的转换经历了以下几个步骤:

  1. 如果对象有 toString() 方法,就调用该方法。如果返回的是原始值并且不是字符串,则转换为字符串。然后返回字符串结果。
  2. 如果对象没有 toString() 方法,或者该方法返回的不是原始值,则 JavaScript 就会继续调用 valueOf() 方法。如果对象有该方法就调用它,如果返回的是原始值并且不是字符串,则转换为字符串。然后返回字符串结果。
  3. 如果以上都不满足,说明 JavaScript 无法通过 toString() 或者 valueOf() 方法获得原始值,则将抛出一个类型错误异常。

到数字的转换

转换规则和到字符串的转换一样:只适用于本地对象(native object),宿主对象(如浏览器定义的对象)则根据各自规定的规则进行转换。

如果存在原始值,则默认转换为原始值。由于对象是复合值,并且大多数对象无法真正表示为一个原始值,因此默认的 valueOf() 方法只是简单地返回对象本身。

下面这些转换结果中,需要注意的是日期类的转换:它返回的是从 1970 年 1 月 1 日以来的毫秒数。

({ x: 1, y: 2}).valueOf() // => {x: 1, y: 2}
[1, 2, 3, 4].valueOf() // => (4) [1, 2, 3, 4]
(function f(x) { x; }).valueOf() // => ƒ f(x) { x; }
/\d+/g.valueOf() // => /\d+/g
new Date().valueOf() // => 1507618941654
1
2
3
4
5

细节探究:在 JavaScript 中,对象到数字的转换经历了和字符串类似的几个步骤:

  1. 如果对象有 valueOf() 方法就调用它。如果返回的是原始值并且不是数字,则转换为数字。然后返回结果。
  2. 如果对象没有 valueOf() 方法,或者该方法返回的不是原始值,则 JavaScript 就会继续调用 toString() 方法。如果对象有该方法就调用它,如果返回的是原始值并且不是数字,则转换为数字。然后返回结果。
  3. 如果以上都不满足,说明 JavaScript 无法通过 valueOf() 或者 toString() 方法获得原始值,则将抛出一个类型错误异常。

上面的内容解释了空数组在参与数学运算的时候为什么会被转换为 0,以及为什么只有一个元素的数组会转换成数字。数组继承了默认的 valueOf() 方法,该方法返回的是对象。所以数组到数字的转换调用 toString() 方法,空数组被转换成空字符串,空字符串又转换成数字 0。只有一个元素的数组同理。

运算符带来的转换

TODO: 这一节没太看明白。

二元 + 运算符的其中一个操作数为对象的话(另一个对象就是原始值),JavaScript 将使用特殊的方法(TODO: 什么方法?)将对象转换为原始值。== 运算符也是如此,如果将对象和原始值比较,则会先将对象转换为原始值。

但是,如果上面所说的对象是日期类型的话就不一样了,日期类是 JavaScript 核心类型中唯一的一个预定义类型。对于日期类到字符串和数字的转换,JavaScript 都定义了有意义的实现方式。对于所有的非日期对象来说,对象到原始值的转换基本上都是对象到数字的转换(先调用 valueOf()),日期对象则使用对象到字符串的转换模式。但这里的转换模式还和前面所说的不太一样:valueOf() 或者 toString() 所返回的原始值不会被转换为数字或字符串,而是直接使用。

< 以及其它关系运算符和 == 一样,也会做对象到原始值的转换,但不包括日期对象:任何其它对象都会先调用 valueOf(),再调用 toString()。转换后的原始值不会被进一步转换,而是直接使用。

+==!= 是仅有的几个会执行字符串到原始值的转换的运算符,其他运算符到特定类型的转换都很明确,而且日期对象也没有特殊情况。比如 - 减号运算符就会把两个操作数都转换为数字。

var now = new Date();
typeof(now + 1); // => "string"
typeof(now - 1); // => "number"
now == now.toString(); // => true
now > (now - 1); // => true
1
2
3
4
5

变量声明

JavaScript 中使用一个变量之前先声明一下是个好习惯,使用关键字 var 来声明。可以用一个 var 关键字声明多个变量,而且还能将变量的声明和赋值写在一起:

var message = 'hello';
var i = 0, j = 0, k = 0;
1
2

如果只是声明但没有赋值,那么这个时候该变量的值就是 undefined

另外,在 for 循环或者 for/in 循环内也可以用 var 语句来声明循环变量。

for (var i = 0; i < 5; i++) console.log(i);
for (var p in obj) console.log(p);
1
2

变量无类型

JavaScript 中并没有规定变量始终只能为一种类型:

var a = 1.2;
a = 'Hello';
a = [1, 2, 3];
1
2
3

重复/遗漏的声明

使用 var 重复声明变量是合法的,如果重复声明还带赋值,那就会覆盖前一次的赋值。

var a = 1;
var a = 2;
a; // => 2
1
2
3

读取一个未声明的变量时,JavaScript 会报错。在 ES5 严格模式中,给未声明的变量赋值也会报错。虽然在非严格模式下,给未声明的变量赋值时,会给全局对象创建一个同名属性,并且工作起来似乎像一个正确声明的全局变量。但非常不建议这样写代码,所以一定要用 var 来声明变量。

变量作用域

全局变量:具有全局作用域,在 JavaScript 代码中的所有地方都有定义。

局部变量:只在所声明的函数体内有定义。函数参数也是局部变量。

优先级:邻近原则,在函数体内,局部变量的优先级高于同名的全局变量。

函数作用域与声明提前

在 JavaScript 中,没有块级作用域(block scope)的概念,只有函数作用域(function scope):在函数体中声明的变量,只在函数及其内部嵌套的函数中有定义。

function test(o) {
    var i = 0;
    if (typeof o == 'object') {
        var j = 0;
        for (var k = 0; k < 10; k++) {}
        console.log('k1 = ' + k);
    }
    console.log('k2 = ' + k);
    console.log('j = ' + j);
}

test({});
// => k1 = 10
// => k2 = 10
// => j = 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

JavaScript 的函数作用域,不仅是指在函数中声明的变量,在函数及内部嵌套的函数中均有定义;而且函数里的变量在声明之前就可以被使用。这个特性称作声明提前(hoisting)。但是,只有函数的声明会被“提前”至函数体的顶部,赋值并不会被提前,见下面代码的执行结果。

var scope = 'global';
function f() {
    console.log(scope);
    var scope = 'local';
    console.log(scope);
};
f();
// => undefined
// => local
1
2
3
4
5
6
7
8
9

上面的函数可以按下面的执行顺序来理解。

function f() {
    var scope;
    console.log(scope);
    scope = 'local';
    console.log(scope);
}
1
2
3
4
5
6

由于 JavaScript 函数作用域的特性,同名的局部变量遮盖了全局变量。但是只有在程序执行到 var 语句的时候,局部变量才被赋值。所以在这之前就使用它的话,值自然是 undefined

基于 JavaScript 的这种特性,在函数内定义变量时,可以将变量声明整体放在函数体的顶部,这样就能够清晰、准确地反映真实的变量作用域。

作为属性的变量

声明全局变量时,实际上是定义了全局对象的一个属性。

如果用 var 声明全局变量,则这个变量/属性是不可删除的(无法用 delete 运算符删除)。

如果未使用严格模式,并且直接给一个未声明的变量赋值的话(不用 var 声明),JavaScript 就会自动创建一个全部变量,并且这个变量/属性是可删除的(可以用 delete 运算符删除)。

var truevar = 1;
fakevar = 2;
this.fakevar2 = 3;
console.log(delete truevar); // => false
console.log(delete fakevar); // => true
console.log(delete this.fakevar2); // => true
1
2
3
4
5
6

ES 规范强制规定全局变量是全局对象的属性。虽然对局部变量没有做类似的规定,但是可以将局部变量理解为跟函数调用相关的某个对象的属性。在 ES5 中,该对象称为“声明上下文对象”(declarative environment record)。

JavaScript 中可以用 this 关键字来引用全局对象,但没有方法引用存放局部变量的对象。这种存放局部变量的对象所特有的性质,是对我们不可见的内部实现。但是!可以通过闭包在局部变量的作用域之外使用它,具体请看 MDN 上关于闭包的文档

作用域链

JavaScript 是基于词法作用域的语言:只要看看包含变量定义在内的几行源码,就能知道变量的作用域了。全局变量在程序中始终有定义,局部变量则在声明它的整个函数体内都有定义。

假设有一类定义了自身实现方式的对象(TODO: 说的就是函数?),而我们将(函数中定义的)局部变量看作是这类对象的属性的话,我们就可以换个角度来理解变量作用域了。

每一段 JavaScript 代码(不管是全局代码还是函数)都有一个与之关联的作用域链(scope chain)。这个作用域链是一系列对象组成的列表(list)或者链表(chain),而这一系列对象定义了它们的“作用域”中的变量。JavaScript 在查找变量 x 时(这个过程叫做变量解析 - variable resolution),它会从作用域链上的第一个对象开始,一直找到最后一个对象,如果整个作用域链上都没找到包含属性 x 的对象,就认为 x 不在这个作用域链上,并且会抛出一个引用错误(ReferenceError)的异常。

在 JavaScript 的最顶层(最外层,不在任何函数定义内)代码中,作用域链上只有一个对象:全局对象。在无嵌套的函数体内,作用域链上有两个对象:第一个对象定义了这个函数的参数和函数体内的局部变量,第二个对象就是全局对象。在有嵌套的函数体内,作用域链包含至少三个函数对象。(这么一说,不包含嵌套的函数,也可以看作是“嵌套”在全局对象内的函数?)

定义一个函数时,它会保存它的作用域链/定义链——非嵌套函数会保存定义了这个函数的参数和局部变量的对象,以及全局对象;嵌套函数则保存从当前嵌套函数直至最顶层函数的相关对象(定义了每个函数的局部变量,含参数),以及全局对象。(这里可以把这个作用域链称作“定义链”。)

调用这个函数时,它会创建一个新对象来保存它的局部变量(前面说过,函数参数也是局部变量),然后把这个新对象添加到定义链上,形成一个新的、更长的作用域链,这个新的作用域链表示函数的调用——也就是调用链。

涉及到嵌套函数的时候就更有意思了,因为每次调用外部函数的时候,内部函数就会被重新定义一次(TODO: 也没看懂 => 看后面的解释)。而每次调用外部函数的时候,作用域链/调用链和前一次是不一样的,内部函数在每次重新定义的时候都会和前一次有微妙的差别——每次调用外部函数时,被重新定义的内部函数的代码虽然是不变的,但是与这段代码关联的作用域链/调用链却是变化的——因为每次调用外部函数时,外部函数都会重新创建一个新对象来保存局部变量,再将这个新对象添加到定义链上——所以说每次调用外部函数时,作用域链/调用链和前一次调用是不同的,因此内部函数也和前一次不同。(TODO: 还是没有理解透彻。)

表达式和运算符

表达式:JavaScript 解释器会将其计算(evaluate)出一个结果。

  • 常量:最简单的一类表达式。
  • 变量名:也是一种简单的表达式,它的值就是赋给变量的值。

复杂表达式:由简单表达式通过运算符(或其它符号,如数组元素访问或函数调用)组合而成。

原始表达式

原始表达式:最简单的表达式,也是表达式的最小单位——不再包含其它表达式。常量、直接量、关键字、变量都是原始表达式。

1.23 // 数字直接量
'hello' // 字符串直接量
/pattern/ // 正则表达式直接量
1
2
3

一些保留字也构成原始表达式:

true // 返回布尔值:真
false // 返回布尔值:假
null // 返回值:空
this // 返回“当前”对象
1
2
3
4

还有一种原始表达式就是变量:

i // 返回变量 i 的值
undefined // undefined 是全局变量,而 null 是一个关键字,它俩是不一样的
1
2

JavaScript 代码中出现了标识符的话,就会将其当作变量去查找它的值。如果变量名不存在,表达式的运算结果就是 undefined。在 ES5 严格模式下的话,对不存在的变量名进行求值就会抛出一个引用错误异常。

对象和数组的初始化表达式

对象和数组的初始化表达式其实是新建的对象和数组,有时候也称作“对象直接量”和“数组直接量”。但是,它们不是原始表达式,因为它们所包含的成员或者元素都是子表达式。

[] // 空数组
[1 + 2, 3 + 4] // 数组拥有两个元素,3 和 7
var matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]; // 嵌套表达式
1
2
3

JavaScript 对数组初始化表达式进行求值的时候,表达式中的元素表达式也会各计算一次。也就是说,数组初始化表达式每次计算的值有可能是不同的。

var i = 0;
var a = [++i, ++i, ++i]; // [1, 2, 3]
1
2

数组直接量中的逗号间的元素可省略,会填充为 undefined。但是如果元素列表结尾处有一个逗号,逗号后就不会再创建一个 undefined 元素了。不过访问数组元素时,数组索引表达式的值大于数组最后一个元素的索引的话,结果肯定就是 undefined 了。

var a = [1,,,,3]; // => [1, empty × 3, 3]
var b = [1,]; // => [1]
1
2

对象的初始化和数组非常类似。

var p = { x: 1, y: 2 }; // 拥有两个属性成员的对象
var emp = {}; // 空对象
var rectangle = { upperLeft: { x: 2, y: 2 },
                  lowerRight: { x: 4, y: 5 } }; // 也可以嵌套
1
2
3
4

和数组一样,求对象初始化表达式的值的时候,对象表达式也会各自计算一次。

var side = 1;
var square = { 'upperLeft': { x: p.x, y: p.y }, 'lowerRight': { x: p.x + side, y: p.y + side } };
// => { upperLeft: {x: 2.3, y: -1.2}, lowerRight: {x: 3.3, y: -0.19999999999999996} }
1
2
3

函数定义表达式

函数定义表达式的值就是新定义的函数,也可以叫做函数直接量。

var square = function(x) { return x * x; };
1

属性访问表达式

属性访问表达式会计算得到一个对象属性或一个数组元素的值。

expression.identifier
expression[expression]
1
2

获取数组元素时只能用上面的第二种写法,而对象则两种都可以用。

var o = { x: 1, y: { z: 3 } }; // 示例对象
var a = [o, 4, [5, 6]]; // 包含示例对象的数组
o.x // => 1: 表达式 o 的属性 x
o.y.z // => 3: 表达式 o.y 的属性 z
o["x"] // => 1: 表达式 o 的属性 x
a[1] // => 4: 表达式 a 中索引为 1 的元素
a[2]["1"] // => 6: 表达式 a[2] 中索引为 1 的元素
a[0].x // => 1: 表达式 a[0] 的属性 x
1
2
3
4
5
6
7
8
  • 对于上面两种属性访问表达式,在 . 或者 [ 之前的表达式都会首先计算。

  • 如果计算结果是 null 或 undefined,表达式会抛出一个类型错误异常,因为这两个值都不能包含任何属性。

  • 如果运算结果不是对象(或数组),JavaScript 会先将其转换为对象。

  • 如果表达式后跟句点和标识符,则会查找标识符所指定的属性的值,并作为整个表达式的值返回。

  • 如果表达式后跟一对方括号,则先计算方括号内的表达式的值并转换为字符串,然后查找字符串所指定的属性的值并返回。

  • 如果查找的属性不存在,则整个属性访问表达式的值就是 undefined。

  • 虽然用标识符访问属性的写法更简单(a.b),但这种写法只适用于属性名是合法标识符,并且已经知道属性名的情况。

  • 如果属性名包含空格或标点符号,或者是数字(对于数组而言),就必须使用方括号。

  • 如果属性名不是固定值而是计算得出的值,也必须用方括号。

调用表达式

调用表达式(invocation expression):调用(或者执行)函数或方法的语法表示,它由函数表达式和紧邻其后的一对圆括号组成。

f(0) // f 是函数表达式,0 是参数表达式
Math.max(x, y, z) // Math.max 是函数,x、y 和 z 是参数
a.sort() // a.sort 是函数,没有参数
1
2
3

对调用表达式求值:

  • 先计算函数表达式,然后计算参数表达式,得到一组参数值。
  • 如果函数表达式不是一个可调用的对象,则抛出一个类型错误异常。
  • 函数表达式可调用,将实参的值依次赋给形参,然后执行函数体。
  • 函数使用 return 语句给出返回值的话,这个值就是整个调用表达式的值。
  • 否则,调用表达式的值就是 undefined。

方法调用(method invocation):调用表达式是属性访问表达式的情况。在方法调用中,执行函数体的时候,作为属性访问主体的对象和数组,就是其调用方法内 this 的指向。借由这种特性,在面向对象编程中,函数可以调用其宿主对象。

调用表达式不属于方法调用时,通常将全局对象作为 this 的值。但是,在 ES5 严格模式中定义的函数,this 的值就是 undefined,而不是全局对象了。

对象创建表达式

对象创建表达式:创建对象并调用函数(构造函数)来初始化新对象的属性。和函数调用表达式很类似,只是多了一个必需的关键字 new

new Object()
new Point(2, 3)
1
2

如果对象创建表达式不需要传入参数的话,这对空的圆括号也可以省略掉。

new Object
new Date
1
2

计算对象创建表达式的值:

  • 先创建一个新的空对象。
  • 将参数传入指定的函数,并将新对象当作这个函数里的 this 的值,函数可以使用 this 来初始化这个新创建的对象的属性。
  • 如果构造函数不返回值,那么对象创建表达式的值就是这个新创建的并且被初始化了的对象。
  • 如果构造函数返回对象,那么这个返回的对象就是对象创建表达式的值,新创建的对象就被丢弃了。

运算符概述

绝大多数运算符都是标点符号,比如 +=,只有少数是由关键字表示的,比如 deleteinstanceof

操作数的个数

JavaScript 中大部分运算符都是二元运算符(也就是有两个操作数),将两个表达式合并成一个稍微复杂一点的表达式。也有一少部分运算符是一元运算符,将一个表达式转换为另一个稍微复杂一点的表达式。唯一的一个三元运算符是条件判断运算符 ? :,它将三个表达式合并成一个表达式。

操作数类型和结果类型

虽然有些运算符可以用于所有数据类型,但操作数是指定类型数据的话就更好了;并且大多数运算符返回(或计算出)的也是一个特定类型的值。比如逻辑非运算符 ! 期望的操作数就是 bool 类型,如果不是,则会先将其转换为该类型。

左值

赋值及其它几个运算符期望的操作数是 lval 类型,表示该操作数(表达式)只能出现在赋值运算符的左侧。在 JavaScript 中,变量、对象属性和数组元素都是左值。

ES规范允许内置函数返回一个左值,但自定义函数则不能返回左值:只有内置函数才可以返回变量、对象属性或数组元素。

TODO: 再次看这个概念,还是不太明白。

运算符的副作用

计算简单的表达式(比如 2*3)不会影响程序的运行状态,后续执行的状态也不会受影响。但有些表达式就会对程序有影响了,比如赋值运算符:给一个变量或属性赋值的话,后续使用这个变量或属性的表达式的值都会发生变化。自增和自减运算符也是这样,因为它们包含隐式的赋值。delete 运算符也是如此:删除一个属性就类似于(但不等于)给这个属性赋值 undefined

其它的 JavaScript 运算符都没有副作用,除了函数调用表达式和对象创建表达式:在函数体或者构造函数内部运用了这些运算符并产生了副作用的时候,就说函数调用表达式和对象创建表达式是有副作用的(TODO: 也没太看懂……)。

运算符优先级

属性访问表达式和调用表达式的优先级要比所有其它运算符都高:

typeof ( my.function )[x](y)
1

虽然 typeof 是优先级最高的运算符之一,但也是在属性访问和函数调用之后执行的。

最保险的保证优先级的方法,就是用圆括号来强行指定。

注意:乘法和除法的优先级高于加法和减法,赋值运算符的优先级非常低,通常总是最后执行的。

运算符的结合性

结合性:多个具有同样优先级的运算符表达式中的运算顺序。比如减法运算符具有从左至右的结合性,因此下面两行代码的执行效果是一样的:

w = x - y - z;
w = ((x - y) - z);
1
2

运算顺序

运算符的优先级和结合性规定了它们在复杂的表达式中的运算顺序,但并没有规定子表达式在计算过程中的运算顺序 —— JavaScript 总是严格按照从左至右的顺序来计算表达式。

w = x + y * z
1

比如在上面的表达式中,先依次计算表达式 w、x、y 和 z,然后计算 y * z,再计算 x + y * z,最后将其赋值给 w 所代表的变量或属性。

给表达式添加括号可以改变乘法、加法和赋值运算等运算符的关系,但从左至右计算子表达式的顺序是不会改变的。

只有在任一表达式具有副作用而影响到其它表达式的时候,其求值顺序才会和看上去有所不同。

var a = 1;
var b = (a++)+a;
1
2

上面的表达式计算顺序是这样的:

  1. 先计算子表达式 b 的值;
  2. 计算 a++,子表达式的计算结果为 1,但是计算完成后 a 的值已经是 2 了;
  3. 计算 a,值为 2;
  4. 计算 (a++)+a,值为 3;
  5. 将该表达式的结果 3 赋给 b。

算术表达式

基本的算术运算符:*(乘法)、/(除法)、%(取余)、+(加法)和 -(减法)。

加法之外的运算符都是在必要的时候将操作数转换为数字,然后求积、商、余数和差。无法转换为数字的操作数,都转换为 NaN。如果操作数是 NaN,算术运算的结果也是 NaN。

对于 JavaScript 来说,所有的数字都是浮点数,所以除法运算的结果总是浮点型。除数为 0 的时候,运算结果为正/负无穷大,0/0 的结果则是 NaN,所有这些运算都有结果,都不会报错。

取余运算符不限于整数,浮点数也可以,比如 6.5 % 2.1 === 0.2(实际输出 0.19999999999999973)。

+ 运算符

该运算符既可以将两个数字相加,也可以连接两个字符串。

一般来说,优先进行字符串连接。只要其中一个操作数是字符串或者转换为字符串的对象,另外一个操作数也会转换为字符串,然后加法运算符连接这两个字符串。

只有两个操作数都不是类字符串(string-like)的时候,才会进行算术加法运算。

加法运算符的表现如下:

  1. 其中一个操作数是对象的话,就会遵循对象到原始值的转换规则进行转换:日期对象通过 toString() 方法转换,其它对象则通过 valueOf() 方法转换(如果 valueOf() 方法返回一个原始值的话)。因为大部分对象都没有可用的 valueOf() 方法,所以这些对象其实还是会通过 toString() 方法进行转换。
  2. 对象转换到原始值后,如果其中一个操作数是字符串,则另一个操作数也会转换为字符串,然后加号运算符连接这两个字符串。
  3. 两个操作数都不是对象的话,就会都转换为数字(或者 NaN),然后相加。
1 + 2 // => 3: 加法
'1' + '2' // => '12': 字符串连接
'1' + 2 // => '12': 数字转换为字符串之后连接字符串
1 + {} // => '1[object Object]': 对象转换为字符串后连接字符串
true + true // => 2: 布尔值转换为数字后做加法
2 + null // => 2: null 转换为 0 之后做加法
2 + undefined // => NaN: undefined 转换为 NaN 之后做加法
1
2
3
4
5
6
7

另外,加号运算符和数字一起使用的时候,还要注意加法的结合性对运算顺序的影响。

1 + 2 + ' blind mice' // => '3 blind mice'
1 + (2 + 'blind mice') // => '12blind mice'
1
2

一元算术运算符

一元运算符作用于一个操作数并产生一个新值。JavaScript 中的一元运算符都有很高的优先级,并且都是右结合。一元运算符在必要的时候会将操作数转换为数字。

一元加法(+):该运算符把操作数转换为数字或者 NaN,并返回转换后的数字。如果操作数本身就是数字的话则直接返回(该运算符并不会改变操作数的正负号 => +(-5) === -5)。 一元减法(-):该运算符根据需要把操作数转换为数字,然后改变运算结果的符号。

递增(++):运算符将操作数(变量、数组元素或对象属性)转换为数字之后加 1,并将加 1 后的数值重新赋给变量、数组元素或对象属性。该运算符的返回值依赖于它相对于操作数的位置。运算符在操作数之前时,称为“前增量”,它将操作数加一之后,返回计算后的值。而运算符在操作数之后的话,称为“后增量”运算符,它对操作数进行加一运算,但返回的是加一之前的值。

var i = 1, j = ++i; // => i: 2, j: 2
var i = 1, j = i++; // => i: 2, j: 1
1
2

这里还要注意区分 ++xx = x + 1++运算符只执行数值运算操作,而 + 运算符有可能执行字符串连接操作。

var x = '1'; ++x; // => 2
var x = '1'; x = x + 1; // => '11'
1
2

此外,由于 JavaScript 会自动进行分号补全,所以不能在后增量运算符和操作数之间插入换行符。如果插入了换行符,JavaScript 会把操作数当作一条单独的语句,并在其之前补上一个分号。

var x = 1;
x
++ // => x: 1
1
2
3

递减(--):与递增运算符相同。

位运算符

先 pass 这一小节。

关系表达式

关系运算符用于测试两个值之间的关系(比如“相等”、“小于”,或者“是……的属性”),并根据关系是否存在而返回 true 或者 false。

相等和不等运算符

===== 运算符都用于比较两个值是否相等,都允许任意类型的操作数。

=== 也称为“严格相等运算符”,

!=!== 运算符是 ===== 运算符的求反:如果两个值通过 ===== 比较的结果为 true,则 !=!== 的比较结果则为 false,反之亦然。

在 JavaScript 中,比较两个对象时,比较的是引用而不是值。对象只和它本身相等,和其它任何对象都不相等:即使具有相同数量的属性、相同的属性名和值也依然是不相等的。数组也是如此。

严格相等运算符 === 的比较流程如下:

  • 如果两个值的类型不相等,则它们不相等。
  • 如果两个值都是 null 或者 undefined ,则它们不相等。
  • 如果两个值都是布尔值 true 或 false,则它们相等。
  • 只要有一个值是 NaN,它们就不相等:NaN 和自身也不相等。所以可以用 x !== x 来判断是否为 NaN。
  • 如果两个值为数字且相等,则它们相等。0 和 -0 也相等。
  • 如果两个值为字符串,且各个对应位上的 16 位数完全相等,则它们相等。如果它们的长度或内容不同,则不相等。具有不同编码的 16 位值的两个字符串,所显示的字符可能是一样的。JavaScript 并不对 Unicode 进行标准化的转换,所以这样的字符通过 ===== 的比较结果也是不相等的。(TODO: 这里需要进一步了解,貌似有个有关特殊网址的 0day 漏洞应用的就是这个知识点?)
  • 如果两个引用值均指向同一个对象、数组或函数,则它们相等。如果指向的是不同的对象,肯定不相等。

相等运算符 == 的判断就没那么严格了,如果两个操作数类型不同,相等运算符会先转换为相同类型,再进行比较:

  • 如果两个操作数类型相同,则和上文所述的严格相等的比较规则和结果一样。
  • 如果两个操作数类型不相同,则依据如下规则进行转换和比较:
    • 如果一个值是 null,另一个是 undefined,则它们相等。
    • 如果一个值是数字,另一个是字符串,则先将字符串转换为数字,然后比较两个数字。
    • 如果一个值是 true,则将其转换为 1 再进行比较;为 false 的话,则转换为 0 再进行比较。
    • 如果一个值是对象,另一个值是数字或字符串,则按照对象转换为原始值的规则,先将对象转换为原始值,再进行比较。JavaScript 语言核心的内置类中,只有对象使用 toString() 转换,其余类都是先尝试使用 valueOf() 转换,再尝试使用 toString() 转换。非核心的对象,则通过各自定义的方法转换为原始值。
    • 其它不同类型之间的比较均不相等。

'1' == true 为例:布尔值 true 首先转换为数字 1,然后字符串 '1' 也转换为数字 1,最后两个数字 1 相等,因此比较结果为 true。

比较运算符

虽然比较运算符的操作数可以是任意类型,但只有数字和字符串才能真正进行比较。其它类型的操作数的转换规则如下:

  • 操作数为对象,则首先尝试用 valueOf() 转换成一个原始值,否则再用 toString() 的转换结果进行比较。
  • 对象转换为原始值之后,如果两个操作数都是字符串,则依照字母表的顺序对两个字符串进行比较。这里的字母表顺序,是指组成字符串的 16 位 Unicode 字符的索引顺序。
  • 对象转换为原始值之后,如果至少有一个操作数不是字符串,则两个操作数都转换为数字。0 和 -0 相等,Infinity 大于任何其它数(除了自己),-Infinity 小于任何其它数(除了自己)。如果其中一个操作数是(或者转换后是)NaN,则比较结果为 false。

注意:由于 JavaScript 字符串是由 16 位整数值组成的序列,所以字符串的比较就是这些数值的比较。而 Unicode 字符编码的顺序和传统字符编码顺序不太一样,其中所有大写的 ASCII 字母都“小于”小写字母。而 String.localCompare() 则使用本地语言的字母表中定义的字符次序进行比较,更符合用户习惯。

另外,对于数字和字符串来说,加号运算符和比较运算符也有所不同:加号运算符更偏爱字符串,至少有一个操作数是字符串,它就会进行字符串连接操作;比较运算符则更偏爱数字,只要有一个操作数是数字,它就进行数学加法操作。

1 + 2 // => 3
'1' + '2' // => '12'
'1' + 2 // => '12'
11 < 3 // => false
'11' < '3' // => true
'11' < 3 // => false
'one' < 3 // => false
1
2
3
4
5
6
7

最后,<=>= 则判断相等的时候,只是简单的“不大于”和“不小于”,并不依赖于相等运算符和严格相等运算符的比较规则。仅有一个例外:当其中一个操作数是(或者转换后是)NaN 的时候,所有 4 个比较运算符均返回 false。

in 运算符

该运算符检查右侧的对象是否拥有一个名为左侧操作数的值的属性名,或者检查右侧的数组是否包含左侧的索引值。

var point = { x: 1, y: 1 };
'x' in point // => true: 对象包含属性 x
'z' in point // => false: 对象不包含属性 z
'toString' in point // => true: 对象继承了 toString() 方法

var data = [7, 8, 9];
0 in data // => true: 数组包含索引为 0 的元素
'1' in data // => true: 数组包含索引为 1 的元素
3 in data // => false: 数组不包含索引为 3 的元素
7 in data // => false: 数组不包含索引为 7 的元素
1
2
3
4
5
6
7
8
9
10

instanceof 运算符

该运算符检查左操作数对象是否为右操作数构造函数(不含函数名之后的括号 ())的实例。

var d = new Date(); // => 通过 Date() 构造函数来新建一个对象
d instanceof Date // => true: d 是由 Date() 构造函数创建的,所以是 Date 这个构造函数的实例
d instanceof Date() // => VM224:1 Uncaught TypeError: Right-hand side of 'instanceof' is not an object
d instanceof Object // => true: 所有的对象都是 Object 的实例
d instanceof Number // => false
1
2
3
4
5

所有的对象都是 Object 的实例,而通过 instanceof 判断一个对象是否是一个类的实例的时候,这个判断也会包含对“父类”(superclass)的检测(TODO: 没太看懂……)。

instanceof 的左操作数不是对象的话,则返回 false;右操作数不是对象所属的类的话,就会抛出一个类型异常,看上面代码的第三就行(TODO: 不正确,待修正)。

要理解 instanceof 是如何工作的,必须首先理解“原型链”(prototype chain)——即 JavaScript 的继承机制。

计算表达式 o instanceof f 时,JavaScript 首先计算 f.prototype,然后在原型链中查找 o。如果能找到,则 of(或者 f 的子类)的一个实例,表达式返回 true;否则就说明 o 不是 f 的实例,表达式返回 false。

逻辑表达式

逻辑与(&&)运算符

该运算符可以对两个布尔值执行布尔与(AND)操作,也可以对真值和假值进行布尔与操作。在对真值/假值进行布尔与操作的时候,会先计算左操作数的值,只有左操作数为真值时,才会进一步计算右操作数。这个规则,也叫“短路”(short circuiting)。

逻辑或(||)运算符

该运算符也有类似的表现:如果左操作数为真,就不会再计算右操作数了。它的一种常用方式,是从一组备选表达式中选出第一个真值表达式:

// 将 o 的成员属性复制到 p 中,并返回 p
function copy(o, p) {
    p = o || {}; // 如果未向参数 p 传入对象,则使用一个新建的空对象。
    // 函数体主逻辑
}
1
2
3
4
5

逻辑非(!)运算符

该运算符首先将操作数转换为布尔值,然后再对布尔值求反。结合这个特性,就可以用 !! 求出操作数的等价布尔值。

赋值表达式

其左操作数是一个左值:变量或者对象属性(或数组元素),右操作数可以是任意类型的任意值,且赋值表达式的值就是右操作数的值。

赋值操作符的结合性是从右至左,也就是说,下面的代码,先给 k 赋值,再给 j 赋值,最后给 i 赋值。

i = j = k = 0;
i++ = ++j = 0; // => 报错:自增表达式不能作为左值
1
2

带操作的赋值运算

下面的两个表达式是等价的:

total += sales_tax
total = total + sales_tax
1
2

所有带操作的赋值运算:+=-=*=/=%=<<=>>=>>>=&=|=^=

a op= b
a = a op b
1
2

在上面的两个表达式中,第一行的 a 只计算了一次,而第二行的 a 计算了两次。如果 a 有副作用(比如函数调用和赋值),在使用该表达式的时候就需要注意一下。

表达式计算

TODO: 本小节暂时跳过。

其它运算符

条件运算符(?:)

该运算符常常可用来判断变量是否已定义,如果已定义则使用它,否则使用一个默认值:

var greeting = 'hello ' + (username ? username : 'there');
1

typeof 运算符

该运算符获取操作数的类型。

x typeof x
undefined 'undefined'
null 'object'
true / false 'boolean'
任意数字或 NaN 'number'
任意字符串 'string'
任意函数 'function'
任意内置对象(非函数) 'object'
任意宿主对象 由编译器各自实现的字符串,但不是 'undefined'、'boolean'、'number' 或者 'string'

常见用法:(typeof value == 'string') ? '"' + value + '"' : value

typeof 运算符也可以像函数一样带上圆括号,带不带括号,运行结果都是一样的。

另外,由于 typeof null 的返回结果也是 object,所以需要区分 null 和对象的时候,还要用 !!value 单独判断一下。

此外,客户端 JavaScript 中的大多数宿主对象都是 object 类型。

如果想进一步区分对象的类,就需要使用 instanceof 运算符、class 特性以及 constructor 属性。(TODO: 这个话题可以把相关的内容总结一下,形成一篇文章。)

delete 运算符

该运算符用来删除对象的属性或者数组的元素。删除后的属性或者元素将不再存在,可以用 in 运算符来检查属性(名称)或者数组元素(索引)是否还存在。

var o = { x: 1, y: 2 };
delete o.x; // => true: 成功删除属性
'x' in o; // => false: 属性不再存在
'y' in o; // => true: 该属性还存在

var a = [4, 5, 6];
delete a[2]; // => true: 成功删除元素
2 in a; // => false: 元素索引不再存在
1 in a; // => true: 该元素索引还存在
a.length // => 3: 数组长度未变!删除操作虽然删除了元素,但是并没有修改数组长度,这样就产生了一个稀疏数组。所以该运算符在操作数组元素时要慎用。
1
2
3
4
5
6
7
8
9
10

如果 delete 的操作数不是左值,则运算符将不作任何操作并返回 true;否则,运算符将试图删除这个左值,成功的话返回 true。诸如内置核心属性、客户端属性、var 语句声明的属性、function 语句定义的函数和函数参数都不能删除。

在 ES5 严格模式中,如果 delete 的操作数是变量、函数参数或函数参数之类的非法操作数,则该操作将抛出一个语法错误(SyntaxError)异常;在删除不可配置的属性时,会抛出一个类型错误异常,只有操作数是属性访问表达式时才可删除。在非严格模式下,这些操作都不再报错,而只是简单地返回 false。

var o = { x: 1, y: 2 };
delete o.x; // => true: 成功删除对象属性
typeof o.x; // => "undefined": 属性不再存在
delete o.x; // => true: 删除不存在的属性也返回 true
delete o; // => false: 不能删除 var 语句声明的变量
delete 1; // => true: 参数不是左值,删除失败,但也返回 true
this.x = 1; // => 1: 给全局对象定义一个属性
delete x; // => true: 非严格模式下可删除,严格模式下需改成 delete this.x
x // => 运行时错误,x 未定义
Uncaught ReferenceError: x is not defined
1
2
3
4
5
6
7
8
9
10

void 运算符

一元运算符 void 出现在任意类型的操作数之前,操作数会照常计算,但表达式永远返回 undefined。由于 void 会忽略操作数,所以操作数有副作用的时候可以搭配该运算符使用。

该运算符常常用在客户端的 URL——javascript:URL 中,在 URL 可以放心地写带有副作用的表达式,void 则用来让浏览器不显示表达式的计算结果。

<a href="javascript:void window.open();">打开一个新窗口</a>
1

但是在实际开发中其实不会这么写,而是给该节点的 onclick 绑定一个事件处理程序。

逗号运算符(,)

逗号运算符是二元运算符,它依次计算左操作数和右操作数的值,然后返回右操作数的值。该运算符最常用的场景在 for 循环中:

for ( var i = 0, j = 10; i < j; i++, j--) {
    console.log(i + j);
}
1
2
3

语句

在 JavaScript 中,表达式是短语,语句(statement)就是整句或者命令。表达式只是计算出一个值,只有语句才能用来执行以让某件事发生。

“让某件事发生”的方法之一,是计算带有副作用的表达式,也叫表达式语句,类似的语句还有声明语句。

默认情况下,JavaScript 解释器按照语句的编写顺序依次执行。另一种“让某件事发生”的方法则是改变语句的默认执行顺序,JavaScript 中有很多语句和控制结构可以改变语句的默认执行顺序。

本质上,JavaScript 程序就是一些以分号分隔的语句的集合。只要掌握了语句,就可以开始编写程序了。

表达式语句

具有副作用的表达式是 JavaScript 中最简单的语句:包括赋值语句(含自增/自减)、delete 语句、函数调用语句。

复合语句和空语句

逗号运算符用于将几个表达式连接成一个表达式,而花括号则可以将几条语句联合成一条复合语句(compound statement),也叫做语句块。

注意

  1. 语句块的结尾不需要分号,只有块内的原始语句才需要。
  2. 为了代码便于阅读,建议语句尽使用缩进。
  3. JavaScript 中没有块级作用域,语句块中声明的变量不是语句块私有的。

空语句则只有一个引号,有时它还是有用的:

// 初始化数组a
for (var i = 0; i < a.length; a[i++] = 0) ;
1
2

这样就不需要在循环体里再写语句了,多方便。不过出于特殊目的需要使用空语句的时候,最好在代码中添加注释,便于理解。

声明语句

var

var 语句用来声明至少一个变量:

var name_1 [= value_1] [, ... , name_n [= value_n]];
1

声明多个变量时,用逗号分隔的变量可以独占一行。

var x = 2,
    f = function(x) { return x * x; },
    y = f(x);
1
2
3

声明的变量如果没有赋值,则值为 undefined。由于 JavaScript 声明提前特性的存在,脚本或函数中定义的变量,声明语句其实都会被“提前”至脚本或函数的顶部,但是初始化操作还是在原来的位置上执行的。

function

前面讲过了函数定义表达式,函数定义还可以写成语句的形式。

function f(x) { return x + 1; } // 函数声明语句
var f = function(x) { return x + 1; } // 函数定义表达式
1
2

TODO: 这两种函数定义方式的差异,可以参考徐老师的文章:两种定义函数方式的差异

定义函数时,并不会执行函数体里的语句,这些语句只和调用函数时待执行的新函数对象相关联。

注意:function 语句里的花括号是必需的,即使只有一条语句也是如此。

如果函数内还嵌套了函数,则内部嵌套的函数,其定义不能出现在 if 语句、while 循环或任何其它语句中,只能出现在所嵌套函数的顶部(TODO: 没太看懂……为什么不能出现?)。由于函数声明位置的这种限制,ES 规范因此并没有将函数声明归类为真正的语句。

函数声明语句创建的变量和 var 语句创建的变量一样,都是无法删除的。但是函数声明语句创建的这些变量不是只读的,可以被重写。

函数声明语句和函数定义表达式的区别:

见“函数”这一章的“函数定义”小节。

条件语句

和其它语言的基本一样,只列出几个关键点。

对于 switch 语句,关键字 switch 里的表达式和 case 子句中的表达式在比较的时候,用的是 === 这个严格相等运算符来比较的,不会做任何类型转换,这一点要注意。

另外,switch 语句中的 case 表达式是在运行时计算的,从而导致该语句的效率会比较低。

循环

for 循环的工作流程,可以用一个等价的 while 循环来理解:

initialize;
while(test) {
    statement
    increment;
}
// 等价于
for(initialize; test; increment) { statement; }
1
2
3
4
5
6
7

下面的 for 循环活用了空语句来遍历链表数据结构,并返回链表中最后一个对象(即第一个不包含 next 属性的对象):

function tail(o) {
    for (; o.next; o = o.next) /* empty */ ;
    return o;
}
1
2
3
4

for 循环适合于遍历数组元素,而 for/in 循环则适合用于遍历对象属性成员(通过属性名来遍历):

for (var p in o) {
    console.log(o[p]);
}
1
2
3

该循环的执行流程:

for (variable in object)
    statement
1
2
  1. JavaScript 解释器首先计算 object 表达式,表达式如果为 null 或者 undefined,则解释器会跳过循环并执行后续的代码。
  2. 如果表达式等于一个原始值,则原始值会被转换为与之对应的包装对象。
  3. 否则表达式本身就是一个对象,则依次枚举对象的属性来执行循环。
  4. 在每次循环之前,JavaScript 都会先计算 variable 表达式的值,并将属性名(字符串)赋给它。

注意:for/in 循环中,variable 的值只要可以当作赋值表达式左值,就可以是任意表达式。比如下面这段代码将对象的所有属性名称复制到一个数组中:

var o = { x: 1, y: 2, z: 3 };
var a = [], i = 0;
for (a[i++] in o) /* empty */;
1
2
3

因为 JavaScript 中的数组本质上也是对象,所以 for/in 循环可以像枚举对象的属性一样,枚举数组的索引。接着上面的代码,可以枚举数组的索引:

for ( i in a ) console.log(i);
1

还要注意,for/in 循环并不会遍历对象的所有属性,只会遍历“可枚举”(enumerable)的属性。JavaScript 语言核心部分所定义的内置方法就不可枚举,比如 toString() 方法。而在代码中用户自定义的所有属性和方法都是可枚举的(除了通过特殊手段设置的属性)。从其他自定义对象继承而来的自定义属性也是可以用 for/in 枚举出来的。

如果在 for/in 循环中删除了一个还未被枚举的属性,则该属性将不再被枚举。如果在 for/in 循环中定义了新属性,这些属性通常也不会被枚举。

属性枚举的顺序

TODO: 暂时先不深入该知识点。

主流浏览器厂商实际上是按照属性定义的先后顺序来枚举简单对象的属性的。如果用直接量创建对象,则按照直接量中属性出现的顺序来枚举。

而对于下面的情况,枚举的顺序则取决于具体的实现(并且还是非交互的):

  • 对象继承了可枚举属性;
  • 对象具有整数数组索引的属性;
  • 使用 delete 删除了对象已有的属性;
  • 使用 Object.defineProperty() 或者类似的方法改变了对象的属性。

跳转

标签语句

JavaScript 中,语句也是可以加标签的,用做标签的标识符必须是合法的,不能是保留字。

identifier: statement
1

由于标签的命名空间和变量或函数的命名空间是不同的,所以语句标签和变量名或函数名可以同名。

语句标签只在它起作用的语句内有定义。两个互不嵌套的代码段里的语句标签可以同名,和它内部的语句标签则不能同名。

一条语句可以有多个标签。

给语句定义标签之后,就可以在程序的任何地方通过标签名引用该语句。虽然也可以给多条语句定义标签,但只有在给语句块定义标签时才更有用。

通过给循环定义一个标签,可以在循环体内部使用 breakcontinue 来退出循环,或者直接跳到下一个循环的开始处。breakcontinue 是 JavaScript 中仅有的可以使用标签的语句。

break 语句

单独使用 break 会立即退出最内层循环或 switch 语句:break;

break 和标签一起使用时,程序将跳转到这个标签所标识的语句块的结束处,或者直接终止闭合语句块的执行。由于 break 本身就能直接退出循环或 switch 语句,因此如果要和标签一起使用的话,标签应该标识的是由花括号括起来的一组语句。

var matrix = getDate(); // 从某处获取一个二维数组
// 对矩阵所有元素求和
// 从标签名开始,以便报错时退出程序
compute_sum: if (matrix) {
    for (var x = 0; x < matrix.length; x++) {
        var row = matrix[x];
        if (!row) break compute_sum;
        for (var y = 0; y < row.length; y++) {
            var cell = row[y];
            if (isNaN(cell)) break compute_sum;
            sum += cell;
        }
    }
    success = true;
}
// break 语句跳转至此
// 如果在 success == false 的条件下到达这里,说明给出的矩阵中有错误
// 否则将矩阵中所有的元素进行求和
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

最后还要注意:break 语句的控制权无法越过函数边界,即不能从函数内部跳转到函数外部。

continue 语句

continue 语句直接执行下一次循环。在 for 循环中,首先计算自增表达式,然后检测 test 表达式,用以判断是否执行循环体。

break 语句不同的是,continue 语句只能在循环体内使用。

return 语句

return 语句指定函数调用后的返回值,它只能在函数体内出现,否则就会报语法错误。执行到该语句时,函数就会停止执行,并返回其右侧的表达式(如果有的话)的值给调用程序。

throw 语句

在 JavaScript 中产生运行时错误或程序使用 throw 语句时都会显式抛出异常。该语句的表达式可以是任意类型,但解释器抛出异常时通常采用 Error 类型或其子类型。

一个 Error 对象包含:表示错误类型的 name 属性,存放传递给构造函数的字符串的 message 属性。

  • 抛出异常时,JavaScript 解释器会立即执行当前正在执行的逻辑,并跳转至就近的异常处理程序。
  • 如果抛出异常的代码块没有相关联的 catch 从句,解释器会检查更高层的闭合代码块,直到找到异常处理程序为止。
  • 如果抛出异常的函数没有一个 try/catch/finally 语句来处理它,则异常将向上传播至调用该函数的代码。这样异常就会沿着方法的词法结构和调用栈向上传播。
  • 如果最后都没有找到任何异常处理程序,JavaScript 就会把异常当成程序错误来处理,并报告给用户。

try/catch/finally 语句

TOD: 这个知识点在必要的时候需要重新弄清楚。

需要处理异常的代码块位于 try 从句中,catch 从句跟随在 try 从句之后,处理 try 从句中的异常。最后跟着 finally 从句,放置清理代码,这里的代码总是会被执行到,只要 try 从句中有一部分代码执行了。

catchfinally 至少要存在一个,并且三个从句里的代码块都要用花括号括起来。

如果 returncontinuebreak 或者 throw 语句使 finally 块发生了跳转,或者它调用了会抛出异常的方法使其发生了跳转,解释器都会忽略所挂起的跳转,而去执行新的跳转(TODO: 后面的例子能看懂,但是这里的文字不太懂……)。比如 finally 从句如果抛出了异常,则该异常会代替之前正在处理的异常。如果 finally 从句运行到了 return 语句,则该方法会正常返回,之前所抛出且未处理的异常将不会被处理。

try {
  try {
    throw new SyntaxError();
  } catch (ex) {
    ;
  } finally {
    throw new RangeError();
  }
} catch (ex) {
  console.info(ex.name);
} finally {
  console.info('cleaned');
}
// => RangeError
// => cleaned
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

其它语句类型

with 语句

with 语句用于临时扩展作用域链,它将对象添加到作用域链头部,执行语句之后,再把作用域链恢复到原始状态。

with (object)
    statement
1
2

由于使用 with 语句的代码很难优化,并且运行速度也慢,所以非严格模式里不推荐使用,严格模式更是禁止使用。

对象嵌套层次很深的时候,可以用 with 来简化代码。下面第一行的表达式如果则代码中多次出现,则可以用后面的 with 语句将 form 对象添加至作用域链的顶层:

document.forms[0].address.value;
with (document.forms[0]) {
    // 直接访问表单元素:
    name.value = "";
    address.value = "";
    email.value = "";
}
1
2
3
4
5
6
7

注意:只有在查找标识符的时候才会用到作用域链,创建新变量的时候是用不着的:

with (o) x = 1;
1

对于上面的代码,如果对象 o 有属性 x,则将会赋值 1;否则就是在当前作用域内新建变量 x 并赋值 1。

debugger 语句

在调试程序的时候,该语句可以让代码停在当前位置。比如调用函数 f() 时使用了未定义的参数,函数抛出异常,但无法定位是哪里抛出了异常,这个时候就可以利用 debugger 了:

function f(o) {
    if (o == undefined) debugger; // 用于临时调试
    ... // 函数的其它部分
}
1
2
3
4

"use strict"

这是 ES5 引入的一条指令,它和普通的语句有两个重要区别:

  • 它不包含任何关键字。
  • 它只能出现在脚本代码的开始处或者函数体的开始处,或者任意实体语句之前。但它不必非得出现在脚本或函数体的首行。

该指令说明后续的代码将会解析为严格代码(strict code)。严格模式和非严格模式之间的区别仅列出最重要的前三条:

  • 严格模式中禁止使用 with 语句。
  • 严格模式中,所有的变量都要先声明再赋值,如果未声明就赋值,将会抛出引用错误异常(在非严格模式中,这种写法会给全局对象添加新属性)。
  • 严格模式中,调用的函数(不包括方法)中的 this 的值是 undefined(在非严格模式中,调用的函数中的 this 的值总是全局对象)。可以利用这种特性来判断当前是否支持严格模式:
var hasStrictMode = (function() { "use strict"; return this === undefined; }());
1

对象

前言

对象可以看做是属性的无序集合,每个属性都是一个名/值对。

由于属性名只能是字符串,因此也可以把对象看成是从字符串到值的映射。

但是!对象不仅仅是从字符串到值的映射。对象不仅可以保持自有属性,还可以从原型对象继承属性。对象的方法一般都是继承而来的属性(不然什么方法都要自己写,还要你 JS 干嘛?)。这种“原型继承”是 JavaScript 的核心特征

JavaScript 中的值,只有字符串、数字、布尔值、null 和 undefined 不是对象。而且其中的字符串、数字和布尔值的行为还和不可变对象非常类似。

对象是可变的,我们通过引用来操作对象(可以参考 JavaScript 究竟是如何传值的? 中提到的几篇文章)。

常见的关于对象的用法,是对对象的属性进行创建(create)、设置(set)、查找(query)、删除(delete)、检测(test)、枚举(enumerate)这几项操作(之前读犀牛书的时候没看到这句话,这次看到了,还看到图书内容就是按这几种操作进行分类的,说明之前的阅读效果不够好啊)。

对象的属性由属性名和属性值(以及“属性特性——property attribute”)组成。

  • 属性名可以是任意字符串,包含空字符串(如果有空字符串会怎样?)。
  • 属性名不能相同(会自动用新值覆盖旧值?)。
  • 属性值可以是 JavaScript 中的值,也可以是 getter 或者 setter 函数(或两者都是,如何实现两者都是?)。

对象的属性特性包括:

  • 可写:设置属性的值。
  • 可枚举:通过 for/in 循环得到该属性。
  • 可配置:删除或修改该属性。

对象不仅包含属性,它还有三个相关的对象特性(object attribute):

  • 原型:指向另一个对象,本对象会继承其原型的属性(TODO: 只限于可继承属性?)。
  • 类:标识对象类型的字符串。
  • 扩展标记:明确了在 ES5 中是否可以向该对象添加新属性。

创建对象

对象直接量

对象的属性名可以是标识符也可以是字符串直接量(空字符串也可以),属性的值可以是任意类型的表达式,表达式的值就是属性的值。

var empty = {}; // 没有任何属性的空对象
var point = { x: 1, y: 1 }; // 两个属性
var point2 = { x: point.x, y: point.y+1 }; // 更复杂的值
var book = {
    "main title": "JavaScript", // 属性名有空格,必须用字符串表示
    "sub-title": "The Definitive Guide", // 属性名有连字符,必须用字符串表示
    "for": "all audiences", // "for"是保留字,必须用引号
    "author": { // 该属性的值是一个对象
        firstname: "David", // 这里的属性名都没有引号
        surname: "Flanagan"
    }
};
1
2
3
4
5
6
7
8
9
10
11
12

对象直接量是表达式,这个表达式的每次运算都会创建并初始化一个新的对象。每次计算对象直接量时,也都会计算它的每个属性的值。因此,如果在一个重复调用的函数中的循环体内使用了对象直接量,它将创建很多新对象,并且每次创建的对象的属性值也有可能不同。

通过 new 创建新对象

关键字 new 的后面跟随一个函数调用,用来创建并初始化新对象。这里的函数为构造函数,语言核心中的原始类型都包含构造函数。也可以用自定义的构造函数来初始化新对象。

var o = new Object(); // 创建一个空对象,和 {} 一样
var a = new Array(); // 创建一个空数组,和 [] 一样
var d = new Date(); // 创建一个表示当前时间的 Date 对象
var r = new RegExp("js"); // 创建一个可以进行模式匹配的 RegExp 对象
1
2
3
4

原型

TODO: 没太看明白……

在 JavaScript 中,每个对象(除了 null)都和另一个对象相关联——另一个对象就是原型。每个对象都从原型继承属性。

通过对象直接量创建的所有对象具有共同的原型对象,并且可以通过 Object.prototype 获得对原型对象的引用。通过关键字 new 及构造函数所创建的对象,其原型就是构造函数的 prototype 属性的值。所以 new Object() 所创建的对象也继承自 Object.prototypenew Array() 创建的对象其原型就是 Array.prototype

只有少数对象没有原型,包括 Object.prototype,它不继承任何属性。其他的原型对象都是普通对象,普通对象都有原型。所有的内置构造函数以及大部分自定义的构造函数,都有一个继承自 Object.prototype 的原型,比如 Date.prototype 的属性就继承自 Object.prototype。因此,由 new Date() 创建的 Date 对象的属性,同时继承自 Date.prototypeObject.prototype,这一系列链接起来的原型对象就是所谓的“原型链”(prototype chain)。

Object.create()

ES5 中定义了该方法用于创建对象。第一个参数为对象的原型,第二个可选参数用于进一步描述对象的属性。

Object.create() 是一个静态函数,也就是说它不能被某个对象作为方法调用:

var o1 = Object.create({x:1, y:2}); // o1 继承了属性 x 和 y
1

可以传入参数 null 来创建一个没有原型的对象,这个对象不会继承任何东西,包括 toString() 这样的基础方法。

var o2 = Object.create(null); // 不继承任何属性和方法,在浏览器中输入o2,然后再输入一个点号的话,不会有任何自动完成的提示
1

如果想创建一个普通的空对象(就像 {} 或者 new Object() 创建的对象),要传入参数 Object.prototype

var o3 = Object.create(Object.prototype); // 和 {} 及 new Object() 一样
1

还可以通过任意原型创建新对象(也就是可以使任意对象可继承),下面的代码就模拟了原型继承:

// TODO: 为什么是让一个空构造函数的原型为p?而不是让一个空对象原型为p?这是用构造函数新建对象的方式?
function inherit(p) {
    if (p == null) throw TypeError(); // p必须是非null的对象
    if (Object.create) return Object.create(p); // 该方法存在时,则直接使用
    var t = typeof p; // 否则进一步检测
    if (t !== "object" && t !== "function") throw TypeError();
    function f() {}; // 定义空构造函数
    f.prototype = p; // 令其原型为 p
    return new f(); // 用 f() 创建 p 的继承对象
}
1
2
3
4
5
6
7
8
9
10

上面创建的 inherit() 函数,作用之一就是防止库函数无意间修改了不受控制的对象。该方法不是直接将对象作为参数传入函数,而是将目标对象的继承对象传给函数(令返回的对象的原型为传入的对象)。这样函数在读取继承对象属性的时候,读取的就是继承过来的值。在给继承对象的属性赋值的时候,就只影响继承对象自身,不会影响原始对象了:

var o1 = { x: 1 };
var o2 = inherit(o1);
o2.x = 2;
o2.x // => 2
o1.x // => 1: o2 继承自 o1,修改 o2 的属性 x,没有影响 o1 中的同名属性
1
2
3
4
5

属性的查询和设置

在 JavaScript 中,可以通过点号(.)或者方括号([])来获取或者设置属性的值:

var author = book.author; // 获取 book 的 "author" 属性
var title = book["main title"]; // 获取 book 的 "main title" 属性
book.edition = 6; // 设置 book 的 edition 属性
book["main title"] = "ECMAScript"; // 设置 book 的 "main title" 属性
1
2
3
4

使用点运算符时,右侧必须是以属性名称命名的简单标识符;对于方括号来说,方括号内必须是一个计算结果为字符串(或是一个可以转换为字符串的值)的表达式。

作为关联数组的对象

上面讲到的两种属性查询方法都能够查询对象属性的值,方括号的形式看起来更像数组,只是这个“数组”——对象的元素是通过字符串索引的,这种数组就叫关联数组(associative array)。

在一些强类型语言中(如C、C++、Java等),对象的属性都是提前定义好的,无法动态增删。而 JavaScript 由于是弱类型语言,因此可以在任何对象中创建任意数量的属性。通过点号 . 访问对象属性时,属性名称为标识符,而标识符不是数据类型,因此无法修改。

而用方括号 [] 来访问对象属性时,此时的属性名为字符串,字符串又是 JavaScript 的数据类型,所以在程序运行时可以修改或创建它们。假设要给 portfolio 这个对象动态添加新的属性——股票,就可以用 [] 运算符来实现:

function addstock(portfolio, stockname, shares) {
    portfolio[stockname] = shares;
}
1
2
3

再结合 for/in 循环,就可以很方便地遍历关联数组(对象)了:

function getValue(portfolio) {
    var total = 0.0;
    for (stock in portfolio) { // 遍历 portfolio 中的每只股票
        var shares = portfolio[stock]; // 获取每只股票的份额
        var price = getQuote(stock); // 查找股票价格
        total += shares * price; // 将结果累加至 total 中
    }
    return total; // 返回 total 的值
}
1
2
3
4
5
6
7
8
9

继承

JavaScript 中的对象,既有自有属性(own property),也有从原型对象继承来的属性。在查询对象 o 的属性 x 时,如果 o 中不存在 x,就会继续在 o 的原型对象中查询属性 x。如果原型对象也没有 x,但这个原型对象还有原型,就会继续在这个原型对象的原型中查询,直到找到 x 或者找到原型为 null 的对象为止。对象及其原型构成了一个“链条”,通过这个链条就实现了属性的继承。

var o = {}; // o 通过这种形式从 Object.prototype 继承对象
o.x = 1; // 给 o 定义属性 x
var p = inherit(o); // p 继承 o 和 Object.prototype
p.y = 2; // 给 p 定义属性 y
var q = inherit(p); // q 继承 p、o 和 Object.prototype
q.x = 3; // 给 q 定义同名属性 x
var s = q.toString(); // toString 继承自 Object.prototype
q.x + q.y; // => 3: x 用的是 q 中的自有属性,y 则继承自对象 p
1
2
3
4
5
6
7
8

假设现在要给对象 o 的属性 x 赋值,如果 o 中已经有了自有属性 x,则赋值操作就只改变这个自有属性的值。如果 o 中不存在属性 x,则赋值操作就给 o 添加一个新属性 x 并赋值。如果 o 的原型对象中有属性 x,那么在 o 中新建的属性 x 就会覆盖原型对象中的同名属性。

给属性赋值时,首先会检查原型链,确认是否允许赋值。如果 o 继承自一个只读属性 x,则赋值操作是不允许的。如果允许赋值,也总是在原始对象上创建属性或对已有的属性赋值,并不会修改原型链——不会修改原型对象中的同名属性。因此,只有在查询属性时才能体会到继承的存在,设置属性就和继承无关了,这样可以让程序员选择性地覆盖(override)继承的属性。

var unitcircle = { r: 1 }; // 用于继承的对象
var c = inherit(unitcircle); // c 继承了属性 r
c.x = 1; c.y = 1; // c 新定义两个属性
c.r = 2; // c 覆盖了继承来的属性
unitcircle.r; // => 1: 原型对象未被修改
1
2
3
4
5

给属性赋值,有几种可能的结果:要么失败,要么创建一个属性,要么在原始对象中设置属性(TODO: 是指修改原始对象现有属性的值?),只有一种例外:如果 o 继承了属性 x,而属性 x 是一个具有 setter 方法的 accessor 属性,这时将调用 setter 方法,而不会给 o 创建属性 x。注意:调用 setter 方法的将是 o,而不是定义这个属性的原型对象;因此,如果 setter 方法定义了属性的话,这个定义属性的操作是作用在 o 上的,而不是去修改原型链。

属性访问错误

本节讲讲查询或设置属性时,一些出错的情况。

先讲讲查询属性:查询不存在的属性时不会报错,在对象的原型链上查找不存在的属性时,返回 undefined。

book.subtitle; // => undefined: 属性不存在
1

但是,查询一个不存在的对象的属性时,就会报错了。null 和 undefined 都没有属性,所以查询它俩的属性就会报错:

var len = book.subtitle.length;
// 抛出一个类型错误异常,说 undefined 没有 length 属性
// => TypeError: Cannot read property 'length' of undefined
1
2
3

为了避免出错,可以用下面两种方法查询属性:

// 第一种方法有些罗嗦,但容易看懂
var len = undefined;
if (book) {
    if ('subtitle' in book) len = book.subtitle.length;
} else {
    // Do something...
}
// ↑↑↑ 注意:book 对象不存在时,这样的代码还是会报错
// Uncaught ReferenceError: book is not defined
// 所以要用 try...catch 之类的错误处理语句进行处理

// 第二种方法则比较简练
var len = book && book.subtitle && book.subtitle.length;
//问题同上
1
2
3
4
5
6
7
8
9
10
11
12
13
14

再讲讲设置属性:给 null 和 undefined 设置属性肯定会报类型错误,给只读属性设置值也会报错;但还有些不允许新增属性的对象,对其设置属性时,失败了却不会报错:

// 内置构造函数的原型是只读的
Object.prototype = 0; // 赋值失败,但没有报错,Object.prototype 没有被修改
1
2

这是个历史遗留问题,在 ES5 的严格模式中已经修复了。在严格模式中,任何失败的属性设置操作都会抛出一个类型错误异常。

给对象 o 设置属性 p 时,会失败的场景总结如下(TODO: 这些先记下来,回头用的时候再弄清楚):

  • o 中的属性 p 是只读的:不能给只读属性重新赋值(defineProperty() 方法中有个例外,可以对可配置的只读属性重新赋值)。
  • o 中的属性 p 是继承属性,且是只读的:不能通过同名自有属性覆盖只读的继承属性。
  • o 中不存在自有属性:o 没有使用 setter 方法继承属性 p,并且 o 的可扩展性(entensible attribute)为 false。如果 o 中不存在 p,并且没有 setter 方法可供调用,则 p 一定会添加至 o 中。但如果 o 是不可扩展的,在 o 中就不能定义新属性了。

删除属性

delete 运算符可以删除对象的属性,但本质上只是断开属性和所属对象的关系,其实并不是将对象的属性从内存中删除。

a = { p: { x: 1 } };
b = a.p;
delete a.p;
b.x; // => 1
1
2
3
4

由于已经删除的属性的引用依然存在,所以如果代码写得不严谨,就容易造成内存泄漏。因此,在销毁对象的时候,要遍历属性中的属性,依次删除。

另外,delete 运算符只能删除自有属性,不能删除继承属性(只能从定义这个属性的原型对象上删除这个继承属性,而且这样会影响到所有继承自这个原型的对象)。

delete 表达式删除成功或没造成任何副作用(比如删除不存在的属性)时,就会返回 true。如果 delete 运算符的操作数不是属性访问表达式,依然返回 true,包装对象也是如此:

o = { x: 1 }; // o 具有自有属性 x,并且继承了属性 toString
delete o.x; // 成功删除属性 x
delete o.x; // 什么都没做(属性 x 不存在了)
delete o.toString; // 什么都没做(不能删除继承属性)
delete 1; // 无意义
1
2
3
4
5

delete 不能删除可配置性为 false 的属性(但可以删除不可扩展对象的可配置属性)。某些内置对象的属性就是不可配置的,比如通过变量声明和函数声明创建的全局对象的属性。在严格模式中,删除一个不可配置的属性会报类型错误。在非严格模式中,这类的 delete 操作会返回 false:

delete Object.prototype; // => false: 不能删除,属性不可配置
var x = 1; // 声明全局变量
delete this.x; // => false: 全局变量不能删除
function f() {} // 声明全局函数
delete this.f; // => false: 全局函数也不能删除
1
2
3
4
5

在非严格模式中删除全局对象的可配置属性时,可以省略对全局对象的引用:

this.x = 1; // 创建了一个可配置的全局属性
delete x; // true
this.x; // undefined
1
2
3

但是在非严格模式中这样删除会报语法错误,必须显式指定对象及其属性:

delete x; // => Uncaught SyntaxError: Delete of an unqualified identifier in strict mode.
delete this.x; // => true
1
2

检测属性

一句话概括:

  • in:是属性就行(自有或继承)
  • hasOwnProperty:必须是自有(继承就不行)
  • propertyIsEnumerable:自有且可枚举

JavaScript 中的对象可以看作是属性的集合,检测集合中成员所属关系——也就是判断某个属性是否存在于某个对象中,是很常见的操作,可以通过 in 运算符、hasOwnProperty()propertyIsEnumerable() 来完成这个工作,其实属性查询也可以做到这一点。

前面已经讲过,in 运算符用于检查左侧的属性名是否为右侧对象的自有属性或继承属性:

var o = { x: 1 };
"x" in o; // => true: x 是 o 的属性
"y" in o; // => false: y 不是 o 的属性
"toString" in o; // => true: toString 是 o 继承来的属性
1
2
3
4

对象的 hasOwnProperty() 方法则更严格:它检查一个名称是否为对象的自有属性:

var o = { x: 1 };
o.hasOwnProperty('x'); // => true: x 是 o 的自有属性
o.hasOwnProperty('y'); // => false: y 不是 o 的自有属性
o.hasOwnProperty('toString'); // => false: toString 是继承属性
1
2
3
4

propertyIsEnumerable() 则又是 hasOwnProperty() 的增强版:只有该属性为自有属性,且该属性的可枚举性为 true 时,该方法才返回 true。某些内置属性是不可枚举的,不过 JavaScript 代码创建的属性一般都是可枚举的(除非在 ES5 中用一个特殊方法改变了属性的可枚举性):

var o = inherit({ y: 2 });
o.x = 1; // => 1
o.propertyIsEnumerable("x"); // => true: x 是 o 的可枚举的自有属性
o.propertyIsEnumerable("y"); // => false: y 是继承属性
Object.prototype.propertyIsEnumerable("toString"); // => false: toString 不可枚举
1
2
3
4
5

要判断属性的值是否为 undefined,除了可以使用 in 运算符,更简便的方法是使用 !== 来判断:

var o = { x: 1 };
o.x !== undefined; // => true: o 中有属性 x
o.y !== undefined; // => false: o 中没有属性 y
o.toString !== undefined; // => true: o 继承了属性 toString
1
2
3
4

但是,有一种场景只能用 in 运算符来判断:就是需要区分属性究竟是不存在,还是存在但是值为 undefined 的情况:

var o = { x: undefined }; // 显式赋值属性 x 为 undefined
o.x !== undefined; // => false: 属性存在,但值为 undefined
o.y !== undefined; // => false: 属性不存在
"x" in o; // => true: 属性存在
"y" in o; // => false: 属性不存在
delete o.x; // => true: 删除了属性 x
"x" in o; // => false: 属性不再存在
1
2
3
4
5
6
7

注意:上面的代码用的是 !== 运算符,而不是 !=!== 可以区分 null 和 undefined,但有时候不需要这种区分,可以用 != 或者直接什么都不用:

// 如果 o 中含有属性 x,且 x 的值不是 null 或 undefined,o.x 乘以 2
if (o.x != null) o.x *= 2;
// 如果 o 中含有属性 x,且 x 的值不能转换为 false,o.x 乘以 2
// 如果 x 为 undefined、null、false、""、0 或 NaN,则保持不变
if (o.x) o.x *= 2;
1
2
3
4
5

枚举属性

一句话概括:

  • for/in:只需可枚举(自有或继承)
  • Object.keys():可枚举+自有
  • Object.getOwnPropertyNames():只需自有(包含不可枚举的)

前面提到过的 for/in 循环,可以遍历对象所有可枚举的属性(包括自有属性和继承属性),然后将属性名称赋值给循环变量。对象继承来的内置方法不可枚举,而在代码中给对象添加的属性都是可枚举的(也有例外):

var o = { x: 1, y: 2, z: 3 }; // 三个可枚举的自有属性
o.propertyIsEnumerable('toString'); // => false: 继承来的内置方法,不可枚举
for (p in o) console.log(p); // => x y z: 只输出可枚举的属性
1
2
3

许多实用工具库都向 Object.prototype 中添加了各种方法或属性,这些方法和属性可以被所有对象继承并使用。但是在 ES5 标准之前,添加的这些方法和属性无法设置为不可枚举,所以会在 for/in 循环中枚举出来,而用户其实不需要把这些方法或属性枚举出来。所以为了避免这种情况,就需要过滤 for/in 循环返回的属性,下面列出两种过滤不需要的属性的最常见的方式:

for (p in o) {
    if (!o.hasOwnProperty(p)) continue; // 跳过继承的属性
}
for (p in o) {
    if (typeof o[p] === 'function') continue; // 跳过方法
}
1
2
3
4
5
6

下面的代码定义了一些实用的工具函数来操控对象的属性,这些函数都用到了 for/in 循环。其中的 extend() 函数其实经常出现在 JavaScript 的实用工具库中。

// 把 p 中的可枚举属性复制/扩展到 o 中并返回 o
// p 会覆盖 o 中的同名属性
// 但不会处理 getter 和 setter 以及复制属性: TODO: 这里需要看了后面的相关知识才能弄明白……
function extend(o, p) {
    for (var prop in p) { // 遍历 p 中所有可枚举属性
        o[prop] = p[prop]; // 将属性添加至 o 中
    }
    return o;
}

// 把 p 中的可枚举属性复制/合并到 o 中并返回 o
// p 不会覆盖 o 中的同名属性
// 并且不会处理 getter 和 setter 以及复制属性
function merge(o, p) {
    for (var prop in p) { // 遍历 p 中所有可枚举属性
        if (o.hasOwnProperty(prop)) continue; // 过滤掉已经存在于 o 中的属性
        o[prop] = p[prop]; // 将属性添加至 o 中
    }
    return o;
}

// 删除 o 独有的属性,并返回 o
function restrict(o, p) {
    for (var prop in o) { // 遍历 p 中所有可枚举属性
        if (!(prop in p)) delete o[prop]; // 不存在于 p 中的话就删除
    }
    return o;
}

// 从 o 中删除 p 中也有的同名属性,并返回 o
function subtract(o, p) {
    for (var prop in p) { // 遍历 p 中所有可枚举属性
        delete o[prop]; // 从 o 中删除(删除不存在的属性也不会报错)
    }
    return o;
}

// 返回一个同时拥有 o 和 p 的属性的新对象
// 对于重名属性,用 p 中的属性值
function union(o, p) { return extend(extend({}, o), p); }


// 返回一个对象,拥有 o 和 p 的同名属性,采用 o 中的属性值
function intersection(o, p) { return restrict(extend({}, o), p); }

// 返回一个数组,包含 o 中可枚举的自有属性的名称
function keys(o) {
    if (typeof o !== 'object') throw TypeError(); // 参数必须是对象
    var result = []; // 保存属性名称的数组
    for (var prop in o) {
        if (o.hasOwnProperty(prop)) result.push(prop); // 只添加可枚举的自有属性
    }
    return result;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

遍历属性的方法,除了 for/in 循环,还有 ES5 所定义的两个函数:Object.keys() 返回一个数组,元素为对象中可枚举的自有属性,工作原理和上面代码中的工具函数 keys() 类似。

还有一个函数是 Object.getOwnPropertyNames(),它和 Object.keys() 类似,只不过返回的是所有的自有属性的名称,包括那些不可枚举的属性。

属性 gettersetter

对象的属性,是由名字、值和一组特性(attribute)组成的,而在 ES5 中,属性的值可以用两个方法替代:gettersetter。这两个方法定义的属性称为“存取器属性”(accessor property),和前面所讲的“数据属性”(data attribute)不一样——数据属性只有一个简单的值。

程序在查询存取器属性的值的时候,就会不带参数地调用 getter 方法,其返回值就是属性存取表达式的值。程序在设置存取器属性的值的时候,就会调用 setter 方法,将赋值表达式右侧的值当作参数传入 setter——可以将这个方法看作是在负责“设置”属性的值。setter 方法的返回值可以忽略。

存取器属性和数据属性不一样,存取器属性不具有可写性(writable attribute)。同时具有 gettersetter 方法的属性是读/写属性,只有 getter 方法的是只读属性,只有 setter 方法的则是只写属性,读取只写属性会得到 undefined。

可以用对象直接量语法的一种扩展写法来定义存取器属性:

var o = {
    data_prop: value, // 普通的数据属性

    // 存取器属性都是成对定义的函数
    get accessor_prop() { /* 函数体 */ },
    set accessor_prop(value)  { /* 函数体 */ },
};
1
2
3
4
5
6
7

gettersetter 函数的函数名必须相同,用关键字 getset 而不是 function 来定义,且 getter 方法和 setter 方法之间需要用逗号分隔开。

var p = {
    // x 和 y 是普通的可读写的数据属性
    x: 1.0,
    y: 1.0,

    // r 是可读写的存取器属性,同时有 getter 和 setter
    // 如果函数体之后还有别的属性定义,函数体后面要带逗号
    get r() {
        return Math.sqrt(this.x * this.x + this.y * this.y);
    },
    set r(newValue) {
        var oldValue = Math.sqrt(this.x * this.x + this.y * this.y);
        var ratio = newValue / oldValue;
        this.x *= ratio;
        this.y *= ratio;
    },

    // theta 是只读存取器属性,只有 getter 方法
    get theta() {
        return Math.atan2(this.y, this.x);
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

注意上面代码中,gettersetterthis 关键字的用法:JavaScript 是把这些(存取器属性中定义的)函数当作对象的方法来调用的,所以在函数体内的 this 指向的是表示这个点的对象。因此,属性 rgetter 方法可以通过 this.x 这样的格式引用对象中的属性。

另外,这段代码使用存取器属性定义 API,提供了表示同一组数据的两种方法(笛卡尔座标系和极座标系表示法)。

存取器属性和数据属性一样是可继承的,因此上面代码中的对象 p 可以是另一个“点”的原型。以 p 为原型定义一个新对象的话,可以给新对象重新定义属性 x 和 y,然后继承属性 r 和 theta:

var q = inherit(p); // 创建一个继承了 getter 和 setter 的新对象
q.x = 1, q.y = 1, // 给 q 添加两个自有属性,覆盖了原型中的同名属性
console.log(q.r); // 使用继承来的存取器属性中的 getter 方法
console.log(q.theta);
1
2
3
4

还有很多场景可以用到存取器属性,比如智能检测属性的写入值,以及在每次属性读取时返回不同的值:

// 该对象产生严格自增的序列号
var serialNum = {
    // 该数据属性包含下一个序列号
    // $ 符号暗示该属性为私有属性
    $n: 0,

    // 返回当前值,然后自增
    get next() { return this.$n++; },

    // 设置 $n 的新值,但只有大于当前值时才成功
    set next(n) {
        if (n > this.$n) this.$n = n;
        else throw '序列号的值不能比当前值小';
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

最后再看一个例子,它使用 getter 方法实现一种“神奇”的属性:

// 该对象有一个可以返回随机数的存取器属性
// 比如表达式 random.octet 会产生一个在 0~255 之间的随机数
var random = {
    get octet() {
        return Math.floor(Math.random() * 256);
    },
    get uint16() {
        return Math.floor(Math.random() * 65536);
    },
    get int16() {
        return Math.floor(Math.random() * 65536) - 32768;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

属性的特性

数据属性 存取器属性
值 value 读取 get
可写性 writable 写入 set
可枚举性 enumerable 可枚举性 enumerable
可配置性 configurable 可配置性 configurable

上表中列出的是数据属性和存取器属性各自的四个特性,为了查询/设置属性的特性,ES5 中定义了“属性描述符”(property descriptor)这个对象,该对象代表那四个特性,且描述符对象的属性和它们所描述的属性特性是同名的。所以,数据属性的描述符对象,包含 valuewritableenumerableconfigurable 这四个属性,存取器属性的描述符对象,则包含 getsetenumerableconfigurable 这四个属性,其中 writableenumerableconfigurable 都是布尔值,而 getset 则是函数值。

Object.getOwnPropertyDescriptor() 可以获取某个对象特定属性的属性描述符:

Object.getOwnPropertyDescriptor({ x: 1}, 'x');
// => {value: 1, writable: true, enumerable: true, configurable: true}
// 查询前面定义的 random 对象的 octet 属性
Object.getOwnPropertyDescriptor(random, 'octet');
// => {set: undefined, enumerable: true, configurable: true, get: ƒ}
// 查询继承属性或不存在的属性时返回 undefined
Object.getOwnPropertyDescriptor({}, 'x');
// => undefined
Object.getOwnPropertyDescriptor({}, 'toString');
// => undefined
1
2
3
4
5
6
7
8
9
10

Object.getOwnPropertyDescriptor() 只能得到对象自有属性的描述符,要想获取继承属性的特性,就需要遍历原型链了。

要设置属性的特性,或者想让新建属性具有某种特性,就要用 Object.defineProperty()

var o = {}; // 创建一个空对象
// 添加一个不可枚举的数据属性,并赋值为 1
Object.defineProperty(o, 'x', { value: 1, writable: true, enumerable: false, configurable: true});

// 属性存在,但不可枚举
o.x; // => 1
Object.keys(o); // => []

// 让属性变为只读
Object.defineProperty(o, 'x', { writable: false });
o.x = 2; // => 2
o.x; // => 1

// 虽然是只读,但是可配置,所以依然可以用 Object.defineProperty 修改属性的值
Object.defineProperty(o, 'x', { value: 3 });
o.x; // => 3

// 将 x 从数据属性更改为存取器属性
Object.defineProperty(o, 'x', { get: function() { return 0; }});
o.x; // => 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

传入 Object.defineProperty() 的属性描述符对象,不需要包含所有四个属性,就像上面的代码那样,包含至少一个属性就可以了。新创建的属性,其默认的特性值是 falseundefined。修改已有属性时,不会修改默认的特性值。该方法不能修改继承属性,只能修改已有属性或新建自有属性。

要同时修改或创建多个对象时,就要用该方法的复数形式——Object.defineProperties()——第一个参数是所要修改的对象,第二个参数是个映射表:包含所要修改/新建的属性的名称,及各属性的属性描述符:

var p = Object.defineProperties({}, {
    x: { value: 1, writable: true, enumerable: true, configurable: true },
    y: { value: 1, writable: true, enumerable: true, configurable: true },
    r: {
        get: function() { return Math.sqrt(this.x * this.x + this.y * this.y)},
        enumerable: true,
        configurable: true
    }
});
1
2
3
4
5
6
7
8
9

上面这段代码给一个空对象添加了两个数据属性和一个只读存取器属性,然后将修改后的对象返回给变量 p。

对于不允许被创建或修改的属性来说,用 Object.defineProperty() 或其复数形式对其进行操作(新建或修改)就会抛出类型错误异常,比如给不可扩展的对象新增属性时。这些方法抛出类型错误异常的其它原因则和特性本身相关,以下是完整的规则,违反规则使用 Object.defineProperty() 或其复数形式都会抛出类型错误异常:

  • 对于不可扩展对象,可编辑已有的自有属性,但不可新增属性。
  • 对于不可配置属性,不能修改可配置性和可枚举性。
  • 对于不可配置的存取器属性,不能修改其 gettersetter 方法,也不能将其转换为数据属性。
  • 对于不可配置的数据属性,不能将其转换为存取器属性。
  • 对于不可配置的数据属性,不能将其可写性从 false 修改为 true,但可以从 true 修改为 false
  • 对于不可配置且不可写的数据属性,不能修改它的值。但可配置不可写的属性的值是可以修改的(其实是先将其标记为可写的,然后修改值,再标记成不可写的)。

枚举属性这一节中的 extend() 函数,把一个对象的属性复制到另一个对象中,但只是简单地复制属性名和值,没有复制属性的特性,也没有复制存取器属性的 gettersetter 方法,只是简单地将其转换为静态的数据属性。下面的代码则给出了改进的 extend() 方法,使用 Object.getOwnPropertyDescriptor()Object.defineProperty() 对属性的所有特性进行复制。改进后的方法作为不可枚举属性被添加到 Object.prototype 中,因此它是在 Object 上定义的新方法,并不是一个独立的函数。

// (TODO: 没太看懂……)
// 给 Object.prototype 添加一个不可枚举的 extend() 方法
// 该方法继承自调用它的对象,将作为参数传入的对象的属性逐一进行复制
// 不仅复制值,也复制属性的所有特性,除非在目标对象中存在同名属性
// 参数对象的所有自有对象(自有属性?)(包括不可枚举的属性)也会被逐一复制
Object.defineProperty(Object.prototype, 'extend',
    {
        writable: true,
        enumerable: false,
        configurable: true,
        value: function(o) { // 值就是这个函数
            // 得到所有的自有属性,包括不可枚举属性
            var names = Object.getOwnPropertyNames(o);
            // 遍历这些属性
            for (var i = 0; i < names.length; i++) {
                // 如果属性已存在,则跳过
                if (names[i] in this) continue;
                // 获得 o 中的属性描述符
                var desc = Object.getOwnPropertyDescriptor(o, names[i]);
                // 用它给 this 创建一个属性
                Object.defineProperty(this, names[i], desc);
            }
        }
    });
// TODO: 执行这段代码之后,定义一个对象 p,然后执行 Object.extend(p),再查看 Object.prototype 的属性,没有发现 p 中的属性;再执行 Object.prototype.extend(p),则直接是 jQuery 的报错信息了,是在百度的首页环境下,在浏览器控制台中测试的
// TODO: 所以上面代码中的 this 究竟指的是什么?又将传入参数的对象的属性,复制到什么地方去了?
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

getter 和 setter 的老式 API

对象直接量语法可以给新对象定义存取器属性,但无法查询属性的 gettersetter 方法,或给已有的对象添加新的存取器属性。ES5 中可以通过 Object.getOwnPropertyDescriptor()Object.defineProperty() 来完成这些工作,但是在 ES5 发布之前,各大浏览器是如何实现这些功能的呢?其实在 ES5 标准被采纳之前,大多数的 JavaScript 的实现(IE 除外)就已经可以支持对象直接量语法中的 getset 写法了,这些实现提供了非标准的老式 API 来查询和设置 gettersetter,这些 API 由四个方法组成,所有对象都有这些方法:__lookupGetter__()__lookupSetter__() 用来返回一个命名属性的 gettersetter 方法,__defineGetter__()__defineSetter__() 则用来定义 gettersetter。前后的两条下划线,用来表明它们是非标准的方法。

对象的三个属性

每一个对象都有与之相关的三个属性:原型(prototype)、类(class)和可扩展性(extensible attribute)。

原型属性

原型属性是用来从原型继承属性的,这个属性很重要,所以会常常把“o 的原型属性”直接叫做“o 的原型”。

在创建对象实例的时候,原型属性就设置好了:

  • 由对象直接量创建的对象,其原型为 Object.prototype
  • new 创建的对象,其原型为构造函数的 prototype 属性。
  • Object.create() 创建的对象,其原型为第一个参数(或者 null)。

在 ES5 中,用 Object.getPrototypeOf() 可以查询所传入的对象的原型。ES3 中虽然没有等价的函数,但可以用表达式 o.constructor.prototype 来检测对象的原型。new 创建的对象通常会继承 constructor 属性,这个属性指代创建这个对象的构造函数。而构造函数所拥有的 prototype 属性,定义了用该构造函数所创建出的对象的原型。后面会解释为什么这种方法(o.constructor.prototype)检测对象原型并不是100%可靠。通过对象直接量或者 Object.create() 传入对象直接量作为第一个参数所创建的对象,都有一个 constructor 属性,该属性就是 Object() 这个构造函数。因此,constructor.prototype 是对象直接量的原型,但不一定是 Object.create() 的原型(TODO: 为什么呢?)。

isPrototypeOf() 方法能够检测一个对象是否为另一个对象的原型,或者是否在另一个对象的原型链上:

var p = { x: 1 }; // 定义原型对象
var o = Object.create(p); // 用原型创建新对象
p.isPrototypeOf(o); // => true: o 继承自 p
Object.prototype.isPrototypeOf(p); // => true: p 继承自 Object.prototype
1
2
3
4

书上说 isPrototypeOf() 实现的功能和 instanceof 运算符非常类似,一个是检查某对象是否为另一个对象的原型,另一个是检查某对象是否为另一个构造函数的实例,这么一看,的确是比较相似。

PS: Mozilla 实现的 JavaScript 对外暴露了一个命名为 __proto__ 的属性,用来直接查询/设置对象的原型。虽然其它浏览器也部分支持,但不建议使用该属性。

类属性

对象的类属性是一个字符串,表示对象的类型信息。ES3 和 ES5 都没有提供设置该属性的方法,只能通过一种间接的方法查询它。默认的 toString() 方法(继承自 Object.prototype)返回如下格式字符串:

[object class]

上面字符串中的 class 即为所查询目标的类型名称。由于很多对象所继承的 toString() 方法被该对象重写了,所以必须间接调用 function.call() 方法,下面的例子就用这种方法来返回任意对象所属的类:

function classOf(o) {
    if (o === undefined) return 'undefined';
    if (o === null) return 'null';
    return Object.prototype.toString.call(o).slice(8, -1);
}

classOf(1); // => "Number"
classOf('1'); // => "String"
classOf(true); // => "Boolean"
classOf(null); // => "null"
classOf(undefined); // => "undefined"
classOf(new Date()); // => "Date"
classOf(new Array()); // => "Array"
classOf(new Object()); // => "Object"
classOf(/./); // => "RegExp"
function f() {};
classOf(new f()); // => "Object"
classOf(() => {}); // => "Function"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

有了这个函数,就能够查询任意值的类型了。对于字符串、数字和布尔值这三种原始数据类型,其实是通过这些类型的变量调用的 toString() 方法,而不是通过它们的直接量调用的,因为 JavaScript 本身不允许直接量这样调用。这个函数还处理了 null 和 undefined 这两种特殊类型。ArrayDate 这样通过内置构造函数创建的对象,其类属性与构造函数名称相匹配(相同)。宿主对象通常也有一些有意义的类属性,不过和具体的实现有关。由对象直接量或者 Object.create 创建的对象,其类属性是 Object。由自定义构造函数所创建的对象,其类属性也是 Object:因此对于自定义的类而言,没有办法通过类属性来区分对象的类。

可扩展性

该属性表示是否可以给对象添加新属性。

在 ES3 中,所有的内置对象和自定义对象都是显示可扩展的,宿主对象则由其具体实现决定。在 ES5 中,除了被转换为不可扩展的对象,所有的内置和自定义对象都是可扩展的;宿主对象依然由其具体实现决定。

ES5 中定义了查询/设置对象可扩展性的函数:Object.isExtensible()。另外,Object.preventExtensions() 能够将参数对象转换为不可扩展。注意:转换为不可扩展的操作是不可逆的!也就是无法再转换成可扩展。不过 preventExtensions() 只影响对象本身的可扩展性,不会影响其原型链上其它对象的可扩展性,所以通过给对象的原型添加属性,能够变相地扩展该对象。

可扩展性的作用是将对象锁定在某种状态,避免外界的干扰。可扩展性常常与可配置性和可写性配合使用。

Object.seal()Object.preventExtensions() 有些相似,但更为强大:它不仅将对象设置为不可扩展,还将对象所有自有属性设置为不可配置。也就是不能给对象添加新属性,已有的属性也不能删除或配置,不过可写属性依然可以设置(配置和设置有什么区别?前面讲过配置,就是修改,但设置又是什么?)。用 Object.seal() 封闭了的属性是无法解封的。Object.isSealed() 可以检测对象是否封闭。

Object.freeze() 相比 Object.seal() 则更加严格了:不仅将对象设置为不可扩展、自有属性不可配置,还将所有的自有数据属性设置为只读——所以函数名是 freeze(对象的存取器属性有 setter 方法的话,存取器属性则不受影响,依然可以通过给属性赋值来调用它们)。可以用 Object.isFrozen() 检测对象是否冻结。

Object.preventExtensions()Object.seal()Object.freeze() 都会将传入的对象返回,因此可以嵌套调用这些方法:

// 创建了一个封闭的对象,包括一个冻结的原型和一个不可枚举的属性
var o = Object.seal(Object.create(Object.freeze({ x: 1 }), {y: { value: 2, writable: true}}));
1
2

序列化对象

序列化(serialization):将对象的状态转换为字符串。当然了,字符串也可以还原为对象。

ES5 所提供的内置函数 JSON.stringify()JSON.parse() 分别用来序列化和还原 JavaScript 对象,这两种方法都用 JSON 作为数据交换格式。

o = { x: 1, y: { z: [false, null, '']}};
s = JSON.stringify(o); // => "{"x":1,"y":{"z":[false,null,""]}}"
p = JSON.parse(s); // => p 是 o 的深拷贝
1
2
3

JSON 支持对象、数组、字符串、有穷大数字、true、false 和 null,这些对象可以被序列化和还原。

日期对象会被序列化为 ISO 格式的日期字符串,但无法还原成原始的日期对象,而是依然保留字符串形态。

函数、RegExp、Error 对象和 undefined 不能被序列化和还原。

JSON.stringify() 只能序列化对象可枚举的自有属性,其余不能序列化的属性,在序列化后的输出字符串中会将其省略掉。

JSON.stringify()JSON.parse() 都接收第二个可选参数,通过传入需要序列化或还原的属性列表,来自定义序列化或还原操作。

var a = JSON.stringify({ x: 1 }); // => a: "{"x":1}"
var b = JSON.parse(a); // => b: {x: 1}
var c = JSON.stringify(new Date()); // => c: ""2017-11-04T08:25:59.683Z""
var d = JSON.parse(c); // => d: "2017-11-04T08:25:59.683Z"
1
2
3
4

对象方法

所有的 JavaScript 对象都从 Object.prototype 继承属性(除了那些不通过原型显式创建的对象),继承的这些属性主要是方法。前面已经讨论过 hasOwnProperty()propertyIsEnumerable()isPrototypeOf() 这三个方法,以及在 Object 这个构造函数里定义的静态函数 Object.create()Object.getPrototypeOf() 等。本节将对定义在 Object.prototype 中的对象方法进行讲解,一些特定的类会重写这些方法。

toString() 方法

该方法无参数,返回的是调用这个方法的对象的值的字符串。需要将对象转换为字符串的时候,JavaScript 都会调用这个方法,比如在用 + 运算符连接一个字符串和一个对象时,或者在希望使用字符串的方法中使用了对象时。

该方法在默认情况下的返回值,信息量非常少:

var s = { x: 1, y: 1 }.toString(); // => s: "[object Object]"
1

由于默认的 toString() 并不会输出多少有用的信息,所以很多类都会自定义 toString()。比如数组转换为字符串之后,结果就是数组元素组成的列表。函数转换为字符串之后,就是函数的源代码。

[1, 2, [3, 4, 5]].toString(); // => "1,2,3,4,5"
(function f() { return x + y; }).toString(); // => "function f() { return x + y; }"
1
2

toLocaleString() 方法

所有的对象还都会包含 toLocaleString() 方法:返回一个表示该对象的本地化字符串。Object 中默认的 toLocaleString() 方法并不做任何额外操作,只是调用 toString() 方法并返回对应值。DateNumber 类对 toLocaleString() 方法做了自定义,可以对数字、日期和时间做本地化的转换。Array 类的 toLocaleString() 方法和 toString() 方法很像,唯一不同的是每个数组元素都会调用 toLocaleString() 方法转换为字符串,而不是调用各自的 toString() 方法。

new Date().toLocaleString() // => "11/4/2017, 4:58:27 PM"
1E3.toLocaleString() // => "1,000"
[1, 2, [3, 4, 5]].toLocaleString(); // => "1,2,3,4,5"
1
2
3

toJSON() 方法

实际上 Object.prototype 没有定义 toJSON() 方法,不过对于需要序列化的对象来说,JSON.stringify() 方法会调用 toJSON() 方法。如果需要序列化的对象存在这个方法,就会调用它,返回值就是序列化之后的结果,而不是原始的对象了。

new Date().toJSON(); // => "2017-11-04T10:16:19.305Z"
1

valueOf() 方法

该方法和 toString() 方法非常相似,但一般是需要将对象转换为某种非字符串的原始值时才会调用它,尤其是转换为数字的时候。在需要使用原始值的上下文中使用了对象时, JavaScript 就会调用该方法。另外有些内置类还自定义了 valueOf() 方法。

new Date().valueOf(); // => 1509790841639
1

数组

创建数组

如果数组直接量中有连续的逗号,那么这就是一个稀疏数组。所有省略掉值的地方都是没有元素的,但是由于 JavaScript 的特性(普通的查询方式无法区分数组某处无元素和数组元素的值为 undefined 的情况),查询这些位置的元素也会返回 undefined。

var count = [1, , 3]; // 只有在索引 0 和 2 处有元素
var undefs = [,,]; // 数组没有元素,但是 length 属性值为 2
1
2

数组直接量中,最后一个逗号之后如果没有值,则认为这个逗号之后没有元素。所以 [,,]length 属性值为 2。

var a = [, , ,];
a.length; // => 3
1
2

var a = [];var a = new Array(); 等价,创建的都是空数组。

带参数调用构造函数 Array() 时,会预分配一个数组空间,这个数组中没有存储值,连数组的索引属性都还没定义。

数组元素的读写

访问数组元素要使用 a[2] 这种形式,[] 操作符中必须是一个返回非负整数的表达式。如果是负数或非整数表达式,那这个数值就会被转换为字符串作为属性名来使用。

数组其实是对象的特殊形式,使用方括号访问数组元素就像访问对象属性一样。JavaScript 将指定的数字索引转换成字符串——索引值 1 变成 '1'——然后将其作为属性名来用。这种将索引值从数字转换为字符串的方式,常规对象也可以用:

o = {}; // 创建一个普通的对象
o[1] = 'one'; // 用一个整数来索引它
o // => {1: "one"}
1
2
3

数组一个特别的地方在于:当使用小于 2^32 的非负整数作为属性名时,数组会自动维护其 length 属性值。

数组的索引和对象的属性名是不一样的:

  • 所有的索引都是属性名,但只有在 0 ~ (2^32 -2) 之间的整数属性名才是索引(TODO: 一定是索引?)。
  • 所有数组都是对象,可以为其创建任意名字的属性。
  • 如果使用的属性是数组的索引,数组就会根据需要来更新它的 length 属性值。
a[-1.23] = true; // 会创建一个名为 '-1.23' 的属性
a[1.000]; // 等价于 a[1]
1
2

其实数组索引只是对象属性名的一种特殊类型,所以在 JavaScript 中不会有数组“越界”的概念。查询对象属性时,如果不存在,只是会返回 undefined 而已,对于数组也是如此。

那么既然数组是对象,就可以从原型中继承元素(TODO: 如何继承?)。在 ES5 中,数组可以定义元素的 gettersetter 方法。如果数组继承了元素,或者使用了元素的 getter 或者 setter 方法,那么建议你使用使用非优化的代码路径(TODO: 具体是什么?):访问这样的数组中的元素的时间,和查找常规对象属性的时间差不多。

稀疏数组

稀疏数组的索引从 0 开始且不连续。对于稀疏数组,length 属性的值大于元素的个数。下面两种方式都创建了一个稀疏数组,delete 操作符也能够产生稀疏数组。

a = new Array(5); // 数组没有元素,但 a.length 是 5
b = []; // 创建一个空数组,此时 length = 0
b[1000] = 0; // 赋值添加一个元素,此时 length 就为 1001 了
1
2
3

足够稀疏的数组在实现上通常比稠密的数组更慢、内存利用率更高(是说占用空间更多?),在这样的数组中查找元素的时间,和查找常规对象属性的时间一样长。

在数组直接量中省略值时(后面没有值的逗号),会创建稀疏数组。省略的元素在数组中是不存在的:

var a1 = [,]; // 数组 a1 的 length 属性的值为 1,但是没有元素
var a2 = [undefined]; // 数组 a2 有一个元素,就是 undefined
0 in a1; // => false: 数组 a1 并没有元素
0 in a2; // => true: 数组 a2 有索引为 0 的元素
1
2
3
4

数组长度

数组的 length 属性使其区别于常规的 JavaScript 对象,稠密(非稀疏)数组的 length 属性值代表数组中元素的个数,其值比数组中最大的索引大 1;而稀疏数组的该属性值则大于元素个数。因此,无论数组稀疏与否,其长度肯定大于所有元素的索引值。为了保持此规则永远成立,数组有两个特殊的行为:

  1. 如果为一个数组元素赋值之后,它的索引 i 大于等于现有数组长度时,length 属性将设置为 i+1。
  2. 如果设置 length 属性为一个小于当前长度的非负整数 n 时,当前数组中索引值大于等于 n 的元素将被删除:
a = [1, 2, 3, 4, 5]; // 新建一个有 5 个元素的数组
a.length = 3; // a 现在为 [1, 2, 3]
a.length = 0; // a 现在为 []
a.length = 5; // a 长度为 5,但没有元素,结果等价于 new Array(5)
1
2
3
4

如果设置 length 属性大于当前长度时,就会在数组尾部创建一个空区域。

在 ES5 中,可以用 Object.defineProperty() 让数组的 length 属性变为只读属性:

a = [1, 2, 3];
Object.defineProperty(a, 'length', { writable: false }); // 让length 属性只读
a.length = 0; // 这样的语句就无效了
1
2
3

另外,如果让数组的元素(到底是指数组,还是指具体的某个元素?)不能被配置,那么就不能删除该元素(这里所说的删除,指的是什么方式?delete 还是 pop() 还是别的方式?)。如果不能删除该元素,length 属性也就不能设置为小于不可配置元素的索引值。(TODO: 书上说要结合 Object.seal()Object.freeze(),但是不知道这段文字的具体含义……)

数组元素的添加和删除

先说添加元素:

  • 可以给新索引赋值,从而向数组添加元素;
  • 也可以用 push() 方法在数组末尾增加任意多个元素;
  • 还可以用 unshift() 方法在数组首部插入任意多个元素:
var a = [];
a[0] = 'zero'; // => a: ['zero']
a[1] = 'one'; // => a: ['zero', 'one']
a.push(2); // => a: ['zero', 'one', 2]
a.push(3, 4); // => a: ['zero', 'one', 2, 3, 4]
a.unshift(0); // => a: [0, 'zero', 'one', 2, 3, 4]
1
2
3
4
5
6

再说删除元素:

  • delete 运算符删除数组元素后,原来的索引不再存在,但数组长度未变,这样数组就变成了稀疏数组;
  • 直接设置 length 属性也可以删除数组后面的一部分元素;
  • pop() 方法删除最后一个元素,并返回该元素的值;
  • shift() 方法则删除第一个元素,然后将所有其余元素的索引减一,并返回所删除元素的值。

数组遍历

最常见的遍历数组元素的方法是 for 循环:

var o = [1, 3, 5, 7, 2, 4, 6, 8];
var keys = Object.keys(o);
var values = [];
for (var i = 0; i < keys.length; i++) {
  var key = keys[i];
  values[i] = o[key];
}
1
2
3
4
5
6
7

嵌套循环之类的对性能要求很高的上下文中,应当对上面的代码进一步优化,数组的长度应当只检测一次:

for (var i = 0, len = keys.length; i< len; i++) { // do something }
1

上面的代码有个假设前提:数组是稠密的,并且所有元素都是合法数据。否则的话,应该先检测一下数组元素,然后再使用。几种检测方式如下:

for (var i = 0; i < keys.length; i++) {
    if (a[i] === null || a[i] === undefined) continue; // 跳过 null、undefined 和不存在的元素,如果只是用 !a[i] 判断的话,假值也会一并跳过
    if (a[i] === undefined) continue; // 跳过 undefined 和不存在的元素
    if (!(i in a)) continue; // 跳过不存在的元素
}
1
2
3
4
5

遍历稀疏数组的时候,还可以使用 for/in 循环,将会只遍历可枚举的属性名(包括数组索引):

for (var index in sparseArray) {
    var value = sparseArray[index];
    // do something
}
1
2
3
4

由于 for/in 循环遍历的是所有可枚举的属性,那么自然会将继承的属性也遍历出来,比如添加到 Array.prototype 中的方法。为了只遍历出数组的索引,要么不用 for/in 循环,要么在循环体内增加额外的检测手段:

for (var i in a) {
    if (!a.hasOwnProperty(i)) continue; // 跳过继承的属性
    if (String(Math.floor(Math.abs(i))) != i) continue; // 跳过不是非负整数的 i
}
1
2
3
4

ES 规范允许 for/in 循环以各种顺序遍历对象的属性,虽然通常是以索引的升序遍历的,但不一定总是这样。如果数组同时拥有对象属性和数组元素,则返回的属性名很可能是按照创建顺序排列的。该问题的实现各不相同,如果需要保证一定的顺序,那么就不要用 for/in 循环,而应该用 for 循环。

ES5 定义了遍历数组元素的新方法,按照索引顺序依次遍历各元素,比如最常用的 forEach() 方法:

var data = [1, 2, 3, 4, 5];
var sumOfSquares = 0;
data.forEach(function(x) {
    sumOfSquares += x * x;
});
sumOfSquares;
1
2
3
4
5
6

多维数组

JavaScript 不支持真正的多维数组,可以用数组的数组来近似模拟:a[0][1]

数组方法

本节介绍 ES3 中定义的操作数组的方法。

TODO: 找机会测试一下对于稀疏数组,各种方法的处理结果。

join()

Array.join() 方法将数组中所有元素转换为字符串并连接在一起,然后返回连接起来的字符串。可指定字符串中分隔各元素的分隔符,不指定则默认用逗号。

var a = [1, 2, 3];
a.join(); // => "1,2,3"
a.join(' '); // => "1 2 3"
a.join(''); // => "123"
var b = new Array(10);
b.join('-'); // => "---------"
1
2
3
4
5
6

该方法是 String.split() 方法的逆操作:后者将字符串以指定字符作为分隔符分隔成一个数组。

reserve()

注意:该方法会直接修改原数组。

Array.reserve() 方法返回逆序的数组。

var a = [1, 2, 3];
a.reserve(); // => a: [3, 2, 1]
1
2

sort()

注意:该方法会直接修改原数组。

不带参数调用该方法时,以字母表顺序对数组元素排序:

var a = [2, 1, 3];
a.sort(); // => [1, 2, 3]
a; // => [1, 2, 3]
1
2
3

数组中包含 undefined 元素时,该元素会被排到数组尾部。

sort() 方法还接受一个比较函数用来自定义排序的依据。比较函数的两个参数为数组的两个不同的元素,比较函数的结果大于 0 时,第一个参数排在第二个参数的后面;比较函数的结果小于 0 时,第一个参数则排在第二个参数的前面;结果等于 0 的话,两个参数相等,也就不用排序了。

a = [33, 4, 1111, 222];
a.sort(); // => [1111, 222, 33, 4]: 按照字母顺序排序
a.sort(function(a, b) { return a - b; }); // => [4, 33, 222, 1111]: 按照自定义的比较函数排序
// a - b > 0 时,a 排在 b 的后面,说明这个函数将数组按数值顺序由小到大排列
1
2
3
4

注意这里使用了匿名函数,因为这个比较函数一般只用一次,所以没必要用命名函数。

下面是另一个例子,对字符串数组进行不区分大小写的按字母表排序:

a = ['ant', 'Bug', 'cat', 'Dog'];
a.sort(); // => ["Bug", "Dog", "ant", "cat"]: 按默认的字母顺序排列,小写字母在前
a.sort(function(s, t) {
    var a = s.toLowerCase();
    var b = t.toLowerCase();
    if (a < b) return -1;
    if (a > b) return 1;
    return 0;
}); // => ["ant", "Bug", "cat", "Dog"]: 按自定义的顺序排列
1
2
3
4
5
6
7
8
9

concat()

该方法将参数合并至调用该方法的数组的后面。如果参数是一维数组,则该方法会将数组扁平化——将参数数组中的每个元素合并至调用方法的数组后面。如果参数是二维以上的数组的话,则不会递归扁平化数组的数组,具体结果见下面的代码:

var a = [1, 2, 3];
a.concat(); // => [1, 2, 3]
a.concat(4, 5); // => [1, 2, 3, 4, 5];
a.concat([4, 5]); // => [1, 2, 3, 4, 5];
a.concat([4, 5], [6, 7]); // => [1, 2, 3, 4, 5, 6, 7];
a.concat(4, [5, [6, 7]]); // => [1, 2, 3, 4, 5, [6, 7]];
1
2
3
4
5
6

slice()

该方法返回参数所指定的索引之间的元素。

  • 一个非负整数参数:从参数索引所指的元素开始,直到数组最后一个元素。如果参数值大于最后一个元素的索引值,则返回空数组。
  • 一个负整数参数:负数索引表示从后往前数,最后一个元素是 -1,倒数第二个是 -2,依此类推。返回的元素返回和前一条相同。
  • 两个整数参数:从第一个参数索引所指的元素开始,到第二个参数索引所指的元素再往回(比第二个参数小 1)一个元素为止。参数为负数的情况参考前一条。
var a = [1, 2, 3, 4, 5];
a.slice(); // => [1, 2, 3, 4, 5]
a.slice(0); // => [1, 2, 3, 4, 5]
a.slice(3); // => [4, 5]: 从指定的索引位置直到数组结束
a.slice(-3); // => [3, 4, 5]
a.slice(-2); // => [4, 5]: 负数索引则是相对于最后一个元素而言,最后一个元素为-1,倒数第二个为-2,依此类推
a.slice(2, 3); // => [3]: 从第一个参数指定的索引开始,到第二个参数指定的索引再往前一个索引为止
a.slice(2, -1); // => [3, 4]: 等价于 a.slice(2, 4)
1
2
3
4
5
6
7
8

splice()

注意:该方法会直接修改原数组。

该方法能够对数组实施插入元素、删除元素或同时完成这两种操作。在插入点/删除点之后的数组元素,索引会自动更新,所以数组的其它部分依然是连续的。

  • 只传入一个参数,则将参数索引所指代的元素直到最后一个元素都从原数组中“剪切”出去,并作为返回值返回(注意:在 ES5 规范中并没有规定该方法只传入一个参数时应当如何运行,具体的结果还要看实际的运行环境)。
  • 第二个参数指定所要“剪切”的元素的个数。
  • 之后的所有参数依次“粘接”到第一个参数所指定的插入点。
var a = [1, 2, 3, 4, 5, 6, 7, 8];

a.splice(); // => []: a 未被修改
// 只传入一个参数,则将参数索引所指代的元素直到最后一个元素都从原数组中“剪切”出去,并作为返回值返回
a.splice(0); // => [1, 2, 3, 4, 5, 6, 7, 8]: a 此时为 []
a.splice(5); // => [6, 7, 8]: 从索引 5 所指元素直到最后一个元素,a 此时为 [1, 2, 3, 4, 5]
a.splice(-6); // => [3, 4, 5, 6, 7, 8]: 负数索引的理解方式和 slice 方法一样,a 此时为 [1, 2]
// 第二个参数指定所要“剪切”的元素的个数
a.splice(2, 2); // => [3, 4]: a 此时为 [1, 2, 5, 6, 7, 8],说明 [3, 4] 从原数组中被剪切出去了
// 之后的所有参数依次“粘接”到第一个参数所指定的插入点
a.splice(3, 4, 'a', ['b', 'c'], 55, [66, [77, 88]]); // => [4, 5, 6, 7]: a 此时为 [1, 2, 3, "a", Array(2), 55, Array(2), 8]
1
2
3
4
5
6
7
8
9
10
11

TODO: 为什么 slice() 方法的第二个参数所指代的元素不包含在方法返回的数组中,而 splice() 则包含在数组中?

  • 在英语中,slice 的意思是“切片”,所以在 JS 中,该方法就是从数组中“切下”一段数组,但原数组保持不变,所以 slice 方法其实是从原数组中复制一段数组出来。
  • splice 的意思是“粘接”,所以在 JS 中,该方法将前两个参数所指代的数组从原数组中真正地“剪切”出去并作为返回值返回,然后将第三个及之后的所有参数再“粘接”到原数组的插入点/删除点上。

push()pop()

注意:该方法会直接修改原数组。

push()pop() 方法将数组当成栈来使用。push() 方法在数组尾部添加一个或多个数组,pop() 方法则将数组最后一个元素删除,数组长度减一,并返回这个删除的元素。

TODO: push() 的返回值是什么?

var stack = [];
stack.push(1, 2);   // stack: [1, 2]        返回 2
stack.pop();        // stack: [1]           返回 2
stack.push(3);      // stack: [1, 3]        返回 2
stack.pop();        // stack: [1]           返回 3
stack.push([4, 5]); // stack: [1, [4, 5]]   返回 2
stack.pop();        // stack: [1]           返回 [4, 5] 返回数组的最后一个元素,不管这个元素是什么类型
stack.pop();        // stack: []            返回 1
1
2
3
4
5
6
7
8

unshift()shift()

注意:该方法会直接修改原数组。

这两个方法和 push() 以及 pop() 方法类似,只不过这两个方法是在数组的头部进行插入/删除操作。

unshift() 在数组的头部插入元素并返回新数组的长度,其余元素则往后移动。shift() 则删除并返回数组的第一个元素,其余元素往前移动。

var a = [];             // a: []
a.unshift();            // a: []
a.unshift(1);           // a: [1]               返回 1
a.unshift(22);          // a: [22, 1]           返回 2
a.shift();              // a: [1]               返回 22
a.unshift(3, [4, 5]);   // a: [3, [4, 5], 1]    返回 3
a.shift();              // a: [[4, 5], 1]       返回 3
a.shift();              // a: [1]               返回 [4, 5]
a.shift();              // a: []                返回 1
1
2
3
4
5
6
7
8
9

注意unshift() 有多个参数时,插入的参数会保持其原来的顺序。上面代码中 a.unshift(3, [4, 5]) 执行后,数组头部变为 [3, [4, 5], ...],可见向 unshift() 一次传入多个参数的话,和依次传入各个参数的效果是相反的。

另外,shift() 可接受参数,但参数不起任何作用。

toString()toLocaleString()

toString() 方法的执行结果,和不带参数调用方法 join() 的结果是一样的。

[1, 2, 3].toString()        // "1,2,3"
['a', 'b', 'c'].toString()  // "a,b,c"
[1, [2, 'c']].toString()    // "1,2,c"
1
2
3

关于 toLocaleString() 这个方法,在 对象 -> 对象方法 一节已经讲过,此处不再重复。

ES5 中的数组方法

ES5 中新增的 9 个操作数组的方法,第一个参数普遍为函数,这个函数会作用于数组的每个(或部分)元素(稀疏数组不存在的元素除外)。

传入第一个参数的函数,通常接收三个参数:数组元素、元素索引、数组本身。一般只需要第一个参数——数组元素就足够了。

接受函数作为第一个参数的方法,还接受可选的第二个参数。如果传入了第二个参数,那么第一个参数——也就是那个函数,是作为第二个参数的方法进行调用的。也就是说,第二个参数是第一个函数内部的 this 关键字的值。

ES5 中的数组方法都不会修改数组的,但是传入方法的函数是可以修改的。

forEach()

该方法遍历数组每个元素,对每个元素执行传入的函数。

var data = [1, 2, 3, 4, 5];
var sum = 0;
data.forEach(function(value) { sum += value; });
data;                                               // (5) [1, 2, 3, 4, 5]
sum;                                                // 15
data.forEach(function(v, i, a) { a[i] = v + 2; });  // (5) [3, 4, 5, 6, 7]
1
2
3
4
5
6

forEach() 一般是不能用 break 之类的语句提前终止遍历的。必须把它放到 try 块中,并且抛出一个 foreach.break 异常,才能提前终止遍历。

TODO: 书上的代码没法直接执行,试了试网上的代码,循环倒是的确会提前终止,只不过并不会显示异常。

map()

该方法将数组的每个元素传入函数,然后将每次得到的函数返回值组成数组进行返回。

注意:由于是将每次的函数返回值组成数组,因此如果函数没有 return 语句的话,最终返回的会是一个元素全是 undefined 的数组。

var data = [1, 2, 3, 4, 5];
data.map(function(x) { return x * x; });  // [1, 4, 9, 16, 25]
1
2

filter()

对数组的每个元素执行该方法内部调用的函数之后,将函数返回值为 true 或者能转化为 true 的值组成新数组,作为该方法的返回值返回。

var a = [5, 4, 3, 2, 1];
smallValues = a.filter(function(x) { return x < 3; });        // [2, 1]
everyother = a.filter(function(x, i) { return i % 2 == 0; }); // [5, 3, 1]
1
2
3

该方法会跳过稀疏数组中缺少的元素,也就是说可以用来压缩稀疏数组。如果要一并压缩 undefined 和 null 的话,可以这样写:

var a = [1, , 3, , 5, , , undefined, , null];
a.filter(function(v) { return !!v; });          // [1, 3, 5]
1
2

every()some()

这两个方法对数组中元素用函数进行判定,返回值为 true 或 false。

every() 方法来说,仅当所有元素用判定函数都返回 true 时,它才返回 true。

some() 则是至少有一个元素用判定函数返回 true,它就返回 true。

var a = [1, 2, 3, 4, 5];
a.every(function(v) { return v < 10; });      // true
a.every(function(v) { return !(v % 2); });    // false
a.some(function(v) { return !(v % 2); });     // true
a.some(isNaN);                                // false
1
2
3
4
5

reduce()reduceRight()

这两个方法通过指定的函数将数组元素进行组合,最后生成一个值。函数式编程中常见这种操作,也可以叫做“注入”或者“折叠”。

var a = [1, 2, 3, 4, 5];
var sum = a.reduce((x,y) => { return x + y; });             // 15
var product = a.reduce((x, y) => { return x * y; });        // 120
var max = a.reduce((x, y) => { return (x > y) ? x : y; });  // 5
1
2
3
4

以上面第二行代码中的函数为例,在不传入第二个参数的情况下,reduce() 方法先将数组前两个元素相加,然后得到的结果再和第三个元素相加,最后就得到了数组全部元素的和。后面两个方法也是如此。

如果向 reduce()reduceRight() 方法中传入第二个参数的话,则第二个参数为内部函数的第一个参数,数组的第一个元素为函数的第二个参数。这句话可参照着下面这段代码进行理解:

var minus = a.reduce((x, y) => { return x - y; }, -10);   // -25
// -10 - 1 = -11
// -11 - 2 = -13
// -13 - 3 = -16
// -16 - 4 = -20
// -20 - 5 = -25
1
2
3
4
5
6

对空数组调用这两种方法,并且不传入第二个参数的话,代码就会报错:Uncaught TypeError: Reduce of empty array with no initial value

如果数组只有一个元素并且不传入第二个参数,或者对空数组调用并且传入第二个参数的话,这两个方法就会直接返回仅有的那个值。

reduceRight() 除了是从右往左处理数组之外,其它地方和 reduce() 方法是相同的。比如下面的代码就可用来进行数组元素依次乘方的计算:

var a = [ 2, 3, 5];
var big = a.reduceRight((accu, order) => { return Math.pow(accu, order); });  // 15625
// = 2^(3^5)
1
2
3

前面两个例子都是数学运算方面的,但是这两个方法还可以对对象数组进行操作,结合前面对象部分定义的 union() 函数,就可以计算对象的并集,帅不帅?

var objects = [ { x: 1 }, { y: 2 }, { z: 3 }];
var merged = objects.reduce(union);             // {x: 1, y: 2, z: 3}
1
2

两个对象有同名属性时,union() 函数会用第二个对象的同名属性覆盖第一个对象,这样 reduce()reduceRight() 就会有不同的结果:

var objects = [ { x: 1, a: 1 }, { y: 2, a: 2 }, { z: 3, a: 3} ];
var merged = objects.reduce(union);       // {x: 1, a: 3, y: 2, z: 3}
var merged = objects.reduceRight(union);  // {z: 3, a: 1, y: 2, x: 1}
1
2
3

indexOf()lastIndexOf()

这两个方法都用来查找指定值在数组中首次出现的位置,indexOf() 从左往右查找,lastIndexOf() 则从右往左查找。

另外,这两个方法都接收第二个参数,用于指定查找的起始位置。第二个参数如果为正数,就是正常的数组索引;如果是负数,负数的绝对值 N 代表对应于数组中的倒数第 N 个元素,最后一个元素为 -1,倒数第二个元素为 -2,依此类推。

var a = [0, 1, 2, 1, 0],
    value = 1,
    position = 1;

a.indexOf(value, position);     // 1
position = 2;
a.indexOf(value, position);     // 3
position = -1;
a.indexOf(value, position);     // -1
position = -2;
a.indexOf(value, position);     // 3

position = 1;
a.lastIndexOf(value, position); // 1
position = 3;
a.lastIndexOf(value, position); // 3
position = -2;
a.lastIndexOf(value, position); // 3
position = -3;
a.lastIndexOf(value, position); // 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

下面的例子,就是应用 indexOf() 方法查找指定值在数组中的所有索引:

function findAll(a, x) {
  var len = a.length,
      pos = 0,
      result = [];

  while (len > 0 && pos < len) {
    pos = a.indexOf(x, pos);
    if (pos === -1) break;
    result.push(pos);
    pos = pos + 1;
  }

  return result;
}

var a = [0, 1, 2, 1, 0];
findAll(a, 1);            // [1, 3]
findAll(a, 0);            // [0, 4]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

注意:字符串也有这两种方法,功能和数组的类似。因为 JS 中可以将字符串当成数组来看待,这样就容易理解了。

数组类型

对于一个未知对象,要判断它是否为数组,在 ES5 中,可以用 Array.isArray() 方法进行判断。但是在 ES5 之前,就比较困难了。typeof 操作符对于函数之外的目标,得到的结果都是 object

instanceof 虽然能简单地判断一下,但是对于前端来说,Web 浏览器中往往有多个 window 或多个 frame,每个都有自己的 JavaScript 环境,每个环境都有自己的全局对象,而每个全局对象都有自己的一组构造函数。也就是说,即使是看起来完全相同的两个对象,如果这两个对象各属于不同的 frame 的话,在一个 frame 中用 instanceof 判断这两个对象,结果也是不同的。

真正可行的方法,是检查未知对象的类(class)属性,数组对象的这个属性就是 Array,在“类属性”这一节中,定义了一个 classOf() 函数,就是用来判断传入对象的类属性的。所以,在 ES3 中,isArray() 函数可以这样写:

var isArray = Function.isArray || function(o) {
  return typeof o === "object" &&
    Object.prototype.toString.call(o) === "[object Array]";
};
1
2
3
4

实际上 ES5 中的 isArray() 函数干的就是这件事。

类数组对象

在 JavaScript 中,数组有一些其它类型的对象所不具备的特性:

  • 数组中增加新元素之后,length 属性会自动更新。
  • length 属性设置为一个比之前小的值,会裁剪 length 值之后的部分。
  • 数组从 Array.prototype 这个原型上继承了很多实用的方法。
  • 数组的 class 这个属性的值为 Array

正是这些特征使得数组独一无二,但它们还不足以成为定义一个数组的关键因素。给一个普通的对象添加一个数值类型的 length 属性然后给这个属性赋上非负数的值,就可以差不多把这个对象当作数组了。

在实际开发中,这些“类数组对象”还真是时不时会用到的。虽然不能通过它们来调用数组的方法,也不能指望它们的 length 属性的表现能和真正的数组一模一样,但是完全可以用迭代真正的数组的方式,来迭代这些类数组对象。许多用于数组的算法,在类数组对象身上的表现和数组差不多。如果把数组当作只读数组来用,或者数组长度不变的话,这种现象就更明显了。

下面的代码拿一个普通的对象作为示范,给这个对象添加了一些属性,就成了类数组对象了,然后再迭代这个伪数组元素:

var a = {}; // a 就是个普通的对象

// 添加些属性,让它看起来像数组
var i = 0;
while(i < 10) {
    a[i] = i * i;
    i++;
}
a.length = i;

// 用迭代真正的数组的形式迭代它
var total = 0;
for (var j = 0; j < a.length; j++) {
    total += a[j];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在 变长参数列表/参数对象 这一节中所讲到的概念,也是类数组对象。在客户端 JavaScript 中,很多 DOM 相关的方法返回的都是类数组对象,比如 document.getElementsByTagName()。下面这个函数,你就可以用来判断参数是不是类数组对象:

// 函数判断 o 是否为类数组对象
// 可以用 typeof 排除字符串和函数,因为它们也有数值类型的 length 属性
// 在客户端 JavaScript 中,文本类型的 DOM 节点也有 length 属性,所以需要额外用 o.nodeType != 3 排除
function isArrayLike(o) {
  if (o &&                                  // 排除 null、undefined
    typeof o === 'object' &&                // 数组也是对象
    isFinite(o.length) &&                   // length 属性有限大
    o.length >= 0 &&                        // length 属性非负
    o.length === Math.floor(o.length) &&    // length 属性为整数
    o.length < 4294967296)                  // length 属性 < 2^32
    return true;
  else
    return false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

下一节可以看到,ES5 中的字符串的表现和数组很像(在 ES5 之前,有些浏览器已经让字符串可以索引了)。不过上面判断类数组对象的函数在用于字符串时,通常返回的都是 false —— 最好是把它们当作字符串处理(TODO: 不太懂……)。

JavaScript 中的数组方法是故意设置为通用的,这样这些方法在类数组对象身上也可以用。在 ES5 中,所有数组方法都是通用的,在 ES3 中则只有 toString()toLocaleString() 不是通用的。不过 concat() 方法是个例外——虽然也可以用在类数组对象上,但它并没有把这个对象扩充进数组中。因为类数组对象并不是从 Array.prototype 继承而来,所以它们不能直接调用数组方法。要通过 Function.call 这个方法来间接调用:

var a = {
  '0': 'a',
  '1': 'b',
  '2': 'c',
  length: 3
};

Array.prototype.join.call(a, '+');  // => "a+b+c"
Array.prototype.slice.call(a, 0);   // => ["a", "b", "c"]
Array.prototype.map.call(a, function(x) {
  return x.toUpperCase();
});                                 // => ["A", "B", "C"]
1
2
3
4
5
6
7
8
9
10
11
12

关于 Function 对象的 call 方法,在下一章:函数中会深入探讨,此处不做过多讨论。

在 Firefox 1.5 中引入了 ES5 的数组方法,因为这些方法通用性很好,所以 Firefox 还将这些方法作为 Array 对象的构造函数中的方法来引入。有了这些方法,前面的例子就可以改写成下面这样了:

var a = {
  '0': 'a',
  '1': 'b',
  '2': 'c',
  length: 3
};

Array.join(a, '+');
Array.slice(a, 0);
Array.map(a, function(x) {
  return x.toUpperCase();
});
1
2
3
4
5
6
7
8
9
10
11
12

用在类数组对象上的时候,这些数组方法的静态函数版本就很有用了。不过它们不是标准方法,所以不可能所有浏览器里面都能用。像下面这样写代码,就可以保证只有在需要的函数存在的时候,才会调用它们:

Array.join = Array.join || function(a, sep) {
  return Array.prototype.join.call(a, sep);
};
Array.slice = Array.slice || function(a, from, to) {
  return Array.prototype.slice.call(a, from, to);
};
Array.map = Array.map || function(a, f, thisArg) {
  return Array.prototype.map.call(a, f, thisArg);
};
1
2
3
4
5
6
7
8
9

作为数组的字符串

在 ES5 中,以及 ES5 之前的诸多浏览器——包括 IE8——之中,字符串都表现得像个只读数组。除了用 charAt() 方法访问某个字符,你还可以用方括号实现相同的操作:

var s = "test";
s.charAt(0);    // => "t"
s[1];           // => "e"
1
2
3

不过字符串也只是表现得像个数组,用 typeof 判断的话,返回的仍然是 string,给 Array.isArray() 方法传递一个字符串的话,返回的也是 false。

可索引字符串最大的一个好处就是可以用方括号来索引,这样就不需要 charAt() 了,岂不是很方便?这样更精练、可读性更强,而且还可能效率更高。“字符串表现得像个只读数组”也意味着可以对它们使用数组通用的方法:

s = 'JavaScript';
Array.prototype.join.call(s, ' ');  // => 字母之间添加空格:"J a v a S c r i p t"
Array.prototype.filter.call(s,
  function(x) {
    return x.match(/[^aeiou]/);
}).join('');                        // => 筛选出非元音字母:"JvScrpt"
1
2
3
4
5
6

一定要记住,字符串是不可变的。所以如果是把它们当作数组用的话,它们肯定就是只读数组。像 push()sort() 之类的方法会修改数组,这些方法对字符串就不起作用。不过用数组方法修改字符串并不报错,这一点要注意。

函数

函数的形参叫 parameter,实参叫 argument

函数调用时除了实参,还会有本次调用的上下文(context),也就是 this 关键字的值。

如果函数作为属性挂载在对象上,那这样的函数就叫做对象的方法(method)。通过对象调用这个函数时,对象就是这次调用的上下文,也就是函数中 this 的值。(TODO: 非函数类型的对象,是否有 this 呢?)

用于初始化新建对象的函数叫做构造函数(比如 new Array())。

在 JavaScript 中,一切皆对象,函数自然也不例外。可以把函数赋值给变量,也可以作为参数传递给其它函数。因为函数就是对象,所以可以给他们设置属性,甚至可以调用他们的方法(函数的方法)。(TODO: Promises 中的 then 是不是就是如此?)

嵌套在其它函数定义内的函数,可以访问被定义时所处的作用域中的任何变量,这就构成了一个闭包。

函数定义

定义函数的标准格式:function funcName() {},其中 funcName 是函数名称标识符。

// 函数定义表达式可以包含名称,从而可以定义递归调用的函数
var f = function fact(x) { if (x <= 1) return 1; return x * fact(x - 1); };
1
2

函数声明语句和函数定义表达式的区别:

  • 对函数声明语句来说,其实是声明了一个变量,然后把函数对象赋值给它。但是对函数定义表达式来说,并没有声明一个变量。倒是可以像上面的阶乘函数一样给函数命名,这样的话,函数的局部作用域会包含一个绑定到函数对象的名称,其实函数的名称就成了函数内的局部变量了。不过一般来说,用表达式方式定义函数时不需要名称,能让代码更紧凑,很适合用来定义只用到一次的函数。
  • 函数声明语句中的函数名是一个变量名,变量指向函数对象。和通过 var 声明的变量一样,函数声明语句所定义的函数被显式地“提前”到了脚本或函数的顶部,因此它们在整个脚本或函数里都是可见的。
  • var 声明的函数定义表达式,只有变量声明被提前了,变量赋值并没有提前。因此用这种方式定义的函数,在定义之前是无法调用的。

函数声明语句并不是真正的语句,只是 ECMAScript 规范允许它们作为顶级语句。它们可以出现在全局代码里,或内嵌在其它函数中,但是不能出现在循环、条件判断,或者 try/catch/finally 以及 with 语句中。但是函数定义表达式无此限制,可以出现在代码的任何地方。

函数调用

函数体中的代码在定义时并不会执行,只有在调用该函数的时候才会执行,下面是四种调用函数的方式:

  • 作为函数
  • 作为方法
  • 作为构造函数
  • 通过函数的 call()apply() 方法间接调用

函数调用

对普通的函数调用来说,return 语句的返回值就是函数的返回值,如果没值或者没有 return,则返回值就是 undefined

在非严格模式中,调用上下文(this 的值)是全局对象,严格模式下则是 undefined

虽然函数形式的调用一般不会用到 this 关键字,但是可以用它来判断当前是否为严格模式。

var strict = (function() { return !this; })();
1

方法调用

方法调用和函数调用的一个重要区别就是调用上下文:方法调用的调用上下文是主调对象,比如 o.m(x, y) 中的 o 就是方法 m 的调用上下文。

var calculator = {
    operand1: 1,
    operand2: 1,
    add: function () {
        this.result = this.operand1 + this.operand2; // TODO: 这算是隐式地新增了一个属性 result?
    }
};
calculator.add();
calculator.result // => 2
1
2
3
4
5
6
7
8
9

方法调用也可以用方括号来访问属性:

o["m"](x, y); // => 等价于 o.m(x, y)
a[0](z) // => a[0] 是一个函数
1
2

方法调用还可以包含更复杂的属性访问表达式:

f().m() // => f() 调用结束后,再调用返回值中的方法 m()
1

方法和 this 关键字是面向对象编程的核心。作为方法调用的函数都会传入一个隐式的实参——也就是调用这个方法的对象。

方法链:方法的返回值是对象时,这个对象可以再次调用它的方法,在这样的方法链中,每次的调用结果都是另一个表达式的组成部分,比如 $("#id").map(() => this.id).get().sort()。方法不需要返回值的时候,尽量直接返回 this

注意:方法的链式调用和构造函数的链式调用是不同的。TODO: 后面可以就这两个概念进行区分。

this 是关键字,只能读取,不能赋值。

关键字 this 和变量不一样,它没有作用域:嵌套在内部的函数不会继承外部函数的 this。如果嵌套函数作为方法被调用,this 依然是调用它的对象;如果作为函数被调用,this 要么是全局对象(非严格模式)要么是 undefined。嵌套在内部的函数如果想访问外部函数的 this,外部函数就需要事先保存其 this 的值。

var o = {
    m: function () {
        var self = this; // 保存 this 的值
        console.log(this === o); // 检查 this 是否为对象 o
        f();

        function f() { // 定义嵌套函数
            console.log(this === o); // => false: this 没有继承,值是全局变量或者 undefined
            console.log(self === o); // => true: self 是外部函数的 this 的值
        }
    }
};
o.m();
1
2
3
4
5
6
7
8
9
10
11
12
13

构造函数调用

带关键字 new 调用的方法或者函数,就是构造函数调用。

如果构造函数没有形参,调用的时候是可以省略实参列表和圆括号的,所以下面两行代码等价:

var o = new Object(); // => {}
var o = new Object; // => {}
1
2

构造函数调用会创建一个新的空对象,对象继承自构造函数的 prototype 属性。构造函数初始化这个新对象,并用它做调用上下文,所以构造函数可以用 this 引用这个新对象,也就是说,new o.m() 中,调用上下文并不是 o 而是构造函数 m() 所新建的对象。

构造函数的返回值永远是新建的对象:

  • 构造函数一般是用来初始化新对象的,所以通常用不到 return 关键字,这种时候,构造函数调用表达式的返回值就是这个新对象的值。
  • 如果构造函数显示地用 return 返回一个对象,返回值就是这个对象。如果用 return 但没有指定返回值,或者返回一个原始值,则原始值将被忽略,依然将对象作为返回值。

间接调用

JavaScript 中的函数也是对象,自然也可以包含方法。call()apply() 这两个方法就可以用来间接调用函数。

这两个方法都可以显式指定调用时 this 的值,也就是任何函数都可以作为任何对象的方法被调用,即使这个函数不是对象的方法也没关系。

call() 方法使用自有的实参列表作为函数的实参,apply() 则需要以数组形式传入实参。

函数的实参和形参

JavaScript 的函数定义并不指定形参的类型,调用时也不检查实参的类型,甚至不检查传入形参的个数。

可选形参

函数调用时传入的实参比声明时指定的形参个数要少的时候,其余的形参的值都为 undefined。为了让形参有良好的适应性,应该给省略的参数赋一个合理的默认值:

function getPropertyNames(o, /* optional */ a) {
    if (a === undefined) a = []; // 如果未定义,则定义新数组
    for (var property in o) a.push(property);
    return a;
}

var o = { x: 1, y: 2, z: 3 };
var a = getPropertyNames(o); // => ["x", "y", "z"]
var p = { a: 'a', b: 'b', c: 'c' };
getPropertyNames(p, a); // => ["x", "y", "z", "a", "b", "c"]
1
2
3
4
5
6
7
8
9
10

上面代码中的 if 语句,还可以改写成:a = a || []。在这里,由于 a 是作为形参传入的,相当于 var a,已经被声明了,所以才能这样用。

用这种可选实参来设计函数的时候,可选实参一定要放在实参列表的最后,因为 JavaScript 本身只会按实参传入的顺序依次赋值给形参。对于可选实参,可以传入 null 或者 undefined 作为占位符。

可变长的实参列表:实参对象

上面讲了调用函数时传入的实参数量比函数定义时的形参数量少的情况,下面再讲讲实参比形参多的情况。对于这种情况,普通的方式无法获取到多出来的实参的信息,这个时候就要用实参对象了。在函数体中,标识符 arguments 是指向实参对象的引用。因为实参对象是一个类数组对象,这样通过下标就可以访问各个实参了,而不用担心实参比形参多导致没法通过名字来得到实参了。

下面就是实参对象的示例用法:

function f(x, y, z) {
  if (arguments.length !== 3) throw new Error;
  // ...
}
1
2
3
4

不过实际上用不着这样做,因为 JavaScript 默认会处理好各种事情:没有传入的实参都是 undefined,多余的实参也会自动忽略(TODO: 忽略是指什么意思?没法通过形参访问多余的实参?)。

注意:如果传入函数的参数为对象,在函数内直接修改对象的话,外部的对象的值也会同步更改;如果传入的是原始值,则不会有这种情况。TODO: 这个知识点值得单独拿出来进行讨论。

实参对象的一个重要用途,就是可以让函数操作任意数量的实参:

function max(/* ... */) {
  var max = Number.NEGATIVE_INFINITY;
  for (var i = 0; i < arguments.length; i++)
    if (arguments[i] > max) max = arguments[i];
  return max;
}

var largest = max(1, 10, 100, 2, 3, 1000, 4, 5, 10000, 6); // => 10000
1
2
3
4
5
6
7
8

这种可以接收任意个实参的函数,也叫做不定实参函数 ( varargs function )。

对于不定实参函数,参数的个数最好不要是 0。arguments[] 对象适用的函数,是包含固定个数已命名的必需参数,且跟着不定个可选实参的函数。

一定要注意,arguments 并不是数组,而是实参对象。它其实就是个对象,只是碰巧可以用数字索引而已。

实参对象还有一个特殊的性质:在非严格模式中,如果函数定义了形参,那么这个 类数组 的实参对象中的各个元素就是形参所对应实参的别名。形参的名称,和实参对象的数组元素,就是相当于同一个变量的两个名称而已。通过形参名称和 arguments 都可以改变参数的值:

function f(x) {
  console.log(x); // 输出实参的初始值
  arguments[0] = null; // 修改实参数组的元素也会修改 x 的值
  console.log(x); // => null
}
1
2
3
4
5

如果实参对象只是个普通数组的话,上面第二次输出的结果就不会是 null 了。在上面的例子中,arguments[0]x 指向的是同一个值,修改其中一个就会影响另一个。

在 ES5 的严格模式中,实参对象的这种特性被移除了。而且,在严格模式中,arguments 是关键字,是和 ifvar 一样的关键字了。

实参对象的 calleecaller 属性

在 ES5 严格模式中没法使用这两个属性,在非严格模式中,callee 指的是当前正在执行的函数,caller 指的则是调用当前正在执行函数的函数。通过 caller 可以访问调用栈,而 callee 则可以在匿名函数中递归地调用自身。

var factorial = function (x) {
  if (x <= 1) return 1;
  return x * arguments.callee(x - 1);
}
1
2
3
4

将对象属性用作实参

如果函数的参数多了,又要记住每个参数的含义,又要记住各个参数的顺序,岂不是很麻烦?这个时候,就可以把函数改进一下,把参数包裹在一个对象里面,传参的时候只传一个对象,岂不皆大欢喜?

function sepeParams(name, email, tel, mobile, wechat) { ... }
function packParams(contact) { ... }
1
2

上面两个函数,哪个用起来更方便,一看便知吧?

实参类型

JavaScript 没法限制形参的类型,传入实参时,语言本身也没有类型检查机制。变通的方法,或者是采用语义化的单词来给参数命名,或者给参数补充注释。

由于 JavaScript 在一些情况下会执行类型转换,比如函数接收一个字符串参数,如果传入的实参不是字符串,那么 JavaScript 会自动转换成字符串,并且这个过程一般不会报错。可如果函数需要一个数组,却传入一个原始值呢?

一种方法,就是对传入的参数执行严格的类型检查,并且对于所有非预期的参数类型给予准确的提示(报错)。在传参阶段的报错远比执行阶段的报错容易处理。

function sum(a) {
  if (isArrayLike(a)) {
    var total = 0;
    for (var i = 0; i < a.length; i++) {
      var element = a[i];
      if (element == null) continue; // 跳过 null 和 undefined
      if (isFinite(element)) total += element;
      else throw new Error("sum(): elements must be finite numbers");
    }
    return total;
  }
  else throw new Error("sum() : argument must be array-like");
}
1
2
3
4
5
6
7
8
9
10
11
12
13

另一种方法,则是先尽量对传入的参数进行可能的转换,所有尝试都失败之后,再报错给用户,这样函数的灵活性就更强了。

function flexisum(a) {
  var total = 0;
  for(var i = 0; i < arguments.length; i++) {
    var element = arguments[i], n;
    if (element == null) continue; // 忽略 null 和 undefined 实参
    if (isArray(element)) // 实参是数组的话
      n = flexisum.apply(this, element); // 就递归累加
    else if (typeof element === 'function') // 否则如果是函数
      n = Number(element()); // 执行函数并对结果做类型转换
    else
      n = Number(element); // 否则直接类型转换
    if (isNaN(n)) // 转换失败就报错
      throw Error("flexisum(): can't convert " + element + " to number");
    total += n; // 否则就累加
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

作为值的函数

函数最重要的特性,是它可以被定义,还可以被调用。在 JavaScript 中,函数不仅是语法,它也是值。这样一来,就可以把函数赋值给变量,可以把它保存在对象属性或者数组元素中,还可以作为参数传入到别的函数中。

为了理解上面所说的“函数不仅是语法,它也是值”,先来看看下面这个函数定义:

function square(x) { return x * x; }
1

上面的函数定义创建了一个函数对象,并把它赋给变量 square。函数的名称其实无关紧要,它只不过是指向函数对象的一个变量名而已。这个函数对象也可以赋给另一个变量,这两个变量用起来是一样的:

var s = square; // square 和 s 指向同一个函数对象
square(4); // => 16
s(4); // => 16
1
2
3

函数对象不仅可以赋给变量,还可以赋给对象属性。前面讲过,作为对象属性的函数就叫方法。

var o = {square: function(x) { return x*x; }}; // 对象的属性值是函数
var y = o.square(4); // => 16
1
2

函数没有名字也完全 OK:

var a = [function(x) { return x*x; }, 20]; // 数组元素是没有名字的函数
a[0](a[1]); // => 400
1
2

最后这种函数的格式可能看着还挺怪,但它也是完全合法的函数调用表达式。

下面的示例就演示了作为值的函数可以怎么用:

// 定义四个简单的函数
function add(x, y) { return x + y; }
function subtract(x, y) { return x - y; }
function multiply(x, y) { return x * y; }
function divide(x, y) { return x / y; }

// 下面的函数接受函数作为参数,并且在函数内部将两个实参函数作为操作数传入另一个实参函数并执行
function operate(operator, operand1, operand2) {
  return operator(operand1, operand2);
}

// 下面的函数调用,计算的就是 (2 + 3) + (4 * 5) = 25
var i = operate(add, operate(add, 2, 3), operate(multiply, 4, 5));

// 这次把函数定义为对象的属性
var operators = {
  add: function (x, y) { return x + y; },
  subtract: function (x, y) { return x - y; },
  multiply: function (x, y) { return x * y; },
  divide: function (x, y) { return x / y; },
  pow: Math.pow // 预定义的函数也可以作为对象的属性值
};

// 在对象中查找第一个参数所对应的属性名,找到之后将后两个参数传入属性名所对应的函数并执行
function operate2(operation, operand1, operand2) {
  if (typeof operators[operation] === 'function')
    return operators[operation](operand1, operand2);
  else throw 'unknown operator';
}

// 计算的就是 ('hello' + ' ' + 'world')
var j = operate2('add', 'hello', operate2('add', '', 'world'));
// 用预定义函数计算
var k = operate2('pow', 10, 2);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

Array.sort() 这个方法也是函数作为值的一个很好的例子。因为可以用各种方式来对数组进行排序,所以 sort() 方法接受一个可选函数作为参数,用来决定应该如何排序。这个可选函数接受两个参数,并且返回一个值,这个值决定了哪个参数排在前面哪个排在后面。这样一来,sort() 方法的扩展性就非常强了,可以根据任何需求来进行排序。

自定义函数属性

在 JavaScript 中,函数是一种特殊的对象,也就意味着函数也可以有自己的属性。如果函数要用到一个每次调用时都保持不变的“静态”变量,给函数定义一个属性肯定比定义一个全局变量要好多了。比如说要让函数每次被调用时返回一个唯一的整数,同时函数每次调用时返回的值还不能相同,那么函数就需要记录它之前每次所返回的值,并且这些值应当在每次函数调用中都保存下来/持久化(persist)。虽然可以把这个值保存在一个全局变量中,但是这个值只有函数才会用到,所以还是保存为函数对象的属性更合适。下面的示例,就演示了一个每次调用时返回不同值的函数:

// 因为函数声明语句的作用域会被提升至文件顶部
// 所以可以在函数声明之前就为函数对象的属性赋值
uniqueInteger.counter = 0;

function uniqueInteger() {
  return uniqueInteger.counter++;
}

uniqueInteger(); // => 0
uniqueInteger(); // => 1
uniqueInteger(); // => 2
uniqueInteger(); // => 3...
1
2
3
4
5
6
7
8
9
10
11
12

再来看个例子:下面这个函数 factorial() 把自己看成是个数组,利用自身的属性来缓存前一次的计算结果。

function factorial(n) {
  // 有限大的正整数
  if (isFinite(n) && n > 0 && n === Math.round(n)) {
    // 如果没有缓存结果则进行计算,用数组索引是否存在来判断
    if (!(n in factorial)) {
      // 计算并缓存结果
      factorial[n] = n * factorial(n - 1);
    }
    return factorial[n];
  } else {
    // 输入参数不合法,直接返回 NaN
    return NaN;
  }
}
// 初始化阶乘的初值,用于更大阶乘的计算
factorial[1] = 1;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

作为命名空间的函数

在前面的章节讲过函数作用域:在函数体内定义的变量,只能在函数体内(及嵌套在该函数内部的函数体内)课件,在函数之外是不可见的。在所有函数之外定义的变量则是全局变量,在整个 JavaScript 程序中都是可见的。在不是函数的普通代码块内声明的变量,是没办法让它只在这段代码块内可见的,所以可以通过定义函数来创建一个临时的命名空间,这样在其内部定义的变量就不会污染全局命名空间了。

比如想要写一个 JS 模块,这个模块会用在各种地方,在这个模块中需要定义一个变量,来保存计算时的中间结果。如果不把模块定义在函数中的话,就没法确保所定义的这个变量不会和用到这个模块的代码相冲突。这样一来,把这个模块定义成函数自然就可以解决这个问题了。

function module() {
  // do something
}
module();

(function() {
  // do something
}());
1
2
3
4
5
6
7
8

在上面的代码中,第一种函数声明语句通过定义函数的方式来实现模块。第二种方式更为轻巧,定义一个匿名函数然后立刻调用它,这种方式用得也很普遍。function 关键字左边的圆括号,会让 JavaScript 认为括号里面的是 函数定义表达式 而不是 函数声明语句,这样就会立刻执行表达式并且返回计算结果。

下面的示例定义了一个匿名函数并将执行结果赋给变量 extend。执行结果 extend 也是一个函数,用于处理传入该函数的参数,将第二个及之后的参数的属性全都复制到第一个参数中。

var extend = (function() {
  for (var p in { toString: null }) {
    return function extend(o) {
      for (var i = 1; i < arguments.length; i++) {
        var source = arguments[i];
        for (var prop in source) o[prop] = source[prop];
      }
      return o;
    };
  }

  return function patched_extend(o) {
    for (var i = 1; i < arguments.length; i++) {
      var source = arguments[i];
      for (var prop in source) o[prop] = source[prop];

      for (var j = 0; j < protoprops.length; j++) {
        prop = protoprops[j];
        if (source.hasOwnProperty(prop)) o[prop] = source[prop];
      }
    }
    return o;
  };

  var protoprops = ['toString', 'valueOf', 'constructor',
    'hasOwnProperty', 'isPrototypeOf',
    'propertyIsEnumerable', 'toLocaleString'];
}());

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

函数属性、方法和构造函数

在 JavaScript 中,函数是值,对函数使用 typeof 操作符,返回结果是 function 这个字符串。但函数其实是一种特殊的对象,所以它也可以拥有自己的属性和方法。Function() 构造函数还可以用来创建新的函数对象。

length 属性

在函数体中,arguments.length 指的是传入函数的实参的数量,但是函数本身的 length 属性则指的是定义函数时的形参的数量,是只读属性。

下面定义的函数 check(),检查函数的形参数量 arguments.callee.length 和实参数量 arguments.length 是否相等,如果不相等则抛出异常。

function check(args) {
  var actual = args.length;
  var expected = args.callee.length;
  if (actual !== expected) {
    throw Error('Expected ' + expected + 'args; got ' + actual);
  }
}

function f(x, y, z) {
  check(arguments);
  return x + y + z;
}
1
2
3
4
5
6
7
8
9
10
11
12

prototype 属性

每个函数都有 prototype 属性,这个属性指向原型对象(prototype object),每个函数的原型对象也各不相同。把函数当构造函数使用的时候,新建的对象就会继承原型对象的属性。

在前面的章节讲过原型和 prototype 属性,在后面的章节也会继续深入探讨。

call()apply() 方法

可以把 call()apply() 看作是对象的方法,通过调用方法的形式来间接调用函数。传入这两个方法的第一个参数是被间接调用的函数的对象,也是函数的调用上下文,在函数体内用 this 来引用。

这两个方法的使用方式如下:

f.call(o);
f.apply(o);
1
2

上面的代码等价于:

o.m = f;    // 将函数 f 临时存储为 o 的方法 m
o.m();      // 调用这个临时方法
delete o.m; // 删除临时方法
1
2
3

在 ES5 的严格模式中,不管传入这两个方法的第一个参数是什么,它都是被间接调用的函数内的 this 的值。而在 ES5 的非严格模式,和 ES3 中,传入的第一个参数是 null 或者 undefined 的话,就会自动替换为全局对象,其它原始值则会被对应的包装对象所替代。

对于 call() 方法来说,第一个实参之后的所有参数都会被传入函数,f.call(o, 1, 2) 就向函数 f 中传入了两个数字 1 和 2。

而对于 apply() 来说,所传入的实参都在第二个数组实参中:f.apply(o, [1, 2])。这第二个参数,可以是实际的数组,也可以是类数组对象。这样一来,就可以把一个函数的实参对象 arguments 传入 apply() 来让另一个函数调用。

// 把对象 o 中的方法 m 替换为另一个方法
// 在调用原来的方法之前和之后输出日志
function trace(o, m) {
  var original = o[m];  // 在闭包中保存原始的方法
  o[m] = function() {   // 定义新的方法
    console.log(new Date(), 'Entering:', m);      // 输出日志
    var result = original.apply(this, arguments); // 调用原始函数
    console.log(new Date(), 'Exiting:', m);       // 输出日志
    return result;                                // 返回结果
  }
}
1
2
3
4
5
6
7
8
9
10
11

上面的函数 trace() 接收两个参数:一个对象和一个方法名,将指定的方法替换为一个新方法,新方法是一个 包裹 了原始方法的泛函数(特指一种变换,以函数为输入,输出可以是值,也可以是另一个函数)。这种动态修改现有方法的做法,可以叫做 monkey-patching

这两个方法的意义是什么?它俩可以自定义函数的调用上下文,也就是函数体内的 this

相关阅读:

bind() 方法

该方法顾名思义,主要是用来把函数绑定到对象上。将 bind() 以函数 f 的方法的形式调用,并且传入一个对象 o 作为参数之后,就会返回一个新的函数 g。以方法的形式调用新的函数 g,就会将原来的函数 f 作为对象 o 的方法来调用,也就是说,对象 o 是原函数 f 的调用上下文。所有传入新函数 g 的实参都会原封不动地传入原函数 f。

function f(y) { return this.x + y; }  // 需要绑定的函数
var o = { x: 1 };                     // 将与之绑定的对象
var g = f.bind(o);                    // 调用 g(x) 的时候其实在调用 o.f(x)
g(3);                                 // => 4
1
2
3
4

前面刚讲过 call()apply() 方法,那么想要实现同样的功能其实很简单:

function bind(f, o) {
  if (f.bind) return f.bind(o);
  return function() {
    return f.apply(o, arguments);
  }
}
1
2
3
4
5
6

在 ES5 中的 bind() 方法实际上并不只是把函数绑定到对象上,它还把第二个及之后的实参跟 this 绑定在一起。这是一种常见的函数式编程的技巧,叫做柯里化。

var sum = function(x, y) { return (this.x || 0) + y; };
var succ1 = sum.bind({ x: 1}, null);  // 第一个参数 { x: 1 } 作为 this 传入 sum,第二个参数 null 就是实参 x 的值
succ1(4);                             // => 5: 实参 y 的值为 4
var succ2 = sum.bind(null, { x: 1});  // 第一个参数 null 作为 this 传入 sum,第二个参数 1 就是实参 x 的值
succ2(4);                             // => 5: 实参 y 的值为 4
1
2
3
4
5

ES3 的 bind() 方法可以用下面的代码进行模拟,代码中把方法保存为 Function.prototype.bind,这样所有的函数对象就都会继承它了。

if (!Function.prototype.bind) {
  Function.prototype.bind = function(o /*, args */) {
    var self = this, boundArgs = arguments;

    return function() {
      var args = [], i;
      for(i = 1; i< boundArgs.length; i++) args.push(boundArgs[i]);
      for(i = 0; i< arguments.length; i++) args.push(arguments[i]);

      return self.apply(o, args);
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

上面自定义的 bind() 方法返回的函数是一个闭包,用到了外部函数内定以的 selfboundArgs 变量。

ES5 版本 bind() 方法的一些特性是上面的 ES3 版本没法模拟的。

  • 首先,ES5 中的方法所返回的函数对象具有 length 属性,这个属性的值等于所绑定函数的形参数量减去形参数量(但不会小于 0)(TODO: 没大看懂……)。
  • 其次,该方法还可以用作构造函数。如果把 bind() 返回的函数当作构造函数来用,那么就会忽略掉传给 bind()this 的值,并将原始的函数当作构造函数来调用,同时将传入的实参原样传给原始函数(TODO: 没大看懂……)。
  • 另外,该方法返回的函数没有 prototype 属性(普通函数的这个属性是删不掉的),那么把绑定的函数当作构造函数来用的话,所创建的对象就会继承原始函数的 prototype 属性。
  • 最后,跟 instanceof 运算符一起用的话,绑定的构造函数和未绑定的构造函数是一样的。

那么,bind() 方法的意义又是什么呢?一方面,它可以像 call()apply() 一样,自定义函数的调用上下文;另一方面,它还可以把函数柯里化!这样一来,就可以实现函数式编程了,这个概念也会在后面继续展开。

相关阅读:

toString() 方法

函数跟别的对象一样,也有 toString() 方法。ES 规范规定这个方法返回一个字符串,并且字符串和函数声明语句的语法相关(TODO: 没大看懂……)。

大部分情况下,这个方法返回的都是函数完整的源代码,不过内置的函数会把 [native code] 之类的字符串作为函数体返回。

Function() 构造函数

在前面定义函数的时候,不管是函数声明语句,还是函数定义表达式,用的都是 function 关键字。但是!也可以用 Function() 这个构造函数来定义函数。

var f = new Function('x', 'y', 'return x * y;');
var f = function(x, y) { return x * y; }
1
2

上面两种方式所定义的函数 f 基本上差不多。Function() 构造函数接受任意个字符串作为实参,最后一个实参就是函数体,实参由 JavaScript 语句组成,语句之间用分号分割。之前的所有实参都是所定义函数的形参。如果需要定义一个不接受参数的函数,那就只给构造函数 Function() 传一个字符串就行,也就是函数体。

跟函数表达式一样,Function() 构造函数创建的是匿名函数,所传入的参数没有用来定义函数名的。

有几点要注意:

  • 可以用 Function() 构造函数在运行时动态地创建和编译函数。
  • 每次调用 Function() 构造函数的时候,都会解析函数体并创建新的函数对象。如果在循环体内频繁调用构造函数的话,那么这个循环的性能就会很差。相比而言,循环体内的嵌套函数和函数定义表达式就只会在第一次循环时被编译。
  • 最重要的一点:Function() 构造函数所创建的函数不会使用词法作用域,而总是作为顶层的函数被编译(TODO: 也就意味着不会产生闭包?):
var scope = 'global';
function constructFunction() {
  var scope = 'local';
  return new Function('return scope');
}

constructFunction()();  // => 'global
1
2
3
4
5
6
7

可以认为 Function() 构造函数就是在全局作用域中的 eval(),在自己的私有作用域中定义新的变量和函数。在实际开发中,谨慎使用 Function()

可调用的对象

前面讲过类数组对象,虽然不是数组,但很多时候可以当数组来用。函数也是如此,可调用对象 本质上是一种对象,可以在函数调用表达式中以函数的形式调用。所有的函数都是可调用的,但并不是所有的可调用对象都是函数。

在现代的 JavaScript 环境中,在两种情况下会遇到不是函数的可调用对象:

首先,在 IE8 及更早版本中,Window.alert()Document.getElementById() 之类的客户端方法是通过可调用对象实现的,而不是通过原生的函数对象来实现。这些方法的表现和在其它浏览器中是一样的,但是它们并不是函数对象。IE 从 9 开始才使用真正的函数对象,所以浏览器中的函数对象会越来越少。

另一种不是函数的可调用对象比较常见一些:那就是 RegExp 对象。在很多浏览器中可以直接调用 RegExp 对象,比调用它的 exec() 方法要方便。开发者编写的代码尽量不要对 RegExp 对象的可调用性有依赖,这个特性将来很可能会被废弃掉。对这类对象执行 typeof,有些浏览器得到的结果是 function(新版的 Chrome、Firefox、Edge),有些浏览器则是 object

要想判断一个对象是不是正儿八经的函数对象,需要检查它的 class 属性:

function isFunction(x) {
  return Object.prototype.toString.call(x) === "[object Function]";
}
1
2
3

这里所用的判断方式和前面讲到过的 isArray() 是一样的。

函数式编程

虽然 JavaScript 并不是 Lisp 或者 Haskell 那样的函数式编程语言,但是 JavaScript 可以像操作对象那样操作函数,这就意味着也可以用 JavaScript 实现函数式编程。 ES5 中的数组方法 map()reduce() 就是函数式编程风格的。

用函数处理数组

假设有一个数组,想要计算它的平均值和标准差,结构化编程的代码就不用写了,各种循环,各种流程控制,看着就头大。

但是,有了 map()reduce(),这个世界就清新了!

var sum = function(x, y) { return x + y; };
var square = function(x) { return x * x; };

var data = [1, 1, 3, 5, 5];
var mean = data.reduce(sum) / data.length;
var deviations = data.map((x) => x - mean);
var stddev = Math.sqrt(deviations.map(square).reduce(sum) / (data.length - 1));
1
2
3
4
5
6
7

怎么样?看了上面的代码,世界是不是瞬间清晰了许多?

高阶函数

高阶函数接受函数作为参数,并且返回值也是函数。

function not(f) {
  return function() {
    var result = f.apply(this, arguments);
    return !result;
  };
}

var even = x => x % 2 === 0;  // 判断是否为偶数
var odd = not(even);          // 判断是否为奇数
[1, 1, 3, 5, 5].every(odd);   // => true
1
2
3
4
5
6
7
8
9
10

上面的 not() 函数就是一个高阶函数,它接受函数作为参数,并且返回值也是函数。下面的 mapper() 函数也是一个高阶函数,它接受一个函数作为参数,所返回的函数会对数组中的每个元素应用所传入的函数。这里的重点,是要理解它和 map() 的区别:

function mapper(f) {
  return function(a) { return a.map(f); };
}

var increment = function(x) { return x + 1; };
var incrementer = mapper(increment);
incrementer([1, 2, 3]); // => [2, 3, 4]
1
2
3
4
5
6
7

下面是一个更常见的例子:接收两个函数 f 和 g,返回一个新函数,用于计算 f(g())

function compose(f, g) {
  return function() {
    // 只需要给 f 传一个实参,所以用 call
    // g 需要传入实参数组,所以用 apply
    return f.call(this, g.apply(this, arguments));
  };
}

var square = function(x) { return x * x; };
var sum = function(x, y) { return x + y; };
var squareOfSum = compose(square, sum);
squareOfSum(2, 3);  // => 25
1
2
3
4
5
6
7
8
9
10
11
12

不完全函数

函数 fbind() 方法返回一个新函数,这个新函数用特点的上下文和实参调用 f。可以认为是把函数绑定到了对象上,并且传入一部分参数。bind() 方法把传入的参数放在参数列表的左侧,当然也可以放在右侧,下面的三个不完全调用值得好好地体会一下:

function array(a, n) {
  return Array.prototype.slice.call(a, n || 0);
}

var f = function(x, y, z) { return x * (y - z); };

function partialLeft(f /*, ...*/) {
  var args = arguments;               // => {"1":2},外部的实参数组的值
  return function() {
    var a = array(args, 1);           // => 2,外部实参数组的第一个参数是函数 f,所以从第二个实参开始截取
    a = a.concat(array(arguments));   // => 2,3,4 内部的实参数组为 {"0":3,"1":4},将其附在外部实参数组之后
    return f.apply(this, a);
  }
}

partialLeft(f, 2)(3, 4);              // => -2: 2 * (3 - 4)

function partialRight(f /*, ...*/) {
  var args = arguments;               // => {"1":2},外部的实参数组的值
  return function() {
    var a = array(arguments);         // => {"0":3,"1":4},内部的实参数组的值
    a = a.concat(array(args, 1));     // => 3,4,2 将外部实参数组附在内部实参数组之后
    return f.apply(this, a);
  }
}

partialRight(f, 2)(3, 4);             // => 6: 3 * (4 - 2)

function partial(f /*, ...*/) {
  var args = arguments;
  return function() {
    var a = array(args, 1);
    var i = 0, j = 0;
    for(; i < a.length; i++)
      if (a[i] === undefined) a[i] = arguments[j++];
    a = a.concat(array(arguments, j));// => 3,2,4
    return f.apply(this, a);
  }
}

partial(f, undefined, 2)(3, 4);       // => -6: 3 * (2 - 4)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

有了不完全函数,就可以利用现有的函数来编写新的函数,是不是很好玩?

var increment = partialLeft(sum, 1);
var cuberoot = partialRight(Math.pow, 1/3);
String.prototype.first = partial(String.prototype.charAt, 0);
String.prototype.last = partial(String.prototype.slice, -1);
1
2
3
4

如果再把不完全函数和高阶函数合并在一起,那就更好玩了。

var not = partialLeft(compose, function(x) { return !x; });
var even = function(x) { return x % 2 === 0; };
var odd = not(even);
var isNumber = not(isNaN);
1
2
3
4

还可以用函数的组合和不完全函数来重新编写上面求平均数和标准差的代码:

var data = [1, 1, 3, 5, 5];
var sum = function(x, y) { return x + y; };
var product = function(x, y) { return x * y; };
var neg = partial(product, -1);
var square = partial(Math.pow, undefined, 2);
var sqrt = partial(Math.pow, undefined, .5);
var reciprocal = partial(Math.pow, undefined, -1);

var mean = product(reduce(data, sum), reciprocal(data.length));
var stddev = sqrt(
  product(
    reduce(
      map(
        data,
        compose(
          square,
          partial(
            sum,
            neg(mean)
          )
        ),
        sum)
    ),
    reciprocal(
      sum(
        data.length,
        -1
      )
    )
  )
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

前面多次用到了嵌套函数,那么嵌套函数的实参,是否就是外部函数的实参呢?看看下面两段代码:

function a1(args) {
  console.log(`outer args: ${args}`);   // => outer args: 1,2,3
  return function() {
    console.log(`inner args: ${args}`); // => outer args: 1,2,3
  };
}

a1([1, 2, 3])([4, 5, 6]); // 嵌套函数内外的 args 相同

function a2(args) {
  console.log(`outer args: ${args}`);   // => outer args: 1,2,3
  return function(args) {
    console.log(`inner args: ${args}`); // => outer args: 4,5,6
  };
}

a2([1, 2, 3])([4, 5, 6]); // 嵌套函数内外的 args 不同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

根据上面代码的执行结果,可以判定:如果嵌套函数未传入实参,则其实参为父函数的实参列表(显然如此嘛,内部不存在的变量,肯定就要去外部找了);如果嵌套函数传入了实参,就用传入的实参。

记忆

在前面的章节中,定义过一个会缓存每次计算结果的阶乘函数。在函数式编程中,这种缓存的技巧叫做“记忆”。下面定义的高阶函数 memorize() 就接受一个函数作为实参,然后返回这个函数带有记忆功能的版本:

// 返回带记忆功能的 f
// 只有 f 的实参数组中各元素的字符串形式全不相同时才起作用
function memorize(f) {
  var cache = {};       // 在闭包中缓存值

  return function() {
    // 把实参数组转换为字符串形式,用作缓存的键
    var key = arguments.length + Array.prototype.join.call(arguments, ",");
    if (key in cache) return cache[key];
    return cache[key] = f.apply(this, arguments);
  };
}

// 只会输出一次 cache 和 key 的值
function f(num) {
  return num === 1 ? 1 : (num * f(num - 1));
}

var g = memorize(f);
g(5);

// 会输出每一次的 cache 和 key 的值
var factorial = memorize(function(n) {
  return (n <= 1) ? 1 : n * factorial(n - 1);
});

factorial(5);
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

TODO: 为什么上面定义的 f 记忆化之后,只会输出一次 cachekey?而 factorial 按预期的那样输出五次?

类和模块

在 JS 中,每个对象都是一组属性的集合,各个对象是互不相同的。也可以为共享一批属性的一组对象定义一个“类”,类的成员,或者说实例(也就是对象)拥有自己特有的属性来保存自身的状态,另外还有一些属性用来定义自身的行为——方法,所有的实例共享这些行为。假设有一个 Complex 类表示复数,同时还定义了复数运算。那么 Complex 的实例就可以在属性中保存复数的实部和虚部(状态),同时 Complex 类还可以定义复数的加法和乘法(行为)。

在 JavaScript 中,类是通过原型继承机制实现的。如果两个对象从同一个原型对象上继承属性,那么它俩是同一个类的实例。

如果两个类继承同一个原型,一般来说它俩是由同一个构造函数所创建和初始化的。

如果熟悉 Java 或者 C++ 之类的强类型语言的话,你就会发现 JavaScript 的类和这些语言的大不一样。两者之间在语法上是有些相似,JavaScript 也可以模拟经典的类的特性,但是对于 JavaScript 的类及其基于原型的继承机制,以及 Java 的类及其基于类的继承机制来说,一定要理解这两者是完全不同的。

JavaScript 中的类的一个重要特性就是可以动态扩展,后面的小节会深入探讨。类也可以看做是一种类型,就像字符串、数字、对象那样,有好几种方法可以确定对象属于哪种类型。另外本章还会介绍一种编程哲学——鸭式辩型,它弱化了对象的类型,强调类的功能。

编写模块化的、可重用的代码有很多种方式,定义类就是其中之一。本章最后一节会专门讨论 JavaScript 中的模块。

最后更新于: 7/30/2018, 7:22:27 PM