万字长文吐血总结ES新特性


ECMAScript新特性

PART 1

1、ECMAScript概述

ECMAScript(缩写为ES)也是一门脚本语言,通常看做JavaScript的标准化规范。

实际上JavaScript是ECMAScript的扩展语言,ECMAScript只提供了最基本的语法而JavaScript实现了ECMAScript这种标准并在此基础上进行了一些扩展,让我们可以在浏览器中操作DOM和BOM,在NODE环境中可以去做读写文件之类的操作。

JavaScript语言本身指的就是ECMAScript。在2015年开始ES保持每年一个版本的迭代,并且在ES2015开始按照年份命名,不过很多人都习惯把ES2015称为ES6(之前一个版本为2011年的ES5.1)。

2、ES2015概述

ES2015(ES6)是最新ECMAScript标准的代表版本,相比于ES5.1的变化较大并且标准命名规则也发生改变。注:目前有些开发者喜欢使用ES6来泛指ES2015之后的所有新标准。(async、await函数是在ES2017中制定的标准)本文旨在重点介绍在ES5.1基础之上的变化。

这些变化可简单归为四类:

  • 解决原有语法上的一些问题或者不足(如:let和const提供的块级作用域等)
  • 对原有语法进行增强(如:解构,展开,参数默认值,模板字符串等)
  • 全新的对象、全新的方法、全新的功能(如:Promise,proxy,Object.keys等)
  • 全新的数据类型和数据结构(如:Symbol,Set和Map等)

3、ES2015 let与块级作用域

作用域——某个成员能够起作用的范围。在此之前,ES中只有两种作用域:全局作用域和函数作用域,而在ES2015中又新增了一个块级作用域。“块”就是用“{ }”包裹起来的范围。

if(true){
    var foo = 'foo';
}
console.log(foo);  //foo
if(true){
    let foo = 'foo';
}
console.log(foo);  //foo is not defined

块级作用域可解决循环嵌套中同名计数器导致的问题:

for(var i = 0; i < 3; i++){
	for(var i = 0; i < 3; i++){
        console.log(i);
    }
    console.log('内层循环结束 i = ' + i);
}
//0 1 2 内层循环结束 i = 3
for(let i = 0; i < 3; i++){
	for(let i = 0; i < 3; i++){
        console.log(i);
    }
    console.log('内层循环结束 i = ' + i);
}
//0 1 2 内层循环结束 i = 0
//0 1 2 内层循环结束 i = 1
//0 1 2 内层循环结束 i = 2

其中最重要的是内层的循环,若把外层的循环计数器改为var i = 0结果也不变。注意:最好不要设置内外的循环计数器名称相同!

还有一个典型的应用场景:我们在循环注册事件时,在事件的处理函数中若我们要去访问循环的计数器,在这种情况下,以前就会出现一些问题:

//模拟场景
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

在上面的代码里的i其实始终都是全局作用下的i,在循环结束后,i已经被累加到了3,所以才会都打印3。而这也是闭包的常见应用场景之一:

//模拟场景
var elements = [{},{},{}];
for(var i = 0; i < elements.length; i++){
    elements[i].onClick = (function(i){
        return function(){
        	console.log(i);  //此处利用了闭包,这里的i为传入的实参i
    	}
    })(i)
}
elements[0].onClick();  //0
elements[1].onClick();  //1
elements[2].onClick();  //2

其实闭包也是利用了函数作用域去摆脱全局作用域产生的影响,而现在有了块级作用域之后就没有必要再这么麻烦了:

//模拟场景
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

不过这里其实也是用了闭包的机制,因为在onClick执行时,循环早就已经结束了,那实际的i早就已经销毁了,而就是因为闭包的机制才可以拿到原本执行循环时的i所对应的值。

另外,for循环中还有一个特别之处:在for循环内部其实有两层作用域:

for(let i = 0; i < 3; i++){
    let i = 'foo';
    console.log(i);  //foo foo foo
}

//上面的代码等价于下面代码

let i = 0;
if(i < 3){
    let i = 'foo';
    console.log(i);  //foo
}
i++;

if(i < 3){
    let i = 'foo';
    console.log(i);  //foo
}
i++;

if(i < 3){
    let i = 'foo';
    console.log(i);  //foo
}
i++;

从上面可以看出,在if语句(for语句)内有一个作用域,而在外层的计数器中也有一个作用域,所以他们是互不影响的。

let和var还有一个重要的区别就是let的声明不会出现变量提升的情况,传统的var在声明变量后都会让声明的变量提升到代码最开始的位置去:

console.log(foo);  //undefined
var foo = 'czs';

在打印时并不会报错,而是打印的undefined,这说明了在打印时foo已经声明了,只不过还没有赋值而已,这种现象就叫做:“变量声明的提升”。但就目前来看,这种现象其实也是一个BUG,不过官方的BUG不叫BUG,而是“特性”。不过为了纠正这样一个问题,在ES2015中let就纠正了这个特性,从语法层面就要求我们先声明变量,再使用变量,否则就会报一个错误。

console.log(foo);  //Cannot access 'foo' before initialization
let foo = 'czs';

但为什么不是升级var而是定义了一个新的关键字?兼容性!

补充:函数执行时会产新生成一个执行上下文,一般来说函数中的代码执行结束之后就需要出栈从而释放当前上下文所占据的内存空间,从而释放它内部的声明和值,但是如果此时当前执行上下文当中的数据(一般就是堆内存引用)被当前上下文之外的变量所引用,那么这个上下文就不能被释放掉,此时就形成了一个闭包。

4、ES2015 const

ES2015中还新增了一个const关键字,可以用来声明一个只读的“常量”,就是在let的基础上多了只读的特性,即变量声明过后不允许再被修改,同时,这也使得const变量必须在声明的同时进行赋值。

const name = 'czs';
name = 'George';
//错误:Assignment to constant vatiable

注意,这里的不允许被修改是指在const变量声明过后不允许再指向新的内存地址,而不是不允许改变const变量中的属性成员:

const obj = {};
obj.name = 'czs';
console.log(obj);  //{name: 'czs'}
//但若是让obj指向新地址就会出错
obj = [];
//错误:Assignment to constant vatiable

最佳实践:尽量不用var,主用const,配合let。(const可以更明确代码中声明的成员是否会被修改)

5、ES2015 数组解构

解构(Destructuring)是一种从数组或对象中获取指定元素的一种快捷方式。

之前写法:

const arr = [100, 200, 300];
const foo = arr[0];
const bar = arr[1];
const far = arr[2]
console.log(foo, bar, far);  //100 200 300

现在,我们可以使用数组解构:

const arr = [100, 200, 300];
const [foo, bar, far] = arr;  //foo对应arr[0],以此类推
console.log(foo, bar, far);  //100 200 300

当只需要取部分值时,可以:

const arr = [100, 200, 300];
const [ , , far] = arr;  //far就对应arr[2]
console.log(far);  //300

还可以提取从某一位置往后的所有成员:

const arr = [100, 200, 300];
const [foo, ...rest] = arr;
console.log(foo, rest)  //100 [200, 300]

注意这种写法只能在解构位置的最后一个使用。

另外,若解构位置的成员个数小于被解构的数组长度,就会按照从前到后的顺序去提取:

const arr = [100, 200, 300];
const [foo] = arr;
console.log(foo);  //100

反之,若解构位置的成员大于数组的长度,那就会提取到undefined,与访问到数组中不存在的下标是一样的:

const arr = [100, 200, 300];
const [foo, bar, far, more] = arr;
console.log(foo, bar, far, more);  //100 200 300 undefined

同时,还可以对解构成员设置默认值,当没有提取到值时这里的变量则为默认值:

const arr = [100, 200, 300];
const [foo, bar, far = 123, more = 'default'] = arr;
console.log(foo, bar, far, more);  //100 200 300 'default'

常见用法,拆分字符串后获取拆分后的指定位置:

//以前需要通过中间变量来保存拆分后的数组
const path = '/home/george';
const tmp = path.split('/');
const usrdir = tmp[2];
console.log(usrdir);  //george
//现在可以直接使用解构赋值了
const path = '/home/george';
const [ , , usrdir] = path.split('/');
console.log(usrdir);  //george

6、ES2015 对象解构

同样,对象也可被解构,但与数组不同的是对象解构用的是属性名而非位置,因数组有按顺序的下标,而对象没有:

const obj = { name: 'czs', age: 25};
const { name } = obj;
console.log(name);  //czs

和数组一样,若没有匹配到成员也会返回undefined,也可以设置默认值,但在对象中还有一个特殊的情况,就是因为解构的变量名同时又是用来去匹配对象当中的属性名的,所以若当前作用域中有同名的变量就会发生冲突:

const obj = { name: 'czs', age: 25};
const name = 'jack';
const { name } = obj;
console.log(name);  //Identifier 'name' has already been declared

这时我们可以用重命名的方式来解决这个问题:

const obj = { name: 'czs', age: 25};
const name = 'jack';
const { name: objName } = obj;  //冒号左边的是匹配的属性名,右边的是对应的变量名称
console.log(objName);  //czs

同时也可设置相应的默认值:

const obj = { name: 'czs', age: 25};
const name = 'jack';
const { nameObj: objName = 'George' } = obj;  //设置默认值
console.log(objName);  //George

使用场景举例:

比如我们大量使用了console中的log方法,我们就可以把log解构出来单独使用:

const { log } = console;
log('foo');  //foo
log('bar');  //bar

7、ES2015 模板字符串字面量(Template literals)

在ES2015中还增强了定义字符串的方式——模板字符串:

//在传统JS中,我们用单引号或双引号定义字符串
const str1 = 'hello es2015';
console.log(str1);  //hello es2015
//而在ES2015中新增了模板字符串的方式,用反引号标识,就是键盘左上角数字1左边的键
const str2 = `hello es2015`;
console.log(str2);  //hello es2015
//同样,我们也可以用反斜杠"\"来进行转义
const str3 = `hello \`es2015`;
console.log(str3);  //hello `es2015

