美文网首页
js 中的 14 种设计模式(上)

js 中的 14 种设计模式(上)

作者: HoPGoldy | 来源:发表于2020-07-19 21:36 被阅读0次

前言

最近在读《JavaScript 设计模式与开发实践》,书中出现的设计模式对自己的思考都很有帮助,虽然其中有部分例子都略显过时。本文就是将书中的主要内容加以精简和概况并替换了一些老旧的例子,来便于日后回顾和思考。如果想更详细的了解一下,不妨买一本细读一下。而在正式开始之前需要先把本书的核心思想着重介绍一下,其实不仅是 js,很多语言的设计模式的核心都是如此:封装变化

《设计模式》 —— GoF

考虑你的设计中哪些地方可能变化,这种方式与关注会导致重新设计的原因相反。它不是考虑什么时候会迫使你的设计改变,而是考虑你怎样才能够 在不重新设计的情况下进行改变。这里的关键在于封装发生变化的概念,这是许多设计模式的主题。

简单理解就是:将日后可能会变动的代码从不会变动的代码中抽象出来并加以封装。而设计模式就是通过总结得出的最为高效优雅的封装模型。下面提到的所有模式都会对此概念有所涉及,所以最好配合下文细细理解。

还有一点需要注意的地方,某些设计模式经过抽象之后的代码会比原先的更多并且更加复杂。不要因此就单方面的觉得这种设计模式不好,代码量并不能说明代码的优劣。并且,请确保你对 闭包原型链 有最基础的了解,不然阅读本文可能会比较费劲。ok,接下来就废话少说,开始正文:

1、单例模式

如果一个对象在整个程序中只需要一个,例如线程池、全局缓存、页面中的登陆窗口等。那么它就应该只被实例化一次,后续直接使用该实例而不是重新创建。所以单例模式简单归纳一下就是:保证一个类仅有一个实例

例:创建一个页面唯一的 div。

// 使用闭包保存唯一实例
const CreateDiv = (function() {
    let instance

    // 真正被 new 的 CreateDiv 类
    const CreateDivClass = function() {
        if (instance) return instance

        this.init()
        return instance = this
    }

    CreateDivClass.prototype.init = function() {
        const div = document.createElement('div')
        document.body.appendChild(div)
    }

    return CreateDivClass
})()

然后使用上面的类创建实例:

// 在不同地方 new 了两个 CreateDiv
const a = new CreateDiv()
const b = new CreateDiv()

// 这两个实际上是同一个实例
console.log(a === b)

可以看到我们 new 了两个实例,但是这两个实例实际上是同一个。并且这里创建单例对象的写法和创建其他普通对象没有任何区别,我们不需要因为它是单例类就特别对待,这种实现被称为“透明”的单例类。

不过上面的写法有一点小问题,就是单例模式的实现和“创建 div”的实现耦合在了一起,如果我们想要再创建一个全局唯一的 span 对象,那就需要把这些代码整个复制一遍,这违反了 封装变化 的思想以及 单一职责 原则。接下来我们将不变的“单例模式实现”和可变的“创建 div 实现”进行解耦。这里实际上用到了另一个设计模式:代理模式

// 创建 div 的实现类
const CreateDiv = function() {
    this.init()
}

CreateDiv.prototype.init = function() {
    const div = document.createElement('div')
    document.body.appendChild(div)
}

// 单例模式的实现类
const ProxySingleton = (function() {
    let instance

    /**
     * @param {class} singleClass 要进行单例化的类
     * @param {array} args 要传递给单例类的参数
     */
    return function(singleClass, ...args) {
        if (!instance) instance = new singleClass(...args)

        return instance
    }
})()

接下来进行单例创建:

// 在不同地方 new 了两个 CreateDiv
const a = new ProxySingleton(CreateDiv)
const b = new ProxySingleton(CreateDiv)

// 这两个依旧是同一个实例
console.log(a === b)

可以看到“创建 div 的实现”和“单例模式的实现”被完全解耦了,以后如果想对其他的类进行单例化,只需要将其作为参数传递给 ProxySingleton 即可。

小结

通过闭包和自执行函数,保存唯一的实例,借此来实现单例化。并且通过创建代理类,将单例化实现独立出来,使其可以更加通用。

