级联选择和穿梭框组件怎样设计?
级联选择把树形数据按层展开成几列,选中一项就用它的子节点生成下一列,值是从根到当前节点的路径数组;子节点可以在选中时懒加载,搜索时要把每条路径拍平再匹配。穿梭框是左右两个可多选列表,内部只维护“已选 ID 集合”和两侧各自的勾选项,移动时只改这个集合,所以顺序和数据都不会被打乱;两侧列表按 WAI-ARIA 的多选 listbox 模式提供键盘操作。
简答
级联选择最常见的是选省市区:先选省,右边出现这个省的市,再选市,又出现区。数据本身是一棵树,组件把它按层摆成几列,选中哪一项,就拿它的子节点生成下一列。组件的值是从根到选中节点的路径,比如
['zhejiang', 'hangzhou', 'xihu']。这样设计的原因是,同名的节点在不同上级下可能都有,只存最后一级的 ID 不够;而存路径以后,回显时按路径逐层找就能恢复每一列。子节点多或者要从接口取时,可以在选中某一项时再懒加载下一列。
穿梭框是左右两个可多选的列表,把左边勾选的项移到右边。实现时不要真的把数据从一个数组搬到另一个数组,而是只维护一个“已选 ID 集合”,两侧列表都从原始数据按这个集合过滤出来,这样顺序不会乱,搜索和全选也只是在过滤结果上操作。
要注意数据量。级联的搜索要先把每条路径拍平才能匹配;穿梭框数据上千时,每侧列表要做虚拟滚动。键盘操作上,两侧列表可以按 WAI-ARIA 的多选 listbox 模式实现。
级联选择的数据和值
下面的例子做两件事:根据当前路径生成每一列要显示的选项;以及把树拍平成“完整路径”的列表,用来搜索。
const options = [
{
value: 'zj',
label: '浙江',
children: [
{ value: 'hz', label: '杭州', children: [{ value: 'xh', label: '西湖区' }] },
{ value: 'nb', label: '宁波' },
],
},
{ value: 'js', label: '江苏', children: [{ value: 'nj', label: '南京' }] },
];
// 第一列永远是根节点;之后每选中一项,就用它的 children 作为下一列。
function columnsOf(path) {
const columns = [options];
let current = options;
for (const value of path) {
const node = current.find((item) => item.value === value);
if (!node || !node.children) break;
columns.push(node.children);
current = node.children;
}
return columns;
}
function flattenPaths(nodes, prefix = [], result = []) {
for (const node of nodes) {
const path = [...prefix, node];
if (node.children) flattenPaths(node.children, path, result);
else result.push(path);
}
return result;
}
const columns = columnsOf(['zj', 'hz']);
console.log(columns.map((col) => col.map((item) => item.label).join(',')).join(' | ')); // 浙江,江苏 | 杭州,宁波 | 西湖区
const keyword = '杭州';
const matched = flattenPaths(options).filter((path) => path.some((node) => node.label.includes(keyword)));
console.log(matched.map((path) => path.map((node) => node.label).join(' / ')).join(';')); // 浙江 / 杭州 / 西湖区
为什么搜索要先拍平?因为用户搜“杭州”时,想看到的是“浙江 / 杭州 / 西湖区”这样一整条可选路径,而不是孤零零的一个“杭州”。拍平以后每一项就是一条完整路径,匹配和显示都直接。例子里只把叶子当作可选结果,如果业务允许选中任意一级,就在每个节点处都把路径收进去。
columnsOf 只依赖路径,所以回显很简单:后端返回 ['zj', 'hz', 'xh'],组件按它逐层查找就能恢复三列和每列的高亮项。
懒加载下一列
省市区这种数据可以一次给全,但组织架构、商品类目往往很大,要在选中某项时再请求它的子节点。节点上标一个 isLeaf 或“有子节点”,选中非叶子节点时请求,拿到结果写回这个节点的 children,再重新算列。
这里有个容易漏的情况:用户连续快速点了两个不同的省,第一个请求后回来,会把错误的市列表显示出来。可以给请求带上它所属的路径,结果回来时如果当前路径已经变了就丢弃。回显时,如果中间几级还没加载,要按路径依次请求,或者让后端直接返回每一级的标签。
穿梭框为什么只维护一个集合
穿梭框直观的写法是两个数组,移动时从左边删、往右边加。可是这样移动几次以后,左边的顺序就乱了,搜索框、全选、禁用项也都要在两个数组上各写一遍。
更简单的做法是数据源只有一份,组件状态只有三个:已选 ID 集合,左侧勾选的 ID,右侧勾选的 ID。
const data = [
{ key: 1, title: '张三' },
{ key: 2, title: '李四' },
{ key: 3, title: '王五' },
{ key: 4, title: '赵六' },
];
let targetKeys = new Set();
const left = () => data.filter((item) => !targetKeys.has(item.key));
const right = () => data.filter((item) => targetKeys.has(item.key));
function moveToRight(keys) {
targetKeys = new Set([...targetKeys, ...keys]);
}
function moveToLeft(keys) {
const removing = new Set(keys);
targetKeys = new Set([...targetKeys].filter((key) => !removing.has(key)));
}
moveToRight([3, 1]);
console.log(left().map((item) => item.title).join(',')); // 李四,赵六
console.log(right().map((item) => item.title).join(',')); // 张三,王五
moveToLeft([1]);
console.log(left().map((item) => item.title).join(',')); // 张三,李四,赵六
先移过去的是“王五”,后移的是“张三”,右侧显示时仍然按原始顺序,移回左边的“张三”也回到了原来的位置。因为两侧都是从同一份 data 过滤出来的,顺序天然一致。
如果业务要求右侧按“添加的先后”排列,就把 targetKeys 换成数组,右侧按数组顺序取数据,左侧仍从 data 过滤。搜索和“全选”也只作用于当前过滤后的可见项,并且跳过禁用项,否则用户会把看不见的数据一起移走。
大数据量和可访问性
穿梭框两侧一次显示上千项会卡,要对每侧列表做虚拟滚动,见《定高虚拟列表怎样手写?怎样根据 scrollTop 算出要渲染哪几行?》。级联选择每一列一般不长,但搜索结果可能很多,同样要限制条数或虚拟化。
可访问性方面,穿梭框两侧可以各自是一个多选 listbox:容器用 role="listbox" 和 aria-multiselectable="true",每项是 option,用 aria-selected 表示是否勾选。WAI-ARIA APG 推荐的多选键盘模型是方向键移动焦点、空格切换当前项,Shift 加方向键和 Ctrl+A 是可选的批量选择快捷键。中间的移动按钮用原生 button。带输入框的级联搜索则属于 combobox,键盘和 ARIA 行为见《可访问的 Combobox 需要哪些键盘和 ARIA 行为?》。
常见追问
级联选择允许选中非叶子节点怎么办?
加一个配置,比如“选中任意一级即改变值”。这时点击非叶子节点既展开下一列又更新值,值是到这一级为止的路径。搜索拍平时也要把每个中间节点的路径收进结果。
级联选择要支持多选怎么设计?
值变成路径的数组,每一列的选项前加复选框,父子之间的勾选联动和半选和树组件完全一样,见《树组件怎样设计?上万个节点时虚拟树怎样实现?》。显示时选中项太多可以折叠成“+N”。