相比于传统的字符串方式,模板字符串多了一些非常有用的新特性:

  1. 传统的字符串不支持直接换行,如果字符串里有换行,我们要手动添加”\n”来进行换行,而在最新的模板字符串中支持多行字符串,即我们可以在模板字符串中直接输入换行符来进行换行:
const str = `hello
es2015`;
console.log(str);
//hello
//es2015

这点对我们输出HTML字符串是非常方便的。

  1. 模板字符串中还支持通过插值表达式的方式在字符串中嵌入所对应的数值
const name = 'George';
const msg = `hey, ${name}`;  //通过插值表达式的方式在字符串中嵌入对应的数值
console.log(msg);  //hey, George

这种方式比之前字符串拼接的方式要方便的多,也更直观。注意,插值表达式中不仅仅可以嵌入变量,还可以嵌入任何标准的JS语句,而这个语句的返回值最终会被输出到字符串中插值表达式所在的位置:

const msg = `hey, ${1 + 1} = ${Math.random()}`;  //嵌入JS语句
console.log(msg);  //hey, 2 = 打印一个随机数

8、ES2015 带标签的模板字符串(Tagged templates)

模板字符串还有一个更加高级的用法,就是在定义模板字符串之前,添加一个标签,而这个标签实际上是一个特殊的函数,添加这个标签就是在调用这个函数:

const str = console.log`hello es2015`;
console.log(str);  //['hello es2015']

注意这里是打印了一个数组,为什么这里是一个数组呢,我们来看:

const name = 'George';
const gender = true;
function myTagFunc(strings){  //  使用标签函数前必须先定义标签函数
    console.log(strings);  //  ['hey, ', ' is a ', '']
}
const result = myTagFunc`hey, ${name} is a ${gender}`;

我们发现,这个数组中的内容就是模板字符串中的内容按照表达式分割过后的那些静态的内容,所以是一个数组。且除了这个数组以外,这个函数还可以接收到所有在这个模板字符串当中出现的表达式的返回值:

const name = 'George';
const gender = true;
function myTagFunc(strings, name, gender){  //  使用标签函数前必须先定义标签函数
    console.log(strings, name, gender);  //  ['hey, ', ' is a ', ''] 'George' true
}
const result = myTagFunc`hey, ${name} is a ${gender}`;

这个函数内部的返回值就是这个带标签的模板字符串所对应的返回值

const name = 'George';
const gender = true;
function myTagFunc(strings, name, gender){  //  使用标签函数前必须先定义标签函数
    return '123';
}
const result = myTagFunc`hey, ${name} is a ${gender}`;
console.log(result);  //123

所以,如果我们要返回正确的内容:

const name = 'George';
const gender = true;
function myTagFunc(strings, name, gender){  //  使用标签函数前必须先定义标签函数
    return strings[0] + name + strings[1] + gender + strings[2];
}
const result = myTagFunc`hey, ${name} is a ${gender}`;
console.log(result);  //hey, George is a true

这种标签函数的作用,实际上就是对模板字符串进行加工:

const name = 'George';
const gender = true;
function myTagFunc(strings, name, gender){  //  使用标签函数前必须先定义标签函数
    const sex = gender ? 'boy' : 'girl';
    return strings[0] + name + strings[1] + sex + strings[2];
}
const result = myTagFunc`hey, ${name} is a ${gender}`;
console.log(result);  //hey, George is a boy

使用标签函数的特性可以实现例如文本的多语言化,或检查模板字符串当中是否存在不安全的字符之类的需求。甚至,我们还可以用这种特性实现一个小型的模板引擎。

9、ES2015 字符串的扩展方法

ES2015字符串中有3种扩展方法,分别为:includes()、startsWith()、endsWith()。他们是一组方法,能更方便地判断字符串中是否有指定的内容。

若需要知道一个字符串是否以某一字符串开头,可以使用startsWith方法:

const message = 'Error: foo is not defined.';
console.log(message.startsWith('Error'));  //true

若需要知道一个字符串是否以某一字符串结尾,可以使用endsWith方法:

const message = 'Error: foo is not defined.';
console.log(message.endsWith('.'));  //true

若需要知道一个字符串中间是否包含某一字符串,可以使用includes方法:

const message = 'Error: foo is not defined.';
console.log(message.includes('foo'));  //true

相比于我们使用indexof或正则来判断,这样的一组方法会让我们的字符串查找便捷很多。

PART 2

10、ES2015 参数默认值

ES2015中为函数的形参列表扩展了一些有用的新方法,首先就是参数的默认值。ES2015以前若想要为函数中的参数定义默认值,需要在函数体中通过逻辑代码来实现:

function foo(enable){
    //若调用时没有传递参数(或传递undefined),则该参数为undefined,从而赋默认值。
    enable = enable === undefined ? true : enable;
    console.log(enable);
}
foo();  //true
foo(false);  //false
foo(true);  //true

而现在,可用参数默认值来代替:

function foo(enable = true){
    console.log(enable);
}
foo();  //true
foo(false);  //false
foo(true);  //true

需注意的是,若有多个参数时,带有默认值的形参一定要出现在参数列表的最后,因参数是按照次序传递的,如果带有默认值的参数不在最后的话,那么默认值将无法正常工作:

function foo(enable = true, foo){
    console.log(enable, foo);
}
foo('czs');  //czs undefined
foo( ,'czs');  //Uncaught SyntaxError: Unexpected token ','

正确做法,带有默认值的形参一定要出现在参数列表的最后

function foo(foo, enable = true){
    console.log(enable, foo);
}
foo('czs');  //true 'czs'

11、ES2015 剩余参数(Rest parameters)

在ES中,很多方法都可以传递任意个数的参数,例如console对象的log方法就可以接收任意个数的参数并且最终把这些参数打印到同一行中:

console.log(1, 2, 3, 'czs');  //1 2 3 'czs'

对于未知个数的参数,ES2015以前都是使用ES提供的arguments对象去接收,arguments对象实际上是一个伪数组:

function foo(){
    console.log(arguments);
}
foo('foo', 'czs');  //Arguments(2) ['foo', 'czs']

在ES2015中新增了一个”…”操作符,这个操作符有两个作用,这里用到的就是第一个作用“剩余操作符”(Rest)。可以在函数的形参前加上”…”,那么这个形参就会以数组形式接收从当前这个位置开始往后的所有实参,这种方式就可以取代以arguments对象去接收无限参数的操作:

function foo(...args){
    console.log(args);
}
foo('foo', 'czs');  //['foo', 'czs']

注意:因为接收的是所有的参数,所以这种操作符只能出现在形参的最后一位而且只可以使用一次

function foo(bar, ...args){
    console.log(bar, args);
}
foo('bar','foo', 'czs');  //bar ['foo', 'czs']

12、ES2015 展开数组(Spread)

“…”操作符除了有接收所有参数的Rest用法,还有一种spread的用法,即展开,而这个展开操作符的用处有很多,例如与函数相关的数组参数展开。在以前,若我们想把数组中的每个成员按照次序传递给console.log方法,最笨的方法自然就是通过下标一个个找到数组中的每一个元素,分别传到console.log中:

const arr = ['foo', 'bar', 'far'];
console.log(arr[0], arr[1], arr[2]);  //foo bar far

但若数组中的元素个数是不固定的,那一个个传的方式就行不通了,而这在以前一般用的是函数的apply方法,因这个方法可以以数组的形式去接收实参列表:

const arr = ['foo', 'bar', 'far'];
//apply方法的第一个参数是this的指向,这里的log方法是console对象调用的,所以第一个参数传入console对象。
//而第二个参数就是传递的实参列表数组。
console.log.apply(console, arr);  //foo bar far

而在ES2015中,就没有必要这么麻烦了,我们可以直接调用console对象的log方法,然后通过”…”操作符展开数组,”…”操作符就会把数组当中的每个成员按次序传到参数列表当中:

const arr = ['foo', 'bar', 'far'];
console.log(...arr);  //foo bar far

13、ES2015 箭头函数(Arrow functions)

在ES2015当中,还简化了函数表达式的定义方式,他允许我们使用”=>”这种类似箭头的符号来定义函数,这种函数不仅简化了函数的定义,还增加了新的特性。

传统定义函数表达式是用function关键字:

function inc(number){
    return number + 1;
}

现在,可以使用ES2015的箭头函数去定义一个完全相同的函数:

const inc = n => n + 1;

此时,我们会发现相比于普通的函数,箭头函数确实大大简化了我们定义函数的代码。另外,还可以安装一个叫Fira Code的字体,他会让代码的显示更加美观,对很多类似的操作符都有更好的展示,具体请看:安装程序员友好的字体——Fira Code

再来看箭头函数的相关语法,在”=>”的左边,实际上是参数的列表,如果有多个参数的话,可以用”()”的方式去定义。在箭头的右边是函数体,若函数体中只有一句表达式的话,这条表达式的执行结果就会作为这个函数的返回值返回:

const inc = (n, m) => n + m;
console.log(inc(100,10));  //110

如果要在函数的函数体中执行多条语句,同样可以使用花括号”{}”来包裹,不过一旦使用了花括号之后,返回值就需要我们手动通过return关键字去返回了:

const inc = (n, m) => {
    console.log('inc invoked');
    return n + m;
}
console.log(inc(100,10));
//inc invoked
//110

而使用箭头函数最主要的变化就是极大地简化了回调函数的编写:

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

//回调函数匿名函数写法
arr.filter(function(item){
    return item % 2;
})
//回调函数箭头函数写法
arr.filter(i => i%2)

14、ES2015 箭头函数与this

相比于普通的函数,箭头函数还有一个很重要的变化,就是箭头函数不会改变this指向。

在普通函数中,this始终会指向调用函数的对象:

const person = {
    name: 'tom',
    sayHi: function(){
        console.log(`hi,my name is ${this.name}`);
    }
}
person.sayHi();  //hi,my name is tom

而在箭头函数中,没有this机制,他不会改变改变this指向:

const person = {
    name: 'tom',
    sayHi: () => {
        console.log(`hi,my name is ${this.name}`);
    }
}
person.sayHi();  //hi,my name is XX(Window.name)

也就是说,在箭头函数的外面this是什么,那在里面拿的this就是什么。

例如在定时器函数中,若传进去一个普通的函数表达式,那在函数的内部就没法拿到当前作用域的this,因这个函数在定时器函数里最终会放在全局对象上被调用,所以说这个函数拿不到当前作用域的this对象,而是拿到了全局作用域(window)下的this

const person = {
    name: 'tom',
    sayHiAsync: function(){
        setTimeout(function(){
            console.log(`hi,my name is ${this.name}`);
        }, 1000)
    }
}
person.sayHiAsync();  //hi,my name is XX(Window.name)

以前,为了解决这样的问题,我们常常会定义一个:_this变量来保存当前作用域下的this,利用闭包机制去在内部使用_this

const person = {
    name: 'tom',
    sayHiAsync: function(){
        const _this = this;
        setTimeout(function(){
            console.log(`hi,my name is ${_this.name}`);
        }, 1000)
    }
}
person.sayHiAsync();  //hi,my name is tom

如果使用的是箭头函数的话,那就duck不必了:

const person = {
    name: 'tom',
    sayHiAsync: function(){
        setTimeout(() => {
            console.log(`hi,my name is ${this.name}`);
        }, 1000)
    }
}
person.sayHiAsync();  //hi,my name is tom

箭头函数中的 this 是静态的,也就是作用域是不会被改变的,始终指向的是该箭头函数声明时所在的真正的执行环境(外层的this),以后但凡是代码中需要使用_this这种情况,都可以考虑使用箭头函数。

补充:this的7种绑定:
  1. 普通函数:window
  2. 定时器函数:window
  3. 立即执行函数:window
  4. 对象的方法:对象
  5. 构造函数:实例对象
  6. 事件处理函数:事件源
  7. 箭头函数:不绑定this(还是外层的this)

15、ES2015 对象字面量的增强

对象,是ES当中最常用的数据结构,ES2015中升级了对象字面量的语法。

传统的对象字面量要求我们要在花括号“{}”中使用属性名:属性值这种语法,且即便属性值是一个变量,也必须是属性名:变量名语法:

const bar = '345';

const obj ={
    foo: 123,
    bar: bar
}
console.log(obj);  //{foo: 123, bar: '345'}

而现在,如果说我们的变量名和要添加到对象中的属性名是一致的话,那就可以省略掉“:”以及后面的变量名,这两种方法是完全等价的:

const bar = '345';

const obj ={
    foo: 123,
    bar
}
console.log(obj);  //{foo: 123, bar: '345'}

除此之外,如果需要为对象添加一个普通的方法的话,传统的做法就是通过方法名:函数表达式

const bar = '345';

const obj ={
    foo: 123,
    bar,
    method: function(){
        console.log('method');
    }
}
console.log(obj);  //{foo: 123, bar: '345', method: ƒ}

而现在,可以省略掉方法名后的“:”以及function,这两种方法也是完全等价的:

const bar = '345';

const obj ={
    foo: 123,
    bar,
    method(){
        console.log('method');
    }
}
console.log(obj);  //{foo: 123, bar: '345', method: ƒ}

但需要注意的是,这种方法的背后其实就是普通的function,也就是说如果我们通过对象去调用这个方法,那么内部的this就会指向当前对象:

const bar = '345';

const obj ={
    foo: 123,
    bar,
    method(){
        console.log(this);
    }
}
obj.method();  //{foo: 123, bar: '345', method: ƒ}

另外,对象字面量还有一个很重要的变化就是他可以用表达式的返回值作为对象的属性名。以前,如果说我们要为对象添加一个动态的属性名,我们就只能在对象声明过后,通过索引器的方式即“[]”去动态添加,而不能在属性名中使用:

const bar = '345';

const obj ={
    foo: 123,
    bar,
    method(){
        console.log(this);
    },
    //Math.random(): 111     //不能直接使用
}

obj[Math.random()] = 111;  //需要用索引器的方式动态添加
obj.method();  //{foo: 123, bar: '345', 一个随机数:111, method: ƒ}

而在ES2015后,对象字面量的属性名就可以直接通过方括号“[]”来直接使用动态的值(任意合法表达式)了:

const bar = '345';

const obj ={
    foo: 123,
    bar,
    method(){
        console.log(this);
    },
    [Math.random()]: 111,  //ES2015后可通过"[]"来直接使用任意合法表达式作为属性名了
    [bar]: 567,
    [1 + 2]: 3
}
obj.method();  //{3: 3, 345: 567, foo: 123, bar: '345', 一个随机数: 111, method: ƒ}

这样的一个特性也称为:计算属性名

16、ES2015 Object.assign

ES2015中为Object对象提供了一些拓展方法,本文主要介绍一些最主要的方法。首先是Object.assign方法,Object.assign方法支持传入任意个数的对象,其中第一个参数是目标对象,后面参数为源对象

这个方法可以将多个源对象中的属性复制到一个目标对象中,如果对象之间有相同的属性,那么源对象中的属性就会覆盖掉目标对象中的属性,并返回这个目标对象

const source1 = {
    a: 123,
    b: 345
}
const target1 = {
    a: 567,
    c: 789
}
const result = Object.assign(target1, source1);
console.log(result);  //{a: 123, c: 789, b: 345}
console.log(target1);  //{a: 123, c: 789, b: 345}
console.log(result === target1);  //true

要注意的是,传入多个源对象时,后面传入的对象若与之前传入的对象有相同的属性名,则后传入的对象相关属性值会覆盖先传入的对象的相关属性值

const source1 = {
    a: 123,
    b: 345
}
const source2 = {
    b: 666,
    d: 999
}
const target1 = {
    a: 567,
    c: 789
}
const result = Object.assign(target1, source1, source2);
console.log(result);  //{a: 123, c: 789, b: 666, d: 999}
console.log(target1);  //{a: 123, c: 789, b: 666, d: 999}
console.log(result === target1);  //true

我们常用这种方法去复制一个对象。例如有一个函数,接收一个对象参数,若我们在函数内部中直接修改了对象参数的属性,那外界所传入的那个实参对象所对应的属性也会发生变化,因为对象是引用传递的,他们都指向同一内存地址:

function func(obj){
    obj.name = 'func Obj';
    console.log(obj);  //{name: 'func Obj'}
}
const obj = { name: 'global obj' }
func(obj);
console.log(obj);  //{name: 'func Obj'}

如果我们只是希望在这个函数的内部去修改这个对象,就可以使用Object.assign方法,去把这个对象赋给一个全新的空对象上面,那么,内部对象就是一个全新的对象,对他的修改也就不会影响外部数据了:

function func(obj){
    const funcObj = Object.assign({}, obj);
    funcObj.name = 'func Obj';
    console.log(funcObj);  //{name: 'func Obj'}
}
const obj = { name: 'global obj' }
func(obj);
console.log(obj);  //{name: 'global obj'}

但需要注意:Object.assign方法实行的是浅拷贝,如果源对象某个属性的值是对象,那么目标对象拷贝得到的是这个对象的引用:

var obj1 = {a: {b: 1}};
var obj2 = Object.assign({}, obj1);
obj1.a.b = 2;
console.log(obj2.a.b);  // 2

对于这种嵌套的对象,一旦遇到同名属性,Object.assign的处理方法是替换,而不是添加:

var target = { a: { b: 'c', d: 'e' } }
var source = { a: { b: 'hello' } }
const result = Object.assign(target, source);
console.log(result);  // { a: { b: 'hello' } }

而且Object.assign还可以用来处理数组,但是会把数组视为对象:

const result = Object.assign([1, 2, 3], [4, 5]);
console.log(result);  // [4, 5, 3]

除此之外,Object.assign方法也可为options对象参数设置默认值:

const DEFAULTS = {
	logLevel: 0,
	outputFormat: 'html'
};
/*
	DEFAULTS对象是默认值,options对象是用户提供的参数。
	Object.assign方法将DEFAULTS和options合并成一个新对象,
	如果两者有同名属性,则option的属性值会覆盖DEFAULTS的属性值。
*/
function processContent(options) {
	let options = Object.assign({}, DEFAULTS, options);
}

同样需要注意,由于存在浅拷贝的问题,DEFAULTS对象和options对象的所有属性的值,都只能是简单类型,而不能指向另一个对象。否则,将导致DEFAULTS对象的该属性不起作用。

17、ES2015 Object.is

ES2015中还为Object对象新增了一个is方法,用来判断两个值是否相等

在此之前,我们在ES中判断两个值是否相等可以使用“==”相等运算符或“===”严格相等运算符。这两者的区别就是“==”存在装箱转换,即在比较之前会自动转换数据类型。而“===”是严格去对比两者之间的数值是否相同:

console.log(0 == false);  //true
console.log(0 === false);  //false

但严格相等运算符也有两个特殊情况,首先就是对于数字0,他的正负是没有办法区分的。其次是对于NaN,两个NaN在“===”比较时是不相等的,因为以前认为NaN是一个非数字,也就是说他有无限种可能,所以两个NaN是不相等的:

console.log(+0 === -0);  //true
console.log(NaN === NaN);  //false

但在今天看来,NaN实际上就是一个特别的值,所以说两个NaN应该是完全相等的,故在ES2015中就提出了一种新的同值比较算法Object.is,现在就可以使用Object.is方法来解决这个问题:

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

不过在一般情况下我们都不会用到这个方法,大多数时候还是建议使用严格相等运算符“===”。

18、ES2015 Proxy

如果想要监视某个对象中的属性读写,那么可以使用ES5提供的Object.defineProperty方法来为对象添加属性,这样我们就可以捕获到对象中的属性读写过程。这种方法的应用也非常广泛,在Vue3.0以前的版本就是用这样的方法来实现数据响应,从而完成双向数据绑定。而在ES2015中重新设计了一个叫做Proxy的类型,他就是专门用来为对象设置访问代理器的,通过Proxy就可以轻松监视到对象的读写过程。相比于Object.defineProperty,Proxy的功能要更为强大,使用起来也更为方便。

