引言
随着前端技术的快速发展,Vue.js作为主流的前端框架之一,在企业级应用开发中扮演着越来越重要的角色。Vue 3的发布带来了Composition API、更好的性能优化以及更灵活的组件设计模式,为构建复杂的企业级应用提供了强大的技术支持。
本文将深入探讨Vue 3企业级项目的完整架构设计,从基础的组件库封装到复杂的全局状态管理,涵盖路由设计、权限控制、国际化、单元测试等关键技术领域。通过系统性的架构设计和最佳实践,帮助开发者构建可维护、可扩展、高性能的企业级前端应用。
一、项目架构概览
1.1 架构设计理念
在企业级Vue 3项目中,我们采用模块化、组件化的架构设计理念:
- 分层架构:将应用分为展示层、逻辑层和数据层
- 组件化设计:通过可复用的组件构建用户界面
- 状态管理:统一管理全局状态,确保数据一致性
- 工程化实践:采用现代化的构建工具和开发流程
1.2 目录结构设计
src/
├── assets/ # 静态资源文件
│ ├── images/ # 图片资源
│ ├── styles/ # 样式文件
│ └── icons/ # 图标资源
├── components/ # 公共组件
│ ├── base/ # 基础组件
│ ├── business/ # 业务组件
│ └── index.js # 组件导出入口
├── composables/ # 可复用的组合式函数
├── hooks/ # 自定义钩子
├── views/ # 页面组件
├── router/ # 路由配置
├── store/ # 状态管理
├── services/ # API服务层
├── utils/ # 工具函数
├── plugins/ # 插件
├── layouts/ # 布局组件
├── locales/ # 国际化资源
├── tests/ # 测试文件
└── App.vue # 根组件
二、组件库封装实践
2.1 组件库设计原则
企业级应用的组件库需要具备以下特点:
- 可复用性:组件应该能够跨项目、跨模块使用
- 可配置性:通过props和slots实现灵活配置
- 可扩展性:支持自定义样式和行为扩展
- 易维护性:清晰的代码结构和文档
2.2 基础组件封装示例
<!-- components/base/Btn.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">
<Spinner />
</span>
<span :class="{ 'btn__text--hidden': loading }">
<slot></slot>
</span>
</button>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import Spinner from './Spinner.vue'
const props = defineProps({
type: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'danger'].includes(value)
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['click'])
const handleClick = (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.2s ease;
font-size: 14px;
&--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;
}
}
&--danger {
background-color: #dc3545;
color: white;
&:hover:not(.btn--disabled) {
background-color: #c82333;
}
}
&--disabled {
opacity: 0.6;
cursor: not-allowed;
}
&__spinner {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 8px;
}
&__text--hidden {
visibility: hidden;
}
}
</style>
2.3 组件库导出配置
// components/index.js
import Btn from './base/Btn.vue'
import Input from './base/Input.vue'
import Table from './business/Table.vue'
import Modal from './business/Modal.vue'
const components = [
Btn,
Input,
Table,
Modal
]
const install = function (Vue) {
components.forEach(component => {
Vue.component(component.name, component)
})
}
// 支持CDN引入
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue)
}
export default {
version: '1.0.0',
install,
Btn,
Input,
Table,
Modal
}
export {
Btn,
Input,
Table,
Modal
}
三、状态管理设计
3.1 Pinia状态管理方案
Vue 3推荐使用Pinia作为状态管理工具,相比Vuex具有更简洁的API和更好的TypeScript支持。
// store/modules/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login, logout, getUserInfo } from '@/services/auth'
export const useUserStore = defineStore('user', () => {
// 状态
const userInfo = ref(null)
const token = ref('')
const permissions = ref([])
const loading = ref(false)
// 计算属性
const isAuthenticated = computed(() => !!token.value)
const hasPermission = computed(() => (permission) => {
return permissions.value.includes(permission)
})
// 方法
const loginAction = async (credentials) => {
try {
loading.value = true
const response = await login(credentials)
token.value = response.token
userInfo.value = response.user
// 存储token到localStorage
localStorage.setItem('token', response.token)
return response
} catch (error) {
throw error
} finally {
loading.value = false
}
}
const logoutAction = async () => {
try {
await logout()
token.value = ''
userInfo.value = null
permissions.value = []
// 清除localStorage
localStorage.removeItem('token')
} catch (error) {
console.error('Logout failed:', error)
}
}
const fetchUserInfo = async () => {
try {
const response = await getUserInfo()
userInfo.value = response.user
permissions.value = response.permissions
} catch (error) {
console.error('Failed to fetch user info:', error)
}
}
// 初始化方法
const init = async () => {
const savedToken = localStorage.getItem('token')
if (savedToken) {
token.value = savedToken
await fetchUserInfo()
}
}
return {
userInfo,
token,
permissions,
loading,
isAuthenticated,
hasPermission,
loginAction,
logoutAction,
fetchUserInfo,
init
}
})
3.2 多模块状态管理
// store/index.js
import { createPinia } from 'pinia'
import { useUserStore } from './modules/user'
import { useAppStore } from './modules/app'
const pinia = createPinia()
export default pinia
export {
useUserStore,
useAppStore
}
// store/modules/app.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAppStore = defineStore('app', () => {
// 状态
const sidebarCollapsed = ref(false)
const theme = ref('light')
const language = ref('zh-CN')
const notifications = ref([])
// 计算属性
const isSidebarCollapsed = computed(() => sidebarCollapsed.value)
// 方法
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const setTheme = (newTheme) => {
theme.value = newTheme
document.body.setAttribute('data-theme', newTheme)
}
const setLanguage = (lang) => {
language.value = lang
// 设置全局语言环境
document.documentElement.lang = lang
}
const addNotification = (notification) => {
const id = Date.now()
notifications.value.push({
...notification,
id,
timestamp: new Date()
})
// 自动移除通知
setTimeout(() => {
removeNotification(id)
}, 5000)
}
const removeNotification = (id) => {
notifications.value = notifications.value.filter(n => n.id !== id)
}
return {
sidebarCollapsed,
theme,
language,
notifications,
isSidebarCollapsed,
toggleSidebar,
setTheme,
setLanguage,
addNotification,
removeNotification
}
})
3.3 状态持久化方案
// store/plugins/persist.js
import { watch } from 'vue'
export const persistPlugin = (store) => {
// 从localStorage恢复状态
const savedState = localStorage.getItem('pinia-state')
if (savedState) {
try {
const parsedState = JSON.parse(savedState)
Object.keys(parsedState).forEach(key => {
if (store[key] && typeof store[key] === 'object') {
Object.assign(store[key], parsedState[key])
}
})
} catch (error) {
console.error('Failed to restore state from localStorage:', error)
}
}
// 监听状态变化并保存到localStorage
watch(
() => store.$state,
(newState) => {
try {
localStorage.setItem('pinia-state', JSON.stringify(newState))
} catch (error) {
console.error('Failed to save state to localStorage:', error)
}
},
{ deep: true }
)
}
四、路由系统设计
4.1 动态路由配置
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/store/modules/user'
import { useAppStore } from '@/store/modules/app'
// 路由元信息类型定义
const routeMeta = {
requiresAuth: boolean,
permissions: Array,
title: string,
icon: string
}
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { title: '首页', icon: 'home' }
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: {
requiresAuth: true,
title: '仪表盘',
icon: 'dashboard'
}
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/layouts/AdminLayout.vue'),
redirect: '/admin/users',
meta: { requiresAuth: true, permissions: ['admin'] },
children: [
{
path: 'users',
name: 'Users',
component: () => import('@/views/admin/Users.vue'),
meta: { title: '用户管理', icon: 'user' }
},
{
path: 'roles',
name: 'Roles',
component: () => import('@/views/admin/Roles.vue'),
meta: { title: '角色管理', icon: 'role' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const appStore = useAppStore()
// 设置页面标题
if (to.meta.title) {
document.title = to.meta.title + ' - 应用名称'
}
// 权限检查
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
// 权限验证
if (to.meta.permissions && to.meta.permissions.length > 0) {
const hasPermission = to.meta.permissions.every(permission =>
userStore.hasPermission(permission)
)
if (!hasPermission) {
next('/403')
return
}
}
// 初始化用户信息
if (userStore.isAuthenticated && !userStore.userInfo) {
try {
await userStore.fetchUserInfo()
} catch (error) {
console.error('Failed to fetch user info:', error)
}
}
next()
})
export default router
4.2 路由懒加载优化
// router/utils.js
export const loadComponent = (componentPath) => {
return () => import(`@/views/${componentPath}.vue`)
}
// 动态路由加载示例
const dynamicRoutes = [
{
path: '/products',
name: 'Products',
component: loadComponent('products/List'),
meta: { title: '产品管理' }
},
{
path: '/orders',
name: 'Orders',
component: loadComponent('orders/List'),
meta: { title: '订单管理' }
}
]
五、权限控制体系
5.1 权限验证组件
<!-- components/security/Permission.vue -->
<template>
<slot v-if="hasPermission" />
<div v-else class="permission-denied">
<slot name="denied">
<span>无权限访问</span>
</slot>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/store/modules/user'
const props = defineProps({
permission: {
type: String,
required: true
}
})
const userStore = useUserStore()
const hasPermission = computed(() => {
return userStore.hasPermission(props.permission)
})
</script>
<style scoped>
.permission-denied {
padding: 16px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
color: #6c757d;
text-align: center;
}
</style>
5.2 权限路由配置
// router/permission.js
import { useUserStore } from '@/store/modules/user'
export const filterRoutes = (routes, userPermissions) => {
return routes.filter(route => {
// 如果不需要权限验证,直接通过
if (!route.meta || !route.meta.permissions) {
return true
}
// 检查用户是否具有所需权限
const requiredPermissions = route.meta.permissions
return requiredPermissions.every(permission =>
userPermissions.includes(permission)
)
})
}
// 动态路由生成
export const generateRoutes = (userPermissions) => {
const allRoutes = [
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { permissions: ['dashboard:view'] }
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/layouts/AdminLayout.vue'),
redirect: '/admin/users',
children: [
{
path: 'users',
name: 'Users',
component: () => import('@/views/admin/Users.vue'),
meta: { permissions: ['user:view', 'user:manage'] }
},
{
path: 'roles',
name: 'Roles',
component: () => import('@/views/admin/Roles.vue'),
meta: { permissions: ['role:view', 'role:manage'] }
}
]
}
]
return filterRoutes(allRoutes, userPermissions)
}
六、国际化实现
6.1 国际化插件设计
// plugins/i18n.js
import { createI18n } from 'vue-i18n'
import { ref, watch } from 'vue'
const messages = {
'zh-CN': {
common: {
save: '保存',
cancel: '取消',
confirm: '确认',
delete: '删除',
edit: '编辑'
},
menu: {
dashboard: '仪表盘',
users: '用户管理',
settings: '系统设置'
}
},
'en-US': {
common: {
save: 'Save',
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
edit: 'Edit'
},
menu: {
dashboard: 'Dashboard',
users: 'User Management',
settings: 'Settings'
}
}
}
const i18n = createI18n({
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
messages,
legacy: false
})
// 全局语言切换
export const useI18n = () => {
const currentLocale = ref('zh-CN')
const changeLocale = (locale) => {
i18n.global.locale.value = locale
currentLocale.value = locale
// 存储用户偏好设置
localStorage.setItem('locale', locale)
// 设置页面语言属性
document.documentElement.lang = locale
}
// 初始化语言设置
const initLocale = () => {
const savedLocale = localStorage.getItem('locale')
if (savedLocale) {
changeLocale(savedLocale)
}
}
return {
i18n,
currentLocale,
changeLocale,
initLocale
}
}
export default i18n
6.2 国际化组件使用
<!-- components/locale/LanguageSwitcher.vue -->
<template>
<div class="language-switcher">
<el-dropdown trigger="click" @command="handleLanguageChange">
<span class="el-dropdown-link">
{{ currentLocale === 'zh-CN' ? '中文' : 'English' }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN">中文</el-dropdown-item>
<el-dropdown-item command="en-US">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from '@/plugins/i18n'
const { currentLocale, changeLocale } = useI18n()
const handleLanguageChange = (locale) => {
changeLocale(locale)
}
</script>
<style scoped>
.language-switcher {
margin-right: 20px;
}
</style>
七、单元测试实践
7.1 测试环境配置
// tests/unit/setup.js
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
// 全局mock
config.global.mocks = {
$t: (key) => key,
$tc: (key) => key
}
// Mock router
config.global.plugins = [
{
install: (app) => {
app.config.globalProperties.$router = {
push: vi.fn(),
replace: vi.fn()
}
app.config.globalProperties.$route = {}
}
}
]
7.2 组件测试示例
<!-- tests/unit/components/Btn.spec.js -->
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Btn from '@/components/base/Btn.vue'
describe('Btn Component', () => {
it('renders correctly with default props', () => {
const wrapper = mount(Btn, {
slots: {
default: 'Click me'
}
})
expect(wrapper.text()).toBe('Click me')
expect(wrapper.classes()).toContain('btn--primary')
})
it('renders with different types', () => {
const wrapper = mount(Btn, {
props: {
type: 'secondary'
},
slots: {
default: 'Secondary Button'
}
})
expect(wrapper.classes()).toContain('btn--secondary')
})
it('handles click event', async () => {
const wrapper = mount(Btn, {
slots: {
default: 'Click me'
}
})
const clickHandler = vi.fn()
wrapper.vm.$emit('click', clickHandler)
await wrapper.trigger('click')
expect(clickHandler).toHaveBeenCalled()
})
it('disables when disabled prop is true', async () => {
const wrapper = mount(Btn, {
props: {
disabled: true
}
})
expect(wrapper.classes()).toContain('btn--disabled')
expect(wrapper.attributes('disabled')).toBeDefined()
})
})
7.3 状态管理测试
// tests/unit/store/user.spec.js
import { describe, it, expect, beforeEach } from 'vitest'
import { useUserStore } from '@/store/modules/user'
import { setActivePinia, createPinia } from 'pinia'
describe('User Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should initialize with empty state', () => {
const store = useUserStore()
expect(store.userInfo).toBeNull()
expect(store.token).toBe('')
expect(store.permissions).toEqual([])
expect(store.isAuthenticated).toBe(false)
})
it('should set user info correctly', async () => {
const store = useUserStore()
// Mock API call
const mockUserInfo = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
}
store.userInfo = mockUserInfo
expect(store.userInfo).toEqual(mockUserInfo)
expect(store.isAuthenticated).toBe(true)
})
})
八、性能优化策略
8.1 组件懒加载
// utils/lazyLoad.js
export const lazyLoad = (component) => {
return () => import(`@/components/${component}.vue`)
}
// 在路由中使用
const routes = [
{
path: '/heavy-component',
component: lazyLoad('HeavyComponent')
}
]
8.2 虚拟滚动优化
<!-- components/base/VirtualList.vue -->
<template>
<div class="virtual-list" ref="listContainer">
<div
class="virtual-list__spacer"
:style="{ height: totalHeight + 'px' }"
></div>
<div
class="virtual-list__items"
:style="{ transform: `translateY(${scrollTop}px)` }"
>
<div
v-for="item in visibleItems"
:key="item.id"
class="virtual-list__item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item"></slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps({
items: {
type: Array,
required: true
},
itemHeight: {
type: Number,
default: 50
}
})
const listContainer = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(0)
const totalHeight = computed(() => props.items.length * props.itemHeight)
const visibleCount = computed(() => Math.ceil(containerHeight.value / props.itemHeight))
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, props.items.length))
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value)
})
const handleScroll = () => {
if (listContainer.value) {
scrollTop.value = listContainer.value.scrollTop
}
}
onMounted(() => {
if (listContainer.value) {
containerHeight.value = listContainer.value.clientHeight
listContainer.value.addEventListener('scroll', handleScroll)
}
})
onUnmounted(() => {
if (listContainer.value) {
listContainer.value.removeEventListener('scroll', handleScroll)
}
})
</script>
<style scoped>
.virtual-list {
height: 400px;
overflow-y: auto;
position: relative;
}
.virtual-list__spacer {
width: 100%;
position: relative;
}
.virtual-list__items {
position: absolute;
left: 0;
right: 0;
top: 0;
will-change: transform;
}
</style>
九、构建部署优化
9.1 构建配置优化
// vite.config.js
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(({ mode }) => {
return {
base: mode === 'production' ? '/my-app/' : '/',
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
ui: ['element-plus'],
utils: ['lodash-es']
}
}
},
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
},
server: {
port: 3000,
host: '0.0.0.0',
proxy
评论 (0)