2、策略模式

当一个功能可以通过多种途径(算法)解决时,比如压缩文件可以用 zip 也可以用 rar。那么我们就可以使用策略模式进行设计。即:定义一系列功能,将它们进行封装,并使其可以相互替换

例:对一段用户输入进行校验。以下为反例实现:

/**
 * 输入校验
 * @param {string} content 要校验的内容
 * @returns {boolean} 是否通过校验
 */
const validator = function(content) {
    if (content === '') {
        console.log('输入不能为空')
        return false
    }
    if (content.length < 6) {
        console.log('输入不能少于 6 个字符')
        return false
    }
    if (!/(^1[3|5|7|8][0-9]{9}$)/.test(content)) {
        console.log('输入的不是有效的手机号')
        return false
    }
    return true
}

validator('123asbsdf') // 输入的不是有效的手机号
validator('13712345678') // true

这个实现有以下问题:

  • 包含太多重复的 if-else 语句。
  • 缺乏弹性,如果要添加新的规则,就需要深入 validator 的内部实现进行修改,违反了 开放-封闭原则
  • 没有有效封装,例如"输入不能少于 6 个字符"这个规则中的字符数量很容易发生变化,而这里并没有很好的将其抽取出来。

现在我们用策略模式进行重写,策略模式包含两个部分,一组策略实现以及一个环境类 Context。Context 接收用户请求,并根据用户需要将请求委托给某一策略。

// 校验策略组
const strategies = {
    // 输入是否为空
    isNonEmpty: (content, errorMsg) => content ? true : errorMsg,
    // 最小长度检查
    minLength: (content, length, errorMsg) => content.length >= length ? true : errorMsg,
    // 是否为手机号
    isMobile: (content, errorMsg) => /(^1[3|5|7|8][0-9]{9}$)/.test(content) ? true : errorMsg
}

/**
 * 检验类实现
 * 该类即为上文提到的环境类 Context
 * 
 * @param {array} rules 启用的校验规则组
 *   @property {string} strategy 校验规则及参数
 *   @property {string} errorMsg 校验失败时返回的信息
 */
const Validator = function(rules) {
    this.executors = []

    // 解析所有规则,并将其存放到执行器列表中
    rules.map(rule => {
        let strategyArgs = rule.strategy.split(':')
        const strategyName = strategyArgs.shift()

        // 注意这里,只是生成执行器函数并推入数组,并没有实际执行函数进行检查
        this.executors.push((content) => strategies[strategyName](content, ...strategyArgs, rule.errorMsg))
    })
}

/**
 * 对指定内容进行校验
 * 
 * @param {string} content 要进行校验的内容
 * @returns 校验成功返回 true,校验失败返回一个字符串数组,包含所有未通过的校验
 */
Validator.prototype.test = function(content) {
    const results = this.executors
        // 遍历所有执行器进行检查
        .map(executor => executor(content))
        // 剔除所有通过的检查
        .filter(result => result !== true)
    
    return results.length > 0 ? results : true
}

然后实例化 Validator 执行检查:

// 自定义设置检查规则
const validator = new Validator([{
    strategy: 'isNonEmpty',
    errorMsg: '输入不能为空'
}, {
    strategy: 'minLength:6',
    errorMsg: '输入不能少于 6 个字符'
}, {
    strategy: 'isMobile',
    errorMsg: '输入的不是有效的手机号'
}])

validator.test('123') // [ '输入不能少于 6 个字符', '输入的不是有效的手机号' ]
validator.test('13712345678') // true

可以看到可变的检查规则被抽象成了策略组,而校验器 Validator 中只剩下了不可变的逻辑。这使得后期维护时的边界更加清晰,极大的减少了维护成本。

小结

策略模式将需要经常维护策略抽象出来并进行了封装,很好的支持了 开放-封闭原则。让策略的边界更加清晰、也更利于理解和拓展。

3、代理模式

当用户代码因为某些原因不适合直接访问目标对象时,我们就可以通过创建中间代理对象的方式来解决这个问题。如下是一个非常常用的缓存代理:

例:带有缓存功能的求乘积函数。

/**
 * 求乘积
 * @param  {...number} args 要相乘的值
 */
