TypeScript 09:更多应用场景 —— CLI、浏览器插件、VS Code 插件、Serverless、脚本
"JS 能跑的地方 TS 都能跑"。本篇用小而完整的例子覆盖其余常见场景,每个都能做成可交付的小产品。
| 场景 | 运行环境 | 工具/框架 | 本篇示例 |
|---|---|---|---|
| 命令行工具 | Node.js | commander + @inquirer/prompts + tsup | acct 记账 CLI |
| 浏览器插件 | Chrome / Edge / Firefox | WXT + Vue | QA 助手:一键填表 + 导出 testid |
| 编辑器插件 | VS Code | 官方 Extension API | 插入测试用例模板 |
| Serverless / 边缘函数 | Cloudflare Workers / Vercel | Hono | 全球部署的记账 API |
| 日常脚本(替代部分 Python 脚本) | Node / Bun / Deno | tsx、faker | 生成测试数据 |
| 其他 | — | Discord/飞书机器人、Office 插件、Figma 插件、游戏(Phaser) | 思路同上 |
1. 命令行工具:acct
mkdir acct-cli && cd acct-cli && npm init -y
npm install commander @inquirer/prompts picocolors
npm install -D typescript tsx tsup @types/node
// src/cli.ts
import { Command } from "commander";
import { input, select, number } from "@inquirer/prompts";
import pc from "picocolors";
import { readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
interface Entry { type: "income" | "expense"; category: string; amount: number; date: string }
const DATA_FILE = join(homedir(), ".acct.json");
async function loadAll(): Promise<Entry[]> {
try { return JSON.parse(await readFile(DATA_FILE, "utf-8")); } catch { return []; }
}
const program = new Command().name("acct").description("命令行记账").version("1.0.0");
program
.command("add")
.description("新增一笔")
.option("-a, --amount <amount>", "金额", parseFloat)
.option("-c, --category <category>", "分类")
.option("-i, --income", "记为收入", false)
.action(async (opts: { amount?: number; category?: string; income: boolean }) => {
// 参数没给全就交互式询问(≈ Python 的 click.prompt)
const amount = opts.amount ?? (await number({ message: "金额", required: true }))!;
const category = opts.category ?? (await input({ message: "分类", default: "food" }));
const type = opts.income ? "income" : "expense";
const all = await loadAll();
all.push({ type, category, amount, date: new Date().toISOString().slice(0, 10) });
await writeFile(DATA_FILE, JSON.stringify(all, null, 2));
console.log(pc.green(`✔ 已记录 ${type === "income" ? "+" : "-"}${amount} (${category})`));
});
program
.command("report")
.description("月度报表")
.argument("[month]", "月份 YYYY-MM", new Date().toISOString().slice(0, 7))
.action(async (month: string) => {
const list = (await loadAll()).filter(e => e.date.startsWith(month));
const sum = (t: Entry["type"]) => list.filter(e => e.type === t).reduce((s, e) => s + e.amount, 0);
console.table(list);
console.log(`收入 ${pc.green(sum("income").toFixed(2))} 支出 ${pc.red(sum("expense").toFixed(2))}`);
});
program
.command("clear")
.action(async () => {
const ok = await select({ message: "确定清空?", choices: [{ name: "否", value: false }, { name: "是", value: true }] });
if (ok) await writeFile(DATA_FILE, "[]");
});
await program.parseAsync();
// package.json 关键配置
{
"type": "module",
"bin": { "acct": "dist/cli.js" },
"scripts": {
"dev": "tsx src/cli.ts",
"build": "tsup src/cli.ts --format esm --banner.js \"#!/usr/bin/env node\""
}
}
npm run dev -- add -a 25 -c lunch # 开发时直接跑 TS
npm run build && npm link # 全局安装到本机,之后直接用 acct report
npm publish # 发布到 npm,别人 npx acct 即可使用
对比 Go 05 篇:Go 产出单个二进制、无需 Node;TS CLI 依赖 Node,但生态(交互、颜色、npm 分发)更方便。
2. 浏览器插件:QA 助手(WXT + Vue)
功能:
- 弹窗里点"填充测试数据",自动填满当前页面的表单;
- 点"导出 testid",列出页面所有
data-testid,方便写 Page Object。
npx wxt@latest init qa-helper # 选择 vue 模板
cd qa-helper && npm install
npm run dev # 自动打开一个加载了插件的 Chrome
entrypoints/
├── popup/ # 点击工具栏图标弹出的 Vue 页面
│ ├── index.html
│ ├── main.ts
│ └── App.vue
├── content.ts # 注入到网页中运行的脚本,可操作页面 DOM
└── background.ts # 后台常驻脚本(事件、右键菜单等)
// utils/messages.ts —— popup 与 content 之间的消息类型
export type QaMessage =
| { kind: "fill-form" }
| { kind: "collect-testids" };
export interface TestIdInfo { testid: string; tag: string; text: string }
// entrypoints/content.ts
import type { QaMessage, TestIdInfo } from "@/utils/messages";
export default defineContentScript({
matches: ["<all_urls>"],
main() {
browser.runtime.onMessage.addListener((msg: QaMessage, _sender, sendResponse) => {
if (msg.kind === "fill-form") {
sendResponse(fillForms());
} else if (msg.kind === "collect-testids") {
sendResponse(collectTestIds());
}
});
},
});
function fillForms(): number {
const samples: Record<string, string> = {
email: "qa+auto@example.com", tel: "13800000000", number: "100",
date: new Date().toISOString().slice(0, 10), text: "QA test",
};
const inputs = document.querySelectorAll<HTMLInputElement>("input:not([type=hidden]), textarea");
inputs.forEach((el) => {
el.value = samples[el.type] ?? samples.text!;
el.dispatchEvent(new Event("input", { bubbles: true })); // 让 Vue/React 感知到变化
});
return inputs.length;
}
function collectTestIds(): TestIdInfo[] {
return [...document.querySelectorAll<HTMLElement>("[data-testid]")].map(el => ({
testid: el.dataset.testid!,
tag: el.tagName.toLowerCase(),
text: (el.innerText ?? "").slice(0, 30),
}));
}
<!-- entrypoints/popup/App.vue -->
<script setup lang="ts">
import { ref } from "vue";
import type { QaMessage, TestIdInfo } from "@/utils/messages";
const result = ref("");
const testIds = ref<TestIdInfo[]>([]);
async function send<T>(msg: QaMessage): Promise<T> {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
return browser.tabs.sendMessage(tab!.id!, msg);
}
async function fill() {
const count = await send<number>({ kind: "fill-form" });
result.value = `已填充 ${count} 个输入框`;
}
async function collect() {
testIds.value = await send<TestIdInfo[]>({ kind: "collect-testids" });
// 生成 Page Object 代码片段并复制到剪贴板
const code = testIds.value
.map(t => ` readonly ${t.testid.replace(/-(\w)/g, (_, c) => c.toUpperCase())} = this.page.getByTestId("${t.testid}");`)
.join("\n");
await navigator.clipboard.writeText(code);
result.value = `已复制 ${testIds.value.length} 个定位器`;
}
</script>
<template>
<div style="width: 320px; padding: 12px">
<button @click="fill">填充测试数据</button>
<button @click="collect">导出 testid</button>
<p>{{ result }}</p>
<ul><li v-for="t in testIds" :key="t.testid">{{ t.tag }} · {{ t.testid }}</li></ul>
</div>
</template>
npm run build # 产物 .output/chrome-mv3,可在 chrome://extensions 加载或打包上架
npm run zip # 生成上传商店用的 zip
3. VS Code 插件:插入测试用例模板
npx --package yo --package generator-code -- yo code # 选 New Extension (TypeScript)
// src/extension.ts
import * as vscode from "vscode";
export function activate(context: vscode.ExtensionContext) {
const cmd = vscode.commands.registerCommand("qaHelper.insertCase", async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) return;
const caseId = await vscode.window.showInputBox({ prompt: "TestLink Case ID", placeHolder: "EAAS-1234" });
if (!caseId) return;
const fn = caseId.toLowerCase().replace("-", "");
await editor.insertSnippet(new vscode.SnippetString(
`def test_\${1:name}_${fn}(\${2:fixtures}):\n """\n @case_number: ${caseId}\n """\n $0\n`,
));
});
context.subscriptions.push(cmd);
}
export function deactivate() {}
// package.json 中声明命令
"contributes": {
"commands": [{ "command": "qaHelper.insertCase", "title": "QA: 插入测试用例模板" }]
}
按 F5 启动一个调试窗口,Cmd+Shift+P 输入 "QA: 插入测试用例模板" 即可。npx @vscode/vsce package 打包 .vsix 分发给团队。
4. Serverless / 边缘函数:Hono
Hono 是一个极小的 Web 框架,同一份代码可以跑在 Cloudflare Workers、Vercel、AWS Lambda、Node、Bun 上。
不用管服务器,按请求计费,免费额度足够个人项目。
npm create hono@latest acct-edge # 选择 cloudflare-workers
// src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
type Bindings = { DB: D1Database; API_TOKEN: string }; // 在 wrangler 配置中绑定
const app = new Hono<{ Bindings: Bindings }>();
app.use("/api/*", cors());
app.use("/api/*", async (c, next) => {
if (c.req.header("Authorization") !== `Bearer ${c.env.API_TOKEN}`) return c.json({ error: "unauthorized" }, 401);
await next();
});
app.get("/api/records", async (c) => {
const month = c.req.query("month") ?? "";
const { results } = await c.env.DB
.prepare("SELECT * FROM records WHERE date LIKE ? ORDER BY date DESC")
.bind(`${month}%`).all();
return c.json(results);
});
const recordSchema = z.object({
type: z.enum(["income", "expense"]), category: z.string().min(1),
amount: z.number().positive(), date: z.string(),
});
app.post("/api/records", zValidator("json", recordSchema), async (c) => {
const r = c.req.valid("json"); // 已校验且有类型
await c.env.DB.prepare("INSERT INTO records (type, category, amount, date) VALUES (?, ?, ?, ?)")
.bind(r.type, r.category, r.amount, r.date).run();
return c.json(r, 201);
});
export default app;
npm run dev # 本地模拟
npm run deploy # wrangler deploy,几秒钟部署到全球节点
5. 日常脚本:用 TS 替代部分 Python 脚本
npm install -D tsx && npm install @faker-js/faker
npx tsx gen-data.ts 100 > records.csv # 直接运行 TS,无需编译
# 或者用 Bun:bun gen-data.ts 100
// gen-data.ts —— 生成测试数据(≈ Python 的 Faker)
import { faker } from "@faker-js/faker/locale/zh_CN";
const count = Number(process.argv[2] ?? 10);
const categories = ["food", "transport", "rent", "salary", "shopping"] as const;
console.log("date,type,category,amount,note");
for (let i = 0; i < count; i++) {
const category = faker.helpers.arrayElement(categories);
const type = category === "salary" ? "income" : "expense";
const date = faker.date.recent({ days: 60 }).toISOString().slice(0, 10);
const amount = faker.number.float({ min: 1, max: type === "income" ? 20000 : 500, fractionDigits: 2 });
console.log([date, type, category, amount, faker.lorem.words(3)].join(","));
}
什么时候用 TS 写脚本、什么时候继续用 Python?
- 用 TS:脚本要和前端/Node 项目共享类型和代码;要操作浏览器(Playwright);团队主要是前端。
- 用 Python:数据分析(pandas)、机器学习、已有大量 Python 工具链。
6. 练习
- 完成
acctCLI,增加export --format csv|json子命令,并npm link到本机。 - 给 QA 助手插件增加:右键菜单"复制该元素的 Playwright 定位器"(background 中用
browser.contextMenus)。 - 把 Hono 版 API 部署到 Cloudflare Workers,用 08 篇的手机 App 调用它。
- 写一个 VS Code 插件命令:选中一段 JSON,自动生成对应的 TS interface。
文章评论 (0)