「面试必问!Proxy对比defineProperty的六大核心差异与底层原理」

引言:在我们前端面试中面试官经常会问到你这样一个问题:"请你聊一聊object.defineProperty和proxy的区别"。这两个东西分别是Vue2和Vue3实现响应式的技术路线。接下来我们看看他们俩之间底层逻辑的区别到底是啥。

一、Object.defineProperty实现解析

我们先看这样一个场景,我们先创建一个data对象,并且想要在修改对象里某个属性的值时触发视图更新。

js 复制代码
function updateView(fn) {
    console.log('视图更新');

}

let data = {
    name: '牢大',
    age: 18,
    like: ['吃饭', '睡觉']
}

data.age = 19

我们希望的是当data.age = 19这段代码执行时updateView函数触发。我们要实现这个效果的关键在于我们现在执行data.age = 19这段代码时没有地方让视图更新函数执行。

为了解决这个问题我们可以引入一个Observe观察者,由他来监控data对象的行为,就像考试时监控室里的老师,无时不刻地盯着屏幕看有没有人在作弊。

js 复制代码
function observer(target) {
    if (typeof target !== 'object' || target == null) { //判断是否为对象
        return target
    }

    for (let key in target) {  // 遍历对象
        defineReactive(target, key, target[key])
    }
}

我们希望有一个函数能够将传入Observe的对象变成一个响应式对象,于是我们就有了下面这个方法

js 复制代码
function defineReactive(target, key, value) {
    Object.defineProperty(target, key, { // 数据劫持
        get() {
           
        },
        set(newVal) {
          
        }
    })
}

Object.defineProperty是它是 ES5 中定义对象属性的方法,直接对对象的某个属性进行劫持。target代表要劫持哪个对象,key代表要劫持对象的哪个属性,value代表要劫持的属性的值。当Object.defineProperty劫持完对象后,它提供了一个getset函数。

这时如果我们想要执行下面这段代码会得到什么结果呢

js 复制代码
function observer(target) {
    if (typeof target !== 'object' || target == null) { //判断是否为对象
        return target
    }

    for (let key in target) {  // 遍历对象
        defineReactive(target, key, target[key])
    }
}

function defineReactive(target, key, value) {
    Object.defineProperty(target, key, { // 数据劫持
        get() {
          
        },
        set(newVal) {
            
        }
    })
}

function updateView(fn) {
    console.log('视图更新');
}

let data = {
    name: '牢大',
    age: 18,
    like: ['吃饭', '睡觉']
}
observer(data)

console.log(data.age)

可以看到运行结果是undefined,这是因为一旦对象里面的属性被劫持了,就不会按照传统的方式去访问。就像是绑匪把对象的某个属性劫持了,现在通过一般途径看不见这个属性。如果想要访问到对象里的属性就要通过绑匪里面的get函数。

现在我们完善Object.defineProperty里的get函数再运行下试试看

js 复制代码
function defineReactive(target, key, value) {
    Object.defineProperty(target, key, { // 数据劫持
        get() {
            return value
        },
        set(newVal) {

        }
    })
}

可以发现可以正常访问到属性里的值了。但是如果我们返回的是其他值比如99呢。访问属性里的值得到的是什么呢?

js 复制代码
function defineReactive(target, key, value) {
    Object.defineProperty(target, key, { // 数据劫持
        get() {
            return 99
        },
        set(newVal) {

        }
    })
}

不难看出我们现在访问对象里的属性的值这个行为完全被Object.defineProperty里的get方法代理了

那修改值这个行为是不是也是被Object.defineProperty里的set方法代理了呢

js 复制代码
observer(data)
data.age = 19
console.log(data.age)

我们现在得到的结果还是18,现在无法修改值,我们继续完善set函数再试试看

js 复制代码
function defineReactive(target, key, value) {
    Object.defineProperty(target, key, { // 数据劫持
        get() {
            return value
        },
        set(newVal) {
            if (newVal !== value) {
                value = newVal
                updateView()
            }
        }
    })
}

我们可以发现我们是通过Object.defineProperty里的set方法来进行值修改这个行为的。

我们现在把对象里的属性的值改为引用类型看看会有什么样的结果

js 复制代码
let data = {
    name: '牢大',
    age: {
        n: 18
    },
    like: ['吃饭', '睡觉']
}
observer(data)
data.age.n = 19
console.log(data.age.n)

我们发现可以成功修改值,但是!仔细看,是不是少了打印视图更新。因为我们前面已经得出结论对象被劫持后修改属性的值的行为会被代理。说明我们根本没有劫持到属性!我们只能劫持到第一层属性age,里面的n劫持不到。

