卷 V · 变CH 21深度 21/24

一千个插件的冷启动

插件系统有一个几乎必然的命运:它会越来越慢。而且慢得没有元凶 —— 每个插件都只慢一点点,加起来就是用户盯着转圈的十二秒。这一章把这笔账算清楚,然后给出唯一有效的解法。有意思的是,那个解法你在第 7 章就已经造好了一半。

懒激活activationEvents启动预算可观测

先算账

假设装了 1000 个插件,每个插件的 activate() 平均要 12ms —— 这不算慢,就是读个配置、建个索引、注册几样东西。

调一下「本次会话真正用到的插件数」,看两种策略的差距:

默认那一档(用户实际用到 5 个)的结果:

① 全量激活   1000 × 12ms = 12000ms = 12.0 秒
② 懒激活     读 1000 份清单(≈ 20ms)+ 5 × 12ms = 80ms

快了 150 倍。而且那 80ms 还分散在用户的操作之间,感知上等于零。

注意「用到 100 个」那一档:仍然快 9.8 倍。懒激活不是只在极端情况下有用 —— 只要「装了的」比「用到的」多,它就一直在赚。

◆ 这笔账的关键不在倍数,在「谁在等」

12 秒和 80ms 的差别,不只是一个性能数字。

全量激活的 12 秒,是用户什么都做不了、盯着启动画面的 12 秒。这是软件里最贵的时间 —— 用户此刻还没得到任何价值,只是在付出。

懒激活的 60ms,分散在用户主动触发某个功能之后。这时候用户有明确的期待,而且 12ms 根本感知不到。

同样的总计算量,放在不同的时刻,用户体验差了一个数量级。这就是「推迟」的全部价值 —— 它甚至不需要让总工作量变少。

懒激活的前提:宿主必须先知道「什么时候需要谁」

推迟听起来简单,但它有一个硬前提:宿主要在不执行插件代码的情况下,知道这个插件在什么条件下才需要被唤醒。

这正是第 7 章那份清单的第二个理由(第一个是安全):

{
  "contributes": {
    "commands": [{ "id": "wc.count", "title": "统计字数" }]
  },
  "activation": ["onCommand:wc.count"]
}

宿主读完这几行就能做两件事:

  • 立刻把「统计字数」放进命令面板(用户看得见、搜得到、能配快捷键);
  • 不加载这个插件的任何代码,直到用户真的点下去。

第 8 章那个「只有壳没有肉」的命令,做的就是这件事。宿主在 runCommand 里发现这条命令没有实现,才去激活对应的插件 —— 整个懒激活机制一共十几行。

VS Code 的激活事件:这个思路的成熟形态

VS Code 把这件事做得最彻底。它的激活事件覆盖了各种「什么时候才需要你」的场景:

激活事件什么时候触发典型用途
onLanguage:python打开了一个 Python 文件语言支持插件
onCommand:foo.bar用户执行了这条命令命令类插件
workspaceContains:**/.editorconfig工作区里有匹配的文件只对特定项目有用的插件
onView:myTreeView用户展开了这个侧边栏视图树视图插件
onDebug开始调试调试适配器
onUri有外部链接唤起OAuth 回调之类
onStartupFinished启动完成之后的某个时刻确实需要常驻但不急的插件
*启动时立刻官方文档明确劝退

两个设计细节值得学:

onStartupFinished —— 给「必须常驻」的插件一条体面的路

总有插件确实需要在后台一直跑(同步服务、状态监控)。如果只有 * 这一个选项,它们就会全部堵在启动路径上。

onStartupFinished 说的是:「我确实要启动,但不急,等 UI 出来之后再说。」它把这些插件从「用户等待的关键路径」挪到了「用户已经能开始工作的空闲时段」 —— 总工作量没变,但感知上的启动时间大幅下降。

这个「不急的常驻」档位,是很多人设计激活机制时会漏掉的一个,但它能救回一大批本来只能写 * 的插件。

② 自动生成激活事件

从 VS Code 1.74 起,命令、语言、视图、自定义编辑器这些贡献点会自动生成对应的激活事件,插件作者不用写。

逻辑很直白:宿主已经从 contributes 里知道「这个插件提供了 foo.bar 命令」,何必再让作者手抄一遍 "onCommand:foo.bar"

这是一个小改动,但它消灭了一整类 bug(清单里写了命令但忘了写激活事件 → 命令面板里有、点了没反应),而且是从根上消灭 —— 不是靠校验器提醒,是靠让那个信息不需要被写第二遍。

◆ 一条通用的 API 设计原则

同一个事实,永远不要让作者写两遍。

只要一个信息在两个地方各写一次,它们就一定会不一致 —— 不是「可能」,是「一定」,只是时间问题。

正确的做法有两个方向:要么让第二处从第一处推导出来(VS Code 的自动激活事件),要么让校验器强制它们一致(第 7 章那个「说了没做 / 做了没说」的检查)。

第一个方向永远更好,因为它让错误不可能发生,而不是「发生了能被发现」。

还有一类更隐蔽的慢:没有元凶的慢

第 15 章末尾提过,这里展开。

假设你的渲染流程会依次调用所有 transform 插件。装了 20 个,每个 8ms,那就是 160ms —— 每次渲染都掉帧

但没有任何一个插件「有问题」。每个作者都会说「我这才 8ms 啊」,而且他说得对。

这类问题只能从扩展点的形状上解决:

做法一:让插件声明它关心什么

// ✗ 每次渲染都问所有插件
transforms.forEach(t => { text = t.fn(text); });

