ECMAScript新特性

344 阅读30分钟

        学习ECMAScript首先要弄清楚语言和平台之间的关系,以JS为例我们日常编写的代码哪些属于语言层面哪些又属于平台层面。在NodeJS平台越来越重要的今天,弄清楚这个关系对于平时开发是很重要的。所以系统化的学习ECMAScript,以及它的新特性很有必要,同时这些内容对于开发者去写出更现代化更高质量的代码也会有很大的帮助。

一、ECMAScript 与 JavaScript

       ECMAScript 也是一种脚本语言,缩写为 ES 通常被看作 JavaScript 的标准化规范。实际上 JavaScript 是 ECMAScript 的扩展语言,因为 ECMAScript 当中只提供了最基本的语法,通俗点来说就是约定了代码该如何编写,例如该怎么样去定义变量和函数或者是怎么去实现分支循环之类的语句。它只是停留在语言层面,并不能直接用来完成我们应用中的实际功能开发,而我们经常使用的 JS 它实现了 ECMAScript 的语言的标准并且在这个基础上做了一些扩展。使得我们可以在浏览器环境当中去操作 DOM 和 BOM 在 Node 环境当中可以去做读写文件之类的一些操作。总的来说在浏览器环境当中的JS 就等于 ECMAScript + Web APIs (BOM & DOM);

在Node环境当中所使用的 JavaScript 就等于  ECMAScript + Node APIs (fs + net + etc.)

JavaScript 语言本身指的就是 ECMAScript

二、ECMAScript的发展过程

      随着这些年web 这种应用模式深入的发展,从2015年开始ECMAScript就保持着每年一个大版本的迭代。伴随着这些新版本的迭代很多新特性陆续出现,这也就导致我们现如今Js这门语言的本身也就变得越来越高级、越来越便捷。下表就给出了截止到目前为止所有的ECMAScript的标准的名称以及它的版本号还有它的发行时间,在这其中ES2015它值得我们单独去了解的内容有很多因为在这个版本当中,它相对比较特殊他在上一个版本也就是ES5发布过后经历了近6年的时间才被完全的标准化。而且这6年的时间也是web发展的黄金时间,所以说在这个版本当中它包含了很多颠覆式的新功能,也正是因为ES2015迭代的时间太长导致发布的内容过多,所以从之后的版本开始ES的发布会变得更加频繁那也更符合我们当下互联网小步快跑这种精神。而且从ES2015过后ECMAScript就决定不再按照保本号去命名,而是使用发行年份来命名。 由于这样一个决定是在ES2015诞生的过程中产生的,所以当时很多人就已经习惯了ES6这样一个名称,所以对于ES2015就出现了有人称之为ES6的情况。随着ECMAScript开始稳步的迭代发展市面上主流的运行环境也都纷纷跟进,已经开始逐步支持这些最新的特性所以说对于我们使用JS的开发者而言,学习这些新特性很有必要。 

三、ECMAScript 2015 的新特性

        ECMAScript2015也可以叫做ES6,它可以算是新时代ECMAScript标准的代表版本。一来它相比于上一个版本变化比较大;二来从这个版本开始它的命名规范发生了变化更准确的缩写名称是ES2015.顺便解释一下目前有很多开发者喜欢使用ES6这样一个名称去泛指从ES5.1以后所有的新版本。例如我们在很多的资料中会看到使用ES6的async/await之类的说法但实际上async/await是ES2017标准当中新特性,所以以后你需要去注意分辨你所看到的ES6它指的到底是ECMAScript2015标准还是说泛指所有的新标准。ECMAScript2015标准规范长达26章如果可以的话,我建议你也花一点时间简单过一遍ECMAScript2015的完整语言规格文件,因为这个规格文件中不是仅仅介绍了这个版本所引入的新特性而是包含这个新特性过后所有的语言标准规范。我们这里所要介绍的只是ECMAScript2015标准中所提出的一些比较重要值得我们单独去了解的新特性。我个人把这些变化简单的归为4大类:

  • 解决原有语法上的一些问题和不足,例如let、const所提供的块级作用域;
  • 对原有语法进行增强,使之变得更为便捷、易用,例如解构、展开还有参数默认值、模版字符串等等;
  • 全新的对象、全新的方法、全新的功能,例如Promise、Process、Object.assin()等等;
  • 全新的数据类型和数据结构,例如Symbel、set、map等等;

1、let 与块级作用域

作用域顾名思义指就是我们代码当中的一个成员它能够起作用的范围,在ES2015之前ECMAScript当中只有两种类型的作用域:全局作用域函数作用域。在ES2015中又新增了一个块级作用域,块指的就是代码中用一对花括号{}所包裹起来的范围。例如 if 语句和for循环语句中的花括号都会产生这里所说的块,以前块是没有单独的作用域的,这就导致在块中定义的成员外部也可以访问到。这一点对于复杂代码是非常不利的,也是不安全的。有了块级作用域之后,就可以在代码当中通过一个新的关键词 let 去声明变量。它的用法和传统的 var 是一样的,只不过**通过 let 声明的变量只能在所声明的这个代码块中被访问到,也就是说在块级作用域定义的变量在外部是不能访问到的。**这样一个特性非常有利于声明 for 循环当中的计数器,传统的for循环如果出现了循环嵌套的情况,就必须为循环中的计数器设置不同的名称否则的话就会出现问题。

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

例如一个for循环中嵌套了另一个for循环,并且两个循环的计数器都叫 i 。然后在内层循环中去打印 i ,按照预想的情况打印的结果应该是两次循环次数相乘的积。但实际上的结果是只打印了内部循环的次数,仔细分析原因其实也很简单。因为外层声明了这个 i 过后,内层循环再次声明了这个变量而且它们都是使用 var 去声明的,也就是说并不是一个块级作用域内的成员而是全局成员。内层所声明的这个 i ,就会覆盖掉之前外层所声明的 i 。等到内层循环执行完了过后 i 的值就是内部循环的最大值,对于外层来讲的话外层 拿到的 i 仍然是全局当中的 i 。所以所它的值如果大于外层的最大值,就不会满足外层的循环条件自然也就不会继续循环了。如果说使用的是 let 的话,就不会有这样的一个问题。因为let所声明的变量只能在当前循环体中所在的代码块中生效,这样内层的循环执行次数就等于内外部次数的积。**因为内部循环的i是块级作用域的局部成员,需要注意的是这里真正解决问题的实际上是内层循环当中的 let 。因为它把内部的 i 关进了一个盒子当中并不再去影响外部,就算把外部的 let 改成var也不会影响结果。**虽然let关键词解决了循环嵌套当中计数器重名导致的问题,但是最好不要去使用同名的计数器,因为这样的话不利于后期再去理解代码。

const chalk = require('chalk')
for (let i = 0; i < 3; i++) {
  console.log(chalk.red(`外部:${i}`))
  for (let i = 0; i < 5; i++) {
    console.log(chalk.yellow(`内部:${i}`))
  }
}

