JavaScript - 从设计原则深入学习设计模式

122 阅读31分钟

理解设计模式前需要具备一些基础知识,包括但不限于:面向对象的概念、this、call和apply的应用、闭包和高阶函数等。

设计原则:是指在编写JavaScript代码时应当遵循的一系列准则,这些原则旨在提高代码的可读性、可维护性、可扩展性和可重用性。适合JavaScript常用的原则有:单一职责原则、最少知识原则、开放-封闭原则、代码重构原则等。

设计模式:是为了解决常见问题而采用的一系列最佳实践、结构和模板,旨在写出高可维护性的代码。模式的社区一直在发展,GoF在1995年提出了23种设计模式,但不仅仅局限于这23种。而在JavaScript开发中更常见的有以下14种:单例模式、策略模式、代理模式、迭代器模式、发布订阅模式、命令模式、组合模式、模板方法模式、享元模式、职责链模式、中介者模式、装饰者模式、状态模式、适配器模式。

可以说每种设计模式都是为了让代码迎合其中一个或者多个设计原则而出现的,它们本身已经融入到了设计模式之中,给面向对象编程指明了方向。

单一职责原则

一个对象(方法)应该只做一件事。该原则可以通过这些设计模式来表现,例如:单例模式、代理模式、迭代器模式和装饰者模式。

单例模式

保证一个类只用一个实例,并提供全局访问。比如:全局缓存、浏览器中的window对象、全局弹窗、vuex的store、全局loading等。 要实现一个标准的单例模式并不复杂、就是用一个变量来标志当前是否已经为某个类创建过对象,如果是,则下一次获取该类的实例时,直接返回之前创建的对象。

// 根据单一职责原则,创建对象的方法fn作为参数动态传入管理单例逻辑的getSingle方法中
var getSingle = function (fn) {
    var result;
    // 返回一个新函数,用一个变量result来保存fn的结果,这样result就身在闭包中,永远不会被销毁。
    return function () {
        // 如果result已经被赋值,那么它将返回这个值
        return result || (result = fn.apply(this, arguments))
    }
}

// 创建登录弹窗的方法
var createLoginLayer = function () {
    var popup = document.createElement('div');
    popup.innerHTML = '我是登录弹窗';
    popup.style.display = 'none';
    document.body.appendChild(popup);
    return popup;
}

var createSingLoginLayer = getSingle(createLoginLayer);
document.getElementById('loginBtn').onclick = function () {
    var loginLayer = createSingLoginLayer();
    loginLayer.style.display = 'block';
}

// 创建唯一的iframe用于动态加载第三方页面
var createSingleIframe = getSingle(function () {
    var iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    return iframe;
});

document.getElementById('loginBtn').onclick = function () {
    var loginLayer = createSingleIframe();
    loginLayer.src = 'http://baidu.com';
}

以上示例把实例对象的职责和管理单例的职责分别放置在两个方法里,这两个方法可以独立变化而互不影响,结合在一起的时候就完成了创建唯一实例对象的功能。

代理模式

为一个对象提供一个代用品或占位符。以便控制对它的访问

  • 保护代理:用于控制不同权限的对象对目标对象的访问;vue3中实现的proxy虽然也是拦截对象操作,阻止对某些属性的访问或修改,或者在对属性进行修改时执行额外的验证逻辑,虽然类似于保护代理,但更多地与实现响应式系统和数据绑定相关,而没有直接对应保护代理的概念。
  • 虚拟代理:把一些开销很大的对象,延迟到真正需要它的时候才去创建。

例如:虚拟代理实现图片预加载:先用一张loading图片占位,然后用异步的方式加载图片,加载完了再把它填充到img节点中。

// 自执行函数,创建img标签
// myImage为返回的闭包函数,可以用来设置src
var myImage = (function () {
    var imgNode = document.createElement('img');
    document.appendChild(imgNode);
    return function (src) {
        imgNode.src = src;
    }
});
// 代理 自执行函数,创建Image图片加载实例
// proxyImage为闭包函数,可以为img标签设置src和进行图片预加载。
var proxyImage = (function () {
    var img = new Image;
    img.onload = function () {
        myImage(this.src);
    }
    return function (src) {
        myImage('./img.png');
        img.src = src;
    }
});
proxyImage('http://imgxxx.png')

如果抛开代理,也可以一个方法实现图片懒加载,但实际上,我们给img节点设置src,预加载图片只是一个锦上添花的功能,而使用代理的意义就体现在,代理负责预加载图片,预加载操作完成之后,把请求重新交给本体myImage。给img设置src和图片预加载者两个功能,被隔离在两个对象里,可以各自变化而不影响对方,如果以后不需要预加载了,只需要改成请求本体而不是请求代理对象即可。此外代码中没有改变或增加myImage接口,但是通过代理对象,给程序添加了新行为,也符合开放-封闭原则。

迭代器模式

提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。 使用场景:根据给的数据创建div,并添加到body中;

// 正常快速实现功能如下,代码简单易懂,但是appendDiv方法同时实现了遍历元素、创建元素插入body两个功能,不符合单一职责原则,如果遍历逻辑发生修改,或创建元素逻辑修改,则会相互影响,难以维护
var appendDiv = function (data) {
    for (var i = 0, l = data.length; i < l; i++) {
        var div = document.createElement('div');
        div.innerHTML = data[i];
        document.body.appendChild(div);
    }
}
appendDiv([1, 2, 3, 4, 5, 6]);

// 使用迭代器模式实现,把迭代的过程从业务逻辑中分离出来,不关心对象的内部构造,也可以顺序访问其中的每一个元素。更符合单一职责原则
// 循环
var each = function (obj, callback) {
    var value,
        i = 0,
        length = obj.length,
        isArray = isArraylike(obj); // isArraylike判断是否为数组
    if (isArray) { // 迭代类数组
        for (; i < length; i++) {
            value = callback.call(obj[i], i, obj[i]);
            if (value === false) {
                break;
            }
        }
    } else { // 迭代object对象
        for (i in obj) {
            value = callback.call(obj[i], i, obj[i]);
            if (value === false) {
                break;
            }
        }
    }
}
// 添加
var appendDiv = function (data) {
    ecah(data, function (i, n) {
        var div = document.createElement('div');
        div.innerHTML = n;
        document.body.appendChild(div);
    });
}

appendDiv([1, 2, 3, 4, 5, 6]);
appendDiv({ a: 1, b: 2, c: 3, d: 4 });

JavaScript内置了一些遵循迭代器模式或可迭代协议的对象和接口,使得它们可以被轻松地遍历,例如:数组、字符串、Map、Set、arguments、NodeList对象、Generator生成器等。通过for...of循环、扩展运算符(...)、解构赋值等语言特性,可以轻松地遍历这些集合。

对于拿着锤子的人来讲,全世界都是钉子 -- 查理·芒格

其实在实际开发中,关于根据给的数据创建div,并添加到body中的使用场景,第一种方式反而更加简洁,易于维护,第二种反而复杂,不易维护。所以开发中违法原则并不奇怪,不必强行使用设计模式,在合适的场景下使用才有意义。

装饰者模式

装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态的添加职责,

最少知识原则(迪米特法则)

一个对象应当尽可能少地与其他对象(不仅包括对象,还包括系统、类、模块、函数、变量等)发生相互作用。即应该减少对象之间的交互,如果两个对象之间不必彼此直接通讯,那么这两个对象就不要发生直接的相互联系,常见的做法是引入一个第三者对象,来承担这些对象之间的通讯作用,如果一些对象需要向另一些对象发起请求,可以通过第三者对象来转发这些请求。

最少知识原则在设计模式中体现得最多得地方是中介者模式和外观模式。

中介者模式

定义一个对象(中介者),该对象封装了系统中对象间得交互方式。中介者模式的作用就是解除对象与对象之间的紧耦合关系。通过增加一个中介者对象,让所有的相关对象都通过中介者对象来通讯,而不是相互引用。所以当一个对象发生改变时,只需要通知中介者对象即可。

参考如下购买手机的案例:在购买流程中,可以选择手机的颜色以及购买数量,这两种选择实时影响着对应的库存结果,如果库存数量少于这次的购买数量,按钮将被禁用并且显示库存不足,反之按钮可以点击并且显示放入购物车。

// 根据选择情况分析,我们需要分别监听 选择颜色`colorSelect` 的 `onchange` 事件和 输入购买数量`numberInput` 的 `oninput` 事件
// 如colorSelect的事件处理 ,html代码省略
var colorSelect = document.getElementById('colorSelect'), // 颜色选择
    numberInput = document.getElementById('numberInput'), // 数量输入
    nextBtn = document.getElementById('nextBtn'); // 结果按钮

var goods = { // 手机库存 
    "red": 3,
    "blue": 6
};
colorSelect.onchange = function () {
    var color = this.value, // 颜色
        number = numberInput.value,
        stock = goods[color]; // 该颜色手机对应的当前库存
    if (!color) {
        nextBtn.disabled = true;
        nextBtn.innerHTML = '请选择手机颜色';
        return;
    }
    // 用户输入的购买数量是否为正整数
    if (((number - 0) | 0) !== number - 0) {
        nextBtn.disabled = true;
        nextBtn.innerHTML = '请输入正确的购买数量';
        return;
    }
    // 当前选择数量是否超过库存量 
    if (number > stock) { 
        nextBtn.disabled = true;
        nextBtn.innerHTML = '库存不足';
        return;
    }
    nextBtn.disabled = false;
    nextBtn.innerHTML = '放入购物车';
};
// numberInput.oninput逻辑相似,省略
// 但是如果分别处理colorSelect.onchange与numberInput.oninput的事件,其处理逻辑不但内容耦合,相互影响,也不易维护,如果后续新增别的选择条件,那么每一个事件处理函数都需要变更。

// 引入中介者,所有选择对象只跟中介者通讯,新增内存大小的选择象,并展示选择的结果
var mediator = (function () {
    var colorSelect = document.getElementById('colorSelect'),
        memorySelect = document.getElementById('memorySelect'),
        numberInput = document.getElementById('numberInput'),
        colorInfo = document.getElementById('colorInfo'), // 颜色信息
        memoryInfo = document.getElementById('memoryInfo'), // 内存信息
        numberInfo = document.getElementById('numberInfo'), // 购买数量信息
        nextBtn = document.getElementById('nextBtn');
    return {
        changed: function (obj) {
            var color = colorSelect.value, // 颜色 
                memory = memorySelect.value,// 内存 
                number = numberInput.value, // 数量 
                // 颜色和内存对应的手机库存数量
                stock = goods[color + '|' + memory];  
            if (obj === colorSelect) { // 如果改变的是选择颜色下拉框 
                colorInfo.innerHTML = color;
            } else if (obj === memorySelect) {
                memoryInfo.innerHTML = memory;
            } else if (obj === numberInput) {
                numberInfo.innerHTML = number;
            }
            if (!color) {
                nextBtn.disabled = true;
                nextBtn.innerHTML = '请选择手机颜色';
                return;
            }
            if (!memory) {
                nextBtn.disabled = true;
                nextBtn.innerHTML = '请选择内存大小';
                return;
            }
            if (((number - 0) | 0) !== number - 0) {
                nextBtn.disabled = true;
                nextBtn.innerHTML = '请输入正确的购买数量';
                return;
            }
            nextBtn.disabled = false;
            nextBtn.innerHTML = '放入购物车';
        }
    }
})();
// 事件函数:
colorSelect.onchange = function () {
    mediator.changed(this);
};
memorySelect.onchange = function () {
    mediator.changed(this);
};
numberInput.oninput = function () {
    mediator.changed(this);
};
// 如果后续再新增选择条件,只需要修改mediator中介者即可

中介者模式也存在一些缺点,其中最大的缺点就是系统中会新增一个中介者对象,因为对象之间交互的复杂性,转移成了中介者对象的复杂性,使得中介者对象经常是巨大的。中介者对象自身往往就是一个难以维护的对象。

外观模式

外观模式在JavaScript中的使用场景并不多,外观模式主要是为子系统中的一组接口提供一个一致的界面,该模式定义了一个高层接口,这个接口使子系统更加容易使用。外观模式的作用主要有两点:

  • 为一组子系统提供一个简单便利的访问入口。
  • 隔离客户与复杂子系统之间的联系,客户不用去了解子系统的细节。

从第二点来看,外观模式是符合最少知识原则的,就比如全自动洗衣机一键洗衣按钮,隔开了客户和浸泡、洗衣、漂洗、脱水这些子系统的直接联系,客户不用去了解这些子系统的具体实现。

开放封闭原则

软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。 当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。 不管是具体的各种设计模式,还是更抽象的面向对象设计原则,都是为了让程序遵守开放-封闭原则而出现的,可以说,开放-封闭原则是编写一个好程序的目标,其他设计原则都是达到这个目标的过程。

