js 设计模式:面向对象还是基于对象 | 我们真的需要“类“吗?

js 设计模式:面向对象还是基于对象 | 我们真的需要"类"吗?

reference

主要内容参考:
你不知道的 js(上)
极客时间专栏-重学前端
阮一峰 es6
mdn web Docs

前言

最近突发奇想,想把关于对象的一些知识成体系的整理一下,于是记录了这篇博客。但内容确实比较繁杂,所以如果有勘误或者疑问欢迎评论区交流qwq,本人也只是三脚猫功夫

如果你有学过一门面向对象的语言,eg. C++、Java,那你一定对面向对象的核心技术不陌生:

  1. 封装
  2. 继承
  3. 多态

与之相对的,也带来了 class、new、extends 等关键字,用以描述类和对象的关系。

很自然的,我们会认为 js 中既然也提供了类似的关键字,那么就可以按照面向对象的方式来思考、设计我们的代码。大部分时候,我们写出的代码也符合我们的预期。但是,js 中提供的类真的是我们预想中的类吗?

也许我们期望的结果是“是的”,但实际上 js 中的类和我们想象中的类相距甚远。js 中的类是以另一种方式实现的,又或者说只是基于历史因素,对类设计模式的一种拙劣的模仿

虽然随着 es6 支持 class 关键字,我们不用再写一些丑陋的代码来模拟类,但实现的时候也是两个概念

pre- js 中对象的特征

在正式开始之前,我认为有必要回顾一下 js 中对象的概念

Grandy Booch《面向对象分析与设计》中对对象的描述:

  1. 对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象。
  2. 对象有状态:对象具有状态,同一对象可能处于不同状态之下。
  3. 对象具有行为:即对象的状态,可能因为它的行为产生变迁。

对于 1,一般而言,各种语言的对象唯一标识性都是用内存地址来体现的, 对象具有唯一标识的内存地址,所以具有唯一的标识。
js 也不例外:

var o1 = { a: 1 };
var o2 = { a: 1 };
console.log(o1 == o2); // false

对于状态行为,不同语言会使用不同的术语来抽象描述它们,比如 C++ 中称它们为成员变量成员函数,Java 中则称它们为属性方法

而在 js 中,2、3 被统一抽象为属性(函数也是对象,也就是一个普通属性),比如这里的 d 和 f,对 o 来说这就是他的两个普通属性

var o = {
  d: 1,
  f() {
    console.log(this.d);
  },
};

刚才提及了 js 的类也是对象(抛开 class 语法糖,类也是用 function 模拟的),而实际上有一种说法: js 万物皆为对象。

支持这种说法的理由是:

  1. 基本数据类型也是对象:
    基本数据类型可以直接使用对象的方法和属性(包装对象)
  2. 函数也是对象:(的一个子类型/可调用对象)
    函数可以作为参数传递,也可以赋值给变量,也可以作为返回值。也就是所谓的一等公民
  3. 数组也是对象的一种类型,具备一些额外的行为
  4. 甚至还有正则表达式、日期对象、错误对象等

但其实这种说法也是不确切的,对象也只是 6/7 个基础类型之一。

我们会这么认为,或许是因为 js 中的对象具有高度的动态性,而这来源于 JavaScript 赋予了使用者在运行时为对象添改状态和行为的能力。

同时为了提高抽象能力,js 还提供了更抽象的数据属性和访问器属性(getter/setter)

pre- js 中的原型

除了对象以外,我们还需要回顾一下原型

  1. 每个对象都有私有字段 [[prototype]],它指向该对象的原型对象。(Object.create(null)创建的对象,该值为 null)
  2. 读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止。
    (原型链到 Object.prototype 终止,它的值为 null)
let obj = {
  a: 2,
};

let anotherObj = Object.create(obj);

console.log(anotherObj.a); //2

我们在 anotherObj 上显然没有定义 a,所以会继续访问原型,也就是 obj,obj 上有 a,所以返回 2

