0%

ES6 速查笔记

完整 ES6 教程详见 《ES6 标准入门》一书。

ECMA262 官网地址。本文发布时,官网最后更新时间为:2023/10/12


基础 JavaScript 需要注意的内容

this 关键字

绑定 this 的方法

Function.prototype.call()

函数实例的 call 方法,可以指定函数内部 this 的指向(即函数执行时所在的作用域),然后在所指定的作用域中,调用该函数。

1
2
3
4
5
6
7
8
var obj = {};

var f = function () {
return this;
};

f() === window // true
f.call(obj) === obj // true

上面代码中,全局环境运行函数 f 时,this 指向全局环境(浏览器为 window 对象);call 方法可以改变 this 的指向,指定 this 指向对象 obj,然后在对象 obj 的作用域中运行函数f

call 方法的参数,应该是一个对象。如果参数为空、nullundefined,则默认传入全局对象。

1
2
3
4
5
6
7
8
9
10
11
12
var n = 123;
var obj = { n: 456 };

function a() {
console.log(this.n);
}

a.call() // 123
a.call(null) // 123
a.call(undefined) // 123
a.call(window) // 123
a.call(obj) // 456

如果 call 方法的参数是一个原始值,那么这个原始值会自动转成对应的包装对象,然后传入 call 方法。

1
2
3
4
5
6
var f = function () {
return this;
};

f.call(5)
// Number {[[PrimitiveValue]]: 5}

上面代码中,call 的参数为 5,不是对象,会被自动转成包装对象(Number 的实例),绑定 f 内部的 this

call 方法还可以接受多个参数。

1
func.call(thisValue, arg1, arg2, ...)

call 的第一个参数就是 this 所要指向的那个对象,后面的参数则是函数调用时所需的参数。

1
2
3
4
5
function add(a, b) {
return a + b;
}

add.call(this, 1, 2) // 3

上面代码中,call 方法指定函数 add 内部的 this 绑定当前环境(对象),并且参数为 12,因此函数 add 运行后得到 3

Function.prototype.apply()

apply 方法的作用与 call 方法类似,也是改变 this 指向,然后再调用该函数。唯一的区别就是,它接收一个数组作为函数执行时的参数,使用格式如下。

1
func.apply(thisValue, [arg1, arg2, ...])

apply 方法的第一个参数也是 this 所要指向的那个对象,如果设为 nullundefined,则等同于指定全局对象。第二个参数则是一个数组,该数组的所有成员依次作为参数,传入原函数。原函数的参数,在 call 方法中必须一个个添加,但是在 apply 方法中,必须以数组形式添加。

1
2
3
4
5
6
function f(x, y){
console.log(x + y);
}

f.call(null, 1, 1) // 2
f.apply(null, [1, 1]) // 2

上面代码中,f 函数本来接受两个参数,使用 apply 方法以后,就变成可以接受一个数组作为参数。

利用这一点,可以做一些有趣的应用。

(1)找出数组最大元素

JavaScript 不提供找出数组最大元素的函数。结合使用 apply 方法和 Math.max 方法,就可以返回数组的最大元素。

1
2
var a = [10, 2, 4, 15, 9];
Math.max.apply(null, a) // 15

(2)将数组的空元素变为 undefined

通过 apply 方法,利用 Array 构造函数将数组的空元素变成 undefined

1
2
Array.apply(null, ['a', ,'b'])
// [ 'a', undefined, 'b' ]

空元素与 undefined 的差别在于,数组的 forEach 方法会跳过空元素,但是不会跳过 undefined。因此,遍历内部元素的时候,会得到不同的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = ['a', , 'b'];

function print(i) {
console.log(i);
}

a.forEach(print)
// a
// b

Array.apply(null, a).forEach(print)
// a
// undefined
// b

(3)转换类似数组的对象

另外,利用数组对象的 slice 方法,可以将一个类似数组的对象(比如 arguments 对象)转为真正的数组。

1
2
3
4
Array.prototype.slice.apply({0: 1, length: 1}) // [1]
Array.prototype.slice.apply({0: 1}) // []
Array.prototype.slice.apply({0: 1, length: 2}) // [1, undefined]
Array.prototype.slice.apply({length: 1}) // [undefined]

上面代码的 apply 方法的参数都是对象,但是返回结果都是数组,这就起到了将对象转成数组的目的。从上面代码可以看到,这个方法起作用的前提是,被处理的对象必须有 length 属性,以及相对应的数字键。

(4)绑定回调函数的对象

前面的按钮点击事件的例子,可以改写如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = new Object();

o.f = function () {
console.log(this === o);
}

var f = function (){
o.f.apply(o);
// 或者 o.f.call(o);
};

// jQuery 的写法
$('#button').on('click', f);

上面代码中,点击按钮以后,控制台将会显示 true 。由于 apply() 方法(或者 call() 方法)不仅绑定函数执行时所在的对象,还会立即执行函数,因此不得不把绑定语句写在一个函数体内。更简洁的写法是采用下面介绍的 bind() 方法。

Function.prototype.bind()

bind() 方法用于将函数体内的 this 绑定到某个对象,然后返回一个新函数。

1
2
3
4
5
var d = new Date();
d.getTime() // 1481869925657

var print = d.getTime;
print() // Uncaught TypeError: this is not a Date object.

上面代码中,我们将 d.getTime() 方法赋给变量 print,然后调用 print() 就报错了。这是因为 getTime() 方法内部的 this,绑定 Date 对象的实例,赋给变量 print 以后,内部的 this 已经不指向 Date 对象的实例了。

bind() 方法可以解决这个问题。

1
2
var print = d.getTime.bind(d);
print() // 1481869925657

上面代码中,bind() 方法将 getTime() 方法内部的 this 绑定到 d 对象,这时就可以安全地将这个方法赋值给其他变量了。

bind 方法的参数就是所要绑定 this 的对象,下面是一个更清晰的例子。

1
2
3
4
5
6
7
8
9
10
var counter = {
count: 0,
inc: function () {
this.count++;
}
};

var func = counter.inc.bind(counter);
func();
counter.count // 1

上面代码中,counter.inc() 方法被赋值给变量 func。这时必须用 bind() 方法将 inc() 内部的 this,绑定到 counter,否则就会出错。

this 绑定到其他对象也是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
13
var counter = {
count: 0,
inc: function () {
this.count++;
}
};

var obj = {
count: 100
};
var func = counter.inc.bind(obj);
func();
obj.count // 101

上面代码中,bind() 方法将 inc() 方法内部的 this,绑定到 obj 对象。结果调用 func 函数以后,递增的就是 obj 内部的 count 属性。

bind() 还可以接受更多的参数,将这些参数绑定原函数的参数。

1
2
3
4
5
6
7
8
9
10
11
var add = function (x, y) {
return x * this.m + y * this.n;
}

var obj = {
m: 2,
n: 2
};

var newAdd = add.bind(obj, 5);
newAdd(5) // 20

上面代码中,bind() 方法除了绑定 this 对象,还将 add() 函数的第一个参数 x 绑定成 5,然后返回一个新函数 newAdd(),这个函数只要再接受一个参数 y 就能运行了。

如果 bind() 方法的第一个参数是 nullundefined,等于将 this 绑定到全局对象,函数运行时 this 指向顶层对象(浏览器为 window)。

1
2
3
4
5
6
function add(x, y) {
return x + y;
}

var plus5 = add.bind(null, 5);
plus5(10) // 15

上面代码中,函数 add() 内部并没有 this,使用 bind() 方法的主要目的是绑定参数 x,以后每次运行新函数 plus5(),就只需要提供另一个参数 y 就够了。而且因为 add() 内部没有 this,所以 bind() 的第一个参数是 null,不过这里如果是其他对象,也没有影响。

bind() 方法有一些使用注意点。

(1)每一次返回一个新函数

bind() 方法每运行一次,就返回一个新函数,这会产生一些问题。比如,监听事件的时候,不能写成下面这样。

1
element.addEventListener('click', o.m.bind(o));

上面代码中,click 事件绑定 bind() 方法生成的一个匿名函数。这样会导致无法取消绑定,所以下面的代码是无效的。

1
element.removeEventListener('click', o.m.bind(o));

正确的方法是写成下面这样:

1
2
3
4
var listener = o.m.bind(o);
element.addEventListener('click', listener);
// ...
element.removeEventListener('click', listener);

(2)结合回调函数使用

回调函数是 JavaScript 最常用的模式之一,但是一个常见的错误是,将包含 this 的方法直接当作回调函数。解决方法就是使用 bind() 方法,将 counter.inc() 绑定 counter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var counter = {
count: 0,
inc: function () {
'use strict';
this.count++;
}
};

function callIt(callback) {
callback();
}

callIt(counter.inc.bind(counter));
counter.count // 1

上面代码中,callIt() 方法会调用回调函数。这时如果直接把 counter.inc 传入,调用时 counter.inc() 内部的 this 就会指向全局对象。使用 bind() 方法将 counter.inc 绑定 counter 以后,就不会有这个问题,this 总是指向 counter

还有一种情况比较隐蔽,就是某些数组方法可以接受一个函数当作参数。这些函数内部的 this 指向,很可能也会出错。

1
2
3
4
5
6
7
8
9
10
11
12
var obj = {
name: '张三',
times: [1, 2, 3],
print: function () {
this.times.forEach(function (n) {
console.log(this.name);
});
}
};

obj.print()
// 没有任何输出

上面代码中,obj.print 内部 this.timesthis 是指向 obj 的,这个没有问题。但是,forEach() 方法的回调函数内部的 this.name 却是指向全局对象,导致没有办法取到值。稍微改动一下,就可以看得更清楚。

1
2
3
4
5
6
7
8
9
10
obj.print = function () {
this.times.forEach(function (n) {
console.log(this === window);
});
};

obj.print()
// true
// true
// true

解决这个问题,也是通过 bind() 方法绑定 this

1
2
3
4
5
6
7
8
9
10
obj.print = function () {
this.times.forEach(function (n) {
console.log(this.name);
}.bind(this));
};

obj.print()
// 张三
// 张三
// 张三

(3)结合 call() 方法使用

利用 bind() 方法,可以改写一些 JavaScript 原生方法的使用形式,以数组的 slice() 方法为例。

1
2
3
[1, 2, 3].slice(0, 1) // [1]
// 等同于
Array.prototype.slice.call([1, 2, 3], 0, 1) // [1]

上面的代码中,数组的 slice 方法从 [1, 2, 3] 里面,按照指定的开始位置和结束位置,切分出另一个数组。这样做的本质是在 [1, 2, 3] 上面调用 Array.prototype.slice() 方法,因此可以用 call 方法表达这个过程,得到同样的结果。

call() 方法实质上是调用 Function.prototype.call() 方法,因此上面的表达式可以用 bind() 方法改写。

1
2
var slice = Function.prototype.call.bind(Array.prototype.slice);
slice([1, 2, 3], 0, 1) // [1]

上面代码的含义就是,将 Array.prototype.slice 变成 Function.prototype.call 方法所在的对象,调用时就变成了 Array.prototype.slice.call。类似的写法还可以用于其他数组方法。

1
2
3
4
5
6
7
8
9
var push = Function.prototype.call.bind(Array.prototype.push);
var pop = Function.prototype.call.bind(Array.prototype.pop);

var a = [1 ,2 ,3];
push(a, 4)
a // [1, 2, 3, 4]

pop(a)
a // [1, 2, 3]

如果再进一步,将 Function.prototype.call 方法绑定到 Function.prototype.bind 对象,就意味着 bind 的调用形式也可以被改写。

1
2
3
4
5
6
7
function f() {
console.log(this.v);
}

var o = { v: 123 };
var bind = Function.prototype.call.bind(Function.prototype.bind);
bind(f, o)() // 123

上面代码的含义就是,将 Function.prototype.bind 方法绑定在 Function.prototype.call 上面,所以 bind 方法就可以直接使用,不需要在函数实例上使用。

setTimeout 和 setInterval 的运行机制

setTimeoutsetInterval 的运行机制,是将指定的代码移出本轮事件循环,等到下一轮事件循环,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就继续等待。

这意味着,setTimeoutsetInterval 指定的回调函数,必须等到本轮事件循环的所有同步任务都执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeoutsetInterval 指定的任务,一定会按照预定时间执行。

1
2
setTimeout(someTask, 100);
veryLongTask();

上面代码的 setTimeout,指定100毫秒以后运行一个任务。但是,如果后面的 veryLongTask 函数(同步任务)运行时间非常长,过了100毫秒还无法结束,那么被推迟运行的 someTask 就只有等着,等到 veryLongTask 运行结束,才轮到它执行。

再看一个 setInterval 的例子。

1
2
3
4
5
6
7
8
9
10
11
setInterval(function () {
console.log(2);
}, 1000);

sleep(3000);

function sleep(ms) {
var start = Date.now();
while ((Date.now() - start) < ms) {
}
}

上面代码中,setInterval 要求每隔1000毫秒,就输出一个2。但是,紧接着的sleep语句需要3000毫秒才能完成,那么 setInterval 就必须推迟到3000毫秒之后才开始生效。注意,生效后 setInterval 不会产生累积效应,即不会一下子输出三个 2,而是只会输出一个 2

setTimeout(f, 0)

含义

setTimeout 的作用是将代码推迟到指定时间执行,如果指定时间为 0,即 setTimeout(f, 0),那么会立刻执行吗?

答案是不会。因为刚才说过,必须要等到当前脚本的同步任务,全部处理完以后,才会执行 setTimeout 指定的回调函数 f。也就是说,setTimeout(f, 0) 会在下一轮事件循环一开始就执行。

1
2
3
4
5
6
setTimeout(function () {
console.log(1);
}, 0);
console.log(2);
// 2
// 1

上面代码先输出 2,再输出 1。因为 2 是同步任务,在本轮事件循环执行,而 1 是下一轮事件循环执行。

总之,setTimeout(f, 0) 这种写法的目的是,尽可能早地执行 f,但是并不能保证立刻就执行 f

实际上,setTimeout(f, 0) 不会真的在0毫秒之后运行,不同的浏览器有不同的实现。以 Edge 浏览器为例,会等到4毫秒之后运行。如果电脑正在使用电池供电,会等到16毫秒之后运行;如果网页不在当前 Tab 页,会推迟到1000毫秒(1秒)之后运行。这样是为了节省系统资源。

应用

