從規範的角度解析物件 — 原始值轉換
阿新 • • 發佈:2020-11-13
# 物件 — 原始值轉換
當物件相加 `obj1 + obj2`,相減 `obj1 - obj2`,或者使用 `alert(obj)` 列印時會發生什麼?
在這種情況下,物件會被自動轉換為原始值,然後執行操作。
在 [型別轉換](https://zh.javascript.info/object-toprimitive) 一章中,我們已經看到了數值,字串和布林轉換的規則。但是我們沒有講物件的轉換規則。現在我們已經掌握了方法(method)和 symbol 的相關知識,可以開始學習物件原始值轉換了。
1. 所有的物件在布林上下文(context)中均為 `true`。所以對於物件,不存在 to-boolean 轉換,只有字串和數值轉換。
2. 數值轉換髮生在物件相減或應用數學函式時。例如,`Date` 物件(將在 [日期和時間](https://zh.javascript.info/date) 一章中介紹)可以相減,`date1 - date2` 的結果是兩個日期之間的差值。
3. 至於字串轉換 —— 通常發生在我們像 `alert(obj)` 這樣輸出一個物件和類似的上下文中。
## ToPrimitive
我們可以使用特殊的物件方法,對字串和數值轉換進行微調。
下面是三個型別轉換的變體,被稱為 "hint",在 [規範](https://tc39.github.io/ecma262/#sec-toprimitive) 中有詳細介紹(譯註:當一個物件被用在需要原始值的上下文中時,例如,在 `alert` 或數學運算中,物件會被轉換為原始值):
`"string"`: 物件到字串的轉換,當我們對期望一個字串的物件執行操作時,如 "alert":
```js
// 輸出
alert(obj);
// 將物件作為屬性鍵
anotherObj[obj] = 123;
```
`"number"`: 物件到數字的轉換,例如當我們進行數學運算時:
```js
// 顯式轉換
let num = Number(obj);
// 數學運算(除了二進位制加法)
let n = +obj; // 一元加法
let delta = date1 - date2;
// 小於/大於的比較
let greater = user1 > user2;
```
`"default"`: 在少數情況下發生,當運算子“不確定”期望值的型別時。
例如,二進位制加法 `+` 可用於字串(連線),也可以用於數字(相加),所以字串和數字這兩種型別都可以。因此,當二元加法得到物件型別的引數時,它將依據 `"default"` hint 來對其進行轉換。
此外,如果物件被用於與字串、數字或 symbol 進行 `==` 比較,這時到底應該進行哪種轉換也不是很明確,因此使用 `"default"` hint。
```js
// 二元加法使用預設 hint
let total = obj1 + obj2;
// obj == number 使用預設 hint
if (user == 1) { ... };
```
像 `<` 和 `>` 這樣的小於/大於比較運算子,也可以同時用於字串和數字。不過,它們使用 "number" hint,而不是 "default"。這是歷史原因。
實際上,我們沒有必要記住這些奇特的細節,除了一種情況(`Date` 物件,我們稍後會學到它)之外,所有內建物件都以和 `"number"` 相同的方式實現 `"default"` 轉換。我們也可以這樣做。
> **沒有 "boolean" hint**
請注意 —— 只有三種 hint。就這麼簡單。
>
> 沒有 "boolean" hint(在布林上下文中所有物件都是 `true`)或其他任何東西。如果我們將 `"default"` 和 `"number"` 視為相同,就像大多數內建函式一樣,那麼就只有兩種轉換了。
**為了進行轉換,JavaScript 嘗試查詢並呼叫三個物件方法:**
1. 呼叫 `obj[Symbol.toPrimitive](hint)` —— 帶有 symbol 鍵 `Symbol.toPrimitive`(系統 symbol)的方法,如果這個方法存在的話,
2. 否則,如果 hint 是 `"string"` —— 嘗試 `obj.toString()` 和 `obj.valueOf()`,無論哪個存在。
3. 否則,如果 hint 是 `"number"` 或 `"default"` —— 嘗試 `obj.valueOf()` 和 `obj.toString()`,無論哪個存在。
## Symbol.toPrimitive
我們從第一個方法開始。有一個名為 `Symbol.toPrimitive` 的內建 symbol,它被用來給轉換方法命名,像這樣:
```js
obj[Symbol.toPrimitive] = function(hint) {
// 返回一個原始值
// hint = "string"、"number" 和 "default" 中的一個
}
```
例如,這裡 `user` 物件實現了它:
```js
let user = {
name: "John",
money: 1000,
[Symbol.toPrimitive](hint) {
alert(`hint: ${hint}`);
return hint == "string" ? `{name: "${this.name}"}` : this.money;
}
};
// 轉換演示:
alert(user); // hint: string -> {name: "John"}
alert(+user); // hint: number -> 1000
alert(user + 500); // hint: default -> 1500
```
從程式碼中我們可以看到,根據轉換的不同,`user` 變成一個自描述字串或者一個金額。單個方法 `user[Symbol.toPrimitive]` 處理了所有的轉換情況。
## toString/valueOf
方法 `toString` 和 `valueOf` 來自上古時代。它們不是 symbol(那時候還沒有 symbol 這個概念),而是“常規的”字串命名的方法。它們提供了一種可選的“老派”的實現轉換的方法。
如果沒有 `Symbol.toPrimitive`,那麼 JavaScript 將嘗試找到它們,並且按照下面的順序進行嘗試:
- 對於 "string" hint,`toString -> valueOf`。
- 其他情況,`valueOf -> toString`。
這些方法必須返回一個原始值。如果 `toString` 或 `valueOf` 返回了一個物件,那麼返回值會被忽略(和這裡沒有方法的時候相同)。
預設情況下,普通物件具有 `toString` 和 `valueOf` 方法:
- `toString` 方法返回一個字串 `"[object Object]"`。
- `valueOf` 方法返回物件自身。
下面是一個示例:
```js
let user = {name: "John"};
alert(user); // [object Object]
alert(user.valueOf() === user); // true
```
所以,如果我們嘗試將一個物件當做字串來使用,例如在 `alert` 中,那麼在預設情況下我們會看到 `[object Object]`。
這裡提到預設值 `valueOf` 只是為了完整起見,以避免混淆。正如你看到的,它返回物件本身,因此被忽略。別問我為什麼,那是歷史原因。所以我們可以假設它根本就不存在。
讓我們實現一下這些方法。
例如,這裡的 `user` 執行和前面提到的那個 `user` 一樣的操作,使用 `toString` 和 `valueOf` 的組合(而不是 `Symbol.toPrimitive`):
```js
let user = {
name: "John",
money: 1000,
// 對於 hint="string"
toString() {
return `{name: "${this.name}"}`;
},
// 對於 hint="number" 或 "default"
valueOf() {
return this.money;
}
};
alert(user); // toString -> {name: "John"}
alert(+user); // valueOf -> 1000
alert(user + 500); // valueOf -> 1500
```
我們可以看到,執行的動作和前面使用 `Symbol.toPrimitive` 的那個例子相同。
通常我們希望有一個“全能”的地方來處理所有原始轉換。在這種情況下,我們可以只實現 `toString`,就像這樣:
```js
let user = {
name: "John",
toString() {
return this.name;
}
};
alert(user); // toString -> John
alert(user + 500); // toString -> John500
```
如果沒有 `Symbol.toPrimitive` 和 `valueOf`,`toString` 將處理所有原始轉換。
## 返回型別
關於所有原始轉換方法,有一個重要的點需要知道,就是它們不一定會返回 "hint" 的原始值。
沒有限制 `toString()` 是否返回字串,或 `Symbol.toPrimitive` 方法是否為 hint "number" 返回數字。
唯一強制性的事情是:這些方法必須返回一個原始值,而不是物件。
> **歷史原因**
>
> 由於歷史原因,如果 `toString` 或 `valueOf` 返回一個物件,則不會出現 error,但是這種值會被忽略(就像這種方法根本不存在)。這是因為在 JavaScript 語言發展初期,沒有很好的 "error" 的概念。
>
> 相反,`Symbol.toPrimitive` **必須** 返回一個原始值,否則就會出現 error。
## 進一步的轉換
我們已經知道,許多運算子和函式執行型別轉換,例如乘法 `*` 將運算元轉換為數字。
如果我們將物件作為引數傳遞,則會出現兩個階段:
1. 物件被轉換為原始值(通過前面我們描述的規則)。
2. 如果生成的原始值的型別不正確,則繼續進行轉換。
例如:
```js
let obj = {
// toString 在沒有其他方法的情況下處理所有轉換
toString() {
return "2";
}
};
alert(obj * 2); // 4,物件被轉換為原始值字串 "2",之後它被乘法轉換為數字 2。
```
1. 乘法 `obj * 2` 首先將物件轉換為原始值(字串 "2")。
2. 之後 `"2" * 2` 變為 `2 * 2`(字串被轉換為數字)。
二元加法在同樣的情況下會將其連線成字串,因為它更願意接受字串:
```js
let obj = {
toString() {
return "2";
}
};
alert(obj + 2); // 22("2" + 2)被轉換為原始值字串 => 級聯
```
## 總結
物件到原始值的轉換,是由許多期望以原始值作為值的內建函式和運算子自動呼叫的。
這裡有三種類型(hint):
- `"string"`(對於 `alert` 和其他需要字串的操作)
- `"number"`(對於數學運算)
- `"default"`(少數運算子)
規範明確描述了哪個運算子使用哪個 hint。很少有運算子“不知道期望什麼”並使用 `"default"` hint。通常對於內建物件,`"default"` hint 的處理方式與 `"number"` 相同,因此在實踐中,最後兩個 hint 常常合併在一起。
轉換演算法是:
1. 呼叫 `obj[Symbol.toPrimitive](hint)` 如果這個方法存在,
2. 否則,如果 hint 是 `"string"`
- 嘗試 `obj.toString()` 和 `obj.valueOf()`,無論哪個存在。
3. 否則,如果 hint 是 `"number"` 或者 `"default"`
- 嘗試 `obj.valueOf()` 和 `obj.toString()`,無論哪個存在。
在實踐中,為了便於進行日誌記錄或除錯,對於所有能夠返回一種“可讀性好”的物件的表達形式的轉換,只實現以 `obj.toString()` 作為全能轉換的方法就夠了。
---
> 現代 JavaScript 教程:開源的現代 JavaScript 從入門到進階的優質教程。[React 官方文件推薦,與 MDN 並列的 JavaScript 學習教程](https://zh-hans.reactjs.org/docs/getting-started.html#javascript-resources)。
>
> 線上免費閱讀:https://zh.javascript.info
---
**微信掃描下方二維碼,關注公眾號「技術漫談」,訂閱更多精彩內容。**
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6d62b21deb3b47f7bd7c610d3c658da2~tplv-k3u1fbpfcp-zoom-1