Skip to content

代码批判性分析与改进建议 ​

本文记录了对 use-vue-service 当前实现的全面批判性审查,涵盖 API 设计、内部实现、架构、以及与同类开源产品的横向对比。 每条建议后预留了「作者结论」栏位,用于记录后续的讨论和决策。


一、API 设计层面 ​

1.1 三套 API 的命名对称性问题 ​

问题描述

作用域声明使用
组件级declareProvidersuseService
全局级declareRootProvidersuseRootService
App 级declareAppProvidersuseAppService

useService 与另两个不对称——如果遵循命名规律,组件级应叫 useComponentService,或者全局级应叫无前缀的 useService。对初次使用者来说,看到 useService 不清楚它默认查找哪个容器。

对比 Angular:inject() 是统一入口,作用域由 @Injectable({ providedIn: ... }) 在声明侧控制,而非在使用侧分叉。对比 Inversify + inversify-inject-decorators:也是统一 @inject 使用,通过容器绑定控制层级。

改进建议

考虑提供统一的 inject(token) / useService(token) 作为主入口,内部自动按优先级查找(组件容器 → App 容器 → Root 容器),把"查找哪个容器"的逻辑放到框架内部,而不是暴露给调用者。README 中的 Todo List 第 4 条也指出了这个问题。

作者结论

这个设计是经过深思熟虑的,三套 API 对应三种明确不同的使用场景,并非命名不对称,而是有意拆分。

使用频次上:90% 的场景只需要 declareProviders + useService,尤其是组件开发时。

三组 API 的场景分工:

  • declareProviders / useService:组件级作用域,最常用;useService 之所以不加前缀,正是因为它是主入口。
  • declareRootProviders / useRootService:全局单例服务。useRootService 存在的原因是 useService 依赖 Vue 的 provide/inject,只能在组件上下文中调用,而路由中间件、工具类等场景不在组件上下文中,需要一个脱离上下文的获取方式。此组 API 仅适用于 CSR 项目。
  • declareAppProviders / useAppService:App 级作用域,适用于 SSR(每个请求对应一个 App 实例,避免数据污染)以及同一页面多个 Vue App 实例共存需要隔离的场景。

获取服务的范围(容器继承链):三个 use* API 并非各自封闭,而是遵循容器继承关系向上查找:

  • useService:可以获取由 declareProviders、declareAppProviders、declareRootProviders 绑定的服务(沿继承链:组件容器 → App 容器 → Root 容器)
  • useAppService:可以获取由 declareAppProviders、declareRootProviders 绑定的服务(App 容器 → Root 容器)
  • useRootService:只能获取由 declareRootProviders 绑定的服务,不向上也不向下查找

这形成了一个清晰的关系:useService ⊇ useAppService ⊇ useRootService(从获取侧看,useService 能访问的服务范围最广)。但从声明侧看则相反——越靠近"根"的层级(declareRootProviders),声明的服务可见范围越大(可被所有层级的容器访问到),但优先级越小(当更近的容器绑定了同一 token 时会被覆盖),同时也越脱离 Vue 上下文的依赖。

与 Angular 的本质区别:Angular 在定义服务时(@Injectable({ providedIn: ... }))就绑定了作用域,服务的定义和作用域是耦合的。本库做了明确拆分:①定义服务只是单纯定义服务;②通过独立 API 指定服务的作用域,同一个服务可以同时注册到多个作用域,层级越低的容器优先级越高;③最后通过独立 API 获取实例。这种拆分让服务类本身更纯粹,作用域决策交给调用方,灵活性更高。

结论:当前 API 设计无问题,维持现状。


1.2 declareProviders 的 Provider 类型设计过于简单 ​

问题描述

ts
// 目前只支持这两种
type NewableProvider = Newable[];
type FunctionProvider = (container: Container) => void;
  • 函数形式(FunctionProvider)把底层 Container API 暴露给用户,这是一种泄漏抽象。用户需要了解 .bind().toSelf()、.toDynamicValue() 等底层细节,与库的高级抽象目标相悖。
  • 对比 Angular 的 Provider 配置对象({ provide: Token, useClass: Impl, useValue: val, useFactory: fn }),Angular 的方式无需暴露容器就能覆盖大多数场景。
  • 对比 NestJS(底层也是 inversify 思想):provider 对象形式更符合声明式风格。

改进建议

引入类 Angular 的 provider 描述对象,例如:

ts
{ provide: Token, useClass: Impl }
{ provide: Token, useValue: someValue }
{ provide: Token, useFactory: (dep1, dep2) => new Impl(dep1, dep2), deps: [Dep1, Dep2] }

这样既能保留强类型,又不暴露 Container 内部 API。

作者结论

这个设计是在轻量级与功能完备之间有意做了取舍。99% 以上的场景中,服务都是通过类来定义的,NewableProvider(类数组)已经足以覆盖绝大部分需求。FunctionProvider 则作为逃生舱口,保留给极少数需要自定义绑定的特殊场景。

引入类 Angular 的 provider 配置对象虽然更声明式,但会显著增加 API 的复杂度和维护成本,与本库保持轻量级的定位不符。

结论:保持现状,不做处理。


1.3 declareProviders 与 declareAppProviders 的参数顺序不对称 ​

问题描述

ts
// 组件级
declareProviders(providers)

// App 级
declareAppProviders(providers, app)  // ← app 在后

这个顺序虽然合理,但与 Vue 插件风格(app.use(plugin) / plugin.install(app))有些割裂。declareAppProvidersPlugin 解决了一部分,但 declareAppProviders 本身还是需要手动传 app,且 app 在参数尾部位置不够突出。

改进建议

可以考虑将 app 提到第一位:declareAppProviders(app, providers),或者推荐用户始终通过 declareAppProvidersPlugin 使用 App 级注册。

作者结论

app 参数放在后面是为了与其他接口(declareProviders(providers))保持参数顺序一致,providers 始终是第一个参数。app 作为必需参数无法省略,只能由用户手动传入,这是合理的设计。

推荐使用场景:

  • Vite 项目:优先使用 declareAppProvidersPlugin,通过 app.use() 注册,符合 Vue 插件惯例
  • Nuxt 项目:在 Nuxt 插件中可以拿到 app 实例,直接调用 declareAppProviders(providers, app) 更灵活

结论:保持现状,不做处理。


1.4 useService 的错误信息质量差 ​

问题描述

ts
// core.ts:109
throw new Error('getProvideContainer 只能在 setup 中使用');

这个错误是内部实现的错误信息,被直接暴露给用户。用户看到的是 getProvideContainer(一个内部函数名),而不是他们实际调用的 useService。

改进建议

在 useService 层面捕获并抛出更友好的错误:useService 只能在 setup 上下文中调用。内部函数的错误信息只应出现在内部(或加 [internal] 前缀),不应直接暴露给库的使用者。

作者结论

问题成立。已通过以下方式修复:

新增内部工具函数 assertInjectionContext(callerName: string),专门负责检查注入上下文并抛出友好错误信息。useService 和 declareProviders 在函数入口分别调用 assertInjectionContext('useService') 和 assertInjectionContext('declareProviders'),用户看到的错误信息直接指向其实际调用的公开 API。

getProvideContainer 移除了原有的 hasInjectionContext 检查,职责回归为单纯的"查找容器",不再承担错误报告的职责。

结论:已修复,采用 assertInjectionContext 工具函数方案,改动最小且语义清晰。


1.5 findChildService / findChildrenServices 的发现性与设计问题 ​

问题描述

这两个功能通过 Token 注入获取,使用方式比较隐晦:

ts
const findChild = useService(FIND_CHILD_SERVICE);
const child = findChild(ChildService);
  • 不直觉——用户很难从文档外发现这个功能存在
  • 返回 T | undefined 但不抛出错误,与 useService(直接抛错)行为不一致
  • 向下查找子容器这个操作在 Angular / inversify 中根本不存在。传统 DI 的依赖方向是单向向上的:子(消费者)依赖父(提供者)的接口,父不关心谁在消费,容器设计上父容器也不持有子容器的引用。FIND_CHILD_SERVICE 反向遍历子容器树,打破了这个方向性假设。但这个批评有局限性:后端 DI(Angular Service、NestJS)是纯逻辑层,确实不需要向下查找;而前端 Vue 组件树有可视化层级结构,父组件触发行为影响子组件 UI 是真实且合理的需求,用后端 DI 视角来评判前端场景并不完全适用

改进建议

这个特性本身值得重新审视是否应该存在。如果保留,至少把它提升为直接 API(findChildService(token))而不是通过 token 间接使用。

作者结论

这个设计有其明确的使用场景,且经过了深思熟虑,批判性分析中的理解有偏差,需要补充背景。

为什么父组件不能长期持有子组件的服务:useService 向上查找是安全的,因为父组件的生命周期 ≥ 子组件,子组件持有父组件的服务不会出现悬空引用。反过来父组件持有子组件的服务则是危险的——子组件随时可能被销毁,而父组件还持有已销毁的服务实例,不符合业务逻辑。