setTimeout(f, 0) 有几个非常重要的用途。它的一大应用是,可以调整事件的发生顺序。比如,网页开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的事件回调函数触发。如果,想让父元素的事件回调函数先发生,就要用到 setTimeout(f, 0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// HTML 代码如下
// <input type="button" id="myButton" value="click">

var input = document.getElementById('myButton');

input.onclick = function A() {
setTimeout(function B() {
input.value +=' input';
}, 0)
};

document.body.onclick = function C() {
input.value += ' body'
};

上面代码在点击按钮后,先触发回调函数 A,然后触发函数 C。函数 A 中,setTimeout 将函数 B 推迟到下一轮事件循环执行,这样就起到了,先触发父元素的回调函数 C 的目的了。

另一个应用是,用户自定义的回调函数,通常在浏览器的默认动作之前触发。比如,用户在输入框输入文本,keypress 事件会在浏览器接收文本之前触发。因此,下面的回调函数是达不到目的的。

1
2
3
4
5
6
// HTML 代码如下
// <input type="text" id="input-box">

document.getElementById('input-box').onkeypress = function (event) {
this.value = this.value.toUpperCase();
}

上面代码想在用户每次输入文本后,立即将字符转为大写。但是实际上,它只能将本次输入前的字符转为大写,因为浏览器此时还没接收到新的文本,所以 this.value 取不到最新输入的那个字符。只有用 setTimeout 改写,上面的代码才能发挥作用。

1
2
3
4
5
6
document.getElementById('input-box').onkeypress = function() {
var self = this;
setTimeout(function() {
self.value = self.value.toUpperCase();
}, 0);
}

上面代码将代码放入setTimeout之中,就能使得它在浏览器接收到文本之后触发。

由于 setTimeout(f, 0) 实际上意味着,将任务放到浏览器最早可得的空闲时段执行,所以那些计算量大、耗时长的任务,常常会被放到几个小部分,分别放到 setTimeout(f, 0) 里面执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var div = document.getElementsByTagName('div')[0];

// 写法一
for (var i = 0xA00000; i < 0xFFFFFF; i++) {
div.style.backgroundColor = '#' + i.toString(16);
}

// 写法二
var timer;
var i=0x100000;

function func() {
timer = setTimeout(func, 0);
div.style.backgroundColor = '#' + i.toString(16);
if (i++ == 0xFFFFFF) clearTimeout(timer);
}

timer = setTimeout(func, 0);

上面代码有两种写法,都是改变一个网页元素的背景色。写法一会造成浏览器“堵塞”,因为 JavaScript 执行速度远高于 DOM,会造成大量 DOM 操作“堆积”,而写法二就不会,这就是 setTimeout(f, 0) 的好处。

另一个使用这种技巧的例子是代码高亮的处理。如果代码块很大,一次性处理,可能会对性能造成很大的压力,那么将其分成一个个小块,一次处理一块,比如写成 setTimeout(highlightNext, 50) 的样子,性能压力就会减轻。

闭包

JavaScript 语言特有的”链式作用域”结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见的,反之则不成立。

由于在 JavaScript 语言中,只有函数内部的子函数才能读取内部变量,因此可以把闭包简单理解成“定义在一个函数内部的函数”。闭包最大的特点,就是它可以“记住”诞生的环境。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。

闭包的最大用处有两个,一个是可以读取外层函数内部的变量,另一个就是让这些变量始终保持在内存中,即闭包可以使得它诞生环境一直存在。

请看下面的例子,闭包使得内部变量记住上一次调用时的运算结果。

1
2
3
4
5
6
7
8
9
10
11
function createIncrementor(start) {
return function () {
return start++;
};
}

var inc = createIncrementor(5);

inc() // 5
inc() // 6
inc() // 7

上面代码中,start 是函数 createIncrementor 的内部变量。通过闭包,start 的状态被保留了,每一次调用都是在上一次调用的基础上进行计算。从中可以看到,闭包 inc 使得函数 createIncrementor 的内部环境,一直存在。所以,闭包可以看作是函数内部作用域的一个接口。

为什么闭包能够返回外层函数的内部变量?原因是闭包(上例的 inc)用到了外层变量(start),导致外层函数(createIncrementor)不能从内存释放。只要闭包没有被垃圾回收机制清除,外层函数提供的运行环境也不会被清除,它的内部变量就始终保存着当前值,供闭包读取。

闭包的另一个用处,是封装对象的私有属性和私有方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Person(name) {
var _age;
function setAge(n) {
_age = n;
}
function getAge() {
return _age;
}

return {
name: name,
getAge: getAge,
setAge: setAge
};
}

var p1 = Person('张三');
p1.setAge(25);
p1.getAge() // 25

上面代码中,函数 Person 的内部变量 _age,通过闭包 getAgesetAge,变成了返回对象 p1 的私有变量。

注意,外层函数每次运行,都会生成一个新的闭包,而这个闭包又会保留外层函数的内部变量,所以内存消耗很大。因此不能滥用闭包,否则会造成网页的性能问题。

立即调用的函数表达式(IIFE)

根据 JavaScript 的语法,圆括号 () 跟在函数名之后,表示调用该函数。比如,print() 就表示调用 print 函数。

有时,我们需要在定义函数之后,立即调用该函数。这时,你不能在函数的定义之后加上圆括号,这会产生语法错误。

1
2
function(){ /* code */ }();
// SyntaxError: Unexpected token (

产生这个错误的原因是,function 这个关键字既可以当作语句,也可以当作表达式。

1
2
3
4
5
// 语句
function f() {}

// 表达式
var f = function f() {}

当作表达式时,函数可以定义后直接加圆括号调用。

1
2
var f = function f(){ return 1}();
f // 1

上面的代码中,函数定义后直接加圆括号调用,没有报错。原因就是 function 作为表达式,引擎就把函数定义当作一个值。这种情况下,就不会报错。

为了避免解析的歧义,JavaScript 规定,如果 function 关键字出现在行首,一律解释成语句。因此,引擎看到行首是 function 关键字之后,认为这一段都是函数的定义,不应该以圆括号结尾,所以就报错了。

函数定义后立即调用的解决方法,就是不要让 function 出现在行首,让引擎将其理解成一个表达式。最简单的处理,就是将其放在一个圆括号里面。

1
2
3
(function(){ /* code */ }());
// 或者
(function(){ /* code */ })();

上面两种写法都是以圆括号开头,引擎就会认为后面跟的是一个表达式,而不是函数定义语句,所以就避免了错误。这就叫做“立即调用的函数表达式”(Immediately-Invoked Function Expression),简称 IIFE。

注意,上面两种写法最后的分号都是必须的。如果省略分号,遇到连着两个 IIFE,可能就会报错。

1
2
3
// 报错
(function(){ /* code */ }())
(function(){ /* code */ }())

上面代码的两行之间没有分号,JavaScript 会将它们连在一起解释,将第二行解释为第一行的参数。

推而广之,任何让解释器以表达式来处理函数定义的方法,都能产生同样的效果,比如下面三种写法。

1
2
3
var i = function(){ return 10; }();
true && function(){ /* code */ }();
0, function(){ /* code */ }();

甚至像下面这样写,也是可以的。

1
2
3
4
!function () { /* code */ }();
~function () { /* code */ }();
-function () { /* code */ }();
+function () { /* code */ }();

通常情况下,只对匿名函数使用这种“立即执行的函数表达式”。它的目的有两个:一是不必为函数命名,避免了污染全局变量;二是 IIFE 内部形成了一个单独的作用域,可以封装一些外部无法读取的私有变量。

1
2
3
4
5
6
7
8
9
10
11
// 写法一
var tmp = newData;
processData(tmp);
storeData(tmp);

// 写法二
(function () {
var tmp = newData;
processData(tmp);
storeData(tmp);
}());

上面代码中,写法二比写法一更好,因为完全避免了污染全局变量。


ES6 数据类型

  • 数值(number):整数和小数(比如 13.14)。
  • 字符串(string):文本(比如 Hello World)。
  • 布尔值(boolean):表示真伪的两个特殊值,即 true (真)和 false (假)。
  • undefined:表示“未定义”或不存在,即由于目前没有定义,所以此处暂时没有任何值。
  • null:表示空值,即此处的值为空。
  • 对象(Object):各种值组成的集合。
  • 数组(Array)
  • ES6 新增:Map
  • ES6 新增:Set
  • ES6 新增:大整数(BigInt)
  • ES6 新增:Symbol

数值字符串布尔值Symbol 这四种类型,合称为原始数据类型(primitive type),即它们是最基本的数据类型,不能再细分了。

对象则称为合成类型(complex type),因为一个对象往往是多个原始类型的值的合成,可以看作是一个存放各种值的容器。

undefinednull,一般将它们看成两个特殊值。

块级作用域与函数声明

  • 避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。
  • ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。

示例:块级作用域内声明函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 块级作用域内部的函数声明语句,建议不要使用
{
let a = 'secret';
function f() {
return a;
}
}

// 块级作用域内部,优先使用函数表达式
{
let a = 'secret';
let f = function () {
return a;
};
}

示例:块级作用域

1
2
3
4
5
6
7
// 第一种写法,报错
if (true) let x = 1;

// 第二种写法,不报错
if (true) {
let x = 1;
}

let 命令

主要特点

  • 只在 let 命令所在的代码块内有效
  • 不存在变量提升。var 命令会发生“变量提升”现象,即变量可以在声明之前使用,值为 undefined。而 let 则不会。
  • 暂时性死区。只要块级作用域内存在 let 命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。
  • 不允许在相同作用域内,重复声明同一个变量。

基本用法

for 循环的计数器,就很合适使用 let 命令。

1
2
3
4
5
6
for (let i = 0; i < 10; i++) {
// ...
}

console.log(i);
// ReferenceError: i is not defined

对于 for 循环的计数器,使用 letvar 是有很大区别的,它们的区别可能是就是产生 bug 的原因,请看下面两个示例:

1
2
3
4
5
6
7
8
var a = [];
for (var i = 0; i < 10; i++) { // 此处使用了的是 var
a[i] = function () {
console.log(i);
};
}
a[5](); // 10
a[6](); // 10

上面代码中,变量 ivar 命令声明的,在全局范围内都有效,所以全局只有一个变量 i。每一次循环,变量 i 的值都会发生改变,而循环内被赋给数组 a 的函数内部的 console.log(i),里面的 i 指向的就是全局的 i。也就是说,所有数组 a 的成员里面的 i,指向的都是同一个 i,导致运行时输出的 i 值都是 10

再来看一个示例:

1
2
3
4
5
6
7
8
var a = [];
for (let i = 0; i < 10; i++) { // 此处使用了的是 let
a[i] = function () {
console.log(i);
};
}
a[5](); // 5
a[6](); // 6

上面代码中,变量 ilet 声明的,当前的 i 只在本轮循环有效,所以每一次循环的 i 其实都是一个新的变量,所以最后输出结果和预期是一样的。

你可能会问,如果每一轮循环的变量 i 都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量 i 时,就在上一轮循环的基础上进行计算。

另外,for 循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。

1
2
3
4
5
6
7
for (let i = 0; i < 3; i++) {
let i = 'abc';
console.log(i);
}
// abc
// abc
// abc

上面代码正确运行,输出了 3 次 abc。这表明函数内部的变量 i 与循环变量 i 不在同一个作用域,有各自单独的作用域(同一个作用域不可使用 let 重复声明同一个变量)。

不存在变量提升

1
2
3
4
5
6
7
// var 的情况
console.log(foo); // 输出undefined
var foo = 2;

// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;

暂时性死区

在代码块内,使用 let 命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。

只要块级作用域内存在 let 命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。

ES6 明确规定,如果区块中存在 letconst 命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

1
2
3
4
5
6
var tmp = 123;

if (true) {
tmp = 'abc'; // ReferenceError
let tmp;
}

上面代码中,存在全局变量 tmp,但是块级作用域内 let 又声明了一个局部变量 tmp,导致后者绑定这个块级作用域,所以在 let 声明变量前,对 tmp 赋值会报错。

不允许重复声明

let 不允许在相同作用域内,重复声明同一个变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 报错
function func() {
let a = 10;
var a = 1;
}

// 报错
function func() {
let a = 10;
let a = 1;
}

// 不同作用域可以使用同名变量。
// 以下代码可以正常运行
function f1() {
let n = 5;
if (true) {
let n = 10;
}
console.log(n); // 5
}

因此,不能在函数内部重新声明参数。

1
2
3
4
5
6
7
8
9
10
11
function func(arg) {
let arg;
}
func() // 报错

function func(arg) {
{
let arg;
}
}
func() // 不报错

const 命令

主要特点

  • 声明一个只读的常量。一旦声明,常量的值就不能改变。
  • 声明的同时必须立即初始化,不能留到以后赋值。只声明不赋值,就会报错。
  • const 的作用域与 let 命令相同:只在声明所在的块级作用域内有效。
  • 同样不存在变量提升,同样存在暂时性死区,只能在声明的位置后面使用。
  • 不可重复声明。

本质

const 实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。

对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const 只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。

ES6 声明变量的六种方法

  • var 命令
  • function 命令

以下是 ES6 新增方式:

  • let 命令
  • const 命令
  • import 命令
  • class 命令

顶层对象的属性

主要特点:

  • var 命令和 function 命令声明的全局变量,依旧是顶层对象的属性。
  • let 命令、const 命令、class 命令声明的全局变量,不属于顶层对象的属性。

顶层对象,在浏览器环境指的是 window 对象,在 Node 指的是 global 对象。ES5 之中,顶层对象的属性与全局变量是等价的。

1
2
3
4
5
window.a = 1;
a // 1

a = 2;
window.a // 2

上面代码中,顶层对象的属性赋值与全局变量的赋值,是同一件事。

顶层对象的属性与全局变量挂钩,被认为是 JavaScript 语言最大的设计败笔之一。这样的设计带来了几个很大的问题,首先是没法在编译时就报出变量未声明的错误,只有运行时才能知道(因为全局变量可能是顶层对象的属性创造的,而属性的创造是动态的);其次,程序员很容易不知不觉地就创建了全局变量(比如打字出错);最后,顶层对象的属性是到处可以读写的,这非常不利于模块化编程。另一方面,window 对象有实体含义,指的是浏览器的窗口对象,顶层对象是一个有实体含义的对象,也是不合适的。

ES6 为了改变这一点,一方面规定,为了保持兼容性,var 命令和 function 命令声明的全局变量,依旧是顶层对象的属性;另一方面规定,let 命令、const 命令、class 命令声明的全局变量,不属于顶层对象的属性。也就是说,从 ES6 开始,全局变量将逐步与顶层对象的属性脱钩。

1
2
3
4
5
6
7
var a = 1;
// 如果在 Node 的 REPL 环境,可以写成 global.a
// 或者采用通用方法,写成 this.a
window.a // 1

let b = 1;
window.b // undefined

上面代码中,全局变量 avar 命令声明,所以它是顶层对象的属性;全局变量 blet 命令声明,所以它不是顶层对象的属性,返回 undefined

globalThis 对象

JavaScript 语言存在一个顶层对象,它提供全局环境(即全局作用域),所有代码都是在这个环境中运行。但是,顶层对象在各种实现里面是不统一的。

  • 浏览器里面,顶层对象是 window,但 Node 和 Web Worker 没有 window
  • 浏览器和 Web Worker 里面,self 也指向顶层对象,但是 Node 没有 self
  • Node 里面,顶层对象是 global,但其他环境都不支持。

同一段代码为了能够在各种环境,都能取到顶层对象,现在一般是使用 this 关键字,但是有局限性。

  • 全局环境中,this 会返回顶层对象。但是,Node.js 模块中 this 返回的是当前模块,ES6 模块中 this 返回的是 undefined
  • 函数里面的 this,如果函数不是作为对象的方法运行,而是单纯作为函数运行,this 会指向顶层对象。但是,严格模式下,这时 this 会返回 undefined
  • 不管是严格模式,还是普通模式,new Function('return this')(),总是会返回全局对象。但是,如果浏览器用了 CSP(Content Security Policy,内容安全策略),那么 evalnew Function 这些方法都可能无法使用。

综上所述,很难找到一种方法,可以在所有情况下,都取到顶层对象。下面是两种勉强可以使用的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 方法一
(typeof window !== 'undefined'
? window
: (typeof process === 'object' &&
typeof require === 'function' &&
typeof global === 'object')
? global
: this);

// 方法二
var getGlobal = function () {
if (typeof self !== 'undefined') { return self; }
if (typeof window !== 'undefined') { return window; }
if (typeof global !== 'undefined') { return global; }
throw new Error('unable to locate global object');
};

ES2020 在语言标准的层面,引入 globalThis 作为顶层对象。也就是说,任何环境下,globalThis 都是存在的,都可以从它拿到顶层对象,指向全局环境下的 this

解构

概要

含义

从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

主要特点

  • 解构赋值的匹配方式为“模式匹配”。等号两边要么模式相同,要么属性名(即 ‘key’)相同。

  • 只要有可能,就不要在“模式”中放置圆括号。以下情况不能在“模式”中使用圆括号:

    • 变量声明语句不能使用圆括号
    • 函数参数不能使用圆括号
    • 赋值语句的模式不能使用圆括号
  • 只有赋值语句的非模式部分,可以使用圆括号。

  • 只要等号右边的值不是对象或数组,就先将其转为对象。由于 undefinednull 无法转为对象,所以对它们进行解构赋值,都会报错。

  • 解构赋值允许指定默认值。当数组成员或属性名严格等于 undefined,默认值才会生效。

  • 解构不成功,变量的值就等于 undefined

数组的解构赋值

  • 数组的元素是按次序排列的,数组的解构其变量的取值由它的位置决定。

  • 只要等号两边的模式相同,左边的变量就会被赋予对应的值。

  • 事实上,只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。

  • 如果解构不成功,变量的值就等于 undefined

  • 解构赋值允许指定默认值。ES6 内部使用严格相等运算符(===),判断一个位置是否有值。所以,只有当一个数组成员严格等于 undefined,默认值才会生效。如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到的时候,才会求值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
let [a, b, c] = [1, 2, 3];

let [foo, [[bar], baz]] = [1, [[2], 3]];
foo // 1
bar // 2
baz // 3

let [ , , third] = ["foo", "bar", "baz"];
third // "baz"

let [x, , y] = [1, 2, 3];
x // 1
y // 3

let [head, ...tail] = [1, 2, 3, 4];
head // 1
tail // [2, 3, 4]

let [x, y, ...z] = ['a'];
x // "a"
y // undefined
z // []

// 以下是不完全解构的示例
let [x, y] = [1, 2, 3];
x // 1
y // 2

let [a, [b], d] = [1, [2, 3], 4];
a // 1
b // 2
d // 4

// 对于 Set 结构,也可以使用数组的解构赋值。
let [x, y, z] = new Set(['a', 'b', 'c']);
x // "a"
y // "b"
z // "c"

// 只有当一个数组成员严格等于 `undefined`,默认值才会生效。
let [x = 1] = [undefined];
x // 1

let [x = 1] = [null];
x // null

// 如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到的时候,才会求值。
// 以下代码因为 x 能取到值,所以函数 f 根本不会执行。
function f() {
console.log('aaa');
}

let [x = f()] = [1];

// 默认值可以引用解构赋值的其他变量,但该变量必须已经声明。
let [x = 1, y = x] = []; // x=1; y=1
let [x = 1, y = x] = [2]; // x=2; y=2
let [x = 1, y = x] = [1, 2]; // x=1; y=2
let [x = y, y = 1] = []; // ReferenceError: y is not defined

对象的解构赋值

主要特点

  • 对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
  • 若没有同名属性,则值为 undefined。除非指定别名,例如:let { foo: f, bar: b } = { foo: 'aaa', bar: 'bbb' }; 其结果是 f 的值为 aaab 的值为 bbb
  • 可以很方便地将现有对象的方法,赋值到某个变量。
  • 允许指定默认值。默认值生效的条件是,对象的属性值严格等于 undefined

注意点

  1. 如果要将一个已经声明的变量用于解构赋值,必须非常小心。
1
2
3
4
// 错误的写法
let x;
{x} = {x: 1};
// SyntaxError: syntax error

上面代码的写法会报错,因为 JavaScript 引擎会将 {x} 理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免 JavaScript 将其解释为代码块,才能解决这个问题。

1
2
3
// 正确的写法
let x;
({x} = {x: 1});

上面代码将整个解构赋值语句,放在一个圆括号里面,就可以正确执行。关于圆括号与解构赋值的关系,参见下文。

  1. 由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。
1
2
3
4
let arr = [1, 2, 3];
let {0 : first, [arr.length - 1] : last} = arr;
first // 1
last // 3

上面代码对数组进行对象解构。数组 arr0 键对应的值是 1[arr.length - 1] 就是 2 键,对应的值是 3。方括号这种写法,属于“属性名表达式”。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
let { foo, bar } = { foo: 'aaa', bar: 'bbb' };
foo // "aaa"
bar // "bbb"

let { baz } = { foo: 'aaa', bar: 'bbb' };
baz // undefined

// 将现有对象的方法,赋值到某个变量
let { log, sin, cos } = Math;

const { log } = console;
log('hello') // hello

// 如果变量名与属性名不一致,必须写成下面这样。
let { foo: baz } = { foo: 'aaa', bar: 'bbb' };
baz // "aaa"

let obj = { first: 'hello', last: 'world' };
let { first: f, last: l } = obj;
f // 'hello'
l // 'world'

// 与数组一样,解构也可以用于嵌套结构的对象。
let obj = {
p: [
'Hello',
{ y: 'World' }
]
};

let { p: [x, { y }] } = obj;
x // "Hello"
y // "World"

// 注意,这时p是模式,不是变量,因此不会被赋值。如果p也要作为变量赋值,可以写成下面这样。
let obj = {
p: [
'Hello',
{ y: 'World' }
]
};

let { p, p: [x, { y }] } = obj;
x // "Hello"
y // "World"
p // ["Hello", {y: "World"}]

// 下面是另一个例子
const node = {
loc: {
start: {
line: 1,
column: 5
}
}
};

let { loc, loc: { start }, loc: { start: { line }} } = node;
line // 1
loc // Object {start: Object}
start // Object {line: 1, column: 5}

// 对象的解构也可以指定默认值
var {x = 3} = {};
x // 3

var {x, y = 5} = {x: 1};
x // 1
y // 5

var {x: y = 3} = {};
y // 3

var {x: y = 3} = {x: 5};
y // 5

var { message: msg = 'Something went wrong' } = {};
msg // "Something went wrong"

// 默认值生效的条件是,对象的属性值严格等于undefined
var {x = 3} = {x: undefined};
x // 3

var {x = 3} = {x: null};
x // null

字符串的解构赋值

字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。

1
2
3
4
5
6
const [a, b, c, d, e] = 'hello';
a // "h"
b // "e"
c // "l"
d // "l"
e // "o"

类似数组的对象都有一个 length 属性,因此还可以对这个属性解构赋值。

1
2
let {length : len} = 'hello';
len // 5

数值和布尔值的解构赋值

主要特点

  • 解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
1
2
3
4
5
let {toString: s} = 123;
s === Number.prototype.toString // true

let {toString: s} = true;
s === Boolean.prototype.toString // true

上面代码中,数值和布尔值的包装对象都有 toString 属性,因此变量 s 都能取到值。

函数参数的解构赋值

主要特点:

  • 可以对函数的参数进行解构赋值。
  • 同样可以使用默认值。
  • 同样的,undefined 才会触发函数参数的默认值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 参数为数组
function add([x, y]){
return x + y;
}

add([1, 2]); // 3

// 参数为数组,此时对 map 的参数解构时,需使用圆括号括起来
[[1, 2], [3, 4]].map(([a, b]) => a + b);
// [ 3, 7 ]

// 参数为对象
function move({x = 0, y = 0} = {}) {
return [x, y];
}

move({x: 3, y: 8}); // [3, 8]
move({x: 3}); // [3, 0]
move({}); // [0, 0]
move(); // [0, 0]

// 注意:下面的写法与上面的写法稍有不同,但结果却不一样
function move({x, y} = { x: 0, y: 0 }) {
return [x, y];
}

move({x: 3, y: 8}); // [3, 8]
move({x: 3}); // [3, undefined]
move({}); // [undefined, undefined]
move(); // [0, 0]

圆括号问题

建议:只要有可能,就不要在模式中放置圆括号。

不能使用圆括号的情况

以下三种解构赋值不得使用圆括号。

(1)变量声明语句

1
2
3
4
5
6
7
8
9
// 全部报错
let [(a)] = [1];

let {x: (c)} = {};
let ({x: c}) = {};
let {(x: c)} = {};
let {(x): c} = {};

let { o: ({ p: p }) } = { o: { p: 2 } };

上面 6 个语句都会报错,因为它们都是变量声明语句,模式不能使用圆括号。

(2)函数参数

函数参数也属于变量声明,因此不能带有圆括号。

1
2
3
4
// 报错
function f([(z)]) { return z; }
// 报错
function f([z,(x)]) { return x; }

(3)赋值语句的模式

1
2
3
// 全部报错
({ p: a }) = { p: 42 };
([a]) = [5];

上面代码将整个模式放在圆括号之中,导致报错。

1
2
// 报错
[({ p: a }), { x: c }] = [{}, {}];

上面代码将一部分模式放在圆括号之中,导致报错。

可以使用圆括号的情况

可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。

1
2
3
[(b)] = [3]; // 正确
({ p: (d) } = {}); // 正确
[(parseInt.prop)] = [3]; // 正确

上面三行语句都可以正确执行,因为首先它们都是赋值语句,而不是声明语句;其次它们的圆括号都不属于模式的一部分。第一行语句中,模式是取数组的第一个成员,跟圆括号无关;第二行语句中,模式是 p,而不是 d;第三行语句与第一行语句的性质一致。

用途

交换变量的值

1
2
3
4
let x = 1;
let y = 2;

[x, y] = [y, x];

从函数返回多个值

函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 返回一个数组

function example() {
return [1, 2, 3];
}
let [a, b, c] = example();

// 返回一个对象

function example() {
return {
foo: 1,
bar: 2
};
}
let { foo, bar } = example();

函数参数的定义

解构赋值可以方便地将一组参数与变量名对应起来。

1
2
3
4
5
6
7
// 参数是一组有次序的值
function f([x, y, z]) { ... }
f([1, 2, 3]);

// 参数是一组无次序的值
function f({x, y, z}) { ... }
f({z: 3, y: 2, x: 1});

提取 JSON 数据

解构赋值对提取 JSON 对象中的数据,尤其有用。

1
2
3
4
5
6
7
8
9
10
let jsonData = {
id: 42,
status: "OK",
data: [867, 5309]
};

let { id, status, data: number } = jsonData;

console.log(id, status, number);
// 42, "OK", [867, 5309]

上面代码可以快速提取 JSON 数据的值。

函数参数的默认值

1
2
3
4
5
6
7
8
9
10
11
jQuery.ajax = function (url, {
async = true,
beforeSend = function () {},
cache = true,
complete = function () {},
crossDomain = false,
global = true,
// ... more config
} = {}) {
// ... do stuff
};

指定参数的默认值,就避免了在函数体内部再写 var foo = config.foo || 'default foo'; 这样的语句。

遍历 Map 结构

任何部署了 Iterator 接口的对象,都可以用 for...of 循环遍历。Map 结构原生支持 Iterator 接口,配合变量的解构赋值,获取键名和键值就非常方便。

1
2
3
4
5
6
7
8
9
const map = new Map();
map.set('first', 'hello');
map.set('second', 'world');

for (let [key, value] of map) {
console.log(key + " is " + value);
}
// first is hello
// second is world

如果只想获取键名,或者只想获取键值,可以写成下面这样。

1
2
3
4
5
6
7
8
9
// 获取键名
for (let [key] of map) {
// ...
}

// 获取键值
for (let [,value] of map) {
// ...
}

输入模块的指定方法

加载模块时,往往需要指定输入哪些方法。解构赋值使得输入语句非常清晰。

1
const { SourceMapConsumer, SourceNode } = require("source-map");

标签模板(tagged template)

定义

在函数名后面紧跟模板字符串。

特点

标签模板是函数调用的一种特殊形式。

注意点

如果模板字符里面有变量,就不是简单的调用了,而是会将模板字符串先处理成多个参数,再调用函数。

示例

1
2
3
4
5
6
7
8
9
10
11
alert`hello`
// 等同于
alert(['hello'])

// 模板字符里面有变量的情况
let a = 5;
let b = 10;

tag`Hello ${ a + b } world ${ a * b }`;
// 等同于
tag(['Hello ', ' world ', ''], 15, 50);

正则的扩展

主要内部

  • ES6 变化:允许 RegExp 函数的第二个参数添加修饰符。且返回的正则表达式会忽略原有的正则表达式的修饰符,只使用新指定的修饰符。

  • ES6 新增:u 修饰符,含义为“Unicode 模式”,可以正确处理四个字节的 UTF-16 编码。

  • ES6 新增: unicode 属性,表示是否设置了 u 修饰符。

  • ES6 新增: sticky 属性,表示是否设置了 y 修饰符。

  • ES6 新增: flags 属性,返回正则表达式的修饰符。

  • ES6 新增: dotAll 属性,表示该正则表达式是否处在 dotAll 模式。

  • ES6 新增:y 修饰符,叫做“粘连”(sticky)修饰符。与 g 修饰符类似,也是全局匹配。不过 y 修饰符要求匹配必须从剩余的第一个位置开始匹配,这也就是“粘连”的涵义。

  • y 修饰符号隐含了头部匹配的标志 ^

  • ES2018 (ES9) 新增:s 修饰符即 dotAll 模式,使得 . 可以匹配任意单个字符。即包括“行终止符”。

  • ES2018 (ES9) 新增:支持“后行断言”(lookbehind)及后行否定断言。

    • 后行断言:(?<=)
    • 后行否定断言:(?<!)
  • “后行断言”按照“先右后左”的执行顺序进行匹配。即先匹配 /(?<=y)x/x,然后再回到左边,匹配 y 的部分,这与所有其他正则操作相反。

    • “后行断言”组匹配顺序从右到左。
    • “后行断言”反斜杠引用必须放在对应的括号前。
  • ES2018 (ES9) 新增:Unicode 属性类,允许使用 \p{...}\P{...}\P\p 的否定形式)代表一类 Unicode 字符,匹配满足条件的所有字符。

  • v 修饰符:Unicode 属性类的运算。

  • ES2018 (ES9) 新增:具名组匹配Named Capture Groups),允许为每一个组匹配指定一个名字,便于阅读代码和引用。

  • 使用“具名组匹配”时,可以进行解构赋值和替换

  • 使用 \k<组名> 的写法,可以在正则表达式内部引用某个“具名组匹配”。

  • ES2022 (ES13) 新增d 修饰符,可以让 exec()match() 的返回结果添加 indices 属性,在该属性上面可以拿到匹配的开始位置和结束位置。

  • ES2020 (ES11) 新增String.prototype.matchAll() 方法,一次性取出所有匹配。该函数返回一个遍历器(Iterator),而不是数组。

