为了账号安全,请及时绑定邮箱和手机立即绑定

邂逅AOP:说说JavaScript中的修饰器

标签:
JavaScript

 Hi,各位朋友,大家好,我是Payne。这个月基本上没怎么更新博客和公众号,所以今天想写一篇科普性质的文章,主题是JavaScript中的修饰器。 为什么使用了”邂逅”这样一个词汇呢?因为当你知道无法再邂逅爱情的时候,你只能去期待邂逅爱情以外的事物;当你意识到爱情不过是生命里的小插曲,你只能去努力弥补生命的完整性。在过往的博客中,我曾向大家介绍过譬如Spring.NET、Unity、AspectCore等AOP相关的框架,亦曾向大家介绍过譬如Python中的装饰器、.NET中的Attribute、Java中的注解等等。再我看来,这些都是非常相近的概念,所以今天这篇文章我们又双叒叕要说AOP啦!什么?你说JavaScript里居然AOP!这简直比任何特性都要开心好吗?而这就要从本文的主角——JavaScript中的修饰器说起。

什么是修饰器?

  JavaScript中的修饰器(Decorator),是ES7的一个提案。目前的浏览器版本均不支持这一特性,所以主流的技术方案是采用Babel进行转译,事实上前端的工具链有相当多的工具都是这样,当然这些都是我们以后的话题啦!修饰器的出现,主要解决了下面这两个问题:

  • 不同类间共享方法

  • 编译时期间对类及其方法进行修改

  这里第一点看起来意义并不显著啊,因为JavaScript里有了模块化以后,在不同间共享方法只需要将其按模块导出即可。当然,在模块化这个问题上,JavaScript社区发扬了一贯的混乱传统,CommonJS、AMD、CMD等等不同的规范层出不穷,幸运的是ES6中使用了import和export实现了模块功能,这是目前事实上的模块化标准。这里需要关注的第二点,在编译时期间对类及其方法进行修改,这可以对类及其方法进行修改,这就非常有趣了呀!再注意到这里的修饰器即Decorator,我们立刻想Python中的装饰器,想到装饰器模式,想到代理模式,所以相信到这里大家不难理解我所说的,我们又双叒叕要说AOP啦!

  那么说了这么多,JavaScript中的修饰器到底长什么样子呢?其实,它没有什么好神秘的,我们在Python和Java中都曾见过它,前者称为装饰器,后者称为注解,即在类或者方法的上面增加一个@符号,联想一下Spring中的Controller,我们大概知道它长下面这样:

/* 修饰类 */
@bar
class foo {}

/* 修饰方法 */
@bar
foo(){}

  OK,现在大家一定觉得,这TM简直就是抄袭了Python好吗?为了避免大家变成一个肤浅的人,我们一起来看看下面具体的例子:

修饰类

@setProp
class User {}

function setProp(target) {
    target.age = 30
}

console.log(User.age)

  这个例子展示的是,我们如何通过修饰器函数setProp()来为User对象赋值,为什么叫做修饰器函数呢?因为这就是个函数啊,而且JavaScript和Python一样都是支持函数式编程的编程语言,所以大家看到这个大可不必感到吃惊,因为大道至简殊途同归。好了,注意到SetProp()方法有一个参数target,因为该方法修饰User类,所以它的参数就是User类,显然它为User类扩展了一个属性age,并给它赋值为30。相信有朋友一定会奇怪这个age是哪里定义的,我只能说JavaScript是个神奇的语言,一切都是对象,一切都是函数。现在,当我们执行到最后一句时,会输出30,这是因为修饰器对类进行修改。

  现在我们尝试修改下这个方法,我们希望可以通过修饰器修改age属性的值,而不是让它成为一个固定数值30,这样就涉及到带参数的修饰器函数。修饰器函数本身会接收三个参数,第一个参数是被修饰的对象,因此为了增加一个新的参数,我们需要对原来的函数进行一层包装,你知道吗?此时我感到非常兴奋,因为这TM真的和Python一模一样啊。好了,遵从这个策略,我们修改原来的代码,并将其调整如下:

@setProp(20)
class User {}

function setProp(value) {
    return function (target) {
        target.age = value
    }
}

console.log(User.age)

此种差别,大家可以非常明显地看出来,我们在使用修饰器函数setProp()的时候,现在允许传入一个参数20,此时的结果是非常地显而易见的,这段代码将如你所愿地输出20。

