「推敲代码」如何为回调函数设计 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 的一部分提供给调用方,这样调用方可以在不查看具体实现的情况下,基于默认行为做扩展。
  • 把回调函数提取成类,通过类的继承,可以将默认行为作为基类的方法提供给调用方。
  • 一个不使用类的更轻量的做法,将默认行为作为回调函数的参数提供给调用方。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

You can use markdown syntax in comment