动态

详情 返回 返回

JavaScript 原型鏈詳解 - 动态 详情

繼續補檔,發現這塊內容其實蠻多的。後面估計還會有兩篇(怎麼還有兩篇啊喂!),分別是 JavaScript執行原理·補 和 JavaScript部分特性,這周不知道能不能搞定。

先看 JS 原型鏈吧。

JS 繼承機制設計

1994年,網景公司(Netscape)發佈了 Navigator v0.9,轟動一時。但當時的網頁不具備交互功能,數據的交互全部依賴服務器端,這浪費了時間與服務器資源。

網景公司需要一種網頁腳本語言實現用户與瀏覽器的互動,工程師 Brendan Eich 負責該語言的開發。他認為這種語言不必複雜,只需進行一些簡單操作即可,比如填寫表單。

可能是受當時面向對象編程(object-oriented programming)的影響,Brendan 設計的 JS 裏面所有的數據類型都是對象(object)。他需要為 JS 設計一種機制把這些對象連接起來,即“繼承”機制。

繼承允許子類繼承父類的屬性和方法,並且可以在子類中添加新的屬性和方法,實現代碼的重用和擴展性。

出於設計的初衷,即“開發一種簡單的網頁腳本語言”,Brendan 沒有選擇給 JS 引入類(class)的概念,而是創造了基於原型鏈的繼承機制。

在 Java 等面向對象的語言中,一般是通過調用 class 的構造函數(construct)創建實例,如:

class Dog {
    public String name;
    
    public Dog(String name) {
        this.name = name;
    }
}

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog("Rover");
        System.out.println(dog.name); // Rover
    }
}

Brendam 為 JS 做了簡化設計,直接對構造函數使用new創建實例:

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

var dog = new Dog("Rover");
console.log(dog.name) // Rover

這種設計避免了在 JS 中引入 class,但這引出一個問題:JS 的實例該如何共享屬性和方法?基於構造函數創建的實例都是獨立的副本。

先看看 Java 是如何基於 class 實現屬性和方法共享的:

class Animal {
    public void eat() {
        System.out.println("Animal is eating");
    }
}

class Dog extends Animal {
    public void bark() {
        System.out.println("Dog is barking");
    }
}

class Cat extends Animal {
    public void meow() {
        System.out.println("Cat is meowing");
    }
}

public class Main {
    public static void main(String[] args) {
        Dog myDog = new Dog();
        Cat myCat = new Cat();
        
        myDog.eat(); // Animal is eating
        myDog.bark(); // Dog is barking
        myCat.eat(); // Animal is eating
        myCat.meow(); // Cat is meowing
    }
}

在這個例子中,DogCat子類繼承了Animal父類的eat()方法,並分別添加了bark()meow()方法,這種基於類實現的繼承很順暢也便於理解。

JS 中沒有 class,但這種需求切實存在。Brendan 通過為構造函數添加prototype屬性解決這個問題。

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

Dog.prototype.bark = function() {
    console.log(this.name)
}

var dogA = new Dog("Rover");
var dogB = new Dog("Fido");
dogA.bark(); // Rover
dogB.bark(); // Fido

我們給構造函數Dogprototype添加了bark()方法,這樣做的話,基於Dog創建的實例都可以使用bark()方法,數據共享同理。

那這是如何實現的呢,或者説,prototype是什麼,為什麼可以在多個實例之間共享屬性及方法?這就是我們接下來要説的內容。

在這裏先丟一張圖,接下來的內容可以搭配這張圖一起看,相信這會對初學者理解 JS 原型鏈很有幫助:

img

prototype 原型

在 JS 中,每個函數都有一個prototype屬性,每個對象都有一個__proto__屬性。

函數的prototype 屬性本質上是一個對象,它包含了通過這個函數作為構造函數(即使用 new 關鍵字)創建的所有實例所共享的屬性和方法。

__proto__是所有對象都有的一個屬性,它指向了創建這個對象的構造函數的prototype。也就是説,如果我們有var dog = new Dog(),那麼dog.__proto__就是Dog.prototype

“引用”是指一個變量或者對象指向內存中的一個位置,這個位置存儲了某個值。這裏也可以説dog.__proto__Dog.prototype的一個引用。

