单一职责 SRP

个类应该仅有一个引起它变化的原因。

更具体地说,单一职责原则意味着一个类应该只负责一项职责或功能。如果一个类承担了过多的职责,那么在软件的修改过程中,修改某一功能可能会影响到由这一类管理的其他功能。遵循单一职责原则可以使类更加独立,从而容易理解、维护和扩展。

在JavaScript中类往往是弱化的,因此单一职责往往更多的应用在对象和方法上。

代理模式中的单一职责

我们实现一个图片的懒加载它需要做两件事件,第一件是创建img图片并添加到页面中,第二件就是占位图设置和实际图片加载完成后重新赋值src。

利用单一职责,我们将其拆分成两个方法:

var myImage = (function() {
    var imgNode = document.createElement('img');
    document.body.appendChild(imgNode);
    return {
        setSrc: function(src) {
            imgNode.src = src;
        }
    }
})();

var proxyImage = (function() {
    var img = new Image;
    img.onload = function() {
        myImage.setSrc(this.src);
    }
    return {
        setSrc: function(src) {
            myImage.setSrc('file:// /C:/Users/svenzeng/Desktop/loading.gif');
            img.src = src;
        }
    }
})();

proxyImage.setSrc('http:// imgcache.qq.com/music/photo/000GGDys0yA0Nk.jpg');

myImage负责img元素创建和插入,抛出设置src接口,proxyImage 负责懒加载功能,它代理了原 myImage 的功能,增加了占位图和加载完成后重新赋值src。

迭代器模式 单一职责

我们有这样一段代码,先遍历一个集合,然后往页面中添加一些 div,这些 div 的 innerHTML 分别对应集合里的元素:

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]);

appendDiv除了渲染dom元素,还需要处理数据data的遍历,如果有一天data的数据变成了对象而不是数组,那么appendDiv就需要改动代码,比如改成 for...in的形式。

实际的我们遵循单一职责原则,将他们的职责拆分:遍历、渲染dom

var each = function(obj, callback) {
    var value,
        i = 0,
        length = obj.length,
        isArray = isArraylike(obj); // isArraylike 函数未实现,可以翻阅 jQuery 源代码
    if (isArray) { // 迭代类数组
        for (; i < length; i++) {
            callback.call(obj[i], i, obj[i]);
        }
    } else {
        for (i in obj) { // 迭代 object 对象
            value = callback.call(obj[i], i, obj[i]);
        }
    }
    return obj;
};

var appendDiv = function(data) {
    each(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
});

这使得代码更加健壮。

单例模式 单一职责

在之前的单例模式文章中,我们将单例模式单独抽取成一个通用的方式,使得单例与具体的逻辑是独立的,这也是单一职责的表现。

var getSingle = function(fn) { // 获取单例
    var result;
    return function() {
        return result || (result = fn.apply(this, arguments));
    }
};

var createLoginLayer = function() { // 创建登录浮窗
    var div = document.createElement('div');
    div.innerHTML = '我是登录浮窗';
    document.body.appendChild(div);
    return div;
};

var createSingleLoginLayer = getSingle(createLoginLayer);
var loginLayer1 = createSingleLoginLayer();
var loginLayer2 = createSingleLoginLayer();

alert(loginLayer1 === loginLayer2); // 输出: true

装饰者模式 单一职责

我们在装饰者模式中使用AOP的形式对函数进行了装饰,通常被装饰的方法或者对象一开始只有一些基础的功能,更多的职责都是在代码运行时动态装饰到对象上面,从某种角度来看,它也是分离职责的一种方式。

Function.prototype.after = function(afterfn) {
    var __self = this;
    return function() {
        var ret = __self.apply(this, arguments);
        afterfn.apply(this, arguments);
        return ret;
    }
};

var showLogin = function() {
    console.log('打开登录浮层');
};

var log = function() {
    console.log('上报标签为: ' + this.getAttribute('tag'));
};

document.getElementById('button').onclick = showLogin.after(log); // 打开登录浮层之后上报数据

