组件库开发日志-Calendar(1) | 青训营笔记

57 阅读5分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第16天

需求分析

  • 美观的日历表,默认模式为可以选择月份+日期
  • 可以切换日期
  • 可以快速跳转到当前月份
  • 可以配合输入框使用
  • 可以切换为选择年份+月份的模式

基本框架与流程

文件解释

generator

与数据生成的函数都放在这里。

首先要定义生成日历表的函数createWeeks,总共生成6*7个日期,这里使用了dayjslodash两个库辅助生成。之后日期相关的对象全部使用dayjs对象。

  • 第一周从周日(0)开始
  • 月份从0开始

此外还有生成星期名的函数createWeekNames

CalendarLayout

定义日历表的布局,布局图如下:

采用flex纵向布局,其中header再采用flex的横向布局。

react组件将以属性的方式传入CalendarLayout

在组件标签里加组件不需要加{ }!!! 例:<Calendar headerElement = test>

button:定义不同功能和样式的button

DatePicker

日期选择组件。

调用createWeekscreateWeekNames生成日期数据,并使用useMemo缓存数据,使之只被调用一次。每当点击切换月份的按钮ArrowButton时再重新渲染数据。

使用useContext获得选择日期的回调函数,当点击某个日期按钮时,可以改变组件中当前选择的日期。

DateView

月份+日期的日历表模式组件,也是默认的模式。

主要定义各种跳转行为,如切换到上/下个月份等等。

在跳转到上个月时,为了让取模操作不出现负数,可以使用((m%n)+n)%n取模

Calendar

完整的日历表组件。

管理两个状态,isDateView状态判断当前显示的是月份+日期的模式还是年份+月份的模式;calendar状态控制当前展示的年份和月份。

  • calendar状态不需要放在DateManager中进行管理,当然要放也不是不行

InputDatePicker

加上了输入框的日历组件,选择的日期可以被反应在输入框内。

使用showCalendar状态控制日历表的显示。当输入框被聚焦时、或日历表被聚焦时显示日历表,两者都失焦时关闭日历表。

Calendar中使用tabIndex使得日历表组件可以被聚焦。

FocusManager

管理聚焦和失焦事件。在React中可以作为父组件,接收blur和focus事件的冒泡。

因为渲染引擎先处理blur再处理focus事件,为防止输入框失焦后日历表直接消失,无法被聚焦,使用setTimeout推后blur事件的触发,若输入框的blur事件后紧接着触发了日历表的focus事件,则clearTimeout取消触发blur事件,再触发focus事件。

DateManager

管理控制日期值、输入框的值,以及选择日期(改变状态)的函数,并将之作为上下文传递给子组件。

创建的上下文不能放在组件里,要放在组件外export

开发亮点

  1. 使用((m%n)+n)%n进行取模操作
  2. 使用useMemo缓存数据
  3. 使用setTimeout推迟执行blur事件,使得日历表能被正确聚焦
  4. 抽象出一个DateManager组件,用于管理日期与设定日期的函数
  5. 抽象出CalendarLayout组件,将组件以属性的形式传入,使得布局可以被复用
  6. 实现了快速跳转到当前月份的功能

重要组件的实现

DatePicker

创建日期选择表

const DatePicker: React.FC<DatePickerProps> = ({ calendar, selectedDate }) => {
  const { year, monthIndex } = calendar;
  const { onSelectDate } = useContext(DateContext);
  const weeks = useMemo(
    () => createWeeks(year, monthIndex),
    [year, monthIndex],
  ); // 生成日期并缓存
  const weekNames = useMemo(() => createWeekNames(), []); // 生成星期名
  const click = (evt: any, day: Dayjs) => {
    if (onSelectDate) onSelectDate(evt, day);
  };
  return (
    <table>
      <thead>
        <tr className="calendar-header">
          {weekNames.map((name, i) => (
      <th key={i}>{name}</th>
    ))}
        </tr>
      </thead>
      <tbody>
        {weeks.map((week, i) => (
      <tr key={i}>
        {week.map((day, j) => {
        // 若为特殊日期,则加上特殊的样式
        const isToday =
          day.format('YYYY/MM/DD') === dayjs().format('YYYY/MM/DD');
        const notCrrentMonth = dayjs().month() !== day.month();
        const isSelected = day.diff(selectedDate) === 0;
        const classes = classnames({
          'is-today': isToday,
          'not-current-month': notCrrentMonth,
          'is-selected': isSelected,
        });
        return (
          <td key={j}>
            <CalendarButton
              className={classes}
              onClick={(evt) => click(evt, day)}
              >
              {day.date()}
            </CalendarButton>
          </td>
        );
      })}
      </tr>
    ))}
      </tbody>
    </table>
  );
};

