前言

本读书笔记节是针对阮一峰编写的 《ECMAScript 6 入门教程》的个人解读。适合已经掌握 ES5 的读者,用来了解这门语言的最新发展。笔记内容仅包括日常开发中可能会用到的概念,剩余部分请自行阅读。


20. async 函数

20.1 含义

ES6引入的 async 函数,使得异步操作变得更加方便。简单来说,async 函数就是 Generator 函数的语法糖。它将 Generator函数的星号*替换成asyncyield替换成await

const fs = require("fs");

const readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName, function (error, data) {
if (error) return reject(error);
resolve(data);
});
});
};

const gen = function* () {
const f1 = yield readFile("/etc/fstab");
const f2 = yield readFile("/etc/shells");
console.log(f1.toString());
console.log(f2.toString());
};

上面代码中,函数gen依次读取两个文件,如果写成async函数,就是下面这样。

const asyncReadFile = async function () {
const f1 = await readFile("/etc/fstab");
const f2 = await readFile("/etc/shells");
console.log(f1.toString());
console.log(f2.toString());
};

async函数对 Generator 函数的改进,体现在以下四点:

  1. 内置执行器: Generator 函数的执行必须靠执行器(co模块),而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样。
  2. 更好的语义: asyncawait,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
  3. 更广的适用性: co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值。(数值、字符串和布尔值,但会自动转成立即 resolvedPromise 对象)
  4. 返回值是 Promise: async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便,可以用then方法指定下一步的操作。

进一步说,async函数可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是可以替代then的语法糖。下面的 Promise 链可以被重写为一个 async 函数。

fetch("https://api.example.com/data")
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error("Error:", error));

// 等同于

async function fetchData() {
try {
let response = await fetch("https://api.example.com/data");
let data = await response.json();
console.log(data);
} catch (error) {
console.error("Error:", error);
}
}

20.2 基本用法

async函数返回一个 Promise 对象,可以使用then方法添加回调函数。当函数执行的时候,遇到await就会暂停当前的async函数执行,等到异步操作完成,再接着执行函数体内后面的语句

async function fetchUserData(userId) {
try {
// 这里假设我们有一个返回Promise的fetch函数
const response = await fetch(`https://api.example.com/user/${userId}`);

// 需要检查响应是否ok,如果不ok则抛出错误
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const user = await response.json(); // 假设服务器响应的是JSON数据
return user;
} catch (error) {
console.error(
`There was a problem with the fetch operation: ${error.message}`
);
}
}

// 使用这个函数
fetchUserData(123)
.then((user) => console.log(user))
.catch((error) => console.error(error));

上面代码中,fetchUserData是一个异步函数,它使用fetchAPI从服务器获取数据,然后使用await关键字等待Promise解决。注意,在异步函数中,我们应该始终使用try/catch来捕获可能出现的错误。

async函数有多种使用形式。它可以作为声明,作为表达式,还可以用来定义对象或类的方法。

// 函数声明
async function foo() {}

// 函数表达式
const foo = async function () {};

// 对象的方法
let obj = { async foo() {} };
obj.foo().then(...)

// Class 的方法
class Storage {
constructor() {
this.cachePromise = caches.open('avatars');
}

async getAvatar(name) {
const cache = await this.cachePromise;
return cache.match(`/avatars/${name}.jpg`);
}
}

const storage = new Storage();
storage.getAvatar('jake').then(…);

// 箭头函数
const foo = async () => {};

20.3 语法

async函数的语法规则总体上比较简单,难点是错误处理机制。

(1)返回 Promise 对象

async函数返回一个 Promise 对象(不管有没有return语句,总是返回一个 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))
.catch((e) => console.log("reject", e));

(2)Promise 对象的状态变化

async函数返回的 Promise 对象,必须等到内部所有await命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到return语句或者抛出错误。也就是说,只有async函数内部的异步操作执行完,才会执行then方法指定的回调函数

async function asyncFunc() {
const response = await fetch("https://api.example.com/data");
const data = await response.json();
return data;
}

asyncFunc()
.then((data) => console.log(data))
.catch((error) => console.error(error));

上面代码中,then方法的回调函数会等到asyncFunc函数内部的两个await操作都完成后才会被调用。

(3)await 命令

正常情况下,await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值

async function f() {
// 等同于
// return 123;
return await 123;
}

f().then((v) => console.log(v));
// 123

另一种情况是,await命令后面是一个thenable对象(即定义了then方法的对象,详细请看Promise章节的resolve()方法),那么await会将其等同于 Promise 对象。

let thenable = {
then: function (resolve, reject) {
resolve("Resolved!");
},
};

async function asyncFunc() {
let result = await thenable;
console.log(result); // 'Resolved!'
}

asyncFunc();
// 1000

上面代码中,thenable对象有一个then方法,所以可以在asyncFunc函数中使用await来等待它。JS引擎看到我们试图等待一个thenable对象时,它会自动调用该对象的then方法,并等待该方法调用其resolve参数所传入的值。

如果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 函数立即停止执行,而是会抛出异常。我们可以用 try/catch 语句来捕获这个异常并处理它。(如果没有错误处理机制,async函数会中断执行)

// 情况 1
async function f() {
await Promise.reject("出错了");
await Promise.resolve("hello world"); // 不会执行
}

// 情况 2
async function asyncFunc() {
try {
let response = await fetch("https://api.example.com/data");
let data = await response.json();
return data;
} catch (error) {
console.error("Error:", error);
// 这里可以处理错误,并决定如何继续执行
// 比如可以重新抛出错误,或者返回一个默认值,等等
}
}

有时我们希望即使前一个异步操作失败,也不要中断后面的异步操作。我们可以将await放在try...catch结构里面,这样不管这个异步操作是否成功,下一个await都会执行。

async function f() {
try {
await Promise.reject("出错了");
} catch (e) {
console.log(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

这种方法的优点是它更加紧凑,不需要显式地使用try/catch。但它的缺点是需要确保每个可能产生错误的await都有一个catch方法,否则错误可能会被忽视。

(4)错误处理

如果await后面的异步操作出错,那么等同于async函数返回的 Promise 对象被reject

async function asyncFunc() {
let response = await fetch("https://api.example.com/data");
// 如果 fetch 出错(例如,由于网络问题),那么下面的代码将不会被执行
let data = await response.json();
return data;
}

asyncFunc()
.then((data) => console.log(data))
.catch((error) => console.error("Error:", error));
// 如果 asyncFunc 在任何地方抛出了一个错误,那么这个 .catch 将会捕获到

上面代码中,如果fetch函数抛出一个错误,那么asyncFunc会立即停止执行并抛出一个错误,导致它返回的Promise变为rejected状态。这个错误然后被catch捕获并处理。

防止出错的方法,就是将其放在try...catch代码块之中。

async function f() {
try {
await new Promise(function (resolve, reject) {
throw new Error("出错了");
});
} catch (e) {
console.log(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);
}
}

注意,上面三个异步操作是串行处理,即secondStep将在firstStep完成后开始。如果这三个操作之间没有依赖关系,可以使用Promise.all()并行执行以提高效率。

(5)使用注意点

第一点,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) {
let docs = [{}, {}, {}];

// 可能得到错误结果
docs.forEach(async function (doc) {
await db.post(doc);
});
}

上面代码可能不会正常工作,原因是这时三个db.post()操作将是并发执行(forEach 不会等待回调函数),也就是同时执行,而不是继发执行。正确的写法是采用for循环。

async function dbFuc(db) {
let docs = [{}, {}, {}];

for (let doc of docs) {
await db.post(doc);
}
}

另一种方法是使用数组的reduce()方法来逐个顺序处理数组中的元素。

async function dbFunc(db) {
let docs = [{}, {}, {}];

await docs.reduce(async (prevPromise, doc) => {
await prevPromise;
try {
await db.post(doc);
} catch (error) {
console.error("An error occurred: ", error);
}
}, Promise.resolve());
}

上面代码中,传递给reduce()的函数返回一个Promise,这个Promise在前一个 Promise解析之后开始另一项数据库操作。我们使用Promise.resolve()作为初始值,以开始 Promise链。

如果希望多个请求并发执行,可以使用Promise.all()方法。下面两种写法效果相同。

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 函数可以保留运行堆栈。它会在等待Promise解析期间暂停函数的执行,而不是在全局范围内阻止执行,这使得调试工具能够在可能发生错误的地方停下来。

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()


20.4 async 函数实现原理

async 函数的实现原理,就是将 Generator 函数和自动执行器,包装在一个函数里。

async function fn(args) {
// ...
}

// 等同于

function fn(args) {
return spawn(function* () {
// ...
});
}

所有的async函数都可以写成上面的第二种形式,其中的spawn函数就是自动执行器。

function spawn(genF) {
return new Promise(function (resolve, reject) {
const gen = genF(); // 建立 Generater 函数
function step(nextF) {
// nextF 是一个 Generater 函数
let next;
try {
next = nextF(); // 启动 Generater,返回当前状态,运行至下一个 yield
} catch (e) {
return reject(e);
}
if (next.done) {
return resolve(next.value); // 结束执行器
}
Promise.resolve(next.value).then(
function (v) {
step(function () {
return gen.next(v);
}); // 开始递归
},
function (e) {
step(function () {
return gen.throw(e);
});
}
);
}
// 传递一个 function,用来启动 Generater,第一次传参是无效的
step(function () {
return gen.next(undefined);
});
});
}

20.5 异步处理方法的比较

通过一个例子,我们来看看 async 函数与 PromiseGenerator 函数的比较。

  • 假定某个 DOM 元素上面,部署了一系列的动画,前一个动画结束,才能开始后一个。如果当中有一个动画出错,就不再往下执行,返回上一个成功执行的动画的返回值。

首先是 Promise 的写法。

function chainAnimationsPromise(elem, animations) {
// 变量 ret 用来保存上一个动画的返回值
let ret = null;

// 初始化一个已解决的 Promise
let p = Promise.resolve();

// 遍历 animations 数组中的每个动画
for (let anim of animations) {
// 将每个动画链接到 Promise 链
p = p.then(function (val) {
// 保存前一个动画的解决值
ret = val;
// 执行当前动画并返回新的 Promise
return anim(elem);
});
}

// 任何一个动画的 Promise 被拒绝,将被 catch 捕捉
return p
.catch(function (e) {
/* 忽略错误,继续执行 */
})
.then(function () {
return ret;
});
}

虽然 Promise 的写法比回调函数的写法大大改进,但是一眼看上去,代码完全都是 PromiseAPI,操作本身的语义反而不容易看出来。

接着是 Generator 函数的写法。

function chainAnimationsGenerator(elem, animations) {
return spawn(function* () {
let ret = null;
try {
for (let anim of animations) {
ret = yield anim(elem);
}
} catch (e) {
/* 忽略错误,继续执行 */
}
return ret;
});
}

上面代码使用 Generator 函数遍历了每个动画,语义比 Promise 写法更清晰,用户定义的操作全部都写在spawn函数的内部。但是,这个写法必须提供一个任务运行器,自动执行 Generator 函数,而且必须保证yield语句后面的表达式返回一个 Promise

最后是 async 函数的写法。

async function chainAnimationsAsync(elem, animations) {
let ret = null;
try {
for (let anim of animations) {
ret = await anim(elem);
}
} catch (e) {
/* 忽略错误,继续执行 */
}
return ret;
}

可以看到 Async 函数的实现最简洁,最符合语义。它将 Generator 写法中的自动执行器,改在语言层面提供,不暴露给用户,因此代码量最少。


20.6 按顺序完成异步操作

实际开发中,会遇到一组异步操作需要按照顺序完成的情况。比如,依次远程读取一组 URL,然后按照读取的顺序输出结果。

Promise 的写法如下。

function logInOrder(urls) {
// 远程读取所有URL
const textPromises = urls.map((url) => {
return fetch(url).then((response) => response.text());
});

// 按次序输出
// chain 代表当前的 Promise 链,初始值是 Promise.resolve()
textPromises.reduce((chain, textPromise) => {
// 返回的是一个新的 Promise
return chain
.then(() => textPromise) // 返回当前正在处理的 textPromise
.then((text) => console.log(text));
}, Promise.resolve()); // 初始化 Promise 链
}

上面代码使用fetch方法,同时远程读取一组 URL。每个fetch操作都返回一个 Promise 对象,放入textPromises数组。然后,reduce方法依次处理每个 Promise 对象,然后使用then,将所有 Promise 对象连起来,因此就可以依次输出结果。

这种写法可读性比较差。下面是 async 函数实现。

async function logInOrder(urls) {
for (const url of urls) {
const response = await fetch(url);
console.log(await response.text());
}
}

上面代码确实大大简化,问题是所有远程操作都是继发。只有前一个 URL 返回结果,才会去读取下一个 URL,这样做效率很差,我们需要的是并发发出远程请求。

async function logInOrder(urls) {
// 并发读取远程URL
const textPromises = urls.map(async (url) => {
const response = await fetch(url);
return response.text();
});

// 按次序输出
for (const textPromise of textPromises) {
console.log(await textPromise);
}
}

上面代码中,urls.map 方法会对 urls 数组的每个元素执行一个异步函数,这个异步函数会远程读取URL,然后等待响应。所有的这些操作都在 map 方法调用的同时开始,所以这些操作是并发的。

注意,在这个异步函数中,await fetch(url) 会暂停函数的执行,直到 fetch 操作完成。但是,这并不会阻止其他操作,因为每个元素都在自己的异步函数中处理。因此,textPromises 是一个包含Promise的数组,每个Promise都代表一个正在进行的异步操作。


20.7 异步操作继发与并发

继发执行异步操作意味着等待一个操作完成后再执行下一个操作。

(1)使用 Promise 链

let promise = Promise.resolve();

[func1, func2, func3].forEach((func) => {
promise = promise.then(() => func());
});

promise.then(() => {
console.log("All async operations finished.");
});

上面代码中,func1, func2func3 是返回Promise的异步函数。这个代码段创建了一个Promise链,依次调用每个函数,并在所有函数都完成后输出一条消息。

(2)使用 Promise 链 II

doAsyncOperation1()
.then((result1) => {
console.log(result1);
return doAsyncOperation2();
})
.then((result2) => {
console.log(result2);
return doAsyncOperation3();
})
.then((result3) => {
console.log(result3);
})
.catch((error) => {
console.log(error);
});

上面代码中,每一个 then 里面返回一个新的 Promise,这样只有前一个 Promise 状态变为 resolved,后一个 Promise 才会开始执行。

(3)使用 async/await

async function runInSequence() {
await func1();
await func2();
await func3();
console.log("All async operations finished.");
}

runInSequence();

上面代码中,func1, func2func3也是返回Promise的异步函数。runInSequence函数使用await关键字依次等待每个函数完成,然后输出一条消息。

(4)使用 for…of 循环

async function runInSequence(functions) {
for (const func of functions) {
await func();
}
console.log("All async operations finished.");
}

runInSequence([func1, func2, func3]);

这个例子和前一个例子类似,但是runInSequence函数接受一个函数数组,并使用for...of循环来依次调用它们。

并发执行异步操作是指同时开始多个异步操作,并在它们都完成后进行下一步。

(1)使用 Promise.all()

let promise1 = doAsyncOperation1();
let promise2 = doAsyncOperation2();
let promise3 = doAsyncOperation3();

Promise.all([promise1, promise2, promise3])
.then(([result1, result2, result3]) => {
console.log(result1, result2, result3);
})
.catch((err) => {
console.error(err);
});

上面代码中,三个promise会同时开始,并行执行。只有当这三个操作都成功完成,Promise.all() 才会解决。如果任何一个操作失败,那么 Promise.all() 也会立即被 reject,不再等待其他操作。

(2)使用 Promise.allSettled()

let promise1 = doAsyncOperation1();
let promise2 = doAsyncOperation2();
let promise3 = doAsyncOperation3();

Promise.allSettled([promise1, promise2, promise3]).then((results) => {
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`Operation ${index + 1} succeeded with ${result.value}`);
} else {
console.log(`Operation ${index + 1} failed with ${result.reason}`);
}
});
});