除此之外还有一个典型的应用场景就是循环去注册事件,在事件的处理函数中要求访问循环的计数器,这种情况下以前出现一些问题。在node环境中可以使用函数来模拟,首先定义一个elements数组,然后在数组当中去定义成员对象,每个对象都是一个空对象代表一个界面元素。然后遍历整个数并模拟为每一个元素添加一个 onClick 事件,实际上就是为每个对象添加一个 onClick 方法。在这个事件的处理函数当中,访问当前循环的计数器并打印出来。 

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

这是因为每次打印的 i 都是全局作用域里的 i ,当循环结束过后 i 已经被累加到了 3 。所以说无论打印的是哪一个元素的 click 它的结果都是一样的,同时这也是闭包的一个典型应用场景。通过建立闭包就可以解决这样的一个问题,其实闭包也就是借助于函数作用域去摆脱全局作用域所产生的影响。现在有了块级作用域过后就不必要在这么麻烦了,这里只需要将 var 关键字修改成 let 。由此可以认识到块级作用域内部也是一种闭包的机制,因为在onClick 执行的时候循环在就结束了实际的 i 也早就销毁了,就是因为闭包的机制才可以拿到原本执行循环的时候的那个 i 所对应的值。另外在 for 循环中还有一个特别之处,因为在 for 循环内部实际上会有两层作用域,比如在下面这个案例中,i 在外层和内层被两次赋值却互不影响,因为它们在不同的作用域当中。它可以用 if 语句来模拟,可以看出循环体当中的 i 是独立的作用域,外层是 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)
}
i++;
if (i < 3) {
    let i = "foo";
    console.log(i)
}
i++;
if (i < 3) {
    let i = "foo";
    console.log(i)
}
...

除了会产生块级作用域限制以外, let 和 var 还有一个很大的区别就是 let 的声明不会出现提升的情况。传统的 var 去声明变量都会导致声明的变量提升到代码最开始的位置,如果在声明之前调用一个用var定义的变量,控制台并不会报错,而是打印了一个 undefined 这也就是说明在打印的时候 foo 此时就已经声明了只是还没有赋值而已,这种现象叫做变量声明的提升。其实在目前来看这样一个现象实际上是一个bug,在Es2015 let 的出现就是为了要解决这样一个问题。它从语法层面就要就开发者必须要先声明变量,再去使用变量否则的话就会报出一个为定义的错误。

2、const 恒量/常量

 Es2015中还新增了一个 const 关键词用来声明一个只读的恒量/常量,它的特点就是在 let 的基础上多了一个只读特性。所谓的只读指的就是变量一旦声明过后就不能够再被修改,如果去修改一个已被声明过的 const 常量就会报错。既然 const 所声明的是常量,那么就要求在声明时必须要有一个初始值,声明和赋值不能像 let 或者 var 一样放到两个语句当中。这里的所说的修改实际上指的就是不能重新指向新的内存地址,并不是说我们不能修改恒量中的属性成员。例如,使用 const 声明了一个对象 obj 等于一个空对象 {},然后去设置这个对象的 name 属性。这种情况实际上并没有修改 obj 所指向的内存地址,它只是修改了这块内存空间的当中的数据所以说是被允许的。相反,如果说是将 obj 等于一个新的空对象,此时就会报错因为这种赋值他会改变 obj 的内存指向。除此之外 const 的其他特性都和 let 关键词相同。在项目的最佳实践就是,不用 var、默认全部使用 const、对于一定会需要修改的值使用 let 去声明。按照这种方式去选择的话代码质量实际上会用明显的提高,因为 var 会导致很多陋习所以坚决不用、默认使用 const 的原因是因为它可以让开发者更明确代码中所声明的成员会不会被修改。

3、数组的解构 Destructuring

ECMAscript 2015新增了从数组或对象中获取指定元素的一种快捷方式,这是一种新的语法这种新语法叫做结构 Destructuring 。例如,这里有一个数组数组中有三个元素以前如果想要获取每个元素的值都是通过索引访问对应的值,然后将访问过后的结果放在一个变量当中。

// 数组的解构
const arr = [100, 200, 300];
const foo = arr[0];
const bar = arr[1];
const baz = arr[2];

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

// 数组的解构
const arr = [100, 200, 300];
const [foo, bar, baz] = arr;

如果只是想获取某个位置所对应的成员,例如这里只获取第三个成员,就可以把前两个成员留空保留所对应的逗号,确保结构位置的格式与数组是一致的这样就能提取到指定位置的成员。

// 数组的解构
const arr = [100, 200, 300];
const [, , baz] = arr;

除此之外还可以在结构位置的变量名之前添加扩展运算符 (...),表示从当前位置开始往后的所有成员最终所有的结果会放到一个数组当中,需要注意的是这种扩展运算的用法它只能在我们结构位置的最后一个成员上使用。

// 数组的结构
const arr = [100, 200, 300];
const [foo, ...rest] = arr;

另外如果说结构位置的成员个数小于被结构的数组长度,就会按照从前到后的顺序去提取多出来的成员就不会被提取,反之如果说结构位置的成员大于数组的长度提取到的就是 undefined ,这和访问一个数组中不存在的下标是一样的。

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

如果要个提取到的成员设置默认值这就语法同样也能够支持,只需要在结构位置的变量名后面跟上一个等号然后写上一个默认值,这样的话如果没有提取到数组当中对应的成员这样变量就会得到这里的默认值。

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

这种新语法在很多场景下都能给我们带来很大的便捷,例如去拆分一个字符串并获取拆分后的指定位置,传统的做法需要用到临时变量去做一个中间的过渡,

const path = '/foo/bar/baz';
const temp = path.split('/');
const rootdir = temp[1]

通过结构的方式就可以大大简化这样一个过程,使之变得更加简单。

const [, rootdir] = path.split('/');

4、对象的结构 Destructuring

在ECMAscript 2015 当中除了数组可以被解构,对象也可以被解构不同的是对象的结构是通过属性名被提取而不是位置。因为数组中的元素有下标,也就是说它是有顺序规则的而对象里面的成员没有固定的次序,所以说它不能按照位置去提取。例如,下面已经定义好了一个对象 obj 解构它里面的成员就是在以前的变量名位置去使用一个对象字面量的花括号,然后在这个花括号里同样也是提取出来的数据所存放的变量名。不过这里的变量名还有一个很重要的作用,就是去匹配被解构对象中的成员,从而去提取指定成员的值。

// 对象的结构
const obj = {name: 'zce', age: 18};
const {name} = obj;

解构对象的其他特点基本上和解构数组是一样的,例如没有匹配到的成员也会返回 undefined 、也可以设置默认值。在对象当中还有一个特殊的情况,就是因为解构的变量名它同时又是用来匹配我们被解构对象当中的属性名的。所以说当我们当前作用域当中有同名的成员,就会产生冲突。

