如何理解Object.defineProperty()?

趁你还年轻

几乎所有使用Vue的开发者都知道,Vue的双向绑定是通过Object.defineProperty()实现的,也知道在getter中收集依赖,在setter中通知更新。

那么除了知道getter和setter之外,Object.defineProperty()还有哪些值得我们去注意的地方呢?是不是有很多细节的东西不懂呢?

你可能会说,除了getter和setter之外,Object.defineProperty()还有value,writable,enumerable,configurable。

那么问题来了?

  • value或writable与getter,setter可以共存吗?与enumerable,configurable呢?
  • 概括讲下writable,enumerable,configurable分别是什么意思?
  • enumerable在Object.keys()和for...in以及展开操作符...是如何表现的?
  • configurable会限制哪些属性不可redefine?value会被限制吗?会限制属性的删除吗?
  • 通过obj.foo和Object.defineProperty(obj,foo)方式定义的属性有何区别?
  • data descriptor、accessor descriptor、shared descriptor是什么?

如果看了上面这些问题一脸懵逼,不要惊慌,我们先来看一道非常直观易懂的题目:

// 实现下面的逻辑
console.log(a+a+a); // 'abc'

题目看完了,带着问题开始阅读下面的内容吧。
如果能耐心看完的话对于个人的前端技术提升会非常大。
往近了说,不出意外上面这些问题全部可以迎刃而解,对于a+a+a题目的题解也会理解更加透彻。
往远了说,可以去看懂Vue源码相关的实现,以及看懂任何使用到Object.defineProperty()这个API的库的源码实现,甚至最后自己写个小轮子。

  • 初识Object.defineProperty()
  • 语法

    • 参数
    • 返回值
  • Object.defineProperty()概览

    • 基本知识点
    • data和accessor两种描述符
    • 描述符必须是data, accessor之一,不能同时具有两种特性
    • 如何区分data descriptor和accessor descriptor?
    • descriptor key概览

      • 共享descriptor key概览
      • data descriptor key概览
      • accessor descriptor key概览
    • 牢记属性不仅仅是descriptor自己的属性,还要考虑继承属性
    • 三个很基础但是很好的例子

      • 默认descriptor:不可写,不可枚举,不可配置
      • 重用同一对象记忆上一次的value值
      • 冻结Object.prototype
  • Object.defineProperty()详解

    • 创建一个property
    • 修改一个property

      • Writable attribute
      • Enumerable attribute

        • 知识点
        • 在for...in中如何表现?
        • 在Object.keys()中如何表现?
        • 在展开操作符...中如何表现?
        • 如何检测属性是否可以枚举?
      • Configurable attribute
    • 增加属性和默认值
    • 自定义setter和getter
    • properties的继承
  • 如何获取属性的descriptor?
  • console.log(a+a+a); // 'abc'题解

    • 解法1: Object.defineProperty() 外部变量
    • 解法1(优化版):Object.defineProperty() 内部变量
    • 解法2: Object.prototpye.valueOf()
    • 解法3:charCodeAt,charFromCode
    • 解法3(优化版一):内部变量this._count和_code
    • 解法3(优化版二):内部变量this._code
    • 题目扩展: 打印a...z
    • 题目扩展(优化版): 打印a...z

初识Object.defineProperty()

静态方法Object.defineProperty()会直接在一个对象上定义一个新的属性,或者修改对象上已经存在的属性,然后返回这个对象。

const obj = {};
Object.defineProperty(obj, 'prop', {
    value: 42,
    writable: true
});
console.log(obj); // {prop: 42}
obj.prop = 43; // {prop: 43}

语法

Object.defineProperty(obj, prop, descriptor)

参数

  • obj 需要定义属性的对象
  • prop 需要定义或者修改的property的名字或者Symbol
  • descriptor 定义和修改的property的描述符

    返回值

    返回传递进函数的对象。

