基本的布局完成了,我们来添加一些参数:
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 同时支持了受控和非受控模式。
日历组件是一个常用组件,而且是经常需要定制的那种,因为各种场景下对它有不同的要求,所以能够自己实现各种日历组件是一个必备技能。
