淺談JavaScript的型別轉換

丁春雷發表於2018-12-24

這些是本人在 github.pages上寫的部落格,歡迎大家關注和糾錯,本人會定期在github pages上更新。有想要深入瞭解的知識點可以留言。

概述

在 JavaScript 中,將一種值型別轉換為另一種值型別,叫做型別轉換,出於動態型語言的特性,型別轉換髮生在執行時階段。這些轉換在我們平時寫的程式碼裡無處不在,儘管我們沒有注意,但是這些轉換已經存在於我們的程式碼裡了。像 if、for、while、==、===、+、- 等等語句中。

而在 JavaScript 中,有兩種轉換風格:隱式強制型別轉換和顯式強制型別轉換。

舉個栗子


    let str = 42 + '' // '42'  隱式
    let anotherStr = String(42) // '42'  顯示

複製程式碼

下面,將會從型別轉換的執行機制對轉換值的機制進行深入分析

值轉換的抽象操作

下面介紹一些抽象操作,ToString, ToNumber, ToBoolean, ToPrimitive 注意:這裡的抽象操作不代表方法,而是對型別進行轉換執行的一系列方法。

ToString

ToString 主要負責處理非字串型別轉換為字串型別。我們將待轉換的型別進行劃分:

基本型別和物件型別

string 型別是 JS 中很特殊,也是最重要的基本型別,基本每個內建物件都實現了自身的 toString 方法。

基本型別值的操作很常規,都遵循著通用的規則。


    null -> 'null'
    undefined -> 'undefined'
    true -> 'true'
    21 -> '21'

複製程式碼

對普通的物件而言,機制就變得複雜起來。

物件自身呼叫 toString() 進行字串化或者顯示字串化(如: String() )。

基本上 obj.toString() === String(obj) 過程如下:

  • 先檢查該物件是否有 toString 方法。如果該物件自身重寫了從 Object 原型鏈繼承的 toString ,那麼呼叫該重寫的方法
  • 如果該物件自身沒有 toString 方法。那麼就呼叫原型鏈上游的 toString 方法,直到呼叫到 Object.prototype.toString() 方法。如果原型鏈上沒有此方法或者 toString 方法被重寫為普通屬性,則丟擲 TypeError 的錯誤

    let arr = [1, 2, 3]
    arr.toString() === String(arr) // '1,2,3'
    let obj = { name: 'jack' }
    obj.toString() === String(obj) // [object, Object]

複製程式碼

特殊情況:呼叫 String(obj) 時,如果物件的 toString 方法被重寫為普通屬性,則會退而求其次執行 valueOf 方法。如果執行的結果未返回基本型別,則會報錯。


    let obj = {
        valueOf() {
            return '12'
        }
        toString: undefined
    }
    String(obj) // 12
    obj.valueOf = () => ({})
    String(obj) // TypeError:Cannot convert object to primitive value

複製程式碼

建議:不要輕易的重寫物件屬性的 valueOf 和 toString 方法。因為這兩個屬性涉及物件的表現形式。在型別轉換中至關重要。

Object.prototype.toString() 方法返回的是內部屬性 [[ class ]] 的值。如([objecct, Object], [object, Function])

ToPrimitive

該抽象操作為了將物件型別轉換為基本型別,步驟如下:

  • 檢查該值是否有 valueOf() 方法。如果有且返回基本型別值,則使用該值
  • 如果沒有返回基本型別值或者沒有該方法,就使用 toString 方法的返回值(如果存在)
  • 如果 valueOf 或者 toString 都不返回基本型別值 則會報錯 TypeError

    let nullObj = Object.create(null)
    Number(nullObj) || String(nullObj) // VM3789:1 Uncaught TypeError: Cannot convert object to primitive value

    let obj = {
        valueOf: function() {
            return '12'
        }
    }

    Number(obj) // 12 經過的步驟 valueOf -> '12' -> 經過字串轉數字 -> 12
    Number([]) // 0 經過步驟 valueOf -> [] -> toString -> '' -> 經過字串轉數字 -> 0

複製程式碼

ToNumber

ToNumber 主要負責將其他型別轉換為 number 型別

