Vue 3 + TypeScript 项目的完整工程规范,涵盖项目结构、组件设计、Composables、路由、Pinia 状态管理、API 层、错误处理、测试和性能优化。当用户在 Vue 项目中创建、修改组件或模块,涉及架构设计、代码编写时自动激活。
适用于使用 Vue 3 + TypeScript 的仓库。
以下为中大型 Vue 3 项目的业界最佳实践结构,按项目实际情况裁剪:
src/
├── app/ # 应用入口与全局配置
│ ├── App.vue # 根组件
│ ├── main.ts # 应用启动入口
│ └── router.ts # 路由实例与配置
│
├── pages/ # 页面组件(与路由一一对应)
│ ├── Dashboard/
│ │ ├── DashboardPage.vue
│ │ ├── components/ # 页面私有组件
│ │ └── composables/ # 页面私有 composables
│ ├── UserList/
│ └── Settings/
│
├── layouts/ # 布局组件
│ ├── MainLayout.vue # 主布局(侧边栏 + 顶栏 + 内容区)
│ ├── AuthLayout.vue # 登录/注册页布局
│ └── BlankLayout.vue # 空白布局(错误页等)
│
├── features/ # 功能模块(按业务领域划分)
│ ├── auth/
│ │ ├── components/ # 模块组件
│ │ ├── composables/ # 模块 composables
│ │ ├── api.ts # 模块 API 调用
│ │ ├── types.ts # 模块类型定义
│ │ ├── constants.ts # 模块常量
│ │ └── index.ts # 模块公开导出
│ └── order/
│
├── components/ # 全局共享 UI 组件
│ ├── AppButton/
│ │ ├── AppButton.vue
│ │ └── __tests__/
│ ├── AppModal/
│ ├── AppForm/
│ └── AppErrorBoundary/
│
├── composables/ # 全局共享 composables
│ ├── useAuth.ts
│ ├── useDebounce.ts
│ └── useMediaQuery.ts
│
├── services/ # API 基础层
│ ├── request.ts # Axios/fetch 实例与拦截器
│ └── endpoints/ # API 端点定义(如按领域拆分)
│
├── stores/ # Pinia 状态管理
│ ├── authStore.ts
│ └── uiStore.ts
│
├── locales/ # 国际化语言包
│ ├── zh-CN.json # 中文
│ ├── en-US.json # 英文
│ └── index.ts # i18n 实例初始化(vue-i18n)
│
├── assets/ # 静态资源
│ ├── images/ # 图片(PNG、JPG、WebP)
│ ├── icons/ # SVG 图标
│ └── fonts/ # 自定义字体
│
├── config/ # 应用配置
│ ├── env.ts # 环境变量类型化封装
│ └── features.ts # Feature Flags 管理
│
├── types/ # 全局共享类型
│ ├── api.ts # API 响应/请求通用类型
│ ├── models.ts # 业务实体类型
│ └── global.d.ts # 全局类型扩展(组件类型、模块声明等)
│
├── utils/ # 纯工具函数
│ ├── format.ts # 日期、数字、货币格式化
│ ├── validators.ts # 表单校验规则
│ └── storage.ts # LocalStorage / SessionStorage 封装
│
├── directives/ # 自定义指令
│ ├── vPermission.ts # 权限指令
│ └── vClickOutside.ts # 点击外部关闭
│
├── plugins/ # Vue 插件注册
│ ├── i18n.ts # vue-i18n 插件配置
│ └── index.ts # 插件统一注册入口
│
├── styles/ # 全局样式与主题
│ ├── global.css # 全局基础样式(reset / normalize)
│ ├── variables.css # CSS 变量(颜色、间距、字号)
│ ├── breakpoints.ts # 响应式断点常量
│ └── themes/ # 主题定义
│ ├── light.css # 亮色主题变量
│ ├── dark.css # 暗色主题变量
│ └── index.ts # 主题切换逻辑
│
└── constants/ # 全局常量
├── routes.ts # 路由路径常量
└── config.ts # 业务常量(分页大小、超时时间等)
pages/ 做路由映射和布局组合,不放业务逻辑layouts/ 定义页面骨架(侧边栏、顶栏、面包屑),由路由配置的 component 引用features/ 按业务领域划分,模块内自包含(components + composables + api + types)components/ 仅放无业务耦合的通用组件,可跨项目复用composables/ 仅放通用逻辑(防抖、媒体查询等),业务 composables 放到对应 feature 中locales/ 存放语言包 JSON 文件,模板中使用 $t('key') 而非硬编码文案assets/ 存放静态资源,图标优先使用 SVG,图片优先使用 WebP/AVIFconfig/ 封装环境变量和 Feature Flags,禁止组件中直接读取 import.meta.envstyles/themes/ 通过 CSS 变量实现主题切换,组件中引用变量而非硬编码颜色index.ts 管控公开 API,避免深层路径导入<script setup lang="ts">defineProps / defineEmits 并附带类型页面组件 (Pages) → 路由映射、布局组合
└── 容器组件 (Containers) → 数据获取、状态编排
└── 业务组件 (Features) → 领域逻辑展示
└── 通用组件 (UI) → 纯展示,无业务耦合
@param / @returns / @example),说明用中文即可,除非仓库统一要求英文。通用 TypeScript / JavaScript 约定见插件模板 templates/rules/typescript.md(初始化到项目后为 .claude/rules/typescript.md)。
<script setup lang="ts">
interface Props {
title: string;
items: Item[];
loading?: boolean;
}
interface Emits {
(e: 'select', item: Item): void;
(e: 'delete', id: string): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
const emit = defineEmits<Emits>();
</script>
withDefaults 设置默认值any,优先使用精确类型defineExpose 暴露的方法需有类型约束use 前缀命名Ref 或 getter)export function useUserList(params: MaybeRef<QueryParams>) {
const data = ref<User[]>([]);
const loading = ref(false);
const error = ref<Error | null>(null);
async function fetch() {
loading.value = true;
error.value = null;
try {
const res = await getUserList(toValue(params));
data.value = res.list;
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
watchEffect(() => { fetch(); });
return { data: readonly(data), loading: readonly(loading), error: readonly(error), refetch: fetch };
}
readonly 引用防止外部意外修改onUnmounted 中清理定时器、事件监听等副作用<slot> 实现组件组合,而非过多 props<template>
<div class="card">
<div class="card-header">
<slot name="header">{{ title }}</slot>
</div>
<div class="card-body">
<slot :data="processedData" :loading="loading" />
</div>
</div>
</template>
// keys.ts
export const ThemeKey: InjectionKey<Ref<Theme>> = Symbol('theme');
// Provider.vue
provide(ThemeKey, theme);
// Consumer.vue
const theme = inject(ThemeKey);
// app/router.ts
const routes: RouteRecordRaw[] = [
{
path: '/',
component: MainLayout,
children: [
{ path: '', name: 'Dashboard', component: () => import('@/pages/Dashboard/DashboardPage.vue') },
{ path: 'users', name: 'UserList', component: () => import('@/pages/UserList/UserListPage.vue') },
{ path: 'users/:id', name: 'UserDetail', component: () => import('@/pages/UserDetail/UserDetailPage.vue') },
{ path: 'settings', name: 'Settings', component: () => import('@/pages/Settings/SettingsPage.vue') },
],
},
{ path: '/login', name: 'Login', component: () => import('@/pages/Login/LoginPage.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/pages/NotFound.vue') },
];
nameimport() 按需加载beforeEach),而非在每个页面内判断// 导航守卫
router.beforeEach((to) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'Login', query: { redirect: to.fullPath } };
}
});
| 状态类型 | 推荐方案 |
|---|---|
| 组件内临时 UI 状态 | ref / reactive |
| 跨组件共享业务状态 | Pinia store |
| 服务端数据缓存 | VueQuery / 自定义 composable |
| URL 驱动状态 | 路由参数 / useRoute().query |
| 表单状态 | VeeValidate / FormKit |
使用 Composition API 风格(setup store):
// stores/authStore.ts
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const token = ref<string | null>(localStorage.getItem('token'));
const isLoggedIn = computed(() => !!token.value);
async function login(credentials: LoginParams) {
const res = await authApi.login(credentials);
user.value = res.user;
token.value = res.token;
localStorage.setItem('token', res.token);
}
function logout() {
user.value = null;
token.value = null;
localStorage.removeItem('token');
}
return { user: readonly(user), isLoggedIn, login, logout };
});
readonly 的状态,通过 action 修改// services/request.ts
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
});
request.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
});
request.interceptors.response.use(
(res) => res.data,
(error) => {
if (error.response?.status === 401) {
const authStore = useAuthStore();
authStore.logout();
router.push({ name: 'Login' });
}
return Promise.reject(normalizeError(error));
},
);
// features/user/api.ts
export function getUserList(params: UserQueryParams): Promise<PageResult<User>> {
return request.get('/users', { params });
}
export function updateUser(id: string, data: UpdateUserDTO): Promise<User> {
return request.put(`/users/${id}`, data);
}
// main.ts
app.config.errorHandler = (err, instance, info) => {
reportError(err, { component: instance?.$options.name, info });
};
onErrorCaptured 在父组件中捕获子组件错误// directives/vPermission.ts
export const vPermission: Directive<HTMLElement, string> = {
mounted(el, binding) {
const authStore = useAuthStore();
if (!authStore.hasPermission(binding.value)) {
el.parentNode?.removeChild(el);
}
},
};
updated 钩子<style scoped> 隔离样式:deep() 而非已废弃的 ::v-deep:class / :style 绑定describe('UserForm', () => {
it('should emit submit with valid data', async () => {
const wrapper = mount(UserForm);
await wrapper.find('[data-testid="username"]').setValue('test');
await wrapper.find('form').trigger('submit');
expect(wrapper.emitted('submit')?.[0]).toEqual([{ username: 'test' }]);
});
it('should show validation error on empty submit', async () => {
const wrapper = mount(UserForm);
await wrapper.find('form').trigger('submit');
expect(wrapper.text()).toContain('用户名不能为空');
});
});
describe('authStore', () => {
beforeEach(() => setActivePinia(createPinia()));
it('should login and set user', async () => {
const store = useAuthStore();
await store.login({ username: 'admin', password: 'pass' });
expect(store.isLoggedIn).toBe(true);
});
});
shallowRef / shallowReactive 优化大型对象v-for 中使用 v-if(提取为 computed 过滤)defineAsyncComponent 懒加载重型组件v-for 必须有稳定的 :keyimport() 按需加载watch + 手动赋值模拟 computedcomponents/ 中放业务耦合组件index.ts<script setup lang="ts">