0%

JavaScript 权威指南 11:JavaScript 标准库

接下来将学习一些重要却没有那么基础的 API,可以将它们看作 JavaScript 的标准库,包括 JavaScript 内置的、在浏览器和 Node 中对所有 JavaScript 程序都可用的类和函数。

集合与映射

JavaScript 的 Object 类型是一种万能数据结构,可用于把字符串(对象的属性名)映射为任意值,当映射为固定值(例如 true),对象就可以作为字符串的集合。对象在 JavaScript 编程中经常被用作映射和集合,但却要受到对字符串约束的限制。另外对象正常都会继承带名字(如toString)的属性,这就会带来一些干扰。

为此,ES6 新增了真正的 Set 和 Map类。

Set

集合就是一组值,集合没有索引或顺序,也不允许重复:一个值要么是集合的成员,要么不是;这个值不可能在一个集合中出现多次。

  • 使用 Set() 构造函数创建集合对象
  • Set() 构造函数的参数不一定是数组,但必须是一个可迭代对象(可以是其他集合)
  • 集合的 size 属性类似数组的 length 属性,保存着集合包含多少个值
1
2
3
4
5
6
7
8
9
> let s = new Set()
> let s1 = new Set([1, s])
> s1
Set(2) { 1, Set(0) {} }
> let u = new Set("AABBCC")
> u
Set(3) { 'A', 'B', 'C' }
> u.size
3

集合不一定在创建时初始化,可以在创建之后再通过 add()delete()clear() 方法给它添加元素或从中删除元素。

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
> let s = new Set()
> s.size
0

> s.add(1)
Set(1) { 1 }
> s.add(1)
Set(1) { 1 }
> s.add([1, 2, 3])
Set(2) { 1, [ 1, 2, 3 ] }

> s.delete(1)
true
> s
Set(1) { [ 1, 2, 3 ] }
> s.delete("no")
false

> s.clear()
> s.size
0

> s.add([1, 2, 3])
Set(1) { [ 1, 2, 3 ] }
> s.delete([1, 2, 3])
false

> s.has([1, 2, 3])
false
  • add() 方法接收一个参数,如果传入一个数组,它会把数组而不是数组的元素添加到集合中。add() 始终返回调用它的集合,因此如果想给集合添加多个值,可以连缀调用 add()
  • delete() 方法一次也只删除一个集合元素。delete() 返回一个布尔值。如果指定的值确实是一个集合成员,那么 delete() 删除它并返回 true;否则,delete() 什么也不做并返回 false
  • 最后,很重要的一点是要记住集合成员是根据严格相等来判断是否重复的。所以对于引用类型,即使两个对象(包括数组)的内容相同,它们也被认为是不同的值

检查某个值是不是集合的成员,可以使用 has() 方法。而且该方法专门做了优化,无论集合有多少成员,has() 方法都非常快。

1
2
3
4
5
> let s = new Set([1, 2])
> s.has(1)
true
> s.has("1")
false

Set类是可迭代的,这意味着可以使用 for/of 循环枚举集合的所有元素。因为 Set 象是可迭代的,所以可以使用扩展操作符 ... 把集合转换为数组或参数:

1
2
> [...s]
[ 1, 2 ]

JavaScript 的 Set 类会记住元素的插入顺序,而且始终按该顺序迭代集合:第一个元素第一个迭代(假定之前没有删除它)​,刚刚添加的元素最后一个迭代。

除了可以迭代,Set 类也实现了一个 forEach() 方法,与数组的同名方法类似,但是第一个和第二个参数都是元素的值:

1
2
3
> s.forEach((n, m) => { console.log(n, m) })
1 1
2 2

Map 类

Map 对象表示一组键值对,它可以使用任何值作为 key。无论映射有多大,查询与某个键关联的值都很快(虽然没有通过索引访问数组那么快)​。

  • 可以使用 Map() 构造函数创建映射对象
  • Map() 构造函数的可选参数应该是一个可迭代对象,产出值为包含两个元素的数组 [key,value]​
  • 这意味着如果想在创建映射时初始化它,通常需要把关联的键和值写成数组的数组的形式
  • 也可以使用 Map() 构造函数复制其他映射
  • 或者从已有对象复制属性名和值
1
2
3
4
5
6
7
8
9
10
11
12
> let m = new Map()
> let n = new Map([["one", 1], ["two", 2]])
> n
Map(2) { 'one' => 1, 'two' => 2 }

> let n1 = new Map(n)
> let n2 = new Map(Object.entries({x:1, y:2}))

> n1
Map(2) { 'one' => 1, 'two' => 2 }
> n2
Map(2) { 'x' => 1, 'y' => 2 }

创建 Map 对象后,可以使用 get() 方法和键来查询关联的值,可以使用 set() 方法添加新的键/值对

  • 如果调用 set() 传入一个映射中已经存在的键,将会修改与该键关联的值
  • 映射的 set() 方法可以连缀调用(即 set() 返回修改后的映射对象本身)
  • has() 检查映射中是否包含指定键
  • delete() 从映射中删除指定键(及其关联值)
  • clear() 删除映射中的所有键/值对
  • size 属性保存映射中键/值对的数量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> n2.set("x", 2)
Map(2) { 'x' => 2, 'y' => 2 }
> n2.get("x")
2
> n2.delete("x")
true
> n2.get("x")
undefined

> n2.has("y")
true

> n2.clear()
> n2.size
0

与集合一样,任何 JavaScript 值都可以在映射中作为键或值。这包括 nullundefinedNaN,以及对象和数组等引用类型。同样与集合类一样,映射按照全等性而非相等性比较键

1
2
3
4
5
6
7
> let n3 = new Map()
> n3.set({}, 1)
Map(1) { {} => 1 }
> n3.set({}, 2)
Map(2) { {} => 1, {} => 2 }
> n3.get({})
undefined

映射对象是可迭代的,迭代的每个值是一个两个元素的数组,其中第一个元素是键,第二个元素是与该键关联的值:

  • 如果对映射对象使用扩展操作符,会得到一个数组的数组,就像传给 Map() 构造函数的一样。
  • 在使用 for/of 循环迭代映射时,习惯上通过解构赋值把键和值赋给不同的变量:
  • 与 Set 类一样,Map 类也是按照插入顺序迭代的,即迭代的第一个键/值对是最早添加到映射中的,最后一个键/值对是最晚添加的
  • 如果只想迭代映射的键或关联的值,可以使用 keys() 和 values() 方法,它们也是按照插入顺序返回键或值
  • entries() 方法返回的可迭代对象用于迭代键/值对,与直接迭代映射一样
  • 映射也实现了 forEach() 方法,可以迭代映射。但是特别注意,这里传给回调的参数值在前、键在后(可以把映射想象成一种通用数组,只不过整数索引被替换为任何键值。数组的 forEach() 方法是先传数组元素,后传数组索引,映射的 forEach() 方法也先传映射的值,后传映射的键)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
> [...n]
[ [ 'one', 1 ], [ 'two', 2 ] ]

> for ( let [k, v] of n ) { console.log(k, v); }
one 1
two 2

> [...n.keys()]
[ 'one', 'two' ]
> [...n.values()]
[ 1, 2 ]
> [...n.entries()]
[ [ 'one', 1 ], [ 'two', 2 ] ]

> n.forEach((v, k) => console.log(v, k))
1 one
2 two

WeakMap 和 WeakSet