何时应该分离职责

单一职责是最简单也是最难正确运用的原则之一,要明确的是,并不是所有的职责都需要一一分离。

如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们,另一方面只有当我们确信职责会发生变化时,分离这些职责才是有意义的。如果目前这两个职责虽然被耦合在一起,但暂时没有变化的迹象,那么可能没有必要急于将它们分开。可以在未来需要重构代码时再考虑分离,这样可以避免过早优化。

以下是一些时机和情况,你可以考虑分离职责:

  1. 功能复杂性增加:当一个类或方法开始承担越来越多的职责,导致它变得过于复杂时,应该考虑分离职责。例如,如果一个类同时处理数据访问、业务逻辑和用户界面显示,这可能是一个迹象表明需要将这些功能分离到不同的类或模块中。
  2. 修改频率和原因:如果你发现一个类因为多种不相关的原因需要频繁地修改,这通常是这个类承担了过多职责的标志。每个职责应该是一个改变的唯一原因;如果存在多个改变的原因,那么应该将这个类拆分。
  3. 可测试性问题:难以为一个类编写测试,或者测试非常复杂,可能是因为类包含了多个不同的功能,这增加了测试的难度。将职责分离可以提高测试的简易度和覆盖率。
  4. 重用性要求:当你发现在多个地方需要重用类中的某些功能时,单独将这些功能提取出来成为独立的类可以增加代码的复用性。
  5. 团队协作需求:在一个大型项目中,多个开发人员可能会同时工作在同一个模块上。如果一个类或模块包含多个职责,可能会造成工作重叠或冲突。职责的适当分离可以帮助团队更有效地协作。

违反单一职责

在人的常规思维中,总是习惯性地把一组相关的行为放到一起,如何正确地分离职责不是一 件容易的事情。

我们也许从来没有考虑过如何分离职责,但这并不妨碍我们编写代码完成需求。对于 SRP 原则,许多专家委婉地表示“This is sometimes hard to see.”。

一方面,我们受设计原则的指导,另一方面,我们未必要在任何时候都一成不变地遵守原则。 在实际开发中,因为种种原因违反 SRP 的情况并不少见。比如 jQuery 的 attr 等方法,就是明显 违反 SRP 原则的做法。jQuery 的 attr 是个非常庞大的方法,既负责赋值,又负责取值,这对于 jQuery 的维护者来说,会带来一些困难,但对于 jQuery 的用户来说,却简化了用户的使用。

在方便性与稳定性之间要有一些取舍。具体是选择方便性还是稳定性,并没有标准答案,而 是要取决于具体的应用环境。比如如果一个电视机内置了 DVD 机,当电视机坏了的时候,DVD 机也没法正常使用,那么一个 DVD 发烧友通常不会选择这样的电视机。但如果我们的客厅本来 就小得夸张,或者更在意 DVD 在使用上的方便,那让电视机和 DVD 机耦合在一起就是更好的 选择

单一职责优缺点

SRP原则降低了单个类或者对象的复杂度,按照职责将对象分解成更小的粒度,这有利于代码的复用,也有利于进行单元测试,当一个职责需要变更的时候,不会影响到其他职责(目的不变,过程变化)。

但是SRP原则明显的增加的编写代码的复杂度,当我们按照职责把对象 分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。

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

一个对象应当对其他对象有尽可能少的了解。换句话说,一个软件实体应当尽可能少地与其他实体发生相互作用,只与其直接的朋友通信。

单一职责将对象划分成更小的粒度,但是会导致对象的数量增加,一个对象可能依赖多个对象,在程序中,这种情况并不是一件很好的事情,“城门失火殃及池鱼”和“一人犯法,株连九族”的故事时有发生。

最少知识原则要求我们在程序设计之时,应当尽量减少对象之间的交互。如果两个对象之间不 必彼此直接通信,那么这两个对象就不要发生直接的相互联系。常见的做法是引入一个第三者对 象,来承担这些对象之间的通信作用。如果一些对象需要向另一些对象发起请求,可以通过第三 者对象来转发这些请求。