Promise.allSettled() 的行为与 Promise.all() 类似,但是它会等待所有操作完成,无论成功还是失败。这对于希望了解所有操作的结果,而不仅仅是第一个失败的操作很有用。

(3)使用 async/await 结合 for..of

let urls = ["url1", "url2", "url3"];

let requests = urls.map((url) => fetch(url));

for (let request of requests) {
let result = await request;
console.log(result);
}

这种方法可以并发启动多个异步操作,但等待结果的顺序依然是串行的。也就是说,这种方法不会阻止异步操作的启动,但会按顺序等待每个异步操作的结果。


20.8 顶层 await

早期的语法规定,await命令只能出现在 async 函数内部,否则报错。

// 报错
const data = await fetch("https://api.example.com");

上面代码中,await命令独立使用,没有放在 async 函数里面,就会报错。

ES6 开始,允许在模块的顶层独立使用await命令,使得上面那行代码不会报错了。它的主要目的是使用await解决模块异步加载的问题。

// awaiting.js
let output;
async function main() {
const dynamic = await import(someMission);
const data = await fetch(url);
output = someProcess(dynamic.default, data);
}
main();
export { output };

上面代码中,模块awaiting.js的输出值output,取决于异步操作。我们把异步操作包装在一个 async 函数里面,然后调用这个函数,只有等里面的异步操作都执行,变量output才会有值,否则就返回undefined

