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 ]; 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 const [mergedValue, setMergedValue] = useMergedState (() => value || generateConfig.getNow (), { defaultValue, value, }); 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 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)) { 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], );
渲染日历面板的月份或者日期以用户自定义渲染函数优先,否则加载原计划中的布局和样式。
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 ;
日历头部组件,分为三个子组件:年份选择,月份选择和模式转换,涉及到最重要的函数就是onChange
和onModeChange
函数,作用就是当年份或者月份和显示模式变动之后修改对应的组件状态,从而重新渲染组件。
渲染头部年份/月份/模式选择
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
是一个函数,前面执行逗号表达式返回这个函数,后面再传参调用此函数。