Vue 基础体系 · 第 38/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 数据表格工程:列模型、排序、筛选、选择、编辑和可访问性

数据表格不是“把数组循环成 <tr>”这么简单。当表格同时具备排序、筛选、批量选择和行内编辑时,真正需要设计的是一套可预测的数据模型:

原始数据
  ↓
筛选
  ↓
排序
  ↓
可见行
  ↓
选择状态、编辑状态、展示状态

如果这些状态没有明确边界,常见结果包括:

  • 排序后选错行,因为选择状态使用了行索引;
  • 筛选后批量操作了用户看不见的行;
  • 编辑中的输入被重新加载覆盖;
  • 数字按字符串排序,100 排在 20 前面;
  • 点击表头排序时触发了行选择;
  • 屏幕阅读器无法知道某列当前正在升序还是降序;
  • 远程请求返回顺序错乱,旧数据覆盖新数据。

本文使用 Vue 3、Composition API、TypeScript 和现代 Vite 工具链,逐步构建一个客户端数据表格,并说明当数据量或业务复杂度增加时,哪些逻辑应该迁移到服务端。


一、先定义数据表格的状态边界

一个可维护的表格至少包含五类状态。

1. 原始数据状态

原始数据是服务端或父组件提供的业务记录:

type User = {
  id: number
  name: string
  role: 'admin' | 'editor' | 'viewer'
  salary: number
  active: boolean
}

原始数据不应该因为“当前筛选条件”而被删除,也不应该直接变成排序后的副本。它代表当前已加载的数据集合。

2. 视图状态

视图状态描述用户怎样查看数据,例如:

type SortDirection = 'asc' | 'desc'

type SortState = {
  key: keyof User | null
  direction: SortDirection
}

还包括搜索词、当前页、每页数量等。视图状态可以改变,但不应改变业务数据本身。

3. 选择状态

选择状态描述哪些业务记录被选中:

const selectedIds = ref<Set<number>>(new Set())

这里使用 id 而不是数组下标。数组下标是视图位置,排序和筛选都会改变它;id 才是记录的稳定身份。

4. 编辑状态

编辑状态不能简单等同于原始记录。行内编辑通常需要一份草稿:

const editingId = ref<number | null>(null)
const draft = reactive<Partial<User>>({})

用户输入应先写入草稿,验证通过并保存成功后,再提交到原始数据或通知父组件更新。

5. 加载和错误状态

远程表格还需要区分:

const loading = ref(false)
const errorMessage = ref('')

loading 不等于“表格没有数据”,errorMessage 也不等于“空结果”。这三种状态应该分别表达:

  • 正在加载;
  • 加载成功但结果为空;
  • 加载失败。

二、列模型:把列定义从模板中抽出来

2.1 什么是列模型

列模型是对一列行为的结构化描述。它至少应包含:

  • 列的稳定标识;
  • 绑定的数据字段;
  • 表头文本;
  • 是否可排序;
  • 是否可编辑;
  • 如何格式化单元格;
  • 列宽或其他展示信息。

例如:

type Column<Row extends object> = {
  key: keyof Row
  label: string
  sortable?: boolean
  editable?: boolean
  format?: (value: Row[keyof Row], row: Row) => string
}

对于 User

const columns: Column<User>[] = [
  {
    key: 'name',
    label: '姓名',
    sortable: true,
    editable: true,
  },
  {
    key: 'role',
    label: '角色',
    sortable: true,
    editable: true,
    format: (value) => {
      const labels = {
        admin: '管理员',
        editor: '编辑者',
        viewer: '查看者',
      } satisfies Record<User['role'], string>

      return labels[value as User['role']]
    },
  },
  {
    key: 'salary',
    label: '薪资',
    sortable: true,
    editable: true,
    format: (value) => `¥${Number(value).toLocaleString('zh-CN')}`,
  },
  {
    key: 'active',
    label: '状态',
    sortable: true,
    format: (value) => (value ? '启用' : '停用'),
  },
]

列模型的意义不是减少模板行数,而是让“列是否能排序、怎样显示、是否允许编辑”成为可检查的数据,而不是散落在多个 v-if 中。

2.2 列模型的边界

列模型适合描述稳定的列能力,但不应把所有业务流程塞进 format 函数。例如:

format: (value) => {
  // 不建议在格式化函数里发送请求、修改状态或触发弹窗
}

格式化函数最好是纯函数:输入值和行,输出展示文本。排序、筛选、保存等副作用应位于组件逻辑或组合式函数中。

如果需要自定义复杂单元格,可以扩展列模型:

type Column<Row extends object> = {
  key: keyof Row
  label: string
  sortable?: boolean
  editable?: boolean
  format?: (value: Row[keyof Row], row: Row) => string
  kind?: 'text' | 'number' | 'select' | 'boolean'
}

