认识JavaScript设计模式/代理模式和Proxy

代理模式

​ 是指在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。代理模式在生活中的场景也非常多。比如足球运动员都有经纪人作为代理,俱乐部想要签约球员都得联系经纪人,经纪人把转会费和薪酬谈好了才会给球员签合同。其实代理模式就是生活中的中介。

1、生活中的代理模式

​ 大家平时在工作生活中,如果想要上谷歌查东西或者去YouTube看视频,是不是都会绞尽脑汁,想尽办法去“科学上网”呢?而我们”科学上网“所使用的VPN的背后,其实就是代理模式在起作用。

0

​ 我们通过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本质也是给新增的属性手动observer,接下来结合set 本质也是给新增的属性手动 observer,接下来结合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
}
复制代码

img

  1. 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最终会变成下图这样

img

从打印的结果我们可以得知,被代理的目标对象 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;
}
复制代码

img

​ Vue 3 进行了全新改进,使用 Proxy 代理的作为全新的变更检测,不再使用 Object.defineProperty,使用代理的好处是,对目标对象 target 架设了一层拦截,可以对外界的访问进行过滤和改写,不用再递归遍历对象的所有属性并进行 getter/setter 转换操作,这使得组件更快的初始化,运行时的性能上将得到极大的改进,据测试新版本的 Vue 比之前 速度快了 2 倍(非常夸张)。

总结

四种类型虽然均为代理模式,但是各自的目的并不相同,保护代理是为了阻止外部对内部对象的访问或者是操作等;虚拟代理是为了提升性能,延迟本体执行,在合适的时机进行触发,目的是减少本体的执行次数;缓存代理同样是为了提升性能,但是为了减缓内存的压力,同样的属性,在内存中只保留一份;事件代理则是为了提高性能。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享