细说如何封装一个日历组件(多视图、可选择、国际化)

前言

最近好奇日历组件是怎么实现的。于是阅读了下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) 方式:点击单元格按钮,视图从高维变低维。

处理逻辑:

  1. 更新范围值activeStartDate
  2. 切换视图view

向上弹出(DrillUp) 方式:点击导航栏的中间按钮。 处理逻辑:

  1. 判断当前是否可以继续弹出
  2. 切换视图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]);

导航栏

功能

  1. 点击中间标签,则向上切换视图view。
  2. 点击两侧按钮,会改变当前视图范围 - 即改变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)
    • 是否在周末
    • 是否在相邻月份

以下是的四张图,可以辅助理解代码,表现分别为:

  1. 点击第一个日单元格
  2. 选择一个日单元格后,hover某个日单元格
  3. 点击第二个日单元格
  4. 当前月份的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]);

其他细节

  1. 各个视图的单元格按钮样式类似,于是将公共样式提取了到一个类名中 - 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;
  }
}

猜你喜欢

转载自blog.csdn.net/Jernnifer_mao/article/details/131233078