JavaScript学习笔记: Object.defineProperty()

发布于 大漠

前段时间在学习Vue的双向绑定原理及实现时,简单的知道Object.defineProperty()有很大的用处。这个方法会直接在一个对象上定义一个新属性,或者修改一个对象现有的属性,并返回这个对象。感觉他非常强大,但并不知道其中原委。回过头来重新补一下这方面的基础知识。

对象定义属性和赋值

在对象中,我们有很多种方式给其定义属性和赋值。最常见的是obj.prop = valueobj['prop'] = value。比如:

let Person = {}

Person.name = '大漠'
Person['age'] = 35

console.log(Person)

Object.defineProperty()

除了上述的方式之外,还可以使用Object.defineProperty()方法来定义和修改对象属性。下面我们就来好好的探讨一下这个方法。

Object.defineProperty()语法

Object.defineProperty()的作用就是直接在一个对象上定义一个新属性,或者修改一个已经存在的属性,并返回这个对象,我们先来看一下怎么使用这个方法:

Object.defineProperty(obj, prop, descriptor)

这个方法有三个参数:

  • obj:需要被定义(或修改)属性的那个对象
  • prop:需要被定义(或修改)的属性名
  • descriptor:定义(或修改)的属性prop的描述

其返回值是被传递给函数的对象。

该方法允许精确添加或修改对象的属性。一般情况下,为对象添加属性是通过赋值来创建并显示在属性枚举中(for...inObject.keys方法),但这种方式添加的属性值可以被改变,也可以被删除。而使用Object.defineProperty()则允许改变这些额外细节的默认设置。例如,默认情况下,使用Object.defineProperty()增加的属性值是不可改变的。

属性特性和内部属性

JavaScript中有三种类型的属性:

  • 命名数据属性:拥有一个确定的值的属性。这也是最常见的属性
  • 命名访问器属性:通过gettersetter进行读取和赋值的属性
  • 内部属性:由JavaScript引擎内部使用的属性,不能通过JavaScript代码直接访问到,不过可以通过一些方法间接的读取和设置。比如,每个对象都有一个内部属性[[Prototype]],你不能直接访问这个属性,但可以通过Object.getPrototypeOf()方法间接的读取到它的值。虽然内部属性通常用一个双吕括号包围的名称来表示,但实际上这并不是它们的名字,它们是一种抽象操作,是不可见的,根本没有上面两种属性有的那种字符串类型的属性名

属性特性

对象中每个属性都有四个特性。两种类型的属性一共有六个属性特性:

  • 命名数据属性特有的特性:属性的值的[[Value]]特性和控制属性的值是否可以修改的[[Writable]]特性
  • 命名访问器属性特有的特性:存储着getter方法的[[Get]]和存储着setter方法的[[Set]]
  • 两种属性都有的特性:如果一个属性是不可枚举的,则一些操作下,这个属性是不可见的,比如for...inObject.keys,那么可以通过[[Enumerable]]特性来设置;如果一个属性是不可配置的,则该属性的所有特性([[Value]])都不可改变,那么可以通过[[Configurable]]特性来设置

内部属性

除了上面所说的之外,下面几个是所有对象都有的内部属性:

  • [[Prototype]]:对象的原型
  • [[Extensible]]:对象是否可扩展,也就是是否可以添加新的属性
  • [[DefineOwnProperty]]:定义一个属性的内部方法
  • [[Put]]:为一个属性赋值的内部方法

属性描述符

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

  • 数据描述符:是一个拥有可写或不可写值的属性
  • 存取描述符:是由一对 gettersetter 函数功能来描述的属性

描述符必须是两种形式之一;不能同时是两者。另外,属性描述符可以将一个属性的所有特性编码成一个对象并返回。例如:

Object.defineProperty(obj, 'key', {
    enumerable: false,
    configurable: false,
    writable: false,
    value: 'static'
})

属性描述符除了在Object.defineProperty()中使用之外,还常常使用在Object.getOwnPropertyDescriptor()Object.create()中。如果对象中的某个属性省略了属性描述符,则该属性会取一个默认值:

属性名 默认值
value undefined
get undefined
set undefined
writable false
enumerable false
configurable false

先对属性描述符做一个简单的归纳,因为后面接下来的篇幅都将围绕着Object.defineProperty()的属性描述符descriptor来展开的。

数据描述符和存取描述均具有以下可选键值

configurable:这个特性决定了对象的属性是否可以被删除,以及除writable特性外的其它特性是否可以被修改;并且writable特性值只可以是false。默认为false。同样通过示例来帮助我们理解这个描述符的特性:

let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠',
    configurable: false
})
console.log(Person.name)  // => 大漠
delete Person.name
console.log(Person.name)  // => 大漠

