1. 程式人生 > 實用技巧 >標準庫:屬性描述物件

標準庫:屬性描述物件

標準庫:屬性描述物件

概述

JavaScript 提供了一個內部資料結構,用來描述物件的屬性,控制它的行為,比如該屬性是否可寫、可遍歷等等。這個內部資料結構稱為“屬性描述物件”(attributes object)。每個屬性都有自己對應的屬性描述物件,儲存該屬性的一些元資訊。

下面是屬性描述物件的一個例子。

{
  value: 123,
  writable: false,
  enumerable: true,
  configurable: false,
  get: undefined,
  set: undefined
}

屬性描述物件提供6個元屬性。

(1)value

value

是該屬性的屬性值,預設為undefined

(2)writable

writable是一個布林值,表示屬性值(value)是否可改變(即是否可寫),預設為true

(3)enumerable

enumerable是一個布林值,表示該屬性是否可遍歷,預設為true。如果設為false,會使得某些操作(比如for...in迴圈、Object.keys())跳過該屬性。

(4)configurable

configurable是一個布林值,表示可配置性,預設為true。如果設為false,將阻止某些操作改寫該屬性,比如無法刪除該屬性,也不得改變該屬性的屬性描述物件(value屬性除外)。也就是說,configurable

屬性控制了屬性描述物件的可寫性。

(5)get

get是一個函式,表示該屬性的取值函式(getter),預設為undefined

(6)set

set是一個函式,表示該屬性的存值函式(setter),預設為undefined

Object.getOwnPropertyDescriptor()

Object.getOwnPropertyDescriptor()方法可以獲取屬性描述物件。它的第一個引數是目標物件,第二個引數是一個字串,對應目標物件的某個屬性名。

var obj = { p: 'a' };

Object.getOwnPropertyDescriptor(obj, 'p')
// Object { value: "a",
//   writable: true,
//   enumerable: true,
//   configurable: true
// }

上面程式碼中,Object.getOwnPropertyDescriptor()方法獲取obj.p的屬性描述物件。

注意,Object.getOwnPropertyDescriptor()方法只能用於物件自身的屬性,不能用於繼承的屬性。

var obj = { p: 'a' };

Object.getOwnPropertyDescriptor(obj, 'toString')
// undefined

上面程式碼中,toStringobj物件繼承的屬性,Object.getOwnPropertyDescriptor()無法獲取。

Object.getOwnPropertyNames()

Object.getOwnPropertyNames方法返回一個數組,成員是引數物件自身的全部屬性的屬性名,不管該屬性是否可遍歷。

var obj = Object.defineProperties({}, {
  p1: { value: 1, enumerable: true },
  p2: { value: 2, enumerable: false }
});

Object.getOwnPropertyNames(obj)
// ["p1", "p2"]

上面程式碼中,obj.p1是可遍歷的,obj.p2是不可遍歷的。Object.getOwnPropertyNames會將它們都返回。

這跟Object.keys的行為不同,Object.keys只返回物件自身的可遍歷屬性的全部屬性名。

Object.keys([]) // []
Object.getOwnPropertyNames([]) // [ 'length' ]

Object.keys(Object.prototype) // []
Object.getOwnPropertyNames(Object.prototype)
// ['hasOwnProperty',
//  'valueOf',
//  'constructor',
//  'toLocaleString',
//  'isPrototypeOf',
//  'propertyIsEnumerable',
//  'toString']

Object.defineProperty(),Object.defineProperties()

Object.defineProperty()方法允許通過屬性描述物件,定義或修改一個屬性,然後返回修改後的物件,它的用法如下。

Object.defineProperty(object, propertyName, attributesObject)

Object.defineProperty方法接受三個引數,依次如下。

  • object:屬性所在的物件
  • propertyName:字串,表示屬性名
  • attributesObject:屬性描述物件

舉例來說,定義obj.p可以寫成下面這樣。

var obj = Object.defineProperty({}, 'p', {
  value: 123,
  writable: false,
  enumerable: true,
  configurable: false
});

obj.p // 123

obj.p = 246;
obj.p // 123

上面程式碼中,Object.defineProperty()方法定義了obj.p屬性。由於屬性描述物件的writable屬性為false,所以obj.p屬性不可寫。注意,這裡的Object.defineProperty方法的第一個引數是{}(一個新建的空物件),p屬性直接定義在這個空物件上面,然後返回這個物件,這是Object.defineProperty()的常見用法。

如果屬性已經存在,Object.defineProperty()方法相當於更新該屬性的屬性描述物件。

如果一次性定義或修改多個屬性,可以使用Object.defineProperties()方法。

var obj = Object.defineProperties({}, {
  p1: { value: 123, enumerable: true },
  p2: { value: 'abc', enumerable: true },
  p3: { get: function () { return this.p1 + this.p2 },
    enumerable:true,
    configurable:true
  }
});

obj.p1 // 123
obj.p2 // "abc"
obj.p3 // "123abc"

上面程式碼中,Object.defineProperties()同時定義了obj物件的三個屬性。其中,p3屬性定義了取值函式get,即每次讀取該屬性,都會呼叫這個取值函式。

注意,一旦定義了取值函式get(或存值函式set),就不能將writable屬性設為true,或者同時定義value屬性,否則會報錯。

var obj = {};

Object.defineProperty(obj, 'p', {
  value: 123,
  get: function() { return 456; }
});
// TypeError: Invalid property.
// A property cannot both have accessors and be writable or have a value

Object.defineProperty(obj, 'p', {
  writable: true,
  get: function() { return 456; }
});
// TypeError: Invalid property descriptor.
// Cannot both specify accessors and a value or writable attribute

上面程式碼中,同時定義了get屬性和value屬性,以及將writable屬性設為true,就會報錯。

Object.defineProperty()Object.defineProperties()引數裡面的屬性描述物件,writableconfigurableenumerable這三個屬性的預設值都為false

var obj = {};
Object.defineProperty(obj, 'foo', {});
Object.getOwnPropertyDescriptor(obj, 'foo')
// {
//   value: undefined,
//   writable: false,
//   enumerable: false,
//   configurable: false
// }

上面程式碼中,定義obj.foo時用了一個空的屬性描述物件,就可以看到各個元屬性的預設值。

Object.prototype.propertyIsEnumerable

例項物件的propertyIsEnumerable()方法返回一個布林值,用來判斷某個屬性是否可遍歷。注意,這個方法只能用於判斷物件自身的屬性,對於繼承的屬性一律返回false

var obj = {};
obj.p = 123;

obj.propertyIsEnumerable('p') // true
obj.propertyIsEnumerable('toString') // false

上面程式碼中,obj.p是可遍歷的,而obj.toString是繼承的屬性。

元屬性

屬性描述物件的各個屬性稱為“元屬性”,因為它們可以看作是控制屬性的屬性。

value

value屬性是目標屬性的值。

var obj = {};
obj.p = 123;

Object.getOwnPropertyDescriptor(obj, 'p').value
// 123

Object.defineProperty(obj, 'p', { value: 246 });
obj.p // 246

上面程式碼是通過value屬性,讀取或改寫obj.p的例子。

writable

writable屬性是一個布林值,決定了目標屬性的值(value)是否可以被改變。

var obj = {};

Object.defineProperty(obj, 'a', {
  value: 37,
  writable: false
});

obj.a // 37
obj.a = 25;
obj.a // 37

上面程式碼中,obj.awritable屬性是false。然後,改變obj.a的值,不會有任何效果。

注意,正常模式下,對writablefalse的屬性賦值不會報錯,只會默默失敗。但是,嚴格模式下會報錯,即使對a屬性重新賦予一個同樣的值。

'use strict';
var obj = {};

Object.defineProperty(obj, 'a', {
  value: 37,
  writable: false
});

obj.a = 37;
// Uncaught TypeError: Cannot assign to read only property 'a' of object

上面程式碼是嚴格模式,對obj.a任何賦值行為都會報錯。

如果原型物件的某個屬性的writablefalse,那麼子物件將無法自定義這個屬性。

var proto = Object.defineProperty({}, 'foo', {
  value: 'a',
  writable: false
});

var obj = Object.create(proto);

obj.foo = 'b';
obj.foo // 'a'

上面程式碼中,proto是原型物件,它的foo屬性不可寫。obj物件繼承proto,也不可以再自定義這個屬性了。如果是嚴格模式,這樣做還會丟擲一個錯誤。

但是,有一個規避方法,就是通過覆蓋屬性描述物件,繞過這個限制。原因是這種情況下,原型鏈會被完全忽視。

var proto = Object.defineProperty({}, 'foo', {
  value: 'a',
  writable: false
});

var obj = Object.create(proto);
Object.defineProperty(obj, 'foo', {
  value: 'b'
});

obj.foo // "b"

enumerable

enumerable(可遍歷性)返回一個布林值,表示目標屬性是否可遍歷。

JavaScript 的早期版本,for...in迴圈是基於in運算子的。我們知道,in運算子不管某個屬性是物件自身的還是繼承的,都會返回true

var obj = {};
'toString' in obj // true

上面程式碼中,toString不是obj物件自身的屬性,但是in運算子也返回true,這導致了toString屬性也會被for...in迴圈遍歷。

這顯然不太合理,後來就引入了“可遍歷性”這個概念。只有可遍歷的屬性,才會被for...in迴圈遍歷,同時還規定toString這一類例項物件繼承的原生屬性,都是不可遍歷的,這樣就保證了for...in迴圈的可用性。

具體來說,如果一個屬性的enumerablefalse,下面三個操作不會取到該屬性。

  • for..in迴圈
  • Object.keys方法
  • JSON.stringify方法

因此,enumerable可以用來設定“祕密”屬性。

var obj = {};

Object.defineProperty(obj, 'x', {
  value: 123,
  enumerable: false
});

obj.x // 123

for (var key in obj) {
  console.log(key);
}
// undefined

Object.keys(obj)  // []
JSON.stringify(obj) // "{}"

上面程式碼中,obj.x屬性的enumerablefalse,所以一般的遍歷操作都無法獲取該屬性,使得它有點像“祕密”屬性,但不是真正的私有屬性,還是可以直接獲取它的值。

注意,for...in迴圈包括繼承的屬性,Object.keys方法不包括繼承的屬性。如果需要獲取物件自身的所有屬性,不管是否可遍歷,可以使用Object.getOwnPropertyNames方法。

另外,JSON.stringify方法會排除enumerablefalse的屬性,有時可以利用這一點。如果物件的 JSON 格式輸出要排除某些屬性,就可以把這些屬性的enumerable設為false

configurable

configurable(可配置性)返回一個布林值,決定了是否可以修改屬性描述物件。也就是說,configurablefalse時,valuewritableenumerableconfigurable都不能被修改了。

var obj = Object.defineProperty({}, 'p', {
  value: 1,
  writable: false,
  enumerable: false,
  configurable: false
});

Object.defineProperty(obj, 'p', {value: 2})
// TypeError: Cannot redefine property: p

Object.defineProperty(obj, 'p', {writable: true})
// TypeError: Cannot redefine property: p

Object.defineProperty(obj, 'p', {enumerable: true})
// TypeError: Cannot redefine property: p

Object.defineProperty(obj, 'p', {configurable: true})
// TypeError: Cannot redefine property: p

上面程式碼中,obj.pconfigurablefalse。然後,改動valuewritableenumerableconfigurable,結果都報錯。

注意,writable只有在false改為true會報錯,true改為false是允許的。

var obj = Object.defineProperty({}, 'p', {
  writable: true,
  configurable: false
});

Object.defineProperty(obj, 'p', {writable: false})
// 修改成功

至於value,只要writableconfigurable有一個為true,就允許改動。

var o1 = Object.defineProperty({}, 'p', {
  value: 1,
  writable: true,
  configurable: false
});

Object.defineProperty(o1, 'p', {value: 2})
// 修改成功

var o2 = Object.defineProperty({}, 'p', {
  value: 1,
  writable: false,
  configurable: true
});

Object.defineProperty(o2, 'p', {value: 2})
// 修改成功

另外,writablefalse時,直接目標屬性賦值,不報錯,但不會成功。

var obj = Object.defineProperty({}, 'p', {
  value: 1,
  writable: false,
  configurable: false
});

obj.p = 2;
obj.p // 1

上面程式碼中,obj.pwritablefalse,對obj.p直接賦值不會生效。如果是嚴格模式,還會報錯。

可配置性決定了目標屬性是否可以被刪除(delete)。

var obj = Object.defineProperties({}, {
  p1: { value: 1, configurable: true },
  p2: { value: 2, configurable: false }
});

delete obj.p1 // true
delete obj.p2 // false

obj.p1 // undefined
obj.p2 // 2

