es6笔记

01 【ES6 介绍】

ECMAScript 6.0(以下简称 ES6)是 JavaScript 语言的下一代标准,已经在 2015 年 6 月正式发布了。它的目标,是使得 JavaScript 语言可以用来编写复杂的大型应用程序,成为企业级开发语言。

1.ECMAScript 和 JavaScript 的关系

一个常见的问题是,ECMAScript 和 JavaScript 到底是什么关系?

要讲清楚这个问题,需要回顾历史。1996 年 11 月,JavaScript 的创造者 Netscape 公司,决定将 JavaScript 提交给标准化组织 ECMA,希望这种语言能够成为国际标准。次年,ECMA 发布 262 号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript,这个版本就是 1.0 版。

该标准从一开始就是针对 JavaScript 语言制定的,但是之所以不叫 JavaScript,有两个原因。一是商标,Java 是 Sun 公司的商标,根据授权协议,只有 Netscape 公司可以合法地使用 JavaScript 这个名字,且 JavaScript 本身也已经被 Netscape 公司注册为商标。二是想体现这门语言的制定者是 ECMA,不是 Netscape,这样有利于保证这门语言的开放性和中立性。

因此,ECMAScript 和 JavaScript 的关系是,前者是后者的规格,后者是前者的一种实现(另外的 ECMAScript 方言还有 JScript 和 ActionScript)。日常场合,这两个词是可以互换的。

2.ES6 与 ECMAScript 2015 的关系

ECMAScript 2015(简称 ES2015)这个词,也是经常可以看到的。它与 ES6 是什么关系呢?

2011 年,ECMAScript 5.1 版发布后,就开始制定 6.0 版了。因此,ES6 这个词的原意,就是指 JavaScript 语言的下一个版本。

但是,因为这个版本引入的语法功能太多,而且制定过程当中,还有很多组织和个人不断提交新功能。事情很快就变得清楚了,不可能在一个版本里面包括所有将要引入的功能。常规的做法是先发布 6.0 版,过一段时间再发 6.1 版,然后是 6.2 版、6.3 版等等。

但是,标准的制定者不想这样做。他们想让标准的升级成为常规流程:任何人在任何时候,都可以向标准委员会提交新语法的提案,然后标准委员会每个月开一次会,评估这些提案是否可以接受,需要哪些改进。如果经过多次会议以后,一个提案足够成熟了,就可以正式进入标准了。这就是说,标准的版本升级成为了一个不断滚动的流程,每个月都会有变动。

标准委员会最终决定,标准在每年的 6 月份正式发布一次,作为当年的正式版本。接下来的时间,就在这个版本的基础上做改动,直到下一年的 6 月份,草案就自然变成了新一年的版本。这样一来,就不需要以前的版本号了,只要用年份标记就可以了。

ES6 的第一个版本,就这样在 2015 年 6 月发布了,正式名称就是《ECMAScript 2015 标准》(简称 ES2015)。2016 年 6 月,小幅修订的《ECMAScript 2016 标准》(简称 ES2016)如期发布,这个版本可以看作是 ES6.1 版,因为两者的差异非常小(只新增了数组实例的includes方法和指数运算符),基本上是同一个标准。根据计划,2017 年 6 月发布 ES2017 标准。

因此,ES6 既是一个历史名词,也是一个泛指,含义是 5.1 版以后的 JavaScript 的下一代标准,涵盖了 ES2015、ES2016、ES2017 等等,而 ES2015 则是正式名称,特指该年发布的正式版本的语言标准。本书中提到 ES6 的地方,一般是指 ES2015 标准,但有时也是泛指“下一代 JavaScript 语言”。

3.ES6简介

ES6 = ECMAScript 这门标准的第 6 代版本(2015)。

  • ECMAScript 是语言的标准
  • 6 是版本号

ECMA:欧洲计算机制造商协会

具体内容:语法 + API

历史版本:ES1——>3、ES5——>6(ES4 被废弃了)

我们目前使用 JS 的大部分内容都是 ES3 的部分

ES 与 JS 的关系:JavaScript(浏览器端) = ESMAScript(语法+API) + DOM + BOM

ES6 的兼容性:

  • 主流浏览器的最新版本几乎都全部支持 ES6
  • IE 老版本等不支持的浏览器,可以使用 Babel 转码
  • 总之,请放心大胆地使用 ES6

02 【let和const】

1.let 命令

1.1 基本用法

ES6 新增了let命令,用来声明变量。它的用法类似于var,但是所声明的变量,只在let命令所在的代码块内有效。

{
  let a = 10;
  var b = 1;
}

a // ReferenceError: a is not defined.
b // 1

上面代码在代码块之中,分别用letvar声明了两个变量。然后在代码块之外调用这两个变量,结果let声明的变量报错,var声明的变量返回了正确的值。这表明,let声明的变量只在它所在的代码块有效。

for循环的计数器,就很合适使用let命令。

for (let i = 0; i < 10; i++) {
  // ...
}

console.log(i);
// ReferenceError: i is not defined

上面代码中,计数器i只在for循环体内有效,在循环体外引用就会报错。

下面的代码如果使用var,最后输出的是10

var a = [];
for (var i = 0; i < 10; i++) {
  a[i] = function () {
    console.log(i);
  };
}
a[6](); // 10

上面代码中,变量ivar命令声明的,在全局范围内都有效,所以全局只有一个变量i。每一次循环,变量i的值都会发生改变,而循环内被赋给数组a的函数内部的console.log(i),里面的i指向的就是全局的i。也就是说,所有数组a的成员里面的i,指向的都是同一个i,导致运行时输出的是最后一轮的i的值,也就是 10。

如果使用let,声明的变量仅在块级作用域内有效,最后输出的是 6。

var a = [];
for (let i = 0; i < 10; i++) {
  a[i] = function () {
    console.log(i);
  };
}
a[6](); // 6

上面代码中,变量ilet声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。你可能会问,如果每一轮循环的变量i都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。

另外,for循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。

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

上面代码正确运行,输出了 3 次abc。这表明函数内部的变量i与循环变量i不在同一个作用域,有各自单独的作用域(同一个作用域不可使用 let 重复声明同一个变量)。

1.2 不存在变量提升

var命令会发生“变量提升”现象,即变量可以在声明之前使用,值为undefined。这种现象多多少少是有些奇怪的,按照一般的逻辑,变量应该在声明语句之后才可以使用。

为了纠正这种现象,let命令改变了语法行为,它所声明的变量一定要在声明后使用,否则报错。

// var 的情况
console.log(foo); // 输出undefined
var foo = 2;

// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;

上面代码中,变量foovar命令声明,会发生变量提升,即脚本开始运行时,变量foo已经存在了,但是没有值,所以会输出undefined。变量barlet命令声明,不会发生变量提升。这表示在声明它之前,变量bar是不存在的,这时如果用到它,就会抛出一个错误。

1.3 暂时性死区

只要作用域内存在 let、const,它们所声明的变量或常量就自动 “绑定” 这个区域,不再受到外部作用域的影响。

let a = 2;
function func() {
    console.log(a);        // 报错
    let a = 1;
}
func();
let a = 2;
function func() {
    console.log(a);        // 2
}
func();

即:只要作用域内出现了同名的 let 或 const,那么就会去找这个量(向前找),如果找不到也不会跳去外部找,只会直接报错!

只要我们遵守 “先声明后使用”,那么其实就基本不会遇到变量提升及暂时性死区问题。

1.4 不允许重复声明

let不允许在相同作用域内,重复声明同一个变量。

// 报错
function func() {
  let a = 10;
  var a = 1;
}

// 报错
function func() {
  let a = 10;
  let a = 1;
}

因此,不能在函数内部重新声明参数。

function func(arg) {
  let arg;
}
func() // 报错

function func(arg) {
  {
    let arg;
  }
}
func() // 不报错

2.块级作用域

2.1 为什么需要块级作用域?

ES5 只有全局作用域和函数作用域,没有块级作用域,这带来很多不合理的场景。

第一种场景,内层变量可能会覆盖外层变量。

var tmp = new Date();

function f() {
  console.log(tmp);
  if (false) {
    var tmp = 'hello world';
  }
}

f(); // undefined

上面代码的原意是,if代码块的外部使用外层的tmp变量,内部使用内层的tmp变量。但是,函数f执行后,输出结果为undefined,原因在于变量提升,导致内层的tmp变量覆盖了外层的tmp变量。

第二种场景,用来计数的循环变量泄露为全局变量。

var s = 'hello';

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

console.log(i); // 5

上面代码中,变量i只用来控制循环,但是循环结束后,它并没有消失,泄露成了全局变量。

2.2 ES6 的块级作用域

let实际上为 JavaScript 新增了块级作用域。

  • 作用域链:内层作用域 ——> 外层作用域 ——> 全局作用域

  • 块级作用域:除了对象 {},函数 {}(函数作用域)之外的一切 {} 都属于块级作用域。

function f1() {
  let n = 5;
  if (true) {
    let n = 10;
  }
  console.log(n); // 5
}

上面的函数有两个代码块,都声明了变量n,运行后输出 5。这表示外层代码块不受内层代码块的影响。如果两次都使用var定义变量n,最后输出的值才是 10。

ES6 允许块级作用域的任意嵌套。

{{{{
  {let insane = 'Hello World'}
  console.log(insane); // 报错
}}}};

上面代码使用了一个五层的块级作用域,每一层都是一个单独的作用域。第四层作用域无法读取第五层作用域的内部变量。

内层作用域可以定义外层作用域的同名变量。

{{{{
  let insane = 'Hello World';
  {let insane = 'Hello World'}
}}}};

块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数表达式(匿名 IIFE)不再必要了。

// IIFE 写法
(function () {
  var tmp = ...;
  ...
}());

// 块级作用域写法
{
  let tmp = ...;
  ...
}

2.3 块级作用域与函数声明

函数能不能在块级作用域之中声明?这是一个相当令人混淆的问题。

ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。

// 情况一
if (true) {
  function f() {}
}

// 情况二
try {
  function f() {}
} catch(e) {
  // ...
}

上面两种函数声明,根据 ES5 的规定都是非法的。

但是,浏览器没有遵守这个规定,为了兼容以前的旧代码,还是支持在块级作用域之中声明函数,因此上面两种情况实际都能运行,不会报错。

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于let,在块级作用域之外不可引用。

function f() { console.log('I am outside!'); }

(function () {
  if (false) {
    // 重复声明一次函数f
    function f() { console.log('I am inside!'); }
  }

  f();
}());

上面代码在 ES5 中运行,会得到“I am inside!”,因为在if内声明的函数f会被提升到函数头部,实际运行的代码如下。

// ES5 环境
function f() { console.log('I am outside!'); }

(function () {
  function f() { console.log('I am inside!'); }
  if (false) {
  }
  f();
}());

ES6 就完全不一样了,理论上会得到“I am outside!”。因为块级作用域内声明的函数类似于let,对作用域之外没有影响。但是,如果你真的在 ES6 浏览器中运行一下上面的代码,是会报错的,这是为什么呢?

// 浏览器的 ES6 环境
function f() { console.log('I am outside!'); }

(function () {
  if (false) {
    // 重复声明一次函数f
    function f() { console.log('I am inside!'); }
  }

  f();
}());
// Uncaught TypeError: f is not a function

上面的代码在 ES6 浏览器中,都会报错。

原来,如果改变了块级作用域内声明的函数的处理规则,显然会对老代码产生很大影响。

  • 允许在块级作用域内声明函数。
  • 函数声明类似于var,即会提升到全局作用域或函数作用域的头部。
  • 同时,函数声明还会提升到所在的块级作用域的头部。

注意,上面三条规则只对 ES6 的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作let处理。

根据这三条规则,浏览器的 ES6 环境中,块级作用域内声明的函数,行为类似于var声明的变量。上面的例子实际运行的代码如下。

// 浏览器的 ES6 环境
function f() { console.log('I am outside!'); }
(function () {
  var f = undefined;
  if (false) {
    function f() { console.log('I am inside!'); }
  }

  f();
}());
// Uncaught TypeError: f is not a function

考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。

// 块级作用域内部的函数声明语句,建议不要使用
{
  let a = 'secret';
  function f() {
    return a;
  }
}

// 块级作用域内部,优先使用函数表达式
{
  let a = 'secret';
  let f = function () {
    return a;
  };
}

另外,还有一个需要注意的地方。ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。

// 第一种写法,报错
if (true) let x = 1;

// 第二种写法,不报错
if (true) {
  let x = 1;
}

上面代码中,第一种写法没有大括号,所以不存在块级作用域,而let只能出现在当前作用域的顶层,所以报错。第二种写法有大括号,所以块级作用域成立。

函数声明也是如此,严格模式下,函数只能声明在当前作用域的顶层。

// 不报错
'use strict';
if (true) {
  function f() {}
}

// 报错
'use strict';
if (true)
  function f() {}

3.const 命令

3.1 基本用法

const声明一个只读的常量。一旦声明,常量的值就不能改变。

const PI = 3.1415;
PI // 3.1415

PI = 3;
// TypeError: Assignment to constant variable.

上面代码表明改变常量的值会报错。

const声明的变量不得改变值,这意味着,const一旦声明变量,就必须立即初始化,不能留到以后赋值。

const foo;
// SyntaxError: Missing initializer in const declaration

上面代码表示,对于const来说,只声明不赋值,就会报错。

const的作用域与let命令相同:只在声明所在的块级作用域内有效。

if (true) {
  const MAX = 5;
}

MAX // Uncaught ReferenceError: MAX is not defined

const命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。

if (true) {
  console.log(MAX); // ReferenceError
  const MAX = 5;
}

上面代码在常量MAX声明之前就调用,结果报错。

const声明的常量,也与let一样不可重复声明。

var message = "Hello!";
let age = 25;

// 以下两行都会报错
const message = "Goodbye!";
const age = 30;

3.2 本质

const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。

const foo = {};

// 为 foo 添加一个属性,可以成功
foo.prop = 123;
foo.prop // 123

// 将 foo 指向另一个对象,就会报错
foo = {}; // TypeError: "foo" is read-only

上面代码中,常量foo储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把foo指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。

下面是另一个例子。

const a = [];
a.push('Hello'); // 可执行
a.length = 0;    // 可执行
a = ['Dave'];    // 报错

上面代码中,常量a是一个数组,这个数组本身是可写的,但是如果将另一个数组赋值给a,就会报错。

3.3 window 对象的属性和方法(全局作用域中)

全局作用域中,var 声明的变量,function 声明的函数,会自动变成 window 对象的属性或方法。

var age = 18;
function add() {}
console.log(window.age);            // 18
console.log(window.add === add);     // true
let age = 18;
const add = function() {}
console.log(window.age);            // undefined
console.log(window.add === add);     // false

3.4 什么时候用 let,什么使用用 const

原则:如果不知道用什么的时候,就用 const

原因:如果应该是常量,那么刚好符合需求。如果应该是变量,那么后来报错时,再来改为变量也为时不晚。同时,一开始就设置为常量还会避免真的需要为常量时,该值在后来被意外修改的情况。

4.let和const总结

  1. let 声明的变量会产生块作用域,var 不会产生块作用域
  2. const 声明的常量也会产生块作用域
  3. 不同代码块之间的变量无法互相访问
  4. 注意: 对象属性修改和数组元素变化不会出发 const 错误 (数组和对象存的是引用地址)
  5. 应用场景:声明对象类型使用 const,非对象类型声明选择 let
  6. cosnt声明必须赋初始值,标识符一般为大写,值不允许修改。

5.顶层对象的属性

顶层对象,在浏览器环境指的是window对象,在 Node 指的是global对象。ES5 之中,顶层对象的属性与全局变量是等价的。

window.a = 1;
a // 1

a = 2;
window.a // 2

上面代码中,顶层对象的属性赋值与全局变量的赋值,是同一件事。

顶层对象的属性与全局变量挂钩,被认为是 JavaScript 语言最大的设计败笔之一。这样的设计带来了几个很大的问题,首先是没法在编译时就报出变量未声明的错误,只有运行时才能知道(因为全局变量可能是顶层对象的属性创造的,而属性的创造是动态的);其次,程序员很容易不知不觉地就创建了全局变量(比如打字出错);最后,顶层对象的属性是到处可以读写的,这非常不利于模块化编程。另一方面,window对象有实体含义,指的是浏览器的窗口对象,顶层对象是一个有实体含义的对象,也是不合适的。

ES6 为了改变这一点,一方面规定,为了保持兼容性,var命令和function命令声明的全局变量,依旧是顶层对象的属性;另一方面规定,let命令、const命令、class命令声明的全局变量,不属于顶层对象的属性。也就是说,从 ES6 开始,全局变量将逐步与顶层对象的属性脱钩。

var a = 1;
// 如果在 Node 的 REPL 环境,可以写成 global.a
// 或者采用通用方法,写成 this.a
window.a // 1

let b = 1;
window.b // undefined

上面代码中,全局变量avar命令声明,所以它是顶层对象的属性;全局变量blet命令声明,所以它不是顶层对象的属性,返回undefined

03【解构赋值】

1.数组的解构赋值

1.1 原理

ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

以前,为变量赋值,只能直接指定值。

let a = 1;
let b = 2;
let c = 3;

ES6 允许写成下面这样。

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

上面代码表示,可以从数组中提取值,按照对应位置,对变量赋值。

  1. 模式(结构)匹配 [] = [1, 2, 3];

  2. 索引值相同的完成赋值 const [a, b, c] = [1, 2, 3];

  3. 举例

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

1.2 数组解构赋值的默认值

(1)默认值的基本用法

const [a, b] = [];
console.log(a, b);    // undefined undefined

// ---------------------------------------
const [a = 1, b = 2] = [];
console.log(a, b);    // 1 2

(2)默认值的生效条件

只有当一个数组成员严格等于 (===) undefined 时,对应的默认值才会生效。

const [a = 1, b = 2] = [3, 0];        // 3 0
const [a = 1, b = 2] = [3, null];    // 3 null
const [a = 1, b = 2] = [3];            // 3 2

(3)默认值表达式

如果默认值是表达式,默认值表达式是惰性求值的(即:当无需用到默认值时,表达式是不会求值的)

const func = () => {
    return 24;
};

const [a = func()] = [1];    // 1
const [b = func()] = [];    // 24

1.3 数组解构赋值的应用

(1)arguments

function func() {
    const [a, b] = arguments;
    console.log(a, b);    // 1 2
}
func(1, 2);

(2)NodeList

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>NodeList</title>
</head>
<body>
<p>1</p>
<p>2</p>
<p>3</p>
<script>
    const [p1, p2, p3] = document.querySelectorAll('p');
    console.log(p1, p2, p3);
    /*
    <p>1</p>
    <p>2</p>
    <p>3</p>
    */
</script>
</body>
</html>

(3)函数参数的解构赋值

const array = [1, 1];
// const add = arr => arr[0] + arr[1];
const add = ([x = 0, y = 0]) => x + y;
console.log(add(array));    // 2
console.log(add([]));        // 0

(4)交换变量的值

let x = 2, y = 1;

// 原来
let tmp = x;
x = y;
y = tmp;

// 现在
[x, y] = [y, x];
// 理解:[x, y] = [2, 1]
console.log(x, y);
// 1 2

(5)跳过某项值使用逗号隔开

在解构数组时,可以忽略不需要解构的值,可以使用逗号对解构的数组进行忽略操作,这样就不需要声明更多的变量去存值了:

var [a, , , b] = [10, 20, 30, 40];
console.log(a);   // 10
console.log(b);   // 40

上面的例子中,在 a、b 中间用逗号隔开了两个值,这里怎么判断间隔几个值呢,可以看出逗号之间组成了多少间隔,就是间隔了多少个值。如果取值很少的情况下可以使用下标索引的方式来获取值。

(6)剩余参数中的使用

通常情况下,需要把剩余的数组项作为一个单独的数组,这个时候我们可以借助展开语法把剩下的数组中的值,作为一个单独的数组,如下:

var [a, b, ...rest] = [10, 20, 30, 40, 50];
console.log(a);     // 10
console.log(b);     // 20
console.log(rest);  // [30, 40, 50]

在 rest 的后面不能有 逗号 不然会报错,程序会认出你后面还有值。...rest 是剩余参数的解构,所以只能放在数组的最后,在它之后不能再有变量,否则则会报错。

1.4 必须要分号的两种情况

// 1. 立即执行函数
// ;(function () { })();
// (function () { })();

// 2. 使用数组解构的时候
// const arr = [1, 2, 3]
const str = 'pink';
[1, 2, 3].map(function (item) {
  console.log(item)
})

let a = 1
let b = 2
  ;[b, a] = [a, b]

console.log(a, b)

2.对象的解构赋值

2.1 原理

对象的解构和数组基本类似,对象解构的变量是在 {} 中定义的。对象没有索引,但对象有更明确的键,通过键可以很方便地去对象中取值。在 ES6 之前直接使用键取值已经很方便了:

var obj = { name: 'imooc', age: 7 };
var name = obj.name;  // imooc
var age = obj.age;    // 7

但是在 ES6 中通过解构的方式,更加简洁地对取值做了简化,不需要通过点操作增加额外的取值操作。

var obj = { name: 'imooc', age: 7 };
var { name, age } = obj;  // name: imooc, age: 7

{} 直接声明 name 和 age 用逗号隔开即可得到目标对象上的值,完成声明赋值操作。

  1. 模式(结构)匹配 {} = {};
  2. 属性名相同的完成赋值 const {name, age} = {name: 'jerry', age: 18};const {age, name} = {name: 'jerry', age: 18};

2.2 对象解构赋值的默认值

  1. 对象的属性值严格等于 undefined 时,对应的默认值才会生效。

  2. 如果默认值是表达式,默认值表达式是惰性求值的。

对象的默认值和数组的默认值一样,只能通过严格相等运算符(===)来进行判断,只有当一个对象的属性值严格等于 undefined,默认值才会生效。

var {a = 10, b = 5} = {a: 3};                 // a = 3, b = 5
var {a = 10, b = 5} = {a: 3, b: undefined};   // a = 3, b = 5
var {a = 10, b = 5} = {a: 3, b: null};        // a = 3, b = null

所以这里的第二项 b 的值是默认值,第三项的 null === undefined 的值为 false,所以 b 的值为 null。

2.3 重命名属性

在对象解构出来的变量不是我们想要的变量命名,这时我们需要对它进行重命名。

var {a:x = 8, b:y = 3} = {a: 2};

console.log(x); // 2
console.log(y); // 3

这里把 a 和 b 的变量名重新命名为 x 和 y。

2.4 对象解构赋值的应用

(1)对象作为函数参数

// 之前
const logPersonInfo = user => console.log(user.name, user.age);
logPersonInfo({name: 'jerry', age: 18});

// 之后
const logPersonInfo = ({age = 21, name = 'ZJR'}) => console.log(name, age);
logPersonInfo({name: 'jerry', age: 18});    // jerry 18
logPersonInfo({});    // ZJR 21

(2)复杂的嵌套(主要是缕清逻辑关系即可)

const obj = {
    x: 1,
    y: [2, 3, 4],
    z: {
        a: 5,
        b: 6
    }
};

// ----------------------------------------------------
const {x, y, z} = obj;
console.log(x, y, z);    // 1 [ 2, 3, 4 ] { a: 5, b: 6 }

// ----------------------------------------------------
const {y: [, y2]} = obj;
console.log(y2);    // 3
console.log(y);        // 报错

// ----------------------------------------------------
const {y: y, y: [, y2]} = obj;
console.log(y2);    // 3
console.log(y);        // [ 2, 3, 4 ]

// ----------------------------------------------------
const {y, y: [, y2], z, z: {b}} = obj;
console.log(y2);    // 3
console.log(y);        // [ 2, 3, 4 ]
console.log(z);        // { a: 5, b: 6 }
console.log(b);        // 6

(3)剩余参数中的使用

在对象的解构中也可以使用剩余参数,对对象中没有解构的剩余属性做聚合操作,生成一个新的对象。

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

对象中的 b、d 没有被解构,通过剩余参数语法把没有解构的对象属性聚合到一起形成新的对象。

2.5 注意点

(1)如果要将一个已经声明的变量用于解构赋值,必须非常小心。

// 错误的写法
let x;
{x} = {x: 1};
// SyntaxError: syntax error

上面代码的写法会报错,因为 JavaScript 引擎会将{x}理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免 JavaScript 将其解释为代码块,才能解决这个问题。

// 正确的写法
let x;
({x} = {x: 1});

上面代码将整个解构赋值语句,放在一个圆括号里面,就可以正确执行。关于圆括号与解构赋值的关系,参见下文。

(2)解构赋值允许等号左边的模式之中,不放置任何变量名。因此,可以写出非常古怪的赋值表达式。

({} = [true, false]);
({} = 'abc');
({} = []);

上面的表达式虽然毫无意义,但是语法是合法的,可以执行。

(3)由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。

let arr = [1, 2, 3];
let {0 : first, [arr.length - 1] : last} = arr;
first // 1
last // 3

上面代码对数组进行对象解构。数组arr0键对应的值是1[arr.length - 1]就是2键,对应的值是3

3.字符串的解构赋值

既可以用数组的形式来解构赋值,也可以用对象的形式来解构赋值。

// 数组形式解构赋值
const [a, b, , , c] = 'hello';
console.log(a, b, c);    // h e o

// 对象形式解构赋值
const {0: a, 1: b, 4: o, length} = 'hello';
console.log(a, b, o, length);    // h e o 5

4.数值和布尔值的解构赋值

只能按照对象的形式来解构赋值。

(会先自动将等号右边的值转为对象)