WeakMap(弱映射)类是 Map 类的一个变体(不是子类),它不会阻止键被当作垃圾收集。垃圾收集是 JavaScript 解释器收回内存空间的过程,凡是已经 无法访问 因而无法被程序使用的对象,都会被当作垃圾收回。

  • 常规映射对自己的键保持着 引用,即使对它们的所有其他引用都不存在了,仍然可以通过映射访问这些键
  • WeakMap 保持着对它们键的 引用,因此无法通过 WeakMap 访问这些键

WeakMap 的存在并不妨碍它们的键被回收。WeakMap 的主要用途是实现值与对象的关联而不导致内存泄漏。

  • WeakMap 的键必须是对象或数组,原始值不受垃圾收集控制,不能作为键
  • WeakMap 只实现了 get()、set()、has()和 delete() 方法。特别地,WeakMap 不是可迭代对象,所以没有定义 keys()、values() 和 forEach()方法。如果 WeakMap 是可迭代的,那么它的键就是可访问的,也就谈不上
  • WeakMap 没有实现 size 属性,因为弱映射的大小可能随着对象被当作垃圾收集而随时改变

WeakSet(弱集合)实现了一组对象,不会妨碍这些对象被作为垃圾收集:

  • WeakSet 不允许原始值作为成员
  • WeakSet 只实现了 add()、has() 和 delete()方法,而且不可迭代
  • WeakSet 没有 size 属性

WeakSet 的使用场景不多,其主要应用场景与 WeakMap 类似。

定型数组与二进制数据

常规 JavaScript 数组可以包含任意类型的元素,可以动态扩展或收缩。ES6 新增了定型数组(typed array),与 C 语言的低级数组非常接近。定型数组严格来讲并不是数组(Array.isArray()对它们返回false)​,但它们实现了所有数组方法,外加几个它们自己的方法。

定型数组与常规数组存在如下几个非常重要的区别:

  • 定型数组的元素全部都是数值,定型数组允许指定存储在数组中的数值的类型(有符号和无符号数组以及IEEE-754浮点数)和大小(8 位到 64 位)
  • 创建定型数组时必须指定长度,且该长度不能再改变
  • 定型数组的元素在创建时始终都会被初始化为 0

定型数组的类型

JavaScript 定义了 11 种定型数组,每种都有自己的元素类型和构造函数:

  • Int8Array():有符号 8 位字节
  • Uint8Array():无符号 8 位字节
  • Uint8ClampedArray():无符号 8 位字节(上溢不归 0)
  • Int16Array():有符号 16 位整数
  • Uint16Array():无符号 16 位整数
  • Int32Array():有符号 32 位整数
  • Uint32Array():无符号 32 位整数
  • BigInt64Array():有符号 64 位 BigInt 值
  • BigUint64Array():无符号 64 位 BigInt 值
  • Float32Array():32 位浮点数
  • Float64Array():64 位浮点数(对应 JavaScript 的浮点数)

上面每种定型数组构造函数都有一个 BYTES_PER_ELEMENT 属性,根据类型不同,这个属性的值可能是 1、2、4、8。

创建定型数组

  • 创建定型数组最简单的方式就是调用相应的构造函数,并传入一个表示数组元素个数的数值参数。如果以这种方式创建定型数组,则数组元素一定会全部初始化为 0、0n 或 0.0
1
2
3
> let bytes = new Uint8Array(1024)
> bytes.length
1024
  • 每种定型数组构造函数都有静态的工厂方法 from() 和 of(),类似于 Array.from() 和 Array.of():
  • 构造函数和 from() 工厂方法都支持复制已有的定型数组,尽管类型可能会改变
  • 在通过已有数组、可迭代或类数组对象创建新定型数组时,为适应类型限制,已有的值可能被截短。在此过程中,不会有警告,也不会报错
1
2
3
4
5
6
7
8
9
10
11
> let a = Uint8Array.of(1, 2, 3, 4)
> a
Uint8Array(4) [ 1, 2, 3, 4 ]

> let a1 = Uint8Array.from([1, 2, 3, 4], x => x * 2)
> a1
Uint8Array(4) [ 2, 4, 6, 8 ]

> let a11 = Uint32Array.from(a1)
> a11
Uint32Array(4) [ 2, 4, 6, 8 ]
  • 还有一种创建定型数组的方式,该方式要用到 ArrayBuffer 类型。ArrayBuffer是对一块内存的不透明引用。可以通过构造函数创建 ArrayBuffer,只要传入想分配内存的字节数即可
  • ArrayBuffer 类不允许读取或写入分配的任何字节。但是可以创建使用该缓冲区内存的定型数组,通过这个数组来读取或写入该内存
    • 在调用定型数组的构造函数时需要将 ArrayBuffer 作为第一个参数
    • 将该缓冲区内的字节偏移量作为第二个参数(可选)
    • 将数组的长度(单位是元素而非字节)作为第三个参数(可选)
    • 如果省略第二个和第三个参数,则数组会使用缓冲区的所有内存
    • 如果只省略长度参数,则数组会使用从起点位置到缓冲区结束的所有可用内存
    • 数组的内存必须是对齐的,所以如果你指定了字节偏移量,那么这个值应该是类型大小的倍数
    • 当通过同一个 ArrayBuffer 创建多个定型数组时,它们底层共享该 ArrayBuffer,可以认为不同的定型数组是该 ArrayBuffer 的不同视图
1
2
3
4
5
6
> let a0 = new Uint32Array(buf)
undefined
> a0.length
262144
> buf.byteLength / 4
262144

如果调用定型数组构造函数时没有传缓冲区对象,则会自动以适当大小创建一个缓冲区。所有定型数组都有一个 buffer 属性,引用自己底层的 ArrayBuffer 对象。

使用定型数组

  • 创建定型数组后,可以通过常规的中括号语法读取或写入其元素,与操作其他类数组对象一样。
  • 定型数组并不是真正的数组,但它们重新实现了多数数组方法,因此几乎可以像使用常规数组一样使用它们
1
2
3
4
5
6
7
8
9
10
11
> let ints = new Uint16Array(10);
> ints[0] = 10;
10
> ints
Uint16Array(10) [
10, 0, 0, 0, 0,
0, 0, 0, 0, 0
]

> ints.fill(3).map(x => x *x).join("")
'9999999999'
  • 定型数组的长度是固定的,因此 length 属性是只读的,而定型数组并未实现改变数组长度的方法(例如 push()、pop() 等),但实现了修改数组内容而不改变长度的方法(如 sort()、reverse() 和 fill())

  • 诸如 map()slice() 等返回新数组的方法,则返回与调用它们的定型数组相同类型的数组

定型数组的方法与属性

除了标准的数组方法,定型数组也实现了它们自己的一些方法。

  • set() 方法用于一次性设置定型数组的多个元素,即把其他常规数组或定型数组的元素复制到当前定型数组中

    • 以一个数组或定型数组作为其第一个参数
    • 以一个元素偏移量作为其可选的第二个参数,如果不指定则默认为 0。
  • 定型数组也有一个 subarray 方法,返回调用它的定型数组的一部分

    • subarray() 接收与 slice() 方法相同的参数
    • 但二者有个重要区别:slice() 以新的、独立的定型数组返回指定的元素,不与原始数组共享内存;而 subarray() 则不复制内存,只返回相同底层值的一个新视图:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
> let bytes = new Uint8Array(1024)
> let pattern = new Uint8Array([1, 2, 3, 4])

> bytes.set(pattern)
> bytes.set(pattern, 4)

> let s1 = bytes.slice(0, 8)
> let s2 = bytes.subarray(0, 8)
> bytes[0] = 100

> s1
Uint8Array(8) [
1, 2, 3, 4,
1, 2, 3, 4
]
> s2
Uint8Array(8) [
100, 2, 3, 4,
1, 2, 3, 4
]

每个定型数组都有 3 个属性与底层的缓冲区(ArrayBuffer)相关:

1
2
3
4
5
6
> s2.buffer === bytes.buffer
true
> s2.byteOffset
0
> s2.byteLength
8
  • buffer 属性是数组的 ArrayBuffer,byteOffset 是数组数据在这个底层缓冲区的起点位置,而 byteLength 是数组数据的字节长度
  • a.length * a.BYTES_PER_ELEMENT === a.byteLength

需要注意,ArrayBuffer 只是不透明的字节块。通过定型数组可以访问其中的字节,但 ArrayBuffer 本身并不是定型数组。你可以像对任何 JavaScript 对象一样对 ArrayBuffer 使用数值索引。但这样做并不会访问缓冲区中的字节,而是在 ArrayBuffer 对象上设置一个属性:

1
2
3
4
5
6
7
8
9
10
11
> let b = new Uint8Array(2)
> b.buffer
ArrayBuffer { [Uint8Contents]: <00 00>, byteLength: 2 }

> b.buffer[1] = "test"
'test'
> b.buffer
ArrayBuffer { [Uint8Contents]: <00 00>, byteLength: 2, '1': 'test' }

> b[1]
0

DataView 与字节序

使用定型数组可以查看相同字节序列的 8、16、32 或 64 位视图。这就涉及 字节序 问题了。为效率考虑,定型数组使用底层硬件的原生字节序。在小端系统中,ArrayBuffer 中的字节排列顺序为低字节到高字节。在大端系统中,字节排列顺序为高字节到低字节:

1
2
3
> let littleEndian = new Int8Array(new Int32Array([1]).buffer)[0] === 1
> littleEndian
true

DataView 类定义的方法可以显式指定读、写 ArrayBuffer 值时的字节序:

  • DataView 为 10种定型数组类(不包括 Uint8ClampedArray)定义了 10 个 get 方法。这些方法的名字类似 getInt16、getUint32()、getBigInt64() 和 getFloat64()

    • 它们的第一个参数是 ArrayBuffer 中的字节偏移量,表示读取值的开始位置
    • 这些读取方法(除 getInt8() 和 getUint8() 之外)都接收一个可选的布尔值作为第二个参数。true 表示使用小端字节序,false 或者省略则表示大端
  • DataView 也定义了 10 个对应的设置方法,用于向底层 ArrayBuffer 写入值。

    • 这些方法的第一个参数是偏移量,表示写入值的开始位置。
    • 第二个参数是要写入的值
    • 第三个参数(可选)则表示字节序:true 表示使用小端字节序,false 或者省略则表示大端
1
2
3
4
5
6
7
8
9
10
11
12
> let b = new Uint32Array([0x01020304])
> view = new DataView(b.buffer, b.byteOffset, b.byteLength)
DataView {
byteLength: 4,
byteOffset: 0,
buffer: ArrayBuffer { [Uint8Contents]: <04 03 02 01>, byteLength: 4 }
}

> view.getUint32(0, true).toString(16)
'1020304'
> view.getUint32(0, false).toString(16)
'4030201'

定型数组和 DataView 提供了处理二进制数据所需的全部工具。

正则表达式与模式匹配

正则表达式是一种描述文本模式的对象。JavaScript 的 RegExp 类表示正则表达式。String 和 RegExp 都定义了使用正则表达式对文本执行强大模式匹配和搜索替换功能的方法。

定义正则表达式

RegExp 对象可以使用 RegExp() 构造函数来创建,但更多的是通过一种特殊的字面量语法来创建。正则表达式字面量就是包含在一对斜杠 / 字符之间的字符,例如:

1
2
3
4
5
> let pattern = /s$/
> typeof pattern
'object'
> pattern instanceof RegExp
true

同样的正则表达式对象用 RegExp() 构造函数创建:

1
> let p2 = new RegExp("s$")

正则表达式也支持一个或多个标志字符,用于控制匹配的方式:

  • 在正则表达式字面量中,标志需要放在第二个斜杠字符后面
  • 在 RegExp() 构造函数中,标志要作为第二个字符串参数

正则表达式字符

接下来快速介绍 JavaScript 正则表达式中使用的各种字符和元字符。

字符字面量:

  • 所有字母字符和数字在正则表达式中都匹配自身的字面值。JavaScript 正则表达式语法通过以反斜杠 \ 开头的转义序列也支持一些非字母字符,例如 \n 匹配换行符。
  • 有一些英文标点符号在正则表达式中具有特殊含义,如果想在正则表达式中包含这些标点符号的字面值,必须在这些字符前面加个反斜杠 \
  • 如果记不住哪些标点符号字符需要使用反斜杠转义,可以给所有标点符号字符前面都加上反斜杠。但也要记住,也要知道很多字母和数字前面如果加了反斜杠也会具有特殊含义,因此任何想匹配字面值的字母和数字都不应该加反斜杠
  • 如果要匹配反斜杠本身,则要使用 \\
  • 如果使用 RegExp() 构造函数,则要记住,正则表达式中的任何反斜杠都要写两次,因为字符串也使用反斜杠作为转义字符

字符类:

  • 把个别字面值字符放到方括号中可以组合成字符类。字符类匹配方括号中包含的任意字符
  • 也可以定义排除性的字符类,匹配除方括号中包含的字符之外的任意字符。排除性字符类就是把插入符号 ^ 作为方括号中的第一个字符
  • 字符类可以使用连字符表示字符范围,例如 [a-z] 表示所有小写字母。如果想通过字符类匹配真正的连字符,只要把它放到右方括号前面,作为字符类的最后一个字符即可
  • 某些字符类很常用,JavaScript 正则表达式语法中包含一些特殊字符和转义序列来表示这些字符类
    • \w:任意 ASCII 单词字符
    • \W:任意 ASCII 非单词字符
    • \s:任意 Unicode 空白字符
    • \S:任意非 Unicode 空白字符
    • \d:任意 ASCII 数字字符
    • \D:任意非 ASII 数字字符
    • .:任意单个字符(换行符除外)。如果 RegExp 对象设置了 s 标志,. 也匹配换行符

所有特殊字符类转义序列本身也可以出现在方括号中。例如 \s 匹配任意空白字符,而 \d 匹配任意数字,所以 /[\s\d]/ 匹配任意空白字符或数字。

在 ES2018 中,如果正则表达式使用了 u 标志,则支持字符类 \p{...} 及其排除性形式 \P{...}

指定重复:

指定重复的字符始终跟在应用它们的模式后面。由于某些重复的形式非常常用,还会有特殊字符表示这些情况。

  • {n, m}:匹配前项至少 n 次,但不超过 m 次
  • {n, }:匹配前项 n 或更多次
  • {n}:匹配前项恰好 n 次
  • ?:匹配前项 0 次或 1 次,即前项是可选的
  • +:匹配前项 1 次或更多次
  • *:匹配前项 0 次或更多次