可以看出,虽然执行了delete Person.name,但name的属性值并没有删除。这主要是因为,configurable的值为false,不允许删除。如果我们把其值设置为true,但结果就不一样了:

let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠',
    configurable: false
})
console.log(Person.name)  // => 大漠
delete Person.name
console.log(Person.name)  // => undefined

在上面的示例基础上,咱们再添加writable的描述符,首先来看writabletrue的情况:

let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠',
    configurable: false,
    writable: true
})
console.log(Person.name) // => 大漠
delete Person.name
console.log(Person.name) // => 大漠

另外来看writable: true的情况:

let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠',
    configurable: false,
    writable: false
})
console.log(Person.name)  // => 大漠
delete Person.name
console.log(Person.name)  // => 大漠

再来看看另外两种情况:

let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠',
    configurable: true,
    writable: true
})
console.log(Person.name)  // => 大漠
delete Person.name
console.log(Person.name)  // => undefined

// 或
let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠',
    configurable: true,
    writable: false
})
console.log(Person.name)  // => 大漠
delete Person.name
console.log(Person.name)  // => undefined

enumerable:仅当该属性的enumerabletrue时,该属性才能够出现在对象的枚举属性中。默认为false。也就是说,当enumerable的值为true时,才可以使用for(prop in obj)Object.keys()进行枚举。

let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠',
    enumerable: true // 可以枚举
})
Object.defineProperty(Person, 'age', {
    value: 35,
    enumerable: false // 不可枚举
})
Object.defineProperty(Person, 'title', {
    value: '切图仔' // enumerable取默认值,为false
})

Person.from = 'W3cplus' // 如果使用直接赋值的方式创建对象的属性,则这个属性的enumerable为true

for (let i in Person) {
    console.log(i) // => name, from
}

Object.keys(Person)

Person.propertyIsEnumerable('name')  // => true
Person.propertyIsEnumerable('age')   // => false
Person.propertyIsEnumerable('title') // => false
Person.propertyIsEnumerable('from')  // => true

数据描述符同时具有以下可选键值

value:该属性对应的值。可以是任何有效的JavaScript值(数值、对象、函数等)。默认为undefined。来看一个小示例:

let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠'
})
console.log(Person)

Object.defineProperty()

从上面的结果中我们可以看到,我们给Person定义了一个新的属性name,然后我们打印这个对象就是我们预期的那样,其中对象Person.name的值为'大漠'。在上面的基础上,我们来通过普通的obj.name=value这样的方式重新给对象Person中的name属性赋值:

let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠'
})
console.log(Person)

Person.name = 'w3cplus_大漠'
console.log(Person)

Object.defineProperty()

正如大家所看到的,尽管我们使用Person.name='w3cplus_大漠'的方式,给对象Person中的name属性重新赋值'w3cplus_大漠',却发现这个属性name并没有得到改变,还是以第一次我们赋给它的值。主要原因是,这个属性的writable默认值为false,需要将writable修饰符重置为truename属性才可以被修改。

writable:仅当该属性的writabletrue时,该属性的属性值才能被改变。默认为false。回到上面的示例中来,如果我们想把Person对象中的name值修改成我们所期望的属性值,那么就得在Object.defineProperty()定义name属性时,就指定该属性的writable的描述符值为true

let Person = {}
Object.defineProperty(Person, 'name', {
    value: '大漠',
    writable: true
})
console.log(Person)

Person.name = 'w3cplus_大漠'
console.log(Person)

Object.defineProperty()

正如你所看到的结果,我们可以重新设置name的属性值。

存取描述符同时具有以下可选键值

get:一个给属性提供getter的方法,如果没有getter则为undefined。该方法返回值被用作属性值。默认为undefined

下面这个示例说明了如何实现自我存档的对象。当temperature属性设置时,archive数组会得到一个log

function Archiver () {
    let temperature = null
    let archive = []

    Object.defineProperty(this, 'temperature', {
        get: function () {
            console.log('Get!')
            return temperature
        }
    })

    this.getArchive = function () {
        return archive
    }
}

let arc = new Archiver()

arc.temperature
arc.temperature = 11
arc.temperature = 13
arc.getArchive()

输出结果如下:

Object.defineProperty()

set:一个给属性提供setter的方法,如果没有setter则为undefined。该方法将接受唯一参数,并将该参数的新值分配给该属性。默认值为undefined

function Archiver () {
    let temperature = null
    let archive = []

    Object.defineProperty(this, 'temperature', {
        get: function () {
            console.log('Get!')
            return temperature
        },

        set: function (value) {
            temperature = value
            archive.push({
                val: temperature
            })
        }
    })

    this.getArchive = function () {
        return archive
    }
}

