Claude Code 实战 Vue 3 开发:TypeScript、Pinia 与测试
用 Claude Code 改进 Vue 3 项目:表单、Pinia、Composable、Vitest 与既有代码重构的实务流程。
在 Vue 3 项目里使用 Claude Code,真正有价值的地方不是让它快速生成一个组件,而是让它理解现有代码、保留 TypeScript 类型、拆出可复用逻辑、整理 Pinia 状态,并补上可以防止回归的测试。这样的工作更接近真实团队每天要做的改修,而不是演示性质的代码片段。
Vue 的灵活性很高,也因此容易产生混乱。一个项目里可能同时存在 Options API、Composition API、script setup、Pinia、旧的 Vuex 习惯、Nuxt 约定和本地工具函数。如果只对 Claude Code 说“帮我写一个 Vue 表单”,它可能写出看起来能运行、但不符合项目边界的代码。
本文以 Claude Code + Vue 3 + TypeScript 为前提,整理五个实务场景:既有 Vue 组件改修、表单实现、Pinia 状态管理、Composable 抽取、Vitest 测试追加。Vue 官方资料建议同时参考 Vue TypeScript 与 Composition API 指南、Pinia 官方文档 和 Vitest 测试指南。Claude Code 的相关内容可以继续阅读 TypeScript 技巧 与 测试策略指南。
项目前提
本文假设项目使用 Vue 3、TypeScript、Vite、Pinia、Vitest。适合的场景包括客服工单、后台管理、预约系统、内部审批、内容管理等。这类应用通常不是动画最难,而是表单状态、筛选、分页、共享数据、权限和测试组合在一起后变得复杂。
给 Claude Code 的上下文不能只写需求。更稳妥的做法是提供 harness,也就是“让智能体安全工作的脚手架”。它包括允许编辑的文件、必须遵守的项目约定、禁止事项、验证命令和审查重点。没有这个脚手架,Claude Code 可能为了解决局部问题而引入 any、改动 UI 文案、拆错状态层级。
| 场景 | 适合交给 Claude Code | 需要人工决定 |
|---|---|---|
| 既有 Vue 改修 | 阅读 SFC、找重复逻辑、补类型 | UI 兼容性与发布风险 |
| 表单实现 | 本地状态、校验、提交流程 | 文案、接口契约 |
| Pinia 状态 | store 类型、getter、action、测试 | 是否持久化、职责边界 |
| Composable 抽取 | 筛选、分页、复用逻辑 | 是否真的需要复用 |
| 测试追加 | 正常系、异常系、回归测试 | 覆盖范围与风险等级 |
架构关系图
当示例代码变多时,重点不是 Claude Code 能不能写,而是每个职责应该放在哪里。清楚的边界会让生成差分更容易审查。
flowchart LR
A[Vue SFC] --> B[Composable]
A --> C[Pinia Store]
B --> D[Vitest]
C --> D
E[Claude Code Prompt] --> A
E --> B
E --> C
E --> D
SFC 负责画面和用户事件。Composable 负责可复用的响应式逻辑,例如筛选和分页。Pinia store 负责跨页面共享的应用状态。Vitest 负责把不想被破坏的行为固定下来。提示词里明确这些边界,Claude Code 的输出会稳定很多。
创建 Vue 3 + TypeScript 基线
新项目可以先用 Vue 官方脚手架创建最小环境。既有项目则先确认当前的 typecheck 和单元测试通过,这样才能判断 Claude Code 的改动是否引入了新问题。
npm create vue@latest support-desk-vue -- --typescript --router --pinia --vitest
cd support-desk-vue
npm install
npm run dev
不要只写“用现代 Vue 实现”。应该明确包管理器、是否使用 Vue Router、是否使用 Pinia、单元测试命令是什么。既有项目中,先让 Claude Code 阅读 package.json、vite.config.ts、tsconfig.app.json、src/components、src/stores,再开始编辑。
用例1:实现类型安全的表单SFC
表单是很适合练习 Claude Code 的场景,因为它同时包含 v-model、props、emit、校验、异步提交和 store action。下面的文件可以放在 src/components/TicketForm.vue。它不直接修改 props,而是用本地 ref 保存可编辑值,提交成功后通过 emit 通知父组件。
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useTicketStore, type TicketPriority } from '@/stores/ticketStore';
const props = withDefaults(
defineProps<{
initialTitle?: string;
defaultPriority?: TicketPriority;
}>(),
{
initialTitle: '',
defaultPriority: 'medium',
},
);
const emit = defineEmits<{
submitted: [id: string];
}>();
const store = useTicketStore();
const title = ref(props.initialTitle);
const description = ref('');
const priority = ref<TicketPriority>(props.defaultPriority);
const touched = ref(false);
const titleError = computed(() => {
if (!touched.value) return '';
if (title.value.trim().length < 5) return 'Use at least 5 characters.';
return '';
});
const descriptionError = computed(() => {
if (!touched.value) return '';
if (description.value.trim().length < 20) return 'Use at least 20 characters.';
return '';
});
const canSubmit = computed(() => {
return (
title.value.trim().length >= 5 &&
description.value.trim().length >= 20 &&
!store.isSaving
);
});
async function submit() {
touched.value = true;
if (!canSubmit.value) return;
const ticket = await store.saveTicket({
title: title.value.trim(),
description: description.value.trim(),
priority: priority.value,
});
title.value = '';
description.value = '';
priority.value = props.defaultPriority;
touched.value = false;
emit('submitted', ticket.id);
}
</script>
<template>
<form class="ticket-form" @submit.prevent="submit">
<label>
Title
<input v-model="title" name="title" @blur="touched = true" />
</label>
<p v-if="titleError" class="error">{{ titleError }}</p>
<label>
Description
<textarea v-model="description" name="description" @blur="touched = true" />
</label>
<p v-if="descriptionError" class="error">{{ descriptionError }}</p>
<label>
Priority
<select v-model="priority" name="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<button type="submit" :disabled="!canSubmit">
{{ store.isSaving ? 'Saving...' : 'Create ticket' }}
</button>
</form>
</template>
让 Claude Code 写这种组件时,除了说明目标,也要说明禁止事项:不要直接修改 props,不要引入 any,不要临时增加 UI 库,不要把 API 细节塞进组件。负面约束能减少后期清理成本。
用例2:用 Pinia 分离共享状态
如果工单列表会被多个页面使用,表单组件就不应该自己持有全部状态。Pinia 可以把共享状态、保存中标记、getter 和 action 放到清晰的位置。
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export type TicketPriority = 'low' | 'medium' | 'high';
export type TicketStatus = 'open' | 'closed';
export interface Ticket {
id: string;
title: string;
description: string;
priority: TicketPriority;
status: TicketStatus;
createdAt: string;
}
export type NewTicketInput = Pick<Ticket, 'title' | 'description' | 'priority'>;
export const useTicketStore = defineStore('tickets', () => {
const tickets = ref<Ticket[]>([]);
const isSaving = ref(false);
const openTickets = computed(() => {
return tickets.value.filter((ticket) => ticket.status === 'open');
});
function addTicket(input: NewTicketInput) {
const ticket: Ticket = {
id: crypto.randomUUID(),
...input,
status: 'open',
createdAt: new Date().toISOString(),
};
tickets.value.unshift(ticket);
return ticket;
}
async function saveTicket(input: NewTicketInput) {
isSaving.value = true;
try {
await new Promise((resolve) => setTimeout(resolve, 150));
return addTicket(input);
} finally {
isSaving.value = false;
}
}
function closeTicket(id: string) {
const ticket = tickets.value.find((item) => item.id === id);
if (ticket) ticket.status = 'closed';
}
return {
tickets,
isSaving,
openTickets,
addTicket,
saveTicket,
closeTicket,
};
});
真实项目中,saveTicket 会调用类型化的 API client。建议先让 Claude Code 固定 store 的领域模型,再接入网络请求,最后补错误处理和重试。这样每一步差分都比较容易审查。
用例3:抽取 Composable
Composable 是把 Vue 响应式逻辑做成可复用函数。对初学者来说,可以理解为“从画面里抽出来的逻辑部件”。筛选、优先级选择和分页通常适合抽取。
import { computed, ref, watch, type Ref } from 'vue';
import type { Ticket, TicketPriority } from '@/stores/ticketStore';
export function useFilteredTickets(tickets: Ref<Ticket[]>) {
const query = ref('');
const selectedPriority = ref<TicketPriority | 'all'>('all');
const currentPage = ref(1);
const pageSize = ref(10);
const filteredTickets = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase();
return tickets.value.filter((ticket) => {
const matchesQuery =
normalizedQuery.length === 0 ||
ticket.title.toLowerCase().includes(normalizedQuery) ||
ticket.description.toLowerCase().includes(normalizedQuery);
const matchesPriority =
selectedPriority.value === 'all' ||
ticket.priority === selectedPriority.value;
return matchesQuery && matchesPriority;
});
});
const totalPages = computed(() => {
return Math.max(1, Math.ceil(filteredTickets.value.length / pageSize.value));
});
const pagedTickets = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
return filteredTickets.value.slice(start, start + pageSize.value);
});
watch([query, selectedPriority], () => {
currentPage.value = 1;
});
return {
query,
selectedPriority,
currentPage,
pageSize,
filteredTickets,
pagedTickets,
totalPages,
};
}
这里的 watch 只做一件事:筛选条件变化时把页码重置为 1。其他派生结果都使用 computed。可以在提示词中写明:派生值用 computed,只有明确副作用才用 watch。
用例4:追加 Vitest 回归测试
Claude Code 让实现速度变快后,回归测试更重要。先测试 Pinia store 是一个低成本选择,因为它接近业务行为,也比 DOM 交互更稳定。
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTicketStore } from './ticketStore';
describe('useTicketStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.stubGlobal('crypto', {
randomUUID: () => 'ticket-1',
});
});
it('adds a new open ticket', () => {
const store = useTicketStore();
const ticket = store.addTicket({
title: 'Cannot submit expense form',
description: 'The submit button stays disabled after all fields are filled.',
priority: 'high',
});
expect(ticket.id).toBe('ticket-1');
expect(store.tickets).toHaveLength(1);
expect(store.openTickets).toHaveLength(1);
});
it('closes an existing ticket', () => {
const store = useTicketStore();
const ticket = store.addTicket({
title: 'Profile image is broken',
description: 'The image URL returns 404 on the account settings page.',
priority: 'medium',
});
store.closeTicket(ticket.id);
expect(store.openTickets).toHaveLength(0);
expect(store.tickets[0]?.status).toBe('closed');
});
});
不要只让 Claude Code “多写一些测试”。更好的说法是指定行为:短输入时不能提交、有效输入可以保存、保存中按钮禁用、保存失败时状态恢复、store 状态正确变化。这样得到的测试更接近真实风险。
既有 Vue 改修的提示词模板
既有代码改修最怕一次性大重写。建议先让 Claude Code 读代码并列出风险,再补类型,再抽取 Composable,必要时整理 Pinia,最后补测试。
You are working in a Vue 3 + TypeScript + Pinia project.
Goal:
Refactor the ticket form so validation logic is typed, reusable, and covered by Vitest.
Allowed files:
- src/components/TicketForm.vue
- src/composables/useFilteredTickets.ts
- src/stores/ticketStore.ts
- src/stores/ticketStore.test.ts
Rules:
- Use <script setup lang="ts">.
- Do not mutate props directly.
- Do not introduce any.
- Prefer computed for derived values.
- Use watch only for explicit side effects.
- Keep UI text unchanged unless a validation message is missing.
Verification:
- npm run typecheck
- npm run test:unit
After editing, explain the risk areas and the tests you added.
这个提示词把 Claude Code 可以判断的部分和必须由维护者确认的部分分开。抽取方式、类型补强、测试雏形可以交给 Claude Code;UI 文案、API 契约、发布时机和兼容性风险应该由人来确认。
常见落坑
第一个坑是 Options API 与 Composition API 混用却没有计划。Vue 3 支持两者,但同一个组件里同时出现 data、methods、setup、script setup 会让审查困难。小修就保持现有风格,迁移就按组件逐个迁移。
第二个坑是误解 ref 和 reactive。基础值、数组、会整体替换的值通常用 ref 更容易审查。reactive 适合一组内聚对象,但解构时容易让响应式行为变得不直观。新团队建议从 ref 为主开始。
第三个坑是直接修改 props。子组件不应该悄悄改父组件拥有的状态。应该用本地 ref 保存草稿,通过 emit 返回父组件,或者在团队约定后使用 defineModel。
第四个坑是滥用 watch。能推导的值用 computed。watch 留给路由查询同步、页码重置、API 调用、本地存储写入这类副作用。
第五个坑是类型变成 any。Claude Code 有时会用 as any 绕过类型错误。审查时必须检查 any、unknown、类型断言和空数组推断。
第六个坑是过度拆分 SFC。很多不会复用的小组件会制造大量 props 和 emit。先抽逻辑到 Composable,UI 只有在真正复用或语义独立时再拆。
第七个坑是测试不足。生成代码看起来整洁,不代表边界值正确。表单至少要覆盖短输入、有效输入、保存中、保存成功、保存失败和 store 状态变化。
实务流程与咨询导线
在真实仓库里,不建议从“把整个页面做完”开始。先让 Claude Code 检查现有组件并列出风险,然后一次只做一个小改动,运行 typecheck 和测试,再进入下一步。这样差分更小,审查也更可靠。
团队使用时,可以把规则写进 CLAUDE.md:Vue 组件默认使用 script setup,共享状态使用 Pinia setup store,派生值使用 computed,副作用使用 watch,提交前运行 npm run typecheck 和 npm run test:unit。如果需要把这些规则落到真实团队流程中,ClaudeCodeLab 的 Claude Code 咨询与培训 可以一起整理代码规范、提示词模板和审查清单。
总结
Claude Code 可以加速 Vue 3 的既有改修、表单、Pinia、Composable 和测试工作。关键不是让它一次写更多代码,而是把边界讲清楚。允许编辑哪些文件、使用哪种 Vue 风格、不能改什么、如何验证,都应该写进提示词。
初学者可以从一个 SFC、一个 Composable、一个 store、一个测试文件开始。这个小循环既能学习 Vue 的心智模型,也能产出接近生产项目的代码。
实际试用结果
我用一个小型 Vue 3 + TypeScript 项目验证了本文流程。一次性让 Claude Code 生成表单、store、Composable 和测试时,差分较大,审查时间更长。按 SFC、Pinia、Composable、Vitest 分步进行后,问题更容易定位。最有效的约束是“不要直接修改 props”“不要引入 any”“派生值使用 computed”。生成后的代码更容易通过 npm run typecheck 和 npm run test:unit 进行确认。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。