测试首页 / SELMULTI01 · 多选对比(M29 SELECTLAB01 的多选版)

下拉框多选实现方式对比:6 种做法并排跑一遍

同一个 32 项中文检测项目库,喂给 6 种下拉框实现,这一轮全部改成多选。每块都能:点开、筛选、 勾好几项、取消其中一项、看多值回显,再用内置 mini 表单留空提交, 亲眼看到这一家在 select[multiple][required](「至少选一项」)上的真实反应。 事实标注来自源码走查 + Chromium 实测(单选基线见 M27 / M29 报告);本页只做选型依据,不改任何业务代码。

怎么用(多选路径):从上到下逐块点开 → 在每块面板里敲一个关键词(比如「铬」)→ 勾一项 → 换关键词再敲一次(这是多选最容易踩的坑)→ 勾第二项 → 看触发器里已选项怎么显示、有几个能直接点掉的 × → 按每块标注的方式取消一项(点 × / 再点一次 / 按退格,各家不一样)→ 点「空值提交试一下」。每块下方两行「实测」是运行时用 DOM 数出来的: 一行量原生 select 被藏成什么样,一行数「已选几项 / 触发器有几个可点删除的按钮 / 面板可见几行 / 哪些已选项在面板里看不到了」。

一眼看完:多选的五个关键维度

实现多选交互形态已选值怎么显示取消一项筛选 × 多选
bootstrap-select 1.13.18 勾选面板:li > a[role=option],面板 portal 到 body 的 .bs-container,行内一个 .check-mark 勾标记;勾完不自动收面板 一行纯文本,逗号连接全部已选项(实测 总铬, 铅)。没有 chip、没有 ×,选到第 5 项就撑破一行 回面板把已勾的那一行再点一次(实测 2→1 生效)。触发器上无处可点;面板里也没有「全选 / 取消全选」按钮;全清只能调 selectpicker('deselectAll') 已选项会被筛掉。敲「氨」后面板只剩命中的行(实测 32→1),总铬/铅 从面板里消失,触发器文本照旧不变
select2 4.1.0 勾选面板:li.select2-results__option[role=option],aria-multiselectable=true;勾完自动收面板(closeOnSelect 默认 true,与 bootstrap-select 相反) chip:li.select2-selection__choice,每块自带一个 button×,带 title/aria-label="Remove item" 点 chip 上的 ×(实测生效)——六家里最干净。默认没有「清空全部」,要 allowClear:true 才有 面板只剩命中行,chip 不受影响——已选项在触发器里始终看得见(相对 bootstrap-select 的关键优势)。但筛选态下结果行的 aria-selected 会标成 true(实测哪怕根本没选中也标),读屏会读错
choices.js 11.2.4 勾选面板:.choices__list--dropdown 下的 .choices__item--selectable;面板留在控件内;勾完不自动收 chip:.choices__list--multiple 里的 .choices__item。默认没有删除按钮——removeItemButton 默认 false(实测 chip 内 0 个 <button>) 三条路:① 面板里再点一次该行(实测生效);② 检索框里按退格删最后一个(实测生效);③ 自己开 removeItemButton:true 才有 chip 上的 × 面板只剩命中行,chip 不受影响;选中即从面板移除(实测 32→31 行),用检索词能把它搜回来。筛选时 dropdown 的 DOM 是「32 行原列表 + N 行搜索结果」两份(实测敲「总」→36 行)
tom-select 2.6.2 展开列表框:.ts-dropdown .option[data-value][role=option](不是 v1 的 ts-dropdown__item);默认留 wrapper 内;勾完不自动收 chip:.ts-control .item[data-value]。默认没有 ×——plugins 默认 [],remove_button 插件只在 .popular 构建里、不默认注册 检索框里按退格删最后一个(实测生效)。回面板点已选行做不到——已选项已被移出面板;想要 chip 上的 × 必须自己注册 remove_button 插件 面板只剩命中行,chip 不受影响;选中即从面板移除(实测 32→31 行)。未命中的已选 chip 会一直占宽度,不自己回收
Radix Select 2.3.7 官方组件没有多选:Select.Root 只接受单个 value,没有 multiple 属性。下面的 胶水 chip 排 + 单选取数器 全是本页自组 胶水 chip(本页 React 组件画的,不是库产物) 点胶水 chip 的 ×——这一行的每个字节都是自己写的 筛选只影响「取数器」里那份列表(并且本页把已选项从取数器里剔掉防重复勾选,这是胶水自己定的策略);已选 chip 一直在
form-kit 现状(enhanceSelect) 勾选面板:引擎就是 choices.js 11.2.4(PKCHOICE01 换芯),面板 DOM 与上面 choices.js 一行相同 与上面 choices.js 相同:chip(.choices__list--multiple) 与上面 choices.js 相同:点 chip 上的 .choices__button(多选默认开,removeItemButton:true) 与上面 choices.js 相同:选中即从面板移除;检索筛掉面板行、不影响 chip

