0%

ECMAScript新特性

ECMAScriptJavaScript语言的规范,从2011年的ES5.1到2015年的ES6跨度比较大,这个版本更新的内容也是最多的,由此,本文主要针对ES6提出的新特性和新方法进行梳理归纳。

什么是ECMAScript

ECMAScript是一种由Ecma国际(前身为欧洲计算机制造商协会)在标准ECMA-262中定义的脚本语言规范。这种语言在万维网上应用广泛,它往往被称为JavaScriptJScript,但实际上后两者是ECMA-262标准的实现和扩展

ECMAScript是由网景布兰登·艾克开发的一种脚本语言的标准化规范;最初命名为Mocha,后来改名为LiveScript,最后重命名为JavaScript[1]。1995年12月,升阳与网景联合发表了JavaScript[2]。1996年11月,网景公司将JavaScript提交给欧洲计算机制造商协会进行标准化。ECMA-262的第一个版本于1997年6月被Ecma组织采纳。ECMAScript是由ECMA-262标准化的脚本语言的名称。

尽管JavaScript和JScript与ECMAScript兼容,但包含超出ECMAScript的功能[3]

通常,js又有web端和node本地两种实现:

在浏览器端或者说web端,javaScript包含了:

  • ECMAScript。
  • Web API,即BomDom两部分。

Node.js中的javaScript包含了:

  • ECMAScript。
  • Node API,包含fs模块、net模块等等。

所以说javaScriptecmaScript的实现与扩展,扩展的部分就是提供的各种api。

ECMAScript的发展历程

从1997年发布以来,es的迭代版本信息主要为:

版本 发表日期 与前版本的差异
1 1997年6月 首版
2 1998年6月 格式修正,以使得其形式与ISO/IEC16262国际标准一致
3 1999年12月 强大的正则表达式,更好的词法作用域链处理,新的控制指令,异常处理,错误定义更加明确,数据输出的格式化及其它改变
4 放弃 由于关于语言的复杂性出现分歧,第4版本被放弃,其中的部分成为了第5版本及Harmony的基础
5 2009年12月 新增“严格模式(strict mode)”,一个子集用作提供更彻底的错误检查,以避免结构出错。澄清了许多第3版本的模糊规范,并适应了与规范不一致的真实世界实现的行为。增加了部分新功能,如getters及setters,支持JSON以及在对象属性上更完整的反射[4][5][6][7][8]
5.1 2011年6月 ECMAScript标5.1版形式上完全一致于国际标准ISO/IEC 16262:2011。
6 2015年6月 ECMAScript 2015(ES2015),第 6 版,最早被称作是 ECMAScript 6(ES6),添加了类和模块的语法,其他特性包括迭代器,Python风格的生成器和生成器表达式,箭头函数,二进制数据,静态类型数组,集合(maps,sets 和 weak maps),promise,reflection 和 proxies。作为最早的 ECMAScript Harmony 版本,也被叫做ES6 Harmony。
7 2016年6月 ECMAScript 2016(ES2016),第 7 版,多个新的概念和语言特性[9]
8 2017年6月 ECMAScript 2017(ES2017),第 8 版,多个新的概念和语言特性[10]
9 2018年6月 ECMAScript 2018 (ES2018),第 9 版,包含了异步循环,生成器,新的正则表达式特性和 rest/spread 语法。
10 2019年6月 ECMAScript 2019 (ES2019),第 10 版
11 2020年6月 ECMAScript 2020 (ES2020),第 11 版

从上面的版本更迭中有个特殊的阶段,就是2011年的es5.1到2015年的es6,由于间隔年份较大,语法和使用特性上的变化也最大,ECMAScript2015的新特性在ES5.1的基础上做了大量的优化,所以这也是本文将要探讨的内容。

letconstvar的区别

letconst都是ES6新增的变量申明关键字。与var最主要的不同之处在于,变量作用域的不同

ES6之前,js的作用域一般分为:全局作用域和函数作用域。这样,有时候我们在使用回调函数等场景时往往由于变量的作用域问题,而不得不使用闭包特性来解决变量属于函数作用域的问题。

典型的一个问题,在循环中闭包的使用:

1
2
3
4
5
6
7
8
9
var elements = [{}, {}, {}]
for(var i = 0; i < elements.length; i++) {
elements[i].onClick = function(){
console.log(i)
}
}
elements[0].onClick() // 3
elements[1].onClick() // 3
elements[2].onClick() // 3

我们构建了elements的onClick函数并手动触发,但我们发现,给自的打印结果都是3,而不是我们预期的0, 1, 2

原因是赋值给onClick函数的是闭包,而这个参数i是函数作用域,闭包指向的也就是循环结束后最终的i值。

解决这个问题,我们需要再使用一层闭包,将i作为参数时的作用域不再是for循环中的函数作用域,而属于当前闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
function eleClick(elements, i) {
elements[i].onClick = function(){
console.log(i)
}
}

var elements = [{}, {}, {}]
for(var i = 0; i < elements.length; i++) {
eleClick(elements, i);
}
elements[0].onClick() // 0
elements[1].onClick() // 1
elements[2].onClick() // 2

这样,我们就创建了一个eleClick函数来应用闭包特性,也就独立维护了参数i的作用域,不再是外层函数的作用域。

当然,可能我们更常使用匿名闭包,但都是一个原理:

1
2
3
4
5
6
7
8
var elements = [{}, {}, {}]
for(var i = 0; i < elements.length; i++) {
(function(i){
elements[i].onClick = function(){
console.log(i)
}
})(i)
}

但是,有了es6提供的let变量申明关键字,我们不需要再使用闭包。除了上面说的全局作用局和函数作用域,es6新增了块级作用域的概念,即更小单位的作用域,一般的代码块都可以拥有自己的变量作用域,比如一个循环体内部,一个if else代码块内部,一个try catch代码块内部,代码块中的变量将不会自动变量提升,作用域只属于当前代码块,使用let或者const申明的变量就是块级作用域变量

我们将上面的循环案例中的var换成let试试看:

1
2
3
4
5
6
7
8
9
var elements = [{}, {}, {}]
for(let i = 0; i < elements.length; i++) {
elements[i].onClick = function(){
console.log(i)
}
}
elements[0].onClick() // 0
elements[1].onClick() // 1
elements[2].onClick() // 2

效果显而易见,我们只是将i声明为块作用域,这时,i是只能在循环这个代码块中访问的,所以变量也不会提升,自然不会在循环结束后保留i = 3,而且在循环外部我们将无法再访问这个变量。

总结letvar区别就是:作用域不同,let申明的变量是块级作用域,而var申明的变量是函数作用域,且能自动提升(即可以使用后申明)。

const也是块级作用域,与let的区别是,const只读变量申明,即申明的时候就需要初始化变量,且后续不能进行赋值操作。

