第 3 章 语言基础
本章内容
- 语法
- 数据类型
- 流控制语句
- 理解函数
任何语言的核心所描述的都是这门语言在最基本的层面上如何工作,涉及语法、操作符、数据类型以及内置功能,在此基础之上才可以构建复杂的解决方案。如前所述,ECMA-262以一个名为ECMAScript的伪语言的形式,定义了JavaScript的所有这些方面。
ECMA-262第5版(ES5)定义的ECMAScript,是目前为止实现得最为广泛(即受浏览器支持最好)的一个版本。第6版(ES6)在浏览器中的实现(即受支持)程度次之。到2017年底,大多数主流浏览器几乎或全部实现了这一版的规范。为此,本章接下来的内容主要基于ECMAScript第6版。
3.1 语法
ECMAScript的语法很大程度上借鉴了C语言和其他类C语言,如Java和Perl。熟悉这些语言的开发者,应该很容易理解ECMAScript宽松的语法。
3.1.1 区分大小写
首先要知道的是,ECMAScript中一切都区分大小写。无论是变量、函数名还是操作符,都区分大小写。换句话说,变量test
和变量Test
是两个不同的变量。类似地,typeof
不能作为函数名,因为它是一个关键字(后面会介绍)。但Typeof
是一个完全有效的函数名。
3.1.2 标识符
所谓标识符,就是变量、函数、属性或函数参数的名称。标识符可以由一或多个下列字符组成:
- 第一个字符必须是一个字母、下划线(
_
)或美元符号($
); - 剩下的其他字符可以是字母、下划线、美元符号或数字。
标识符中的字母可以是扩展ASCII(Extended ASCII)中的字母,也可以是Unicode的字母字符,如À和Æ(但不推荐使用)。
按照惯例,ECMAScript标识符使用驼峰大小写形式,即第一个单词的首字母小写,后面每个单词的首字母大写,如:
firstSecond
myCar
doSomethingImportant
虽然这种写法并不是强制性的,但因为这种形式跟ECMAScript内置函数和对象的命名方式一致,所以算是最佳实践。
注意 关键字、保留字、
true
、false
和null
不能作为标识符。具体内容请参考3.2节。
3.1.3 注释
ECMAScript采用C语言风格的注释,包括单行注释和块注释。单行注释以两个斜杠字符开头,如:
// 单行注释
块注释以一个斜杠和一个星号(/*
)开头,以它们的反向组合(*/
)结尾,如:
/* 这是多行
注释 */
3.1.4 严格模式
ECMAScript 5增加了严格模式(strict mode)的概念。严格模式是一种不同的JavaScript解析和执行模型,ECMAScript 3的一些不规范写法在这种模式下会被处理,对于不安全的活动将抛出错误。要对整个脚本启用严格模式,在脚本开头加上这一行:
"use strict";
虽然看起来像个没有赋值给任何变量的字符串,但它其实是一个预处理指令。任何支持的JavaScript引擎看到它都会切换到严格模式。选择这种语法形式的目的是不破坏ECMAScript 3语法。
也可以单独指定一个函数在严格模式下执行,只要把这个预处理指令放到函数体开头即可:
function doSomething() {
"use strict";
// 函数体
}
严格模式会影响JavaScript执行的很多方面,因此本书在用到它时会明确指出来。所有现代浏览器都支持严格模式。
3.1.5 语句
ECMAScript中的语句以分号结尾。省略分号意味着由解析器确定语句在哪里结尾,如下面的例子所示:
let sum = a + b // 没有分号也有效,但不推荐
let diff = a - b; // 加分号有效,推荐
即使语句末尾的分号不是必需的,也应该加上。记着加分号有助于防止省略造成的问题,比如可以避免输入内容不完整。此外,加分号也便于开发者通过删除空行来压缩代码(如果没有结尾的分号,只删除空行,则会导致语法错误)。加分号也有助于在某些情况下提升性能,因为解析器会尝试在合适的位置补上分号以纠正语法错误。
多条语句可以合并到一个C语言风格的代码块中。代码块由一个左花括号({
)标识开始,一个右花括号(}
)标识结束:
if (test) {
test = false;
console.log(test);
}
if之类的控制语句只在执行多条语句时要求必须有代码块。不过,最佳实践是始终在控制语句中使用代码块,即使要执行的只有一条语句,如下例所示:
// 有效,但容易导致错误,应该避免
if (test)
console.log(test);
// 推荐
if (test) { console.log(test);
}
在控制语句中使用代码块可以让内容更清晰,在需要修改代码时也可以减少出错的可能性。
3.2 关键字与保留字
ECMA-262描述了一组保留的关键字,这些关键字有特殊用途,比如表示控制语句的开始和结束,或者执行特定的操作。按照规定,保留的关键字不能用作标识符或属性名。ECMA-262第6版规定的所有关键字如下:
break do in typeof
case else instanceof var
catch export new void
class extends return while
const finally super with
continue for switch yield
debugger function this
default if throw
delete import try
规范中也描述了一组未来的保留字,同样不能用作标识符或属性名。虽然保留字在语言中没有特定用途,但它们是保留给将来做关键字用的。
以下是ECMA-262第6版为将来保留的所有词汇。
始终保留:
enum
严格模式下保留:
implements package public
interface protected static
let private
模块代码中保留:
await
这些词汇不能用作标识符,但现在还可以用作对象的属性名。一般来说,最好还是不要使用关键字和保留字作为标识符和属性名,以确保兼容过去和未来的ECMAScript版本。
3.3 变量
ECMAScript变量是松散类型的,意思是变量可以用于保存任何类型的数据。每个变量只不过是一个用于保存任意值的命名占位符。有3个关键字可以声明变量:var
、const
和let
。其中,var
在ECMAScript的所有版本中都可以使用,而const
和let
只能在ECMAScript 6及更晚的版本中使用。
3.3.1 var
关键字
要定义变量,可以使用var
操作符(注意var
是一个关键字),后跟变量名(即标识符,如前所述):
var message;
这行代码定义了一个名为message
的变量,可以用它保存任何类型的值。(不初始化的情况下,变量会保存一个特殊值undefined
,下一节讨论数据类型时会谈到。)ECMAScript实现变量初始化,因此可以同时定义变量并设置它的值:
var message = "hi";
这里,message
被定义为一个保存字符串值hi
的变量。像这样初始化变量不会将它标识为字符串类型,只是一个简单的赋值而已。随后,不仅可以改变保存的值,也可以改变值的类型:
var message = "hi";
message = 100; // 合法,但不推荐
在这个例子中,变量message
首先被定义为一个保存字符串值hi
的变量,然后又被重写为保存了数值100。虽然不推荐改变变量保存值的类型,但这在ECMAScript中是完全有效的。
var声明作用域
关键的问题在于,使用var
操作符定义的变量会成为包含它的函数的局部变量。比如,使用var
在一个函数内部定义一个变量,就意味着该变量将在函数退出时被销毁:function test() { var message = "hi"; // 局部变量 } test(); console.log(message); // 出错!
这里,
message
变量是在函数内部使用var
定义的。函数叫test()
,调用它会创建这个变量并给它赋值。调用之后变量随即被销毁,因此示例中的最后一行会导致错误。不过,在函数内定义变量时省略var
操作符,可以创建一个全局变量:function test() { message = "hi"; // 全局变量 } test(); console.log(message); // "hi"
去掉之前的
var
操作符之后,message
就变成了全局变量。只要调用一次函数test()
,就会定义这个变量,并且可以在函数外部访问到。注意 虽然可以通过省略
var
操作符定义全局变量,但不推荐这么做。在局部作用域中定义的全局变量很难维护,也会造成困惑。这是因为不能一下子断定省略var
是不是有意而为之。在严格模式下,如果像这样给未声明的变量赋值,则会导致抛出ReferenceError
。如果需要定义多个变量,可以在一条语句中用逗号分隔每个变量(及可选的初始化):
var message = "hi", found = false, age = 29;
这里定义并初始化了3个变量。因为ECMAScript是松散类型的,所以使用不同数据类型初始化的变量可以用一条语句来声明。插入换行和空格缩进并不是必需的,但这样有利于阅读理解。
在严格模式下,不能定义名为eval
和arguments
的变量,否则会导致语法错误。var声明提升
使用var
时,下面的代码不会报错。这是因为使用这个关键字声明的变量会自动提升到函数作用域顶部:function foo() { console.log(age); var age = 26; } foo(); // undefined
之所以不会报错,是因为ECMAScript运行时把它看成等价于如下代码:
function foo() { var age; console.log(age); age = 26; } foo(); // undefined
这就是所谓的“提升”(hoist),也就是把所有变量声明都拉到函数作用域的顶部。此外,反复多次使用
var
声明同一个变量也没有问题:function foo() { var age = 16; var age = 26; var age = 36; console.log(age); } foo(); // 36
3.3.2 let
声明
let
跟var
的作用差不多,但有着非常重要的区别。最明显的区别是,let
声明的范围是块作用域,而var
声明的范围是函数作用域。
if (true) {
var name = 'Matt';
console.log(name); // Matt
}
console.log(name); // Matt
if (true) {
let age = 26;
console.log(age); // 26
}
console.log(age); // ReferenceError: age没有定义
在这里,age
变量之所以不能在if
块外部被引用,是因为它的作用域仅限于该块内部。块作用域是函数作用域的子集,因此适用于var
的作用域限制同样也适用于let
。
let
也不允许同一个块作用域中出现冗余声明。这样会导致报错:
var name;
var name;
let age;
let age; // SyntaxError;标识符age已经声明过了
当然,JavaScript引擎会记录用于变量声明的标识符及其所在的块作用域,因此嵌套使用相同的标识符不会报错,而这是因为同一个块中没有重复声明:
var name = 'Nicholas';
console.log(name); // 'Nicholas'
if (true) {
var name = 'Matt';
console.log(name); // 'Matt'
}
let age = 30;
console.log(age); // 30
if (true) {
let age = 26;
console.log(age); // 26
}
对声明冗余报错不会因混用let
和var
而受影响。这两个关键字声明的并不是不同类型的变量,它们只是指出变量在相关作用域如何存在。
var name;
let name; // SyntaxError
let age;
var age; // SyntaxError
- 暂时性死区
let
与var
的另一个重要的区别,就是let
声明的变量不会在作用域中被提升。 ``` // name会被提升 console.log(name); // undefined var name = ‘Matt’;
// age不会被提升 console.log(age); // ReferenceError:age没有定义 let age = 26;
1. 在解析代码时,JavaScript引擎也会注意出现在块后面的`let`声明,只不过在此之前不能以任何方式来引用未声明的变量。在`let`声明之前的执行瞬间被称为“暂时性死区”(temporal dead zone),在此阶段引用任何后面才声明的变量都会抛出`ReferenceError`。
2. **全局声明**<br />与`var`关键字不同,使用`let`在全局作用域中声明的变量不会成为`window`对象的属性(`var`声明的变量则会)。
var name = ‘Matt’; console.log(window.name); // ‘Matt’
let age = 26; console.log(window.age); // undefined
2. 不过,`let`声明仍然是在全局作用域中发生的,相应变量会在页面的生命周期内存续。因此,为了避免`SyntaxError`,必须确保页面不会重复声明同一个变量。
3. **条件声明**<br />在使用`var`声明变量时,由于声明会被提升,JavaScript引擎会自动将多余的声明在作用域顶部合并为一个声明。因为`let`的作用域是块,所以不可能检查前面是否已经使用`let`声明过同名变量,同时也就不可能在没有声明的情况下声明它。
```
3. 使用`try`/`catch`语句或`typeof`操作符也不能解决,因为条件块中`let`声明的作用域仅限于该块。
```
3. 为此,对于`let`这个新的ES6声明关键字,不能依赖条件声明模式。
> **注意** 不能使用`let`进行条件式声明是件好事,因为条件声明是一种反模式,它让程序变得更难理解。如果你发现自己在使用这个模式,那一定有更好的替代方式。
4. **for循环中的let声明**<br />在`let`出现之前,`for`循环定义的迭代变量会渗透到循环体外部:
for (var i = 0; i < 5; ++i) { // 循环逻辑 } console.log(i); // 5
4. 改成使用`let`之后,这个问题就消失了,因为迭代变量的作用域仅限于`for`循环块内部:
for (let i = 0; i < 5; ++i) { // 循环逻辑 } console.log(i); // ReferenceError: i没有定义
4. 在使用`var`的时候,最常见的问题就是对迭代变量的奇特声明和修改:
for (var i = 0; i < 5; ++i) { setTimeout(() => console.log(i), 0) } // 你可能以为会输出0、1、2、3、4 // 实际上会输出5、5、5、5、5
4. 之所以会这样,是因为在退出循环时,迭代变量保存的是导致循环退出的值:5。在之后执行超时逻辑时,所有的`i`都是同一个变量,因而输出的都是同一个最终值。<br />而在使用`let`声明迭代变量时,JavaScript引擎在后台会为每个迭代循环声明一个新的迭代变量。每个`setTimeout`引用的都是不同的变量实例,所以`console.log`输出的是我们期望的值,也就是循环执行过程中每个迭代变量的值。
for (let i = 0; i < 5; ++i) { setTimeout(() => console.log(i), 0) } // 会输出0、1、2、3、4
4. 这种每次迭代声明一个独立变量实例的行为适用于所有风格的`for`循环,包括`for-in`和`for-of`循环。
<a name="6f2ed35d"></a>
### 3.3.3 `const`声明
`const`的行为与`let`基本相同,唯一一个重要的区别是用它声明变量时必须同时初始化变量,且尝试修改`const`声明的变量会导致运行时错误。
const age = 26; age = 36; // TypeError: 给常量赋值 // const也不允许重复声明 const name = ‘Matt’; const name = ‘Nicholas’; // SyntaxError
// const声明的作用域也是块 const name = ‘Matt’; if (true) { const name = ‘Nicholas’; } console.log(name); // Matt
`const`声明的限制只适用于它指向的变量的引用。换句话说,如果`const`变量引用的是一个对象,那么修改这个对象内部的属性并不违反`const`的限制。
const person = {}; person.name = ‘Matt’; // ok
即使JavaScript引擎会为`for`循环中的`let`声明分别创建独立的变量实例,而且`const`变量跟`let`变量很相似,也不能用`const`来声明迭代变量(因为迭代变量会自增):
for (const i = 0; i < 10; ++i) {} // TypeError:给常量赋值
不过,如果你只想用`const`声明一个不会被修改的`for`循环变量,那也是可以的。也就是说,每次迭代只是创建一个新变量。这对`for-of`和`for-in`循环特别有意义:
let i = 0; for (const j = 7; i < 5; ++i) { console.log(j); } // 7, 7, 7, 7, 7
for (const key in {a: 1, b: 2}) { console.log(key); } // a, b
for (const value of [1,2,3,4,5]) { console.log(value); } // 1, 2, 3, 4, 5
<a name="edda9a96"></a>
### 3.3.4 声明风格及最佳实践
ECMAScript 6增加`let`和`const`从客观上为这门语言更精确地声明作用域和语义提供了更好的支持。行为怪异的`var`所造成的各种问题,已经让JavaScript社区为之苦恼了很多年。随着这两个新关键字的出现,新的有助于提升代码质量的最佳实践也逐渐显现。
1. **不使用var**<br />有了`let`和`const`,大多数开发者会发现自己不再需要`var`了。限制自己只使用`let`和`const`有助于提升代码质量,因为变量有了明确的作用域、声明位置,以及不变的值。
2. **const优先,let次之**<br />使用`const`声明可以让浏览器运行时强制保持变量不变,也可以让静态代码分析工具提前发现不合法的赋值操作。因此,很多开发者认为应该优先使用`const`来声明变量,只在提前知道未来会有修改时,再使用`let`。这样可以让开发者更有信心地推断某些变量的值永远不会变,同时也能迅速发现因意外赋值导致的非预期行为。
<a name="213f049c"></a>
## 3.4 数据类型
ECMAScript有6种简单数据类型(也称为**原始类型**):`Undefined`、`Null`、`Boolean`、`Number`、`String`和`Symbol`。`Symbol`(符号)是ECMAScript 6新增的。还有一种复杂数据类型叫`Object`(对象)。`Object`是一种无序名值对的集合。因为在ECMAScript中不能定义自己的数据类型,所有值都可以用上述7种数据类型之一来表示。只有7种数据类型似乎不足以表示全部数据。但ECMAScript的数据类型很灵活,一种数据类型可以当作多种数据类型来使用。
<a name="8063554b"></a>
### 3.4.1 `typeof`操作符
因为ECMAScript的类型系统是松散的,所以需要一种手段来确定任意变量的数据类型。`typeof`操作符就是为此而生的。对一个值使用`typeof`操作符会返回下列字符串之一:
- `"undefined"`表示值未定义;
- `"boolean"`表示值为布尔值;
- `"string"`表示值为字符串;
- `"number"`表示值为数值;
- `"object"`表示值为对象(而不是函数)或`null`;
- `"function"`表示值为函数;
- `"symbol"`表示值为符号。
下面是使用`typeof`操作符的例子:
let message = “some string”; console.log(typeof message); // “string” console.log(typeof(message)); // “string” console.log(typeof 95); // “number”
在这个例子中,我们把一个变量(`message`)和一个数值字面量传给了`typeof`操作符。注意,因为`typeof`是一个操作符而不是函数,所以不需要参数(但可以使用参数)。
注意`typeof`在某些情况下返回的结果可能会让人费解,但技术上讲还是正确的。比如,调用`typeof null`返回的是`"object"`。这是因为特殊值`null`被认为是一个对空对象的引用。
> **注意** 严格来讲,函数在ECMAScript中被认为是对象,并不代表一种数据类型。可是,函数也有自己特殊的属性。为此,就有必要通过`typeof`操作符来区分函数和其他对象。
<a name="91c0767a"></a>
### 3.4.2 `Undefined`类型
`Undefined`类型只有一个值,就是特殊值`undefined`。当使用`var`或`let`声明了变量但没有初始化时,就相当于给变量赋予了`undefined`值:
let message; console.log(message == undefined); // true
在这个例子中,变量`message`在声明的时候并未初始化。而在比较它和`undefined`的字面值时,两者是相等的。这个例子等同于如下示例:
let message = undefined; console.log(message == undefined); // true
这里,变量`message`显式地以`undefined`来初始化。但这是不必要的,因为默认情况下,任何未经初始化的变量都会取得`undefined`值。
> **注意** 一般来说,永远不用显式地给某个变量设置`undefined`值。字面值`undefined`主要用于比较,而且在ECMA-262第3版之前是不存在的。增加这个特殊值的目的就是为了正式明确空对象指针(`null`)和未初始化变量的区别。
注意,包含`undefined`值的变量跟未定义变量是有区别的。请看下面的例子:
let message; // 这个变量被声明了,只是值为undefined
// 确保没有声明过这个变量 // let age
console.log(message); // “undefined” console.log(age); // 报错
在上面的例子中,第一个`console.log`会指出变量`message`的值,即`"undefined"`。而第二个`console.log`要输出一个未声明的变量`age`的值,因此会导致报错。对未声明的变量,只能执行一个有用的操作,就是对它调用`typeof`。(对未声明的变量调用`delete`也不会报错,但这个操作没什么用,实际上在严格模式下会抛出错误。)
在对未初始化的变量调用`typeof`时,返回的结果是`"undefined"`,但对未声明的变量调用它时,返回的结果还是`"undefined"`,这就有点让人看不懂了。比如下面的例子:
let message; // 这个变量被声明了,只是值为undefined
// make sure this variable isn’t declared // let age
console.log(typeof message); // “undefined” console.log(typeof age); // “undefined”
无论是声明还是未声明,`typeof`返回的都是字符串`"undefined"`。逻辑上讲这是对的,因为虽然严格来讲这两个变量存在根本性差异,但它对任何一个变量都不可能执行什么真正的操作。
> **注意** 即使未初始化的变量会被自动赋予`undefined`值,但我们仍然建议在声明变量的同时进行初始化。这样,当`typeof`返回`"undefined"`时,你就会知道那是因为给定的变量尚未声明,而不是声明了但未初始化。
`undefined`是一个假值。因此,如果需要,可以用更简洁的方式检测它。不过要记住,也有很多其他可能的值同样是假值。所以一定要明确自己想检测的就是`undefined`这个字面值,而不仅仅是假值。
let message; // 这个变量被声明了,只是值为undefined // age没有声明
if (message) { // 这个块不会执行 }
if (!message) { // 这个块会执行 }
if (age) { // 这里会报错 }
<a name="dcbe9f5f"></a>
### 3.4.3 `Null`类型
`Null`类型同样只有一个值,即特殊值`null`。逻辑上讲,`null`值表示一个空对象指针,这也是给`typeof`传一个`null`会返回`"object"`的原因:
let car = null; console.log(typeof car); // “object”
在定义将来要保存对象值的变量时,建议使用`null`来初始化,不要使用其他值。这样,只要检查这个变量的值是不是`null`就可以知道这个变量是否在后来被重新赋予了一个对象的引用,比如:
if (car != null) { // car是一个对象的引用 }
`undefined`值是由`null`值派生而来的,因此ECMA-262将它们定义为表面上相等,如下面的例子所示:
console.log(null == undefined); // true
用等于操作符(`==`)比较`null`和`undefined`始终返回`true`。但要注意,这个操作符会为了比较而转换它的操作数(本章后面将详细介绍)。
即使`null`和`undefined`有关系,它们的用途也是完全不一样的。如前所述,永远不必显式地将变量值设置为`undefined`。但`null`不是这样的。任何时候,只要变量要保存对象,而当时又没有那个对象可保存,就要用`null`来填充该变量。这样就可以保持`null`是空对象指针的语义,并进一步将其与`undefined`区分开来。
`null`是一个假值。因此,如果需要,可以用更简洁的方式检测它。不过要记住,也有很多其他可能的值同样是假值。所以一定要明确自己想检测的就是`null`这个字面值,而不仅仅是假值。
let message = null; let age;
if (message) { // 这个块不会执行 }
if (!message) { // 这个块会执行 }
if (age) { // 这个块不会执行 }
if (!age) { // 这个块会执行 }
<a name="6ad1bafc"></a>
### 3.4.4 `Boolean`类型
`Boolean`(布尔值)类型是ECMAScript中使用最频繁的类型之一,有两个字面值:`true`和`false`。这两个布尔值不同于数值,因此`true`不等于1,`false`不等于0。下面是给变量赋布尔值的例子:
let found = true; let lost = false;
注意,布尔值字面量`true`和`false`是区分大小写的,因此`True`和`False`(及其他大小混写形式)是有效的标识符,但不是布尔值。
虽然布尔值只有两个,但所有其他ECMAScript类型的值都有相应布尔值的等价形式。要将一个其他类型的值转换为布尔值,可以调用特定的`Boolean()`转型函数:
let message = “Hello world!”; let messageAsBoolean = Boolean(message);
在这个例子中,字符串`message`会被转换为布尔值并保存在变量`messageAsBoolean`中。`Boolean()`转型函数可以在任意类型的数据上调用,而且始终返回一个布尔值。什么值能转换为`true`或`false`的规则取决于数据类型和实际的值。下表总结了不同类型与布尔值之间的转换规则。
| 数据类型 | 转换为`true`的值 | 转换为`false`的值 |
| :--- | :--- | :--- |
| `Boolean` | `true` | `false` |
| `String` | 非空字符串 | `""`(空字符串) |
| `Number` | 非零数值(包括无穷值) | `0`、`NaN`(参见后面的相关内容) |
| `Object` | 任意对象 | `null` |
| `Undefined` | `N/A`(不存在) | `undefined` |
理解以上转换非常重要,因为像`if`等流控制语句会自动执行其他类型值到布尔值的转换,例如:
let message = “Hello world!”; if (message) { console.log(“Value is true”); }
在这个例子中,`console.log`会输出字符串`"Value is true"`,因为字符串`message`会被自动转换为等价的布尔值`true`。由于存在这种自动转换,理解流控制语句中使用的是什么变量就非常重要。错误地使用对象而不是布尔值会明显改变应用程序的执行流。
<a name="68b3dcd4"></a>
### 3.4.5 `Number`类型
ECMAScript中最有意思的数据类型或许就是`Number`了。`Number`类型使用IEEE 754格式表示整数和浮点值(在某些语言中也叫双精度值)。不同的数值类型相应地也有不同的数值字面量格式。
最基本的数值字面量格式是十进制整数,直接写出来即可:
let intNum = 55; // 整数
整数也可以用八进制(以8为基数)或十六进制(以16为基数)字面量表示。对于八进制字面量,第一个数字必须是零(0),然后是相应的八进制数字(数值0~7)。如果字面量中包含的数字超出了应有的范围,就会忽略前缀的零,后面的数字序列会被当成十进制数,如下所示:
let octalNum1 = 070; // 八进制的56 let octalNum2 = 079; // 无效的八进制值,当成79处理 let octalNum3 = 08; // 无效的八进制值,当成8处理
八进制字面量在严格模式下是无效的,会导致JavaScript引擎抛出语法错误。**1**
**1**ECMAScript 2015或ES6中的八进制值通过前缀`0o`来表示;严格模式下,前缀`0`会被视为语法错误,如果要表示八进制值,应该使用前缀`0o`。——译者注
要创建十六进制字面量,必须让真正的数值前缀`0x`(区分大小写),然后是十六进制数字(0F)。十六进制数字中的字母大小写均可。下面是几个例子:
let hexNum1 = 0xA; // 十六进制10 let hexNum2 = 0x1f; // 十六进制31
使用八进制和十六进制格式创建的数值在所有数学操作中都被视为十进制数值。
> **注意** 由于JavaScript保存数值的方式,实际中可能存在正零(+0)和负零(-0)。正零和负零在所有情况下都被认为是等同的,这里特地说明一下。
1. **浮点值**<br />要定义浮点值,数值中必须包含小数点,而且小数点后面必须至少有一个数字。虽然小数点前面不是必须有整数,但推荐加上。下面是几个例子:
let floatNum1 = 1.1; let floatNum2 = 0.1; let floatNum3 = .1; // 有效,但不推荐
1. 因为存储浮点值使用的内存空间是存储整数值的两倍,所以ECMAScript总是想方设法把值转换为整数。在小数点后面没有数字的情况下,数值就会变成整数。类似地,如果数值本身就是整数,只是小数点后面跟着0(如1.0),那它也会被转换为整数,如下例所示:
let floatNum1 = 1.; // 小数点后面没有数字,当成整数1处理 let floatNum2 = 10.0; // 小数点后面是零,当成整数10处理
1. 对于非常大或非常小的数值,浮点值可以用科学记数法来表示。科学记数法用于表示一个应该乘以10的给定次幂的数值。ECMAScript中科学记数法的格式要求是一个数值(整数或浮点数)后跟一个大写或小写的字母e,再加上一个要乘的10的多少次幂。比如:
let floatNum = 3.125e7; // 等于31250000
1. 在这个例子中,`floatNum`等于31 250 000,只不过科学记数法显得更简洁。这种表示法实际上相当于说:“以3.125作为系数,乘以10的7次幂。”<br />科学记数法也可以用于表示非常小的数值,例如0.000 000 000 000 000 03。这个数值用科学记数法可以表示为3e-17。默认情况下,ECMAScript会将小数点后至少包含6个零的浮点值转换为科学记数法(例如,0.000 000 3会被转换为3e-7)。<br />浮点值的精确度最高可达17位小数,但在算术计算中远不如整数精确。例如,0.1加0.2得到的不是0.3,而是0.300 000 000 000 000 04。由于这种微小的舍入错误,导致很难测试特定的浮点值。比如下面的例子:
if (a + b == 0.3) { // 别这么干! console.log(“You got 0.3.”); }
1. 这里检测两个数值之和是否等于0.3。如果两个数值分别是0.05和0.25,或者0.15和0.15,那没问题。但如果是0.1和0.2,如前所述,测试将失败。因此永远不要测试某个特定的浮点值。
> **注意** 之所以存在这种舍入错误,是因为使用了IEEE 754数值,这种错误并非ECMAScript所独有。其他使用相同格式的语言也有这个问题。
2. **值的范围**<br />由于内存的限制,ECMAScript并不支持表示这个世界上的所有数值。ECMAScript可以表示的最小数值保存在`Number.MIN_VALUE`中,这个值在多数浏览器中是5e-324;可以表示的最大数值保存在`Number.MAX_VALUE`中,这个值在多数浏览器中是1.797 693 134 862 315 7e+308。如果某个计算得到的数值结果超出了JavaScript可以表示的范围,那么这个数值会被自动转换为一个特殊的`Infinity`(无穷)值。任何无法表示的负数以`-Infinity`(负无穷大)表示,任何无法表示的正数以`Infinity`(正无穷大)表示。<br />如果计算返回正`Infinity`或负`Infinity`,则该值将不能再进一步用于任何计算。这是因为`Infinity`没有可用于计算的数值表示形式。要确定一个值是不是有限大(即介于JavaScript能表示的最小值和最大值之间),可以使用`isFinite()`函数,如下所示:
let result = Number.MAX_VALUE + Number.MAX_VALUE; console.log(isFinite(result)); // false
2. 虽然超出有限数值范围的计算并不多见,但总归还是有可能的。因此在计算非常大或非常小的数值时,有必要监测一下计算结果是否超出范围。
> **注意** 使用`Number.NEGATIVE_INFINITY`和`Number.POSITIVE_INFINITY`也可以获取正、负`Infinity`。没错,这两个属性包含的值分别就是`-Infinity`和`Infinity`。
3. **NaN**<br />有一个特殊的数值叫`NaN`,意思是“不是数值”(Not a Number),用于表示本来要返回数值的操作失败了(而不是抛出错误)。比如,用0除任意数值在其他语言中通常都会导致错误,从而中止代码执行。但在ECMAScript中,0、+0或-0相除会返回`NaN`:
console.log(0/0); // NaN console.log(-0/+0); // NaN
3. 如果分子是非0值,分母是有符号0或无符号0,则会返回`Infinity`或`-Infinity`:
console.log(5/0); // Infinity console.log(5/-0); // -Infinity
3. `NaN`有几个独特的属性。首先,任何涉及`NaN`的操作始终返回`NaN`(如`NaN/10`),在连续多步计算时这可能是个问题。其次,`NaN`不等于包括`NaN`在内的任何值。例如,下面的比较操作会返回`false`:
console.log(NaN == NaN); // false
3. 为此,ECMAScript提供了`isNaN()`函数。该函数接收一个参数,可以是任意数据类型,然后判断这个参数是否“不是数值”。把一个值传给`isNaN()`后,该函数会尝试把它转换为数值。某些非数值的值可以直接转换成数值,如字符串`"10"`或布尔值。任何不能转换为数值的值都会导致这个函数返回`true`。举例如下:
console.log(isNaN(NaN)); // true console.log(isNaN(10)); // false,10是数值 console.log(isNaN(“10”)); // false,可以转换为数值10 console.log(isNaN(“blue”)); // true,不可以转换为数值 console.log(isNaN(true)); // false,可以转换为数值1
3. 上述的例子测试了5个不同的值。首先测试的是`NaN`本身,显然会返回`true`。接着测试了数值10和字符串`"10"`,都返回`false`,因为它们的数值都是10。字符串`"blue"`不能转换为数值,因此函数返回`true`。布尔值`true`可以转换为数值1,因此返回`false`。
> **注意** 虽然不常见,但`isNaN()`可以用于测试对象。此时,首先会调用对象的`valueOf()`方法,然后再确定返回的值是否可以转换为数值。如果不能,再调用`toString()`方法,并测试其返回值。这通常是ECMAScript内置函数和操作符的工作方式,本章后面会讨论。
4. **数值转换**<br />有3个函数可以将非数值转换为数值:`Number()`、`parseInt()`和`parseFloat()`。`Number()`是转型函数,可用于任何数据类型。后两个函数主要用于将字符串转换为数值。对于同样的参数,这3个函数执行的操作也不同。<br />`Number()`函数基于如下规则执行转换。
- 布尔值,`true`转换为1,`false`转换为0。
- 数值,直接返回。
- `null`,返回0。
- `undefined`,返回`NaN`。
- 字符串,应用以下规则。
- 如果字符串包含数值字符,包括数值字符前面带加、减号的情况,则转换为一个十进制数值。因此,`Number("1")`返回1,`Number("123")`返回123,`Number("011")`返回11(忽略前面的零)。
- 如果字符串包含有效的浮点值格式如`"1.1"`,则会转换为相应的浮点值(同样,忽略前面的零)。
- 如果字符串包含有效的十六进制格式如`"0xf"`,则会转换为与该十六进制值对应的十进制整数值。
- 如果是空字符串(不包含字符),则返回0。
- 如果字符串包含除上述情况之外的其他字符,则返回`NaN`。
- 对象,调用`valueOf()`方法,并按照上述规则转换返回的值。如果转换结果是`NaN`,则调用`toString()`方法,再按照转换字符串的规则转换。
从不同数据类型到数值的转换有时候会比较复杂,看一看`Number()`的转换规则就知道了。下面是几个具体的例子:
let num1 = Number(“Hello world!”); // NaN let num2 = Number(“”); // 0 let num3 = Number(“000011”); // 11 let num4 = Number(true); // 1
可以看到,字符串`"Hello world"`转换之后是`NaN`,因为它找不到对应的数值。空字符串转换后是0。字符串`000011`转换后是`11`,因为前面的零被忽略了。最后,`true`转换为1。
> **注意** 本章后面会讨论到的一元加操作符与`Number()`函数遵循相同的转换规则。
考虑到用`Number()`函数转换字符串时相对复杂且有点反常规,通常在需要得到整数时可以优先使用`parseInt()`函数。`parseInt()`函数更专注于字符串是否包含数值模式。字符串最前面的空格会被忽略,从第一个非空格字符开始转换。如果第一个字符不是数值字符、加号或减号,`parseInt()`立即返回`NaN`。这意味着空字符串也会返回`NaN`(这一点跟`Number()`不一样,它返回0)。如果第一个字符是数值字符、加号或减号,则继续依次检测每个字符,直到字符串末尾,或碰到非数值字符。比如,`"1234blue"`会被转换为1234,因为`"blue"`会被完全忽略。类似地,`"22.5"`会被转换为22,因为小数点不是有效的整数字符。<br />假设字符串中的第一个字符是数值字符,`parseInt()`函数也能识别不同的整数格式(十进制、八进制、十六进制)。换句话说,如果字符串以`"0x"`开头,就会被解释为十六进制整数。如果字符串以`"0"`开头,且紧跟着数值字符,就会被解释为八进制整数。<br />下面几个转换示例有助于理解上述规则:
let num1 = parseInt(“1234blue”); // 1234 let num2 = parseInt(“”); // NaN let num3 = parseInt(“0xA”); // 10,解释为十六进制整数 let num4 = parseInt(22.5); // 22 let num5 = parseInt(“70”); // 70,解释为十进制值 let num6 = parseInt(“0xf”); // 15,解释为十六进制整数
不同的数值格式很容易混淆,因此`parseInt()`也接收第二个参数,用于指定底数(进制数)。如果知道要解析的值是十六进制,那么可以传入16作为第二个参数,以便正确解析:
let num = parseInt(“0xAF”, 16); // 175
事实上,如果提供了十六进制参数,那么字符串前面的`"0x"`可以省掉:
let num1 = parseInt(“AF”, 16); // 175 let num2 = parseInt(“AF”); // NaN
在这个例子中,第一个转换是正确的,而第二个转换失败了。区别在于第一次传入了进制数作为参数,告诉`parseInt()`要解析的是一个十六进制字符串。而第二个转换检测到第一个字符就是非数值字符,随即自动停止并返回`NaN`。<br />通过第二个参数,可以极大扩展转换后获得的结果类型。比如:
let num1 = parseInt(“10”, 2); // 2,按二进制解析 let num2 = parseInt(“10”, 8); // 8,按八进制解析 let num3 = parseInt(“10”, 10); // 10,按十进制解析 let num4 = parseInt(“10”, 16); // 16,按十六进制解析
因为不传底数参数相当于让`parseInt()`自己决定如何解析,所以为避免解析出错,建议始终传给它第二个参数。
> **注意** 多数情况下解析的应该都是十进制数,此时第二个参数就要传入10。
`parseFloat()`函数的工作方式跟`parseInt()`函数类似,都是从位置0开始检测每个字符。同样,它也是解析到字符串末尾或者解析到一个无效的浮点数值字符为止。这意味着第一次出现的小数点是有效的,但第二次出现的小数点就无效了,此时字符串的剩余字符都会被忽略。因此,`"22.34.5"`将转换成22.34。<br />`parseFloat()`函数的另一个不同之处在于,它始终忽略字符串开头的零。这个函数能识别前面讨论的所有浮点格式,以及十进制格式(开头的零始终被忽略)。十六进制数值始终会返回0。因为`parseFloat()`只解析十进制值,因此不能指定底数。最后,如果字符串表示整数(没有小数点或者小数点后面只有一个零),则`parseFloat()`返回整数。下面是几个示例:
let num1 = parseFloat(“1234blue”); // 1234,按整数解析 let num2 = parseFloat(“0xA”); // 0 let num3 = parseFloat(“22.5”); // 22.5 let num4 = parseFloat(“22.34.5”); // 22.34 let num5 = parseFloat(“0908.5”); // 908.5 let num6 = parseFloat(“3.125e7”); // 31250000
<a name="ee5e2cb0"></a>
### 3.4.6 `String`类型
`String`(字符串)数据类型表示零或多个16位Unicode字符序列。字符串可以使用双引号(")、单引号(')或反引号(`)标示,因此下面的代码都是合法的:
let firstName = “John”;
let lastName = ‘Jacob’;
let lastName = Jingleheimerschmidt
跟某些语言中使用不同的引号会改变对字符串的解释方式不同,ECMAScript语法中表示字符串的引号没有区别。不过要注意的是,以某种引号作为字符串开头,必须仍然以该种引号作为字符串结尾。比如,下面的写法会导致语法错误:
let firstName = ‘Nicholas”; // 语法错误:开头和结尾的引号必须是同一种
1. **字符字面量**<br />字符串数据类型包含一些字符字面量,用于表示非打印字符或有其他用途的字符,如下表所示:
| 字面量 | 含义 |
| :--- | :--- |
| `\n` | 换行 |
| `\t` | 制表 |
| `\b` | 退格 |
| `\r` | 回车 |
| `\f` | 换页 |
| `\\\\` | 反斜杠(`\`) |
| `\'` | 单引号(`'`),在字符串以单引号标示时使用,例如`'He said, \'hey.\''` |
| `\"` | 双引号(`"`),在字符串以双引号标示时使用,例如`"He said, \"hey.\""` |
| `\\\`` | 反引号(```),在字符串以反引号标示时使用,例如``He said, \\`hey.\\``` | |
| `\x*nn*` | 以十六进制编码`*nn*`表示的字符(其中`*n*`是十六进制数字0~F),例如`\x41`等于`"A"` |
| `\u*nnnn*` | 以十六进制编码`*nnnn*`表示的Unicode字符(其中`*n*`是十六进制数字0~F),例如`\u03a3`等于希腊字符`"Σ"` |
1. 这些字符字面量可以出现在字符串中的任意位置,且可以作为单个字符被解释:
let text = “This is the letter sigma: \u03a3.”;
1. 在这个例子中,即使包含6个字符长的转义序列,变量`text`仍然是28个字符长。因为转义序列表示一个字符,所以只算一个字符。<br />字符串的长度可以通过其`length`属性获取:
console.log(text.length); // 28
1. 这个属性返回字符串中16位字符的个数。
> **注意** 如果字符串中包含双字节字符,那么`length`属性返回的值可能不是准确的字符数。第5章将具体讨论如何解决这个问题。
2. **字符串的特点**<br />ECMAScript中的字符串是不可变的(immutable),意思是一旦创建,它们的值就不能变了。要修改某个变量中的字符串值,必须先销毁原始的字符串,然后将包含新值的另一个字符串保存到该变量,如下所示:
let lang = “Java”; lang = lang + “Script”;
2. 这里,变量`lang`一开始包含字符串`"Java"`。紧接着,`lang`被重新定义为包含`"Java"`和`"Script"`的组合,也就是`"JavaScript"`。整个过程首先会分配一个足够容纳10个字符的空间,然后填充上`"Java"`和`"Script"`。最后销毁原始的字符串`"Java"`和字符串`"Script"`,因为这两个字符串都没有用了。所有处理都是在后台发生的,而这也是一些早期的浏览器(如Firefox 1.0之前的版本和IE6.0)在拼接字符串时非常慢的原因。这些浏览器在后来的版本中都有针对性地解决了这个问题。
3. **转换为字符串**<br />有两种方式把一个值转换为字符串。首先是使用几乎所有值都有的`toString()`方法。这个方法唯一的用途就是返回当前值的字符串等价物。比如:
let age = 11; let ageAsString = age.toString(); // 字符串”11” let found = true; let foundAsString = found.toString(); // 字符串”true”
3. `toString()`方法可见于数值、布尔值、对象和字符串值。(没错,字符串值也有`toString()`方法,该方法只是简单地返回自身的一个副本。)`null`和`undefined`值没有`toString()`方法。<br />多数情况下,`toString()`不接收任何参数。不过,在对数值调用这个方法时,`toString()`可以接收一个底数参数,即以什么底数来输出数值的字符串表示。默认情况下,`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”
3. 这个例子展示了传入底数参数时,`toString()`输出的字符串值也会随之改变。数值10可以输出为任意数值格式。注意,默认情况下(不传参数)的输出与传入参数10得到的结果相同。<br />如果你不确定一个值是不是`null`或`undefined`,可以使用`String()`转型函数,它始终会返回表示相应类型值的字符串。`String()`函数遵循如下规则。
- 如果值有`toString()`方法,则调用该方法(不传参数)并返回结果。
- 如果值是`null`,返回`"null"`。
- 如果值是`undefined`,返回`"undefined"`。
下面看几个例子:
let value1 = 10; let value2 = true; let value3 = null; let value4;
console.log(String(value1)); // “10” console.log(String(value2)); // “true” console.log(String(value3)); // “null” console.log(String(value4)); // “undefined”
这里展示了将4个值转换为字符串的情况:一个数值、一个布尔值、一个`null`和一个`undefined`。数值和布尔值的转换结果与调用`toString()`相同。因为`null`和`undefined`没有`toString()`方法,所以`String()`方法就直接返回了这两个值的字面量文本。
> **注意** 用加号操作符给一个值加上一个空字符串`""`也可以将其转换为字符串(加号操作符本章后面会介绍)。
4. **模板字面量**<br />ECMAScript 6新增了使用模板字面量定义字符串的能力。与使用单引号或双引号不同,模板字面量保留换行字符,可以跨行定义字符串:
let myMultiLineString = ‘first line\nsecond line’;
let myMultiLineTemplateLiteral = first line
second line
;
console.log(myMultiLineString); // first line // second line”
console.log(myMultiLineTemplateLiteral); // first line // second line
console.log(myMultiLineString === myMultiLinetemplateLiteral); // true
4. 顾名思义,模板字面量在定义模板时特别有用,比如下面这个HTML模板:
let pageHTML = `
`;
4. 由于模板字面量会保持反引号内部的空格,因此在使用时要格外注意。格式正确的模板字符串可能会看起来缩进不当:
// 这个模板字面量在换行符之后有25个空格符
let myTemplateLiteral = first line
second line
;
console.log(myTemplateLiteral.length); // 47
// 这个模板字面量以一个换行符开头
let secondTemplateLiteral = first line
second line
;
console.log(secondTemplateLiteral[0] === ‘\n’); // true
// 这个模板字面量没有意料之外的字符
let thirdTemplateLiteral = first line
second line
;
console.log(thirdTemplateLiteral[0]);
// first line
// second line
5. **字符串插值**<br />模板字面量最常用的一个特性是支持字符串插值,也就是可以在一个连续定义中插入一个或多个值。技术上讲,模板字面量不是字符串,而是一种特殊的JavaScript句法表达式,只不过求值后得到的是字符串。模板字面量在定义时立即求值并转换为字符串实例,任何插入的变量也会从它们最接近的作用域中取值。<br />字符串插值通过在`${}`中使用一个JavaScript表达式实现:
let value = 5; let exponent = ‘second’; // 以前,字符串插值是这样实现的: let interpolatedString = value + ‘ to the ‘ + exponent + ‘ power is ‘ + (value * value);
// 现在,可以用模板字面量这样实现:
let interpolatedTemplateLiteral =
${ value } to the ${ exponent } power is ${ value * value }
;
console.log(interpolatedString); // 5 to the second power is 25 console.log(interpolatedTemplateLiteral); // 5 to the second power is 25
5. 所有插入的值都会使用`toString()`强制转型为字符串,而且任何JavaScript表达式都可以用于插值。嵌套的模板字符串无须转义:
console.log(Hello, ${
World}!
); // Hello, World!
5. 将表达式转换为字符串时会调用`toString()`:
let foo = { toString: () => ‘World’ };
console.log(Hello, ${ foo }!
); // Hello, World!
5. 在插值表达式中可以调用函数和方法:
function capitalize(word) {
return ${ word[0].toUpperCase() }${ word.slice(1) }
;
}
console.log(${ capitalize('hello') }, ${ capitalize('world') }!
); // Hello, World!
5. 此外,模板也可以插入自己之前的值:
let value = ‘’;
function append() {
value = ${value}abc
console.log(value);
}
append(); // abc
append(); // abcabc
append(); // abcabcabc
6. **模板字面量标签函数**<br />模板字面量也支持定义**标签函数**(tag function),而通过标签函数可以自定义插值行为。标签函数会接收被插值记号分隔后的模板和对每个表达式求值的结果。<br />标签函数本身是一个常规函数,通过前缀到模板字面量来应用自定义行为,如下例所示。标签函数接收到的参数依次是原始字符串数组和对每个表达式求值的结果。这个函数的返回值是对模板字面量求值得到的字符串。<br />最好通过一个例子来理解:
let a = 6; let b = 9;
function simpleTag(strings, aValExpression, bValExpression, sumExpression) { console.log(strings); console.log(aValExpression); console.log(bValExpression); console.log(sumExpression);
return ‘foobar’; }
let untaggedResult = ${ a } + ${ b } = ${ a + b }
;
let taggedResult = simpleTag${ a } + ${ b } = ${ a + b }
;
// [“”, “ + “, “ = “, “”]
// 6
// 9
// 15
console.log(untaggedResult); // “6 + 9 = 15” console.log(taggedResult); // “foobar”
6. 因为表达式参数的数量是可变的,所以通常应该使用剩余操作符(rest operator)将它们收集到一个数组中:
let a = 6; let b = 9;
function simpleTag(strings, …expressions) { console.log(strings); for(const expression of expressions) { console.log(expression); }
return ‘foobar’;
}
let taggedResult = simpleTag${ a } + ${ b } = ${ a + b }
;
// [“”, “ + “, “ = “, “”]
// 6
// 9
// 15
console.log(taggedResult); // “foobar”
6. 对于有![](https://private.codecogs.com/gif.latex?n#align=left&alt=n&display=inline&message=%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87%E5%A4%B1%E8%B4%A5%EF%BC%8C%E8%AF%B7%E9%87%8D%E8%AF%95&percent=0&status=uploading)个插值的模板字面量,传给标签函数的表达式参数的个数始终是![](https://private.codecogs.com/gif.latex?n#align=left&alt=n&display=inline&message=%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87%E5%A4%B1%E8%B4%A5%EF%BC%8C%E8%AF%B7%E9%87%8D%E8%AF%95&percent=0&status=uploading),而传给标签函数的第一个参数所包含的字符串个数则始终是![](https://private.codecogs.com/gif.latex?n+1#align=left&alt=n%2B1&display=inline&message=%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87%E5%A4%B1%E8%B4%A5%EF%BC%8C%E8%AF%B7%E9%87%8D%E8%AF%95&percent=0&status=uploading)。因此,如果你想把这些字符串和对表达式求值的结果拼接起来作为默认返回的字符串,可以这样做:
let a = 6; let b = 9;
function zipTag(strings, …expressions) {
return strings[0] +
expressions.map((e, i) => ${e}${strings[i + 1]}
)
.join(‘’);
}
let untaggedResult = ${ a } + ${ b } = ${ a + b }
;
let taggedResult = zipTag${ a } + ${ b } = ${ a + b }
;
console.log(untaggedResult); // “6 + 9 = 15” console.log(taggedResult); // “6 + 9 = 15”
7. **原始字符串**<br />使用模板字面量也可以直接获取原始的模板字面量内容(如换行符或Unicode字符),而不是被转换后的字符表示。为此,可以使用默认的`String.raw`标签函数:
// Unicode示例
// \u00A9是版权符号
console.log(\u00A9
); // ©
console.log(String.raw\u00A9
); // \u00A9
// 换行符示例
console.log(first line\nsecond line
);
// first line
// second line
console.log(String.rawfirst line\nsecond line
); // “first line\nsecond line”
// 对实际的换行符来说是不行的
// 它们不会被转换成转义序列的形式
console.log(first line
second line
);
// first line
// second line
console.log(String.rawfirst line
second line
);
// first line
// second line
7. 另外,也可以通过标签函数的第一个参数,即字符串数组的`.raw`属性取得每个字符串的原始内容:
function printRaw(strings) { console.log(‘Actual characters:’); for (const string of strings) { console.log(string); }
console.log(‘Escaped characters;’); for (const rawString of strings.raw) { console.log(rawString); } }
printRaw\u00A9${ 'and' }\n
;
// Actual characters:
// ©
//(换行符)
// Escaped characters:
// \u00A9
// \n
<a name="5c6c84d6"></a>
### 3.4.7 `Symbol`类型
`Symbol`(符号)是ECMAScript 6新增的数据类型。符号是原始值,且符号实例是唯一、不可变的。符号的用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险。
尽管听起来跟私有属性有点类似,但符号并不是为了提供私有属性的行为才增加的(尤其是因为Object API提供了方法,可以更方便地发现符号属性)。相反,符号就是用来创建唯一记号,进而用作非字符串形式的对象属性。
1. **符号的基本用法**<br />符号需要使用`Symbol()`函数初始化。因为符号本身是原始类型,所以`typeof`操作符对符号返回`symbol`。
let sym = Symbol(); console.log(typeof sym); // symbol
1. 调用`Symbol()`函数时,也可以传入一个字符串参数作为对符号的描述(description),将来可以通过这个字符串来调试代码。但是,这个字符串参数与符号定义或标识完全无关:
let genericSymbol = Symbol(); let otherGenericSymbol = Symbol();
let fooSymbol = Symbol(‘foo’); let otherFooSymbol = Symbol(‘foo’);
console.log(genericSymbol == otherGenericSymbol); // false console.log(fooSymbol == otherFooSymbol); // false
1. 符号没有字面量语法,这也是它们发挥作用的关键。按照规范,你只要创建`Symbol()`实例并将其用作对象的新属性,就可以保证它不会覆盖已有的对象属性,无论是符号属性还是字符串属性。
let genericSymbol = Symbol(); console.log(genericSymbol); // Symbol()
let fooSymbol = Symbol(‘foo’); console.log(fooSymbol); // Symbol(foo);
1. 最重要的是,`Symbol()`函数不能用作构造函数,与`new`关键字一起使用。这样做是为了避免创建符号包装对象,像使用`Boolean`、`String`或`Number`那样,它们都支持构造函数且可用于初始化包含原始值的包装对象:
let myBoolean = new Boolean(); console.log(typeof myBoolean); // “object”
let myString = new String(); console.log(typeof myString); // “object”
let myNumber = new Number(); console.log(typeof myNumber); // “object”
let mySymbol = new Symbol(); // TypeError: Symbol is not a constructor
1. 如果你确实想使用符号包装对象,可以借用`Object()`函数:
let mySymbol = Symbol(); let myWrappedSymbol = Object(mySymbol); console.log(typeof myWrappedSymbol); // “object”
2. **使用全局符号注册表**<br />如果运行时的不同部分需要共享和重用符号实例,那么可以用一个字符串作为键,在全局符号注册表中创建并重用符号。<br />为此,需要使用`Symbol.for()`方法:
let fooGlobalSymbol = Symbol.for(‘foo’); console.log(typeof fooGlobalSymbol); // symbol
2. `Symbol.for()`对每个字符串键都执行幂等操作。第一次使用某个字符串调用时,它会检查全局运行时注册表,发现不存在对应的符号,于是就会生成一个新符号实例并添加到注册表中。后续使用相同字符串的调用同样会检查注册表,发现存在与该字符串对应的符号,然后就会返回该符号实例。
let fooGlobalSymbol = Symbol.for(‘foo’); // 创建新符号 let otherFooGlobalSymbol = Symbol.for(‘foo’); // 重用已有符号
console.log(fooGlobalSymbol === otherFooGlobalSymbol); // true
2. 即使采用相同的符号描述,在全局注册表中定义的符号跟使用`Symbol()`定义的符号也并不等同:
let localSymbol = Symbol(‘foo’); let globalSymbol = Symbol.for(‘foo’);
console.log(localSymbol === globalSymbol); // false
2. 全局注册表中的符号必须使用字符串键来创建,因此作为参数传给`Symbol.for()`的任何值都会被转换为字符串。此外,注册表中使用的键同时也会被用作符号描述。
let emptyGlobalSymbol = Symbol.for(); console.log(emptyGlobalSymbol); // Symbol(undefined)
2. 还可以使用`Symbol.keyFor()`来查询全局注册表,这个方法接收符号,返回该全局符号对应的字符串键。如果查询的不是全局符号,则返回`undefined`。
// 创建全局符号 let s = Symbol.for(‘foo’); console.log(Symbol.keyFor(s)); // foo
// 创建普通符号 let s2 = Symbol(‘bar’); console.log(Symbol.keyFor(s2)); // undefined
2. 如果传给`Symbol.keyFor()`的不是符号,则该方法抛出`TypeError`:
Symbol.keyFor(123); // TypeError: 123 is not a symbol
3. **使用符号作为属性**<br />凡是可以使用字符串或数值作为属性的地方,都可以使用符号。这就包括了对象字面量属性和`Object.defineProperty()`/`Object.definedProperties()`定义的属性。对象字面量只能在计算属性语法中使用符号作为属性。
let s1 = Symbol(‘foo’), s2 = Symbol(‘bar’), s3 = Symbol(‘baz’), s4 = Symbol(‘qux’);
let o = { [s1]: ‘foo val’ }; // 这样也可以:o[s1] = ‘foo val’;
console.log(o); // {Symbol(foo): foo val}
Object.defineProperty(o, s2, {value: ‘bar val’});
console.log(o); // {Symbol(foo): foo val, Symbol(bar): bar val}
Object.defineProperties(o, { [s3]: {value: ‘baz val’}, [s4]: {value: ‘qux val’} });
console.log(o); // {Symbol(foo): foo val, Symbol(bar): bar val, // Symbol(baz): baz val, Symbol(qux): qux val}
3. 类似于`Object.getOwnPropertyNames()`返回对象实例的常规属性数组,`Object.getOwnPropertySymbols()`返回对象实例的符号属性数组。这两个方法的返回值彼此互斥。`Object.getOwnPropertyDescriptors()`会返回同时包含常规和符号属性描述符的对象。`Reflect.ownKeys()`会返回两种类型的键:
let s1 = Symbol(‘foo’), s2 = Symbol(‘bar’);
let o = { [s1]: ‘foo val’, [s2]: ‘bar val’, baz: ‘baz val’, qux: ‘qux val’ };
console.log(Object.getOwnPropertySymbols(o)); // [Symbol(foo), Symbol(bar)]
console.log(Object.getOwnPropertyNames(o)); // [“baz”, “qux”]
console.log(Object.getOwnPropertyDescriptors(o)); // {baz: {…}, qux: {…}, Symbol(foo): {…}, Symbol(bar): {…}}
console.log(Reflect.ownKeys(o)); // [“baz”, “qux”, Symbol(foo), Symbol(bar)]
3. 因为符号属性是对内存中符号的一个引用,所以直接创建并用作属性的符号不会丢失。但是,如果没有显式地保存对这些属性的引用,那么必须遍历对象的所有符号属性才能找到相应的属性键:
let o = { [Symbol(‘foo’)]: ‘foo val’, [Symbol(‘bar’)]: ‘bar val’ };
console.log(o); // {Symbol(foo): “foo val”, Symbol(bar): “bar val”}
let barSymbol = Object.getOwnPropertySymbols(o) .find((symbol) => symbol.toString().match(/bar/));
console.log(barSymbol); // Symbol(bar)
4. **常用内置符号**<br />ECMAScript 6也引入了一批**常用内置符号**(well-known symbol),用于暴露语言内部行为,开发者可以直接访问、重写或模拟这些行为。这些内置符号都以`Symbol`工厂函数字符串属性的形式存在。<br />这些内置符号最重要的用途之一是重新定义它们,从而改变原生结构的行为。比如,我们知道`for-of`循环会在相关对象上使用`Symbol.iterator`属性,那么就可以通过在自定义对象上重新定义`Symbol.iterator`的值,来改变`for-of`在迭代该对象时的行为。<br />这些内置符号也没有什么特别之处,它们就是全局函数`Symbol`的普通字符串属性,指向一个符号的实例。所有内置符号属性都是不可写、不可枚举、不可配置的。
> **注意** 在提到ECMAScript规范时,经常会引用符号在规范中的名称,前缀为`@@`。比如,`@@iterator`指的就是`Symbol.iterator`。
5. **Symbol.asyncIterator**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法返回对象默认的`AsyncIterator`。由`for-await-of`语句使用”。换句话说,这个符号表示实现异步迭代器API的函数。<br />`for-await-of`循环会利用这个函数执行异步迭代操作。循环时,它们会调用以`Symbol.asyncIterator`为键的函数,并期望这个函数会返回一个实现迭代器API的对象。很多时候,返回的对象是实现该API的`AsyncGenerator`:
class Foo { async *Symbol.asyncIterator {} }
let f = new Foo();
console.log(fSymbol.asyncIterator);
// AsyncGenerator {
5. 技术上,这个由`Symbol.asyncIterator`函数生成的对象应该通过其`next()`方法陆续返回`Promise`实例。可以通过显式地调用`next()`方法返回,也可以隐式地通过异步生成器函数返回:
class Emitter { constructor(max) { this.max = max; this.asyncIdx = 0; }
async *Symbol.asyncIterator { while(this.asyncIdx < this.max) { yield new Promise((resolve) => resolve(this.asyncIdx++)); } } }
async function asyncCount() { let emitter = new Emitter(5);
for await(const x of emitter) { console.log(x); } }
asyncCount(); // 0 // 1 // 2 // 3 // 4
> **注意** `Symbol.asyncIterator`是ES2018规范定义的,因此只有版本非常新的浏览器支持它。关于异步迭代和`for-await-of`循环的细节,参见附录A。
6. **Symbol.hasInstance**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法决定一个构造器对象是否认可一个对象是它的实例。由`instanceof`操作符使用”。`instanceof`操作符可以用来确定一个对象实例的原型链上是否有原型。`instanceof`的典型使用场景如下:
function Foo() {} let f = new Foo(); console.log(f instanceof Foo); // true
class Bar {} let b = new Bar(); console.log(b instanceof Bar); // true
6. 在ES6中,`instanceof`操作符会使用`Symbol.hasInstance`函数来确定关系。以`Symbol.hasInstance`为键的函数会执行同样的操作,只是操作数对调了一下:
function Foo() {} let f = new Foo(); console.log(FooSymbol.hasInstance); // true
class Bar {} let b = new Bar(); console.log(BarSymbol.hasInstance); // true
6. 这个属性定义在`Function`的原型上,因此默认在所有函数和类上都可以调用。由于`instanceof`操作符会在原型链上寻找这个属性定义,就跟在原型链上寻找其他属性一样,因此可以在继承的类上通过静态方法重新定义这个函数:
class Bar {} class Baz extends Bar { static Symbol.hasInstance { return false; } }
let b = new Baz(); console.log(BarSymbol.hasInstance); // true console.log(b instanceof Bar); // true console.log(BazSymbol.hasInstance); // false console.log(b instanceof Baz); // false
7. **Symbol.isConcatSpreadable**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个布尔值,如果是`true`,则意味着对象应该用`Array.prototype.concat()`打平其数组元素”。ES6中的`Array.prototype.concat()`方法会根据接收到的对象类型选择如何将一个类数组对象拼接成数组实例。覆盖`Symbol.isConcatSpreadable`的值可以修改这个行为。<br />数组对象默认情况下会被打平到已有的数组,`false`或假值会导致整个对象被追加到数组末尾。类数组对象默认情况下会被追加到数组末尾,`true`或真值会导致这个类数组对象被打平到数组实例。其他不是类数组对象的对象在`Symbol.isConcatSpreadable`被设置为`true`的情况下将被忽略。
let initial = [‘foo’];
let array = [‘bar’]; console.log(array[Symbol.isConcatSpreadable]); // undefined console.log(initial.concat(array)); // [‘foo’, ‘bar’] array[Symbol.isConcatSpreadable] = false; console.log(initial.concat(array)); // [‘foo’, Array(1)]
let arrayLikeObject = { length: 1, 0: ‘baz’ }; console.log(arrayLikeObject[Symbol.isConcatSpreadable]); // undefined console.log(initial.concat(arrayLikeObject)); // [‘foo’, {…}] arrayLikeObject[Symbol.isConcatSpreadable] = true; console.log(initial.concat(arrayLikeObject)); // [‘foo’, ‘baz’]
let otherObject = new Set().add(‘qux’); console.log(otherObject[Symbol.isConcatSpreadable]); // undefined console.log(initial.concat(otherObject)); // [‘foo’, Set(1)] otherObject[Symbol.isConcatSpreadable] = true; console.log(initial.concat(otherObject)); // [‘foo’]
8. **Symbol.iterator**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法返回对象默认的迭代器。由`for-of`语句使用”。换句话说,这个符号表示实现迭代器API的函数。<br />`for-of`循环这样的语言结构会利用这个函数执行迭代操作。循环时,它们会调用以`Symbol.iterator`为键的函数,并默认这个函数会返回一个实现迭代器API的对象。很多时候,返回的对象是实现该API的`Generator`:
class Foo { *Symbol.iterator {} }
let f = new Foo();
console.log(fSymbol.iterator);
// Generator {
8. 技术上,这个由`Symbol.iterator`函数生成的对象应该通过其`next()`方法陆续返回值。可以通过显式地调用`next()`方法返回,也可以隐式地通过生成器函数返回:
class Emitter { constructor(max) { this.max = max; this.idx = 0; }
*Symbol.iterator { while(this.idx < this.max) { yield this.idx++; } } }
function count() { let emitter = new Emitter(5);
for (const x of emitter) { console.log(x); } }
count(); // 0 // 1 // 2 // 3 // 4
> **注意** 迭代器的相关内容将在第7章详细介绍。
9. **Symbol.match**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法用正则表达式去匹配字符串。由`String.prototype.match()`方法使用”。`String.prototype.match()`方法会使用以`Symbol.match`为键的函数来对正则表达式求值。正则表达式的原型上默认有这个函数的定义,因此所有正则表达式实例默认是这个`String`方法的有效参数:
console.log(RegExp.prototype[Symbol.match]); // f Symbol.match { [native code] }
console.log(‘foobar’.match(/bar/)); // [“bar”, index: 3, input: “foobar”, groups: undefined]
9. 给这个方法传入非正则表达式值会导致该值被转换为`RegExp`对象。如果想改变这种行为,让方法直接使用参数,则可以重新定义`Symbol.match`函数以取代默认对正则表达式求值的行为,从而让`match()`方法使用非正则表达式实例。`Symbol.match`函数接收一个参数,就是调用`match()`方法的字符串实例。返回的值没有限制:
class FooMatcher { static Symbol.match { return target.includes(‘foo’); } }
console.log(‘foobar’.match(FooMatcher)); // true console.log(‘barbaz’.match(FooMatcher)); // false
class StringMatcher { constructor(str) { this.str = str; }
Symbol.match { return target.includes(this.str); } }
console.log(‘foobar’.match(new StringMatcher(‘foo’))); // true console.log(‘barbaz’.match(new StringMatcher(‘qux’))); // false
10. **Symbol.replace**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法替换一个字符串中匹配的子串。由`String.prototype.replace()`方法使用”。`String.prototype.replace()`方法会使用以`Symbol.replace`为键的函数来对正则表达式求值。正则表达式的原型上默认有这个函数的定义,因此所有正则表达式实例默认是这个`String`方法的有效参数:
console.log(RegExp.prototype[Symbol.replace]); // f Symbol.replace { [native code] }
console.log(‘foobarbaz’.replace(/bar/, ‘qux’)); // ‘fooquxbaz’
10. 给这个方法传入非正则表达式值会导致该值被转换为`RegExp`对象。如果想改变这种行为,让方法直接使用参数,可以重新定义`Symbol.replace`函数以取代默认对正则表达式求值的行为,从而让`replace()`方法使用非正则表达式实例。`Symbol.replace`函数接收两个参数,即调用`replace()`方法的字符串实例和替换字符串。返回的值没有限制:
class FooReplacer { static Symbol.replace { return target.split(‘foo’).join(replacement); } }
console.log(‘barfoobaz’.replace(FooReplacer, ‘qux’)); // “barquxbaz”
class StringReplacer { constructor(str) { this.str = str; }
Symbol.replace { return target.split(this.str).join(replacement); } }
console.log(‘barfoobaz’.replace(new StringReplacer(‘foo’), ‘qux’)); // “barquxbaz”
11. **Symbol.search**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法返回字符串中匹配正则表达式的索引。由`String.prototype.search()`方法使用”。`String.prototype.search()`方法会使用以`Symbol.search`为键的函数来对正则表达式求值。正则表达式的原型上默认有这个函数的定义,因此所有正则表达式实例默认是这个`String`方法的有效参数:
console.log(RegExp.prototype[Symbol.search]); // f Symbol.search { [native code] }
console.log(‘foobar’.search(/bar/)); // 3
11. 给这个方法传入非正则表达式值会导致该值被转换为`RegExp`对象。如果想改变这种行为,让方法直接使用参数,可以重新定义`Symbol.search`函数以取代默认对正则表达式求值的行为,从而让`search()`方法使用非正则表达式实例。`Symbol.search`函数接收一个参数,就是调用`match()`方法的字符串实例。返回的值没有限制:
class FooSearcher { static Symbol.search { return target.indexOf(‘foo’); } }
console.log(‘foobar’.search(FooSearcher)); // 0 console.log(‘barfoo’.search(FooSearcher)); // 3 console.log(‘barbaz’.search(FooSearcher)); // -1
class StringSearcher { constructor(str) { this.str = str; }
Symbol.search { return target.indexOf(this.str); } }
console.log(‘foobar’.search(new StringSearcher(‘foo’))); // 0 console.log(‘barfoo’.search(new StringSearcher(‘foo’))); // 3 console.log(‘barbaz’.search(new StringSearcher(‘qux’))); // -1
12. **Symbol.species**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个函数值,该函数作为创建派生对象的构造函数”。这个属性在内置类型中最常用,用于对内置类型实例方法的返回值暴露实例化派生对象的方法。用`Symbol.species`定义静态的获取器(getter)方法,可以覆盖新创建实例的原型定义:
class Bar extends Array {} class Baz extends Array { static get Symbol.species { return Array; } }
let bar = new Bar(); console.log(bar instanceof Array); // true console.log(bar instanceof Bar); // true bar = bar.concat(‘bar’); console.log(bar instanceof Array); // true console.log(bar instanceof Bar); // true
let baz = new Baz(); console.log(baz instanceof Array); // true console.log(baz instanceof Baz); // true baz = baz.concat(‘baz’); console.log(baz instanceof Array); // true console.log(baz instanceof Baz); // false
13. **Symbol.split**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法在匹配正则表达式的索引位置拆分字符串。由`String.prototype.split()`方法使用”。`String.prototype.split()`方法会使用以`Symbol.split`为键的函数来对正则表达式求值。正则表达式的原型上默认有这个函数的定义,因此所有正则表达式实例默认是这个`String`方法的有效参数:
console.log(RegExp.prototype[Symbol.split]); // f Symbol.split { [native code] }
console.log(‘foobarbaz’.split(/bar/)); // [‘foo’, ‘baz’]
13. 给这个方法传入非正则表达式值会导致该值被转换为`RegExp`对象。如果想改变这种行为,让方法直接使用参数,可以重新定义`Symbol.split`函数以取代默认对正则表达式求值的行为,从而让`split()`方法使用非正则表达式实例。`Symbol.split`函数接收一个参数,就是调用`match()`方法的字符串实例。返回的值没有限制:
class FooSplitter { static Symbol.split { return target.split(‘foo’); } }
console.log(‘barfoobaz’.split(FooSplitter)); // [“bar”, “baz”]
class StringSplitter { constructor(str) { this.str = str; }
Symbol.split { return target.split(this.str); } }
console.log(‘barfoobaz’.split(new StringSplitter(‘foo’))); // [“bar”, “baz”]
14. **Symbol.toPrimitive**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法将对象转换为相应的原始值。由`ToPrimitive`抽象操作使用”。很多内置操作都会尝试强制将对象转换为原始值,包括字符串、数值和未指定的原始类型。对于一个自定义对象实例,通过在这个实例的`Symbol.toPrimitive`属性上定义一个函数可以改变默认行为。<br />根据提供给这个函数的参数(`string`、`number`或`default`),可以控制返回的原始值:
class Foo {} let foo = new Foo();
console.log(3 + foo); // “3[object Object]” console.log(3 - foo); // NaN console.log(String(foo)); // “[object Object]”
class Bar { constructor() { this[Symbol.toPrimitive] = function(hint) { switch (hint) { case ‘number’: return 3; case ‘string’: return ‘string bar’; case ‘default’: default: return ‘default bar’; } } } } let bar = new Bar();
console.log(3 + bar); // “3default bar” console.log(3 - bar); // 0 console.log(String(bar)); // “string bar”
15. **Symbol.toStringTag**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个字符串,该字符串用于创建对象的默认字符串描述。由内置方法`Object.prototype.toString()`使用”。<br />通过`toString()`方法获取对象标识时,会检索由`Symbol.toStringTag`指定的实例标识符,默认为`"Object"`。内置类型已经指定了这个值,但自定义类实例还需要明确定义:
let s = new Set();
console.log(s); // Set(0) {} console.log(s.toString()); // [object Set] console.log(s[Symbol.toStringTag]); // Set
class Foo {} let foo = new Foo();
console.log(foo); // Foo {} console.log(foo.toString()); // [object Object] console.log(foo[Symbol.toStringTag]); // undefined
class Bar { constructor() { this[Symbol.toStringTag] = ‘Bar’; } } let bar = new Bar();
console.log(bar); // Bar {} console.log(bar.toString()); // [object Bar] console.log(bar[Symbol.toStringTag]); // Bar
16. **Symbol.unscopables**<br />根据ECMAScript规范,这个符号作为一个属性表示“一个对象,该对象所有的以及继承的属性,都会从关联对象的`with`环境绑定中排除”。设置这个符号并让其映射对应属性的键值为`true`,就可以阻止该属性出现在`with`环境绑定中,如下例所示:
let o = { foo: ‘bar’ };
with (o) { console.log(foo); // bar }
o[Symbol.unscopables] = { foo: true };
with (o) { console.log(foo); // ReferenceError }
> **注意** 不推荐使用`with`,因此也不推荐使用`Symbol.unscopables`。
<a name="44aa341c"></a>
### 3.4.8 `Object`类型
ECMAScript中的对象其实就是一组数据和功能的集合。对象通过`new`操作符后跟对象类型的名称来创建。开发者可以通过创建`Object`类型的实例来创建自己的对象,然后再给对象添加属性和方法:
let o = new Object();
这个语法类似Java,但ECMAScript只要求在给构造函数提供参数时使用括号。如果没有参数,如上面的例子所示,那么完全可以省略括号(不推荐):
let o = new Object; // 合法,但不推荐
`Object`的实例本身并不是很有用,但理解与它相关的概念非常重要。类似Java中的`java.lang.Object`,ECMAScript中的`Object`也是派生其他对象的基类。`Object`类型的所有属性和方法在派生的对象上同样存在。
每个`Object`实例都有如下属性和方法。
- `constructor`:用于创建当前对象的函数。在前面的例子中,这个属性的值就是`Object()`函数。
- `hasOwnProperty(*propertyName*)`:用于判断当前对象实例(不是原型)上是否存在给定的属性。要检查的属性名必须是字符串(如`o.hasOwnProperty("name")`)。
- `isPrototypeof(*object*)`:用于判断当前对象是否为另一个对象的原型。(第5章将详细介绍原型。)
- `propertyIsEnumerable(*propertyName*)`:用于判断给定的属性是否可以使用(本章稍后讨论的)`for-in`语句枚举。与`hasOwnProperty()`一样,属性名必须是字符串。
- `toLocaleString()`:返回对象的字符串表示,该字符串反映对象所在的本地化执行环境。
- `toString()`:返回对象的字符串表示。
- `valueOf()`:返回对象对应的字符串、数值或布尔值表示。通常与`toString()`的返回值相同。
因为在ECMAScript中`Object`是所有对象的基类,所以任何对象都有这些属性和方法。第5章和第6章将介绍对象间的继承机制。
> **注意** 严格来讲,ECMA-262中对象的行为不一定适合JavaScript中的其他对象。比如浏览器环境中的BOM和DOM对象,都是由宿主环境定义和提供的宿主对象。而宿主对象不受ECMA-262约束,所以它们可能会也可能不会继承`Object`。
<a name="0381c4b8"></a>
## 3.5 操作符
ECMA-262描述了一组可用于操作数据值的**操作符**,包括数学操作符(如加、减)、位操作符、关系操作符和相等操作符等。ECMAScript中的操作符是独特的,因为它们可用于各种值,包括字符串、数值、布尔值,甚至还有对象。在应用给对象时,操作符通常会调用`valueOf()`和`/`或`toString()`方法来取得可以计算的值。
<a name="2e6ede80"></a>
### 3.5.1 一元操作符
只操作一个值的操作符叫**一元操作符**(unary operator)。一元操作符是ECMAScript中最简单的操作符。
1. **递增/递减操作符**<br />递增和递减操作符直接照搬自C语言,但有两个版本:前缀版和后缀版。顾名思义,前缀版就是位于要操作的变量前头,后缀版就是位于要操作的变量后头。前缀递增操作符会给数值加1,把两个加号(`++`)放到变量前头即可:
let age = 29; ++age;
1. 在这个例子中,前缀递增操作符把`age`的值变成了30(给之前的值29加1)。因此,它实际上等于如下表达式:
let age = 29; age = age + 1;
1. 前缀递减操作符也类似,只不过是从一个数值减1。使用前缀递减操作符,只要把两个减号(`--`)放到变量前头即可:
let age = 29; —age;
1. 执行操作后,变量`age`的值变成了28(从29减1)。<br />无论使用前缀递增还是前缀递减操作符,变量的值都会在语句被求值之前改变。(在计算机科学中,这通常被称为具有**副作用**。)请看下面的例子:
let age = 29; let anotherAge = —age + 2;
console.log(age); // 28 console.log(anotherAge); // 30
1. 在这个例子中,变量`anotherAge`以`age`减1后的值再加2进行初始化。因为递减操作先发生,所以`age`的值先变成28,然后再加2,结果是30。<br />前缀递增和递减在语句中的优先级是相等的,因此会从左到右依次求值。比如:
let num1 = 2; let num2 = 20; let num3 = —num1 + num2; let num4 = num1 + num2; console.log(num3); // 21 console.log(num4); // 21
1. 这里,`num3`等于21是因为`num1`先减1之后才加`num2`。变量`num4`也是21,那是因为加法使用的也是递减后的值。<br />递增和递减的后缀版语法一样(分别是`++`和`--`),只不过要放在变量后面。后缀版与前缀版的主要区别在于,后缀版递增和递减在语句被求值后才发生。在某些情况下,这种差异没什么影响,比如:
let age = 29; age++;
1. 把递增操作符放到变量后面不会改变语句执行的结果,因为递增是唯一的操作。可是,在跟其他操作混合时,差异就会变明显,比如:
let num1 = 2; let num2 = 20; let num3 = num1— + num2; let num4 = num1 + num2; console.log(num3); // 22 console.log(num4); // 21
1. 这个例子跟前面的那个一样,只是把前缀递减改成了后缀递减,区别很明显。在使用前缀版的例子中,`num3`和`num4`的值都是21。而在这个例子中,`num3`的值是22,`num4`的值是21。这里的不同之处在于,计算`num3`时使用的是`num1`的原始值(2),而计算`num4`时使用的是`num1`递减后的值(1)。<br />这4个操作符可以作用于任何值,意思是不限于整数——字符串、布尔值、浮点值,甚至对象都可以。递增和递减操作符遵循如下规则。
- 对于字符串,如果是有效的数值形式,则转换为数值再应用改变。变量类型从字符串变成数值。
- 对于字符串,如果不是有效的数值形式,则将变量的值设置为`NaN`。变量类型从字符串变成数值。
- 对于布尔值,如果是`false`,则转换为0再应用改变。变量类型从布尔值变成数值。
- 对于布尔值,如果是`true`,则转换为1再应用改变。变量类型从布尔值变成数值。
- 对于浮点值,加1或减1。
- 如果是对象,则调用其(第5章会详细介绍的)`valueOf()`方法取得可以操作的值。对得到的值应用上述规则。如果是`NaN`,则调用`toString()`并再次应用其他规则。变量类型从对象变成数值。
下面的例子演示了这些规则:
let s1 = “2”; let s2 = “z”; let b = false; let f = 1.1; let o = { valueOf() { return -1; } };
s1++; // 值变成数值3 s2++; // 值变成NaN b++; // 值变成数值1 f—; // 值变成0.10000000000000009(因为浮点数不精确) o—; // 值变成-2
2. **一元加和减**<br />**一元加和减操作符**对大多数开发者来说并不陌生,它们在ECMAScript中跟在高中数学中的用途一样。一元加由一个加号(`+`)表示,放在变量前头,对数值没有任何影响:
let num = 25; num = +num; console.log(num); // 25
2. 如果将一元减应用到非数值,则会执行与使用`Number()`转型函数一样的类型转换:布尔值`false`和`true`转换为0和1,字符串根据特殊规则进行解析,对象会调用它们的`valueOf()`和/或`toString()`方法以得到可以转换的值。<br />下面的例子演示了一元加在应用到不同数据类型时的行为:
let s1 = “01”; let s2 = “1.1”; let s3 = “z”; let b = false; let f = 1.1; let o = { valueOf() { return -1; } };
s1 = +s1; // 值变成数值1 s2 = +s2; // 值变成数值1.1 s3 = +s3; // 值变成NaN b = +b; // 值变成数值0 f = +f; // 不变,还是1.1 o = +o; // 值变成数值-1
2. 一元减由一个减号(`-`)表示,放在变量前头,主要用于把数值变成负值,如把1转换为-1。示例如下:
let num = 25; num = -num; console.log(num); // -25
2. 对数值使用一元减会将其变成相应的负值(如上面的例子所示)。在应用到非数值时,一元减会遵循与一元加同样的规则,先对它们进行转换,然后再取负值:
let s1 = “01”; let s2 = “1.1”; let s3 = “z”; let b = false; let f = 1.1; let o = { valueOf() { return -1; } };
s1 = -s1; // 值变成数值-1 s2 = -s2; // 值变成数值-1.1 s3 = -s3; // 值变成NaN b = -b; // 值变成数值0 f = -f; // 变成-1.1 o = -o; // 值变成数值1
2. 一元加和减操作符主要用于基本的算术,但也可以像上面的例子那样,用于数据类型转换。
<a name="73168d6e"></a>
### 3.5.2 位操作符
接下来要介绍的操作符用于数值的底层操作,也就是操作内存中表示数据的比特(位)。ECMAScript中的所有数值都以IEEE 754 64位格式存储,但位操作并不直接应用到64位表示,而是先把值转换为32位整数,再进行位操作,之后再把结果转换为64位。对开发者而言,就好像只有32位整数一样,因为64位整数存储格式是不可见的。既然知道了这些,就只需要考虑32位整数即可。
有符号整数使用32位的前31位表示整数值。第32位表示数值的符号,如0表示正,1表示负。这一位称为**符号位**(sign bit),它的值决定了数值其余部分的格式。正值以真正的二进制格式存储,即31位中的每一位都代表2的幂。第一位(称为第0位)表示20,第二位表示21,依此类推。如果一个位是空的,则以0填充,相当于忽略不计。比如,数值18的二进制格式为00000000000000000000000000010010,或更精简的10010。后者是用到的5个有效位,决定了实际的值(如图3-1所示)。
![](http://www.ituring.com.cn/figures/2020/JavaScriptWebDeve4th/004.png#align=left&alt=img&display=inline&message=%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87%E5%A4%B1%E8%B4%A5%EF%BC%8C%E8%AF%B7%E9%87%8D%E8%AF%95&percent=0&status=uploading)
**图 3-1**
负值以一种称为**二补数**的二进制编码存储。一个数值的二补数通过如下3个步骤计算得到:
(1) 确定绝对值的二进制表示(如,对于-18,先确定18的二进制表示);
(2) 找到数值的一补数,换句话说,就是每个0都变成1,每个1都变成0;
(3) 给结果加1。
基于上述步骤确定-18的二进制表示,首先从18的二进制表示开始:
0000 0000 0000 0000 0000 0000 0001 0010
然后,计算一补数,即反转每一位的二进制值:
1111 1111 1111 1111 1111 1111 1110 1101
最后,给一补数加1:
1111 1111 1111 1111 1111 1111 1110 1101
1
1111 1111 1111 1111 1111 1111 1110 1110
那么,-18的二进制表示就是11111111111111111111111111101110。要注意的是,在处理有符号整数时,我们无法访问第31位。
ECMAScript会帮我们记录这些信息。在把负值输出为一个二进制字符串时,我们会得到一个前面加了减号的绝对值,如下所示:
let num = -18; console.log(num.toString(2)); // “-10010”
在将-18转换为二进制字符串时,结果得到-10010。转换过程会求得二补数,然后再以更符合逻辑的形式表示出来。
> **注意** 默认情况下,ECMAScript中的所有整数都表示为有符号数。不过,确实存在无符号整数。对无符号整数来说,第32位不表示符号,因为只有正值。无符号整数比有符号整数的范围更大,因为符号位被用来表示数值了。
在对ECMAScript中的数值应用位操作符时,后台会发生转换:64位数值会转换为32位数值,然后执行位操作,最后再把结果从32位转换为64位存储起来。整个过程就像处理32位数值一样,这让二进制操作变得与其他语言中类似。但这个转换也导致了一个奇特的副作用,即特殊值`NaN`和`Infinity`在位操作中都会被当成0处理。
如果将位操作符应用到非数值,那么首先会使用`Number()`函数将该值转换为数值(这个过程是自动的),然后再应用位操作。最终结果是数值。
1. **按位非**<br />按位非操作符用波浪符(`~`)表示,它的作用是返回数值的一补数。按位非是ECMAScript中为数不多的几个二进制数学操作符之一。看下面的例子:
let num1 = 25; // 二进制00000000000000000000000000011001 let num2 = ~num1; // 二进制11111111111111111111111111100110 console.log(num2); // -26
1. 这里,按位非操作符作用到了数值25,得到的结果是-26。由此可以看出,按位非的最终效果是对数值取反并减1,就像执行如下操作的结果一样:
let num1 = 25; let num2 = -num1 - 1; console.log(num2); // “-26”
1. 实际上,尽管两者返回的结果一样,但位操作的速度快得多。这是因为位操作是在数值的底层表示上完成的。
2. **按位与**<br />按位与操作符用和号(`&`)表示,有两个操作数。本质上,按位与就是将两个数的每一个位对齐,然后基于真值表中的规则,对每一位执行相应的与操作。
| 第一个数值的位 | 第二个数值的位 | 结果 |
| :--- | :--- | :--- |
| 1 | 1 | 1 |
| 1 | 0 | 0 |
| 0 | 1 | 0 |
| 0 | 0 | 0 |
2. 按位与操作在两个位都是1时返回1,在任何一位是0时返回0。<br />下面看一个例子,我们对数值25和3求与操作,如下所示:
let result = 25 & 3; console.log(result); // 1
2. 25和3的按位与操作的结果是1。为什么呢?看下面的二进制计算过程:
25 = 0000 0000 0000 0000 0000 0000 0001 1001
3 = 0000 0000 0000 0000 0000 0000 0000 0011
AND = 0000 0000 0000 0000 0000 0000 0000 0001
2. 如上所示,25和3的二进制表示中,只有第0位上的两个数都是1。于是结果数值的所有其他位都会以0填充,因此结果就是1。
3. **按位或**<br />按位或操作符用管道符(`|`)表示,同样有两个操作数。按位或遵循如下真值表:
| 第一个数值的位 | 第二个数值的位 | 结果 |
| :--- | :--- | :--- |
| 1 | 1 | 1 |
| 1 | 0 | 1 |
| 0 | 1 | 1 |
| 0 | 0 | 0 |
3. 按位或操作在至少一位是1时返回1,两位都是0时返回0。<br />仍然用按位与的示例,如果对25和3执行按位或,代码如下所示:
let result = 25 | 3; console.log(result); // 27
3. 可见25和3的按位或操作的结果是27:
25 = 0000 0000 0000 0000 0000 0000 0001 1001
3 = 0000 0000 0000 0000 0000 0000 0000 0011
OR = 0000 0000 0000 0000 0000 0000 0001 1011
3. 在参与计算的两个数中,有4位都是1,因此它们直接对应到结果上。二进制码11011等于27。
4. **按位异或**<br />按位异或用脱字符(`^`)表示,同样有两个操作数。下面是按位异或的真值表:
| 第一个数的位 | 第二个数的位 | 结果 |
| :--- | :--- | :--- |
| 1 | 1 | 0 |
| 1 | 0 | 1 |
| 0 | 1 | 1 |
| 0 | 0 | 0 |
4. 按位异或与按位或的区别是,它只在一位上是1的时候返回1(两位都是1或0,则返回0)。<br />对数值25和3执行按位异或操作:
let result = 25 ^ 3; console.log(result); // 26
4. 可见,25和3的按位异或操作结果为26,如下所示:
25 = 0000 0000 0000 0000 0000 0000 0001 1001
3 = 0000 0000 0000 0000 0000 0000 0000 0011
XOR = 0000 0000 0000 0000 0000 0000 0001 1010
4. 两个数在4位上都是1,但两个数的第0位都是1,因此那一位在结果中就变成了0。其余位上的1在另一个数上没有对应的1,因此会直接传递到结果中。二进制码11010等于26。(注意,这比对同样两个值执行按位或操作得到的结果小1。)
5. **左移**<br />左移操作符用两个小于号(`<<`)表示,会按照指定的位数将数值的所有位向左移动。比如,如果数值2(二进制10)向左移5位,就会得到64(二进制1000000),如下所示:
let oldValue = 2; // 等于二进制10 let newValue = oldValue << 5; // 等于二进制1000000,即十进制64
5. 注意在移位后,数值右端会空出5位。左移会以0填充这些空位,让结果是完整的32位数值(见图3-2)。<br />![](http://www.ituring.com.cn/figures/2020/JavaScriptWebDeve4th/005.png#align=left&alt=%7B%25%7D&display=inline&message=%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87%E5%A4%B1%E8%B4%A5%EF%BC%8C%E8%AF%B7%E9%87%8D%E8%AF%95&percent=0&status=uploading)<br />**图 3-2**<br />注意,左移会保留它所操作数值的符号。比如,如果-2左移5位,将得到-64,而不是正64。
6. **有符号右移**<br />有符号右移由两个大于号(`>>`)表示,会将数值的所有32位都向右移,同时保留符号(正或负)。有符号右移实际上是左移的逆运算。比如,如果将64右移5位,那就是2:
let oldValue = 64; // 等于二进制1000000 let newValue = oldValue >> 5; // 等于二进制10,即十进制2
6. 同样,移位后就会出现空位。不过,右移后空位会出现在左侧,且在符号位之后(见图3-3)。ECMAScript会用符号位的值来填充这些空位,以得到完整的数值。<br />![](http://www.ituring.com.cn/figures/2020/JavaScriptWebDeve4th/006.png#align=left&alt=%7B%25%7D&display=inline&message=%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87%E5%A4%B1%E8%B4%A5%EF%BC%8C%E8%AF%B7%E9%87%8D%E8%AF%95&percent=0&status=uploading)<br />**图 3-3**
7. **无符号右移**<br />无符号右移用3个大于号表示(`>>>`),会将数值的所有32位都向右移。对于正数,无符号右移与有符号右移结果相同。仍然以前面有符号右移的例子为例,64向右移动5位,会变成2:
let oldValue = 64; // 等于二进制1000000 let newValue = oldValue >>> 5; // 等于二进制10,即十进制2
7. 对于负数,有时候差异会非常大。与有符号右移不同,无符号右移会给空位补0,而不管符号位是什么。对正数来说,这跟有符号右移效果相同。但对负数来说,结果就差太多了。无符号右移操作符将负数的二进制表示当成正数的二进制表示来处理。因为负数是其绝对值的二补数,所以右移之后结果变得非常之大,如下面的例子所示:
let oldValue = -64; // 等于二进制11111111111111111111111111000000 let newValue = oldValue >>> 5; // 等于十进制134217726
7. 在对-64无符号右移5位后,结果是134 217 726。这是因为-64的二进制表示是11111111111111111111111111000000,无符号右移却将它当成正值,也就是4 294 967 232。把这个值右移5位后,结果是00000111111111111111111111111110,即134 217 726。
<a name="84c6143c"></a>
### 3.5.3 布尔操作符
对于编程语言来说,布尔操作符跟相等操作符几乎同样重要。如果没有能力测试两个值的关系,那么像`if-else`和循环这样的语句也没什么用了。布尔操作符一共有3个:逻辑非、逻辑与和逻辑或。
1. **逻辑非**<br />逻辑非操作符由一个叹号(`!`)表示,可应用给ECMAScript中的任何值。这个操作符始终返回布尔值,无论应用到的是什么数据类型。逻辑非操作符首先将操作数转换为布尔值,然后再对其取反。换句话说,逻辑非操作符会遵循如下规则。
- 如果操作数是对象,则返回`false`。
- 如果操作数是空字符串,则返回`true`。
- 如果操作数是非空字符串,则返回`false`。
- 如果操作数是数值0,则返回`true`。
- 如果操作数是非0数值(包括`Infinity`),则返回`false`。
- 如果操作数是`null`,则返回`true`。
- 如果操作数是`NaN`,则返回`true`。
- 如果操作数是`undefined`,则返回`true`。
以下示例验证了上述行为:
console.log(!false); // true console.log(!”blue”); // false console.log(!0); // true console.log(!NaN); // true console.log(!””); // true console.log(!12345); // false
逻辑非操作符也可以用于把任意值转换为布尔值。同时使用两个叹号(`!!`),相当于调用了转型函数`Boolean()`。无论操作数是什么类型,第一个叹号总会返回布尔值。第二个叹号对该布尔值取反,从而给出变量真正对应的布尔值。结果与对同一个值使用`Boolean()`函数是一样的:
console.log(!!”blue”); // true console.log(!!0); // false console.log(!!NaN); // false console.log(!!””); // false console.log(!!12345); // true
2. **逻辑与**<br />逻辑与操作符由两个和号(`&&`)表示,应用到两个值,如下所示:
let result = true && false;
2. 逻辑与操作符遵循如下真值表:
| 第一个操作数 | 第二个操作数 | 结果 |
| :--- | :--- | :--- |
| `true` | `true` | `true` |
| `true` | `false` | `false` |
| `false` | `true` | `false` |
| `false` | `false` | `false` |
2. 逻辑与操作符可用于任何类型的操作数,不限于布尔值。如果有操作数不是布尔值,则逻辑与并不一定会返回布尔值,而是遵循如下规则。
- 如果第一个操作数是对象,则返回第二个操作数。
- 如果第二个操作数是对象,则只有第一个操作数求值为`true`才会返回该对象。
- 如果两个操作数都是对象,则返回第二个操作数。
- 如果有一个操作数是`null`,则返回`null`。
- 如果有一个操作数是`NaN`,则返回`NaN`。
- 如果有一个操作数是`undefined`,则返回`undefined`。
逻辑与操作符是一种短路操作符,意思就是如果第一个操作数决定了结果,那么永远不会对第二个操作数求值。对逻辑与操作符来说,如果第一个操作数是`false`,那么无论第二个操作数是什么值,结果也不可能等于`true`。看下面的例子:
let found = true; let result = (found && someUndeclaredVariable); // 这里会出错 console.log(result); // 不会执行这一行
上面的代码之所以会出错,是因为`someUndeclaredVariable`没有事先声明,所以当逻辑与操作符对它求值时就会报错。变量`found`的值是`true`,逻辑与操作符会继续求值变量`someUndeclaredVariable`。但是由于`someUndeclaredVariable`没有定义,不能对它应用逻辑与操作符,因此就报错了。假如变量`found`的值是`false`,那么就不会报错了:
let found = false; let result = (found && someUndeclaredVariable); // 不会出错 console.log(result); // 会执行
这里,`console.log`会成功执行。即使变量`someUndeclaredVariable`没有定义,由于第一个操作数是`false`,逻辑与操作符也不会对它求值,因为此时对`&&`右边的操作数求值是没有意义的。在使用逻辑与操作符时,一定别忘了它的这个短路的特性。
3. **逻辑或**<br />逻辑或操作符由两个管道符(`||`)表示,比如:
let result = true || false;
3. 逻辑或操作符遵循如下真值表:
| 第一个操作数 | 第二个操作数 | 结果 |
| :--- | :--- | :--- |
| `true` | `true` | `true` |
| `true` | `false` | `true` |
| `false` | `true` | `true` |
| `false` | `false` | `false` |
3. 与逻辑与类似,如果有一个操作数不是布尔值,那么逻辑或操作符也不一定返回布尔值。它遵循如下规则。
- 如果第一个操作数是对象,则返回第一个操作数。
- 如果第一个操作数求值为`false`,则返回第二个操作数。
- 如果两个操作数都是对象,则返回第一个操作数。
- 如果两个操作数都是`null`,则返回`null`。
- 如果两个操作数都是`NaN`,则返回`NaN`。
- 如果两个操作数都是`undefined`,则返回`undefined`。
同样与逻辑与类似,逻辑或操作符也具有短路的特性。只不过对逻辑或而言,第一个操作数求值为`true`,第二个操作数就不会再被求值了。看下面的例子:
let found = true; let result = (found || someUndeclaredVariable); // 不会出错 console.log(result); // 会执行
跟前面的例子一样,变量`someUndeclaredVariable`也没有定义。但是,因为变量`found`的值为`true`,所以逻辑或操作符不会对变量`someUndeclaredVariable`求值,而直接返回`true`。假如把`found`的值改为`false`,那就会报错了:
let found = false; let result = (found || someUndeclaredVariable); // 这里会出错 console.log(result); // 不会执行这一行
利用这个行为,可以避免给变量赋值`null`或`undefined`。比如:
let myObject = preferredObject || backupObject;
在这个例子中,变量`myObject`会被赋予两个值中的一个。其中,`preferredObject`变量包含首选的值,`backupObject`变量包含备用的值。如果`preferredObject`不是`null`,则它的值就会赋给`myObject`;如果`preferredObject`是`null`,则`backupObject`的值就会赋给`myObject`。这种模式在ECMAScript代码中经常用于变量赋值,本书后面的代码示例中也会经常用到。
<a name="babb49ee"></a>
### 3.5.4 乘性操作符
ECMAScript定义了3个乘性操作符:乘法、除法和取模。这些操作符跟它们在Java、C语言及Perl中对应的操作符作用一样,但在处理非数值时,它们也会包含一些自动的类型转换。如果乘性操作符有不是数值的操作数,则该操作数会在后台被使用`Number()`转型函数转换为数值。这意味着空字符串会被当成0,而布尔值`true`会被当成1。
1. **乘法操作符**<br />乘法操作符由一个星号(`*`)表示,可以用于计算两个数值的乘积。其语法类似于C语言,比如:
let result = 34 * 56;
1. 不过,乘法操作符在处理特殊值时也有一些特殊的行为。
- 如果操作数都是数值,则执行常规的乘法运算,即两个正值相乘是正值,两个负值相乘也是正值,正负符号不同的值相乘得到负值。如果ECMAScript不能表示乘积,则返回`Infinity`或`-Infinity`。
- 如果有任一操作数是`NaN`,则返回`NaN`。
- 如果是`Infinity`乘以0,则返回`NaN`。
- 如果是`Infinity`乘以非0的有限数值,则根据第二个操作数的符号返回`Infinity`或`-Infinity`。
- 如果是`Infinity`乘以`Infinity`,则返回`Infinity`。
- 如果有不是数值的操作数,则先在后台用`Number()`将其转换为数值,然后再应用上述规则。
2. **除法操作符**<br />除法操作符由一个斜杠(`/`)表示,用于计算第一个操作数除以第二个操作数的商,比如:
let result = 66 / 11;
2. 跟乘法操作符一样,除法操作符针对特殊值也有一些特殊的行为。
- 如果操作数都是数值,则执行常规的除法运算,即两个正值相除是正值,两个负值相除也是正值,符号不同的值相除得到负值。如果ECMAScript不能表示商,则返回`Infinity`或`-Infinity`。
- 如果有任一操作数是`NaN`,则返回`NaN`。
- 如果是`Infinity`除以`Infinity`,则返回`NaN`。
- 如果是0除以0,则返回`NaN`。
- 如果是非0的有限值除以0,则根据第一个操作数的符号返回`Infinity`或`-Infinity`。
- 如果是`Infinity`除以任何数值,则根据第二个操作数的符号返回`Infinity`或`-Infinity`。
- 如果有不是数值的操作数,则先在后台用`Number()`函数将其转换为数值,然后再应用上述规则。
3. **取模操作符**<br />取模(余数)操作符由一个百分比符号(`%`)表示,比如:
let result = 26 % 5; // 等于1
3. 与其他乘性操作符一样,取模操作符对特殊值也有一些特殊的行为。
- 如果操作数是数值,则执行常规除法运算,返回余数。
- 如果被除数是无限值,除数是有限值,则返回`NaN`。
- 如果被除数是有限值,除数是0,则返回`NaN`。
- 如果是`Infinity`除以`Infinity`,则返回`NaN`。
- 如果被除数是有限值,除数是无限值,则返回被除数。
- 如果被除数是0,除数不是0,则返回0。
- 如果有不是数值的操作数,则先在后台用`Number()`函数将其转换为数值,然后再应用上述规则。
<a name="467780ed"></a>
### 3.5.5 指数操作符
ECMAScript 7新增了指数操作符,`Math.pow()`现在有了自己的操作符`**`,结果是一样的:
console.log(Math.pow(3, 2); // 9 console.log(3 ** 2); // 9
console.log(Math.pow(16, 0.5); // 4 console.log(16** 0.5); // 4
不仅如此,指数操作符也有自己的指数赋值操作符`**=`,该操作符执行指数运算和结果的赋值操作:
let squared = 3; squared **= 2; console.log(squared); // 9
let sqrt = 16; sqrt **= 0.5; console.log(sqrt); // 4
<a name="db7f6150"></a>
### 3.5.6 加性操作符
加性操作符,即加法和减法操作符,一般都是编程语言中最简单的操作符。不过,在ECMAScript中,这两个操作符拥有一些特殊的行为。与乘性操作符类似,加性操作符在后台会发生不同数据类型的转换。只不过对这两个操作符来说,转换规则不是那么直观。
1. **加法操作符**<br />加法操作符(`+`)用于求两个数的和,比如:
let result = 1 + 2;
1. 如果两个操作数都是数值,加法操作符执行加法运算并根据如下规则返回结果:
- 如果有任一操作数是`NaN`,则返回`NaN`;
- 如果是`Infinity`加`Infinity`,则返回`Infinity`;
- 如果是`-Infinity`加`-Infinity`,则返回`-Infinity`;
- 如果是`Infinity`加`-Infinity`,则返回`NaN`;
- 如果是`+0`加`+0`,则返回`+0`;
- 如果是`-0`加`+0`,则返回`+0`;
- 如果是`-0`加`-0`,则返回`-0`。
不过,如果有一个操作数是字符串,则要应用如下规则:
- 如果两个操作数都是字符串,则将第二个字符串拼接到第一个字符串后面;
- 如果只有一个操作数是字符串,则将另一个操作数转换为字符串,再将两个字符串拼接在一起。
如果有任一操作数是对象、数值或布尔值,则调用它们的`toString()`方法以获取字符串,然后再应用前面的关于字符串的规则。对于`undefined`和`null`,则调用`String()`函数,分别获取`"undefined"`和`"null"`。<br />看下面的例子:
let result1 = 5 + 5; // 两个数值 console.log(result1); // 10 let result2 = 5 + “5”; // 一个数值和一个字符串 console.log(result2); // “55”
以上代码展示了加法操作符的两种运算模式。正常情况下,`5 + 5`等于10(数值),如前两行代码所示。但是,如果将一个操作数改为字符串,比如`"5"`,则相加的结果就变成了`"55"`(原始字符串值),因为第一个操作数也会被转换为字符串。<br />ECMAScript中最常犯的一个错误,就是忽略加法操作中涉及的数据类型。比如下面这个例子:
let num1 = 5; let num2 = 10; let message = “The sum of 5 and 10 is “ + num1 + num2; console.log(message); // “The sum of 5 and 10 is 510”
这里,变量`message`中保存的是一个字符串,是执行两次加法操作之后的结果。有人可能会认为最终得到的字符串是`"The sum of 5 and 10 is 15"`。可是,实际上得到的是`"The sum of 5 and 10 is 510"`。这是因为每次加法运算都是独立完成的。第一次加法的操作数是一个字符串和一个数值(5),结果还是一个字符串。第二次加法仍然是用一个字符串去加一个数值(10),同样也会得到一个字符串。如果想真正执行数学计算,然后把结果追加到字符串末尾,只要使用一对括号即可:
let num1 = 5; let num2 = 10; let message = “The sum of 5 and 10 is “ + (num1 + num2); console.log(message); // “The sum of 5 and 10 is 15”
在此,我们用括号把两个数值变量括了起来,意思是让解释器先执行两个数值的加法,然后再把结果追加给字符串。因此,最终得到的字符串变成了`"The sum of 5 and 10 is 15"`。
2. **减法操作符**<br />减法操作符(`-`)也是使用很频繁的一种操作符,比如:
let result = 2 - 1;
2. 与加法操作符一样,减法操作符也有一组规则用于处理ECMAScript中不同类型之间的转换。
- 如果两个操作数都是数值,则执行数学减法运算并返回结果。
- 如果有任一操作数是`NaN`,则返回`NaN`。
- 如果是`Infinity`减`Infinity`,则返回`NaN`。
- 如果是`-Infinity`减`-Infinity`,则返回`NaN`。
- 如果是`Infinity`减`-Infinity`,则返回`Infinity`。
- 如果是`-Infinity`减`Infinity`,则返回`-Infinity`。
- 如果是`+0`减`+0`,则返回`+0`。
- 如果是`+0`减`-0`,则返回`-0`。
- 如果是`-0`减`-0`,则返回`+0`。
- 如果有任一操作数是字符串、布尔值、`null`或`undefined`,则先在后台使用`Number()`将其转换为数值,然后再根据前面的规则执行数学运算。如果转换结果是`NaN`,则减法计算的结果是`NaN`。
- 如果有任一操作数是对象,则调用其`valueOf()`方法取得表示它的数值。如果该值是`NaN`,则减法计算的结果是`NaN`。如果对象没有`valueOf()`方法,则调用其`toString()`方法,然后再将得到的字符串转换为数值。
以下示例演示了上面的规则:
let result1 = 5 - true; // true被转换为1,所以结果是4 let result2 = NaN - 1; // NaN let result3 = 5 - 3; // 2 let result4 = 5 - “”; // “”被转换为0,所以结果是5 let result5 = 5 - “2”; // “2”被转换为2,所以结果是3 let result6 = 5 - null; // null被转换为0,所以结果是5
<a name="436fe432"></a>
### 3.5.7 关系操作符
关系操作符执行比较两个值的操作,包括小于(`<`)、大于(`>`)、小于等于(`<=`)和大于等于(`>=`),用法跟数学课上学的一样。这几个操作符都返回布尔值,如下所示:
let result1 = 5 > 3; // true let result2 = 5 < 3; // false
与ECMAScript中的其他操作符一样,在将它们应用到不同数据类型时也会发生类型转换和其他行为。
- 如果操作数都是数值,则执行数值比较。
- 如果操作数都是字符串,则逐个比较字符串中对应字符的编码。
- 如果有任一操作数是数值,则将另一个操作数转换为数值,执行数值比较。
- 如果有任一操作数是对象,则调用其`valueOf()`方法,取得结果后再根据前面的规则执行比较。如果没有`valueOf()`操作符,则调用`toString()`方法,取得结果后再根据前面的规则执行比较。
- 如果有任一操作数是布尔值,则将其转换为数值再执行比较。
在使用关系操作符比较两个字符串时,会发生一个有趣的现象。很多人认为小于意味着“字母顺序靠前”,而大于意味着“字母顺序靠后”,实际上不是这么回事。对字符串而言,关系操作符会比较字符串中对应字符的编码,而这些编码是数值。比较完之后,会返回布尔值。问题的关键在于,大写字母的编码都小于小写字母的编码,因此以下这种情况就会发生:
let result = “Brick” < “alphabet”; // true
在这里,字符串`"Brick"`被认为小于字符串`"alphabet"`,因为字母B的编码的66,字母a的编码是97。要得到确实按字母顺序比较的结果,就必须把两者都转换为相同的大小写形式(全大写或全小写),然后再比较:
let result = “Brick”.toLowerCase() < “alphabet”.toLowerCase(); // false
将两个操作数都转换为小写,就能保证按照字母表顺序判定`"alphabet"`在`"Brick"`前头。
另一个奇怪的现象是在比较两个数值字符串的时候,比如下面这个例子:
let result = “23” < “3”; // true
这里在比较字符串`"23"`和`"3"`时返回`true`。因为两个操作数都是字符串,所以会逐个比较它们的字符编码(字符`"2"`的编码是50,而字符`"3"`的编码是51)。不过,如果有一个操作数是数值,那么比较的结果就对了:
let result = “23” < 3; // false
因为这次会将字符串`"23"`转换为数值23,然后再跟3比较,结果当然对了。只要是数值和字符串比较,字符串就会先被转换为数值,然后进行数值比较。对于数值字符串而言,这样能保证结果正确。但如果字符串不能转换成数值呢?比如下面这个例子:
let result = “a” < 3; // 因为”a”会转换为NaN,所以结果是false
因为字符`"a"`不能转换成任何有意义的数值,所以只能转换为`NaN`。这里有一个规则,即任何关系操作符在涉及比较`NaN`时都返回`false`。这样一来,下面的例子有趣了:
let result1 = NaN < 3; // false let result2 = NaN >= 3; // false
在大多数比较的场景中,如果一个值不小于另一个值,那就一定大于或等于它。但在比较`NaN`时,无论是小于还是大于等于,比较的结果都会返回`false`。
<a name="4dc1977c"></a>
### 3.5.8 相等操作符
判断两个变量是否相等是编程中最重要的操作之一。在比较字符串、数值和布尔值是否相等时,过程都很直观。但是在比较两个对象是否相等时,情形就比较复杂了。ECMAScript中的相等和不相等操作符,原本在比较之前会执行类型转换,但很快就有人质疑这种转换是否应该发生。最终,ECMAScript提供了两组操作符。第一组是**等于**和**不等于**,它们在比较之前执行转换。第二组是**全等**和**不全等**,它们在比较之前不执行转换。
1. **等于和不等于**<br />ECMAScript中的等于操作符用两个等于号(`==`)表示,如果操作数相等,则会返回`true`。不等于操作符用叹号和等于号(`!=`)表示,如果两个操作数不相等,则会返回`true`。这两个操作符都会先进行类型转换(通常称为**强制类型转换**)再确定操作数是否相等。<br />在转换操作数的类型时,相等和不相等操作符遵循如下规则。
- 如果任一操作数是布尔值,则将其转换为数值再比较是否相等。`false`转换为0,`true`转换为1。
- 如果一个操作数是字符串,另一个操作数是数值,则尝试将字符串转换为数值,再比较是否相等。
- 如果一个操作数是对象,另一个操作数不是,则调用对象的`valueOf()`方法取得其原始值,再根据前面的规则进行比较。
在进行比较时,这两个操作符会遵循如下规则。
- `null`和`undefined`相等。
- `null`和`undefined`不能转换为其他类型的值再进行比较。
- 如果有任一操作数是`NaN`,则相等操作符返回`false`,不相等操作符返回`true`。记住:即使两个操作数都是`NaN`,相等操作符也返回`false`,因为按照规则,`NaN`不等于`NaN`。
- 如果两个操作数都是对象,则比较它们是不是同一个对象。如果两个操作数都指向同一个对象,则相等操作符返回`true`。否则,两者不相等。
下表总结了一些特殊情况及比较的结果。
| 表达式 | 结果 |
| :--- | :--- |
| `null == undefined` | `true` |
| `"NaN" == NaN` | `false` |
| `5 == NaN` | `false` |
| `NaN == NaN` | `false` |
| `NaN != NaN` | `true` |
| `false == 0` | `true` |
| `true == 1` | `true` |
| `true == 2` | `false` |
| `undefined == 0` | `false` |
| `null == 0` | `false` |
| `"5" == 5` | `true` |
2. **全等和不全等**<br />全等和不全等操作符与相等和不相等操作符类似,只不过它们在比较相等时不转换操作数。全等操作符由3个等于号(`===`)表示,只有两个操作数在不转换的前提下相等才返回`true`,比如:
let result1 = (“55” == 55); // true,转换后相等 let result2 = (“55” === 55); // false,不相等,因为数据类型不同
2. 在这个例子中,第一个比较使用相等操作符,比较的是字符串`"55"`和数值`55`。如前所述,因为字符串`"55"`会被转换为数值55,然后再与数值55进行比较,所以返回`true`。第二个比较使用全等操作符,因为没有转换,字符串和数值当然不能相等,所以返回`false`。<br />不全等操作符用一个叹号和两个等于号(`!==`)表示,只有两个操作数在不转换的前提下不相等才返回`true`。比如:
let result1 = (“55” != 55); // false,转换后相等 let result2 = (“55” !== 55); // true,不相等,因为数据类型不同
2. 这一次,第一个比较使用不相等操作符,它会把字符串`"55"`转换为数值`55`,跟第二个操作数相等。既然转换后两个值相等,那就返回`false`。第二个比较使用不全等操作符。这时候可以这么问:“字符串55和数值55有区别吗?”答案是“有”(`true`)。<br />另外,虽然`null == undefined`是`true`(因为这两个值类似),但`null === undefined`是`false`,因为它们不是相同的数据类型。
> **注意** 由于相等和不相等操作符存在类型转换问题,因此推荐使用全等和不全等操作符。这样有助于在代码中保持数据类型的完整性。
<a name="b61b4f91"></a>
### 3.5.9 条件操作符
条件操作符是ECMAScript中用途最为广泛的操作符之一,语法跟Java中一样:
variable = boolean_expression ? true_value : false_value;
上面的代码执行了条件赋值操作,即根据条件表达式`boolean_expression`的值决定将哪个值赋给变量`variable`。如果`boolean_expression`是`true`,则赋值`true_value`;如果`boolean_expression`是`false`,则赋值`false_value`。比如:
let max = (num1 > num2) ? num1 : num2;
在这个例子中,`max`将被赋予一个最大值。这个表达式的意思是,如果`num1`大于`num2`(条件表达式为`true`),则将`num1`赋给`max`。否则,如果`num1`小于`num2`(条件表达式为`false`),则将`num2`赋给`max`。
<a name="da12baf2"></a>
### 3.5.10 赋值操作符
简单赋值用等于号(`=`)表示,将右手边的值赋给左手边的变量,如下所示:
let num = 10;
复合赋值使用乘性、加性或位操作符后跟等于号(`=`)表示。这些赋值操作符是类似如下常见赋值操作的简写形式:
let num = 10; num = num + 10;
以上代码的第二行可以通过复合赋值来完成:
let num = 10; num += 10;
每个数学操作符以及其他一些操作符都有对应的复合赋值操作符:
- 乘后赋值(`*=`)
- 除后赋值(`/=`)
- 取模后赋值(`%=`)
- 加后赋值(`+=`)
- 减后赋值(`-=`)
- 左移后赋值(`<<=`)
- 右移后赋值(`>>=`)
- 无符号右移后赋值(`>>>=`)
这些操作符仅仅是简写语法,使用它们不会提升性能。
<a name="793e8891"></a>
### 3.5.11 逗号操作符
逗号操作符可以用来在一条语句中执行多个操作,如下所示:
let num1 = 1, num2 = 2, num3 = 3;
在一条语句中同时声明多个变量是逗号操作符最常用的场景。不过,也可以使用逗号操作符来辅助赋值。在赋值时使用逗号操作符分隔值,最终会返回表达式中最后一个值:
let num = (5, 1, 4, 8, 0); // num的值为0
在这个例子中,`num`将被赋值为0,因为0是表达式中最后一项。逗号操作符的这种使用场景并不多见,但这种行为的确存在。
<a name="dc1e68cb"></a>
## 3.6 语句
ECMA-262描述了一些语句(也称为**流控制语句**),而ECMAScript中的大部分语法都体现在语句中。语句通常使用一或多个关键字完成既定的任务。语句可以简单,也可以复杂。简单的如告诉函数退出,复杂的如列出一堆要重复执行的指令。
<a name="58af137a"></a>
### 3.6.1 `if`语句
`if`语句是使用最频繁的语句之一,语法如下:
if (condition) statement1 else statement2
这里的条件(`condition`)可以是任何表达式,并且求值结果不一定是布尔值。ECMAScript会自动调用`Boolean()`函数将这个表达式的值转换为布尔值。如果条件求值为`true`,则执行语句`statement1`;如果条件求值为`false`,则执行语句`statement2`。这里的语句可能是一行代码,也可能是一个代码块(即包含在一对花括号中的多行代码)。来看下面的例子:
if (i > 25) console.log(“Greater than 25.”); // 只有一行代码的语句 else { console.log(“Less than or equal to 25.”); // 一个语句块 }
这里的最佳实践是使用语句块,即使只有一行代码要执行也是如此。这是因为语句块可以避免对什么条件下执行什么产生困惑。
可以像这样连续使用多个`if`语句:
if (condition1) statement1 else if (condition2) statement2 else statement3
下面是一个例子:
if (i > 25) { console.log(“Greater than 25.”); } else if (i < 0) { console.log(“Less than 0.”); } else { console.log(“Between 0 and 25, inclusive.”); }
<a name="98885808"></a>
### 3.6.2 `do-while`语句
`do-while`语句是一种后测试循环语句,即循环体中的代码执行后才会对退出条件进行求值。换句话说,循环体内的代码至少执行一次。`do-while`的语法如下:
do { statement } while (expression);
下面是一个例子:
let i = 0; do { i += 2; } while (i < 10);
在这个例子中,只要`i`小于10,循环就会重复执行。`i`从0开始,每次循环递增2。
> **注意** 后测试循环经常用于这种情形:循环体内代码在退出前至少要执行一次。
<a name="0eae1920"></a>
### 3.6.3 `while`语句
`while`语句是一种先测试循环语句,即先检测退出条件,再执行循环体内的代码。因此,`while`循环体内的代码有可能不会执行。下面是`while`循环的语法:
while(expression) statement
这是一个例子:
let i = 0; while (i < 10) { i += 2; }
在这个例子中,变量`i`从0开始,每次循环递增2。只要`i`小于10,循环就会继续。
<a name="7b231659"></a>
### 3.6.4 `for`语句
`for`语句也是先测试语句,只不过增加了进入循环之前的初始化代码,以及循环执行后要执行的表达式,语法如下:
for (initialization; expression; post-loop-expression) statement
下面是一个用例:
let count = 10; for (let i = 0; i < count; i++) { console.log(i); }
以上代码在循环开始前定义了变量`i`的初始值为0。然后求值条件表达式,如果求值结果为`true`(`i < count`),则执行循环体。因此循环体也可能不会被执行。如果循环体被执行了,则循环后表达式也会执行,以便递增变量`i`。`for`循环跟下面的`while`循环是一样的:
let count = 10; let i = 0; while (i < count) { console.log(i); i++; }
无法通过`while`循环实现的逻辑,同样也无法使用`for`循环实现。因此`for`循环只是将循环相关的代码封装在了一起而已。
在`for`循环的初始化代码中,其实是可以不使用变量声明关键字的。不过,初始化定义的迭代器变量在循环执行完成后几乎不可能再用到了。因此,最清晰的写法是使用`let`声明迭代器变量,这样就可以将这个变量的作用域限定在循环中。
初始化、条件表达式和循环后表达式都不是必需的。因此,下面这种写法可以创建一个无穷循环:
for (;;) { // 无穷循环 doSomething(); }
如果只包含条件表达式,那么`for`循环实际上就变成了`while`循环:
let count = 10; let i = 0; for (; i < count; ) { console.log(i); i++; }
这种多功能性使得`for`语句在这门语言中使用非常广泛。
<a name="cd80e260"></a>
### 3.6.5 `for-in`语句
`for-in`语句是一种严格的迭代语句,用于枚举对象中的非符号键属性,语法如下:
for (property in expression) statement
下面是一个例子:
for (const propName in window) { document.write(propName); }
这个例子使用`for-in`循环显示了BOM对象`window`的所有属性。每次执行循环,都会给变量`propName`赋予一个`window`对象的属性作为值,直到`window`的所有属性都被枚举一遍。与`for`循环一样,这里控制语句中的`const`也不是必需的。但为了确保这个局部变量不被修改,推荐使用`const`。
ECMAScript中对象的属性是无序的,因此`for-in`语句不能保证返回对象属性的顺序。换句话说,所有可枚举的属性都会返回一次,但返回的顺序可能会因浏览器而异。
如果`for-in`循环要迭代的变量是`null`或`undefined`,则不执行循环体。
<a name="338a2b80"></a>
### 3.6.6 `for-of`语句
`for-of`语句是一种严格的迭代语句,用于遍历可迭代对象的元素,语法如下:
for (property of expression) statement
下面是示例:
for (const el of [2,4,6,8]) { document.write(el); }
在这个例子中,我们使用`for-of`语句显示了一个包含4个元素的数组中的所有元素。循环会一直持续到将所有元素都迭代完。与`for`循环一样,这里控制语句中的`const`也不是必需的。但为了确保这个局部变量不被修改,推荐使用`const`。
`for-of`循环会按照可迭代对象的`next()`方法产生值的顺序迭代元素。关于可迭代对象,本书将在第7章详细介绍。
如果尝试迭代的变量不支持迭代,则`for-of`语句会抛出错误。
> **注意** ES2018对`for-of`语句进行了扩展,增加了`for-await-of`循环,以支持生成期约(promise)的异步可迭代对象。相关内容将在附录A介绍。
<a name="8dc92dea"></a>
### 3.6.7 标签语句
标签语句用于给语句加标签,语法如下:
label: statement
下面是一个例子:
start: for (let i = 0; i < count; i++) { console.log(i); }
在这个例子中,`start`是一个标签,可以在后面通过`break`或`continue`语句引用。标签语句的典型应用场景是嵌套循环。
<a name="e50ca322"></a>
### 3.6.8 `break`和`continue`语句
`break`和`continue`语句为执行循环代码提供了更严格的控制手段。其中,`break`语句用于立即退出循环,强制执行循环后的下一条语句。而`continue`语句也用于立即退出循环,但会再次从循环顶部开始执行。下面看一个例子:
let num = 0;
for (let i = 1; i < 10; i++) { if (i % 5 == 0) { break; } num++; }
console.log(num); // 4
在上面的代码中,`for`循环会将变量`i`由1递增到10。而在循环体内,有一个`if`语句用于检查`i`能否被5整除(使用取模操作符)。如果是,则执行`break`语句,退出循环。变量`num`的初始值为0,表示循环在退出前执行了多少次。当`break`语句执行后,下一行执行的代码是`console.log(num)`,显示4。之所以循环执行了4次,是因为当`i`等于5时,`break`语句会导致循环退出,该次循环不会执行递增`num`的代码。如果将`break`换成`continue`,则会出现不同的效果:
let num = 0;
for (let i = 1; i < 10; i++) { if (i % 5 == 0) { continue; } num++; }
console.log(num); // 8
这一次,`console.log`显示8,即循环被完整执行了8次。当`i`等于5时,循环会在递增`num`之前退出,但会执行下一次迭代,此时`i`是6。然后,循环会一直执行到自然结束,即`i`等于10。最终`num`的值是8而不是9,是因为`continue`语句导致它少递增了一次。
`break`和`continue`都可以与标签语句一起使用,返回代码中特定的位置。这通常是在嵌套循环中,如下面的例子所示:
let num = 0;
outermost: for (let i = 0; i < 10; i++) { for (let j = 0; j < 10; j++) { if (i == 5 && j == 5) { break outermost; } num++; } }
console.log(num); // 55
在这个例子中,`outermost`标签标识的是第一个`for`语句。正常情况下,每个循环执行10次,意味着`num++`语句会执行100次,而循环结束时`console.log`的结果应该是100。但是,`break`语句带来了一个变数,即要退出到的标签。添加标签不仅让`break`退出(使用变量`j`的)内部循环,也会退出(使用变量`i`)的外部循环。当执行到`i`和`j`都等于5时,循环停止执行,此时`num`的值是55。`continue`语句也可以使用标签,如下面的例子所示:
let num = 0;
outermost: for (let i = 0; i < 10; i++) { for (let j = 0; j < 10; j++) { if (i == 5 && j == 5) { continue outermost; } num++; } }
console.log(num); // 95
这一次,`continue`语句会强制循环继续执行,但不是继续执行内部循环,而是继续执行外部循环。当`i`和`j`都等于5时,会执行`continue`,跳到外部循环继续执行,从而导致内部循环少执行5次,结果`num`等于95。
组合使用标签语句和`break`、`continue`能实现复杂的逻辑,但也容易出错。注意标签要使用描述性强的文本,而嵌套也不要太深。
<a name="34b40d4f"></a>
### 3.6.9 `with`语句
`with`语句的用途是将代码作用域设置为特定的对象,其语法是:
with (expression) statement;
使用`with`语句的主要场景是针对一个对象反复操作,这时候将代码作用域设置为该对象能提供便利,如下面的例子所示:
let qs = location.search.substring(1); let hostName = location.hostname; let url = location.href;
上面代码中的每一行都用到了`location`对象。如果使用`with`语句,就可以少写一些代码:
with(location) { let qs = search.substring(1); let hostName = hostname; let url = href; }
这里,`with`语句用于连接`location`对象。这意味着在这个语句内部,每个变量首先会被认为是一个局部变量。如果没有找到该局部变量,则会搜索`location`对象,看它是否有一个同名的属性。如果有,则该变量会被求值为`location`对象的属性。
严格模式不允许使用`with`语句,否则会抛出错误。
> **警告** 由于`with`语句影响性能且难于调试其中的代码,通常不推荐在产品代码中使用`with`语句。
<a name="6ec96144"></a>
### 3.6.10 `switch`语句
`switch`语句是与`if`语句紧密相关的一种流控制语句,从其他语言借鉴而来。ECMAScript中`switch`语句跟C语言中`switch`语句的语法非常相似,如下所示:
switch (expression) { case value1: statement break; case value2: statement break; case value3: statement break; case value4: statement break; default: statement }
这里的每个`case`(条件/分支)相当于:“如果表达式等于后面的值,则执行下面的语句。”`break`关键字会导致代码执行跳出`switch`语句。如果没有`break`,则代码会继续匹配下一个条件。`default`关键字用于在任何条件都没有满足时指定默认执行的语句(相当于`else`语句)。
有了`switch`语句,开发者就用不着写类似这样的代码了:
if (i == 25) { console.log(“25”); } else if (i == 35) { console.log(“35”); } else if (i == 45) { console.log(“45”); } else { console.log(“Other”); }
而是可以这样写:
switch (i) { case 25: console.log(“25”); break; case 35: console.log(“35”); break; case 45: console.log(“45”); break; default: console.log(“Other”); }
为避免不必要的条件判断,最好给每个条件后面都加上`break`语句。如果确实需要连续匹配几个条件,那么推荐写个注释表明是故意忽略了`break`,如下所示:
switch (i) { case 25: /跳过/ case 35: console.log(“25 or 35”); break; case 45: console.log(“45”); break; default: console.log(“Other”); }
虽然`switch`语句是从其他语言借鉴过来的,但ECMAScript为它赋予了一些独有的特性。首先,`switch`语句可以用于所有数据类型(在很多语言中,它只能用于数值),因此可以使用字符串甚至对象。其次,条件的值不需要是常量,也可以是变量或表达式。看下面的例子:
switch (“hello world”) { case “hello” + “ world”: console.log(“Greeting was found.”); break; case “goodbye”: console.log(“Closing was found.”); break; default: console.log(“Unexpected message was found.”); }
这个例子在`switch`语句中使用了字符串。第一个条件实际上使用的是表达式,求值为两个字符串拼接后的结果。因为拼接后的结果等于`switch`的参数,所以`console.log`会输出`"Greeting was found."`。能够在条件判断中使用表达式,就可以在判断中加入更多逻辑:
let num = 25; switch (true) { case num < 0: console.log(“Less than 0.”); break; case num >= 0 && num <= 10: console.log(“Between 0 and 10.”); break; case num > 10 && num <= 20: console.log(“Between 10 and 20.”); break; default: console.log(“More than 20.”); }
上面的代码首先在外部定义了变量`num`,而传给`switch`语句的参数之所以是`true`,就是因为每个条件的表达式都会返回布尔值。条件的表达式分别被求值,直到有表达式返回`true`;否则,就会一直跳到`default`语句(这个例子正是如此)。
> **注意** `switch`语句在比较每个条件的值时会使用全等操作符,因此不会强制转换数据类型(比如,字符串`"10"`不等于数值10)。
<a name="5a81ee54"></a>
## 3.7 函数
函数对任何语言来说都是核心组件,因为它们可以封装语句,然后在任何地方、任何时间执行。ECMAScript中的函数使用`function`关键字声明,后跟一组参数,然后是函数体。
> **注意** 第10章会更详细地介绍函数。
以下是函数的基本语法:
function functionName(arg0, arg1,…,argN) { statements }
下面是一个例子:
function sayHi(name, message) { console.log(“Hello “ + name + “, “ + message); }
可以通过函数名来调用函数,要传给函数的参数放在括号里(如果有多个参数,则用逗号隔开)。下面是调用函数`sayHi()`的示例:
sayHi(“Nicholas”, “how are you today?”);
调用这个函数的输出结果是`"Hello Nicholas, how are you today?"`。参数`name`和`message`在函数内部作为字符串被拼接在了一起,最终通过`console.log`输出到控制台。
ECMAScript中的函数不需要指定是否返回值。任何函数在任何时间都可以使用`return`语句来返回函数的值,用法是后跟要返回的值。比如:
function sum(num1, num2) { return num1 + num2; }
函数`sum()`会将两个值相加并返回结果。注意,除了`return`语句之外没有任何特殊声明表明该函数有返回值。然后就可以这样调用它:
const result = sum(5, 10);
要注意的是,只要碰到`return`语句,函数就会立即停止执行并退出。因此,`return`语句后面的代码不会被执行。比如:
function sum(num1, num2) { return num1 + num2; console.log(“Hello world”); // 不会执行 }
在这个例子中,`console.log`不会执行,因为它在`return`语句后面。
一个函数里也可以有多个`return`语句,像这样:
function diff(num1, num2) { if (num1 < num2) { return num2 - num1; } else { return num1 - num2; } }
这个`diff()`函数用于计算两个数值的差。如果第一个数值小于第二个,则用第二个减第一个;否则,就用第一个减第二个。代码中每个分支都有自己的`return`语句,返回正确的差值。
`return`语句也可以不带返回值。这时候,函数会立即停止执行并返回`undefined`。这种用法最常用于提前终止函数执行,并不是为了返回值。比如在下面的例子中,`console.log`不会执行:
function sayHi(name, message) { return; console.log(“Hello “ + name + “, “ + message); // 不会执行 } ```
注意 最佳实践是函数要么返回值,要么不返回值。只在某个条件下返回值的函数会带来麻烦,尤其是调试时。
严格模式对函数也有一些限制:
- 函数不能以
eval
或arguments
作为名称; - 函数的参数不能叫
eval
或arguments
; - 两个函数的参数不能叫同一个名称。
如果违反上述规则,则会导致语法错误,代码也不会执行。
3.8 小结
JavaScript的核心语言特性在ECMA-262中以伪语言ECMAScript的形式来定义。ECMAScript包含所有基本语法、操作符、数据类型和对象,能完成基本的计算任务,但没有提供获得输入和产生输出的机制。理解ECMAScript及其复杂的细节是完全理解浏览器中JavaScript的关键。下面总结一下ECMAScript中的基本元素。
- ECMAScript中的基本数据类型包括
Undefined
、Null
、Boolean
、Number
、String
和Symbol
。 - 与其他语言不同,ECMAScript不区分整数和浮点值,只有
Number
一种数值数据类型。 Object
是一种复杂数据类型,它是这门语言中所有对象的基类。- 严格模式为这门语言中某些容易出错的部分施加了限制。
- ECMAScript提供了C语言和类C语言中常见的很多基本操作符,包括数学操作符、布尔操作符、关系操作符、相等操作符和赋值操作符等。
- 这门语言中的流控制语句大多是从其他语言中借鉴而来的,比如
if
语句、for
语句和switch
语句等。
ECMAScript中的函数与其他语言中的函数不一样。
- 不需要指定函数的返回值,因为任何函数可以在任何时候返回任何值。
- 不指定返回值的函数实际上会返回特殊值
undefined
。