编程语言

TypeScript教程(九)更多应用场景 —— CLI、浏览器插件、VS Code 插件、Serverless、脚本

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)

功能:

  1. 弹窗里点"填充测试数据",自动填满当前页面的表单;
  2. 点"导出 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. 练习

  1. 完成 acct CLI,增加 export --format csv|json 子命令,并 npm link 到本机。
  2. 给 QA 助手插件增加:右键菜单"复制该元素的 Playwright 定位器"(background 中用 browser.contextMenus)。
  3. 把 Hono 版 API 部署到 Cloudflare Workers,用 08 篇的手机 App 调用它。
  4. 写一个 VS Code 插件命令:选中一段 JSON,自动生成对应的 TS interface。

文章评论 (0)