DateView

生成日历表

const DateView: FC<DateViewProps> = ({ calendar, setCalendar }) => {
  const { year, monthIndex } = calendar;
  const context = useContext(DateContext) 
  const toPreMonth = () => { // 跳转到上个月
    const preMonthIndex = (monthIndex - 1 + 12) % 12;
    const preYear = year + Math.floor((monthIndex - 1) / 12);
    setCalendar({ year: preYear, monthIndex: preMonthIndex });
  };
  const toNextMonth = () => { // 跳转到下个月
    const nextMonthIndex = (monthIndex + 1) % 12;
    const nextYear = year + Math.floor((monthIndex + 1) / 12);
    setCalendar({ year: nextYear, monthIndex: nextMonthIndex });
  };
  const toToday = () => { // 跳转到本月
    setCalendar({year:dayjs().year(), monthIndex:dayjs().month()})
  }
  // 将组件作为属性传入布局组件
  return (
    <CalendarLayout
      headerElement={{
        leftElement: <ArrowButton icon="arrow-left" onClick={toPreMonth} />,
        middleElement: <p>{`${year}年${monthIndex + 1}月`}</p>,
        rightElement: <ArrowButton icon="arrow-right" onClick={toNextMonth} />,
      }}
      bodyElement=<DatePicker
                    calendar={calendar}
                    selectedDate={context.date}
                    />
      footerElement=<Button btnType="primary" onClick={toToday}>Today</Button>
      />
  );
};

FocusManager

// 管理聚焦和失焦事件
// 在React中可以作为父组件,接收blur和focus事件的冒泡
interface FocusManagerProps extends React.HTMLAttributes<HTMLDivElement> {
  childFocus: Function;
  childBlur: Function;
}
const FocusManager: FC<FocusManagerProps> = ({
  childFocus,
  childBlur,
  ...rest
}) => {
  let _timer: any = null;
  const onBlur = (e: any) => {
    // 使用setTimeout防止失焦后日历消失无法聚焦
    _timer = setTimeout(() => childBlur(e), 0);
  };
  const onFocus = (e: any) => {
    // 若子组件失焦后立即被聚焦,则清除执行失焦事件的函数
    clearTimeout(_timer);
    childFocus(e);
  };
  return (
    <div onFocus={onFocus} onBlur={onBlur} {...rest}>
    </div>
  );
};

开发中遇到的问题

InputDatePicker组件中使用DateManager时,发现Input组件并没有接收到上下文。

产生原因

DateManagerInput实际上是在同一层,不构成父子关系。

DateManager中加个div也不行。

// 错误的实现
const InputDatePicker = () => {
  const [showCalendar, setShowCalendar] = useState(false);
  const context = useContext(DateContext);
  return (
    <DateManager>
      <input
        type="text"
        value={context.textInput}
        onChange={() => console.log('change')}
        placeholder={'点击选择日期'}
        />
      {showCalendar && <Calendar />}
    </DateManager>
  );
};

解决方法

单独将Input抽离成一个CalendarInput组件,在CalendarInput中使用上下文

// 正确的实现
// InputDatePicker
const InputDatePicker = () => {
  const [showCalendar, setShowCalendar] = useState(false);
  return (
    <FocusManager
      childFocus={() => setShowCalendar(true)}
      childBlur={() => setShowCalendar(false)}
      >
      <DateManager>
        <CalendarInput />
        {showCalendar && <Calendar />}
      </DateManager>
    </FocusManager>
  );
};

// CalendarInput
const CalendarInput = () => {
  const context = useContext(DateContext);
  return (
      <input
        type="text"
        value={context.textInput}
        onChange={() => console.log('change')}
        placeholder={'点击选择日期'}
      />
  );
};

可以改进的点

还有几个需求没实现,如选择年份+月份的模式、允许键盘输入日期等

可以在主要组件里加一个css类,方便样式定位与调整