设计模式中的最少知识原则

中介者模式和外观模式就是遵循了最少知识原则。

中介者模式通过增加一个中介者对象,让所有的相关对象都通 过中介者对象来通信,而不是互相引用。所以,当一个对象发生改变时,只需要通知中介者对象即可。

外观模式主要是为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个 接口使子系统更加容易使用。

外观模式在JavaScript中使用场景不多,简单点来说就是外观模式提供了一个对客户使用更加便捷的接口,但是客户也可以不使用这个接口,转而自己去自定义使用子系统。

举个例子来说:

全自动洗衣机的一键洗衣按钮,这个一键洗衣按钮就是一个外观。如果是老式洗衣机, 客户要手动选择浸泡、洗衣、漂洗、脱水这 4 个步骤。如果这种洗衣机被淘汰了,新式洗衣机的 漂洗方式发生了改变,那我们还得学习新的漂洗方式。而全自动洗衣机的好处很明显,不管洗衣 机内部如何进化,客户要操作的,始终只是一个一键洗衣的按钮。这个按钮就是为一组子系统所 创建的外观。但如果一键洗衣程序设定的默认漂洗时间是 20 分钟,而客户希望这个漂洗时间是 30 分钟,那么客户自然可以选择越过一键洗衣程序,自己手动来控制这些“子系统”运转。

最简单的外观模式应该是类似下面的代码:

var A = function() {
    a1();
    a2();
}

var B = function() {
    b1();
    b2();
}

var facade = function() {
    A();
    B();
}

facade();

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

封装在最少知道原则中的体现

封装在很大程度上表达的是数据的隐藏。一个模块或者对象可以将内部的数据或者实现细节隐藏起来,只暴露必要的接口 API 供外界访问。对象之间难免产生联系,当一个对象必须引 用另外一个对象的时候,我们可以让对象只暴露必要的接口,让对象之间的联系限制在最小的 范围之内。

在JavaScript中封装往往用来限制变量的作用域,将变量的可见性限制在一个较小的范围内,这个变量对其它不相干模块的影响就越小,变量被改写和发生冲突的机会也越小,这也是广义上最少知识原则的一种提醒。

开放-封闭原则

开放-封闭原则最早由 Eiffel 语言的设计者 Bertrand Meyer 在其著作 Object-Oriented Software Construction 中提出。它的定义如下:

软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。

假设我们是一个大型 Web 项目的维护人员,在接手这个项目时,发现它已经拥有 10 万行以 上的 JavaScript 代码和数百个 JS 文件。 不久后接到了一个新的需求,即在 window.onload 函数中打印出页面中的所有节点数量。这 当然难不倒我们了。

于是我们打开文本编辑器,搜索出 window.onload 函数在文件中的位置,在 函数内部添加以下代码:

window.onload = function() {
    // 原有代码略
    console.log(document.getElementsByTagName('*').length);
};

在项目需求变迁的过程中,我们经常会找到相关代码,然后改写它们。这似乎是理所当然的 事情,不改动代码怎么满足新的需求呢?想要扩展一个模块,最常用的方式当然是修改它的源代 码。如果一个模块不允许修改,那么它的行为常常是固定的。然而,改动代码是一种危险的行为, 也许我们都遇到过 bug 越改越多的场景。刚刚改好了一个 bug,但是又在不知不觉中引发了其他的bug。

那么,有没有办法在不修改代码的情况下,就能满足新需求呢?在第 15 章中,我们已经得 到了答案,通过增加代码,而不是修改代码的方式,来给 window.onload 函数添加新的功能,代码如下:

Function.prototype.after = function(afterfn) {
    var __self = this;
    return function() {
        var ret = __self.apply(this, arguments);
        afterfn.apply(this, arguments);
        return ret;
    }
};

window.onload = (window.onload || function() {}).after(function() {
    console.log(document.getElementsByTagName('*').length);
});

当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。

用对象的多态性消除条件分支

