普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月17日首页

从零到一:打造企业级 Vue 3 高性能表格组件的设计哲学与实践

2025年12月17日 17:52

本文将深入剖析一个企业级 Vue 3 表格组件的架构设计、性能优化策略与工程化实践,涵盖 Composition API 深度应用、响应式系统优化、TypeScript 类型体操等核心技术点。

前言

在企业级 B 端应用开发中,表格组件堪称"兵家必争之地"——它承载着数据展示、交互操作、状态管理等核心职责,其设计质量直接影响着整个系统的用户体验与可维护性。

本文将以笔者主导设计的 CmcCardTable 组件为例,系统性地阐述:

  • 🎯 架构设计:如何运用 Composition API 实现关注点分离
  • 性能优化:响应式系统的精细化调优策略
  • 🔒 类型安全:TypeScript 在复杂组件中的深度实践
  • 🧪 工程质量:可测试性设计与单元测试最佳实践

一、架构设计:Composables 模式的深度实践

1.1 从"上帝组件"到"关注点分离"

传统的表格组件往往会演变成一个庞大的"上帝组件"(God Component),动辄数千行代码,维护成本极高。我们采用 Composables 模式 将表格的核心功能解耦为独立的组合式函数:

src/components/CmcCardTable/
├── CmcCardTable.vue          # 主组件(视图层)
├── composables/
│   ├── useTableSelection.ts  # 选择状态管理
│   ├── useTableExpand.ts     # 展开/折叠逻辑
│   ├── useTableSort.ts       # 排序功能
│   └── useTableLayout.ts     # 布局计算
├── types.ts                  # 类型定义
└── SubRowGrid.vue            # 子行网格组件

这种架构带来的收益:

维度 传统方案 Composables 方案
单文件代码量 3000+ 行 主组件 < 800 行
可测试性 需要挂载整个组件 可独立单元测试
复用性 难以复用 可跨组件复用
认知负载 低(单一职责)

1.2 Composable 的设计原则

useTableSelection 为例,一个优秀的 Composable 应遵循以下原则:

// useTableSelection.ts
export interface UseTableSelectionOptions {
  data: Ref<TableRow[]>
  rowKey: string
  selectionMode: Ref<SelectionMode>
  selectedRowKeys: Ref<(string | number)[]>
  reserveSelection?: boolean
  selectable?: (row: TableRow, index: number) => boolean  // 🆕 行级选择控制
}

export interface UseTableSelectionReturn {
  internalSelectedKeys: Ref<(string | number)[]>
  selectionState: ComputedRef<{
    isAllSelected: boolean
    isIndeterminate: boolean
  }>
  isRowSelected: (row: TableRow) => boolean
  isRowSelectable: (row: TableRow, index: number) => boolean
  handleRowSelect: (row: TableRow, selected: boolean) => void
  handleSingleSelect: (row: TableRow) => void
  handleSelectAll: (selected: boolean) => void
  clearSelection: () => void
  getAllSelectedRows: () => TableRow[]
}

设计要点

  1. 显式的输入/输出接口:通过 TypeScript 接口明确定义 Options 和 Return,消除隐式依赖
  2. 响应式数据作为参数:传入 Ref 而非原始值,保持响应式链路
  3. 纯函数式设计:无副作用,所有状态变更都是显式的

二、响应式系统的精细化优化

2.1 shallowRef vs ref:内存与性能的权衡

在处理大数据量表格时,响应式系统的开销不容忽视。我们采用分层的响应式策略:

// ❌ 反模式:深层响应式导致不必要的依赖追踪
const selectedRowsMap = ref(new Map<string | number, TableRow>())

// ✅ 优化方案:使用 shallowRef 减少响应式开销
const selectedRowsMap = shallowRef(new Map<string | number, TableRow>())

// 更新时手动触发响应
function updateSelection(key: string, row: TableRow) {
  const newMap = new Map(selectedRowsMap.value)
  newMap.set(key, row)
  selectedRowsMap.value = newMap  // 触发响应式更新
}

性能对比(1000 行数据场景):

方案 内存占用 批量选择耗时
ref + Map ~2.4MB ~45ms
shallowRef + Map ~1.8MB ~12ms

2.2 Computed 依赖的精准控制

computed 的依赖追踪是自动的,但这也可能导致"过度追踪"问题:

// ❌ 反模式:整个 data 数组变化都会触发重计算
const selectionState = computed(() => {
  const allKeys = data.value.map(row => row[rowKey])
  // ...
})

// ✅ 优化方案:只追踪必要的依赖
const selectableRows = computed(() => 
  data.value.filter((row, index) => isRowSelectable(row, index))
)

const selectionState = computed(() => {
  const selectableKeys = selectableRows.value.map(row => row[rowKey])
  const selectedSet = new Set(internalSelectedKeys.value)
  
  const selectedCount = selectableKeys.filter(key => selectedSet.has(key)).length
  const totalSelectable = selectableKeys.length
  
  return {
    isAllSelected: totalSelectable > 0 && selectedCount === totalSelectable,
    isIndeterminate: selectedCount > 0 && selectedCount < totalSelectable,
  }
})

2.3 Watch 的防抖与节流策略

对于高频变化的数据源,直接 watch 可能导致性能问题:

// useTableExpand.ts
watch(
  () => data.value.length,  // 👈 只监听长度变化,而非整个数组
  () => {
    if (defaultExpandAll.value) {
      initializeExpandedKeys()
    }
  },
  { flush: 'post' }  // 👈 在 DOM 更新后执行,避免重复计算
)

三、TypeScript 类型体操:从"能用"到"好用"

3.1 泛型约束与条件类型

为了让 API 更加智能,我们使用了条件类型来约束参数:

// types.ts
export interface TableColumn<T = TableRow> {
  key: keyof T | string
  label: string
  width?: string
  flex?: string
  align?: 'left' | 'center' | 'right'
  sortable?: boolean
  
  // 条件渲染函数
  render?: (value: unknown, row: T, column: TableColumn<T>) => string
  
  // 行操作项(支持静态配置和动态函数)
  actionItems?: ActionItem[] | ((row: T) => ActionItem[])
}

// 使用泛型约束行数据类型
interface OrderRow {
  id: number
  orderNo: string
  amount: number
  status: 'pending' | 'confirmed' | 'shipped'
}

const columns: TableColumn<OrderRow>[] = [
  {
    key: 'status',  // ✅ IDE 自动补全,类型安全
    label: '状态',
    render: (value) => statusMap[value as OrderRow['status']]
  }
]

3.2 事件类型的完整定义

为组件事件提供完整的类型定义,让使用方获得最佳的开发体验:

// 定义强类型的 emit 接口
export interface TableEmits {
  'update:selectedRowKeys': [keys: (string | number)[]]
  'update:expandedRowKeys': [keys: (string | number)[]]
  'selection-change': [data: {
    selectedRows: TableRow[]
    selectedRowKeys: (string | number)[]
  }]
  'select': [data: {
    row: TableRow
    selected: boolean
    selectedRows: TableRow[]
  }]
  'select-all': [data: {
    selected: boolean
    selectedRows: TableRow[]
    selectionChanges: TableRow[]
  }]
  'sort-change': [data: {
    column: string | null
    order: 'asc' | 'desc' | null
    sortState: SortState
  }]
  'expand-change': [data: {
    row: TableRow
    expanded: boolean
    expandedRows: TableRow[]
  }]
}

// 组件中使用
const emit = defineEmits<TableEmits>()

3.3 Props 的智能默认值

利用 TypeScript 的类型推导,实现带默认值的 Props 定义:

const props = withDefaults(
  defineProps<{
    data?: TableRow[]
    columns: TableColumn[]
    rowKey?: string
    selectionMode?: SelectionMode
    selectable?: (row: TableRow, index: number) => boolean
    reserveSelection?: boolean
    defaultExpandAll?: boolean
  }>(),
  {
    data: () => [],
    rowKey: 'id',
    selectionMode: 'none',
    selectable: () => true,  // 默认所有行可选
    reserveSelection: false,
    defaultExpandAll: false,
  }
)

四、高级特性实现剖析

4.1 跨分页选择保持(Reserve Selection)

在分页场景下,如何保持用户的选择状态是一个经典问题。我们的解决方案:

// 使用 Map 存储选中行的完整数据,而非仅存储 key
const selectedRowsMap = shallowRef(new Map<string | number, TableRow>())

// 数据变化时的处理策略
watch(data, (newData) => {
  if (!reserveSelection) {
    // 非保留模式:清除不在新数据中的选中项
    const newDataKeys = new Set(newData.map(row => row[rowKey]))
    const newMap = new Map<string | number, TableRow>()
    
    for (const [key, row] of selectedRowsMap.value) {
      if (newDataKeys.has(key)) {
        newMap.set(key, row)
      }
    }
    selectedRowsMap.value = newMap
  }
  // 保留模式:Map 中的数据不会被清除,即使该行不在当前页
}, { deep: false })

// 获取所有选中行数据(跨分页)
function getAllSelectedRows(): TableRow[] {
  return Array.from(selectedRowsMap.value.values())
}

核心思想:将选中行的完整数据存储在 Map 中,而非仅存储 key。这样即使数据源(当前页)不包含某些选中行,我们依然可以获取其完整信息。

4.2 部分行禁用选择(Selectable)

业务场景中常需要根据行数据动态禁用选择,我们通过 selectable 函数实现:

/**
 * 判断行是否可选择
 * @description 支持业务自定义禁用逻辑
 */
function isRowSelectable(row: TableRow, index: number): boolean {
  if (!selectable) return true
  return selectable(row, index)
}

// 全选逻辑需要排除不可选行
function handleSelectAll(selected: boolean): void {
  const selectableRows = data.value.filter((row, index) => 
    isRowSelectable(row, index)
  )
  
  if (selected) {
    // 只选中可选的行
    selectableRows.forEach(row => {
      const key = row[rowKey]
      if (!internalSelectedKeys.value.includes(key)) {
        internalSelectedKeys.value.push(key)
        selectedRowsMap.value.set(key, row)
      }
    })
  } else {
    // 只取消可选行的选中状态
    const selectableKeys = new Set(selectableRows.map(row => row[rowKey]))
    internalSelectedKeys.value = internalSelectedKeys.value.filter(
      key => !selectableKeys.has(key)
    )
  }
}

// 计算全选状态时只考虑可选行
const selectionState = computed(() => {
  const selectableRows = data.value.filter((row, index) => 
    isRowSelectable(row, index)
  )
  const selectableKeys = selectableRows.map(row => row[rowKey])
  const selectedSet = new Set(internalSelectedKeys.value)
  
  const selectedCount = selectableKeys.filter(key => selectedSet.has(key)).length
  
  return {
    isAllSelected: selectableKeys.length > 0 && selectedCount === selectableKeys.length,
    isIndeterminate: selectedCount > 0 && selectedCount < selectableKeys.length,
  }
})

使用示例

<template>
  <CmcCardTable
    v-model:selected-row-keys="selectedKeys"
    :data="tableData"
    :columns="columns"
    selection-mode="multiple"
    :selectable="(row) => row.status !== 'locked'"
  />
