前言
最近好奇日历组件是怎么实现的。于是阅读了下react-calendar的源码,并实现了简化版的日历组件。本文把实现日历的设计思路分享给大家。只要理清了主要逻辑,就不难实现了。
技术栈:react、typescript
预览
在线预览demo:coder-xuwentao.github.io/react-mini-…
主要功能
- 可选择日期
- 可选择日期范围
- 支持十年视图、年视图、月视图
- 国际化
- 支持最大/最小不可选
Api
export type CalendarProps = {
defaultValue?: Value; // 默认选择的日期值
value?: Value; // 选择的日期值
showNavigation?: boolean; // 是否展示导航栏
locale?: string; // 地区
selectRangeEnable?: boolean; // 是否支持选取范围
className?: string;
onChange?: (value: Value) => void; // 点击日历导致value变化时的事件勾子
onClickDay?: OnChangeFunc; // 点击日
onClickMonth?: OnChangeFunc; // 点击月
onClickYear?: OnChangeFunc; // 点击年
// StartDate,指的是当前日历组件展示的开头日期。一般在view改变时更新StartDate
onActiveStartDateChange?: (args: onActiveStartDateChangeArgs) => void;
calendarRef?: React.Ref<HTMLDivElement>; // 日历组件的ref
defaultView?: View; // 默认的视图:十年、年、月
maxDate?: Date; // 最大
minDate?: Date; // 最小
[key: string]: any;
};
// value为Date时,选择的是一个日期
// value为[Date, Date]时,选择的是日期的范围
export type Value = Date | [Date, Date] | undefined;
// 视图
export enum View {
'Decade',
'Year',
'Month',
}
使用例子
const locale = 'zh-CN';
function CalendarDemo() {
const [value, onChange] = useState<Value>(new Date());
return (
<Calendar value={value} onChange={onChange} locale={locale} selectRangeEnable />
);
}
设计思路:
首先日历分为上下两个部分导航栏和视图。
每个视图都有遍历可点击的单元格按钮,比如下方的“X日”。(下文就都叫单元格按钮 或 单元格)
视图
日历有三个维度的视图,从大到小为:十年(Decade)、年(year)、月(Month)。
视图范围
视图都需要一个范围,比如上图年视图,从 2023-1 到 2023-12;月视图,从2023-6-1到2023-6-30。
我们只需要用一个状态来记录范围的起始日期即可(activeStartDate)。各个视图会根据activeStartDate,在遍历渲染单元格按钮时,把日期值关联到按钮,方便展示和获取日期。
视图之间的切换
两种方式:
向下深入(DrillDown) 方式:点击单元格按钮,视图从高维变低维。
处理逻辑:
- 更新范围值activeStartDate
- 切换视图view
向上弹出(DrillUp) 方式:点击导航栏的中间按钮。 处理逻辑:
- 判断当前是否可以继续弹出
- 切换视图view
activeStartDate不需更新,因为此时的activeStartDate显然在新view的范围内。
部分代码预览:
// Calendar.tsx
// 深入到月
const haddleDrillDownToMonth = useCallback((monthIdx: number, event: React.MouseEvent) => {
setViewState(View.Month); // 切换视图view
const nextStartDate = getDateBySetMonth(activeStartDateState, monthIdx);
setActiveStartDate(nextStartDate, 'drillDown'); // 更新范围值activeStartDate
onClickMonth?.(nextStartDate, event);
}, [activeStartDateState, setActiveStartDate, onClickMonth]);
// 深入到年
const haddleDrillDownToYear = useCallback((year: number, event: React.MouseEvent) => {
setViewState(View.Year); // 切换视图view
const nextStartDate = getDateBySetYear(activeStartDateState, year);
setActiveStartDate(nextStartDate, 'drillDown'); // 更新范围值activeStartDate
onClickYear?.(nextStartDate, event);
}, [activeStartDateState, setActiveStartDate, onClickYear]);
// 在月视图点击“日”单元格按钮,显然无需做深入操作
const handleClickDay = () => {}
// 向上弹出
const handleDrillUp = useCallback(() => {
const drillUpAvailable = sortedViews.indexOf(viewState) > 0;
// 判断当前是否可以继续弹出
if (drillUpAvailable) {
// 切换视图view
// 其中sortedViews为 [View.Decade, View.Year, View.Month];
setViewState(sortedViews[sortedViews.indexOf(viewState) - 1]);
}
}, [viewState]);
导航栏
功能
- 点击中间标签,则向上切换视图view。
- 点击两侧按钮,会改变当前视图范围 - 即改变activeStartDate。
- 如上图,当前展示为2023年6月,点击“‹”会把范围调整到上一个月(2023-5),点击“«”会把范围调整到上一年(2022-6)
注意点
导航栏的中间标签的展示、以及两侧四个按钮的onClick逻辑,在不同视图view有不同的逻辑。比如在年视图时,点击“‹”会将当前日期减1年,而在月视图时,点击“‹”会将当前日期减1月。
可以用switch...case来分开各个view的逻辑。
部分代码预览
typescript
复制代码
// 中间标签展示的内容 const defaultLabel = (() => { switch (view) { case View.Decade: // 十年视图 // getDecadeFromDate获取十年的范围 return formatDecade(getDecadeFromDate(date), locale); case View.Year: // 年视图 return formatYear(date, locale); case View.Month: // 月视图 return formatMonthYear(date, locale); default: throw new Error(`Invalid view: ${view}.`); } })(); // 其中 formatXXX 函数的作用是格式化date。详见下面“格式化”
typescript
复制代码
// 点击"‹"时,计算最新的activeStartDate // ...'»'、'›'等逻辑类似, 这里就不列举了 export function getDatePrevious(view: View, date: Date): Date { const newDate = new Date(date); switch (view) { case View.Decade: // 十年视图 return getForeYear(newDate, -10); // 当前日期减10年 case View.Year: // 年视图 return getForeYear(newDate, -1); // 当前日期减1年 case View.Month: // 月视图 return getForeMonth(newDate, -1); // 当前日期减1月 default: throw new Error(`Invalid view type: ${view}`); } } // 当前日期的月份加num export function getForeMonth(date: Date, num: number) { const newDate = new Date(date); newDate.setMonth(newDate.getMonth() + num); return newDate; } // 当前日期的年份加num export function getForeYear(date: Date, num: number) { const newDate = new Date(date); newDate.setFullYear(newDate.getFullYear() + num); return newDate; }
格式化(国际化)
组件展示日期时,使用了ECMAScript 的国际化 API - Intl.DateTimeFormat.prototype.format()来进行格式化日期。
为了避免重复new对象造成消耗,在getFormatter
做了两层缓存处理:第一层的key是locale,第二层的key是format()的选项options。
相关代码:
typescript
复制代码
type Options = Intl.DateTimeFormatOptions; const localeToFormatterCache = new Map(); // key是locale, value是formatterCache function getFormatter(options: Options) { return function formatter(date: Date, locale = 'en-US') { if (!localeToFormatterCache.has(locale)) { localeToFormatterCache.set(locale, new Map()); } // key是 options, value是Intl的format方法 const formatterCache = localeToFormatterCache.get(locale); if (!formatterCache.has(options)) { formatterCache.set( options, new Intl.DateTimeFormat(locale, options).format, ); } return formatterCache.get(options)(date); }; } const formatDayOptions: Options = { day: 'numeric' }; const formatMonthOptions: Options = { month: 'long' }; const formatMonthYearOptions: Options = { month: 'long', year: 'numeric', }; const formatShortWeekdayOptions: Options = { weekday: 'short' }; const formatYearOptions: Options = { year: 'numeric' }; const formatTimeOptions: Options = { day: 'numeric', month: 'long', year: 'numeric', hour: "2-digit", minute: "2-digit", second: "2-digit", }; export const formatDay = getFormatter(formatDayOptions); export const formatMonth = getFormatter(formatMonthOptions); export const formatMonthYear = getFormatter(formatMonthYearOptions); export const formatShortWeekday = getFormatter(formatShortWeekdayOptions); export const formatYear = getFormatter(formatYearOptions); export const formatTime = getFormatter(formatTimeOptions); export function formatDecade ([start, end]: [Date, Date], locale?: string) { return `${formatYear(start, locale)} - ${formatYear(end, locale)}` } // 使用方式:formatMonthYear(date, locale);
细说月视图
这里挑出比较难的月视图来讲下。理解了月视图,也就理解了另外两个视图。
主要功能实现逻辑
-
从activeStartDate找出日历范围(start、end),然后再根据start、end遍历渲染单元格。
- 根据start、end,计算出日期信息,并将其绑定到日单元格,方便点击时获取对应日期。
-
判断单元格日期:
- 是否被选中(见下图1,图2)。
- 是否在hover范围内(见下图3)。
- 是否禁止点击(见下图4)
- 是否在周末
- 是否在相邻月份
以下是的四张图,可以辅助理解代码,表现分别为:
- 点击第一个日单元格
- 选择一个日单元格后,hover某个日单元格
- 点击第二个日单元格
- 当前月份的1、2日,在最小值minDate之外
相关代码
注释中有详细解释原理。也可以直接看源码,代码里的变量命名尽量做到了名副其实。
typescript
复制代码
// MonthView/Days.tsx import Day from './Day'; // ...其他import const className = 'mini-calendar__month-view__days'; export default function Days(props: DaysProps) { const { activeStartDate, locale, onClickDay, value, selectRangeEnable, maxDate, minDate, } = props; // 鼠标hover到的日单元格按钮对应的日期 const [hoverDate, setHoverDate] = useState<Date | null>(null); // 从activeStartDate获取所处年、月 const startYear = useMemo(() => activeStartDate.getFullYear(), [activeStartDate]); const startMonth = useMemo(() => activeStartDate.getMonth(), [activeStartDate]); // activeStartDate处于周几。其中,周日时getDay()为0, 这里改为7. const dayOfWeek = activeStartDate.getDay() || 7; // activeStartDate所处月份天数 const daysInMonth = getDaysInMonth(activeStartDate); // start和end,标记当前月份下的日期范围。 // 为了展示完整的一周,开头和结尾会考虑临近的省份, // 所以start有概率是负数, end有概率大于当前月份天数。 const start = -dayOfWeek + 2; // “-dayOfWeek+2”理解: // 首先dayOfWeek指的是在当月第一天处于周几。 // 2023.6.1是周四,start = -dayOfWeek + 2 = -4 + 2 = -2。 // 于是我们就知道,start为当月第-2天,也就还需要展示前一月的三天(-2=>-1=>0)。 // 。。。 // 而负数也可以用作new Date中day入参,然后计算出前一月的日期 // 比如开头的日期为:new Date(2023, 5/* 6月 */, -2/* start */) // 为2023-5-29 const end = (() => { // 当月的最后一天。 const activeEndDate = new Date(startYear, startMonth, daysInMonth); // 当月的最后一天 距离本周结束还剩几天 // 比如2023.6.30是周五。7 - 5 = 2,还剩两天 // 加上daysInMonth,则end为 32 // 。。。 // 而new Date的day入参超出当月天数范围,。后计算出后一月的日期 // 所以结尾日期为:new Date(2023, 5/* 6月 */, 32/* end */) // 即2023-7-2 const daysUntilEndOfTheWeek = 7 - activeEndDate.getDay(); if (daysUntilEndOfTheWeek === 7) { // getDay()为0,即此时刚好是周日,直接返回daysInMonth return daysInMonth; } return daysInMonth + daysUntilEndOfTheWeek; })(); // 事件:点击日单元格 const handleClickDay = useCallback((event: React.MouseEvent) => { if (!(event.target instanceof HTMLButtonElement)) { return; } // 用了事件委托, 会直接把日期记录每个单元格按钮的dataset上 const { date: dateStr } = event.target.dataset; const clickedDate = new Date(dateStr!); // 将相关日期回调到父组件calendar上处理,比如更新value值 onClickDay?.(new Date(clickedDate), event); }, [onClickDay]); // 事件:hover在日单元格上时 // 虽然是监听onMouseMove事件,但不需要debounce, 否则会卡。 const handleHoverIn = useCallback((event: React.MouseEvent) => { // 如果没开启 选择日期范围 的功能,那么就不处理此事件了。 if (!selectRangeEnable) { return; } if (!(event.target instanceof HTMLButtonElement)) { return; } const { date: dateStr } = event.target.dataset; const hoveredDate = new Date(dateStr!); setHoverDate(hoveredDate) }, [selectRangeEnable]); const handleHoverOut = useCallback(() => { if (!selectRangeEnable) { return; } setHoverDate(null) }, [selectRangeEnable]); // 此日期是否被选择。(即是否等于value,或者在value数组内) // 效果见上图1和图3 const isActiveDate = useCallback((date: Date) => { // 如果value为数组形式,即此时value值的是一个范围。 if (value instanceof Array) { return isInDatesRange(date, value) || areDatesEqual(date, value && getDayStart(value[0]) ) || areDatesEqual(date, value && getDayStart(value[1]) ) } return areDatesEqual(date, value && new Date(value.getFullYear(), value.getMonth(), value.getDate()) ) }, [value]) // 此日期是否禁止点击。在minDate ~ maxDate才可以点击。 const isDisabledDay = useCallback((date: Date) => { let disabled = false; if (maxDate && !areDatesEqual(date, getDayStart(maxDate))) { disabled ||= date.getTime() > maxDate.getTime() } if (minDate && !areDatesEqual(date, getDayStart(minDate))) { disabled ||= date.getTime() < minDate.getTime() } return disabled; }, [minDate, maxDate]) // 此日期是否在 hover下日期 和 value日期之间 // 效果见上图2 const isHover = useCallback((date: Date) => { if (!hoverDate) { return false; } if (value instanceof Array || value === undefined) { return false; } return isInDatesRange(date, [hoverDate, value].sort((a, b) => a.getTime() - b.getTime())) }, [hoverDate, value]) function renderDays() { const dayTiles = []; for (let dayPoint = start; dayPoint <= end; dayPoint += 1) { // 计算出每个单元格对应的日期,并绑定到日单元格中 const date = new Date(startYear, startMonth, dayPoint); dayTiles.push( <Day key={dayPoint} date={date} dayPoint={dayPoint} locale={locale} disabled={isDisabledDay(date)} isActive={isActiveDate(date)} isHover={isHover(date)} /> ); } return dayTiles; } return ( <div className={className} onClick={handleClickDay} onMouseMove={handleHoverIn} onMouseLeave={handleHoverOut}> {renderDays()} </div> ); }
typescript
复制代码
// MonthView/Day.tsx const tileClassName = 'mini-calendar-tile'; // 用于提取day、month、decade等按钮的公共样式 export default function Day({ date, dayPoint, locale, isActive, isHover, disabled }: DayProps) { const year = date.getFullYear(); const month = date.getMonth() + 1; const dayOfMonth = date.getDate(); const dayOfWeek = date.getDay(); const dateStr = `${year}-${month}-${dayOfMonth}`; // 是否是周末 const isWeekEnd = (dayOfWeek % 6 === 0) || (dayOfWeek % 7 === 0); // 此日单元格是否是相邻省份的。 // 比如dayPoint如果小于0,显然是上一个月的。而date.getDate()必不会是负数,所以不相等。 const isNeighboringMonth = dayOfMonth !== dayPoint; return ( <button className={classnames(className, tileClassName, { [`${tileClassName}--active`]: isActive, [`${className}--neighboringMonth`]: isNeighboringMonth, [`${className}--weekend`]: isWeekEnd, [`${className}--hover`]: isHover, })} data-date={dateStr} disabled={disabled} > {formatDay(date, locale)} </button> ); }
选择单项、选择范围
组件prop selectRangeEnable为true时,即开启日期范围选择功能。否则只能选择单项
那么如何区分单个日期选择值、日期范围选择值?
- 代码中的value记录着选择值。
- 如果selectRangeEnable prop为false,那么每次点击日期value都是Date类型
- 如果selectRangeEnable prop为true,那么第一次点击日期是Date类型,第二次不同的日期是两个Date构成的数组,也就形成了日期范围。
相关代码:
// Calendar.tsx
type Value = Date | [Date, Date] | undefined;
const [valueState, setValueState] = useState<Value>(defaultValue);
const setValue = useCallback((newValue: Value) => {
if (valueProp === undefined) {
setValueState(newValue);
}
onChange?.(newValue); // 回调给用户的事件
}, [valueProp, onChange]);
// 处理<Days />组件中的onClickDay事件
const handleClickDay = useCallback((date: Date, event: React.MouseEvent) => {
onClickDay?.(date, event); // 回调给用户的事件
// selectRangeEnable prop为true
// 并且第一次已经点过了
if (selectRangeEnable && value instanceof Date) {
// 如果第二次点击是同一个日期,则跳过
if (value.getTime() === date.getTime()) {
return;
} else {
// 排序下作为日期范围的value
setValue([value, date].sort((a, b) => a.getTime() - b.getTime()) as [Date, Date]);
}
} else {
// selectRangeEnable 为 false,
// 或者selectRangeEnable为 true,但是是第一次点击日期
setValue(date);
}
}, [value, setValue, onClickDay, selectRangeEnable]);
其他细节
- 各个视图的单元格按钮样式类似,于是将公共样式提取了到一个类名中 -
mini-calendar-tile
.mini-calendar-tile {
&:enabled:hover {
background: #e6e6e6;
}
&:disabled {
color: rgba(16, 16, 16, 0.3);
}
&--active {
color: white;
background: #006edc;
}
&--active:enabled:hover,
&--active:enabled:focus {
color: white;
background: #1087ff;
}
}