最近好奇日历组件是怎么实现的。于是阅读了下react-calendar的源码,并实现了简化版的日历组件。本文把实现日历的设计思路分享给大家。只要理清了主要逻辑,就不难实现了。
技术栈:react、typescript
在线预览demo:coder-xuwentao.github.io/react-mini-…
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; // 日历组件的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(new Date());
return (
);
}
首先日历分为上下两个部分导航栏和视图。
每个视图都有遍历可点击的单元格按钮,比如下方的“X日”。(下文就都叫单元格按钮 或 单元格)
日历有三个维度的视图,从大到小为:十年(Decade)、年(year)、月(Month)。
视图都需要一个范围,比如上图年视图,从 2023-1 到 2023-12;月视图,从2023-6-1到2023-6-30。
我们只需要用一个状态来记录范围的起始日期即可(activeStartDate)。各个视图会根据activeStartDate,在遍历渲染单元格按钮时,把日期值关联到按钮,方便展示和获取日期。
两种方式:
向下深入(DrillDown) 方式:点击单元格按钮,视图从高维变低维。
处理逻辑:
向上弹出(DrillUp) 方式:点击导航栏的中间按钮。 处理逻辑:
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]);
导航栏的中间标签的展示、以及两侧四个按钮的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遍历渲染单元格。
判断单元格日期:
以下是的四张图,可以辅助理解代码,表现分别为:
注释中有详细解释原理。也可以直接看源码,代码里的变量命名尽量做到了名副其实。
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
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 ( ); }
组件prop selectRangeEnable为true时,即开启日期范围选择功能。否则只能选择单项
那么如何区分单个日期选择值、日期范围选择值?
相关代码:
// Calendar.tsx
type Value = Date | [Date, Date] | undefined;
const [valueState, setValueState] = useState(defaultValue);
const setValue = useCallback((newValue: Value) => {
if (valueProp === undefined) {
setValueState(newValue);
}
onChange?.(newValue); // 回调给用户的事件
}, [valueProp, onChange]);
// 处理 组件中的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;
}
}