javascript 隱式轉換

wdapp發表於2020-02-21

你有沒有在面試中遇到特別奇葩的js隱形轉換的面試題,第一反應是怎麼會是這樣呢?難以自信,js到底是怎麼去計算得到結果,你是否有深入去了解其原理呢?下面將深入講解其實現原理。 其實這篇文章初稿三個月前就寫好了,在我讀一些原始碼庫時,遇到了這些基礎知識,想歸檔整理下,就有了這篇文章。由於一直忙沒時間整理,最近看到了這個比較熱的題,決定把這篇文章整理下。

const a = {
  i: 1,
  toString: function () {
    return a.i++;
  }
}
if (a == 1 && a == 2 && a == 3) {
  console.log('hello world!');
}
複製程式碼

網上給出了很多不錯的解析過程,讀了下面內容,你將更深入的瞭解其執行過程。

1、js資料型別

js中有7種資料型別,可以分為兩類:原始型別、物件型別:

基礎型別(原始值):

Undefined、 Null、 String、 Number、 Boolean、 Symbol (es6新出的,本文不討論這種型別)

複製程式碼

複雜型別(物件值):

object
複製程式碼

2、三種隱式轉換型別

js中一個難點就是js隱形轉換,因為js在一些操作符下其型別會做一些變化,所以js靈活,同時造成易出錯,並且難以理解。

涉及隱式轉換最多的兩個運算子 + 和 ==。

+運算子即可數字相加,也可以字串相加。所以轉換時很麻煩。== 不同於===,故也存在隱式轉換。- * / 這些運算子只會針對number型別,故轉換的結果只能是轉換成number型別。

既然要隱式轉換,那到底怎麼轉換呢,應該有一套轉換規則,才能追蹤最終轉換成什麼了。

隱式轉換中主要涉及到三種轉換:

  1. 將值轉為原始值,ToPrimitive()。
  2. 將值轉為數字,ToNumber()。
  3. 將值轉為字串,ToString()。

2.1、通過ToPrimitive將值轉換為原始值

js引擎內部的抽象操作ToPrimitive有著這樣的簽名:

ToPrimitive(input, PreferredType?)

input是要轉換的值,PreferredType是可選引數,可以是Number或String型別。他只是一個轉換標誌,轉化後的結果並不一定是這個引數所值的型別,但是轉換結果一定是一個原始值(或者報錯)。

2.1.1、如果PreferredType被標記為Number,則會進行下面的操作流程來轉換輸入的值。

1、如果輸入的值已經是一個原始值,則直接返回它
2、否則,如果輸入的值是一個物件,則呼叫該物件的valueOf()方法,
   如果valueOf()方法的返回值是一個原始值,則返回這個原始值。
3、否則,呼叫這個物件的toString()方法,如果toString()方法返回的是一個原始值,則返回這個原始值。
4、否則,丟擲TypeError異常。

複製程式碼

2.1.2、如果PreferredType被標記為String,則會進行下面的操作流程來轉換輸入的值。

1、如果輸入的值已經是一個原始值,則直接返回它
2、否則,呼叫這個物件的toString()方法,如果toString()方法返回的是一個原始值,則返回這個原始值。
3、否則,如果輸入的值是一個物件,則呼叫該物件的valueOf()方法,
   如果valueOf()方法的返回值是一個原始值,則返回這個原始值。
4、否則,丟擲TypeError異常。
複製程式碼

既然PreferredType是可選引數,那麼如果沒有這個引數時,怎麼轉換呢?PreferredType的值會按照這樣的規則來自動設定:

1、該物件為Date型別,則PreferredType被設定為String
2、否則,PreferredType被設定為Number
複製程式碼

2.1.3、valueOf方法和toString方法解析

上面主要提及到了valueOf方法和toString方法,那這兩個方法在物件裡是否一定存在呢?答案是肯定的。在控制檯輸出Object.prototype,你會發現其中就有valueOf和toString方法,而Object.prototype是所有物件原型鏈頂層原型,所有物件都會繼承該原型的方法,故任何物件都會有valueOf和toString方法。

