Vue3 为啥用 Proxy 换掉Object.defineProperty

1,075 阅读8分钟

写在开头

希望你阅读完这篇文章后能轻松回答出以下的几个问题:

  1. vue3 为什么要用 proxy
  2. defineProperty 和 proxy 的区别
  3. 你是怎么理解 ES6 中的 Proxy?
  4. Proxy 的使用场景有哪些?

Object.defineProperty

第一次接触这个 API 是在 JS 对象中,这个 API 的功能就是修改对象属性描述符

对象属性描述符:主要是对属性进行一些限制,比如这个属性是否可以通过 delete 删除?这个属性是否可以在 for-in 遍历的时候被遍历出来呢?属性描述有两种类型:

  • 数据属性描述符
  • 访问器属性描述符
    • get:获取属性时会执行的函数,默认为 undefined(vue2 主要使用)
    • set:设置属性时会执行的函数,默认为 undefined(vue2 主要使用)
    • 等等

vue2 利用 Object.defineProerty 劫持对象的访问器(getset),在属性值发生变化时,我们可以获取变化,从而进一步操作的。

简单例子

Object.defineProperty语法:

  • obj:要定义属性的对象
  • prop:一个字符串或者 Symbol,指定了要定义或者修改的属性键
  • descriptor:要定义或修改的属性的描述符
Object.defineProperty(obj, prop, descriptor)

利用上述描述的 Object.defineProperty 来实现一个简单的响应函数defineReactive

function update(){
  app.innerText = obj.foo
}

function defineReactive(obj,key,val){
  Object.defineProperty(obj,key,{
    get(){
      console.log(`get ${key}:${val}`)
      return val
    },
    set(newVal){
      if(newVal!==val){
        val = newVal
        update()
      }
    }
  })
}

调用defineReactive,数据发生变化触发update方法,实现数据响应式:

const obj = {}
defineReactive(obj, 'foo', '')
setTimeout(()=>{
    obj.foo = new Date().toLocaleTimeString()
},1000)

缺陷一:只能劫持对象的属性

上述定义的简单响应式函数并不能满足大部分的要求,针对以下情况还需要补充:

  • 在对象存在多个key情况下,需要进行遍历
  • 如果存在嵌套对象的情况,还需要在defineReactive中进行递归
  • 当给key赋值为对象的时候,还需要在set属性中进行递归
function update() {
  app.innerText = obj.foo
}

//如果存在嵌套对象的情况,还需要在defineReactive中进行递归
function defineReactive(obj, key, val) {
  observe(val)
  Object.defineProperty(obj, key, {
    get() {
      console.log(`get ${key}:${val}`);
      return val
    },
    //当给key赋值为对象的时候,还需要在set属性中进行递归
    set(newVal) {
      if (newVal !== val) {
        val = newVal
        observe(newVal) // 新值是对象的情况
        update()
      }
    }
  })
}
//在对象存在多个key情况下,需要进行遍历
function observe(obj) {
  if (typeof obj !== 'object' || obj == null) {
    return
  }
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}

以上算是能够实现了对一个对象的基本响应式,但是仍然存在一些问题:

  • 现在对一个对象进行删除与添加属性操作,也是无法劫持到

缺陷二:只能劫持数组的部分操作

我们总说,Object.defineProperty不能拦截数组,这种说法不太准确,看示例:

let list = [1,2,3,4];
observe(list);
console.log(list[0]) // 拦截到正在获取属性:0
list[0] = 2; // 拦截到正在修改属性:0

list[6] = 6; // 无法拦截...
list.push(3); // 无法拦截...

通过索引去访问或修改已经存在的元素,是可以拦截到的。如果是不存在的元素,或者是通过push等方法去修改数组,则无法拦截。

  • push
  • pop
  • shift
  • unshift
  • splice
  • sort
  • reverse

因此,vue2 在使用时,使用了一些hack,重写了数组原型上的七个方法来解决无法拦截的问题。

