编程语言

TypeScript教程(五)实战 —— 前端(Vue 3)与工程化

TypeScript 05:实战 —— 前端(Vue 3)与工程化

本篇覆盖 TS 最典型的三个使用场景:

  1. 浏览器端调用 API(可直接用于本 Django 博客的模板页面)
  2. Vue 3 + TypeScript 组件开发(<script setup lang="ts">、组合式函数、Pinia)
  3. Playwright 端到端自动化测试(对 QA 很实用,可对照 Selenium + pytest)

1. 场景一:类型安全的 API 客户端

不管是原生页面还是 Vue,都建议先封装一个 API 客户端。

// src/api/client.ts
export class ApiError extends Error {
  constructor(public status: number, public body: unknown) {
    super(`API error ${status}`);
  }
}

async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(url, {
    ...init,
    headers: { "Content-Type": "application/json", ...init.headers },
  });
  const body = res.status === 204 ? null : await res.json();
  if (!res.ok) throw new ApiError(res.status, body);
  return body as T;
}

export const api = {
  get: <T>(url: string) => request<T>(url),
  post: <T>(url: string, data: unknown) =>
    request<T>(url, { method: "POST", body: JSON.stringify(data) }),
  del: (url: string) => request<void>(url, { method: "DELETE" }),
};

// src/api/records.ts
import { api } from "./client";

export interface AccountRecord {
  id: number; type: "income" | "expense"; category: string; amount: number; date: string;
}
export const recordsApi = {
  list: (month: string) => api.get<AccountRecord[]>(`/api/records?month=${month}`),
  create: (data: Omit<AccountRecord, "id">) => api.post<AccountRecord>("/api/records", data),
  remove: (id: number) => api.del(`/api/records/${id}`),
};

原生 DOM 操作(不引入框架,适合给 Django 模板增强交互)

// 给博客文章增加"点赞"按钮
const btn = document.querySelector<HTMLButtonElement>("#like-btn");
const counter = document.querySelector<HTMLSpanElement>("#like-count");

btn?.addEventListener("click", async () => {
  btn.disabled = true;
  try {
    const { likes } = await api.post<{ likes: number }>(`/blog/${btn.dataset.postId}/like/`, {});
    if (counter) counter.textContent = String(likes);
  } finally {
    btn.disabled = false;
  }
});

Django 需要 CSRF token:可从 cookie 读取 csrftoken 放入 X-CSRFToken 请求头。
编译:用 esbuild 或 vite build 打包成一个 JS 文件放到 static/ 下即可。

2. 场景二:Vue 3 + TypeScript

2.1 创建项目

npm create vue@latest accounting-web
# 交互式勾选:TypeScript ✔  Vue Router ✔  Pinia ✔  Vitest ✔  Playwright ✔  ESLint ✔
cd accounting-web && npm install && npm run dev

vite.config.ts 中配置代理,前端开发时把 /api 转发到第 04 篇的后端:

import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [vue()],
  resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
  server: { proxy: { "/api": "http://localhost:3000" } },
});

2.2 如果你用过 Vue 2 / 选项式 API:对照表

Vue 2(选项式) Vue 3 + TS(组合式<script setup>)
data() { return { n: 0 } } const n = ref(0)(脚本中用 n.value,模板中直接 n)
data 里的对象 const form = reactive({...})
computed: { total() {...} } const total = computed(() => ...)
watch: { month() {...} } watch(() => props.month, fn)
props: { month: String } const props = defineProps<{ month: string }>()
this.$emit("changed", v) const emit = defineEmits<{ changed: [v: number] }>()
mounted() onMounted(() => {...})
mixins 组合式函数(composables,useXxx)
Vuex Pinia
this.$refs.input const input = useTemplateRef<HTMLInputElement>("input")(Vue 3.5+)

核心好处:props、emit、ref 全部有类型,父组件传错属性、模板里写错字段名,vue-tsc 都会报错。

2.3 组件:记账列表 + 新增表单

<!-- src/components/RecordList.vue -->
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { recordsApi, type AccountRecord } from "@/api/records";

const props = defineProps<{ month: string }>();
const emit = defineEmits<{ changed: [balance: number] }>();

const records = ref<AccountRecord[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);

type RecordForm = Omit<AccountRecord, "id">;
const emptyForm = (): RecordForm => ({ type: "expense", category: "", amount: 0, date: "" });
const form = reactive<RecordForm>(emptyForm());

async function load() {
  loading.value = true;
  try {
    records.value = await recordsApi.list(props.month);
    error.value = null;
  } catch (e) {
    error.value = (e as Error).message;
  } finally {
    loading.value = false;
  }
}

// month 变化时重新加载;immediate 相当于 mounted 时先执行一次
watch(() => props.month, load, { immediate: true });

const balance = computed(() =>
  records.value.reduce((sum, r) => sum + (r.type === "income" ? r.amount : -r.amount), 0),
);
watch(balance, (v) => emit("changed", v));