1
2
3
4
5
6
7
8
if(true){
const a = 'hello'
console.log(a) // hello
a = 'hi'
console.log(a) // TypeError: Assignment to constant variable.
}
// const也是块级作用域,在外部使用会报错
console.log(a) // ReferenceError: a is not defined

最佳实践,建议不再使用var,主用const,搭配使用let

数组和对象的解构赋值

解构赋值ES6提出的一种新语法,通过解构赋值, 可以将属性/值从对象/数组中取出,赋值给其他变量。

对象和数组逐个对应表达式,或称对象字面量和数组字面量,提供了一种简单的定义一个特定的数据组的方法。

1
const arr = [1, 2, 3, 4, 5]

解构赋值使用了相同的语法,不同的是在表达式左边定义了要从原变量中取出什么变量。

1
2
3
4
5
const arr = [1, 2, 3, 4, 5]
const [a, b, c] = arr // 对数组arr进行解构
console.log(a) // 1
console.log(b) // 2
console.log(c) // 3

数组解构

上面的例子已经很浅显的表示了解构的基本语法,我们只需要在赋值表达式左边使用相同的装箱构造就能达到解构的目的。

左边的构造会和右边的数据一一对应,并逐一进行赋值。

有时候数组太长,我们只希望解构其中部分数据可以这样写:

1
2
3
4
5
// 解构部分
const arr = [1, 2, 3, 4, 5]
const [a, , c] = arr
console.log(a) // 1
console.log(c) // 3

有时候数组过长,可以将剩余数组赋值给一个变量

1
2
3
4
5
const arr = [1, 2, 3, 4, 5]
const [a, b, ...c] = arr
console.log(a) // 1
console.log(b) // 2
console.log(c) // [3, 4, 5]

还可以给解构时的变量设置默认值(当解构赋值的值为undefined时会使用默认值):

1
2
3
4
5
const arr = [1, 2]
const [a, b, c=0] = arr
console.log(a) // 1
console.log(b) // 2
console.log(c) // 0

使用数组解构的特性,我们还能用来交换变量

1
2
3
4
5
let a = 1;
let b = 2;
[a, b] = [b, a]
console.log(a) // 2
console.log(b) // 1

注意,这里的;不写会报Cannot access ‘b’ before initialization的错误。算是一个小bug?

利用解构的特性,我们可以复制一个数组

1
2
3
4
5
const arr = [1, 2, 3]
const [...arr2] = arr
arr[0] = 0
console.log(arr) // [ 0, 2, 3 ]
console.log(arr2) // [ 1, 2, 3 ]

对象解构

和数组的解构一样,我们在表达式左边书写对象的封装表达即可:

1
2
3
4
5
6
7
const obj = {
name: 'Json',
age: 18,
}
const {name, age} = obj // 对象的解构
console.log(name) // Json
console.log(age) // 18

注意,我们使用对象的键作为解构赋值的提取关键字和变量名,可能存在这样的情况,关键字已经被占用了

1
2
3
4
5
6
7
8
const name = 'Zoom'
const obj = {
name: 'Json',
age: 18,
}
const {name: name2, age} = obj
console.log(name) // Zoom
console.log(name2) // Json

同样,我们也能设置默认值

1
const {name: name2 = '', age = 0} = obj

有时候对象里嵌套了数组,我们同样能进行嵌套解构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 示例对象
const obj = {
hobbies: ['reading', 'gaming', 'coding'],
info: {
name: 'Jinx',
age: 28,
}
}
// 解构
const {
hobbies: [h1, h2, h3],
info: {
name,
age,
},
} = obj
// 验证
console.log(h1) // reading
console.log(h2) // gaming
console.log(h3) // coding
console.log(name) // Jinx
console.log(age) // 28

模板字符串

模板字面量是允许嵌入表达式的字符串字面量。你可以使用多行字符串和字符串插值功能。它们在ES2015规范的先前版本中被称为“模板字符串“。

我们使用符号 ` 将字符串包裹起来即可。

使用模板字符串表示多行字符串,能保留原有的格式

1
2
3
4
const str = 
`晚风吹尽 荷花叶 任我醉倒在池边
等你清除看见我的美 月光晒成眼泪`
console.log(str)

更方便的地方在于,我们能使用插值表达式进行字符串构造:

1
2
3
const name = 'Jinx',
age = 24;
console.log(`I'm ${name}, I am ${age} years old.`) // I'm Jinx, I am 24 years old.

更高级的一种用法是:带标签的模板字符串,标签使您可以用函数解析模板字符串

标签函数的第一个参数包含一个字符串值的数组。其余的参数与表达式相关。最后,你的函数可以返回处理好的的字符串(或者它可以返回完全不同的东西)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const person = 'Mike';
const age = 28;

function myTag(strings, personExp, ageExp) {
const str0 = strings[0]; // "that "
const str1 = strings[1]; // " is a "

const ageStr = ageExp > 99 ? 'centenarian' : 'youngster';

return str0 + personExp + str1 + ageStr;

}

const output = myTag`that ${ person } is a ${ age }`;

console.log(output); // that Mike is a youngster

字符串的扩展方法

ES2015新增三个字符串的原型方法:includes、startsWith、endsWith。

includes

这个方法可以帮你判断一个字符串是否包含另外一个字符串。

语法:str.includes(searchString[, position])

  • searchString:搜索的字符串

  • position:从当前字符串的哪个索引位置开始搜寻子字符串,默认值为 0

1
'Blue Whale'.includes('blue'); // return false

注:和数组的includes的用法一致。

startsWith

这个方法用来判断字符串是否以某个字符串开头。

语法:str.startWidth(searchString[, startPosition])

变量意义同includs一样。

1
2
console.log('Blue Whale'.startsWith('Blue')) // true
console.log('Blue Whale'.startsWith('lue', 1)) // true

endsWith

这个方法用来判断字符串是否以某个字符串开头。

语法:str.startWidth(searchString[, endPosition])

不同的是,endPosition是规定结束的位置,默认为字符串长度。

1
2
console.log('Blue Whale'.endsWith('ale')) // true
console.log('Blue Whale'.endsWith('ue', 4)) // true

参数默认值

在ES2015之前,我们常常需要在函数内部进行参数默认值赋值:

1
2
3
4
5
6
function add(num1, num2){
num1 = num1 === undefined ? 0 : num1;
num2 = num2 === undefined ? 0 : num2;
return num1 + num2;
}
console.log(add(3)) // 3

有了默认参数值这种语法结构,我们可以更为简单的书写代码:

1
2
3
4
function add(num1=0, num2=0){
return num1 + num2;
}
console.log(add(3)) // 3

值得注意的是,带有默认值的参数必须写在非默认参数的后面。

剩余参数

当我们申明一个未知参数数量的函数时,我们常常使用内置的类数组对象arguments来接受所有参数。

1
2
3
4
5
6
7
8
function add(){
let n = 0;
Array.from(arguments).forEach(arg => {
n += arg;
});
return n;
}
console.log(add(1, 2, 3)) // 6

