前言

这是关于设计模式的系列文章,在每篇文章中将对常见设计模式进行讲解,因为针对前端方向,而且前端常用语言 JavaScript 本身是弱类型,面向对象(模拟面向对象)编程的实现相较于其他强类型语言实现更为繁琐,所以代码主要以 JavaScript 表现。

系列文章链接:

代理模式概念

由于某些情况下一个对象不能直接引用另一个对象,所以需要代理对象在这两个对象之间起到中介作用或者实现控制,这样的模式叫 “代理模式”。


代理模式 UML 图
代理模式 UML 图


基本实现

// 假设无法客户端无法直接使用这个类
class Google {
  get(url) {
    return url + ' is google';
  }
}

// 只能通过代理操作 Google 类
class Proxy {
  constructor() {
    this.google = new Google();
  }
  get(url) {
    return this.google.get(url);
  }
}

const proxy = new Proxy();
const result = proxy.get('http://www.google.com');
console.log(result); // http://www.google.com is google

假设 Google 类我们无法直接使用,只有 Proxy 可以使用 Google,我们可以通过 Proxy 类去操作使用 Google 类,此时 Proxy 类就是一个代理。

ES6 的 Proxy

ES6 标准以后,JavaScript 提供了原生的代理模式 Proxy 类,可以代理其他对象,并在对象属性的获取和赋值时增加拦截。

/* ES6 Proxy 的使用 */
const lucy = {
  name: 'lucy',
  age: 20,
  height: 165
};

const lucyMother = new Proxy(lucy, {
  get(target, key) {
    if (key === 'age') {
      return target.age - 2;
    } else if (key === 'height') {
      return target.height + 5;
    } else {
      return target[key];
    }
  },
  set(target, key, val) {
    if (key === 'boyfriend') {
      if (val.age > 40) {
        console.log('太老了');
      } else if (val.salary < 20000) {
        console.log('太穷了');
      } else {
        target[key] = val;
      }
    }
  }
});

console.log(lucyMother.name); // lucy
console.log(lucyMother.age); // 18
console.log(lucyMother.height); // 170

lucyMother.boyfriend = {
  age: 42,
  salary: 25000
}
// 太老了

lucyMother.boyfriend = {
  age: 36,
  salary: 18000
}
// 太穷了

上面是一个接地气的案例,创建一个对象存储 lucy 的基本信息,使用代理创建 lucyMotherlucy 找男朋友,通过代理对象获取 lucy 的基本信息时会虚报年龄和身高,而在设置男朋友对象时会检查是否符合要求。

代理模式、适配器模式和装饰器模式

从代码实现来看,代理模式、适配器模式、装饰器模式非常的相似,非常容易混淆,但其实是有本质区别的。

  • 代理模式和适配器模式:代理模式不会改变原有的接口,代理类和被代理的类属性方法使用方式完全一致,而适配器模式是因为旧的接口无法使用,通过适配器创建新的接口去兼容旧的接口;
  • 代理模式和装饰器模式:装饰器功能会保证被装饰类功能正常使用的情况下新增功能,而代理模式保证原有接口,但会改变原来接口的功能;
  • 适配器模式和装饰器模式:装饰器是对一个类的包装,而适配器更多是去建立提供接口的类与无法适配的类之间的联系。

代理模式的应用

事件委托

事件委托是浏览器事件注册的一种优化手段,如果同类型的元素非常多,且都有相同的事件,如列表,则不必给每一个元素注册这个事件,而是将事件注册给父元素,即将事件委托给父元素,避免了相同事件的重复注册,这种优化利用了 “代理模式”,又称事件代理。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>事件委托</title>
</head>
<body>
  <ul id="list">
    <li>1</li>
    <li>2</li>
    <li>3</li>
  </ul>
  <script>
    const ulList = document.getElementById('list');

    ulList.addEventListener('click', function (event) {
      console.log(event.target.innerHTML);
    });
  </script>
</body>
</html>

在浏览器中,委托给父元素的事件触发后,可以通过事件对象的属性 target 获取到具体触发事件的子元素。

图片加载

图片加载是一个提高用户体验的功能,也是非常常见的,原因是浏览器向服务器请求资源图片是需要等待的,由于网络等因素的影响会导致等待的时间更长,此时我们需要一个 loading 图片来过渡,这就是图片加载的基本需求。

