Skip to content

Latest commit

 

History

History
1636 lines (1213 loc) · 90 KB

04章:基本引用类型.md

File metadata and controls

1636 lines (1213 loc) · 90 KB

本章内容

  • Date 类型
  • RegExp 类型
  • 原始值包装类型
  • 单例内置对象

引用值(或者对象)是某个特定引用类型的实例。在 ECMAScript 中,引用类型是把数据和功能组织到一起的结构,经常被人错误地称作“类”。虽然从技术上讲 JavaScript 是一门面向对象语言,但 ECMAScript 缺少传统的面向对象编程语言所具备的某些基本结构,包括类和接口。引用类型有时候也被称为对象定义,因为它们描述了自己的对象应有的属性和方法。

注意 引用类型虽然有点像类,但跟类并不是一个概念。为避免混淆,本章后面不会使用术语“类”。

对象被认为是某个特定引用类型的实例。新对象通过使用 new 操作符后跟一个 构造函数(constructor) 来创建。构造函数就是用来创建新对象的函数,比如下面这行代码:

const now = new Date();

这行代码创建了引用类型 Date 的一个新实例,并将它保存在变量 now 中。Date()在这里就是构造函数,它负责创建一个只有默认属性和方法的简单对象。ECMAScript 提供了很多像 Date 这样的原生引用类型,帮助开发者实现常见的任务。

注意 函数也是一种引用类型,但有关函数的内容太多了,一章放不下,所以本书专门用第 10 章来介绍函数。

1.1. Date

ECMAScript 的 Date 类型参考了 Java 早期版本中的 java.util.Date。为此,Date 类型将日期保存为自协调世界时(UTC,Universal Time Coordinated)时间 1970 年 1 月 1 日午夜(零时)至今所经过的毫秒数。使用这种存储格式,Date 类型可以精确表示 1970 年 1 月 1 日之前及之后 285 616 年的日期。

要创建日期对象,就使用 new 操作符来调用 Date 构造函数:

let now = new Date();

在不给 Date 构造函数传参数的情况下,创建的对象将保存当前日期和时间。要基于其他日期和时间创建日期对象,必须传入其毫秒表示(UNIX 纪元 1970 年 1 月 1 日午夜之后的毫秒数)。

1.1.1. 继承方法

与其他类型一样,Date 类型重写了 toLocaleString()、toString()和 valueOf()方法。但与其他类型不同,重写后这些方法的返回值不一样。Date 类型的 toLocaleString()方法返回与浏览器运行的本地环境一致的日期和时间。这通常意味着格式中包含针对时间的 AM(上午)或 PM(下午),但不包含时区信息(具体格式可能因浏览器而不同)。toString()方法通常返回带时区信息的日期和时间,而时间也是以 24 小时制(0~23)表示的。下面给出了 toLocaleString()和 toString()返回的 2019 年 2 月 1 日零点的示例(地区为'en-US'的 PST,即 Pacific Standard Time,太平洋标准时间):

toLocaleString() - 2/1/2019 12:00:00 AM toString() - Thu Feb 1 2019 00:00:00 GMT-0800 (Pacific Standard Time)

现代浏览器在这两个方法的输出上已经趋于一致。在比较老的浏览器上,每个方法返回的结果可能在每个浏览器上都是不同的。这些差异意味着 toLocaleString()和 toString()可能只对调试有用,不能用于显示。

Date 类型的 valueOf()方法根本就不返回字符串,这个方法被重写后返回的是日期的毫秒表示。因此,操作符(如小于号和大于号)可以直接使用它返回的值。比如下面的例子:

let date1 = new Date(2019, 0, 1); // 2019 年1 月1 日
let date2 = new Date(2019, 1, 1); // 2019 年2 月1 日

console.log(date1 < date2); // true
console.log(date1 > date2); // false

日期 2019 年 1 月 1 日在 2019 年 2 月 1 日之前,所以说前者小于后者没问题。因为 2019 年 1 月 1 日的毫秒表示小于 2019 年 2 月 1 日的毫秒表示,所以用小于号比较这两个日期时会返回 true。这也是确保日期先后的一个简单方式。

1.1.2. 格式化方法

Date 类型有几个专门用于格式化日期的方法,它们都会返回字符串:

  • toDateString()显示日期中的周几、月、日、年(格式特定于实现);
  • toTimeString()显示日期中的时、分、秒和时区(格式特定于实现);
  • toLocaleDateString()显示日期中的周几、月、日、年(格式特定于实现和地区);
  • toLocaleTimeString()显示日期中的时、分、秒(格式特定于实现和地区);
  • toUTCString()显示完整的 UTC 日期(格式特定于实现)。

这些方法的输出与 toLocaleString()和 toString()一样,会因浏览器而异。因此不能用于在用户界面上一致地显示日期。

注意 还有一个方法叫 toGMTString(),这个方法跟 toUTCString()是一样的,目的是为了向后兼容。不过,规范建议新代码使用 toUTCString()。

1.1.3. 组件方法

Date 类型剩下的方法(见下表)直接涉及取得或设置日期值的特定部分。注意表中“UTC 日期”,指的是没有时区偏移(将日期转换为 GMT)时的日期。

方 法 说 明
getTime() 返回日期的毫秒表示;与 valueOf()相同
setTime(milliseconds) 设置日期的毫秒表示,从而修改整个日期
getFullYear() 返回 4 位数年(即 2019 而不是 19)
getUTCFullYear() 返回 UTC 日期的 4 位数年
setFullYear(year) 设置日期的年(year 必须是 4 位数)
setUTCFullYear(year) 设置 UTC 日期的年(year 必须是 4 位数)
getMonth() 返回日期的月(0 表示 1 月,11 表示 12 月)
getUTCMonth() 返回 UTC 日期的月(0 表示 1 月,11 表示 12 月)
setMonth(month) 设置日期的月(month 为大于 0 的数值,大于 11 加年)
setUTCMonth(month) 设置 UTC 日期的月(month 为大于 0 的数值,大于 11 加年)
getDate() 返回日期中的日(1~31)
getUTCDate() 返回 UTC 日期中的日(1~31)
setDate(date) 设置日期中的日(如果 date 大于该月天数,则加月)
setUTCDate(date) 设置 UTC 日期中的日(如果 date 大于该月天数,则加月)
getDay() 返回日期中表示周几的数值(0 表示周日,6 表示周六)
getUTCDay() 返回 UTC 日期中表示周几的数值(0 表示周日,6 表示周六)
getHours() 返回日期中的时(0~23)
getUTCHours() 返回 UTC 日期中的时(0~23)
setHours(hours) 设置日期中的时(如果 hours 大于 23,则加日)
setUTCHours(hours) 设置 UTC 日期中的时(如果 hours 大于 23,则加日)
getMinutes() 返回日期中的分(0~59)
getUTCMinutes() 返回 UTC 日期中的分(0~59)
setMinutes(minutes) 设置日期中的分(如果 minutes 大于 59,则加时)
setUTCMinutes(minutes) 设置 UTC 日期中的分(如果 minutes 大于 59,则加时)
getSeconds() 返回日期中的秒(0~59)
getUTCSeconds() 返回 UTC 日期中的秒(0~59)
setSeconds(seconds) 设置日期中的秒(如果 seconds 大于 59,则加分)
setUTCSeconds(seconds) 设置 UTC 日期中的秒(如果 seconds 大于 59,则加分)
getMilliseconds() 返回日期中的毫秒
getUTCMilliseconds() 返回 UTC 日期中的毫秒
setMilliseconds(milliseconds) 设置日期中的毫秒
setUTCMilliseconds(milliseconds) 设置 UTC 日期中的毫秒
getTimezoneOffset() 返回以分钟计的 UTC 与本地时区的偏移量(如美国 EST 即“东部标准时间”返回 300,进入夏令时的地区可能有所差异)

下面是一个演示:

function getTime(date) {
  return `${date.getFullYear()}-${
    date.getMonth() + 1
  }-${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()} ${date.getMilliseconds()}ms`;
}
console.log(getTime(new Date())); // 2021-8-7 17:55:40 837ms

1.1.4. Date 静态方法

ECMAScript 为此提供了两个辅助方法:Date.parse()和 Date.UTC()。

Date.parse()方法接收一个表示日期的字符串参数,尝试将这个字符串转换为表示该日期的毫秒数。ECMA-262 第 5 版定义了 Date.parse()应该支持的日期格式,填充了第 3 版遗留的空白。所有实现都必须支持下列日期格式:

  • “月/日/年”,如'08/07/2021';
  • “月名 日, 年”,如'Aug 07, 2021';
  • “周几 月名 日 年 时:分:秒 时区”,如'Sat Aug 07 2021 00:00:00 GMT-0700';
  • ISO 8601 扩展格式“YYYY-MM-DDTHH:mm:ss.sssZ”,如 2021-08-07T00:00:00(只适用于 兼容 ES5 的实现)。

比如,要创建一个表示“2021 年 08 月 07 日”的日期对象,可以使用以下代码:

let someDate = new Date(Date.parse('Aug 07, 2021'));

如果传给 Date.parse()的字符串并不表示日期,则该方法会返回 NaN。如果直接把表示日期的字符串传给 Date 构造函数,那么 Date 会在后台调用 Date.parse()。换句话说,下面这行代码跟前面那行代码是等价的:

let someDate = new Date('Aug 07, 2021');

这两行代码得到的日期对象相同。

注意 不同的浏览器对 Date 类型的实现有很多问题。比如,很多浏览器会选择用当前日期替代越界的日期,因此有些浏览器会将'January 32, 2019'解释为'February 1,2019'。Opera 则会插入当前月的当前日,返回'January 当前日, 2019'。就是说,如果是在 9 月 21 日运行代码,会返回'January 21, 2019'。

Date.UTC()方法也返回日期的毫秒表示,但使用的是跟 Date.parse()不同的信息来生成这个值。传给 Date.UTC()的参数是年、零起点月数(1 月是 0,2 月是 1,以此类推)、日(131)、时(023)、分、秒和毫秒。这些参数中,只有前两个(年和月)是必需的。如果不提供日,那么默认为 1 日。其他参数的默认值都是 0。下面是使用 Date.UTC()的两个例子:

// GMT 时间2000 年1 月1 日零点
let y2k = new Date(Date.UTC(2000, 0));

// GMT 时间2005 年5 月5 日下午5 点55 分55 秒
let allFives = new Date(Date.UTC(2005, 4, 5, 17, 55, 55));

这个例子创建了两个日期 。第一个日期是 2000 年 1 月 1 日零点(GMT),2000 代表年,0 代表月(1 月)。因为没有其他参数(日取 1,其他取 0),所以结果就是该月第 1 天零点。第二个日期表示 2005 年 5 月 5 日下午 5 点 55 分 55 秒(GMT)。虽然日期里面涉及的都是 5,但月数必须用 4,因为月数是零起点的。小时也必须是 17,因为这里采用的是 24 小时制,即取值范围是 0~23。其他参数就都很直观了。

与 Date.parse()一样,Date.UTC()也会被 Date 构造函数隐式调用,但有一个区别:这种情况下创建的是本地日期,不是 GMT 日期。不过 Date 构造函数跟 Date.UTC()接收的参数是一样的。因此,如果第一个参数是数值,则构造函数假设它是日期中的年,第二个参数就是月,以此类推。前面的例子也可以这样来写:

// 本地时间2000 年1 月1 日零点
let y2k = new Date(2000, 0);

// 本地时间2005 年5 月5 日下午5 点55 分55 秒
let allFives = new Date(2005, 4, 5, 17, 55, 55);

以上代码创建了与前面例子中相同的两个日期,但这次的两个日期是(由于系统设置决定的)本地时区的日期。

ECMAScript 还提供了 Date.now()方法,返回表示方法执行时日期和时间的毫秒数。这个方法可以方便地用在代码分析中:

// 起始时间
let start = Date.now();

// 调用函数
doSomething();

// 结束时间
let stop = Date.now(),
  result = stop - start;

1.2. RegExp

ECMAScript 通过 RegExp 类型支持正则表达式。正则表达式使用类似 Perl 的简洁语法来创建:

let expression = /pattern/flags;

这个正则表达式的 pattern(模式)可以是任何简单或复杂的正则表达式,包括字符类、限定符、分组、向前查找和反向引用。每个正则表达式可以带零个或多个 flags(标记),用于控制正则表达式的行为。下面给出了表示匹配模式的标记。

  • g:全局模式,表示查找字符串的全部内容,而不是找到第一个匹配的内容就结束。
  • i:不区分大小写,表示在查找匹配时忽略 pattern 和字符串的大小写。
  • m:多行模式,表示查找到一行文本末尾时会继续查找。
  • y:粘附模式,表示只查找从 lastIndex 开始及之后的字符串。
  • u:Unicode 模式,启用 Unicode 匹配。
  • s:dotAll 模式,表示元字符.匹配任何字符(包括\n 或\r)。

使用不同模式和标记可以创建出各种正则表达式,比如:

// 匹配字符串中的所有'at'
let pattern1 = /at/g;

// 匹配第一个'bat'或'cat',忽略大小写
let pattern2 = /[bc]at/i;

// 匹配所有以'at'结尾的三字符组合,忽略大小写
let pattern3 = /.at/gi;

与其他语言中的正则表达式类似,所有元字符在模式中也必须转义,包括:

( [ { \ ^ $ | ) ] } ? * + .

元字符在正则表达式中都有一种或多种特殊功能,所以要匹配上面这些字符本身,就必须使用反斜杠来转义。下面是几个例子:

// 匹配第一个'bat'或'cat',忽略大小写
let pattern1 = /[bc]at/i;

// 匹配第一个'[bc]at',忽略大小写
let pattern2 = /\[bc\]at/i;

// 匹配所有以'at'结尾的三字符组合,忽略大小写
let pattern3 = /.at/gi;

// 匹配所有'.at',忽略大小写
let pattern4 = /\.at/gi;

这里的 pattern1 匹配'bat'或'cat',不区分大小写。要直接匹配'[bc]at',左右中括号都必须像 pattern2 中那样使用反斜杠转义。在 pattern3 中,点号表示'at'前面的任意字符都可以匹配。如果想匹配'.at',那么要像 pattern4 中那样对点号进行转义。

前面例子中的正则表达式都是使用字面量形式定义的。正则表达式也可以使用 RegExp 构造函数来创建,它接收两个参数:模式字符串和(可选的)标记字符串。任何使用字面量定义的正则表达式也可以通过构造函数来创建,比如:

// 匹配第一个'bat'或'cat',忽略大小写
let pattern1 = /[bc]at/i;

// 跟pattern1 一样,只不过是用构造函数创建的
let pattern2 = new RegExp('[bc]at', 'i');

这里的 pattern1 和 pattern2 是等效的正则表达式。注意,RegExp 构造函数的两个参数都是字符串。因为 RegExp 的模式参数是字符串,所以在某些情况下需要二次转义。所有元字符都必须二次转义,包括转义字符序列,如\n(\转义后的字符串是\,在正则表达式字符串中则要写成\\)。下表展示了几个正则表达式的字面量形式,以及使用 RegExp 构造函数创建时对应的模式字符串。

字面量模式 对应的字符串
/\[bc\]at/ '\\[bc\\]at'
/\.at/ '\\.at'
/name\/age/ 'name\\/age'
/\d.\d{1,2}/ '\\d.\\d{1,2}'
/\w\\hello\\123/ '\\w\\\\hello\\\\123'

此外,使用 RegExp 也可以基于已有的正则表达式实例,并可选择性地修改它们的标记:

const re1 = /cat/g;
console.log(re1); // '/cat/g'

const re2 = new RegExp(re1);
console.log(re2); // '/cat/g'

const re3 = new RegExp(re1, 'i');
console.log(re3); // '/cat/i'

1.2.1. RegExp 实例属性

每个 RegExp 实例都有下列属性,提供有关模式的各方面信息。

  • global:布尔值,表示是否设置了 g 标记。
  • ignoreCase:布尔值,表示是否设置了 i 标记。
  • unicode:布尔值,表示是否设置了 u 标记。
  • sticky:布尔值,表示是否设置了 y 标记。
  • lastIndex:整数,表示在源字符串中下一次搜索的开始位置,始终从 0 开始。
  • multiline:布尔值,表示是否设置了 m 标记。
  • dotAll:布尔值,表示是否设置了 s 标记。
  • source:正则表达式的字面量字符串(不是传给构造函数的模式字符串),没有开头和结尾的斜杠。
  • flags:正则表达式的标记字符串。始终以字面量而非传入构造函数的字符串模式形式返回(没有前后斜杠)。

通过这些属性可以全面了解正则表达式的信息,不过实际开发中用得并不多,因为模式声明中包含这些信息。下面是一个例子:

let pattern1 = /\[bc\]at/i;

console.log(pattern1.global); // false
console.log(pattern1.ignoreCase); // true
console.log(pattern1.multiline); // false
console.log(pattern1.lastIndex); // 0
console.log(pattern1.source); // '\[bc\]at'
console.log(pattern1.flags); // 'i'

let pattern2 = new RegExp('\\[bc\\]at', 'i');

console.log(pattern2.global); // false
console.log(pattern2.ignoreCase); // true
console.log(pattern2.multiline); // false
console.log(pattern2.lastIndex); // 0
console.log(pattern2.source); // '\[bc\]at'
console.log(pattern2.flags); // 'i'

注意,虽然第一个模式是通过字面量创建的,第二个模式是通过 RegExp 构造函数创建的,但两个模式的 source 和 flags 属性是相同的。source 和 flags 属性返回的是规范化之后可以在字面量中使用的形式。

1.2.2. RegExp 实例方法

RegExp 实例的主要方法是 exec(),主要用于配合捕获组使用。这个方法只接收一个参数,即要应用模式的字符串。如果找到了匹配项,则返回包含第一个匹配信息的数组;如果没找到匹配项,则返回 null。返回的数组虽然是 Array 的实例,但包含两个额外的属性:index 和 input。index 是字符串中匹配模式的起始位置,input 是要查找的字符串。这个数组的第一个元素是匹配整个模式的字符串,其他元素是与表达式中的捕获组匹配的字符串。如果模式中没有捕获组,则数组只包含一个元素。来看下面的例子:

let text = 'mom and dad and baby';
let pattern = /mom( and dad( and baby)?)?/gi;

let matches = pattern.exec(text);
console.log(matches.index); // 0
console.log(matches.input); // 'mom and dad and baby'
console.log(matches[0]); // 'mom and dad and baby'
console.log(matches[1]); // ' and dad and baby'
console.log(matches[2]); // ' and baby'

在这个例子中,模式包含两个捕获组:最内部的匹配项' and baby',以及外部的匹配项' and dad'或' and dad and baby'。调用 exec()后找到了一个匹配项。因为整个字符串匹配模式,所以 matchs 数组的 index 属性就是 0。数组的第一个元素是匹配的整个字符串,第二个元素是匹配第一个捕获组的字符串,第三个元素是匹配第二个捕获组的字符串。

如果模式设置了全局标记,则每次调用 exec()方法会返回一个匹配的信息。如果没有设置全局标记,则无论对同一个字符串调用多少次 exec(),也只会返回第一个匹配的信息。

let text = 'cat, bat, sat, fat';
let pattern = /.at/;

let matches = pattern.exec(text);
console.log(matches.index); // 0
console.log(matches[0]); // cat
console.log(pattern.lastIndex); // 0

matches = pattern.exec(text);
console.log(matches.index); // 0
console.log(matches[0]); // cat
console.log(pattern.lastIndex); // 0

上面例子中的模式没有设置全局标记,因此调用 exec()只返回第一个匹配项('cat')。lastIndex 在非全局模式下始终不变。

如果在这个模式上设置了 g 标记,则每次调用 exec()都会在字符串中向前搜索下一个匹配项,如下面的例子所示:

let text = 'cat, bat, sat, fat';
let pattern = /.at/g;
let matches = pattern.exec(text);
console.log(matches.index); // 0
console.log(matches[0]); // cat
console.log(pattern.lastIndex); // 3

matches = pattern.exec(text);
console.log(matches.index); // 5
console.log(matches[0]); // bat
console.log(pattern.lastIndex); // 8

matches = pattern.exec(text);
console.log(matches.index); // 10
console.log(matches[0]); // sat
console.log(pattern.lastIndex); // 13

这次模式设置了全局标记,因此每次调用 exec()都会返回字符串中的下一个匹配项,直到搜索到字符串末尾。注意模式的 lastIndex 属性每次都会变化。在全局匹配模式下,每次调用 exec()都会更新 lastIndex 值,以反映上次匹配的最后一个字符的索引。

