切图仔最后的倔强:包教不包会设计模式 - 结构型

7,762 阅读15分钟

1. 什么是结构型模式

结构型模式主要用于处理类和对象的组合,对应思维导图:

2. 外观模式: Facade Pattern

对接口二次封装隐藏其复杂性,并简化其使用。 外观模式包含如下角色:

  • Facade: 外观角色
  • SubSystem: 子系统角色

使用时机

当我们将系统分成多个子系统时,我们会降低代码复杂性。编程时的最佳实践是最小化子系统之间的通信和依赖关系。实现这一目标的一个好方法是引入一个facade对象,为子系统提供单一且统一的接口。

1. 跨浏览器监听事件

要保证处理事件的代码在大多数浏览器下一致运行,需要关注冒泡阶段。

在做跨浏览器网站时,你已经不经意间使用了外观模式

var addMyEvent = function( el,ev,fn ){
  if( el.addEventListener ){//存在DOM2级方法,则使用并传入事件类型、事件处理程序函数和第3个参数false(表示冒泡阶段)
        el.addEventListener( ev,fn, false );
  }else if(el.attachEvent){ // 为兼容IE8及更早浏览器,注意事件类型必须加上"on"前缀
        el.attachEvent( "on" + ev, fn );
  }else{
       el["on" + ev] = fn;//其他方法都无效,默认采用DOM0级方法,使用方括号语法将属性名指定为事件处理程序
    }
};

2. jQuery $(document).ready(..)

我们都熟悉$(document).ready(..)。在源码中,这实际上是一个被调用的方法提供的bindReady()

加载事件共用两种方法:window.onload()$(document).ready()

