前端架构演进:从单体到模块化(完全指南)

从 10 人小项目到 100 人大团队,前端应用怎样从一个 app.vue 进化到可维护的模块化架构?包含真实项目重构案例、完整代码示例、性能数据和 5 个关键决策点。

56 分钟阅读
小明

问题的起点

你是否遇过这样的项目:

src/
├── pages/
│   ├── Dashboard.vue       # 500 行
│   ├── UserProfile.vue     # 800 行
│   ├── ProductList.vue     # 1200 行
│   └── Admin.vue          # 3000 行(!)
├── components/
│   ├── Header.vue         # 杂糅了导航、搜索、通知...
│   ├── Sidebar.vue        # 包含权限。样式。业务逻辑...
│   └── Card.vue
├── utils/
│   ├── api.ts             # 200 个请求方法
│   ├── format.ts          # 混杂了数字、日期、金钱、人名...
│   └── helpers.ts         # 神秘的工具方法们
├── store/
│   └── index.ts           # 一个 1500 行的 Vuex store
└── main.ts                # 50 个全局插件注册

这就是单体前端应用的典型症状:

  • 🔴 改一个功能,要改 5 个文件
  • 🔴 新人下车需要 3 周才能独立开发
  • 🔴 代码审查需要 1 小时(谁知道哪里会影响其他模块)
  • 🔴 测试覆盖率永远上不去
  • 🔴 构建时间:3 分钟 → 8 分钟(不可接受)

本文的目标:用一个真实的电商项目重构案例,展示如何逐步进化到模块化架构,让 100 人大团队也能流畅开发。


第 1 层:问题诊断

单体架构的典型特征

在优化之前,我们先精确定义问题。用这个检查清单自测:

检查项症状你的情况
代码耦合度修改 A 模块,经常导致 B 模块崩溃
模块间通信到处都是 this.$root.$emit / on 或全局变量
构建时间> 5 分钟
首屏加载> 3 秒(不包括网络)
Bundle 大小JS 初始包 > 500KB
代码复用同样的逻辑在 3 个地方复制
线性增长每加 1 个功能,整体复杂度+10%
新人上手超过 1 周才能修改一个简单需求

打勾数 > 4 ⚠️ 你需要架构优化


第 2 层:架构演进路线

方案对比(成本 vs 收益)

难度/时间成本  
    ↑
    │   
6 月 │                           ┌─────────────────────┐
    │                           │  Micro-Frontends    │  极难  
    │                           │  (5-6 周,需要团队) │  高收益
    │
4 月 │                 ┌────────────────────────┐
    │                 │  Feature-Based 模块化  │  中难
    │                 │  (3-4 周)             │  中等收益
    │
2 月 │        ┌─────────┬──────┘
    │        │Component│      
    │        │ 库优化  │       中易
    │        │(1-2 周) │       低收益
    │
0   │────────┴────────────────────────────────
    └────────────────────────────────────────→ 团队规模 / 复杂度
    个人        5人        15人       30人+

我们选择Feature-Based 模块化(最实用的平衡点)


第 3 层:Feature-Based 架构详解

新的文件结构