可通过new Proxy的方式来创建一个代理对象,Proxy构造函数的第一个参数就是需要代理的目标对象,第二个参数也是一个对象,称为代理的处理对象,这个对象中可以用get方法去监视属性的访问,通过set方法来监视属性的设置。get方法可以接收两个参数,第一个是我们所代理的目标对象,第二个是外部所访问的属性名。get方法的返回值为外部去访问属性所得到的结果:

const person = {
    name: 'czs',
    age: 25
}

const personProxy = new Proxy(person, {
    get(target, property){
        console.log(target, property);  //{name: 'czs', age: 25} 'name'
        return 100;
    },
    set(){}
})

console.log(personProxy.name);  //100

而get方法内部正常的逻辑应该是先去判断代理目标对象当中是否存在这样一个属性,如果存在的话就返回对应的值,如果不存在的话就返回undefined或默认值’default’:

const person = {
    name: 'czs',
    age: 25
}

const personProxy = new Proxy(person, {
    get(target, property){
        return property in target ? target[property] : 'default';
    },
    set(){}
})

console.log(personProxy.name);  //czs
console.log(personProxy.gender);  //'default'

对于set方法,其默认接收三个参数,分别为代理目标对象,需要写入的属性名称和需要写入的属性值:

const person = {
    name: 'czs',
    age: 25
}

const personProxy = new Proxy(person, {
    get(target, property){
        return property in target ? target[property] : 'default';
    },
    set(target, property, value){
        console.log(target, property, value);
    }
})

personProxy.gender = true;  //{name: 'czs', age: 25} 'gender' true

而set方法内部正常的逻辑就应该是为代理目标设置指定的属性,不过在设置之前还可进行一系列的校验:

const person = {
    name: 'czs',
    age: 25
}

const personProxy = new Proxy(person, {
    get(target, property){
        return property in target ? target[property] : 'default';
    },
    set(target, property, value){
        if(property === 'age'){
            if(!Number.isInteger(value)){
                throw new TypeError(`${value} is not an int`);
            }
        }
        target[property] = value;
    }
})
personProxy.age = 100;
console.log(personProxy.age);  //100
personProxy.gender = true;
console.log(personProxy.gender);  //true
personProxy.age = 'male';  //Uncaught TypeError: male is not an int

以上就是Proxy的基本用法,在Vue3.0开始就使用Proxy来实现内部的数据响应了。

19、ES2015 Proxy对比defineProperty

在了解了Proxy的基本用法后,让我们再深入探索一下相比于Object.defineProperty,Proxy到底有哪些优势。

首先,最明显的优势就是Proxy的功能更加强大,具体体现在Object.defineProperty只能监视属性的读写,而Proxy能够监视到更多对象操作,例如对象中的方法调用或delete操作等。

以代理delete操作为例,我们需要在代理的处理对象中添加一个deleteProperty代理方法,此方法会在外部对当前代理对象进行delete操作时执行。deleteProperty方法同样接收两个参数,分别是代理目标对象以及所要删除的属性名称:

const person = {
    name: 'czs',
    age: 25
}
const personProxy = new Proxy(person, {
    deleteProperty(target, property){
        console.log('delete', property);  //delete age
        delete target[property];
    }
})
delete personProxy.age;
console.log(person);  //{name: 'czs'}

当然,除了delete以外,还有很多其他的对象操作能被监视到,详见下图:

Proxy可监视的对象操作

Proxy的第二点优势就是对于数组对象的监视。以前我们若想通过Object.defineProperty去监视数组的操作,最常见的方式就是重写数组的操作方法,这也是Vue.js中的操作方式,大体的思路就是通过自定义的方法去覆盖掉数组原型对象上的push、shift等方法从而劫持对应的方法调用过程。

使用Proxy对象监视数组时,代理的目标对象即为某一个数组,而在处理对象中去添加一个set方法来监视数据的写入:

const list = [];
const listProxy = new Proxy(list, {
    set(target, property, value){
        //注意push会改变数组中对应索引的值以及数组的length属性,
        //故会输出:set 0 100以及set length 1,
        //其中第一个set中的0表示索引,100为对应索引的值。后一个set为设置数组长度
        console.log('set', property, value);
        target[property] = value;
        return true;  //设置成功
    }
})

listProxy.push(100);  //set 0 100  //set length 1

最后,相比于Object.defineProperty,Proxy是以非侵入的方式来监管对象的读写,也就是说对于一个已经定义好的对象,我们不需要对对象本身做任何的操作就可以监视到他内部成员的读写。而Object.defineProperty的方式就要求我们必须通过特定的方式单独去定义对象当中那些需要被监视的属性,这对于一个已经存在的对象,想去监视他的属性,则需要很多额外的操作。不过这点优势需要有大量的使用,然后再这个过程中去慢慢地体会。

//Object.defineProperty监视对象读写
const person = {};
Object.defineProperty(person, 'name', {
    get(){
        console.log('name被访问');
        return person._name
    },
    set(value){
        console.log('name被设置');
        person._name = value;
    }
});
Object.defineProperty(person, 'age', {
    get(){
        console.log('age被访问');
        return person._age
    },
    set(value){
        console.log('age被设置');
        person._age = value;
    }
});
person.name = 'George';  //name被设置
console.log(person.name);  //name被访问  //George

//Proxy监视对象读写
const person2 = {
    name: 'czs',
    age: 25
};
const personProxy = new Proxy(person2, {
    get(target, property){
        console.log(`${property}被访问`);
        return target[property];
    },
    set(target, property, value){
        console.log(`${property}被设置为${value}`);
        target[property] = value;
    }
});
personProxy.name = 'George';  //name被设置为George
console.log(personProxy.name);  //name被访问  //George

20、ES2015 Reflect

Reflect是ES2015中提供的全新的内置对象,如果按照java或C#语言的说法,Reflect属于一个静态类,即他不能通过new的方式去构建一个实例对象,只能够去调用静态类中的静态方法,就像JavaScript中的Math对象。

Reflect内部封装了一系列对对象的底层操作,一共提供了14个静态方法,但其中1个已被废弃,剩下13个,而这13个方法名与Proxy对象当中处理对象里的方法成员是完全一致的,实际上这些方法就是Proxy处理对象的那些方法内部的默认实现。即如果我们没有在Proxy的处理对象中添加具体的处理方法,那处理对象中默认实现的逻辑就是调用了Reflect对象中所对应的方法:

//Proxy处理对象中的方法内部的默认实现就是调用了Reflect对象中所对应的方法,以get为例
const obj = {
    foo: '123',
    bar: '456'
}

const proxy = new Proxy(obj, {
    get(target, property){
        return Reflect.get(target, property);
    }
})

这也表明当我们实现自定义的get,set这样的逻辑时,更标准的做法是先去实现自己所需要的监视逻辑,最后再去返回通过Reflect中对应方法的调用结果:

const obj = {
    foo: '123',
    bar: '456'
}

const proxy = new Proxy(obj, {
    get(target, property){
        console.log('watch logic');
        return Reflect.get(target, property);
    }
})
console.log(proxy.foo);  //watch logic  //123

Reflect对象的用法其实很简单,但大多数人可能没有意识到Reflect对象的实际意义。在我看来,其最大的意义就是他统一提供了一套用于操作对象的API。因为在此之前,若要操作对象则可能会用到Object对象上的一些方法,也可能使用像delete,in这样的操作符,而这些操作实际上有些乱,并没有什么规律。Reflect对象的出现则很好地解决了这个问题,他统一了对象的操作方式:

const obj = {
    name: 'czs',
    age: 25
}

//在Reflect出现以前
console.log('name' in obj);  //true
console.log(delete obj['age']);  //true
console.log(Object.keys(obj));  //['name']

//在Reflect出现后,统一了以前方法的操作方式
console.log(Reflect.has(obj, 'name'));  //true
console.log(Reflect.deleteProperty(obj, 'age'));  //true
console.log(Reflect.ownKeys(obj));  //['name']

需要注意的是,目前,以前的对象操作方式依然可以使用,但ECMAScript官方希望经过一段时间的过渡之后,以后的标准中能把之前的方法废弃掉。所以现在就应该去了解这13个方法以及他们各自所取代的用法,详细可以参看MDN中的Reflect相关内容

21、ES2015 Promise

Promise也是ECMAScript2015中提供的一个内置对象,他提供了一种全新的异步编程解决方案,通过链式调用的方式,解决了在传统JS编程中回调函数嵌套过深的问题。不过关于Promise的细节有很多的内容,所以在本文中先不做详细介绍,而在“JS异步编程”中进行详细分析。在这里提及的目的是为了对ES2015新增的所有特性有一个系统化的认识。

PART 3

22、ES2015 class类

在ES2015之前,我们都是定义函数以及函数的原型对象来实现的类:

//Peoson函数为Person类的构造函数
function Person(name){
    //在构造函数中可以通过this去访问当前实例中的实例对象
    this.name = name;
}

//如果需要在类的实例间共享一些成员,可以使用函数对象的prototype即原型来实现
Person.prototype.say = function(){
    console.log(`hi, my name is ${this.name}`)
}
const person = new Person('czs');
person.say();  //hi, my name is czs

自从ES2015开始,就可以使用class关键词来声明一个类:

class Person{
    //constructor方法为当前类的构造函数
    constructor(name){
        //同样,在构造函数中可以通过this去访问当前类的实例对象
        this.name = name;
    }
    //如果需要添加实例方法,则只需要在类中添加对应的方法即可
    say(){
        console.log(`hi, my name is ${this.name}`)
    }
}
const person = new Person('czs');
person.say();  //hi, my name is czs

23、ES2015 静态方法

