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
  • 1

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

yarn nodemon ./index.js
  • 1

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

let 与块级作用域

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

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

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

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

for (var i = 0; i < 10; i++) {
    console.log('yd');
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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

if (true) {
    var foo = 'yd';
}
console.log(foo); // yd
  • 1
  • 2
  • 3
  • 4

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

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

if (true) {
    let foo = 'yd';
}
console.log(foo); // yd
  • 1
  • 2
  • 3
  • 4

此时控制台就会打印一个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);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5

仔细分析原因其实也很简单,因为外层声明了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); 
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5

因为内存循环中的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();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

你会发现这里打印的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();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

其实闭包他也就是借助于函数作用域去摆脱全局作用域带来的影响。现在有了块级作用域过后就不必要这么麻烦了,只需要将声明计数器的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();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

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

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

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

此时控制台当中可以正常输出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++;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

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

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

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

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

console.log(foo);

var foo = 'yd';
  • 1
  • 2
  • 3

那此时我们的控制台并不会报出一个错误, 而是打印的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';
  • 1
  • 2
  • 3

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


// const name = 'yd';

var name2;

name2 = 'yd';
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

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

const obj = {}
obj.name = 'yd';
  • 1
  • 2

但是如果说我们是将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);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

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);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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

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

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

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

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

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

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

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

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

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

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

对象的解构

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

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

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

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

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

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

const { name } = obj;
  • 1
  • 2
  • 3

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

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

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

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

const { name: name1 } = obj;

console.log(name1);
  • 1
  • 2
  • 3
  • 4
  • 5

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

const { log } = consult;
log('1');
  • 1
  • 2

模板字符串

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

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

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

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

const str = `
123
456
`
  • 1
  • 2
  • 3
  • 4

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

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

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

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

  • 1
  • 2
  • 3
  • 4
  • 5

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

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

带标签的模板字符串

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

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

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

const result = tag`My name is ${name}, I am ${age} years old`;
  • 1
  • 2
  • 3
  • 4

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

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

除了这个数组以外,这个函数还可以接收到所有在我们这个模板字符串中出现的表达式的返回值,例如我们这里的模板字符串就使用了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'}`;

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

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

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

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

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

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

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

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

字符串扩展方法

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

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

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

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

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

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

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

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

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

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