/* node 服务器 */
const express = require('express');
const path = require('path');

const app = express();

app.get('/loading.gif', function (req, res) {
  res.sendFile(path.resolve('img', 'loading.gif'));
});

app.get('/img/:name', function (req, res) {
  setTimeout(function () {
    res.sendFile(path.join(__dirname, req.path));
  }, 3000);
});

app.use(express.static(__dirname));

app.listen(3000, function () {
  console.log('server start 3000');
});

上面服务器模拟了加载图片响应慢的场景,loading 图片立即响应,其他图片则延迟 3s 响应。

<!-- Dom 结构 -->
<ul id="menu">
  <li data-src="/img/bg1.jpg">图片1</li>
  <li data-src="/img/bg2.jpg">图片2</li>
</ul>
<div id="bgimg"></div>
/* 没有实现 loading */
const menu = document.getElementById('menu');
const bgimg = document.getElementById('bgimg');

const background = (function () {
  const img = new Image();
  bgimg.appendChild(img)
  return {
    setSrc(src) {
      img.src = src;
    }
  }
})();

menu.addEventListener('click', function (event) {
  const src = event.target.dataset.src;
  background.setSrc(src);
});

上面的代码是没有实现懒加载的,当点击按钮向服务器请求图片时,并没有加入 loading 图片过渡,之所以说图片加载应用了 “代理模式” 并不是指加载功能本身,而是我们的实现方式,编写的代码质量要高至少要遵循单一职责原则和开放封闭原则,就是说最好不要直接在事件监听的函数中增加 loading 过渡的逻辑,而是把这个过渡功能交给代理对象去处理。

/* 使用代理对象实现 loading 过渡 */
const menu = document.getElementById('menu');
const bgimg = document.getElementById('bgimg');

// 请求图片的对象
const background = (function () {
  const img = new Image();
  bgimg.appendChild(img)
  return {
    setSrc(src) {
      img.src = src;
    }
  }
})();

// 增加 loading 过度的代理对象
const proxyBackground = (function () {
  const img = new Image();
  img.onload = function () {
    background.setSrc(this.src);
  }
  return {
    setSrc(src) {
      background.setSrc('./img/loading.gif');
      img.src = src;
    }
  }
})();

// 监听获取图片的事件中使用的是代理对象 proxyBackground
menu.addEventListener('click', function (event) {
  const src = event.target.dataset.src;

  // 防止缓存
  proxyBackground.setSrc(src + '?time=' + Date.now());
});

上面的实现方式就符合 “代理模式”,background 对象是提供基本功能,而proxyBackground(代理对象)增强了基本功能,却并没有改变接口的使用方式,依然通过 setSrc 方法去请求图片。

防抖代理

防抖的作用是在做一个操作时不需要很频繁,如搜索查询,在连续输入时如果每次触发输入事件都向后端发送请求,性能是极差的,我们希望的是连续输入只在最后一次统一发送请求,这种处理叫做防抖处理,是前端优化的手段。

<!-- 未使用防抖代理处理 -->
<input type="text" id="ipt">
<script>
  const ipt = document.getElementById('ipt');

  function post() {
    console.log('发送请求了');
  }

  ipt.addEventListener('input', post);
</script>

上面代码未使用防抖代理,每次输入都会打印 “发送请求了”。

<!-- 使用防抖代理优化 -->
<input type="text" id="ipt">
<script>
  const ipt = document.getElementById('ipt');

  function post() {
    console.log('发送请求了');
  }

  // 代理函数去执行 post
  const debouncePost = (function () {
    let timer = null;
    return function () {
      clearInterval(timer);
      timer = setTimeout(function () {
        post();
      }, 500);
    }
  })();

  ipt.addEventListener('input', debouncePost);
</script>

使用防抖代理函数优化后,保留了原有功能的基础上进行了增强,实现了连续输入停止 500ms 后统一发送一次请求,防抖的实现方式有很多种,包括并不限于函数式编程等,而上面代码使用了 “代理模式” 实现 。

总结

使用 “代理模式” 的场景在后端会更多,比如代理跨域,Nginx 代理等等,还有一点需要注意的是,“代理模式” 并非单一的,对于同一个对象,可以有多个代理对象去增强不同的功能,最后附上 案例地址