跳转到内容

Vue开发规范

组合式 API、组件、Pinia


  • [Requirement] 组件必须使用 <script setup lang="ts"> 组合式 API 语法,禁止使用 Options API(新代码);Props 与 Emits 使用 defineProps / defineEmits 声明并带类型。
  • [Requirement] Props 必须使用 withDefaults 提供默认值,必填项不给默认值;运行时校验与类型声明同时存在。
  • [Requirement] 组件文件一个文件只导出一个组件,文件名使用大驼峰 XxxComponent.vue 或短横线 xxx-component.vue,全局统一一种风格(本规范默认短横线 kebab-case)。
  • [Requirement] 模板中组件标签使用 kebab-case(<user-card />),自闭合;Props 传参使用 kebab-case(:user-id="id")。
  • [Requirement] 事件命名使用 kebab-case,defineEmits 中声明,如 defineEmits<{ 'update:modelValue': [value: string] }>()。
  • [Standard] 单文件组件按 <script setup> → <template> → <style scoped> 顺序组织;<style> 必须加 scoped,全局样式单独文件。
  • [Standard] 组件职责单一,超过 300 行或嵌套层级超过 4 层时拆分为子组件;复用逻辑提取到 composables/。
  • [Standard] 列表渲染 v-for 必须绑定 :key 为业务唯一 ID,禁止使用数组下标;v-for 与 v-if 禁止同元素使用。
  • [Default] Props 命名使用 camelCase(userId),模板中使用 kebab-case(user-id);v-model 自定义参数使用 v-model:title。
  • [Default] 透传 Attributes($attrs)显式使用 inheritAttrs: false + v-bind="$attrs",避免属性散落。
  • [Suggestion] 组件按「响应式状态 → 计算属性 → 监听 → 方法 → 暴露」顺序组织 <script setup> 代码。
  • [Caution] 禁止在模板中直接调用会产生副作用的方法(如 fetch),模板只做展示,副作用放在 onMounted / 事件回调。
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
  user: { id: string; name: string; avatar: string | null };
  showFollow?: boolean;
}
interface Emits {
  (e: 'follow', userId: string): void;
}
 
const props = withDefaults(defineProps<Props>(), {
  showFollow: true,
});
const emit = defineEmits<Emits>();
 
const followed = ref(false);
 
const handleFollow = () => {
  followed.value = true;
  emit('follow', props.user.id);
};
</script>
 
<template>
  <div class="user-card">
    <img :src="user.avatar ?? defaultAvatar" :alt="user.name" />
    <span>{{ user.name }}</span>
    <button v-if="showFollow" :disabled="followed" @click="handleFollow">
      {{ followed ? '已关注' : '关注' }}
    </button>
  </div>
</template>
 
<style scoped>
.user-card { display: flex; gap: 8px; }
</style>
<!-- 列表使用业务 ID 作为 key -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
</template>
<!-- 反模式:v-for 与 v-if 同元素 -->
<li v-for="item in list" v-if="item.active" :key="item.id">
  <!-- v-if 先于 v-for 执行,list 未定义报错;
       应先用 computed 过滤再 v-for -->
</li>
 
<!-- 反模式:模板中调用副作用方法 -->
<template>
  <div>{{ loadData() }}</div>   <!-- 每次渲染都请求 -->
</template>
 
<!-- 反模式:Props 无默认值且必填校验缺失 -->
<script setup>
const props = defineProps(['user']);   // 无类型、无默认值、无校验
</script>
 
