文章概述

这里我们要介绍的是ECMAScript以及他这几年发布的新特性,可能你会说,这不就是ES6么,我天天用的就是这个,的确,就目前而言一个合格的前端开发都应该对他比较熟悉。

那为什么我们这里还是要再次说起ECMAScript呢,具体的原因也很简单。

第一点就是结合面试过程中的经验,我们发现很多开发者并没有理解语言和平台之间的关系。

以javascript为例,我们日常编写的代码,哪些属于语言层面,哪些属于平台层面,这一点,很多人都回答不出来。

在以前,不了解这些一样可以写代码,但是在node平台发展的今天,搞明白这些非常重要。

很多人对ES的理解和掌握程度不尽相同,市面上的资料也比较碎片,很难整理。

所以系统化的学习ECMAScript就显得很有必要。同时这些内容对你去写出更现代化,更高质量的代码也会有很大的帮助。

话不多说,我们言归正传,这里我们先来看一下会介绍哪些内容,其实也比较简单,首先我们会先去搞明白ECMAScript与JavaScript到底是怎样的关系,弄明白语言和平台之间的联系。

然后我们会详细介绍ECMAScript2015也就是ES6中具体出现了哪些新特性,以及这些新特性他们出现的背景或者是优势。

当然了我们还会介绍到2016,2017等等新版本中常用的特性。其中涉及到的重点内容可能涉及到的东西会比较多,比如Promise或者是ES Modules, 这样的内容我们会在后续的模块当中作为专题来去详细介绍。

我们这里的目标就是整体对ECMAScript的新特性有一个完整的认识。简历一个完整的知识体系。

ECMAScript概述

ECMAScript他也是一门脚本语言,一般缩写为ES,通常我们会把他看作为JavaScript的标准规范。

但实际上JavaScript是ECMAScript的扩展语言,因为ECMAScript只是提供了最基本的语法,通俗点来说只是约定了代码的如何编写,例如我们该怎么样定义变量或函数,怎样去实现分支或者循环之类的语句,这只是停留在语言层面,并不能完成我们应用中的实际功能的开发。

而JavaScript实现了标准开发,并且在这个语言基础上做了一定的扩展,使得可以在浏览器环境中操作DOM,BOM;在Node环境可以去做读写文件之类的操作。

那总的来说,在浏览器环境中的JavaScript他就等于ECMAScript加上web所提供的API,也就是我们所说的DOM 和 BOM。

js本身指的就是ECMAScript + DOM + BOM;

那在Node环境中所使用的JavaScript,它实际上就等于是ECMAScript加上Node所提供的一系列API。例如像fs或者是net这样的内置模块所提供的API。

所以说JavaScript中语言本身指的就是ECMAScript,随着这些年web这种应用模式深入的发展从2015年开始ECMAScript就保持每年一个大版本的迭代。伴随着这些新版本的迭代,很多新特性陆续出现,这也就导致我们现如今JavaScript这门语言的本身也就变得越来越高级,越来越便捷。

ES2015值得我们单独去了解的内容有很多,因为在这个版本当中他相对比较特殊,他在上一个版本也就是ES5发布过后经历了近6年的时间才被完全的标准化。而且这6年的时间也是web发展的黄金时间。

所以说在这个版本中他包含了很多颠覆式的新功能,也正是因为ES2015迭代的时间过长导致发布的内容过多,所以从之后的版本开始ES的发布会变得更加频繁,那也更符合我们当下互联网小步快跑这种精神,而且从ES2015过后ECMAScript就决定不再按照版本号命名,而是使用发行年份。

由于这样一个决定是在ES2015诞生的过程中产生的所以当时很多人就已经习惯了ES6这样一个名称,所以对于ES2015就出现了有人称之为ES6的情况。

随着ECMAScript开始稳步的迭代发展,市面上主流的运行环境也都纷纷跟进,已经开始逐步支持这些最新的特性,所以说对于我们使用JavaScript的开发者而言,学习这些新特性很有必要。

下面我们就从ES2015开始去了解这些版本当中发布了哪些最为核心最为有用的新特性。

ES2015 概述

ECMAScript2015也可以叫做ES6, 那他可以算作新时代ECMAScript标准的代表版本。一来它相比于上一个版本变化比较大,二来从这个版本开始他的命名规则发生了变化,更准确的缩写名称叫做ES2015。

顺便解释一下目前有很多开发者使用ES6这样一个名称去泛指从ES5.1以后所有的新版本,例如我们在很多资料中会看到使用ES6的async和await之类的一些说法。但实际上async和await是ES2017中指定的标准。所以以后我们需要去注意分辨所看到的ES6指的到底是ECMAScript2015标准还是说泛指所有的新标准。

ECMAScript2015的标准规范长达26章,如果可以建议花一点时间简单过一遍ES2015的完整语言规格文件,因为这个规格文件中不是仅仅介绍了这个版本所引入的新特性,而是包含这个新特性过后所有的语言标准规范。

我们这里要介绍的只是ES2015标准中所提出的一些比较重要,值得我们单独去了解的新特性。我们这里把这些变化简单的归为四大类。

首先第一类就是解决原有语法上的一些问题或者不足,例如像let或者const所提供的块级作用域。

第二类就是对原有语法进行增强使之变得更为便捷,易用,例如像解构,展开还有参数默认值,模板字符串等等。

第三类就是全新的对象,全新的方法还有全新的功能,例如像Promise还有Proxy,以及像Object.assign方法之类的。

第四类就是全新的数据类型和数据结构,例如像是Symbol, Set, Map等等。

那下面我们就一起了解一下这些最主要的新特性。

准备工作

由于这里只是介绍语言本身,并不会涉及到运行环境所提供的API所以任何一个支持ES2015的环境都是可以的,我们这里为了有更直观的展示,所以我们选择使用Node.js的环境去运行我们这里的每一个示例。当然你也可以使用最新的Chrome浏览器去运行他们。也都是可以支持的。

这里我们会用到一个叫做nodemon的小工具,他的作用就是在我们修改完代码过后自动重新执行我们的代码,这样的话我们的演示可以更加的便捷一点。

使用的方式也非常简单,你可以在全局范围或者项目中先去安装这个模块。

yarn add nodemon --dev

安装过后执行nodemon命令就可以了。

yarn nodemon ./index.js

在执行之后不会立即退出,他会去监视我们执行的脚本文件,一旦文件发生变化过后他就会立即重新执行这个脚本非常方便。

let 与块级作用域

作用域顾名思义指的就是我们代码当中某一个成员它能够起作用的范围。

在ES2015之前,ECMAScript当中只有两种类型的作用不,分别是全局作用域和函数作用域。在ES2015中又新增了一个块级作用域。

块指的就是我们代码中用一对{}所包裹起来的范围,例如if语句和for语句中的{}都会产生我们这里所说的块。

if (true) {
    consoel.log('yd');
}

for (var i = 0; i < 10; i++) {
    console.log('yd');
}

在以前块是没有单独的作用域的,这就导致我们在块中定义的成员外部也可以访问到。例如我们在if当中去定义了一个foo的变量,然后在if的外面打印这个foo,结果也是可以正常打印出来的。

if (true) {
    var foo = 'yd';
}
console.log(foo); // yd

这一点对于复杂代码是非常不利的,也是不安全的,有了块级作用域之后,我们就可以在代码当中通过一个新的关键词,就是let去声明变量。

他的用法和传统的var是一样的,只不过通过let声明的变量他只能在所声明的这个代码块中被访问到。我们这里将刚刚的var尝试修改为let,然后保存。

if (true) {
    let foo = 'yd';
}
console.log(foo); // yd

此时控制台就会打印一个foo is not defined的一个错误,这也就表示在快级内定义的成员,外部是无法访问的。

这样一个特性非常适合我们声明for循环当中的计数器,传统的for循环如果出现了循环嵌套的情况,我们就必须要为循环中的计数器设置不同的名称。否则的话就会出现问题。

例如我们在这里添加两个for循环的嵌套,而且我们这两个for循环嵌套的计数器的变量都叫i,然后我们在内存循环去打印这个i。

设想一下我们这个双层循环嵌套,他应该是一个 3 * 3 的一个循环,应该打印9次,但是我们执行过后发现这里出现了问题,这里只打印了3次。

for (var i = 0; i < 3; i++) {
    for (var i = 0; i < 3; i++) {
        console.log(i);
    }
}

仔细分析原因其实也很简单,因为外层声明了i过后,内存循环再次声明了i,而且他们都是使用var去声明的,也就是说并不是一个快级作用域内的成员,而是全局成员。

那内层所声明的这个i就会覆盖之前外层所声明的i,等到内存循环执行完了过后,我们这个i的值就是3,对于外层来讲的话,外层拿到的这个i仍然是全局中的i也就是3。也就不满足循环条件,自然也就不会继续循环了。

那如果说我们使用的是let的话就不会有这样的一个问题,因为let所声明的一个变量只能在当前循环所在的这个代码块中生效。

我们这里将var修改为let,此时内层循环就会正常的执行9次。

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

因为内存循环中的i是一个内部的快级作用域的局部成员,需要注意的是这里真正解决问题的实际上是内层循环当中的这个let,因为他才是把我们内部的这个i关进了一个盒子中,并不再去影响外部。

我们即便是把外部的let改回var也是可以的。

虽然let关键词解决了循环嵌套当中我们计数器重名导致的问题,但还是建议一般不要去使用同名的计数器,因为这样的话不利于后期再去理解我们的代码。

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

我们这里定义一个elements数组去演示一下,在数组中定义三个成员对象,每个对象都是一个空的对象,他们代表一个界面的元素。

然后我们去遍历整个数组,然后去模拟为每一个元素添加一个onclick事件,实际上就是添加一个onclick方法。然后在事件的处理函数当中我们去访问当前循环的计数器,也就是i我们把他打印出来。

完成过后我们再到循环结束过后我们去任意调用elements当中的任意一个成员的onclick。

var elements = [{}, {}, {}];