很自然的,想要测试链式查找,我们可能会写出如下代码:

obj.prototype.b = 3;

console.log(anotherObj.b); //TypeError: Cannot set properties of undefined (setting 'b')

这是什么情况?为什么 obj 没有 prototype 呢?其实是有的。

  1. [[Prototype]]

    • [[Prototype]] 是每个 JavaScript 对象内部的一个隐藏属性,用于指向该对象的原型。这是 JavaScript 中的一个内部机制,通常不可见,不能直接访问。
    • [[Prototype]] 是对象原型的实际连接,用于查找属性和方法。
    • 通过 Object.getPrototypeOf(obj) 可以获取对象的 [[Prototype]]
  2. __proto__

    • __proto__ 是 JavaScript 中的一个非标准的属性,它是一种浏览器提供的方式,用于访问和修改对象的原型。
    • __proto__ 通常可以用来读取和设置对象的原型,但它不是 ECMAScript 标准的一部分。
    • 尽管 __proto__ 在某些情况下可以用于操作原型,但不建议在生产代码中使用,因为它不是标准的,可能在不同的 JavaScript 环境中表现不一致。

我们在创建 obj 对象的时候,实际上创建了一个{}空对象作为 obj 的原型
而刚才的 Object.create,只是把 obj 作为 anotherObj 的原型对象,而 obj 本身并不是一个原型

那么,我们就可以写出如下代码:

let obj = {
  a: 2,
};

let anotherObj = Object.create(obj);

// obj.__proto__.b = 3; //可行,但不推荐

// Object.prototype.b = 3; //可行,通过原型链追溯都能找到

Object.getPrototypeOf(obj).b = 3; //可行,调api拿到obj的prototype再在这上面加属性

console.log(anotherObj.b);

这里只推荐第三种方式。这是获取原型对象的标准方法
而且从原型链查找也是要耗费性能的,自然在最近的地方加是最好的,虽然在这里是一致的
console.log(Object.getPrototypeOf(obj) === Object.prototype); //true

基于类 vs 基于原型

好的,那么现在可以正式进入我们的主题了:

C++、Java 是基于类的编程,提倡使用一个关注分类和类之间关系开发模型。
总是先有类,再从类去实例化一个对象,类与类之间又可能会形成继承、组合等关系。
类将一组行为和属性封装在一起,方便复用

js 是基于原型的编程,提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将它们分成类。
对象通过复制的方式创建新对象
类是对象的一种,它和对象一样,可以拥有属性和方法。(当然你也可以说 js 中方法也是属性的一种,只抽象为属性)

eg:

当我们看到老虎的时候,基于类的描述会使用猫科豹属豹亚种,而基于原型的描述会使用大猫

事实上,js 的机制更适合后者(甚至似乎一直在阻止你使用类设计模式),不过只要你足够了解 js 中的类,这两种方式都是可选的
不过注意,要用类设计模式最好使用 class 语法糖而非手动操作原型和构造函数!

抛开对类的模拟,当我们对大猫进行描述的时候,代码应该是这样的:

var cat = {
  say() {
    console.log("meow~");
  },
  jump() {
    console.log("jump");
  },
};

var tiger = Object.create(cat, {
  //属性描述对象
  say: {
    writable: true,
    configurable: true,
    enumerable: true,
    value: function () {
      console.log("roar!");
    },
  },
});

var anotherCat = Object.create(cat);

anotherCat.say(); //meow~

var anotherTiger = Object.create(tiger);

anotherTiger.say(); //roar!

可以看到,这里并没有的存在,却也实现了抽象和复用。(但这里需要指出,js 中不提倡让方法名相同,来重写,而是建议使用不同的名称,原因后述)

而如果我们要用类来模拟的话,在 es6 之前我们就绕不开混入模式,写出丑陋的显式伪多态/寄生继承的代码。见附录

模拟类设计模式-从 function 开始的模拟之路

