Lesson 0002 激活与生命周期

Lesson 0001 中我们解剖了 package.json——扩展的"宣言"。这一课我们打开 extension.ts,追踪 VS Code 实际加载扩展时发生了什么:从 activate() 被调用,到 deactivate() 被清理。

1. activate():一切的起点

VS Code 在满足 activationEvents 条件时,执行入口文件的 activate() 函数。它传入一个 ExtensionContext 对象——这是扩展与 VS Code 之间的桥梁

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    // context 是 VS Code 给你的工具箱
    // 最重要的工具:context.subscriptions
}

1.1 ExtensionContext 的核心属性

属性/方法类型用途
subscriptionsDisposable[]推送需要自动清理的资源(命令、监听器、定时器……)
extensionPathstring扩展的安装目录(只读文件)
globalStateMemento跨会话持久化存储(全局)
workspaceStateMemento跨会话持久化存储(每工作区)
secretsSecretStorage安全存储敏感信息(API key 等)
🔑 关键概念:subscriptions 是 VS Code 扩展中最重要的资源管理模式。把它想象成一个"自动清理清单"——你注册命令、绑定事件监听器时,把返回的 Disposable 对象 push 进这个数组。当扩展被停用时,VS Code 会自动调用每个对象的 dispose() 方法。你不需要手动逐一清理。

1.2 为什么 subscriptions 不能自动管理?

你可能会想:既然每个 registerXxx() 都返回 Disposable,为什么 VS Code 不自动把它们加到 subscriptions 里?为什么要开发者手动 push?这触及了 VS Code Extension API 的设计哲学核心。

原因一:技术约束——注册时拿不到 Context

// registerCommand 是 vscode.commands 上的静态方法
// 它不在 activate() 内部,根本访问不到 context
const disposable = vscode.commands.registerCommand('my.command', callback);
// context 只在 activate(context) 的参数里出现
// 两者在代码结构上是完全分离的

registerCommand() 是挂载在 vscode.commands 命名空间上的全局静态方法,它不知道——也不应该知道——当前的 ExtensionContext 在哪里。要把它们耦合在一起,API 需要改头换面(比如让每个 register 方法接受一个可选的 context 参数),但 VS Code 团队刻意没这么做。

原因二:生命周期 ≠ 扩展生命周期

不是所有 Disposable 都应该活到扩展停用。有时你需要中途销毁某个资源:

// 场景:切换工作区时重建监听器
let workspaceWatcher: vscode.Disposable;

function setupWorkspace() {
    workspaceWatcher?.dispose();  // 先清理旧的
    workspaceWatcher = vscode.workspace.onDidChangeTextDocument(handleChange);
    // 这个监听器不应该进 subscriptions——我们要在切换工作区时手动 dispose
}

如果 VS Code 自动把所有东西塞进 subscriptions,你就失去了中途清理的能力——资源只能等扩展停用才释放。这会浪费内存,甚至因为持有了已失效的引用而出错。

原因三:拥有权原则

"创建资源的人负责销毁它。"
——Dispose Pattern(源自 .NET / RxJS 社区)

registerCommand() 创建了一个命令注册,但它把所有权Disposable 对象的形式转移给你。你可以选择:

自动管理会剥夺这种选择权。手动 push 不是负担,而是赋权。

原因四:非 VS Code 资源

fs.watch() 返回的是 Node.js 的 FSWatcher,不是 VS Code 的 Disposable。VS Code 不可能自动管理它不认识的资源类型。但通过适配器模式——包一层 { dispose: () => watcher.close() }——你可以把任意资源包装成 VS Code 约定,享受统一的清理待遇。

📐 一句话总结:subscriptions 不是 VS Code "帮不了你"的妥协,而是把资源生命周期的控制权交给你。这是一种赋权,不是负担。

2. activate() 内部:claude-context-bar 做了什么

打开 temp_repo/src/extension.ts 第 36 行,activate() 做了五件事,按照顺序:

2.1 注册命令

const hideCommand = vscode.commands.registerCommand(
    'claudeContextBar.hideSession',
    (sessionFile: string) => {
        hiddenSessions.set(sessionFile, Date.now());
        refreshAllSessions();
    }
);
context.subscriptions.push(hideCommand);  // ⬅ 关键:推入清理清单