例如以下几个模式,能让我们更进一步的了解设计模式在遵守开放-封闭原则方面做出的努力。

发布订阅模式

发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。

发布订阅模式用来降低多个对象之间的依赖关系,它可以取代对象之间硬编码的通知机制,一个对象不用再显式地调用另一个对象地某一个接口。当有新的订阅者出现时,发布者地代码不需要进行任何修改。同样当发布者需要改变时,也不会影响到之前地订阅者。发布订阅模式通用实现:

var event = {
    clientList: [],
    listen: function (key, fn) {
        if (!this.clientList[key]) {
            // 如果还没有订阅过此类消息,给该类消息创建一个缓存列表
            this.clientList[key] = [];
        }
        this.clientList[key].push(fn); // 订阅的消息添加进消息缓存列表
    },
    trigger: function () {
        var key = Array.prototype.shift.call(arguments), // 取出消息类型
            fns = this.clientList[key]; // 取出该消息对应的回调函数集合
        if (!fns || fns.length === 0) {
            // 如果没有订阅该消息,则返回
            return false;
        }
        for (var i = 0, fn; (fn = fns[i++]);) {
            fn.apply(this, arguments); // arguments 是发布消息时附送的参数
        }
    }
};
// 定义一个 `installEvent` 函数,这个对象可以给所有的对象都动态安装发布-订阅功能
var installEvent = function (obj) {
    for (var i in event) {
        obj[i] = event[i];
    }
};

// 测试:给对象 `salesOffices` 动态增加发布-订阅功能
var salesOffices = {};
installEvent(salesOffices);
// 小明订阅消息
salesOffices.listen("squareMeter88", function (price) {
    console.log("价格= " + price);
});
// 小红订阅消息
salesOffices.listen("squareMeter100", function (price) {
    console.log("价格= " + price);
});
salesOffices.trigger("squareMeter88", 2000000); // 输出:2000000
salesOffices.trigger("squareMeter100", 3000000); // 输出:3000000

其优点非常明显,一为时间上地解耦,二为对象之间地解耦。MVC和MVVM的架构都少不了发布-订阅模式的参与。

缺点也是有的,创建订阅者本身要消耗一定的时间和内存,而且当订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。发布-订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象与对象之间必要的联系也将被深埋在背后,会导致程序难以跟踪维护和理解。

模板方法模式

模板方法模式是一种只需要使用继承就可以实现的非常简单的模式。是一种典型的通过封装变化提高系统扩展性的设计模式。

在一个运用了模板方法模式地程序中,子类的方法种类和执行顺序都是不变的,所以我们把这部分逻辑抽出来放到父类的模板方法里面,而子类的方法具体怎么实现则是可变的,于是把这部分变化的逻辑封装到子类中。通过增加新的子类,便能给系统增加新的功能,并不需要改动抽象父类以及其他子类,这也符合开放-封闭原则。

参考Coffee or Tea 的示例,通过泡一杯咖啡与泡一壶茶的过程分解抽象之后为:

  1. 把水煮沸
  2. 用沸水泡饮料
  3. 把饮料倒进杯子
  4. 加调料
var Beverage = function () { };

Beverage.prototype.boilWater = function () {
    console.log('把水煮沸');
};
// 空方法,应该由子类重写
Beverage.prototype.brew = function () { };
// 空方法,应该由子类重写
Beverage.prototype.pourInCup = function () { };
// 空方法,应该由子类重写
Beverage.prototype.addCondiments = function () { };

Beverage.prototype.init = function () {
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
};

// 创建咖啡类和茶类,并让它们继承饮料类
var Coffee = function () { }; 
Coffee.prototype = new Beverage();

// 重写抽象父类中的一些方法,只有“把水煮沸”这个行为可以直接使用父类 `Beverage` 中的 `boilWater` 方法,其他都需要在 `Coffee` 子类中重写
Coffee.prototype.brew = function () {
    console.log('用沸水冲泡咖啡');
};
Coffee.prototype.pourInCup = function () {
    console.log('把咖啡倒进杯子');
};
Coffee.prototype.addCondiments = function () {
    console.log('加糖和牛奶');
};
var Coffee = new Coffee();
Coffee.init();

// 完成茶的冲泡的 `Tea` 类同Coffee一样。

被称为模板方法的是Beverage.prototype.init,该方法中封装了子类的算法框架,它作为算法的一个模板,指导子类以何种顺序去执行哪些方法。在 Beverage.prototype.init 方法中,算法内的每一个步骤都清楚地展示在我们面前。

