Skip to content

结构型模式 #8

Description

@edwineo

装饰器模式

概念:允许向一个现有的对象添加新的功能,同时又不改变其结构。只作为现有的类的一个包装。这种模式创建了一个装饰类,用来包装原有的类,并在保持类方法签名完整性的前提下,提供了额外的功能

场景:当我们做需求的时候,我需要对它已有的功能做个拓展,只关心拓展出来的那部分新功能如何实现,不关心旧逻辑。

如果我们要实现一个按钮,在点击后弹出弹窗:

// 弹框创建逻辑
const Modal = (function() {
  let modal = null
  return function() {
    if(!modal) {
      modal = document.createElement('div')
      modal.id = 'modal'
      modal.style.display = 'none'
      document.body.appendChild(modal)
    }
    return modal
  }
})()

// 点击打开按钮展示modal
document.getElementById('open').addEventListener('click', function() {
  const modal = new Modal()
  modal.style.display = 'block'
})

// 点击关闭按钮隐藏modal
document.getElementById('close').addEventListener('click', function() {
  const modal = document.getElementById('modal')
  if(modal) {
    modal.style.display = 'none'
  }
})

后续如果我们需要添加一系列新功能,例如点击后按钮文字改变,按钮置灰等,那么我们需要直接去修改已有的函数体,这种做法违背了我们的“开放封闭原则”;往一个函数体里塞这么多逻辑,违背了我们的“单一职责原则”。

这个时候我们就需要装饰器模式,只拓展新功能,并不关心已有的功能。

ES5

抽离旧逻辑:

// 将弹出Modal的逻辑单独封装
function openModal() {
    const modal = new Modal()
    modal.style.display = 'block'
}

编写新逻辑:

// 按钮文案修改逻辑
function changeButtonText() {
    const btn = document.getElementById('open')
    btn.innerText = '快去登录'
}

// 按钮置灰逻辑
function disableButton() {
    const btn =  document.getElementById('open')
    btn.setAttribute("disabled", true)
}

// 新版本功能逻辑整合
function changeButtonStatus() {
    changeButtonText()
    disableButton()
}

然后把三个操作逐个添加open按钮的监听函数里:

document.getElementById('open').addEventListener('click', function() {
    openModal()
    changeButtonStatus()
})

如此一来,我们就实现了“只添加,不修改”的装饰器模式

ES6

ES6实现:

// 定义打开按钮
class OpenButton {
    // 点击后展示弹框(旧逻辑)
    onClick() {
        const modal = new Modal()
    		modal.style.display = 'block'
    }
}

// 定义按钮对应的装饰器
class Decorator {
    // 将按钮实例传入
    constructor(open_button) {
        this.open_button = open_button
    }
    
    onClick() {
      	// 旧逻辑
        this.open_button.onClick()
        // “包装”了一层新逻辑
        this.changeButtonStatus()
    }
    
    changeButtonStatus() {
        this.changeButtonText()
        this.disableButton()
    }
    
    disableButton() {
        const btn =  document.getElementById('open')
        btn.setAttribute("disabled", true)
    }
    
    changeButtonText() {
        const btn = document.getElementById('open')
        btn.innerText = '快去登录'
    }
}

const openButton = new OpenButton()
const decorator = new Decorator(openButton)

document.getElementById('open').addEventListener('click', function() {
    decorator.onClick()
})

ES6 这个版本的实现我们需要特别的关注,这里我们把按钮实例传给了 Decorator,以便于后续 Decorator 可以对它进行无限的逻辑的拓展。这是我们通常会用的实现方式

ES7

在 ES7 中,Decorator 作为一种语法被直接支持了,它的书写会变得更加简单,但背后的原理其实与此大同小异。

类装饰器:

// 类装饰器,它的第一个参数是目标类
function classDecorator(target) {
    target.hasDecorator = true
  	return target
}

// 将装饰器“安装”到Button类上
@classDecorator
class Button {
    // Button类的相关逻辑
}

// 验证装饰器是否生效
console.log('Button 是否被装饰了:', Button.hasDecorator)

函数装饰器:

// 函数装饰器
function funcDecorator(target, name, descriptor) {
    let originalMethod = descriptor.value
    descriptor.value = function() {
      console.log('我是Func的装饰器逻辑')
      return originalMethod.apply(this, arguments)
    }
  	return descriptor
}

class Button {
    @funcDecorator
    onClick() { 
        console.log('我是Func的原有逻辑')
    }
}

// 验证装饰器是否生效
const button = new Button()
button.onClick()

装饰器的最最基本操作就是定义装饰器函数,将被装饰者“交给”装饰器。这也正是装饰器语法糖首先帮我们做掉的工作,我们一起看看装饰器的实现细节:

当我们定义类装饰器函数时,此时的入参 target 就是被装饰的类本身;当我们给一个类中的方法添加装饰器时,例如给上述给 button 添加的装饰器,此处的 target 变成了Button.prototype,即类的原型对象。

这是因为 onClick 方法总是要依附其实例存在的,修饰 onClick 其实是修饰它的实例。实例是在我们的代码运行时动态生成的,而装饰器函数则是在编译阶段就执行了。所以说装饰器函数真正能触及到的,就只有类这个层面上的对象。

在编写类装饰器时,我们一般获取一个target参数就足够了。但在编写方法装饰器时,我们往往需要至少三个参数:

  1. target:类的原型对象
  2. name:修饰的目标属性属性名
  3. descriptor:属性描述对象,是我们使用频率最高的一个参数,相当于 Object.defineProperty(obj, prop, descriptor) 中的 descriptor,这里面有数据描述符(value),存取描述符(get, set)等。很明显,拿到了 descriptor,就相当于拿到了目标方法的控制权。通过修改 descriptor,我们就可以对目标方法的逻辑进行无限的拓展了。