// 先来复习一下将数值和布尔值转化为对象
console.log(new Number(123));
console.log(new Boolean(true));
// 转化后的对象里没有任何的属性(没有 123 这个属性,也没有 true 这个属性)和方法,
// 所有的属性和方法都在它的继承 __proto__ 中,比如 toString 方法就是继承来的。

// 里面的值只能是默认值,继承的方法倒是可以取到
const {a = 1, toString} = 123;
console.log(a, toString);    // 1 [Function: toString]

// 里面的值只能是默认值,继承的方法倒是可以取到
const {b = 1, toString} = true;
console.log(b, toString);    // 1 [Function: toString]

知道有这回事即可,一般都用不到,因为没太大意义。

5.undefined 和 null 没有解构赋值

由于 undefined 和 null 无法转为对象,所以对它们进行解构赋值,都会报错。

6.小结

本节讲解了 ES6 解构赋值的使用方法,总结下来一共有以下几点:

  1. 解构赋值一般针对对象和数组,如果解构对象是 undefined 或是 null 都会报错;
  2. 默认值的生效条件是,只有当解构的对象的值是严格模式下的 undefined 的情况下,默认值才会生效;
  3. 可以不借助中间变量来交换两个值;
  4. 在解构复杂的数据解构时,注意声明的对象要和目标的对象有着相同的解构形式,才能去解构目标对象。
  5. 04 【函数的扩展】

1.函数参数的默认值

1.1 认识函数参数的默认值

调用函数的时候传参了,就用传递的参数;如果没传参,就用默认值

1.2 函数参数默认值的基本用法

// 之前的默认值实现方式
const multiply = (x, y) => {
    if (typeof y === 'undefined') {
        y = 3;
    }
    return x * y;
};
console.log(multiply(2, 2));    // 4
console.log(multiply(2));        // 6
// ES6 默认值实现方式
const multiply = (x, y = 3) => {
    return x * y;
};
console.log(multiply(2, 2));    // 4
console.log(multiply(2));        // 6

1.3 默认值的生效条件

不传参数,或者明确的传递 undefined 作为参数,只有这两种情况下,默认值才会生效。

注意:null 就是 null,不会使用默认值。

1.4 与解构赋值默认值结合使用

参数默认值可以与解构赋值的默认值,结合起来使用。

function foo({x, y = 5}) {
  console.log(x, y);
}

foo({}) // undefined 5
foo({x: 1}) // 1 5
foo({x: 1, y: 2}) // 1 2
foo() // TypeError: Cannot read property 'x' of undefined

上面代码只使用了对象的解构赋值默认值,没有使用函数参数的默认值。只有当函数foo()的参数是一个对象时,变量xy才会通过解构赋值生成。如果函数foo()调用时没提供参数,变量xy就不会生成,从而报错。通过提供函数参数的默认值,就可以避免这种情况。

function foo({x, y = 5} = {}) {
  console.log(x, y);
}

foo() // undefined 5

上面代码指定,如果没有提供参数,函数foo的参数默认为一个空对象。

下面是另一个解构赋值默认值的例子。

function fetch(url, { body = '', method = 'GET', headers = {} }) {
  console.log(method);
}

fetch('http://example.com', {})
// "GET"

fetch('http://example.com')
// 报错

上面代码中,如果函数fetch()的第二个参数是一个对象,就可以为它的三个属性设置默认值。这种写法不能省略第二个参数,如果结合函数参数的默认值,就可以省略第二个参数。这时,就出现了双重默认值。

function fetch(url, { body = '', method = 'GET', headers = {} } = {}) {
  console.log(method);
}

fetch('http://example.com')
// "GET"

上面代码中,函数fetch没有第二个参数时,函数参数的默认值就会生效,然后才是解构赋值的默认值生效,变量method才会取到默认值GET

注意,函数参数的默认值生效以后,参数解构赋值依然会进行。

function f({ a, b = 'world' } = { a: 'hello' }) {
  console.log(b);
}

f() // world

上面示例中,函数f()调用时没有参数,所以参数默认值{ a: 'hello' }生效,然后再对这个默认值进行解构赋值,从而触发参数变量b的默认值生效。

1.5 参数默认值的位置

通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。

// 例一
function f(x = 1, y) {
  return [x, y];
}

f() // [1, undefined]
f(2) // [2, undefined]
f(, 1) // 报错
f(undefined, 1) // [1, 1]

// 例二
function f(x, y = 5, z) {
  return [x, y, z];
}

f() // [undefined, 5, undefined]
f(1) // [1, 5, undefined]
f(1, ,2) // 报错
f(1, undefined, 2) // [1, 5, 2]

上面代码中,有默认值的参数都不是尾参数。这时,无法只省略该参数,而不省略它后面的参数,除非显式输入undefined

如果传入undefined,将触发该参数等于默认值,null则没有这个效果。

function foo(x = 5, y = 6) {
  console.log(x, y);
}

foo(undefined, null)
// 5 null

上面代码中,x参数对应undefined,结果触发了默认值,y参数等于null,就没有触发默认值。

1.6 函数参数默认值的应用

接收很多参数的时候

// 普通时候
const logUser = (username = 'zjr', age = 18, sex = 'male') => {
    console.log(username, age, sex);
};
// 需要能够记住参数的顺序,如果参数较多那么需要配合文档,使用不方便
logUser('jerry', 18, 'male');

// ------------------------------------------------------------

// 接收一个对象作为参数
// 不需要记住参数的顺序
const logUser = options => {
    console.log(options.username, options.age, options.sex);
};
logUser({
    username: 'jerry',
    age: 18,
    sex: 'male'
});

// ------------------------------------------------------------

// 再优化
const logUser = ({username, age, sex}) => {
    console.log(username, age, sex);
};

logUser({
    username: 'jerry',
    age: 18,
    sex: 'male'
});

// ------------------------------------------------------------

// 引入默认值
const logUser = ({
    username = 'zjr',
    age = 18,
    sex = 'male'
}) => {
    console.log(username, age, sex);
};

// 其实是解构赋值原理
logUser({username: 'jerry'});    // jerry 18 male

logUser({});    // zjr 18 male

logUser();        // 报错,因为这样相当于传了一个 undefined,不符合解构赋值

// ------------------------------------------------------------

// 再优化(函数默认值 + 解构赋值 + 解构赋值默认值)
const logUser = ({
    username = 'zjr',
    age = 18,
    sex = 'male'
} = {}) => {
    console.log(username, age, sex);
};
logUser();    // zjr 18 male

/* 
解释:
1、options 与 {username = 'zjr', age = 18, sex = 'male'} 互等
2、{username = 'zjr', age = 18, sex = 'male'} = {} 其实就是 options = {}
3、由于 logUser() 的实参为 undefined,所以默认值为 {}
4、再因为 {username = 'zjr', age = 18, sex = 'male'} = {} 是解构赋值
5、由于 {} 内为 undefined,所以解构赋值启用默认值
5、所以真正的形参为 {username = 'zjr', age = 18, sex = 'male'}
注明:这样做的好处是增加函数的健壮性!
*/

某一个参数不得省略,如果省略就抛出一个错误

function throwIfMissing() {
  throw new Error('Missing parameter');
}

function foo(mustBeProvided = throwIfMissing()) {
  return mustBeProvided;
}

foo()
// Error: Missing parameter

上面代码的foo函数,如果调用的时候没有参数,就会调用默认值throwIfMissing函数,从而抛出一个错误。

从上面代码还可以看到,参数mustBeProvided的默认值等于throwIfMissing函数的运行结果(注意函数名throwIfMissing之后有一对圆括号),这表明参数的默认值不是在定义时执行,而是在运行时执行。如果参数已经赋值,默认值中的函数就不会运行。

另外,可以将参数默认值设为undefined,表明这个参数是可以省略的。

function foo(optional = undefined) { ··· }

2.rest 参数

2.1 前言

剩余语法(Rest syntax 也可以叫剩余参数)看起来和展开语法完全相同都是使用 ... 的语法糖,不同之处在于剩余参数用于解构数组和对象。从某种意义上说,剩余语法与展开语法是相反的:展开语法将数组展开为其中的各个元素,而剩余语法则是将多个元素收集起来成为一个整体。

2.2 函数参数

在讲解剩余参数前,我们先来看看,剩余参数在函数参数中都解决了哪些问题?为什么会引入剩余参数的概念?

在 ES5 中,函数经常会传入不定参数,在传入不定参数时,ES5 的给出的解决方案是通过 arguments 对象来获取函数调用时传递的参数。 arguments 对象不是一个数组,它是一个类数组对象,所谓类数组对象,就是指可以通过索引属性访问元素并且拥有 length 属性的对象。

一个简单的类数组对象是长这样的:

var arrLike = {
  0: 'name',
  1: 'age',
  2: 'job',
  length: 3
}

而它所对应的数组应该是这样子的:

var arr = ['name', 'age', 'job'];

这里我们说类数组对象与数组的性质相似,是因为类数组对象在访问赋值获取长度上的操作与数组是一致的,具体内容可查阅相关的类数组使用。

在函数体中定义了 Arguments 对象,其包含函数的参数和其它属性,以 arguments 变量来指代。下面我们看个实例:

function fn() {
    console.log(arguments);
}
fn('imooc', 7, 'ES6')

在控制台中打印出上面的代码结果,如下图所示:在定义函数的时候没有给定参数,但是通过 arguments 对象可以拿到传入的参数。可以看到 arguments 中包含了函数传递的参数、length 等属性,length 属性表示的是实参的长度,即调用函数的时候传入的参数个数。这样我们就对 arguments 对象有了一定的了解。

image-20220826180356855

在 ES5 的开发模式下,想要使用传递的参数,则需要按位置把对应的参数取出来。尽管 arguments 是一个类数组且可遍历的变量,但它终究不是数组,它不支持数组方法,因此我们不能调用 arguments.forEeach (…) 等数组的方法。需要使用一些特殊的方法转换成数组使用,如:

function fn() {
  var arr = [].slice.call(arguments);
  console.log(arr)
}
fn('ES6');
//  ["ES6"]
fn('imooc', 7, 'ES6');
//  ["imooc", 7, "ES6"]

终于借助 call 方法把 arguments 转化成一个真正的数组了。但是这样无疑是一个繁琐的过程,而且不容易理解。这时 ES6 给出了它的完美解决方案 —— 剩余参数,那剩余参数是如何在函数传参中使用的呢?下面我们来看看实例:

语法:const add = (x, y, z, ...args) => {};

function fn(...args) {
  console.log(args)
}
fn('ES6');
//  ["ES6"]
fn('imooc', 7, 'ES6');
//  ["imooc", 7, "ES6"]

使用方式很简单在函数定义时使用 ... 紧接着跟一个收集的参数,这个收集的参数就是我们所传入不定参数的集合 —— 也就是数组。这样就很简单地摆脱了 arguments 的束缚。另外,还可以指定一个默认的参数,如下示例:

function fn(name, ...args) {
  console.log(name);  // 基础参数
  console.log(args);  // 剩下的参数组成的数组
}
fn('ES6');
//    'ES6'
//    []
fn('imooc', 7, 'ES6');
//  "imooc"
//    [7, "ES6"]

上面的代码中给函数第一个参数,声明一个变量 name,剩余的参数会被 ... 收集成一个数组,这就是剩余参数。引入剩余参数就是为了能替代函数内部的 arguments,由于 arguments 对象不具备数组的方法,所以很多时候在使用之前要先转换成一个数组。而剩余参数本来就是一个数组,避免了这多余的一步,使用起来既优雅又自然。

2.3 注意事项

箭头函数的剩余参数

箭头函数的参数部分即使只有一个剩余参数,也不能省略圆括号。

const add = (...args) => {};

使用剩余参数替代 arguments 获取实际参数

  • 剩余参数是一个 “真数组”,arguments 是一个 “伪数组”
  • 剩余参数的名字可以自定义

剩余参数的位置

剩余参数只能是最后一个参数,之后不能再有其他参数,否则会报错。

2.4 剩余参数的应用

作为数组的应用:

const add = (...args) => {
    let sum = 0;

    for (let i = 0; i < args.length; i++) {
        sum += args[i];
    } // 当然此处,arguments 也可以

    return sum;
};

console.log(add());            // 0
console.log(add(1, 1));        // 2
console.log(add(1, 2, 3));    // 6

与解构赋值结合使用:

(剩余参数不一定非要作为函数参数使用)

  • 与数组解构赋值结合
let array = [1, 2, 3, 4, 5];
let [a, b, ...others] = array;
console.log(a);                     // 1
console.log(b);                     // 2
console.log(others);         // [3,4,5]
  • 与对象解构赋值结合
const {x, y, ...z} = {a: 3, x: 1, y: 2, b: 4};
console.log(x, y, z);
// 1 2 { a: 3, b: 4 }
// 这里的剩余参数是个对象(准确的应该叫:剩余元素)
const func = ({x, y, ...z}) => {
    console.log(x, y, z);    // 1 2 { a: 3, b: 4 }
};
func({a: 3, x: 1, y: 2, b: 4});
  • 在函数传参的时候也可以是和解构一起使用
function fun(...[a, b, c]) {
  return a + b + c;
}
fun('1')          // NaN (b 和 c 都是 undefined)
fun(1, 2, 3)      // 6
fun(1, 2, 3, 4)   // 6 多余的参数不会被获取到

上面的代码中,a、b、c 会去解构传入参数,加上有剩余语法的作用,对应的值从数组中的项解构出来,在函数内部直接使用解构出来的参数即可。剩余语法看起来和展开语法完全相同,不同点在于,剩余参数用于解构数组和对象。

2.5 小结

本节结合了 ES5 函数中的 arguments 对象引入了为什么 ES6 会引入剩余参数的概念,可以看到剩余参数所带来的好处。本节内容可以总结以下几点:

  1. 剩余参数是为了能替代函数内部的 arguments 而引入的;
  2. 和展开语法相反,剩余参数是将多个单个元素聚集起来形成一个单独的个体的过程。

3.箭头函数

3.1 前言

在编程中使用最多的就是函数,在 ES5 中是用 function 关键字来定义函数的,由于历史原因 function 定义的函数存在一些问题,如 this 的指向、函数参数 arguments 等。

ES6 规定了可以使用 “箭头” => 来定义一个函数,语法更加简洁。它没有自己的 thisargumentssupernew.target,箭头函数表达式更适用于那些本来需要匿名函数的地方,但它不能用作构造函数。

3.2 认识箭头函数

普通函数:

  • function 函数名() {}
  • const 变量名 = function () {};

箭头函数:

  • 参数 => 函数体

  • () => {}

由于箭头函数是匿名函数,所以我们通常把它赋给一个变量

const add = (x, y) => {
    return x + y;
};

console.log(add(1, 1));        // 2

3.3 箭头函数注意事项

3.3.1 省略写法
const add = (x) => {
    return x + 1;
};

// 单个参数可以省略 ()
const add = x => {
    return x + 1;
};

// 无参数
const test = () => {
    return 1;
};
//或者
const test = _ => {
    return 1;
};
3.3.2 单行函数体
const add = (x, y) => {
    return x + y;
};

// 单行函数体可以省略 return 和 {},且一但省略就 return 和 {} 都要一起省略
const add = (x, y) => x + y; 
3.3.3 单行对象
const add = (x, y) => {
    return {
        value: x + y
    };
};

// const add = (x, y) => {value: x + y};  报错!因为 {} 会产生歧义!
// () 可以将语句变为表达式,从而 {} 就可以被顺理成章解释为对象
const add = (x, y) => ({value: x + y});

// 数组就没有以上问题
const add = (x, y) => [x, y];

推荐:一般情况最好不要简写!

3.4 非箭头函数中的 this 指向

3.4.1 全局作用域中的 this 指向
console.log(this);
// window
3.4.2 一般函数(非箭头函数)中的 this 指向

只有在函数调用的时候 this 指向才能确定,不调用的时候,不知道指向谁。

this 指向和函数在哪儿没有关系,只和谁在调用有关。

function add() {
    console.log(this);
}

add();    // window
// 在非严格模式下,this 其实是先指向 undefined,然后被自动转为了 window
'use strict'    // 严格模式

function add() {
    console.log(this);
}

add();    // undefined
// 在严格模式下,this 为 undefined
'use strict'    // 严格模式

function add() {
    console.log(this);
}

const calc = {
    add: add
};

calc.add();        // 上下文 this 为 calc

const adder = calc.add;
adder();        // 指向 undefined(非严格模式下指向 window)

3.5 箭头函数没有 this

在 JavaScript 中,要说让人最头疼的知识点中,this 绑定绝对算一个,这是因为 this 的绑定 ‘难以捉摸’,出错的时候还往往不知道为什么,相当反逻辑。下面我们来看一个示例:

var title = "全局标题";
var imooc = {
    title: "慕课网 ES6 Wiki",
    getTitle : function(){
        console.log(this.title);
    }
};
imooc.getTitle();        // 慕课网 ES6 Wiki
var bar = imooc.getTitle;
bar();        // 全局标题

通过上面的小例子的打印结果可以看出 this 的问题,说明 this 的指向是不固定的。

这里简单说明一下 this 的指向,this 指向的是调用它的对象。例子中的 this 是在 getTitle 的函数中的,执行 imooc.getTitle() 这个方法时,调用它的对象是 imooc,所以 this 的指向是 imooc

之后把 imooc.getTitle 方法赋给 bar,这里要注意的是,只是把地址赋值给了 bar ,并没有调用。 而 bar 是全局对象 window 下的方法,所以在执行 bar 方法时,调用它的是 Window 对象,所以这里打印的结果是 window 下的 title——“全局标题”。

TIPS: 上面的示例只是简单的 this 指向问题,还有很多更加复杂的,在面试中经常会被问到,所以还不清楚的同学可以去研究一下 this 的问题。

ES6 为了规避这样的问题,提出了箭头函数的解决方案,在箭头函数中没有自己的 this 指向,所有的 this 指向都指向它的上一层 this ,这样规定就比较容易理解了。下面看使用箭头函数下的 this 指向:

var title = "全局标题";
var imooc = {
    title: "慕课网 ES6 Wiki",
    getTitle : () => {
        console.log(this.title);
    }
};
imooc.getTitle();        // 全局标题
var bar = imooc.getTitle;
bar();        // 全局标题

上面的打印结果可以看出来,所有的 this 指向都指向了 window 对象下的 title,本身的 imooc 对象下没有了 this ,它的上一层就是 window。

3.6 不适用箭头函数的场景

3.6.1 作为构造函数

因为箭头函数没有 this,而构造函数的核心就是 this。

3.6.2 需要 this 指向调用对象的时候

因为箭头函数没有 this,所以如果箭头函数中出现了 this,那么这个 this 就是外层的!

3.6.3 需要使用 arguments 的时候

箭头函数没有 arguments。

(这个问题有替代解决方案:剩余参数)

var fun = function() {
  console.log(arguments)
};
fun(1,2,3);  // Arguments(3) [1, 2, 3, callee: ƒ, Symbol(Symbol.iterator): ƒ]

var fun = () => {
  console.log(arguments)
};
fun(1,2,3);  // Uncaught ReferenceError: arguments is not defined

上面的示例中,对比两种定义函数的方法可以明显的看出,在箭头函数中去取 arguments 时会报引用错误,没有定义的 arguments

arguments 的主要作用是获取所有调用函数时所需要传入的参数,在箭头函数中使用剩余参数 ...args,在函数内可以直接使用。

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

3.7 其他注意点

3.7.1 不能用作构造器

箭头函数不能用作构造器,和 new 一起用会抛出错误。

var Foo = () => {};
var foo = new Foo(); // TypeError: Foo is not a constructor
3.7.2 没有 prototype 属性

箭头函数没有 prototype 属性。

var Foo = () => {};
console.log(Foo.prototype); // undefined
3.7.3 不能使用 yield 命令

yield 关键字通常不能在箭头函数中使用,因此箭头函数不能用作 Generator 函数。

3.8 小结

本节主要讲解了 ES6 的箭头函数,总结了以下几点:

  • 更短的函数,优雅简洁;
  • 箭头函数不会创建自己的 this,它只会从自己的作用域链的上一层继承 this;
  • 不能绑定 arguments, 只能使用 ...args 展开运算来获取当前参数的数组。

4.函数参数的尾逗号

ES2017 允许函数的最后一个参数有尾逗号(trailing comma)。

此前,函数定义和调用时,都不允许最后一个参数后面出现逗号。

function clownsEverywhere(
  param1,
  param2
) { /* ... */ }

clownsEverywhere(
  'foo',
  'bar'
);

上面代码中,如果在param2bar后面加一个逗号,就会报错。

如果像上面这样,将参数写成多行(即每个参数占据一行),以后修改代码的时候,想为函数clownsEverywhere添加第三个参数,或者调整参数的次序,就势必要在原来最后一个参数后面添加一个逗号。这对于版本管理系统来说,就会显示添加逗号的那一行也发生了变动。这看上去有点冗余,因此新的语法允许定义和调用时,尾部直接有一个逗号。

function clownsEverywhere(
  param1,
  param2,
) { /* ... */ }

clownsEverywhere(
  'foo',
  'bar',
);

这样的规定也使得,函数参数与数组和对象的尾逗号规则,保持一致了。

5.catch 命令的参数省略

JavaScript 语言的try...catch结构,以前明确要求catch命令后面必须跟参数,接受try代码块抛出的错误对象。

try {
  // ...
} catch (err) {
  // 处理错误
}

上面代码中,catch命令后面带有参数err

很多时候,catch代码块可能用不到这个参数。但是,为了保证语法正确,还是必须写。ES2019做出了改变,允许catch语句省略参数。

try {
  // ...
} catch {
  // ...
}

05【数组的扩展】

1.扩展运算符

1.1 含义

扩展运算符(spread)是三个点(...)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。

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

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

[...document.querySelectorAll('div')]
// [<div>, <div>, <div>]

该运算符主要用于函数调用。

function push(array, ...items) {
  array.push(...items);
}

function add(x, y) {
  return x + y;
}

const numbers = [4, 38];
add(...numbers) // 42

上面代码中,array.push(...items)add(...numbers)这两行,都是函数的调用,它们都使用了扩展运算符。该运算符将一个数组,变为参数序列。

扩展运算符与正常的函数参数可以结合使用,非常灵活。

function f(v, w, x, y, z) { }
const args = [0, 1];
f(-1, ...args, 2, ...[3]);

扩展运算符后面还可以放置表达式。

const arr = [
  ...(x > 0 ? ['a'] : []),
  'b',
];

如果扩展运算符后面是一个空数组,则不产生任何效果。

[...[], 1]
// [1]

注意,只有函数调用时,扩展运算符才可以放在圆括号中,否则会报错。

(...[1, 2])
// Uncaught SyntaxError: Unexpected number

console.log((...[1, 2]))
// Uncaught SyntaxError: Unexpected number

console.log(...[1, 2])
// 1 2

上面三种情况,扩展运算符都放在圆括号里面,但是前两种情况会报错,因为扩展运算符所在的括号不是函数调用。

1.2 替代函数的 apply() 方法

由于扩展运算符可以展开数组,所以不再需要apply()方法将数组转为函数的参数了。

// ES5 的写法
function f(x, y, z) {
  // ...
}
var args = [0, 1, 2];
f.apply(null, args);

// ES6 的写法
function f(x, y, z) {
  // ...
}
let args = [0, 1, 2];
f(...args);

下面是扩展运算符取代apply()方法的一个实际的例子,应用Math.max()方法,简化求出一个数组最大元素的写法。

// ES5 的写法
Math.max.apply(null, [14, 3, 77])

// ES6 的写法
Math.max(...[14, 3, 77])

// 等同于
Math.max(14, 3, 77);

上面代码中,由于 JavaScript 不提供求数组最大元素的函数,所以只能套用Math.max()函数,将数组转为一个参数序列,然后求最大值。有了扩展运算符以后,就可以直接用Math.max()了。

另一个例子是通过push()函数,将一个数组添加到另一个数组的尾部。

// ES5 的写法
var arr1 = [0, 1, 2];
var arr2 = [3, 4, 5];
Array.prototype.push.apply(arr1, arr2);

// ES6 的写法
let arr1 = [0, 1, 2];
let arr2 = [3, 4, 5];
arr1.push(...arr2);

上面代码的 ES5 写法中,push()方法的参数不能是数组,所以只好通过apply()方法变通使用push()方法。有了扩展运算符,就可以直接将数组传入push()方法。

下面是另外一个例子。

// ES5
new (Date.bind.apply(Date, [null, 2015, 1, 1]))

// ES6
new Date(...[2015, 1, 1]);

1.3 扩展运算符的应用

(1)复制数组

数组是复合的数据类型,直接复制的话,只是复制了指向底层数据结构的指针,而不是克隆一个全新的数组。

const a1 = [1, 2];
const a2 = a1;

a2[0] = 2;
a1 // [2, 2]

上面代码中,a2并不是a1的克隆,而是指向同一份数据的另一个指针。修改a2,会直接导致a1的变化。

ES5 只能用变通方法来复制数组。

const a1 = [1, 2];
const a2 = a1.concat();

a2[0] = 2;
a1 // [1, 2]

上面代码中,a1会返回原数组的克隆,再修改a2就不会对a1产生影响。

扩展运算符提供了复制数组的简便写法。

const a1 = [1, 2];
// 写法
const a2 = [...a1];

上面的两种写法,a2都是a1的克隆。

(2)合并数组

扩展运算符提供了数组合并的新写法。

const arr1 = ['a', 'b'];
const arr2 = ['c'];
const arr3 = ['d', 'e'];