Object.defineProperty()概览

  • 基本知识点
  • data和accessor两种描述符
  • 描述符必须是data, accessor之一,不能同时具有两种特性
  • 如何区分data descriptor和accessor descriptor?
  • descriptor key概览

    • 共享descriptor key概览
    • data descriptor key概览
    • accessor descriptor key概览
  • 牢记属性不仅仅是descriptor自己的属性,还要考虑继承属性
  • 三个很基础但是很好的例子

    • 默认descriptor:不可写,不可枚举,不可配置
    • 重用同一对象记忆上一次的value值
    • 冻结Object.prototype

    基本知识点

  • Object.defineProperty()允许精准添加或者修改对象上的一个属性。
  • 通过const obj = {};obj.foo = 1这种赋值方式增添的属性,可以通过for...in或者Object.keys枚举,他的值可能发生改变,也可能被删除。
  • Object.defineProperty()允许对对象属性的默认方法做出改变。
  • 默认情况下通过Object.defineProperty()是immutable(不可变的)。不能通过delete obj.foo删除这个属性。
  • Object.defineProperty()具有data和accessor两种描述符,描述符生效时只能是其中之一,不能同时生效。
  • data和accessor两种描述符都是object,dataDescriptor = {value, writable},accessorDescriptor={get(){}, set(){}}
  • data和accessor有各自独有的key,它们也有共享的key。data accessor特有的key为value和writable,accessor descriptor特有的key为get和set。共享的key为configurable和enumerable。
  • 如果descriptor没有value, writable, get和set,会被当做一个data descriptor;如果同时有value或writable和get或set,异常会抛出。

data和accessor两种描述符

对象的属性descriptor描述符主要有两种:data descriptor和accessor descriptor。

data descriptor

数据描述符指的是value,writable,它可能是可写的,也可能是不可写的。

accessor descriptor

权限描述符指的是通过getter-setter函数get(),set()对property的描述。

描述符必须是data, accessor之一,不能同时具有两种特性

下面的代码会报错的原因破案了:只能是data,accessor 之一。

