深入理解JS對象隱式類型轉換的過程

在平時的開發工作中,我們有時會遇到需要將一個對象轉換成基本類型的情況。很多情況下這個過程都是自動完成的,我們不需要手動處理。但是每當遇到這種情況的時候,你是否有思考過其背後的邏輯是怎樣的?這篇文章會跟大家一起探討一下這個問題。

在開始這篇文章之前,大家可以嘗試思考一下下面問題的答案,看看自己對這部分知識的掌握程度怎麼樣。

let a = {
    [Symbol.toPrimitive] (hint) {
        if (hint === 'number') {
            console.log('   >>> a hint number');
            return 10;
        }
        if (hint === 'string') {
            console.log('   >>> a hint string');
            return '10';
        }
        console.log('   >>> a hint default');
        return 'a';
    }
};
let b = {
    toString() {
        console.log('   >>> b hint string');
        return 'b';
    },
    valueOf() {
        console.log('   >>> b hint number');
        return 20;
    }
};

console.log(+a);
console.log(`${a}`);
console.log(a + '');
console.log(a == b);
console.log(a + b);
console.log(a * b);

如果你能夠全部回答正確,那麼恭喜你,這一部分你掌握得很不錯。可以不用繼續往下看了,當然你也可以繼續看下去,看看實際的轉換過程跟你想想的過程是不是一樣的。如果有回答錯誤的,那正好可以借這個機會好好學習一下,查漏補缺。

接下來我們來深入探討一下,將一個對象轉換爲基本類型的值需要經過那些過程。如果問大家,將一個對象轉換爲一個基本類型,會調用那些方法。大部分同學首先會想到Object.toStringObject.valueOf,如果對ES6瞭解比較深入的話,你可能還會想到Object[Symbol.toPrimitive]。當然只知道這些還是不夠的,我們還需要知道每一個方法會在哪些情況下被調用,如果某一個或多個方法不存在,那麼它們的調用順序是怎樣的。

ToPrimitive

想要回答上面的問題,我們就要從官方的文檔入手,從源頭上了解關於對象類型轉換爲基本類型的定義。文檔上是這麼定義的,如果將一個對象轉換爲基本類型,那麼這個過程可以使用一個抽象的操作ToPrimitive來表示,ToPrimitive接收一個input參數(也就是當前需要被轉換的對象)和一個可選的PreferredType參數,這個操作會把input轉換爲一個非對象類型的基本類型值,如果這個對象可以被轉換爲多種基本類型值,那麼這個時候就可以根據對象所處的上下文環境使用可選的提示參數PreferredType,來轉換爲符合這個上下文環境的基本類型。具體的過程如下:

  1. 斷言:首先我們需要確定傳入的input值是JavaScript的一種數據類型
  2. 判斷input的類型是否是對象,如果是繼續下一步
    • 如果PreferredType沒有出現,那麼將hint賦值爲default
    • 如果PreferredType暗示是字符串,那麼將hint賦值爲string
    • 否則
      • 斷言:這個時候可以確定PreferredType暗示是數字類型
      • hint賦值爲number
    • 聲明exoticToPrim,如果input上面的toPrimitive方法不爲空,將exoticToPrim賦值爲這個方法
    • 如果exoticToPrim不是undefined,那麼進行下面的步驟
      • 聲明result,將inputhint作爲參數傳遞給exoticToPrim,並且運行這個函數。如果運行的結果不爲空,將result賦值爲這個結果
      • 如果result類型不是對象,那麼返回這個值
      • 拋出類型錯誤
    • 如果hint的值是default,那麼將hint賦值爲number
    • 運行OrdinaryToPrimitive(input, hint),如果運行的結果不爲空就返回這個結果
  3. 直接返回基本類型

可以看到上面的轉換過程包含一個OrdinaryToPrimitive的操作,我們暫時先不考慮這個操作,這部分的講解會在文章的後面給出。如果暫時不考慮OrdinaryToPrimitive操作,我們會發現,上面的過程中有一個exoticToPrim函數,這個函數對應的就是對象上面定義的Symbol.toPrimitive屬性,Symbol.toPrimitive是一個內置的Symbol值,這個屬性是一個函數屬性。當將一個對象轉換爲原始值的時候會優先調用這個函數。