// ES5 的合并数组
arr1.concat(arr2, arr3);
// [ 'a', 'b', 'c', 'd', 'e' ]

// ES6 的合并数组
[...arr1, ...arr2, ...arr3]
// [ 'a', 'b', 'c', 'd', 'e' ]

不过,这两种方法都是浅拷贝,使用的时候需要注意。

const a1 = [{ foo: 1 }];
const a2 = [{ bar: 2 }];

const a3 = a1.concat(a2);
const a4 = [...a1, ...a2];

a3[0] === a1[0] // true
a4[0] === a1[0] // true

上面代码中,a3a4是用两种不同方法合并而成的新数组,但是它们的成员都是对原数组成员的引用,这就是浅拷贝。如果修改了引用指向的值,会同步反映到新数组。

(4)字符串转为数组

扩展运算符还可以将字符串转为真正的数组。

console.log(...'alex');                // a l e x
console.log('a', 'l', 'e', 'x');    // a l e x

console.log([...'alex']);            // [ 'a', 'l', 'e', 'x' ]
// ES6 之前字符串转数组是通过:'alex'.split('');

(5)类数组转为数组

// arguments
function func() {
    console.log(arguments);            // [Arguments] { '0': 1, '1': 2 }
    console.log([...arguments]);    // [ 1, 2 ]
}
func(1, 2);

// NodeList
console.log([...document.querySelectorAll('p')].push);

2.Array.from()

2.1 前言

在前端开发中经常会遇到类数组,但是我们不能直接使用数组的方法,需要先把类数组转化为数组。本节介绍 ES6 数组的新增方法 Array.from(),该方法用于将类数组对象(array-like)和可遍历的对象(iterable)转换为真正的数组进行使用。

2.2 方法详情

2.2.1 基本语法

Array.from() 方法会接收一个类数组对象然后返回一个真正的数组实例,返回的数组可以调用数组的所有方法。

语法使用:

Array.from(arrayLike[, mapFn[, thisArg]])

参数解释:

参数描述
arrayLike想要转换成数组的类数组对象或可迭代对象
mapFn如果指定了该参数,新数组中的每个元素会执行该回调函数
thisArg可选参数,执行回调函数 mapFn 时 this 对象

2.2 类数组转化

所谓类数组对象,就是指可以通过索引属性访问元素,并且对象拥有 length 属性,类数组对象一般是以下这样的结构:

var arrLike = {
  '0': 'apple',
  '1': 'banana',
  '2': 'orange',
  length: 3
};

在 ES5 中没有对应的方法将类数组转化为数组,但是可以借助 call 和 apply 来实现:

var arr = [].slice.call(arrLike);
// 或
var arr = [].slice.apply(arrLike);

有了 ES6 的 Array.from() 就更简单了,对类数组对象直接操作,即可得到数组。

var arr = Array.from(arrLike);
console.log(arr)  // ['apple', 'banana', 'orange']
2.2.3 第二个参数 —— 回调函数

Array.from 中第二个参数是一个类似 map 函数的回调函数,该回调函数会依次接收数组中的每一项作为传入的参数,然后对传入值进行处理,最得到一个新的数组。
Array.from(obj, mapFn, thisArg) 也可以用 map 改写成这样 Array.from(obj).map(mapFn, thisArg)

var arr = Array.from([1, 2, 3], function (x) {
  return 2 * x;
});
var arr = Array.from([1, 2, 3]).map(function (x) {
  return 2 * x;
});
//arr: [2, 4, 6]

上面的例子展示了,Array.from 的参数可以使用 map 方法来进行替换,它们是等价的操作。

2.2.4 第三个参数 ——this

Array.from 中第三个参数可以对回调函数中 this 的指向进行绑定,该参数是非常有用的,我们可以将被处理的数据和处理对象分离,将各种不同的处理数据的方法封装到不同的的对象中去,处理方法采用相同的名字。

在调用 Array.from 对数据对象进行转换时,可以将不同的处理对象按实际情况进行注入,以得到不同的结果,适合解耦。

let obj = {
  handle: function(n){
    return n + 2
  }
}

Array.from([1, 2, 3, 4, 5], function (x){
  return this.handle(x)
}, obj)
// [3, 4, 5, 6, 7]

定义一个 obj 对象可以认作是,Array.from 回调函数中处理数据的方法集合,handle 是其中的一个方法,把 obj 作为第三个参数传给 Array.from 这样在回调函数中可以通过 this 来拿到 obj 对象。

2.2.5 从字符串里生成数组

Array.from() 在传入字符串时,会把字符串的每一项都拆成单个的字符串作为数组中的一项。

Array.from('imooc'); 
// [ "i", "m", "o", "o", "c" ]
2.2.6 从 Set 中生成数组

Set 定义的数组对象,可以使用 Array.from() 得到一个正常的数组。

const set = new Set(['a', 'b', 'c', 'd']);
Array.from(set);
// [ "a", "b", "c", "d" ]

上面的代码中创建了一个 Set 数据结构,把实例传入 Array.from() 可以得到一个真正的数组。

2.2.7 从 Map 中生成数组

Map 对象保存的是一个个键值对,Map 中的参数是一个数组或是一个可迭代的对象。 Array.from() 可以把 Map 实例转换为一个二维数组。

const map = new Map([[1, 2], [2, 4], [4, 8]]);

Array.from(map);  // [[1, 2], [2, 4], [4, 8]]

2.3 使用案例

2.3.1 创建一个包含从 0 到 99 (n) 的连续整数的数组
  1. 一般情况下我们可以使用 for 循环来实现。
var arr = [];
for(var i = 0; i <= 99; i++) {
  arr.push(i);
}

这种方法的主要优点是最直观了,性能也最好的,但是很多时候我们不想使用 for 循环来进行操作。

  1. 使用 Array 配合 map 来实现。
var arr = Array(100).join(' ').split('').map(function(item,index){return index});

Array (100) 创建了一个包含 100 个空位的数组,但是这样创建出来的数组是没法进行迭代的。所以要通过字符串转换,覆盖 undefined,最后调用 map 修改元素值。

  1. 使用 es6 的 Array.from 实现。
var arr = Array.from({length:100}).map(function(item,index){return index});

Array.from({length:100}) 可以定义一个可迭代的数组,数组的每一项都是 undefined,这样就非常方便的定义出所需要的数组了,但是这样定义的数组性能最差,具体可以参考 constArray 的测试结果。

2.3.2 数组去重合并
function combine(){ 
  let arr = [].concat.apply([], arguments);  //没有去重复的新数组 
  return Array.from(new Set(arr));
} 

var m = [1, 2, 2], n = [2,3,3]; 
console.log(combine(m,n));                     // [1, 2, 3]

首先定义一个去重数组函数,通过 concat 把传入的数组进行合并到一个新的数组中去,通过 new Set () 可以对 arr 进行去重操作,再使用 Array.from() 返回一个拷贝后的数组。

2.4 小结

本节讲解了字符串的 Array.from() 方法的使用,用于将类数组对象和可迭代的对象转化真正的数组,在编程中主要用于更加方便的初始化一个有默认值的数组,还可以用于将获取的 html 的 DOM 对象转化为数组,可以使用数组方法进行操作。

3.Array.of()

Array.of()方法用于将一组值,转换为数组。

Array.of(3, 11, 8) // [3,11,8]
Array.of(3) // [3]
Array.of(3).length // 1

这个方法的主要目的,是弥补数组构造函数Array()的不足。因为参数个数的不同,会导致Array()的行为有差异。

Array() // []
Array(3) // [, , ,]
Array(3, 11, 8) // [3, 11, 8]

上面代码中,Array()方法没有参数、一个参数、三个参数时,返回的结果都不一样。只有当参数个数不少于 2 个时,Array()才会返回由参数组成的新数组。参数只有一个正整数时,实际上是指定数组的长度。

Array.of()基本上可以用来替代Array()new Array(),并且不存在由于参数不同而导致的重载。它的行为非常统一。

Array.of() // []
Array.of(undefined) // [undefined]
Array.of(1) // [1]
Array.of(1, 2) // [1, 2]

Array.of()总是返回参数值组成的数组。如果没有参数,就返回一个空数组。

Array.of()方法可以用下面的代码模拟实现。

function ArrayOf(){
  return [].slice.call(arguments);
}

4.find(),findIndex(),findLast(),findLastIndex()

数组实例的find()方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为true的成员,然后返回该成员。如果没有符合条件的成员,则返回undefined

[1, 4, -5, 10].find((n) => n < 0)
// -5

上面代码找出数组中第一个小于 0 的成员。

[1, 5, 10, 15].find(function(value, index, arr) {
  return value > 9;
}) // 10

上面代码中,find()方法的回调函数可以接受三个参数,依次为当前的值、当前的位置和原数组。

数组实例的findIndex()方法的用法与find()方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1

[1, 5, 10, 15].findIndex(function(value, index, arr) {
  return value > 9;
}) // 2

这两个方法都可以接受第二个参数,用来绑定回调函数的this对象。

function f(v){
  return v > this.age;
}
let person = {name: 'John', age: 20};
[10, 12, 26, 15].find(f, person);    // 26

上面的代码中,find()函数接收了第二个参数person对象,回调函数中的this对象指向person对象。

另外,这两个方法都可以发现NaN,弥补了数组的indexOf()方法的不足。

[NaN].indexOf(NaN)
// -1

[NaN].findIndex(y => Object.is(NaN, y))
// 0

上面代码中,indexOf()方法无法识别数组的NaN成员,但是findIndex()方法可以借助Object.is()方法做到。

find()findIndex()都是从数组的0号位,依次向后检查。ES2022 新增了两个方法findLast()findLastIndex(),从数组的最后一个成员开始,依次向前检查,其他都保持不变。

const array = [
  { value: 1 },
  { value: 2 },
  { value: 3 },
  { value: 4 }
];

array.findLast(n => n.value % 2 === 1); // { value: 3 }
array.findLastIndex(n => n.value % 2 === 1); // 2

上面示例中,findLast()findLastIndex()从数组结尾开始,寻找第一个value属性为奇数的成员。结果,该成员是{ value: 3 },位置是2号位。

5.filter()

filter()方法用于过滤数组成员,满足条件的成员组成一个新数组返回。

它的参数是一个函数,所有数组成员依次执行该函数,返回结果为true的成员组成一个新数组返回。该方法不会改变原数组。

[1, 2, 3, 4, 5].filter(function (elem) {
  return (elem > 3);
})
// [4, 5]

上面代码将大于3的数组成员,作为一个新数组返回。

var arr = [0, 1, 'a', false];

arr.filter(Boolean)
// [1, "a"]

上面代码中,filter()方法返回数组arr里面所有布尔值为true的成员。

filter()方法的参数函数可以接受三个参数:当前成员,当前位置和整个数组。

[1, 2, 3, 4, 5].filter(function (elem, index, arr) {
  return index % 2 === 0;
});
// [1, 3, 5]

上面代码返回偶数位置的成员组成的新数组。

filter()方法还可以接受第二个参数,用来绑定参数函数内部的this变量。

var obj = { MAX: 3 };
var myFilter = function (item) {
  if (item > this.MAX) return true;
};

var arr = [2, 8, 3, 4, 1, 3, 2, 9];
arr.filter(myFilter, obj) // [8, 4, 9]

上面代码中,过滤器myFilter()内部有this变量,它可以被filter()方法的第二个参数obj绑定,返回大于3的成员。

6.map()

map()方法将数组的所有成员依次传入参数函数,然后把每一次的执行结果组成一个新数组返回。

var numbers = [1, 2, 3];

numbers.map(function (n) {
  return n + 1;
});
// [2, 3, 4]

numbers
// [1, 2, 3]

上面代码中,numbers数组的所有成员依次执行参数函数,运行结果组成一个新数组返回,原数组没有变化。

map()方法接受一个函数作为参数。该函数调用时,map()方法向它传入三个参数:当前成员、当前位置和数组本身。

[1, 2, 3].map(function(elem, index, arr) {
  return elem * index;
});
// [0, 2, 6]

上面代码中,map()方法的回调函数有三个参数,elem为当前成员的值,index为当前成员的位置,arr为原数组([1, 2, 3])。

map()方法还可以接受第二个参数,用来绑定回调函数内部的this变量(详见《this 变量》一章)。

var arr = ['a', 'b', 'c'];

[1, 2].map(function (e) {
  return this[e];
}, arr)
// ['b', 'c']

上面代码通过map()方法的第二个参数,将回调函数内部的this对象,指向arr数组。

如果数组有空位,map()方法的回调函数在这个位置不会执行,会跳过数组的空位。

var f = function (n) { return 'a' };

[1, undefined, 2].map(f) // ["a", "a", "a"]
[1, null, 2].map(f) // ["a", "a", "a"]
[1, , 2].map(f) // ["a", , "a"]

上面代码中,map()方法不会跳过undefinednull,但是会跳过空位。

7.reduce()

reduce()方法依次处理数组的每个成员,最终累计为一个值。它们的差别是,reduce()是从左到右处理(从第一个成员到最后一个成员)。

语法:arr.reduce(function(累计值, 当前元素){}, 起始值)

[1, 2, 3, 4, 5].reduce(function (a, b) {
  console.log(a, b);
  return a + b;
})
// 1 2
// 3 3
// 6 4
// 10 5
//最后结果:15

上面代码中,reduce()方法用来求出数组所有成员的和。reduce()的参数是一个函数,数组每个成员都会依次执行这个函数。如果数组有 n 个成员,这个参数函数就会执行 n - 1 次。

  • 第一次执行:a是数组的第一个成员1b是数组的第二个成员2
  • 第二次执行:a为上一轮的返回值3b为第三个成员3
  • 第三次执行:a为上一轮的返回值6b为第四个成员4
  • 第四次执行:a为上一轮返回值10b为第五个成员5。至此所有成员遍历完成,整个方法的返回值就是最后一轮的返回值15

reduce()方法的第一个参数都是一个函数。该函数接受以下四个参数。

  1. 累积变量。第一次执行时,默认为数组的第一个成员;以后每次执行时,都是上一轮的返回值。
  2. 当前变量。第一次执行时,默认为数组的第二个成员;以后每次执行时,都是下一个成员。
  3. 当前位置。一个整数,表示第二个参数(当前变量)的位置,默认为1
  4. 原数组。

这四个参数之中,只有前两个是必须的,后两个则是可选的。

[1, 2, 3, 4, 5].reduce(function (
  a,   // 累积变量,必须
  b,   // 当前变量,必须
  i,   // 当前位置,可选
  arr  // 原数组,可选
) {
  // ... ...

如果要对累积变量指定初值,可以把它放在reduce()方法的第二个参数。

[1, 2, 3, 4, 5].reduce(function (a, b) {
  return a + b;
}, 10);
// 25

上面代码指定参数a的初值为10,所以数组从10开始累加,最终结果为25。注意,这时b是从数组的第一个成员开始遍历,参数函数会执行5次。

建议总是加上第二个参数,这样比较符合直觉,每个数组成员都会依次执行reduce()方法的参数函数。另外,第二个参数可以防止空数组报错。

function add(prev, cur) {
  return prev + cur;
}

[].reduce(add)
// TypeError: Reduce of empty array with no initial value
[].reduce(add, 1)
// 1

上面代码中,由于空数组取不到累积变量的初始值,reduce()方法会报错。这时,加上第二个参数,就能保证总是会返回一个值。

总结

//reduce 返回函数累计处理的结果,经常用于求和等
/*
计值参数:
1. 如果有起始值,则以起始值为准开始累计, 累计值 = 起始值
2. 如果没有起始值, 则累计值以数组的第一个数组元素作为起始值开始累计
3. 后面每次遍历就会用后面的数组元素 累计到 累计值 里面(类似求和里面的 sum )
*/

8.some(),every()

这两个方法类似“断言”(assert),返回一个布尔值,表示判断数组成员是否符合某种条件。

它们接受一个函数作为参数,所有数组成员依次执行该函数。该函数接受三个参数:当前成员、当前位置和整个数组,然后返回一个布尔值。

some方法是只要一个成员的返回值是true,则整个some方法的返回值就是true,否则返回false

var arr = [1, 2, 3, 4, 5];
arr.some(function (elem, index, arr) {
  return elem >= 3;
});
// true

上面代码中,如果数组arr有一个成员大于等于3,some方法就返回true

every方法是所有成员的返回值都是true,整个every方法才返回true,否则返回false

var arr = [1, 2, 3, 4, 5];
arr.every(function (elem, index, arr) {
  return elem >= 3;
});
// false

上面代码中,数组arr并非所有成员大于等于3,所以返回false

注意,对于空数组,some方法返回falseevery方法返回true,回调函数都不会执行。

function isEven(x) { return x % 2 === 0 }

[].some(isEven) // false
[].every(isEven) // true

someevery方法还可以接受第二个参数,用来绑定参数函数内部的this变量。

9.fill()

arr.fill(value[, start[, end]])方法用一个固定值填充一个数组中从起始索引到终止索引内的全部元素。不包括终止索引。
起始索引,默认值为 0。
终止索引,默认值为 this.length。

['a', 'b', 'c'].fill(7)
// [7, 7, 7]

new Array(3).fill(7)
// [7, 7, 7]

上面代码表明,fill方法用于空数组的初始化非常方便。数组中已有的元素,会被全部抹去。

fill方法还可以接受第二个和第三个参数,用于指定填充的起始位置和结束位置。

['a', 'b', 'c'].fill(7, 1, 2)
// ['a', 7, 'c']

上面代码表示,fill方法从 1 号位开始,向原数组填充 7,到 2 号位之前结束。

注意,如果填充的类型为对象,那么被赋值的是同一个内存地址的对象,而不是深拷贝对象。

let arr = new Array(3).fill({name: "Mike"});
arr[0].name = "Ben";
arr
// [{name: "Ben"}, {name: "Ben"}, {name: "Ben"}]

let arr = new Array(3).fill([]);
arr[0].push(5);
arr
// [[5], [5], [5]]

10.at()

长久以来,JavaScript 不支持数组的负索引,如果要引用数组的最后一个成员,不能写成arr[-1],只能使用arr[arr.length - 1]

这是因为方括号运算符[]在 JavaScript 语言里面,不仅用于数组,还用于对象。对于对象来说,方括号里面就是键名,比如obj[1]引用的是键名为字符串1的键,同理obj[-1]引用的是键名为字符串-1的键。由于 JavaScript 的数组是特殊的对象,所以方括号里面的负数无法再有其他语义了,也就是说,不可能添加新语法来支持负索引。

为了解决这个问题,ES2022为数组实例增加了at()方法,接受一个整数作为参数,返回对应位置的成员,并支持负索引。这个方法不仅可用于数组,也可用于字符串和类型数组(TypedArray)。

const arr = [5, 12, 8, 130, 44];
arr.at(2) // 8
arr.at(-2) // 130

如果参数位置超出了数组范围,at()返回undefined

const sentence = 'This is a sample sentence';

sentence.at(0); // 'T'
sentence.at(-1); // 'e'

sentence.at(-100) // undefined
sentence.at(100) // undefined

11.entries(),keys() 和 values()

ES6 提供三个新的方法——entries()keys()values()——用于遍历数组。它们都返回一个遍历器对象,可以用for...of循环进行遍历,唯一的区别是keys()是对键名的遍历、values()是对键值的遍历,entries()是对键值对的遍历。

for (let index of ['a', 'b'].keys()) {
  console.log(index);
}
// 0
// 1

for (let elem of ['a', 'b'].values()) {
  console.log(elem);
}
// 'a'
// 'b'

for (let [index, elem] of ['a', 'b'].entries()) {
  console.log(index, elem);
}
// 0 "a"
// 1 "b"

12.includes()

Array.prototype.includes方法返回一个布尔值,表示某个数组是否包含给定的值,与字符串的includes方法类似。ES2016 引入了该方法。

[1, 2, 3].includes(2)     // true
[1, 2, 3].includes(4)     // false
[1, 2, NaN].includes(NaN) // true

该方法的第二个参数表示搜索的起始位置,默认为0。如果第二个参数为负数,则表示倒数的位置,如果这时它大于数组长度(比如第二个参数为-4,但数组长度为3),则会重置为从0开始。

[1, 2, 3].includes(3, 3);  // false
[1, 2, 3].includes(3, -1); // true

没有该方法之前,我们通常使用数组的indexOf方法,检查是否包含某个值。

if (arr.indexOf(el) !== -1) {
  // ...
}

indexOf方法有两个缺点,一是不够语义化,它的含义是找到参数值的第一个出现位置,所以要去比较是否不等于-1,表达起来不够直观。二是,它内部使用严格相等运算符(===)进行判断,这会导致对NaN的误判。

[NaN].indexOf(NaN)
// -1

includes使用的是不一样的判断算法,就没有这个问题。

[NaN].includes(NaN)
// true

另外,Map 和 Set 数据结构有一个has方法,需要注意与includes区分。

  • Map 结构的has方法,是用来查找键名的,比如Map.prototype.has(key)WeakMap.prototype.has(key)Reflect.has(target, propertyKey)
  • Set 结构的has方法,是用来查找值的,比如Set.prototype.has(value)WeakSet.prototype.has(value)

13.toReversed(),toSorted(),toSpliced(),with()

很多数组的传统方法会改变原数组,比如push()pop()shift()unshift()等等。数组只要调用了这些方法,它的值就变了。现在有一个提案,允许对数组进行操作时,不改变原数组,而返回一个原数组的拷贝。

这样的方法一共有四个。

  • Array.prototype.toReversed() -> Array
  • Array.prototype.toSorted(compareFn) -> Array
  • Array.prototype.toSpliced(start, deleteCount, ...items) -> Array
  • Array.prototype.with(index, value) -> Array

它们分别对应数组的原有方法。

  • toReversed()对应reverse(),用来颠倒数组成员的位置。
  • toSorted()对应sort(),用来对数组成员排序。
  • toSpliced()对应splice(),用来在指定位置,删除指定数量的成员,并插入新成员。
  • with(index, value)对应splice(index, 1, value),用来将指定位置的成员替换为新的值。

上面是这四个新方法对应的原有方法,含义和用法完全一样,唯一不同的是不会改变原数组,而是返回原数组操作后的拷贝。

下面是示例。

const sequence = [1, 2, 3];
sequence.toReversed() // [3, 2, 1]
sequence // [1, 2, 3]

const outOfOrder = [3, 1, 2];
outOfOrder.toSorted() // [1, 2, 3]
outOfOrder // [3, 1, 2]

const array = [1, 2, 3, 4];
array.toSpliced(1, 2, 5, 6, 7) // [1, 5, 6, 7, 4]
array // [1, 2, 3, 4]

const correctionNeeded = [1, 1, 3];
correctionNeeded.with(1, 2) // [1, 2, 3]
correctionNeeded // [1, 1, 3]

14.isArray()

14.1 前言

在程序中判断数组是很常见的应用,但在 ES5 中没有能严格判断 JS 对象是否为数组,都会存在一定的问题,比较受广大认可的是借助 toString 来进行判断,很显然这样不是很简洁。ES6 提供了 Array.isArray() 方法更加简洁地判断 JS 对象是否为数组。

14.2 方法详情

判断 JS 对象,如果值是 Array,则为 true; 否则为 false。

语法使用:

Array.isArray(obj)

参数解释:

参数描述
obj需要检测的 JS 对象

14.3 ES5 中判断数组的方法

通常使用 typeof 来判断变量的数据类型,但是对数组得到不一样的结果

// 基本类型
typeof 123;  //number
typeof "123"; //string
typeof true; //boolean

// 引用类型
typeof [1,2,3]; //object

上面的代码中,对于基本类型的判断没有问题,但是判断数组时,返回了 object 显然不能使用 typeof 来作为判断数组的方法。

14.3.1 通过 instanceof 判断

instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链。

instanceof 可以用来判断数组是否存在,判断方式如下:

var arr = ['a', 'b', 'c'];
console.log(arr instanceof Array);            // true 
console.log(arr.constructor === Array;); // true

在解释上面的代码时,先看下数组的原型链指向示意图:

image-20220826200454480

数组实例的原型链指向的是 Array.prototype 属性,instanceof 运算符就是用来检测 Array.prototype 属性是否存在于数组的原型链上,上面代码中的 arr 变量就是一个数组,所有拥有 Array.prototype 属性,返回值 true,这样就很好的判断数组类型了。

但是,需要注意的是,prototype 属性是可以修改的,所以并不是最初判断为 true 就一定永远为真。

14.3.2 通过 constructor 判断

我们知道,Array 是 JavaScript 内置的构造函数,构造函数属性(prototype)的 constructor 指向构造函数(见下图),那么通过 constructor 属性也可以判断是否为一个数组。

var arr = new Array('a', 'b', 'c');
arr.constructor === Array;    //true

下面我们通过构造函数的示意图来进行分析:

image-20220826200839160

由上面的示意图可以知道,我们 new 出来的实例对象上的原型对象有 constructor 属性指向构造函数 Array,由此我们可以判断一个数组类型。

但是 constructor 是可以被重写,所以不能确保一定是数组,如下示例:

var str = 'abc';
str.constructor = Array;
str.constructor === Array // true

上面的代码中,str 显然不是数组,但是可以把 constructor 指向 Array 构造函数,这样再去进行判断就是有问题的了。

constructorinstanceof 也存在同样问题,不同执行环境下,constructor 的判断也有可能不正确。

14.4 Array.isArray () 的使用

下面我们通过示例来看下 Array.isArray() 是怎样判断数组的。

// 下面的函数调用都返回 true
Array.isArray([]);
Array.isArray([10]);
Array.isArray(new Array());
Array.isArray(new Array('a', 'b', 'c'))
// 鲜为人知的事实:其实 Array.prototype 也是一个数组。
Array.isArray(Array.prototype); 

// 下面的函数调用都返回 false
Array.isArray();
Array.isArray({});
Array.isArray(null);
Array.isArray(undefined);
Array.isArray(17);
Array.isArray('Array');
Array.isArray(true);
Array.isArray(false);
Array.isArray(new Uint8Array(32))
Array.isArray({ __proto__: Array.prototype });

上面的代码中对 JavaScript 中的数据类型做验证,可以很好地区分数组类型。

14.5 自定义 isArray

在 ES5 中比较通用的方法是使用 Object.prototype.toString 去判断一个值的类型,也是各大主流库的标准。在不支持 ES6 语法的环境下可以使用下面的方法给 Array 上添加 isArray 方法

if (!Array.isArray){
  Array.isArray = function(arg){
    return Object.prototype.toString.call(arg) === '[object Array]';
  };
}

14.6 小结

本节介绍了判断一个值是数组类型的方法 Array.isArray() 此方法可以很准确地判断数组,学习了在 ES5 中判断数组类型的几个方法的缺陷。在不支持 ES6 的情况下也可以通过 Object.prototype.toString 自定义 Array.isArray() 方法。

06【对象的扩展】

1.属性的简洁表示法

ES6 允许在大括号里面,直接写入变量和函数,作为对象的属性和方法。这样的书写更加简洁。

const foo = 'bar';
const baz = {foo};
baz // {foo: "bar"}

// 等同于
const baz = {foo: foo};

上面代码中,变量foo直接写在大括号里面。这时,属性名就是变量名, 属性值就是变量值。下面是另一个例子。

function f(x, y) {
  return {x, y};
}

// 等同于

function f(x, y) {
  return {x: x, y: y};
}

f(1, 2) // Object {x: 1, y: 2}

除了属性简写,方法也可以简写。

const o = {
  method() {
    return "Hello!";
  }
};

// 等同于

const o = {
  method: function() {
    return "Hello!";
  }
};

下面是一个实际的例子。

let birth = '2000/01/01';

const Person = {

  name: '张三',

  //等同于birth: birth
  birth,

  // 等同于hello: function ()...
  hello() { console.log('我的名字是', this.name); }

};

这种写法用于函数的返回值,将会非常方便。

function getPoint() {
  const x = 1;
  const y = 10;
  return {x, y};
}

getPoint()
// {x:1, y:10}

2.方括号语法

2.1 方括号语法的用法

const prop = 'age';
const person = {};
person.prop = 18;
console.log(person);	// { prop: 18 }

// -----------------------------------------

const prop = 'age';
const person = {};
person[prop] = 18;
console.log(person);	// { age: 18 }

// -----------------------------------------

// ES6 增强
const prop = 'age';
const person = {
    [prop]: 18
};
console.log(person);	// { age: 18 }

2.2 方括号中可以放什么

// [值、可以得到值的表达式]
const prop = 'age';
const func = () => 'age2';
const person = {
    [prop]: 18,
    [func()]: 24,
    ['sex']: 'man',
    ['s' + 'ex2']: 'womam'
};
console.log(person);	// { age: 18, age2: 24, sex: 'man', sex2: 'womam' }

注意,属性名表达式如果是一个对象,默认情况下会自动将对象转为字符串[object Object],这一点要特别小心。

const keyA = {a: 1};
const keyB = {b: 2};

const myObject = {
  [keyA]: 'valueA',
  [keyB]: 'valueB'
};

myObject // Object {[object Object]: "valueB"}

上面代码中,[keyA][keyB]得到的都是[object Object],所以[keyB]会把[keyA]覆盖掉,而myObject最后只有一个[object Object]属性。

2.3 方括号语法和点语法的区别

  1. 点语法是方括号语法的特殊形式
  2. 属性名由数字、字母、下划线以及 $ 构成,并且数字还不能打头的时候可以使用点语法(合法标识符)
  3. 能用点语法优先使用点语法
const person = {
    age: 18
};

person.age 等价于 person['age']

3.super 关键字

我们知道,this关键字总是指向函数所在的当前对象,ES6 又新增了另一个类似的关键字super,指向当前对象的原型对象。

const proto = {
  foo: 'hello'
};

const obj = {
  foo: 'world',
  find() {
    return super.foo;
  }
};

Object.setPrototypeOf(obj, proto);
obj.find() // "hello"

上面代码中,对象obj.find()方法之中,通过super.foo引用了原型对象protofoo属性。

注意,super关键字表示原型对象时,只能用在对象的方法之中,用在其他地方都会报错。

// 报错
const obj = {
  foo: super.foo
}

// 报错
const obj = {
  foo: () => super.foo
}

// 报错
const obj = {
  foo: function () {
    return super.foo
  }
}

上面三种super的用法都会报错,因为对于 JavaScript 引擎来说,这里的super都没有用在对象的方法之中。第一种写法是super用在属性里面,第二种和第三种写法是super用在一个函数里面,然后赋值给foo属性。目前,只有对象方法的简写法可以让 JavaScript 引擎确认,定义的是对象的方法。

JavaScript 引擎内部,super.foo等同于Object.getPrototypeOf(this).foo(属性)或Object.getPrototypeOf(this).foo.call(this)(方法)。

const proto = {
  x: 'hello',
  foo() {
    console.log(this.x);
  },
};

const obj = {
  x: 'world',
  foo() {
    super.foo();
  }
}

Object.setPrototypeOf(obj, proto);

obj.foo() // "world"

上面代码中,super.foo指向原型对象protofoo方法,但是绑定的this却还是当前对象obj,因此输出的就是world

4.对象的展开运算符

4.1 展开对象

对象不能直接展开,必须在 {} 中展开。

const apple = {
    color: '红色',
    shape: '球形',
    taste: '甜'
};
console.log({...apple});			// { color: '红色', shape: '球形', taste: '甜' }
console.log({...apple} === apple);	// false

4.2 合并对象

const apple = {
    color: '红色',
    shape: '球形',
    taste: '甜'
};

const pen = {
    color: '黑色',
    shape: '圆柱形',
    use: '写字'
};

// 新对象拥有全部属性,相同属性,后者覆盖前者
console.log({...apple, ...pen});	// { color: '黑色', shape: '圆柱形', taste: '甜', use: '写字' }
console.log({...pen, ...apple});	// { color: '红色', shape: '球形', use: '写字', taste: '甜' }

4.3 注意事项

4.3.1 空对象的展开

如果展开一个空对象,则没有任何效果。

console.log({...{}});			// {}
console.log({...{}, a: 1});		// { a: 1 }
4.3.2 非对象的展开

如果展开的不是对象,则会自动将其转为对象,再将其属性罗列出来(没有属性便为空)。

console.log({...1});			// {}
console.log(new Object(1));		// [Number: 1]
console.log({...undefined});	// {}
console.log({...null});			// {}
console.log({...true});			// {}
4.3.3 字符串的展开

如果展开运算符后面是字符串,它会自动转成一个类似数组的对象,因此返回的不是空对象。

// 字符串在对象中展开
console.log({...'alex'});		// { '0': 'a', '1': 'l', '2': 'e', '3': 'x' }

// 字符串在数组中展开
console.log([...'alex']);		// [ 'a', 'l', 'e', 'x' ]

// 字符串直接展开
console.log(...'alex');			// a l e x
4.3.4 数组的展开
console.log({...[1, 2, 3]});	// { '0': 1, '1': 2, '2': 3 }
4.3.5 对象中对象属性的展开

不会展开对象中的对象属性。

const apple = {
    feature: {
        taste: '甜'
    }
};

const pen = {
    feature: {
        color: '黑色',
        shape: '圆柱形'
    },
    use: '写字'
};

console.log({...apple});			// { feature: { taste: '甜' } }

// feature 会直接覆盖,因为 feature 不能展开
console.log({...apple, ...pen});	// { feature: { color: '黑色', shape: '圆柱形' }, use: '写字' }

4.4 对象展开运算符的应用

4.4.1 复制对象
const a = {x: 1, y: 2};
const c = {...a};
console.log(c, c === a);
// { x: 1, y: 2 } false
4.4.2 用户参数和默认参数
const logUser = userParam => {
    const defaultPeram = {
        username: 'ZhangSan',
        age: 0,
        sex: 'male'
    };

    const param = {...defaultPeram, ...userParam};
    console.log(param.username, param.age, param.sex);
};

logUser({username: 'jerry'});	// jerry 0 male

再优化:

const logUser = userParam => {
    const defaultPeram = {
        username: 'ZhangSan',
        age: 0,
        sex: 'male'
    };

    const {username, age, sex} = {...defaultPeram, ...userParam};
    console.log(username, age, sex);
};

logUser({username: 'jerry'});	// jerry 0 male

5.对象的新增方法

5.1 Object.is()

ES5 比较两个值是否相等,只有两个运算符:相等运算符(==)和严格相等运算符(===)。它们都有缺点,前者会自动转换数据类型,后者的NaN不等于自身,以及+0等于-0。JavaScript 缺乏一种运算,在所有环境中,只要两个值是一样的,它们就应该相等。

ES6 提出“Same-value equality”(同值相等)算法,用来解决这个问题。Object.is就是部署这个算法的新方法。它用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。

Object.is('foo', 'foo')
// true
Object.is({}, {})
// false

不同之处只有两个:一是+0不等于-0,二是NaN等于自身。

+0 === -0 //true
NaN === NaN // false

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

5.2 Object.assign()

Object.assign()方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。

const target = { a: 1 };

const source1 = { b: 2 };
const source2 = { c: 3 };

Object.assign(target, source1, source2);
target // {a:1, b:2, c:3}

注意,如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性。

const target = { a: 1, b: 1 };

const source1 = { b: 2, c: 2 };
const source2 = { c: 3 };

Object.assign(target, source1, source2);
target // {a:1, b:2, c:3}

汇总

// 基本用法
// Object.assign(目标对象, 源对象1, 源对象2, ...);
const apple = {
    color: '红色',
    shape: '圆形',
    taste: '甜'
};
const pen = {
    color: '黑色',
    shape: '圆柱形',
    use: '写字'
};
console.log(Object.assign(apple, pen));	
// 后面的覆盖前面的(最终返回的不是新的,而是修改了前面的)
// { color: '黑色', shape: '圆柱形', taste: '甜', use: '写字' }
// Object.assign 直接合并到了第一个参数中,返回的就是合并后的对象
console.log(apple);	// { color: '黑色', shape: '圆柱形', taste: '甜', use: '写字' }
console.log(Object.assign(apple, pen) === apple);	// true


// 可以合并多个对象
// 第一个参数使用一个空对象来实现合并返回一个新对象的目的
console.log(Object.assign({}, apple, pen));	// { color: '黑色', shape: '圆柱形', taste: '甜', use: '写字' }
console.log(apple);	// { color: '红色', shape: '圆形', taste: '甜' }
console.log({...apple, ...pen}); // { color: '黑色', shape: '圆柱形', taste: '甜', use: '写字' }


// 注意事项
// (1) 基本数据类型作为源对象
// 与对象的展开类似,先转换成对象,再合并
console.log(Object.assign({}, undefined));	// {}
console.log(Object.assign({}, null));		// {}
console.log(Object.assign({}, 1));			// {}
console.log(Object.assign({}, true));		// {}
console.log(Object.assign({}, 'str'));		// { '0': 's', '1': 't', '2': 'r' }
// (2) 同名属性的替换
// 后面的直接覆盖前面的
const apple = {
    color: ['红色', '黄色'],
    shape: '圆形',
    taste: '甜'
};
const pen = {
    color: ['黑色', '银色'],
    shape: '圆柱形',
    use: '写字'
};
console.log(Object.assign({}, apple, pen));	// { color: [ '黑色', '银色' ], shape: '圆柱形', taste: '甜', use: '写字' }


// 应用
// 合并默认参数和用户参数
const logUser = userOptions => {
    const DEFAULTS = {
        username: 'ZhangSan',
        age: 0,
        sex: 'male'
    };

    const options = Object.assign({}, DEFAULTS, userOptions);
    console.log(options);
};
logUser();						// { username: 'ZhangSan', age: 0, sex: 'male' }
logUser({});					// { username: 'ZhangSan', age: 0, sex: 'male' }
logUser({username: 'Alex'});	// { username: 'Alex', age: 0, sex: 'male' }

5.3 Object.keys()、Object.values() 和 Object.entries()

Object.keys方法,返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键名。

Object.values方法返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键值。

Object.entries()方法返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键值对数组。

// 基本用法
const person = {
    name: 'Alex',
    age: 18
};
// 返回键数组
console.log(Object.keys(person));		// [ 'name', 'age' ]
// 返回值数组
console.log(Object.values(person));		// [ 'Alex', 18 ]
// 返回键值二维数组
console.log(Object.entries(person));	// [ [ 'name', 'Alex' ], [ 'age', 18 ] ]


// 与数组类似方法的区别
console.log([1, 2].keys());			// Object [Array Iterator] {}
console.log([1, 2].values());		// Object [Array Iterator] {}
console.log([1, 2].entries());		// Object [Array Iterator] {}
// 数组的 keys()、values()、entries() 等方法是实例方法,返回的都是 Iterator
// 对象的 Object.keys()、Object.values()、Object.entries() 等方法是构造函数方法,返回的是数组


// 应用(使用 for...of 循环遍历对象)
const person = {
    name: 'Alex',
    age: 18
};
for (const key of Object.keys(person)) {
    console.log(key);		
}
// name
// age
for (const value of Object.values(person)) {
    console.log(value);		
}
// Alex
// 18
for (const entries of Object.entries(person)) {
    console.log(entries);	
}
// [ 'name', 'Alex' ]
// [ 'age', 18 ]
for (const [key, value] of Object.entries(person)) {
    console.log(key, value);
}
// name Alex
// age 18

// Object.keys()/values()/entires() 并不能保证顺序一定是你看到的样子,这一点和 for in 是一样的
// 如果对遍历顺序有要求那么不能用 for in 以及这种方法,而要用其他方法

07【字符串的扩展】

1.模板字符串

1.1 认识模板字符串

  • 普通字符串:
'字符串'
"字符串"
  • 模板字符串:
`字符串`

1.2 模板字符串与一般字符串的区别

  • 对于普通用法没有区别
const name1 = 'zjr';
const name2 = `zjr`;
console.log(name1, name2, name1 === name2);
// zjr zjr true
  • 字符串拼接的巨大区别
const person = {
    name: 'zjr',
    age: 18,
    sex: '男'
};

const info =
    '我的名字是:' + person.name +
    ',性别是:' + person.sex +
    ',今年:' + person.age + '岁';

console.log(info);

// 我的名字是:zjr,性别是:男,今年:18岁
const person = {
    name: `zjr`,
    age: 18,
    sex: ``
};

const info = `我的名字是:${person.name},性别是:${person.sex},今年:${person.age}`;

console.log(info);

// 我的名字是:zjr,性别是:male,今年:18岁

模板字符串最大的优势:方便注入!

1.3 模板字符串的注意事项

1.3.1 输出多行字符串
// 一般字符串
const info = '第一行\n第二行';
console.log(info);
/*
第一行
第二行
*/


// 模板字符串
const info = `第一行
第二行`;	// 注意不能有缩进
console.log(info);
/*
第一行
第二行
*/

模板字符串中,所有的空格、换行或缩进都会被保存在输出中

1.3.2 输出 `` ` 等特殊字符
const info = `\``;	// ```
const info = `\\`;	// `\`
const info = `""`;	// `""`
const info = `''`;	// `''`
1.3.3 模板字符串的注入
const username = 'alex';
const person = {
    age: 18,
    sex: `male`
};
const getSex = function (sex) {
    return sex === `male` ? '男' : '女';
};

const info = `${username},${person.age + 2},${getSex(person.sex)}`;
console.log(info);

// alex,20,男

模板字符串的 ${} 注入可以兼容几乎所有的值!

模板字符串、字符串、数值、布尔值、表达式、函数……(只要结果是个 “值” 即可)

1.4 模板字符串的应用

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <title>模板字符串的应用</title>
    <style>
        body {
            padding: 50px 0 0 300px;
            font-size: 22px;
        }

        ul {
            padding: 0;
        }

        p {
            margin-bottom: 10px;
        }
    </style>
</head>
<body>
<p>学生信息表</p>
<ul id="list">
    <li style="list-style: none;">信息加载中……</li>
</ul>

<script>
    // 数据(此处只是模拟数据,后期是通过 Ajax 从后台获取)
    const students = [
        {
            username: 'Alex',
            age: 18,
            sex: 'male'
        },
        {
            username: 'ZhangSan',
            age: 28,
            sex: 'male'
        },
        {
            username: 'LiSi',
            age: 20,
            sex: 'female'
        }
    ];

    const list = document.getElementById('list');

    let html = '';

    for (let i = 0; i < students.length; i++) {
        html += `<li>我的名字是:${students[i].username},${students[i].sex},${students[i].age}</li>`;
    }

    list.innerHTML = html;
</script>
</body>
</html>
image-20220315130229559

2.includes(), startsWith(), endsWith()

传统上,JavaScript 只有indexOf方法,可以用来确定一个字符串是否包含在另一个字符串中。ES6 又提供了三种新方法。

  • includes():返回布尔值,表示是否找到了参数字符串。
  • startsWith():返回布尔值,表示参数字符串是否在原字符串的头部。
  • endsWith():返回布尔值,表示参数字符串是否在原字符串的尾部。
let s = 'Hello world!';

s.startsWith('Hello') // true
s.endsWith('!') // true
s.includes('o') // true

这三个方法都支持第二个参数,表示开始搜索的位置。

let s = 'Hello world!';

s.startsWith('world', 6) // true
s.endsWith('Hello', 5) // true
s.includes('Hello', 6) // false

上面代码表示,使用第二个参数n时,endsWith的行为与其他两个方法有所不同。它针对前n个字符,而其他两个方法针对从第n个位置直到字符串结束。

3.repeat()

repeat方法返回一个新字符串,表示将原字符串重复n次。

'x'.repeat(3) // "xxx"
'hello'.repeat(2) // "hellohello"
'na'.repeat(0) // ""

参数如果是小数,会被取整。

'na'.repeat(2.9) // "nana"

如果repeat的参数是负数或者Infinity,会报错。

'na'.repeat(Infinity)
// RangeError
'na'.repeat(-1)
// RangeError

但是,如果参数是 0 到-1 之间的小数,则等同于 0,这是因为会先进行取整运算。0 到-1 之间的小数,取整以后等于-0repeat视同为 0。

'na'.repeat(-0.9) // ""

参数NaN等同于 0。

'na'.repeat(NaN) // ""

如果repeat的参数是字符串,则会先转换成数字。

'na'.repeat('na') // ""
'na'.repeat('3') // "nanana"

4.padStart(),padEnd()

ES2017 引入了字符串补全长度的功能。如果某个字符串不够指定长度,会在头部或尾部补全。padStart()用于头部补全,padEnd()用于尾部补全。

'x'.padStart(5, 'ab') // 'ababx'
'x'.padStart(4, 'ab') // 'abax'

'x'.padEnd(5, 'ab') // 'xabab'
'x'.padEnd(4, 'ab') // 'xaba'

上面代码中,padStart()padEnd()一共接受两个参数,第一个参数是字符串补全生效的最大长度,第二个参数是用来补全的字符串。

如果原字符串的长度,等于或大于最大长度,则字符串补全不生效,返回原字符串。

'xxx'.padStart(2, 'ab') // 'xxx'
'xxx'.padEnd(2, 'ab') // 'xxx'

如果用来补全的字符串与原字符串,两者的长度之和超过了最大长度,则会截去超出位数的补全字符串。

'abc'.padStart(10, '0123456789')
// '0123456abc'

如果省略第二个参数,默认使用空格补全长度。

'x'.padStart(4) // '   x'
'x'.padEnd(4) // 'x   '

padStart()的常见用途是为数值补全指定位数。下面代码生成 10 位的数值字符串。

'1'.padStart(10, '0') // "0000000001"
'12'.padStart(10, '0') // "0000000012"
'123456'.padStart(10, '0') // "0000123456"

另一个用途是提示字符串格式。

'12'.padStart(10, 'YYYY-MM-DD') // "YYYY-MM-12"
'09-12'.padStart(10, 'YYYY-MM-DD') // "YYYY-09-12"

5.trimStart(),trimEnd()

trimStart()trimEnd()这两个方法,它们的行为与trim()一致,trimStart()消除字符串头部的空格,trimEnd()消除尾部的空格。它们返回的都是新字符串,不会修改原始字符串。

const s = '  abc  ';

s.trim() // "abc"
s.trimStart() // "abc  "
s.trimEnd() // "  abc"

上面代码中,trimStart()只消除头部的空格,保留尾部的空格。trimEnd()也是类似行为。

除了空格键,这两个方法对字符串头部(或尾部)的 tab 键、换行符等不可见的空白符号也有效。

浏览器还部署了额外的两个方法,trimLeft()trimStart()的别名,trimRight()trimEnd()的别名。

6.at()

at()方法接受一个整数作为参数,返回参数指定位置的字符,支持负索引(即倒数的位置)。

const str = 'hello';
str.at(1) // "e"
str.at(-1) // "o"

如果参数位置超出了字符串范围,at()返回undefined

08【运算符的扩展】

1.指数运算符

ES2016 新增了一个指数运算符(**)。

2 ** 2 // 4
2 ** 3 // 8

这个运算符的一个特点是右结合,而不是常见的左结合。多个指数运算符连用时,是从最右边开始计算的。

// 相当于 2 ** (3 ** 2)
2 ** 3 ** 2
// 512

上面代码中,首先计算的是第二个指数运算符,而不是第一个。

指数运算符可以与等号结合,形成一个新的赋值运算符(**=)。

let a = 1.5;
a **= 2;
// 等同于 a = a * a;

let b = 4;
b **= 3;
// 等同于 b = b * b * b;

2.链判断运算符

编程实务中,如果读取对象内部的某个属性,往往需要判断一下,属性的上层对象是否存在。比如,读取message.body.user.firstName这个属性,安全的写法是写成下面这样。

// 错误的写法
const  firstName = message.body.user.firstName || 'default';

// 正确的写法
const firstName = (message
  && message.body
  && message.body.user
  && message.body.user.firstName) || 'default';

上面例子中,firstName属性在对象的第四层,所以需要判断四次,每一层是否有值。

三元运算符?:也常用于判断对象是否存在。

const fooInput = myForm.querySelector('input[name=foo]')
const fooValue = fooInput ? fooInput.value : undefined

上面例子中,必须先判断fooInput是否存在,才能读取fooInput.value

这样的层层判断非常麻烦,因此 ES2020 引入了“链判断运算符”(optional chaining operator)?.,简化上面的写法。

const firstName = message?.body?.user?.firstName || 'default';
const fooValue = myForm.querySelector('input[name=foo]')?.value

上面代码使用了?.运算符,直接在链式调用的时候判断,左侧的对象是否为nullundefined。如果是的,就不再往下运算,而是返回undefined

下面是判断对象方法是否存在,如果存在就立即执行的例子。

iterator.return?.()

上面代码中,iterator.return如果有定义,就会调用该方法,否则iterator.return直接返回undefined,不再执行?.后面的部分。

对于那些可能没有实现的方法,这个运算符尤其有用。

if (myForm.checkValidity?.() === false) {
  // 表单校验失败
  return;
}

上面代码中,老式浏览器的表单对象可能没有checkValidity()这个方法,这时?.运算符就会返回undefined,判断语句就变成了undefined === false,所以就会跳过下面的代码。

链判断运算符?.有三种写法。

  • obj?.prop // 对象属性是否存在
  • obj?.[expr] // 同上
  • func?.(...args) // 函数或对象方法是否存在

下面是obj?.[expr]用法的一个例子。

let hex = "#C0FFEE".match(/#([A-Z]+)/i)?.[1];

上面例子中,字符串的match()方法,如果没有发现匹配会返回null,如果发现匹配会返回一个数组,?.运算符起到了判断作用。

下面是?.运算符常见形式,以及不使用该运算符时的等价形式。

a?.b
// 等同于
a == null ? undefined : a.b

a?.[x]
// 等同于
a == null ? undefined : a[x]

a?.b()
// 等同于
a == null ? undefined : a.b()

a?.()
// 等同于
a == null ? undefined : a()

上面代码中,特别注意后两种形式,如果a?.b()a?.()。如果a?.b()里面的a.b有值,但不是函数,不可调用,那么a?.b()是会报错的。a?.()也是如此,如果a不是nullundefined,但也不是函数,那么a?.()会报错。

使用这个运算符,有几个注意点。

(1)短路机制

本质上,?.运算符相当于一种短路机制,只要不满足条件,就不再往下执行。

a?.[++x]
// 等同于
a == null ? undefined : a[++x]

上面代码中,如果aundefinednull,那么x不会进行递增运算。也就是说,链判断运算符一旦为真,右侧的表达式就不再求值。

(2)括号的影响

如果属性链有圆括号,链判断运算符对圆括号外部没有影响,只对圆括号内部有影响。

(a?.b).c
// 等价于
(a == null ? undefined : a.b).c

上面代码中,?.对圆括号外部没有影响,不管a对象是否存在,圆括号后面的.c总是会执行。

一般来说,使用?.运算符的场合,不应该使用圆括号。

(3)报错场合

以下写法是禁止的,会报错。

// 构造函数
new a?.()
new a?.b()

// 链判断运算符的右侧有模板字符串
a?.`{b}`
a?.b`{c}`

// 链判断运算符的左侧是 super
super?.()
super?.foo

// 链运算符用于赋值运算符左侧
a?.b = c

3.Null 判断运算符

读取对象属性的时候,如果某个属性的值是nullundefined,有时候需要为它们指定默认值。常见做法是通过||运算符指定默认值。

const headerText = response.settings.headerText || 'Hello, world!';
const animationDuration = response.settings.animationDuration || 300;
const showSplashScreen = response.settings.showSplashScreen || true;

上面的三行代码都通过||运算符指定默认值,但是这样写是错的。开发者的原意是,只要属性的值为nullundefined,默认值就会生效,但是属性的值如果为空字符串或false0,默认值也会生效。

为了避免这种情况,ES2020引入了一个新的 Null 判断运算符??。它的行为类似||,但是只有运算符左侧的值为nullundefined时,才会返回右侧的值。

const headerText = response.settings.headerText ?? 'Hello, world!';
const animationDuration = response.settings.animationDuration ?? 300;
const showSplashScreen = response.settings.showSplashScreen ?? true;

上面代码中,默认值只有在左侧属性值为nullundefined时,才会生效。

这个运算符的一个目的,就是跟链判断运算符?.配合使用,为nullundefined的值设置默认值。

const animationDuration = response.settings?.animationDuration ?? 300;

上面代码中,如果response.settingsnullundefined,或者response.settings.animationDurationnullundefined,就会返回默认值300。也就是说,这一行代码包括了两级属性的判断。

这个运算符很适合判断函数参数是否赋值。

function Component(props) {
  const enable = props.enabled ?? true;
  // …
}

上面代码判断props参数的enabled属性是否赋值,基本等同于下面的写法。

function Component(props) {
  const {
    enabled: enable = true,
  } = props;
  // …
}

??本质上是逻辑运算,它与其他两个逻辑运算符&&||有一个优先级问题,它们之间的优先级到底孰高孰低。优先级的不同,往往会导致逻辑运算的结果不同。

现在的规则是,如果多个逻辑运算符一起使用,必须用括号表明优先级,否则会报错。

// 报错
lhs && middle ?? rhs
lhs ?? middle && rhs
lhs || middle ?? rhs
lhs ?? middle || rhs

上面四个表达式都会报错,必须加入表明优先级的括号。

(lhs && middle) ?? rhs;
lhs && (middle ?? rhs);

(lhs ?? middle) && rhs;
lhs ?? (middle && rhs);

(lhs || middle) ?? rhs;
lhs || (middle ?? rhs);

(lhs ?? middle) || rhs;
lhs ?? (middle || rhs);

4.逻辑赋值运算符

ES2021 引入了三个新的逻辑赋值运算符(logical assignment operators),将逻辑运算符与赋值运算符进行结合。

// 或赋值运算符
x ||= y
// 等同于
x || (x = y)

// 与赋值运算符
x &&= y
// 等同于
x && (x = y)

// Null 赋值运算符
x ??= y
// 等同于
x ?? (x = y)

这三个运算符||=&&=??=相当于先进行逻辑运算,然后根据运算结果,再视情况进行赋值运算。

它们的一个用途是,为变量或属性设置默认值。

// 老的写法
user.id = user.id || 1;

// 新的写法
user.id ||= 1;

上面示例中,user.id属性如果不存在,则设为1,新的写法比老的写法更紧凑一些。

下面是另一个例子。

function example(opts) {
  opts.foo = opts.foo ?? 'bar';
  opts.baz ?? (opts.baz = 'qux');
}

上面示例中,参数对象opts如果不存在属性foo和属性baz,则为这两个属性设置默认值。有了“Null 赋值运算符”以后,就可以统一写成下面这样。

function example(opts) {
  opts.foo ??= 'bar';
  opts.baz ??= 'qux';
}

09【Set 和 Map 数据结构】

1.什么是 Set?

Set 是一系列无序、没有重复值的数据集合。

数组是一系列有序(下标索引)的数据集合。

const s = new Set();
s.add(1);
s.add(2);

// Set 中不能有重复的成员
s.add(1);
console.log(s);		// Set(2) { 1, 2 }

// Set 没有下标去标识每一个值,所以 Set 是无序的,也不能像数组那样通过下标去访问 Set 的成员。

2.Set 实例的方法和属性

2.1 add 方法

const s = new Set();
s.add(0);
// 可以连写
s.add(1).add(2).add(2).add(3);
console.log(s);		// Set(4) { 0, 1, 2, 3 }

2.2 has 方法

const s = new Set();
s.add(0);
s.add(1).add(2).add(2).add(3);
console.log(s.has(1));	// true
console.log(s.has(4));	// false

2.3 delete 方法

const s = new Set();
s.add(0);
s.add(1).add(2).add(2).add(3);
s.delete(2);
// 使用 delete 删除不存在的成员,什么都不会发生,也不会报错
s.delete(4);
console.log(s);	// Set(3) { 0, 1, 3 }

2.4 clear 方法

const s = new Set();
s.add(0);
s.add(1).add(2).add(2).add(3);
s.clear();
console.log(s);	// Set(0) {}

2.5 forEach 方法

作用:用于遍历 Set 的(按照成员添加进集合的顺序遍历)。

forEach 方法可以接受两个参数,第一个是:回调函数,第二个是:指定回调函数的 this 指向。

const s = new Set();
s.add(0);
s.add(1).add(2).add(2).add(3);

s.forEach(function (value, key, set) {
    // Set 中 value = key,原因:好多数据结构都有 forEach 方法,为了方便统一,所以参数是统一的,但是参数的意义各有不同
    // set 就是 s 本身
    console.log(value, key, set === s);
    console.log(this);
});

/*
0 0 true
Window
1 1 true
Window
2 2 true
Window
3 3 true
Window 
*/
const s = new Set();
s.add(0);
s.add(1).add(2).add(2).add(3);

s.forEach(function (value, key, set) {
    // Set 中 value = key,原因:好多数据结构都有 forEach 方法,为了方便统一,所以参数是统一的,但是参数的意义各有不同
    // set 就是 s 本身
    console.log(value, key, set === s);
    console.log(this);
}, document);

/*
0 0 true
#document
1 1 true
#document
2 2 true
#document
3 3 true
#document
*/

2.6 size 属性

const s = new Set();
s.add(0);
s.add(1).add(2).add(2).add(3);

console.log(s.size);	// 4

3.Set 构造函数的参数

  • 数组
  • 字符串、arguments、NodeList、Set 等

【数组】

const s = new Set([1, 2, 1]);
console.log(s);		// Set(2) { 1, 2 }

【字符串】

console.log(new Set('hiii'));	// Set(2) { 'h', 'i' }

【arguments】

function func() {
    console.log(new Set(arguments));
}
func(1, 2, 1);	// Set(2) { 1, 2 }

【NodeList】

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p>1</p>
<p>2</p>
<p>3</p>
<script>
    console.log(new Set(document.querySelectorAll('P')));
</script>
</body>
</html>

【Set】

const s = new Set([1, 2, 1]);
console.log(new Set(s));	// Set(2) { 1, 2 }
console.log(s);				// Set(2) { 1, 2 }
// 这也是复制一个 Set 的方法

4. Set 注意事项

【Set 如何判断重复】

  • Set 对重复值的判断基本遵循严格相等(===)

  • 但是对于 NaN 的判断与 === 不同,Set 中 NaN 等于 NaN

const s = new Set();
s.add({}).add({});
console.log({} === {});	 // false
console.log(s);			 // Set(2) { {}, {} }

【什么时候使用 Set】

  • 数组或字符串需要去重时
  • 不需要通过下标访问,只需要遍历时
  • 为了使用 Set 提供的方法和属性时

5.Set 的应用

【数组去重】

const s = new Set([1, 2, 1]);
console.log(s);			// Set(2) { 1, 2 }
console.log([...s]);	// [ 1, 2 ]

【字符串去重】

const s = new Set('abbacbd');
console.log(s);					// Set(4) { 'a', 'b', 'c', 'd' }
console.log([...s].join(''));	// abcd

【存放 DOM 元素】

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p>1</p>
<p>2</p>
<p>3</p>
<script>
    // 这里使用 Set 是因为我们不需要通过下标去访问,只需直接遍历即可
    const s = new Set(document.querySelectorAll('p'));
    s.forEach(function (elem) {
        elem.style.color = 'red';
    });
</script>
</body>
</html>

【遍历】

数组的mapfilter方法也可以间接用于 Set 了。

let set = new Set([1, 2, 3]);
set = new Set([...set].map(x => x * 2));
// 返回Set结构:{2, 4, 6}

let set = new Set([1, 2, 3, 4, 5]);
set = new Set([...set].filter(x => (x % 2) == 0));
// 返回Set结构:{2, 4}

因此使用 Set 可以很容易地实现并集(Union)、交集(Intersect)和差集(Difference)。

let a = new Set([1, 2, 3]);
let b = new Set([4, 3, 2]);

// 并集
let union = new Set([...a, ...b]);
// Set {1, 2, 3, 4}

// 交集
let intersect = new Set([...a].filter(x => b.has(x)));
// set {2, 3}

// (a 相对于 b 的)差集
let difference = new Set([...a].filter(x => !b.has(x)));
// Set {1}

6.什么是 Map?

Map 可以理解为:“映射”。

Map 和 对象 都是键值对的集合。

// 键 ——> 值,key ——> value
// 对象:
const person = {
    name: 'alex',
    age: 18
};

// Map:
const m = new Map();
m.set('name', 'alex');
m.set('age', 18);
console.log(m);		// Map(2) { 'name' => 'alex', 'age' => 18 }

// Map 和 对象 的区别:
// 对象一般用字符串当作 “键”(当然在书写时字符串键的引号可以去掉).
// Map 中的 “键” 可以是一切类型。
const m = new Map();
m.set(true, 'true');
m.set({}, 'object');
m.set(new Set([1, 2]), 'set');
m.set(undefined, 'undefined');
console.log(m);
/*
Map(4) {
  true => 'true',
  {} => 'object',
  Set(2) { 1, 2 } => 'set',
  undefined => 'undefined'
}
*/

7.Map 实例的方法和属性

7.1 set 方法

set方法设置键名key对应的键值为value,然后返回整个 Map 结构。如果key已经有值,则键值会被更新,否则就新生成该键。

const m = new Map();

m.set('edition', 6)        // 键是字符串
m.set(262, 'standard')     // 键是数值
m.set(undefined, 'nah')    // 键是 undefined

set方法返回的是当前的Map对象,因此可以采用链式写法。

let map = new Map()
  .set(1, 'a')
  .set(2, 'b')
  .set(3, 'c');

7.2 get 方法

get方法读取key对应的键值,如果找不到key,返回undefined

const m = new Map();

const hello = function() {console.log('hello');};
m.set(hello, 'Hello ES6!') // 键是函数

m.get(hello)  // Hello ES6!

7.3 has 方法

has方法返回一个布尔值,表示某个键是否在当前 Map 对象之中。

const m = new Map();

m.set('edition', 6);
m.set(262, 'standard');
m.set(undefined, 'nah');

m.has('edition')     // true
m.has('years')       // false
m.has(262)           // true
m.has(undefined)     // true

7.4 delete 方法

delete方法删除某个键,返回true。如果删除失败,返回false

const m = new Map();
m.set(undefined, 'nah');
m.has(undefined)     // true

m.delete(undefined)
m.has(undefined)       // false

7.5 clear 方法

clear方法清除所有成员,没有返回值。

let map = new Map();
map.set('foo', true);
map.set('bar', false);

map.size // 2
map.clear()
map.size // 0

7.6 forEach 方法

m.forEach(function (value, key, map) {
    console.log(this);
}, document);

7.7 size 属性

size属性返回 Map 结构的成员总数。

const map = new Map();
map.set('foo', true);
map.set('bar', false);

map.size // 2

8.Map 构造函数的参数

  • 二维数组
  • Set、Map 等

【二维数组】

console.log(new Map([
    ['name', 'alex'],
    ['age', 18]
]));
// Map(2) { 'name' => 'alex', 'age' => 18 }

【Set、Map】

// Set
// Set 中也必须体现出键和值
const s = new Set([
    ['name', 'alex'],
    ['age', 18]
]);
console.log(new Map(s));
console.log(s);
// Map(2) { 'name' => 'alex', 'age' => 18 }
// Set(2) { [ 'name', 'alex' ], [ 'age', 18 ] }

// Map
const m = new Map([
    ['name', 'alex'],
    ['age', 18]
]);
console.log(m);
const m2 = new Map(m);
console.log(m2, m2 === m);
// Map(2) { 'name' => 'alex', 'age' => 18 }
// Map(2) { 'name' => 'alex', 'age' => 18 } false
// Map 复制的方法

9.Map 注意事项

【Map 如何判断键名是否相同】

在 Set 中遇到重复的值直接去掉后者,而 Map 中遇到重复的键值则是后面的覆盖前面的。

  • 基本遵循严格相等(===)
  • Map 中 NaN 也是等于 NaN

【什么时候使用 Map】

  • 如果只是需要键值对结构
  • 需要字符串以外的值做键
  • 对象一般用在模拟实体上

10.Map 的应用

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p>1</p>
<p>2</p>
<p>3</p>
<script>
    const [p1, p2, p3] = document.querySelectorAll('p');
    const m = new Map([
        [p1, {
            color: 'red',
            backgroundColor: 'yellow',
            fontSize: '40px'
        }],
        [p2, {
            color: 'green',
            backgroundColor: 'pink',
            fontSize: '40px'
        }],
        [p3, {
            color: 'blue',
            backgroundColor: 'orange',
            fontSize: '40px'
        }]
    ]);
    m.forEach((propObj, elem) => {
        for (const p in propObj) {
            elem.style[p] = propObj[p];
        }
    });	// 由于不需要改变 this 指向,所以可以使用箭头函数
</script>
</body>
</html>

image-20220327154158420

10【Promise对象】

1.同步异步的介绍

Promise 是异步操作的一种解决方案。

异步的概念

异步(Asynchronous, async)是与同步(Synchronous, sync)相对的概念。

在我们学习的传统单线程编程中,程序的运行是同步的(同步不意味着所有步骤同时运行,而是指步骤在一个控制流序列中按顺序执行)。而异步的概念则是不保证同步的概念,也就是说,一个异步过程的执行将不再与原有的序列有顺序关系。

简单来理解就是:同步按你的代码顺序执行,异步不按照代码顺序执行,异步的执行效率更高。

以上是关于异步的概念的解释,接下来我们通俗地解释一下异步:异步就是从主线程发射一个子线程来完成任务。

img

什么时候用异步编程

在前端编程中(甚至后端有时也是这样),我们在处理一些简短、快速的操作时,例如计算 1 + 1 的结果,往往在主线程中就可以完成。主线程作为一个线程,不能够同时接受多方面的请求。所以,当一个事件没有结束时,界面将无法处理其他请求。

现在有一个按钮,如果我们设置它的 onclick 事件为一个死循环,那么当这个按钮按下,整个网页将失去响应。

为了避免这种情况的发生,我们常常用子线程来完成一些可能消耗时间足够长以至于被用户察觉的事情(或者是一些需要等待某个时机在背后自动执行的任务,比如:事件监听),比如读取一个大文件或者发出一个网络请求。因为子线程独立于主线程,所以即使出现阻塞也不会影响主线程的运行。但是子线程有一个局限:一旦发射了以后就会与主线程失去同步,我们无法确定它的结束,如果结束之后需要处理一些事情,比如处理来自服务器的信息,我们是无法将它合并到主线程中去的。

JavaScript 是单线程语言,为了解决多线程问题,JavaScript 中的异步操作函数往往通过回调函数来实现异步任务的结果处理。

回调函数(callback function)

在 JavaScript 中,回调函数具体的定义为:函数A 作为参数(函数引用)传递到另一个 函数B 中,并且这个 函数B 执行函数A。我们就说 函数A 叫做回调函数。如果没有名称(函数表达式),就叫做匿名回调函数。

回调函数就是一个作为参数的函数,它是在我们启动一个异步任务的时候就告诉它:等你完成了这个任务之后要干什么。这样一来主线程几乎不用关心异步任务的状态了,他自己会善始善终。

注意:回调和异步不是同一个东西,许多人误认为 js 中每个回调函数都是异步处理的,实际上并不是,可以同步回调,也可以异步回调。只不过说:回调可以是同步也可以是异步,异步必须放在回调里执行,也就是对于一个异步任务只有回调函数里的才是异步的部分。

回调同步的例子:

const test = function (func) {
func();
}

test(() => {
console.log('func');
})

回调异步的例子:

setTimeout(()=>{
console.log('one');
}, 3000);
console.log('two');

实例

setInterval()setTimeout() 是两个异步语句。

异步(asynchronous):不会阻塞 CPU 继续执行其他语句,当异步完成时(包含回调函数的主函数的正常语句完成时),会执行 “回调函数”(callback)。

<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<title>菜鸟教程(runoob.com)</title>
</head>

<body>

<p>回调函数等待 3 秒后执行。</p>
<p id="demo"></p>
<p>异步方式,不影响后续执行。</p>
<script>
  function print() {
      document.getElementById("demo").innerHTML = "RUNOOB!";
  }
  setTimeout(print, 3000);
</script>

</body>

</html>

1

这段程序中的 setTimeout 就是一个消耗时间较长(3 秒)的过程,它的第一个参数是个回调函数,第二个参数是毫秒数,这个函数执行之后会产生一个子线程,子线程会等待 3 秒,然后执行回调函数 “print”,在命令行输出 “RUNOOB!”。

当然,JavaScript 语法十分友好,我们不必单独定义一个函数 print ,我们常常将上面的程序写成:

实例

<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<title>菜鸟教程(runoob.com)</title>
</head>

<body>

<p>回调函数等待 3 秒后执行。</p>
<p id="demo"></p>
<p>异步方式,不影响后续执行。</p>
<script>
  setTimeout(function () {
      document.getElementById("demo").innerHTML = "RUNOOB!";
  }, 3000);
  /* ES6 箭头函数写法
  setTimeout(() => {
      document.getElementById("demo").innerHTML = "RUNOOB!";
  }, 3000);
  */
</script>

</body>

</html>

**注意:**既然 setTimeout 会在子线程中等待 3 秒,在 setTimeout 函数执行之后主线程并没有停止,所以:

实例

<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<title>菜鸟教程(runoob.com)</title>
</head>

<body>

<p>回调函数等待 3 秒后执行。</p>
<p id="demo1"></p>
<p id="demo2"></p>
<script>
  setTimeout(function () {
      document.getElementById("demo1").innerHTML = "RUNOOB-1!";
  }, 3000);
  document.getElementById("demo2").innerHTML = "RUNOOB-2!";
</script>

</body>

</html>

这段程序的执行结果是:

2

(之前常用的异步操作解决方案是:回调函数)

document.addEventListener(
    'click',
    () => {
        console.log('这里是异步的');
    },
    false
);
console.log('这里是同步的');

什么时候使用 Promise 呢?

Promise 一般用来解决层层嵌套的回调函数(回调地狱 callback hell)的问题。

例如下面展示两个回调地狱的例子:

例子1:分别间隔一秒打印省市县

<!DOCTYPE html>
<html lang="zh">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>回调举例</title>
</head>

<body>
<script>
    /*
    // 此种方式,省市县都会在一秒后同时打印,没有实现要求
    setTimeout(() => {
        console.log("云南省");
    }, 1000);
    setTimeout(() => {
        console.log("玉溪市");
    }, 1000);
    setTimeout(() => {
        console.log("峨山县");
    }, 1000);
    */

    // 通过回调函数的方式,实现异步
    setTimeout(() => {
        console.log("云南省");
        let str01 = "云南省";
        setTimeout(() => {
            console.log(str01 + "玉溪市");
            let str02 = "云南省玉溪市";
            setTimeout(() => {
                console.log(str02 + "峨山县");
            }, 1000, str02);
        }, 1000, str01);
    }, 1000);
    console.log("通过回调函数的方式,实现异步");
</script>
</body>

</html>

555

例子2:当我们点击窗口后,盒子依次 “右——>下——>左” 移动

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <title>Promise</title>
    <style>
        * {
            padding: 0;
            margin: 0;
        }

        #box {
            width: 300px;
            height: 300px;
            background-color: red;
            transition: all 0.5s;
        }
    </style>
</head>
<body>
<div id="box"></div>
<script>    
    // 运动函数
    const move = (el, {x = 0, y = 0} = {}, end = () => {}) => {
        el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
        el.addEventListener(
            // transitionend 事件在 CSS 完成过渡后触发。
            'transitionend',
            () => {
                end();
            },
            false
        );
    };

    const boxEl = document.getElementById('box');

    // 形成回调地狱
    document.addEventListener(
        'click',
        () => {
            move(boxEl, {x: 150}, () => {
                move(boxEl, {x: 150, y: 150}, () => {
                    move(boxEl, {y: 150}, () => {
                        move(boxEl, {x: 0, y: 0});
                    });
                });
            });
        },
        false
    );
</script>
</body>
</html>

2.Promise 的含义

Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。

所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。

Promise 有三个状态:pending(等待)、fulfilled 或 resolved(成功)、rejected(失败)。

并且 Promise 必须接收一个回调函数,这个回调函数有两个参数,这两个参数也是两个函数,(resolve, reject) => {}

  • 实例化 Promise 后,默认是等待状态。

  • 当执行 resolve() 函数时,Promise 从等待状态——>成功状态。

  • 当执行 reject() 函数时,Promise 从等待状态——>失败状态。

注意:当 Promise 的状态一但从等待转变为某一个状态后,后续的转变就自动忽略了,比如:先调用 resolve() 再调用 reject(),那么 Promise 的最终结果是成功状态。

注意:这里的 resolve reject 只是一个形参,可以取任意名字,但是我们约定直接使用 resolve reject。

注意,为了行文方便,本章后面的resolved统一只指fulfilled状态,不包含rejected状态。

有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。

Promise也有一些缺点。首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。第三,当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

3.Promise 的基本用法

ES6 规定,Promise对象是一个构造函数,用来生成Promise实例。

下面代码创造了一个Promise实例。

const promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});

Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolvereject。它们是两个函数,由 JavaScript 引擎提供,不用自己部署。