关于“后行断言”的注意事项

由于“后行断言”按照“先右后左”的执行顺序进行匹配。因此有一些注意事项。

组匹配顺序从右到左

1
2
/(?<=(\d+)(\d+))$/.exec('1053') // ["", "1", "053"]
/^(\d+)(\d+)$/.exec('1053') // ["1053", "105", "3"]

上面代码中,需要捕捉两个组匹配。没有“后行断言”时,第一个括号是贪婪模式,第二个括号只能捕获一个字符,所以结果是 1053。而“后行断言”时,由于执行顺序是从右到左,第二个括号是贪婪模式,第一个括号只能捕获一个字符,所以结果是 1053

引用必须放在对应的括号前

“后行断言”的反斜杠引用,也与通常的顺序相反,必须放在对应的那个括号之前。

1
2
/(?<=(o)d\1)r/.exec('hodor')  // null
/(?<=\1d(o))r/.exec('hodor') // ["r", "o"]

上面代码中,如果后行断言的反斜杠引用(\1)放在括号的后面,就不会得到匹配结果,必须放在前面才可以。因为后行断言是先从左到右扫描,发现匹配以后再回过头,从右到左完成反斜杠引用。

其它说明

关于 s 修饰符:dotAll 模式

正则表达式中,点(.)是一个特殊字符,代表任意的单个字符,但是有两个例外:

  • 一个是四个字节的 UTF-16 字符,这个可以用 u 修饰符解决。

  • 另一个是行终止符(line terminator character)。所谓行终止符,就是该字符表示一行的终结。以下四个字符属于“行终止符”。

    • U+000A 换行符(\n

    • U+000D 回车符(\r

    • U+2028 行分隔符(line separator)

    • U+2029 段分隔符(paragraph separator)

1
2
/foo.bar/.test('foo\nbar')
// false

上面代码中,因为 . 不匹配 \n,所以正则表达式返回 false

ES2018 (ES9) 引入 s 修饰符,使得 . 可以匹配任意单个字符。

1
/foo.bar/s.test('foo\nbar') // true

这被称为 dotAll 模式,即点(dot)代表一切字符。所以,正则表达式还引入了一个 dotAll 属性,返回一个布尔值,表示该正则表达式是否处在 dotAll 模式。

/s 修饰符和多行修饰符 /m 不冲突,两者一起使用的情况下,. 匹配所有字符,而 ^$ 匹配每一行的行首和行尾。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 构造函数 方法1:参数是字符串+修饰符
var regex = new RegExp('xyz', 'i');
// 等价于
var regex = /xyz/i;

// 构造函数 方法2:参数是一个正则表示式
var regex = new RegExp(/xyz/i);
// 等价于
var regex = /xyz/i;

// ES6 开始,支持参数是正则表示式+修饰符
new RegExp(/abc/ig, 'i').flags // "i"

const r1 = /hello/;
const r2 = /hello/u;

r1.unicode // false
r2.unicode // true

// y 修饰符
var s = 'aaa_aa_a';
var r1 = /a+/g;
var r2 = /a+/y;

r1.exec(s) // ["aaa"]
r2.exec(s) // ["aaa"]
// r1,r2 第一次执行后,剩余的字符串都是 _aa_a
// 第二次执行时,y 修饰符要求匹配必须是从头部即 a 开始。
// 所以 r2 返回 null。
r1.exec(s) // ["aa"]
r2.exec(s) // null

// y 修饰符
var s = 'aaa_aa_a';
var r = /a+_/y;

r.exec(s) // ["aaa_"]
r.exec(s) // ["aa_"]

// y 修饰符号隐含了头部匹配的标志^
/b/y.exec('aba') // null

const REGEX = /a/gy;
'aaxa'.replace(REGEX, '-') // '--xa'

'a1a2a3'.match(/a\d/y) // ["a1"]
'a1a2a3'.match(/a\d/gy) // ["a1", "a2", "a3"]

// 后行断言
/(?<=\$)\d+/.exec('Benjamin Franklin is on the $100 bill') // ["100"]
/(?<!\$)\d+/.exec('it’s worth about €90') // ["90"]

/(?<=(o)d\1)r/.exec('hodor') // null
/(?<=\1d(o))r/.exec('hodor') // ["r", "o"]

数值的扩展

  • 二进制 0b0B0b111110111 === 503 // true
  • 八进制 0o0O0o767 === 503 // true
  • 十六进制 0x0X0xff // 255
  • 数值分隔符:let budget = 1_000_000_000_0000.000_001
  • ES2020 (ES11) 新增:BigInt(大整数)。BigInt 类型的数据必须添加后缀 n

函数的扩展

函数的默认值

主要特点

  • 允许为函数的参数设置默认值。且参数默认值是惰性求值的。只有 undefined 才会触发参数等于默认值,null 则没有这个效果。
  • 参数默认值可以与解构赋值默认值结合使用。
  • 设置了默认值的参数,会形成一个单独的作用域
  • ES6 新增:rest 参数(形式为 ...变量名),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 参数默认值的惰性求值
let x = 99;
// 参数p的默认值是x + 1,每次调用函数foo(),都会重新计算x + 1,而不是默认p等于 100。
function foo(p = x + 1) {
console.log(p);
}

// 每次调用函数foo(),都会重新计算x + 1
foo() // 100

x = 100;
// 每次调用函数foo(),都会重新计算x + 1
foo() // 101

// 与解构赋值默认值结合使用
function foo({x, y = 5} = {}) {
console.log(x, y);
}

foo() // undefined 5

// 函数参数的默认值生效以后,参数解构赋值依然会进行。
function f({ a, b = 'world' } = { a: 'hello' }) {
console.log(b);
}

f() // world

// 注意以下两种写法的差异所导致的不同结果
// 写法一
function m1({x = 0, y = 0} = {}) {
return [x, y];
}

// 写法二
function m2({x, y} = { x: 0, y: 0 }) {
return [x, y];
}

// 函数没有参数的情况
m1() // [0, 0]
m2() // [0, 0]

// x 和 y 都有值的情况
m1({x: 3, y: 8}) // [3, 8]
m2({x: 3, y: 8}) // [3, 8]

// x 有值,y 无值的情况
m1({x: 3}) // [3, 0]
m2({x: 3}) // [3, undefined]

// x 和 y 都无值的情况
m1({}) // [0, 0];
m2({}) // [undefined, undefined]

m1({z: 3}) // [0, 0]
m2({z: 3}) // [undefined, undefined]

参数默认值的应用

指定某一个参数不得省略
1
2
3
4
5
6
7
8
9
10
11
function throwIfMissing() {
throw new Error('Missing parameter');
}

// 指定某一个参数不得省略,如果省略就抛出一个错误。
function foo(mustBeProvided = throwIfMissing()) {
return mustBeProvided;
}

foo()
// Error: Missing parameter
指定可以省略的参数

可以将参数默认值设为undefined,表明这个参数是可以省略的。

1
function foo(optional = undefined) { ··· }

rest 参数

基本用法

特点:将逗号分隔的多余参数序列变成数组。

rest 参数(形式为 ...变量名),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。

注意事项

  • rest 参数之后不能再有其他参数(即只能是最后一个参数)。
  • 函数的 length 属性,不包括 rest 参数。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// arguments 变量的写法
// 注意:arguments 对象不是数组,而是一个类似数组的对象。所以为了使用数组的方法,必须使用 Array.from 先将其转为数组。
function sortNumbers() {
return Array.from(arguments).sort();
}

// rest参数的写法
const sortNumbers = (...numbers) => numbers.sort();

// 下面是一个利用 rest 参数改写数组 push 方法的例子。
function push(array, ...items) {
items.forEach(function(item) {
array.push(item);
console.log(item);
});
}

var a = [];
push(a, 1, 2, 3)

箭头函数

ES6 允许使用“箭头”(=>)定义函数。

示例

仅有一个参数
1
2
3
4
5
6
var f = v => v;

// 等同于
var f = function (v) {
return v;
};
不需要或需要多个参数

如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。

1
2
3
4
5
6
7
8
9
var f = () => 5;
// 等同于
var f = function () { return 5 };

var sum = (num1, num2) => num1 + num2;
// 等同于
var sum = function(num1, num2) {
return num1 + num2;
};
代码块多于一条语句

如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用 return 语句返回。

1
var sum = (num1, num2) => { return num1 + num2; }
返回对象

由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。

1
2
3
4
5
// 报错
let getTempItem = id => { id: id, name: "Temp" };

// 不报错
let getTempItem = id => ({ id: id, name: "Temp" });
仅一行语句且无需返回值

如果箭头函数只有一行语句,且不需要返回值,可以采用下面的写法,就不用写大括号了。

1
let fn = () => void doesNotReturn();
与解构结合
1
2
3
4
5
6
const full = ({ first, last }) => first + ' ' + last;

// 等同于
function full(person) {
return person.first + ' ' + person.last;
}

其它特点

  • 箭头函数使得表达更加简洁。
1
2
const isEven = n => n % 2 === 0;
const square = n => n * n;
  • 简化回调函数。
1
2
3
4
5
6
7
// 普通函数写法
[1,2,3].map(function (x) {
return x * x;
});

// 箭头函数写法
[1,2,3].map(x => x * x);
1
2
3
4
5
6
7
// 普通函数写法
var result = values.sort(function (a, b) {
return a - b;
});

// 箭头函数写法
var result = values.sort((a, b) => a - b);

下面是 rest 参数与箭头函数结合的例子:

1
2
3
4
5
6
7
8
9
const numbers = (...nums) => nums;

numbers(1, 2, 3, 4, 5)
// [1,2,3,4,5]

const headAndTail = (head, ...tail) => [head, tail];

headAndTail(1, 2, 3, 4, 5)
// [1,[2,3,4,5]]

注意事项

箭头函数有几个使用注意点。

(1)箭头函数没有自己的 this 对象(详见下文)。

(2)不可以当作构造函数,也就是说,不可以对箭头函数使用 new 命令,否则会抛出一个错误。

(3)不可以使用 arguments 对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。

(4)不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。

上面四点中,最重要的是第一点。对于普通函数来说,内部的 this 指向函数运行时所在的对象,但是这一点对箭头函数不成立。它没有自己的 this 对象,内部的 this 就是定义时上层作用域中的 this。也就是说,箭头函数内部的 this 指向是固定的,相比之下,普通函数的 this 指向是可变的。

1
2
3
4
5
6
7
8
9
10
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}

var id = 21;

foo.call({ id: 42 });
// id: 42

上面代码中,setTimeout() 的参数是一个箭头函数,这个箭头函数的定义生效是在 foo 函数生成时,而它的真正执行要等到 100 毫秒后。如果是普通函数,执行时 this 应该指向全局对象 window,这时应该输出 21。但是,箭头函数导致 this 总是指向函数定义生效时所在的对象(本例是 {id: 42}),所以打印出来的是 42

下面例子是回调函数分别为箭头函数和普通函数,对比它们内部的 this 指向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Timer() {
this.s1 = 0;
this.s2 = 0;
// 箭头函数
setInterval(() => this.s1++, 1000);
// 普通函数
setInterval(function () {
this.s2++;
}, 1000);
}

var timer = new Timer();

setTimeout(() => console.log('s1: ', timer.s1), 3100);
setTimeout(() => console.log('s2: ', timer.s2), 3100);
// s1: 3
// s2: 0

上面代码中,Timer 函数内部设置了两个定时器,分别使用了箭头函数和普通函数。前者的this绑定定义时所在的作用域(即 Timer 函数),后者的 this 指向运行时所在的作用域(即全局对象)。所以,3100 毫秒之后,timer.s1 被更新了 3 次,而 timer.s2 一次都没更新。

箭头函数实际上可以让 this 指向固定化,绑定 this 使得它不再可变,这种特性很有利于封装回调函数。下面是一个例子,DOM 事件的回调函数封装在一个对象里面。

1
2
3
4
5
6
7
8
9
10
11
12
var handler = {
id: '123456',

init: function() {
document.addEventListener('click',
event => this.doSomething(event.type), false);
},

doSomething: function(type) {
console.log('Handling ' + type + ' for ' + this.id);
}
};

上面代码的 init() 方法中,使用了箭头函数,这导致这个箭头函数里面的 this,总是指向 handler 对象。如果回调函数是普通函数,那么运行 this.doSomething() 这一行会报错,因为此时 this 指向 document 对象。

总之,箭头函数根本没有自己的 this,导致内部的 this 就是外层代码块的 this。正是因为它没有 this,所以也就不能用作构造函数。

请问下面的代码之中,this 的指向有几个?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo() {
return () => {
return () => {
return () => {
console.log('id:', this.id);
};
};
};
}

var f = foo.call({id: 1});

var t1 = f.call({id: 2})()(); // id: 1
var t2 = f().call({id: 3})(); // id: 1
var t3 = f()().call({id: 4}); // id: 1

答案是 this 的指向只有一个,就是函数 foothis,这是因为所有的内层函数都是箭头函数,都没有自己的 this,它们的 this 其实都是最外层 foo 函数的 this。所以不管怎么嵌套,t1t2t3 都输出同样的结果。如果这个例子的所有内层函数都写成普通函数,那么每个函数的 this 都指向运行时所在的不同对象。

除了 this,以下三个变量在箭头函数之中也是不存在的,在箭头函数中它们均指向外层函数的对应变量:argumentssupernew.target

1
2
3
4
5
6
7
8
function foo() {
setTimeout(() => {
console.log('args:', arguments);
}, 100);
}

foo(2, 4, 6, 8)
// args: [2, 4, 6, 8]

上面代码中,箭头函数内部的变量 arguments,其实是函数 fooarguments 变量。

另外,由于箭头函数没有自己的 this,所以当然也就不能用call()apply()bind() 这些方法去改变 this 的指向。

1
2
3
4
5
6
(function() {
return [
(() => this.x).bind({ x: 'inner' })()
];
}).call({ x: 'outer' });
// ['outer']

上面代码中,箭头函数没有自己的 this,所以 bind 方法无效,内部的 this 指向外部的 this

不适用场合

由于箭头函数使得 this 从“动态”变成“静态”,下面两个场合不应该使用箭头函数。

  • 第一个场合是定义对象的方法,且该方法内部包括 this
1
2
3
4
5
6
const cat = {
lives: 9,
jumps: () => {
this.lives--;
}
}

上面代码中,cat.jumps() 方法是一个箭头函数,这是错误的。调用 cat.jumps() 时,如果是普通函数,该方法内部的 this 指向 cat;如果写成上面那样的箭头函数,使得 this 指向全局对象,因此不会得到预期结果。这是因为对象不构成单独的作用域,导致 jumps 箭头函数定义时的作用域就是全局作用域。

再看一个例子。

1
2
3
4
5
6
7
8
globalThis.s = 21;

const obj = {
s: 42,
m: () => console.log(this.s)
};

obj.m() // 21

上面例子中,obj.m() 使用箭头函数定义。JavaScript 引擎的处理方法是,先在全局空间生成这个箭头函数,然后赋值给 obj.m,这导致箭头函数内部的 this 指向全局对象,所以 obj.m() 输出的是全局空间的 21,而不是对象内部的 42。上面的代码实际上等同于下面的代码。

1
2
3
4
5
6
7
8
9
globalThis.s = 21;
globalThis.m = () => console.log(this.s);

const obj = {
s: 42,
m: globalThis.m
};

obj.m() // 21

由于上面这个原因,对象的属性建议使用传统的写法定义,不要用箭头函数定义。

  • 第二个场合是需要动态 this 的时候,也不应使用箭头函数。
1
2
3
4
var button = document.getElementById('press');
button.addEventListener('click', () => {
this.classList.toggle('on');
});

上面代码运行时,点击按钮会报错,因为 button 的监听函数是一个箭头函数,导致里面的 this 就是全局对象。如果改成普通函数,this 就会动态指向被点击的按钮对象。

另外,如果函数体很复杂,有许多行,或者函数内部有大量的读写操作,不单纯是为了计算值,这时也不应该使用箭头函数,而是要使用普通函数,这样可以提高代码可读性。

尾调用优化

完整说明,详见文档

概念

尾调用(Tail Call)是指某个函数的最后一步是调用另一个函数。

1
2
3
4
// 尾调用示例
function f(x){
return g(x);
}

以下三种情况,都不属于尾调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 情况一
function f(x){
let y = g(x);
return y;
}

// 情况二
function f(x){
return g(x) + 1;
}

// 情况三
function f(x){
g(x);
}

上面代码中,情况一是调用函数 g 之后,还有赋值操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。情况三等同于下面的代码。

1
2
3
4
function f(x){
g(x);
return undefined;
}

尾调用不一定出现在函数尾部,只要是最后一步操作即可。

1
2
3
4
5
6
function f(x) {
if (x > 0) {
return m(x)
}
return n(x);
}

上面代码中,函数 mn 都属于尾调用,因为它们都是函数 f 的最后一步操作。

数组的扩展

主要内容

其它说明

  • 所谓类似数组的对象,本质特征只有一点,即必须有 length 属性。因此,任何有 length 属性的对象,都可以通过 Array.from() 方法转为数组,而扩展运算符就无法转换。

  • 关于数组的空位,如果数组某一个位置的值等待 undefined,那么它依然是有值的,而空位是没有任何值的。

    注意:由于不同函数对待空位的处理方式有所不同,有的会忽略空位,有的则不会。因此建议避免出现空位,可以使用 undefined 来代替空位。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 数组的空位
0 in [undefined, undefined, undefined] // true
0 in [, , ,] // false

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

// ES5 的写法
var arr1 = [].slice.call(arrayLike); // ['a', 'b', 'c']

// ES6 的写法
let arr2 = Array.from(arrayLike); // ['a', 'b', 'c']

Array.from([1, 2, 3], (x) => x * x)
// [1, 4, 9]

Array.from({ length: 2 }, () => 'jack')
// ['jack', 'jack']

// 部署了 Iterator 接口的数据结构,可转为数组。
Array.from('hello')
// ['h', 'e', 'l', 'l', 'o']

// 部署了 Iterator 接口的数据结构,可转为数组。
let namesSet = new Set(['a', 'b'])
Array.from(namesSet) // ['a', 'b

// 将含有四个字的 Unicode 字符串转为数组,可以返回正确的字符串长度。
function countSymbols(string) {
return Array.from(string).length;
}

Array.of(3, 11, 8) // [3,11,8]

扩展运算符(spread)(...)

数组的扩展运算符

主要特点

  • 展开数组,相当于 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。
  • 该运算符主要用于函数调用。
  • 扩展运算符后面还可以放置表达式。
  • 只有函数调用时,扩展运算符才可以放在圆括号中。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
console.log(...[1, 2, 3])
// 1 2 3

console.log(1, ...[2, 3, 4], 5)
// 1 2 3 4 5

[...document.querySelectorAll('div')]
// [<div>, <div>, <div>]

// 主要用于函数调用
function push(array, ...items) {
array.push(...items); // 注意这里
}

function add(x, y) {
return x + y;
}

const numbers = [4, 38];
add(...numbers) // 42 // 注意这里

// 扩展运算符后面放置表达式
const arr = [
...(x > 0 ? ['a'] : []),
'b',
];

常见用途

替代函数的 apply() 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ES5 的写法
Math.max.apply(null, [14, 3, 77])

// ES6 的写法
Math.max(...[14, 3, 77])

// 等同于
Math.max(14, 3, 77);

// ES5 的写法
var arr1 = [0, 1, 2];
var arr2 = [3, 4, 5];
Array.prototype.push.apply(arr1, arr2);

// ES6 的写法
let arr1 = [0, 1, 2];
let arr2 = [3, 4, 5];
arr1.push(...arr2);

// ES5
new (Date.bind.apply(Date, [null, 2015, 1, 1]))

// ES6
new Date(...[2015, 1, 1]);
复制数组(数组克隆)
1
2
3
4
5
const a1 = [1, 2];
// 写法一
const a2 = [...a1];
// 写法二
const [...a2] = a1;

上面的两种写法,a2 都是 a1 的克隆。修改 a2 不会对 a1 产生影响。

合并数组(浅拷贝)
1
2
3
4
5
6
7
8
9
10
11
const arr1 = ['a', 'b'];
const arr2 = ['c'];
const arr3 = ['d', 'e'];

// ES5 的合并数组
arr1.concat(arr2, arr3);
// [ 'a', 'b', 'c', 'd', 'e' ]

// ES6 的合并数组
[...arr1, ...arr2, ...arr3]
// [ 'a', 'b', 'c', 'd', 'e' ]
与解构赋值结合

扩展运算符可以与解构赋值结合起来,用于生成数组。

注意:如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ES5
a = list[0], rest = list.slice(1)

// ES6
[a, ...rest] = list

const [first, ...rest] = [1, 2, 3, 4, 5];
first // 1
rest // [2, 3, 4, 5]

const [first, ...rest] = [];
first // undefined
rest // []

const [first, ...rest] = ["foo"];
first // "foo"
rest // []
字符串

扩展运算符还可以将字符串转为真正的数组。且能正确识别四个字节的 Unicode 字符。

注意:通常,JavaScript 会将四个字节的 Unicode 字符,识别为 2 个字符,采用扩展运算符就没有这个问题。因此,凡是涉及到操作四个字节的 Unicode 字符的函数,最好都用扩展运算符改写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[...'hello']
// [ "h", "e", "l", "l", "o" ]

'x\uD83D\uDE80y'.length // 4
[...'x\uD83D\uDE80y'].length // 3

// 正确返回字符串长度的函数
// 可正确识别四个字节的 Unicode 字符
function length(str) {
return [...str].length;
}

length('x\uD83D\uDE80y') // 3

// 对比以下不同处理方式的结果
// 注意以下 reverse() 函数的处理运行结果。
let str = 'x\uD83D\uDE80y';

str.split('').reverse().join('')
// 'y\uDE80\uD83Dx'

[...str].reverse().join('')
// 'y\uD83D\uDE80x'
实现了 Iterator 接口的对象

任何定义了遍历器(Iterator)接口的对象,都可以用扩展运算符转为真正的数组。

1
2
let nodeList = document.querySelectorAll('div');
let array = [...nodeList];

对于那些没有部署 Iterator 接口的类似数组的对象,扩展运算符就无法将其转为真正的数组。这时,可以改为使用 Array.from 方法将 arrayLike 转为真正的数组。

1
2
3
4
5
6
7
8
9
10
11
12
let arrayLike = {
'0': 'a',
'1': 'b',
'2': 'c',
length: 3
};

// TypeError: Cannot spread non-iterable object.
let arr = [...arrayLike];

let arrLike = Array.from(arrayLike);
// [ 'a', 'b', 'c' ]
Map 和 Set 结构,Generator 函数

扩展运算符内部调用的是数据结构的 Iterator 接口,因此只要具有 Iterator 接口的对象,都可以使用扩展运算符,比如 Map 结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let map = new Map([
[1, 'one'],
[2, 'two'],
[3, 'three'],
]);

let arr = [...map.keys()]; // [1, 2, 3

// 对于 Generator 函数返回的遍历器对象执行扩展运算符,
// 会将内部遍历得到的值,转为一个数组。
const go = function*(){
yield 1;
yield 2;
yield 3;
};

[...go()] // [1, 2, 3]

对象的扩展运算符

对象的扩展运算符,只会返回参数对象自身的、可枚举的属性,这一点要特别小心,尤其是用于类的实例对象时。

1
2
3
4
5
6
7
8
9
10
class C {
p = 12;
m() {}
}

let c = new C();
let clone = { ...c };

clone.p; // ok
clone.m(); // 报错

上面示例中,cC 类的实例对象,对其进行扩展运算时,只会返回 c 自身的属性 c.p,而不会返回 c 的方法 c.m(),因为这个方法定义在 C 的原型对象上(详见 Class 的章节)。

对象的扩展运算符等同于使用 Object.assign() 方法。

1
2
3
let aClone = { ...a };
// 等同于
let aClone = Object.assign({}, a);

上面的例子只是拷贝了对象实例的属性,如果想完整克隆一个对象,还拷贝对象原型的属性,可以采用下面的写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 写法一
const clone1 = {
__proto__: Object.getPrototypeOf(obj),
...obj
};

// 写法二
const clone2 = Object.assign(
Object.create(Object.getPrototypeOf(obj)),
obj
);

// 写法三
const clone3 = Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj)
)