在类的方法中,一般分为实例方法和静态方法,实例方法就是需要通过类构造的实例对象来调用,而静态方法则是直接通过类本身来调用。在以前实现静态方法就是直接在构造函数对象上挂载方法,因在JS中函数也是对象,也可以添加一些方法成员。而在ES2015中多了一个专门用来添加静态成员的static关键词:

class Person{
    constructor(name){
        this.name = name;
    }
    say(){
        console.log(`hi,my name is ${this.name}`);
    }
    
    static create(name){
        return new Person(name);
    }
}

const czs = Person.create('czs');
czs.say();  //hi,my name is czs

这里需要注意,因为静态方法是挂载到类上面的,所以静态方法内部的this就不会指向某个实例对象而是当前的类

24、ES2015 类的继承

继承是面向对象中的一个重要特性,通过继承这种特性我们就能抽象出相似类之间重复的地方。在ES2015之前,大多数情况我们都会使用原型的方式去实现继承,而在ES2015中实现了一个专门用于类的继承的关键词extends:

class Person{
    constructor(name){
        this.name = name
    }
    say(){
        console.log(`hi,my name is ${this.name}`);
    }
}

//让student继承自Person,即Student类中有Person类中的所有成员
class Student extends Person{
    constructor(name, number){
        //name在父类中也需要用到,故需要用super对象,他始终指向父类,调用他相当于调用了父类的构造函数
        super(name);
        this.number = number;  //定义继承类中特有成员,学号
    }
    //定义继承类中特有方法
    hello(){
        //可以使用super对象去访问父类成员
        super.say();
        console.log(`my student number is ${this.number}`);
    }
}

const student = new Student('George', '12345');
student.hello();  //hi,my name is George  //my student number is 12345

25、ES2015 ES2015 Set

ES2015中提供了一个叫做Set的全新数据结构,可以理解为集合。他与传统的数组非常类似,但Set的内部成员是不允许重复的,即每一个值在同一个Set中都是唯一的。

//Set是一个类,可以通过Set类构造的实例来存放不重复的数据
const set = new Set();
//用add方法来向集合中添加数据。由于add方法会返回集合对象本身故可以链式调用
set.add(1).add(2).add(3).add(4).add(5);
console.log(set);  //Set(5) {1, 2, 3, 4, 5}
//如果添加之前已经存在的值,则添加的值会被忽略
set.add(2);
console.log(set)  //Set(5) {1, 2, 3, 4, 5}

//若想要遍历集合中的数据,就可以使用集合对象的forEach方法
set.forEach(i => console.log(i));  //1  //2  //3  //4  //5
//或者使用ES2015提供的for of循环。for of同时也可遍历普通的数组
for(let i of set){
    console.log(i);  //1  //2  //3  //4  //5
}

//在set对象中还可以通过size属性来获取整个集合的长度,与数组中的length类似
console.log(set.size);  //5

除此之外,集合还提供了一些常用的方法:

const set = new Set();
set.add(1).add(2).add(3).add(4).add(5);

//has方法可以判断集合中是否存在某个特定的值
console.log(set.has(4));  //true
console.log(set.has(100));  //false

//delete方法可以删除集合中的某个指定值,删除成功会返回true
console.log(set.delete(5));  //true
console.log(set.delete(5));  //false
console.log(set);  //Set(4) {1, 2, 3, 4}

//clear方法可以清除当前集合里的所有内容
set.clear();
console.log(set);  //Set(0) {size: 0}

集合这种数据结构最常见的应用场景就是为数组中的元素去重:

const arr = [1, 2, 3, 1, 4 ,2];
//Set的构造函数可以接收一个数组,这个数组里的元素会作为Set的初始值,若数组里存在重复值则会被忽略。
const result1 = new Set(arr);
console.log(result1);  //Set(4) {1, 2, 3, 4}

//若还想要得到一个数组的话,可以使用ES2015中新增的Array.from方法把Set再转为数组
const result2 = Array.from(new Set(arr));
console.log(result2);  //[1, 2, 3, 4]

//当然,还可以使用"..."操作符,在一个空数组中展开这个Set,则Set中的成员就成为了空数组中的成员
const result3 = [...new Set(arr)];
console.log(result3);  //[1, 2, 3, 4]

26、ES2015 Map

ES2015中还增加了一个Map数据结构,这种结构与ECMAScript中的对象非常类似,本质上都是键值对集合。但对象中的键只能够是字符串类型,所以若要存放一些复杂结构的数据时会有一些问题。

不过还是让我们先尝试一下添加不同类型的键:

const obj = {};
obj[true] = 'Boolean';
obj[123] = 'Number';
obj[{ a: 1 }] = 'Object';
console.log(Reflect.ownKeys(obj));  //['123', 'true', '[object Object]']

由此可见,原本我们设置的各种不同类型的键最终都被转为了字符串,实际上如果我们给键添加的不是字符串,那内部就会将这个数据toString的结果作为键。

而Map才能算是严格意义上的键值对集合,用来映射两个任意类型数据之间的对应关系:

const map = new Map();
const sam = {name: 'sam'};
//可用Map实例的set方法存数据,这里的键可以是任意类型的数据
map.set(sam, 100);
console.log(map);  //Map(1) { { name: 'sam' } => 100}
//如果需要获取数据,可以使用get方法
console.log(map.get(sam));  //100

//同样有has、delete、clear等方法
console.log(map.has(sam));  //true
map.delete(sam);
console.log(map);  //Map(0) {size: 0}

map.set(sam, 200);
console.log(map);  //Map(1) { { name: 'sam' } => 200}
map.clear(sam);
console.log(map);  //Map(0) {size: 0}

map.set(sam, 300);
//如果需要遍历map中的所有键值,可以使用实例对象的forEach方法。回调函数中的第一个参数是被遍历的值,第二个是被遍历的键
map.forEach((value, key) => {
    console.log(value, key);  //300 {name: 'sam'}
})

map数据结构与对象的最大区别就是他可以用任意类型的数据作为键,而对象实际上只能用字符串作为键。

27、ES2015 Symbol

在ES2015之前,对象的属性名都是字符串,而字符串有可能会重复,重复就会产生冲突。

例如在多文件情景时:

//shared.js中
//假设存在一个用于数据缓存的对象cache,并约定是全局共享的
const cache = {};

//a.js中
//在a.js文件中尝试往缓存中写入内容
cache['foo'] = 'czs';

//b.js中
//假设在b.js文件中我们又要向cache对象写入一个新的内容。
//这时若我们不知道cache对象之前已存在一个叫foo的键而也去使用foo作为键去存放另一个数据,就会产生冲突。
cache['foo'] = '123';
console.log(cache);  //{foo: '123'}

现如今我们大量使用第三方模块,很多时候都需要扩展第三方模块中提供的对象,此时我们是不知道该对象中是否存在某个键的,如果贸然去扩展就可能会产生冲突的问题。以前解决这样问题的最好方式是约定,例如在上面代码中我们约定在a.js中放入的缓存键都以“a_”开头,而在b.js中都以“b_”开头,这样的话就不会产生冲突了:

//shared.js中
//假设存在一个用于数据缓存的对象cache,并约定是全局共享的
const cache = {};

//a.js中
//在a.js文件中尝试往缓存中写入内容,在a.js中放入的缓存键都以“a_”开头
cache['a_foo'] = 'czs';

//b.js中
//在b.js文件中我们又要向cache对象写入一个新的内容,但在b.js中都以“b_”开头。
cache['b_foo'] = '123';
console.log(cache);  //{a_foo: 'czs', b_foo: '123'}

但约定的方式只是规避了问题并没有彻底地解决这个问题,如果在这个过程中有人不遵守这个约定那这个问题就依然会存在。而ES2015为了解决这种类型的问题就提供了一种全新的原始数据类型:Symbol,他的作用就是表示一个独一无二的值。

//通过Symbol函数就能够创建一个Symbol类型的数据
const symbol = Symbol();
console.log(symbol);  //Symbol()
//这种数据typeof的结果就是symbol,表示他的确是一个全新的类型
console.log(typeof symbol);  //symbol

这种类型最大的特点就是独一无二,即我们用Symbol函数创建的每一个值都是唯一的,永远都不会重复:

console.log(Symbol() === Symbol());  //false

考虑在开发过程中的调试,Symbol函数允许我们传入一个字符串作为这个值的描述文本,这样的话对于我们多次使用Symbol的情况就可以在控制台中区分出来到底是那个对应的Symbol:

console.log(Symbol('foo'));  //Symbol(foo)
console.log(Symbol('bar'));  //Symbol(bar)
console.log(Symbol('far'));  //Symbol(far)

而且在ES2015开始,对象就可以直接使用Symbol类型的值作为属性名,也即现在对象的属性名可以是两种类型:String、Symbol。又因Symbol的值是独一无二的,所以我们就不用担心可能会产生冲突的问题:

const obj = {};
obj[Symbol()] = '123';
obj[Symbol()] = '456';
console.log(obj);  //{Symbol(): '123', Symbol(): '456'}

我们也可以使用计算属性名的方式直接在对象字面量中使用Symbol作为属性名:

const obj = {
    [Symbol()]: 123
}
console.log(obj);  //{Symbol(): 123}

另外,Symbol除了可以用来避免对象属性名重复产生的问题,我们还可以借助这种类型的特点去模拟实现对象的私有成员。以前我们去定义私有成员都是靠约定,例如我们约定用”_“开头就表示私有成员,并约定外界不允许访问“_”开头的成员。而现在我们有了Symbol之后就可以用Symbol去创建私有属性名了。在对象的内部,我们可以使用创建属性时的Symbol去拿到对应的属性成员。但在外部文件中,因我们无法在外部文件中创建一个完全相同的Symbol,所以我们就无法直接访问到这个成员,只能调用该对象中普通名称的成员,这样的话就实现了所谓的私有成员:

//a.js中
const name = Symbol();
const person = {
    [name]: 'czs',
    say(){
        console.log(this[name]);
    }
}

