一、为什么需要自己实现日历组件?
在开发前端项目时,我们经常会遇到需要日历组件的场景。虽然市面上有众多优秀的日历组件库,但在实际项目中,我们往往会发现:
- UI适配难题:现有组件难以完全匹配设计稿的视觉效果
- 功能定制需求:需要特殊交互或业务逻辑(如禁止选择未来日期)
- 视图多样性:需要同时支持日、周、月、年等不同视图模式
正是这些实际需求,促使我决定从零开始实现一个完全自定义的日历组件。下面我将详细介绍这个组件的实现过程和技术细节。
二、组件核心功能全景
这个日历组件主要包含四大视图模式:
- 月视图:传统的月份日历展示
- 周视图:高亮显示整周日期
- 月份选择器:快速切换月份
- 年份选择器:快速切换年份
每种视图都有其独特的交互逻辑和显示方式,但它们共享相同的基础数据和状态管理。
三、基础架构与数据管理
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()
}
七、关键技术与难点解决方案
- 跨月渲染处理:通过计算当前月份的开始星期和总天数,动态补全前后月份的日期,确保日历网格完整显示6行。
- 性能优化:使用计算属性(computed)缓存日历周数据,避免不必要的重复计算。
- 日期比较逻辑:正确处理日期边界情况,如跨年、跨月等情况下的日期比较。
- 响应式更新:通过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"/>
九、总结与展望
这个自定义日历组件通过合理的架构设计,实现了:
- 多种视图模式的灵活切换
- 精确的日期计算和渲染
- 完善的交互体验
- 严格的业务规则限制
未来可能的改进方向包括:
- 增加动画过渡效果提升用户体验
- 支持多语言国际化
- 添加日期范围选择功能
- 优化移动端触摸交互体验
通过这个组件的开发过程,我深刻体会到,对于复杂的交互组件,有时候自己实现反而比改造第三方库更加高效可控。希望这篇详细的实现解析对大家有所帮助!
最后贴上完整代码,因为使用了原子化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>