redi Dependency Injection Library

repository·main·Indexed 41 days ago

https://github.com/wzhudev/redi

A lightweight, zero-dependency dependency injection (DI) library for TypeScript and JavaScript, version 1.1.3. It is esbuild-friendly and provides built-in React bindings, including hooks like useDependency and connectDependencies. Key features include support for various provider types (Class, Value, Factory, Async), interface injection via createIdentifier, lazy instantiation, injector hierarchies with @Self() and @SkipSelf(), and RxJS support.

Tokens
20.3K
Snippets
72
Records
92
Agent score
51%

What's inside redi

  1. Core Features of redi

    main

    redi provides several advanced dependency injection capabilities:

    • Dependency Items: Support for different provider types including Class ({ useClass }), Value ({ useValue }), Factory ({ useFactory }), and Async ({ useAsync }).
    • Interface Injection: Use createIdentifier for injection based on interfaces.
    • Lazy Instantiation: Defer the creation of dependencies using the { lazy: true } option.
    • Hierarchy Injection: Support for parent-child injector trees using @Self() and @SkipSelf() decorators.
    • Optional & Many: Use @Optional() for non-mandatory dependencies and @Many() to inject multiple providers for the same identifier.
    • React Integration: Built-in hooks like useDependency and connectDependencies.
    • RxJS Support: Reactive programming support via useObservable and useUpdateBinder.
  2. What is a Dependency Item?

    main

    A Dependency Item is the actual implementation or value that will be provided when an identifier is requested. It answers the question: "What should be provided?"

    Common types of dependency items include:

    • Class: A class that the injector will instantiate (e.g., { useClass: AuthService }).
    • Value: A constant, pre-existing value (e.g., { useValue: { apiUrl: '...' } }).
    • Factory: A function that returns the dependency, allowing for dynamic creation (e.g., { useFactory: () => new Service() }).
  3. What is an Identifier?

    main

    An Identifier is a unique token used to request a dependency. It answers the question: "What do I want to inject?"

    There are two ways to define an identifier:

    • Class: Used when the class itself serves as the unique token (e.g., AuthService).
    • IdentifierDecorator: Used when you want to program to an interface rather than a concrete implementation. You create these using createIdentifier<T>(token).
  4. How redi solves dependency management issues

    main

    The redi library provides a systematic solution to three core dependency management problems encountered in large-scale frontend projects:

    1. Dependency Construction Issue: Instead of manually managing construction order and parameters in a massive entry-point class (which violates the principle of least knowledge), redi automates construction via the Injector.
    2. Abstract Dependency Issue: Following the Dependency Inversion Principle (DIP), redi allows high-level modules to depend on abstractions rather than concrete implementations. This makes it easy to swap implementations (e.g., switching from a pcDialog to a mobileDialog) without modifying the business logic.
    3. Dependency Relationship Issue: By managing the lifecycle and instantiation through a central injector, redi helps prevent the chaotic and uncontrollable dependency relationships (like static singletons or global objects) that often lead to circular dependencies.
  5. How to create Bindings in redi

    main

    A Binding connects an identifier to a dependency item. You register bindings by passing an array of tuples to the Injector constructor. Each tuple follows the pattern [Identifier, DependencyItem?].

    • If you provide only the identifier (e.g., [AuthService]), redi assumes the class is both the identifier and the implementation.
    • If you provide an identifier and an options object (e.g., [ILogger, { useClass: ConsoleLogger }]), you are mapping an interface/token to a specific implementation.
    const injector = new Injector([
      [AuthService],                                    // Class as both identifier and item
      [ILogger, { useClass: ConsoleLogger }],           // Interface → Class
      [IConfig, { useValue: { debug: true } }],         // Interface → Value
    ]);
  6. Understanding Dependency Injection (DI) via Decoupling

    main

    Dependency Injection (DI) is a design pattern used to decouple the construction of a dependency from its use.

    In a tightly coupled system, a class (the consumer) is responsible for instantiating its own dependencies. This exposes the internal construction details of those dependencies to the consumer. If the dependency's constructor changes, the consumer must also be updated.

    By using DI, the consumer only declares which dependencies it requires (usually via its constructor) without worrying about how those dependencies are built. This allows a separate module or container to handle the instantiation logic, fulfilling the requirements of the consumer.

  7. Compare DI implementation approaches: Automatic vs. Manual declaration

    main

    When designing or using a Dependency Injection (DI) library in JavaScript/TypeScript, there are two primary patterns for declaring dependencies. This document discusses the trade-offs between automatic inference via metadata and manual declaration via decorators.

    1. Automatic Inference (e.g., InversifyJS, early Angular)

    This approach uses the @Injectable() decorator on the class. It relies on reflect-metadata and the TypeScript compiler to record constructor parameter types at compile time. The injector automatically resolves dependencies based on these recorded types.

    Pros: Reduces boilerplate code for class-based injections. Cons: Requires reflect-metadata, cannot be used in plain JavaScript (requires TypeScript compilation), and has limited inference capabilities (e.g., struggles with identifier-based injections).

    2. Manual Declaration (e.g., VS Code approach)

    This approach requires users to explicitly mark which parameters should be handled by the DI framework using a decorator like @Inject(Token) directly on the constructor parameters.

    Pros:

    • Works in JavaScript (with appropriate Babel configuration).
    • Provides API consistency for both class-based and identifier-based injections.
    • Allows for "hybrid" constructors where some parameters are provided by the DI framework and others are passed manually by the developer.
    • Does not require reflect-metadata.

    Cons: Requires slightly more explicit code (though this can be mitigated with editor snippets).

    // Approach 1: Automatic Inference
    @Injectable()
    class A {
      constructor(private readonly b: B) {}
    }
    
    // Approach 2: Manual Declaration
    class A {
      constructor(@Inject(B) private readonly b: B) {}
    }
  8. Implement layered architecture using Injector hierarchies

    main

    To enforce architectural boundaries (e.g., ensuring lower-level modules cannot depend on upper-level modules), use a tree of injectors.

    • Parent Injector: Should contain lower-level, foundational modules (e.g., DataModel, StorageService).
    • Child Injector: Created via parentInjector.createChild(dependencies), should contain upper-level modules (e.g., Controller).

    When a dependency is requested from a child injector, it first looks locally. If not found, it delegates to the parent. This delegation only flows from child to parent, preventing upper-level modules in the child from being accidentally accessed by lower-level modules in the parent.

    class DataModel {}
    
    // Lower-level module in parent
    const parentInjector = new Injector([[DataModel]]);
    
    class Controller {
      constructor(@Inject(DataModel) private readonly dataModel: DataModel) {}
    }
    
    // Upper-level module in child
    const childInjector = parentInjector.createChild([[Controller]]);
    
    // Successfully retrieves Controller from child, and DataModel from parent
    const controller = childInjector.get(Controller);
  9. Create child injectors for scoped dependency management

    main

    Injectors in redi can form parent-child hierarchies. This allows you to create scoped environments where child injectors can access dependencies from their parents, but parents cannot access dependencies defined only in their children.

    You can create a child injector using two methods:

    1. Use the createChild method on an existing injector instance.
    2. Pass the parent injector as the second argument to the Injector constructor.
    // Method 1: Using createChild
    const parentInjector = new Injector([[PlatformService]]);
    const childInjector = parentInjector.createChild([[OrderService]]);
    
    // Method 2: Pass parent to constructor
    const childInjector = new Injector([[OrderService]], parentInjector);
  10. Understand dependency lookup in injector hierarchies

    main

    When you call .get(Token) on a child injector, redi follows a hierarchical lookup pattern:

    1. It first searches the current injector's local bindings.
    2. If the dependency is not found, it automatically traverses up to the parent injector.
    3. This continues up the chain until the dependency is found or the root is reached.
    const parentInjector = new Injector([[PlatformService]]);
    const childInjector = parentInjector.createChild([[OrderService]]);
    
    // PlatformService is not in childInjector, so it is found in parentInjector and returned
    const service = childInjector.get(PlatformService);