策略模式

策略模式是定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

策略模式和模板方法模式是一对竞争者,在大多数情况下,他们可以相互替换使用。模板方法模式基于继承的思想,而策略模式则偏重于组合和委托。策略模式将各种算法都封装成单独的策略类,这些策略类可以被交换使用。策略和使用策略的客户代码可以分别独立进行修改而互不影响。我们新增一个新的策略类也非常方便,完全不用修改之前的代码。

参考计算奖金的示例,以计算年终奖为例,例如:绩效为S的人年终奖4倍工资,绩效为A的人年终奖3倍工资,绩效为B的人年终奖2倍。

// javascript 版本策略模式:函数也是对象,更简单直接的做法就是把策略定义为函数

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)) // 输出:80000
console.log(calculateBonus('A', 10000)) // 输出:30000
职责链模式

使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

代码案例:一手机电商网站针对支付过定金的用户有一定的优惠政策,500 元定金得100 元的商城优惠券,200 元定金得50元的优惠券,没有支付定金的用户进入普通购买模式,没有优惠券,且在库存有限的情况下不一定保证能买到。

// 采用灵活可拆分的职责链节点处理逻辑。如下函数表示3种购买模式的节点函数,如果某个节点不能处理请求,则返回一个特定的字符串`nextSuccessor`来表示该请求需要继续往后面传递搞清楚
// 500 元订单
var order500 = function (orderType, pay, stock) {
    if (orderType === 1 && pay === true) {
        console.log('500 元定金预购, 得到 100 优惠券');
    } else {
        return 'nextSuccessor'; // 我不知道下一个节点是谁,反正把请求往后面传递
    }
};
// 200 元订单
var order200 = function (orderType, pay, stock) {
    if (orderType === 2 && pay === true) {
        console.log('200 元定金预购, 得到 50 优惠券');
    } else {
        return 'nextSuccessor'; // 我不知道下一个节点是谁,反正把请求往后面传递
    };
}
// 普通购买订单
var orderNormal = function (orderType, pay, stock) {
    if (stock > 0) {
        console.log('普通购买, 无优惠券');
    } else {
        console.log('手机库存不足');
    }
};

// 把函数包装进职责链节点
// 定义一个构造函数 `Chain`,在 `new Chain` 的时候传递的参数即为需要被包装的函数,同时它还拥有一个实例属性 `this.successor`,表示在链中的下一个节点。

var Chain = function (fn) {
    this.fn = fn;
    this.successor = null;
};
// 指定在链中的下一个节点
Chain.prototype.setNextSuccessor = function (successor) {
    return this.successor = successor;
};
// 传递请求给某个节点
Chain.prototype.passRequest = function () {
    var ret = this.fn.apply(this, arguments);
    if (ret === 'nextSuccessor') {
        return this.successor && this.successor.passRequest.apply(this.successor, arguments);
    } return ret;
};

// 把3个订单函数分别包装成职责链的节点
var chainOrder500 = new Chain(order500);
var chainOrder200 = new Chain(order200);
var chainOrderNormal = new Chain(orderNormal);

// 指定节点在职责链中的顺序
chainOrder500.setNextSuccessor(chainOrder200); chainOrder200.setNextSuccessor(chainOrderNormal);

// 把请求传递给第一个节点
chainOrder500.passRequest(1, true, 500);// 输出:500 元定金预购,得到 100 优惠券
chainOrder500.passRequest(2, true, 500);// 输出:200 元定金预购,得到 50 优惠券 
chainOrder500.passRequest(3, true, 500);// 输出:普通购买,无优惠券
chainOrder500.passRequest(1, false, 0);// 输出:手机库存不足

// 可以自由灵活地增加、移除和修改链中的节点顺序,加入新增支持 300 元定金购买,只需要增加一个节点即可
chainOrder300 = new Chain(order300);
chainOrder500.setNextSuccessor(chainOrder300);
chainOrder300.setNextSuccessor(chainOrder200);

现实生活中类似职责链模式的场景比如:早高峰挤上公交车后,不知道售票员在哪里,只能把硬币往前递,除非运气好,你面前的第一个人就是售票员,否则你的硬币通常要在N个人手上传递,才能最终到达售票员手里。因此职责链模式的最大优点是:请求发送者只需要知道链中的第一个节点,从而弱化了发送者和一组接收者之间的强联系。如果不使用职责链模式,那么在公交车上,我就得先搞清楚谁是售票员,才能把硬币递给他。

职责链模式也有缺点:

  • 它不能保证某个请求一定会被链中的节点处理,在这种情况下,我们可以在链尾增加一个保底的接受者节点来处理这种即将离开链尾的请求。
  • 职责链模式使得程序中多了一些节点对象,可能在某一次的请求传递过程中,大部分节点并没有起到实质性的作用,它们的作用仅仅是让请求传递下去,从性能方面考虑,我们要避免过长的职责链带来的性能损耗。

在JavaScript开发中,职责链模式是最容易被忽视得模式之一,实际上只要运用得当,可以很好的帮助我们管理代码。无论是作用域链、原型链,还是DOM节点的冒泡事件都能从中找到职责链模式的影子。

代码重构原则

模式和重构之间有着一种与生俱来的关系,从某种角度来看,设计模式的目的就是为许多重构行为提供目标。无论你进行了再多的努力,重构都是一个会在未来发生的事情,只不过是一个早晚的问题。但具体是否需要重构,以及如何进行重构,需要我们根据系统的类型、项目工期、人力等外界因素一起决定。

提炼函数

在JavaScript开发中,大部分时间都在跟函数打交道,我们希望函数有着良好的命名,函数体内包含的逻辑清晰。如果一个函数过长,不得不加若干注释才能让函数显得易懂,那这些函数就很有必要进行重构。如果函数中有一段代码可以被独立出来,那我们最好把这些代码放进另外一个独立的函数中,这是很常见的优化工作。有以下优点:

  • 避免出现超大函数。
  • 独立出来的函数有助于代码复用。
  • 独立出来的函数更容易被覆写。
  • 独立出来的函数如果有个良好的命名,它本身就起到了注释的作用。
合并重复代码片段

如果一个函数体中有一些条件分支语句,而这些条件分支语句内部散布了一些重复的代码,那么就有必要进行合并去重工作,方便复用。

// 不合并重复代码
var paging = function (currPage) {
    if (currPage <= 0) {
        currPage = 0;
        jump(currPage);
    } else if (currPage >= totalPge) {
        currPage = totalPge;
        jump(currPage);
    } else {
        jump(currPage);
    }
}

// 优化后,合并重复代码
var paging = function (currPage) {
    if (currPage <= 0) {
        currPage = 0;
    } else if (currPage >= totalPge) {
        currPage = totalPge;
    }
    jump(currPage);
}

把条件分支语句提炼成函数 复杂的条件分支语句是导致程序难以阅读和理解的重要原因,而且容易导致超大函数。如果把分支语句单独提炼出来,既可以精简代码,避免超大函数,还利于维护,方便复用。

// 优化前
var getPrice = function (price) {
    var date = new Date();
    if (date.getMonth() >= 6 && date.getMonth() <= 9) {
        return price * 0.8;
    }
    return price;
};

// 优化后,将条件提炼成函数
var getPrice = function (price) {
    var date = new Date();
    if (isSummer()) {
        return price * 0.8;
    }
    return price;
};

var isSummer = function () {
    var date = new Date();
    return date.getMonth() >= 6 && date.getMonth() <= 9;
};
合理使用循环

如果有一些代码实际上负责的是一些重复性的工作,那么合理使用循环不仅可以完成同样的功能,还可以使代码量更少。

// 优化前
var createXHR = function () {
    var xhr;
    try {
      xhr = new ActiveXObject("MSXML2.XMLHttp6.0");
    } catch (e) {
      try {
        xhr = new ActiveXObject("MSXML2.XMLHttp.3.0");
      } catch (e) {
        xhr = new ActiveXObject("MSXML2.XMLHttp");
      }
    }
    return xhr;
  };
  
  var xhr = createXHR();
