【译】javascript-深入理解原型链

前段时间在dev上看到一篇关于 原型链的文章,层层递进讲述了原型链的原理和使用方法,鄙人觉得很受用,所以想分享一下给大家。
原链接:dev
作者:Sagiv ben giat
翻译:潘小安
)

首先学习原型链继承的概念

在这篇文章中我们将要学习JavaScript的原型链。我们将会了解对象之间是如何进行连接,怎样去实现“继承”以及对象之间的关系。

我们的目标

作为开发者,在编写代码的时,我们的主要任务通常都是就是操作数。我们拿到数据然后把它存起来,再用这些数据来运行函数。
假如把函数和相关数据放到同一个地方是不是更好呢?那样对我们开发者来说,处理函数和相关数据会显得更简单。
想象一个名为 Player 的对象:

{
  userName: 'sag1v',
  score: '700'
}

如果我们想使用这个对象去运行一个方法,比如改变里面的 score 参数,我们应该把 setScore 方法放哪呢?

对象

我们想存储一些相互之间有关联的数据的时候,我们通常使用对象。把对象当做是一个盒子,然后往这个盒子里放入所有相关的数据。在我们深入之前,首先让我们来了解一下什么是对象并探索一下创建对象的方法。

字面量创建法

const player1 = {
  userName: 'sag1v',
  score: '700',
  setScore(newScore){
   player1.score = newScore;
  }
}

字面量创建对象是一个表达式,每当执行出现在其中的语句的时候,每个对象那个初始化器都会创建一个新对象。
我们也可以通过点表示法和括号表示法来给对象赋值

const player1 = {
  name: 'Sagiv',
}

player1.userName = 'sag1v';
player1['score'] = 700;
player1.setScore = function(newScore) {
  player1.score = newScore;
}

Object.create

另一种创建对象的方法是使用 Object.create 方法:

const player1 = Object.create(null)
player1.userName = 'sag1v';
player1['score'] = 700;
player1.setScore = function(newScore) {
  player1.score = newScore;
}

当我们传一个null作为该方法的参数的时候,Object.create方法会返回一个空对象。但是当我们一个其他不同对象的时候我们会得到一些附加特性,这个我们稍后讨论。

自动化

很显然,我们不想每次都这样手动创建对象,我们可能想要一个东西自动执行操作。所以让我们写一个可以帮我们创建Player对象的方法。

工厂方法

function createPlayer(userName, score) {
  const newPlayer = {
    userName,
    score,
    setScore(newScore) {
      newPlayer.score = newScore;
    }
  }
  return newPlayer;
}

const player1 = createPlayer('sag1v', 700);

这种创建对象的方法通常被称作工厂模式,就像在工厂中输出对象的传送带一样,我们传入相关参数得到我们需要的对象。
如果我们调用两次这个方法呢?

function createPlayer(userName, score) {
  const newPlayer = {
    userName,
    score,
    setScore(newScore) {
      newPlayer.score = newScore;
    }
  }
  return newPlayer;
}

const player1 = createPlayer('sag1v', 700);
const player2 = createPlayer('sarah', 900);

我们会得到下面这两个对象:

{
  userName: 'sag1v',
  score: 700,
  setScore: ƒ
}

{
  userName: 'sarah',
  score: 900,
  setScore: ƒ
}