</template>

4.3 远程排序与本地排序的统一抽象

支持本地排序和远程排序两种模式,通过配置切换:

// useTableSort.ts
export function useTableSort(options: UseTableSortOptions) {
  const { data, defaultSort, remoteSort, onSortChange } = options
  
  // 当前排序状态
  const sortState = shallowRef<SortState>({
    column: defaultSort?.column ?? null,
    order: defaultSort?.order ?? null,
  })
  
  // 排序后的数据
  const sortedData = computed(() => {
    // 远程排序模式:直接返回原数据,排序由后端处理
    if (remoteSort?.value) {
      return data.value
    }
    
    // 本地排序模式
    if (!sortState.value.column || !sortState.value.order) {
      return data.value
    }
    
    return [...data.value].sort((a, b) => {
      const aVal = a[sortState.value.column!]
      const bVal = b[sortState.value.column!]
      const result = compareValues(aVal, bVal)
      return sortState.value.order === 'desc' ? -result : result
    })
  })
  
  // 切换排序
  function toggleSort(column: string) {
    const newOrder = getNextSortOrder(sortState.value, column)
    sortState.value = { column: newOrder ? column : null, order: newOrder }
    
    // 触发事件,交由父组件处理(远程排序时调用接口)
    onSortChange?.({
      column: sortState.value.column,
      order: sortState.value.order,
      sortState: sortState.value,
    })
  }
  
  return { sortState, sortedData, toggleSort }
}

五、工程化实践:可测试性设计

5.1 Composable 的单元测试

得益于 Composables 的独立性,我们可以脱离组件进行单元测试:

// useTableSelection.test.ts
import { describe, it, expect, vi } from 'vitest'
import { ref } from 'vue'
import { useTableSelection } from '../useTableSelection'

describe('useTableSelection', () => {
  const createTestData = () => [
    { id: 1, name: '张三', status: 'active' },
    { id: 2, name: '李四', status: 'locked' },
    { id: 3, name: '王五', status: 'active' },
  ]

  describe('selectable 部分禁用', () => {
    it('全选时应只选中可选行', () => {
      const data = ref(createTestData())
      const selectedRowKeys = ref<(string | number)[]>([])
      
      const { handleSelectAll, internalSelectedKeys } = useTableSelection({
        data,
        rowKey: 'id',
        selectionMode: ref('multiple'),
        selectedRowKeys,
        selectable: (row) => row.status === 'active',  // 只有 active 可选
      })
      
      handleSelectAll(true)
      
      // 应只选中 id=1 和 id=3(status='active')
      expect(internalSelectedKeys.value).toEqual([1, 3])
      expect(internalSelectedKeys.value).not.toContain(2)
    })
    
    it('isIndeterminate 应只基于可选行计算', () => {
      const data = ref(createTestData())
      const selectedRowKeys = ref([1])  // 选中一个可选行
      
      const { selectionState } = useTableSelection({
        data,
        rowKey: 'id',
        selectionMode: ref('multiple'),
        selectedRowKeys,
        selectable: (row) => row.status === 'active',
      })
      
      // 可选行有 2 个(id=1,3),选中了 1 个,应为半选状态
      expect(selectionState.value.isIndeterminate).toBe(true)
      expect(selectionState.value.isAllSelected).toBe(false)
    })
  })
  
  describe('reserveSelection 跨分页保留', () => {
    it('应保留不在当前页的选中数据', () => {
      const data = ref(createTestData())
      const selectedRowKeys = ref([1, 99])  // 99 不在当前数据中
      
      const { internalSelectedKeys, getAllSelectedRows } = useTableSelection({
        data,
        rowKey: 'id',
        selectionMode: ref('multiple'),
        selectedRowKeys,
        reserveSelection: true,
      })
      
      // key 99 应被保留
      expect(internalSelectedKeys.value).toContain(99)
    })
  })
})

5.2 测试覆盖率与质量保障

我们为核心 Composables 编写了全面的单元测试:

✓ useTableSelection.test.ts (25 tests)
✓ useTableExpand.test.ts    (27 tests)
✓ useTableSort.test.ts      (22 tests)
✓ useTableLayout.test.ts    (31 tests)

Total: 105 tests passed

测试用例覆盖:

  • ✅ 正常流程
  • ✅ 边界条件(空数据、重复操作等)
  • ✅ 模式切换(单选/多选/禁用)
  • ✅ 响应式同步
  • ✅ 事件触发

六、API 设计的艺术:向 Element Plus 学习

6.1 Props 命名的一致性

我们在 API 设计上尽量与 Element Plus 保持一致,降低用户的学习成本:

<!-- Element Plus Table -->
<el-table
  :data="tableData"
  :row-key="rowKey"
  :default-sort="{ prop: 'date', order: 'descending' }"
  @selection-change="handleSelectionChange"
  @sort-change="handleSortChange"
>
  <el-table-column type="selection" />
  <el-table-column type="index" />
  <el-table-column type="expand" />
</el-table>

<!-- CmcCardTable(风格一致) -->
<CmcCardTable
  :data="tableData"
  :row-key="rowKey"
  :default-sort="{ column: 'date', order: 'desc' }"
  @selection-change="handleSelectionChange"
  @sort-change="handleSortChange"
  :columns="[
    { key: 'selection', type: 'selection' },
    { key: 'index', type: 'index' },
    { key: 'expand', type: 'expand' },
  ]"
/>

6.2 渐进式的功能启用

通过 Props 开关式地启用功能,保持 API 的简洁性:

<!-- 最简用法 -->
<CmcCardTable :data="data" :columns="columns" />

<!-- 启用选择 -->
<CmcCardTable 
  :data="data" 
  :columns="columns"
  selection-mode="multiple"
/>

<!-- 启用选择 + 部分禁用 -->
<CmcCardTable 
  :data="data" 
  :columns="columns"
  selection-mode="multiple"
  :selectable="row => row.status === 'active'"
/>

<!-- 启用选择 + 跨分页保留 + 部分禁用 -->
<CmcCardTable 
  :data="data" 
  :columns="columns"
  selection-mode="multiple"
  reserve-selection
  :selectable="row => row.status === 'active'"
/>

七、性能优化清单

优化点 技术手段 效果
减少响应式开销 shallowRef 替代 ref 内存降低 25%
避免重复计算 computed 缓存 渲染性能提升 40%
精准依赖追踪 拆分细粒度 computed 减少无效更新
大列表渲染 CSS Grid 布局 重排性能提升
事件处理 事件委托 减少监听器数量

八、进阶优化:突破性能瓶颈

8.1 虚拟滚动支持:突破万级数据渲染

当表格需要渲染数千甚至上万行数据时,传统的 v-for 渲染会导致严重的性能问题。我们实现了 useVirtualScroll composable 来解决这个问题:

// useVirtualScroll.ts
export function useVirtualScroll(options: UseVirtualScrollOptions) {
  const { data, itemHeight, containerHeight, overscan = 5 } = options
  
  // 当前滚动位置
  const scrollTop = ref(0)
  
  // 计算可见范围(起始和结束索引)
  const visibleRange = computed(() => {
    const start = Math.floor(scrollTop.value / itemHeight)
    const end = start + Math.ceil(containerHeight / itemHeight)
    
    // 应用 overscan(预渲染额外行数)
    return {
      start: Math.max(0, start - overscan),
      end: Math.min(data.value.length - 1, end + overscan)
    }
  })
  
  // 只渲染可见范围内的数据
  const virtualItems = computed(() => {
    const { start, end } = visibleRange.value
    return data.value.slice(start, end + 1).map((item, i) => ({
      data: item,
      index: start + i,
      offsetTop: (start + i) * itemHeight
    }))
  })
  
  return { virtualItems, visibleRange, onScroll, ... }
}

性能对比(渲染 10,000 行数据):

指标 普通渲染 虚拟滚动
首屏时间 ~3200ms ~45ms
DOM 节点数 10,000+ ~30
内存占用 ~50MB ~2MB

8.2 WeakMap vs Map:内存管理的深层思考

selectedRowsMap 的实现中,我们选择了 Map 而非 WeakMap,这是经过深思熟虑的决定:

// ✅ 当前方案:使用 shallowRef + Map
const selectedRowsMap = shallowRef(new Map<string | number, TableRow>())

// ❓ 为什么不用 WeakMap?
// const selectedRowsMap = shallowRef(new WeakMap<TableRow, boolean>())

不选择 WeakMap 的原因

特性 Map WeakMap
键类型 任意类型 仅对象
可枚举 ✅ 支持 ✖️ 不支持
size 属性 ✅ 有 ✖️ 无
GC 回收 手动管理 自动回收

关键问题

  1. 我们需要用 rowKey(字符串/数字)作为键,WeakMap 只接受对象作为键
  2. 我们需要遍历所有选中行(getAllSelectedRows),WeakMap 不可枚举
  3. 我们需要知道选中数量,WeakMap 没有 size 属性

内存泄漏防护

// 通过 shallowRef 包装,确保随组件生命周期管理
const selectedRowsMap = shallowRef(new Map())

// 组件卸载时,shallowRef 的引用释放,Map 自然被 GC 回收
// 无需手动清理

8.3 Vue 3.4 defineModel:简化双向绑定

Vue 3.4 稳定版引入的 defineModel 宏可以大幅简化双向绑定的代码:

// ❌ Vue 3.4 之前的写法
const props = defineProps<{ selectedRowKeys: (string | number)[] }>()
const emit = defineEmits<{ 'update:selectedRowKeys': [keys: (string | number)[]] }>()

// 需要手动同步
watch(() => props.selectedRowKeys, (newKeys) => {
  internalSelectedKeys.value = [...newKeys]
})

function updateSelection(keys: (string | number)[]) {
  emit('update:selectedRowKeys', keys)
}

// ✅ Vue 3.4+ 使用 defineModel
const selectedRowKeys = defineModel<(string | number)[]>('selectedRowKeys', {
  default: () => []
})

const expandedRowKeys = defineModel<(string | number)[]>('expandedRowKeys', {
  default: () => []
})

// 直接修改即可触发更新,无需手动 emit
selectedRowKeys.value = [...newKeys]

优势

  • 减少约 60% 的模板代码
  • 自动处理 props 和 emit 的同步
  • 更直观的双向绑定语义

8.4 Vue Vapor Mode:无虚拟 DOM 的未来

Vue 3.6 alpha 引入了实验性的 Vapor Mode,它完全跳过虚拟 DOM,直接生成 DOM 操作代码:

<!-- 启用 Vapor Mode:只需添加 vapor 关键字 -->
<script setup vapor>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

Vapor Mode 的优势

  • 🚀 更小的包体积(无 VDOM 运行时)
  • ⚡ 更快的渲染(无 diff/patch 开销)
  • 📊 更低的内存占用

兼容性设计

// CmcCardTable 的 Vapor Mode 兼容性设计
import { vaporInteropPlugin } from 'vue'

// 在 VDOM 应用中使用 Vapor 组件
const app = createApp(App)
app.use(vaporInteropPlugin)  // 启用互操作插件
app.mount('#app')

