TypeScript 05:实战 —— 前端(Vue 3)与工程化
本篇覆盖 TS 最典型的三个使用场景:
- 浏览器端调用 API(可直接用于本 Django 博客的模板页面)
- Vue 3 + TypeScript 组件开发(
<script setup lang="ts">、组合式函数、Pinia) - 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. 练习
- 用 Vue 3 + Pinia 为第 04 篇的 API 实现:月份切换、新增、删除、分类饼图(
vue-echarts)。 - 用 Element Plus 的
el-table+el-form重写记账页面,并加上表单校验规则。 - 为页面写 3 个 Playwright 用例,其中 1 个用
page.routemock 数据;再写 1 个 Vitest 组件测试。 - 尝试把本博客的某个 Django 页面交互用 TS 重写,打包到
static/js/。
文章评论 (0)