Object.defineProperty({this, 'a', {
    value: 'a', // data descriptor
    get(){
         // access descriptor
    }
})
// `Invalid property descriptor.Cannot both specify accessors and a value or writable attribue.`

如何区分data descriptor和accessor descriptor?

data accessor特有的key为value和writable。
accessor descriptor特有的key为get和set。

// 典型的data descriptor
Object.defineProperty({this, 'a', {
    value: 'a',
    writable: false
})
// 典型的accessor descriptor
Object.defineProperty({this, 'a', {
    get(){ ... }
    set(){ ... }
})

descriptor key概览

默认情况下是通过Object.defineProperty()定义属性的。

共享descriptor key概览
configurable
  • 默认值为false
  • 当且仅当属性的描述符类型可能发生变化以及属性描述符可能从对象上删除和这个属性相关联
  • configurable为false时,非data descriptor的属性不能被重定义,也就是说除value和writable之外的属性不能定义,而且特别要注意,value可以随意改,而writable仅能从true改为false。get(), set(), enumerable, configurable是都不能重新定义的。
  • 而且不能切换descriptor的类型:data descriptor和accessor descriptor
  • configurable 不仅仅影响属性的修改,还影响到了属性的删除。configurable为false时delete obj.o失效

为什么configurable设置为false时要这样设计?

  • 提升对象属性可控性
  • 提升安全性

这是因为get(), set(), enumerable, configurable是权限相关的属性,为了避免引起不必要的bug。
很多库的作者不允许自己修改这个属性,让它保持在一种可控的状态,从而代码按照自己的预期去运行。而且这样做也更加安全。

enumerable
  • 默认值为false
  • 当且仅当对象的属性枚举展示时会和这个属性相关联
data descriptor key概览
value
  • 默认值为undefined
  • 属性相关联的value
  • 可以是任何JavaScript值 number,object,function等等
writable
  • 默认是false
  • 当且仅当通过赋值操作符赋值时会和这个属性相关联
accessor descriptor key概览
get
  • 默认值为undefined
  • 作为属性的getter服务于属性,如果没有getter的话,get为undefined。
  • 当property被访问时,这个函数会在不传参的情况下调用然后,并将this设置为访问属性的对象(this由于继承可能不是定义属性的对象。)
  • 返回值会作为property的value。

    set
  • 默认值为undefined
  • 作为属性的setter服务于属性,如果没有setter的话,set为undefined。
  • 当属性重新赋值时,函数在传递一个参数的情况下调用,并将这个集合设置为属性赋值的对象。

牢记属性不仅仅是descriptor自己的属性,还要考虑继承属性

为确保保留了这些默认值:

  • 可以freeze Object.prototype
  • 或者Object.create(null)

三个很基础但是很好的例子

默认descriptor:不可写,不可枚举,不可配置
var obj = {};
var descriptor = Object.create(null); // no inherited properties
descriptor.value = 'static';

// not enumerable, not configurable, not writable as defaults
Object.defineProperty(obj, 'key', descriptor);

// being explicit
Object.defineProperty(obj, 'key', {
  enumerable: false,
  configurable: false,
  writable: false,
  value: 'static'
});
重用同一对象记忆上一次的value值
function withValue(value) {
  var d = withValue.d || (
    // 记忆住上一次的值
    withValue.d = {
      enumerable: false,
      writable: false,
      configurable: false,
      value: value
    }
  );

  // 避免重复赋值
  if (d.value !== value) d.value = value;
  return d;
}
Object.defineProperty(obj, 'key', withValue('static'));
冻结Object.prototype
Object.freeze(Object.prototype)

Object.defineProperty()详解

创建一个property

属性如果在对象上不存在的话,Object.defineProperty()会创建一个新的属性。
可以省略很多描述符中字段,并且输入这些字段的默认值。

// 创建对象
var o = {};
// 定义属性a并且传入data descriptor
Object.defineProperty(o, 'a', {
    value: 37,
    writable: true,
    enumerable: true,
    configurable: true,
})
// 定义属性b并且传入accessor descriptor
// 伪造value(好处是更细粒度的value控制):外部变量和get()
// 伪造writable(好处是更细粒度的writable控制):外部变量和set()
// 在这个例子中,o.b的值与bValue做了强关联。bValue是什么值,o.b就是什么值。除非o.b被重新定义
var bValue = 38;
Object.defineProperty(o, 'b', {
    get() { return bValue },
    set(newValue) { bValue = newVlaue },
    enumerable: true,
    configurable: true,
})
// 不可以同时混合定义两者
Object.defineProperty(o, 'conflict', {
    value: 'a',
    get() { return 'a' }
})
// 报错:Cannot both specify accessors and a value or writable
// 重新解读报错:Cannot both specify accessors descriptor and data descriptor(a value or writable)

修改一个property

  • 当一个属性在对象中存在时,Object.defineProperty()可以根据descriptor中的值和对象返回值的配置尝试修改这个属性。
  • 如果旧的descriptor有configurable属性,并且设置为false,意思是”不可配置“。

    • 意味着不能修改任意共享descriptor和accessor descriptor的属性的值
    • 可以重定义data descriptor:value任意变,writable只能从true变为false(不能从false改为true)。
    • 而且不能切换descriptor的类型:data descriptor和accessor descriptor
    • 违反规则报错:Cannot redefine property: xxx;符合规则和没有修改属性的话不报错。
    Writable attribute

    当writable设置为false时,属性是不可写的,意味着无法重新赋值。

  • 非严格模式不会报错,只是赋值失败
  • 严格模式会报错Cannot assign to read only property 'b' of object '#<Object>'

    // 非严格模式不会报错,只是赋值失败
    var o = {};
    Object.defineProperty(o, 'a', {
    value: 37,
    writable: false
    });
    console.log(o.a); // logs 37
    o.a = 25; // 不会报错
    // (只会在strict mode报错,或者值没改变也不会报错)
    console.log(o.a); // logs 37. 重新赋值没有生效
    
    // 严格模式会报错
    // strict mode
    (function() {
    'use strict';
    var o = {};
    Object.defineProperty(o, 'b', {
      value: 2,
      writable: false
    });
    o.b = 3; // 抛出Cannot assign to read only property 'b' of object '#<Object>'
    return o.b; // 2
    }());
    Enumerable attribute
  • 知识点
  • 在for...in中如何表现?
  • 在Object.keys()中如何表现?
  • 在展开操作符...中如何表现?
  • 如何检测属性是否可以枚举?

    知识点
  • enumerable属性定义了属性是否可以被Object.assign()或者spread(...) pick到。
  • 对于非symbol的属性,它还会影响到for...in和Object.keys()对属性的pick。
  • 可以用obj.propertyIsEnumerable(prop)检测属性是否可遍历。
var o = {};
Object.defineProperty(o, 'a', {
  value: 1,
  enumerable: true
});
Object.defineProperty(o, 'b', {
  value: 2,
  enumerable: false
});
Object.defineProperty(o, 'c', {
  value: 3, // enumerable默认为false
}); 
o.d = 4; // enumerable默认为true
Object.defineProperty(o, Symbol.for('e'), {
  value: 5,
  enumerable: true
});
Object.defineProperty(o, Symbol.for('f'), {
  value: 6,
  enumerable: false
});
在for...in中如何表现?

只有'a'和'd'打印了出来。
enumerable为true的都能被解构出来,不包括Symbol。

for (var i in o) {
  console.log(i); // 'a','d'
}
在Object.keys()中如何表现?

只有'a'和'd'被搜集到。
enumerable为true的都能被解构出来,不包括Symbol。

Object.keys(o); // ['a', 'd']
在展开操作符...中如何表现?

enumerable为true的都能被解构出来,包括Symbol。

var p = { ...o }
p.a // 1
p.b // undefined
p.c // undefined
p.d // 4
p[Symbol.for('e')] // 5
p[Symbol.for('f')] // undefined
如何检测属性是否可以枚举?

可以用obj.propertyIsEnumerable(prop)检测属性是否可遍历

o.propertyIsEnumerable('a'); // true
o.propertyIsEnumerable('b'); // false
o.propertyIsEnumerable('c'); // false
o.propertyIsEnumerable('d'); // true
o.propertyIsEnumerable(Symbol.for('e')); // true
o.propertyIsEnumerable(Symbol.for('f')); // false
Configurable attribute

configurable属性控制属性是否可以被修改(除value和writable外),或者属性被删除。

var o = {};
Object.defineProperty(o, 'a', {
  get() { return 1; },
  configurable: false
});

Object.defineProperty(o, 'a', {
  configurable: true
}); // throws a TypeError
Object.defineProperty(o, 'a', {
  enumerable: true
}); // throws a TypeError
Object.defineProperty(o, 'a', {
  set() {}
}); // throws a TypeError (set初始值为undefined)
Object.defineProperty(o, 'a', {
  get() { return 1; }
}); // throws a TypeError
// (即使set没有变化)
Object.defineProperty(o, 'a', {
  value: 12
}); // throws a TypeError // ('value' can be changed when 'configurable' is false but not in this case due to 'get' accessor)

console.log(o.a); // logs 1
delete o.a; // 不能删除
console.log(o.a); // logs 1

增加属性和默认值

属性的默认值很值得思考一下。
通过点操作符.赋值和通过Object.defineProperty()是有区别的。

两种赋初始值方式的区别如下

  • 通过点操作符定义的属性,writable,configurable,enumerable值都为true,value为赋入的值
  • 通过Object.defineProperty只指定value的属性,writable,configurable,enumerable值都为false
通过点操作符定义的属性

通过点操作符定义的属性等价于Object.defineProperty的data descriptor和共享descriptor为true。

var o = {};
o.a = 1;
// 等价于
Object.defineProperty(o, 'a', {
  value: 1,
  writable: true,
  configurable: true,
  enumerable: true
});
通过Object.defineProperty只指定value的属性
Object.defineProperty(o, 'a', { value: 1 });
// 等价于
Object.defineProperty(o, 'a', {
  value: 1,
  writable: false,
  configurable: false,
  enumerable: false
});

自定义setter和getter

下面的例子展示了如何实现一个自存档的对象。
当temperature属性设置后,archive数组会打印。

  • 常见的一种gettter,setter使用方式
  • 这个getter和setter总是返回相同的值

    常见的一种gettter,setter使用方式
    function Archiver() {
      var temperature = null;
      var archive = [];
      Object.defineProperty(this, 'temperature', {
          get(){
              console.log('get!');
              return temperature;
          },
          set(value) {
              temperature = value;
              archive.push({ val: temperature });
          }
      });
      this.getArchive = function(){ return archive; };
    }
    var arc = new Archiver();
    arc.temperature; // 'get'
    arc.temperature = 11;
    arc.temperature = 13;
    arc.getArchive(); // [{val: 11}, {vale: 13}]
    这个getter和setter总是返回相同的值
    var pattern = {
      get() {
          return 'I always return this string, ' +
                 'whatever you have assigned';
      },
      set() {
          this.myname = 'this is my name string';
      }
    };
    
    function TestDefineSetAndGet() {
      Object.defineProperty(this, 'myproperty', pattern);
    }
    
    var instance = new TestDefineSetAndGet();
    instance.myproperty = 'test';
    console.log(instance.myproperty);
    // I always return this string, whatever you have assigned
    
    console.log(instance.myname); // this is my name string

properties的继承

  • 如果一个accessor属性是继承的,它的get和set方法会在属性被访问时调用,并且在后代对象上被修改。如果这些方法使用变量来存储这个值,这个值会在所有对象间共享(即使使用new仍然会共享)
  • 如果一个value属性是继承的,它可以直接设置在对象上。但是,如果继承了一个不可写的值属性,它仍然会阻止修改对象上的属性。

主要为以下3个问题:

  • Object.defineProperty与prototype的问题
  • 如何解决 Object.defineProperty与prototype的问题
  • Object.defineProperty的writable和__proto__
Object.defineProperty与prototype的问题

这个例子展示了继承带来的问题:

function myclass() {
}

var value;
Object.defineProperty(myclass.prototype, "x", {
  get() {
    return value;
  },
  set(x) {
    value = x;
  }
});

var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // 1
如何解决 Object.defineProperty与prototype的问题

如何解决这个问题呢?
可以将值存储在另一个this属性上。这样使用new创建新实例时,可以为自己开辟单独的属性空间。
在get和set方法中,this指向使用、访问、修改属性的对象实例。

function myclass() {
}
Object.defineProperty(myclass.prototype, "x", {
  get() {
    return this._x;
  },
  set(x) {
    this._x = x; // 用this._x来存储value
  }
});

var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // 1
Object.defineProperty的writable和__proto__

下面的例子,点操作符赋值的属性可写,但是继承的myclass.prototype的初始值不会发生更改;不可写的属性不可写。

function myclass() {
}

myclass.prototype.x = 1;
Object.defineProperty(myclass.prototype, "y", {
  writable: false,
  value: 1
});

var a = new myclass();
a.x = 2;
console.log(a.x); // 2
console.log(myclass.prototype.x); // 1
a.y = 2; // Ignored, throws in strict mode
console.log(a.y); // 1
console.log(myclass.prototype.y); // 1

值得分析一波的截图:
image

  • __proto__是通过Object.defineProperty(foo.prototype)实现的继承
  • 如果属性的writable为true,会在__proto__的上一级创建新的属性

如何获取属性的descriptor?

Object.getOwnPropertyDescriptor(obj,prop)

使用示例:

var o = {};
Object.defineProperty(o, 'a', { value: 1 });
Object.getOwnPropertyDescriptor(o,'a')
// {
//     configurable: false
//     enumerable: false
//     value: 1
//     writable: false
// }

console.log(a+a+a); // 'abc'题解

/*
  console.log(a + a + a); // 打印'abc'
*/
  • 解法1: Object.defineProperty() 外部变量
  • 解法1(优化版):Object.defineProperty() 内部变量
  • 解法2: Object.prototpye.valueOf()
  • 解法3:charCodeAt,charFromCode
  • 解法3(优化版一):内部变量this._count和_code
  • 解法3(优化版二):内部变量this._code
  • 题目扩展: 打印a...z
  • 题目扩展(优化版): 打印a...z

    /**
     * 解法1: Object.defineProperty() 外部变量
     */
    let value = "a";
    Object.defineProperty(this, "a", {
    get() {
      let result = value;
      if (value === "a") {
        value = "b";
      } else if (value === "b") {
        value = "c";
      }
      return result;
    },
    });
    console.log(a + a + a);
    /**
     * 解法1(优化版):Object.defineProperty() 内部变量
     */
    Object.defineProperty(this, "a", {
    get() {
      this._v = this._v || "a";
      if (this._v === "a") {
        this._v = "b";
        return "a";
      } else if (this._v === "b") {
        this._v = "c";
        return "b";
      } else {
        return this._v;
      }
    },
    });
    console.log(a + a + a);
    
    /**
     * 解法2: Object.prototpye.valueOf()
     */
    let index = 0;
    let a = {
    value: "a",
    valueOf() {
      return ["a", "b", "c"][index++];
    },
    };
    console.log(a + a + a);
    
    /**
     * 解法3:charCodeAt,charFromCode
     */
    let code = "a".charCodeAt(0);
    let count = 0;
    Object.defineProperty(this, "a", {
    get() {
      let char = String.fromCharCode(code + count);
      count++;
      return char;
    },
    });
    console.log(a + a + a); // 'abc'
    
    /**
     * 解法3(优化版一):内部变量this._count和_code
     */
    Object.defineProperty(this, "a", {
    get() {
      let _code = "a".charCodeAt(0);
      this._count = this._count || 0;
      let char = String.fromCharCode(_code + this._count);
      this._count++;
      return char;
    },
    });
    console.log(a + a + a); // 'abc'
    
    /**
     * 解法3(优化版二):内部变量this._code
     */
    Object.defineProperty(this, "a", {
    get() {
      this._code = this._code || "a".charCodeAt(0);
      let char = String.fromCharCode(this._code);
      this._code++;
      return char;
    },
    });
    console.log(a + a + a); // 'abc'
    
    /*
     题目扩展: 打印`a...z`
     a+a+a; //'abc'
     a+a+a+a; //'abcd'
    */
    /**
     * charCodeAt,charFromCode
     */
    let code = "a".charCodeAt(0);
    let count = 0;
    Object.defineProperty(this, "a", {
    get() {
      let char = String.fromCharCode(code + count);
      if (count >= 26) {
        return "";
      }
      count++;
      return char;
    },
    });
    // 打印‘abc’
    console.log(a + a + a); // 'abc'
    
    // 打印‘abcd’
    let code = "a".charCodeAt(0);
    let count = 0;
    // {...定义a...}
    console.log(a + a + a); // 'abcd'
    
    // 打印‘abcdefghijklmnopqrstuvwxyz’
    let code = "a".charCodeAt(0);
    let count = 0;
    // {...定义a...}
    let str = "";
    for (let i = 0; i < 27; i++) {
    str += a;
    }
    console.log(str); // "abcdefghijklmnopqrstuvwxyz"
    
    /*
     题目扩展(优化版): 打印`a...z`
     a+a+a; //'abc'
     a+a+a+a; //'abcd'
    */
    
    Object.defineProperty(this, "a", {
    get() {
      this._code = this._code || "a".charCodeAt(0);
      let char = String.fromCharCode(this._code);
      if (this._code >= "a".charCodeAt(0) + 26) {
        return "";
      }
      this._code++;
      return char;
    },
    });
    // 打印‘abc’
    console.log(a + a + a); // 'abc'
    

参考资料:
https://developer.mozilla.org...
https://developer.mozilla.org...
https://developer.mozilla.org...

期待和大家交流,共同进步:

  • 微信公众号: 大大大前端 / excellent_developers
  • 前端问答互助星球:t.zsxq.com/yBA2Biq

努力成为优秀前端工程师!
阅读 2.6k

趁你还年轻,做个优秀的前端工程师
努力成为优秀的前端工程师! 文章在微信公众号:大大大前端 同步更新,期待你的关注~
4k 声望
4.1k 粉丝
0 条评论
4k 声望
4.1k 粉丝
文章目录
宣传栏