分类: 推敲代码

  • 「推敲代码」如何为回调函数设计 API

    TLDR

    在设计回调函数形式的扩展点时,扩展点的默认行为也要提供给调用方,方便基于默认行为进行扩展。达到这一目的的模式有:

    1. 把回调函数提取成单独的类,把默认行为作为基类方法提供给调用方
    2. 把默认行为作为回调函数的参数提供给调用方

    推敲过程

    Alice:Hi,Bob,我有一个重构代码的需求,能帮我看看吗?

    Bob:没问题,Show me your code 。

    Alice:之前的代码是这样的

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params) {
        // do something
        this.fooBehavior();
        // finally
        this.update();
      }
    }

    现在,我需要允许调用方修改 changeState 要执行的行为

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, behaviorType?: string) {
        // do something
        if (behaviorType === 'A') {
          // do behavior A
        } else if (behaviorType === 'B') {
          // do behavior B
        } else {
          this.fooBehavior();
        }
        // finally
        this.update();
      }
    }

    Bob:根据以往的经验,这里的 if-else 很可能会变成叠 buff 的屎山,建议把 behaviorType 换成一个回调函数,让调用方决定这里应该干些啥。

    Alice:改好了,你再看看?

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, onChangeState?: () => void) {
        // do something
        if (onChangeState) {
          this.onChangeState()
        } else {
          this.fooBehavior();
        }
        // finally
        this.update();
      }
    }

    Bob:这里还有些问题,调用方只能覆盖默认行为,并不能在默认行为的基础之上做扩展。

    Alice:调用方也可以自己手动调用 fooBehavior 啊,这也是个公共 API 。

    Bob:但是调用方必须得翻看 changeState 的具体实现才知道「fooBehavioronChangeState 的默认实现,这显然是不合理的。

    Alice:在使用这个类时翻看源码很正常,因为这是一个非常高级晦涩的 API,不翻看源码根本不清楚要用它来干什么。

    Bob:OK,就算调用方在拓展这个地方的时候来查看了 onChangeState 的默认实现,然后把这里的代码复制过去来基于默认行为做扩展。但是,随着版本更新迭代,MyClass 中的默认实现可能会发生变更,这个时候,要怎么通知这些依赖默认行为的调用方更新呢?

    Alice:好吧,这样看来确实是个问题。

    Bob:我觉得这个时候可以把 onChangeState 提取成一个单独的类,它是这个扩展点的基类。调用方在进行扩展的时候,可以通过基类的方法来调用默认行为。

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, changeStateHook: ChangeStateHook = new ChangeStateHook()) {
        // do something
        changeStateHook.after(this);
        // finally
        this.update();
      }
    }
    class ChangeStateHook {
      after(instance: MyClass) {
        instance.fooBehabior();
      }
    }
    class ConsumerChangeStateHook extends ChangeStateHook {
      after(instance: MyClass) {
        if(/* some case */) {
          super.after();
        } else {
          // do something else
        }
      }
    }

    Alice:这样倒是可以满足你在上面提到的需求,但是代码看起来也太阵仗了。为了自定义这个回调函数,还得专门声明一个类!我觉得用个字段来存储默认行为,然后在注释上标明就足够了。

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public defaultOnChangeState() {
        this.fooBehavior();
      }
      /**
       * 默认 onChangeState 是 defaultOnChangeState
       */
      public changeState(params, onChangeState?: () => void) {
        // do something
        if (onChangeState) {
          this.onChangeState();
        } else {
          this.defaultOnChangeState();
        }
        // finally
        this.update();
      }
    }

    Bob:这样做确实轻量很多,但并不是一个好的「模式」。如果后面这种拥有默认行为的回调函数越来越多, MyClass 上就会有很多 defaultOnXxxx ,看起来就很糟心。不如把默认行为作为回调函数的参数提供给调用方。

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, onChangeState?: (defaultOnChangeState: () => void) => void) {
        // do something
        const defaultOnChangeState = () => {
          this.fooBehavior();
        }
        if (onChangeState) {
          this.onChangeState(defaultOnChangeState);
        } else {
          defaultOnChangeState();
        }
        // finally
        this.update();
      }
    }

    Alice:看起来很不错,跟我写的第一版回调函数形式 API 相比没有多几行代码,但是却有更加灵活的扩展空间。

    Bob:好的,最后来总结一下:

    • 回调函数的默认行为需要作为 API 的一部分提供给调用方,这样调用方可以在不查看具体实现的情况下,基于默认行为做扩展。
    • 把回调函数提取成类,通过类的继承,可以将默认行为作为基类的方法提供给调用方。
    • 一个不使用类的更轻量的做法,将默认行为作为回调函数的参数提供给调用方。