携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
前言
在这个技术更迭不断的时代,一个好的编程思想是永垂不朽,一通百通的东西。
学框架,学的是外功,等到框架过时的那一天,是否就是白学了呢?而编程思想,对于框架就是换汤不换药; 比如vue2到vue3,写法有着很大的变化,而其中最大的变化是编程思想的一种转变,composition api相较于options api让高内聚拥有更多的可能性。
一些编程思想说出来可能觉得这不是理所当然,一些常识吗? 而在实际开发能真正用上又是另说了,要能察觉到优化空间,该用什么方式优化,怎么做到系统的可扩展性和可维护性。
为什么需要编程思想
俗话说,写出来让电脑能看懂的代码很简单,写出让人能看懂的代码很难。
如果初期没有做好设计,那么必然是一座屎山的诞生,虽说经历过业务的变更,所有系统最终都会演变成屎山; 但是屎也是分等级的,有屎的特别厉害的,也有屎的很轻微的;而我们尽力去做好设计,让项目不至于全盘皆💩,给项目留一片净土。
既然业务是一定会发生变更的,那么如何设计才能从容面对,不至于一点修改就让整座屎山崩塌;我们应当做到提前思考需求走向,初次编码就已经预留了后续扩展的接口,另一方面,需要时刻准备重构,在实际开发中,功能逐渐变多,需要重构时,尽快重构,否则再多下去,就没法重构,从而背上沉重的历史包袱。
编程思想的应用场景可大可小,小到这个方法怎么实现,大到模块怎么划分,甚至整个软件的架构,比如VSCode的微内核加插件体系。
高内聚,弱耦合
高内聚,弱耦合,是判断软件设计好坏的标准。
换句话说就是“内紧而外松”,内紧指的是模块内的各个功能紧密相关,从而便于移植、复用;外松指的是不同模块之间,要尽可能的不关联,使其独立存在,以免在修改时,影响到其他模块。
如果多个模块之间关系复杂,需要频繁通信、互相调用,那么就需要考虑重新划分模块。
内聚性
内聚性是指模块内各元素凝聚的状态或程度,内聚性越高,则模块单一性越强,单一性越强就越容易移植、复用。
就好比搬家时,通常多个物品为了方便搬运,我们会用个盒子装起来,如果你再有规划一点,可以将物品分类,比如衣服装一个盒子,鞋子装一个盒子,这又进一步提升了"内聚性",那么到新家卸货时就非常方便了。
举个例子,在vue2的options api的写法中,一个逻辑可能分布在data、computed、methods等各个地方,这样的写法内聚性是比较低的,不过组件颗粒度足够低也是可以高内聚的,但是颗粒度太低又是否值得呢?而vue3的compsition api的写法,我们则可以将一系列逻辑写在相同位置,而对于一些通用逻辑,我们可以很简单的封装成一个hook,这就达到了逻辑的高内聚,不至于在修改时,需要改了这里,又改那里,同时让复用也变得更简单。
偶然内聚
多个模块只是偶然间被凑到一起,内聚程度最低。
逻辑上无关,实际上也没有关联,比如工具模块,由多个不相干的工具元素组成,我们应当保证每个工具元素都可以单独出来。
逻辑内聚
只是在逻辑上是相同的模块,但并没有实质性的联系,内聚程度很低。
比如一个网站的登录方式有很多,用户可以选择QQ、微信、短信验证码、邮箱等多种方式登录,用户点击不同的按钮,通过传递一个类型参数,拉起不同的登录方式;这种内聚性很低,我们应当保证各个登录方式互相独立,类似策略模式。
时间内聚
多个模块只是在同一时间执行,并没有什么联系,内聚程度偏低。
比如打开VSCode后的初始化,需要同时执行:加载文件列表、加载插件、加载语言能力等等。
过程内聚
多个模块需要按照特定顺序来执行,内聚程度中等。
比如注册,输入账号、输入密码,二次输入密码,设置用户名等等,各个步骤的逻辑都需要按顺序执行,我们可以视情况将这一系列操作封装成一个模块,如果这里面某些步骤需要复用到其他地方,则需要考虑封装的颗粒度。
通信内聚
多个模块都共同访问和操作一个公共数据源,内聚程度中等。
比如我们通常将用户信息存储在vuex中的,登录模块需要使用,个人中心模块也需要使用。
顺序内聚
多个模块执行时,需要严格按照顺序,且后面的模块依赖前面模块的输出,内聚程度偏高。
比如我曾经实现的一个小型打包器,首先需要读取入口文件,再根据文件内容生成ast语法树,再根据ast语法树循环解析生成依赖图谱,然后根据依赖图谱,分别读取出各个文件的内容,最终通过立即执行函数加共享上层exports变量实现es module;可以看到,这里面每一步的操作都依赖于上一步的输出,内聚程度比较高,建议封装成一个模块。
功能内聚
多个元素的目的都是为了完成某一个功能,每个元素都是缺一不可,这是最高程度的内聚,这种情况下我们需要将其单独划分。
比如http请求,创建xhr实例、调用send方法和监听响应都是为了处理一个http请求。这种情况应当保证这个模块的内聚性,使其能独立存在。
耦合性
耦合性是指模块间互相的依赖程度,依赖程度越低就越容易独立出来;反之,一个模块太过于依赖于其他模块或被其他模块依赖,那么在修改时很可能会牵一发而动全身。
理想状态是各模块保管好自己的数据,完成好自己的操作;但是,总会有自己做不到的,或别人需要你做的,所以对外开放也是必要的;对外开放意味着需要跟其他模块打交道,而一旦这样的事情发生时,我们便称该模块依赖于另一模块,存在耦合性。
内容耦合
一个模块直接访问另一模块的内容,则称这两个模块为内容耦合;这是耦合性最强的类型,这也是我们需要尽量避免的。
比如两个组件,当一个组件1进行一些操作之后,组件2需要刷新界面上某个元素;做法1就是组件1操作完成后,直接调用组件2的刷新方法,这种就是内容耦合,应当需要避免;而优化的做法2,当组件1操作完成后,抛出事件,组件2监听到事件,自行刷新界面,或者通过父组件监听,然后刷新组件2。
公共耦合
多个模块都访问同一个数据,则称之为公共耦合,会造成排错困难,这就是为什么出现了一些如vuex状态管理库的原因;状态管理库通常会约束修改数据的方式,比如对某个变量,比如通过某个方法进行操作,且只能进行增加值,然后可以在这个方法中做一些限制。
外部耦合
多个模块访问同一个全局简单变量(非全局数据结构)并且不是通过参数表传递此全局变量信息被称为外部耦合;与公共耦合类似,不同的是,直接访问全局变量,而不是通过全局的某个对象来操作某个变量。
控制耦合
模块之间传递控制信息,例如标志、开关量等,而数据都是放在各自模块内部;模块之间传递信息中包含用于控制模块内部的信息被称为控制耦合;控制耦合可能会导致模块之间控制逻辑相互交织,逻辑之间相互影响,非常不利于代码维护。
比如模块A的某个状态变更为xx,然后模块B的某个流程会随之变化;这种耦合我们是需要避免的。
标记耦合
调用模块和被调用模块之间传递数据结构而不是简单数据。
多个模块通过参数表(可以简单理解为传递的是一个对象,对象中包含了数据,也可能拥有一些操作数据的方法,而不是一个原始值)传递数据结构信息被称为标记耦合。
数据耦合
调用模块和被调用模块之间传递具体的数据。
与标记耦合类似,不同点是传递的具体数据,而不是一个包含了数据的数据结构(简单理解为对象)。
非直接耦合
两个模块之间没有直接关系,完全是通过主模块来控制调用,耦合性比较弱,这也是一种理想的耦合方式,独立性较高,类似于中介者模式。
比如上述直接耦合的优化做法2,组件1完成操作后,抛出事件,父组件监听到事件,调用组件2的刷新方法。
如何做好高内聚弱耦合
宏观思路就是:定职责、做归类、划边界。
具体的手段
-
做封装,将同一逻辑封装在一起,只暴露出少量外部需要调用的接口。
-
策略模式,将不同类似模块各自封装,通过一个策略模式进行调度。
-
中介者模式,多模块之间不互相直接操作,通过一个中介者来通信。
-
发布订阅,每个模块只负责监听和派发事件,所有操作完完全全是自己执行的,这是一种高度解耦的方式,保证了模块的独立性。
-
遵守单一职责,这一点是做好设计的基础,使得模块具有独立性。
封装的建议
模块接收的参数尽可能少,依赖外部传入的参数越多,模块的稳定性就越低,同时外部使用也会更麻烦,但不是说越少越好,在保证模块的灵活性的前提下,避免一些不必要的参数传入。
模块返回的参数尽可能的多,调用方可能在不同时期需要不同的数据,而被调用方应该返回尽可能多的数据,保证能应对调用方的多种场景,至于有些数据调用方会不会使用,则不用去考虑,同时也避免了提供过多的接口。
这一点在前后端分离的场景下尤为显眼,通常后端会返回很多数据,而有些数据前端可能并不会用上,但需求是瞬息万变的,可能前一秒产品经理说只要展示这些信息,下一秒就说要额外展示其他的一些信息。
抽象
定义:抽象是从众多的事物中抽取出共同的、本质性的特征,而舍弃其非本质的特征。
-
提取共性:
打个比方,一辆宝马汽车和奥迪汽车,他们的共同点都是汽车,再抽象一点则是交通工具。 -
摒弃特征:
打个比方,小明,抛弃名字特征,就是一个男生,抛弃性别特征,就是人,抛弃物种特征,就是生物。
在编程中,抽象使得系统更稳定、生动,具象的模块可以继承抽象的模块,避免了重复编写一些共性代码,使得同一份代码可以多处复用,而更高程度的复用也降低了维护成本, 某个抽象模块发送变化时,只需改动一处则多处具象模块跟着生效;但是越抽象,下层使用起来可能就越麻烦,这个需要把控好一个度。
抽象就像是找规律,出现的场合越多,就越容易看出规律,从而可以更准确的抽象出共性;所以我们在编码的时候,初期不太建议就进行封装,除非你对可能出现的场景特别有把握,否则还是等采样场景足够多的情况下,再开始着手抽象封装。
我们在对方法、变量起名的时候,也可以考虑到抽象,如下:
// 具象,针对性太强
const getUserList = () => {}
// 抽象,更容易复用,不是完全建议这么写,为了更准确的表达意思,某些情况不能太抽象
const getList = () => {}
以下是一个简单封装localStorage和sessionStorage的示例:
// 具象写法
const storage = {
local: {
getItem(key) {
const value = localStorage.getItem(key)
if(value) {
return JSON.parse(value)
}
return value
},
setItem(key, value) {
localStorage.setItem(key, JSON.stringify(value))
}
},
session: {
getItem(key) {
const value = sessionStorage.getItem(key)
if(value) {
return JSON.parse(value)
}
return value
},
setItem(key, value) {
sessionStorage.setItem(key, JSON.stringify(value))
}
}
}
// 可以看到这里面写了两套极为相似的逻辑
// 优化:首先我们提取共性,即对getItem、setItem的封装;
// 再摒弃特征,即目标对象:localStorage和sessionStorage,
// 我们将目标对象抽象成叫storage,具体写法如下:
// 抽象
function createStorage(storage) {
return {
getItem(key) {
const value = storage.getItem(key)
if(value) {
return JSON.page(value)
}
return value
},
setItem(key, value) {
storage.setItem(key, JSON.stringify(value))
}
}
}
const storage = {
local: createStorage(localStorage),
session: createStorage(sessionStorage)
}
抽象三原则:
-
DRY原则,DRY是 Don't repeat yourself 的缩写,意思是"不要重复自己"。这个原则有时也称为"一次且仅一次"原则(Once and Only Once)。
-
YAGNI原则,YAGNI是 You aren't gonna need it 的缩写,意思是"你不会需要它"。这是"极限编程"提倡的原则,指的是你自以为有用的功能,实际上都是用不到的。因此,除了最核心的功能,其他功能一概不要部署,这样可以大大加快开发。
-
Rule of three 称为"三次原则",指的是当某个功能第三次出现时,才进行"抽象化"。
引用自:代码的抽象三原则
六大原则
单一职责 | SRP
定义:一个模块只负责一种职责的工作,不存在多个原因使得一个模块发生变化。
如果一个模块承担了过多职责,那么多个功能变动都需要改动这个模块,这种场景用一句歌词来形容就是:如此生活30年,直到大厦崩塌;如果模块中某个功能出了问题,可能要从头到尾捋一遍这个模块的逻辑,甚至是包含一些不相干的逻辑。
遵循单一职责更利于团队开发,比如说模块中的功能A其他人正在开发中,此时我需要改动功能B,只能耐心等同事开发完,否则一起修改将会在提交时造成冲突。
举个栗子:
以下是一个发送消息给订阅用户的服务示例:
function notifications(users) {
users.forEach(user => {
// 用户订阅了邮箱通知,且在白名单中
if(user.isSubscribe && user.isWhiteList) {
// 获取用户订阅记录
const record = typeorm.userModel.find(user)
const transporter = nodemailer.createTransport({
host: "smtp.qq.com",
port: 465,
auth: {
user: '*******@**.***',
pass: '*********'
}
})
const mail = {
from: '<*******@**.***>',
to: record.eamil,
text: `通知内容`,
}
// 发送邮件
transporter.sendMail(mail)
}
})
}
如果让你优化一下以上代码,你会怎么写?
首先我们来分析一下以上代码,有哪些流程:
- 1、判断用户是否订阅和是否白名单
- 2、获取用户订阅记录
- 3、发送邮件
显然notifications
方法太过臃肿,且不说后续是否还会增加其他什么功能;单是现在如果一个新人接手这段代码,可能都需要整体通读一遍,理解其逻辑。
那么通过单一职责来重构后是什么样子呢?
function notifications(users) {
users
.filter(checkSend)
.forEach(user => {
const record = findUserSubscribe(user)
sendEmail(record)
})
}
// 获取用户订阅记录
function findUserSubscribe(user) {
return typeorm.userModel.find(user)
}
// 校验用户是否可以发送通知
function checkSend(user) {
return user.isSubscribe && user.isWhiteList
}
// 发送邮件
function sendEmail(userRecord) {
const transporter = nodemailer.createTransport({
host: "smtp.qq.com",
port: 465,
auth: {
user: '*******@**.***',
pass: '*********'
}
})
const mail = {
from: '<*******@**.***>',
to: userRecord.email,
text: `通知内容`,
}
transporter.sendMail(mail)
}
那么经过改造后有什么好处呢?
降低了notifications
方法的复杂度,明显该方法代码减少,更清晰了;
通过模块划分,我们可以直接通过方法名确认其功能(这个就是代码的自注释性);阅读代码时,当看到方法名,就能知道大概的逻辑流程;修改某个功能时,我只需根据方法名即可定位到对应的代码
具体的一些例子:
- 1、假如获取用户订阅记录的方式发生变化,此时只需改动
findUserSubscribe
方法 - 2、假如白名单限制变成黑名单限制或者直接去掉,此时只需改动
checkSend
方法 - 3、假如发送的是短信通知而不是邮件,则只是新增一个
sendSms
的方法,将sendEmail
替换掉就行了 - 4、假如其他模块也需要发送邮件的功能呢,只需将
sendEmail
抽象出去,以供复用
何时需要单一职责
如定义所说一个模块引起变化的因素只有一个那就没必要;或者是一个模块中两个职责在变化时都会一起变化,就没有必要单一职责,比如上文中提到的时间内聚、过程内聚等;
创建 xhr 对象和发送 xhr 请求几乎总是在一起的,那么创建 xhr 对象的职责和发送
xhr 请求的职责就没有必要分开。
单一职责不是一成不变需要遵守的,在实际开发中,应对不同场景我们需要灵活变通,比如jQuery的很多api都具备了多个职责,其目的是为了使用的便捷性,复杂度留给了jQuery内部,这是一个成熟的库该有的担当,当然其内部也会使用一些合理的设计,来规避多个职责造成复杂度提升的问题。
总结
单一职责是一种很简单,但很有效的原则,它的优点是降低了单个模块的复杂度,按照职责把模块分解成更小的粒度,这有助于代码的复用、维护和测试;当一个职责需要变更的时候,不会影响到其他的职责。
但单一职责原则也有一些缺点,在一些简单场景过多的遵循单一职责反会增加复杂度,当我们按照职责把模块分解成更小的颗粒度之后,实际上也会增加这些模块之间通信的难度。
开放封闭 | OCP
定义:对扩展开放,对修改关闭,保证核心不受影响,又可以增加额外的功能
开放封闭是非常重要、非常基础的原则,任何一个好设计都能经得起开放封闭原则的考验,就拿vue来说,我们不可以修改vue内部的响应式设计,而我们可以使用vue.use这个api来扩展功能;还有如jQuery提供了extends来扩展功能,koa、express则提供了中间件。
插件式设计思想(插件式是一种很优秀的设计思想,下文会详细讲解)就是OCP很好的体现,像webpack的plugin、loader,最原始的webpack是只支持js文件打包,但它实现了一个非常强大的基座;如果我们需要增加对其他类型文件的支持,只需增加对应的loader,比如vue文件,可以增加vue-loader,less文件,可以增加less-loader; 如果我们需要打包完后清除之前的dist目录,则可以增加clean-webpack-plugin等。
可能以上例子对于我们比较遥远,那么我再举个贴切点的例子:
vue的插槽就是一个开放封闭原则的体现;比如说我们要封装一个弹窗组件,我们一般会内置好标题、关闭按钮、遮罩层等,然后预留出一个插槽,供外部填入内容;此处开放的就是插槽内容,封闭的就是弹窗最基本的样式、遮罩层、关闭按钮等。
举个例子
以下是一个简单的ajax封装,我们需要对外暴露出可以在请求各个时期做出处理的扩展
// 首先实现ajax的核心:发出请求,接收响应
function ajax({ url, method, params }) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.send(JSON.stringify(params));
xhr.onreadystatechange = function () {
if(xhr.readyState === 4) {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(JSON.parse(xhr.responseText))
}
}
}
})
}
// 然后我们需要对外暴露:请求前、请求中,可以实现拦截特定请求(如mock处理)、自动处理loading等
function ajax({ url, method, params }) {
const { beforeRequest, onRequest } = ajax
// 请求之前的钩子
if (beforeRequest) {
const customResponse = beforeRequest({ url, method, params })
if (customResponse) {
return Promise.resolve(customResponse)
}
}
const promise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.send(JSON.stringify(params));
xhr.onreadystatechange = function () {
if(xhr.readyState === 4) {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(JSON.parse(xhr.responseText))
}
}
}
})
// 请求发出的钩子
if (onRequest) {
onRequest(promise, { url, method, params })
}
return promise
}
// 扩展一个针对用户列表的mock拦截
ajax.beforeRequest = function({ url, method, params }) {
if (url === '/user/list') {
return [
{ name: 'ly', age: 18, id: 1 },
{ name: 'yl', age: 19, id: 2 },
]
}
}
// 扩展一个loading处理
ajax.onRequest = function(promise, { url, method, params }) {
console.log('loading')
promise.finally(() => {
console.log('clear loading')
})
}
可以看到这样一个实现,扩展功能时并不需要深入到ajax方法中去修改。
总结
对于开放封闭原则的关键在于需要找出变化和不变的地方,在系统演变过程中,可能需要不停的修改变化的地方和不变的地方,比如上面的弹窗组件,可能后续需要可以自定义关闭按钮等。
让程序一开始就完美的遵守开放封闭原则,大概是一件不太可能的事;我们需要采样到更多的使用场景,才能更准确的实现的开放封闭,在演变过程中,对于一些不合适的开放封闭,需要尽快修改。
依赖倒置 | DIP
定义:高层模块不应依赖于低层模块,二者应依赖于抽象;抽象不应依赖于细节,细节应依赖于抽象。
调用端调用抽象方法,被调用端实现抽象方法,抽象方法中在模块中做的事情不一样;两个模块,需要被其他模块调用,那么这两个模块都需要暴露一个对外统一的接口,或者可以引入一个中间层,做接口统一外发,甚至有一句话是这么说的:计算机科学中的所有问题都可以通过引入一个间接层得到解决。
举个例子
甲方要求程序员开发一个APP,开发APP是抽象,具体用什么技术开发,java、flutter、react-native,这是细节,而甲方只要求结果,不关注过程;程序员无论是用什么技术,只要最终按要求产出一个APP就行了。
// 程序员(我们假设他会java、flutter、rn等技术,不会ios开发)
class Coder {
constructor(projectManager, appName) {
this.projectManager = projectManager
this.currentApp = {
name: appName
}
}
developFlutterApp() {
const app = null
const codeDart = () => {
return Promise.resolve({})
}
codeDart().then(pkg => {
this.currentApp.pkg = pkg
this.projectManager.receiveApp(this.currentApp)
})
}
developJavaApp() {
const app = null
const codeJava = () => {
return Promise.resolve({})
}
codeJava().then(pkg => {
this.currentApp.pkg = pkg
this.projectManager.receiveApp(this.currentApp)
})
}
developRNApp() {
const app = null
const codeCss = () => {
return Promise.resolve({})
}
const codeJsx = () => {
return Promise.resolve({})
}
const codeJs = () => {
return Promise.resolve({})
}
Promise.all([
codeJsx(),
codeCss(),
codeJs(),
]).then(pkg => {
this.currentApp.pkg = pkg
this.projectManager.receiveApp(this.currentApp)
})
}
}
// 项目经理(这里充当一个中介者的角色,需求方不会去关注技术细节,所以委托给项目经理,项目经理再做一层转换)
class ProjectManager {
releaseListeners = []
developApp({
appName,
ios = false,
android = true,
performance,
time
}) {
const coder = new Coder(this, appName)
const developTypes = [
{
name: 'RN开发',
develop: coder.developRNApp.bind(coder),
config: {
ios: true,
android: true,
performance: 50,
time: 30
}
},
{
name: 'Flutter开发',
develop: coder.developFlutterApp.bind(coder),
config: {
ios: true,
android: true,
performance: 80,
time: 60
}
},
{
name: 'Java开发',
develop: coder.developJavaApp.bind(coder),
config: {
ios: false,
android: true,
performance: 100,
time: 90
}
},
]
const canDevelopTypes = developTypes.filter(item => {
const { name, develop, config } = item
if (ios && !config.ios) {
console.log(`${appName}用${name}不支持ios`)
return false
}
if (android && !config.android) {
console.log(`${appName}用${name}不支持android`)
return false
}
if (performance > config.performance) {
console.log(`${appName}用${name}性能达不到要求`)
return false
}
if (time > config.time) {
console.log(`${appName}用${name}时间不够`)
return false
}
return true
})
if (canDevelopTypes.length > 0) {
console.log(`${appName}可以使用:${canDevelopTypes.map(item => item.name).toString()}`)
console.log(`${appName}我们最终使用了:`, canDevelopTypes[0].name)
return canDevelopTypes[0].develop()
}
console.error(`${appName}要求太高,干不了!`)
}
// 接收程序员提交的app
receiveApp(app) {
if (app.bugCount > 0) {
console.log(`${appName}还有bug?改!`)
return
}
this.onRelease(app)
}
onRelease(app) {
console.warn('无监听者')
}
}
// 需求方,负责提出需求和验收
class DemandSide {
getApp(condition) {
const projectManager = new ProjectManager()
projectManager.developApp(condition)
// 监听app发布
projectManager.onRelease = this.verifyReceipt
}
verifyReceipt(app) {
console.log(`验收了${app.name}`)
}
}
const demandSide = new DemandSide()
demandSide.getApp({
appName: 'app1',
ios: true,
android: true,
performance: 50,
time: 30
})
在以上示例中,需求方依赖项目经理的developApp和onRelease接口,程序员则依赖项目经理的receiveApp接口,双方都各自依赖着项目经理,并不是需求方和程序员直接互相调用;如果哪一天这个程序员离职了,换了个程序员,需求方根本不用知道,这样就增加了需求方的稳定性。
总结
依赖倒置原则需要一定的抽象能力,是面向接口编程的一种体现,容易变动的模块尽可能的提供抽象接口,使得其在变动时不会影响到其他的模块。
最少知识/迪米特法则(LKP)
定义:我们在设计程序时,应当尽量减少对象之间的交互,一个对象应该对其他对象保持最少的了解。
遵循最少知识原则,可以降低模块之间产生耦合的可能性,而耦合带来的缺点上文也有提及,会发生牵一发而动全身的情况。
举个例子:
春节的时候,家家户户都会进行一点打麻将之类的娱乐活动,我们通常都是这么算账的,只会关心自己的该入账多少、出账多少,从来不会关心别人赢多少输多少;只关心自己的账可以大大减少出错的概率。
总结
对于不应该让外部访问的接口,应该做到私有化,避免外部意外的访问到,外部对当前模块知道的越少,变动时产生副作用的概率也就越低;如vue3相较于vue2新增的defineExpose接口,也是遵循了这个原则。
接口隔离 | ISP
定义:调用端不应该依赖那些它不需要的接口;用多个专门的接口,而不使用单一的总接口。
接口隔离理解起来和单一职责有点相似,接口隔离让多个接口各干各的事,互不干扰,可以避免污染,也是高内聚的一种体现。
举个例子
比如说,后端同学需要向前端同学提供用户列表和用户详情的查询,有点经验的后端同学都不会将用户详情在用户列表中返回给前端同学,而是提供一个专门获取用户列表和一个专门获取用户详情的接口。
里斯替换 | LSP
定义:子类对象可以替换父类对象,而不会引起程序的异常和错误。
里氏替换原则是实现开闭原则的体现之一,其意思也和开闭原则差不多,子类可以扩展父类的功能,但不能改变父类原有的功能;也就是说:子类继承父类时,可以新增功能,但不能覆写父类的功能;简单来说就是子类可以向上兼容吗,这也和我们模块升级一致,我们升级通常要照顾以前的历史版本。
这里用词虽然是类,但不代表不适用于函数式,所有的编程原则并不局限于某种编程范式。
面向切面 | AOP
面向切面,就是在层与层之间加入代码,成为切面代码;面向切面的主旨是不破坏封装的原则下,添加新的功能,和开放封闭原则非常相似。
像webpack的插件机制,可以理解为是一种AOP,它的原理是在打包的各个时间点会抛出一些事件,事件携带了当前打包的产物,插件中可以对这些产物进行一些处理。
再比如说,我们封装一个vue指令用作用户行为记录埋点,也可以理解为是一种AOP,避免了与业务逻辑混合。
举个例子
假如我们需要往一个非常庞大的函数中添加新的功能,最坏的做法是继续往里面添加代码,那么用AOP的思想来优化该怎么做呢?大概是这样:
function fn() {
... // 此处代码几千行
}
const fnTemp = fn
fn = function () {
fnTemp()
... // 新增的功能
}
我们再简单给Function原型链上扩展出一个after函数,来简化优化上述用法
Function.prototype.after = function (callback) {
return () => {
const ret = this.apply( this, arguments );
callback.apply( this, arguments );
return ret;
}
}
function fn() {
... // 此处代码几千行
}
fn = fn.after(() => {
... // 新增的功能
})
fn()
以上解决方法其实都不算好,只能算是无奈之举,我们应该从源头避免这种问题,不应该让一个方法如此庞大。
面向接口 | IOP
面向接口的意思是不关心具体实现,只关心你是否暴露了这个接口。
像我们说的鸭子类型就是面向接口的体现,“如果它走起路来像鸭子,叫起来也是鸭子,那么我们认为它就是一只鸭子。”
比如说招聘,你有什么技能,符合哪些条件,就是你符合了我的接口定义,你额外的一些因素:会打球、唱歌,这些和我招聘要求无关的我都不会管。
面向接口的好处就是使得逻辑扩展性高,表明上我们是这个模块依赖了另一个模块,而实际上只是这个模块依赖了这些抽象出来的接口,我们换一个符合这个接口定义的模块,仍然是不会影响到整体的。
举个例子
ES6新增的for of语法,就是面向接口的设计,它可以用来遍历具备Iterator接口的数据结构。
原生具备 Iterator 接口的数据结构有以下这些:
- Array
- Map
- Set
- String
- TypedArray
- 函数的 arguments 对象
- NodeList 对象
我们可以自行实现一个具备Iterator接口的数据结构,来满足for of的要求:
const obj1 = {}
// for of会报错,因为obj1并不具备Iterator接口
for(const item of obj1) {} // Uncaught TypeError: obj1 is not iterable
const obj2 = {
i: 0,
[Symbol.iterator]() {
return {
next() {
obj2.i ++
return {
value: obj2.i,
done: obj2.i > 10
}
}
}
}
}
for(const item of obj2) {
// 依次打印:1,2,3,...,10
console.log(item)
}
关于Iterator具体请见:es6.ruanyifeng.com/#docs/itera…
插件式架构
什么是插件式
插件式式是拥有一个较少功能的系统内核,但支持扩展许许多多的插件,这些插件对于系统来说是可以没有的;就好比一个笔记本,预留了HMDI接口、USB接口等,我们可以给笔记本扩展一个显示器、鼠标、键盘等,就算笔记本没有这些,我们也照样可以使用,只不过没那么方便而已。
可以说现在的微前端也是插件式架构的一种体现,其中项目基座/主应用对应系统内核,一个个子系统就是一个个插件。
插件式的好处
系统的功能被划分为插件模块和系统核心,他们之间互不干扰,保证了逻辑的高内聚、弱耦合,提供了可扩展性、灵活性、功能隔离的特性;不会因为新增功能而导致系统复杂度提升。
我们在做设计的时候并不是一蹴而就的,是需要持续完善的,很难把具体的功能都能想的清清楚楚,退一步讲,就算能想的清清楚楚,在目前这种敏捷开发的大环境下也是不适用的,一人之力难以支撑项目的快速上线,通常都需要他人的协作,而插件式就是一种我们可选的很好的设计。
如何实现插件式
实现插件式,初期需要考验程序员的抽象能力,对于具体功能我们无法想的清清楚楚,而我们考虑一些抽象情况就相对简单许多,然后就是如何实现一个稳定的插件基座,我们可以基于未来会需要扩展的哪些功能来进行定义,对外暴露出一些可能会被用到的接口。
插件连接/配置方式
首先我们看看一些流行库是如何做插件式的:
- vue.use,内部是个install接收一个vue实例,通过操作vue实例来进行功能扩展;
- webpack,webpack的配置很多,其中有一个plugin配置,接收一个实例化的插件,插件中可以监听到webpack抛出的很多事件,然后插件通过操作事件携带的内容进行功能扩展;
- pinia,它提供了赋值和取值的钩子,我们通过这些钩子对读取值进行一些额外的处理,比如说我们可以给它扩展一个持久化插件
可以看到这些流行库实现插件大致就两个思路:
- 1、约定配置,供系统内核直接调用;
- 2、钩子机制,系统内核在指定时期抛出事件,插件监听做处理。
插件式实践
还是ajax封装,上文中开放封闭原则也封装了ajax,有一点插件式的感觉,但不完善。
function ajax({ url, method, params }) {
return new Promise((resolve, reject) => {
const requestConfig = { url, method, params }
const beforeListeners = []
const requestListeners = []
const responseListeners = []
// 执行插件
ajax.plugins.forEach(plugin => {
plugin({
beforeRequest(callback) {
beforeListeners.push(callback)
},
onRequest(callback) {
requestListeners.push(callback)
},
onResponse(callback) {
responseListeners.push(callback)
}
})
})
// 设置请求前挂钩
for(const listener of beforeListeners) {
const ret = listener(requestConfig)
if (ret) {
return resolve(ret)
}
}
const promise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.send(JSON.stringify(params));
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(JSON.parse(xhr.responseText))
}
}
}
})
// 设置响应挂钩
for(const listener of responseListeners) {
listener((resolve, reject) => {
promise = promise.then(resolve, reject)
})
}
// 设置请求中挂钩
for(const listener of requestListeners) {
listener(promise, requestConfig)
}
return resolve(promise)
})
}
ajax.plugins = []
// 定义插件扩展接口
ajax.use = function (plugin) {
ajax.plugins.push(plugin)
}
// loading插件
const loadingPlugin = ({ onRequest }) => {
onRequest(promise => {
console.log('loading...')
promise.finally(() => {
console.log('loading end')
})
})
}
// mock插件
const mockPlugin = ({ beforeRequest }) => {
beforeRequest(({ url }) => {
if (url.endsWith('/todos/1')) {
return Promise.resolve({
aaa: '11232',
b: 'dsadasd'
})
}
})
}
// 应用插件
ajax.use(loadingPlugin)
ajax.use(mockPlugin)
ajax({
method: 'get',
url: 'https://jsonplaceholder.typicode.com/todos/1',
}).then(res => {
console.log(res)
})
以上通过插件式的设计,无论后续扩展多少功能都不会提升ajax方法的复杂度,而插件的一些功能也非常的高内聚。
颗粒度
在颗粒度的问题上,不是说越低越好,编程中最大的准则永远是用最合理的设计来规划当前所面临的场景;和单一职责类似,模块的颗粒度越大,被修改的概率也就越大,模块的颗粒度越小,就越稳定,但随之又会带来组装的复杂性。
上文中提到的两种编程范式:面向对象和函数式,面向对象的颗粒度更高,带来的好处是组织性更好,而函数式的颗粒度更低,可形成的组合很多,但是多则乱,非常考验程序员的素养。
我们经常会有这样的感觉,明明感觉自己已经封装了一个非常通用的模块,可以应对各种场景,可现实非常残酷,总会有那么一些需求我们无法满足;这种情况大概率是因为模块的颗粒度太高。
如果一个模块,尽管拥有多个功能,但它的代码少的可怜,而且还需要互相通信,同时他们又没有被复用的可能性,这种情况下,我们完全没必要去再将它拆分成多个模块;可能拆分后,因通信难度的提升,反而还增加了复杂度。
像tailwind-css,相较于传统的css库,它是一个颗粒度非常低的css库,基本上一个类对应一个css样式,好处是不用离开html就可以写出想要的样式效果,还可以省去给class起名;而坏处则是可维护性,阅读taildwind-css提供的class远没有阅读我们熟悉的css来的直观,同时这种设计也算是违反了结构样式分离的原则;总的来说taildwind-css有利有弊,但这只是我个人的一些看法,有更深的见解非常欢迎分享。
逻辑视图分离
上文中说到的分层设计,逻辑视图分离也是一种分层设计;解耦了视图和逻辑的关联,代码可以更大可能的复用。
针对逻辑一致,只是视图层展示不同的场景,我们应该做到逻辑视图分离,如果有新的展示方式,我们则完全可以不改动逻辑,只需要专注于视图。
举个例子
我在开发project-helper时,分离了文件列表的操作逻辑和视图。
这里面文件选择、移除、清空等都是非常基础的逻辑,我将其抽象出来,再到视图组件中在引入使用。
- 逻辑:
import { ref } from 'vue';
const remote = require('@electron/remote');
function useFileChoose({
filters,
repeatable = false
}) {
if(!Array.isArray(filters)) {
throw new Error('Filters只能为数组');
}
const uniqueFilePaths = new Set();
const fileList = ref([]);
function chooseFile() {
return choose(false, false);
}
function chooseFiles() {
return choose(false, true);
}
function chooseFolder() {
return choose(true, false);
}
function choose(openDirectory = false, multiSelections = true) {
return remote.dialog.showOpenDialog({
properties: [
openDirectory ? 'openDirectory' : 'openFile',
multiSelections ? 'multiSelections' : null
].filter(item => item),
filters: filters
}).then(({ canceled, filePaths }) => {
if(!canceled) {
fileList.value = fileList.value.concat(
repeatable ? filePaths : filePaths.filter(item => {
const result = !uniqueFilePaths.has(item);
uniqueFilePaths.add(item);
return result;
})
);
return fileList.value;
} else {
return Promise.reject({ errMsg: 'canceled' });
}
})
}
function removeFile({
filePath,
index
} = {}) {
if(typeof index !== 'number') {
if(typeof filePath !== 'string') {
throw new Error('必须传入filePath或index');
}
index = fileList.value.findIndex(item => item === filePath);
}
if(index < 0 || index >= fileList.value.length) {
throw new Error('Delete target does not exist');
}
uniqueFilePaths.delete(fileList.value[index]);
fileList.value.splice(index, 1);
return fileList.value;
}
function clear() {
uniqueFilePaths.clear();
return fileList.value = [];
}
return {
fileList,
chooseFile,
chooseFiles,
chooseFolder,
removeFile,
clear,
}
}
export default useFileChoose;
- 视图:
<template>
<div class="container">
<div class="top">
<div class="left">
<Button @click="chooseFiles()" icon="add">添加文件</Button>
<Button @click="clear()" icon="clear-empty" icon-in="right">清空列表</Button>
</div>
<slot name="top-right"></slot>
</div>
<div class="block-wrap">
<template v-if="fileList.length">
<slot name="file-list" :fileList="fileList" :chooseFiles="chooseFiles" :removeFile="index => removeFile({ index })"></slot>
</template>
<div class="empty" v-else @click="chooseFiles()">
<i class="iconfont icon-upload"/>
<div class="tips">{{ emptyTips }}</div>
</div>
</div>
</div>
</template>
<script setup>
import useFileChoose from '@/core/file-choose';
const {
fileList,
chooseFiles,
removeFile,
clear,
} = useFileChoose({
filters: props.filters
})
</script>
<style lang="less" scoped>
...
</style>
反过来说
逻辑不同,视图层一致的场景,其实也很好理解,我们的一些基础UI组件就是这样一种设计,我们采用不同的逻辑往组件中填充数据。
个人看法
不要重复造轮子?
虽然大家都在说不要再重复造轮子了,我认为在工作中为了效率和稳定性确实不要重复造轮子,但为了自己的技术能力提升,可以尝试在自己的项目中造轮子,而造轮子对个人能力的提升是非常大的。
过度设计?
俗话说没有绝对的对与错,在对于一些很简单的场景,设计的太多确实会有过渡设计的嫌疑; 而对于一些复杂的场景而言,若是不好好设计一番,后续的工作将会难以进行; 所以什么时候需要什么样的设计,设计到哪种程度、哪种深度,全凭当下所面临的场景自行把控和取舍。
🎉 完结
以上是我这些年来的对于编程思想的一些理解,写作期间明显感觉到有些地方功力不足,如有遗漏或错误,欢迎大家补充和纠正。
其实设计模式也是属于编程思想的范畴,但设计模式是一个比较庞大的话题,本文不再敞开细说,每一种设计模式都值得作为一篇文章拿出来讲解。
本次写作目的有很多,一是加深自己对编程思想的理解,二是技术分享,三是给自己一个警示,约束自己的编码,做到不要没设计,也不要过度设计。
最后推荐一本书《JavaScript设计模式与开发实践》,作者幽默风趣,结合实例讲解,浅显易懂。
参考资料: