普通视图

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

2025OpenTiny星光ShowTime!年度贡献者征集启动!

2025年12月17日 18:00

前言

携手共创,致敬不凡!

2025年,OpenTiny持续在前端开源领域扎根,每一位开发者都是推动项目共同前行的宝贵力量。从bug修复,到技术探讨;从参与开源活动,到输出技术文章;从使用项目,到参与共建,每一步跨越,都凝聚了开发者的智慧与汗水。致敬所有在OpenTiny社区里默默付出、积极贡献、引领创新的杰出个人,我们正式启动“OpenTiny年度贡献者评选”活动!欢迎各位开发者踊跃报名~

活动详情

活动简介:

本次活动主要是通过开发者申报+社区评选+开发者投票形式开展,入选开发者后续可获得相应活动礼品。本次活动一共设置 4 类奖项。

  1.  “技术炼金师”(参与共建)、“布道魔法师”(参与分享)、“社区宝藏玩家”(参与社区讨论) 三个类目奖项通过投票评选获奖选手,本次投票共选出5名获奖选手,按照名次顺利依次给予相应奖励。
  2. “技术硬核奖”则由社区自主根据实际共建情况评选 2 位,获得机械键盘/蓝牙音响(2选1)及荣誉证书

活动奖品:

荣誉 奖项 礼品
第一名
  • 技术炼金师
  • 布道魔法师
  • 社区宝藏玩家
机械键盘 / 蓝牙音响(2选1) +荣誉证书
第二名 华为 66W 快充充电宝+荣誉证书
第三名 BKT 护腰坐垫椅+荣誉证书
第四/五名 屏幕挂灯+荣誉证书
社区优秀共建者 技术硬核奖 机械键盘 / 蓝牙音响(2选1) +荣誉证书

活动时间:

  • 年度贡献者征集时间:2025年12月17日-2025年12月24日
  • 年度贡献者投票评选时间:2025年12月25日-2025年12月31日

报名入口:

v.wjx.cn/vm/tdGJdjR.…

默认标题__2025-12-17+16_34_23.jpg

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

从零到一:打造企业级 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:现代前端构建工具的革命与实战指南

作者 AY1024
2025年12月17日 13:21

Vite:现代前端构建工具的革命

引言:前端构建工具的演进

在 Vite 出现之前,Webpack 几乎统治了前端构建工具领域。Webpack 通过静态分析依赖关系,将项目中的所有模块打包成少数几个 bundle 文件,这种"打包优先"的策略在早期确实解决了模块化开发的问题。但随着项目规模的增长,Webpack 的构建速度逐渐成为开发体验的瓶颈——即使是小型项目,冷启动时间也可能达到数十秒,热更新也需要几秒钟。

正是在这样的背景下,Vue.js 作者尤雨溪于 2020 年推出了 Vite(法语意为"快速"),它彻底改变了前端开发的构建范式,带来了革命性的开发体验提升。

Vite 的核心架构优势

1. 基于原生 ES 模块的急速冷启动

Vite 最显著的特点是极快的冷启动速度。与传统打包器不同,Vite 在开发环境下直接使用浏览器原生 ES 模块:

<!-- index.html -->
<script type="module" src="/src/main.js"></script>
// main.js - 浏览器直接执行 ES 模块
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

工作原理:

  • Vite 将应用模块分为依赖源码两部分
  • 依赖使用 esbuild 预构建(Go 语言编写,比 JavaScript 快 10-100 倍)
  • 源码按需编译并提供,浏览器只请求当前页面所需的模块
  • 这种方式避免了整个应用的打包过程,实现了毫秒级的启动速度

2. 高效的热模块替换(HMR)

Vite 的热更新同样基于原生 ES 模块系统,实现了精准的更新策略:

// 当修改一个 Vue 组件时
// Vite 只会重新编译该组件,并通过 HMR API 快速更新
if (import.meta.hot) {
  import.meta.hot.accept('./Foo.vue', (newModule) => {
    // 更新逻辑
  })
}

HMR 优势:

  • 更新速度不受应用规模影响

  • 保持应用状态不变

  • 支持 Vue 单文件组件的模板和样式热更新

  • 后端node会自动检查文件的修改情况,并且自动更新

  • 如图:


屏幕录制 2025-12-17 125503.gif

3. 开箱即用的现代化支持

# 一键创建项目
npm create vite@latest my-vue-app -- --template vue

Vite 原生支持:

  • TypeScript
  • JSX
  • CSS 预处理器(Sass、Less、Stylus)
  • PostCSS
  • 现代 CSS 功能(CSS Modules、CSS Nesting)
  • 静态资源处理
  • WebAssembly

工程化实践:构建完整的 Vue 应用

项目结构标准化

my-project/
├── src/
│   ├── main.js              # 应用入口
│   ├── App.vue              # 根组件
│   ├── views/               # 页面组件
│   │   ├── Home.vue
│   │   └── About.vue
│   ├── components/          # 可复用组件
│   ├── router/              # 路由配置
│   └── store/               # 状态管理
├── index.html               # 入口 HTML
└── vite.config.js           # Vite 配置

