Use Cases (更新: 2026/6/2)

Claude Code 实战 Vue 3 开发:TypeScript、Pinia 与测试

用 Claude Code 改进 Vue 3 项目:表单、Pinia、Composable、Vitest 与既有代码重构的实务流程。

Claude Code 实战 Vue 3 开发:TypeScript、Pinia 与测试

在 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 进行确认。

#Claude Code #Vue.js #Nuxt.js #Pinia #Composition API
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

让 Claude Code 真正进入可验证的工作流

先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。