下面是加载这个模块的写法。

// usage.js
import { output } from "./awaiting.js";

function outputPlusValue(value) {
return output + value;
}

console.log(outputPlusValue(100));
setTimeout(() => console.log(outputPlusValue(100)), 1000);

上面代码中,outputPlusValue()的执行结果,完全取决于执行的时间。如果awaiting.js里面的异步操作没执行完,加载进来的output的值就是undefined

目前的解决方法,就是让原始模块输出一个 Promise 对象,从这个 Promise 对象判断异步操作有没有结束(检查当前状态是否为resolved,即使用then的回调函数)。

// awaiting.js
let output;
export default (async function main() {
const dynamic = await import(someMission);
const data = await fetch(url);
output = someProcess(dynamic.default, data);
})();
export { output };

上面代码中,awaiting.js除了输出output,还默认输出一个 Promise 对象(async 函数立即执行后,返回一个 Promise 对象),从这个对象判断异步操作是否结束。

// usage.js
import promise, { output } from "./awaiting.js";

function outputPlusValue(value) {
return output + value;
}

promise.then(() => {
console.log(outputPlusValue(100));
setTimeout(() => console.log(outputPlusValue(100)), 1000);
});

上面代码中,将awaiting.js对象的输出,放在promise.then()里面,这样就能保证异步操作完成以后,才去读取output

这种写法比较麻烦,等于要求模块的使用者遵守一个额外的使用协议,按照特殊的方法使用这个模块。一旦忘记使用 Promise 加载,这个模块的代码就可能出错。

顶层的await命令,就是为了解决这个问题。它保证只有异步操作完成,模块才会输出值

// awaiting.js
const dynamic = import(someMission);
const data = fetch(url);
export const output = someProcess((await dynamic).default, await data);

上面代码中,两个异步操作在输出的时候,都加上了await命令。只有等到异步操作完成,这个模块才会输出值。加载这个模块的写法如下。

// usage.js
import { output } from "./awaiting.js";
function outputPlusValue(value) {
return output + value;
}

console.log(outputPlusValue(100));
setTimeout(() => console.log(outputPlusValue(100)), 1000);

上面代码的写法,与普通的模块加载完全一样。也就是说,模块的使用者完全不用关心,依赖模块的内部有没有异步操作,正常加载即可。

这时,模块的加载会等待依赖模块的异步操作完成,才执行后面的代码。所以,它总是会得到正确的output,不会因为加载时机的不同,而得到不一样的值。

注意,顶层await只能用在 ES6 模块。


21. Class 的基本语法

21.1 类的由来

JS中,生成实例对象的传统方法是通过构造函数。下面是一个例子。

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关键字,可以定义类。

class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}

toString() {
return "(" + this.x + ", " + this.y + ")";
}
}

上面代码定义了一个“类”,可以看到里面有一个constructor()方法,这就是构造方法,而this关键字则代表实例对象。新的class写法让对象原型的写法更加清晰、更像面向对象编程。

Point类除了构造方法,还定义了一个toString()方法。注意,定义toString()方法的时候,前面不需要加上function这个关键字,直接把函数定义放进去了就可以了。

使用的时候,也是直接对类使用new命令,跟构造函数的用法完全一致。

class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}

toString() {
return "(" + this.x + ", " + this.y + ")";
}
}

const point = new Point(1, 2);
point.toString();

事实上,类的所有方法都定义在类的prototype属性上面(也称为原型对象)。因此,在类的实例上面调用方法,其实就是调用原型上的方法。

function Car(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
}

Car.prototype.start = function () {
console.log(this.make + " " + this.model + " engine started.");
};

let car1 = new Car("Eagle", "Talon TSi", 1993);
car1.start(); // 输出:"Eagle Talon TSi engine started."

由于类的方法都定义在prototype对象上面,所以类的新方法可以添加在prototype对象上面。Object.assign()方法可以很方便地一次向类添加多个方法。(class 直接添加就行)

function Car(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
}

Object.assign(Car.prototype, {
start: function () {
console.log(this.make + " " + this.model + " engine started.");
},
stop: function () {
console.log(this.make + " " + this.model + " engine stopped.");
},
});

var car1 = new Car("Eagle", "Talon TSi", 1993);
car1.start(); // 输出:"Eagle Talon TSi engine started."
car1.stop(); // 输出:"Eagle Talon TSi engine stopped."

JS中,每一个函数(包括类,因为在JS中类也是函数)都有一个prototype属性,它是一个对象,这个对象有一个constructor属性,默认指向函数本身。也就是说,prototype对象的 constructor属性直接指向“类”的本身。

class Car {
constructor(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
}
}

