代理模式
是指在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。代理模式在生活中的场景也非常多。比如足球运动员都有经纪人作为代理,俱乐部想要签约球员都得联系经纪人,经纪人把转会费和薪酬谈好了才会给球员签合同。其实代理模式就是生活中的中介。
1、生活中的代理模式
大家平时在工作生活中,如果想要上谷歌查东西或者去YouTube看视频,是不是都会绞尽脑汁,想尽办法去“科学上网”呢?而我们”科学上网“所使用的VPN的背后,其实就是代理模式在起作用。
我们通过VPN上网时,比起常规的访问过程,多出了一个第三方的代理服务器。这个第三方的IP地址,是不在被禁用的名单之中的,因此我们可以访问得到。而代理服务器是可以访问到外网的,它在请求成功之后,把响应体发给我们,让我们间接地可以访问到外网数据。像这种第三方代替我们访问目标对象的模式,就是代理模式。
2、ES6中的代理器
在ES6中,提供了一个专门的代理器———Proxy,用于创建一个对象的代理,从而实现基本操作的拦截和自定义,那什么是代理呢,可以理解为在目标对象之前设置一个“拦截”,当该对象被访问的时候,都必须经过这层拦截。因此提供了一种机制,可以对外界的访问进行过滤和改写。意味着你可以在这层拦截中进行各种操作。比如你可以在这层拦截中对原对象进行处理,返回你想返回的数据结构。ES6 原生提供 Proxy 构造函数,MDN上的解释为:Proxy 对象用于定义基本操作的自定义行为(如属性查找,赋值,枚举,函数调用等)。详细内容可查看Mdn上对 proxy 的介绍
参数
-
target: 要使用
Proxy
包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。 -
handler: 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理proxy的行为,也就是要拦截的行为。
一个容纳一批特定属性的占位符对象。它包含有
Proxy
的各个捕获器(trap)。所有的捕捉器是可选的。如果没有定义某个捕捉器,那么就会保留源对象的默认行为。
值得一提的是,Proxy代替了Object.defineProperty成为了Vue3.0内部响应式的实现方式,这个最后会给大家介绍
3、代理模式实践
代理模式在js的实践主要有事件代理、缓存代理、虚拟代理和保护代理,接下来会一一介绍
3.1、事件代理
场景: 父元素下有多个子元素
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>事件代理</title>
</head>
<body>
<div id="father">
<a href="#">链接1号</a>
<a href="#">链接2号</a>
<a href="#">链接3号</a>
<a href="#">链接4号</a>
<a href="#">链接5号</a>
<a href="#">链接6号</a>
</div>
</body>
</html>
需求:希望鼠标点击每个 a 标签,都可以弹出“我是xxx”这样的提示。比如点击第一个 a 标签,弹出“我是链接1号”这样的提示。
不使用代理: 这意味着我们至少要安装6个监听函数给6个不同的元素一般我们会用循环,代码如下所示),如果我们的a标签进一步增多,那么性能的开销会更大
// 假如不用代理模式,我们将循环安装监听函数
const aNodes = document.getElementById('father').getElementsByTagName('a')
const aLength = aNodes.length
for(let i=0;i < aLength; i++) {
aNodes[i].addEventListener('click', function(e) {
e.preventDefault()
alert(`我是${aNodes[i].innerText}`)
})
}
代理模式: 考虑到事件本身具有“冒泡”的特性,当我们点击 a 元素时,点击事件会“冒泡”到父元素 div 上,从而被监听到。如此一来,点击事件的监听函数只需要在 div 元素上被绑定一次即可,而不需要在子元素上被绑定 N 次——这种做法就是事件代理,它可以很大程度上提高我们代码的性能。
// 获取父元素
const father = document.getElementById('father')
// 给父元素安装一次监听函数
father.addEventListener('click', function(e) {
// 识别是否是目标子元素
if(e.target.tagName === 'A') {
// 以下是监听函数的函数体
e.preventDefault()
alert(`我是${e.target.innerText}`)
}
} )
3.2、虚拟代理
虚拟代理是把一些开销很大的对象,延迟到真正需要他的时候去创建,在我理解,就是用户认为已经执行了某个功能,事实上却是使用代理对象进行占位,待触发的时机到来,才会真正的执行本体对象的操作。简单的讲个应用示例
图片预加载:预加载主要是为了避免网络不好、或者图片太大时,页面长时间给用户留白的尴尬。常见的操作是先让这个 img 标签展示一个占位图,然后创建一个 Image 实例,让这个 Image 实例的 src 指向真实的目标图片地址、观察该 Image 实例的加载情况 —— 当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将 DOM 上的 img 元素的 src 指向真实的目标图片地址。此时我们直接去取了目标图片的缓存,所以展示速度会非常快,从占位图到目标图片的时间差会非常小、小到用户注意不到,这样体验就会非常好了。节流的处理方式也是虚拟代理思想的体现
class LoadImage {
constructor(imgNode) {
// 获取真实的DOM节点
this.imgNode = imgNode
}
// 操作img节点的src属性
setSrc(imgUrl) {
this.imgNode.src = imgUrl
}
}
class ProxyImage {
// 占位图的url地址
static LOADING_URL = 'xxxxxx'
constructor(targetImage) {
// 目标Image,即LoadImage实例
this.targetImage = targetImage
}
// 该方法主要操作虚拟Image,完成加载
setSrc(targetUrl) {
// 真实img节点初始化时展示的是一个占位图
this.targetImage.setSrc(ProxyImage.LOADING_URL)
// 创建一个帮我们加载图片的虚拟Image实例
const virtualImage = new Image()
// 监听目标图片加载的情况,完成时再将DOM上的真实img节点的src属性设置为目标图片的url
virtualImage.onload = () => {
this.targetImage.setSrc(targetUrl)
}
// 设置src属性,虚拟Image实例开始加载图片
virtualImage.src = targetUrl
}
}
ProxyImage 帮我们调度了预加载相关的工作,我们可以通过 ProxyImage 这个代理,实现对真实 img 节点的间接访问,并得到我们想要的效果。它并没有改变原有的LoadImage,属于为系统添加了新的行为,并且都对外提供了setSrc的方法,对使用来说保证了代理和本体使用上的一致性,好处是:
- 用户可以放心请求代理,他只关心能否得到想要的结果
- 在任何使用本体的地方都可以替换成使用代理
在这个实例中,virtualImage 这个对象是一个“幕后英雄”,它始终存在于 JavaScript 世界中、代替真实 DOM 发起了图片加载请求、完成了图片加载工作,却从未在渲染层面抛头露面。因此这种模式被称为“虚拟代理”模式。
3.3、缓存代理
缓存代理可以理解为一些开销大的运算结果提供暂时的缓存,在下次运算时,如果传递的参数与之前一样,则直接返回之前存储的运算结果, 并将该对象保存在闭包中,这样可以一次创建多次使用。 实例:求和函数。
const addAll = function() {
var arg = [].slice.call(arguments);
return arg.reduce(function(a, b) {
return a + b;
});
}
// 为求和方法创建代理
const proxyAddAll = (function(){
// 求和结果的缓存池
const resultCache = {}
return function() {
// 将入参转化为一个唯一的入参字符串
const args = [].slice.call(arguments).join(',');
// 检查本次入参是否有对应的计算结果
if(args in resultCache) {
// 如果有,则返回缓存池里现成的结果
console.log('使用缓存结果')
return resultCache[args]
} else {
return resultCache[args] = addAll(...arguments)
}
}
})()
在处理大量ajax请求时,可以采取缓存代理的方式,当已经拉取到的数据在缓存中时,直接获取缓存的数据,减少请求的调用。
3.4、保护代理
所谓**"保护代理"**,就是在访问层做文章,保护代理的重点在于,代理对象保护外界对于本体对象的可访问和可操作性,也就是说在保护代理中,代理对象是用于禁止外界对本体对象的操作,防止本体对象的属性被外界进行操作。
Proxy
基础示例
当我定义一个拦截器,拦截器的作用是当对象属性名不存在时,默认返回某个特定的字符串
const origin = {}
const obj = new Proxy(origin, {
get: function (target, propKey, receiver) {
return '10'
}
});
obj.a // 10
obj.b // 10
origin.a // undefined
origin.b // undefined
上方代码我们给一个空对象的get架设了一层代理,所有get操作都会直接返回我 们定制的数字 10,需要注意的是,代理只会对proxy对象生效,如上方的origin就没有任何效果
常用方法
方法 | 描述 |
---|---|
handler.has() | in 操作符的捕捉器。 |
handler.get() | 属性读取操作的捕捉器。 |
handler.set() | 属性设置操作的捕捉器。 |
handler.deleteProperty() | delete 操作符的捕捉器 |
handler.ownKeys() | Object.getOwnPropertyNames 方法和 Object.getOwnPropertySymbols 方法的捕捉器。 |
handler.apply() | 函数调用操作的捕捉器。 |
handler.construct() | new 操作符的捕捉器 |
get用于代理目标对象的属性读取操作,接受三个参数 get(target, propKey, ?receiver)
- target 目标对象
- propKey 属性名
- Receiver Proxy 实例本身
const person = {
like: "vuejs"
}
const obj = new Proxy(person, {
get: function(target, propKey) {
if (propKey in target) {
return target[propKey];
} else {
throw new ReferenceError("Prop name \"" + propKey + "\" does not exist.");
}
}
})
obj.like // vuejs
obj.test // Uncaught ReferenceError: Prop name "test" does not exist.
上面的代码表示在读取代理目标的值时,如果有值则直接返回,没有值就抛出一个自定义的错误
注意: 如果要访问的目标属性是不可写以及不可配置的,则返回的值必须与改目标属性的值相同,举个🌰
const obj = {};
Object.defineProperty(obj, "a", {
configurable: false,
enumerable: false,
value: 10,
writable: false
})
const p = new Proxy(obj, {
get: function(target, prop) {
return 20;
}
})
p.a // Uncaught TypeError: 'get' on proxy: property 'a' is a read-only and non-configurable..
可撤销的Proxy
proxy有一个唯一的静态方法,Proxy.revocable(target, handler)
Proxy.revocable()方法可以用来创建一个可撤销的代理对象
该方法的返回值是一个对象,其结构为: {"proxy": proxy, "revoke": revoke}
- proxy 表示新生成的代理对象本身,和用一般方式 new Proxy(target, handler) 创建的代理对象没什么不同,只是它可以被撤销掉。
- revoke 撤销方法,调用的时候不需要加任何参数,就可以撤销掉和它一起生成的那个代理对象。 该方法常用于完全封闭对目标对象的访问, 如下示例
const target = { name: 'vuejs'}
const {proxy, revoke} = Proxy.revocable(target, handler)
proxy.name // 正常取值输出 vuejs
revoke() // 取值完成对proxy进行封闭,撤消代理
proxy.name // TypeError: Revoked
应用
下面我们使用Proxy实现一个逻辑分离的数据格式验证器
const target = {
_id: '1024',
name: 'vuejs'
}
const validators = {
name(val) {
return typeof val === 'string';
},
_id(val) {
return typeof val === 'number' && val > 1024;
}
}
const createValidator = (target, validator) => {
return new Proxy(target, {
_validator: validator,
set(target, propkey, value, proxy){
let validator = this._validator[propkey](value)
if(validator){
return Reflect.set(target, propkey, value, proxy)
}else {
throw Error(`Cannot set ${propkey} to ${value}. Invalid type.`)
}
}
})
}
const proxy = createValidator(target, validators)
proxy.name = 'vue-js.com' // vue-js.com
proxy.name = 10086 // Uncaught Error: Cannot set name to 10086. Invalid type.
proxy._id = 1025 // 1025
proxy._id = 22 // Uncaught Error: Cannot set _id to 22. Invalid type
vue3为什么选择proxy,解决了什么问题
首先我们可以回顾一下vue2.x数据响应存在的问题:
- 检测不到对象属性的添加和删除:当你在对象上新加了一个新属性
newProperty
,当前新加的这个属性并没有加入vue检测数据更新的机制(因为是在初始化之后添加的)。vue.$set
是能让vue知道你添加了属性, 它会给你做处理,$set
内部也是通过调用Object.defineProperty()
去处理的, 向响应式对象中添加一个 property,并确保这个新 property 同样是响应式的,且触发视图更新。它必须用于向响应式对象上添加新 property,因为 Vue 无法探测普通的新增 property。 - 无法监控到数组下标的变化,导致直接通过数组的下标给数组设置值,不能实时响应。
- 当对象层级嵌套层数很深的时候,递归遍历带来的性能开销就会比较大,因为要遍历data中所有的数据并给其设置成响应式的。
vue2.x使用**Object.defineProperty()**实现:Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象
Object.defineProperty(obj, prop, descriptor)
- obj 要定义属性的对象
- prop 要定义或修改的属性的名称或 Symbol
- descriptor 要定义或修改的属性描述符
vue3之前的双向绑定都是通过defineProperty的getter、setter来实现的,看下部分源码
// ...
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
// ...
if (Dep.target) {
// 收集依赖
dep.depend()
}
return value
},
set: function reactiveSetter (newVal) {
// ...
// 通知视图更新
dep.notify()
}
})
// 用过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的源码,就不难看出
/**
* Set a property on an object. Adds the new property and
* triggers change notification if the property doesn't
* already exist.
*/
export function set (target: Array | Object, key: any, val: any): any {
// 如果target是数组,且key是有效的数组索引,会调用数组的splice方法,
// 数组的splice方法会被重写,重写的方法中会手动Observe
// 所以vue的set方法,对于数组,就是直接调用重写splice方法
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
// 对于对象,如果key本来就是对象中的属性,直接修改值就可以触发更新
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
// vue的响应式对象中都会添加了__ob__属性,所以可以根据是否有__ob__属性判断是否为响应式对象
const ob = (target: any).__ob__
// 如果不是响应式对象,直接赋值
if (!ob) {
target[key] = val
return val
}
// 调用defineReactive给数据添加了 getter 和 setter,
// 所以vue的set方法,对于响应式的对象,就会调用defineReactive重新定义响应式对象,defineReactive 函数
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}
-
Proxy的代理针对的是整个对象,而不是像Object.defineProperty针对某个属性。只需做一层代理就可以监听同级结构下的所有属性变化,包括新增属性和删除属性。
const obj = { name: 'app', age: '18', a: { b: 1, c: 2, }, } const p = new Proxy(obj, { get(target, propKey, receiver) { console.log('你访问了' + propKey); return Reflect.get(target, propKey, receiver); }, set(target, propKey, value, receiver) { console.log('你设置了' + propKey); console.log('新的' + propKey + '=' + value); Reflect.set(target, propKey, value, receiver); } }); p.age = '20'; console.log(p.age); p.newPropKey = '新属性'; console.log(p.newPropKey);
可以看到,从新增的属性,并不需要添加响应式处理,因为 Proxy 是对对象的操作,只要你访问对象,就会走到 Proxy的逻辑中。被 Proxy 拦截、过滤了一些默认行为之后,可以使用 Reflect 恢复未被拦截的默认行为。通常它们两个会结合在一起使用。
Reflect(ES6引入) 是一个内置的对象,它提供拦截 JavaScript 操作的方法。将Object对象一些明显属于语言内部方法(比如
Object.defineProperty()
)放到Reflect
对象上。修改某些Object方法的返回结果,让其变得更合理。让Object操作都变成函数行为。具体内容查看MDN
深入vue3.0源码
在 Vue 3 中,将 Vue 的核心功能(例如创建和观察响应状态)公开为独立功能,例如使用 reactive() 创建一个响应状态:
import { reactive } from 'vue'
// reactive state
const state = reactive({
name: "vue 3.0",
count: ref(42)
})
我们向 reactive() 函数传入了一个 {name: "Vue 3.x", count: {…}},对象,reactive() 函数会将传入的对象进行 Proxy 封装,将其转换为"可观测"的对象。
//reactive f => createReactiveObject()
function createReactiveObject(target, toProxy, toRaw, baseHandlers, collectionHandlers) {
...
// 设置拦截器
const handlers = collectionTypes.has(target.constructor)
? collectionHandlers
: baseHandlers;
observed = new Proxy(target, handlers);
...
return observed;
}
传入的目标对象target最终会变成下图这样
从打印的结果我们可以得知,被代理的目标对象 target 设置了 get()、set()、deleteProperty()、has()、ownKeys(),这几个陷阱函数,结合我们上文介绍的内容,一起来看下它们都做了什么。
get() 读取属性
get() 会自动读取使用 ref 对象创建的响应数据,并进行 track 调用。
// get() => createGetter(false)
function createGetter(isReadonly: boolean, unwrap: boolean = true) {
return function get(target: object, key: string | symbol, receiver: object) {
// 恢复默认行为
let res = Reflect.get(target, key, receiver)
// 根据目标对象 key 类型进行的一些处理
if (isSymbol(key) && builtInSymbols.has(key)) {
return res
}
// 如果目标对象存在使用 ref 创建的数据,直接获取内部值
if (unwrap && isRef(res)) {
res = res.value // 案例中 这里是 42
} else {
// 调用 track() 方法
track(target, OperationTypes.GET, key)
}
return isObject(res)
? isReadonly
? readonly(res)
: reactive(res)
: res
}
}
set() - 设置属性
set() 陷阱函数,对目标对象上不存在的属性设置值时,进行 “添加” 操作,并且会触发 trigger() 来通知响应系统的更新。解决了 Vue 2.x 中无法检测到对象属性的添加的问题。
function set(target, key, value, receiver) {
value = toRaw(value);
// 获取修改之前的值,进行一些处理
const oldValue = target[key];
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value;
return true;
}
const hadKey = hasOwn(target, key);
// 恢复默认行为
const result = Reflect.set(target, key, value, receiver);
// //如果目标对象在原型链上,不要 trigger
if (target === toRaw(receiver)) {
/* istanbul ignore else */
{
const extraInfo = {
oldValue,
newValue: value
};
// 如果设置的属性不在目标对象上 就进行 Add
// 这就解决了 Vue 2.x 中无法检测到对象属性的添加或删除的问题
if (!hadKey) {
trigger(target, "add" /* ADD */ , key, extraInfo);
} else if (hasChanged(value, oldValue)) {
// trigger 方法进行一系列的调度工作,贯穿着整个响应系统,是变更检测的“通讯员”
trigger(target, "set" /* SET */ , key, extraInfo);
}
}
}
return result;
}
Vue 3 进行了全新改进,使用 Proxy 代理的作为全新的变更检测,不再使用 Object.defineProperty,使用代理的好处是,对目标对象 target 架设了一层拦截,可以对外界的访问进行过滤和改写,不用再递归遍历对象的所有属性并进行 getter/setter 转换操作,这使得组件更快的初始化,运行时的性能上将得到极大的改进,据测试新版本的 Vue 比之前 速度快了 2 倍(非常夸张)。
总结
四种类型虽然均为代理模式,但是各自的目的并不相同,保护代理是为了阻止外部对内部对象的访问或者是操作等;虚拟代理是为了提升性能,延迟本体执行,在合适的时机进行触发,目的是减少本体的执行次数;缓存代理同样是为了提升性能,但是为了减缓内存的压力,同样的属性,在内存中只保留一份;事件代理则是为了提高性能。