而ES6提供了一种新的展开语法,使用... 可以让我们直接封装剩余参数:

1
2
3
4
5
6
7
function add(num = 0, ...others){
others.forEach(n => {
num += n;
});
return num;
}
console.log(add(1, 2, 3)) // 6

剩余参数会将多余未接受的参数放到一个数组中,最为一个参数传递到函数中。

展开操作符...

上面我们已经提到了剩余参数就是使用...来实现的。此外...操作符还有别的用法。

比如我们想要循环打印一个数组,你可能会这样来操作:

1
2
3
4
5
6
const arr = [1, 2, 3]

// 打印方法1
console.log(arr[0], arr[1], arr[2]);
// 打印方法2
console.log.apply(this, arr);

有了展开操作符,我们可以直接这样写:

1
2
const arr = [1, 2, 3]
console.log(...arr);

箭头函数

箭头函数表达式的语法比函数表达式更简洁,并且没有自己的thisargumentssupernew.target。箭头函数表达式更适用于那些本来需要匿名函数的地方,并且它不能用作构造函数。

让你的代码更简洁优美

1
2
3
4
5
6
7
8
9
10
11
// 一般写法
function add(n1, n2) {
return n1 + n2;
}
// 箭头函数
const add2 = (n1, n2) => n1 + n2;

// 回调箭头函数
let num = 0;
[1,2,3].forEach(n => num += n)
console.log(num) // 6

没有单独的this对象

在箭头函数出现之前,每一个新函数根据它是被如何调用的来定义这个函数的this值:

  • 如果是该函数是一个构造函数,this指针指向一个新的对象
  • 在严格模式下的函数调用下,this指向undefined
  • 如果是该函数是一个对象的方法,则它的this指针指向这个对象
  • 等等

this被证明是令人厌烦的面向对象风格的编程。

1
2
3
4
5
6
7
8
9
10
11
12
function Person() {
// Person() 构造函数定义 `this`作为它自己的实例.
this.age = 0;

setInterval(function growUp() {
// 在非严格模式, growUp()函数定义 `this`作为全局对象,
// 与在 Person()构造函数中定义的 `this`并不相同.
this.age++;
}, 1000);
}

var p = new Person();

而通过箭头函数来书写可以解决这个问题:

