1. 程式人生 > 前端設計 >別在問我什麼是原型鏈

別在問我什麼是原型鏈

前言

記得初學JavaScript時,我一直對這門語言感到疑惑. 眾所周知,面向物件有三大特性封裝、繼承和多型. 就從我當時的理解來講,我認為JavaScript是沒有繼承的,因為它沒有"類"(class)和"例項"(instance)的區分,但它有一個叫做 原型鏈prototype chain)的神奇模式,來實現繼承.

(在2015年6月最新發布的 ECMAScript 6.0 標準中,已經有了 class 以及 extends 關鍵字,但是基本上來說,ES6的class可以看做是一個語法糖,新的寫法只是讓物件原型寫法更清晰、更加貼近面向物件程式設計的語法而已. 具體可以看到下文介紹)

認識原型

我們都知道,Java和C++是使用廣泛的面嚮物件語言,他們都使用new命令,來生成例項:

Foo *f1 = new Foo();	// c++
Foo f1 = new Foo(); 	// java
複製程式碼

其中上圖的Foo在這兩門語言中均是指的class,他們在呼叫new命令時,都會呼叫建構函式(constructor). 而在JavaScript中,new命令後面跟的不是,而是建構函式.

我們從一個最簡單的物件初始化開始:

function Foo() {
  this.name = 'xxx'
}
let f1 = new Foo()
複製程式碼

上面我們建立了一個建構函式 Foo()

,並用new關鍵字例項化該建構函式得到一個例項化物件 f1.