async function handleSubmit() {
  await recordsApi.create({ ...form });
  Object.assign(form, emptyForm());
  await load();
}

async function remove(id: number) {
  await recordsApi.remove(id);
  await load();
}
</script>

<template>
  <section>
    <form @submit.prevent="handleSubmit">
      <select v-model="form.type" name="type">
        <option value="expense">支出</option>
        <option value="income">收入</option>
      </select>
      <input v-model.trim="form.category" name="category" placeholder="分类" required />
      <input v-model.number="form.amount" name="amount" type="number" step="0.01" required />
      <input v-model="form.date" name="date" type="date" required />
      <button type="submit">添加</button>
    </form>

    <p v-if="loading">加载中...</p>
    <p v-if="error" role="alert">{{ error }}</p>

    <table>
      <tbody>
        <tr v-for="r in records" :key="r.id" data-testid="record-row">
          <td>{{ r.date }}</td>
          <td>{{ r.category }}</td>
          <td>{{ r.type === "income" ? "+" : "-" }}{{ r.amount }}</td>
          <td><button @click="remove(r.id)">删除</button></td>
        </tr>
      </tbody>
    </table>
    <p>结余:<strong data-testid="balance">{{ balance.toFixed(2) }}</strong></p>
  </section>
</template>

父组件使用:

<script setup lang="ts">
import { ref } from "vue";
import RecordList from "@/components/RecordList.vue";

const month = ref(new Date().toISOString().slice(0, 7)); // "2026-11"
</script>

<template>
  <input v-model="month" type="month" />
  <RecordList :month="month" @changed="(b) => console.log('结余', b)" />
  <!-- 写成 :mounth="month" 或者 @changed 回调参数类型不符,vue-tsc 会报错 -->
</template>

2.4 组合式函数(composable,≈ 可复用的 mixin,但类型安全)

// src/composables/useAsync.ts
import { ref, shallowRef, watch, type WatchSource } from "vue";

export function useAsync<T>(fn: () => Promise<T>, source: WatchSource) {
  const data = shallowRef<T>();
  const error = ref<Error | null>(null);
  const loading = ref(false);
  let seq = 0; // 防止旧请求晚返回覆盖新结果(竞态)

  async function run() {
    const id = ++seq;
    loading.value = true;
    try {
      const result = await fn();
      if (id === seq) { data.value = result; error.value = null; }
    } catch (e) {
      if (id === seq) error.value = e as Error;
    } finally {
      if (id === seq) loading.value = false;
    }
  }

  watch(source, run, { immediate: true });
  return { data, error, loading, reload: run };
}

// 用法(在 <script setup> 中)
// const { data: records, loading, error, reload } =
//   useAsync(() => recordsApi.list(props.month), () => props.month);

2.5 Pinia 状态管理(≈ Vuex,但对 TS 友好得多)

// src/stores/records.ts
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { recordsApi, type AccountRecord } from "@/api/records";

export const useRecordStore = defineStore("records", () => {
  const month = ref(new Date().toISOString().slice(0, 7));
  const records = ref<AccountRecord[]>([]);

  const balance = computed(() =>
    records.value.reduce((s, r) => s + (r.type === "income" ? r.amount : -r.amount), 0));

  const byCategory = computed(() => {
    const m = new Map<string, number>();
    for (const r of records.value.filter(r => r.type === "expense")) {
      m.set(r.category, (m.get(r.category) ?? 0) + r.amount);
    }
    return [...m].map(([name, value]) => ({ name, value })); // 直接喂给 ECharts 饼图
  });

  async function fetch() {
    records.value = await recordsApi.list(month.value);
  }

  return { month, records, balance, byCategory, fetch };
});

// 组件中
// const store = useRecordStore();
// const { records, balance } = storeToRefs(store);  // 解构保持响应式

2.6 组件单元测试(Vitest + @vue/test-utils ≈ pytest + mock)

