函数式的依赖注入系统
去年写 Circuit Simulator 时,我需要一套插件系统。四个硬需求:
- 功能模块独立注册、独立卸载。渲染器、事件处理、工具栏各自是一个插件
- 多个画布实例之间互不干扰。每个画布有自己的状态,但共享全局服务(日志、存储)
- 编译期类型检查。注册一个服务,使用它的时候 IDE 能自动补全,而不是靠记忆或者查文档
- 和 React 深度集成。用 hooks 获取服务,不需要额外包装
我试了 InversifyJS 和 TSyringe。都是好库,但在 React 里用有三处不适配:装饰器语法和函数组件格格不入;需要配置 reflect-metadata 和实验性编译选项,多一层心智负担;默认设计是全局单例容器,多实例隔离得自己补代码。另外装饰器依赖自定义 Transformer 做编译转换,出了 bug 调试时源码和运行代码对不上,堆栈信息也不直观。
花了一个周末写了这套实现。纯函数,零装饰器,核心大约 500 行。在 Circuit Simulator 里跑了几个月,没出过什么大问题。它不是生产级框架。如果你需要成熟方案,InversifyJS 和 TSyringe 更靠谱。这篇文章只是记录一个不同的做法。
§插件就是一个函数
整个系统的核心思路很简单。插件是函数,接收一个上下文对象,往里注册东西,可以返回清理函数,也可以不返回。
- 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
- ;
- // 定义服务接口
-
-
-
- // 创建类型安全的服务键
- ;
- // 插件函数接收上下文,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'.
-
-
-
-
-
-
-
-
-
-
- ;
不用装饰器,不用类,一个函数就完成了注册。definePlugin 参考了 webpack、vite 的配置函数模式。通过函数参数解构,TypeScript 精确推导上下文类型,插件只拿到自己需要的 API。
§服务 vs 钩子
系统里两种注册方式。
服务是单例。 同一个 key 只能注册一个实例,后注册的覆盖前面的。适合放核心功能。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- ;
-
-
-
- // 创建服务键
- ;
- // 注册服务
-
-
-
-
-
-
- ;
钩子是集合。 同一个 key 可以注册多个,系统收集到一个数组里。适合扩展点,比如事件监听、生命周期回调。你不知道有多少插件会注册鼠标事件,但你知道怎么把它们全拿到。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- ;
- ;
- // 多个插件可以注册到同一个钩子键
-
-
-
-
- ;
-
-
-
-
- ;
§作用域
Circuit Simulator 支持多画布。打开两个画布,各自有独立的缩放、平移、选中状态。如果所有服务放在全局共享,两个画布的状态会互相污染。
作用域就是干这个的。每个画布是一个作用域,有独立的一套服务和钩子。
- 1
- 2
- 3
- 4
- ;
- ;
- ;
作用域可以嵌套成树。EditorScope 是 PainterScope 的子作用域。
§服务查找:向上查
从 EditorScope 获取服务时,先查自己,没有就查 PainterScope,再没有查 RootScope,到了根还找不到就抛错。
- 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
-
-
-
-
- ;
- ;
- /** 根作用域服务键 */
- ;
- ;
- // 在根作用域注册服务
-
-
-
-
-
-
-
-
-
- ;
- // 在子作用域使用根作用域注册的服务
-
-
-
-
-
-
-
-
-
- ;
日志服务在根作用域注册一次,所有画布都能用,不用每个画布都注册一遍。反过来,父作用域够不到子作用域的服务,隔离是单向的。
还有一个好处:子作用域可以覆盖父作用域的同名服务。比如根作用域有个默认存储,某个画布想用自己的独立存储,在画布作用域注册一个同 key 的服务就行。查找时优先拿自己这层的,找不到再向上,自然实现了覆盖。
§钩子查找:只看当前层
钩子不向上查。EditorScope 只返回注册在 EditorScope 的钩子,PainterScope 的不混进来。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
-
-
-
-
- ;
- ;
- // 在根作用域注册钩子
-
-
-
- ;
- // 在子作用域注册钩子
-
-
-
- ;
- // 在子作用域获取钩子,只会返回 childHook,不会包含 rootHook
-
-
这个设计来自需求本身。不同画布有不同的事件处理。画布 A 需要框选,画布 B 只需要点击。钩子向上查找的话,A 的逻辑会漏到 B。
如果确实需要在父作用域注册钩子,可以显式操作。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- ;
- ;
-
-
-
- ;
但跨作用域消费钩子就麻烦了。需要用 useHookWithScope 手动指定作用域。这种情况极少,如果经常需要这么做,说明钩子本身的设计该重新考虑。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- ;
- ;
-
-
§类型安全设计
大多数 DI 库用字符串做服务键,实现简单,但返回值类型是 any,编译器帮不上忙。拼错一个方法名要到运行时才报错。
这个系统用品牌类型替代字符串。createServiceKey<T> 返回的是 symbol & { readonly __type: T },__type 只在编译期存在,运行时就是普通 Symbol。
- 1
- 2
- 3
- 4
- 5
- 6
-
- ;
-
-
创建服务键时传入类型参数,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
§接入 React
在组件树根部挂上 InjectContext.Provider,传入一个 Map 作为 DI 容器。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- ;
- ;
-
-
-
-
-
-
然后在子组件中调用 useInjectInstall 初始化。它会创建作用域树并执行所有已注册的插件,返回的 pluginInitialized 为 true 时说明初始化完成。
- 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
- ;
- ;
- ;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
全局作用域有三个快捷方法,useServiceWithGlobal、useHookWithGlobal、useLifeCycleWithGlobal,不需要手动传入作用域。
§子作用域
为功能模块创建独立的作用域,同时导出该作用域的快捷 hooks。
- 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
- ;
- // 导出画布作用域的插件定义函数
- ;
- // 导出画布作用域的快捷方法
- ;
- ;
- ;
子作用域相关的代码中,直接用导出的 useService、useHook、useLifeCycle,不用每次传作用域参数。
§驱动层
DI 系统管理了服务和钩子,但还需要一层胶水把它们和 React 组件接起来,这就是驱动层。
以事件监听为例。各个插件通过registerHook注册事件处理函数,驱动层负责把这些函数绑定到 DOM 事件上。这样插件只管注册,不用碰 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
- ;
- ;
- ;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
组件里用起来很简单。
- 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 的实现就是创建一个带类型断言的 Symbol。
- 1
- 2
- 3
- 4
- 5
- ;
-
-
类型定义如下。
- 1
- 2
-
- ;
__type 只在编译期存在,运行时被擦除。createServiceKey<ILoggerService>('ILoggerService') 返回的 Symbol 在运行时只是普通标识符,在编译期 TypeScript 把它当作携带了 ILoggerService 类型标记的品牌类型。
§插件注册与安装
系统用一个全局 Map 管理插件元信息,键是安装器函数本身。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- export const PluginMetaInfos = new Map<PluginInstaller, IPluginMeta>();
- function definePlugin(scope: symbol, installer: PluginInstaller) {
- if (!PluginMetaInfos.has(installer)) {
- PluginMetaInfos.set(installer, { scope, installer });
- }
- }
useInjectInstall 被调用时,先创建作用域树,再安装插件。
作用域树的创建是一个递归过程,从根作用域开始,读取 ScopeMetaInfos 中的父子关系,建立完整的树形结构。
- 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
- ;
-
-
-
- ;
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
安装阶段的核心是一个 isInstalling 标志位。安装器执行期间标志为 true,此时调用 getService 或 getHook 会直接抛错,强制插件在注册阶段只注册不访问。
- 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;
插件返回的卸载函数会注册为生命周期钩子,在组件卸载时自动调用。
§服务查找
getServiceWithScope 从当前作用域开始查,找不到就逐级向上。
- 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
系统用 React Context 存储 IScopeManager。
- 1
- 2
- 3
- 4
- ;
- ;
- ;
createReactHookWithScope 为指定作用域创建一套 hooks。
- 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 缓存。
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;
- }
§防止循环依赖
isInstalling 标志位来自一个实际问题:如果注册阶段允许访问服务,A 插件注册时去拿 B 的服务,B 又在注册时拿 A 的,直接死锁。所以注册阶段一刀切禁止访问,把依赖解析推迟到所有插件安装完成后。
注册阶段发生错误时,系统会解析调用栈,提取用户代码的文件路径和行号,方便定位。
getServices 用 Object.defineProperty 实现延迟访问。返回对象的每个属性是 getter,实际访问时才触发查找。
- 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;
- }
这样插件可以在注册阶段调用 getServices 拿到对象引用,在生命周期钩子中再访问属性,绕开了注册阶段的访问限制。
§生命周期管理
生命周期钩子按 order 排序分组,组内并发执行,组间顺序执行。卸载时逆序执行卸载钩子。
- 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;
- }
createReactHookWithScope 封装了通用逻辑,每个作用域直接调用各自的 useLifeCycle 即可。需要自定义执行方式的作用域可以自己实现,不强制使用封装。
§回顾与局限
函数式插件模式在 React 里有几个实际好处。零配置、调试直观、作用域隔离天然适合多实例场景。代价是手动管理依赖,没有自动注入方便。
当前系统还有不少缺口。只支持 React,不能在纯 JS 环境用;服务查找走作用域链,深层嵌套时性能会受影响;不支持服务工厂和懒加载,每次都是同一个实例;用 Symbol 做键,调试时不太直观。
如果继续完善,优先级大概是服务工厂和懒加载、调试可视化、测试 mock 支持这几个方向。但每个改进都会增加复杂度,需要按实际需求取舍。
和 InversifyJS、TSyringe 比,这个方案没有孰优孰劣。它们选 Class 模式有充分理由,功能更全,生态更好。我选函数式,是因为在 React 里零配置、少心智负担比功能完备更重要。如果你的场景不同,结论可能完全相反。