1. 词法
1.1 概述
词法规定了语言的最小语义单元:token,可以翻译成“标记”或者“词”。
从字符到词的整个过程是没有结构的,只要符合词的规则,就构成词,一般来说,词法设计不会包含冲突。词法分析技术上可以使用状态机或者正则表达式来进行,我们的课程主要是学习词法,关于它们实现的细节就不多谈了。
我们先来看一看 JavaScript 的词法定义。JavaScript 源代码中的输入可以这样分类:
- WhiteSpace 空白字符
- LineTerminator 换行符
- Comment 注释
- Token 词
- IdentifierName 标识符名称,典型案例是我们使用的变量名,注意这里关键字也包含在内了。
- Punctuator 符号,我们使用的运算符和大括号等符号。
- NumericLiteral 数字直接量,就是我们写的数字。
- StringLiteral 字符串直接量,就是我们用单引号或者双引号引起来的直接量。
- Template 字符串模板,用反引号`括起来的直接量。
这个设计符合比较通用的编程语言设计方式,不过,JavaScript 中有一些特别之处,我下面就来讲讲特别在哪里。
首先是除法和正则表达式冲突问题。我们都知道,JavaScript 不但支持除法运算符“ / ”和“ /= ”,还支持用斜杠括起来的正则表达式“ /abc/ ”。
但是,这时候对词法分析来说,其实是没有办法处理的,所以 JavaScript 的解决方案是定义两组词法,然后靠语法分析传一个标志给词法分析器,让它来决定使用哪一套词法。
JavaScript 词法的另一个特别设计是字符串模板,模板语法大概是这样的:
`Hello, ${name}`;
理论上,“ ${ } ”内部可以放任何 JavaScript 表达式代码,而这些代码是以“ } ” 结尾的,也就是说,这部分词法不允许出现“ } ”运算符。
是否允许“ } ”的两种情况,与除法和正则表达式的两种情况相乘就是四种词法定义,所以你在 JavaScript 标准中,可以看到四种定义:
- InputElementDiv;
- InputElementRegExp;
- InputElementRegExpOrTemplateTail;
- InputElementTemplateTail。
为了解决这两个问题,标准中还不得不把除法、正则表达式直接量和“ } ”从 token 中单独抽出来,用词上,也把原本的 Token 改为 CommonToken。
但是我认为,从理解的角度上出发,我们不应该受到影响,所以在本课,我们依然把它们归类到 token 来理解。
对一般的语言的词法分析过程来说,都会丢弃除了 token 之外的输入,但是对 JavaScript 来说,不太一样,换行符和注释还会影响语法分析过程,这个我们将会在语法部分给你详细讲解(所以要实现 JavaScript 的解释器,词法分析和语法分析非常麻烦,需要来回传递信息)。
1.2 空白符号 Whitespace
说起空白符号,想必给大家留下的印象就是空格,但是实际上,JavaScript 可以支持更多空白符号。
<HT>
(或称<TAB>
) 是 U+0009,是缩进 TAB 符,也就是字符串中写的 \t 。<VT>
是 U+000B,也就是垂直方向的 TAB 符 \v,这个字符在键盘上很难打出来,所以很少用到。<FF>
是 U+000C,Form Feed,分页符,字符串直接量中写作 \f ,现代已经很少有打印源程序的事情发生了,所以这个字符在 JavaScript 源代码中很少用到。<SP>
是 U+0020,就是最普通的空格了。<NBSP>
是 U+00A0,非断行空格,它是 SP 的一个变体,在文字排版中,可以避免因为空格在此处发生断行,其它方面和普通空格完全一样。多数的 JavaScript 编辑环境都会把它当做普通空格(因为一般源代码编辑环境根本就不会自动折行……)。HTML 中,很多人喜欢用的
最后生成的就是它了。<ZWNBSP>
(旧称<BOM>
) 是 U+FEFF,这是 ES5 新加入的空白符,是 Unicode 中的零宽非断行空格,在以 UTF 格式编码的文件中,常常在文件首插入一个额外的 U+FEFF,解析 UTF 文件的程序可以根据 U+FEFF 的表示方法猜测文件采用哪种 UTF 编码方式。这个字符也叫做“bit order mark”。
此外,JavaScript 支持所有的 Unicode 中的空格分类下的空格,我们可以看下表:
很多公司的编码规范要求 JavaScript 源代码控制在 ASCII 范围内,那么,就只有<TAB>
<VT>
<FF>
<SP>
<NBSP>
五种空白可用了。
1.3 换行符 LineTerminator
接下来我们来看看换行符,JavaScript 中只提供了 4 种字符作为换行符。
<LF>
<CR>
<LS>
<PS>
其中,<LF>
是 U+000A,就是最正常换行符,在字符串中的\n。
<CR>
是 U+000D,这个字符真正意义上的“回车”,在字符串中是\r,在一部分 Windows 风格文本编辑器中,换行是两个字符\r\n。
<LS>
是 U+2028,是 Unicode 中的行分隔符。<PS>
是 U+2029,是 Unicode 中的段落分隔符。
大部分 LineTerminator 在被词法分析器扫描出之后,会被语法分析器丢弃,但是换行符会影响 JavaScript 的两个重要语法特性:自动插入分号和“no line terminator”规则。
1.4 注释 Comment
JavaScript 的注释分为单行注释和多行注释两种:
/* MultiLineCommentChars */
// SingleLineCommentChars
多行注释中允许自由地出现 MultiLineNotAsteriskChar,也就是除了*
之外的所有字符。而每一个*
之后,不能出现正斜杠符/
。
除了四种 LineTerminator 之外,所有字符都可以作为单行注释。
我们需要注意,多行注释中是否包含换行符号,会对 JavaScript 语法产生影响,对于“no line terminator”规则来说,带换行的多行注释与换行符是等效的。
1.5 标识符名称 IdentifierName
IdentifierName 可以以美元符“$”、下划线“_”或者 Unicode 字母开始,除了开始字符以外,IdentifierName 中还可以使用 Unicode 中的连接标记、数字、以及连接符号。
IdentifierName 的任意字符可以使用 JavaScript 的 Unicode 转义写法,使用 Unicode 转义写法时,没有任何字符限制。
IdentifierName 可以是 Identifier、NullLiteral、BooleanLiteral 或者 keyword,在 ObjectLiteral 中,IdentifierName 还可以被直接当做属性名称是用哪个。
仅当不是保留字的时候,IdentifierName 会被解析为 Identifier。
注意<ZWNJ
和<ZWJ>
是 ES5 新加入的两个格式控制字符,它们都是 0 宽的。
我在前面提到了,关键字也属于这个部分,在 JavaScript 中,关键字有:
await break case catch class const continue debugger default delete do else export extends finally for function if import in instance of new return super switch this throw try typeof var void while with yield
除了上述的内容之外,还有 1 个为了未来使用而保留的关键字:
enum
在严格模式下, 有一些额外的为未来使用而保留的关键字:
implements package protected interface private public
除了这些之外,NullLiteral (null) 和 BooleanLiteral (true false) 也是保留字,不能用于 Identifier。
1.6 符号 Punctuator
因为前面提到的除法和正则问题, / 和 /= 两个运算符被拆分为 DivPunctuator,因为前面提到的字符串模板问题,}
也被独立拆分。加在一起,所有符号为:
{ ( ) [ ] . ... ; , < > <= >= == != === !== + - * % ** ++ -- << >> >>> & | ^ ! ~ && || ? : = += -= *= %= **= <<= >>= >>>= &= |= ^= => / /= }
1.7 数字直接量 NumericLiteral
JavaScript 规范中规定的数字直接量可以支持四种写法:十进制数、二进制整数、八进制整数和十六进制整数。
十进制的 Number 可以带小数,小数点前后部分都可以省略,但是不能同时省略,我们看几个例子:
0.01;
12;
12.01;
这都是合法的数字直接量。这里就有一个问题,我们看一段代码:
12.toString() // Uncaught SyntaxError: Invalid or unexpected token
这时候 12. 会被当做省略了小数点后面部分的数字而看成一个整体,所以我们要想让点单独成为一个 token,就要加入空格,这样写:
(12).toString();
数字直接量还支持科学计数法,例如:
10.24e2;
10.24e-2;
10.24e2;
这里 e 后面的部分,只允许使用整数。当以 0x
、 0b
或者 0o
开头时,表示特定进制的整数:
0xfa; // 十六进制整数
0o73; // 八进制整数
0b10000; // 二进制整型
上面这几种进制都不支持小数,也不支持科学计数法。
1.8 字符串直接量 StringLiteral
JavaScript 中的 StringLiteral 支持单引号和双引号两种写法。
``" DoubleStringCharacters "
' SingleStringCharacters '
单双引号的区别仅仅在于写法,在双引号字符串直接量中,双引号必须转义,在单引号字符串直接量中,单引号必须转义。字符串中其他必须转义的字符是\
和所有换行符。
JavaScript 中支持四种转义形式,还有一种虽然标准没有定义,但是大部分实现都支持的八进制转义。
第一种是单字符转义。 即一个反斜杠\
后面跟你一个字符这种形式。
有特别意义的字符包括有 SingleEscapeCharacter 所定义的 9 种,见下表:
除了这 9 种字符、数字、x 和 u 以及所有的换行符之外,其它字符经过\
转义后都是自身。
1.9 正则表达式直接量 RegularExpressionLiteral
正则表达式由 Body 和 Flags 两部分组成,例如:
/RegularExpressionBody/g;
其中 Body 部分至少有一个字符,第一个字符不能是*
(因为 /*
跟多行注释有词法冲突)。
正则表达式有自己的语法规则,在词法阶段,仅会对它做简单解析。
正则表达式并非机械地见到/
就停止,在正则表达式[
]
中的/
就会被认为是普通字符。我们可以看一个例子:
/[/]/.test('/');
除了\
、/
和[
三个字符之外,JavaScript 正则表达式中的字符都是普通字符。
用\
和一个非换行符可以组成一个转义,[
]
中也支持转义。正则表达式中的 flag 在词法阶段不会限制字符。
虽然只有 ig 几个是有效的,但是任何 IdentifierPart(Identifier 中合法的字符)序列在词法阶段都会被认为是合法的。
1.10 字符串模板 Template
从语法结构上,Template 是个整体,其中的 $
{
}
是并列关系。
但是实际上,在 JavaScript 词法中,包含 $
{
}
的 Template,是被拆开分析的,如:
`a${b}c${d}e`;
它在 JavaScript 中被认为是:
`a${b}c${d}e`;
它被拆成了五个部分:
a${
这个被称为模板头}c${
被称为模板中段}e
被称为模板尾b
和d
都是普通标识符
实际上,这里的词法分析过程已经跟语法分析深度耦合了。
不过我们学习的时候,大可不必按照标准和引擎工程师这样去理解,可以认为模板就是一个由反引号括起来的、可以在中间插入代码的字符串。
模板支持添加处理函数的写法,这时模板的各段会被拆开,传递给函数当参数:
function f() {
console.log(arguments);
}
var a = 'world';
f`Hello ${a}!`; // [["Hello", "!"], world]
模板字符串不需要关心大多数字符的转义,但是至少 ${ 和 ` 还是需要处理的。
模板中的转义跟字符串几乎完全一样,都是使用 \
。
2. 语法
2.1 脚本和模块
首先,JavaScript 有两种源文件,一种叫做脚本,一种叫做模块。这个区分是在 ES6 引入了模块机制开始的,在 ES5 和之前的版本中,就只有一种源文件类型(就只有脚本)。
脚本是可以由浏览器或者 node 环境引入执行的,而模块只能由 JavaScript 代码用 import 引入执行。
从概念上,我们可以认为脚本具有主动性的 JavaScript 代码段,是控制宿主完成一定任务的代码;而模块是被动性的 JavaScript 代码段,是等待被调用的库。
我们对标准中的语法产生式做一些对比,不难发现,实际上模块和脚本之间的区别仅仅在于是否包含 import 和 export。
脚本是一种兼容之前的版本的定义,在这个模式下,没有 import 就不需要处理加载“.js”文件问题。
现代浏览器可以支持用 script 标签引入模块或者脚本,如果要引入模块,必须给 script 标签添加 type=“module”。如果引入脚本,则不需要 type。
<script type="module" src="xxxxx.js"></script>
如果 script 标签如果不加type=“module”
,默认认为我们加载的文件是脚本而非模块,如果我们在脚本中写了 export,就会抛错。
脚本中可以包含语句。模块中可以包含三种内容:import 声明,export 声明和语句。普通语句我们会在下文专门给你讲解,下面我们就来讲讲 import 声明和 export 声明。
2.1.1 import 声明
我们首先来介绍一下 import 声明,import 声明有两种用法,一个是直接 import 一个模块,另一个是带 from 的 import,它能引入模块里的一些信息。
import 'mod'; // 引入一个模块
import v from 'mod'; // 把模块默认的导出值放入变量 v
直接 import 一个模块,只是保证了这个模块代码被执行,引用它的模块是无法获得它的任何信息的。
带 from 的 import 意思是引入模块中的一部分信息,可以把它们变成本地的变量。
带 from 的 import 细分又有三种用法,我们可以分别看下例子:
import x from "./a.js"
引入模块中导出的默认值。import {a as x, modify} from "./a.js";
引入模块中的变量。import * as x from "./a.js"
把模块中所有的变量以类似对象属性的方式引入。
第一种方式还可以跟后两种组合使用。
import d, {a as x, modify} from "./a.js"
import d, * as x from "./a.js"
语法要求不带 as 的默认值永远在最前。注意,这里的变量实际上仍然可以受到原来模块的控制。
我们看一个例子,假设有两个模块 a 和 b。我们在模块 a 中声明了变量和一个修改变量的函数,并且把它们导出。我们用 b 模块导入了变量和修改变量的函数。
模块 a:
export var a = 1;
export function modify() {
a = 2;
}
模块 b:
import { a, modify } from './a.js';
console.log(a);
modify();
console.log(a);
当我们调用修改变量的函数后,b 模块变量也跟着发生了改变。这说明导入与一般的赋值不同,导入后的变量只是改变了名字,它仍然与原来的变量是同一个。
2.1.2 export 声明
我们再来说说 export 声明。与 import 相对,export 声明承担的是导出的任务。
模块中导出变量的方式有两种,一种是独立使用 export 声明,另一种是直接在声明型语句前添加 export 关键字。
独立使用 export 声明就是一个 export 关键字加上变量名列表,例如:
export { a, b, c };
我们也可以直接在声明型语句前添加 export 关键字,这里的 export 可以加在任何声明性质的语句之前,整理如下:
- var
- function (含 async 和 generator)
- class
- let
- const
export 还有一种特殊的用法,就是跟 default 联合使用。export default 表示导出一个默认变量值,它可以用于 function 和 class。这里导出的变量是没有名称的,可以使用 import x from "./a.js"
这样的语法,在模块中引入。
export default 还支持一种语法,后面跟一个表达式,例如:
var a = {};
export default a;
但是,这里的行为跟导出变量是不一致的,这里导出的是值,导出的就是普通变量 a 的值,以后 a 的变化与导出的值就无关了,修改变量 a,不会使得其他模块中引入的 default 值发生改变。
在 import 语句前无法加入 export,但是我们可以直接使用 export from 语法。
export a from 'a.js';
JavaScript 引擎除了执行脚本和模块之外,还可以执行函数。而函数体跟脚本和模块有一定的相似之处,所以接下来,给你讲讲函数体的相关知识。
2.2 函数体
执行函数的行为通常是在 JavaScript 代码执行时,注册宿主环境的某些事件触发的,而执行的过程,就是执行函数体(函数的花括号中间的部分)。
我们先看一个例子,感性地理解一下:
setTimeout(function() {
console.log('go go go');
}, 10000);
这段代码通过 setTimeout 函数注册了一个函数给宿主,当一定时间之后,宿主就会执行这个函数。
你还记得吗,我们前面已经在运行时这部分讲过,宿主会为这样的函数创建宏任务。
当我们学习了语法之后,我们可以认为,宏任务中可能会执行的代码包括“脚本 (script)”“模块(module)”和“函数体(function body)”。正因为这样的相似性,我们把函数体也放到一起来讲解。
函数体其实也是一个语句的列表。跟脚本和模块比起来,函数体中的语句列表中多了 return 语句可以用。
函数体实际上有四种,下面,我来分别介绍一下。
- 普通函数体,例如:
function foo() {
//Function body
}
- 异步函数体,例如:
async function foo() {
//Function body
}
- 生成器函数体,例如:
function* foo() {
//Function body
}
- 异步生成器函数体,例如:
async function* foo() {
//Function body
}
上面四种函数体的区别在于:能否使用 await 或者 yield 语句。
关于函数体、模块和脚本能使用的语句,我整理了一个表格,你可以参考一下:
讲完了三种语法结构,我再来介绍两个 JavaScript 语法的全局机制:预处理和指令序言。
这两个机制对于我们解释一些 JavaScript 的语法现象非常重要。不理解预处理机制我们就无法理解 var 等声明类语句的行为,而不理解指令序言,我们就无法解释严格模式。
2.3 预处理
JavaScript 执行前,会对脚本、模块和函数体中的语句进行预处理。预处理过程将会提前处理 var、函数声明、class、const 和 let 这些语句,以确定其中变量的意义。
因为一些历史包袱,这一部分内容非常复杂,首先我们看一下 var 声明。
2.3.1 var 声明
var 声明永远作用于脚本、模块和函数体这个级别,在预处理阶段,不关心赋值的部分,只管在当前作用域声明这个变量。
我们还是从实例来进行学习。
var a = 1;
function foo() {
console.log(a);
var a = 2;
}
foo();
这段代码声明了一个脚本级别的 a,又声明了 foo 函数体级别的 a,我们注意到,函数体级的var
出现在 console.log 语句之后。
但是预处理过程在执行之前,所以有函数体级的变量 a,就不会去访问外层作用域中的变量 a 了,而函数体级的变量 a 此时还没有赋值,所以是 undefined。我们再看一个情况:
var a = 1;
function foo() {
console.log(a);
if (false) {
var a = 2;
}
}
foo();
这段代码比上一段代码在var a = 2
之外多了一段 if,我们知道 if(false) 中的代码永远不会被执行,但是预处理阶段并不管这个,var 的作用能够穿透一切语句结构,它只认脚本、模块和函数体三种语法结构。所以这里结果跟前一段代码完全一样,我们会得到 undefined。
我们看下一个例子,我们在运行时部分讲过类似的例子。
var a = 1;
function foo() {
var o = { a: 3 };
with (o) {
var a = 2;
}
console.log(o.a);
console.log(a);
}
foo();
在这个例子中,我们引入了 with 语句,我们用 with(o) 创建了一个作用域,并把 o 对象加入词法环境,在其中使用了var a = 2;
语句。
在预处理阶段,只认var
中声明的变量,所以同样为 foo 的作用域创建了 a 这个变量,但是没有赋值。
在执行阶段,当执行到var a = 2
时,作用域变成了 with 语句内,这时候的 a 被认为访问到了对象 o 的属性 a,所以最终执行的结果,我们得到了 2 和 undefined。
这个行为是 JavaScript 公认的设计失误之一,一个语句中的 a 在预处理阶段和执行阶段被当做两个不同的变量,严重违背了直觉,但是今天,在 JavaScript 设计原则“don’t break the web”之下,已经无法修正了,所以你需要特别注意。
因为早年 JavaScript 没有 let 和 const,只能用 var,又因为 var 除了脚本和函数体都会穿透,人民群众发明了“立即执行的函数表达式(IIFE)”这一用法,用来产生作用域,例如:
for (var i = 0; i < 20; i++) {
void (function(i) {
var div = document.createElement('div');
div.innerHTML = i;
div.onclick = function() {
console.log(i);
};
document.body.appendChild(div);
})(i);
}
这段代码非常经典,常常在实际开发中见到,也经常被用作面试题,为文档添加了 20 个 div 元素,并且绑定了点击事件,打印它们的序号。
我们通过 IIFE 在循环内构造了作用域,每次循环都产生一个新的环境记录,这样,每个 div 都能访问到环境中的 i。
如果我们不用 IIFE:
for (var i = 0; i < 20; i++) {
var div = document.createElement('div');
div.innerHTML = i;
div.onclick = function() {
console.log(i);
};
document.body.appendChild(div);
}
这段代码的结果将会是点每个 div 都打印 20,因为全局只有一个 i,执行完循环后,i 变成了 20。
2.3.2 function 声明
function 声明的行为原本跟 var 非常相似,但是在最新的 JavaScript 标准中,对它进行了一定的修改,这让情况变得更加复杂了。
在全局(脚本、模块和函数体),function 声明表现跟 var 相似,不同之处在于,function 声明不但在作用域中加入变量,还会给它赋值。
我们看一下 function 声明的例子:
console.log(foo);
function foo() {}
这里声明了函数 foo,在声明之前,我们用 console.log 打印函数 foo,我们可以发现,已经是函数 foo 的值了。
function 声明出现在 if 等语句中的情况有点复杂,它仍然作用于脚本、模块和函数体级别,在预处理阶段,仍然会产生变量,它不再被提前赋值:
console.log(foo);
if(true) {
function foo(){
}
}
这段代码得到 undefined。如果没有函数声明,则会抛出错误。
这说明 function 在预处理阶段仍然发生了作用,在作用域中产生了变量,没有产生赋值,赋值行为发生在了执行阶段。
出现在 if 等语句中的 function,在 if 创建的作用域中仍然会被提前,产生赋值效果,我们会在下文继续讨论。
2.3.3 class 声明
class 声明在全局的行为跟 function 和 var 都不一样。
在 class 声明之前使用 class 名,会抛错:
console.log(c);
class c {}
这段代码我们试图在 class 前打印变量 c,我们得到了个错误,这个行为很像是 class 没有预处理,但是实际上并非如此。
我们看个复杂一点的例子:
var c = 1;
function foo() {
console.log(c);
class c {}
}
foo();
这个例子中,我们把 class 放进了一个函数体中,在外层作用域中有变量 c。然后试图在 class 之前打印 c。
执行后,我们看到,仍然抛出了错误,如果去掉 class 声明,则会正常打印出 1,也就是说,出现在后面的 class 声明影响了前面语句的结果。
这说明,class 声明也是会被预处理的,它会在作用域中创建变量,并且要求访问它时抛出错误。
class 的声明作用不会穿透 if 等语句结构,所以只有写在全局环境才会有声明作用,这部分我们将会在下文讲解。
这样的 class 设计比 function 和 var 更符合直觉,而且在遇到一些比较奇怪的用法时,倾向于抛出错误。
按照现代语言设计的评价标准,及早抛错是好事,它能够帮助我们尽量在开发阶段就发现代码的可能问题。
2.4 指令序言机制
脚本和模块都支持一种特别的语法,叫做指令序言(Directive Prologs)。
这里的指令序言最早是为了 use strict 设计的,它规定了一种给 JavaScript 代码添加元信息的方式。
'use strict';
function f() {
console.log(this);
}
f.call(null);
这段代码展示了严格模式的用法,我这里定义了函数 f,f 中打印 this 值,然后用 call 的方法调用 f,传入 null 作为 this 值,我们可以看到最终结果是 null 原封不动地被当做 this 值打印了出来,这是严格模式的特征。
如果我们去掉严格模式的指令需要,打印的结果将会变成 global。
“use strict” 是 JavaScript 标准中规定的唯一一种指令序言,但是设计指令序言的目的是,留给 JavaScript 的引擎和实现者一些统一的表达方式,在静态扫描时指定 JavaScript 代码的一些特性。
例如,假设我们要设计一种声明本文件不需要进行 lint 检查的指令,我们可以这样设计:
'no lint';
'use strict';
function doSth() {
//......
}
//......
JavaScript 的指令序言是只有一个字符串直接量的表达式语句,它只能出现在脚本、模块和函数体的最前面。
我们看两个例子:
function doSth() {
//......
}
('use strict');
var a = 1;
//......
这个例子中,”use strict” 没有出现在最前,所以不是指令序言。
'use strict';
function doSth() {
//......
}
var a = 1;
//......
这个例子中,’use strict’ 是单引号,这不妨碍它仍然是指令序言。
2.5 语句
在 JavaScript 标准中,把语句分成了两种:声明和语句,不过,这里的区分逻辑比较奇怪,所以,这里我还是按照自己的思路给你整理一下。
普通语句:
声明型语句:
我们根据上面的分类,来遍历学习一下这些语句。
2.5.1 语句块
我们可以这样去简单理解,语句块就是一对大括号。
{
var x, y;
x = 10;
y = 20;
}
语句块的意义和好处在于:让我们可以把多行语句视为同一行语句,这样,if、for 等语句定义起来就比较简单了。不过,我们需要注意的是,语句块会产生作用域,我们看一个例子:
{
let x = 1;
}
console.log(x); // 报错
这里我们的 let 声明,仅仅对语句块作用域生效,于是我们在语句块外试图访问语句块内的变量 x 就会报错。
2.5.2 空语句
空语句就是一个独立的分号,实际上没什么大用。我们来看一下:
空语句的存在仅仅是从语言设计完备性的角度考虑,允许插入多个分号而不抛出错误。
2.5.3 if 语句
if 语句是条件语句,示例如下:
if (a < b) console.log(a);
if 语句的作用是,在满足条件时执行它的内容语句,这个语句可以是一个语句块,这样就可以实现有条件地执行多个语句了。
if 语句还有 else 结构,用于不满足条件时执行,一种常见的用法是,利用语句的嵌套能力,把 if 和 else 连写成多分支条件判断:
if (a < 10) {
//...
} else if (a < 20) {
//...
} else if (a < 30) {
//...
} else {
//...
}
这段代码表示四个互斥的分支,分别在满足 a<10、a<20、a<30 和其它情况时执行。
2.5.4 switch 语句
switch 语句继承自 Java,Java 中的 switch 语句继承自 C 和 C++,原本 switch 语句是跳转的变形,所以我们如果要用它来实现分支,必须要加上 break。
其实 switch 原本的设计是类似 goto 的思维。我们看一个例子:
switch(num) {
case 1:
print(1);
case 2:
print 2;
case 3:
print 3;
}
这段代码当 num 为 1 时输出 1 2 3,当 num 为 2 时输出 2 3,当 num 为 3 时输出 3。如果我们要把它变成分支型,则需要在每个 case 后加上 break。
switch(num) {
case 1:
print 1;
break;
case 2:
print 2;
break;
case 3:
print 3;
break;
}
在 C 时代,switch 生成的汇编代码性能是略优于 if else 的,但是对 JavaScript 来说,则无本质区别。
2.5.5 循环语句
循环语句应该也是你所熟悉的语句了,这里我们把重点放在一些新用法上。
while 循环和 do while 循环
这两个都是历史悠久的 JavaScript 语法了,示例大概如下:
let a = 100;
while (a--) {
console.log('*');
}
let a = 101;
do {
console.log(a);
} while (a < 100);
注意,这里 do while 循环无论如何至少会执行一次。
普通 for 循环
首先我们来看看普通的 for 循环。
for (i = 0; i < 100; i++) console.log(i);
for (var i = 0; i < 100; i++) console.log(i);
for (let i = 0; i < 100; i++) console.log(i);
var j = 0;
for (const i = 0; j < 100; j++) console.log(i);
这里为了配合新语法,加入了允许 let 和 const,实际上,const 在这里是非常奇葩的东西,因为这里声明和初始化的变量,按惯例是用于控制循环的,但是它如果是 const 就没法改了。
我想,这一点可能是从保持 let 和 const 一致性的角度考虑的吧。
for in 循环
for in 循环枚举对象的属性,这里体现了属性的 enumerable 特征。
let o = { a: 10, b: 20 };
Object.defineProperty(o, 'c', { enumerable: false, value: 30 });
for (let p in o) console.log(p);
这段代码中,我们定义了一个对象 o,给它添加了不可枚举的属性 c,之后我们用 for in 循环枚举它的属性,我们会发现,输出时得到的只有 a 和 b。
如果我们定义 c 这个属性时,enumerable 为 true,则 for in 循环中也能枚举到它。
for of 循环和 for await of 循环
for of 循环是非常棒的语法特性。
我们先看下基本用法,它可以用于数组:
for (let e of [1, 2, 3, 4, 5]) console.log(e);
但是实际上,它背后的机制是 iterator 机制。
我们可以给任何一个对象添加 iterator,使它可以用于 for of 语句,看下示例:
let o = {
[Symbol.iterator]: () => ({
_value: 0,
next() {
if (this._value == 10)
return {
done: true
};
else
return {
value: this._value++,
done: false
};
}
})
};
for (let e of o) console.log(e);
这段代码展示了如何为一个对象添加 iterator。但是,在实际操作中,我们一般不需要这样定义 iterator,我们可以使用 generator function。
function* foo() {
yield 0;
yield 1;
yield 2;
yield 3;
}
for (let e of foo()) console.log(e);
此外,JavaScript 还为异步生成器函数配备了异步的 for of,我们来看一个例子:
function sleep(duration) {
return new Promise(function(resolve, reject) {
setTimeout(resolve,duration);
})
}
async function* foo(){
i = 0;
while(true) {
await sleep(1000);
yield i++;
}
}
for await(let e of foo())
console.log(e);
这段代码定义了一个异步生成器函数,异步生成器函数每隔一秒生成一个数字,这是一个无限的生成器。
接下来,我们使用 for await of 来访问这个异步生成器函数的结果,我们可以看到,这形成了一个每隔一秒打印一个数字的无限循环。
但是因为我们这个循环是异步的,并且有时间延迟,所以,这个无限循环的代码可以用于显示时钟等有意义的操作。
2.5.6 return 语句
return 语句用于函数中,它终止函数的执行,并且指定函数的返回值
function squre(x) {
return x * x;
}
这段代码展示了 return 的基本用法。它后面可以跟一个表达式,计算结果就是函数返回值。
2.5.7 with 语句
with 语句是个非常巧妙的设计,但它把 JavaScript 的变量引用关系变得不可分析,所以一般都认为这种语句都属于糟粕。
但是历史无法改写,现在已经无法去除 with 了。我们来了解一下它的基本用法即可。
let o = { a: 1, b: 2 };
with (o) {
console.log(a, b);
}
with 语句把对象的属性在它内部的作用域内变成变量。
2.5.8 try 语句和 throw 语句
try 语句和 throw 语句用于处理异常。它们是配合使用的。在大型应用中,异常机制非常重要。
try {
throw new Error('error');
} catch (e) {
console.log(e);
} finally {
console.log('finally');
}
一般来说,throw 用于抛出异常,但是单纯从语言的角度,我们可以抛出任何值,也不一定是异常逻辑,但是为了保证语义清晰,不建议用 throw 表达任何非异常逻辑。
try 语句用于捕获异常,用 throw 抛出的异常,可以在 try 语句的结构中被处理掉:try 部分用于标识捕获异常的代码段,catch 部分则用于捕获异常后做一些处理,而 finally 则是用于执行后做一些必须执行的清理工作。
catch 结构会创建一个局部的作用域,并且把一个变量写入其中,需要注意,在这个作用域,不能再声明变量 e 了,否则会出错。
在 catch 中重新抛出错误的情况非常常见,在设计比较底层的函数时,常常会这样做,保证抛出的错误能被理解。
finally 语句一般用于释放资源,它一定会被执行,我们在前面的课程中已经讨论过一些 finally 的特征,即使在 try 中出现了 return,finally 中的语句也一定要被执行。
2.5.9 debugger 语句
debugger 语句的作用是:通知调试器在此断点。在没有调试器挂载时,它不产生任何效果。
介绍完普通语句,我们再来看看声明型语句。声明型语句跟普通语句最大区别就是声明型语句响应预处理过程,普通语句只有执行过程。
2.5.10 var
var 声明语句是古典的 JavaScript 中声明变量的方式。而现在,在绝大多数情况下,let 和 const 都是更好的选择。
我们在上文已经讲解了 var 声明对全局作用域的影响,它是一种预处理机制。
如果我们仍然想要使用 var,我的个人建议是,把它当做一种“保障变量是局部”的逻辑,遵循以下三条规则:
- 声明同时必定初始化;
- 尽可能在离使用的位置近处声明;
- 不要在意重复声明。
例如:
var x = 1,
y = 2;
doSth(x, y);
for (var x = 0; x < 10; x++) doSth2(x);
这个例子中,两次声明了变量 x,完成了两段逻辑,这两个 x 意义上可能不一定相关,这样,不论我们把代码复制粘贴在哪里,都不会出错。
当然,更好的办法是使用 let 改造,我们看看如何改造:
{
let x = 1,
y = 2;
doSth(x, y);
}
for (let x = 0; x < 10; x++) doSth2(x);
这里我用代码块限制了第一个 x 的作用域,这样就更难发生变量命名冲突引起的错误了。
2.5.11 let 和 const
let 和 const 都是变量的声明,它们的特性非常相似,所以我们放在一起讲了。let 和 const 是新设计的语法,所以没有什么硬伤,非常地符合直觉。let 和 const 的作用范围是 if、for 等结构型语句。
我们看下基本用法:
const a = 2;
if (true) {
const a = 1;
console.log(a);
}
console.log(a);
这里的代码先在全局声明了变量 a,接下来又在 if 内声明了 a,if 内构成了一个独立的作用域。
const 和 let 语句在重复声明时会抛错,这能够有效地避免变量名无意中冲突:
let a = 2;
const a = 1;
这段代码中,先用 let 声明了 a,接下来又试图使用 const 声明变量 a,这时,就会产生错误。
let 和 const 声明虽然看上去是执行到了才会生效,但是实际上,它们还是会被预处理。如果当前作用域内有声明,就无法访问到外部的变量。我们来看这段代码:
const a = 2;
if (true) {
console.log(a); // 抛错
const a = 1;
}
这里在 if 的作用域中,变量 a 声明执行到之前,我们访问了变量 a,这时会抛出一个错误,这说明 const 声明仍然是有预处理机制的。
在执行到 const 语句前,我们的 JavaScript 引擎就已经知道后面的代码将会声明变量 a,从而不允许我们访问外层作用域中的 a。
2.5.12 class 声明
class 最基本的用法只需要 class 关键字、名称和一对大括号。它的声明特征跟 const 和 let 类似,都是作用于块级作用域,预处理阶段则会屏蔽外部变量。
class a {}
const a = 2;
if (true) {
console.log(a); // 抛错
class a {}
}
class 内部,可以使用 constructor 关键字来定义构造函数。还能定义 getter/setter 和方法。
class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea() {
return this.height * this.width;
}
}
这个例子来自 MDN,它展示了构造函数、getter 和方法的定义。
以目前的兼容性,class 中的属性只能写在构造函数中,相关标准正在 TC39 讨论。
需要注意,class 默认内部的函数定义都是 strict 模式的。
2.5.13 函数声明
函数声明使用 function 关键字。
在上文中,我们已经讨论过函数声明对全局作用域的影响了。这里,我们来看看函数声明具体的内容,我们先看一下函数声明的几种类型。
function foo() {}
function* foo() {
yield 1;
yield 2;
yield 3;
}
async function foo() {
await sleep(3000);
}
async function* foo() {
await sleep(3000);
yield 1;
}
带 * 的函数是 generator,生成器函数可以理解为返回一个序列的函数,它的底层是 iterator 机制。
async 函数是可以暂停执行,等待异步操作的函数,它的底层是 Promise 机制。异步生成器函数则是二者的结合。
函数的参数,可以只写形参名,现在还可以写默认参数和指定多个参数,看下例子:
function foo(a = 1, ...other) {
console.log(a, other);
}
这个形式可以代替一些对参数的处理代码,表意会更加清楚。
2.6 表达式
表达式语句实际上就是一个表达式,它是由运算符连接变量或者直接量构成的(关于直接量我们在下一节详细讲解)。
一般来说,我们的表达式语句要么是函数调用,要么是赋值,要么是自增、自减,否则表达式计算的结果没有任何意义。
但是从语法上,并没有这样的限制,任何合法的表达式都可以当做表达式语句使用。比如我们看下面的例子。
a + b;
这句代码计算了 a 和 b 相加的值,但是不会显示出来,也不会产生任何执行效果(除非 a 和 b 是 getter),但是不妨碍它符合语法也能够被执行。
下面我们就一起来了解下都有哪些表达式,我们从粒度最小到粒度最大了解一下。
2.6.1 PrimaryExpression 主要表达式
首先我们来给你讲解一下表达式的原子项:Primary Expression。它是表达式的最小单位,它所涉及的语法结构也是优先级最高的。
Primary Expression 包含了各种“直接量”,直接量就是直接用某种语法写出来的具有特定类型的值。我们已经知道,在运行时有各种值,比如数字 123,字符串 Hello world,所以通俗地讲,直接量就是在代码中把它们写出来的语法。
我们在类型部分,已经介绍过一些基本类型的直接量。比如,我们当时用 null 关键字获取 null 值,这个用法就是 null 直接量,这里我们仅仅把它们简单回顾一下:
'abc';
123;
null;
true;
false;
除这些之外,JavaScript 还能够用直接量的形式定义对象,针对函数、类、数组、正则表达式等特殊对象类型,JavaScript 提供了语法层面的支持。
({});
(function() {});
(class {});
[];
/abc/g;
需要注意,在语法层面,function、{ 和 class 开头的表达式语句与声明语句有语法冲突,所以,我们要想使用这样的表达式,必须加上括号来回避语法冲突。
在 JavaScript 标准中,这些结构有的被称作直接量(Literal),有的被称作表达式(**Expression),在我看来,把它们都理解成直接量比较合适。
Primary Expression 还可以是 this 或者变量,在语法上,把变量称作“标识符引用”。
this;
myVar;
任何表达式加上圆括号,都被认为是 Primary Expression,这个机制使得圆括号成为改变运算优先顺序的手段。
a + b;
这就是 Primary Expression 的几种形式了,接下来,我们讲讲由 Primary Expression 构成的更复杂的表达式:Member Expression。
2.6.2 MemberExpression 成员表达式
Member Expression 通常是用于访问对象成员的。它有几种形式:
a.b;
a["b"];
new.target;
super.b;
前面两种用法都很好理解,就是用标识符的属性访问和用字符串的属性访问。而 new.target 是个新加入的语法,用于判断函数是否是被 new 调用,super 则是构造函数中,用于访问父类的属性的语法。
从名字就可以看出,Member Expression 最初设计是为了属性访问的,不过从语法结构需要,以下两种在 JavaScript 标准中当做 Member Expression:
f`a${b}c`;
这是一个是带函数的模板,这个带函数名的模板表示把模板的各个部分算好后传递给一个函数。
new Cls();
另一个是带参数列表的 new 运算,注意,不带参数列表的 new 运算优先级更低,不属于 Member Expression。
实际上,这两种被放入 Member Expression,仅仅意味着它们跟属性运算属于同一优先级,没有任何语义上的关联。接下来我们看看 Member Expression 能组成什么。
2.6.3 NewExpression NEW 表达式
这种非常简单,Member Expression 加上 new 就是 New Expression(当然,不加 new 也可以构成 New Expression,JavaScript 中默认独立的高优先级表达式都可以构成低优先级表达式)。
注意,这里的 New Expression 特指没有参数列表的表达式。我们看个稍微复杂的例子:
new new Cls(1)();
直观看上去,它可能有两种意思:
new new Cls(1)();
new new Cls()(1);
实际上,它等价于第一种。我们可以用以下代码来验证:
class Cls {
constructor(n) {
console.log('cls', n);
return class {
constructor(n) {
console.log('returned', n);
}
};
}
}
new new Cls(1)();
这段代码最后得到了下面这样的结果。
cls 1
returned undefined
这里就说明了,1 被当做调用 Cls 时的参数传入了。
2.6.4 CallExpression 函数调用表达式
除了 New Expression,Member Expression 还能构成 Call Expression。它的基本形式是 Member Expression 后加一个括号里的参数列表,或者我们可以用上 super 关键字代替 Member Expression。
a.b(c);
super();
这看起来很简单,但是它有一些变体。比如:
a.b(c)(d)(e);
a.b(c)[3];
a.b(c).d;
a.b(c)`xyz`;
这些变体的形态,跟 Member Expression 几乎是一一对应的。实际上,我们可以理解为,Member Expression 中的某一子结构具有函数调用,那么整个表达式就成为了一个 Call Expression。
而 Call Expression 就失去了比 New Expression 优先级高的特性,这是一个主要的区分。
2.6.5 LeftHandSideExpression 左值表达式
接下来,我们需要理解一个概念:New Expression 和 Call Expression 统称 LeftHandSideExpression,左值表达式。
我们直观地讲,左值表达式就是可以放到等号左边的表达式。JavaScript 语法则是下面这样。
a() = b;
这样的用法其实是符合语法的,只是,原生的 JavaScript 函数,返回的值都不能被赋值。因此多数时候,我们看到的赋值将会是 Call Expression 的其它形式,如:
a().c = b;
另外,根据 JavaScript 运行时的设计,不排除某些宿主会提供返回引用类型的函数,这时候,赋值就是有效的了。
左值表达式最经典的用法是用于构成赋值表达式,但是其实如果你翻一翻 JavaScript 标准,你会发现它出现在各种场合,凡是需要“可以被修改的变量”的位置,都能见到它的身影。
那么接下来我们就讲讲 AssignmentExpression 赋值表达式。
2.6.6 AssignmentExpression 赋值表达式
AssignmentExpression 赋值表达式也有多种形态,最基本的当然是使用等号赋值:
a = b;
这里需要理解的一个稍微复杂的概念是,这个等号是可以嵌套的:
a = b = c = d;
这样的连续赋值,是右结合的,它等价于下面这种:
a = b = c = d;
也就是说,先把 d 的结果赋值给 c,再把整个表达式的结果赋值给 b,再赋值给 a。
当然,这并非一个很好的代码风格,我们讲解语法是为了让你理解这样的用法,而不是推荐你这样写代码。
赋值表达式的使用,还可以结合一些运算符,例如:
a += b;
相当于
a = a + b;
能有这样用的运算符有下面这几种:
*=、/=、%=、+=、-=、<<=、>>=、>>>=、&=、^=、|=、**=
我想你已经注意到了,赋值表达式的等号左边和右边能用的表达式类型不一样,我们已经关注完了表达式的左边部分(左值表达式)的语法结构,接下来,我们将会给你重点讲解表达式的右边部分。
在一些通用的计算机语言设计理论中,能够出现在赋值表达式右边的叫做:右值表达式(RightHandSideExpression),而在 JavaScript 标准中,规定了在等号右边表达式叫做条件表达式(ConditionalExpression),不过,在 JavaScript 标准中,从未出现过右值表达式字样。
JavaScript 标准也规定了左值表达式同时都是条件表达式(也就是右值表达式),此外,左值表达式也可以通过跟一定的运算符组合,逐级构成更复杂的结构,直到成为右值表达式。
关于这块的知识,我们有时会看到按照运算符来组织的讲解形式。
这样讲解形式是因为:对运算符来说的“优先级”,如果从我们语法的角度来看,那就是“表达式的结构”。讲“乘法运算的优先级高于加法”,从语法的角度看就是“乘法表达式和加号运算符构成加法表达式”。
对于右值表达式来说,我们可以理解为以左值表达式为最小单位开始构成的,接下来我们就来看看左值表达式是如何一步步构成更为复杂的语法结构。
更新表达式 UpdateExpression
左值表达式搭配 ++
--
运算符,可以形成更新表达式。
--a;
++a;
a--;
a++;
更新表达式会改变一个左值表达式的值。分为前后自增,前后自减一共四种。
我们要注意一下,这里在 ES2018 中,跟早期版本有所不同,前后自增自减运算被放到了同一优先级。
一元运算表达式 UnaryExpression
更新表达式搭配一元运算符,可以形成一元运算表达式,我们看下例子:
delete a.b;
void a;
typeof a;
-a;
~a;
!a;
await a;
它的特点就是一个更新表达式搭配了一个一元运算符。
乘方表达式 ExponentiationExpression
乘方表达式也是由更新表达式构成的。它使用
(++i) ** 30;
2 ** 30 - // 正确
2 ** 30; // 报错
我们看一下例子,-2 这样的一元运算表达式,是不可以放入乘方表达式的,如果需要表达类似的逻辑,必须加括号。
这里我们需要注意一下结合性,** 运算是右结合的,这跟其它正常的运算符(也就是左结合运算符)都不一样。
我们来看一个例子。
4 ** (3 ** 2);
事实上,它是这样被运算的:
4 ** (3 ** 2);
而不是这样被运算的:
(4 ** 3) ** 2;
我们来实际在代码中执行一下试试。最终结果是 262144, 而不是 4096。
乘法表达式 MultiplicativeExpression
到这里,我们进入了比较熟悉的表达式类型,乘方表达式可以构成乘法表达式,用乘号或者除号、取余符号连接就可以了,我们看看例子:
x * 2;
乘法表达式有三种运算符:
*
/
%
它们分别表示乘、除和取余。它们的优先级是一样的,所以统一放在乘法运算表达式中。
加法表达式 AdditiveExpression
加法表达式是由乘法表达式用加号或者减号连接构成的。我们看下例子:
a + b * c;
加法表达式有加号和减号两种运算符。
+
-
这就是我们小学学的加法和减法的意思了。不过要注意,加号还能表示字符串连接,这也比较符合一般的直觉。
移位表达式 ShiftExpression
移位表达式由加法表达式构成,移位是一种位运算,分成三种:
<< 向左移位
>> 向右移位
>>> 无符号向右移位
移位运算把操作数看做二进制表示的整数,然后移动特定位数。所以左移 n 位相当于乘以 2 的 n 次方,右移 n 位相当于除以 2 取整 n 次。
普通移位会保持正负数。无符号移位会把减号视为符号位 1,同时参与移位:
-1 >>> 1;
这个会得到 2147483647,也就是 2 的 31 次方,跟负数的二进制表示法相关,这里就不详细讲解了。
在 JavaScript 中,二进制操作整数并不能提高性能,移位运算这里也仅仅作为一种数学运算存在,这些运算存在的意义也仅仅是照顾 C 系语言用户的习惯了。
关系表达式 RelationalExpression
移位表达式可以构成关系表达式,这里的关系表达式就是大于、小于、大于等于、小于等于等运算符号连接,统称为关系运算。
<=
>=
<
>
instanceof
in
需要注意,这里的 <= 和 >= 关系运算,完全是针对数字的,所以 <= 并不等价于 < 或 ==。例如:
null <= undefined;
//false
null == undefined;
//true
请你务必不要用数学上的定义去理解这些运算符。
相等表达式 EqualityExpression
在语法上,相等表达式是由关系表达式用相等比较运算符(如 ==)连接构成的。所以我们可以像下面这段代码一样使用,而不需要加括号。
a instanceof 'object' == true;
相等表达式由四种运算符和关系表达式构成,我们来看一下运算符:
==
!=
===
!==
相等表达式又包含一个 JavaScript 中著名的设计失误,那就是 == 的行为
一些编程规范甚至要求完全避免使用 == 运算,我觉得这样规定是比较合理的,但是这里我还是尽量解释一下 == 的行为。
虽然标准中写的 == 十分复杂,但是归根结底,类型不同的变量比较时 == 运算只有三条规则:
- undefined 与 null 相等;
- 字符串和 bool 都转为数字再比较;
- 对象转换成 primitive 类型再比较。
这样我们就可以理解一些不太符合直觉的例子了,比如:
false == '0' // true
true == 'true' // false
[] == 0 // true
[] == false // true
new Boolean('false') == false // false
这里不太符合直觉的有两点:
- 一个是即使字符串与 boolean 比较,也都要转换成数字;
- 另一个是对象如果转换成了 primitive 类型跟等号另一边类型恰好相同,则不需要转换成数字。
此外,== 的行为也经常跟 if 的行为也经常跟 if 的行为(转换为 boolean)混淆。总之,我建议,仅在确认 == 发生在 Number 和 String 类型之间时使用,比如:
document.getElementsByTagName('input')[0].value == 100;
在这个例子中,等号左边必然是 string,右边的直接量必然是 number,这样使用 == 就没有问题了。
位运算表达式
位运算表达式含有三种:
- 按位与表达式 BitwiseANDExpression
- 按位异或表达式 BitwiseANDExpression
- 按位或表达式 BitwiseORExpression。
位运算表达式关系比较紧密,我们这里放到一起来讲。
按位与表达式由按位与运算符(&
)连接按位异或表达式构成,按位与表达式把操作数视为二进制整数,然后把两个操作数按位做与运算。
按位异或表达式由按位异或运算符(^
)连接按位与表达式构成,按位异或表达式把操作数视为二进制整数,然后把两个操作数按位做异或运算。异或两位相同时得 0,两位不同时得 1。
异或运算有个特征,那就是两次异或运算相当于取消。所以有一个异或运算的小技巧,就是用异或运算来交换两个整数的值。
let a = 102,
b = 324;
a = a ^ b;
b = a ^ b;
a = a ^ b;
console.log(a, b);
按位或表达式由按位或运算符(|
)连接相等表达式构成,按位或表达式把操作数视为二进制整数,然后把两个操作数按位做或运算。
按位或运算常常被用在一种叫做 Bitmask 的技术上。Bitmask 相当于使用一个整数来当做多个布尔型变量,现在已经不太提倡了。不过一些比较老的 API 还是会这样设计,比如我们在 DOM 课程中,提到过的 Iterator API,我们看下例子:
var iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT, null, false);
var node;
while ((node = iterator.nextNode())) {
console.log(node);
}
这里的第二个参数就是使用了 Bitmask 技术,所以必须配合位运算表达式才能方便地传参。
逻辑与表达式和逻辑或表达式
逻辑与表达式由按位或表达式经过逻辑与运算符连接构成,逻辑或表达式则由逻辑与表达式经逻辑或运算符连接构成。
这里需要注意的是,这两种表达式都不会做类型转换,所以尽管是逻辑运算,但是最终的结果可能是其它类型。
比如:
false || 1;
这句将会得到结果 1。
false && undefined;
这句将会得到 undefined。
另外还有一点,就是逻辑表达式具有短路的特性,例如:
true || foo();
这里的 foo 将不会被执行,这种中断后面表达式执行的特性就叫做短路。
条件表达式 ConditionalExpression
条件表达式由逻辑或表达式和条件运算符构成,条件运算符又称三目运算符,它有三个部分,由两个运算符 ?
和 :
配合使用。
condition ? branch1 : branch2;
这里需要注意,条件表达式也像逻辑表达式一样,可能忽略后面表达式的计算。这一点跟 C 语言的条件表达式是不一样的。
条件表达式实际上就是 JavaScript 中的右值表达式了 RightHandSideExpression,是可以放到赋值运算后面的表达式。
2.6.7 Expression 表达式
赋值表达式可以构成 Expression 表达式的一部分。在 JavaScript 中,表达式就是用逗号运算符连接的赋值表达式。
在 JavaScript 中,比赋值运算优先级更低的就是逗号运算符了。我们可以把逗号可以理解为一种小型的分号。
(a = b), (b = 1), null;
逗号分隔的表达式会顺次执行,就像不同的表达式语句一样。“整个表达式的结果”就是“最后一个逗号后的表达式结果”。比如我们文中的例子,整个“a = b, b = 1, null;”
表达式的结果就是,
后面的null
。
在很多场合,都不允许使用带逗号的表达式,比如我们在前文提到,export 后只能跟赋值表达式,意思就是表达式中不能含有逗号。