本文首发于个人博客 https://maclaren0920.github.io
AOP的基本概念
什么是AOP?首先我们看一下来自维基百科的一段简单概述:
AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,是计算机科学中的一种程序设计思想,旨在将横切关注点与业务主体进行进一步分离,以提高程序代码的模块化程度。通过在现有代码基础上增加额外的通知(Advice)机制,能够对被声明为“切点(Pointcut)”的代码块进行统一管理与装饰,如“对所有方法名以‘set*’开头的方法添加后台日志”。该思想使得开发人员能够将与代码核心业务逻辑关系不那么密切的功能(如日志功能)添加至程序中,同时又不降低业务代码的可读性。面向切面的程序设计思想也是面向切面软件开发的基础。
AOP这个词相对于后端开发来说会比较常见,比如java中大名鼎鼎的spring,反之前端却比较陌生,是一个常常被忽视的技术,其主要和javascript是一门多范式、弱面向对象的语言有一定的关系。
为什么需要AOP
面向对象编程是针对业务处理中软件实体、行为和属性进行的封装和抽象,而面向切面编程是针对具体业务中核心功能和其他业务中的职责和逻辑分离,来减少系统的耦合度。
面向切面编程(即AOP)是一种新的编程方式,但是并不是为了替代面向对象编程,它只是对面向对象的一种补充和完善,它们两者是一种相辅相成的关系。
使用场景
在前端常见的需求中,以下业务场景中经常能看到AOP的影子:
- 表单验证
- 数据埋点、数据上报
- 基于老旧项目扩展新需求
- 异常处理
下面我们来看下AOP在代码中的具体实现方式。
应用实例
前面说了一大堆的理论,好像感觉并没什么用,下面我用实例案例来看看AOP的实际应用。
1. 数据埋点、数据上报
在实际业务开发中,我们可能会有数据数据埋点的需求,比较传统的实现方式应该是这样:
const sendPoint = () => {
axios('https://www.xxx.com');
...
}
const onSubmit = () => {
sendPoint();
// 提交逻辑...
}
onSubmit();
这好像并没有什么问题,但是onSubmit函数并不依赖于sendPoint,甚至在onSubmit中sendPoint完全是多余的,那么我们用AOP来优化下它:
首选实现下AOP函数:
Function.prototype.before = function(fn) {
const self = this;
return function () {
fn.apply(this, arguments);
return self.apply(this, arguments);
}
}
然后调用onSubmit就变成了:
onSubmit.before(sendPoint)();
你可能不太喜欢这种基于原型扩展的方式,这样污染了原型,我们同样可以用普通函数来实现:
const before = function (originalFn, fn) {
const self = this;
return function () {
fn.apply(this, arguments);
return originalFn.apply(this, arguments);
}
}
然后修改下onSubmit函数的定义:
const onSubmit = before(() => {
sendPoint();
// 提交逻辑...
}, sendPoint);
onSubmit();
这样onSubmit和sendPoint函数的逻辑就完全分离了。
2. 表单验证
表单验证是实际开发中非常常见的需求,在有些项目中可能会存在非常多的表单;每次在提交的接口时,都需要做数据校验来确保传递给后端的数据是正确的。
const validate = (params) => {
if (!params.username) {
return false;
}
if (!params.password) {
return false;
}
...
return true;
}
const onFormSubmit = () => {
const bool = validate(params);
if (bool) {
axios('xxxx')
...
} esle {
...
}
}
onFormSubmit();
以上代码是经常会遇到的,仔细分析下,onFormSubmit只是一个提交接口,却承担了提交数据到后台和数据校验的职责,接下来我们用AOP来对它们进行优化:
这里参考以上数据埋点的示例,AOP函数的实现方式基本一致,只是表单验证这里的参数校验函数需要返回一个boolean值,我们对此进行一下优化:
Function.prototype.before = function(fn) {
const self = this;
return function () {
const bool = fn.apply(this, arguments);
if (!bool) {
return;
}
return self.apply(this, arguments);
}
}
然后调用onFormSubmit的方式就变成了:
onFormSubmit.before(validate)();
这样onFormSubmit是不是就变成了一个非常干净的提交接口,和数据校验的逻辑分开了呢,并且后续如果有数据校验的的变动,只需要修改validate函数即可;并不需要修改提交接口的代码,这也完全符合软件设计中的SRP职责。
3. 基于老旧项目扩展新需求
在实际开发中,经常会遇到需要在之前老项目的基础上增加一些新的需求,但是该项目可能开发年代比较久远,之前的开发人员可能已经各奔东西,你却需要在此基础上来修改代码,作为开发人员往往会面临这种局面,参考以下代码:
function execute () {
// big code blocks
... 以下省略几百行代码
}
你需要在execute函数中增加一些新的逻辑,但是在big code blocks中有大量复杂的业务逻辑,你并不敢随意修改,这可能会导致意想不到的bug出现,这时候我们用AOP来对它进行扩展,基于以上数据埋点的示例,我们来实现下AOP函数:
Function.prototype.after = function(fn) {
const self = this;
return function () {
const result = self.apply(this, arguments);
fn.apply(this, arguments);
return result;
}
}
然后之前的调用方式变成了:
execute.after(() => {
// 新增的业务需求
...
})();
以上就实现对之前原有逻辑的扩展,在不修改之前代码的提前了,增加新的业务逻辑代码。
4. 异常处理
基于以上表单的验证的示例,在提交接口中往往会遇到异常处理的情况,如果在多个接口中异常做一个同样的处理,传统的方式是在方法中加一个异常捕获,具体如下:
const handlerError = (err) => {
...
}
const fetchData = async () => {
try {
await axios('https://xxxxx.com/xxx', data);
} catch (error) {
// handler error
handlerError(error);
}
}
let fetchData1 = async () => {
try {
await axios('https://yyyyy.com/xxx', data);
} catch (error) {
// handler error
handlerError(error);
}
fetchData();
fetchData1();
如果是多个方法中都需要做这样的处理,那么将会使代码中充斥着大量的try catch,这将使代码变的非常臃肿和难以维护,其实catch中的异常处理和实际的业务功能并没有什么必然关系。
下面我们用AOP的方式将它们的之间的逻辑进行分离,因为异常处理所依赖的前置条件是接口调用,因为它是异步的,所以实现方式和以上表单略有不同,我们需要返回一个异步函数。
首先实现下异常处理的AOP函数:
const errorCatch = (fn, handlerError) => {
const self = this;
return async function () {
try {
return await fn.apply(this, arguments);
} catch (err) {
handlerError.call(this, err);
}
}
}
然后在定义fetchData函数时就变成了:
const fetchData = errorCatch(async () => {
await axios('https://xxxxx.com/xxx', data);
}, handlerError);
这样fetchData和handlerError的逻辑就完全分开了,并且fetchData中也不再充斥着try catch。
扩展
以上的示例中AOP函数的实现有两种方式:
- 原型扩展
- 函数劫持
在ES6之后推出了装饰器,我们一样可以用装饰器来实现:
function before() {
return function (target, property, descriptor) {
const fn = descriptor.value;
descriptor.value = function() {
console.log(`before...`)
let value = fn.apply(this, arguments);
return value;
};
}
}
function after() {
return function (target, property, descriptor) {
const fn = descriptor.value;
descriptor.value = function() {
let value = fn.apply(this, arguments);
console.log(`after...`)
return value;
};
}
}
定义一个测试类:
class Person {
@before()
@after()
run() {
console.log(`run log`);
}
}
const p = new Person();
p.run();
// before ...
// run log
// after ...
以上代码通过ES6装饰器的方式实现了以上示例的AOP功能,在使用的时候通过类似于java中注解@的方式来使用AOP函数。
但是通过装饰器的实现有个问题,装饰器必须使用在类中或者类的方法上,对于传统定义的函数并不支持这种用法,因为在js有函数存在提升。
AOP函数的实现方式有多种,具体使用哪种方式来实现,完全取决于自己的喜好和具体的应用场景。
总结
以上通过4个常见的场景来说明AOP的实际应用和落地,但实际业务开发中往往有更多场景也同样会遇到这样的问题,这需要大家对函数所承载的职责和实现有更充分的理解。所谓的AOP其实就是设计模式中装饰者模式的一种实现,其原理就是对函数劫持和扩展。
通过以上示例总结出AOP的几个优点:
- 将核心代码逻辑和其他业务逻辑进行分离,减少业务间的耦合度
- 在不修改原有代码的前提下,扩展新的业务逻辑代码,践行开闭原则
- 减少重复代码
- 提高系统的高内聚、低耦合特性
参考
- <<javascript设计模式与开发实践>>
- 阮一峰 - ES6标准入门