Object.defineProperty
约 1380 字大约 5 分钟
2026-02-26
重要
Object.defineProperty 让属性具备可控行为:可写、可枚举、可配置、访问拦截
基本使用
Object.defineProperty() 静态方法会直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象
| 参数 | 描述 |
|---|---|
obj | 目标对象 |
key | 属性名 |
descriptor | 属性描述符 |
Object.defineProperty(obj, key, descriptor)
Object.defineProperties(obj, descriptors)数据描述符
描述符字段详情
| 字段 | 描述 |
|---|---|
value | 属性值 |
writable | 属性是否可写,默认 false |
enumerable | 属性是否可枚举,默认 false |
configurable | 属性是否可配置,默认 false |
const obj = {}
Object.defineProperty(obj, 'a', {
value: 1,
})
console.log(obj.a) // 1
obj.a = 10
console.log(obj.a) // 1(默认 writable: false)const obj = {}
Object.defineProperty(obj, 'a', {
value: 1,
writable: true,
enumerable: true,
configurable: true,
})
obj.a = 5
console.log(obj.a) // 5
for (const k in obj) {
console.log(k) // a
}
delete obj.a
console.log(obj.a) // undefined访问器描述符
访问器描述符用于定义属性的读取和写入行为
| 字段 | 描述 |
|---|---|
get | 读取属性时调用的函数 |
set | 写入属性时调用的函数 |
重要
value/writable 不能和 get/set 同时出现,否则会抛 TypeError
const obj = {}
let inner = 0
Object.defineProperty(obj, 'count', {
get() {
return inner
},
set(v) {
console.log('set =>', v)
inner = v
},
enumerable: true,
configurable: true,
})
obj.count = 10 // set => 10
console.log(obj.count) // 10实现响应式拦截
Vue 2 的响应式系统基于 Object.defineProperty,核心思路是将对象的每个属性转换为 getter/setter,从而在读写时执行额外逻辑(依赖收集、派发更新等)
对象的拦截
以下面这个对象为例:
const person = {
name: 'Aaron',
age: 18,
address: {
home: '',
now: '',
},
}遍历属性,定义 getter/setter
将每个属性转换为 getter/setter 最直觉的做法 —— 遍历所有 key,用
defineProperty逐一劫持:Object.keys(person).forEach((key) => { Object.defineProperty(person, key, { get() { console.log('get:' + key) return person[key] // ← 问题在这里 }, set(val) { console.log('set:' + key) person[key] = val // ← 同样的问题 }, }) }) console.log(person.name) // Maximum call stack size exceeded由于在
get里读person[key]会再次触发get,就会导致无限递归直到栈溢出,所以需要使用闭包缓存值,切断循环引用闭包缓存值,切断循环引用
把原始值通过函数参数
val传入,getter/setter 直接操作这个闭包变量,不再访问原对象属性:function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { get() { console.log('get:' + key) return val }, set(newVal) { console.log('set:' + key) val = newVal }, }) } function observer(obj) { if (typeof obj !== 'object' || !obj) return Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key])) } observer(person) person.name = 'foo' // set:name console.log(person.name) // get:name → 'foo'这样做完之后,基本的读写拦截已经生效,但还有两个深层问题:
- 无法处理嵌套对象
- 无法处理初始值为嵌套对象的场景
处理赋值为新对象的场景
这段代码中
person.name.firstName = 'foo'实际上拆解为两步:先get拿到person.name;再对这个普通对象设置firstName。由于新赋值的对象没有被observer处理过,自然无法拦截person.name = { firstName: 'yuan', lastName: 'will' } // set:name ✓ // get:name(只触发了 name 的 get)firstName 的 set 没有被触发 ✗ person.name.firstName = 'foo'解决方案:可以在
set中判断newVal是否为对象,如果是则递归观测function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { get() { console.log('get:' + key) return val }, set(newVal) { console.log('set:' + key) if (typeof newVal === 'object') { observer(newVal) } val = newVal }, }) }处理初始值为嵌套对象的场景
如果一个属性在初始化时就是一个对象,那么也是没办法触发
get的,因为defineReactive只在当前层定义了 getter/setter,并没有深入到这个内部。因此需要在defineReactive开头递归观测valfunction defineReactive(obj, key, val) { observer(val) Object.defineProperty(obj, key, { get() { console.log('get:' + key) return val }, set(newVal) { console.log('set:' + key) if (typeof newVal === 'object') { observer(newVal) } val = newVal }, }) } function observer(obj) { if (typeof obj !== 'object' || !obj) return Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key])) }
数组的拦截
由于 Object.defineProperty 按属性名工作,因此对数组有天然盲区:虽然可以将索引作为 key 进行劫持,但是如果访问不存在的元素时,或者是通过 push 等方法去修改数组时,则无法拦截
Vue 2 的策略是重写数组原型上的 7 个变异方法,在调用原始逻辑前后插入拦截:
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
methodsToPatch.forEach((method) => {
const original = arrayProto[method]
Object.defineProperty(arrayMethods, method, {
value(...args) {
const result = original.apply(this, args)
// push / unshift / splice 会插入新元素,需要对其进行观测
let inserted
if (method === 'push' || method === 'unshift') inserted = args
else if (method === 'splice') inserted = args.slice(2)
if (inserted) inserted.forEach(item => observer(item))
return result
},
})
})// 改造 `observer`,遇到数组时替换原型链
function observer(obj) {
if (typeof obj !== 'object' || !obj) return
if (Array.isArray(obj)) {
Object.setPrototypeOf(obj, arrayMethods)
obj.forEach(item => observer(item))
return
}
Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]))
}const data = { list: [1, 2, { name: 'a' }] }
observer(data)
data.list.push(4) // 拦截到 push
data.list.splice(1, 1, 'new') // 拦截到 splice为什么不直接劫持索引
从技术上讲,Object.defineProperty 可以对数组索引定义 getter/setter,但 Vue 2 有意不这么做:
- 动态增长无法覆盖 —
push、splice创建的新索引从未被defineProperty处理,赋值不会被拦截 - 性能代价过高 — 长度为 10000 的数组就要调用 10000 次
defineProperty,且每次长度变化都要补定义新索引 - 稀疏数组不可预知 —
arr[99999] = 1这种操作,不可能提前为每个潜在索引都定义 getter/setter
此外,length 属性的描述符为 { configurable: false },无法将其改为访问器描述符,因此 arr.length = 0 的变化也完全无法拦截