kind 可以决定编辑控件和默认比较器,但它仍然不是业务保存逻辑的替代品。


三、排序:排序的是派生数据,不是原始数组

3.1 排序的数学条件

给定两条记录 ab,比较器 compare(a, b) 应满足以下基本性质:

  1. 自反性:compare(a, a) = 0
  2. 反对称性:如果 compare(a, b) < 0,则应有 compare(b, a) > 0
  3. 传递性:如果 a < bb < c,则应有 a < c

如果比较器不满足这些条件,排序结果可能依赖运行时实现,表现为顺序不稳定或难以复现。

对数字不能使用默认字符串比较:

[2, 10, 100].sort()
// 可能得到 [10, 100, 2]

因为默认比较会把元素转换成字符串。数字应使用:

[2, 10, 100].sort((a, b) => a - b)

3.2 空值排序必须先定义规则

设列值可能为空。必须先决定空值排在前面还是后面。下面的规则让空值始终排在最后:

function compareValues(a: unknown, b: unknown): number {
  const aEmpty = a === null || a === undefined || a === ''
  const bEmpty = b === null || b === undefined || b === ''

  if (aEmpty && bEmpty) return 0
  if (aEmpty) return 1
  if (bEmpty) return -1

  if (typeof a === 'number' && typeof b === 'number') {
    return a - b
  }

  return String(a).localeCompare(String(b), 'zh-CN', {
    numeric: true,
    sensitivity: 'base',
  })
}

numeric: true 使类似 "item2""item10" 的字符串更接近人类预期顺序,但它不能替代真实数字字段的数值比较。

3.3 稳定排序

稳定排序意味着:如果两条记录在当前排序字段上相等,它们保留原来的相对顺序。

例如原始顺序是:

A:编辑者
B:管理员
C:编辑者

按角色排序后,A 和 C 的角色相等。如果希望它们仍然保持 A 在 C 前面,就需要一个原始位置作为最终比较条件:

const withIndex = rows.map((row, index) => ({ row, index }))

withIndex.sort((a, b) => {
  const result = compareValues(a.row.role, b.row.role)
  return result === 0 ? a.index - b.index : result
})

现代 JavaScript 规范要求 Array.prototype.sort 稳定,但显式保留原始索引仍有两个好处:它清楚表达了需求,并且在把排序逻辑迁移到服务端时,可以对应一个明确的次级排序字段。

3.4 排序操作应产生新数组

不要直接对响应式原数组调用 .sort()

// 不推荐
users.value.sort(...)

这会改变原始数据顺序,并可能让“原始数据”和“视图数据”失去边界。应对副本排序:

const sorted = [...users.value].sort(...)

在 Vue 中,使用 computed 表达这个派生关系:

const sortedRows = computed(() => {
  const rows = [...users.value]

  if (!sortState.value.key) {
    return rows
  }

  const key = sortState.value.key
  const direction = sortState.value.direction === 'asc' ? 1 : -1

  return rows
    .map((row, index) => ({ row, index }))
    .sort((a, b) => {
      const result = compareValues(a.row[key], b.row[key])
      return result === 0
        ? a.index - b.index
        : result * direction
    })
    .map(({ row }) => row)
})

四、筛选:先明确筛选作用域,再计算可见行

筛选不是“把不匹配的行隐藏掉”这么简单。必须先确定它作用于:

  • 当前已加载的全部数据;
  • 当前页数据;
  • 服务端数据集。

客户端表格通常对当前已加载数据筛选。数据流可以写成:

users
  → filteredRows
  → sortedRows
  → visibleRows

筛选和排序的顺序会影响结果,尤其是分页时。常见的客户端分页流程是:

原始数据 → 筛选 → 排序 → 分页

如果先分页再筛选,用户搜索时只能在当前页中查找,会得到不符合直觉的结果。

一个简单的文本筛选器:

const searchQuery = ref('')

const filteredRows = computed(() => {
  const query = searchQuery.value.trim().toLocaleLowerCase()

  if (!query) return users.value

  return users.value.filter((row) => {
    return [row.name, row.role]
      .join(' ')
      .toLocaleLowerCase()
      .includes(query)
  })
})

这里把 role 的内部值也用于搜索。更完整的实现可以搜索格式化后的中文标签,但要注意:展示值和业务值可能不同,筛选规则应当明确。

4.1 防抖不是筛选算法的一部分

本地数组筛选通常可以在输入时立即计算。防抖只解决“减少输入事件触发的远程请求”问题,不应被误认为筛选逻辑本身。

服务端搜索时,流程通常变成:

输入变化
  → 等待防抖时间
  → 取消或标记旧请求
  → 请求 query、sort、page
  → 只接受最新请求结果

五、选择:使用稳定 ID,并定义“全选”的语义