const mult = function(...args) {
    return args.reduce((pre, cur) => pre * cur, 1)
}

// 闭包创建缓存
const proxyMult = (function() {
    const cache = {}
    return function(...args) {
        // 有缓存直接返回
        const cacheKey = args.join(',')
        if (cacheKey in cache) return cache[cacheKey]

        return cache[cacheKey] = mult(...args)
    }
})()

调用

console.log(proxyMult(2, 3, 4)) // 输出 24

你可能会有这种疑惑,仅仅是一个缓存功能,直接写在 mult 方法里不行么,为什么要额外引入代理模式?事实上,你可以这么写,但是这样违反了单一职责原则。缓存功能不一定是必须的,如果我们日后需要去除缓存功能,写在同一个方法里会加大去除的工作量。

并且这么写还有个优点,就是非侵入的给原先的方法增加了新的功能,原先的方法完全不会受到影响,这点类似于装饰器,并且也符合开放-封闭原则。

保护代理与虚拟代理

  • 当目标对象调用成本较高时,可以创建代理来为其拦截一些不必要的请求。这种代理方式被称为 保护代理
  • 当目标对象需要长时间的加载时,可以创建代理来在其未加载完成时暂时负责外部模块的调用(暂存请求并返回友好的提示 ),这种方式被称为 虚拟代理

代理模式还有如下应用:

  • 防抖与节流:通过创建代理来减少目标的调用次数,属于保护代理。
  • 懒加载中的虚拟代理:当一个功能需要长时间加载时,为了减少用户的等待时间,可以先创建一个代理对象,这个代理对象会正常接收用户的请求并进行缓存,等到该模块加载成功后再将请求递交过去进行处理。

上面的例子中为 mult 单独创建了一个代理对象,实际上我们可以稍加改进,创造一个更通用的代理:

例:使用工厂函数为不同的方法创建代理

// 求乘积
const mult = function(...args) {
    return args.reduce((pre, cur) => pre * cur, 1)
}

// 求加和
const plus = function(...args) {
    return args.reduce((pre, cur) => pre + cur)
}

// 创建代理的工厂函数
const createProxyFactory = function(fn) {
    const cache = {}
    return function(...args) {
        // 有缓存直接返回
        const cacheKey = args.join(',')
        if (cacheKey in cache) return cache[cacheKey]

        return cache[cacheKey] = fn(...args)
    }
}

然后就可以快速的创建代理:

const proxyMult = createProxyFactory(mult)
const proxyPlus = createProxyFactory(plus)

console.log(proxyMult(2, 3, 4)) // 24
console.log(proxyPlus(2, 3, 4)) // 9

小结

代理模式的应用十分广泛,在 js 中最常用的就是虚拟代理和缓存代理(两者都可以提高页面的响应速度)。不过代理模式属于“锦上添花”的功能,在实际开发中可以先只开发实际业务功能,当后期需要时再按照上面提到的编写代理对象或者工厂函数。

4、迭代器模式

迭代器模式非常常用,它提供了一种途径在不暴露对象内部表示的情况下访问其中的各个元素。在 js 中,已经存在了很多迭代器了,例如.map.reduce.find.filter等,而接下来就来更深入的了解下:

迭代器分为内部迭代器和外部迭代器,内部迭代器封装好了迭代的规则和形式,如下:

// 内部迭代器
const each = function(arr, callback) {
    for (let i = 0; i < arr.length; i ++) {
        callback.call(arr[i], i, arr[i])
    }
}

// 调用
each([1, 2, 3], (index, item) => console.log(`[${index}] > ${item}`))

// 输出
// [0] > 1
// [1] > 2
// [2] > 3

如上迭代器就属于内部迭代器,在调用时只需要调用一次即可。而外部迭代器的调用形式会稍微复杂一点,但是也由此提高了迭代的灵活度:

const Iterator = function(arr) {
    this.data = arr
    this.index = 0
}

// 迭代是否完成
Iterator.prototype.done = function() {
    return this.index >= this.data.length
}

// 执行迭代
Iterator.prototype.next = function() {
    this.index ++
}

// 获取当前元素
Iterator.prototype.getCurrent = function() {
    return this.data[this.index]
}
// 生成迭代器
const iterator = new Iterator([1, 2, 3])

