[Element Plus 源码解析] Calendar 日历

3,544 阅读4分钟

一、组件介绍

官网链接:Calendar 组件 | Element (gitee.io)

Calendar用于显示日期。

Element plus中的日期处理使用的是day.js,这个库大小只有2KB,是moment.js的轻量化方案。

1.1 属性

  • v-model: Date类型,表示选中的日期;
  • range: Date[]类型,表示显示的日期范围;不传range时默认当前月,且可以切换月份。

1.2 插槽

  • data-cell: 具名插槽,用于自定义日期显示内容。

二、源码分析

2.1 Calendar组件代码

2.1.1 template

<template>
  <div class="el-calendar">
    <div class="el-calendar__header">
      // 标题:格式为 2021年7月
      <div class="el-calendar__title">{{ i18nDate }}</div>
      // 非range模式
      <div v-if="validatedRange.length === 0" class="el-calendar__button-group">
        // 非range模式时,可以切换月份
        // 按钮组,上一月、今天、下一月 
        <el-button-group>
          <el-button
            size="mini"
            @click="selectDate('prev-month')"
          >
            {{ t('el.datepicker.prevMonth') }}
          </el-button>
          <el-button size="mini" @click="selectDate('today')">
            {{
              t('el.datepicker.today')
            }}
          </el-button>
          <el-button
            size="mini"
            @click="selectDate('next-month')"
          >
            {{ t('el.datepicker.nextMonth') }}
          </el-button>
        </el-button-group>
      </div>
    </div>
    // 非range模式时的日期table展示
    <div v-if="validatedRange.length === 0" class="el-calendar__body">
      // date-table是一个内部组件,用于渲染日期table
      <date-table
        :date="date"
        :selected-day="realSelectedDay"
        @pick="pickDay"
      >
        <template v-if="$slots.dateCell" #dateCell="data">
          // date-cell具名插槽
          <slot name="dateCell" v-bind="data"></slot>
        </template>
      </date-table>
    </div>
    // range模式,只展示range范围内的日期
    <div v-else class="el-calendar__body">
      <date-table
        v-for="(range_, index) in validatedRange"
        :key="index"
        :date="range_[0]"
        :selected-day="realSelectedDay"
        :range="range_"
        :hide-header="index !== 0"
        @pick="pickDay"
      >
        <template v-if="$slots.dateCell" #dateCell="data">
          <slot name="dateCell" v-bind="data"></slot>
        </template>
      </date-table>
    </div>
  </div>
</template>

2.1.2 script

import { t } from '@element-plus/locale'
import dayjs, { Dayjs } from 'dayjs'
import DateTable from './date-table.vue'

