资源列表的稳定身份:类型归一、树形数据与跨页多选
本文目录
文件管理、日历列表和管理后台经常会把文件夹、资源和可勾选行放在同一张列表里。几个容易被忽略的基础选择会影响正确性:行身份应该稳定,服务端 DTO 不应被渲染代码原地改写,选择状态要先定义是否跨页保留,未知格式要有安全兜底。
用明确类型替代“可空字段猜测”
不要让每个模板自己判断 resource == null、type == 1、扩展名等组合,再在循环里改写原对象。先把接口结果映射成 UI 视图模型:
type FileFormat = 'image' | 'video' | 'pdf' | 'spreadsheet' | 'text' | 'other';
type ResourceRow =
| { kind: 'folder'; id: string; name: string; parentId: string | null }
| {
kind: 'file';
id: string;
name: string;
format: FileFormat;
mimeType: string | null;
previewUrl: string | null;
};
function classifyFormat(extension: string): FileFormat {
const ext = extension.toLowerCase().replace(/^\./, '');
if (['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext)) return 'image';
if (['mp4', 'webm', 'mov'].includes(ext)) return 'video';
if (ext === 'pdf') return 'pdf';
if (['xls', 'xlsx', 'csv'].includes(ext)) return 'spreadsheet';
if (['txt', 'md'].includes(ext)) return 'text';
return 'other';
}
classifyFormat 的返回类型使用显式 FileFormat 联合类型;DTO 到 view model 的转换再调用它,避免由后端对象的临时字段决定页面状态。
映射时为每种记录建立一个新 view model,不要把 format 从 xlsx 改写成 spreadsheet 写回 API 数据。展示分类只是 UI 标签,不是文件真实性证明;服务端仍要校验真实内容、访问权限和允许的处理方式。未知类型显示通用图标与受限操作,不要因为没识别就默认允许预览或执行。
文件名、描述和远程预览 URL 可能来自用户输入。使用文本节点渲染名称;预览只允许经过服务端校验、符合站点 URL 策略的地址。不能把服务端返回的字符串拼进 innerHTML,也不能只看扩展名就信任文件内容。
id 才是行身份,不是数组位置
列表过滤、排序、插入和分页都会改变数组位置。Vue 的 v-for 应使用稳定且同级唯一的原始值 key,例如记录 ID;不要用 index,也不要用每次 render 新建的对象作为 key。
<template>
<ul>
<li v-for="row in visibleRows" :key="row.id">
<label>
<input
type="checkbox"
:checked="selectedIds.has(row.id)"
@change="toggleRow(row.id, $event.target.checked)"
/>
<span>{{ row.name }}</span>
</label>
</li>
</ul>
</template>
如果两个文件可能重名,文件名就不是身份;ID 应由服务端定义并保证在当前集合中稳定唯一。若记录 ID 是较大的整数,不要为了方便一律 Number(id);JavaScript Number 的整数精度有限,保持 API 的字符串标识可避免截断。嵌套目录要明确区分“节点 ID”和“路径”,移动/重命名不会改变选择身份的语义也要写清楚。
先定义多选的作用范围
复选状态至少有三种常见产品语义:只对当前页有效、跨页保留,或筛选条件变化时清空。实现前把行为写成可测试规则。选择集合只保存 ID;整行对象可能随后被刷新、替换或从结果中删除,不能作为长期选择事实。
下面的函数把当前可见页的选择与其它页的既有选择分开:
const selectedIds = new Set();
function syncVisibleSelection(visibleIds, checkedVisibleIds) {
for (const id of visibleIds) selectedIds.delete(id);
for (const id of checkedVisibleIds) selectedIds.add(id);
}
function toggleRow(id, checked) {
if (checked) selectedIds.add(id);
else selectedIds.delete(id);
}
visibleIds 必须是当前可操作页面的全部行 ID;这样取消当前页某行不会误删其它页的选择。若产品是“只选当前页”,直接在页码或筛选改变时重置集合即可。全选框要区分全选、未选和部分选中状态;禁用行、已删除行和不可访问行不应被无声纳入批量操作。
提交批量删除、下载或移动时,把明确的 ID 列表发给服务端;服务端逐项检查当前用户权限、资源状态、父子关系和操作限制。前端 checkbox 只是交互状态,不是授权证明。请求失败时保留用户选择并报告哪些操作成功/失败,避免假装全部完成。
目录和文件不要伪装成同一种记录
目录可导航,文件可预览/下载;它们的操作、字段和权限不同。用联合类型或可靠的 kind 字段区分,避免依赖字段是否为空推断类型。文件夹树还要定义循环引用、跨目录移动、分页加载和软删除后的行为。
当列表接口返回更新数据时:
- 用纯映射函数把 DTO 转成
ResourceRow[],不修改原数组和嵌套对象。 - 在映射边界统一处理缺省值、未知类型和无效日期,并保留原始 ID。
- 过滤/排序得到派生的
visibleRows,不要在多个组件里各自实现一套规则。 - 选中行单独存 ID;刷新后把已不存在或已无权操作的 ID 清理或明确标记失效。
- 对文件和目录分别渲染动作按钮及可访问名称,避免点击行与勾选按钮互相触发。
回归用例
- 同名文件、不同 ID;相同 ID 更新名称;插入/排序/筛选后,选择仍指向原记录。
- 当前页和跨页选择按产品规则保留或清空;取消一页的选择不误删其它页。
- 空
resource、未知扩展名、缺失父目录和损坏数据有安全的显示兜底。 - 用户名/文件名包含 HTML、控制字符、超长路径或危险 URL 时只作为文本展示或被拒绝。
- 删除后刷新列表,陈旧 ID 不会误操作新的同名记录。
- 服务端拒绝其中一个 ID 时显示部分失败,且不伪造成功状态。
- 键盘可以勾选、批量选择状态有可读标签,筛选/排序后焦点不跳到另一条记录。
参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。