registerCommand() 返回一个 Disposable。如果不 push 到 subscriptions,扩展停用后命令引用会泄漏——用户点击状态栏上的隐藏按钮时 VS Code 会尝试调用一个已经不存在的回调。

📖 registerCommand 详解:两种命令,两种注册方式

VS Code 命令分为用户命令内部命令两类,注册方式和用途完全不同:
用户命令内部命令
声明位置package.jsoncontributes.commands只在代码中 registerCommand()
命令面板✅ 出现在 Ctrl+Shift+P❌ 不出现
快捷键✅ 可绑定❌ 不可绑定
典型用途用户主动触发的操作UI 组件的点击回调、程序间通信

claudeContextBar.hideSession内部命令——你在 package.json 里找不到它,因为它不是给用户在命令面板里调用的。它存在的唯一目的是:当用户点击状态栏条目时,VS Code 执行对应的回调

触发链路:用户点击状态栏条目 → VS Code 读取 StatusBarItem.command → 按 ID 找到 registerCommand 注册的回调 → 传入 arguments 作为参数 → 回调执行。

除了 UI 点击,命令还有其他触发方式: 不在 package.json 声明 hideSession 是一种有意为之:用户不应该在命令面板中看到 "Hide Session" 这个选项(他们不知道 session 是什么),这个命令只作为状态栏点击的"技术回调"存在。声明式配置管用户界面,命令式注册管内部逻辑——各司其职。

2.2 监听配置变化

const configWatcher = vscode.workspace.onDidChangeConfiguration(e => {
    if (e.affectsConfiguration('claudeContextBar')) {
        refreshAllSessions();  // 用户在 settings.json 中改了配置,立即刷新
    }
});
context.subscriptions.push(configWatcher);
🎨 通用模式:注册 → 推入 subscriptions

你在 VS Code Extension API 中看到的几乎所有 registerSomething()onDidSomething() 方法都返回 Disposable。你负责把它 push 到 context.subscriptions,VS Code 负责在停用时调用 dispose()。简单、统一、不容易遗漏。

2.3 首次刷新

refreshAllSessions();  // 立即扫描一次,生成初始状态栏 UI

这是 activate() 中的唯一一个"做事"的调用——前面的注册只是"设置规则",这里的 refreshAllSessions() 才是实际干活:扫描 Claude Code 的会话文件、解析 token 用量、创建状态栏条目。

2.4 设置文件监听

const claudeProjectsDir = getClaudeProjectsDir();
if (fs.existsSync(claudeProjectsDir)) {
    fileWatcher = fs.watch(claudeProjectsDir, { recursive: true },
        (event, filename) => {
            if (filename?.endsWith('.jsonl')) {
                refreshAllSessions();  // 会话文件变化时刷新
            }
        }
    );
}

Node.js 的 fs.watch() 直接监听文件系统。每当 Claude Code 写入新的会话数据,.jsonl 文件发生变化,扩展立刻刷新状态栏。注意这里 fileWatcher 没有 被 push 到 subscriptions——因为它不是 VS Code Disposable,而是 Node.js 的 FSWatcher 对象,需要在 deactivate() 中手动 .close()

2.5 设置定时刷新

const config = vscode.workspace.getConfiguration('claudeContextBar');
const intervalSeconds = config.get<number>('refreshInterval', 30);
refreshInterval = setInterval(refreshAllSessions, intervalSeconds * 1000);

文件监听不能覆盖所有情况(比如网络文件系统、某些编辑器的写入方式),所以扩展设置了一个兜底定时器。这是工程中的常见模式——用轮询弥补事件驱动的盲区。

3. context.subscriptions 的"乾坤袋"模式

除了 push 单个 Disposable,context.subscriptions 还可以直接 push 一个包含 dispose() 方法的对象。claude-context-bar 在 activate 末尾用了这个技巧:

context.subscriptions.push({
    dispose: () => {
        if (fileWatcher) { fileWatcher.close(); }
        if (refreshInterval) { clearInterval(refreshInterval); }
        statusBarItems.forEach(entry => entry.item.dispose());
        statusBarItems.clear();
    }
});

这使得所有需要清理的资源(Node.js watcher、定时器、状态栏条目)都被统一在一个 dispose() 函数中,享受和 VS Code Disposable 相同的自动清理待遇。这是适配器模式——把非 VS Code 的资源包装成 VS Code 的 Disposable 契约。

