小程序开发实战:我手写日历组件踩过的那些坑

最近在做一个日期选择功能,说实话踩了不少坑。想着把整个过程记录下来,一方面给自己备忘,另一方面也希望能给遇到类似问题的朋友一点参考。

先说下最终实现的效果:

  • 完整的日历视图(上月、本月、下月日期都显示)
  • 精确到分钟的日期时间选择
  • 支持带边框/无边框两种样式
  • 可设置初始日期
  • 有完整的事件回调

组件结构大概是这样的:

bash 复制代码
calendar/
├── calendar.js/wxml/wxss/json    # 主组件
├── core/
│   ├── time.js                   # 时间计算核心类
│   └── data.js                   # 静态数据定义
├── time/                        # 时间选择子组件
└── week/                        # 星期标题子组件

最核心的问题:日历怎么画出来?

这部分真的是让我头秃了。一开始我直接用 new Date().getDay() 获取星期,结果发现总是对不上。后来翻了 MDN 文档,又反复调试,终于搞明白了。

1. 计算月份起止信息

首先要知道这个月第一天是周几,最后一天是周几:

javascript 复制代码
// 获取给定月份第一天和最后一天是星期几
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. 填充上月末尾日期

日历显示要完整,第一行空出来的位置要填充上个月的日期:

javascript 复制代码
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. 填充本月日期

javascript 复制代码
for(var i=1; i<=dayLastOfMonth; i++){
  var item = {year: year, month: month, day: i}
  item.isSelected = false
  days.push(item)
}

这个最简单,直接循环 1 到月底。

4. 填充下月开头日期

javascript 复制代码
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. 标记选中日期

javascript 复制代码
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(),结果发现各个浏览器行为不一致。后来改成手动解析:

javascript 复制代码
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,返回的是索引数组,要转成时间字符串:

javascript 复制代码
const time_n2s = (numbers) => {
  let h_n = numbers[0]
  let m_n = numbers[2]
  return HOURS[h_n] + ":" + MINUTES[m_n]
}

这里也有个细节:numbers[2] 是因为我在时间选择器中间插了个冒号列,所以分钟索引是第 2 列。这个也是踩坑后改的。

月份切换逻辑

这个比较简单,主要是处理跨年:

javascript 复制代码
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 号的日历。

组件状态和交互

核心状态管理

javascript 复制代码
data: {
  days: [],          // 日历日期数组
  timeNumbers: [],   // 时间选择索引数组 [小时, 冒号, 分钟]
  dateTime: '',      // 格式化后的日期时间字符串
  isShow: false,     // 弹窗显示状态
  year: 0,           // 当前年份
  month: 0,          // 当前月份
  day: 0             // 当前日期
}

初始化逻辑

javascript 复制代码
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 事件。

监听属性变化

javascript 复制代码
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 时,日历就会自动更新。这个功能还挺实用的。

几个关键的交互逻辑

选择日期

javascript 复制代码
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。然后更新状态。

月份切换

javascript 复制代码
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)
}

左右切换月份时,先计算上下月日期,然后重新生成日历。

阻止事件冒泡

这个细节很重要。日历弹窗点击遮罩要关闭,但点击内容区不应该关闭:

html 复制代码
<view class="mask" wx:if="{{isShow}}" catchtap="onHide">
  <view class="content" catchtap>
    <!-- 内部点击不会触发 onHide -->
  </view>
</view>

这里用的是 catchtap 而不是 bind:tapcatchtap 会阻止事件冒泡,所以点击内容区不会触发遮罩的 onHide。这个我一开始也踩坑了,点一下就关闭,搞得我很懵。

子组件设计

时间选择器 (time)

html 复制代码
<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)

这个比较简单,就是个纯展示组件,从静态数据中读取星期数组:

javascript 复制代码
const WEEKS = ['日','一','二','三','四','五','六']

样式设计

布局结构

css 复制代码
.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;
}

使用固定定位+半透明遮罩,从底部弹出日历面板。这个是标准的弹窗布局。

网格布局

css 复制代码
.top {
  display: grid;
  grid-template-columns: 100rpx auto 100rpx;
  margin-bottom: 16rpx;
}

月份切换区我用的是 CSS Grid 三列布局,实现左右箭头+中间月份文本的结构。一开始想用 flex,但感觉 grid 更直观。

选中状态

css 复制代码
.col.active {
  color: #fff;
  background-color: #0081ff;
}

使用 .active 类名控制选中样式,主题色为 #0081ff。这个颜色是随便选的,大家可以根据自己的主题调整。

使用示例

html 复制代码
<!-- 基础用法 -->
<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>
javascript 复制代码
Page({
  onDateChanged(e) {
    console.log('选中的时间:', e.detail)
  }
})

使用起来还算简单,传入 initDate 就能初始化日期,监听 changed 事件就能拿到选择结果。

一些可以改进的地方

写完之后我也想了想,还有不少可以优化的地方:

  1. 性能优化 :可以试试 setData 的局部更新,避免全量更新
  2. 国际化:现在只支持中文,如果能加个英文版会更好
  3. 范围限制 :可以加个 minDatemaxDate,限制选择范围
  4. 主题定制:用 CSS 变量支持主题切换,这样更灵活
  5. 无障碍访问:这个我还没研究,应该可以加些 aria 标签

总结

写这个组件的过程虽然有点曲折,但学到的东西还是挺多的:

  • JavaScript Date 对象的一些特殊用法
  • 小程序组件的生命周期和状态管理
  • 事件冒泡的处理
  • 一些布局技巧

代码结构还算清晰,核心逻辑也验证过了。如果你的项目也有类似需求,希望这篇文章能给你一点参考。

如果你有什么疑问或者建议,欢迎交流。我也是边学边写,肯定还有不少不足之处,请大家多多包涵。


完整代码:如果需要的话我可以把代码整理一下放出来

写在最后:小程序开发真的是边踩坑边学习,每个组件都是宝贵的经验。希望大家都能少踩点坑,多写点好代码。

相关推荐
工藤新一¹2 小时前
《操作系统》第一章(1)
java·服务器·前端
用户9714171814272 小时前
Flex 和 Grid 详细使用指南:从入门到实战避坑
前端·css
不会敲代码12 小时前
使用 Mock.js 模拟 API 数据,实现前后端并行开发
前端·javascript
琛説2 小时前
Web-Rooter:一种 IR + Lint 模式的 AI Agent 创新尝试【或许是下一个 AI 爆火方向】
前端·人工智能
用户9714171814272 小时前
absolute 元素的包含块(containing block)怎么找
前端·css
青山Coding2 小时前
Cesium应用(四):全球台风气象可视化实现
前端·vue.js·cesium
kyriewen2 小时前
响应式设计:一套代码,手机平板电脑全拿下
前端·css·html
姝然_95272 小时前
Jetpack Compose Shape 基础使用
前端
cxxcode2 小时前
ArrayBuffer / TypedArray / Blob / File 关系与操作指南
前端