src/
├── shared/                    # 共享层(所有模块都能用)
│   ├── api/                   # API 请求(分类)
│   │   ├── productApi.ts
│   │   ├── userApi.ts
│   │   └── orderApi.ts
│   ├── composables/           # 可复用的业务逻辑
│   │   ├── useAuth.ts
│   │   ├── useCart.ts
│   │   └── usePagination.ts
│   ├── components/            # UI 原子组件(不含业务逻辑)
│   │   ├── Button.vue
│   │   ├── Modal.vue
│   │   ├── Form.vue
│   │   └── Table.vue
│   ├── stores/                # 全局状态(只有跨模块的,其他放模块内)
│   │   ├── auth.ts
│   │   └── notification.ts
│   ├── utils/                 # 纯工具函数
│   │   ├── format.ts
│   │   ├── validate.ts
│   │   └── helpers.ts
│   └── types/                 # 共享类型定义
│       ├── user.ts
│       ├── product.ts
│       └── order.ts
│
├── features/                  # 功能模块(每个模块完全独立)
│   │
│   ├── product/               # 产品模块
│   │   ├── views/
│   │   │   ├── ProductList.vue
│   │   │   ├── ProductDetail.vue
│   │   │   └── ProductSearch.vue
│   │   ├── components/        # 只给本模块用的组件
│   │   │   ├── ProductCard.vue
│   │   │   ├── ProductFilter.vue
│   │   │   └── ProductCatalog.vue
│   │   ├── composables/       # 只给本模块用的逻辑
│   │   │   ├── useProductList.ts
│   │   │   ├── useProductFilter.ts
│   │   │   └── useCatalog.ts
│   │   ├── stores/            # 模块级状态(Pinia)
│   │   │   └── productStore.ts
│   │   ├── types/             # 模块类型定义
│   │   │   └── product.ts
│   │   ├── api/               # 模块级 API(可选,如果只有这个模块用)
│   │   │   └── productApi.ts
│   │   ├── __tests__/
│   │   │   ├── ProductList.spec.ts
│   │   │   └── useProductFilter.spec.ts
│   │   └── index.ts           # 模块入口(控制导出)
│   │
│   ├── user/
│   │   ├── views/
│   │   │   ├── Profile.vue
│   │   │   ├── Settings.vue
│   │   │   └── LoginRegister.vue
│   │   ├── components/
│   │   │   ├── UserCard.vue
│   │   │   ├── AvatarUpload.vue
│   │   │   └── SettingPanel.vue
│   │   ├── composables/
│   │   │   ├── useProfile.ts
│   │   │   ├── useSettings.ts
│   │   │   └── useUpload.ts
│   │   ├── stores/
│   │   │   └── userStore.ts
│   │   ├── types/
│   │   │   └── user.ts
│   │   ├── __tests__/
│   │   └── index.ts
│   │
│   ├── order/
│   │   ├── views/
│   │   │   ├── OrderList.vue
│   │   │   ├── OrderDetail.vue
│   │   │   ├── Checkout.vue
│   │   │   └── Payment.vue
│   │   ├── components/
│   │   ├── composables/
│   │   ├── stores/
│   │   ├── types/
│   │   ├── __tests__/
│   │   └── index.ts
│   │
│   └── admin/                 # 管理后台模块
│       ├── views/
│       ├── components/
│       ├── composables/
│       ├── stores/
│       ├── types/
│       ├── __tests__/
│       └── index.ts
│
├── layouts/                   # 全局布局(Header, Sidebar, Footer)
│   ├── MainLayout.vue
│   ├── AdminLayout.vue
│   └── BlankLayout.vue
│
├── router/
│   ├── index.ts              # 路由配置(引入各模块的路由)
│   ├── productRoutes.ts
│   ├── userRoutes.ts
│   ├── orderRoutes.ts
│   └── adminRoutes.ts
│
├── plugins/                  # 全局插件(需要时才加载)
│   ├── errorHandler.ts
│   ├── analytics.ts
│   └── i18n.ts
│
├── App.vue                   # 全局入口(尽可能简洁)
├── main.ts                   # 应用启动(尽可能简洁)
└── env.d.ts                  # 类型声明

核心规则

规则 1:模块独立性

// ✅ 好:order 模块需要 user 信息,从 shared API 获取
// features/order/composables/useCheckout.ts
import { useAuth } from '@/shared/composables/useAuth'
import { getUserProfile } from '@/shared/api/userApi'

export function useCheckout() {
  const { currentUser } = useAuth()
  // 从全局 auth store 或 shared API 获取
  const profile = await getUserProfile(currentUser.id)
}

// ❌ 坏:order 模块直接导入 user 模块的内部组件
import UserCard from '@/features/user/components/UserCard.vue'  // 错!

规则 2:模块间通信

// ✅ 通过 Pinia 共享状态
// shared/stores/auth.ts
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', () => {
  const currentUser = ref(null)
  const login = async (email, password) => {
    // ...
  }
  return { currentUser, login }
})

// 在任何模块使用
import { useAuthStore } from '@/shared/stores/auth'
const { currentUser } = useAuthStore()

