做小程序日历组件,尤其是周历这种带左右滑动切换的场景,很多人第一反应就是拿 Swiper 来做。这个思路本身没错,但真正往下写的时候才会发现,滑动切换周、日期联动、下方数据动态刷新这三件事凑在一起,远比想象中麻烦。我这次要分享的,就是基于小程序 Swiper 组件实现一个周历,支持左右滑动动态修改数据。中间踩过不少坑,也整理了能直接用的实现方式,希望给准备做周历的同学省点时间。
这个方案适合两类人:一类是刚接触小程序、被 Swiper 用法搞晕的新手,另一类是想把现有日历组件做得更顺滑、不闪烁不卡顿的开发者。我会从需求拆解讲到完整实现,再到常见问题排查,全部基于真实跑通的代码,你可以直接复制改一改就用。
1. 需求拆解与整体设计思路
1.1 周历到底要解决什么问题
先说清楚需求:页面上展示一周七天,今天默认高亮,支持左右滑动切换上/下一周;每切一周,页面下方的内容(比如课程列表、打卡记录、待办事项)要跟着变。更进一步,还可以点某个日期,单独选中某一天并刷新当天数据。
这个“动态修改数据”看起来只是 setData 一个数组,但实际上牵扯到日期计算、滑动手势方向、Swiper 的翻页机制、动画结束时机,还有数据请求的节流。很多项目做了一半卡住,基本都是在这些环节出了问题。
我最初拿到这个需求时,第一版做得很简单:一个 Swiper,里面放一个 swiper-item,滑动时替换日期数组。设想是挺好的,但实际跑起来,滑动过程中能明显看到日期跳动,有时候甚至出现“滑到一半数据变了”的鬼畜效果。后来改成三格 Swiper 预渲染,一下就稳了。
1.2 为什么选 Swiper,而不是 scroll-view
也有同学会问,横向滚动用 scroll-view 不也一样吗?理论上确实能做,但靠 scroll-view 实现周历,你需要自己监听 scroll 事件、计算滚动偏移量、判断当前滑到第几周、滑动结束后做吸附校准,跨月和跨年时还得自己保证日期计算不跑偏。这些逻辑自己写一遍,工作量不少,而且体验不一定好。
Swiper 的优势在于它是按页翻的,自带惯性滚动和回弹动画,还有 current 索引可以直接控制当前展示哪一页。配合 bindchange、bindanimationfinish 这些事件,天然适合“翻页切周”的场景。
但 Swiper 也有自己的坑。比如高度必须明确,不然会出现空白页;current 变化和内容更新如果放在同一个 setData 里,容易导致闪烁;真机上动画结束事件触发时机和开发者工具不完全一致。这些坑后面我会单独说。
1.3 组件化与页面结构设计
我的做法是把周历封装成一个自定义组件,周历头部和 Swiper 放组件内部,下方数据区留在页面里。组件只负责日期展示和滑动切换,切换结果通过 triggerEvent 抛给页面,让页面去发起数据请求。
这样拆的好处是:日历逻辑可以复用,其他页面接过来就能用;数据请求和 UI 分离,不会因为请求慢导致日历卡顿。组件需要的入参分别是每周起始日(周一或周日)、当前选中日期、是否需要显示非本月日期等;输出事件主要是一个 change,携带新的周起始日期、一周日期数组和选中日期。
结构上,页面布局是比较常规的:顶部是周历区域,包含一个月份标题和一个 Swiper;下方是内容列表,滚动和加载都独立处理。这里有个关键点,内容列表千万不要放进 swiper-item 里,否则竖向滚动会和左右翻页手势打架,高度也会变得非常难处理。
2. 核心细节解析与实操要点
2.1 日期计算与周历数据的生成规则
日期计算是周历的地基,地基不稳,后面全是空中楼阁。首先要明确一周从周一开始还是从周日开始,这直接决定了计算偏移量的公式。
小程序里new Date().getDay()返回的是 0 到 6,其中 0 代表周日,1 代表周一。如果要让周一作为一周开始,获取当前周一的代码建议这样写:
function getMonday(date) { const d = new Date(date); const day = d.getDay(); // 周日是0,需要往前推6天到周一 const diff = day === 0 ? -6 : 1 - day; d.setDate(d.getDate() + diff); d.setHours(0, 0, 0, 0); return d; }这里最容易被坑的地方是周日。如果你直接用1 - day,周日的时候会得到1 - 0 = 1,意思是往后加一天,结果跑到周二去了。所以周日必须单独处理。
拿到周一之后,生成一周七天就很简单了,循环 7 次,每次给 Date 对象加一天。注意一定要用setDate(getDate() + 1),不要自己去处理什么“这个月有 30 天还是 31 天”,Date 对象会自动进位,跨月跨年都帮你搞定。
每个日期单元格需要的信息包括:日期字符串(用于 key 和事件传递)、几号、是否今天、是否本月、是否选中。这些布尔值用来控制样式。
function buildWeek(start) { const days = []; const today = new Date(); for (let i = 0; i < 7; i++) { const date = new Date(start); date.setDate(start.getDate() + i); days.push({ key: formatDate(date), // 例如 2025-01-16 day: date.getDate(), month: date.getMonth() + 1, isToday: isSameDay(date, today), isCurrentMonth: date.getMonth() === start.getMonth(), isSelected: false }); } return days; }这里我习惯用isCurrentMonth判断是否属于起始日期所在月份,跨周时非本月的几天会显示成灰色,视觉上更清晰。
2.2 Swiper 的结构设计:单页还是三页预渲染
这是整个周历最核心的决策。
最直觉的方案是只有一个 swiper-item,每次滑动结束把里面的日期数组替换成新一周。我第一版就是这么写的,问题非常明显:Swiper 在滑动过程中会对当前页做位移和缩放计算,如果这时候内部 DOM 结构里的数据变了,页面会重新渲染,视觉上就是日期“跳一下”或者闪烁。尤其快速连续滑动时,基本没法看。
正确的做法是三个 swiper-item 预渲染,分别对应上一周、当前周、下一周。初始时 Swiper 的 current 固定为 1,也就是默认展示中间这一格。用户左滑,从第 1 格滑到第 0 格,动画结束后,我们把三段数据整体平移,再把 current 重置回 1。因为中间格的日期已经变成了用户想要看的那一周,所以回位动作在视觉上是连续的。
这个方案相当于把“翻页”和“数据更新”两个动作解耦:滑动只是让 Swiper 产生动画,数据更新发生在动画结束之后。数据永远是提前渲染好的,用户看不到空白和跳动。
当然也可以开circular实现无限循环,但我建议先别急着开。三格方案配合 current 重置已经很顺滑,开了 circular 反而要处理边界情况和回弹动画,收益不大。
2.3 动态修改数据的正确时机
Swiper 切换周的事件有几个,bindchange会在 current 改变时立刻触发,bindtransition在滑动过程中多次触发,bindanimationfinish是动画结束之后触发。
很多人喜欢在 bindchange 里直接更新数据,这是闪烁的根源之一。因为 bindchange 触发时,Swiper 的滑动动画还没结束,界面正处在位移过程中,这时候 setData 修改 swiper-item 内部内容,等于让正在运动的物体突然换了件衣服,自然会产生视觉撕裂。
我的经验是:在 bindchange 里只记录滑动方向,真正更新数据放到 bindanimationfinish 里做。拿到 e.detail.current 之后,判断当前是第 0 格还是第 2 格,决定是往上周切还是往下周切,然后重新生成三段周数据,更新 current 回 1。
另外,swiper 的 duration 可以适当调短一点,默认 300 毫秒我觉得有点拖沓,改成 200 毫秒,滑动反馈更干脆。
3. 实操过程与核心环节实现
3.1 WXML 结构与样式要点
下面是一份可以直接用的 WXML 结构。Swiper 放在日历区域,里面循环渲染三组周数据,每组是一个横向 flex 布局。
<view class="calendar-wrapper"> <view class="calendar-header"> <text>{{monthTitle}}</text> </view> <swiper class="calendar-swiper" current="{{current}}" duration="{{200}}" bindchange="onSwiperChange" bindanimationfinish="onSwiperAnimationFinish" > <swiper-item wx:for="{{weeks}}" wx:key="index"> <view class="week-row"> <view class="day-cell {{item.isToday ? 'is-today' : ''}} {{item.isCurrentMonth ? '' : 'not-current-month'}} {{item.isSelected ? 'is-selected' : ''}}" wx:for="{{item}}" wx:for-item="day" wx:key="key" >.calendar-swiper { height: 120rpx; } .week-row { display: flex; height: 100%; align-items: center; } .day-cell { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100rpx; border-radius: 12rpx; } .is-today .day-num { color: #ff4d4f; font-weight: 600; } .is-selected { background-color: #1677ff; } .is-selected .day-num { color: #fff; } .not-current-month .day-num { opacity: 0.4; }3.2 JS 数据层与日期刷新核心代码
Component 里的核心数据结构很简单:
Component({ data: { weeks: [], // 二维数组:三段周数据 current: 1, // Swiper 当前索引,固定为中间页 currentWeekStart: '', // 当前周起始日期,例如 2025-01-13 selectedDate: '', // 当前选中日期 monthTitle: '' // 顶部月份标题 }, lifetimes: { attached() { const today = new Date(); this.initCalendar(today); } }, methods: { // 初始化日历,默认选中今天 initCalendar(date) { const start = getMonday(date); const weeks = [ buildWeek(addDays(start, -7)), buildWeek(start), buildWeek(addDays(start, 7)) ]; this.setData({ weeks, currentWeekStart: formatDate(start), selectedDate: formatDate(date), monthTitle: formatMonthTitle(start) }); }, // 绑定 Swiper change,只记录方向 onSwiperChange(e) { this.swiping = true; }, // 动画结束后统一刷新数据 onSwiperAnimationFinish(e) { const current = e.detail.current; if (current === 1) return; const delta = current === 0 ? -7 : 7; const currentStart = new Date(this.data.currentWeekStart); const newStart = addDays(currentStart, delta); const weeks = [ buildWeek(addDays(newStart, -7)), buildWeek(newStart), buildWeek(addDays(newStart, 7)) ]; this.setData( { weeks, currentWeekStart: formatDate(newStart), selectedDate: formatDate(newStart), monthTitle: formatMonthTitle(newStart) }, () => { // 数据更新完成后再把 Swiper 移回中间页 this.setData({ current: 1 }); this.triggerEvent('change', { weekStart: formatDate(newStart), selectedDate: formatDate(newStart) }); } ); }, // 点击具体日期 onDayTap(e) { const date = e.currentTarget.dataset.date; if (!date) return; const newWeeks = this.data.weeks.map(week => { return week.map(day => { return { ...day, isSelected: day.key === date }; }); }); this.setData({ weeks: newWeeks, selectedDate: date }); this.triggerEvent('selectday', { date }); } } });配套的几个工具函数我在上面已经写过,addDays的实现也是同理:
function addDays(date, days) { const d = new Date(date); d.setDate(d.getDate() + days); return d; } function formatDate(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function formatMonthTitle(start) { const end = addDays(start, 6); const y = start.getFullYear(); const m1 = start.getMonth() + 1; const m2 = end.getMonth() + 1; if (m1 === m2) { return `${y}年${m1}月`; } return `${y}年${m1}月 - ${m2}月`; }3.3 数据联动方案:从周历组件外部刷新内容
组件只负责日期部分,页面要做什么数据加载,通过事件联动。比如页面里放一个列表区域:
const app = getApp(); Page({ data: { list: [], loading: false }, onCalendarChange(e) { const { weekStart, selectedDate } = e.detail; // 以“周起始日期 + 选中日期”作为缓存标识 this.loadData(selectedDate); }, onSelectDay(e) { const { date } = e.detail; this.loadData(date); }, loadData(date) { if (!date) return; // 防止滑动太快重复请求 const bitmap = app.calendarCache; if (bitmap[date] && this.data.loading) return; this.setData({ loading: true }); // 模拟请求 setTimeout(() => { const list = [`${date} 的数据1`, `${date} 的数据2`]; app.calendarCache[date] = list; this.setData({ list, loading: false }); }, 200); } });实际项目中数据可能来自接口,我的建议是至少做两层保护:一层是缓存,同一日期已经请求过就直接返回;另一层是 loading 锁,防止用户连续快速滑动时发出多个重复请求。等接口回来再用 props/全局缓存更新数据,不要每次滑动都重新 loading。
3.4 跨月跨年与边界样式
跨月跨年主要靠 Date 对象自动进位,公式上不用特殊处理。需要留意的是两个显示层的地方:一是顶部月份标题,跨月时要显示“2025年1月 - 2月”而不是只显示一个月份;二是非本月日期的样式,我习惯用半透明降低存在感,但依然保留点击能力。
跨年场景下,格式化成YYYY-MM-DD字符串时年份很关键,千万不要只取MM-DD,不然排序和 key 全乱。
4. 常见问题与排查技巧实录
4.1 滑动过程中日期跳动和闪烁
这是我遇到最多的问题,原因也最典型:在 bindchange 里直接更新了日期数据,导致正在动画中的 swiper-item 被重新渲染。
解决办法就是前面说的三格预渲染。同时还有一个小细节:三个 swiper-item 的内部结构务必保持一致,每一天的 key 都要稳定存在,不要让某一天的数据动态新增或删除字段,否则小程序 diff 时会重渲染整个格子,也会闪。
如果闪得特别明显,检查一下是不是在 setData 里同时改了weeks和current。有些机型对同一个 setData 批次里的多维数组和索引更新处理得不好,建议拆成两步:先改 weeks,回调里再改 current。
4.2 Swiper 高度塌陷或显示空白
常见场景是:Swiper 设置了height: 100%,但父容器高度不确定,结果 Swiper 拿到了 0 高度,页面一片空白。有些同学把列表也放进 swiper-item,列表高度变化时 Swiper 内容高度跟着变,也会出现白屏。
我的经验是 Swiper 区域只放周历头部,高度固定,内容列表永远放在 Swiper 外面。如果确实需要 Swiper 高度自适应,可以在数据渲染完成后用wx.nextTick重新设置高度,但周历这种场景没必要,固定高度最简单可控。
4.3 真机与开发者工具表现不一致
开发者工具上动画结束事件触发时机比较“理想”,真机上偶尔会出现动画还没结束就收到 animationfinish,或者快速滑动时丢事件。我的处理办法是不要依赖单个事件,在 bindchange 里记录方向,在 bindanimationfinish 里做数据更新,同时用this.swiping做护栏,确保同一时间只处理一次切换。
另外,真机上new Date()的时区会导致日期字符串相差一天,格式化时尽量不要用new Date('2025-01-16')这种字符串解析方式,兼容性不稳,建议直接用年、月、日构造。
4.4 滑动方向判断不可靠
常见问题是:用户右滑,current 从 1 变成 2,用户左滑,current 从 1 变成 0。按理说很清晰,但快速滑动时,bindchange 可能连续触发两次,比如从 1 滑到 0,紧接着又滑回 1,方向判断就会错。
我加了一道保险:在 touchstart 时记录起始 X 坐标,bindchange 里结合current变化和坐标差值判断真正意图。如果坐标差值很小,说明用户只是轻扫了一下,Swiper 自动弹回,不需要切换数据。
onTouchStart(e) { this.touchStartX = e.touches[0].clientX; }, onSwiperChange(e) { const current = e.detail.current; if (current === 1) return; const deltaX = this.touchStartX ? e.detail.current : 0; this.swipeDirection = current === 0 ? 'prev' : 'next'; }4.5 数据请求重复和竞态问题
快速左右滑动时,bindanimationfinish 可能会连续触发好几次,如果你每次都去请求新数据,后端会收到一堆重复请求,而且响应顺序不一定,最终显示的数据可能是旧请求返回的。
我在项目里用的方案是请求缓存加序号。每次发起请求时带上一个自增序号,回调里判断序号是不是最新,不是最新就丢弃。这样即使旧请求晚回来,也不会覆盖新数据。
4.6 列表滚动与 Swiper 手势冲突
如果把下方列表放进 Swiper 里,用户上下滑列表时,Swiper 可能会误判成左右滑动,两个手势互相抢,体验很糟。正确做法前面已经说了:Swiper 只负责周历头部那一段,列表区域独立滚动。
另外,Swiper 默认是允许手势切换的,如果你的页面里还有横向滑动的 tab,也建议把日历区域的 catchtouchmove 处理好,避免手势穿透到其他组件。
最后分享一个我自己的习惯:周历组件做完之后,我会专门做一个所有日期都带特殊标记的测试页面,连续快速滑动几十次,观察数据有没有错位、有没有重复请求。这种交互组件,快速滑动下的稳定性比慢速滑动更重要,很多问题都是快速操作时才暴露出来的。如果你也要做类似的周历,建议先用三格 Swiper 加 animationfinish 整体刷新这个思路把骨架跑通,再慢慢加样式和手势细节,这个方向基本不会走偏。