處理規則:

  • true 轉換為 1,false 轉換為 0
  • undefined 轉換為 NaN
  • null 轉換為 0
  • "" 轉換為 0,其他字串轉換按照特定規則解析 如果能將其解析成數字型別,就解析成該值,否則為NaN
  • 物件型別先將其轉換為基本型別( ToPrimitive ),然後再按上述步驟進行轉換。

    Number('') // 0
    Number(true) // 1
    Number(false) // 0
    Number(undefined) // NaN
    Number(null) // 0
    Number('1d1') // NaN
    let obj = {
        valueOf() {
            return '142'
        }
        toString() {
            return 'obj self'
        }
    }
    Number(obj) // 經歷過程 obj 先經過ToPrimitive抽象操作 valueOf -> '142' ->ToNumber -> 142
    obj.valueOf = null // 這個時候 valueOf 不是一個方法,所以直接呼叫 toString()
    Number(obj) // ToPrimitive 無 valueOf 方法 -> toString() -> NaN

複製程式碼

ToBoolean

假植(falsy)和真值,在 JavaScript 中,除了 true 和 false,還有其他一些列的真值和假值,這些真值和假植有區別於 true 和 false 總的來說,JavaScript中的值分為兩類,真值和假值。因為 boolean 型別只有兩個值,沒有第三個值。

下面的這些值是假值,假值的布林型別轉換為false

  • false
  • undefined
  • null
  • 0,-0,+0,NaN
  • ""

除了以上列出的假值,其他全為真值。所以ToBoolean的操作也很簡單。就是尋找以上假值中是否存在目標值。 存在即為false,不存在即為true

顯式強制型別轉換

數字、字串和布林值的強制型別轉換

規則如下:

  • 數字的強制型別轉換遵循以上所述的 ToNumber 抽象操作,通常所用方法是 Number(), 一元操作符+
  • 字串的強制型別轉換遵循以上所述的 ToString 抽象操作,通常所用方法是 String(), toString()
  • 布林值的強制型別轉換遵循以上所述的 ToBoolean 抽象操作,通常所有方法是 Boolean(), !!val

    Number('12') // 12
    +'12' // 12
    Number('') // 0
    +'' // 0
    Number({}) // NaN
    Number([]) // 0
    String(12) // '12'
    String({}) // '[object Object]'
    String([]) // ''
    Boolean('') // false
    Boolean(0) // false
    Boolean(1) // true
    !!1 // true
    !1 // false
    [1,2,'',undefined,0].filter(Boolean) -> [1,2]
    [1,3,'',undefined,0].filter(v -> !!v) -> [1,2]

複製程式碼

詳解 parseInt 方法

parseInt 方法區別於 Number 方法。不同點有

  • parseInt 只處理字串型別,如果接受的引數不是字串型別,會先將其轉化為字串型別(執行 ToString 抽象操作)。
  • parseInt 支援轉化為特定的資料型別,第二個引數就是特定的進位制資料型別。第二個引數預設值為10,即十進位制解析
  • parseInt 可以會從前至後依次按照第二個引數進位制解析字串,一旦解析到為 NaN 時。則結束解析。返回之前解析的結果。

注意:parseInt 要解析的引數,如果第一個引數以0開頭,會按照八進位制資料進行解析。0x 會按照十六進位制進行解析。會覆蓋預設的十進位制解析。即使顯式的指定十進位制解析,也會進行覆蓋。


    parseInt('112ssasd') // 112
    parseInt(true) // NaN
    parseInt(null) // NaN
    parseInt(112, 2) // 3 解析過程 112 按照二進位制解析 二進位制只能識別 0 和 1,所以 只能解析 11,二進位制的結果為3
    parseInt(012) // 10
    parseInt(0x12) // 18

複製程式碼

常見的考題: [ 1,2,4 ].map(parseInt) 的結果,根據上述分析,顯然不是[ 1,2,4 ]了。 下面的幾種結果,停下來,思考一下,自己可以分析出來,也就對該方法徹底掌握了


    let noop = function() { }
    parseInt(noop, 15)
    parseInt(noop, 16)
    parseInt(1/0)
    parseInt(1/0, 19)

複製程式碼

隱式強制型別轉換

字串的隱式型別轉換

二元運算子 + 是最重要的一個操作符,因為該運算子即可以作為兩個數字進行相加,又可以作為字串的連線符號。 現在,我們討論作為連線符時的注意點以及相關規則。

規則:

先將 + 兩側的資料型別轉化為基本資料型別(ToPrimitive 抽象操作),如果一邊有字串,那麼此時,就作為連線符使用。