如果你不喜欢追根溯源,那么这一章完全可以跳过,因为它真的很丑陋且毫无用处。

在看之前,抛弃大猫,接受猫科豹属豹亚种的想法

模拟-构造函数与 new

类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,被称为构造函数

但我们需要清楚,js 中的 new 只是一个语法糖,它并不是面向类的语言里的 new。

所谓的构造函数,只是让我们原有的函数进行了一个“构造调用”,或者说是在使用 new 操作符时被调用的函数

那么,当我们使用 new 的时候,发生了什么?

  1. 创建一个全新的对象
  2. 这个新对象会被执行[[Prototype]]链接
  3. 这个新对象会绑定到函数调用的 this(new 绑定)
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象
function Foo() {}

var a = new Foo();
console.log(Object.getPrototypeOf(a) === Foo.prototype); //true

这里着重强调的就是这个链接的过程。链接就让两个对象相互关联了起来,以实现类之间的关系

然而还没完,Foo.prototype 还有一招:

function Foo() {}
var a = new Foo();
console.log(a.constructor === Foo); //true

constructor 属性是哪来的?为什么它还是 Foo?

实际上,在代码第一行声明时,Foo.prototype 就存在一个公有且不可枚举的属性:constructor,这个属性引用的是对象关联的函数(Foo)
通过构造函数创建的对象(a),都会自动获取到这个属性,指向构造函数本身

第二句话可能不太好理解,我们打印 a 出来看看

请添加图片描述

其实意思就是,constructor 不是 a 的属性,只不过可以通过 a.constructor 来访问到它罢了,我们通过这种方式拿到的就是 Foo.prototype 上的 constructor,从始至终就只有一个玩意,就和我们前面通过anotherObj.b来访问 b 的原理一样

看看这几句代码,应该能懂“指向构造函数本身”了吧

console.log(a.__proto__.constructor === Foo); //true 为什么用__proto__见上

a 和 constructor 一点关系都没有!a 是通过委托给 prototype 获取的 constructor!

function Foo() {}
Foo.prototype = {};
var a = new Foo();

console.log(a.constructor === Foo); //false
console.log(a.constructor === Object); //true
//如果想fix的话可以再给Foo.prototype添加一个constructor,且enumable:false,这里就不再赘述了

另外还要强调的一点就是刚才提到过的构造调用。调用,new 做完劫持后会(以构造对象的形式)调用这个函数

function NothingSpecial() {
  console.log("Dont mind me!");
}

var a = new NothingSpecial();
//Dont mind me!

好的,终于铺垫完了,其实构造的代码就这么点

function Foo(name) {
  this.name = name;
}

Foo.prototype.getName = function () {
  return this.name;
};

var a = new Foo("a");
var b = new Foo("b");

console.log(a.getName());
console.log(b.getName());

当然,我们可以说 a 和 b 都是 Foo 的实例,并且通过 this 绑定实现了我们预期中的效果

插叙-手写 new 一道经典面试题

function myNew(constructor, ...args) {
  //使用__proto__ 1、2步

  // 创建一个空对象,它将成为新的实例
  const instance = {};

  // 将实例的原型指向构造函数的原型,建立原型链
  if (constructor.prototype !== null) {
    instance.__proto__ = constructor.prototype;
  }

  // 使用Object.create 12步合一起
  // const instance = Object.create(constructor.prototype);

  // 使用构造函数来初始化实例,将构造函数内部的属性和方法应用于实例(绑定this)
  const result = constructor.apply(instance, args);

  // 如果构造函数返回一个对象,那么返回该对象;否则返回实例对象
  return typeof result === "object" ? result : instance;
}

模拟-继承与多态

//Animal类
function Animal(name) {
  this.name = name;
}

//Animal类-speak方法
Animal.prototype.speak = function () {
  console.log(this.name + " makes a noise");
};

Animal.prototype.jump = function () {
  console.log(this.name + " jump");
};

