Tagtag Starter 前端基于 Vue 3 + TypeScript + Vite 开发,采用组件化和模块化的设计理念。本文档将详细介绍前端开发的规范、最佳实践和常用工具,帮助开发者快速上手并保持代码质量。
| 技术 | 版本 | 用途 |
|---|---|---|
| Vue | 3.3+ | 前端框架,使用 Composition API |
| TypeScript | 5.0+ | 类型系统,提供类型安全 |
| Vite | 4.3+ | 构建工具,提供快速的开发体验 |
| Pinia | 2.0+ | 状态管理,替代 Vuex |
| Vue Router | 4.2+ | 路由管理,支持动态路由 |
| Ant Design Vue | 4.0+ | UI 组件库,提供丰富的企业级组件 |
| Axios | 1.5+ | HTTP 客户端,封装在 @vben/request 中 |
| VXE Table | 4.0+ | 高性能表格组件,用于复杂表格场景 |
| ESLint | 8.40+ | 代码检查,确保代码质量 |
| Prettier | 2.8+ | 代码格式化,统一代码风格 |
| Vitest | 最新 | 单元测试框架 |
| Cypress | 最新 | E2E 测试框架 |
前端项目采用 Monorepo 结构,位于 frontend 目录下。这种结构允许我们在一个仓库中管理多个应用和共享包,提高代码复用性和维护效率。
frontend/
├── apps/ # 应用目录
│ └── tagtag/ # 主应用
│ ├── src/ # 源代码
│ │ ├── adapter/ # 适配器层,封装第三方库
│ │ │ ├── component/ # 组件适配器
│ │ │ ├── form.ts # 表单适配器
│ │ │ └── vxe-table.ts # 表格适配器
│ │ ├── api/ # API 定义
│ │ │ ├── core/ # 核心 API(登录、用户、菜单等)
│ │ │ ├── modules/ # 模块 API(iam、system、storage)
│ │ │ ├── request.ts # 请求客户端配置
│ │ │ └── index.ts
│ │ ├── assets/ # 静态资源
│ │ ├── components/# 公共组件
│ │ ├── composables/# 组合式函数
│ │ ├── hooks/ # 自定义 Hooks
│ │ │ └── web/ # Web 相关 Hooks
│ │ ├── layouts/ # 布局组件
│ │ │ ├── auth.vue # 认证布局
│ │ │ └── basic.vue # 基础布局
│ │ ├── locales/ # 国际化
│ │ │ └── langs/ # 语言包
│ │ ├── router/ # 路由配置
│ │ │ ├── routes/ # 路由定义
│ │ │ ├── guard.ts # 路由守卫
│ │ │ └── access.ts # 权限控制
│ │ ├── store/ # 状态管理
│ │ │ ├── auth.ts # 认证状态
│ │ │ └── index.ts
│ │ ├── styles/ # 样式文件
│ │ ├── types/ # 类型定义
│ │ ├── utils/ # 工具函数
│ │ ├── views/ # 页面组件
│ │ │ ├── _core/ # 核心页面(登录、404等)
│ │ │ ├── dashboard/ # 仪表盘
│ │ │ └── modules/ # 业务模块
│ │ ├── app.vue # 根组件
│ │ ├── bootstrap.ts # 应用启动逻辑
│ │ ├── main.ts # 入口文件
│ │ └── preferences.ts # 应用偏好设置
│ ├── public/ # 公共资源
│ ├── index.html # HTML 模板
│ ├── vite.config.ts # Vite 配置
│ └── tsconfig.json # TypeScript 配置
├── internal/ # 内部包
│ ├── lint-configs/ # 代码检查配置
│ ├── node-utils/ # Node 工具函数
│ ├── tailwind-config/ # Tailwind CSS 配置
│ ├── tsconfig/ # TypeScript 配置
│ └── vite-config/ # Vite 配置
├── packages/ # 公共包
│ └── @core/ # 核心包
│ ├── base/ # 基础功能(设计、图标、共享工具)
│ ├── composables/ # 组合式函数
│ └── typings/ # 类型定义
└── pnpm-workspace.yaml # pnpm 工作区配置
vxe-table.ts 封装了 VXE Table 的配置,form.ts 封装了表单相关的逻辑。_core 目录包含核心页面(登录、404等),modules 目录包含业务模块页面。FormModal.vue、UserTable.vue<form-modal></form-modal>、<user-table></user-table>命名示例:
文件名: UserFormDrawer.vue
组件名: UserFormDrawer
使用: <user-form-drawer />
组件应遵循以下结构:
<template>
<!-- 组件模板 -->
</template>
<script setup lang="ts">
// 组件逻辑
</script>
<style scoped>
/* 组件样式 */
</style>
使用 script setup 语法糖,简化组件开发。按照以下顺序组织代码:
示例:
<template>
<div class="form-modal">
<a-modal
v-model:open="visible"
:title="title"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form :model="formData" :rules="rules" ref="formRef">
<!-- 表单内容 -->
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { FormInstance } from 'ant-design-vue';
// 1. 导入语句
import { message } from 'ant-design-vue';
// 2. Props 定义
const props = defineProps<{
visible: boolean;
title?: string;
initialData?: any;
}>();
// 3. Emits 定义
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'submit', data: any): void;
}>();
// 4. 响应式数据
const formRef = ref<FormInstance | null>(null);
const formData = reactive<any>({
// 表单字段
});
// 5. 计算属性
const rules = computed(() => ({
// 表单规则
}));
// 6. 生命周期钩子
onMounted(() => {
if (props.initialData) {
Object.assign(formData, props.initialData);
}
});
// 7. 方法定义
const handleOk = async () => {
if (formRef.value) {
await formRef.value.validate();
emit('submit', formData);
emit('update:visible', false);
}
};
const handleCancel = () => {
emit('update:visible', false);
};
</script>
<style scoped>
.form-modal {
/* 组件样式 */
}
</style>
Props/Emits 示例:
<!-- 父组件 -->
<template>
<ChildComponent
:data="parentData"
@update="handleUpdate"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';
const parentData = ref({ name: 'test' });
const handleUpdate = (newData: any) => {
parentData.value = newData;
};
</script>
<!-- 子组件 -->
<template>
<div @click="emit('update', { name: 'updated' })">
{{ data.name }}
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
data: any;
}>();
const emit = defineEmits<{
(e: 'update', data: any): void;
}>();
</script>
scoped 属性确保样式作用域隔离block__element--modifier!important示例:
/* BEM 命名规范 */
.post-card {
/* 块样式 */
padding: 16px;
border: 1px solid #e8e8e8;
}
.post-card__title {
/* 元素样式 */
font-size: 18px;
font-weight: 500;
}
.post-card--highlight {
/* 修饰符样式 */
background-color: #f0f9ff;
}
.post-card__content--large {
/* 元素 + 修饰符 */
font-size: 16px;
}
对于可复用的组件,建议:
src/components 目录示例:
<template>
<div class="card">
<div v-if="$slots.header" class="card__header">
<slot name="header"></slot>
</div>
<div class="card__body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="card__footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup lang="ts">
interface CardProps {
title?: string;
bordered?: boolean;
}
const props = withDefaults(defineProps<CardProps>(), {
bordered: true
});
</script>
use 前缀命名,如 useAuth、useTable、useDict| Hook | 用途 | 示例 |
|---|---|---|
ref | 创建响应式数据 | const count = ref(0) |
reactive | 创建响应式对象 | const state = reactive({ count: 0 }) |
computed | 创建计算属性 | const double = computed(() => count.value * 2) |
watch | 监听数据变化 | watch(count, (newVal) => console.log(newVal)) |
watchEffect | 自动监听依赖变化 | watchEffect(() => console.log(count.value)) |
onMounted | 组件挂载后执行 | onMounted(() => fetchData()) |
onUpdated | 组件更新后执行 | onUpdated(() => console.log('updated')) |
onUnmounted | 组件卸载前执行 | onUnmounted(() => clearInterval(timer)) |
provide | 提供数据给后代组件 | provide('key', value) |
inject | 从祖先组件获取数据 | const value = inject('key') |
useAuth Hook用于管理用户认证状态,封装了登录、登出、权限检查等功能。
// src/hooks/useAuth.ts
import { useAuthStore } from '@/store/auth';
import { computed } from 'vue';
export function useAuth() {
const authStore = useAuthStore();
const isAuthenticated = computed(() => !!authStore.token);
const userInfo = computed(() => authStore.userInfo);
const permissions = computed(() => authStore.permissions);
const hasPermission = (permission: string) => {
return permissions.value.includes(permission);
};
const login = async (loginData: any) => {
return authStore.login(loginData);
};
const logout = async () => {
return authStore.logout();
};
return {
isAuthenticated,
userInfo,
permissions,
hasPermission,
login,
logout
};
}
使用示例:
<template>
<div v-if="isAuthenticated">
<p>欢迎,{{ userInfo?.username }}</p>
<a-button @click="logout">退出登录</a-button>
</div>
<div v-else>
<a-button @click="handleLogin">登录</a-button>
</div>
</template>
<script setup lang="ts">
import { useAuth } from '@/hooks/useAuth';
const { isAuthenticated, userInfo, login, logout } = useAuth();
const handleLogin = async () => {
await login({ username: 'admin', password: '123456' });
};
</script>
useTable Hook用于简化表格组件的开发,封装了分页、搜索、重置等功能。
// src/hooks/useTable.ts
import { ref, reactive } from 'vue';
export function useTable<T>(fetchData: (params: any) => Promise<any>) {
const loading = ref(false);
const dataSource = ref<T[]>([]);
const total = ref(0);
const pagination = reactive({
current: 1,
pageSize: 10,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total: number) => `共 ${total} 条数据`
});
const searchParams = reactive<any>({});
const fetchTableData = async () => {
loading.value = true;
try {
const params = {
page: pagination.current,
size: pagination.pageSize,
...searchParams
};
const result = await fetchData(params);
dataSource.value = result.records || [];
total.value = result.total || 0;
} catch (error) {
console.error('获取表格数据失败:', error);
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.current = 1;
fetchTableData();
};
const handleReset = () => {
Object.keys(searchParams).forEach(key => {
searchParams[key] = '';
});
pagination.current = 1;
fetchTableData();
};
const handlePageChange = (page: number, pageSize: number) => {
pagination.current = page;
pagination.pageSize = pageSize;
fetchTableData();
};
return {
loading,
dataSource,
total,
pagination,
searchParams,
fetchTableData,
handleSearch,
handleReset,
handlePageChange
};
}
useDict Hook用于加载和管理字典数据,支持多个字典类型的并行加载。
// src/hooks/useDict.ts
import { onMounted, reactive, toRefs } from 'vue';
import { getDictDataList } from '@/api/modules/system/dict';
export function useDict(...dictTypes: string[]) {
const dicts = reactive<Record<string, any[]>>({});
const initDict = async () => {
const promises = dictTypes.map(async (type) => {
const data = await getDictDataList(type);
dicts[type] = data.map((item) => ({
label: item.itemName,
value: item.itemCode,
color: item.cssClass,
...item,
}));
});
await Promise.all(promises);
};
onMounted(() => {
initDict();
});
return {
...toRefs(dicts),
};
}
使用示例:
<template>
<a-form-item label="性别">
<a-select v-model:value="form.gender">
<a-select-option
v-for="item in genderDict"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { useDict } from '@/hooks/useDict';
const { genderDict } = useDict('gender');
const form = reactive({
gender: undefined
});
</script>
toRefs 返回响应式数据,便于解构使用Pinia 是 Vue 3 官方推荐的状态管理库,相比 Vuex 更轻量、更简洁。在 Tagtag Starter 中,我们使用 Pinia 进行全局状态管理。
AuthStore、UserStorestate 函数定义响应式状态示例:
// src/store/auth.ts
import { defineStore } from 'pinia';
import { loginApi, logoutApi } from '@/api/core/auth';
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || '',
userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null'),
permissions: JSON.parse(localStorage.getItem('permissions') || '[]')
}),
getters: {
isAuthenticated: (state) => !!state.token,
hasPermission: (state) => (permission: string) => {
return state.permissions.includes(permission);
}
},
actions: {
async login(loginData: any) {
const result = await loginApi(loginData);
this.token = result.token;
this.userInfo = result.user;
this.permissions = result.user.permissions || [];
localStorage.setItem('token', this.token);
localStorage.setItem('userInfo', JSON.stringify(this.userInfo));
localStorage.setItem('permissions', JSON.stringify(this.permissions));
return result;
},
async logout() {
await logoutApi();
this.clearAuth();
},
clearAuth() {
this.token = '';
this.userInfo = null;
this.permissions = [];
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
localStorage.removeItem('permissions');
}
}
});
在 Tagtag Starter 项目中,认证 Store 的实现更加完善,包含了 token 刷新、登录状态管理等功能。
// frontend/apps/tagtag/src/store/auth.ts
import type { Recordable, UserInfo } from '@vben/types';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { notification } from 'ant-design-vue';
import { defineStore } from 'pinia';
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
import { $t } from '#/locales';
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const router = useRouter();
const loginLoading = ref(false);
async function authLogin(
params: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
const { accessToken, refreshToken } = await loginApi(params);
if (accessToken) {
accessStore.setAccessToken(accessToken);
accessStore.setRefreshToken(refreshToken ?? null);
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess
? await onSuccess?.()
: await router.push(
userInfo.homePath || preferences.app.defaultHomePath,
);
}
if (userInfo?.realName || userInfo?.username) {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName || userInfo?.username}`,
duration: 3,
message: $t('authentication.loginSuccess'),
});
}
}
} finally {
loginLoading.value = false;
}
return {
userInfo,
};
}
async function logout(redirect: boolean = true) {
try {
const token = accessStore.accessToken ?? '';
if (token) {
await logoutApi(token);
}
} catch {
}
resetAllStores();
accessStore.setLoginExpired(false);
await router.replace({
path: LOGIN_PATH,
query: redirect
? {
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
}
: {},
});
}
async function fetchUserInfo() {
let userInfo: null | UserInfo = null;
userInfo = await getUserInfoApi();
userStore.setUserInfo(userInfo);
return userInfo;
}
function $reset() {
loginLoading.value = false;
}
return {
$reset,
authLogin,
fetchUserInfo,
loginLoading,
logout,
};
});
auth(认证)、user(用户)、access(权限)ref 或 reactive前端使用 Axios 进行 API 请求,配置位于 src/api/index.ts。
示例:
// src/api/index.ts
import axios from 'axios';
import { useAuthStore } from '@/store/auth';
const request = axios.create({
baseURL: import.meta.env.VITE_APP_API_BASE_URL,
timeout: 10000
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
request.interceptors.response.use(
(response) => {
return response.data;
},
async (error) => {
// 错误处理逻辑
return Promise.reject(error);
}
);
export default request;
将 API 按模块划分,每个模块对应一个文件。
示例:
// src/api/modules/user.ts
import request from '../index';
export const userApi = {
// 获取用户列表
getList: (params: any) => {
return request.get('/users', { params });
},
// 获取用户详情
getDetail: (id: number) => {
return request.get(`/users/${id}`);
},
// 创建用户
create: (data: any) => {
return request.post('/users', data);
},
// 更新用户
update: (data: any) => {
return request.put('/users', data);
},
// 删除用户
delete: (id: number) => {
return request.delete(`/users/${id}`);
}
};
路由配置位于 src/router 目录下,采用模块化设计。
示例:
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import Layout from '@/layouts/main.vue';
const routes = [
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: '仪表盘',
icon: 'dashboard',
permission: 'sys:dashboard:view'
}
}
]
},
{
path: '/auth',
component: () => import('@/layouts/auth.vue'),
children: [
{
path: 'login',
name: 'Login',
component: () => import('@/views/auth/login.vue'),
meta: {
title: '登录',
requiresAuth: false
}
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/404.vue')
}
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
});
export default router;
使用路由守卫实现权限控制和页面跳转逻辑。
示例:
// src/router/guard.ts
import router from './index';
import { useAuthStore } from '@/store/auth';
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
// 设置页面标题
document.title = `${to.meta.title || 'Tagtag Starter'} - 后台管理系统`;
// 检查是否需要认证
if (to.meta.requiresAuth !== false) {
if (authStore.isAuthenticated) {
// 已登录,检查权限
if (to.meta.permission) {
if (authStore.hasPermission(to.meta.permission as string)) {
next();
} else {
// 无权限,跳转到403页面
next({ path: '/403' });
}
} else {
next();
}
} else {
// 未登录,跳转到登录页面
next({ path: '/auth/login', query: { redirect: to.fullPath } });
}
} else {
next();
}
});
前端项目使用 ESLint 进行代码检查,规则配置位于 .eslintrc.cjs 文件中。
主要规则:
使用 Prettier 进行代码格式化,配置位于 .prettierrc 文件中。
使用 Commitizen 提交代码,遵循 Angular 提交规范:
<type>(<scope>): <subject>
<body>
<footer>
类型:
feat:新功能fix:修复 bugdocs:文档更新style:代码样式更新refactor:代码重构test:测试更新chore:构建工具或依赖更新使用 Vitest 进行单元测试,测试文件位于 __tests__ 目录下。
示例:
// src/components/Button/__tests__/Button.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Button from '../Button.vue';
describe('Button Component', () => {
it('should render correctly', () => {
const wrapper = mount(Button, {
slots: {
default: 'Button Text'
}
});
expect(wrapper.text()).toBe('Button Text');
});
it('should emit click event', () => {
const wrapper = mount(Button);
wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeTruthy();
});
});
使用 Cypress 进行端到端测试,测试文件位于 cypress/e2e 目录下。
defineAsyncComponent 或动态 import 懒加载组件v-once:对于静态内容使用 v-once 指令v-memo:对于频繁更新的列表使用 v-memo 指令computed 和 watch 优化渲染逻辑# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 构建生产版本
pnpm build
# 预览生产版本
pnpm preview
# 运行 ESLint
pnpm lint
# 运行 Prettier
pnpm format
# 运行单元测试
pnpm test:unit
# 运行 E2E 测试
pnpm test:e2e
本文档介绍了 Tagtag Starter 前端开发的规范、最佳实践和常用工具。遵循这些规范可以提高代码质量、可维护性和开发效率,确保团队协作顺畅。
前端开发是一个不断演进的领域,建议开发者持续关注 Vue 3、TypeScript 等技术的最新发展,不断学习和实践新的技术和最佳实践。