基于React手写简易日历组件

很多人写过日历组件,最近在工作中碰到了类似的需求,不过我当时是用vue写的,今天用React再实现一个简易的版本,先看效果(忽略简陋的样式):

由图中可以看到一个简易的日历组件主要分为两个部分:文本框日历组件主体

文本框主要干了两件事:

  1. 唤起显示日历组件;
  2. 回显日历组件选中的日期;

日历组件主体主要干了五件事:

  1. 显示年月;
  2. 切换月份;
  3. 切换年份;
  4. 显示星期;
  5. 渲染日历主体;

有了一个大概的轮廓以后,就可以开干了。我的思路是:不用关心具体细节的实现,先做最简单的事,创建项目和画页面

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">
              &lt;&lt;
            </div>
            <div className="calendar__content__toolbar__iconBox__icon month">
              &lt;
            </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">
              &gt;
            </div>
            <div className="calendar__content__toolbar__iconBox__icon year">
              &gt;&gt;
            </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. 日历主体的显示与隐藏;
  2. 找出日期与星期的对应关系;
  3. 默认渲染当前月份的日期;
  4. 切换月份;
  5. 切换年份;

我们一个一个来分析:

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:

  1. 由于useState的异步更新,我们在切换年月的时候,日历面板的数据总会慢一步才更新;
  2. 二月份有可能是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中使用forwardRefuseImperativeHandle向父组件暴露selectDate属性

js 复制代码
const Calendar: React.FC<CalendarProps> = forwardRef((props, parentRef) => {
  // TODO
});

/**
 * 向父组件暴露属性/方法
 */
useImperativeHandle(parentRef, () => ({
  selectDate,
}));

完整代码:github.com/cj814/calen... ,如有不对或者不好的地方,欢迎大佬指正

相关推荐
bysking32 分钟前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓1 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
fg_4111 小时前
无网络安装ionic和运行
前端·npm
理想不理想v1 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
酷酷的阿云1 小时前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js
微信:137971205871 小时前
web端手机录音
前端
齐 飞1 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
神仙别闹1 小时前
基于tensorflow和flask的本地图片库web图片搜索引擎
前端·flask·tensorflow
GIS程序媛—椰子2 小时前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_0012 小时前
前端八股文(一)HTML 持续更新中。。。
前端·html