Vue Router 集成

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router
<!-- App.vue -->
<template>
  <nav>
    <router-link to="/">Home</router-link>
    <router-link to="/about">About</router-link>
  </nav>
  <router-view/>
</template>

生产构建优化

虽然 Vite 开发体验优秀,但生产构建仍使用 Rollup:

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

export default defineConfig({
  plugins: [vue()],
  build: {
    // 生产构建配置
    rollupOptions: {
      output: {
        manualChunks: {
          // 代码分割策略
          vendor: ['vue', 'vue-router'],
          utils: ['lodash', 'axios']
        }
      }
    },
    // 构建输出目录
    outDir: 'dist',
    // 静态资源处理
    assetsDir: 'assets'
  },
  server: {
    // 开发服务器配置
    port: 3000,
    open: true
  }
})

Vite 生态系统与插件

Vite 拥有丰富的插件生态系统:

// 常用插件配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'

export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    AutoImport({
      imports: ['vue', 'vue-router'],
      dts: true // 生成 TypeScript 声明文件
    })
  ]
})

4.构建一个vite项目

  • 安装项目依赖npm init vite,在终端输入
  • 输入项目名称:vite-test
  • 选择项目框架,vue/react,都可以
  • 选择语言,我们选择js
  • Use rolldown-vite (Experimental)?选择no,这个是问我们是否要选择实验性的打包器,我们不选择,因为其还在实验阶段,可能不稳定,选择no,使用默认的 Rollup 打包器(稳定)
  • Install with npm and start now?这是 Vite 在询问你是否要使用 npm 安装依赖并立即启动,我们选择yes
  • 最后按住Ctrl键,然后点击Local: http://localhost:5173/,就可以看到我们的初始化项目了
  • 如图

屏幕截图 2025-12-17 130611.png

image.png

vite目录解析

Vite 项目目录结构解析

以下是典型的 Vite + Vue 3 项目目录结构及详细解析:

基础目录结构

my-vite-project/
├── node_modules/          # 依赖包
├── public/               # 静态资源(不参与打包)
├── src/                  # 源代码目录
├── .gitignore           # Git 忽略文件
├── index.html           # 项目入口 HTML
├── package.json         # 项目配置和依赖
├── package-lock.json    # 依赖锁定文件
├── vite.config.js       # Vite 配置文件
└── README.md            # 项目说明

详细解析

1. node_modules/

node_modules/
└── 所有通过 npm/yarn 安装的依赖包
  • 作用:存放项目依赖的第三方库
  • 注意:此文件夹不应提交到 Git,通过 .gitignore 忽略

2. public/ 目录

public/
├── favicon.ico          # 网站图标
└── robots.txt           # 搜索引擎爬虫协议
  • 作用:存放不会被处理的静态资源
  • 特点
    • 不会被 Vite 处理或编译
    • 通过 / 根路径直接访问
    • 例如:public/logo.png 可以通过 /logo.png 访问

3. src/ 目录(核心)

src/
├── assets/              # 静态资源(会被处理)
│   ├── logo.png
│   └── styles/
│       └── main.css
├── components/          # 组件目录
│   ├── HelloWorld.vue
│   └── Navbar.vue
├── views/               # 页面级组件
│   ├── Home.vue
│   ├── About.vue
│   └── User/
│       ├── Profile.vue
│       └── Settings.vue
├── router/              # 路由配置
│   └── index.js
├── stores/              # 状态管理(Pinia)
│   └── counter.js
├── utils/               # 工具函数
│   └── helpers.js
├── api/                 # API 接口
│   └── user.js
├── App.vue              # 根组件
└── main.js              # 应用入口

4. 关键文件详解

index.html - 项目入口
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- 引入 main.js -->
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
  • 特点:Vite 将 index.html 作为入口点
  • ES 模块:通过 <script type="module"> 支持原生 ES 模块
src/main.js - 应用入口
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'

// 创建 Vue 应用
const app = createApp(App)

// 使用插件
app.use(router)

// 挂载到 DOM
app.mount('#app')
src/App.vue - 根组件
<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <router-view/>
  </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
}
</style>
vite.config.js - Vite 配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    port: 3000,               // 开发服务器端口
    open: true,               // 自动打开浏览器
    proxy: {                  // 代理配置
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  },
  resolve: {
    alias: {                  // 路径别名
      '@': '/src',
      '@components': '/src/components'
    }
  },
  build: {
    outDir: 'dist',           // 打包输出目录
    sourcemap: true           // 生成 sourcemap
  }
})

5. package.json

{
  "name": "my-vite-project",
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",           // 开发模式
    "build": "vite build",   // 生产构建
    "preview": "vite preview" // 预览生产版本
  },
  "dependencies": {
    "vue": "^3.3.0",
    "vue-router": "^4.2.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.2.0",
    "vite": "^4.4.0"
  }
}