上面代码中,写法一的 __proto__ 属性在非浏览器的环境不一定部署,因此推荐使用写法二和写法三。

下面的示例使用了写法二的方式进行克隆:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const {log} = console;

class C {
p = 12;
m() {
log("output m()")
}
}

let c = new C();
// let clone = { ...c };
const clone = Object.assign(
Object.create(Object.getPrototypeOf(c)),
c
);

log(clone.p); // 12
clone.m(); // output m()

运算符的扩展

指数运算符 **

1
2
2 ** 2 // 4
2 ** 3 // 8

这个运算符的一个特点是右结合,而不是常见的左结合。多个指数运算符连用时,是从最右边开始计算的。

1
2
// 相当于 2 ** (3 ** 2)
2 ** 3 ** 2 // 512

指数运算符可以与等号结合,形成一个新的赋值运算符(**=):

1
2
3
let a = 1.5;
a **= 2;
// 等同于 a = a * a;

链判断运算符 ?.

链判断运算符?.有三种写法。

  • obj?.prop // 对象属性是否存在
  • obj?.[expr] // 同上
  • func?.(...args) // 函数或对象方法是否存在

下面是 ?. 运算符常见形式,以及不使用该运算符时的等价形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
a?.b
// 等同于
a == null ? undefined : a.b

