OHIF 自定义定制服务类型化指南:用 AppTypes.Customizations 注册表让 getCustomization 读取类型化、setCustomizations 写入受检
【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers
本文围绕 OHIF(DICOM 查看器与肿瘤 Lesion Tracker 平台)CustomizationService 的类型化改造机制展开:如何把任意字符串 id 的getCustomization(id)升级为"读取有精确返回类型、写入被编译期校验"的强类型 API。读完本篇,你将掌握在任意扩展包中通过声明合并(declaration merging)注册定制键的完整流程,理解Authorable<T>如何区分"写侧可写的标记"与"读侧干净的值",并了解已知局限(any 键降级、拼写错误静默通过等)及其规避方法。
背景:为什么 getCustomization 默认是"弱类型"
OHIF 中各模块通过customizationService.getCustomization(id)读取定制值。默认行为是:该方法接受任意字符串,返回宽松的Customization联合类型(定义于 types.ts,涵盖组件、字符串、数字、布尔及若干定制对象形态)。这带来三个问题:
- 调用方被迫在调用点写类型断言(
as string、as unknown as ColorbarCustomization、as any等); setCustomizations的载荷(包括$set/$push/$merge命令 spec)完全不受编译期检查;- 无法从代码中发现究竟存在哪些定制 id——文档表格是手工维护的,会与已注册的键漂移。
类型化机制的目标就是把这三点补齐,同时不改变任何运行时行为:未声明的 id 保持原有工作方式,整个机制是纯增量的编译期约束。
机制核心:声明合并的全局注册表
注册表是一个通过声明合并扩展的全局接口,与AppTypes.Services使用的模式完全一致。种子接口在 AppTypes.ts(platform/core)中:
// platform/core/src/types/AppTypes.ts declare global { namespace AppTypes { export interface Customizations { sortingCriteria: (a: DisplaySet, b: DisplaySet) => number; instanceSortingCriteria: { defaultSortFunctionName?: string; sortFunctions?: Record<string, (a: unknown, b: unknown) => number>; }; } } }服务 API 通过函数重载利用这个注册表(见 CustomizationService.ts):
public getCustomization<K extends keyof AppTypes.Customizations>( customizationId: K ): AppTypes.Customizations[K]; public getCustomization(customizationId: string): Customization | undefined;声明一个 id 后获得三样东西:
- id 本身有自动补全——
keyof AppTypes.Customizations是有限集合; - 精确返回类型——
getCustomization/getValue直接返回声明类型,调用点的断言可以删除; - 受检的写入——
setCustomizations会用声明类型校验值,包括$set/$push/$merge命令 spec。
未声明的 id 走第二个重载签名,返回Customization | undefined,与改造前行为一致。
如何声明一个键
在你的包中添加types/AppTypes.ts(或扩展现有文件),合并你拥有的键:
// platform/ui-next/src/types/AppTypes.ts declare global { namespace AppTypes { interface Customizations { /** * Sort options offered by the study browser's sort dropdown. Read by * `StudyBrowserSort`, which indexes `[0]` for its initial selection, so a * default with at least one entry is expected. */ 'studyBrowser.sortFunctions': Array<{ label: string; sortFunction: (a: AppTypes.DisplaySet, b: AppTypes.DisplaySet) => number; }>; } } } export {};这就是全部机制。效果立竿见影:StudyBrowserSort.tsx 现在可以直接读取而无需断言和?.守卫:
const sortFunctions = customizationService.getCustomization('studyBrowser.sortFunctions'); const [selectedSort, setSelectedSort] = useState(sortFunctions[0]); // ... {sortFunctions.map(sort => <DropdownMenuItem key={sort.label}>{sort.label}</DropdownMenuItem>)}对于任何实际规模的值类型,建议从产生默认值的文件导出类型并在注册表中引用,而不是内联形状。studyBrowser.sortFunctions的默认值由extension-default注册(见 studyBrowserCustomization.ts)。
在哪里声明:消费方包
由消费该键的包声明其类型,紧挨着消费方。
这通常也是注册默认值的包,但并非总是如此——这个差别很重要。上例的studyBrowser.sortFunctions声明在platform/ui-next,因为读取发生在该包的StudyBrowserSort组件里;而默认值由extension-default注册。在消费方声明类型,正是让提供方的默认值能够针对消费方所依赖的契约做校验的方式。
被platform/core读取的键则声明在 core 中,以保持依赖方向不依赖扩展包——AppTypes.ts 中的sortingCriteria与instanceSortingCriteria就是这样的例子。声明sortingCriteria之后,两个包中的同一处断言都被删除了(createStudyBrowserTabs.ts 与 defaultRouteInit.ts):
- const sortCriteria = customizationService.getCustomization('sortingCriteria') as (a, b) => number; + const sortCriteria = customizationService.getCustomization('sortingCriteria');第三方扩展以同样方式在自己的仓库外声明自己的 id。无需任何集中注册。
声明"解析后"的值:读侧与写侧的类型分裂
声明的类型描述的是getCustomization交还的东西——即inheritsFrom合并、$transform执行、$reference展开之后的值。它不是你写入的形状。
这个区分正是组合类键能够被类型化的原因。toolbarButtons解析后是一个按钮列表,所以要声明的也是按钮列表:
interface Customizations { toolbarButtons: Button[]; }……尽管它几乎总是用$reference标记来写入的:
// modes/basic/src/index.tsx toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }],两种写法都被接受。从源码看,$reference/$transform是读时标记而非更新命令:服务在读取时替换/调用它们(CustomizationService.ts 的_resolveReferences递归展开引用,数组项位置的引用若目标是数组会被展平进父列表;transform处理inheritsFrom与$transform,见 第 679-691 行),而写入路径的hasDollarKey有意把这两个键排除在 immutability-helper 命令之外。因此标记可以出现在解析器会遍历的任何位置——整个值、数组项、或普通对象的属性值——同时读取端保持干净:
customizationService.setCustomizations({ // 整个值 toolbarButtons: { $reference: 'cornerstone.toolbarButtons' }, // 追加到已有列表 toolbarSections: { $push: [{ $reference: 'cornerstone.toolbarSections' }] }, // 与字面量条目混用 toolGroupAdditions: { default: [{ $reference: 'x' }], mpr: [] }, // 读取时基于兄弟属性计算(必须是 function 而非箭头函数—— // `$transform` 通过 this 读取兄弟属性) measurementsContextMenu: { inheritsFrom: 'ohif.contextMenu', $transform: function (customizationService) { return { ...this, menus: this.menus.map(menu => ({ ...menu })) }; }, }, });这个写侧宽容由 types.ts 中的Authorable<T>精确编码:它递归穿透数组和普通对象,在_resolveReferences原样返回的东西(函数、构造函数、React 元素、Date、RegExp)处停止。关键的结构决策是:Authorable<T>只应用在写侧(CustomizationEntries中):
// platform/core/src/services/CustomizationService/types.ts export type CustomizationEntries = { [K in KnownCustomizationIds]?: | Authorable<AppTypes.Customizations[K]> | Spec<Authorable<AppTypes.Customizations[K]>, CustomizationUpdateCommands>; } & { [customizationId: string]: unknown; };注册表声明的是一个键解析成什么,所以getCustomization的返回类型保持干净。若在注册表里声明标记联合类型(toolbarButtons: (Button | ReferenceMarker)[]),就会迫使每个读取点去处理getCustomization永不返回的标记——既错误又不可用。
自定义更新命令也是一个注册表
$filter由服务自身注册(其参数类型FilterSpec声明在 types.ts)。如果你的扩展通过registerCustomUpdateCommand(CustomizationService.ts)在运行时注册更多命令,就在平行的AppTypes.CustomizationUpdateCommands注册表中声明,使用它们的 spec 即可无断言通过类型检查:
declare global { namespace AppTypes { interface CustomizationUpdateCommands { /** Reorders toolbar entries by weight. */ $reweight: { id: string; weight: number }; } } }customizationService.registerCustomUpdateCommand('reweight', (query, original) => ...); customizationService.setCustomizations({ toolbarButtons: { $reweight: { id: 'Zoom', weight: 3 } }, });有一个不明显的约束值得提前知晓:命令注册表在 CustomizationUpdateCommands 中被包成CustomCommands<Partial<AppTypes.CustomizationUpdateCommands>>。这里的Partial不是装饰性的——immutability-helper的Spec通过C extends CustomCommands<infer O> ? O : never暴露自定义命令,而O会推断到整个注册表接口;没有Partial,每个 spec 就必须同时提供所有已注册命令,注册表中一旦有第二个命令,普通的{ $filter: ... }就会被拒报误导性错误。
可空性:类型即"有默认值"的承诺
不带| undefined的声明是对"已注册默认值"的承诺。消费方此后可以直接使用值——现有消费方就是这样写的:StudyBrowserSort直接索引sortFunctions[0],没有任何守卫。
仅对确实不附带默认值的 id 追加| undefined:
interface Customizations { // 有默认值,由 extension-default 注册 'studyBrowser.sortFunctions': SortFunction[]; // 无默认值;每个消费方都必须处理缺失 'studyBrowser.onDoubleClick': DoubleClickHandler | undefined; }注意仓库自身的 tsconfig.json 未开启strictNullChecks,所以| undefined在仓库内会被擦除;它是给严格编译的下游消费者的契约。从源码结构看,这也是一处有意保留的残余风险:承诺由声明的包作出,却由注册默认值的包兑现——如果某个部署的pluginConfig.json省略了该提供方,运行时会拿到类型所不容忍的undefined。这本来就是今天的运行时失败,类型没有使其变坏,但该不变式目前存在于约定而非编译器中。
已知局限(Gotchas)
以下是回退设计的已接受取舍,不是待修的 bug。列出来以免使用者重新踩一遍:
1. 类型为any的键会关闭所有检查。如果你传入的 id 是any——例如从一个无类型的 props 袋里解构出来——调用会选中泛型重载并返回any,这比未声明 id 得到的Customization | undefined更弱。把键注解为string:
// items: any —— 完全无检查 export default function MoreDropdownMenu(bindProps) { const { menuItemsKey } = bindProps; const items = customizationService.getCustomization(menuItemsKey); // items: Customization | undefined —— 正确的回退 export default function MoreDropdownMenu(bindProps) { const { menuItemsKey }: { menuItemsKey: string } = bindProps; const items = customizationService.getCustomization(menuItemsKey);从源码结构看,这是"方法对键做泛型化"的固有代价:any键会匹配K extends keyof AppTypes.Customizations的泛型重载并推断出any;单签名条件返回形式也无法规避,修复只能在调用点进行。
2. 已声明 id 的拼写错误不会被捕获。因为未声明 id 必须继续工作,setCustomizations接受任何字符串键(CustomizationEntries末尾的[customizationId: string]: unknown索引签名同时禁用了多余属性检查),所以'panelSegmentation.disabledEditing'会被静默当作未注册的动态键,而不是被报告为某个已声明键的拼写错误。在"未声明 id 必须继续工作"的前提下这不可避免。
3.$transform的返回类型不受检查——它被校验为函数,但不针对声明的值类型校验。Spec本身就接受裸的(value: T) => T形式,而$transform的this是无类型的,这是一个动态逃生舱口。受检的路径是直接值与$set/$push/ … 的 spec。
4.getValue的回退值不受检查。对已声明 id 传入类型不匹配的fallbackValue不会报错;调用落回宽松签名并返回回退值的类型(见 CustomizationService.ts 的getValue重载)。已声明 id 请优先使用getCustomization。
仓库中的落地状态与推广计划
该机制的仓库内推进由 CUSTOMIZATION_TYPING_PLAN.md 记录,其分阶段计划可作为第三方包跟进类型化的参照:
- Phase 1(已完成):基础设施加跨包证明。core 声明
sortingCriteria/instanceSortingCriteria,ui-next 声明studyBrowser.sortFunctions——证明声明能双向跨越包边界:sortingCriteria在 core 声明、在extension-default注册默认、在 core和platform/app消费; - Phase 2:为
extension-default与extension-cornerstone填充注册表(两者注册了约 90 个已知 id 中的 75 个)。每个键的流程是:从产生方文件导出值类型 → 加入扩展的types/AppTypes.ts增补 → 删除消费点冗余断言(每删一处都是一次免费的正确性校验)→ 把喂给getCustomization的动态键注解为string; - Phase 3:其余扩展(
cornerstone-dicom-seg、cornerstone-dicom-sr、measurement-tracking等);modes 在onModeEnter中的setCustomizations调用会在键声明后自动获得检查; - Phase 4(可选):提交编译期测试(
// @ts-expect-error断言)、从注册表生成文档定制表格以避免漂移、断言每个无| undefined的键确实在init()后拥有默认值、从AppTypes.Customizations生成 JSON Schema 以给?customization=JSONC 文件提供编辑器校验。
该 PR 的验证配方同样可复用:npx jest platform/core/src/services/CustomizationService(7 个套件 79 个测试通过),以及全仓库npx tsc --noEmit与改动前基线的错误列表差分(3341 → 3337,零新增)。由于仓库有数千条既有 tsc 错误且没有 tsc CI 门禁,差分排序后的错误列表是唯一可靠的全局检查。
小结
类型化定制值的完整配方是:消费方包声明键 → 声明解析后的值类型(不声明标记)→ 读侧获得精确类型、写侧由Authorable<T>放行$reference/$transform/ 命令 spec → 动态键注解为string以免 any 降级。运行时行为零改动,纯编译期收益:自动补全、断言删除、写入受检。进一步的语义细节可参见 Advanced Customization(inheritsFrom与$transform语义)和 Customization Service($set/$push/$filter命令语法——本文这些类型正是校验它们的对象)。
【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考