Symbol.toPrimitive接收一個參數值也就是上面轉換過程中的hint,這個參數有三個固定值分別是default, string, numberhint的值由對象在轉換過程中的上下文決定,比如在${obj}中,hint的值就爲string,如果是在+obj中,hint的值就爲number, 如果是在obj + obj中, 這個時候hint的值就是default了,因爲+可以用作字符串的連接以及數字求和。我們來實踐一下吧。

練習[Symbol.toPrimitive]

let obj = {
   [Symbol.toPrimitive](hint) {
      if (hint === 'string') {
         console.log('當前上下文需要一個 string 類型的值');
         return 'hello world!';
      } else if (hint === 'number') {
         console.log('當前上下文需要一個 number 類型的值');
         return 100;
      } else {
         console.log('當前上下文無法確定需要轉換的基本類型');
         return 0;
      }
   },
};
console.log('--- 測試: ${obj} ---');
console.log(`${obj}`);
console.log('\n--- 測試: +obj ---');
console.log(+obj);
console.log('\n--- 測試: obj + obj ---');
console.log(obj + obj);

看了上面的解釋,相信大家應該都可以回答出上面輸出的內容了;如果有哪裏還不明白,可以再看看上面的解釋。

還有一些需要我們注意的細節,當ToPrimitive操作被調用的時候,如果沒有hint,那麼這時候通常這個操作的表現就像是hint的值是number。對象可以通過定義Object[Symbol.toPrimitive]來覆寫這個行爲。規範中定義的對象只有Date類型和Symbol類型的對象覆寫了這個默認的方法。其中Date類型對待沒有hint的表現就像hint的值是string一樣。

OrdinaryToPrimitive

接下來我們要講一講OrdinaryToPrimitive操作的過程了,我們繼續看一下官方文檔上面關於OrdinaryToPrimitive的解釋。

這個抽象的操作需要兩個參數,分別是Ohint,當被調用的時候會執行下面的過程:

  1. 斷言:O是一個對象。
  2. 斷言:hint是一個字符串,它的值只能是string或者number(通過上面的解釋我們可以知道,在沒有調用OrdinaryToPrimitive之前,如果hint的值是default的話,會把hint的值更新爲number,然後再開始調用OrdinaryToPrimitive)。
  3. 如果hint的值是string,那麼:
    • 聲明methodNames列表,它的值爲« “toString”, “valueOf” »
  4. 否則
    • 聲明methodNames列表,它的值爲« “valueOf”, “toString” »
  5. 遍歷methodNames列表中的每一個name,做下面的操作:
    • 聲明method方法,賦值爲對象的上面的name方法
    • 如果method是可以調用的,那麼進行下面的操作:
      • 聲明result,將其賦值爲在對象上運行name函數的結果
      • 只要result的類型不是一個對象,那就返回這個結果
  6. 拋出類型錯誤異常

看了上面的過程,我們對OrdinaryToPrimitive的操作也有了比較深入的理解,那麼我們接下來也做一個簡單的實踐,來驗證一下上面的過程。

let obj = {
    toString() {
        console.log('執行obj的toString方法');
        return 'hello world!';
    },
    valueOf() {
        console.log('執行obj的valueOf方法');
        return 100;
    }
};

console.log('--- 測試: ${obj} ---');
console.log(`${obj}`);
console.log('\n--- 測試: +obj ---');
console.log(+obj);
console.log('\n--- 測試: obj + obj ---');
console.log(obj + obj);

細心的你會發現console.log(obj + obj)與之前的不太一樣,它的輸出結果是200。這是爲什麼呢?上面我們有講到說,在obj + obj這個上下文環境中,hint的值是default,在進行OrdinaryToPrimitive操作之前,hint的值會更新爲number。所以當hint的值爲number的時候就可以輕鬆的得到上面的結果。

到這裏爲止,關於對象轉換爲原始值的大部分內容都已經講解完了。總結來說就是,如果需要將一個對象轉換爲原始類型的值,首先要判斷這個對象所處的上下文環境,看一下需要將對象轉換爲什麼類型的原始值,然後首先會調用對象上面的Symbol.toPrimitive方法,如果有基本類型的返回值,就返回這個值。如果沒有正確的返回值,接下來由上下文環境決定調用對象上面的valueOftoString方法的順序,只要這兩個方法有一個方法的返回是一個基本類型,那麼該對象就會被轉換成這個基本值,否則就會拋出錯誤。