那麼 JS 是如何通過prototype實現繼承的呢?

當我們試圖訪問一個對象的屬性時,如果該對象本身沒有這個屬性,JS 就會去它的__proto__(也就是它的構造函數的prototype)中尋找。因為prototype本身也是一個對象,如果 JS 在prototype中也沒有找到被訪問的屬性,那麼它就會去prototype__proto__中尋找,以此類推,直到找到這個屬性或者到達原型鏈的末端null

通過這種方式,JS 就實現了它所需要的繼承機制。這種通過對象的__proto__屬性逐步向上查詢的機制,就是我們所説的 JS 原型鏈。

再拿這個例子做一次講解:

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

Dog.prototype = {
  "species": "dog",
} 
  
var dog = new Dog('Rover');

console.log(dog.name); // Rover
console.log(dog.species); // dog
console.log(dog.age); // undefined
console.log(dog.__proto__.__proto__.__proto__); // null

console.log(dog.__proto__ === Dog.prototype) // true
console.log(Dog.prototype.__proto__ === Object.prototype) // true
console.log(Object.prototype.__proto__) // null

調用dog.name時,JS 查找到dog實例有name屬性,就返回Rover

調用dog.species時,JS 發現當前實例中沒有該屬性,就去dog.__proto__中查詢,找到species屬性並返回dog

調用dog.age時,JS 發現當前實例和當前實例的__proto__屬性中都沒有該屬性,就再向上去尋找,也就到Dog.prototype.__proto__(即Object.prototype)中去尋找,已然沒有找到,就繼續向上找,但Object.prototype.__proto__是整條原型鏈的起點——null,JS 查找不到age屬性,就會返回一個undefined

如果我們再向上查詢一層,即嘗試訪問dog.__proto__.__proto__.__proto__.__proto__,會直接拋出報錯,JS 定義null沒有原型,yejiu1無法訪問到它的prototype屬性。

constructor 構造函數

在 JS 中,每個函數對象還有一個特殊的屬性叫做constructor。這個屬性指向創建該對象的構造函數。當我們創建一個函數時,JS 會自動為該函數創建一個prototype對象,並且這個prototype對象包含一個指向該函數本身的constructor屬性。

當我們使用構造函數創建實例對象時,這些實例對象會繼承構造函數的prototype對象,從而形成原型鏈。因此,通過constructor屬性,實例對象就可以訪問到創建它們的構造函數。

直接把constructor當作反向prototype理解即可。以剛才的代碼舉例:

console.log(Dog.prototype.constructor === Dog); // true

前端開發中的原型鏈

class 語法糖

現在的 Web 前端開發中幾乎不直接使用原型鏈了,JS 已經在 ES6(ECMAScript 2015)中引入了類(Class)的概念,因為這能使得面向對象編程更加直觀。

個人感覺這表示着 JS 與 Brendan Eich 當年所設想的“簡單的客户端腳本語言”越走越偏了,但這也説明 JS 一直在蓬勃發展,活躍的社區生態讓 JS 把它的觸手伸向了互聯網的角角落落,越來越多的開發者將 JS 變得愈來愈完善。

但請注意,JS 的 class 在底層上仍然是基於原型鏈的,只是一種語法糖。

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

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

let animal = new Animal('Simba');
animal.speak(); // Outputs: "Simba makes a noise."

以上代碼是一個使用了 class 的 JS 示範,其基於原型鏈的版本如下:

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

Animal.prototype.speak = function() {
  console.log(this.name + ' makes a noise.');
}

let animal = new Animal('Simba');
animal.speak(); // Outputs: "Simba makes a noise."

這兩個例子在功能上是相同的,但是它們的寫法有所不同。class 語法提供了一種更清晰的方式來創建對象和處理繼承。在 class 語法中,你可以直接在類定義內部聲明方法,而在原型鏈中,你需要在原型對象上添加方法。

性能影響

我們前面説過,JS 在原型鏈中查找當前對象不存在的屬性時,需要一級級的向上查找。如果我們要查找的屬性在較深層的對象中,就會拖慢我們程序的運行速度;如果目標屬性不存在中,JS 就會遍歷整個原型鏈,這無疑會對程序的性能造成負面影響。