非贪婪重复:

上面所介绍的这些重复字符会尽可能多地匹配,同时也允许正则表达式剩余的部分继续匹配。我们说这种重复是 贪婪的。在重复字符后面简单地加个问号,就可以指定非贪婪地重复。

任选、分组和引用:

正则表达式的语法中也包含指定任选、分组子表达式和引用前面子表达式的特殊字符:

  • 竖线字符 | 用于分隔任选模式,例如 /abc|def/ 可以匹配字符串 “abc” 或 “def”。
  • 圆括号在正则表达式中有几种不同的作用,一种作用是把独立的模式分组为子表达式,从而让这些模式可以被 |、*、+、? 等当作一个整体
  • 圆括号在正则表达式中的另一个作用是在完整的模式中定义子模式。当正则表达式成功匹配一个目标字符串后,可以从目标字符串中提取出与圆括号包含的子模式对应的部分
  • 与圆括号分组的子表达式相关的一个用途是在同一个正则表达式中回引子表达式。回引前面的子表达式要使用 \ 字符加上数字。这里的数字指的是圆括号分组的子表达式在整个正则表达式中的位置,例如 \1 回引第一个子表达式。
  • 注意,由于子表达式可能会嵌套,所以它们的位置是按照左括号来计算的
  • 回引表达式可以引用得到对应子模式所匹配的文本,如下展示了一个示例,它可以保证前后的引号是匹配的:
1
/(['"])[^'"]*\1/
  • 如果不想让圆括号分组的子表达式生成数字引用,那么可以在圆括号的开头添加 ?:。这样只会产生一个分组,无法引用该分组

命名捕获组:

ES2018 标准化了一个新特性,让正则表达式可以自我解释且更容易理解。这个新特殊被称为 命名捕获组(named capture gorup)。可以给正则表达式中的每个左圆括号指定一个关联的名字,以便后面使用这个名字而不是数字来引用匹配的文本。

  • 要命名一个分组,使用 (?<...>) 而不是 (),把分组的名字放在尖括号内
  • 如果想在正则表达式中回引某个命名捕获组,可以使用名字。\k<quote> 是一个命名反向引用,引用捕获的命名分组

指定匹配位置:

一些正则表达式组件匹配字符间的位置而非实际的字符,有时候,这些组件也被称作正则表达式锚点,因为它们把模式锚定到被搜索字符串中特定的位置

  • ^ 匹配字符串开头
  • $ 匹配字符串末尾
  • \b 匹配 ASCII 单词边界
  • \B 匹配非单词边界
  • 可以使用任意正则表达式作为锚定条件。如果在 (?=) 字符之间包含一个表达式,它构成向前查找断言。即该表达式必须存在,但又不在匹配结果中
  • (?!)) 之间的表达式构成否定向前查找断言。即该表达式必须不存在

ES2018 扩展了正则表达式语法,支持 向后查找 断言。向后查找断言与向前查找断言类似,但关注的是当前匹配位置之前的文本。肯定式向前查找使用 (?<=...),否定式向后查找断言使用 (?<!...)。例如要搜索美国邮件地址,希望从中匹配 5 位邮政编码,但仅限于前面是两位字母的州简写的情况:

1
/(?<= [A-Z]{2})\d{5}/

标志:

每个正则表达式都可以带一个或多个标志,用于修改其行为:

  • g 标志表示正则表达式是 全局性的(global),即使用这个标志意味着想要找到字符串中包含的所有匹配项,而不只是找到第一个匹配项
  • i 标志表示模式匹配应该不区分大小写
  • m 标志表示匹配应该以 多行(multiline) 模式进行,即这个 RegExp 要用于多行字符串
  • s 标志同样可以用在要搜索的文本包含换行符的时候
  • u 标志代表 Unicode,可以让正则表达式匹配完整的码点而不是匹配 16 位值。这个标志是 ES6 新增的
  • y 标志表示正则表达式是 有粘性的(sticky),应该在字符串开头匹配或在紧跟前一个匹配后的第一个字符处匹配。它会导致 String的match() 方法和 RegExp的exec() 方法产生特殊行为,强制将每个后续匹配都锚定到前一个匹配(在字符串中)的结束位置

以上这些标志可以任意组合,顺序也不分先后。

模式匹配的字符串方法

String 支持 4 个使用正则表达式的方法:

  • search() 方法接收一个正则表达式参数,返回第一个匹配项起点字符的位置,如果没有找到匹配项,则返回-1

    • 如果 search() 方法的参数不是正则表达式,它会先把这个参数传给 RegExp() 构造函数,把它转换为正则表达式
    • search() 方法不支持全局搜索,因此其正则表达式参数中包含的 g 标志会被忽略。
  • replace() 方法执行搜索替换操作。它接收一个正则表达式作为第一个参数,接收一个替换字符串作为第二个参数

    • 如果 replace() 的第一个参数是字符串而非正则表达式,它不会将字符串通过 RegExp() 转换为正则表达式,而是会按照字面值搜索
    • 如果替换字符串中出现了 $ 符号后跟一个数字,replace() 会将这两个字符替换为与指定子表达式匹配的文本
    • 如果 RegExp 中使用的是命名捕获组,可以通过名字而非数字来引用匹配的文本
    • 除了给 replace() 传替换字符串,还可以传一个函数,这个函数会被调用以计算替换的值。这个替换函数在被调用时会接收到几个参数:
      • 第一个是匹配的整个文本
      • 然后是,如果 RegExp 有捕获组,则后面几个参数分别是这些捕获组匹配的子字符串
      • 然后是,匹配项在整个字符串中的位置
      • 然后是,调用 replace 方法的整个字符串
      • 最后如果 RegExp 包含命名捕获组,替换函数还会收到一个参数,这个参数是一个对象,其属性名是捕获组的名字,属性值是匹配的文本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> "JavaScript".search(/script/ui)
4
> "JavaScript".search(/script/u)
-1

> "Hello, java, JAVAScript".replace(/java/gui, "Java")
'Hello, Java, JavaScript'
> "she said: 'hello'".replace(/'([^']*)'/gu, "<<$1>>")
'she said: <<hello>>'

> "she said: 'hello'".replace(/'(?<content>[^']*)'/gu, "|$<content>|")
'she said: |hello|'

> "10 is 10".replace(/\d+/gu, n => parseInt(n).toString(16))
'a is a'
  • match() 是字符串最通用的正则表达式方法,它只有一个正则表达式参数(或者如果参数不是正则表达式,会把它传给 RegExp() 构造函数)​,返回一个数组,其中包含匹配的结果;如果没有找到匹配项,就返回 null
    • 如果正则表达式有 g 标志,这个方法返回的数组会包含在字符串中找到的所有匹配项
    • 如果正则表达式没有 g 标志,match() 不会执行全局搜索,只会查找第一个匹配项。在非全局搜索时,match() 仍然返回数组,但数组元素完全不同。此时数组第一个元素是匹配的字符串,剩下的所有元素是正则表达式中括号分组的捕获组匹配的子字符串。
    • 在非全局搜索的情况下,match()返回的数组除了可以通过数值索引的元素,也有一些对象属性,包括 index(匹配项在字符串中的位置)、input(原始字符串)和 groups(命名捕获组的对象)
    • y 标志对 match 的行为也有影响:第一个匹配项必须始于字符串开头,每个后续的匹配项必须从前一个匹配项的后一个字符开始。默认的起始位置可以通过 RegExp 的 lastIndex 属性设置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