resolve函数的作用是,将Promise对象的状态从“未完成”变为“成功”(即从 pending 变为 resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject函数的作用是,将Promise对象的状态从“未完成”变为“失败”(即从 pending 变为 rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

Promise实例生成以后,可以用then方法分别指定resolved状态和rejected状态的回调函数。

resolve()reject() 函数是可以接收参数的。

  • resolve() 接收的参数会传递给 then 方法的第一个回调函数
  • reject() 接收的参数会传递给 then 方法的第二个回调函数

注意:通常我们不仅仅会传递一个基本数据类型的值,我们还常常传递对象,比如再 reject 中传递一个错误对象:

reject(new Error("出错了!"));

promise.then(function(value) {
  // success
}, function(error) {
  // failure
});

then方法可以接受两个回调函数作为参数。第一个回调函数是Promise对象的状态变为resolved时调用,第二个回调函数是Promise对象的状态变为rejected时调用。这两个函数都是可选的,不一定要提供。它们都接受Promise对象传出的值作为参数。

下面是一个Promise对象的简单例子。

function timeout(ms) {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, ms, 'done');
  });
}

timeout(100).then((value) => {
  console.log(value);
});

上面代码中,timeout方法返回一个Promise实例,表示一段时间以后才会发生的结果。过了指定的时间(ms参数)以后,Promise实例的状态变为resolved,就会触发then方法绑定的回调函数。

Promise 新建后就会立即执行。

let promise = new Promise(function(resolve, reject) {
  console.log('Promise');
  resolve();
});

promise.then(function() {
  console.log('resolved.');
});

console.log('Hi!');

// Promise
// Hi!
// resolved

上面代码中,Promise 新建后立即执行,所以首先输出的是Promise。然后,then方法指定的回调函数,将在当前脚本所有同步任务执行完才会执行,所以resolved最后输出。

下面是异步加载图片的例子。

function loadImageAsync(url) {
  return new Promise(function(resolve, reject) {
    const image = new Image();

    image.onload = function() {
      resolve(image);
    };

    image.onerror = function() {
      reject(new Error('Could not load image at ' + url));
    };

    image.src = url;
  });
}

上面代码中,使用Promise包装了一个图片加载的异步操作。如果加载成功,就调用resolve方法,否则就调用reject方法。

如果调用resolve函数和reject函数时带有参数,那么它们的参数会被传递给回调函数。reject函数的参数通常是Error对象的实例,表示抛出的错误;resolve函数的参数除了正常的值以外,还可能是另一个 Promise 实例,比如像下面这样。

const p1 = new Promise(function (resolve, reject) {
  // ...
});

const p2 = new Promise(function (resolve, reject) {
  // ...
  resolve(p1);
})

上面代码中,p1p2都是 Promise 的实例,但是p2resolve方法将p1作为参数,即一个异步操作的结果是返回另一个异步操作。

注意,这时p1的状态就会传递给p2,也就是说,p1的状态决定了p2的状态。如果p1的状态是pending,那么p2的回调函数就会等待p1的状态改变;如果p1的状态已经是resolved或者rejected,那么p2的回调函数将会立刻执行。

const p1 = new Promise(function (resolve, reject) {
  setTimeout(() => reject(new Error('fail')), 3000)
})

const p2 = new Promise(function (resolve, reject) {
  setTimeout(() => resolve(p1), 1000)
})

p2
  .then(result => console.log(result))
  .catch(error => console.log(error))