上面程式碼中,obj.p1configurabletrue,所以可以被刪除,obj.p2就無法刪除。

存取器

除了直接定義以外,屬性還可以用存取器(accessor)定義。其中,存值函式稱為setter,使用屬性描述物件的set屬性;取值函式稱為getter,使用屬性描述物件的get屬性。

一旦對目標屬性定義了存取器,那麼存取的時候,都將執行對應的函式。利用這個功能,可以實現許多高階特性,比如定製屬性的讀取和賦值行為。

var obj = Object.defineProperty({}, 'p', {
  get: function () {
    return 'getter';
  },
  set: function (value) {
    console.log('setter: ' + value);
  }
});

obj.p // "getter"
obj.p = 123 // "setter: 123"

上面程式碼中,obj.p定義了getset屬性。obj.p取值時,就會呼叫get;賦值時,就會呼叫set

JavaScript 還提供了存取器的另一種寫法。

// 寫法二
var obj = {
  get p() {
    return 'getter';
  },
  set p(value) {
    console.log('setter: ' + value);
  }
};

上面兩種寫法,雖然屬性p的讀取和賦值行為是一樣的,但是有一些細微的區別。第一種寫法,屬性pconfigurableenumerable都為false,從而導致屬性p是不可遍歷的;第二種寫法,屬性pconfigurableenumerable都為true,因此屬性p是可遍歷的。實際開發中,寫法二更常用。

注意,取值函式get不能接受引數,存值函式set只能接受一個引數(即屬性的值)。

存取器往往用於,屬性的值依賴物件內部資料的場合。

var obj ={
  $n : 5,
  get next() { return this.$n++ },
  set next(n) {
    if (n >= this.$n) this.$n = n;
    else throw new Error('新的值必須大於當前值');
  }
};

obj.next // 5

obj.next = 10;
obj.next // 10

obj.next = 5;
// Uncaught Error: 新的值必須大於當前值

上面程式碼中,next屬性的存值函式和取值函式,都依賴於內部屬性$n

物件的拷貝

有時,我們需要將一個物件的所有屬性,拷貝到另一個物件,可以用下面的方法實現。

var extend = function (to, from) {
  for (var property in from) {
    to[property] = from[property];
  }

  return to;
}

extend({}, {
  a: 1
})
// {a: 1}

上面這個方法的問題在於,如果遇到存取器定義的屬性,會只拷貝值。

extend({}, {
  get a() { return 1 }
})
// {a: 1}

為了解決這個問題,我們可以通過Object.defineProperty方法來拷貝屬性。

var extend = function (to, from) {
  for (var property in from) {
    if (!from.hasOwnProperty(property)) continue;
    Object.defineProperty(
      to,
      property,
      Object.getOwnPropertyDescriptor(from, property)
    );
  }

  return to;
}

extend({}, { get a(){ return 1 } })
// { get a(){ return 1 } })

上面程式碼中,hasOwnProperty那一行用來過濾掉繼承的屬性,否則可能會報錯,因為Object.getOwnPropertyDescriptor讀不到繼承屬性的屬性描述物件。

控制物件狀態

有時需要凍結物件的讀寫狀態,防止物件被改變。JavaScript 提供了三種凍結方法,最弱的一種是Object.preventExtensions,其次是Object.seal,最強的是Object.freeze

Object.preventExtensions()

Object.preventExtensions方法可以使得一個物件無法再新增新的屬性。

var obj = new Object();
Object.preventExtensions(obj);

Object.defineProperty(obj, 'p', {
  value: 'hello'
});
// TypeError: Cannot define property:p, object is not extensible.

obj.p = 1;
obj.p // undefined

上面程式碼中,obj物件經過Object.preventExtensions以後,就無法新增新屬性了。

Object.isExtensible()

Object.isExtensible方法用於檢查一個物件是否使用了Object.preventExtensions方法。也就是說,檢查是否可以為一個物件新增屬性。

var obj = new Object();

Object.isExtensible(obj) // true
Object.preventExtensions(obj);
Object.isExtensible(obj) // false

上面程式碼中,對obj物件使用Object.preventExtensions方法以後,再使用Object.isExtensible方法,返回false,表示已經不能新增新屬性了。

Object.seal()

Object.seal方法使得一個物件既無法新增新屬性,也無法刪除舊屬性。