如果模式设置了粘附标记 y,则每次调用 exec()就只会在 lastIndex 的位置上寻找匹配项。粘附标记覆盖全局标记。

let text = 'cat, bat, sat, fat';
let pattern = /.at/y;
let matches = pattern.exec(text);
console.log(matches.index); // 0
console.log(matches[0]); // cat
console.log(pattern.lastIndex); // 3

// 以索引3 对应的字符开头找不到匹配项,因此exec()返回null
// exec()没找到匹配项,于是将lastIndex 设置为0
matches = pattern.exec(text);
console.log(matches); // null
console.log(pattern.lastIndex); // 0

// 向前设置lastIndex 可以让粘附的模式通过exec()找到下一个匹配项:
pattern.lastIndex = 5;
matches = pattern.exec(text);
console.log(matches.index); // 5
console.log(matches[0]); // bat
console.log(pattern.lastIndex); // 8

正则表达式的另一个方法是 test(),接收一个字符串参数。如果输入的文本与模式匹配,则参数返回 true,否则返回 false。这个方法适用于只想测试模式是否匹配,而不需要实际匹配内容的情况。test()经常用在 if 语句中:

let text = '000-00-0000';
let pattern = /\d{3}-\d{2}-\d{4}/;

if (pattern.test(text)) {
  console.log('The pattern was matched.');
}

在这个例子中,正则表达式用于测试特定的数值序列。如果输入的文本与模式匹配,则显示匹配成功的消息。这个用法常用于验证用户输入,此时我们只在乎输入是否有效,不关心为什么无效。

无论正则表达式是怎么创建的,继承的方法 toLocaleString()和 toString()都返回正则表达式的字面量表示。比如:

let pattern = new RegExp('\\[bc\\]at', 'gi');
console.log(pattern.toString()); // /\[bc\]at/gi
console.log(pattern.toLocaleString()); // /\[bc\]at/gi

这里的模式是通过 RegExp 构造函数创建的,但 toLocaleString()和 toString()返回的都是其字面量的形式。

注意 正则表达式的 valueOf()方法返回正则表达式本身。

1.2.3. RegExp 静态方法

RegExp 构造函数本身也有几个属性。(在其他语言中,这种属性被称为静态属性。)这些属性适用于作用域中的所有正则表达式,而且会根据最后执行的正则表达式操作而变化。这些属性还有一个特点,就是可以通过两种不同的方式访问它们。换句话说,每个属性都有一个全名和一个简写。下表列出了 RegExp 构造函数的属性。