6. 配置文件详解

.gitignore
# 依赖
node_modules/

# 构建输出
dist/
dist-ssr/

# 环境变量
.env
.env.local

# 日志
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# 编辑器
.vscode/
.idea/
*.swp
*.swo
环境变量文件
.env                # 所有情况下加载
.env.local          # 本地覆盖,不提交到 Git
.env.development    # 开发环境
.env.production     # 生产环境
.env.test           # 测试环境

Vite 特殊目录/文件

src/env.d.ts - TypeScript 环境声明

/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

src/auto-imports.d.ts - 自动导入声明

/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-auto-import
export {}
declare global {
  const ref: typeof import('vue')['ref']
  const reactive: typeof import('vue')['reactive']
  // ... 其他自动导入的 API
}

项目结构建议

小型项目

src/
├── components/
├── views/
├── App.vue
└── main.js

中型项目

src/
├── assets/
├── components/
│   ├── common/      # 通用组件
│   ├── layout/      # 布局组件
│   └── ui/          # UI 基础组件
├── composables/     # 组合式函数
├── router/
├── stores/
├── utils/
├── views/
├── App.vue
└── main.js

大型项目

src/
├── api/             # API 接口管理
├── assets/
├── components/
├── composables/
├── directives/      # 自定义指令
├── filters/         # 过滤器(Vue 2)
├── i18n/           # 国际化
├── layouts/         # 布局组件
├── middleware/      # 中间件
├── plugins/         # 插件
├── router/
├── stores/
├── types/          # TypeScript 类型定义
├── utils/
├── views/
├── App.vue
└── main.js

Vite 的优势总结

✅ 显著优势:

  1. 极速启动:冷启动时间比 Webpack 快 10-100 倍
  2. 即时更新:HMR 更新几乎无感知延迟
  3. 开发友好:错误提示清晰,配置简单
  4. 现代化:原生支持 ES 模块、TypeScript 等
  5. 生态完善:与 Vue、React、Svelte 等框架深度集成
  6. 插件丰富:活跃的插件生态系统

⚠️ 需要考虑的点:

  1. 浏览器兼容性

    • 开发依赖现代浏览器(支持原生 ES 模块)
    • 生产构建会自动转换为兼容格式
  2. 生态成熟度

    • 相比 Webpack,部分插件和工具链仍在完善中
    • 大型企业级应用迁移需要考虑现有工具链兼容性
  3. 构建优化

    • 生产构建基于 Rollup,对于超大型项目可能需要额外优化
    • 代码分割策略需要手动配置
  4. SSR 支持

    • Vite 的 SSR 支持相对较新,部分场景可能需要更多配置

实际性能对比

指标 Webpack Vite
冷启动(小型项目) 5-10s 50-200ms
冷启动(大型项目) 30-60s 1-3s
HMR 更新 1-3s 10-100ms
生产构建 优秀 优秀
配置复杂度

结语:以上就是对vite的介绍和使用教程了,望学习愉快!!!

用AI把猫主子变成冰球猛将?我搞了个“宠物拟人化”神器,结果……它真敢打!

2025年12月17日 11:53

“家里的猫天天躺着晒太阳,要是能上冰场打球该多好。”
——来自一位被猫统治的程序员的悲鸣

如果你也养了只“懒癌晚期”的猫或狗,每天除了吃就是睡,连看一眼你都嫌累,那你一定得看看这篇文章。
最近字节,搞了一个骚操作:把宠物照片一键变成冰球运动员!

没错,就是那种穿着红蓝球衣、手持球杆、眼神凶狠、仿佛下一秒就要破门得分的——拟人化冰球猛将

而且,整个过程只需要上传一张宠物照,剩下的交给AI搞定。
这不仅是个技术项目,更是一场让毛孩子当明星的奇幻冒险


🐱 从“我家猫很胖”到“我是冰球队门将”,只差一个AI

假设作为实习生团队的一员,我们的任务是为公司内部的“冰球俱乐部”策划一场趣味活动。
目标很简单:

让会员们上传自家宠物的照片,生成一张“宠物变冰球运动员”的酷炫海报。

听起来像魔法?不,这是低代码 + AI + Vue 的完美组合


🔧 技术栈三件套:Coze + Vue + AI 图像生成

1. Coze 工作流:拖拽式搭建,谁都能玩

我们选择了 Coze(扣子)平台 来构建整个AI工作流。它的优势在于:

  • 低代码编辑器:拖拖拽拽就能连节点
  • 支持自定义代码逻辑
  • 集成图像生成、特征提取、文本理解等能力

比如下面这个流程图,就是我们亲手“画”出来的:

4f7147aa-3bbd-4273-a3fa-cbf23fc6be88.png

(注:图片展示的是真实工作流界面)