1
2
3
4
5
6
7
8
function Person() {
var that = this;
that.age = 0;

setInterval(function growUp() {
// 回调引用的是`that`变量, 其值是预期的对象.
that.age++;

通过 call 或 apply 调用时需要注意:由于 箭头函数没有自己的this指针,通过 call() apply() 方法调用一个函数时,只能传递参数,他们的第一个参数会被忽略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var adder = {
base : 1,

add : function(a) {
var f = v => v + this.base;
return f(a);
},

addThruCall: function(a) {
var f = v => v + this.base;
var b = {
base : 2
};

return f.call(b, a);
}
};

console.log(adder.add(1)); // 输出 2
console.log(adder.addThruCall(1)); // 仍然输出 2

其他需要注意的点:

  • 箭头函数不绑定Arguments 对象。因此,arguments只是引用了封闭作用域内的。

  • 箭头函数不能用作构造器,和 new一起用会抛出错误。

  • 箭头函数没有prototype属性。

  • yield 关键字通常不能在箭头函数中使用(除非是嵌套在允许使用的函数内)。因此,箭头函数不能用作函数生成器。

  • 写法

    1
    2
    3
    4
    5
    var func = x => x * x;                  
    // 简写函数 省略return

    var func = (x, y) => { return x + y; };
    //常规编写 明确的返回值

对象字面量的增强

ES6之前,我们在构建对象时需要严格按照键值对的写法:key: value,现在我们可以更简洁自由得书写:

1
2
3
4
5
6
7
8
9
10
11
12
const name = 'Jinx';
const age = 22;
// 增强写法1
const obj = {
name,
age,
}
// 增强写法2
const key = 'haha'
obj[[key]] = 'hehe' // 我们可以在[]内写一些表达式
console.log(obj.key) // undefined
console.log(obj.haha) // hehe

Object的新增的一些方法

Object.assign

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。

1
2
3
4
5
6
const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };

const returnedTarget = Object.assign(target, source);

console.log(target); Object { a: 1, b: 4, c: 5 }

借此,我们可以用来拷贝一个对象

1
const objCopy = Object.assign({}, obj);

但需要注意的是,针对深拷贝,需要使用其他办法,因为 Object.assign()拷贝的是(可枚举)属性值。

Object.is

Object.is() 方法判断两个值是否为同一个值

判断两个值是否相等,我们常常使用==或者===来判断,那这个Object.is又有什么不同呢?

Object.is的规则如下:

  • 都是 undefined
  • 都是 null
  • 都是 truefalse
  • 都是相同长度的字符串且相同字符按相同顺序排列
  • 都是相同对象(意味着每个对象有同一个引用)
  • 都是数字且
    • 都是 +0
    • 都是 -0
    • 都是 NaN
    • 或都是非零而且非 NaN 且为同一个值

对于严格比较运算符(===)来说,仅当两个操作数的类型相同且值相等为 true,而对于被广泛使用的比较运算符(==)来说,会在进行比较之前,将两个操作数转换成相同的类型。

== 运算不同。 == 运算符在判断相等前对两边的变量(如果它们不是同一类型) 进行强制转换 (这种行为的结果会将 "" == false 判断为 true), 而 Object.is不会强制转换两边的值。

=== 运算也不相同。 === 运算符 (也包括 == 运算符) 将数字 -0+0 视为相等 ,而将Number.NaNNaN视为不相等.

简单来说Object.is===严格程度很接近,但不同在于-0+0前者视为不等,后者视为相等;而对于NaN则是前者视为相等,后者视为不等。

1
2
3
4
5
console.log(+0 === -0)  // true
console.log(NaN === NaN) // false

console.log(Object.is(+0, -0)) // false
console.log(Object.is(NaN, NaN)) // true

代理对象Proxy

Proxy 对象用于定义基本操作的自定义行为(如属性查找、赋值、枚举、函数调用等)。

proxy代理的意思,正如引用描述的一样,Proxy 对象用于定义基本操作的自定义行为,即我们可以使用Proxy来自定义基本操作,也就达到了监听数据变化的作用。

语法

1
const p = new Proxy(target, handler)
  • target:被 Proxy 代理虚拟化的对象。它常被作为代理的存储后端。根据目标验证关于对象不可扩展性或不可配置属性的不变量(保持不变的语义)。要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
  • handler:包含捕捉器(trap)的占位符对象,可译为处理器对象。一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

handler 对象是一个容纳一批特定属性的占位符对象。它包含有 Proxy 的各个捕获器(trap)。

handler对象的方法

应用

一个简单的示例:

1
2
3
4
5
6
7
8
9
const p = new Proxy({}, {
get(obj, prop){
return prop in obj ? obj[prop] : null;
}
});

p.a = 'haha'
console.log(p.a) // haha
console.log(p.b) // null

通过代理,你可以轻松地验证向一个对象的传值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const validator = {
set: function(obj, prop, value) {
if (prop === 'age') {
if (!Number.isInteger(value)) {
throw new TypeError('The age is not an integer');
}
if (value > 200) {
throw new RangeError('The age seems invalid');
}
}

// The default behavior to store the value
obj[prop] = value;

// 表示成功
return true;
}
};

const p = new Proxy({}, validator);

p.age = '' // TypeError
p.age = 300 // RangeError
p.age = 89 // true

Object.defineProperty的对比

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

其实defineProperty也是一种代理对象,但proxy更为强大,实现的捕捉器更多。

此外,proxy对数组的捕获有更好地支持,比如set方法能够监听数组的push等赋值的方法。

统一对象的操作API: Reflect

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与proxy handlers的方法相同。Reflect不是一个函数对象,因此它是不可构造的。

当我们使用Proxy代理对象时,没有进行处理器对象proxy.handles的初始化也能正常使用代理对象,这是因为默认处理器对象中的方法默认都是Reflect提供的静态方法,此外,Reflect对象中还提供了一些对象的方法。具体参考:Reflect - MDN

可能你在使用中发现Reflect中的方法都可能有别的写法,比如下面一些常见的对象操作方法:

1
2
3
4
5
6
7
8
const obj = {
name: 'Jinx',
age: 18,
hobbies: [],
}
console.log('name' in obj) // true
console.log(Object.keys(obj)) // [ 'name', 'age', 'hobbies' ]
console.log(delete obj.hobbies) // true

可以发现,既有操作符也有对象方法,使用起来比较混乱,我们可以使用Reflect中的方法来替代,会更具语义化:

1
2
3
4
5
6
7
8
const obj = {
name: 'Jinx',
age: 18,
hobbies: [],
}
console.log(Reflect.has(obj, 'name')) // true
console.log(Reflect.ownKeys(obj)) // [ 'name', 'age', 'hobbies' ]
console.log(Reflect.deleteProperty(obj, 'hobbies')) // true

Promise

Promise 对象用于表示一个异步操作的最终完成 (或失败), 及其结果值。

Promise对象是ES6提出的一个用于解决传统异步编程回调函数嵌套过深等问题而设计的,Promise的内容比较多,本文不在此展开,而会在另一篇文章中详细介绍,你可以访问这里进行查看:TODO

或者你可以浏览官方说明:Promise - MDN

类class

ECMAScript 2015 中引入的 JavaScript 类实质上是 JavaScript 现有的基于原型的继承的语法糖。类语法不会为JavaScript引入新的面向对象的继承模型。

Class实际上是个“特殊的函数”,就像你能够定义的函数表达式函数声明一样,类语法有两个组成部分:类表达式类声明

类的出现只是相较于原型链,能让我们更好地理解和处理面向对象的逻辑关系,本质还是一个函数。

类的定义

类的定义有两种方法,类似函数的定义,我们使用关键字class进行声明,或者使用类表达式:

1
2
3
4
5
6
7
8
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
const p = new Person('Jinx', 22);
console.log(p.name) // Jinx

类声明和类表达式的主体都执行在严格模式下。比如,构造函数,静态方法,原型方法,gettersetter都在严格模式下执行。

构造函数

constructor方法是一个特殊的方法,这种方法用于创建和初始化一个由class创建的对象。一个类只能拥有一个名为 “constructor”的特殊方法。如果类包含多个constructor的方法,则将抛出 一个SyntaxError

一个构造函数可以使用 super 关键字来调用一个父类的构造函数。

原型方法

参见方法定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person {
// 构造函数
constructor(name, age) {
this.name = name;
this.age = age;
}
// getter
get sign() {
return this.age >= 18 ? 'adult' : 'child';
}
// 普通方法
selfIntroduction() {
return `hello, my name is ${this.name}, I'm ${this.age} years old.`;
}
}
const p = new Person('Jinx', 22);
console.log(p.sign); // adult
console.log(p.selfIntroduction()); // hello, my name is Jinx, I'm 22 years old.

静态方法

static 关键字用来定义一个类的一个静态方法。调用静态方法不需要实例化该类,但不能通过一个类实例调用静态方法。静态方法通常用于为一个应用程序创建工具函数。

1
2
3
4
5
6
7
8
class Person {
// 静态方法
static run() {
return 'running....';
}
}
// console.log(p.run()); // TypeError: p.run is not a function
console.log(Person.run()) // running...

静态方法不需要实例化就能调用,实例化之后反而不能调用是因为,静态方法是直接绑定在类(或者说函数)上面的,而普通方法都在原型链上。

共有字段和私有字段(实验阶段功能)

公共和私有字段声明是JavaScript标准委员会TC39提出的实验性功能(第3阶段)。浏览器中的支持是有限的,但是可以通过Babel等系统构建后使用此功能。

上面的声明可以写成:

1
2
3
4
5
6
7
8
class Person {
name = ''; // 共有字段
#age = 0; // 私有字段
constructor(name, age) {
this.name = name;
this.age = age;
}
}

私有字段特殊在于,只能在类内部使用,其子类是不能访问这些私有字段的,具体如何实现可参考下一章节。

继承

在类声明时使用关键字extends可以继承一个类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}

class Student extends Person {
constructor(name, age, classNumber) {
super(name, age);
this.classNumber = classNumber;
}
}

const s = new Student('Jinx', 17, '20200910');
console.log(s.classNumber);

注意,如果子类中定义了构造函数,那么它必须先调用 super() 才能使用 this

除了继承一般类,也可以继承传统的基于函数的“类”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Person(name, age){
this.name = name;
this.age = age;
}
Person.prototype.say = function(){
return `I am ${this.name}, I am ${this.age} years old.`;
}

class Student extends Person {
constructor(name, age, classNumber) {
super(name, age);
this.classNumber = classNumber;
}

say(){
return super.say() + `My classNumber is ${this.classNumber}`;
}
}

const s = new Student('Jinx', 17, '20200910');
console.log(s.say());

但类不能继承常规对象(不可构造的)。如果要继承常规对象,可以改用 Reflect.setPrototypeOf() 或者Object.setPrototypeOf()(可设置对象的原型(即内部的 [[Prototype]] 属性)为另一个对象或 null,如果操作成功返回 true,否则返回 false。)

警告:由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]]在**各个**浏览器和 JavaScript 引擎上都是一个很慢的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const Person = {
name: '',
age: 0,
say() {
`I am ${this.name}, I am ${this.age} years old.`;
},
}

