
1. 为什么放着现成组件不用非要手搓一个年月选择器先交代一下背景。我一直在做跨端业务主力框架是 uniapp目标平台覆盖 AppiOS/Android、各家小程序和 H5。之前项目里需要用户选择某年某月场景是账单筛选、报表切换这类低频但操作频次不低的交互。一开始我肯定先找现成的市面上有不少日期选择器picker的modedate也能凑合但真正落到业务里问题就来了modedate需要传完整日期哪怕我只想要年月它也得先给个20260101这种值回显和二次选择都别扭。第三方组件库确实有年月选择器但多端兼容性全凭作者维护有的在微信小程序里一切正常到 App 端 calendar 样式就崩或者弹出层在 H5 上定位错乱。我们的 UI 设计稿要求弹窗居中、蒙层半透明、确认和取消按钮在底部这类定制需求改第三方组件往往比写一个新的还费劲。所以最后我决定自己写一个。核心诉求就三条弹窗形态居中模态框、年月二级滚动年滚轮 月滚轮、多端表现一致。做完之后顺便把它沉淀成了项目里的通用组件目前已经在 iOS、Android、微信小程序、H5 四个端跑过一遍表现稳定。这篇文章就把整个手搓过程拆开讲从选型思路到核心代码再到多端适配的坑一次性说清楚。2. 先想清楚这个组件到底要做什么写代码之前最忌讳上来就撸我先列了一下交互预期和使用边界。2.1 交互预期用户看到什么、点哪里用户从某个入口进来点一下输入框或者筛选按钮页面中间弹出一个居中的面板面板上两层滚轮左边是年份比如 2020-2026右边是月份01-12底部分别是取消和确认两个按钮。面板上方蒙层点击蒙层可以收起。选中年份和月份后点确认组件通过事件对外抛出一个格式化好的值比如202601字符串形式方便直接塞给后端。这是最典型的使用预期整个组件闭环就这三件事弹出、选择、回调。这里我没做打开时定位到当前年月以外的任何高级交互比如跨年禁用、默认值外部传入这些可以在基础版本上扩展后面我会单独讲扩展思路。2.2 数据格式约定用字符串还是对象这个点看着小实际特别影响后续对接。我最终采用的是对外统一传字符串YYYYMM对内用一个会有year和month两个字段的对象。原因很朴素后端接口一般就吃202601这种字符串前端自己展示的时候需要拆成年和月对象在外传还得 serialize多一道工序。而且字符串在 URL query 里直接能拼不需要二次处理。内部组件里维护的响应式数据是这样data() { return { yearList: [], // 年列表例如 [2022, 2023, 2024, 2025, 2026] monthList: [], // 月列表固定 [01, 02, ..., 12] selectedYear: 2025, // 当前滚动选中的年份 selectedMonth: 06, // 当前滚动选中的月份 } }月份我存的是数字06而不是字符串06因为picker-view的change事件里拿到的current是索引我需要拿索引去monthList里取值而monthList存的是字符串06。这样在传给后端之前monthList[monthIndex]直接就是最终想要的格式。2.3 边界考虑年份区间和默认值年份区间我限定为当前年往前推 4 年往后推 2 年。为什么是 4 和 2因为业务场景是报表筛选最近几年是高频区未来偶尔会有人要选下一年两年够用了。如果你要选出生日期往前 50 年都不够这个区间应该设计成属性暴露出来让外部传入。默认值就是当前年月。但这里有个细节当你把默认年月传入组件内部时一定要记得把selectedYear和selectedMonth设置成对应的列表索引而不是直接设置成年份数字。因为picker-view的value只认索引。picker-view :value[yearIndex, monthIndex] changeonChange picker-view-column view v-foritem in yearList :keyitem{{ item }}年/view /picker-view-column picker-view-column view v-foritem in monthList :keyitem{{ item }}月/view /picker-view-column /picker-view[yearIndex, monthIndex]这里必须是一个长度为 2 的数组分别对应两个列。想找当前年月的索引就findIndex一下const currentYearIndex this.yearList.findIndex(item item currentYear) this.yearIndex currentYearIndex不要偷懒直接写死不然 2026 年再打开组件它还在 2025 年的位置。2.4 选型对比为什么选 picker-view 而不是 swiper 或者自绘滚轮实话讲uniapp 里实现滚动选择有不少路子swiper加垂直轮播、scroll-view做边界循环、甚至自己写触摸监听。但我最后选picker-view理由有三个它是官方原生封装多端有底层原生组件支撑滚动惯性、回弹效果在 iOS 和 Android 上都有一致的原生手感这是我手写触摸监听永远比不上的。picker-view-column天然支持滚动选中我不需要对每一项做滚动位移计算代码量直接少一半。它天然不会冒泡影响外层滚动没有scroll-view那种嵌套滚动的经典问题。这里多说一嘴很多小程序端的picker-view在 App 端会有渲染层级问题我在第三部分专门讲怎么处理。3. 核心骨架年月选择器主体逻辑实现3.1 模板结构弹层、蒙层、滚轮、按钮一个标准的模态框要有四层结构蒙层、面板、滚轮区、按钮区。代码结构如下template view v-ifvisible classym-modal-overlay taponOverlayTap view classym-modal-panel tap.stop view classym-modal-title{{ title || 请选择年月 }}/view picker-view classym-modal-picker :value[yearIndex, monthIndex] changeonChange picker-view-column view v-foritem in yearList :keyitem classym-modal-picker-item {{ item }}年/view /picker-view-column picker-view-column view v-foritem in monthList :keyitem classym-modal-picker-item {{ item }}月/view /picker-view-column /picker-view view classym-modal-footer view classym-modal-btn ym-modal-btn-cancel taponCancel取消/view view classym-modal-btn ym-modal-btn-confirm taponConfirm确认/view /view /view /view /template这里有一个小细节需要展开讲一下tap.stop。这个stop是为了阻止面板上的点击事件穿透到蒙层。因为蒙层本身绑定了tap如果面板上没有stop用户点击面板任意位置包括滚轮和按钮都会被判定成点击蒙层组件就会立刻收起。最典型的表现就是用户刚想拨滚轮面板消失了体验非常差。另一个细节是v-ifvisible。我为什么不用v-show因为弹窗组件出现在页面节点树里如果做成v-show它在 H5 端会常驻 DOM某些小程序端也容易出现层级问题。用v-if保证每次打开都重新渲染picker-view初始化时value索引必定是最新的不会有缓存的旧位置问题。3.2 样式方案固定定位加层级样式上我没有动picker-view的原生滚动条而是把整个弹窗做成了一个居中的面板。核心样式如下.ym-modal-overlay { position: fixed; left: 0; top: 0; right: 0; bottom: 0; z-index: 9999; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; } .ym-modal-panel { width: 600rpx; background: #ffffff; border-radius: 24rpx; overflow: hidden; display: flex; flex-direction: column; } .ym-modal-picker { height: 420rpx; width: 100%; } .ym-modal-picker-item { height: 68rpx; line-height: 68rpx; text-align: center; font-size: 32rpx; color: #333; }这里有一个 App 端特有的坑原生控件层级压不住 WebView 层的普通元素。在 iOS 上如果z-index不够picker-view会直接盖在蒙层上面背景变白或者蒙层形同虚设。实测下来z-index: 9999在大部分机型上够用但如果你实在遇到层级穿透可以用 uniapp 官方提供的subNVue或者页面内使用原生导航栏避开这种场景不常见但遇到就知道疼。这个组件里我没用rpx去写死高度滚轮高度420rpx是给picker-view划出的可视区域内部每个 item 高度68rpx两者配合能让滚动居中效果明显。不要嫌rpx老土在 uniapp 里它就是最稳的长度单位不同端表现一致。3.3 JS 逻辑初始化、监听滚动值、对外事件组件内部逻辑分成四块初始化、监听滚轮变化、确认回调、取消/蒙层关闭。初始化部分我在watch监听visible一旦切换为true就执行一次重置。为什么不放在created因为v-if控制下组件每次打开都重新挂载created当然可以但如果未来你改成v-show就必须依赖watch。我现在用的是v-if所以初始化逻辑放在created里也是通的只是习惯上用watch更保险。watch: { visible(newVal) { if (newVal) { this.initData() } }, }, methods: { initData() { const now new Date() const currentYear now.getFullYear() const currentMonth now.getMonth() 1 this.yearList [] for (let i currentYear - 4; i currentYear 2; i) { this.yearList.push(i) } this.yearIndex this.yearList.findIndex(item item currentYear) this.monthIndex currentMonth - 1 this.selectedYear currentYear this.selectedMonth currentMonth }, onChange(e) { const current e.detail.value this.yearIndex current[0] this.monthIndex current[1] this.selectedYear this.yearList[current[0]] this.selectedMonth this.monthList[current[1]] }, onConfirm() { const result ${this.selectedYear}${this.monthList[this.monthIndex]} this.$emit(confirm, result) this.$emit(update:visible, false) }, onCancel() { this.$emit(cancel) this.$emit(update:visible, false) }, onOverlayTap() { this.$emit(cancel) this.$emit(update:visible, false) }, }这里有个细节monthList我固定为[01, 02, ..., 12]所以在onConfirm里我不需要做补零操作直接取this.monthList[this.monthIndex]就是两位字符串。selectedMonth存的是数字月份但如果你的业务里需要展示在别的位置要注意自己在模板里补零。注意picker-view的change事件返回的e.detail.value是一个数组长度等于列数顺序就是你在模板中定义的顺序不要搞反。3.4 组件对外接口属性、事件、双向绑定组件要做成通用件必然要支持外部传参和事件监听。我最终暴露的接口很简单visible控制弹窗显隐用.sync修饰符即update:visibletitle弹窗顶部的标题文字confirm事件点击确认后触发参数为格式化后的YYYYMMcancel事件点击取消或蒙层触发用法就是这样ym-year-month-picker :visible.syncpickerVisible title选择筛选月份 confirmonMonthConfirm cancelonMonthCancel /如果你不喜欢.sync也可以这样写ym-year-month-picker :visiblepickerVisible confirmonMonthConfirm update:visibleval this.pickerVisible val /两种方式等价看团队习惯。这里我不建议用v-model因为组件里有两个维度的状态需要对外同步——弹窗显隐是一个选中结果又是一个。v-model只能绑定一个值强行塞两个语义进去反而让调用方犯迷糊。4. 多端兼容的实操要点与踩坑记录这部分是我觉得最有价值的部分我把在四个端实测遇到的实际问题和解决方式列出来。这些问题很多是文档不会告诉你但真机调试才发现的硬骨头。4.1 微信小程序端picker-view 的默认样式和 item 高度微信小程序里picker-view的默认高度是自适应的如果你没有给它设宽度和高度它可能只显示一行或者干脆把内容挤在一起。我最初在 H5 上调得好好的切到小程序端发现滚轮只看到半个 item还以为代码写错了排查半天发现就是没给picker-view设高度。解决方式就是我在 CSS 里那两行.ym-modal-picker { height: 420rpx; width: 100%; }高度设完还有一个问题每个 item 的文字在滚动时会有个固定高亮位置这个是通过::after伪元素画出来的指示条。但小程序里picker-view的伪元素支持在不同基础库版本里表现不同有的早期版本压根不渲染。为了不折腾我没做指示条靠背景色区分选中区。如果你确实想要指示条效果建议这样处理不要用伪元素直接在picker-view后面盖一个上下边框的普通view绝对定位置于picker-view中间然后让picker-view的indicator-style去自定义。picker-view classym-modal-picker :indicator-styleheight: ${itemHeight}rpx; :value[yearIndex, monthIndex] changeonChange indicator-style在小程序里是可用的可以设置选中框的样式。这个比伪元素方案稳健太多。4.2 H5 端蒙层下的页面滚动穿透H5 端最大问题就是滚动穿透。用户在弹窗里滚动滚轮时手指释放后事件会冒泡到页面外层导致页面也跟着滚动。要在 H5 端彻底解决我给overlay加了一层touchmove.prevent。view v-ifvisible classym-modal-overlay taponOverlayTap touchmove.prevent 但这里有个取舍如果弹窗内部有独立的滚动区域比如你说要做多列联动上下滚动touchmove.prevent会把内部滚动一起禁掉。我的场景里picker-view是原生滚动组件加这行不影响它的滚动行为实际测试有效。如果你的弹窗内部还有别的长列表要滑就得用更细粒度的方案比如给内部滚动容器单独处理stop。App 端不需要这个处理原生组件的事件处理机制和 H5 不同我没遇到页面穿透问题。4.3 App 端iPad 等大屏的弹窗宽度适配App 端尤其是 iPad有个坑rpx是基于屏幕宽度的等比单位在 iPad 上 600rpx 换算出来的实际像素巨大。很多适配不佳的组件上了 iPad 后弹窗变得又宽又扁观感特别差。我的妥协方案是给panel加一个最大宽度限制同时兼容横竖屏.ym-modal-panel { width: 600rpx; max-width: 340px; min-width: 260px; }注意这里max-width我用的是px而不是rpx。因为在 iPad 上rpx会被放大px是绝对像素单位可以卡住上限。这个组合值是我在真机上调出来的iPhone 上600rpx约等于 300px看着协调iPad 上被max-width: 340px卡住不会超过屏幕宽的 60%。4.4 各端滚动位置回显差异真机测试比模拟器可靠模拟器里调整好的滚动位置真机上不一定一致因为picker-view的渲染和value数组传入的时机在不同端有差异。我的经验是确保picker-view渲染完成后再设置value。具体做法是在visible变为true后用nextTick延迟一下watch: { visible(newVal) { if (newVal) { this.initData() this.$nextTick(() { this.yearIndex this.yearList.findIndex(item item this.defaultYear || new Date().getFullYear()) this.monthIndex (this.defaultMonth || new Date().getMonth() 1) - 1 }) } } }不加nextTick的话微信小程序端和 App 端偶尔会出现picker-view初始滚动到了错误位置但是current却显示正确的值从外部看就是滚轮内容和实际值不一致很迷惑。5. 细节打磨与体验优化基础功能跑通之后我开始抠体验。这个阶段的工作往往是拉开组件的实用品质和 demo 差距的关键。5.1 加一个轻量滑动动画不做花哨的过渡弹窗出现时我给蒙层加了个简单的fade动画面板加了个轻微上移的动画。uniapp 里可以用transition但我在多端测试后决定用animation结合v-if来做原因是transition在 App 端有时候不生效。.ym-modal-overlay { animation: ymFadeIn 0.2s ease; } keyframes ymFadeIn { from { opacity: 0; } to { opacity: 1; } }面板的动画单独写省略。这个动画时长不宜超过 0.25s长了显得拖沓短了又看不出来。0.2s 是一个比较跟手的值。5.2 支持默认年月传入业务方在编辑场景可能要给一个历史年月做回显。我在组件上加了两个可选属性defaultYear和defaultMonth没传就用当前年月。props: { visible: Boolean, title: String, defaultYear: { type: Number, default: 0 }, defaultMonth: { type: Number, default: 0 } }initData里判断一下即可const year this.defaultYear || new Date().getFullYear() const month this.defaultMonth || new Date().getMonth() 1这里有两点需要注意我设计的默认年月只生效一次如果外部在弹窗打开期间改了defaultYear不会打乱用户当前正在滚动选择的值。如果defaultYear不在yearList区间内比如去年组件生成时才 2026今年传了个 2020findIndex会返回 -1导致picker-view的value数组错乱。稳妥做法是先判断let yearIndex this.yearList.findIndex(item item year) if (yearIndex -1) { this.yearList.push(year) this.yearList.sort((a, b) a - b) yearIndex this.yearList.findIndex(item item year) }这个扩展很实用因为你永远不知道业务方会传什么年份进来。5.3 确认时返回值格式别只用字符串前面说对外传字符串YYYYMM但真实项目中有时候还要校验一下用户是否真的选过。我加了一个额外的yearMonthObject参数格式是对象this.$emit(confirm, { value: ${this.selectedYear}${this.monthList[this.monthIndex]}, year: this.selectedYear, month: Number(this.monthList[this.monthIndex]), label: ${this.selectedYear}年${this.monthList[this.monthIndex]}月, })label是给页面上展示用的很多时候用户需要一个2026年01月这样的文字直接拼好传出去调用方就不用再写模板逻辑了。很多 UI 库组件我也会习惯看一下它抛出的结构不完全照抄但尽量让调用方少做重复拼接。5.4 键盘安全和页面遮挡在 H5 端重视一下H5 端如果页面内容是列表弹窗打开时底部表格应该有一层视觉上的弱化。我实现的很朴素蒙层本身带了半透明黑背景已经实现了这个效果。唯一要注意的是不要给蒙层设置过低的透明度否则弹出时底下内容依然抢眼用户会找不到当前交互焦点。6. 扩展思路从年月弹窗到更复杂的联动选择组件做出来后团队里立刻有人说能不能改成年季度或者年月日的联动。我在这个思路上做了一点预留这里也分享给有类似需求的读者。6.1 支持年季度模式如果只需要年季度改法很简单把monthList换成quarterList内容为[01, 02, 03, 04]显示文案改为四季度或者第1季度。bean不变onConfirm里拼值的时候用quarterList[quarterIndex]即可。本质上还是一个两列picker-view。6.2 年月日模式年月日就有门槛了月份天数不固定2 月有 28/29 天大小月各有不同。我的做法是再监听一列dayList当monthIndex变化时重新计算该月的天数并重建dayList同时重置dayIndex为 0。getDaysInMonth(year, month) { return new Date(year, month, 0).getDate() }这种三列联动在picker-view里完全可以实现只要记得onChange里拿到新的year和month后先计算天数再生成 dayList然后更新 dayIndex。顺序不能反否则会出现 1 月 31 日切换到 2 月时,日列表还在 31 号的情况用户就莫名其妙地落到一个 2 月 31 日的无效值。6.3 禁用区间限制可选年份范围业务上有一类需求是起始年份和终止年份由外部传入。这个我在组件上预留了startYear和endYear两个属性默认为0表示不限制。const start this.startYear || currentYear - 4 const end this.endYear || currentYear 2 for (let i start; i end; i) { this.yearList.push(i) }如果你连月份也需要限制比如只能选 2026 年 03 月之后的月份那就要在onChange里动态过滤monthList这会引入一些复杂逻辑但能处理。7. 组件可复用性设计与版本管理一个组件写完不算完还要考虑让它在项目里长期活着。7.1 结构上做三层分离我把最终交付的组件拆成了三层纯 UI 层picker-view、蒙层、面板、按钮只负责展示和滚动。交互逻辑层yearList、monthList的生成、onChange解析、对外事件派发。业务扩展层defaultYear、startYear、endYear、title这些 props方便具体页面传入差异化参数。这样拆的好处是如果你只需要一个简版可以删掉第三层如果你要在别的项目复用直接拷贝一二层加重新配 props 就行。不用为了适配所有需求在单个.vue文件里堆几百行。7.2 版本命名和文档我在组件顶部注释里写了版本和变更记录。这个习惯很小但真的能救命尤其是半年后再看自己写的代码没有注释基本等于重读。/** * ym-year-month-picker v1.2.0 * - 支持默认年月回显 * - 支持 startYear/endYear 限制年份区间 * - 支持 confirm 事件返回格式化字符串与对象 */内部变量命名也尽量统一前缀比如ym-方便全局搜索。团队协作时这个前缀还能避免不同组件之间的样式和事件命名冲突。7.3 性能与渲染频次组件本身是低成本控件唯一需要关注的是yearList里如果年份区间特别大比如做了出生日期选择跨度 80 年一次渲染 80 个 item 也不算压力但findIndex在onChange里会频繁触发如果yearList几千个 indexfindIndex会线性扫描。这种场景建议改成Map存储这里只是一个提醒我的组件用不到。8. 写在最后的几点实战体会组件从搭骨架到多端实测我前后花了大概两三个晚上真正踩到的坑其实不超过五个。比起那些动辄上千行配置第三方库的方案手写一个 200 行的组件反而可控得多。我个人的体会是uniapp 里凡是涉及弹层和滚轮交互的组件最稳的多端方案就是picker-view加自定义面板不要用swiper硬顶也不要在picker-modedate上面反复横跳。你只需要把接口设计得简单清晰、样式保持克制剩下的兼容问题大多可以通过高度、定位、z-index 三个维度解决。最后再分享一个小技巧真机扫码调试时把微信开发者工具的增强编译和 App 端的原生渲染开关都测一下很多模拟器没问题的样式在这两个模式下可能出奇奇怪怪的位移。多测一遍上线前就不会被测试同学找上门。如果你也想在自己的项目里加一个年月选择弹窗直接照这个思路写就行。核心代码量不大但做完你会对 uniapp 的多端渲染逻辑有更深的理解。以后再遇到类似的联动选择器心里就有底了。