为了在工位带薪背雅思,我连夜敲了个 VS Code 插件!

9 阅读11分钟

最近我做了一款面向开发者的 VS Code 英语词汇学习插件——CodeWords。 它不会打开独立学习页面,也不会打断当前编码流程,而是把单词、音标、短语、例句、错题和学习统计放进 VS Code 左侧面板。写代码间隙可以顺手完成一轮练习,免费词库包括 CET-4、CET-6 和程序员高频词汇。 插件已经发布到 VS Code Marketplace 👉在 VS Code Marketplace 查看 CodeWords【CodeWords 市场链接】 这篇文章不只介绍产品功能,更想完整拆解它背后的实现:为什么选择 Webview、React 如何与 Extension Host 通信、如何适配 VS Code 主题、如何处理登录和 Pro 权益,以及一个看似简单的背单词插件,为什么最后会涉及前端、插件、认证、数据库和授权系统。

一、为什么要在 VS Code 里做英语学习?

很多开发者并不是没有英语学习需求,而是很难长期打开一款独立的学习软件。 日常开发中,我们会不断接触这些内容:

deprecated
dependency
payload
middleware
repository
rollback
authorization
configuration

英文报错、Git 提交、Pull Request、技术文档、Issue 和海外项目沟通,都离不开英语。 但传统背单词产品存在一个明显问题:它和开发工作流是割裂的。 你需要主动切换软件、进入学习模式、完成任务,再切回编辑器。对于碎片时间学习来说,这个切换成本并不低。 CodeWords 的核心思路是: 不要求用户专门进入学习场景,而是把学习嵌入用户每天已经在使用的工具里。 因此,第一版没有做独立桌面应用,也没有做网页,而是选择了 VS Code 插件。 用户点击 Activity Bar 中的 CodeWords 图标后,左侧会打开一个独立面板,主编辑区仍然保留当前代码文件。 插件不会读取项目代码,也不会改变用户原有的编码习惯。

二、产品交互:学习入口必须足够轻

CodeWords 当前包含五个 Tab:

练习 / 错题 / 打卡 / 统计 / 我的

练习是默认入口,只保留当前学习所需的信息:

词库选择
当前单词
英式、美式音标
中文释义
常用短语
英文例句
拼写输入框

每轮默认学习 10 个词,完成后可以继续下一轮,不限制每日学习数量。 之所以采用“轮次制”,而不是“每日最多学习多少词”,是因为每日上限很容易变成一种人为限制。 更合理的体验是: 完成一轮

→ 查看本轮结果
→ 继续下一轮 / 复习错题 / 暂时结束。

输入错误的词会自动进入错题本。 同一个词多次拼错时,不会生成多条重复记录,而是累加错误次数。用户可以单独发起一轮错题复习。 打卡用于记录学习习惯,但不承担强制任务功能。 它展示:

今日已练词数
连续学习天数
月度日历
每轮学习数量设置
统计
统计页集中展示:
累计练习词数
正确率
平均输入速度
错题数量
近四周练习热力图
不同词库的学习分布

这些数据没有放在练习首页,是为了避免核心入口过载。 “我的”负责承载账号和 Pro 权益:

登录
用户邮箱
当前 Free / Pro 状态
兑换码输入
设备数量
退出登录

这样账号系统不会干扰免费用户的基础练习。

三、插件整体架构

CodeWords 实际上由两个独立项目组成。

codewords-extension
├─ VS Code Extension Host
├─ React Webview
├─ 本地词库
├─ 练习、错题和统计逻辑
└─ 登录与权益接口客户端
codewords-server
├─ Next.js
├─ Supabase Auth
├─ Pro 权益系统
├─ 兑换码系统
├─ 设备激活
└─ 后台管理系统

插件端继续使用: VS Code Extension API React Vite TypeScript

服务端使用: Next.js App Router Supabase Auth Supabase Postgres Vercel

没有把插件 Webview 改成 Next.js,因为 VS Code Webview 更适合打包后的静态资源,而 Next.js 的服务端渲染、路由和 API 能力在插件 UI 中并没有明显价值。

四、为什么使用 React Webview?

VS Code 原生 Tree View 很适合目录、列表和命令入口,但 CodeWords 包含较多交互状态:

当前 Tab 当前词库 当前词汇 英美音标切换 短语切换 例句切换 本轮进度 错题列表 打卡日历 统计热力图 登录状态 Pro 状态 购买与兑换引导

如果全部使用原生 DOM 手动维护,组件很快会变得难以管理。

因此 Webview UI 使用 React,并拆分为多个组件:

App
├─ Tabs
├─ PracticeTab
│  ├─ LibrarySwitcher
│  ├─ WordCard
│  ├─ PhoneticToggle
│  ├─ PhraseBlock
│  └─ SentenceBlock
├─ WrongWordsTab
├─ CheckInTab
├─ StatsTab
├─ MyTab
└─ ProAccessPanel

