ES6 学习笔记(3)

紧接上篇,篇幅太长。主要讲述 Promise,异步操作和 async 函数,Class,修饰器和 Module

Promise

Promise 是一个对象,用来传递异步操作的消息。代表了某个未来才会知道结果的事件,并且这个事件提供统一的 API,可供进一步处理。Promise 对象有以下两个特点:

  1. 对象的状态不受外界影响。Promise 对象代表一个异步操作,有3种状态: Pending、Resolved、Rejected。只有异步操作的结果可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。
  2. 一旦状态改变就不会再变,任何时候都可以得到这个结果。Promise 对象的状态改变只有两种可能:从 Pending 变为 Resolved 和从 Pending 变为 Rejected。当改变已经发生,再对 Promise 对象添加回调函数,也会立即得到这个结果。

基本用法

Promise 对象是一个构造函数,接受一个函数作为参数,该函数的两个参数分别是 resolve 和 reject,用来生成 Promise 实例。resolve 和 reject 是两个函数,有 javascript 引擎提供,不用自己部署。resolve 函数的作用是,将 Promise 对象的状态从 pending 变为 resolved,在异步操作成功时调用,并将异步操作的结果作为参数传递出去;reject 函数是从 pending 变为 rejected,在异步操作失败时调用,并将异步操作爆出的错误作为参数传递出去。Promise 实例生成以后,可以用 then 方法分别指定 Resolved 和 Rejected 状态的回调函数。

如果调用 resolve 和 reject 函数时带有参数,那么这些参数会被传给回调函数。reject 函数的参数通常是 Error 对象的实例,表示抛出的错误;resolve 函数的参数除了正常的值外,还可能是另一个 Promise 实例,表示异步操作的结果有可能是一个值,也有可能是一个异步操作。

1
2
const p1 = new Promise((resolve, reject) => {});
const p2 = new Promise((resolve, reject) => resolve(p1));

此时,p1 的状态会传递给 p2。即 p1 的状态决定了 p2 的状态。如果 p1 的状态是 pending,那么 p2 的回到函数就会等待 p1 的状态改变;如果 p1 的状态已经是 resolved 或 rejected,那么 p2 的回调函数就会立刻执行。

  1. Promise.prototype.then() 为 Promise 实例添加状态改变时的回调函数,返回的是一个新的 Promise 实例(注意,不是原来的那个 Promise 实例)。
  2. Promise.prototype.catch() 是 Promise.prototype.then(null, rejection) 的别名,指定发生错误时的回调函数。如果 Promise 状态已经变成 resolved,再抛出错误是不会被捕获的。Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。如果没有使用 catch 方法捕获,则 Promise 对象抛出的错误不会传递到外层代码,即不会有任何反应。另外,catch 方法返回的还是一个 Promise 对象。
  3. Promise.resolve()/Promise.reject() 将现有对象转为 Promise 对象。如果参数不是具有 then 方法的对象,则返回一个新的 Promise 对象,且状态为 resolved/rejected。如果参数是一个 Promise 实例,则会被原封不动地返回。允许调用时不带参数。

    1
    2
    3
    Promise.resolve('hello');
    // 等价于
    new Promise(resolve => resolve('foo'));
  4. Promise.all()/Promise.race() 将多个 Promise 实例包装成一个新的 Promise 实例,就收一个数组作为参数,数组成员都是 Promise 对象的实例;如果不是,会先调用 Promise.resolve 方法,将参数转为 Promise 实例,再进一步处理(函数的参数不一定是数组,但必须具有 Iterator 接口)。

异步操作与 async 函数

ES6 诞生前,异步编程的方法大概有下面4种:

  1. 回调函数
  2. 事件监听
  3. 发布/订阅
  4. Promise 对象

ES7 提供了 async 函数,使得异步操作变的更加方便。而 async 函数就是 Generator 函数的语法糖,将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await。async 函数对 Generator 函数的改进体现在以下4点:

  1. 内置执行器。async 函数自带执行器。而 Generator 函数需要调用 next 方法或者自动执行器才能真正执行。
  2. 更好的语义。
  3. 更广的适用性。async 函数的 await 命令后面可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
  4. 返回值是 Promise。async 函数的返回值是 Promise 对象,而 Generator 函数的返回值是 Iterator 对象。

Class

JavaScript 语言的传统方法是通过构造函数定义并生成新对象。

