🌑

Mocha's Blog

目录
  1. 混合对象——类
    1. 类的概念
    2. 类与实例
    3. 类的继承
    4. 混入 - Mixin
      1. Mixin的实现
  2. 对象原型
    1. [[Prototype]]
      1. Object.prototype 的责任
      2. 属性设置与屏蔽
    2. JavaScript 中的模仿“类”
    3. 用原型实现继承
    4. 检查原型关系
    5. 对象关联
  3. 行为委托
    1. 函数类与对象委托对比
  4. 总结 & 结尾

重读你不知道的JavaScript-上(三)

发布时间:2021年7月21日

该篇文章内容涉及《你不知道的JavaScript》上的第二部分 ———— this 和 对象原型 后三章内容

混合对象——类

类的概念

目前比较流行的编程模式有以下几种:

  1. 面向过程编程,即编程的重点是函数对数据的处理过程,比如 C语言
  2. 面向对象编程,即编程的重点是对共性内容的规划、抽象与封装,比如 C++、Java等
  3. 函数式编程,目前的发展趋势,主张通过对各种纯函数的应用实现代码逻辑

在我们平常的讨论中,常见的设计模式如:工厂模式、观察者模式、单例模式,这些其实都是面向对象编程的设计模式。

而实际上,类其实也是设计模式的一种,在函数式编程语言中,类模式是一种非常常用的设计模式。

在 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 关键字的使用可以使用到其父类定义的公共方法

混入 - Mixin

在没有 classextend 关键字之前,我们会使用对象的方式来实现类的功能,而 mixin —— 混入,就是我们实现继承的方式。

Mixin的实现

上面我们说过,继承其实是对父类数据的拷贝,那么 mixin 的实现也很好理解:

  1. 把父类中的数据复制到子类当中 => 继承

  2. 如果子类已经存在相同的 key 的数据,则不拷贝 => 多态

  3. 如果子类需要调用父类的方法,使用 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;
}

对象原型

[[Prototype]]

JavaScript 中对象类型的数据都会有一个 [[prototype]] 的属性,这个属性指向另外一个对象。

在第二篇文章中我们介绍了对象内置默认的 [[Get]] 操作会深入原型链中进行查找,其实就是沿着对象的 [[prototype]] 进行查找,直到 [[prototype]] 不存在为止。

判断一个属性是否存在在一个对象中,我们既可以用 in 来查找,也可以通过 for in 遍历对象数据查找。

for inin 都会深入对象的原型链中进行查找,不同的是 for in 只会输出对象的可枚举属性。

Object.prototype 的责任

所有普通对象的原型链的尽头都会指向内置的 Object.prototype 。由于所有普通对象都会最终指向 Object.prototype,所以 Object.prototype 就承载着存放通用方法的责任,比如 toString()/valueOf()/hasOwnProperty 等。

属性设置与屏蔽

当我们从一个对象中获取某一属性的值时,一旦查找到目标时就返回,即总是返回原型链中最底层的属性值。

所以当我们对一个对象的属性进行设置时,如果该属性已经存在于对象自身,也存在于其原型链上,那么就会发生 属性屏蔽

如果要设置的属性不存在该对象中,那么就会沿着 [[prototype]] 查找,此时会有以下三种情况(以下情况均为在原型链中的判断):

  1. 如果存在该属性,且属性描述符中 writable 的值为 true,那么会直接在当前对象中添加该属性,此时会发生属性屏蔽。(ps:并不会修改原型链中的值)
  2. 如果存在该属性,且属性描述符中的 writable 值为 false,即不允许修改,这时既不会修改原型链中该属性的值,也不会在对象自身上新建属性
  3. 如果存在该属性,且自定义了该属性的 setter,那么就调用这个 setter。该属性不会添加到对象自身。

从第二种情况来看,原型链中,同属性名的属性描述符中 writable: false 时,会阻止下层原型链中继续创建同名属性。

但是这个限制只作用于通过 = 赋值的情况,如果我们使用 Object.definePropery 来操作,就不会存在这个问题。

JavaScript 中的模仿“类”

上面我们说过在 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 的判断会发现, PersonMocha 类均是 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 对象中,我们实现了分别调用属于 PersonFrontend 对象的方法功能。

与传统的类相比,作者把这种编程风格成为 对象关联(objectes linked to other objects)

在上述代码中,我们的 mocha 对象委托了 FrontendPerson 对象;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 中的作用讲解的十分清晰。

碍于个人能力,文章肯定存在理解偏差的地方,不知道这篇文章会被多少人看到,如果你对文章内容有自己的看法或者想和我交流,欢迎随时通过邮件与我联系。

再会。

Powered By Hexo.js Hexo and Minima. Support By Oracle & Docker-Compose.

友情链接: 相随