const name = "tom";
const { name } = obj;
// SyntaxError: Identifier 'name' has already been declared

但如果要提取出来相应属性的属性值就必须要使用这个属性名,这样的话这样的冲突就不可避免。这个时候就可以使用重命名的方式去解决这样的问题,具体的语法就是在结构位置的成员名后面加上(:)然后跟上一个新的名称,此时就会有两个名称,冒号左边的实际上就是用来匹配对象当中的属性名从而提出对应的属性值;右边就是最终提取到的值所放入的变量的名称,这样的话就可以任意取一个变量名称,从而避免冲突的出现。而此时如果还需要为这个变量添加默认值,就可以在变量后面在继续添加一个等号然后去设置对应的默认值。

const name = 'tom'
const { name: objName = 'jack' } = obj 

结构对象的应用场景就更多了,并且大部分场景都是为了简化代码。

5、模版字符串字面量 Template literals

在ECMAscript 2015 中还增强了定义字符串的方式,传统定义字符串的方式是通过单引号或者双引来表示。

const str = 'foo'

在ES2015 新增了一种叫做模版字符串的方式,它需要使用反引号来标识。直接使用它和普通的字符串没有什么区别,如果要在字符串的内容当中需要反引号同样也可以使用斜线去转义。

const str = `this is a \`string`

相比于普通的字符串这种模版字符串的方式它多了一些非常有用的新特性,首先第一点就是传统的字符串不支持换行,如果说字符串里面有换行符需要通过 \n 这种字符来表示。而在最新的模版字符串当中它可以支持多行字符串,也就是说可以直接在字符串当中输入换行符。这一点对于输出 html 字符串是非常方便的;其次,模版字符串还支持差值表达式的方式在字符串中去嵌入所对应的数值,使用方式是 ${变量名}。

cont name = 'tom';
const msg = `hey, ${name}`;

这种方式比之前字符串拼接的方式方便的多,也更直观一点不容易写错事实上${}里面的内容就是标准的 javascript ,也就是说这里不仅可以嵌入变量也可以嵌入任何标准的 js 语句。这个语句的返回值最终会被输出到字符串当中差值表达式所存在的位置。

const msg = `hey, ${name} --- ${1 + 2} --- ${Math.random()}`

6、模版字符串标签函数 Tagged templates

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

const str = tag`hello world`
console.log`hello world` // ['hello world']

标签函数接收到的第一个参数是一个数组,它实际上是模版字符串内容分割后的结果。这是因为在模版字符串当中可能会有嵌入的差值表达式,所以说这个数据就是按照表达式分割过后那些静态的内容,所以说它是一个数组。除了这个数组以外这个函数还可以接收到所有的在这个模版字符串当中出现的表达式的返回值,同时这个标签函数的返回值就是带标签的模板字符串所对应的返回值。标签函数的作用实际上就是对模版字符串的加工,使用这个特性可以实现例如像文本多语言化或者检查文本是否出现不安全字符这类的需求,甚至可以使用这种特性去实现一个小型的模版引擎。

const name = 'tom';
const gender = true;
function myTagFunc (strings,name, gender) {
    const sex = gender ? 'man' : 'woman';
    return strings[0] + name + stings[1] + sex + strings[2]
}
const result = myTagFunc`hey, ${name} is a ${gender}.`

7、字符串的扩展方法

ECMAscript 2015 当中还为字符串对象提供了一系列扩展方法,比如 includes()startsWith()endsWith() 。它们是一组方法可以用来去更方便的判断字符串当中是否包含指定的内容,相比于之前使用 indexOf() 或者是正则表达式来判断这样一组方法会让字符串查找便捷很多。

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

8、参数默认值 Default parameters

EECMAscript 2015 当中为函数的形参列表扩展了一些非常有用的新语法,首先是参数的默认值。参数默认值的定义是在没有去传递实际参数时就是在缺省状态所使用的一个值,没有传实参得到就应该是 undefined,以前想要为函数中的参数去定义默认值,需要在函数体中通过逻辑代码来实现。

function foo (enable) {
    // 很多人喜欢在这里使用短路运算的方式 enable = enable || true,添加默认值
    // 但这里这种情况下是不能使用短路运算的,
    // 这样会导致传入 false 时也会等到 true,
    // 正确的做法是判断 enable 是否等于 undefined。
    enable = enable === undefined ? true : enable;
    console.log('foo invoked ~ enable:', enable);
}
foo(true)

有了参数默认值这样的新功能过后这一切就会变得简单的多,这样就可以在形参的后面通过等号去设置一个默认值就可以了。

function foo (enable = true) {
    console.log('foo invoked ~ enable:', enable);  
}
foo()

要注意的一点就是,如果有多个形参带默认值的形参一定要在参数列表的最后面,因为参数是按照次序传入的,如果说带有默认值的这种参数不再最后的话,默认值将无法正常工作。

9、剩余参数 Rest parameters

ECMAscript 中很多方法都可以传递任意个数的参数,对于未知个数的参数以前都是使用 ECMAscript 所提供的 arguments 对象去接收,arguments 对象实际上是一个伪数组。

function foo () {
    console.log(arguments)
}
foo (1, 2, 3, 4) // [Arguments] {'0': 1, '1': 2, '2': 3, '3': 4}

在ES2015 当中新增了 ... 运算符,它有两个作用这里需要用到的就是它的 rest 作用,也就是剩余操作符。使用方式就是在函数的形参前面加上 ... ,此时这个形参就会以数组的形式去接收从当前这个参数位置开始往后所有的实参,这种方式就可以取代以前通过 arguments 对象去接收无限参数这样一种操作。

function foo (...args) {
    console.log(args)
}
foo (1, 2, 3, 4) // [1, 2, 3, 4]

因为接收的是所有的参数,所以说这种操作符它只能够出现在形参的最后面,而且只可以使用一次。

function foo (first, ...args) {
    console.log(args)
}
foo (1, 2, 3, 4) // [2, 3, 4]

10、展开数组 Spread

... 操作符除了用来收集剩余数据这种 rest 用法,它还有一个 spread (展开) 用法。 这种展开用法的用途有很多,这里先来了解与函数相关的数组参数展开。

const arr = ['foo', 'bar', 'baz'];
// 将数组中的成员按照次序传递给 console.log 方法
// 最原始的方式,通过下标一个个去找到数组中的元素分别传递
console.log(arr[0], arr[1], arr[2])
// 如果说数组中的元素个数是不固定的,一个个传的方式就行不通了
// 以前处理如此参数不固定的方法就是使用函数对象的 apply 方法去调用函数
// 这个方法可以使用以数组的形式去接收我们的实参列表
// apply 方法的第一个参数是 this 的指向, 第二个参数就是实参列表
console.log.apply(console, arr)
// ES2015 当中就没必要这么麻烦了,
// 这里展开操作符就会自动把数组当中的每一个成员按照次序传递到参数列表当中
// 这样就大大简化了操作
console.log(...arr)