FIND_CHILD_SERVICE 的设计定位是「瞬态查找」,不是长期持有。典型场景:父组件的按钮点击事件需要触发某个深层子组件弹窗的显示(弹窗的 visible 属性由子组件服务控制)。此时有三种方案:

  1. 将子组件服务的注册范围提升到父组件——导致服务与组件生命周期不一致,不推荐
  2. 通过 ref + defineExpose 调用子组件方法——跨层级时极为繁琐
  3. 在点击事件函数内部通过 FIND_CHILD_SERVICE 瞬态查找——函数执行完毕后引用自动释放,安全且便捷

关键约束:只能在函数内部使用,不能通过闭包在函数外部长期持有子组件服务的引用,因为子组件随时可能被销毁。每次需要时都应重新查找。

为什么必须通过 Token + useService/@Inject 获取,而不能是全局函数:查找子组件服务需要以「当前组件的容器」为起点向下遍历,不同组件调用时起点不同,所以不能是全局方法。若设计为 findChildService(container, token),则需要用户自己获取 container,暴露了更多内部细节,反而更差。通过 Token 注入的方式,框架在注入时已绑定了当前容器的上下文,用户拿到的函数开箱即用。

关于「不直觉」的问题:这确实是一个真实的 DX 问题,主要靠文档解决,需要在文档中专门说明此场景和用法。

结论:设计合理,维持现状。文档层面需要补充使用场景说明,强调「瞬态查找、不可长期持有」的约束。


二、内部实现层面 ​

2.1 @Computed 装饰器:this 的指向风险 ​

问题描述

ts
// computed.ts:29
const scope = getEffectScope(this);  // this = reactive proxy
const raw = toRaw(this);

getEffectScope(this) 传入的是 reactive proxy,SCOPE_KEY 被写到 proxy 上还是 raw 上取决于 Vue 的 proxy 实现细节(scope.ts:22 中 that[SCOPE_KEY] = scope 在 reactive proxy 上赋值,Vue 的 reactive 会将赋值代理到原始对象——目前正确,但依赖了 Vue 内部行为)。

更大的风险:如果 getter 在非 reactive 上下文中首次被访问(例如直接 demo.age,不经过 reactive(demo)),this 就是普通实例,Object.defineProperty(raw, ...) 会覆盖 getter,后续 reactive 代理访问时会得到一个没有 scope 关联的 ComputedRef,该 ComputedRef 不会随容器销毁而被 stop,存在内存泄漏风险。

改进建议

在 computedDecorator 中增加对 this 是否为 reactive proxy 的检测(可使用 isReactive(this)),若不是则走降级路径(直接返回 getter 原始值,不创建 ComputedRef)。

作者结论

部分成立,已修复核心问题,其余建议不采纳。

已修复:将 const that = this 和 const raw = toRaw(this) 提到函数顶部,并将 getEffectScope(this) 改为 getEffectScope(raw)。scope 的读写现在直接操作原始实例,不再经过 reactive proxy 转发,消除了对 Vue proxy 内部实现的隐性依赖。

关于"更大的风险"(手动 new 实例):如果用户绕过 DI 容器直接 new 一个服务实例并访问 @Computed 属性,确实无法自动销毁 @Computed 创建的 EffectScope,存在内存泄漏风险。但手动 new 实例不在本库的正常使用范围内,服务实例应始终通过 DI 容器创建和管理,容器的 onDeactivation 钩子会负责调用 removeScope 清理 EffectScope。因此不做防御性处理。

关于 isReactive(this) 检测的改进建议:针对上述手动 new 的误用场景,不过度防御,不采纳。

结论:已修复 proxy 依赖问题,其余维持现状。


2.2 @Computed 的 setter 查找可以提前到装饰器执行期 ​

问题描述

ts
// computed.ts:35-43
let proto = Object.getPrototypeOf(raw);
while (proto) {
  const desc = Object.getOwnPropertyDescriptor(proto, propertyName);
  if (desc && desc.set) { ... }
  proto = Object.getPrototypeOf(proto);
}

这段代码在每次实例首次访问该 getter 时执行一次原型链遍历。对于继承层级深的类有轻微性能影响。更重要的是,context.name 和原型链在类定义时就已确定,这个查找完全可以在装饰器执行期(类定义时)一次性完成,而不是延迟到运行时。

改进建议

在 computedDecorator 的外层(装饰器工厂执行时)提前遍历原型链,将 originalSet 缓存到闭包,避免运行时重复查找。

作者结论

建议不采纳,维持现状。

关于性能:原型链遍历看似在"每次实例首次访问"时执行,但实际上只会执行一次。首次访问后,Object.defineProperty 会在原始实例上写入同名数据属性,覆盖原型链上的 getter。后续访问直接读取该数据属性(ComputedRef),不会再进入 getter 函数,原型链遍历因此只执行一次,性能问题不成立。

关于 context.addInitializer:setter 查找依赖 Object.getPrototypeOf(raw),而 raw 来自 toRaw(this),this 只能在装饰器返回的函数内部访问。如果要在装饰器执行期(类定义时)提前查找,唯一的办法就是借助 context.addInitializer——但 decorate 方法无法模拟该 API,会导致装饰器在 decorate 场景下不兼容。引入新 API 依赖换来的收益微乎其微,不值得。

结论:保持现状,不做处理。


2.3 @Raw 的 field 装饰器实现细节依赖规范执行顺序 ​

问题描述

ts
// raw.ts:48-63
context.addInitializer(function (this: any) {
  let cachedValue: unknown;
  Object.defineProperty(this, propertyName, { get, set });
});
return ensureRaw;  // 作为 field initializer 处理初始值

TC39 规范中 field initializer(return ensureRaw)在 addInitializer 回调之后执行,即先 defineProperty 定义 setter,再把 ensureRaw(initValue) 赋给该 setter——这是正确的。但这个执行顺序依赖了规范细节,代码中没有注释说明,维护风险较高。

另外,defineProperty 中的 get/set 是每个实例创建时新建的函数对象,相比原型方法内存占用更高(每个实例独占一套函数对象)。

改进建议

添加注释说明 addInitializer 与 field initializer 的执行顺序依赖,防止后续维护者误改顺序。如果实例数量很大,可以考虑将 get/set 提升为共享函数并通过 WeakMap 存储每个实例的 cachedValue,以降低内存占用。

作者结论

两点批评均不成立,但讨论过程中发现了一处冗余代码并已删除。

关于"依赖规范执行顺序":这不是本代码特有的风险。TC39 规范明确规定 addInitializer 回调先于 field initializer 执行,所有正确的 field 装饰器实现都必然依赖这个顺序,不存在"不依赖此顺序的替代写法"。因此"依赖规范细节"只是陈述了 field 装饰器的工作原理,没有实际风险。

关于 get/set 提升为共享函数:不可行。get/set 通过闭包引用 cachedValue 来存储各实例的值。若提升为共享函数,则需要用 WeakMap 存储每个实例每个属性的值,但一个类有多个 @Raw 属性时,共享的 get/set 无法区分当前操作的是哪个属性——除非每个属性名各自维护一个独立 WeakMap,这与现在的闭包方案完全等价,毫无收益。

发现的冗余代码:TC39 规范中 addInitializer 回调先执行(定义 setter),field initializer 后执行(赋初始值)。赋初始值时 setter 已就位,会调用 ensureRaw,因此原来 return ensureRaw 作为 field initializer 是冗余的(初始值经过了两次 ensureRaw,而 ensureRaw 是幂等的,结果正确但多余)。已删除该行。

结论:原批评不成立,维持现状。顺带删除了冗余的 return ensureRaw。


2.4 @RunInScope 返回类型无法被 TypeScript 自动推断 ​

问题描述

TypeScript 装饰器目前无法自动修改被装饰方法的返回类型。原始方法返回 void,但装饰器在运行时将返回值替换为 EffectScope。调用侧默认推断为 void,无法直接访问 EffectScope 的属性(如 stop()),只能用 as any 或 as unknown as EffectScope 绕过。

改进建议

提供一个非装饰器版本的函数式 helper,作为类型安全的替代方案:

ts
const scope = runInScope(this, () => {
  watchEffect(() => { ... });
});
scope.stop(); // 类型完全正确

这样的函数式 API 也更符合 Vue Composition API 的风格,可以和装饰器版本并存,用户按偏好选择。

作者结论

建议不采纳,维持装饰器形式,理由如下。

绝大多数场景不需要关注返回值:@RunInScope 修饰的方法通常只是在内部启动一批响应式副作用(watch、watchEffect 等),调用方并不关心返回值,TypeScript 推断为 void 完全够用。

少数需要精细管理 scope 生命周期的场景(如需要主动调用 scope.stop() 停止副作用),有以下三种方案可选:

  • 方案 1:as any 类型断言——放弃类型安全
  • 方案 2:return null as unknown as EffectScope——方法体有欺骗性的占位 return
  • 方案 3:调用时强制转换返回值类型:const scope = this.setup() as unknown as EffectScope

三种方案都不够优雅,但这类场景极少,任选其一都足以应付。

关于函数式 runInScope(this, callback) 替代方案:API 风格上不够理想,且将 this 暴露为显式参数,不符合本库的设计风格。

结论:保持装饰器形式,不引入函数式替代 API。


2.5 removeScope 中的防御性检查多余 ​

问题描述

ts
// scope.ts:42-49
export function removeScope(obj: object): void {
  const that = obj as any;
  if (that) {  // ← object 类型不可能是 falsy
    ...
  }
}

参数类型是 object,object 值在 TypeScript 类型系统中不可能是 null 或 undefined(那是 null/undefined 类型),if (that) 这个检查是多余的,反映了对类型的使用不够精确(或早期代码遗留)。

改进建议

直接删除 if (that) 这层判断,依赖 TypeScript 类型约束即可。如果确实需要防御 null,应将参数类型改为 object | null | undefined,让类型与实现保持一致。

作者结论

建议不采纳,防御性检查有其存在的必要性。

removeScope 的实际调用方是 create-container.ts 中的 deactivationHandle(obj: any),该回调由 @kaokei/di 的容器在销毁实例时触发,obj 的类型没有任何限制,非对象值(数值、字符串等原始类型)都有可能传入。因此 if (that) 的防御性检查是必要的,不能依赖 TypeScript 的类型约束。

结论:保持现状,防御性检查合理,不做处理。


2.6 declareProviders 中 container = null as any 的清理方式 ​

问题描述

ts
// core.ts:158-161
onUnmounted(() => {
  container.destroy();
  container = null as any;
});

将 container 置为 null as any 虽然意图是帮助 GC,但:

  • 这是一个闭包变量,随 onUnmounted 回调一起释放后,GC 自然会回收,手动置 null 效果有限
  • null as any 是类型不安全的写法,如果闭包内后续还有代码访问 container,TypeScript 不会报错,存在运行时 NPE 风险

改进建议

删除 container = null as any 这行,依赖 GC 正常工作。或者如果真的需要提前释放引用,应将变量类型声明为 Container | null 并在后续访问时做 null 检查。

作者结论

建议成立,已删除两处 container = null as any,同时将 let container 改回 const container。

GC 分析:onUnmounted/app.onUnmount 的回调是一个闭包,通过 container 变量持有 Container 对象的引用。组件/App 卸载后,Vue 会清理组件实例上所有生命周期钩子的引用,整个闭包(含 container 变量)随之失去引用,Container 对象 自然可以被 GC 回收。手动将 container 置为 null 发生在回调执行期间(Vue 尚未释放对回调的引用),此时置 null 对 GC 没有任何帮助,是无效操作。

同时存在的类型安全问题:null as any 绕过了 TypeScript 类型检查,如果后续代码访问已被置为 null 的 container,运行时会抛出 NPE 且 TypeScript 不会报错。删除该行后改用 const 声明,从语言层面保证 container 不可被重新赋值。

结论:已修复,删除冗余的 null as any 赋值,改用 const 声明。


2.7 find-service.ts 直接访问 container.children 内部属性 ​

问题描述

