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-extendedmock,不 mockfetch/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(); }); // 状态跨用例污染