React 与 NestJS 的依赖注入(DI)协同:在前端模拟后端控制反转心智模型的工程实践

各位下午好,各位未来的架构师,还有那些正坐在工位上假装在敲代码实际上在思考晚饭吃什么的朋友们。

今天我们不聊“如何用 CSS 画出个圆”,也不聊“如何把 div 换成 span”,我们来聊点重量的——依赖注入

听说过 NestJS 吗?那种把 TypeScript 玩弄于股掌之间,仿佛你写的不是代码,是魔法咒语的后端框架?在 NestJS 里,你不需要到处 import 一个服务到组件里,你只需要在构造函数里写个 constructor(private service: SomeService) {},然后魔法就发生了。

但是,在 React 里呢?我们要么把服务挂到 window 上,要么把它塞进 Context 里,要么就是像个勤劳的小蜜蜂一样,一层层往 props 里传。这感觉就像是你去餐厅点菜,厨师(组件)想用盐(服务),还得亲自去后厨(父组件)拿,还得把盐带回来,万一盐撒了呢?

今天,我们要搞个大新闻:我们要在前端 React 里,复刻 NestJS 的依赖注入(DI)机制。

这不是为了炫技,也不是为了显摆我们会写框架。这是为了让你在写前端代码时,能像写后端代码一样,享受那种“上帝视角”的控制感。

准备好了吗?把手里的咖啡放下,因为我们要开始重构了。


第一章:受虐狂式的 Props Drilling

在讨论解决方案之前,我们必须先承认痛苦。

假设你正在写一个复杂的仪表盘。最顶层的 Dashboard 组件需要显示用户的信息。于是你把 UserContext 深埋在 App 组件里。

App -> Header -> Sidebar -> Dashboard -> UserStats

好,现在 UserStats 组件想用 UserService 来获取用户数据。你该怎么办?

// App.tsx
function App() {
  const user = useUser(); // 这里很舒服
  return (
    <Layout user={user}>
      <Dashboard />
    </Layout>
  )
}

// Layout.tsx
function Layout({ user }: { user: User }) {
  return (
    <div className="layout">
      <Header user={user} /> {/* 又传一遍 */}
      <Sidebar />
      <Dashboard />
    </div>
  )
}

// Dashboard.tsx
function Dashboard() {
  // 假设 Layout 传了 user 给 Dashboard
  // 但 Dashboard 想用 UserService...
  return <UserStats />
}

// UserStats.tsx
function UserStats() {
  // 糟糕,用户数据在哪?
  // 老板说 Dashboard 组件太重了,把 UserStats 拆出去独立成一个模块。
  // 你一看代码,好家伙,还得回 App.tsx 顺着树形结构找一遍,把 UserContext 传下去。
  return <div>...</div>
}

这种“Props Drilling”不仅恶心,而且极其脆弱。稍微改一下组件层级,你可能就得在三个文件里来回横跳。

这就像是你在装修房子,每一层楼都需要喝水(服务),但你不得不从地下一楼的水管直接拉管子到顶楼。水管多了,这房子还怎么住?

而 NestJS 的做法是:你只需要告诉容器“我渴了”,容器就会把水直接递到你嘴边。这就是控制反转

第二章:DI 容器的哲学——你的管家,不是你的奴隶

什么是 DI?

简单来说,就是“我不生产依赖,我只是依赖的搬运工”。或者说,我不负责创建对象,我只负责组装对象。

在 React 中,我们习惯了 useEffect 去副作用里获取数据,或者在 useMemo 里计算。但如果我们能把业务逻辑抽离成“服务类”,并且让 React 的组件变得像“骨架”一样,只负责渲染和交互,而不负责数据的繁杂初始化,那该多好?

我们想在前端实现一个 DI Container

想象一下,我们有一个名为 Container 的东西。它像是一个超级仓库管理员。

  1. 注册: 当你启动应用时,你告诉仓库管理员:“这个 AuthService 是一个单例,永远别扔了,谁需要就给谁。”
  2. 注入: 当组件初始化时,它通过某种机制(类似装饰器或者工厂函数)告诉管理员:“我要 AuthService。”管理员一查,手里刚好有一个,直接扔给组件。

这有什么好处?

  • 解耦: 你的组件再也不需要知道 AuthService 是怎么实现的。它只需要知道“我有了一个服务,它能帮我登录”。
  • 可测试: 这才是重点!当你想测试 LoginPage 组件时,你不需要真的调用后端 API。你只需要告诉容器:“把 AuthService 换成个假的,返回个 true 就行。”然后你一运行测试,通过了!
  • 单例管理: 避免了内存泄漏,避免了重复创建昂贵的对象。

第三章:动手吧,构建你的“NestJS-lite”

好,别光说不练。我们要在 React 里造个轮子。为了显得专业,我们得引入一些 NestJS 的术语,假装我们真的在写后端。

