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:可以获取由 declareProvidersdeclareAppProvidersdeclareRootProviders 绑定的服务(沿继承链:组件容器 → App 容器 → Root 容器)
  • useAppService:可以获取由 declareAppProvidersdeclareRootProviders 绑定的服务(App 容器 → Root 容器)
  • useRootService:只能获取由 declareRootProviders 绑定的服务,不向上也不向下查找

这形成了一个清晰的关系:useServiceuseAppServiceuseRootService(从获取侧看,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 declareProvidersdeclareAppProviders 的参数顺序不对称

问题描述

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),专门负责检查注入上下文并抛出友好错误信息。useServicedeclareProviders 在函数入口分别调用 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 = thisconst 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 anyas 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
    ...
  }
}

参数类型是 objectobject 值在 TypeScript 类型系统中不可能是 nullundefined(那是 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 declareProviderscontainer = 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 类型检查,如果后续代码访问已被置为 nullcontainer,运行时会抛出 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/diBinding 接口有 transient 字段和 inTransientScope() 方法,但 use-vue-service 目前没有将这个能力暴露给用户。所有通过 container.bind(Cls).toSelf() 的绑定默认是单例(在容器作用域内)。

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

对比 Angular:providedIn: 'root' vs 组件级 provider 本身控制了单例边界;NestJS 有 Scope.TRANSIENTScope.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/digetAsync() 和异步 @PostConstruct 已经支持异步初始化,但 use-vue-service 只暴露了同步的 useService(),没有对应的 useServiceAsync()

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

改进建议

暴露异步版本的获取 API:

ts
const service = await useServiceAsync(MyService);

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

作者结论

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

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

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

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

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


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

问题描述

Angular 有 EventEmitterSubject(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 官方的 useStateuseNuxtApp() 的 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 场景需用户自行处理
测试隔离✅ 按测试文件拆分,模块状态天然隔离✅ TestBedsetActivePinia⚠️ 手动
瞬态作用域✅ 可通过 FunctionProvider 或多 token 实现✅ 多种scopeN/A
响应式 opt-out✅ 支持属性级别(@Raw field/accessor)和类级别(@Raw class)✅ 不强制✅ 不强制✅ 不强制
DevTools 支持❌ 无✅ Angular DevTools✅ Pinia DevTools
生态成熟度🆕✅ 极成熟✅ Vue官方⚠️ 小众

五、优先级汇总

待实现

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

已修复 / 已关闭

#事项对应章节备注
F1useService / declareProviders 错误信息质量1.4已修复
F2@ComputedgetEffectScope(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 已暴露 defineMetadataactivationHandle 已接入 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 属性