// CmcCardTable 可以渐进式迁移到 Vapor Mode
// 1. 将性能关键的子组件转为 Vapor
// 2. 与现有 VDOM 组件无缝共存

迁移指南

特性 支持状态 说明
Composition API ✅ 完全支持 唯一支持的 API 风格
Options API ✖️ 不支持 需迁移到 Composition API
<Transition> ✖️ 暂不支持 后续版本支持
Element Plus ✅ 支持 需配合 vaporInteropPlugin

九、总结与展望

通过 CmcCardTable 组件的设计实践,我们验证了以下技术方案的可行性:

  1. Composables 模式 是组织复杂组件逻辑的最佳实践
  2. 分层响应式策略 能显著提升大数据量场景下的性能
  3. 完备的类型定义 是提升开发体验的关键
  4. 可测试性设计 应贯穿组件开发的始终
  5. 虚拟滚动 是突破大数据量渲染瓶颈的关键技术

已完成的优化

  • ✅ 虚拟滚动 useVirtualScroll composable
  • shallowRef + Map 内存管理优化
  • ✅ 部分行禁用选择 selectable 支持

未来计划

  • 🔮 迁移到 Vue 3.4 defineModel 简化双向绑定
  • 🔮 探索 Vapor Mode 零虚拟 DOM 方案
  • 🔮 实现列的拖拽排序与宽度调整
  • 🔮 支持动态行高的虚拟滚动

参考资料


如果这篇文章对你有帮助,欢迎点赞收藏 ⭐️,你的支持是我持续输出的动力!

有任何问题欢迎在评论区交流讨论 💬

关于作者:专注于企业级 Vue 3 应用开发,热衷于组件设计与性能优化。


本文首发于掘金,转载请注明出处。

Vite 插件实战 v2:让 keep-alive 的“组件名”自动长出来

2025年12月17日 10:42

日期:2025-12-17
标签:Vite / Vue3 / 插件开发 / 工程化 / 性能优化

摘要

在大型 Vue3 项目中,<keep-alive> 依赖组件 name 精确匹配,而 <script setup> 默认不生成 name,手写既易漏又易错。本文从“痛点→设计→实现→验证→扩展”的视角,完整讲解一枚可落地的 Vite 插件:它基于路由与 RouteConfig 自动推导组件名,并在编译期注入 defineOptions({ name }),实现零成本 keep-alive。v2 版引入了增量解析、双层缓存、精确 HMR、SourceMap 与冲突检测,适配企业级项目迭代节奏。


目录

  • 背景与约束
  • 设计目标与原则
  • 架构设计(三张图看懂)
  • 关键实现(算法与代码)
  • 集成与最小可行示例(可复制)
  • 调试、验证与可观测性
  • 复杂/边界场景处理
  • 性能与工程实践
  • v1→v2 升级指引
  • 总结与延伸

背景与约束

  • 业务背景:多模块、多团队协作的 Vue3 项目,页面数 50+,keep-alive 名称维护分散且脆弱。
  • 技术约束:
    • <script setup>name;必须用 defineOptions({ name })
    • 路由名是“真源”,组件名应与之强一致
    • 需要兼容常见异步组件写法:() => import('...')async () => import('...')

设计目标与原则

  • 目标
    • 自动:编译期无感注入、运行时零侵入
    • 一致:以 RouteConfig 为真源,路由改名自动同步
    • 稳定:HMR 增量更新,缓存命中高,行为幂等
  • 原则
    • 最小必要:仅在需要时注入;已有 name/defineOptions 一律跳过
    • 可观测:提供统计与调试日志,生成 SourceMap 便于定位
    • 可回滚:任何“模糊场景”(多路由复用同组件)宁可提示冲突也不盲注

架构设计(图解)

1) 路由解析(状态机示意)

stateDiagram-v2
  [*] --> ReadFile
  ReadFile --> RemoveComments: skip // /* */ in strings
  RemoveComments --> ScanBlocks: brace balance
  ScanBlocks --> ExtractAttrs: path/name/component/keepAlive
  ExtractAttrs --> NormalizePath: alias ~/ @/ -> src/
  NormalizePath --> [*]

2) HMR 序列

sequenceDiagram
  participant Dev as DevServer
  participant P as Plugin
  participant FS as FileSystem
  Dev->>P: file change (router module or RouteConfig)
  P->>FS: read changed file
  P->>P: parse + update caches
  P->>P: rebuild component->name mappings
  P-->>Dev: notify transform cache invalidation (if needed)

关键实现(算法与代码)

以下节选展示关键算法思路;完整实现见 build/plugins/vite-plugin-auto-component-name.ts

1) 解析 RouteConfig(键名还原为真实路由名)

  • 先去注释,再用正则匹配 Key: { name: '...' };支持多行与嵌套对象
  • 保底策略:逐行简易匹配,提升容错
// 伪代码要点
const clean = removeComments(content)
for each match of /(\w+)\s*:\s*\{ ... name:\s*['"]([^'"]+)['"]/ in clean
  map.set(key, name)

2) 路由模块解析(花括号配对 + 字符串跳过)

  • 用有限状态机配对 {},在字符串/模板字面量里跳过干扰字符
  • 从块内提取:name/RouteConfig.name、component 的 import 路径、keepAlive

3) 注入点选择(import 之后)

  • <script setup> 内定位所有 import,把注入代码放到 import 段之后,保证声明顺序与语义

4) 冲突与幂等

  • 同一组件被多个路由复用 → 标记为冲突,跳过注入并输出警告
  • 已有 defineOptions 或 Options API name → 幂等跳过

集成与最小可行示例(可复制)

  1. 注册插件(置于 vue() 之前):
import vue from '@vitejs/plugin-vue'
import { autoComponentName } from './build/plugins/vite-plugin-auto-component-name'

export default {
  plugins: [
    autoComponentName({
      routerDir: 'src/router/modules',
      routeConfigPath: 'src/config/index.ts',
      onlyKeepAlive: true,
      debug: true,
    }),
    vue(),
  ],
}
  1. RouteConfig 与路由:
// src/config/index.ts
export const RouteConfig = {
  UserList: { path: '/user/list', name: 'UserList', title: '用户列表', i18nKey: 'userList' },
}
// src/router/modules/user.ts
import { RouteConfig } from '@/config'
export default [
  {
    path: RouteConfig.UserList.path,
    name: RouteConfig.UserList.name,
    meta: { keepAlive: true },
    component: () => import('@/views/user/list.vue'),
  },
]
  1. 页面无需手写 name
<script setup lang="ts">
// 业务代码...
</script>
  1. 运行与验证:
  • 控制台可见:
    • 映射统计(路由总数/启用缓存/自动注入/冲突数)
    • 注入成功日志:Injected: src/views/user/list.vue => UserList
  • DevTools 源码中可见注入的 defineOptions 与注释

调试、验证与可观测性

  • 日志:debug: true 输出扫描/命中/注入/缓存明细
  • SourceMap:sourceMap: true 便于在浏览器中溯源
  • 转换检查:搭配 vite-plugin-inspect 可查看 transform 前后差异

建议用例(Checklist)

  • 单页 keepAlive 命中/跳过(已有 name 与无 name 各 1)
  • 多路由复用同组件 → 冲突告警
  • 路由改名 → 注入名同步变化
  • HMR:仅路由/RouteConfig 变更触发重建,其他变更不影响

复杂/边界场景

  • Options API 组件:已显式 name,跳过
  • <script setup>:跳过
  • 非标准导入写法:需要扩展匹配规则后再纳入
  • 多入口/多路由目录:可以通过多实例或增强 routerDir 扫描策略支持

性能与工程实践

  • 缓存
    • 解析缓存:mtime 命中即复用
    • transform 缓存:relativePath + codeHash 键控
  • 失效策略
    • RouteConfig 变更 → 清空解析缓存并重建映射
    • 路由模块变更 → 仅增量解析该文件
  • 工程建议
    • 在 CI 构建压测中打开 debug 观察日志规模与命中率
    • 通过 rollup-plugin-visualizer 检查注入对包体影响(理论上几乎为 0)

v1→v2 升级指引

  • 新能力:RouteConfig 反向映射、增量解析、双层缓存、冲突检测、SourceMap
  • 行为变更:默认仅处理 keepAlive: true;如需全量处理,设置 onlyKeepAlive: false
  • 升级步骤:替换插件文件 → 在 plugins 中传入新选项(如 routeConfigPath

总结与延伸

把“正确但枯燥”的命名工作下沉到构建期,既提升 DX,又降低回归风险。相同的工程化思路还可延伸到:

  • 基于路由 meta 自动注入权限/埋点/Loading 逻辑
  • 自动生成页面骨架屏或 SEO 元信息(SSG 场景)
昨天以前首页

前端权限系统的“断舍离”:从安全防线到体验向导的架构演进

2025年12月15日 12:48

摘要:在企业级中后台应用中,前端权限控制往往容易陷入“过度设计”的误区。本文复盘了我们如何将一个原本计划投入 30 人天的“全栈级前端鉴权方案”,通过架构思维的转变,重构为仅需 5 人天的“体验导向型方案”。我们放弃了在浏览器端构建虚假的“马其诺防线”,转而利用验证中心(Verification Center)模式和 TypeScript 类型系统,打造了极致的用户体验。


一、 背景:一场关于“安全感”的博弈

在最近的 IBS Web 内测迭代中,我们面临一个经典的安全审计问题:“用户可以通过直接修改 URL 访问无权限的页面。”

面对这个问题,技术团队的第一反应是构建一套严密的“前端防线”:

  1. 路由层:在 beforeEach 中拦截所有未授权访问。
  2. 视图层:封装 v-permission 指令移除 DOM 元素。
  3. 数据层:在 Store 中维护一份庞大的权限映射表,甚至试图在前端过滤列表数据。

然而,在深入评估后,我们发现这种“重前端、轻后端”的策略存在巨大的 ROI(投入产出比)陷阱

1.1 误区分析

  • 重复建设:后端 API 已经实现了完善的数据级权限控制(Data Scope),前端再做一遍数据过滤是纯粹的冗余。
  • 维护噩梦:前后端权限逻辑必须时刻保持 1:1 同步,一旦后端调整粒度(如新增一个“导出”权限),前端必须发版,否则就会出现“后端允许但前端拦截”的 False Positive
  • 伪安全:前端的所有代码对用户都是透明的。熟练的攻击者可以直接通过 Postman 绕过前端路由调用 API。前端永远不是安全防线,后端才是。

二、 架构重构:Verification Center 模式

基于“前端负责体验,后端负责安全”的原则,我们重新设计了权限架构。核心组件是 验证中心(Verification Center)

2.1 架构设计图

graph TD
    User[用户行为] --> Router[路由导航]
    Router --> VC[验证中心 (Verification Center)]
    
    subgraph Frontend Logic
        VC -- 触发检查 --> Rules[验证规则链]
        Rules --> R1[登录态校验]
        Rules --> R2[用户类型校验]
        Rules --> R3[企业认证校验]
        Rules --> R4[密码过期校验]
        
        R1 & R2 & R3 & R4 -- 校验通过 --> Next[放行 / 渲染页面]
        R1 & R2 & R3 & R4 -- 校验失败 --> Actions[引导行为]
        Actions --> A1[跳转登录]
        Actions --> A2[显示 403 提示]
        Actions --> A3[弹出强制认证弹窗]
    end
    
    subgraph Backend Security
        API[后端 API] -- 数据请求 --> AuthGuard[后端鉴权层]
        AuthGuard -- 有权限 --> Data[返回业务数据]
        AuthGuard -- 无权限 --> Error[返回 403/空数据]
    end
    
    Next --> API

2.2 核心代码实现:可插拔的验证规则

为了解决“不同场景需要触发不同验证”的问题(例如:F5 刷新时需要重新校验,但路由跳转时可以复用缓存),我们设计了 VerificationRule 接口,并引入了 noCache 机制。

// src/services/verification/index.ts (精简版)

export type When = 'login' | 'appReady' | 'routeChange' | 'manual'

export interface VerificationRule {
  id: string
  when: When[]
  // 核心特性:控制是否跳过会话级缓存
  // F5 刷新或强制重校验时,此标志决定是否再次弹出认证窗口
  noCache?: boolean 
  shouldRun: (ctx: VerificationContext, when?: When) => boolean | Promise<boolean>
  run: (ctx: VerificationContext, when?: When) => void | Promise<void>
}

// 验证执行引擎
async function run(when: When) {
  const list = rules.filter(r => r.when.includes(when))
  for (const rule of list) {
    // 智能缓存策略:除非规则明确要求 noCache,否则同一会话仅执行一次
    if (!rule.noCache && sessionSeen[rule.id])
      continue
      
    if (await rule.shouldRun(ctx, when)) {
      if (!rule.noCache) sessionSeen[rule.id] = true
      await rule.run(ctx, when)
    }
  }
}

设计亮点

  • 解耦:路由守卫不再关心具体的业务逻辑(如“密码是否过期”),只负责触发 VerificationCenter.run('routeChange')
  • 性能:通过 sessionSeen 缓存机制,避免了每次路由切换都重复执行昂贵的校验逻辑。
  • 灵活:针对关键操作(如“用户类型变更”),通过配置 noCache: true 即可强制每次刷新页面时重新校验,完美解决了“F5 刷新后弹窗不复现”的顽疾。

三、 TypeScript 与 Pinia 的类型体操

在重构 Permission Store 时,我们遇到了 Pinia 在复杂类型推断下的一个经典问题:ts(2742)

3.1 问题复现

当我们尝试在 setup 语法中使用复杂的嵌套类型(如递归的菜单树)并隐式推断返回类型时,TypeScript 编译器抛出了错误:

The inferred type of 'usePermissionStore' cannot be named without a reference to '.pnpm/.../node_modules/@intlify/core-base'. This is likely not portable. A type annotation is necessary.

这是因为推断出的类型包含了一些不仅不可见、而且路径极深的第三方内部类型。

3.2 解决方案:显式接口定义

为了解决这个问题,并遵循“高内聚”的设计原则,我们放弃了隐式推断,转而定义明确的 Store 接口。

// src/store/core/permission.ts

// 1. 明确定义路由类型(解决递归类型推断问题)
export type AppRouteRecordRaw = RouteRecordRaw & {
  hidden?: boolean
  children?: AppRouteRecordRaw[]
}

// 2. 定义 Store 公开接口(Contract)
export interface PermissionStoreAPI {
  routes: Ref<AppRouteRecordRaw[]>
  generateRoutesFromMenu: (menuList: MenuItem[]) => MenuItem[]
  restoreRoutes: () => boolean
}

// 3. 在 defineStore 中显式应用接口
export const usePermissionStore = defineStore('permission', (): PermissionStoreAPI => {
  const routes = ref<AppRouteRecordRaw[]>([])
  
  function generateRoutesFromMenu(menuList: MenuItem[]) {
    // ... 具体的业务逻辑
    return []
  }

  function restoreRoutes() {
    // ... 恢复逻辑
    return true
  }

  return {
    routes,
    generateRoutesFromMenu,
    restoreRoutes,
  }
})

这种做法虽然多写了几行代码,但带来了显著的收益:

  • 类型稳定:切断了对第三方私有类型的依赖。
  • 文档化PermissionStoreAPI 接口本身就是最好的文档,开发者一眼就能看出这个 Store 提供了哪些能力。

四、 路由层的“软拦截”策略

在路由层面,我们放弃了传统的“硬拦截”(即检测到无权限直接 next(false) 或重定向),转而采用“软拦截”策略。

4.1 为什么要软拦截?

在内测阶段,如果用户通过 URL 访问了一个尚未在菜单配置的页面,硬拦截会直接导致 404 或死循环。而软拦截允许页面加载,但通过后端 API 的 403 响应来驱动 UI 展示。

4.2 实现方式

// src/router/index.ts

router.beforeEach(async (to, from, next) => {
  // 1. 启动进度条,提升感知
  nprogressManager.start()

  // 2. 核心:不在此处做复杂的权限比对
  // 我们信任后端数据安全,这里只做基础的登录态检查
  // 如果用户已登录但无权限,让他进入页面,看到“无数据”或“无权限”的空状态组件
  
  // 3. 触发验证中心(异步,不阻塞路由跳转)
  VerificationCenter.run('routeChange')

  next()
})

这种策略将“权限不足”的处理权交还给了页面组件(配合 <el-empty description="无权访问" />),既保证了系统的鲁棒性,又提升了用户体验。


五、 总结与思考

这次重构不仅仅是代码层面的修改,更是技术价值观的校准。

  1. 分层治理:后端守住安全底线,前端负责交互上限。
  2. 体验优先:权限控制的目的是“引导用户”,而不是“防御用户”。
  3. 极简主义:用 20% 的代码解决 80% 的核心体验问题,剩下的 20% 极端场景交给后端兜底。

通过这套架构,我们将原本需要 30 人天的庞大工程,精简为 5 人天的高效迭代,同时彻底解决了 F5 刷新、类型推断错误等技术债。这或许才是架构设计的魅力所在:在约束中寻找最优解。

企业级 Vue 3 项目图标系统重构实践:从多源混乱到单一数据源

2025年12月12日 18:25

日期: 2025-12-12
技术栈: Vue 3 + TypeScript + iconfont

前言

在大型前端项目中,图标管理是一个看似简单却容易失控的问题。随着业务迭代,往往会出现多套图标方案并存的情况:有人用 SVG 文件,有人用 iconfont,有人直接用图片……这不仅增加了维护成本,还容易导致图标风格不统一、打包体积膨胀等问题。

本文将分享我们在 CMC Link IBS Web 项目中进行的一次图标系统重构实践,核心目标是:统一图标来源,降低维护成本,提升开发体验

一、问题诊断:混乱的图标现状

1.1 现状分析

重构前,项目中存在两套并行的图标方案:

图标来源
├── iconfont(阿里图标库)
│   ├── iconfont.cssFont Class 模式
│   └── iconfont.js      → Symbol 模式(支持彩色)
│
└── 本地 SVG 图标
    └── src/assets/icons/ → 150+ 个 SVG 文件
        └── vite-plugin-svg-spritemap 处理

1.2 痛点总结

问题 影响
双重维护 新增图标需要决定放哪里,老员工用 SVG,新员工用 iconfont
处理逻辑复杂 SVG 需要 SVGO 插件处理 fill/stroke/width/height 属性
彩色图标识别困难 需要通过文件名约定(c- 前缀)或内容分析来判断
构建依赖 额外引入 @spiriit/vite-plugin-svg-spritemap 依赖
心智负担 开发者需要了解两套方案的差异和适用场景

1.3 核心矛盾

开发效率 vs 技术债务
    ↓
每次新增图标都在累积技术债
    ↓
维护成本随项目规模线性增长

二、方案设计:单一数据源架构

2.1 设计原则

  1. 单一数据源:所有图标统一从 iconfont 获取
  2. 向后兼容:现有代码无需修改即可工作
  3. 渐进迁移:支持新旧写法并存,逐步过渡
  4. 开发体验优先:新增图标流程简化

2.2 架构设计

┌─────────────────────────────────────────────────┐
│                  使用层                          │
│  <SvgIcon name="search" />  (旧代码,无需修改)    │
│  <CmcIcon name="icon-search" /> (新代码)         │
└──────────────────┬──────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────┐
│              SvgIcon (兼容层)                    │
│  - 接收旧的 name/icon 属性                       │
│  - 通过映射表转换为 iconfont 名称                 │
│  - 自动判断彩色/单色                             │
│  - 内部渲染 CmcIcon                             │
└──────────────────┬──────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────┐
│              CmcIcon (核心组件)                  │
│  - Font Class 模式(单色,可改颜色)              │
│  - Symbol 模式(彩色,保留原色)                  │
└──────────────────┬──────────────────────────────┘
                   │
┌──────────────────▼──────────────────────────────┐
│           iconfont 资源 (单一数据源)             │
│  - iconfont.css (Font Class)                    │
│  - iconfont.js (Symbol)                         │
└─────────────────────────────────────────────────┘

2.3 关键设计决策

决策1:为什么选择 iconfont 作为单一数据源?

方案 优势 劣势
本地 SVG 完全可控、离线可用 需要构建处理、维护成本高
iconfont 在线管理、团队协作、支持彩色 依赖外部服务

选择 iconfont 的原因:

  • 已有成熟的图标库(400+ 图标)
  • 支持 Symbol 模式(彩色图标)
  • 团队协作友好(设计师可直接上传)
  • 无需额外构建插件

决策2:兼容层设计

不破坏现有代码是重构的底线。通过代理模式,让旧的 SvgIcon 组件内部调用新的 CmcIcon

<!-- SvgIcon.vue - 兼容层 -->
<script setup lang="ts">
import CmcIcon from '../CmcIcon/CmcIcon.vue'
import { getIconfontName, isColorfulIcon } from '../CmcIcon/icon-mapping'

// ... props 定义

const iconfontName = computed(() => getIconfontName(rawIconName.value))
const colorful = computed(() => isColorfulIcon(rawIconName.value))
</script>

<template>
  <CmcIcon
    :name="iconfontName"
    :size="size"
    :color="color"
    :colorful="colorful"
  />
</template>

决策3:映射表策略

对于名称不一致的情况,通过映射表解决:

// icon-mapping.ts
export const SVG_TO_ICONFONT_MAP: Record<string, string> = {
  'dingcangicon': 'icon-menu-dingcang',
  'billoflading': 'icon-menu-tidan',
  // ...
}

export const COLORFUL_ICONS = new Set([
  'menu-chukou',
  'menu-jinkou',
  'USD', 'CNY', 'EUR',
  // ...
])

三、实现细节

3.1 CmcIcon 核心组件

<script lang="ts" setup>
interface Props {
  name: string           // 图标名称(需带 icon- 前缀)
  size?: number | string // 尺寸,默认 16px
  color?: string         // 颜色(仅单色有效)
  colorful?: boolean     // 是否为彩色图标
}

const props = withDefaults(defineProps<Props>(), {
  size: 16,
  color: 'currentColor',
  colorful: false,
})
</script>

<template>
  <!-- 彩色图标:Symbol 模式 -->
  <svg v-if="colorful" class="cmc-icon" :style="{ width: sizeValue, height: sizeValue }">
    <use :xlink:href="`#${iconName}`" />
  </svg>

  <!-- 单色图标:Font Class 模式 -->
  <i v-else class="cmc-icon iconfont-cmc" :class="iconName" :style="{ fontSize: sizeValue, color }" />
</template>

3.2 iconfont 的两种模式

Font Class 模式(单色图标):

  • 通过 CSS 类名引用图标
  • 支持 color 属性动态改变颜色
  • 文件:iconfont.css

Symbol 模式(彩色图标):

  • 通过 SVG <use> 引用
  • 保留图标原始颜色
  • 文件:iconfont.js
<!-- 单色:可通过 color 控制颜色 -->
<CmcIcon name="icon-search" color="red" />

<!-- 彩色:保留原始多色 -->
<CmcIcon name="icon-menu-chukou" colorful />

3.3 清理冗余代码

移除了不再需要的构建配置:

// build/plugins.ts
- import VitePluginSVGSpritemap from '@spiriit/vite-plugin-svg-spritemap'

export function createVitePlugins() {
  return [
    // ...其他插件
-   createSvgIconsPlugin(),  // 移除 SVG 处理插件
  ]
}

- // 移除 138 行 SVG 处理代码
- function createSvgIconsPlugin() { ... }
- function processMonoIcon() { ... }
- function processRootIcon() { ... }
- function traverseSvgNodes() { ... }

四、收益分析

4.1 量化收益

指标 重构前 重构后 变化
图标来源 2 套 1 套 -50%
构建依赖 +1 0 -100%
plugins.ts 代码行数 241 103 -57%
新增图标步骤 5 步 3 步 -40%

4.2 定性收益

  1. 降低心智负担:开发者只需了解一套方案
  2. 简化新增流程:上传 iconfont → 更新资源 → 使用
  3. 减少构建时间:移除 SVGO 处理环节
  4. 代码更简洁:核心组件 < 100 行

4.3 新增图标流程对比

重构前(SVG 方案):

  1. 获取 SVG 文件
  2. 判断是单色还是彩色
  3. 如果单色,手动处理 fill/stroke 属性
  4. 放入对应目录(mono/ 或 colorful/)
  5. 使用 <SvgIcon name="xxx" />

重构后(iconfont 方案):

  1. 上传到 iconfont 项目
  2. 下载更新资源文件
  3. 使用 <CmcIcon name="icon-xxx" />

五、经验总结

5.1 重构原则

  1. 向后兼容是底线:通过兼容层保证现有代码正常工作
  2. 渐进式迁移:新代码用新方案,旧代码按需迁移
  3. 单一数据源:避免多源并存的混乱
  4. 简化优于完美:够用就好,不过度设计

5.2 技术选型思考

选择 iconfont 而非自建 SVG 方案的核心原因:

  • 团队协作:设计师可直接在 iconfont 管理图标
  • 成本效益:利用现有成熟方案,避免重复造轮子
  • 彩色支持:Symbol 模式原生支持多色图标

5.3 适用场景

本方案适合:

  • 已在使用 iconfont 的项目
  • 团队规模中等以上,需要设计师协作
  • 图标更新频繁的业务系统

不太适合:

  • 对离线可用性要求极高的场景
  • 图标需要复杂动画的场景
  • 完全私有化部署、无法访问外网的环境

六、后续优化方向

  1. 自动化更新:编写脚本自动从 iconfont 拉取最新资源
  2. 类型安全:生成图标名称的 TypeScript 类型定义
  3. 按需加载:对于大型图标库,考虑按需加载策略
  4. 文档自动化:从 iconfont.json 自动生成图标文档

结语

图标系统看似是个小问题,但在大型项目中却能显著影响开发效率和代码质量。这次重构的核心思路是:识别技术债务 → 设计兼容方案 → 统一数据源 → 渐进式迁移

希望本文的实践经验能为你的项目提供一些参考。记住,最好的架构不是最复杂的,而是最适合团队的。


本文基于公司项目的真实重构实践整理,如有问题欢迎讨论。

Vue 3 统一面包屑导航系统:从配置地狱到单一数据源

2025年12月11日 17:13

本文分享我们在 Vue 3 + TypeScript 项目中重构面包屑导航系统的实践经验,通过将面包屑配置迁移到路由 meta 中,实现了配置的单一数据源,大幅降低了维护成本。

一、问题背景

1.1 原有架构的痛点

在重构之前,我们的面包屑系统采用独立的配置文件 breadcrumb.ts,存在以下问题:

// 旧方案:独立的面包屑配置文件(700+ 行)
export const breadcrumbConfigs: BreadcrumbItemConfig[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    title: '订舱管理',
    showInBreadcrumb: true,
    children: [
      { path: '/export/booking/create', name: 'BookingCreate', title: '新建订舱' },
      { path: '/export/booking/edit', name: 'BookingEdit', title: '编辑订舱' },
      // ... 更多子页面
    ],
  },
  // ... 几十个类似的配置
]