//b.js中
//在b.js中,因我们无法在b.js中创建一个与a.js中完全相同的Symbol,所以我们就无法直接访问到这个成员,
//只能调用该对象中普通名称的成员,这样的话就实现了所谓的私有成员。
console.log(person[Symbol()]);  //undefined
person.say();  //czs

而Symbol类型的值目前最主要的作用就是为对象添加独一无二的属性标识符,截止到ES2019一共定义了6种原始数据类型,加上Object一共是7种数据类型,在未来还会新增一个叫做BigInt的原始数据类型,用于存放更长的数字,只不过目前这个类型还处在stage-4阶段,预计在下一个版本就能被标准化,之后就是8种数据类型了(Number,String,Null,Boolean,Undefined,Object,Symbol,BigInt)。

28、ES2015 Symbol补充

Symbol在使用上还有一些值得我们注意的地方。

首先是他的唯一性,每次通过Symbol创建的值都是唯一的,不管我们传入的描述文本是否相同,每次调用Symbol函数得到的结果都是全新的一个值

console.log(Symbol() === Symbol());  //false
console.log(Symbol('foo') === Symbol('foo'));  //false

如果我们需要在全局去复用一个相同的Symbol值,可以使用全局变量的方式去实现,或使用Symbol类提供的静态方法for去实现。for方法可以接收一个字符串作为参数,相同的字符串就一定会返回相同的Symbol类型值,这个方法内部维护了一个全局的注册表,为字符串和Symbol值提供了一个一一对应的关系:

const s1 = Symbol.for('foo');
const s2 = Symbol.for('foo');
console.log(s1 === s2);  //true

需要注意的是,这个方法内部维护的是字符串和Symbol的对应关系,如果传入的不是字符串,那这个方法的内部会自动把他转换成字符串,这样就会导致传入布尔值的true和传入字符串的true拿到的都是一样的:

console.log(Symbol.for(true) === Symbol.for('true'));  //true

而且在Symbol类中还提供了很多内置的Symbol常量,用来作为内部方法的标识,这些标识符可以让自定义对象去实现一些JS中内置的接口

console.log(Symbol.iterator);  //Symbol(Symbol.iterator)
console.log(Symbol.hasInstance);  //Symbol(Symbol.hasInstance)

例如,我们去定义一个obj对象,然后调用这个对象的toString方法,这个对象的toString结果默认就是”[Object Object]”。我们把这样的字符串称为对象的toString标签。如果我们想要自定义对象的toString标签,就可以在对象中添加一个特定的成员来去标识。考虑到如果使用字符串去添加标识符就有可能和内部的成员产生重复,所以ECMAScript就要求我们使用Symbol值去实现这样的接口:

const obj = {
    //这里的toStringTag就是内置的一个Symbol常量
    [Symbol.toStringTag]: 'XObject'
}
console.log(obj.toString());  //[object Xobject]

上面的Symbol用法在为对象设置迭代器时会经常用到。

最后,当我们使用Symbol值作为对象的属性名时,通过传统的for…in循环是无法拿到的,而且通过Object.Keys方法也是获取不到这样的Symbol属性名。并且如果我们通过JSON.Stringify去序列化对象为一个json字符串的话,Symbol属性也会被忽略。这些特性都使得Symbol类型的属性特别适合作为对象的私有属性。不过想要获取这种类型的属性名也不是完全没有办法,我们可以使用Object对象里的getOwnPropertySymbols方法,这个方法类似于Object.Keys方法,所不同的是Object.Keys方法只能够获取到对象中所有的字符串属性名,而Object.getOwnPropertySymbols方法获取到的全是Symbol类型的属性名

const obj = {
    [Symbol()]: 'symbol value',
    foo: 'normal value'
}
for(var key in obj){
    console.log(key);  //foo
}
console.log(Object.keys(obj));  //['foo']
console.log(JSON.stringify(obj));  //{"foo":"normal value"}
console.log(Object.getOwnPropertySymbols(obj));  //[Symbol()]

29、ES2015 for…of循环

在ECMAScript中遍历数据有很多种方法,首先就是最基本的for循环,他比较适用于遍历普通的数组。然后就是for…in循环,他比较适合遍历键值对。再有就是函数式的一些遍历方法,例如数组对象的forEach方法。这些各种各样的遍历方式都会有一些局限性,所以ES2015借鉴了很多其他的一些语言,引入了一种全新的遍历方式:for…of循环,这种循环方式以后会作为遍历所有数据结构的统一方式。也即如果我们明白for…of内部工作的原理,就可以使用这种循环遍历任意一种自定义的数据结构。

介绍原理之前,先来了解一下for…of循环的基本用法:

const arr = [100, 200, 300, 400];
//不同于传统的for...in循环,for...of循环拿到的是数组中的每个元素,而非对应的下标
for(const item of arr){
    console.log(item);   //100  //200  //300  //400
}

for…of这种循环方式也可以取代我们之前常用的数组实例中的forEach方法,而且相比于forEach方法,for…of循环可以使用break关键词随时终止循环,而forEach方法是无法终止遍历的:

const arr = [100, 200, 300, 400];
arr.forEach(item => {
    console.log(item);  //100  //200  //300  //400
})

for(const item of arr){
    console.log(item);  //100 //200
    if(item > 100){
        break;
    }
}

在以前我们为了能够随时终止遍历,必须要使用数组实例的some或every方法,在some方法的回调函数之中返回true,在every方法的回调函数当中返回false都可以终止遍历。而在forEach方法当中无论返回true还是false都不会终止遍历:

const arr = [100, 200, 300, 400];
arr.some((item) => {
    console.log(item);  //100  //200
    if(item > 100){
        return true;
    }
});
arr.every((item) => {
    //这里只输出一个100,是因为默认返回的为false;
    console.log(item);  //100
    if(item > 100){
        return false;
    }
});
arr.forEach(item => {
    console.log(item);  //100  //200  //300  //400
    if(item > 100){
        return true;
    }
    else if(item > 200){
        return false;
    }
})

现在在for…of循环中就可以直接使用break关键字去随时终止循环。

除了数组可以被for…of直接遍历,一些伪数组对象也可以直接使用for…of循环去直接遍历的,例如在函数中的arguments对象,或者是在DOM操作时的元素节点的列表,他们的遍历都和普通的数组遍历没有任何区别,在此就不再赘述。另外还可以用for…of去遍历ES2015新增的set和map对象:

const set = new Set(['foo', 'bar']);
for(const item of set){
    console.log(item);  //foo  //bar
}

const map = new Map();
map.set('foo', '123');
map.set('bar', '345');

for(const item of map){
    console.log(item);  //['foo', '123']  //['bar', '345']
}

注意,我们遍历map每次得到的还是一个数组,而且数组当中都是两个成员,这两个成员分别就是当前被遍历的键和值。因为我们遍历的是一个键值结构,而一般键和值在循环体中都要用到,所以在这里是以一个数组的形式来提供键和值。这里还可以配合数组的解构语法来直接拿到数组中的键和值,这样我们在遍历map时就可以直接去使用对应的键和值了:

const map = new Map();
map.set('foo', '123');
map.set('bar', '345');
for(const [key, value] of map){
    console.log(key, value);  //foo 123  //bar 345
}

这就是我们遍历map和遍历数组之间的细微差异。

最后,我们再尝试用for…of循环去遍历最普通的对象:

const obj = { foo: 123, bar: 456 }
for(const item of obj){
    console.log(item);
}
//Uncaught TypeError: obj is not iterable

