uniapp从零实现一个多功能日历组件:技术细节全解析(翻到最后有惊喜)

795 阅读7分钟

一、为什么需要自己实现日历组件?

在开发前端项目时,我们经常会遇到需要日历组件的场景。虽然市面上有众多优秀的日历组件库,但在实际项目中,我们往往会发现:

  1. UI适配难题:现有组件难以完全匹配设计稿的视觉效果
  2. 功能定制需求:需要特殊交互或业务逻辑(如禁止选择未来日期)
  3. 视图多样性:需要同时支持日、周、月、年等不同视图模式

正是这些实际需求,促使我决定从零开始实现一个完全自定义的日历组件。下面我将详细介绍这个组件的实现过程和技术细节。

二、组件核心功能全景

这个日历组件主要包含四大视图模式:

  1. 月视图:传统的月份日历展示

image.png

  1. 周视图:高亮显示整周日期

image.png

  1. 月份选择器:快速切换月份

image.png

  1. 年份选择器:快速切换年份

image.png

每种视图都有其独特的交互逻辑和显示方式,但它们共享相同的基础数据和状态管理。

三、基础架构与数据管理

1. 组件参数设计

组件通过props接收初始配置:

const props = defineProps({
  initYear: Number,    // 初始年份
  initMonth: Number,   // 初始月份(1-12)
  initDay: Number,     // 初始日期
  type: {              // 视图类型
    type: String,
    default: 'month'   // 'month'|'week'|'month-picker'|'year-picker'
  }
})

2. 核心数据状态

const today = new Date()
const currentYear = ref(props.initYear ?? today.getFullYear())
const currentMonth = ref(props.initMonth != null ? props.initMonth - 1 : today.getMonth())
const currentDay = ref(props.initDay ?? today.getDate())

// 月份名称配置
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 
                   'July', 'August', 'September', 'October', 'November', 'December']
const monthNamesShort = ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May', 'June', 
                        'July', 'Aug.', 'Sept.', 'Oct.', 'Nov.', 'Dec.']
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

四、月视图实现详解

1. 日历网格生成算法

核心函数generateCalendar负责生成一个月的日历数据:

function generateCalendar(year, month) {
  const firstDay = new Date(year, month, 1)      // 当月第一天
  const lastDay = new Date(year, month + 1, 0)  // 当月最后一天
  const totalDays = lastDay.getDate()           // 当月总天数
  const startDay = firstDay.getDay()            // 当月第一天是星期几
  
  const prevMonthLastDay = new Date(year, month, 0).getDate()
  const days = []

  // 添加上个月末尾的几天
  for (let i = startDay - 1; i >= 0; i--) {
    days.push({
      day: prevMonthLastDay - i,
      currentMonth: false,
      year,
      month: month - 1
    })
  }

  // 添加当月所有天数
  for (let i = 1; i <= totalDays; i++) {
    days.push({
      day: i,
      currentMonth: true,
      year,
      month
    })
  }

  // 补充下个月初的几天(凑齐6行)
  const remaining = 35 - days.length
  for (let i = 1; i <= remaining; i++) {
    days.push({
      day: i,
      currentMonth: false,
      year,
      month: month + 1
    })
  }

  calendarDays.value = days
}

2. 日期选择逻辑

function selectDate(item) {
  if (!item.currentMonth) return // 不能选择非当月日期

  const selected = new Date(item.year, item.month, item.day)
  const now = new Date(today.getFullYear(), today.getMonth(), today.getDate())
  if (selected > now) return // 禁止选择未来日期

  // 更新选中状态
  selectedDate.value = {
    year: currentYear.value,
    month: currentMonth.value,
    day: item.day
  }

  // 触发选择事件
  emit('select', formatDate({
    year: currentYear.value,
    month: currentMonth.value,
    day: item.day
  }))
}

3. 特殊状态样式

日期单元格有多种状态样式:

/* 基础单元格样式 */
.day-cell {
  text-align: center;
  font-size: 24rpx;
  line-height: 60rpx;
  width: 60rpx;
  height: 60rpx;
  border-radius: 16rpx;
  margin: 4rpx;
}

/* 今天未选中状态 */
.day-cell.today-unselected {
  color: #ff5e00;
}

/* 选中状态 */
.day-cell.selected {
  background-color: #ff5e00;
  color: white;
}