简单来说,流程如下:

  1. 用户上传宠物照片
  2. 系统自动识别宠物特征(如体型、毛色)
  3. 使用代码节点随机分配位置和持杆手
  4. 生成描述文案:“一只戴着红色头盔的哈士奇,身穿10号球衣,正准备射门……”
  5. 最终调用图像生成模型,输出一张“拟人化冰球运动员”照片

2. 自定义代码节点:给AI加点“智商”

为了让生成效果更有逻辑性,我们加入了关键的 JavaScript 代码节点

const random = (start: number, end: number) => {
    const p = Math.random();
    return Math.floor(start * (1 - p) + end * p);
}

async function main({ params }: Args): Promise<Output> {
    if (params.position == null) params.position = random(0, 3);
    if (params.shooting_hand == null) params.shooting_hand = random(0, 2);

    const style = params.style || '写实';
    const uniform_number:string = (params.uniform_number || 10).toString();
    const uniform_color = params.uniform_color || '红';
    const position = params.position == 0 ? '守门员': (params.position == 1 ? '前锋': '后卫');
    const shooting_hand = params.shooting_hand == 0 ? '左手': '右手';

    const ret = {
        style,
        uniform_number,
        uniform_color,
        position,
        shooting_hand,
    };

    return ret;
}

这段代码的作用是:

  • 如果用户没选位置,就随机分配:守门员、前锋、后卫
  • 持杆手也随机:左手 or 右手
  • 默认风格是“写实”,但也可以改成“卡通”、“赛博朋克”等

这样一来,每只猫都可能是下一个“冰球界梅西”。


3. 特征提取 + 图像生成:AI眼中的“猫”是什么样?

我们在流程中加入了 imgUnderstand特征提取 节点,用来分析原始图片。

例如,系统会自动识别出:

  • 这是一只“短毛猫”还是“长毛狗”
  • 毛色是黑、白、灰还是花斑
  • 是否有胡须、耳朵形状等细节

然后把这些信息融合进提示词(prompt),比如:

“一只黑白相间的猫咪,身穿蓝色10号球衣,正在滑行射门,背景是冰球场,风格写实。”

最终由图像生成模型生成一张完全拟人化的冰球运动员照片


💡 实战演示:我家猫变成了“冰球门将”!

我上传了一张我家主子的照片——一只慵懒的布偶猫,平时连逗猫棒都不理。

结果……它居然成了这样:

iceball_player.png

(图中是一只穿着红色球衣、戴着头盔、手持球杆的“布偶猫门将”,眼神坚毅,仿佛刚扑掉一个致命射门)

我朋友看完后说:“这猫怕不是真的练过?”
我说:“不,它是被AI逼出来的。”


🎯 为什么这个项目值得做?

✅ 用户体验极佳

  • 不需要懂AI,也不需要编程
  • 上传照片 → 一键生成 → 分享朋友圈
  • 完全自动化,适合节日营销

✅ 技术亮点满满

  • 结合了图像理解、自然语言处理、图像生成
  • 使用低代码平台快速迭代
  • 支持个性化定制(球衣颜色、号码、风格)

✅ 幽默感拉满

谁不想看到自己家的狗穿上球衣、拿着球杆、一脸“老子要进球”的表情?

尤其是那些平时只会趴着睡觉的“废柴宠物”,突然变得英姿飒爽,反差萌直接拉满!


🚀 如何复刻这个项目?

如果你想自己动手做一个类似的“宠物变英雄”项目,可以按以下步骤来:

Step 1:注册 Coze 平台

访问 coze.cn,创建个人空间。

Step 2:新建工作流

  • 名称:pet_hockey_player
  • 添加输入节点:picture, style, uniform_number, uniform_color

Step 3:添加节点

  1. 代码节点:实现随机逻辑
  2. imgUnderstand:解析图片内容
  3. 特征提取:获取动物特征
  4. 图像生成:使用通用模型 + 提示词生成图片
  5. 结束节点:返回结果

Step 4:前端用 Vue 搭建界面

<template>
  <div class="container">
    <h1>上传你的宠物,让它成为冰球明星!</h1>
    <input type="file" @change="handleFileUpload" accept="image/*" />
    <button @click="generate">生成冰球运动员</button>
    <img v-if="result" :src="result" alt="Generated Player" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      result: null
    }
  },
  methods: {
    async handleFileUpload(event) {
      const file = event.target.files[0];
      // 上传文件并获取URL
      this.uploadedUrl = URL.createObjectURL(file);
    },
    async generate() {
      const res = await fetch('/api/coze-workflow', {
        method: 'POST',
        body: JSON.stringify({
          picture: this.uploadedUrl,
          style: '写实',
          uniform_number: 7,
          uniform_color: '蓝'
        })
      });
      this.result = await res.json().data;
    }
  }
}
</script>

注:实际调用 Coze API 需要配置鉴权,具体可参考官方文档。


🤔 我的思考:AI不只是工具,更是“造梦机器”

做这个项目时,我一直在想一个问题:

当AI能把一只猫变成冰球运动员时,我们到底是在创造什么?

答案是:一种新的情感连接。