我们可以看到控制台中报了一个错误:obj is not iterable。意思是我们的obj对象是不可迭代的,而在之前我们曾说过for…of循环可以作为遍历所有数据结构的统一方式,但我们通过实验发现他连最基本的普通对象都没有办法遍历(这不是啪啪打脸吗( ̄ε(# ̄)☆╰╮( ̄▽ ̄///)),这又究竟是为什么呢,到底是道德的沦丧,还是人性的缺失,还请看下一节。

PART 4

30、ES2015 可迭代接口

上节说到for…of循环是ES2015中最新推出一种循环语法,是一种遍历所有数据结构的统一方式。但经过我们的实际尝试发现他只能够遍历数组之类的数据结构,对于普通对象如果直接去遍历就会报错。这究竟是什么原因呢?其实真相是这样的,因ES中能够表示有结构的数据类型越来越多,从最早的数组到对象,到现在又新增了set和map,而且开发者还可以组合使用这些类型去定义一些符合自己业务需求的数据结构。而为了提供一种统一的遍历方式,ES2015就提出了一个叫做Iterable的接口,意思就是“可迭代的”。如果不太理解编程中接口的概念,可以把他近似为一种规格标准,例如在ES中任意一种数据类型都有toString方法,这就是因为他们都实现了统一的规格标准。而在编程语言中,更专业的说法就是他们都实现了统一的接口。再来看可迭代接口,他就是一种可以被for…of循环统一遍历访问的规格标准,换句话说,只要这个数据结构实现了可迭代接口,那他就能够被for…of循环遍历。这也表明了我们之前尝试的那些能够被for…of循环遍历的数据类型都已经在内部实现了这个接口。

这里我们脱离for…of循环的表象来看看这个叫做Iterable的接口:

console.log([].__proto__);
//..........
//Symbol(Symbol.iterator): ƒ values()
//.....

console.log(new Set().__proto__.)
//..........
//Symbol(Symbol.iterator): ƒ values()
//.....

console.log(new Map().__proto__)
//..........
//Symbol(Symbol.iterator): ƒ values()
//.....

因这三个能被for…of遍历的对象都有上面注释中的方法,且这个方法的名字为iterator,故可以基本确定iterable接口约定的就是对象当中必须挂载一个叫做iterator的方法,那这个方法到底是干什么的呢,我们可以先来看看:

const arr = ['foo', 'bar', 'far'];
//arr[Symbol.iterator]()方法返回一个数组的迭代器对象,对象中有一个next方法
console.log(arr[Symbol.iterator]())
//..........
//next: ƒ next()
//.....

我们再来手动调用一下next方法:

const arr = ['foo', 'bar', 'far'];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next())  //{value: 'foo', done: false}

iterator.next()方法返回一个对象,这个对象有两个成员,分别是value和done,value中的值是数组中的第一个元素,done中的值是一个false,若我们再多次调用next()方法:

const arr = ['foo', 'bar', 'far'];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next())  //{value: 'foo', done: false}
console.log(iterator.next())  //{value: 'bar', done: false}
console.log(iterator.next())  //{value: 'far', done: false}
console.log(iterator.next())  //{value: undefined, done: true}
console.log(iterator.next())  //{value: undefined, done: true}

现在,我们应该能够很容易想到,在这个迭代器内部应该是维护了一个数据指针,我们每调用一次next,指针都会往后移动一位,而done属性的作用应该就是表示内部的数据是否全部被遍历完了。

最后,我们再来总结一下:所有能被for…of循环遍历的数据类型都必须要实现Iterable接口,即他们内部都必须要挂载一个iterator方法,这个方法需要返回一个带有next方法的对象,我们不断调用这个next方法就可以实现对内部所有数据的遍历。

最后的最后,让我们再来实践一下对于Set对象我们之前的结论是否正确:

const set = new Set(['foo', 'bar', 'far']);
const iterator = set[Symbol.iterator]();
console.log(iterator.next())  //{value: 'foo', done: false}
console.log(iterator.next())  //{value: 'bar', done: false}
console.log(iterator.next())  //{value: 'far', done: false}
console.log(iterator.next())  //{value: undefined, done: true}
console.log(iterator.next())  //{value: undefined, done: true}

显然,我们可以看到Set中的数据确实被正常遍历了,这也进一步证实了我们刚刚的推断是正确的。

其实,这就是for…of内部的工作原理,for…of循环内部就是按照这里的执行过程实现的遍历。当然,我们也同样可以使用while等循环去实现相同的遍历。

31、ES2015 实现可迭代接口

了解了for…of循环内部的原理后,我们应该就能理解为什么for…of循环能够作为遍历所有数据结构的统一方式了,因为他内部就是去调用被遍历对象的iterator方法,得到一个迭代器,从而遍历内部所有的数据。那也就是Iterable接口所约定的内容,换句话说,只要我们的对象也实现了Iterable接口,就可以实现用for…of循环去遍历我们自己的对象。

在ES当中,去实现Iterable接口实际上就是在这个对象中挂载一个iterator方法,然后在这个方法中返回一个迭代器对象

//最外层的自定义对象实现了可迭代接口,英文名为:"Iterable"。
//这个接口约定的是对象内部必须要有一个用于返回迭代器的iterator方法
const obj = {
    //Symbol.iterator是Symbol类提供的一个常量,要用计算属性名的方式定义到字面量中。
    [Symbol.iterator]: function(){
        //iterator方法返回的对象实现了迭代器接口(Iterator),这个接口约定对象内部要有一个用于迭代的next方法。
        return {
            next: function(){
                //在next方法中返回的对象实现的是迭代结果接口(IterationResult)。
                //这个接口约定对象内部要有一个value属性用于表示当前被迭代的数据,可以是任意类型。
                //除此之外还需要有一个布尔值类型的done属性,用来表示迭代是否结束。
                return {
                    value: 'czs',
                    done: true
                }
            }
        }
    }
}

for(const item of obj){
    console.log('循环体')  //没报错,也无输出
}

上面的for…of循环没有报错就意味着我们实现的可迭代接口能够被for…of遍历,只不过这里的循环体并没有被执行。原因也很简单,因为我们写死了next方法返回的结果,导致内部第一次调用next返回结果当中的done属性值就是true,所以就表示循环已经结束,循环体自然就不会再执行了。下面,我们再来修改一下这个对象:

const obj = {
    store: ['foo', 'bar', 'far'],
    [Symbol.iterator]: function(){
        let index = 0;
        //这里由于next函数中的this并不是obj对象,所以定义self接收当前this
        const self = this;
        return {
            next: function(){
                const result = {
                    value: self.store[index],
                    done: index >= self.store.length
                }
                index++;
                return result;
            }
        }
    }
}

for(const item of obj){
    console.log('循环体',item)  //循环体 foo  //循环体 bar  //循环体 far
}

这里大家可能就会疑问说这有神马用?关于这个我们下回再讨论。

32、ES2015 迭代器模式

上回说到,如何让我们的自定义对象实现可迭代接口,从而实现能够用for…of循环去迭代我们的对象,这也就是设计模式中的迭代器模式。大家不要被这种名头唬住,下面我们再通过一个小案例去理解这种模式的优势。

假设我们正协同开发一个任务清单应用。我的任务就是去设计一个用于存放所有任务的对象。你的任务是把我定义的对象中所有任务项都罗列到界面上。在这种情景下:

//我的代码
const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语']
}

//你的代码
for(const item of todos.life){
    console.log(item);  //吃饭  //睡觉  //打豆豆
}
for(const item of todos.learn){
    console.log(item);  //语文  //数学  //外语
}

如果这个时候,我的数据结构发生了变化,例如添加了一个全新的类目,但你的代码与我之前的数据结构是强耦合的,所以也需要跟着一起变化:

//我的代码
const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语'],
    work: ['摸鱼']
}

//你的代码
for(const item of todos.life){
    console.log(item);  //吃饭  //睡觉  //打豆豆
}
for(const item of todos.learn){
    console.log(item);  //语文  //数学  //外语
}
for(const item of todos.work){
    console.log(item);  //摸鱼
}

假设我的数据结构可以对外提供一个统一的遍历接口,那么对于调用者(你)而言就不需要关心对象内部的结构是如何的,更不需要关心内部数据结构改变后产生的影响:

//我的代码
const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语'],
    work: ['摸鱼'],
    
    each: function(callback){
        const all = [].concat(this.life, this.learn, this.work);
        for(const item of all){
            callback(item)
        }
    }
}

//你的代码
todos.each(function(item){
    console.log(item)  //吃饭  //睡觉  //打豆豆  //语文  //数学  //外语  //摸鱼
});

实际上,实现可迭代接口也是相同的道理,下面我们再尝试用迭代器的方式解决这个问题:

//我的代码
const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语'],
    work: ['摸鱼'],
    
    [Symbol.iterator]: function(){
        //用ES2015的“...”操作符将内部的三个数组展开组成一个新数组
        const all = [...this.life, ...this.learn, ...this.work];
        let index = 0;
        return {
            next: function(){
                return {
                    value: all[index],
                    //使用index后不要忘了++,否则会产生死循环
                    done: index++ >= all.length
                }
            }
        }
    }
}

for(const item of todos){
    console.log(item)  //吃饭  //睡觉  //打豆豆  //语文  //数学  //外语  //摸鱼
}

这就是我们实现迭代器的意义,迭代器模式的核心就是对外提供统一接口,让外部不用再去关心数据内部的结构是怎样的。不过我们上面使用的each方法只适用于当前这个对象结构,而ES2015中的迭代器是在语言层面实现的迭代器模式,所以他可以适用于任何数据结构,只需要你用代码实现Iterator方法,实现他的迭代逻辑就可以了。这种模式其实在很多地方都会用到,只不过很多时候我们的观察都只停留在表面,认为只要知道某个API的使用就行了,根本就不去关心内部的实现逻辑。

33、ES2015 生成器

在ES2015中还新增了一种生成器函数,英文叫:Generator。引入这个新特性的目的是为了能在复杂的异步代码中减少回调函数嵌套产生的问题,从而提供更好的异步编程解决方案。在这里我们先来了解一下生成器函数的语法以及基本应用:

//定义生成器函数就是在普通的function关键字后添加一个*号
function * foo(){
    console.log('czs');
    return 100;
}

const result = foo();
console.log(result);  //foo {<suspended>}
console.log(result.__proto__);  //Generator {}
console.log(result.__proto__.__proto__);
//Generator {constructor: GeneratorFunction, Symbol(Symbol.toStringTag): 'Generator', next: ƒ, return: ƒ, throw: ƒ}

若foo是一个普通函数,在第一个地方打印出来的就应该是“czs”以及100了。但这里并没有和普通函数一样,而是打印了一个生成器对象,而且在这个对象的原型上也有一个和迭代器对象一样的next方法,让我们调用一下next方法看看会发生什么情况:

//定义生成器函数就是在普通的function关键字后添加一个*号
function * foo(){
    console.log('czs');
    return 100;
}

const result = foo();
console.log(result.next());  //czs  //{value: 100, done: true}

这里我们可以看到,在函数当中打印的“czs”字符串被正常输出了,这也说明我们的函数体在此时才开始执行。而且next方法的返回值与迭代器next方法的返回值也有相同的结构,也是value和done,且函数中的返回值被放到value中了,这就是因为生成器对象其实也实现了Iterator接口。不过只是这么去使用的话,根本看不出生成器函数的作用,因为生成器函数在实际使用的时候都会配合一个叫做“yield”的关键词,“yield”关键词与“return”关键词非常类似但也有很大不同,让我们接着往下看:

function * foo(){
    console.log('111');
    yield 100;
    console.log('222');
    yield 200;
    console.log('333');
    yield 300;
}

const generator = foo();
console.log(generator.next());  //111  //{value: 100, done: false}
console.log(generator.next());  //222  //{value: 200, done: false}
console.log(generator.next());  //333  //{value: 300, done: false}
console.log(generator.next());  //{value: undefined, done: true}

通过上面的尝试,我们可以发现其中的一些特点,总结一下就是:生成器函数会自动帮我们返回一个生成器对象,调用这个对象的next方法才会让这个函数的函数体开始执行,在执行过程中一旦遇到了yield关键词,则函数的执行就会被暂停。而且yield后面的值将会作为next的结果返回,如果我们再继续调用这个生成器对象的next,那么函数就会在暂停的位置继续开始执行,周而复始,一直到这个函数完全结束,且next所返回的done的值也就变成了true。这就是生成器函数的基本用法,他最大的特点就是惰性执行,也就是“抽一次,动一次”。另外,生成器函数还有消息传递的作用,具体请看Generator异步方案-消息传递

