在日常 Vue 项目开发中,我们经常会遇到需要展示当月日历并支持日期选中的场景,比如预约打卡、日期筛选等。如果不需要复杂的年月切换功能,大可不必引入庞大的第三方日历库,自己实现一个轻量极简的日历组件既高效又灵活。今天就来手把手教大家实现这样一个无年月切换、支持点击选中的 Vue 日历组件。
组件效果预览

这个组件最终实现的功能如下:
- 自动渲染当前月份的完整日历,包含周日至周六的星期头部
- 自动标记当天日期,区分当月有效日期和月初空白占位
- 支持点击当月日期进行选中,选中状态高亮展示
- 选中日期变化时向外派发事件,传递完整的日期信息
- 样式简洁美观,支持自适应布局,适配移动端(使用 rpx 单位,兼容 uni-app/Vue 移动端项目)
组件实现思路拆解
一个日历组件的核心逻辑其实是日期的计算与格式化,再配合 Vue 的模板渲染和样式美化,就能快速成型。整体实现分为三个核心步骤:
- 计算当月核心日期信息(当月第一天、最后一天、总天数、第一天是星期几)
- 生成日历渲染所需的日期数组,包含日期状态(是否当月、是否当天、日期字符串等)
- 模板渲染与样式美化,绑定点击事件实现选中功能
完整代码与分步解析
下面我们就结合完整代码,一步步拆解组件的实现细节,本文以 Vue 3 为例(兼容 uni-app,样式使用 SCSS)。
第一步:模板结构搭建(template 部分)
模板部分主要分为星期头部 和日期主体两部分,结构清晰简洁,利用 Vue 的指令实现数据驱动渲染。
vue
<template>
<view class="calendar-container">
<!-- 日历头部:星期标题 -->
<view class="calendar-week-header">
<view class="week-item" v-for="(week, index) in weekList" :key="index">
{{ week }}
</view>
</view>
<!-- 日历主体:日期格子 -->
<view class="calendar-date-content">
<view
class="date-item"
v-for="(date, index) in calendarDateList"
:key="index"
:class="{
'empty-item': !date.isCurrentMonth, // 非当月空白占位
'today-item': date.isToday, // 当天日期样式
'selected-item': date.dateStr === selectedDateStr, // 选中日期样式
}"
@click="handleDateClick(date)" // 日期点击事件
>
{{ date.day || "" }}
</view>
</view>
</view>
</template>
模板关键说明:
- 星期头部通过
v-for遍历weekList数组,快速渲染 "日、一、二、三、四、五、六" - 日期主体同样通过
v-for遍历核心数据calendarDateList,每个元素对应一个日历格子 - 动态 class 绑定三个状态样式,实现不同状态的视觉区分,提升用户体验
- 点击事件绑定
handleDateClick,并传递当前日期对象,方便后续处理 - 日期展示使用
date.day || "",确保非当月占位格子不显示无效内容
第二步:核心逻辑实现(script 部分)
脚本部分是日历组件的灵魂,负责日期计算、数据生成和事件处理,我们按模块逐一解析。
1. 组件基础配置与数据定义
javascript
运行
export default {
name: "CurrentMonthCalendar",
data() {
return {
weekList: ["日", "一", "二", "三", "四", "五", "六"], // 星期头部数据
calendarDateList: [], // 日历核心渲染数组
selectedDateStr: "", // 选中日期的格式化字符串(用于状态判断)
};
},
// 组件创建后立即执行,初始化日历数据
created() {
this.initCurrentMonthCalendar();
},
};
2. 核心方法:日历初始化(initCurrentMonthCalendar)
这个方法是整个组件的核心,负责计算当月所有日期信息,并生成calendarDateList数组,步骤拆解如下:
javascript
运行
initCurrentMonthCalendar() {
// 1. 获取当前日期的核心信息
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth(); // 注意:JavaScript中月份是0-11
const currentDay = now.getDate();
// 2. 计算当月关键日期:第一天、最后一天、第一天是星期几、当月总天数
const firstDayOfMonth = new Date(currentYear, currentMonth, 1); // 当月第一天
const lastDayOfMonth = new Date(currentYear, currentMonth + 1, 0); // 当月最后一天
const firstDayWeek = firstDayOfMonth.getDay(); // 当月第一天是星期几(0=周日,6=周六)
const currentMonthDayCount = lastDayOfMonth.getDate(); // 当月总天数
// 3. 计算日历所需的总格子数(月初空白占位 + 当月总天数)
const actualGridCount = firstDayWeek + currentMonthDayCount;
// 可选优化:凑满整行(避免最后一行缺列):Math.ceil((firstDayWeek + currentMonthDayCount) / 7) * 7;
// 4. 循环生成日历渲染数组
this.calendarDateList = [];
for (let i = 0; i < actualGridCount; i++) {
// 计算当前格子对应的相对日期(相对于当月第一天)
const relativeDay = i - firstDayWeek + 1;
let dateItem = {};
// 5. 区分当月有效日期和月初空白占位
if (relativeDay >= 1 && relativeDay <= currentMonthDayCount) {
// 当月有效日期:组装完整日期信息
const currentDate = new Date(currentYear, currentMonth, relativeDay);
const dateStr = this.formatDateStr(currentDate);
dateItem = {
year: currentYear,
month: currentMonth + 1, // 转换为1-12的月份格式,符合日常使用习惯
day: relativeDay,
dateStr: dateStr, // 格式化日期字符串,用于选中状态判断
isCurrentMonth: true, // 标记为当月日期
isToday: relativeDay === currentDay, // 标记是否为当天
};
} else {
// 月初空白占位:仅保留基础结构,不显示有效内容
dateItem = {
year: currentYear,
month: currentMonth + 1,
day: "",
dateStr: "",
isCurrentMonth: false,
isToday: false,
};
}
this.calendarDateList.push(dateItem);
}
// 6. 初始化选中日期为当天
this.selectedDateStr = this.formatDateStr(now);
}
3. 辅助方法:日期格式化与补零
javascript
运行
// 格式化日期为"YYYY-MM-DD"格式
formatDateStr(date) {
const year = date.getFullYear();
const month = this.padZero(date.getMonth() + 1);
const day = this.padZero(date.getDate());
return `${year}-${month}-${day}`;
},
// 数字补零(确保月份、日期都是两位数格式)
padZero(num) {
return num < 10 ? `0${num}` : `${num}`;
}
4. 事件方法:日期点击处理
javascript
运行
handleDateClick(date) {
// 过滤非当月日期,禁止选中空白占位
if (!date.isCurrentMonth) return;
// 更新选中日期状态
this.selectedDateStr = date.dateStr;
// 向外派发日期变化事件,传递完整日期对象,方便父组件接收
this.$emit("date-change", date);
}
脚本关键说明:
- 利用 JavaScript 的
Date对象精准计算当月关键日期,这是日历渲染的基础 - 区分 "当月有效日期" 和 "月初空白占位",确保日历布局规整且无无效内容
- 日期格式化统一为
YYYY-MM-DD格式,避免选中状态判断出现歧义 - 点击事件做了非当月日期过滤,提升交互的合理性
- 组件初始化时默认选中当天,符合用户使用习惯
- 向外派发
date-change事件,实现组件与父组件的通信,提升组件复用性
第三步:样式美化(style 部分)
样式使用 SCSS 编写,开启scoped确保样式隔离,不污染全局,同时兼顾美观和移动端适配。
scss
<style scoped lang="scss">
.calendar-container {
width: 100%;
padding: 20rpx;
box-sizing: border-box;
background: #fff;
}
.calendar-week-header {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
.week-item {
width: 14.28%; // 1/7,确保7个元素均匀分布
text-align: center;
font-size: 28rpx;
color: #666;
}
}
.calendar-date-content {
display: flex;
flex-wrap: wrap; // 自动换行,实现日历网格布局
.date-item {
width: 14.28%;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #333;
box-sizing: border-box;
margin-bottom: 10rpx;
// 非当月占位样式(透明化)
&.empty-item {
color: transparent;
}
// 当天日期样式(浅蓝背景+蓝色文字)
&.today-item {
background: #e6f7ff;
color: #1890ff;
font-weight: bold;
}
// 选中日期样式(深蓝背景+白色文字)
&.selected-item {
background: #1890ff;
color: #fff;
font-weight: bold;
}
}
}
</style>
样式关键说明:
- 采用
flex布局实现星期头部和日期网格的均匀分布,14.28%对应 1/7,确保 7 列布局规整 - 日期格子使用
flex居中对齐,提升视觉美观度 - 不同状态使用差异化的背景色和文字色,视觉区分明显,用户体验佳
- 使用
rpx单位适配移动端不同设备屏幕,兼容性更好 scoped属性确保样式仅作用于当前组件,避免样式冲突
组件使用方法
这个组件实现完成后,可直接在父组件中引入使用,步骤如下:
- 引入并注册组件
javascript
运行
<script setup>
// 引入日历组件
import CurrentMonthCalendar from "../../components/CurrentMonthCalendar/CurrentMonthCalendar.vue";
// 补充组件抛出事件的处理方法
const handleCalendarDateChange = (date) => {
console.log("选中的日期:", date);
// 后续业务逻辑可在此扩展
};
</script>
- 在模板中使用并监听日期变化事件
vue
<template>
<view class="page-container">
<!-- 引入日历组件 -->
<CurrentMonthCalendar @date-change="handleCalendarDateChange" />
</view>
</template>
- 父组件接收选中日期
javascript
运行
methods: {
handleCalendarDateChange(selectedDate) {
console.log("选中的日期信息:", selectedDate);
// 后续可进行预约、筛选等业务逻辑处理
}
}
组件优化与扩展方向
这个极简日历组件满足了基础需求,我们还可以根据业务场景进行优化和扩展:
- 样式优化:添加圆角、阴影效果,提升视觉层次感;支持自定义主题色
- 功能扩展:增加年月切换按钮、支持日期范围选择、禁用指定日期
- 兼容性优化:适配 Vue 3,支持 Composition API;兼容 PC 端(替换 rpx 为 px/rem)
- 性能优化:缓存当月日期数据,避免组件重复渲染时重复计算
希望这篇文章能帮助大家理解日历组件的实现逻辑,在实际项目中快速落地相关功能!
完整源码
<template>
<view class="calendar-container">
<!-- 日历头部:星期标题 -->
<view class="calendar-week-header">
<view class="week-item" v-for="(week, index) in weekList" :key="index">
{{ week }}
</view>
</view>
<!-- 日历主体:日期格子 -->
<view class="calendar-date-content">
<view
class="date-item"
v-for="(date, index) in calendarDateList"
:key="index"
:class="{
'empty-item': !date.isCurrentMonth,
'today-item': date.isToday,
'selected-item': date.dateStr === selectedDateStr,
}"
@click="handleDateClick(date)"
>
{{ date.day || "" }}
</view>
</view>
</view>
</template>
<script>
export default {
name: "CurrentMonthCalendar",
data() {
return {
weekList: ["日", "一", "二", "三", "四", "五", "六"],
calendarDateList: [],
selectedDateStr: "",
};
},
// 关键:组件创建后立即执行(必触发)
created() {
this.initCurrentMonthCalendar();
},
methods: {
initCurrentMonthCalendar() {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth();
const currentDay = now.getDate();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1);
const lastDayOfMonth = new Date(currentYear, currentMonth + 1, 0);
const firstDayWeek = firstDayOfMonth.getDay(); // 当月第一天是星期几(0=周日)
const currentMonthDayCount = lastDayOfMonth.getDate(); // 当月总天数
// 关键修改1:计算实际需要的格子数(不再固定42个)
// 公式:月初空白格子数 + 当月总天数
// 月初空白格子数 = firstDayWeek(周日开头)
const actualGridCount = firstDayWeek + currentMonthDayCount;
// 可选:若想凑满整行(避免最后一行缺列),可计算到最近的7的倍数(注释掉则直接截止到最后一天)
// const actualGridCount = Math.ceil((firstDayWeek + currentMonthDayCount) / 7) * 7;
this.calendarDateList = [];
for (let i = 0; i < actualGridCount; i++) {
// 关键修改2:循环到实际格子数,而非42
const relativeDay = i - firstDayWeek + 1;
let dateItem = {};
if (relativeDay >= 1 && relativeDay <= currentMonthDayCount) {
// 当月有效日期
const currentDate = new Date(currentYear, currentMonth, relativeDay);
const dateStr = this.formatDateStr(currentDate);
dateItem = {
year: currentYear,
month: currentMonth + 1,
day: relativeDay,
dateStr: dateStr,
isCurrentMonth: true,
isToday: relativeDay === currentDay,
};
} else {
// 月初空白占位(仅保留上方,无下方占位)
dateItem = {
year: currentYear,
month: currentMonth + 1,
day: "",
dateStr: "",
isCurrentMonth: false,
isToday: false,
};
}
this.calendarDateList.push(dateItem);
}
this.selectedDateStr = this.formatDateStr(now);
},
formatDateStr(date) {
const year = date.getFullYear();
const month = this.padZero(date.getMonth() + 1);
const day = this.padZero(date.getDate());
return `${year}-${month}-${day}`;
},
padZero(num) {
return num < 10 ? `0${num}` : `${num}`;
},
handleDateClick(date) {
if (!date.isCurrentMonth) return;
this.selectedDateStr = date.dateStr;
this.$emit("date-change", date);
},
},
};
</script>
<style scoped lang="scss">
.calendar-container {
width: 100%;
padding: 20rpx;
box-sizing: border-box;
background: #fff;
}
.calendar-week-header {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
.week-item {
width: 14.28%;
text-align: center;
font-size: 28rpx;
color: #666;
}
}
.calendar-date-content {
display: flex;
flex-wrap: wrap;
.date-item {
width: 14.28%;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #333;
box-sizing: border-box;
margin-bottom: 10rpx;
&.empty-item {
color: transparent;
}
&.today-item {
background: #e6f7ff;
color: #1890ff;
font-weight: bold;
}
&.selected-item {
background: #1890ff;
color: #fff;
font-weight: bold;
}
}
}
</style>