过多的条件分支语句是造成程序违反开放封闭原则的一个常见原因。每当需要增加一个新 的 if 语句时,都要被迫改动原函数。把 if 换成 switch-case 是没有用的,这是一种换汤不换药 的做法。实际上,每当我们看到一大片的 if 或者 swtich-case 语句时,第一时间就应该考虑,能 否利用对象的多态性来重构它们。

var makeSound = function(animal) {
    if (animal instanceof Duck) {
        console.log('嘎嘎嘎');
    } else if (animal instanceof Chicken) {
        console.log('咯咯咯');
    }
};

var Duck = function() {};
var Chicken = function() {};

makeSound(new Duck()); // 输出:嘎嘎嘎
makeSound(new Chicken()); // 输出:咯咯咯

动物世界里增加一只狗之后,makeSound 函数必须改成:

var makeSound = function(animal) {
    if (animal instanceof Duck) {
        console.log('嘎嘎嘎');
    } else if (animal instanceof Chicken) {
        console.log('咯咯咯');
    } else if (animal instanceof Dog) { // 增加跟狗叫声相关的代码
        console.log('汪汪汪');
    }
};

var Dog = function() {};

makeSound(new Dog()); // 增加一只狗

利用多态的思想,我们将变化的部分隔离开来。

var makeSound = function(animal) {
    animal.sound();
};

var Duck = function() {};

Duck.prototype.sound = function() {
    console.log('嘎嘎嘎');
};

var Chicken = function() {};

Chicken.prototype.sound = function() {
    console.log('咯咯咯');
};

makeSound(new Duck()); // 嘎嘎嘎
makeSound(new Chicken()); // 咯咯咯

/********* 增加动物狗,不用改动原有的 makeSound 函数 ****************/

var Dog = function() {};

Dog.prototype.sound = function() {
    console.log('汪汪汪');
};

makeSound(new Dog()); // 汪汪汪

找出变化的地方

开发封装原则如何落地,最明显的就是找出程序中将要发生变化的地方,然后把变化封装起来,将稳定的部分和容易变化的部分分隔开来。

常见的例子有:

放置挂钩(hook)

这也是模板方法模式中应对更多变化处理方式,由于子类的数量是无限制的,总会有一些“个性化”的子类迫使我们不得不去改变已经封装 好的算法骨架。于是我们可以在父类中的某个容易变化的地方放置挂钩,挂钩的返回结果由具体 子类决定。这样一来,程序就拥有了变化的可能。

回调函数

回调函数其实也是封装变化部分的方式之一,我们将回调函数作为参数传入一个稳定和封闭的函数中,当回调函数被执行时,就会因为调用的函数不同,而产生不同的结果。

比如ajax的回调函数,绑定的事件函数,forEach传入的函数,这些都是。

设计模式中的开闭原则

几乎所有的设计模式都是遵循开闭原则的,不管是单一职责、最少知识原则、依赖倒置原则等,都是让程序遵循开闭原则而出现的,可以说开闭原则是编写一个好程序的目标,而其他原则都是达到这个目标的过程。

开闭原则的相对性

开闭原则要求我们只能通过新增代码的方式扩展程序的功能,而不允许直接修改原代码。

实际上,让程序完全保持开闭原则是非常难的,就算技术上可以实现,也需要话费太多的时间和精力。

比如在之前的职责链模式中,我们新增了一种职责,就不得在在链条中设置新的节点,这必然会改动原来的代码:

order500yuan.setNextSuccessor(order200yuan).setNextSuccessor(orderNormal);

// 变为:

order500yuan.setNextSuccessor(order200yuan).setNextSuccessor(order100yuan).setNextSuccessor(orderNormal);

在实际的编程中,我们应该因地制宜,挑选出最容易发生变化的地方,然后构造抽象来封闭这些变化,在不可避免发生修改的时间,尽量修改那些相对容易修改的地方。拿一个开源库来说,修改它提供的配置文件,总比修改它的源代码要简单的多。

依赖倒置原则