setup(props, ctx) {
    // 选中的日期
    const selectedDay = ref(null)
    // 生成一个dayjs实例,日期指向今天
    // 这里使用我写文章的日期为例,今天是 2021/07/19
    const now = dayjs()
    
    // 计算属性,当前选中的日期,注意这是一个dayjs实例
    const date: ComputedRef<Dayjs> = computed(() => {
      // 没有传入v-model情况
      // vue3中v-model默认的属性是modelValue,默认的事件是update:modelValue
      if (!props.modelValue) {
        if (realSelectedDay.value) {
          return realSelectedDay.value
        } else if (validatedRange.value.length) {
          return validatedRange.value[0][0]
        }
        return now
      } else {
        // 传了v-model的情况
        return dayjs(props.modelValue)
      }
    })
    
    // 计算属性,根据选中日期,计算上个月的今天
    // 按照我写文章的日期算,是2021/06/19
    const prevMonthDayjs = computed(() => {
      // 使用dayjs的subtract api,使月份-1
      return date.value.subtract(1, 'month')
    })
    // 计算属性,根据选中日期,格式化成YYYY-MM形式
    const curMonthDatePrefix = computed(() => {
      return dayjs(date.value).format('YYYY-MM')
    })
    
    // 计算属性,根据选中日期,计算上个月的今天
    // 按照我写文章的日期算,是2021/09/19
    const nextMonthDayjs = computed(() => {
      // 使用dayjs的add api,使月份+1
      return date.value.add(1, 'month')
    })
    
    // 使用i18n处理当前日期 
    const i18nDate = computed(() => {
      const pickedMonth = `el.datepicker.month${date.value.format('M')}`
      // t方法是Elment plus内部实现的国际化方法,可以兼容vue-i18n
      return `${date.value.year()} ${t('el.datepicker.year')} ${t(pickedMonth)}`
    })
    
    // 实际选中的日期,通过get set方式设置计算属性,在访问时触发get方法,在修改时触发set方法
    const realSelectedDay = computed({
      get() {
        if (!props.modelValue) return selectedDay.value
        return date.value
      },
      set(val: Dayjs) {
        selectedDay.value = val
        const result = val.toDate()

        ctx.emit('input', result)
        ctx.emit('update:modelValue', result)
      },
    })

    // 如果是range模式,validatedRange将会是一个二维数组
    const validatedRange = computed(() => {
      if (!props.range) return []
      // 将props.range属性中的起始和结束日期传入dayjs,生成2个dayjs实例
      const rangeArrDayjs = props.range.map(_ => dayjs(_))
      // 解构赋值,拿到起始日期和结束日期的dayjs实例
      const [startDayjs, endDayjs] = rangeArrDayjs
      // 起始日期大于结束日期,报错提醒
      if (startDayjs.isAfter(endDayjs)) {
        console.warn(
          '[ElementCalendar]end time should be greater than start time',
        )
        return []
      }
      // 起始日期和结束日期在同一个月的情况
      // 使用dayjs的 isSame api进行判断
      if (startDayjs.isSame(endDayjs, 'month')) {
        // 返回二维数组,其中含有一个元素
        return [[
          // 起始日期所在周的首日,是一个周日,如今天2021/07/19是周一,那今天所在周的首日就是2021/07/18周日
          startDayjs.startOf('week'),
          // 终止日期所在周的末日,是一个周六,如今天2021/07/19是周一,那今天所在周的末日就是2021/07/24周六
          endDayjs.endOf('week'),
        ]]
      } else {
        // 如果起始日期和终止日期的月份间隔超过1个月,报错提示
        if (startDayjs.add(1, 'month').month() !== endDayjs.month()) {
          console.warn(
            '[ElementCalendar]start time and end time interval must not exceed two months',
          )
          return []
        }
        // 结束日期所在月份的首日,简称
        const endMonthFirstDay = endDayjs.startOf('month')
        // 【结束月首日】所在周的首日
        const endMonthFirstWeekDay = endMonthFirstDay.startOf('week')
        let endMonthStart = endMonthFirstDay
        // 【结束月首日】和 【结束月首日】所在周的首日 不在同一个月时
        // 这说明 【结束月首日】所在周一部分属于上个月
        if (!endMonthFirstDay.isSame(endMonthFirstWeekDay, 'month')) {
          endMonthStart = endMonthFirstDay.endOf('week').add(1, 'day')
        }
        return [
          // 二维数据,第一个元素是起始月份的日期范围
          [
            startDayjs.startOf('week'),
            startDayjs.endOf('month'),
          ],
          // 第二个元素是起始月份的日期范围
          [
            endMonthStart,
            endDayjs.endOf('week'),
          ],
        ]
      }
    }) 
    
    // 切换月份
    const selectDate = type => {
      let day: Dayjs
      if (type === 'prev-month') {
        // 上个月的今天
        day = prevMonthDayjs.value
      } else if (type === 'next-month') {
        // 下个月的今天
        day = nextMonthDayjs.value
      } else {
        // 今天
        day = now
      }
      // 点击的日期和当前选择的一致,不处理
      if (day.isSame(date.value, 'day')) return
      pickDay(day)
    }
    
    const pickDay = (day: Dayjs) => {
      realSelectedDay.value = day
    }
    
    return {
      selectedDay,
      curMonthDatePrefix,
      i18nDate,
      realSelectedDay,
      date,
      validatedRange,
      pickDay,
      selectDate,
      t,
    }
  },

2.2 date-table 组件代码

2.2.1 template

<template>
  // 使用原生table表格
  <table
    :class="{
      'el-calendar-table': true,
      'is-range': isInRange
    }"
    cellspacing="0"
    cellpadding="0"
  >
    <thead v-if="!hideHeader">
      // 表头: 周日 ~ 周六
      <th v-for="day in weekDays" :key="day">{{ day }}</th>
    </thead>
    <tbody>
      // 表格rows
      <tr
        v-for="(row, index) in rows"
        :key="index"
        :class="{
          'el-calendar-table__row': true,
          'el-calendar-table__row--hide-border': index === 0 && hideHeader
        }"
      >
        // column
        <td
          v-for="(cell, key) in row"
          :key="key"
          :class="getCellClass(cell)"
          @click="pickDay(cell)"
        >
          <div class="el-calendar-day">
            <slot
              name="dateCell"
              :data="getSlotData(cell)"
            >
              <span>{{ cell.text }}</span>
            </slot>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
</template>

2.2.2 script