while (!iterator.done()) {
    console.log(iterator.getCurrent())
    iterator.next()
}

// 输出
// 1
// 2
// 3

可以看到,在迭代器外部也可以自由控制是否迭代,这就给予业务逻辑更高的灵活度。两种迭代器没有优劣之分,如何使用要根据需求来决定。接下来我们通过书中的例子看一下迭代器的应用:

// 获取上传组件
const getUploadObj = function() {
    try {
        return new ActiveXObject('TXFTNActiveX.FTNUpload') // 使用 IE 控件生成上传组件
    }
    catch (e) {
        if (supportFlash()) {
            const str = '<object type="application/x-shockwave-flash"></object>' // flash 上传
            return $(str).appendTo($('body'))
        }
        else {
            const str = '<input name="file" type="file">' // 原生上传
            return $(str).appendTo($('body'))
        }
    }
}

虽然这个例子的内容略显老旧,但是依旧能看出为了实现上传组件的兼容能力,这段代码被硬塞进了 try/catch 和嵌套组成的 if,非常的丑陋,维护起来也需要修改函数的内部实现。接下来我们就用迭代模式的思想来重写这段代码:

// 使用 IE 控件生成上传组件
const getActiveUpload = function() {
    try {
        return new ActiveXObject('TXFTNActiveX.FTNUpload')
    }
    catch (e) {
        return false
    }
}

// flash 上传
const getFlashUpload = function() {
    if (!supportFlash()) return false

    const str = '<object type="application/x-shockwave-flash"></object>' 
    return $(str).appendTo($('body'))
}

// 原生上传
const getFormUpload = function() {
    const str = '<input name="file" type="file">'
    return $(str).appendTo($('body'))
}

// 使用 find 迭代器获取可用的上传组件
const uploadObj = [ getActiveUpload, getFlashUpload, getFormUpload ].find(fn => fn())

可以看到不同的获取方式都被抽象了出来,这样如果要兼容新的获取方式的话只需要新建新的方法并在下方的数组中添加即可。而关键的地方就在于最后生成时使用的 .find 迭代器,通过依次执行定义好的方法并判断其返回值,由于获取失败的方法都会返回 false,find 迭代器就会去执行下个方法直到获取到可用的上传组件(Object 类型会被当作 true 从而被迭代器返回出去)。

小结

迭代器在日常编码中非常常用,当要从多个不同的渠道获取一个目标对象时,迭代模式就可以很好的对代码进行解耦。

5、发布-订阅模式(观察者模式)

发布-订阅模式(以下统称为观察者模式 )广泛应用于异步编程中,尤其是 js,我们常用的回调函数就是基于该模式的(事件模型),它定义了对象之间的一种一对多的依赖关系,当一个对象状态发生变化时,所有订阅过它的对象都将得到通知。例如下例:

document.body.addEventListener('click', (div) => {
    console.log(div, '被点击')
})

例子中给 body 绑定了一个点击事件,其中的发布者就是 document.body,而订阅者(观察者)就是代码所处的模块,其中的 'click' 为要订阅的内容,第二个参数中的函数就是订阅内容更新后执行的逻辑。观察者模式非常适合进行模块之间的通信,两者之间不需要了解彼此的细节,出现新的订阅者时也不需要对发布者的代码进行修改。

接下来我们来创建一个可以为任何对象安装发布-订阅功能的函数:

const eventTemplate = {
    // 保存订阅
    clientList: {},
    
    /**
     * 订阅事件
     * @param {string} key 要订阅的内容
     * @param {function} fn 触发的回调
     */
    listen(key, fn) {
        if (!this.clientList[key]) this.clientList[key] = []

        this.clientList[key].push(fn)
    },

    /**
     * 移除订阅
     * @param {string} key 要删除的订阅事件
     * @param {function} fn [可选] 要移除的订阅,当为空时删除该 key 下的所有订阅
     */
    remove(key, fn) {
        const callbacks = this.clientList[key]
        if (!callbacks || callbacks.length === 0) return false

        // 为空时直接移除所有
        if (!fn) this.clientList[key] = []
        // 否则找到目标订阅将其移除
        else callbacks.find((callback, index) => {
            if (callback !== fn) return false

            callbacks.splice(index, 1)
            return true    
        })
    },

    /**
     * 触发订阅
     * @param {string} key 要触发的订阅
     * @param  {...any} args 要传递给回调函数的参数列表
     */
    trigger(key, ...args) {
        const callbacks = this.clientList[key]
        if (!callbacks || callbacks.length === 0) return false

        callbacks.map(callback => callback(...args))
    }
}

// 给指定对象安装发布-订阅功能
const installEvent = function(obj) {
    for (const key in eventTemplate) obj[key] = eventTemplate[key]
}

然后我们就使用installEvent来为目标对象安装功能:

const targetObj = {}
installEvent(targetObj)

// 订阅事件
const onClick = (...args) => console.log('click 触发!', args)
targetObj.listen('click', onClick)
targetObj.listen('click', (...args) => console.log('click 再次触发!', args))

// 触发事件
targetObj.trigger('click', 1, 2, 3)

// 移除事件
targetObj.remove('click', onClick)
targetObj.trigger('click', 1, 2, 3)

必须要先订阅再发布么?

在刚才的实现中,如果想要接收事件更新的话就必须提前订阅好该事件,那么如果一个订阅者都没有呢?发布者发布出去的消息都会被直接丢弃再也找不到了,这在某些需求中是不被允许的(想象一下在 QQ 中有人给发消息,但是你不在线,这些消息就直接被丢弃了 )。为了解决这个问题,可以在 trigger 触发事件时进行检查,如果一个订阅者都没有的话,就先把消息压入到一个缓存栈中,等到有订阅了再一次性全部弹出去。

订阅者的作用域

为了简单起见,有些时候会设计一个全局的总线来统一订阅和发布事件更新,这种设计虽然简单但是不一定时最好的,如果发布者和订阅者的数量太多时,我们就会失去对消息流向的把控(想象一下把所有电线都缠在一起 ),这对于后期维护也是不利的。

小结

发布-订阅模式实现了时间上的解耦(两者都不用时刻监听彼此的动态)和 对象间的解耦(不需要为了和其他模块通信进行硬编码)。这对于像 js 这种异步编程语言来说帮助非常大。但是发布-订阅模式优点也是缺点,这种松耦合会使得对象间的联系变得更加难以发现,当发布者和订阅者的数量增多或嵌套在一起时,对于问题的追踪将变得更加困难。

6、命令模式

命令模式就是把一个个的事务封装成对象,这个对象上拥有事务的基本信息以及如何执行、如何撤销等操作,如下:

// 一个餐馆的订单命令对象
{
    // 基本信息,订了什么,订了多少
    food: [
        { type: '...', number: 1 },
        { type: '...', number: 2 },
        // ...
    ],
    address: '要送到的地方',
    // 开始做菜
    execute: () => { /** ... */ },
    // 订单被取消
    undo: () => { /** ... */ },
    // 更多操作...
}

通过把一个事务进行封装,我们就可以在此之上执行一些更高级的操作,例如同时发布多个命令、创建命令队列、撤销指定命令等。接下来我们就来实现一个可以撤回重做的按键功能,首先封装单个按键命令:

// 输入的内容
let input = ''

// 封装单个按键命令
const TypeCommand = function (key) {
    this.key = key
}

// 按下按键触发的逻辑
TypeCommand.prototype.execute = function() {
    input += this.key
}

// 撤销按键触发的逻辑
TypeCommand.prototype.undo = function() {
    input = input.substr(0, input.length - 1)
}

然后我们使用按键命令来制作键盘,键盘可以打字、撤销以及重做:

// 实现键盘
const keyboard = {
    // 按键命令队列
    typeList: [],
    index: 0,
    // 打字
    type(...keys) {
        // 如果打字之前撤回了,那撤回的内容就会被丢弃
        this.typeList.splice(this.index, this.typeList.length - 1 - this.index)

        // 把 keys 制作成按键命令并执行
        const commands = keys.map(key => {
            const typeCommand = new TypeCommand(key)
            typeCommand.execute()
            return typeCommand
        })

        // 将按键命令推入队列
        this.typeList.push(...commands)
        this.index = this.typeList.length - 1
    },
    /**
     * 撤回
     * @param {number} time 撤回的次数
     */
    undo(time) {
        for (let i = 0; i < time; i++) {
            if (this.index - 1 < 0) break

            this.typeList[this.index--].undo()
        }
    },
    /**
     * 重做
     * @param {number} time 重做的次数
     */
    redo(time) {
        for (let i = 0; i < time; i++) {
            if (this.index + 1 >= this.typeList.length) break

            this.typeList[++this.index].execute()
        }
    }
}

做好了之后我们来打字:

keyboard.type('W', 'S', 'A', 'D') // WSAD
keyboard.type('W', 'S', 'A', 'D') // WSADWSAD
keyboard.undo(5) // WSA
keyboard.redo(20) // WSADWSAD

可以看到,在上面的实现中,keyboard 做的事情只是对队列中的 TypeCommand 进行管理,并在适合的时候调用其上的 executeundo 方法。这么做也恰好将可变和不变逻辑进行了解耦,比方说我们打字后不是把内容插入 input 而是写入某个文件,这时只是按键命令发生了变化,而键盘的打字撤销重做功能并没有变化,所以我们只需要对 TypeCommand 进行修改即可。

宏命令

由于命令模式就是把各个事务封装成对象,并且对外提供了相同的操作方式(execute 和 undo 等方法)。所以我们也可以简单的通过命令模式实现宏:

// 定义指令
const commandCloseDoor = { execute: () => console.log('关门') }
const commandOpenPC = { execute: () => console.log('启动电脑') }
const commandStartQQ = { execute: () => console.log('启动 QQ') }

const Macro = function() {
    return {
        commandList: [],
        add(cmd) {
            this.commandList.push(cmd)
        },
        execute() {
            this.commandList.forEach(cmd => cmd.execute())
        }
    }
}

// 生成宏
let macro = new Macro()
macro.add(commandCloseDoor)
macro.add(commandOpenPC)
macro.add(commandStartQQ)

// 执行宏
macro.execute()

然后我们就可以看到我们组装的宏依次执行了其中包含的功能。

小结

当我们需要实现一堆事务,然后对这些事务进行更高层级的控制时,就可以使用命令模式。命令可以执行、回滚或执行其他功能。并且,由于 js 的高阶函数特性,我们可以直接把命令定义成一个函数而不是包含 execute 方法的对象。并由此更加简单的实现命令模式。

7、组合模式

在程序设计中,有时候会遇到“一些事务是由相同的子事务构成的,而这些事务又能作为更大事务的子事务”这种需求,例如上一章中提到的宏命令,一个宏是由宏指令构成,而这个宏又可以作为更大的宏的子指令。宏之间相互组合,构筑成了一颗树。这个组合的概念就是本章要讲的组合模式。

组合模式的关键在于 整棵树上的每一个节点都有相同的接口规范,还是拿上一章的宏作为例子,无论是没有实际功能,只是作为代理的 macro 对象(枝节点),还是完成具体工作的 command* 对象(叶节点)。它们上面都是有相同的 execute 方法的,也就是只需要无脑调用子节点的 execute 方法即可深度优先的遍历完整棵树。

其实我们电脑中的文件夹存放就是典型的组合模式。文件夹中可以存放文件和其他文件夹,而当我们复制文件夹时,其中包含的所有文件都会被复制,那么接下来我们就来实现文件的添加和显示功能:

// --------------------- 实现文件夹 ---------------------
const Folder = function(name) {
    this.name = name
    this.files = []
}

Folder.prototype.add = function(file) {
    this.files.push(file)
}

Folder.prototype.show = function() {
    console.log('打开文件夹', this.name)
    this.files.forEach(file => file.show())
}

// --------------------- 实现文件 ---------------------
const File = function(name) {
    this.name = name
}

File.prototype.add = function() {
    throw new Error('不能在文件中添加文件')
}

File.prototype.show = function() {
    console.log('  -', this.name)
}

注意我们为 File 也添加了 .add 方法,这可以让那些想在文件中保存文件的人知道自己不小心做错事了。接下来我们创建一个简单的文件结构:

const folder = new Folder('D:')
const folder1 = new Folder('学习资料')
const folder2 = new Folder('游戏')

const file = new File('Node.js')
const file1 = new File('HTML')
const file2 = new File('Minecraft.exe')

folder.add(folder1)
folder.add(folder2)
folder1.add(file)
folder1.add(file1)
folder2.add(file2)

然后执行 folder.show() 就可以直接查看该目录下的所有文件夹和文件了:

打开文件夹 D:
打开文件夹 学习资料
  - Node.js
  - HTML
打开文件夹 游戏
  - Minecraft.exe

而诸如文件 / 文件夹的复制、移动操作就不再赘述了。但是还有一个操作需要注意以下,那就是文件的删除操作。如果我们直接删除该文件的话,并不会通知存放该文件的文件夹。所以当文件夹执行其他操作遍历到该文件(已经被删除了)时,就会出现意料之外的问题。总结一下就是 我们的树只能自上而下遍历,无法把信息从子节点传递至父节点。而解决这个问题的办法,就是接下来要讲的 父对象引用

父对象引用

故名思意,父对象引用的意思就是在自己身上保存父级节点的引用,这样当自己有什么更新需要往上通知时就可以通过该引用访问到父级节点,接下来我们为文件夹实现删除功能:

// ...

// 在添加时给子文件设置父节点为自己
Folder.prototype.add = function(file) {
    file.parent = this
    this.files.push(file)
}

// 删除操作
Folder.prototype.remove = function() {
    if (this.parent) {
        // 如果有父节点的话就在其文件列表中找到自己并移除
        this.parent.files.find((file, index) => {
            if (file !== this) return false

            this.parent.files.splice(index, 1)
            return true    
        })
    }

    console.log(`\n ${this.name} 已删除 \n`)
}

// ...

注意 .remove 中通过 this.parent 访问到了自己的父节点。然后测试一下:

const folder = new Folder('D:')
const folder1 = new Folder('学习资料')
const folder2 = new Folder('游戏')

const file = new File('Node.js')
const file1 = new File('HTML')
const file2 = new File('Minecraft.exe')

folder.add(folder1)
folder.add(folder2)
folder1.add(file)
folder1.add(file1)
folder2.add(file2)

folder1.remove()
folder.show()

// 打开文件夹 D:
// 打开文件夹 学习资料
//   - Node.js
//   - HTML
// 打开文件夹 游戏
//   - Minecraft.exe

//  学习资料 已删除

// 打开文件夹 D:
// 打开文件夹 游戏
//   - Minecraft.exe

其中有一点需要注意的是,上面的实现里为了简单起见我们直接在子节点里访问了父文件夹的文件列表。在实际开发中这其实属于“越权”行为。正确的做法应该是子节点访问父节点的某个形如 this.parent.remove(this) 的方法通知父节点,然后由父节点完成对自己的删除。

不适合组合模式的场景

我们都知道组合模式最终会形成一颗树,而树的子节点只会有一个父节点,这保证了它只会被调用一次。而某些场景下子节点不一定只有一个父节点。例如公司到部门再到小组再到员工,这个架构看起来很适合组合模式,但是其中某些员工可能隶属于多个小组,这就可能导致该员工会对一个命令执行多次。因此在设计时一定要考虑到节点之间的关系问题。

小结

组合模式对于实现重复性高的系统十分有效,通过相同的接口,像搭积木一样就可以完成系统的搭建。但是组合模式并不是十全十美的,由于节点之间十分相似,对于问题出现在那个节点上以及影响范围并不好判断,并且由于请求会不断的向下传播,当树中的节点过多时可能会导致系统的负载过大。

8、模板方法模式

模板方法是一种基于继承的设计模式,该设计由两部分组成,一部分是抽象的父类,封装了子类的通用逻辑,包括公用方法和这些方法的执行顺序。另一部分是具体的子类,实现了父类中的抽象方法。我们通过这种方式将多个类中的重复代码抽象出来,来提升代码的健壮性。

我们拿书中的咖啡与茶例子进行说明,泡茶和泡咖啡的流程可以进行如下抽象:

泡咖啡 泡茶 抽象
把水煮沸 把水煮沸 把水煮沸
用沸水冲泡咖啡 用沸水冲泡茶叶 用沸水冲泡饮品
把咖啡倒入杯子 把茶倒入杯子 把饮料倒入杯子
加糖和牛奶 加柠檬 加佐料