class Student {
constructor(name, age, classNumber) {
this.name = name;
this.age = age;
this.classNumber = classNumber;
}

say(){
return `I am ${this.name}, I am ${this.age} years old.My classNumber is ${this.classNumber}`;
}
}
Reflect.setPrototypeOf(Student.prototype, Person); // 没有原型方法的对象,只能通过这种方式"继承",不推荐使用

const s = new Student('Jinx', 17, '20200910');
console.log(s.say());

派生类Species

你可能希望在派生数组类 MyArray 中返回 Array对象。这种 species 方式允许你覆盖默认的构造函数。

例如,当使用像map()返回默认构造函数的方法时,您希望这些方法返回一个父Array对象,而不是MyArray对象。Symbol.species 符号可以让你这样做:

1
2
3
4
5
6
7
8
9
10
class MyArray extends Array {
// Overwrite species to the parent Array constructor
static get [Symbol.species]() { return Array; }
}
var a = new MyArray(1,2,3);
var mapped = a.map(x => x * x);

console.log(a instanceof MyArray); // true
console.log(mapped instanceof MyArray); // false
console.log(mapped instanceof Array); // true

多继承Mix-ins

请参考另一篇文章:Javascript中的多继承与混合类Mixin

利用ES5语法实现ES6的类Class

为了更好地理解类的实现原理,我们尝试使用ES5的语法进行实现。

类声明的实现

首先是实现类的定义,类的本质还是一个函数,所以定义类就是在定义一个函数:

1
2
3
4
5
6
7
8
9
10
11
class Person {
constructor(name, age){
this.name = name;
this.age = age;
}
}
// =====> es5
function Person(name, age) {
this.name = name;
this.age = age;
}

有时候为了保证Person类能正常创建实例,我们可以加上这样的检测函数:

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
/** 
* 检查实例与类是否符合规范
*/
function _classCallCheck(instance, Constructor){
// 无法创建改函数(类)的实例
if(!_instanceOf(instance, Constructor)){
throw new TypeError("Cannot call a class as a function");
}
}

/**
* 判断对象是否为某个类的实例
*/
function _instanceOf(instance, Constructor) {
if(Constructor && Symbol && Constructor[Symbol.hasInstance]) {
return !!Constructor[Symbol.hasInstance](instance);
}
return instance instanceof Constructor;
}

function Person(name, age) {
_classCallCheck(this, Person);
this.name = name;
this.age = age;
}
  • Constructor[Symbol.hasInstance](obj)用于判断某对象是否为某构造器的实例。
  • 在函数的定义中,函数名本身就相当于构造器本身,而this指向该类的实例,本质上就是一个对象。

原型方法的实现

原型方法顾名思义,就是绑定在原型链上的函数,比如新增一个原型方法say

1
2
3
4
5
6
7
8
9
10
11
function Person(name, age) {
_classCallCheck(this, Person);
this.name = name;
this.age = age;
}

Person.prototype.say = function(){
return console.log(`hello, My name is ${this.name}, I'm ${this.age} years old.`);
}

new Person('Jinx', 23).say(); // hello, My name is Jinx, I'm 23 years old.

为了写得更严谨一些,我们可以使用代理对象(ES6可以用Proxy,这里是ES5版本,所以我们还是使用Object.defineProperty)来实现原型链的函数绑定:

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
/** 
* 使用代理对象绑定属性
*/
function _defineProperties(obj, props) {
props && props.forEach(prop => {
Object.defineProperty(obj, prop.key, {
configurable: !!prop.configurable,
enumerable: !!prop.enumerable,
writable: true,
value: prop.value
})
});
}

function Person(name, age) {
_classCallCheck(this, Person);
this.name = name;
this.age = age;
// 原型函数的绑定
_defineProperties(Person.prototype, [{
key: 'say',
value: function() {
console.log('hello, My name is '.concat(this.name, ", I'm ", this.age, ' years old.'));
}
}]);
}

静态方法的实现

静态方法与原型方法的不同之处就在于,静态方法是直接绑定在构造器上的,所以直接不用实例就能调用,比如我们把say方法改为今天方法,只需要修改代理对象的即可:(Person.prototype -> Person)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var Person = (function (name, age) {
function Person() {
_classCallCheck(this, Person);
this.name = name;
this.age = age;
}
// 静态函数
_defineProperties(Person, [{
key: 'say',
value: function() {
console.log('hello, My name is '.concat(this.name, ", I'm ", this.age, ' years old.'));
}
}]);
return Person;
})();

我们发现,这里使用了类表达式来申明,这是因为静态函数不需要创建实例就能使用,所以我们在申明函数时就需要绑定好静态方法与构造函数之间的关系。

字段的绑定

属性也就是同方法的绑定没有太大区别,一般属性绑定在实例对象上,而静态属性直接绑定在构造函数上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const Person = (function(){
function Person(name){
this.name = name;
}
// 字段(属性)
Person.prototype.age = 0;
// 静态属性
Person.flag = 'person class';
return Person;
})();

const p = new Person('Jinx');
console.log(p.name) // 'Jinx'
console.log(p.age) // 0
console.log(p.flag) // undefined
console.log(Person.flag) // person class

继承的实现

当谈到继承时,JavaScript 只有一种结构:对象。每个实例对象( object )都有一个私有属性(称之为 proto )指向它的构造函数的原型对象(prototype )。该原型对象也有一个自己的原型对象( proto ) ,层层向上直到一个对象的原型对象为 null。根据定义,null 没有原型,并作为这个原型链中的最后一个环节。

几乎所有 JavaScript 中的对象都是位于原型链顶端的 Object 的实例。

由于篇幅过长,会另外开启一篇文章进行梳理,可以参考这里TODO

此外,我们也可以使用下面的地址将ES6代码转化为ES5代码,方便我们对比各个版本之间的写法:

集合Set对象

Set对象是值的集合,你可以按照插入的顺序迭代它的元素。 Set中的元素只会出现一次,即 Set 中的元素是唯一的。

你可以把Set理解为没有重复元素的数组。语法如下:

new Set([iterable])

你可以传入一个可迭代对象iterable作为参数,这样,集合就能接手并不重复地把迭代对象中的元素加入到集合实例中。

集合Setapi和数组类似,比如常用的一些api

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const s = new Set([1, 2, 3]);

// 实例属性 size :表示集合长度(元素个数)
console.log(s.size); // 3

// [添加]一个元素,返回实例本身,所以你可以链式调用
console.log(s.add(4).add(5)); // Set { 1, 2, 3, 4, 5 }

// [删除]一个元素,成功则返回true,否则返回false
console.log(s.delete(3)) // true

// [清空]所有元素,无返回值
console.log(s.clear()) // undefined

// [has]判断元素是否存在集合中
console.log(s.has(0)); // false
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
const s = new Set([1, 2, 3]);

// 返回一个新的【迭代器对象】,这个对象的元素是类似 [value, value] 形式的数组,即IterableIterator<[number, number]>
for(let ent of s.entries()) {
console.log(ent);
}
// [ 1, 1 ]
// [ 2, 2 ]
// [ 3, 3 ]