主要痛点:

  1. 配置分散:路由定义在 router/modules/*.ts,面包屑配置在 config/breadcrumb.ts,新增页面需要修改两处
  2. 维护成本高:配置文件超过 700 行,嵌套结构复杂,容易出错
  3. 同步困难:路由变更后容易忘记更新面包屑配置,导致显示异常
  4. 类型安全差:配置与路由之间缺乏类型关联

1.2 期望目标

  • 单一数据源:面包屑配置与路由定义合并,一处修改全局生效
  • 类型安全:利用 TypeScript 确保配置正确性
  • 易于维护:新增页面只需在路由配置中添加一行
  • 向后兼容:平滑迁移,不影响现有功能

二、技术方案

2.1 核心思路

将面包屑路径配置到路由的 meta 字段中,通过 Composable 自动解析生成面包屑导航。

路由配置 (meta.breadcrumb) → useBreadcrumb() → BreadCrumb.vue

2.2 扩展路由 Meta 类型

首先,扩展 Vue Router 的 RouteMeta 接口:

// src/router/types.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    /** 页面标题 */
    title?: string
    /** 国际化 key */
    i18nKey?: string
    /** 面包屑路径(路由名称数组) */
    breadcrumb?: string[]
    /** 是否缓存 */
    keepAlive?: boolean
    // ... 其他字段
  }
}

/** 面包屑项类型 */
export interface BreadcrumbItem {
  title: string
  path: string
  name: string
  i18nKey?: string
  isClickable: boolean
}

2.3 路由配置示例

在路由模块中添加 breadcrumb meta:

// src/router/modules/export.ts
export const exportRoutes: RouteRecordRaw[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    component: () => import('~/views/export/booking/index.vue'),
    meta: {
      title: '订舱管理',
      keepAlive: true,
      breadcrumb: ['Export', 'BookingManage'], // 出口 > 订舱管理
    },
  },
  {
    path: '/export/booking/create/:mode',
    name: 'BookingCreate',
    component: () => import('~/views/export/booking/create.vue'),
    meta: {
      title: '新建订舱',
      breadcrumb: ['Export', 'BookingManage', 'BookingCreate'], // 出口 > 订舱管理 > 新建订舱
    },
  },
]

配置规则:

  • 数组元素为路由名称(name)或虚拟节点名称
  • 按层级顺序排列:[一级菜单, 二级菜单, 当前页面]
  • 空数组 [] 表示不显示面包屑(如首页)

2.4 useBreadcrumb Composable

核心逻辑封装在 Composable 中:

// src/composables/useBreadcrumb.ts
import type { BreadcrumbItem } from '~/router/types'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'

/**
 * 虚拟路由配置(菜单分类节点)
 * 这些节点在路由系统中不存在,但需要在面包屑中显示
 */
const VIRTUAL_ROUTES: Record<string, { title: string, i18nKey: string }> = {
  Mine: { title: '我的', i18nKey: 'mine' },
  Export: { title: '出口', i18nKey: 'export' },
  Import: { title: '进口', i18nKey: 'import' },
  Finance: { title: '财务', i18nKey: 'finance' },
  BoxManage: { title: '箱管', i18nKey: 'boxManage' },
}

export function useBreadcrumb() {
  const route = useRoute()
  const router = useRouter()
  const { t } = useI18n()

  /** 根据路由名称获取路由信息 */
  function getRouteByName(name: string) {
    return router.getRoutes().find(r => r.name === name)
  }

  /** 获取面包屑项的标题(支持国际化) */
  function getTitle(name: string, routeRecord?: RouteRecordNormalized): string {
    // 优先使用虚拟路由配置
    if (VIRTUAL_ROUTES[name]) {
      return t(`system.routes.${VIRTUAL_ROUTES[name].i18nKey}`, VIRTUAL_ROUTES[name].title)
    }
    // 使用路由 meta 配置
    if (routeRecord?.meta?.i18nKey) {
      return t(`system.routes.${routeRecord.meta.i18nKey}`, routeRecord.meta.title || name)
    }
    return routeRecord?.meta?.title || name
  }

  /** 计算面包屑列表 */
  const breadcrumbs = computed<BreadcrumbItem[]>(() => {
    const routeName = route.name as string
    if (!routeName) return []

    // 从路由 meta 获取面包屑配置
    const breadcrumbPath = route.meta?.breadcrumb as string[]
    if (!breadcrumbPath || breadcrumbPath.length === 0) {
      return []
    }

    // 构建面包屑列表
    return breadcrumbPath.map((name, index) => {
      const routeRecord = getRouteByName(name)
      const isLast = index === breadcrumbPath.length - 1
      const isVirtual = !!VIRTUAL_ROUTES[name]

      return {
        title: getTitle(name, routeRecord),
        path: isLast ? route.path : (routeRecord?.path || ''),
        name,
        i18nKey: isVirtual ? VIRTUAL_ROUTES[name].i18nKey : routeRecord?.meta?.i18nKey,
        isClickable: !isLast && !isVirtual && !!routeRecord,
      }
    })
  })

  /** 是否应该显示面包屑 */
  const shouldShow = computed<boolean>(() => {
    // 首页、登录页等不显示面包屑
    const hiddenPaths = ['/', '/index', '/dashboard', '/login', '/register']
    if (hiddenPaths.includes(route.path)) {
      return false
    }
    return breadcrumbs.value.length > 0
  })

  return { breadcrumbs, shouldShow }
}

2.5 面包屑组件

组件只需调用 Composable 即可:

<!-- src/layout/components/BreadCrumb/BreadCrumb.vue -->
<script setup lang="ts">
import { useBreadcrumb } from '~/composables'

const { breadcrumbs, shouldShow } = useBreadcrumb()
</script>

<template>
  <el-breadcrumb v-if="shouldShow" separator="/">
    <el-breadcrumb-item
      v-for="item in breadcrumbs"
      :key="item.name"
      :to="item.isClickable ? item.path : undefined"
    >
      {{ item.title }}
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