for (var i = 0; i < elements.length; i++) {
    elements[i].onclick = function() {
        console.log(i);
    }
}

elements[0].onclick();

你会发现这里打印的i都是3,这是因为我们这里打印的i它实际上始终都是全局作用域当中的i,在循环执行完成过后我们的i就已经被累加到了3,所以我们无论打印的是哪一个元素的click他的结果都是一样的。

这里也是闭包的一个典型的应用场景,通过建立闭包就可以解决这样一个问题。

var elements = [{}, {}, {}];

for (var i = 0; i < elements.length; i++) {
    elements[i].onclick = (function(i){
        return function() {
            console.log(i);
        }
    })(i)
}

elements[0].onclick();

其实闭包他也就是借助于函数作用域去摆脱全局作用域带来的影响。现在有了块级作用域过后就不必要这么麻烦了,只需要将声明计数器的var修改为let

使用新关键字let或const声明,区别不var,这样就使i只能够在块级作用域内被访问,这样的话我们这个问题就自然被解决了。

var elements = [{}, {}, {}];

for (let i = 0; i < elements.length; i++) {
    elements[i].onclick = function() {
        console.log(i);
    }
}

elements[0].onclick();

其实这个内部他也是一种闭包的机制,因为在我们onclick执行的时候,我们这个循环早就结束了,那实际的i早就已经销毁掉了,就是因为闭包的机制我们才可以拿到这个原本执行循环的时候那个i所对应的值。

另外在for循环中还有一个特别之处,因为在for循环内部它实际上会有两层作用域,例如我们这里再来添加一个使用let的for循环,然后在这个循环内部我们再去使用let声明一个i='foo', 可能你会觉得这两个i会有冲突。

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

此时控制台当中可以正常输出3次foo,这也就表明我们这两个i实际上是互不影响的。也就是说他们不会在同一个作用域当中。

这么说可能不好理解,这里我们把这个循环拆解开,用if的方式去演示一下就明白了,for循环实际上先就是执行的let i = 0; 然后判断i < 3; 如果小于3继续执行,在if里声明i=foo,if完成过后我们再执行i++; 以此类推,这就是循环的完整过程。

let i = 0;

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

i++;

现在你就可以看到let i = foo实际上是if这样一个块级作用域内部的一个局部变量,而外部的循环计数器实际上是外部这个循环的这个块里面所产生的局部变量。所以说他们是互不影响的。

这样的话你就应该能够理解为什么说有两层嵌套的作用域了,循环体中的i是内存独立的作用域,外层是for循环本身的作用域。

除了会产生快就作用域限制以外,let和var还有一个很大的区别就是let的声明是不会出现提升的情况的。

传统的var去声明变量都会导致我们所声明的这个变量提升到我们代码最开始的这个位置。例如我们这里通过var去声明一个foo,然后我们在声明之前去打印这个foo。

console.log(foo);

var foo = 'yd';

那此时我们的控制台并不会报出一个错误, 而是打印的undefined,这也就说明在我们打印的时候foo此时就已经存在了,只是还没有赋值而已,这种现象叫做变量声明的提升。

其实在目前来看的话这样一个现象实际上是一个bug,但是我们开玩笑的说一句官方的bug他不叫bug,应该叫特性。

为了纠正这样一个问题,ES2015他的let就取消了这样一个所谓的特性。他从语法层面就要求我们必须要先声明变量再去使用变量。否则就会报出一个未定义的错误。

我们可以把这个的var修改为let,保存过后就可以在控制台当中看到所报出来的一个引用异常的一个错误。

以上就是let以及块级作用域最主要的一些特性。至于ES2015为什么不是在原有的var基础之上做一些升级而是定义了一些新的关键词。

原因也很简单如果说是直接升级var的话就会导致很多以前的项目无法正常工作,所以说ECMAScript决定使用了一个新的关键词叫做let。

const

ES2015中还新增了一个const关键字,他可以用来去声明一个只读的恒量或者叫常量,他的特点就是在let的基础上多了一个只读特性。

所谓只读指的就是变量一旦声明过后就不能够再被修改,例如我们这里可以通过const去声明一个name然后他的值是yd,如果我们在声明过后再去修改这个成员就会出现错误。

const name = 'yd';

name = 'zd';

那既然const是恒量,那也就是说const在声明的同时就必须要去设置一个初始值。声明和赋值不能像var一样放到两个语句当中。

// const name = 'yd';

var name2;

name2 = 'yd';

这里还有一个药注意的问题就是const他所声明的成员不能被修改,只是说我们不允许在声明了过后重新去指向一个新的内存地址。并不是说不允许修改恒量中的属性成员。

例如我们这里通过const去定义一个obj,让他等于一个空的{}, 然后我们再去设置这个对象的name属性,这种情况它实际上并没有修改我们obj所指向的内存地址。他只是修改了这块内存空间当中的数据。所以说是被允许的。

const obj = {}
obj.name = 'yd';

但是如果说我们是将obj等于一个新的空对象是不被允许的。因为赋值会改变obj的内存指向。

除此之外其他的一些特性都和let关键词相同,所以说我们就不用单独去演示了。

至此我们就了解了ES2015中的两个新关键词,分别是let和const,加上原本的var一共是三个关键词可以用来声明变量。

我们一般是不使用var,主要使用const,变化的变量使用let。按照这种方式去选择的话代码的质量实际上会有明显的提高。原因也很简单,var的一些特性都算是开发中的一些陋习。例如先去使用变量再去声明变量,这种都属于陋习,所以我们坚决不用。

默认使用const的原因是因为他可以让我更明确我们代码中所声明的这些成员会不会被修改。

数组的解构

ECMAScript2015新增了从数组或对象中获取指定元素的一种快捷方式,这是一种新的语法,这种新语法叫做解构。

例如我们这里有一个数组,数组中有3个不同的数值,以前我们需要获取这个数组中指定的元素我们需要通过索引去访问对应的值。然后将访问到的结果放到一个变量当中。

const arr = [100, 200, 300];

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

现在我们可以使用解构的这种方式去快速的提取数组当中的指定成员,具体的用法就是把以前我们定义变量名的地方修改为一个数组的[], 里面就是我们需要提取出来的数据所存放的变量名。内部就会按照我们这里变量名出现的位置分配数组当中所对应位置的数值。

const arr = [100, 200, 300];

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

如果只是想获取其中某个位置所对应的成员,例如只获取第三个成员, 这里可以把前两个成员都删掉。但是需要保留对应的逗号。确保解构位置的格式与我们数组是一致的。这样的话就能够提取到指定位置的成员。

const [, , baz] = arr;
console.log(baz);

除此之外我们还可以在解构位置的变量名之前添加三个.表示提取从当前位置开始往后的所有成员,最终所有的结果会放在一个数组当中。

const [foo, ...rest] = arr;
console.log(rest);

需要注意的是这种三个点的用法只能在解构位置的最后一个成员上使用,例如这里就可以解构到200和300两个成员的一个数组。

另外如果解构位置的成员个数小于被解构的数组长度,就会按照从前到后的顺序去提取,多出来的成员就不会被提取。

反之如果解构位置的成员大于数组长度,那么提取到的就是undefined。这和我们访问数组当中一个不存在的下标是一样的。

const [foo, bar, baz, more] = arr;
console.log(more); // undefined

如果需要给提取到的成员设置默认值,这种语法也是支持的,只需要在解构变量的后面跟上一个等号,然后后面写上一个默认值,这样的话如果我们没有提取到数组当中对应的成员,这样我们这个变量就会的到这里的默认值。

const [foo, bar, baz, more = 'default value'] = arr;
console.log(more);

以上就是数组解构的一些基本用法,这种新语法在很多场景下都会给我们带来很大的便捷,例如我们去拆分一个字符串,然后获取拆分后的指定位置,传统的做法是需要一个临时变量去做中间的过渡,通过解构就可以大大简化这样一个过程。使之变得更加简单。

对象的解构

在ECMAScript2015当中除了数组可以 被解构对象也同样可以被解构,不过对象的结构需要去根据属性名去匹配提取,而不是位置。

因为数组中的元素有下标,也就是说他是有顺序规则的,而对象里面的成员没有一个固定的次序,所以说不能够按照位置去提取。

例如我们定义一个obj对象。

const obj = { name: 'yd', age: 18 };

解构他里面的成员就是在以前变量位置去使用一个对象字面量的{}, 然后在{}里同样也是提取出来的数据所存放的变量名,不过这里的变量名还有一个很重要的作用就是去匹配被解构对象中的成员,从而去提取指定成员的值。例如这里所使用的name。

const obj = { name: 'yd', age: 18 };

const { name } = obj;

这就是提取了obj对象的属性值,放到了name变量当中。

解构对象的其他特点基本上和解构数组是完全一致的。未匹配到的成员返回undefined,也可以设置默认值。

在对象当中有一个特殊的情况,解构的变量名是被解构对象的属性名,所以说当前作用域中如果有这个名称就会产生冲突。这个时候我们可以使用重命名的方式去解决这个问题。

const obj = { name: 'yd', age: 18 };

const { name: name1 } = obj;

console.log(name1);

解构对象的应用场景比较多,不过大部分的场景都是为了简化我们的代码,比如代码中如果大量用到了console对象的方法,我们就可以先把这个对象单独解构出来,然后再去使用独立的log方法。

const { log } = console;
log('1');

模板字符串

