Hi-Bus中如何使用Proxy实现修饰器功能

280 阅读6分钟

Hi-Bus是我在项目中开发出来发布订阅消息的消息总线系统,与常用的消息总线系统类似,就是实现了订阅消息和发布消息,不过我使用了TypeScript的修饰器来简化了订阅和发布的操作,经过实际项目的使用觉得很方便,所以发布出来与大家分享下。

源代码我发布在了Github有兴趣的朋友可以看看,全部代码都不多完全可以自己手写实现哦。

这里我将把我的设计思路也一并分享下,对于感兴趣的同学希望对你学习修饰器Decorate代理Proxy有所帮助。

废话不多说,先来一个示例代码看看使用的效果

// Import Hi-Bus, you can use any word instead 'HiBus'
import HiBus, {Bus, Publish, Subscribe} from 'hi-bus'

// First we must create a class with decorate ‘@Bus'
// decorate '@Bus’ is used to collect subscribe functions
// it will not work without '@Bus’ when you subscribe message in class

@Bus
class Test {  

  // Subscribe topic “handshake” with doctorate @Subscribe
  // You only focus the function logic
  @Subscribe("handshake")
  handshake() {
    console.log(“Handshake”);
  }

  // when you call function ‘pulbishHandshake’
  // it will publish topic ‘handshake’ automatic
  // the function must return something to trigger publish
  // if return nothing or return void, it will not trigger publish
  @Publish("handshake")
  publishHandshake() {
    return {};
  }

}

const test = new Test();

test.publishHandshake();
// 输出:
// Handshake

可以看到Hi-Bus的订阅和发布消息只需要使用修饰器声明就能实现,避免了手工写订阅代码,如此一来也就会减少拼写错误和反复查找的麻烦。

如何实现消息订阅

消息订阅是Hi-Bus中最关键的部分,这里同时用到了Proxy代理Reflect反射两个新功能,这两个功能已经是JavaScript的新标准的一部分了,所以可以放心大胆的使用。

首先就是如何实现@Subscribe修饰器,这个其实很简单,就是给目标方法添加一个新的属性,在这个新的属性中增加需要订阅的topic就可以了。请不要奇怪为什么方法还能添加属性,其实在JavaScript中一切都是对象,包括方法,现在我们已经很习惯这样写一个类

class Test {
  hello(){...}
}

其实这个是语法糖而已,实际上还是会按照这样来处理

const Test = {}
Test.prototype.hello = function () {...}

方法hello其实是对象Test的一个原型链上面的属性,所以完全可以给hello这个方法再增加属性

const Test = {}
Test.prototype.hello = function() {...}
Test.prototype.hello.topic = 'topic'

像这样给方法hello添加属性完全是可以的,这就是实现@Subscribe的第一步。

接下来的事情就简单了,只要给被修饰器标注的方法添加topic属性就表示这是一个消息回调方法,订阅的消息在属性topic中。

export const Subscribe = (topic: string): MethodDecorator => {
  return (target, propertyKey, descriptor) => {
    Reflect.set(descriptor.value, BusMeta.Subscrible, topic);
  }
}

这里我使用了新的标准Reflect反射来给目标方法添加属性,其实完全可以使用Object.defineProperty来实现效果一样。这里需要吐槽一下微软,因为微软又打算在TypeScript增加meta元属性,就是给方法啦,属性啦对象啦增加meta元属性,其实JavaScript本身就能实现,干嘛又要多此一举呢?

要注意的是方法修饰器签名函数中的参数,MDN文档中有详细的介绍,这里我只对我写的代码做一个解释

按我们的直觉理解target应该是表示被修饰的方法,然而实际上target指向的是定义方法的类Class本身,而要获取到被修饰的方法应该是从descriptor描述子中获取,为什么要这么反直觉的实际呢,我认为是为了增加灵活性,这里不做深入探讨了。

