likes
comments
collection
share

Proxy 还是 defineProperty ?小孩子才做选择

作者站长头像
站长
· 阅读数 5

前言

在最近面试中,经常遇到面试官问到 Vue 的响应式是怎么实现的,我们知道 Vue2 与 Vue3 的响应式实现是有差别的,Vue 2 使用Object.defineProperty来实现响应式,但由于它的一些局限,Vue 3 改用Proxy实现,ProxyObject.defineProperty虽然都是用于创建和管理对象属性的方法,但还是有所差别,这篇文章咱们就来好好分析一下🧐。

Object.defineProperty

Object.defineProperty() 是 ES5 中监听对象属性的方法,它会直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。详情参考(Object.defineProperty() - JavaScript | MDN (mozilla.org)

语法

Object.defineProperty(obj, prop, descriptor)

其中obj是要定义属性的对象,prop为一个String或 Symboldescriptor为要定义或修改的属性的描述符,返回值为这个对象,并且它所指定的属性已被添加或修改。

对象中存在的属性描述符有两种主要类型:数据描述符和访问器描述符。

⚠️注意:描述符只能是这两种类型之一,不能同时为两者。

数据描述符

是一个具有可写或不可写值的属性。

valuewritableenumerableconfigurable
类型任意BooleanBooleanBoolean
默认值undefinedfalsefalsefalse
描述设置属性的值属性是否可写属性是否可以枚举属性是否可以删除

举个栗子🌰

//默认值
const obj = {};

Object.defineProperty(obj, 'name', {
  value: '阳阳羊',
});

obj.name = '喜羊羊';
console.log(obj.name); //阳阳羊
console.log(Object.keys(obj)); //[]
delete obj.name
console.log(obj.name); //阳阳羊

———————————————————————————————————————————————————————————————————————————————————————————
//全部改为true
Object.defineProperty(obj, 'name', {
  value: '阳阳羊',
  writable: true, //可写
  enumerable : true, //可迭代
  configurable : true //可删
});

obj.name = '喜羊羊';
console.log(obj.name); //喜羊羊
console.log(Object.keys(obj)); //[ 'name' ]
delete obj.name
console.log(obj.name); //undefined

根据对比,很容易理解,这里就不过多赘述。

访问器描述符

是由 getter/setter 函数对描述的属性。

getset
类型Function 或 undefinedFunction 或 undefined
默认值undefinedundefined
描述当访问该属性时,会调用此方法,并返回其返回值作为属性的值当属性值被修改时,会调用此方法,传入新的值

举个栗子🌰

let obj = {};
let name = '阳阳羊'

Object.defineProperty(obj, 'name', {
    get() {
        console.log('访问了');
        return name
    },
    set(newVal) {
        console.log('修改了');
        name = newVal
    }
});

console.log(obj.name); //访问了 阳阳羊
obj.name = '修改后的阳阳羊'; //修改了
console.log(obj.name); //访问了 修改后的阳阳羊

监听多个属性

通过静态方法Object.keys()拿到对象的key数组,遍历每个key并监听:

let obj = {
    name : '阳阳羊',
    age : 18,
};

Object.keys(obj).forEach(key => {
    Object.defineProperty(obj, key, {
        get() {
            console.log('访问了');
            return obj[key];
        },
        set(newValue) {
            console.log('修改了');
            obj[key] = newValue;
        }
    });
});

console.log(obj.name); //爆栈

Proxy 还是 defineProperty ?小孩子才做选择

这里每个属性的 get 会陷入无限循环,因为我们在 get 中 return obj[key],访问 obj[key] 也会调用 get 方法,导致递归调用,从而爆栈。

解决这个问题的一种方法是使用一个内部的存储来保存属性值,而不是直接在对象上存储。

let obj = {
    name : '阳阳羊',
    age : 18,
};

let data = {};

Object.keys(obj).forEach(key => {
    data[key] = obj[key];
    Object.defineProperty(obj, key, {
        get() {
            console.log('访问了');
            return data[key];
        },
        set(newValue) {
            console.log('修改了');
            data[key] = newValue;
        }
    });
});

console.log(obj.name); // 访问了 阳阳羊
obj.name = '喜羊羊'; // 修改了
console.log(obj.name); // 访问了 喜羊羊

或者我们可以使用两个函数来解决

let obj = {
    name: '阳阳羊',
    age: 0
}
// 实现一个响应式函数
function defineProperty(obj, key, val) {
    Object.defineProperty(obj, key, {
        get() {
            console.log('访问了')
            return val
        },
        set(newVal) {
            console.log('修改了')
            val = newVal
        }
    })
}
// 实现一个遍历函数Observer
function Observer(obj) {
    Object.keys(obj).forEach((key) => {
        defineProperty(obj, key, obj[key])
    })
}

Observer(obj)
console.log(obj.name); // 访问了 阳阳羊
obj.name = '喜羊羊'; // 修改了
console.log(obj.name); // 访问了 喜羊羊

深度监听对象

可以使用递归来遍历对象的每个属性

function deepProperty(obj) {
    let data = {};

    Object.keys(obj).forEach(key => {
        if (typeof obj[key] === 'object' && obj[key] !== null) {
            data[key] = deepProperty(obj[key]); // 属性是对象时进行递归
        } else {
            data[key] = obj[key];
        }

        Object.defineProperty(obj, key, {
            get() {
                console.log('访问了');
                return data[key];
            },
            set(newValue) {
                console.log('修改了');
                if (typeof newValue === 'object' && newValue !== null) {
                    data[key] = deepProperty(newValue); // 对新值进行递归拦截
                } else {
                    data[key] = newValue;
                }
            }
        });
    });
    return obj;
}

let obj = {
    name: '阳阳羊',
    age: 18,
    address: {
        city: '南昌',
        street: '广兰大道'
    }
};

let propertyObj = deepProperty(obj);

console.log(propertyObj.name); // 访问了 阳阳羊
console.log(propertyObj.address.city); // 访问了 访问了 南昌

propertyObj.name = '喜羊羊'; // 修改了
propertyObj.address.city = '北京'; // 访问了 修改了

console.log(propertyObj.name); // 访问了 喜羊羊
console.log(propertyObj.address.city); // 访问了 访问了 北京

这里递归监听,所以会打印两个访问了

监听数组属性

实现监听数组属性的过程与监听对象属性的过程类似,但需要考虑到数组的特殊性,比如数组的长度属性和一些数组 API

let arr = [1, 2, 3]
let obj = {}
Object.defineProperty(obj, 'arr', {
    get() {
        console.log('访问了')
        return arr
    },
    set(newVal) {
        console.log('修改了')
        arr = newVal
    }
})
console.log(obj.arr) //访问了  [ 1, 2, 3 ]
obj.arr = [1, 2, 3, 4] //修改了
obj.arr.push(5) //访问了
obj.arr.pop() //访问了

这里我们发现数组的push()pop()方法添加或删除元素并不能被 set 监听到,这就是defineProperty其中一大缺陷,针对这个缺陷vue2的做法是:对数组的一系列方法进行了重写,使其能够被监听,具体重写方法详见 vue2官网文档

小结

  • Object.defineProperty只能监听对象的属性
  • 监听多个属性时,需要遍历每个属性
  • 需要递归深度监听每个对象的属性
  • 无法监听数组的方法

Proxy

Proxy 对象是在 ES6 中引入的一种新机制,它用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。与 Object.defineProperty 相比,Proxy 提供了更加灵活强大的功能,是处理拦截需求的首选。

语法

const p = new Proxy(target, handler)

其中target是要使用 Proxy 包装的目标对象;第二个参数handler通常是一个配置对象,对于每一个被代理的操作,需要提供一个对应的处理函数,该函数将拦截对应的操作。

举个例子🌰

let target = {
    name: '阳阳羊',
    age:'18',
}

let handler = {
    get(obj, key) {
        console.log('访问了')
        return key in obj ? obj[key] : null
    },
    set(obj, key, val) {
        console.log('修改了')
        obj[key] = val
        return true
    }
}
const p = new Proxy(target, handler)

console.log(p.name) //访问了 阳阳羊
p.name = '喜羊羊' // 修改了

这里明显可见,使用 proxy 拦截的是整个对象,而不是对象的属性,使用proxy监听这个对象就相当于监听了每个属性,所以我们不用像Object.defineProperty一样遍历对象属性。

  • get 接收三个参数target:目标对象,property:被获取的属性名,receiver(可选):Proxy 或者继承 Proxy 的对象。
  • set 接收四个参数target:目标对象,property:将被设置的属性名或Symbolvalue:新属性值,receiver:最初接收赋值的对象。

hander 对象不仅支持 get 和 set 方法,还有其他共13种方法,摘自 阮一峰老师的ES6入门

  • get(target, propKey, receiver) :拦截对象属性的读取,比如proxy.fooproxy['foo']
  • set(target, propKey, value, receiver) :拦截对象属性的设置,比如proxy.foo = vproxy['foo'] = v,返回一个布尔值。
  • has(target, propKey) :拦截propKey in proxy的操作,返回一个布尔值。
  • deleteProperty(target, propKey) :拦截delete proxy[propKey]的操作,返回一个布尔值。
  • ownKeys(target) :拦截Object.getOwnPropertyNames(proxy)Object.getOwnPropertySymbols(proxy)Object.keys(proxy)for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()的返回结果仅包括目标对象自身的可遍历属性。
  • getOwnPropertyDescriptor(target, propKey) :拦截Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
  • defineProperty(target, propKey, propDesc) :拦截Object.defineProperty(proxy, propKey, propDesc)Object.defineProperties(proxy, propDescs),返回一个布尔值。
  • preventExtensions(target) :拦截Object.preventExtensions(proxy),返回一个布尔值。
  • getPrototypeOf(target) :拦截Object.getPrototypeOf(proxy),返回一个对象。
  • isExtensible(target) :拦截Object.isExtensible(proxy),返回一个布尔值。
  • setPrototypeOf(target, proto) :拦截Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。
  • apply(target, object, args) :拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)proxy.call(object, ...args)proxy.apply(...)
  • construct(target, args) :拦截 Proxy 实例作为构造函数调用的操作,比如new proxy(...args)

