Halo 的 ESM UI Provider 运行时:Provider Manifest、共享依赖快照与混合加载机制的源码级解析
Halo 从 2.x 时代就允许插件与主题向 Console(控制台)和 User Center(用户中心)提供 UI 模块,早期它们一律被打包成拼接的 IIFE bundle。本文以 Halo 开放规格变更 2026-08-07-support-esm-ui-plugins 中定义的 ui-plugin-esm-runtime 规格(当前权威版本见 openspec/specs/ui-plugin-esm-runtime/spec.md)为主体,系统讲解 ESM UI Provider 运行时契约:Provider 如何通过 ui-plugin.json 清单被识别为 ESM 模块、Halo 如何通过不可变"宿主运行时快照"和宿主自有的 Import Map 裁决共享依赖、后端如何以单份 Provider Descriptor 统一描述并驱动混合加载、前端共享 Pinia store 如何登记生命周期状态,以及故障隔离、Legacy IIFE 兼容、缓存边界与开发/生产拓扑一致性是如何逐条保证的。读完本文,你将掌握这套"插件/主题 -> 构建工具 -> 后端描述端点 -> 前端混合加载运行时"的完整链路,并能在仓库源码中逐行验证每一条契约。
一、为什么需要一套 ESM UI Provider 运行时
变更提案 proposal.md 给出了明确动因:Halo 过去把插件与激活主题的 UI 模块加载为"拼接的 IIFE bundle",模块与共享依赖都通过全局变量暴露。这种方式有两大致命短板:
- 无法使用浏览器标准的 ESM 异步 chunk 加载,也无法对单个 provider 做独立缓存;
- provider 无法在构建时确认自己解析到的共享依赖版本是否落在目标 Halo 版本承诺的兼容窗口内。
于是该变更把"为插件和主题同时引入原生 ESM 支持"做成一份兼顾兼容性的契约——既有新的 ui-plugin-esm-runtime 能力,也要求整套既有 IIFE 协议在 Halo 2.x 内原样保留。同时提出回归保护规则:新增或修改的仅服务于 IIFE 兼容层的代码,必须在相邻位置标注"Halo 3 移除"注释。
从规格看,这一运行时契约覆盖七条核心 Requirement(Requirements),本文后续按此骨架展开:Provider UI 模块清单、宿主运行时快照、宿主自有的共享依赖解析、带版本的 Provider Descriptor、共享 UI Provider 注册 store、Legacy 与 ESM 混合加载、ESM Provider 故障隔离,再加上兼容、缓存与开发/生产一致性约束。
二、核心概念与角色速览
阅读前先明确规格中反复出现的关键角色,它们贯穿全文:
| 术语 | 含义 |
|---|---|
| UI Provider | 向 Console / User Center 提供 UI 模块的插件或激活主题,两者使用同一份模块契约与同一套 bundler 工具链 |
| Provider Manifest | 由构建产物自动生成的描述文件 ui-plugin.json,是 ESM UI 模块的权威描述 |
| 宿主运行时快照(Host Runtime Snapshot) | 稀疏、不可变的 JSON 文件,记录某 Halo 基线实际解析并对外共享的依赖版本与根导出 |
| 共享包根(Shared Package Root) | Halo 通过 Import Map 统一供应的一组包根,如 vue、@halo-dev/components 等 |
| Provider Descriptor | 后端返回的一份额外认证响应,一次描述当前所有 UI Provider 的分类、入口、启动样式与失效原因 |
| 共享注册 store | @halo-dev/ui-shared 导出的 stores.uiPlugins(),提供与格式无关的 provider 可用性与注册状态 |
| Legacy IIFE Provider | 未提供 ESM 清单的插件/主题,走历史 bundle + 全局模块协议 |
flowchart LR
A[Plugin / Theme 构建] -->|ui-plugin-bundler-kit| B[ui-plugin.json + ESM 产物]
B --> C[后端 Provider Descriptor]
C --> D[前端 Import Map + 混合加载器]
D --> E[stores.uiPlugins 登记]
E --> F[稳定顺序初始化 PluginModule]
三、Provider UI Module Manifest:判定"谁是 ESM"的唯一依据
规格第一项 Requirement 指出:Halo 必须使用生成的 provider manifest 作为插件或主题提供的 ESM UI 模块的权威描述。这份清单的实体定义可以在 bundler-kit 源码中找到。
3.1 清单文件与校验规则
在 ui/packages/ui-plugin-bundler-kit/src/provider-manifest.ts 中:
export const ESM_PROVIDER_MANIFEST = "ui-plugin.json";
export interface EsmProviderManifest {
format: "esm";
entry: string;
style?: string;
}
即清单文件固定命名为 ui-plugin.json,结构极简——只有三个字段,且 format 必须恒等于 "esm":
{
"format": "esm",
"entry": "./main.js",
"style": "./style.css"
}
validateEsmProviderManifest(provider-manifest.ts)逐项校验:必须是对象;键集合只允许 entry、format、style;format 必须为 esm;entry 与 style 必须为字符串。规格中"缺失运行时字段、非法 JSON、不受支持的 format、非法资源路径"都会被判定为 invalid provider,且不允许回退成 Legacy IIFE 执行其资源——这是明确的失败语义,不是"能跑就行"。
3.2 资源路径不得逃逸 provider 根目录
规格要求:清单中若出现绝对路径、跨域 URL、或可逃逸 provider 根目录的资源路径,Halo 必须整体拒绝该清单。这与代码中的 normalizeProviderResourcePath(provider-manifest.ts)一一对应:
if (
!normalizedSlashes ||
normalizedSlashes.startsWith("/") ||
normalizedSlashes.startsWith("//") ||
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(normalizedSlashes) || // 拦截 http:、data: 等
normalizedSlashes.includes("?") ||
normalizedSlashes.includes("#")
) {
throw new Error(`Provider resource path must be provider-root-relative: ${resourcePath}.`);
}
const normalized = path.posix.normalize(normalizedSlashes);
if (normalized === ".." || normalized.startsWith("../")) {
throw new Error(`Provider resource path escapes its root: ${resourcePath}.`);
}
实现细节很有参考价值:先把 \ 统一成 /,随后以是否以 / 开头、是否含 URL scheme、是否含 ?/# 三条规则判定"跨源/绝对化",再用 path.posix.normalize 折叠 ../,凡结果逃出根目录即抛错。合法路径会被规范化为 ./ 前缀的相对路径。这与规格里"resource path escapes provider root -> reject"的场景完全吻合。
3.3 无清单 = Legacy IIFE
没有 manifest 时,插件或激活主题直接按 Legacy IIFE Provider 分类;插件与主题套用同一套清单 schema 与校验(对主题提供 ESM 模块的场景同样生效)。
四、Halo Host Runtime Snapshot:不承诺依赖范围的稀疏不可变快照
ESM 化之后最大难题是:provider 构建时引用 vue、@halo-dev/components 等共享包,但 Halo 各版本实际提供的版本在变。规格给出的方案是发布稀疏、不可变的宿主运行时快照——只记录 Halo 某个基线真实解析出的版本和真实导出的根符号,绝不声明"我们支持 provider 的某段版本范围"。
4.1 快照记录什么:精确版本 + 根导出 + 运行时桥信息
快照数据结构定义在 ui/packages/ui-plugin-bundler-kit/src/runtime-snapshot.ts:
export interface HostRuntimeSnapshotEntry {
version: string;
exports: string[];
runtime: {
bridge: string;
global: string;
identity: "singleton" | "shared";
};
}
export interface HaloHostRuntimeSnapshot {
haloVersion: string;
packages: Record<SharedPackageRoot, HostRuntimeSnapshotEntry>;
}
规格要求快照精确识别以下 10 个共享包根的版本与实际导出:[vue、vue-router、pinia、axios、@formkit/vue、@formkit/core、@halo-dev/ui-shared、@halo-dev/components、@halo-dev/api-client、@halo-dev/richtext-editor]。这与代码中的常量逐一对应:
export const SHARED_PACKAGE_ROOTS = [
"vue", "vue-router", "pinia", "axios",
"@formkit/vue", "@formkit/core",
"@halo-dev/ui-shared", "@halo-dev/components",
"@halo-dev/api-client", "@halo-dev/richtext-editor",
] as const;
仓库当前内置的快照是 ui/packages/ui-plugin-bundler-kit/src/runtime-snapshots/halo-2.26.0.json,它由 generate-runtime-snapshot.mjs 生成并由 runtime-snapshots/index.ts 统一导出。节选真实内容:
{
"haloVersion": "2.26.0",
"packages": {
"vue": { "version": "3.5.41", "exports": ["BaseTransition", "createApp", "computed", "createRouter", "watch", "h", "ref", ...], "runtime": { "bridge": "vue", "global": "Vue", "identity": "singleton" } },
"vue-router": { "version": "5.1.0", "exports": ["createRouter", "createWebHistory", "RouterLink", ...], "runtime": { "bridge": "vue-router", "global": "VueRouter", "identity": "singleton" } },
"pinia": { "version": "4.0.3", "exports": ["createPinia", "defineStore", "storeToRefs", ...], "runtime": { "bridge": "pinia", "global": "Pinia", "identity": "singleton" } },
"axios": { "version": "1.16.1", "exports": ["create", "Axios", "AxiosError", "isCancel", ...], "runtime": { "bridge": "axios", "global": "axios", "identity": "shared" } },
"@formkit/vue": { "version": "2.1.2", "exports": ["FormKit", "reset", "createInput", ...], "runtime": { "bridge": "formkit-vue", "global": "FormKitVue", "identity": "singleton" } },
"@formkit/core": { "version": "2.1.2", "exports": ["createNode", "getNode", "reset", "watchRegistry", ...], "runtime": { "bridge": "formkit-core", "global": "FormKitCore", "identity": "singleton" } }
}
}
几点值得展开的规格细节:
- 不暴露非共享依赖:快照不得把 VueUse、Tiptap、ProseMirror、其他 FormKit 子包或任意第三方依赖暴露为共享 specifier。这与
SHARED_PACKAGE_ROOTS的封闭集合互为表里。 - 记录的是"真实解析结果":当声明的依赖范围、workspace 链接或 lockfile 解析与包实际解析出的版本不一致时,快照必须记录实际 Halo UI 构建解析得到的版本,而不是声明范围、peer 后缀、workspace 协议或 link 协议。对应实现
resolveSharedPackage(runtime-snapshot.ts)会用pkg-types从 provider 根按browser/import/defaultconditions 解析出真实package.json并核对包名与版本。 - 导出必须来自真实浏览器产物:快照记录根导出必须从 Halo 浏览器运行时产物推导并核对,不能保留产物并不暴露的"合成导出"。校验逻辑在
validateSnapshotEntry(runtime-snapshot.ts):exports必须是去重的合法 JS 标识符数组,version必须是稳定 semver,runtime的 bridge/global/identity 都有各自的格式约束。 - 版本输出路径由 bundler-kit 版本推导:生成器运行时应从
@halo-dev/ui-plugin-bundler-kit的包版本推导 Halo 基线与带版本号的输出路径,且不得要求在每次包构建或 CI 中自动执行快照生成与校验(它是一次性的发布级动作)。
4.2 快照选择与"尽力而为"的兼容诊断
selectHaloHostRuntimeSnapshot(runtime-snapshot.ts)实现快照选择策略:
- Halo 构建宿主运行时桥时,若无精确匹配的快照文件,选择基线不晚于当前发布的最新快照;预发布版本按稳定核心版本比较"年龄",因此同核心的快照不会被误判为更旧。
- 快照不可变且稀疏,因此 Halo 某次发布若未改变共享版本事实、根导出或运行时桥行为,允许复用早前快照而不是发布重复内容。
Provider 端解析到的共享包版本与快照不一致时,规格给出的策略是尽力而为的诊断上下文而非兼容性证明或自动构建失败:
- provider 版本比宿主更新 -> 警告;
- 与宿主 major 不同 -> 更强警告。
对应代码在 ui/packages/ui-plugin-bundler-kit/src/shared-dependencies.ts:SharedDependencyValidator.getBuildReport() 会生成一个对齐的表格(package / provider / Halo host 三列),并在存在 major 差异或 provider 更新时附上 Compatibility notes 警告段。同时该文件里的 validateImport/shouldExternalize 会硬性拒绝共享包根的深路径导入(如 vue/xxx),错误信息明确提示"导入包根或改用 IIFE 输出"。
五、宿主自有的共享依赖解析:一个 Import Map 说了算
快照解决"该打包成什么版本"的问题,而浏览器运行时由宿主自有的 Import Map 裁决"vue 等共享根到底加载哪份 JS"。
5.1 时序约束:Import Map 必须先于一切模块
规格要求:Console / User Center 启动时,Halo 必须在任何消费共享 specifier 的宿主或 provider 模块解析之前安装 Import Map;provider import 共享包根时,浏览器必须解析到当前 Halo 发布所提供的运行时 URL。而插件/主题若自带对共享 specifier 的 Import Map 或映射,Halo 必须忽略或拒绝 provider 自有的映射——宿主独占浏览器映射权。
5.2 身份敏感依赖必须单例
Vue、Vue Router、Pinia、FormKit Vue、FormKit Core 这类身份敏感依赖,宿主与所有 ESM provider 必须使用同一份宿主模块身份与状态支撑的运行时模块。规格特别强调 FormKit:provider 同时 import @formkit/vue 和 @formkit/core 时,两条根都要解析到同一份 Halo 构建的 FormKit 模块图,从而 node 查找、表单提交、reset 等操作与宿主观察到同一个 Core registry。快照中 FormKit 两个根 identity: "singleton"、版本同为 2.1.2,正是这种"同一图"承诺的载体。即使 provider 自行 bundle 了其它 @formkit/* 包,只要它保留了 @formkit/core 的运行时导入,该导入也必须解析到宿主图而非 provider 私有的 Core 副本。
5.3 边界清单:什么共享、什么自包、Axios 的两张面孔
共享解析边界非常明确,逐条说清:
- VueUse 不共享:provider 源码 import VueUse,必须在 provider 自身产物里打包进去,不通过 Halo Import Map 解析。Legacy 场景里
window.VueUse兼容全局是另一回事(见第九节),但兼容全局不等于共享 ESM specifier。 - axios 共享的是"裸模块":provider import
axios拿到标准 Halo 供应的 Axios 模块,但不会隐式拿到@halo-dev/api-client内部配置过的那个 Axios 实例(它带有拦截器与会话逻辑)。 - 要带宿主认证与错误处理的实例需显式取用:provider import
axiosInstancefrom@halo-dev/api-client时,拿到的才是 Halo 单独创建的共享 API 实例。规格同时要求:普通 provider 客户端应使用axios.create()自建,而不是去改动共享实例的 defaults 或拦截器——避免跨 provider 污染共享实例。@halo-dev/api-client的快照导出列表确实同时包含axiosInstance、createConsoleApiClient、createCoreApiClient等条目,从数据面印证了这一分工。
六、带版本的 Provider Descriptor:单一响应描述一切 UI Provider
Provider Descriptor 是后端与前端之间的契约中心,规格要求用一份经过认证的响应描述当前所有 UI Provider,并复用既有静态资源映射,同时提供稳定的 catalog 与 provider 缓存键。
6.1 后端端点与"不暴露多余列表"原则
后端实现位于 application/src/main/java/run/halo/app/core/endpoint/console/UiPluginEndpoint.java:
.GET("ui-plugins/-/providers", this::fetchProviders,
builder -> builder.operationId("fetchUiPluginProviders")
.description("Fetch the currently enabled UI provider descriptor.")
.tag(tag)
.response(responseBuilder().implementation(UiPluginProviderDescriptor.class)))
.GET("ui-plugins/-/bundle.js", ...) // TODO(Halo 3): 兼容桥
.GET("ui-plugins/-/bundle.css", ...) // TODO(Halo 3): 兼容桥
fetchProviders 通过 UiPluginBundleService.getProviderDescriptor() 返回描述,且响应使用 CacheControl.noStore()——描述本身每次新鲜获取,不承诺上一响应内容的不可变性。规格对响应结构有强约束:
- 响应包含一份有序 provider 列表,每个被发现的 provider 恰好出现一次,附带 Halo 持有的身份、类型、安装版本、分类种类,以及种类对应的 entry / 启动样式 / 失效原因;
- 仅当存在至少一个 Legacy provider 时,才出现带 catalog 版本的 legacy script URL;
- provider 列表是发现、启动样式优先级与注册顺序的权威依据;
- 响应不得额外暴露一份独立的 catalog 版本、注册列表、样式表列表、ESM provider 列表或失效 provider 列表——所有信息都在 provider 记录内联。
该契约通过 OpenAPI 文档与自动生成的 TypeScript API 客户端进入前端:由后端 UiPluginV1alpha1ConsoleApi 标签描述的端点与 schema 会进入 api-docs/openapi 聚合文档,再生成到 ui/packages/api-client/src/api,Console 与 User Center 直接使用生成的 API 方法与模型(UiPluginV1alpha1ConsoleApi 等),不允许另写一份手工契约。
6.2 资源映射与缓存键
分类为 ESM 的 provider,其 entry URL 必须使用既有映射并带稳定缓存键:
- ESM 插件:
/plugins/{name}/assets/ui/映射(可选回退到历史/assets/console/),query 带插件专属稳定缓存键; - ESM 主题:
/themes/{name}/ui-plugin/assets/映射,query 带主题专属稳定缓存键。
细节约束:缓存键派生自 provider 的 Halo 管理身份与安装版本;Provider 记录不得暴露"仅为异步 chunk 生成的 CSS";每个启动样式 URL 使用既有 provider 静态资源映射,保证相对 CSS 资源从所属 provider 解析。
6.3 启动样式与 Legacy 聚合 CSS 兼容桥
启动样式直接挂在所属 provider 记录上。而 Halo 2.x 老客户端请求既有的聚合 CSS 端点时,端点作为兼容桥保留,但实现改为:输出有序的 CSS @import 规则引用各 provider 直链样式 URL,而不是把源码拼到聚合 API URL 下。这从 Java 端 /ui-plugins/-/bundle.css 的"TODO(Halo 3) 移除"注释可以印证——它只会在 Legacy IIFE 支持结束后删除。
主题还有一个联动场景:当激活主题的状态报告 UnsatisfiedRequiresVersion(与运行中的 Halo 版本不兼容)时,Halo 在读取或导入其 entry 之前就把该主题的 UI provider 判为 invalid,并把兼容性信息作为 invalid reason 暴露。
七、共享 UI Provider 注册 Store:格式无关的可用性与注册状态
Provider 之间、Provider 与宿主之间需要互相感知"谁在场、谁注册成功",但不能互相访问实现。答案是 @halo-dev/ui-shared 导出的共享 Pinia store stores.uiPlugins()。
7.1 数据模型与生命周期状态
实现在 ui/packages/shared/src/stores/index.ts:
export interface UiPluginRegistration {
name: string;
type: "plugin" | "theme";
version: string;
status: "pending" | "registered" | "failed";
}
export interface UiPluginsStore {
readonly registrations: readonly Readonly<UiPluginRegistration>[];
get(name: string): Readonly<UiPluginRegistration> | undefined;
isEnabled(name: string): boolean;
isRegistered(name: string): boolean;
}
/** @internal Host loader lifecycle actions; UI providers consume UiPluginsStore. */
export interface UiPluginsHostStore extends UiPluginsStore {
_seed(registrations: readonly UiPluginRegistration[]): void;
_setStatus(name: string, status: UiPluginRegistration["status"]): void;
}
生命周期被建模为三态:pending(合法 provider 的初始态)-> registered(模块注册提交成功)或 failed(描述发现阶段已判定失效、或加载/注册失败)。_seed 与 _setStatus 以下划线开头且注释明确"Host loader lifecycle actions",即只供宿主加载器调用;provider 侧对外只消费只读的 UiPluginsStore 接口。
7.2 关键语义
- 先填充再求值:Console / User Center 收到 Provider Descriptor 后,必须在求值任何 Legacy 或 ESM provider entry 之前填充共享 store;描述发现阶段已失效的 provider 直接以
failed起步。 isEnabled(name):响应式指示该 UI provider 是否出现在当前描述中,与 IIFE / ESM 格式无关;get(name)本质是"当前描述里是否有一份该名字的注册记录"。isRegistered(name):只有该 provider 的模块注册在当前页面提交成功后才为 true。这里有个贴心语义:被发现的 Legacy provider 即使没有 UI 模块,也被当作一次成功的兼容性 no-op,上报为registered——避免老插件因为"没有模块"而在新加载器里被误判失败。- 不允许跨 provider 读实现:provider 从共享 store 只能拿到身份、类型、版本与生命周期状态,拿不到别的 provider 的
PluginModule、路由、组件或可调用实现。规格明确:客户端侧的"变异逃生舱"不作为受支持 API,也不构成安全边界;registrations与宿主生命周期动作在类型与文档层面就是为 provider 只读消费设计的。
这与规格中"迁移 presence 检查"的诉求闭合:provider 若曾用 window[providerName] 或 window.enabledUiPlugins 判断在场,应改由 isEnabled/isRegistered/get 替代,且直接访问别的 provider 模块实现始终不受支持。
八、Legacy 与 ESM 混合加载:同一会话内各走各的协议
规格要求:在同一个 Console / User Center 会话内,Legacy IIFE 与 ESM UI Provider 可以共存,且无需重建任何 Legacy provider。
8.1 混合启动流程
初始化时,认证会话先拿到"provider 中性"的描述(含启用的插件 provider 与激活的主题 provider;未激活主题不参与)。随后启动分叉:
- Legacy 聚合:只要存在一个 IIFE provider,就通过描述里的带版本聚合 bundle 加载它们;聚合排除被分类为 ESM 的 provider;但聚合里的 legacy enabled-provider 元数据要包含每一个合法启用 provider,无论 IIFE 还是 ESM——保证老式 metadata 全局里"谁被启用了"仍然完整。
- ESM entry:存在 ESM provider 时,并行发起所有独立 entry import,并以
all-settled语义等待;provider 能自行加载其相对异步 chunk 与产物;单个 entry 落定后不刷新页面。 - 大规模并行:描述里有许多样式与 entry 时,Halo 会先启动每一个样式加载、每一个 ESM entry import 与 legacy script 加载,再等待任何一个资源落定;style 元素按 provider-list 顺序保留(与网络完成顺序无关),且不引入应用级请求批处理去串行化 provider 启动。
8.2 顺序稳定与样式失败隔离
异步 ESM import 完成顺序是不确定的,但 provider 模块最终按稳定 provider 顺序初始化;provider 不能依赖 ESM 求值顺序,也不能互相直接 import。ESM entry 求值完成后,其 default export 必须符合既有 PluginModule 形状,并走既有的插件模块初始化流程。
启动样式按 provider-list 顺序直接插入加载;某 provider 的样式失败只失败其所属 provider,不阻断无关 provider 与核心 UI 继续启动。若某 provider 的样式成功加载、但后续 entry / export / registration 失败,宿主只移除为该 provider 启动插入的那个 link(若页面本来就有同名匹配样式则保留)。
8.3 异步 CSS chunk
provider 后续 import 带 CSS 的 JS chunk 时,CSS 由 bundler 运行时按需从该 provider 静态资源映射加载,且 Halo 不会把它提前吞进启动聚合。
九、ESM Provider 故障隔离:一次失败不拖垮全局
宿主生命周期暴露隔离边界的地方,Halo 必须把 ESM provider 在发现、启动样式、import、求值、导出、注册、延迟 chunk 各环节的失败与其他 provider 和核心 UI 隔离开。
9.1 入口失败与版本拒绝
单个 ESM entry 在 fetch / link / evaluate / export 合法 PluginModule 任一环节失败,Halo 跳过该 provider,继续加载与初始化其它合法 provider,核心 UI 照常启动。若 provider 的 spec.requires 与运行中的 Halo 版本不兼容,则在 import entry 之前就拒绝,拒绝信息要同时点出 provider、其要求的 Halo 范围与当前 Halo 版本。多个 provider 同轮失败时,只弹一条面向用户的汇总通知,逐 provider 的结构化诊断保留给日志与管理 UI。
9.2 注册期回滚与路由恢复
provider 注册路由、组件、store、扩展等宿主集成时同步失败,规格要求非常细的回滚语义:
- 对支持撤销的变更,按逆序调用记录的移除/恢复句柄;
- 被失败 provider 替换的具名路由要恢复到先前注册的路由;
- Halo 管理的匿名父路由要有内部身份,使得其下被替换的具名子路由可被恢复;
- 若某路由挂在无法识别的匿名父路由下且替换成功,则保留"后注册胜出"的 Legacy 行为;
- 若替换之后跟随失败事务、无法可靠恢复,则诊断为"不完整的路由回滚",要求整页刷新,而不是假装在变更前就拒绝了 provider;
- 失败的 provider 之后,继续注册后续 provider;
- FormKit inputs 只从注册提交成功的 provider 收集;
- 无法逆转的变更一律诊断,并以整页刷新作为最终恢复边界。
这与整体生命周期哲学一致:非事务性的顶层副作用(定时器、事件监听、宿主注册表之外的异步工作)本就不承诺可回滚,可观测失败只做归属与诊断,恢复边界仍是整页刷新。
9.3 延迟 chunk 与"可恢复"细节
启动之后某个已接受 provider 的路由或异步组件 chunk 失败,要能归因到该 provider(路由/组件错误处理),尽量给出 provider 专属失败态,但不影响无关路由与 provider;嵌套异步组件错误沿组件实例父链继承 provider 归属;已解析懒路由在其解析组件不再匹配已注册 loader 时回退到当前匹配路由的 provider 归属。已成功注册的 provider 启动样式予以保留;普通后代渲染/事件错误不得被重新归类为 chunk 加载失败。Vue Router 识别的函数式组件不能被包装成异步 loader 而被破坏;provider 若注册了与 Halo 核心组件同名的全局组件,在核心组件与 FormKit setup 完成后仍保持"后注册胜出"的 Legacy 优先级。
十、Legacy UI Provider 兼容:Halo 2.x 的承诺与 Halo 3 的退出机制
规格要求在整个 Halo 2.x 生命周期内保留既有 IIFE UI provider 协议:
- 既有 IIFE 插件只要
spec.requires接受当前 Halo 版本,无需重建、无需新清单即可加载;既有 bundle 端点、全局模块注册、共享全局名、enabled-provider 元数据、ui到console资源回退全部保留。 - 只有样式、无 UI script 的 Legacy 主题,仍保留在
enabledUiPlugins兼容元数据中;提供 IIFE UI 模块的主题继续走 Legacy 聚合与全局模块协议。 - 每次升级 Legacy 共享依赖前,运行冻结的 legacy fixtures 与维护中的生态用法样例;保留已知被使用的导出或在可行处补聚焦的兼容行为;文档明确上游行为兼容是 best-effort,不逐 provider 保证。
window.VueUse兼容全局在 Halo 2.x 保留,但这不使 VueUse 成为共享 ESM specifier。- 纯为维持 IIFE 全局、metadata 全局、聚合别名、资源回退、bundler 全局映射、global-backed ESM bridges 而写的代码,一律带"Halo 3 移除"注释;导出的 legacy 声明尽可能使用语言级 deprecation 标记。Java 端
/ui-plugins/-/bundle.js、/bundle.css上的TODO(Halo 3)注释即为此规则的后端实例。
十一、生命周期与缓存边界:整页刷新是唯一模块替换边界
安装、升级、启用、停用或激活 provider 后,Halo 不热卸载、不热替换正在运行的模块,而是要求或提示整页刷新。若 provider 在页面模块图启动之后发生变化,旧响应不承诺不可变内容,也不保留 provider 资源的副本;刷新后拿到的是按最新状态重新版本化的描述。
缓存键规则随产物来源分两种约定:
- 默认 presets 产物(内容哈希命名):entry 与启动样式文件名含内容派生哈希,描述 URL 直接使用清单选择的 provider 相对路径、不追加 query 缓存键;异步 chunk 走 provider 相对内容哈希 URL;Legacy 聚合 URL 以当前 catalog 版本为缓存键;provider 发现元数据被重新校验以反映当前启用的 provider。另有"chunk import provider entry"的反重复坑位约定:chunk 引用与描述 entry 必须解析到同一规范 URL,浏览器不会因为 Halo 追加了 query 缓存键而二次 fetch / evaluate entry。
- 覆盖命名/自定义产物:若调用方 bundler 配置或 hooks 用稳定或自定义文件名输出,Halo 不重写这些资源、也不给 ESM entry 与启动样式 URL 追加 query 缓存键——此时缓存失效由 provider 开发者自己负责。
- 开发产物:provider 反复被描述但直载构建输出未变时,manifest 选定的 entry 与启动样式 URL 保持不变;当 manifest、entry 或启动样式变化时,内容哈希启动文件名与 catalog 版本一起变化,而其它未变化 provider 的直链 URL 不受牵连。
十二、开发与打包双拓扑一致性
ESM UI Provider 在两种拓扑下都必须可用,这直接决定了开发调试体验与生产发布形态:
- 代理开发拓扑:开发者经 Halo 开发服务器打开 Console/User Center 路由、同时独立运行 UI 开发服务器时,代理返回的 HTML 必须在模块 entry 之前包含开发 Import Map;Halo UI 运行时模块从 UI 开发服务器加载;API 与插件/主题 provider 资源仍从 Halo 后端加载。打开深层嵌套路由时,代理后的 entry、Import Map、共享运行时模块、provider entry 与 provider chunk 都必须能解析——不依赖 UI 开发服务器作为 document origin。
- 打包生产拓扑:Halo 以构建产物(BootJar)运行、没有 UI 开发服务器时,打包后的 Console / User Center HTML 把共享依赖映射到打包的内容哈希运行时资源,所有宿主与 provider 运行时资源均不带任何开发服务器 URL。
这正是源码目录里 console.html、uc.html 与 ui/src/setup(setupApiClient、setupModules 等)各司其职的原因:HTML 入口负责注入 Import Map,setup 层负责按描述填充 store 并驱动混合加载器。
十三、从契约到实现:一份可继续深入的地图
如果你希望沿着本文继续深入源码,推荐按如下顺序浏览(全部相对仓库根目录):
- 规格主体:openspec/specs/ui-plugin-esm-runtime/spec.md 是权威版规格;本次归档的变更文档在 openspec/changes/archive/2026-08-07-support-esm-ui-plugins,其中 proposal.md 概述动因、design.md 与 tasks.md 记录设计与落地任务。
- 构建侧(provider 产物如何生成):ui/packages/ui-plugin-bundler-kit,重点看
provider-manifest.ts、runtime-snapshot.ts、shared-dependencies.ts、runtime-snapshots/halo-2.26.0.json,以及vite-esm.ts/rsbuild-esm.ts/legacy.ts反映的 ESM 与 IIFE 双模式;provider 侧的规格见 openspec/specs/ui-plugin-bundler-provider/spec.md。 - 后端侧:UiPluginEndpoint.java 是描述端点与聚合兼容端点所在;UiPluginBundleServiceImpl.java 承载描述组装与缓存键派生,主题侧关联 ThemeUiResources.java。
- 前端运行时:ui/packages/shared/src/stores/index.ts 定义
stores.uiPlugins();描述 API 模型由 ui/packages/api-client/src/api 的生成结果提供(含UiPluginV1alpha1ConsoleApi等导出,可见于快照 exports 列表);console.html/uc.html与 ui/src/setup 承载宿主启动装配。
结语
把视角拉回整体设计,这套 ESM UI Provider 运行时真正的价值在于三处取舍的平衡:用"不可变稀疏快照 + 尽力而为版本诊断"取代脆弱的依赖范围承诺;用"宿主独占 Import Map + 单份 Provider Descriptor + 共享注册 store"把模块身份、发现顺序与生命周期状态的管辖权牢牢握在宿主手中;用"Legacy 与 ESM 双轨协议 + 整页刷新恢复边界 + Halo 3 移除注释"让演进对既有生态完全无损。对于插件/主题开发者,最需要记住的三条操作规则是:无 ui-plugin.json 即为 Legacy;共享根只能整根导入、深路径会被拒绝,VueUse 等非共享依赖要打进自己的产物;判断"某个 UI 是否在场/注册成功"请用 stores.uiPlugins() 的 isEnabled/isRegistered,而不要访问其它 provider 的模块实现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00