全 名 简 写 说 明
input $_ 最后搜索的字符串(非标准特性)
lastMatch $& 最后匹配的文本
lastParen $+ 最后匹配的捕获组(非标准特性)
leftContext $` input 字符串中出现在 lastMatch 前面的文本
rightContext $' input 字符串中出现在 lastMatch 后面的文本

通过这些属性可以提取出与 exec()和 test()执行的操作相关的信息。来看下面的例子:

let text = 'this has been a short summer';
let pattern = /(.)hort/g;

if (pattern.test(text)) {
  console.log(RegExp.input); // this has been a short summer
  console.log(RegExp.leftContext); // this has been a
  console.log(RegExp.rightContext); // summer
  console.log(RegExp.lastMatch); // short
  console.log(RegExp.lastParen); // s
}

以上代码创建了一个模式,用于搜索任何后跟'hort'的字符,并把第一个字符放在了捕获组中。不同属性包含的内容如下。

  • input 属性中包含原始的字符串。
  • leftConext 属性包含原始字符串中'short'之前的内容,rightContext 属性包含'short'之后的内容。
  • lastMatch 属性包含匹配整个正则表达式的上一个字符串,即'short'。
  • lastParen 属性包含捕获组的上一次匹配,即's'。

这些属性名也可以替换成简写形式,只不过要使用中括号语法来访问,如下面的例子所示,因为大多数简写形式都不是合法的 ECMAScript 标识符:

let text = 'this has been a short summer';
let pattern = /(.)hort/g;

/*
 * 注意:Opera 不支持简写属性名
 * IE 不支持多行匹配
 */
if (pattern.test(text)) {
  console.log(RegExp.$_); // this has been a short summer
  console.log(RegExp['$`']); // this has been a
  console.log(RegExp['$'']); // summer
  console.log(RegExp['$&']); // short
  console.log(RegExp['$+']); // s
}

RegExp 还有其他几个构造函数属性,可以存储最多 9 个捕获组的匹配项。这些属性通过 RegExp.$1RegExp.$9 来访问,分别包含第 19 个捕获组的匹配项。在调用 exec()或 test()时,这些属性就会被填充,然后就可以像下面这样使用它们:

let text = 'this has been a short summer';
let pattern = /(..)or(.)/g;
if (pattern.test(text)) {
  console.log(RegExp.$1); // sh
  console.log(RegExp.$2); // t
}

在这个例子中,模式包含两个捕获组。调用 test()搜索字符串之后,因为找到了匹配项所以返回 true,而且可以打印出通过 RegExp 构造函数的$1 和$2 属性取得的两个捕获组匹配的内容。

注意 RegExp 构造函数的所有属性都没有任何 Web 标准出处,因此不要在生产环境中使用它们。

1.3. 原始值包装类型

为了方便操作原始值,ECMAScript 提供了 3 种特殊的引用类型:Boolean、Number 和 String。这些类型具有本章介绍的其他引用类型一样的特点,但也具有与各自原始类型对应的特殊行为。每当用到某个原始值的方法或属性时,后台都会创建一个相应原始包装类型的对象,从而暴露出操作原始值的各种方法。来看下面的例子:

let s1 = 'some text';
let s2 = s1.substring(2);

在这里,s1 是一个包含字符串的变量,它是一个原始值。第二行紧接着在 s1 上调用了 substring()方法,并把结果保存在 s2 中。我们知道,原始值本身不是对象,因此逻辑上不应该有方法。而实际上这个例子又确实按照预期运行了。这是因为后台进行了很多处理,从而实现了上述操作。具体来说,当第二行访问 s1 时,是以读模式访问的,也就是要从内存中读取变量保存的值。在以读模式访问字符串值的任何时候,后台都会执行以下 3 步:

  1. 创建一个 String 类型的实例;
  2. 调用实例上的特定方法;
  3. 销毁实例。

可以把这 3 步想象成执行了如下 3 行 ECMAScript 代码:

let s1 = new String('some text');
let s2 = s1.substring(2);
s1 = null;

这种行为可以让原始值拥有对象的行为。对布尔值和数值而言,以上 3 步也会在后台发生,只不过使用的是 Boolean 和 Number 包装类型而已。

引用类型与原始值包装类型的主要区别在于对象的生命周期。在通过 new 实例化引用类型后,得到的实例会在离开作用域时被销毁,而自动创建的原始值包装对象则只存在于访问它的那行代码执行期间。这意味着不能在运行时给原始值添加属性和方法。比如下面的例子:

let s1 = 'some text';
s1.color = 'red';
console.log(s1.color); // undefined

这里的第二行代码尝试给字符串 s1 添加了一个 color 属性。可是,第三行代码访问 color 属性时,它却不见了。原因就是第二行代码运行时会临时创建一个 String 对象,而当第三行代码执行时,这个对象已经被销毁了。实际上,第三行代码在这里创建了自己的 String 对象,但这个对象没有 color 属性。

可以显式地使用 Boolean、Number 和 String 构造函数创建原始值包装对象。不过应该在确实必要时再这么做,否则容易让开发者疑惑,分不清它们到底是原始值还是引用值。在原始值包装类型的实例上调用 typeof 会返回'object',所有原始值包装对象都会转换为布尔值 true。

另外,Object 构造函数作为一个工厂方法,能够根据传入值的类型返回相应原始值包装类型的实例。比如:

let obj = new Object('some text');
console.log(obj instanceof String); // true

如果传给 Object 的是字符串,则会创建一个 String 的实例。如果是数值,则会创建 Number 的实例。布尔值则会得到 Boolean 的实例。

注意,使用 new 调用原始值包装类型的构造函数,与调用同名的转型函数并不一样。例如:

let value = '25';
let number = Number(value); // 转型函数
console.log(typeof number); // 'number'
let obj = new Number(value); // 构造函数
console.log(typeof obj); // 'object'

在这个例子中,变量 number 中保存的是一个值为 25 的原始数值,而变量 obj 中保存的是一个 Number 的实例。

虽然不推荐显式创建原始值包装类型的实例,但它们对于操作原始值的功能是很重要的。每个原始值包装类型都有相应的一套方法来方便数据操作。

1.3.1. Boolean

Boolean 是对应布尔值的引用类型。要创建一个 Boolean 对象,就使用 Boolean 构造函数并传入 true 或 false,如下例所示:

let booleanObject = new Boolean(true);

Boolean 的实例会重写 valueOf()方法,返回一个原始值 true 或 false。toString()方法被调用时也会被覆盖,返回字符串'true'或'false'。不过,Boolean 对象在 ECMAScript 中用得很少。不仅如此,它们还容易引起误会,尤其是在布尔表达式中使用 Boolean 对象时,比如:

let falseObject = new Boolean(false);
let result = falseObject && true;
console.log(result); // true

let falseValue = false;
result = falseValue && true;
console.log(result); // false

在这段代码中,我们创建一个值为 false 的 Boolean 对象。然后,在一个布尔表达式中通过&&操作将这个对象与一个原始值 true 组合起来。在布尔算术中,false && true 等于 false。可是,这个表达式是对 falseObject 对象而不是对它表示的值(false)求值。前面刚刚说过,所有对象在布尔表达式中都会自动转换为 true,因此 falseObject 在这个表达式里实际上表示一个 true 值。那么 true && true 当然是 true。

除此之外,原始值和引用值(Boolean 对象)还有几个区别。首先,typeof 操作符对原始值返回'boolean',但对引用值返回'object'。同样,Boolean 对象是 Boolean 类型的实例,在使用 instaceof 操作符时返回 true,但对原始值则返回 false,如下所示:

console.log(typeof falseObject); // object
console.log(typeof falseValue); // boolean
console.log(falseObject instanceof Boolean); // true
console.log(falseValue instanceof Boolean); // false

理解原始布尔值和 Boolean 对象之间的区别非常重要,强烈建议永远不要使用后者。

1.3.2. Number

Number 是对应数值的引用类型。要创建一个 Number 对象,就使用 Number 构造函数并传入一个数值,如下例所示:

let numberObject = new Number(10);

与 Boolean 类型一样,Number 类型重写了 valueOf()、toLocaleString()和 toString()方法。valueOf()方法返回 Number 对象表示的原始数值,另外两个方法返回数值字符串。toString()方法可选地接收一个表示基数的参数,并返回相应基数形式的数值字符串,如下所示:

let num = 10;
console.log(num.toString()); // '10'
console.log(num.toString(2)); // '1010'
console.log(num.toString(8)); // '12'
console.log(num.toString(10)); // '10'
console.log(num.toString(16)); // 'a'

toLocaleString(locales, options) 方法返回这个数字在特定语言环境下的表示字符串。新的 locales 和 options 参数让应用程序可以指定要进行格式转换的语言,并且定制函数的行为。在旧的实现中,会忽略 locales 和 options 参数,使用的语言环境和返回的字符串的形式完全取决于实现方式。详细 API 请参见MDN

这里来看一些例子:

const float = 1024.1234;

float.toLocaleString('zh-CN');
// '1,024.123'

float.toLocaleString('zh-Hans-CN-u-nu-hanidec');
// '一,〇二四.一二三'

float.toLocaleString('de-DE');
// '1.024,123'

float.toLocaleString('ar-EG');
// '١٬٠٢٤٫١٢٣'

const number = 1024;

number.toLocaleString('zh-CN', {style: 'decimal'});
// '1,024'

number.toLocaleString('zh-CN', {style: 'currency', currency: 'CNY'});
// '¥1,024.00'

number.toLocaleString('zh-CN', {style: 'percent'});
// '102,400%'

number.toLocaleString('zh-CN', {
  style: 'currency',
  currency: 'CNY',
  currencyDisplay: 'symbol',
});
// '¥1,024.00'

number.toLocaleString('zh-CN', {
  style: 'currency',
  currency: 'CNY',
  currencyDisplay: 'code',
});
// 'CNY 1,024.00'

number.toLocaleString('zh-CN', {
  style: 'currency',
  currency: 'CNY',
  currencyDisplay: 'name',
});
// '1,024.00人民币'

除了继承的方法,Number 类型还提供了几个用于将数值格式化为字符串的方法。

1.3.2.1. toFixed()

toFixed()方法返回包含指定小数点位数的数值字符串,如:

let num = 10;
console.log(num.toFixed(2)); // '10.00'

这里的 toFixed()方法接收了参数 2,表示返回的数值字符串要包含两位小数。结果返回值为'10.00',小数位填充了 0。如果数值本身的小数位超过了参数指定的位数,则四舍五入到最接近的小数位:

let num = 10.005;
console.log(num.toFixed(2)); // '10.01'

toFixed()自动舍入的特点可以用于处理货币。不过要注意的是,多个浮点数值的数学计算不一定得到精确的结果。比如,0.1 + 0.2 = 0.30000000000000004。

注意 toFixed()方法可以表示有 0~20 个小数位的数值。某些浏览器可能支持更大的范围,但这是通常被支持的范围。

1.3.2.2. toExponential()

另一个用于格式化数值的方法是 toExponential(),返回以科学记数法(也称为指数记数法)表示的数值字符串。与 toFixed()一样,toExponential()也接收一个参数,表示结果中小数的位数。来看下面的例子:

let num = 10;
console.log(num.toExponential(1)); // '1.0e+1'

这段代码的输出为'1.0e+1'。一般来说,这么小的数不用表示为科学记数法形式。如果想得到数值最适当的形式,那么可以使用 toPrecision()。

1.3.2.3. toPrecision()

toPrecision()方法会根据情况返回最合理的输出结果,可能是固定长度,也可能是科学记数法形式。这个方法接收一个参数,表示结果中数字的总位数(不包含指数)。来看几个例子:

let num = 99;
console.log(num.toPrecision(1)); // '1e+2'
console.log(num.toPrecision(2)); // '99'
console.log(num.toPrecision(3)); // '99.0'

在这个例子中,首先要用 1 位数字表示数值 99,得到'1e+2',也就是 100。因为 99 不能只用 1 位数字来精确表示,所以这个方法就将它舍入为 100,这样就可以只用 1 位数字(及其科学记数法形式)来表示了。用 2 位数字表示 99 得到'99',用 3 位数字则是'99.0'。本质上,toPrecision()方法会根据数值和精度来决定调用 toFixed()还是 toExponential()。为了以正确的小数位精确表示数值,这 3 个方法都会向上或向下舍入。

与 Boolean 对象类似,Number 对象也为数值提供了重要能力。但是,考虑到两者存在同样的潜在问题,因此并不建议直接实例化 Number 对象。在处理原始数值和引用数值时,typeof 和 instacnceof 操作符会返回不同的结果,如下所示:

let numberObject = new Number(10);
let numberValue = 10;
console.log(typeof numberObject); // 'object'
console.log(typeof numberValue); // 'number'
console.log(numberObject instanceof Number); // true
console.log(numberValue instanceof Number); // false

原始数值在调用 typeof 时始终返回'number',而 Number 对象则返回'object'。类似地,Number 对象是 Number 类型的实例,而原始数值不是。

1.3.2.4. isInteger()

ES6 新增了 Number.isInteger()方法,用于辨别一个数值是否保存为整数。有时候,小数位的 0 可能会让人误以为数值是一个浮点值:

console.log(Number.isInteger(1)); // true
console.log(Number.isInteger(1.0)); // true
console.log(Number.isInteger(1.01)); // false

IEEE 754 数值格式有一个特殊的数值范围,在这个范围内二进制值可以表示一个整数值。这个数值范围从 Number.MIN_SAFE_INTEGER(-2^53 + 1)到 Number.MAX_SAFE_INTEGER(2^53 - 1)。对超出这个范围的数值,即使尝试保存为整数,IEEE 754 编码格式也意味着二进制值可能会表示一个完全不同的数值。例如:

let test = 2 ** 53 === 2 ** 53 + 1;
console.log(test); // true

为了鉴别整数是否在这个范围内,可以使用 Number.isSafeInteger()方法:

console.log(Number.isSafeInteger(-1 * 2 ** 53)); // false
console.log(Number.isSafeInteger(-1 * 2 ** 53 + 1)); // true
console.log(Number.isSafeInteger(2 ** 53)); // false
console.log(Number.isSafeInteger(2 ** 53 - 1)); // true

1.3.3. String

String 是对应字符串的引用类型。要创建一个 String 对象,使用 String 构造函数并传入一个数值,如下例所示:

let stringObject = new String('hello world');

String 对象的方法可以在所有字符串原始值上调用。3 个继承的方法 valueOf()、toLocaleString()和 toString()都返回对象的原始字符串值。

每个 String 对象都有一个 length 属性,表示字符串中字符的数量。来看下面的例子:

let stringValue = 'hello world';
console.log(stringValue.length); // '11'

这个例子输出了字符串'hello world'中包含的字符数量:11。注意,即使字符串中包含双字节字符(而不是单字节的 ASCII 字符),也仍然会按单字符来计数。

String 类型提供了很多方法来解析和操作字符串。

1.3.3.1. JavaScript 字符

JavaScript 字符串由 16 位 码元(code unit) 组成。对多数字符来说,每 16 位码元对应一个字符。换句话说,字符串的 length 属性表示字符串包含多少 16 位码元:

let message = 'abcde';
console.log(message.length);
// -> 5

此外,charAt()方法返回给定索引位置的字符,由传给方法的整数参数指定。具体来说,这个方法查找指定索引位置的 16 位码元,并返回该码元对应的字符:

let message = 'abcde';
console.log(message.charAt(2));
// -> 'c'

JavaScript 字符串使用了两种 Unicode 编码混合的策略:UCS-2 和 UTF-16。对于可以采用 16 位编码的字符(U+0000~U+FFFF),这两种编码实际上是一样的。

注意 要深入了解关于字符编码的内容,推荐 Joel Spolsky 写的博客文章:“The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)”。另一个有用的资源是 Mathias Bynens 的博文:“JavaScript’s Internal Character Encoding: UCS-2 or UTF-16?”。

使用 charCodeAt()方法可以查看指定码元的字符编码。这个方法返回指定索引位置的码元值,索引以整数指定。比如:

let message = 'abcde';

// Unicode '小写拉丁字母 C'的编码是U+0063
console.log(message.charCodeAt(2)); // 99

// 十进制99 等于十六进制63
console.log(99 === 0x63); // true

fromCharCode()方法用于根据给定的 UTF-16 码元创建字符串中的字符。这个方法可以接受任意多个数值,并返回将所有数值对应的字符拼接起来的字符串:

// Unicode '小写拉丁字母 A'的编码是U+0061
// Unicode '小写拉丁字母 B'的编码是U+0062
// Unicode '小写拉丁字母 C'的编码是U+0063
// Unicode '小写拉丁字母 D'的编码是U+0064
// Unicode '小写拉丁字母 E'的编码是U+0065
console.log(String.fromCharCode(0x61, 0x62, 0x63, 0x64, 0x65)); // 'abcde'
// 0x0061 === 97
// 0x0062 === 98
// 0x0063 === 99
// 0x0064 === 100
// 0x0065 === 101
console.log(String.fromCharCode(97, 98, 99, 100, 101)); // 'abcde'

对于 U+0000~U+FFFF 范围内的字符,length、charAt()、charCodeAt()和 fromCharCode()返回的结果都跟预期是一样的。这是因为在这个范围内,每个字符都是用 16 位表示的,而这几个方法也都基于 16 位码元完成操作。只要字符编码大小与码元大小一一对应,这些方法就能如期工作。

小写字母为 'a'(97) 到 'z'(122)。

如下的代码演示了字母表:

let tempAlphabet = [];
for (let charCode = 97; charCode <= 122; charCode++) {
  tempAlphabet.push(String.fromCharCode(charCode));
}
console.log(tempAlphabet);
/* [
  'a', 'b', 'c', 'd', 'e', 'f',
  'g', 'h', 'i', 'j', 'k', 'l',
  'm', 'n', 'o', 'p', 'q', 'r',
  's', 't', 'u', 'v', 'w', 'x',
  'y', 'z'
] */

中文字符中基本汉字的范围是 0x4E00('一') 到 0x9FA5('龥')。一共 20902 字。

这个对应关系在扩展到 Unicode 增补字符平面时就不成立了。问题很简单,即 16 位只能唯一表示 65 536 个字符。这对于大多数语言字符集是足够了,在 Unicode 中称为 基本多语言平面(BMP)。为了表示更多的字符,Unicode 采用了一个策略,即每个字符使用另外 16 位去选择一个增补平面。这种每个字符使用两个 16 位码元的策略称为 代理对

在涉及增补平面的字符时,前面讨论的字符串方法就会出问题。比如,下面的例子中使用了一个笑脸表情符号,也就是一个使用代理对编码的字符:

// 'smiling face with smiling eyes' 表情符号的编码是U+1F60A
// 0x1F60A === 128522

let message = 'ab☺de';
console.log(message.length); // 6
console.log(message.charAt(1)); // b
console.log(message.charAt(2)); // <?>
console.log(message.charAt(3)); // <?>
console.log(message.charAt(4)); // d
console.log(message.charCodeAt(1)); // 98
console.log(message.charCodeAt(2)); // 55357
console.log(message.charCodeAt(3)); // 56842
console.log(message.charCodeAt(4)); // 100
console.log(String.fromCodePoint(0x1f60a)); // ☺
console.log(String.fromCharCode(97, 98, 55357, 56842, 100, 101)); // ab☺de

这些方法仍然将 16 位码元当作一个字符,事实上索引 2 和索引 3 对应的码元应该被看成一个代理对,只对应一个字符。fromCharCode()方法仍然返回正确的结果,因为它实际上是基于提供的二进制表示直接组合成字符串。浏览器可以正确解析代理对(由两个码元构成),并正确地将其识别为一个 Unicode 笑脸字符。

为正确解析既包含单码元字符又包含代理对字符的字符串,可以使用 codePointAt()来代替 charCodeAt()。跟使用 charCodeAt()时类似,codePointAt()接收 16 位码元的索引并返回该索引位置上的 码点(code point)。码点是 Unicode 中一个字符的完整标识。比如,'c'的码点是 0x0063,而'☺'的码点是 0x1F60A。码点可能是 16 位,也可能是 32 位,而 codePointAt()方法可以从指定码元位置识别完整的码点。

let message = 'ab☺de';
console.log(message.codePointAt(1)); // 98
console.log(message.codePointAt(2)); // 128522
console.log(message.codePointAt(3)); // 56842
console.log(message.codePointAt(4)); // 100

注意,如果传入的码元索引并非代理对的开头,就会返回错误的码点。这种错误只有检测单个字符的时候才会出现,可以通过从左到右按正确的码元数遍历字符串来规避。迭代字符串可以智能地识别代理对的码点:

console.log([...'ab☺de']); // ['a', 'b', '☺', 'd', 'e']

与 charCodeAt()有对应的 codePointAt()一样,fromCharCode()也有一个对应的 fromCodePoint()。这个方法接收任意数量的码点,返回对应字符拼接起来的字符串:

console.log(String.fromCharCode(97, 98, 55357, 56842, 100, 101)); // ab☺de
console.log(String.fromCodePoint(97, 98, 128522, 100, 101)); // ab☺de

1.3.3.2. normalize()

某些 Unicode 字符可以有多种编码方式。有的字符既可以通过一个 BMP 字符表示,也可以通过一个代理对表示。比如:

// U+00C5:上面带圆圈的大写拉丁字母A
console.log(String.fromCharCode(0x00c5)); // Å
// U+212B:长度单位“埃”
console.log(String.fromCharCode(0x212b)); // Å
// U+004:大写拉丁字母A
// U+030A:上面加个圆圈
console.log(String.fromCharCode(0x0041, 0x030a)); // Å

比较操作符不在乎字符看起来是什么样的,因此这 3 个字符互不相等。

let a1 = String.fromCharCode(0x00c5),
  a2 = String.fromCharCode(0x212b),
  a3 = String.fromCharCode(0x0041, 0x030a);

console.log(a1, a2, a3); // Å, Å, Å
console.log(a1 === a2); // false
console.log(a1 === a3); // false
console.log(a2 === a3); // false

为解决这个问题,Unicode 提供了 4 种规范化形式,可以将类似上面的字符规范化为一致的格式,无论底层字符的代码是什么。这 4 种规范化形式是:NFD(Normalization Form D)、NFC(Normalization Form C)、NFKD(Normalization Form KD)和 NFKC(Normalization Form KC)。可以使用 normalize()方法对字符串应用上述规范化形式,使用时需要传入表示哪种形式的字符串:'NFD'、'NFC'、'NFKD'或'NFKC'。

注意 这 4 种规范化形式的具体细节超出了本书范围,有兴趣的读者可以自行参考 UAX15#: Unicode Normalization Forms 中的 1.2 节“Normalization Forms”。

通过比较字符串与其调用 normalize()的返回值,就可以知道该字符串是否已经规范化了:

let a1 = String.fromCharCode(0x00c5),
  a2 = String.fromCharCode(0x212b),
  a3 = String.fromCharCode(0x0041, 0x030a);
// U+00C5 是对0+212B 进行NFC/NFKC 规范化之后的结果
console.log(a1 === a1.normalize('NFD')); // false
console.log(a1 === a1.normalize('NFC')); // true
console.log(a1 === a1.normalize('NFKD')); // false
console.log(a1 === a1.normalize('NFKC')); // true
// U+212B 是未规范化的
console.log(a2 === a2.normalize('NFD')); // false
console.log(a2 === a2.normalize('NFC')); // false
console.log(a2 === a2.normalize('NFKD')); // false
console.log(a2 === a2.normalize('NFKC')); // false
// U+0041/U+030A 是对0+212B 进行NFD/NFKD 规范化之后的结果
console.log(a3 === a3.normalize('NFD')); // true
console.log(a3 === a3.normalize('NFC')); // false
console.log(a3 === a3.normalize('NFKD')); // true
console.log(a3 === a3.normalize('NFKC')); // false

选择同一种规范化形式可以让比较操作符返回正确的结果:

let a1 = String.fromCharCode(0x00c5),
  a2 = String.fromCharCode(0x212b),
  a3 = String.fromCharCode(0x0041, 0x030a);
console.log(a1.normalize('NFD') === a2.normalize('NFD')); // true
console.log(a2.normalize('NFKC') === a3.normalize('NFKC')); // true
console.log(a1.normalize('NFC') === a3.normalize('NFC')); // true

1.3.3.3. 操作方法

本节介绍几个操作字符串值的方法。首先是 concat(),用于将一个或多个字符串拼接成一个新字符串。来看下面的例子:

let stringValue = 'hello ';
let result = stringValue.concat('world');
console.log(result); // 'hello world'
console.log(stringValue); // 'hello'

在这个例子中,对 stringValue 调用 concat()方法的结果是得到'hello world',但 stringValue 的值保持不变。concat()方法可以接收任意多个参数,因此可以一次性拼接多个字符串,如下所示:

let stringValue = 'hello ';
let result = stringValue.concat('world', '!');
console.log(result); // 'hello world!'
console.log(stringValue); // 'hello'

这个修改后的例子将字符串'world'和'!'追加到了'hello '后面。虽然 concat()方法可以拼接字符串,但更常用的方式是使用加号操作符(+)。而且多数情况下,对于拼接多个字符串来说,使用加号更方便。

ECMAScript 提供了 3 个从字符串中提取子字符串的方法:slice()、substr()和 substring()。这 3 个方法都返回调用它们的字符串的一个子字符串,而且都接收一或两个参数。第一个参数表示子字符串开始的位置,第二个参数表示子字符串结束的位置。对 slice()和 substring()而言,第二个参数是提取结束的位置(即该位置之前的字符会被提取出来)。对 substr()而言,第二个参数表示返回的子字符串数量。任何情况下,省略第二个参数都意味着提取到字符串末尾。与 concat()方法一样,slice()、substr()和 substring()也不会修改调用它们的字符串,而只会返回提取到的原始新字符串值。来看下面的例子:

let stringValue = 'hello world';
console.log(stringValue.slice(3)); // 'lo world'
console.log(stringValue.substring(3)); // 'lo world'
console.log(stringValue.substr(3)); // 'lo world'
console.log(stringValue.slice(3, 7)); // 'lo w'
console.log(stringValue.substring(3, 7)); // 'lo w'
console.log(stringValue.substr(3, 7)); // 'lo worl'

在这个例子中,slice()、substr()和 substring()是以相同方式被调用的,而且多数情况下返回的值也相同。如果只传一个参数 3,则所有方法都将返回'lo world',因为'hello'中'l'位置为 3。如果传入两个参数 3 和 7,则 slice()和 substring()返回'lo w'(因为'world'中'o'在位置 7,不包含),而 substr()返回'lo worl',因为第二个参数对它而言表示返回的字符数。

当某个参数是负值时,这 3 个方法的行为又有不同。比如,slice()方法将所有负值参数都当成字符串长度加上负参数值。

而 substr()方法将第一个负参数值当成字符串长度加上该值,将第二个负参数值转换为 0。substring()方法会将所有负参数值都转换为 0。看下面的例子:

let stringValue = 'hello world';
console.log(stringValue.slice(-3)); // 'rld'
console.log(stringValue.substring(-3)); // 'hello world'
console.log(stringValue.substr(-3)); // 'rld'
console.log(stringValue.slice(3, -4)); // 'lo w'
console.log(stringValue.substring(3, -4)); // 'hel'
console.log(stringValue.substr(3, -4)); // '' (empty string)

这个例子明确演示了 3 个方法的差异。在给 slice()和 substr()传入负参数时,它们的返回结果相同。这是因为-3 会被转换为 8(长度加上负参数),实际上调用的是 slice(8)和 substr(8)。而 substring()方法返回整个字符串,因为-3 会转换为 0。

在第二个参数是负值时,这 3 个方法各不相同。slice()方法将第二个参数转换为 7,实际上相当于调用 slice(3, 7),因此返回'lo w'。而 substring()方法会将第二个参数转换为 0,相当于调用 substring(3, 0),等价于 substring(0, 3),这是因为这个方法会将较小的参数作为起点,将较大的参数作为终点。对 substr()来说,第二个参数会被转换为 0,意味着返回的字符串包含零个字符,因而会返回一个空字符串。

1.3.3.4. 位置方法

