OHIF 自定义定制服务类型化指南:用 AppTypes.Customizations 注册表让 getCustomization 读取类型化、setCustomizations 写入受检
2026/9/18 6:17:56 网站建设 项目流程

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 stringas unknown as ColorbarCustomizationas 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 后获得三样东西:

  1. id 本身有自动补全——keyof AppTypes.Customizations是有限集合;
  2. 精确返回类型——getCustomization/getValue直接返回声明类型,调用点的断言可以删除;
  3. 受检的写入——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 中的sortingCriteriainstanceSortingCriteria就是这样的例子。声明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 元素、DateRegExp)处停止。关键的结构决策是: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-helperSpec通过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形式,而$transformthis是无类型的,这是一个动态逃生舱口。受检的路径是直接值与$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注册默认、在 coreplatform/app消费;
  • Phase 2:为extension-defaultextension-cornerstone填充注册表(两者注册了约 90 个已知 id 中的 75 个)。每个键的流程是:从产生方文件导出值类型 → 加入扩展的types/AppTypes.ts增补 → 删除消费点冗余断言(每删一处都是一次免费的正确性校验)→ 把喂给getCustomization的动态键注解为string
  • Phase 3:其余扩展(cornerstone-dicom-segcornerstone-dicom-srmeasurement-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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询