多选与 required 的语义(六家完全一致,因为都靠原生 select[multiple][required]): 只要有任意一个 option 被选中就放行,全空才拦。差异不在「拦不拦」,而在 空值提交时用户能不能看见被拦了——那仍然是下面单选基线表里的老账(choices.js 唯一给出真红框, select2 零补偿,tom-select 的红框样式不存在,bootstrap-select 依赖页面自己加 .was-validated, form-kit 换芯后与 choices.js 同源,也归在这一档)。
多选 select 里不要放 value="" 的占位 option:它在 select[multiple] 里 就是一个能被勾中的「空值」,勾上之后 required 反而放行、FormData 还会收到一个空串。本页六块都已去掉占位项。
提交拿到的是多值:FormData.getAll(name) 返回数组,不是逗号串。 要一个字符串就得自己 join,join 用什么分隔符是业务约定,不是浏览器行为。

单选基线的四个关键维度(M29 SELECTLAB01 实测,本轮未变)

实现原生 select 怎么藏面板挂载定位 / 翻转required 补偿
bootstrap-select 1.13.18 0.5px 宽 + opacity:0(display:none 只在初始化构建期存在,结束就摘掉) portal 到 body 固定向下,无 flip(源码 grep flip 零命中) 写了 CSS 但默认不生效:.was-validated select:invalid + .dropdown-toggle 要求 form 带 .was-validated,实测边框没变红
select2 4.1.0 clip:rect(0 0 0 0) + 1px + absolute(sr-only 变体,仍可聚焦) portal 到 body(dropdownParent 可改) 有 flip:按 window 视口双向判空间;另有滚动祖先 scrollTop 回写防抖 零补偿:全源码搜 required/invalid 无实质命中
choices.js 11.2.4 源码写 el.hidden = true,但自带 CSS 把它顶回 display:block + opacity:0(实测仍可聚焦) 留在控件内,无 portal 选项 有 flip(shouldFlip():视口 + matchMedia) 监听 invalid → .is-invalid 红框,实测这红框真的出来了(六家里唯一)
tom-select 2.6.2 clip + 1px + absolute(与 select2 几乎逐字相同) 默认留控件内;dropdownParent:'body' 才 portal 固定向下,无 flip(positionDropdown 开头就 return) 监听 invalid → .invalid 类,但默认主题 CSS 里 invalid 出现 0 次
Radix Select 2.3.7 VISUALLY_HIDDEN_STYLES(sr-only clip) portal(react-portal)+ floating-ui + remove-scroll floating-ui avoidCollisions / sideOffset / collisionPadding 造一个真 <select required> 透传,浏览器原生全链路
form-kit 现状(enhanceSelect) 与上面 choices.js 相同:源码写 el.hidden = true,自带 CSS 顶回 display:block + opacity:0(实测仍可聚焦) 留在 .formkit-picker{position:relative} 内,无 portal 与上面 choices.js 相同:position:'auto',有 flip(但 flip 只看 window 视口、不看滚动祖先——在 overflow:auto 的弹窗 body 里贴底打开实测 isFlipped:false、下拉被容器裁掉 25px) 与上面 choices.js 相同:监听 invalid → .is-invalid;但 form-kit 默认 novalidate,这条不触发,红框靠 .field-error 红字

