呼叫了這麼久的JS方法是長在物件、類、值本身還是原型鏈上?

CamWang發表於2024-06-20

呼叫了這麼久的JS方法是長在物件、類、值本身還是原型鏈上?

JavaScript這門語言總是能帶給我驚喜,在敲程式碼的時候習以為常的寫法,退一步再看看發現自己其實對很多基操只有表面的使用,而從來沒思考過為何要這樣操作。

今天整理JS程式碼的時候突然發出靈魂三連問:

  • 為什麼有些時候操作物件,可以直接呼叫物件上的方法,但有些時候我們使用類似Array.from()的寫法?
  • 在物件上呼叫的方法跟在原型上呼叫的方法區別是什麼?這兩者相同麼?
  • 為什麼JS上可以直接在基礎型別值上呼叫物件上面才存在的方法?基礎型別值上呼叫的方法與在物件上呼叫的方法有區別麼?

不同的方法呼叫方式

瞟了眼我的程式碼,立馬就發現了一個呼叫類上方法的片段:

const obj = { a: 1 };
console.log(Object.hasOwn(obj, 'a')); // true
// 但是如果在物件上呼叫,則會拋不存在的錯誤
console.log(obj.hasOwn('a')); // TypeError: obj.hasOwn is not a function

在上面的例子裡,Object.hasOwn是一個可以直接呼叫的方法,但令人困惑的是,當我們嘗試直接在物件例項上呼叫hasOwn方法時,卻丟擲了一個型別錯誤,是不是有點反直覺? 我仔細想了一想突然發現,其實這只是一個基礎JS概念的一個外在表現,只不過我們習慣了作為現象使用它,卻很少會想到它背後的邏輯。

靜態方法與例項方法

其實,我們需要做的只是區分JavaScript靜態方法例項方法

靜態方法 是定義在類上的方法,而不是在類的例項上,靜態方法內部訪問不到this與例項變數。所以我們只能透過類來呼叫這些方法,而不能透過一個例項來呼叫

class MyClass {
  static staticMethod() {
    console.log('這是個靜態方法');
  }
}

MyClass.staticMethod(); // 正常執行
const myInstance = new MyClass();
myInstance.staticMethod(); // Error: myInstance.staticMethod is not a function

例項方法 是定義在類的原型上的方法,例項方法內可以訪問物件的屬性,也可以訪問this,可以直接在例項化物件上呼叫這些方法

class MyClass {
  instanceMethod() {
    console.log('這是個例項/物件方法');
  }
}

const myInstance = new MyClass();
myInstance.instanceMethod(); // 正常執行

概括來說,上面例子中Object.hasOwn()是一個需要傳參的、在Object這個類上的靜態方法,所以才需要在類上直接呼叫,而不能在例項物件上呼叫;但在例如arr.sort()的呼叫,實際呼叫的是例項物件上的方法

至於為何會做如此區分,原因是一個簡單的物件導向程式設計需求:如果一個方法邏輯不涉及物件上的屬性,但又邏輯上屬於這個類,透過接受引數就可以實現功能的,則可以作為一個類的靜態方法存在。但如果它需要直接訪問類上屬性,直接作為例項方法顯然更加妥當。

原型鏈與方法呼叫

JavaScript中的每個物件都有一個原型(prototype)(除了Object.protoype也就是所有原型的盡頭),物件的方法實際上是定義在原型鏈上的。雖然我們可能是在物件上呼叫了一個方法,實際上JavaScript引擎會沿著原型鏈查詢該方法並呼叫。

const arr = [1, 2, 3];
console.log(arr.join('-')); // "1-2-3"
console.log(Array.prototype.join.call(arr, '-')); // "1-2-3"

上面的例子裡,join方法是陣列的例項方法。例項方法可以直接在陣列的例項上呼叫,也可以透過Array.prototype.join.call的方式來呼叫,這倆本質上是一樣的。唯一區別是Array.prototype.join.call允許我們在任何類似陣列的物件上呼叫這個方法,哪怕它不是一個真正的陣列。
等等?我們可以在不是陣列的值上呼叫join?是的

const pseudoArray = { 0: 'one', 1: 'two', 2: 'three', length: 3 };

// ❌顯然object上沒有join方法,這樣呼叫會報錯
pseudoArray.join(','); // Error: pseudoArray.join is not a function

// 成功在object上呼叫join!!
const result = Array.prototype.join.call(pseudoArray, ',');
console.log(result); // "one,two,three"

所以,在物件上呼叫例項方法,等同於按照這個物件的原型鏈一層一層向父類上找同名方法來呼叫。

基礎型別的自動包裝

雖然其他支援物件導向程式設計正規化的語言也有類似行為,也就是對基本型別的自動包裝自動拆包,但為了百分百掌握JavaScript的行為與他們的異同,還是再來確定一遍吧

每當我們在基本型別值上(例如"hello"6)上呼叫方法,JavaScript引擎都會先使用基本型別對應的包裝型別對值進行包裝,呼叫對應的方法,最後將包裝物件丟掉還原基礎型別。這是個引擎內部的隱式操作,所以我們沒有任何的感知。

JavaScript對於以下的基本型別,都有對應的包裝型別。可以透過typeof操作結果是基本型別名還是object來確認:

  • string - String
  • number - Number
  • boolean - Boolean
  • symbol - Object
  • bigint - Object

讓我們列一下他們基本型別對應包裝型別的使用:

// string
const primitiveString = "hello";
const objectString = new String("hello");
console.log(typeof primitiveString); // "string"
console.log(typeof objectString); // "object"

// number
const primitiveNumber = 42;
const objectNumber = new Number(42);
console.log(typeof primitiveNumber); // "number"
console.log(typeof objectNumber); // "object"

// boolean
const primitiveBoolean = true;
const objectBoolean = new Boolean(true);
console.log(typeof primitiveBoolean); // "boolean"
console.log(typeof objectBoolean); // "object"

// symbol
const primitiveSymbol = Symbol("description");
const objectSymbol = Object(primitiveSymbol);
console.log(typeof primitiveSymbol); // "symbol"
console.log(typeof objectSymbol); // "object"

// bigint
const primitiveBigInt = 123n;
const objectBigInt = Object(primitiveBigInt);
console.log(typeof primitiveBigInt); // "bigint"
console.log(typeof objectBigInt); // "object"

所以,在基本型別上呼叫方法,等同於建立這個基本型別對應的包裝型別的物件並呼叫方法,最後拆包並返回原始型別的值。本質上還是呼叫了同型別包裝行為建立的物件上的方法。

"str".toUpperCase();
// 等同於
(new String("str")).toUpperCase()
// 當然,這裡巧了,toUpperCase()本來也沒想返回包裝型別的物件

結語

哈哈,原來這個類、物件方法呼叫現象的原因其實一直都在我的大腦裡,這只是物件導向程式設計中的一個很稀鬆平常的事實,但平時從來只是使用,還從來沒聯想過為何他會這樣。

不知道你有沒有感受到這種程式語言帶來的實踐經驗與基礎理論交融的樂趣。在一點點的實踐中才會慢慢發現原來看似“這樣寫就能跑”的一些程式碼,其實背後都有曾經學習、分析過的程式概念和理論的支撐。這種感受或許就是程式設計快樂的其中之一個源頭吧。

為大家的好奇心與耐心致敬。

相關文章