5.1 选择状态不能使用数组下标

错误示例:

const selectedIndexes = ref<number[]>([])

用户先选中第 2 行,之后排序,原来的第 2 行可能已经变成另一条记录。正确做法是存储稳定主键:

const selectedIds = ref<Set<number>>(new Set())

切换一行:

function toggleSelected(id: number) {
  const next = new Set(selectedIds.value)

  if (next.has(id)) {
    next.delete(id)
  } else {
    next.add(id)
  }

  selectedIds.value = next
}

这里创建新的 Set 再赋值,而不是原地修改,能让响应式依赖关系更清晰:

// 容易造成维护困难
selectedIds.value.add(id)

5.2 “全选”不是唯一语义

表格中的“全选”至少有两种解释:

  1. 选择当前筛选结果中的全部行;
  2. 选择数据集中的全部行,包括当前页面和未加载的行。

客户端完整加载数据时,可以把“全选”定义为“选择当前可见行”。远程分页时,这个定义就不够了,因为当前页面只代表整个数据集的一部分。

远程大数据表格通常需要:

type SelectionState = {
  mode: 'explicit' | 'allMatching'
  selectedIds: Set<number>
  excludedIds: Set<number>
}

含义是:

  • explicit:只选择 selectedIds
  • allMatching:选择所有符合当前筛选条件的记录,但排除 excludedIds

这时批量删除请求不能只发送当前页面的 ID,而应发送筛选条件、选择模式和排除集合,或由服务端生成选择快照。

5.3 部分选中状态

当前可见行集合为 V,已选 ID 集合为 S

allSelected = |V| > 0 且 V 中每个 ID 都属于 S
someSelected = V 中至少一个 ID 属于 S,且不是 allSelected

对应代码:

const visibleIds = computed(() => sortedRows.value.map((row) => row.id))

const allVisibleSelected = computed(() => {
  const ids = visibleIds.value
  return ids.length > 0 && ids.every((id) => selectedIds.value.has(id))
})

const someVisibleSelected = computed(() => {
  const ids = visibleIds.value
  return ids.some((id) => selectedIds.value.has(id)) &&
    !allVisibleSelected.value
})

“全选”按钮只作用于当前可见行:

function toggleAllVisible() {
  const next = new Set(selectedIds.value)

  if (allVisibleSelected.value) {
    for (const id of visibleIds.value) {
      next.delete(id)
    }
  } else {
    for (const id of visibleIds.value) {
      next.add(id)
    }
  }

  selectedIds.value = next
}

如果筛选条件改变,已选但不可见的记录是否保留,必须明确规定。保留选择适合批量操作场景;自动清除则更简单,但可能让用户误以为数据丢失。无论采用哪种方案,都应在界面上说明批量操作范围。


六、编辑:草稿、校验和提交必须分离

6.1 直接修改原始行的问题

以下写法会在用户点击“保存”之前就改变表格数据:

// 不推荐:输入时直接修改业务数据
row.name = inputValue

如果用户点击取消,原始值已经被覆盖;如果保存请求失败,界面也可能显示一个服务端并未接受的值。

更可靠的生命周期是:

查看
  → 开始编辑
  → 复制草稿
  → 输入修改草稿
  → 本地校验
  → 提交服务端
  → 成功:更新原始数据
  → 失败:保留草稿并显示错误

6.2 编辑状态应按 ID 绑定

const editingId = ref<number | null>(null)
const draft = reactive<Partial<User>>({})
const editError = ref('')

function startEdit(row: User) {
  editingId.value = row.id
  Object.assign(draft, {
    name: row.name,
    role: row.role,
    salary: row.salary,
  })
  editError.value = ''
}

function cancelEdit() {
  editingId.value = null
  Object.keys(draft).forEach((key) => {
    delete draft[key as keyof typeof draft]
  })
  editError.value = ''
}

不能用行索引记录编辑状态,因为排序和筛选同样会改变行位置。

6.3 校验应在保存前完成

function validateDraft(): string {
  const name = String(draft.name ?? '').trim()
  const salary = Number(draft.salary)

  if (!name) return '姓名不能为空'
  if (!Number.isFinite(salary) || salary < 0) {
    return '薪资必须是大于等于 0 的数字'
  }

  return ''
}

校验失败时,不应该关闭编辑状态,也不应该更新原始记录。

6.4 保存中的并发保护

用户可能连续点击保存,或者切换编辑行时旧请求仍未完成。至少需要:

const saving = ref(false)

完整实现还应处理请求过期问题。对于同一行,可以使用版本号:

const saveVersion = ref(0)