var obj = { p: 'hello' };
Object.seal(obj);

delete obj.p;
obj.p // "hello"

obj.x = 'world';
obj.x // undefined

上面程式碼中,obj物件執行Object.seal方法以後,就無法新增新屬性和刪除舊屬性了。

Object.seal實質是把屬性描述物件的configurable屬性設為false,因此屬性描述物件不再能改變了。

var obj = {
  p: 'a'
};

// seal方法之前
Object.getOwnPropertyDescriptor(obj, 'p')
// Object {
//   value: "a",
//   writable: true,
//   enumerable: true,
//   configurable: true
// }

Object.seal(obj);

// seal方法之後
Object.getOwnPropertyDescriptor(obj, 'p')
// Object {
//   value: "a",
//   writable: true,
//   enumerable: true,
//   configurable: false
// }

Object.defineProperty(o, 'p', {
  enumerable: false
})
// TypeError: Cannot redefine property: p

上面程式碼中,使用Object.seal方法之後,屬性描述物件的configurable屬性就變成了false,然後改變enumerable屬性就會報錯。

Object.seal只是禁止新增或刪除屬性,並不影響修改某個屬性的值。

var obj = { p: 'a' };
Object.seal(obj);
obj.p = 'b';
obj.p // 'b'

上面程式碼中,Object.seal方法對p屬性的value無效,是因為此時p屬性的可寫性由writable決定。

Object.isSealed()

Object.isSealed方法用於檢查一個物件是否使用了Object.seal方法。

var obj = { p: 'a' };

Object.seal(obj);
Object.isSealed(obj) // true

這時,Object.isExtensible方法也返回false

var obj = { p: 'a' };

Object.seal(obj);
Object.isExtensible(obj) // false

Object.freeze()

Object.freeze方法可以使得一個物件無法新增新屬性、無法刪除舊屬性、也無法改變屬性的值,使得這個物件實際上變成了常量。

var obj = {
  p: 'hello'
};

Object.freeze(obj);

obj.p = 'world';
obj.p // "hello"

obj.t = 'hello';
obj.t // undefined

delete obj.p // false
obj.p // "hello"

上面程式碼中,對obj物件進行Object.freeze()以後,修改屬性、新增屬性、刪除屬性都無效了。這些操作並不報錯,只是默默地失敗。如果在嚴格模式下,則會報錯。

Object.isFrozen()

Object.isFrozen方法用於檢查一個物件是否使用了Object.freeze方法。

var obj = {
  p: 'hello'
};

Object.freeze(obj);
Object.isFrozen(obj) // true

使用Object.freeze方法以後,Object.isSealed將會返回trueObject.isExtensible返回false

var obj = {
  p: 'hello'
};

Object.freeze(obj);

Object.isSealed(obj) // true
Object.isExtensible(obj) // false

Object.isFrozen的一個用途是,確認某個物件沒有被凍結後,再對它的屬性賦值。

var obj = {
  p: 'hello'
};

Object.freeze(obj);

if (!Object.isFrozen(obj)) {
  obj.p = 'world';
}

上面程式碼中,確認obj沒有被凍結後,再對它的屬性賦值,就不會報錯了。

侷限性

上面的三個方法鎖定物件的可寫性有一個漏洞:可以通過改變原型物件,來為物件增加屬性。

var obj = new Object();
Object.preventExtensions(obj);

var proto = Object.getPrototypeOf(obj);
proto.t = 'hello';
obj.t
// hello

上面程式碼中,物件obj本身不能新增屬性,但是可以在它的原型物件上新增屬性,就依然能夠在obj上讀到。

一種解決方案是,把obj的原型也凍結住。

var obj = new Object();
Object.preventExtensions(obj);

var proto = Object.getPrototypeOf(obj);
Object.preventExtensions(proto);

proto.t = 'hello';
obj.t // undefined

另外一個侷限是,如果屬性值是物件,上面這些方法只能凍結屬性指向的物件,而不能凍結物件本身的內容。

var obj = {
  foo: 1,
  bar: ['a', 'b']
};
Object.freeze(obj);

obj.bar.push('c');
obj.bar // ["a", "b", "c"]

上面程式碼中,obj.bar屬性指向一個數組,obj物件被凍結以後,這個指向無法改變,即無法指向其他值,但是所指向的陣列是可以改變的。