为了能够劫持更深层次的属性,我们在数据劫持时要做一个递归的操作。如果keyvalue值也是引用类型我们也把value劫持。

js 复制代码
function defineReactive(target, key, value) {
    observer(value)
    if (typeof value === 'object' && value !== null) {
        observer(value)
    }
    Object.defineProperty(target, key, { // 数据劫持
        get() {
            return value
        },

        set(newVal) {
            if (newVal !== value) {
                value = newVal
                updateView()
            }
        }
    })
}

我们再次执行上面代码就会发现深层次的value也被劫持了

js 复制代码
let data = {
    name: '牢大',
    age: {
        n: 18
    },
    like: ['吃饭', '睡觉']
}
observer(data)
data.age.n = 19
console.log(data.age.n)

现在又有一个问题,假如我们对象的属性的值为一个数组类型,我们往数组里面添加元素,会触发视图更新吗?

我们先来看能不能访问得到这个数组

js 复制代码
observer(data)
data.like.push('coding')
console.log(data.like)

我们可以看到我们通过Object.defineProperty自带的Getter方法访问到了数组,但是没有触发视图更新,但是数组上的push方法无法触发数据劫持的Setter方法。

如果这样的话在Vue2中遇到data.like.push('coding')这样的代码岂不是无法做到响应式了,为了解决这个问题Vue官方重写了数组身上的方法

js 复制代码
let oldArrayPrototype = Array.prototype

我们先拿到了Array构造函数的显式原型,之所以数组能用push这个方法就是因为Array构造函数的显式原型上有push这个方法。

js 复制代码
let proto = Object.create(oldArrayPrototype)

我们调用Object.create方法创建了一个新对象,并且这个新对象的隐式原型为Array构造函数的显式原型。这样我们就拷贝了一份数组的原型。

Vue官方把数组身上所有的方法都重写了,我们看样例代码

js 复制代码
Array.from(['push', 'pop', 'shift', 'unshift']).forEach(method => {
    proto[method] = function (...args) {
        oldArrayPrototype[method].call(this, ...args) //仍然为官方的方法
        updateView() // 额外添加视图更新的逻辑
    }
})

既然JS官方打造的数组身上的方法无法触发响应式,那么我们就重写JS官方的代码,在官方的方法上额外添加实现响应式的功能。

那么现在问题又来了,如何让程序员用到的是Vue重写后的数组的方法而不是JS原本的数组的方法?我们接下来看代码。

js 复制代码
function observer(target) {
    if (typeof target !== 'object' || target == null) {
        return target
    }

    if (Array.isArray(target)) {
        target.__proto__ = proto
        return
    }

    for (let key in target) {
        defineReactive(target, key, target[key])
    }
}

因为Observe已经观察了这个对象,如果这个对象是一个数组的话。那么我们就直接把对象的隐式原型赋值为我们打造的新的对象的原型。

我们再看一个例子,如果往对象里面添加一个属性是否会被劫持呢?

js 复制代码
observer(data)
data.sex = 'boy'
console.log(data);

可以看到没有触发视图更新效果。说明往对象上添加属性也不会被Object.defineProperty劫持。