console.log(Car.prototype.constructor === Car); // 输出:true

let car = new Car("Eagle", "Talon TSi", 1993);
console.log(car.constructor === Car); // 输出:true

21.2 constructor 方法

constructor()方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。一个类必须有constructor()方法,如果没有显式定义,一个空的constructor()方法会被默认添加。

class Point {}

// 等同于
class Point {
constructor() {}
}

如果想在创建对象时进行一些初始化操作,比如设置对象的属性,那么需要显式定义你自己的 constructor()方法。

class Car {
constructor(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
}

start() {
console.log(this.make + " " + this.model + " engine started.");
}
}

let car = new Car("Eagle", "Talon TSi", 1993);
car.start(); // 输出:"Eagle Talon TSi engine started."

constructor()方法默认返回实例对象(即this),但完全可以指定返回另外一个对象。

class Car {
constructor(make, model, year) {
this.make = make;
this.model = model;
this.year = year;

// 创建一个新对象,并返回它
return {
make: "Ford",
model: "F-150",
year: 2020,
};
}
}

let car = new Car("Eagle", "Talon TSi", 1993);
console.log(car.make); // 输出:"Ford"

上面代码中,constructor()方法返回了一个完全不同的对象。因此,当我们打印car.make时,输出的是'Ford'而不是'Eagle'


21.3 类的实例

生成类的实例的写法,与ES5完全一样,也是使用new命令。

class Point {
// ...
}

// 报错
let point = Point(2, 3);

// 正确
let point = new Point(2, 3);

类的属性和方法,除非显式定义在其本身(即定义在this对象上,通过实例访问),否则都是定义在原型上(即定义在class上,静态属性或者静态方法)

class Car {
constructor(make, model, year) {
// 实例属性
this.make = make;
this.model = model;
this.year = year;
}

// 原型方法
start() {
console.log(this.make + " " + this.model + " engine started.");
}

// 静态方法
static isCar(obj) {
return obj instanceof Car;
}
}

// 静态属性
Car.manu = "Unknown";

let car = new Car("Eagle", "Talon TSi", 1993);

console.log(car.make); // 输出:"Eagle"
car.start(); // 输出:"Eagle Talon TSi engine started."
console.log(Car.isCar(car)); // 输出:true
console.log(Car.manu); // 输出:"Unknown"

上面代码中,makemodelyear是实例属性,start是原型方法,isCar是静态方法,manu是静态属性。可以看到,makestart可以通过实例car访问,而isCarmanu只能通过类名Car访问。

ES5一样,类的所有实例共享一个原型对象

let p1 = new Point(2, 3);
let p2 = new Point(3, 2);

p1.__proto__ === p2.__proto__;
//true

上面代码中,p1p2都是Point的实例,它们的原型都是Point.prototype,所以__proto__属性是相等的。这也意味着,可以通过实例的__proto__属性为“类”添加方法。

然而,使用 __proto__ 通常是不推荐的,因为这并不是语言的一个标准特性,而且在不同的 JS 环境中可能表现不同。更好的方式是直接使用 Object.getPrototypeOf(obj) 函数获取一个对象的原型,或者使用 Object.setPrototypeOf(obj, prototype) 函数设置一个对象的原型。

let p1 = new Point(2, 3);
let p2 = new Point(3, 2);

p1.__proto__.printName = function () {
return "Oops";
};

p1.printName(); // "Oops"
p2.printName(); // "Oops"

let p3 = new Point(4, 2);
p3.printName(); // "Oops"

上面代码在p1的原型上添加了一个printName()方法,由于p1的原型就是p2的原型,因此p2也可以调用这个方法。而且,此后新建的实例p3也可以调用这个方法。这意味着,使用实例的__proto__属性改写原型,必须相当谨慎,不推荐使用,因为这会改变“类”的原始定义,影响到所有实例


21.4 实例属性的新写法

ES6 为类的实例属性,又规定了一种新写法。实例属性现在除了可以定义在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

注意,新写法定义的属性是实例对象自身的属性,而不是定义在实例对象的原型prototype上面。因为如果原型变了,比如这里的_count我们做计数处理,那么所有实例的_count属性都会改变,这就会出现与我们预期不同的结果。

这种新写法的好处是,所有实例对象自身的属性都定义在类的头部,看上去比较整齐,一眼就能看出这个类有哪些实例属性。

class foo {
bar = "hello";
baz = "world";

constructor() {
// ...
}
}

上面的代码,foo类有两个实例属性,一目了然。另外,写起来也比较简洁。


21.5 取值函数 getter 和存值函数 setter

ES5一样,在“类”的内部可以使用getset关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为

class MyClass {
constructor() {
this._myProperty = "Default value";
}

// Getter
get myProperty() {
console.log("Getting myProperty");
return this._myProperty;
}

// Setter
set myProperty(value) {
console.log("Setting myProperty");
this._myProperty = value;
}
}

let obj = new MyClass();
console.log(obj.myProperty); // 输出:"Getting myProperty" 和 "Default value"
obj.myProperty = "New value"; // 输出:"Setting myProperty"
console.log(obj.myProperty); // 输出:"Getting myProperty" 和 "New value"

上面代码中,myProperty属性有对应的存值函数和取值函数,因此赋值和读取行为都被自定义了。注意,存值函数和取值函数是设置在属性的 Descriptor 对象上的


21.6 Class 表达式

与函数一样,类也可以使用表达式的形式定义。

// 命名类表达式
let User = class MyClass {
constructor(name) {
this.name = name;
}

sayHello() {
console.log(`Hello, my name is ${this.name}`);
}
};