我们不是在“欺骗”用户,而是在用科技,帮他们完成一次对爱宠的浪漫想象

就像小时候幻想自己是超级英雄一样,现在我们可以让宠物也成为“赛场上的王者”。

而这一切,只需要一行代码、一个工作流、一点点创意。


🎉 结语:下次别再说“我家猫不会打球”了

从今以后,请记住:

每只宠物,都有成为冰球巨星的潜力。

只要它愿意——或者,只要AI愿意。


📌 项目总结

项目 内容
平台 Coze(扣子)
技术 图像生成 + 特征提取 + 低代码工作流
前端 Vue + Axios
核心功能 宠物拟人化 + 冰球运动员生成
应用场景 节日活动、社区运营、品牌互动

微信扫码登录 iframe 方案中的状态拦截陷阱

作者 鹏北海
2025年12月17日 11:31

微信扫码登录 iframe 方案中的状态拦截陷阱

背景

在 Web 端实现微信扫码登录时,常见的方案是使用 iframe 嵌入微信二维码页面。用户扫码授权后,iframe 内部会重定向到我们配置的回调页面,回调页面再通过 postMessage 通知父页面完成登录。

最近在给登录流程增加「用户协议勾选」功能时,遇到了一个有趣的问题:用户勾选协议后扫码,在手机上确认授权前又取消了勾选,结果登录流程依然执行了

问题现象

预期行为:用户取消勾选协议 → 拦截登录流程 → 不跳转

实际行为:用户取消勾选协议 → 控制台显示"未同意协议,不触发事件" → 页面依然跳转了

架构分析

整个微信登录的组件结构如下:

Login.vue (页面)
  └── Container.vue
        └── wxQrCodeLogin.vue
              └── iframe (微信二维码)
                    └── WxLogin.vue (回调页面,iframe 内部)

登录流程:

  1. 用户勾选协议 → 显示二维码(iframe)
  2. 用户手机扫码 → 微信授权页面
  3. 用户确认授权 → iframe 重定向到 WxLogin.vue
  4. WxLogin.vue 调用后端接口获取 token
  5. 通过 postMessage 通知父页面
  6. 父页面完成登录跳转

问题根因

在 wxQrCodeLogin.vue 中,我添加了协议状态拦截:

window.addEventListener("message", (msg) => {
  // 未勾选协议,直接返回
  if(!isAgree.value) {
    console.log("未同意协议,不触发事件");
    return;
  }

  if(msg.data.type === '1') {
    emit('qrLoginSuccess', msg.data.token);
  }
});

看起来没问题,但实际上拦截失效了。原因在 WxLogin.vue(iframe 内的回调页面):

if(token) {
    Store.set_cookie('token', token);  // 问题在这里!
    window.parent.postMessage({ type: '1', token }, '*');
}

iframe 内部直接设置了 cookie!

由于 iframe 和父页面同域,cookie 是共享的。当 token 被写入 cookie 后,主站的登录状态检测逻辑检测到 token,自动触发了页面跳转。

整个过程:

  1. 微信授权成功 → iframe 内 WxLogin.vue 执行
  2. Store.set_cookie('token', token) → cookie 已写入
  3. postMessage 发送给父页面
  4. 父页面 isAgree 检查 → 返回,不处理
  5. 但 cookie 已经存在 → 主站检测到登录状态 → 跳转

拦截的是 postMessage,但 cookie 的写入发生在 postMessage 之前,根本拦不住。

解决方案

核心原则

iframe 回调页面只负责「中转」,不应该直接操作登录状态(cookie、localStorage 等)。状态的写入应该由父页面根据业务逻辑决定。

代码修改

WxLogin.vue(iframe 回调页面):

// 修改前
if(token) {
    Store.set_cookie('token', token);  // 删除这行
    window.parent.postMessage({ type: '1', token }, '*');
}

// 修改后
if(token) {
    // 只传递 token,不设置 cookie
    window.parent.postMessage({ type: '1', token }, '*');
}

父页面在收到 postMessage 后,根据 isAgree 状态决定是否设置 cookie 并完成登录:

window.addEventListener("message", (msg) => {
  if(!isAgree.value) {
    // 可以弹出协议确认弹窗,让用户选择
    return;
  }

  if(msg.data.type === '1') {
    // 在这里设置 cookie
    await loginCallback({ token: msg.data.token });
    emit('qrLoginSuccess', msg.data.token);
  }
});

延伸思考

为什么 v-show 不能解决问题?

最初尝试用 v-show 隐藏 iframe,但 v-show 只是 display: none,iframe 依然存在,内部的回调逻辑照常执行。

为什么 v-if 也有问题?

v-if 会销毁 iframe,但如果用户已经扫码进入微信授权页面,此时销毁 iframe 再重建,新的 iframe 无法接收之前扫码的授权回调,用户需要重新扫码。