let arc = new Archiver()

arc.temperature
arc.temperature = 11
arc.temperature = 13
arc.getArchive()

Object.defineProperty()

记住,这些选项不一定是自身属性,如果是继承来的也要考虑。为了确认保留这些默认值,你可能要在这之前冻结Object.prototype,明确指定所有的选项,或者将__proto__属性指向null

属性定义和属性赋值

@Dr. Axel Rauschmayer写了一篇文章,详细的阐述了JavaScript中的属性定义和赋值的区别。摘取文章中的内容,简单的看看JavaScript中的属性定义和属性赋值。

属性定义

定义属性是通过内部方法来进行操作的:

[[DefineOwnProperty]](P, Desc, Throw)

P是要定义的属性名称,参数Throw决定了在定义操作被拒绝的时候是否要抛出异常。如果Throwtrue,则抛出异常;否则,操作只会静默失败。当调用[[DefineOwnProperty]]时,具体会执行下面的操作步骤。

  • 如果this没有名为P的自身属性的话:如果this是可扩展的话,则创建P这个自身属性,否则拒绝
  • 如果this已经有了名为P的自身属性:则按照下面的步骤重新配置这个属性
  • 如果这个已有的属性是不可配置的,则进行下面的操作会被拒绝:将一个数据属性转换成访问器属性,反之变然;改变[[Configurable]][[Enumerable]];改变[[Writable]];在[[Writable]]false时改变[[Value]]和改变[[Get]][[Set]]
  • 否则,这个已有的属性可以被重新配置

如果Desc就是P属性当前的属性描述符,则该定义操作永远不会被拒绝。

定义属性的函数有两个:Object.definePropertyObject.defineProperties。例如:

Object.defineProperty(obj, propName, desc)

在引擎内部,会转换成这样的方法调用:

obj.[[DefineOwnProperty]](propName, desc, true)

属性赋值

为属性赋值是通过内部方法进行操作的:

[[Put]](P, Value, Throw)

参数P以及Throw[[DefineOwnProperty]]方法中的参数表现的一样。在调用[[Put]]方法的时候,会执行下面这样的操作步骤:

  • 如果在原型链上存在一个名为P的只读属性(只读的数据属性或者没有setter的访问器属性),则拒绝
  • 如果在原型链上存在一个名为P的且拥有setter的访问器属性,则调用这个setter
  • 如果没有名为P的自身属性,则如果这个对象是可扩展的,就使用下面的操作创建一个新属性,否则,如果这个对象是不可扩展的,则拒绝
  • 否则,如果已经存在一个可写的名为P的自身属性,则调用this.[[DefineOwnProperty]](P, {value: Value}, Throw)。该操作只会更改P属性的值,其他的特性(比如可枚举性)都不会改变

赋值运算符=就是在调用[[Put]]。比如:

Obj.prop = value

在引擎内部,会转换成这样的方法调用:

Obj.[[Put]]('prop', value, isStrictModeOn)

isStrictModeOn对应着参数Throw。也就是说,赋值运算符只有在严格模式下才有可能抛出异常。[[Put]]没有返回值,但赋值运算符有。

作用及影响

属性的定义操作和赋值操作各自有自己的作用和影响。

赋值可能会调用原型上的setter,定义会创建一个自身属性。比如,给一个空对象obj,他的原型proto有一个名为foo的访问器属性。

let proto = {
    get foo() {
        console.log('Getter!')
        return 'a'
    },
    set foo(x) {
        console.log(`Setter: ${x}`)
    }
}

let obj = Object.create(proto)

console.log(obj)

Object.defineProperty()

那么,在obj身上定义一个foo属性和为objfoo属性赋值有什么区别呢?

如果是定义操作的话,则会在obj身上添加一个自身属性foo

Object.defineProperty(obj, 'foo', {
    value: 'b'
})

console.log(obj.foo)

console.log(proto.foo)

Object.defineProperty()

但如果为foo属性赋值的话,则意味着你想改变某个已经存在的属性的值。所以这次赋值操作会转交给原型protofoo属性的setter访问器来处理。下面代码的执行结果就能证明这一结论:

Object.defineProperty()

你还可以定义一个只读的访问器属性,办法是:只定义一个getter,省略setter。下面的例子中,protobar属性就是这样的只读属性,obj继承了这个属性。

'use strict';

let proto = {
    get bar() {
        console.log('Getter!')
        return 'a'
    }
}

let obj = Object.create(proto)

console.log(obj)

Object.defineProperty()

在开启严格模式的话,下面的赋值操作会抛出异常。非严格模式的话,赋值操作只会静默失败(不起任何作用,也不报错)

