【step by step】使用vue3撸一个日历组件

4,933 阅读6分钟

日历组件大家都不陌生,在做一些中后台项目时,多数都会遇到。实际项目时,往往都会选择一套完整的UI框架,如Vue常用的ElementUI, React常用的antd Design等,这些组件库都会集成功能完善的日历组件,"拿来主义",我们会使用就好了。但是仔细想一下, 基本的日历组件以展示为主,交互并不复杂,那么我们能不能自己撸一个日历组件呢?接下来我们就一步一步, 分析该如何写出一个日历组件

一、核心功能

calendar-2.png

一个完整的日历展示大致如上图。包含三个区块:

  1. 月份日期;
  2. 上月末尾日期(红色框);
  3. 下月开头日期(蓝色框); 如果仅仅显示当月日期也是可以的, 但是日历上会有空白区域,不是那么美观, 通常的做法是用上月和下月的天数补齐, 因此我们这里采用42宫格的日历表,这样可以完整的展示任何月份的日历信息。 难点问题也围绕这三个, 如何正确渲染区块日期
    计算原理:

1)获取当前月份的天数,计算出1号是周几,用于渲染当前月份的起始位置, 结束位置就是当月天数;2)补充上月末尾天数;上月末尾天数需要根据上月的天数和1号星期索引来计算: 上月天数 - 当月1号星期索引 + i(循环参数) + 1;3)下月开始天数,只需要知道当前日期表剩余的天数,从1号开始递增填补即可。

接下来我们就 step by step, 写出日历组件。

这里我们分两步走,首先先完成逻辑部分,要暴露出 generateCalendar(date: Date)函数, 这个函数最终会吐出完整的月份表的数据, 即42宫格数据,包括上月和下月的数据,然后根据数据,再写样式。

二、月份日期计算

月份日期计算要知道两点:这个月有几天,这个月1号是周几?
我们通过javascript的内置日期类:Date可以操作几乎所有日期相关内容。

2.1 月份天数

我们知道一年月份中,1,3,5,7,8,10,12月有31天,4,6,9,11月有30天,闰年2月29天,平年2月29天。所以我们需要两个函数:isLeap(year: number)getDays(year: number, month: year)来确定给日期的准确天数。

// 是否为闰年
const isLeap = (year: number) => {
  return (year % 4 === 0 && year % 100 !== 0) || year % 100 === 0;
};