修饰方法

  既然修饰器可以修饰类,那么可不可以修饰方法呢?答案自然是可以的。因为当修饰器修饰类的时候,修饰器函数的参数是一个对象,即target,而当修饰器修饰方法的时候,修饰器函数的参数是一个函数。可函数难道就不是对象吗?.NET里的委托最终不是同样会生成一个类吗?Python中不是有函数对象这一概念吗?那么,我们继续看一个例子 :

class User {
    @readonly
    getName() {
        return 'Hello World'
    }
}

// readonly修饰函数,对方法进行只读操作
function readonly(target, name, descriptor) {
    descriptor.writable = false
    return descriptor
}

let u = new User()
// 尝试修改函数,在控制台会报错
u.getName = () => {
    return 'I will override'
}

在这个例子中,我们通过修饰器函数readonly()对getName()方法进行修饰,使其变成一个readonly的方法。我们提到修饰器函数有三个参数,target指被修饰的对象,name指被修饰器对象的名称,descriptor指被修饰对象的defineProperty。因为设置descriptor的writable属性为false以后,这个函数就无法被覆盖重写,所以代码中尝试重写该方法时就会报错;同理,如果我们对descriptor的value属性进行修改,则可以对该函数进行重写。

总结

  相信熟悉Python中的朋友,应该会知道在Python中内置了大量的装饰器,譬如@property可以让一个方法像属性一样被调用、@staticmethod可以让一个方法变成静态方法、@classmethod可以让一个方法变成类方法等。那么,作为Python的追随者,JavaSript中是否存在相类似的概念呢?答案还是肯定的啊!哈哈。具体大家可以参考这里:ES6  Decorator

AOP与修饰器

  熟悉我写作风格的朋友,应该可以猜到我接下来要做什么了。的确,作为一个在某些方面有强迫症的人,我一直在不遗余力地向大家推广AOP,因为我相信AOP真的可以帮大家去做很多事情。比如最简单的记录日志,或许在前端项目中大家更习惯用console.log()来记录日志,甚至是使用alert(),毕竟这些东西不会在界面上展示出来,所以写一写这些东西好像无可厚非。可当你有了AOP以后,为什么还要做如此出力不讨好的事情呢?我写这篇文章的一个重要原因,正是我看到在前端同事的代码中,使用修饰器做了一个简单的AOP,这非常符合我的品味。具体怎么样去做呢?我们一起来看这段代码:

class Bussiness {
    @log
    step1() {}

    @log
    step2() {}
}

function log(target,name,decriptor){
    var origin = descriptor.value;
    descriptor.value = function(){
      console.log('Calling function "${name}" with ', argumants);
      return origin.apply(null, arguments);
    };

    return descriptor;
}

  我们刚刚提到通过修改descriptor的value属性可以达到重写方法的目的,那么这里就是利用这种方式对原来的方法进行了修改,在调用原来的方法前调用console.log()写了一行日志。的确,就是这样一行平淡无奇的代码,将我们从泥潭中解救出来。试想看到一段日志记录和业务流程掺杂的代码,谁会有心情去解读代码背后真实的含义,更不必说将来有一天要去删除这些日志有多么艰难啦。AOP的基本思想是在代码执行前后插入代码片段,因为根据JavaScript中的原型继承,我们可以非常容易地为Function类型扩展出before和after两个函数:

Function.prototype.before = function(beforefunc){
  var self = this;
  var outerArgs = Array.prototype.slice.call(arguments,1);
  return function{
    var innerArgs = Array.prototype.slice.call(arguments);
    beforefunc.apply(this,innerArgs);
    self.apply(this,outerArgs)
  };
};

Function.prototype.after = function(afterfunc){
  var self = this;
  var outerArgs = Array.prototype.slice.call(arguments,1);
  return function{
    var innerArgs = Array.prototype.slice.call(arguments);
    self.apply(this,outerArgs)
    afterfunc.apply(this,innerArgs);
  };
};

  想象一下,现在我们在重写descriptor的value属性的时候,可以同时指定它的before()方法和after()方法,所以最初的这段代码可以继续被改写为:

var func = function(){
    console.log('Calling function "${name}" with ', argumants);
    return origin.apply(null, arguments);
};