// 返回一个新的【迭代器对象】,与entries不同在于,values | keys 方法只返回集合中元素的迭代对象(由于键和值一样,这两个方法也无区别)
for (let ent of s.values()) {
console.log(ent);
}
// 1
// 2
// 3

// 【forEach】遍历集合
s.forEach((key, val, set) => {
console.log(key, '-', val);
});
// 1 - 1
// 2 - 2
// 3 - 3

注:for of循环也是ES6提出的一种新循环语句,只要实现可迭代接口,就能遍历任意数据类型。详细请参考后文。

映射集合Map对象

Map 对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者原始值) 都可以作为一个键或一个值。

说到键值对对象,我们很容易想到js最普遍的Object对象就是一种键值对对象,那为什么还需要Map呢?我们先看一个例子:

1
2
3
4
5
6
7
8
const obj = {
name: 'Jinx',
true: 'true',
1: 1,
[undefined]: null,
}
let keys = Object.keys(obj);
console.log(keys); // [ '1', 'name', 'true', 'undefined' ]

可以发现,Object这种映射只是字符串 - 数据的映射,不管我们传入什么值作为键,都会被转为字符串或者Symbol(ES6新增的一种数据类型,见后文)。

Map是真正的数据之间的映射,Map的之间的键可能是任意类型的对象(数据),语法如下:

new Map([iterable])

Iterable 可以是一个数组或者其他 iterable 对象,其元素为键值对(两个元素的数组,例如: [[ 1, ‘one’ ],[ 2, ‘two’ ]])。 每个键值对都会添加到新的 Map。null 会被当做 undefined。

1
2
3
4
5
6
7
8
9
10
11
let say = 11;
const map = new Map([
['name', 'Jinx'],
['age', 22],
[say, function(){
return 'say something';
}],
])
console.log(map); // Map { 'name' => 'Jinx', 'age' => 22, 11 => [Function] }

console.log(map.get(say)()); // say something

一个Map对象在迭代时会根据对象中元素的插入顺序来进行 — 一个 for...of 循环在每次迭代后会返回一个形式为[key,value]的数组。

一些基本的api,和Set很类似:

  • clear():清空集合。
  • delete(key):按照键删除该映射关系,成功则返回true。
  • entries():返回一个新的 Iterator 对象,它按插入顺序包含了Map对象中每个元素的 [key, value] 数组
  • forEach():按插入顺序,为 Map对象里的每一键值对调用一次callbackFn函数。
  • get(key):根据映射关系的键返回对应的值。
  • set(key, value):设置Map对象中键的值。返回该Map对象。
  • has(key):判断该键是否存在对应的映射关系,注意只要设置了映射关系,就算值为undefined也返回true。
  • keys():按插入顺序返回集合键的Iterator对象。
  • values():按插入顺序返回集合值的Iterator对象。

总结一下MapObject区别

- Map Object
意外的键 Map 默认情况不包含任何键。只包含显式插入的键。 一个 Object 有一个原型, 原型链上的键名有可能和你自己在对象上的设置的键名产生冲突。注意: 虽然 ES5 开始可以用 Object.create(null) 来创建一个没有原型的对象,但是这种用法不太常见。
键的类型 一个 Map的键可以是任意值,包括函数、对象或任意基本类型。 一个Object 的键必须是一个 String 或是Symbol
键的顺序 Map 中的 key 是有序的。因此,当迭代的时候,一个 Map 对象以插入的顺序返回键值。 一个 Object 的键是无序的注意:自ECMAScript 2015规范以来,对象确实保留了字符串和Symbol键的创建顺序; 因此,在只有字符串键的对象上进行迭代将按插入顺序产生键。
Size Map 的键值对个数可以轻易地通过size 属性获取 Object 的键值对个数只能手动计算
迭代 Mapiterable 的,所以可以直接被迭代。 迭代一个Object需要以某种方式获取它的键然后才能迭代。
性能 在频繁增删键值对的场景下表现更好。 在频繁添加和删除键值对的场景下未作出优化。

新数据类型Symbol

新增的一种原始数据类型,读作“符号”。该类型的性质在于这个类型的值可以用来创建匿名的对象属性

symbol 数据类型具有非常明确的目的,并且因为其功能性单一的优点而突出;一个 symbol 实例可以被赋值到一个左值变量,还可以通过标识符检查类型,这就是它的全部特性。

【目的】唯一性:在 JavaScript 运行时环境中,一个符号类型值可以通过调用函数 Symbol() 创建,这个函数动态地生成了一个匿名,唯一的值

【特点】匿名性:当一个 symbol 类型的值在属性赋值语句中被用作标识符,该属性(像这个 symbol 一样)是匿名的;并且是不可枚举的。它同样不会出现在 “Object.getOwnPropertyNames()” 的返回数组里。这个属性可以通过创建时的原始 symbol 值访问到,或者通过遍历 “Object.getOwnPropertySymbols()” 返回的数组。

Symbol 类具有一些静态属性,对于匿名命名来说,这带有一点讽刺意味。这类属性只有几个; 它们是所谓的“众所周知”的 symbol。 它们是在某些内置对象中找到的某些特定方法属性的 symbol。 暴露出这些 symbol 使得可以直接访问这些行为;这样的访问可能是有用的,例如在定义自定义类的时候。 普遍的 symbol 的例子有:“Symbol.iterator”用于类似数组的对象,“Symbol.search”用于字符串对象。

Symbol()函数及其创建的 symbol 值可能对设计自定义类的编程人员有用。 symbol 值提供了一种自定义类可以创建私有成员的方式(匿名,所以不能被继承下去),并维护一个仅适用于该类的 symbol 注册表。 在类定义中,动态创建的 symbol 值将保存到作用域变量中,该变量只能在类定义中私有地使用。 没有 token 字符串; 作用域变量起到 token 的等同作用。

举个简单的例子,文章前文对比过ObjectMap区别,其中一点就是Object常常会造成不确定的键导致数据覆盖。比如:

main.js向外暴露出一个obj对象,但其他模块是不知道该对象有哪些键的,如果使用到了相同的键就会发生不必要的意外,一般的做法是约定自己内部使用的键,加上特殊的标记,比如b.js的做法:加上特殊前缀:b_

1
2
3
4
5
6
7
8
9
10
11
// --- main.js
const obj = {
name: 'Jinx',
age: 23,
}

// --- a.js
obj.name = 'Tom' // 发生覆盖

// --- b.js
obj.b_name = 'Yasuo' // 做一个约定,避免覆盖

symbol的出现很好地解决了这一问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
// --- main.js
const obj = {
name: 'Jinx',
age: 23,
}

// --- a.js
const name = Symbol();
obj[name] = 'Tom'

console.log(obj['name']); // Jinx
console.log(obj[name]); // Tom
console.log(obj[Symbol()]); // undefined