// 获取xx年xx月有几天
const getDays = (year: number, month: number): number => {
  const feb = isLeap(year) ? 29 : 28;
  const daysPerMonth = [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  return daysPerMonth[month];
};

然后我们需要知道这个月1号是周几,这个我们可以通过Date.prototype.getDay()来获取。
注意:该API获取的星期是从周日开始,即0表示周日,其他以此递增。

我们这里使用的月份表示6x7的42宫格, 可以完整的展示月份信息。接下来我们来来构造generateCalendar(date: Date)函数, 先计算出当前月份的位置:

export interface CalendarItem {
  year: number;
  month: number;
  day: number;
  isCurrentMonth: boolean;
}
// ...
export const generateCalendar = (date: Date) => {
  const currentYear = date.getFullYear();
  const currentMonth = date.getMonth();
  // 当月天数
  const days = getDays(currentYear, currentMonth);
  // 1号是星期几
  const weekIndex = new Date(`${currentYear}, ${currentMonth + 1}, 1`).getDay();
  const calendarTable: CalendarItem[] = [];
  for (let i = 0; i < calendarGrid; i++) {
    if (i < weekIndex) {
      // 补充上月天数
    } else if (i >= days + weekIndex) {
      // 补充下月天数
    }
  }
  // 填充当月日期
  for (let d = 1; d <= days; d++) {
    calendarTable[weekIndex + d - 1] = {
      year: currentYear,
      month: currentMonth,
      day: d,
      isCurrentMonth: true,
    };
  }

  return calendarTable;
};

接口CalendarItem中,day表示这天是几号,isCurrentMonth表示这天是否是当月的日期, 因为我们的表里会有上月和下月的日期,有可能或出现相同日期的时间(同日不同月),我们要对不是当月的日期做次级处理(置灰处理),需要isCurrentMonth标识。
我们这里采用的是构造一维数组:calendarTable来渲染保存日期,很自然,这个一维数组的中间一部分才是当月日期;
注意看填充当月日期部分代码:例如本月有30天,1号是周五,那么本月日日期的起始位置是calendarTable数组中的第 5 + 1 - 1(weekIndx + d - 1)的位置开始, 一直填充到30(days)为止;

2.2 上月天数和下月天数补充

完成第一步,日历就已算完成,但是表的开头和结尾会有空白, 不是那么美观, 我们可以用上月末尾天数和下月起始天数来填充。
上月和下月是根据当前月份来确定的, 注意:

  1. 如果当前月是1月, 那么上月就是上一年的12月;
  2. 如果当前月是12月,那么下月就是下一年的1月;
  3. 其他中间月份,月份正常±1即可,年份不变;
// 获取下个月/上个月有多少天
const getNextOrLastMonthDays = (date: Date, type: 'next' | 'last') => {
  const month = date.getMonth();
  const year = date.getFullYear();
  if (type === 'last') {
    const lastMonth = month === 0 ? 11 : month - 1;
    const lastYear = lastMonth === 11 ? year - 1 : year;
    return {
      year: lastYear,
      month: lastMonth,
      days: getDays(lastYear, lastMonth),
    };
  }
  const nextMonth = month === 11 ? 0 : month + 1;
  const nextYear = nextMonth === 0 ? year + 1 : year;
  return {
    year: nextYear,
    month: nextMonth,
    days: getDays(nextYear, nextMonth),
  };
};

然后我们继续完成generateCalendar函数:

// ... 
const calendarGrid = 42; // 7 * 6宫格;
const generateCalendar = (date: Date) => {
  const currentYear = date.getFullYear();
  const currentMonth = date.getMonth();
  // 当月天数
  const days = getDays(currentYear, currentMonth);
  // 获取上月末尾天数和下月开头的天数,用于填补当月日历空白
  const { days: lastMonthDays, year: lastMonthYear, month: lastMonth } = getNextOrLastMonthDays(date, 'last');
  const { year: nextMonthYear, month: nextMonthMonth } = getNextOrLastMonthDays(date, 'next');
  // 1号是星期几
  const weekIndex = new Date(`${currentYear}, ${currentMonth + 1}, 1`).getDay();
  // 显示在当月末尾的下月天数
  const trailDays = calendarGrid - weekIndex - days;
  let trailVal = 0;
  const calendarTable: CalendarItem[] = [];
  for (let i = 0; i < calendarGrid; i++) {
    if (i < weekIndex) {
      // 补充上月天数
      calendarTable[i] = {
        year: lastMonthYear,
        month: lastMonth,
        day: lastMonthDays - weekIndex + i + 1,
        isCurrentMonth: false,
      };
    } else if (i >= days + weekIndex) {
      // 补充下月天数
      if (trailVal < trailDays) {
        trailVal += 1;
      }
      calendarTable[i] = {
        year: nextMonthYear,
        month: nextMonthMonth,
        day: trailVal,
        isCurrentMonth: false,
      };
    }
  }
  // 填充当月日期
  for (let d = 1; d <= days; d++) {
    calendarTable[weekIndex + d - 1] = {
      year: currentYear,
      month: currentMonth,
      day: d,
      isCurrentMonth: true,
    };
  }

  return calendarTable;
};

再次引用原理:

1)获取当前月份的天数,计算出1号是周几,用于渲染当前月份的起始位置, 结束位置就是当月天数;2)补充上月末尾天数;上月末尾天数需要根据上月的天数和1号星期索引来计算: 上月天数 - 当月1号星期索引 + i(循环参数) + 1;3)下月开始天数,只需要知道当前日期表剩余的天数,从1号开始递增填补即可。