bindReady: function() {
    ...
    if ( document.addEventListener ) {
      // Use the handy event callback
      document.addEventListener( "DOMContentLoaded", DOMContentLoaded, false );

      // A fallback to window.onload, that will always work
      window.addEventListener( "load", jQuery.ready, false );

    // If IE event model is used
    } else if ( document.attachEvent ) {

      document.attachEvent( "onreadystatechange", DOMContentLoaded );

      // A fallback to window.onload, that will always work
      window.attachEvent( "onload", jQuery.ready );

Facade 外观模式大量应用于 jQuery库以让其更容易被使用。譬如我们使用 jQuery$(el).css()$(el).animate() 等方法 。

使我们不必手动在jQuery 内核中调用很多内部方法以便实现某些行为,也同时避免了手动与 DOM API 交互。

类似的还有D3.js

3. 适配器模式: Adapter Pattern

  • 传统:适配两个及以上类接口不兼容的问题
  • JS: 可额外适配两个及以上代码库、前后端数据等。

使用时机 通常使用适配器的情况:

  • 需要集成新组件并与应用程序中的现有组件一起工作。
  • 重构,程序的哪些部分用改进的接口重写,但旧代码仍然需要原始接口。

1. jQuery.fn.css()规范化显示

// Cross browser opacity:
// opacity: 0.9;  Chrome 4+, FF2+, Saf3.1+, Opera 9+, IE9, iOS 3.2+, Android 2.1+ 
// filter: alpha(opacity=90);  IE6-IE8 
   
// Setting opacity
$( ".container" ).css( { opacity: .5 } );

// Getting opacity
var currentOpacity = $( ".container" ).css('opacity');

内部实现为:

get: function( elem, computed ) {
  return ropacity.test( (
        computed && elem.currentStyle ? 
            elem.currentStyle.filter : elem.style.filter) || "" ) ?
    ( parseFloat( RegExp.$1 ) / 100 ) + "" :
    computed ? "1" : "";
},

set: function( elem, value ) {
  var style = elem.style,
    currentStyle = elem.currentStyle,
    opacity = jQuery.isNumeric( value ) ? 
          "alpha(opacity=" + value * 100 + ")" : "",
    filter = currentStyle && currentStyle.filter || style.filter || "";

  style.zoom = 1;

  // 如果将不透明度设置为1,则移除其他過濾器
  //exist - attempt to remove filter attribute #6652
  if ( value >= 1 && jQuery.trim( filter.replace( ralpha, "" ) ) === "" ) {
    style.removeAttribute( "filter" );
    if ( currentStyle && !currentStyle.filter ) {
      return;
    }
  }

  // otherwise, set new filter values
  style.filter = ralpha.test( filter ) ?
    filter.replace( ralpha, opacity ) :
    filter + " " + opacity;
}
};

2. Vue中的computed

yck - 《前端面试之道》

Vue 中,我们其实经常使用到适配器模式。

比如父组件传递给子组件一个时间戳属性,组件内部需要将时间戳转为正常的日期显示,一般会使用 computed 来做转换这件事情,这个过程就使用到了适配器模式。

4. 代理模式: Proxy Pattern

为其他对象提供一种代理以便控制对这个对象的访问。

可以详细控制访问某个类(对象)的方法,在调用这个方法前作的前置处理(统一的流程代码放到代理中处理)。调用这个方法后做后置处理。

例如:明星的经纪人,租房的中介等等都是代理

使用代理模式的意义是什么?

  • “单一职责原则”:面向对象设计中鼓励将不同的职责分布到细粒度的对象中,Proxy 在原对象的基础上进行了功能的衍生而又不影响原对象,符合松耦合高内聚的设计理念

  • 遵循“开放-封闭原则”:代理可以随时从程序中去掉,而不用对其他部分的代码进行修改,在实际场景中,随着版本的迭代可能会有多种原因不再需要代理,那么就可以容易的将代理对象换成原对象的调用。

特点:

  • 解决系统之间的耦合度以及系统资源开销大
  • 通过代理对象可保护被代理的对象,使其扩展性不受外界的影响
  • 在js中,它的执行常常依托于浏览器
  • 事件代理就用到了代理模式。

分类:

  1. 远程代理(Remote Proxy):为一个位于不同的地址空间的对象提供一个本地的代理对象
  2. 虚拟代理(Virtual Proxy):如果需要创建一个资源消耗较大的对象,先创建一个消耗相对较小的对象来表示,真实对象只在需要时才会被真正创建。
  3. 保护代理(Protect Proxy):控制对一个对象的访问,可以给不同的用户提供不同级别的使用权限。
  4. 缓冲代理(Cache Proxy):为某一个目标操作的结果提供临时的存储空间,以便多个客户端可以共享这些结果。
  5. 智能引用代理(Smart Reference Proxy):当一个对象被引用时,提供一些额外的操作,例如将对象被调用的次数记录下来等。

缺点::

  1. 由于在客户端和真实主题之间增加了代理对象,因此有些类型的代理模式可能会造成请求的处理速度变慢,例如保护代理。

  2. 实现代理模式需要额外的工作,而且有些代理模式的实现过程较为复杂,例如远程代理。

前端用得最多的是 虚拟代理保护代理缓冲代理

1. ES6中的Proxy

ES6所提供Proxy构造函数能够让我们轻松的使用代理模式:

// target: 表示所要代理的对象,handler: 用来设置对所代理的对象的行为。
let proxy = new Proxy(target, handler);

2. 图片预加载

目前一般的网站都会有图片预加载机制,也就是在真正的图片在被加载完成之前用一张菊花图(转圈的gif图片)表示正在加载图片。

const img = new Image();
img.src = '/some/big/size/image.jpg';
document.body.appendChild(img);

创建虚拟图片节点virtualImg并构造创建代理函数:

// 图片懒加载: 虚拟代理
const createImgProxy = (img, loadingImg, realImg) => {
  let hasLoaded = false;
  const virtualImg = new Image();
  virtualImg.src = realImg;
  virtualImg.onload = () => {
    Reflect.set(img, 'src', realImg);
    hasLoaded = true;
  }
  return new Proxy(img, {
    get(obj, prop) {
      if (prop === 'src' && !hasLoaded) {
        return loadingImg;
      }
      return obj[prop];
    }
  });

最后是将原始的图片节点替换为代理图片进行调用:

const img = new Image();
const imgProxy = createImgProxy(img, '/loading.gif', '/some/big/size/img.jpg');
document.body.appendChild(imgProxy);

3. 分页数据:缓存代理

如,前后端分离,向后端请求分页的数据的时候,每次页码改变时都需要重新请求后端数据,我们可以将页面和对应的结果进行缓存,当请求同一页的时候,就不再请求后端的接口而是从缓存中去取数据。

const getFib = (number) => {
  if (number <= 2) {
    return 1;
  } else {
    return getFib(number - 1) + getFib(number - 2);
  }
}

const getCacheProxy = (fn, cache = new Map()) => {
  return new Proxy(fn, {
    apply(target, context, args) {
      const argsString = args.join(' ');
      if (cache.has(argsString)) {
        // 如果有缓存,直接返回缓存数据        console.log(`输出${args}的缓存结果: ${cache.get(argsString)}`);
        
        return cache.get(argsString);
      }
      const result = fn(...args);
      cache.set(argsString, result);

      return result;
    }
  })
}
const getFibProxy = getCacheProxy(getFib);
getFibProxy(40); // 102334155getFibProxy(40); // 输出40的缓存结果: 102334155

4. 事件代理

事件代理就用到了代理模式。

<ul id="ul">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
</ul>
<script>
    let ul = document.querySelector('#ul')
    ul.addEventListener('click', (event) => {
        console.log(event.target);
    })
</script>

通过给父节点绑定一个事件,让父节点作为代理去拿到真实点击的节点。

5. 装饰者模式: Decorator Pattern

在不改变原对象的基础上,通过对其进行包装拓展(添加属性或者方法)使原有对象可以满足用户更复杂的需求

装饰器类似于高阶函数的概念。装饰器将基本形式作为参数,并在其上添加处理并将其返回。 优点:

  • 优点是把类(函数)的核心职责和装饰功能区分开了。

问题:

  • 装饰链叠加了函数作用域,如果过长也会产生性能问题。

JavaScript中:

  • 装饰者模式提供比继承更有弹性的替代方案。
  • 装饰者用于包装同接口的对象,用于通过重载方法的形式添加新功能,该模式可以在被装饰者的前面或后面加上自己的行为以达到特定的目的。

核心就是缓存上一次的函数

1. 简单例子

举一个简单的例子:


var xiaoming = function () {
  this.run = function () {
    return '跑步'
  },
  this.eat = function () {
    return: '吃饭'
  }
}
// 小明可以跑步,也可以吃饭
// 下面是一个装饰类,给小明进行装饰
var decor = function (xiaoming) {
  this.run = function () {
    return xiaoming.run + '很快'
  }
  this.eat = function () {
    return xiaoming.eat + '很多'
  }
}

通过一个装饰类,实现了对小明类的装饰。

2. TypeScript函数修饰符: @

“@”,与其说是修饰函数倒不如说是引用、调用它修饰的函数。

或者用句大白话描述:@: "下面的被我包围了。"

举个栗子,下面的一段代码,里面两个函数,没有被调用,也会有输出结果:

test(f){
    console.log("before ...");
    f()
		console.log("after ...");
 }

@test
func(){
	console.log("func was called");
}

直接运行,输出结果:

before ...
func was called
after ...

3. React中的装饰器模式

React中,装饰器模式随处可见:


import React, { Component } from 'react';
import {connect} from 'react-redux';
class App extends Component {
 render() {
  //...
 }
}
// const mapStateToProps
// const actionCreators
export default connect(mapStateToProps,actionCreators)(App);

Ant Design中创建表单的最后一步其实也算装饰器模式

class CustomizedForm extends React.Component {}

CustomizedForm = Form.create({})(CustomizedForm);

6. 桥接模式:Bridge Pattern

桥接模式将实现层与抽象次层解耦分离,使两部分可以独立变化。 该模式包含如下角色:

  • Abstraction(抽象类)
  • RefinedAbstraction(扩充抽象类)
  • Implementor(实现类接口)
  • ConcreteImplementor(具体实现类)

常用于应用程序(客户端)和数据库驱动程序(服务):

应用程序写入定义的数据库API,例如ODBC,但在此API之后,会发现每个驱动程序的实现对于每个数据库供应商(SQL Server,MySQL,Oracle等)都是完全不同的。

  • 多见于驱动程序开发,在JavaScript中很少见。
  • 一些软件的跨平台设计有时候也是应用了桥接模式

1. 网站主题替换

在大型网站中,不同模块可能会有不同主题,也有分白天/黑夜 或 用户自主选择的主题。

这时为每个主题创建每个页面的多个副本明显不合理,而桥接模式是更好的选择:

javascript-design-patterns-for-human

不同模块:

class About{ 
    constructor(theme) {
        this.theme = theme
    }
    
    getContent() {
        return "About page in " + this.theme.getColor()
    }
}

class Careers{
   constructor(theme) {
       this.theme = theme
   }
   
   getContent() {
       return "Careers page in " + this.theme.getColor()
   } 
}

以及不同主题:


class DarkTheme{
    getColor() {
        return 'Dark Black'
    }
}
class LightTheme{
    getColor() {
        return 'Off white'
    }
}
class AquaTheme{
    getColor() {
        return 'Light blue'
    }
}

生成主题:

const darkTheme = new DarkTheme()

const about = new About(darkTheme)
const careers = new Careers(darkTheme)

console.log(about.getContent() )// "About page in Dark Black"
console.log(careers.getContent() )// "Careers page in Dark Black"

7. 组合模式: Composite Pattern

  • 又称 部分-整体模式,将对象组合成树形结构以表示“部分整体”的层次结构。
  • 使得用户对单个对象和组合对象的使用具有一致性。(参考卡片和表单组成)

该模式包含以下角色:

  1. Component - 声明组合中对象的接口并实现默认行为(基于Composite
  2. Leaf - 表示合成中的原始对象
  3. Composite - 在Component接口中实现与子相关的操作,并存储Leaf(primitive)对象。

1. 操作系统中的文件目录结构

计算机文件结构是组合模式的一个实例。

如果你删除某个文件夹,也将删除该文件夹的所有内容,是吗? 这实质上就是组合模式运行原理。 你

你可以调用结构树上较高层次的组合对象,消息将沿这一层次结构向下传输。

2. 批量操作DOM

Javascript设计模式理论与实战:组合模式

HTML文档的DOM结构就是天生的树形结构,最基本的元素醉成DOM树,最终形成DOM文档,非常适用适用组合模式。

我们常用的jQuery类库,其中组合模式的应用更是频繁,例如经常有下列代码实现:

$(".test").addClass("noTest").removeClass("test");

不论$(“.test”)是一个元素,还是多个元素,最终都是通过统一的addClassremoveClass接口进行调用。

我们简单模拟一下addClass的实现:

var addClass = function (eles, className) {
    if (eles instanceof NodeList) {
        for (var i = 0, length = eles.length; i < length; i++) {
            eles[i].nodeType === 1 && (eles[i].className += (' ' + className + ' '));
        }
    }
    else if (eles instanceof Node) {
        eles.nodeType === 1 && (eles.className += (' ' + className + ' '));
    }
    else {
        throw "eles is not a html node";
    }
}
addClass(document.getElementById("div3"), "test");
addClass(document.querySelectorAll(".div"), "test");

对于NodeList或者是Node来说,客户端调用都是同样的使用了addClass这个接口,这个就是组合模式的最基本的思想,使部分和整体的使用具有一致性。

8. 享元模式:Flyweight Pattern

享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。

  • 主要用于减少创建对象的数量,以减少内存占用和提高性能
  • 运用共享技术来有效支持大量细粒度的对象

享元模式的核心是运用共享技术来有效支持大量细粒度的对象。

如果系统中因为创建了大量类似的对象而导致内存占用过高,享元模式就非常有用了。在JavaScript中,浏览器特别是移动端的浏览器分配的内存并不算多,如何节省内存就成了一件非常有意义的事情。

享元模式有以下角色:

  • 客户端:用来调用享元工厂来获取内在数据的类,通常是应用程序所需的对象,
  • 享元工厂:用来维护享元数据的类
  • 享元类:保持内在数据的类

1. 简单例子

在下面的例子中,我们创建了一个“Book”类来处理有关特定书籍,然后创建一个“BookFactory”类来控制如何创建这些Book对象。

为了获得更好的内存性能,如果同一对象被实例化两次,则会重用这些对象。

class Book {
  constructor(title, isbn, author, ratings) {
    this.title = title;
    this.isbn = isbn;
    this.author = author;
    this.ratings = ratings;
  }

  getAverageReview() {
    let averageReview =  (this.ratings.reduce((a,b) => a+b)) / this.ratings.length
    return averageReview;
  }
}

class BookFactory {
  constructor() {
    this._books = [];
  }

  createBook(title, isbn, author, ratings) {
    let book = this.getBookBy(isbn);
    if (book) { //重用对象
      return book;
    } else {
      const newBook = new Book(title, isbn, author, ratings);
      this._books.push(newBook);
      return newBook;
    }
  }

  getBookBy(attr) {
    return this._books.find(book => book.attr === attr);
  }
}

2. 在线表格思路实现

打开谷歌在线表格,提取打印其节点元素。

可以看到就算是滚动至千行,它们都只是共用两个视图。

用的就是享元模式,来防止无限滚动造成卡顿。

以下是模拟实现:

首先是HTML

<section id="app">
  <table id="table"></table>
  <div class="controls">
    <input type="range" name="scroll" id="scroll" value="0">
  </div>
</section>

样式:

#app {
  position: relative;
  padding: 30px 0 30px 10px;
  
  #table {
    padding: 20px;
    border-radius: 10px;
    min-width: 450px;
    transition: background 0.5s;
    background: rgba(73, 224, 56, 0.1);
    
    &.low-range {
      background: rgba(73, 224, 56, 0.47);
      td {
        border-bottom: 1px solid rgba(73, 224, 56, 0.9)
      }
    }
    &.mid-range {
      background: rgba(224, 196, 56, 0.47);
      td {
        border-bottom: 1px solid rgba(224, 196, 56, 0.9)
      }
    }
    &.high-range {
      background: rgba(224, 56, 56, 0.47);
      td {
        border-bottom: 1px solid rgba(224, 56, 56, 0.9)
      }
    }
    &.ultra-high-range {
      background: rgba(224, 56, 56, 0.9);
      td {
        border-bottom: 1px solid black
      }
    }
    td {
      border-bottom: 1px solid black;
      padding: 10px;
      font-weight: bold;
    }
  }
  .controls {
    padding-top: 20px;
    
    #scroll {
      width: 450px;
      box-sizing: border-box;
    }
  }
}

