Object.defineProperty()

Object.defineProperty()

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

1
2
3
4
5
// obj         需要定义属性的对象
// prop 需定义或修改的属性的名字
// descriptor 将被定义或修改的属性的描述符
// return 返回传入函数的对象,即第一个参数 obj
Object.defineProperty(obj, prop, descriptor)

对象里目前存在的属性描述符有两种主要形式『数据描述符』和『存取描述符』

数据描述符是一个拥有可写或不可写值的属性

存取描述符是由一对 getter-setter 函数功能来描述的属性

描述符必须是两种形式之一,不能同时是两者,并且数据描述符和存取描述符不能混合使用,在 ES5 之前,JavaScript 语言本身并没有提供可以直接检测属性特性的方法,比如判断属性是否是只读,但是从 ES5 开始,所有属性都具备了属性描述符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var myObject = {
a: 2
}

Object.getOwnPropertyDescriptor(myObject, 'a')

/*

{
value: 2,
writable: true,
enumerable: true,
configurable: true
}

*/

如你所见,这个普通的对象属性对应的属性描述符(也被称为数据描述符,因为它只保存一个数据值),不仅仅只有一个 2,它还包含了另外三个特性,writable(可写),enumerable(可枚举),configurable(可配置),在创建普通属性时属性描述符会使用默认值,我们也可以使用 Object.defineProperty() 来添加一个新属性或者修改一个已有属性(如果它是 configurable),并对特性进行设置

1
2
3
4
5
6
7
8
9
10
var myObject = {}

Object.defineProperty(myObject, 'a', {
value: 2,
writable: true,
configurable: true,
enumerable: true
})

myObject.a // 2

我们使用 defineProperty()myObject 添加了一个普通的属性并显式的指定了一些特性,然而,一般不会使用这种方式,除非你想修改属性描述符

默认情况下,使用 Object.defineProperty() 增加的属性值是不可改变的

Writable

writable 决定是否可以修改属性的值

1
2
3
4
5
6
7
8
9
10
11
12
var myObject = {}

Object.defineProperty(myObject, a, {
value: 2,
writable: false, // 不可写
configurable: true,
enumerable: true
})

myObject.a = 3

myObject.a // 2

如你所见,我们对于属性值的修改静默失败(silently failed),如果在严格模式(use strict)下,还会导致出错,简单来说,你可以把 writable: false 看作是属性不可改变,相当于空操作的 setter(后面会提到)

Configurable

只有属性是可配置的,就可以使用 defineProperty() 方法来修改属性描述符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var myObject = {
a: 2
}

myObject.a = 3
myObject.a // 3

Object.defineProperty(myObject, 'a', {
value: 4,
writable: true,
configurable: false, // 不可配置
enumerable: true
})

myObject.a // 4
myObject.a = 5
myObject.a // 5

Object.defineProperty(myObject, 'a', {
value: 6,
writable: true,
configurable: true,
enumerable: true
}) // TypeError

最后一个 defineProperty() 会产生一个 TypeError 错误,不管是不是处于严格模式,尝试修改一个不可配置的属性描述符都会出错

注意,把 configurable 修成为 false 是单向操作,无法撤销

但是有一个例外,即便属性是 configurable: false ,我们还是可以把 writable 的状态由 true 改成 false,但是无法由 false 改成 true

除了无法修改,configurable: false 还会禁止删除这个属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var myObject = {
a: 2
}

myObject.a // 2

delete myObject.a

myObject.a // undefined

Object.defineProperty(myObject, 'a', {
value: 2,
writable: true,
configurable: false,
enumerable: true
})

myObject.a // 2

delete myObject.a

myObject.a // 2

如你所见,最后一个 delete 语句(静默)失败了,因为属性是不可配置的,在本例中,delete 只用来直接删除对象的(可删除)属性,如果对象的某个属性是某个对象或者函数的最后一个引用者,对这个属性执行 delete 操作之后,这个未引用的对象或者函数就可以被垃圾回收(是一个删除对象属性的操作,仅此而已)

Enumerable

这个描述符控制的是属性是否会出现在对象的属性枚举中,比如说 for-in 循环,如果把 enumerable 设置成 false,这个属性就不会出现在枚举中,虽然仍然可以正常访问它,相对地,设置成 true 就会让它出现在枚举中,如果你不希望某些特殊属性出现在枚举中,那就把它设置成 enumerable: false

