浅谈JavaScript中的AOP和装饰器

AOP在其他编程语言中应用较多,在js中却应用较少,由于最近参与的项目中涉及到性能打点上报等,开始探索和了解无侵入地监控或修改代码的方式,趁此机会深入学习了一下AOP与装饰器

一、什么是AOP

AOP(Aspect-Oriented Programming),即面向切面编程,是一种编程范式,与之对应的有OOP(Object-Oriented Programming)面向对象编程。在JavaScript开发中,面向对象编程不管是ES5中直接操作原型还是ES6+中的class语法糖,大家用的比较多也应该都挺熟悉,但是相对而言,AOP用的就没那么多了。

对于OOP,我们在开发过程中往往是对类和对象通过继承进行纵向拓展 ,但是在AOP中则更侧重于把对象、方法作为切面,对原对象、方法进行无侵入横向拓展,下面我们以一些简单的例子和代码来讲一下AOP。

假设我们有一个按钮,绑定了一个点击事件处理函数handleClick,现在需要对按钮增加点击上报,很简单,只需要在handleClick中调用一下上报函数就可以了:

javascript 复制代码
const report = (message) => {
  console.log(`上报了信息:${message}`);
}
const handleClick = (e) => {
  // do something...
  report('一条信息')
}

如果以后又需要增加对这个函数运行时间的上报,那么就需要在函数开头和结尾各自计算一下时间。

但是想一下,这样做带来的坏处是显而易见的,一是不利于拓展 ,每次增加新的点都需要改一次handleClick函数,改的越多越不利于后期维护;二是有悖于单一职责原则handleClick里处理了大量非按钮的逻辑,同样增加了维护难度。这时候AOP就是一个很好的解决办法了。

二、ES6+之前的实现

在ES5以及ES6+的装饰器出现之前,我们往往是这样做的(这里用了ES6的部分语法),通过修改Function构造函数的原型,给原型增加两个方法beforeafter,如下:

javascript 复制代码
Function.prototype.before = function (beforeFn, beforeFnArgs) {
  const self = this;
  return function () {
    beforeFn.call(this, beforeFnArgs, ...arguments);
    return self.apply(this, arguments);
  };
};

Function.prototype.after = function (afterFn, afterFnArgs) {
  const self = this;
  return function () {
    const ret = self.apply(this, arguments);
    afterFn.call(this, afterFnArgs, ...arguments);
    return ret;
  };
};

通过这两个方法,实现在任意一个函数执行前后,执行我们自己自定义的行为,以达到装饰或拦截原函数、对象行为的目的。现在,实现上面点击上报的方式变成了这样:

javascript 复制代码
const report = (message) => {
  console.log(`上报了信息:${message}`);
};

let handleClick = function (e) {
  // do something...
};

handleClick = handleClick.after(report, '一条信息');

和修改原型类似的,我们还可以用高阶函数HOF来实现一个上报函数执行时间的函数(beforeafter本质上也是高阶函数,只不过是定义在Function原型上的高阶函数):

javascript 复制代码
const reportExeTime = (fn) => {
  return function () {
    let beforeTime;
    return fn
      .before(() => {
        beforeTime = Date.now();
      })
      .after(() => {
        report(`${Date.now() - beforeTime}`);
      });
  };
};

handleClick = reportExeTime(handleClick);

假设之后还要增加其他和业务逻辑无关的埋点、统计、监控等逻辑,只需要通过类似的方式来装饰原函数即可,而无需修改原函数。通过这种方式,我们实现了对原函数无侵入的横向拓展,是一种比继承更具弹性的代替方案。

其实到这里,很多小伙伴已经意识到了,在前端开发中已经有很多地方已经用到过AOP这种思想了,例如React的高阶组件HOC、Redux的中间件等,都是这种思想的体现。

三、ES6+通过装饰器实现

上面说的是ES6+中装饰器出现前AOP的实现方式,然而装饰器(Decorators)的出现给我们提供了另一种更为优雅的实现方式(本质上和高阶函数类似),虽然到目前为止这个特性也还只是在提案的第二阶段,还未真正引入到ES标准中,但是通过Babel(> 7.1.0)或者使用TypeScript,就可以提前使用这个特性。

关于装饰器的定义和使用方法已经有很多资料讲解了,这里不做过多介绍,我们来看下用装饰器怎么实现上面的逻辑。很容易想到,这里需要的是方法装饰 器,首先定义一个上报的方法装饰器withReport

javascript 复制代码
const withReport = (target, keyName, descriptor) => {
  const originFn = descriptor.value;
  descriptor.value = function () {
    const ret = originFn.apply(this, arguments);
    console.log('上报了信息'); // 数据上报
  };
  return descriptor;
};

class MyClass {
  @withReport
  handleClick(e) {
    // do something...
  }
}

或者我们还可以仿照上面的beforeafter,分别实现一个方法前置和后置装饰器工厂来执行任意前置后置方法,如下:

javascript 复制代码
const before = (beforeFn, beforeFnArgs) => {
  return function (target, keyName, descriptor) {
    const originFn = descriptor.value;
    descriptor.value = function () {
      beforeFn.call(this, beforeFnArgs, ...arguments);
      return originFn.apply(this, arguments);
    };
    return descriptor;
  };
};

