ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vant实现Select效果:单选多选与组件封装的完整实践

Vant实现Select效果:单选多选与组件封装的完整实践 做移动端H5的同学应该都有体会Vant用起来确实顺手但真到了要做一个下拉选择器的时候你会发现官方组件库里居然没有一个叫 Select 的东西。PC端select标签那种点击展开、选中收起的交互在移动端完全不是那么回事。Vant给的是Picker滚动选择器、Popup弹出层、Field输入框这些积木得你自己拼。这篇文章就是把“Vant实现Select效果”这件事彻底讲透重点放在单选框和多选框这两个最刚需的场景。我会从方案选型、完整代码、数据结构处理到实际开发中遇到的坑一条龙地分享。不论你是刚接触Vant的新手还是已经在表单页面里挣扎过的老手照着这套思路改一改基本能满足大部分业务需求。1. 先聊清楚Vant里为什么需要一个“假”的Select1.1 官方组件不缺缺的是一个“语义化的选择器”Vant 4的组件库里跟选择相关的组件其实不少Picker负责滚动选择、Popup负责弹层容器、Field负责展示当前值、Radio负责单选、Checkbox负责多选。问题在于它们没有一个统一的“点击-展开-选择-回填”的完整闭环。你不可能把一个Picker直接丢到页面上它必须有触发源、有弹层、有确认逻辑。很多朋友第一次上手会直接用Popup Picker来做“假Select”。这个思路对于单选来说还行因为Picker天然支持一列滚动选择选中后触发confirm事件拿当前值。但一旦需求变成多选Picker就非常难用了它本质上还是一列里选一个你总不能做多列联动吧。所以我在项目里更倾向于“Field做触发框Popup做弹层Radio/Checkbox做选项列表”的组合。这个方案有四个好处触发源和弹层相互独立交互逻辑清晰选项列表支持任意自定义结构包括搜索框、分组标题、全选按钮单选、多选只是内部选项组件不同外围代码结构一致便于封装复用样式自由度高跟设计稿对得齐。1.2 三种常见实现方案怎么选最省事我见过不少项目里对“Select”的替代方案这里直接做一个对比方便你按实际场景选型方案适用场景优点缺点Popup Picker单选、级联、时间选择滚动体验接近原生性能好多选基本没法做样式定制麻烦Popup Radio/Checkbox 按钮区单选、多选、带确认/取消结构清晰交互可控样式自由选项很多时列表滚动需要自己处理ActionSheet 按钮选项数量少、不需要搜索实现极简几行搞定选项多时体验差无选中态回显个人建议如果只是3到5个固定选项ActionSheet确实够快。但只要牵扯到“选完之后还要在表单里看到已选项”或者选项超过10个就老老实实用Popup Radio/Checkbox的方案后面扩展空间大得多。2. 单选SelectField Popup Radio 的组合拳2.1 基础代码一条双向绑定打通整条链路单选场景的关键点是点击Field的时候记录当前选中的值弹层打开后显示当前高亮用户在弹层里切换选中项点确定之后才把值同步回外层v-model。这样做的好处是不会像change事件那样每次点击选项就立刻改掉页面上的展示文字用户反悔了都来不及。直接上一个 Vue 3 Vant 4 的完整例子template div classpage-container van-field v-modeldisplayText is-link readonly label所属部门 placeholder请选择所属部门 clickopenSingleSelect / van-popup v-model:showsinglePopupVisible positionbottom round :style{ height: 45% } div classselect-panel div classselect-panel__header span classselect-panel__cancel clicksinglePopupVisible false取消/span span classselect-panel__title选择所属部门/span span classselect-panel__confirm clickconfirmSingleSelect确定/span /div div classselect-panel__body van-radio-group v-modeltempSingleValue van-cell-group inset van-cell v-foritem in options :keyitem.value :titleitem.label clickable clicktempSingleValue item.value template #right-icon van-radio :nameitem.value / /template /van-cell /van-cell-group /van-radio-group /div /div /van-popup /div /template script setup import { ref, computed } from vue; const options [ { label: 产品部, value: product }, { label: 技术部, value: tech }, { label: 设计部, value: design }, { label: 运营部, value: operation }, ]; // 外层真正提交的值 const singleValue ref(); const singlePopupVisible ref(false); // 弹层内部的临时值点确定后才同步出去 const tempSingleValue ref(); const displayText computed(() { const matched options.find(item item.value singleValue.value); return matched ? matched.label : ; }); function openSingleSelect() { // 打开弹层前先把当前值同步到临时变量保证高亮正确 tempSingleValue.value singleValue.value; singlePopupVisible.value true; } function confirmSingleSelect() { singleValue.value tempSingleValue.value; singlePopupVisible.value false; } /script这里面最核心的套路就是“临时值”的概念tempSingleValue和最终的singleValue分开。用户点开弹层选了半天但不点确定外层表单不应该有任何变化。等你点确定再把临时值同步给外层v-model。这个思路后面多选场景也要用先记下来。2.2 关键细节点击态、滚动穿透、回显逻辑代码跑通不难但有几个细节一定要处理好否则测试阶段会被反复提bug。第一Field的readonly必须加。加了之后用户没法手动输入只能通过弹层选择。is-link会在右侧显示一个箭头视觉上告诉用户这是个可点击的选择框。第二点击整行van-cell和点击右侧van-radio不要触发两次更新。我的写法是在van-cell上挂clicktempSingleValue item.value右侧van-radio只负责展示选中态不要再加change。否则某些版本下同一个选项可能会闪两下。第三回显逻辑。上面示例里displayText用的是computed直接在外层value基础上找label。这种写法比“在 options 里 find 一下再赋值给另一个字段”简单得多因为不管值是在表单里回填的还是异步接口返回的computed会根据最新的singleValue自动算出正确的展示文字。第四弹层高度。不要固定写死height: 45%如果选项少弹层就会显得很空。我一般会在Popup里动态计算高度选项少于5个时高度自适应超过5个就固定为一半屏幕高度或者max-height 内部滚动。这样视觉上更自然。2.3 配合表单校验清空、重置与默认值很多表单场景里下拉选择框需要支持清空操作。Vant 的Field并没有自带清空图标那是给输入框用的所以你要么在右侧加一个“重置”按钮要么用Field的right-icon插槽自定义一个 “×” 图标。实际项目中我更推荐在弹层底部加“清空”操作而不是放在Field右侧。因为在移动端误触清空的代价很高用户可能已经选了七八个条件一个误触全没了。放在弹层底部至少需要先打开弹层才能点清空误触概率小很多。重置逻辑也很简单function clearSingleSelect() { singleValue.value ; tempSingleValue.value ; singlePopupVisible.value false; }表单校验方面如果用的是 Vant 的Form组件Field的rules并不会自动识别你自己封装的 Select。我的做法是给Field设置name属性然后在van-form的submit回调里手动校验const formRef ref(); function onSubmit(values) { if (!singleValue.value) { showToast(请选择所属部门); return; } // 提交逻辑 }虽然多写了几行代码但胜在可控不会出现 Vant 内部校验和自定义组件状态不同步的诡异问题。3. 多选SelectCheckbox 进场交互复杂度翻倍3.1 多选核心代码CheckboxGroup 的用法与回显多选和单选最大的区别在于值变成了数组确认时必须把临时数组同步到外层同时展示字段需要拼接所有选中的label。如果选项多字段里根本放不下所以展示策略一般有两种简短模式展示 “已选 N 项”完整模式展示 “技术部、产品部、设计部”超出用省略号截断。这两种方式我都实现过代码结构几乎一样改一个displayText就行。下面是基于CheckboxGroup的完整多选弹层template van-field :model-valuedisplayText is-link readonly label擅长技能 placeholder请选择擅长技能 clickopenMultiSelect / van-popup v-model:showmultiPopupVisible positionbottom round :style{ height: 55% } div classselect-panel div classselect-panel__header span classselect-panel__cancel clickmultiPopupVisible false取消/span span classselect-panel__title选择擅长技能/span span classselect-panel__confirm clickconfirmMultiSelect确定/span /div div classselect-panel__body van-checkbox-group v-modeltempMultiValue van-cell-group inset van-cell v-foritem in options :keyitem.value :titleitem.label clickable clicktoggleCheckbox(item.value) template #right-icon van-checkbox :nameitem.value / /template /van-cell /van-cell-group /van-checkbox-group /div /div /van-popup /template script setup import { ref, computed } from vue; const multiOptions [ { label: Vue, value: vue }, { label: React, value: react }, { label: 小程序, value: mini-program }, { label: Flutter, value: flutter }, { label: iOS, value: ios }, { label: Android, value: android }, ]; const multiValue ref([]); const multiPopupVisible ref(false); const tempMultiValue ref([]); const displayText computed(() { if (multiValue.value.length 0) return ; return multiValue.value.map(v { const matched multiOptions.find(item item.value v); return matched ? matched.label : v; }).join(、); }); function openMultiSelect() { tempMultiValue.value [...multiValue.value]; multiPopupVisible.value true; } function toggleCheckbox(value) { const index tempMultiValue.value.indexOf(value); if (index -1) { tempMultiValue.value.splice(index, 1); } else { tempMultiValue.value.push(value); } } function confirmMultiSelect() { multiValue.value [...tempMultiValue.value]; multiPopupVisible.value false; } /script这里有个细节要特别提醒openMultiSelect里用的是tempMultiValue.value [...multiValue.value]而不是直接赋值。因为tempMultiValue是响应式数组直接赋值 multiValue.value的话两者会指向同一个引用你在弹层里改了tempMultiValue外层multiValue也会跟着变临时值的意义就没了。3.2 全选、反选与“已选N项”的计数交互多选弹层里“全选”几乎是标配需求。实现全选有两种方式一种是在CheckboxGroup外部加一个van-checkbox控制全选通过change事件批量更新tempMultiValue。示例代码如下van-checkbox :model-valueisAllSelected update:model-valuehandleToggleAll 全选/van-checkbox script setup import { computed } from vue; const isAllSelected computed(() { return tempMultiValue.value.length multiOptions.length; }); function handleToggleAll(checked) { if (checked) { tempMultiValue.value multiOptions.map(item item.value); } else { tempMultiValue.value []; } } /script另一种是在每个van-cell的click里自己维护数组也就是我上面代码里的toggleCheckbox。两种方式可以共存因为handleToggleAll会整体覆盖数组而单个选项切换是局部增删互不冲突。“已选 N 项”的计数器可以直接绑定在确认按钮旁边div classselect-panel__footer span classselect-panel__count已选 {{ tempMultiValue.length }} 项/span van-button typeprimary sizesmall round clickconfirmMultiSelect确定/van-button /div注意这里我用的也是tempMultiValue.length不是multiValue.length。这样用户打开弹层后能看到当前已经勾选了多少项但点取消不会影响外层表单。3.3 最大选择数限制与禁用项有些业务场景要求限制选择数量比如“最多选3个技能”。Vant 的CheckboxGroup有一个max属性传一个数字即可。当勾选数量达到上限时再点击其他选项Vant 会默认阻止选中并触发一条提示。但这里有个体验问题默认提示文案比较生硬。我一般会监听CheckboxGroup的change事件手动做一次拦截van-checkbox-group v-modeltempMultiValue :max3 changehandleMaxChange function handleMaxChange() { if (tempMultiValue.value.length 3) { showToast(最多选择3项); } }禁用项更简单给van-checkbox加disabled属性即可van-checkbox :nameitem.value :disableditem.disabled /需要禁用项生效的话记得options里对应的数据支持disabled字段。另外如果某项已经被选中了再设置成禁用它依然会在已选列表中只是用户无法手动去掉。这个逻辑是否符合产品预期要提前和业务方对齐。4. 数据结构与后端对接的坑4.1 选项数据格式字符串数组还是对象数组很多初学者拿到接口文档发现后端返回的选项是这样的[产品部, 技术部, 设计部]这种格式有时候确实省事直接遍历渲染字符串就能出效果。但只要需求稍微变一下比如展示值和提交值不一致或者选项带图标、带分组就得全部返工。我强烈建议从一开始就用对象数组[ { label: 产品部, value: product }, { label: 技术部, value: tech } ]label负责展示value负责提交给后端。这样后端拿到的是稳定的枚举值而不是可能会变的中文名称。4.2 回显后端返回ID数组前端怎么反查label回显是另一个高频坑。编辑页面里后端返回的是一个已选值数组比如[tech, product]但前端 Select 的显示文本需要技术部、产品部。如果跟我一样用computed来算displayText就不存在同步问题const displayText computed(() { return multiValue.value.map(v { const matched multiOptions.find(item item.value v); return matched ? matched.label : v; }).join(、); });这份代码里有几个设计点用find去匹配时间复杂度是O(n)选项几十个场景完全够用如果某个value在options里找不到直接把原始值v兜底显示避免出现空白用join(、)拼接而不是字符串保证顺序和检查列表一致。如果你选的value是数字类型特别注意后端返回的是字符串1还是数字1某些后端框架会自动转类型不一致时find 就会匹配不上。处理办法是统一用String(item.value) String(v)做比较。4.3 长列表与远程搜索选项超过50个之后整列表直接放在Popup里滚动体验会明显下降。用户想找一个冷门选项可能要滚很久。这个时候要做远程搜索。我的做法是在弹层顶部加一个van-search监听search或update:model-value配合防抖然后调接口拿匹配结果替换options。这里有一个小技巧不要把搜索到的结果直接赋值给底层options否则选完之后再打开弹层临时options已经被覆盖已选项的label会显示不出来。正确做法是维护两个变量const allOptions ref([...]); // 全量数据用于回显 const visibleOptions ref([]); // 弹层内展示的数据搜索时替换回显displayText时始终用allOptions去找label弹层列表渲染用visibleOptions。这样无论搜索怎么折腾已选项的展示都不会丢。5. 常见问题与排查技巧实录5.1 弹层里的滚动把页面背景带跑了这是移动端弹层最常见的坑。Vant 的Popup默认带了lock-scroll理论上能锁住背景滚动。但如果你把Popup通过teleport移到了body下或者外层页面结构比较复杂偶尔还是会锁不住。我的排查顺序是确认Popup是否设置了lock-scrollVant 4默认是 true检查弹层内部有没有自己写的overflow-y: auto容器如果外层的高度没有被正确约束滚动会冒泡到body实在不行在Popup的open和close事件里手动加function lockBody(lock) { document.body.style.overflow lock ? hidden : ; }当然手动锁body会影响页面内其他固定定位的元素所以这是兜底方案不是第一选择。5.2 多选确定后页面没刷新如果你在confirmMultiSelect里执行了multiValue.value [...tempMultiValue.value]之后发现页面上的展示文字没变大概率是displayText里的computed没有依赖正确的响应式数据。举个例子如果你把displayText写成了普通函数并且只在Field的click里调用一次那无论multiValue怎么变Field的显示都不会更新。始终要把展示文本定义成computed它才会跟随multiValue的引用变化而自动重算。另外还有一个容易忽略的点如果你的选项数据是从接口异步加载的而multiValue又是在接口返回之前就被赋值了那computed可能匹配不到label。解决办法是在接口返回后手动触发一次展示刷新或者让displayText的匹配逻辑不依赖数组顺序只依赖值本身。5.3 同一页面多个Select互相干扰一个页面里有两三个下拉选择框太常见了。如果每个选择框都复制一份Popup Radio的代码很容易出现两个弹层互相覆盖或者临时值串了的问题。根本解法是封装成独立组件每个组件维护自己的tempValue和popupVisible。我在第6节会给出封装思路。如果你暂时不想封装至少也要保证每个选择框的tempValue、popupVisible是独立的ref不要图省事共用一个变量。5.4 Vant 3和Vant 4的API差异很多老项目还在用 Vant 3新项目已经切到 Vant 4。两者在 Select 相关写法的差异主要集中在差异点Vant 3Vant 4弹层双向绑定v-modelshowv-model:showshow组件按需引入需要手动注册推荐vant/auto-import-resolver主题定制themeVars在根节点配置ConfigProvider组件CSS 变量命名--van-popup-round-radius类似但部分变量有变化整体逻辑差异不大上面代码在 Vant 3 里只要把v-model:show改成v-model基本可以跑通。Vant 2 是另一套风格已经不推荐新项目使用。6. 封装成通用组件团队协作更省心6.1 用新建一个可复用的 SelectPanel把前面一整套逻辑收敛成一个组件核心接口保持简单团队里其他人拿到就能用template SelectPanel v-modelformData.role :optionsroleOptions label角色 placeholder请选择角色 typesingle / /template组件内部需要做的事情v-model绑定最终值v-model对应update:modelValue事件options负责渲染选项并驱动displayText的计算type区分单多选弹层的开关、临时值、确认取消逻辑全部内聚在组件内部对外抛出update:modelValue事件提交表单时拿到的就是最终值。大致骨架如下// SelectPanel.vue 核心逻辑 const props defineProps({ modelValue: { type: [String, Number, Array], default: () [], }, options: { type: Array, required: true, }, label: String, placeholder: String, type: { type: String, default: single, // single | multiple }, }); const emit defineEmits([update:modelValue]); const popupVisible ref(false); const tempSingle ref(); const tempMulti ref([]); function openPanel() { if (props.type single) { tempSingle.value props.modelValue; } else { tempMulti.value [...(props.modelValue || [])]; } popupVisible.value true; } function confirm() { if (props.type single) { emit(update:modelValue, tempSingle.value); } else { emit(update:modelValue, [...tempMulti.value]); } popupVisible.value false; }6.2 插槽与自定义底部按钮的扩展通用组件最大的敌人是变化的需求。比如今天弹层底部只要一个“确定”明天要加“清空”后天又要加“新增选项”按钮。这时候就要用Popup底部插槽来做扩展。你可以把弹层内部拆成几个具名插槽#header覆盖头部标题区#search在选项列表上方放搜索框#footer放底部按钮区默认插槽直接渲染选项内容。核心的“确定/取消”逻辑保留在组件内部业务方只需要通过插槽注入额外UI不需要重写一套弹层逻辑。6.3 预留远程搜索与异步加载给组件增加一个remote-search开关打开后弹层内渲染van-search并把搜索词通过事件抛给父组件const emit defineEmits([update:modelValue, search]); function handleSearch(query) { emit(search, query); }父组件监听search事件请求接口后把返回的选项通过options属性传回来。整个组件不需要关心接口细节只需要把“搜索词”和“数据源”对接好即可。最后再分享一个小技巧封装组件时不要把options的默认值设成[]而是设成() []。尤其在 Vant 的Form里如果初始值是同一个数组引用多个组件会互相污染数据源。用函数返回新数组从根上避免这个问题。我最初做这套方案的时候也纠结过是不是直接用 Picker 或者第三方库更省事。后来踩过几轮坑发现“Field Popup Radio/Checkbox”这套组合虽然代码看着多一点但胜在可维护、可控、可扩展。你把这个骨架吃透了往后不管遇到什么下拉选择需求改起来都很快。
返回列表