JavaScript Algorithms and Data Structures(七)
freeCodeCamp —- JavaScript Algorithms and Data Structures
面向对象编程
1. 创建一个基本的 JavaScript 对象
想想我们在生活中每天都可见到的事物:比如汽车、商店以及小鸟等。 它们都是对象:即人们可以观察和与之互动的实体事物。
这些物体的性质是什么? 汽车有轮子, 商店销售物品, 鸟儿有翅膀。
这些特征,或者说是属性,定义了一个对象由什么构成的。 需要注意的是:那些相似的对象可以拥有相同的属性,但是这些属性可能会有不同的值。 举个例子:所有的汽车都有轮子,但并不是所有汽车的轮子个数都是一样的。
JavaScript 中的对象可以用来描述现实世界中的物体,并赋予它们属性和行为,就像它们在现实世界中的对应物一样。 下面是使用这些概念来创建一个 duck 对象的示例:
1 | let duck = { |
这个 duck 对象有两组键值对:一个是 name 属性,它的值是 Aflac;另一个是 numLegs 属性,它的值是 2。
2. 使用点符号来访问对象的属性
访问这些属性的值。 下面是一个示例:
1 | let duck = { |
我们可以用“点号表示法”来访问对象的属性,duck 后面加上点号以及属性名 name,来访问到 Aflac。
3. 在对象上创建方法
对象可以有一个叫做 method 的特殊属性。
方法属性也就是函数。 这给对象添加了不同的行为。 以下就是一个带有方法属性的 duck 示例:
1 | let duck = { |
示例添加了 sayName 方法,函数返回包含 duck 名字的一个句子。 注意:这个方法在返回语句中使用 duck.name 的方式来获取 name 的属性值。
4. 使用 this 关键字提高代码重用性
通过使用 “点号表示法” duck.name 来获取 name 的属性值:
1 | sayName: function() {return "The name of this duck is " + duck.name + ".";} |
虽然这是访问对象属性的有效方法,但是这里有一个陷阱。 如果变量名发生了改变,那么引用了原始名称的任何代码都需要更新。 在一个简短的对象定义中,这并不是问题,但是如果对象有很多对其属性的引用,那么发生错误的可能性就更大了。
我们可以使用 this 关键字来避免这一问题:
1 | let duck = { |
this 是一个很复杂的知识点,而上面那个例子也只是使用它的一种方法而已。 在当前的上下文环境中,this 指向的就是与这个方法有关联的 duck 对象。 如果把对象的变量名改为 mallard,那使用 this 后就没有必要在代码中找到所有指向 duck 的部分。 这样可以使得代码更具有可读性和复用性。
5. 定义构造函数
构造函数是创建对象的函数。 函数给这个新对象定义属性和行为。 可将它们视为创建的新对象的蓝图。
以下就是一个构造函数的示例:
1 | function Bird() { |
这个构造函数定义了一个 Bird 对象,其属性 name、color 和 numLegs 的值分别被设置为 Albert、blue 和 2。 构造函数遵循一些惯例规则:
- 构造函数函数名的首字母大写,这是为了方便我们区分构造函数(
constructors)和其他非构造函数。 - 构造函数使用
this关键字来给它将创建的这个对象设置新的属性。 在构造函数里面,this指向的就是它新创建的这个对象。 - 构造函数定义了属性和行为就可创建对象,而不是像其他函数一样需要设置返回值。
6. 使用构造函数创建对象
创建一个 Bird 构造函数:
1 | function Bird() { |
注意: 构造函数内的 this 总是指被创建的对象。
注意:通过构造函数创建对象的时候要使用 new 操作符。 因为只有这样,JavaScript 才知道要给 Bird 这个构造函数创建一个新的实例:blueBird。 如果不使用 new 操作符来新建对象,那么构造函数里面的 this 就无法指向新创建的这个对象实例,从而产生不可预见的错误。 现在 blueBird 这个实例就继承了Bird 构造函数的所有属性,如下:
1 | blueBird.name; |
由构造函数创建的实例也和其他对象一样,它的属性可以被访问和修改:
1 | blueBird.name = 'Elvira'; |
7. 扩展构造函数以接收参数
所有通过Bird 构造函数创建出来的实例 Birds 都自动的取名为 Albert,颜色都是蓝色,还都有两条腿。 如果你想要新创建出来的小鸟们拥有不同的名字和颜色要怎么办呢? 当然,手动的去修改每一个小鸟实例自己的属性也是可以实现的,只是会增加很多无谓的工作量:
1 | let swan = new Bird(); |
假如你写了一个程序来追踪一个鸟舍里面的几百只甚至几千只不同的小鸟。 你将会花费很多时间去创建所有的小鸟实例并给它们的属性一一修改为不同的值。 为了减轻创建不同 Bird 对象的工作量,你可以给你的 Bird 设置为可以接收参数的构造函数:
1 | function Bird(name, color) { |
然后将值通过参数的方式传递给 Bird 构造函数来定义每一个唯一的小鸟实例: let cardinal = new Bird("Bruce", "red"); 这给 Bird 的 name 和 color 属性分别赋值为 Bruce 和 red 色。 但 numLegs 属性仍然设置为 2。 cardinal 有以下这些属性:
1 | cardinal.name |
这样一来构造函数就变得很灵活了。 现在可以在创建每个Bird实例时直接定义属性,这是 JavaScript 构造函数非常实用的用法之一。 它们根据共同或相似的属性和行为将对象归纳为一组,并能够自动的创建各自实例。
8. 使用 instanceof 验证对象的构造函数
凡是通过构造函数创建出的新对象,这个对象都叫做这个构造函数的实例。 JavaScript 提供了一种很简便的方法来验证这个事实,那就是通过 instanceof 操作符。 instanceof 允许你将对象与构造函数之间进行比较,根据对象是否由这个构造函数创建的返回 true 或者 false。 以下是一个示例:
1 | let Bird = function(name, color) { |
instanceof 方法会返回 true。
如果一个对象不是使用构造函数创建的,那么 instanceof 将会验证这个对象不是构造函数的实例:
1 | let canary = { |
instanceof 方法会返回 false。
9. 了解自有属性
请看下面的实例,Bird 构造函数定义了两个属性:name 和 numLegs。
1 | function Bird(name) { |
name 和 numLegs 被叫作自身属性,因为它们是直接在实例对象上定义的。 这就意味着 duck 和 canary 这两个对象分别拥有这些属性的独立副本。 事实上,Bird 的所有实例都将拥有这些属性的独立副本。 下面的代码将 duck 的所有自身属性都存到一个叫作 ownProps 的数组里面:
1 | let ownProps = []; |
控制台将显示值 ["name", "numLegs"]。
10. 使用原型属性来减少重复代码
所有 Bird 实例可能会有相同的 numLegs 值,所以在每一个 Bird 的实例中本质上都有一个重复的变量 numLegs。
当只有两个实例时可能并不是什么问题,但想象一下如果有数百万个实例。 这将会产生许许多多重复的变量。
更好的方法是使用 Bird 的 prototype。 prototype 是一个可以在所有 Bird 实例之间共享的对象。 以下是一个在 Bird prototype 中添加 numLegs 属性的示例:
1 | Bird.prototype.numLegs = 2; |
现在所有的 Bird 实例都拥有了共同的 numLegs 属性值。
1 | console.log(duck.numLegs); |
由于所有的实例都可以继承 prototype 上的属性,所以可以把 prototype 看作是创建对象的 “配方”。 请注意:duck 和 canary 的 prototype 属于 Bird 的构造函数,即 Bird 的原型 Bird.prototype。
11. 迭代所有属性
现在你已经了解了两种属性: 自身属性和 prototype 属性。 自身属性是直接在对象上定义的。 而原型属性在 prototype 上定义。
1 | function Bird(name) { |
这个示例会告诉你如何将 duck 的自身属性和 prototype 属性分别添加到 ownProps 数组和 prototypeProps 数组里面:
1 | let ownProps = []; |
console.log(ownProps) 将在控制台中显示 ["name"] ,console.log(prototypeProps) 将显示 ["numLegs"]。
12. 了解构造函数属性
创建的实例对象 duck 和 beagle 都有一个特殊的 constructor 属性:
1 | let duck = new Bird(); |
这两次 console.log 调用都将在控制台中显示 true。
需要注意到的是这个 constructor 属性是对创建这个实例的构造函数的一个引用。 constructor 属性的一个好处是可以通过检查这个属性来找出它是一个什么对象。 下面是一个例子,来看看是怎么使用的:
1 | function joinBirdFraternity(candidate) { |
注意: 由于 constructor 属性可以被重写,所以最好使用instanceof 方法来检查对象的类型。
13. 将原型更改为新对象
可以单独给 prototype 添加属性了:
1 | Bird.prototype.numLegs = 2; |
需要添加多个属性的,这未免会显得拖沓。
1 | Bird.prototype.eat = function() { |
一种更有效的方法就是给对象的 prototype 设置为一个已经包含了属性的新对象。 这样一来,所有属性都可以一次性添加进来:
1 | Bird.prototype = { |
14. 更改原型时,记得设置构造函数属性
手动设置一个新对象的原型有一个重要的副作用。 它清除了 constructor 属性! 此属性可以用来检查是哪个构造函数创建了实例,但由于该属性已被覆盖,它现在给出了错误的结果:
1 | duck.constructor === Bird; |
按顺序,这些表达式会返回 false、true 和 true。
为了解决这个问题,凡是手动给新对象重新设置过原型对象的,都别忘记在原型对象中定义一个 constructor 属性:
1 | Bird.prototype = { |
15. 了解对象的原型来自哪里
就像人们从父母那里继承基因一样,对象也可直接从创建它的构造函数那里继承其 prototype。 请看下面的例子:Bird 构造函数创建了一个 duck 对象:
1 | function Bird(name) { |
duck 从 Bird 构造函数那里继承了它的 prototype。 你可以使用 isPrototypeOf 方法来验证他们之间的关系:
1 | Bird.prototype.isPrototypeOf(duck); |
这将返回 true。
16. 了解原型链
JavaScript 中所有的对象(除了少数例外)都有自己的 prototype。 而且,对象的 prototype 本身也是一个对象。
1 | function Bird(name) { |
正因为 prototype 是一个对象,所以 prototype 对象也有它自己的 prototype! 这样看来的话,Bird.prototype 的 prototype 就是 Object.prototype:
1 | Object.prototype.isPrototypeOf(Bird.prototype); |
这有什么作用呢? 你可能还记得我们在上一个挑战中学到的 hasOwnProperty 方法:
1 | let duck = new Bird("Donald"); |
hasOwnProperty 是定义在 Object.prototype 上的一个方法,尽管在 Bird.prototype 和 duck上并没有定义该方法,但是我们依然可以在这两个对象上访问到。 这就是 prototype 链的一个例子。 在这个prototype 链中,Bird 是 duck 的 supertype,而 duck 是 subtype。 Object 则是 Bird 和 duck 实例共同的 supertype。 Object 是 JavaScript 中所有对象的 supertype,也就是原型链的最顶层。 因此,所有对象都可以访问 hasOwnProperty 方法。
17. 使用继承避免重复
有一条原则叫做:Don’t Repeat Yourself。常以缩写形式 DRY 出现,意思是“不要自己重复”。 编写重复代码会产生的问题是:任何改变都需要去多个地方修复所有重复的代码。 这通常意味着我们需要做更多的工作,会产生更高的出错率。
请观察下面的示例,Bird 和 Dog 共享 describe 方法:
1 | Bird.prototype = { |
我们可以看到 describe 方法在两个地方重复定义了。 根据以上所说的 DRY 原则,我们可以通过创建一个 Animal supertype(或者父类)来重写这段代码:
1 | function Animal() { }; |
Animal 构造函数中定义了 describe 方法,可将 Bird 和 Dog 这两个构造函数的方法删除掉:
1 | Bird.prototype = { |
18. 从超类继承行为
创建了一个Animal 超类(supertype),用来定义所有动物共有的行为:
1 | function Animal() { } |
将在 Bird 和 Dog 中重用 Animal 中的方法,而无需重新定义它们。 这里我们会用到构造函数的继承特性。 创建一个超类 supertype(或者叫父类)的实例。 你已经学会了一种创建 Animal 实例的方法,即使用 new 操作符:
1 | let animal = new Animal(); |
此语法用于继承时会存在一些缺点
相反,学习另外一种没有这些缺点的方法来替代 new 操作:
1 | let animal = Object.create(Animal.prototype); |
Object.create(obj) 创建了一个新对象,并指定了 obj 作为新对象的 prototype。 回忆一下,我们之前说过 prototype 就像是创建对象的“配方”。 如果我们把 animal 的 prototype 设置为与 Animal 构造函数的 prototype 一样,那么就相当于让 animal 这个实例具有与 Animal 的其他实例相同的“配方”了。
1 | animal.eat(); |
instanceof 方法会返回 true.
19. 将子辈的原型设置为父辈的实例
在上一个挑战中,我们学习了从超类(或者叫父类) Animal 继承其行为的第一个步骤:创建一个 Animal 的新实例。
这一节挑战我们将学习第二个步骤:给子类型(或者子类)设置 prototype。 这样一来,Bird 就是 Animal 的一个实例了。
1 | Bird.prototype = Object.create(Animal.prototype); |
请记住,prototype 类似于创建对象的“配方”。 从某种意义上来说,Bird 对象的配方包含了 Animal 的所有关键“成分”。
1 | let duck = new Bird("Donald"); |
duck 继承了Animal 的所有属性,其中包括了 eat 方法。
20. 重置一个继承的构造函数属性
当一个对象从另一个对象那里继承了其 prototype 时,那它也继承了父类的 constructor 属性。
请看下面的举例:
1 | function Bird() { } |
但是 duck 和其他所有 Bird 的实例都应该表明它们是由 Bird 创建的,而不是由 Animal 创建的。 为此,你可以手动将 Bird 的构造函数属性设置为 Bird 对象:
1 | Bird.prototype.constructor = Bird; |
21. 继承后添加方法
从超类构造函数继承其 prototype 对象的构造函数,除了继承的方法外,还可以拥有自己的方法。
请看举例:Bird 是一个构造函数,它继承了 Animal 的 prototype:
1 | function Animal() { } |
除了从 Animal 构造函数继承的行为之外,还需要给 Bird 对象添加它独有的行为。 这里,我们给 Bird 对象添加一个 fly() 函数。 函数会以一种与其他构造函数相同的方式添加到 Bird's 的 prototype 中:
1 | Bird.prototype.fly = function() { |
现在 Bird 的实例中就有了 eat() 和 fly() 这两个方法:
1 | let duck = new Bird(); |
duck.eat() 将在控制台中显示字符串 nom nom nom, duck.fly() 将显示字符串 I'm flying!。
22. 重写继承的方法
在上一个挑战中,我们学习了一个对象可以通过引用另一个对象的 prototype 来继承其属性和行为(或方法):
1 | ChildObject.prototype = Object.create(ParentObject.prototype); |
然后,ChildObject 将自己的方法链接到它的 prototype中:
1 | ChildObject.prototype.methodName = function() {...}; |
我们还可以重写继承的方法。 以同样的方式 - 通过使用一个与需要重写的方法相同的方法名,向ChildObject.prototype 中添加方法。 请看下面的举例:Bird 重写了从 Animal 继承来的 eat() 方法:
1 | function Animal() { } |
如果你有一个实例:let duck = new Bird();,然后你调用了 duck.eat(),以下就是 JavaScript 在 duck 的 prototype 链上寻找方法的过程:
duck=>eat()是定义在这里吗? 不是。Bird=>eat()是定义在这里吗? => 是的。 执行它并停止往上搜索。Animal=> 这里也定义了eat()方法,但是 JavaScript 在到达这层原型链之前已停止了搜索。- Object => JavaScript 在到达这层原型链之前也已经停止了搜索。
23. 使用 Mixin 在不相关对象之间添加共同行为
正如你所见,行为是可以通过继承来共享的。 然而,在有些情况下,继承不是最好的解决方案。 继承不适用于不相关的对象,比如 Bird 和 Airplane。 虽然它们都可以飞行,但是 Bird 并不是一种 Airplane,反之亦然。
对于不相关的对象,更好的方法是使用 mixins。 mixin 允许其他对象使用函数集合。
1 | let flyMixin = function(obj) { |
flyMixin 能接受任何对象,并为其提供 fly 方法。
1 | let bird = { |
这里的 flyMixin 接收了bird 和 plane 对象,然后将 fly 方法分配给了每一个对象。 现在 bird 和 plane 都可以飞行了:
1 | bird.fly(); |
控制台将显示字符串 Flying, wooosh! 两次,每次 .fly() 调用都会显示。
注意观察 mixin 是如何允许相同的 fly 方法被不相关的对象 bird 和 plane 重用的。
24. 使用闭包保护对象内的属性不被外部修改
bird 有一个公共属性 name。 公共属性的定义就是:它可以在 bird 的定义范围之外被访问和更改。
1 | bird.name = "Duffy"; |
因此,代码的任何地方都可以轻松地将 bird 的 name 属性更改为任意值。 想想密码和银行账户之类的东西,如果代码库的任何部分都可以轻易改变它们, 那将会引起很多问题。
使属性私有化最简单的方法就是在构造函数中创建变量。 可以将该变量范围限定在构造函数中,而不是全局可用。 这样,属性只能由构造函数中的方法访问和更改。
1 | function Bird() { |
这里的 getHatchedEggCount 是一种特权方法,因为它可以访问私有属性 hatchedEgg。 这是因为 hatchedEgg 是在与 getHatchedEggCount 相同的上下文中声明的。 在 JavaScript 中,函数总是可以访问创建它的上下文。 这就叫做 closure。
25. 了解立即调用函数表达(IIFE)
JavaScript 中的一个常见模式就是,函数在声明后立刻执行:
1 | (function () { |
这是一个匿名函数表达式,立即执行并输出 Chirp, chirp!。
请注意,函数没有名称,也不存储在变量中。 函数表达式末尾的两个括号()会让它被立即执行或调用。 这种模式被叫做立即调用函数表达式(immediately invoked function expression) 或者IIFE。
26. 使用 IIFE 创建一个模块
一个立即调用函数表达式(IIFE)通常用于将相关功能分组到单个对象或者是 module 中。 例如,先前的挑战中定义了两个 mixins:
1 | function glideMixin(obj) { |
我们可以将这些 mixins 分成以下模块:
1 | let motionModule = (function () { |
注意:一个立即调用函数表达式(IIFE)返回了一个 motionModule 对象。 返回的这个对象包含了作为对象属性的所有 mixin 行为。 module 模式的优点是,所有的运动相关的行为都可以打包成一个对象,然后由代码的其他部分使用。 下面是一个使用它的例子:
1 | motionModule.glideMixin(duck); |