一、组件介绍
官网链接: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 总结
Element plus
中使用dayjs
库进行日期处理,该库体积小,功能丰富;- 日历组件内部使用table生成日期表格进行展示,展示6周,可以完整覆盖一个月的日期;
- Vue是数据驱动视图,重点在于管理好数据。