从 ECMA 规范解析 JavaScript 默认的取值和赋值行为

4,093 阅读6分钟

前言

如果你是一个经验丰富的 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]] 操作,它在标准中是这么定义的

凭本人的渣渣英语水平大致翻译的结果是这样的

  1. 首先先会执行 [[GetProperty]] 操作,它的作用是判断对象属性是否存在于当前对象,如果存在,则直接返回这个属性,否则会递归向对象的原型链上找,找到后返回该属性,直到原型链尽头则返回 undefined
  2. 拿到第一步的结果后如果是 undefined,则 [[Get]] 的结果就是 undefined,即这个对象中没有这个属性
  3. 如果不是 undefined,会判断这个属性是否被定义了数据描述符,如果是,则返回数据描述符的 value 属性
  4. 如果这个属性被定义了访问器描述符,即 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 时,代表当前属性可以被赋值,执行以下逻辑

  1. 如果属性在当前对象上,且拥有数据描述符,则直接返回数据描述符的 value 属性,同时触发 [[DefineOwnProperty]] 这个内部方法

一般情况下,对象属性赋值一般都是执行这个逻辑并返回 value 属性作为赋值语句的结果值,举个例子

给 obj 对象的 a 属性赋值数字123,那么 123 就是 a 属性数据描述符中 value 的值,[[Put]] 操作最终返回的值就是 123,对应最后一行赋值语句的结果值

触发 [[DefineOwnProperty]] 这个内部方法 这句话又怎么理解呢?规范中 [[DefineOwnProperty]] 的行为非常复杂,这里我再举个小例子

通过拦截 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 标准