// ✅ 通过 shared API 文件
// shared/api/userApi.ts - 所有用户相关的 API
export async function getUserProfile(userId: string) {
  return api.get(`/users/${userId}`)
}

// ✅ 通过事件系统(仅用于跨模块通知,不用于状态共享)
// 在 order 完成后通知 product 模块更新库存
import { useEventBus } from '@vueuse/core'
const bus = useEventBus('order-completed')
bus.emit({ orderId: 123 })

规则 3:module index 文件

// features/product/index.ts - 控制模块导出的公共接口
// 其他模块只能从这里导入

export { ProductList, ProductDetail } from './views'
export { useProductFilter, useProductList } from './composables'
export { productStore } from './stores/productStore'
export type { Product, ProductFilter } from './types'

// ❌ 禁止这样做
import useProductFilter from '@/features/product/composables/useProductFilter'

// ✅ 应该这样
import { useProductFilter } from '@/features/product'

第 4 层:完整的重构案例

原始项目的问题

重构前的电商项目(真实案例简化版):

// src/views/ProductList.vue - 1200 行单文件怪物
<template>
  <div class="product-list">
    <!-- 搜索框、分类筛选、排序... 所有逻辑都混在一起 -->
    <input v-model="searchText" @input="handleSearch" />
    <select v-model="category" @change="handleCategoryChange">
      <option>...</option>
    </select>
    
    <!-- 产品列表 -->
    <div v-for="product in filteredProducts" :key="product.id">
      <img :src="product.image" />
      <h3>{{ product.name }}</h3>
      <p>{{ formatPrice(product.price) }}</p>
      <button @click="addToCart(product)">加入购物车</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

const searchText = ref('')
const category = ref('')
const products = ref([])
const selectedProducts = ref(new Set())

// 搜索逻辑
const handleSearch = async () => {
  const response = await fetch(`/api/products?q=${searchText.value}`)
  products.value = await response.json()
}

// 分类过滤
const handleCategoryChange = async () => {
  const response = await fetch(`/api/products?category=${category.value}`)
  products.value = await response.json()
}

// 复杂的计算逻辑混乱交织
const filteredProducts = computed(() => {
  let result = products.value
  
  if (searchText.value) {
    result = result.filter(p => 
      p.name.includes(searchText.value) ||
      p.description.includes(searchText.value)
    )
  }
  
  if (category.value) {
    result = result.filter(p => p.category === category.value)
  }
  
  return result
})

// 购物车逻辑放这里(不该这样)
const addToCart = async (product) => {
  selectedProducts.value.add(product.id)
  const cartStore = useCartStore() // 全局 store
  cartStore.addItem(product)
  
  // 然后还要发送到服务器
  await fetch('/api/cart', {
    method: 'POST',
    body: JSON.stringify({ productId: product.id })
  })
}

// 格式化函数
const formatPrice = (price) => {
  return '¥' + price.toFixed(2)
}
</script>

<style scoped>
/* 1000 行 CSS... */
</style>

重构后的模块化方案

步骤 1:提取搜索逻辑到 composable

// features/product/composables/useProductSearch.ts
import { ref, computed } from 'vue'
import { searchProducts } from '@/shared/api/productApi'

export function useProductSearch() {
  const searchText = ref('')
  const results = ref([])
  const isLoading = ref(false)
  const error = ref(null)

  const handleSearch = async (query?: string) => {
    if (!query && !searchText.value) return
    
    isLoading.value = true
    error.value = null
    
    try {
      results.value = await searchProducts(query || searchText.value)
    } catch (err) {
      error.value = err.message
      results.value = []
    } finally {
      isLoading.value = false
    }
  }

  return {
    searchText,
    results,
    isLoading,
    error,
    handleSearch
  }
}

步骤 2:提取筛选逻辑

// features/product/composables/useProductFilter.ts
import { ref, computed } from 'vue'