/* 非当前月日期 */
.day-cell.empty {
  color: #ccc;
}

五、周视图特殊处理

周视图在月视图基础上增加了整周高亮效果:

1. 周高亮判断逻辑

function isSameWeek(weekIndex) {
  if (props.type !== 'week') return false
  
  // 查找选中日期在日历数组中的位置
  const selectedFullIndex = calendarDays.value.findIndex(
    d => d.year === selectedDate.value.year && 
         d.month === selectedDate.value.month && 
         d.day === selectedDate.value.day
  )
  
  // 判断是否属于同一周
  return Math.floor(selectedFullIndex / 7) === weekIndex
}

2. 周选择事件处理

if (props.type === 'week') {
  const weekIndex = calendarWeeks.value.findIndex(week =>
    week.some(d => d.day === item.day && d.month === item.month && d.year === item.year)
  )
  const weekData = calendarWeeks.value[weekIndex]
  emit('select', {
    range: [formatDate(weekData[0]), formatDate(weekData[6])],
    selected: formatDate({ year: currentYear.value, month: currentMonth.value, day: item.day })
  })
}

六、月份/年份选择器实现

1. 月份选择器视图

<view class="grid grid-cols-4 gap-y-20rpx text-center">
  <view 
    v-for="(m, index) in monthNamesShort" 
    :key="index"
    :class="['py-50rpx rounded-16rpx', { 
      'bg-[#FFF4F0] text-[#FF5E00]': currentMonth === index 
    }]"
    @click="selectMonth(index)"
  >
    {{ m }}
  </view>
</view>

2. 年份切换逻辑

function prevYear() {
  currentYear.value--
  updateSelectedDateForMonthPicker()
}

function nextYear() {
  const nextYearDate = new Date(currentYear.value + 1, 0, 1)
  const now = new Date(today.getFullYear(), today.getMonth(), 1)
  if (nextYearDate > now) return // 禁止跳到未来年份
  
  currentYear.value++
  updateSelectedDateForMonthPicker()
}

七、关键技术与难点解决方案

  1. 跨月渲染处理:通过计算当前月份的开始星期和总天数,动态补全前后月份的日期,确保日历网格完整显示6行。
  2. 性能优化:使用计算属性(computed)缓存日历周数据,避免不必要的重复计算。
  3. 日期比较逻辑:正确处理日期边界情况,如跨年、跨月等情况下的日期比较。
  4. 响应式更新:通过watch监听props变化,实现外部控制日历状态。

八、组件使用示例

<!-- 月视图 -->
<Calendar :init-year="2023" :init-month="11" type="month" @select="handleDateSelect"/>

<!-- 周视图 -->
<Calendar type="week" @select="handleWeekSelect"/>

<!-- 月份选择器 -->
<Calendar type="month-picker" @select="handleMonthSelect"/>

<!-- 年份选择器 -->
<Calendar type="year-picker" @select="handleYearMonthSelect"/>

九、总结与展望

这个自定义日历组件通过合理的架构设计,实现了:

  • 多种视图模式的灵活切换
  • 精确的日期计算和渲染
  • 完善的交互体验
  • 严格的业务规则限制

未来可能的改进方向包括:

  1. 增加动画过渡效果提升用户体验
  2. 支持多语言国际化
  3. 添加日期范围选择功能
  4. 优化移动端触摸交互体验

通过这个组件的开发过程,我深刻体会到,对于复杂的交互组件,有时候自己实现反而比改造第三方库更加高效可控。希望这篇详细的实现解析对大家有所帮助!

cc9f5fc7173636ad20f3434649bb7c1.jpg 最后贴上完整代码,因为使用了原子化css,如果各位想要使用的话可以把原子化css去变成自己的css

<template>
<view class="calendar">
  <!-- 普通日历头部 -->
  <view class="calendar-header" v-if="props.type !== 'month-picker' && props.type !== 'year-picker'">
    <view class="w-full h-108rpx flex justify-between items-center">
      <view
        class="w-68rpx h-68rpx rounded-16rpx border-width-2rpx border-solid border-color-#D0D5DD flex justify-center items-center"
        @click="prev">
        <wd-icon name="arrow-left" size="40rpx" color="#7B7B7B"></wd-icon>
      </view>
      <view class="w-218rpx h-84rpx flex flex-col justify-between items-center">
        <view class="font-600 text-40rpx color-#222B45">{{ monthNames[currentMonth] }}</view>
        <view class="font-600 text-24rpx color-#8F9BB3">{{ currentYear }}</view>
      </view>
      <view
        class="w-68rpx h-68rpx rounded-16rpx border-width-2rpx border-solid border-color-#D0D5DD flex justify-center items-center"
        @click="next">
        <wd-icon name="arrow-right" size="40rpx" color="#7B7B7B"></wd-icon>
      </view>
    </view>

    <view class="week-row">
      <view v-for="(w, i) in weekDays" :key="i" class="week-cell">{{ w }}</view>
    </view>
  </view>

  <!-- 月份选择视图 -->
  <view v-else-if="props.type === 'month-picker'" class="month-picker-header">
    <view class="calendar-header">
      <view class="w-full h-108rpx flex justify-between items-center">
        <view
          class="w-68rpx h-68rpx rounded-16rpx border-width-2rpx border-solid border-color-#D0D5DD flex justify-center items-center"
          @click="prevYear">
          <wd-icon name="arrow-left" size="40rpx" color="#7B7B7B"></wd-icon>
        </view>
        <view class="w-218rpx h-84rpx flex justify-center items-center">
          <view class="font-600 text-40rpx color-#222B45">{{ currentYear }}</view>
        </view>
        <view
          class="w-68rpx h-68rpx rounded-16rpx border-width-2rpx border-solid border-color-#D0D5DD flex justify-center items-center"
          @click="nextYear">
          <wd-icon name="arrow-right" size="40rpx" color="#7B7B7B"></wd-icon>
        </view>
      </view>
    </view>

    <view class="grid grid-cols-4 gap-y-20rpx text-center text-28rpx text-[#7B7B7B] font-semibold">
      <view v-for="(m, index) in monthNamesShort" :key="index"
        :class="['py-50rpx rounded-16rpx', { 'bg-[#FFF4F0] text-[#FF5E00]': currentMonth === index }]"
        @click="selectMonth(index)">
        {{ m }}
      </view>
    </view>
  </view>

  <!-- 年份选择视图 -->
  <view v-else-if="props.type === 'year-picker'">
    <view class="calendar-header">
      <view class="w-full h-108rpx flex justify-between items-center">
        <view
          class="w-68rpx h-68rpx rounded-16rpx border-width-2rpx border-solid border-color-#D0D5DD flex justify-center items-center"
          @click="prevYear">
          <wd-icon name="arrow-left" size="40rpx" color="#7B7B7B"></wd-icon>
        </view>
        <view class="w-218rpx h-84rpx flex justify-center items-center">
          <view class="font-600 text-40rpx color-#222B45">{{ currentYear }}</view>
        </view>
        <view
          class="w-68rpx h-68rpx rounded-16rpx border-width-2rpx border-solid border-color-#D0D5DD flex justify-center items-center"
          @click="nextYear">
          <wd-icon name="arrow-right" size="40rpx" color="#7B7B7B"></wd-icon>
        </view>
      </view>
    </view>

    <view class="grid grid-cols-4 gap-y-20rpx text-center text-28rpx font-semibold text-[#FF5E00]">
      <view v-for="(m, index) in monthNamesShort" :key="index" class="py-50rpx rounded-16rpx"
        @click="selectYearMonth(index)">
        {{ m }}
      </view>
    </view>
  </view>

  <!-- 日历网格 -->
  <view class="day-grid" v-if="props.type !== 'month-picker' && props.type !== 'year-picker'">
    <template v-for="(week, weekIndex) in calendarWeeks" :key="weekIndex">
      <view class="week-highlight-wrapper" :class="{ 'highlight-week': isSameWeek(weekIndex) }">
        <view v-for="(item, i) in week" :key="i" class="day-cell" :class="{
          empty: !item.currentMonth,
          'today-unselected': isToday(item) && !isSelected(item),
          selected: isSelected(item)
        }" @click="selectDate(item)">
          {{ item.day }}
        </view>
      </view>
    </template>
  </view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, defineProps, defineEmits, defineExpose, watch } from 'vue'

const props = defineProps({
  initYear: Number,
  initMonth: Number,
  initDay: Number,
  type: {
    type: String,
    default: 'month'
  }
})
const emit = defineEmits(['select'])

