深刻理解js中的原型

440 阅读6分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第11天,点击查看活动详情

理解原型编程范式

作为JavaScript的开发者,都应该知道原型是 JavaScript 这门语言面向对象系统的根本。但在其它语言,比如 JAVA 中,类才是它面向对象系统的根本。

类是对一类实体的结构、行为的抽象。在基于类的面向对象语言中,我们首先关注的是抽象 —— 我们需要先把具备通用性的类给设计出来,才能用这个类去实例化一个对象,进而关注到具体层面的东西

在JS这样的原型语言中,我们首先需要关注的就是具体 —— 具体的每一个实例的行为。根据不同实例的行为特性,我们把相似的实例关联到一个原型对象里去 —— 在这个被关联的原型对象里,就囊括了那些较为通用的行为和属性。基于此原型的实例,都能复制它的能力。

在原型编程范式中,我们正是通过复制来创建新对象。但这个复制未必一定要开辟新的内存、把原型对象照着再实现一遍 —— 我们复制的是能力,而不必是实体。比如在 JS 中,我们就是通过使新对象保持对原型对象的引用来做到了复制

JavaScript 中的"类"

ECMAScript 2015 中引入的 JavaScript 类实质上是 JavaScript 现有的基于原型的继承的语法糖。类语法不会为 JavaScript 引入新的面向对象的继承模型。

当我们尝试用 class 去定义一个 Dog 类时:

class Dog {
  constructor(name ,age) {
   this.name = name
   this.age = age
  }
  
  eat() {
    console.log('肉')
  }
}

其实完全等价于写了这么一个构造函数,你可以用babel进行编译下就知道了:

function Dog(name, age) {
  this.name = name
  this.age = age
}
Dog.prototype.eat = function() {
  console.log('肉')
}

所以说,JS 以原型作为其面向对象系统基石的本质并没有被改变。

理解原型与原型链

原型

在 JavaScript 中,每个构造函数都拥有一个 prototype 属性,它指向构造函数的原型对象,这个原型对象中有一个 construtor 属性指回构造函数;每个实例都有一个__proto__属性,当我们使用构造函数去创建实例时,实例的__proto__属性就会指向构造函数的原型对象。(这段话非常重要)

具体来说,当我们这样使用构造函数创建一个对象时:

// 创建一个Dog构造函数
function Dog(name, age) {
  this.name = name
  this.age = age
}
Dog.prototype.eat = function() {
  console.log('肉')
}
// 使用Dog构造函数创建dog实例
const dog = new Dog('旺财', 1)

这段代码里的几个实体之间就存在着这样的关系:

js30.jpg

原型链

在上面那段代码的基础上,进行两个方法调用:

// 输出"肉骨头真好吃"
dog.eat()
// 输出"[object Object]"
dog.toString()

明明没有在dog实例里手动定义 eat 方法和 toString 方法,它们还是被成功地调用了。这是因为当我试图访问一个 JavaScript 实例的属性/方法时,它首先搜索这个实例本身;当发现实例没有定义对应的属性/方法时,它会转而去搜索实例的原型对象;如果原型对象中也搜索不到,它就去搜索原型对象的原型对象,这个搜索的轨迹,就叫做原型链

以eat 方法和 toString 方法的调用过程为例,它的搜索过程就是这样子的:

js31.jpg

这些彼此相连的 prototype,就构成了所谓的 “原型链”。

几乎所有 JavaScript 中的对象都是位于原型链顶端的 Object 的实例,除了 Object.prototype。当然,如果我们手动用 Object.create(null) 创建一个没有任何原型的对象,那它也不是 Object 的实例。

下面用动物(父类)和狗(子类)的例子来演示,让 Dog 继承 Animal 的属性和方法:

// 1. 定义父类:动物构造函数
function Animal(name) {
  // 实例属性(每个实例独有)
  this.name = name;
  // 实例方法(不推荐这么定义,会导致每个实例都创建一份,浪费内存)
  this.eat = function() {
    console.log(`${this.name}正在吃东西`);
  };
}

// 父类原型上的方法(所有实例共享) 推荐
Animal.prototype.run = function() {
  console.log(`${this.name}正在奔跑`);
};

// 2. 定义子类:狗构造函数
function Dog(name, breed) {
  // 继承父类的实例属性(手动传参给父类)
  Animal.call(this, name);
  // 子类独有的实例属性
  this.breed = breed;
}

// 3. 核心:设置原型链,让Dog继承Animal的原型方法
// 将Dog的原型指向Animal的实例(关键步骤)
Dog.prototype = new Animal();
// 修正子类原型的constructor指向(否则Dog.prototype.constructor会指向Animal)
Dog.prototype.constructor = Dog;

// 4. 子类原型上扩展独有的方法
Dog.prototype.bark = function() {
  console.log(`${this.name}${this.breed})正在汪汪叫`);
};

// 5. 测试继承效果
// 创建Dog实例
const husky = new Dog('旺财', '哈士奇');

// 访问父类实例属性
console.log(husky.name); // 输出:旺财
// 访问子类实例属性
console.log(husky.breed); // 输出:哈士奇

// 调用父类实例方法
husky.eat(); // 输出:旺财正在吃东西
// 调用父类原型方法
husky.run(); // 输出:旺财正在奔跑
// 调用子类原型方法
husky.bark(); // 输出:旺财(哈士奇)正在汪汪叫

// 验证原型链关系
console.log(husky instanceof Dog); // true
console.log(husky instanceof Animal); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // true

构造函数的工作机理

当我们用 new 去创建一个实例时,new 做了什么?它做了这四件事:

  • 为这个新的对象开辟一块属于它的内存空间
  • 把函数体内的 this 指到 1 中开辟的内存空间去
  • 将新对象的_proto_这个属性指向对应构造函数的prototype属性,把实例和原型对象关联起来
  • 执行函数体内的逻辑,最后即便你没有手动return,构造函数也会帮你把创建的这个新对象 return 出来

自有属性与原型继承属性

function A() {
    this.name = 'a'
    this.color = ['green', 'yellow']
 }
 function B() {
   
 }
 B.prototype = new A()
 var b1 = new B()
 var b2 = new B()
 
 b1.name = 'change'
 b1.color.push('black')

console.log(b2.name) // 'a'
console.log(b2.color) // ["green", "yellow", "black"]

读操作与写操作的区别

b1.name = 'change'

很多人认为,在查找 b1 的 name 属性时,难道不应该沿着原型链去找,然后定位并修改原型链上的 name 吗?

实际上,这个"逆流而上"的变量定位过程,当且仅当我们在进行"读"操作时会发生。

上面的这行代码,是一个赋值动作,是一个"写"操作。在写属性的过程中,如果发现 name 这个属性在 b1 上还没有,那么就会原地为 b1 创建这个新属性,而不会去打扰原型链了。

那么 color 这个属性,看上去也像是一个"写"操作,为什么它没有给 b2 新增属性、而是去修改了原型链上的 color 呢?首先,这样的写法:

b1.color.push('black')

它实际上并没有改变对象的引用,而仅仅是在原有对象的基础上修改了它的内容而已。像这种不触发引用指向改变的操作,它走的就是 原型链 查询 + 修改 的流程,而非原地创建新属性的流程。

如何把它变成写操作呢?直接赋值:

b1.color = ['newColor']

这样一来,color 就会变成 b1 的一个自有属性了。 因为 ['newColor'] 是一个全新的数组,它对应着一个全新的引用。对 js 来说,这才是真正地在向 b1"写入"一个新的属性。