Calendar日历,有传统可添加日程的方格形式,也有卡片的mini模式。

要解析这个组件,首先来看看它引入了哪些工具:
① 引入了moment的typescript接口。
② 基于rc-picker组件,还引入了包括其组件在内的一些ts类型和工具函数。
③ 引入了rc-util工具包中的useMergedState hook函数用于状态处理。
④ 引入lodash的padStart函数用于字符串处理。
⑤ 使用了Select组件和Radio组件内置的Group、Button。

index入口页

1
2
3
4
5
6
7
8
import { Moment } from 'moment';
import momentGenerateConfig from 'rc-picker/lib/generate/moment';
import generateCalendar, { CalendarProps } from './generateCalendar';

const Calendar = generateCalendar<Moment>(momentGenerateConfig);

export { CalendarProps };
export default Calendar;

入口文件rc-picker组件moment工具函数的generateConfig模块,该模块内部其实就是调用node的moment模块进行一些常规操作的封装,比如:获取/设置当前时间,获取/设置年月日等。调用组件主函数generateCalendar并导出执行结果和Propr类型。

API参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export interface CalendarProps<DateType> {
prefixCls?: string;
className?: string;
style?: React.CSSProperties;
/** 国际化配置 */
locale?: typeof enUS;
validRange?: [DateType, DateType];
/** 不可选择的日期,参数为当前 value,注意使用时不要直接修改 */
disabledDate?: (date: DateType) => boolean;
/** 自定义渲染日期单元格,返回内容覆盖单元格 */
dateFullCellRender?: (date: DateType) => React.ReactNode;
/** 自定义渲染日期单元格,返回内容会被追加到单元格 */
dateCellRender?: (date: DateType) => React.ReactNode;
/** 自定义渲染月单元格,返回内容覆盖单元格 */
monthFullCellRender?: (date: DateType) => React.ReactNode;
/** 自定义渲染月单元格,返回内容会被追加到单元格 */
monthCellRender?: (date: DateType) => React.ReactNode;
/** 自定义头部内容 */
headerRender?: HeaderRender<DateType>;
/** 展示日期 */
value?: DateType;
/** 默认展示的日期 */
defaultValue?: DateType;
/** 初始模式 */
mode?: CalendarMode;
/** 是否全屏显示 */
fullscreen?: boolean;
/** 日期变化回调 */
onChange?: (date: DateType) => void;
/** 日期面板变化回调 */
onPanelChange?: (date: DateType, mode: CalendarMode) => void;
/** 点击选择日期回调 */
onSelect?: (date: DateType) => void;
}

三个工具函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function isSameYear(date1: DateType, date2: DateType) {
return date1 && date2 && generateConfig.getYear(date1) === generateConfig.getYear(date2);
}

function isSameMonth(date1: DateType, date2: DateType) {
return (
isSameYear(date1, date2) && generateConfig.getMonth(date1) === generateConfig.getMonth(date2)
);
}

function isSameDate(date1: DateType, date2: DateType) {
return (
isSameMonth(date1, date2) && generateConfig.getDate(date1) === generateConfig.getDate(date2)
);
}

这三个函数用于判断当前选择的时间日期和之前的日期是否是处于同一天/同一月/同一年。

render函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
return (
<LocaleReceiver componentName="Calendar" defaultLocale={getDefaultLocale}>
{(mergedLocale: any) => (
<div
className={classNames(
calendarPrefixCls,
{
[`${calendarPrefixCls}-full`]: fullscreen,
[`${calendarPrefixCls}-mini`]: !fullscreen,
[`${calendarPrefixCls}-rtl`]: direction === 'rtl',
},
className,
)}
style={style}
>
{headerRender ? (
headerRender({
value: mergedValue,
type: mergedMode,
onChange: onInternalSelect,
onTypeChange: triggerModeChange,
})
) : (
<CalendarHeader
prefixCls={calendarPrefixCls}
value={mergedValue}
generateConfig={generateConfig}
mode={mergedMode}
fullscreen={fullscreen}
locale={mergedLocale.lang}
validRange={validRange}
onChange={onInternalSelect}
onModeChange={triggerModeChange}
/>
)}

<RCPickerPanel
value={mergedValue}
prefixCls={prefixCls}
locale={mergedLocale.lang}
generateConfig={generateConfig}
dateRender={dateRender}
monthCellRender={date => monthRender(date, mergedLocale.lang)}
onSelect={onInternalSelect}
mode={panelMode}
picker={panelMode as any}
disabledDate={mergedDisabledDate}
hideHeader
/>
</div>
)}
</LocaleReceiver>
);

① 使用国际化配置组件LocaleReceiver,内部通过组件名componentName获取国际化配置数据,并传参到this.props.children()函数中。
② 优先加载用户配置的自定义头部渲染headerRender
③ 若没有配置该项则渲染CalendarHeader组件,然后渲染rc-picker