obj.bar = 'b'
console.log(obj.bar)

Object.defineProperty()

我们还可以定义一个自身属性bar,遮蔽从原型上继承的bar属性:

Object.defineProperty(obj, 'bar', {
    value: 'b'
})

console.log(obj.bar)
console.log(proto.bar)

Object.defineProperty()

原型链中的同名只读属性可能会阻止赋值操作,但不会阻止定义操作。如果原型链中存在一个同名的只读属性,则无法通过赋值的方式在原对象上添加这个自身属性,必须使用定义操作才可以。这项限制是在ECMAScript 5.1中引入的:

'use strict'

let proto = Object.defineProperties(
    {},
    {
        foo: {                  // foo属性的特性
            value: 'a',         // foo属性的值
            writable: false,    // 只读
            configurable: true  // 可配置
        }
    }
)

let obj = Object.create(proto)

console.log(obj)

Object.defineProperty()

赋值操作会导致异常:

obj.foo = 'b'

console.log(obj.foo)

Object.defineProperty()

通过定义的方式,我们可以成功创建一个新的自身属性:

Object.defineProperty(obj, 'foo', {
    value:  'b'
})

console.log(obj.foo)    // => b
console.log(proto.foo)  // => a

赋值运算符不会改变原型链上的属性。执行下面的代码,则obj会从proto上继承到foo属性。

let proto = {
    foo: 'a'
}

let obj = Object.create(proto)

console.log(obj)

Object.defineProperty()

不能通过为obj.foo赋值来改变proto.foo的值。这种操作只会在obj上新建一个自身属性。

obj.foo = 'b'

console.log(obj.foo)   // => b
console.log(proto.foo) // => a

只有通过定义操作,才能创建一个拥有指定特性的属性。如果通过赋值操作创建一个自身属性,则该属性始终拥有默认的特性。如果你想指定某个特性的值,必须通过定义操作。

对象字面量中的属性是通过定义操作添加的。下面的代码将变量obj指向一个对象字面量:

let obj = {
    name: '大漠'
}

这样的语句在引擎内部,可能会被转换成下面两种操作方式中的一种。首先可能是赋值操作:

let obj = new Object()
obj.name = '大漠'

其次,可能是个定义操作:

let obj = new Object()

Object.defineProperties(obj, {
    name: {
        value: '大漠',
        enumerable: true,
        configurable: true,
        writable: true
    }
})

到底是哪种呢?正确答案是第二种。因为第二种操作方式能够更好的表达出对象字面量的语义:创建新的属性Object.create()接受一个属性描述符作为第二个可选参数,也是这个原因

可以通过定义操作新建一个只读的方法属性:

'use strict'

function Stack() {

}

Object.defineProperties(Stack.prototype, {
    push: {
        writable: false,
        configurable: true,
        value: function (x) {
            console.log(x)
        }
    }
})

目的是为了防止在实例身上发生意外的赋值操作。

let s = new Stack()

s.push = 5

Object.defineProperty()

不过,由于push是可配置的,所以我们仍可以通过定义操作来为实例添加一个自身的push方法。

let s = new Stack()

Object.defineProperty(s, 'push', {
    value: function () {
        return 'yes'
    }
})

console.log(s.push()) // => yes

我们甚至可以通过定义操作来重新定义原型上的push方法:Stack.prototype.push

添加多个属性和默认值

考虑特性被赋予的默认特性值非常重要,通常,使用点运算符和Object.defineProperty()为对象的属性赋值时,数据描述符中的属性默认值是不同的,如下例所示。

let obj = {}

obj.name = '大漠'

上面的代码等同于:

Object.defineProperty(obj, 'name', {
    value: '大漠',
    writable: true,
    configurable: true,
    enumerable: true
})

另一主面:

Object.defineProperty(obj, 'name', {
    value: '大漠'
})

等同于:

Object.defineProperty(obj, 'name', {
    value: '大漠',
    writable: false,
    configurable: false,
    enumerable: false
})

总结

这篇文章主要介绍了Object.defineProperty(obj, prop,descriptor)。我想大家和我一样,对这个方法应该有了一定的了解。有了这个基础,咱们回过头来理解《学习Vue的双向绑定原理及实现》一文中提到的Vue的双向数据绑定的原理就变得容易一些了。而且这个属性也能更好的帮助我们后面理解Vue响应式视图,或者说Vue中的计算属性的奥秘

由于作者也是这方面的初学者,如果文章中有不对请各路大婶拍正。如果你有更好的经验,欢迎在下面的评论中与我们一起分享。

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/javascript/object-defineproperty.htmlAir Max 1 Mid Liberty Max Kicks