<!-- 反模式:v-for 使用 index 作 key -->
<li v-for="(item, i) in items" :key="i">{{ item.name }}</li>

  • [Requirement] 响应式状态使用 ref(基本类型)与 reactive(对象),禁止直接修改 ref 的 .value 之外的方式;解构 reactive 会丢失响应式,需用 toRefs。
  • [Requirement] 副作用必须放在生命周期钩子(onMounted、onUnmounted)或 watch 中,禁止在 <script setup> 顶层直接执行异步副作用。
  • [Requirement] watch 必须显式声明监听源,禁止深度监听整个大对象导致性能问题;深度监听需指定 deep: true 并评估代价。
  • [Requirement] 可推导状态使用 computed,禁止用 watch 同步派生状态(watch(x, () => y.value = x.value + 1))。
  • [Standard] watch 监听多个源时使用数组;需要立即执行使用 watchEffect 或 watch(..., { immediate: true })。
  • [Standard] computed 默认只读;需要可写计算属性时显式提供 get/set。
  • [Standard] 组合式函数(composables)必须以 use 开头,返回响应式状态与方法,不直接修改调用方状态。
  • [Default] 组件卸载时清理定时器、监听器、订阅,使用 onScopeDispose 或在 onUnmounted 中释放。
  • [Default] 多个相关 ref 合并为一个 reactive 对象或使用 useReducer 模式,避免状态分散。
  • [Suggestion] composables 超过 50 行或被 3 处以上复用时提取到 src/composables/,命名 useXxx.ts。
  • [Caution] 禁止在 watch 回调中无条件修改被监听的源,会导致循环触发。
// src/composables/useDebounce.ts
import { ref, watch, onScopeDispose } from 'vue';
 
export function useDebounce<T>(value: Ref<T>, delay = 300) {
  const debounced = ref(value.value) as Ref<T>;
 
  let timer: ReturnType<typeof setTimeout>;
  const stop = watch(value, (v) => {
    clearTimeout(timer);
    timer = setTimeout(() => { debounced.value = v; }, delay);
  });
 
  onScopeDispose(() => {
    clearTimeout(timer);
    stop();
  });
 
  return debounced;
}
<script setup lang="ts">
// 推荐:computed 派生状态
const firstName = ref('');
const lastName = ref('');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
 
// 推荐:watch 显式源 + 清理
const searchQuery = ref('');
watch(searchQuery, (q) => {
  const controller = new AbortController();
  fetchUsers(q, { signal: controller.signal });
  return () => controller.abort();   // 清理上一次请求
}, { debounce: 300 });
</script>
// 反模式:watch 同步派生状态
const count = ref(0);
const doubled = ref(0);
watch(count, (v) => { doubled.value = v * 2; });   // 应使用 computed
 
// 反模式:解构 reactive 丢失响应式
const state = reactive({ count: 0 });
const { count } = state;   // count 不再响应式
// 应使用 const { count } = toRefs(state);
 
// 反模式:顶层直接执行副作用
<script setup>
const data = ref(null);
fetch('/api/data').then(r => r.json()).then(d => data.value = d);
// 应放入 onMounted
</script>
 
// 反模式:深度监听大对象
watch(state, (v) => { /* ... */ }, { deep: true });
// 大对象深度监听性能差,应监听具体字段

  • [Requirement] 全局状态必须使用 Pinia,禁止使用 Vuex(新项目);Store 按领域拆分,一个 store 管理一个业务领域。
  • [Requirement] Store 使用 defineStore 定义,组合式风格(setup 函数),状态用 ref,计算属性用 computed,方法用普通函数。
  • [Requirement] 服务端数据与客户端 UI 状态分离:接口数据用 Pinia + actions 或 @tanstack/vue-query,UI 状态放 UI store。
  • [Requirement] 修改状态必须通过 actions 或直接赋值(Pinia 允许直接修改),但禁止在组件中跨 store 直接修改其他 store 的状态,应通过 actions。
  • [Standard] Store 命名使用大驼峰 useXxxStore,id 使用小驼峰 xxx;defineStore('user', () => {...})。
  • [Standard] 复杂异步操作放在 actions 中,处理 loading/error 状态,组件只调用 action 不直接处理异步细节。
  • [Standard] Store 中不直接访问路由、DOM、localStorage 等副作用,通过参数注入或在组件层处理。
  • [Default] 持久化使用 pinia-plugin-persistedstate,明确指定需持久化的字段,不持久化敏感信息。
  • [Default] 组件中从 store 解构状态时使用 storeToRefs,保持响应式;actions 直接解构(非响应式)。
  • [Suggestion] 跨 store 通信通过 actions 互相调用,不直接访问对方 state。
  • [Caution] 禁止把整个应用状态塞进一个大 store(上帝 store),按领域拆分。