Proxy 深度监听对象

let target = {
    name: '阳阳羊',
    age:'18',
    address:{
        city:'南昌',
    }
}

let handler = {
    get(obj, key) {
        console.log('访问了')
        return key in obj ? obj[key] : null
    },
    set(obj, key, val) {
        console.log('修改了')
        obj[key] = val
        return true
    }
}
const p = new Proxy(target, handler)

console.log(p.address.city) //访问了 南昌
p.address.city = '北京' // 访问了
console.log(p.address.city) // 访问了 北京

这里我们修改对象中的对象属性,不会触发 set ,要想实现深度监听,也需要递归监听每个对象。

let target = {
    name: '阳阳羊',
    age:'18',
    address:{
        city:'南昌',
    }
}

let handler = {
    get(obj, key) {
        console.log('访问了')
        return key in obj ? obj[key] : null
    },
    set(obj, key, val) {
        console.log('修改了')
        obj[key] = val
        return true
    }
}

function Observer(target,handler){
    // 递归代理对象的对象属性
    for (const key of Object.keys(target)) {
        if (target[key] && typeof target[key] === 'object') {
            target[key] = Observer(target[key], handler);
        }
    }
    return new Proxy(target, handler);
}
const p = Observer(target, handler);

console.log(p.address.city) //访问了 访问了 南昌
p.address.city = '北京' // 访问了 修改了
console.log(p.address.city) // 访问了 访问了 北京