最佳实践

  1. iframe 回调页面职责单一:只负责接收授权结果、调用后端接口、通过 postMessage 传递数据
  2. 状态操作由父页面控制:cookie、localStorage、页面跳转等操作都应该在父页面根据业务状态决定
  3. 考虑异步流程中的状态变化:用户可能在异步操作过程中改变状态,设计时要考虑这种边界情况

总结

这个问题的本质是职责划分不清晰导致的。iframe 内的回调页面越权操作了本应由父页面控制的登录状态,使得父页面的拦截逻辑形同虚设。

在设计跨窗口/跨 iframe 通信的功能时,要明确各个组件的职责边界,状态的写入和业务逻辑的执行应该集中在一个地方,避免分散导致的控制失效。

代码宇宙的精密蓝图:深入探索 Vue 3 + Vite 项目的灵魂结构

作者 AAA阿giao
2025年12月17日 10:52

引子:从一行命令到一座数字都市

“在数字世界的深处,有一座由逻辑、美学与工程智慧共同构筑的城市。它的街道井然有序,建筑功能分明,每一砖一瓦都闪耀着现代前端工程化的光芒——这座城市的名字,叫 all-vue。”


你是否曾想过,当你在终端敲下:

npm create vite@latest all-vue -- --template vue

并按下回车的那一刻,你其实不是在“创建一个项目”——
你是在召唤一座未来之城!

这座城没有钢筋水泥,却有比物理世界更严谨的秩序;
它不靠图纸施工,却比任何建筑都更模块化、可扩展、易维护。

今天,就让我们化身“前端考古学家”,手持探照灯,走进这座名为 all-vue 的 Vue 3 + Vite 项目城市,逐街逐巷地揭开它的神秘面纱。你会发现:每一个文件夹,都是一片功能区;每一个文件,都是一位忠诚的市民。

准备好了吗?City Tour Now Begins! 🚌


第一站:城市总览 —— 一张地图看懂全貌

我们的城市 all-vue/ 布局如下:

all-vue/
├── .vscode/                  # 智能市政厅(IDE 配置中心)
├── 项目架构图解/             # 城市博物馆(学习资料档案馆)
├── node_modules/             # 万神殿(依赖神祇的居所)
├── public/                   # 中央广场(静态资源直通区)
├── src/                      # 核心城区(源码心脏地带)
│   ├── assets/               # 艺术工坊(图标、SVG、字体)
│   ├── components/           # 工匠街区(可复用 UI 积木)
│   ├── router/               # 驿站总局(路由调度中枢)
│   ├── views/                # 行政办公区(页面级视图)
│   ├── App.vue               # 国师府(根组件,全局布局)
│   ├── main.js               # 王座厅(应用入口,创世起点)
│   └── style.css             # 染织局(全局样式规范)
├── index.html                # 城门广场(HTML 入口,迎接访客)
├── package.json              # 城市宪法(依赖与脚本律法)
├── package-lock.json         # 户籍档案(锁定依赖版本)
├── README.md                 # 游客指南(项目说明书)
├── vite.config.js            # 城建总规(构建配置蓝图)
└── .gitignore                # 边境守则(Git 忽略规则)

这不仅是一份目录列表——这是一座高度现代化、分工明确、自给自足的数字文明


第二站:城门广场 —— index.html:欢迎来到 Vue 世界!

一切旅程,始于城门。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <link rel="icon" href="/favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite + Vue</title>
</head>
<body>
  <div id="app"></div> <!-- 神圣挂载点 -->
</body>
</html>
  • <div id="app"> 是整座城市的“祭坛”。
    就像古希腊神庙中央的圣火,Vue 应用将在此显形、呼吸、生长。
  • Vite 的魔法:无需手动引入 JS 文件!开发时,Vite 会自动注入 <script type="module" src="/src/main.js">,实现原生 ES Module 加载。
  • public/ 下的资源(如 /favicon.ico)直接映射到根路径,因为它们属于“公共基础设施”。

冷知识:Vite 利用浏览器原生支持 <script type="module"> 的特性,跳过传统打包环节,实现毫秒级冷启动——这就是为什么你的项目“嗖”一下就打开了!


第三站:王座厅 —— src/main.js:应用的诞生仪式

走进核心城区,首先抵达的是王座厅——main.js。这里是整个 Vue 应用的“出生证明”:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(router) // 册封路由为宰相
app.mount('#app') // 登基大典

短短六行,完成三大创世行为:

  1. 召唤 Vue 实例createApp() 创建应用容器。
  2. 册封插件app.use(router) 注册 Vue Router,赋予其导航权柄。
  3. 登基挂载app.mount('#app') 将虚拟 DOM 绑定到真实 DOM。

Vue 3 的优雅:不再需要 new Vue({}),而是函数式 API,更轻量、更灵活。


第四站:国师府 —— App.vue:全局布局与命运之镜

接下来是国师府——App.vue,它是所有页面的“父容器”:

<template>
  <nav>
    <router-link to="/">首页</router-link> |
    <router-link to="/about">关于</router-link>
  </nav>
  <router-view /> <!-- 命运之镜 -->
