了解了 __proto__ 、 prototype 、 constructor 三者的关系那么我们就要来学习一下构造函数的继承了,上面我们定义了一个动物的构造函数,但是我们不能直接去 new 一个实例,因为 new 出来的实例没有任何意义,是一个动物实例,没有具体指向。这时我们需要创建一个子类来继承它。这时可以对 Animal 类做个限制:function Animal(type) { if (new.target === Animal) { throw new Error('Animal 类不能被 new,只能被继承!') } this.type = type || '鸟类';}Animal.prototype.eat = function() { console.log('鸟类吃虫子!')};var animal = new Animal();//VM260:3 Uncaught Error: Animal 类不能被 new,只能被继承!既然不能被 new 那要怎么去继承呢?虽然不能被 new 但是我们可以去执行这个构造函数啊,比较它本质还是一个函数。执行构造函数时 this 的指向就不是当前的实例了,所以还需要对 this 进行绑定。我们定义一个子类:Owl(猫头鹰)function Owl() { Animal.call(this);}var owl = new Owl();通过使用 call 方法在 Owl 内部绑定 this,这样实例就继承了 Animal 上 this 的属性了。但是在 Animal 的原型中还有关于 Animal 类的方法,这些方法怎么继承呢?首先要明确的是不能使用 Owl.prototype = Animal.prototype 这样的方式去继承,上面也说了这会使我们对子类原型修改的方法会作用到其他子类中去。那么怎么可以实现这一继承呢?这时就需要原型链出场了,我们可以使用 Owl 原型上的原型链指向 Animal 的原型,实例 owl 根据链的查找方式是可以继承 Animal 的原型上的方法的。function Owl() { Animal.call(this);}Owl.prototype.__proto__ = Animal.prototype;var owl = new Owl();owl.eat(); // 鸟类吃虫子!通过原型链的方式还是比较麻烦的,也不优雅,ES6 提供了 setPrototypeOf() 方法可以实现相同的效果:// Owl.prototype.__proto__ = Animal.prototype;Owl.setPrototypeOf(Owl.prototype, Animal.prototype);这样在子类 Owl 的原型上增加方法不会影响父类,这样也算是比较好的方式解决了子类的继承。
常量就是定义并赋值后再也不能修改的量,通常一些不会改变的量,如配置、物理值等会声明为常量,在 ES6 之前是没有提供常量这一特性的。但是根据常量自身的特性,定义赋值后不能被修改,就可以通过一些方式来模拟常量。第一种就是采用约定的形式,通常常量都是大写,不同单词之间用下划线分隔。var PI = 3.1415926535;var DB_ACCOUNT = 'root';var DB_PASSWORD = 'root';这种方式定义的常量本质上还是变量,值还是可以修改的,但因为命名格式采用国际惯例,一眼就能看出是常量,不会对其修改。这种方式是最简单的方式,但不安全。第二种方式就是利用对象下属性的描述来控制可写性,将对象的属性设置为只读。var CONFIG = {};Object.defineProperty(CONFIG, 'DB_ACCOUNT', { value: 'root', writable: false,});console.log(CONFIG.DB_ACCOUNT); // 输出:rootCONFIG.DB_ACCOUNT = 'guest';console.log(CONFIG.DB_ACCOUNT); // 因为不可被改写,所以输出:root这种方式将常量都放在一个对象下,通过Object.defineProperty定义属性,设定其writable为false,就可以防止被改写。但有一个问题,CONFIG自身这个对象可能被修改。换一个思路,既然在最外层声明的变量是放在window上的,那可以用这个方式往 window上挂不可改写的属性。Object.defineProperty(window, 'DB_ACCOUNT', { value: 'root', writable: false,});console.log(DB_ACCOUNT); // 输出:rootDB_ACCOUNT = 'guest';console.log(DB_ACCOUNT); // 因为不可被改写,所以输出:root通常情况下 window 对象是不可被修改的,这样常量的安全系数就变得非常高,但缺点是可能性较差,通过一点修改可以提升可读性。var define = function(name, value) { Object.defineProperty(window, name, { value: value, writable: false, });};define('DB_ACCOUNT', 'root');define('DB_PASSWORD', 'root');只要约定好使用 define 函数定义的都为常量即可。还有两种方式,就是结合Object.seal与Object.freeze的特性来声明常量。前者可以使对象不能再被扩充,但是所有属性还需要再手动设置不可写,后者可以让对象不能扩充,属性也不能修改。这里对这两个原生方法不再做过多描述,有兴趣可以查阅相关 API 资料。
在进行简单的语法练习时,可以通过上面介绍的 tsc 命令来编译 ts 文件,而在实际项目工程中,可以采取另一种工程化方案:① 在 ts-practice 目录下创建 src 目录:mkdir src && touch src/index.ts② 接下来用 npm 进行项目初始化(初始化过程中的交互命令有兴趣可自行查阅相关资料,目前一路按“回车键”即可):npm init你会发现目录中多了一个 package.json 文件,它定义了这个项目所需要的各种模块,以及项目的配置信息(比如名称、版本、作者、license等信息)。将 package.json 中入口文件选项改为刚刚创建的 index.ts:{ "main": "src/index.ts",}③ 然后,使用 tsc 命令进行初始化:tsc --init这时候你会发现目录下又多了一个 tsconfig.json 文件,它指定了用来编译这个项目的根文件和编译选项。Tips:不带任何输入文件的情况下调用 tsc 命令,编译器会从当前目录开始去查找 tsconfig.json 文件,逐级向上搜索父目录。当命令行上指定了输入文件时,tsconfig.json 文件会被忽略。后续会有专门一节来介绍 tsconfig.json 文件的各项参数,这里将刚才生成的配置文件稍作修改:{ "compilerOptions": { "target": "ESNext", /* 支持 ES6 语法 */ "module": "commonjs", "outDir": "./lib", "rootDir": "./src", "declaration": true, /* 生成相应的.d.ts文件 */ "strict": true, "strictNullChecks": false, "noImplicitThis": true }, "exclude": ["node_modules", "lib", "**/*.test.ts"], "include": ["src"]}④ 在 package.json 文件中,加入 script 命令以及依赖关系:{ "name": "ts-practice", "version": "1.0.0", "description": "", "main": "src/index.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "tsc": "tsc" }, "author": "", "license": "ISC", "devDependencies": { "@types/node": "^13.1.1", "typescript": "^3.7.4" }}⑤ 根据配置文件 package.json 中的配置选项,下载所需模块,也就是配置项目所需的运行和开发环境:npm install你会看到多了一个 node_modules 文件夹和一个 package-lock.json 文件,node_modules 文件夹是项目的所有依赖包,package-lock.json 文件将项目依赖包的版本锁定,避免依赖包大版本升级造成不兼容问题。⑥ 与介绍 tsc 命令时一样,将以下内容写入 index.ts 文件:// src/index.tsexport enum TokenType { ACCESS = 'accessToken', REFRESH = 'refreshToken'}⑦ 在项目根目录输入编译命令:npm run tsc这时候可以看到多了一个 lib 文件夹,里面的内容就是项目的编译结果了! ???
上节我们学习了 Map 的使用,在 JavaScript 中对对象的引用都是强保留的,这意味着只要持有该对象的引用,垃圾回收机制就不会回收该对象。var obj = {a: 10, b: 88};上面是一个字面量对象,只要我们访问 obj 对象,或者任何地方有引用该对象,这个对象就不会被垃圾回收。而在 ES6 之前 JavaScript 中没有弱引用概念,弱引用的本质上就是不会影响垃圾回收机制。其实,WeakMap 并不是真正意义上的弱引用,只要键仍然存在,它就强引用其上的内容。WeakMap 仅在键被垃圾回收之后,才弱引用它的内容,所以也不用太纠结其中的弱。在官方上对为什么使用 WeakMap 做了描述,Map 在存储值是有顺序的,这种顺序是通过二维数组的形式来完成的。我们知道 Map 在初始化时接受一个数组,数组中的每一项也是一个数组,这个数组中包含两个值,一个存放的是键,一个存放的是值。新添加的值会添加到数组的末尾,从而使得键值具有索引的含义。在取值时就需要进行遍历,通过索引取出对应的值。但是这样存在两个很大的缺陷:赋值和搜索的时间复杂度都是 O (n) (n 是键值对的个数),因为这两个操作都是要遍历整个数组才能完成的;可能会导致内存泄漏,因为数组会一直引用每个键和值。这种引用使得垃圾回收算法不能回收处理它们,即使没有任何引用存在。相比之下,原生的 WeakMap 持有的是 “弱引用”,这意味着它不会影响垃圾回收。WeakMap 中的 key 只有在键值存在的情况才会引用,而且只是一个读取操作,并不会对引用的值产生影响。也正因为这样的弱引用关系,导致 WeakMap 中的 key 是不可枚举的,假设 key 是可枚举的,就会对该值产生引用关系,影响垃圾回收。如果只是单纯地向对象上添加值用于检查某些逻辑判断,又不想影响垃圾回收机制,这个时候就可以使用 WeakMap。这里说一点,在一些框架中已经使用了像 WeakMap 和 WeakSet 这样的数据结构,其中 Vue3 就引入了这样的新数据进行一些必要的逻辑判断,有兴趣的可以去扒扒 Vue3 的源码研究研究。
上一节我们已经知道了 Promise 是一个类,默认接收一个参数 executor(执行器),并且会立即执行。所以首先需要创建一个 Promise 的类,然后传入一个回调函数并执行它,故有如下的初始代码:class Promise { constructor(executor) { executor(); }}Promise 有三个状态:等待(padding)、成功(fulfilled),失败(rejected)。默认是等待状态,等待态可以突变为成功态或失败态,所以我们可以定义三个常量来存放这三个状态const PENDING = 'PENDING';const RESOLVED = 'RESOLVED'; // 成功态const REJECTED = 'REJECTED'; // 失败态class Promise { constructor(executor) { this.status = PENDING; // 默认是等待态 executor(); }}这样我们就知道了 Promise 的基本状态,那内部的状态是怎么突变为成功或失败的呢?这里执行器(executor)会提供两个个方法用于改变 Promise 的状态,所以我们需要在初始化时定义 resolve 和 reject 方法:在成功的时候会传入成功的值,在失败的时候会传入失败的原因。并且每个Promise 都会提供 then方法用于链式调用。class Promise { constructor(executor) { this.status = PENDING; const resolve = (value) => {}; const reject = (reason) => {}; // 执行executor时,传入成功或失败的回调 executor(resolve, reject); } then(onfulfilled, onrejected) { }}这时我们就可以开始着手去更改 Promise的状态了,由于默认情况下 Promise 的状态只能从 pending 到 fulfilled 和 rejected 的转化。class Promise { constructor(executor) { this.status = PENDING; const resolve = (value) => { // 只有等待态时才能更改状态 if (this.status === PENDING) { this.status = RESOLVED; } }; const reject = (reason) => { if (this.status === PENDING) { this.status = REJECTED; } }; executor(resolve, reject); } ...}成功和失败都会返回对应的结果,所以我们需要定义成功的值和失败的原因两个全局变量,用于存放返回的结果。class Promise { constructor(executor) { this.status = PENDING; this.value = undefined; this.reason = undefined; const resolve = (value) => { // 只有等待态时才能更改状态 if (this.status === PENDING) { this.value = value; this.status = RESOLVED; } }; const reject = (reason) => { if (this.status === PENDING) { this.reason = reason; this.status = REJECTED; } }; executor(resolve, reject); } ...}这时我们就已经为执行器提供了两个回调函数了,如果在执行器执行时抛出异常时,我们需要使用 try…catch 来补货一下。由于是抛出异常,所以,需要调用 reject 方法来修改为失败的状态。try { executor(resolve, reject);} catch(e) { reject(e)}我们知道实例在调用 then 方法时会传入两个回调函数 onfulfilled, onrejected 去执行成功或失败的回调,所以根据状态会调用对应的函数来处理。then(onfulfilled, onrejected) { if (this.status === RESOLVED) { onfulfilled(this.value) } if (this.status === REJECTED) { onrejected(this.reason) }}这样我们就完了 Promise 最基本的同步功能,let promise = new Promise((resolve, reject) => { resolve('value'); // throw new Error('错误'); // reject('error reason') // setTimeout(() => { // resolve('value'); // }, 1000)})promise.then((data) => { console.log('resolve response', data);}, (err) => { console.log('reject response', err);})用上面的代码对我们写的 Promise 进行验证,通过测试用例可知,我们写的 Promise 只能在同步中运行,当我们使用 setTimeout 异步去返回时,并没有预想的在then的成功回调中打印结果。对于这种异步行为需要专门处理,如何处理异步的内容呢?我们知道在执行异步任务时 Promise 的状态并没有被改变,也就是并没有执行 resolve 或 reject 方法,但是 then 中的回调已经执行了,这时就需要增加当 Promise 还是等待态的逻辑,在等待态时把回调函数都存放起来,等到执行 resolve 或 reject 再依次执行之前存放的then的回调函数,也就是我们平时用到的发布订阅模式。实现步骤:首先,需要在初始化中增加存放成功的回调函数和存放失败的回调函数;然后,由于是异步执行 resolve 或 reject 所以需要在 then 方法中把回调函数存放起来;最后,当执行 resolve 或 reject 时取出存放的回调函数依次执行。根据以上的实现步骤可以得到如下的代码:class Promise { constructor(executor) { this.status = PENDING; this.value = undefined; // 成功的值 this.reason = undefined; // 失败的原因+ // 存放成功的回调函数+ this.onResolvedCallbacks = [];+ // 存放失败的回调函数+ this.onRejectedCallbacks = []; let resolve = (value) => { if (this.status === PENDING) { this.value = value; this.status = RESOLVED;+ // 异步时,存放在成功的回调函数依次执行+ this.onResolvedCallbacks.forEach(fn => fn()) } }; let reject = (reason) => { if (this.status === PENDING) { this.value = reason; this.status = REJECTED;+ // 异步时,存放在失败的回调函数依次执行+ this.onRejectedCallbacks.forEach(fn => fn()) } }; try { executor(resolve, reject); } catch(e) { reject(e) } } then(onfulfilled, onrejected) { if (this.status === RESOLVED) { onfulfilled(this.value) } if (this.status === REJECTED) { onrejected(this.reason) }+ if (this.status === PENDING) {+ this.onResolvedCallbacks.push(() => {+ // TODO+ onfulfilled(this.value);+ })+ this.onRejectedCallbacks.push(() => {+ // TODO+ onrejected(this.reason);+ })+ } }}上面的代码中,在存放回调函数时把 onfulfilled, onrejected 存放在一个函数中执行,这样的好处是可以在前面增加处理问题的逻辑。这样我们就完成了处理异步的 Promise 逻辑。下面是测试用例,可以正常的执行 then 的成功回调函数。let promise = new Promise((resolve, reject) => { setTimeout(() => { resolve('100'); }, 1000)})promise.then((data) => { console.log('resolve response:', data); // resolve response: 100}, (err) => { console.log('reject response:', err);})到这里我们是不是已经基本实现了 Promise 的功能呢?ES6 中的 then 方法支持链式调用,那我们写的可以吗?我们在看下面的一个测试用例:let promise = new Promise((resolve, reject) => { setTimeout(() => { resolve('100'); }, 1000)})promise.then((data) => { console.log('resolve response:', data); // resolve response: 100 return 200}, (err) => { console.log('reject response:', err);}).then((data) => { console.log('data2:', data)}, null)// TypeError: Cannot read property 'then' of undefined然而当我们在执行的时候会报错,then 是 undefined。为什么会这样呢?那我们要知道如何满足链式调用的规范,那就是在完成任务后再返回一个Promise 实例。那如何返回一个 Promise 实例呢?在 Promise A+ 规范的 2.2.7 小节在有详细的描述,再实例化一个 promise2 来存放执行后的结果,并返回 promise2。那么我们就要改造 then 方法了。class Promise { ... then(onfulfilled, onrejected) { let promise2 = new Promise((resolve, reject) => { if (this.status === RESOLVED) { const x = onfulfilled(this.value) resolve(x) } if (this.status === REJECTED) { const x = onrejected(this.reason); reject(x) } if (this.status === PENDING) { this.onResolvedCallbacks.push(() => { const x = onfulfilled(this.value) resolve(x) }) this.onRejectedCallbacks.push(() => { const x = onrejected(this.reason); reject(x) }) } }) return promise2 }}再使用上面的测试用例,就可以得到正确的结果:let promise = new Promise((resolve, reject) => { resolve('100');})promise.then((data) => { console.log('data1:', data); // data1: 100 return 200}, null).then((data) => { console.log('data2:', data); // data2: 200 throw new Error('error')}, null).then(null, () => { consol.log('程序报错...')})上面的测试用例中,当 then 的回调函数抛出异常时需要去捕获错误,传到下一个 then 的失败回调函数中。class Promise { ... then(onfulfilled, onrejected) { let promise2 = new Promise((resolve, reject) => { if (this.status === RESOLVED) { try{ const x = onfulfilled(this.value) resolve(x) } catch(e) { reject(e) } } if (this.status === REJECTED) { try{ const x = onrejected(this.reason); resolve(x) } catch(e) { reject(e) } } if (this.status === PENDING) { this.onResolvedCallbacks.push(() => { try{ const x = onfulfilled(this.value) resolve(x) } catch(e) { reject(e) } }) this.onRejectedCallbacks.push(() => { try{ const x = onrejected(this.reason); resolve(x) } catch(e) { reject(e) } }) } }) return promise2 }}到这里为止我们就已经实现了一个简版的 Promise,因为Promise是一个规范,很多人都可以实现自己的 Promise 所以 Promise A+ 规范做了很多兼容处理的要求,如果想实现一个完整的 Promise 可以参考 Promise A+ 规范 。