async function saveEdit() {
  const error = validateDraft()

  if (error) {
    editError.value = error
    return
  }

  const row = users.value.find((item) => item.id === editingId.value)
  if (!row) return

  const version = ++saveVersion.value
  saving.value = true
  editError.value = ''

  try {
    const updated = await updateUserOnServer(row.id, {
      name: String(draft.name).trim(),
      role: draft.role as User['role'],
      salary: Number(draft.salary),
    })

    // 旧请求即使晚返回,也不能覆盖更新版本
    if (version !== saveVersion.value) return

    const index = users.value.findIndex((item) => item.id === updated.id)
    if (index !== -1) {
      users.value[index] = updated
    }

    cancelEdit()
  } catch (error) {
    if (version === saveVersion.value) {
      editError.value = error instanceof Error
        ? error.message
        : '保存失败,请稍后重试'
    }
  } finally {
    if (version === saveVersion.value) {
      saving.value = false
    }
  }
}

updateUserOnServer 可以是实际 API,也可以是开发阶段的模拟函数:

function updateUserOnServer(
  id: number,
  patch: Pick<User, 'name' | 'role' | 'salary'>,
): Promise<User> {
  return new Promise((resolve) => {
    window.setTimeout(() => resolve({ id, ...patch, active: true }), 400)
  })
}

生产环境中,更新接口可能返回服务端重新计算后的字段,例如更新时间、权限或格式化金额。因此成功后应优先使用服务端返回的完整记录,而不是盲目合并本地草稿。


七、一个可运行的 Vue 3 单文件组件

下面的示例实现了:

  • 类型化列模型;
  • 文本筛选;
  • 稳定排序;
  • 当前可见行全选;
  • 基于 ID 的选择;
  • 行内编辑;
  • 本地校验;
  • 可访问的表头排序按钮和复选框。

将它保存为 UserTable.vue,放入 Vue 3 + Vite + TypeScript 项目即可使用。

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

type Role = 'admin' | 'editor' | 'viewer'

type User = {
  id: number
  name: string
  role: Role
  salary: number
  active: boolean
}

type SortDirection = 'asc' | 'desc'

type Column<Row extends object> = {
  key: keyof Row
  label: string
  sortable?: boolean
  editable?: boolean
  format?: (value: Row[keyof Row], row: Row) => string
}

const users = ref<User[]>([
  { id: 1, name: '林晓', role: 'admin', salary: 24000, active: true },
  { id: 2, name: '周宁', role: 'editor', salary: 16000, active: true },
  { id: 3, name: '陈默', role: 'viewer', salary: 9000, active: false },
  { id: 4, name: '赵安', role: 'editor', salary: 18000, active: true },
])

const roleLabels: Record<Role, string> = {
  admin: '管理员',
  editor: '编辑者',
  viewer: '查看者',
}

const columns: Column<User>[] = [
  {
    key: 'name',
    label: '姓名',
    sortable: true,
    editable: true,
  },
  {
    key: 'role',
    label: '角色',
    sortable: true,
    editable: true,
    format: (value) => roleLabels[value as Role],
  },
  {
    key: 'salary',
    label: '薪资',
    sortable: true,
    editable: true,
    format: (value) => `¥${Number(value).toLocaleString('zh-CN')}`,
  },
  {
    key: 'active',
    label: '状态',
    sortable: true,
    format: (value) => (value ? '启用' : '停用'),
  },
]

const searchQuery = ref('')
const selectedIds = ref<Set<number>>(new Set())
const sortState = ref<{
  key: keyof User | null
  direction: SortDirection
}>({
  key: null,
  direction: 'asc',
})

const editingId = ref<number | null>(null)
const draft = reactive<Partial<User>>({})
const editError = ref('')
const saving = ref(false)

function compareValues(a: unknown, b: unknown): number {
  const aEmpty = a === null || a === undefined || a === ''
  const bEmpty = b === null || b === undefined || b === ''

  if (aEmpty && bEmpty) return 0
  if (aEmpty) return 1
  if (bEmpty) return -1

  if (typeof a === 'number' && typeof b === 'number') {
    return a - b
  }

  return String(a).localeCompare(String(b), 'zh-CN', {
    numeric: true,
    sensitivity: 'base',
  })
}

const filteredRows = computed(() => {
  const query = searchQuery.value.trim().toLocaleLowerCase()

  if (!query) return users.value

  return users.value.filter((row) => {
    const searchableText = [
      row.name,
      roleLabels[row.role],
      row.active ? '启用' : '停用',
    ]
      .join(' ')
      .toLocaleLowerCase()

    return searchableText.includes(query)
  })
})

const sortedRows = computed(() => {
  const rows = [...filteredRows.value]
  const { key, direction } = sortState.value

  if (!key) return rows

  const multiplier = direction === 'asc' ? 1 : -1

  return rows
    .map((row, index) => ({ row, index }))
    .sort((a, b) => {
      const result = compareValues(a.row[key], b.row[key])
      return result === 0 ? a.index - b.index : result * multiplier
    })
    .map(({ row }) => row)
})