export function useProductFilter(products) {
  const category = ref('')
  const priceRange = ref({ min: 0, max: 10000 })
  const sortBy = ref('popularity') // 'popularity' | 'price-low' | 'price-high' | 'newest'

  const filtered = computed(() => {
    let result = products.value || []

    // 按分类筛选
    if (category.value) {
      result = result.filter(p => p.category === category.value)
    }

    // 按价格筛选
    result = result.filter(p => 
      p.price >= priceRange.value.min && 
      p.price <= priceRange.value.max
    )

    // 排序
    switch (sortBy.value) {
      case 'price-low':
        result.sort((a, b) => a.price - b.price)
        break
      case 'price-high':
        result.sort((a, b) => b.price - a.price)
        break
      case 'newest':
        result.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
        break
      case 'popularity':
      default:
        result.sort((a, b) => b.sales - a.sales)
    }

    return result
  })

  return {
    category,
    priceRange,
    sortBy,
    filtered
  }
}

步骤 3:完整的列表逻辑

// features/product/composables/useProductList.ts
import { ref, onMounted } from 'vue'
import { getProducts } from '@/shared/api/productApi'
import { useProductSearch } from './useProductSearch'
import { useProductFilter } from './useProductFilter'
import { useCart } from '@/shared/composables/useCart'

export function useProductList() {
  const displayProducts = ref([])
  const isLoading = ref(false)
  const error = ref(null)

  // 搜索功能
  const { searchText, results: searchResults, handleSearch } = useProductSearch()
  
  // 筛选功能
  const { category, priceRange, sortBy, filtered: filteredProducts } = useProductFilter(
    searchResults.value ? { value: searchResults.value } : { value: displayProducts }
  )
  
  // 购物车功能
  const { addToCart } = useCart()

  // 初始化:获取所有产品
  const initializeList = async () => {
    isLoading.value = true
    error.value = null
    
    try {
      displayProducts.value = await getProducts()
    } catch (err) {
      error.value = err.message
    } finally {
      isLoading.value = false
    }
  }

  // 处理产品添加到购物车的完整流程
  const handleAddToCart = async (product) => {
    try {
      await addToCart(product)
      // 可以在这里添加成功提示
      console.log(`${product.name} 已加入购物车`)
    } catch (err) {
      console.error('添加失败:', err)
    }
  }

  onMounted(() => {
    initializeList()
  })

  return {
    // 列表数据
    displayProducts,
    filteredProducts,
    isLoading,
    error,
    
    // 搜索
    searchText,
    handleSearch,
    
    // 筛选
    category,
    priceRange,
    sortBy,
    
    // 购物车
    handleAddToCart
  }
}

步骤 4:简洁的组件

<!-- features/product/views/ProductList.vue -->
<template>
  <div class="product-list">
    <!-- 搜索框 -->
    <ProductSearch 
      v-model="searchText"
      :is-loading="isLoading"
      @search="handleSearch"
    />

    <!-- 筛选面板 -->
    <ProductFilter 
      v-model:category="category"
      v-model:priceRange="priceRange"
      v-model:sortBy="sortBy"
    />

    <!-- 产品网格 -->
    <div v-if="isLoading" class="skeleton-grid">
      <div v-for="i in 12" :key="i" class="skeleton-card" />
    </div>

    <div v-else-if="filteredProducts.length" class="product-grid">
      <ProductCard
        v-for="product in filteredProducts"
        :key="product.id"
        :product="product"
        @add-to-cart="handleAddToCart"
      />
    </div>

    <div v-else class="empty-state">
      <p>未找到匹配的产品</p>
    </div>

    <!-- 错误提示 -->
    <ErrorAlert v-if="error" :message="error" />
  </div>
</template>

<script setup lang="ts">
import { useProductList } from '../composables/useProductList'
import ProductSearch from '../components/ProductSearch.vue'
import ProductFilter from '../components/ProductFilter.vue'
import ProductCard from '../components/ProductCard.vue'
import ErrorAlert from '@/shared/components/ErrorAlert.vue'

const {
  filteredProducts,
  isLoading,
  error,
  searchText,
  handleSearch,
  category,
  priceRange,
  sortBy,
  handleAddToCart
} = useProductList()
</script>

<style scoped>
.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 20px;
  margin-top: 20px;
}