简单总结一下

  • Writable
    • 如果在 Object.defineProperty() 中定义了 writable: false,重新给对象赋值的话是无效的
    • 严格模式(use strict)下,还会导致出错
  • Configurable
    • 如果在 Object.defineProperty() 中定义了 configurable: false,可以重新给对象赋值
    • 但是不管是不是处于严格模式,不能再重新使用 Object.defineProperty 来重新配置属性,这样的操作会导致报错
    • configurable 修成为 false 的操作是单向操作,是无法撤销的!(同时也会禁止删除这个属性)
    • 但是可以把 writable 的状态由 true 改成 false,但是无法由 false 改成 true(同样是单向操作)
  • Enumerable
    • 如果在 Object.defineProperty() 中定义了 enumerable: false,属性就不会出现在对象的属性枚举中
    • 比如说 for-in 循环,虽然仍然可以正常访问它

不变性

有时候你会希望属性或者对象是不可改变(无论有意还是无意)的,在 ES5 中可以通过很多种方法来实现,很重要的一点是,所有的方法创建的都是浅不变形,也就是说,它们只会影响目标对象和它的直接属性,如果目标对象引用了其他对象(数组、对象、函数,等),其他对象的内容不受影响,仍然是可变的

1
2
3
myImmutableObject.foo            // [1, 2, 3]
myImmutableObject.foo.push(4)
myImmutableObject.foo // [1, 2, 3, 4]

对象常量

结合 writable: falseconfigurable: false 就可以创建一个真正的常量属性(不可修改、重定义或者删除)

1
2
3
4
5
6
7
var myObject = {}

Object.defineProperty(myObject, 'FAVORITE_NUMBER', {
value: 42,
writable: false,
configurable: false
})

禁止扩展

如果你想禁止一个对象添加新属性并且保留已有属性,可以使用 Object.preventExtensions()

1
2
3
4
5
6
7
8
var myObject = {
a: 2
}

Object.preventExtensions(myObject)

myObject.b = 3
myObject.b // undefined

在非严格模式下,创建属性 b 会静默失败,在严格模式下,将会抛出 TypeError 错误

密封

Object.seal() 会创建一个密封(seal)的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions() 并把所有现有属性标记为 configurable: false,所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)

冻结

Object.freeze() 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal() 并把所有数据访问属性标记为 writable: false,这样就无法修改它们的值

[[Get]]

先看以下代码

1
2
3
4
5
var myObject = {
a: 2
}

myObject.a // 2

myObject.a 是一次属性访问,但是这条语句并不仅仅是在 myObjet 中查找名字为 a 的属性,虽然看起来好像是这样,在语言规范中,myObject.amyObject 上实际上是实现了 [[Get]] 操作(有点像函数调用,[[Get]]()),对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值

然而,如果没有找到名称相同的属性,按照 [[Get]] 算法的定义会执行另外一种非常重要的行为(遍历可能存在的 [[Prototype]] 链,也就是原型链),如果无论如何都没有找到名称相同的属性,那 [[Get]] 操作会返回值 undefined

1
2
3
4
5
var myObject = {
a: 2
}

myObject.b // undefined

注意,这种方法和访问变量时是不一样的,如果你引用了一个当前词法作用域中不存在的变量,并不会像对象属性一样返回 undefined,而是会抛出一个 ReferenceError 异常,

1
2
3
4
5
6
7
var myObject = {  
a: undefined
}

myObject.a // undefined

myObject.b // undefined

从返回值的角度来说,这两个引用没有区别 — 它们都返回了 undefined,然而,尽管乍看之下没什么区别,实际上底层的 [[Get]] 操作对 myObject.b 进行了更复杂的处理,由于仅根据返回值无法判断出到底变量的值为 undefined 还是变量不存在,所以 [[Get]] 操作返回了 undefined

[[Put]]

既然有可以获取属性值的 [[Get]] 操作,就一定有对应的 [[Put]] 操作,[[Put]] 被触发时,实际的行为取决于许多因素,包括对象中是否已经存在这个属性(这是最重要的因素)

  1. 属性是否是访问描述符?如果是并且存在 setter 就调用 setter
  2. 属性的数据描述符中 writable 是否是 false?如果是,在非严格模式下静默失败,在严格模式下抛出 TypeError 异常
  3. 如果都不是,将该值设置为属性的值

Getter 和 Setter

对象默认的 [[Put]][[Get]] 操作分别可以控制属性值的设置和获取,在 ES5 中可以使用 gettersetter 部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上,getter 是一个隐藏函数,会在获取属性值时调用,setter 也是一个隐藏函数,会在设置属性值时调用

当你给一个属性定义 gettersetter 或者两者都有时,这个属性会被定义为访问描述符(和数据描述符相对),对于访问描述符来说,JavaScript 会忽略它们的 valuewritable 特性,取而代之的是关心 setget(还有 configurableenumerable)特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var myObject = {
// 给 a 定义一个 getter
get a() {
return 2
}
}