可以看到,虽然泡咖啡和泡茶的具体操作不一样,但是都能抽象出一套统一的流程,那么我们就把这个统一流程中相同的部分抽取出来作为模板,然后通过模板实现具体的泡茶和泡咖啡。如下:

const Beverage = function(param) {
    // 泡饮料的四个步骤
    const boilWater = () => console.log('把水煮沸')
    const brew = param.brew || (() => { throw new Error('必须传递 brew 方法') })
    const pourInCup = param.pourInCup || (() => { throw new Error('必须传递 pourInCup 方法') })
    const addCondiment = param.addCondiment || (() => { throw new Error('必须传递 addCondiment 方法') })

    const F = function() {}
    // 定义模板方法
    F.prototype.init = function() {
        boilWater()
        brew()
        pourInCup()
        addCondiment()
    }

    return F
}

我们把上面四步定义成了boilWaterbrewpourInCupaddCondiment四个方法,注意后面三个方法被默认定义为了一个会抛出异常的函数,这是因为想要泡指定的饮料的话这三步是必须指定的,有一个为空就无法完成具体的工作。目前的 Beverage 还是不能用的,接下来我们生成实际的类:

const Coffee = Beverage({
    brew: () => console.log('用沸水冲泡咖啡'),
    pourInCup: () => console.log('把咖啡倒入杯子'),
    addCondiment: () => console.log('加糖和牛奶')
})

const Tea = Beverage({
    brew: () => console.log('用沸水浸泡茶叶'),
    pourInCup: () => console.log('把茶倒入茶杯'),
    addCondiment: () => console.log('加柠檬')
})

const coffee = new Coffee()
coffee.init()

const tea = new Tea()
tea.init()

这里的 Beverage 就是模板,我们给它传递了泡咖啡 / 泡茶的后三步的具体步骤,然后由此生成了可用的具体类。而其中关键的一点就是其中的 .init 方法,这个方法规定了具体类中方法的执行流程,所以在创建具体类的时候就只需要把抽象方法实现即可,不用关系这些方法到底是谁先谁后。这个方法就是本章标题中提到的“模板方法”。

应用场景

虽然模板方法模式比较简单,但是它可以完成模块层面的抽象。例如很多模块都有生命周期,onCreatedonMountedonRemoved 等等。这些生命周期都是顺序固定但具体实现不同的,那么就可以使用模板方法模式将整体的框架抽象出来,然后由此生成具体的模块。

好莱坞原则

当有新人演员想要到好莱坞碰碰运气时,他们都会向演艺公司提交自己的简历,然后回家等待。而演艺公司会决定什么时候用哪些新人。这时候如果新人打电话想要询问情况时,演艺公司往往会回答:不要给我们打电话,我们会给你打电话(don't call us, we'll call you)。

模板方法模式就是好莱坞原则的经典使用场景,上面泡茶的例子中,TeaCoffee就是新人演员,创建时传递给 Beverage 的参数就是自己的简历。 当使用这种模式时,具体类就放弃了对自己的控制权,而 Beverage 就像演艺公司一样规定了新人演员应该做什么:“不要调用我,我们会调用你”。

小结

模板方法模式是一种非常典型的封装变化的设计模式,封装不会变动的整体调用顺序和通用方法,然后把可变的具体执行逻辑下放给具体类进行实现,从而降低了开发新功能时的代码成本。而 js 中也不需要严格的按照定义抽象类 - 实现抽象类的顺序实现该模式,上文例子中的高阶函数显然更加适合 js。

总结

本文介绍了如下 14 种设计模式中的前 8 种:

减轻代码复杂度的单例模式;解耦不同实现的策略模式;提高系统可靠性和简单性的代理模式;减轻遍历复杂度的迭代模式;提供了更好交流模型的发布-订阅模式;对不同事务进行了封装的命令模式;由简单到复杂的组合模式以及抽象框架的模板方法模式。

接下来你可以点击 JS 中的 14 种设计模式(下)来继续阅读。

相关文章

网友评论

      本文标题:js 中的 14 种设计模式(上)

      本文链接:https://www.haomeiwen.com/subject/vfhexktx.html