读表提醒:本表已按本页实测修正过三处既有调研结论,请以本页为准
① choices.js 并没有真的 display:none——但顶回 display 的不是那条 [hidden] 规则。源码确实调 el.hidden = true,并且自己给这个 select 加了 .choices__input 类;真正压过浏览器 UA 的 [hidden]{display:none} 的是 choices.css 里那两条作者规则—— .choices__input{display:inline-block} 与 .choices[data-type*=select-one] .choices__input{display:block}。.choices [hidden]{position:absolute;inset:0;pointer-events:none;opacity:0} 一句 display 都没有(只留它一条时 computed display 仍是 none、offsetHeight 为 0、focus() 无效),它只负责不影响布局与不可点。 本页按标准用法加载官方 CSS,控件仍可聚焦:原生气泡与滚动都在,Console 也不会报 not focusable。只有当你自己把那两条 display 规则压掉时,它才会退化成 display:none 的坏情况 (2026-10-02 按 M38 评审 r2 的实测更正;form-kit.js 的 overlay 注释与 lims2.css 同段已同步)。
② bootstrap-select 的 required 红框是「写了但默认不生效」。它靠 .was-validated select:invalid + .dropdown-toggle,而 .was-validated 得由页面自己加到 form 上, bootstrap-select 自己不加。实测空值提交后按钮只多了一个 .bs-invalid 类,边框没红。
③ bootstrap-select 对 Bootstrap 是硬依赖,不只是「缺皮肤」。缺 Bootstrap 时不只是点面板抛 Cannot read properties of undefined (reading 'Constructor')(它硬读 $.fn.dropdown.Constructor.Default), 更致命的是开合状态机也没了:触发器按自己有没有 open 类决定下一次是开还是关, 而那个类归 Bootstrap 3 的 dropdown data-api 管。只设 BootstrapVersion 只能骗过版本探测, 面板会打开后永远关不掉。本页按正路引入 bootstrap@3.4.1 的 JS(只引 JS,不引 Bootstrap CSS), 两条依赖才真的补齐;顺带说明 Bootstrap 5 不能替代——它没有 jQuery 的 dropdown 插件。

样式隔离做法:本页把六家平铺在同一张页面上(不用 iframe),因为要看的就是并排手感。 已逐个核对四家库自带 CSS 的选择器作用域——bootstrap-select 只引库自身 CSS、不引完整 bootstrap.css, 其规则全部限定在 .bootstrap-select 下,没有全局 reset;select2 / choices / tom-select 同样自带作用域。 bootstrap-select 缺的 .dropdown-menu 皮肤,以及 Bootstrap 3 里 .dropdown-menu 的 display:none / position:absolute 两条,都在 lab.css 里用少量本页专属规则补齐,没改任何库文件。 Bootstrap 只引 JS 不引 CSS:正是为了不给整页带来它的全局 reset;vendor-sources/package.json 里钉的是 3.4.1, 升到 5.x 这块会直接失效(BS5 没有 jQuery dropdown 插件)。
顺带撞出来的全局 CSS 冲突:lims2.css 的 select:not([multiple]):not([size]) 带 padding-right:28px !important,配上全局 box-sizing:border-box, 会把 bootstrap-select 的 0.5px 隐藏 select 和 select2 的 1px 隐藏 select 一起撑成 28px(实测)。 视觉上无影响(opacity:0 / clip),但这就是「全局 select 基座 vs 第三方控件」的典型冲突面。
本轮加的一条:select:not([multiple]) 这条选择器本来就不会命中多选 select, 所以六块改成多选后,lims2 的三角与 28px padding 基座对它们全部失效——这是一条天然只对单选生效的全局规则。

①

bootstrap-select

1.13.18