三、迁移策略

3.1 渐进式迁移

为了确保平滑过渡,我们采用渐进式迁移策略:

  1. 阶段一:新增 useBreadcrumb Composable,支持从路由 meta 读取配置
  2. 阶段二:逐个模块添加 breadcrumb meta 字段
  3. 阶段三:验证所有页面面包屑正常后,删除旧配置文件

3.2 迁移清单

模块 文件 页面数
核心页面 core.ts 4
用户管理 user.ts 3
查询服务 search_service.ts 6
出口业务 export.ts 25+
进口业务 import.ts 4
财务结算 payment_settlement.ts 6
箱管业务 equipment-control.ts 12

3.3 国际化配置

确保所有菜单分类节点都有对应的国际化配置:

// src/i18n/zh/system.ts
export default {
  routes: {
    // 菜单分类节点
    mine: '我的',
    export: '出口',
    import: '进口',
    finance: '财务',
    boxManage: '箱管',
    
    // 具体页面
    bookingManage: '订舱管理',
    bookingCreate: '新建订舱',
    // ...
  },
}

四、效果对比

4.1 代码量对比

指标 重构前 重构后 变化
配置文件行数 737 行 0 行(已删除) -100%
新增页面修改文件数 2 个 1 个 -50%
类型安全

4.2 新增页面对比

重构前:

// 1. 修改路由配置
{ path: '/new-page', name: 'NewPage', component: ... }

// 2. 修改面包屑配置(容易遗漏!)
{ path: '/new-page', name: 'NewPage', title: '新页面', ... }

重构后:

// 只需修改路由配置
{
  path: '/new-page',
  name: 'NewPage',
  component: ...,
  meta: {
    title: '新页面',
    breadcrumb: ['ParentMenu', 'NewPage'],
  },
}

五、最佳实践

5.1 面包屑配置规范

// ✅ 推荐:使用路由名称数组
breadcrumb: ['Export', 'BookingManage', 'BookingCreate']

// ❌ 避免:使用路径
breadcrumb: ['/export', '/export/booking', '/export/booking/create']

5.2 虚拟节点使用场景

当菜单分类本身不是一个可访问的页面时,使用虚拟节点:

// "出口" 是菜单分类,不是实际页面
const VIRTUAL_ROUTES = {
  Export: { title: '出口', i18nKey: 'export' },
}

// 路由配置
breadcrumb: ['Export', 'BookingManage'] // 出口 > 订舱管理

5.3 动态路由处理

对于带参数的动态路由,Composable 会自动使用当前路由的完整路径:

// 路由定义
{ path: '/export/lading/edit/:id', name: 'BookingLadingEdit', ... }

// 面包屑配置
breadcrumb: ['Export', 'BookingLadingManagement', 'BookingLadingEdit']

// 实际显示:出口 > 提单管理 > 编辑提单
// 最后一项路径:/export/lading/edit/123(保留实际 ID)

六、总结

通过将面包屑配置迁移到路由 meta 中,我们实现了:

  1. 单一数据源:路由配置即面包屑配置,消除了配置分散的问题
  2. 维护成本降低:删除了 700+ 行的独立配置文件
  3. 开发效率提升:新增页面只需修改一处
  4. 类型安全增强:TypeScript 类型检查确保配置正确性
  5. 国际化支持:无缝集成 vue-i18n

这种方案特别适合中大型 Vue 3 项目,尤其是菜单结构复杂、页面数量多的企业级应用。


相关技术栈:

  • Vue 3.5+ (Composition API)
  • Vue Router 4
  • TypeScript 5+
  • vue-i18n

参考资料:

前端基础数据中心:从混乱到统一的架构演进

2025年12月9日 17:41

本文记录了我们团队在 Vue 3 + TypeScript 项目中,如何将散乱的基础数据管理逻辑重构为统一的「基础数据中心」。如果你的项目也有类似的痛点,希望这篇文章能给你一些参考。

一、问题是怎么来的

做过 B 端系统的同学应该都有体会——基础数据无处不在。港口、船舶、航线、货币、字典……这些数据在几乎每个页面都会用到,要么是下拉选择,要么是代码翻译,要么是表格筛选。

我们项目一开始的做法很「朴素」:哪里用到就哪里请求。后来发现这样不行,同一个港口列表接口一个页面能请求三四次。于是开始加缓存,问题是加着加着,代码变成了这样:

store/basicData/cache.ts      <- Pinia 实现的缓存
composables/basicData/cache.ts  <- VueUse + localStorage 实现的缓存
store/port.ts                   <- 独立的港口缓存(历史遗留)

三套缓存系统,各自为政。更要命的是 CACHE_KEYS 这个常量在两个地方都有定义,改一处忘一处是常态。

某天排查一个 bug:用户反馈页面显示的港口名称和实际不一致。查了半天发现是两套缓存系统的数据版本不同步——A 组件用的 Pinia 缓存已经过期刷新了,B 组件用的 localStorage 缓存还是旧数据。

是时候重构了。

二、想清楚再动手

重构之前,我们先梳理了需求优先级:

需求 优先级 说明
跨组件数据共享 P0 同一份数据,全局只请求一次
缓存 + 过期机制 P0 减少请求,但数据要能自动刷新
请求去重 P1 并发请求同一接口时,只发一次
持久化 P1 关键数据存 localStorage,提升首屏速度
DevTools 调试 P2 能在 Vue DevTools 里看到缓存状态

基于这些需求,我们确定了架构原则:

Store 管状态,Composable 封业务,Component 只消费。

三、分层架构设计

最终的架构分三层:

┌─────────────────────────────────────────────────┐
│               Component Layer                    │
│              (Vue 组件/页面)                     │
│  只使用 Composables,不直接访问 Store            │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│              Composable Layer                    │
│           (composables/basicData/)              │
│  usePorts / useVessels / useDict / ...          │
│  封装 Store,提供业务友好的 API                  │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│                Store Layer                       │
│             (store/basicData/)                  │
│  useBasicDataStore                              │
│  统一缓存、加载状态、请求去重、持久化            │
└─────────────────────────────────────────────────┘

为什么要分这么多层?

  • Store 层:单一数据源,解决「数据从哪来」的问题
  • Composable 层:业务封装,解决「数据怎么用」的问题
  • Component 层:纯消费,只关心「界面怎么展示」

这样分层之后,职责边界就清晰了。组件开发者不用关心缓存策略,只管调 usePorts() 拿数据就行。

四、核心实现

4.1 Store 层:请求去重是关键

Store 层最核心的逻辑是 loadData 方法。这里要处理三种情况:

  1. 缓存命中 → 直接返回
  2. 有相同请求正在进行 → 复用已有 Promise
  3. 发起新请求 → 请求完成后写入缓存
// store/basicData/useBasicData.ts
export const useBasicDataStore = defineStore('basic-data', () => {
  const cacheMap = ref<Map<BasicDataType, CacheEntry>>(new Map())
  const pendingRequests = new Map<BasicDataType, Promise<unknown>>()

  async function loadData<T>(
    type: BasicDataType,
    fetcher: () => Promise<T>,
    config?: CacheConfig
  ): Promise<T | null> {
    // 1. 缓存命中
    const cached = getCache<T>(type)
    if (cached !== null) return cached

    // 2. 请求去重——这是关键
    const pending = pendingRequests.get(type)
    if (pending) return pending as Promise<T | null>

    // 3. 发起新请求
    const request = (async () => {
      try {
        const data = await fetcher()
        setCache(type, data, config)
        return data
      } finally {
        pendingRequests.delete(type)
      }
    })()

    pendingRequests.set(type, request)
    return request
  }

  return { loadData, getCache, setCache, clearCache }
})

请求去重的实现很简单:用一个 Map 存储正在进行的 Promise。当第二个请求进来时,直接返回已有的 Promise,不发新请求。

这样即使页面上 10 个组件同时调用 usePorts(),实际 API 请求也只有 1 次。

4.2 Composable 层:工厂函数批量生成

港口、船舶、航线……这些 Composable 的逻辑高度相似,用工厂函数批量生成:

// composables/basicData/hooks.ts
function createBasicDataComposable<T extends BaseDataItem>(
  type: BasicDataType,
  fetcher: () => Promise<T[]>,
  config?: CacheConfig
) {
  return () => {
    const store = useBasicDataStore()

    // 响应式数据
    const data = computed(() => store.getCache<T[]>(type) || [])
    const loading = computed(() => store.getLoadingState(type).loading)
    const isReady = computed(() => data.value.length > 0)

    // 自动加载
    store.loadData(type, fetcher, config)

    // 业务方法
    const getByCode = (code: string) => 
      data.value.find(item => item.code === code)

    const options = computed(() => 
      data.value.map(item => ({
        label: item.nameCn,
        value: item.code
      }))
    )

    return { data, loading, isReady, getByCode, options, refresh }
  }
}

// 一行代码定义一个 Composable
export const usePorts = createBasicDataComposable('ports', fetchPorts, { ttl: 15 * 60 * 1000 })
export const useVessels = createBasicDataComposable('vessels', fetchVessels, { ttl: 15 * 60 * 1000 })
export const useLanes = createBasicDataComposable('lanes', fetchLanes, { ttl: 30 * 60 * 1000 })

这样做的好处是:

  • 新增一种基础数据,只需加一行代码
  • 所有 Composable 的 API 完全一致,学习成本低
  • 类型安全,TypeScript 能正确推断返回类型

4.3 字典数据:特殊处理

字典数据稍微复杂一些,因为它是按类型分组的。我们单独封装了 useDict

export function useDict() {
  const store = useBasicDataStore()

  // 加载全量字典数据
  store.loadData('dict', fetchAllDict, { ttl: 30 * 60 * 1000 })

  const getDictItems = (dictType: string) => {
    const all = store.getCache<DictData>('dict') || {}
    return all[dictType] || []
  }

  const getDictLabel = (dictType: string, value: string) => {
    const items = getDictItems(dictType)
    return items.find(item => item.value === value)?.label || value
  }

  const getDictOptions = (dictType: string) => {
    return getDictItems(dictType).map(item => ({
      label: item.label,
      value: item.value
    }))
  }

  return { getDictItems, getDictLabel, getDictOptions }
}

使用起来非常直观:

<script setup>
const dict = useDict()
const cargoTypeLabel = dict.getDictLabel('CARGO_TYPE', 'FCL') // "整箱"
</script>

<template>
  <el-select>
    <el-option 
      v-for="opt in dict.getDictOptions('CARGO_TYPE')" 
      :key="opt.value" 
      v-bind="opt" 
    />
  </el-select>
</template>

五、实际使用场景

场景一:下拉选择器

最常见的场景。以前要自己请求数据、处理格式,现在一行搞定:

<script setup>
import { usePorts } from '@/composables/basicData'

const { options: portOptions, loading } = usePorts()
const selectedPort = ref('')
</script>

<template>
  <el-select v-model="selectedPort" :loading="loading" filterable>
    <el-option v-for="opt in portOptions" :key="opt.value" v-bind="opt" />
  </el-select>
</template>

场景二:表格中的代码翻译