适配器模式

适配器模式是作为两个不兼容的接口之间的桥梁。它结合了两个独立接口的功能。这种模式涉及到一个单一的类,该类负责加入独立的或不兼容的接口功能。

生产实践:axios中的适配器

axios 本身就用到了我们的适配器模式,它的兼容方案值得我们学习和借鉴。

在使用axios时,作为用户我们只需要掌握下面常用的接口为代表的一套api,便可轻松地发起各种姿势的网络请求,而不用去关心底层的实现细节:

axios.post('/user', {
  firstName: 'Fred',
  lastName: 'Flintstone'
})
  .then(function (response) {
  console.log(response);
})
  .catch(function (error) {
  console.log(error);
});

axios({
  method: 'post',
  url: '/user/12345',
  data: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  }
})

除了简明优雅的api之外,axios 强大的地方还在于,它不仅仅是一个局限于浏览器端的库。在Node环境下,我们尝试调用上面的 api,会发现它照样好使 —— axios 完美地抹平了两种环境下api的调用差异,靠的正是对适配器模式的灵活运用。

axios 的核心逻辑中,我们可以注意到实际上派发请求的是 dispatchRequest 方法。该方法内部其实主要做了两件事:

  1. 数据层面的适配,数据转换,转换请求体/响应体;
  2. 调用适配器。

调用适配器的逻辑如下:

// 若用户未手动配置适配器,则使用默认的适配器
var adapter = config.adapter || defaults.adapter;
  
// dispatchRequest方法的末尾调用的是适配器方法
return adapter(config).then(function onAdapterResolution(response) {
  // 请求成功的回调
  throwIfCancellationRequested(config);

  // 转换响应体
  response.data = transformData(
    response.data,
    response.headers,
    config.transformResponse
  );

  return response;
}, function onAdapterRejection(reason) {
  // 请求失败的回调
  if (!isCancel(reason)) {
    throwIfCancellationRequested(config);

    // 转换响应体
    if (reason && reason.response) {
      reason.response.data = transformData(
        reason.response.data,
        reason.response.headers,
        config.transformResponse
      );
    }
  }

  return Promise.reject(reason);
});

“若用户未手动配置适配器,则使用默认的适配器”。手动配置适配器允许我们自定义处理请求。

在实际开发中,我们使用默认适配器的频率更高。默认适配器在axios/lib/default.js里是通过getDefaultAdapter方法来获取的:

function getDefaultAdapter() {
  var adapter;
  // 判断当前是否是node环境
  if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // 如果是node环境,调用node专属的http适配器
    adapter = require('./adapters/http');
  } else if (typeof XMLHttpRequest !== 'undefined') {
    // 如果是浏览器环境,调用基于xhr的适配器
    adapter = require('./adapters/xhr');
  }
  return adapter;
}

http适配器:

module.exports = function httpAdapter(config) {
  return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
    // 具体逻辑
  }
}

xhr适配器:

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    // 具体逻辑
  }
}

除开具体逻辑,我们现在可以注意两点:

  1. 两个适配器的入参都是 config
  2. 两个适配器的出参都是一个 Promise

要是仔细读了源码,会发现两个适配器中的 Promise 的内部结构也是如出一辙

这么一来,通过 axios 发起跨平台的网络请求,不仅调用的接口名是同一个,连入参、出参的格式都只需要掌握同一套。这导致它的学习成本非常低,开发者看了文档就能上手;同时因为足够简单,在使用的过程中也不容易出错,带来了极佳的用户体验,axios 也因此越来越流行。

这正是一个好的适配器的自我修养——把变化留给自己,把统一留给用户。

在此处,所有关于 http 模块、关于 xhr 的实现细节,全部被 Adapter 封装进了自己复杂的底层逻辑里,暴露给用户的都是十分简单的统一的东西——统一的接口,统一的入参,统一的出参,统一的规则。用起来就很舒服。

代理模式

概念:一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。

实践:

  1. 科学上网

  2. 在 ES6 中,提供了专门以代理角色出现的代理器 —— Proxy。它的基本用法如下:

    const proxy = new Proxy(obj, handler)

    第一个参数是我们的目标对象,handler 也是一个对象,用来定义代理的行为。当我们通过 proxy 去访问目标对象的时候,handler 会对我们的行为作一层拦截,我们的每次访问都需要经过 handler 这个第三方。

下面是常见的四种代理类型:事件代理、虚拟代理、缓存代理和保护代理

事件代理

事件代理是代理模式最常见的一种应用方式,最常见的就是利用事件“冒泡”的特性,来让父元素代理子元素的事件。例如统一在父元素上绑定点击事件的回调函数。

虚拟代理

实践:图片预加载

除了图片懒加载,还有一种操作叫图片预加载。预加载主要是为了避免网络不好、或者图片太大时,页面长时间给用户留白的尴尬。常见的操作是先让这个 img 标签展示一个占位图,然后创建一个 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
    }
}

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

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

缓存代理

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

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

// 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)
    }
})()

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

保护代理

所谓“保护代理”,就是在访问层面做文章,在 getter 和 setter 函数里去进行校验和拦截,确保一部分变量是安全的。上节中我们提到的 Proxy,它本身就是为拦截而生的,所以我们目前实现保护代理时,考虑的首要方案就是 ES6 中的 Proxy。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions