开发技巧

VS Code 插件开发:中文与英文实时字数统计

从零写一个 VS Code 插件“中文字数统计”:在状态栏实时显示 Markdown 和纯文本文件的中文字数、英文单词数和预计阅读时间,选中文本时只统计选中部分,最后打包成 .vsix 文件安装或分享。

BINBHO · 图文教程

从零写一个 VS Code 插件“中文字数统计”:在状态栏实时显示 Markdown 和纯文本文件的中文字数、英文单词数和预计阅读时间,选中文本时只统计选中部分,最后打包成 .vsix 文件安装或分享。

先看清成品

成品是一个 TypeScript 插件项目。打开 Markdown 文件时,状态栏右侧显示“📖 1234 字 · 4 分钟”,点击弹出详细统计;阅读速度可以在设置里修改。本文代码已实际编译,4 个单元测试全部通过,并用官方 vsce 4.0.0 打包出 4.82 KB 的 .vsix。在 VS Code 里实际点击的效果未在本文环境中截图验证。

做完之后你会得到什么?

成果 说明
插件源码 src/extension.ts + src/count.ts
单元测试 用 Node 自带测试运行器,不依赖 VS Code
调试配置 F5 打开一个加载了插件的新窗口
安装包 zh-word-count-0.1.0.vsix

需要准备哪些环境?

项目 要求 说明
VS Code 1.100 或更高 插件声明的最低版本
Node.js 22 或更高 打包工具 vsce 4.x 要求 Node 22
TypeScript 5.x 作为开发依赖安装
vsce @vscode/vsce 官方打包和发布工具

第一步:项目结构是什么样的?

zh-word-count/
├── .vscode/
│   └── launch.json
├── src/
│   ├── extension.ts     # 插件入口:状态栏、事件、命令
│   ├── count.ts         # 纯函数:统计逻辑
│   └── count.test.ts    # 单元测试
├── package.json         # 插件清单
├── tsconfig.json
├── .vscodeignore        # 打包时排除的文件
├── README.md
└── LICENSE

官方也提供脚手架 npx --package yo --package generator-code -- yo code,会生成更完整的模板。本文手写最小结构,方便看清每个文件的作用。

1. 让 AI 帮你写(可选)

帮我写一个 VS Code 插件,TypeScript:
- 只在 markdown 和 plaintext 文件激活
- 状态栏右侧显示汉字数 + 英文单词数和预计阅读分钟数
- 有选中内容时只统计选中部分
- Markdown 统计时排除代码块、行内代码、图片和链接地址
- 统计逻辑写成独立纯函数,便于用 node --test 测试
- 阅读速度做成可配置项 zhWordCount.charsPerMinute,默认 400

第二步:插件清单 package.json 怎么写?

{
  "name": "zh-word-count",
  "displayName": "中文字数统计",
  "description": "在状态栏实时显示 Markdown 和纯文本的中文字数、英文单词数和预计阅读时间",
  "version": "0.1.0",
  "publisher": "your-name",
  "license": "MIT",
  "engines": { "vscode": "^1.100.0" },
  "categories": ["Other"],
  "activationEvents": ["onLanguage:markdown", "onLanguage:plaintext"],
  "main": "./out/extension.js",
  "contributes": {
    "commands": [
      { "command": "zhWordCount.showDetail", "title": "字数统计:显示详情" }
    ],
    "configuration": {
      "title": "中文字数统计",
      "properties": {
        "zhWordCount.charsPerMinute": {
          "type": "number",
          "default": 400,
          "description": "阅读速度(中文字/分钟),用于估算阅读时间"
        }
      }
    }
  },
  "scripts": {
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./",
    "test": "npm run compile && node --test out/count.test.js"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@types/vscode": "^1.100.0",
    "typescript": "^5.8.0"
  }
}
字段 作用
engines.vscode 最低支持的 VS Code 版本,要和 @types/vscode 版本对应
activationEvents 打开 Markdown 或纯文本时才加载插件
main 编译后的入口文件
contributes.commands 注册到命令面板的命令
contributes.configuration 出现在设置界面的配置项

第三步:核心代码怎么写?

1. 统计逻辑 src/count.ts

// 纯函数:不依赖 VS Code,方便用 node --test 单独测试
export interface CountResult {
  cjk: number;      // 中日韩汉字数
  words: number;    // 英文单词数(连续字母或数字算一个)
  total: number;    // 汉字 + 单词
  minutes: number;  // 预计阅读分钟数,至少 1
}

const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g;
const WORD = /[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g;

export function stripMarkdown(text: string): string {
  return text
    .replace(/```[\s\S]*?```/g, "")          // 代码块不计入
    .replace(/`[^`]*`/g, "")                  // 行内代码
    .replace(/!\[[^\]]*\]\([^)]*\)/g, "")     // 图片
    .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")  // 链接只保留文字
    .replace(/^#{1,6}\s+/gm, "");             // 标题符号
}

export function count(text: string, charsPerMinute = 400, markdown = false): CountResult {
  const body = markdown ? stripMarkdown(text) : text;
  const cjk = (body.match(CJK) ?? []).length;
  const words = (body.replace(CJK, " ").match(WORD) ?? []).length;
  const total = cjk + words;
  // 英文按每分钟 200 词折算成“汉字当量”
  const minutes = Math.max(1, Math.ceil((cjk + words * (charsPerMinute / 200)) / charsPerMinute));
  return { cjk, words, total, minutes };
}

把统计逻辑和 VS Code API 分开,是插件开发里很实用的习惯:纯函数可以在几毫秒内测试完,不需要启动编辑器。

2. 插件入口 src/extension.ts

import * as vscode from "vscode";
import { count } from "./count";

let item: vscode.StatusBarItem;

export function activate(context: vscode.ExtensionContext) {
  item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
  item.command = "zhWordCount.showDetail";
  context.subscriptions.push(item);

  context.subscriptions.push(
    vscode.window.onDidChangeActiveTextEditor(update),
    vscode.window.onDidChangeTextEditorSelection(update),
    vscode.workspace.onDidChangeTextDocument((e) => {
      if (e.document === vscode.window.activeTextEditor?.document) update();
    }),
    vscode.commands.registerCommand("zhWordCount.showDetail", showDetail)
  );
  update();
}

function currentText(editor: vscode.TextEditor): { text: string; selected: boolean } {
  const sel = editor.selection;
  if (!sel.isEmpty) return { text: editor.document.getText(sel), selected: true };
  return { text: editor.document.getText(), selected: false };
}

function cpm(): number {
  return vscode.workspace.getConfiguration("zhWordCount").get<number>("charsPerMinute", 400);
}

function update() {
  const editor = vscode.window.activeTextEditor;
  if (!editor || !["markdown", "plaintext"].includes(editor.document.languageId)) {
    item.hide();
    return;
  }
  const { text, selected } = currentText(editor);
  const r = count(text, cpm(), editor.document.languageId === "markdown");
  item.text = `$(book) ${selected ? "选中 " : ""}${r.total} 字 · ${r.minutes} 分钟`;
  item.tooltip = "点击查看详细统计";
  item.show();
}

function showDetail() {
  const editor = vscode.window.activeTextEditor;
  if (!editor) return;
  const { text, selected } = currentText(editor);
  const r = count(text, cpm(), editor.document.languageId === "markdown");
  vscode.window.showInformationMessage(
    `${selected ? "选中内容" : "全文"}:汉字 ${r.cjk},英文单词 ${r.words},合计 ${r.total},预计阅读 ${r.minutes} 分钟`
  );
}

export function deactivate() {}
代码 作用
createStatusBarItem 创建状态栏条目
$(book) 内置图标语法,显示一本书
onDidChangeTextDocument 内容变化时刷新
onDidChangeTextEditorSelection 选区变化时刷新
context.subscriptions.push 插件停用时自动清理

3. 测试 src/count.test.ts

import { test } from "node:test";
import assert from "node:assert/strict";
import { count, stripMarkdown } from "./count";

test("纯中文按字计数", () => {
  assert.equal(count("你好,世界").cjk, 4);
});

test("中英混排分别计数", () => {
  const r = count("我在用 VS Code 写 TypeScript 插件");
  assert.equal(r.cjk, 6);
  assert.equal(r.words, 3);
  assert.equal(r.total, 9);
});

test("Markdown 去掉代码块和链接地址", () => {
  const md = "# 标题\n正文[链接](https://example.com)\n```js\nconst a = 1;\n```";
  assert.equal(stripMarkdown(md).includes("const"), false);
  assert.equal(count(md, 400, true).cjk, 6);
  assert.equal(count(md, 400, true).words, 0);
});

test("阅读时间至少 1 分钟,按速度向上取整", () => {
  assert.equal(count("").minutes, 1);
  assert.equal(count("字".repeat(801), 400).minutes, 3);
});

4. tsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "ES2022",
    "outDir": "out",
    "rootDir": "src",
    "lib": ["ES2022"],
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true
  }
}

第四步:怎样编译、测试和调试?

1. 安装依赖并测试

npm install
npm test

本文实际输出(节选):

ok 1 - 纯中文按字计数
ok 2 - 中英混排分别计数
ok 3 - Markdown 去掉代码块和链接地址
ok 4 - 阅读时间至少 1 分钟,按速度向上取整
# pass 4
# fail 0

2. 调试配置 .vscode/launch.json

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "运行插件",
      "type": "extensionHost",
      "request": "launch",
      "args": ["--extensionDevelopmentPath=${workspaceFolder}"],
      "outFiles": ["${workspaceFolder}/out/**/*.js"],
      "preLaunchTask": "npm: compile"
    }
  ]
}

npm: compile 是 VS Code 根据 package.json 里的 scripts 自动识别出的任务,不需要额外写 tasks.json。

3. 按 F5 启动

VS Code 会编译代码并打开一个标题带 [Extension Development Host] 的新窗口。

4. 检查结果

操作 期望
新窗口里打开一个 .md 文件 状态栏右侧出现字数
输入文字 数字实时变化
选中一段 显示“选中 N 字”
点击状态栏 弹出详细统计
打开 .js 文件 状态栏条目隐藏
设置里搜 zhWordCount 能修改阅读速度

第五步:怎样打包和安装?

1. 打包

npx @vscode/vsce package

.vscodeignore 排除了源码、测试和调试配置,安装包只保留运行需要的文件:

src/**
out/**/*.test.js
out/**/*.map
tsconfig.json
.vscode/**

本文实际打包结果:

DONE  Packaged: zh-word-count-0.1.0.vsix (8 files, 4.82 KB)

如果 package.json 没写 repository 字段,vsce 会询问是否继续,可以加 --allow-missing-repository 跳过。

2. 安装

code --install-extension zh-word-count-0.1.0.vsix

也可以在扩展面板右上角的“…”菜单里选择“从 VSIX 安装”。

3. 发布到扩展市场(可选)

需要先在 Visual Studio Marketplace 创建发布者,并把 publisher 改成你的发布者 ID,再执行 npx @vscode/vsce publish。详细步骤见项目资源里的发布文档。

出问题时该怎么调?

1. 编译报 esModuleInterop 错误

本文第一次编译时遇到:

src/count.test.ts(2,8): error TS1259: Module '"node:assert/strict"' can only be default-imported using the 'esModuleInterop' flag

在 tsconfig.json 的 compilerOptions 里加上 "esModuleInterop": true 即可。

2. 按 F5 后状态栏没有显示

依次检查:

检查 方法
插件是否激活 新窗口里打开的必须是 markdown 或纯文本文件
是否报错 原窗口的“调试控制台”查看异常
编译产物 out/extension.js 是否存在

3. 汉字数和其他工具不一样

不同工具对标点、数字、英文的算法不同。本插件不计标点,英文按单词计,数字串算一个单词。需要和平台规则一致时,修改 count.ts 里的正则并补充测试。

4. vsce 报 Node 版本错误

vsce 4.x 要求 Node 22 或更高,用 node -v 检查后升级。

常见问题怎么处理?

现象 原因 解决方法
安装时提示版本不兼容 engines.vscode 高于本机 VS Code 升级 VS Code 或调低版本要求
@types/vscode 版本冲突 类型版本高于 engines.vscode 两者保持一致
命令面板找不到命令 contributes.commands 拼写错 命令 ID 和 registerCommand 一致
修改代码不生效 没重新编译 npm run watch 后在调试窗口 Ctrl+R 重载
打包提示缺少 LICENSE 没有许可证文件 添加 LICENSE
大文件输入卡顿 每次按键都全文统计 可加防抖,延迟 300 毫秒再统计

项目资源

用途 地址
第一个插件教程 https://code.visualstudio.com/api/get-started/your-first-extension
状态栏 UX 指南 https://code.visualstudio.com/api/ux-guidelines/status-bar
插件清单字段 https://code.visualstudio.com/api/references/extension-manifest
激活事件 https://code.visualstudio.com/api/references/activation-events
打包与发布 https://code.visualstudio.com/api/working-with-extensions/publishing-extension
内置图标列表 https://code.visualstudio.com/api/references/icons-in-labels
vsce 仓库 https://github.com/microsoft/vscode-vsce
Node 测试运行器 https://nodejs.org/api/test.html

建议先只做到第四步:跑通单元测试,再按 F5 看到状态栏出现字数。

之后再按自己的需求改统计规则,比如加上“不含空格的字符数”,每改一次补一条测试。