注意點:

  • 是將物件運用 ToPrimitive 抽象操作, 即先 valueOf ,然後 toString (如果有必要)
  • 有區別於 String(): String() 是直接尋找 toString 方法,如果未找到,尋找原型鏈上的方法。如果當前 toString 未被定義為方法,則呼叫valueOf。如果未能轉化為基本型別,則報錯typeError。

    let num = 1
    num + '' // '1'
    let bool = true
    bool + '' // 'true'
    let nul = null
    nul + '' // 'nul'
    let arr = [ 1,2,3 ]
    arr + '' // '1,2,3'
    let obj = {}
    obj + '' // '[object Object]'
    arr.valueOf = () => '111'
    arr + '' // '111'
    arr + 1 // '1111'
    1 + [1] // '11'

複製程式碼

數字的隱式型別轉換

數字的隱式轉換有多種,像二元操作符 + - * / % 都可以對型別進行數字的隱式型別轉換,我們先來討論特殊的 +

  • 二元操作符 + 如果有非基本型別的值,先轉化為基本型別的值(ToPrimitive抽象操作)
  • 如果沒有出現字串,則使用數字相加進行運算,非數字型別參照上述規則 ToNumber 抽象操作轉換。

    null + 1 // 1
    true + 1 // 2
    false + 1 // 1
    undefined + 1 // NaN
    let obj = {
        valueOf: function() {
            return 12
        }
    }
    obj + 1 // 13

複製程式碼

而其他的二元操作符 如: -、*、%、/ 都會對操作符兩側進行 ToNumber 抽象操作。相當於 Number(variable)


    1 - '12' // -11 相當於 1 - Number('12')
    1 * '12' // 12
    1 - [1] // 0
    1 / '12' // 0.08333333333333333
    1 % '12' // 1

複製程式碼

布林值的隱式型別轉換

布林值的隱式型別轉換規則很簡單 參照上述的 ToBoolean 的抽象操作就可以了,除去列出的假值,其他均為真值

那麼布林值的應用場景有哪些呢?

  • 除去我們常用的 if 語句等迴圈判斷語句之外。有filter
  • 還有我們之後要討論的邏輯運算子

    之前的寫法是顯示的轉換 如:
    let arr = [1, 2, 4, '', 0, undefined, null, [], {}]
    arr.filter(Boolean) // [1, 2, 4, [], {}]
    arr.filter(v => !!v) // [1, 2, 4, [], {}]
    現在我們可以寫成隱式轉換
    arr.filter(v => v) // [1, 2, 4, [], {}]

複製程式碼

|| 和 && 的抽象邏輯

很多人都認為 || 和 && 是返回布林值的,這是一種誤解。其實這兩個運算子從來都不是用來返回布林值的,相反,這是一種運算,可以返回任意型別的值。 接下來詳細介紹這兩個運算子。

|| 的使用方式 如 a || b ,運算規則如下

先對 a 進行抽象型別轉換 (ToBoolean抽象操作),如果 a 是真值,那麼直接返回 a ,否則返回 b


    let a = [], b = ''
    a || b // [] 因為 a 是真值,所以直接返回 a,不對 b 進行計算。
    b || a // [] 因為 b 是假值,所以直接返回 a

複製程式碼

&& 的使用方式 如 a && b,運算規則如下

先對 a 進行抽象型別轉換(ToBoolean抽象操作),如果 a 是真值,那麼直接返回 b,否則返回 a


    let a = [], b = ''
    a && b // '' 因為 a 是真值,所以直接返回 b
    b && a // '' 因為 b 是假值,所以直接返回 b,不對 a 進行計算

複製程式碼

所以 這兩種運算子的性質決定了其又叫短路運算子

抽象相等(==)和 (===)

這兩個操作符,我們平時在寫程式碼時,用到的地方特別多,但是如何抉擇?為什麼推薦使用全等 ===,而不推薦使用 == ? 我們都知道 == 是經過型別轉換之後再比較值是否相等。那麼值轉換到底是什麼樣的順序?

下面將通過深入分析,為什麼 === 比 == 要好。首先我們需要熟悉規則是什麼?

== 的規則如下:

  • 物件與物件之間進行比較時,結果直接為false(前提是引用地址的記憶體地址不同)。
  • null 和 undefined 比較結果為true。與其他相比直接為false。
  • 非null 和 非 undefined 情況下,先將兩邊都轉換為基本型別( 可能會經過ToPrimitive抽象操作 ),如果兩邊的資料型別都相同,則直接進行比較。
  • 兩邊的資料型別不相同的情況下,則對兩邊都進行 ToNumber 抽象操作。然後進行比較。