我们需要三个核心概念:

  1. Provider(提供者): 具体的服务类(比如 AuthService)。
  2. Token(令牌/接口): 用于类型安全的唯一标识符(比如 IAuthService)。
  3. Container(容器): 存储和分发 Provider 的地方。

3.1 定义接口与令牌

首先,我们要模拟 NestJS 的 @Injectable()。我们定义一个基类,所有的服务都得继承它。

// base.service.ts
export class BaseService {
  // 模拟 NestJS 的 constructor 生命周期钩子
  constructor() {
    console.log(`[DI] Service ${this.constructor.name} has been initialized.`);
  }
}

然后是令牌。在 NestJS 里,我们通常用 forwardRef 或构造函数参数类型来注入。在前端,为了类型安全,我们通常定义一个符号(Symbol)或者一个接口作为 Token。

// auth.service.ts
import { BaseService } from './base.service';

// 这个 Token 就像是服务的大门钥匙
export const IAuthService = Symbol('IAuthService');

export class AuthService extends BaseService {
  private users: User[] = [];

  login(username: string, password: string): boolean {
    console.log(`[AuthService] Attempting login for ${username}...`);
    // 模拟网络请求延迟
    setTimeout(() => {
      const success = username === 'admin' && password === '123456';
      console.log(success ? '[AuthService] Login Successful!' : '[AuthService] Access Denied.');
    }, 500);
    return true;
  }

  getCurrentUser(): User {
    return { name: 'CurrentUser' };
  }
}

注意看,AuthService 只知道 IAuthService 这个令牌,它不知道它到底长什么样,这叫依赖倒置

3.2 构建容器

现在我们需要一个 Container 类。这是整个系统的核心。

// di-container.ts
type ProviderType = new (...args: any[]) => any;

class DIContainer {
  private providers: Map<PropertyKey, { useClass: ProviderType; instance?: any }> = new Map();

  // 注册服务
  register<T extends ProviderType>(token: PropertyKey, useClass: T) {
    console.log(`[Container] Registering service ${String(token)}...`);
    this.providers.set(token, { useClass, instance: null });
  }

  // 获取服务(核心魔法)
  resolve<T>(token: PropertyKey): T {
    const provider = this.providers.get(token);

    if (!provider) {
      throw new Error(`[Container] Error: Token ${String(token)} is not registered.`);
    }

    // 单例模式:如果已经实例化了,直接返回,别浪费资源
    if (provider.instance) {
      return provider.instance as T;
    }

    // 实例化服务
    // 这里是关键:我们通过 new 关键字动态实例化服务
    provider.instance = new provider.useClass();

    return provider.instance as T;
  }
}

// 导出单例容器
export const container = new DIContainer();

这就是 NestJS 里“魔法发生的地方”。你在后端写 new UserService(),在容器里写,在组件里注入。在这里,我们把 new 的权力收归了容器,组件只管要。

3.3 注册服务

在应用启动时(比如 main.tsx 或者 bootstrap.tsx),我们需要初始化容器。

// app-bootstrap.tsx
import { container } from './di-container';
import { AuthService } from './auth.service';
import { IAuthService } from './auth.service';

export function bootstrapApp() {
  // 1. 注册服务
  container.register(IAuthService, AuthService);

  // 2. 初始化一些全局状态(如果需要)
  console.log('[App] Bootstrapping complete. DI Container is ready.');
}

第四章:React 组件的“无感”集成

现在,到了最爽的时刻。我们的 React 组件终于可以像个真正的消费者一样,优雅地使用服务了。

我们不需要 import { AuthService } from ...,我们只需要从容器里拿。

// login.component.tsx
import { useState } from 'react';
import { container } from './di-container';
import { IAuthService } from './auth.service';

// 声明组件依赖
declare module './di-container' {
  interface DIContainer {
    getAuthService(): any;
  }
}

// 扩展容器方法,提供更符合 React 的 API
container.getAuthService = function() {
  return this.resolve(IAuthService);
}

function LoginPage() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  // 获取服务实例
  const authService = container.getAuthService();

  const handleLogin = () => {
    setLoading(true);
    // 直接调用,没有任何 props drilling,没有任何繁琐的 API 调用
    authService.login(username, password);

    // 模拟后续逻辑
    setTimeout(() => {
      setLoading(false);
      alert('模拟登录成功!');
    }, 600);
  };

  return (
    <div className="login-page">
      <h1>React DI Edition</h1>
      <input 
        type="text" 
        value={username} 
        onChange={(e) => setUsername(e.target.value)} 
      />
      <input 
        type="password" 
        value={password} 
        onChange={(e) => setPassword(e.target.value)} 
      />
      <button onClick={handleLogin} disabled={loading}>
        {loading ? 'Logging in...' : 'Login'}
      </button>
    </div>
  );
}

