首页 / 文章 / 基于 InversifyJS 的框架无关前端依赖注入与组合根机制

基于 InversifyJS 的框架无关前端依赖注入与组合根机制

如何选择 TypeScript DI 容器,将每个领域封装为 ContainerModule,在一个 Composition Root 中连接所有组件,并将其与 React、Vue 和 Angular 相集成。

3230 词

庞大的前端代码库很少会因为某个有问题的组件而出错;它们出错的原因是业务规则逐渐与 React hooks、HTTP 客户端及存储 API 纠缠在一起,最终导致这些组件无法单独进行测试或复用。依赖注入是一种低层机制,可用于将这类规则分隔开来,但前提是必须精心设计其连接方式。本指南将详细介绍如何为多领域 TypeScript 单体仓库选择容器、定义类型安全的令牌、通过单个模块隔离每个领域、在组合根处构建整体结构,以及通过每段仅十几行代码的桥接机制将其暴露给 React、Vue 和 Angular。

为何必须在建模领域之前确定隔离方案

经典的领域驱动设计建议认为,实现细节应推迟处理:先设计模型实体和用例,再选择工具。实际上,在进行重大架构变革的团队中,提前解决一个技术问题会很有帮助,那就是如何真正实现模块隔离。如果没有统一的机制,最初的几个领域就会以临时方式连接起来,而相关的规范也永远无法恢复。

其背后的原则是依赖倒置原则:高层策略应依赖于抽象,而具体细节则应依赖于这些相同的抽象。依赖注入则是实现这一原则的实用技术。类会请求抽象标识符而非具体实现,由类外部决定这些标识符最终指向什么。在领域驱动设计及整洁架构中,这并非可有可无的优化措施;领域层绝不能知道自己运行在何种UI框架、HTTP客户端或持久化层之上。

大多数前端团队首先会选择 React Context(或在 Vue 中使用 provide/inject)。这种方式看似足够:在组件根节点附近声明一个值,然后在下层任何地方读取它。但很快就会发现其局限性:没有生命周期管理功能,也没有工厂函数或作用域的概念,而且所有使用该值的组件都依赖于 React 运行时,因此没有它逻辑就无法运行或测试。本指南的其余部分将介绍如何从这种初始方式过渡到真正的组合式根节点。通常情况下,编写定制化的容器并不值得,因此首要任务是客观地评估现有的依赖注入选项。

前端 DI 容器的选择标准