// src/components/__tests__/RecordList.spec.ts
import { describe, it, expect, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import RecordList from "../RecordList.vue";
import { recordsApi } from "@/api/records";

vi.mock("@/api/records", () => ({
  recordsApi: { list: vi.fn(), create: vi.fn(), remove: vi.fn() },
}));

describe("RecordList", () => {
  it("根据记录计算结余", async () => {
    vi.mocked(recordsApi.list).mockResolvedValue([
      { id: 1, type: "income", category: "salary", amount: 1000, date: "2026-11-01" },
      { id: 2, type: "expense", category: "food", amount: 200, date: "2026-11-01" },
    ]);
    const wrapper = mount(RecordList, { props: { month: "2026-11" } });
    await flushPromises();                     // 等待异步加载完成
    expect(wrapper.findAll('[data-testid="record-row"]')).toHaveLength(2);
    expect(wrapper.get('[data-testid="balance"]').text()).toBe("800.00");
    expect(wrapper.emitted("changed")?.[0]).toEqual([800]);
  });
});

vitest.config.ts 需设置 test: { environment: "jsdom" }(create-vue 勾选 Vitest 时已自动配置)。

2.7 常用 UI 组件库

  • Element Plus:后台管理系统首选,国内资料多。
  • Naive UI:TS 编写,类型完善。
  • Vuetify / PrimeVue:Material 风格 / 组件最全。
  • 图表:ECharts(vue-echarts)。

3. 场景三:Playwright 端到端测试(对照 Selenium + pytest)

npm init playwright@latest
npx playwright test            # 运行
npx playwright test --ui       # 可视化调试
npx playwright codegen https://example.com   # 录制生成代码
Selenium + pytest Playwright Test
WebDriverWait + EC 自动等待(locator 默认等待可操作)
fixture test.beforeEach / 自定义 fixture
pytest.mark.parametrize for 循环生成 test
POM 类 同样推荐 POM
截图/日志插件 内置 trace、截图、视频

Page Object

// tests/pages/RecordPage.ts
import { type Page, type Locator, expect } from "@playwright/test";

export class RecordPage {
  readonly rows: Locator;
  readonly balance: Locator;

  constructor(private page: Page) {
    this.rows = page.getByTestId("record-row");
    this.balance = page.getByTestId("balance");
  }

  async goto() {
    await this.page.goto("/");
  }

  async addRecord(r: { type: "income" | "expense"; category: string; amount: number; date: string }) {
    await this.page.locator("select[name=type]").selectOption(r.type);
    await this.page.getByPlaceholder("分类").fill(r.category);
    await this.page.locator("input[name=amount]").fill(String(r.amount));
    await this.page.locator("input[name=date]").fill(r.date);
    await this.page.getByRole("button", { name: "添加" }).click();
  }

  async expectBalance(value: string) {
    await expect(this.balance).toHaveText(value);
  }
}

测试用例

// tests/record.spec.ts
import { test, expect } from "@playwright/test";
import { RecordPage } from "./pages/RecordPage";

test.describe("记账页面", () => {
  test("新增收入和支出后结余正确", async ({ page }) => {
    const recordPage = new RecordPage(page);
    await recordPage.goto();
    await recordPage.addRecord({ type: "income", category: "salary", amount: 1000, date: "2026-11-01" });
    await recordPage.addRecord({ type: "expense", category: "food", amount: 200, date: "2026-11-01" });
    await expect(recordPage.rows).toHaveCount(2);
    await recordPage.expectBalance("800.00");
  });

  // 参数化
  for (const amount of [0.01, 99999]) {
    test(`支持边界金额 ${amount}`, async ({ page }) => {
      const recordPage = new RecordPage(page);
      await recordPage.goto();
      await recordPage.addRecord({ type: "expense", category: "test", amount, date: "2026-11-01" });
      await expect(recordPage.rows.first()).toContainText(String(amount));
    });
  }

  // 拦截/Mock 接口:模拟后端错误
  test("接口失败时显示错误信息", async ({ page }) => {
    await page.route("**/api/records*", route => route.fulfill({ status: 500, body: "{}" }));
    await page.goto("/");
    await expect(page.getByRole("alert")).toBeVisible();
  });
});

API 测试(Playwright 也能做)

test("API: 创建记录", async ({ request }) => {
  const res = await request.post("/api/records", {
    data: { type: "expense", category: "food", amount: 10, date: "2026-11-01" },
  });
  expect(res.status()).toBe(201);
  expect(await res.json()).toMatchObject({ category: "food" });
});

4. 工程化必备

工具 作用 Python 对照
tsc --noEmit / vue-tsc --noEmit 类型检查(后者支持.vue 文件) mypy / pyright
ESLint (typescript-eslint、eslint-plugin-vue) 代码规范 flake8 / ruff
Prettier 格式化 black
Vitest / Jest 单元测试 pytest
Vite / esbuild 构建打包 —
pnpm / npm 包管理 uv / pip
VS Code 插件Vue - Official(原 Volar) .vue 文件的类型提示 Pylance

推荐脚本:

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "lint": "eslint .",
    "format": "prettier --write src/",
    "typecheck": "vue-tsc --noEmit",
    "test": "vitest run",
    "e2e": "playwright test"
  }
}

tsconfig.json 中强烈建议开启:"strict": true、"noUncheckedIndexedAccess": true。

5. 练习

  1. 用 Vue 3 + Pinia 为第 04 篇的 API 实现:月份切换、新增、删除、分类饼图(vue-echarts)。
  2. 用 Element Plus 的 el-table + el-form 重写记账页面,并加上表单校验规则。
  3. 为页面写 3 个 Playwright 用例,其中 1 个用 page.route mock 数据;再写 1 个 Vitest 组件测试。
  4. 尝试把本博客的某个 Django 页面交互用 TS 重写,打包到 static/js/。

文章评论 (0)