三个State状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Current Value
// 引入rc-util中的工具hook函数 状态值处理
const [mergedValue, setMergedValue] = useMergedState(() => value || generateConfig.getNow(), {
defaultValue,
value,
});

// Mode
const [mergedMode, setMergedMode] = useMergedState('month', {
value: mode,
});
const panelMode = React.useMemo<'month' | 'date'>(
() => (mergedMode === 'year' ? 'month' : 'date'),
[mergedMode],
);

三个状态中,mergedValue存储的是日历的当前值,mergedMode储存的是日历头部的模式,panelMode则是日历面板的模式。

禁用处理

1
2
3
4
5
6
7
8
9
10
11
12
// Disabled Date
// 判断不可用时间是否在有效时间范围内 在做禁用处理
const mergedDisabledDate = React.useCallback(
(date: DateType) => {
const notInRange = validRange
? generateConfig.isAfter(validRange[0], date) ||
generateConfig.isAfter(date, validRange[1])
: false;
return notInRange || !!disabledDate?.(date);
},
[disabledDate, validRange],
);

对于有配置时间显示范围和禁用函数的情况下执行这个函数。

一些事件处理函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const triggerPanelChange = (date: DateType, newMode: CalendarMode) => {
onPanelChange?.(date, newMode);
};

const triggerChange = (date: DateType) => {
setMergedValue(date);

if (!isSameDate(date, mergedValue)) {
// Trigger when month panel switch month
if (
(panelMode === 'date' && !isSameMonth(date, mergedValue)) ||
(panelMode === 'month' && !isSameYear(date, mergedValue))
) {
triggerPanelChange(date, mergedMode);
}

onChange?.(date);
}
};
// 头部显示模式改变
const triggerModeChange = (newMode: CalendarMode) => {
setMergedMode(newMode);
triggerPanelChange(mergedValue, newMode);
};
// 时间被选择
const onInternalSelect = (date: DateType) => {
triggerChange(date);

onSelect?.(date);
};

时间处理包括日历头部显示模式改变和日期改变,从而导致组件状态的变更触发组件重新渲染。

渲染日期/月份

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 渲染日期 渲染月份
const dateRender = React.useCallback(
(date: DateType): React.ReactNode => {
if (dateFullCellRender) {
return dateFullCellRender(date);
}

return (
<div
className={classNames(`${prefixCls}-cell-inner`, `${calendarPrefixCls}-date`, {
[`${calendarPrefixCls}-date-today`]: isSameDate(today, date),
})}
>
<div className={`${calendarPrefixCls}-date-value`}>
{padStart(String(generateConfig.getDate(date)), 2, '0')}
</div>
<div className={`${calendarPrefixCls}-date-content`}>
{dateCellRender && dateCellRender(date)}
</div>
</div>
);
},
[dateFullCellRender, dateCellRender],
);

const monthRender = React.useCallback(
(date: DateType, locale: Locale): React.ReactNode => {
if (monthFullCellRender) {
return monthFullCellRender(date);
}

const months = locale.shortMonths || generateConfig.locale.getShortMonths!(locale.locale);

return (
<div
className={classNames(`${prefixCls}-cell-inner`, `${calendarPrefixCls}-date`, {
[`${calendarPrefixCls}-date-today`]: isSameMonth(today, date),
})}
>
<div className={`${calendarPrefixCls}-date-value`}>
{months[generateConfig.getMonth(date)]}
</div>
<div className={`${calendarPrefixCls}-date-content`}>
{monthCellRender && monthCellRender(date)}
</div>
</div>
);
},
[monthFullCellRender, monthCellRender],
);

渲染日历面板的月份或者日期以用户自定义渲染函数优先,否则加载原计划中的布局和样式。

CalendarHeader组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export interface CalendarHeaderProps<DateType> {
prefixCls: string;
value: DateType;
validRange?: [DateType, DateType];
generateConfig: GenerateConfig<DateType>;
locale: Locale;
mode: CalendarMode;
fullscreen: boolean;
onChange: (date: DateType) => void;
onModeChange: (mode: CalendarMode) => void;
}
function CalendarHeader<DateType>(props: CalendarHeaderProps<DateType>) {
const { prefixCls, fullscreen, mode, onChange, onModeChange } = props;
const divRef = React.useRef<HTMLDivElement>(null);

const sharedProps = {
...props,
onChange,
fullscreen,
divRef,
};

return (
<div className={`${prefixCls}-header`} ref={divRef}>
<YearSelect {...sharedProps} />
{mode === 'month' && <MonthSelect {...sharedProps} />}
<ModeSwitch {...sharedProps} onModeChange={onModeChange} />
</div>
);
}