我们现在来总结下Object.defineProperty的特性

  • 属性级拦截 :通过重新定义对象的某个属性(getter/setter)实现拦截。
  • 静态劫持:必须在对象初始化时显式定义劫持的属性,无法自动拦截新增属性。
  • 侵入性修改:会直接修改原始对象的属性描述符。
  • 仅支持基础操作 :只能拦截属性的读取(get)和设置(set)。
  • 无法拦截以下操作
    1. 属性的删除(delete obj.a
    2. 检测属性存在性(key in obj
    3. 遍历对象(Object.keys(obj)
    4. 数组的索引修改(arr[0] = 1)和长度变化(arr.length = 0
  • 无法直接监听数组变化 :需通过重写数组方法(如 pushpop 等)实现响应式。
  • 初始化性能消耗:需要递归遍历对象的所有属性进行劫持。

二、Proxy实现解析

同样的场景,我们先创建一个data对象,并且想要在修改对象里某个属性的值时触发视图更新。

js 复制代码
function reactive(target) {
    return createReactiveObject(target)
}



function updateView(fn) {
    console.log('视图更新');

}

let data = {
    name: 'awei',
    age: 18,
    like: ['吃饭', '睡觉']
}

let newData = reactive(data)
newData.name = 'jack'

但是现在我们使用Proxy数据代理的方式来实现响应式,不用原来的data对象来触发视图更新,而是用代理后的对象newData来触发。reactive是用来返回响应式对象的一个函数。

js 复制代码
function createReactiveObject(target) {
    if (!isObject(target)) {
        return target
    }

    let baseHandler = {
        get(target, key, receiver) {
            console.log('读取值');
            let result = Reflect.get(target, key, receiver)
            return isObject(result) ? reactive(result) : result
        },

        set(target, key, value, receiver) {
            console.log('修改值');
            let res = Reflect.set(target, key, value, receiver)
            updateView()
            return res
        },
        // ... 一共有13个拦截器
    }

observe是代理后得到的对象,target是被代理的对象,baseHandler是代理之后的行为一共有13 种。get函数接收的三个参数的含义分别为:被代理的对象、被代理对象的key,代理后得到的对象。

我们来看个例子修改对象的行为此时有没有被代理

js 复制代码
let newData = reactive(data)
newData.name = 'jack'

触发了视图更新所以修改值这个行为被代理了。

我们再来看读取深层次的值会不会被代理

js 复制代码
let data = {
    name: '牢大',
    age: {
        n: 18
    },
    like: ['吃饭', '睡觉']
}
let newData = reactive(data)
newData.age.n = 20

可以看到没有触发视图更新,因为我们没有做递归操作。但是Vue3与Vue2不同的是,Vue3在读取值时会返回读取后的值。所以这里也打印了"读取值"。我们把递归操作放在了get操作中,如果返回的是一个对象会再次执行reactive函数。

所以Proxy是在读取值的过程中来判断是否为一个对象,这样就做到了按需递归,而不是默认都递归。如果对象里的某个值没有被读取就不会被代理。

我们再来看往数组里添加一个值会不会被代理

js 复制代码
let newData = reactive(data)
newData.like.push('coding')

可以看到触发了视图更新,所以这个行为是可以被Proxy代理的。

我们再来看往对象里添加值这个行为会不会被代理

js 复制代码
let newData = reactive(data)
newData.sex = 'boy

可以看到视图更新了,并且走的是set。

现在我们来总结下Proxy的特性

  • 对象级代理:创建一个对象的代理(Proxy),拦截整个对象的所有操作。

  • 动态拦截 :无需预先定义属性,可以自动拦截新增属性和所有访问方式(包括 deletein 等)。

  • 非侵入式:通过代理对象间接操作原始对象,不修改原对象。

  • 全面拦截:支持 13 种拦截操作,包括:

    • 属性读取(get)、设置(set)、删除(deleteProperty
    • 检测存在性(has
    • 遍历(ownKeys
    • 函数调用(apply,用于拦截函数对象)
    • 构造函数调用(construct
    • 更多(如 getPrototypeOfsetPrototypeOf 等)
  • 天然支持数组:可以直接拦截数组的索引赋值、长度修改以及原生方法调用。

  • 更简洁的实现:无需重写数组方法。

  • 按需拦截:仅在访问代理对象时触发拦截,初始化更快。

  • 内存优化:代理对象本身是一个轻量包装器。

三、Proxy对比defineProperty

总结对比表

特性 Object.defineProperty Proxy
拦截粒度 属性级 对象级
新增属性拦截 不支持(需 Vue.set) 支持
数组响应式 需重写方法 原生支持
拦截操作类型 get/set 13 种(如 hasdelete 等)
兼容性 IE9+ 现代浏览器(IE 不支持)
性能初始化 较差(需遍历属性) 较好
相关推荐
陈卓41032 分钟前
Redis-限流方案
前端·redis·bootstrap
顾林海40 分钟前
Flutter Dart 运算符全面解析
android·前端
七月丶1 小时前
🚀 现代 Web 开发:如何优雅地管理前端版本信息?
前端
漫步云端的码农1 小时前
Three.js场景渲染优化
前端·性能优化·three.js
悬炫1 小时前
赋能大模型:ant-design系列组件的文档知识库搭建
前端·ai 编程
Huooya1 小时前
springboot的外部配置加载顺序
spring boot·面试·架构
用户108386386801 小时前
95%开发者不知道的调试黑科技:Apipost让WebSocket开发效率翻倍的秘密
前端·后端
钢板兽1 小时前
Java后端高频面经——Spring、SpringBoot、MyBatis
java·开发语言·spring boot·spring·面试·mybatis
钢板兽1 小时前
Java后端高频面经——JVM、Linux、Git、Docker
java·linux·jvm·git·后端·docker·面试
稀土君1 小时前
👏 用idea传递无限可能!AI FOR CODE挑战赛「创意赛道」作品提交指南
前端·人工智能·trae