let user = new User("Alice");
user.sayHello(); // 输出 "Hello, my name is Alice"
console.log(MyClass); // 报错,MyClass 在类外部不可用

上面代码使用表达式定义了一个类。需要注意的是,这个类的名字是MyClass,但是MyClass只在 Class 的内部可用,指代当前类。在 Class 外部,这个类只能用User引用

如果类的内部没用到的话,可以省略MyClass,也就是可以写成下面的形式。

// 匿名类表达式
let User = class {
constructor(name) {
this.name = name;
}

sayHello() {
console.log(`Hello, my name is ${this.name}`);
}
};

let user = new User("Alice");
user.sayHello(); // 输出 "Hello, my name is Alice"

采用 Class 表达式,可以写出立即执行的 Class

let user = new (class {
constructor(name) {
this.name = name;
}

sayHello() {
console.log(`Hello, my name is ${this.name}`);
}
})("Alice");

user.sayHello(); // 输出 "Hello, my name is Alice"

21.7 静态方法

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

class Foo {
static classMethod() {
return "hello";
}
}

Foo.classMethod(); // 'hello'

let foo = new Foo();
foo.classMethod();
// TypeError: foo.classMethod is not a function

上面代码中,Foo类的classMethod方法前有static关键字,表明该方法是一个静态方法,可以直接在Foo类上调用,而不是在Foo类的实例上调用。如果在实例上调用静态方法,会抛出一个错误,表示不存在该方法。

注意,如果静态方法包含this关键字,这个this指的是类,而不是实例

class MyClass {
static myStaticMethod() {
console.log(this);
}

myInstanceMethod() {
console.log(this);
}
}

MyClass.myStaticMethod(); // 输出 MyClass [Function: MyClass],this 指向类
let instance = new MyClass();
instance.myInstanceMethod(); // 输出 MyClass {},this 指向实例

上面代码中,myStaticMethodMyClass的静态方法,this指向的是类MyClassmyInstanceMethod是实例方法,this指向的是类的实例。另外,从这个例子还可以看出,静态方法可以与非静态方法重名

父类的静态方法,可以被子类继承。同时,静态方法也是可以从super对象上调用的

class ParentClass {
static greeting() {
console.log("Hello from ParentClass");
}
}

class ChildClass extends ParentClass {}

ChildClass.greeting(); // 输出 "Hello from ParentClass"

-----------------------------------------

class ParentClass {
static greeting() {
console.log("Hello from ParentClass");
}
}

class ChildClass extends ParentClass {
static greeting() {
super.greeting();
console.log("Hello from ChildClass");
}
}

ChildClass.greeting();
// 输出:
// "Hello from ParentClass"
// "Hello from ChildClass"

21.8 私有方法和私有属性

ES6class添加了私有属性,方法是在属性名之前使用#表示

class IncreasingCounter {
#count = 0;
get value() {
console.log("Getting the current value!");
return this.#count;
}
increment() {
this.#count++;
}
}

const counter = new IncreasingCounter();
counter.#count; // 报错
counter.#count = 42; // 报错

上面代码中,#count就是私有属性,只能在类的内部使用(this.#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是一个不存在的私有属性,不管在函数内部或外部,读取该属性都会导致报错。注意,私有属性的属性名必须包括#,如果不带#,会被当作另一个属性。

ES6不仅可以写私有属性,还可以用来写私有方法

class Foo {
#a;
#b;
constructor(a, b) {
this.#a = a;
this.#b = b;
}
#sum() {
return this.#a + this.#b;
}
printSum() {
console.log(this.#sum());
}
}

另外,私有属性也可以设置 gettersetter 方法

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 MyClass {
#privateProperty = "I'm private";

checkPrivateProperty(otherInstance) {
return otherInstance.#privateProperty;
}
}

const instance1 = new MyClass();
const instance2 = new MyClass();

console.log(instance1.checkPrivateProperty(instance2));
// 输出 "I'm private"

私有属性和私有方法也可以加上static关键字,表示这是一个静态的私有属性或私有方法

class MyClass {
static #privateStaticMethod() {
return "Hello from private static method";
}

static callPrivateStaticMethod() {
return this.#privateStaticMethod();
}
}

console.log(MyClass.callPrivateStaticMethod());
// 输出 "Hello from private static method"

21.9 in 运算符

前面说过,直接访问某个类不存在的私有属性会报错,但是访问不存在的公开属性不会报错。这个特性可以用来判断,某个对象是否为类的实例

class C {
#brand;

static isC(obj) {
try {
obj.#brand;
return true;
} catch {
return false;
}
}
}

上面示例中,类C的静态方法isC()就用来判断,某个对象是否为C的实例。它采用的方法就是,访问该对象的私有属性#brand。如果不报错,就会返回true;如果报错,就说明该对象不是当前类的实例,从而catch部分返回false

因此,try...catch结构可以用来判断某个私有属性是否存在。但是,这样的写法很麻烦,代码可读性很差,ES6 改进了in运算符,使它也可以用来判断私有属性。它不会报错,而是返回一个布尔值

class C {
#brand;

static isC(obj) {
if (#brand in obj) {
// 私有属性 #brand 存在
return true;
} else {
// 私有属性 #foo 不存在
return false;
}
}
}

in也可以跟this一起配合使用,用来检查一个对象是否拥有某个属性的,无论这个属性是在实例本身还是在它的原型链中。注意,判断私有属性时,in只能用在类的内部