具体代码可以参考:vue/src/core/observer/array.ts at main · vuejs/vue

Proxy

proxy 译为代理,可以理解为在操作目标对象前有一层程序,将所有本该我们手动操作的工作都交给这层程序来处理。生活中也有许多“proxy”,比如:代购、中介等。

除此之外,Proxy 拦截的是整个对象,根本不用关心具体的 key,它可以去「修改 obj 上的任意 key」 和「读取 obj 上的任意 key」,最后返回一个新的对象

语法

  • target 要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)
  • handler 一个通常以函数作为属性的对象,用来定制拦截行为
const proxy = new Proxy(target, handle)

常见的 handler 方法如下:

方法描述
handler.has()in 操作符的捕捉器。
handler.get()属性读取操作的捕捉器。
handler.set()属性设置操作的捕捉器。
handler.deleteProperty()delete 操作符的捕捉器。
handler.ownKeys()Object.getOwnPropertyNames 方法和 Object.getOwnPropertySymbols 方法的捕捉器。
handler.apply()函数调用操作的捕捉器。
handler.construct()new 操作符的捕捉器

简单例子

利用上述描述的proxy来实现一个简单的响应函数defineReactive

const reactive = obj => {
    // 如果obj不是一个对象,就没必要包装了
    if(typeof obj !== 'object' || !obj) {
        return obj;
    }
    const proxyConfig = {
        get(target, key, receiver) {
            console.log('拦截到正在获取属性:' + key);
            return Reflect.get(target, key, receiver)
        },
        set(target, key, val, receiver) {
            console.log('拦截到正在修改属性:' + key);
            return Reflect.set(target, key, val, receiver);;
        }
    };
    const observed = new Proxy(obj, proxyConfig);
    return observed;
}

测试一下简单数据的操作,发现都能劫持

const state = reactive({
  foo: 'foo'
})
// 1.获取
state.foo // ok
// 2.设置已存在属性
state.foo = 'fooooooo' // ok
// 3.设置不存在属性
state.dong = 'dong' // ok
// 4.删除属性
delete state.dong // ok

测试一下简单数组的操作:

let listProxy = reactive(['张三','李四','王五','赵六']);

console.log(listProxy[0]) // 拦截到正在获取属性:0
listProxy[0] = '李四'; // 拦截到正在修改属性:0
listProxy[6] = 6; // 拦截到正在修改属性:6
/**
 * 拦截到正在获取属性:push
 * 拦截到正在获取属性:length
 * 拦截到正在修改属性:7
 * 拦截到正在修改属性:length
 */
listProxy.push('赵七'); 

解释一下,为何listProxy.push会经历这么多过程:

  1. 获取 push 方法:首先需要获取数组的 push 方法,这会触发 get 拦截器,输出:拦截到正在获取属性:push
  2. 获取 length 属性:push 方法需要知道数组的当前长度,以便在数组的末尾添加新元素,这会触发 get 拦截器,输出:拦截到正在获取属性 push
  3. 设置新元素:push 方法会在末尾添加新元素,这会触发 set 拦截器,输出:拦截到正在修改属性:7
  4. 更新 leng 属性:push 方法会更新数组的 length 属性以反映新元素的添加,这会触发 set 拦截器,输出:拦截到正在修改属性:length

人无完人:嵌套对象

虽然 proxy 可以代理整个对象,但是对于这种嵌套对象来说,会有以下问题:

  • 深层嵌套对象未被代理:在上述的简单 reactive 函数中,代理只应用于顶层对象。如果一个对象的某个属性本身是一个对象(如:person.address),那么该嵌套对象的属性访问和修改不会被 完全拦截
let person = {
    name: 'yuanwill',
    age: 26,
    address: {
        home: 'guangzhou',
        now: 'shenzhen'
    }
};
const personProxy = reactive(person)
personProxy.name = {
    firstName: 'yuan',
    lastName: 'will'
}; // 拦截到正在修改属性:name

