InversifyJS:高级绑定(命名绑定、标签绑定、多重注入、可选注入)

这篇补上几块“看文档时很容易跳过,但在复杂项目里很有用”的特性:
命名绑定(whenTargetNamed)、标签绑定(whenTargetTagged)、多重注入(multiInject)、可选注入(optional)
这些东西合在一起,基本构成了 Theia 里那种“很多实现挂在同一个接口下”的能力。

场景:
你有一个接口 Formatter,但有多种实现,比如 JsonFormatter / YamlFormatter
希望在同一个服务标识下注册多个实现,然后在注入时按名字区分。

 1export const TYPES = {
 2  Formatter: Symbol('Formatter'),
 3} as const;
 4
 5export interface Formatter {
 6  format(input: any): string;
 7}
 8
 9@injectable()
10class JsonFormatter implements Formatter {
11  format(input: any) {
12    return JSON.stringify(input, null, 2);
13  }
14}
15
16@injectable()
17class YamlFormatter implements Formatter {
18  format(input: any) {
19    // 伪代码
20    return toYaml(input);
21  }
22}
23
24container.bind<Formatter>(TYPES.Formatter).to(JsonFormatter).whenTargetNamed('json');
25container.bind<Formatter>(TYPES.Formatter).to(YamlFormatter).whenTargetNamed('yaml');
1@injectable()
2class ReportService {
3  constructor(
4    @inject(TYPES.Formatter) @named('json') private readonly jsonFormatter: Formatter,
5    @inject(TYPES.Formatter) @named('yaml') private readonly yamlFormatter: Formatter,
6  ) {}
7}

关键点:

  • whenTargetNamed('xxx') 在 binding 端打上“名字标签”;
  • 注入时用 @named('xxx') 精确指定要的是哪一个实现。

在 Theia 里,类似的模式会用在某些“同一接口不同 flavor”的场景,例如不同的 debug adapter、不同的语言后端等。

命名绑定是用“一个 name 字段”区分实现;
标签绑定则是用“键值对标签”区分,更灵活一些。

1container
2  .bind<Formatter>(TYPES.Formatter)
3  .to(JsonFormatter)
4  .whenTargetTagged('type', 'json');
5
6container
7  .bind<Formatter>(TYPES.Formatter)
8  .to(YamlFormatter)
9  .whenTargetTagged('type', 'yaml');
1@injectable()
2class ReportService {
3  constructor(
4    @inject(TYPES.Formatter) @tagged('type', 'json') private readonly jsonFormatter: Formatter,
5    @inject(TYPES.Formatter) @tagged('type', 'yaml') private readonly yamlFormatter: Formatter,
6  ) {}
7}

标签绑定的好处是:
可以用多个标签组合表达更丰富的条件,比如 ('language', 'ts') + ('mode', 'strict')

在 Theia 里,你会经常看到这种模式:
一个扩展点有很多实现(多个 Contribution),启动时要把它们统统注入进来,然后遍历调用。

InversifyJS 用 multiInject 支持这种模式。

 1export const TYPES = {
 2  Contribution: Symbol('Contribution'),
 3} as const;
 4
 5@injectable()
 6class FooContribution { /* ... */ }
 7
 8@injectable()
 9class BarContribution { /* ... */ }
10
11container.bind(TYPES.Contribution).to(FooContribution);
12container.bind(TYPES.Contribution).to(BarContribution);
 1import { multiInject } from 'inversify';
 2
 3@injectable()
 4class ContributionManager {
 5  constructor(
 6    @multiInject(TYPES.Contribution)
 7    private readonly contributions: ReadonlyArray<unknown>, // 可以用具体接口
 8  ) {}
 9
10  initializeAll() {
11    for (const c of this.contributions) {
12      // 调用每个贡献点的方法
13    }
14  }
15}

这和 Theia 里的 FrontendApplicationContribution / CommandContribution 等模式高度吻合:
容器里可以有很多实现,启动时统一注入成一个数组,按顺序调用。

有时候某个依赖不是必须的:
例如某个功能只有在特定模块存在时才可用,否则就静默禁用。

InversifyJS 提供 @optional() 装饰器来表达这一点:

 1import { optional } from 'inversify';
 2
 3@injectable()
 4class MaybeUseFeatureX {
 5  constructor(
 6    @inject(TYPES.FeatureX) @optional() private readonly featureX?: FeatureX,
 7  ) {}
 8
 9  doSomething() {
10    if (this.featureX) {
11      this.featureX.run();
12    } else {
13      // 安静地退化行为
14    }
15  }
16}

注意:

  • 如果没有 @optional(),而容器里又没有对应的绑定,container.get() 会直接抛错;
  • 加了 @optional() 之后,如果找不到绑定,对应参数会是 undefined

在类似插件系统、可选模块的场景里非常有用。

虽然 Theia 自己对 InversifyJS 做了一层封装(各种 ContributiontoService 等),但这些高级绑定特性背后的抽象是一致的:

  • 命名/标签绑定:多个实现挂在同一接口下,按条件注入某一类;
  • 多重注入:把所有实现一次性注入进来,像处理插件那样遍历调用;
  • 可选注入:某些扩展模块存在则生效,不存在则退化。

如果你在看 Theia 源码时看到一些“按条件挑扩展”的逻辑,可以试着往 InversifyJS 这几个概念上去对照,大概率能找到一一对应的影子。

对我来说,这些“高级绑定”更像是让 IoC 容器从“简单服务注册表”升级成“插件分发中心”的一组能力——
一旦掌握了它们,就能在自己的工程里更自然地建出类似 Theia 那样的扩展点机制。