代码批判性分析与改进建议
本文记录了对
use-vue-service当前实现的全面批判性审查,涵盖 API 设计、内部实现、架构、以及与同类开源产品的横向对比。 每条建议后预留了「作者结论」栏位,用于记录后续的讨论和决策。
一、API 设计层面
1.1 三套 API 的命名对称性问题
问题描述
| 作用域 | 声明 | 使用 |
|---|---|---|
| 组件级 | declareProviders | useService |
| 全局级 | declareRootProviders | useRootService |
| App 级 | declareAppProviders | useAppService |
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 类型设计过于简单
问题描述
// 目前只支持这两种
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 描述对象,例如:
{ 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 的参数顺序不对称
问题描述
// 组件级
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 的错误信息质量差
问题描述
// 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 注入获取,使用方式比较隐晦:
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属性由子组件服务控制)。此时有三种方案:
- 将子组件服务的注册范围提升到父组件——导致服务与组件生命周期不一致,不推荐
- 通过
ref+defineExpose调用子组件方法——跨层级时极为繁琐- 在点击事件函数内部通过
FIND_CHILD_SERVICE瞬态查找——函数执行完毕后引用自动释放,安全且便捷关键约束:只能在函数内部使用,不能通过闭包在函数外部长期持有子组件服务的引用,因为子组件随时可能被销毁。每次需要时都应重新查找。
为什么必须通过 Token +
useService/@Inject获取,而不能是全局函数:查找子组件服务需要以「当前组件的容器」为起点向下遍历,不同组件调用时起点不同,所以不能是全局方法。若设计为findChildService(container, token),则需要用户自己获取container,暴露了更多内部细节,反而更差。通过 Token 注入的方式,框架在注入时已绑定了当前容器的上下文,用户拿到的函数开箱即用。关于「不直觉」的问题:这确实是一个真实的 DX 问题,主要靠文档解决,需要在文档中专门说明此场景和用法。
结论:设计合理,维持现状。文档层面需要补充使用场景说明,强调「瞬态查找、不可长期持有」的约束。
二、内部实现层面
2.1 @Computed 装饰器:this 的指向风险
问题描述
// 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 查找可以提前到装饰器执行期
问题描述
// 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 装饰器实现细节依赖规范执行顺序
问题描述
// 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,作为类型安全的替代方案:
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 中的防御性检查多余
问题描述
// 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 的清理方式
问题描述
// 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 内部属性
问题描述
// 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 机制
问题描述
// create-container.ts:8-10
function activationHandle(_: Context, obj: any) {
return isObject(obj) ? reactive(obj) : obj;
}容器里所有注入的对象都会被 reactive() 包裹,这意味着:
- 不需要响应式的服务(纯 utility 类、HTTP client、logger)也被 reactive 化,有不必要的开销
- 第三方 SDK 实例(ECharts、Monaco Editor)如果被注入,必须手动加
@Raw()装饰器,否则出问题——这个"陷阱"对新用户不够显眼 - 对比 Angular:服务本身不是响应式的,响应式状态由服务内部显式声明(
signal())。Angular 把响应式的决定权留给服务作者,而不是框架强制全量 reactive
改进建议
提供类级别的 opt-out 机制,例如 @NotReactive() 装饰器标记整个类不需要被 reactive 化:
@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 是模块级单例,测试污染严重
问题描述
// core.ts:45
const ROOT_CONTAINER = createContainer();这个单例在模块加载时创建,在测试用例之间共享状态。从测试文件可以看出(test24 中 useRootService 期望抛出),测试依赖于根容器的特定状态——这正是全局单例在测试中的典型问题。
对比 Angular:TestBed 每次测试都会创建全新的 injector,完全隔离。对比 Pinia:setActivePinia(createPinia()) 可以在每个测试前重置状态。
改进建议
暴露一个 resetRootContainer() 函数(或导出 createRootContainer() 工厂),让测试代码可以在每个 beforeEach 中重置根容器状态:
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 配置:
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:
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 场景,建议:
- 文档中明确说明
declareRootProviders只适合注册无状态或请求无关的服务(如配置、工具类) - SSR 场景中有状态的服务应使用
declareAppProviders(每个请求对应一个 App 实例),而不是declareRootProviders - 考虑提供 SSR 专用的最佳实践章节
作者结论
问题部分成立,SSR 场景确实是本库目前的短板,但需要区分两个独立的问题。
关于请求间状态污染: 在 SSR 环境中,有状态且与用户相关的服务应使用
declareAppProviders声明(每个请求对应一个 App 实例,天然隔离)。declareRootProviders并非要求完全无状态,而是要求没有用户相关的状态——全局统计数据(如总访问人数、服务运行时间等)是允许放在 Root 容器中的。遵循这个原则即可避免请求间污染问题,无需新增 API。关于客户端数据恢复(Hydration): 经过对 Pinia、Angular
TransferState、NuxtuseAsyncData等方案的完整调研,最终决定本库不提供任何 SSR 专用 API,保持轻量级定位。核心结论:Nuxt 的
useAsyncData内部已封装onServerPrefetch+ payload 机制,天然具备服务端序列化和客户端水合能力。只要需要 SSR 水合的异步数据通过useAsyncData获取并手动赋值给 service 属性,服务类本身不需要实现任何 SSR 相关方法。此方案的边界:仅适用于 Nuxt 场景;非 Nuxt 的 Vite SSR、以及 Root 级全局服务在组件外异步初始化的场景,需用户自行处理(如自写 Nuxt 插件)。完整最佳实践见 17.SSR最佳实践.md。
结论:请求隔离问题已有明确使用规范。客户端数据恢复问题通过依赖 Nuxt
useAsyncData解决,本库不新增 API,问题关闭。
四、对比同类开源产品的劣势总结
根据各问题的最终结论,更新横向对比表格:
| 维度 | use-vue-service | Angular DI | Pinia | inversify + Vue |
|---|---|---|---|---|
| 类型安全 | ✅ 强 | ✅ 强 | ✅ 强 | ✅ 强 |
| 统一注入入口 | ✅ 三套 API 有意拆分,场景分工明确 | ✅ inject() 统一 | ✅ useStore() | ⚠️ 手动 |
| Provider 声明方式 | ✅ 类数组覆盖 99% 场景,函数形式作逃生舱口 | ✅ 对象配置 | ✅ defineStore() | ✅ 绑定API |
| SSR 请求隔离 | ✅ 遵循规范(有状态服务用 declareAppProviders)可避免污染 | ✅ Platform隔离 | ✅ pinia.state 隔离 | ⚠️ 手动 |
| SSR 客户端数据恢复 | ⚠️ 依赖 Nuxt useAsyncData,非 Nuxt 场景需用户自行处理 | ✅ | ✅ | ❌ |
| 测试隔离 | ✅ 按测试文件拆分,模块状态天然隔离 | ✅ TestBed | ✅ setActivePinia | ⚠️ 手动 |
| 瞬态作用域 | ✅ 可通过 FunctionProvider 或多 token 实现 | ✅ 多种scope | N/A | ✅ |
| 响应式 opt-out | ✅ 支持属性级别(@Raw field/accessor)和类级别(@Raw class) | ✅ 不强制 | ✅ 不强制 | ✅ 不强制 |
| DevTools 支持 | ❌ 无 | ✅ Angular DevTools | ✅ Pinia DevTools | ❌ |
| 生态成熟度 | 🆕 | ✅ 极成熟 | ✅ Vue官方 | ⚠️ 小众 |
五、优先级汇总
待实现
(当前无待实现项,所有已确认方案均已落地)
已修复 / 已关闭
| # | 事项 | 对应章节 | 备注 |
|---|---|---|---|
| F1 | useService / declareProviders 错误信息质量 | 1.4 | 已修复 |
| F2 | @Computed 中 getEffectScope(this) 改为 getEffectScope(raw) | 2.1 | 已修复 |
| F3 | @Raw field 装饰器删除冗余的 return ensureRaw | 2.3 | 已修复 |
| F4 | declareProviders / declareAppProviders 中删除冗余的 container = null as any | 2.6 | 已修复 |
| F5 | find-service.ts 改用 container.getChildren() | 2.7 | 已修复,@kaokei/di 已暴露 getChildren() |
| F6 | @Raw 扩展支持 class 级别 opt-out | 3.1 | 已实现,@kaokei/di 已暴露 defineMetadata,activationHandle 已接入 getOwnMetadata |
| F7 | SSR 客户端数据恢复(Hydration) | 3.6 | 已关闭,决定依赖 Nuxt useAsyncData,本库不新增 API,详见 17.SSR最佳实践.md |
不采纳(有明确理由维持现状)
| # | 建议 | 对应章节 | 理由摘要 |
|---|---|---|---|
| N1 | 三套 API 统一为单一入口 | 1.1 | 有意拆分,场景分工明确 |
| N2 | Provider 对象配置形式 | 1.2 | 与轻量级定位不符 |
| N3 | declareAppProviders 参数顺序调整 | 1.3 | 现有顺序与其他 API 一致 |
| N4 | findChildService 提升为直接 API | 1.5 | Token 注入方式已绑定容器上下文,设计合理 |
| N5 | @Computed setter 查找提前到装饰器执行期 | 2.2 | 实际只执行一次,且 addInitializer 破坏 decorate 兼容性 |
| N6 | @Raw 增加 isReactive 防御检测 | 2.1 | 手动 new 不在本库使用范围内,不过度防御 |
| N7 | removeScope 删除防御性检查 | 2.5 | 调用方传入 any 类型,防御检查必要 |
| N8 | @RunInScope 提供函数式替代 API | 2.4 | 需精细管理 scope 的场景极少,类型断言够用 |
| N9 | ROOT_CONTAINER 暴露重置 API | 3.2 | 测试文件拆分可解决,不需新增 API |
| N10 | 暴露瞬态作用域 API | 3.3 | 可通过 FunctionProvider 或多 token 实现 |
| N11 | useServiceAsync() | 3.4 | 推荐 @PostConstruct(true) + 响应式 loading 属性 |