---
title: 多选交互设计：从命令模式到单向数据流
date: "2015-10-10 21:50"
tags: ["架构"]
published: true
description: 云盘 Web 端重构中，用命令模式与单向数据流实现接近 Google Drive / 本地文件系统的多选交互：状态模型、命令对象、reducer、DOM 事件适配层的拆分与选择状态转换实例。
---

云盘 Web 端的文件列表包含单击选中、Ctrl+click 多选、Shift+click 连续区间选、Ctrl+A 全选、拖框选和方向键移动焦点。早期实现让各个 DOM 事件直接读写选中样式，状态变化分散在回调中；右侧操作栏（删除、重命名、移动）也无法从单一位置读取当前选择。

文件列表需要维护 `items` 集合和 `selection` 集合。鼠标、键盘和拖框等输入只描述一次操作，状态层根据操作产生新的选择结果。本文先定义这条命令式状态路径；[下一篇](./10-24-文件管理器虚拟滚动从零实现列表与缩略图双模式.md)再将列表渲染为虚拟滚动窗口，最后在[文件选择交互实现](./10-17-云盘文件选择交互实现.md)中基于该窗口处理框选和键盘选择。

实现分为四层：state model、command、reducer、DOM 事件适配层。分层借鉴 Flux 的单向数据流，但使用原生 JavaScript 的对象与函数即可，不依赖 React 或函数式组件。

## 状态模型

一个文件列表的选中交互，拆开来看需要知道几件事：

1. items：当前列表里有哪些文件/文件夹，每个有一个稳定 id
2. selection：当前被选中的 id 集合
3. anchor：连续选择的起点，shift+click 时用来确定区间一端
4. focus：当前焦点项，键盘移动的锚点
5. viewMode：当前是 grid 还是 list，影响拖框和键盘移动的方向计算

写成 TypeScript 风格的结构如下：

```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 只处理语义明确的操作。

```typescript
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，返回新状态。整个文件列表的选择状态变化都经过这个函数。

```typescript
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，是三套完全不同的语义：

```typescript
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`：

```typescript
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 和浏览器之间的一层翻译。它负责：

1. 监听 DOM 事件（click、mousedown->mouseup 拖框、keydown）
2. 从事件中提取必要信息，组装成 command
3. 将 command 交给 reducer
4. 把产出的新 state 交给 view 渲染

适配层不处理选择规则。以拖框选为例，它在 mousedown 时记录起始坐标，mousemove 时计算矩形，mouseup 时根据矩形与布局计算命中的 ids，组装成 `{ type: 'BOX_SELECT', ids: [...], additive: ctrlKey || metaKey }` 并交给 reducer。selection 是替换还是与已有选择合并，由 `handleBoxSelect` 决定。

```typescript
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 回调中同步修改选择状态。后续的[虚拟滚动](./10-24-文件管理器虚拟滚动从零实现列表与缩略图双模式.md)和[文件选择交互](./10-17-云盘文件选择交互实现.md)继续复用这条状态路径。
