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 排序的数学条件
给定两条记录 a 和 b,比较器 compare(a, b) 应满足以下基本性质:
- 自反性:
compare(a, a) = 0 - 反对称性:如果
compare(a, b) < 0,则应有compare(b, a) > 0 - 传递性:如果
a < b且b < 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 “全选”不是唯一语义
表格中的“全选”至少有两种解释:
- 选择当前筛选结果中的全部行;
- 选择数据集中的全部行,包括当前页面和未加载的行。
客户端完整加载数据时,可以把“全选”定义为“选择当前可见行”。远程分页时,这个定义就不够了,因为当前页面只代表整个数据集的一部分。
远程大数据表格通常需要:
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>
这个组件的关键因果关系是:
users保存原始记录;filteredRows根据搜索词产生筛选结果;sortedRows对筛选结果复制后排序;- 模板只渲染
sortedRows; - 选择使用
row.id,所以排序和筛选不会改变选择身份; - 编辑写入
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
这张图中,filteredRows 和 sortedRows 是派生状态,通常使用 computed;draft 和 selectedIds 是用户交互产生的独立状态,使用 ref 或 reactive;保存成功后,数据流才回到 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 节点的问题,不会自动解决排序、筛选、选择、编辑或可访问性问题。
十三、实现自查的核心不变量
在代码评审或测试中,可以检查以下不变量:
- 原始数据不因排序改变语义顺序:排序只作用于副本或派生结果。
- 每个渲染行都有稳定 ID:
v-for的key与选择、编辑使用同一身份来源。 - 筛选和排序顺序固定:客户端通常是筛选后排序,再分页。
- 选择状态独立于可见顺序:不能使用索引表示选择。
- 编辑输入先进入草稿:保存成功前不改变已确认业务数据。
- 失败状态可恢复:保存失败后草稿仍在,用户可以重试或取消。
- 异步结果不会倒置覆盖:取消旧请求或验证请求版本。
- 排序状态可感知:表头有
aria-sort,排序入口是键盘可操作按钮。 - 全选范围可解释:当前可见行、当前页和全部匹配记录不能混为一谈。
- 空结果和错误分开:没有数据不等于请求失败。
这些不变量比某个具体表格组件的 API 更稳定。只要它们成立,表格从本地数组迁移到远程分页、从简单文本列扩展到可编辑列时,结构仍然可控。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 文件上传:选择、拖拽、分片、进度、取消和重试
- 下一篇:Vue 长列表虚拟化:可视区、动态高度、滚动锚点和性能
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论