拓展與思考

++[[]][+[]]+[+[]]的輸出爲什麼是10

相信很多同學都看過上面這個表達式,你可能也會對它的輸出爲什麼是10感到詫異。我們今天也順便來分析一下這個表達式的值爲什麼是10

就像你看一個魔術一樣,如果你不知道魔術背後的祕密,那麼魔術對你來說就是一個謎。但是對於表演的魔術師來說,那只不過是在道具的幫助下,做了一連串迅速而又不出錯的動作而已。

同樣,對於上面這個表達式,我們只需要一步一步的分析,找到一些關鍵點,化繁爲簡。最後的結果也就呼之欲出了。

首先我們需要給這個表達式做一下格式的優化,這需要我們知道操作符優先級的相關知識,詳情可以看運算符優先級。按照操作符的優先級我們可以把上面的表達式變爲:

++[[]][+[]] + [+[]]

因爲[[]][+[]]屬於成員訪問,[[]][+[]] + [+[]] 中的+屬於加法運算符,它的作用是數值求和,或者字符串拼接,++爲前置遞增運算符。他們的優先級是成員訪問優先級高於前置遞增運算符前置遞增運算符的優先級高於加法運算

那接下來的問題就是簡化這個表達式,我們看到表達式中[+[]]出現了兩次,那麼[+[]]如何簡化呢?對於[+[]]重要的就是裏面的+[],我們上面也解釋過了,對於+我們知道這是一個一元操作符,會把[]轉換爲一個數字,這時候會首先調用數組的valueOf方法,因爲數組的valueOf方法返回的是數組本身,不是一個基本類型。所以接下來要調用數組的toString方法。toString方法返回的是""一個空字符串,是一個基本類型。因爲+會把""轉換爲一個數字,那麼把""轉換爲數字是數字0。所以上面的[+[]]其實就是[0],所以最初的表達式可以轉換爲++[[]][0] + [0]

我們繼續把上面的表達式轉換爲更簡單的形式,[[]][0]其實就是獲取[[]]數組的第一個元素,也就是[],所以++[[]][0] + [0]到這裏爲止就被轉換爲了++[] + [0]。到這裏已經比最初的版本精簡很多了。但是這裏還有一個知識點,++在這裏是前置遞增運算符。它會把++a表達式中的a先轉換爲一個數字,然後將這個數字加1,最後返回這個新值。所以上面的的表達式就變爲了1 + [0]。其實如果你在瀏覽器的控制檯運行一下++[],你會發現會報錯Uncaught SyntaxError: Invalid left-hand side expression in prefix operation,這是因爲++運算符
作用的表達式需要是一個引用,而不是一個字面量。所以如果你運行let a = []; ++a,那麼a的值就會變爲1。而[[]][0]就是一個引用,所以我們可以把表達式轉換爲1 + [0]

對於1 + [0][0]需要被轉換爲基本類型,因爲在這個上下文環境中,+可以用作兩個數字相加或者兩個字符串的拼接。所以對於[0]在執行上面的ToPrimitive抽象過程的時候,hint值由最初的default被轉換爲了number,但是因爲數組對象默認的Symbol.toPrimitive屬性爲空,所以要繼續進行OrdinaryToPrimitive抽象操作,所以[0]最終被轉換爲了"0"字符串。

所以上面的表達式又被轉換爲了1 + "0",這時候結果就顯而易見了,就是字符串"10"。因爲當+左右兩側只要有一個操作數是字符串的時候,+運算符執行的就是字符串的拼接。關於+運算符的規則可以看這裏

至此,上面那個複雜的表達式就這樣一步一步被我們攻破了。如果你對這一部分很有興趣,推薦你看看Write any JavaScript with 6 Characters: []()!+

在比較的過程中拋出錯誤

學習了上面的知識,我們可以很容易的在有對象參與比較的時候拋出錯誤,比如你可以這樣:

let a = { valueOf: undefined, toString: undefined} 
a == 1 // 報錯
let d = { valueOf: () => ({}), toString: undefined}
d == 1 // 也會報錯

如果大家對上面的內容有什麼疑問和建議,都可以在這裏提出來,我們可以繼續討論一下。文章內容如有變動,我會第一時間更新在我的博客,也歡迎大家關注我的博客。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章