Vue 3 日历组件实现:从基础渲染到交互优化的完整方案

在现代 Web 应用中,日历组件是一个不可或缺的交互元素,广泛应用于预约系统、日程管理、日期选择等场景。一个设计精良的日历组件不仅要视觉美观,更要提供直观的操作体验和灵活的功能扩展。本文将详细解析如何使用 Vue 3 的 Composition API 实现一个功能完善的日历组件,包括月份切换、日期选择、禁用控制等核心功能。

组件功能与设计理念

本文实现的日历组件具备以下核心功能:

  • 完整的月份视图展示,包括正确的日期排列
  • 支持月份切换(上一个月 / 下一个月)
  • 日期选择功能,带有选中状态高亮
  • 可配置的日期禁用功能(支持禁用过去的日期)
  • 多语言支持(中英文月份和星期显示)

设计理念遵循 "简洁直观" 原则,通过清晰的视觉层次和明确的交互反馈,让用户能够快速完成日期选择操作。同时,组件采用模块化设计,确保各功能点低耦合、高内聚,便于后续维护和扩展。

技术栈与实现方式

  • 框架:Vue 3(使用 Composition API)
  • 状态管理refreactive 与 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

核心功能实现要点

  1. 精准的日期网格渲染
    日历的核心在于正确计算每月日期分布,通过获取当月第一天和最后一天的星期位置,填充前后空白日期,确保网格始终保持 7 列完整布局。这种实现既符合用户对日历的视觉预期,又避免了因月份天数差异导致的布局错乱。

  2. 智能的月份切换逻辑
    处理月份切换时需考虑跨年场景:1 月切换到上一月自动变为上一年 12 月,12 月切换到下一月自动变为下一年 1 月。通过响应式状态管理当前年月,结合计算属性实时更新日期列表,实现无缝的月份导航体验。

  3. 灵活的日期选择机制
    选中状态通过日期字符串比对实现,确保跨时区场景下的准确性。同时支持日期禁用功能,通过IsEnabled属性可快速开启对过去日期的限制,满足预约系统等场景的特殊需求。

  4. 多语言支持与样式设计
    内置中英文月份和星期显示,便于国际化适配。样式设计上采用圆形选中态、灰度禁用态等视觉语言,通过悬停效果增强交互反馈,符合现代 UI 设计审美。

技术架构与最佳实践

  • 基于 Composition API 的状态管理:使用ref存储简单状态(选中日期),reactive管理复杂状态(当前年月),computed处理衍生数据(日期列表、月份名称),使代码逻辑清晰可维护。

  • 原生日期 API 的高效运用:不依赖第三方库,通过原生Date对象实现日期计算,减少包体积的同时保证兼容性。

  • 组件通信设计:通过props接收配置参数,emit事件传递选中结果,符合 Vue 组件设计规范,便于集成到各类业务场景。

  • 样式隔离与响应式布局:使用 scoped CSS 避免样式污染,通过百分比分配日期宽度,确保在不同屏幕尺寸下的显示效果一致。

可扩展方向

该组件可根据业务需求进一步扩展:

  • 增加日期范围选择功能,支持开始 / 结束日期联动
  • 集成日程标记功能,在对应日期显示事件提示
  • 支持自定义日期格式和禁用规则
  • 增加快捷键操作,提升键盘用户体验

总结

日历组件作为基础交互单元,其设计质量直接影响用户对产品的感知。通过合理的技术选型、清晰的逻辑设计和细致的体验优化,才能打造出既好用又好看的日历组件,为用户提供高效愉悦的日期选择体验。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值