深入浅出javaScript设计模式(二):结构型设计模式

写在前面

生活中的很多事做多了熟练以后经常能结出一些套路,使用套路能让事情的处理变得容易起来。而设计模式就是写代码的套路,好比剑客的剑谱,说白了就是一个个代码的模板。

就算从没看过设计模式的人工作中肯定多多少少也接触或不经意间就使用过某种设计模式。因为代码的原理都是一样的,实现某个需求的时候可能自然而然就以最佳的模式去实现了。

这些模式已经有前人做了整理总结,不仅可以直接拿来使用,在阅读开源库源码时也会发现这些库都大量的使用了设计模式。

系列文章

深入浅出javaScript设计模式

本系列内容主要来自于对张容铭所著《JavaScript设计模式》一书的理解与总结(共5篇),由于文中有我自己的代码实现,并使用了部分新语法,抛弃了一些我认为繁琐的内容,甚至还有对书中 "错误" 代码的修正。所以如果发现我理解有误,代码写错的地方麻烦务必指出!非常感谢!

前置知识

掌握javaScript基础语法,并对js原理(特别是原型链)有较深理解。

结构型设计模式

结构型设计模式专注于如何将类或对象组合成更大,更复杂的结构,以简化设计。

一、外观模式

为一组复杂的子系统提供一个更高级的统一接口,通过这个接口使得子系统接口的访问更容易。在js中有时也会用于对底层结构兼容性做统一封装来简化用户使用。

简单说外观模式就是对一系列复杂接口进行二次封装,隐藏其中的细节,简化使用。

比如给元素绑定点击事件,在不同浏览器中需要使用不同的api,就可以通过外观模式封装一个统一的绑定事件方法。

function addEvent(dom, eventType, fn) {
  if(dom.addEventListener) {
    // 对于支持dome2级实践处理程序addEventListener方法的浏览器
    dom.addEventListener(eventType, fn)

  } else if (dom.attachEvent) {
    // 对于不支持addEventListener但支持attachEvent的浏览器
    dom.attachEvent('on' + eventType, fn)

  } else {
    // 对于不支持addEventListener 也不支持 attachEvent,但支持on + 事件名的浏览器
    dom['on' + eventType] = fn
  }
}
复制代码

很多代码库中同是通过外观模式封装多个功能,简化底层操作方法。

例如jquery能极大的简化了dom操作,它的选择器方法封装兼容各种dom元素的选择方式。

$('#testDom').html('test')
复制代码

二、适配器模式

将一个类(对象)的接口(方法或属性)转化为另一个接口,以满足用户需求,是类(对象)之间接口的不兼容问题通过适配器得以解决。

所谓适配器就是将本身不兼容的双方兼容起来,比如三角插头的手机充电器在两口的插座上无法使用,如果中间提供一个适配器就可以使用了。

场景一: 库适配器

原本在项目中定义并使用自己的库

// 定义
const MytTools = {
    setInnerHtml(id, html) {
      const $dom = document.getElementById(id)
      $dom.innerHTML = html
   }
   // 其他方法
}

// 使用
MytTools.setInnerHtml('testDom', 'hello')
复制代码

随着项目的发展,考虑团队协作,提高维护性与兼容性,想引入jQuery替换原来的MytTools。

// 编写适配器
MytTools.setInnerHtml = function(id, html) {
  $('#' + id).html(html)
}

复制代码

这样一来写新需求时直接使用jQuery的方法,老的代码也通过适配器使用了jQuery,整个项目都替换成了jQuery。

场景二:参数适配器

当一个函数入参较多时,通常会采用一个对象的方式传入

function fn(name, age, color, size, price) {
// do something
}

function fn1(obj) {
  /* 
    obj.name
    obj.age
    obj.color
    obj.size
    obj.price
  */
  // do something
}
复制代码

但调用时可能不知道传入的对象参数是否完整,有一些必要的参数是否传入,一些参数有默认值等,此时可以使用参数适配器。

function fn(obj) {
  // 参数适配器
  const _adapter = {
    name: '王狗蛋',
    age: 18,
    color: 'red',
    size: '18cm',
    price: 100
  }

  for(key in _adapter) {
    _adapter[key] = obj[key] || _adapter[key]
  }

  console.log(_adapter.age)
}

// 传入空对象调用
fn({}) // 18
复制代码

其实使用es6之后通过结构和默认参数已经能更好的实现这个目的,甚至连空对象都不用传入。

function fn({
    name = '王狗蛋',
    age = 18,
    color = 'red',
    size = '18cm',
    price = 100
  } = {}) {

  console.log(age)
}