订阅方法增加必要的属性后就是要把这些方法添加到消息回调队列了,这里通过修饰器@Bus实现这个功能。实现代码如下。

export const Bus = function (target: any) {

  // Get descriptiors of target's prototype
  const props = Object.getOwnPropertyDescriptors(target.prototype)

  // Create proxy class
  const proxy = new Proxy(target, {
    construct(C, args) {

      const instance = new C(...args);

      Object.values(props).forEach(prop => {
        const method = prop.value;

        // 检查是否有 [消息订阅] 和 [发布消息] 属性
        const topicSubscribe = Reflect.get(method, BusMeta.Subscrible);

        // 判断是否是订阅消息
        if (typeof topicSubscribe !== 'undefined') {
          const proxyMethod = new Proxy(method, {
            apply(_method, _this, args) {
              _method.apply(instance, args);
            }
          })
          busInstance.subscribe(topicSubscribe, proxyMethod);
        }
      })

      return instance;
    }
  })

  // 返回代理的实例对象
  return proxy;
}

如何在Bus修饰器中将回调方法推入到回调消息队列中

@Bus修饰器中,我通过创建一个代理对象成功的截获了目标类Class的实例化方法,是不是顿时觉得Proxy代理器好牛逼,没错我当时也是震惊了,真的太厉害了,有了Proxy代理器可以实现许多天马行空的创意了。在截获实例化方法中,我首先便是创建了一个目标类Class的实例

const instance = new C(...args);

为什么我要创建一个新的实例呢?原因是当我使用apply方法的时候我需要把this指向目标类Class的实例对象,但是类修饰器中的target其实是目标类Class,我写成原型的样式更容易理解

const Test = {};
//           ^
//           |
//         target

也就是说我直接用类修饰器target参数其实是没有办法获取到实例对象的!然后我想去Proxy代理中找找看有没有办法获取到实例对象,结果也是否定的。因此我就突发奇想的试了一下在构造器constructor中先创建一个对象实例,然后返回这个实例对象是否可行,事实证明完全可行,用起来就跟普通的实例对象一摸一样。

现在有了实例对象,就已经前进了一大步了,接下来就是获取目标类class中被标记订阅消息的方法了,问题又来了,类修饰器中的参数target中并没有我们定义的方法,事实上它是一个空的对象,那不对啊,我定义的方法都去哪里了呢?好好想想看JavaScript是怎么实现面向对象的。

const Test = {}
Test.prototype.hello = function () {...}

没错!我们所有在目标类Class中定义的属性还有方法其实都是定义在了目标类Class的原型链上面了,所以实际上要获取的是target.prototype才对!代码如下

// Get descriptiors of target's prototype
const props = Object.getOwnPropertyDescriptors(target.prototype)

如此以来我就能获取到所有的方法了,接下来的事情就简单了,只要遍历一下看看哪个方法中标注了订阅消息大功告成了。实现代码如下

Object.values(props).forEach(prop => {
    const method = prop.value;

    // 检查是否有 [消息订阅] 和 [发布消息] 属性
    const topicSubscribe = Reflect.get(method, BusMeta.Subscrible);

    // 判断是否是订阅消息
    if (typeof topicSubscribe !== 'undefined') {
      const proxyMethod = new Proxy(method, {
        apply(_method, _this, args) {
          _method.apply(instance, args);
        }
      })
      busInstance.subscribe(topicSubscribe, proxyMethod);
    }
  })

这里我对订阅方法又创建了新的代理,这样做的目的是为了将this指向刚刚创建的实例对象,这样使用者在编写订阅方法的时候就好像是在类中使用的那样,完全不需要去考虑this指向了哪里。

结语

以上就是Hi-Bus中我是如何实现@Subscribe@Bus这两个修饰器的思路,写下此文是为了让大家在使用第三方库的时候不仅仅是会使用,更是要理解这个库是怎么创造出来的,当你的项目安全要求很高不能使用第三方库的时候,也能自己手工去实现。