function Cat(name) {
  this.name = name;
}
//Cat与Animal建立父子关系
Cat.prototype = Object.create(Animal.prototype);
//重写父类speak方法
Cat.prototype.speak = function () {
  console.log(this.name + " meow~");
};

var cat1 = new Cat("Tom1");
var cat2 = new Cat("Tom2");
cat1.speak(); //Tom1 meow~
cat2.speak(); //Tom2 meow~
cat1.jump(); //Tom1 jump
cat2.jump(); //Tom2 jump

可以看到,我们成功的模拟了 Animal-Cat-cat1/2 实例对象的关系,并且也实现了 Cat 重写 speak 方法,继承 jump 方法
而且找不出任何毛病

那不出意外的话肯定就要出意外了。

某一天,cat1 突然不想 speak 了,但是它采取了一种错误的修改方式

Object.getPrototypeOf(cat1).speak = function () {
  console.log(this.name + " dont want to meow!");
};

cat1.speak(); //Tom1 dont want to meow!
cat2.speak(); //Tom2 dont want to meow!

由此可见,我们费劲心思的模拟还是有问题的:实例化对象之间,仍通过 prototype 关联着

尽管我们可以通过避免这种错误代码的编写来规避问题,但是本质始终如此

正确代码:

cat1.speak = function () {
  console.log(this.name + " dont want to meow!");
};

还没完,有一天,cat1 想回归本能,想要调用 Animal 的 speak 方法,怎么办?

但是由于在 Cat 中重写了 speak,这个方法已经被覆盖了

那其实最简单的方式,就是不要重写,将 Animal 原型上的 speak 更名为 animalSpeak,这样我们在 cat1 中直接调,就会顺着原型链一路向前,最终找到并调用方法,且不用我们去操作 this 的指向

或者对 this 熟练的话,操作一下 this:Animal.prototype.speak.call(cat1);,这样感觉比较类似 Animal::speak() hh

差别

两种模式最大的差距则是在于复制操作上:

类设计模式的复制,是深拷贝,可以理解为是切实的复制了对象,从此两个对象再无关联

原型设计模式的复制,是浅拷贝,新对象持有一个原型的引用

说白了,无论用不用语法糖,js 的类设计始终是不完善的,你需要在清楚它的运行模式下进行

es6 语法糖-用 class 的方式模拟类

让我们继续刚才的例子,拿最经典的动物-猫狗的例子来说
C++中,我们会先创建 Animal 类,然后再创建 Cat/Dog 类 继承 Animal,并且重写 Animal 类中提供的方法。(多态)

得益于 es6 语法糖,我们可以通过相同的方式来模拟:

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

  speak() {
    console.log(this.name + " makes a noise");
  }

  jump() {
    console.log(this.name + " jump");
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name);
  }

  speak() {
    console.log(this.name + " wang!");
  }
}

class Cat extends Animal {
  constructor(name) {
    super(name);
  }

  speak() {
    console.log(this.name + " meow~");
  }
}

const dog = new Dog("Mitzie");
const cat = new Cat("Tom");

//1 实例化对象调方法
dog.speak();
cat.speak();
dog.jump();
cat.jump();

//2 实例化对象和类的继承关系
console.log(dog instanceof Dog);
console.log(dog instanceof Animal);
console.log(dog instanceof Cat);

//3 子类的原型表现(这个才是js的实现)
console.log(Dog.prototype);
console.log(Cat.prototype);
console.log(Dog.prototype === Cat.prototype);
console.log(cat.jump === dog.jump);

不妨先思考一下这几组console.log的输出,看看有没有不符合预期的地方

答案:

//1 实例化对象调方法
dog.speak(); //Mitzie wang!
cat.speak(); //Tom meow~
dog.jump(); //Mitzie jump
cat.jump(); //Tom jump

//2 实例化对象和类的继承关系
console.log(dog instanceof Dog); //true
console.log(dog instanceof Animal); //true
console.log(dog instanceof Cat); //false