> "1, 2, 3".match(/\d+/gu)
[ '1', '2', '3' ]

> "1, 2, 3".match(/\d+/u)
[ '1', index: 0, input: '1, 2, 3', groups: undefined ]

> "http://www.google.com/api".match(/(\w+):\/\/([\w.]+)\/(\S*)/)
[
'http://www.google.com/api',
'http',
'www.google.com',
'api',
index: 0,
input: 'http://www.google.com/api',
groups: undefined
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
> let p = /[aeiou]/y;

> "test".match(p)
null
> p.lastIndex = 1;
> "test".match(p)
[ 'e', index: 1, input: 'test', groups: undefined ]
> p.lastIndex
2

> "test".match(p)
null
> p.lastIndex
0
  • matchAll() 方法是 ES2020 中定义的,在 2020 年年初已经被现代浏览器和 Node 实现

    • matchAll() 接收一个带 g 标志的正则表达式
    • 返回一个迭代器,每次迭代都产生一个与使用 match() 时传入非全局 RegExp 得到的匹配对象相同的对象。
    • 也可以设置 RegExp 对象的lastIndex属性,告诉 matchAll() 从字符串中的哪个索引开始匹配。但是,与其他模式匹配方法不同的是,matchAll() 不会修改传入 RegExp 的 lastIndex 属性
  • split() 方法使用传入的参数作为分隔符,将调用它的字符串拆分为子字符串保存到一个数组中

    • 该参数可以是一个正则表达式,这样就可以指定更通用的分隔符。
    • 如果调用 split() 时传入 RegExp 作为分隔符,且这个正则表达式中包含捕获组,则捕获组匹配的文本也会包含在返回的数组中
1
2
3
4
5
6
7
> let s = "1, 2, 3,\n4, 5"
> s.split()
[ '1, 2, 3,\n4, 5' ]
> s.split(",")
[ '1', ' 2', ' 3', '\n4', ' 5' ]
> s.split(/\s*,\s*/)
[ '1', '2', '3', '4', '5' ]

RegExp 类

RegExp() 构造函数接收一个或两个字符串参数,创建一个新 RegExp 对象。

  • 第一个参数是正则表达式的文本,即在正则表达式字面量中出现在斜杠中间的部分
  • 注意,字符串字面量和正则表达式都使用 \ 字符转义,因此在以字符串字面量形式给 RegExp() 传入正则表达式时,必须把所有 \ 字符替换成 \\
  • RegExp() 的第二个参数是可选的。如果提供了这个参数,则代表指定正则表达式的标志
  • 除了给 RegExp() 的第一个参数传字符串,也可以传一个 RegExp 对象。这样可以复制已有的正则表达式,并且修改它的标志
1
let zipcode = new RegExp("\\d{5}", "g");

RegExp() 构造函数主要用于动态创建正则表达式,即创建那些无法用正则表达式字面量语法表示的正则表达式。

RegExp 提供 source、flags、global、ignoreCase、multiline、dotAll、unicode、sticky、lastIndex 等属性。

RegExp 类的 test() 方法是使用正则表达式的最简单方式。该方法接收一个字符串参数,如果字符串与模式匹配则返回 true,如果没有找到匹配项则返回 false。test() 方法的原理很简单,它会调用下面介绍的 exec() 方法,如果 exec() 返回非空值就返回true。

RegExp的 exec() 方法是使用正则表达式最通常、最强大的方式。该方法接收一个字符串参数,并从这个字符串寻找匹配:

  • 如果没有找到匹配项,则返回 null
  • 如果找到了匹配项,则会返回一个数组,跟字符串的 match() 方法在非全局搜索时返回的数组一样

exec() 方法无论正则表达式是否设置了 g 标志都会返回相同的数组,即始终都是返回一个匹配项。在通过设置了全局 g 或粘着 y 标志的正则表达式调用 exec() 时,exec() 会根据 RegExp 对象的 lastIndex 属性来决定从哪里开始查找匹配。

  • 如果设置了 y 标志,那么也会限制匹配项必须从该位置开始
  • 每次 exec() 成功执行,找到一个匹配项,都会更新 RegExp 的 lastIndex 属性,将其改写为匹配文本之后第一个字符的索引。如果 exec() 没有找到匹配项,它会将 lastIndex 重置为 0
  • 这个特殊行为让我们得以重复调用 exec(),从而逐个找到字符串中所有的匹配项
1
2
3
4
5
6
7
8
let pattern = /Java/g;
let text = "JavaScript > Java";
let match;

while ((match = pattern.exec(text)) !== null) {
console.log(`Matched ${match[0]} at ${match.index}`);
console.log(`Next search begins at ${pattern.lastIndex}`);
}
1
2
3
4
5
# node exec.js
Matched Java at 0
Next search begins at 4
Matched Java at 13
Next search begins at 17

JavaScript 正则表达式 API 其实挺复杂的。其中配合 gy 标志的 lastIndex 属性则是这套 API 中最费解的地方。每当使用这两个标志时,都要在调用 match()exec()test() 方法时特别小心。因为这些方法的行为依赖于 lastIndex,而 lastIndex 的值依赖于之前对 RegExp 对象做了什么。

如下是两个例子:

1
2
3
4
5
6
7
let match, positions = [];

// 可能无限循环,因为在循环结构中使用正则表达式字面量
// 每次循环都创建新的 RegExp 对象,每次都是 lastIndex 0 处开始搜索
while ((match = /<p>/g.exec(html)) !== null) {
positions.push(match.index);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
let s = ["apple", "book", "coffee"];
let results = [];
let pattern = /(\w)\1/g;

// 复用 pattern,再第一次匹配 apple 后,其 lastIndex 就更新为 3
// 因此匹配不到 book
for (let w of s) {
if (pattern.test(w)) {
results.push(w);
}
}

console.log(results); // => [ 'apple', 'coffee' ]

lastIndex 让 RegExp API 很容易出错。因此在使用 g 或 y 标志和循环时要格外注意。在ES2020 及之后的版本中,应该使用 String 的 matchAll() 方法而不是 exec() 来避开这个问题,因为 matchAll() 不会修改 lastIndex。

日期与时间

Date 类是 JavaScript 中用于操作日期和时间的 API。使用 Date() 构造函数可以创建一个日期对象。

  • 在不传参数的情况下,这个构造函数会返回一个表示当前日期和时间的 Date 对象
  • 如果传入一个数值参数,Date() 构造函数会将其解释为自 1970 年至今经过的毫秒数:
  • 如果传入两个或多个整数参数,它们会被解释为本地时区的年、月、日、时、分、秒和毫秒,
  • 在使用多个参数调用时,Date()构造函数会使用本地计算机的时区来解释它们。如果想以 UTC 指定日期和时间,可以使用 Date.UTC() 静态方法
1
2
3
4
5
> let d = new Date()
> d
2025-01-24T09:07:26.916Z
> d2
1970-01-01T00:00:00.000Z

如果要打印日期​,默认会以本地时区打印。如果想以 UTC 显示日期,应该先使用 toUTCString()toISOString() 转换它。

如果给 Date() 构造函数传入字符串,它会尝试按照日期和时间格式来解析该字符串。这个构造函数可以解析 toString()、toUTCString() 和 toISOString() 方法产生的格式

有了一个 Date 对象后,可以通过很多方法获取或设置这个对象的年、月、日、时、分、秒和毫秒字段。这些方法都有两种形式,一种使用本地时间获取和设置,另一种使用 UTC 时间获取和设置。例如 getFullYear()/getUTCFullYear()、setFullYear()/setUTCFullYear() 等。

时间戳

JavaScript 在内部将日期表示为整数,代表自 1970 年 1 月 1 日半夜 12 点起(或之前)的毫秒数。对于任何 Date 对象,getTime()方法返回这个内部值,而setTime()方法设置这个值。

这些毫秒值有时候也被称为时间戳 timestamp,有时候直接使用这些值比使用 Date 对象更方便。静态的 Date.now() 方法返回当前时间的时间戳。

1
2
3
4
5
6
> let d = new Date()
> d.getTime()
1737710290117

> Date.now()
1737710400557

高精度时间戳

Date.now() 返回的时间戳是以毫秒为单位的。毫秒对计算机来说实际上是个比较长的时间单位。有时候可能需要使用更高的精度来表示经历的时间。此时可以使用 performance.now(),虽然它返回的也是以毫秒为单位的时间戳,但返回值并不是整数,包含毫秒后面的小数部分。

  • performance.now() 返回的值并不是像 Date.now() 那样返回一个绝对时间戳,而是相对于网页加载完成后或 Node 进程启动后经过的时间
1
2
> performance.now()
253795.39952754974

日期计算

  • Date对象可以使用 JavaScript 标准的 <、<=、> 和 >= 等比较操作符进行比较
  • 可以用一个 Date 对象减去另一个以确定两个日期相关的毫秒数
  • 要完成涉及天数、月数和年数的计算,可以使用 setDate()、setMonth() 和 setYear()。日期设置方法即使在数值溢出的情况下也能正确工作

格式化与解析日期字符串

Date 类定义了如下字符串格式方法:

  • toString():使用本地时区但不按照当地惯例格式化日期和时间
  • toUTCString():使用 UTC 时区但不按照当地惯例格式化日期和时间
  • toISOString():使用 UTC 时区(结尾的 Z 可以看出)并以 ISO 8601 标准格式化日期和时间
  • toLocaleString():使用本地时区并根据当地惯例格式化日期和时间
  • toDateString():使用本地时区格式化日期(不包含时间)。它使用本地时区,但不与当地惯例适配
  • toLocaleDateString():使用本地时区并根据当地惯例格式化日期部分(不包含时间)
  • toTimeString():使用本地时区格式化时间部分(不包含日期)
  • toLocaleTimeString():使用本地时区并根据当地惯例格式化时间部分(不包含日期)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
> d.toString()
'Fri Jan 24 2025 17:29:07 GMT+0800 (China Standard Time)'
> d.toUTCString()
'Fri, 24 Jan 2025 09:29:07 GMT'
> d.toISOString()
'2025-01-24T09:29:07.987Z'
> d.toLocaleString()
'1/24/2025, 5:29:07 PM'

> d.toDateString()
'Fri Jan 24 2025'
> d.toLocaleDateString()
'1/24/2025'

> d.toTimeString()
'17:29:07 GMT+0800 (China Standard Time)'
> d.toLocaleTimeString()
'5:29:07 PM'

Date.parse() 静态方法接收一个字符串参数,并尝试将其作为日期和时间来解析,返回一个表示该日期的时间戳。

Error 类

JavaScript 的 throw 和 catch 语句可以抛出和捕获任何 JavaScript 值,包括原始值。JavaScript 也定义了一个 Error 类。惯常的做法是使用 Error 类或其子类的实例作为 throw 抛出的错误。

使用 Error 对象的一个主要原因就是在创建 Error 对象时,该对象能够捕获 JavaScript 的栈状态,如果异常未被捕获,则会显示包含错误消息的栈跟踪信息,而这对排查错误很有帮助(栈跟踪信息会展示创建 Error 对象的地方,而不是 throw 语句抛出它的地方)。

Error 对象有两个属性:message 和 name,还有一个 toString() 方法:

  • message 属性的值是我们传给 Error() 构造函数的值,必须时会被转换为字符串
  • 通过 Error() 创建的错误对象,其 name 属性值为 “Error”
  • toString() 方法返回一个字符串,由 name 属性的值后跟一个冒号和一个空格,再后跟 message 属性的值构成。

虽然 ECMAScript 标准并没有定义,但 Node 和所有现代浏览器也都在 Error 对象上定义了 stack 属性:包含创建错误对象时 JavaScript 调用栈的栈跟踪信息。在捕获到异常错误时,可以将这个属性的信息作为日志收集起来。

除了 Error 类,JavaScript 还定义了一些它的子类,以便触发 ECMAScript 定义的一些特殊类型的错误。这些子类包括:EvalError、RangeError、ReferenceError、SyntaxError、TypeError 和 URIError。

作为开发者,我们可以自己定义 Error 的子类,以便更好地封装自己程序的错误信息。自定义错误对象可以不限于 message 和 name 属性,可以任意添加新属性以提供更多的错误细节。

JSON 序列化与解析

当程序需要保存数据或需要通过网络连接向另一个程序传输数据时,必须将内存中的数据结构转换为字节或字符的序列,才可以保存或传输。这个将数据结构转换为字节或字符流的方式称为序列化。

JavaScript 中序列化数据的最简单方式是使用一种称为 JSON 的序列化格式。JSON 是JavaScript Object Notation​(JavaScript 对象表示法)的简写形式。这种格式使用JavaScript 对象和数组字面量语法,将对象和数组形式的数据结构转换为字符串:

  • JSON 支持原始数值和字符串,也支持 true、false 和 null值,以及在这些原始值基础上构建起来的对象和数组
  • JSON 不支持其他 JavaScript 类型,如 Map、Set、RegExp、Date 或定型数组

JavaScript 通过两个函数 JSON.stringify ()和 JSON.parse() 支持 JSON 序列化和反序列化。可以使用这对函数(以没有那么高效的方式)创建对象的深度副本:

1
2
3
function deepCopy(o) {
return Json.parse(JSON.stringify(o));
}

数据被序列化为 JSON 格式后,结果是有效的 JavaScript 表达式源代码,可以求值为原始数据结构的一个副本。

JSON 有时候也被用为人类友好的配置文件格式。如果你发现自己在手工编辑 JSON 文件,注意 JSON 格式是 JavaScript 的严格子集。不允许有注释,属性名也必须包含在双引号中。

JSON.stringify()的第三个参数告诉它应该把数据格式化为多行缩进格式。如果第三个参数是个数值,则该数值表示每级缩进的空格数。如果第三个参数是空白符(如’\t’)字符串,则每级缩进就使用该字符串。

1
2
3
4
5
6
7
8
9
10
11
12
> let o = {s: "test", n: 10}
> JSON.stringify(o, null, 2)
'{\n "s": "test",\n "n": 10\n}'

> console.log(JSON.stringify(o, null, 2))
{
"s": "test",
"n": 10
}

> console.log(JSON.stringify(o))
{"s":"test","n":10}

JSON 自定义

如果 JSON.stringify() 在序列化时碰到了 JSON 格式原生不支持的值,它会查找这个值是否有 toJSON() 方法。如果有这个方法,就会调用它,然后将其返回值字符串化以代替原始值。例如 Date 对象就实现了 toJSON() 方法,返回和 toISOStrin() 相同的字符串。但是在解析序列化之后的字符串时,重新创建的数据结构就不会与开始时的完全一样了,因为原来的 Date 值变成了字符串。

如果想重新创建这个 Date 对象(或以其他方式修改解析后的对象)​,可以给 JSON.parse() 的第二个参数传一个 复活(reiver)函数。如果指定了这个 复活 函数,该函数就会在解析输入字符串中的每个原始值时被调用(但解析包含这些原始值的对象和数组时不会调用)​。复活函数的参数如下:

  • 第一个是属性名,可能是对象属性名,也可能是转换为字符串的数组索引名
  • 第二个参数是该对象属性或数组元素对应的原始值
  • 这个函数会作为包含上述原始值的对象或数组的方法调用,因此可以在其中通过 this 关键字引用包含对象

复活函数的返回值会变成命名属性的新值:

  • 如果复活函数返回它的第二个参数,那么属性保持不变
  • 如果它返回 undefined,则相应的命名属性会从对象或数组中删除,即 JSON.parse() 返回给用户的对象中将不包含该属性
1
2
3
4
5
6
7
8
9
10
let data = JSON.parse(text, function(key, value) {
if (key[0] === "_") return undefined;

if (typeof value === "string" &&
/^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\dd.\d\d\dZ$/.text(value)) {
return new Date(value);
}

return value;
})

除了前面提到的 toJSON()JSON.stringify() 也支持给它传入一个数组或函数作为第二个参数来自定义其输出字符串。

  • 如果第二个参数传入的是一个字符串数组(或者数值数组,其中的数值会转换为字符串)​,那么这些字符串会被当作对象属性(或数组元素)的名字。任何名字不在这个数组之列的属性会被字符串化过程忽略
  • 如果给 JSON.stringify() 的第二个参数传入一个函数,则该函数就是一个替代函数(作用与传给 JSON.parse() 的可选的复活函数恰好相反)​
    • 这个替代函数的第一个参数是对象属性名或值在对象中的数组索引,第二个参数是值本身
    • 这个替代函数会作为包含要被字符串化的值的对象或数组的方法调用
    • 替代函数的返回值会替换原始值。如果替代函数返回 undefined 或什么也不返回,则该值(及其数组元素或对象属性)将在字符串化过程中被忽略
1
let json = JSON.stringify(o, (k, v) => v instanceof RegExp ? undefined : v);

国际化 API

JavaScript 国际化 API 包括 3 个类:

  • Intl.NumberFormat
  • Intl.DateTimeFormat
  • Intl.Collator

这 3 个类允许我们以适合当地的方式格式化数值(包括货币数量和百分数)​、日期和时间,以及以适合当地的方式比较字符串。

格式化数值

世界各地的用户对数值格式的预期是不同的,Intl.NumberFormat 类定义了一个 format() 方法,以满足各种格式化需求:

  • 这个构造函数接收两个参数,第一个参数指定作为数值格式化依据的地区,第二个参数是用于指定格式化细节的对象。
    • 如果第一个参数忽略或者为 undefined,则使用系统设置中的地区
    • 第二个参数为一个对象,包含一个或多个如下属性:style(数值的格式类型)、useGrouping(是否使用千位分隔符)等等。
1
2
3
> let euros = Intl.NumberFormat("en", {style: "currency", currency: "EUR"})
> euros.format(10)
'€10.00'

Intl.NumberFormat(及其他Intl类)有一个很有用的特性,即它的 format() 方法会绑定到自己所属的 NumberFormat 对象。

1
2
3
> let format =  Intl.NumberFormat("en", {style: "currency", currency: "EUR"}).format
> [10, 20, 30].map(format)
[ '€10.00', '€20.00', '€30.00' ]

格式化日期和时间

Intl.DateTimeFormat() 构造函数与 Intl.NumberFormat() 接收相同的两个参数:一个地区或地区数组,另一个是格式化选项的对象。使用 Intl.DateTimeFormat 实例的方式也是调用其 format() 方法,将 Date 对象转换为字符串。

1
2
> Intl.DateTimeFormat("en-US", {weekday: "long", month:"long", year:"numeric"}).format(d)
'January 2025 Sunday'

Intl.DateTimeFormat默认使用儒略历,但也可以使用其他日历。

1
2
3
4
> d
2025-01-26T01:57:00.232Z
> Intl.DateTimeFormat("en-U-ca-chinese").format(d)
'12/27/2024'

比较字符串

可以创建一个 Intl.Collator 对象,可以将这个对象的 compare() 方法传给 sort() 方法,以执行适合当地的字符串排序。与 Intl.NumberFormat()Intl.DateTimeFormat() 类似,Intl.Collator() 构造函数也接收两个参数:

  • 第一个参数指定地区或地区数组
  • 第二个参数是一个可选的对象,其属性指定具体执行哪种比较

在通过选项为目标地区创建 Intl.Collator 对象之后,可以使用它的 compare() 方法比较两个字符串:

  • compare() 方法接收两个字符串参数,返回一个小于、等于或大于 0 的数值
  • Intl.Collator 也会自动将 compare() 方法绑定到它的实例
  • 因此可以直接把这个方法传给 sort() 而无须编写包装函数再通过整理器调用它

控制台 API

我们一直使用 console.log 来输出信息:

  • 在浏览器中,console.log() 会在开发者工具面板的 控制台 标签页中打印字符串,这是排查问题时非常有用的功能
  • 在 Node 中,console.log() 是通用的输出函数,可以将其参数打印到进程的标准输出流,通常会作为程序输出显示在用户的终端窗口中

控制台 API 并不是 ECMAScript 标准,但已经被浏览器和 Node 支持:

  • console.log():它将参数转换为字符串并输出到控制台。它会在参数之间输出空格,并在输出所有参数后重新开始一行
  • console.debug()、console.info()、console.warn()、console.error():这几个函数与console.log() 几乎相同
    • 在 Node 中,console.error() 将其输出发送到标准错误流,而不是标准输出流。除此之外的其他函数都是 console.log() 的别名。
    • 在浏览器中,这几个函数生成的输出消息前面可能会带一个图标,表示级别或严重程度
  • console.assert():
    • 如果这个函数的第一个参数是真值(也就是断言通过)​,则这个函数什么也不做
    • 但如果第一个参数是假值,则剩余参数会像被传给 console.error() 一样打印出来,且前面带一个 Assertion failed 前缀,该函数不会抛出异常
  • console.clear():清空控制台
  • console.table():产生表列数据输出
  • console.trace():这个函数会像 console.log() 一样打印它的参数,此外在输出之后还会打印栈跟踪信息
  • console.count():这个函数接收一个字符串参数,并打印该字符串,后面跟着已经通过该字符串调用的次数。在调试事件处理程序时,如果需要知道事件处理程序被触发的次数,可以使用这个函数
  • console.countReset():接收一个字符串参数,并重置针对该字符串的计数器
  • console.group():可以将控制台的输出消息以缩进的形式形成分组
  • console.groupCollapsed():与 console.group() 类似,但默认情况下分组会被折叠
  • console.groupEnd():结束一个分组
  • console.time():接收一个字符串参数,并记录以该字符串调用自身时的时间,没有输出
  • console.timeLog():接收一个字符串作为第一个参数,如果这个字符串之前传给过 console.time(),那么它会打印该字符串及自上次调用 console.time() 之后经过的时间。其他参数则像传递给 console.log() 一样处理
  • console.timeEnd():接收一个字符串参数,如果该参数之前传给过 console.time(),则它打印该参数及经过的时间。在调用 console.timeEnd() 之后,如果不再调用 console.time(),则调用 console.timeLog() 将是不合法的
1
2
3
4
5
6
7
8
9
> let a = [{x: 1, y:2}, {x:3, y:4}, {x:5, y:6}]
> console.table(a)
┌─────────┬───┬───┐
│ (index) │ x │ y │
├─────────┼───┼───┤
012
134
256
└─────────┴───┴───┘

通过控制台格式化输出

console.log() 这样打印自己参数的控制台函数都有一个不太为人所知的特性:如果第一个参数是包含 %s%i%d%f%o%O%c 的字符串,则这个参数会被当成格式字符串。后续参数的值会被代入这个字符串,以取代这些两个字符的 % 序列。

但一般在使用控制台函数时,通常并不需要格式字符串。一般来说,只要把一个或多个值(包括对象)传给这些函数,由实现决定如何以有用的方式显示它们就可以了。

URL API

由于 JavaScript 多用于浏览器和服务器,因此 JavaScript 代码经常需要操作 URL。URL API 并不是 ECMAScript 标准定义的,但 Node 和所有浏览器都实现了它。

使用 URL() 构造函数创建 URL 对象时,

  • 可以传入一个绝对 URL 作为参数
  • 也可以将一个相对 URL 作为第一个参数,将其相对的绝对 URL 作为第二个参数

创建了 URL 对象后,可以通过它的各种属性查询 URL 不同部分的非转义值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
> let url = new URL("https://www.example.com:8000/test?id=1#fragment")
> url
URL {
href: 'https://www.example.com:8000/test?id=1#fragment',
origin: 'https://www.example.com:8000',
protocol: 'https:',
username: '',
password: '',
host: 'www.example.com:8000',
hostname: 'www.example.com',
port: '8000',
pathname: '/test',
search: '?id=1',
searchParams: URLSearchParams { 'id' => '1' },
hash: '#fragment'
}

尽管并不常用,但 URL 可以包含用户名或者用户和密码,URL 类也可以解析这些 URL 组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> let url = new URL("https://user:password@example.com/api/data")
> url
URL {
href: 'https://user:password@example.com/api/data',
origin: 'https://example.com',
protocol: 'https:',
username: 'user',
password: 'password',
host: 'example.com',
hostname: 'example.com',
port: '',
pathname: '/api/data',
search: '',
searchParams: URLSearchParams {},
hash: ''

URL 类有一个重要特性,即它会在需要时正确地在 URL 中添加标点符号及转义特殊字符

1
2
3
4
5
6
7
8
9
10
11
> let url = new URL("https://example.com");
> url.pathname = "path with space"
'path with space'
> url.search = "q=foo#bar"
'q=foo#bar'
> url.pathname
'/path%20with%20space'
> url.search
'?q=foo%23bar'
> url.href
'https://example.com/path%20with%20space?q=foo%23bar'

读取 href 属性相当于调用 toString(),即将 URL 的所有部分组合成一个字符串形式的正式 URL。将 href 设置为一个新字符串会返回该新字符串上的 URL 解析器,就好像再次调用了 URL() 构造函数一样。

HTTP 请求经常会使用 application/x-www-form-urlencoded 格式将多个表单字段的值或多个 API 参数编码为 URL 的查询部分。在这个格式中,URL的查询部分以问号开头,然后是一个或多个由 & 分隔的名/值对。此时,searchParams 属性比 search 属性更有用。search 属性是一个对 URLSearchParams 对象的只读引用,而 URLSearchParams 对象具有获取、设置、添加、删除和排序参数(该参数编码为URL的查询部分)的 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
> let url = new URL("https://example.com/search")
> url.search
''
> url.searchParams
URLSearchParams {}

> url.searchParams.append("q", "term")
> url.search
'?q=term'
> url.searchParams.get("q")
'term'
> url.searchParams.get("p")
null
> url.searchParams.has("p")
false

> url.searchParams.append("opts", "1")
> url.searchParams.append("opts", "2")
> url.search
'?q=term&opts=1&opts=2'
> url.searchParams.get("opts")
'1'
> url.searchParams.getAll("opts")
[ '1', '2' ]

searchParams 属性的值是一个 URLSearchParams 对象。如果想把 URL 参数编码为查询字符串,可以创建 URLSearchParams 对象,追加参数,然后再将它转换为字符串并将其赋值给 URL 的 search 属性。

1
2
3
4
5
6
7
8
9
> let url = new URL("https://example.com")
> let params = new URLSearchParams()
undefined
> params.append("q", "term")
> params.append("opts", "exact")
> url.search = params.toString()
'q=term&opts=exact'
> url.href
'https://example.com/?q=term&opts=exact'

遗留 URL 函数

在前面介绍的 URL API 标准化之前,JavaScript 语言也曾多次尝试支持对 URL 的转义和反转义。例如 escape()unescape() 函数,如今这两个函数已经被废弃,ECMAScript 增加了两对替代性的全局函数。

  • encodeURI() 和 decodeURI():
    • 实现对字符串的 URL 编码/解码
    • encodeURI() 是要编码整个 URL,所以不会转义 URL 分隔符(如 /?#)​。这意味着 encodeURI() 不能正确地处理其组件中包含这些字符的 URL
  • encodeURIComponent() 和 decodeURIComponent():
    • 它们专门用于转义 URL 的单个组件,因此它们也会转义用于分隔 URL 组件的 /?# 字符
    • 但它也有缺陷:例如会转义路径名中的/字符,而这可能并不是我们想要的;把查询参数中的空格转换为 %20,而实际上查询参数中的空格应该被转义为+。
1
2
> encodeURI("https://example.com/path?q=?1")
'https://example.com/path?q=?1'

这些遗留函数的根本问题在于它们都在寻求把一种编码模式应用给 URL 的所有部分,而事实却是 URL 的不同部分使用的是不同的编码方案。如果想正确地格式化和编码 URL,最简单的办法就是使用 URL 类完成所有 URL 相关的操作。

计时器

利用 setTimeout()setInterval() 这两个函数,程序可以让浏览器在指定的时间过后调用一个函数,或者每经过一定时间就重复调用一次某个函数。

  • setTimeout() 的第一个参数是函数,第二个参数是数值,数值表示过多少毫秒之后调用第一个函数

    • setTimeout() 并不会等到指定时间之后再返回
    • 如果省略传给 setTimeout() 的第二个参数,则该参数默认值为 0,这意味着注册的函数会被尽快被调用
    • setTimeout() 注册的函数只会被调用一次
  • setInterval() 接收的参数与 setTimeout() 相同,但会导致每隔指定时间(同样是个近似的毫秒值)就调用一次指定函数。

  • setTimeout()setInterval() 都返回一个值。如果把这个值保存在变量中,之后可以把它传给 clearTimeout()clearInterval() 以取消对函数的调用

1
2
3
4
5
6
7
8
> setTimeout(() => console.log("called"), 3000)
> called

> setInterval(() => console.log("called"), 3000)
> called
called
called
......
1
2
3
4
5
6
let clock = setInterval(() => {
console.clear();
console.log(new Date().toLocaleString());
}, 1000);

setTimeout(() => { clearInterval(clock); }, 10000);