解耦在js中的应用
errol发表于2023-11-18 12:39:24 | 分类为 编程 | 标签为js解耦

软件工程中,耦合(Coupling)表示两个子系统(或类)之间的关联程度,当一个子系统(或类)发生变化时对另一个子系统(或类)的影响很小,则称它们是松散耦合的;反之,如果变化的影响很大时,则称它们是紧密耦合的。

以上是百度百科中关于耦合的描述。

代码解耦一直以来都是编程中的重要课题,许多编程概念、思想、技术,本质上就是为了划分职责,降低模块(系统、类、方法等)之间的关联度的,模块之间的关联度越低,项目系统的可维护性与拓展性就越高,反之则越低。我们也经常接触到相关的术语,如面向对象、分层架构(mvc、mvvm等)、控制反转[IoC]/依赖注入[DI]、设计模式(订阅发布、策略等)、面向切面编程[AOP]等等。

但由于各种各样的众所周知的原因,许多人包括我在内,在平时写程序的时候,都不太注重代码耦合度问题。

特别是在前端开发领域,由于还“很年轻”,尚未形成行业规范,导致没有一种可遵循规章制度,程序员们都只能按照自己的理解认知来完成开发任务,或许就是前端项目系统比较参差不齐的原因。

一般情况下,我们会不假思索地把业务与非业务逻辑代码混杂在一起,因为确实没有谁指导或提供将二者分离的代码编写方式,而且这样也确实能更快实现功能并看到成果,大多数人都乐于采用这样的方式。

例如,前端开发中,向服务器请求数据是必不可少的,在某些情况下,请求之前需要进行参数校验,而这里就产生了比较典型的接口调用和接口参数校验逻辑的耦合。

不过,这很可能会留下隐患(虽然暂时看不出来)...

在随后的项目迭代中,耦合的开发方式无疑会对现存的功能进行改动,而这种“改动”很可能会导致新问题的出现(不改动就不会有问题);不仅如此,这种“改动”还很可能会在原来的基础上不断增加代码量,比如,某一天需求发生了变更:除了要校验参数外,还要禁止频繁点击,并且后续可能还有其他别的需求... 久而久之,一个难以名状的“庞然大物”形成了。

此时,无论是从维护的角度,还是从拓展的角度,都是非常难以入手的(俗称“祖传代码”),因此,开发当中应该要极力避免这种局面。

相信没有人会想看到这样的事情,但苦于不知道从何处、怎么开始,缺少一种实际落地的简单有效的方法手段。

而今天就是针对这一问题,为大家带来新的思考角度。

ps:文中的示例代码将会用到Function.prototype.bind()Function.prototype.apply()接口,此外,还用到了promiseasync 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、在当前禁用拦截器添加恢复逻辑

这种方式需要对工具源码做一些改动,此外还需要在被拦截的方法中返回数据,作为判断本次执行是否通过的依据,如:

以下代码是本次更新新增的测试方法

image

图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、触发禁用效果

image

图2 禁用按钮

2、延迟过后,解除禁用

image

图3 解除按钮禁用

以上就是本次更新的说明,示例代码也已经更新至仓库中。

返回