//3 子类的原型表现(这个才是js的实现)
console.log(Dog.prototype); //Animal {}
console.log(Cat.prototype); //Animal {}
console.log(Dog.prototype === Cat.prototype); //false 每个构造函数都会创建自己的原型对象 结合new理解
console.log(cat.jump === dog.jump); //true

对于 1 ,熟悉重写的程序员会觉得很正常。而 js 中是这么描述的:
如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做 overriding

如果想要在 dog 上调 Animal 的 speak 方法,应该怎么做?
C++的话可能会立刻想到Animal::speak()
而对于js,我们只能通过在 Dog 类里新增方法:

animalSpeak(){ super.speak() }

所以,对于父类中可能会用到的方法,我们不使用重写,在子类/父类中直接换一个方法名反而是更简洁的措施。

但首先应该反思一下自己采取的模式是否正确,因为想要同时用到父类和子类的相同方法就更像是委托而非继承

不需要用到父类的方法当然是最好的,为了保持统一性,你想重写也完全没有问题,不过要注意只有ts才支持abstract关键字

而在 Java 和 C++中,方法重写是一种非常重要且被提倡的编程概念,它是面向对象编程的基础之一,用于实现多态性

对于 2,我们使用instanceof判断对象是否为某个构造函数的实例

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof
instanceof运算符的左边是实例对象,右边是构造函数。它会检查右边构建函数的原型对象(prototype),是否在左边对象的原型链上。

对于dog instanceof DogDog.prototype.isPrototypeOf(dog)也是一种等价的写法

另外,
instanceof 运算符通常用于检查对象是否是特定构造函数的实例,而 isPrototypeOf 方法通常用于检查一个对象是否是另一个对象的原型。

通过console.log(dog instanceof Animal); //true,我们也能得知instanceof是遍历整条原型链的。

对于 3,我们研究子类的原型

这里最重要就在于最后一条cat.jump === dog.jump 是为true的。说明 cat 和 dog 共享了同一个 jump 方法!

但上一条Dog.prototype === Cat.prototype // false明明表明了二者的原型是的 Animal 对象,为什么会这样呢?

让我们修改 jump 函数,来测试一下

修改实例化对象上的 jump:

dog.jump = () => {
  console.log("The dog doesn't want to jump anymore");
};

dog.jump(); //The dog doesn't want to jump anymore
cat.jump(); //Tom jump

修改 Animal 原型上的 jump:

Animal.prototype.jump = () => {
  console.log("I dont want to jump anymore!");
};

dog.jump(); //I dont want to jump anymore!
cat.jump(); //I dont want to jump anymore!

而第二种情况在实际开发中,无论是有意还是无意,修改到就会产生的坑。(不过你可以放心的绑定 this,因为 new 绑定的优先级高于显式绑定)

同时,这个例子也说明了:Cat 和 Dog 中的 jump 只是对于同一个函数的不同引用

同样的,class 语法糖没有解决刚才用 function 模拟的时候遇到的问题:实例化对象之间通过 prototype 有联系

const cat2 = new Cat("Tom2");

Object.getPrototypeOf(cat2).speak = function () {
  console.log(this.name + " dont want to meow~");
};

cat.speak(); //Tom dont want to meow~
cat2.speak(); //Tom2 dont want to meow~

我们为什么会理所应当的觉得 jump 函数不同,正是因为被传统类设计思想误导了,认为通过 new 产生的的 Animal 对象里有属于它的方法

再次重申:

js 中的所谓方法根本就不存在:一个类不会拥有一个函数,函数是作为对象的属性存在的,而不是类的一部分。

函数本身就是对象,和对象是并列的兄弟关系,只不过平时被人调用惯了。这也是 js 函数和 Java、C++这类面向对象语言最大的一点不同。

刚才例子中的 jump 函数,同时它也是一个对象。其他引用类型的数据的表现是一样的:复制的新对象拿到的只是引用,修改它会引起别的对象的变化!

另外,为什么在实例化对象上修改不会存在这个问题?根据 prototype 想一下就知道了,答案在后面揭晓^^

js 的 prototype 继承

js 的 prototype 机制,是所有继承的基石。但暴露 prototype,也意味着你随时可能破坏掉继承关系。

但这个破坏又非常讲究了。之前我们也提到过 instanceof,他会向上遍历原型链,判断 instance 的原型链中是否包含 constructor 的原型对象。

让我们继续推进:

Cat.prototype.newFn = () => {
  console.log("这是我在外部新添加的方法!");
};
//有个小细节是如果想拿到this就别用箭头函数
Cat.prototype.speak = function () {
  console.log(this.name, "这是我在外部改变的speak方法!");
};

cat2.newFn(); //这是我在外部新添加的方法!
cat2.speak(); //Test 这是我在外部改变的speak方法!

//理清楚新加的/改变的在哪里
console.log(cat2.hasOwnProperty("newFn")); //false
console.log(Object.getPrototypeOf(cat2).hasOwnProperty("newFn")); //true
console.log(Cat.hasOwnProperty("newFn")); //false
console.log(Cat.prototype === Object.getPrototypeOf(cat2)); //true

//这种改变没有破坏继承关系 实例是子类实例,也是父类实例
console.log(cat2 instanceof Cat); //true
console.log(cat2 instanceof Animal); //true
//当一个类继承自另一个类时,子类的prototype对象会成为父类prototype对象上的一个实例
console.log(Cat.prototype instanceof Animal); //true

好了,现在让我们揭晓刚才问题的答案:我们只需要清楚一点:在 dog/cat 这个对象身上,到底有没有 jump 方法?
(对于 cat/cat2 同理)

答案是没有。我们能访问它是通过他的 prototype 拿到的。那在我们单独对 dog.jump 重新赋值后呢?
实际上,dog 这个实例对象的 jump 方法被我们重写了,之后 dog 就直接拥有了这个属性
而 cat 身上依旧没有,它往上找,一直到 Animal 的 prototype,找到了,于是拿来用

dog.speak = () => {
  console.log("nonnondayo!");
};

console.log("check-dog", dog.hasOwnProperty("speak")); //true

这个讲的也是刚才的那一长串代码。

那其实你也能猜到接下来我要怎么破坏继承关系了

Object.setPrototypeOf(cat2, {});

console.log(Object.getPrototypeOf(cat2) === Object.prototype); //false
console.log(Object.getPrototypeOf(cat2) instanceof Object); //true
console.log(cat2.hasOwnProperty("speak")); //false

console.log(cat2 instanceof Cat); //false
console.log(cat2 instanceof Animal); //false
console.log(Cat.prototype instanceof Animal); //true

但是,这种方式太明显了,我想不会有人看到这个 set 会犯错的

那么如果是这种修改呢?

const cat3 = new Cat("Tom3");
cat3.__proto__ = {};

console.log(cat3 instanceof Cat); //false

Cat.prototype.__proto__ = {};

console.log(Cat.prototype instanceof Animal); //false

回到 pre-2,这样的修改你会中招吗?

提供一个替代方案,而这个方案的道理和第一个例子中继承关系不变的例子是一样的。

如果你学过 vue2,vue2 的响应式原理是基于 defineProperty 的,为了响应式重写了数组方法。
原来的 Array.push 等方法不会改变数组的地址,因此 vue 监测不到数组的变化,为了监测,重写的 push 方法会改变数组的地址,相当于拿了一个新的数组给原数组替换掉,以触发响应式更新

这个例子和我们这里对某个对象的属性动态的做了增删改,而对象的地址是没有变的是一个道理

const cat3 = new Cat("Tom3");
cat3.__proto__.newkey = 123;

console.log(cat3 instanceof Cat); //true

Cat.prototype.__proto__.newkey = 123;

console.log(Cat.prototype instanceof Animal); //true

当然,这些都是坏的代码风格,你应该始终使用 Object.setPrototypeOf()的方式显式破坏继承关系

