
1. 为什么vant里没有现成的Select组件我们却要自己拼前阵子接手一个移动端H5项目产品提了个需求表单里要有一个下拉选择控件支持单选也支持多选。我当时第一反应是直接查vant的官方文档找一个Select组件不就行了吗结果翻遍vant官网发现根本没有名为Select的组件。细想一下也不奇怪vant是面向移动端的Vue组件库移动端的交互习惯和PC端差异很大PC端那种点击展开的下拉列表放在手机上操作不直观还容易误触。移动端更常用的选择交互是底部弹层。vant提供了Picker选择器、Popup弹出层、Field输入框这三个基础组件。把这三个组合起来就能实现一个体验很接近原生iOS/Android的Select效果。Picker负责滚动选择Popup负责弹层展示Field负责展示当前选中的值。这个组合方案在vant的官方文档里虽然没有明确叫Select但社区和实际项目里用得非常多属于一个约定俗成的标准做法。适合看这篇内容的人我觉得有三类第一类是用vant做移动端后台管理页需要把PC端的Select交互搬到手机上的第二类是表单里需要单选/多选字段但不想用原生select和checkbox那种生硬样式的第三类是刚接触vant想知道Field、Popup、Picker之间怎么配合使用的初学者。这篇文章的核心就是两件事第一如何用FieldPopupPicker实现单选效果第二如何用FieldPopupCheckboxGroup实现多选效果并且把选中结果用Tag标签展示在输入框区域。看完之后你不仅能直接抄代码还能理解每个组件为什么这样组合以及遇到动态加载、回显、默认值等问题时去哪里排查。先说清楚一个基本概念vant的Picker组件本身只支持单选——你滚到一个选项上它就选中一个。如果要实现多选就要换个思路比如在Popup里塞一个CheckboxGroup或者用多个Picker联动。这两种方式我都实测过各有优缺点后面会详细对比。2. 基础环境与组件依赖准备2.1 安装和引入方式我使用的是vant的Vue 3版本包名是vant版本号在4.x以上。如果你用的是Vue 2项目对应的包名是vant2引入方式略有差异但组合思路完全一样。先说安装。在项目根目录执行npm install vant如果你的项目用的是Vue 3 Vite推荐使用按需引入的方式配合unplugin-vue-components插件这样打包体积更小。安装插件npm install unplugin-vue-components -D然后在vite.config.js里配置import { defineConfig } from vite import vue from vitejs/plugin-vue import Components from unplugin-vue-components/vite import { VantResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), Components({ resolvers: [VantResolver()] }) ] })配置完成之后在组件模板里直接写van-field、van-popup、van-picker这种标签插件就会自动帮你把对应的组件和样式导入进来不需要再手动import了。如果不想引入插件也可以全量引入import { createApp } from vue import Vant from vant import vant/lib/index.css const app createApp(App) app.use(Vant)全量引入的好处是省心适合小项目坏处是打包体积会大一些。我个人建议稍微正规一点的项目都用按需引入尤其是组件库这种体积不小的依赖能省不少流量。2.2 需要用到哪些vant组件实现本文的Select效果会用到以下几个组件我先列一个表方便你对号入座组件名作用使用频率van-field展示当前选中值触发点击事件单选、多选都用van-popup底部弹出层承载选择器内容单选、多选都用van-picker滚轮选择器实现单选单选场景van-checkbox-group复选组实现多选多选场景van-checkbox复选项搭配checkbox-group多选场景van-tag标签展示多选结果多选场景van-button弹层底部确认按钮单选、多选都用还有两个容易被忽略的组件van-cell和van-icon。van-field本身是基于cell实现的默认就带右侧的下拉箭头图标叫arrow这个箭头在PC端的Select里也常见能提示用户这里可以点击。如果你要做更复杂的样式定制比如输入框右侧需要一个清空按钮那还得用到van-icon的clear图标或者自己嵌入一个span标签。2.3 页面的基本布局结构在做任何逻辑之前先搭一个最基础的表单页面template div classselect-page van-form van-cell-group inset van-field v-modelsingleValueText is-link readonly label单选择 placeholder请选择 clickshowSinglePicker true / van-field v-modelmultiValueText is-link readonly label多选择 placeholder请选择 clickshowMultiPicker true / /van-cell-group /van-form /div /template注意这里我给van-field加了一个is-link属性它会在右侧显示一个类似链接跳转的小箭头。同时设置readonly因为我们需要自己控制点击行为而不是让用户直接在输入框里打字。移动端的表单里这种只读点击弹出的交互非常常见能避免键盘弹出打断选择操作。接下来两个弹出层和对应选择器分别放在下面两个章节里详细展开。3. 单选SelectFieldPopupPicker的组合实现3.1 核心代码单选的实现思路是点击Field弹出一个PopupPopup底部有一个PickerPicker滚到某个选项后点击确认按钮把选中值赋值给Field的显示文本。先看模板部分template van-popup v-model:showshowSinglePicker positionbottom round van-picker title选择类型 :columnssingleColumns confirmonSingleConfirm cancelshowSinglePicker false / /van-popup /template对应的逻辑部分import { ref } from vue const showSinglePicker ref(false) const singleColumns [ { text: 待处理, value: pending }, { text: 处理中, value: processing }, { text: 已完成, value: finished }, { text: 已取消, value: cancelled } ] const singleValue ref() const singleValueText ref() const onSingleConfirm ({ selectedOptions }) { const option selectedOptions[0] singleValue.value option.value singleValueText.value option.text showSinglePicker.value false }这里有个细节van-picker的columns数据源每一项建议写成{ text, value }结构。text是展示给用户看的文字value是真正提交给后端的值。如果你只写一个字符串数组比如[待处理, 处理中]那么selectedOptions里每个option的value和text就完全相同后续如果要提交代码值还得去映射很麻烦。3.2 为什么要用readonly加is-link的Field不知道你会不会疑惑直接用van-picker不是也能选吗为什么外面要包一层Field答案是Picker本身是独立存在的选择器它没有一个固定的输入框来展示最终结果。如果不用Field展示选中值用户选择一个值之后只能靠toast提示或者页面其他地方来确认交互不完整。Field在这里扮演的角色就是PC端里select元素的收起状态——显示当前选中值的那个窄条。加了readonly则是为了避免用户手动输入。因为我们要保证这个输入框的值只能通过弹层里的选择器来修改不能由键盘直接输入。这也是Select效果和普通输入框的本质区别。is-link属性会让Field右侧出现一个灰色小箭头视觉上更像一个可点击的下拉选择控件。如果你不喜欢默认的箭头也可以不设is-link自己在Field的right-icon插槽里放一个自定义的van-icon。3.3 默认值和回显的处理实际业务中最常见的需求是编辑页回显。后端返回一个状态码processing你要让Field自动显示处理中Picker的滚轮也要停留在对应位置。首先需要处理Field的显示文本。在拿到后端返回值时遍历singleColumns找到匹配项const initSingleValue (value) { const matched singleColumns.find(item item.value value) singleValue.value value singleValueText.value matched ? matched.text : }然后处理Picker的默认选中位置。Picker组件支持v-model绑定一个selectedValues数组对应每一列选中项的value值。对于单列Picker来说它是一个只有一项的数组van-picker v-modelsingleSelectedValues title选择类型 :columnssingleColumns confirmonSingleConfirm cancelshowSinglePicker false /const singleSelectedValues ref([]) // 打开弹层之前把当前值同步给Picker const openSinglePicker () { singleSelectedValues.value singleValue.value ? [singleValue.value] : [] showSinglePicker.value true }这里有一个经验一定要在打开Popup之前同步selectedValues否则会出现Picker里滚动的默认位置和Field显示不一致的情况。比如你上次选了已完成再次打开弹层Picker应该默认停留在已完成这一项而不是每次都从第一项开始。如果不设置v-modelPicker每次打开都会回到第一项用户得多滚好几下体验很差。3.4 确认和取消的交互细节Picker默认自带取消和确认按钮。vant 4里按钮文字通过confirm-button-text和cancel-button-text属性配置默认是确认和取消。如果你要改成别的文案可以这样van-picker title选择类型 :columnssingleColumns confirm-button-text确定 cancel-button-text返回 confirmonSingleConfirm cancelshowSinglePicker false /还有一点要注意Picker的cancel事件触发后弹层不会自动关闭你需要手动把showSinglePicker设为false。confirm同理也需要在回调里手动关闭。这跟Popup的v-model:show双向绑定是两个独立的控制逻辑别想着Picker确认了Popup会自动关不会的。如果Picker顶部不需要大标题可以把title属性去掉或者设置toolbar插槽来自定义工具栏的内容。我经常用toolbar插槽把右侧的确认按钮换成完成并加粗视觉上更符合移动端风格van-picker :columnssingleColumns confirmonSingleConfirm template #toolbar div classpicker-toolbar span clickshowSinglePicker false取消/span span classconfirm-btn clickonSingleToolbarConfirm完成/span /div /template /van-picker自定义toolbar时要注意confirm事件依然会触发但你需要在onSingleToolbarConfirm里自己读取当前Picker的选中值。可以通过给Picker设置ref然后调用ref.getSelectedOptions()方法获取选中项。4. 多选场景的方案选型为什么不能直接依赖Picker4.1 Picker做多选的天然短板很多人第一次做多选会尝试在Picker上想办法能不能多选几列比如有一个分类需求要同时选多个分类是不是把分类列表拆成多列每列选一个这个思路在交互上是有问题的。Picker的每一列代表一个维度比如省份、城市、区县这叫级联选择。而多选多个同维度的值比如在10个分类里选3个它不是一个每一列选一个的逻辑而是勾选哪些的逻辑。强行用多列Picker实现用户根本看不懂操作也繁琐。vant的Picker组件在设计上就没有多选模式。官方文档里Picker的column数据类型、selectedValues都是单维度数组没有checkbox勾选的概念。所以在vant生态里实现多选最可靠的是换组件用Popup包一个CheckboxGroup。4.2 三种多选方案的对比我在不同项目里试过三种方案简单做了一张对比表方案实现方式优点缺点适用场景Popup CheckboxGroup弹层里放复选组交互清晰支持勾选、全选弹层较高时体验一般选项数量适中5~20个Picker多列模拟把多个同维度值拆成多列无需新组件交互反人类难懂不推荐级联TreeSelect使用van-tree-select支持分组、层级清晰多选逻辑需自己实现分组展示的多选场景我平时用得最多的是第一种PopupCheckboxGroup。它最接近用户理解的多选概念而且vant的Checkbox组件本身支持icon-size、shape等样式配置可以做成圆形勾选或者方形勾选。选项数量特别多比如50个以上我会再加一个搜索框这个后面会讲到。4.3 TreeSelect方案的简单说明如果你要做的多选是有层级的比如先选一级分类再选二级分类vant有一个van-tree-select组件左边一级分类右边二级列表。严格来说van-tree-select也不支持多选但右边的列表可以配合Checkbox自己渲染勾选状态。树形多选这块我在另一个项目里实现过。核心思路是左侧tab切换一级分类右侧用van-checkbox把二级选项渲染出来维护一个选中的二级id数组。提交时既要提交选中的二级id也要根据父级id做聚合。这个方案代码量会多一些但交互效果和层级清晰度是最好的。本文的重点放在PopupCheckboxGroup这个通用方案上因为它是大多数Select多选需求的直接答案。5. 多选SelectPopupCheckboxGroup的完整实现5.1 页面模板结构多选的模板和单选类似区别在于Popup里的内容从Picker换成了CheckboxGrouptemplate van-popup v-model:showshowMultiPicker positionbottom round classmulti-popup div classmulti-popup-header span classmulti-popup-title选择标签/span span classmulti-popup-cancel clickshowMultiPicker false取消/span span classmulti-popup-confirm clickonMultiConfirm确定/span /div div classmulti-popup-body van-checkbox-group v-modelmultiCheckedValues van-cell v-foritem in multiOptions :keyitem.value :titleitem.text clickable clicktoggleCheckbox(item.value) template #right-icon van-checkbox :nameitem.value refcheckboxRefs / /template /van-cell /van-checkbox-group /div /van-popup /template这段模板里有一个很实用的设计我用van-cell作为每一行的容器把van-checkbox放在cell的right-icon插槽里。这样整行都可以点击勾选而不是只能点那个小圆圈。移动端用户的点击目标一般比较大如果只能点一个小checkbox误触率很高。5.2 逻辑代码对应的逻辑部分const showMultiPicker ref(false) const multiOptions [ { text: 重要, value: important }, { text: 紧急, value: urgent }, { text: 待办, value: todo }, { text: 备忘, value: memo }, { text: 临时, value: temp } ] const multiCheckedValues ref([]) const multiValueText ref() // 打开弹层前同步已选数据 const openMultiPicker () { multiCheckedValues.value [...selectedMultiValues.value] showMultiPicker.value true } // 点击整行时切换checkbox const toggleCheckbox (value) { const index multiCheckedValues.value.indexOf(value) if (index -1) { multiCheckedValues.value.splice(index, 1) } else { multiCheckedValues.value.push(value) } } // 确认选择 const onMultiConfirm () { selectedMultiValues.value [...multiCheckedValues.value] multiValueText.value selectedMultiValues.value .map(val multiOptions.find(item item.value val)?.text || ) .join() showMultiPicker.value false }这里我用了两个变量multiCheckedValues是弹层内部的临时选择selectedMultiValues是最终确认后的已选值。为什么要分开因为用户在弹层里勾勾选选可能勾错了想取消如果直接改selectedMultiValues那点击取消退出弹层时底下的Field显示已经在变了这不对。正确做法是进入弹层时把已选值复制到临时变量用户勾选只是在临时变量上改只有点确定才真正提交。这个模式在移动端弹层选择里一定要养成习惯。5.3 用Tag标签展示多选结果多选结果的展示如果只是把多个文本用逗号拼起来放在Field里文字一多会显得很拥挤。我推荐的做法是Field的input区域用自定义插槽把每个选中值渲染成一个van-tag标签。模板改成这样van-field readonly is-link label多选择 placeholder请选择 clickopenMultiPicker template #input div classmulti-tags van-tag v-foritem in selectedMultiOptions :keyitem.value typeprimary closeable sizemedium closeremoveMultiValue(item.value) {{ item.text }} /van-tag /div /template /van-field计算属性const selectedMultiOptions computed(() { return selectedMultiValues.value .map(val multiOptions.find(item item.value val)) .filter(Boolean) })移除单个标签const removeMultiValue (value) { const index selectedMultiValues.value.indexOf(value) if (index -1) { selectedMultiValues.value.splice(index, 1) updateMultiValueText() } }van-tag的closeable属性会显示一个关闭小叉用户点叉能快速移除某个选项这个交互在移动端很顺手。要注意的是close事件触发后tag不会自己消失你要手动从selectedMultiValues里把对应值移除。5.4 多选回显与计数提示多选同样有回显问题。后端给你一个数组比如[important, todo]你在初始化时const initMultiValue (values) { selectedMultiValues.value Array.isArray(values) ? [...values] : [] updateMultiValueText() } const updateMultiValueText () { if (selectedMultiValues.value.length 0) { multiValueText.value return } multiValueText.value selectedMultiValues.value .map(val multiOptions.find(item item.value val)?.text || ) .join() }如果你不想在Field里堆一堆标签还有一个常见做法是显示已选3项具体选项收在弹层里看。做法也更简单只需要统计selectedMultiValues.value.length。不过我个人还是推荐标签展示因为用户不用打开弹层就能看到自己选了哪些减少操作步骤。6. 动态加载选项和数据格式的坑6.1 Picker的columns动态更新问题实际业务里选项往往不是写死的而是从接口拿到的。比如选择用户、选择商品分类这些列表都是异步加载的。用Picker做单选时最常见的问题就是接口返回数据后Picker的columns更新了但滚轮没有刷新或者选中的值对不上。解决办法其实不复杂columns是一个响应式数组你只要在拿到接口数据后重新赋值Picker会自动刷新。关键是你赋值的格式要对const singleColumns ref([]) const loadColumns async () { const res await fetch(/api/options) const data await res.json() singleColumns.value data.map(item ({ text: item.name, value: item.id })) }这里要注意接口返回的字段名可能不是text和value你要在map的时候做一次转换。别直接把{ id: 1, name: 张三 }这种数据塞给columns不然Picker会显示空的。多选的CheckboxGroup动态加载相对简单因为multiOptions也只是一个数组v-for渲染会自动更新。唯一要注意的是如果选项是异步加载的而用户已经选了某个值要确保这个值能在加载完成后从multiOptions里找到并正确显示标签。6.2 selectedValues与columns不同步的典型报错单选Picker有一个非常经典的报错场景接口还没返回数据时用户已经点开了Picker并且选中了一个值然后接口返回columns被整体重新赋值此时selectedValues里存的是旧数据跟新columns对不上。vant内部会尝试根据selectedValues去匹配新columns匹配不到时可能报错也可能滚轮显示空白。我的处理方案是在重新给columns赋值后如果之前有选中值就把selectedValues同步一次const refreshColumns (newColumns) { singleColumns.value newColumns const currentValue singleValue.value if (currentValue !newColumns.some(item item.value currentValue)) { // 当前值不在新列表里清掉 singleValue.value singleValueText.value singleSelectedValues.value [] } }这么做的好处是当后端把某个选项下架了前端不至于还把已经失效的值展示出来。6.3 大量数据时Picker的性能如果你遇到选项特别多的情况比如城市列表几百个条目Picker滚动起来可能会有一点点卡顿。vant的Picker本身做了性能优化但建议你把columns拆成每屏能展示的数量感觉不会卡的粒度。如果确实上千条可以考虑用van-area、van-tree-select这类带索引/分组的组件不要硬塞给Picker。多选的CheckboxGroup在数据量大时同样有性能问题。v-for渲染几百个van-cell初次渲染可能几百毫秒用户会感觉到卡。我的建议是用虚拟滚动或者做搜索过滤前端先通过搜索框缩小列表范围再渲染。下面这段是给多选弹层加简单搜索过滤的示例const searchKeyword ref() const filteredMultiOptions computed(() { const keyword searchKeyword.value.trim() if (!keyword) return multiOptions.value return multiOptions.value.filter(item item.text.includes(keyword)) })然后在模板里v-for遍历filteredMultiOptions而不是multiOptions。7. 实际项目中遇到的几个奇葩问题7.1 Popup关闭再打开Picker位置不还原有一次测试反馈单选Picker选完第3项后关闭再次打开Picker滚轮停留在第3项这是期望行为。但产品希望每次打开都回到第一项或者回到上一次确认的位置这个需求看似简单实际处理时要注意时机的选择。如果是每次打开都回第一项在openSinglePicker里设置const openSinglePicker () { singleSelectedValues.value [] showSinglePicker.value true }如果希望回到上次确认的位置那就正常把上次的值同步给selectedValues。这个取决于产品需求没有对错但一定要落实到代码逻辑否则就会变成时而回到上次时而从头开始这种玄学状态。7.2 弹层里的滚动穿透问题移动端页面常见的坑是滚动穿透。Popup打开后手指在弹层里滑动选择底下的页面也跟着滚。vant 4的Popup组件默认开启了锁定滚动在绝大多数情况下不会穿透。但如果你发现弹层内容太长内部滚动的时候底部页面在抖可以先检查一下页面有没有给body设置overflow: hidden或者弹层里有没有监听touchmove却没有调用preventDefault的代码。如果你在vant的Popup里嵌套了原生滚动容器建议给容器加上overflow-y: auto; -webkit-overflow-scrolling: touch;保证内部滚动手感顺畅。7.3 表单校验字段名和提交值的剥离最后说一个表单层面的细节。我用van-form做提交校验时会把Field的v-model绑定为显示文本真正提交时用另一个变量存代码值。刚开始会遇到一个问题提交校验时显示文本是已完成但接口需要的是finished总不能在提交前去反查一遍吧所以我在代码里其实维护的是两个状态// 提交给后端的代码值 const singleValue ref() // 展示给用户看的文本 const singleValueText ref()在onSingleConfirm里同时更新这两个值。提交时用singleValue展示时用singleValueText。这样逻辑清晰不会出现显示对了但提交错了或者提交对了但显示不对的尴尬。多选同理selectedMultiValues是给后端的标签展示是通过它map出来的selectedMultiOptions。7.4 移动端键盘弹出问题还有一个非常容易被忽略的点如果Field没有加readonly属性用户在手机上点击时底部的软键盘会弹出来把Popup弹层顶上去视觉上很糟糕。我见过同事排查了半天结果是Field少了readonly。所以再次强调所有充当Select展示的Field都必须加readonly。实在需要用户手动输入的场景那就不是Select是另一个交互逻辑两者不要混用。8. 一套可以复用的封装思路项目里如果有多处地方用到了单选或多选建议封装成公共组件不要每处都复制粘贴这套模板和逻辑。我封装时的做法是抽成两个组件SelectSingleField.vueprops接收columns、v-model内部封装FieldPopupPicker。SelectMultiField.vueprops接收options、v-model内部封装FieldPopupCheckboxGroup。封装时需要注意几个点props的value格式单选是String多选是Arrayemit的事件名统一用update:modelValue这样可以直接v-model绑定Field的label通过props传入placeholder通过props传入。以多选组件为例核心props定义大概是这样的const props defineProps({ modelValue: { type: Array, default: () [] }, options: { type: Array, default: () [] }, label: { type: String, default: }, placeholder: { type: String, default: 请选择 } }) const emit defineEmits([update:modelValue])然后内部通过computed去代理props.modelValue在确认时emit更新。这样用的时候非常爽select-multi-field v-modelformData.tags :optionstagOptions label标签 placeholder请选择标签 /封装完之后整个项目的Select逻辑就从每个页面一套代码收敛成一个组件多处复用后面要改样式、加功能都只需要动一个文件。这才是把Select效果真正落地到项目里的正确姿势。我在实际使用中发现最值得投入时间优化的不是组件的拆分方式而是数据模型的设计。如果一开始就把text和value分开存后面无论做回显、提交、联动都会省很多事。这算是我在这类选择器需求里踩过几次坑之后最想向你强调的一点。