很多人写过日历组件,最近在工作中碰到了类似的需求,不过我当时是用vue写的,今天用React再实现一个简易的版本,先看效果(忽略简陋的样式):
由图中可以看到一个简易的日历组件主要分为两个部分:文本框 和日历组件主体;
文本框主要干了两件事:
- 唤起显示日历组件;
- 回显日历组件选中的日期;
日历组件主体主要干了五件事:
- 显示年月;
- 切换月份;
- 切换年份;
- 显示星期;
- 渲染日历主体;
有了一个大概的轮廓以后,就可以开干了。我的思路是:不用关心具体细节的实现,先做最简单的事,创建项目和画页面
js
npx create-react-app --template=typescript calendar-test
日历结构与布局
创建好项目以后,新建components/Calendar.tsx组件,并在App.tsx中引入。在Calendar.tsx中绘制日历页面结构:
js
import "./Calendar.scss";
const Calendar: React.FC = () => {
return (
<div className="calendar">
<input type="text" className="calendar__text" />
<div className="calendar__content">
<div className="calendar__content__toolbar">
<div className="calendar__content__toolbar__iconBox">
<div className="calendar__content__toolbar__iconBox__icon year">
<<
</div>
<div className="calendar__content__toolbar__iconBox__icon month">
<
</div>
</div>
<div className="calendar__content__toolbar__current">
2023 年 11 月
</div>
<div className="calendar__content__toolbar__iconBox">
<div className="calendar__content__toolbar__iconBox__icon month">
>
</div>
<div className="calendar__content__toolbar__iconBox__icon year">
>>
</div>
</div>
</div>
<div className="calendar__content__weeks">
<span className="calendar__content__weeks__text">一</span>
<span className="calendar__content__weeks__text">二</span>
<span className="calendar__content__weeks__text">三</span>
<span className="calendar__content__weeks__text">四</span>
<span className="calendar__content__weeks__text">五</span>
<span className="calendar__content__weeks__text">六</span>
<span className="calendar__content__weeks__text">日</span>
</div>
<div className="calendar__content__days">
<span className="calendar__content__days__text">1</span>
<span className="calendar__content__days__text">2</span>
<span className="calendar__content__days__text">3</span>
<span className="calendar__content__days__text">4</span>
<span className="calendar__content__days__text">5</span>
<span className="calendar__content__days__text">6</span>
<span className="calendar__content__days__text">7</span>
<span className="calendar__content__days__text">8</span>
<span className="calendar__content__days__text">9</span>
<span className="calendar__content__days__text">10</span>
<span className="calendar__content__days__text">11</span>
<span className="calendar__content__days__text">12</span>
<span className="calendar__content__days__text">13</span>
<span className="calendar__content__days__text">14</span>
<span className="calendar__content__days__text">15</span>
<span className="calendar__content__days__text">16</span>
<span className="calendar__content__days__text">17</span>
<span className="calendar__content__days__text">18</span>
<span className="calendar__content__days__text">19</span>
<span className="calendar__content__days__text">20</span>
<span className="calendar__content__days__text">21</span>
<span className="calendar__content__days__text">22</span>
<span className="calendar__content__days__text">23</span>
<span className="calendar__content__days__text">24</span>
<span className="calendar__content__days__text">25</span>
<span className="calendar__content__days__text">26</span>
<span className="calendar__content__days__text">27</span>
<span className="calendar__content__days__text">28</span>
<span className="calendar__content__days__text">29</span>
<span className="calendar__content__days__text">30</span>
</div>
</div>
</div>
);
};
export default Calendar;
页面结构写好以后,简单写下样式,文本框和日历主体默认上下排。既然是自定义的组件,那么样式肯定不能受到页面中其他元素的干扰,并且一般都是文本框在哪,日历主体就正好在文本框的正下方。
所以我的想法是给日历主体设置position: fixed
切换年月份箭头我就直接用尖括号代替了,简单点实现,年份的两个尖括号需要紧凑一点显示,可以设置letter-spacing
为负值
星期和日期就用flex布局,一行7个,日期每7个换行
css
&__days {
display: flex;
flex-wrap: wrap;
&__text {
width: calc(100% / 7);
}
}
结构和样式写好以后,大概效果如下:
日历功能实现
写好布局以后,接下来就要实现以下几个功能:
- 日历主体的显示与隐藏;
- 找出日期与星期的对应关系;
- 默认渲染当前月份的日期;
- 切换月份;
- 切换年份;
我们一个一个来分析:
1.日历主体的显示与隐藏
定义一个visible变量,文本框获得焦点的时候为true,点击日历以外的部分为false(我是监听整个window的点击事件,当点击的dom元素不包含calendar类名时,则视为日历外部。感觉可能有更好的办法实现,但是我水平有限只能想到这么个处理办法)
js
const [visible, setVisible] = useState<boolean>(false);
/**
* 显示
*/
<input type="text" className="calendar__text" onFocus={() => setVisible(true)} />
/**
* 点击日历以外的部分,隐藏日历
*/
const handleDisappear = () => {
window.addEventListener("click", (event: MouseEvent) => {
const className = (event.target as HTMLElement)?.className;
!className.includes("calendar") && setVisible(false);
});
};
2.找出日期与星期的对应关系
我们知道一个月最多有31天,所以在渲染的时候我们会本能的想到最多需要31个dom元素去绘制,但如果真的完全按照当月的天数去绘制的话,就会出现日历主体忽高忽低的情况,因为二月份通常只有28天,如图:
所以我们需要在日期的首尾适当的填充一些空格,使日历组件在切换切换年月的时候高度不会忽高忽低。那首尾空格到底应该填充多少呢?我们打开windows日历看一个极端情况,以2023年10月份为例:
10月份的天数最多(31天),并且1号是星期天,这种情况下需要的行数最多(6行),所以完整渲染整个日期部分需要的dom不是31个,而是6x7=42个。由上图我们可以很清楚的看到,渲染日期面板主要分为三个部分:渲染上个月 ,渲染当月 ,渲染下个月
渲染上个月:我们需要知道当月的1号是星期几(如:星期日),那么上个月需要渲染的天数为:7-1=6天;
渲染当月:我们可以定义一个map,用来记录月份与天数的对应关系
js
const [dayCount] = useState<Map<number, number>>(
new Map([
[1, 31],
[2, 28],
[3, 31],
[4, 30],
[5, 31],
[6, 30],
[7, 31],
[8, 31],
[9, 30],
[10, 31],
[11, 30],
[12, 31],
])
);
渲染下个月:用42 - 上个月需要渲染的天数(如:6) - 当月需要渲染的天数(如:31)
有了渲染的思路以后,代码实现思路如下:
定义两个变量year/month,默认为当前年/月
js
const [year, setYear] = useState<number>(new Date().getFullYear());
const [month, setMonth] = useState<number>(new Date().getMonth() + 1);
然后还需要一个dates变量,用来渲染日期,日期格式我们也限定下类型,只接收date和enable(true代表当月,false代表上/下月)两个属性
js
// 定义日期数据格式
type DateItem = { date: number; enable: boolean };
const [dates, setDates] = useState<Array<DateItem>>();
页面部分:
js
<div className="calendar__content__days">
{dates?.map((dateItem: DateItem, index: number) => {
if (dateItem.enable) {
return (
<span
className="calendar__content__days__text"
key={index}
onClick={() => {
handleSelectDate(dateItem.date);
setVisible(false);
}}
>
{dateItem.date}
</span>
);
} else {
return (
<span
className="calendar__content__days__text disabled"
key={index}
>
{dateItem.date}
</span>
);
}
})}
</div>
然后在渲染日期的函数里面定义三个变量,分别用来存储上个月、当月、下个月的日期
js
/**
* 渲染日历
*/
const renderDays = () => {
const monthStr = month < 10 ? `0${month}` : month;
const firstDate = new Date(`${year}-${monthStr}-01`);
const firstDay = firstDate.getDay() === 0 ? 7 : firstDate.getDay();
let prevDates: DateItem[] = [];
let curDates: DateItem[] = [];
let nextDates: DateItem[] = [];
// 渲染上个月
for (let i = 0; i < firstDay - 1; i++) {
prevDates.unshift({
date: dayCount.get(month - 1 === 0 ? 12 : month - 1)! - i,
enable: false,
});
}
// 渲染当月
for (let i = 1; i <= dayCount.get(month)!; i++) {
curDates.push({
date: i,
enable: true,
});
}
// 渲染下个月
for (let i = 1; i <= 42 - dayCount.get(month)! - firstDay + 1; i++) {
nextDates.push({
date: i,
enable: false,
});
}
setDates([...prevDates, ...curDates, ...nextDates]);
};
这里map.get方法后用了个感叹号是为了告诉ts编译器通过月份拿到的天数一定不为空,防止编译报错。渲染日期函数我觉得是日历组件最核心的东西,有了渲染函数以后,再渲染当/上/下月,无非就是修改下year/month,再重新调下renderDays函数就行了,先看下初步渲染效果:
3.默认渲染当前月份的日期
这个就比较简单了,由于我们设置的year/month变量默认就是当前的年/月,所以直接在页面初始化的时候调用renderDays就行
js
useEffect(() => {
handleDisappear();
renderDays();
}, []);
4.切换月份
这个刚才讲到了,设置month值,并重新调用renderDays函数,需要注意的是当month等于12时,需要重置成1月份,并且年份也要相应的+1或-1
js
/**
* 切换上个月
*/
const handlePrevMonth = () => {
let tempMonth = month - 1;
if (tempMonth < 1) {
tempMonth = 12;
setYear(year - 1);
}
setMonth(tempMonth);
renderDays();
};
/**
* 切换下个月
*/
const handleNextMonth = () => {
let tempMonth = month + 1;
if (tempMonth > 12) {
tempMonth = 1;
setYear(year + 1);
}
setMonth(tempMonth);
renderDays();
};
5.切换年份
设置year值,并重新调用renderDays函数
js
/**
* 切换上一年
*/
const handlePrevYear = () => {
setYear(year - 1);
renderDays();
};
/**
* 切换下一年
*/
const handleNextYear = () => {
setYear(year + 1);
renderDays();
};
到了这里,整个日历组件的大致功能就差不多了。经过自测,我们发现了两个bug:
- 由于useState的异步更新,我们在切换年月的时候,日历面板的数据总会慢一步才更新;
- 二月份有可能是28天,也有可能是29天;
我们在useEffect的第二个参数可以同时加上year和month,这样state变化的时候就可以实时拿到最新的值了。然后再定义一个函数,每次切换年/月的时候,都拿最新的year值去判断当年的二月份是多少天就可以了
js
/**
* 设置二月份天数
*/
const setFebruaryDayCount = () => {
dayCount.set(2, year % 4 === 0 ? 29 : 28);
};
useEffect(() => {
handleDisappear();
setFebruaryDayCount();
renderDays();
}, [month, year]);
解决了bug以后,最后就是引用了。既然是自定义组件,我们需要在父组件引入的时候,需要动态的传入一些参数,我们平时用日历组件用的最多的就是获取选中的date值,以及选中date值以后需要一个回调函数,所以可以继续在Calendar.tsx中定义组件的属性
js
// 定义日历组件属性
type CalendarProps = {
ref: React.Ref<any>;
value: string;
onChange: (date: string) => void;
};
const Calendar: React.FC<CalendarProps> = () => {
// TODO
}
在App.tsx里可以定义一个按钮用来获取选中的date值
js
import React, { useRef, useState } from "react";
import Calendar from "./components/Calendar";
const App: React.FC = () => {
const calendarRef = useRef<any>(null);
const [dateValue, setDateValue] = useState<string>("");
const handleDateChange = (selectDate: string) => {
setDateValue(selectDate);
};
const handleClick = () => {
console.log("dateValue:", dateValue);
};
return (
<div style={{ display: "flex", alignItems: "center" }}>
<Calendar
ref={calendarRef}
value={dateValue}
onChange={handleDateChange}
/>
<button
onClick={handleClick}
style={{ height: "30px", marginLeft: "10px" }}
>
获取选中日期
</button>
</div>
);
};
export default App;
效果如图:
最后,如果需要在父组件通过ref.current的方式直接获取选中的date值,可以在Calendar.tsx中使用forwardRef
, useImperativeHandle
向父组件暴露selectDate属性
js
const Calendar: React.FC<CalendarProps> = forwardRef((props, parentRef) => {
// TODO
});
/**
* 向父组件暴露属性/方法
*/
useImperativeHandle(parentRef, () => ({
selectDate,
}));
完整代码:github.com/cj814/calen... ,如有不对或者不好的地方,欢迎大佬指正