我们真的需要类吗-委托

终于结束了繁琐的模拟类之旅,让我们回归大猫的思维,用委托的方式来重构代码(当然这也只是一种模式,选择喜欢的风格即可)

首先,抛弃一切的继承关系,转为委托关系

然后,我们不要抽象出类的概念,直接着手于一个现成对象,就拿这只 Katty 猫开始!

现在,我们来抽象一些它的属性,比如 name 和 description,比如一个方法 speak(),这对于我们的猫来说目前已经足够,于是我们可以直接写出如下代码

var Kitty = {
  name: "Kitty",
  description: "A cute cat",
  speak() {
    console.log(this.name + " meow~");
  },
};

Kitty.speak(); //Kitty meow~

但是有一天,我们在路口遇到了另外一只猫 Tom,我们发现这只猫和我们有的这只 Katty 猫有些相似,也有很多地方不一样,例如他不会说 Meow,而是会说 aowwww,他也并不可爱,我们需要用新的描述词来为他描述,同时,他还会很多技能。

var Tom = {
  name: "Tom",
  description: "A handsome cat",
  skills: ["run", "jump", "fun"],
  speak() {
    console.log(this.name + " aowwwwww!");
  },
};

Tom.speak(); //Tom aowwwwww!

而此时,我们发现,这两只猫都会 speak,并且都有 name 和 description,那么我们就可以通过委托的方式,把他们公有的属性委托给一个新的对象托管,然后在这两只猫分别要做什么事情的时候,它不直接调用自己的方法,而是去找到这个委托对象,然后调用这个委托对象的方法。

var myFriends = {
  setName: function (name) {
    this.name = name;
  },
  setDescription: function (des) {
    this.description = des;
  },
  doSpeak: function (text) {
    console.log(this.name + " " + text);
  },
};

var Kitty = Object.create(myFriends);

Kitty.init = function (name, des) {
  this.setName(name);
  this.setDescription(des);
};

Kitty.speak = function (text) {
  this.doSpeak(text);
};

Kitty.init("Kitty", "A cute cat");
Kitty.speak("meow~"); //Kitty meow~
console.log(Kitty); //{name: 'Kitty', description: 'A cute cat', init: ƒ, speak: ƒ}

var Tom = Object.create(myFriends);

Tom.skills = ["run", "jump", "fun"];

Tom.init = function () {
  this.setName("Tom");
  this.setDescription("A handsome cat");
};

Tom.speak = function () {
  this.doSpeak("aowwwwww!");
};

Tom.init();
Tom.speak(); //Tom aowwwwww!
console.log(Tom); //skills: Array(3), name: 'Tom', description: 'A handsome cat', init: ƒ, speak: ƒ}

这就是基于委托的一个 demo。现在我的这两个朋友就可以通过 prototype 追溯到 myFriends 对象的方式,去设置他们的名字、描述和说话了。

但是你或许会觉得后续的 init 和 speak 很奇怪,而且感觉凭空复杂了好多。那其实 init 和 speak 只是我对于他们行为的描述,你完全可以直接通过 Tom 调 setName 方法等,都是可以的,这里只是为了表示灵活性,还有我喜欢这样写而已。然后注意 doSpeak 和 speak 的设计,委托倡导的就是不重名!

但是这个例子还没有完,要体现委托,我们还有事情要做

经过一段时间之后,我的朋友扩充了很多,而有的朋友非常好啊,愿意提供他的能力并且免费为我打工。

刚好 Tom 遇到了某些事情,想要借我这个会 dig 的朋友一用。我就可以直接让他去找我的朋友了

同时我也愿意提供场所,让我的朋友们一起 happy

var myFriends = {
  setName: function (name) {
    this.name = name;
  },
  setDescription: function (des) {
    this.description = des;
  },
  doSpeak: function (text) {
    console.log(this.name + " " + text);
  },

  //新增
  dig: function () {
    console.log("A good friend is helping " + this.name + " dig!");
  },
  play: function (...args) {
    args.forEach((item) => {
      console.log(item.name + " ");
    });
    console.log("lets have fun!");
  },
};