看!多么干净。LoginPage 甚至不知道 AuthService 是怎么写的。它只是向容器要了一个它需要的东西。

第五章:真正的魔法——依赖倒置与 Mocking

这部分才是我们今天讲座的压轴大戏。为什么要学 NestJS 的 DI?因为测试

如果不使用 DI,你想测试 LoginPage,你必须 mock AuthService。但是 AuthService 是个类,你必须在 LoginPageimport 它。这就意味着你不仅引入了代码,还引入了依赖。

而在 DI 容器中,我们只需要在测试前替换掉容器里的服务。

5.1 编写测试

假设我们要测试 LoginPage 的 UI 渲染,或者测试登录按钮的点击逻辑,但不关心网络请求。

// login.component.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { LoginPage } from './login.component';
import { container } from './di-container';
import { IAuthService } from './auth.service';

// 1. 创建一个假的 Mock Service
class MockAuthService extends BaseService {
  login = jest.fn().mockResolvedValue(true);
}

describe('LoginPage Integration', () => {
  beforeEach(() => {
    // 2. 每次测试前,先把容器里的 AuthService 换成假的
    container.register(IAuthService, MockAuthService);
    // 注意:因为我们用了单例模式 resolve,注册新的类会覆盖旧的实例吗?
    // 为了简单起见,我们在 resolve 里判断,如果是同一个类,就不重新 new 了。
    // 所以我们需要手动清除缓存,或者使用一个新的 Token。
    // 这里为了演示方便,我们假设我们在容器里做了特殊处理。
  });

  test('should call login method when button is clicked', () => {
    render(<LoginPage />);

    // 模拟用户输入
    fireEvent.change(screen.getByPlaceholderText(''), { target: { value: 'admin' } });
    fireEvent.change(screen.getByPlaceholderText(''), { target: { value: '123456' } });

    // 点击按钮
    fireEvent.click(screen.getByText('Login'));

    // 3. 断言 Mock 方法被调用了
    const authService = container.getAuthService();
    expect(authService.login).toHaveBeenCalledWith('admin', '123456');
  });
});

这就是 NestJS 风格 DI 的精髓。你的组件依赖的是抽象(接口),而不是具体的实现。 测试时,你只需要注入一个假的实现。这就像你在演戏,导演说“你演个皇帝”,你不需要真的当皇帝,你只需要穿个戏服,假戏真做而已。

第六章:进阶——作用域与循环依赖

如果你深入钻研 NestJS,你会听到 Scopes(作用域) 的概念。有 Singleton(单例,默认),有 Request(请求级,一个 HTTP 请求对应一个实例)。

在前端,我们通常用 Singleton。但有时候,我们希望每个组件重新渲染时,都能拿到一个新的服务实例(虽然很少见,但为了严谨,我们要聊聊)。

6.1 模拟 Scoped Provider

我们需要修改 DIContainer

class DIContainer {
  // ... 之前的代码 ...

  // Scoped 版本的 resolve
  resolveScoped<T>(token: PropertyKey): T {
    const provider = this.providers.get(token);
    if (!provider) {
      throw new Error(`[Container] Error: Token ${String(token)} is not registered.`);
    }

    // Scoped 模式:每次调用 resolveScoped,都返回一个新的实例
    // 这就像每次你点菜,服务员都去厨房现做一盘,而不是上剩菜
    return new provider.useClass() as T;
  }
}

然后我们在组件里使用它。

function AsyncComponent() {
  // 强制获取一个 Scoped 实例
  const scopeService = container.resolveScoped(IScopeService);
  // 这个 scopeService 每次渲染可能都是新的,或者你可以保持状态
  return <div>...</div>
}

6.2 循环依赖的噩梦

在 NestJS 里,如果 A 注入 B,B 又注入 A,这叫循环依赖,是编程界的禁忌。后端通常用 forwardRef 解决。

在前端,如果你用类和 DI,这个问题也会出现。因为类加载时,会触发构造函数,而构造函数里尝试从容器拿依赖,但容器可能还在初始化这个类。

解决方案:懒加载(Lazy Loading)

当你拿到 A 的时候,不要立马去容器里拿 B。而是先返回一个“占位符”,等到真正需要 B 的时候,再从容器里去拿。

// 解决循环依赖的简单思路
class Container {
  // ... 
  getDep<T>(token: PropertyKey) {
    // 如果已经有值,直接返回
    if (this.cache.has(token)) return this.cache.get(token);

    // 如果没有值,说明可能还没准备好
    // 我们创建一个 Promise,稍后再 resolve
    const promise = new Promise((resolve) => {
        this.deferredResolves.set(token, resolve);
    });

    this.cache.set(token, promise); // 先把 promise 放进去,防止死锁

    return promise.then(() => this.cache.get(token)!);
  }
}