11、箭头函数 Arrow functions

在 ECMAscript 2015 当中还简化了函数表达式的定义方式,可以使用箭头的符合的形式定义函数。这种形式一来简化了函数的定义、二来多了一些新特性。具体来看,首先来看语法。传统方式定义一个函数表达式,要通过 function 关键词去定义,现在可以使用 ES2015 的箭头函数定义一个完全相同的函数。

function inc (number) {
    return number + 1
}
// 等价于
const inc =
    n // 参数列表,如果有多个参数,可以使用圆括号的方式(a, b, c, ...)
    =>
    n + 1 // 函数体,这里已有一句表达式,它的执行结果就会作为函数的返回值返回,如果有多条语句
          // 可以使用{}来包裹,使用了花括号后返回值就需要手动使用 return 关键词

console.log(inc(100))

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

const arr = [1, 2, 3, 4, 5, 6, 7]
arr.filter(function (item) {
    return item%2
})
// 箭头函数的形式
arr.filter(i => i%2)

12、箭头函数与 this

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

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

如果将 sayHi 方法修改成箭头函数,你会发现 this.name 打印出来的是 undefined 。这就是箭头函数和普通函数最重要的区别,因为在箭头函数当中没有 this 的机制所以说它不会改变 this 的指向。也就是说在箭头函数外面 this 是什么,那么在里面拿到的 this 就是什么任何情况下都不会发生改变。

const person = {
    name: 'Tom',
    sayHi: () => {
        console.log(`hi, my name is ${this.name}`)
    },
    sayHiAsyncThis: function () {
        setTimeout(function(){
            // 异步函数会被放在全局对象上被调用
            console.log(this.name)
        }, 1000)
    },
    sayHiAsync_this: function () {
        // 通常解决此类问题,都会借助闭包的方式
        // 在外部定义一个 _this 来存储 this
        const _this = this
        setTimeOut(function(){
            console.log(_this.name)
        },1000)
     }
    sayHiAsyncArrow: function () {
        // 在箭头函数当中 this 始终指向的是当前作用域里面的 this
        setTimeOut(() => {
            console.log(this.name)
        }, 1000)
    }    
}
person.sayHi() // hi, my name is undefined
person.sayHiAsyncThis() // undefined
person.sayHiAsync_this() // Tom
person.sayHiAsyncArrow() // Tom

13、对象字面量的增强 Enhanced object literals

对象是ECMAscript当中最常用的数据结构,ECMAscript 2015 当中升级了对象字面量的语法。传统的字面量语法要求必须在花括号里面使用属性名冒号属性值 { 属性名 : 属性值 } 这种语法,即便说我们的属性是一个变量,也必须是这种形式来定义。在ES2015中,若果变量名和属性名是一致的话,就可以省略掉冒号以及后面的变量名。

const bar = '345';
const obj = {
    foo: 123,
    bar: bar
}
// 等价于
const obj = {
    foo: 123,
    bar,
}

除此之外,如果我们需要为对象添加一个普通的方法,传统的做法就是通过 { 方法名:函数表达式 } 这种形式。在 ES2015 中可以去掉冒号和 function,需要注意的是这种方法的背后实际上就是普通的 function ,也就是说通过对象去调用这个方法内部的 this 指向当前对象 。

const obj = {
    name: 'foo',
    method: function () {
        console.log('method')
    },
    method1 () {
        console.log(this)
    }
}
obj.method1() // foo

对象字面量还有一个很重要的变化就是,可以使用表达式的返回值作为对象的属性名。以前如果说要为对象添加一个动态的属性名,就只能在对象声明过后通过索引器的方式[方法名]来动态添加。在 ES2015 过后对象字面量属性名就直接可以通过方括号,直接去使用动态的值这样一个特性叫做计算属性名。

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

14、对象扩展方法 Object.assign、Object.is

ECMAscript 2015 中为Object对象提供了一些扩展方法,这里我们看几个主要的方法。

  • assign 方法:将多个源对象中的属性复制到一个目标对象中。如果对象当中有相同的属性那么源对象当中的属性将覆盖掉目标对象当中的属性,这里所说的愿对象和目标对象其实都是普通的对象,只不过用处不同我们是从源对象中取然后往目标对象当中放。支持传入任意个数的对象,第一个参数是目标对象也就是说所有源对象中的属性都会复制到这个对象当中,剩下的都是源对象,它的返回值也就是这个目标对象。常用在对象复制的使用场景中,除此以外 Object.assign 用来为 options 对象参数去设置默认值也是一个常见的应用场景。

    const source1 = {
    a: 123,
    b: 123,    }
    const target = {
    a: 456,
    c: 456,    }
    const result = Object.assign(target, source1)
    console.log(target) // {a:123, b:123, c: 456}
    console.log(result === target) // true
    // assign 使用场景
    function copyObj (obj) {
    // 如果在函数内部修改了 obj,在外部的 obj 也会被修改,因为它们指向同一个内存地址
    // 如果只是希望在这个函数的内部去修改这对象,就可以使用 Object.assign 方法去
    // 把这个对象复制到一个全新的空对象上,这样的话内部的对象就是一个全新的对象
    // 它的修改也不会影响到外部对象
    return Object.assign({}, obj)   }
    const resultCopy = copyObj(result)
    console.log(result === resultCopy) // false
    
  • is方法:用来判断两个值是否相等。在此之前如果要判断两个值是否相等可以使用 == 、 === ,这两者之间的区别就是: == 运算符比较之前自动转化数据类型就会导致 0 == flase 这种情况是成立的;=== 严格对比两者之间的数值是否相同,因为 0 和 false 它们的类型不同所以所它们是不会严格相等的。严格相等运算符也有一个特殊情况:首先就是对于数字 0 它的正负是没有办法区分的;其次是 NaN,两个 NaN 在严格相等的运算结果是 false,NaN 代表非数字说明它有无限多的可能,所以说两个 NaN 是不相等的,但现在看来 NaN 就是一个特别的值,所以所两个 NaN 应该是完全相等的,在 ES2015 中就提出了一种新的同值比较的算法,可以使用 Object 全新的 is 方法来解决这个问题。

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

15、代理对象 Proxy

如果我们要监视某个对象当中的属性读写,可以使用ES5所提供的 Object.defineProperty 这样的方法来去为我们的对象添加属性,这样的话我们就可以捕获到对象当中属性的读写过程。这种方法应用的非常广泛,在 vue 3.0 以前的版本就是使用这样一个方法来去实现的数据双向绑定。在 ES2015 当中全新设计了一个叫做 Proxy 的类型, 它就是专门用来为对象设置访问代理器的。可以把代理想像成门卫,也就是说不管你进去拿东西还是往里放东西都必须要经过这样一个代理。通过 Proxy 就可以轻松监视到对象的读写过程,相比于 defineProperty Proxy 它的功能要更为强大,使用起来也更为方便。Proxy 的构造函数第一个参数就是需要代理的目标对象;第二个参数也是一个对象可以把这个对象称之为代理的处理对象,在这个对象中可以通过get 方法来去监视属性的访问,set 方法监视对象当中属性的写入。

