前言
随着前端技术的快速发展,构建现代化的企业级应用已成为开发者的重要技能。Vue 3作为当前主流的前端框架,结合TypeScript的类型安全特性和Vite的极速构建能力,为现代Web开发提供了强大的技术栈组合。
本文将从零开始,详细阐述如何构建一个基于Vue 3、TypeScript和Vite的企业级项目架构,涵盖从项目初始化到代码组织、组件设计、状态管理等核心要素,分享实际开发中的最佳实践和经验总结。
项目初始化与环境配置
使用Vite创建Vue 3项目
首先,我们使用Vite的官方脚手架工具来创建项目:
npm create vite@latest my-vue-enterprise-app --template vue-ts
cd my-vue-enterprise-app
npm install
这个命令会创建一个包含Vue 3、TypeScript和Vite的完整项目结构。
项目目录结构设计
src/
├── assets/ # 静态资源文件
│ ├── images/
│ ├── styles/
│ └── icons/
├── components/ # 公共组件
│ ├── common/
│ ├── layout/
│ └── ui/
├── views/ # 页面组件
│ ├── home/
│ ├── dashboard/
│ └── user/
├── router/ # 路由配置
│ └── index.ts
├── store/ # 状态管理
│ ├── index.ts
│ ├── modules/
│ └── types/
├── services/ # API服务层
│ ├── api/
│ ├── http/
│ └── types/
├── utils/ # 工具函数
│ ├── helpers/
│ ├── validators/
│ └── constants/
├── composables/ # 可复用逻辑
├── hooks/ # 自定义Hook
├── types/ # 类型定义
└── App.vue
TypeScript类型安全实践
定义应用基础类型
在src/types/index.ts中定义基础类型:
// src/types/index.ts
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
createdAt: string;
}
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
timestamp: number;
}
export interface Pagination {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
export interface PageResponse<T> extends ApiResponse<T[]> {
pagination: Pagination;
}
组件Props类型定义
// src/components/common/Avatar.vue
import { defineComponent, PropType } from 'vue';
interface User {
id: number;
name: string;
avatar?: string;
}
export default defineComponent({
name: 'Avatar',
props: {
user: {
type: Object as PropType<User>,
required: true
},
size: {
type: String as PropType<'small' | 'medium' | 'large'>,
default: 'medium'
},
showName: {
type: Boolean,
default: false
}
},
setup(props) {
const getSizeClass = () => {
switch (props.size) {
case 'small': return 'w-8 h-8';
case 'large': return 'w-16 h-16';
default: return 'w-12 h-12';
}
};
return {
getSizeClass
};
}
});
API响应类型定义
// src/services/types/user.ts
import { User, Pagination } from '@/types';
export interface UserListParams {
page: number;
pageSize: number;
keyword?: string;
role?: string;
}
export interface UserListResponse {
users: User[];
pagination: Pagination;
}
Vite构建优化配置
Vite配置文件优化
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "src/assets/styles/variables.scss";`,
},
},
},
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
ui: ['element-plus'],
},
},
},
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
},
});
构建性能优化
// vite.config.ts - 预加载优化
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
build: {
rollupOptions: {
output: {
// 分块策略优化
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
ui: ['element-plus'],
utils: ['lodash-es', 'axios'],
},
// 预加载配置
assetFileNames: (assetInfo) => {
if (assetInfo.name.endsWith('.css')) {
return 'assets/css/[name].[hash].css';
}
return 'assets/[name].[hash].[ext]';
},
},
},
},
});
组件化设计与最佳实践
基础组件库封装
// src/components/common/Button.vue
<template>
<button
:class="[
'btn',
`btn--${type}`,
{ 'btn--disabled': disabled, 'btn--loading': loading }
]"
:disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="btn__spinner"></span>
<slot />
</button>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
interface Props {
type?: 'primary' | 'secondary' | 'danger' | 'success';
disabled?: boolean;
loading?: boolean;
}
interface Emits {
(e: 'click', event: Event): void;
}
const props = withDefaults(defineProps<Props>(), {
type: 'primary',
disabled: false,
loading: false
});
const emit = defineEmits<Emits>();
const handleClick = (event: Event) => {
if (!props.disabled && !props.loading) {
emit('click', event);
}
};
</script>
<style scoped lang="scss">
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
&--primary {
background-color: #007bff;
color: white;
&:hover:not(.btn--disabled) {
background-color: #0056b3;
}
}
&--secondary {
background-color: #6c757d;
color: white;
&:hover:not(.btn--disabled) {
background-color: #545b62;
}
}
&--disabled {
opacity: 0.6;
cursor: not-allowed;
}
&__spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
响应式组件设计
// src/components/common/DataTable.vue
<template>
<div class="data-table">
<div class="data-table__header">
<div class="data-table__search">
<el-input
v-model="searchKeyword"
placeholder="搜索..."
@input="handleSearch"
/>
</div>
<div class="data-table__actions">
<slot name="actions"></slot>
</div>
</div>
<el-table
:data="tableData"
:loading="loading"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="50" />
<el-table-column
v-for="column in columns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:width="column.width"
:formatter="column.formatter"
/>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<slot name="actions" :row="row"></slot>
</template>
</el-table-column>
</el-table>
<div class="data-table__pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { TableProps } from 'element-plus';
interface Column {
prop: string;
label: string;
width?: number;
formatter?: (row: any, column: any, cellValue: any) => string;
}
interface Props {
columns: Column[];
data: any[];
loading?: boolean;
total?: number;
currentPage?: number;
pageSize?: number;
searchFields?: string[];
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
loading: false,
total: 0,
currentPage: 1,
pageSize: 10,
searchFields: () => []
});
const emit = defineEmits<{
(e: 'update:currentPage', page: number): void;
(e: 'update:pageSize', size: number): void;
(e: 'search', keyword: string): void;
(e: 'selection-change', selection: any[]): void;
}>();
const searchKeyword = ref('');
const tableData = ref(props.data);
const currentPage = ref(props.currentPage);
const pageSize = ref(props.pageSize);
// 处理搜索
const handleSearch = () => {
emit('search', searchKeyword.value);
};
// 处理分页变化
const handleSizeChange = (size: number) => {
pageSize.value = size;
emit('update:pageSize', size);
};
const handleCurrentChange = (page: number) => {
currentPage.value = page;
emit('update:currentPage', page);
};
// 处理选择变化
const handleSelectionChange = (selection: any[]) => {
emit('selection-change', selection);
};
// 监听props变化
watch(() => props.data, (newData) => {
tableData.value = newData;
});
watch(() => props.currentPage, (newPage) => {
currentPage.value = newPage;
});
</script>
<style scoped lang="scss">
.data-table {
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 16px;
}
&__search {
flex: 1;
}
&__actions {
display: flex;
gap: 8px;
}
&__pagination {
margin-top: 16px;
text-align: right;
}
}
</style>
状态管理设计
Pinia状态管理实现
// src/store/index.ts
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
export default pinia;
用户状态模块
// src/store/modules/user.ts
import { defineStore } from 'pinia';
import type { User } from '@/types';
import { login, logout, getUserInfo } from '@/services/api/auth';
interface UserState {
userInfo: User | null;
token: string | null;
permissions: string[];
loading: boolean;
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
userInfo: null,
token: localStorage.getItem('token') || null,
permissions: [],
loading: false
}),
getters: {
isAuthenticated: (state) => !!state.token && !!state.userInfo,
hasPermission: (state) => (permission: string) => {
return state.permissions.includes(permission);
}
},
actions: {
async login(credentials: { username: string; password: string }) {
this.loading = true;
try {
const response = await login(credentials);
const { token, user } = response.data;
this.token = token;
this.userInfo = user;
this.permissions = user.role === 'admin' ? ['read', 'write', 'delete'] : ['read'];
// 存储token到localStorage
localStorage.setItem('token', token);
return response;
} catch (error) {
throw error;
} finally {
this.loading = false;
}
},
async logout() {
try {
await logout();
this.token = null;
this.userInfo = null;
this.permissions = [];
localStorage.removeItem('token');
} catch (error) {
console.error('Logout failed:', error);
}
},
async fetchUserInfo() {
if (!this.token) return;
try {
const response = await getUserInfo();
this.userInfo = response.data;
this.permissions = response.data.role === 'admin' ?
['read', 'write', 'delete'] : ['read'];
} catch (error) {
console.error('Failed to fetch user info:', error);
}
},
setUserInfo(userInfo: User) {
this.userInfo = userInfo;
}
},
persist: {
key: 'user-store',
paths: ['token', 'userInfo', 'permissions']
}
});
应用状态管理
// src/store/modules/app.ts
import { defineStore } from 'pinia';
interface AppState {
sidebarCollapsed: boolean;
theme: 'light' | 'dark';
language: string;
loading: boolean;
}
export const useAppStore = defineStore('app', {
state: (): AppState => ({
sidebarCollapsed: false,
theme: 'light',
language: 'zh-CN',
loading: false
}),
getters: {
isSidebarCollapsed: (state) => state.sidebarCollapsed,
currentTheme: (state) => state.theme,
currentLanguage: (state) => state.language
},
actions: {
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed;
},
setTheme(theme: 'light' | 'dark') {
this.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
},
setLanguage(language: string) {
this.language = language;
},
setLoading(loading: boolean) {
this.loading = loading;
}
},
persist: {
key: 'app-store',
paths: ['sidebarCollapsed', 'theme', 'language']
}
});
路由配置与权限控制
路由基础配置
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import { useUserStore } from '@/store/modules/user';
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Dashboard.vue'),
meta: { requiresAuth: true, title: '仪表板' }
},
{
path: '/users',
name: 'Users',
component: () => import('@/views/user/UserList.vue'),
meta: { requiresAuth: true, title: '用户管理', permission: 'read' }
},
{
path: '/settings',
name: 'Settings',
component: () => import('@/views/settings/Settings.vue'),
meta: { requiresAuth: true, title: '系统设置' }
}
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
});
// 路由守卫
router.beforeEach((to, from, next) => {
const userStore = useUserStore();
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
next({
path: '/login',
query: { redirect: to.fullPath }
});
} else if (to.meta.permission && !userStore.hasPermission(to.meta.permission as string)) {
next('/403');
} else {
next();
}
});
export default router;
动态路由实现
// src/router/dynamic.ts
import type { RouteRecordRaw } from 'vue-router';
import { useUserStore } from '@/store/modules/user';
export const generateRoutes = (): RouteRecordRaw[] => {
const userStore = useUserStore();
if (!userStore.isAuthenticated) {
return [];
}
// 根据用户权限动态生成路由
const routes: RouteRecordRaw[] = [];
if (userStore.hasPermission('read')) {
routes.push({
path: '/users',
name: 'Users',
component: () => import('@/views/user/UserList.vue'),
meta: { title: '用户管理' }
});
}
if (userStore.hasPermission('write')) {
routes.push({
path: '/users/create',
name: 'UserCreate',
component: () => import('@/views/user/UserCreate.vue'),
meta: { title: '创建用户' }
});
}
if (userStore.hasPermission('delete')) {
routes.push({
path: '/users/delete',
name: 'UserDelete',
component: () => import('@/views/user/UserDelete.vue'),
meta: { title: '删除用户' }
});
}
return routes;
};
API服务层设计
HTTP客户端封装
// src/services/http/index.ts
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios';
import { useUserStore } from '@/store/modules/user';
class HttpClient {
private instance: AxiosInstance;
constructor() {
this.instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
this.setupInterceptors();
}
private setupInterceptors() {
// 请求拦截器
this.instance.interceptors.request.use(
(config) => {
const userStore = useUserStore();
const token = userStore.token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
this.instance.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
if (error.response?.status === 401) {
const userStore = useUserStore();
userStore.logout();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
public get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.instance.get(url, config);
}
public post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.instance.post(url, data, config);
}
public put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.instance.put(url, data, config);
}
public delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.instance.delete(url, config);
}
}
export const http = new HttpClient();
API服务封装
// src/services/api/auth.ts
import { http } from '../http';
import type { User } from '@/types';
export interface LoginParams {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
user: User;
}
export const login = (params: LoginParams) => {
return http.post<LoginResponse>('/auth/login', params);
};
export const logout = () => {
return http.post('/auth/logout');
};
export const getUserInfo = () => {
return http.get<User>('/user/info');
};
// src/services/api/user.ts
import { http } from '../http';
import type { User, UserListParams, UserListResponse } from '@/types';
export const getUserList = (params: UserListParams) => {
return http.get<UserListResponse>('/users', { params });
};
export const createUser = (data: Partial<User>) => {
return http.post<User>('/users', data);
};
export const updateUser = (id: number, data: Partial<User>) => {
return http.put<User>(`/users/${id}`, data);
};
export const deleteUser = (id: number) => {
return http.delete(`/users/${id}`);
};
工具函数与常量定义
常用工具函数
// src/utils/helpers/index.ts
import { ref, type Ref } from 'vue';
/**
* 防抖函数
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): T {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (...args: Parameters<T>) {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => func.apply(this, args), wait);
} as T;
}
/**
* 节流函数
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): T {
let inThrottle: boolean;
let lastFunc: ReturnType<typeof setTimeout> | null = null;
return function (...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
} as T;
}
/**
* 格式化日期
*/
export function formatDate(date: Date | string, format: string = 'YYYY-MM-DD'): string {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return format
.replace('YYYY', year.toString())
.replace('MM', month)
.replace('DD', day);
}
/**
* 深度克隆
*/
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as any;
if (obj instanceof Array) return obj.map(item => deepClone(item)) as any;
if (typeof obj === 'object') {
const clonedObj = {} as any;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
return obj;
}
/**
* 空值检查
*/
export function isNil(value: any): boolean {
return value === null || value === undefined;
}
常量定义
// src/utils/constants/index.ts
export const API_STATUS = {
SUCCESS: 200,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
SERVER_ERROR: 500
};
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
export const USER_ROLES = {
ADMIN: 'admin',
USER: 'user',
GUEST: 'guest'
};
export const STATUS_OPTIONS = [
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'inactive' }
];
export const FORM_RULES = {
required: [{ required: true, message: '此项为必填项', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号码', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
]
};
组件可复用逻辑封装
自定义Hook实现
// src/hooks/usePagination.ts
import { ref, watch } from 'vue';
interface PaginationState {
page: number;
pageSize: number;
total: number;
}
export function usePagination(initialState: Partial<PaginationState> = {}) {
const page = ref(initialState.page || 1);
const pageSize = ref(initialState.pageSize || 10);
const total = ref(initialState.total || 0);
const handleSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
};
const handleCurrentChange = (current: number) => {
page.value = current;
};
const reset = () => {
page.value = 1;
pageSize.value
评论 (0)