前端JS高频面试题—3.代理模式

吐槽君 分类:javascript

一、概念

代理模式,式如其名——在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。

举个例子

相信众多小伙伴都使用过Google搜索,也有很多人使用过VPN(虚拟专用网络),当你正常情况下(不使用VPN)访问Google.com,Chrome都会给你这样一个提示:

image.png

这是为啥呢?这就要从网络请求的整个流程说起了。一般情况下,当我们访问一个 url 的时候,会发生下图的过程:

image.png

为了屏蔽某些网站,一股神秘的东方力量会作用于你的 DNS 解析过程,告诉它:“你不能解析出xxx.xxx.xxx.xxx(某个特殊ip)的地址”。而我们的 Google.com,不幸地出现在了这串被诅咒的 ip 地址里,于是你的 DNS 会告诉你:“对不起,我查不到”。

但有时候,一部分人为了搞学习,通过访问VPN,是可以间接访问到 Google.com 的。这背后,就是代理模式在给力。在使用VPN时,我们的访问过程是这样的:

image.png

没错,比起常规的访问过程,多出了一个第三方 —— 代理服务器。这个第三方的 ip 地址,不在被禁用的那批 ip 地址之列,我们可以顺利访问到这台服务器。而这台服务器的 DNS 解析过程,没有被施加咒语,所以它是可以顺利访问 Google.com 的。代理服务器在请求到 Google.com 后,将响应体转发给你,使你得以间接地访问到目标网址 —— 像这种第三方代替我们访问目标对象的模式,就是代理模式

二、上代码

在这里我们选取业务开发中最常见的四种代理类型:事件代理、保护代理、缓存代理和虚拟代理来进行学习。

1.事件代理

事件代理,可能是代理模式最常见的一种应用方式,也是一道实打实的高频面试题。它的场景是一个父元素下有多个子元素,像这样:

<!DOCTYPE html>
<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}`)
    }
} )
 

在这种做法下,我们的点击操作并不会直接触及目标子元素,而是由父元素对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。

2.保护代理

这里举一个小明为了追求女神送鲜花的例子:

// 定义一个鲜花累
var Flower = function(){}; 
// 定义对象小明
var xiaoming = { 
 sendFlower: function( target ){ 
     var flower = new Flower(); 
     target.receiveFlower( flower ); 
 } 
}; 
// 定义一个女神A
var A = { 
 receiveFlower: function( flower ){ 
     console.log( '收到花 ' + flower ); 
 } 
}; 
// 小明送鲜花给A
xiaoming.sendFlower( A );
 

但是小明这个人胆子有点小,抹不开面子,不敢自己送这多鲜花,然后他发现自己和女神有个共同的好朋友B,所以他请求B帮他把这朵鲜花送给A:

var Flower = function(){}; 
var xiaoming = { 
 sendFlower: function( target){ 
     var flower = new Flower(); 
     target.receiveFlower( flower ); 
 } 
}; 
var B = { 
 receiveFlower: function( flower ){ 
     A.receiveFlower( flower ); 
 } 
}; 
var A = { 
 receiveFlower: function( flower ){ 
     console.log( '收到花 ' + flower ); 
 } 
}; 
xiaoming.sendFlower( B );
 

小明正在这期待着结果呢,B告诉小明说:“我很了解A,她喜欢年龄小的,你年龄太大了,这花我还是不送了”。小明听了很是难过。

var Flower = function(){}; 
var xiaoming = { 
 age: 30,
 sendFlower: function( target){ 
     var flower = new Flower(); 
     target.receiveFlower( flower, this.age ); 
 } 
}; 
var B = { 
 receiveFlower: function( flower, age ){
     if(age> 20){
        console.log('你年龄太大了,她接受不了')
        return;
     }
     A.receiveFlower( flower ); 
 } 
}; 
var A = { 
 receiveFlower: function( flower ){ 
     console.log( '收到花 ' + flower ); 
 } 
}; 
xiaoming.sendFlower( B );
 

虽然这只是个虚拟的例子,但我们可以从中找到保护代理模式的身影。代理 B 可以帮助 A过滤掉一些请求,比如送花的人中年龄太大的或者没有宝马的,这种请求就可以直接在代理 B处被拒绝掉。这种代理叫作保护代理。A 和 B 一个充当白脸,一个充当黑脸。白脸 A 继续保持良好的女神形象,不希望直接拒绝任何人,于是找了黑脸 B 来控制对 A 的访问。这就是保护代理

3.缓存代理

缓存代理比较好理解,它应用于一些计算量较大的场景里。在这种场景下,我们需要“用空间换时间”——当我们需要用到某个已经计算过的值的时候,不想再耗时进行二次计算,而是希望能从内存里去取出现成的计算结果。这种场景下,就需要一个代理来帮我们在进行计算的同时,进行计算结果的缓存了。

一个比较典型的例子,是对传入的参数进行求和:

// addAll方法会对你传入的所有参数做求和操作
const addAll = function() {
    console.log('进行了一次新计算')
    let result = 0
    const len = arguments.length
    for(let i = 0; i < len; i++) {
        result += arguments[i]
    }
    return result
}

// 为求和方法创建代理
const proxyAddAll = (function(){
    // 求和结果的缓存池
    const resultCache = {}
    return function() {
        // 将入参转化为一个唯一的入参字符串
        const args = Array.prototype.join.call(arguments, ',')

        // 检查本次入参是否有对应的计算结果
        if(args in resultCache) {
            // 如果有,则返回缓存池里现成的结果
            return resultCache[args]
        }
        return resultCache[args] = addAll(...arguments)
    }
})()
 

我们把这个方法丢进控制台,尝试同一套入参两次,结果喜人:

image.png

我们发现 proxyAddAll 针对重复的入参只会计算一次,这将大大节省计算过程中的时间开销。现在我们有 6 个入参,可能还看不出来,当我们针对大量入参、做反复计算时,缓存代理的优势将得到更充分的凸显。

4.虚拟代理

在 Web 开发中,图片预加载是一种常用的技术,如果直接给某个 img 标签节点设置 src 属性,
由于图片过大或者网络不佳,图片的位置往往有段时间会是一片空白。常见的做法是先用一张
loading 图片占位,然后用异步的方式加载图片,等图片加载好了再把它填充到 img 节点里,这种
场景就很适合使用虚拟代理

下面我们来实现这个虚拟代理,首先创建一个普通的本体对象,这个对象负责接收一个img的节点,并且提供一个对外的 setSrc 接口,外界调用这个接口,便可以给该 img 标签设置src 属性:

class PreLoadImage {
    constructor(imgNode) {
        // 获取该实例对应的DOM节点
        this.imgNode = imgNode
    }

    // 该方法用于设置真实的图片地址
    setSrc(targetUrl) {
        this.imgNode.src = targetUrl
    }
}
 

我们把网速调至 5KB/s,然后通过 new PreLoadImage(node).setSrc 给该 img 节点设置 src,可以看到,在图片
被加载好之前,页面中有一段长长的空白时间。

接着我们创建一个 Image 实例,让这个 Image 实例的 src 指向真实的目标图片地址、观察该 Image 实例的加载情况 —— 当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将 DOM 上的 img 元素的 src 指向真实的目标图片地址。此时我们直接去取了目标图片的缓存,所以展示速度会非常快,从占位图到目标图片的时间差会非常小、小到用户注意不到,这样体验就会非常好了。

class PreLoadImage {
    // 占位图的url地址
    static LOADING_URL = 'xxxxxx'

    constructor(imgNode) {
        // 获取该实例对应的DOM节点
        this.imgNode = imgNode
    }

    // 该方法用于设置真实的图片地址
    setSrc(targetUrl) {
        // img节点初始化时展示的是一个占位图
        this.imgNode.src = PreLoadImage.LOADING_URL
        // 创建一个帮我们加载图片的Image实例
        const image = new Image()
        // 监听目标图片加载的情况,完成时再将DOM上的img节点的src属性设置为目标图片的url
        image.onload = () => {
            this.imgNode.src = targetUrl
        }
        // 设置src属性,Image实例开始加载图片
        image.src = targetUrl
    }
}
 

这个 PreLoadImage 乍一看没问题,但其实违反了我们设计原则中的单一职责原则。PreLoadImage 不仅要负责图片的加载,还要负责 DOM 层面的操作(img 节点的初始化和后续的改变)。这样一来,就出现了两个可能导致这个类发生变化的原因。

好的做法是将两个逻辑分离,让 PreLoadImage 专心去做 DOM 层面的事情(真实 DOM 节点的获取、img 节点的链接设置),再找一个对象来专门来帮我们搞加载——这两个对象之间缺个媒婆,这媒婆非代理器不可:

class PreLoadImage {
    constructor(imgNode) {
        // 获取真实的DOM节点
        this.imgNode = imgNode
    }

    // 操作img节点的src属性
    setSrc(imgUrl) {
        this.imgNode.src = imgUrl
    }
}

class ProxyImage {
    // 占位图的url地址
    static LOADING_URL = 'xxxxxx'

    constructor(targetImage) {
        // 目标Image,即PreLoadImage实例
        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
    }
}
 
// 使用
new ProxyImage(new ProloadImage(imgNode)).setSrc('xxxxxxxxxx')
 

ProxyImage 帮我们调度了预加载相关的工作,我们可以通过 ProxyImage 这个代理,实现对真实 img 节点的间接访问,并得到我们想要的效果。

在这个实例中,virtualImage 这个对象是一个“幕后英雄”,它始终存在于 JavaScript 世界中、代替真实 DOM 发起了图片加载请求、完成了图片加载工作,却从未在渲染层面抛头露面。因此这种模式被称为“虚拟代理”模式。

三、其他代理

代理模式的变体种类非常多,限于篇幅及其在 JavaScript 中的适用性,本章只简约介绍一下这些代理,就不一一详细展开说明了。

  • 防火墙代理:控制网络资源的访问,保护主题不让“坏人”接近。
  • 远程代理:为一个对象在不同的地址空间提供局部代表,在 Java 中,远程代理可以是另一个虚拟机中的对象。
  • 智能引用代理:取代了简单的指针,它在访问对象时执行一些附加操作,比如计算一个对象被引用的次数。
  • 写时复制代理:通常用于复制一个庞大对象的情况。写时复制代理延迟了复制的过程,当对象被真正修改时,才对它进行复制操作。写时复制代理是虚拟代理的一种变体,DLL(操作系统中的动态链接库)是其典型运用场景。

四、扩展

相信很多小伙伴都使用过Vue进行项目的开发,也应该有很多小伙伴遇见过跨域的问题,这跨域问题上,代理(proxy)也是一种可行的解决方案。

直接上代码:
在vue工程中根目录增加一个配置文件:vue.config.js

module.exports = {
    // 修改的配置
    devServer: {
        host:"0.0.0.0",
        disableHostCheck: true,
        //  https: true,
        proxy: {
            "/api/": {
                "target": "http://localhost:8090/",
                changeOrigin: true,
                ws: true,
                pathRewrite: {
                    '^/api': '/'
                }
            },"/rest/": {
                "target": "http://localhost:8000/",
                changeOrigin: true,
                ws: true,
                pathRewrite: {
                    '^/rest': '/'
                }
            }
        }
    }
}
 

五、小结

代理模式行文至此,相信大家都已经做到了心中有数。在本节,我们看到代理模式的目的是十分多样化的,既可以是为了加强控制、拓展功能、提高性能,也可以仅仅是为了优化我们的代码结构、实现功能的解耦。无论是出于什么目的,这种模式的套路就只有一个—— A 不能直接访问 B,A 需要借助一个帮手来访问 B,这个帮手就是代理器。需要代理器出面解决的问题,就是代理模式发光发热的应用场景。

感谢

谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。

我是Nicnic,如果觉得写得可以的话,请点个赞吧❤。

写作不易,「点赞」+「在看」+「转发」 谢谢支持❤

往期好文

《前端JS高频面试题---1.发布-订阅模式》

《前端JS高频面试题---2.单例模式》

《前端CSS高频面试题---1.CSS选择器、优先级、以及继承属性》

《前端CSS高频面试题---2.em/px/rem/vh/vw的区别》

回复

我来回复
  • 暂无回复内容