依赖:jQuery 3.7.1 + Bootstrap 3.4.1,两个都是硬依赖,缺一个这块就点不开。Bootstrap 提供两样东西: 版本探测($.fn.dropdown.Constructor,缺了直接抛异常)与 dropdown 的开合状态机(给按钮增删 open 类——bootstrap-select 的触发器按这个类决定 下一次是开还是关,缺了它面板一旦打开就永远关不掉)。

多选形态
勾选面板:<select multiple> + selectpicker({multiple:true}),面板里是 li > a[role=option],.inner 带 aria-multiselectable=true;勾完不自动收面板
已选展示
触发器一行纯文本,逗号连接(实测 总铬, 铅)。没有 chip、没有 ×;不传 title 时按钮 title 会落成英文「Nothing selected」;可用 selectedItemsFormat:'count' 换成「已选 2 项」
取消一项
回面板把已勾的那一行再点一次(实测 2→1)。触发器上无处可点,面板里也没有「全选 / 取消全选」按钮;全清只能调 selectpicker('deselectAll')
筛选 × 多选
本轮最大的坑:已选项会被筛选带走。选中「总铬/铅」后把搜索词敲成「氨」,面板里只剩命中的行(实测 32→1),这两项从面板消失(触发器文本不变),用户很容易以为没选中、重复去勾别的东西
藏法
position:absolute; width:.5px; opacity:0(bs-select-hidden 只在 init 期间存在,结束即摘)
挂载
portal 到 body(options.container)
翻转
无。想朝上得自己给容器加 .dropup;面板空间不足时直接被视口吃掉
required
CSS 写好了却默认不生效:.was-validated select:invalid + .dropdown-toggle 要求 form 自身带 .was-validated,而它从不加这个类。实测空值提交后按钮只多个 .bs-invalid 类,边框没红

已选 0 项:(空)

尚未提交

②

select2

4.1.0

依赖:自带 jQuery。面板里自带检索框(本页额外开了 liveSearch 即时过滤)。

多选形态
勾选面板:<select multiple> + select2({multiple:true}),面板行 li.select2-results__option[role=option];勾完自动收面板(closeOnSelect 默认 true——与 bootstrap-select 相反,连勾两项要开两次面板)
已选展示
chip:li.select2-selection__choice,每块自带 button.select2-selection__choice__remove,带 title 与 aria-label="Remove item"
取消一项
点 chip 上的 ×(实测生效)——六家里最干净。默认没有「清空全部」,要 allowClear:true 才多一个清空 ×
筛选 × 多选
面板只剩命中行,chip 完全不受影响——已选项在触发器里始终看得见,这是它相对 bootstrap-select 的关键优势。但筛选态下结果行的 aria-selected 会标成 true(实测哪怕根本没选中也标),读屏会读错
藏法
clip:rect(0 0 0 0) !important; clip-path:inset(50%); 1px,仍可聚焦
挂载
portal 到 body,dropdownParent 可改
翻转
唯一有完整 flip 的四库之一:算 enoughRoomAbove/Below 并切 --above/--below
required
零补偿:无红框、无 aria-invalid。原生气泡锚在 1px 隐藏框上,基本等于没有反馈

已选 0 项:(空)

尚未提交

③

choices.js

11.2.4

依赖:无。面板里自带检索框,position:'auto' 时会自动向上翻转。