const after = (afterFn, afterFnArgs) => {
  return function (target, keyName, descriptor) {
    const originFn = descriptor.value;
    descriptor.value = function () {
      const ret = originFn.apply(this, arguments);
      afterFn.call(this, afterFnArgs, ...arguments);
      return ret;
    };
    return descriptor;
  };
};

这样我们可以方便地传入一个report函数来实现上报:

javascript 复制代码
const report = (message) => {
  console.log(`上报了信息:${message}`);
};

class MyClass {
  @after(report, '一条信息')
  handleClick(e) {
    // do something...
  }
}

对于后续增加的其他非业务相关逻辑,也可以通过多个装饰器组合来实现,如上报函数执行时间:

javascript 复制代码
const reportExeTime = (target, keyName, descriptor) => {
  const originFn = descriptor.value;
  descriptor.value = function () {
    const beforeTime = Date.now();
    const ret = originFn.apply(this, arguments);
    report(`${Date.now() - beforeTime}`);
    return ret;
  };
  return descriptor;
};

class MyClass {
  @after(report, '一条信息') // 这个装饰器后执行
  @reportExeTime // 这个装饰器先执行
  handleClick(e) {
    // do something...
  }
}

通过装饰器,我们更优雅地实现了AOP,对方法进行横向拓展,而且不会像之前那样直接修改Function原型(确实是一种不太好的方式,污染原型链),装饰器之所以可以做到这一点是因为它并不是在运行时改变类、属性、方法的行为,而是在代码编译的时候就修改了,所以本质上来讲,装饰器就是一个在编译时运行的语法糖函数

四、 AOP与装饰者模式和职责链模式

上面提到了,我们可以通过AOP来拓展函数或对象的行为,可以达到装饰或拦截 原函数、对象行为的目的,对应的有两个设计模式,分别是装饰者模式职责链模式(详见《JavaScript设计模式与开发实践》),AOP在这两种模式中的应用有一个明显的区别,就是能否阻断/覆盖原函数、对象的行为。有一点要声明的是,AOP只是实现这两种设计模式的其中一种方式,并不是唯一的实现方式,这里只是探讨在两种模式中AOP的应用。

对于装饰者模式,上面的几个示例都是很好的例子,不管是修改原型实现还是装饰器实现,都是通过beforeafter这两个来横向(区别于纵向的继承)的拓展原函数的能力,这里的beforeafter都是静默执行 的,即beforeafter里执行的函数都不会阻断后续函数的执行或者覆盖原函数的返回值。应用场景也比较多,如上报、计时、日志等等,这也是AOP应用的比较多的场景。

然而对于职责链模式,对于beforeafter的能力要求则变成了:beforeafter里执行的函数可以阻断后续函数的执行,或beforeafter里执行的函数的返回值可以覆盖原函数的返回值。对应的代码实现变成了类似于下面这种:

  • 修改原型实现
javascript 复制代码
Function.prototype.before = function (beforeFn, beforeFnArgs) {
  const self = this;
  return function () {
    const beforeRet = beforeFn.call(this, beforeFnArgs, ...arguments);
    if (!beforeRet) {
      // 如果不合法则阻断后续操作,这里为了表示方便这样简写
      return beforeRet;
    }
    return self.apply(this, arguments);
  };
};

Function.prototype.after = function (afterFn, afterFnArgs) {
  const self = this;
  return function () {
    const ret = self.apply(this, arguments);
    if (ret) {
      // 返回值符合条件时才返回,这里为了表示方便这样简写
      return ret;
    }
    return afterFn.call(this, afterFnArgs, ...arguments); // 在原函数返回值不符合条件时,执行after后续函数,覆盖原函数返回值
  };
};
  • 装饰器实现
javascript 复制代码
const before = (beforeFn, beforeFnArgs) => {
  return function (target, keyName, descriptor) {
    const originFn = descriptor.value;
    descriptor.value = function () {
      const beforeRet = beforeFn.call(this, beforeFnArgs, ...arguments);
      if (!beforeRet) {
        // 如果不合法则阻断后续操作,这里为了表示方便这样写
        return beforeRet;
      }
      return originFn.apply(this, arguments);
    };
    return descriptor;
  };
};

const after = (afterFn, afterFnArgs) => {
  return function (target, keyName, descriptor) {
    const originFn = descriptor.value;
    descriptor.value = function () {
      const ret = originFn.apply(this, arguments);
      if (ret) {
        // 返回值符合条件时才返回,这里为了表示方便这样简写
        return ret;
      }
      return afterFn.call(this, afterFnArgs, ...arguments); // 在原函数返回值不符合条件时,执行after后续函数,覆盖原函数返回值
    };
    return descriptor;
  };
};

这种也有很多应用场景,最常见的就是表单等的校验,如传入的值不合法,则阻断后续函数执行,或者在原函数返回值不符合要求时,则用after里函数的返回值来覆盖。

五、 结语

其实除了上面提到的,我们还可以拓展下思路,上面对于函数的修饰,都是需要项目中的每个开发者手动在方法、类等前面显式调用,那么对于一些基础、通用的修饰方法,能否在打包构建时,通过打包构建插件分析语法树自动地给代码里的特定方法、类增加修饰,这个值得我们进一步思考探索。

相关推荐
y先森10 分钟前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy10 分钟前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu108301891113 分钟前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿1 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡2 小时前
commitlint校验git提交信息
前端
虾球xz3 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇3 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒3 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员3 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐3 小时前
前端图像处理(一)
前端