// src/stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
 
export const useUserStore = defineStore('user', () => {
  const profile = ref<User | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);
 
  const isLoggedIn = computed(() => profile.value !== null);
  const displayName = computed(() => profile.value?.name ?? '游客');
 
  async function fetchProfile(userId: string) {
    loading.value = true;
    error.value = null;
    try {
      profile.value = await api.getUser(userId);
    } catch (e) {
      error.value = (e as Error).message;
    } finally {
      loading.value = false;
    }
  }
 
  function logout() {
    profile.value = null;
  }
 
  return { profile, loading, error, isLoggedIn, displayName, fetchProfile, logout };
});
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useUserStore } from '@/stores/user';
 
const userStore = useUserStore();
const { profile, isLoggedIn } = storeToRefs(userStore);  // 保持响应式
const { fetchProfile, logout } = userStore;               // actions 直接解构
</script>
// 反模式:上帝 store
export const useAppStore = defineStore('app', () => {
  const user = ref(null);
  const orders = ref([]);
  const theme = ref('light');
  const notifications = ref([]);
  // ... 所有领域混在一起
});
 
// 反模式:组件中直接修改其他 store 状态
const orderStore = useOrderStore();
orderStore.items.push(item);   // 应通过 orderStore.addItem(item) action
 
// 反模式:store 中直接操作 localStorage
export const useStore = defineStore('x', () => {
  const token = ref(localStorage.getItem('token'));   // 副作用耦合
});
 
// 反模式:解构 store 丢失响应式
const { profile } = useUserStore();   // profile 非响应式

  • [Requirement] 使用 Vue Router 4,路由配置集中管理,使用 createRouter + createWebHistory;路由表按模块拆分后合并。
  • [Requirement] 路由必须配置 name,页面跳转使用 name 而非 path,避免路径变更导致跳转失效。
  • [Requirement] 路由级组件必须懒加载 () => import('@/views/Xxx.vue'),拆分首屏包体积。
  • [Requirement] 路由参数类型通过 route.params.xxx as string 或路由配置的 props: true + 组件 Props 类型声明处理。
  • [Standard] 路由 meta 中声明权限、标题、面包屑等元信息;权限校验在全局前置守卫 beforeEach 中统一处理。
  • [Standard] 页面间传参优先使用路由 params/query,复杂对象传参使用 Pinia store 或 sessionStorage,不使用 route.query 传大对象。
  • [Standard] 嵌套路由使用 children,父组件必须包含 <router-view />;路由出口唯一。
  • [Default] 404 兜底路由使用 pathMatch(.*)* 捕获所有未匹配路径,重定向到 404 页面。
  • [Default] 路由切换时取消未完成请求(通过 axios cancel token 或 AbortController)。
  • [Suggestion] 路由守卫中异步操作使用 next() 或 return,避免多次调用 next。
  • [Caution] 禁止在组件中硬编码路由路径字符串,统一使用路由 name。
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
 
const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/Home.vue'),
    meta: { title: '首页', requiresAuth: false },
  },
  {
    path: '/users/:id',
    name: 'user-detail',
    component: () => import('@/views/UserDetail.vue'),
    props: true,   // 路由参数作为组件 props
    meta: { title: '用户详情', requiresAuth: true },
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/views/NotFound.vue'),
  },
];
 
const router = createRouter({
  history: createWebHistory(),
  routes,
});
 