a?.[x]
// 等同于
a == null ? undefined : a[x]

a?.b()
// 等同于
a == null ? undefined : a.b()

a?.()
// 等同于
a == null ? undefined : a()

Null 判断运算符 ??

读取对象属性的时候,如果某个属性的值是 nullundefined,有时候需要为它们指定默认值。常见做法是通过 || 运算符指定默认值。

1
2
3
const headerText = response.settings.headerText || 'Hello, world!';
const animationDuration = response.settings.animationDuration || 300;
const showSplashScreen = response.settings.showSplashScreen || true;

上面的三行代码都通过 || 运算符指定默认值,但是这样写是错的。开发者的原意是,只要属性的值为 nullundefined,默认值就会生效,但是属性的值如果为空字符串或 false0,默认值也会生效。

为了避免这种情况,ES2020 引入了一个新的 Null 判断运算符 ??。它的行为类似 ||,但是只有运算符左侧的值为 nullundefined 时,才会返回右侧的值。

1
2
3
const headerText = response.settings.headerText ?? 'Hello, world!';
const animationDuration = response.settings.animationDuration ?? 300;
const showSplashScreen = response.settings.showSplashScreen ?? true;

上面代码中,默认值只有在左侧属性值为 nullundefined 时,才会生效。

这个运算符的一个目的,就是跟链判断运算符 ?. 配合使用,为 nullundefined 的值设置默认值。

1
const animationDuration = response.settings?.animationDuration ?? 300;

上面代码中,如果 response.settingsnullundefined,或者 response.settings.animationDurationnullundefined,就会返回默认值300。也就是说,这一行代码包括了两级属性的判断。

这个运算符很适合判断函数参数是否赋值。

1
2
3
4
function Component(props) {
const enable = props.enabled ?? true;
// …
}

逻辑赋值运算符

ES2021 引入了三个新的逻辑赋值运算符(logical assignment operators),将逻辑运算符与赋值运算符进行结合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 或赋值运算符
x ||= y
// 等同于
x || (x = y)

// 与赋值运算符
x &&= y
// 等同于
x && (x = y)

// Null 赋值运算符
x ??= y
// 等同于
x ?? (x = y)

这三个运算符 ||=&&=??= 相当于先进行逻辑运算,然后根据运算结果,再视情况进行赋值运算。

它们的一个用途是,为变量或属性设置默认值。

1
2
3
4
5
// 老的写法
user.id = user.id || 1;

// 新的写法
user.id ||= 1;

上面示例中,user.id 属性如果不存在,则设为 1,新的写法比老的写法更紧凑一些。

下面是另一个例子。

1
2
3
4
function example(opts) {
opts.foo = opts.foo ?? 'bar';
opts.baz ?? (opts.baz = 'qux');
}

上面示例中,参数对象 opts 如果不存在属性 foo 和属性 baz,则为这两个属性设置默认值。有了“Null 赋值运算符”以后,就可以统一写成下面这样。

1
2
3
4
function example(opts) {
opts.foo ??= 'bar';
opts.baz ??= 'qux';
}

#! 命令

Unix 的命令行脚本都支持 #! 命令,又称为 ShebangHashbang。这个命令放在脚本的第一行,用来指定脚本的执行器。

比如 Bash 脚本的第一行。

1
#!/bin/sh

Python 脚本的第一行。

1
#!/usr/bin/env python

ES2023 为 JavaScript 脚本引入了 #! 命令,写在脚本文件或者模块文件的第一行。

1
2
3
4
5
6
7
8
9
// 写在脚本文件第一行
#!/usr/bin/env node
'use strict';
console.log(1);

// 写在模块文件第一行
#!/usr/bin/env node
export {};
console.log(1);

有了这一行以后,Unix 命令行就可以直接执行脚本。

1
2
3
4
5
# 以前执行脚本的方式
$ node hello.js

# hashbang 的方式
$ ./hello.js

对于 JavaScript 引擎来说,会把 #! 理解成注释,忽略掉这一行。

Iterator 和 for…of 循环

Iterator(遍历器)的概念

JavaScript 原有的表示“集合”的数据结构,主要是数组(Array)和对象(Object),ES6 又添加了 MapSet。这样就有了四种数据集合,用户还可以组合使用它们,定义自己的数据结构,比如数组的成员是 MapMap 的成员是对象。这样就需要一种统一的接口机制,来处理所有不同的数据结构。

遍历器(Iterator)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。

Iterator 的作用有三个:一是为各种数据结构,提供一个统一的、简便的访问接口;二是使得数据结构的成员能够按某种次序排列;三是 ES6 创造了一种新的遍历命令 for...of 循环,Iterator 接口主要供 for...of 消费。

Iterator 的遍历过程是这样的。

(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。

(2)第一次调用指针对象的 next 方法,可以将指针指向数据结构的第一个成员。

(3)第二次调用指针对象的 next 方法,指针就指向数据结构的第二个成员。

(4)不断调用指针对象的 next 方法,直到它指向数据结构的结束位置。

每一次调用 next 方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含 valuedone 两个属性的对象。其中,value 属性是当前成员的值,done 属性是一个布尔值,表示遍历是否结束。

默认 Iterator 接口

Iterator 接口的目的,就是为所有数据结构,提供了一种统一的访问机制,即 for...of 循环(详见下文)。当使用 for...of 循环遍历某种数据结构时,该循环会自动去寻找 Iterator 接口。

一种数据结构只要部署了 Iterator 接口,我们就称这种数据结构是“可遍历的”(iterable)。

ES6 规定,默认的 Iterator 接口部署在数据结构的 Symbol.iterator 属性,或者说,一个数据结构只要具有 Symbol.iterator 属性,就可以认为是“可遍历的”(iterable)。Symbol.iterator 属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。至于属性名 Symbol.iterator,它是一个表达式,返回 Symbol 对象的 iterator 属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号内(参见《Symbol》一章)。

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
[Symbol.iterator] : function () {
return {
next: function () {
return {
value: 1,
done: true
};
}
};
}
};

上面代码中,对象 obj 是可遍历的(iterable),因为具有 Symbol.iterator 属性。执行这个属性,会返回一个遍历器对象。该对象的根本特征就是具有 next 方法。每次调用 next 方法,都会返回一个代表当前成员的信息对象,具有 valuedone 两个属性。

ES6 的有些数据结构原生具备 Iterator 接口(比如数组),即不用任何处理,就可以被 for...of 循环遍历。原因在于,这些数据结构原生部署了 Symbol.iterator 属性(详见下文),另外一些数据结构没有(比如对象)。凡是部署了 Symbol.iterator 属性的数据结构,就称为部署了遍历器接口。调用这个接口,就会返回一个遍历器对象。

原生具备 Iterator 接口的数据结构如下。

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的 arguments 对象
  • NodeList 对象

一个对象如果要具备可被 for...of 循环调用的 Iterator 接口,就必须在 Symbol.iterator 的属性上部署遍历器生成方法(原型链上的对象具有该方法也可)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class RangeIterator {
constructor(start, stop) {
this.value = start;
this.stop = stop;
}

[Symbol.iterator]() { return this; }

next() {
var value = this.value;
if (value < this.stop) {
this.value++;
return {done: false, value: value};
}
return {done: true, value: undefined};
}
}

function range(start, stop) {
return new RangeIterator(start, stop);
}

for (var value of range(0, 3)) {
console.log(value); // 0, 1, 2
}

上面代码是一个类部署 Iterator 接口的写法。Symbol.iterator 属性对应一个函数,执行后返回当前对象的遍历器对象。

说明:对于遍历器对象来说,done: falsevalue: undefined 属性都是可以省略的。因此上述代码中的 next() 函数可以简写成如下形式:

1
2
3
4
5
6
7
8
next() {
var value = this.value;
if (value < this.stop) {
this.value++;
return {value: value}; // 可以忽略 done: false
}
return {done: true}; // 可以忽略 value: undefined
}

下面是通过遍历器实现“链表”结构的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function Obj(value) {
this.value = value;
this.next = null;
}

Obj.prototype[Symbol.iterator] = function() {
var iterator = { next: next };

var current = this;

function next() {
if (current) {
var value = current.value;
current = current.next;
return { done: false, value: value };
}
return { done: true };
}
return iterator;
}

var one = new Obj(1);
var two = new Obj(2);
var three = new Obj(3);

one.next = two;
two.next = three;

for (var i of one){
console.log(i); // 1, 2, 3
}

上面代码首先在构造函数的原型链上部署 Symbol.iterator 方法,调用该方法会返回遍历器对象 iterator,调用该对象的 next 方法,在返回一个值的同时,自动将内部指针移到下一个实例。

下面是另一个为对象添加 Iterator 接口的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let obj = {
data: [ 'hello', 'world' ],
[Symbol.iterator]() {
const self = this;
let index = 0;
return {
next() {
if (index < self.data.length) {
return {
value: self.data[index++],
done: false
};
}
return { value: undefined, done: true };
}
};
}
};

有了遍历器接口,数据结构就可以用 for...of 循环遍历(详见下文),也可以使用 while 循环遍历。

1
2
3
4
5
6
7
var $iterator = ITERABLE[Symbol.iterator]();
var $result = $iterator.next();
while (!$result.done) {
var x = $result.value;
// ...
$result = $iterator.next();
}

上面代码中,ITERABLE 代表某种可遍历的数据结构,$iterator 是它的遍历器对象。遍历器对象每次移动指针(next 方法),都检查一下返回值的 done 属性,如果遍历还没结束,就移动遍历器对象的指针到下一步(next 方法),不断循环。

调用 Iterator 接口的场合

有一些场合会默认调用 Iterator 接口(即 Symbol.iterator 方法),除了下文会介绍的 for...of 循环,还有几个别的场合。

(1)解构赋值

对数组和 Set 结构进行解构赋值时,会默认调用 Symbol.iterator 方法。

1
2
3
4
5
6
7
let set = new Set().add('a').add('b').add('c');

let [x,y] = set;
// x='a'; y='b'

let [first, ...rest] = set;
// first='a'; rest=['b','c'];

(2)扩展运算符

扩展运算符(…)也会调用默认的 Iterator 接口。

1
2
3
4
5
6
7
8
// 例一
var str = 'hello';
[...str] // ['h','e','l','l','o']

// 例二
let arr = ['b', 'c'];
['a', ...arr, 'd']
// ['a', 'b', 'c', 'd']

上面代码的扩展运算符内部就调用 Iterator 接口。

实际上,这提供了一种简便机制,可以将任何部署了 Iterator 接口的数据结构,转为数组。也就是说,只要某个数据结构部署了 Iterator 接口,就可以对它使用扩展运算符,将其转为数组。

1
let arr = [...iterable];

(3)yield*

yield* 后面跟的是一个可遍历的结构,它会调用该结构的遍历器接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let generator = function* () {
yield 1;
yield* [2,3,4];
yield 5;
};

var iterator = generator();

iterator.next() // { value: 1, done: false }
iterator.next() // { value: 2, done: false }
iterator.next() // { value: 3, done: false }
iterator.next() // { value: 4, done: false }
iterator.next() // { value: 5, done: false }
iterator.next() // { value: undefined, done: true }

(4)其他场合

