软件工程中,耦合(Coupling)表示两个子系统(或类)之间的关联程度,当一个子系统(或类)发生变化时对另一个子系统(或类)的影响很小,则称它们是松散耦合的;反之,如果变化的影响很大时,则称它们是紧密耦合的。
以上是百度百科中关于耦合的描述。
代码解耦一直以来都是编程中的重要课题,许多编程概念、思想、技术,本质上就是为了划分职责,降低模块(系统、类、方法等)之间的关联度的,模块之间的关联度越低,项目系统的可维护性与拓展性就越高,反之则越低。我们也经常接触到相关的术语,如面向对象、分层架构(mvc、mvvm等)、控制反转[IoC]/依赖注入[DI]、设计模式(订阅发布、策略等)、面向切面编程[AOP]等等。
但由于各种各样的众所周知的原因,许多人包括我在内,在平时写程序的时候,都不太注重代码耦合度问题。
特别是在前端开发领域,由于还“很年轻”,尚未形成行业规范,导致没有一种可遵循规章制度,程序员们都只能按照自己的理解认知来完成开发任务,或许就是前端项目系统比较参差不齐的原因。
一般情况下,我们会不假思索地把业务与非业务逻辑代码混杂在一起,因为确实没有谁指导或提供将二者分离的代码编写方式,而且这样也确实能更快实现功能并看到成果,大多数人都乐于采用这样的方式。
例如,前端开发中,向服务器请求数据是必不可少的,在某些情况下,请求之前需要进行参数校验,而这里就产生了比较典型的接口调用和接口参数校验逻辑的耦合。
不过,这很可能会留下隐患(虽然暂时看不出来)...
在随后的项目迭代中,耦合的开发方式无疑会对现存的功能进行改动,而这种“改动”很可能会导致新问题的出现(不改动就不会有问题);不仅如此,这种“改动”还很可能会在原来的基础上不断增加代码量,比如,某一天需求发生了变更:除了要校验参数外,还要禁止频繁点击,并且后续可能还有其他别的需求... 久而久之,一个难以名状的“庞然大物”形成了。
此时,无论是从维护的角度,还是从拓展的角度,都是非常难以入手的(俗称“祖传代码”),因此,开发当中应该要极力避免这种局面。
相信没有人会想看到这样的事情,但苦于不知道从何处、怎么开始,缺少一种实际落地的简单有效的方法手段。
而今天就是针对这一问题,为大家带来新的思考角度。
ps:文中的示例代码将会用到Function.prototype.bind()、Function.prototype.apply()接口,此外,还用到了promise和async function,用于处理异步流程。
一、思路预热
我们在日常生活中都看到、接触过洋葱,知道它由多个层级构成,每两层之间互相连接,而这种结构与调用栈十分相近,从外层到内层就像是一次函数/方法的调用:调用最外层的函数,紧接着调用次外层的函数(嵌套),... ,直到调用最内层的函数,此时调用开始返回。
本文章的灵感正是来源于此。
在这种结构中,将非核心业务代码分发交给各层处理,把最内层作为核心业务层,这样也就达成了解藕的效果。
二、解耦核心代码
以下声明了一个名为applyingInterceptors的函数,它接收两个参数,fn为核心业务方法,而interceptors是一组被称为“拦截器”的对象,拦截器对象内含有在fn执行前后运行的方法,可以拦截某一次执行fn的动作。
// 拦截器对象
export const exampleInterceptor = {
// 在执行核心方法前执行
preHandle(ctx) {},
// 在执行核心方法后执行
postHandle(ctx) {},
// 执行核心方法出现异常的时候执行
rollback(ctx) {},
// 其他属性...
};
调用applyingInterceptors()后,首先会对拦截器进行初始化,包括分组和判断对应的拦截器方法是否存在,最后返回一个匿名函数,同时也是解耦的核心逻辑。
在匿名函数中,group1、group2中的拦截器被依次执行,如果出现错误则抛出异常;或者返回中断信号则返回调用,停止往下执行。
export const applyingInterceptors = (fn, interceptors=[]) => {
const group1 = [];
const group2 = [];
// 对所有的拦截器进行初始化
for (const interceptor of interceptors) {
if (!interceptor.group || interceptor.group == 1) {
group1.push(interceptor);
}
else if (interceptor.group == 2) {
group2.push(interceptor);
}
interceptor.isPreHandleFunction = typeof interceptor.preHandle == 'function';
interceptor.isPostHandleFunction = typeof interceptor.postHandle == 'function';
interceptor.isRollbackFunction = typeof interceptor.rollback == 'function';
}
return async function(...args) {
const errors = [];
let _this = this;
// 执行_run()时,如果返回了0,则表示不再继续往下执行;
let continued = await _run(group1, args, _this, errors);
if (errors.length) {
throw new CustomException('执行过程出现错误', errors);
}
let result;
try {
if (continued === 0) {
return;
}
continued = await _run(group2, args, _this, errors);
if (continued === 0) {
return;
}
result = await fn.apply(_this, args);
// 如果调用fn()没有出现异常,则调用_run2,即postHandle;
_run2(group2, args, _this);
_run2(group1, args, _this);
} catch (e) {
console.log("[applyingInterceptors]执行方法/函数出现异常:" + e.message);
// 如果调用fn()出现异常,则调用_run3,即rollback;
_run3(group1, args, _this);
_run3(group2, args, _this);
}
return result;
}
}
执行拦截器对象方法的代码如下(主要是为了方便调用而抽取的函数)。
其中run()函数接收四个参数(run2、run3为三个),分别为拦截器数组、匿名函数参数列表、this对象、错误信息容器,它们将会作为调用拦截器方法的参数。
// 执行preHandle()
const _run = async (interceptors, args, _this, errors) => {
let continued;
for (const interceptor of interceptors) {
if (!interceptor.isPreHandleFunction) {
continue;
}
continued = await interceptor.preHandle({ args, _this, interceptor, errors });
if (continued === 0) {
break;
}
}
return continued;
}
// 执行postHandle()
const _run2 = (interceptors, args, _this) => {
for (let i=interceptors.length - 1; i>0; i--) {
const interceptor = interceptors[i];
if (!interceptor.isPostHandleFunction) {
continue;
}
interceptor.postHandle({ args, _this, interceptor });
}
}
// 执行rollback()
const _run3 = (interceptors, args, _this) => {
for (const interceptor of interceptors) {
if (!interceptor.isRollbackFunction) {
continue;
}
interceptor.rollback({ args, _this, interceptor });
}
}
三、代码解耦
在这一小节中,我们将会模拟一个简单的场景:账号注册。
提交数据前,需要对参数进行校验,并且期间禁止用户频繁发送验证码。
在这个过程中,发送验证码和注册账号就是核心业务,其他的都属于非核心业务。
0、模拟接口
偷懒了,反正也不重要。。
// 注册
function register(params) {
console.log('--- 模拟注册账号 ---');
for (const key in params) {
console.log(`${key}:${params[key]}`);
}
console.log('---');
// 返回0模拟接口调用成功
return { code: 0 };
}
// 发送验证码
function sendEmailVerifyCode(params) {
console.log('--- 模拟发送验证码 ---');
for (const key in params) {
console.log(`${key}:${params[key]}`);
}
console.log('---');
return { code: 0 };
}
1、编写拦截器
首先得先编写好拦截器,才能应用于核心业务方法中。
// 注册接口拦截器
const interceptors = [{
preHandle({ args, errors }) {
const { username, password, password2, email, captcha } = args[0];
if (!username) {
errors.push("账号不能为空");
}
if (!password || !password2) {
errors.push("(二次)密码不能为空");
}
else {
if (password && password2 && (password != password2)) {
errors.push("二次密码输入不正确");
}
}
if (!email) {
errors.push("邮箱不能为空");
}
else {
if (!emailRule.test(email)) {
errors.push("邮箱格式不正确");
}
}
if (!captcha) {
errors.push("验证码不能为空");
}
}
}];
// 发送验证码接口拦截器
const interceptors2 = [{
preHandle({ args, errors }) {
const { email } = args[0];
if (!email) {
errors.push("邮箱不能为空");
}
else {
if (!emailRule.test(email)) {
errors.push("邮箱格式不正确");
}
}
}
}, {
group: 2,
preHandle({_this }) {
if (_this.cd != CD) {
return 0;
}
let timer = setInterval(() => {
_this.cd = _this.cd - 1;
_this.btnText = `${_this.cd}秒后重试`;
if (_this.cd === 0) {
_this.cd = CD;
_this.btnText = defaultBtnText;
clearInterval(timer);
}
}, 1000);
}
}];
2、应用拦截器
需要注意的是,由于发送验证码接口拦截器中需要用到this对象,因此它需要在组件的生命周期中调用方法,将this绑定到匿名方法【demo中使用的是vue】。
register = applyingInterceptors(register, interceptors);
// vue组件的created钩子
created() {
sendEmailVerifyCode = applyingInterceptors(sendEmailVerifyCode, interceptors2).bind(this);
}
3、核心业务代码
可以明显地看到,代码量非常少,因为非核心业务代码已经通过拦截器分发出去了,这时,两个模块之间的关联度相对较低,即使其中一边发生了改动,对另一边的影响是比较小的。
methods: {
async register() {
try {
const resp = await register(this.formData);
if (resp && resp.code == 0) {
this.$message.success("注册成功");
}
} catch (error) {
console.log(error);
if (error instanceof CustomException) {
this.$message.error(error.defaultErrorMessage);
}
}
},
async sendEmailVerifyCode() {
try {
const params = {
email: this.formData.email
};
const resp = await sendEmailVerifyCode(params);
if (resp && resp.code == 0) {
this.$message.success("发送成功");
}
} catch (error) {
console.log(error);
if (error instanceof CustomException) {
this.$message.error(error.defaultErrorMessage);
}
}
}
}
四、前端界面及运行效果
本文章所用到的demo存放在这里,需要的读者可以自行下载测试。
1、获取验证码运行效果
2、注册账号运行效果
到目前为止,已经成功地实现代码解耦了。
当然也还没有“完全”解耦,比方说,在拦截器方法中传入了this对象,这样一来,双方都可以读写this对象,会互相产生影响,这不是个好的设计,但奈何只有使用this时才比较方便,目前还没找到好的解决方式。
总的来说,这种实现解耦的方法并不算复杂,只有少量的几行代码组成,不需要具备丰富的理论知识也能自行实现。
然而,这并不是最重要的,我真正想说的是,js这门语言,如果能有效利用其动态性(将函数作为参数传递,动态绑定&动态调用等特性),就可以完成一些比较复杂的功能。
当然,文中的示例只是一个比较简单的功能,所以还得读者根据自己的实际情况挖掘开发。
以上就是本篇文章的全部内容了,希望对读者有所帮助,如若存在不足之处或有好的建议想法,还请不吝赐教。
20240114更新
目前作者已经在几个项目中应用了该解耦小工具,感觉还不错,不过在使用的时候,也发现了一个体验很不好的问题。
当禁用类的拦截器触发效果后,再出现其他错误时,会停留在当前状态(如账号密码输入不正确时停留在登陆界面),由于没有恢复状态的逻辑,目标元素仍处于被禁用状态,无法再次操作,需要重新加载。
针对这一情况,今天对该小工具做一次小更新。
要处理这个问题很简单,既然没有恢复逻辑,那就添加。
有以下两种方式:
- 在新增的拦截器中添加;
- 在当前禁用拦截器添加。
1、 在新增的拦截器中添加恢复逻辑
相对来说,这种方式是最容易实现的,且不需要对工具进行改动。
import { onlyOneClickInterceptor, timeoutRollback, disabledInterceptorPreHandle } from "./interceptor";
// ...
const interceptors4 = [
{
preHandle: disabledInterceptorPreHandle,
paramName: 'btnDisabled2',
},
{
group: 2, paramName: 'btnDisabled2',
preHandle: timeoutRollback // 在新增的拦截器中添加了一个延迟解除禁用的处理器
}
];
export const timeoutRollback = ({ args, _this, interceptor, errors }) => {
const { paramName, timeout } = interceptor;
setTimeout(() => {
_this[paramName] = false;
}, timeout || 2000);
};
拦截器运行后,其中的处理器被依次执行,其中也包括了解除禁用拦截器,它将会在指定的延迟时间后触发效果,即解除禁用状态。
2、在当前禁用拦截器添加恢复逻辑
这种方式需要对工具源码做一些改动,此外还需要在被拦截的方法中返回数据,作为判断本次执行是否通过的依据,如:
以下代码是本次更新新增的测试方法
图1 login方法
在login方法中返回了resp。
const interceptors4 = [
{
preHandle: disabledInterceptorPreHandle,
paramName: 'btnDisabled2',
// 在异常处理器中添加解除禁用的逻辑
rollback: ({ args, _this, interceptor, errors }) => {
_this[interceptor.paramName] = false;
}
}
];
新增判断方法:
util/interceptor.js
// 一般通用的isPass方法
export const isPassForCommon = (resp) => {
if (resp && resp.code == 0) {
return true;
}
return false;
}
修改applyingInterceptors:
util/common.js
fn()返回了数据时,就使用isPass()进行判断,如果不符合预期,则将会抛出异常,并触发异常处理器,从而达到恢复状态的效果。
当然,如果有需要时,可以自定义isPass()。
import { isPassForCommon } from "../interceptor";
// ...
export const applyingInterceptors = (fn, interceptors = [], options) => {
options = Object.assign({}, options);
let { isPass } = options;
const isPassFunction = typeof isPass == 'function';
if (!isPassFunction) {
isPass = isPassForCommon;
}
// ...
const anonymous = async function (...args) {
// ...
try {
// ...
result = await fn.apply(_this, args);
if (result && !isPass(result)) {
throw new Error('执行结果校验不通过;result=' + (JSON.stringify(result)));
}
else {
_run2(group2, args, _this);
_run2(group1, args, _this);
}
} catch (e) {
// ...
}
// ...
}
}
两种方式任选其一即可,下面将使用第一种方式进行测试。
1、触发禁用效果
图2 禁用按钮
2、延迟过后,解除禁用
图3 解除按钮禁用
以上就是本次更新的说明,示例代码也已经更新至仓库中。