17 · 自定义 Provider 四形态:useValue / useClass / useFactory / useExisting 到底在选什么

项目开源地址(本专栏实证代码的教程仓) https://gitee.com/yanjinqiang/corp-rag-tutorial (复制到浏览器打开)
承接上篇 :上一篇《LifecycleEvents》结尾留了根线:VectorDbService 特意选"类 provider 而非 useFactory 返回裸对象",就为了让生命周期钩子必然被调------"选哪种 provider 形态"原来是个有后果的决策 。16 期前,04 期也讲过 token(类/字符串/Symbol),但当时只回答了"钥匙长什么样"。 这篇补上"锁芯":当容器默认的"反射 + new + 单例"不满足你时,四个开关(useValue/useClass/useFactory/useExisting)各自在什么场景下掰开默认接线,判断红线是什么。
定位:本篇讲 provider 的四种自定义形态与选型判据,实证全部来自 rag-server 真实 providers 数组(APP_CONFIG 的 useValue、AUTH_TOKENS 的 useFactory、APP_GUARD 的 useClass、VectorDbService 的类速记、useExisting 的诚实未用)。不讲 token 反射机制(04/05 期)、不讲动态模块 forRoot(18 期)。读完你看到任何 provider 定义,能秒答"为什么是这个形态、换别的会怎样"。

一、先立真相:providers: [X] 只是速记

providers: [CatsService] 是下面这行的语法糖:

ts 复制代码
providers: [{ provide: CatsService, useClass: CatsService }]

语义拆开是两件事:

  • provide(token):给依赖起个"名字/钥匙",别人用它来注入(04 期讲过的那把钥匙);
  • useXxx(recipe/配方):告诉容器"满足这个 token 时,该怎么造、给什么"。

默认(useClass)= 照常反射 design:paramtypes、递归 new、单例缓存(05 期那条链)。而你迟早遇到"不想照常 new"的场景------给配置类对象、按环境换实现、要 async 建连接、老 token 想统一------这时就换 recipe:

形态 一句话 典型场景
useValue 容器啥也不做,直接把你给的值发出去 配置对象、常量、mock、第三方 SDK 实例
useClass 同 token 换"用哪个类来 new",仍走容器反射 按环境切实现、测试替身
useFactory 给个函数现场组装,支持 async + 声明自身依赖 连接类对象:要参数、要异步、要注入别的 provider
useExisting 给已有 provider 起别名,指向同一个实例 后向兼容、统一两个入口 token

判断信号:当你想"给一个不是类的东西做依赖",或"同一个 token 在不同时候给不同实现",你就在自定义 provider 的门口了。
前端移植锚点:这就是依赖注入容器的"Provider 配置"------和组件库里"注册一个全局 Provider,底下组件按 key 取"同构;useValue/useFactory 的差别,像 React Context 里塞"现成的对象"还是"useMemo 现算的派生值"。

二、rag-server 全量 provider 形态盘点

先看事实,再逐个判:

Provider 形态 为什么是这个形态
RagController/RagService/HealthController/VectorDbService/DebugGuard 等 类速记(=useClass) 标准服务:有构造依赖、要反射、16 期讲过类 provider 才保生命周期钩子
{ provide: APP_GUARD, useClass: AuthGuard } useClass(token ≠ 类) APP_* 系统 token 是框架约定的"注入槽位",把你的类装进去
{ provide: APP_CONFIG, useValue: options.config } useValue 配置是"数据不是服务",已在 core 生成,容器无需再造
{ provide: AUTH_TOKENS, useFactory: ..., inject: [APP_CONFIG] } useFactory 值要依赖另一个 provider 现算(从宽 config 挑出两枚 token)
{ provide: VECTOR_DB_OPTIONS, useValue: options } useValue forRoot 传进来的参数对象,原样注入
useExisting 未用 项目无"老 token 指向新实现"的迁移期诉求

四形态里项目占了三,唯独 useExisting 没用 ------不是不会,是没有后向兼容包袱(单人项目没有"老代码还在注入旧 token"的历史债)。这个"未用"本身就是判断:useExisting 的唯一入口是迁移期别名,新项目从零写,没有这个入口。

三、逐形态拆(配项目源码)

3.1 useValue:注入"现成的东西",不经过 new

ts 复制代码
// config.module.ts(真实代码,forRoot 内部)
providers: [
    { provide: APP_CONFIG, useValue: options.config },
],

容器不做反射、不 new、不进依赖图 ------@Inject(APP_CONFIG) 拿到的就是 options.config 那个对象本身。两个甜点:

  • "数据而不是服务"的注入:配置、feature flag 这类,天然没有"构造过程";
  • 单测零成本 mock :DI 让所有依赖从容器经过,测试里 overrideProvider(X).useValue(fake) 即可整体替换,业务类一行不改。

注意 16 期的反面提醒:useValue 塞的对象没有生命周期钩子------ConfigValidator 必须是类(钩子拒起),APP_CONFIG 必须是值(数据无生灭),两种形态在同一个模块里各就各位,这是"形态即语义"的活样本。

3.2 useClass:同一个 token,换"造哪个类"

ts 复制代码
// app.module.ts(真实代码)
providers: [
    { provide: APP_GUARD, useClass: AuthGuard },    // token 是框架的系统槽位
    { provide: APP_GUARD, useClass: DebugGuard },   // 同一 token 第二次 = 两个都注册(10 期顺序探针)
    { provide: APP_PIPE, useClass: TrimBodyPipe },
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
    { provide: APP_FILTER, useClass: AllExceptionsFilter },
],

useClass 有两种用法,项目用的是第二种:

  1. 抽象换实现 (教科书用法):provide: LoggerService, useClass: env === "prod" ? JsonLogger : ConsoleLogger------消费方只认 token,换实现是容器一处改动,下游全无感;
  2. 系统槽位 (项目用法):APP_GUARD/APP_PIPE 这些是框架约定的 token ,本身不是类,你把自己的类"装进槽位",框架在管线对应位置消费它们(08 期绑定表里 APP_* 那几行的来历)。

仍是容器 new + 反射,所以 AuthGuard 自己的构造依赖(AUTH_TOKENS、Reflector)照常被注入------recipe 换的是"new 谁",不换"怎么 new"。

3.3 useFactory:函数现场组装(最强大,三条纪律)

ts 复制代码
// config.module.ts(真实代码)
{
    provide: AUTH_TOKENS,
    useFactory: (cfg: AppConfig): AuthTokens => ({
        authToken: cfg.authToken,
        adminToken: cfg.adminToken,
    }),
    inject: [APP_CONFIG],        // ← 关键:工厂自己的依赖在这里声明
},

为什么 AUTH_TOKENS 不用 useValue?因为它的值要依赖另一个 provider(APP_CONFIG)现算 ------"从宽 config 里挑出守卫真正需要的两枚 token"。这是窄 token 模式:让 AuthGuard 不依赖整个 config 对象,只拿它要的两枚(接口最小化,04 期讲过的 token 消费现场)。

三条纪律(漏一条就是运行时坑):

  1. 工厂的依赖必须写进 inject 数组 ------函数不是类,没有 design:paramtypes 可反射,不写就拿到 undefined;
  2. inject 里的 token 按位置传给 useFactory 的参数(第一个 token → 第一个参数);
  3. 可选依赖用对象形式 { token: "X", optional: true }。

另外两个能力:useFactory 支持 async (返回 Promise,容器 await 后再注入------21 期 AsyncProviders 展开);但工厂 provider 之间不支持循环依赖(20 期:类可以靠 forwardRef 二次实例化兜底,函数不能调两次)。

3.4 useExisting:别名,且是"同一个实例"

ts 复制代码
// 项目未用,示意
const alias = {
    provide: "LEGACY_LOGGER",    // 老代码习惯注入这个名字
    useExisting: LoggerService,  // 指向新实现 token
};

关键区别 :useExisting 拿到的是和原 token 同一个单例 ;用 useClass 再造一遍则是另一个实例。典型场景只有一个------重构迁移期:老 token 与新 token 并存,老入口自动指向新实现,业务逐步迁移,迁完删别名。验证方法:

ts 复制代码
constructor(
    private readonly logger: LoggerService,
    @Inject("LEGACY_LOGGER") private readonly legacy: LoggerService,
) {
    logger === legacy;   // true ------ 同一个对象
}

四、跨模块:自定义 provider 按 token 导出

自定义 provider 要被别的模块消费,必须 exports 出来,且按 token 导出:

ts 复制代码
// config.module.ts(真实代码)
exports: [APP_CONFIG, AUTH_TOKENS],

ConfigValidator 在 providers 里但不进 exports------它是模块内部自检,不供别的模块注入(16 期讲过它被 AppModule import 即实例化、钩子必然触发)。

判断信号:看到 @Inject(APP_CONFIG) 报 "not available in the ... module",先查提供方模块有没有把那个 token 加进 exports------光放 providers 不够,exports 才对外可见。

五、判断红线(背这张表就够)

你想表达的语义 选它 别选的理由
注入的是数据/常量/现成对象 useValue useClass 会画蛇添足去 new
同 token,按环境/测试换实现类 useClass useValue 得手拼完整实例,失去反射注入
要 async / 要喂依赖 / 现算派生值 useFactory 前两者做不到真异步与依赖注入
只想给老 token 指到新实现,且要同实例 useExisting useClass 会变成两个实例,状态不共享
要被生命周期钩子管理 类(useClass/速记) useValue 裸对象没有钩子(16 期)
token 会跨包/跨模块共享 Symbol(项目 APP_CONFIG 即是) 字符串易撞易拼错

一句话:useValue 给"值",useClass 换"类",useFactory 管"过程",useExisting 造"别名"。 从默认 useClass 开始,遇到具体痛点再升级------rag-server 的 providers 数组就是按这张表长出来的:能一行判对的形态选型,不需要更多理由。