const today = new Date()
const currentYear = ref(props.initYear ?? today.getFullYear())
const currentMonth = ref(props.initMonth != null ? props.initMonth - 1 : today.getMonth())
const currentDay = ref(props.initDay ?? today.getDate())

const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const monthNamesShort = ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May', 'June', 'July', 'Aug.', 'Sept.', 'Oct.', 'Nov.', 'Dec.']
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const calendarDays = ref([])
const selectedDate = ref({
  year: currentYear.value,
  month: currentMonth.value,
  day: currentDay.value
})
const hasUserSelected = ref(false)

function formatDate(dateObj) {
  const { year, month, day } = dateObj
  const m = String(month + 1).padStart(2, '0')
  const d = String(day).padStart(2, '0')
  return `${year}-${m}-${d}`
}

function generateCalendar(year, month) {
  const firstDay = new Date(year, month, 1)
  const lastDay = new Date(year, month + 1, 0)
  const totalDays = lastDay.getDate()
  const startDay = firstDay.getDay()
  const prevMonthLastDay = new Date(year, month, 0).getDate()
  const days = []

  for (let i = startDay - 1; i >= 0; i--) {
    days.push({ day: prevMonthLastDay - i, currentMonth: false, year, month: month - 1 })
  }
  for (let i = 1; i <= totalDays; i++) {
    days.push({ day: i, currentMonth: true, year, month })
  }
  const remaining = 35 - days.length
  for (let i = 1; i <= remaining; i++) {
    days.push({ day: i, currentMonth: false, year, month: month + 1 })
  }

  calendarDays.value = days
}

const calendarWeeks = computed(() => {
  const weeks = []
  for (let i = 0; i < calendarDays.value.length; i += 7) {
    weeks.push(calendarDays.value.slice(i, i + 7))
  }
  return weeks
})

function isToday(item) {
  return item.currentMonth && item.day === today.getDate() && currentMonth.value === today.getMonth() && currentYear.value === today.getFullYear()
}

function isSelected(item) {
  return item.year === selectedDate.value.year && item.month === selectedDate.value.month && item.day === selectedDate.value.day
}

function isSameWeek(weekIndex) {
  if (props.type !== 'week') return false
  const selectedFullIndex = calendarDays.value.findIndex(
    d => d.year === selectedDate.value.year && d.month === selectedDate.value.month && d.day === selectedDate.value.day
  )
  return Math.floor(selectedFullIndex / 7) === weekIndex
}
function selectDate(item) {
  if (!item.currentMonth) return

  const selected = new Date(item.year, item.month, item.day)
  const now = new Date(today.getFullYear(), today.getMonth(), today.getDate())
  if (selected > now) return  // 禁止选中未来日期

  hasUserSelected.value = true
  selectedDate.value = { year: currentYear.value, month: currentMonth.value, day: item.day }

  if (props.type === 'week') {
    const weekIndex = calendarWeeks.value.findIndex(week =>
      week.some(d => d.day === item.day && d.month === item.month && d.year === item.year)
    )
    const weekData = calendarWeeks.value[weekIndex]
    emit('select', {
      range: [formatDate(weekData[0]), formatDate(weekData[6])],
      selected: formatDate({ year: currentYear.value, month: currentMonth.value, day: item.day })
    })
  } else {
    emit('select', formatDate({ year: currentYear.value, month: currentMonth.value, day: item.day }))
  }
}



function prev() {
  if (--currentMonth.value < 0) {
    currentYear.value--
    currentMonth.value = 11
  }
  generateCalendar(currentYear.value, currentMonth.value)
}

function next() {
  const nextDate = new Date(currentYear.value, currentMonth.value + 1, 1)
  const now = new Date(today.getFullYear(), today.getMonth(), 1)
  if (nextDate > now) return  // 禁止跳到下个月及以后

  currentMonth.value++
  if (currentMonth.value > 11) {
    currentYear.value++
    currentMonth.value = 0
  }
  generateCalendar(currentYear.value, currentMonth.value)
}


function prevYear() {
  currentYear.value--
  updateSelectedDateForMonthPicker()
}

function nextYear() {
  const nextYearDate = new Date(currentYear.value + 1, 0, 1)
  const now = new Date(today.getFullYear(), today.getMonth(), 1)
  if (nextYearDate > now) return  // 禁止跳到未来年份

  currentYear.value++
  updateSelectedDateForMonthPicker()
}


