云盘 Web 端的文件列表包含单击选中、Ctrl+click 多选、Shift+click 连续区间选、Ctrl+A 全选、拖框选和方向键移动焦点。早期实现让各个 DOM 事件直接读写选中样式,状态变化分散在回调中;右侧操作栏(删除、重命名、移动)也无法从单一位置读取当前选择。
文件列表需要维护 items 集合和 selection 集合。鼠标、键盘和拖框等输入只描述一次操作,状态层根据操作产生新的选择结果。本文先定义这条命令式状态路径;下一篇再将列表渲染为虚拟滚动窗口,最后在文件选择交互实现中基于该窗口处理框选和键盘选择。
实现分为四层:state model、command、reducer、DOM 事件适配层。分层借鉴 Flux 的单向数据流,但使用原生 JavaScript 的对象与函数即可,不依赖 React 或函数式组件。
状态模型
一个文件列表的选中交互,拆开来看需要知道几件事:
- items:当前列表里有哪些文件/文件夹,每个有一个稳定 id
- selection:当前被选中的 id 集合
- anchor:连续选择的起点,shift+click 时用来确定区间一端
- focus:当前焦点项,键盘移动的锚点
- viewMode:当前是 grid 还是 list,影响拖框和键盘移动的方向计算
写成 TypeScript 风格的结构如下:
interface FileItem {
id: string;
name: string;
type: 'file' | 'folder';
}
type ViewMode = 'grid' | 'list';
interface SelectionState {
items: FileItem[];
selection: Set<string>;
anchor: string | null;
focus: string | null;
viewMode: ViewMode;
}几个容易产生歧义的地方先交代清楚:
selection用 Set 而不用数组。选中判断是高频操作,Set 的has是 O(1),数组的indexOf是 O(n),列表长了之后差别就出来了。并且同一个 id 点两次不能出现两次,Set 天然去重。anchor和focus是两件事。focus 是当前键盘焦点所在位置,不按 shift 的时候不影响 selection;anchor 是连续选择段的起点,只在 shift+click 时用到。Google Drive 里面焦点和当前选中项经常不重合——你 Ctrl+click 选中了第 1 和第 5 项,焦点可能停在第 1 项,但 anchor 取决于你最后一次没有按 Ctrl 的点击落在哪里。- viewMode 放进来是因为 grid 模式下拖框的范围计算依赖列数,list 模式下没有这个问题。
Command
所有用户输入都先由 DOM 事件适配层翻译为 command。command 是普通对象,必须有一个 type 字段,其余字段是 reducer 计算新状态所需的参数。
mousedown 只描述鼠标在某个坐标按下,并不表示“选择某一项”。适配层负责把原始事件翻译为 command;reducer 只处理语义明确的操作。
type SelectionCommand =
| { type: 'CLICK_ITEM'; id: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }
| { type: 'KEY_DOWN'; key: 'a' | 'Enter' | 'ArrowUp' | 'ArrowDown' | 'Escape'; ctrlKey: boolean; metaKey: boolean }
| { type: 'BOX_SELECT'; ids: string[]; additive: boolean }
| { type: 'CLEAR_SELECTION' };几点设计上的取舍:
- CLICK_ITEM 没有拆成 CLICK 和 CTRL_CLICK 两种 command。如果拆开,在 macOS 上要额外处理 metaKey 与 ctrlKey 的映射。将 modifier keys 作为字段传入 command 后,判断逻辑集中在 reducer 一处。
- BOX_SELECT 的
ids是适配层根据拖框的矩形范围和 items 的布局算出来的,reducer 不做几何计算,只听结果。这是适配层该干的事。 - DELETE、RENAME、MOVE 也可定义为 command,但它们属于“选中之后执行什么”的另一组操作,不在本文的选择状态模型中。
Reducer
reducer 是纯函数 (state, command) => state,给定旧状态和 command,返回新状态。整个文件列表的选择状态变化都经过这个函数。
function selectionReducer(state: SelectionState, command: SelectionCommand): SelectionState {
switch (command.type) {
case 'CLICK_ITEM':
return handleItemClick(state, command);
case 'KEY_DOWN':
return handleKeyDown(state, command);
case 'BOX_SELECT':
return handleBoxSelect(state, command);
case 'CLEAR_SELECTION':
return { ...state, selection: new Set(), anchor: null, focus: null };
default:
return state;
}
}每个 case 都计算新的 selection、anchor 和 focus,再与旧 state 合并返回。函数没有副作用,不访问 DOM 或 API;相同输入始终得到相同输出。因此测试只需传入 state 与 command 并比对返回值,不必模拟浏览器环境。
handleItemClick 是整个 reducer 里分支最多的一个,因为单击这件事,按没按 Ctrl/ Cmd、按没按 Shift,是三套完全不同的语义:
function handleItemClick(
state: SelectionState,
command: { id: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }
): SelectionState {
const { id, ctrlKey, metaKey, shiftKey } = command;
// 直接接受两种平台的修饰键,事件适配层不需要判断运行平台。
const multi = metaKey || ctrlKey;
if (shiftKey) {
// Shift+click:以 anchor 到当前 id 的连续区间替换 selection
return rangeSelect(state, state.anchor ?? id, id);
}
if (multi) {
// ctrl/meta+click:切换单项的选中状态
const next = new Set(state.selection);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { ...state, selection: next, anchor: id, focus: id };
}
// 普通单击:替换式单选
return {
...state,
selection: new Set([id]),
anchor: id,
focus: id,
};
}rangeSelect 是 Shift+click 的核心。它拿到起点和终点两个 id,找到它们在 items 数组里的下标,并以这个闭区间替换当前 selection:
function rangeSelect(state: SelectionState, startId: string, endId: string): SelectionState {
const ids = state.items.map((item) => item.id);
const startIdx = ids.indexOf(startId);
const endIdx = ids.indexOf(endId);
if (startIdx === -1 || endIdx === -1) {
return state;
}
const [from, to] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
const next = new Set<string>();
for (let i = from; i <= to; i++) {
next.add(ids[i]);
}
return { ...state, selection: next, focus: endId };
}anchor 和 focus 在普通单击和 Ctrl+click 之后都会更新到当前项。这样下一步 shift+click 才有了区间的起点。
Demo 为了让 reducer 状态能直接展示和序列化,把 selection 存为有序的数字数组,再在渲染阶段派生 Set 做 O(1) 判断;两种表示的选择语义相同。真实业务若以稳定 id 存储,还应额外维护 id → index 映射,避免每次范围选择都扫描整个数组。
一个状态转换的例子
假设当前列表有 5 个文件 ['a','b','c','d','e'],初始状态:
selection = {}
anchor = null
focus = null第一次操作:单击 ‘c’。command { type: 'CLICK_ITEM', id: 'c', ctrlKey: false, shiftKey: false }。走普通单击分支:
selection = {'c'}
anchor = 'c'
focus = 'c'第二次操作:Shift+click ‘e’。command { type: 'CLICK_ITEM', id: 'e', ctrlKey: false, shiftKey: true }。rangeSelect(state, 'c', 'e') 得到 c 到 e 的下标 2 到 4:
selection = {'c', 'd', 'e'}
anchor = 'c'
focus = 'e'第三次操作:Ctrl+click ‘a’。command { type: 'CLICK_ITEM', id: 'a', ctrlKey: true, shiftKey: false }。切换 ‘a’ 的选中状态,‘a’ 不在当前 selection 内,因此加入:
selection = {'a', 'c', 'd', 'e'}
anchor = 'a'
focus = 'a'第四次操作:Shift+click ‘c’。command { type: 'CLICK_ITEM', id: 'c', ctrlKey: false, shiftKey: true }。此时 anchor 已经更新为 ‘a’(上次 Ctrl+click 设定),rangeSelect(state, 'a', 'c') 以 a 到 c 的下标 0 到 2 替换当前选择:
selection = {'a', 'b', 'c'}
anchor = 'a'
focus = 'c'这里采用的是范围替换策略:Shift+click 的结果始终是 anchor 到当前项的连续区间。若产品需要“在已有选择上追加区间”,可在 rangeSelect 中从 new Set(state.selection) 开始;两种策略需要在 reducer 中固定,不能由 DOM 节点的当前样式决定。
DOM 事件适配层
DOM 事件适配层是 reducer 和浏览器之间的一层翻译。它负责:
- 监听 DOM 事件(click、mousedown->mouseup 拖框、keydown)
- 从事件中提取必要信息,组装成 command
- 将 command 交给 reducer
- 把产出的新 state 交给 view 渲染
适配层不处理选择规则。以拖框选为例,它在 mousedown 时记录起始坐标,mousemove 时计算矩形,mouseup 时根据矩形与布局计算命中的 ids,组装成 { type: 'BOX_SELECT', ids: [...], additive: ctrlKey || metaKey } 并交给 reducer。selection 是替换还是与已有选择合并,由 handleBoxSelect 决定。
function attachFileList(container: HTMLElement, dispatch: (command: SelectionCommand) => void) {
let boxStart: { x: number; y: number } | null = null;
container.addEventListener('mousedown', (e: MouseEvent) => {
const itemEl = (e.target as HTMLElement).closest('[data-item-id]');
if (!itemEl) {
// 点空白区域,开始拖框
boxStart = { x: e.clientX, y: e.clientY };
return;
}
// 点在某一项上,让 click 事件去处理
});
container.addEventListener('mousemove', (e: MouseEvent) => {
if (!boxStart) return;
const rect = computeRect(boxStart, { x: e.clientX, y: e.clientY });
highlightIdsInRect(container, rect); // 视觉反馈,不改状态
});
container.addEventListener('mouseup', (e: MouseEvent) => {
if (!boxStart) return;
const rect = computeRect(boxStart, { x: e.clientX, y: e.clientY });
const ids = collectIdsInRect(container, rect);
boxStart = null;
if (ids.length > 0) {
dispatch({
type: 'BOX_SELECT',
ids,
additive: e.ctrlKey || e.metaKey,
});
}
});
container.addEventListener('click', (e: MouseEvent) => {
const itemEl = (e.target as HTMLElement).closest('[data-item-id]');
if (!itemEl) {
dispatch({ type: 'CLEAR_SELECTION' });
return;
}
const id = itemEl.getAttribute('data-item-id')!;
dispatch({
type: 'CLICK_ITEM',
id,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
shiftKey: e.shiftKey,
});
});
}键盘事件的适配也类似:按键与 Ctrl/Cmd 修饰被组装成 KEY_DOWN command。Escape 清空、Enter 打开当前焦点项、方向键移动焦点等语义都在 reducer 中定义;适配层只翻译物理按键。
为什么要这么拆
这个问题值得单独讲,因为刚上手的时候很容易把 reducer 的逻辑散到 DOM 事件监听器里,也能跑,早期我们就是这么干的。
拆开的核心动力是「谁对状态变化负责」。如果不拆,状态变化散落在各个事件回调里:
- click 回调直接改 selection
- keydown 回调直接改 selection 和 focus
- 拖框 mouseup 回调直接改 selection
这样写一个两个还守得住,加到五个十个,你没法一次看完「一次操作之后 selection 到底变成什么样」。调试的时候得在每个回调里打断点,看现场。更麻烦的是同样的逻辑会重复出现:Ctrl+click 的切换逻辑在 click 回调里写了一遍,万一以后加一个右键菜单的「切换选中」又得再写一遍。
拆开之后:
- reducer 是状态变化的唯一入口,想看怎么变的,一个 switch 看完
- 适配层只负责翻译输入,可以独立替换(比如改成触屏适配,reducer 不动)
- 业务规则集中在一处,测试可以针对 reducer 写,不依赖浏览器
这个拆法也有代价:文件列表本身这么拆其实偏重了。如果只有一两种交互,上 reducer 是不划算的。但文件列表天然不只两三种交互——单击、多选、连续选、全选、拖框、键盘移动,加起来的组合不算少,State 又是多组件共享的(列表本体、底栏按钮、右键菜单都要读 selection),这个代价在我们的场景下是值得付的。
最后
这套东西在云盘 Web 端跑了快三个月,交互层面没有出过什么大毛病。事后看有几个可以做得更好的地方:一是 reducer 里 handleItemClick 的几个分支可以再抽成独立的小函数,switch 那段会更干净;二是键盘移动的焦点逻辑应该和选中逻辑写成两个 reducer 组合而不是揉在一起,当初赶时间没拆;三是 rangeSelect 遇到过滤/排序后的列表时,区间计算必须用当前展示的 items 顺序而不是原始数据顺序,这一点最早漏了,后来补的。
整体结构让新增交互只需增加 command 分支和适配层事件,而不必在多个 DOM 回调中同步修改选择状态。后续的虚拟滚动和文件选择交互继续复用这条状态路径。

