跳到主要内容

Proxy

Proxy 是 ECMAScript 2015(ES6)中引入的一种新的元编程特性,它允许你创建一个对象的代理(Proxy),通过这个代理对象对原始对象的访问进行操作。Proxy 可以拦截和自定义对象的基本操作,如属性读取、属性赋值、枚举属性等。

元编程

  • 元编程(英语:Metaprogramming,又译超编程,是指某类计算机程序的编写,这类计算机程序编写或者操纵其它程序(或者自身)作为它们的数据,或者在运行时完成部分本应在编译时完成的工作
#!/bin/bash
# metaprogram
echo '#!/bin/bash' >program
for ((I=1; I<=1024; I++)) do
echo "echo $I" >>program
done
chmod +x program

这段程序每执行一次能帮我们生成一个名为program的文件,文件内容为1024行echo,如果我们手动来写1024行代码,效率显然低效

  • 元编程优点:与手工编写全部代码相比,程序员可以获得更高的工作效率,或者给与程序更大的灵活度去处理新的情形而无需重新编译

proxy 译为代理,可以理解为在操作目标对象前架设一层代理,将所有本该我们手动编写的程序交由代理来处理,生活中也有许许多多的“proxy”, 如代购,中介,因为他们所有的行为都不会直接触达到目标对象

如何理解 Proxy

Proxy 的基本用法包括两个参数:目标对象(target)和处理器对象(handler)。

处理器对象是一个包含“陷阱”(trap)的方法的对象,这些陷阱提供了许多拦截操作的方式,例如属性访问、赋值、枚举和函数调用等。当这些操作在代理对象上执行时,相应的陷阱会被触发,允许我们定义这些操作的新行为。

let target = {};
let handler = {
get: function (obj, prop) {
return prop in obj ? obj[prop] : 37; // 默认返回值37
}
};

let proxy = new Proxy(target, handler);
console.log(proxy.a); // 输出:37,因为 'a' 属性不存在

语法

let proxy = new Proxy(target, handler);
  • target:目标对象(可以是任何类型的对象,包括数组、函数或其他代理)。
  • handler:一个对象,其属性是当执行一个操作时定义代理的行为的函数。

Handler 对象的方法

handler 对象可以包含“陷阱”(trap),即拦截操作的方法。这些方法对应于对象的基本操作。下面是一些常用的陷阱方法:

  • get(target, propKey, receiver):拦截对象属性的读取操作。
  • set(target, propKey, value, receiver):拦截对象属性的设置操作。
  • has(target, propKey):拦截 propKey in proxy 的操作,返回一个布尔值。
  • deleteProperty(target, propKey):拦截 delete proxy[propKey] 的操作,返回一个布尔值。
  • apply(target, thisArg, argumentsList):拦截函数的调用操作。
  • construct(target, argumentsList, newTarget):拦截 new 操作符,用于初始化代理的构造函数。

case1: 创建一个简单的读取和设置拦截的代理:

let target = {};
let handler = {
get: function(obj, prop) {
return prop in obj ? obj[prop] : 37; // 如果属性不存在,返回37
},
set: function(obj, prop, value) {
obj[prop] = value;
// 你可以在这里做更多事情,比如记录日志等
return true; // 表示成功
}
};

let proxy = new Proxy(target, handler);

proxy.a = 1;
console.log(proxy.a); // 输出: 1
console.log(proxy.b); // 输出: 37,因为 `b` 属性不存在

case2: 拦截函数调用

function sum(a, b) {
return a + b;
}

let handler = {
apply: function(target, thisArg, argumentsList) {
console.log(`Calculate sum: ${argumentsList}`);
return target(argumentsList[0], argumentsList[1]) * 10; // 放大结果
}
};

let proxy = new Proxy(sum, handler);

console.log(proxy(1, 2)); // 输出: "Calculate sum: 1,2" 然后是 30

case3: 私有属性

const target = {
_id: '1024',
name: 'vuejs'
}

const proxy = new Proxy(target, {
get(target, propkey, proxy){
if(propkey[0] === '_'){
throw Error(`${propkey} is restricted`)
}
return Reflect.get(target, propkey, proxy)
},
set(target, propkey, value, proxy){
if(propkey[0] === '_'){
throw Error(`${propkey} is restricted`)
}
return Reflect.set(target, propkey, value, proxy)
}
})

proxy.name // vuejs
proxy._id // Uncaught Error: _id is restricted
proxy._id = '1025' // Uncaught Error: _id is restricted

为什么要用Proxy重构

在 Proxy 之前,JavaScript 中就提供过 Object.defineProperty,允许对对象的 getter/setter 进行拦截。

Vue3.0之前的双向绑定是由 defineProperty 实现, 在3.0重构为 Proxy,那么两者的区别究竟在哪里呢?

首先我们再来回顾一下它的定义

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象

上面给两个词划了重点,对象上,属性,我们可以理解为是针对对象上的某一个属性做处理的。

语法

  • obj 要定义属性的对象
  • prop 要定义或修改的属性的名称或 Symbol
  • descriptor 要定义或修改的属性描述符
Object.defineProperty(obj, prop, descriptor)
const obj = {};
Object.defineProperty(obj, 'a', {
set(val) {
console.log(`开始设置新值: ${val}`)
},
get() {
console.log(`开始读取属性`)
return 1;
},
writable : true
})

obj.a = 2 // 开始设置新值: 2
obj.a // 开始获取属性

对象新增属性为什么不更新

这个问题用过Vue的同学应该有超过95%比例遇到过

data  () {
return {
obj: {
a: 1
}
}
}

methods: {
update () {
this.obj.b = 2
}
}

上面的伪代码,当我们执行 update 更新 obj 时,我们预期视图是要随之更新的,实际是并不会

这个其实很好理解,我们先要明白 vue 中 data init 的时机,data init 是在生命周期 created 之前的操作,会对 data 绑定一个观察者 Observer,之后 data 中的字段更新都会通知依赖收集器Dep触发视图更新

然后我们回到 defineProperty 本身,是对对象上的属性做操作,而非对象本身

一句话来说就是,在 Observer data 时,新增属性并不存在,自然就不会有 getter, setter,也就解释了为什么新增视图不更新,解决有很多种,Vue 提供的全局$set 本质也是给新增的属性手动 observer

// 源码位置 https://github.com/vuejs/vue/blob/dev/src/core/observer/index.js#L201
function set (target: Array<any> | Object, key: any, val: any): any {
// ....
if (!ob) {
target[key] = val
return val
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}

数组变异

由于 JavaScript 的限制,Vue 不能检测以下数组的变动: 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue

先来看一段代码

var vm = new Vue({
data: {
items: ['1', '2', '3']
}
})
vm.items[1] = '4' // 视图并未更新

文档已经做出了解释,但并不是defineProperty的锅,而是尤大在设计上对性能的权衡,下面这段代码可以验证

function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function defineGet() {
console.log(`get key: ${key} val: ${val}`);
return val;
},
set: function defineSet(newVal) {
console.log(`set key: ${key} val: ${newVal}`);
val = newVal;
}
})
}