function updateSelectedDateForMonthPicker() {
  selectedDate.value.year = currentYear.value
  const m = String(currentMonth.value + 1).padStart(2, '0')
  emit('select', `${selectedDate.value.year}-${m}`)
}

function selectMonth(monthIndex) {
  const now = new Date(today.getFullYear(), today.getMonth(), 1)
  const selected = new Date(currentYear.value, monthIndex, 1)
  if (selected > now) return

  currentMonth.value = monthIndex
  selectedDate.value.month = monthIndex
  selectedDate.value.year = currentYear.value
  hasUserSelected.value = true
  const m = String(monthIndex + 1).padStart(2, '0')
  emit('select', `${currentYear.value}-${m}`)
}

function selectYearMonth(monthIndex) {
  const now = new Date(today.getFullYear(), today.getMonth(), 1)
  const selected = new Date(currentYear.value, monthIndex, 1)
  if (selected > now) return

  currentMonth.value = monthIndex
  selectedDate.value.month = monthIndex
  selectedDate.value.year = currentYear.value
  hasUserSelected.value = true
  emit('select', `${currentYear.value}-${String(monthIndex + 1).padStart(2, '0')}`)
}


function getSelectedDate() {
  const { year, month, day } = selectedDate.value
  return { year, month: month + 1, day }
}

onMounted(() => {
  generateCalendar(currentYear.value, currentMonth.value)
  if (!hasUserSelected.value) {
    if (props.type === 'week') {
      const todayIndex = calendarDays.value.findIndex(d =>
        d.day === today.getDate() && d.month === today.getMonth() && d.year === today.getFullYear()
      )
      if (todayIndex !== -1) {
        const startOfWeekIndex = Math.floor(todayIndex / 7) * 7
        const weekData = calendarDays.value.slice(startOfWeekIndex, startOfWeekIndex + 7)
        emit('select', [formatDate(weekData[0]), formatDate(weekData[6])])
      }
    } else if (props.type === 'month-picker' || props.type === 'year-picker') {
      const m = String(currentMonth.value + 1).padStart(2, '0')
      emit('select', `${currentYear.value}-${m}`)
    } else {
      emit('select', formatDate({ year: currentYear.value, month: currentMonth.value, day: today.getDate() }))
    }
  }
})

//  动态监听 props 变化并更新视图和选中日期
watch(
  () => [props.initYear, props.initMonth, props.initDay],
  ([newYear, newMonth, newDay]) => {
    if (newYear != null) currentYear.value = newYear
    if (newMonth != null) currentMonth.value = newMonth - 1
    if (newDay != null) currentDay.value = newDay

    selectedDate.value = {
      year: currentYear.value,
      month: currentMonth.value,
      day: currentDay.value
    }

    generateCalendar(currentYear.value, currentMonth.value)

    if (props.type === 'week') {
      const index = calendarDays.value.findIndex(d =>
        d.day === currentDay.value && d.month === currentMonth.value && d.year === currentYear.value
      )
      if (index !== -1) {
        const weekStart = Math.floor(index / 7) * 7
        const weekData = calendarDays.value.slice(weekStart, weekStart + 7)
        emit('select', [formatDate(weekData[0]), formatDate(weekData[6])])
      }
    } else {
      emit('select', formatDate(selectedDate.value))
    }
  }
)

defineExpose({ getSelectedDate })
</script>

<style scoped>
.calendar {
  padding: 20rpx;
  background-color: #fff;
}

.week-row {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  text-align: center;
}

.week-cell {
  font-size: 26rpx;
  color: #999;
  padding: 10rpx 0;
}

.day-grid {
  display: flex;
  flex-direction: column;
  gap: 4rpx;
}

.week-highlight-wrapper {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  background-color: transparent;
  border-radius: 32rpx;
  padding: 6rpx 0;
}

.week-highlight-wrapper.highlight-week {
  background-color: #fff4f0;
}

.day-cell {
  text-align: center;
  font-size: 24rpx;
  color: #222B45;
  font-weight: 600;
  line-height: 60rpx;
  width: 60rpx;
  height: 60rpx;
  border-radius: 16rpx;
  margin: 4rpx;
  user-select: none;
}

.day-cell.today-unselected {
  color: #ff5e00;
}

.day-cell.selected {
  background-color: #ff5e00;
  color: white;
}

.day-cell.empty {
  color: #ccc;
}
</style>