// Error: fail

上面代码中,p1是一个 Promise,3 秒之后变为rejectedp2的状态在 1 秒之后改变,resolve方法返回的是p1。由于p2返回的是另一个 Promise,导致p2自己的状态无效了,由p1的状态决定p2的状态。所以,后面的then语句都变成针对后者(p1)。又过了 2 秒,p1变为rejected,导致触发catch方法指定的回调函数。

注意,调用resolvereject并不会终结 Promise 的参数函数的执行。

new Promise((resolve, reject) => {
  resolve(1);
  console.log(2);
}).then(r => {
  console.log(r);
});
// 2
// 1

上面代码中,调用resolve(1)以后,后面的console.log(2)还是会执行,并且会首先打印出来。这是因为立即 resolved 的 Promise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。

一般来说,调用resolvereject以后,Promise 的使命就完成了,后继操作应该放到then方法里面,而不应该直接写在resolvereject的后面。所以,最好在它们前面加上return语句,这样就不会有意外。

new Promise((resolve, reject) => {
  return resolve(1);
  // 后面的语句不会执行
  console.log(2);
})

4.Promise.prototype.then()

  1. then 方法的两个回调函数什么时候执行

    • pending——>resolved时,执行 then 的第一个回调函数
    • pending——>rejected 时,执行 then 的第二个回调函数
  2. then 方法执行后的返回值

    • then 方法执行后默认自动返回一个新的 Promise 对象
  3. then 方法返回的 Promise 对象的状态改变

    • then 方法其实默认返回的是 undefined,即:return undefined,但是 ES6 的机制规定:当 then 返回 undefined 时,那么会将这个 undefined 包装成一个 Promise,并且这个 Promise 默认调用了 resilve() 方法(成功态),并且把 undefined 作为了 resilve() 的参数,相当于:

      const p = new Promise((resolve, reject) => {
          resolve();
      });
      p.then(() => {
          // 默认会执行这一条
          // return undefined;
      }, () => {
      });
      
      // 实际上,return 会包装为一个 Promise 对象,同时默认执行 resolve(),并把 return 的值作为 resolve() 的参数
      /*
      return new Promise(resolve => {
          resolve(undefined);
      });
      */
      
      // -----------------------------
      // 如果我们在这个返回的 Promise 上继续调用 then 方法,并接收参数的话,可以发现 then 中成功接收到了被 Promise 包装后的参数
      const p2 = new Promise((resolve, reject) => {
          resolve();
      });
      p2.then(() => {
          // 默认会执行这一条
          // return undefined;
      }).then(data => {
          console.log(data);  // 打印 undefined
          // 手动 return 一个值
          return 24;
          // 相当于:return new Promise(resolve => {resolve(24);});
      }).then((data) => {
          console.log(data);	// 打印 24
      });
      
    • 如果我们要让 then 返回一个失败状态的 Promise,那么我们可以手动 return 一个 Promise 并执行 reject() 方法。

      const p3 = new Promise((resolve, reject) => {
          resolve();
      });
      p3.then(() => {
          // 手动返回一个调用了 reject 的 Promise
          return new Promise((resolve, reject) => {
              reject("失败");
          })
      }).then(() => {}, errData => {
          console.log(errData);	// 失败
      });
      

总结:Promise 是一个构造函数,需要 new 才能使用。在 new Promise() 的时候需要传递一个匿名回调函数作为 Promise() 唯一的参数,这个回调函数有两个参数 resolve reject,这两个参数也是函数,当回调函数执行第一个 resolve 函数后 Promise 便变为了成功状态,反之回调函数执行了 reject 后 Promise 便变为了失败状态,且每个 Promise 只能要么执行 resolve,要么执行 reject,不能同时执行!当 Promise 被 new 之后就会有一个 then 方法,该方法默认接收两个匿名回调函数作为参数,其中第一个回调函数是在 Promise 为成功状态时自动调用的,反之第二个回调函数是在 Promise 为失败状态时自动调用的,并且这两个回调函数是可以接收参数的,参数就来自于 resolve 或 reject 调用时传递的实参!在 then 方法执行后会默认返回 undefined(在没有指定返回值的情况下),ES6 会将其包装为一个新的成功态的 Promise,该 Promise 会自动执行 resolve 函数,该函数的参数来自于 then 方法的返回值(如果没有返回值那么默认就返回 undefined)。如果需要返回一个失败态的 Promise,那么需要在 then 中手动指定返回值:

return new Promise((resolve, reject) => {
	reject(参数);
}

案例:分别间隔一秒打印省市县。

<!DOCTYPE html>
<html lang="zh">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Promise</title>
</head>

<body>
<script>
    // 通过 Promise 的方式,解决回调地狱
    new Promise((resolve) => {
        setTimeout(() => {
            console.log("云南省");
            resolve("云南省");
        }, 1000);
    }).then(res => {
        return new Promise((resolve) => {
            setTimeout(() => {
                console.log(res + "玉溪市");
                resolve(res + "玉溪市");
            }, 1000);
        });
    }).then(res => {
        setTimeout(() => {
            console.log(res + "峨山县");
        }, 1000);
    });

    console.log("通过 Promise 的方式,实现异步");
</script>
</body>

</html>

2

5.Promise.prototype.catch()

由之前的例子可以看出,我们在使用 Promise 的时候,大部分情况下,我们只用 resolve() 方法(成功态),所以在 Promise 回调函数中我们常常省略 reject 函数参数,在 then 中我们常常省略第二个回调函数。

但是我们还是需要处理异步中的异常,所以 ES6 中提供了我们一个 catch() 方法专门用来处理 Promise 的异常部分(失败态)。

  • catch 专门用来处理 rejected 状态

  • catch 本质上是 then 的特例

new Promise((resolve, reject) => {
    reject("失败");
}).then(res => {
    console.log(res);
}).catch(err => {
    console.log(err);   // 失败
});

// -------------------------------------
// 上面的代码本质上等同于
new Promise((resolve, reject) => {
    reject("失败");
}).then(res => {
    console.log(res);
}).then(null, err => {
    console.log(err);	// 失败
});

在 Promise 中,一但出现了错误状态,那么这个错误是不会消失的,会一直向下传递,直到遇到可以处理错误的函数。

由于 catch 是 then 的特例,所以 catch 依旧返回的是一个 Promise 对象,我们可以在 catch 后继续调用 then。

new Promise((resolve, reject) => {
    reject("失败");
}).then(res => {
    console.log(res);
}).catch(err => {
    console.log(err);   // 失败
    return "测试";
}).then(res => {
   console.log(res);	// 测试 
});

一般总是建议,Promise 对象后面要跟一个或多个 catch 方法,这样可以处理 Promise 内部发生的错误!

6.Promise.prototype.finally()

当 Promise 状态发生变化时,不论如何变化都会执行,不变化不执行。

  • finally() 不能接收参数。

  • finally 也是 then 的特例。

finally()方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。该方法是 ES2018 引入标准的。

promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});

上面代码中,不管promise最后的状态,在执行完thencatch指定的回调函数以后,都会执行finally方法指定的回调函数。

下面是一个例子,服务器使用 Promise 处理请求,然后使用finally方法关掉服务器。

server.listen(port)
  .then(function () {
    // ...
  })
  .finally(server.stop);

finally方法的回调函数不接受任何参数,这意味着没有办法知道,前面的 Promise 状态到底是fulfilled还是rejected。这表明,finally方法里面的操作,应该是与状态无关的,不依赖于 Promise 的执行结果。

上面代码中,如果不使用finally方法,同样的语句需要为成功和失败两种情况各写一次。有了finally方法,则只需要写一次。

new Promise(resolve => {
    resolve("测试01");
}).finally(data => {
    console.log(data + " finally01");
    return new Promise((resolve, reject) => {
        reject("测试02");
    })
}).finally(data => {
    console.log(data + " finally02")
}).catch(err => {
    console.log("catch: " + err);
});

/*
undefined finally01
undefined finally02
catch: 测试02
*/

// 从以上示例可以看出:finally 可以接收正确状态或错误状态,但是不能接收参数。

// -------------------------------------
// finally 也是 then 的特例
// finally 等同于:
new Promise((resolve, reject) => {
    ...
}).then(res => {
    return res;
}, err => {
    return new Promise((resolve, reject) => {
        reject(err);
    })
})

finally:主要是用来处理一些必做操作,比如在操作数据库之后(无论成功与否)都要关闭数据库连接。

7.Promise.all()

Promise.all()方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。

const p = Promise.all([p1, p2, p3]);

上面代码中,Promise.all()方法接受一个数组作为参数,p1p2p3都是 Promise 实例,如果不是,就会先调用下面讲到的Promise.resolve方法,将参数转为 Promise 实例,再进一步处理。另外,Promise.all()方法的参数可以不是数组,但必须具有 Iterator 接口,且返回的每个成员都是 Promise 实例。

p的状态由p1p2p3决定,分成两种情况。

(1)只有p1p2p3的状态都变成fulfilledp的状态才会变成fulfilled,此时p1p2p3的返回值组成一个数组,传递给p的回调函数。

(2)只要p1p2p3之中有一个被rejectedp的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。

下面是一个具体的例子。

// 生成一个Promise对象的数组
const promises = [2, 3, 5, 7, 11, 13].map(function (id) {
  return getJSON('/post/' + id + ".json");
});

Promise.all(promises).then(function (posts) {
  // ...
}).catch(function(reason){
  // ...
});

上面代码中,promises是包含 6 个 Promise 实例的数组,只有这 6 个实例的状态都变成fulfilled,或者其中有一个变为rejected,才会调用Promise.all方法后面的回调函数。

/*
Promise.all() 的状态变化与所有传入的 Promise 实例对象状态有关
用途举例:在用 Ajax 从后端接口获取数据的时候,如果全部获取到了,那么才处理,否则不处理。
*/

const delay = ms => {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
};

// 示例一:所有状态都变为 resolved
const p1 = delay(1000).then(() => {
    console.log('p1 完成了');
    return 'p1';
});
const p2 = delay(2000).then(() => {
    console.log('p2 完成了');
    return 'p2';
});
const p = Promise.all([p1, p2]);
p.then(res => {
    console.log(res + " 成功");
}, err => {
    console.log(err + " 失败");
});

/*
p1 完成了
p2 完成了
p1,p2 成功
*/
/*
解释:
1、Promise.all() 直接执行两个 Promise 实例
2、执行 p1,输出 p1 完成了
3、检测到 resolved,Promise.all() 继续执行
4、执行 p2,输出 p2 完成了
5、检测到 resolved,由于 Promise 已经全部执行完,所以执行 then 第一个回调输出 p1,p2 成功,Promise.all() 终止。
*/

// 示例二:出现一个 rejected 状态
const p1 = delay(1000).then(() => {
    console.log('p1 完成了');
    return Promise.reject('p1');
});
const p2 = delay(2000).then(() => {
    console.log('p2 完成了');
    return 'p2';
});
const p = Promise.all([p1, p2]);
p.then(res => {
    console.log(res + " 成功");
}, err => {
    console.log(err + " 失败");
});
/*
p1 完成了
p1 失败
p2 完成了
*/
/*
解释:
1、Promise.all() 直接执行两个 Promise 实例
2、执行 p1,输出 p1 完成了
3、检测到 rejected,Promise.all() 直接变为 rejected,执行 then 第二个回调输出 p1 失败,至此 Promise.all() 已经执行完毕。
4、由于 p2 延迟了两秒执行所以在后面输出(如果 p2 延时小于 p1,那么应该先输出 p2 完成了,然后在是 p1 完成了,p1 失败)
*/

8.Promise.resolve()和Promise.reject()

以上两者都是 Promise 构造函数的方法。

下面我们以 Promise.resolve() 举例,Promise.reject() 同理。

// Promise.resolve() 可以理解为普通成功状态的一种简写形式
new Promise(resolve => resolve('foo'));
// 简写
Promise.resolve('foo');

Promise.resolve() 与 Promise.reject() 的参数问题:

1、一般参数

Promise.resolve('foo').then(data => {
    console.log(data);
})	// foo

2、特殊参数:Promise 作为参数

const p1 = new Promise(resolve => {
    setTimeout(resolve, 1000, '我执行了');
    /*
    上述延时器写法相当于:
    setTimeout(()=>{
        resolve('我执行了');
    }, 1000);
     */
});
Promise.resolve(p1).then(data => {
    console.log(data);	// 等待一秒后,输出 '我执行了'
});

/*
当 Promise.resolve() 接收的是 Promise 对象时,直接返回这个 Promise 对象,什么都不做
*/

// 所以,以上代码等同于:
p1.then(data => {
   console.log(data); // 等待一秒后,输出 '我执行了'
});

// 验证
console.log(Promise.resolve(p1) === p1);	// true

// 由于 Promise.resolve() 可以理解为普通成功状态的一种简写形式,所以:
new Promise(resolve => resolve(p1)).then(data => {
   console.log(data); // 等待一秒后,输出 '我执行了'
});

3、特殊参数:具有 then 方法的对象(了解即可)

const thenable = {
    then() {
        console.log('then');
    }
};
Promise.resolve(thenable).then(
    res => console.log("res " + res),
    err => console.log("err " + err)
);

/*
then
*/

// 当接收一个含 then 方法的对象时,Promise.resolve() 会直接调用 then 方法。

// 为什么不会执行 then 中的两个回调函数呢?
console.log(Promise.resolve(thenable));
/*
Promise { <pending> }
then
*/
// 可见,当接收一个含 then 方法的对象时,默认返回一个 Promise 并且是等待状态的,没有状态的变化,那么就不可能会执行 then 的回调函数
// 如果我们要改变这个返回的 Promise 对象的状态,并让 then 的回调对应处理的话,ES6 规定了以下写法:
const thenable02 = {
    then(resolve, reject) {
        console.log('then');
        resolve('then');
    }
};
Promise.resolve(thenable02).then(
    res => console.log("res " + res),
    err => console.log("err " + err)
);
/*
then
res then
*/

与 Promise.resolve() 不同,Promise.reject() 无论接收什么类型的参数,都会原封不动的向后传递!

9.Promise.race()

Promise.race()方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。

const p = Promise.race([p1, p2, p3]);

上面代码中,只要p1p2p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数。

Promise.race()方法的参数与Promise.all()方法一样,如果不是 Promise 实例,就会先调用Promise.resolve()方法,将参数转为 Promise 实例,再进一步处理。

下面是一个例子,如果指定时间内没有获得结果,就将 Promise 的状态变为reject,否则变为resolve

const p = Promise.race([
  fetch('/resource-that-may-take-a-while'),
  new Promise(function (resolve, reject) {
    setTimeout(() => reject(new Error('request timeout')), 5000)
  })
]);

p
.then(console.log)
.catch(console.error);

上面代码中,如果 5 秒之内fetch方法无法返回结果,变量p的状态就会变为rejected,从而触发catch方法指定的回调函数。

10.Promise.allSettled()

有时候,我们希望等到一组异步操作都结束了,不管每一个操作是成功还是失败,再进行下一步操作。但是,现有的 Promise 方法很难实现这个要求。

Promise.all()方法只适合所有异步操作都成功的情况,如果有一个操作失败,就无法满足要求。

const urls = [url_1, url_2, url_3];
const requests = urls.map(x => fetch(x));

try {
  await Promise.all(requests);
  console.log('所有请求都成功。');
} catch {
  console.log('至少一个请求失败,其他请求可能还没结束。');
}

上面示例中,Promise.all()可以确定所有请求都成功了,但是只要有一个请求失败,它就会报错,而不管另外的请求是否结束。

为了解决这个问题,ES2020引入了Promise.allSettled()方法,用来确定一组异步操作是否都结束了(不管成功或失败)。所以,它的名字叫做”Settled“,包含了”fulfilled“和”rejected“两种情况。

Promise.allSettled()方法接受一个数组作为参数,数组的每个成员都是一个 Promise 对象,并返回一个新的 Promise 对象。只有等到参数数组的所有 Promise 对象都发生状态变更(不管是fulfilled还是rejected),返回的 Promise 对象才会发生状态变更。

const promises = [
  fetch('/api-1'),
  fetch('/api-2'),
  fetch('/api-3'),
];

await Promise.allSettled(promises);
removeLoadingIndicator();

上面示例中,数组promises包含了三个请求,只有等到这三个请求都结束了(不管请求成功还是失败),removeLoadingIndicator()才会执行。

该方法返回的新的 Promise 实例,一旦发生状态变更,状态总是fulfilled,不会变成rejected。状态变成fulfilled后,它的回调函数会接收到一个数组作为参数,该数组的每个成员对应前面数组的每个 Promise 对象。

const resolved = Promise.resolve(42);
const rejected = Promise.reject(-1);

const allSettledPromise = Promise.allSettled([resolved, rejected]);

allSettledPromise.then(function (results) {
  console.log(results);
});
// [
//    { status: 'fulfilled', value: 42 },
//    { status: 'rejected', reason: -1 }
// ]

上面代码中,Promise.allSettled()的返回值allSettledPromise,状态只可能变成fulfilled。它的回调函数接收到的参数是数组results。该数组的每个成员都是一个对象,对应传入Promise.allSettled()的数组里面的两个 Promise 对象。

results的每个成员是一个对象,对象的格式是固定的,对应异步操作的结果。

// 异步操作成功时
{status: 'fulfilled', value: value}

// 异步操作失败时
{status: 'rejected', reason: reason}

成员对象的status属性的值只可能是字符串fulfilled或字符串rejected,用来区分异步操作是成功还是失败。如果是成功(fulfilled),对象会有value属性,如果是失败(rejected),会有reason属性,对应两种状态时前面异步操作的返回值。

下面是返回值的用法例子。

const promises = [ fetch('index.html'), fetch('https://does-not-exist/') ];
const results = await Promise.allSettled(promises);

// 过滤出成功的请求
const successfulPromises = results.filter(p => p.status === 'fulfilled');

// 过滤出失败的请求,并输出原因
const errors = results
  .filter(p => p.status === 'rejected')
  .map(p => p.reason);

11.Promise的应用

【异步加载图片】

异步加载:也称为图片的预加载。利用 js 代码提前加载图片,用户需要时可以直接从本地缓存获取,但是会增加服务器前端的压力。这样做可以提高用户的体验,因为同步加载大图片的时候,图片会一层一层的显示处理,但是经过预加载后,直接显示出整张图片。

 <!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <title>Promise 的应用</title>
    <style>
        #img {
            width: 24%;
            padding: 24px;
        }
    </style>
</head>
<body>
<!-- 一般加载图片方式 -->
<!-- <img src="https://scpic.chinaz.net/files/pic/pic9/202009/apic27858.jpg" alt=""/> -->
<img src="" alt="" id="img">

<script>
    // 异步加载图片
    // 异步加载图片函数(参数:图片路径)
    const loadImgAsync = url => {
        // Promise 实现异步
        return new Promise((resolve, reject) => {
            // 创建一个图片对象
            const img = new Image();

            // 图片成功加载触发事件
            img.onload = () => {
                resolve(img);
            };

            // 图片加载失败触发事件
            img.onerror = () => {
                reject(new Error(`Could not load image at ${url}`));
            };

            // 这个放在 onload 与 onerror 之后
            // 一但给 img.src 赋值,那么便立马开始发送请求加载图片(在后台加载,页面上不会显示)
            // 注意:这里的 src 是 img 对象的属性,与 html 中 img 的 src 无关
            img.src = url;
        });
    };

    const imgDOM = document.getElementById('img');
    loadImgAsync('https://scpic.chinaz.net/files/pic/pic9/202009/apic27858.jpg')
        .then(img => {
            // 如果加载成功,那么把后台缓存的图片显示到页面上
            imgDOM.src = img.src;
        })
        .catch(err => {
            console.log(err);
        });
</script>
</body>
</html>

image-20220528144323405

11【async 函数】

1.基本用法

async函数返回一个 Promise 对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

下面是一个例子。

async function getStockPriceByName(name) {
  const symbol = await getStockSymbol(name);
  const stockPrice = await getStockPrice(symbol);
  return stockPrice;
}

getStockPriceByName('goog').then(function (result) {
  console.log(result);
});

上面代码是一个获取股票报价的函数,函数前面的async关键字,表明该函数内部有异步操作。调用该函数时,会立即返回一个Promise对象。

下面是另一个例子,指定多少毫秒后输出一个值。

function timeout(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function asyncPrint(value, ms) {
  await timeout(ms);
  console.log(value);
}

asyncPrint('hello world', 50);

上面代码指定 50 毫秒以后,输出hello world

由于async函数返回的是 Promise 对象,可以作为await命令的参数。所以,上面的例子也可以写成下面的形式。

async function timeout(ms) {
  await new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function asyncPrint(value, ms) {
  await timeout(ms);
  console.log(value);
}

asyncPrint('hello world', 50);

async 函数有多种使用形式。

// 函数声明
async function foo() {}

// 函数表达式
const foo = async function () {};

// 对象的方法
let obj = { async foo() {} };
obj.foo().then(...)

// 箭头函数
const foo = async () => {};

2.语法

async函数的语法规则总体上比较简单,难点是错误处理机制。

2.1 返回 Promise 对象

async函数返回一个 Promise 对象。

async函数内部return语句返回的值,会成为then方法回调函数的参数。

async function f() {
  return 'hello world';
}

f().then(v => console.log(v))
// "hello world"

上面代码中,函数f内部return命令返回的值,会被then方法回调函数接收到。

async函数内部抛出错误,会导致返回的 Promise 对象变为reject状态。抛出的错误对象会被catch方法回调函数接收到。

async function f() {
  throw new Error('出错了');
}

f().then(
  v => console.log('resolve', v),
  e => console.log('reject', e)
)
//reject Error: 出错了

2.2 Promise 对象的状态变化

async函数返回的 Promise 对象,必须等到内部所有await命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到return语句或者抛出错误。也就是说,只有async函数内部的异步操作执行完,才会执行then方法指定的回调函数。

下面是一个例子。

async function getTitle(url) {
  let response = await fetch(url);
  let html = await response.text();
  return html.match(/<title>([\s\S]+)<\/title>/i)[1];
}
getTitle('https://tc39.github.io/ecma262/').then(console.log)
// "ECMAScript 2017 Language Specification"

上面代码中,函数getTitle内部有三个操作:抓取网页、取出文本、匹配页面标题。只有这三个操作全部完成,才会执行then方法里面的console.log

2.3 await 命令

正常情况下,await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。

async function f() {
  // 等同于
  // return 123;
  return await 123;
}

f().then(v => console.log(v))
// 123

上面代码中,await命令的参数是数值123,这时等同于return 123

这个例子还演示了如何实现休眠效果。JavaScript 一直没有休眠的语法,但是借助await命令就可以让程序停顿指定的时间。下面给出了一个简化的sleep实现。

function sleep(interval) {
  return new Promise(resolve => {
    setTimeout(resolve, interval);
  })
}

// 用法
async function one2FiveInAsync() {
  for(let i = 1; i <= 5; i++) {
    console.log(i);
    await sleep(1000);
  }
}

one2FiveInAsync();

await命令后面的 Promise 对象如果变为reject状态,则reject的参数会被catch方法的回调函数接收到。

async function f() {
  await Promise.reject('出错了');
}

f()
.then(v => console.log(v))
.catch(e => console.log(e))
// 出错了

注意,上面代码中,await语句前面没有return,但是reject方法的参数依然传入了catch方法的回调函数。这里如果在await前面加上return,效果是一样的。

任何一个await语句后面的 Promise 对象变为reject状态,那么整个async函数都会中断执行。

async function f() {
  await Promise.reject('出错了');
  await Promise.resolve('hello world'); // 不会执行
}

上面代码中,第二个await语句是不会执行的,因为第一个await语句状态变成了reject

有时,我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将第一个await放在try...catch结构里面,这样不管这个异步操作是否成功,第二个await都会执行。

async function f() {
  try {
    await Promise.reject('出错了');
  } catch(e) {
  }
  return await Promise.resolve('hello world');
}

f()
.then(v => console.log(v))
// hello world

另一种方法是await后面的 Promise 对象再跟一个catch方法,处理前面可能出现的错误。

async function f() {
  await Promise.reject('出错了')
    .catch(e => console.log(e));
  return await Promise.resolve('hello world');
}

f()
.then(v => console.log(v))
// 出错了
// hello world

2.4 错误处理

如果await后面的异步操作出错,那么等同于async函数返回的 Promise 对象被reject

async function f() {
  await new Promise(function (resolve, reject) {
    throw new Error('出错了');
  });
}

f()
.then(v => console.log(v))
.catch(e => console.log(e))
// Error:出错了

上面代码中,async函数f执行后,await后面的 Promise 对象会抛出一个错误对象,导致catch方法的回调函数被调用,它的参数就是抛出的错误对象。

防止出错的方法,也是将其放在try...catch代码块之中。

async function f() {
  try {
    await new Promise(function (resolve, reject) {
      throw new Error('出错了');
    });
  } catch(e) {
  }
  return await('hello world');
}

如果有多个await命令,可以统一放在try...catch结构中。

async function main() {
  try {
    const val1 = await firstStep();
    const val2 = await secondStep(val1);
    const val3 = await thirdStep(val1, val2);

    console.log('Final: ', val3);
  }
  catch (err) {
    console.error(err);
  }
}

下面的例子使用try...catch结构,实现多次重复尝试。

const superagent = require('superagent');
const NUM_RETRIES = 3;

async function test() {
  let i;
  for (i = 0; i < NUM_RETRIES; ++i) {
    try {
      await superagent.get('http://google.com/this-throws-an-error');
      break;
    } catch(err) {}
  }
  console.log(i); // 3
}

test();

上面代码中,如果await操作成功,就会使用break语句退出循环;如果失败,会被catch语句捕捉,然后进入下一轮循环。

3.使用注意点

第一点,前面已经说过,await命令后面的Promise对象,运行结果可能是rejected,所以最好把await命令放在try...catch代码块中。

async function myFunction() {
  try {
    await somethingThatReturnsAPromise();
  } catch (err) {
    console.log(err);
  }
}

// 另一种写法

async function myFunction() {
  await somethingThatReturnsAPromise()
  .catch(function (err) {
    console.log(err);
  });
}

第二点,多个await命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。

let foo = await getFoo();
let bar = await getBar();

上面代码中,getFoogetBar是两个独立的异步操作(即互不依赖),被写成继发关系。这样比较耗时,因为只有getFoo完成以后,才会执行getBar,完全可以让它们同时触发。

// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);

// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;

上面两种写法,getFoogetBar都是同时触发,这样就会缩短程序的执行时间。

第三点,await命令只能用在async函数之中,如果用在普通函数,就会报错。

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  // 报错
  docs.forEach(function (doc) {
    await db.post(doc);
  });
}

上面代码会报错,因为await用在普通函数之中了。但是,如果将forEach方法的参数改成async函数,也有问题。

function dbFuc(db) { //这里不需要 async
  let docs = [{}, {}, {}];

  // 可能得到错误结果
  docs.forEach(async function (doc) {
    await db.post(doc);
  });
}

上面代码可能不会正常工作,原因是这时三个db.post()操作将是并发执行,也就是同时执行,而不是继发执行。正确的写法是采用for循环。

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  for (let doc of docs) {
    await db.post(doc);
  }
}

