做 uniapp 微信小程序开发的人,早晚会遇到一个需求:表单里的下拉选择器,光用原生 picker 搞不定。产品经理不会只给你一个“苹果、香蕉、橘子”这样的静态列表,他会在某次迭代里突然说:“这个下拉框里要显示用户的头像、昵称,还要把手机号带出来,编辑时还要自动回显之前选的这个人。”你要是还套原生 picker 或者直接引一个重量级 UI 库,就会被各种定制需求反复折腾。这篇文章记录的是我自己在 Uniapp 微信小程序里封装一个自定义插槽下拉选择器的全过程,从设计思路到完整代码,再到踩过的坑,一次性讲清楚。
这个组件做完之后,我发现它不仅能解决当前项目的需求,后续换到 H5 页面、App 端,也能直接复用同一份代码。它的核心能力是:通过 v-model 双向绑定选中的值;数据源可以传普通数组、对象数组,甚至可以传一个 Promise,组件内部自动归一化;下拉触发区域和选项区域都暴露了插槽,业务方可以从原数据里取任意字段做自定义渲染;编辑场景下需要数据回显时,只需要把 id 传进去,组件会在数据加载完成后自动匹配并预选高亮。整个过程完全不用业务页面去手动找索引、转格式。
为了照顾不同基础的读者,我会先讲清楚为什么要自封装、设计思路是什么,再贴完整代码和接入示例,最后把我在微信小程序端踩过的坑整理成速查表。不管你是刚接触 uniapp 的新手,还是已经写了不少页面的开发者,照着这篇文章的思路走,应该都能少走不少弯路。
1. 为什么需要自封装:原生 picker 的边界在哪
1.1 原生 picker 的三个硬伤
微信小程序自带的 picker 组件,在表单场景里用起来其实挺别扭的。第一个硬伤是展示内容太单一,它只适合渲染“文本列表”,你很难在下拉选项里放一张用户头像、一个状态标签,再混合一段描述文字。有人可能会说,用 picker-view 自己拼一个选择器不就行了?确实可以,但 picker-view 是一个底层滚动选择组件,它要求的 value 是“当前项的索引”,而不是业务 id。这意味着你在编辑页要做一次“根据 id 反查索引”的操作,数据一变索引就得跟着变,联动、回显、排序任何一环出问题,整个选择器就错乱了。
第二个硬伤是样式控制成本高。原生 picker 弹出的列选面板,在 iOS 和 Android 上的视觉表现有细微差异,用户点击触发区域时的交互反馈也不够灵活。你想做一个“点击整行任意位置都能展开”的效果,或者想在触发区域里显示多行信息,原生 picker 做不到,你只能在外面包一层 view 做伪造。
第三个硬伤是定制插槽无从谈起。插槽这个概念,原生 picker 是没有的。一旦 UI 设计稿里出现“头像 + 昵称 + 手机号 + 选中对勾”这种复杂选项结构,原生组件直接就歇菜了。所以对于中后台管理类的表单页面,自封装一个下拉选择器几乎是必经之路。
1.2 UI 库组件和自封装怎么选
我知道很多项目会引入 uView 或者 uni-ui 这类组件库,它们的 picker 组件确实能解决一部分问题,比如字段映射、数据回显都有现成方案。但组件库的问题在于“约定大于自由”,你必须按它的数据格式传参,按它的 API 去处理选中事件。一旦遇到产品想要的样式和组件库默认结构对不上,你就得去翻源码改样式,那感觉比从零写一个还难受。
另外,组件库的体积和依赖也值得考虑。一个下拉选择器可能只用到 UI 库里的两个组件,但打包的时候会把整库的依赖链带进去。对于追求体积控制的小程序项目,这不是一个好选择。自封装就没有这些包袱,你只需要一个 vue 文件,放 components 目录里,用到哪个页面就引哪个页面,逻辑清晰、样式独立,后续扩展也完全由自己控制。
我这里的建议是:如果项目里已经有了成熟的 UI 库体系,并且组件的默认样式刚好能满足需求,直接用库里的没问题;但如果你的场景需要高度定制,或者项目本身就很轻量,那就花半天时间自己写一个,一劳永逸。这篇文章提供的方案就是后者。
1.3 这次封装的目标清单
动手之前,我给自己列了一个需求清单,后面所有代码都是围绕这几条展开的:
- 支持 v-model 双向绑定,业务页面不感知数据查找逻辑。
- 支持字符串数组、对象数组、Promise 等多种数据源格式。
- 数据回显时,即使接口数据异步返回,也能自动匹配并预选高亮。
- 触发区域和下拉选项都支持插槽,允许调用方抓取原始数据的任意字段。
- 支持基础搜索过滤,方便处理长列表。
- 屏蔽微信小程序端的滚动穿透、事件冒泡等平台级问题。
这个清单写清楚之后,后面每一步设计都有据可循了。
2. 整体设计思路:用插槽换掉写死的结构
2.1 v-model 双向绑定与数据归一化
自定义组件里做 v-model,Vue2 的机制是接收 value prop,然后通过 $emit('input') 把新值传出去;Vue3 则改成了 modelValue 和 update:modelValue。Uniapp 目前大部分项目还在用 Vue2 语法,我这篇文章的代码也会以 Vue2 为主,但会标出 Vue3 需要改动的几个点。
v-model 只是表象,核心难点在于“数据归一化”。业务页面可能传来这样的数据:['苹果', '香蕉', '橘子'],也可能是[{ id: 1, name: '苹果' }],还可能是[new Promise(...)]。组件内部不能假设数据的结构,它得把各种输入统一成一份内部用的标准列表,每一项都带有__uid、__label、__value、__origin四个字段。__origin存的是原始对象,这样插槽渲染时才能“抓取任意字段”。
归一化放到组件内部还有一个好处:业务页面只关心“我有什么数据”,不关心“组件怎么匹配”。比如后端给的数据字段名是id、text,而另一个接口返回的是code、name,组件只要通过value-field和label-field两个 props 做好字段映射,就能无缝切换,业务代码一行不用改。
2.2 两层插槽:trigger 和 option 各司其职
自定义插槽下拉选择器听起来复杂,其实核心就两处可插拔:触发区域和选项区域。
触发区域的插槽负责渲染“这个下拉框长什么样”。默认情况它是一个带边框的输入框样式,中间显示当前选中文本,右侧有一个小箭头。如果你想换成按钮、卡片,或者要在选中后显示头像加昵称,就用#trigger插槽覆盖默认结构。插槽作用域里会暴露selectedItem和selectedText,业务页面可以直接读取当前选中的完整对象,自由渲染。
选项区域的插槽是这篇文章标题里“自定义插槽”的重头戏。默认选项只显示一行文本和一个选中的对勾,但业务方可以这样写:
<template #option="{ option, selected }"> <view class="option-item"> <image :src="option.avatar" class="option-avatar"></image> <view class="option-info"> <text class="option-name">{{ option.name }}</text> <text class="option-desc">{{ option.mobile }}</text> </view> <text v-if="selected" class="option-check">✓</text> </view> </template>这里option就是归一化之后的标准项,但它内部保留了__origin指向原始数据,所以像option.avatar、option.mobile这些字段都能直接取到,这就实现了“抓取任意字段”。
2.3 数据回显与预选中实现思路
数据回显是我这次封装最看重的点。在编辑页面里,通常要面对两个异步请求:一个是回显数据接口,返回当前编辑对象的 id;另一个是下拉选项数据接口,返回可选列表。这两个请求谁先回来,在真实网络环境下完全不可预测。如果只监听 value 变化,可能 value 已经传进来了,但 options 还没加载完;如果只监听 options 变化,又可能 options 先到,value 后到。所以组件里必须两个 watch 互相兜底。
预选中的含义是:当列表加载完成且当前 value 能匹配到某一项时,组件不仅要把触发区文本刷新成该项的 label,还要在用户打开下拉面板时,自动让该项出现在可视区域并高亮。如果列表特别长,还需要通过 scroll-into-view 滚动到对应位置。这个逻辑放在组件内部,业务方就不用管“编辑页回显时要定位到第几个选项”这种细节了。
3. 核心细节实现:字段映射与异步数据
3.1 props 定义与默认值
组件取名 select-slot,props 设计如下:
| 参数名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | String/Number/Object | '' | v-model 绑定的当前选中值 |
| options | Array/Promise/String | [] | 数据源 |
| value-field | String | 'value' | 对象数组中值的字段名 |
| label-field | String | 'label' | 对象数组中展示文本的字段名 |
| placeholder | String | '请选择' | 未选中时的占位符 |
| disabled | Boolean | false | 是否禁用 |
| searchable | Boolean | false | 是否开启搜索过滤 |
| search-placeholder | String | '搜索' | 搜索框占位符 |
| panel-max-height | Number | 400 | 下拉面板最大高度,单位 px |
| empty-text | String | '暂无数据' | 空列表展示文本 |
props 的设计原则是“按需开放”。一开始我也想把所有能力都做成参数,后来发现参数多了反而难维护,很多配置项其实用插槽就能解决。比如清空按钮,完全可以在#trigger插槽里自己实现,没必要让组件内置。
3.2 多格式数据源归一化
归一化函数是组件的核心之一,它的逻辑可以概括为:先判断数据源是不是一个真正的数组,再判断第一个元素是不是对象,最后按不同情况生成标准项。
对于字符串数组或数字数组,每一项的 value 和 label 就是它本身,_uid 用索引_值拼接,避免出现相同值时 key 冲突。对于对象数组,从每一项里取valueField和labelField指定的字段,如果字段不存在,就用整个对象作为 value。这里还有一个细节:如果 label 字段为空,组件会退化成展示 JSON.stringify 的结果,防止页面上出现空文本。
我建议在组件内部用__uid来做选中项的匹配,而不是直接用 value。因为 value 可能是 0、空字符串这类 falsy 值,用if (!value)判断很容易出错;另外对象类型的 value 做相等比较也很麻烦,_uid 天然带索引,可以保证唯一性,用作循环 key 和滚动定位都很稳妥。
3.3 数据回显的时序处理
回显逻辑里,我写了一个 syncSelected 方法,它做的事情本质上是:拿着当前 value,去 normalizedOptions 里找匹配项。匹配时做了一个兼容处理,数字 1 和字符串 '1' 要能匹配上,因为接口返回的数据类型往往不可控,这两个值可能互相转换。
isSameValue(a, b) { if (a === b) return true; if (a == null || b == null) return false; if (typeof a === 'object' && typeof b === 'object') { return JSON.stringify(a) === JSON.stringify(b); } return String(a) === String(b); }这个兼容函数看着不起眼,但实际调试数据回显问题时,十次有八次是类型不一致导致的。尤其是 id 字段,后端返回数字,前端从路由参数拿到的是字符串,如果直接===比较,永远匹配不上。
3.4 搜索过滤的实现
searchable 开启以后,组件内部会维护一个 keyword,通过计算属性对 normalizedOptions 做过滤。过滤逻辑很简单,把 label 转小写后判断是否包含关键词。这里不直接在数据源上做变更,而是通过 computed 派生 renderList,这样既不影响回显匹配,也不会污染原始数据。
搜索过滤要注意一个边界:用户过滤之后的列表里可能没有当前选中项,这时下拉面板的高亮会消失。这是合理行为,因为用户正在寻找一个新的选项。实际使用中我建议配合远程搜索使用,也就是把搜索关键词抛给父组件,让父组件重新请求接口,这样选中的值改变之后,列表可以刷新成完整数据。
4. 完整组件代码与页面接入
4.1 select-slot.vue 完整源码
下面是组件的完整源码,Vue2 写法,适用于 HBuilderX 创建的 uniapp 项目。如果你用的是 Vue3 语法,只需要把 props 里的value改成modelValue,把$emit('input')改成$emit('update:modelValue'),其他地方基本不用动。
<template> <view class="select-slot"> <view class="select-slot__trigger" @tap="togglePicker"> <slot name="trigger" :selectedItem="selectedItem" :selectedText="selectedText"> <view class="select-slot__default-trigger" :class="{ 'is-disabled': disabled }"> <text class="select-slot__text" :class="{ 'is-placeholder': !selectedText }"> {{ loading ? '加载中...' : selectedText || placeholder }} </text> <view class="select-slot__arrow" :class="{ 'is-open': isOpen }"></view> </view> </slot> </view> <view v-if="isOpen" class="select-slot__mask" @touchmove.stop.prevent="stopMove" @tap="closePicker" > <view class="select-slot__panel" :style="{ maxHeight: panelMaxHeight + 'px' }" @tap.stop > <view v-if="searchable" class="select-slot__search"> <input v-model="keyword" class="select-slot__search-input" type="text" :placeholder="searchPlaceholder" confirm-type="search" /> </view> <scroll-view scroll-y class="select-slot__scroll" :style="{ maxHeight: (panelMaxHeight - (searchable ? 50 : 0)) + 'px' }" :scroll-into-view="scrollIntoView" > <view v-for="(item, index) in renderList" :key="item.__uid" :id="'nselect-option-' + item.__uid" class="select-slot__option" :class="{ 'is-selected': tempKey === item.__uid }" @tap="selectOption(item, index)" > <slot name="option" :option="item" :index="index" :selected="tempKey === item.__uid"> <view class="select-slot__default-option"> <text>{{ item.__label }}</text> <text v-if="tempKey === item.__uid" class="select-slot__check">✓</text> </view> </slot> </view> <view v-if="!renderList.length" class="select-slot__empty"> {{ emptyText }} </view> </scroll-view> </view> </view> </view> </template> <script> export default { name: 'SelectSlot', model: { prop: 'value', event: 'input', }, props: { value: { type: [String, Number, Object], default: '', }, options: { type: [Array, Promise, String], default: () => [], }, valueField: { type: String, default: 'value', }, labelField: { type: String, default: 'label', }, placeholder: { type: String, default: '请选择', }, disabled: { type: Boolean, default: false, }, searchable: { type: Boolean, default: false, }, searchPlaceholder: { type: String, default: '搜索', }, panelMaxHeight: { type: Number, default: 400, }, emptyText: { type: String, default: '暂无数据', }, }, data() { return { isOpen: false, selectedItem: null, tempKey: '', keyword: '', normalizedOptions: [], loading: false, scrollIntoView: '', }; }, computed: { selectedText() { return this.selectedItem ? this.selectedItem.__label : ''; }, renderList() { const kw = String(this.keyword || '').trim().toLowerCase(); if (!kw) return this.normalizedOptions; return this.normalizedOptions.filter((item) => { return String(item.__label).toLowerCase().indexOf(kw) > -1; }); }, }, watch: { value: { immediate: true, handler() { this.syncSelected(); }, }, options: { immediate: true, handler(val) { this.handleSource(val); }, }, }, methods: { stopMove() {}, handleSource(source) { if (!source) { this.normalizedOptions = []; this.syncSelected(); return; } if (typeof source === 'string') { try { source = JSON.parse(source); } catch (e) { source = []; } } if (source && typeof source.then === 'function') { this.loading = true; source .then((data) => { this.loading = false; this.normalizedOptions = this.normalizeSource(data); this.syncSelected(); }) .catch(() => { this.loading = false; this.normalizedOptions = []; this.syncSelected(); }); return; } this.normalizedOptions = this.normalizeSource(source); this.syncSelected(); }, normalizeSource(source) { if (!Array.isArray(source) || source.length === 0) return []; const first = source[0]; if (first !== null && typeof first === 'object' && !(first instanceof Date)) { return source.map((item, index) => { const value = item[this.valueField]; const label = item[this.labelField]; const uid = value !== undefined && value !== null ? String(value) + '_' + index : String(index) + '_unique'; return { __uid: uid, __value: value !== undefined ? value : item, __label: label !== undefined && label !== '' ? label : JSON.stringify(item), __origin: item, __index: index, }; }); } return source.map((item, index) => ({ __uid: String(index) + '_' + String(item), __value: item, __label: String(item), __origin: item, __index: index, })); }, togglePicker() { if (this.disabled) return; if (this.isOpen) { this.closePicker(); } else { this.openPicker(); } }, openPicker() { this.isOpen = true; this.tempKey = this.selectedItem ? this.selectedItem.__uid : ''; this.keyword = ''; this.$nextTick(() => { if (!this.tempKey) return; this.setScrollIntoView(); }); }, setScrollIntoView() { this.scrollIntoView = 'nselect-option-' + this.tempKey; // 小程序端首次渲染面板时,scroll-view 可能还没完成布局, // 这里做一次延时补偿,确保能滚动到选中项。 setTimeout(() => { this.scrollIntoView = 'nselect-option-' + this.tempKey; }, 50); }, closePicker() { this.isOpen = false; this.scrollIntoView = ''; }, selectOption(item, index) { this.tempKey = item.__uid; this.selectedItem = item; this.$emit('input', item.__value); this.$emit('select', item.__origin, index); this.$emit('change', item.__origin, index); this.closePicker(); }, syncSelected() { const list = this.normalizedOptions; if (!list.length) { this.selectedItem = null; return; } const currentValue = this.value; const found = list.find((item) => this.isSameValue(item.__value, currentValue)); if (found) { this.selectedItem = found; this.tempKey = found.__uid; } else { this.selectedItem = null; } }, isSameValue(a, b) { if (a === b) return true; if (a == null || b == null) return false; if (typeof a === 'object' && typeof b === 'object') { return JSON.stringify(a) === JSON.stringify(b); } return String(a) === String(b); }, }, }; </script> <style scoped> .select-slot { position: relative; width: 100%; box-sizing: border-box; } .select-slot__trigger { width: 100%; } .select-slot__default-trigger { width: 100%; height: 88rpx; padding: 0 24rpx; display: flex; align-items: center; justify-content: space-between; background: #ffffff; border: 1rpx solid #dcdfe6; border-radius: 8rpx; box-sizing: border-box; } .select-slot__default-trigger.is-disabled { background: #f5f7fa; color: #c0c4cc; } .select-slot__text { font-size: 28rpx; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .select-slot__text.is-placeholder { color: #c0c4cc; } .select-slot__arrow { width: 14rpx; height: 14rpx; margin-left: 16rpx; border-right: 3rpx solid #909399; border-bottom: 3rpx solid #909399; transform: rotate(45deg); transition: transform 0.2s; flex-shrink: 0; } .select-slot__arrow.is-open { transform: rotate(-135deg); } .select-slot__mask { position: fixed; left: 0; top: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.3); z-index: 999; } .select-slot__panel { position: absolute; left: 0; right: 0; margin-top: 8rpx; background: #ffffff; border-radius: 12rpx; box-shadow: 0 6rpx 30rpx rgba(0, 0, 0, 0.15); z-index: 1000; overflow: hidden; } .select-slot__search { padding: 16rpx; border-bottom: 1rpx solid #f2f3f5; } .select-slot__search-input { width: 100%; height: 64rpx; padding: 0 24rpx; font-size: 28rpx; background: #f5f7fa; border-radius: 8rpx; box-sizing: border-box; } .select-slot__scroll { width: 100%; box-sizing: border-box; } .select-slot__option { padding: 0 24rpx; } .select-slot__default-option { height: 88rpx; display: flex; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #f2f3f5; font-size: 28rpx; color: #303133; } .select-slot__option.is-selected .select-slot__default-option, .select-slot__option.is-selected { background: #f5f7fa; color: #3b82f6; } .select-slot__check { color: #3b82f6; font-weight: bold; } .select-slot__empty { padding: 40rpx 0; text-align: center; font-size: 26rpx; color: #909399; } </style>这段代码里有两个设计点需要解释一下。第一,selectedItem一旦匹配成功,就不会被后续某个 watch 误清空,因为两个 watch 里都会调用 syncSelected,而 syncSelected 是幂等操作,只要 value 和 options 都到位,结果一定是选中项。第二,下拉面板用的是绝对定位,挂在触发区域的下方,而不是用 fixed 全屏弹层。这样在下拉面板中滚动时,能自动跟随页面流布局,不会出现面板错位的问题。
4.2 基础用法:字符串数组
如果只需要一个简单的文本下拉,直接把数据源传成一个字符串数组就行,一行额外配置都不用写。
<template> <view class="page"> <select-slot v-model="fruit" :options="fruitList" placeholder="请选择水果"></select-slot> <text>当前选中的值:{{ fruit }}</text> </view> </template> <script> export default { data() { return { fruit: '', fruitList: ['苹果', '香蕉', '橘子', '西瓜', '葡萄'], }; }, }; </script>这个场景下,组件内部会把字符串数组归一化成标准对象列表,选中后通过 v-model 把字符串吐出来,业务页面不用做任何二次处理。
4.3 高级用法:插槽任意展示字段
下面这种用法才是这个组件真正发挥价值的地方。假设数据源里每个对象包含id、name、avatar、mobile四个字段,默认只展示name,但你想在下拉选项里展示头像、昵称、手机号三个信息,同时触发区域也想显示头像加昵称,就可以这样写:
<template> <view class="page"> <select-slot v-model="userId" :options="userList" value-field="id" label-field="name" placeholder="请选择联系人" > <template #trigger="{ selectedItem, selectedText }"> <view class="custom-trigger" :class="{ 'is-empty': !selectedItem }"> <image v-if="selectedItem" :src="selectedItem.avatar" class="custom-avatar" ></image> <text class="custom-trigger-text">{{ selectedText || '请选择联系人' }}</text> <view class="custom-arrow"></view> </view> </template> <template #option="{ option, selected }"> <view class="custom-option"> <image :src="option.avatar" class="custom-option-avatar"></image> <view class="custom-option-info"> <text class="custom-option-name">{{ option.name }}</text> <text class="custom-option-mobile">{{ option.mobile }}</text> </view> <view v-if="selected" class="custom-option-check">✓</view> </view> </template> </select-slot> </view> </template> <script> export default { data() { return { userId: 2, userList: [ { id: 1, name: '张伟', avatar: '/static/avatar1.png', mobile: '13800000001' }, { id: 2, name: '李娜', avatar: '/static/avatar2.png', mobile: '13800000002' }, { id: 3, name: '王芳', avatar: '/static/avatar3.png', mobile: '13800000003' }, ], }; }, }; </script>这里注意,option作用域里拿到的对象,实际上是归一化后的标准项,它内部带着__origin指向原始对象。因为我们在归一化的时候把原始对象的字段都铺平了,所以可以直接option.avatar,不需要再写option.__origin.avatar。这个小小的设计省了不少事。
4.4 异步加载与回显组合场景
编辑页最常见的一个场景是:进入页面后,两个接口并发请求,一个拿详情数据,一个拿下拉选项。下面的代码用 Promise.all 模拟了这种情况:
<template> <view class="page"> <select-slot v-model="form.categoryId" :options="categoryOptions" value-field="id" label-field="name" searchable placeholder="请选择分类" ></select-slot> </view> </template> <script> export default { data() { return { form: { categoryId: '', }, categoryOptions: [], }; }, mounted() { this.initPage(); }, methods: { async initPage() { const [detail, categoryList] = await Promise.all([ this.fetchDetail(), this.fetchCategoryList(), ]); this.form.categoryId = detail.categoryId; this.categoryOptions = categoryList; }, fetchDetail() { return new Promise((resolve) => { setTimeout(() => { resolve({ categoryId: 12 }); }, 100); }); }, fetchCategoryList() { return new Promise((resolve) => { setTimeout(() => { resolve([ { id: 5, name: '前端开发' }, { id: 12, name: '后端开发' }, { id: 18, name: '数据分析' }, ]); }, 600); }); }, }, }; </script>在这个场景里,detail.categoryId先被赋值,但categoryOptions还没回来。组件内部的 value watch 先触发,此时列表为空,selectedItem 被置空。600ms 之后 options 到达,options watch 触发,再一次调用 syncSelected,就能正确匹配 id=12 的选项,并把触发区文本刷新成“后端开发”。所以两个 watch 缺一不可,只监听一个必定在某些时候出问题。
5. 常见问题与排查实录
5.1 v-model 在 Vue2/Vue3 里的差异
这是迁移项目时最容易踩的坑。Vue2 的自定义组件 v-model,默认要求接收valueprop 并触发input事件;Vue3 则改成了modelValue和update:modelValue。如果你在同一个 uniapp 项目里既有 Vue2 又有 Vue3 页面,建议统一封装一层适配:组件内部同时声明value和modelValue两个 prop,根据$emit的事件名做分发。
不过说实话,uniapp 项目最好是统一 Vue 版本,混用会带来很多隐性成本。我的建议是:检查一下项目的 manifest.json 里vueVersion字段,确认是 2 还是 3,然后代码里只保留对应写法。如果你想做双版本兼容组件,可以在 props 里同时声明,再通过 computed 归一化成内部值,但这样代码会多出一层,不是特别必要。
5.2 数据回显时高亮无效
这个问题我在组件代码里已经做了双重保障,但如果你是在自己的组件里重新实现,可能会遇到。症状是:编辑页进入时,选项数据加载完成,文本也显示正确了,但用户点击下拉,选中项没有高亮,或者高亮跑到了第一项。
原因通常出在时序上:下拉面板第一次弹出来时,scroll-view 内部布局还没完成,你设置的 scroll-into-view 目标 id 可能还没渲染出来。解决方法是 $nextTick 之后再叠加一个 setTimeout 50ms,给小程序渲染层留出时间。另外要注意,scroll-into-view 的 id 不能以数字开头,页面上滚动目标必须是一个真实渲染出来的节点,如果搜索过滤后目标不在列表里,自然也就无法滚动。
5.3 微信小程序滚动穿透问题
在小程序里,当遮罩层用 fixed 定位覆盖全屏后,手指在遮罩上滑动,底部页面仍然会滚动。这是小程序的老问题,解决方法是给遮罩层加上@touchmove.stop.prevent。我代码里写了一个空的stopMove方法,就是为了阻断 touchmove 事件。
但这里有个细节要注意:下拉面板内部用了 scroll-view,scroll-view 本身是可滚动区域。如果给 scroll-view 也加上 catchtouchmove,可能会把滚动能力也禁掉,所以只给遮罩层加就行。面板容器不需要加,因为滚动事件发生在 scroll-view 内部,冒泡到 mask 时已经被阻止了。
5.4 插槽内容事件绑定不生效
自定义组件里的插槽内容,本质上是在父组件作用域里渲染的。如果你在插槽内部给某个 view 绑定了 tap 事件,小程序端有时会出现事件不触发或者触发两次的情况。排查思路是先确认事件是不是被组件根节点拦截了,再看是否因为父组件作用域和子组件作用域混用导致绑定失败。
我的处理方式是:面板中每个选项的整体点击事件由组件内部统一处理,插槽内容只负责展示;如果业务方确实需要在选项内部单独绑定点击事件,就在插槽的最外层容器上加上@tap.stop,这样既不会冒泡到组件自身的选中逻辑,也能正常触发自己的事件。这是我实际调试过的问题,亲测有效。
5.5 面板最大高度的计算
panelMaxHeight 默认 400px,但在 iPhone 小屏机型上,如果触发区域距离屏幕底部很近,面板可能会超出屏幕。我建议在页面接入时结合uni.getSystemInfoSync()动态计算:
const sys = uni.getSystemInfoSync(); const windowHeight = sys.windowHeight; const triggerTop = 200; // 获取触发区域的 top 值 const safeBottom = 80; this.maxHeight = Math.min(400, windowHeight - triggerTop - safeBottom);计算之后传给组件的panel-max-height。这个值不宜写死,因为不同机型的可视区域差异很大,写死了在部分机型上会出现体验问题。
6. 后续扩展思路
组件现在已经能覆盖大部分单选下拉场景,但如果你有更多需求,可以在现有基础上继续扩展。
多级联动是常见的方向。比如省市区选择器,可以把 value 改成数组,options 改成嵌套结构,选中一级后,面板内容自动切换成二级列表。现有的插槽机制不需要大改,只需要在组件内部加一个 level 状态,然后在 selectOption 时根据当前层级决定是继续深入还是收起面板即可。
多选支持也比较直观。把 value 的类型扩展成数组,选中时做 toggle,触发区域默认渲染多个 tag。如果选中的项太多,可以给 tag 加一个超出隐藏的样式。插槽区域也可以根据 selected 状态显示对勾或者取消勾选图标。
远程搜索也很实用。现在 searchable 是在本地过滤,如果数据量上万,本地过滤就会卡。可以给组件加一个remote参数,当 keyword 变化时向外抛一个search事件,由父页面去请求接口,再把新数据赋值给 options。这样组件不用改数据结构,只用加一段 watch keyword 的逻辑。
这些扩展场景都建立在统一的归一化数据模型之上,所以后续不管怎么加功能,回显、高亮、插槽这些基础能力都能复用。
最后说一点我在实际项目里的体会。组件封装最忌讳一开始就做大而全,把什么参数都加上,结果调一个 bug 得排查十几个条件分支。我最初写这个下拉选择器时,就犯过这个毛病。后来重构的时候,把核心收敛到三个点:v-model 双向绑定、数据源归一化、数据回显匹配,很多边界问题一下子就清晰了。现在再遇到新需求,先看这三个点有没有被破坏,没有就大胆加东西,有就停下来重新设计。这大概就是封装组件的底层方法论吧。