逻辑实现,请配合注释食用:

// 生成单元格实例
const makeRowCells = data => data.map(value => new Cell(value));

// 定义常量
const scrollViewport = 10; // 当前表格视图大小
const tableSize = 2000; // 行数
let scrollIndex = 0; // 初始滚动索引

let DATA = []; // 初始数据集
while (DATA.length < scrollViewport) {
  const unit = DATA.length * 10;
  DATA.push('12345678'.split('').map(() => unit));
}

/**
* cell类 - 列
*/
class Cell {
  constructor(content) {
    this.content = content;
  }
  // 更新列
  updateContent(content) {
    this.content = content;
    this.cell.innerText = content;
  }
  
  // 渲染列
  render() {
    const cell = document.createElement('td');
    this.cell = cell;
    cell.innerText = this.content;
    
    return cell;
    
  }
}

/**
* row类 - 行
*/
class Row {
  constructor(cellItems) {
    this.cellItems = cellItems;
  }
  // 更新行
  updateRowData(newData) {
    this.cellItems.forEach((item, idx) => {
      item.updateContent(newData[idx]);
    });
  }
  
  // 渲染行
  render() {
    const row = document.createElement('tr');
    this.cellItems.forEach(item => row.appendChild(item.render()));
    
    return row;
  }
}

