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 链
下面代码中,func1, func2 和 func3 是返回Promise的异步函数。这个代码段创建了一个Promise链,依次调用每个函数,并在所有函数都完成后输出一条消息。
let promise = Promise.resolve(); |
(2)使用 Promise 链 II
下面代码中,每一个 then 里面返回一个新的 Promise,这样只有前一个 Promise 状态变为 resolved,后一个 Promise 才会开始执行。
doAsyncOperation1() |
(3)使用 async/await
下面代码中,func1, func2 和 func3也是返回Promise的异步函数。runInSequence函数使用await关键字依次等待每个函数完成,然后输出一条消息。
async function runInSequence() { |
(4)使用 for…of 循环
这个例子和前一个例子类似,但是runInSequence函数接受一个函数数组,并使用for...of循环来依次调用它们。
async function runInSequence(functions) { |
并发执行异步操作是指同时开始多个异步操作,并在它们都完成后进行下一步。
(1)使用 Promise.all()
下面代码中,三个promise会同时开始,并行执行。只有当这三个操作都成功完成,Promise.all() 才会解决。如果任何一个操作失败,那么 Promise.all() 也会立即被 reject,不再等待其他操作。
let promise1 = doAsyncOperation1(); |
(2)使用 Promise.allSettled()Promise.allSettled() 的行为与 Promise.all() 类似,但是它会等待所有操作完成,无论成功还是失败。这对于希望了解所有操作的结果,而不仅仅是第一个失败的操作很有用。
let promise1 = doAsyncOperation1(); |
(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构造函数会忽略参数。






