ES6学习笔记 • 下
前言
本读书笔记节是针对阮一峰编写的 《ECMAScript 6 入门教程》的个人解读。适合已经掌握
ES5
的读者,用来了解这门语言的最新发展。笔记内容仅包括日常开发中可能会用到的概念,剩余部分请自行阅读。
20. async 函数
20.1 含义
ES6
引入的 async
函数,使得异步操作变得更加方便。简单来说,async
函数就是 Generator
函数的语法糖。它将 Generator
函数的星号*
替换成async
,yield
替换成await
。
const fs = require("fs"); |
上面代码中,函数gen
依次读取两个文件,如果写成async
函数,就是下面这样。
const asyncReadFile = async function () { |
async
函数对 Generator
函数的改进,体现在以下四点:
- 内置执行器:
Generator
函数的执行必须靠执行器(co
模块),而async
函数自带执行器。也就是说,async
函数的执行,与普通函数一模一样。 - 更好的语义:
async
和await
,比起星号和yield
,语义更清楚了。async
表示函数里有异步操作,await
表示紧跟在后面的表达式需要等待结果。 - 更广的适用性:
co
模块约定,yield
命令后面只能是Thunk
函数或Promise
对象,而async
函数的await
命令后面,可以是Promise
对象和原始类型的值。(数值、字符串和布尔值,但会自动转成立即resolved
的Promise
对象) - 返回值是 Promise:
async
函数的返回值是Promise
对象,这比Generator
函数的返回值是Iterator
对象方便,可以用then
方法指定下一步的操作。
进一步说,async
函数可以看作多个异步操作,包装成的一个 Promise
对象,而await
命令就是可以替代then
的语法糖。下面的 Promise
链可以被重写为一个 async
函数。
fetch("https://api.example.com/data") |
20.2 基本用法
async
函数返回一个 Promise
对象,可以使用then
方法添加回调函数。当函数执行的时候,遇到await
就会暂停当前的async
函数执行,等到异步操作完成,再接着执行函数体内后面的语句。
async function fetchUserData(userId) { |
上面代码中,fetchUserData
是一个异步函数,它使用fetchAPI
从服务器获取数据,然后使用await
关键字等待Promise
解决。注意,在异步函数中,我们应该始终使用try/catch
来捕获可能出现的错误。
async
函数有多种使用形式。它可以作为声明,作为表达式,还可以用来定义对象或类的方法。
// 函数声明 |
20.3 语法
async
函数的语法规则总体上比较简单,难点是错误处理机制。
(1)返回 Promise 对象
async
函数返回一个 Promise
对象(不管有没有return
语句,总是返回一个 Promise
对象)。async
函数内部return
语句返回的值,会成为then
方法回调函数的参数。
async function f() { |
上面代码中,函数f
内部return
命令返回的值,会被then
方法回调函数接收到。
async
函数内部抛出错误,会导致返回的 Promise
对象变为reject
状态。抛出的错误对象会被catch
方法回调函数接收到。
async function f() { |
(2)Promise 对象的状态变化
async
函数返回的 Promise
对象,必须等到内部所有await
命令后面的 Promise
对象执行完,才会发生状态改变,除非遇到return
语句或者抛出错误。也就是说,只有async
函数内部的异步操作执行完,才会执行then
方法指定的回调函数。
async function asyncFunc() { |
上面代码中,then
方法的回调函数会等到asyncFunc
函数内部的两个await
操作都完成后才会被调用。
(3)await 命令
正常情况下,await
命令后面是一个 Promise
对象,返回该对象的结果。如果不是 Promise
对象,就直接返回对应的值。
async function f() { |
另一种情况是,await
命令后面是一个thenable
对象(即定义了then
方法的对象,详细请看Promise
章节的resolve()
方法),那么await
会将其等同于 Promise
对象。
let thenable = { |
上面代码中,thenable
对象有一个then
方法,所以可以在asyncFunc
函数中使用await
来等待它。当JS
引擎看到我们试图等待一个thenable
对象时,它会自动调用该对象的then
方法,并等待该方法调用其resolve
参数所传入的值。
如果await
命令后面的 Promise
对象如果变为reject
状态,则reject
的参数会被catch
方法的回调函数接收到。
async function f() { |
注意,上面代码中await
语句前面没有return
,但是reject
方法的参数依然传入了catch
方法的回调函数。这里如果在await
前面加上return
,效果是一样的。
任何一个await
语句后面的 Promise
对象变为reject
状态,不会导致整个 async
函数立即停止执行,而是会抛出异常。我们可以用 try/catch
语句来捕获这个异常并处理它。(如果没有错误处理机制,async
函数会中断执行)
// 情况 1 |
有时我们希望即使前一个异步操作失败,也不要中断后面的异步操作。我们可以将await
放在try...catch
结构里面,这样不管这个异步操作是否成功,下一个await
都会执行。
async function f() { |
另一种方法是await
后面的 Promise
对象再跟一个catch
方法,处理前面可能出现的错误。
async function f() { |
这种方法的优点是它更加紧凑,不需要显式地使用try/catch
。但它的缺点是需要确保每个可能产生错误的await
都有一个catch
方法,否则错误可能会被忽视。
(4)错误处理
如果await
后面的异步操作出错,那么等同于async
函数返回的 Promise
对象被reject
。
async function asyncFunc() { |
上面代码中,如果fetch
函数抛出一个错误,那么asyncFunc
会立即停止执行并抛出一个错误,导致它返回的Promise
变为rejected
状态。这个错误然后被catch
捕获并处理。
防止出错的方法,就是将其放在try...catch
代码块之中。
async function f() { |
如果有多个await
命令,可以统一放在try...catch
结构中。
async function main() { |
注意,上面三个异步操作是串行处理,即secondStep
将在firstStep
完成后开始。如果这三个操作之间没有依赖关系,可以使用Promise.all()
并行执行以提高效率。
(5)使用注意点
第一点,await
命令后面的Promise
对象,运行结果可能是rejected
,所以最好把await
命令放在try...catch
代码块中。
async function myFunction() { |
第二点,多个await
命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。
let foo = await getFoo(); |
上面代码中,getFoo
和getBar
是两个独立的异步操作(即互不依赖),被写成继发关系。这样比较耗时,因为只有getFoo
完成以后,才会执行getBar
,完全可以让它们同时触发。
// 写法一 |
上面两种写法,getFoo
和getBar
都是同时触发,这样就会缩短程序的执行时间。
第三点,await
命令只能用在async
函数之中,如果用在普通函数,就会报错。
async function dbFuc(db) { |
上面代码会报错,因为await
用在普通函数之中。但是,就算将forEach
方法的参数改成async
函数也有问题。
function dbFuc(db) { |
上面代码可能不会正常工作,原因是这时三个db.post()
操作将是并发执行(forEach
不会等待回调函数),也就是同时执行,而不是继发执行。正确的写法是采用for
循环。
async function dbFuc(db) { |
另一种方法是使用数组的reduce()
方法来逐个顺序处理数组中的元素。
async function dbFunc(db) { |
上面代码中,传递给reduce()
的函数返回一个Promise
,这个Promise
在前一个 Promise
解析之后开始另一项数据库操作。我们使用Promise.resolve()
作为初始值,以开始 Promise
链。
如果希望多个请求并发执行,可以使用Promise.all()
方法。下面两种写法效果相同。
async function dbFuc(db) { |
第四点,async
函数可以保留运行堆栈。它会在等待Promise
解析期间暂停函数的执行,而不是在全局范围内阻止执行,这使得调试工具能够在可能发生错误的地方停下来。
const a = () => { |
上面代码中,假定函数a
内部运行了一个异步任务b()
。当b()
运行的时候,函数a()
不会中断,而是继续执行。等到b()
运行结束,可能a()
早就运行结束了,b()
所在的上下文环境已经消失了。如果b()
或c()
报错,错误堆栈将不包括a()
。
现在将这个例子改成async
函数。
const a = async () => { |
上面代码中,b()
运行的时候,a()
是暂停执行,上下文环境都保存着。一旦b()
或c()
报错,错误堆栈将包括a()
。
20.4 async 函数实现原理
async
函数的实现原理,就是将 Generator
函数和自动执行器,包装在一个函数里。
async function fn(args) { |
所有的async
函数都可以写成上面的第二种形式,其中的spawn
函数就是自动执行器。
function spawn(genF) { |
20.5 异步处理方法的比较
通过一个例子,我们来看看 async
函数与 Promise
、Generator
函数的比较。
- 假定某个
DOM
元素上面,部署了一系列的动画,前一个动画结束,才能开始后一个。如果当中有一个动画出错,就不再往下执行,返回上一个成功执行的动画的返回值。
首先是 Promise
的写法。
function chainAnimationsPromise(elem, animations) { |
虽然 Promise
的写法比回调函数的写法大大改进,但是一眼看上去,代码完全都是 Promise
的 API
,操作本身的语义反而不容易看出来。
接着是 Generator
函数的写法。
function chainAnimationsGenerator(elem, animations) { |
上面代码使用 Generator
函数遍历了每个动画,语义比 Promise
写法更清晰,用户定义的操作全部都写在spawn
函数的内部。但是,这个写法必须提供一个任务运行器,自动执行 Generator
函数,而且必须保证yield
语句后面的表达式返回一个 Promise
。
最后是 async
函数的写法。
async function chainAnimationsAsync(elem, animations) { |
可以看到 Async
函数的实现最简洁,最符合语义。它将 Generator
写法中的自动执行器,改在语言层面提供,不暴露给用户,因此代码量最少。
20.6 按顺序完成异步操作
实际开发中,会遇到一组异步操作需要按照顺序完成的情况。比如,依次远程读取一组 URL
,然后按照读取的顺序输出结果。
Promise
的写法如下。
function logInOrder(urls) { |
上面代码使用fetch
方法,同时远程读取一组 URL
。每个fetch
操作都返回一个 Promise
对象,放入textPromises
数组。然后,reduce
方法依次处理每个 Promise
对象,然后使用then
,将所有 Promise
对象连起来,因此就可以依次输出结果。
这种写法可读性比较差。下面是 async
函数实现。
async function logInOrder(urls) { |
上面代码确实大大简化,问题是所有远程操作都是继发。只有前一个 URL
返回结果,才会去读取下一个 URL
,这样做效率很差,我们需要的是并发发出远程请求。
async function logInOrder(urls) { |
上面代码中,urls.map
方法会对 urls
数组的每个元素执行一个异步函数,这个异步函数会远程读取URL
,然后等待响应。所有的这些操作都在 map
方法调用的同时开始,所以这些操作是并发的。
注意,在这个异步函数中,await fetch(url)
会暂停函数的执行,直到 fetch
操作完成。但是,这并不会阻止其他操作,因为每个元素都在自己的异步函数中处理。因此,textPromises
是一个包含Promise
的数组,每个Promise
都代表一个正在进行的异步操作。
20.7 异步操作继发与并发
继发执行异步操作意味着等待一个操作完成后再执行下一个操作。
(1)使用 Promise 链
let promise = Promise.resolve(); |
上面代码中,func1
, func2
和 func3
是返回Promise
的异步函数。这个代码段创建了一个Promise
链,依次调用每个函数,并在所有函数都完成后输出一条消息。
(2)使用 Promise 链 II
doAsyncOperation1() |
上面代码中,每一个 then
里面返回一个新的 Promise
,这样只有前一个 Promise
状态变为 resolved
,后一个 Promise
才会开始执行。
(3)使用 async/await
async function runInSequence() { |
上面代码中,func1
, func2
和 func3
也是返回Promise
的异步函数。runInSequence
函数使用await
关键字依次等待每个函数完成,然后输出一条消息。
(4)使用 for…of 循环
async function runInSequence(functions) { |
这个例子和前一个例子类似,但是runInSequence
函数接受一个函数数组,并使用for...of
循环来依次调用它们。
并发执行异步操作是指同时开始多个异步操作,并在它们都完成后进行下一步。
(1)使用 Promise.all()
let promise1 = doAsyncOperation1(); |
上面代码中,三个promise
会同时开始,并行执行。只有当这三个操作都成功完成,Promise.all()
才会解决。如果任何一个操作失败,那么 Promise.all()
也会立即被 reject
,不再等待其他操作。
(2)使用 Promise.allSettled()
let promise1 = doAsyncOperation1(); |
Promise.allSettled()
的行为与 Promise.all()
类似,但是它会等待所有操作完成,无论成功还是失败。这对于希望了解所有操作的结果,而不仅仅是第一个失败的操作很有用。
(3)使用 async/await 结合 for..of
let urls = ["url1", "url2", "url3"]; |
这种方法可以并发启动多个异步操作,但等待结果的顺序依然是串行的。也就是说,这种方法不会阻止异步操作的启动,但会按顺序等待每个异步操作的结果。
20.8 顶层 await
早期的语法规定,await
命令只能出现在 async
函数内部,否则报错。
// 报错 |
上面代码中,await
命令独立使用,没有放在 async
函数里面,就会报错。
从 ES6 开始,允许在模块的顶层独立使用await
命令,使得上面那行代码不会报错了。它的主要目的是使用await
解决模块异步加载的问题。
// awaiting.js |
上面代码中,模块awaiting.js
的输出值output
,取决于异步操作。我们把异步操作包装在一个 async
函数里面,然后调用这个函数,只有等里面的异步操作都执行,变量output
才会有值,否则就返回undefined
。
下面是加载这个模块的写法。
// usage.js |
上面代码中,outputPlusValue()
的执行结果,完全取决于执行的时间。如果awaiting.js
里面的异步操作没执行完,加载进来的output
的值就是undefined
。
目前的解决方法,就是让原始模块输出一个 Promise
对象,从这个 Promise
对象判断异步操作有没有结束(检查当前状态是否为resolved
,即使用then
的回调函数)。
// awaiting.js |
上面代码中,awaiting.js
除了输出output
,还默认输出一个 Promise
对象(async
函数立即执行后,返回一个 Promise
对象),从这个对象判断异步操作是否结束。
// usage.js |
上面代码中,将awaiting.js
对象的输出,放在promise.then()
里面,这样就能保证异步操作完成以后,才去读取output
。
这种写法比较麻烦,等于要求模块的使用者遵守一个额外的使用协议,按照特殊的方法使用这个模块。一旦忘记使用 Promise
加载,这个模块的代码就可能出错。
顶层的await
命令,就是为了解决这个问题。它保证只有异步操作完成,模块才会输出值。
// awaiting.js |
上面代码中,两个异步操作在输出的时候,都加上了await
命令。只有等到异步操作完成,这个模块才会输出值。加载这个模块的写法如下。
// usage.js |
上面代码的写法,与普通的模块加载完全一样。也就是说,模块的使用者完全不用关心,依赖模块的内部有没有异步操作,正常加载即可。
这时,模块的加载会等待依赖模块的异步操作完成,才执行后面的代码。所以,它总是会得到正确的output
,不会因为加载时机的不同,而得到不一样的值。
注意,顶层await
只能用在 ES6
模块。
21. Class 的基本语法
21.1 类的由来
在JS
中,生成实例对象的传统方法是通过构造函数。下面是一个例子。
function Point(x, y) { |
上面这种写法跟传统的面向对象语言(比如 C++
和 Java
)差异很大,很容易感到困惑。
ES6
引入了 Class
(类)这个概念。通过class
关键字,可以定义类。
class Point { |
上面代码定义了一个“类”,可以看到里面有一个constructor()
方法,这就是构造方法,而this
关键字则代表实例对象。新的class
写法让对象原型的写法更加清晰、更像面向对象编程。
Point
类除了构造方法,还定义了一个toString()
方法。注意,定义toString()
方法的时候,前面不需要加上function
这个关键字,直接把函数定义放进去了就可以了。
使用的时候,也是直接对类使用new
命令,跟构造函数的用法完全一致。
class Point { |
事实上,类的所有方法都定义在类的prototype
属性上面(也称为原型对象)。因此,在类的实例上面调用方法,其实就是调用原型上的方法。
function Car(make, model, year) { |
由于类的方法都定义在prototype
对象上面,所以类的新方法可以添加在prototype
对象上面。Object.assign()
方法可以很方便地一次向类添加多个方法。(class
直接添加就行)
function Car(make, model, year) { |
在JS
中,每一个函数(包括类,因为在JS
中类也是函数)都有一个prototype
属性,它是一个对象,这个对象有一个constructor
属性,默认指向函数本身。也就是说,prototype
对象的 constructor
属性直接指向“类”的本身。
class Car { |
21.2 constructor 方法
constructor()
方法是类的默认方法,通过new
命令生成对象实例时,自动调用该方法。一个类必须有constructor()
方法,如果没有显式定义,一个空的constructor()
方法会被默认添加。
class Point {} |
如果想在创建对象时进行一些初始化操作,比如设置对象的属性,那么需要显式定义你自己的 constructor()
方法。
class Car { |
constructor()
方法默认返回实例对象(即this
),但完全可以指定返回另外一个对象。
class Car { |
上面代码中,constructor()
方法返回了一个完全不同的对象。因此,当我们打印car.make
时,输出的是'Ford'
而不是'Eagle'
。
21.3 类的实例
生成类的实例的写法,与ES5
完全一样,也是使用new
命令。
class Point { |
类的属性和方法,除非显式定义在其本身(即定义在this
对象上,通过实例访问),否则都是定义在原型上(即定义在class
上,静态属性或者静态方法)。
class Car { |
上面代码中,make
、model
和year
是实例属性,start
是原型方法,isCar
是静态方法,manu
是静态属性。可以看到,make
和start
可以通过实例car
访问,而isCar
和manu
只能通过类名Car
访问。
与ES5
一样,类的所有实例共享一个原型对象。
let p1 = new Point(2, 3); |
上面代码中,p1
和p2
都是Point
的实例,它们的原型都是Point.prototype
,所以__proto__
属性是相等的。这也意味着,可以通过实例的__proto__
属性为“类”添加方法。
然而,使用 __proto__
通常是不推荐的,因为这并不是语言的一个标准特性,而且在不同的 JS
环境中可能表现不同。更好的方式是直接使用 Object.getPrototypeOf(obj)
函数获取一个对象的原型,或者使用 Object.setPrototypeOf(obj, prototype)
函数设置一个对象的原型。
let p1 = new Point(2, 3); |
上面代码在p1
的原型上添加了一个printName()
方法,由于p1
的原型就是p2
的原型,因此p2
也可以调用这个方法。而且,此后新建的实例p3
也可以调用这个方法。这意味着,使用实例的__proto__
属性改写原型,必须相当谨慎,不推荐使用,因为这会改变“类”的原始定义,影响到所有实例。
21.4 实例属性的新写法
ES6 为类的实例属性,又规定了一种新写法。实例属性现在除了可以定义在constructor()
方法里面的this
上面,也可以定义在类内部的最顶层。
// 原来的写法 |
上面示例中,实例属性_count
定义在constructor()
方法里面的this
上面。现在的新写法是,这个属性也可以定义在类的最顶层,其他都不变。
class IncreasingCounter { |
上面代码中,实例属性_count
与取值函数value()
和increment()
方法,处于同一个层级。这时,不需要在实例属性前面加上this
。
注意,新写法定义的属性是实例对象自身的属性,而不是定义在实例对象的原型prototype
上面。因为如果原型变了,比如这里的_count
我们做计数处理,那么所有实例的_count
属性都会改变,这就会出现与我们预期不同的结果。
这种新写法的好处是,所有实例对象自身的属性都定义在类的头部,看上去比较整齐,一眼就能看出这个类有哪些实例属性。
class foo { |
上面的代码,foo
类有两个实例属性,一目了然。另外,写起来也比较简洁。
21.5 取值函数 getter 和存值函数 setter
与ES5
一样,在“类”的内部可以使用get
和set
关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。
class MyClass { |
上面代码中,myProperty
属性有对应的存值函数和取值函数,因此赋值和读取行为都被自定义了。注意,存值函数和取值函数是设置在属性的 Descriptor
对象上的。
21.6 Class 表达式
与函数一样,类也可以使用表达式的形式定义。
// 命名类表达式 |
上面代码使用表达式定义了一个类。需要注意的是,这个类的名字是MyClass
,但是MyClass
只在 Class
的内部可用,指代当前类。在 Class
外部,这个类只能用User
引用。
如果类的内部没用到的话,可以省略MyClass
,也就是可以写成下面的形式。
// 匿名类表达式 |
采用 Class
表达式,可以写出立即执行的 Class
。
let user = new (class { |
21.7 静态方法
类相当于实例的原型,所有在类中定义的方法都会被实例继承。如果在一个方法前,加上static
关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。
class Foo { |
上面代码中,Foo
类的classMethod
方法前有static
关键字,表明该方法是一个静态方法,可以直接在Foo
类上调用,而不是在Foo
类的实例上调用。如果在实例上调用静态方法,会抛出一个错误,表示不存在该方法。
注意,如果静态方法包含this
关键字,这个this
指的是类,而不是实例。
class MyClass { |
上面代码中,myStaticMethod
是MyClass
的静态方法,this
指向的是类MyClass
。myInstanceMethod
是实例方法,this
指向的是类的实例。另外,从这个例子还可以看出,静态方法可以与非静态方法重名。
父类的静态方法,可以被子类继承。同时,静态方法也是可以从super
对象上调用的。
class ParentClass { |
21.8 私有方法和私有属性
ES6为class
添加了私有属性,方法是在属性名之前使用#
表示。
class IncreasingCounter { |
上面代码中,#count
就是私有属性,只能在类的内部使用(this.#count
)。如果在类的外部使用,就会报错。
另外,不管在类的内部或外部,读取一个不存在的私有属性,都会报错。这跟公开属性的行为完全不同,如果读取一个不存在的公开属性,不会报错,只会返回undefined
。
class IncreasingCounter { |
上面示例中,#myCount
是一个不存在的私有属性,不管在函数内部或外部,读取该属性都会导致报错。注意,私有属性的属性名必须包括#
,如果不带#
,会被当作另一个属性。
ES6
中不仅可以写私有属性,还可以用来写私有方法。
class Foo { |
另外,私有属性也可以设置 getter
和 setter
方法。
class Counter { |
上面代码中,#x
是一个私有属性,它的读写都通过get #x()
和set #x()
操作另一个私有属性#xValue
来完成。
私有属性不限于从this
引用,只要是在类的内部,实例也可以引用私有属性。
class MyClass { |
私有属性和私有方法也可以加上static
关键字,表示这是一个静态的私有属性或私有方法。
class MyClass { |
21.9 in 运算符
前面说过,直接访问某个类不存在的私有属性会报错,但是访问不存在的公开属性不会报错。这个特性可以用来判断,某个对象是否为类的实例。
class C { |
上面示例中,类C
的静态方法isC()
就用来判断,某个对象是否为C
的实例。它采用的方法就是,访问该对象的私有属性#brand
。如果不报错,就会返回true
;如果报错,就说明该对象不是当前类的实例,从而catch
部分返回false
。
因此,try...catch
结构可以用来判断某个私有属性是否存在。但是,这样的写法很麻烦,代码可读性很差,ES6 改进了in
运算符,使它也可以用来判断私有属性。它不会报错,而是返回一个布尔值。
class C { |
in
也可以跟this
一起配合使用,用来检查一个对象是否拥有某个属性的,无论这个属性是在实例本身还是在它的原型链中。注意,判断私有属性时,in
只能用在类的内部。
class A { |
21.10 静态块
静态属性的一个问题是,如果它有初始化逻辑,这个逻辑要么写在类的外部,要么写在constructor()
方法里面。
class MyClass { |
为了解决这个问题,ES2022
引入了静态块(static block
),允许在类的内部设置一个代码块,在类生成时运行且只运行一次,主要作用是对静态属性进行初始化。以后,新建类的实例时,这个块就不运行了。
class Foo { |
每个类允许有多个静态块,每个静态块中只能访问之前声明的静态属性。同时,静态块的内部不能有return
语句,但是可以使用类名或this
,指代当前类。
class MyClass { |
除了静态属性的初始化,静态块还有一个作用,就是将私有属性与类的外部代码分享。
class MyClass { |
这个特性提供了一种灵活的方式来控制类的私有属性的访问,可以根据需要决定哪些私有属性应该对外部代码开放,以及在何种条件下开放。
21.11 类的注意点
(1)Generator 方法
如果某个方法之前加上星号*
,就表示该方法是一个 Generator
函数。
lass MyClass { |
(2)this 的指向
类的方法内部如果含有this
,它默认指向类的实例。但是,如果将类的方法提取出来单独使用,那么this
可能就不再指向原来的对象了。
class MyClass { |
在上述代码中,myMethod
方法中的this
默认指向MyClass
的实例。但当我们将myMethod
方法赋值给变量method
,并单独调用method()
时,this
就变成了undefined
。
解决方法是,在构造方法中绑定this
,或者使用箭头函数。
class MyClass { |
22. Class 的继承
本章内容复杂,故先跳过一部分。
22.1 简介
Class
可以通过extends
关键字实现继承,让子类继承父类的属性和方法。extends
的写法比 ES5
的原型链继承,要清晰和方便很多。
class Point {} |
上面示例中,Point
是父类,ColorPoint
是子类,它通过extends
关键字,继承了Point
类的所有属性和方法。下面,我们在ColorPoint
内部加上代码。
class Point { |
上面示例中,constructor()
方法和toString()
方法内部,都出现了super
关键字。super
在这里表示父类的构造函数,用来新建一个父类的实例对象。
ES6
规定,子类必须在constructor()
方法中调用super()
,否则就会报错。这是因为子类自己的this
对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再添加子类自己的实例属性和方法。如果不调用super()
方法,子类就得不到自己的this
对象。
class Point { |
为什么子类的构造函数,一定要调用super()
?原因就在于 ES6
的继承机制,与 ES5
完全不同。
ES5
的继承机制,是先创造一个独立的子类的实例对象,然后再将父类的方法添加到这个对象上面,即“实例在前,继承在后”。ES6
的继承机制,则是先将父类的属性和方法,加到一个空的对象上面,然后再将该对象作为子类的实例,即“继承在前,实例在后”。
这就是为什么 ES6
的继承必须先调用super()
方法,因为这一步会生成一个继承父类的this
对象,没有这一步就无法继承父类。
注意,这意味着新建子类实例时,父类的构造函数必定会先运行一次。
class Foo { |
上面示例中,子类 Bar
新建实例时,会输出1
和2
。原因就是子类构造函数调用super()
时,会执行一次父类构造函数。
另一个需要注意的地方是,在子类的构造函数中,只有调用super()
之后,才可以使用this
关键字,否则会报错。这是因为子类实例的构建,必须先完成父类的继承,只有super()
方法才能让子类实例继承父类。
class Parent { |
上面代码中,子类的constructor()
方法没有调用super()
之前,就使用this
关键字,结果报错,而放在super()
之后就是正确的。
如果子类没有定义constructor()
方法,这个方法会默认添加,并且里面会调用super()
。也就是说,不管有没有显式定义,任何一个子类都有constructor()
方法。
class Parent { |
有了子类的定义,就可以生成子类的实例了。
child instanceof Child; // true |
注意,如果父类和子类有同名属性,子类的属性会覆盖父类的属性。这是因为在子类中定义的属性和方法具有更高的优先级。
class Parent { |
22.2 私有属性和私有方法的继承
父类所有的属性和方法,都会被子类继承,除了私有的属性和方法。子类无法继承父类的私有属性,或者说,私有属性只能在定义它的 class
里面使用。
class Foo { |
如果父类定义了私有属性的读写方法,子类就可以通过这些方法,读写私有属性。
class Foo { |
上面示例中,getP()
是父类用来读取私有属性的方法,子类可以用这个方法读到父类的私有属性。
22.3 静态属性和静态方法的继承
父类的静态属性和静态方法,也会被子类继承。
class A { |
上面代码中,hello()
是A
类的静态方法,B
继承A
,也继承了A
的静态方法。
注意,静态属性是通过软拷贝实现继承的。
class A { |
上面示例中,foo
是 A
类的静态属性,B
类继承了 A
类,因此也继承了这个属性。但是,在 B
类内部操作B.foo
这个静态属性,影响不到A.foo
,原因就是 B
类继承静态属性时,会采用浅拷贝,拷贝父类静态属性的值,因此A.foo
和B.foo
是两个彼此独立的属性。
但是,由于这种拷贝是浅拷贝,如果父类的静态属性的值是一个对象,那么子类的静态属性也会指向这个对象,因为浅拷贝只会拷贝对象的内存地址。
class A { |
上面示例中,A.foo
的值是一个对象,浅拷贝导致B.foo
和A.foo
指向同一个对象。所以,子类B
修改这个对象的属性值,会影响到父类A
。
22.4 super 关键字
super
这个关键字,既可以当作函数使用,也可以当作对象使用。
(1)当super
作为函数调用时,代表父类的构造函数。
class A {} |
调用super()
的作用是形成子类的this
对象,把父类的实例属性和方法放到这个this
对象上面。子类在调用super()
之前,是没有this
对象的,任何对this
的操作都要放在super()
的后面。
注意,这里的super
虽然代表了父类的构造函数,但是因为返回的是子类的this
(即子类的实例对象),所以super
内部的this
代表子类的实例,而不是父类的实例。
class A { |
上面示例中,new.target
指向当前正在执行的函数。可以看到,在super()
执行时,它指向的是子类B
的构造函数,而不是父类A
的构造函数。也就是说,super()
内部的this
指向的是B
。
不过,由于super()
在子类构造方法中执行时,子类的属性和方法还没有绑定到this
,所以如果存在同名属性,此时拿到的是父类的属性。
class A { |
上面示例中,最后一行输出的是A
,而不是B
,原因就在于super()
执行时,B
的name
属性还没有绑定到this
,所以this.name
拿到的是A
类的name
属性。
作为函数时,super()
只能用在子类的构造函数之中,用在其他地方就会报错。
class A {} |
(2)当super
作为对象时,在普通方法中指向父类的原型对象,在静态方法中指向父类。
class Parent { |
如果super
指向父类的原型对象,那么定义在父类实例上的方法或属性(即定义在constructor
里),是无法通过super
调用的。这时候想在子类中访问到父类实例上的属性,则需要在父类中定义一个方法来返回这个属性,然后在子类中调用这个方法。
class Parent { |
在子类普通方法中通过super
调用父类的方法时,方法内部的this
指向当前的子类实例。所以通过super
对某个属性赋值,super
就是this
,赋值的属性会变成子类实例的属性。
class A { |
上面代码中,super.print()
虽然调用的是A.prototype.print()
,但是A.prototype.print()
内部的this
指向子类B
的实例,导致输出的是2
,而不是1
。
class A { |
上面代码中,super.x
赋值为3
,这时等同于对this.x
赋值为3
。而当读取super.x
的时候,读的是A.prototype.x
,所以返回undefined
。
如果super
作为对象,用在静态方法之中,这时super
将指向父类,而不是父类的原型对象。
class Parent { |
上面代码中,super
在静态方法之中指向父类,在普通方法之中指向父类的原型对象。
另外,在子类的静态方法中通过super
调用父类的方法时,方法内部的this
指向当前的子类,而不是子类的实例。
class A { |
上面代码中,静态方法B.m
里面,super.print
指向父类的静态方法。这个方法里面的this
指向的是B
,而不是B
的实例。
22.5 类的 prototype 属性和 __proto__ 属性
大多数浏览器的 ES5
实现之中,每一个对象都有__proto__
属性,指向对应的构造函数的prototype
属性。Class
作为构造函数的语法糖,同时有prototype
属性和__proto__
属性,因此同时存在两条继承链。
- 子类的
__proto__
属性,表示构造函数的继承,总是指向父类。 - 子类
prototype
属性的__proto__
属性,表示方法的继承,总是指向父类的prototype
属性。
class A {} |
上面代码中,子类B
的__proto__
属性指向父类A
,子类B
的prototype
属性的__proto__
属性指向父类A
的prototype
属性。
这两条继承链,可以这样理解:
- 作为一个对象,子类(
B
)的原型(__proto__
属性)是父类(A
)。 - 作为一个构造函数,子类(
B
)的原型对象(prototype
属性)是父类的原型对象(prototype
属性)的实例。
B.prototype = Object.create(A.prototype); |
22.6 原生构造函数的继承
ES6
允许继承原生构造函数定义子类,因为 ES6
是先新建父类的实例对象this
,然后再用子类的构造函数修饰this
,使得父类的所有行为都可以继承。下面是一个继承Array
的例子。
class MyArray extends Array { |
上面代码定义了一个MyArray
类,继承了Array
构造函数,因此就可以从MyArray
生成数组的实例。这意味着,ES6
可以自定义原生数据结构(比如Array
、String
等)的子类,这是 ES5
无法做到的。
上面这个例子也说明,extends
关键字不仅可以用来继承类,还可以用来继承原生的构造函数。因此可以在原生数据结构的基础上,定义自己的数据结构。下面就是定义了一个带版本功能的数组。
class VersionedArray extends Array { |
上面代码中,VersionedArray
会通过commit
方法,将自己的当前状态生成一个版本快照,存入history
属性。revert
方法用来将数组重置为最新一次保存的版本。除此之外,VersionedArray
依然是一个普通数组,所有原生的数组方法都可以在它上面调用。
注意,继承Object
的子类,有一个行为差异。
class NewObj extends Object { |
上面代码中,NewObj
继承了Object
,但是无法通过super
方法向父类Object
传参。这是因为 ES6
改变了Object
构造函数的行为,一旦发现Object
方法不是通过new Object()
这种形式调用,ES6
规定Object
构造函数会忽略参数。