多选形态
勾选面板:<select multiple> + new Choices(el),根节点自动带 data-type="select-multiple",面板行 .choices__item--selectable;勾完不自动收。不需要(也不能)传 multiple:true——choices 读的是 element.type,多传一项会报 Unknown config option(s) passed multiple
已选展示
chip:.choices__list--multiple 里的 .choices__item。默认没有删除按钮——removeItemButton 默认 false(源码 defaults.ts,实测 chip 内 0 个 <button>)
取消一项
三条路:① 面板里再点一次该行(实测生效);② 检索框里按退格删最后一个(实测生效,源码 _onDeleteKey);③ 自己开 removeItemButton:true 才有 chip 上的 ×
筛选 × 多选
面板只剩命中行,chip 不受影响;选中即从面板移除(实测 32→31 行),用检索词能把它搜回来。筛选时 dropdown 的 DOM 是「32 行原列表 + N 行搜索结果」两份(实测敲「总」→36 行),遍历与读屏都会翻倍
藏法
源码调 el.hidden = true(tabIndex=-1),但它没退化成 display:none:choices 初始化时自己给这个 select 加了 .choices__input 类,choices.css 里 .choices__input{display:inline-block} 与 .choices[data-type*=select-one] .choices__input{display:block} 这两条作者规则的 display 盖过 UA 的 [hidden]{display:none};.choices [hidden]{position:absolute;inset:0;pointer-events:none;opacity:0} 只负责不影响布局与不可点,一句 display 都没有 → 实测 display:block; opacity:0,仍可聚焦
挂载
留在控件内,没有 portal 选项。M27 实测:弹窗里打开只有 13px 可见
翻转
有 flip(shouldFlip()),判据是视口不是滚动容器
required
监听 invalid 加 .is-invalid 红框(#d33141)——实测红框真的出来了,六家里唯一。因为控件可聚焦,原生气泡与滚动也都在

已选 0 项:(空)

尚未提交

④

tom-select

2.6.2

依赖:无。面板里自带检索框。默认不 portal,dropdownParent 默认 null。

多选形态
展开列表框:<select multiple> + new TomSelect(el),wrapper 自动加 multi 类,面板行是 .ts-dropdown .option[data-value][role=option](不是 v1 的 ts-dropdown__item);勾完不自动收
已选展示
chip:.ts-control .item[data-value]。默认没有 ×——plugins 默认 [],remove_button 插件只在 .popular 构建里、不默认注册(实测 chip 内 0 个 <button>)
取消一项
检索框里按退格删最后一个(实测生效)。回面板点已选行做不到——已选项已被移出面板;想要 chip 上的 × 必须自己注册 remove_button 插件
筛选 × 多选
面板只剩命中行,chip 不受影响;选中即从面板移除(实测 32→31 行)。未命中的已选 chip 会一直占宽度,不会自己回收——选得多时触发器会被挤成两行
藏法
ts-hidden-accessible:clip + 1px + absolute,与 select2 几乎逐字相同
挂载
默认留 wrapper 内(会被滚动容器裁掉);设 dropdownParent:'body' 才 portal
翻转
无 flip。positionDropdown() 除非 dropdownParent==='body' 否则直接 return
required
监听 invalid 加 .invalid 类,但默认主题里这条样式不存在(grep 计数 = 0)→ 看不出变化

已选 0 项:(空)

尚未提交

⑤

Radix Select

2.3.7 · React 18
官方 Select 不支持 multiple,这一块的「多选」是本页用 React state 自组的胶水。 Select.Root 只接受单个 value,没有 multiple 属性、没有 token 列表、 也没有「已选集合」这个概念。下面那排 chip、「加一项」的选择器、以及把多值写回原生 select[multiple] 供 required 校验的代码,每一个字节都是本页自己写的, 用来回答「选 Radix 的话,多选要自己造多少东西」。

依赖:React 18(库本身零依赖)。无 UMD 构建,本页用 esbuild 把 react + react-dom + @radix-ui/react-select 打成单文件 IIFE, 产物 vendor/radix/radix-lab.iife.js(复现命令见页脚)。Radix 完全无样式,本页 lab.css 里的 .radix-* 全部是本页自己写的。

多选形态
库没有。本页的形态是「胶水 chip 排 + 一个单选 Radix Select 当加项器」——每勾一次把一项 append 进 React state 数组
已选展示
胶水 chip(本页画的 .radix-chip,不是库产物)
取消一项
点胶水 chip 的 ×,从 React state 数组里 splice 掉;「加项器」里同时只能有一个高亮值
筛选 × 多选
筛选只喂给「加项器」那份列表;已选 chip 一直在。这是胶水顺带的,不是库能力
required
Radix 的 bubble input 是单选 <select>,管不了多值。要拿到原生的「至少选一项」校验,必须自己再造一个真 <select multiple required> 并把值逐个写回去(下方 mini 表单里那个 radixMulti 就是胶水产物)
检索
无内建检索。下面那行筛选框是本页自组的胶水(React state 过滤后只渲染命中的 Item),不是库能力。多选下这层胶水还要多管一件事:chip 排、已选集合、以及把多值写回原生 select
藏法
VISUALLY_HIDDEN_STYLES(sr-only clip),藏在真 <select> 里
挂载
portal(react-portal)+ floating-ui,且打开时 remove-scroll 锁背景滚动
翻转
floating-ui avoidCollisions:参照浮层 + 边界自动避让,可用 sideOffset/collisionPadding 调
正在加载 React + Radix 产物…
⑥

form-kit 现状

enhanceSelect · 本仓现状

对照组:直接调 FormKit.enhanceSelect,看本仓现状形态。 2026-10-01(PKCHOICE01)起这一列的引擎就是 choices.js 11.2.4——form-kit 的自绘面板 (.formkit-picker-panel / .formkit-picker-search / .formkit-picker-option) 已整体退役,下面几条形态因此与上面的 choices.js 卡片一致。 与上一张表的对照意义在于:选型结论已经落地成产品默认, 而 form-kit 仍守住的那条契约是「原生 select 是唯一事实源」—— 取值走 values()、必填走 required、aria-invalid 与 data-guide-id 仍挂在 select 上,choices 只负责画。

多选形态
勾选面板:引擎 choices.js 11.2.4,<select multiple> 被顶成 hidden,面板是 .choices__list--dropdown
已选展示
chip:.choices__list--multiple .choices__item,DOM 里与上面 choices.js 一行相同
取消一项
点 chip 上的 .choices__button(removeItemButton 随 select.multiple 自动开)。注意 11.2.4 的按钮类名是 .choices__button,不是 v9/v10 的 .choices__item__remove
筛选 × 多选
与 choices.js 相同:选中即从面板移除,所以已选项不在面板里,取消只能靠 chip 上的 ×;检索只筛面板行,chip 不受影响
藏法
源码写 el.hidden = true,顶回 UA display:none 的是 choices 给它加的 .choices__input 类上那两条 display 规则(.choices__input{display:inline-block}、.choices[data-type*=select-one] .choices__input{display:block}),不是 .choices [hidden] 那条——后者只管不影响布局与不可点、一句 display 都没有——所以是隐藏但仍可聚焦,这是 required 还能生效的前提
挂载
留在 .formkit-picker{position:relative} 内,无 portal(面板是 .choices 的子节点,不是 append 到 body)
翻转
position:'auto',有 flip。但 flip 只看 window 视口、不看滚动祖先:在 overflow:auto 的弹窗 body 里贴底打开实测 isFlipped:false、下拉被容器裁掉约 25px。与自绘面板时代的「固定 top:100%」相比是净改善,但这个坑没被消灭
Esc
choices 内建关面板(搜索词一并清空),且事件继续冒泡到 document。form-kit 用 MutationObserver 把 containerOuter.is-open 镜像到包裹层的 .formkit-picker.is-open,各 *-common.js 的 stackForeignOverlay() 靠它让路,弹窗才不会被连坐关掉
required
与 choices.js 相同:靠「隐藏但可聚焦」保住原生校验。form-kit 默认 novalidate,所以 invalid 不触发、choices 的 .is-invalid 红框不出现,红字靠 .field-error
换 options 后
走 controller.refresh()。choices 自带的 refresh() 不读原生 select 的 .selected(多选换完 options 芯片区会空、getValue() 返回 []),所以 form-kit 的 refresh 是销毁重建整实例
窄视口
innerWidth < 768 不初始化引擎(纯像素单判据),原生 select 直接接管、resize 实时切换初始化/销毁、切换时值不丢。此时 lims2 的 padding-right:28px 与三角照常(豁免规则限定在 :has(> .choices) 增强态)

已选 0 项:(空)

尚未提交