在现代 Web 应用中,日历组件是一个不可或缺的交互元素,广泛应用于预约系统、日程管理、日期选择等场景。一个设计精良的日历组件不仅要视觉美观,更要提供直观的操作体验和灵活的功能扩展。本文将详细解析如何使用 Vue 3 的 Composition API 实现一个功能完善的日历组件,包括月份切换、日期选择、禁用控制等核心功能。
组件功能与设计理念
本文实现的日历组件具备以下核心功能:
- 完整的月份视图展示,包括正确的日期排列
- 支持月份切换(上一个月 / 下一个月)
- 日期选择功能,带有选中状态高亮
- 可配置的日期禁用功能(支持禁用过去的日期)
- 多语言支持(中英文月份和星期显示)
设计理念遵循 "简洁直观" 原则,通过清晰的视觉层次和明确的交互反馈,让用户能够快速完成日期选择操作。同时,组件采用模块化设计,确保各功能点低耦合、高内聚,便于后续维护和扩展。
技术栈与实现方式
- 框架:Vue 3(使用 Composition API)
- 状态管理:
ref
、reactive
与computed
- 样式:CSS(使用 scoped 作用域隔离)
- 日期处理:原生 Date 对象(避免引入额外依赖)
选择原生 Date 对象处理日期逻辑,虽然需要手动处理一些细节,但可以减少第三方依赖,降低组件体积,同时提高兼容性。
完整代码实现
下面是日历组件的完整实现代码,包含模板、脚本和样式:
<template>
<div>
<div class="calendar">
<!-- 标题与月份切换按钮 -->
<div class="title_rl">
<div class="title_num">{{ currentMonthName }} {{ currentYear }}</div>
<div class="title_btns">
<div class="left_rl" @click="prevMonth">
<img :src="require('./images/leftIcon.png')" alt="上一个月" />
</div>
<div class="right_rl" @click="nextMonth">
<img :src="require('./images/rightIcon.png')" alt="下一个月" />
</div>
</div>
</div>
<!-- 星期标题 -->
<div class="days-of-week">
<div v-for="day in daysOfWeek" :key="day">{{ day }}</div>
</div>
<!-- 日期网格 -->
<div class="days">
<div
v-for="date in daysInMonth"
:key="date.date"
@click="selectDate(date.date)"
>
<div
class="rl_Arr"
:class="{
selected: isDateSelected(date.date),
Placing: IsEnabled ? checkInputDate(date.date) : false,
}"
>
{{ date.day }}
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, reactive, onMounted } from "vue";
export default {
name: "Calendar",
props: {
// 是否启用日期限制(禁用过去的日期)
IsEnabled: {
type: Boolean,
default: false,
},
},
setup(props, context) {
// 响应式状态
const selectedDate = ref(new Date()); // 当前选中的日期
const currentDate = ref(new Date()); // 当前日期
const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; // 英文星期
const daysOfWeekEN = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; // 中文星期
const state = reactive({
currentMonth: currentDate.value.getMonth(), // 当前月份(0-11)
currentYear: currentDate.value.getFullYear(), // 当前年份
});
// 计算属性:当前月份的英文名称
const currentMonthName = computed(() => {
const months = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
return months[state.currentMonth];
});
// 计算属性:当前月份的中文名称
const currentMonthName1 = computed(() => {
const months = [
"一月", "二月", "三月", "四月", "五月", "六月",
"七月", "八月", "九月", "十月", "十一月", "十二月"
];
return months[state.currentMonth];
});
// 计算属性:当月的所有日期(包括前后月份的填充日期)
const daysInMonth = computed(() => {
const days = [];
// 当月第一天
const firstDayOfMonth = new Date(state.currentYear, state.currentMonth, 1);
// 当月最后一天
const lastDayOfMonth = new Date(state.currentYear, state.currentMonth + 1, 0);
// 当月第一天是星期几(0-6,0是周日)
const firstDayOfWeek = firstDayOfMonth.getDay();
// 当月最后一天是星期几
const lastDayOfWeek = lastDayOfMonth.getDay();
// 填充当月第一天之前的空白
for (let i = 0; i < firstDayOfWeek; i++) {
days.push({ date: null, day: "" });
}
// 填充当月的所有日期
for (let day = 1; day <= lastDayOfMonth.getDate(); day++) {
days.push({
date: new Date(state.currentYear, state.currentMonth, day),
day: day.toString().padStart(2, "0"), // 确保两位数显示
});
}
// 填充当月最后一天之后的空白,使日历保持完整的网格
const totalDaysInWeek = daysOfWeek.length;
const daysInThisMonth = lastDayOfMonth.getDate();
// 找到当月最后一天在数组中的索引
const lastFilledDayIndex = days.findIndex(
(d) => d.date && d.date.getDate() === daysInThisMonth
);
// 计算需要填充的空白天数
const daysToFill = totalDaysInWeek - ((lastDayOfWeek + 1) % totalDaysInWeek);
for (let i = lastFilledDayIndex + 1; i <= lastFilledDayIndex + daysToFill; i++) {
days.push({ date: null, day: "" });
}
return days;
});
// 检查日期是否被选中
const isDateSelected = (date) => {
if (!date) return false;
return selectedDate.value.toDateString() === new Date(date).toDateString();
};
// 检查日期是否是今天
const isToday = (date) => {
if (!date) return false;
const today = new Date();
return today.toDateString() === new Date(date).toDateString();
};
// 选择日期
const selectDate = (date) => {
// 如果没有日期数据(空白填充),不执行任何操作
if (!date) return;
// 如果启用了日期限制,检查是否可以选择该日期
if (props.IsEnabled) {
const isDisabled = checkInputDate(date);
if (isDisabled) {
return; // 禁用的日期不执行选择操作
}
}
// 更新选中日期并通知父组件
selectedDate.value = new Date(date);
context.emit("success", new Date(date));
};
// 切换到上一个月
const prevMonth = () => {
if (state.currentMonth === 0) {
// 如果是1月,切换到上一年的12月
state.currentMonth = 11;
state.currentYear -= 1;
} else {
state.currentMonth -= 1;
}
};
// 切换到下一个月
const nextMonth = () => {
if (state.currentMonth === 11) {
// 如果是12月,切换到下一年的1月
state.currentMonth = 0;
state.currentYear += 1;
} else {
state.currentMonth += 1;
}
};
// 组件挂载时初始化
onMounted(() => {
state.currentMonth = currentDate.value.getMonth();
state.currentYear = currentDate.value.getFullYear();
selectedDate.value = new Date(); // 默认选中今天
});
// 日期格式化:将日期对象转换为 "年/月/日" 字符串
const datatime = (inputStartMonth) => {
let date;
if (inputStartMonth) {
date = new Date(inputStartMonth);
} else {
date = new Date();
}
const y = date.getFullYear();
let m = date.getMonth() + 1;
let d = date.getDate();
// 月份和日期补零处理
m = m < 10 ? "0" + m : m;
d = d < 10 ? "0" + d : d;
return `${y}/${m}/${d}`;
};
// 检查日期是否为过去的日期(用于禁用控制)
const checkInputDate = (inputStartMonth) => {
if (!inputStartMonth) return false;
const targetDate = datatime(inputStartMonth);
const today = datatime();
// 拆分日期为年、月、日
const [targetYear, targetMonth, targetDay] = targetDate.split("/").map(Number);
const [currentYear, currentMonth, currentDay] = today.split("/").map(Number);
// 比较年份
if (targetYear < currentYear) {
return true; // 过去的年份,禁用
} else if (targetYear === currentYear) {
// 同一年,比较月份
if (targetMonth < currentMonth) {
return true; // 过去的月份,禁用
} else if (targetMonth === currentMonth) {
// 同一月,比较日期
return targetDay < currentDay; // 过去的日期,禁用
}
}
return false; // 未来的日期,允许选择
};
// 暴露给模板的属性和方法
return {
currentMonthName,
currentMonthName1,
daysOfWeek,
daysOfWeekEN,
daysInMonth,
isDateSelected,
isToday,
selectDate,
prevMonth,
nextMonth,
checkInputDate,
datatime,
currentYear: computed(() => state.currentYear), // 当前年份
};
},
};
</script>
<style scoped>
:root,
:host {
--van-cell-group-background: transparent;
}
.calendar {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 16px;
text-align: center;
}
/* 标题与月份切换按钮样式 */
.title_rl {
margin-left: 10px;
height: 44px;
align-items: center;
display: flex;
.title_num {
font-size: 20px;
font-weight: 900;
line-height: 24px;
text-align: left;
flex: 1;
}
.title_btns {
display: flex;
.left_rl {
width: 18px;
height: 18px;
margin-right: 30px;
img {
width: 100%;
height: 100%;
}
}
.right_rl {
width: 18px;
height: 18px;
img {
width: 100%;
height: 100%;
}
}
}
}
/* 星期标题样式 */
.days-of-week {
display: flex;
justify-content: space-between;
div {
width: 14.28%; /* 7天平均分配宽度 */
height: 18px;
font-size: 13px;
font-weight: 700;
line-height: 18px;
text-align: center;
color: #3C3C434D;
}
}
/* 日期网格样式 */
.days {
height: 52px;
display: flex;
flex-wrap: wrap;
div {
display: flex;
align-items: center;
justify-content: center;
width: 14.28%; /* 7天平均分配宽度 */
height: 48px;
cursor: pointer;
transition: background-color 0.3s;
.rl_Arr {
display: flex;
width: 32px;
height: 32px;
border-radius: 50%;
align-items: center;
justify-content: center;
font-size: 20px;
}
/* 选中状态样式 */
div.selected {
border-radius: 50%;
background-color: #007AFF;
color: white;
font-size: 20px;
font-weight: 700;
}
/* 禁用状态样式 */
div.Placing {
color: rgba(60, 60, 67, .3);
font-size: 20px;
}
/* 悬停效果(非选中状态) */
div:hover:not(.selected) {
background-color: #f0f0f0;
}
}
}
</style>
核心功能解析
1. 日历网格渲染逻辑
日历渲染的核心在于正确计算每个月的日期分布,包括填充前后月份的空白日期以保持网格的完整性:
const daysInMonth = computed(() => {
const days = [];
// 计算当月第一天和最后一天
const firstDayOfMonth = new Date(state.currentYear, state.currentMonth, 1);
const lastDayOfMonth = new Date(state.currentYear, state.currentMonth + 1, 0);
// 填充月初空白
for (let i = 0; i < firstDayOfMonth.getDay(); i++) {
days.push({ date: null, day: "" });
}
// 填充当月日期
for (let day = 1; day <= lastDayOfMonth.getDate(); day++) {
days.push({
date: new Date(state.currentYear, state.currentMonth, day),
day: day.toString().padStart(2, "0"),
});
}
// 填充月末空白
// ...计算逻辑...
return days;
});
这种实现方式确保了日历始终以完整的星期网格展示,无论当月第一天是星期几。
2. 月份切换功能
月份切换需要处理年际变化(如 1 月切换到 12 月时年份减 1):
// 上一个月
const prevMonth = () => {
if (state.currentMonth === 0) {
state.currentMonth = 11;
state.currentYear -= 1;
} else {
state.currentMonth -= 1;
}
};
// 下一个月
const nextMonth = () => {
if (state.currentMonth === 11) {
state.currentMonth = 0;
state.currentYear += 1;
} else {
state.currentMonth += 1;
}
};
通过修改响应式状态 state.currentMonth
和 state.currentYear
,结合计算属性 daysInMonth
的自动更新,实现了月份的无缝切换。
3. 日期选择与状态管理
日期选择功能需要处理选中状态的更新和父组件通信:
const selectDate = (date) => {
if (!date) return;
// 日期限制检查
if (props.IsEnabled && checkInputDate(date)) {
return;
}
// 更新选中状态并通知父组件
selectedDate.value = new Date(date);
context.emit("success", new Date(date));
};
通过 isDateSelected
方法判断日期是否被选中,结合动态类名实现视觉反馈:
const isDateSelected = (date) => {
if (!date) return false;
return selectedDate.value.toDateString() === new Date(date).toDateString();
};
<div :class="{ selected: isDateSelected(date.date) }">
{{ date.day }}
</div>
4. 日期禁用功能
当 IsEnabled
属性为 true
时,组件会禁用过去的日期:
const checkInputDate = (inputStartMonth) => {
if (!inputStartMonth) return false;
const [targetYear, targetMonth, targetDay] = datatime(inputStartMonth).split("/").map(Number);
const [currentYear, currentMonth, currentDay] = datatime().split("/").map(Number);
// 逐级比较年月日
if (targetYear < currentYear) return true;
if (targetYear > currentYear) return false;
if (targetMonth < currentMonth) return true;
if (targetMonth > current
核心功能实现要点
-
精准的日期网格渲染
日历的核心在于正确计算每月日期分布,通过获取当月第一天和最后一天的星期位置,填充前后空白日期,确保网格始终保持 7 列完整布局。这种实现既符合用户对日历的视觉预期,又避免了因月份天数差异导致的布局错乱。 -
智能的月份切换逻辑
处理月份切换时需考虑跨年场景:1 月切换到上一月自动变为上一年 12 月,12 月切换到下一月自动变为下一年 1 月。通过响应式状态管理当前年月,结合计算属性实时更新日期列表,实现无缝的月份导航体验。 -
灵活的日期选择机制
选中状态通过日期字符串比对实现,确保跨时区场景下的准确性。同时支持日期禁用功能,通过IsEnabled
属性可快速开启对过去日期的限制,满足预约系统等场景的特殊需求。 -
多语言支持与样式设计
内置中英文月份和星期显示,便于国际化适配。样式设计上采用圆形选中态、灰度禁用态等视觉语言,通过悬停效果增强交互反馈,符合现代 UI 设计审美。
技术架构与最佳实践
-
基于 Composition API 的状态管理:使用
ref
存储简单状态(选中日期),reactive
管理复杂状态(当前年月),computed
处理衍生数据(日期列表、月份名称),使代码逻辑清晰可维护。 -
原生日期 API 的高效运用:不依赖第三方库,通过原生
Date
对象实现日期计算,减少包体积的同时保证兼容性。 -
组件通信设计:通过
props
接收配置参数,emit
事件传递选中结果,符合 Vue 组件设计规范,便于集成到各类业务场景。 -
样式隔离与响应式布局:使用 scoped CSS 避免样式污染,通过百分比分配日期宽度,确保在不同屏幕尺寸下的显示效果一致。
可扩展方向
该组件可根据业务需求进一步扩展:
- 增加日期范围选择功能,支持开始 / 结束日期联动
- 集成日程标记功能,在对应日期显示事件提示
- 支持自定义日期格式和禁用规则
- 增加快捷键操作,提升键盘用户体验
总结
日历组件作为基础交互单元,其设计质量直接影响用户对产品的感知。通过合理的技术选型、清晰的逻辑设计和细致的体验优化,才能打造出既好用又好看的日历组件,为用户提供高效愉悦的日期选择体验。