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

766 阅读15分钟

代理模式

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

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 倍(非常夸张)。

总结

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