class A {
#foo = 0;
m() {
console.log(#foo in this); // true
console.log(#bar in this); // false
}
}

21.10 静态块

静态属性的一个问题是,如果它有初始化逻辑,这个逻辑要么写在类的外部,要么写在constructor()方法里面。

class MyClass {
constructor() {
// ...
}
}

MyClass.myStaticProperty = "some value";

// 或者

class MyClass {
constructor() {
if (!MyClass.myStaticProperty) {
MyClass.myStaticProperty = "some value";
}
}
}

为了解决这个问题,ES2022 引入了静态块static block),允许在类的内部设置一个代码块,在类生成时运行且只运行一次,主要作用是对静态属性进行初始化。以后,新建类的实例时,这个块就不运行了

class Foo {
static a;

static {
try {
this.a = doSomethingThatMightFail();
} catch (error) {
this.a = "default value";
}
}
}

每个类允许有多个静态块,每个静态块中只能访问之前声明的静态属性。同时,静态块的内部不能有return语句,但是可以使用类名或this,指代当前类。

class MyClass {
static prop1;
static prop2;

static {
// 第一个静态块
this.prop1 = "value1";
}

static {
// 第二个静态块
this.prop2 = this.prop1 + " value2";
}
}

console.log(MyClass.prop1); // 输出 "value1"
console.log(MyClass.prop2); // 输出 "value1 value2"

除了静态属性的初始化,静态块还有一个作用,就是将私有属性与类的外部代码分享

class MyClass {
// 私有属性
static #privateProp = "private value";

static {
// 在静态块中定义公开方法
this.getPrivateProp = function () {
return MyClass.#privateProp;
};
}
}

console.log(MyClass.getPrivateProp()); // 输出 "private value"

这个特性提供了一种灵活的方式来控制类的私有属性的访问,可以根据需要决定哪些私有属性应该对外部代码开放,以及在何种条件下开放。


21.11 类的注意点

(1)Generator 方法

如果某个方法之前加上星号*,就表示该方法是一个 Generator 函数。

lass MyClass {
* generatorMethod() {
yield 'Hello,';
yield 'world!';
}
}

const myInstance = new MyClass();
const gen = myInstance.generatorMethod();

console.log(gen.next().value); // 输出 "Hello,"
console.log(gen.next().value); // 输出 "world!"
console.log(gen.next().value); // 输出 undefined

(2)this 的指向

类的方法内部如果含有this,它默认指向类的实例。但是,如果将类的方法提取出来单独使用,那么this可能就不再指向原来的对象了。

class MyClass {
myMethod() {
console.log(this);
}
}

const myInstance = new MyClass();
myInstance.myMethod(); // MyClass的实例

const { method } = myInstance;
method(); // undefined或者全局对象(在非严格模式下)

在上述代码中,myMethod方法中的this默认指向MyClass的实例。但当我们将myMethod方法赋值给变量method,并单独调用method()时,this就变成了undefined

解决方法是,在构造方法中绑定this,或者使用箭头函数。

class MyClass {
constructor() {
this.myMethod = this.myMethod.bind(this);
}

myMethod() {
console.log(this);
}
}

// 或者

class MyClass {
myMethod = () => {
console.log(this);
};
}

22. Class 的继承

本章内容复杂,故先跳过一部分。

22.1 简介

Class 可以通过extends关键字实现继承,让子类继承父类的属性和方法extends 的写法比 ES5 的原型链继承,要清晰和方便很多。

class Point {}

class ColorPoint extends Point {}

上面示例中,Point是父类,ColorPoint是子类,它通过extends关键字,继承了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

为什么子类的构造函数,一定要调用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 新建实例时,会输出12。原因就是子类构造函数调用super()时,会执行一次父类构造函数。

另一个需要注意的地方是,在子类的构造函数中,只有调用super()之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建,必须先完成父类的继承,只有super()方法才能让子类实例继承父类。

class Parent {
constructor() {
this.name = "Parent";
}
}

class Child extends Parent {
constructor() {
// Error: Must call super constructor in derived class before accessing 'this'
// console.log(this.name);

super(); // 正确初始化子类实例

console.log(this.name); // 输出: 'Parent'
}
}

new Child();

上面代码中,子类的constructor()方法没有调用super()之前,就使用this关键字,结果报错,而放在super()之后就是正确的。

如果子类没有定义constructor()方法,这个方法会默认添加,并且里面会调用super()。也就是说,不管有没有显式定义,任何一个子类都有constructor()方法。

class Parent {
constructor(name) {
this.name = name;
}
}

class Child extends Parent {
// 这里我们没有显式定义 constructor()
}

const child = new Child("John");
console.log(child.name); // 输出: 'John'

有了子类的定义,就可以生成子类的实例了。

child instanceof Child; // true
child instanceof Parent; // true

注意,如果父类和子类有同名属性,子类的属性会覆盖父类的属性。这是因为在子类中定义的属性和方法具有更高的优先级。

class Parent {
constructor() {
this.name = "Parent";
}
}

class Child extends Parent {
constructor() {
super();
this.name = "Child";
}

showName() {
console.log(this.name);
}
}

const child = new Child();
child.showName(); // 输出: 'Child'

22.2 私有属性和私有方法的继承

父类所有的属性和方法,都会被子类继承,除了私有的属性和方法。子类无法继承父类的私有属性,或者说,私有属性只能在定义它的 class 里面使用

class Foo {
#p = 1;
#m() {
console.log("hello");
}
}