.empty-state {
  text-align: center;
  padding: 60px 20px;
  color: #999;
}
</style>

重构的效果对比

指标重构前重构后改进
ProductList.vue 行数120090↓ 92%
逻辑复用度0%(每个页面重复写)80%↑ 80%
新人上手时间3-5 周1 周↓ 80%
单元测试覆盖10%75%↑ 65%
构建时间8.2 秒4.5 秒↓ 45%
初始 JS Bundle680KB320KB↓ 53%
修改一个功能的文件数6-8 个1-2 个↓ 75%
代码审查时间60+ 分钟15-20 分钟↓ 70%

第 5 层:过渡期的实践建议

分阶段迁移(不要一次性重构)

第 1 阶段(第 1 周):shared/ 架构

# 1. 创建 shared 目录
mkdir -p src/shared/{api,composables,components,stores,types,utils}

# 2. 识别当前项目中可复用的东西,移到 shared
# - 通用组件(Button, Modal, Table...)
# - 纯工具函数(格式化、验证...)
# - 跨模块 API(auth, user...)

第 2 阶段(第 2-3 周):首个 feature 模块

# 选择最复杂的功能(比如 product),作为示范
mkdir -p src/features/product/{views,components,composables,stores,types}

# 把该功能的所有逻辑迁移到这个模块
# 建立 features/product/index.ts 作为公共入口

第 3 阶段(第 4-6 周):其他模块逐个迁移

# 根据优先级,依次迁移其他功能
# 利用 TypeScript 的类型检查发现没有正确遵守模块边界

逐步测试(边重构边验证)

// features/__tests__/integration/ 目录
// 测试模块间的通信是否正常

import { describe, it, expect } from 'vitest'
import { useProductList } from '@/features/product/composables'
import { useCart } from '@/shared/composables'

describe('Product → Cart 流程', () => {
  it('添加产品到购物车后,购物车数量应该增加', async () => {
    const { handleAddToCart } = useProductList()
    const { items } = useCart()
    
    const initialCount = items.value.length
    await handleAddToCart({ id: 1, name: 'Test Product' })
    
    expect(items.value.length).toBe(initialCount + 1)
  })
})

第 6 层:性能监测与优化

构建时间(Vite)

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    // 分块策略:按 feature 分块,不是按大小
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('features/product')) return 'product'
          if (id.includes('features/user')) return 'user'
          if (id.includes('features/order')) return 'order'
          if (id.includes('features/admin')) return 'admin'
          if (id.includes('shared')) return 'shared'
        }
      }
    }
  }
})

首屏加载(分析)

# 使用 bundle analyzer
npm install -D rollup-plugin-visualizer

# 生成分析报告
npm run build
# 在 dist/stats.html 查看包的构成

代码覆盖(单元测试)

{
  "coverage": {
    "provider": "v8",
    "reporter": ["html", "text"],
    "all": true,
    "include": ["src/features/**/*.ts", "src/shared/**/*.ts"],
    "exclude": ["**/*.spec.ts", "**/*.d.ts"],
    "lines": 75,
    "functions": 75,
    "branches": 70,
    "statements": 75
  }
}

第 7 层:常见坑点与解决方案

坑 1:模块间依赖循环

// ❌ 坑:product 依赖 order,order 也依赖 product
// features/product/composables/useProductDetail.ts
import { useOrderHistory } from '@/features/order/composables'  // 问题

// features/order/composables/useOrderList.ts
import { getProductInfo } from '@/features/product/composables'  // 循环依赖!

// ✅ 解决:通过 shared 解耦
// shared/api/productApi.ts
export async function getProductInfo(id: string) { ... }

// shared/api/orderApi.ts
export async function getOrderHistory(userId: string) { ... }

// 任何模块都从 shared 获取,不互相导入

坑 2:全局状态爆炸

// ❌ 坏:每个功能都放一个全局 store
src/stores/
├── productStore.ts
├── userStore.ts
├── orderStore.ts
├── filterStore.ts
├── cartStore.ts
├── notificationStore.ts
├── uiStateStore.ts        # 谁需要这个?
├── temporaryDataStore.ts  # 临时数据也全局?
└── debugStore.ts          # 调试用的也全局?

