從 ECMA 規範解析 JavaScript 預設的取值和賦值行為

yeyan1996發表於2019-07-14

前言

如果你是一個經驗豐富的 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 函式就不會執行預設的賦值邏輯了啊,這不是弟弟問題麼

從 ECMA 規範解析 JavaScript 預設的取值和賦值行為

事實上 JavaScript 在訪問物件屬性或者給物件屬性賦值的時候會分別執行 [[Get]] 和 [[Put]] 操作,它們是物件內建的 2 個預設行為,無法修改

接下來我們通過 ECMA 規範來分析 JavaScript 在物件取值和賦值的時候內部究竟做了什麼

[[Get]]

當從物件中獲取某個執行值時,會執行 [[Get]] 操作,它在標準中是這麼定義的

從 ECMA 規範解析 JavaScript 預設的取值和賦值行為

從 ECMA 規範解析 JavaScript 預設的取值和賦值行為

憑本人的渣渣英語水平大致翻譯的結果是這樣的

  1. 首先先會執行 [[GetProperty]] 操作,它的作用是判斷物件屬性是否存在於當前物件,如果存在,則直接返回這個屬性,否則會遞迴向物件的原型鏈上找,找到後返回該屬性,直到原型鏈盡頭則返回 undefined
  2. 拿到第一步的結果後如果是 undefined,則 [[Get]] 的結果就是 undefined,即這個物件中沒有這個屬性
  3. 如果不是 undefined,會判斷這個屬性是否被定義了資料描述符,如果是,則返回資料描述符的 value 屬性
  4. 如果這個屬性被定義了訪問器描述符,即 get 函式,則會觸發 get 函式,並返回執行後的結果

通過標準就能很明顯的看出 JavaScript 在訪問物件屬性時執行的邏輯,當這個屬性不存在於當前物件會沿著原型鏈查詢,這就是為什麼空物件也可以呼叫 toString,valueOf 等方法,因為這些方法都存在於物件的原型鏈上,同時如果屬性定義了 get 函式也會直接返回執行的結果

[[CanPut]]

[[Put]] 比 [[Get]] 的行為要複雜一點,規範原文是這麼寫的

從 ECMA 規範解析 JavaScript 預設的取值和賦值行為

[[Put]] 方法依賴一個叫 [[CanPut]] 的內部行為,我們來看它的定義

從 ECMA 規範解析 JavaScript 預設的取值和賦值行為

首先會判斷當前屬性是否存在於當前物件中,如果存在則繼續判斷屬性是否有訪問器描述符,即 set 函式,如果 set 函式存在 [[CanPut]] 的結果為 true,否則如果訪問器描述符為 undefined 或者不合法則返回 false。或者當屬性存在於當前物件但是沒有定義訪問器描述符,那該屬性一定被定義了資料描述符, [[CanPut]] 的結果為資料描述符的 writable 值,最後當屬性不存在與當前物件,和 [[Get]] 相同會往上遍歷原型鏈,直到終點,反覆執行之前的邏輯

通俗的來說 [[CanPut]] 返回的是一個布林值,表示當前屬性是否可被賦值

[[Put]]

回到 [[Put]] 中,當 [[CanPut]] 的值是 false 時會直接退出賦值的邏輯,並且根據 Throw 這個引數,當 Throw 為 true 時,丟擲異常,反之靜默,而這個 Throw 對應的是否開啟嚴格模式,同時也驗證了嚴格模式下賦值失敗會丟擲錯誤的行為

當 [[CanPut]] 的值是 true 時,代表當前屬性可以被賦值,執行以下邏輯

  1. 如果屬性在當前物件上,且擁有資料描述符,則直接返回資料描述符的 value 屬性,同時觸發 [[DefineOwnProperty]] 這個內部方法

一般情況下,物件屬性賦值一般都是執行這個邏輯並返回 value 屬性作為賦值語句的結果值,舉個例子

從 ECMA 規範解析 JavaScript 預設的取值和賦值行為

給 obj 物件的 a 屬性賦值數字123,那麼 123 就是 a 屬性資料描述符中 value 的值,[[Put]] 操作最終返回的值就是 123,對應最後一行賦值語句的結果值

觸發 [[DefineOwnProperty]] 這個內部方法 這句話又怎麼理解呢?規範中 [[DefineOwnProperty]] 的行為非常複雜,這裡我再舉個小例子

從 ECMA 規範解析 JavaScript 預設的取值和賦值行為

通過攔截 defineProperty 和 getOwnPropertyDescriptor 可以發現,預設的賦值行為會觸發這個兩個攔截器,更多的行為有興趣的朋友可以根據底部連結自行檢視

  1. 否則如果屬性在當前物件或者原型鏈上,且擁有訪問器描述符,則讓賦值表示式右邊的值作為唯一引數傳入 set 函式並返回結果

  2. 否則如果屬性在當前物件原型鏈上,且擁有資料描述符,則在當前物件建立一個新的屬性,並讓其資料描述符的值為 {[[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 上卷

ECMA-262 標準

相關文章