NestJS、Spring 和 .NET 等后端框架都将依赖注入作为已解决的标准功能内置其中。前端代码则面临更多限制:运行时成本、包大小限制,以及需要在多种 UI 框架上运行的需求。以下五个标准决定了评估准则:

  1. 框架无关性。该容器必须能在纯 TypeScript 环境中运行,无需依赖 React、Vue 或 Angular,并且能够在浏览器、用于服务端渲染的 Node.js 环境以及 React Native 中正常工作。
  2. 模块化。每个功能领域都应能提供一个预配置好的模块,其内部绑定关系被封闭起来,这样应用程序只需加载该模块即可。这有助于避免出现长达 500 行、需手动注册所有服务的 main.ts 文件。
  3. 生命周期管理。单例、临时实例以及作用域解析功能必须是首屈一指的。
  • 可测试性。将依赖项替换为模拟对象或假数据只需一行代码,无需重新构建容器。
  • 包体积影响。无论原始版本、压缩版还是gzip压缩后的版本,其对生产环境性能的影响都应很小。
  • 候选方案

    InversifyJS

    InversifyJS 长期以来都是大型 TypeScript 项目的默认选择,也是该生态系统中最为完善的 IoC 容器。它不依赖特定框架,通过装饰器来声明依赖关系。其 ContainerModule 能将各种绑定内容整合为逻辑单元,同时支持 inSingletonScope、inTransientScope 和 inRequestScope 模式、子容器功能、运行时模块的加载与卸载,以及激活钩子。不过它的缺点是体积庞大:在各项测试中都是最重的选项,称当前版本为轻量级会具有误导性。

    TSyringe

    TSyringe由微软维护,以便利性为代价换取了更广泛的功能。它能自动从TypeScript元数据中解析构造函数依赖关系,提供丰富的装饰器集,并支持常见的生命周期(Singleton、Transient、ResolutionScoped、ContainerScoped)。对于中型应用而言,这是一种便捷且设置简单的选择。但在多域架构中它有两个缺点:没有一等模块的概念,因此必须通过注册函数或createChildContainer()手动实现组合逻辑;同时它依赖于reflect-metadata的polyfill,这会增加额外的加载负担。

    Awilix

    Awilix 完全避免了装饰器的使用。它借助 ES6 Proxy API 将依赖项与构造函数参数名或工厂函数的参数对象键对应起来。因此,它非常适合那些不希望使用装饰器的团队,同时也是经过评估后体积最小的独立容器。它支持 SINGLETON、SCOPED 和 TRANSIENT 三种生命周期;模块则通过自定义的注册函数来组织。

    有一个针对前端环境的特殊问题值得注意。在 CLASSIC 注入模式下,Awilix 会将构造函数参数名视为字符串,而代码压缩工具会重新命名这些参数,从而导致生产环境中的解析失败。只有默认的 PROXY 模式能够抵御这种激烈的代码混淆处理。

    Angular 的内置注入器

    Angular 拥有前端开发中最强大的分层依赖注入系统之一,内置基于令牌的注入机制、作用域提供者以及懒加载功能。如果无需 Angular 运行时即可使用它,那它绝对能成为领域层技术的有力竞争者。但现实是无法做到这一点,而这正是问题所在。

    React Context

    Context 实际上并非依赖注入容器,而是一种避免属性层层传递的方式。提供者位于代码结构的最顶层,组件通过 useContext() 来读取该提供者中的值。一旦提供的值发生变化,所有使用它的组件都会重新渲染,而且它没有生命周期管理、没有工厂绑定功能,也没有作用域隔离机制。它唯一的真正优势在于不会给代码包增加额外的体积。

    Vue 的 provide 和 inject

    Vue的机制通过组件树向下传递值,并受到Context架构限制的约束:它并不适合作为领域服务图的实际基础。不过它确实有一个优点,即可以通过插件全局注册依赖项,而无需用嵌套的provider包裹整个组件树。Angular的现代inject()函数也基于类似的活动注入上下文理念。

    普通构造函数注入

    零库方案会明确地通过构造函数传递所有依赖项。这种方式完全具备类型安全性,且没有运行时开销。但其缺点在于扩展性:当应用程序包含大约10到15个领域服务时,若在入口点手动连接这些服务关系,就会生成一个庞大且脆弱的大文件,没人敢去修改。

    对比结果说明

    通过使用 esbuild --bundle --minify --platform=browser 将导入每个包公共 API 的入口文件打包,再使用 gzip -9 对结果进行压缩,以此比较不同打包大小,从而将容器自身的开销与应用程序代码分开。最终得出以下四个结论:

    1. 框架依赖注入属于 UI 层,而非领域层。 若将 useContext() 或 Angular 的 @Injectable() 放入领域库中,就会使业务规则与某个框架绑定在一起,从而无法满足第一个标准。
    2. 手动注入仅适用于小型项目。 在大型企业级单仓库项目中,组合根会演变成数千行手动编写的连接代码。
  • InversifyJS 在模块化方面更具优势。它是唯一一个将模块(ContainerModule)视为一等价值的候选者,领域库可以以此为单元进行构建、封装和导出。
  • 构建配置至关重要。InversifyJS 和 TSyringe 需要在 tsconfig.json 中设置 experimentalDecorators: true、emitDecoratorMetadata: true 以及 target: ES2022。InversifyJS v8 已不再需要单独的 reflect-metadata polyfill。对于基于 esbuild 或 SWC 的工具(如 Vite、Next.js、Bun),需确认其转换步骤能够支持旧版装饰器元数据,通常需要通过插件实现,因为 esbuild 本身不会自动输出装饰器元数据。
  • 防止容器演变为服务定位器的四项规则

    容器本身并不能实现任何隔离功能;如果使用不当,它就会变成一个全局的服务集合,任何文件都能访问其中的内容。因此,该架构依赖于四项约定:

    • 使用 Symbol.for 以及 $ 属性来创建抽象的依赖注入令牌。
    • 每个领域库仅导出其容器模块,而从不导出具体的类。
    • 通过单一的组成根来构建应用程序结构图。
    • 每个 UI 框架都配有轻量级的桥接层,用于将容器暴露给各个组件。

    利用 Symbol.for 和 $ 创建类型安全的令牌

    容器需要运行时标识符来将抽象概念映射到具体实现。TypeScript 的接口在编译时会消失,因此无法直接承担这一角色。此处采用的方案是将 @my-app/*-contracts 包中的每个接口与一个同名的常量配对,该常量的 $ 属性包含一个 Symbol。

    // libs/auth/contracts/src/lib/interfaces/auth.facade.ts
    import type { ServiceIdentifier } from 'inversify';
    
    export interface AuthFacade {
      getAccessToken(): Promise<string | null>;
      login(): Promise<void>;
      logout(): Promise<void>;
    }
    export const AuthFacade = {
      $: Symbol.for('AuthFacade') as ServiceIdentifier<AuthFacade>,
    };
    
    // libs/auth/contracts/src/lib/interfaces/pin.facade.ts
    export interface PinFacade {
      setupPin(pin: string): Promise<void>;
      verifyPin(sub: string, pin: string): Promise<PinVerifyResult>;
      changePin(sub: string, currentPin: string, newPin: string): Promise<PinVerifyResult>;
    }
    export const PinFacade = {
      $: Symbol.for('PinFacade') as ServiceIdentifier<PinFacade>,
    };
    

    该代码片段中包含了三个决策:

    1. 类型与值使用同一个名称。 TypeScript 将类型和值分别存储在不同的命名空间中,因此 AuthFacade 可以同时作为接口和令牌持有者。编译器会根据上下文选择正确的含义,无需再人为创造诸如 AuthFacadeToken 这样的名称。
  • 应使用 Symbol.for 而非 Symbol()。 每次调用 Symbol() 都会返回一个全新的值,而 Symbol.for() 会在运行时的全局符号注册表中查找对应键。如果在单仓库项目或模块联合环境中,某个契约包被复制到两个独立的打包中,这两个副本仍然会生成相同的符号,且查找功能依然正常工作,而不会出现莫名其妙的错误。不过缺点是注册表键为全局字符串,因此在整个应用程序中必须保持唯一性。
  • ServiceIdentifier<AuthFacade> 类型转换。 将 $ 定义为 Inversify 的 ServiceIdentifier<T> 可以在编译时将符号与其接口绑定,这样解析器无需显式使用泛型即可推断出正确的类型。
  • 下面的代码片段展示了两种情况:一种是通过构造函数注入方式让类获取外观层,另一种则是直接调用container.get方法,其返回类型由对应的标记来推断。

    import { inject, injectable } from 'inversify';
    import { AuthFacade } from '@my-app/auth-contracts';
    
    @injectable()
    export class LoginPageComponent {
      // Strongly-typed injection via token
      constructor(@inject(AuthFacade.$) private readonly auth: AuthFacade) {}
    }
    
    // Resolution site automatically infers return type as AuthFacade
    const facade = container.get(AuthFacade.$); // AuthFacade
    

    使用者仅引用接口契约,完全不知道@my-app/auth-core内部包含什么,而这正是设计的目的。

    一个领域,一个ContainerModule

    每个领域的核心库都只暴露一个内容:其依赖注入模块。具体的用例、实体、端口和仓库则保持私有状态。在模块内部,内部的用例彼此关联,而公共的外观层则与其契约标记相关联。

    // libs/auth/core/src/lib/auth-container.module.ts
    import { ContainerModule, type ContainerModuleLoadOptions } from 'inversify';
    import { AuthFacade } from '@my-app/auth-contracts';
    import { CoreAuthFacade } from './facades/core-auth.facade';
    import { LoginUseCase } from './use-cases/login.use-case';
    import { LogoutUseCase } from './use-cases/logout.use-case';
    import { GetAccessTokenUseCase } from './use-cases/get-access-token.use-case';
    
    export const authContainerModule = new ContainerModule((options: ContainerModuleLoadOptions) => {
      // Private use cases (registered to self within domain)
      options.bind(LoginUseCase).toSelf().inSingletonScope();
      options.bind(GetAccessTokenUseCase).toSelf().inSingletonScope();
      options.bind(LogoutUseCase).toSelf().inSingletonScope();
      // Public contract implementation bound to token
      options.bind(AuthFacade.$).to(CoreAuthFacade).inSingletonScope();
    });
    
    // libs/auth/core/src/index.ts - ONLY the ContainerModule is re-exported!
    export * from './lib/auth-container.module';
    

    封装机制被强制执行了两次:

    1. 公共 API。该库的 index.ts 仅重新导出 authContainerModule。像 LoginUseCase 或 CoreAuthFacade 这样的类无法被其他包访问。
    2. 代码检查规则。Nx 的 @nx/enforce-module-boundaries ESLint 规则会检查项目标签,因此例如标记为 type:core 的项目不允许导入其他域中同样标记为 type:core 的项目。有关如何声明标签与约束的详细信息,请参阅 Nx 模块边界文档。

    导出边界可防止意外导入,而代码检查规则则能阻止通过复杂路径进行的刻意绕过行为。二者结合使得架构成为 CI 可以验证的内容,而非需要审查人员记忆的事项。

    组合根

    组合根是依赖图被创建的唯一位置,通常是 apps/my-app/src/composition-root.ts,或是从 main.ts 调用的某个函数。对此有一项规则:

    组合根是代码库中唯一允许导入具体实现、基础设施适配器以及领域核心模块的地方。

    下面的函数首先绑定全局基础设施(基于 Axios 的 HTTP 客户端,以及通过工厂模式构建的日志记录器,以便其能够接收配置好的传输方式),然后再加载领域模块。

    // apps/my-app/src/composition-root.ts
    import { Container } from 'inversify';
    import { HttpClientPort, LoggerPort } from '@my-app/shared-contracts';
    import { AxiosHttpClient, BrowserLogger, ConsoleLogTransport, FileLogTransport } from '@my-app/shared-infrastructure';
    
    import { authContainerModule } from '@my-app/auth-core';
    import { paymentsContainerModule } from '@my-app/payments-core';
    export const initAppContainer = (): Container => {
      const container = new Container();
    
      // 1. Bind global infrastructure adapters
      container.bind(HttpClientPort.$).to(AxiosHttpClient).inSingletonScope();
      container.bind(LoggerPort.$).toDynamicValue(() => new BrowserLogger({
        transports: [
          new ConsoleLogTransport(),
          new FileLogTransport(),
        ],
      })).inSingletonScope();
    
      // 2. Load domain modules
      container.load(authContainerModule);
      container.load(paymentsContainerModule);
    
      return container;
    };
    

    为完整性起见,BrowserLogger适配器是一个实现LoggerPort接口的普通类,负责将每条消息分发到相应的传输层。需要注意的是它没有携带任何装饰器;由于它在toDynamicValue方法内部被创建,因此容器无需检查其构造函数。

    // libs/shared/infrastructure/src/lib/logging/browser-logger.ts
    import type { LoggerPort } from '@my-app/shared-contracts';
    import type { LogTransport } from './interfaces/log-transport.interface';
    
    export interface BrowserLoggerOptions {
      transports: LogTransport[];
    }
    
    export class BrowserLogger implements LoggerPort {
      constructor(private readonly options: BrowserLoggerOptions) {}
    
      info(message: string, ...args: unknown[]): void {
        this.options.transports.forEach(t => t.write('INFO', message, args));
      }
    }
    

    同一个域核心下的多个应用

    因为在组合根处选择了基础设施,不同的应用可以复用相同的域包,只需使用不同的适配器以及不同选定的模块即可。一个React Native应用可能会使用基于AsyncStorage的存储方式,而一个网页管理应用则会使用IndexedDB,并加载审计相关域而非支付相关域。

    // apps/mobile/src/composition-root.ts — React Native target
    container.bind(StoragePort.$).to(AsyncStorageAdapter).inSingletonScope();
    container.bind(LoggerPort.$).to(RnLogger).inSingletonScope();
    container.load(authContainerModule);
    container.load(paymentsContainerModule);
    
    // apps/admin/src/composition-root.ts - Web Admin target
    container.bind(StoragePort.$).to(IndexedDbAdapter).inSingletonScope();
    container.bind(LoggerPort.$).to(BrowserLogger).inSingletonScope();
    container.load(authContainerModule);
    container.load(auditContainerModule); // Payments domain excluded entirely
    

    @my-app/auth-core在移动端、管理端和桌面端的构建版本中完全相同,它无法识别存储方式是AsyncStorage还是IndexedDB。

    允许一个域名使用另一个域名的功能

    假设payments-core需要由auth-core管理的访问令牌,但边界规则禁止导入@my-app/auth-core。因此,支付功能只能依赖存储在contracts包中的认证域名的合约令牌,而这种做法是被允许的。

    // libs/payments/core/src/lib/use-cases/create-payment.use-case.ts
    import { inject, injectable } from 'inversify';
    
    // Import from contracts, NOT core — permitted by boundary rules
    import { AuthFacade } from '@my-app/auth-contracts';
    import { PaymentsGatewayPort } from '../ports/payments-gateway.port';
    
    @injectable()
    export class CreatePaymentUseCase {
      constructor(
        @inject(AuthFacade.$) private readonly auth: AuthFacade,
        @inject(PaymentsGatewayPort.$) private readonly gateway: PaymentsGatewayPort,
      ) {}
    
      async execute(amount: number): Promise<void> {
        const token = await this.auth.getAccessToken();
        return this.gateway.processPayment(amount, token);
      }
    }
    

    paymentsContainerModule并未绑定AuthFacade.$,而只是使用它。只有当组合根将这两个模块加载到同一个容器中时,绑定关系才会成立。实际后果是:如果应用程序在未启用认证的情况下加载支付相关功能,运行时就会发生解析失败,因此建议为每个目标应用程序编写测试,确保所有公共令牌都能被正确解析。

    将容器与UI框架连接起来

    由于容器对任何UI库都不了解,因此每个框架都需要一个大约10到15行的小型适配器。

    React

    该容器在启动时仅创建一次且不会被替换,因此上下文值始终不变。这样就避免了与 Context 相关的常规重新渲染问题:使用者获取的是一个稳定的引用。useInjection 会为每个容器和令牌缓存查找结果,若提供者缺失则会抛出明确的错误。

    // libs/shared/react-di/src/lib/di-context.tsx
    import React, { createContext, useContext, useMemo, type ReactNode } from 'react';
    import type { Container, ServiceIdentifier } from 'inversify';
    
    export interface DIProviderProps {
      container: Container;
      children: ReactNode;
    }
    
    const DIContext = createContext<Container | null>(null);
    
    export const DIProvider = ({ container, children }: DIProviderProps) => (
      <DIContext.Provider value={container}>
        {children}
      </DIContext.Provider>
    );
    
    export const useInjection = <T,>(token: ServiceIdentifier<T>): T => {
      const container = useContext(DIContext);
    
      return useMemo(() => {
        if (!container) {
          throw new Error('useInjection must be used within a DIProvider');
        }
    
        return container.get(token);
      }, [container, token]);
    };
    

    Vue

    Vue 的插件系统以及带类型的 InjectionKey 使得适配器更加简洁。插件在应用层面提供容器,而组合式函数则从中解析令牌。

    // libs/shared/vue-di/src/lib/di-plugin.ts
    import type { App, InjectionKey } from 'vue';
    import { inject } from 'vue';
    import type { Container, ServiceIdentifier } from 'inversify';
    
    export const DI_CONTAINER: InjectionKey<Container> = Symbol('DI_CONTAINER');
    
    export const diPlugin = {
      install(app: App, { container }: { container: Container }) {
        app.provide(DI_CONTAINER, container);
      },
    };
    
    export const useInjection = <T>(token: ServiceIdentifier<T>): T => {
      const container = inject(DI_CONTAINER);
    
      if (!container) {
        throw new Error('diPlugin is not installed');
      }
    
      return container.get(token);
    };
    

    Angular

    Angular 自带的层次化注入器可以通过工厂提供者委托给域容器。通过 InjectionToken 传递容器,并在每次启动时创建新的容器,从而防止并发的服务器渲染请求共享状态。

    // apps/angular-app/src/app/domain.providers.ts
    import { InjectionToken, type Provider } from '@angular/core';
    import type { Container } from 'inversify';
    import { AuthFacade } from '@my-app/auth-contracts';
    import { initAppContainer } from './composition-root';
    
    export const DI_CONTAINER = new InjectionToken<Container>('DI_CONTAINER');
    export const AUTH_FACADE = new InjectionToken<AuthFacade>('AUTH_FACADE');
    
    export const provideDomainContainer = (container: Container): Provider[] => [
      {
        provide: DI_CONTAINER,
        useValue: container,
      },
      {
        provide: AUTH_FACADE,
        useFactory: (c: Container) => c.get(AuthFacade.$),
        deps: [DI_CONTAINER],
      },
    ];
    
    // main.ts - fresh container instance created per bootstrap
    bootstrapApplication(AppComponent, {
      providers: [...provideDomainContainer(initAppContainer())],
    });
    

    每个额外的 Angular 组件面层都需要独立的 InjectionToken 和工厂提供者,因此随着公共 API 的增加,相关列表也会随之增长;当列表变得过长时,可以从一个小型映射表中生成这些元素。

    成本及何时无需使用此方法

    该配置在压缩后的体积约为 21 KB,并且在构建过程中需要使用 emitDecoratorMetadata。对于拥有十个或更多域的企业级单仓库项目,这些成本很快就能得到弥补。而对于只有两三个界面的应用来说,这种隔离机制带来的开销是不必要的;普通的构造函数注入甚至模块级单例就能满足需求。

    关键要点

    • 将框架的依赖注入机制(Context、provide/inject、Angular 提供者)保持在 UI 层;领域核心部分应仅依赖契约令牌。
  • 首先为模块结构选择一个容器:一个导出单个密封的 ContainerModule 的域,这样才能保持入口点的简洁。
  • 使用类型为 ServiceIdentifier<T> 的 Symbol.for 令牌,这样既能避免包重复,又能维持类型推断的正常运行。
  • 应通过导出机制和代码检查规则来明确边界,而不能仅依赖惯例。
  • 只允许一个文件——即组合根——直接接触具体实现;如此多平台构建就只需更换绑定即可完成。
  • 请记住,注入机制仅是一种工具。真正的目标是实现反转:如果无需修改域层就能用 Vue 替换 React、用 IndexedDB 替换 SQLite 或用 Fetch 替换 Axios,那就说明该策略已经达到了预期效果。
  • 相关阅读

  • 选择 React 文件夹结构:七种布局及其失效点 —— 对比 React 的扁平式、基于类型、基于功能、原子设计、DDD、功能切片设计以及单仓库布局,了解每种布局在何种规模下会失效。
  • 使用 withFetch() 将 Angular HttpClient 迁移到 Fetch 后端 —— 了解为何 Angular 的 HttpClient 正从 XMLHttpRequest 转向 fetch,探究 withFetch() 如何实现边缘服务器端渲染和流式处理,以及这对拦截器有何影响。
  • Angular依赖注入如何通过inject()解析服务 — 了解Angular DI如何通过inject()、providers及分层注入器来提供服务,以及如何控制实例的作用域并避免因缺少provider而产生的错误。
  • 以变化为视角的SOLID原则:重构Spring Boot服务 — 通过思考哪些部分可能发生变化,学习如何在Spring Boot后端应用各项SOLID原则,同时避免因过度设计而带来的问题。