插件能安装却不能保存:宿主接口与版本兼容
开发桌面应用的插件(扩展、主题),常会遇到这样的情况:
插件安装成功了,设置页也能打开,点了保存,界面提示成功——但设置其实没有生效,重启后一切照旧。
这里面至少有三件不同的事:包管理器接受了依赖范围、宿主提供了对应的能力、宿主接受并持久化了修改。任何一件没做到,“保存”都不算真正完成。
本文取材于一个桌面主题扩展在 2026 年 9—10 月的接口迁移,核对日期为 2026-10-23。只讨论通用的插件工程,不复制项目的配置、素材或宿主的内部实现。
读完这篇你会
- 分清插件版本、开发依赖版本和宿主兼容范围
- 把“调用结束”“写入被接受”“当前值已确认”分开验证
- 理解分层配置下“恢复默认值”的正确含义
- 避开 semver 预发布版本范围的误读,并核对实际加载的产物
1. 原理:插头、电压和开关
把插件接入宿主,就像把一台电器接上电:
- 插头能插进插座(包管理器检查依赖范围通过、插件能安装)——不代表电器能工作;
- 电压匹配(宿主真的提供了插件需要的能力和接口)——不代表电器已经在运行;
- 按下开关、灯真的亮了(宿主接受了这次修改,并且持久化保存)——这才算真正完成。
很多“装得上、存不住”的问题,都是只验证了第一步,就以为全部完成了。
2. 先把三种版本分开
| 版本 | 表达什么 | 不能代替什么 |
|---|---|---|
| 插件版本 | 这次插件发布的身份 | 宿主的版本 |
| 开发依赖版本 | 编译时使用的类型定义和工具 | 用户实际的运行环境 |
| 宿主兼容范围 | 声明允许和哪些宿主版本协作 | 真实的兼容验证 |
peerDependencies 用来表达和宿主或其他包的兼容关系,不能把“安装检查通过”当成运行验收。npm package.json
3. 坑一:只放宽了兼容范围,没有迁移接口
现象:宿主升级后插件报“不兼容”,于是把 peerDependencies 的范围放宽,安装通过了,但设置页显示为空,或者保存没有任何效果。
原因:宿主的新版本换了一套设置接口。只改兼容声明,并不会自动完成接口的迁移。
修复:近期的修复把开发依赖对齐到新的宿主 SDK,同时放宽了原先过窄的兼容声明;实际代码也一起调整了注入的服务、配置的命名空间和宿主侧的注册方式。
这次迁移从旧的“设置绑定”接口,转向宿主提供的“配置表单”能力:客户端按命名空间获取表单,订阅权威的快照,再根据当前的状态判断能否编辑和保存。整条链要作为一个整体核对:
text
宿主注册配置定义
↓
客户端取得对应命名空间
↓
加载状态与可写能力
↓
草稿 + 基线版本 → 提交修改
↓
宿主接受结果 + 权威快照 → 确认保存只替换一个方法名,可能留下另一端没有注册、命名空间不一致、或者连接只有只读权限的问题。当前的界面分别显示“加载中”“连接能力不足”“保存结果未确认”和“冲突”,而不是一律显示空白的设置。
4. 坑二:Promise 完成了,就显示“保存成功”
现象:调用保存方法后 Promise 正常结束,界面显示“已保存”,实际上宿主拒绝了这次修改。
原因:Promise 完成,只代表调用结束了,不代表宿主接受了修改,更不代表当前的值就是你提交的值。
修复:近期的修复会显式检查修改操作的“接受结果”,然后再次读取宿主的快照,确认它处于可以确认的状态、并且值和提交的目标一致,才更新页面的基线。
通用的教学伪代码:
js
const accepted = await form.write(changes, baseRevision);
if (!accepted) throw new Error('WRITE_REJECTED');
const current = form.snapshot();
if (!current.authoritative || !sameValues(current.value, submitted)) {
throw new Error('WRITE_UNCONFIRMED');
}这里的 API 名字只是示意。重点是把“调用结束”“写入被接受”“当前值已确认”分开,而不是建议读者复制一个宿主专用的函数。
当前的设置页还区分了“草稿”和“已保存的基线”。其他窗口更新了设置时:本地没有修改,就直接同步;本地有未保存的草稿,就保留草稿并提示冲突,是否重新加载由用户明确选择。这和 编辑会话与本地草稿 中“确认的是哪一份快照”是同一个道理。
5. 坑三:恢复默认值后,以后再也跟不上默认值的变化
现象:用户点了“恢复默认”,看起来一切正常。后来宿主更新了某项的默认值,这个用户的设置却一直停在旧的默认值上。
原因:在分层配置里,用户的值会覆盖宿主或插件的默认值。如果“恢复默认”的实现是“把一组默认常量写进用户设置”,那么用户设置里就多了一份和默认值一样的覆盖值。将来默认值变了,这个覆盖值依然生效,用户被旧值锁住了。
修复:当前的重置操作是删除用户的覆盖键,再确认这些键确实已经不存在了,而不是只比较界面上显示的值是否等于默认值。
这是配置语义的问题:只有宿主确实采用“继承与覆盖”的模型时,删除覆盖才有这个含义,不能推广到所有设置系统。
6. 坑四:以为放宽了范围,就兼容所有预发布版本
现象:兼容范围写的是 >=1.4.0-rc.1 <2.0.0,在宿主的 1.5.0-rc.1 测试版上却提示不兼容。
原因:默认的 node-semver 匹配规则会限制预发布版本:范围里显式包含了某个版本号的 rc,并不表示后续小版本的所有 rc 都会匹配。node-semver 预发布规则
下面是独立的合成示例,统一使用范围 >=1.4.0-rc.1 <2.0.0,结果已在本地用 node-semver 核对过(没有加载目标宿主):
| 被判断的版本 | 默认的匹配结果 |
|---|---|
1.4.0-rc.2 | 匹配 |
1.5.0-rc.1 | 不匹配 |
1.5.0 | 匹配 |
规则可以理解为:预发布版本只有在和范围中某个预发布版本的主、次、修订号完全相同时才会被考虑。所以,源码里“放宽了范围”和“覆盖了所有桌面预发布运行时”不能画等号。建议用实际发布过的目标版本逐个测试;如果宿主使用了特殊的匹配参数,也要明确记录,不能默认它开启了预发布兼容选项。
7. 坑五:改了源码,用户加载的还是旧代码
现象:仓库里的代码已经修好了,用户安装新版本后问题依旧。
原因:当前的扩展把宿主入口和客户端入口分别构建,并把部分宿主共享的依赖设为 external;包的入口指向生成后的文件。只改了源代码、没有更新实际的入口产物,就会出现“仓库看起来修好了,用户加载的还是旧代码”。
修复:发布前核对源代码、类型检查、构建结果、导出路径和包内容是否一致,并确认运行时由谁提供 external 依赖。能在开发目录里运行,不等于安装包里包含了全部必要的文件。可以用 npm pack 生成安装包,解开后检查里面实际包含的文件。
这里总结的是现有的构建模式,本次没有实际执行这个扩展的安装或桌面兼容矩阵,也没有重新分发它的素材。
排查思路
- 装得上、存不住:按“插头 → 电压 → 开关”的顺序检查:依赖范围、宿主是否提供了对应的能力和命名空间、写入是否被接受、快照里的值是否和提交的一致。
- 只在某个宿主版本出问题:用 semver 工具核对这个版本是否真的在兼容范围内,特别注意预发布版本。
- 修复后问题依旧:检查用户实际加载的入口文件,确认它是新构建的产物。
- 默认值不跟随更新:检查用户配置层里是否残留了覆盖键。
建议验证矩阵
- [ ] 最低支持版本、常用的稳定版本和目标预发布版本分别加载测试
- [ ] 宿主和客户端能力缺失时,给出可以理解的“不可用”状态
- [ ] 修改被拒绝或返回不一致的快照时,不显示保存成功
- [ ] 两个窗口同时编辑,失败的一方仍然保留草稿
- [ ] 重置后用户覆盖层消失,而不只是看起来值相同
- [ ] 实际打包产物中的入口、依赖和源码版本一致