我們首先來了解一下new操作符將函式作為構造器進行呼叫時的過程:

  1. 建立一個空物件 f1
  2. f1的**__proto__**屬性指向建構函式Foo的原型,即 obj.__proto__ = Foo.prototype
  3. 將建構函式 Foo 內部的 this 繫結到新建的物件f1,執行Foo(此時函式的this指向了f1,所以相當於 f1.Foo()f1.name = 'xxx'
  4. 若建構函式沒有返回非原始值(即不是引用型別的值),則返回該新建的物件obj(預設會新增 return this). 否則,返回引用型別的值

由上面的構造過程,我們可以知道最終構造出來的物件 f1

擁有一個屬性 __proto__,該屬性指向了他的建構函式的一個屬性prototype,這也就是我們常說的原型. 當我們在瀏覽器中列印可以看到他們的指向是相同的:

image.png

每一個JavaScript物件(null除外)在建立的時候就會與之關聯另一個物件,這個物件就是我們所說的原型,每一個物件都會從原型 "繼承" 屬性. 但是與Java、C++ 等語言不同的是,這裡說的繼承指的是例項與他的父類共享一個屬性,不論是例項或者父類修改這個屬性,都會影響共享這個屬性的所有例項

由此我們可以先得出一個簡單的關係圖:

1.png

上圖我們可以看到這是一個非常簡單清晰的指向關係,實際上列印一下我們可以看到他們其中還包含有一個constructor屬性,這個屬性對於js的原型鏈的構成也是不可或缺、非常重要.

WX20200618152737.png

接下來我們就基於這個例子拓展一下這個指向圖,來詳細瞭解一下 __proto__,prototype 以及 constructor 他們三者複雜的"三角關係"

__proto__ 屬性

首先,__proto__ 它是物件所獨有的;

它的作用是當訪問一個物件屬性時,如果該物件內部不存在這個屬性,那麼就會去它的**__proto__屬性所指向的那個物件(可以理解為父物件)裡找,如果父物件也不存在,則繼續往父物件的__proto__屬性所指向的那個物件裡找,如果還沒有找到,則繼續往上找,直到原型鏈頂端null**;

我們可以繼續拓展上面的指向關係圖:

2.png

這種通過**__proto__屬性來連線物件直到null的一條鏈即為我們平時所謂的原型鏈(null為原型鏈的終點). 我們平時所經常使用的字串方法、陣列方法、物件方法、函式方法等都是靠__proto__**繼承而來的.

__proto__ 屬性在ES標準定義中的名字應該是**[[Prototype]],但是具體實現是由瀏覽器代理自己實現,Chrome就是將其命名為__proto__**. 可以通過Object.getPrototypeOf({__proto__: null}) === null檢測是否支援屬性

prototype 屬性

prototype屬性它是函式所獨有的;

但是由於在JavaScript中函式也是一個物件,所以函式也擁有**__proto__constructor**屬性.

prototype 的含義是函式的原型物件,也就是這個函式所建立的例項物件的原型物件,他的作用就是包含可以由特定型別的所有例項共享的屬性和方法,也就是讓該函式所例項化的物件們都可以找到公有的屬性和方法,任何函式建立的時候,都會默認同時建立該函式的prototype物件.

為了更直觀的表示,我們繼續將函式原型也補充到圖中:

3.png

constructor 建構函式

我們現在已經知道了,我們剛剛初始化的f1物件中有一個**__proto__屬性,指向他的父類Fooprototype**,二者共享這個屬性. 那麼我們現在來列印一下這個屬性,我們來看看他其中包含什麼

WX20200618152737.png

可以看到其中有一個constructor,這就是我們常說的建構函式,而且每個原型都有一個 constructor 屬性指向關聯的建構函式.

constructor屬性是從一個物件指向一個函式,含義就是指向該物件的建構函式,每個物件都有建構函式,也就是說:

f1.__proto__.constructor === Foo	// true; Foo()是f1的建構函式
複製程式碼

但是Function這個物件比較特殊,它的建構函式就是它自己(見下方),所有函式和物件最終都是由 Function 建構函式得來,所有 constructor 屬性的終點就是Function這個函式

Foo.prototype.constructor === Foo		// true; Function物件的建構函式是它自己
複製程式碼

為了更直觀的體現,我們把constructor的指向也輸入到圖中:

WX20200618172728.png

(圖上虛線部分指的是繼承(共享)而來的屬性,因為例項物件f1不具有constructor屬性;)

constructor部分也加入到其中之後,我們就得到了一張完整的原型鏈. 有不明白的可以結合上文看一下,其實梳理完成之後還是很簡單、清晰的.

至此原型鏈的介紹就結束了,我們來總結一下幾個重點:

1. __proto__ 和 constructor 它是物件所獨有的 
2. __proto__的作用是當訪問一個物件屬性時,那麼就會去它的__proto__屬性所指向的那個物件(可以理解為父物件)裡找,則繼續往父物件的__proto__屬性所指向的那個物件裡找,直到原型鏈頂端null
3. prototype 是函式所獨有的.  但是由於JavaScript中函式也是一個物件,所以函式也擁有 __proto__ 和 constructor 屬性
4. prototype 的含義是函式的原型物件,也就是這個函式所建立的例項物件的原型物件,他的作用就是包含可以由特定型別的所有例項共享的屬性和方法,都會默認同時建立該函式的prototype物件
5. constructor 屬性是從一個物件指向一個函式,含義就是指向該物件的建構函式,每個物件都有建構函式. Function這個物件比較特殊,它的建構函式就是它自己,所有 constructor 屬性的終點就是Function這個函式
複製程式碼

原型鏈汙染

在2019年,著名的 'lodash' 庫就被曝出一個安全漏洞,就是由於原型使用不當會造成的原型汙染 (原文連結),具體是涉及到了 _.defaultsDeep() 這個方法,該方法被用來遞迴合併物件.

_defaultsDeep({ 'a': { 'b': 2 } },{ 'a': { 'b': 1,'c': 3 } })
// result => { 'a': { 'b': 2,'c': 3 } }
複製程式碼

可以看到,該方法用來將一個物件裡面另一個物件中不存在的鍵深拷貝到後者當中. 下面的示例展示了該方法是如何汙染原型的:

const mergeFn = require('lodash').defaultsDeep
const payload = '{"constructor": {"prototype": {"a0": true}}}'

function check () {
  mergeFn({},JSON.parse(payload))
  if (({})[`a0`] === true) {
		console.log(`原型被汙染! ${payload}`)
  }
}

check()
複製程式碼

上面程式碼展示了defaultsDeep通過複製payload,在空物件的constructor的原型中寫入了一個鍵值對"a0: true". 此後所有的物件的原型上都會多出這個鍵值對,而會引起的安全問題包括從屬性注入程式碼注入等,我們在開發中也要避免這種問題的出現.

我們已經瞭解原型鏈的一個基本的概念,__proto__prototype屬性指向一個包含所有例項共享的屬性和方法的物件. 既然它是**'共享'的,那麼就一定會出現'汙染'**的這麼一個問題:

WX20200622171536.png

上圖可以看到我們修改f1.name屬性,結果更改卻影響到了另一個例項f2,這在大部分時候不是我們想看到的. 那麼在我們如何來實現一個不會被汙染的原型繼承呢,下面來改造一下這個例子:

function Foo () {
  this.name = [1,2,3]
}
function FooSon () {
  Foo.call(this)				// added; 當FooSon作為建構函式執行到這裡的時候,此時函式內部的this指向已經繫結到物件 `f1`
}
FooSon.prototype = new Foo()
let f1 = new FooSon()
f1.name.push(4)
console.log(f1.name)		// print [1,2,3,4]
let f2 = new FooSon()
console.log(f2.name)		// print [1,3]
複製程式碼

這裡來解釋一下我們加的唯一一行程式碼Foo.call(this)的作用,我們上面介紹過new關鍵字所做的事情,其中有一步是 將建構函式 FooSon 內部的 this 繫結到新建的物件f1,執行FooSon. 也就是說:

  1. 當我們建構函式FooSon執行的時候,函式內部的this指向已經變成了f1,那我們新增的程式碼Foo.call(this)就等同於Foo.call(f1)
  2. 繼續向下走執行Foo的時候,起內部的this指向也已經被指定為f1,所以最終在Foo函式內部的 this.name = [1,3] 就等同於 f1.name = [1,3]

這種方式被稱為 ''組合繼承''(又叫偽經典繼承),他解決了屬性共用的問題,同時解決了函式無法共用的問題,實際上現在已經成為了js中最常用的繼承模式

ES6 中的 class

ES6 提供了更接近傳統語言的寫法,引入了 Class(類)這個概念,作為物件的模板。通過class關鍵字,可以定義類。

基本上,ES6 的class可以看作只是一個語法糖,它的絕大部分功能,ES5 都可以做到,新的class寫法只是讓物件原型的寫法更加清晰、更像面向物件程式設計的語法而已,JavaScript 仍然是基於原型的。

ES6 的類,其實完全可以看作建構函式的另一種寫法。

class Point {
	// ...
}

typeof Point 	// "function"
Point === Point.prototype.constructor	// true
複製程式碼

上面程式碼表明,類的資料型別就是函式,類本身就指向建構函式。

也就是說,使用ES6語法的類來構造物件,其原型原型鏈的表述,與我們傳統的物件宣告沒有什麼區別. 關於class這裡就不具體展開了,感興趣的同學可以直接去參考阮一峰老師的ES6入門,裡面介紹的已經很詳細了.

babel是如何轉換ES6中的class、extends

上文講到了ES6的class語法,我們知道我們所常用的的React框架就是使用es6的Class以及extends來實現元件之間的繼承. 但是由於相容性的問題,我們在專案中大多數時候,還是會使用babel將es6程式碼轉換成es5,最後我們來看一下,babel是如何轉換es6的classextends

我們開啟babel線上解析器,輸入使用es6語法編寫的我們上述的例子,然後執行轉換:

WX20200623114024.png

我們可以看到右側是轉換之後的es5的程式碼,那我們現在就來一起簡單解析一下他做了什麼(下面程式碼去除了一些型別判斷、非空判斷的函式,只保留了主要流程,方便我們整理)

"use strict";

/*********** 輔助方法 ************/

function _inherits(subClass,superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function");
  }
  // 1. 將subClass(FooSon)的prototype指向一個根據superClass(Foo)的prototype新建立的物件,其中包含一個指向自己的建構函式constructor
  subClass.prototype = Object.create(superClass && superClass.prototype,{
    constructor: { value: subClass,writable: true,configurable: true }
  });
  // 2. 將superClass(Foo)的__proto__屬性指向superClass
  if (superClass) _setPrototypeOf(subClass,superClass);
}


