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

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

前言

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

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

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

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

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

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

bash 复制代码
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 应遵循以下原则:

typescript 复制代码
// 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:内存与性能的权衡

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

typescript 复制代码
// ❌ 反模式:深层响应式导致不必要的依赖追踪
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 的依赖追踪是自动的,但这也可能导致"过度追踪"问题:

typescript 复制代码
// ❌ 反模式:整个 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 可能导致性能问题:

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

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

3.1 泛型约束与条件类型

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

typescript 复制代码
// 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 事件类型的完整定义

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

typescript 复制代码
// 定义强类型的 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 定义:

typescript 复制代码
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)

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

typescript 复制代码
// 使用 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 函数实现:

typescript 复制代码
/**
 * 判断行是否可选择
 * @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,
  }
})

使用示例

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

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

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

typescript 复制代码
// 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 的独立性,我们可以脱离组件进行单元测试:

typescript 复制代码
// 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 编写了全面的单元测试:

scss 复制代码
✓ 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 保持一致,降低用户的学习成本:

vue 复制代码
<!-- 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 的简洁性:

vue 复制代码
<!-- 最简用法 -->
<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 来解决这个问题:

typescript 复制代码
// 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,这是经过深思熟虑的决定:

typescript 复制代码
// ✅ 当前方案:使用 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 属性

内存泄漏防护

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

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

8.3 Vue 3.4 defineModel:简化双向绑定

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

typescript 复制代码
// ❌ 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 操作代码:

vue 复制代码
<!-- 启用 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 开销)
  • 📊 更低的内存占用

兼容性设计

typescript 复制代码
// 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 应用开发,热衷于组件设计与性能优化。


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

相关推荐
疯狂平头哥2 小时前
微信小程序真机预览-数字不等宽如何解决
前端
Drift_Dream2 小时前
前端趣味交互:如何精准判断鼠标从哪个方向进入元素?
前端
计算机毕设VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue图书借阅管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
hqk2 小时前
鸿蒙ArkUI:状态管理、应用结构、路由全解析
android·前端·harmonyos
小马哥编程2 小时前
【软考架构】滑动窗口限流算法的原理是什么?
java·开发语言·架构
米思特儿林2 小时前
NuxtImage 配置上传目录配置
前端
Mr_chiu2 小时前
AI加持的交互革新:手把手教你用Vue3打造智能模板输入框
前端
精神状态良好2 小时前
告别聊天式编程:引入 OpenSpec,构建结构化的 AI 开发工作流
前端
WangHappy2 小时前
出海不愁!用Vue3 + Node.js + Stripe实现全球支付
前端·node.js