javaScript设计模式

501 阅读6分钟

前言

阅读了javascript设计模式这本书,觉得作者写的挺实用的,我们开发过程中往往太注重是否实现了结果,而忽略了函数算法的设计思路,以下是阅读过程中做的笔记,跟大家分享。

单例模式

单例模式的定义:

保证一个类仅有一个实例,并提供一个访问它的全局访问点。

简单的例子

要求:实现一个CreateDiv的类,负责在页面中创建唯一的div节点。

var CreateDiv = function(html) { // 负责创建div
  this.html = html
  this.init()
}

CreateDiv.prototype.init = function(){ // 原型方法
  var div = document.createElement('div')
  div.innerHtml = this.html
  document.body.appendChild(div)
}

var ProxySingletonCreateDiv = (function(){ // 代理类,负责判断是否唯一
  var instance;
  return function(html) {
    if(!instance){
      // 返回一个CreateDiv实例
      instance = new CreateDiv(html)
    }
      return instance
  }
})()


var a = new ProxySingletonCreateDiv('test1')
var b = new ProxySingletonCreateDiv('test2')

// true
console.log(a === b)

全局变量不是单例模式

在上面的例子中,我们可以看到instance是用闭包的形式实现唯一性并提供全局访问。而不是直接使用全局变量。

全局变量

在js开发中,通常会把全局变量当成单例来使用,但是全局变量很容易造成命名空间污染问题,也很容易被覆盖,所以作为开发者,我们有必要减少全局变量的使用,以下几种方式可以相对降低全局变量带来的命名污染。

使用命名空间

适当地使用命名空间,并不会杜绝全局变量,但可以减少全局变量的数量。

var namespace = {
  a: function(){ console.log('a') },
  b: function(){ console.log('b') }
}
namespace.a()
namespace.b()

使用闭包封装私有变量

把一些变量封装在闭包的内部,只暴露一些接口跟外界通信

var user = (function(){
  // 用下划线来约定私有变量,封装在闭包产生的作用域中,外部访问不到这两个变量,避免对全局的命令污染
  const _name = 'xiaoli'
  const _age = 18
  
  return {
    getUserInfo: function() {
      return `名字:${_name}, 年龄:${_age}`
    }
  }
  
})()

// 名字:xiaoli, 年龄:18
console.log(user.getUserInfo())

惰性单例

惰性单例指的是在需要的时候才创建对象实例。简单来说就是需要的时候才创建,而不是页面加载好的时候就创建。

网站的登录浮窗

  • 把不变的逻辑隔离出来
  • 遵循单一职责原则
// 创建浮窗逻辑
const createLoginLayer = () => {
  const div = document.createElement('div')
  div.innerHTML = '登录浮窗'
  div.style.display = 'none'
  document.body.appendChild(div)
  return div
}

// 创建提示
const createTipsLayer = () => {
const div = document.createElement('div')
div.innerHTML = '提示浮窗'
div.style.display = 'none'
document.body.appendChild(div)
return div
}

// 判断浮窗是否已经被创建, 把创建对象的方法fn当成参数动态传入getSingle函数,
// 这样我们不仅能传入createLoginLayer,还能传入createTipsLayer、createIframe等。
const getSingle = (fn) => {
  let result = null
  return function() {
    return result || (result = fn.apply(this, arguments))
  }
}

// 调用
const createSingleLoginLayer = getSingle(createLoginLayer)
const createSingleTipsLayer = getSingle(createTipsLayer)

document.getElementById("loginBtn").onclick = () => {
   const loginLayer = createSingleLoginLayer();
   loginLayer.style.display = 'block'
}

document.getElementById("tipsBtn").onclick = () => {
   const outLayer = createSingleTipsLayer();
   outLayer.style.display = 'block'
}

策略模式

策略模式的定义

定义一系列的算法,把他们一个个封装起来,并且它们可以相互替换。目的就是将算法的使用与算法的实现分离开来。

简单例子

要求:为公司员工计算年终奖,绩效S的人年终奖有4倍工资,绩效为A的人年终奖有3倍工资,绩效为B的人年终奖是2倍工资。

方法1

通常有人使用if else,不推荐(这里就不把例子写出来了)

方法2:使用策略模式

使用策略模式,可以消除程序中大片的条件分支语句,所有跟计算奖金的逻辑不会放在Context中,而是分布在各个策略对象中,每个策略对象负责的算法被封装在对象的内部。

// 奖金算法
var strategies = {
  "S": function(salary){
      return salary * 4
  },
  "A": function(salary){
      return salary * 3
  },
  "B": function(salary){
      return salary * 2
  }
}
// 计算奖金
var calculateBonus = function(level, salary) {
  return strategies[level](salary)
}
// 调用
console.log(calculateBonus('S', 20000))

使用策略模式实现表单校验

下面我们来看下平时比较常见的一个业务场景:表单校验。

// html代码
<body>
    <form id="registerForm" method="POST">
        请输入用户名:<input type="text" name="userName" />
        请输入密码:<input type="password" name="password" />
        请输入手机号:<input type="password" name="phoneNumber" />
        <button>提交</button>
    </form>
</body>

常见的处理逻辑

缺点:

  • 这样的registerForm.onsubmit函数比较庞大,包含了很多if-else语句,这些语句需要覆盖所有的校验规则
  • 并且registerForm.onsubmit函数缺乏弹性,如果新增了一种校验规则,或者想把密码的长度校验从6改成8,我们都必须深入registerForm.onsubmit函数的内部实现,这违反了开放-封闭原则。
  • 算法复用性差,如果在程序中增加了另外一个表单,这个表单也需要进行一些类似的校验,那我们很可能将这些校验逻辑复制的漫山遍野。
<script>
    const registerForm = document.getElementById("registerForm")
    registerForm.onsubmit = function () {
        if (registerForm.userName.value === '') {
            console.log('用户名不可为空')
            return false
        }
        if (registerForm.password.value.length < 6) {
            console.log('密码不能少于6位')
            return false
        }
        if (!/(^1[3|5|8][0-9]{9}$)/.test(registerForm.phoneNumber.value)) {
            console.log('手机号格式不正确')
            return false
        }
    }
</script>

使用策略模式重构表单校验

使用策略模式重构代码后,我们可以通过配置的方式就可以完成表单的校验,这些校验规则也可以复用在程序的任何地方,还能作为插件的形式,方便移植到其他的项目中。

<script>
    const registerForm = document.getElementById("registerForm")
    // 把校验逻辑都封装成策略对象
    const strategies = {
        isNonEmpty: function (value, errorMsg) { // 不为空
            if (value === '') {
                return errorMsg
            }

        },
        minLength: function (value, length, errorMsg) { // 限制最小长度
            if (value.length < length) {
                return errorMsg
            }
        },
        isMobile: function (value, errorMsg) { // 手机号码格式
            if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
                return errorMsg
            }
        }
    }


    // 向Validator发送请求(在实现Validator类之前,我们先思考并定义用户如何向Validator类发送请求,有助于编写Validator类)
    const validataFunc = function () {
        // 创建一个validator对象
        const validator = new Validator()

        // 添加校验规则
        validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空!')
        validator.add(registerForm.password, 'minLength:6', '密码长度不能少于6位!')
        validator.add(registerForm.phoneNumber, 'isMobile', '手机号码格式不正确!')

        const errorMsg = validator.start()

        return errorMsg
    }

    // 定义一个验证类
    const Validator = function () {
        // 保存校验规则
        this.cache = []
    }
    Validator.prototype.add = function (dom, rule, errorMsg) {
        // 把策略和参数分开
        const ary = rule.split(":")
        // 把校验的步骤用空函数包装起来,并且放入cache
        this.cache.push(function () {
            // 获取策略
            const strategy = ary.shift()
            // 把input的value添加进参数列表
            ary.unshift(dom.value)
            // 把errorMsg添加进参数列表
            ary.push(errorMsg)
           
            return strategies[strategy].apply(dom, ary)
        })
    }
    // 开始校验,并取得校验后的返回信息
    Validator.prototype.start = function () {
        for (let i = 0; i < this.cache.length; i++) {
            const validatorFunc = this.cache[i]
            const msg = validatorFunc()
            if (msg) {
                return msg
            }
        }
    }


    registerForm.onsubmit = function () {
        var errorMsg = validataFunc()

        if (errorMsg) {
            console.log(errorMsg)
            return false
        }
    }
</script>

给某个文本输入框添加多种校验规则

上面的例子只满足了给某个文本添加一种规则,当我们想给某个文本添加多种规则时如何实现呢?例如我们期望如下:

    validator.add(registerForm.userName, [{
        strategy: 'isNonEmpty',
        errorMsg: '用户名不为空'
    }, {
        strategy: 'minLength:10',
        errorMsg: '密码长度不能少于10位!'
    }])
  • 其实很简单我们只要稍微修改下Validator.prototype.add的接收参数的类型
   Validator.prototype.add = function (dom, rules) {
        for (let i = 0; i < rules.length; i++) {
            const rule = rules[i]
            const { errorMsg } = rule
            // 把策略和参数分开
            const ary = rule.strategy.split(":")
            // 把校验的步骤用空函数包装起来,并且放入cache
            this.cache.push(function () {
                // 获取策略
                const strategy = ary.shift()
                // 把input的value添加进参数列表
                ary.unshift(dom.value)
                // 把errorMsg添加进参数列表
                ary.push(errorMsg)

                return strategies[strategy].apply(dom, ary)
            })

        }
    }

用户调用

        // 添加校验规则
        validator.add(registerForm.userName, [{
            strategy: 'isNonEmpty',
            errorMsg: '用户名不为空'
        }, {
            strategy: 'minLength:10',
            errorMsg: '用户名长度不能少于10位!'
        }])

        validator.add(registerForm.password, [{
            strategy: 'minLength:6',
            errorMsg: '密码长度不能少于6位!'
        }])

        validator.add(registerForm.phoneNumber, [{
            strategy: 'isMobile',
           errorMsg: '手机号码格式不正确!'
        }])

策略模式的优缺点

  • 优点
  • 可以有效地避免多重条件选择语句
  • 提供了对开放-封闭原则的支持,将算法独立于strategy中,使它们易于切换、理解、拓展
  • 策略模式可以复用在系统任意地方,避免重复的复制粘贴工作
  • 策略模式中利用组合和委托让Context拥有执行算法的能力,这也是继承的一种更轻便的替代方案
  • 缺点 使用策略模式,必须了解strategy算法,才能选择一个合适的strategy,违反了最少知识原则。

代理模式

代理模式的定义

代理模式是为了一个对象提供一个代用品或者占位符,以便控制对它的访问

虚拟代理

虚拟代理实现图片预加载,当网络不佳或者图片过大时图片位置往往有段时间会是一片空白,常见的做法可以先用一张loading图片占位,然后用异步的方式加载图片,等图片加载好了再把它填充到img节点里。我们可以设置下网速测试下。

   const myImage = (function () {
        const imgNode = document.createElement('img');
        imgNode.width = 500
        imgNode.height = 600
        document.body.appendChild(imgNode);

        return {
            setSrc: function (src) {
                imgNode.src = src
            }
        }
    })();

    const proxyImage = (function () {
        var img = new Image;
        img.onload = function () {
            myImage.setSrc(this.src)
        }

        return {
            setSrc: function (src) {
                // 占位图片在真正图片加载好之前,先把img节点的src设置成地的一张照片
                myImage.setSrc('./test.jpg')
                img.src = src
            }
        }
    })();

    proxyImage.setSrc('https://cdn-qa.tailwinds.cn/yfPortal/有风助手 (1)_1616390939013.png')

代理的意义

也许大家会有疑问,上面虚拟代理实现图片预加载其实不需要引用任何代理模式也能办到,那么引入代理模式的好处究竟在哪里?
如果直接在myImage实现图片预加载,其实违反了单一职责原则,例如很多年后的网速根本就不再需要再预加载,我们希望把预加载这段代码删掉这个时候就不得不改动myImage,而应用了代理它们可以各自变化而不影响对方,如果不需要预加载了只需要改成请求本体而不是请求代理对象即可。

代理和本体接口的一致性

例如前面说到的,如果有一天不需要预加载,那么就不再需要代理对象,可以选择直接请求本体
其中关键是代理对象和本体都对外提供了setSrc方法,在用户可来代理对象和本体对象是一致的,代理接手请求过程对于用户来说是透明的,用户并不清楚代理和本体的区别

  • 用户可以放心地请求代理,他只关心是否能得到想要的结果
  • 在任何使用本体的地方都可以替换成代理,代理和本体可以被替换使用

缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前的一致,则可以直接返回前面存储的运算结果。

    // 用于求乘积的函数
    const mult = function () {
        console.log('开始计算')
        let a = 1
        for (let i = 0; i < arguments.length; i++) {
            a = a * arguments[i]
        }
        return a
    }
 
    // 通过增加缓存代理的方式,mult函数可以专注自身职责-计算乘积,缓存功能是由代理实现的 
    const proxyMult = (function () {
        const cache = {}
        return function () {
            const args = Array.prototype.join.call(arguments, ',')
            if (args in cache) {
                return cache[args]
            }
            return cache[args] = mult.apply(this, arguments)

        }
    })()
    console.log(proxyMult(1, 2, 3, 4))
    // 第二次调用proxyMult本体mult并没有被计算,proxyMult直接返回了之前缓存好的计算结果
    console.log(proxyMult(1, 2, 3, 4))

发布订阅模式

发布订阅模式的定义

它定义对象间的一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都将得到通知。


function Event() {
    // 事件列表
    this.clientList = {};
}

// 订阅事件的函数,动态往事件列表里面添加内容
Event.prototype.listen = function ({ key, fn }) {
    if (!this.clientList[key]) {
        this.clientList[key] = []
    }
    this.clientList[key].push(fn);
};

// 触发事件订阅,遍历执行事件列表函数
Event.prototype.trigger = function ({ key, data }) {

    const fns = this.clientList[key];

    if (!fns || !fns.length) {
        return false
    }

    fns.forEach(item => {
        item(data);
    });
}

const eventA = new Event();
const eventB = new Event();

eventA.listen({
    key: 'eventA', fn: function (data) {
        console.log('价格:', data)
    }
})

eventA.listen({
    key: 'eventB', fn: function (data) {
        console.log('平方:', data)
    }
})

eventA.trigger({ key: 'eventA', data: 900 })

setTimeout(() => {
    eventA.trigger({ key: 'eventB', data: 100 })
}, 3000)


tips

在js开发中虚拟代理和缓存代理比较常用,但其实我们在编写业务代码的时候,往往不需要去预先猜测是否需要使用代理模式,当真正发现不方便直接访问某个对象的时候,再编写也不迟。

最后

感谢大家阅读,如有问题欢迎纠正!