1
2
3
4
5
6
7
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.toString = function() {} {
return `(${this.x}, ${this.y})`;
}

上面的代码用 ES6 的 Class 改写如下。类的所有方法都定义在类的 prototype 属性上,且类内部定义的所有方法都是不可枚举的。

1
2
3
4
5
6
7
8
9
10
11
12
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() {
return `(${this.x}, ${this.y})`;
}
}
typeof Point; // "function"
Point === Point.prototype.constructor; // true
Object.keys(Point.prototype); // []

constructor 方法

constructor 方法是类的默认方法,通过 new 命令生成对象实例时自动调用该方法。一个类必须有该方法,默认是一个空的 constructor 方法。constructor 方法默认返回实例对象(即this),不过完全可以指定返回另外一个对象,但这会导致实例对象不是构造函数类的实例。

Class 表达式

1
2
3
4
5
const MyClass = class Me {
getClassName() {
return Me.name;
}
}

与函数一样,Class 也可以使用表达式的形式定义。此时这个类的名字是 MyClass 而不是 Me,Me 只在 Class 的内部代码可用。如果 Class 内部没有用到,那么可以省略,而采用 Class 表达式,可以写出立即执行的 Class。

1
2
3
4
5
let person = new class {
constructor(name) {
this.name = name;
}
}('qingguoing');

Class 的继承

Class 之间可以通过 extends 关键字实现继承。子类必须在 constructor 方法中调用 super 方法,否则新建实例时会报错。这是因为子类没有自己的 this 对象,而是继承了父类的 this 对象,然后对其进行加工。如果不调用 super 方法,子类就得不到 this 对象,而且只有调用 super 之后,才可以使用 this 关键字,否则会报错。如果子类没有定义 constructor 方法,那么这个方法会被默认添加:

1
2
3
constructor(...args) {
super(...args);
}

Class 作为构造函数的语法糖,同时有 prototype 属性和 __proto__ 属性:

  1. 子类的 __proto__ 属性表示构造函数的继承,总是指向父类。作为一个对象,子类的原型(__proto__ 属性)是父类。
  2. 子类 prototype 属性的 __proto__ 属性表示方法的继承,总是指向父类的 prototype 属性。作为一个构造函数,子类的原型是父类的实例。

extends 的继承目标

extends 关键字后面可以跟多种类型的值,class B extends A {} A 是一个有 prototype 属性的函数,就能被 B 继承。由于函数都有 prototype 属性,因此 A 可以是任意函数。下面三种特殊情况:

  1. class A extends Object {} 此时 A 是构造函数 Object 的复制,A 的实例就是 Object 的实例。

    1
    2
    A.__proto__ === Object; // true
    A.prototype.__proto__ === Object.prototype; // true
  2. class A {} 不存在任何继承,此时 A 作为一个基类就是一个普通函数,所以继承 Function.prototype。但是 A 调用后返回一个空对象即 Object 实例,所以 A.prototype.__proto__ 指向构造函数(Object)的 prototype属性。

  3. class A extends null {} 此时 A 是一个普通函数,直接继承 Function.prototype。但是 A 调用后返回的对象不继承任何方法,所以A的 __proto__ 指向 Function.prototype,即实质执行了下面代码:
    1
    2
    3
    4
    5
    6
    7
    8
    class A extends null {
    // 此处若不声明 constructor,会报错,因为父类 null 没有 constructor 方法,调用默认的 super 导致出错。
    constructor() {
    return Object.create(null);
    }
    }
    A.__proto__ === Function.prototype; // true
    A.prototype.__proto__ === undefined; // true

子类实例的 __proto__ 属性的 __proto__ 属性,指向父类实例的 __proto__ 属性,即子类的原型的原型是父类的原型。因此,通过子类实例的 __proto__.__proto__ 属性可以修改父类实例的行为。

原生构造函数的继承

原生构造函数是指语言内置的构造函数,通常用来生成数据结构。ECMAScript 的原生构造函数大致有下面几种:Boolean()Number()String()Array()Date()Function()RegExp()Error()Object()
extends 关键字不仅可以用于继承类,还可用于继承原生的构造函数。因为 ES6 是先新建父类的实例对象 this,然后再用子类的构造函数修饰 this,这使得父类的所有行为都可以继承。因此可以在原生数据结构的基础上定义自己的数据结构。而 ES5 是先新建子类的实例对象 this,再将父类的属性添加到子类上,由于父类的内部属性无法获取,导致无法继承原生的构造函数。