var Kitty = Object.create(myFriends);

Kitty.init = function (name, des) {
  this.setName(name);
  this.setDescription(des);
};

Kitty.speak = function (text) {
  this.doSpeak(text);
};

Kitty.init("Kitty", "A cute cat");

var Tom = Object.create(myFriends);

Tom.skills = ["run", "jump", "fun"];

Tom.init = function () {
  this.setName("Tom");
  this.setDescription("A handsome cat");
};

Tom.speak = function () {
  this.doSpeak("aowwwwww!");
};

Tom.init();

Tom.dig(); //A good friend is helping Tom dig!

var me = Object.create(myFriends);

me.host = function (...args) {
  this.play(...args);
};

me.host(Tom, Kitty);
//Tom
//Kitty
//lets have fun!

如果你愿意,这个故事可以一直延续下去。

我希望你也能接受这种风格的代码,而且这更贴合 js 的 prototype 机制

委托行为意味着某些对象在找不到属性或者方法引用时,会把这个请求委托给另一个对象

附录-其他模仿类方式

显式伪多态

// 非常简单的mixin(..)例子:
function mixin(sourceObj, targetObj) {
  for (var key in sourceObj) {
    // 只会在不存在的情况下复制
    if (!(key in targetObj)) {
      targetObj[key] = sourceObj[key];
    }
  }

  return targetObj;
}

var Vehicle = {
  engines: 1,
  ignition: function () {
    console.log("Turning on my engine.");
  },

  drive: function () {
    this.ignition();
    console.log("Steering and moving forward! ");
  },
};

var Car = mixin(Vehicle, {
  wheels: 4,
  drive: function () {
    //this改到子级,也就是兼具父子属性的Car上,虽然这个例子看不出效果,可以在ignition处log一下this
    Vehicle.drive.call(this);
    console.log("Rolling on all " + this.wheels + " wheels! ");
  },
});

Car.drive();

这里主要可以说明一点,如果不用重写,我们混入就完全不用操作这个复杂的 this,因此推荐委托模式不要用重写!
同时这种方式也会共享引用类型的数据

寄生继承

// “传统的 JavaScript 类”Vehicle
function Vehicle() {
  this.engines = 1;
}
Vehicle.prototype.ignition = function () {
  console.log("Turning on my engine.");
};
Vehicle.prototype.drive = function () {
  this.ignition();
  console.log("Steering and moving forward! ");
};

// “寄生类” Car
function Car() {
  // 首先,car 是一个 Vehicle
  // 寄生的体现也在于这个new调用的位置
  var car = new Vehicle();

  // 接着我们对car进行定制
  car.wheels = 4;

  // 保存到Vehicle::drive()的特殊引用
  var vehDrive = car.drive;

  // 重写Vehicle::drive()
  car.drive = function () {
    vehDrive.call(this);
    console.log("Rolling on all " + this.wheels + " wheels! ");
  };
  return car;
}

var myCar = new Car();

myCar.drive();

隐式混入

var Something = {
  cool: function () {
    this.greeting = "Hello World";
    this.count = this.count ? this.count + 1 : 1;
    this.obj = {
      val: this.count ? this.count + 1 : 1,
    };
    console.log("计数器被调用了一次");
  },
};

Something.cool();
console.log(Something.greeting);
// "Hello World"
console.log(Something.count); // 1
var Another = {
  cool: function () {
    console.log("this", this);
    // 隐式把Something混入Another
    Something.cool.call(this);
  },
};

Another.cool();
console.log(Another.greeting);
// "Hello World"
console.log(Another.count); // 1(count不是共享状态)
console.log(Another.obj.val); //2 对象共享状态

不共享状态的原因很简单:count 是基本类型的数字
加了一个 obj 进去则可以共享

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sayoriqwq

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值