高层的模块不应该依赖底层模块,两者应该依赖抽象,抽象不应该依赖细节,细节应该依赖抽象。

抽象不应依赖细节 意味着高层模块(如服务、业务逻辑等)不应该依赖低层模块的具体实现。这样,高层模块就不会受到低层模块改变的影响,增强了系统的灵活性和可维护性。

细节应依赖抽象 指的是低层模块的实现应该依赖于抽象接口或抽象类,这样即使低层模块的具体实现变化,只要它们遵循相同的抽象接口,高层模块也不需要做出改变。

在JavaScript中是不存在抽象和接口的,所以理解这个还有点难,等接触到TS就会明白了,因为TS中大量用到了接口和抽象类。

实现依赖倒置原则的一种做法就是面向接口编程

里氏替换原则

简单来说,这个原则要求子类必须能够替换掉它们的基类,而不破坏应用的正确性,这意味着子类在扩展基类的功能时,不应该改变基类原有的行为。

拿JavaScript中的错误对象Error来说,在ES6的时候,我们已经可以通过继承它来实现自定义错误对象。

不管继承之后派生出多少自定义错误,我们总是能通过catch捕获到它,保证了错误处理的一致性。

其实这也是一种开闭原则的体现,对修改原功能是关闭的,保证了程序的稳定性,对扩展是开启的,子类在保证原有功能的情况下实现其他逻辑。

接口隔离原则

接口隔离原则的核心思想是:客户端不应该被迫依赖于它不使用的接口。换句话说,该原则鼓励将大接口分割成更小和更具体的接口,使得实现接口的类不必实现它不使用的方法。

解释接口隔离原则的目的:

  1. 降低依赖性:当接口被分割成较小的接口时,各个实现类之间的依赖关系变得更加清晰,减少了不必要的相互影响。
  2. 提高灵活性:通过细化的接口设计,各个类只需关注它们真正需要的部分,使得系统更加灵活,易于扩展和维护。
  3. 减少影响范围:当一个接口发生改变时,只影响依赖于该接口的类,而非整个系统。这降低了改动带来的风险和复杂性。

应用实例

在现代编程实践中,尤其是在使用诸如 Java 或 C# 这类强类型语言时,接口隔离原则尤为重要。考虑以下场景:

多功能设备接口: 假设有一个多功能机(打印、扫描、传真)接口 IMultiFunctionDevice 将打印、扫描、传真功能都整合在一起。这意味着即使某个设备只支持打印,它也需要实现接口中的扫描和传真方法。根据接口隔离原则,应该将 IMultiFunctionDevice 接口分割成三个接口:IPrinterIScannerIFax。这样,一个只支持打印的设备就只需要实现 IPrinter 接口。

前端应用

在前端开发中,接口隔离原则同样适用,特别是在使用TypeScript这样的具有明确类型系统的环境中:

组件开发: 当开发大型应用的UI组件时,可以为不同的功能(如渲染、事件处理、数据获取等)定义不同的接口,而每个组件只实现它需要的接口。这有助于降低组件之间的耦合度,并提高代码的可维护性。

总之,接口隔离原则是一种鼓励细粒度界定职责和功能的设计方法,能有效提升代码的质量和可维护性。在设计系统时,考虑这一原则有助于创建清晰、易于管理和扩展的API和系统架构。

合成复用原则

合成复用原则推荐开发者在编写代码的时候,优先使用对象组合的方式来代替继承来实现代码的复用。

本意就是为了减少类的爆炸增长和继承的强耦合,由于继承本身就是一种强耦合结构,父类的改变都可能影响到子类的行为,相比之下,使用组合多个对象来实现功能,这使得组合对象之间的关系是松耦合的,甚至可以通过接口形式来更大程度的解耦。

在前端应用中,vue2的mixin混入和vue3的Composition API都是通过组合的形式实现代码的复用。

分类: JavaScript设计模式与开发实践 标签: JavaScript模式设计原则

评论

暂无评论数据

暂无评论数据

目录