class Bar extends Foo {
constructor() {
super();
console.log(this.#p); // 报错
this.#m(); // 报错
}
}

如果父类定义了私有属性的读写方法,子类就可以通过这些方法,读写私有属性

class Foo {
#p = 1;
getP() {
return this.#p;
}
}

class Bar extends Foo {
constructor() {
super();
console.log(this.getP()); // 1
}
}

上面示例中,getP()是父类用来读取私有属性的方法,子类可以用这个方法读到父类的私有属性。


22.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

上面示例中,fooA 类的静态属性,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


22.4 super 关键字

super这个关键字,既可以当作函数使用,也可以当作对象使用。

(1)当super作为函数调用时,代表父类的构造函数

class A {}

class B extends A {
constructor() {
super();
}
}

调用super()的作用是形成子类的this对象,把父类的实例属性和方法放到这个this对象上面。子类在调用super()之前,是没有this对象的,任何对this的操作都要放在super()的后面。

注意,这里的super虽然代表了父类的构造函数,但是因为返回的是子类的this(即子类的实例对象),所以super内部的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()在子类构造方法中执行时,子类的属性和方法还没有绑定到this,所以如果存在同名属性,此时拿到的是父类的属性

class A {
name = "A";
constructor() {
console.log("My name is " + this.name);
}
}

class B extends A {
name = "B";
}

const b = new B(); // My name is A

上面示例中,最后一行输出的是A,而不是B,原因就在于super()执行时,Bname属性还没有绑定到this,所以this.name拿到的是A类的name属性。

作为函数时,super()只能用在子类的构造函数之中,用在其他地方就会报错。

class A {}

class B extends A {
m() {
super(); // 报错
}
}

(2)当super作为对象时,在普通方法中指向父类的原型对象,在静态方法中指向父类

class Parent {
constructor() {
this.name = "Parent";
}

greet() {
console.log(`Hello, ${this.name}`);
}

static sayHello() {
console.log("Hello from Parent class");
}
}

class Child extends Parent {
constructor() {
super();
this.name = "Child";
}

greet() {
super.greet(); // 使用super在普通方法中调用父类方法
console.log(`Hello, ${this.name} from child class`);
}

static sayHello() {
super.sayHello(); // 在静态方法中使用super调用父类静态方法
console.log("Hello from Child class");
}
}

let c = new Child();
c.greet();
Child.sayHello();

如果super指向父类的原型对象,那么定义在父类实例上的方法或属性(即定义在constructor里),是无法通过super调用的。这时候想在子类中访问到父类实例上的属性,则需要在父类中定义一个方法来返回这个属性,然后在子类中调用这个方法。

class Parent {
constructor() {
this.name = "Parent";
}

getName() {
return this.name;
}
}

class Child extends Parent {
constructor() {
super();
console.log(super.name); // TypeError ...
console.log(super.getName()); // 'Parent'
}
}

在子类普通方法中通过super调用父类的方法时,方法内部的this指向当前的子类实例。所以通过super对某个属性赋值,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

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的实例。


22.5 类的 prototype 属性和 __proto__ 属性

大多数浏览器的 ES5 实现之中,每一个对象都有__proto__属性,指向对应的构造函数的prototype属性。Class 作为构造函数的语法糖,同时有prototype属性和__proto__属性,因此同时存在两条继承链。

  • 子类的__proto__属性,表示构造函数的继承,总是指向父类
  • 子类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属性

这两条继承链,可以这样理解:

  • 作为一个对象,子类(B)的原型(__proto__属性)是父类(A)。
  • 作为一个构造函数,子类(B)的原型对象(prototype属性)是父类的原型对象(prototype属性)的实例。
B.prototype = Object.create(A.prototype);
// 等同于
B.prototype.__proto__ = A.prototype;

22.6 原生构造函数的继承

ES6 允许继承原生构造函数定义子类,因为 ES6 是先新建父类的实例对象this,然后再用子类的构造函数修饰this,使得父类的所有行为都可以继承。下面是一个继承Array的例子。

class MyArray extends Array {
constructor(...args) {
super(...args);
}
}

var arr = new MyArray();
arr[0] = 12;
arr.length; // 1

arr.length = 0;
arr[0]; // undefined

上面代码定义了一个MyArray类,继承了Array构造函数,因此就可以从MyArray生成数组的实例。这意味着,ES6 可以自定义原生数据结构(比如ArrayString等)的子类,这是 ES5 无法做到的。

上面这个例子也说明,extends关键字不仅可以用来继承类,还可以用来继承原生的构造函数。因此可以在原生数据结构的基础上,定义自己的数据结构。下面就是定义了一个带版本功能的数组。

class VersionedArray extends Array {
constructor() {
super();
this.history = [[]];
}
commit() {
this.history.push(this.slice());
}
revert() {
this.splice(0, this.length, ...this.history[this.history.length - 1]);
}
}

var x = new VersionedArray();

x.push(1);
x.push(2);
x; // [1, 2]
x.history; // [[]]

x.commit();
x.history; // [[], [1, 2]]

x.push(3);
x; // [1, 2, 3]
x.history; // [[], [1, 2]]

x.revert();
x; // [1, 2]

上面代码中,VersionedArray会通过commit方法,将自己的当前状态生成一个版本快照,存入history属性。revert方法用来将数组重置为最新一次保存的版本。除此之外,VersionedArray依然是一个普通数组,所有原生的数组方法都可以在它上面调用。

注意,继承Object的子类,有一个行为差异

class NewObj extends Object {
constructor() {
super(...arguments);
}
}
var o = new NewObj({ attr: true });
o.attr === true; // false

上面代码中,NewObj继承了Object,但是无法通过super方法向父类Object传参。这是因为 ES6 改变了Object构造函数的行为,一旦发现Object方法不是通过new Object()这种形式调用,ES6 规定Object构造函数会忽略参数。


附录

该学习笔记的所有章节均保存在Github中,如有需要,可下载并在Typora中阅读。