如何封装一个日历组件(多视图、可选择、国际化)
前言
最近好奇日历组件是怎么实现的。于是阅读了下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的逻辑。
部分代码预览
// 中间标签展示的内容
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。详见下面“格式化”
// 点击"‹"时,计算最新的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。
相关代码:
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之外




相关代码
注释中有详细解释原理。也可以直接看源码,代码里的变量命名尽量做到了名副其实。
// 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>
);
}
// 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>
);
}
其他细节
- 各个视图的单元格按钮样式类似,于是将公共样式提取了到一个类名中 -
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;
}
}
最后
源码
内容特点:
- 尽量使用函数式编程范式。
- 不依赖 moment.js。点击此处看为啥不用
- 主要参考了 3k star 的react-calendar开源组件,相当于它的简化版本-方便学习。
- 代码库里还顺便实现了时间选择器、日期选择器,有兴趣也可以看看。
转载自:https://juejin.cn/post/7244466015999098935