34、ES2015 生成器应用

了解了生成器函数的基本用法之后,我们先来看一个简单的应用场景,实现一个发号器。我们在实际的业务开发过程中经常需要用到自增的ID,而且我们每次调用ID都需要在原有的基础上加1。这里若我们使用生成器函数来实现这个功能则是最合适的了:

function * createIdMaker(){
    let id = 1;
    //这里不需要担心死循环的问题,因为我们每次在yield过后,这个方法都会被暂停
    while(true){
        yield id++;
    }
}

const idMaker = createIdMaker();
console.log(idMaker.next().value);  //1
console.log(idMaker.next().value);  //2
console.log(idMaker.next().value);  //3
console.log(idMaker.next().value);  //4

当然,实现一个发号器是一个非常简单的需求,我们还可以使用生成器函数实现对象的iterator方法,因为生成器也实现了iterator接口,而且使用生成器函数去实现iterator方法回会比之前的方式要简单很多。这里我们在之前吃饭,睡觉,打豆豆的案例基础之上使用生成器函数来实现iterator方法:

//我的代码
const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语'],
    work: ['摸鱼'],
    
    //我们不再需要手动返回迭代器对象了,
    //而是用生成器函数在iterator方法内部直接遍历成员并用yield返回每个被遍历到的成员即可
    [Symbol.iterator]: function * (){
        //用ES2015的“...”操作符将内部的三个数组展开组成一个新数组
        const all = [...this.life, ...this.learn, ...this.work];
        for(const item of all){
            yield item;
        }
    }
}

for(const item of todos){
    console.log(item)  //吃饭  //睡觉  //打豆豆  //语文  //数学  //外语  //摸鱼
}

以上,就是我们使用生成器函数的一些简单用途,但是他最重要的目的还是为了解决异步编程中回调嵌套过深所导致的问题,不过这个点我们会放到JS异步编程篇中详细介绍。

35、ES2015 ES Modules

ES Modules是ES2015中标准化的一套语言层面的模块化规范,将会在模块化开发篇中进行详细介绍。到时候我们会把他和CommonJS以及其他的一些标准做一个统一的对比。

36、ES2016 概述

ES2016,正式名称应该叫做ECMAScript2016,发布与2016年的6月,与ES2015相比,ES2016只是一个小版本,仅包含两个小功能。

首先就是数组实例对象中的includes方法,这个方法让我们去检查数组当中是否包含指定元素变得更加简单。在此之前,若我们想要检查某个数组当中是否存在某个指定的元素,我们就必须要使用数组对象的indexOf方法去实现,这个方法可以帮我们找到元素在数组当中所对应的下标,而在没有找到指定元素的情况下,他会返回一个-1:

const arr = ['foo', 1, NaN, false];
console.log(arr.indexOf('foo'));  //0
console.log(arr.indexOf('bar'));  //-1

但是用这种方式去判断是否存在某一元素也会存在一个问题,他不能用于查找数组当中的NaN。而现在有了includes方法后我们就可以直接判断数组当中是否存在某一个指定的元素了。includes方法直接返回一个布尔值来表示存在与否,而且includes方法相对于indexOf还可以去查找NaN这样的数值:

const arr = ['foo', 1, NaN, false];
console.log(arr.indexOf('NaN'));  //-1
console.log(arr.includes('foo'));  //true
console.log(arr.includes(NaN));  //true

除了includes以外,ES2016另外一个新功能就是多了一个指数运算符。在以前,我们要进行指数运算的话,需要借助Math对象中的power方法来实现。而在ES2016中新增的指数运算符就是语言本身的运算符,就像加减乘除运算符一样,使用起来也非常简单:

//第一个参数是底数,第二个是指数
console.log(Math.pow(2, 10));  //1024
//ES2016新增的指数运算符,用**表示,前面为底数,后面为指数
console.log(2 ** 10);  //1024

这种新的运算符对于数学密集型的应用是一个很好地补充。不过我们在日常的应用开发过程中很少会用到指数运算。

37、ES2017概述

ES2017是ECMAScript标准的第8个版本,正式名称应该叫做ECMAScript2017,他发布于2017年的6月。和ES2015相比,ES2017也是一个小版本,但他同样带来了一些非常有用的新功能。

首先就是对Object对象的三个扩展方法:Object.values、Object.Entries与Object.getOwnPropertyDescriptors方法。

Object.values与ES5的Object.keys非常类似,但keys返回的是键组成的数组,而values返回的是值所组成的数组:

const obj = {
    foo: 'value1',
    bar: 'value2'
}
console.log(Object.values(obj));  //['value1', 'value2']

Object.Entries方法是以数组的形式返回数组当中所有的键值对,这使得我们可以直接使用for…of循环去遍历普通对象。此外,因为Map的构造函数就是需要这种格式的数组,所以我们就可以借助entries方法将普通对象转化为Map类型的对象:

const obj = {
    foo: 'value1',
    bar: 'value2'
}
console.log(Object.entries(obj));  //[Array(2), Array(2)]
for(const [key, value] of Object.entries(obj)){
    console.log(key, value);  //foo value1  //bar value2
}
console.log(new Map(Object.entries(obj)));  //Map(2) {'foo' => 'value1', 'bar' => 'value2'}

最后,Object.getOwnPropertyDescriptors方法可以帮我们获取对象中完整的属性描述信息。自从ES5过后,我们就可以为对象定义getter或setter属性,而这些属性是不能直接通过Object.assign方法去完全复制的:

const p1 = {
    firstName: 'George',
    lastName: 'Smith',
    // 这样就相当于为外界提供了一个只读属性fullName
    get fullName(){
        return `${this.firstName} ${this.lastName}`;
    }
}
console.log(p1.fullName);  //George Smith

// 我们通过Object.assign方法复制一个p2出来,并修改p2的firstName
const p2 = Object.assign({}, p1);
p2.firstName = 'czs';
// 然而此时我们拿到的还是p1中的firstName
console.log(p2.fullName);  //George Smith
// 原来是因为Object.assign在复制时只是把fullName当做一个普通的属性去复制了
console.log(p2)  //{firstName: 'czs', lastName: 'Smith', fullName: 'George Smith'}

在上面这种情况下,我们就可以使用Object.getOwnPropertyDescriptors方法去获取对象当中属性的完整描述信息,然后再使用Object.defineProperties方法去将这个描述信息定义到一个新对象当中。这样的话,我们对于getter,setter类型的属性就可以做到复制了:

const p1 = {
    firstName: 'George',
    lastName: 'Smith',
    // 这样就相当于为外界提供了一个只读属性fullName
    get fullName(){
        return `${this.firstName} ${this.lastName}`;
    }
}
const descriptors = Object.getOwnPropertyDescriptors(p1);
console.log(descriptors);  //{firstName: {…}, lastName: {…}, fullName: {…}}
const p2 = Object.defineProperties({}, descriptors);
// 此时我们再去修改p2对象中的firstName的话,fullName也会发生变化了
p2.firstName = 'czs';
console.log(p2.fullName);  //czs Smith

这就是Object.getOwnPropertyDescriptors方法的意义,他主要就是配合ES5中新增的getter,setter去使用。

另外,ES2017还新增了两个字符串填充方法,分别是padStart与padEnd。他们的功能也非常简单,却很实用。比如我们可以用他为我们的数字添加前导0,或用这种方法去对齐字符串输出的长度:

const books = {
    html: 5,
    css: 16,
    javascript: 128
}
// 若直接去遍历输出这些数据,控制台就会显示得非常乱
for(const [name, count] of Object.entries(books)){
    console.log(name, count);  //html 5  //css 16  //javascript 128
}
// 这时就可以使用字符串的pad相关方法将文本做一些对齐
for(const [name, count] of Object.entries(books)){
    // padEnd的第一个参数为字符总长度,第二个参数为用于填充的字符,padStart类似。前者为填充后面,后者反之。
    console.log(`${name.padEnd(10, '-')}|${count.toString().padStart(3, '0')}`);
}
//html------|005  //css-------|016  //javascript|128

总之,padEnd与padStart的效果就是用给定的字符串填充目标字符串的结束或开始位置,直到字符串达到指定长度为止。

除此之外,ES2017还有一个非常小的变化就是,他允许函数参数列表的最后一位添加一个结束的尾逗号。这是一个非常小的变化,有这个变化的原因也很简单,就像很多人去定义数组或对象时,最后一个元素后都会添加一个逗号,和这里的原因是一样的。

function foo(
	bar,
    far, 
    ) {
    console.log(bar, far);
}
foo(123, 345);  //123 345

就像一个普通的数组一样:

const arr = [
    111,
    222,
    333,
]

尽管在执行层面,最后添加一个逗号与否没有任何差异。但很多人都会这么去使用,这样用有两个好处,首先就是如果我们想要重新排列数组中元素的顺序,因数组中元素的格式是一致的,所以调整起来就比较方便。第二就是如果我们去修改数组中元素的个数时,若最后有一个逗号则我们只需要新建一行然后把我们的元素放进新一行就行了。但如果最后没有逗号的话,我们则还要在最后添加一个逗号,新增一行去添加新元素,这样的话,对于我们的源代码来讲,就需要修改两行,所以说,我们在结束位置去添加一个逗号可以让源代码管理工具更精确地定位到我们代码中实际发生变化的位置。考虑到以上的两个优势,越来越多的人使用尾逗号这种形式,ES2017只是允许我们在定义函数和调用函数的参数位置使用尾逗号罢了,目的都是一样的。

最后一个点,也是最重要的一个点就是ES2017中标准化了一个最重要的功能:Async函数。async函数再去配合await关键词就彻底解决了异步编程中回调函数嵌套过深所产生的问题,使得我们的代码变得更加简洁易读。async函数本质上就是使用promise的一种语法糖而已,这个特性也将在js异步编程篇中详细介绍。


文章作者: 智升
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 智升 !
评论
  目录