有两个方法用于在字符串中定位子字符串:indexOf()和 lastIndexOf()。这两个方法从字符串中搜索传入的字符串,并返回位置(如果没找到,则返回-1)。两者的区别在于,indexOf()方法从字符串开头开始查找子字符串,而 lastIndexOf()方法从字符串末尾开始查找子字符串。来看下面的例子:

let stringValue = 'hello world';
console.log(stringValue.indexOf('o')); // 4
console.log(stringValue.lastIndexOf('o')); // 7

这里,字符串中第一个'o'的位置是 4,即'hello'中的'o'。最后一个'o'的位置是 7,即'world'中的'o'。如果字符串中只有一个'o',则 indexOf()和 lastIndexOf()返回同一个位置。

这两个方法都可以接收可选的第二个参数,表示开始搜索的位置。这意味着,indexOf()会从这个参数指定的位置开始向字符串末尾搜索,忽略该位置之前的字符;lastIndexOf()则会从这个参数指定的位置开始向字符串开头搜索,忽略该位置之后直到字符串末尾的字符。下面看一个例子:

let stringValue = 'hello world';
console.log(stringValue.indexOf('o', 6)); // 7
console.log(stringValue.lastIndexOf('o', 6)); // 4

在传入第二个参数 6 以后,结果跟前面的例子恰好相反。这一次,indexOf()返回 7,因为它从位置 6(字符'w')开始向后搜索字符串,在位置 7 找到了'o'。而 lastIndexOf()返回 4,因为它从位置 6 开始反向搜索至字符串开头,因此找到了'hello'中的'o'。像这样使用第二个参数并循环调用 indexOf()或 lastIndexOf(),就可以在字符串中找到所有的目标子字符串,如下所示:

let stringValue = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit';
let positions = new Array();
let pos = stringValue.indexOf('e');
while (pos > -1) {
  positions.push(pos);
  pos = stringValue.indexOf('e', pos + 1);
}
console.log(positions); // [3,24,32,35,52]

这个例子逐步增大开始搜索的位置,通过 indexOf()遍历了整个字符串。首先取得第一个'e'的位置,然后进入循环,将上一次的位置加 1 再传给 indexOf(),确保搜索到最后一个子字符串实例之后。每个位置都保存在 positions 数组中,可供以后使用。

1.3.3.5. 包含方法

ECMAScript 6 增加了 3 个用于判断字符串中是否包含另一个字符串的方法:startsWith()、endsWith()和 includes()。这些方法都会从字符串中搜索传入的字符串,并返回一个表示是否包含的布尔值。它们的区别在于,startsWith()检查开始于索引 0 的匹配项,endsWith()检查开始于索引(string.length - substring.length)的匹配项,而 includes()检查整个字符串:

let message = 'foobarbaz';
console.log(message.startsWith('foo')); // true
console.log(message.startsWith('bar')); // false
console.log(message.endsWith('baz')); // true
console.log(message.endsWith('bar')); // false
console.log(message.includes('bar')); // true
console.log(message.includes('qux')); // false

startsWith()和 includes()方法接收可选的第二个参数,表示开始搜索的位置。如果传入第二个参数,则意味着这两个方法会从指定位置向着字符串末尾搜索,忽略该位置之前的所有字符。下面是一个例子:

let message = 'foobarbaz';
console.log(message.startsWith('foo')); // true
console.log(message.startsWith('foo', 1)); // false
console.log(message.includes('bar')); // true
console.log(message.includes('bar', 4)); // false

endsWith()方法接收可选的第二个参数,表示应该当作字符串末尾的位置。如果不提供这个参数,那么默认就是字符串长度。如果提供这个参数,那么就好像字符串只有那么多字符一样:

let message = 'foobarbaz';
console.log(message.endsWith('bar')); // false
console.log(message.endsWith('bar', 6)); // true

1.3.3.6. trim()

ECMAScript 在所有字符串上都提供了 trim()方法。这个方法会创建字符串的一个副本,删除前、后所有空格符,再返回结果。比如:

let stringValue = ' hello world ';
let trimmedStringValue = stringValue.trim();
console.log(stringValue); // ' hello world '
console.log(trimmedStringValue); // 'hello world'

由于 trim()返回的是字符串的副本,因此原始字符串不受影响,即原本的前、后空格符都会保留。另外,trimLeft()和 trimRight()方法分别用于从字符串开始和末尾清理空格符。

1.3.3.7. repeat()

ECMAScript 在所有字符串上都提供了 repeat()方法。这个方法接收一个整数参数,表示要将字符串复制多少次,然后返回拼接所有副本后的结果。

let stringValue = 'na ';
console.log(stringValue.repeat(16) + 'batman');
// na na na na na na na na na na na na na na na na batman

1.3.3.8. padStart() 与 padEnd()

padStart()和 padEnd()方法会复制字符串,如果小于指定长度,则在相应一边填充字符,直至满足长度条件。这两个方法的第一个参数是长度,第二个参数是可选的填充字符串,默认为空格(U+0020)。

let stringValue = 'foo';
console.log(stringValue.padStart(6)); // ' foo'
console.log(stringValue.padStart(9, '.')); // '......foo'
console.log(stringValue.padEnd(6)); // 'foo '
console.log(stringValue.padEnd(9, '.')); // 'foo......'

可选的第二个参数并不限于一个字符。如果提供了多个字符的字符串,则会将其拼接并截断以匹配指定长度。此外,如果长度小于或等于字符串长度,则会返回原始字符串。

let stringValue = 'foo';
console.log(stringValue.padStart(8, 'bar')); // 'barbafoo'
console.log(stringValue.padStart(2)); // 'foo'
console.log(stringValue.padEnd(8, 'bar')); // 'foobarba'
console.log(stringValue.padEnd(2)); // 'foo'

1.3.3.9. 迭代与解构

字符串的原型上暴露了一个@@iterator 方法,表示可以迭代字符串的每个字符。可以像下面这样手动使用迭代器:

let message = 'abc';
let stringIterator = message[Symbol.iterator]();
console.log(stringIterator.next()); // {value: 'a', done: false}
console.log(stringIterator.next()); // {value: 'b', done: false}
console.log(stringIterator.next()); // {value: 'c', done: false}
console.log(stringIterator.next()); // {value: undefined, done: true}

在 for-of 循环中可以通过这个迭代器按序访问每个字符:

for (const c of 'abcde') {
  console.log(c);
}
// a
// b
// c
// d
// e

有了这个迭代器之后,字符串就可以通过解构操作符来解构了。比如,可以更方便地把字符串分割为字符数组:

let message = 'abcde';
console.log([...message]); // ['a', 'b', 'c', 'd', 'e']

1.3.3.10. 大小写转换

下一组方法涉及大小写转换,包括 4 个方法:toLowerCase()、toLocaleLowerCase()、toUpperCase()和 toLocaleUpperCase()。toLowerCase()和 toUpperCase()方法是原来就有的方法,与 java.lang.String 中的方法同名。toLocaleLowerCase()和 toLocaleUpperCase()方法旨在基于特定地区实现。在很多地区,地区特定的方法与通用的方法是一样的。但在少数语言中(如土耳其语),Unicode 大小写转换需应用特殊规则,要使用地区特定的方法才能实现正确转换。下面是几个例子:

let stringValue = 'hello world';
console.log(stringValue.toLocaleUpperCase()); // 'HELLO WORLD'
console.log(stringValue.toUpperCase()); // 'HELLO WORLD'
console.log(stringValue.toLocaleLowerCase()); // 'hello world'
console.log(stringValue.toLowerCase()); // 'hello world'

这里,toLowerCase()和 toLocaleLowerCase()都返回 hello world,而 toUpperCase()和 toLocaleUpperCase()都返回 HELLO WORLD。通常,如果不知道代码涉及什么语言,则最好使用地区特定的转换方法。

1.3.3.11. 模式匹配

String 类型专门为在字符串中实现模式匹配设计了几个方法。第一个就是 match()方法,这个方法本质上跟 RegExp 对象的 exec()方法相同。match()方法接收一个参数,可以是一个正则表达式字符串,也可以是一个 RegExp 对象。来看下面的例子:

let text = 'cat, bat, sat, fat';
let pattern = /.at/;

// 等价于pattern.exec(text)
let matches = text.match(pattern);
console.log(matches.index); // 0
console.log(matches[0]); // 'cat'
console.log(pattern.lastIndex); // 0

如果正则表达式没有 g 标记,则 match()方法返回的数组与 RegExp 对象的 exec()方法返回的数组是一样的:第一个元素是与整个模式匹配的字符串,其余元素则是与表达式中的捕获组匹配的字符串(如果有的话)。

如果正则表达式有 g 标记,则 match() 方法返回的数组为所有匹配的字符串,来看下面的例子:

let text = 'cat, bat, sat, fat';
let pattern = /.at/g;

let matches = text.match(pattern);
console.log(matches); // [ 'cat', 'bat', 'sat', 'fat' ]

另一个查找模式的字符串方法是 search()。这个方法唯一的参数与 match()方法一样:正则表达式字符串或 RegExp 对象。这个方法返回模式第一个匹配的位置索引,如果没找到则返回-1。search()始终从字符串开头向后匹配模式。看下面的例子:

let text = 'cat, bat, sat, fat';
let pos = text.search(/at/);
console.log(pos); // 1

这里,search(/at/)返回 1,即'at'的第一个字符在字符串中的位置。

为简化子字符串替换操作,ECMAScript 提供了 replace()方法。这个方法接收两个参数,第一个参数可以是一个 RegExp 对象或一个字符串(这个字符串不会转换为正则表达式),第二个参数可以是一个字符串或一个函数。如果第一个参数是字符串,那么只会替换第一个子字符串。要想替换所有子字符串,第一个参数必须为正则表达式并且带全局标记,如下面的例子所示:

let text = 'cat, bat, sat, fat';
let result = text.replace('at', 'ond');
console.log(result); // 'cond, bat, sat, fat'
result = text.replace(/at/g, 'ond');
console.log(result); // 'cond, bond, sond, fond'

在这个例子中,字符串'at'先传给 replace()函数,而替换文本是'ond'。结果是'cat'被修改为'cond',而字符串的剩余部分保持不变。通过将第一个参数改为带全局标记的正则表达式,字符串中的所有'at'都被替换成了'ond'。

第二个参数是字符串的情况下,有几个特殊的字符序列,可以用来插入正则表达式操作的值。ECMA-262 中规定了下表中的值。