function observe(data) {
Object.keys(data).forEach(function(key) {
defineReactive(data, key, data[key]);
})
}

let test = [1, 2, 3];

observe(test);

test[0] = 4 // output: set key: 0 val: 4

虽然说索引变更不是 defineProperty 的锅,但新增索引的确是 defineProperty 做不到的,所以就有了数组的变异方法

能看到这里,大概也能猜到内部实现了,还是跟$set一样,手动 observer,下面我们验证一下

const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]

methodsToPatch.forEach(function (method) {
// 缓存原生数组
const original = arrayProto[method]
// def使用Object.defineProperty重新定义属性
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args) // 调用原生数组的方法

const ob = this.__ob__ // ob就是observe实例observe才能响应式
let inserted
switch (method) {
// push和unshift方法会增加数组的索引,但是新增的索引位需要手动observe的
case 'push':
case 'unshift':
inserted = args
break
// 同理,splice的第三个参数,为新增的值,也需要手动observe
case 'splice':
inserted = args.slice(2)
break
}
// 其余的方法都是在原有的索引上更新,初始化的时候已经observe过了
if (inserted) ob.observeArray(inserted)
// dep通知所有的订阅者触发回调
ob.dep.notify()
return result
})
})

对比

一个优秀的开源框架本身就是一个不断打碎重朔的过程,上面做了些许铺垫,现在我们简要总结一下

  • Proxy 作为新标准将受到浏览器厂商重点持续的性能优化
  • Proxy 能观察的类型比 defineProperty 更丰富
  • Proxy 不兼容IE,也没有 polyfill, defineProperty 能支持到IE9
  • Object.definedProperty 是劫持对象的属性,新增元素需要再次 definedProperty。而 Proxy 劫持的是整个对象,不需要做特殊处理
  • 使用 defineProperty 时,我们修改原来的 obj 对象就可以触发拦截,而使用 proxy,就必须修改代理对象,即 Proxy 的实例才可以触发拦截