多选交互设计:从命令模式到单向数据流

2 分钟阅读
·

云盘 Web 端重构中,用命令模式与单向数据流实现接近 Google Drive / 本地文件系统的多选交互:状态模型、命令对象、reducer、DOM 事件适配层的拆分与选择状态转换实例。

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

文件列表需要维护 items 集合和 selection 集合。鼠标、键盘和拖框等输入只描述一次操作,状态层根据操作产生新的选择结果。本文先定义这条命令式状态路径;下一篇再将列表渲染为虚拟滚动窗口,最后在文件选择交互实现中基于该窗口处理框选和键盘选择。

实现分为四层: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 风格的结构如下:

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 天然去重。
  • anchorfocus 是两件事。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 };
}

anchorfocus 在普通单击和 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 决定。

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 回调中同步修改选择状态。后续的虚拟滚动文件选择交互继续复用这条状态路径。


517 字 · 59 段落
ximing

Written by ximingFollow onGitHub

相关文章