一个前端编辑器有很多"注册式"入口:工具要注册到工具表,画布层要注册到图层管理器,命令要注册到命令工厂,标注类型要注册到类型表。这些注册最初都是手写清单——一个初始化函数里逐行 register(...)。
清单短的时候没问题,长到几十行、且分散在多个文件后就开始难受:新增一个功能要在三四处登记,漏一处就出现"能用但不显示"或"注册了但没接上"。
这篇文章讲怎么用一个 Vite 插件把这些手写清单换成构建期扫描 + 声明式描述符,思路借鉴 Spring Boot 的组件扫描:每个功能自己带一份注册描述文件,构建时自动发现并聚合,运行时统一注册。
现象:注册清单越铺越散
以工具注册为例,最初是一个函数把所有工具依次注册:
export function registerAllTools(): void {
registerSelectTool()
registerLineTool()
registerCircleTool()
// ... 十几行
}
每个 registerXxxTool 又在各自模块里定义,同时模块底部还挂着两段"自注册"副作用——把状态面板 provider、数值浮层 provider 顺手注册掉:
// 模块底部
toolStatusProviderRegistry.register(circleStatusProvider)
valueOverlayRegistry.register(circleOverlayProvider)
画布层更直接,一个 bootstrap 函数里 31 行 new XxxLayer():
canvasManager.registerLayer(new CirclePreviewLayer())
canvasManager.registerLayer(new LinePreviewLayer())
// ... 一直到第 31 行
于是新增一个工具,要碰的地方有:工具模块的 registerXxxTool、模块底部两段自注册、registerAllTools 里加一行。四处,缺一处就出问题。
原因:手写清单不是声明,是重复劳动
手写注册清单有三个固有问题。
清单和实现分离,容易漂移。功能定义在一处,注册登记在另一处,两者靠人工同步。清单是一份"必须记得更新"的外部账本,不是功能自己声明出来的。
副作用注册难追踪。模块底部那种"import 进来就注册"的副作用,删除时要小心——你不知道哪个 import 只是为了触发它。它把注册时机绑在模块加载顺序上,隐式且脆弱。
新增成本恒定偏高。每加一个功能都要重复"改多处"的机械劳动,且没有任何机制拦住"漏改一处"。清单越长,漏的概率越高。
理想状态应该是:功能自己声明"我要注册什么",框架负责发现和装配,人不再维护中心清单。这正是 Spring @Component + 组件扫描解决的问题。
前提:JS 没有运行时反射,扫描必须在构建期
照搬 Spring 之前要先认清一个本质差异。
Java 的组件扫描靠运行时反射 + classpath 扫描:一个类只要在 classpath 上、带了注解,即使没人 import 它,容器启动时也能扫到并实例化。
前端不行。打包器会 tree-shaking——没有被任何地方 import 的模块直接从产物里删掉。所以"扫描"不能发生在运行时,必须发生在构建期:由构建工具保证所有匹配的模块都被 import 进来,再由这些模块的副作用或导出的描述符完成注册。
这就决定了实现路径:写一个 Vite 插件,构建期扫描约定目录,把发现的模块聚合成一个虚拟模块,让应用 import 这个虚拟模块。
方案:扫描插件 + 虚拟模块
先看整体数据流:描述符散落在功能旁,构建期由插件扫描聚合成虚拟模块,运行时应用 import 它并逐个注册。
插件做三件事:约定一个虚拟模块 id、扫描目录、生成聚合代码。核心是 Vite 的 resolveId + load 两个钩子——这是虚拟模块的标准写法,文件系统上并不存在这个模块,插件在 load 里现生成它的源码。
const VIRTUAL_PREFIX = 'virtual:registry/'
const RESOLVED_PREFIX = '\0' + VIRTUAL_PREFIX // \0 前缀是虚拟模块约定
export function autoRegistryPlugin(options): Plugin {
return {
name: 'auto-registry',
resolveId(id) {
if (id.startsWith(VIRTUAL_PREFIX)) return RESOLVED_PREFIX + id.slice(VIRTUAL_PREFIX.length)
},
load(id) {
if (id.startsWith(RESOLVED_PREFIX)) {
const files = findRegistrationModules(config) // 扫描
return generateRegistryModule(files, exportName) // 生成聚合代码
}
},
}
}
扫描是纯函数:递归找出目录下"文件名匹配指定后缀、且导出了指定常量"的模块,跳过 node_modules 与隐藏目录,按路径排序保证产物稳定。
export function findRegistrationModules(config): string[] {
const marker = new RegExp(`export\s+const\s+${config.exportName}\b`)
// 递归遍历 config.dir,收集 basename 以 fileSuffix 结尾、
// 且文本命中 marker 的文件,返回绝对路径数组(已排序)
}
关键:生成的 import 用绝对路径
生成聚合代码时有一个容易踩的坑:虚拟模块里的 import 路径该怎么写。
虚拟模块在文件系统上不存在,相对路径无从解析。而用项目别名(@features/xxx)或包名,又依赖各应用各自的解析配置——多个应用、多套构建配置、测试环境,任何一处别名没对齐就解析失败。
这一步的关键是:生成的 import 一律用扫描时拿到的绝对路径。绝对路径是打包器恒能解析的稳定形式,不经过任何别名,从根上绕开跨模块解析漂移。
export function generateRegistryModule(files, exportName): string {
const imports = files
.map((f, i) => `import { ${exportName} as r${i} } from ${JSON.stringify(toPosix(f))}`)
.join('\n')
const array = files.map((_, i) => `r${i}`).join(', ')
return `${imports}\nexport const registrations = [${array}]\n`
}
生成结果就是一段普通 ESM,把扫到的描述符聚合成一个数组:
import { toolRegistration as r0 } from "/abs/path/circle.registration.ts"
import { toolRegistration as r1 } from "/abs/path/line.registration.ts"
// ...
export const registrations = [r0, r1, /* ... */]
路径统一转成 POSIX 分隔符,否则 Windows 的反斜杠会破坏 import 说明符。
描述符:每个功能自带一份声明
有了插件,每个功能只需在自己旁边放一个描述符文件。工具的描述符长这样:
// circle.registration.ts
export const toolRegistration: ToolRegistration = {
order: 8, // 显式排序键
create: () => new CircleTool(), // 构造函数
meta: { category: 'draw' },
statusProvider: circleStatusProvider, // 可选
overlayProvider: circleOverlayProvider, // 可选
}
消费端读虚拟模块,遍历注册即可:
import { registrations } from 'virtual:registry/tools'
export function registerAllTools(): void {
applyToolRegistrations(registrations) // 内部按 order 升序注册
}
这里有两个要点。
顺序不能靠扫描顺序。工具栏的显示顺序沿用注册顺序,而插件是按文件路径字母序扫描的。所以顺序必须由描述符自己带一个 order 字段,消费端排序后再注册——顺序是声明出来的,不是文件系统给的。
画布层则相反,它的渲染顺序由图层管理器内部按 z-index 排序决定,与注册顺序无关,所以层描述符不需要 order。同样是自动注册,是否需要携带顺序取决于下游怎么用。
处理运行时依赖:工厂 + 上下文
工具描述符是零参构造,但画布层不是。有几个层的构造需要运行时信息——画布尺寸、边界是否可见、数据源回调,这些只有应用启动时才知道。
盲目 () => new XxxLayer() 会丢掉这些构造参数。解法是描述符不直接给实例,而给一个接收上下文的工厂:
// CanvasBoundaryLayer.registration.ts
export const layerRegistration: LayerRegistration = {
createLayer: (ctx) => new CanvasBoundaryLayer({
canvasWidth: ctx.canvasWidth,
canvasHeight: ctx.canvasHeight,
visible: ctx.boundaryVisible,
}),
}
消费端在启动时把上下文注入进去:
applyCanvasLayers(
{ canvasWidth, canvasHeight, boundaryVisible: !unlimited },
(layer) => canvasManager.registerLayer(layer),
)
零参层的 createLayer 忽略入参即可。这样"需要运行时依赖"和"不需要"的层用同一套描述符协议,差别只在工厂内部。
护栏一:一致性测试防漏扫
自动注册有个新风险——漏扫是静默的。描述符文件名写错后缀、或忘了导出约定的常量,插件就扫不到它,而运行时不会报错,只是那个功能"神秘消失"。
因为测试运行器本身跑在 Vite 上,可以直接 import 虚拟模块做端到端校验:
import { registrations } from 'virtual:registry/tools'
it('发现的描述符数量等于预期', () => {
expect(registrations).toHaveLength(EXPECTED_TOOL_COUNT)
})
it('工具 type 互不重复', () => {
const types = registrations.map((r) => r.create().type)
expect(new Set(types).size).toBe(types.length)
})
一个"发现数 == 预期数"的断言,就把"漏扫"从线上事故拦成了测试失败。
护栏二:给虚拟模块一份类型声明
虚拟模块对 TypeScript 是透明的,需要一份环境声明把它收窄成具体类型,否则消费端拿到的是 unknown[]:
declare module 'virtual:registry/tools' {
export const registrations: readonly ToolRegistration[]
}
这里踩过一个坑。标注类型的描述符各自是具体泛型(Definition<TextAnnotation> 等),想统一加宽成 Definition<基类> 时报了类型错误——因为定义里 getBoundingBox(annotation: T) 这类方法的参数是逆变的,具体类型不是基类型的子类型。
解法不是强行 as 断言,而是描述符保留各自的具体泛型不加宽,只在虚拟模块的环境声明里统一为宽类型数组。描述符文件自身的类型检查和消费端的类型检查是两条独立的路径,各自自洽即可,中间不需要断言桥接。
效果
四个注册面改造完成后,手写清单全部消失:工具、画布层、命令工厂、标注类型,共几十个描述符,各自散落在功能旁边,由插件在构建期发现。
下图对比新增一个功能需要改动的位置数——改造前工具面要碰四处(含模块底部两段自注册),改造后统一为"加一个描述符文件"。
清单不再由人维护,一致性测试兜住漏扫,类型声明保证消费端类型安全。
边界:不是所有注册都该自动化
改造过程中评估了五个注册面,最后只做了四个。剩下三个是反例,值得单独说。
固定核心基座不必自动化。状态面板的模式和事件源是一组稳定的内建项,几乎不新增。它更像框架自带的组件,不是开放扩展点。自动注册的收益(新增免登记)在这里几乎为零,反而多出一堆描述符文件的间接性。
生命周期动态注册做不到。快捷键是工具激活时注册、停用时注销的,是有生命周期的运行时行为。而扫描式是构建期静态聚合,天生只能表达"启动时一次性注册",没法表达"激活才注册"。这不是要不要做,是技术上不兼容。
单点内部自注册没必要。某个策略表在自己内部一次性注册完默认项,就一处调用,拆成扫描式毫无收益。
自动注册的价值在"高频、分散、开放新增"的注册面。对固定基座和生命周期动态注册强行套用,是把手段当目的。知道一个模式适用于哪里,和知道它怎么实现,同样重要。

参与讨论
(Participate in the discussion)
参与讨论