最近在弄一个同城业务的小程序,最绕不开的就是城市选择器。一开始以为用picker就可以,但客户要那种带字母索引的侧边栏,还要能搜索。uniapp官方没有现成的,很多开源组件一用就各种兼容问题,尤其App端和微信小程序表现不一致。后来干脆自己写了一个,前前后后踩了不少坑。今天把这个组件一步步拆给大家看,顺便聊聊跨端的那点事。
一、数据准备
城市数据是“城市名+拼音首字母”的数组,大概是这样:
[
{ name: '北京', key: 'B' },
{ name: '上海', key: 'S' }
]
实际项目里,我习惯把数据按首字母分组,弄成一个对象:{ A: [{…}], B: [{…}] },这样前端展示效率更高。数据量就几百个城市,后端一次性拉全量就行。
二、页面骨架
按uniapp惯例,页面就是一个 .vue 文件。先写模板结构。我们需要一个搜索框、一个滚动的列表区域、一个侧边索引条。注意这里没有写任何样式,只关注布局和逻辑。
<template>
<view class="page">
<view class="search-bar">
<input v-model="keyword" placeholder="输入城市名或拼音" />
</view>
<scroll-view class="city-list" :scroll-y="true" :scroll-into-view="scrollIntoId" scroll-with-animation>
<view v-for="group in filteredGroups" :id="'city-' + group.letter" :key="group.letter">
<view class="letter-header">{{ group.letter }}</view>
<view v-for="city in group.cities" :key="city.name" @tap="selectCity(city)" class="city-item">
{{ city.name }}
</view>
</view>
</scroll-view>
<view class="index-bar">
<view v-for="letter in letters" :key="letter" @touchstart="touchStart" @touchmove.stop.prevent="touchMove" @touchend="touchEnd">
{{ letter }}
</view>
</view>
</view>
</template>
这里用 scroll-view 而不是页面自带的滚动,是为了配合 scroll-into-view 实现点击索引跳转。后面会说到。
三、核心逻辑:侧边栏滑动
侧边索引条是整个组件最容易翻车的地方。思路很简单:手指在索引条上滑动时,根据触摸点的 Y 坐标计算出对应的字母,然后修改 scrollIntoId 让列表滚动到对应城市组。
但是要拿到“索引条”在整个页面上的位置,这需要用到 uni.createSelectorQuery()。有个坑是 App 端返回的像素单位和 H5 端不一样,所以我们还需要用 uni.getSystemInfoSync() 里的 windowWidth 去换算。稳妥起见,我在组件挂载时查询一次:
mounted() {
const query = uni.createSelectorQuery().in(this);
query.select('.index-bar').boundingClientRect((rect) => {
this.barTop = rect.top;
this.barHeight = rect.height;
}).exec();
}
然后在 touchmove 和 touchstart 里统一调用一个更新索引的方法:
methods: {
touchStart(e) {
this.updateIndex(e);
},
touchMove(e) {
this.updateIndex(e);
},
touchEnd() {
// 不需要额外处理,也可以做一下高亮回位
},
updateIndex(e) {
const touch = e.touches[0] || e.changedTouches[0];
const clientY = touch.clientY;
const dy = clientY - this.barTop;
let ratio = dy / this.barHeight;
if (ratio < 0) ratio = 0;
if (ratio > 0.99) ratio = 0.99;
const index = Math.floor(ratio * this.letters.length);
this.scrollIntoId = 'city-' + this.letters[index];
}
}
这样手指滑动时,列表会跟手。但要注意:有些小程序端 e.touches 可能为空,为了稳妥,我使用了 touch[0] || touch.changedTouches[0] 的写法。
四、搜索过滤逻辑
搜索功能不是难点,关键在于过滤后侧边索引字母也要同步变化。所以我没有直接用源数据,而是写了一个计算属性 filteredGroups:
computed: {
filteredGroups() {
if (!this.keyword.trim()) {
return this.cityGroups;
}
const kw = this.keyword.toLowerCase();
const result = [];
this.cityGroups.forEach(group => {
const cities = group.cities.filter(city =>
city.name.includes(this.keyword.trim()) || city.pinyin.includes(kw)
);
if (cities.length) {
result.push({ letter: group.letter, cities });
}
});
return result;
},
letters() {
return this.filteredGroups.map(group => group.letter);
}
}
这样搜索“上海”之后,索引条就只显示 “S”,列表也自动收缩。如果搜索词没有匹配任何城市,侧边索引条会变成空数组,那就不会出现滚动错乱的问题。
五、封装成组件
如果只在一个页面用,写到页面里就够了。但城市选择器这种场景,很可能在登录页、首页、发布页都要用,所以我还是包装成了组件。组件考虑两点:
- 支持父组件传入初始城市,比如
value或者defaultCity。 - 选择城市后触发事件,把城市对象交给父组件。
在 Vue 3 组合式 API 下面,组件大概是这样:
<template>
<view class="city-picker">
<view class="mask" @tap="close"></view>
<view class="dialog">
<view class="search-bar">
<input v-model="keyword" placeholder="输入城市名" />
</view>
<scroll-view class="city-list" :scroll-into-view="scrollIntoId">
<!-- 同前面的列表结构 -->
</scroll-view>
<view class="index-bar">...</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
const props = defineProps({
modelValue: String,
cities: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'change']);
// ...内部逻辑
</script>
然后在父组件中这样用:
<city-picker v-model="city.name" :cities="cityList" @change="onCityChange" />
这样就把组件和业务解耦了。父组件的 city.name 会显示在输入框上,子组件弹出层选择后通过 emit 更新。
六、跨端必须注意的坑
刚才踩了一个大坑,就是 scroll-into-view 的 id 在微信小程序里不能以数字开头,不能包含特殊符号。我之前写的是 id="B",结果怎么都不生效,改成 id="city-B" 就好了。
还有,在 App 端,@touchmove.stop.prevent 有时候不生效,页面还是会跟着滚动。解决的办法是给索引条加上 catchtouchmove?不过 uni-app 里没法直接用 catchtouchmove,只能用 @touchmove.stop。如果仍是滚动穿透,可以给索引条加上 disable-scroll: true?但那个是 scroll-view 的属性,不适用于普通view。实际测试下来,微信小程序和 H5 用 @touchmove.stop.prevent 都可以,App 端比较顽固,我最后是给页面加了超时处理,或者改用小程序的 cover-view?后来发现是项目里没开“renderjs”,导致 App 端 touch 事件和普通 view 滚动冲突。但如果你只是实现城市选择器,可以在选择器弹出的时候给 scroll-view 设置 :scroll-y="false",这样就不会跟索引条抢滚动了。
另外,iPhoneX 底部安全区的问题,索引条最底下的按钮会被 home indicator 挡住,需要在索引条下边加一个 padding-bottom 为 env(safe-area-inset-bottom),这个可以写内联样式吗?按理说我们不写样式,但这里提一下,开发者可以自己加个 class。
七、性能优化
几百个城市用 v-for 渲染完全没问题,但如果做全国县区级选择器,3000多个城市同时渲染,在低端安卓 App 上会明显卡顿。一次性能优化是把列表变成“可视区域渲染”,但是 uni-app 社区没有特别好的通用方案,而且代码复杂度会翻倍。我在做的时候没有直接用虚拟滚动,而是给索引滑动加了一个节流:
function throttle(fn, delay = 30) {
let timer = null;
return function(...args) {
if (timer) return;
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
}
}
把 updateIndex 用节流包一下,滑动时会减少很多次 scroll-into-view 的赋值,渲染压力降低不少。在真机上滑动特别快时体验会稍微好一些。
八、最终效果与总结
我在微信开发者工具、H5、以及安卓真机上测试了这个小组件。除了 App 端初次渲染稍慢外,其他表现基本一致。现在同城项目的三个页面都复用了这个城市选择器,代码量并没有增加多少,还顺手删掉之前引入的 ui 组件库里的 picker 插件。
其实这种自定义组件,核心就是两点:触摸事件的跨端处理,以及 scroll-view 的 scroll-into-view 控制。如果这两点梳理清楚,再配合 Vue 的响应式数据,做一个城市选择器并不复杂。以后如果想加上“热门城市”或“定位城市”,只需要在数据层多向 filteredGroups 塞一组数据就行,完全不影响索引逻辑。
遇到问题还是要先查官方文档,多写点真机测试,这个组件我前前后后调了一整天,踩了不少冤枉路。分享出来,希望大家可以少走这些坑。

