最近在做一个日期选择功能,说实话踩了不少坑。想着把整个过程记录下来,一方面给自己备忘,另一方面也希望能给遇到类似问题的朋友一点参考。
先说下最终实现的效果:
- 完整的日历视图(上月、本月、下月日期都显示)
- 精确到分钟的日期时间选择
- 支持带边框/无边框两种样式
- 可设置初始日期
- 有完整的事件回调
组件结构大概是这样的:
calendar/
├── calendar.js/wxml/wxss/json # 主组件
├── core/
│ ├── time.js # 时间计算核心类
│ └── data.js # 静态数据定义
├── time/ # 时间选择子组件
└── week/ # 星期标题子组件
最核心的问题:日历怎么画出来?
这部分真的是让我头秃了。一开始我直接用 new Date().getDay() 获取星期,结果发现总是对不上。后来翻了 MDN 文档,又反复调试,终于搞明白了。
1. 计算月份起止信息
首先要知道这个月第一天是周几,最后一天是周几:
// 获取给定月份第一天和最后一天是星期几
get_week_of_first_last_by_year_month(year, month){
let dt_first = new Date(year, month-1, 1)
let dt_last = new Date(year, month, 0)
return {'first': dt_first.getDay(), 'last': dt_last.getDay()}
}
// 获取给定月份最后一天的日期
get_date_of_last_by_month(year, month){
let last = new Date(year, month, 0)
return last.getDate()
}
这里有个技巧我也是后来才知道的:new Date(year, month, 0) 这个写法很巧妙。当日期为 0 时,JavaScript 会返回上个月的最后一天。所以想获取某月最后几天,直接传下个月的 0 号就行了。
getDay() 返回 0-6,分别代表周日到周六,这个一开始我也搞错了,以为是周一是 0。
2. 填充上月末尾日期
日历显示要完整,第一行空出来的位置要填充上个月的日期:
if(wk.first != 0){
var pre = this.preMonth(year, month, day)
for(var i=1; i<=wk.first; i++){
var item = {
year: pre.year,
month: pre.month,
day: dayLastPreMonth - wk.first + i
}
item.isSelected = false
days.push(item)
}
}
比如这个月第一天是周三(getDay() = 3),那就要填充上个月的最后 3 天。这个逻辑我想了半天,最后画图才弄明白。
3. 填充本月日期
for(var i=1; i<=dayLastOfMonth; i++){
var item = {year: year, month: month, day: i}
item.isSelected = false
days.push(item)
}
这个最简单,直接循环 1 到月底。
4. 填充下月开头日期
if(wk.last != 6){
var next = this.nextMonth(year, month, day)
for(var i=1; i<=6-wk.last; i++){
var item = {year: next.year, month: next.month, day: i}
item.isSelected = false
days.push(item)
}
}
最后一行没满的话,就用下月开头日期补齐。
5. 标记选中日期
for(var i=0; i<days.length; i++){
var item = days[i]
days[i].isSelected = item.year==year && item.month==month && item.day==day
}
这一步给每个日期对象加个 isSelected 标记,方便后面样式控制。
时间处理也有坑
解析时间字符串
组件支持传入初始时间,格式是 "2025-09-18 16:30"。我一开始直接传给 new Date(),结果发现各个浏览器行为不一致。后来改成手动解析:
initDateTime(dtString){
let now = new Date()
if(dtString){
// 分离日期时间 "2025-09-18 16:30"
let dt = dtString.split(" ")
let dates = dt[0].split("-")
let times = dt[1].split(":")
now = new Date(dates[0], dates[1]-1, dates[2], times[0], times[1])
}
return {
year: now.getFullYear(),
month: now.getMonth()+1,
day: now.getDate(),
week: now.getDay(),
time: [now.getHours(), 0, now.getMinutes()]
}
}
这个坑踩得挺惨的:getMonth() 返回 0-11,所以要 +1。这点特别容易忘,我调试了好久才发现。
时间索引转字符串
时间选择用的是 picker-view,返回的是索引数组,要转成时间字符串:
const time_n2s = (numbers) => {
let h_n = numbers[0]
let m_n = numbers[2]
return HOURS[h_n] + ":" + MINUTES[m_n]
}
这里也有个细节:numbers[2] 是因为我在时间选择器中间插了个冒号列,所以分钟索引是第 2 列。这个也是踩坑后改的。
月份切换逻辑
这个比较简单,主要是处理跨年:
preMonth(y, m, d){
let year = y
let month = m - 1
let day = d
year = month==0 ? year-1 : year
month = month==0 ? 12 : month
day = 1
return {'year':year, 'month':month, day:1}
}
nextMonth(y, m, d){
let year = y
let month = m + 1
let day = d
year = month>12 ? year+1 : year
month = month>12 ? 1 : month
day = 1
return {'year':year, 'month':month, day:1}
}
注意这里我把日期重置为 1 了,这样切换月份时显示的就是当月 1 号的日历。
组件状态和交互
核心状态管理
data: {
days: [], // 日历日期数组
timeNumbers: [], // 时间选择索引数组 [小时, 冒号, 分钟]
dateTime: '', // 格式化后的日期时间字符串
isShow: false, // 弹窗显示状态
year: 0, // 当前年份
month: 0, // 当前月份
day: 0 // 当前日期
}
初始化逻辑
lifetimes: {
attached: function(){
// 组件初始化
var dateTime = time.initDateTime(this.data.initDate)
var y = dateTime.year
var m = dateTime.month
var d = dateTime.day
this.setData({year:y, month:m, day:d, timeNumbers:dateTime.time})
this.setData({dateTime:time.getDateTime(y,m,d,dateTime.time)})
this.onChanged(this.data.dateTime)
let days = time.genCalendar(y,m,d)
this.setData({days:days})
}
}
组件挂载时,先解析初始时间,生成日历数据,然后触发 changed 事件。
监听属性变化
observers: {
'initDate': function(){
var dateTime = time.initDateTime(this.data.initDate)
var y = dateTime.year
var m = dateTime.month
var d = dateTime.day
this.setData({year:y, month:m, day:d, timeNumbers:dateTime.time})
this.setData({dateTime:time.getDateTime(y,m,d,dateTime.time)})
this.onChanged(this.data.dateTime)
}
}
用 observers 监听外部传入的 initDate,这样父组件修改 initDate 时,日历就会自动更新。这个功能还挺实用的。
几个关键的交互逻辑
选择日期
onSelect(e){
var day = e.currentTarget.dataset.item
for(var i=0; i<this.data.days.length; i++){
var item = this.data.days[i]
this.data.days[i].isSelected = day.year==item.year && day.month==item.month && day.day==item.day
}
this.setData({
days: this.data.days,
year: day.year,
month: day.month,
day: day.day
})
}
点击日期时,先遍历所有日期,把选中的那个标记 isSelected = true,其他都设为 false。然后更新状态。
月份切换
onPreMonth(){
var pre = time.preMonth(this.data.year, this.data.month, this.data.day)
this.updateDate(pre)
this.updateCalendarPanel(pre)
},
onNextMonth(){
var next = time.nextMonth(this.data.year, this.data.month, this.data.day)
this.updateDate(next)
this.updateCalendarPanel(next)
}
左右切换月份时,先计算上下月日期,然后重新生成日历。
阻止事件冒泡
这个细节很重要。日历弹窗点击遮罩要关闭,但点击内容区不应该关闭:
<view class="mask" wx:if="{{isShow}}" catchtap="onHide">
<view class="content" catchtap>
<!-- 内部点击不会触发 onHide -->
</view>
</view>
这里用的是 catchtap 而不是 bind:tap。catchtap 会阻止事件冒泡,所以点击内容区不会触发遮罩的 onHide。这个我一开始也踩坑了,点一下就关闭,搞得我很懵。
子组件设计
时间选择器 (time)
<picker-view indicator-style="height: 56px;" bindchange="onChange"
style="width: 100%; height: 56px;" value="{{timeNumbers}}">
<picker-view-column>
<view wx:for="{{hour}}" wx:key="id">{{item}}</view>
</picker-view-column>
<picker-view-column>
<view class="spe">:</view> <!-- 冒号装饰列 -->
</picker-view-column>
<picker-view-column>
<view wx:for="{{min}}" wx:key="id">{{item}}</view>
</picker-view-column>
</picker-view>
这里我用了小程序原生的 picker-view 组件。中间那个冒号列是我自己加的装饰,为了视觉效果好看点。通过 bindchange 事件可以实时获取选择结果。
星期标题 (week)
这个比较简单,就是个纯展示组件,从静态数据中读取星期数组:
const WEEKS = ['日','一','二','三','四','五','六']
样式设计
布局结构
.mask {
position: fixed;
background: rgba(0,0,0,0.5);
top: 0; bottom: 0; left: 0; right: 0;
z-index: 120000;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.content {
background: #fff;
width: 100%;
z-index: 120001;
height: fit-content;
}
使用固定定位+半透明遮罩,从底部弹出日历面板。这个是标准的弹窗布局。
网格布局
.top {
display: grid;
grid-template-columns: 100rpx auto 100rpx;
margin-bottom: 16rpx;
}
月份切换区我用的是 CSS Grid 三列布局,实现左右箭头+中间月份文本的结构。一开始想用 flex,但感觉 grid 更直观。
选中状态
.col.active {
color: #fff;
background-color: #0081ff;
}
使用 .active 类名控制选中样式,主题色为 #0081ff。这个颜色是随便选的,大家可以根据自己的主题调整。
使用示例
<!-- 基础用法 -->
<calendar initDate="2025-09-18 16:30"></calendar>
<!-- 带边框 -->
<calendar initDate="2025-09-18 16:30" frame="true"></calendar>
<!-- 监听变化 -->
<calendar initDate="2025-09-18 16:30"
bind:changed="onDateChanged"></calendar>
Page({
onDateChanged(e) {
console.log('选中的时间:', e.detail)
}
})
使用起来还算简单,传入 initDate 就能初始化日期,监听 changed 事件就能拿到选择结果。
一些可以改进的地方
写完之后我也想了想,还有不少可以优化的地方:
- 性能优化:可以试试
setData的局部更新,避免全量更新 - 国际化:现在只支持中文,如果能加个英文版会更好
- 范围限制:可以加个
minDate和maxDate,限制选择范围 - 主题定制:用 CSS 变量支持主题切换,这样更灵活
- 无障碍访问:这个我还没研究,应该可以加些 aria 标签
总结
写这个组件的过程虽然有点曲折,但学到的东西还是挺多的:
- JavaScript Date 对象的一些特殊用法
- 小程序组件的生命周期和状态管理
- 事件冒泡的处理
- 一些布局技巧
代码结构还算清晰,核心逻辑也验证过了。如果你的项目也有类似需求,希望这篇文章能给你一点参考。
如果你有什么疑问或者建议,欢迎交流。我也是边学边写,肯定还有不少不足之处,请大家多多包涵。
完整代码:如果需要的话我可以把代码整理一下放出来
写在最后:小程序开发真的是边踩坑边学习,每个组件都是宝贵的经验。希望大家都能少踩点坑,多写点好代码。