在ECMAScript2015中还增强了定义字符串的方式,传统定义字符串的方式需要通过单引号或者是双引号来标识,字符串使用单引号或双引号标明。ES2015新增了模板字符串,使用反引号 ` 声明,。如果在字符串中需要使用反引号,可以使用斜线去转译。

相比于普通的字符串,这种模板字符串的方式多了一些非常有用的新特性。

首先第一点就是传统的字符串他并不支持换行如果说我们字符串内容里面有换行符,我们需要通过\n这种字符来表示。

而在最新的模板字符串当中可以支持多行字符串。也就是说我们可以直接在字符串中输入换行符。

const str = `
123
456
`

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

其次模板字符串当中还支持通过插值表达式的方式在字符串中去嵌入所对应的数值,例如我们这里先去定义一个name变量,然后我们在字符串中可以使用${name}就可以在我们的字符串当中去嵌入name变量中的值。

const name = 'yd';
const age = 18;

const str = `my name is ${name}, I am ${age} years old`;

那这种方式会比之前字符串拼接方式要方便的多页更直观一点,不容易写错,事实上${}里面的内容就是标准的JavaScript也就是说这里不仅仅可以嵌入变量,还可以嵌入任何标准的js语句。

那这个语句的返回值最终会被输出到我们字符串当中插值表达式所存在的位置。

带标签的模板字符串

模板字符串还有一个更高级的用法,就是在定义模板字符串的时候可以在前面添加一个标签,那这个标签呢实际上就是一个特殊的函数。添加这个标签就是调用这个函数。

我们首先定义一个name和gender变量,然后定义一个使用tag函数的模板字符串。

const name = 'yd';
const age = 18;

const result = tag`My name is ${name}, I am ${age} years old`;

那使用这个标签函数就要求必须先定义这个标签函数,我们定义一个tag的函数, 那这个函数他可以接收到一个数组参数,这个参数就是我们模板字符串内容分割过后的结果。这是因为在模板字符串当中可能会有嵌入的表达式,所以说这里的数组实际上就是按照表达式分割过后那些静态的内容。所以说他是一个数组。

const tag = (params) => {
    consoel.log(params); // ['My name is ', ' I am ', ' years old'];
}

除了这个数组以外,这个函数还可以接收到所有在我们这个模板字符串中出现的表达式的返回值,例如我们这里的模板字符串就使用了name和age这两个差值,所以说我们在这就可以接收到name和age所对应的值。

const tag = (params, name, age) => {
    consoel.log(params, name, age); // ['My name is ', ' I am ', ' years old']; 'yd' 18
}

const str = tag`hello ${'world'}`;

那这个函数内部的返回值呢就会是我们这个带标签的模板字符串所对应的返回值,例如我们在这个函数中直接返回'123', 那我们这里的result就是'123';

const tag = (params, name, age) => {
    return '123';
}
console.log(result); // '123';

所以说如果我们要返回正常的内容那这里就应该是拼接的字符串, 这样就会把我们模板字符串拼接的结果给他返回出来。

const tag = (params, name, age) => {
    return params[0] + name + params[1] + age + params[2];
}

那这种标签函数的作用呢实际上就是对我们模板字符串进行加工,例如我们这里的age他直接输出的结果就是18,我们可以在函数里对它进行加工,让他更适合用户的阅读。

可以利用标签的这样一个特性来实现文本的多语言化,比如说翻译成中文或者翻译成英文,或者检查模板字符串当中是否存在不安全的一些字符之类的一些需求。

设置你还可以使用这种特性来去实现一个小型的魔板引擎也都是可以的。

字符串扩展方法

ECMAScript2015当中为字符串对象提供了一系列扩展方法这里我们来看几个非常常用的。分别是includes,startsWith和endsWith,那他们是一组方法,可以用来去更方便的去判断我们的字符串当中是否包含指定的内容。

例如我们这里定义一个叫做massage的字符串,字符串的内容是一个错误消息。假设这是程序运行过程中得到的错误消息。

const message = 'ErrorL foo is not defined.';

如果我们想要知道这个字符串是否以Error开头,那么我们可以使用startsWith去判断,通过message.startsWith, 传入我们需要判断的内容。结果就是true。

console.log(message.startsWith('Error')); // true

同理如果我们想要知道这个字符串是否以.结尾,我们就可以使用endsWith。结果同样也是true。

console.log(message.endsWith('.')); // true

如果我们需要明确的是字符串中间是否包含某个内容,例如我们这里想要知道这个字符串当中是否包含foo,那我们就可以使用includes方法,结果同样也是true.

console.log(message.includes('foo')); // true

相比于之前我们使用indexOf或者是使用正则去判断,这样一组方法会让我们字符串查找便捷很多。

参数默认值

ECMAScript2015当中为函数的形参列表扩展了一些非常有用的新语法,我们这里分别来看一下。

首先是参数的默认值,以前我们想要为函数中的参数去定义默认值我们需要在函数体中通过逻辑代码来实现,例如下面的foo函数有一个enable参数。如果我们需要他的默认值是true,这里我们就需要逻辑判断来去决定是否使用默认值。

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

foo(true);

这里也有一个很多人都会犯错的地方,很多人喜欢使用短路运算的方式去设置默认值, 仔细一点你就会发现,这里这种情况其实不能使用短路运算的方式来去设置默认值,因为这会导致如果我们传入false时,也会导致使用默认值,这是很明显的错误。

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

foo(false);

正确的做法就是判断我们这个enable是否是undefined,然后去决定是否使用默认值,因为参数默认值的定义呢就是在我们没有去传递实际参数时所使用的的一个值。

没有传递实参,我们得到的就是一个undefined,所以这里应该判断是否为undefined

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

foo(false);

有了参数默认值这个新功能以后,这一切就会变得简单的多,我们可以直接在形参的后面直接通过等号去设置一个默认值就ok了。这里设置的默认值只会在我们调用时没有传递实参或者实参传递的是一个undefined时才会被使用。

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

foo(false);

需要注意的是如果说有多个参数的话,那带有默认值的这种形参一定要出现在参数列表的最后,因为我们的参数是按照次序传递的。如果带有参数默认值的这种参数不在最后的话,将无法正常工作,因为程序没办法确定是哪一个参数没有传递。

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

foo(false);

剩余参数

在ECMAScript中很多方法都可以传递任意个数的参数,例如console.log方法他可以接收任意个数的参数,并且最终会把这些参数打印在同一行当中。

对于未知个数的参数,以前我们都是使用arguments对象去获取,arguments对象实际上是一个伪数组,在ES2015当中新增了一个...操作符,那这种操作符有两个作用。

这里我们用到的是他的reset作用,也就是剩余操作符,我们可以在函数的形参前面加上..., 那此时这个形参args就会以数组的形式去接收从当前这个参数的位置开始往后所有的实参。

这种方式就可以取代以前通过arguments对象去接收无限参数这种一种操作,那此时如果我们再去调用我们这里的函数,我们传递的所有的参数都将被放在args数组当中。

// function foo() {
//     console.log(arguments); // 参数集合
// }

function foo (...args) => {
    console.log(args); // 参数集合
}

foo(1, 2, 3, 4);

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

展开数组

...操作符除了可以收起剩余数据这种reset用法,他还有一种spread的用法,意思就是展开。

那这个展开操作符的用法他的用途有很多,这里我们先来了解与函数相关的数组参数展开。

例如我们这里有一个数组,我们想要把数组当中的每一个成员按照次序传递给console.log方法,最原始的办法是通过下标一个一个去找到数组当中的每一个元素,分别传入到console.log方法当中。

如果说数组当中元素个数是不固定的。那一个个传递的方式就行不通了,我们就必须要换一种方式。

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

以前面对这种问题一般我们都是使用函数对象的apply方法去调用函数,因为这个方法可以以数组的方式去接收实参列表,我们这里就是console.log.apply,这个方法的第一个参数是this的指向,这里log是console调用的,所以第一个参数传入console, 第二个参数就是是参列表的数组。

console.log.apply(console, arr);

在ES2015当中就没有必要这么麻烦了,我们可以直接去调用console的log方法,然后通过...的操作符展开这里的数组。...操作符会把数组当中的每一个成员按照次序传递到列表当中。

console.log( ...arr );

这样就大大简化了我们需要的操作。

箭头函数

在ECMAScript当中简化了函数表达式的定义方式允许我们使用=>这种类似箭头的符号来去定义函数,那这种函数一来简化了函数的定义,二来多了一些特性我们具体来看。

传统我们来定义一个函数需要使用function关键词,现在我们可以使用ES2015来去定义一个完全相同的函数。

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

const inc = n => n + 1;

此时你会发现,相比于普通的函数,剪头函数确实大大简化了我们所定义函数这样一些相关的代码。

我们这里来看一下剪头函数的语法,剪头函数的左边是参数列表,如果有多个参数的话可以使用()包裹起来,在剪头的右边是函数体。只有一句表达式,执行结构会作为返回值返回。

如果在这个函数的函数体内需要执行多条语句,同样可以使用{}去包裹。不过一旦使用了{}返回值就需要手动通过return关键词去返回。

const inc = (n , m) => {
    return  n + 1;
};

使用剪头函数最主要的变化就是极大的简化了我们回调函数的编写。例如我们这里定义一个数组,如果说我们想要筛选出数组中所有的基数,就可以使用数组对象的filter方法,然后传入一个包含筛选逻辑的函数。

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

arr.filter(function(item) {
    return item % 2;
})
arr.filter(i => i % 2);

对比一下普通函数和剪头函数的写法你会发现,使用剪头函数会让我们的代码更简短,而且更易读。

对象字面量的增强

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

这里我们定义一个person对象,然后在这个对象当中去定义一个name属性,然后我们再去定义一个sayHi的方法,这个方法中我们就可以使用this去获取当前对象。

因为在普通函数中this始终会指向调用这个函数的对象。我们把this里面的name打印出来。

const person = {
    name: 'yd',
    sayHi: function() {
        console.log(this.name);
    }
}

person.sayHi(); // yd

我们这里把sayHi改为箭头函数的方式。这个时候打印出来的name就是undefined

const person = {
    name: 'yd',
    sayHi: () => {
        console.log(this.name);
    }
}

person.sayHi(); // undefined

这就是箭头函数和普通函数最重要的区别,在剪头函数当中没有this的机制。所以说不会改变this的指向。也就是说在剪头函数的外面this是什么,在里面拿到的就是什么,任何情况下都不会发生改变。

在这里我们再添加一个sayHiAsync的方法,这是一个普通的函数,在这个方法当中需要延迟一秒再去打印消息,可以使用setTimeout来实现。

此时如果setTimeout传递进去的是一个普通的函数表达式,在这个函数内部就没有办法拿到当前作用域的this,因为这个函数在setTimout里面会放在全局对象上执行,所以他里面是拿不到当前作用域里面的this对象,拿到的应该是全局对象。

const person = {
    name: 'yd',
    sayHi: () => {
        console.log(this.name);
    },
    sayHiAsync: function() {
        setTimeout(function() {
            console.log(this.name);
        }, 1000)
    }
}

person.sayHiAsync(); // undefined

很多时候为了解决这个问题我们会定义一个变量self来存储当前this, 借助闭包这样一个机制去在内部使用self。

const person = {
    name: 'yd',
    sayHi: () => {
        console.log(this.name);
    },
    sayHiAsync: function() {
        const self = this;
        setTimeout(function() {
            console.log(self.name);
        }, 1000)
    }
}

person.sayHiAsync(); // yd

如果我们这里使用的是箭头函数就不用这么麻烦了,因为在剪头函数当中this始终指向的都是当前作用域里面的this,以后但凡你的代码中需要使用变量存储this的情况,都可以使用剪头函数来去避免。

对象字面量的增强

对象是我们在ECMAScript当中最常用的数据结构,ECMAScript当中升级了我们对象字面量的语法。

传统的字面量要求我们必须在{}里面使用属性名:属性值这种语法。即便说我们属性的值是一个变量,那也必须是属性名:变量名

const bar = '123';
const obj = {
    key: 'value',
    bar: bar
}

而现在如果我们的变量名与我们添加到对象中的属性名是一样的,我们就可以省略掉:变量名。

const bar = '123';
const obj = {
    key: 'value',
    bar
}

这两种方式实际上是完全等价的。除此之外如果我们需要为对象添加一个普通的方法,传统的做法是通过方法名:函数表达式,现在我们可以省略里面的:function, 这两种方式同样也是等价的。

const bar = '123';
const obj = {
    key: 'value',
    bar,
    // method1: function () {
    //     console.log('method1');
    // },
    method1 () {
        console.log('method1');
    }
}

console.log(obj)

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

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

const obj = {};

obj[Math.random()] = 123;

在ES2015过后,对象字面量的属性名直接可以通过[]直接去使用动态的值了,这样一个特性叫做计算属性名,具体的用法就是在我们属性名的位置用[]包起来。在里面就可以使用任意的表达式了。这个表达式的执行结果将会作为这个对象的属性名。

const obj = {
    [Math.random()]: 123,
}

Object.assign

ECMAScript中为Object对象提供了一些扩展方法,这里我们来看几个最主要的方法,首先是assign方法,这个方法可以将多个源对象当中的属性复制到一个目标对象当中,如果对象当中有相同的属性,那么我们源对象当中的属性就会覆盖掉目标对象的属性。

这里所说的源对象和目标对象他们都是普通的对象,只不过用处不同,我们是从源对象当中取,然后往目标对象当中放。

例如我们这里先定义一个source1对象,在这个对象当中我们定义一个a属性和一个b属性。然后我们再来定义一个target对象,这个对象当中我们也定义一个a属性,还有一个c属性。

const source1 = {
    a: 123,
    b: 123,
}

const target = {
    a: 456,
    c: 456
}

有了这两个对象之后我们就可以使用Object.assign方法去合并他们了,Object.assign支持传入任意个数的参数,其中第一个参数就是我们的目标对象,也就是说我们所有源对象当中的属性都会复制到目标对象当中。这个方法的返回值也就是这个目标对象。

const result = Object.assign(target, source1);

console.log(target, result === target); {a: 123, c: 456, b: 123 }// true

可以发现目标对象中的a被源对象覆盖掉了,c还是目标对象原本的,b是从源对象当中复制过来的。这就是assign的作用,总结一下就是用后面的对象覆盖目标对象的属性。

除此之外我们也能看到assign方法的返回值其实就是第一个对象,他俩是完全相等的。

如果我们要传入多个源对象,例如我们这里再来定义一个source2对象

const source2 = {
    b: 789,
    d: 789,
}

然后我们将source2也传入到Object.assign方法当中, 效果也是一样的,他只是依次的把我们每一个源对象当中的属性去覆盖到第一个对象当中。

const result = Object.assign(target, source1, source1);

这个方法其实特别常用。很多时候我们都可以使用他去复制一个对象。例如我们这里来定义一个函数。在这个函数中去接收一个对象参数。在这种情况下如果我们在这个函数内部直接修改了这个对象参数的属性,那外界这个对象也同时会发生变化。因为我们知道他们是指向同一个内存地址,也就是同一个数据。

function func (obj) {
    obj.name = 'zd';
}

const obj = { name: 'yd' };

func(obj);

console.log(obj);

如果我们只是希望在这个函数内部去修改这个对象,我们就可以使用Object.assign方法,去把这个对象复制到全新的一个空对象当中,那这样的话我们内部的这个对象就是一个全新的对象。他的修改也就不会影响到外部的数据了。

function func (obj) {
    const funcObj = Object.assign({}, obj);
    funcObj.name = 'zd';
}

const obj = { name: 'yd' };

func(obj);

console.log(obj);

除此以外,Object.assign用来为options对象参数设置默认值也是一个非常常见的应用场景。

const default = {
    name: 'yd',
    age: 18
}

const options = Object.assign(default, opt);

Object.is

ECMAScript中还为Object对象新增了一个is方法用来判断两个值是否相等。在此之前我们再ECMAScript当中去判断两个值是否相等我们可以使用两个等号的相等运算符。或者是三个等号的严格相等运算符。

那这两者是区别是两等运算符会在比较之前自动转换数据类型,那也就会导致0 == false这种情况是成立的。而三等他就是严格去对比两者之间的数值是否相同。因为0和false他们之间的类型不同所以说他们是不会严格相等的。

但是严格相等运算符他也有两个特殊情况,首先就是对于数字0,他的正负是没有办法区分的,也就是说在我们用三等运算符去比较+0和-0时,返回的结果是true。

当然这对我们应用开发来讲这种问题其实并不需要关心,只有在处理一些特殊的数学问题的时候才会有这种情况出现。

其次是对于NaN, 两个NaN在三等比较时是不相等的。以前认为NaN是一个非数字,也就是说他有无限种可能,所以两个NaN他是不相等的,但在今天看来,NaN他实际上就是一个特别的值,所以说两个NaN他应该是完全相等的。

所以说在ES2015中就提出了一种新的同值比较的算法,那可以使用Object对象全新的is方法来解决这个问题,通过Obejct.is正负零就可以被区分开,而且NaN也是等于NaN的。

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

不过一般情况下我们根本不会用到这个方法,大多时候我们还是使用严格相等运算符,也就是三个等号。

Proxy

如果我们想要监视某个对象中的属性读写,我们可以使用ES5中提供的Object.defineProperty这样的方法来去为我们的对象添加属性,这样的话我们就可以捕获到我们对象中属性的读写过程。

这种方法实际上运用的非常广泛,在Vue3.0以前的版本就是使用这样的一个方法来去实现的数据响应,从而完成双向数据绑定。

在ES2015当中全新设计了一个叫做Proxy类型,他就是专门为对象设置访问代理器的,那如果你不理解什么是代理可以想象成门卫,也就是说不管你进去那东西还是往里放东西都必须要经过这样一个代理。

通过Proxy就可以轻松监视到对象的读写过程,相比于defineProperty,Proxy他的功能要更为强大甚至使用起来也更为方便,那下面我们具体来看如何去使用Proxy。

这里我们定义一个person对象,我们通过new Proxy的方式来去为我们的person来创建一个代理对象。

Proxy构造函数的第一个参数就是我们需要代理的对象,这里是person,第二个参数也是一个对象,我们可以把这个对象称之为代理的处理对象,这个对象中可以通过get方法来去监视属性的访问,通过set方法来去介绍对象当中设置属性这样的一个过程。

const person = {
    name: 'yd',
    age: 18
}

const personProxy = new Proxy(person, {
    get() {},
    set() {}
})

我们先来看get方法,这个方法最简单可以接收两个参数,第一个就是所代理的目标对象,第二个就是外部所访问的这个属性的属性名。这个方法的返回值将会作为外部去访问这个属性得到的结果。

{
    get(target, property) {
        console.log(target, property);
        return property in target ? target[property] : undefined;
    }
}

我们再来看下set方法,这个方法默认接收三个参数, 分别是代理目标对象,以及我们要写入的属性名称还有最后我们要写入的属性值。

我们可以做一些校验,比如说如果设置的是age,他的值就必须是整数,否则就抛错。

{
    set(target, property, value) {
        console.log(target, property, value);
        if (property === 'age') {
            if (!Number.isInteger(value)) {
                throw new TypeError(``${value} must be a integer);
            }
        }
        target[property] = value;
    }
}

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

Proxy 对比 defineProperty

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

首先最明显的优势就是在于Proxy要更为强大一些,那这个强大具体体现在Object.defineProperty只能监听到对象属性的读取或者是写入,而Proxy除读写外还可以监听对象中属性的删除,对对象当中方法的调用等等。

这里我们为person对象定义一个Proxy对象,在Proxy对象的处理对象中的外的添加一个deleteProperty的代理方法,这个方法会在外部对当前这个代理对象进行delete操作时会自动执行。

这个方法同样接收两个参数,分别是代理目标对象和所要删除的这个属性的名称。

const person = {
    name: 'yd',
    age: 18
}

const personProxy = new Proxy(person, {
    deleteProperty(target, property) {
        console.log(target, property);
        delete target[property];
    },
})

这是Object.defineProperty无法做到的, 除了delete以外, 还有很多其他的对象操作都能够被监视到,列举如下。

get: 读取某个属性

set: 写入某个属性

has: in 操作符调用

deleteProperty: delete操作符调用

getProperty: Object.getPropertypeOf()

setProperty: Object.setProtoTypeOf()

isExtensible: Object.isExtensible()

preventExtensions: Object.preventExtensions()

getOwnPropertyDescriptor: Object.getOwnPropertyDescriptor()

defineProperty: Object.defineProperty()

ownKeys: Object.keys(), Object.getOwnPropertyNames(), Object.getOwnPropertSymbols()

apply: 调用一个函数

construct: 用new调用一个函数。

接下来我们来看第二点优势就是对于数组对象进行监视,

通常我们想要监视数组的变化,基本要依靠重写数组方法,这也是Vue的实现方式,proxy可以直接监视数组的变化。以往我们想要通过Object.defineProperty去监视数组的操作最常见的方式是重写数组的操作方法,这也是Vue.js中所使用的方式,大体的方式就是通过自定义的方法去覆盖掉数组原型对象上的push,shift之类的方法,以此来劫持对应的方法调用的过程。

我们这里来看如何直接使用Proxy对象来对数组进行监视。这里我们定义一个list数组,然后对这个list数组进行Proxy监视。

在这个Proxy对象的处理对象上我们去添加一个set方法,用于监视数据的写入,在这个方法的内部我们打印参数的值,然后再target对象上设置传入的值,最后返回一个true表示写入成功。

这样我们再外部对数组的写入都会被监视到,例如我们这里通过push向数组中添加值。

const list = [];
const listproxy = new Proxy(list, {
    set(target, property, value) {
        console.log(target, property, value);
        target[property] = value;
        return true; // 写入成功
    }
});

listproxy.push(100);

Proxy内部会自动根据push操作推断出来他所处的下标,每次添加或者设置都会定位到对应的下标property。

数组其他的也谢操作方式都是类似的,我们这里就不再演示了。这就是Proxy对数组的一个监视。他的功能还是非常强大的,这一点如果我们放在Object.defineProperty上要想去实现的话就会特别的麻烦。

最后相比于Object.defineProperty还有一点优势就是,Proxy是以非入侵的方式监管了对象的读写,那也就是说一个已经定义好的对象我们不需要对对象本身去做任何的操作,就可以监视到他内部成员的读写,而defineProperty的方式就要求我们必须按特定的方式单独去定义对象当中那些被监视的属性。

对于一个已经存在的对象我们要想去监视他的属性我们需要做很多额外的操作。这个优势实际上需要有大量的使用然后在这个过程当中去慢慢的体会。

Reflect

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

这一点应该并不陌生,因为在javascript中的Math对象也是相同的,Reflect内部封装了一系列对对象的底层操作,具体一共提供了14个静态方法,其中有1个已经被废弃掉了,那还剩下13个,仔细去查看Reflect的文档你会发现这13个方法的方法名与Proxy的处理对象里面的方法成员是完全一致的。

其实这些方法就是Proxy处理对象那些方法内部的默认实现,你可能觉得这句话不是很好理解,我们这里来用代码说明一下。

这里我们定义一个proxy对象。只是proxy处理对象中什么也没有写,通过前面的介绍我们可以知道,我们可以在这个proxy处理对象中去添加不同的方法成员来去监听对象所对应的操作。

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

const proxy = new Proxy(obj, {

})

如果说我们没有添加具体的处理方法例如get或者set,那他内部这些get或者set是怎样执行的呢?其实proxy处理对象内部默认实现的逻辑就是调用了Reflect对象当中所对应的方法。

那也就是说,我们没有定义get方法就等同于是定义了一个get方法,在内部将参数原封不动的交给Reflect的get方法,结果是一样的。

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

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

const proxy = new Proxy(obj, {
    get(target, property) {
        console.log('实现监视逻辑');
        return Reflect.get(target, property);
    }
})

Reflect对象的用法其实很简单,mdn上实际上已经有了非常清晰的介绍,但是大多数人接触到这个对象的第一个感觉就是为什么要有Reflect这样一个对象。也就是说他的价值具体体现在什么地方。

个人认为Reflect对象最大的意义就是他提供了一套统一操作Object的API,因为在这之前我们去操作对象时有可能使用Object对象上的方法,也有可能使用像delete或者是in这样的操作符,这些对于新手来说实在是太乱了,并没有什么规律。

Reflect对象就很好的解决了这样一个问题,他统一了对象的操作方式,我们可以通过几个简单的例子来看一下。

这里我们先定义一个obj对象,然后在对象当中定义name和age。按照传统的方式如果我们需要判断这个对象中是否存在某个属性,我们需要使用in这个语句,用到in操作符,删除name属性我们需要使用到delete语句。而如果说我们需要获取对象中所有的属性名,那有需要去使用Object.keys这样的方法。

const obj = {
    name: 'yd',
    age: 18,
}

console.log('name' in obj);
console.log(delete obj['age']);
console.log(Object.keys(obj));

那也就是说我们同样都是去操作这个对象,但是我们一会需要用操作符的方式,一会又需要用到某一个对象当中的方法。

换做现在Reflect对象就提供了一个统一的方式,那我们去判断这个对象当中是否存在某一个属性我们可以使用Reflect.has方法。

console.log(Reflect.has(obj, 'name'));

删除一个属性我们可以使用deleteProperty方法。

console.log(Reflect.deleteProperty(obj, 'age'));

对于想要获取对象中所有的属性名我们可以使用ownKeys方法,那这样的一种体验也会更为合理一点,当然这只是个人的一个感悟,这个还是需要个人多多体会。

console.log(Reflect.ownKeys(obj));

需要注意的一点是,目前以前的那些对象的操作方式还是可以使用的,但是ECMAScript他希望经过一段时间的过渡过后以后的标准中就会把之前的那些方法把他给废弃掉。

所以我觉得我们现在就应该去了解这13个方法以及他们各自取代的用法。这些内容在mdn上都有完整的描述,我们这里就不重复了。

Promise

Promise同样也是ECMAScript2015当中提供的一个内置对象,那他提供了一种全新的异步编程解决方案,通过链式调用的方式解决了我们在传统异步编程过程中回调函数嵌套过深的问题。

不过关于Promise的细节有很多内容,所以说我们这里先不做详细介绍在JavaScript异步编程的文章中已经专门针对Promise进行了详细的分析。这里简短介绍的目的是为了让你对ECMAScript2015新增的所有的特性有一个系统化的认识。

class类

在此之前ECMAScript中都是通过定义函数以及函数的原型对象来去实现的类型,例如我们这里想要定义一个Person的类型,我们需要先定义一个叫做Person的函数,然后作为这个类型的构造函数。

在构造函数中我们可以通过this去访问当前的实例对象,如果我们需要在这个类型所有的实例间去共享一些成员,可以借助于函数对象的prototype, 也就是原型去实现。

function Person (name) {
    this.name = name;
}

Person.prototype.say = function() {
    console.log(this.name);
}

自从ECMAScript2015开始我们就可以使用一个叫做class的关键词,来去声明一个类型,那这种独立定义类型的语法,相比较之前函数的方式,要更容易理解。结构也会更加清晰一些。

我们这里通过class来重构一下刚刚的Person,通过class关键字加上类型名称, 然后跟上一对{}, 这就定义了一个Person类型。

class Person {

}

这种语法与一些老牌面向对象语言当中class是非常相似的。如果说我们需要在构造函数当中做一些额外的逻辑,我们可以添加一个叫做constructor的方法,那这个方法就是当前这个类型的构造函数。

我们同样可以在这个函数中使用this去访问当前类型的实例对象。如果我们想要为这个类型定义一些实例方法我们只需要在这个类型里面去添加对应的方法成员就可以了。例如我们这里再来添加一个叫做say的方法, 在这个方法中也可以通过this拿到当前的实例对象。

class Person {
    constructor (name) {
        this.name = name;
    }
    say() {
        console.log(this.name);
    }
}

创建实例的方式和function的方式一样,都是通过new关键字的方式来创建。通过创建的实例也可以调用类中的say方法。

const p = new Person('yd');
p.say();

静态方法

在我们类型当中的方法一般分为实例方法和静态方法,那实例方法就是需要通过这个类型构造的实例对象去调用,而静态方法是直接通过类型本身去调用,以前我们去实现静态方法我们是直接在构造函数函数对象上去挂载方法来去实现因为在JS当中函数也是对象。他也可以去添加一些方法成员。

而在ECMAScript2015中他就多了一个专门用来添加静态方法的关键词,叫做static,下面我们来看具体的用法。这里我们来给当前的Person类型添加一个create的静态方法,用来去创建Person类型的实例。

这个方法的内部逻辑就是直接return一个Person实例。

class Person {
    constructor (name) {
        this.name = name;
    }
    say() {
        console.log(this.name);
    }
    static create (name) {
        return new Person(name);
    }
}

调用静态方法就是直接通过类型然后通过成员操作符调用方法名字。

const yd = Person.create('yd');

不过这里需要注意,因为我们静态方法是挂载到类型上面的,所以说在静态方法内部他不会指向某一个实例对象,而是当前的类型,如果说你之前对this这样一个特性不太清晰这一点是尤其需要注意的。

类的继承

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

这里我们再定义一个Student类型,我们让他继承自Person,这样的话Student类型当中就会拥有Person类型里面所有的成员了。

在Student类型的构造函数当中,我们去接收两个参数,分别是name和number,number就是学号。name参数在我们父类当中也需要用到。所以这里我们需要用到super对象。

这个对象始终指向父类, 调用它就是调用了父类的构造函数,然后我们可以在这里定义一些这个类型所特有的成员。

例如我们这里添加一个叫做hello的方法,在这个方法中我们同样可以使用super对象对象去访问父类当中的成员。例如我们这里调用父类中的say方法。

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

    hello () {
        super.say();
        console.log(this.number);
    }
}

完成以后我们就可以通过Student类型去创建一个对象。当我们调用这个方法的时候会先执行父类的say然后再打印自己的number。

const s = new Student('yd', '100');

s.hello();

这就是我们在class当中去使用extends去实现的继承,他相比于原型继承要更方便一点,也更清楚一点。

Set

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

那他是一个类型,我们通过这个类型构造的实例就用来存放不同的数据。我们可以通过这个实例的add方法向集合当中去添加数据,由于这个add方法他会返回集合对象本身,所以我们可以链式调用。那如果我们在这个过程中添加了之前已经存在的值那所添加的这个值就会被忽略掉。

const s = new Set();

s.add(1).add(2).add(3).add(2);

想要遍历集合当中的数据,我们可以使用集合对象的forEach方法去传递一个回调函数。

s.forEach(i => console.log(i));

或者是我们也可以使用ECMAScript2015中所提供的for…of循环,这种循环是一个新的语法。他也可以去遍历我们普通的数组,他所遍历的i就是数组当中的每一个成员。

for (let i of s) {
    console.log(i);
}

除此之外在Set对象当中还可以通过size属性来去获取整个集合的长度,这与数组当中的length是相同的道理。

console.log(s.size);

除此以外他还提供了其他几个常用的方法我们分别来看一下。首先是has方法,这个方法就用来判断集合当中是否存在某一个特定的值。

console.log(s.has(100)); // false

delete方法用来删除集合当中指定的值,删除成功将会返回一个true。

console.log(s.delete(3)); // true

最后还有一个clear方法,用于清除当前集合当中的全部内容。

s.clear()

集合的常用应用场景就是用来为数组中的元素去去重,例如我们这里定义一个有重复元素的数组。

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

如果我们想去掉重复元素那现在最简单的方式就是通过new Set的方式去加工一下这个数组,Set的构造函数会接收一个数组,这个数组会作为Set里面的初始值,重复的值会被忽略掉。如果我们想得到一个数组的话可以使用ES2015中新增的一个Array.from方法来去把Set再次转换回数组。

当然你也可以使用...这样的展开操作符在一个空的数组当中去展开Set, 那这样的话Set当中的成员就会作为我们这个空数组当中的成员了,这样也可以得到一个数组。

// const result = Array.from(new Set(arr));

const result = [ ...new Set(arr)]

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

Map

ECMAScript2015还多了一个叫做Map的数据结构,那这种结构与ECMAScript中的对象非常类似,本质上他们都是键值对集合但是这种对象结构中的键,他只能够是字符串类型,如果说用其他类型作为键会被转换成字符串,出现[object object]这种奇怪的键名。不同的对象转换成字符串可能会变成相同的键名[object object],导致数据覆盖丢失。

为了解决这个问题ES2015中提供么Map类型,他才算是严格上的键值对类型,用来映射两个任意类型之间键值对的关系,用法上也非常的简单。

首先我们需要通过Map构造函数创建一个实例,然后可以使用这个对象的set方法去存数据。这里的键就可以是任意类型的数据。最终也不需要担心他会被转换为字符串。

const m = new Map();

const key = {};

m.set(key, 18);

console.log(m);

如果我们想要获取其中的数据,我们可以使用get方法,同时他也可以使用has方法判断他里面是否存在每个键。然后delete方法去删除某个键。clear方法清空所有的键值。

console.log(m.get(key));

console.log(m.has(key));

m.delete(key);

m.clear();

如果我们需要遍历整个Map当中所有的键值我们可以使用实例对象的forEach方法。在这个方法的回调函数当中第一个参数就是被遍历的值,第二个参数是被遍历的键。

m.forEach((value, key) => {
    console.log(value, key);
})

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

Symbol

在ECMAScript2015之前,对象的属性名都是字符串,而字符串是有可能会重复的。如果重复的话就会产生冲突,比如我们在使用第三方模块时,如果需要扩展第三方模块,而这时就有可能把第三方模块的方法覆盖掉,导致代码执行异常。

以前解决这种问题最好的方式就是约定,但是约定的方式只是规避了问题并不是彻底解决了这个问题。如果在这个过程中有人不遵守约定那这个问题仍然会存在。

ES2015为了解决这个问题提供了一种全新的原始数据类型Symbol,翻译过来的意思叫做符号,翻译过来就是表示一个独一无二的值。

通过Symbol函数就可以创建一个Symbol类型的数据,而且这种类型的数据typeof的结果就是symbol,那这也就表示他确实是一个全新的类型。

const s = Symbol();
typeof s; // symbol类型

这种类型最大的特点就是独一无二,也就是说我们通过Symbol函数创建的每一个值都是唯一的。他永远不会重复。

Symbol() === Symbol(); // false

考虑到在开发过程中的调试Symbol创建时允许接收一个字符串,作为这个值的描述文本, 对于我们多次使用Symbol时就可以区分出是哪一个Symbol,但这个参数也仅是描述作用,相同的描述字段生成的值仍是不同的。

从ES2015开始,对象就已经允许使用Symbol的组织

const s1 = Symbol('foo');
const s2 = Symbol('foo');

s1 === s2; // false

从ES2015开始,对象就已经允许使用Symbol作为属性名。那也就是说现在对象的属性名可以是两种类型,字符串和Symbol。

const person = {
    [Symbol()]: 123,
    [Symbol()]: 456
}

Symbol除了用在对象中避免重复以外,我们还可以借助这种类型的特点来模拟实现对象的私有成员。假设我们有一个js对外暴露一个对象,以前我们私有成员都是通过约定,例如我们约定使用下划线开头就表示是私有成员。约定外界不允许访问下划线开头的成员。

现在有了Symbol就可以使用Symbol去作为私有成员的属性名了。在这个对象的内部可以使用创建属性时的Symbol。去拿到对应的属性成员。

const name = Symbol();
const person = {
    [name]: 'yd',
    say() {
        return this[name];
    }
}

这样在外部文件中就拿不到this[name]这个私有成员,只能调用对象提供的say方法。这样就实现了所谓的私有成员。

以上就是Symbol的一些用法和一些典型的场景,这种类型的值目前最主要的作用就是为对象添加一个独一无二的属性标识符。

截止到2019标准,ECMAScript一共定义了6种基本数据类型,加上Object一共7种数据类型。
未来还会有BigInt, 用于去存放更长的数字只不过目前这个类型还处在stage-4阶段,预计在ES2020标准中会正式被标准化,到时候就一共是8中数据类型了。

Symbol在使用上还有一些值得我们注意的地方,首先是他的唯一性。不论传入的描述是不是相同的,每次调用Symbol得到的结果都是一个全新的值。

Symbol('foo') === Symbol('foo'); // false

如果我们需要在全局去复用一个相同的Symbol值,我们可以使用全局变量的方式去实现,或者是使用Symbol类型提供的一个静态方法去实现。具体就是Symbol的静态方法for,这个方法接收一个字符串作为参数,相同的参数一定对应相同的值。

const s1 = Symbol.for('foo');
const s2 = Symbol.for('foo');

s1 === s2; // true

这个方法维护了一个全局的注册表,为字符串和Symbol提供了一个对应关系。需要注意的是,在内部维护的是字符串和Symbol的关系,那也就是说如参数不是字符串,会转换为字符串。

const s1 = Symbol.for('true');
const s2 = Symbol.for(true);

s1 === s2; // true

在Symbol内部提供了很多内置的Symbol常量,用来去作为内部方法的标识,这些标识符可以让自定义对象去实现一些js内置的接口,例如我们这里定义一个Object对象,然后去调用这个对象的toString方法, 默认结果就是[object object];我们把这样的字符串叫做对象的toString标签。

const obj = {};
obj.toString(); // [object object];

如果我们想要自定义对象的toString标签,我们就可以在这个对象当中去添加一个特定的成员来去标识,考虑到如果使用字符串取添加这种标识符就有可能和内部的成员产生重复所以ECMAScript要求我们使用Symbol的值来去实现这样一个接口。

obj[Symbol.toStringTag] = 'test'
obj.toString(); // [object test];

这里的toStringTag就是内置的一个Symbol常量,这种Symbol我们在后面为对象去实现迭代器时会经常遇到。

最后呢,我们使用Symbol的值去作为对象的属性名那这个属性我们通过传统的for in循环是无法拿到的。而且我们通过Object.keys方法也是获取不到这样Symbol类型的属性名。

JSON.stringify去序列化,Symbol属性也会被隐藏掉。

const obj = {
    [Symbol()]: 'symbol value',
    foo: 'normal value'
}

for (var key in obj) {
    console.log(key);
}

Object.keys(obj);

总之这些特性都使得我们的Symbol属性,特别适合作为对象的私有属性,当然想要获取这种类型的属性名也不是完全没有办法,我们可以使用Object.getOwnPropertySymbols(obj)方法。

Object.getOwnPropertySymbols(obj)

这个方法的作用类似于Object.keys, 所不同的是Object.keys他只能获取对象当中字符串属性名,而Object.getOwnPropertySymbols方法他获取到的全是Symbol类型的属性名。

for…of

在ECMAScript中遍历数据有很多种方法,首先就是最基本的for循环,他比较适用于去遍历普通的数组,然后是for…in循环,他比较适合去遍历键值对。再有就是一些函数式的遍历方法例如数组对象的forEach方法。

那这些各种各样遍历数据的方式都会有一定的局限性,所以ES2015借鉴了很多其他的语言,引入了一种全新的遍历方式,叫做for…of循环,

for…of 是ECMAScript2015之后新增的遍历方式。未来会作为遍历所有数据结构的统一方式。那换句话说只要你明白for…of内部实现的原理,那你就可以用for…of去遍历任何自定义的结构,我们先来了解一下for…of循环的基本用法。

const arr = [1, 2, 3, 4];
for (const item of arr) {
    console.log(item); // 1, 2,3,4
    // break; // 终止循环
}

不用于传统的for…in循环,for…of循环拿到的就是数组中的每一个元素,而不是对应的下标。这种循环方式就可以取代我们之前常用的数组实例当中的forEach方法。

而且相比于forEach方法for…of循环他可以使用break关键词随时去终止循环。而forEach方法是无法去终止遍历的。

以前我们为了随时去终止遍历我们必须去使用数组实例的some或者every方法,在some方法的回调函数中我们去返回true在every方法的回调函数中去返回false都可以用来去终止遍历。

而在forEach方法中无论返回true还是false都不会去终止遍历。那现在在for…of中我们就可以使用break随时去终止循环。

除了数组可以直接被for…of循环去遍历,一些伪数组对象也是可以直接被for…of去遍历的,例如arguments,set,map。

for…of在遍历Map时, 可以直接拿到键和值。键和值是直接以数组的形式返回的,也就是说数组的第一个元素就是当前的键名,第二个元素就是值。我们这里就可以配合数组的解构语法,直接拿到键和值。

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

for (const item if m) {
    console.log(item); // ['foo', '123'];
}

for (const [key, value] if m) {
    console.log(key, value); // 'foo', '123'
}

for…of是不能直接遍历普通对象的,他要求被遍历的对象必须存在一个叫做Iterable的接口。

可迭代接口

ECMAScript中能够表示有结构的数据类型越来越多,从最早的数组和对象到现在新增了Set和Map,而且我们开发者还可以组合使用这些类型去定义一些符合自己业务需求的数据结构,为了提供一种统一的遍历方式,ES2015提出了一个叫做Iterable的接口,意为可迭代的。如果你不理解编程语言中接口的概念可以理解为一种规格标准,例如ECMAScript中任意一种类型都有toString方法这就是因为他们都实现了统一的规格标准。在编程语言中更专业的说法是他们都实现了统一的接口。

那可迭代接口就是一种可以被for…of循环统一遍历访问的规格标准,换句话说只要这个数据结构实现了可迭代接口他就能够被for…of循环遍历,那这也就是说我们之前尝试的那些能够直接被for…of循环去遍历的数据类型他都已经在内部实现了这个接口。

这里我们脱离掉for…of循环的表象,来看一看这个叫做iterable的接口,到底约定了哪些内容。

在控制台打印arr可以发现在arr的原型对象上存在一个叫做Symbol.iterable的属性,他的值是一个函数。

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

iterable约定对象中必须要挂载一个叫做Symbol.iterable的方法,这个方法返回一个对象,对象上存在一个next方法,next方法也返回一个对象,对象中存在value和done两个属性,value的值就是数组中的第一个元素,done的值是false。

const iterable = arr[Symbol.iterable]();
iterable.next(); // 返回一个对象 { value: 1, done: false }

实际上value是当前遍历到的值,done为是否为最后一个。每调用一次next就会后移一位。

总结起来就是所有被for…of 遍历的数据类型必须包含一个叫做iterable的接口,也就是内部必须挂载一个Symbol.iterable方法,这个方法需要返回一个带有next方法的对象,不断调用这个next方法就可以实现对内部所有成员的遍历。这就是for…of循环的内部原理。

实现可迭代接口

了解了for…of循环的内部原理过后我们就应该理解为什么说for…of循环可以作为遍历所有数据结构的统一方式了,因为他内部就是去调用被遍历对象的iterable方法得到一个迭代器,从而去遍历内部所有的数据,这也就是iterable接口所约定的内容。

换句话说只要我们的对象也实现了iterable接口,那我们就可以实现使用for…of循环去遍历我们自己的对象。我们这里定义一个obj对象,然后定义一个叫做Symbol.iterable的属性,然后他的值是一个函数,这个函数需要返回一个对象。

在这个对象中需要提供一个next方法用于实现向后迭代的逻辑。

在next方法中需要返回一个迭代结果对象,这个对象需要有两个成员分别是value和done,这里我们先用固定值,让语法可以通过,然后进行测试。

const obj = {
    [Symbol.iterable]: function() { // 约定内部存在一个可迭代的iterable方法
        return { // 返回一个对象,对象包含一个next方法。
            next: function() {
                return { // 返回value和done, value 为当前值,done为是否遍历结束
                    value: 'yd',
                    done: true
                }
            }
        }
    }
}

for (const item of obj) {
    console.log(item);
}

这里并不会打印任何东西,因为第一次遍历的时候done就返回了true,表示迭代结束了,所以并不会进入循环体,也就什么都打印不出来。

我们再来修改一下这个对象,我们在这个对象中方一个数组store用来存放值得被遍历的数据,然后我们在next方法中去迭代这个数组,我们需要去维护一个下标index,我们让他默认等于0;

由于next中的函数并不是obj对象,所以我们使用self去存储一下当前的this供下面使用,在next方法中value就是self.store[index],done就是index >= self.store.length。完成以后我们需要让index++, 也就是让指针后移一位。

const obj = {
    store: [1, 2, 3, 4, 5],
    [Symbol.iterable]: function() {
        let index = 0;
        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); // 1, 2, 3, 4, 5
}

此时obj就可以正常被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: ['编码']
}

那如果说这个定义的数据结构如果能对外提供一个统一的遍历接口,对于调用者而言就不用去关心对象内部的结构是怎么样的了。更不用关心数据结构改变过后所产生的影响。

例如这里可以在对象内部定义一个each方法,这个方法接收外部的一个回调函数参数,然后在这个函数内部去遍历所有的数据,并且将每个数据都交给这个回调函数。

const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语'],
    work: ['编码'],
    each: function (cb) {
        [...this.life, ...this.learn, ...this.work].forEach(cb);
    }
}

这样一来就相当于对外提供了一个统一遍历的接口,对于其他人就会省心很多,因为根本不用关心内部是什么情况,只管调用这里的each这样一个统一的遍历接口就可以了。

实现可迭代接口也是相同的道理,我们可以使用迭代器来实现这个接口。

const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语'],
    work: ['编码'],
    [Symbol.iterator]: function() {
        const all = [...this.life, ...this.learn, ...this.work];
        let index = 0;
        return {
            next: function() {
                return {
                    value: all[index],
                    done: index++ >= all.length
                }
            }
        }
    }
}

这样我们在外部就可以使用for…of循环统一去遍历这个todos对象了。这就是实现迭代器的意义,那迭代器这样一个模式他的核心就是对外提供统一遍历接口,让外部不用关心这个数据内部结构是怎样的。只不过我们这里使用的each方法他只适用于当前这个数据结构而ES2015中的迭代器他是语言层面去实现的迭代器模式。所以说他可以去适用于任何数据结构。只需要你通过代码去实现iterable实现他的逻辑就可以了。

这种模式在很多地方都会用到,只不过很多时候我们的观察都是停留在表象上认为我们知道某一个api的使用就可以了,根本就不会去关心他内部做的这样一些事情或者忽略掉很多的为什么。

生成器

在ECMAScript2015中还新增了一种生成器函数,英文叫做generator,引入这样一个新特性的目的就是为了能够在复杂的异步编程中减少回调函数嵌套产生的问题,从而去提供更好的异步编程解决方案。

这里我们先来了解一下生成器函数的语法,以及他的基本应用。定义生成器函数就是在普通的函数function后面添加一个*,这样我们的函数就变成了一个生成器函数。函数执行之后会返回一个生成器对象。

function * foo() {
    return 100;
}
const result = foo();
console.log(result);

并且在这个对象上也和迭代器一样有一个next方法,实际上生成器函数也实现了iterable接口,也就是迭代器接口协议。

一般生成器函数在使用中都会配合一个叫做yield的关键字,yield关键词与return关键词类似,但是又有很大的不同。

生成器函数会自动返回一个生成器对象,调用这个生成器的next才会让这个函数的函数体开始执行,执行过程中一旦遇到yield关键词,函数的执行就会被暂停下来,而且yield的值将会被作为next的接过返回,如果继续调用next函数就会从暂停的位置继续向下执行到下一个yield,直到这个函数完全结束。

const * foo() {
    console.log(1111);
    yield 100;
    console.log(2222);
    yield 200;
    console.log(3333);
    yield 300;
}

生成器函数最大的特点就是惰性执行,每调用一次next就会执行一次yield。

生成器应用

了解了生成器函数的基本用法过后这里我们先来看一个简单的应用场景,就是去实现一个发号器。

我们在实际业务开发过程中经常需要用到自增的id,而且我们每次调用这个id都需要在原有的基础上去+1,这里如果我们使用生成器函数去实现这样一个功能是最合适的了。

首先我们定义一个createId生成器函数,然后定义一个初始的id等于1,然后我们通过一个死循环不断的去yield id++。这里不需要担心死循环的问题,因为我们每次在yield过后这个方法会被暂停,循环自然也就会被暂停。直到下一次调用next再次去执行一次又会被暂停下来。

这样我们在外部就可以通过这个方法去创建一个生成器对象id,每次调用一下这个生成器的next方法就能够获取到自增的value,也就是id。

function * createId() {
    let id = 1;
    while(true) {
        yield id++;
    }
}
const id = createId();

id.next().value;

当然实现发号器是一个非常简单的需求,我们还可以使用生成器函数实现对象的iterator方法,因为生成器也实现了对象的iterator接口,而且我们使用生成器函数去实现iterator方法会比之前的方式简单很多。

我们这里修改一下之前的案例。我们不需要手动返回迭代器对象了,而是将Symbol.iterator定义为生成器函数,然后在函数内部直接遍历对象成员,然后通过yield去返回每一个被遍历到的对象就可以了。

const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语'],
    work: ['编码'],
    [Symbol.iterator]: function * () {
        const all = [...this.life, ...this.learn, ...this.work];
        for (const item of all) {
            yield item;
        }
    }
}

以上就是使用生成器函数的一些简单的用途,但他最总要的目的还是为了解决异步编程过程回调嵌套过深所导致的问题。

ES Modules

ES Modules是ECMAScript2015中标准化的一套语言层面的模块化标准规范,我之前写过一篇模块化发展历程的文章,里面有详细的介绍,里面和commonjs以及其他标准做了统一的对比,感兴趣的可以翻阅一下那篇文章。

ES2016概述

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

首先就是数组的include方法,这个方法帮我们检查数组中是否存在某个元素。在这之前如果我们需要检查数组中是否包含某个元素我们都是使用indexOf方法。

但是indexOf不能查询到数组中的NaN,现在有了includes方法之后我们就直接可以判断数组当中是否存在某个指定的元素了,并且他返回的是一个布尔值,而且也可以判断NaN。

除了includes,ES2016另外一个新功能就是多了一个指数运算符。以前需要进行指数运算需要借助Math对象的pow方法来去实现。例如我们去求2的10次方。

Math.pow(2, 10); // 表示2的10次方。

在ES2016中新增的指数运算符,他就是语言本身的运算符,就像是我们之前所使用的加减乘除运算符一样,使用起来也非常简单就是两个星号**

2**10; // 2的10次方

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

ECMAScript2017

ES2017他是ES标准的第八个版本,发布于2017年的6月。与ES2015相比ES2017也只是一个小版本,但是他同样带来了一些非常有用的新功能。

首先就是对Object对象新增三个扩展方法

Object.keys 返回的是所有的键组成的数组,Object.values返回的是所有值组成的数组。

Object.entries 将对象转成数组,每个元素是键值对的数组,可以快速将对象转为Map

const l = Object.entries({a: 1, b: 2});
const m = new Map(l);

Object.getOwnPropertyDescriptors;获取对象的描述信息

Object.assign 复制时,将对象的属性和方法当做普通属性来复制,并不会复制完整的描述信息,比如this等.

const p1 = {
    a: 'y',
    b: 'd',
    get name() {
        return `${this.a} ${this.b}`;
    }
}

const p2 = Object.assign({}, p1);
p2.a = 'z';
p2.name; // y d; 发现并没有修改到a的值,是因为this仍旧指向p1

使用 Object.getOwnPropertyDescriptors 获取完整描述信息

const description = Object.getOwnPropertyDescriptors(p1);
const p2 = Object.defineProperty({}, description);
p2.a = 'z';
p2.name; // z d

新增字符串填充功能

'abc'.padEnd(5, '1'); // abc11; 用给定的字符串在尾部拼接到指定长度

'abc'.padStart(5, '1'); // 11abc; 用给定的字符串在首部拼接到指定长度

允许对象和数组在最后添加一个逗号 [1, 2, 3,], {a: 1, b: 2, }

添加 async + await 异步函数。

在函数声明时加入async关键字,则函数会变为异步函数,当使用await调用时,只有等到被await的promise返回,函数才会向下执行。

const as = async () => {
const data = await ajax();
}

ECMAScript2018

ECMAScript2018新增属性允许将对象的剩余属性收集到新对象中

const data = {a: 1, b: 2, c: 3, d: 4};
const {a, b, ...arg} = data;
console.log(arg); // {c: 3, d: 4};

以前该方式可以运用到数组 从 ES 2018 开始也支持对象了。事实上 Map、Set、String 同样支持该能力。

for of 支持异步迭代。
在此之前想要实现异步迭代想要在for of 外层嵌套一个async函数

async function () {
    for (const fn of actions) {
        await fn();
    }
}

ES2018提供了一种新的书写方式。

async function() {
    for await (const fn of actions) {
        fn();
    }
}

ECMAScript2019

JSON 成为 ECMAScript 的完全子集:在以前,行分隔符(\u2028)和段分隔符(\u2029)会导致 JSON.parse 抛出语法错误异常。

ECMAScript优化了这个功能,不再报错。可正常显示。

JSON.stringify也做了改进,对于超出 Unicode 范围的转义序列,JSON.stringify() 会输出未知字符:JSON.stringify('\uDEAD'); // '"�"'

对Function.prototpye.toString()进行修正, 显示更加完善。在以前,返回的内容中 function 关键字和函数名之间的注释,以及函数名和参数列表左括号之间的空格,是不会被显示出来的。ES2019 现在会精确返回这些内容,函数如何定义的,这就会如何显示。

ES2019为数组新增两个函数,Array.prorptype.flat() 和 Array.prorptype.flatMap()。

flat() 用于对数组进行降维,它可以接收一个参数,用于指定降多少维,默认为 1。降维最多降到一维

const array = [1, [2, [3]]]
array.flat() // [1, 2, [3]]
array.flat(1) // [1, 2, [3]],默认降 1 维
array.flat(2) // [1, 2, 3]
array.flat(3) // [1, 2, 3],最多降到一维

flatMap() 允许在对数组进行降维之前,先进行一轮映射,用法和 map() 一样。然后再将映射的结果降低一个维度。可以说 arr.flatMap(fn) 等效于 arr.map(fn).flat(1)。但是根据 MDN的说法,flatMap() 在效率上略胜一筹,谁知道呢。

flatMap() 也可以等效为 reduce() 和 concat() 的组合,下面这个案例来自 MDN,但是……这不是一个 map 就能搞定的事么?

var arr1 = [1, 2, 3, 4];

arr1.flatMap(x => [x * 2]);
// 等价于
arr1.reduce((acc, x) => acc.concat([x * 2]), []);
// [2, 4, 6, 8]

ES2019 为字符串也新增了两个函数:trimStart() 和 trimEnd()。用过字符串trim()的人都知道了,这两个函数各自负责只去掉单边的多余空格。字符串的trim()是两边都去。

从名字就能看出来,这是 Object.entries() 的逆过程。Object.fromEntries(); 可以将数组转化为对象。

Symbol 是 ES2015引入的新的原始类型,通常在创建Symbol时我们会附加一段描述。过去,只有把这个Symbol转成String才能看到这段描述,而且外层还套了个 'Symbol()' 字样。ES2019 为 Symbol 新增了 description 属性,专门用于查看这段描述。

const sym = Symbol('The description');
String(sym) // 'Symbol(The description)'
sym.description // 'The description'

try…catch 的语法大家都很熟悉了,过去,catch 后面必须有一组括号,里面用一个变量(通常叫 e 或者 err)代表错误信息对象。现在这部分是可选的了,如果异常处理部分不需要错误信息,我们可以把它省略,像写 if…else 一样写 try…catch。

try {
  throw new Error('Some Error')
} catch {
  handleError() // 这里没有用到错误信息,可以省略 catch 后面的 (e)。
}

在之前JavaScript中内置的数组排序算法使用的是不稳定的排序算法,也就是说在每一次执行后,对于相同数据来说,它们的相对位置是不一致的。

比如以下这段代码:

var arr1 = [{a: 1, b: 2}, {a: 2, b: 2}, {a: 1, b: 3}, {a: 2, b: 4}, {a: 5, b: 3}];
arr1.sort((a, b) => a.a - b.a);

返回的结果第一次可能是这样的: [{a: 1, b: 2}, {a: 1, b: 3}…]

但是第二次就变成: [{a:1,b:3}, {a:1, b: 2}….]

这就是不稳定排序算法的一些弊端。那么在es2019中,JavaScript内部放弃了不稳定的快排算法,而选择使用Tim Sort这种稳定的排序算法。优化了这个功能。

ECMAScript2020

类的主要目的之一是将代码包含到更可重用的模块中。因为你将创建一个在许多不同地方使用的类,所以你可能不希望它内部的所有内容都是全局可用的。现在,通过在变量或函数前面添加一个简单的哈希符号,我们可以将它们完全保留为类内部使用

class Message {
 #message = "Howdy"
 greet() { console.log(this.#message) }
}

const greeting = new Message()

greeting.greet() // Howdy 内部可以访问
console.log(greeting.#message) // Private name #message is not defined  不能直接被访问

新增Promise.allSettled方法。

当我们处理多个Promise时,特别是当它们相互依赖时,记录每个Promise所发生的事情来调试错误是很有必要的。通过Promise.allSettled,我们可以创建一个新的Promise,它只在所有传递给它的Promise都完成时返回一个数组,其中包含每个Promise的数据。

const p1 = new Promise((res, rej) => setTimeout(res, 1000));

const p2 = new Promise((res, rej) => setTimeout(rej, 1000));

Promise.allSettled([p1, p2]).then(data => console.log(data));

// [
//   Object { status: "fulfilled", value: undefined},
//   Object { status: "rejected", reason: undefined}
// ]

区别于Promise.all方法,Promise.all是当多个promise全部成功,或出现第一个失败就会结束。Promise.allSettled 是所有都执行完成,无论成功失败。

假设变量a不存在,我们希望给系统一个默认值,一般我们会使用 || 运算符。但是在javascript中空字符串,数字0,false都会执行 || 运算符,所以ECMAScript2020引入合并空运算符解决该问题,只允许在值为null或未定义时使用默认值。

const name = '';

console.log(name || 'yd'); // yd;
console.log(name ?? 'yd'); // '';

业务代码中经常会遇到这样的情况,a对象有个属性b, b也是一个对象有个属性c,

const a = {
    b: {
        c: 123,
    }
}

我们需要访问c,经常会写成 a.b.c,但是如果b不存在时,就会出错。

ECMAScript2020定义可选链运算符解决该问题,通过在.之前添加一个?将键名变成可选

let person = {};
console.log(person?.profile?.age ?? 18); // 18

JavaScript可以处理的最大数字是2的53次方 - 1,我们可以在我们可以在 Number.MAX_SAFE_INTEGER 中看到, 更大的数字则无法处理,ECMAScript2020引入BigInt数据类型来解决这个问题。通过把字母n放在末尾, 可以运算大数据。可以通过常规操作(例如算术运算符)进行加、减、乘、除、余数和幂等运算。它可以由数字和十六进制或二进制字符串构造。此外它还支持 AND、OR、NOT 和 XOR 之类的按位运算。唯一无效的位运算是零填充右移运算符。

const bigNum = 100000000000000000000000000000n;
console.log(bigNum * 2n); // 200000000000000000000000000000n

const bigInt = BigInt(1);
console.log(bigInt); // 1n;

const bigInt2 = BigInt('2222222222222222222');
console.log(bigInt2); // 2222222222222222222n;

BigInt是一个大整数,不能存储小数。

动态导入import('./a.js').then

const a = 123;
export { a };
import('./a.js').then(data => {
    console.log(data.a); // 123;
})

提供一种标准化方式访问全局对象,globalThis,在浏览器中window作为全局对象,在node中global作为全局对象,ECMAScript2020提供globalThis作为语言的全局对象,方便代码移植到不同环境中运行。