// 优化后
var createXHR = function () {
    var versions = ["MSXML2.XMLHttp.6.0ddd", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"];
    for (var i = 0, version; (version = versions[i++]);) {
        try {
            return new ActiveXObject(version);
        } catch (e) { }
    }
};

var xhr = createXHR();
提前让函数退出代替条件分支

嵌套的条件分支语句绝对是代码维护者的噩梦,对于阅读代码的人来说,嵌套的if、else语句相比平铺的if、else语句在阅读和理解上更加困难,于是可以挑选一个条件分支,在进入这些条件分支之后,就立即让这个函数退出。要做到这一点,有一个常见的技巧,即在面对一个嵌套的if分支时,可以把外层的if表达式进行反转。

// 把外层判断反转,如果是只读,直接return,不必看之后的处理逻辑
var del = function (obj) {
    if (obj.isReadOnly) {
        // 反转if表达式
        return;
    }
    if (obj.isFolder) {
        return deleteFolder(obj);
    }
    if (obj.isFile) {
        return deleteFile(obj);
    }
};

传递对象参数代替过长的参数列表

有时候一个函数有可能接受多个参数,而参数的数量越多,函数就越难理解和使用,我们可以把参数都放在一个对象内,把对象传入函数,在函数中可解构,需要的数据就自行获取即可,不用关心参数的顺序和数量,只要保证参数key不变就可以。

// 传入多个参数
setUserInfo(1, "xiaoxu", "shanghai", "男", 1231221213, "qq.com");

var setUserInfo = function (obj) {
    const { id, name, address, sex, monile, qq } = obj
    console.log("id= ", id);
    console.log("name= ", name);
    console.log("address= ", address);
    console.log("sex= ", sex);
    console.log("mobile= ", mobile);
    console.log("qq= ", qq);
};
// 传入对象
setUserInfo({
    id: 1,
    name: "xiaoxu",
    address: "shanghai",
    sex: "male",
    mobile: 1234131,
    qq: "qq.com",
});

尽量减少参数数量

如果一个函数不需要传入任何参数就可以使用,这种函数是深受喜爱的,但是在实际开发中,向函数传递参数不可避免,如果参数多了,我们在使用的时候就需要搞清楚这些参数代表的含义,必须小心翼翼的把它们按照顺序传入该函数,用起来比较费劲。因此我们应该尽量的减少函数接收的参数数量。

var draw = function(width, height, square){};

var draw = function(width, height){ 
    var square = width * height; 
}
合理使用链式调用

使用链式调用的方式并不会造成太多阅读上的困难,也确实能省下一些字符和中间变量,但节省下来的字符数量同样是微不足道的。链式调用带来的坏处就是在调试的时候非常不方便,如果知道一条链中有错误出现,必须得先把这条链拆开才能加上一些调试的log或者增加断点,这样才能定位错误出现的地方。如果链式的结构相对稳定,后期不容易发生修改,那么使用链式调用无可厚非。但如果该链条很容易发生变化,导致调试和维护困难,那么还是建议使用普通调用。

var User = {
    id: null,
    setId: function (id) {
        this.id = id;
        return this; // 方法中return this可进行链式调用

    },
    setName: function (name) {
        this.name = name;
        return this;
    }
}
console.log(User.setId(1).setName('one'))

合理使用三目运算符

如果是条件分支逻辑简单清晰,无碍我们使用三目运算符,但是但如果条件分支逻辑非常复杂,那么相比损失的代码可读性和可维护性,三目运算符节省的代码量可以忽略不计。那么最好的选择还是按部就班的编写if、else。其语句的好处很多,一是阅读相对容易,二是修改的时候比修改三目运算符周围的代码更加方便。

这些都是平常常见和经典的代码重构技巧,仅是建议,没有哪些是必须严格遵守的标准,可根据实际开发情况选择。

以上的设计原则及模式都是比较适合javascript开发的,其中单一职责原则与开放封闭原则是我们最常用的。当然还有其他的模式以及一些设计原则如下:

其他模式

组合模式

在程序设计中,有一些和“事物是由相似的子事物构成”类似的思想。组合模式就是用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的“孙对象”构成的。

组合模式将对象组合成树形结构,并且能像处理单个对象一样处理整个树。优点是客户端可以一致地处理单个对象和组合对象,无需区分它们的类型。可以通过增加新的组合节点或叶子节点,可以轻松地扩展系统的功能。可以在不修改现有代码的情况下,通过添加新的节点来扩展系统的功能,体现了开放封闭原则。

这种设计模式特别适用于表示部分与整体层次结构的场景,如文件系统的目录和文件、组织结构的部门和员工等。在Web开发中,可以使用组合模式来构建复杂的用户界面组件,如菜单、工具栏等。

享元模式

享元(flyweight)模式是一种用于性能优化的模式,“fly”在这里是苍蝇的意思,意为蝇量级。其核心是运用共享技术来有效支持大量细粒度的对象。

享元模式通过共享对象来减少对象的创建数量,这样可以显著降低内存消耗和提高系统性能。当需要添加新的享元类型时,我们不需要修改现有的享元类,而是可以通过扩展共享对象的方式来实现。这种设计模式符合开放封闭原则。

状态模式

状态模式是一种非同寻常的优秀模式,它也许是解决某些需求场景的最好方法。状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。

状态模式主要用来解决系统中复杂对象的状态转换以及不同状态下行为的封装问题。通过使用状态模式,可以将特定的状态相关的行为局部化,并且将不同状态的行为分割开来,每一个状态对应一个具体的类。比如文件上传过程中有很多状态,比如:扫描、正在上传、暂停、上传成功、上传失败等等,就适合采用状态模式解决。

命令模式

命令模式是最简单和优雅的模式之一,命令模式中的命令指的是一个执行某些特定事情的指令。

命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时,希望用一种松耦合的方式来设计程序,使的请求发送者和请求接收者能够消除彼此之间的耦合关系。

设计模式的主题总是把不变的事物和变化的事物分离开来。命令模式就是如此。例如:程序员写了一个button,只知道点击后会发生某些事情,但具体发生什么却不清楚。按下按钮后发生一些事情是不变的,具体发生什么事情是可变的,而这种关联我们可以通过command对象的帮助来改变。使用命令模式定义一个setCommand函数,该函数预留好安装命令的接口,负责往按钮上面安装命令即可。其实就是通过setCommand函数给按钮绑定不同的换回调函数,指定不同的行为操作。

适配器模式

适配器模式的作用是解决两个软件实体间的接口不兼容的问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。适配器的别名是包装器(wrapper),是一个相对简单的模式。开发中常用的处理数据格式转换的函数就是一个适配器。

SOLID原则

面向对象的五个基本原则,也被称为SOLID原则,是面向对象编程(OOP)设计中的重要指导方针,旨在帮助开发者编写灵活、可维护和可扩展的代码。分别是:

  • 单一职责原则(Single Responsibility Principle, SRP)
  • 开放-封闭原则(Open-Closed Principle, OCP)
  • 里氏替换原则(Liskov Substitution Principle, LSP)
  • 接口隔离原则(Interface Segregation Principle, ISP)
  • 依赖倒置原则(Dependency Inversion Principle, DIP)
里氏替换原则

任何基类(父类)出现的地方,其子类的对象(派生类)应该可以无差别地替换掉,并且程序的逻辑行为不应该因此发生变化。换句话说,如果软件系统中使用了一个基类对象,那么当使用其子类对象替换基类对象时,应当保证程序的正确性。

  • 子类必须能够替换掉父类而不影响程序的正确性。
  • 子类可以通过添加新的方法或覆盖父类的方法来扩展功能,但不应该修改父类原有的行为。
  • 在类型系统中,子类类型应当被视为其父类类型的合法替代品。
接口隔离原则

接口各路原则是基于接口设计考虑。客户端不应该依赖它不需要的接口。一个类对另一个类的依赖应该建立在最小的接口上。即要尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含使用者感兴趣的方法。能够减少类之间的耦合度,提高系统的灵活性和可维护性。简单来讲,就是不要在一个接口里面放很多的方法,应该尽量细化,一个接口对应一个功能模块,跟单一职责原则的思想类似。

依赖倒置原则

依赖倒置原则要求高层模块不应该依赖于低层模块,而是应该依赖于抽象。换句话说,具体类之间的依赖关系应该尽可能减少,而抽象类或接口之间的依赖关系应该尽可能增加。这里的“高层模块”通常指的是那些调用其他模块功能的模块,而“低层模块”则是被调用的模块。

相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建的架构比以细节为基础搭建的架构要稳定的多。所以在设计中,应该尽量通过接口或抽象类来定义模块之间的依赖关系,而不是直接依赖于具体的实现类。这样做的好处是可以降低模块之间的耦合度,使得系统更加灵活和易于扩展。

本文主要参考了《JavaScript设计模式与开发实践》这本书籍,代码部分没有涉及ES6内容,引用了书中案例,随着前端发展,有些内容可能有更新变化 ,但整体思想还是有很大的参考价值。对我们开发高可维护性代码提供了专业指南。 参考资料:《JavaScript设计模式与开发实践》 --曾探