</template>
  • <router-view /> 是“命运之镜”:它本身不渲染内容,而是动态插入当前路由匹配的组件(如 Home.vueAbout.vue)。
  • <router-link> 是“传送符”:点击即触发无刷新跳转,并自动添加 .router-link-active 类用于高亮。

设计哲学App.vue 只负责全局布局(导航栏、页脚),绝不掺和具体业务逻辑。页面内容,交给 views/ 中的专业团队。


第五站:行政办公区 —— src/views/:页面级组件的家园

这里住着城市的“公务员”——页面级组件:

  • Home.vue:首页,展示核心功能或欢迎语。
  • About.vue:关于页,讲述项目故事。

每个 .vue 文件都是一个单文件组件(SFC) ,三位一体:

<template> <!-- 视觉层 -->
  <h1>关于我们</h1>
</template>

<script> <!-- 逻辑层 -->
export default { name: 'About' }
</script>

<style scoped> <!-- 局部样式 -->
h1 { color: royalblue; }
</style>
  • scoped 样式:确保 CSS 仅作用于当前组件,避免“样式污染”——就像给每个办公室装上隔音墙。
  • 命名规范:大驼峰(PascalCase),如 UserProfile.vue,一眼识别为组件。

第六站:工匠街区 —— src/components/:可复用 UI 的熔炉

如果说 views/ 是政府机构,那 components/ 就是民间手工艺人聚集地

  • HelloWorld.vue 是官方示例组件,常用于演示 props、事件等基础概念:
<script>
export default {
  props: { msg: String }
}
</script>

在父组件中使用:

<HelloWorld msg="欢迎来到 Vue 宇宙!" />

组件化思想:将 UI 拆分为独立、可组合、可测试的单元,是现代前端开发的基石。就像乐高积木,拼出无限可能。


第七站:驿站总局 —— src/router/index.js:单页应用的交通网

没有交通,城市就会瘫痪。而 router/index.js 正是这座城市的交通调度中心

import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'

const routes = [
  { path: '/', component: Home },
  { 
    path: '/about', 
    component: () => import('../views/About.vue') // 动态导入
  }
]

const router = createRouter({
  history: createWebHistory(), // 启用 HTML5 History 模式
  routes
})

export default router
关键技术亮点:
  • # 的 URLcreateWebHistory() 让地址变成 /about 而非 /#/about,更美观、SEO 友好。
  • 懒加载(Lazy Loading)import() 语法使 About.vue 仅在访问时加载,减少首屏体积。
  • 命名路由name: 'About' 便于编程式导航(router.push({ name: 'About' }))。

部署注意:若使用 History 模式,服务器需将所有路径 fallback 到 index.html,否则刷新会 404。


第八站:艺术工坊 vs 中央广场 —— assets/public/ 的分工

很多人混淆这两个目录,其实它们职责分明:

目录 用途 构建处理 引用方式
src/assets/ 组件内使用的资源(如 logo.png) ✅ 被 Vite 处理(哈希、压缩) import img from '@/assets/logo.png'
public/ 全局静态资源(如 favicon.ico) ❌ 原样复制 /favicon.ico

最佳实践

  • 组件相关的图片 → assets/
  • SEO/PWA 相关资源(manifest.json、robots.txt)→ public/

第九站:城市宪法 —— package.jsonvite.config.js

package.json:律法典籍

{
  "scripts": {
    "dev": "vite",          // 启动开发服务器
    "build": "vite build",  // 构建生产代码
    "preview": "vite preview" // 本地预览
  },
  "dependencies": { "vue": "^3.4.0" },
  "devDependencies": { "vite": "^5.0.0" }
}
  • dependencies:运行时必需(如 Vue)
  • devDependencies:仅开发时需要(如 Vite)

 vite.config.js:城建总规

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') }
  },
  server: {
    port: 3000,
    open: true // 自动打开浏览器
  }
})
  • 路径别名 @import Home from '@/views/Home.vue' 更简洁。
  • 可扩展性:轻松添加代理、CSS 预处理器、PWA 插件等。

第十站:魔法助手 —— 开发体验的极致优化

Volar:Vue 的智能先知

  • VS Code 官方插件,提供:

    • 语法高亮
    • 智能提示
    • 重构支持
    • 类型推导(即使使用 JS)

建议:禁用旧版 Vetur,启用 Volar 并开启 “Take Over Mode”。

Vue Devtools:灵魂透视镜

Chrome 插件,F12 打开后新增 “Vue” 标签页:

  • 实时查看组件树
  • 监听响应式数据变化
  • 追踪路由历史
  • 分析性能瓶颈

整体流程:从启动到渲染的奇幻旅程