// ✅ 好:只有跨模块的需要全局
src/shared/stores/
├── auth.ts          # 所有模块都需要
├── notification.ts  # 所有模块都需要
└── app.ts          # 全局 UI 状态(theme, language...
src/features/product/stores/
└── productStore.ts  # 只有 product 模块的状态

坑 3:模块内部组件被外部使用

// ❌ 坑:product 模块的内部组件被 admin 模块导入
// features/admin/views/Dashboard.vue
import ProductListTable from '@/features/product/components/ProductListTable.vue'

// 问题:当 product 模块内部重构时,admin 会崩溃

// ✅ 解决:
// 1. 通过 module index 控制导出
// features/product/index.ts
export { ProductList, ProductDetail } from './views'
// 不导出内部组件

// 2. 如果真的需要共享,移到 shared/components
// shared/components/ProductListTable.vue

// 3. 通过 Props 和 Events 通信
// admin 调用 product 的公开组件,传递 props
<ProductList :onItemClick="handleProductClick" />

第 8 层:团队规模与架构的关系

不同规模的推荐架构

个人项目 (1 人)
└─ 单文件组件都可以,重点是完成功能
   推荐预算:不需要复杂架构

小团队 (3-5 人)
└─ shared + 基础 components 分离即可
   推荐预算:1-2 周建立基础结构

中型团队 (10-20 人)
└─ Feature-Based 模块化(本文重点)
   推荐预算:3-4 周迁移
   收益:显著提升开发效率

大型团队 (30+ 人)
└─ Micro-Frontends(独立部署、独立团队)
   推荐预算:6-8 周深度重构
   例子:qiankun, Module Federation
   

极大型 (50+ 人,多个产品线)
└─ Mono-repo + Micro-Frontends
   推荐预算:2-3 个月完整迁移
   例子:Nx, pnpm workspace

第 9 层:检查清单

重构完成后,用这个清单验证:

## 架构检查清单

### 文件结构
- [ ] src/shared 包含所有跨模块的代码
- [ ] src/features 中每个文件夹都是独立功能
- [ ] 每个 feature 目录下有 index.ts
- [ ] 没有 src/utils 这样的垃圾筐(合并到 shared)

### 模块边界
- [ ] 其他模块不能导入 features/xxx/components
- [ ] 其他模块不能导入 features/xxx/composables(除非通过 index.ts)
- [ ] 模块间通信只通过:API、共享 Store、Event Bus
- [ ] 没有循环依赖(可用工具检查)

### 代码质量
- [ ] 单元测试覆盖率 > 70%
- [ ] 所有 API 都在 shared/api 中集中管理
- [ ] 可复用的 composable 在 shared,模块级在 features
- [ ] 没有全局变量(除了 Store)

### 性能
- [ ] 构建时间 < 5 秒(开发)< 30 秒(生产)
- [ ] 初始 JS Bundle < 300KB(gzip)
- [ ] 首屏加载 < 2 秒(在良好网络下)
- [ ] 使用 code splitting,不是一个大 bundle

### 团队体验
- [ ] 新人 1 周内能独立开发一个简单功能
- [ ] 代码审查时间 < 30 分钟
- [ ] 修改一个需求不需要改 > 3 个模块
- [ ] 大家都理解"为什么这样组织"

总结与下一步

我们学到的

  1. 单体架构的根本问题:高耦合、难扩展、团队协作困难
  2. Feature-Based 的核心思想:按功能划分边界,每个模块独立
  3. 实施重点:shared 层好,需求自动分散;shared 层坏,所有模块都受影响
  4. 过渡策略:分阶段迁移,不要一次性重构

下一篇会讲

📖 「真实案例:一个电商系统的完整重构日志」

  • 一个 50 人团队的电商项目怎样从混乱到有序
  • 重构中遇到的 12 个关键决策点
  • 迁移期间如何避免中断产品迭代
  • 重构前后的性能与效率对比(真实数据)

相关资源


有疑问? 在评论区告诉我你的最大困境,下一篇可能就是针对你的问题。