React 只负责 UI 和交互,敏感逻辑仍然放在 Extension Host。

五、Extension Host 与 React Webview 如何通信?

VS Code Webview 不是普通浏览器页面。

它不能直接访问完整的 VS Code API,因此需要通过消息通信连接 Extension Host。

Webview 发送消息:

vscode.postMessage({
  type: 'submitAnswer',
  payload: {
    answer: inputValue,
  },
});

Extension Host 接收:

webviewView.webview.onDidReceiveMessage(async (message) => {
  switch (message.type) {
    case 'submitAnswer':
      await practiceService.submitAnswer(message.payload.answer);
      break;

    case 'startLogin':
      await authService.startLogin();
      break;

    case 'redeemCode':
      await authService.redeemCode(message.payload.redeemCode);
      break;
  }
});

Extension Host 更新状态后,再发回 Webview:

webviewView.webview.postMessage({
  type: 'stateUpdated',
  payload: state,
});

这里有一个重要的架构边界: Webview 负责展示;Extension Host 负责 VS Code API、SecretStorage、设备标识和远程接口调用。 这样可以避免 Token 长期暴露在 React 状态或 Webview LocalStorage 中。

六、如何跟随 VS Code 深色和浅色主题?

插件 UI 不能只适配深色模式。

用户可能使用:

Dark+ Light+ GitHub Theme One Dark Pro 高对比度主题 自定义主题

因此 CodeWords 没有写死主要颜色,而是使用 VS Code 提供的主题变量:

body {
  background: var(--vscode-sideBar-background);
  color: var(--vscode-sideBar-foreground);
  font-family: var(--vscode-font-family);
  font-size: var(--vscode-font-size);
}

.word-card {
  background: var(--vscode-editor-background);
  border: 1px solid var(--vscode-panel-border);
}

input {
  color: var(--vscode-input-foreground);
  background: var(--vscode-input-background);
  border: 1px solid var(--vscode-input-border);
}

button {
  color: var(--vscode-button-foreground);
  background: var(--vscode-button-background);
}

button:hover {
  background: var(--vscode-button-hoverBackground);
}

VS Code 还会自动向 Webview 的 body 注入主题 class:

vscode-dark vscode-light vscode-high-contrast

因此也可以针对特殊场景调整:

body.vscode-high-contrast .word-card {
  border-color: var(--vscode-focusBorder);
}

最终效果是,用户切换 VS Code 主题时,CodeWords 会自动跟随,不需要插件自己维护深浅色主题配置。

七、从简单四元组到完整词库结构

第一版词库使用的是非常简单的元组:

type WordSeed = readonly [
  word: string,
  phonetic: string,
  meaning: string,
  example: string
];

例如:

[  'acquire',  '/əˈkwaɪə(r)/',  '获得;习得',  'Reading helps us acquire new skills.',]

后续接入了更完整的开源词库,数据结构变成:

interface RawWordItem {
  word: string;
  us?: string;
  uk?: string;
  translations?: Array<{
    translation: string;
    type?: string;
  }>;
  phrases?: Array<{
    phrase: string;
    translation: string;
  }>;
  sentences?: Array<{
    sentence: string;
    translation: string;
  }>;
}

为了避免 UI 和原始数据强耦合,我增加了标准化层:

function normalizeWordData(
  rawWords: RawWordItem[],
  category: WordCategory,
): WordItem[] {
  return rawWords.map((item) => ({
    id: `${category}-${item.word}`,
    word: item.word,
    phonetics: {
      us: item.us,
      uk: item.uk,
    },
    translations: item.translations ?? [],
    phrases: item.phrases ?? [],
    sentences: item.sentences ?? [],
    category,
  }));
}

这样即使某个词缺少音标、短语或例句,也不会导致组件报错。

八、短语和例句为什么设计成点击切换?

侧边栏空间有限,不适合一次性展开一个单词的所有短语和例句。

因此 WordCard 默认只展示一条短语和一条例句:

短语:
full stack developer
全栈开发者

例句:
A developer needs to understand the product context.
开发者需要理解产品背景。

点击短语区域,会循环切换到下一条短语;点击例句区域,则循环切换下一条例句。

状态只保留在当前 React 组件:

const [phraseIndex, setPhraseIndex] = useState(0);
const [sentenceIndex, setSentenceIndex] = useState(0);

切换新单词时重置:

useEffect(() => {
  setPhraseIndex(0);
  setSentenceIndex(0);
}, [currentWord.id]);

这类交互不会影响学习统计,也不需要写入持久化存储。

九、键盘交互中的一个细节:Esc 跳过

最初只有输入框获得焦点时,按 Esc 才能跳过当前词。

后来做了一个小优化:

只要焦点仍然位于 CodeWords 面板内,并且当前处于有效练习状态,按 Esc 都可以跳过当前词。

Webview 内监听:

useEffect(() => {
  const handleKeyDown = (event: KeyboardEvent) => {
    if (event.key !== 'Escape') return;

    const target = event.target as HTMLElement | null;

    const isEditable =
      target?.tagName === 'INPUT' ||
      target?.tagName === 'TEXTAREA' ||
      target?.isContentEditable;

    if (isEditable) {
      return;
    }

    if (!canSkipCurrentWord) {
      return;
    }

    event.preventDefault();
    skipCurrentWord();
  };

  window.addEventListener('keydown', handleKeyDown);

  return () => {
    window.removeEventListener('keydown', handleKeyDown);
  };
}, [canSkipCurrentWord, skipCurrentWord]);

这里不能注册全局 Escape 快捷键,否则会干扰编辑器补全、搜索框、命令面板和弹窗。

十、免费功能为什么坚持本地优先?

CodeWords 的目标用户是开发者,而开发者对编辑器插件的权限非常敏感。

因此免费功能坚持:

不读取当前代码 不分析工作区 不上传文件 不收集项目路径 不在后台扫描代码 本地词库可离线使用

插件可以显示在真实代码旁边,但不会读取代码内容。

本地学习数据使用 context.globalState 保存:

await context.globalState.update('codewords.progress', progress);

账号 Token 则使用 SecretStorage:

await context.secrets.store(
  'codewords.pluginAccessToken',
  accessToken,
);

这是两个完全不同的存储层:

globalState:
学习进度、当前词库、Pro 状态摘要

SecretStorage:
Access Token、Refresh Token

十一、为什么登录流程要跳转浏览器?

CodeWords 支持邮箱和 GitHub 登录。

没有直接把 OAuth 页面嵌进 VS Code Webview,而是采用:

插件点击登录 → 打开系统浏览器 → 完成 Supabase Auth → 后端生成一次性插件登录码 → 用户复制登录码回插件 → 插件交换自己的 Access Token

一次性登录码类似:

CW-LOGIN-ABCD-EFGH

登录码:

10 分钟过期 只能使用一次 数据库只保存 Hash 不直接把 Supabase Refresh Token 暴露给插件

插件端拿到的是独立的 Plugin Session。

这样浏览器登录、Supabase Session 和插件 Session 之间形成了明确边界。

十二、Pro 权益和兑换码系统

CodeWords 的免费功能不需要登录。

只有用户需要兑换 Pro 时,才进入账号体系。

兑换流程:

用户登录
→ 输入 CW-PRO 兑换码
→ 后端校验兑换码
→ 绑定 user_id
→ 创建 Pro 权益
→ 绑定当前设备
→ 插件刷新 Pro 状态

兑换码只能使用一次。

一个 Pro 账号默认支持两台常用设备。

核心表包括:

redeem_codes
user_entitlements
user_device_activations
plugin_sessions

兑换接口需要保证原子性:

设备未超过限制
兑换码仍然 unused
创建权益成功
创建设备记录成功

这些操作必须一起成功,否则兑换码不能被提前消耗。

十三、为什么还做了后台管理系统?

只要开始有真实用户,直接去 Supabase 数据库里排查问题就会变得低效。

常见售后问题包括:

用户兑换码为什么不能用?
兑换码是不是已经被使用?
当前账号是不是 Pro?
绑定了几台设备?
哪台设备最后验证过?
用户换电脑后如何解绑?

因此后端又增加了 /admin 管理系统:

Dashboard
用户管理
兑换码管理
批次管理
设备管理
订单管理
快速搜索

管理员可以通过邮箱、用户 ID、兑换码后几位、订单号和设备 Hash 快速定位问题。

所有管理操作都会记录到 admin_logs,例如:

手动开通 Pro
禁用 Pro
恢复 Pro
解绑设备
撤销 Session
禁用兑换码
生成兑换码批次

后台只允许存在于 admin_users 表中的 Supabase 用户访问。

十四、做完这个插件后的一些体会

CodeWords 表面上只是一个“在 VS Code 中背单词”的工具,但真正做完整后,会涉及:

产品交互
VS Code Extension API
React Webview
主题适配
本地状态管理
OAuth 登录
Token 管理
兑换码
设备授权
后台管理
插件发布

其中最重要的不是堆功能,而是保持边界清晰:

免费功能不依赖登录
Webview 不保存敏感 Token
Extension Host 负责安全能力
后端负责权益和授权
插件不读取用户代码
Pro 失败不能影响本地免费功能

对于工具类插件来说,信任和稳定性往往比功能数量更重要。

最后

CodeWords 目前已经可以在 VS Code 中完成程序员词汇、CET-4、CET-6 等内容的轻量练习,并支持音标、短语、例句、错题、打卡和统计。

如果你平时经常阅读英文技术文档,或者想利用编码间隙积累英语词汇,可以安装体验一下:

👉在 VS Code Marketplace 查看 CodeWords【CodeWords 市场链接】

也欢迎在评论区反馈使用体验、希望增加的词库,或者你在 VS Code 插件开发中遇到的问题。