探索与实践:函数式风格的 React DI 系统设计
§引言与背景
§为什么做这个实验
在 React 项目中,依赖注入 (Dependency Injection / DI) 常常被忽略。组件通过props传递依赖,或通过Context共享状态,但缺少统一的依赖管理机制。随着项目复杂度上升,模块间耦合增加,测试变难,可维护性下降。
主流DI库(如InversifyJS、TSyringe)多采用Class 模式和装饰器,与 React 的函数式风格不一致。它们功能强大,但在 React 生态中显得“重”,且需要额外配置。
正是在这样的背景下,我在开发我自己的开源项目Circuit Simulator时遇到了具体的需求。这个项目需要一套插件化的架构,让各个功能模块可以独立开发和注册,同时还要支持多个画布实例的隔离。现有的 DI 方案要么太重,要么功能不足,无法满足需求。具体来说,我需要:
- 插件化架构,各模块可独立注册与卸载
- 作用域隔离,不同画布实例互不干扰
- 类型安全,编译期检查依赖关系
- 与 React 深度集成,使用 Hooks 访问服务
因此,我决定尝试一个更贴合 React 的函数式插件化 DI 系统,作为一次技术探索。
§为什么没有使用现成的方案
在开始设计之前,我调研了现有的 DI 库,发现主流 DI 库都采用 Class 模式和装饰器模式,但这种方式在 React 生态中存在一些问题,比如说装饰器实现复杂且版本差异大,与函数式风格不一致等。因此,我决定尝试设计一个全新的方案,探索函数式风格插件化在 DI 中的应用。这不是为了重复造轮子,而是想验证一种不同的可能性。
既然要设计新方案,首先要明确设计方向。主流 DI 库普遍采用 Class 模式和装饰器,但这种方式在 React 生态中存在一些问题。接下来,我想详细分析一下 Class 模式与装饰器的“重”,以及为什么选择函数式作为替代方案。
§Class 模式与装饰器的“重”
Class 模式与装饰器在 DI 中常见,但存在一些“重”的问题:
- 装饰器实现复杂且版本差异大:装饰器提案历经多个阶段,不同版本实现差异明显。虽然现在已稳定,但若 DI 基座与插件使用不同装饰器版本,可能产生兼容性问题。不同库的装饰器实现也可能不兼容。
- 依赖自定义
Transformer增加调试难度:部分 DI 系统依赖自定义的代码转换器将装饰器转为目标代码。这会让调试变难,源码与运行代码不一致,堆栈信息不直观,错误定位困难。 - 元数据与反射的开销:装饰器需要元数据支持,运行时反射机制带来额外开销。编译配置更复杂,需要启用实验性特性。
- 与函数式风格不一致:React 以函数式组件为主,Class 模式显得格格不入。需要额外的编译配置和学习成本。
因此,我想设计一套纯函数式的 DI 系统,不依赖 Class 和装饰器,在保持良好开发体验的同时,降低技术实现复杂度。
§项目定位与设计目标
首先要说明的式,本文介绍的是一个实验性的 DI 系统实现,它是我在自己的开源项目 Circuit Simulator 中的一次技术探索。
- ✅ 是一个学习与实验项目,用于探索不同的 DI 设计思路
- ✅ 在 Circuit Simulator 中实际使用,验证了设计可行性
- ❌ 不是一个生产就绪的成熟框架
- ❌ 不建议直接用于大型生产环境
本文旨在分享设计思路与实践经验,探讨函数式插件化在 DI 中的应用,提供一种不同的技术视角,而非推广一个成熟框架。
如果你正在寻找生产级的 DI 解决方案,建议考虑InversifyJS、TSyringe等成熟库。如果你对不同的设计思路感兴趣,欢迎继续阅读。
§纯函数式风格实现,零装饰器依赖
采用纯函数式风格插件,不依赖 Class 和装饰器,并且函数式的风格更符合 React 的函数式风格,也更灵活。每个插件是一个函数,接收注册上下文,可以注册服务、钩子,并返回卸载函数。这样避免了装饰器版本差异和Transformer带来的调试问题,实现更简单、调试更直观。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- ;
- /** 服务键 */
- ;
- // 纯函数式插件,无需装饰器
-
-
-
-
-
-
-
- ;
§精确且安全的类型推导
插件可以注册任意对象(字面量、实例、函数等),不强制使用 Class。并且使用Symbol实现服务键的实现和类型包装,服务键不仅用于运行时查找,还能在编译期推导服务类型,避免类型错误。
definePlugin的设计参考了webpack、vite等构建工具的配置函数模式。这种设计的优势在于,通过函数参数,TypeScript 可以精确推导出上下文类型,无需维护一个包含所有可能方法的巨大接口。同时,这种设计实现了按需暴露 API,只暴露插件实际需要的 API,而不是一个臃肿的上下文对象。更重要的是,类型定义更简洁,不需要定义包含所有可能方法的接口,类型系统自动处理,让代码更加简洁易读。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
-
-
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- ;
- // 服务类型定义
-
-
-
- // 创建类型安全的服务键
- ;
- // 插件函数接收上下文,TypeScript 自动推导类型
-
-
-
-
- [TS2345] Argument of type '{ info(msg: string): void; }' is not assignable to parameter of type 'ILoggerService'.
- Property 'error' is missing in type '{ info(msg: string): void; }' but required in type 'ILoggerService'.
-
-
-
-
-
-
-
- ;
§降低技术复杂度,保持开发体验
这是与 Class 模式相比的主要优势。系统无需配置装饰器或transformer,也无需启用实验性特性,开箱即用。调试时源码与运行代码一致,堆栈信息清晰,错误定位更容易。同时,系统保持了类型安全和良好的开发体验,TypeScript 可以提供准确的类型检查和自动补全,让开发更加高效。
§作用域隔离机制
支持多级作用域,每个作用域有独立的服务容器。子作用域可访问父作用域的服务,但钩子只在当前作用域生效。这样可以在不同画布实例间实现隔离,同时共享全局服务。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
-
-
-
-
- ;
- // 创建作用域
- ;
- // 在作用域中注册插件
- ;
§防止循环依赖
在插件注册阶段禁止访问服务,强制延迟访问。通过getServices的延迟访问机制,可以在生命周期钩子中安全地使用服务,避免循环依赖问题。
§React 深度集成
提供 React Hooks API,让组件可以方便地访问服务。同时支持生命周期钩子,与 React 组件的生命周期对齐。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
-
-
-
-
-
- ;
- /** 流服务键 */
- ;
- /** 事件监听钩子键 */
- ;
-
-
-
-
§设计的边界
在开始之前,我也明确了一些边界,这些边界帮助我聚焦核心问题,避免过度设计:
- 不追求功能完整性:专注于核心功能,不实现所有可能的特性
- 不追求性能极致:优先保证正确性和可维护性
- 不追求通用性:针对 React 生态优化,不考虑其他框架
- 不追求完美:允许不完美,重点是探索和学习
接下来,就让我们开始吧——
§核心概念
§插件系统
插件是函数,接收注册上下文,可以注册服务、钩子,并返回卸载函数。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- ;
-
-
- ;
- ;
-
-
-
-
-
-
-
-
-
-
-
- ;
这个插件系统的特点是:
- 函数式风格:插件是纯函数,不依赖 Class 或装饰器
- 延迟执行:插件在
useInjectInstall调用时才执行 - 生命周期:插件可以返回卸载函数,在组件卸载时自动调用
- 作用域绑定:每个插件绑定到特定作用域
- 跨作用域访问:通过
parent()和root()方法可以访问父作用域或根作用域
§服务与钩子
系统中有两种注册类型:服务 Service和钩子 Hook。
服务是单例,每个服务键对应一个实例。注册时会覆盖之前的实例,确保同一键只有一个服务。适合提供核心功能,如配置服务、存储服务等。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- ;
-
-
-
- // 创建服务键
- ;
- // 注册服务
-
-
-
-
-
-
- ;
钩子是集合,每个钩子键可对应多个实例。多个插件可注册到同一个钩子键,系统会收集所有实例。适合扩展点模式,如事件监听钩子、生命周期钩子、渲染器等。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- ;
- ;
- // 多个插件可以注册到同一个钩子键
-
-
-
-
- ;
-
-
-
-
- ;
这种设计让服务适合提供核心功能,钩子适合扩展点模式。
§作用域
系统支持多级作用域,形成作用域树。每个作用域有独立的服务容器和钩子容器。
在复杂应用中,不同模块可能需要不同的服务实例。例如,多个画布实例需要独立的状态管理,不同上下文需要不同的服务,测试时需要独立的服务容器。如果没有作用域,所有服务都在全局共享,无法隔离的话很容易出现难以排查的问题。
作用域创建子作用域:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- ;
- // 创建根作用域(系统内置)
- ;
- // 创建子作用域
- ;
- ;
作用域树结构如下:
- 1
- 2
- 3
- 4
- RootScope
- └─── PainterScope
- ├── EditorScope
- └── ViewerScope
§服务查找策略
服务查找策略采用了向上查找的策略。从当前作用域开始,如果找不到,会向上查找父作用域,直到根作用域。这样子作用域可以访问父作用域的服务,实现服务共享。但父作用域无法访问子作用域的服务,这样保证隔离,也符合编程的直觉。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
-
-
-
-
- ;
- ;
- /** 根作用域服务键 */
- ;
- ;
- // 在根作用域注册服务
-
-
-
-
-
-
-
-
-
- ;
- // 在子作用域使用根作用域注册的服务
-
-
-
-
-
-
-
-
-
- ;
服务采用向上查找策略,主要有几个原因。首先,这样可以减少重复注册,全局服务只需在根作用域注册一次,所有子作用域都可以访问,避免了在每个子作用域都重复注册相同的服务。其次,这种策略能够节约资源,避免为每个作用域创建重复的服务实例,特别是对于那些无状态或可以共享的服务来说,这种方式更加高效。同时,这种设计还提供了灵活性,可以在不同层级提供不同的服务实现,子作用域优先使用自己的服务,如果找不到再向上查找,这样既支持了服务共享,又允许在特定作用域中覆盖服务实现。最后,这种单向继承的设计保证了隔离性,父作用域无法访问子作用域的服务,确保了作用域之间的隔离,避免了意外的服务访问。额外的是,这种设计比较符合一般意义上的编程直觉。
§钩子隔离策略
只在当前作用域查找。默认只在当前作用域查找。钩子不会向上查找,每个作用域只返回自己注册的钩子。这样不同作用域可以有独立的钩子集合,实现隔离。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
-
-
-
-
-
- ;
- ;
- // 在根作用域注册钩子
-
-
-
- ;
- // 在子作用域注册钩子
-
-
-
- ;
- // 在子作用域获取钩子,只会返回 childHook,不会包含 rootHook
-
-
如果需要注册父作用域的钩子,可以通过parent()或root()方法显式跨作用域注册。在definePlugin 的回调上下文中,提供了这两个方法。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- ;
- ;
-
-
-
-
-
- ;
要访问父作用域的钩子,就比较麻烦了,需要拿到上级作用域的键本身,然后使用withScope系列的方法来实现,但是这种情况应该是极少的,钩子的设计本来就是可以跨作用域的注册,但是尽量不要跨作用域消费的,如果你需要跨作用域的消费钩子,那应该考虑这个钩子的设计是否合理。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
-
-
-
-
-
- ;
- ;
- // 在子作用域获取全局的钩子
-
-
钩子采用隔离策略,主要是为了精确控制每个作用域的扩展点。每个作用域可以精确控制自己的钩子集合,不会受到其他作用域的影响。这种隔离设计还能避免污染,子作用域的钩子不会影响父作用域,确保了不同作用域之间的独立性。更重要的是,这种设计让不同作用域可以有完全不同的扩展集合,每个作用域都可以根据自己的需求注册不同的钩子,互不干扰。同时,系统也提供了灵活性,当确实需要注册父作用域的钩子时,可以通过parent()或root()方法显式跨作用域访问,这样既保证了默认的隔离性,又提供了必要的灵活性。
§类型安全设计
在传统的 DI 系统中,服务键通常是字符串或Symbol,这些标识符在运行时才能知道对应的服务类型。这会导致从容器获取的服务类型变成any,失去了类型信息。更严重的是,这种类型丢失会让编译期无法检查类型错误,只能在运行时才能发现问题。同时,IDE 也无法提供准确的自动补全,开发体验大打折扣。
因此,我们需要一种机制能够在编译期就能推导出服务类型,让 TypeScript 的类型系统能够帮助我们检查错误,提供更好的开发体验。
TypeScript 的品牌类型 Branded Types可以在运行时保持简单(使用Symbol),同时在编译期提供类型信息。
品牌类型的核心思想是:给一个基础类型添加一个类型标记,这个标记只在编译期存在,运行时会被擦除。这样既保持了运行时的简单性,又提供了编译期的类型安全。
通过品牌类型,服务键不仅是一个运行时标识,还能携带类型信息。TypeScript 可以从服务键推导出对应的服务类型,实现类型安全的依赖注入。
使用品牌类型后,系统可以获得编译期类型检查的能力。当使用服务时,TypeScript 可以检查类型是否正确,如果调用了不存在的方法或属性,会在编译期就报错,而不是等到运行时才发现问题。同时,系统还支持自动类型推导,不需要手动指定类型,TypeScript 会根据服务键自动推导出对应的服务类型。这种自动推导不仅减少了代码量,还让代码更加简洁易读。
更重要的是,这种类型推导带来了更好的 IDE 支持。IDE 可以提供准确的自动补全和类型提示,让开发者在编写代码时就能知道服务有哪些方法和属性,大大提升了开发效率。最后,这种类型安全还让重构变得更加友好。当修改服务接口时,TypeScript 可以检查所有使用处,确保修改不会破坏现有的代码,让重构变得更加安全和可靠。
使用示例:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
-
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
-
- 36
§基础使用示例
§服务与钩子
服务和钩子其实注册方式都差不多,这里仅介绍服务,钩子的使用方式类似。
§创建服务键
首先,需要创建服务键。服务键是类型安全的标识符,用于标识和查找服务:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- ;
- // 定义服务接口
-
-
-
- // 创建服务键
- ;
§注册服务
通过definePlugin注册服务。插件是一个函数,接收注册上下文:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- ;
- ;
-
-
-
-
-
-
-
-
-
-
-
-
- ;
§在组件中使用
在组件树的根部,需要创建InjectContext.Provider,并传入一个Map作为 DI 容器:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- ;
- ;
-
-
-
-
-
-
这个Map就是整个应用共享的 DI 容器,所有子组件都可以通过Context访问它。
然后在需要使用 DI 的组件中,首先调用useInjectInstall初始化 DI 系统,它会执行所有已注册的插件,初始化服务和钩子。返回的pluginInitialized表示插件是否已初始化完成。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- ;
- ;
-
-
-
-
-
-
-
-
初始化完成后,可以使用全局作用域的快捷方法获取服务:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- ;
- ;
- ;
-
-
-
-
-
-
全局作用域提供了三个快捷方法:
useServiceWithGlobal:获取服务useHookWithGlobal:获取钩子useLifeCycleWithGlobal:管理生命周期
这些方法会自动获取全局作用域的服务、钩子和生命周期,不需要手动传入作用域。
§作用域的使用
§定义作用域和快捷方法
在实际项目中,通常会为特定功能模块创建独立的作用域,并导出快捷方法。这样的话,在子作用域相关的代码中,就可以直接使用useService、useHook和useLifeCycle,而不需要每次都传入作用域参数。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- // painter/context/index.ts
-
-
-
-
-
- ;
- // 创建画布作用域
- ;
- // 为画布作用域创建快捷 Hooks
- ;
- // 导出画布作用域的插件定义函数
- ;
- // 导出画布作用域的快捷方法
- ;
- ;
- ;
§在作用域中注册插件
使用导出的definePlugin在子作用域中注册插件:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- ;
-
-
-
- ;
-
-
-
-
-
-
- ;
§连接组件与 DI
DI 系统提供了钩子/服务机制,但如何将钩子/服务与 React 组件连接起来呢?这就需要驱动层 Driver Layer。
驱动层的核心思路是:在组件中创建一个驱动函数,从 DI 系统获取所有注册的钩子/服务,然后将这些钩子/服务绑定到实际的 DOM 事件或 React 生命周期上。
§驱动层工作原理
DI 系统提供了钩子机制,但如何将钩子与 React 组件连接起来呢?这就需要驱动层 Driver Layer。驱动层的核心思路是:在组件中创建一个驱动函数,从 DI 系统获取所有注册的钩子,然后将这些钩子绑定到实际的 DOM 事件或 React 生命周期上。
以事件监听器钩子为例,展示驱动层的工作原理。首先,各个插件通过registerHook注册事件监听钩子,这些钩子包含了事件处理函数,比如onMouseDown、onMouseUp等。然后,在组件中创建一个驱动层函数(如useEventListenerDriver),这个函数使用useHook从 DI 系统获取所有注册的事件监听钩子。接下来,驱动层将这些钩子中的事件处理函数绑定到实际的 DOM 事件监听器上,比如将onMouseDown绑定到mousedown事件。当事件发生时,驱动层会按顺序调用所有相关的钩子函数,实现事件分发。最后,当组件卸载时,驱动层会清理所有事件监听器,避免内存泄漏。
这种设计的好处是插件只需要注册钩子,不需要关心事件如何绑定。驱动层统一管理事件监听,避免重复绑定,同时钩子可以按order排序,控制执行顺序。更重要的是,这种设计实现了组件与 DI 系统的解耦,组件不需要知道有哪些插件注册了钩子,只需要调用驱动层即可,让代码更加模块化和易于维护。
§驱动层实现示例
首先,定义事件监听钩子接口:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- ;
- /** 事件监听 */
-
-
-
-
-
- /** 事件监听钩子 */
- ;
在组件中创建驱动层,将钩子绑定到 DOM 事件:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- ;
- ;
- ;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
最后,将 DI 系统中的钩子与 DOM 事件连接:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- ;
- ;
-
-
-
-
-
§实际应用场景
在实际项目中,通常会这样组织代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- // services/logger.ts
- export const ILoggerService = createServiceKey<ILoggerService>('ILoggerService');
- // plugins/logger/register.ts
- import { defineGlobalPlugin } from '@@local/inject';
- import { ILoggerService } from '../services/logger';
- defineGlobalPlugin(({ registerService }) => {
- const logger: ILoggerService = {
- log(message: string) {
- console.log(`[${new Date().toISOString()}] ${message}`);
- },
- error(message: string) {
- console.error(`[${new Date().toISOString()}] ${message}`);
- },
- };
- registerService(ILoggerService, logger);
- });
- // context/index.ts
- export {
- useServiceWithGlobal as useService,
- useHookWithGlobal as useHook,
- useLifeCycleWithGlobal as useLifeCycle,
- useInjectInstall,
- InjectContext,
- defineGlobalPlugin as definePlugin,
- } from '@@local/inject';
- // components/App.tsx
- import { InjectContext, useInjectInstall, useService } from '../context';
- import { ILoggerService } from '../services/logger';
- function Layout() {
- const logger = useService(ILoggerService);
- useEffect(() => {
- logger.log('应用已加载');
- }, [logger]);
- return <div>应用内容</div>;
- }
- function Initialization() {
- const [pluginInitialized] = useInjectInstall();
- if (!pluginInitialized) {
- return null;
- }
- return <Layout />;
- }
- export function App() {
- return (
- <InjectContext.Provider value={new Map()}>
- <Initialization />
- </InjectContext.Provider>
); }
这种组织方式让代码结构清晰,易于维护。每个功能模块都有自己的作用域和快捷方法,使用起来非常方便。
§核心实现解析
§服务键的类型与实现
在第三部分中,我们看到了如何使用 createServiceKey 创建服务键。现在让我们深入看看它的实现:
- 1
- 2
- 3
- 4
- 5
- ;
-
-
实现很简单——创建一个 Symbol,然后通过类型断言转换为品牌类型。关键在于类型定义:
- 1
- 2
-
- ;
这个类型定义表示ServiceTypeWithKey<T>是symbol类型与一个只读属性的交集类型。__type属性只在编译期存在,用于类型推导,运行时会被 TypeScript 擦除。
当我们使用createServiceKey<ILoggerService>('ILoggerService')时,返回的Symbol在运行时只是一个普通的标识符,但在编译期,TypeScript 会将其视为带有ILoggerService类型标记的品牌类型。这样,当我们使用这个服务键时,TypeScript 可以从类型参数T推导出对应的服务类型,实现类型安全的依赖注入。
§插件注册与安装机制
在第三部分中,我们看到了如何使用definePlugin注册插件。现在让我们看看插件是如何被管理和安装的。
§插件元信息管理
系统使用全局 Map 来管理插件信息:
- 1
- export const PluginMetaInfos = new Map<PluginInstaller, IPluginMeta>();
这个Map的键是插件安装器函数本身,值包含作用域和安装器。注册插件时,系统会检查插件是否已经注册过:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- function definePlugin(scope: symbol, installer: PluginInstaller) {
- if (!PluginMetaInfos.has(installer)) {
- PluginMetaInfos.set(installer, {
- scope,
- installer,
- });
- }
- }
这种设计允许同一个插件函数只注册一次,即使多次调用definePlugin也不会重复注册。同时,使用函数本身作为键,可以确保函数引用相同的插件不会重复注册。
§作用域树的创建
在useInjectInstall被调用时,系统首先创建作用域树:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- ;
-
-
-
- ;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
这个递归函数从根作用域开始,递归创建所有子作用域,并建立父子关系。每个作用域容器都包含指向父作用域的引用和子作用域数组,形成完整的树形结构。
§插件安装过程
作用域树创建完成后,系统开始安装插件:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- let isInstalling = true;
- const uninstaller = installer({
- registerService: (key, service) => { /* 注册服务 */ },
- registerHook: (key, hook) => { /* 注册钩子 */ },
- getService: (key) => {
- if (isInstalling) {
- throw new Error('在插件注册阶段不允许获取服务');
- }
- return getServiceWithScope(key, scope, manager);
- },
- // ...
- });
- isInstalling = false;
安装过程的关键是isInstalling标志位。在插件安装器执行期间,这个标志为true,此时如果调用getService或getHook,会抛出错误。安装完成后,标志被设置为false,此时可以正常访问服务。这种设计强制插件在注册阶段只能注册服务,不能访问服务,从而避免了循环依赖问题。另外,如果插件返回了卸载函数,系统会将其注册为生命周期钩子,在组件卸载时自动调用。
§作用域管理与服务查找
在第三部分中,我们看到了服务如何在不同作用域间共享。现在让我们看看服务查找的实现:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- ;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
这个算法从当前作用域开始查找,如果找不到,就向上查找父作用域,直到找到服务或到达根作用域。算法的时间复杂度是O(h),其中h是作用域树的深度。在实际应用中,作用域树的深度通常不会很深,所以性能影响可以忽略。
钩子的查找更简单,只在当前作用域查找:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- ;
-
-
-
-
-
-
-
-
-
-
-
钩子不会向上查找,每个作用域只返回自己注册的钩子,实现了作用域隔离。
§React 集成与 Hooks 实现
在第三部分中,我们看到了如何在组件中使用useServiceWithGlobal等方法。现在让我们看看这些 Hooks 的实现。
§Context 管理
系统使用React Context来管理作用域管理器:
- 1
- 2
- 3
- 4
- ;
- ;
- ;
这个 Context 存储了整个作用域管理器,所有组件都可以通过useContext访问。在组件树的根部,我们创建InjectContext.Provider并传入一个Map:
- 1
- 2
- 3
- <InjectContext.Provider value={new Map()}>
- <Initialization />
- </InjectContext.Provider>
这个Map就是整个应用共享的 DI 容器,所有子组件都可以通过Context访问它。
§Hooks API 实现
全局作用域的快捷方法实现如下:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- ;
- ;
- ;
- ;
- ;
这些方法内部使用createReactHookWithScope创建:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
-
-
-
-
-
- ;
- ;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
useService直接调用查找函数,而useHook使用useMemo缓存结果,避免不必要的重新计算。这样,开发者可以为特定作用域创建一套Hooks,使用时就不需要每次都传入作用域参数,简化了 API。
§初始化 Hook
useInjectInstall的实现如下:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- export function useInjectInstall() {
- const manager = useContext(InjectContext);
- const [isInitialized, setIsInitialized] = useState(false);
- useEffect(() => {
- // 创建作用域
- createScope(ScopeMetaInfos, manager);
- // 安装插件
- installPlugin(PluginMetaInfos, manager);
- // 标记初始化完成
- setIsInitialized(true);
-
- return () => {
- setIsInitialized(false);
- };
- }, []);
- return [isInitialized] as const;
- }
这个Hook在组件挂载时执行一次,创建作用域树并安装所有插件。返回的isInitialized表示插件是否已初始化完成,组件可以根据这个状态决定是否渲染内容。
§防止循环依赖的机制
在第三部分中,我们看到了如何使用getServices实现延迟访问。现在让我们深入看看这个机制是如何工作的。
§注册阶段限制
系统通过isInstalling标志位,在插件注册阶段禁止访问服务。这不仅防止了循环依赖,还让插件的职责更加清晰:注册阶段只负责注册,不负责使用。
如果注册阶段发生错误,还会解析错误信息,以及提供简单的快捷建议。系统还会从调用栈中提取用户代码的位置信息,包括文件路径、行号和列号,让开发者可以直接看到错误发生的位置。
§getServices 延迟访问实现
getServices使用 JavaScript 的Object.defineProperty实现延迟访问:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- getServices: (services) => {
- const result: Record<string, any> = {};
- const cache: Record<string, any> = {};
- for (const [key, serviceKey] of Object.entries(services)) {
- Object.defineProperty(result, key, {
- get() {
- // 检查是否在安装阶段访问服务
- if (isInstalling) {
- throw createInstallPhaseError('服务', serviceKey, installer, true);
- }
-
- if (cache.hasOwnProperty(key)) {
- return cache[key];
- }
-
- const service = getServiceWithScope(serviceKey, scope, manager);
- cache[key] = service;
- return service;
- },
- enumerable: true,
- configurable: false,
- });
- }
- return result as any;
- }
这个实现的关键在于使用get访问器属性。当调用getServices时,返回的对象中的每个属性都是一个访问器属性,只有在实际访问时才会执行查找逻辑。这样,插件可以在注册阶段调用getServices获取对象引用,然后在生命周期钩子中再访问属性,实现延迟访问。
§生命周期管理
在第三部分中,我们看到了如何使用生命周期钩子。现在让我们看看生命周期管理的实现。
生命周期管理的核心思路是收集所有生命周期钩子,按order排序并分组,然后按顺序执行。钩子按照order值排序,order越小优先级越高。相同order的钩子分为一组,组内并发执行,组间顺序执行。系统支持异步钩子,使用Promise.all等待所有异步钩子完成。卸载时,会按顺序逆序执行卸载钩子,确保清理顺序正确。这种设计让生命周期钩子的执行顺序可控。
§通用生命周期管理
根据钩子的特性,每个作用域获取生命周期钩子时,只能拿到当前作用域注册的钩子。这意味着每个作用域需要自己管理自己的生命周期。但系统已经封装了通用的生命周期管理方法,通过createReactHookWithScope创建的快捷 Hooks 中包含了useLifeCycle方法。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- useLifeCycle() {
- const hooks = hook.useHook(ILifeCycleHook);
- const [isInitialized, setIsInitialized] = useState(false);
- useEffect(() => {
- // 按 order 排序并分组
- const sortedHooks = hooks.slice().sort((a, b) => {
- return getOrder(a.order) - getOrder(b.order);
- });
-
- const groups = new Map<number, ILifeCycleHook[]>();
- for (const hook of sortedHooks) {
- const order = getOrder(hook.order);
- groups.set(order, [...(groups.get(order) ?? []), hook]);
- }
-
- // 按 order 顺序执行挂载钩子,组内并发
- executeHooks(groups, true, true)
- .then(() => setIsInitialized(true));
-
- // 卸载时按 order 逆序执行卸载钩子
- return () => {
- executeHooks(groups, false, false);
- };
- }, [hooks]);
- return [isInitialized] as const;
- }
这样,如果当前作用域内的生命周期是通用的形式,那么每个作用域只需要调用对应的useLifeCycle方法即可,不需要重复编写生命周期管理代码。而假如当前作用域的生命周期需要特殊的执行方式,那么也可以自己去写自定义的处理过程,保留了自由度。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- // 全局作用域
- function GlobalComponent() {
- const [isInitialized] = useLifeCycleWithGlobal();
- // ...
- }
- // 画布作用域
- const painterHooks = createReactHookWithScope(PainterScope);
- function PainterComponent() {
- const [isInitialized] = painterHooks.useLifeCycle();
- // ...
- }
§设计权衡与总结
在实现过程中,我们做了几个关键决策,每个决策都有其权衡。
§设计决策回顾
§函数式插件模式 vs Class 模式
选择函数式插件模式而非 Class 模式,主要考虑与 React 生态的契合度。函数式插件更符合 React 的函数式风格,避免了装饰器的复杂性和版本差异问题。但这也意味着失去了自动依赖注入的能力,需要手动管理依赖关系。在实际使用中,虽然需要手动调用getService,但通过getServices的延迟访问机制,可以在生命周期钩子中安全地使用服务,避免了循环依赖问题。
§服务共享 vs 钩子隔离
服务采用向上查找策略,子作用域可以访问父作用域的服务,实现了服务共享。这种设计减少了重复注册,节约了资源,但也意味着父作用域无法访问子作用域的服务,保证了隔离性。钩子采用隔离策略,每个作用域只返回自己注册的钩子,实现了精确控制,但也意味着如果需要访问父作用域的钩子,需要通过parent()或root()显式跨作用域访问。这种设计在我个人的项目实践中很实用,多个画布实例可以共享全局服务,同时每个画布实例有独立的事件处理钩子。
§注册阶段限制
为了防止循环依赖,系统在插件注册阶段禁止访问服务。这种设计强制插件在注册阶段只能注册服务,不能访问服务,从而避免了循环依赖问题。但这也意味着插件需要将服务访问延迟到生命周期钩子中,增加了代码的复杂度。通过getServices的延迟访问机制,可以在一定程度上缓解这个问题,但仍然是需要开发者注意的设计约束。
§实验收获
通过这次实验,我们验证了几个设计思路的可行性。函数式插件模式在 React 生态中确实有其优势,类型安全通过品牌类型得到了很好的保障,作用域隔离机制能够很好地支持插件化架构。同时,我们也发现了一些问题。手动依赖管理确实不如自动注入方便,性能优化还有很大空间,测试支持需要进一步完善。
更重要的是,这次实验让我们对 DI 系统有了更深入的理解。不同的设计选择都有其适用场景,没有完美的方案,只有适合的方案。函数式插件模式虽然在某些方面不如 Class 模式方便,但在 React 生态中,它提供了更好的开发体验和更低的复杂度。
§当前限制与不足
这个系统目前还存在一些限制和不足。首先,它只支持 React 环境,无法用于其他框架或纯 JavaScript 环境。其次,服务查找需要遍历作用域链,可能影响性能,特别是在作用域树很深的情况下。第三,不支持服务工厂模式,每次返回的都是同一个实例,无法实现每次获取都创建新实例的需求。第四,不支持懒加载,服务在注册时即创建,无法实现按需创建。第五,不支持依赖自动注入,需要手动获取依赖,不如 Class 模式的构造函数注入方便。
在调试方面,虽然系统提供了详细的错误信息,但使用Symbol作为键,调试时不够直观。作用域关系不够可视化,在复杂的作用域树中,很难快速理解服务的位置和查找路径。
§改进方向
如果继续完善这个系统,可以考虑以下几个方向。首先是支持服务工厂,允许插件注册工厂函数,每次获取时创建新实例。第三是支持懒加载,允许服务在首次访问时才创建。第四是改进调试体验,提供可视化工具,帮助开发者理解作用域关系和服务位置。第五是增强测试支持,提供更好的 mock 机制和测试工具。
但这些改进都需要权衡。性能优化可能会增加代码复杂度,服务工厂和懒加载可能会让系统变得更加复杂,可视化工具需要额外的开发成本。在实际项目中,需要根据具体需求来决定是否实现这些功能。
§与主流 DI 库的对比
与主流的 DI 库相比,这个系统有其独特的优势,也有明显的不足。优势在于函数式风格更符合 React 生态,无需装饰器配置,调试更直观,作用域隔离机制更适合插件化架构。不足在于功能不如主流库完善,缺少依赖自动注入,性能可能不如主流库优化得好,生态工具不如主流库丰富。
主流 DI 库选择 Class 模式有其合理性。Class 模式提供了更强大的功能,更好的类型推导,更丰富的生态工具。但在 React 生态中,函数式插件模式提供了更好的开发体验和更低的复杂度。这两种方案各有适用场景,没有孰优孰劣,只有适合与否。
§对读者的建议
如果你在学习 DI 系统设计,可以参考这个实现,了解函数式插件模式的设计思路。如果你需要生产级解决方案,建议使用成熟的 DI 库。如果你也想探索不同的可能性,欢迎一起讨论。
技术探索的价值不在于完美,而在于思考与实践。这个实验性项目虽然不完美,但它提供了一个不同的视角,也许能启发新的思路。在技术选型时,没有银弹,只有适合的方案。重要的是理解不同方案的优劣,根据实际需求做出选择。