由于数组的遍历会调用遍历器接口,所以任何接受数组作为参数的场合,其实都调用了遍历器接口。下面是一些例子。

  • for…of
  • Array.from()
  • Map(), Set(), WeakMap(), WeakSet()(比如 new Map([['a',1],['b',2]])
  • Promise.all()
  • Promise.race()

字符串的 Iterator 接口

字符串是一个类似数组的对象,也原生具有 Iterator 接口。

1
2
3
4
5
6
7
8
9
var someString = "hi";
typeof someString[Symbol.iterator]
// "function"

var iterator = someString[Symbol.iterator]();

iterator.next() // { value: "h", done: false }
iterator.next() // { value: "i", done: false }
iterator.next() // { value: undefined, done: true }

上面代码中,调用 Symbol.iterator 方法返回一个遍历器对象,在这个遍历器上可以调用 next 方法,实现对于字符串的遍历。

可以覆盖原生的 Symbol.iterator 方法,达到修改遍历器行为的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var str = new String("hi");

[...str] // ["h", "i"]

str[Symbol.iterator] = function() {
return {
next: function() {
if (this._first) {
this._first = false;
return { value: "bye", done: false };
} else {
return { done: true };
}
},
_first: true
};
};

[...str] // ["bye"]
str // "hi"

上面代码中,字符串 strSymbol.iterator 方法被修改了,所以扩展运算符(...)返回的值变成了 bye,而字符串本身还是 hi

Iterator 接口与 Generator 函数

Symbol.iterator() 方法的最简单实现是使用 Generator 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let myIterable = {
[Symbol.iterator]: function* () {
yield 1;
yield 2;
yield 3;
}
};
[...myIterable] // [1, 2, 3]

// 或者采用下面的简洁写法

let obj = {
* [Symbol.iterator]() {
yield 'hello';
yield 'world';
}
};

for (let x of obj) {
console.log(x);
}
// "hello"
// "world"

上面代码中,Symbol.iterator() 方法几乎不用部署任何代码,只要用 yield 命令给出每一步的返回值即可。

遍历器对象的 return(),throw()

遍历器对象除了具有 next() 方法,还可以具有 return() 方法和 throw() 方法。如果你自己写遍历器对象生成函数,那么 next() 方法是必须部署的,return() 方法和 throw() 方法是否部署是可选的。

return() 方法的使用场合是,如果 for...of 循环提前退出(通常是因为出错,或者有 break 语句),就会调用 return() 方法。如果一个对象在完成遍历前,需要清理或释放资源,就可以部署 return() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function readLinesSync(file) {
return {
[Symbol.iterator]() {
return {
next() {
return { done: false };
},
return() {
file.close();
return { done: true };
}
};
},
};
}

上面代码中,函数 readLinesSync 接受一个文件对象作为参数,返回一个遍历器对象,其中除了 next() 方法,还部署了 return() 方法。下面的两种情况,都会触发执行 return() 方法。

1
2
3
4
5
6
7
8
9
10
11
// 情况一
for (let line of readLinesSync(fileName)) {
console.log(line);
break;
}

// 情况二
for (let line of readLinesSync(fileName)) {
console.log(line);
throw new Error();
}

上面代码中,情况一输出文件的第一行以后,就会执行 return() 方法,关闭这个文件;情况二会在执行 return() 方法关闭文件之后,再抛出错误。

注意,return() 方法必须返回一个对象,这是 Generator 语法决定的。

throw() 方法主要是配合 Generator 函数使用,一般的遍历器对象用不到这个方法。

Promise 对象

所谓 Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。

基本用法

ES6 规定,Promise 对象是一个构造函数,用来生成 Promise 实例。

下面代码创造了一个 Promise 实例。

1
2
3
4
5
6
7
8
9
const promise = new Promise(function(resolve, reject) {
// ... some code

if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
});

Promise 构造函数接受一个函数作为参数,该函数的两个参数分别是 resolvereject。它们是两个函数,由 JavaScript 引擎提供,不用自己部署。

resolve 函数的作用是,将 Promise 对象的状态从“未完成”变为“成功”(即从 pending 变为 resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject 函数的作用是,将 Promise 对象的状态从“未完成”变为“失败”(即从 pending 变为 rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

Promise 实例生成以后,可以用 then 方法分别指定 resolved 状态和 rejected 状态的回调函数。

1
2
3
4
5
promise.then(function(value) {
// success
}, function(error) {
// failure
});

then 方法可以接受两个回调函数作为参数。第一个回调函数是 Promise 对象的状态变为 resolved 时调用,第二个回调函数是 Promise 对象的状态变为 rejected 时调用。这两个函数都是可选的,不一定要提供。它们都接受 Promise 对象传出的值作为参数。

下面是一个 Promise 对象的简单例子。

1
2
3
4
5
6
7
8
9
function timeout(ms) {
return new Promise((resolve, reject) => {
setTimeout(resolve, ms, 'done');
});
}

timeout(100).then((value) => {
console.log(value);
});

上面代码中,timeout 方法返回一个 Promise 实例,表示一段时间以后才会发生的结果。过了指定的时间(ms 参数)以后,Promise 实例的状态变为 resolved,就会触发 then 方法绑定的回调函数。

Promise 新建后就会立即执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let promise = new Promise(function(resolve, reject) {
console.log('Promise');
resolve();
});

promise.then(function() {
console.log('resolved.');
});

console.log('Hi!');

// Promise
// Hi!
// resolved

上面代码中,Promise 新建后立即执行,所以首先输出的是 Promise。然后,**then 方法指定的回调函数,将在当前脚本所有同步任务执行完才会执行**,所以 resolved 最后输出。

请再次注意:

then 方法指定的回调函数,将在当前脚本所有同步任务执行完才会执行。

Promise.prototype.catch()

Promise.prototype.catch() 方法是 .then(null, rejection).then(undefined, rejection) 的别名,用于指定发生错误时的回调函数。

请注意 catch() 被调用的时机

如果 Promise 状态变为 resolved,则会调用 then() 方法指定的回调函数;如果异步操作抛出错误,状态就会变为 rejected,就会调用 catch() 方法指定的回调函数,处理这个错误。另外,then() 方法指定的回调函数,如果运行中抛出错误,也会被 catch() 方法捕获。

值得注意的是,reject() 方法的作用,等同于抛出错误。因此,当调用 reject() 时,同样会触发 catch() 方法。

此外,如果 Promise 状态已经变成 resolved,再抛出错误是无效的。

Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个 catch 语句捕获。

1
2
3
4
5
6
7
getJSON('/post/1.json').then(function(post) {
return getJSON(post.commentURL);
}).then(function(comments) {
// some code
}).catch(function(error) {
// 处理前面三个Promise产生的错误
});

上面代码中,一共有三个 Promise 对象:一个由 getJSON() 产生,两个由 then() 产生。它们之中任何一个抛出的错误,都会被最后一个 catch() 捕获。

一般来说,不要在 then() 方法里面定义 Reject 状态的回调函数(即 then 的第二个参数),总是使用 catch 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// bad
promise
.then(function(data) {
// success
}, function(err) {
// error
});

// good
promise
.then(function(data) { //cb
// success
})
.catch(function(err) {
// error
});

上面代码中,第二种写法要好于第一种写法,理由是第二种写法可以捕获前面 then 方法执行中的错误,也更接近同步的写法(try/catch)。因此,建议总是使用 catch() 方法,而不使用 then() 方法的第二个参数。

跟传统的 try/catch 代码块不同的是,如果没有使用 catch() 方法指定错误处理的回调函数,Promise 对象抛出的错误不会传递到外层代码,即不会有任何反应。

不过,Node.js 有一个 unhandledRejection 事件,专门监听未捕获的 reject 错误,上面的脚本会触发这个事件的监听函数,可以在监听函数里面抛出错误。

1
2
3
process.on('unhandledRejection', function (err, p) {
throw err;
});

上面代码中,unhandledRejection 事件的监听函数有两个参数,第一个是错误对象,第二个是报错的 Promise 实例,它可以用来了解发生错误的环境信息。

注意,Node 有计划在未来废除 unhandledRejection 事件。如果 Promise 内部有未捕获的错误,会直接终止进程,并且进程的退出码不为 0。

Promise.try()

实际开发中,经常遇到一种情况:不知道或者不想区分,函数 f 是同步函数还是异步操作,但是想用 Promise 来处理它。因为这样就可以不管 f 是否包含异步操作,都用 then 方法指定下一步流程,用 catch 方法处理 f 抛出的错误。并且希望当 f 是同步函数时同步执行,当 f 是异步函数时异步执行。并让它们有统一的函数来写。

上述要求,可以用 Promise.try() 来实现。

1
2
3
4
5
const f = () => console.log('now');
Promise.try(f);
console.log('next');
// now
// next

由于 Promise.try 为所有操作提供了统一的处理机制,所以如果想用then方法管理流程,最好都用 Promise.try 包装一下。这样有许多好处,其中一点就是可以更好地管理异常。

Generator 函数的语法

基本概念

Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。

Generator 函数有多种理解角度。语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。

执行 Generator 函数会返回一个遍历器对象,也就是说,Generator 函数除了状态机,还是一个遍历器对象生成函数。返回的遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。

形式上,Generator 函数是一个普通函数,但是有两个特征:

  1. function 关键字与函数名之间有一个星号。
  2. 函数体内部使用 yield 表达式,定义不同的内部状态(yield 在英语里的意思就是“产出”)。
1
2
3
4
5
6
7
function* helloWorldGenerator() {
yield 'hello';
yield 'world';
return 'ending';
}

var hw = helloWorldGenerator();

上面代码定义了一个 Generator 函数 helloWorldGenerator,它内部有两个 yield 表达式(helloworld),即该函数有三个状态:helloworldreturn 语句(结束执行)。

然后,Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是遍历器对象(Iterator Object)。

下一步,必须调用遍历器对象的 next 方法,使得指针移向下一个状态。也就是说,每次调用 next 方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个 yield 表达式(或 return 语句)为止。换言之,Generator 函数是分段执行的,yield 表达式是暂停执行的标记,而 next 方法可以恢复执行。

1
2
3
4
5
6
7
8
9
10
11
hw.next()
// { value: 'hello', done: false }

hw.next()
// { value: 'world', done: false }

hw.next()
// { value: 'ending', done: true }

hw.next()
// { value: undefined, done: true }

总结一下,调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。以后,每次调用遍历器对象的 next 方法,就会返回一个有着 valuedone 两个属性的对象。value 属性表示当前的内部状态的值,是 yield 表达式后面那个表达式的值;done 属性是一个布尔值,表示是否遍历结束。

yield 表达式

由于 Generator 函数返回的遍历器对象,只有调用 next 方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield 表达式就是暂停标志。

遍历器对象的 next 方法的运行逻辑如下。

(1)遇到 yield 表达式,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值,作为返回的对象的 value 属性值。

(2)下一次调用 next 方法时,再继续往下执行,直到遇到下一个 yield 表达式。

(3)如果没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值。

(4)如果该函数没有 return 语句,则返回的对象的 value 属性值为 undefined

需要注意的是,yield 表达式后面的表达式,只有当调用 next 方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。

1
2
3
function* gen() {
yield 123 + 456;
}

上面代码中,yield 后面的表达式 123 + 456,不会立即求值,只会在 next 方法将指针移到这一句时,才会求值。

yield 表达式与 return 语句既有相似之处,也有区别。相似之处在于,都能返回紧跟在语句后面的那个表达式的值。区别在于每次遇到 yield,函数暂停执行,下一次再从该位置继续向后执行,而 return 语句不具备位置记忆的功能。一个函数里面,只能执行一次(或者说一个)return 语句,但是可以执行多次(或者说多个) yield 表达式。正常函数只能返回一个值,因为只能执行一次 return;Generator 函数可以返回一系列的值,因为可以有任意多个 yield。从另一个角度看,也可以说 Generator 生成了一系列的值,这也就是它的名称的来历(英语中,generator 这个词是“生成器”的意思)。

Generator 函数可以不用 yield 表达式,这时就变成了一个单纯的暂缓执行函数。

1
2
3
4
5
6
7
8
9
function* f() {
console.log('执行了!')
}

var generator = f();

setTimeout(function () {
generator.next()
}, 2000);

上面代码中,函数 f 如果是普通函数,在为变量 generator 赋值时就会执行。但是,函数 f 是一个 Generator 函数,就变成只有调用 next 方法时,函数 f 才会执行。

另外需要注意,yield 表达式只能用在 Generator 函数里面,用在其他地方都会报错。

yield 表达式如果用在另一个表达式之中,必须放在圆括号里面。

1
2
3
4
5
6
7
function* demo() {
console.log('Hello' + yield); // SyntaxError
console.log('Hello' + yield 123); // SyntaxError

console.log('Hello' + (yield)); // OK
console.log('Hello' + (yield 123)); // OK
}

yield 表达式用作函数参数或放在赋值表达式的右边,可以不加括号。

1
2
3
4
function* demo() {
foo(yield 'a', yield 'b'); // OK
let input = yield; // OK
}

与 Iterator 接口的关系

任意一个对象的 Symbol.iterator 方法,等于该对象的遍历器生成函数,调用该函数会返回该对象的一个遍历器对象。

由于 Generator 函数就是遍历器生成函数,因此可以把 Generator 赋值给对象的 Symbol.iterator 属性,从而使得该对象具有 Iterator 接口。

1
2
3
4
5
6
7
8
var myIterable = {};
myIterable[Symbol.iterator] = function* () {
yield 1;
yield 2;
yield 3;
};

[...myIterable] // [1, 2, 3]

上面代码中,Generator 函数赋值给 Symbol.iterator 属性,从而使得 myIterable 对象具有了 Iterator 接口,可以被 ... 运算符遍历了。

Generator 函数执行后,返回一个遍历器对象。该对象本身也具有 Symbol.iterator 属性,执行后返回自身。

1
2
3
4
5
6
7
8
function* gen(){
// some code
}

var g = gen();

g[Symbol.iterator]() === g
// true

上面代码中,gen 是一个 Generator 函数,调用它会生成一个遍历器对象 g。它的 Symbol.iterator 属性,也是一个遍历器对象生成函数,执行后返回它自己。

next 方法的参数

yield 表达式本身没有返回值,或者说总是返回 undefinednext 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值

1
2
3
4
5
6
7
8
9
10
11
12
function* f() {
for(var i = 0; true; i++) {
var reset = yield i;
if(reset) { i = -1; }
}
}

var g = f();

g.next() // { value: 0, done: false }
g.next() // { value: 1, done: false }
g.next(true) // { value: 0, done: false }

上面代码先定义了一个可以无限运行的 Generator 函数 f,如果 next 方法没有参数,每次运行到 yield 表达式,变量 reset 的值总是 undefined。当 next 方法带一个参数 true 时,变量 reset 就被重置为这个参数(即 true),因此 i 会等于 -1,下一轮循环就会从 -1 开始递增。

这个功能有很重要的语法意义。Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。通过 next 方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。

再看一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* foo(x) {
var y = 2 * (yield (x + 1));
var z = yield (y / 3);
return (x + y + z);
}

var a = foo(5);
a.next() // Object{value:6, done:false}
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}

var b = foo(5);
b.next() // { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }

上面代码中,第二次运行 next 方法的时候不带参数,导致 y 的值等于 2 * undefined(即 NaN ),除以 3 以后还是 NaN,因此返回对象的 value 属性也等于 NaN。第三次运行 Next 方法的时候不带参数,所以 z 等于 undefined,返回对象的 value 属性等于 5 + NaN + undefined,即 NaN

如果向 next 方法提供参数,返回结果就完全不一样了。上面代码第一次调用 bnext 方法时,返回 x+1 的值 6;第二次调用 next 方法,将上一次 yield 表达式的值设为 12,因此 y 等于24,返回 y / 3 的值 8;第三次调用 next 方法,将上一次 yield 表达式的值设为 13,因此 z 等于 13,这时 x 等于5y 等于 24,所以 return 语句的值等于 42

注意由于 next 方法的参数表示上一个 yield 表达式的返回值,所以在第一次使用 next 方法时,传递参数是无效的。V8 引擎直接忽略第一次使用 next 方法时的参数,只有从第二次使用 next 方法开始,参数才是有效的。从语义上讲,第一个 next 方法用来启动遍历器对象,所以不用带有参数。

再看一个通过 next 方法的参数,向 Generator 函数内部输入值的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* dataConsumer() {
console.log('Started');
console.log(`1. ${yield}`);
console.log(`2. ${yield}`);
return 'result';
}

let genObj = dataConsumer();
genObj.next();
// Started
genObj.next('a')
// 1. a
genObj.next('b')
// 2. b

上面代码是一个很直观的例子,每次通过 next 方法向 Generator 函数输入值,然后打印出来。

如果想要第一次调用 next 方法时,就能够输入值,可以在 Generator 函数外面再包一层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function wrapper(generatorFunction) {
return function (...args) {
let generatorObject = generatorFunction(...args);
generatorObject.next();
return generatorObject;
};
}

const wrapped = wrapper(function* () {
console.log(`First input: ${yield}`);
return 'DONE';
});

wrapped().next('hello!')
// First input: hello!

上面代码中,Generator 函数如果不用 wrapper 先包一层,是无法第一次调用 next 方法,就输入参数的。

for…of 循环

for...of 循环可以自动遍历 Generator 函数运行时生成的 Iterator 对象,且此时不再需要调用 next 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* foo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6;
}

for (let v of foo()) {
console.log(v);
}
// 1 2 3 4 5

上面代码使用 for...of 循环,依次显示 5 个 yield 表达式的值。这里需要注意,一旦 next 方法的返回对象的 done 属性为 truefor...of 循环就会中止,且不包含该返回对象,所以上面代码的 return 语句返回的 6,不包括在 for...of 循环之中。

下面是一个利用 Generator 函数和 for...of 循环,实现斐波那契数列的例子。

1
2
3
4
5
6
7
8
9
10
11
12
function* fibonacci() {
let [prev, curr] = [0, 1];
for (;;) {
yield curr;
[prev, curr] = [curr, prev + curr];
}
}

for (let n of fibonacci()) {
if (n > 1000) break;
console.log(n);
}

从上面代码可见,使用 for...of 语句时不需要使用 next 方法。

Generator.prototype.throw()

Generator 函数返回的遍历器对象,都有一个 throw 方法,可以在函数体外抛出错误,然后在 Generator 函数体内捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var g = function* () {
try {
yield;
} catch (e) {
console.log('内部捕获', e);
}
};

var i = g();
i.next();

try {
i.throw('a');
i.throw('b');
} catch (e) {
console.log('外部捕获', e);
}
// 内部捕获 a
// 外部捕获 b

上面代码中,遍历器对象 i 连续抛出两个错误。第一个错误被 Generator 函数体内的 catch 语句捕获。i 第二次抛出错误,由于 Generator 函数内部的 catch 语句已经执行过了,不会再捕捉到这个错误了,所以这个错误就被抛出了 Generator 函数体,被函数体外的 catch 语句捕获。

throw 方法可以接受一个参数,该参数会被 catch 语句接收,建议抛出 Error 对象的实例。

1
2
3
4
5
6
7
8
9
10
11
12
var g = function* () {
try {
yield;
} catch (e) {
console.log(e);
}
};

var i = g();
i.next();
i.throw(new Error('出错了!'));
// Error: 出错了!(…)

注意,不要混淆遍历器对象的 throw 方法和全局的 throw 命令。上面代码的错误,是用遍历器对象的 throw 方法抛出的,而不是用 throw 命令抛出的。后者只能被函数体外的 catch 语句捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var g = function* () {
while (true) {
try {
yield;
} catch (e) {
if (e != 'a') throw e;
console.log('内部捕获', e);
}
}
};

var i = g();
i.next();

try {
throw new Error('a');
throw new Error('b');
} catch (e) {
console.log('外部捕获', e);
}
// 外部捕获 [Error: a]

上面代码之所以只捕获了 a,是因为函数体外的 catch 语句块,捕获了抛出的 a 错误以后,就不会再继续 try 代码块里面剩余的语句了。

如果 Generator 函数内部没有部署 try...catch 代码块,那么 throw 方法抛出的错误,将被外部 try...catch 代码块捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var g = function* () {
while (true) {
yield;
console.log('内部捕获', e);
}
};

var i = g();
i.next();

try {
i.throw('a');
i.throw('b');
} catch (e) {
console.log('外部捕获', e);
}
// 外部捕获 a

上面代码中,Generator 函数 g 内部没有部署 try...catch 代码块,所以抛出的错误直接被外部 catch 代码块捕获。

如果 Generator 函数内部和外部,都没有部署 try...catch 代码块,那么程序将报错,直接中断执行。

1
2
3
4
5
6
7
8
9
10
var gen = function* gen(){
yield console.log('hello');
yield console.log('world');
}

var g = gen();
g.next();
g.throw();
// hello
// Uncaught undefined

上面代码中,g.throw 抛出错误以后,没有任何 try...catch 代码块可以捕获这个错误,导致程序报错,中断执行。

throw 方法抛出的错误要被内部捕获,前提是必须至少执行过一次 next 方法。

1
2
3
4
5
6
7
8
9
10
11
function* gen() {
try {
yield 1;
} catch (e) {
console.log('内部捕获');
}
}

var g = gen();
g.throw(1);
// Uncaught 1

上面代码中,g.throw(1) 执行时,next 方法一次都没有执行过。这时,抛出的错误不会被内部捕获,而是直接在外部抛出,导致程序出错。这种行为其实很好理解,因为第一次执行 next 方法,等同于启动执行 Generator 函数的内部代码,否则 Generator 函数还没有开始执行,这时 throw 方法抛错只可能抛出在函数外部。

throw 方法被内部捕获以后,会附带执行到下一条 yield 表达式,这种情况下等同于执行一次 next 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var gen = function* gen(){
try {
yield 1;
} catch (e) {
yield 2;
}
yield 3;
}

var g = gen();
g.next() // { value:1, done:false }
g.throw() // { value:2, done:false }
g.next() // { value:3, done:false }
g.next() // { value:undefined, done:true }

上面代码中,g.throw 方法被内部捕获以后,等同于执行了一次 next 方法,所以返回 { value:2, done:false }。另外,也可以看到,只要 Generator 函数内部部署了 try...catch 代码块,那么遍历器的 throw 方法抛出的错误,不影响下一次遍历。

另外,throw 命令与 g.throw 方法是无关的,两者互不影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var gen = function* gen(){
yield console.log('hello');
yield console.log('world');
}

var g = gen();
g.next();

try {
throw new Error();
} catch (e) {
g.next();
}
// hello
// world

上面代码中,throw 命令抛出的错误不会影响到遍历器的状态,所以两次执行 next 方法,都进行了正确的操作。

这种函数体内捕获错误的机制,大大方便了对错误的处理。多个 yield 表达式,可以只用一个 try...catch 代码块来捕获错误。如果使用回调函数的写法,想要捕获多个错误,就不得不为每个函数内部写一个错误处理语句,现在只在 Generator 函数内部写一次 catch 语句就可以了。

Generator 函数体外抛出的错误,可以在函数体内捕获;反过来,Generator 函数体内抛出的错误,也可以被函数体外的 catch 捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* foo() {
var x = yield 3;
var y = x.toUpperCase();
yield y;
}

var it = foo();

it.next(); // { value:3, done:false }

try {
it.next(42);
} catch (err) {
console.log(err);
}

上面代码中,第二个 next 方法向函数体内传入一个参数 42,数值是没有 toUpperCase 方法的,所以会抛出一个 TypeError 错误,被函数体外的 catch 捕获。

一旦 Generator 执行过程中抛出错误,且没有被内部捕获,就不会再执行下去了。如果此后还调用 next 方法,将返回一个 value 属性等于 undefineddone 属性等于 true 的对象,即 JavaScript 引擎认为这个 Generator 已经运行结束了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function* g() {
yield 1;
console.log('throwing an exception');
throw new Error('generator broke!');
yield 2;
yield 3;
}

function log(generator) {
var v;
console.log('starting generator');
try {
v = generator.next();
console.log('第一次运行next方法', v);
} catch (err) {
console.log('捕捉错误', v);
}
try {
v = generator.next();
console.log('第二次运行next方法', v);
} catch (err) {
console.log('捕捉错误', v);
}
try {
v = generator.next();
console.log('第三次运行next方法', v);
} catch (err) {
console.log('捕捉错误', v);
}
console.log('caller done');
}

log(g());
// starting generator
// 第一次运行next方法 { value: 1, done: false }
// throwing an exception
// 捕捉错误 { value: 1, done: false }
// 第三次运行next方法 { value: undefined, done: true }
// caller done

上面代码一共三次运行 next 方法,第二次运行的时候会抛出错误,然后第三次运行的时候,Generator 函数就已经结束了,不再执行下去了。

Generator.prototype.return()

Generator 函数返回的遍历器对象,还有一个 return() 方法,可以返回给定的值,并且终结遍历 Generator 函数。

1
2
3
4
5
6
7
8
9
10
11
function* gen() {
yield 1;
yield 2;
yield 3;
}

var g = gen();

g.next() // { value: 1, done: false }
g.return('foo') // { value: "foo", done: true }
g.next() // { value: undefined, done: true }

上面代码中,遍历器对象 g 调用 return() 方法后,返回值的 value 属性就是 return() 方法的参数 foo。并且,Generator 函数的遍历就终止了,返回值的 done 属性为 true,以后再调用next()方法,done 属性总是返回 true

如果 return() 方法调用时,不提供参数,则返回值的 value 属性为 undefined

1
2
3
4
5
6
7
8
9
10
function* gen() {
yield 1;
yield 2;
yield 3;
}

var g = gen();

g.next() // { value: 1, done: false }
g.return() // { value: undefined, done: true }

如果 Generator 函数内部有 try...finally 代码块,且正在执行 try 代码块,那么 return() 方法会导致立刻进入 finally 代码块,执行完以后,整个函数才会结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function* numbers () {
yield 1;
try {
yield 2;
yield 3;
} finally {
yield 4;
yield 5;
}
yield 6;
}
var g = numbers();
g.next() // { value: 1, done: false }
g.next() // { value: 2, done: false }
g.return(7) // { value: 4, done: false }
g.next() // { value: 5, done: false }
g.next() // { value: 7, done: true }

上面代码中,调用 return() 方法后,就开始执行 finally 代码块,不执行 try 里面剩下的代码了,然后等到 finally 代码块执行完,再返回 return() 方法指定的返回值。

next()、throw()、return() 的共同点

next()throw()return() 这三个方法本质上是同一件事,可以放在一起理解。它们的作用都是让 Generator 函数恢复执行,并且使用不同的语句替换 yield 表达式。

next() 是将 yield 表达式替换成一个值。

1
2
3
4
5
6
7
8
9
10
11
const g = function* (x, y) {
let result = yield x + y;
return result;
};

const gen = g(1, 2);
gen.next(); // Object {value: 3, done: false}

gen.next(1); // Object {value: 1, done: true}
// 相当于将 let result = yield x + y
// 替换成 let result = 1;

上面代码中,第二个 next(1) 方法就相当于将 yield 表达式替换成一个值 1。如果 next 方法没有参数,就相当于替换成 undefined

throw() 是将 yield 表达式替换成一个 throw 语句。

1
2
3
gen.throw(new Error('出错了')); // Uncaught Error: 出错了
// 相当于将 let result = yield x + y
// 替换成 let result = throw(new Error('出错了'));

return() 是将 yield 表达式替换成一个 return 语句。

1
2
3
gen.return(2); // Object {value: 2, done: true}
// 相当于将 let result = yield x + y
// 替换成 let result = return 2;

yield* 表达式

如果在 Generator 函数内部,调用另一个 Generator 函数。需要在前者的函数体内部,自己手动完成遍历。

ES6 提供了 yield* 表达式,作为解决办法,用来在一个 Generator 函数里面执行另一个 Generator 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function* bar() {
yield 'x';
yield* foo();
yield 'y';
}

// 等同于
function* bar() {
yield 'x';
yield 'a';
yield 'b';
yield 'y';
}

// 等同于
function* bar() {
yield 'x';
for (let v of foo()) {
yield v;
}
yield 'y';
}

for (let v of bar()){
console.log(v);
}
// "x"
// "a"
// "b"
// "y"

Generator 与协程

Generator 函数是 ES6 对协程的实现,但属于不完全实现。Generator 函数被称为“半协程”(semi-coroutine),意思是只有 Generator 函数的调用者,才能将程序的执行权还给 Generator 函数。如果是完全执行的协程,任何函数都可以让暂停的协程继续执行。

如果将 Generator 函数当作协程,完全可以将多个需要互相协作的任务写成 Generator 函数,它们之间使用 yield 表达式交换控制权。

应用

Generator 可以暂停函数执行,返回任意表达式的值。这种特点使得 Generator 有多种应用场景。

异步操作的同步化表达

Generator 函数的暂停执行的效果,意味着可以把异步操作写在 yield 表达式里面,等到调用 next 方法时再往后执行。这实际上等同于不需要写回调函数了,因为异步操作的后续操作可以放在 yield 表达式下面,反正要等到调用 next 方法时再执行。所以,Generator 函数的一个重要实际意义就是用来处理异步操作,改写回调函数。

1
2
3
4
5
6
7
8
9
10
11
function* loadUI() {
showLoadingScreen();
yield loadUIDataAsynchronously();
hideLoadingScreen();
}
var loader = loadUI();
// 加载UI
loader.next()

// 卸载UI
loader.next()

上面代码中,第一次调用 loadUI 函数时,该函数不会执行,仅返回一个遍历器。下一次对该遍历器调用 next 方法,则会显示 Loading 界面(showLoadingScreen),并且异步加载数据(loadUIDataAsynchronously)。等到数据加载完成,再一次使用 next 方法,则会隐藏 Loading 界面。可以看到,这种写法的好处是所有 Loading 界面的逻辑,都被封装在一个函数,按部就班非常清晰。

Ajax 是典型的异步操作,通过 Generator 函数部署 Ajax 操作,可以用同步的方式表达。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* main() {
var result = yield request("http://some.url");
var resp = JSON.parse(result);
console.log(resp.value);
}

function request(url) {
makeAjaxCall(url, function(response){
it.next(response);
});
}

var it = main();
it.next();

上面代码的 main 函数,就是通过 Ajax 操作获取数据。可以看到,除了多了一个 yield,它几乎与同步操作的写法完全一样。注意,makeAjaxCall 函数中的 next 方法,必须加上 response 参数,因为 yield 表达式,本身是没有值的,总是等于 undefined

下面是另一个例子,通过 Generator 函数逐行读取文本文件。

1
2
3
4
5
6
7
8
9
10
function* numbers() {
let file = new FileReader("numbers.txt");
try {
while(!file.eof) {
yield parseInt(file.readLine(), 10);
}
} finally {
file.close();
}
}

上面代码打开文本文件,使用 yield 表达式可以手动逐行读取文件。

控制流管理

如果有一个多步操作非常耗时,采用回调函数,可能会写成下面这样。

1
2
3
4
5
6
7
8
9
step1(function (value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
// Do something with value4
});
});
});
});

