基于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... ,如有不对或者不好的地方,欢迎大佬指正

相关推荐
程序员阿超的博客11 分钟前
React动态渲染:如何用map循环渲染一个列表(List)
前端·react.js·前端框架
magic 24513 分钟前
模拟 AJAX 提交 form 表单及请求头设置详解
前端·javascript·ajax
小小小小宇5 小时前
前端 Service Worker
前端
只喜欢赚钱的棉花没有糖5 小时前
http的缓存问题
前端·javascript·http
小小小小宇6 小时前
请求竞态问题统一封装
前端
loriloy6 小时前
前端资源帖
前端
源码超级联盟6 小时前
display的block和inline-block有什么区别
前端
GISer_Jing6 小时前
前端构建工具(Webpack\Vite\esbuild\Rspack)拆包能力深度解析
前端·webpack·node.js
让梦想疯狂6 小时前
开源、免费、美观的 Vue 后台管理系统模板
前端·javascript·vue.js
海云前端6 小时前
前端写简历有个很大的误区,就是夸张自己做过的东西。
前端