// 无参数调用
fn() // 18
复制代码
场景三:数据适配器

参数适配器的衍生,对于语义不好的数据结构进行适配。

// 对于每个成员代表意义不同的数组,数据语义不友好
const dataArr = ['王狗蛋', 18, 'red', '18cm', 100]

// 数据适配器
function arrToObjAdapter(arr) {
  return {
    name = arr[0],
    age = arr[1],
    color = arr[2],
    size = arr[3],
    price = arr[4]
  }
}

// 进行适配
const dataObj = arrToObjAdapter(dataArr)
复制代码

有时联调好的接口后端返回字段又发生变化,跟前端不再匹配,也可以使用适配器保证前端页面上的老代码兼容。

// 比如后端将原本数据返回的其中一个name字段变更为title

// 适配器
function resultAdapter(result) {
  return Object.assign({
    name: result.title
  }, result)
}

const data = resultAdapter({ title: '王狗蛋' })
console.log(data.name) // 王狗蛋
复制代码

传统设计模式中,适配器模式往往是适配两个类接口不兼容的问题,而在js中适配器的应用范围更广,比如适配两个代码库,适配前后端数据,等等。

三、代理模式

由于一个对象不能直接引用另一个对象,所以需要通过代理对象在这两个对象之间起到中介的作用。

跨域限制造成的两个系统间无法通信,可以通过找个代理对象实现。

img标签

img标签可以通过src属性向其他域下的服务器发送单向的get请求。

单向的意思就是无法收到响应数据,因此可以用来实现一些只需要发送,不需要知道结果的需求,比如页面上的埋点统计。

// 埋点
const Recording = (function() {
  // 缓存图片
  const _img = new Image()

  // 返回埋点函数
  return function() {
    // 请求地址
    const url = 'http://www.test.com/pageView.png'

    // 发送请求
    _img.src = url;
  }
})()

// 使用
Recording()
复制代码
jsonp与代理模板

jsonp的原理简单说就是通过script标签允许跨域请求并且可以执行请求回来的js代码特性。

首先在前端定义好接受返回数据的callback方法,由后端将返回的数据传入callback函数。

const script = document.createElement('script');
script.src = 'http://www.test.com/listData';
document.head.appendChild(script);

// 比如前端定义的接收数据callback是getData函数,后端返回数据时就就将data放在getData()中
function getData(res) {
  console.log(res);
}
复制代码

参考:juejin.cn/post/684490…

现在开发中跨域基本都通过后端cors解决,书中的jsonp与代理模板已经很少用到,不再赘述。

四、装饰者模式

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

常用于在原有功能基础上,不用修改原有代码的添加新功能。

比如给一批已有的元素的点击事件添加新功能,这些元素有的绑定过点击事件有的没有没有,并且各元素事件内部的功能也不同,如果一个一个具体的去添加事件很麻烦,就可以定义一个装饰者来实现目的。

// 装饰者
function decorator(id, fn) {
  const $element = document.getElementById(id)

  // 如果该元素原本有点击事件
  if(typeof $element.onclick === 'function') {
    // 缓存已有事件函数
    const oldFun = $element.onclick

    // 为元素定义新的事件函数
    $element.onclick = function() {
      // 执行原有事件函数
      oldFun()
      // 执行元素新事件函数
      fn()
    }
  } else {
    // 直接将事件函数绑定
    $element.onclick = fn
  }
}
复制代码
// 使用
decorator('dom1', function() {
  console.log('dom1新功能')
})

decorator('dom2', function() {
  console.log('dom2新功能')
})
复制代码

装饰者模式就是对原有对象的属性与方法的添加

五、桥接模式

在系统沿着多个维度变化的同时,又不增加其复杂度并已达到解耦。

元素的事件与业务逻辑解耦

比如做一个页面上的鼠标滑过特效,修改字体颜色和背景颜色

const $divs = document.getElementsByTagName('div')

$divs[0].addEventListener('mouseover', function() {
  this.style.color = 'red'
  this.style.background = 'blue'
})

$divs[0].addEventListener('mouseout', function() {
  this.style.color = '#000'
  this.style.background = '#fff'
})
复制代码

这样虽然实现了需求,但是这个元素上的事件(鼠标移入/移除)与业务(改变颜色)是高度耦合的。 并且这个改变颜色的特效可能很多其他元素也会用到。

可以使用桥接模式,将业务与事件解耦。

// 抽象出业务
function changeColor(dom, fontColor, bgColor) {
    dom.style.color = fontColor
    dom.style.background = bgColor
}


