前言
如果你是一個經驗豐富的 Vue 開發者,那麼你一定知道 Vue 的響應式原理是通過攔截物件的 get 和 set 實現的
// src/core/observer/index.js
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
//...
},
set: function reactiveSetter (newVal) {
//...
}
})
複製程式碼
所以當給響應式變數賦值的時候就會觸發其中的 set 函式,從而更新檢視
<template>
<div >{{message}}</div>
</template>
<script>
export default {
data() {
return {
message:'hello world'
}
},
mounted() {
this.message = 'hello Vue'
}
}
</script>
複製程式碼
本文和 Vue 框架其實並沒有什麼關係,但是我們來思考一個問題
為什麼給響應式變數賦值會觸發 set 函式,而不是直接賦值?
你給物件的屬性定義了 set 函式就不會執行預設的賦值邏輯了啊,這不是弟弟問題麼
事實上 JavaScript 在訪問物件屬性或者給物件屬性賦值的時候會分別執行 [[Get]] 和 [[Put]] 操作,它們是物件內建的 2 個預設行為,無法修改
接下來我們通過 ECMA 規範來分析 JavaScript 在物件取值和賦值的時候內部究竟做了什麼
[[Get]]
當從物件中獲取某個執行值時,會執行 [[Get]] 操作,它在標準中是這麼定義的
憑本人的渣渣英語水平大致翻譯的結果是這樣的
- 首先先會執行 [[GetProperty]] 操作,它的作用是判斷物件屬性是否存在於當前物件,如果存在,則直接返回這個屬性,否則會遞迴向物件的原型鏈上找,找到後返回該屬性,直到原型鏈盡頭則返回 undefined
- 拿到第一步的結果後如果是 undefined,則 [[Get]] 的結果就是 undefined,即這個物件中沒有這個屬性
- 如果不是 undefined,會判斷這個屬性是否被定義了資料描述符,如果是,則返回資料描述符的 value 屬性
- 如果這個屬性被定義了訪問器描述符,即 get 函式,則會觸發 get 函式,並返回執行後的結果
通過標準就能很明顯的看出 JavaScript 在訪問物件屬性時執行的邏輯,當這個屬性不存在於當前物件會沿著原型鏈查詢,這就是為什麼空物件也可以呼叫 toString,valueOf 等方法,因為這些方法都存在於物件的原型鏈上,同時如果屬性定義了 get 函式也會直接返回執行的結果
[[CanPut]]
[[Put]] 比 [[Get]] 的行為要複雜一點,規範原文是這麼寫的
[[Put]] 方法依賴一個叫 [[CanPut]] 的內部行為,我們來看它的定義
首先會判斷當前屬性是否存在於當前物件中,如果存在則繼續判斷屬性是否有訪問器描述符,即 set 函式,如果 set 函式存在 [[CanPut]] 的結果為 true,否則如果訪問器描述符為 undefined 或者不合法則返回 false。或者當屬性存在於當前物件但是沒有定義訪問器描述符,那該屬性一定被定義了資料描述符, [[CanPut]] 的結果為資料描述符的 writable 值,最後當屬性不存在與當前物件,和 [[Get]] 相同會往上遍歷原型鏈,直到終點,反覆執行之前的邏輯
通俗的來說 [[CanPut]] 返回的是一個布林值,表示當前屬性是否可被賦值
[[Put]]
回到 [[Put]] 中,當 [[CanPut]] 的值是 false 時會直接退出賦值的邏輯,並且根據 Throw 這個引數,當 Throw 為 true 時,丟擲異常,反之靜默,而這個 Throw 對應的是否開啟嚴格模式,同時也驗證了嚴格模式下賦值失敗會丟擲錯誤的行為
當 [[CanPut]] 的值是 true 時,代表當前屬性可以被賦值,執行以下邏輯
- 如果屬性在當前物件上,且擁有資料描述符,則直接返回資料描述符的 value 屬性,同時觸發 [[DefineOwnProperty]] 這個內部方法
一般情況下,物件屬性賦值一般都是執行這個邏輯並返回 value 屬性作為賦值語句的結果值,舉個例子
給 obj 物件的 a 屬性賦值數字123,那麼 123 就是 a 屬性資料描述符中 value 的值,[[Put]] 操作最終返回的值就是 123,對應最後一行賦值語句的結果值
而 觸發 [[DefineOwnProperty]] 這個內部方法
這句話又怎麼理解呢?規範中 [[DefineOwnProperty]] 的行為非常複雜,這裡我再舉個小例子
通過攔截 defineProperty 和 getOwnPropertyDescriptor 可以發現,預設的賦值行為會觸發這個兩個攔截器,更多的行為有興趣的朋友可以根據底部連結自行檢視
-
否則如果屬性在當前物件或者原型鏈上,且擁有訪問器描述符,則讓賦值表示式右邊的值作為唯一引數傳入 set 函式並返回結果
-
否則如果屬性在當前物件原型鏈上,且擁有資料描述符,則在當前物件建立一個新的屬性,並讓其資料描述符的值為
{[[Value]]: V, [[Writable]]: true, [[Enumerable]]: true, [[Configurable]]: true}.
,並拋棄原來的資料描述符,同時觸發 [[DefineOwnProperty]] 內部方法並返回
什麼意思呢,考慮以下情況
let obj = {}
Object.defineProperty(Object.prototype, 'a', {
configurable: false,
enumerable: false,
value: "",
writable: true
})
obj.a = 1
console.log(Object.getOwnPropertyDescriptor(obj,'a'))
// {value: 1, writable: true, enumerable: true, configurable: true}
複製程式碼
obj 物件並沒有屬性 a,而在 Object 的原型物件中定義了一個 a 屬性,其資料描述符的 configurable,enumerable 都為 false,但最終賦值的時候 obj 物件上會存在一個 a 屬性,同時 configurable,enumerable 都為 true
總結
結合《你不知道的 JavaScript 上卷》中對 [[Get]] 和 [[Put]] 的定義,可以得出以下結論
當給物件取值時,會觸發 [[Get]] 操作,如果當前物件上有該屬性,則判斷
- 含有 get 函式時,執行 get 函式,返回執行結果,
- 沒有 get 函式時,返回資料描述符的 value 屬性
如果當前物件上沒有該屬性,會向上查詢原型鏈,直到盡頭,查詢過程中會反覆執行上面兩步
當給物件賦值時,會觸發 [[Put]] ( 不是理想中的 [[Set]] ),如果當前物件上有該屬性,則判斷
- writable 為 true 時,執行賦值操作
- writable 為 false 時,嚴格模式會丟擲錯誤,非嚴格模式下靜默失敗
- 含有 set 函式時,執行 set 函式
如果當前物件沒有該屬性,會向上查詢原型鏈,如果在原型鏈上層找到該屬性,則判斷
- writable 為 true 時,會在當前物件(非原型鏈)建立屬性,且設定資料描述符 configurable,enumerable,writable 為 true,value 為賦值的值
- writable 為 false 時,嚴格模式會丟擲錯誤,非嚴格模式下靜默失敗
- 含有 set 函式時,執行 set 函式
如果屬性是資料描述符的話還會觸發內部的 [[DefineOwnProperty]] 操作,如果定義了 defineProperty 和 getOwnPropertyDescriptor 會觸發這兩個攔截器
參考資料
你不知道的 JavaScript 上卷