const person = {
    name: 'zce',
    age: 20,
}
const personProxy = new Proxy(person, {
    get(target, property) { // 接收两个参数,第一个是所代理的目标对象;第二个是外部所访问的属性名
        // 这个方法的返回值可以作为外部访问这个属性得到的结果
        return property in target ? target[property] : undefined
    },
    set(target, property, value) { // 默认接收三个参数:代理目标对象、写入的属性名称、写入的属性值
        if (property === 'age') {
            if (!Number.isInteger(value)) {
                throw new TypeError(`${value} is not a int`)
            }
        }
        target[property] = value
    }
})

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

首先最明显的优势就是 Proxy 要更为强大一些,这个强大具体体现在 defineProperty 只能监视属性的读写,Proxy 能够监视到更多的对象操作,例如 delete 操作或者对像当中方法的调用等等。

const personProxy = new Proxy(person, {
    deleteProperty (target, property) { // 这个方法会在外部对当前代理对象进行 delete 操作时会自动执行
        delete target[property]
    }
})
delete personProxy.age

下图是Proxy的操作文档

其次 Proxy 更好的支持数组对象的监视,以往我们想要通过 defineProperty 去监视数组的操作最常见的一种的方式就是通过重新数组的操作方法,这也是 vue 当中所使用的方式大体的思路就是通过自定义的方法去覆盖掉数组原型对象上的 push、shift 之类的方法,一次来去劫持对应的方法调用过程。

const list = [];
const listProxy = new Proxy(list, {
    set (target, property, value) { // 调用 push 方法时, property 对应的是数组的下标,value是下标所对应的值
        target[property] = value
        return true // 表示写入成功
    }
})
listProxy.push(100)

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

const person = {};
Object.defineProperty(person, 'name', {
    get () {
        console.log('name 被访问');
        return person._name
    },
    set (value) {
        console.log('name 被设置')  
        person._name = value
    }
})
person.name = 'jack'
console.log(person.name)

16、统一对象的操作 API Reflex

Reflect 是 ES2015中提供的一种全新的内置对象,如果按照 Java 或者 C# 这类语言的说法,Reflect 属于一个静态类,也就是说它不能够通过 new 的方式去构建一个实例对象,只能够去调用这个静态类当中的静态方法。这一点应该不陌生,因为在 JS 中的 Math 对象也是相同的。在 Reflect 内部封装了一系列对对象的底层操作,具体一共提供了14个静态方法其中又一个被废弃掉了还剩下13个。仔细去查看 Reflect 文档你会发现这13个方法的方法名与 Proxy 对象中的处理对象方法成员是完全一致的。其实,Reflect 成员方法就是 Proxy 处理对象的默认实现。在 Proxy 处理对象中可以添加不同的方法成员来去监听对象所对应的操作,如果说没有添加具体的处理方法,它内部默认实现的逻辑就是调用了 Reflect 对象当中所对应的方法。这也就表明我们在去实现自定义的 get 或 set 这样的逻辑时,更标准的做法就是先去实现自己所需要的监视逻辑,最后再去返回通过 Reflect 中对应的方法的一个调用结果。

const obj = {
    foo: '123',
    bar: '456',
};
const proxy = new Proxy(obj, {
    get (target, property) {
        return Reflect.get(target, property)
    },
    set (target, property, value) {
        return Reflect.set()
    }
})

Reflect 的价值体现在:统一提供一套用于操作对象的 API 。

const obj = {
    name: 'zce',
    age: 18,
}
// 判断 obj 中是否包含 name 属性
'name' in obj <==> Reflect.has(obj, 'name')
// 删除 obj 的 age 属性
delete obj['age'] <==> Reflect.deletePropery(obj, 'age')
// 获取 obj 的所有的属性名
Object.keys(obj) <==> Reflect.ownKeys(obj)
  • Reflect.apply(target, thisArgument, argumentsList) 对一个函数进行调用操作,同时可以传入一个数组作为调用参数。和 Function.prototype.apply() 功能类似;
  • Reflect.construct(target, argumentsList[, newTarget])对构造函数进行 new 操作,相当于执行 new target(...args);
  • Reflect.defineProperty(target, propertyKey, attributes)Object.defineProperty() 类似。如果设置成功就会返回 true;
  • Reflect.deleteProperty(target, propertyKey)作为函数的delete操作符,相当于执行 delete target[name];
  • Reflect.get(target, propertyKey[, receiver])获取对象身上某个属性的值,类似于 target[name];
  • Reflect.getOwnPropertyDescriptor(target, propertyKey)类似于 Object.getOwnPropertyDescriptor()。如果对象中存在该属性,则返回对应的属性描述符, 否则返回 undefined;
  • Reflect.getPrototypeOf(target) 类似于 Object.getPrototypeOf();
  • Reflect.has(target, propertyKey) 判断一个对象是否存在某个属性,和 in 运算符 的功能完全相同;
  • Reflect.isExtensible(target) 类似于 Object.isExtensible();
  • Reflect.ownKeys(target) 返回一个包含所有自身属性(不包含继承属性)的数组。(类似于 Object.keys(), 但不会受enumerable影响);
  • Reflect.preventExtensions(target)类似于 Object.preventExtensions()。返回一个Boolean;
  • Reflect.set(target, propertyKey, value[, receiver])将值分配给属性的函数。返回一个Boolean,如果更新成功,则返回true;
  • Reflect.setPrototypeOf(target, prototype) 设置对象原型的函数. 返回一个 Boolean, 如果更新成功,则返回true;

17、Class 类

在此之前,ECMAscript 当中都是通过定义函数以及函数的原型对象来去实现的类。

function Person (name) { // 类型的构造函数
    /**
     * 在构造函数中,可以通过 this 访问当前的实例对象
     */
    this.name = name
}
// 如果需要在这个类型,所有的实例之间共享一些成员可以借助函数对象的 prototype 也就是原型去实现
Person.prototype.say = function () {
    console.log(`hi, my name is ${this.name}`)
}

自从 ES2015 开始就可以使用 class 关键词来声明一个类,相比较之前使用函数的方式要更容易理解,结构也会更加清晰。这种语法与一些老牌面向对象语言当中 class 非常类似的,如果说我们想在构造函数当中做一些额外的逻辑,可以添加 constructor 方法,这个方法就是当前这个类的构造函数,同样可以在这个构造函数中使用 this 访问当前的实例对象。如果想为这个类定义实例方法,只需要在这个类里面去添加对应的方法成员。

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

两者都是通过 new 关键词创建实例,然后去调用这个实例里面所提供的方法。

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

18、静态成员 static

类中的方法一般分为实例方法静态方法实例方法就是通过这个类构造的实例对象去调用静态方法则是直接通过类型本身去调用

静态方法,属于类的方法,即类可以直接调用的方法。为类所有实例化对象所共用(但不能用实例对象之间调用),所以静态成员只在内存中占一块区域;

实例方法,属于实例化类后对象的方法,即实例对象调用的方法。每创建一个类的实例,都会在内存中为非静态成员分配一块存储;

静态方法在一启动时就实例化了,因而静态内存是连续的,且静态内存是有限制的;而非静态方法是在程序运行中生成内存的,申请的是离散的空间。

以前去实现静态方法,就是直接在构造函数对象上去挂载方法来去实现,因为在JS当中函数也是对象也可以去添加一些方法成员。

function Person (name) { // 构造函数
    this.name = name
}
Person.prototype.say = function () { // 实例方法
    console.log(`hi, my name is ${this.name}`)
}
Person.create = function (name) { // 静态方法
    return new Person(name)
}
const leo = Person.create('leo');
leo.say()

而在 ES2015 中新增了一个专门去添加静态方法的关键词 static 这里需要注意,因为静态方法是挂载在类型上的,所以在静态方法内部它的 this 就不会去指向某一个实例对象而是当前的类。如果说,之前对 this 这样一个特性不是很清晰这一点是尤其需要注意的。

class Person {
    constructor (name) { // 构造函数
        this.name = name
    }
    say () { // 实例方法
        console.log(`hi, my name is ${this.name}`)
    }
    static create (name) { // 静态方法,用于创建 Person 类的实例
        return new Person(name)
    }
}
// 调用静态方法就是通过类加方法名的格式
const leo = Person.create('leo');

leo.say();

19、类的继承 extends

继承是面向对象中一个重要的概念,通过继承这种特性就能够抽象出来相似类型之间重复的地方。

function Person (name) { // 构造函数
    console.log('name:', name)
    this.name = name
}
Person.prototype.say = function () { // 实例方法
    console.log(`hi, my name is ${this.name}`)
}
Person.create = function (name) { // 静态方法
    return new Person(name)
}
function Student (name, number) { // 构造函数
    /**
     * 在子类的构造函数里使用 call 或 apply,
     * 复制父类的实例属性给子类(没用到原型)
     */
    Person.call(this, name);
    const say = arguments.callee.prototype.constructor.prototype.say;
    this.number = number;
    this.hello = function () {
        say();
        console.log(`my school number is ${this.number}`);
        console.log(this.name)
    }
}
Student.prototype = new Person()
const leo = new Student('leo', 1001)
leo.hello()

在 ES2015 之前大多数情况都会使用原型的方式去实现继承,而在 ES2015 中它实现了一个专门用于类继承的关键词 extends 。

class Person {
    constructor (name) { // 构造函数
        this.name = name
    }
    static create (name) { // 静态方法
        return new Person (name)
    } 
    say () { // 实例方法
        console.log(`hi, my name is ${this.name}`)
    }
}
class Student extends Person {
    constructor (name, number) {
        super(name) // 始终指向父类,调用它就是调用了父类的构造函数
        this.number = number;
    }
    hello () {
        super.say();
        console.log(`my shoole number is ${this.number}`)
    }
}
const leo = new Student('leo', 1001);
leo.hello();

20、Set 数据结构

ES2015 中提供了一个叫做 Set 的全新数据结构,可以把它理解为集合。它与传统的数组非常相似,不过 Set 内部的成员是不允许重复的。通过这个类型构造的实例就可以用来存放不重复的数据,所以它最常用的应用场景就是为数组中的元素去重。

const s = new Set();
s.add(1) // 往集合当中添加数据,并返回集合对象本身所以说可以进行链式调用
 .add(2)
 .add(3)
 .add(2) // 如过添加了一个已存在的值,所添加的这个值就会被忽略掉
for (let i of s) { // 集合的遍历
    console.log(i)
}
s.size // 获取集合的长度
s.has(8) // 判断集合中是否存在某一个值
s.delete(1) // 删除集合中某个特定的值
s.clear() // 清空集合
const arr = [1, 2, 1, 3, 4, 1];
const result = Array.from(new Set(arr))

21、Map 数据结构

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

// 如果给对象添加的键不是字符串,内部会将这个数据 toString() 的结果作为键
const obj = {};
obj[true] = 'value';
obj[123] = 'value';
obj[{a : 1}] = 'value';
console.log(Reflect.ownKeys(obj)) // ["123", "true", "[object Object]"]

ES2015 中的 Map 结果就是为了解决这个问题的,Map 才能算为严格意义上的键值对集合,用来去映射两个任意类型的数据之间的对应关系。

const m = new Map();
const leo = {name: 'leo'}
m.set(leo, 90) // 设置数据
m.get(leo) // 获取数据
m.has(leo) // 判断某一个键是否存在
m.delete(leo) // 删除某一个键
m.clear() // 清空所有的键值
m.forEach((value, key) => { // 遍历
    console.log('值:', value)
    console.log('键:', key)
})

22、Symbol 一种全新的原始数据类型

在 ES2015 之前对象的属性名都是字符串,而字符串是有可能重复的。如果重复的话就会产生冲突,解决这类冲突常用的方式就是约定。

// shared.js
const catch = {}
// a.js
catch['a_foo'] = Math.random()
// b.js
catch['b_foo'] = '123'

但是约定的方式只是规避了问题,并不是彻底解决了这个问题。如果在这样一个过程当中有人不遵守约定,这个问题仍然会存在。ES2015 为了解决这种类型的问题,提供了一种全新的原始数据类型叫做 Symbol ,它的作用就是表示一个独一无二的值,通过 Symbol 函数创建的每一个值都是唯一的永远不会重复。为了便于调试 Symbol 函数允许传递一个字符串作为描述文本,这样的话对于多次使用 Symbol 的情况就可以在控制台区分出到底是那个对应的 Symbol 。

const s = Symbol()
console.log(s) // Symbol()
console.log(typeof s) // Symbol
console.log(Synbol() === Symbol()) // false
console.log(Symbol('foo')) // Symbol(foo)

从 ES2015 开始对象就可以直接去使用 Symbol 类型的值作为属性名,也就是说对象的属性名可以有两种类型的值分别是 String 和 Symbol。

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

另外 Symbol 除了可以用来去避免对象属性名重复产生的问题,还可以借助这种类型的特点去模拟实现对象的私有成员。

// a.js
const name = Symbol()
const person = {
    [name]: 'leo',
    say () { // 在对象的内部可以使用创建属性时的 Symbol 去拿到对应的属性成员
        console.log(this[name])
    }
}
// b.js
// 在外部文件当中因为我们没有办法再去创建一个完全相同的 Symbol 
// 所以说就不能够直接去访问到这个成员 
// 只能去调用这个对象当中普通名称的成员这样就实现了所谓的私有成员
person.say()