Object.defineProperty(
myObject, // 目标对象
'b', // 属性名
{ // 描述符
get: function () { // 给 b 设置一个 getter
return this.a * 2
},
enumerable: true // 确保 b 会出现在对象的属性列表中
}
)

myObject.a // 2
myObject.b // 4

不管是对象文字语法中的 get a() { ... },还是 defineProperty() 中的显式定义,二者都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数,它的返回值会被当作属性访问的返回值

1
2
3
4
5
6
7
8
9
var myObject = {
// 给 a 定义一个getter
get a() {
return 2
}
}

myObject.a = 3
myObject.a // 2

由于我们只定义了 agetter,所以对 a 的值进行设置时 set 操作会忽略赋值操作,不会抛出错误,而且即便有合法的 setter,由于我们自定义的 getter 只会返回 2,所以 set 操作是没有意义的

为了让属性更合理,还应当定义 setter,和你期望的一样,setter 会覆盖单个属性默认的 [[Put]](也被称为赋值)操作,通常来说 gettersetter 是成对出现的(只定义一个的话通常会产生意料之外的行为)

1
2
3
4
5
6
7
8
9
10
11
12
13
var myObject = {
// 给 a 定义一个 getter
get a() {
return this._a_
},
// 给 a 定义一个 setter
set a(val) {
this._a_ = val * 2
}
}

myObject.a = 2
myObject.a // 4

如何监听对象和数组的属性改变

最后我们再来看一个稍微有些扩展的内容,那就是如何监听对象和数组的属性改变,也算是一个老生常谈的问题,下面我们就来看看如何实现

监听对象属性改变

其实我们实现的方式主要依赖的还是上面介绍到的 Object.defineProperty,它可以接受的第三个参数可以取 get/set 并各自对应一个 getter/setter 的方法,这样一来在对象属性改变的时候我们就可以知道属性的值变化了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a = { obj: 0 }

Object.defineProperty(a, 'obj', {
get: function () {
console.log('get:' + obj)
return obj
},
set: function (value) {
obj = value
console.log('set:' + obj)
}
})

a.obj = 2 // set: 2
console.log(a.obj) // get:2

但是这种方式有个缺点就是在 IE8 及更低版本 IE 是无法使用的,因为这个特性是没有 polyfill 的,所以无法在不支持的平台实现,但是随着版本的更新,现在我们可以使用 ES6 提供的 Proxy 代理来处理,本质原理都是一样的,都是利用 get/set 来监听对象属性的变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var user = {}

var proxy = new Proxy(user, {
get(target, property) {
return target[property]
},
set(target, property, value) {
target[property] = value
}
})

proxy.name = 'zhangsan'
console.log(user)
// {name: 'zhangsan'}

监听数组的变化

监听数组的变化相对来说就没有监听对象那么轻松了,因为没有对应的 get/set 方法来供我们使用,但是我们可以定义一个新的数组,然后让其继承原生的 Array,然后重写其中我们需要监听的方法即可(poppush 等),如下是 ES6 当中的实现方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class NewArray extends Array {
constructor(...args) {
// 调用父类 Array 的 constructor
super(...args)
}

push(...args) {
console.log(`监听到数组变化`)
// 调用父类方法
return super.push(...args)
}
}

let arr = [1, 2]
let newArr = new NewArray(...arr)
console.log(newArr) // [1, 2]

newArr.push(3) // 监听到数组变化
console.log(newArr) // [1, 2, 3]

关于 ES5 以下实现

上面我们介绍了 ES6 当中的数组监听方法的实现,那么 ES6 以下还能实现吗?这里就需要注意了,在 ES5 及以下的 JavaScript 因为无法完美的继承数组,所以虽然可以实现,但是是存在一定缺陷的,因为 Array 构造函数执行时不会对传进去的 this 做任何处理,不止 ArrayStringNumberRegexpObject 等等 JavaScript 的内置类都不行

数组其响应式的 length 属性以及内部的 [[class]] 属性我们无法在 JavaScript 层面实现,这就导致我们无法去用任何一个对象来模仿一个数组,但是可以使用非标准属性 __proto__ 来实现,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 首先获取 Array 原型
const oldMethod = Object.create(Array.prototype)
const newMethod = []

['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
// 在 newMethod 上进行方法的重写
// 这里需要注意的是重写的方法是定义在 newMethod 的属性上而不是其原型属性(newMethod.__proto__ 没有改变)
newMethod[method] = function () {
console.log(`监听到数组的变化`)
return oldMethod[method].apply(this, arguments)
}
})

let list = [1, 2]
// 将需要监听的数组的原型指针指向我们重新定义的新对象
list.__proto__ = newMethod
list.push(3)

// 如果不设定指向,则默认使用原生的方法
let list2 = [1, 2]
list2.push(3)

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×