你注意到一些重复的地方了吗?每一个实例中都有setScore方法,这个违反了 D.R.Y(Don't Repeat Yourself 不要重复自己)的原则。
如果我们把这个方法放在其他地方呢,只放一次,但是仍可以在每个实例中可以访问到它。

对象之间的连接

让我们重新回到Object.create,我们谈到这个方法通常只会返回一个空对象,但是当我们传个对象进去之后,可以得到一些附加特性。

const playerFunctions = {
 setScore(newScore) {
   this.score = newScore;
 }
}

function createPlayer(userName, score) {
 const newPlayer = Object.create(playerFunctions);
 newPlayer.userName = userName;
 newPlayer.score = score;
 return newPlayer;
}

const player1 = createPlayer('sag1v', 700);
const player2 = createPlayer('sarah', 900);

这段代码和我们之前的工厂模式代码一样有效,但是有一点不同的是,我们新的对象实例并没有各自拥有 setScore方法,它们通过playerFunctions来连接它。
原来,js中所有的对象都有一个特殊的隐藏属性,名为_proto_。如果这个属性指向一个对象,那么引擎就会把该对象的属性视为在实例本身。换句话说,每个对象都可以通过_ptoto_来连接另一个对象,并且拿到另一个对象的值,就像是拿自己对象的值一样。
注意
不要把 _proto_prototype 弄混淆了,prototype 是一个只存在function中的属性。_proto_ 是一个只存在对象中的属性。在EcmaScript 规范中,
_proto_ 属性被称为 [[Prototype]],反而更容易混淆~
我们后续还会讨论这个
我们把刚才的代码再执行一遍再打印更直观的观察结果:

const playerFunctions = {
  setScore(newScore) {
    this.score = newScore;
  }
}

function createPlayer(userName, score) {
  const newPlayer = Object.create(playerFunctions);
  newPlayer.userName = userName;
  newPlayer.score = score;
  return newPlayer;
}

const player1 = createPlayer('sag1v', 700);
const player2 = createPlayer('sarah', 900);

console.log(player1)
console.log(player2)

输出结果如下:

player1: {
  userName: 'sag1v',
  score: 700,
  __proto__: playerFunctions
}

player2: {
  userName: 'sarah',
  score: 900,
  __proto__: playerFunctions
}

这就意味着,player1player2都可以拿到playerFunctions,也就是说它们都可以运行setScore方法:

player1.setScore(1000);
player2.setScore(2000);

我们达到了我们的目的,我们有了包含数据和其相关的方法的对象,并且没有破坏DRY原则。
但是我们单单为了创建互相关联的对象,做了太多的工作了:

  1. 我们需要创建一个对象
  2. 我们需要创建一个不同的对象来存放我们的函数
  3. 我们必须使用Object.create来连接 _proto_属性和含有目标方法的对象
  4. 我们需要用属性填充对象
  5. 我们需要返回一个新对象

如果上述步骤已经有东西替我们做了呢?

new操作符-构造函数

在前面的示例中,我们看到需要执行一些“任务”才能在工厂函数中创建关联对象。 在js中,我们仅仅需要用new操作符和函数一起使用就能替代之前的一些工作。
在我们看这章之前,我们需要确保在对待函数的认知在同一水平线上。

函数的真面目是什么?

function double(num) {
    return num * 2;
}

double.someProp = 'Hi there!';

double(5); // 10
double.someProp // Hi there!

double.prototype // {}

我们都知道函数是啥对吗?我们可以声明它,可以用( )的形式调用它。但是我们观察上面的代码,我们也可以在上面进行属性的读写,就像我们在对象上做的事情一样。所以我的结论就是:JavaScript中的函数不仅仅是一个函数,它们是一种"函数&&对象"的结合体。基本上每个函数都可以被调用并且可以当一个对象对待。

prototype属性

事实证明,所有的函数(箭头函数除外)都有一个.prototype属性。
是的,再提醒一次:

不是 _proto_ 或者 [[ Prototype]],而是 prototype。

现在让我们回到new操作符。

使用new操作符调用函数

这是我们的函数在使用 new 运算符时的样子:

⚠️如果你不是100%确定你了解this的运行机制,你可能会想阅读我的另一篇文章 JavaScript - The "this" key word in depth

function Player(userName, score){
  this.userName = userName;
  this.score = score;
}

Player.prototype.setScore = function(newScore){
  this.score = newScore;
}

const player1 = new Player('sag1v', 700);
const player2 = new Player('sarah', 900);

console.log(player1)
console.log(player2)

这段代码会输出:

Player {
  userName: "sag1v",
  score: 700,
  __proto__: Player.prototype
}

Player {
  userName: "sarah",
  score: 900,
  __proto__: Player.prototype
}
让我们过一遍这个代码

我们对Player方法使用了new操作符,注意到这里我把方法名从createPlayerPlayer,仅仅是因为这个是开发者的共识。这个是为了像函数的调用者发出一个这个函数是一个 “构造函数” 的信号,需要用new操作符调用。
当我们使用new操作符调用函数的时候,JavaScript会帮我们做下面这四件事:

  1. 创建一个空对象。
  2. 将新对象那个分配给this上下文。
  3. 把新对象的_proto_属性和目标函数的prototype属性连接起来,在我们例子中就是Player.prototype
  4. 返回新对象,除非你自己返回一个不同的对象。

如果我们要用js来完成上述步骤,它可能看起来像以下片段:

function Player(userName, score){
  this = {} // ⚠️ done by JavaScript
  this.__proto__ = Player.prototype // ⚠️ done by JavaScript

  this.userName = userName;
  this.score = score;

  return this // ⚠️ done by JavaScript
}

让我们来看第三步:

它会把新对象的 proto属性和那个被调用的函数的 prototype属性连接起来

意味着我们可以把任何方法放到Player.prototype,所有通过Player创建的新实例就都可以访问它。
事实就是如此,我们也的确是这样做的:

Player.prototype.setScore = function(newScore){
  this.score = newScore;
}

这就是我们如何通过构造函数创建连接其他对象的对象。

另外,如果我们不用new操作符而是直接调用构造函数,JavaScript就不会帮我们做上面的四步骤,我们将最终在this上下文上进行更改或创建一些属性。 记住这个功能,我们在讲子类的时候会用到这个小技巧。
这里有一些确保构造函数被new操作符调用的方法:

function Player(username, score){

  if(!(this instanceof Player)){
    throw new Error('Player must be called with new')
  }

  // ES2015 syntax
  if(!new.target){
    throw new Error('Player must be called with new')
  }
}

再提醒一下,想要深入了解this关键字的可以看JavaScript - The "this" key word in depth.

Class

如果你不喜欢手写工厂函数或者你不喜欢用构造函数语法或者不喜欢手动检查函数是否被new操作符调用,JavaScript也提供了一个叫class的东西(从ES2015开始)。但是请记住,js中的这些class是函数的语法糖,和其他语言中的传统类有很大的不一样,我们其实仍然在使用"原型继承"
引用 MDN:

JavaScript classes, introduced in ECMAScript 2015, are primarily syntactical sugar over JavaScript's existing prototype-based inheritance. The class syntax does not introduce a new object-oriented inheritance model to JavaScript.
(在ECMAScript2015中介绍的class主要是基于已经存在的原型链继承的语法糖。class语法并不会向js引入新的免洗那个对象的继承模型)

让我们一步步把我们的“构造函数”变成一个class:

声明一个class

我们使用class关键字并像在面部分我们命名构造函数那样去命名我们的class(首字母大写)

class Player {

}

创建一个构造函数

我们将从上一节中获取构造函数的主体,并为此类创建一个构造函数方法:

class Player {
  constructor(userName, score) {
    this.userName = userName;
    this.score = score;
  }
}

向class中添加方法

每个我们想放在Player.prototype可以简单地在class内部声明

class Player {
  constructor(userName, score) {
    this.userName = userName;
    this.score = score;
  }

  setScore(newScore) {
    this.score = newScore;
  }
}

现在整个代码看起来是这个样子的

class Player {
  constructor(userName, score) {
    this.userName = userName;
    this.score = score;
  }

  setScore(newScore) {
    this.score = newScore;
  }
}

const player1 = new Player('sag1v', 700);
const player2 = new Player('sarah', 900);

console.log(player1)
console.log(player2)

当我们运行代码之后,我们得到了和之前相同的输出结果:

Player {
  userName: "sag1v",
  score: 700,
  __proto__: Player.prototype
}

Player {
  userName: "sarah",
  score: 900,
  __proto__: Player.prototype
}

正如你们所见,如您所见,class的工作方式与行为与带有原型链的函数相同,只是语法不同。 同时class还添加了内置的检查机制来检查class是否被new方法调用。

子类-继承

如果我们想要一个比较特殊的Player,比如我们有一个氪金玩家,他就需要比普通玩家多些特权,比如免费改名字~
所以,下面是我们的目标

  • 我们希望一个普通玩家有一个userName,一个score还有一个setScore的方法。
  • 我们也需要一个氪金玩家,拥有普通玩家所有的权限的同时,还多有一个setUserName的方法。

在我们深入之前,先想想有关联对象的原型链
思考一下下面的代码:

function double(num){
    return num * 2;
}

double.toString() // 这个方法哪里来的?

Function.prototype // {toString: f, call: f, bind: f}

double.hasOwnProperty('name') // 这个方法哪里来的??

Function.prototype.__proto__ // -> Object.prototype {hasOwnProperty: f}

我们已经知道,如果一个属性在对象本身没有找到,引擎会通过_proto_属性在和这个对象链接的对象上查找(如果存在的话)。但是如果这个属性也没有在和它直接关联的对象上呢?很显然我们知道,所有的对象都有_proto_属性,所以引擎会继续通过_proto_属性向上寻找,还没找到的话就继续找,直到找到死胡同,也就是null,也就是Object.prototype._proto_

所以我们逐步的去看代码

double.toString()

1.double没有toString方法 ✖️。

  1. 去找double._proto_
  2. double._proto_指向Function.prototype,Function.prototype中有toString方法 ✔️
double.hasOwnProperty('name')
  1. double没有hasOwnProperty 方法 ✖️。
  2. 去找double._proto_
  3. double._proto_指向Function.prototype
  4. Function.prototype中没有toString方法 ✖️。
  5. Function.prototype._proto_瞅瞅。
  6. Function.prototype._proto_指向Object.prototype
  7. Object.prototype里面有hasOwnProperty方法。

下面有一个展示这个过程的小动画

现在会到我们之前为氪金玩家打造特权的事儿。我们会把之前所有的流程走一遍,用“OLOO(对象关联)””模式,"构造函数"模式,还有class来实现继承,这样我们就可以看到每种方法的优缺点。

OLOO(对象关联)

首先是使用对象关联和工厂函数实现我们的目标

const playerFunctions = {
  setScore(newScore) {
    this.score = newScore;
  }
}

function createPlayer(userName, score) {
  const newPlayer = Object.create(playerFunctions);
  newPlayer.userName = userName;
  newPlayer.score = score;
  return newPlayer;
}

const paidPlayerFunctions = {
  setUserName(newName) {
    this.userName = newName;
  }
}

// link paidPlayerFunctions object to createPlayer object
Object.setPrototypeOf(paidPlayerFunctions, playerFunctions);

function createPaidPlayer(userName, score, balance) {
  const paidPlayer = createPlayer(name, score);
  // we need to change the pointer here
  Object.setPrototypeOf(paidPlayer, paidPlayerFunctions);
  paidPlayer.balance = balance;
  return paidPlayer
}

const player1 = createPlayer('sag1v', 700);
const paidPlayer = createPaidPlayer('sag1v', 700, 5);

console.log(player1)
console.log(paidPlayer)

我们可以得到:

player1 {
  userName: "sag1v",
  score: 700,
  __proto__: playerFunctions {
     setScore: ƒ
  }
}

paidPlayer {
  userName: "sarah",
  score: 900,
  balance: 5,
  __proto__: paidPlayerFunctions {
    setUserName: ƒ,
    __proto__: playerFunctions {
      setScore: ƒ
    }
  }
}

如你所见,我们的createPlayer的实现方法并没有变,但是createPaidPlayer方法我们需要讲几个知识点。
createPaidPlayer中,我们使用createPlayer来创建初始的新对象,因此我们无需重复创建新Player的逻辑,但是不幸的是,它将__proto__链接到错误的对象,所以我们需要用Object.setPrototypeOf来修复它。我们传一个目标对象(新创建的需要修复_proto_指向的对象)和一个正确的想让新对象指向的对象,也就是例子中的paidPlayerFunctions
事情还没结束,因为我们把对playerFunction对象的链接中断了,playerFunction中有我们需要的setScore方法。所以我们需要把paidPlayerFunctions playerFunctions方法链接起来,同样再使用Object.setPrototypeOf。这样我们就可以确保paidPlayer链接到paidPlayerFunctions 然后再链接到playerFunctions

构造函数

现在我们看下怎么用构造函数来同样实现这些东西

function Player(userName, score) {
  this.userName = userName;
  this.score = score;
}

Player.prototype.setScore = function(newScore) {
  this.score = newScore;
}

function PaidPlayer(userName, score, balance) {
  this.balance = balance;
  /* 我们这里没有用new操作符去调用Player,而是用call方法
  这使我们可以显式传递“ this”的引用。
   现在,“ Player”功能将改变“ this”
   并为其填充相关属性
*/
  Player.call(this, userName, score);
}

PaidPlayer.prototype.setUserName = function(newName) {
  this.userName = newName;
}

// link PaidPlayer.prototype object to Player.prototype object
Object.setPrototypeOf(PaidPlayer.prototype, Player.prototype);


const player1 = new Player('sag1v', 700);
const paidPlayer = new PaidPlayer('sarah', 900, 5);

console.log(player1)
console.log(paidPlayer)

然后我们应该拿到之前相似的结果:

Player {
  userName: "sag1v",
  score: 700,
  __proto__: Player.prototype {
    setScore: ƒ
  }
}

PaidPlayer {
  userName: "sarah",
  score: 900,
  balance: 5,
  __proto__: PaidPlayer.prototype:{
    setUserName: ƒ,
    __proto__: Player.prototype {
      setScore: ƒ
    }
  }
}

实际上,这与我们使用工厂函数模式所获得的结果相同,不同的是这次有new操作符帮我们自动化了一些在操作。 它可能为我们节省了一些代码行,但确实带来了其他挑战。
我们的第一个挑战是如何使用Player函数完成对氪金玩家实例的初始化。我们没有使用new操作符而是使用.call方法来让我们传递this引用,这样Player函数就不会作为构造函数运行,也不会创建新对象并把this分配给新对象。

function PaidPlayer(userName, score, balance) {
  this.balance = balance;
  /* we are calling "Player" without the "new" operator
  but we use the "call" method,
  which allows us to explicitly pass a ref for "this".
  Now the "Player" function will mutate "this"
  and will populate it with the relevant properties */
  Player.call(this, userName, score);
}

我们只是用Playerthis中改变我们传递的参数,这里的this基本上是指在PaidPlayer中创建的新实例。
另一个挑战就是连接PaidPlayer返回的实例和Player实例才有的函数(setScore),我们用Object.setPrototypeOf做到了,把PaidPlayer.prototype连接到了Player.prototype上。

Object.setPrototypeOf(PaidPlayer.prototype, Player.prototype);

如你所见,引擎帮我们做的事越多,我们的代码量就越少,但是随着抽象数量的增加,我们很难跟踪引擎盖下正在发生的事情。

Class

使用Class我们变得更加更加的抽象,也意味着代码也更少

class Player {
  constructor(userName, score) {
    this.userName = userName;
    this.score = score;
  }

  setScore(newScore) {
    this.score = newScore;
  }
}

class PaidPlayer extends Player {
  constructor(userName, score, balance) {
    super(userName, score);
    this.balance = balance;
  }

  setUserName(newName) {
    this.userName = newName;
  }
}



const player1 = new Player('sag1v', 700);
const paidPlayer = new PaidPlayer('sarah', 900, 5);

console.log(player1)
console.log(paidPlayer)

然后我们得到和构造函数一样相同的输出:

Player {
  userName: "sag1v",
  score: 700,
  __proto__: Player.prototype {
    setScore: ƒ
  }
}

PaidPlayer {
  userName: "sarah",
  score: 900,
  balance: 5,
  __proto__: PaidPlayer.prototype:{
    setUserName: ƒ,
    __proto__: Player.prototype {
      setScore: ƒ
    }
  }
}

所以,class也没啥,也就是一个封装在构造函数上的语法糖。
记住这句话

JavaScript classes, introduced in ECMAScript 2015, are primarily syntactical sugar over JavaScript’s existing prototype-based inheritance...
ECMAScript 2015中引入的JavaScript类主要是语法上的糖,它覆盖了JavaScript现有的基于原型的继承...

是的,但是!
当我们用extends关键字的时候,为什么我们要用supper方法?

记住“构造函数”部分中的这一行:

Player.call(this, userName, score)

super(userName, score)的作用就和这一样一样的~
好吧,如果我们想在这里更加准确地进行说明,它使用了ES2015引入的一项新特性:Reflect.construct.
引用文档中的话来说

The static Reflect.construct() method acts like the new operator, but as a function. It is equivalent to calling new target(...args). It gives also the added option to specify a different prototype.
Reflect.construct方法功能和 new操作符差不多,不同的是它是一个方法。它等效于调用
new Target(...args),它还提供了额外的入参,可以让你选择不同的原型。

所以我们不需要深入构造方法去摸索来了解super的作用,我们只要知道super是通过Reflect.construct实现的就好。另外一点很重要的是,当我们extend了一个class,在我们用super()之前我们不能用this,因为这时候我们的this还没有初始化。

class PaidPlayer extends Player {
  constructor(userName, score, balance) {
    // "this" 还没初始化...
    // super 在这里指代Player
    super(userName, score);
    // super底层是由Rflect。construct实现的

    // this = Reflect.construct(Player, [userName, score], PaidPlayer);

    this.balance = balance;
  }

  setUserName(newName) {
    this.userName = newName;
  }
}

总结

我们学习了链接对象,附加数据和逻辑并将其捆绑在一起的不同方法。我们知道了在js中"继承"是怎么工作的-通过__proto__属性将对象链接到其他对象,有时需要链接多个层级。

我们一遍又一遍地看到,我们抽象越多,引擎的面纱下的内容就越多,我们就越难追寻所以然。

每一种模式都有它的优点和缺点:

  • 使用Object.create,我们需要编写更多代码,但可以对对象进行更细粒度的控制。 尽管进行深层次的链接很繁琐。
  • 使用构造函数,JavaScript可以自动帮我们做一些工作(通过new操作符),但是语法可能有些奇怪。 我们还需要确保使用new关键字来调用我们的函数,否则我们将面临意想不到的bug。同时嵌套继承也不是很友好。
  • 使用class,我们可以获得更简洁的语法,并且内置了new操作符的检查。 当我们使用“继承”时,class最好用,我们只使用extended关键字并调用super(),而不用其他模式那样费尽力气。 语法也更接近于其他语言,并且看起来很容易学习。 尽管这也是一个缺点,因为正如之前所说,此类非彼类,我们仍然使用的是“原型继承”,中间封装了很多抽象层而已。

你可能感兴趣的:(javascript,原型链,继承,构造函数,constructor)