Symbol 最主要的作用就是为对象添加独一无二的属性名,截止到 ECMAscript2019 ECMAscript 当中一共定义了6中原始数据类型加上Object数据类型,一共7种数据类型。在未来还会新增一个叫做 BigInt 的原始数据类型用于去存放更长的数字,只不过这个类型处在 stage-4 阶段预计在下一个版本就能正式被标准化,到时候就8种数据类型了。

Symbol 在使用上还有一些让我们注意的地方,首先是它的唯一性每次通过 Symbol 函数创建的值一定是一个唯一的值,不管我们传入的描述文本是不是相同的,每次去调用 Symbol 函数它得到的结果都是全新的一个值。如果我们需要在全局去复用一个相同的 Symbol 值,可以使用全局变量的方式去实现,或者是去使用 Symbol 类型提供的一个静态方法去实现。

Symbol() === Symbol() // false
Symbol('foo') === Sybol('foo') // false
const s1 = Symbol.for('foo'); 
const s2 = Symbol.for('foo'); 
// 相同的字符串就一定会返回相同的 Symbol 类型的值// 这个方法它内部维护了一个全局的注册表,为字符串和 Symbol 值提供了一个一一对应的关系
// 需要注意的是,这个方法内部维护的是字符串和 Symbol 之间的对应关系,也就是说如果传入的不是字符串
// 这个方法内部,会把它自动转化成字符串,
// 这样就会导致传入布尔值的true 和字符串的 true 结果拿到的都是一样的
console.log(s1 === s2) // true

在 Symbol 类型当中还提供了很多内置的 Symbol 常量用来去作为内部方法的标示,这些标识符可以让自定义对象去实现一些 JS 当中内置的接口。如果想要自定义 toString 标签就可以在对象当中去添加一个特定成员来标识 ,考虑到如果使用字符串添加这种标识符就可能跟内部的一些成员产生重复。所以说 ECMSscript 要求使用 Symbol 值去实现这样一个接口 ,这里的 toStringTag 就是内置的一个 Symbol 常量 。

const obj = {};
const xobj = {
    [Symbol.toStringTag]: 'XObject'
};
console.log(obj.toString()) // [object object] 对象的 toString 标签
console.log(xobj.toString()) // [object XObject]

这种 Symbol 我们在后面为对象去实现迭代器时会经常用到。最后使用 Symbol 值做为对象的属性名,这个属性通过传统的 for in 循环是无法拿到的; Object.keys 方法也是获取不到的;通过 JSON.stringfy(obj) Sybole 属性也会被忽略掉。 总之这些特性都使得 Symbol 类型的属性它特别适合作为对象的私有属性,当然想要获取这种类型的属性名也不是完全没有办法,可以使用 Object 对象里面的 getOwnPropertySymbols 方法 ,它的作用类型于 Object.keys 方法所不同的是 Object.keys 只能获取字符串类型的属性名,getOwnPropertySymbols 方法只能获取 Symbol 类型的属性名。

const obj = {
    [Symbol()]: 'symbol value',
    foo: 'normal value',
}
for (let key in obj) {
    console.log(key)
} // foo

Object.keys(obj) // ['foo']
JSON.stringfy(obj) // {"foo":"normal value"}
Object.getOwnPropertySymbols(obj) // [ Symbol() ]

23、for ··· of 循环

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

const arr = [100, 200, 300, 400];
for (const item of arr) {
    console.log(item)
}
  • for ... of VS for ... in : 前者遍历的是数组的项、后者遍历的是数组的下标;

  • for ... of VS arr.forEach 方法:前者可以使用 break 终止循环、后者不可以,以前要实现终止遍历都是使用 arr.some() 、arr.every();

  • 除了数据可以使用 for ... of 遍历以外,伪数组也可以使用 for ... of 进行遍历,比如 arguments 对象、dom 节点列表、set 对象、map 对象

    const s = new Set(['foo', 'bar']); for (const item of s) { console.log(item) } // foo bar const m = new Map() m.set('foo', '123') m.set('bar', '456') for (const item of m) { console.log(item) } // ['foo', '123'] // ['bar', '456'] const obj = { foo: '123', bar: '456', } for (const item of obj) { console.log(item) } // obj is not iterable

24、可迭代接口 Iterable

上回说到 for ... of 循环是 ES2015 中最新推出的一种循环语法,它是一种遍历所有数据结构的统一方式。但是经过实际尝试发现它只能够去遍历数组之类的数据结构,对应普通的对象如果直接去遍历就会报错。原因是,在 ES 中能够标示有结构的数据类型越来越多,从最早的数组和对象到现在新增了 Set 和 Map 而且开发者还可以组合使用这些类型,去定义一些符合自己业务需求的数据结构,为了提供一种统一的遍历方式 ES2015 就供了 Iterable 接口意思就是可迭代的。接口就是一种规格标准,例如在ECMAscript 当中任意一种数据类型都有 toString 方法,这就是因为它们实现了统一的规格标准。而在编程语言当中更专业的说法就是它们都实现了统一的接口,可迭代接口是一种可以被 for ... of 循环统一遍历访问的规格标准。换句话说只要这个数据结构它实现了可迭代接口,它就能够被 for ... of 循环去遍历。这也就是说我们之前尝试的那些能够直接被 for ... of 循环去遍历的数据类型,它都已经在内部实现了这个接口。这里我们脱离掉 for ... of 循环的表象来去看一看这个叫做 Iterable 接口到底约定了那些内容。

  • 数组

  • Set

  • Map

根据两个理由:第一,这三个直接可以被 for ... of 遍历的对象当中都有这么一个方法;第二,这个方法的名字叫 iterator 。根据这两个理由基本上就可以确定 Iterable 接口约定的就是对象当中必须要去挂载一个叫做 Iterator 的方法(可以是使用 (arr|set|map)[Symbol.iterator] 拿到)。调用这个方法可以返回一个迭代器对象在这个对象里会有一个 next 方法,调用这个 next 方法返回的又是一个对象,在这个对象当中有两个成员 value 和 done ,value 对应的是数组的项、done 用来标识循环是否结束。在迭代器当中内部维护了一个数据指针每调用一次 next 这个指针都会往后移一位,done 属性的作用就是标识内部所有的成员数据是否全部被遍历完了。

const arr = [100, 200, 300];
const iterator = arr[Symbol.iterator]();
iterator.next() // {value:100, done: false}
iterator.next() // {value:200, done: false}
iterator.next() // {value:300, done: false}
iterator.next() // {value: undefined, done: true}

总结:所有可以被 for ... of 循环遍历的数据类型都必须要实现 iterable 接口,也就是它在内部必须要挂载一个 iterator 方法,这个方法需要返回一个带有 next 方法的对象,不断调用这个 next 方法就可以实现对内部所有数据的遍历。