不过,在日常工程实践中,只要你能控制好逻辑,通常避免让 A 直接依赖 B 是更好的架构设计。一个类应该只管理自己负责的那一点点事情。

第七章:实战案例——一个极简的“交易系统”

为了把这些理论串起来,我们来造一个完整的轮子。

场景:一个电商后台,你需要查看订单列表。订单列表需要调用订单服务获取数据。

7.1 定义契约

// interfaces.ts
export interface IOrderService {
  getOrders(): Promise<Order[]>;
}

export interface IUserService {
  getCurrentUser(): User;
}

export const IOrderService = Symbol('IOrderService');
export const IUserService = Symbol('IUserService');

7.2 实现后端模拟服务

我们用 fetch 来模拟后端。

// services.ts
import { IOrderService } from './interfaces';

export class OrderService implements IOrderService {
  async getOrders(): Promise<Order[]> {
    console.log('[OrderService] Fetching orders from API...');
    // 模拟 API 延迟
    const response = await fetch('/api/orders');
    return response.json();
  }
}

7.3 构建前端环境

我们创建一个 React 组件来展示。

// OrderList.tsx
import { useEffect, useState } from 'react';
import { container } from './di-container';
import { IOrderService, IUserService } from './interfaces';

// 扩展容器接口
container.getOrders = () => container.resolve(IOrderService);
container.getCurrentUser = () => container.resolve(IUserService);

function OrderList() {
  const [orders, setOrders] = useState<Order[]>([]);
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const init = async () => {
      setLoading(true);
      try {
        const user = container.getCurrentUser();
        const orderList = await container.getOrders();

        setUser(user);
        setOrders(orderList);
      } catch (e) {
        console.error(e);
      } finally {
        setLoading(false);
      }
    };

    init();
  }, []);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <h1>Order Dashboard</h1>
      <p>Welcome back, {user?.name}!</p>
      <ul>
        {orders.map(order => (
          <li key={order.id}>{order.id} - ${order.total}</li>
        ))}
      </ul>
    </div>
  );
}

7.4 单元测试大杀器

现在,我想测试 OrderList 的渲染逻辑。我不想真的去请求 /api/orders

// OrderList.test.tsx
import { render, screen } from '@testing-library/react';
import { OrderList } from './OrderList';
import { container } from './di-container';
import { IOrderService } from './interfaces';

class MockOrderService implements IOrderService {
  getOrders = jest.fn().mockResolvedValue([
    { id: '1', total: 100 },
    { id: '2', total: 200 }
  ]);
}

beforeEach(() => {
  // 在每个测试用例开始前,注入 Mock
  container.register(IOrderService, MockOrderService);
});

test('renders order list correctly', async () => {
  render(<OrderList />);

  // 等待异步数据加载完成
  await screen.findByText('Welcome back');

  expect(screen.getByText('$100')).toBeInTheDocument();
  expect(screen.getByText('$200')).toBeInTheDocument();
});

看到了吗?这就是 DI 带来的灵活性。你的测试运行速度极快,因为没有任何网络请求,没有任何副作用。

第八章:总结——拥抱混乱,但要有序

好了,朋友们,今天的讲座接近尾声。

我们回顾了一下:

  1. Props Drilling 是种病,得治。
  2. DI Container 是良药,能解耦。
  3. 通过 NestJS 的心智模型,我们在 React 中构建了一个基于类和注册表的服务系统。
  4. 这让我们的代码更容易测试,更符合 SOLID 原则(特别是依赖倒置原则 DIP)。

我知道,有些激进派会说:“React 的 Hooks 已经很棒了,为什么要搞这么复杂?这不是杀鸡用牛刀吗?”

对于简单的页面,确实不需要。但是对于大型应用、对于企业级后台、对于复杂的仪表盘,这种架构就像是为法拉利准备的 F1 赛道。如果你在泥坑里开车(简单的小组件),你用 Hook 走两步就到了;但如果你要飞越珠穆朗玛峰(全公司通用的复杂业务系统),你需要的是一套精密的机械结构(DI 容器)。

DI 的本质,不是代码的写法,而是思维的转变。

它让你从“我需要什么”转变为“我拥有什么”,进而转变为“我依赖什么抽象”。

当你把业务逻辑封装在 Service 里,把 UI 逻辑封装在 Component 里,把配置逻辑封装在 Provider 里时,你就拥有了上帝视角。你可以随意替换任何一个部分,而不影响大局。

正如 NestJS 官网所说:“架构,就是你如何组织代码以使其在长时间内保持可维护性。”

在这个代码量日益膨胀的时代,学会如何“组织”,比学会如何“写代码”更重要。

希望今天的讲座能让你在写代码的时候,少一点吐槽,多一点掌控。现在,拿起你的 TypeScript 编程器,去构建你心中的 NestJS 吧!

下课!

发表回复

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