结合代码的补充上月和下月天数部分,就会比较清晰了。

自此,核心函数 generateCalendar(date: Date)就完成了, 它接收任意日期参数, 就会返回日期所在月份的完整信息。接下来就是展示部分。

三、日历展示

一个基本的日历有日历头(星期栏)和日期内容组成。
我们要写的是vue组件, 使用vue3.2, 结合setup语法糖,写起来不要太爽~
模板大致如下:

<template>
  <div class="calendar">
    <!-- 表头操作  -->
    <div class="calendar-operate">
      <!-- 左侧日切切换按钮组  -->
      <div class="button-group">
        <button class="button" @click="changeMonth('prev')">
          <i class="icon ri-arrow-left-s-line"></i>
        </button>
        <button class="button" @click="changeMonth('next')">
          <i class="icon ri-arrow-right-s-line"></i>
        </button>
      </div>
      <!-- 中间显示当前年月 -->
      <div class="calendar-operate__title">{{ dateText }}</div>
      <!-- 切换今天按钮  -->
      <button class="button" :disabled="isToday" @click="currentDate">今天</button>
    </div>
    <!-- for循环渲染星期数, 周末弱化显示 -->
    <div class="calendar-header">
      <span
        v-for="(item, index) in weekMapZh"
        :key="index"
        class="calendar-header__item"
        :class="{ gray: index === 0 || index === 6 }"
        >{{ item }}</span
      >
    </div>
    <!-- 日期表显示 -->
    <div class="calendar-content" :data-month="date.getMonth() + 1">
      <div
        v-for="(item, index) in calendarTable"
        :key="index"
        class="calendar-content__item"
        :class="[{ light: !item.isCurrentMonth }, { active: isActive(item) }]"
      >
        {{ item.day }}
      </div>
    </div>
  </div>
</template>

这里说下一些细节功能的实现:
首先,我们要高亮当天日期,例如今天是1号,我们要在日历表里着重显示这一天。

<script lang="ts" setup>
// 当切换时间时, date是变化的
const date = ref<Date>(new Date());
const calendarTable = computed(() => generateCalendar(date.value));
// ...
/**
 * 当天日期高亮显示, 兼容切换日期:
 * 年月日都要对上才能高亮
 * ps: 日历可能会显示下月/上月的同样日期, 仅当月日期高亮
 */
const isActive = (item: CalendarItem) => {
  return isAllTrue([
    item.day === date.value.getDate(),
    item.isCurrentMonth,
    item.month === new Date().getMonth(),
    item.year === new Date().getFullYear(),
  ]);
};
</script>

其次, 想要有个切换月份的功能, 即切换到上月或下月,这里有个逻辑,因为generateCalendar(date: Date)函数接收的是Date类型的参数,因此我们所有对日期的操作都将返回Date类型,即改变date这个参数,然后使用计算属性,会联动生成新的日历表。

至此, 一个基本的,带切换日期的基础日历就完成了🎉🎉

image.png

四、总结

这里一步一步的完成了一个基本日历组件,功能并不复杂。 这里做一下简单的复盘:

  1. 对Date类要有一定了解,起初觉得日历有些复杂的原因就是对Date类的用法不熟悉;
  2. 理清逻辑,对要实现的产品功能有一个清晰的认识。只有明白要做什么,才能去思考怎么做;
  3. 扩展性。 我们还要考虑组件的扩展性,通常我们写业务组件会更多一些, 可能这个业务组件只在某几个页面中使用,但是表现形式可能会有所不同,这时我们就要考虑怎样用少的代码,清晰的逻辑让组件易于使用和后期再扩展, 同时要保证组件代码是可维护的(该写注释的写注释,该解耦的解耦~),这样才是一个合格的组件。

最后贴上源码地址, 欢迎小伙伴们多多指教,多多star⭐️

github.com/CiroLee/vue…