const visibleIds = computed(() => sortedRows.value.map((row) => row.id))

const allVisibleSelected = computed(() => {
  const ids = visibleIds.value
  return ids.length > 0 && ids.every((id) => selectedIds.value.has(id))
})

const someVisibleSelected = computed(() => {
  const ids = visibleIds.value
  return ids.some((id) => selectedIds.value.has(id)) &&
    !allVisibleSelected.value
})

function sortBy(column: Column<User>) {
  if (!column.sortable) return

  if (sortState.value.key === column.key) {
    sortState.value.direction =
      sortState.value.direction === 'asc' ? 'desc' : 'asc'
  } else {
    sortState.value = {
      key: column.key,
      direction: 'asc',
    }
  }
}

function sortLabel(column: Column<User>): string {
  if (!column.sortable) return ''
  if (sortState.value.key !== column.key) return '未排序'
  return sortState.value.direction === 'asc' ? '升序' : '降序'
}

function toggleSelected(id: number) {
  const next = new Set(selectedIds.value)

  if (next.has(id)) {
    next.delete(id)
  } else {
    next.add(id)
  }

  selectedIds.value = next
}

function toggleAllVisible() {
  const next = new Set(selectedIds.value)

  if (allVisibleSelected.value) {
    for (const id of visibleIds.value) next.delete(id)
  } else {
    for (const id of visibleIds.value) next.add(id)
  }

  selectedIds.value = next
}

function cellText(row: User, column: Column<User>): string {
  const value = row[column.key]
  return column.format
    ? column.format(value, row)
    : String(value)
}

function startEdit(row: User) {
  editingId.value = row.id
  Object.assign(draft, {
    name: row.name,
    role: row.role,
    salary: row.salary,
  })
  editError.value = ''
}

function cancelEdit() {
  editingId.value = null
  Object.keys(draft).forEach((key) => {
    delete draft[key as keyof typeof draft]
  })
  editError.value = ''
}

function validateDraft(): string {
  const name = String(draft.name ?? '').trim()
  const salary = Number(draft.salary)

  if (!name) return '姓名不能为空'
  if (!Number.isFinite(salary) || salary < 0) {
    return '薪资必须是大于等于 0 的数字'
  }

  return ''
}

async function saveEdit() {
  const error = validateDraft()

  if (error) {
    editError.value = error
    return
  }

  const row = users.value.find((item) => item.id === editingId.value)
  if (!row) return

  saving.value = true
  editError.value = ''

  try {
    const updated = await updateUserOnServer(row.id, {
      name: String(draft.name).trim(),
      role: draft.role as Role,
      salary: Number(draft.salary),
    })

    const index = users.value.findIndex((item) => item.id === updated.id)
    if (index !== -1) users.value[index] = updated

    cancelEdit()
  } catch (error) {
    editError.value = error instanceof Error
      ? error.message
      : '保存失败,请稍后重试'
  } finally {
    saving.value = false
  }
}

function updateUserOnServer(
  id: number,
  patch: Pick<User, 'name' | 'role' | 'salary'>,
): Promise<User> {
  return new Promise((resolve) => {
    window.setTimeout(() => {
      resolve({
        id,
        ...patch,
        active: true,
      })
    }, 400)
  })
}
</script>

<template>
  <section aria-labelledby="user-table-title">
    <h2 id="user-table-title">用户列表</h2>

    <label for="user-search">筛选用户</label>
    <input
      id="user-search"
      v-model="searchQuery"
      type="search"
      placeholder="搜索姓名、角色或状态"
    />

    <p aria-live="polite">
      当前显示 {{ sortedRows.length }} 条,已选择
      {{ selectedIds.size }} 条
    </p>

    <table>
      <caption>用户及其角色、薪资和启用状态</caption>

      <thead>
        <tr>
          <th scope="col">
            <label>
              <span class="sr-only">选择当前显示的全部用户</span>
              <input
                type="checkbox"
                :checked="allVisibleSelected"
                :indeterminate="someVisibleSelected"
                :aria-checked="someVisibleSelected ? 'mixed' : allVisibleSelected"
                @change="toggleAllVisible"
              />
            </label>
          </th>

          <th
            v-for="column in columns"
            :key="String(column.key)"
            scope="col"
            :aria-sort="
              sortState.key === column.key
                ? sortState.direction === 'asc'
                  ? 'ascending'
                  : 'descending'
                : 'none'
            "
          >
            <button
              v-if="column.sortable"
              type="button"
              :aria-label="`按${column.label}排序,当前${sortLabel(column)}`"
              @click="sortBy(column)"
            >
              {{ column.label }}
              <span aria-hidden="true">
                {{ sortState.key === column.key
                  ? sortState.direction === 'asc' ? '↑' : '↓'
                  : '↕' }}
              </span>
            </button>

            <span v-else>{{ column.label }}</span>
          </th>

          <th scope="col">操作</th>
        </tr>
      </thead>

      <tbody>
        <tr v-for="row in sortedRows" :key="row.id">
          <td>
            <input
              type="checkbox"
              :checked="selectedIds.has(row.id)"
              :aria-label="`选择用户 ${row.name}`"
              @change="toggleSelected(row.id)"
            />
          </td>

          <td v-for="column in columns" :key="String(column.key)">
            <template v-if="editingId === row.id && column.editable">
              <input
                v-if="column.key === 'name'"
                v-model="draft.name"
                :aria-label="`${row.name} 的姓名`"
              />

              <select
                v-else-if="column.key === 'role'"
                v-model="draft.role"
                :aria-label="`${row.name} 的角色`"
              >
                <option value="admin">管理员</option>
                <option value="editor">编辑者</option>
                <option value="viewer">查看者</option>
              </select>

              <input
                v-else-if="column.key === 'salary'"
                v-model.number="draft.salary"
                type="number"
                min="0"
                :aria-label="`${row.name} 的薪资`"
              />

              <span v-else>{{ cellText(row, column) }}</span>
            </template>

            <span v-else>{{ cellText(row, column) }}</span>
          </td>

          <td>
            <template v-if="editingId === row.id">
              <button type="button" :disabled="saving" @click="saveEdit">
                {{ saving ? '保存中…' : '保存' }}
              </button>
              <button type="button" :disabled="saving" @click="cancelEdit">
                取消
              </button>
              <p v-if="editError" role="alert">{{ editError }}</p>
            </template>

            <button v-else type="button" @click="startEdit(row)">
              编辑
            </button>
          </td>
        </tr>

        <tr v-if="sortedRows.length === 0">
          <td :colspan="columns.length + 2">
            没有符合条件的用户
          </td>
        </tr>
      </tbody>
    </table>
  </section>
</template>

<style scoped>
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
</style>

这个组件的关键因果关系是:

  1. users 保存原始记录;
  2. filteredRows 根据搜索词产生筛选结果;
  3. sortedRows 对筛选结果复制后排序;
  4. 模板只渲染 sortedRows
  5. 选择使用 row.id,所以排序和筛选不会改变选择身份;
  6. 编辑写入 draft,保存成功后才替换 users 中的记录。

八、可访问性:语义、状态和键盘路径必须同时成立

8.1 使用原生表格语义

真正的二维数据应使用:

<table>
  <caption>...</caption>
  <thead>...</thead>
  <tbody>...</tbody>
</table>

其中:

  • <caption> 说明表格用途;
  • <th scope="col"> 表示列标题;
  • <th scope="row"> 可用于行标题;
  • <td> 表示数据单元格。

不要为了实现样式,把整张表格改成大量 div。如果使用 div 模拟表格,就需要自行实现角色、键盘导航、焦点移动和屏幕阅读器关系,维护成本明显更高。

8.2 排序状态不能只靠箭头

视觉上的 对屏幕阅读器没有可靠含义。列标题应使用:

<th scope="col" aria-sort="ascending">

aria-sort 的典型值包括:

  • ascending:升序;
  • descending:降序;
  • none:当前未排序;
  • other:不是常规升序或降序。

排序动作应使用原生 <button>,而不是给 <th> 绑定点击后依赖鼠标行为。原生按钮天然支持 Tab 聚焦和 Enter/空格操作。

8.3 复选框的三态

普通复选框只有选中和未选中,但表头“全选”在部分行被选中时还需要第三种状态:

<input
  type="checkbox"
  :checked="allVisibleSelected"
  :indeterminate="someVisibleSelected"
  :aria-checked="someVisibleSelected ? 'mixed' : allVisibleSelected"
/>

indeterminate 是 DOM 属性,不是普通业务字段。Vue 会把绑定值应用到元素属性上;如果使用自定义复选框组件,则需要确认组件是否把三态正确传递到底层 DOM。

8.4 动态内容要告知用户

筛选结果数量、保存完成、保存失败等状态可能不会自动被屏幕阅读器感知。可以使用:

<p aria-live="polite">
  当前显示 {{ sortedRows.length }} 条
</p>

错误信息应使用:

<p role="alert">保存失败,请稍后重试</p>

aria-live="polite" 适合结果数量变化;role="alert" 适合需要立即注意的错误。不要把整个表格设置为 role="alert",否则每次排序或输入都可能产生过量播报。

8.5 键盘交互的实际边界

原生按钮、输入框和选择框已经提供了基本键盘能力:

  • Tab 在控件之间移动;
  • Enter 激活按钮;
  • 空格切换复选框;
  • 输入框支持文本编辑;
  • select 支持选项选择。