/**
* 表格类
*/
class Table {
  constructor(selector) {
    this.$table = document.querySelector(selector);
  }
  // 添加行
  addRows(rows) {
    this.rows = rows;
    this.rows.forEach(row => this.$table.appendChild(row.render()));
  }
  
  // 更新table数据
  updateTableData(data) {
    this.rows.forEach((row, idx) => row.updateRowData(data[idx]));
  }
}

// 实例化新表
const table = new Table('#table');
// 匹配滚动条的DOM
const scrollControl = document.querySelector('#scroll');
// 在table下添加单元格行
table.addRows(
  DATA.map(dataItem => new Row(makeRowCells(dataItem))));

const onScrollChange = event => {
  // 为视图准备新数据
  DATA = DATA.map((item, idx) => item.map(cell => parseInt(event.target.value, 10)*10 + idx*10));
  // 更新当前table的数据
  table.updateTableData(DATA);
  // 添加颜色区别样式
  scrollIndex = event.target.value;
  if (event.target.value >= 0) {
    table.$table.classList = 'low-range';
  }
  if (event.target.value > tableSize * 0.4) {
    table.$table.classList = 'mid-range';
  }
  if (event.target.value > tableSize * 0.7) {
    table.$table.classList = 'high-range';
  }
  if (event.target.value > tableSize * 0.9) {
    table.$table.classList = 'ultra-high-range';
  }
};
// 设置滚动条最小和最大范围
scrollControl.setAttribute('min', 0);
scrollControl.setAttribute('max', tableSize);
// 添加滚动事件
scrollControl.addEventListener('input', onScrollChange);

// 初始化事件
const event = {target: {value: 0}};
onScrollChange(event);

9. 结语及参考

至此,结构型设计模式已经讲(水)完了,其中享元模式值得单独拿出来写一篇博客。

参考文章

❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
  2. 关注公众号「前端劝退师」,不定期分享原创知识。
  3. 也看看其它文章

公众号后台回复「设计模式」 领取作者精心自制的思维导图