这样,我们通过Observer函数,遍历每个对象属性,并递归代理。

当我们访问内部对象属性时,会打印触发两次 get ,当修改时,触发一次 set ,完美!

Proxy 监听数组

let target = [1, 2, 3]

let handler = {
    get(obj, key) {
        console.log('访问了')
        return key in obj ? obj[key] : null
    },
    set(obj, key, val) {
        console.log('修改了')
        obj[key] = val
        return true
    }
}
const p = new Proxy(target, handler)
console.log(p) // [ 1, 2, 3 ]
console.log(p[0]) // 访问了 1
console.log(p[3]) // 访问了 null
p[0] = 11 // 修改了
p.push(4) // 访问了 访问了 修改了 修改了
console.log(p) // [ 11, 2, 3, 4 ]

这里Proxy成功监听到数组的push方法,但是为什么触发了两次 get 和 set?

  1. 访问 push 方法Proxyget 方法被调用来获取 push 方法。这是第一次触发。
  2. 执行 push 方法push 方法首先会读取数组的长度来确定从哪个位置开始插入新元素。这再次触发 get
  3. 设置新元素:将新元素插入到数组中。第一次 set 方法被触发,记录这个改变。
  4. 更新数组长度push 方法完成后,数组的 length 属性更新。set 方法被捕捉到,第二次的触发。

其他数组 API 拦截行为可以有不同的表现,这取决于每个方法如何访问和修改数组的内部状态。有兴趣的同学可以自行了解下。

兼容性

Proxy 还是 defineProperty ?小孩子才做选择

总结

  • Proxy监听的是整个对象而不是单个属性,无需遍历每个属性
  • Proxy也需要递归监听对象的对象属性
  • Proxy能够监听数组的一系列方法
  • Proxy拥有共 13 种拦截方法,功能也更加齐全

参考

最后

码字不易,感谢点赞转发 ~

技术小白记录学习过程,有错误或不解的地方还请评论区留言,如果这篇文章对你有所帮助请 “点赞 收藏+关注” ,感谢支持!!