根據ES5規範 11.9.3.2-3規定。 null == undefined


    let arr = []
    arr == false // true 轉換過程 [] -> ToPrimitive -> ''(這步還熟悉吧), 兩邊都 ToNumber '' -> 0, false -> 0
    arr == 0 // true
    1 == true // true
    2 == true // false 因為 true -> ToNumber -> 1 相當於 2 == 1 為false
    [1] == 1 // true
    [1] == [1] // false 
    [] == {} // false
    {} == [] // 會報 SyntaxError,為什麼?

複製程式碼

針對上述的 {} == [] ,這裡 JS 引擎會將 == 前面的大括號解析為塊級作用域。所以會報語法錯誤


    相當於 
    {
        // some code 
    }
    == []

複製程式碼

所以改成 ({}) == [] 就可以檢視結果了。

比較少見的情況:

  • 物件重寫了 valueOf 方法,會返回意想不到的值。
  • [] == ![] 結果為 true。因為 ![] 會先進行 ToBoolean 抽象操作。結果就是 [] == false 很明顯為true
  • 0 == [ null ] 結果為 true。因為 [ null ].valueOf() == [ null ] [ null ].toString() == '',所以結果也很明顯

建議

  • 如果兩邊的值有 true 或者 false,避免使用 ==
  • 如果兩邊的值有 []、""、0,避免使用 ==
  • 我們應該在必要和保證安全的情況下使用顯式的強制型別轉換來保證程式的可靠性與可讀性

總結:個人來看,抽象相等 == 用的好的話可以進行很多有趣的程式碼組合,前提是型別之間的互相切換我們已經很熟悉了。但是每次比較都可能會造成兩側的資料進行多次資料型別轉換。效能和安全性,穩定性都不如 嚴格抽象全等 === 來的高。

抽象關係比較

最後,我們再簡單介紹一下,> 、< 、>= 、<= 這幾種情況。

先介紹下規則:

  • 先對操作符的兩邊進行 ToPrimitive 抽象操作(如果有必要的話)
  • 如果結果出現非字串,那麼將兩邊進行 ToNumber 抽象操作
  • 如果結果兩邊都是字串型別,則按照字母順序進行比較

    // 兩邊出現非字串
    let arr = [12]
    arr < true // false
    arr < 13 // true
    // 兩邊出現字串
    '042' < '12' // true
    let anotherArr = [042]
    let temp = [12]
    anotherArr < temp // false 為什麼結果為false '042' < '12' 不是為 true 嗎 ? 自己思考下 會得出答案的
    let obj = {}
    let obj1 = {}
    obj < obj1 // false 因為 '[object Object]' === '[object Object]'

複製程式碼

最後我們來討論下 a <= b的情況。

舉個栗子


    let obj = {}
    let obj1 = {}
    obj < obj1 // false
    obj == obj1 // false
    obj > obj1 // false
    // 上述3個的結果應該是沒有任何問題
    obj <= obj1 // true ???
    obj >= obj1 // true ???

複製程式碼

意想不到的事情發生了是吧,這不是程式執行的問題,這個結果正符合規範的要求

  • 實際在 JavaScript 中, a <= b 會被執行為 !(a > b)
  • a >= b 同理為 b <= a。被執行為 !(b > a)

我們看剛才的例子


    obj <= obj1 會被執行為 !(obj > obj1) -> !false -> true
    obj >= obj1 會被執行為 !(obj < obj1) -> !false -> true

複製程式碼

這樣,結果就很順其自然了吧。

總結

今天介紹的型別轉換,很多知識點都是參考 KYLE SIMPSON 著有的 YOU DONT KNOW JAVASCRIPT一書。部分知識參照 ES5規範。 然後根據日常的開發,嘗試做的總結。型別轉換基本就介紹完了。介紹這一知識的目的不是讓我們在開發中寫出這些生澀的程式碼,而是讓我們透過寫的程式碼,理解其執行的本質,這樣,能讓我們寫出更好的程式碼。我們在學習的過程中,更加應該,知其然知其所以然。這樣,我們寫出來的程式碼才會又更高的可讀性和穩定性。

如有理解錯誤或者表達不清楚的地方,歡迎一起交流。

相關文章