字符序列 替换文本
$$ $
$& 匹配整个模式的子字符串。与 RegExp.lastMatch 相同
$' 匹配的子字符串之前的字符串。与 RegExp.rightContext 相同
$` 匹配的子字符串之后的字符串。与 RegExp.leftContext 相同
$n 匹配第 n 个捕获组的字符串,其中 n 是 0~9。比如,$1 是匹配第一个捕获组的字符串,$2 是匹配第二个捕获组的字符串,以此类推。如果没有捕获组,则值为空字符串
$nn 匹配第 nn 个捕获组字符串,其中 nn 是 01~99。比如,$01 是匹配第一个捕获组的字符串,$02 是匹配第二个捕获组的字符串,以此类推。如果没有捕获组,则值为空字符串

使用这些特殊的序列,可以在替换文本中使用之前匹配的内容,如下面的例子所示:

let text = 'cat, bat, sat, fat';
result = text.replace(/(.at)/g, 'word ($1)');
console.log(result); // word (cat), word (bat), word (sat), word (fat)

这里,每个以'at'结尾的词都会被替换成'word'后跟一对小括号,其中包含捕获组匹配的内容$1。

replace()的第二个参数可以是一个函数。在只有一个匹配项时,这个函数会收到 3 个参数:与整个模式匹配的字符串、匹配项在字符串中的开始位置,以及整个字符串。在有多个捕获组的情况下,每个匹配捕获组的字符串也会作为参数传给这个函数,但最后两个参数还是与整个模式匹配的开始位置和原始字符串。这个函数应该返回一个字符串,表示应该把匹配项替换成什么。使用函数作为第二个参数可以更细致地控制替换过程,如下所示:

function htmlEscape(text) {
  return text.replace(/[<>'&]/g, function (match, pos, originalText) {
    switch (match) {
      case '<':
        return '&lt;';
      case '>':
        return '&gt;';
      case '&':
        return '&amp;';
      case ''':
        return '&quot;';
    }
  });
}
console.log(htmlEscape('<p class='greeting'>Hello world!</p>'));
// '&lt;p class=&quot;greeting&quot;&gt;Hello world!</p>'

这里,函数 htmlEscape()用于将一段 HTML 中的 4 个字符替换成对应的实体:小于号、大于号、和号,还有双引号(都必须经过转义)。实现这个任务最简单的办法就是用一个正则表达式查找这些字符,然后定义一个函数,根据匹配的每个字符分别返回特定的 HTML 实体。

最后一个与模式匹配相关的字符串方法是 split()。这个方法会根据传入的分隔符将字符串拆分成数组。作为分隔符的参数可以是字符串,也可以是 RegExp 对象。(字符串分隔符不会被这个方法当成正则表达式。)还可以传入第二个参数,即数组大小,确保返回的数组不会超过指定大小。来看下面的例子:

let colorText = 'red,blue,green,yellow';
let colors1 = colorText.split(','); // ['red', 'blue', 'green', 'yellow']
let colors2 = colorText.split(',', 2); // ['red', 'blue']
let colors3 = colorText.split(/[^,]+/); // ['', ',', ',', ',', '']

在这里,字符串 colorText 是一个逗号分隔的颜色名称符串。调用 split(',')会得到包含这些颜色名的数组,基于逗号进行拆分。要把数组元素限制为 2 个,传入第二个参数 2 即可。最后,使用正则表达式可以得到一个包含逗号的数组。注意在最后一次调用 split()时,返回的数组前后包含两个空 字符串。这是因为正则表达式指定的分隔符出现在了字符串开头('red')和末尾('yellow')。

1.3.3.12. localeCompare()

最后一个方法是 localeCompare(),这个方法比较两个字符串,返回如下 3 个值中的一个。

  • 如果按照字母表顺序,字符串应该排在字符串参数前头,则返回负值。(通常是-1,具体还要看与实际值相关的实现。)
  • 如果字符串与字符串参数相等,则返回 0。
  • 如果按照字母表顺序,字符串应该排在字符串参数后头,则返回正值。(通常是 1,具体还要看与实际值相关的实现。)

下面是一个例子:

let stringValue = 'yellow';
console.log(stringValue.localeCompare('brick')); // 1
console.log(stringValue.localeCompare('yellow')); // 0
console.log(stringValue.localeCompare('zoo')); // -1

在这里,字符串'yellow'与 3 个不同的值进行了比较:'brick'、'yellow'和'zoo'。'brick'按字母表顺序应该排在'yellow'前头,因此 localeCompare()返回 1。'yellow'等于'yellow',因此'localeCompare()'返回 0。最后,'zoo'在'yellow'后面,因此 localeCompare()返回-1。强调一下,因为返回的具体值可能因具体实现而异,所以最好像下面的示例中一样使用 localeCompare():

function determineOrder(value) {
  let result = stringValue.localeCompare(value);
  if (result < 0) {
    console.log(`The string 'yellow' comes before the string '${value}'.`);
  } else if (result > 0) {
    console.log(`The string 'yellow' comes after the string '${value}'.`);
  } else {
    console.log(`The string 'yellow' is equal to the string '${value}'.`);
  }
}
determineOrder('brick');
determineOrder('yellow');
determineOrder('zoo');

这样一来,就可以保证在所有实现中都能正确判断字符串的顺序了。localeCompare()的独特之处在于,实现所在的地区(国家和语言)决定了这个方法如何比较字符串。在美国,英语是 ECMAScript 实现的标准语言,localeCompare()区分大小写,大写字母排在小写字母前面。但其他地区未必是这种情况。

1.4. 单例内置对象

ECMA-262 对内置对象的定义是“任何由 ECMAScript 实现提供、与宿主环境无关,并在 ECMAScript 程序开始执行时就存在的对象”。这就意味着,开发者不用显式地实例化内置对象,因为它们已经实例化好了。前面我们已经接触了大部分内置对象,包括 Object、Array 和 String。本节介绍 ECMA-262 定义的另外两个单例内置对象:Global 和 Math。

1.4.1. Global

Global 对象是 ECMAScript 中最特别的对象,因为代码不会显式地访问它。ECMA-262 规定 Global 对象为一种兜底对象,它所针对的是不属于任何对象的属性和方法。事实上,不存在全局变量或全局函数这种东西。在全局作用域中定义的变量和函数都会变成 Global 对象的属性 。本书前面介绍的函数,包括 isNaN()、isFinite()、parseInt()和 parseFloat(),实际上都是 Global 对象的方法。除了这些,Global 对象上还有另外一些方法。

1.4.1.1. URL 编码方法

encodeURI()和 encodeURIComponent()方法用于编码统一资源标识符(URI),以便传给浏览器。有效的 URI 不能包含某些字符,比如空格。使用 URI 编码方法来编码 URI 可以让浏览器能够理解它们,同时又以特殊的 UTF-8 编码替换掉所有无效字符。

ecnodeURI()方法用于对整个 URI 进行编码,比如'www.wrox.com/illegal value.js'。而 encodeURIComponent()方法用于编码 URI 中单独的组件,比如前面 URL 中的'illegal value.js'。这两个方法的主要区别是,encodeURI()不会编码属于 URL 组件的特殊字符,比如冒号、斜杠、问号、井号,而 encodeURIComponent()会编码它发现的所有非标准字符。来看下面的例子:

let uri = 'http://www.google.com/illegal value.js#start';

// 'http://www.google.com/illegal%20value.js#start'
console.log(encodeURI(uri));

// 'http%3A%2F%2Fwww.wrox.com%2Fillegal%20value.js%23start'
console.log(encodeURIComponent(uri));

这里使用 encodeURI()编码后,除空格被替换为%20 之外,没有任何变化。而 encodeURIComponent()方法将所有非字母字符都替换成了相应的编码形式。这就是使用 encodeURI()编码整个 URI,但只使用 encodeURIComponent()编码那些会追加到已有 URI 后面的字符串的原因。

注意 一般来说,使用 encodeURIComponent()应该比使用 encodeURI()的频率更高,这是因为编码查询字符串参数比编码基准 URI 的次数更多。

与 encodeURI()和 encodeURIComponent()相对的是 decodeURI()和 decodeURIComponent()。decodeURI()只对使用 encodeURI()编码过的字符解码。例如,%20 会被替换为空格,但%23 不会被替换为井号(#),因为井号不是由 encodeURI()替换的。类似地,decodeURIComponent()解码所有被 encodeURIComponent()编码的字符,基本上就是解码所有特殊值。来看下面的例子:

let uri = 'http%3A%2F%2Fwww.wrox.com%2Fillegal%20value.js%23start';

// http%3A%2F%2Fwww.wrox.com%2Fillegal value.js%23start
console.log(decodeURI(uri));

// http:// www.wrox.com/illegal value.js#start
console.log(decodeURIComponent(uri));

这里,uri 变量中包含一个使用 encodeURIComponent()编码过的字符串。首先输出的是使用 decodeURI()解码的结果,可以看到只用空格替换了%20。然后是使用 decodeURIComponent()解码的结果,其中替换了所有特殊字符,并输出了没有包含任何转义的字符串。(这个字符串不是有效的 URL。)

注意 URI 方法 encodeURI()、encodeURIComponent()、decodeURI()和 decodeURIComponent()取代了 escape()和 unescape()方法,后者在 ECMA-262 第 3 版中就已经废弃了。URI 方法始终是首选方法,因为它们对所有 Unicode 字符进行编码,而原来的方法只能正确编码 ASCII 字符。不要在生产环境中使用 escape()和 unescape()。

1.4.1.2. eval()

最后一个方法可能是整个 ECMAScript 语言中最强大的了,它就是 eval()。这个方法就是一个完整的 ECMAScript 解释器,它接收一个参数,即一个要执行的 ECMAScript(JavaScript)字符串。来看一个例子:

eval('console.log('hi')');

上面这行代码的功能与下一行等价:

console.log('hi');

当解释器发现 eval()调用时,会将参数解释为实际的 ECMAScript 语句,然后将其插入到该位置。通过 eval()执行的代码属于该调用所在上下文,被执行的代码与该上下文拥有相同的作用域链。这意味着定义在包含上下文中的变量可以在 eval()调用内部被引用,比如下面这个例子:

let msg = 'hello world';
eval('console.log(msg)'); // 'hello world'

这里,变量 msg 是在 eval()调用的外部上下文中定义的,而 console.log()显示了文本'hello world'。这是因为第二行代码会被替换成一行真正的函数调用代码。类似地,可以在 eval()内部定义一个函数或变量,然后在外部代码中引用,如下所示:

eval('function sayHi() { console.log('hi'); }');
sayHi();

这里,函数 sayHi()是在 eval()内部定义的。因为该调用会被替换为真正的函数定义,所以才可能在下一行代码中调用 sayHi()。对于变量也是一样的:

eval('let msg = 'hello world';');
console.log(msg); // Reference Error: msg is not defined

通过 eval()定义的任何变量和函数都不会被提升,这是因为在解析代码的时候,它们是被包含在一个字符串中的。它们只是在 eval()执行的时候才会被创建。

在严格模式下,在 eval()内部创建的变量和函数无法被外部访问。换句话说,最后两个例子会报错。同样,在严格模式下,赋值给 eval 也会导致错误:

'use strict';
eval = 'hi'; // 导致错误

注意 解释代码字符串的能力是非常强大的,但也非常危险。在使用 eval()的时候必须极为慎重,特别是在解释用户输入的内容时。因为这个方法会对 XSS 利用暴露出很大的攻击面。恶意用户可能插入会导致你网站或应用崩溃的代码。

1.4.1.3. Global 对象属性

Global 对象有很多属性,其中一些前面已经提到过了。像 undefined、NaN 和 Infinity 等特殊值都是 Global 对象的属性。此外,所有原生引用类型构造函数,比如 Object 和 Function,也都是 Global 对象的属性。下表列出了所有这些属性。

属 性 说 明
undefined 特殊值 undefined
NaN 特殊值 NaN
Infinity 特殊值 Infinity
Object Object 的构造函数
Array Array 的构造函数
Function Function 的构造函数
Boolean Boolean 的构造函数
String String 的构造函数
Number Number 的构造函数
Date Date 的构造函数
RegExp RegExp 的构造函数
Symbol Symbol 的伪构造函数
Error Error 的构造函数
EvalError EvalError 的构造函数
RangeError RangeError 的构造函数
ReferenceError ReferenceError 的构造函数
SyntaxError SyntaxError 的构造函数
TypeError TypeError 的构造函数
URIError URIError 的构造函数

1.4.1.4. window 对象

虽然 ECMA-262 没有规定直接访问 Global 对象的方式,但浏览器将 window 对象实现为 Global 对象的代理。因此,所有全局作用域中声明的变量和函数都变成了 window 的属性。来看下面的例子:

var color = 'red';
function sayColor() {
  console.log(window.color);
}
window.sayColor(); // 'red'

这里定义了一个名为 color 的全局变量和一个名为 sayColor()的全局函数。在 sayColor()内部,通过 window.color 访问了 color 变量,说明全局变量变成了 window 的属性。接着,又通过 window 对象直接调用了 window.sayColor()函数,从而输出字符串。

注意 window 对象在 JavaScript 中远不止实现了 ECMAScript 的 Global 对象那么简单。关于 window 对象的更多介绍,请参考第 12 章。

另一种获取 Global 对象的方式是使用如下的代码:

let global = (function () {
  return this;
})();

这段代码创建一个立即调用的函数表达式,返回了 this 的值。如前所述,当一个函数在没有明确(通过成为某个对象的方法,或者通过 call()/apply())指定 this 值的情况下执行时,this 值等于 Global 对象。因此,调用一个简单返回 this 的函数是在任何执行上下文中获取 Global 对象的通用方式。

1.4.2. Math

注意 Math 对象上提供的计算要比直接在 JavaScript 实现的快得多,因为 Math 对象上的计算使用了 JavaScript 引擎中更高效的实现和处理器指令。但使用 Math 计算的问题是精度会因浏览器、操作系统、指令集和硬件而异。

1.4.2.1. Math 对象属性

Math 对象有一些属性,主要用于保存数学中的一些特殊值。下表列出了这些属性。

属 性 说 明
Math.E 自然对数的基数 e 的值
Math.LN10 10 为底的自然对数
Math.LN2 2 为底的自然对数
Math.LOG2E 以 2 为底 e 的对数
Math.LOG10E 以 10 为底 e 的对数
Math.PI π 的值
Math.SQRT1_2 1/2 的平方根
Math.SQRT2 2 的平方根

这些值的含义和用法超出了本书的范畴,但都是 ECMAScript 规范定义的,并可以在你需要时使用。

1.4.2.2. min() 与 max()

Math 对象也提供了很多辅助执行简单或复杂数学计算的方法。

min()和 max()方法用于确定一组数值中的最小值和最大值。这两个方法都接收任意多个参数,如下面的例子所示:

let max = Math.max(3, 54, 32, 16);
console.log(max); // 54

let min = Math.min(3, 54, 32, 16);
console.log(min); // 3

在 3、54、32 和 16 中,Math.max()返回 54,Math.min()返回 3。使用这两个方法可以避免使用额外的循环和 if 语句来确定一组数值的最大最小值。

要知道数组中的最大值和最小值,可以像下面这样使用扩展操作符:

let values = [1, 2, 3, 4, 5, 6, 7, 8];
let max = Math.max(...values);

1.4.2.3. 舍入方法

接下来是用于把小数值舍入为整数的 4 个方法:Math.ceil()、Math.floor()、Math.round()和 Math.fround()。这几个方法处理舍入的方式如下所述。

  • Math.ceil()方法始终向上舍入为最接近的整数。
  • Math.floor()方法始终向下舍入为最接近的整数。
  • Math.round()方法执行四舍五入。
  • Math.fround()方法返回数值最接近的单精度(32 位)浮点值表示。

以下示例展示了这些方法的用法:

console.log(Math.ceil(25.9)); // 26
console.log(Math.ceil(25.5)); // 26
console.log(Math.ceil(25.1)); // 26
console.log(Math.round(25.9)); // 26
console.log(Math.round(25.5)); // 26
console.log(Math.round(25.1)); // 25
console.log(Math.fround(0.4)); // 0.4000000059604645
console.log(Math.fround(0.5)); // 0.5
console.log(Math.fround(25.9)); // 25.899999618530273
console.log(Math.floor(25.9)); // 25
console.log(Math.floor(25.5)); // 25
console.log(Math.floor(25.1)); // 25

对于 25 和 26(不包含)之间的所有值,Math.ceil()都会返回 26,因为它始终向上舍入。Math.round()只在数值大于等于 25.5 时返回 26,否则返回 25。最后,Math.floor()对所有 25 和 26(不包含)之间的值都返回 25。

1.4.2.4. random()

Math.random()方法返回一个 0~1 范围内的随机数,其中包含 0 但不包含 1。对于希望显示随机名言或随机新闻的网页,这个方法是非常方便的。可以基于如下公式使用 Math.random()从一组整数中随机选择一个数:

number = Math.floor(
  Math.random() * total_number_of_choices + first_possible_value,
);

这里使用了 Math.floor()方法,因为 Math.random()始终返回小数,即便乘以一个数再加上一个数也是小数。因此,如果想从 1~10 范围内随机选择一个数,代码就是这样的:

let num = Math.floor(Math.random() * 10 + 1);

这样就有 10 个可能的值(110),其中最小的值是 1。如果想选择一个 210 范围内的值,则代码就要写成这样:

let num = Math.floor(Math.random() * 9 + 2);

2~10 只有 9 个数,所以可选总数(total_number_of_choices)是 9,而最小可能的值(first_possible_value)是 2。很多时候,通过函数来算出可选总数和最小可能的值可能更方便,比如:

function selectFrom(lowerValue, upperValue) {
  let choices = upperValue - lowerValue + 1;
  return Math.floor(Math.random() * choices + lowerValue);
}
let num = selectFrom(2, 10);
console.log(num); // 2~10 范围内的值,其中包含2 和10

这里的函数 selectFrom()接收两个参数:应该返回的最小值和最大值。通过将这两个值相减再加 1 得到可选总数,然后再套用上面的公式。于是,调用 selectFrom(2,10)就可以从 2~10(包含)范围内选择一个值了。使用这个函数,从一个数组中随机选择一个元素就很容易,比如:

let colors = ['red', 'green', 'blue', 'yellow', 'black', 'purple', 'brown'];
let color = colors[selectFrom(0, colors.length - 1)];

例如,生成一个随机的颜色码,即一个 6 位的 16 进制数,可以这样做:

let maxValue = (0xffffff).toString(10);
console.log(maxValue); // 16777215

let randomColor =
  '#' + (~~(Math.random() * parseInt('0xffffff', 16) + 1)).toString(16);
console.log(randomColor); // #3684b4

注意 Math.random()方法在这里出于演示目的是没有问题的。如果是为了加密而需要生成随机数(传给生成器的输入需要较高的不确定性),那么建议使用 window.crypto.getRandomValues()。

1.4.2.5. 其他方法

Math 对象还有很多涉及各种简单或高阶数运算的方法。讨论每种方法的具体细节或者它们的适用场景超出了本书的范畴。不过,下表还是总结了 Math 对象的其他方法。

方 法 说 明
Math.abs(x) 返回 x 的绝对值
Math.exp(x) 返回 Math.E 的 x 次幂
Math.expm1(x) 等于 Math.exp(x) - 1
Math.log(x) 返回 x 的自然对数
Math.log1p(x) 等于 1 + Math.log(x)
Math.pow(x, power) 返回 x 的 power 次幂
Math.hypot(...nums) 返回 nums 中每个数平方和的平方根
Math.clz32(x) 返回 32 位整数 x 的前置零的数量
Math.sign(x) 返回表示 x 符号的 1、0、-0 或-1
Math.trunc(x) 返回 x 的整数部分,删除所有小数
Math.sqrt(x) 返回 x 的平方根
Math.cbrt(x) 返回 x 的立方根
Math.acos(x) 返回 x 的反余弦
Math.acosh(x) 返回 x 的反双曲余弦
Math.asin(x) 返回 x 的反正弦
Math.asinh(x) 返回 x 的反双曲正弦
Math.atan(x) 返回 x 的反正切
Math.atanh(x) 返回 x 的反双曲正切
Math.atan2(y, x) 返回 y/x 的反正切
Math.cos(x) 返回 x 的余弦
Math.sin(x) 返回 x 的正弦
Math.tan(x) 返回 x 的正切

即便这些方法都是由 ECMA-262 定义的,对正弦、余弦、正切等计算的实现仍然取决于浏览器,因为计算这些值的方式有很多种。结果,这些方法的精度可能因实现而异。