Class 的 getter 和 setter

与 ES5 一样,在 Class 内部可以使用 get 和 set 关键字对某个属性设置存值函数和取值函数,拦截该属性的存取行为。下面代码中,prop 属性有对应的存值函数和取值函数,因此赋值和读取行为都被定义了。存值函数和取值函数是设置在属性的 descriptor 对象上的。

1
2
3
4
class MyClass {
get prop() {}
set prop(value) {}
}

Class 的 Generator 方法

如果在某个方法前加上星号(*),就表示该方法是一个 Generator 函数。下面的代码中,Foo 类的 Symbol.iterator 方法前有一个星号,表示该方法是一个 Generator 函数。Symbol.iterator 方法返回一个 Foo 类的默认遍历器,for…of循环会自动调用这个遍历器。

1
2
3
4
5
6
7
8
9
10
11
class Foo {
constructor(...args) {
this.args = args;
}
* [Symbol.iterator] {
for(let arg of this.args) {
yield arg;
}
}
}
for(let x of new Foo('hello', 'world')) {}

Class 的静态方法

类相当于实例的原型,所有在类中定义的方法都会被实例继承。如果在一个方法前加上 static 关键字,就表示该方法不会被实例继承,而是直接通过类调用,称为“静态方法”。而父类的静态方法可以被子类继承。静态方法也可以从 super 对象上调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Foo {
static classMethod() {
return 'hello';
}
}
class Bar extends Foo {
static test() {
return `${super.classMethod()} too`;
}
}
Foo.classMethod(); // hello
Bar.classMethod(); // hello
Bar.test(); // hello too

Class 的静态属性

静态属性指的是 Class 本身的属性,即 Class.propName,而不是定义在实例对象(this)上的属性。

1
2
class Foo {}
Foo.prop = 1;

ES6 明确规定,Class 内部只有静态方法,没有静态属性。而 ES7 提供的静态属性的提案,babel 转码器已支持。

1
2
3
4
5
6
class MyClass {
static myProp = 42;
constructor() {
console.log(MyClass.myProp); // 42
}
}

new.target

new 是从构造函数生成实例的命令。ES6 为 new 命令引入了 new.target属性,(在构造函数中)返回 new 命令所作用的构造函数。如果构造函数不是通过 new 命令调用的,那么该属性的值为 undefined。因此该属性可用于确定构造函数是怎么调用的。Class 内部调用 new.target 返回当前 Class,而子类继承父类时 new.target 会返回子类。另外,在函数外部,使用 new.target 会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person(name) {
if (new.target === Person) { this.name = name; }
else {
throw new Error('必须使用new生成实例');
}
}
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
}
}
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
}
const obj = new Square(3); // log: false

修饰器

修饰器(Decorator)是一个表达式,用于修改类的行为。ES7 提案,目前 Babel 转码器已经支持。修饰器对类的行为的改变,是在代码编译时发生的,而不是在运行时。这意味着,修饰器能在编译阶段运行代码。基本上,修饰器的行为如下,修饰器本质上是能在编译时执行的函数。

1
2
3
4
5
@decorator
class A {}
// 等同于
class A {}
A = decorator(A) || A;

修饰器函数可以接受3个参数,依次是目标对象、属性名和该属性的描述对象。后两个参数可省略。如果希望修饰器的行为能根据目标对象的不同而不同,就要在外面再封装一层。下面的代码中,修饰器 testable 可以接受参数,这就等于可以修改修饰器的行为。

1
2
3
4
5
6
7
8
9
function testable(isTestable) {
return function(target) {
target.isTestable = isTestable;
}
}
@testable(true)
class MyTestableClass {}
@testable(false)
class MyClass {}

如果要为类的实例添加方法,可以在修饰器函数中为目标类的 prototype 属性添加方法。

1
2
3
function testable(target) {
target.prototype.isTestable = true;
}

方法的修饰

修饰器不仅可以修饰类,还可以修饰类的属性。下面中的修饰器(readonly)会修改属性的描述对象(descriptor),然后被修改的描述对象再用来定义属性。

1
2
3
4
5
6
7
8
9
10
function readonly(target, name, descriptor) {
descriptor.writable = false;
return descriptor;
}
class Person {
@readonly
name() {
return `${this.first} ${this.last}`
}
}