personProxy.name.firstName = 'haha'; // 拦截到正在获取属性:name
console.log(personProxy.name); // 拦截到正在获取属性:name
/**
 * 拦截到正在获取属性:address
 * shenzhen
 */
console.log(personProxy.address.now)

personProxy.address.home = '北京'//拦截到正在获取属性:address

可以看到,person.name.firstName依然没有拦截到正在修改firstName属性。原因在于,get返回的可能是个对象,我们需要对这个对象再次代理,所以修改如下:

const observer = obj => {
    // 如果obj不是一个对象,就没必要包装了
    if(typeof obj !== 'object' || !obj) {
        return obj;
    }
    const proxyConfig = {
        get(target, key, receiver) {
            console.log('拦截到正在获取属性:' + key);
            const result = Reflect.get(target, key, receiver);
            return observer(result);
        },
        set(target, key, val, receiver) {
            console.log('拦截到正在修改属性:' + key);
            return Reflect.set(target, key, val, receiver);;
        }
    };
    const observed = new Proxy(obj, proxyConfig);
    return observed;
}

在 get 拦截器中使用了递归调用observer来尝试代理嵌套对象,以解决嵌套对象代理问题,验证如下:

const personProxyNew = observer(person)
personProxyNew.name = {
    firstName: 'yuan',
    lastName: 'will'
}; // 拦截到正在修改属性:name

/**
 * 拦截到正在获取属性:name
 * 拦截到正在修改属性:firstName
 */
personProxyNew.name.firstName = 'haha'; 
/**
 * 拦截到正在获取属性:name
 * { firstName: 'haha', lastName: 'will' }
 */
console.log(personProxyNew.name); 
/**
 * 拦截到正在获取属性:address
 * 拦截到正在获取属性:now
 * shenzhen
 */
console.log(personProxyNew.address.now)
/**
 * 拦截到正在获取属性:address
 * 拦截到正在修改属性:home
 */
personProxyNew.address.home = '北京'

人无完人:兼容性

Proxy的劣势就是兼容性问题,而且无法用polyfill磨平。

应用场景

Porxy 提供了对对象操作的全面拦截能力,功能类似于设计模式中的代理模式,因此,可以应用的场景如下:

  • 拦截和监视外部对对象的访问:Vue 中的响应式原理
  • 在复杂操作前对操作进行校验或对所需资源进行管理

使用Proxy在访问或修改对象属性时,自动进行格式化和数据转换。

const user = {
    firstName: 'John',
    lastName: 'Doe',
    age: 25
};

const userProxy = new Proxy(user, {
    get(target, property) {
        if (property === 'fullName') {
            return `${target.firstName} ${target.lastName}`;
        }
        return target[property];
    },
    set(target, property, value) {
        if (property === 'age') {
            if (typeof value !== 'number') {
                throw new TypeError('Age must be a number');
            }
            if (value < 0) {
                throw new RangeError('Age must be a positive number');
            }
        }
        target[property] = value;
        return true;
    }
});

// 使用代理对象
console.log(userProxy.fullName); // 自动格式化输出:John Doe

userProxy.age = 30; // 正常设置
console.log(userProxy.age); // 输出:30

try {
    userProxy.age = 'thirty'; // 触发类型错误
} catch (e) {
    console.error(e.message); // 输出:Age must be a number
}

try {
    userProxy.age = -5; // 触发范围错误
} catch (e) {
    console.error(e.message); // 输出:Age must be a positive number
}

Proxy 使用场景还有很多很多,不再一一列举,如果你需要在某一个动作的生命周期内做一些特定的处理,那么Proxy 都是适合的

最后

示例代码:github.com/kankan-web/…

阅读完这篇文章后,你是否对开篇的问题有了更明确的答案?

如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!

接下来,我会推出一系列Vue 3.0的文章,欢迎关注,一起探索!

参考资料

  1. 面试官:Vue3.0里为什么要用 Proxy API 替代 defineProperty API ? · Issue #47 · febobo/web-interview
  2. 一文彻底弄懂defineProperty和Proxy