如果进一步实现“单元格级键盘导航”,例如方向键在单元格之间移动,就不再是简单的原生表格,需要额外设计:

  • 当前焦点单元格;
  • 可编辑单元格和普通单元格的区别;
  • Tab 是否离开表格;
  • Escape 是否取消编辑;
  • 删除、复制、粘贴的语义;
  • 屏幕阅读器是否还能正确理解焦点位置。

因此,除非交互需求明确,否则优先使用原生控件,而不是自行构造完整的 grid widget。


九、筛选、排序和选择之间的状态关系

9.1 推荐的派生关系

可以把核心关系表示为:

flowchart TD
  A[原始 users] --> B[搜索筛选 filteredRows]
  B --> C[排序 sortedRows]
  C --> D[渲染可见行]
  D --> E[当前可见 ID visibleIds]
  E --> F[全选与部分选中状态]
  G[用户编辑输入] --> H[草稿 draft]
  H --> I[本地校验]
  I --> J[服务端保存]
  J --> A

这张图中,filteredRowssortedRows 是派生状态,通常使用 computeddraftselectedIds 是用户交互产生的独立状态,使用 refreactive;保存成功后,数据流才回到 users

9.2 搜索时是否清除选择

假设用户选中了 A、B 两行,然后搜索只匹配 C:

selectedIds = { A, B }
visibleIds  = { C }

此时:

allVisibleSelected = false
someVisibleSelected = false

这并不表示 A、B 被取消选择,而是表示当前可见行中没有已选记录。

如果批量工具栏显示“已选择 2 条”,用户应能理解这 2 条可能暂时不在当前筛选结果中。若产品不允许保留不可见选择,则应在筛选改变时明确清理,而不是让清理发生得无声无息。

9.3 编辑时排序字段变化

如果用户正在编辑薪资,而表格按薪资排序,保存成功后该行可能移动到新的位置。这是正确结果,因为排序是对最新数据重新计算的。

不正确的做法是为了“保持编辑行不动”而直接修改 DOM 位置或维护一套独立的显示顺序。那会让视图顺序与排序条件不一致。

如果移动造成用户迷失,可以在保存成功后:

  • 保留焦点;
  • 显示成功提示;
  • 滚动到更新后的行;
  • 或在编辑期间暂时锁定排序。

这些是交互取舍,不应通过破坏派生数据关系来解决。


十、远程数据表格:把查询状态作为请求的一部分

当数据量很大时,客户端不能先加载全部数据再筛选。此时服务端需要接收查询参数:

type Query = {
  search: string
  sortKey: string | null
  sortDirection: 'asc' | 'desc'
  page: number
  pageSize: number
}

查询结果通常包括:

type PageResult<Row> = {
  rows: Row[]
  total: number
}

服务端排序必须使用白名单映射,不能直接把用户输入拼接进 SQL:

const sortColumns: Record<string, string> = {
  name: 'name',
  salary: 'salary',
  role: 'role',
}

const sqlColumn = sortColumns[query.sortKey ?? 'name'] ?? 'name'

排序方向也只能从固定集合中选择:

const direction = query.sortDirection === 'desc' ? 'DESC' : 'ASC'

这不是 Vue 特性,而是服务端输入安全边界。列模型中的 key 可以用于前端状态,但不能未经验证直接变成 SQL 列名。

10.1 远程请求的竞态

用户快速输入:

输入 a → 请求 1
输入 ab → 请求 2
输入 abc → 请求 3

如果请求 1 最后返回,它可能覆盖请求 3 的结果。常见解决方案有两类。

使用 AbortController 取消旧请求:

let controller: AbortController | null = null

async function loadRows(query: Query) {
  controller?.abort()
  controller = new AbortController()

  loading.value = true
  errorMessage.value = ''

  try {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(query),
      signal: controller.signal,
    })

    if (!response.ok) {
      throw new Error(`请求失败:HTTP ${response.status}`)
    }

    const result = await response.json() as PageResult<User>
    users.value = result.rows
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      return
    }

    errorMessage.value = error instanceof Error
      ? error.message
      : '加载失败'
  } finally {
    loading.value = false
  }
}

也可以使用递增请求序号,只接受最新序号的结果。取消请求减少资源消耗,序号判断则可以防御某些无法真正取消的异步任务。

10.2 分页和全选的冲突

远程分页时,visibleIds 只包含当前页。此时“全选当前页”和“选择整个筛选结果”必须使用不同的操作文案,例如:

  • 选择当前页;
  • 已选择当前页 20 条;
  • 选择全部 3,248 条符合条件的记录。

如果界面只写“全选”,用户无法判断批量删除的范围,生产事故往往不是代码错误,而是语义不清。


十一、常见失败表现与诊断方法

