发布时间:2021年7月21日
该篇文章内容涉及《你不知道的JavaScript》上的第二部分 ———— this 和 对象原型 后三章内容
目前比较流行的编程模式有以下几种:
在我们平常的讨论中,常见的设计模式如:工厂模式、观察者模式、单例模式,这些其实都是面向对象编程的设计模式。
而实际上,类其实也是设计模式的一种,在函数式编程语言中,类模式是一种非常常用的设计模式。
在 ES6 提供 class
关键字之前,我们通常使用对象或者函数的形式来实现类的功能,但实际上 JavaScript 中的类只是 function
的语法糖而已,类代码最终还是会转换成 function
来实现。
类是对某一种事物高度抽象的结果,比如一个建筑,建筑的类会规划出这个建筑有多宽,多高,多少间屋子以及房屋的位置。但这些只是设计的 计划,并不是真正的建筑。
我们需要一个工人,按照我们的规划来建造出真实的建筑,这个工人就是 new
关键字,新建出来的建筑就是 实例。
类比实例在理解上会更直观。当你想要知道一个建筑有多少间屋子时,从建筑自身看会比较麻烦,此时如果有对应的设计图,会看的更清晰。
实例是类实现的一份副本,避免你直接操作类本身。当你想要对建筑进行一些定制修改时,直接修改建筑即可,无需修改类。
在面向对象语言中,可以先定一个类,然后再定义承了前者的类。我们习惯把第一个类叫做 父类,把第二个类叫做 子类。
在 JavaScript 世界中,我们用 extend
关键字来实现类的继承,比如在 ReactJs
项目代码中我们经常会这么写:
import { Component } from 'react';
class MyComponent extend Component {
// ...
}
export default MyComponent;
在这个例子中,React 官方提供的 Component
类就是我们 MyComponent
的父类。
在 Component
父类中,定义了一些方法,比如 constructor/componentWillReceiveProps/componentDidMount/render
等等,我们在 MyComponent
类中可以对这些方法进行 重写,这种叫做 类型的多态性。
在项目中,我们会存在很多个继承自 Component
类的组件,也就会存在很多个 render
方法,但是在实际运行中这些 render
方法并不会相互干扰,所以得出一个结论:
子类从父类中得到的只是一份副本数据,类的继承其实就是赋值。
子类通过
super
关键字的使用可以使用到其父类定义的公共方法
在没有 class
与 extend
关键字之前,我们会使用对象的方式来实现类的功能,而 mixin
—— 混入,就是我们实现继承的方式。
上面我们说过,继承其实是对父类数据的拷贝,那么 mixin
的实现也很好理解:
把父类中的数据复制到子类当中 => 继承
如果子类已经存在相同的 key
的数据,则不拷贝 => 多态
如果子类需要调用父类的方法,使用 Father.Function.call(this)
的方式实现
我们通常将多态分为两种,一种为 显式多态,另一种为 相对多态。
显式多态其实很好理解,就是我们已经指定了运行的是那个父类的那个方法,如
Father.Function.Call(this)
就是显式多态在 ES6 之前,其实是没有相对多态的机制。在 ES6 引入
class
关键字以及super
关键字后,通过super.Function
调用的函数就是相对多态的体现。
下面是 mixin
函数的代码:
function mixin(sourceObject, targetObject) {
// 遍历父对象(父类)
for (let key in sourceObject) {
// 判断父类的key是否已经存在在子类
// 此处必须应 in 判断,因为 in 关键字会深入子类的原型链中查找
// 如果不使用 in,则可能会发生遮罩效应,即子类的方法/属性遮住了原型链中的方法/属性
if (!(key in targetObject)) {
targetObject[key] = sourceObject[key];
}
}
return targetObject;
}
JavaScript 中对象类型的数据都会有一个 [[prototype]]
的属性,这个属性指向另外一个对象。
在第二篇文章中我们介绍了对象内置默认的 [[Get]]
操作会深入原型链中进行查找,其实就是沿着对象的 [[prototype]]
进行查找,直到 [[prototype]]
不存在为止。
判断一个属性是否存在在一个对象中,我们既可以用 in
来查找,也可以通过 for in
遍历对象数据查找。
for in
与 in
都会深入对象的原型链中进行查找,不同的是 for in
只会输出对象的可枚举属性。
所有普通对象的原型链的尽头都会指向内置的 Object.prototype
。由于所有普通对象都会最终指向 Object.prototype
,所以 Object.prototype
就承载着存放通用方法的责任,比如 toString()/valueOf()/hasOwnProperty
等。
当我们从一个对象中获取某一属性的值时,一旦查找到目标时就返回,即总是返回原型链中最底层的属性值。
所以当我们对一个对象的属性进行设置时,如果该属性已经存在于对象自身,也存在于其原型链上,那么就会发生 属性屏蔽。
如果要设置的属性不存在该对象中,那么就会沿着 [[prototype]]
查找,此时会有以下三种情况(以下情况均为在原型链中的判断):
writable
的值为 true
,那么会直接在当前对象中添加该属性,此时会发生属性屏蔽。(ps:并不会修改原型链中的值)writable
值为 false
,即不允许修改,这时既不会修改原型链中该属性的值,也不会在对象自身上新建属性setter
,那么就调用这个 setter
。该属性不会添加到对象自身。从第二种情况来看,原型链中,同属性名的属性描述符中
writable: false
时,会阻止下层原型链中继续创建同名属性。但是这个限制只作用于通过
=
赋值的情况,如果我们使用Object.definePropery
来操作,就不会存在这个问题。
上面我们说过在 JavaScript 中并没有真正意义上的类,所以就一直存在着一种行为,就是模仿类。 尤其是在 ES6 的 class
类关键字提出后,这种行为变得更甚,为了模仿类,竟然创建一个类的语法糖。
实际上 JavaScript 中所有“类”的实现与继承都离不开一个东西 —— [[prototype]]
。
从表面上看,我们似乎在 JavaScript 中实现了类的能力,但是与传统的类不同的是,JavaScript 中并没有类的复制机制(默认情况)。我们所谓实现的继承,都是通过 [[prototype]]
的相关联实现的,而不是把父类的属性和方法复制到子类当中。
我们把一个对象可以访问另一个对象属性和方法的方式叫做行为委托。
关于行为委托的内容,我们会在后面详细详解。
同样,因为没有真正的类的概念,所以与类相关的构造函数的概念也就不存在。在实际使用中,我们经常通过 new
关键字去实例化一个类(函数),**new
关键字会劫持所有的普通函数并用构造对象的形式来调用该函数,然后返回一个新生成对象,并完成这个新对象的 [[prototype]]
的链接。**
我们在用函数模拟类的实现时,经常会写出这样的代码
MyObject.prototype.xxx = function() { }
,我们给MyObject 这个“类”新增了一个方法,也表明了我们最终还是通过[[prototype]]
去实现类的模拟。
通过上面的内容,我们已经知道了 JavaScript 中继承的实现离不开原型 —— [[prototype]]
,那么如果我们要实现一个函数另一个函数的属性与方法应该怎么做?
下面我们看代码:
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function() {
return `hi, my name is ${this.name}`;
}
function Mocha(name, age) {
Person.call(this, name);
this.age = age;
}
// es5
Mocha.prototype = Object.create(Person.prototype);
// es6
Object.setPrototypeOf(Mocha.prototype, Person.prototype);
Mocha.prototype.speak = function() {
console.log(`${this.sayHi()},i am ${this.age} years old`);
}
const mocha = new Mocha('mocha', 26);
mocha.sayHi(); // hi,my name is mocha
mocha.speak(); // hi,my name is mocha,i am 26 years old
在上面代码中,我们定义了一个 Person
“类”,拥有 name
属性以及打招呼 sayHi
的方法。
然后我们定义了 Mocha
类,Mocha
肯定属于人类,所以我们这个地方就形成了一个继承关系,即 Mocha
类是 Person
的子类。
在一开始提到过,在 JavaScript 中默认情况下,父子类的继承只是通过 [[prototype]]
关联实现,并不是像面向对象语言中类一样,继承是通过把父类复制在子类中实现。如果我们只是将把 Person
的原型与 Mocha
的原型做一个简单关联,如 Mocha.prototype = Person.prototype
这样,那么会出现什么问题呢?
我们都知道 Object.prototype
指向的是一个对象,那么对象在 JavaScript 的世界中是以堆的形式存在的,我们对外提供的只是该对象在内存中的地址。如果我们将 Person.prototype
直接赋予 Mocha.prototype
,那么当我们对 Mocha
类的原型进行一些修改的时候,肯定会影响到 Person
的原型!
所以,我们要做的是,把 Person
的原型复制一份到 Mocha
中去。所以我们在上述代码中,用到了 Object.create
来完成复制操作,在 ES6 中,又添加了辅助函数 Object.setPrototypeOf
来完成这个操作。
在上面代码中,我们实现了“类”的继承,那么如果判断一个实例与一个类的关系呢?
答案是使用 instanceOf
关键字,在浏览器中输入上述继承代码后,通过 instanceOf
的判断会发现, Person
与 Mocha
类均是 mocha
实例的组件
mocha instanceOf Mocha; // true
mocha instanceOf Person; // true
instanceOf
操作符的左侧是我们拿到的实例,右侧是一个函数(类)。
instanceOf
能且仅能处理对象(实例)和函数的关系,要处理两个实例之间的判断,只用 instanceOf
无法实现。
要检查两个对象之间的关联关系,一般用对象的 isPrototypeOf
方法来检测。比如要检测对象 b
是否出现在 c
的 [[prototype]]
链中,直接使用 b.isPrototypeOf(c)
即可判断。
获取一个对象的原型链方法:
Object.getPrototypeOf(对象)
,当然我们常用的对象.__proto__
也可以拿到指定对象的原型链。
我们说过在 JavaScript 中我们可以使用函数/对象来实现模拟类,在上面我们讲过了使用函数如何实现类的模拟,那么本小节内容我们将讲述如何使用对象来实现类的模拟。
其实原理很简单的,在 JavaScript 世界中,函数是对象的一种特殊实现,那么既然函数存在 [[prototype]]
,对象肯定也存在其 [[prototype]]
,所以还是按照上述的方法使用 Object.create()
即可实现对象的类的模拟。
const person = {
sayHi: function(name) {
console.log(`my name is ${name}`)
}
}
const mocha = Object.create(person);
mocha.sayHi('mocha'); // my name is mocha
如果我们想要给 mocha
对象加入一个新的 sayHi
方法来拓展 person
自带的 sayHi
方法,可以用内部委托的方式实现:
const mocha = Object.create(person);
mocha.speak = function(name, age) {
this.sayHi(name);
console.log(`i am ${age} years old`);
}
mocha.speak('mocha', 26); // my name is mocha; i am 26 years old;
通过内部委托的方式,我们可以在对外提供 mocha
对象时,屏蔽掉外部对 person
对象中 sayHi
方法的感知,把对 person
对象的内容封闭在 mocha
对象内部。这样,可以保证 mocha
对象提供的 API 更加纯粹。
行为委托章节是本书的最后一个章节,更深层次的讲解了 JavaScript 中原型链与类实现的相关内容
通过对象原型章节的讲述,我们可以明白,JavaScript 中原型机制其实就是一个对象中通过内部链接,引用到另外一个对象。总结来说,原型机制其实就是描述对象之间关联关系的机制。
首先我们看一段代码:
const Person = {
sayHi: function(name) { console.log(`hi,my name is ${name}`) },
calcExperience: function(experience) { console.log(`Object Person's experience`) }
}
const Frontend = Object.create(Person);
Frontend.calcExperience = function(year) {
console.log(`i have ${year} years experiences`);
}
Frontend.speak = function(name, experience) {
this.sayHi(name);
console.log(`i am a frontend developer`);
this.calcExperience(experience);
}
const mocha = Object.create(Frontend);
mocha.speak();
//output:
// hi,my name is mocha
// i am a frontend developer
// i have 3 years experiences
在上述的代码中,我们实现了一个 Person
对象,一个继承了 Person
对象的 Frontend
对象,然后以 Frontend
为父对象新建了 mocha
对象,在 mocha
对象中,我们实现了分别调用属于 Person
和 Frontend
对象的方法功能。
与传统的类相比,作者把这种编程风格成为 对象关联(objectes linked to other objects)
在上述代码中,我们的 mocha
对象委托了 Frontend
与 Person
对象;Frontend
对象委托了 Person
对象。
在这里我们可以看到 [[prototype]]
的应用:**Frontend
对象中并没有 sayHi
方法,但是它会沿着 [[prototype]]
链往上查找后调用,实现了继承性;Frontend
中存在 calcExperience
方法,所以就不会沿着 [[prototype]]
链查找,实现了多态性。**
在文中,我们分别用函数和对象实现了类功能的模拟,我们可以对比一下他们的思维模型差异
函数类实现:
function Person(name) {}
Person.prototype.sayHi = function(name) {
console.log(`hi,my name is ${name}`);
}
Person.prototype.calcExperience = function(experience) {
console.log(`Object Person's experience`)
}
function Frontend(name, experience) {
this.name = name;
this.experience = experience;
}
Frontend.prototype = Object.create(Person.prototype);
Frontend.prototype.calcExperience = function(year) {
console.log(`i have ${year} years experiences`);
}
Frontend.prototype.speak = function() {
this.sayHi(this.name);
console.log(`i am a frontend developer`);
this.calcExperience(this.experience);
}
const mocha = new Frontend('mocha', 3);
mocha.speak();
对象的实现:
const Person = {
sayHi: function(name) { console.log(`hi,my name is ${name}`) },
calcExperience: function(experience) { console.log(`Object Person's experience`) }
}
const Frontend = Object.create(Person);
Frontend.calcExperience = function(year) {
console.log(`i have ${year} years experiences`);
}
Frontend.speak = function(name, experience) {
this.sayHi(name);
console.log(`i am a frontend developer`);
this.calcExperience(experience);
}
const mocha = Object.create(Frontend);
mocha.speak();
从上述的代码中我们可以清晰的看出,对象关联的代码比函数模拟类的代码要简洁很多,而且函数式对类模拟的代码中到处充斥着明处 prototype
的处理逻辑。
使用对象关联风格代码,可以让我们抛开复杂的构造函数、原型以及 ew
关键字的考虑,更加聚焦于对象自身应该拥有的属性与方法,把关注点转移到对象之间的关联关系上来。
到这里《你不知道的 JavaScript - 上》这本书的内容基本完结了,后面还有一些行为委托和函数类的对比就不再多赘述。
这本书聚焦于 JavaScript 中最基础的部分,涵盖了作用域、this、原型以及两种模拟类的实现方式,虽有一些内容已经过时,但确实算得上一本好书,尤其是最后 混合类、原型以及行为委托模块的这一部分,把原型以及原型链在 JavaScript 中的作用讲解的十分清晰。
碍于个人能力,文章肯定存在理解偏差的地方,不知道这篇文章会被多少人看到,如果你对文章内容有自己的看法或者想和我交流,欢迎随时通过邮件与我联系。
再会。