繼續補檔,發現這塊內容其實蠻多的。後面估計還會有兩篇(怎麼還有兩篇啊喂!),分別是 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
}
}
在這個例子中,Dog和Cat子類繼承了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
我們給構造函數Dog的prototype添加了bark()方法,這樣做的話,基於Dog創建的實例都可以使用bark()方法,數據共享同理。
那這是如何實現的呢,或者説,prototype是什麼,為什麼可以在多個實例之間共享屬性及方法?這就是我們接下來要説的內容。
在這裏先丟一張圖,接下來的內容可以搭配這張圖一起看,相信這會對初學者理解 JS 原型鏈很有幫助:
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 中,往往都是去找一些能夠控制對象鍵名的操作,比如merge、clone等,這其中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