六、常见坑

  1. useFactory 漏写 inject → 工厂参数拿到 undefined,不是编译错,是运行时炸;
  2. useClass 当 useExisting 用 → 同 token 两个实例,状态不共享,"换实现"变成"另起炉灶";
  3. 给接口/配置对象裸写构造注入 → design:paramtypes 反射不出接口,必须 @Inject(token) 显式点名(04 期);
  4. 字符串 token 到处手写字面量 → 两边拼写不一致 = 查表查不到;抽进 xxx.constants.ts(项目的 config.constants.ts 就是这么干的,且全部用 Symbol);
  5. 自定义 provider 不进 exports → 消费方模块报 "not available";exports 按 token,不按类;
  6. useValue 塞对象又期待生命周期钩子 → 没有(16 期);要钩子就当类 provider;
  7. 为了炫技提前上 useFactory → 单纯给值能解决的,工厂是负资产(失去类型直给、多一层间接)。

七、前端心智对照 + 自测

前端概念 对应 本质
Context 里塞现成对象 useValue 值直给,无构造过程
Context 里放 useMemo 派生值 useFactory + inject 依赖别的值现算
Provider 换实现,消费方无感 useClass 换类 只认 token 不认实现
老代码 import 旧路径 + re-export 指向新模块 useExisting 迁移期别名,同一实例
注册 key 两边拼错取不到值 token 不一致 契约常量纪律

自测 4 题(先自己答,再看答案):

  1. providers: [RagService] 展开成什么?provide 和 useXxx 各管什么? → { provide: RagService, useClass: RagService }。provide = token(注入的钥匙),useXxx = recipe(容器满足这个 token 时的造法)。

  2. AUTH_TOKENS 为什么是 useFactory 而不是 useValue? → 它的值要依赖 APP_CONFIG 现算 (从宽 config 挑两枚 token 成窄对象);useValue 只能给"已存在的值",给不了"派生过程"。同时 inject: [APP_CONFIG] 声明工厂依赖------函数没有 design:paramtypes,漏写拿到 undefined。

  3. useExisting 和 useClass(同 token 二次注册)的实例语义差别? → useExisting = 别名指向同一单例 ;useClass 再注册 = 容器 new 另一个实例 。所以迁移期要"老入口指新实现且状态共享"只能 useExisting。项目里 APP_GUARD 注册两次是另一个语义(两个独立守卫都装进槽位、按序执行,10 期),不是别名。

  4. ConfigValidator 和 APP_CONFIG 同在一个模块,为什么一个必须是类、一个必须是值? → ConfigValidator 要 onModuleInit 钩子做 fail-fast 自检------钩子只有类 provider(容器实例化)才有;APP_CONFIG 是纯数据,无构造过程无生灭,useValue 直给。形态跟着语义走,同一模块里两种形态各就各位。

八、本篇收束与下一篇

四形态拆完,provider 这层的"选型自由度"就闭环了:04 期讲 token(钥匙),06 期讲全景(哪些东西进了容器),16 期讲钩子资格(类 provider 的特权),本期把 recipe(锁芯)补齐------token 决定"叫什么",recipe 决定"怎么造",两者拼起来才是完整的 provider 定义。rag-server 的 providers 数组里,每一个形态选择都能用 §五 的红线一行判对。

但你可能已经注意到:ConfigModule 和 VectorDbModule 都不是"静态 @Module"------它们的 providers 是 forRoot(options) 按参数现算返回 的。为什么配置模块要长成函数?isGlobal 参数和 07 期退役的 @Global() 装饰器什么关系?这就是下一篇。

下一篇(18 期《动态模块与 forRoot 模式》)拆 DynamicModule:模块从"静态收纳盒"升级为"带参数的工厂",ConfigModule.forRoot + VectorDbModule.forRoot 两份真实实现逐行对读,以及"组合根"这个角色为什么重要。

相关推荐
碎觉崽1 小时前
我的 QQ 机器人被腾讯反复踢下线,折腾了半个月才搞明白
后端
吱屋猪_1 小时前
一个RAG系统是怎样运行的:从文档分块到大模型回答
embedding·健康医疗·ai编程
传人once1 小时前
网页文字滚动效果如何做
前端·css·html·jquery·html5
Lucas_coding1 小时前
【LLM】多平台思考模式的开启、关闭与响应判断
语言模型·ai编程
liangshanbo12151 小时前
面试题:React 中 Lane 和 Scheduler 是怎么配合的?
前端·react.js·前端框架
liangshanbo12151 小时前
请求拦截器相关面试题
java·前端·javascript
打工仔折腾 AI1 小时前
从零写一个CAD 05:以鼠标为中心的滚轮缩放矩阵顺序不能反
后端·python·线性代数·性能优化·矩阵·计算机外设·ai agent 实战
ShawnLiaoking1 小时前
ADS305 Big Data Lecuture4 Web Scraping
大数据·前端
liangshanbo12151 小时前
React面试题:Hooks 为什么不能在分支和循环里面写?
前端·javascript·react.js