func.before(function(){
  console.log('Start calling function ${name}');
})();

func.after(function(){
  console.log('End calling function ${name}');
})();

  所以,所有让你觉得会增加风险的东西,都是源于你内心的恐惧,因为你不愿意去尝试改变,这是真正的复用,如果Ctrl + C和Ctrl + V可以被称为复用的话,我觉得每一个人都可以说自己是网红啦!这并不是一个笑话,还有什么比写一个@log更简单的吗?同样,我们可以使用修饰器去统计代码运行的时间,而不是在所有地方用两个Date()对象去相减。遵从简洁,从心开始:

function time(){
  return function log(target,name,decriptor){
    var origin = descriptor.value;
    descriptor.value = function(){
      let beginTime = new Date();
      let result = origin.apply(null, arguments);
      let endTime = new Date();
      let time = endTime.getTime() - beginTime.getTime();
      console.log("Calling function '${name}' used '${time}' ms"); 
      return result;
    };

    return descriptor;
  };
}

@time
foo()

  再比如,我们的业务中要求:用户在访问相关资源或者是执行相关操作时,需要确保用户的状态是登录着的,因此,我们不可避免地在代码中,使用if语句去判断用户是否登录,试想如果所有的业务代码都这样写,两个模块间就存在了直接耦合,当然我们可以说这是最简单的做法,因为它照顾了大部分人的思维和情绪,可你看Angular/Redux/TypeScript等项目中无一不遍布着修饰器的身影,当一种框架逐渐流行并成为一种趋势的时候,好像大家立刻就忘记了一件事情:原本我们都是非常排斥这些奇技淫巧的,可因为框架的流行你就默认接受了这种设定。那么,这个逻辑如何使用修饰器来编写会怎么样呢?

class User {
    @checkLogin
    getUserInfo() {
        console.log('获取已登录用户的用户信息')
    }

    @checkLogin
    sendMsg() {
        console.log('发送消息')
    }
}

// 检查用户是否登录,如果没有登录,就跳转到登录页面
function checkLogin(target, name, descriptor) {
    let method = descriptor.value
    descriptor.value = function (...args) {
        //假想的校验方法,假设这里可以获取到用户名/密码
        if (validate(args)) {
            method.apply(this, args)
        } else {
            console.log('没有登录,即将跳转到登录页面...')
        }
    }
}
let u = new User()
u.getUserInfo()
u.sendMsg()

  显然,现在我们可以避免模块间的直接耦合,无需在每个业务方法中重复去写if语句,更重要的是通过JavaScript中的模块化规范,我们可以把checkLogin这个方法,扩展到更多的业务类及其方法中去,而唯一的代价就是在方法上增加@checkLogin修饰,你说,有这样优雅的策略,你为什么就不愿意去使用呢?在ASP.NET中我们通过Authorize特性就可以为API和页面授权,现在看来这是不是有点异曲同工之妙呢?你现在还觉得这样麻烦吗?

本文小结

  这篇文章从一个前端项目中的日志拦截器(InterceptLog)为引子,引出了ES7提案中的一个特性:修饰器。修饰器的出现,解决了两个问题:第一、不同类间共享方法;第二、在编译时期间对类及其方法进行修改。虽然目前修饰器不能直接在浏览器中使用,可是通过Babel这样的转译工具,我们已经可以在项目中提前感受这一特性,这里表扬下前端组的同事们。JavaScript中的修饰器同Python中的修饰器类似,可以修饰类及其方法。JavaScript中的修饰器不建议修饰函数,因为存在一个函数提升的问题,如果一定要修饰函数,按照高阶函数的概念直接包装函数即可。通过修饰器可以简化我们的代码,在本文中我们例举了日志记录、运行时间记录、登录检查三个AOP相关的实例,希望大家可以从这篇文章中有所收获。

  最后,请允许博主爆一个料,因为要写一个简单的修饰器,需要安装若干Babel甚至是Webpack插件,我这篇文章中的代码,截止到写这篇文章时都没能在实际环境中运行,这不能怪我啊,因为前端的工具链实在是太长太多啦,这当然不能和直接内置装饰器的Python相比啊,这真的不是吐槽诶,我需要一个开箱即用的特性就这么难吗?人生苦短,我用Python!(逃

参考文章

原文出处

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消