另一种方法是使用数组的reduce()方法。

async function dbFuc(db) {
  let docs = [{}, {}, {}];

  await docs.reduce(async (_, doc) => {
    await _;
    await db.post(doc);
  }, undefined);
}

上面例子中,reduce()方法的第一个参数是async函数,导致该函数的第一个参数是前一步操作返回的 Promise 对象,所以必须使用await等待它操作结束。另外,reduce()方法返回的是docs数组最后一个成员的async函数的执行结果,也是一个 Promise 对象,导致在它前面也必须加上await

上面的reduce()的参数函数里面没有return语句,原因是这个函数的主要目的是db.post()操作,不是返回值。而且async函数不管有没有return语句,总是返回一个 Promise 对象,所以这里的return是不必要的。

如果确实希望多个请求并发执行,可以使用Promise.all方法。当三个请求都会resolved时,下面两种写法效果相同。

async function dbFuc(db) {
  let docs = [{}, {}, {}];
  let promises = docs.map((doc) => db.post(doc));

  let results = await Promise.all(promises);
  console.log(results);
}

// 或者使用下面的写法

async function dbFuc(db) {
  let docs = [{}, {}, {}];
  let promises = docs.map((doc) => db.post(doc));

  let results = [];
  for (let promise of promises) {
    results.push(await promise);
  }
  console.log(results);
}

第四点,async 函数可以保留运行堆栈。

const a = () => {
  b().then(() => c());
};

上面代码中,函数a内部运行了一个异步任务b()。当b()运行的时候,函数a()不会中断,而是继续执行。等到b()运行结束,可能a()早就运行结束了,b()所在的上下文环境已经消失了。如果b()c()报错,错误堆栈将不包括a()

现在将这个例子改成async函数。

const a = async () => {
  await b();
  c();
};

上面代码中,b()运行的时候,a()是暂停执行,上下文环境都保存着。一旦b()c()报错,错误堆栈将包括a()

4.es13新增

在 JavaScript 中,await 运算符用于暂停执行,直到 Promise 被解决(履行或拒绝)。以前,我们只能在 async 函数中使用此运算符 - 使用 async 关键字声明的函数。我们无法在全球范围内这样做。

function setTimeoutAsync(timeout) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, timeout);
  });
}
// SyntaxError: await is only valid in async functions
await setTimeoutAsync(3000);

使用 ES13,现在我们可以:

function setTimeoutAsync(timeout) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, timeout);
  });
}
// Waits for timeout - no error thrown
await setTimeoutAsync(3000);

12【es6模块化】

1.初识Module

(1)什么是模块

模块:一个一个的局部作用域的代码块。

(2)什么是模块系统

模块系统:系统的解决了模块化一系列问题。

  1. 模块化的写法(之前我们用立即执行函数模拟模块化,ES6 则实现了针对模块化的语法)
  2. 消除全局变量(模块中的变量都是局部的,不同模块之间不会相互干扰,可以通过特定语法暴露指定内容)
  3. 管理加载顺序(之前我们将一个总的 JavaScript 程序分几个文件写,但在最终合并调用时,js 的引入需要满足前后依赖关系。比如:被引用的 js 文件就一定要在引用它的 js 文件之前加载)

2.Module的基本用法

注意:Module 要生效,必须在服务器环境下才能执行。

普通的 HTML、JS 是本地文件环境,地址以 file 协议开头,服务器则以 http 或 https 开头。

方法:VSCode 中使用 Live Server 拓展,WebStorm 默认就是服务器环境。

  • 一个 JS 文件就是一个模块
  • 用 import 关键字导入模块
  • 用 export 关键字导出模块需要暴露的部分
  • 在使用 script 标签加载的时候,需要加上 type=“module”,否则就以普通 JS 文件的形式引入了,就不是模块了

3.Module的导入导出

导出的东西可以被导入(import),并访问到!

对于导入和导出有两种方法:

  • export default 导出,import 导入
  • export 导出,import 导入

3.1 export default 导出和对应的 import 导入

(1)一个模块没有导出,是否可以将其导入?

<!-- 一个模块没有导出,也可以将其导入 -->
<!-- 被导入的模块的代码都会执行一遍,并且同一个模块的导入只执行一遍! -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Module</title>
</head>
<body>
<!-- script 标签需要加上 type="module" -->
<script type="module">
    import "./test.js";		// 浏览器控制台打印:test
    import "./test.js";		// 不执行
    import "./test.js";		// 不执行
    import "./test02.js";	// 浏览器控制台打印:test02
    import "./test.js";		// 不执行
    import "./test02.js";	// 不执行
</script>
</body>
</html>

----------------------------------------------------
<!-- test.js -->

console.log("test");

----------------------------------------------------
<!-- test02.js -->

console.log("test02");

(2)一个模块中只能有一个 export default。

【module.js】

// 模块中的变量都是局部的
const age = 18;
const sex = "male";

export default age;			// 通过 export default 导出(暴露)一个值
// export default sex;		// 报错!因为 export default 只能在一个文件中导出一次!!!

/*
export default 24;				// 可以导出值
export default {};				// 可以导出对象
export default function(){};	 // 可以导出函数
export default class{};			 // 可以导出class
*/

【index.html】

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Module</title>
</head>
<body>
<!-- script 标签需要加上 type="module" -->
<script type="module">
    // import 之后跟一个模块的别名,推荐别名与导出时的名字相同,比如这里就用 age
    import age from "./module.js";
    console.log(age);	// 18
</script>
</body>
</html>

3.2 export 导出和对应的 import 导入

(1)基本用法

【module.js】

/*
const age = 18;
export age;		// 报错
*/

// export 后面只能跟声明或语句!
export const age = 18;

【index.html】

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Module</title>
</head>
<body>
<!-- script 标签需要加上 type="module" -->
<script type="module">
    // import aaa from "./module.js";	// 报错! 
    // export 导出的模块,在导入时不能随意取别名,名称必须与模块导出时相同!并且要使用类似于解构赋值的{}形式!
    
    import {age} from "./module.js";	// 注意:名称不能随意取,一定要与模块相同
    console.log(age);	// 18;
</script>
</body>
</html>

注意:在用 export 导出时,也可以用对象简写形式形式!

【module.js】

const age = 18;

export {age};

(2)多个导入

【module.js】

// 1、采用声明或语句的形式
/*
export funciton fn() {};
export class className {};
export const age = 18;
*/

// 2、采用解构赋值的形式
function fn() {};
class className {};
const age = 18;

/* 方式 1:
export {fn};
export {className};
export {age};
*/

// 方式 2:
export {fn, className, age};

【index.html】

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Module</title>
</head>
<body>
<!-- script 标签需要加上 type="module" -->
<script type="module">
    /* 方式 1:
    import {fn} from "./module.js";
    import {className} from "./module.js";
    import {age} from "./module.js";
    */
    // 方式 2:
    import {fn, className, age} from "./module.js";
    console.log(fn);			// ƒ fn() {}
    console.log(className);		// class className {}
    console.log(age);	    	// 18
</script>
</body>
</html>

(3)导出导入时起别名

export {fn as func, className as cN, age};
import {func, cN, age as nl} from "./module.js";
console.log(func);			// ƒ fn() {}
console.log(cN);			// class className {}
console.log(nl);	    	// 18

(4)整体导入

// 之前的导入方式,如果导入的模块不多那么还好,但是一但模块数量多了起来,那么就特别费劲
// import {fn, className, age} from "./module.js";

// 整体导入
// 将同一文件里的所有模块导入到一个对象中
// 不仅对 export 有效,同时对 export default 也同样有效
import * as imObj from "./module.js";
console.log(imObj);					// 见图片
console.log(imObj.fn);				// ƒ fn() {}
console.log(imObj.className);		// class className {}
console.log(imObj.age);				// 18
// export default 也同样有效:imObj.default
image-20220602154710981

(5)同时导入

当我们需要分别导入 export default 和 export 时,可以使用同时导入的方式。

// 我们可以分开实现
import {fn, className, age} from "./module.js";
import sex from "./module.js";
// 更推荐使用同时导入的方式
import sex, {fn, className, age} from "./module.js";
// 注意:export default 必须在 export 之前

4.export 与 import 的复合写法

如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。

export { foo, bar } from 'my_module';

// 可以简单理解为
import { foo, bar } from 'my_module';
export { foo, bar };

上面代码中,exportimport语句可以结合在一起,写成一行。但需要注意的是,写成一行以后,foobar实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用foobar

模块的接口改名和整体输出,也可以采用这种写法。

// 接口改名
export { foo as myFoo } from 'my_module';

// 整体输出
export * from 'my_module';

默认接口的写法如下。

export { default } from 'foo';

具名接口改为默认接口的写法如下。

export { es6 as default } from './someModule';

// 等同于
import { es6 } from './someModule';
export default es6;

同样地,默认接口也可以改名为具名接口。

export { default as es6 } from './someModule';

ES2020 之前,有一种import语句,没有对应的复合写法。

import * as someIdentifier from "someModule";

ES2020补上了这个写法。

export * as ns from "mod";

// 等同于
import * as ns from "mod";
export {ns};

5.import()

5.1 简介

前面介绍过,import命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行(import命令叫做“连接” binding 其实更合适)。所以,下面的代码会报错。

// 报错
if (x === 2) {
  import MyModual from './myModual';
}

上面代码中,引擎处理import语句是在编译时,这时不会去分析或执行if语句,所以import语句放在if代码块之中毫无意义,因此会报句法错误,而不是执行时错误。也就是说,importexport命令只能在模块的顶层,不能在代码块之中(比如,在if代码块之中,或在函数之中)。

这样的设计,固然有利于编译器提高效率,但也导致无法在运行时加载模块。在语法上,条件加载就不可能实现。如果import命令要取代 Node 的require方法,这就形成了一个障碍。因为require是运行时加载模块,import命令无法取代require的动态加载功能。

const path = './' + fileName;
const myModual = require(path);

上面的语句就是动态加载,require到底加载哪一个模块,只有运行时才知道。import命令做不到这一点。

ES2020提案 引入import()函数,支持动态加载模块。

import(specifier)

上面代码中,import函数的参数specifier,指定所要加载的模块的位置。import命令能够接受什么参数,import()函数就能接受什么参数,两者区别主要是后者为动态加载。

import()返回一个 Promise 对象。下面是一个例子。

const main = document.querySelector('main');

import(`./section-modules/${someVariable}.js`)
  .then(module => {
    module.loadPageInto(main);
  })
  .catch(err => {
    main.textContent = err.message;
  });

import()函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()函数与所加载的模块没有静态连接关系,这点也是与import语句不相同。import()类似于 Node.js 的require()方法,区别主要是前者是异步加载,后者是同步加载。

由于import()返回 Promise对象,所以需要使用then()方法指定处理函数。考虑到代码的清晰,更推荐使用await命令。

async function renderWidget() {
  const container = document.getElementById('widget');
  if (container !== null) {
    // 等同于
    // import("./widget").then(widget => {
    //   widget.render(container);
    // });
    const widget = await import('./widget.js');
    widget.render(container);
  }
}

renderWidget();

上面示例中,await命令后面就是使用import(),对比then()的写法明显更简洁易读。

5.2 适用场合

下面是import()的一些适用场合。

(1)按需加载。

import()可以在需要的时候,再加载某个模块。

button.addEventListener('click', event => {
  import('./dialogBox.js')
  .then(dialogBox => {
    dialogBox.open();
  })
  .catch(error => {
    /* Error handling */
  })
});

上面代码中,import()方法放在click事件的监听函数之中,只有用户点击了按钮,才会加载这个模块。

(2)条件加载

import()可以放在if代码块,根据不同的情况,加载不同的模块。

if (condition) {
  import('moduleA').then(...);
} else {
  import('moduleB').then(...);
}

上面代码中,如果满足条件,就加载模块 A,否则加载模块 B。

(3)动态的模块路径

import()允许模块路径动态生成。

import(f())
.then(...);

上面代码中,根据函数f的返回结果,加载不同的模块。

5.3 注意点

import()加载模块成功以后,这个模块会作为一个对象,当作then方法的参数。因此,可以使用对象解构赋值的语法,获取输出接口。

import('./myModule.js')
.then(({export1, export2}) => {
  // ...·
});

上面代码中,export1export2都是myModule.js的输出接口,可以解构获得。

如果模块有default输出接口,可以用参数直接获得。

import('./myModule.js')
.then(myModule => {
  console.log(myModule.default);
});

上面的代码也可以使用具名输入的形式。

import('./myModule.js')
.then(({default: theDefault}) => {
  console.log(theDefault);
});

如果想同时加载多个模块,可以采用下面的写法。

Promise.all([
  import('./module1.js'),
  import('./module2.js'),
  import('./module3.js'),
])
.then(([module1, module2, module3]) => {
   ···
});

import()也可以用在 async 函数之中。

async function main() {
  const myModule = await import('./myModule.js');
  const {export1, export2} = await import('./myModule.js');
  const [module1, module2, module3] =
    await Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ]);
}
main();

13【Class的语法】

1.类的由来

JavaScript 语言中,生成实例对象的传统方法是通过构造函数。下面是一个例子。

function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.toString = function () {
  return '(' + this.x + ', ' + this.y + ')';
};

var p = new Point(1, 2);

上面这种写法跟传统的面向对象语言(比如 C++ 和 Java)差异很大,很容易让新学习这门语言的程序员感到困惑。

ES6 提供了更接近传统语言的写法,引入了 Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。

基本上,ES6 的class可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。上面的代码用 ES6 的class改写,就是下面这样。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}

上面代码定义了一个“类”,可以看到里面有一个constructor()方法,这就是构造方法,而this关键字则代表实例对象。

Point类除了构造方法,还定义了一个toString()方法。注意,定义toString()方法的时候,前面不需要加上function这个关键字,直接把函数定义放进去了就可以了。另外,方法与方法之间不需要逗号分隔,加了会报错。

ES6 的类,完全可以看作构造函数的另一种写法。

class Point {
  // ...
}

typeof Point // "function"
Point === Point.prototype.constructor // true

上面代码表明,类的数据类型就是函数,类本身就指向构造函数。

使用的时候,也是直接对类使用new命令,跟构造函数的用法完全一致。

class Bar {
  doStuff() {
    console.log('stuff');
  }
}

const b = new Bar();
b.doStuff() // "stuff"

构造函数的prototype属性,在 ES6 的“类”上面继续存在。事实上,类的所有方法都定义在类的prototype属性上面。

class Point {
  constructor() {
    // ...
  }

  toString() {
    // ...
  }

  toValue() {
    // ...
  }
}

// 等同于

Point.prototype = {
  constructor() {},
  toString() {},
  toValue() {},
};

上面代码中,constructor()toString()toValue()这三个方法,其实都是定义在Point.prototype上面。

因此,在类的实例上面调用方法,其实就是调用原型上的方法。

class B {}
const b = new B();

b.constructor === B.prototype.constructor // true

上面代码中,bB类的实例,它的constructor()方法就是B类原型的constructor()方法。

由于类的方法都定义在prototype对象上面,所以类的新方法可以添加在prototype对象上面。Object.assign()方法可以很方便地一次向类添加多个方法。

class Point {
  constructor(){
    // ...
  }
}

Object.assign(Point.prototype, {
  toString(){},
  toValue(){}
});

prototype对象的constructor属性,直接指向“类”的本身,这与 ES5 的行为是一致的。

Point.prototype.constructor === Point // true

另外,类的内部所有定义的方法,都是不可枚举的(non-enumerable)。

class Point {
  constructor(x, y) {
    // ...
  }

  toString() {
    // ...
  }
}

Object.keys(Point.prototype)
// []
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]

上面代码中,toString()方法是Point类内部定义的方法,它是不可枚举的。这一点与 ES5 的行为不一致。

var Point = function (x, y) {
  // ...
};

Point.prototype.toString = function () {
  // ...
};

Object.keys(Point.prototype)
// ["toString"]
Object.getOwnPropertyNames(Point.prototype)
// ["constructor","toString"]

上面代码采用 ES5 的写法,toString()方法就是可枚举的。

2. Class的两种定义形式

2.1 声明形式

class Person {
    constructor() {
        ...
    }
    speak() {
        ...
    }
}

2.2 表达式形式

与函数一样,类也可以使用表达式的形式定义。

const MyClass = class Me {
  getClassName() {
    return Me.name;
  }
};

上面代码使用表达式定义了一个类。需要注意的是,这个类的名字是Me,但是Me只在 Class 的内部可用,指代当前类。在 Class 外部,这个类只能用MyClass引用。

let inst = new MyClass();
inst.getClassName() // Me
Me.name // ReferenceError: Me is not defined

上面代码表示,Me只在 Class 内部有定义。

如果类的内部没用到的话,可以省略Me,也就是可以写成下面的形式。

const MyClass = class { /* ... */ };

采用 Class 表达式,可以写出立即执行的 Class。

let person = new class {
  constructor(name) {
    this.name = name;
  }

  sayName() {
    console.log(this.name);
  }
}('张三');

person.sayName(); // "张三"

上面代码中,person是一个立即执行的类的实例。

2.3 表达式形式扩展

类的属性名,可以采用表达式。

let methodName = 'getArea';

class Square {
  constructor(length) {
    // ...
  }

  [methodName]() {
    // ...
  }
}

上面代码中,Square类的方法名getArea,是从表达式得到的。

3.constructor() 方法

constructor()方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。一个类必须有constructor()方法,如果没有显式定义,一个空的constructor()方法会被默认添加。

class Point {
}

// 等同于
class Point {
  constructor() {}
}

上面代码中,定义了一个空的类Point,JavaScript 引擎会自动为它添加一个空的constructor()方法。

constructor()方法默认返回实例对象(即this),完全可以指定返回另外一个对象。

class Foo {
  constructor() {
    return Object.create(null);
  }
}

new Foo() instanceof Foo
// false

上面代码中,constructor()函数返回一个全新的对象,结果导致实例对象不是Foo类的实例。

类必须使用new调用,否则会报错。这是它跟普通构造函数的一个主要区别,后者不用new也可以执行。

class Foo {
  constructor() {
    return Object.create(null);
  }
}

Foo()
// TypeError: Class constructor Foo cannot be invoked without 'new'

4.类的实例

生成类的实例的写法,与 ES5 完全一样,也是使用new命令。前面说过,如果忘记加上new,像函数那样调用Class(),将会报错。

class Point {
  // ...
}

// 报错
var point = Point(2, 3);

// 正确
var point = new Point(2, 3);

类的属性和方法,除非显式定义在其本身(即定义在this对象上),否则都是定义在原型上(即定义在class上)。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}

var point = new Point(2, 3);

point.toString() // (2, 3)

point.hasOwnProperty('x') // true
point.hasOwnProperty('y') // true
point.hasOwnProperty('toString') // false
point.__proto__.hasOwnProperty('toString') // true

上面代码中,xy都是实例对象point自身的属性(因为定义在this对象上),所以hasOwnProperty()方法返回true,而toString()是原型对象的属性(因为定义在Point类上),所以hasOwnProperty()方法返回false。这些都与 ES5 的行为保持一致。

与 ES5 一样,类的所有实例共享一个原型对象。

var p1 = new Point(2,3);
var p2 = new Point(3,2);

p1.__proto__ === p2.__proto__
//true

上面代码中,p1p2都是Point的实例,它们的原型都是Point.prototype,所以__proto__属性是相等的。

这也意味着,可以通过实例的__proto__属性为“类”添加方法。

__proto__ 并不是语言本身的特性,这是各大厂商具体实现时添加的私有属性,虽然目前很多现代浏览器的 JS 引擎中都提供了这个私有属性,但依旧不建议在生产中使用该属性,避免对环境产生依赖。生产环境中,我们可以使用 Object.getPrototypeOf() 方法来获取实例对象的原型,然后再来为原型添加方法/属性。

var p1 = new Point(2,3);
var p2 = new Point(3,2);

p1.__proto__.printName = function () { return 'Oops' };

p1.printName() // "Oops"
p2.printName() // "Oops"

var p3 = new Point(4,2);
p3.printName() // "Oops"

上面代码在p1的原型上添加了一个printName()方法,由于p1的原型就是p2的原型,因此p2也可以调用这个方法。而且,此后新建的实例p3也可以调用这个方法。这意味着,使用实例的__proto__属性改写原型,必须相当谨慎,不推荐使用,因为这会改变“类”的原始定义,影响到所有实例。

5.实例属性的新写法

ES2022 为类的实例属性,又规定了一种新写法。实例属性现在除了可以定义在constructor()方法里面的this上面,也可以定义在类内部的最顶层。

// 原来的写法
class IncreasingCounter {
  constructor() {
    this._count = 0;
  }
  get value() {
    console.log('Getting the current value!');
    return this._count;
  }
  increment() {
    this._count++;
  }
}

上面示例中,实例属性_count定义在constructor()方法里面的this上面。

现在的新写法是,这个属性也可以定义在类的最顶层,其他都不变。

class IncreasingCounter {
  _count = 0;
  get value() {
    console.log('Getting the current value!');
    return this._count;
  }
  increment() {
    this._count++;
  }
}

上面代码中,实例属性_count与取值函数value()increment()方法,处于同一个层级。这时,不需要在实例属性前面加上this

注意,新写法定义的属性是实例对象自身的属性,而不是定义在实例对象的原型上面。

这种新写法的好处是,所有实例对象自身的属性都定义在类的头部,看上去比较整齐,一眼就能看出这个类有哪些实例属性。

class foo {
  bar = 'hello';
  baz = 'world';

  constructor() {
    // ...
  }
}

上面的代码,一眼就能看出,foo类有两个实例属性,一目了然。另外,写起来也比较简洁。

6.取值函数(getter)和存值函数(setter)

6.1 getter

get 语法将一个对象属性绑定到查询该属性时将被调用的一个函数上。

{get prop() { ... } }  // prop:要绑定到给定函数的属性名
{get [expression]() { ... } } // expression:从ECMAScript 2015 (ES6)开始,还可以使用一个计算的属性名的表达式绑定到给定的函数。

有时候希望访问属性时能返回一个动态计算后的值, 或希望不通过使用明确的方法调用而显示内部变量的状态.在JavaScript中, 能通过使用 getter 实现. 尽管可能结合使用getter和setter创建一个伪属性,但不能既使用getter绑定到一个属性上,同时又用该属性真实的存储一个值.

在新对象初始化时定义一个getter

这会为obj创建一个虚假的属性latest, 该属性会返回log数组的最后一个元素.

var log = ['test'];
var obj = {
  get latest () {
    if (log.length == 0) return undefined;
    return log[log.length - 1]
  }
}
console.log (obj.latest); // Will return "test".

注意,试图赋给latest新值的话不会改变该值.

使用delete操作符删除getter

delete obj.latest;

使用defineProperty在存在的对象上定义 getter

在任意时间添加getter到一个存在的对象,使用 Object.defineProperty()

var o = { a:0 }
Object.defineProperty(o, "b", { get: function () { return this.a + 1; } });
console.log(o.b) // Runs the getter, which yields a + 1 (which is 1)

6.2 setter

{set prop(val) { . . . }}  //prop :要绑定到给定函数的属性名。
{set [expression](val) { . . . }} 
//val:用于保存尝试分配给prop的值的变量的一个别名。
//expression:从ECMAScript 2015 (ES6)开始,还可以使用一个计算的属性名的表达式绑定到给定的函数。

在 javascript 中,如果试着改变一个属性的值,那么对应的 setter 将被执行。setter 经常和 getter 连用以创建一个伪属性。一个拥有真实值的属性就不能再有 setter 了。

在对象初始化时定义 setter

对象 o 定义一个伪属性 current ,当对它赋值时,将会把值更新到 log:

var o = {
  set current (str) {
    return this.log[this.log.length] = str;
  },
  log: []
}

注意,current 属性是未定义的,访问它时将会返回 undefined。

用 delete 操作符移除一个 setter

delete o.current;

使用 defineProperty 为已存在的对象定义 setter

可以随时使用Object.defineProperty() 给一个已经存在的对象添加一个 setter。

var o = { a:0 };



Object.defineProperty(o, "b", { set: function (x) { this.a = x / 2; } });



o.b = 10; // Runs the setter, which assigns 10 / 2 (5) to the 'a' property

6.3 es6中的使用

与 ES5 一样,在“类”的内部可以使用getset关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。

class MyClass {
  constructor() {
    // ...
  }
  get prop() {
    return 'getter';
  }
  set prop(value) {
    console.log('setter: '+value);
  }
}

let inst = new MyClass();

inst.prop = 123;
// setter: 123

inst.prop
// 'getter'

上面代码中,prop属性有对应的存值函数和取值函数,因此赋值和读取行为都被自定义了。

存值函数和取值函数是设置在属性的 Descriptor 对象上的。

class CustomHTMLElement {
  constructor(element) {
    this.element = element;
  }

  get html() {
    return this.element.innerHTML;
  }

  set html(value) {
    this.element.innerHTML = value;
  }
}

var descriptor = Object.getOwnPropertyDescriptor(
  CustomHTMLElement.prototype, "html"
);

"get" in descriptor  // true
"set" in descriptor  // true

上面代码中,存值函数和取值函数是定义在html属性的描述对象上面,这与 ES5 完全一致。

7.实例属性、静态方法和静态属性

7.1 实例属性

我们之前将类的属性利用 this 的方式写在了构造方法里,把类的方法写在了 class 里。

现在我们还可以把类的属性和方法写在 class 里,然后在构造方法里进行值的修改,或者是提供一个 get set 方法来间接控制变量。

class Person {
    _age = 0;        // 类属性之前不能加 var 或 let
    _sex = 'male';   // 类属性被赋予的值相当于就是属性的默认值
    
    /*
    // get、set 还可以用这样的格式来写
    // 这里其实本质上就是定义一个类属性,只不过这个属性指向一个函数而已
    getSex = function() {
        return this._age;
    };
    */

    get age() {
        return this._age;
    }

    set age(value) {
        this._age = value;
    }

    get sex() {
        return this._sex;
    }

    set sex(value) {
        this._sex = value;
    }

    constructor(age, sex) {
        this._age = age;
        this._sex = sex;
    }

    // 类的方法不能用 function 关键字
    speak() {
        console.log(this._age + " " + this._sex);
    }
}

7.2 静态方法

类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。

class Foo {
  static classMethod() {
    return 'hello';
  }
}

Foo.classMethod() // 'hello'

var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function

上面代码中,Foo类的classMethod方法前有static关键字,表明该方法是一个静态方法,可以直接在Foo类上调用(Foo.classMethod()),而不是在Foo类的实例上调用。如果在实例上调用静态方法,会抛出一个错误,表示不存在该方法。

静态方法的 this 指向问题:静态方法的 this 指向这个类本身。

注意:普通方法 this 指向具体的对象,而静态方法的 this 指向类本身。

class Person {
    // 静态属性
    static _name = "user";
    static _age = 18;

    constructor(name, age) {
        this._name = name;
        this._age = age;
    }

    static test() {
        console.log("静态方法");
    }

    // 静态方法
    static readme() {
        // 静态方法中的 this 指向 Person 类本身
        // 并且静态方法的 this 只能引用到类的静态属性及静态方法
        console.log(this._name + " " + this._age);
        this.test();
    }
}

Person.readme();
console.log(Person._name);
/*
user 18
静态方法
user
*/

/*
再次注意:静态方法中只能使用类的静态属性与静态方法
*/

注意,如果静态方法包含this关键字,这个this指的是类,而不是实例。

父类的静态方法,可以被子类继承。

class Foo {
  static classMethod() {
    return 'hello';
  }
}

class Bar extends Foo {
}

Bar.classMethod() // 'hello'

上面代码中,父类Foo有一个静态方法,子类Bar可以调用这个方法。

静态方法也是可以从super对象上调用的。

class Foo {
  static classMethod() {
    return 'hello';
  }
}

class Bar extends Foo {
  static classMethod() {
    return super.classMethod() + ', too';
  }
}

Bar.classMethod() // "hello, too"

7.3 静态属性

静态属性指的是 Class 本身的属性,即Class.propName,而不是定义在实例对象(this)上的属性。

class Foo {
}

Foo.prop = 1;
Foo.prop // 1

上面的写法为Foo类定义了一个静态属性prop

目前,只有这种写法可行,因为 ES6 明确规定,Class 内部只有静态方法,没有静态属性。现在有一个提案提供了类的静态属性,写法是在实例属性的前面,加上static关键字。

class MyClass {
  static myStaticProp = 42;

  constructor() {
    console.log(MyClass.myStaticProp); // 42
  }
}

这个新写法大大方便了静态属性的表达。

// 老写法
class Foo {
  // ...
}
Foo.prop = 1;

// 新写法
class Foo {
  static prop = 1;
}

上面代码中,老写法的静态属性定义在类的外部。整个类生成以后,再生成静态属性。这样让人很容易忽略这个静态属性,也不符合相关代码应该放在一起的代码组织原则。另外,新写法是显式声明(declarative),而不是赋值处理,语义更好。

注意:静态属性 static 的定义方法目前只是一个提案,目前不推荐这样写!某些浏览器不支持。

class Person {
    constructor(name) {
        this.name = name;
    }
    
    // 目前不推荐 static 这种写法,因为还只是一个提案
    // static version = "1.0";
    
    // 推荐利用静态方法来间接实现静态属性
    static getVersion() {
        return "1.0";
    }
}

8.私有方法和私有属性

8.1 早期解决方案

// 方式一:在属性开头加上 _ 表示私有
class Person {
    constructor(name) {
        this._name = name;
    }
    
    _speak() {
        console.log("speak");
    }
    
    getName() {
        return this._name;
    }
}

const p = new Person('Alex');
console.log(p.name);	// 报错!
// console.log(p._name);	// Alex,但是这样做就无意义了,违背了私有化的初衷

/*
注意:加下划线的方式实际上只是行业中约定俗成的一种方法,
我们依旧可以通过 p._name,来访问,但是这样做就无意义了!所以这种方法的使用纯靠程序员自觉。
*/
// 方式二:将私有属性和方法移出类(使用模块)
// 我们目前用立即执行函数(闭包原理)来模拟
(function () {
    let name = "";
    let speak = function () {
        console.log("speak");
    }

    class Person {
        constructor(username) {
            name = username;
        }

        getName() {
            return name;
        }

        runSpeak() {
            speak();
        }
    }

    // 将类添加到全局作用域中暴露
    window.Person = Person;
})();

const p = new Person('Alex');
console.log(p.name);	// 报错
console.log(p.getName());	// Alex
p.runSpeak();	// speak

8.2 私有属性的正式写法

ES2022正式为class添加了私有属性,方法是在属性名之前使用#表示。

class IncreasingCounter {
  #count = 0;
  get value() {
    console.log('Getting the current value!');
    return this.#count;
  }
  increment() {
    this.#count++;
  }
}

上面代码中,#count就是私有属性,只能在类的内部使用(this.#count)。如果在类的外部使用,就会报错。

const counter = new IncreasingCounter();
counter.#count // 报错
counter.#count = 42 // 报错

上面示例中,在类的外部,读取或写入私有属性#count,都会报错。

另外,不管在类的内部或外部,读取一个不存在的私有属性,也都会报错。这跟公开属性的行为完全不同,如果读取一个不存在的公开属性,不会报错,只会返回undefined

class IncreasingCounter {
  #count = 0;
  get value() {
    console.log('Getting the current value!');
    return this.#myCount; // 报错
  }
  increment() {
    this.#count++;
  }
}

const counter = new IncreasingCounter();
counter.#myCount // 报错

上面示例中,#myCount是一个不存在的私有属性,不管在函数内部或外部,读取该属性都会导致报错。

注意,私有属性的属性名必须包括#,如果不带#,会被当作另一个属性。

class Point {
  #x;

  constructor(x = 0) {
    this.#x = +x;
  }

  get x() {
    return this.#x;
  }

  set x(value) {
    this.#x = +value;
  }
}

上面代码中,#x就是私有属性,在Point类之外是读取不到这个属性的。由于井号#是属性名的一部分,使用时必须带有#一起使用,所以#xx是两个不同的属性。

这种写法不仅可以写私有属性,还可以用来写私有方法。

class Foo {
  #a;
  #b;
  constructor(a, b) {
    this.#a = a;
    this.#b = b;
  }
  #sum() {
    return this.#a + this.#b;
  }
  printSum() {
    console.log(this.#sum());
  }
}

上面示例中,#sum()就是一个私有方法。

另外,私有属性也可以设置 getter 和 setter 方法。

class Counter {
  #xValue = 0;

  constructor() {
    console.log(this.#x);
  }

  get #x() { return this.#xValue; }
  set #x(value) {
    this.#xValue = value;
  }
}

上面代码中,#x是一个私有属性,它的读写都通过get #x()set #x()操作另一个私有属性#xValue来完成。

私有属性不限于从this引用,只要是在类的内部,实例也可以引用私有属性。

class Foo {
  #privateValue = 42;
  static getPrivateValue(foo) {
    return foo.#privateValue;
  }
}

Foo.getPrivateValue(new Foo()); // 42

上面代码允许从实例foo上面引用私有属性。

私有属性和私有方法前面,也可以加上static关键字,表示这是一个静态的私有属性或私有方法。

class FakeMath {
  static PI = 22 / 7;
  static #totallyRandomNumber = 4;

  static #computeRandomNumber() {
    return FakeMath.#totallyRandomNumber;
  }

  static random() {
    console.log('I heard you like random numbers…')
    return FakeMath.#computeRandomNumber();
  }
}

FakeMath.PI // 3.142857142857143
FakeMath.random()
// I heard you like random numbers…
// 4
FakeMath.#totallyRandomNumber // 报错
FakeMath.#computeRandomNumber() // 报错

上面代码中,#totallyRandomNumber是私有属性,#computeRandomNumber()是私有方法,只能在FakeMath这个类的内部调用,外部调用就会报错。

9.类的注意点

9.1 严格模式

类和模块的内部,默认就是严格模式,所以不需要使用use strict指定运行模式。只要你的代码写在类或模块之中,就只有严格模式可用。考虑到未来所有的代码,其实都是运行在模块之中,所以 ES6 实际上把整个语言升级到了严格模式。

9.2 不存在提升

类不存在变量提升(hoist),这一点与 ES5 完全不同。

new Foo(); // ReferenceError
class Foo {}

上面代码中,Foo类使用在前,定义在后,这样会报错,因为 ES6 不会把类的声明提升到代码头部。这种规定的原因与下文要提到的继承有关,必须保证子类在父类之后定义。

{
  let Foo = class {};
  class Bar extends Foo {
  }
}

上面的代码不会报错,因为Bar继承Foo的时候,Foo已经有定义了。但是,如果存在class的提升,上面代码就会报错,因为class会被提升到代码头部,而let命令是不提升的,所以导致Bar继承Foo的时候,Foo还没有定义。

9.3 name 属性

由于本质上,ES6 的类只是 ES5 的构造函数的一层包装,所以函数的许多特性都被Class继承,包括name属性。

class Point {}
Point.name // "Point"

name属性总是返回紧跟在class关键字后面的类名。

9.4 this 的指向

类的方法内部如果含有this,它默认指向类的实例。但是,必须非常小心,一旦单独使用该方法,很可能报错。

class Logger {
  printName(name = 'there') {
    this.print(`Hello ${name}`);
  }

  print(text) {
    console.log(text);
  }
}

const logger = new Logger();
const { printName } = logger;
printName(); // TypeError: Cannot read property 'print' of undefined

上面代码中,printName方法中的this,默认指向Logger类的实例。但是,如果将这个方法提取出来单独使用,this会指向该方法运行时所在的环境(由于 class 内部是严格模式,所以 this 实际指向的是undefined),从而导致找不到print方法而报错。

一个比较简单的解决方法是,在构造方法中绑定this,这样就不会找不到print方法了。

class Logger {
  constructor() {
    this.printName = this.printName.bind(this);
  }

  // ...
}

另一种解决方法是使用箭头函数。

class Obj {
  constructor() {
    this.getThis = () => this;
  }
}

const myObj = new Obj();
myObj.getThis() === myObj // true

箭头函数内部的this总是指向定义时所在的对象。上面代码中,箭头函数位于构造函数内部,它的定义生效的时候,是在构造函数执行的时候。这时,箭头函数所在的运行环境,肯定是实例对象,所以this会总是指向实例对象。

14 【Class extends】

1.简介

Class 可以通过extends关键字实现继承,让子类继承父类的属性和方法。extends 的写法比 ES5 的原型链继承,要清晰和方便很多。

class Point {
}

class ColorPoint extends Point {
}

上面示例中,Point是父类,ColorPoint是子类,它通过extends关键字,继承了Point类的所有属性和方法。但是由于没有部署任何代码,所以这两个类完全一样,等于复制了一个Point类。

下面,我们在ColorPoint内部加上代码。

class Point { /* ... */ }

class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y); // 调用父类的constructor(x, y)
    this.color = color;
  }

  toString() {
    return this.color + ' ' + super.toString(); // 调用父类的toString()
  }
}

上面示例中,constructor()方法和toString()方法内部,都出现了super关键字。super在这里表示父类的构造函数,用来新建一个父类的实例对象。

ES6 规定,子类必须在constructor()方法中调用super(),否则就会报错。这是因为子类自己的this对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,添加子类自己的实例属性和方法。如果不调用super()方法,子类就得不到自己的this对象。

class Point { /* ... */ }

class ColorPoint extends Point {
  constructor() {
  }
}

let cp = new ColorPoint(); // ReferenceError

上面代码中,ColorPoint继承了父类Point,但是它的构造函数没有调用super(),导致新建实例时报错。

为什么子类的构造函数,一定要调用super()?原因就在于 ES6 的继承机制,与 ES5 完全不同。ES5 的继承机制,是先创造一个独立的子类的实例对象,然后再将父类的方法添加到这个对象上面,即“实例在前,继承在后”。ES6 的继承机制,则是先将父类的属性和方法,加到一个空的对象上面,然后再将该对象作为子类的实例,即“继承在前,实例在后”。这就是为什么 ES6 的继承必须先调用super()方法,因为这一步会生成一个继承父类的this对象,没有这一步就无法继承父类。

注意,这意味着新建子类实例时,父类的构造函数必定会先运行一次。

class Foo {
  constructor() {
    console.log(1);
  }
}

class Bar extends Foo {
  constructor() {
    super();
    console.log(2);
  }
}

const bar = new Bar();
// 1
// 2

上面示例中,子类 Bar 新建实例时,会输出1和2。原因就是子类构造函数调用super()时,会执行一次父类构造函数。

另一个需要注意的地方是,在子类的构造函数中,只有调用super()之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建,必须先完成父类的继承,只有super()方法才能让子类实例继承父类。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

class ColorPoint extends Point {
  constructor(x, y, color) {
    this.color = color; // ReferenceError
    super(x, y);
    this.color = color; // 正确
  }
}

上面代码中,子类的constructor()方法没有调用super()之前,就使用this关键字,结果报错,而放在super()之后就是正确的。

如果子类没有定义constructor()方法,这个方法会默认添加,并且里面会调用super()。也就是说,不管有没有显式定义,任何一个子类都有constructor()方法。

class ColorPoint extends Point {
}

// 等同于
class ColorPoint extends Point {
  constructor(...args) {
    super(...args);
  }
}

有了子类的定义,就可以生成子类的实例了。

let cp = new ColorPoint(25, 8, 'green');

cp instanceof ColorPoint // true
cp instanceof Point // true

上面示例中,实例对象cp同时是ColorPointPoint两个类的实例,这与 ES5 的行为完全一致。

2.私有属性和私有方法的继承

父类所有的属性和方法,都会被子类继承,除了私有的属性和方法。

子类无法继承父类的私有属性,或者说,私有属性只能在定义它的 class 里面使用。

class Foo {
  #p = 1;
  #m() {
    console.log('hello');
  }
}

class Bar extends Foo {
  constructor() {
    super();
    console.log(this.#p); // 报错
    this.#m(); // 报错
  }
}

上面示例中,子类 Bar 调用父类 Foo 的私有属性或私有方法,都会报错。

如果父类定义了私有属性的读写方法,子类就可以通过这些方法,读写私有属性。

class Foo {
  #p = 1;
  getP() {
    return this.#p;
  }
}

class Bar extends Foo {
  constructor() {
    super();
    console.log(this.getP()); // 1
  }
}

上面示例中,getP()是父类用来读取私有属性的方法,通过该方法,子类就可以读到父类的私有属性。

3.静态属性和静态方法的继承

父类的静态属性和静态方法,也会被子类继承。

class A {
  static hello() {
    console.log('hello world');
  }
}

class B extends A {
}

B.hello()  // hello world

上面代码中,hello()A类的静态方法,B继承A,也继承了A的静态方法。

注意,静态属性是通过软拷贝实现继承的。

class A { static foo = 100; }
class B extends A {
  constructor() {
    super();
    B.foo--;
  }
}

const b = new B();
B.foo // 99
A.foo // 100

上面示例中,foo是 A 类的静态属性,B 类继承了 A 类,因此也继承了这个属性。但是,在 B 类内部操作B.foo这个静态属性,影响不到A.foo,原因就是 B 类继承静态属性时,会采用浅拷贝,拷贝父类静态属性的值,因此A.fooB.foo是两个彼此独立的属性。

但是,由于这种拷贝是浅拷贝,如果父类的静态属性的值是一个对象,那么子类的静态属性也会指向这个对象,因为浅拷贝只会拷贝对象的内存地址。

class A {
  static foo = { n: 100 };
}

class B extends A {
  constructor() {
    super();
    B.foo.n--;
  }
}

const b = new B();
B.foo.n // 99
A.foo.n // 99

上面示例中,A.foo的值是一个对象,浅拷贝导致B.fooA.foo指向同一个对象。所以,子类B修改这个对象的属性值,会影响到父类A

4.Object.getPrototypeOf()

Object.getPrototypeOf()方法可以用来从子类上获取父类。

class Point { /*...*/ }

class ColorPoint extends Point { /*...*/ }

Object.getPrototypeOf(ColorPoint) === Point
// true

因此,可以使用这个方法判断,一个类是否继承了另一个类。

5.super 关键字

super这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。

第一种情况,super作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次super函数。

class A {}

class B extends A {
  constructor() {
    super();
  }
}

上面代码中,子类B的构造函数之中的super(),代表调用父类的构造函数。这是必须的,否则 JavaScript 引擎会报错。

注意,super虽然代表了父类A的构造函数,但是返回的是子类B的实例,即super内部的this指的是B的实例,因此super()在这里相当于A.prototype.constructor.call(this)

class A {
  constructor() {
    console.log(new.target.name);
  }
}
class B extends A {
  constructor() {
    super();
  }
}
new A() // A
new B() // B

上面代码中,new.target指向当前正在执行的函数。可以看到,在super()执行时,它指向的是子类B的构造函数,而不是父类A的构造函数。也就是说,super()内部的this指向的是B

作为函数时,super()只能用在子类的构造函数之中,用在其他地方就会报错。

class A {}

class B extends A {
  m() {
    super(); // 报错
  }
}

上面代码中,super()用在B类的m方法之中,就会造成语法错误。

第二种情况,super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。

class A {
  p() {
    return 2;
  }
}

class B extends A {
  constructor() {
    super();
    console.log(super.p()); // 2
  }
}

let b = new B();

上面代码中,子类B当中的super.p(),就是将super当作一个对象使用。这时,super在普通方法之中,指向A.prototype,所以super.p()就相当于A.prototype.p()

这里需要注意,由于super指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super调用的。

class A {
  constructor() {
    this.p = 2;
  }
}

class B extends A {
  get m() {
    return super.p;
  }
}

let b = new B();
b.m // undefined

上面代码中,p是父类A实例的属性,super.p就引用不到它。

如果属性定义在父类的原型对象上,super就可以取到。

class A {}
A.prototype.x = 2;

class B extends A {
  constructor() {
    super();
    console.log(super.x) // 2
  }
}

let b = new B();

上面代码中,属性x是定义在A.prototype上面的,所以super.x可以取到它的值。

ES6 规定,在子类普通方法中通过super调用父类的方法时,方法内部的this指向当前的子类实例。

class A {
  constructor() {
    this.x = 1;
  }
  print() {
    console.log(this.x);
  }
}

class B extends A {
  constructor() {
    super();
    this.x = 2;
  }
  m() {
    super.print();
  }
}

let b = new B();
b.m() // 2

上面代码中,super.print()虽然调用的是A.prototype.print(),但是A.prototype.print()内部的this指向子类B的实例,导致输出的是2,而不是1。也就是说,实际上执行的是super.print.call(this)

由于this指向子类实例,所以如果通过super对某个属性赋值,这时super就是this,赋值的属性会变成子类实例的属性。

class A {
  constructor() {
    this.x = 1;
  }
}

class B extends A {
  constructor() {
    super();
    this.x = 2;
    super.x = 3;
    console.log(super.x); // undefined
    console.log(this.x); // 3
  }
}

let b = new B();

上面代码中,super.x赋值为3,这时等同于对this.x赋值为3。而当读取super.x的时候,读的是A.prototype.x,所以返回undefined

如果super作为对象,用在静态方法之中,这时super将指向父类,而不是父类的原型对象。

class Parent {
  static myMethod(msg) {
    console.log('static', msg);
  }

  myMethod(msg) {
    console.log('instance', msg);
  }
}

class Child extends Parent {
  static myMethod(msg) {
    super.myMethod(msg);
  }

  myMethod(msg) {
    super.myMethod(msg);
  }
}

Child.myMethod(1); // static 1

var child = new Child();
child.myMethod(2); // instance 2

上面代码中,super在静态方法之中指向父类,在普通方法之中指向父类的原型对象。

另外,在子类的静态方法中通过super调用父类的方法时,方法内部的this指向当前的子类,而不是子类的实例。

class A {
  constructor() {
    this.x = 1;
  }
  static print() {
    console.log(this.x);
  }
}

class B extends A {
  constructor() {
    super();
    this.x = 2;
  }
  static m() {
    super.print();
  }
}

B.x = 3;
B.m() // 3

上面代码中,静态方法B.m里面,super.print指向父类的静态方法。这个方法里面的this指向的是B,而不是B的实例。

注意,使用super的时候,必须显式指定是作为函数、还是作为对象使用,否则会报错。

class A {}

class B extends A {
  constructor() {
    super();
    console.log(super); // 报错
  }
}

上面代码中,console.log(super)当中的super,无法看出是作为函数使用,还是作为对象使用,所以 JavaScript 引擎解析代码的时候就会报错。这时,如果能清晰地表明super的数据类型,就不会报错。

class A {}

class B extends A {
  constructor() {
    super();
    console.log(super.valueOf() instanceof B); // true
  }
}

let b = new B();

上面代码中,super.valueOf()表明super是一个对象,因此就不会报错。同时,由于super使得this指向B的实例,所以super.valueOf()返回的是一个B的实例。

最后,由于对象总是继承其他对象的,所以可以在任意一个对象中,使用super关键字。

var obj = {
  toString() {
    return "MyObject: " + super.toString();
  }
};

obj.toString(); // MyObject: [object Object]

6.类的 prototype 属性和__proto__属性

大多数浏览器的 ES5 实现之中,每一个对象都有__proto__属性,指向对应的构造函数的prototype属性。Class 作为构造函数的语法糖,同时有prototype属性和__proto__属性,因此同时存在两条继承链。

(1)子类的__proto__属性,表示构造函数的继承,总是指向父类。

(2)子类prototype属性的__proto__属性,表示方法的继承,总是指向父类的prototype属性。

class A {
}

class B extends A {
}

B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true

上面代码中,子类B__proto__属性指向父类A,子类Bprototype属性的__proto__属性指向父类Aprototype属性。

extends关键字后面可以跟多种类型的值。

class B extends A {
}

上面代码的A,只要是一个有prototype属性的函数,就能被B继承。由于函数都有prototype属性(除了Function.prototype函数),因此A可以是任意函数。

下面,讨论两种情况。第一种,子类继承Object类。

class A extends Object {
}

A.__proto__ === Object // true
A.prototype.__proto__ === Object.prototype // true

这种情况下,A其实就是构造函数Object的复制,A的实例就是Object的实例。

第二种情况,不存在任何继承。

class A {
}

A.__proto__ === Function.prototype // true
A.prototype.__proto__ === Object.prototype // true

这种情况下,A作为一个基类(即不存在任何继承),就是一个普通函数,所以直接继承Function.prototype。但是,A调用后返回一个空对象(即Object实例),所以A.prototype.__proto__指向构造函数(Object)的prototype属性。

7.实例的__proto__属性

子类实例的__proto__属性的__proto__属性,指向父类实例的__proto__属性。也就是说,子类的原型的原型,是父类的原型。

var p1 = new Point(2, 3);
var p2 = new ColorPoint(2, 3, 'red');

p2.__proto__ === p1.__proto__ // false
p2.__proto__.__proto__ === p1.__proto__ // true

上面代码中,ColorPoint继承了Point,导致前者原型的原型是后者的原型。

因此,通过子类实例的__proto__.__proto__属性,可以修改父类实例的行为。

p2.__proto__.__proto__.printName = function () {
  console.log('Ha');
};

p1.printName() // "Ha"

上面代码在ColorPoint的实例p2上向Point类添加方法,结果影响到了Point的实例p1

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值