// 全局守卫:权限校验
router.beforeEach((to) => {
  const authStore = useAuthStore();
  if (to.meta.requiresAuth && !authStore.isLoggedIn) {
    return { name: 'login', query: { redirect: to.fullPath } };
  }
});
 
export default router;
<!-- UserDetail.vue 通过 props 接收路由参数 -->
<script setup lang="ts">
const props = defineProps<{ id: string }>();
</script>
// 跳转使用 name
router.push({ name: 'user-detail', params: { id: '123' } });
// 反模式:跳转使用硬编码 path
router.push('/users/123');   // 路径变更后失效
 
// 反模式:路由组件同步导入
import UserDetail from '@/views/UserDetail.vue';
{ path: '/users/:id', component: UserDetail }   // 未懒加载
 
// 反模式:路由参数未经类型处理
const id = route.params.id;   // 类型为 string | string[]
 
// 反模式:路由 query 传大对象
router.push({ name: 'detail', query: { data: JSON.stringify(bigObject) } });

  • [Requirement] 组件测试使用 @vue/test-utils,测试用户可见行为(渲染、交互、事件),不测试实现细节。
  • [Requirement] 测试中挂载组件使用 mount,需要存根子组件时使用 shallowMount;异步更新使用 await nextTick()。
  • [Requirement] 测试隔离:每个用例独立挂载组件,不共享状态,afterEach 清理 Pinia store 与全局 mock。
  • [Standard] 组合式函数(composables)在测试中通过挂载简易组件调用,或使用 @vue/test-utils 的 withSetup 辅助。
  • [Standard] 服务端请求使用 MSW 或 vitest-mock-extended mock,不 mock fetch / axios 全局。
  • [Standard] Pinia store 测试使用 createTestingPinia,可注入初始状态与 stub actions。
  • [Default] 测试文件命名 Xxx.spec.ts / Xxx.test.ts,与源码同目录或 __tests__;使用 describe/it 组织。
  • [Default] 关键交互路径必须有组件测试覆盖,单元测试覆盖率不低于 60%。
  • [Suggestion] 使用 @testing-library/vue(基于 test-utils)测试,更关注用户视角。
  • [Caution] 禁止在测试中 console.log 调试后提交;禁止依赖测试执行顺序。
// UserCard.spec.ts
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import UserCard from './UserCard.vue';
 
describe('UserCard', () => {
  const user = { id: '1', name: 'Alice', avatar: null };
 
  it('renders user name', () => {
    const wrapper = mount(UserCard, {
      props: { user },
    });
    expect(wrapper.text()).toContain('Alice');
  });
 
  it('emits follow event with user id', async () => {
    const wrapper = mount(UserCard, { props: { user } });
    await wrapper.find('button').trigger('click');
    expect(wrapper.emitted('follow')?.[0]).toEqual(['1']);
  });
});
// Pinia store 测试
import { createTestingPinia } from '@pinia/testing';
import { useUserStore } from '@/stores/user';
 
describe('user store', () => {
  it('fetchProfile sets profile on success', async () => {
    const pinia = createTestingPinia({
      initialState: { user: { profile: null } },
    });
    const store = useUserStore(pinia);
 
    vi.mocked(api.getUser).mockResolvedValue({ id: '1', name: 'Alice' });
    await store.fetchProfile('1');
 
    expect(store.profile).toEqual({ id: '1', name: 'Alice' });
  });
});
// 反模式:测试实现细节
it('sets followed ref to true', () => {
  const wrapper = mount(UserCard, { props: { user } });
  // 不应访问内部 ref
});
 
// 反模式:异步更新未等待 nextTick
it('updates after click', () => {
  const wrapper = mount(UserCard, { props: { user } });
  wrapper.find('button').trigger('click');
  expect(wrapper.text()).toContain('已关注');   // 未 await,状态未更新
});
 
// 反模式:测试间共享 store 状态
let store: ReturnType<typeof useUserStore>;
beforeAll(() => { store = useUserStore(); });   // 状态跨用例污染