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.jsonvite.config.tstsconfig.app.jsonsrc/componentssrc/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 支持两者,但同一个组件里同时出现 datamethodssetupscript setup 会让审查困难。小修就保持现有风格,迁移就按组件逐个迁移。

第二个坑是误解 refreactive。基础值、数组、会整体替换的值通常用 ref 更容易审查。reactive 适合一组内聚对象,但解构时容易让响应式行为变得不直观。新团队建议从 ref 为主开始。

第三个坑是直接修改 props。子组件不应该悄悄改父组件拥有的状态。应该用本地 ref 保存草稿,通过 emit 返回父组件,或者在团队约定后使用 defineModel

第四个坑是滥用 watch。能推导的值用 computedwatch 留给路由查询同步、页码重置、API 调用、本地存储写入这类副作用。

第五个坑是类型变成 any。Claude Code 有时会用 as any 绕过类型错误。审查时必须检查 anyunknown、类型断言和空数组推断。

第六个坑是过度拆分 SFC。很多不会复用的小组件会制造大量 props 和 emit。先抽逻辑到 Composable,UI 只有在真正复用或语义独立时再拆。

第七个坑是测试不足。生成代码看起来整洁,不代表边界值正确。表单至少要覆盖短输入、有效输入、保存中、保存成功、保存失败和 store 状态变化。

实务流程与咨询导线

在真实仓库里,不建议从“把整个页面做完”开始。先让 Claude Code 检查现有组件并列出风险,然后一次只做一个小改动,运行 typecheck 和测试,再进入下一步。这样差分更小,审查也更可靠。

团队使用时,可以把规则写进 CLAUDE.md:Vue 组件默认使用 script setup,共享状态使用 Pinia setup store,派生值使用 computed,副作用使用 watch,提交前运行 npm run typechecknpm 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 typechecknpm run test:unit 进行确认。

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

免费 PDF: Claude Code 速查表

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

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

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

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