JavaScript 设计原则
单一职责 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); // 打开登录浮层之后上报数据
何时应该分离职责
单一职责是最简单也是最难正确运用的原则之一,要明确的是,并不是所有的职责都需要一一分离。
如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们,另一方面只有当我们确信职责会发生变化时,分离这些职责才是有意义的。如果目前这两个职责虽然被耦合在一起,但暂时没有变化的迹象,那么可能没有必要急于将它们分开。可以在未来需要重构代码时再考虑分离,这样可以避免过早优化。
以下是一些时机和情况,你可以考虑分离职责:
- 功能复杂性增加:当一个类或方法开始承担越来越多的职责,导致它变得过于复杂时,应该考虑分离职责。例如,如果一个类同时处理数据访问、业务逻辑和用户界面显示,这可能是一个迹象表明需要将这些功能分离到不同的类或模块中。
- 修改频率和原因:如果你发现一个类因为多种不相关的原因需要频繁地修改,这通常是这个类承担了过多职责的标志。每个职责应该是一个改变的唯一原因;如果存在多个改变的原因,那么应该将这个类拆分。
- 可测试性问题:难以为一个类编写测试,或者测试非常复杂,可能是因为类包含了多个不同的功能,这增加了测试的难度。将职责分离可以提高测试的简易度和覆盖率。
- 重用性要求:当你发现在多个地方需要重用类中的某些功能时,单独将这些功能提取出来成为独立的类可以增加代码的复用性。
- 团队协作需求:在一个大型项目中,多个开发人员可能会同时工作在同一个模块上。如果一个类或模块包含多个职责,可能会造成工作重叠或冲突。职责的适当分离可以帮助团队更有效地协作。
违反单一职责
在人的常规思维中,总是习惯性地把一组相关的行为放到一起,如何正确地分离职责不是一 件容易的事情。
我们也许从来没有考虑过如何分离职责,但这并不妨碍我们编写代码完成需求。对于 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
捕获到它,保证了错误处理的一致性。
其实这也是一种开闭原则的体现,对修改原功能是关闭的,保证了程序的稳定性,对扩展是开启的,子类在保证原有功能的情况下实现其他逻辑。
接口隔离原则
接口隔离原则的核心思想是:客户端不应该被迫依赖于它不使用的接口。换句话说,该原则鼓励将大接口分割成更小和更具体的接口,使得实现接口的类不必实现它不使用的方法。
解释接口隔离原则的目的:
- 降低依赖性:当接口被分割成较小的接口时,各个实现类之间的依赖关系变得更加清晰,减少了不必要的相互影响。
- 提高灵活性:通过细化的接口设计,各个类只需关注它们真正需要的部分,使得系统更加灵活,易于扩展和维护。
- 减少影响范围:当一个接口发生改变时,只影响依赖于该接口的类,而非整个系统。这降低了改动带来的风险和复杂性。
应用实例
在现代编程实践中,尤其是在使用诸如 Java 或 C# 这类强类型语言时,接口隔离原则尤为重要。考虑以下场景:
多功能设备接口: 假设有一个多功能机(打印、扫描、传真)接口 IMultiFunctionDevice
将打印、扫描、传真功能都整合在一起。这意味着即使某个设备只支持打印,它也需要实现接口中的扫描和传真方法。根据接口隔离原则,应该将 IMultiFunctionDevice
接口分割成三个接口:IPrinter
、IScanner
、IFax
。这样,一个只支持打印的设备就只需要实现 IPrinter
接口。
前端应用
在前端开发中,接口隔离原则同样适用,特别是在使用TypeScript这样的具有明确类型系统的环境中:
组件开发: 当开发大型应用的UI组件时,可以为不同的功能(如渲染、事件处理、数据获取等)定义不同的接口,而每个组件只实现它需要的接口。这有助于降低组件之间的耦合度,并提高代码的可维护性。
总之,接口隔离原则是一种鼓励细粒度界定职责和功能的设计方法,能有效提升代码的质量和可维护性。在设计系统时,考虑这一原则有助于创建清晰、易于管理和扩展的API和系统架构。
合成复用原则
合成复用原则推荐开发者在编写代码的时候,优先使用对象组合的方式来代替继承来实现代码的复用。
本意就是为了减少类的爆炸增长和继承的强耦合,由于继承本身就是一种强耦合结构,父类的改变都可能影响到子类的行为,相比之下,使用组合多个对象来实现功能,这使得组合对象之间的关系是松耦合的,甚至可以通过接口形式来更大程度的解耦。
在前端应用中,vue2的mixin混入和vue3的Composition API都是通过组合的形式实现代码的复用。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据