for...of循环语句

for...of语句是一种新的循环语句,更好地说法叫迭代器,能遍历可迭代对象的所有元素,理论上可以遍历所有类型的对象,只要实现了可迭代接口(详情见后文)。基本语法如下:

for (variable of iterable) {
//statements
}

  • variable:在每次迭代中,将不同属性的值分配给变量。
  • iterable:被迭代枚举其属性的对象。

比如数组的迭代,我们常常使用forEach来进行操作,但该函数由于是回调函数操作,无法结束循环,所以我们可以换成for...of来遍历数组:

1
2
3
4
5
6
7
8
9
const arr = [1, 2, 3, 5, 8, 9, 11];
for(let val of arr) {
if(val > 5) break;
console.log(val);
}
// 1
// 2
// 3
// 5

此外,我们还可以迭代一些常见的数据对象:

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
console.log('----迭代字符串----')
const str = 'abc'
for(let c of str) {
console.log(c) // a b c
}
for(let c in str) {
console.log(c) // 0 1 2
}

console.log('----迭代Set----')
const set = new Set(['a', 'b', 'c']);
for(let s of set){
console.log(s); // a b c
}
for(let s in set){ // 无法枚举,不报错,但不会执行
console.log(s);
}

console.log('----迭代Map----')
const map = new Map([
[1, 'a'],
[2, 'b'],
[3, 'c'],
]);
for(let [k,v] of map){
console.log(k, '-' , v); // 1 - a 2 - b 3 - c
}
for(let s in map){ // 无法枚举,不报错,但不会执行
console.log(s);
}

console.log('----迭代Dom集合----')
//注意:这只能在实现了NodeList.prototype[Symbol.iterator]的平台上运行
let articleParagraphs = document.querySelectorAll("article > p");
for (let paragraph of articleParagraphs) {
paragraph.classList.add("read");
}

对于for...of的循环,可以由break, throw continue return终止。在这些情况下,迭代器关闭。

我们可以发现,for...of可以迭代很多内置对象的实例,包括for in无法枚举的SetMap这些对象,实际上:

所以,for...in能迭代的是具有可枚举属性的对象,然后再根据属性来获取对象的值,而for...of能迭代一切实现了可迭代接口的对象,我们能直接迭代数组这些内置对象,是因为这些对象的意见内置实现了可迭代接口。

可能你已经发现了,我们常常使用到的Object对象反而无法进行迭代,如果我们需要我们的自定义对象能够迭代,还需要我们自行实现可迭代接口,请直接阅读下一章节。

可迭代接口

可迭代接口或者说可迭代协议,允许 JavaScript 对象定义或定制它们的迭代行为,例如,在一个 for..of 结构中,哪些值可以被遍历到。一些内置类型同时是内置可迭代对象,并且有默认的迭代行为,比如 Array 或者 Map,而其他内置类型则不是(比如 Object))。

要成为可迭代对象, 一个对象必须实现 **@@iterator** 方法。这意味着对象(或者它原型链上的某个对象)必须有一个键为 @@iterator 的属性,可通过常量 Symbol.iterator 访问该属性:

属性
[Symbol.iterator] 返回一个符合迭代器协议的对象的无参数函数。

注:此函数可以是普通函数,也可以是生成器函数,以便在调用时返回迭代器对象。 在此生成器函数的内部,可以使用yield提供每个条目。(生成器函数详情可参考下一章节)

迭代器协议

只有实现了一个拥有以下语义(semantic)的 next() 方法,一个对象才能成为迭代器:

属性
next 一个无参数函数,返回一个应当拥有以下两个属性的对象:done(boolean)如果迭代器可以产生序列中的下一个值,则为 false。(这等价于没有指定 done 这个属性。)如果迭代器已将序列迭代完毕,则为 true。这种情况下,value 是可选的,如果它依然存在,即为迭代结束之后的默认返回值。value迭代器返回的任何 JavaScript 值。done 为 true 时可省略。next() 方法必须返回一个对象,该对象应当有两个属性: donevalue,如果返回了一个非对象值(比如 falseundefined),则会抛出一个 TypeError 异常("iterator.next() returned a non-object value")。

总结来说就是,实现迭代器接口就是,需要实现一个[Symbol.iterator]函数,该函数必须实现了next函数,该netx函数必须返回具有donevalue属性的对象。

可能描述有点长,我们来简单实现一下就明了了,假设有这么一个对象需要我们使用for...of进行迭代,我们希望能使用迭代器返回对应的nameage

1
2
3
4
const obj = {
names: ['Jinx', 'Yasuo', 'Cat'],
ages: [22, 23, 4],
}

如果直接使用迭代器是会报错的:

1
2
3
for(let o of obj) { // TypeError: obj is not iterable
console.log(o)
}

第一步:实现迭代器函数[Symbol.iterator]

1
2
3
4
5
6
7
const obj = {
names: ['Jinx', 'Yasuo', 'Cat'],
ages: [22, 23, 4],
[Symbol.iterator]: function() {
console.log('....')
},
}

第二步:该函数需要实现一个next函数:

1
2
3
4
5
6
7
8
9
10
11
const obj = {
names: ['Jinx', 'Yasuo', 'Cat'],
ages: [22, 23, 4],
[Symbol.iterator]: function() {
return {
next: function() {
console.log('next...')
}
}
},
}

第三步next函数必须返回具有donevalue属性的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const obj = {
names: ['Jinx', 'Yasuo', 'Cat'],
ages: [22, 23, 4],
[Symbol.iterator]: function() {
const self = this;
const len = Math.min(self.names.length, self.ages.length);
let index = -1;
return {
next: function() {
return {
done: index++ >= len - 1, // 结束标记
value: [self.names[index], self.ages[index]], // 每次迭代的返回值
};
}
}
},
}

这样,我们就能使用迭代语句进行遍历了:

1
2
3
4
5
6
for(let o of obj) {
console.log(o);
}
// [ 'Jinx', 22 ]
// [ 'Yasuo', 23 ]
// [ 'Cat', 4 ]

为何要实现可迭代接口

在上面的使用场景中,我们不需要实现迭代器接口也能实现对应的功能,但由于模块化的开发越来越普遍,可能这个自定义对象是别人给的,或者说是需要给别人的,也就是不同的人在维护,假设你没有实现可迭代接口,那么使用的人就需要自己遍历数据,但是对象数据是可能发生变更的,比如上面的obj又新增了一个skills字段需要遍历,那么每个使用到这个对象的人都需要修改自己的遍历器,这是比较麻烦的。

但是实现了可迭代接口,每个使用到的人只需要使用相同的for...of这样的迭代语句就能拿到迭代器设计好的数据,也就是对外暴露统一的迭代方法,而无需让使用者担心数据变化带来困扰。

可以发现,迭代语句for...of完全可以使用普通的for或者while等实现,但这相当于一种设计思想,实现统一的接口能为我们解决很多应用场景上的麻烦和带来使用上的便利。

