Vue 3企业级项目架构设计:从组件库封装到状态管理的完整工程化实践

甜蜜旋律
甜蜜旋律 2026-01-22T15:06:00+08:00
0 0 1

引言

随着前端技术的快速发展,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)

    0/2000