这是我参与「第五届青训营 」伴学笔记创作活动的第16天
需求分析
- 美观的日历表,默认模式为可以选择月份+日期
- 可以切换日期
- 可以快速跳转到当前月份
- 可以配合输入框使用
- 可以切换为选择年份+月份的模式
基本框架与流程
文件解释
generator
与数据生成的函数都放在这里。
首先要定义生成日历表的函数createWeeks
,总共生成6*7个日期,这里使用了dayjs
和lodash
两个库辅助生成。之后日期相关的对象全部使用dayjs
对象。
- 第一周从周日(0)开始
- 月份从0开始
此外还有生成星期名的函数createWeekNames
。
CalendarLayout
定义日历表的布局,布局图如下:
采用flex
纵向布局,其中header
再采用flex
的横向布局。
react组件将以属性的方式传入CalendarLayout
在组件标签里加组件不需要加{ }
!!! 例:<Calendar headerElement = test>
button:定义不同功能和样式的button
DatePicker
日期选择组件。
调用createWeeks
与createWeekNames
生成日期数据,并使用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
开发亮点
- 使用
((m%n)+n)%n
进行取模操作 - 使用
useMemo
缓存数据 - 使用
setTimeout
推迟执行blur事件,使得日历表能被正确聚焦 - 抽象出一个
DateManager
组件,用于管理日期与设定日期的函数 - 抽象出
CalendarLayout
组件,将组件以属性的形式传入,使得布局可以被复用 - 实现了快速跳转到当前月份的功能
重要组件的实现
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
组件并没有接收到上下文。
产生原因
DateManager
与Input
实际上是在同一层,不构成父子关系。
在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类,方便样式定位与调整