生成器Generator

生成器对象是由一个 generator function 返回的,并且它符合可迭代协议迭代器协议

单词generator为“发电机”的意思,生成器也是这个道理,生成器函数能源源不断的提供数据,直到return结束。

定义一个生成器函数

生成器函数的语法十分简单,只需要把普通函数的声明关键字function变成function*function *即可:

1
2
3
4
5
6
function* foo(){
console.log('this is a generator.');
return 100;
}

console.log(foo()) // Object [Generator] {}

我们打印发现,函数返回了一个生成器对象,并非return语句后的结果。上面我们说到,生成器对象实现了可迭代协议,所以我们需要使用next函数取出数据:

1
console.log(foo().next()) // { value: 100, done: true }

yield关键字

当然,如果只是这样的迭代器效果,就不能体现生成器的特点了,一般迭代器需要和yield关键字搭配使用。yield关键字使生成器函数执行暂停yield关键字后面的表达式的值返回给生成器的调用者。它可以被认为是一个基于生成器的版本的return关键字。

我们改造一下上面的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function* foo(){
let index = 0;

yield index++;
yield index++;
yield index++;
}

const gnr_foo = foo();
console.log(gnr_foo.next()) // { value: 0, done: false }
console.log(gnr_foo.next()) // { value: 1, done: false }
console.log(gnr_foo.next()) // { value: 2, done: false }
console.log(gnr_foo.next()) // { value: undefined, done: true }

虽然函数体没有写retrun语句,但和普通函数一样,有一个默认的return undefined

生成器的应用

生成器的特点就是能让函数分步执行(暂停执行),我们可以利用这个特点实现一个发号器,比如你到银行排队区号:

1
2
3
4
5
6
7
8
9
10
11
12
// 发号器
function* IdNumberGenerator() {
let id = 1;
while(true) {
yield id++;
}
}
const idGenerator = IdNumberGenerator();
console.log(idGenerator.next().value) // 1
console.log(idGenerator.next().value) // 2
console.log(idGenerator.next().value) // 3
//...

当使用者获取号码时就相当于调用一次生成器的next方法。

此外,生成器还可以解决异步编程的回调函数嵌套过深的问题,这部分内容将会和Promise对象进行统一梳理,感兴趣的可以参考这里进行查看:TODO

模块化Modules

模块化Modules属于语言层面的模块化标准。

早期的javascript作为页面脚本语言是很小的,作为了实现简单的交互效果,但随着web的发展,js代码逐渐变得复杂,代码之间的交互也变得频繁,相互调用的情况愈来愈多,模块化开发是大势所趋。

由于篇幅过长,这里不再展开叙述,感兴趣的可以参考官网的模块化Modules

ES2016新特性

Array.prototype.includes

该方法是新增的一个数组原型方法,用于判断数组是否包含某个元素,我们之前的做法是使用indexOf方法来获取元素对应的下标来判断元素是否存在,但这里还是有有一些区别:

1
2
3
4
5
6
7
8
9
10
const arr = [NaN, -0, false, null];

console.log(arr.includes(NaN)); // true
console.log(arr.indexOf(NaN)); // -1

console.log(arr.includes(0)); // true
console.log(arr.includes(-0)); // true
console.log(arr.indexOf(0)); // 1
console.log(arr.indexOf(-0)); // 1
console.log(Object.is(0, -0)) // false

指数运算符**

以前我们实现指数运算需要借助Math.pow函数,现在可以直接使用**运算符了:

1
console.log(Math.pow(2, 3) == 2 ** 3) // true

ES2017新特性

ES2017新增了Object对象的三个扩展方法,新增字符串的两个原型方法,以及一些小特性。

Object.values

**Object.values()**方法返回一个给定对象自身的所有可枚举属性值的数组,值的顺序与使用for...in循环的顺序相同 ( 区别在于 for-in 循环枚举原型链中的属性 )。

1
2
3
4
5
const obj = {
firstName: 'Jinx',
lastName: 'Chiang',
}
console.log(Object.values(obj)) // [ 'Jinx', 'Chiang' ]

也就是对应Object.keys方法用来获取key一样。

Object.entries

Object.values一样,如果想要同时获得键和值的迭代对象,就可以使用entries

1
2
3
4
5
const obj = {
firstName: 'Jinx',
lastName: 'Chiang',
}
console.log(Object.entries(obj)) // [ [ 'firstName', 'Jinx' ], [ 'lastName', 'Chiang' ] ]

Object.getOwnPropertyDescriptors

上文有说过Object.assign方法,可以用于复制多个对象并组合为一个新的对象,但只能浅复制,比如对象上的getersetter就无法正常复制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const obj = {
firstName: 'Jinx',
lastName: 'Chiang',
get fullName() {
return this.firstName + ' ' + this.lastName;
},
}

console.log(obj.fullName); // Jinx Chiang

const obj2 = Object.assign({}, obj);
obj2.firstName = 'Yasuo';
console.log(obj2.fullName); // Jinx Chiang

obj.firstName = 'Dongoog';
console.log(obj.fullName); // Dongoog Chiang
console.log(obj2.fullName); // Jinx Chiang

这是因为Object.assign复制时只是把fullName当成普通的属性进行复制了,并没有复制为真正的getter

我们通过Object.getOwnPropertyDescriptors就可以拿到这些真正的描述信息,从而进行复制:

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
const obj = {
firstName: 'Jinx',
lastName: 'Chiang',
get fullName() {
return this.firstName + ' ' + this.lastName;
},
}

const objProps = Object.getOwnPropertyDescriptors(obj);
console.log(objProps);
// {
// firstName: {
// value: 'Jinx',
// writable: true,
// enumerable: true,
// configurable: true
// },
// lastName: {
// value: 'Chiang',
// writable: true,
// enumerable: true,
// configurable: true
// },
// fullName: {
// get: [Function: get fullName],
// set: undefined,
// enumerable: true,
// configurable: true
// }
// }
1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
firstName: 'Jinx',
lastName: 'Chiang',
get fullName() {
return this.firstName + ' ' + this.lastName;
},
}

const objProps = Object.getOwnPropertyDescriptors(obj);
const obj2 = Object.defineProperties({}, objProps);
obj2.firstName = 'Yasuo';
console.log(obj2.fullName); // Yasuo Chiang

注意:Object.defineProperties进行的也还是浅拷贝,要实现对象的深拷贝一般需要进行对象遍历再逐一赋值。

String.prototype.padStart & String.prototype.padEnd

1

参考

  1. ECMAScript-维基百科
  2. 解构赋值-MDN
  3. 箭头函数-MDN
  4. Proxy-MDN
  5. Classes-MDN
  6. 原型链与继承-MDN
  7. symbol-MDN
  8. for…of-MDN
  9. 可迭代协议-MDN
谢谢你请我吃糖!