先看看物件的valueOf函式,其轉換結果是什麼?對於js的常見內建物件:Date, Array, Math, Number, Boolean, String, Array, RegExp, Function。

1、Number、Boolean、String這三種建構函式生成的基礎值的物件形式,通過valueOf轉換後會變成相應的原始值。如:

var num = new Number('123');
num.valueOf(); // 123

var str = new String('12df');
str.valueOf(); // '12df'

var bool = new Boolean('fd');
bool.valueOf(); // true
複製程式碼

2、Date這種特殊的物件,其原型Date.prototype上內建的valueOf函式將日期轉換為日期的毫秒的形式的數值。

var a = new Date();
a.valueOf(); // 1515143895500
複製程式碼

3、除此之外返回的都為this,即物件本身:(有問題歡迎告知)

var a = new Array();
a.valueOf() === a; // true

var b = new Object({});
b.valueOf() === b; // true
複製程式碼

再來看看toString函式,其轉換結果是什麼?對於js的常見內建物件:Date, Array, Math, Number, Boolean, String, Array, RegExp, Function。

1、Number、Boolean、String、Array、Date、RegExp、Function這幾種建構函式生成的物件,通過toString轉換後會變成相應的字串的形式,因為這些建構函式上封裝了自己的toString方法。如:

Number.prototype.hasOwnProperty('toString'); // true
Boolean.prototype.hasOwnProperty('toString'); // true
String.prototype.hasOwnProperty('toString'); // true
Array.prototype.hasOwnProperty('toString'); // true
Date.prototype.hasOwnProperty('toString'); // true
RegExp.prototype.hasOwnProperty('toString'); // true
Function.prototype.hasOwnProperty('toString'); // true

var num = new Number('123sd');
num.toString(); // 'NaN'

var str = new String('12df');
str.toString(); // '12df'

var bool = new Boolean('fd');
bool.toString(); // 'true'

var arr = new Array(1,2);
arr.toString(); // '1,2'

var d = new Date();
d.toString(); // "Wed Oct 11 2017 08:00:00 GMT+0800 (中國標準時間)"

var func = function () {}
func.toString(); // "function () {}"
複製程式碼

除這些物件及其例項化物件之外,其他物件返回的都是該物件的型別,(有問題歡迎告知),都是繼承的Object.prototype.toString方法。

var obj = new Object({});
obj.toString(); // "[object Object]"

Math.toString(); // "[object Math]"
複製程式碼

從上面valueOf和toString兩個函式對物件的轉換可以看出為什麼對於ToPrimitive(input, PreferredType?),PreferredType沒有設定的時候,除了Date型別,PreferredType被設定為String,其它的會設定成Number。

因為valueOf函式會將Number、String、Boolean基礎型別的物件型別值轉換成 基礎型別,Date型別轉換為毫秒數,其它的返回物件本身,而toString方法會將所有物件轉換為字串。顯然對於大部分物件轉換,valueOf轉換更合理些,因為並沒有規定轉換型別,應該儘可能保持原有值,而不應該想toString方法一樣,一股腦將其轉換為字串。

所以對於沒有指定PreferredType型別時,先進行valueOf方法轉換更好,故將PreferredType設定為Number型別。

而對於Date型別,其進行valueOf轉換為毫秒數的number型別。在進行隱式轉換時,沒有指定將其轉換為number型別時,將其轉換為那麼大的number型別的值顯然沒有多大意義。(不管是在+運算子還是==運算子)還不如轉換為字串格式的日期,所以預設Date型別會優先進行toString轉換。故有以上的規則:

PreferredType沒有設定時,Date型別的物件,PreferredType預設設定為String,其他型別物件PreferredType預設設定為Number。

2.2、通過ToNumber將值轉換為數字

根據引數型別進行下面轉換:

引數 結果
undefined NaN
null +0
布林值 1或0
數字 true轉換1,false轉換為+0
字串 有字串解析為數字,例如:‘324’轉換為324,‘qwer’轉換為NaN
物件 先進行 ToPrimitive(obj, Number)轉換得到原始值,在進行ToNumber轉換為數字

2.3、通過ToString將值轉換為字串

根據引數型別進行下面轉換:

引數 結果
undefined 'undefined'
null 'null'
布林值 轉換為'true' 或 'false'
數字 數字轉換字串,比如:1.765轉為'1.765'
字串 無須轉換
物件 先進行 ToPrimitive(obj, String)轉換得到原始值,在進行ToString轉換為字串

講了這麼多,是不是還不是很清晰,先來看看一個例子:

({} + {}) = ?
兩個物件的值進行+運算子,肯定要先進行隱式轉換為原始型別才能進行計算。
1、進行ToPrimitive轉換,由於沒有指定PreferredType型別,{}會使預設值為Number,進行ToPrimitive(input, Number)運算。
2、所以會執行valueOf方法,({}).valueOf(),返回的還是{}物件,不是原始值。
3、繼續執行toString方法,({}).toString(),返回"[object Object]",是原始值。
故得到最終的結果,"[object Object]" + "[object Object]" = "[object Object][object Object]"
複製程式碼

再來一個指定型別的例子:

2 * {} = ?
1、首先*運算子只能對number型別進行運算,故第一步就是對{}進行ToNumber型別轉換。
2、由於{}是物件型別,故先進行原始型別轉換,ToPrimitive(input, Number)運算。
3、所以會執行valueOf方法,({}).valueOf(),返回的還是{}物件,不是原始值。
4、繼續執行toString方法,({}).toString(),返回"[object Object]",是原始值。
5、轉換為原始值後再進行ToNumber運算,"[object Object]"就轉換為NaN。
故最終的結果為 2 * NaN = NaN
複製程式碼

3、== 運算子隱式轉換

== 運算子的規則規律性不是那麼強,按照下面流程來執行,es5文件

比較運算 x==y, 其中 x 和 y 是值,返回 true 或者 false。這樣的比較按如下方式進行:

1、若 Type(x) 與 Type(y) 相同, 則

    1* 若 Type(x) 為 Undefined, 返回 true。
    2* 若 Type(x) 為 Null, 返回 true。
    3* 若 Type(x) 為 Number, 則
  
        (1)、若 x 為 NaN, 返回 false。
        (2)、若 y 為 NaN, 返回 false。
        (3)、若 x 與 y 為相等數值, 返回 true。
        (4)、若 x 為 +0 且 y 為 −0, 返回 true。
        (5)、若 x 為 −0 且 y 為 +0, 返回 true。
        (6)、返回 false。
        
    4* 若 Type(x) 為 String, 則當 x 和 y 為完全相同的字元序列(長度相等且相同字元在相同位置)時返回 true。 否則, 返回 false。
    5* 若 Type(x) 為 Boolean, 當 x 和 y 為同為 true 或者同為 false 時返回 true。 否則, 返回 false。
    6*  當 x 和 y 為引用同一物件時返回 true。否則,返回 false。
  
2、若 x 為 null 且 y 為 undefined, 返回 true。
3、若 x 為 undefined 且 y 為 null, 返回 true。
4、若 Type(x) 為 Number 且 Type(y) 為 String,返回比較 x == ToNumber(y) 的結果。
5、若 Type(x) 為 String 且 Type(y) 為 Number,返回比較 ToNumber(x) == y 的結果。
6、若 Type(x) 為 Boolean, 返回比較 ToNumber(x) == y 的結果。
7、若 Type(y) 為 Boolean, 返回比較 x == ToNumber(y) 的結果。
8、若 Type(x) 為 String 或 Number,且 Type(y) 為 Object,返回比較 x == ToPrimitive(y) 的結果。
9、若 Type(x) 為 Object 且 Type(y) 為 String 或 Number, 返回比較 ToPrimitive(x) == y 的結果。
10、返回 false複製程式碼

