首页 / 文章

TypeScript单仓库中的依赖注入:使用InversifyJS处理React和Angular的依赖注入。

TypeScript单仓库的依赖注入:选择容器,定义类型安全的令牌,构建组合根,并将其与React、Vue和Angular连接起来。

发布者:REACTAPP.TOP阅读约 14 分钟3191 词

来源: Frontend Dependency Injection: From Context API to Composition Root

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

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

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

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

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

前端依赖注入容器的选择标准

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由Microsoft维护,以便利性为代价换取了更广泛的功能。它能自动从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这样的名称。
    2. 应使用 Symbol.for 而非 Symbol()。 每次调用 Symbol() 都会返回一个全新的值,而 Symbol.for() 会从运行时的全局符号注册表中查找对应键值。如果在单仓库项目或模块联合架构中,某个契约包被复制到两个不同的包中,这两个副本仍然会生成相同的符号,且查找操作仍能正常进行,而不会出现莫名其妙的错误。不过缺点是注册表键值是全局字符串,因此在整个应用程序中必须保持唯一性。
    3. 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。因此,支付功能只能依赖认证域名所在包中的合约令牌,这种做法是被允许的。

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

    将容器与 React、Vue 和 Angular 相连接

    由于容器对任何 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() 如何实现边缘端 SSR 和流式处理,以及这对拦截器有何影响。
  • Angular依赖注入如何通过inject()解析服务:了解Angular DI如何通过inject()、providers及分层注入器来提供服务,以及如何控制实例的作用域并避免因缺少提供者而产生的错误。
  • 以变化为视角的SOLID原则:重构Spring Boot服务:通过思考哪些部分可能发生变化,学习如何在Spring Boot后端应用各项SOLID原则,同时避免SOLID原则往往导致的过度设计问题。