const $divs = document.getElementsByTagName('div')

$divs[0].addEventListener('mouseover', function() {
  changeColor(this, 'red', 'blue')
})

$divs[0].addEventListener('mouseout', function() {
  changeColor(this, '#000', '#fff')
})

复制代码

此时这个业务函数可以用于不同元素的不同颜色需求,并且如果需求对业务修改,只需要修改业务函数,不需要到每个事件的回调中去修改。

创建多元对象时的解耦

比如在游戏中,人物与一个小球是不同的物体,但他们的移动方法实现起来是一样的,都是x,y坐标的变化;人物与怪物说的语言不同,但实现的方法也是一样的。

对于这种多元对象可以抽象他们的独立单元,创建实体时将每个抽象的单元桥接在一起。

// 移动单元
function Move(x, y) {
  this.x = x
  this.y = y
}
Move.prototype.run = function() {
  console.log(`move to x:${this.x}, y:${this.y}`)
}

// 说话单元
function Speak(wd) {
  this.wd = wd
}
Speak.prototype.say = function() {
  console.log(this.wd)
}

// 创建一个人物类,可以移动和说话
function Person(x, y, wd) {
  this.move = new Move(x, y)
  this.speak = new Speak(wd)
}
Person.prototype.init = function() {
  this.move.run()
  this.speak.say()
}

// 创建一个小球类,只能移动
function Ball(x, y) {
  this.move = new Move(x, y)
}

const p = new Person(10, 15, '你好')
p.init() //move to x:10, y:15  你好
复制代码

这里有些类似创建者模式,我理解的区别在于创建者模式的主要业务在于创建,桥接模式主要是对结构之间的解耦分离。

也就是说创建者模式是因为想要创造出来对象比较复杂而使用的创建方式,目的是创建出一个这样的对象。而桥接模式是为了抽象与结构间的解耦,使实现层与抽象能独立的变化。

六、组合模式

又称部分-整体模式,将对象组合成树形结构以表示"部分整体"的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

简单来说就是创建一个树形结构,结构中的每一层级(小到最末端的子节点,大到最外层的整体树)都具有操作的一致性和数据结构的一致性。

每个节点又是一个独立的个体,互不影响,但组合起来可以表达一个复杂的结构。

编写html页面结构就是一种组合模式的体现,比如这样一个dom结构。

<div class="container" id="div1">
  <div class="header">
    <span>标题</span>
  </div>
  <p id="p1">一段文字</p>
</div>
复制代码

用虚拟dom表示出来可能类似这样。

const vdom = {
  tag: 'div',
  attr: {
    className: 'container',
    id: 'div1'
  },
  children: [
    {
      tag: 'div',
      attr: {
        className: 'header'
      },
      children: [
        {
          tag: 'span',
          attr: {},
          children: ['标题']
        }
      ]
    },
    {
      tag: 'p',
      attr: {
        id: 'p1'
      },
      children: ['一段文字']
    }
  ]
}
复制代码

外层的container节点与其内部包含的span节点都是dom,保持着属性与操作方法的一致性(都可以添加属性,或进行appendChild等dom操作),通过组合实现复杂的结构。

这种模式在写业务代码中不常见,在vdom或者ui组件库中可以看到这种模式,比如elementUI中的导航菜单,树形菜单,还有form表单组件,都可以看到组合模式的设计思想。

七、享元模式

运用共享技术有效地支持大量的细粒度的对象,避免对象间拥有相同内容造成多余的开销。

享元模式的目的在于共享内存,从而提升性能,方式就是提取共有的数据与方法。

类似于50个人各自开私家车上班,目的地相同,变为一起坐公交车,从而节约了资源的使用。

比如一个列表中,每个li上都要绑定一个点击事件。

<ul id="list">
  <li>a1</li>
  <li>a2</li>
  <li>a3</li>
  <li>a4</li>
  <li>a5</li>
</ul>
复制代码

但这些事件的逻辑是类似的,那么可以只在ul上绑定一次事件,通过事件冒泡机制,由于ul来代理事件处理。

const $list = document.getElementById('list')

$list.addEventListener('click', function(e) {
  console.log(e.target.innerHTML)
})
复制代码

原本列表每一项都要绑定的事件,并且列表翻页,更新后都要重新绑定的事件,变为只用绑定一次,通过代理的方式所有列表共享了事件,既符合代理模式也符合享元模式思想。

在性能与内存的消耗对程序执行影响不大时,强行应用享元模式而引入复杂的代码逻辑是得不偿失的。

Guess you like

Origin juejin.im/post/7035454530921693197