三个版本的插件同时在线
某效率产品把「扩展能力」做成插件:网页端装浏览器扩展,桌面端装本地插件,移动端装轻量脚本。上线一年后,插件市场里有 47 个插件、9 个宿主版本,问题开始集中出现。
- 一个插件在宿主 4.0 上白屏,因为清单里只写了
hostApi: "4",没人知道它依赖的是 4.0 还是 4.2。 - 同一个插件 id 被两个团队各发了一版,版本号都是 1.0.0,商店里出现两个「1.0.0」。
- 插件声明了
camera能力,但桌面端根本没有摄像头,宿主没有拒绝安装,运行时才报错。
这三个问题看起来分别是版本、发布、能力问题,根因是同一个:清单没有被当成协议。它被当成一份「填了就行」的配置文件,宿主也没有校验。
清单是协议,不是配置文件
插件清单至少要回答四个问题:我是谁、我依赖哪个宿主、我需要什么、我能做什么。少一个,宿主就只能靠约定和运气。
| 字段 | 作用 | 校验规则 | 缺失后果 |
|---|---|---|---|
id | 全局唯一身份 | 反向域名、小写 | 两个插件互相覆盖 |
version | 插件自身版本 | 严格 x.y.z | 升级无法比较 |
hostApi | 宿主 API 区间 | min/max 合法且 min <= max | 装到不兼容宿主 |
capabilities | 声明所需能力 | 必须在白名单内 | 运行时才发现缺能力 |
name | 展示名 | 非空 | 商店无法展示 |
id 用反向域名不是为了好看,而是为了把「归属」写进字符串。com.acme.notes 一眼能看出归属,也能防止两个团队都叫 notes。
宿主 API 区间而不是单点版本
插件写 hostApi: "4.2.0" 是最常见的错误。它表达的是「我只在 4.2.0 上测试过」,宿主却会理解成「只兼容 4.2.0」。正确写法是一个闭区间:
hostApi:
min: "1.4.0"
max: "1.9.9"
区间两端都算兼容。宿主版本低于 min 时,是宿主太旧;高于 max 时,是插件太旧。这两种情况的处理方式不同:
| 判定 | 含义 | 宿主动作 |
|---|---|---|
compatible | 落在闭区间内 | 允许安装 |
host-too-old | 宿主低于 min | 提示升级宿主,不装 |
host-too-new | 宿主高于 max | 提示插件待适配,不装 |
invalid | 任一侧版本非法 | 拒绝安装并上报 |
把「太旧」和「太新」分开,是为了让提示能指向正确的升级方向。统一报「不兼容」,用户和开发者都不知道该动哪一边。
版本比较也不能用字符串。"1.10.0" < "1.9.0" 在字符串里成立,因为字符 1 小于 9;在语义化版本里,10 大于 9。必须逐段解析成数字再比较。
能力声明:声明的是需求,不是权限
capabilities 容易被写成「我能提供什么」,这会导致宿主无法判断缺什么。它应该声明「我需要宿主提供什么」:
| 能力 | 典型用途 | 宿主可拒绝的端 |
|---|---|---|
camera | 扫码、拍照 | 桌面端 |
location | 附近内容 | 桌面端、部分 Web |
network | 请求外部服务 | 离线模式 |
storage | 本地持久化 | 无 |
clipboard | 复制结果 | 部分浏览器 |
analytics | 埋点上报 | 隐私模式 |
能力声明与权限授予是两件事:前者是安装前的静态检查,后者是运行时的动态授权。3.2 会展开权限模型,这里只需要记住:安装时先算缺口,不要等运行时抛错。
declaredCapabilityGaps 返回「声明了但宿主没有」的能力。缺口非空时,宿主应该阻止安装或在商店里标注「当前端不可用」。
把协议写成代码
下面这个模块把清单校验、版本比较、兼容判定与能力审计都做成纯函数。它不依赖任何宿主框架,可以同时跑在安装器、构建脚本和测试里。
// file: src/plugin/plugin-protocol.ts
// tier: L1
// verify: node --test test/plugin/plugin-protocol.test.ts
export type Capability =
| "camera"
| "location"
| "network"
| "storage"
| "clipboard"
| "analytics";
export type ApiVersion = { major: number; minor: number; patch: number };
export type PluginManifest = {
id: string;
name: string;
version: string;
hostApi: { min: string; max: string };
capabilities: Capability[];
};
export type ManifestResult =
| { ok: true; manifest: PluginManifest; warnings: string[] }
| { ok: false; errors: string[] };
export type Compatibility =
| { status: "compatible"; hostVersion: string }
| { status: "host-too-old"; required: string; hostVersion: string }
| { status: "host-too-new"; supported: string; hostVersion: string }
| { status: "invalid"; reason: string };
export const KNOWN_CAPABILITIES: Capability[] = [
"camera",
"location",
"network",
"storage",
"clipboard",
"analytics",
];
const ID_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
export function parseApiVersion(input: string): ApiVersion | undefined {
const match = SEMVER_PATTERN.exec(input);
if (!match) return undefined;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}
export function compareApiVersion(a: ApiVersion, b: ApiVersion): number {
if (a.major !== b.major) return a.major < b.major ? -1 : 1;
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
return 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function validateManifest(raw: unknown): ManifestResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!isRecord(raw)) return { ok: false, errors: ["清单必须是对象"] };
const id = raw.id;
if (typeof id !== "string" || !ID_PATTERN.test(id)) {
errors.push("id 必须是反向域名格式,例如 com.acme.notes");
}
const name = raw.name;
if (typeof name !== "string" || name.trim() === "") {
errors.push("name 不能为空");
}
const version = raw.version;
if (typeof version !== "string" || !SEMVER_PATTERN.test(version)) {
errors.push("version 必须是 x.y.z 形式的语义化版本");
}
const hostApi = raw.hostApi;
let min: ApiVersion | undefined;
let max: ApiVersion | undefined;
if (!isRecord(hostApi)) {
errors.push("hostApi 必须包含 min 与 max");
} else {
if (typeof hostApi.min !== "string") {
errors.push("hostApi.min 必须是字符串");
} else {
min = parseApiVersion(hostApi.min);
if (!min) errors.push("hostApi.min 不是合法版本");
}
if (typeof hostApi.max !== "string") {
errors.push("hostApi.max 必须是字符串");
} else {
max = parseApiVersion(hostApi.max);
if (!max) errors.push("hostApi.max 不是合法版本");
}
}
if (min && max && compareApiVersion(min, max) > 0) {
errors.push("hostApi.min 不能大于 hostApi.max");
}
if (min && max && min.major !== max.major) {
warnings.push("宿主 API 区间跨主版本,插件需要分别验证");
}
const capabilities: Capability[] = [];
const rawCapabilities = raw.capabilities;
if (!Array.isArray(rawCapabilities)) {
errors.push("capabilities 必须是数组");
} else {
for (const item of rawCapabilities) {
if (typeof item !== "string" || !KNOWN_CAPABILITIES.includes(item as Capability)) {
errors.push(`未知能力: ${String(item)}`);
continue;
}
const capability = item as Capability;
if (capabilities.includes(capability)) {
warnings.push(`能力重复声明: ${capability}`);
continue;
}
capabilities.push(capability);
}
}
if (errors.length > 0) return { ok: false, errors };
const hostRange = hostApi as { min: string; max: string };
return {
ok: true,
manifest: {
id: id as string,
name: (name as string).trim(),
version: version as string,
hostApi: { min: hostRange.min, max: hostRange.max },
capabilities,
},
warnings,
};
}
export function checkCompatibility(
manifest: PluginManifest,
hostVersion: string,
): Compatibility {
const host = parseApiVersion(hostVersion);
if (!host) return { status: "invalid", reason: "宿主版本不是合法语义化版本" };
const min = parseApiVersion(manifest.hostApi.min);
const max = parseApiVersion(manifest.hostApi.max);
if (!min || !max) {
return { status: "invalid", reason: "插件声明的宿主版本区间非法" };
}
if (compareApiVersion(host, min) < 0) {
return {
status: "host-too-old",
required: manifest.hostApi.min,
hostVersion,
};
}
if (compareApiVersion(host, max) > 0) {
return {
status: "host-too-new",
supported: manifest.hostApi.max,
hostVersion,
};
}
return { status: "compatible", hostVersion };
}
export function declaredCapabilityGaps(
manifest: PluginManifest,
hostCapabilities: Capability[],
): Capability[] {
return manifest.capabilities.filter(
(capability) => !hostCapabilities.includes(capability),
);
}
export type CapabilityAudit = { undeclared: Capability[]; unused: Capability[] };
export function auditCapabilities(
manifest: PluginManifest,
used: Capability[],
): CapabilityAudit {
const undeclared = used.filter(
(capability) => !manifest.capabilities.includes(capability),
);
const unused = manifest.capabilities.filter(
(capability) => !used.includes(capability),
);
return { undeclared, unused };
}
export type ConcernOwner = "host" | "plugin" | "shared" | "unknown";
const CONCERN_OWNERS: Record<string, ConcernOwner> = {
"lifecycle": "host",
"route-registration": "host",
"permission-prompt": "host",
"crash-report": "host",
"business-ui": "plugin",
"data-transform": "plugin",
"api-client": "shared",
"storage-schema": "shared",
};
export function ownerOf(concern: string): ConcernOwner {
return CONCERN_OWNERS[concern] ?? "unknown";
}
代码里有三个刻意设计:
validateManifest接收unknown,因为它处理的是外部输入,不能假设调用方已经校验过。- 重复能力只是警告,不阻断安装;未知能力是错误,因为它意味着宿主无法兑现。
auditCapabilities同时返回未声明与未使用,前者是越权,后者是最小授权可以裁剪的空间。
判据与反例
判断一份清单是否合格,不看字段是否填满,而看四个判据:
- 唯一性:
id全局唯一,且与发布者归属一致。 - 可比较:
version与hostApi都能被解析和排序,不依赖字符串比较。 - 可拒绝:宿主能在安装前算出
host-too-old、host-too-new或能力缺口。 - 可收敛:区间有明确上下界,不会出现「永远兼容」的清单。
两个反例:
第一个反例是把 max 写成 99.0.0,表示「永远兼容」。这不是兼容性声明,而是放弃声明。宿主升级后如果出现破坏性变更,插件会在最坏的时候失败。
第二个反例是用插件版本号代替宿主 API 区间。插件从 1.0 升到 2.0,不代表宿主也要升到 2.0;两个版本轴必须分开。
责任边界:宿主必须自己拿住的东西
插件化的常见误解是「把功能都交给插件,宿主只做容器」。容器也要负责容器该负责的事:
| 关注点 | 归属 | 理由 |
|---|---|---|
| 生命周期 | 宿主 | 插件不能决定自己何时被卸载 |
| 路由注册 | 宿主 | 路由冲突需要全局裁决 |
| 权限弹窗 | 宿主 | 授权界面不能被插件伪造 |
| 崩溃上报 | 宿主 | 插件崩溃时可能已无法上报 |
| 业务 UI | 插件 | 这是插件存在的价值 |
| 数据转换 | 插件 | 属于业务逻辑 |
| API 客户端 | 共享 | 协议由宿主定,调用由插件发起 |
| 存储 schema | 共享 | 写入方是插件,迁移责任在宿主 |
ownerOf 把这些归属写成可查询的表。它不是教条,而是评审时的默认答案:如果某个关注点被放到了错误的一侧,评审至少要给出理由。
一次清单事故的复盘
回到开头的白屏。复盘时发现,插件作者在本地用的是宿主 4.2,清单里写了 hostApi: "4",宿主解析器把 "4" 当成非法版本,走了「宽松模式」直接放行。于是三个端的行为不一致:Web 拒绝安装,桌面放行后崩溃,移动端根本没读这个字段。
修复分三步:
- 清单校验失败即拒绝,不再有「宽松模式」。
- 宿主版本与 API 版本分离,
hostApi只描述 API 区间。 - 商店把
host-too-old与host-too-new展示成不同的提示。
事故的直接原因是解析器,但根因是协议允许了模糊表达。只要协议允许模糊,实现就一定会分歧。
小结
插件协议的本质是把「谁能做什么」变成可校验的数据。
- 清单的四个核心字段是身份、插件版本、宿主 API 区间与能力声明,缺一不可。
- 宿主 API 用闭区间表达,两端都兼容;「太旧」和「太新」要分开处理。
- 能力声明描述需求,安装前算缺口,不要等运行时抛错。
- 生命周期、路由、权限弹窗、崩溃上报必须留在宿主,业务 UI 留给插件。
协议先于插件。没有协议的插件市场,只是一个更大的单体。
阅读导航:上一节:2.3 多品牌与多业务复用 · 下一节:3.2 沙箱、权限与失败隔离 。
常见错误与边界
- 把
hostApi写成单点版本。 单点版本无法表达兼容范围,宿主升级一次就要改一次清单。 - 用字符串比较版本号。
"1.10.0" < "1.9.0"在字符串里成立,在语义化版本里不成立。 - 把能力声明当权限。 声明只解决安装前检查,运行时仍需 3.2 的授权模型。
- 重复能力当错误。 重复通常来自合并清单,警告并去重即可,阻断安装收益很低。
- 让插件注册路由。 两个插件抢同一路径时,宿主必须有裁决权,否则行为取决于加载顺序。
- 跨主版本的宿主区间不给警告。 跨主版本意味着插件要在两套 API 上验证,不能静默通过。
- 忽略未使用的能力声明。 未使用能力是最小授权的裁剪信号,长期不清理会扩大攻击面。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。