注:修饰器只能用于类和类的方法,不能用于函数,因为存在函数提升。

Module

ES6 的模块加载称为“编译时加载”,即 ES6 可以在编译时就完成模块编译,效率比 CommonJS 模块的加载方式高。模块的功能主要由两个命令构成:export 和 import。export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。一个模块就是一个独立的文件,该文件内部的所有变量,外部无法获取。如果希望外部能够读取模块内部的某个变量,就必须使用 export 关键字输出该变量。export 命令可以出现在模块的任何位置,只要处于模块的顶层即可。另外,export 语句输出的值是动态绑定的,绑定其所在的模块。

import 命令具有提升效果,会提升到整个模块的头部首先执行。import 语句会执行所加载的模块。如果一个模块中先输入后输出同一个模块,import 语句可以与 export 语句写在一起。

1
2
3
4
5
import 'lodash'; // 仅仅执行 lodash 模块,而不输入任何值。
export { es6 as default } from './someModule';
// 等同于
import { es6 } from './someModule';
export default es6;

模块的整体加载

除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上。

1
2
3
4
5
6
// circle.js
export function area(r) {}
export function circumference(r) {}
// main.js
import { area, circumference } from './circle'; // 逐一加载
import * as circle from './circle'; // 整体加载

module 命令可以取代 import 语句,达到整体输入模块的作用。module 命令后面跟一个变量,表示输入的模块定义在该变量上。module circle from './circle';

export default

export default 命令为模块指定默认输出。一个模块只能有一个默认输出。其他模块加载该模块时,就不需要知道原模块输出的名字,import 命令可以为其指定任意名字。此时,import 命令后面不使用大括号。

本质上,export default 就是输出一个叫作 default 的标量或方法,然后系统允许你为它取任意名字。

模块的继承

模块之间也可以继承。下文中的 export * 表示输出 circle 模块的所有属性和方法。注意,export * 命令会忽略 circle 模块的 default 方法。

1
2
3
export * from 'circle';
export var e = 2.718;
export default function foo(x) {}

模块加载的实质

ES6 模块加载的机制与 CommonJS 模块完全不同。CommonJS 模块输出的是一个值的拷贝,即一旦输出一个值,模块内部的变化就影响不到这个值。而ES6模块输出的是值的动态的只读引用,即值指向的地址是只读的。等到真的需要用到时,再到模块中取值,原始值变了,输入值也会跟着变,而且不会缓存值,模块里面的变量绑定其所在的模块。

循环加载

循环加载指的是 a 脚本的执行依赖 b 脚本,而 b 脚本的执行又依赖 a 脚本。

CommonJS 模块的加载原理

CommonJS 的一个模块就是一个脚本文件,require 命令第一次加载该脚本就会执行整个脚本,然后在内存中生成一个对象。下面的代码中,该对象的 id 属性是模块名,exports 属性是模块输出的各个接口,loaded 表示模块的脚本是否执行完毕。其他还有很多属性,这里省略了。以后需要用到这个模块时,就会到 exports 属性上取值,即使再次执行 require 命令,也不会再次执行该模块,而是到缓存中取值。

1
2
3
4
5
6
{
id: 'xxx',
exports: {},
loaded: true,
// ...
}

CommonJS 模块的重要特性是加载时执行,即脚本代码在 requre 时就会全部执行。CommonJS 的做法是,一旦出现某个模块被“循环加载”,就只输出已经执行的部分,还未执行的部分不会输出。

ES6 模块的循环加载

ES6 模块是动态引用,遇到模块加载命令 import 时不会去执行模块,只是生成一个指向被加载模块的引用,需要开发者自己保证真正取值时能够取到值。下面中的 a.js 能够执行,而改成 CommonJS 加载原理不能够执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// a.js
import { bar } from './b.js';
export function foo() {
bar();
console.log('执行完毕');
}
foo();
//b.js
import { foo } from './a.js';
export function bar() {
if (Math.random() > 0.5) {
foo();
}
}

最后的最后

ES6 这块东东至此算是结束了,自己也是再次回忆了一次,比第一次看时又收货了不少。其实,想总结这个系列,已经计划了大概有一年时间了吧,因为懒癌,也是拖到现在,ES8都已经发出来了,真是惭愧。。。(ES7/8 只能继续往后推,抽时间写出来了)