上面主要分為兩類,x、y型別相同時,和型別不相同時。 型別相同時,沒有型別轉換,主要注意NaN不與任何值相等,包括它自己,即NaN !== NaN。 型別不相同時,

  1. x,y 為null、undefined兩者中一個 // 返回true
  2. x、y為Number和String型別時,則轉換為Number型別比較。
  3. 有Boolean型別時,Boolean轉化為Number型別比較。
  4. 一個Object型別,一個String或Number型別,將Object型別進行原始轉換後,按上面流程進行原始值比較。
原始基本資料型別 (==) null undefined number string boolean
null true true +0 == number +0 == toNumber(string) +0 == toNumber(boolean)
undefined true true toNumber(undefined) == number toNumber(undefined)==toNumber(string) toNumber(undefined)==toNumber(boolean)
number number == +0 number == NaN number == number number == toNumber(string) number == toNumber(boolean)
string toNumber(string) == +0 toNumber(string) == toNumber(undefined) toNumber(string) == number 比較字元、長度、位置 toNumber(string) == toNumber(boolean)
boolean toNumber(boolean) == +0 toNumber(boolean) == NaN toNumber(boolean) == number toNumber(boolean) == toNumber(string) toNumber(boolean) == toNumber(boolean)

3.1、== 例子解析

所以型別不相同時,可以會進行上面幾條的比較,比如:

var a = {
  valueOf: function () {
     return 1;
  },
  toString: function () {
     return '123'
  }
}
true == a // true;
首先,x與y型別不同,x為boolean型別,則進行ToNumber轉換為1,為number型別。
接著,x為number,y為object型別,對y進行原始轉換,ToPrimitive(a, ?),沒有指定轉換型別,預設number型別。
而後,ToPrimitive(a, Number)首先呼叫valueOf方法,返回1,得到原始型別1。
最後 1 == 1, 返回true複製程式碼

我們再看一段很複雜的比較,如下:

[] == !{}
//
1、! 運算子優先順序高於==,故先進行!運算。
2、!{}運算結果為false,結果變成 [] == false比較。
3、根據上面第7條,等式右邊y = ToNumber(false) = 0。結果變成 [] == 0。
4、按照上面第9條,比較變成ToPrimitive([]) == 0。
    按照上面規則進行原始值轉換,[]會先呼叫valueOf函式,返回this。
   不是原始值,繼續呼叫toString方法,x = [].toString() = ''。
   故結果為 '' == 0比較。
5、根據上面第5條,等式左邊x = ToNumber('') = 0。
   所以結果變為: 0 == 0,返回true,比較結束。

複製程式碼

最後我們看看文章開頭說的那道題目:

const a = {
  i: 1,
  toString: function () {
    return a.i++;
  }
}
if (a == 1 && a == 2 && a == 3) {
  console.log('hello world!');
}
複製程式碼
  1. 當執行a == 1 && a == 2 && a == 3 時,會從左到右一步一步解析,首先 a == 1,會進行上面第9步轉換。ToPrimitive(a, Number) == 1。
  2. ToPrimitive(a, Number),按照上面原始型別轉換規則,會先呼叫valueOf方法,a的valueOf方法繼承自Object.prototype。返回a本身,而非原始型別,故會呼叫toString方法。
  3. 因為toString被重寫,所以會呼叫重寫的toString方法,故返回1,注意這裡是i++,而不是++i,它會先返回i,在將i+1。故ToPrimitive(a, Number) = 1。也就是1 == 1,此時i = 1 + 1 = 2。
  4. 執行完a == 1返回true,會執行a == 2,同理,會呼叫ToPrimitive(a, Number),同上先呼叫valueOf方法,在呼叫toString方法,由於第一步,i = 2此時,ToPrimitive(a, Number) = 2, 也就是2 == 2, 此時i = 2 + 1。
  5. 同上可以推導 a == 3也返回true。故最終結果 a == 1 && a == 2 && a == 3返回true 其實瞭解了以上隱形轉換的原理,你有沒有發現這些隱式轉換並沒有想象中那麼難。

相關文章