此外,在遍歷對象的屬性時,原型鏈中的每個可枚舉屬性都將被枚舉。如果我們想要檢查一個對象是否具有某個屬性,並且這個屬性是直接定義在該對象上的,而不是定義在它的原型鏈上的,那麼我們需要使用hasOwnProperty方法或Object.hasOwn方法。

hasOwnProperty可以用來檢查一個對象是否具有特定的自身屬性(也就是該屬性不是從原型鏈上繼承來的)。這個方法是定義在Object.prototype上的,所以除非一個對象的原型鏈被設置為null(或者在原型鏈深層被覆蓋),否則所有的對象都會繼承這個方法。

該方法的使用方法如下:

let obj = {
    prop: 'value'
};
console.log(obj.hasOwnProperty('prop')); // 輸出:true

let objWithNoProto = Object.create(null);
console.log(objWithNoProto.hasOwnProperty); // 輸出:undefined

此外,除非是為了與新的 JS 特性兼容,否則永遠不應擴展原生原型。如果要使用 JS 原型鏈操作,也要對用户的輸入進行嚴格校驗,因為 JS 原型鏈有着獨特的安全問題。

JS 原型鏈污染

JS 原型鏈污染推薦 phithon 大佬的 深入理解 JavaScript Prototype 污染攻擊,以下merge示範代碼就來自這篇文章。

出於設計上的因素,JS 原型鏈操作容易產生獨特的安全問題——JS 原型鏈污染。

原理很簡單,就是 JS 基於原型鏈實現的繼承機制。如果我們能控制某個對象的原型,那我們就可以控制所有基於該原型創建的對象。以下是一個簡單的示範案例:

// 創建一個空對象 userA
let userA = {};

// 給 userA 添加一個屬性 isAdmin
userA.isAdmin = false;
console.log(userA.isAdmin); // false

// 現在我們想讓所有用户都有這個屬性,我們可以使用原型
userA.__proto__.isAdmin = true;
console.log(userA.isAdmin); // false

// 現在我們創建一個新用户 userB
let userB = {};
// userB 會繼承 userA 的 isAdmin 屬性
console.log(userB.isAdmin); // true

在 CTF 中,往往都是去找一些能夠控制對象鍵名的操作,比如mergeclone等,這其中merge又是最常見的可操縱鍵名操作。最普通的merge函數如下:

function merge(target, source) {
  for (let key in source) {
      if (key in source && key in target) {
          merge(target[key], source[key])
      } else {
          target[key] = source[key]
      }
  }
}

此時,我們運行以下代碼,以 JSON 格式創建o2,在與o1合併的過程中,經過賦值操作target[key] = source[key],實現了一個基本的原型鏈污染,被污染的對象是Object.prototype

let o1 = {};
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}');

merge(o1, o2); // 1 2
console.log(o1.a, o1.b);

o3 = {};
console.log(o3.b); // 2
console.log(Object.prototype); // [Object: null prototype] { b: 2 }

還有一個值得思考的問題,如果我們創建o2使用的語句是:let o2 = {a: 1, "__proto__": {b: 2}},則不會實現原型鏈污染,可以思考一下原因。

後話

讀到這裏,應該就能大致理解什麼是 JS 原型鏈了,也對開發和安全中的 JS 原型鏈有了一個基本的認識。

但還有一個疑問沒有解決:JS 原型鏈的本質是什麼,它是一種機制,還是一種數據結構?

原型鏈(Prototype Chain)從本質上來講是一種機制,而不是某種特殊的數據結構。只是從習慣上來講,我們會把從實例對象到 Object 這中間的 __proto__ 調用稱為“原型鏈”,上面説過的dog.__proto__.__proto__.__proto__就是例子——因為這確實很形象。

參閲文章

  • Javascript繼承機制的設計思想,by 阮一峯的網絡日誌
  • 該來理解 JavaScript 的原型鍊了,by Huli's Blog
  • 繼承與原型鏈,by MDN Web Docs
  • 深入理解 JavaScript Prototype 污染攻擊,by phithon

Add a new 评论

Some HTML is okay.