export default CalendarHeader;

日历头部组件,分为三个子组件:年份选择,月份选择和模式转换,涉及到最重要的函数就是onChangeonModeChange函数,作用就是当年份或者月份和显示模式变动之后修改对应的组件状态,从而重新渲染组件。

渲染头部年份/月份/模式选择

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// 渲染头部 年份选择
function YearSelect<DateType>(props: SharedProps<DateType>) {
const {
fullscreen,
validRange,
generateConfig,
locale,
prefixCls,
value,
onChange,
divRef,
} = props;
// 上下十年范围可供选择
const year = generateConfig.getYear(value || generateConfig.getNow());

let start = year - YearSelectOffset;
let end = start + YearSelectTotal;

if (validRange) {
start = generateConfig.getYear(validRange[0]);
end = generateConfig.getYear(validRange[1]) + 1;
}

const suffix = locale && locale.year === '年' ? '年' : '';
const options: { label: string; value: number }[] = [];
for (let index = start; index < end; index++) {
options.push({ label: `${index}${suffix}`, value: index });
}

return (
<Select
size={fullscreen ? undefined : 'small'}
options={options}
value={year}
className={`${prefixCls}-year-select`}
onChange={numYear => {
let newDate = generateConfig.setYear(value, numYear);

if (validRange) {
const [startDate, endDate] = validRange;
const newYear = generateConfig.getYear(newDate);
const newMonth = generateConfig.getMonth(newDate);
if (
newYear === generateConfig.getYear(endDate) &&
newMonth > generateConfig.getMonth(endDate)
) {
newDate = generateConfig.setMonth(newDate, generateConfig.getMonth(endDate));
}
if (
newYear === generateConfig.getYear(startDate) &&
newMonth < generateConfig.getMonth(startDate)
) {
newDate = generateConfig.setMonth(newDate, generateConfig.getMonth(startDate));
}
}

onChange(newDate);
}}
getPopupContainer={() => divRef!.current!}
/>
);
}

以当前年份为中心,提供上下十个年份供选择,循环生成对应的Option选项,再把Select渲染到CalendarHeader组件的div中。

同理,月份Select渲染也是类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 渲染头部 月份选择
function MonthSelect<DateType>(props: SharedProps<DateType>) {
const {
prefixCls,
fullscreen,
validRange,
value,
generateConfig,
locale,
onChange,
divRef,
} = props;
const month = generateConfig.getMonth(value || generateConfig.getNow());

let start = 0;
let end = 11;

if (validRange) {
const [rangeStart, rangeEnd] = validRange;
const currentYear = generateConfig.getYear(value);
if (generateConfig.getYear(rangeEnd) === currentYear) {
end = generateConfig.getMonth(rangeEnd);
}
if (generateConfig.getYear(rangeStart) === currentYear) {
start = generateConfig.getMonth(rangeStart);
}
}

const months = locale.shortMonths || generateConfig.locale.getShortMonths!(locale.locale);
const options: { label: string; value: number }[] = [];
for (let index = start; index <= end; index += 1) {
options.push({
label: months[index],
value: index,
});
}

return (
<Select
size={fullscreen ? undefined : 'small'}
className={`${prefixCls}-month-select`}
value={month}
options={options}
onChange={newMonth => {
onChange(generateConfig.setMonth(value, newMonth));
}}
getPopupContainer={() => divRef!.current!}
/>
);
}

模式切换采用了按钮组的形式,调用了模式切换函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 日历模式切换
function ModeSwitch<DateType>(props: ModeSwitchProps<DateType>) {
const { prefixCls, locale, mode, fullscreen, onModeChange } = props;
return (
<Group
onChange={({ target: { value } }) => {
onModeChange(value);
}}
value={mode}
size={fullscreen ? undefined : 'small'}
className={`${prefixCls}-mode-switch`}
>
<Button value="month">{locale.month}</Button>
<Button value="year">{locale.year}</Button>
</Group>
);
}

总结
① 它山之石可以攻玉,要善于利用工具或者已有的组件进行封装,比如文中提到的lodash rc-util rc-picker等。
② 导出组件的同时,需要把对应的componentNameProps也导出,便于其它使用者采用这个组件的类型和代码提示。
_interopRequireDefault是在rc-util/lib/hooks/useMergedState里面看到的,这个方法可以在es6环境下使用common.js的模块,其内部实现也就是给需要引入的cjs模块添加一个default属性,可以支持es6的使用。

1
2
3
getFixedDate: function getFixedDate(string) {
return (0, _moment.default)(string, 'YYYY-MM-DD');
},

④ 这种写法是在rc-picker/lib/generate/moment函数中看到,_moment.default是一个函数,前面执行逗号表达式返回这个函数,后面再传参调用此函数。