外观
第09章—组件实战:Calendar日历组件(下)
4913字约16分钟
2024-09-19
基本的布局完成了,我们来添加一些参数:
export interface CalendarProps {
value: Dayjs;
style?: CSSProperties;
className?: string | string[];
// 定制日期显示,会完全覆盖日期单元格
dateRender?: (currentDate: Dayjs) => ReactNode;
// 定制日期单元格,内容会被添加到单元格内,只在全屏日历模式下生效。
dateInnerContent?: (currentDate: Dayjs) => ReactNode;
// 国际化相关
locale?: string;
onChange?: (date: Dayjs) => void;
}
style 和 className 用于修改 Calendar 组件外层容器的样式。
内部的布局我们都是用的 flex,所以只要外层容器的样式变了,内部的布局会自动适应。
dateRender 是用来定制日期单元格显示的内容的。
比如加一些日程安排,加一些农历或者节日信息:
dateRender 是整个覆盖,连带日期的数字一起,而 dateInnerContent 只会在日期的数字下添加一些内容。
这两个 props 是不一样的。
locale 是用于国际化的,比如切换到中文显示或者是英文显示。
onChange 是当选择了日期之后会触发的回调。
然后实现下这些参数对应的逻辑。
首先是 className 和 style:
function Calendar(props: CalendarProps) {
const {
value,
style,
className,
} = props;
const classNames = cs("calendar", className);
return <div className={classNames} style={style}>
<Header></Header>
<MonthCalendar {...props}/>
</div>
}
这里用 classnames 这个包来做 className 的合并。
npm install classnames
它可以传入对象或者数组,会自动合并,返回最终的 className:
当 className 的确定需要一段复杂计算逻辑的时候,就用 classname 这个包。
测试下:
import dayjs from 'dayjs';
import Calendar from './Calendar';
function App() {
return (
<div className="App">
<Calendar value={dayjs('2023-11-08')} className={'aaa'} style={{background: 'yellow'}}></Calendar>
</div>
);
}
export default App;
className 和 style 的处理没问题。
然后我们处理下一个 props: dateRender 和 dateInnerContent。
在 MonthCalendar 里把它取出来,传入到 renderDays 方法里:
const {
dateRender,
dateInnerContent
} = props;
renderDays(allDays, dateRender, dateInnerContent)
dateRender 的处理也很简单,就是把渲染日期的逻辑换一下:
在 App.tsx 里传入 dateRender 参数:
import dayjs from 'dayjs';
import Calendar from './Calendar';
function App() {
return (
<div className="App">
<Calendar value={dayjs('2023-11-08')} dateRender={(value) => {
return <div>
<p style={{background: 'yellowgreen', height: '50px'}}>{value.format('YYYY/MM/DD')}</p>
</div>
}}></Calendar>
</div>
);
}
export default App;
这样,渲染的内容就换成自定义的了:
不过现在我们没有做内容溢出时的处理:
加个 overflow: hidden 就好了:
而且之前加 padding 的位置也不对。
改一下渲染日期的逻辑,如果传了 dateRender 那就整个覆盖日期单元格,否则就是只在下面渲染 dateInnerContent 的内容:
function renderDays(
days: Array<{ date: Dayjs, currentMonth: boolean}>,
dateRender: MonthCalendarProps['dateRender'],
dateInnerContent: MonthCalendarProps['dateInnerContent']
) {
const rows = [];
for(let i = 0; i < 6; i++ ) {
const row = [];
for(let j = 0; j < 7; j++) {
const item = days[i * 7 + j];
row[j] = <div className={
"calendar-month-body-cell " + (item.currentMonth ? 'calendar-month-body-cell-current' : '')
}>
{
dateRender ? dateRender(item.date) : (
<div className="calendar-month-body-cell-date">
<div className="calendar-month-body-cell-date-value">{item.date.date()}</div>
<div className="calendar-month-body-cell-date-content">{dateInnerContent?.(item.date)}</div>
</div>
)
}
</div>
}
rows.push(row);
}
return rows.map(row => <div className="calendar-month-body-row">{row}</div>)
}
改下对应的样式:
把加 padding 的位置改为内部的元素。
测试下:
import dayjs from 'dayjs';
import Calendar from './Calendar';
function App() {
return (
<div className="App">
<Calendar value={dayjs('2023-11-08')} dateInnerContent={(value) => {
return <div>
<p style={{background: 'yellowgreen', height: '30px'}}>{value.format('YYYY/MM/DD')}</p>
</div>
}}></Calendar>
</div>
);
}
export default App;
这样,dateRender 和 dateInnerContent 的逻辑就完成了。
接下来做国际化,也就是 locale 参数的处理。
国际化就是可以让日历支持中文、英文、日文等,其实也很简单,就是把写死的文案换成按照 key 从配置中取的文案就行了。
定义下用到的 ts 类型 src/Calendar/locale/interface.ts
export interface CalendarType {
formatYear: string;
formatMonth: string;
today: string;
month: {
January: string;
February: string;
March: string;
April: string;
May: string;
June: string;
July: string;
August: string;
September: string;
October: string;
November: string;
December: string;
} & Record<string, any>;
week: {
monday: string;
tuesday: string;
wednesday: string;
thursday: string;
friday: string;
saturday: string;
sunday: string;
} & Record<string, any>
}
然后分别定义中文和英文的配置:
src/Calendar/locale/zh-CN.ts
import { CalendarType } from "./interface";
const CalendarLocale: CalendarType = {
formatYear: 'YYYY 年',
formatMonth: 'YYYY 年 MM 月',
today: '今天',
month: {
January: '一月',
February: '二月',
March: '三月',
April: '四月',
May: '五月',
June: '六月',
July: '七月',
August: '八月',
September: '九月',
October: '十月',
November: '十一月',
December: '十二月',
},
week: {
monday: '周一',
tuesday: '周二',
wednesday: '周三',
thursday: '周四',
friday: '周五',
saturday: '周六',
sunday: '周日',
}
}
export default CalendarLocale;
src/Calendar/locale/zh-CN.ts
把会用到的文案列出来。
然后再写个英文版:
src/Calendar/locale/en-US.ts
import { CalendarType } from "./interface";
const CalendarLocale: CalendarType = {
formatYear: 'YYYY',
formatMonth: 'MMM YYYY',
today: 'Today',
month: {
January: 'January',
February: 'February',
March: 'March',
April: 'April',
May: 'May',
June: 'June',
July: 'July',
August: 'August',
September: 'September',
October: 'October',
November: 'November',
December: 'December',
},
week: {
monday: 'Monday',
tuesday: 'Tuesday',
wednesday: 'Wednesday',
thursday: 'Thursday',
friday: 'Friday',
saturday: 'Saturday',
sunday: 'Sunday',
},
}
export default CalendarLocale;
我们先把上面的周一到周日的文案替换了:
在 MonthCalendar 引入中文的资源包:
然后把之前写死的文案,改成按照 key 从资源包中取值的方式:
function MonthCalendar(props: MonthCalendarProps) {
const {
dateRender,
dateInnerContent
} = props;
const weekList = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
const allDays = getAllDays(props.value);
return <div className="calendar-month">
<div className="calendar-month-week-list">
{weekList.map((week) => (
<div className="calendar-month-week-list-item" key={week}>
{CalendarLocale.week[week]}
</div>
))}
</div>
<div className="calendar-month-body">
{
renderDays(allDays, dateRender, dateInnerContent)
}
</div>
</div>
}
现在渲染出来的是这样的:
只要改一下用的资源包:
文案就变了:
这就是国际化。
当然,现在我们是手动切换的资源包,其实应该是全局统一配置的。
这个可以通过 context 来做:
新建 src/Calendar/LocaleContext.tsx
import { createContext } from "react";
export interface LocaleContextType {
locale: string;
}
const LocaleContext = createContext<LocaleContextType>({
locale: 'zh-CN'
});
export default LocaleContext;
然后在 Calendar 组件里用 provider 修改 context 的值:
如果传入了参数,就用指定的 locale,否则,就从浏览器取当前语言:
加一个国际化资源包的入口:
src/Calendar/locale/index.ts
import zhCN from "./zh-CN";
import enUS from "./en-US";
import { CalendarType } from "./interface";
const allLocales: Record<string, CalendarType>= {
'zh-CN': zhCN,
'en-US': enUS
}
export default allLocales;
把 MonthCalendar 组件的 locale 改成从 context 获取的:
const localeContext = useContext(LocaleContext);
const CalendarLocale = allLocales[localeContext.locale];
这样,当不指定 locale 时,就会按照浏览器的语言来设置:
当指定 locale 时,就会切换为指定语言的资源包:
接下来,我们实现 value 和 onChange 参数的逻辑。
在 MonthCalendar 里取出 value 参数,传入 renderDays 方法:
用 classnames 的 api 来拼接 className,如果是当前日期,就加一个 xxx-selected 的 className:
function renderDays(
days: Array<{ date: Dayjs, currentMonth: boolean}>,
dateRender: MonthCalendarProps['dateRender'],
dateInnerContent: MonthCalendarProps['dateInnerContent'],
value: Dayjs
) {
const rows = [];
for(let i = 0; i < 6; i++ ) {
const row = [];
for(let j = 0; j < 7; j++) {
const item = days[i * 7 + j];
row[j] = <div className={
"calendar-month-body-cell " + (item.currentMonth ? 'calendar-month-body-cell-current' : '')
}
>
{
dateRender ? dateRender(item.date) : (
<div className="calendar-month-body-cell-date">
<div className={
cs("calendar-month-body-cell-date-value",
value.format('YYYY-MM-DD') === item.date.format('YYYY-MM-DD')
? "calendar-month-body-cell-date-selected"
: ""
)
}>{item.date.date()}</div>
<div className="calendar-month-cell-body-date-content">{dateInnerContent?.(item.date)}</div>
</div>
)
}
</div>
}
rows.push(row);
}
return rows.map(row => <div className="calendar-month-body-row">{row}</div>)
}
添加对应的样式:
&-selected {
background: blue;
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
color: #fff;
border-radius: 50%;
cursor: pointer;
}
现在渲染出来是这样的:
然后我们加上点击的处理:
interface MonthCalendarProps extends CalendarProps {
selectHandler?: (date: Dayjs) => void
}
添加一个 selectHandler 的参数,传给 renderDays 方法。
renderDays 方法里取出来,给日期添加上点击事件:
function renderDays(
days: Array<{ date: Dayjs, currentMonth: boolean}>,
dateRender: MonthCalendarProps['dateRender'],
dateInnerContent: MonthCalendarProps['dateInnerContent'],
value: Dayjs,
selectHandler: MonthCalendarProps['selectHandler']
) {
const rows = [];
for(let i = 0; i < 6; i++ ) {
const row = [];
for(let j = 0; j < 7; j++) {
const item = days[i * 7 + j];
row[j] = <div className={
"calendar-month-body-cell " + (item.currentMonth ? 'calendar-month-body-cell-current' : '')
}
onClick={() => selectHandler?.(item.date)}
>
{
dateRender ? dateRender(item.date) : (
<div className="calendar-month-body-cell-date">
<div className={
cs("calendar-month-body-cell-date-value",
value.format('YYYY-MM-DD') === item.date.format('YYYY-MM-DD')
? "calendar-month-body-cell-date-selected"
: ""
)
}>{item.date.date()}</div>
<div className="calendar-month-cell-body-date-content">{dateInnerContent?.(item.date)}</div>
</div>
)
}
</div>
}
rows.push(row);
}
return rows.map(row => <div className="calendar-month-body-row">{row}</div>)
}
然后这个参数是在 Calendar 组件传进来的:
我们添加一个 state 来存储当前日期,selectHandler 里调用 onChange 的参数,并且修改当前日期。
function Calendar(props: CalendarProps) {
const {
value,
style,
className,
dateRender,
dateInnerContent,
locale,
onChange
} = props;
const [curValue, setCurValue] = useState<Dayjs>(value);
const classNames = cs("calendar", className);
function selectHandler(date: Dayjs) {
setCurValue(date);
onChange?.(date);
}
return <LocaleContext.Provider value={{
locale: locale || navigator.language
}}>
<div className={classNames} style={style}>
<Header></Header>
<MonthCalendar {...props} value={curValue} selectHandler={selectHandler}/>
</div>
</LocaleContext.Provider>
}
试一下,改下 App.tsx:
import dayjs from 'dayjs';
import Calendar from './Calendar';
function App() {
return (
<div className="App">
<Calendar value={dayjs('2023-11-08')} onChange={(date) => {
alert(date.format('YYYY-MM-DD'));
}}></Calendar>
</div>
);
}
export default App;
然后实现下 Header 组件里的日期切换:
根据传入的 value 来展示日期,点击上下按钮的时候会调用传进来的回调函数:
import { Dayjs } from "dayjs";
interface HeaderProps {
curMonth: Dayjs;
prevMonthHandler: () => void;
nextMonthHandler: () => void;
}
function Header(props: HeaderProps) {
const {
curMonth,
prevMonthHandler,
nextMonthHandler
} = props;
return <div className="calendar-header">
<div className="calendar-header-left">
<div className="calendar-header-icon" onClick={prevMonthHandler}><</div>
<div className="calendar-header-value">{curMonth.format('YYYY 年 MM 月')}</div>
<div className="calendar-header-icon" onClick={nextMonthHandler}>></div>
<button className="calendar-header-btn">今天</button>
</div>
</div>
}
export default Header;
然后在 Calendar 组件创建 curMonth 的 state,点击上下按钮的时候,修改月份:
function Calendar(props: CalendarProps) {
const {
value,
style,
className,
dateRender,
dateInnerContent,
locale,
onChange
} = props;
const [curValue, setCurValue] = useState<Dayjs>(value);
const [curMonth, setCurMonth] = useState<Dayjs>(value);
const classNames = cs("calendar", className);
function selectHandler(date: Dayjs) {
setCurValue(date);
onChange?.(date);
}
function prevMonthHandler() {
setCurMonth(curMonth.subtract(1, 'month'));
}
function nextMonthHandler() {
setCurMonth(curMonth.add(1, 'month'));
}
return <LocaleContext.Provider value={{
locale: locale || navigator.language
}}>
<div className={classNames} style={style}>
<Header curMonth={curMonth} prevMonthHandler={prevMonthHandler} nextMonthHandler={nextMonthHandler}></Header>
<MonthCalendar {...props} value={curValue} selectHandler={selectHandler}/>
</div>
</LocaleContext.Provider>
}
测试下:
但现在月份是变了,但下面的日历没有跟着变。
因为我们之前是拿到 value 所在月份来计算的日历,现在要改成 curMonth 所在的月份。
这样,月份切换时,就会显示那个月的日历了:
然后我们加上今天按钮的处理:
import { Dayjs } from "dayjs";
interface HeaderProps {
curMonth: Dayjs;
prevMonthHandler: () => void;
nextMonthHandler: () => void;
todayHandler: () => void;
}
function Header(props: HeaderProps) {
const {
curMonth,
prevMonthHandler,
nextMonthHandler,
todayHandler
} = props;
return <div className="calendar-header">
<div className="calendar-header-left">
<div className="calendar-header-icon" onClick={prevMonthHandler}><</div>
<div className="calendar-header-value">{curMonth.format('YYYY 年 MM 月')}</div>
<div className="calendar-header-icon" onClick={nextMonthHandler}>></div>
<button className="calendar-header-btn" onClick={todayHandler}>今天</button>
</div>
</div>
}
export default Header;
在 Calendar 里传入 todayHandler:
function todayHandler() {
const date = dayjs(Date.now());
setCurValue(date);
setCurMonth(date);
onChange?.(date);
}
同时修改日期和当前月份,并且还要调用 onChange 回调。
测试下:
此外,我们希望点击上下月份的日期的时候,能够跳转到那个月的日历:
这个也简单,切换日期的时候顺便修改下 curMonth 就好了:
测试下:
最后,还要加上 Header 的国际化:
就是把写死的文案,改成丛资源包取值的方式就好了。
function Header(props: HeaderProps) {
const {
curMonth,
prevMonthHandler,
nextMonthHandler,
todayHandler
} = props;
const localeContext = useContext(LocaleContext);
const CalendarContext = allLocales[localeContext.locale];
return <div className="calendar-header">
<div className="calendar-header-left">
<div className="calendar-header-icon" onClick={prevMonthHandler}><</div>
<div className="calendar-header-value">{curMonth.format(CalendarContext.formatMonth)}</div>
<div className="calendar-header-icon" onClick={nextMonthHandler}>></div>
<button className="calendar-header-btn" onClick={todayHandler}>{CalendarContext.today}</button>
</div>
</div>
}
试试看:
没啥问题。
这样,我们的 Calendar 组件就完成了。
最后我们再来优化下代码:
重复逻辑可以抽离出个方法:
function changeDate(date: Dayjs) {
setCurValue(date);
setCurMonth(date);
onChange?.(date);
}
渲染逻辑抽离出来的函数,放在组件外需要传很多参数,而这个函数只有这里用,可以移到组件内:
这样就不用传那些参数了:
此外,我们的 Calendar 的 value 其实是 defaultValue:
和迷你 Calendar 一样,我们也用 ahooks 的 useControllableValue 来做。
安装 ahooks:
npm install --save ahooks
把 useState 换成 ahooks 的 useControllableValue:
export interface CalendarProps {
value?: Dayjs;
defaultValue?: Dayjs;
style?: CSSProperties;
className?: string | string[];
// 定制日期显示,会完全覆盖日期单元格
dateRender?: (currentDate: Dayjs) => ReactNode;
// 定制日期单元格,内容会被添加到单元格内,只在全屏日历模式下生效。
dateInnerContent?: (currentDate: Dayjs) => ReactNode;
// 国际化相关
locale?: string;
onChange?: (date: Dayjs) => void;
}
const [curValue, setCurValue] = useControllableValue<Dayjs>(props, {
defaultValue: dayjs()
});
const [curMonth, setCurMonth] = useState<Dayjs>(curValue);
用到 value 的地方加一下 ?:
这样就同时支持受控非受控,也就是 value 和 defaultValue 了。
试一下 defaultValue 非受控模式:
import dayjs from 'dayjs';
import Calendar from './Calendar';
function App() {
return (
<div className="App">
<Calendar defaultValue={dayjs('2023-11-08')}></Calendar>
</div>
);
}
export default App;
value 受控模式:
import dayjs from 'dayjs';
import Calendar from './Calendar';
import { useState } from 'react';
function App() {
const [value, setValue] = useState(dayjs('2023-11-08'));
return (
<div className="App">
<Calendar value={value} onChange={(val) => {
setValue(val)
}}></Calendar>
</div>
);
}
export default App;
案例代码上传了小册仓库。
总结
上节我们实现了布局,这节加上了参数并且实现了这些参数对应的逻辑。
className 和 style 用于修改外层容器的样式,内部用的 flex 布局,只要容器大小变了,内容会自动适应。
dateRender 和 dateInnerConent 是用于修改日期单元格的内容的,比如显示节日、日程安排等。
locale 是切换语言,国际化就是把写死的文案换成从资源包取值的方式,我们创建了 zh-CN 和 en-US 两个资源包,并且可以通过 locale 参数来切换。
通过 createContext 创建 context 对象来保存 locale 配置,然后通过 Provider 修改其中的值,这样子组件里就通过 useContext 把它取出来就知道当前语言了。
最后我们用 ahooks 的 useControllableValue 同时支持了受控和非受控模式。
日历组件是一个常用组件,而且是经常需要定制的那种,因为各种场景下对它有不同的要求,所以能够自己实现各种日历组件是一个必备技能。