⚠️ 常见陷阱:如果你创建了定时器或文件监听但没有在 deactivate() 或 subscriptions 的 dispose 中清理,扩展停用后它仍然在运行。这会导致内存泄漏、CPU 空转,甚至因为持有了对已销毁 VS Code 对象的引用而崩溃。

4. deactivate():优雅退场

export function deactivate() {
    if (fileWatcher) { fileWatcher.close(); }
    if (refreshInterval) { clearInterval(refreshInterval); }
    statusBarItems.forEach(entry => entry.item.dispose());
    statusBarItems.clear();
}

claude-context-bar 的 deactivate() 和 subscriptions 中那个 dispose 函数几乎一模一样。实际上,在正常流程下这是冗余的——因为两者都会在扩展停用时被调用。

正常流程中发生了什么

// VS Code 停用扩展时的执行顺序:
deactivate() 被调用
  → fileWatcher.close()
  → clearInterval(refreshInterval)
  → statusBarItems dispose
  ↓
context.subscriptions 逐个 dispose
  → 包装对象 dispose() 被调用
  → fileWatcher.close()       ← 第二次调用
  → clearInterval(refreshInterval)  ← 第二次调用
  → statusBarItems dispose    ← 第二次调用

好在这些操作(close()clearInterval()dispose())重复调用通常是无害的 no-op——不会崩溃,只是多余。

这更像演化痕迹而非精心设计

  1. 开发者先写了 deactivate()——这是教程教会每个人的标准模式
  2. 后来学到了 subscriptions 的"乾坤袋"模式,又在 activate 末尾加了一个
  3. 两者没被重构合并——因为重复无害,清理掉反而要改两处

这种"防御性编程"在工程中很常见,但它防御的场景其实不存在——如果 VS Code 连 deactivate() 都不调用,那它大概率也顾不上 dispose subscriptions 了。

最佳实践:选一个,不要混用

方案优点风险
A. 全押 subscriptions(推荐)
context.subscriptions.push(disposable)
每注册一个资源立刻 push,不会漏;省去写 deactivate() 的心智负担需要手动中途 dispose 的资源要自己持有引用
B. 全在 deactivate 手动清理
不 push 到 subscriptions
所有清理逻辑集中一处,便于审查容易漏:你必须在 activate 和 deactivate 之间维护"注册了什么"的对应关系

两者不要同时写——那是没有收益的心智负担。如果你不确定选哪个,方案 A 是 VS Code 社区的默认做法

5. 完整时间线

时刻发生什么
VS Code 启动完成onStartupFinished 激活事件触发
加载扩展VS Code 执行 ./out/extension.js,调用 activate(context)
注册命令registerCommand('claudeContextBar.hideSession', …)
绑定监听onDidChangeConfiguration(…) —— 用户改设置时响应
首次扫描refreshAllSessions() —— 生成初始状态栏 UI
设置文件监听fs.watch(…) —— 会话文件变化时刷新
启动定时器setInterval(refreshAllSessions, …) —— 兜底轮询
运行时循环状态栏持续更新 ← 文件监听 + 定时器 + 配置变化
VS Code 关闭 / 扩展被禁用subscriptions 全部 dispose → deactivate() 调用 → 扩展退出

6. 练习:追踪 activate 的执行路径

  1. 打开 extension.ts,找到 activate() 函数(第 36 行)
  2. 在纸上(或脑中)列出 activate() 执行的五个步骤,不看书
  3. 对每一步,问自己:这一步创建了什么资源?它被 push 到 subscriptions 了吗?如果没有,它在哪里被清理?
  4. 找到 refreshAllSessions() 函数(第 433 行起),快速浏览它的主干逻辑——不要求完全读懂,只需识别它的输入和输出

7. 知识检查

问题 1

activate() 中通过 registerCommand() 注册的命令,它的 Disposable 被 push 到了哪里?

问题 2

claude-context-bar 的 fs.watch() 文件监听没有被直接 push 到 context.subscriptions。它在哪里被清理?

问题 3

为什么 claude-context-bar 同时设置了 fs.watch() 文件监听和 setInterval() 定时器?

问题 4

VS Code 为什么registerCommand() 内部自动帮你把 Disposable 添加到 subscriptions?

8. 推荐深入阅读

💡 提问提示:如果你对 Disposable 模式、为什么资源管理这么重要、或者 activate 中任何一段代码有疑问,直接在这个对话中提问。