const s = new Set([1, 2, 3]);
const iterator = s[Symbol.iterator]
itertaor.next() // {value: 1, done: false}
iterator.next() // {value: 2, done: false}
iterator.next() // {value: 3, done: false}
iterator.next() // {value: undefined, done: true}

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

25、实现可迭代接口  Iterable

了解了 for ... of 循环内部的原理过后,就应该理解为什么 for ... of 循环就可以作为遍历所有数据结构的统一方式。因为它内部就是去调用被遍历对象中的 iterator 方法得到一个迭代器,从而去遍历内部所有的数据。这也就是 iterator 接口所约定的内容,换句话说只要我们的对象也实现了 iterator 接口,那就可以实现使用 for ... of 去遍历普通对象。在 ECMAscript 当中去实现 iterable 接口,实际上就是在这个对象当中去挂载一个 iterator 方法 , 然后在这个方法当中去返回一个迭代器对象。

  1. 最外层实现了可迭代接口 iterable,这个接口约定内部必须要有一个用于返回迭代器的 iterator 方法;

  2. 中间层实现了迭代器接口 iterator,这个接口约定了内部必须要有一个用于迭代的 next 方法。返回一个实现迭代器结构的对象;

  3. 最底层,这个对象实现的是迭代结果接口 iterationResult,这个接口约定的就是我们在这个对象内部必须要有一个 value 属性用来去表示当前被迭代到的数据它的值可以是任意类型;除此之外还必须要有一个 done 的布尔值,表示迭代有没有结束。返回一个迭代结果对象;

    const obj = { // 1 store: ['foo', 'bar', 'baz'], [Symbol.iterator]:function(){ let index = 0; const self = this;

        return { // 2
            next:function () { //用于实现向后迭代的逻辑
                const result = { // 3
                    value: self.store[index],
                    done: index >= self.store.length, 
                }
                index++;
                return result
            }
        }
    }
    

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

26、迭代器模式 Iterator

上回说到如何让我们的自定义对象去实现可迭代接口,从而实现能够使用 for ... of 循环去迭代我们的对象,其实就是设计模式中的迭代器模式。通过下面这个案例可以了解一些迭代器设计模式的优势,假设我们有这样一个多人协同开发一个任务清单应用的需求,其中A 的任务就是去设计一个用于存放所有任务的对象 ,B 的任务就是被 A 定义这个对象当中所有的任务项全部去罗列呈现到界面上。A 为了更有结构的去记录每一个数据这里去设计一个对象结构 ,在这个对象中会定义两个数组分别用于去存放生活类 life 、学习类 learn 的任务 。 此时对于 B 而言就必须要了解 A 所定义的对象的数据结构是怎么样的,才能够有可能去遍历这个对象当中全部的数据内容,从而 B 的代码就有可能分别遍历对象中的两个数组去呈现所有的任务。假设 A 的结构发生了变化添加了一个全新的类目 work,因为B 的代码和 A 的代码存在严重的耦合,从而也要跟着一起去变化 。

// 迭代器设计模式
// 场景:多人协同开发一个任务清单应用
// A 的代码 -----------------------------------
const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语'],
    work: ['喝茶']
}
// B 的代码 -----------------------------------
for (const item of todos.life) { ... }
for (const item of todos.learn) { ... }
for (const item of todos.work) { ... }

这样的代码扩展性会特别的差同时也增加了维护成本,但如果 A 的数据结构能够对外提供一个统一的遍历接口,那么对于 B 而言也就是对于调用者而言那就不用再去关系对象内部的数据结构,更不用担心 A 的内部结构改变过后所产生的影响。

// 迭代器设计模式
// 场景:多人协同开发一个任务清单应用
// A 的代码 -----------------------------------
const todos = {
    life: ['吃饭', '睡觉', '打豆豆'],
    learn: ['语文', '数学', '外语'],
    work: ['喝茶'],
    each: function(callback) {
        const all = [].contact(this.life, this.learn, this.work)
        for(const item of all) {
            callback(item)
        }
    }
    [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
                }
            }
        }
    }
}
// B 的代码 -----------------------------------
todos.each(function(item){
    console.log(item)
})
for (const item of todos) {
    console.log(item)
}

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

27、生成器 Generator

在 ECMAscript2015 中还新增了一种生成器函数,引入这样一个新特性的目的就是为了能够在复杂的异步代码中减少回调函数嵌套过深产生的问题。从而去提供更好的异步编程解决方案,这里先来了解一下,生成器函数的语法以及它的基本应用。生成器函数就是在普通的 function 关键字后面添加一个星号 * ,这样的话普通函数就变成了一个生成器函数。

function * foo () {
    console.log('leo');
    return 100
};
const result = foo();
/**
 * 如果是一个普通的函数这里的执行就应该是先去打印函数体的当中打印的 leo,
 * 然后再去打印函数的返回值也就是100
 * 而实际上打印出的是生成器对象
 */
console.log(result); // Object [Generator] {}
console.log(result.next()) // leo {value: 100, done: true}

从打印结果来看,生成器对象实际上也实现了 iterator 接口。生成器函数在实际使用的时候,一定会配合另外一个关键词 yield,yield 关键词与 return 关键词非常的类似但是又有很大的不同。yield 关键词并不会结束掉方法的执行,可以多次使用。

function * foo {
    console.log('1')
    yield 100
    console.log('2')
    yield 200
    console.log('3')
    yeild 300
}
const generator = foo();
console.log(generator.next()) // 1 {value: 100, done: false}
console.log(generator.next()) // 2 {value: 200, done: false}
console.log(generator.next()) // 3 {value: 300, done: false}
console.log(generator.netx()) // {value: undefined, done: true}

总结:生成器函数会自动返回一个生成器对象,调用这个对象的 next 方法,才会让这个函数的函数体开始执行。执行过程中一旦遇到了 yield 关键词函数的执行就会被暂停下来,而且 yeild 后面的值将会作为 next 的结果返回,如果在继续调用生成器对象的 next 函数会从暂停的位置继续开始执行一直到这个函数完成,它最大的特点就是惰性执行。

28、生成器应用 Generator 

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

function * createIDMaker () {
    let id = 1;
    while (true) {
        yield id++
    }
}
const idMaker = createIDMaker()
console.log(idMaker.next().value) // 1
console.log(idMaker.next().value) // 2

我们还可以使用生成器函数来去实现对象的 iterator 方法,因为生成器它也实现了 iterator 的接口而且使用生成器函数去实现 iterator 方法会比之前方式要简单的多。

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

生成器最重要的作用还是,解决异步编程当中回调嵌套过深所导致的问题。

29、ES2016

新增数组的includes方法、指数运算符  **

30、ES2017

新增 Object 的三个扩展方法:Object.values(obj)、Object.entries(obj)、Object.getOwnPropertyDescriptors();两个字符串填充方法: String.prototype.padStart()、String.prototype.padEnd();在函数参数中添加尾逗号 ;Async/Await