function _createSuper(Derived) {
  // derived = FooSon

  // 判斷是否支援Reflect,如果不支援接下來將降級使用apply,來繼承父類的內部屬性和方法
  var hasNativeReflectConstruct = _isNativeReflectConstruct();

  return function _createSuperInternal() {
    // 獲取FooSon的原型指向__proto__,在上面步驟中已經指向到Foo函式
    var Super = _getPrototypeOf(Derived),result;
    if (hasNativeReflectConstruct) {
      // 獲取 this 的__proto__取得其中constructor (執行到這裡的this實際上已經是物件例項 var f1 了)
      var NewTarget = _getPrototypeOf(this).constructor;
      // ES6新語法,等同於 new Foo(arguments,NewTarget)
      result = Reflect.construct(Super,arguments,NewTarget);
    } else {
      // 不支援Reflect,降級使用apply,這裡的Super = Foo
      result = Super.apply(this,arguments);
    }
    // 判斷result可用性 最終return的是result
    return _possibleConstructorReturn(this,result);
  };
}


/*********** 建構函式 ***********/

// 父類函式Foo
var Foo = function Foo(name) {
  // 檢測建構函式的prototype屬性是否出現在某個例項物件的原型鏈上 內部使用了instanceof
  // 也就是說檢測是否是使用new關鍵字執行,而不是直接調起建構函式
  _classCallCheck(this,Foo);

  this.name = name;
};

// 子類函式FooSon 立即執行函式
var FooSon = /*#__PURE__*/ (function(_Foo) {

  // 修改原型指向,等同於:
  // 將FooSon.prototype = Object.create(_Foo,{constructor: { value: _Foo }})
  // 將FooSon.__proto__ = _Foo
  _inherits(FooSon,_Foo);

  // _super等同於: _Foo.apply(this,arguments)
  var _super = _createSuper(FooSon);

  function FooSon(name) {
    // 使用instanceof檢測FooSon的prototype屬性是否出現在f1(this此時等於f1)的原型鏈
    _classCallCheck(this,FooSon);
    // 最後等同於 _Foo.call(this,name)
    return _super.call(this,name);
  }

  return FooSon;
})(Foo);

var f1 = new FooSon('xxx')
複製程式碼

通過上述分析我們發現,他其實也是一個組合繼承的寫法,只不過實現的更加完備,基本原理同我們上面所寫過的簡單例子其實沒有什麼太大差別.

以上就是本文全部內容了,如果有問題還請各位大佬指出...

ps: 我愛新褲子樂隊,這個標題取自<別在問我什麼是迪斯科>,牆裂推薦給大家,有助於加快程式碼速度