1. 数字排序结果异常

表现:

100
20
3

原因: 使用了字符串比较。

诊断: 查看列值的运行时类型:

console.log(typeof row.salary, row.salary)

如果 API 返回 "100" 而不是 100,应在数据进入表格边界时进行规范化,而不是只在排序器中临时转换。

2. 排序后选中错误记录

表现: 选中一行后点击排序,复选框出现在另一条记录上。

原因: 选择集合保存的是索引或模板循环位置。

修复: 使用稳定的 id,并确保:

<tr v-for="row in rows" :key="row.id">

key 的作用是帮助 Vue 识别节点身份;它不应使用会随排序变化的索引。

3. 筛选后批量操作范围不明确

表现: 用户以为删除当前筛选结果,实际只删除了当前页;或者反过来。

原因: “可见行”“当前页”“全部匹配行”混用了。

诊断: 在操作请求发送前记录:

console.log({
  selectedIds: [...selectedIds.value],
  searchQuery: searchQuery.value,
  visibleIds: visibleIds.value,
})

然后检查请求协议到底表达的是 ID 集合还是筛选条件。

4. 保存失败但界面显示成功

表现: 本地行已经更新,刷新页面后又恢复旧值。

原因: 先改了原始数组,再发送请求,或者忽略了非 2xx 响应。

修复: 先保存,成功后再更新原始数据;对 fetch 必须显式检查 response.ok,因为 fetch 遇到 HTTP 400/500 通常不会自动抛异常。

5. 表头点击导致行被选中

表现: 点击排序按钮时,整行选择状态发生变化。

原因: 事件冒泡到行级点击处理器。

修复: 排序使用独立按钮,并在确实存在行级点击逻辑时使用:

<button type="button" @click.stop="sortBy(column)">
  排序
</button>

不过优先级仍应是避免把整行点击和复杂表格操作混在一起。

6. 屏幕阅读器不知道排序状态

表现: 视觉上有箭头,但读屏只读出“姓名”。

原因: 排序状态只通过图标表达,没有 aria-sort 和按钮可访问名称。

诊断: 使用浏览器无障碍树、键盘操作和实际屏幕阅读器测试,而不是只检查 HTML 是否存在 aria-* 属性。

7. 编辑输入被覆盖

表现: 用户输入过程中,刷新或排序后内容跳回旧值。

原因: 输入值直接绑定到原始行,或者用不稳定的索引维护编辑对象。

修复: 使用 draft 保存编辑过程,并以 id 识别编辑行;远程刷新时还需要规定是否允许覆盖未保存草稿。


十二、生产取舍:什么时候拆分组合式函数

示例组件适合说明完整数据流,但真实项目可以拆成:

useTableQuery()
  管理筛选、排序、分页和远程加载

useRowSelection()
  管理 selectedIds、全选和部分选中

useInlineEdit()
  管理 editingId、draft、校验和保存

DataTable.vue
  负责表格语义、列渲染和事件连接

拆分的依据不是“组件超过多少行”,而是状态是否有独立的生命周期和测试边界。例如选择逻辑不应该依赖具体的薪资输入框;编辑保存也不应该直接读取模板 DOM。

如果表格支持虚拟滚动,还需要重新检查:

  • 屏幕阅读器是否能理解未实际挂载的行;
  • 行索引和总行数是否正确暴露;
  • 键盘焦点是否会落到被回收的节点;
  • “全选”是否仍然表示完整数据集;
  • 选择和编辑状态是否脱离 DOM 生命周期保存。

虚拟滚动主要解决渲染大量 DOM 节点的问题,不会自动解决排序、筛选、选择、编辑或可访问性问题。


十三、实现自查的核心不变量

在代码评审或测试中,可以检查以下不变量:

  1. 原始数据不因排序改变语义顺序:排序只作用于副本或派生结果。
  2. 每个渲染行都有稳定 IDv-forkey 与选择、编辑使用同一身份来源。
  3. 筛选和排序顺序固定:客户端通常是筛选后排序,再分页。
  4. 选择状态独立于可见顺序:不能使用索引表示选择。
  5. 编辑输入先进入草稿:保存成功前不改变已确认业务数据。
  6. 失败状态可恢复:保存失败后草稿仍在,用户可以重试或取消。
  7. 异步结果不会倒置覆盖:取消旧请求或验证请求版本。
  8. 排序状态可感知:表头有 aria-sort,排序入口是键盘可操作按钮。
  9. 全选范围可解释:当前可见行、当前页和全部匹配记录不能混为一谈。
  10. 空结果和错误分开:没有数据不等于请求失败。

这些不变量比某个具体表格组件的 API 更稳定。只要它们成立,表格从本地数组迁移到远程分页、从简单文本列扩展到可编辑列时,结构仍然可控。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。