美文网首页ES6
ES6~ES11 特性介绍之 ES6 篇

ES6~ES11 特性介绍之 ES6 篇

作者: 401 | 来源:发表于2021-01-04 09:09 被阅读0次

    原本想稍微整理一下 ES 新特性,没想到花了相当多的时间,本文也巨长,依然推荐使用 简悦 生成目录。
    原文竟然由于过长无法发布,第一次知道简书还有文章字数限制。现在只能拆成两篇发布。

    本系列文章

    ES6~ES11 特性介绍之 ES6 篇
    ES6~ES11 特性介绍之 ES7~ES11 篇

    一点历史

    JavaScript 与 Java

    1994 年,著名的网景公司 Netscape 成立。同年 12 月份,网景发布了 Netscape Navigator 浏览器 1.0 版本,Navigator 一经推出并占领了超过 90% 的市场份额。

    同时由于当时网费贵、网速慢,网景很快意识到他们需要一个能够运行在浏览器端的语言,将一些行为和逻辑放在浏览器端,从而减少与服务器端不必要的交互。而当时网景正好与刚推出 Java 的 Sun 公司有合作,即在浏览器中支持 Java 小程序(Java Applet)。所以网景考虑过选择 Java 作为浏览器端的语言,但由于 Java 对于浏览器来讲还是「太重」,所以最终还是决定创造一个「足够轻的脚本语言」,并且希望这门脚本语言的语法接近 Java

    1995 年,开发这个脚本语言的任务交给了当时任职于网景的程序员 Brendan Eich。1995 年 5 月,Brendan Eich 仅用了 10 天[1] 就完成了这门语言的第一版。 这门语言最初名为 Mocha,同年 9 月改名为 LiveScript。12 月份,网景公司和 Sun 公司达成协议,这门语言被允许叫做 JavaScript[2]12 月 4 日,两家联合发布了 JavaScript,并对外宣传是 Java 的补充,是轻量级 Java,且专门用来操作网页

    [1] 虽然这确实是一桩美谈,但也不可否认,过于简单、短促的发明过程使得 JavaScript 这门语言留下了不少设计上的缺陷。如块级作用域的缺失、模块化的缺失、nullundefined 的设计、== 的隐含转换等,这些都导致 JavaScript 自诞生以来就一直被吐槽和批评。实际直到今天,很多标准依然是在填补最初设计埋下的坑。
    [2] 网景公司可以借助当时 Java 的势头,Sun 也可以借此拓展在浏览器端的影响力

    从上面 JavaScript 的发明过程可以看出,在语言设计层面上,虽然在设计之初有着「接近或借鉴 Java」的初衷,但 JavaScript 依然存在诸多的不同,JavaScript 与 Java 确实可以说是两门完全不同的语言。但另一方面,在商业和历史层面两者又有着千丝万缕的关联

    这感觉简直就像是传说中的「异父异母的亲兄弟」。

    JavaScript 与 ECMAScript

    1996 年 8 月,与网景竞争的微软按耐不住,开发了自己的 JavaScript 即 JScript,并内置于自家的 IE 3.0 浏览器。

    于是网景开始展示操作,同年 11 月,网景就将 JavaScript 提交给了国际标准化组织 ECMA(European Computer Manufacturers Association,欧洲计算机制造商协会),也就是想尽快的使自家 JavaScript 成为浏览器脚本语言的标准,从而掌握浏览器脚本语言标准的主导权。 这一标准的制定任务最终交给了 ECMA 的 39 号技术委员会(Technical Committee 39,简称 TC39)

    1997 年 7 月,ECMA 推出了标准文件 ECMA-262 第一版,作为浏览器脚本语言的第一版。该标准实际上基本就是按照 JavaScript 来制定的,但由于根据当年网景与 Sun 公司的协议,JavaScript 这一名称只能由网景使用,同时也为体现该标准的制定者是 ECMA 而不是网景,以便保持语言后续的开放性、中立性,该语言最终被命名为 ECMAScript

    所以 ECMAScript 是 JavaScript 的标准与规范,JavaScript 是 ECMAScript 标准的实现。但在日常交流中,两者可以互换

    ECMAScript 的历史

    1997 年 7 月,ECMAScript 1.0 发布。次年即 1998 年 6 月,ECMAScript 2.0 发布。

    1999 年 12 月,ECMAScript 3.0 发布并受到广泛支持,奠定了 JavaScript 的基本语法。

    2000 年启动了 ECMAScript 4.0 的工作,2007 年 10 月 ECMAScript 4.0 的标准草案发布,原先预计次年 8 月发布正式版。

    但标准的制定者 TC39 内部对 ECMAScript 4.0 产生了严重分歧和争论
    Yahoo、Microsoft、Google 为首的大公司认为 ECMAScript 4.0 改动过大,反对一次性发布如此巨大的变动。而以 JavaScript 之父 Brendan Eich 为首的 Mozilla 公司则坚持当前的标准。

    最终结果是暂停 ECMAScript 4.0 的开发,但拿出本次讨论的一些小修改[3]作为 ECMAScript 3.1 发布,还充满内涵的将该版本取名为 Harmony(和谐)。但会后不久 ECMAScript 3.1 被重命名为 ECMAScript 5[4]

    [3] 主要是对原有特性的增强。
    [4] 根据当时的一些讨论: Announcement: ES3.1 renamed to ES5
    可以看出改名的原因应该是 ECMAScript 4.0 后期实现依然会很困难,如果不暂时跃过这个版本,后期发布可能会止步不前。但同时又希望保留 ECMAScript 4.0 草案用以指导后面的标准制定。所以就暂时跃过 4.0 将 ECMAScript 3.1 直接作为 ECMAScript 5 正式发布。
    ECMAScript 4.0 草案中的很多标准实际上在 ECMAScript 6.0 中得以实现,当然也有不少标准至今未实现。

    2009 年 12 月 ECMAScript 5 正式发布。2011 年 6 月 ECMAScript 5.1 发布。

    2015 年 6 月 ECMAScript 6 正式发布,由于是 2015 发布,所以 ECMAScript 6 也可以被称为 ECMAScript 2015。由于种种原因,从 ECMAScript 3.0ECMAScript 6 经过了大约 15 年的时间。但往后 ECMA 基本每一年都会发布一版新标准,按照年份依次类推即可:

    • ECMAScript 7(ECMAScript 2016)
    • ECMAScript 8(ECMAScript 2017)
    • ECMAScript 9(ECMAScript 2018)
    • ECMAScript 10(ECMAScript 2019)
    • ECMAScript 11(ECMAScript 2020)

    本文默认读者了解 JavaScript 的基本语法(即 ECMAScript 5.0),然后梳理和介绍从 ES2015 开始的新特性。

    特性列表

    ES6(ES 2015) 特性

    #01 let 与 const

    let 带来的能力主要有:

    • 块级作用域
    • 约束「变量提升」
    • 不允许重复声明
    1.1 块级作用域

    ES6 以前,JavaScript 只有全局作用域和函数作用域,这无异是一个极其糟糕的设计。这会带来很多不符合正常程序员直觉的现象:

    1. 内层变量覆盖外层变量
    function funcA() {
      var a = 1;
      if (true) {
        var a = 2;
      }
      
      console.log(a); // 输出为 2 
    }
    
    1. 循环变量泄漏为全局变量
    var s = "hello";
    for (var i = 0; i < s.length; i++) {
      console.log(s[i]);
    }
    
    console.log(i); // i 为全局变量,且输出为 5
    

    ES6 引入了 let 实现块级作用域:

    function funcA() {
      let a = 1;
      if (true) {
        let a = 2;
      }
      
      console.log(a); // 输出为 1
    }
    
    let s = "hello";
    for (let i = 0; i < s.length; i++) {
      console.log(s[i]);
    }
    
    console.log(i); // i is not defined
    

    关于块级作用域,这里需要额外补充函数的场景。

    在 ES5 标准中,在块级作用域内声明函数是非法[5]的。由于 ES6 标准引入了块级作用域,所以明确规定块级函数声明是合法的,并且声明的函数拥有块级作用域,即在代码块之外是不可访问的[6]

    [5] 但在一些浏览器的实际实现中,处于兼容旧代码的原因,块级函数声明是被允许的。
    [6] 这是正文中的规定,但是 ES6 的 附录 B 中指出浏览器在实现时可以不按照这个规定,原因依然是处于旧代码兼容性的考虑。

    1.2 约束「变量提升」

    let 除了实现块级作用域之外,还有一个非常重要的能力,即约束「变量提升」。

    所谓的「变量提升」指的是 JavaScript 的「变量可以在声明之前使用」的现象:

    // ES6 之前使用 var 声明的变量会发生「变量提升」
    // 脚本运行时,在未执行 a  = 2 之前,a 就已经存在并被初始化为 undefined
    // 如同变量声明被提升到了代码顶部一样
    console.log(a); // 输出 undefined
    var a = 2;
    

    「变量提升」的底层原因是 JavaScript 引擎在执行代码之前会对代码进行编译分析[7],这个阶段会将检测到的变量和函数声明添加到 JavaScript 引擎中名为 Lexical Environment 的内存数据结构中,并给予一个初始化值为 undefined。然后再进入代码执行阶段。所以在代码执行之前,JS 引擎就已经知晓声明的变量和函数。

    [7] 并不是编译型语言的编译,而是指对代码进行词法分析、变量函数的检测等工作的阶段 。

    这种 var 变量具有的「变量提升」现象无疑是诡异的,所以 let 声明的变量将约束「变量提升」。

    之所以说约束,是因为 let 声明的变量并没有真正取消上述「变量提升」的过程,只是作出了一个关键变更。即将 let 变量添加到 Lexical Environment 后不再进行初始化为 undefined 的操作,JS 引擎只会在执行到词法声明和赋值时才进行初始化。而在变量创建到真正初始化之间的时间跨度内,它们无法访问或使用,ES6 将其称之为暂时性死区( Temporal Dead Zone)。如下所示:

    function funcA() {
       // 代码运行前,a 其实已经被添加到 Lexical Environment 内存数据结构中
      // 但由于 a 未被初始化,所以 JS 引擎将禁止访问它
      // 暂时性死区 TDZ 开始
      a = 'abc';               // ReferenceError
      console.log(a);   // ReferenceError
    
      let a;                     // JS 引擎遇到词法声明,将 a 初始化为 undefined。暂时性死区 TDZ 结束
      console.log(a) ;  // 可以正常访问 a
    }
    
    1.3 不允许重复声明

    在 ES6 之前,var 对同一个作用域内的重复声明没有限制,甚至可以声明与参数同名的变量,如:

    function funcA() {
      var a = 1;
      var a = 2;
    }
    function funcB(args) {
      var args = 1; // 不会报错
    }
    

    let 修复了这种不严谨的设计:

    function funcA() {
      let a = 1;
      let a = 2; // 报错,a 已经被声明过
    }
    function funcB(args) {
      let args = 1; // 报错,args 已经被声明过
    }
    

    const 同样拥有上述的三个特点,与 let 的不同的是它具有「只读」的语义,一旦声明初始化,不可再改变它的值。

    但 const 的本质是保障变量所指向的内存地址里的数据不可改变

    对于数值、字符串、布尔值等简单类型,对应的值就存在变量所指向的地址里,所以使用 const 修饰可以保障其值不被修改。但对于对象、数组等复合类型,变量指向的内存地址保存的是指向实际数据的指针,此时 const 修饰对象、数组并不能保障对象里的成员变量或数组内的元素不被改变。

    如果想使对象不可变,可以使用 Object.freeze

    #02 变量解构

    2.1 数组解构
    2.1.1 基本语法

    ES6 引入了一种从对象、数组等数据或表达式中提取值并赋值给变量的语法糖。例如以前从数组中提取值并赋值给变量,需要编码如下:

    const arr = [1, 2, 3];
    const a = arr[0];
    const b = arr[1];
    const c = arr[2];
    

    解构语法将简化操作:

    const [a, b, c] = [1, 2, 3];
    

    es6 实现对 [1, 2, 3] 解构并依次赋值变量 a、b、c。数组的解构赋值按照位置将值与变量对应

    2.1.2 不完全解构

    数组解构可以实现不完全解构,如下所示:

    // 提取除第一个元素外的其他元素
    const [, b, c] = [1, 2, 3];
    // 提取除第二个元素外的其他元素
    const [a, , c] = [1, 2, 3];
    // 只提取最后一个元素
    const [, , c] = [1, 2, 3];
    

    如果解构时对应的位置没有值,则变量将被赋值为 undefined

    const [a, b, c, d] = [1, 2, 3];
    console.log('undefined d:', d);  // d = undefined;
    
    2.1.3 rest 操作符 ...

    可以使用 rest 操作符 ... 来捕获剩余项:

    const [a, ...b] = [1, 2, 3];
    
    console.log('rest a: ', a); // a = 1
    console.log('rest b', b);   // b = [2, 3]
    
    2.1.4 嵌套数组解构

    可以对复杂嵌套的数组进行解构:

    const [a, [inner_1, inner_2], c] = [1, [2, 3], 4];
    // a = 1; inner_1 = 1; inner_2 = 2; c = 3;
    console.log(a, inner_1, inner_2, c);
    
    2.1.5 支持默认值

    解构时可以制定默认值:

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

    注意必须是对应的值严格等于即=== undefined 时才会使用默认值,例如 null 并不会触发默认值:

    // 不触发默认值 1,null 被正常解构并赋值给 x。
    const [x = 1] = [null];
    console.log('x:', x); // x = null;
    

    默认值可以使用表达式,且此时的表达式为惰性求值

    const inner_func = () => 2;
    const [y = inner_func()] = [undefined];
    
    console.log('default_value y:', y);  // y = 2;
    
    2.2 对象解构
    2.2.1 基本语法

    解构同样可以作用于对象

    const person = { name: "zhang", sex: "female", age: 18 };
    const {name: name1, sex: sex1, age: age1} = person;
    console.log(name1, sex1, age1);  // name1 = "zhang"; sex1 = "femail"; age1 = 18;
    

    数组按照位置进行匹配赋值,而对象按照模式进行匹配。在上述代码 {name: name1, sex: sex1, age: age1} 中,符号 : 左边为匹配的模式,符号 : 右边为被赋值的变量名称。即 const {name: name1, sex: sex1, age: age1} = person 的语义为取出对象 person 中的 name 字段赋值给 name1 变量...

    当然可以将变量名起名为 name

    const {name: name, sex: sex, age: age} = person;
    

    此时可以对上述写法进一步简化:

    // name = person.name;
    // sex = person.sex;
    // age = person.age;
    const {name, sex, age} = person;
    
    2.2.2 嵌套解构赋值

    复杂的嵌套对象依然可以使用解构语法进行解构赋值:

    const person = { name: "zhang", sex: "female", age: 18, addr: {city: "shenzhen", street: "street1"}};
    const {addr: {city, street: default_street}} = person;
    
    console.log('city:', city, 'street:', default_street);  // city = "shenzhen"; street = street1;
    

    再结合上述的嵌套数组,对于更为复杂的嵌套数组和对象的结构也可以进行解构赋值:

    const person = {
      name: "zhang",
      sex: "female",
      age: 18,
      more_infos: [
        {
           company: "xxx_company"
        },
        {
          addr: {city: "shenzhen", street: "street1"}
        }
      ]
    };
    
    const {more_infos: [{ company }, { addr: {city: city1}}]} = person;
    console.log('company:', company, 'city1:', city1); // company = "xxx_company"; city1="shenzhen";
    
    2.2.3 支持默认值

    对象解构同样支持默认值,且对象解构默认值的生效条件同样是:=== undefined,所以 null 会被经常解构赋值而不会应用默认值:

    const person = { name: "zhang", sex: "female", age: 18 };
    const {interest1="watch moive"} = person;
    console.log('interest1', interest1);  // interest1 = "watch moive"
    
    const {interest2="watch moive"} = {interest2: null};
    console.log('interest2', interest2);  // interest2 = null
    
    2.2.4 对已声明变量进行解构赋值

    上文的所有示例代码在进行解构赋值都是配合 letconst 进行的,即声明解构赋值 同时进行。如果直接对已经声明的变量进行赋值将会报错:

    const person = { name: "zhang", sex: "female", age: 18 };
    let age;
    
    { age } = person;  // 报错
    

    上述代码是非法的,因为 JS 引擎在缺乏letconstvar 关键词时,将会把 {age} 理解为代码块从而导致语法错误。

    解决的方法是加上圆括号,如下所示:

    const person = { name: "zhang", sex: "female", age: 18 };
    let age;
    ({age} = person);  // age=18;
    

    给某个已存在的变量赋值可能就经常遇到:

    const me = {
        age: undefined
    };
    
    // 从 person 解构并给 me.age 赋值
    ({age: me.age} = person);
    console.log('me', me); // me = {age: 18};
    
    2.3 其他类型解构
    2.3.1 字符串解构

    可以对字符串解构(字符串将被当成数组):

    const [a, b, c] = 'lcy';
    console.log(a, b, c); // a = 'l'; b = 'c'; c = 'y';
    

    捕获字符串属性:

    const {length : len} = 'lcy';
    console.log('lcy\'s length: ', len);  // len = 3; 
    
    2.3.2 数值和布尔值解构

    对数值和布尔值进行解构时,它们将会先被转为对象,然后再应用解构语法:

    const {toString: number_tostring} = 123;
    // number_tostring 为 Number 的 toString 方法
    console.log(number_tostring === Number.prototype.toString);  // true
    
    const {toString: bool_tostring} = true;
    // bool_tostring 为 Boolean 的 toString 方法
    console.log(bool_tostring === Boolean.prototype.toString);  // true
    
    2.4 函数参数解构

    可以对函数的参数进行解构:

    const inner_func = ([x, y, z]) => {
      return x + y + z;
    }
    
    const sum = inner_func([1, 2, 3]);
    console.log('sum:', sum);  // sum = 6;
    

    函数的参数解构可以给我们带来更好的编程体验和风格。

    在平时编程时,函数的参数过多不是一个好的设计,例如:

    const inner_func = (isOpen, isHook, names, ops, ...) => {
    }
    
    // 调用
    inner_func(true, false, [1, 2], [1], ...);
    

    上述代码中 inner_func 的调用具有一定的心智负担,参数较多较混乱,调用者较容易传错参数

    所以我们经常使用一个对象来承接过多的参数,如下所示:

    const inner_func = (options) => {
      const isOpen = options.isOpen || 'false'; // 使用 || 赋予默认值 
      const isHook = options.isHook || 'true'; 
      const names = options.names || [];
      const ops = options.ops || [];
      // ....
    }
    

    上述的代码使函数调用更为清晰和聚焦,但也存在一定的缺点,即 inner_func 函数的实现不够优雅,函数实现时无法直观的了解 options 内对象。

    此处可以用解构语法,优化上述代码:

    const inner_func = ({isOpen=false, isHook=true, names=[], ops=[]}) => {
      // 马上使用 isOpen、isHook、names、ops 等  
    }
    

    还可以利用函数作为默认值,来实现一些更为灵活的能力,例如实现函数的必填参数

    function requiredParam(param) {
      throw new Error(`Error: Required parameter ${param} is missing`);
    }
        
    const inner_func4 = ({id=requiredParam('id'), isOpen=false, isHook=true, names=[], ops=[]}) => {
      // ...
    }
        
    inner_func4({});  // 如果不指定 id,inner_func4 将会报错
    
    2.5 解构语法的用途举例

    解构语法可以灵活运用,除了上文中已提及的代码,这里再举一些常用的用途。

    解构 Map

    const map = new Map();
    map.set('first', 'hello');
    map.set('second', 'world');
    
    // 配合 for...of 进行解构赋值
    for (let [key, value] of map) {
      console.log(key + " is " + value);
    }
    // 仅获取键名
    for (let [key] of map) {
      // ...
    }
    // 仅获取键值
    for (let [,value] of map) {
      // ...
    }
    

    解构模块

    const { UserUtils, AdminUtils } = require("utils");
    

    解构函数返回值

    function inner_func() {
      return [1, 2, 3];
    }
    
    const [a, b, c] = inner_func();
    

    #03 类 Class

    3.1 ES6 之前的对象

    JavaScript 在设计之初就引入了「面向对象」的设计理念。但由于设计过程十分仓促,所以比起 Java/C++ 等语言,JavaScript 「面向对象」的实现并不是很严谨。

    在 ES6 之前,JavaScript 的对象体系并非基于「」,而是基于「函数」与「原型链」,如下所示:

    // 为了与普通函数做区分
    // 在编程规约上,函数名称首字母打斜
    const Dog = function(name, sex, age) {
      // this 关键字表示生成的对象实例本身
      // 但必须要配合 new 关键字才能真正指向对象实例
      this.name = name;
      this.sex = sex;
      this.age = age;
    }
    
    const dog1 = Dog();       // dog1 = undefined
    const dog2 = new Dog();   // dog2 = { name: 'mimi', sex: 'male', age: 1 }
    

    如上述代码所示,Dog 函数 可以被视为普通函数进行调用即 const dog1 = Dog();,这时候 dog1undefined,因为 Dog 完全就是一个普通函数,在这个函数内没有 return 任何值,并且其中的 this 指向当前的运行时上下文(全局对象) 。

    如果 Dog 函数 想要成为一个能够创建对象实例的「构造函数」,则必须要配合 new 关键字即 const dog2 = new Dog();,其中new 关键字主要完成了如下工作:

    • 创建一个空对象,作为即将返回的对象实例
    • 将这个空对象的原型指向「构造函数」的 prototype 属性
    • 将这个空对象赋值给函数内部的 this 关键字
    • 执行「构造函数」内的代码,例如 this.name = name,将 value 赋值给当前对象属性
    • 如果「构造函数」内写了 return 语句且返回的是一个对象类型,则 new 将返回该对象。否则 new 将默认返回 this 即当前对象

    上述步骤的第二步涉及到的 「指向构造函数的 prototype 属性」就是 JavaScript 实现对象体系的关键:

    1. 构造函数具有 prototype 属性,指向构造函数对应的原型(可理解为其他语言中的「类」)
    2. 所有通过构造函数创建的实例对象,会存在一个默认属性指向上述的原型(浏览器在具体实现时这个属性通常命名为 proto
    3. 上述的原型本身又会有 __proto__ 属性,指向原型的「原型」(上述 Dog 函数的原型的原型就是 顶层原型 Object)

    这样通过 __proto__ 就会形成一条「原型链」,通过「原型链」就可以找到对象实例对应「类」以及「父类」和「父类的父类」[8]...

    [8] 严格来讲类和原型还是有所不同的。原型也被称为「原型对象」,所以原型本质上依然是一个对象。这里说「类」仅仅是为了理解方便。

    由上可知,如果想要实现继承,需要将自己挂在父类的原型链之下:

    // 第一步:继承构造函数
    const Husky = function(name, sex, age, isStupid) {
      Dog.call(this, name, sex, age);
      this.isStupid = true;
    }
    // 第二步:子类的原型指向父类的原型
    Husky.prototype = Object.create(Dog.prototype);
    Husky.prototype.constructor = Husky;  // 修正原型 constructor 的指向
    
    // 测试子类
    const husky1 = new Husky('husky', 'male', 2);
    // Dog 添加 sayHi 函数
    Dog.prototype.sayHi = function() { console.log('wangwang'); }
    console.log('husky1', husky1);
    husky1.sayHi();  // 输出 wangwang,Dog 原型拥有 sayHi() 方法
    console.log('husky1 toString', husky1.toString());  // Object 原型拥有 toString() 方法
    

    在访问上述代码中的 Husky 实例 husky1 的属性和方法时,会首先查询 husky1 本身的属性和方法,如果找不到则会查找其原型(Dog),如果还未找到,则继续查找原型的原型(Object),即按照原型链依次查找。

    除了上述例子外,ES6 之前的继承还可以有其他写法,这里不再扩展。

    3.2 ES6 的 class
    3.2.1 基本语法

    由上不难看出 ES6 之前的「面向对象」写法复杂,不够直观。于是 ES6 引入了类 Class 的概念,使得 JavaScript 终于可以像其他语言那样声明类了:

    class Dog {
      /**
        * 构造函数,相当于之前的 function Dog()
        * 如果不显式定义,则会默认添加一个空的 constructor() {}
        **/
      constructor(name, sex, age) {
        this.name = name;
        this.sex = sex;
        this.age = age;
      }
      
      /** 自定义成员函数 */
      sayHi() {
        console.log("wangwang");
      }
    }
    

    实际上 ES6 的 class 可以视作一种语法糖,只是在语法层面提供了将「构造函数」和「自定义原型属性」写到一起的封装写法。底层的对象体系依然是基于函数和原型链。如上例代码中将 sayHi() 定义在 class 块内,相当于执行了 Dog.prototype.sayHi = function() { console.log('wangwang'); }class 的引入也使得对象的使用更为严谨,传统定义的 Dog 函数,可以不加 new 从而被视为普通函数,而 class 定义的 Dog 必须通过 new 进行调用。同时注意 class 关键字和 letconst 等一样,约束了变量提升。

    classfunction 一样有表达式的写法:

    // DogInner 对外不可见
    const Dog = class DogInner { /* ... */ };
    const Dog = class { /* ... */ };
    
    // 甚至还可以有立即执行的 class
    const dog1 = new class {
      constructor(name) {
        this.name = name;
      }
    }('旺财');
    
    3.2.2 静态方法

    类是对象的模板,而对象是类的实例。由类生成对象时,对象应当具有类中定义的方法和属性。但实际上,并不是类中的所有方法、属性都应该赋予对象

    例如现有一个类 Person,而 小红小黄Person 的两个实例化对象。Person 类中有属性 planet = earth,那么包括小红小黄在内的任何 Person 实例都应该拥有同样的属性值 planet = earth

    每个实例都拥有的共同属性比起被每个实例继承,不如将其视为 Person 这个类本身的属性,即不是 小红.planet小黄.planet 而是 Person.planet 。这样的属性称之为静态属性,类似的方法称为静态方法。但遗憾的是 ES6 还未支持静态属性,但实现了静态方法。静态方法使用 static 关键字修饰:

    class Dog {
      // ....
    
      static showPlanet() {
        console.log("汪星球");
      }
    }
    
    // 类名.方法名 的形式进行调用
    Dog.showPlanet();
    
    3.2.3 class 继承

    在上文的 3.1 节中介绍了 ES6 之前需要手动修改原型链才能实现继承,ES6 引入 extends 来使得继承更为清晰:

    class Husky extends Dog {
      // 如果没有显式定义 constructor
      // 则会自动添加一个默认构造函数:
      // constructor(...args) {
      //   super(...args);
      // }
      constructor(name, sex, age, isStupid) {
        super(name, sex, age); // 调用父类的 constructor(name, sex, age);
        this.isStupid = isStupid;
      }
    }
    

    关于 super

    • 作为函数:即 super(...),super 函数代表父类的构造函数,只能用在子类构造函数中。例如:
    class Husky extends Dog {
      constructor(name, sex, age, isStupid) {
        super(name, sex, age); // 相当于调用 Dog.prototype.constructor.call(this, name, sex, age);
        this.isStupid = isStupid;
      }
    
      sayHi() {
        super();  // 错误,不可在普通方法内调用 super();
      }
    }
    
    • 作为对象:即 super.xxx,super 对象在普通方法中指向父类的原型对象如 Dog.prototype,在静态方法中,指向父类。例如:
    class Husky extends Dog {
      constructor(name, sex, age, isStupid) {
        super(name, sex, age);
        this.isStupid = isStupid;
      }
    
      sayHi() {
        // 相当于 Dog.prototype.sayHi.call(this)。在普通方法内,super 表示父类的原型对象。
        super.sayHi(); 
      }
    
      static showPlanet() {
        // super 表示父类,此处调用了父类的静态方法
        // 注意类的静态方法挂在「类」下,即 Dog.xxx
        // 类的普通方法和构造函数挂在「类的原型对象」下,即 Dog.prototype.xxx
        super.showPlanet(); 
      }
    }
    

    #04 模块 Module

    Brendan Eich 可能一开始只是抱着开发玩具语言的心态创造了 JavaScript,所以他基本想象不到 JavaScript 后续会成为前端领域的标准语言,他更没法预料到,随着 JavaScript 的发展和推广,这门语言开始被用来编写越来越复杂和庞大的系统。如果他一开始能够意识到这一点,我相信他一定会在最开始就提供模块的能力,因为复杂系统的开发离不开模块化的支持。

    正因为 JavaScript 在设计之初就缺失了模块体系,所以导致开发人员在后续十多年都在不断探索和自行推动模块化规范。从「函数封装」到「对象封装」再到「立即执行函数 IIFE」,以及后来的 CommonJS 规范、AMD 规范、CMD 规范。虽然其中很多都已经成为历史,但这些五花八门的模块化方案和规范蕴含了开发人员的努力和智慧。关于 JavaScript 模块化的演化过程和历史可以参见 JavaScript 模块化的前世今生

    虽然在过去长期的工程实践中,「民间」已经自行推出了不少模块化规范,有些还得到充分的实现和发展。但我们最终期待的还是由官方制定的统一的模块化方案,这一期待终于在 ES6 中得以实现。

    ES6 的模块化规范也被称为 ESM,下文就以名称表述 ES6 的模块化规范。

    4.1 模块导出
    4.1.1 基本语法

    ESM 中的一个模块就是一个文件,并通过 export 关键字实现模块导出:

    /* utils.js */
    export const _name = 'utils';
    export const _desc = 'utils for dog and cat';
    export function HandleDog() {/*...*/}; 
    export function HandleCat() {/*...*/}; 
    

    上述代码对外导出了 _name_desc 变量以及 HandleDogHandleCat 函数,export 还有另一种写法:

    const _name = 'utils';
    const _desc = 'utils for dog and cat';
    const HandleDog = function() {/* ... */};
    const HandleCat = function() {/* ... */};
    
    export { _name, _desc, HandleDog, HandleCat };
    // 导出非 default 变量时必须加括号,以下语法是错误的
    // 错误用法:export _name;
    // 错误用法:export 123; 
    
    4.1.2 导出变量重命名

    可以使用 as 关键字对导出的变量重命名:

    // as 重命名时,同一个变量如 _name 可以赋予两个不同名字
    export {
      _name as name,
      _name as another_name,
      _desc as desc,
      HandleDog as handleDog,
      HandleCat as handleCat,
    };
    
    4.1.3 默认导出

    上文通过 export 导出的变量都是有名字的,那么在使用下文即将介绍的 import 时就需要指定名称,如:

    import { name } from '模块';
    

    但有时候模块的使用者希望模块能有一个「默认输出」,即:

    import xxx from '模块';
    xxx();
    

    其中 xxx 不是模块内部的某个变量名,而是在 import 未指定变量名时返回的默认输出,这个默认输出被起名为 xxx。如果这个默认输出是个函数,则可以像上述代码那样直接进行调用 xxx()

    这种默认输出可以在一些场合简化模块的使用,而在模块内我们使用 export default 命令指定本模块的默认输出。

    const _name = 'utils';
    const _desc = 'utils for dog and cat';
    const HandleDog = function() {/* ... */};
    const HandleCat = function() {/* ... */};
    const HandleDefault = function() {/* ... */};
    
    // export default 无需添加大括号 {}
    // 相当于将 HandleDefault 赋值给 default 变量
    export default HandleDefault;
    

    export default 进行默认导出无需添加大括号 {},相当于将导出值如上面的 HandleDefault 函数 赋值给 default 变量。同时要注意到一个模块只有一个「默认输出」,所以 export default 只能使用一次。

    4.2 模块导入
    4.2.1 按名称导入

    ESM 使用 import 命令实现导入加载模块:

    // 获取 utils 模块导出的变量
    import { name, another_name, handleDog, handleCat } from './utils.js';
    
    // 如上所示,使用 import 导入时,括号内的变量名需要和 utils 模块导出的变量名相同
    // 当然也可以在导入的同时使用 as 起一个新名称
    import { name as old_name, another_name as new_name } from './utils.js';
    
    4.2.2 整体导入

    上面的导入语法是将需要的变量依次导入,如果想将模块导出的所有变量作为一个整体一次性导入到一个对象,可使用 * 实现整体加载:

    // .utils.js 模块中通过 export 导出的所有变量将都挂到 Utils 之下
    import * as Utils from './utils.js';
    console.log(Utils.name);
    console.log(Utils.another_name);
    Utils.handleDog();
    // ...
    
    4.2.3 默认导入

    上文已经提及「默认导出」无需使用 {},而在导入「默认导出」时也不需要使用 {}

    因为一个模块只有一个「默认输出」,且对应的变量名为 default,所以在导入时无需指定特定名称,还可以直接为其自定义一个任意名称:

    // 模块内导出的 HandleDefault 已经被赋值为默认 default,所以导出的是 default 变量名
    // 获取模块的默认 default 时无需通过模块内的定义特定名称获取
    // 直接给这个默认 default 自定义一个任意名称即可,例如 HandleOther
    import HandleOther from './utils.js';
    
    4.2.4 import 的一些特性
    1. import 导入的变量是只读的,不可直接覆盖导出的变量,例如:
    import { name, another_name, handleDog, handleCat } from './utils.js';
    
    // 模块导出的变量为 const 变量
    name = 'new utils';  // 错误,name 是只读的
    handleCat.new_name = 'new name';  // 如果导出的是对象类型,可以在该类型上添加属性。但不建议这么做
    
    1. import 具有提升效果:
    handleDog(); // 合法,因为下面的 import 语句在编译期(语句分析阶段)就被执行了,早于 `handleDog 函数` 的调用。
    
    import { name, another_name, handleDog, handleCat } from './utils.js';
    
    1. import 编译期(语句分析阶段)执行,所以是「静态」的,无法使用表达式、变量等运行时才可确定的值:
    import { 'n' + 'ame' } from './utils.js';  // 错误
    
    const module_name = './utils.js';
    import { name } from module_name;  // 错误
    
    1. 重复加载,模块语句只会被执行一次:
    // 加载三次,./utils.js 模块内的语句只会被执行一次
    import { name } from './utils.js';
    import { handleDog } from './utils.js';
    import { handleCat } from './utils.js';
    
    // 上面语句效果与下面语句是等价的
    import { name, handleDog, handleCat } from './utils.js';
    
    1. 导入的是原始值的引用
      import 导入的值是对模块内原始值的引用,这意味着不同文件导入和值和原始值相互影响:
    import { name, changeName } from 'utils';
    
    console.log(name);  // utils
    changeName('changed');
    
     // name 为指向模块内部原始值的引用,原始值被修改,name 变量也会跟着变化
    console.log(name);  // changed
    
    4.3 导入导出混合写法

    在一些场景中,我们可能需要在某个文件里导入某些模块,同时将这些模块再导出。这个过程中可能都不会使用导入的模块,只是起到模块转发的作用。此时可以使用上文介绍的 exportimport 语法混合在一起的特殊语法:

    // 「导入」又立马「导出」,实际是「转发」
    export { name } from './utils.js';
    // 上面语句等同于
    import { name } from './utils.js';
    export { name };
    

    整体转发:

    // 整体「转发」
    export * from './utils.js';
    

    默认转发:

    // 直接转发原有默认输出
    export { default } from './utils.js';
    
    // 转发时修改默认输出,将 name 作为默认输出
    export { name as default } from './utils.js';
    
    // 等同于
    import { name } from './utils.js';
    export default name;
    

    #05 函数扩展

    ES6 对函数进行了语法、功能等扩展,包括参数默认值、箭头函数、尾调用优化等。

    5.1 支持参数默认值

    ES6 以前,不能为函数的参数设置默认值,ES6 实现了对此的支持:

    function getPoint(x = 0, y = 0) {
      console.log(x, y);
    }
    
    getPoint(1, 2);
    

    undefined 才可触发默认值:

    function getPoint(x = 0, y = 0, z = 0) {
      console.log(x, y, z);
    }
    
    getPoint(, , 3);  // 错误,不可省略前面两个参数
    getPoint(undefined, undefined, 3); // 正确,使用 undefined 触发默认值
    getPoint(1, 2);  // 默认值可实现参数的省略
    

    由上例代码中的 getPoint(1, 2) 可知,参数默认值可以实现参数的省略,简化函数的调用。但这通常是建立在「设置默认值的是尾部参数」这个前提,如果是为非尾部参数设置默认值,调用时参数不能被省略,需要使用 undefined 填充触发默认值。

    参数默认值稍微复杂点的场景是和解构语法一起出现:

    function getPoint({x = 0, y = 0}) {
      console.log(x, y);
    }
    
    getPoint({x: 1, y: 2});
    

    引入参数默认值之后,有几点改变需要特别注意:

    1. 函数的 length 属性

    函数的 length 属性本意为:「该函数期待的参数个数」。在参数默认值被引入之前,可以通过 length 获得该函数定义的参数个数,但是引入默认值之后,length 表达的是「第一个默认值参数之前的普通参数个数(length 也不统计 rest 类型参数)」。如下所示:

    const funcA = function(x, y) {
    };
    console.log(funcA.length);  // 函数期待 x,y 两个参数,则长度输出 2 
    
    const funcB = function(x, y = 1) {
    };
    console.log(funcB.length);  // y 提供了默认值,length 表示 y 之前有几个非默认值参数,长度输出为 1
    
    const funcC = function(x = 1, y) {
    };
    console.log(funcC.length);  // 输出为 0 
    
    1. 参数作用域
      设置了参数默认值后,将导致一个与以前不同的行为:参数在被初始化时将形成一个独立作用域,初始化完成后作用域消解。看如下代码并可理解:
    let x = 1;
    
    // y 设置了默认值
    // 则 funcA 被调用时,其中的参数 x, y 将形成一个独立的作用域
    // 所以 y = x 中的 x 是第一个参数 x,而不是上面定义的 let x= 1;
    function funcA(x, y = x) {
      console.log(y);
    }
    
    // 在参数作用域中,y = x 语句的 x 是参数 x 的值,即 2 而不是上面的 let x = 1,所以最终输出为 2
    funcA(2);  
    
    5.2 箭头函数
    5.2.1 箭头函数基本语法

    ES6 引入了「箭头函数」的语法来简化函数的定义,基本语法如下所示:

    // 1. 不传入参数
    const funcA = () => console.log('funcA');
    // 等价于
    const funcA = function() {
      console.log('funcA');
    } 
    
    // 2. 传入参数
    const funcB = (x, y) => x + y;
    // 等价于
    const funcB = function(x, y) {
      return x + y;
    } 
    
    // 3. 单个参数的简化
    const funcC = (x) => x;
    // 对于单个参数,可以去掉 (),简化为
    const funcC = x => x;
    // 等价于
    const funcC = function(x) {
      return x;
    }
    
    // 4. 上述代码函数体只有单条语句,如果有多条,需要使用 {}
    const funcD = (x, y) => { console.log(x, y); return x + y; }
    // 等价于
    const funcD = function(x, y) {
      console.log(x, y);
      return x + y;
    }
    
    5.2.2 箭头函数的重要特性
    1. 不绑定 this

    有经验的前端开发可能经常会写这样的代码 const self = this;const that = this; 看如下代码:

    class Dog {
      constructor(age) {
        this.age = age;
      }
    
      printAge() {
        console.log('output age: ', this.age);  // 输出 10
        setTimeout(function inner() {
          this.age++;
          console.log('output age: ', this.age);  // 输出 undefined
        }, 1000);
      }
    }
    
    let dog = new Dog(10);
    dog.printAge();
    

    在传统定义的函数中,函数内的 this 指向的是运行时的上下文环境。例如上例代码中的 inner 函数在 1 秒后运行,运行时函数内的 this 指向是运行时所在的环境对象即 window,由于 window.a 未定义,所以最终输出的是 undefined。如果希望得到预期的结果,即对 Dog 实例的 age 属性进行操作,则需要避开 this 关键字,使用其他名称来传递「定义时的上下文环境」,如下所示:

    class Dog {
      constructor(age) {
        this.age = age;
      }
    
      printAge() {
        const that = this;  // 使用 that 传递 dog 实例上下文
        console.log('output age: ', this.age);  // 输出 10
    
        setTimeout(function inner() {
          that.age++;
          console.log('output age: ', that.age);  // 输出 11
        }, 1000);
      }
    }
    
    let dog = new Dog(10);
    dog.printAge();
    

    上述现象经常被诟病,但引入箭头函数之后,由于箭头函数不再在函数体内定义和绑定 this ,所以在箭头函数内写 thisthis 指向的就是定义箭头函数所在的上下文,而不是运行时的上下文,如下所示:

    class Dog {
      constructor(age) {
        this.age = age;
      }
    
      printAge() {
        console.log('output age: ', this.age);  // 输出 10
        // this
        setTimeout(() => {
          this.age++;  // 由于箭头函数内部没有 this,所以这里的 this 就是 printAge 函数的 this (也就是 dog 实例)
          console.log('output age: ', this.age);  // 输出 11
        }, 1000);
      }
    }
    
    let dog = new Dog(10);
    dog.printAge();
    
    1. 不绑定 arguments

    普通函数内,实际传入的参数会被绑定到一个内置变量 arguments 中,我们可以从这个变量中获取参数:

    const printAge = function(age) {
      console.log('age: ', arguments[0]);  // 输出 age: 10
    } 
    
    printAge(10);
    

    但箭头函数不会绑定 arguments,如下所示:

    const printAge = age => console.log('age: ', arguments[0]);  // 错误:arguments is not defined
    
    printAge(10);
    
    1. 不可使用 yield

    箭头函数内部不能使用 yield 即不可作为函数生成器 Generator,有关 Generator 可查阅下文的 Generator 部分。

    1. 不可作为构造函数

    上面的第一点已经提及箭头函数缺乏 this,这自然导致箭头函数不能作为构造函数,不可搭配 new 关键字:

    const Dog = () => 1;
    let dog = new Dog();  // 错误,箭头函数不可作为构造函数,不可搭配 new 关键字
    

    由于不能作为构造函数,所以也没有相应的 prototype 属性:

    const Dog = () => 1;
    console.log('Dog prototype', Dog.prototype);  // undefined
    
    1. 不可使用 call()apply()bind() 等函数
      由于缺乏 this,一些依赖于 this 的函数自然也不再适用:
    this.age = 10;
    const printAge1 = function() {
      console.log('age1: ', this.age); 
    }
    
    printAge1.call({age: 20});  // 输出 age1: 20
    
    const printAge2 =() => console.log('age2: ', this.age); 
    printAge2.call({age: 20});  // 输出 age2: 10 
    
    5.3 尾调用优化
    5.3.1 何为尾调用

    所谓的「尾调用」是指这样的情形:一个函数的最后一步返回一个「函数调用」,看代码会更加的清晰:

    function funcA() {
      // ....
      return funcB();  // funcA 函数的尾部,调用了函数 funcB,并且直接返回了 funcB 的执行结果
    }
    

    如果尾部进行了除函数调用外不纯粹的动作,则不应作为尾调用考虑:

    function funcA() {
      // ....
      return funcB() + a; // 除了调用 funcB 之外,还需要将结果 + a 才返回
    }
    
    function funcA() {
      // ...
      let b = funcB();
      return b == 0 ? 1 : b;  // 除了调用 funcB,返回值还需要进行加工判断
    }
    

    当上面尾部函数调用为「自身调用」时,此时的尾调用也就成为了「尾递归」。

    5.3.2 何为尾调用优化

    我们知道一个函数调用的正常流程,例如:

    function funcA() {
      return funcB();
    }
    

    当执行到 funcB() 代码时,大体将进行如下步骤:

    1. 保存 funcA 函数的上下文环境到内存栈
    2. 创建 funcB 函数所需要的上下文环境
    3. 切换到第二步创建的函数上下文环境去执行
    4. 执行完毕后回到栈中保存的 funcA 上下文

    我们思考这样一个问题:当 funcB 为尾调用时,我们是否还有必要再单独创建一个新的上下文环境?

    由于 funcB 函数执行完毕后回到 funcA 后,仅剩的工作就是返回 funcB 的执行结果,而这个过程中 funcA 的上下文环境中的大部分东西是不被需要的,那么完整的保存 funcA 的上下文环境也就没有太大必要。那么我们可以尝试直接复用 funcA 的上下文环境,稍作修改将其作为 funcB 的上下文。于是步骤就变成:

    1. 如果 funcA 是尾调用,那么执行到最后一步时,直接修改 funcA 函数的上下文环境,将其作为 funcB 函数的执行上下文
    2. funcB 执行完毕后直接返回结果

    这样的优化过程被称为尾调用消除(Tail Call Elimination)尾调用优化(Tail Call Optimization, TCO)

    尾调用优化注意点:

    1. C++ 中的尾调用优化
      在 C++ 中,在返回之前可能还涉及到返回值的析构操作,所以 funcB 在 C++ 可能不是最后被执行的函数,这样也就无法应用尾调用优化,解决方案是应用 C++ 的「返回值优化」
    2. 尾调用优化是否支持取决于编译器或解释器。目前并不是所有的编译器或解释器都支持此优化。
    5.4 函数的 name 属性

    ES6 之前,浏览器在实现时已经提供了函数的 name 属性,ES6 则是正式将其写入标准中:

    function funcA() {};  // name = "funcA"
    const funcB = function funcA() {};  // name = "funcA"
    
    // 与之前的浏览器实现略有不同的点:
    const funcA = function() {};  // 不具名函数表达式,ES5 中 name = "",ES6 中 name = "funcA"
    

    #06 其它数据类型的扩展

    6.1 Symbol

    ES6 以前有六种基本数据类型:undefinednull布尔(Boolean)数值(Number)字符串(String)对象。ES6 引入了第七种基本数据类型 Symbol,Symbol 表示一个独一无二的值,主要用于对象属性的标识。

    在 ES6 之前,对象属性存在产生冲突的可能,例如:

    import { Dog } from 'dog.js';
    
    Dog.sayHi = function() {/* ... */};  // Dog 内部可能已经拥有 sayHi 属性
    

    Symbol() 函数可以为我们生成一个独一无二的 Symbol 值,如:

    let s = Symbol();
    
    console.log(typeof s);  // "symbol"
    

    可以传入参数作为 Symbol 实例的描述,但同一个描述返回的仍然是不同的 Symbol 值:

    let s1 = Symbol("dog");
    let s2 = Symbol("dog");
    
    console.log(s1 === s2); // false
    

    有了 Symbol,我们并可为对象设置一个不会冲突的属性:

    import { Dog } from 'dog.js';
    
    let s1 = Symbol("dog");
    Dog[s1] = function() { console.log('say hi by dog.'); } ;
    
    Dog[s1]();  // 调用
    

    Symbol() 函数为我们返回的永远是不同的值,但有时候我们需要得到同一个 Symbol 值,这时候就需要另一个函数 Symbol.for()Symbol.for(xxx) 会根据参数 xxx 搜索对应的 Symbol 值,如果还未存在则创建 xxx 对应的 Symbol 值,并将其注册到全局环境以供搜索。如果已经存在,则直接返回 Symbol 值。这样我们可以根据参数来得到同一个 Symbol 值:

    let s1 = Symbol.for("dog");  // dog 对应的 Symbol 还未存在,则创建并加入全局环境。
    let s2 = Symbol.for("dog");  // 第二次调用, dog 对应的 Symbol 已经存在,则返回上一步创建的值
    
    console.log(s1 === s2);  // true 
    

    ES6 还提供了一系列的内置 Symbol 值,用来表示一些内部方法和内部属性,重写这些方法可以改变对象的行为,例如 Symbol.hasInstance,当对某个对象使用 instanceof,实际调用的就是该函数,如下所示:

    class MyArray {
      [Symbol.hasInstance](data) {
        return data instanceof Array;
      }
    }
    
    // 相当于 MyArray[Symbol.hasInstance]([1, 2, 3]);
    [1, 2, 3] instanceof new MyArray(); // 返回 true
    

    类似的还有 Symbol.iteratorSymbol.split 等。

    6.2 Set 和 Map

    在其他语言中,SetMap 是两种最为常见和常用的数据结构,ES6 为 JavaScritp 补充上了这种数据结构。

    6.2.1 Set
    1. 创建与基本使用

    一种集合数据结构,不允许存在重复值。创建一个 Set 数据结构语法如下所示:

    const mySet = new Set();
    
    // 通过 add 函数添加元素
    mySet.add(1); 
    mySet.add(2);
    mySet.add(1);  // 添加重复元素,重复元素会被「消除」
    
    console.log(mySet);  //  1 2 
    
    1. 判等

    Set 对于 NaN 能够正确识别,能够正确判断 NaNNaN 相等,也就是添加多次 NaN,Set 只会保存一个 NaN。同时两个对象,Set 永远会判断为不相等,如:

    const mySet = new Set();
    
    // 通过 add 函数添加元素
    mySet.add({}); 
    mySet.add({});
    
    console.log(mySet);  //  含有两个元素,{}, {}
    
    1. 初始化

    可通过数组或任何具有 iterable 接口的数据结构来初始化 Set:

    const mySet1 = new Set([1, 2, 3, 1]);
    console.log(mySet1); // 1 2 3
    
    1. 相关属性和方法
    • add(value): 添加元素,返回整个 Set
    • delete(value): 删除元素,返回删除是否成功的布尔值
    • has(value): 判断是否包含某个元素,返回布尔值
    • clear() : 清空元素,无返回值。
    • keys():返回所有键名,对于 Set 结构,键名和键值相同,都是 Set 内的元素值
    • values():返回所有键值,对于 Set 结构,键名和键值相同,都是 Set 内的元素值
    • entries():返回所有键值,对于 Set 结构,键名和键值相同,都是 Set 内的元素值,entries 返回的 key 和 value 相同,即两个重复的元素值
    • size: 含有的元素个数

    上述属性和方法的实例代码如下:

    const mySet = new Set([1, 2,3]);
    mySet.add(4);  // 1 2 3 4
    console.log(mySet.delete(1));  // true,删除后为 2 3 4
    console.log(mySet.has(2));  // 存在 2,输出 true
    console.log(mySet.size);  // 3 个元素,输出 3
    
    for (let key of mySet.keys()) {
      console.log(key); // 2 3 4
    }
    
    for (let value of mySet.values()) {
      console.log(value); // 2 3 4
    }
    
    for (let entry of mySet.entries()) {
      console.log(entry); // [2, 2] [3, 3] [4 4]
    }
    
    mySet.clear();  // 清空元素
    
    6.2.2 Map

    JavaScript 中的对象 Map 有一个特点,由键值对组成,所以有时对象可以作为一个简单的 key-value 结构来使用。但必须意识到对象作为 key-value 结构使用是残缺的,因为对象中的 key 只能以字符串的形式存在。虽然在语法上可以传入不同类型如布尔、数值的数据,但实际上只是被自动转换成字符串类型:

    const map = {};
    map[1] = 'a';  // 实际上为 map['1']
    map[true] = 'b';  // 实际上为 map['true']
    
    console.log(map['1']);  // a
    console.log(map['true']);  // b
    

    当传入对象类型作为 key 时,对象被字符串化为统一的 [object Object]

    const map = {};
    const obj1 = {a: 1};
    const obj2 = {a: 2};
    map[obj1] = 1;
    
    console.log(map[obj1] === map[obj2]);  // true
    

    由上不难看出对象作为 key-value 结构是「不专业」的,所以 ES6 引入了更为专业且能够支持各种数据类型作为 key 的 Map 结构。

    1. 创建与基本使用

    创建一个 Map 结构并使用如下所示:

    const map = new Map();
    const obj = {a: 1};
    
    map.set(true, 1);
    map.set('true', 2);
    map.set(obj, 3);
    
    console.log(map.get(true));  // 1
    console.log(map.get('true'));  // 2
    console.log(map.get(obj));  // 3
    
    1. 判等

    Map 支持不同数据类型作为 key。对于数值、字符串、布尔值类型,Map 通过严格相等 === 判断是否是相同的 key 。且对于数组、对象等,Map 通过地址来判断是否是相同的 key,也就是值完全相同的数组或对象,只要是不同实例,在不同的地址也会被认为不同的 key,如下所示:

    const map = new Map();
    map.set([1], 1);
    console.log(map.get([1]));  // undefined 
    
    1. 初始化
      与 Set 类似,Map 也可以接受任何具有 Iterator 接口的数据结构,且这个数据结构中每个迭代到的元素需要是具有两个元素的数组。如一个二维数组:
    const map = new Map([
      [true, 1],
      ['true', 2],
    ]);
    
    console.log(map);  // true => 1, 'true' => 2
    
    1. 相关属性和方法
    • set(key, value): 设置元素,返回值为整个 Map。如果存在 key,则会更新 value,如果不存在 key,则新建 key。
    • get(key): 获取 key 对应的 value,key 不存在则返回 undefined
    • delete(key):删除某个键值,返回删除是否成功
    • has(key): 判断是否包含某个键,返回布尔值
    • clear() : 清空元素,无返回值。
    • keys():返回所有键名
    • values():返回所有键值
    • entries():返回所有键值对
    • size: 含有的元素个数

    上述属性和方法的实例代码如下:

    const map = new Map([
      ['a', 1],
      ['b', 2],
      ['c', 3],
    ]);
    
    map.set('d', 4);
    console.log(map.get('a'));    // 1
    console.log(map.delete('a'));  // true,删除后为 b => 2, c => 3, d => 4
    console.log(map.has('d'));  // 存在 d,输出 true
    console.log(map.size);  // 3 个元素,输出 3
    
    for (let key of map.keys()) {
      console.log(key); // b c d
    }
    
    for (let value of map.values()) {
      console.log(value); // 2 3 4
    }
    
    for (let entry of map.entries()) {
      console.log(entry); // [b, 2] [c, 3] [d 4]
    }
    
    for (let [key, value] of map.entries()) {
      console.log(key ,value); // b 2、c 3、d 4
    }
    
    map.clear();  // 清空元素
    
    6.3 字符串扩展
    6.3.1 模板字符串

    ES6 之前常见使用 + 拼接字符串,这在一些网页代码拼接场景下会显得非常的繁琐,ES6 引入了「模板字符串」:

    const obj = 'world';
    const str = `hello, ${obj}`;
    

    模板字符串使用反引号 ` 符号来标识,使用 ${} 符号来嵌入变量。

    模板字符串的一个重要特点就是可以保存空格和换行:

    const str = `
    <ul>
      <li>${name}</li>
      <li>${age}</li>
    </ul>
    `
    

    模板字符串还可以跟在一个函数后面,这种模式被成为标签模板

    const age = 123;
    console.log`my age is ${age}!`;  // 函数名称后面直接跟模板字符串
    

    上面代码中的模板字符串会被做一番处理形成参数再被传入 console.log 函数,所做处理如下:

    • 形成的第一个参数是一个数组,里面每个元素为「非变量」部分。例如上例中,第一个参数就是 ['my age is', ' !']
    • 形成的第二个、第三个... 后续参数为变量值。例如上例中,第二个参数为 123
    • 第一个参数数组对象还会带有一个 raw 属性,里面保存原始字符串。

    标签模板的应用场景有过滤 HTML 字符串、多语言转换等,标签模板可以使这些实现变得更加直观清晰:

    SaferHTML`<p>${sender} has sent you a message.</p>`;  // SaferHTML 是对 HTML 字符串进行安全过滤的函数
    
    i18n`HelloWorld.`; // 你好世界。i18n为国际化处理函数
    
    6.3.2 字符串遍历器

    ES6 实现了对字符串的遍历器,使用 for...of 语句来遍历字符串:

    for (const str of 'hello,world') {
      console.log(str);
    }
    
    6.3.3 includes(), startsWith(), endsWith(), repeat()

    ES6 为字符串类型添加了三种方法:

    1. includes: 判断字符串是否包含某个子字符串
    "hello,world".includes("llo");  // 返回 true
    "hello,world".includes("llo", 6); // 从下标 6 开始查找,返回 false
    
    1. startsWith: 判断字符串是否以某个子字符串开始
    "hello,world".startsWith("hello");  // 返回 true
    "hello,world".startsWith("hello", 2); // 从下标 2 开始查找,返回 false
    
    1. endsWith: 判断字符串是否以某个子字符串结尾
    "hello,world".endsWith("world");  // 返回 true
    "hello,world".endsWith("world", 5); // 查找前 5 个字符,判断是否以 world 结尾
    
    1. repeat: 返回一个重复 n 次的字符串
    ' i love you.'.repeat(3000);  // 爱你 3000 遍
    
    6.4 数值扩展
    6.4.1 二进制和八进制

    ES6 提供二进制数值和八进制数值的表示,二进制使用 0b0B 表示,八进制使用 0o0O 表示:

    console.log(0b101 === 5); // true
    console.log(0o701 === 449); // true
    
    console.log(Number('0b101')); // 5, 使用 Number 将二进制转换成十进制
    console.log(Number('0o701'), 449);  // 449, 使用 Number 将八进制转换成十进制
    
    6.4.2 Number.parseInt(), Number.parseFloat()

    ES6 之前 parseIntparseFloat 为全局变量,ES6 开始将这两个函数挂在 Number 下:

    Number.parseInt('123');
    Number.parseFloat('123.4');
    
    6.5 数组扩展
    6.5.1 spread 扩展运算符与数组

    上文的 2.1.3 已经引入了 rest 运算符 ...... 同时也可以作为 spread 扩展运算符。rest 运算符可以理解为将元素组织成数组,而 spread 运算符则是将数组扩展为元素。

    spread 运算符作用于数组:

    function funcA(a, b, c, d) {
      return a + b + c + d;
    } 
    const a = [1, 2, 3, 4];
    funcA(...a);  // 将数组 [1, 2, 3, 4] 展开为 1 2 3 4
    
    const copy_a = [...a];  // 拷贝数组 a
    const [...copy_a] = a;  // 拷贝数组 a
    
    const b = [..."123"]; // 作用于字符串,将其转换成数组 ["1", "2", "3"]
    const c = [5, 6, 7];
    
    // 合并数组 a 和 数组 c,得到数组 d [1, 2, 3, 4, 5, 6, 7]
    // 注意是这里都是浅拷贝,如果数组 a 和数组 c 内的元素为引用类型如对象,那么 d 指向的是 a 和 c 数组中的同一个元素
    // 修改 a、c、d 中的引用类型元素都会影响到其他数组
    const d = [...a, ...c];
    
    6.5.2 fill()、find() 、findIndex()、Array.of()
    1. fill()

    fill 函数用来填充一个数组,这在数组初始化的场景非常有用:

    new Array(3).fill(0);  // [0, 0, 0];
    

    fill 函数的第二个和第三个参数表示填充的起始地址和结束地址(不包含):

    ['a', 'b', 'c'].fill(0, 1, 2);  // 从 1 号下标开始到 2 号下标(不包含),填充为 0
    

    注意点:如果填充的是对象,则填充的是同一个对象,即进行的浅拷贝

    const obj = {key: 'value'};
    const arr = new Array(3).fill(obj);
    arr[0].key = 'update value';  // 对第一个「对象」元素修改,即对所有元素修改
    console.log(arr);  // 输出 [{key: 'update value'}, {key: 'update value'}, {key: 'update value'}]
    
    1. find()、findIndex()

    find 函数从数组中查找某个符合条件的元素,并返回该元素,如果未找到,则返回 undefined。其中条件通过 find 的「参数」(回调函数)给出:

    [1, 2, 3, 4, 5].find(function(value, index, arr) {
      // value: 遍历到的元素
      // index: 遍历到的下标
      // arr: 完整数组
      return value === 5;  // 找到满足值等于 5 的元素
    });  // 返回 5
    

    findIndex 和 find 函数类似,只是返回的是元素的下标:

    [1, 2, 3, 4, 5].findIndex(function(value, index, arr) {
      // value: 遍历到的元素
      // index: 遍历到的下标
      // arr: 完整数组
      return value === 5;  // 找到满足值等于 5 的元素
    });  // 返回下标 4
    

    findIndex 比起 indexOf,findIndex 解决了 indexOf 的一个潜在缺陷,即无法识别 NaN:

    [1, NaN].indexOf(NaN);  // 返回 -1,即不存在
    [1, NaN].findIndex(ele => Object.is(ele, NaN));  // 返回 1
    
    1. Array.of()

    在 ES6 之前,我们有时候会使用 Arraynew Array 来将一组值转为数组:

    const arr1 = new Array(1, 2, 3); // 得到 [1, 2, 3]
    const arr2 = new Array();  // 得到 []
    

    但要注意到 Array 构造函数的重载,导致参数个数不同时,Array 构造函数的语义有所不同,当只传入一个参数时,这个参数表示数组的长度:

    const arr3 = new Array(1);  // 得到 [empty], 长度为 1,且元素为空
    

    而 Array.of() 提供的则是没有歧义的「将一组值转换成数组」:

    Array.of();  // []
    Array.of(1, 2, 3);  // [1, 2, 3];
    Array.of(1);  // [1]
    
    6.5.3 entries(),keys() 和 values()

    keys() 返回数组的键名(下标),values() 返回数组的键值,entries() 返回键值对:

    const arr = ['a', 'b', 'c'];
    for (const idx of arr.keys()) {
      console.log(idx);  // 输出下标 0 1 2
    }
    
    for (const ele of arr.values()) {
      console.log(ele); // 输出数组值 a b c
    }
    
    for (const [idx, ele] of arr.entries()) {
      console.log(idx, ele);  // 输出 下标 数组值,0 a 、1 b、2 c
    }
    
    6.6 对象扩展
    6.6.1 对象属性简写

    在 ES6 之前,定义一个对象如下:

    const name = 'cat';
    const age = 10;
    const dog = {
      name: name,
      age: age,
      sayHi: function() {/* ... */}
    };
    

    ES6 对于上述属性名和变量名相同的情形,可以简写如下:

    const dog = {
      name,
      age,
      sayHi() {/* ... */}  // 注意 ES6 的函数也可以简写,直接写成函数定义即可
    }
    
    6.6.2 属性名表达式

    对象的属性名有时候可能由表达式给出,例如:

    const animal = {
      name: 'cat',
      age: 10,
      sayHi() {/* ... */}
    }
    
    const key = 'wangwang';
    animal[key] = function() { console.log('i am a dog'); };
    animal.wangwang();
    

    在 ES6 以前,表达式给出的属性名只能以 obj[xxx] 的形式定义,无法直接 {} 定义时给出,ES6 添加了对此的支持:

    const key = 'wangwang';
    
    const animal = {
      name: 'cat',
      age: 10,
      sayHi() {/* ... */},
      [key]: function() { console.log('i am a dog'); },  // 直接在 animal 定义时就实现表达式的属性名
    };
    
    animal.wangwang();
    

    注意如果上述的 key 是一个对象,那么会自动转换成字符串 [object Object]

    const key1 = {name: 'wangwang'};
    const key2 = {name: 'miaomiao'};
    const animal = {
      name: 'cat',
      age: 10,
      sayHi() {/* ... */},
      [key1]: function() { console.log('i am a dog'); },  // [object Object]: function
      [key2]: function() { console.log('i am a cat'); },   // [object Object]: function 将会覆盖上面的函数
    };
    

    上例代码中 [key1][key2] 最终生成同名的 [object Object] 属性,animal 最终只会有一个[object Object] 属性,且对应的函数为 i am a cat 函数。

    6.6.3 属性遍历

    我们经常需要对对象的属性进行遍历,一共有以下方法:

    • 表示包含,- 表示不包含
    1. for...in

    能够遍历的属性:继承的属性 + 自身可枚举属性 - Symbol 类型属性

    1. Object.keys(obj)

    能够遍历的属性名:自身可枚举属性 - 继承的属性 - Symbol 类型属性

    1. Object.getOwnPropertyNames(obj)

    能够遍历的属性: 自身所有属性 - 继承的属性 - Symbol 类型属性

    1. Object.getOwnPropertySymbols(obj)

    能够遍历的属性名:自身所有的 Symbol 类型属性

    1. Reflect.ownKeys(obj)
      能够遍历的属性: 自身所有属性 + Symbol 类型属性 - 继承的属性
    6.6.4 Object.is()、Object.assign()
    1. Object.is()

    JavaScript 有很多的坑,NaN 就是其中之一。在 ES6 之前,我们使用 ===== 判断两个值是否相等时,总是需要特殊考虑 NaN 特例。

    ES6 引入了 Object.is() 来判断两个值是否相等,而且对于 NaN 也能正确判断:

    Object.is(NaN, NaN);  // true
    
    1. Object.assign()

    Object.assign 实现将源对象的可枚举属性复制到目标对象:

    const target = {a: 'a', b: 'b'};
    
    const source1 = {c: 'c', d: 'd'};
    const source2 = {e: 'e', f: 'f'};
    
    const obj = Object.assign(target, source1, source2);
    

    如果参数 target, source1, source2, ... 中存在同名属性,则规则为:后面的属性覆盖前面的属性

    #07 Promise

    由于 JavaScript 单线程等特点,异步在 JavaScript 中可谓随处可见。尤其是在 Node.js 中,更是将绝大多数的系统接口都设计为异步函数。

    在 ES6 之前,JavaScript 主要通过「回调函数」的形式实现异步的结果返回。如果多个异步操作之间存在先后次序关系,就会产生了经典的「回调地狱」:

    /* getOne、getTwo、getMore 皆为模拟异步操作 */
    /* 1 秒后通过回调告知操作结果 */
    
    function getOne(params, callback) {
      setTimeout(() => {
        console.log(`oneResult: ${params}、1`);  // oneResult: 0、1
        callback(`${params}、1`)
      }, 1000);
    }
    
    function getTwo(params, callback) {
        setTimeout(() => { 
          console.log(`twoResult: ${params}、2`);  // twoResult: 0、1、2
          callback(`${params}、2`)
        }, 1000);
    }
    
    function getMore(params, callback) {
        setTimeout(() => { 
          console.log(`moreResult: ${params}、3`);  // moreResult: 0、1、2、3
          callback(`${params}、3`)
        }, 1000);
    }
    
    // 上述异步操作存在先后顺序
    // 需要先执行 getOne 得到 oneResult
    // 然后将 oneResult 作为参数传入 getTwo 执行,获取 twoResult
    // 最后将 twoResult 作为参数传入 getMore 执行
    // 则出现「回调函数」嵌套写法如下
    getOne(0, function(oneResult) {
      getTwo(oneResult, function(twoResult) {
        getMore(twoResult, function(moreResult) {
           console.log(`done.`);
        });
      });
    });
    

    「回调地狱」无疑使得代码变得难以阅读和编写,ES6 引入的 Promise 则可解决这一问题。Promise 本质是一个对象,该对象可以将一个「未来才会结束的事件」、「对该事件的回调处理」等封装到一起。这样使得异步函数可以像同步函数那样返回值。

    7.1 基本语法

    将上述的 getOnegetTwogetMore 等异步操作通过 Promise 对象封装:

    function getOne(params) {
      return new Promise(function(resolve, reject) {
        setTimeout(() => {
          console.log(`oneResult: ${params}、1`);  // oneResult: 0、1
          resolve(`${params}、1`)
        }, 1000);
      });
    }
    
    function getTwo(params) {
      return new Promise(function(resolve, reject) {
        setTimeout(() => { 
          console.log(`twoResult: ${params}、2`);  // twoResult: 0、1、2
          resolve(`${params}、2`)
        }, 1000);
      });
    }
    
    function getMore(params) {
      return new Promise(function(resolve, reject) {
        setTimeout(() => { 
          console.log(`moreResult: ${params}、3`);  // moreResult: 0、1、2、3
          resolve(`${params}、3`)
        }, 1000);
      });
    }
    

    如上所示, Promise 构造函数接受一个函数作为参数,且该函数会被传入 resolvereject 两个参数。其中 resolve 函数的作用为将 Promise 对象的状态从「未完成 pending」变为「成功 resolved」,reject 函数的作用则是将 Promise 对象的状态从「未完成 pending」变为「失败 rejected」。

    之后我们可以通过 Promise 实例的 then() 函数来绑定成功时回调函数和失败时回调函数:

    // 绑定成功时回调函数 sucCallBack,可选
    // 绑定失败时回调函数 failCallback,可选
    getOne.then(sucCallBack, failCallback);
    

    Promise 是如何完成异步操作的?整个过程可以简单描述如下:

    在我们通过 new 创建 promise 实例时,传入的函数会被立即执行,例如上述的 setTimeout 代码。而其中异步的操作则会在「未来的某个时刻」返回结果,例如 setTimeout 设置的 1 秒后的回调函数。在该回调函数中我们调用了 resolve 函数,将对象状态从 pending 转换为 resolved。这将触发 promise 实例调用我们通过 then() 绑定的处理函数,其中传入 resolve 函数的参数(通常为异步操作返回的结果)会被传递给我们绑定的处理函数进行处理。reject 函数与 resolve 类似。

    上述过程由 Promise 对象在内部通过「观察者模式」和「状态管理」(pending、resolved、rejected)来实现,开发者只需懂得将异步通过 Promise 实例封装,然后通过 then() 函数绑定回调函数即可。

    7.2 链式调用

    我们在设置 Promise 实例的 then() 函数时,如果其中的返回值又是一个 Promise 对象,则可以在 .then() 紧接着跟一个 .then() 从而实现链式调用。 通过 Promise 链式调用并可解决「回调地狱」的问题:

    getOne(0)
      .then(getTwo)  // getOne 执行成功后并执行回调「getTwo」
      .then(getMore)  // 由于 getTwo 返回的是一个 Promise,所以后面又可以跟上 getMore,且在 getTwo 执行成功后执行回调 getMore
      .then(() => console.log('done.'));  // getMore 执行成功后执行回调输出 done.
    
    7.3 Promise.prototype.catch

    在 7.1 基本语法一节介绍了 .then() 函数的第二个参数为发生错误时或失败时的「回调函数 」failCallBack。Promise 提供了 Promise.prototype.catch() 作为 .then(null, rejection).then(undefined, rejection) 的别名,以时调用语义更友好:

    /* 调用 reject 会被 catch */
    const promise1 = new Promise(function(resolve, reject) {
      setTimeout(() => {
        const one = '1';
        reject(one);
      }, 1000);
    });
    promise1
      .then(() => console.log('callback'))  // 没有输出。因为上面代码调用了 reject 所以属于失败,将调用失败回调 rejection 
      .catch((error) => console.log('error info: ', error));  // 输出 error info:1。 catch 就是 .then(undefined, rejection) 别名
    
    /* 调用 throw 一个 Error 也会被 catch */
    const promise2 = new Promise(function(resolve, reject) {
      setTimeout(() => {
        const one = '1';
      }, 1000);
      throw new Error('1');
    });
    promise2
      .then(() => console.log('callback'))  // 没有输出。因为上面代码调用了 reject 所以属于失败,将调用失败回调 rejection 
      .catch((error) => console.log('error info: ', error));  // 输出 error info:  Error: 1......。 catch 就是 .then(undefined, rejection) 别名
    

    Promise 对象的错误具有「冒泡」性质,会一直向后传递直到被捕获:

    const promise = new Promise(function(resolve, reject) {
      setTimeout(() => {
        const one = '1';
        resolve(one);
      }, 1000);
    });
    promise
      .then(() => x + 1)  // 此处抛出的错误会被最后的 .catch 捕获 
      .then(() => 2)  // 如果此处有错也会被最后的 .catch 捕获
      .catch((error) => console.log('error info: ', error));  // 输出 error info:  ReferenceError: x is not defined......
    

    #08 Generator 函数

    8.1 基本概念和用法

    Generator 函数是 ES6 对协程的一种实现,所谓协程:

    子程序就是协程的一种特例 —— 高德纳 Donald Knuth

    或者也可称 「协程」为「子程序」的泛化,是对子程序概念的扩展。协程相关概念和知识可以查阅 Wikipedia 或其他资料,这里暂时不做扩展。

    由于 Generator 函数的执行权恢复只可由 Generator 函数的调用者实现,所以 Generator 函数也被成为「半协程[9]

    [9] 一个完全的协程,任何函数都应该具备能力使协程恢复执行。

    Generator 函数需要在 function 关键字后跟随一个星号 *,同时函数内部通过 yield 命令实现执行权主动让出

    // function 后面添加星号 *
    // 以下写法也是合法的
    // function * lines
    // function *lines
    // function*lines
    function* lines() {
      yield 'first';
      yield 'second';
      yield 'third';
    
      return 'EOF';
    }
    
    const generator = lines();  // 返回迭代器对象
    generator.next();  // { value: 'first', done: false }
    generator.next();  // { value: 'second', done: false }
    generator.next();  // { value: 'third', done: false }
    generator.next();  // { value: 'EOF', done: true }
    

    如上所示,一个 Generator 函数执行时,不会立马执行函数体,而是返回一个「迭代器对象」,这个对象初始化指向函数头部。

    之后对这个迭代器调用 next() 将开始执行函数体,执行过程中遇到 yield 命令就会暂停,主动交出执行权,并将 yield 后面的值返回。

    例如上例代码中的第一次 .next() 调用后,将从头部开始执行函数体,且在 yield 'first' 让出执行权,将 'first' 返回,当然返回值做了一定的封装,返回的是一个对象 {value: xxx, done: xxx},其中 value 字段为 yield 后边的值,done 字段表示协程是否执行完毕。

    调用一次 .next() 后,我们发现函数体只执行一部分。只有我们主动的继续的调用 .next(),例如上例代码的第二次 .next(),函数体才会继续,且函数会从上次退出的部分继续执行,第二次 .next() 执行的则是 yield 'second',本次返回 { value: 'second', done: false }。之后第三次同理。直到第四次执行 return 'EOF',返回值中的 done 被设置为 true,此时 Generator 函数才完全执行完毕。后续再调用 .next(),只会返回 {value: undefined, done: true}

    仔细体会上述 Generator 函数的执行流程,会发现 Generator 函数有非常特殊的执行流,这个过程中执行权的切换由程序员定义,最终生成的也是一种程序员定义的「函数执行流」。再结合普通函数中只有一个return(或 异常)才能结束的单一执行流,是不是可以理解为什么所谓协程就是「子程序」的泛化。

    网上很多教程,从「用户态线程」的角度出发来理解协程其实并不严谨,有可能导致很多理解偏差。理解协程本质是泛化子程序之后,然后再去意识到协程通常被用于并发,然后再思考被用于并发时本身具有哪些特点,与线程又有哪些区别等。

    我们注意到 yield 命令实际上起到一种将「函数内的值」返回给「函数调用者」的作用,但我们直到函数之间交互必然还需要「函数调用者」将值传递到「函数内」,则这一功能可以通过 .next(xxx) 来实现。即通过 .next(xxx) 我们可以把数据传递给函数:

    function* lines() {
      yield 'first';
      yield 'second';
      const more = yield 'third';
      if (more) {
        yield 'fourth';
      }
    
      return 'EOF';
    }
    
    const generator = lines();  // 返回迭代器对象
    generator.next();  // { value: 'first', done: false }
    generator.next();  // { value: 'second', done: false }
    generator.next();  // { value: 'third', done: false }
    generator.next(true);  // 传入参数 true 给 more ,此时返回 { value: 'fourth', done: false }。如果传入 false,则回跳过 yield 'fourth' 的执行
    generator.next(); // { value: 'EOF', done: true }
    

    另外注意到 Generator 函数返回是一个迭代器对象,所以自然可以通过 for...of... 等操作遍历:

    for (const line of lines()) {
      console.log(line);  // first second third
    }
    
    8.2 Generator.prototype.throw

    Generator 函数返回的迭代器对象还具有 throw() 函数,用来表示 Generator 函数内抛出异常:

    function* lines() {
      yield 'first';
      yield 'second';
      const more = yield 'third';
    
      return 'EOF';
    }
    
    const generator = lines();  // 返回迭代器对象
    generator.next();
    generator.next();
    generator.throw(new Error('出错'));
    

    thrownext 本质上是一样的,都是向函数内传递数据,只是 throw 传入的是一个 Error,即上例代码的 generator.throw(new Error('出错')) 的效果相当于:

    function* lines() {
      yield 'first';
      yield 'second';
    
      // 这条语句将被替换 const more = throw(new Error('出错'));
      const more = yield 'third';
    
      return 'EOF';
    }
    

    catch 到错误,可以在 lines 函数内进行 try...catch,也可以在函数外进行 try...catch

    8.3 Generator.prototype.return

    returnnext 同样本质上是一致的,只是替换的是return 语句:

    function* lines() {
      yield 'first';
      yield 'second';
      const more = yield 'third'; // 这条语句将被替换 const more = return 2;
    
      return 'EOF';
    }
    
    const generator = lines();  // 返回迭代器对象
    generator.next();
    generator.next();
    generator.return(2); // {value: 2, done: true}
    generator.next();  // {value: undefined, done: true} 函数已经结束了。
    
    8.4 yield* 表达式

    如果在 Generator 函数内部,调用另一个 Generator 函数,我们可能需要手动遍历:

    function* lines() {
      yield 'first';
      yield 'second';
      const more = yield 'third'; // 这条语句将被替换 const more = return 2;
    
      return 'EOF';
    }
    
    function* files() {
      yield 'first file';
      // 遍历 lines
      for (const line of lines()) {
        console.log(line);
      }
      return 'EOF';
    }
    

    如果有多重嵌套,那么实现起来就会非常麻烦。所以 ES6 引入了 yield* 表达式:

    function* lines() {
      yield 'first';
      yield 'second';
      const more = yield 'third'; // 这条语句将被替换 const more = return 2;
    
      return 'EOF';
    }
    
    function* files() {
      yield 'first file';
      // yield* 表达式
      yield* lines();
      return 'EOF';
    }
    
    8.5 在异步编程上的应用

    Generator 函数最重要的应用场景就是 JavaScript 中的异步编程,例如实现一个典型的 AJAX 请求:

    function* asyncByGenerator() {
      const result = yield ajax("/users", callback);  // 开始 ajax 异步请求,并不用等其结果立马返回让出执行权
      const resp = JSON.parse(result);  // 这里是从 ajax 回调中 it.next(response) 处恢复的,此时已经得到了异步结果,所以可以恢复继续执行
      console.log(resp.value);
    }
    
    function callback(response) {
        it.next(response);  // 在回调中恢复协程继续执行
    }
    
    // 获得迭代对象
    var it = asyncByGenerator();
    it.next();  // 开始执行
    
    // it.next() 会立马返回,所以不影响做其他事情
    console.log('hi');
    

    上面 callback 实际上只是进行 next() 调用好让协程恢复执行,而业务的「回调逻辑代码」完全可以写到 yield 语句后面。换句话说 yield 后面的语句实际上才是真正的业务回调逻辑代码。同时上面的调用依赖于 it 全局变量,这显然不是一个好的模式。

    所以我们需要进一步封装,实现一个更为高级的 Generator 调度器:

    function run(gen){
      let g = gen();
    
      function next(data){
        const result = g.next(data);
    
        if (result.done) return result.value;  // Generator 函数执行完成,则返回
        
        // 重点,返回值为 Promise,这样我就可以在成功后「递归」调用 next
        // 当然也可以直接返回回调函数,对应的技术称为 Thunk 函数
        result.value.then(function(data){
          next(data);
        });
      }
    
      next();
    }
    
    function* asyncByGenerator() {
      const result = yield special("/users");  // 
      const resp = JSON.parse(result);
      console.log(resp.value);
    }
    
    run(asyncByGenerator);
    

    run 就是 Generator 自动调度器,他可以自动执行一个 Generator 函数,里面如果存在多个 yield,它将自动的依次执行。根本原理其实很简单:执行 next() 之后,他的返回值(也就是 yield 后面的表达式)必须是异步的回调函数(例如 ajax 异步操作的回调函数)或 Promise。在这个回调函数里我再递归调用 next(),这样就可以在上一个 yield 执行完成后继续执行下一个 yield 语句,直到 next() 返回 {done: true}

    上例自动调度器代码中在 next() 方法中对返回值进行了 .then(),即 result.value.then(...),这里就要求 yield 后面的表达式必须返回的是一个 Promise 实例。同时还有另一种写法,即返回一个 Thunk 函数result.value(callback)Thunk 函数相关知识可以查阅此 文档 中的第四节。

    现在已经有一些第三方模块实现封装了类似上文介绍的 Generator 自动调度器,例如 co 模块。

    当然 JavaScript 官方在 ES2017 引入 async/await 语法糖来实现了上述能力。

    #09 Reflect

    在编程领域,反射是一个耳熟能详的概念。有关反射概念的简单介绍可参阅深入 ProtoBuf - 反射原理解析 一文中的 [反射技术简介] 一节。

    JavaScript 由于是一门动态语言,反射的实现会更加自然。所以早在 ES6 之前,JavaScript 中就已经有一些反射相关的接口,如 Object. defineProperty 就可以在一个对象上定义一个新属性或修改一个已有属性。

    ES6 为了实现更为规范和职责更为专一的入口,设计了 Reflect 对象来提供所有反射相关的能力。

    Reflect 对象一共提供了 13 种静态方法来实现对象一系列的反射操作:

    • Reflect.apply(target, thisArg, args)
    • Reflect.construct(target, args)
    • Reflect.get(target, name, receiver)
    • Reflect.set(target, name, value, receiver)
    • Reflect.defineProperty(target, name, desc)
    • Reflect.deleteProperty(target, name)
    • Reflect.has(target, name)
    • Reflect.ownKeys(target)
    • Reflect.isExtensible(target)
    • Reflect.preventExtensions(target)
    • Reflect.getOwnPropertyDescriptor(target, name)
    • Reflect.getPrototypeOf(target)
    • Reflect.setPrototypeOf(target, prototype)

    例如上述的 Reflect.has(target, name) 可以判断某个对象是否存在某个属性,如下所示:

    const dog = {
      name: 'wangwang',
      age: 5,
    };
    
    const attrName1 = 'age';
    const attrName2 = 'master';
    console.log(Reflect.has(dog, attrName1)); // true,dog 对象存在 age 属性
    console.log(Reflect.has(dog, attrName2)); // false, dog 对象不存在 master 属性
    

    其它更多接口详细说明可参阅 Reflect - From MDN

    #10 Proxy

    ES6 引入了 Proxy 对象,可以用来创建对象的代理,从而实现对象属性和方法的拦截(代理)。

    例如实现对一个对象所有属性的访问的代理如下所示:

    const dog = {
      name: 'wangwang',
      age: 5,
    };
    
    // 使用 Proxy: 
    // const proxy = new Proxy(target, handler);
    const dogProxy = new Proxy(dog, {
      // target: 被代理的对象,这里为 dog
      // propKey: 访问的属性
      // receiver: 代理对象,这里为 dogProxy
      get: function (target, propKey, receiver) {
        console.log(`get prop [${propKey}] by proxy.`);
        // ... 例如打日志上报、计数等等更多操作
        return Reflect.get(target, propKey, receiver);
      },
    });
    
    console.log(dogProxy.name);  // get prop [name] by proxy. wangwang
    console.log(dogProxy.age);  // get prop [age] by proxy. 5
    

    上述代码展示了 ES6 proxy 的基本用法:

    const proxy = new Proxy(target, handler);
    

    其中 target 表示被代理的对象,如例子中的 doghandler 则是一个对象,其中有 13 种属性[10],且属性为函数类型,就是通过这些函数属性来定义代理行为。例如上例中的 get 属性,定义属性就是定义「访问被代理对象的属性」的代理函数。

    [10]: 这里的属性和上一节 Reflect 的 13 种静态方法是一一对应的。Reflect 实现针对对象的一系列操作,那么 Proxy 自然会提供这一系列操作的代理。通常在代理函数中,通过 Reflect.xxx 就可以实现在完成相应的代理操作后调用对象的原始行为了,例如上例代码中进行日志输出后通过 Reflect.get(...) 执行原始行为即返回对象的属性。

    更多 handler 属性如 sethasdefineProperty 等可参阅 Proxy - From MDN

    #11 Iterator 迭代器

    迭代器在编程语言中是一种十分常见的接口,它主要用来为不同的数据结构提供统一的访问机制。ES6 中有关 Iterator 的几个概念如下:

    • Iterable: 一个数据结构只要部署了 Symbol.iterator 属性,我们就可以称之为 Iterable 即可迭代的。Symbol.iterator 属性 为一个函数,该函数应该返回本数据结构的迭代器 Iterator 对象
    class Iterable {
      constructor(data) {
        this.data = data
      }
      
      // 部署了 Symbol.iterator 属性的数据结构为 Iterable
      [Symbol.iterator]() {
        let index = 0;
        
        // Symbol.iterator 函数返回的是一个对象,该对象即为本数据结构的 Iterator 对象
        return {
          next: () => {
            if (index < this.data.length) {
              return {value: this.data[index++], done: false}
            } else {
              return {done: true}
            }
          }
        }
      }
    }
    
    • Iterator:通过 Symbol.iterator 函数返回的对象即为用来访问数据结构的 Iterator 对象。该对象通常一开始指向数据结构的起始地址,同时具有 next() 函数,调用 next() 函数指向第一个元素,再次调用 next()函数指向第二个元素.... 重复并可迭代数据结构中所有元素。
    • IteratorResultIterator 对象 每一次调用 next() 访问的元素会被包装返回,返回值为一个对象,其中包含 value 属性表示元素值、done 属性表示是否已经迭代完成。

    ES6 引入 for...of 语句对 Iterable 数据结构进行迭代:

    const myArray = new Iterable([1, 2, 3]);
    for (const ele of myArray) {
      console.log(ele);
    }
    

    ES6 中的很多原生数据结构已经部署了 Symbol.iterator 属性

    • Array
    • Map
    • Set
    • String
    • TypedArray
    • 函数的 arguments 对象
    • NodeList 对象

    Array 为例,如下所示:

    const arr = [1, 2, 3];
    const iterator = arr[Symbol.iterator]();  // 获取迭代器
    
    console.log(iterator.next());  // {value: 1, done: false}
    console.log(iterator.next());  // {value: 2, done: false}
    console.log(iterator.next());  // {value: 3, done: true}
    

    当然可以直接使用 for...of 语句进行迭代:

    const arr = [1, 2, 3];
    for (const ele of arr) {
      console.log(ele);
    }
    

    参考资料

    tc39 From GitHub
    MDN Web Docs
    ECMAScript 6 教程
    Advanced ES6 Destructuring Techniques
    The final feature set of ECMAScript 2016 (ES7)
    ECMAScript 2017 (ES8): the final feature set
    ECMAScript 2018: the final feature set
    ES2018: asynchronous iteration
    ECMAScript 2019: the final feature set
    ECMAScript 2020: the final feature set

    未汪

    未汪。
    请查阅第二篇 ES6~ES11 特性介绍之 ES7~ES11 篇

    相关文章

      网友评论

        本文标题:ES6~ES11 特性介绍之 ES6 篇

        本文链接:https://www.haomeiwen.com/subject/rmfviktx.html