// ✓ 插件声明它只关心某类内容,宿主只调相关的
ctx.ui.addTransform('mermaid 渲染', fn, { appliesTo: 'code-block:mermaid' });
// 渲染一段普通文本时,这个插件根本不会被调用

这需要你在扩展点里设计一个「过滤条件」的概念。VS Code 的 when 子句、Chrome content script 的 matches,都是同一个思路:让宿主能在不调用插件的情况下,判断它这次要不要参与。

做法二:把插件移出热路径

更彻底的做法:渲染时不调插件,改成插件主动推送。

// ✗ 拉取模型:渲染时同步问插件要结果(插件慢 = 渲染慢)
render() { return transforms.reduce((t, f) => f(t), text); }

// ✓ 推送模型:插件异步算好了推给宿主,宿主渲染时只读缓存
ctx.onNoteChanged(async (note) => {
  const result = await heavyCompute(note);
  ctx.ui.setDecoration(note.id, result);      // 算好了再推
});

推送模型的代价是「结果可能滞后一帧」,收益是插件再慢也不会卡住渲染。对大多数装饰性功能(语法高亮、行内提示、图标),这个交换非常划算 —— 这也是 VS Code 的装饰、CodeLens、诊断信息全都是异步推送的原因。

启动预算:把它变成一个能守住的数字

光有懒激活还不够,因为总有插件要在启动时跑。你需要一个预算,和一套能守住它的机制。

① 测量每个插件的激活耗时,并展示出来

VS Code 有一个「扩展运行状况」界面,列出每个扩展的激活耗时。这个功能的价值有三层:

  • 用户能看到是谁拖慢了启动,自己决定要不要禁用;
  • 插件作者能看到自己的排名,社会压力比任何文档都有效;
  • 能拿到全生态的数据,知道该优化哪个 API。

② 给激活设超时和警告线

const t0 = performance.now();
await plugin.activate(ctx);
const ms = performance.now() - t0;

if (ms > 100) {
  log.warn(`[慢] 插件 ${id} 激活耗时 ${ms.toFixed(0)}ms(建议 < 100ms)`);
  telemetry.slowActivation(id, ms);
}

在开发模式下把这条警告打得很显眼,大部分作者会自己去修 —— 性能问题绝大多数是无心的,作者根本不知道自己慢。

③ 给「启动时激活」设一道门槛

如果一个插件声明了 *(启动即激活),可以要求它在清单里写明理由,或者干脆在商店审核时人工过一遍。

让「拖慢所有人」这件事需要付出一点摩擦,比事后追着优化有效得多。

⚠ 懒激活会带来两个新问题,要提前想好

① 首次触发时的延迟。用户第一次点那条命令,要等插件加载 —— 如果插件加载要 500ms,用户会觉得「这个功能好卡」。
解法:给激活加一个 loading 反馈;或者在空闲时预热那些「很可能会用到」的插件(比如上次会话用过的)。

② 插件作者会误解生命周期。有作者会假设 activate() 在启动时就跑了,于是在里面做「注册一个全局快捷键」这种必须尽早发生的事 —— 结果那个快捷键在插件被激活前根本不存在。
解法:凡是「必须尽早生效」的东西,全部走清单声明(快捷键、菜单、文件关联),不允许在代码里注册。这又回到了第 5 章:能声明的别用回调。

装到自己身上

◆ 一个渐进的落地顺序

如果你的插件系统现在是全量激活,按这个顺序改,每一步都能单独上线:

第一步:先测量。加上每个插件的激活耗时统计,打进日志。你需要先知道现状有多糟 —— 而且这个数据是你说服自己做后面几步的依据。

第二步:把「贡献声明」挪进清单。命令、菜单、快捷键、文件关联,全部从代码注册改成清单声明。这一步最花时间,但它是所有后续优化的地基。(而且它顺便解决了第 7 章那些安全和分发问题。)

第三步:加激活事件。先支持最简单的 onCommand:,覆盖大部分场景。然后按需加 onLanguage:onView:

第四步:给剩下的常驻插件一个 onStartupFinished 档位,把它们挪出关键路径。

第五步:把耗时排行展示给用户和作者。这一步几乎不花钱,但它让优化变成一件持续发生的事,而不是你一个人的活。

◆ Android:冷启动尤其致命
// ✗ Application.onCreate 里全量加载插件 —— 直接拉长冷启动,甚至 ANR
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        extensionLoader.loadAll().forEach { it.activate() }   // 💥
    }
}

// ✓ 只读清单(便宜),激活推迟到真正需要
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        // 只做「发现 + 读元数据」,不碰扩展的代码
        appScope.launch(Dispatchers.IO) {
            extensionRegistry.discover()     // PackageManager 扫描 + 读 meta-data
        }
    }
}

// 用户真的打开「浏览」页面时,才实例化那个源
suspend fun getSource(id: Long): Source = withContext(Dispatchers.IO) {
    cache.getOrPut(id) { extensionLoader.instantiate(id) }   // ← 这一刻才反射 newInstance
}

Android 上这件事的收益比桌面端还大,因为:冷启动时间直接影响用户留存和商店评分;Application.onCreate 里的任何阻塞都算在启动时间里;而且低端设备上,反射实例化几十个类的开销比你在旗舰机上测出来的高好几倍。

务必用低端设备实测 —— 在旗舰机上「感觉还行」的启动流程,在千元机上可能就是三秒白屏。

这一章的一句话

懒激活的前提是宿主能在不执行代码的情况下知道「什么时候需要谁」—— 所以清单不只是安全机制,它同时是性能机制;而同样的总计算量,放在用户等待时和放在用户操作后,体验差一个数量级。

卷 V 还剩最后一个问题,也是最难用技术解决的一个:用户装的那个插件,到底是不是作者写的那个?