订单列表里显示港口代码,用户看不懂,要翻译成中文:

<script setup>
import { usePorts } from '@/composables/basicData'

const { getByCode } = usePorts()

// 翻译函数
const translatePort = (code: string) => getByCode(code)?.nameCn || code
</script>

<template>
  <el-table :data="orderList">
    <el-table-column prop="polCode" label="起运港">
      <template #default="{ row }">
        {{ translatePort(row.polCode) }}
      </template>
    </el-table-column>
  </el-table>
</template>

场景三:字典标签渲染

状态、类型这类字段,通常要显示成带颜色的标签:

<script setup>
import { useDict } from '@/composables/basicData'

const dict = useDict()
</script>

<template>
  <el-tag :type="dict.getDictColorType('ORDER_STATUS', row.status)">
    {{ dict.getDictLabel('ORDER_STATUS', row.status) }}
  </el-tag>
</template>

场景四:数据刷新

用户修改了基础数据,需要刷新缓存:

import { usePorts, clearAllCache } from '@/composables/basicData'

const { refresh: refreshPorts } = usePorts()

// 刷新单个
await refreshPorts()

// 刷新全部
clearAllCache()

六、缓存策略

不同数据的变化频率不同,缓存策略也不一样:

数据类型 TTL 持久化 原因
国家/货币 1 小时 几乎不变
港口/码头 15-30 分钟 偶尔变化
船舶 15 分钟 数据量大(10万+),不适合 localStorage
航线/堆场 30 分钟 相对稳定
字典 30 分钟 偶尔变化

持久化用的是 localStorage,配合 TTL 一起使用。数据写入时记录时间戳,读取时检查是否过期。

船舶数据量太大,存 localStorage 会导致写入超时,所以不做持久化,每次刷新页面重新请求。

七、调试支持

用 Pinia 还有一个好处:Vue DevTools 原生支持。

打开 DevTools,切到 Pinia 面板,能看到:

  • 当前缓存了哪些数据
  • 每种数据的加载状态
  • 数据的具体内容

排查问题时非常方便。

另外我们还提供了 getCacheInfo() 方法,可以在控制台查看缓存统计:

import { getCacheInfo } from '@/composables/basicData'

console.log(getCacheInfo())
// {
//   ports: { cached: true, size: 102400, remainingTime: 600000 },
//   vessels: { cached: false, size: 0, remainingTime: 0 },
//   ...
// }

八、踩过的坑

坑 1:响应式丢失

一开始我们这样写:

// ❌ 错误写法
const { data } = usePorts()
const portList = data.value // 丢失响应式!

datacomputed,取 .value 之后就变成普通值了,后续数据更新不会触发视图刷新。

正确做法是保持响应式引用:

// ✅ 正确写法
const { data: portList } = usePorts()
// 或者
const portList = computed(() => usePorts().data.value)

坑 2:循环依赖

Store 和 Composable 互相引用导致循环依赖。解决办法是严格遵守分层原则:Composable 可以引用 Store,Store 不能引用 Composable。

坑 3:SSR 兼容

localStorage 在服务端不存在。如果你的项目需要 SSR,持久化逻辑要加判断:

const storage = typeof window !== 'undefined' ? localStorage : null

九、总结

重构前后的对比:

维度 重构前 重构后
缓存系统 3 套并存 1 套统一
代码复用 到处复制粘贴 工厂函数批量生成
请求优化 无去重,重复请求 自动去重
调试 只能打 log DevTools 原生支持
类型安全 部分 any 完整类型推断

核心收益:

  1. 开发效率提升:新增基础数据类型从半天缩短到 10 分钟
  2. Bug 减少:数据不一致问题基本消失
  3. 性能优化:重复请求减少 60%+

如果你的项目也有类似的基础数据管理问题,可以参考这个思路。关键是想清楚分层,把「状态管理」和「业务封装」分开,剩下的就是体力活了。


本文基于实际项目经验整理,代码已做脱敏处理。欢迎讨论交流。

Pinia Store 平滑迁移:用代理模式实现零风险重构

2025年12月8日 13:50

重构遗留代码最怕什么?改一处崩十处。这篇文章分享一个我在实际项目中用过的方案:用代理模式实现 Pinia Store 的平滑迁移,让几十处旧代码无感升级。

背景:为什么要迁移

项目里有个 useUserStore,最早是用 Options API 写的,随着业务迭代,问题越来越多:

  • 类型定义不完整,到处是 any
  • 命名不规范,setUserInfoActionloginOut 这种命名看着难受
  • 状态结构和后端返回不一致,前端加了很多 hack
  • 没有按业务域组织,所有 Store 都堆在根目录

想重构成 Setup 风格,顺便理清类型和命名。但问题来了:这个 Store 被几十个文件引用,直接改导入路径?风险太大。

方案:代理模式 + 渐进式迁移

核心思路很简单:不动旧路径,让旧文件变成代理

旧导入路径                        新 Store
src/store/user.ts  ───代理───►  src/store/core/user.ts
       ▲                               │
       │                               │
   几十处业务代码                     唯一数据源

这样做的好处:

  • ✅ 旧代码一行不改,继续用 ~/store/user 导入
  • ✅ 新代码直接用 ~/store/core 导入
  • ✅ 数据源唯一,不会出现状态不同步
  • ✅ 可以慢慢把旧代码迁移到新路径

实现步骤

Step 1:先写新的 Store

src/store/core/user.ts 创建新的 Setup 风格 Store:

// src/store/core/user.ts
export const useUserStore = defineStore('user', () => {
  // ==================== State ====================
  const userInfo = ref<UserInfo>(getDefaultUserInfo())
  const permissions = ref<Permission[]>([])
  const locale = ref<SupportedLanguage>('zh')
  const isRouterInitialized = ref(false)

  // ==================== Getters ====================
  const isLoggedIn = computed(() => !!userInfo.value.id)
  const nickname = computed(() => userInfo.value.realName || '')

  // ==================== Actions ====================
  async function loadUserInfo(): Promise<void> {
    const res = await userApi.getPermissionsInfo()
    if (res.data) {
      userInfo.value = { ...getDefaultUserInfo(), ...res.data.user }
      permissions.value = res.data.permissions || []
    }
  }

  async function logout(): Promise<void> {
    userInfo.value = getDefaultUserInfo()
    permissions.value = []
    // ... 清理逻辑
  }

  return {
    userInfo, permissions, locale, isRouterInitialized,
    isLoggedIn, nickname,
    loadUserInfo, logout,
  }
})

类型清晰,命名规范,舒服。

Step 2:把旧文件改成代理

重点来了。把原来的 src/store/user.ts 改成代理层:

// src/store/user.ts - 变成代理层
import type { Pinia } from 'pinia'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useUserStore as useCoreUserStore } from './core/user'

/**
 * @deprecated 建议迁移到 useUserStore from '~/store/core'
 */
export function useUserStore(_pinia?: Pinia) {
  // 转发到新 Store
  const coreStore = useCoreUserStore()

  const { userInfo, permissions, locale, isRouterInitialized } = storeToRefs(coreStore)

  // 兼容旧的 getter 命名
  const getLocale = computed(() => locale.value)
  const getPermissions = computed(() => permissions.value)

  // 兼容旧的 action 命名
  async function setUserInfoAction() {
    await coreStore.loadUserInfo()  // 转发
  }

  async function loginOut() {
    await coreStore.logout()  // 转发
  }

  // 兼容 userStore.user.xxx 的直接访问方式
  const userProxy = {
    get user() { return userInfo.value }
  }

  return {
    userInfo, permissions, locale,
    ...userProxy,  // 支持 store.user.readAll 这种访问
    // 旧命名(兼容)
    getLocale, getPermissions,
    setUserInfoAction, loginOut,
    // 新命名(推荐)
    loadUserInfo: coreStore.loadUserInfo,
    logout: coreStore.logout,
  }
}

几个关键点:

1. 接受可选的 pinia 参数

旧代码可能写成 useUserStore(store),新的代理层要兼容这种写法,虽然参数实际不用。

2. 用 getter 代理 user 属性

旧代码直接 userStore.user.readAll 访问,不是 userStore.user.value.readAll。用 getter 可以实现这种"直接访问"的效果:

const userProxy = {
  get user() { return userInfo.value }
}
return { ...userProxy }

3. 新旧命名都暴露

让业务代码可以渐进式迁移,setUserInfoActionloadUserInfo 同时可用。

Step 3:加上 @deprecated 标记

给代理层加上 JSDoc 的 @deprecated 标记,IDE 会给出提示,方便后续清理:

/** @deprecated 使用 coreStore.loadUserInfo 代替 */
async function setUserInfoAction() {
  await coreStore.loadUserInfo()
}

测试验证

迁移最怕的是"看起来没问题,上线才出事"。这里给一套验证方案。

快速冒烟测试

在浏览器控制台跑一下:

// 旧路径
import { useUserStore } from '~/store/user'
// 新路径
import { useUserStore as useCoreUserStore } from '~/store/core'

const oldStore = useUserStore()
const newStore = useCoreUserStore()

// 验证数据源唯一
console.log('引用相同:', oldStore.userInfo === newStore.userInfo)  // true

// 验证状态同步
newStore.setLocale('en')
console.log('状态同步:', oldStore.locale.value === 'en')  // true

关键路径验证

场景 操作 预期
登录 正常登录 用户名正确显示
权限 访问受限页面 权限判断正常
语言 切换中英文 全局切换,刷新后保持
登出 点击登出 状态清除,跳转登录页
刷新 F5 刷新页面 状态正确恢复

单元测试

写一个兼容性测试,确保新旧 API 行为一致:

describe('User Store 兼容性', () => {
  it('新旧 Store 应指向同一数据源', () => {
    const oldStore = useUserStore()
    const coreStore = useCoreUserStore()
    expect(oldStore.userInfo).toBe(coreStore.userInfo)
  })

  it('setUserInfoAction 应等价于 loadUserInfo', async () => {
    const store = useUserStore()
    await store.setUserInfoAction()
    expect(store.userInfo.value.id).toBeTruthy()
  })
})

渐进式迁移

代理层搞定后,业务代码可以慢慢迁移:

// ============ 旧写法(继续可用) ============
import { useUserStore } from '~/store/user'

const store = useUserStore(pinia)
console.log(store.user.readAll)
await store.setUserInfoAction()

// ============ 新写法(推荐) ============
import { useUserStore } from '~/store/core'

const store = useUserStore()
console.log(store.userInfo.readAll)
await store.loadUserInfo()

没有 deadline 压力的话,可以每次改业务功能的时候顺手把导入路径改掉,几个月后旧路径的引用自然就清零了。


兼容点速查表

旧用法 兼容方式
useUserStore(store) 接受可选参数 _pinia?: Pinia
userStore.user.readAll 使用 getter 代理直接访问
userStore.setUserInfoAction() 转发到 loadUserInfo()
userStore.loginOut() 转发到 logout()
userStore.isSetRouters 别名到 isRouterInitialized
userStore.getLocale computed 包装 locale.value

总结