graph TD
  A[启动项目] --> B[npm run dev]
  B --> C[Vite 开发服务器启动]
  C --> D[监听 src/ 目录变化]
  D --> E[热更新:文件修改 → 浏览器自动刷新]
  E --> F[打开 http://localhost:5173]

  G[index.html] --> H[#app 挂载点]
  H --> I[src/main.js]
  I --> J[创建 Vue 实例]
  J --> K[注册 router]
  K --> L[渲染 App.vue]
  L --> M[<router-view> 渲染当前页面]

热更新原理:Vite 利用 WebSocket 监听文件变化,仅更新修改的模块,无需整页刷新——快到你几乎感觉不到延迟!


结语:你不仅是开发者,更是文明缔造者

这套 Vue 3 + Vite + Vue Router 项目结构,之所以被称为“优秀架构”,是因为它完美体现了现代前端工程化的五大支柱:

支柱 实现方式
模块化 components/, views/, utils/ 分离职责
可维护性 单一职责 + 清晰目录
可扩展性 插件化架构(Vite + Vue 生态)
开发体验 热更新 + 智能提示 + Devtools
生产优化 代码分割 + 压缩 + 缓存策略

当你下次创建新项目,请记住:
你不是在写代码——你是在建造一座可以自我演化、持续生长的数字文明。

all-vue,正是这座文明的第一块基石。

 “npm run dev” 不仅启动了一个服务器——它点燃了一个宇宙的星辰。”
现在,轮到你去书写它的未来了。🚀

Vue2 vs Vue3

2025年12月17日 10:44

Vue 2 与 Vue 3 的主要区别可以从以下几个方面对比:

1. 架构重构

  • Vue 2:基于 Options API,使用 Object.defineProperty 实现响应式
  • Vue 3:基于 Composition API(兼容 Options API),使用 Proxy 实现响应式

2. 响应式系统

// Vue 2 - Object.defineProperty
data() {
  return {
    count: 0
  }
}

// Vue 3 - Proxy
import { ref, reactive } from 'vue'

const count = ref(0)
const state = reactive({ name: 'Vue 3' })
  • 优势:Proxy 能检测到属性的添加/删除,数组索引和长度变化

3. Composition API vs Options API

<!-- Vue 2 Options API -->
<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() { this.count++ }
  },
  mounted() { console.log('mounted') }
}
</script>

<!-- Vue 3 Composition API -->
<script setup>
import { ref, onMounted } from 'vue'

const count = ref(0)
const increment = () => count.value++

onMounted(() => {
  console.log('mounted')
})
</script>

4. 性能提升

  • 打包体积:Vue 3 体积减小约 40%(Tree-shaking 优化)
  • 渲染速度:初始渲染快 55%,更新快 133%
  • 内存占用:减少约 50%

5. TypeScript 支持

  • Vue 2:需要额外的装饰器或复杂配置
  • Vue 3:原生 TypeScript 支持,更好的类型推断

6. 新特性

Fragment

<!-- 可包含多个根节点 -->
<template>
  <header></header>
  <main></main>
  <footer></footer>
</template>

Teleport

<template>
  <teleport to="body">
    <!-- 将组件渲染到 body 下 -->
    <Modal />
  </teleport>
</template>

Suspense

<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <div>Loading...</div>
    </template>
  </Suspense>
</template>

7. API 变化

生命周期

Vue 2 Vue 3 (Options API) Vue 3 (Composition API)
beforeCreate ❌ 使用 setup setup
created ❌ 使用 setup setup
beforeMount beforeMount onBeforeMount
mounted mounted onMounted
beforeUpdate beforeUpdate onBeforeUpdate
updated updated onUpdated
beforeDestroy beforeUnmount onBeforeUnmount
destroyed unmounted onUnmounted

全局 API

// Vue 2
Vue.component()
Vue.directive()
Vue.mixin()

// Vue 3 - 改为应用实例
const app = createApp(App)
app.component()
app.directive()

8. v-model 改进

<!-- Vue 2:每个组件只能有一个 v-model -->
<ChildComponent v-model="value" />

<!-- Vue 3:支持多个 v-model -->
<ChildComponent 
  v-model:title="title"
  v-model:content="content"
/>

9. 事件 API

// Vue 2
this.$on('event', handler)
this.$emit('event', data)

// Vue 3 - 推荐使用第三方库(如 mitt)或 Provide/Inject

10. 迁移建议

  • 新项目:直接使用 Vue 3 + Composition API
  • 老项目
    • 小项目:建议升级
    • 大项目:逐步迁移或使用 Vue 2.7(包含部分 Vue 3 特性)

总结

Vue 3 在性能、开发体验和维护性方面都有显著提升,特别适合大型项目和需要更好 TypeScript 支持的项目。Composition API 提供了更好的逻辑组织和复用能力。

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 场景)
昨天以前首页

Vue自定义拖拽指令架构解析:从零到一实现元素自由拖拽

作者 青青很轻_
2025年12月16日 18:07
Vue项目中通过自定义指令实现元素的拖拽功能。文章从拖拽的核心原理出发,讲解了如何利用Vue指令的生命周期钩子封装鼠标事件监听逻辑,并提供了完整的指令代码、全局注册方法及在组件中的使用示例。
❌
❌