ts
// find-service.ts:17-18
if (container.children && walk(container.children, token, results, findFirst)) {

container.children 是 @kaokei/di Container 的内部实现细节属性(虽然在类型定义中以 children?: Set<Container> 暴露,但语义上属于实现细节而非公开契约)。如果 @kaokei/di 的后续版本改变了 children 的数据结构,这里会静默失效。

这种跨库的内部状态访问是紧耦合的体现,use-vue-service 的正确性依赖 @kaokei/di 的内部实现不变。

改进建议

在 @kaokei/di 中将遍历子容器的能力以公开 API 的形式暴露(如 container.getChildren()),而不是由上层库直接读取内部属性。这样可以将变更风险收敛在 @kaokei/di 内部。

作者结论

建议成立,计划采纳。后续将在 @kaokei/di 中新增 container.getChildren() 公开 API,替代当前直接访问 container.children 内部属性的方式。变更收敛在 @kaokei/di 内部,use-vue-service 侧只需将 find-service.ts 中的 container.children 替换为 container.getChildren() 调用即可。

结论:已修复,@kaokei/di 已新增 getChildren() 公开 API,find-service.ts 中三处 container.children 访问均已替换为 container.getChildren() 调用。


三、架构层面 ​

3.1 activationHandle 全量 reactive():没有 opt-out 机制 ​

问题描述

ts
// create-container.ts:8-10
function activationHandle(_: Context, obj: any) {
  return isObject(obj) ? reactive(obj) : obj;
}

容器里所有注入的对象都会被 reactive() 包裹,这意味着:

  1. 不需要响应式的服务(纯 utility 类、HTTP client、logger)也被 reactive 化,有不必要的开销
  2. 第三方 SDK 实例(ECharts、Monaco Editor)如果被注入,必须手动加 @Raw() 装饰器,否则出问题——这个"陷阱"对新用户不够显眼
  3. 对比 Angular:服务本身不是响应式的,响应式状态由服务内部显式声明(signal())。Angular 把响应式的决定权留给服务作者,而不是框架强制全量 reactive

改进建议

提供类级别的 opt-out 机制,例如 @NotReactive() 装饰器标记整个类不需要被 reactive 化:

ts
@NotReactive()
@Injectable()
export class HttpClient {
  // 纯工具类,不需要响应式
}

目前 @Raw() 只能做属性级别的 opt-out,缺少类级别的控制。

作者结论

建议成立,方案已确定,待实现。

设计决策:本库坚持 opt-out 机制(默认 reactive,由用户选择哪些内容不需要响应式),不切换为 Angular 的 opt-in 机制。当前缺少的是类级别的 opt-out 能力,将通过扩展 @Raw 装饰器支持 class 场景来补全(field / accessor / class 三种粒度统一由 @Raw 覆盖,语义自洽)。

实现方案:@Raw 修饰 class 时,通过 @kaokei/di 暴露的 defineMetadata 方法在构造函数上写入标记(避免直接在类上挂 Symbol 属性)。activationHandle 中用 hasOwn 检查该标记,满足条件则调用 markRaw(obj) 而非 reactive(obj)。

继承场景:hasOwn 保证标记不沿原型链向上查找,@Raw 修饰 A 类不会影响继承自 A 的 B 类实例。class 级别 @Raw 的语义是"整个实例不需要响应式",与 field 级别的"单个属性不需要响应式"粒度不同,两者各司其职互不干扰。

前置依赖:需要 @kaokei/di 先暴露 defineMetadata 方法。

结论:已实现。@Raw 扩展支持类装饰器,修饰 class 时通过 context.metadata 写入 RAW_CLASS_KEY 标记;activationHandle 中通过 getOwnMetadata 读取该标记,有标记则调用 markRaw() 而非 reactive()。


3.2 ROOT_CONTAINER 是模块级单例,测试污染严重 ​

问题描述

ts
// core.ts:45
const ROOT_CONTAINER = createContainer();

这个单例在模块加载时创建,在测试用例之间共享状态。从测试文件可以看出(test24 中 useRootService 期望抛出),测试依赖于根容器的特定状态——这正是全局单例在测试中的典型问题。

对比 Angular:TestBed 每次测试都会创建全新的 injector,完全隔离。对比 Pinia:setActivePinia(createPinia()) 可以在每个测试前重置状态。

改进建议

暴露一个 resetRootContainer() 函数(或导出 createRootContainer() 工厂),让测试代码可以在每个 beforeEach 中重置根容器状态:

ts
beforeEach(() => {
  resetRootContainer();
});

作者结论

建议不采纳,不提供 resetRootContainer() 等新 API。

ROOT_CONTAINER 单例的测试隔离问题属于测试工程问题,而非 API 设计问题。解决方式是在测试侧约束:每个测试场景拆分为独立的测试文件,不同文件之间模块状态天然隔离,无需额外 API 支持。这是本库当前单元测试的组织方式,成本可控。

结论:保持现状,不暴露新 API,测试隔离问题由测试文件拆分来解决。


3.3 没有暴露 scope/生命周期 binding(单例 vs 瞬态) ​

问题描述

@kaokei/di 的 Binding 接口有 transient 字段和 inTransientScope() 方法,但 use-vue-service 目前没有将这个能力暴露给用户。所有通过 container.bind(Cls).toSelf() 的绑定默认是单例(在容器作用域内)。

如果用户想要每次 useService() 都得到不同实例(即瞬态作用域),目前没有直接方式,必须用 FunctionProvider 绕过并了解底层 API。

对比 Angular:providedIn: 'root' vs 组件级 provider 本身控制了单例边界;NestJS 有 Scope.TRANSIENT、Scope.REQUEST 等作用域选项。

改进建议

在 NewableProvider 数组形式的基础上,支持带配置的 provider 对象形式(见建议 1.2),其中可以包含 scope 配置:

ts
declareProviders([
  { provide: MyService, useClass: MyService, scope: 'transient' }
]);

作者结论

建议不采纳,不暴露瞬态作用域 API。

实际业务中绝大多数场景都是单例服务,多次创建同一个服务实例的需求概率极低。若真的遇到,有以下两种方式可以满足需求,无需新增 API:

  • FunctionProvider 逃生舱口:通过函数形式直接调用容器底层的 inTransientScope() 绑定 API
  • 多 token 映射同一个类:如果需要创建有限个可枚举的多实例,可以为同一个类绑定多个不同的 token,每个 token 对应独立的实例,以此实现多例效果

结论:保持现状,不暴露瞬态作用域 API,现有机制已足够应对实际场景。


3.4 异步服务初始化的支持不完整 ​

问题描述

@kaokei/di 的 getAsync() 和异步 @PostConstruct 已经支持异步初始化,但 use-vue-service 只暴露了同步的 useService(),没有对应的 useServiceAsync()。

用户如果有异步初始化的服务(如需要等待远程配置加载),目前只能用 @PostConstruct + 响应式状态的组合绕过,使用体验差,且容易出现在服务未初始化完成时就被访问的问题。

改进建议

暴露异步版本的获取 API:

ts
const service = await useServiceAsync(MyService);

并提供对应的 Composition API helper,例如结合 Suspense 或 onMounted 使用的模式。

作者结论

建议不采纳,不提供 useServiceAsync()。

useService 的使用场景是 Vue 组件的 setup 中,推荐直接获取服务实例本身,而不是等待异步初始化完成后再返回。异步初始化的处理方式:

  • 通过 @PostConstruct(true) 标记异步初始化方法,由 DI 容器负责等待异步完成
  • 业务服务内部维护 loading 响应式属性,组件侧通过观察 loading 的变化来判断服务是否初始化完成,并相应地控制 UI 状态(如显示加载中、禁用操作等)

这种方式与 Vue 的响应式机制天然契合,不需要额外的异步 API。

结论:保持现状,不提供 useServiceAsync(),推荐 @PostConstruct(true) + 响应式 loading 属性的组合模式。


3.5 服务间通信缺少最佳实践文档 ​

问题描述

Angular 有 EventEmitter 和 Subject(RxJS)用于服务间通信;Pinia 的 store 可以直接跨 store 调用。本库目前完全没有服务间通信的推荐模式。用户可以通过 @Inject 注入另一个服务(DI 注入依赖)来实现,但缺少文档指导。

改进建议

这不算代码缺陷,但应在文档中补充"服务间通信"的最佳实践章节,说明:

  • 单向依赖:A 注入 B,A 调用 B 的方法
  • 事件解耦:通过 Vue 的 ref / EventBus 模式实现松耦合通信
  • 避免循环依赖的方法

作者结论

服务间通信的核心机制就是通过 @Inject 注入依赖,根据服务所在容器的层级关系,有以下几种情形:

同一容器中的 A、B 服务:可以互相注入,直接调用对方的方法,无任何限制。

A 在父容器、B 在子容器:B 可以直接注入 A(向上查找);A 无法直接注入 B,但可以在需要时通过 FIND_CHILD_SERVICE 实时查找 B 并调用其方法(瞬态查找,不长期持有)。

A、B 处于两个不同的子容器:两者无法直接互相通信,有以下两种解决方式:

  • 共享状态:将共享状态提升到公共父容器的某个服务中,A、B 各自注入该服务来读写共享状态
  • 跨容器方法调用:通过公共父容器中的中转服务实现,例如 A 调用 Parent 服务的方法,Parent 服务通过 FIND_CHILD_SERVICE 找到 B,再调用 B 的方法

尽量避免使用 EventEmitter、mitt 等事件总线方案,优先通过容器继承关系和 DI 注入来解决通信问题,保持依赖关系显式可追踪。

结论:补充最佳实践文档,说明上述三种层级关系下的通信模式。


3.6 SSR/Nuxt 场景的 ROOT_CONTAINER 请求污染问题 ​

问题描述

ROOT_CONTAINER 是进程级单例。在 SSR 场景下,多个并发请求会共享同一个 Root 容器,存在请求间状态污染风险(一个请求修改了 Root 容器中某个服务的状态,另一个请求可能读到脏数据)。

对比:NestJS 用 REQUEST scope 解决请求级别隔离;Nuxt 官方的 useState 用 useNuxtApp() 的 payload 机制隔离服务端状态。

改进建议

针对 SSR 场景,建议:

  1. 文档中明确说明 declareRootProviders 只适合注册无状态或请求无关的服务(如配置、工具类)
  2. SSR 场景中有状态的服务应使用 declareAppProviders(每个请求对应一个 App 实例),而不是 declareRootProviders
  3. 考虑提供 SSR 专用的最佳实践章节

作者结论

问题部分成立,SSR 场景确实是本库目前的短板,但需要区分两个独立的问题。

关于请求间状态污染: 在 SSR 环境中,有状态且与用户相关的服务应使用 declareAppProviders 声明(每个请求对应一个 App 实例,天然隔离)。declareRootProviders 并非要求完全无状态,而是要求没有用户相关的状态——全局统计数据(如总访问人数、服务运行时间等)是允许放在 Root 容器中的。遵循这个原则即可避免请求间污染问题,无需新增 API。

关于客户端数据恢复(Hydration): 经过对 Pinia、Angular TransferState、Nuxt useAsyncData 等方案的完整调研,最终决定本库不提供任何 SSR 专用 API,保持轻量级定位。

核心结论:Nuxt 的 useAsyncData 内部已封装 onServerPrefetch + payload 机制,天然具备服务端序列化和客户端水合能力。只要需要 SSR 水合的异步数据通过 useAsyncData 获取并手动赋值给 service 属性,服务类本身不需要实现任何 SSR 相关方法。

此方案的边界:仅适用于 Nuxt 场景;非 Nuxt 的 Vite SSR、以及 Root 级全局服务在组件外异步初始化的场景,需用户自行处理(如自写 Nuxt 插件)。完整最佳实践见 17.SSR最佳实践.md。

结论:请求隔离问题已有明确使用规范。客户端数据恢复问题通过依赖 Nuxt useAsyncData 解决,本库不新增 API,问题关闭。


四、对比同类开源产品的劣势总结 ​

根据各问题的最终结论,更新横向对比表格:

维度use-vue-serviceAngular DIPiniainversify + Vue
类型安全✅ 强✅ 强✅ 强✅ 强
统一注入入口✅ 三套 API 有意拆分,场景分工明确✅ inject() 统一✅ useStore()⚠️ 手动
Provider 声明方式✅ 类数组覆盖 99% 场景,函数形式作逃生舱口✅ 对象配置✅ defineStore()✅ 绑定API
SSR 请求隔离✅ 遵循规范(有状态服务用 declareAppProviders)可避免污染✅ Platform隔离✅ pinia.state 隔离⚠️ 手动
SSR 客户端数据恢复⚠️ 依赖 Nuxt useAsyncData,非 Nuxt 场景需用户自行处理✅✅❌
测试隔离✅ 按测试文件拆分,模块状态天然隔离✅ TestBed✅ setActivePinia⚠️ 手动
瞬态作用域✅ 可通过 FunctionProvider 或多 token 实现✅ 多种scopeN/A✅
响应式 opt-out✅ 支持属性级别(@Raw field/accessor)和类级别(@Raw class)✅ 不强制✅ 不强制✅ 不强制
DevTools 支持❌ 无✅ Angular DevTools✅ Pinia DevTools❌
生态成熟度🆕✅ 极成熟✅ Vue官方⚠️ 小众

五、优先级汇总 ​

待实现 ​

(当前无待实现项,所有已确认方案均已落地)

已修复 / 已关闭 ​

#事项对应章节备注
F1useService / declareProviders 错误信息质量1.4已修复
F2@Computed 中 getEffectScope(this) 改为 getEffectScope(raw)2.1已修复
F3@Raw field 装饰器删除冗余的 return ensureRaw2.3已修复
F4declareProviders / declareAppProviders 中删除冗余的 container = null as any2.6已修复
F5find-service.ts 改用 container.getChildren()2.7已修复,@kaokei/di 已暴露 getChildren()
F6@Raw 扩展支持 class 级别 opt-out3.1已实现,@kaokei/di 已暴露 defineMetadata,activationHandle 已接入 getOwnMetadata
F7SSR 客户端数据恢复(Hydration)3.6已关闭,决定依赖 Nuxt useAsyncData,本库不新增 API,详见 17.SSR最佳实践.md

不采纳(有明确理由维持现状) ​

#建议对应章节理由摘要
N1三套 API 统一为单一入口1.1有意拆分,场景分工明确
N2Provider 对象配置形式1.2与轻量级定位不符
N3declareAppProviders 参数顺序调整1.3现有顺序与其他 API 一致
N4findChildService 提升为直接 API1.5Token 注入方式已绑定容器上下文,设计合理
N5@Computed setter 查找提前到装饰器执行期2.2实际只执行一次,且 addInitializer 破坏 decorate 兼容性
N6@Raw 增加 isReactive 防御检测2.1手动 new 不在本库使用范围内,不过度防御
N7removeScope 删除防御性检查2.5调用方传入 any 类型,防御检查必要
N8@RunInScope 提供函数式替代 API2.4需精细管理 scope 的场景极少,类型断言够用
N9ROOT_CONTAINER 暴露重置 API3.2测试文件拆分可解决,不需新增 API
N10暴露瞬态作用域 API3.3可通过 FunctionProvider 或多 token 实现
N11useServiceAsync()3.4推荐 @PostConstruct(true) + 响应式 loading 属性