// 仅展示部分核心代码
setup(props, ctx) {
    // 获取当前语言环境下,一周的文本,如中国是: 周日_周一_周二_周三_周四_周五_周六
    const WEEK_DAYS = ref(dayjs().localeData().weekdaysShort())

    const now = dayjs()

    // 获取当前语言环境下,一周的起始日,比如中国就是1,代表周一
    const firstDayOfWeek = (now as any).$locale().weekStart || 0

    // 计算表头,中文下最终结果是 [周一、周二、周三、周四、周五、周六、周日]
    const weekDays = computed(() => {
      const start = firstDayOfWeek

      if (start === 0) {
        return WEEK_DAYS.value
      } else {
        return WEEK_DAYS.value
          .slice(start)
          .concat(WEEK_DAYS.value.slice(0, start))
      }
    })

    // 将一维数组分割成二维数组,子数组的长度是7位,对应一周的天数
    const toNestedArr = days => {
      return rangeArr(days.length / 7).map((_, index) => {
        const start = index * 7
        return days.slice(start, start + 7)
      })
    }

    // 计算属性,生成table的rows
    const rows = computed(() => {
      let days = []
      // range模式
      if (isInRange.value) {
        // 获得range的起始日期
        const [start, end] = props.range
        // 生成当前月的数组
        const currentMonthRange = rangeArr(
          end.date() - start.date() + 1,
        ).map((_, index) => ({
          text: start.date() + index,
          type: 'current',
        }))

        let remaining = currentMonthRange.length % 7
        remaining = remaining === 0 ? 0 : 7 - remaining
        // 生成下个月的数组
        const nextMonthRange = rangeArr(remaining).map((_, index) => ({
          text: index + 1,
          type: 'next',
        }))
        // 拼接成1个数组
        days = currentMonthRange.concat(nextMonthRange)
      } else {
        // 非range模式
        // 当前选中日期所在月的首日
        const firstDay = props.date.startOf('month').day() || 7
        // 上个月的日期数组
        const prevMonthDays = getPrevMonthLastDays(
          props.date,
          firstDay - firstDayOfWeek,
        ).map(day => ({
          text: day,
          type: 'prev',
        }))
        // 当前月的日期数组
        const currentMonthDays = getMonthDays(props.date).map(day => ({
          text: day,
          type: 'current',
        }))
        days = [...prevMonthDays, ...currentMonthDays]
        // 下个月的日期数组
        const nextMonthDays = rangeArr(42 - days.length).map((_, index) => ({
          text: index + 1,
          type: 'next',
        }))
        // 拼接成一个数组
        // 非range模式下固定每个月展示6周,6*7=42个日期的数据。因为每个月最大天数为31天,最多覆盖在6周中
        days = days.concat(nextMonthDays)
      }
      // 一维数组转换成二维数组
      return toNestedArr(days)
    })

    // 日期格式化,格式化的结果:YYYY-MM-dd
    const getFormattedDate = (day, type): Dayjs => {
      let result
      if (type === 'prev') {
        result = props.date.startOf('month').subtract(1, 'month').date(day)
      } else if (type === 'next') {
        result = props.date.startOf('month').add(1, 'month').date(day)
      } else {
        result = props.date.date(day)
      }
      return result
    }

    // 根据日期类型,设置cell class
    const getCellClass = ({ text, type }) => {
      const classes = [type]
      if (type === 'current') {
        const date_ = getFormattedDate(text, type)
        if (date_.isSame(props.selectedDay, 'day')) {
          classes.push('is-selected')
        }
        if (date_.isSame(now, 'day')) {
          classes.push('is-today')
        }
      }
      return classes
    }

    const pickDay = ({ text, type }) => {
      const date = getFormattedDate(text, type)
      ctx.emit('pick', date)
    }
    // 给插槽提供数据
    const getSlotData = ({ text, type }) => {
      const day = getFormattedDate(text, type)
      return {
        isSelected: day.isSame(props.selectedDay),
        type: `${type}-month`,
        day: day.format('YYYY-MM-DD'),
        date: day.toDate(),
      }
    }

    // 判断是否是range模式
    const isInRange = computed(() => {
      return props.range && props.range.length
    })

    return {
      isInRange,
      weekDays,
      rows,
      getCellClass,
      pickDay,
      getSlotData,
    }
  }

2.3 总结

  1. Element plus中使用dayjs库进行日期处理,该库体积小,功能丰富;
  2. 日历组件内部使用table生成日期表格进行展示,展示6周,可以完整覆盖一个月的日期;
  3. Vue是数据驱动视图,重点在于管理好数据。