采用 Promise 改写上面的代码。

1
2
3
4
5
6
7
8
9
10
Promise.resolve(step1)
.then(step2)
.then(step3)
.then(step4)
.then(function (value4) {
// Do something with value4
}, function (error) {
// Handle any error from step1 through step4
})
.done();

上面代码已经把回调函数,改成了直线执行的形式,但是加入了大量 Promise 的语法。Generator 函数可以进一步改善代码运行流程。

1
2
3
4
5
6
7
8
9
10
11
function* longRunningTask(value1) {
try {
var value2 = yield step1(value1);
var value3 = yield step2(value2);
var value4 = yield step3(value3);
var value5 = yield step4(value4);
// Do something with value4
} catch (e) {
// Handle any error from step1 through step4
}
}

然后,使用一个函数,按次序自动执行所有步骤。

1
2
3
4
5
6
7
8
9
10
scheduler(longRunningTask(initialValue));

function scheduler(task) {
var taskObj = task.next(task.value);
// 如果Generator函数未结束,就继续调用
if (!taskObj.done) {
task.value = taskObj.value
scheduler(task);
}
}

注意,上面这种做法,只适合同步操作,即所有的 task 都必须是同步的,不能有异步操作。因为这里的代码一得到返回值,就继续往下执行,没有判断异步操作何时完成。如果要控制异步的操作流程,详见后文。

下面,利用 for...of 循环会自动依次执行 yield 命令的特性,提供一种更一般的控制流管理的方法。

1
2
3
4
5
6
7
8
let steps = [step1Func, step2Func, step3Func];

function* iterateSteps(steps){
for (var i=0; i< steps.length; i++){
var step = steps[i];
yield step();
}
}

上面代码中,数组 steps 封装了一个任务的多个步骤,Generator 函数 iterateSteps 则是依次为这些步骤加上 yield 命令。

将任务分解成步骤之后,还可以将项目分解成多个依次执行的任务。

1
2
3
4
5
6
7
8
let jobs = [job1, job2, job3];

function* iterateJobs(jobs){
for (var i=0; i< jobs.length; i++){
var job = jobs[i];
yield* iterateSteps(job.steps);
}
}

上面代码中,数组 jobs 封装了一个项目的多个任务,Generator 函数 iterateJobs 则是依次为这些任务加上 yield* 命令。

最后,就可以用 for...of 循环一次性依次执行所有任务的所有步骤。

1
2
3
for (var step of iterateJobs(jobs)){
console.log(step.id);
}

再次提醒,上面的做法只能用于所有步骤都是同步操作的情况,不能有异步操作的步骤。如果想要依次执行异步的步骤,详见后文。

for...of 的本质是一个 while 循环,所以上面的代码实质上执行的是下面的逻辑。

1
2
3
4
5
6
7
8
var it = iterateJobs(jobs);
var res = it.next();

while (!res.done){
var result = res.value;
// ...
res = it.next();
}

部署 Iterator 接口

利用 Generator 函数,可以在任意对象上部署 Iterator 接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function* iterEntries(obj) {
let keys = Object.keys(obj);
for (let i=0; i < keys.length; i++) {
let key = keys[i];
yield [key, obj[key]];
}
}

let myObj = { foo: 3, bar: 7 };

for (let [key, value] of iterEntries(myObj)) {
console.log(key, value);
}

// foo 3
// bar 7

上述代码中,myObj 是一个普通对象,通过 iterEntries 函数,就有了 Iterator 接口。也就是说,可以在任意对象上部署 next 方法。

下面是一个对数组部署 Iterator 接口的例子,尽管数组原生具有这个接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* makeSimpleGenerator(array){
var nextIndex = 0;

while(nextIndex < array.length){
yield array[nextIndex++];
}
}

var gen = makeSimpleGenerator(['yo', 'ya']);

gen.next().value // 'yo'
gen.next().value // 'ya'
gen.next().done // true

作为数据结构

Generator 可以看作是数据结构,更确切地说,可以看作是一个数组结构,因为 Generator 函数可以返回一系列的值,这意味着它可以对任意表达式,提供类似数组的接口。

1
2
3
4
5
function* doStuff() {
yield fs.readFile.bind(null, 'hello.txt');
yield fs.readFile.bind(null, 'world.txt');
yield fs.readFile.bind(null, 'and-such.txt');
}

上面代码就是依次返回三个函数,但是由于使用了 Generator 函数,导致可以像处理数组那样,处理这三个返回的函数。

1
2
3
for (task of doStuff()) {
// task是一个函数,可以像回调函数那样使用它
}

实际上,如果用 ES5 表达,完全可以用数组模拟 Generator 的这种用法。

1
2
3
4
5
6
7
function doStuff() {
return [
fs.readFile.bind(null, 'hello.txt'),
fs.readFile.bind(null, 'world.txt'),
fs.readFile.bind(null, 'and-such.txt')
];
}

上面的函数,可以用一模一样的 for...of 循环处理!两相一比较,就不难看出 Generator 使得数据或者操作,具备了类似数组的接口。

Generator 函数的异步应用

async 函数

先概要性的说一下主要注意点:

  1. async 函数是 Generator 函数的语法糖。就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await

含义

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。

async 函数是什么?一句话,它就是 Generator 函数的语法糖。

前文有一个 Generator 函数,依次读取两个文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const fs = require('fs');

const readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) return reject(error);
resolve(data);
});
});
};

const gen = function* () {
const f1 = yield readFile('/etc/fstab');
const f2 = yield readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

上面代码的函数 gen 可以写成 async 函数,就是下面这样。

1
2
3
4
5
6
const asyncReadFile = async function () {
const f1 = await readFile('/etc/fstab');
const f2 = await readFile('/etc/shells');
console.log(f1.toString());
console.log(f2.toString());
};

一比较就会发现,async 函数就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await,仅此而已。

async 函数对 Generator 函数的改进,体现在以下四点。

(1)内置执行器。

Generator 函数的执行必须靠执行器,所以才有了 co 模块,而 async 函数自带执行器。也就是说,async 函数的执行,与普通函数一模一样,只要一行。

1
asyncReadFile();

上面的代码调用了 asyncReadFile 函数,然后它就会自动执行,输出最后结果。这完全不像 Generator 函数,需要调用 next 方法,或者用 co 模块,才能真正执行,得到最后结果。

(2)更好的语义。

asyncawait,比起星号和 yield,语义更清楚了。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。

(3)更广的适用性。

co 模块约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。

(4)返回值是 Promise。

async 函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用 then 方法指定下一步的操作。

进一步说,async 函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而 await 命令就是内部 then 命令的语法糖。

基本用法

async 函数返回一个 Promise 对象,可以使用 then 方法添加回调函数。当函数执行的时候,一旦遇到 await 就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

下面是一个例子。

1
2
3
4
5
6
7
8
9
async function getStockPriceByName(name) {
const symbol = await getStockSymbol(name);
const stockPrice = await getStockPrice(symbol);
return stockPrice;
}

getStockPriceByName('goog').then(function (result) {
console.log(result);
});

上面代码是一个获取股票报价的函数,函数前面的 async 关键字,表明该函数内部有异步操作。调用该函数时,会立即返回一个 Promise 对象。

下面是另一个例子,指定多少毫秒后输出一个值。

1
2
3
4
5
6
7
8
9
10
11
12
function timeout(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

async function asyncPrint(value, ms) {
await timeout(ms);
console.log(value);
}

asyncPrint('hello world', 50);

上面代码指定 50 毫秒以后,输出 hello world

由于 async 函数返回的是 Promise 对象,可以作为 await 命令的参数。所以,上面的例子也可以写成下面的形式。

1
2
3
4
5
6
7
8
9
10
11
12
async function timeout(ms) {
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

async function asyncPrint(value, ms) {
await timeout(ms);
console.log(value);
}

asyncPrint('hello world', 50);

async 函数有多种使用形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 函数声明
async function foo() {}

// 函数表达式
const foo = async function () {};

// 对象的方法
let obj = { async foo() {} };
obj.foo().then(...)

// Class 的方法
class Storage {
constructor() {
this.cachePromise = caches.open('avatars');
}

async getAvatar(name) {
const cache = await this.cachePromise;
return cache.match(`/avatars/${name}.jpg`);
}
}

const storage = new Storage();
storage.getAvatar('jake').then(…);

// 箭头函数
const foo = async () => {};

语法

async 函数的语法规则总体上比较简单,难点是错误处理机制。

返回 Promise 对象

async 函数返回一个 Promise 对象。

async 函数内部 return 语句返回的值,会成为 then 方法回调函数的参数。

1
2
3
4
5
6
async function f() {
return 'hello world';
}

f().then(v => console.log(v))
// "hello world"

上面代码中,函数 f 内部 return 命令返回的值,会被 then 方法回调函数接收到。

async 函数内部抛出错误,会导致返回的 Promise 对象变为 reject 状态。抛出的错误对象会被 catch 方法回调函数接收到。

1
2
3
4
5
6
7
8
9
async function f() {
throw new Error('出错了');
}

f().then(
v => console.log('resolve', v),
e => console.log('reject', e)
)
//reject Error: 出错了

Promise 对象的状态变化

async 函数返回的 Promise 对象,必须等到内部所有 await 命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到 return 语句或者抛出错误。也就是说,只有 async 函数内部的异步操作执行完,才会执行 then 方法指定的回调函数。

下面是一个例子。

1
2
3
4
5
6
7
async function getTitle(url) {
let response = await fetch(url);
let html = await response.text();
return html.match(/<title>([\s\S]+)<\/title>/i)[1];
}
getTitle('https://tc39.github.io/ecma262/').then(console.log)
// "ECMAScript 2017 Language Specification"

上面代码中,函数 getTitle 内部有三个操作:抓取网页、取出文本、匹配页面标题。只有这三个操作全部完成,才会执行 then 方法里面的 console.log

await 命令

正常情况下,await 命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。

1
2
3
4
5
6
7
8
async function f() {
// 等同于
// return 123;
return await 123;
}

f().then(v => console.log(v))
// 123

上面代码中,await 命令的参数是数值 123,这时等同于 return 123

另一种情况是,await 命令后面是一个 thenable 对象(即定义了 then 方法的对象),那么 await 会将其等同于 Promise 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Sleep {
constructor(timeout) {
this.timeout = timeout;
}
then(resolve, reject) {
const startTime = Date.now();
setTimeout(
() => resolve(Date.now() - startTime),
this.timeout
);
}
}

(async () => {
const sleepTime = await new Sleep(1000);
console.log(sleepTime);
})();
// 1000

上面代码中,await 命令后面是一个 Sleep 对象的实例。这个实例不是 Promise 对象,但是因为定义了 then 方法,await 会将其视为 Promise 处理。

这个例子还演示了如何实现休眠效果。JavaScript 一直没有休眠的语法,但是借助 await 命令就可以让程序停顿指定的时间。下面给出了一个简化的 sleep 实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function sleep(interval) {
return new Promise(resolve => {
setTimeout(resolve, interval);
})
}

// 用法
async function one2FiveInAsync() {
for(let i = 1; i <= 5; i++) {
console.log(i);
await sleep(1000);
}
}

one2FiveInAsync();

编程风格

块级作用域

  • let 取代 var
  • letconst之间,建议优先使用const,尤其是在全局环境,不应该设置变量,只应设置常量。
  • 所有的函数都应该设置为常量。
  • 长远来看,JavaScript 可能会有多线程的实现(比如 Intel 公司的 River Trail 那一类的项目),这时let表示的变量,只应出现在单线程运行的代码中,不能是多线程共享的,这样有利于保证线程安全。
1
2
3
4
5
6
7
8
9
10
// bad
var a = 1, b = 2, c = 3;

// good
const a = 1;
const b = 2;
const c = 3;

// best
const [a, b, c] = [1, 2, 3];

字符串

静态字符串一律使用单引号或反引号,不使用双引号。动态字符串使用反引号。

1
2
3
4
5
6
7
8
9
10
// bad
const a = "foobar";
const b = 'foo' + a + 'bar';

// acceptable
const c = `foobar`;

// good
const a = 'foobar';
const b = `foo${a}bar`;

解构赋值

使用数组成员对变量赋值时,优先使用解构赋值。

1
2
3
4
5
6
7
8
const arr = [1, 2, 3, 4];

// bad
const first = arr[0];
const second = arr[1];

// good
const [first, second] = arr;

函数的参数如果是对象的成员,优先使用解构赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// bad
function getFullName(user) {
const firstName = user.firstName;
const lastName = user.lastName;
}

// good
function getFullName(obj) {
const { firstName, lastName } = obj;
}

// best
function getFullName({ firstName, lastName }) {
}

如果函数返回多个值,优先使用对象的解构赋值,而不是数组的解构赋值。这样便于以后添加返回值,以及更改返回值的顺序。

1
2
3
4
5
6
7
8
9
10
11
// bad
function processInput(input) {
return [left, right, top, bottom];
}

// good
function processInput(input) {
return { left, right, top, bottom };
}

const { left, right } = processInput(input);

对象

单行定义的对象,最后一个成员不以逗号结尾。多行定义的对象,最后一个成员以逗号结尾。

1
2
3
4
5
6
7
8
9
10
11
12
13
// bad
const a = { k1: v1, k2: v2, };
const b = {
k1: v1,
k2: v2
};

// good
const a = { k1: v1, k2: v2 };
const b = {
k1: v1,
k2: v2,
};

对象尽量静态化,一旦定义,就不得随意添加新的属性。如果添加属性不可避免,要使用 Object.assign 方法。

1
2
3
4
5
6
7
8
9
10
11
// bad
const a = {};
a.x = 3;

// if reshape unavoidable
const a = {};
Object.assign(a, { x: 3 });

// good
const a = { x: null };
a.x = 3;

如果对象的属性名是动态的,可以在创造对象的时候,使用属性表达式定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
// bad
const obj = {
id: 5,
name: 'San Francisco',
};
obj[getKey('enabled')] = true;

// good
const obj = {
id: 5,
name: 'San Francisco',
[getKey('enabled')]: true,
};

上面代码中,对象 obj 的最后一个属性名,需要计算得到。这时最好采用属性表达式,在新建 obj 的时候,将该属性与其他属性定义在一起。这样一来,所有属性就在一个地方定义了。

另外,对象的属性和方法,尽量采用简洁表达法,这样易于描述和书写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var ref = 'some value';

// bad
const atom = {
ref: ref,

value: 1,

addValue: function (value) {
return atom.value + value;
},
};

// good
const atom = {
ref,

value: 1,

addValue(value) {
return atom.value + value;
},
};

数组

使用扩展运算符(...)拷贝数组。

1
2
3
4
5
6
7
8
9
10
11
// bad
const len = items.length;
const itemsCopy = [];
let i;

for (i = 0; i < len; i++) {
itemsCopy[i] = items[i];
}

// good
const itemsCopy = [...items];

使用 Array.from 方法,将类似数组的对象转为数组。

1
2
const foo = document.querySelectorAll('.foo');
const nodes = Array.from(foo);

函数

立即执行函数可以写成箭头函数的形式。

1
2
3
(() => {
console.log('Welcome to the Internet.');
})();

那些使用匿名函数当作参数的场合,尽量用箭头函数代替。因为这样更简洁,而且绑定了 this

1
2
3
4
5
6
7
8
9
10
11
12
// bad
[1, 2, 3].map(function (x) {
return x * x;
});

// good
[1, 2, 3].map((x) => {
return x * x;
});

// best
[1, 2, 3].map(x => x * x);

箭头函数取代 Function.prototype.bind,不应再用 self/_this/that 绑定 this

1
2
3
4
5
6
7
8
9
10
11
// bad
const self = this;
const boundMethod = function(...params) {
return method.apply(self, params);
}

// acceptable
const boundMethod = method.bind(this);

// best
const boundMethod = (...params) => method.apply(this, params);

简单的、单行的、不会复用的函数,建议采用箭头函数。如果函数体较为复杂,行数较多,还是应该采用传统的函数写法。

所有配置项都应该集中在一个对象,放在最后一个参数,布尔值最好不要直接作为参数,因为代码语义会很差,也不利于将来增加其他配置项。

1
2
3
4
5
6
7
// bad
function divide(a, b, option = false ) {
}

// good
function divide(a, b, { option = false } = {}) {
}

不要在函数体内使用 arguments 变量,使用 rest 运算符(...)代替。因为 rest 运算符显式表明你想要获取参数,而且 arguments 是一个类似数组的对象,而 rest 运算符可以提供一个真正的数组。

1
2
3
4
5
6
7
8
9
10
// bad
function concatenateAll() {
const args = Array.prototype.slice.call(arguments);
return args.join('');
}

// good
function concatenateAll(...args) {
return args.join('');
}

使用默认值语法设置函数参数的默认值。

1
2
3
4
5
6
7
8
9
// bad
function handleThings(opts) {
opts = opts || {};
}

// good
function handleThings(opts = {}) {
// ...
}

Map 结构

注意区分 ObjectMap,只有模拟现实世界的实体对象时,才使用 Object。如果只是需要 key: value 的数据结构,使用 Map 结构。因为 Map 有内建的遍历机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
let map = new Map(arr);

for (let key of map.keys()) {
console.log(key);
}

for (let value of map.values()) {
console.log(value);
}

for (let item of map.entries()) {
console.log(item[0], item[1]);
}

Class

总是用 Class,取代需要 prototype 的操作。因为 Class 的写法更简洁,更易于理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// bad
function Queue(contents = []) {
this._queue = [...contents];
}
Queue.prototype.pop = function() {
const value = this._queue[0];
this._queue.splice(0, 1);
return value;
}

// good
class Queue {
constructor(contents = []) {
this._queue = [...contents];
}
pop() {
const value = this._queue[0];
this._queue.splice(0, 1);
return value;
}
}

使用 extends 实现继承,因为这样更简单,不会有破坏 instanceof 运算的危险。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// bad
const inherits = require('inherits');
function PeekableQueue(contents) {
Queue.apply(this, contents);
}
inherits(PeekableQueue, Queue);
PeekableQueue.prototype.peek = function() {
return this._queue[0];
}

// good
class PeekableQueue extends Queue {
peek() {
return this._queue[0];
}
}

模块

CommonJS

CommonJS 简称 CJS

  • CommonJS 是一种模块化规范,最早在服务器端的 Node.js 中广泛使用。
  • 它的特点是使用 require 函数来导入模块,使用 module.exports 导出模块。
  • CommonJS 主要用于同步加载模块,适用于服务器端环境或一些早期的前端开发工具。由于同步加载,它不适用于浏览器端,因为会阻塞页面渲染。

ECMAScript Modules

ECMAScript Modules 简称 ESM

  • ESM 是 ECMAScript(JavaScript 的标准化组织)的模块规范的一部分,它引入了 importexport 关键字用于模块加载和导出。
  • ESM 支持异步加载模块,这使得它适用于现代的浏览器环境和服务器端 Node.js。例如:
  • ESM 具有一些优势,例如更好的静态分析和树摇(Tree Shaking)能力,这可以减小最终部署代码的大小。它也是现代 JavaScript 开发中推荐的模块系统。

在浏览器端和新的 Node.js 版本中,通常更倾向于使用 ESM

推荐使用 ESM

ES6 模块语法是 JavaScript 模块的标准写法(ESM),坚持使用这种写法,取代 Node.js 的 CommonJS 语法(CJS)。

首先,使用 import 取代 require()

1
2
3
4
5
6
7
// CommonJS 的写法 (CJS)
const moduleA = require('moduleA');
const func1 = moduleA.func1;
const func2 = moduleA.func2;

// ES6 的写法 (ESM)
import { func1, func2 } from 'moduleA';

其次,使用 export 取代 module.exports

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// commonJS 的写法
var React = require('react');

var Breadcrumbs = React.createClass({
render() {
return <nav />;
}
});

module.exports = Breadcrumbs;

// ES6 的写法
import React from 'react';

class Breadcrumbs extends React.Component {
render() {
return <nav />;
}
};

export default Breadcrumbs;

如果模块只有一个输出值,就使用 export default,如果模块有多个输出值,除非其中某个输出值特别重要,否则建议不要使用 export default,即多个输出值如果是平等关系,export default 与普通的 export 就不要同时使用。

如果模块默认输出一个函数,函数名的首字母应该小写,表示这是一个工具方法。

1
2
3
4
function makeStyleGuide() {
}

export default makeStyleGuide;

如果模块默认输出一个对象,对象名的首字母应该大写,表示这是一个配置值对象。

1
2
3
4
5
6
const StyleGuide = {
es6: {
}
};

export default StyleGuide;

ESLint 的使用

ESLint 是一个语法规则和代码风格的检查工具,可以用来保证写出语法正确、风格统一的代码。

首先,在项目的根目录安装 ESLint

1
$ npm install --save-dev eslint

然后,安装 Airbnb 语法规则,以及 importa11yreact 插件。

1
2
$ npm install --save-dev eslint-config-airbnb
$ npm install --save-dev eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react

最后,在项目的根目录下新建一个 .eslintrc 文件,配置 ESLint

1
2
3
{
"extends": "eslint-config-airbnb"
}

现在就可以检查,当前项目的代码是否符合预设的规则。

index.js 文件的代码如下。

1
2
3
4
5
6
7
8
var unused = 'I have no purpose!';

function greet() {
var message = 'Hello, World!';
console.log(message);
}

greet();

使用 ESLint 检查这个文件,就会报出错误。

1
2
3
4
5
6
7
8
9
$ npx eslint index.js
index.js
1:1 error Unexpected var, use let or const instead no-var
1:5 error unused is defined but never used no-unused-vars
4:5 error Expected indentation of 2 characters but found 4 indent
4:5 error Unexpected var, use let or const instead no-var
5:5 error Expected indentation of 2 characters but found 4 indent

✖ 5 problems (5 errors, 0 warnings)

上面代码说明,原文件有五个错误,其中两个是不应该使用 var 命令,而要使用 letconst;一个是定义了变量,却没有使用;另外两个是行首缩进为 4 个空格,而不是规定的 2 个空格。

参考文献

坚持原创及高品质技术分享,您的支持将鼓励我继续创作!