Dreaming Cat's

函数式的依赖注入系统

去年写 Circuit Simulator 时,我需要一套插件系统。四个硬需求:

  1. 功能模块独立注册、独立卸载。渲染器、事件处理、工具栏各自是一个插件
  2. 多个画布实例之间互不干扰。每个画布有自己的状态,但共享全局服务(日志、存储)
  3. 编译期类型检查。注册一个服务,使用它的时候 IDE 能自动补全,而不是靠记忆或者查文档
  4. 和 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
  • import { definePlugin, createServiceKey } from '@@local/inject';
  • // 定义服务接口
  • interface ILoggerService {
  • info(msg: string): void;
  • error(msg: string): void;
  • }
  • // 创建类型安全的服务键
  • const ILoggerService = createServiceKey<ILoggerService>('ILoggerService');
  • // 插件函数接收上下文,TypeScript 自动推导类型
  • definePlugin((ctx) => {
  • // 注册服务时可以提供精确的自动补全
  • // 这里会报错说少了 error 方法
  • ctx.registerService(ILoggerService, {
  • [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'.
  • // 精确的自动补全和类型检查
  • info(msg: string) {
  • // ..
  • },
  • });
  • // 安全的类型推导
  • const storageService = ctx.getService(ILoggerService);
  • return () => {
  • // 组件卸载时自动调用,做清理
  • };
  • });

不用装饰器,不用类,一个函数就完成了注册。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
  • import { createServiceKey, definePlugin } from '@@local/inject';
  • interface IStreamService {
  • get(key: string): any;
  • clear(): void;
  • }
  • // 创建服务键
  • const IStreamService = createServiceKey<IStreamService>('IStreamService');
  • // 注册服务
  • definePlugin(({ registerService }) => {
  • const service: IStreamService = {
  • get(key) { /* ... */ },
  • clear() { /* ... */ },
  • };
  • registerService(IStreamService, service);
  • });

钩子是集合。 同一个 key 可以注册多个,系统收集到一个数组里。适合扩展点,比如事件监听、生命周期回调。你不知道有多少插件会注册鼠标事件,但你知道怎么把它们全拿到。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • import { definePlugin } from '@@local/inject';
  • import { IEventListenerHook } from '@@local/inject/IEventListenerHook';
  • // 多个插件可以注册到同一个钩子键
  • definePlugin(({ registerHook }) => {
  • registerHook(IEventListenerHook, {
  • onMouseDown: () => { /* ... */ },
  • });
  • });
  • definePlugin(({ registerHook }) => {
  • registerHook(IEventListenerHook, {
  • onMouseUp: () => { /* ... */ },
  • });
  • });

§作用域

Circuit Simulator 支持多画布。打开两个画布,各自有独立的缩放、平移、选中状态。如果所有服务放在全局共享,两个画布的状态会互相污染。

作用域就是干这个的。每个画布是一个作用域,有独立的一套服务和钩子。

  • 1
  • 2
  • 3
  • 4
  • import { RootScope, createScopeSymbol } from '@@local/inject';
  • const PainterScope = createScopeSymbol('Painter', RootScope);
  • const EditorScope = createScopeSymbol('Editor', PainterScope);

作用域可以嵌套成树。EditorScopePainterScope 的子作用域。

§服务查找:向上查

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
  • import {
  • defineGlobalPlugin,
  • defineChildPlugin,
  • createServiceKey,
  • } from '@@local/inject';
  • import { ILifeCycleHook } from '@@local/inject/ILifeCycleHook';
  • /** 根作用域服务键 */
  • const IGlobalService = createServiceKey<{}>('IGlobalService');
  • const IChildService = createServiceKey<{}>('IChildService');
  • // 在根作用域注册服务
  • defineGlobalPlugin(({ registerService, registerHook, getService }) => {
  • // 在根作用域注册服务
  • registerService(IGlobalService, {});
  • // 在生命周期中访问父级作用域服务
  • registerHook(ILifeCycleHook, {
  • onMounted() {
  • const service = getService(IChildService); // 找不到,父看不到子的服务
  • },
  • });
  • });
  • // 在子作用域使用根作用域注册的服务
  • defineChildPlugin(({ registerService, registerHook, getService }) => {
  • // 在子作用域注册服务
  • registerService(IChildService, {});
  • // 在生命周期中访问父级作用域服务
  • registerHook(ILifeCycleHook, {
  • onMounted() {
  • const service = getService(IGlobalService); // 能找到,向上到了根
  • },
  • });
  • });

日志服务在根作用域注册一次,所有画布都能用,不用每个画布都注册一遍。反过来,父作用域够不到子作用域的服务,隔离是单向的。

还有一个好处:子作用域可以覆盖父作用域的同名服务。比如根作用域有个默认存储,某个画布想用自己的独立存储,在画布作用域注册一个同 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
  • import {
  • defineGlobalPlugin,
  • defineChildPlugin,
  • useHook,
  • } from '@@local/inject';
  • import { IEventListenerHook } from '@@local/inject/IEventListenerHook';
  • // 在根作用域注册钩子
  • defineGlobalPlugin(({ registerHook }) => {
  • const rootHook: IEventListenerHook = {};
  • registerHook(IEventListenerHook, rootHook);
  • });
  • // 在子作用域注册钩子
  • defineChildPlugin(({ registerHook }) => {
  • const childHook: IEventListenerHook = {};
  • registerHook(IEventListenerHook, childHook);
  • });
  • // 在子作用域获取钩子,只会返回 childHook,不会包含 rootHook
  • function Component() {
  • const hooks = useHook(IEventListenerHook);
  • }

这个设计来自需求本身。不同画布有不同的事件处理。画布 A 需要框选,画布 B 只需要点击。钩子向上查找的话,A 的逻辑会漏到 B。

如果确实需要在父作用域注册钩子,可以显式操作。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • import { definePlugin } from '@@local/inject';
  • import { IEventListenerHook } from '@@local/inject/IEventListenerHook';
  • definePlugin(({ parent, root, registerHook }) => {
  • parent()?.registerHook(IEventListenerHook, {});
  • root().registerHook(IEventListenerHook, {});
  • });

但跨作用域消费钩子就麻烦了。需要用 useHookWithScope 手动指定作用域。这种情况极少,如果经常需要这么做,说明钩子本身的设计该重新考虑。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • import { RootScope, useHookWithScope, createServiceKey } from '@@local/inject';
  • const IGlobalHook = createServiceKey<{}>('IGlobalHook');
  • function Component() {
  • const hooks = useHookWithScope(IGlobalHook, RootScope);
  • }

§类型安全设计

大多数 DI 库用字符串做服务键,实现简单,但返回值类型是 any,编译器帮不上忙。拼错一个方法名要到运行时才报错。

这个系统用品牌类型替代字符串。createServiceKey<T> 返回的是 symbol & { readonly __type: T }__type 只在编译期存在,运行时就是普通 Symbol。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • export type ServiceTypeWithKey<T> = symbol
  • & { readonly __type: T };
  • export function createServiceKey<T>(name: string): ServiceTypeWithKey<T> {
  • return Symbol(name) as ServiceTypeWithKey<T>;
  • }

创建服务键时传入类型参数,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
  • import { createServiceKey, definePlugin } from '@@local/inject';
  • // 定义服务接口
  • interface IStreamService {
  • get(key: symbol): any;
  • clear(): void;
  • }
  • // 创建服务键,类型被"品牌化"
  • const IStreamService = createServiceKey<IStreamService>('IStreamService');
  • const IStorageService = createServiceKey<{}>('IStorageService');
  • const IConfigurationService = createServiceKey<{}>('IConfigurationService');
  • definePlugin(({ registerService, getService, getServices }) => {
  • // 获取单个服务,TypeScript 自动推导类型
  • const service = getService(IStreamService);
  • const key = Symbol('key');
  • // 类型安全
  • service.get(key);
  • service.clear();
  • // 类型错误:不存在此方法
  • service.unknown();
  • [TS2339] Property 'unknown' does not exist on type 'IStreamService'.
  • // 批量获取服务
  • const services = getServices({
  • stream: IStreamService,
  • storage: IStorageService,
  • config: IConfigurationService,
  • });
  • services.stream.get(key);
  • // 类型错误:不存在此方法
  • services.storage.get('key');
  • [TS2339] Property 'get' does not exist on type '{}'.
  • });

§接入 React

在组件树根部挂上 InjectContext.Provider,传入一个 Map 作为 DI 容器。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • import { InjectContext } from '@@local/inject';
  • import React from 'react';
  • export function App() {
  • return (
  • <InjectContext.Provider value={new Map()}>
  • <div>App</div>
  • </InjectContext.Provider>
  • );
  • }

然后在子组件中调用 useInjectInstall 初始化。它会创建作用域树并执行所有已注册的插件,返回的 pluginInitializedtrue 时说明初始化完成。

  • 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
  • import React, { useEffect } from 'react';
  • import { useInjectInstall, useServiceWithGlobal } from '@@local/inject';
  • import { ILoggerService } from '@@local/inject/ILoggerService';
  • function Layout() {
  • const logger = useServiceWithGlobal(ILoggerService);
  • useEffect(() => {
  • logger.info('组件已加载');
  • }, [logger]);
  • return <div>Layout</div>;
  • }
  • function Initialization() {
  • const [pluginInitialized] = useInjectInstall();
  • // 等待初始化完成
  • if (!pluginInitialized) {
  • return null;
  • }
  • // 这里可以放你真正的 App 组件
  • return <Layout />;
  • }

全局作用域有三个快捷方法,useServiceWithGlobaluseHookWithGlobaluseLifeCycleWithGlobal,不需要手动传入作用域。

§子作用域

为功能模块创建独立的作用域,同时导出该作用域的快捷 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
  • import {
  • createScopeSymbol,
  • RootScope,
  • createPluginDefinitionWithScope,
  • createReactHookWithScope,
  • } from '@@local/inject';
  • // 创建画布作用域
  • export const PainterScope = createScopeSymbol('Painter', RootScope);
  • // 为画布作用域创建快捷 Hooks
  • const reactHook = createReactHookWithScope(PainterScope);
  • // 导出画布作用域的插件定义函数
  • export const definePlugin = createPluginDefinitionWithScope(PainterScope);
  • // 导出画布作用域的快捷方法
  • export const useService = reactHook.useService;
  • export const useHook = reactHook.useHook;
  • export const useLifeCycle = reactHook.useLifeCycle;

子作用域相关的代码中,直接用导出的 useServiceuseHookuseLifeCycle,不用每次传作用域参数。

§驱动层

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
  • import { useHook } from '@@local/inject';
  • import { IEventListenerHook } from '@@local/inject/IEventListenerHook';
  • import { useRef, useEffect } from 'react';
  • export function useEventListenerDriver(ref: React.RefObject<HTMLDivElement>) {
  • // 从 DI 系统获取所有注册的事件监听钩子
  • const hooks = useHook(IEventListenerHook);
  • useEffect(() => {
  • if (!ref.current) {
  • return;
  • }
  • const element = ref.current;
  • // 按 order 排序钩子
  • const sortedHooks = hooks.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
  • // 绑定鼠标按下事件
  • const handleMouseDown = (event: MouseEvent) => {
  • for (const hook of sortedHooks) {
  • hook.onMouseDown?.(event);
  • }
  • };
  • // 绑定鼠标释放事件
  • const handleMouseUp = (event: MouseEvent) => {
  • for (const hook of sortedHooks) {
  • hook.onMouseUp?.(event);
  • }
  • };
  • // 绑定点击事件
  • const handleClick = (event: MouseEvent) => {
  • for (const hook of sortedHooks) {
  • hook.onClick?.(event);
  • }
  • };
  • // 注册事件监听器
  • element.addEventListener('mousedown', handleMouseDown);
  • element.addEventListener('mouseup', handleMouseUp);
  • element.addEventListener('click', handleClick);
  • // 清理函数
  • return () => {
  • element.removeEventListener('mousedown', handleMouseDown);
  • element.removeEventListener('mouseup', handleMouseUp);
  • element.removeEventListener('click', handleClick);
  • };
  • }, [hooks, ref]);
  • }

组件里用起来很简单。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • import React, { useRef } from 'react';
  • import { useEventListenerDriver } from '@@local/inject/useEventListenerDriver';
  • function Component() {
  • const ref = useRef<HTMLDivElement>(null);
  • // 使用驱动层,将钩子绑定到 DOM 事件
  • useEventListenerDriver(ref);
  • return <div ref={ref}>组件内容</div>;
  • }

§文件组织

项目里我通常这样分文件。

  • 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
  • import { ServiceTypeWithKey } from '@@local/inject';
  • export function createServiceKey<T>(name: string): ServiceTypeWithKey<T> {
  • return Symbol(name) as ServiceTypeWithKey<T>;
  • }

类型定义如下。

  • 1
  • 2
  • export type ServiceTypeWithKey<T> = symbol
  • & { readonly __type: T };

__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
  • import { ScopeMetaInfos, IScopeManager, RootScope, IScopeContainer } from '@@local/inject';
  • declare function createScopeData(
  • symbol: symbol,
  • parentContainer?: IScopeContainer | null,
  • ): IScopeContainer;
  • function createScope(scopeMeta: typeof ScopeMetaInfos, manager: IScopeManager) {
  • function createScopeRecursive(scope: symbol, parentScope: symbol | null = null) {
  • const parentContainer = parentScope ? manager.get(parentScope) : null;
  • const scopeContainer = createScopeData(scope, parentContainer);
  • if (parentContainer) {
  • manager.set(scope, scopeContainer);
  • scopeContainer.parent = parentContainer;
  • parentContainer.children.push(scopeContainer);
  • }
  • else {
  • manager.set(scope, createScopeData(scope));
  • }
  • const children = scopeMeta.get(scope);
  • if (children) {
  • for (const child of children) {
  • createScopeRecursive(child, scope);
  • }
  • }
  • }
  • createScopeRecursive(RootScope);
  • }

安装阶段的核心是一个 isInstalling 标志位。安装器执行期间标志为 true,此时调用 getServicegetHook 会直接抛错,强制插件在注册阶段只注册不访问。

  • 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
  • import { ServiceTypeWithKey, IScopeManager } from '@@local/inject';
  • export function getServiceWithScope<T>(
  • key: ServiceTypeWithKey<T>,
  • scope: symbol,
  • ScopeManager: IScopeManager,
  • ): T {
  • let scopeContainer = ScopeManager.get(scope);
  • if (!scopeContainer) {
  • throw new Error(`未找到 ${String(scope)} 作用域`);
  • }
  • let service = scopeContainer.context.ServiceMap.get(key);
  • // 逐级向上查找
  • while (!service && scopeContainer.parent) {
  • scopeContainer = scopeContainer.parent;
  • service = scopeContainer.context.ServiceMap.get(key);
  • }
  • if (!service) {
  • throw new Error(`未找到 ${String(key)} 服务`);
  • }
  • return service as T;
  • }

时间复杂度 O(h)h 是作用域树深度,实际应用中深度通常很小。

钩子查找只在当前作用域。

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • import { ServiceTypeWithKey, IScopeManager } from '@@local/inject';
  • export function getHookWithScope<T>(
  • key: ServiceTypeWithKey<T>,
  • scope: symbol,
  • ScopeManager: IScopeManager,
  • sort: 'asc' | 'desc' = 'asc',
  • ): T[] {
  • const scopeContainer = ScopeManager.get(scope);
  • if (!scopeContainer) {
  • throw new Error(`未找到 ${String(scope)} 作用域`);
  • }
  • return scopeContainer.context.HookMap.get(key) ?? [];
  • }

§React Hooks

系统用 React Context 存储 IScopeManager

  • 1
  • 2
  • 3
  • 4
  • import { createContext } from 'react';
  • import { IScopeManager } from '@@local/inject';
  • export const InjectContext = createContext<IScopeManager>(new Map());

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
  • import {
  • ServiceTypeWithKey,
  • getServiceWithScope,
  • getHookWithScope,
  • InjectContext,
  • } from '@@local/inject';
  • import { useContext, useMemo } from 'react';
  • export function createReactHookWithScope(scope: symbol) {
  • const hook = {
  • useService<T>(key: ServiceTypeWithKey<T>) {
  • return getServiceWithScope(key, scope, useContext(InjectContext));
  • },
  • useHook<T>(key: ServiceTypeWithKey<T>, sort?: 'asc' | 'desc') {
  • const context = useContext(InjectContext);
  • return useMemo(() => {
  • return getHookWithScope(key, scope, context, sort);
  • }, [key, scope, sort, context]);
  • },
  • useLifeCycle() {
  • // 生命周期管理逻辑
  • },
  • };
  • return hook;
  • }

useService 直接调用查找函数,useHookuseMemo 缓存。

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 的,直接死锁。所以注册阶段一刀切禁止访问,把依赖解析推迟到所有插件安装完成后。

注册阶段发生错误时,系统会解析调用栈,提取用户代码的文件路径和行号,方便定位。

getServicesObject.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 里零配置、少心智负担比功能完备更重要。如果你的场景不同,结论可能完全相反。