这套方案的核心就三点:

  1. 数据源唯一:新旧路径最终都指向同一个 Store 实例
  2. API 兼容:代理层转发所有旧的方法调用
  3. 渐进迁移:新旧写法并存,没有硬性切换时间点

适用场景:

  • Store 被大量文件引用,不敢直接改路径
  • 想重构但又怕出问题
  • 团队习惯渐进式改进,不喜欢大爆炸式重构

不适用场景:

  • Store 只有几处引用,直接全局替换更快
  • 重构涉及 Store ID 变更(会影响持久化)

希望这个方案对你有帮助。有问题欢迎讨论 👋

我是如何治理一个混乱的 Pinia 状态管理系统的

2025年12月8日 12:20

最近接手了一个 Vue 3 + TypeScript 的中大型项目,状态管理这块…怎么说呢,一言难尽。花了两周时间做了一次系统性的治理,踩了不少坑,也总结出一些经验,分享给同样在"屎山"中挣扎的朋友们。

背景:接手时的状况

项目用的是 Pinia,但打开 src/store 目录的那一刻,我沉默了:

src/store/
├── index.ts
├── user.ts               # Options API 风格
├── system.ts             # Options API 风格
├── loading.ts            # 半成品
├── keepAlive.ts          # 没有类型
├── point-to-point.ts     # Setup 风格 + 啥都往里塞
├── selection.ts          # 不知道干嘛的
├── xxx-name.ts           # 好几个类似的文件
└── ...还有一堆

十几个 Store 文件扁平地堆在一起,有的用 Options API,有的用 Setup 风格,有的用 TypeScript,有的满屏 any。更离谱的是,composables 目录里也有一套"状态管理",两边功能重叠,谁也不知道该用哪个。

问题诊断:到底哪出了问题

在动手之前,我花了半天时间梳理,把问题分成了三个等级。

P0 - 不治不行

1. 代码风格精神分裂

一半 Options API,一半 Setup 风格。Options API 是 Vue 2 时代的写法,在 Vue 3 + TypeScript 项目里用这个,类型推断很难受:

// 旧代码:Options API
export const useUserStore = defineStore('user', {
  state: () => ({
    userInfo: null as any,  // 到处都是 any
    permissions: [] as any[],
  }),
  getters: {
    getLocale: state => state.locale,  // 这种 getter 毫无意义
  },
  actions: {
    setUserInfoAction(info) {  // Action 后缀是什么鬼
      this.userInfo = info
    },
  },
})

2. 类型形同虚设

as anyas any[] 满天飞,TypeScript 成了摆设。有些复杂对象完全没有类型定义,全靠 IDE 猜。

3. 职责边界模糊

有个叫 point-to-point.ts 的文件,里面塞了:表单状态、字典数据、下拉选项、选中项管理、甚至还有一些工具函数。500 多行,谁都不敢动。

P1 - 迟早要改

  • 命名风格不统一point-to-pointappSystemadmin_user,三种命名法齐活
  • 持久化策略混乱:有的用 localStorage,有的用 sessionStorage,有的根本没做持久化但数据刷新就丢
  • 缓存逻辑重复:好几个 Store 都自己实现了一套"带过期时间的缓存",代码几乎一样

P2 - 代码洁癖

  • Getter 只是简单返回 state,完全多余
  • Action 命名带 Action 后缀,不符合社区习惯
  • 注释缺失,三个月后自己都看不懂

解决方案:怎么治

一、按业务域组织目录

扁平结构最大的问题是:项目一大,找文件全靠搜索。

重构后的目录结构按业务域划分:

src/store/
├── index.ts              # 统一导出
├── core/                 # 核心域:用户、系统、加载状态
│   ├── index.ts
│   ├── user.ts
│   ├── system.ts
│   └── loading.ts
├── basicData/            # 基础数据域:缓存、字典
│   ├── index.ts
│   ├── cache.ts
│   └── dict.ts
├── search/               # 搜索域:查询表单、收藏
│   ├── index.ts
│   ├── queryForm.ts
│   └── favorite.ts
├── order/                # 订单域:表单、草稿
│   ├── index.ts
│   ├── orderForm.ts
│   └── orderDraft.ts
└── types/                # 类型定义
    ├── index.ts
    └── user.types.ts

每个域一个目录,每个目录一个 index.ts 负责导出。使用时可以按域导入,也可以从根目录导入:

// 按域导入(推荐)
import { useUserStore } from '~/store/core'

// 根目录导入
import { useUserStore } from '~/store'

二、统一 Setup 风格 + 代码分块

所有 Store 统一用 Setup 风格重写,代码按 State → Getters → Actions → Return 分块组织:

/**
 * 用户状态管理
 * @description 管理用户信息、权限、Token
 */
export const useUserStore = defineStore(
  'user',
  () => {
    // ==================== State ====================

    /** 用户信息 */
    const userInfo = ref<UserInfo | null>(null)

    /** 权限列表 */
    const permissions = ref<string[]>([])

    /** Token */
    const token = ref('')

    // ==================== Getters ====================

    /** 是否已登录 */
    const isLoggedIn = computed(() => !!token.value && !!userInfo.value)

    /** 检查是否有指定权限 */
    const hasPermission = computed(() => (code: string) =>
      permissions.value.includes(code)
    )

    // ==================== Actions ====================

    /**
     * 加载用户信息
     */
    async function loadUserInfo(): Promise<void> {
      const res = await getUserInfo()
      userInfo.value = res.data
    }

    /**
     * 登出
     */
    function logout(): void {
      userInfo.value = null
      permissions.value = []
      token.value = ''
    }

    // ==================== Return ====================

    return {
      // State
      userInfo,
      permissions,
      token,
      // Getters
      isLoggedIn,
      hasPermission,
      // Actions
      loadUserInfo,
      logout,
    }
  },
  {
    persist: {
      key: 'app-user',
      storage: localStorage,
    },
  },
)

这个结构有几个好处:

  1. 注释分块,一目了然
  2. 返回值显式列出,知道 Store 暴露了什么
  3. 类型推断完美,不需要额外声明

三、统一持久化策略

之前的持久化很随意,现在统一规则:

数据类型 存储方式 理由
用户信息、Token localStorage 需要跨标签页、持久保存
系统配置、主题 localStorage 用户偏好需要持久
表单草稿 localStorage 防止意外关闭丢失
查询条件 sessionStorage 只在当前会话有效
加载状态 不持久化 实时状态,刷新归零

四、带 TTL 的缓存 Store

基础数据(比如字典、省市区)需要缓存,但不能无限期。写了一个通用的带过期时间的缓存 Store:

export const useDictStore = defineStore('dict', () => {
  /** 缓存数据 */
  const cache = ref<Map<string, CacheItem>>(new Map())

  /** 默认 TTL:30 分钟 */
  const DEFAULT_TTL = 30 * 60 * 1000

  /**
   * 获取字典数据(自动处理缓存)
   */
  async function getDict(type: string): Promise<DictItem[]> {
    const cached = cache.value.get(type)

    // 缓存有效,直接返回
    if (cached && Date.now() - cached.timestamp < cached.ttl) {
      return cached.data
    }

    // 缓存过期或不存在,重新请求
    const res = await fetchDictByType(type)
    cache.value.set(type, {
      data: res.data,
      timestamp: Date.now(),
      ttl: DEFAULT_TTL,
    })

    return res.data
  }

  /**
   * 清除指定缓存
   */
  function clearCache(type: string): void {
    cache.value.delete(type)
  }

  return { cache, getDict, clearCache }
})

调用方完全不用关心缓存逻辑,直接 await dictStore.getDict('CONTRACT_TYPE') 就行。

五、Store 和 Composable 的分工

这是很多人纠结的问题:什么时候用 Store,什么时候用 Composable?

我的原则很简单:

场景 用 Store 用 Composable
数据需要跨组件共享
数据需要持久化
数据是全局单例
只在单个组件内使用
封装可复用的逻辑
封装副作用(定时器、事件监听)

举个例子:

// Store:管理全局订单状态
export const useOrderStore = defineStore('order', () => {
  const currentOrder = ref<Order | null>(null)
  const draftList = ref<OrderDraft[]>([])
  return { currentOrder, draftList }
})

// Composable:封装订单表单逻辑
export function useOrderForm() {
  const store = useOrderStore()
  const { t } = useI18n()

  // 表单数据(组件级,不需要共享)
  const formData = ref<OrderFormData>({})
  const loading = ref(false)

  // 表单验证规则
  const rules = computed(() => ({
    productName: [{ required: true, message: t('order.productRequired') }],
  }))

  // 提交订单
  async function submit() {
    loading.value = true
    try {
      const result = await submitOrder(formData.value)
      store.currentOrder = result  // 更新全局状态
      return result
    } finally {
      loading.value = false
    }
  }

  return { formData, loading, rules, submit }
}

Store 负责"数据仓库",Composable 负责"业务逻辑",各司其职。

迁移过程:怎么平滑过渡

不可能一口气把所有 Store 都重写,项目还要正常迭代。我采用的策略是:

1. 向后兼容导出

旧的 Store 暂时保留,新的 Store 写在域目录里,统一在 index.ts 做兼容导出:

// src/store/index.ts

// 新的域导出(推荐使用)
export * from './core'
export * from './basicData'
export * from './search'

// 向后兼容(逐步废弃)
export { useUserStore } from './core/user'  // 旧路径的使用者不会报错

2. 逐步迁移

按优先级分批迁移:

  1. Week 1-2:核心域(user、system、loading)
  2. Week 3-4:基础数据域(缓存、字典)
  3. Week 5-8:业务域(按模块逐个迁移)

每次迁移完一个模块,跑一遍 TypeScript 检查和 E2E 测试,确保没问题再继续。

3. ESLint 规则护航

加了几条 ESLint 规则,防止"新代码写成老样子":

// eslint.config.js
{
  files: ['src/store/**/*.ts'],
  rules: {
    // 禁止在 Store 中使用 any
    '@typescript-eslint/no-explicit-any': 'error',
    // 强制导入排序
    'perfectionist/sort-imports': 'error',
  },
}

最终效果

两周后的 Store 目录:

  • ✅ 6 个业务域,结构清晰
  • ✅ 100% Setup 风格
  • ✅ 100% TypeScript 类型覆盖
  • ✅ 统一的持久化策略
  • ✅ 完善的 JSDoc 注释

维护成本从"看一眼就头疼"变成了"顺手就能改"。

一些心得

  1. 不要一步到位:重构最怕的是"大跃进",分批迁移、逐步验证才是正道
  2. 向后兼容很重要:老代码不可能一夜之间都改完,兼容层是必须的
  3. 规范先行:先定好规范,再动手写代码,不然迁移完了又是一坨新的屎山
  4. Store 和 Composable 别混用:想清楚每个东西的职责,别图方便什么都往 Store 里塞
  5. 类型是文档:好的类型定义比注释更有用,interface 写清楚了,代码自解释

以上就是这次 Pinia 治理的全过程。如果你也在维护一个"历史悠久"的 Vue 项目,希望这篇文章能给你一些参考。

有问题欢迎评论区交流 👋

❌
❌