用react手写一个简单的日历

趣谈前端

共 28170字,需浏览 57分钟

 · 2021-07-12

关注并将「趣谈前端」设为星标

每日定时推送技术干货/优秀开源/技术思维

设计实现一个简单版本的日历。支持定义日历的排放顺序,以周几作为开始。如下图:

  • 先看效果:https://rodchen-king.github.io/react-calendar/components/calendar
  • 源代码:https://github.com/rodchen-king/react-calendar



设计(以最常用的按月份的日历)

日历其实大家都很熟悉,一切的设计都是从功能出发,这是根本。日历的功能分为两大块。

  • 日历头部:当前年份/月份。
  • 日历主体:当前月份的具体的日期信息。
  • 日历主体的行数:现在我们看到的日历基本上为6行,因为一个月最多为31天,假设当前月的第一天为上一月最后一周的最后一天。如果是五行数据的话则只显示了29天,这也是为什么显示6行数据的原因。


功能点

  • 日历初始渲染日期为当前月份
  • 头部的左右滑动,日历数据需要显示对应月份的信息
  • 可以根据调用设置日历的每周数据以星期*为开始,星期天或者星期一。


核心问题

如何获取当前日期的年份以及月份

// Calender/lib/utils.ts
/**
 * 获取日历header内容 格式为:****年 **月
 * @param {*} date
 */

export const getHeaderContent = function (date: Date{
  let _date = new Date(date);

  return dateFormat(_date, 'yyyy年 MM月');
};


如何获取当前月份需要显示的42条数据(6*7),这42条数据是什么?

这个问题的核心是:当前月份显示的42条数据的第一天是哪一天?

这个问题的解决思路还要从上面的设计说起,上面提到日历主题的行数时,说到“假设当前月的第一天为上一月最后一周的最后一天”,那么42条数据显示的内容的第一条数据还要根据当前月的第一天是第一天所在周的第几天。

举例:2019-02-01
2月的第一天,星期五,所以当前月日历的第一天为

var date = new Date()
date.setDate(date.getDate() - date.getDay() + 1// 获取当前月的第一天为2019-01-28


这里有一问题是什么呢?

上面的代码逻辑是假设日历的排列顺序是周一围最开始的(如果你的日历也是将周日放在日历的第一天,没什么问题,可是在中国是将周日放在最后一天的),这也就意味着前面的实现还需要考虑日历的放置顺序,因为日历是按照普通的周一到周日,还是周日到周一,我们获取的当月日历的第一天是不同的。所以上面的代码还要依赖于日历的排放顺序。

这里的排放顺序将是日历组件的第一个可被调用者控制的参数。这里我的设想是将该参数的传入值与date.getDay()匹配。

  • 0:周日
  • 1:周一
  • .....
  • 5:周五
  • 6:周六

所以上面的公式为:

date.setDate(date.getDate() - date.getDay() + x)

但是这里的x值加了之后的日期如果大于当前月份的第一天,那就需要将当前得到的日期数值再减去7天,这个原因就不用说明了吧。

/**
 * 获取当前月日历的第一天
 * @param {*} date
 */

export const getFirstDayOfCalendar = function (
  date: Date,
  weekLabelIndex: number,
{
  let _date = new Date(date);
  _date = new Date(
    _date.setDate(_date.getDate() - _date.getDay() + weekLabelIndex),
  );
  // 如果当前日期大于当前月第一天,则需要减去7天
  if (_date > date) {
    _date = new Date(_date.setDate(_date.getDate() - 7));
  }

  return _date;
};

接下来就好做了,只需要在当前的日期加上加上1,每次得到下一天的日期。

左右切换月份如何设定

上面设计都是以今天为计算初始值,左右切换的初始值如何设计呢?

第一反应是将当前的日期的月份进行加减1,这样是不行的,因为如果今天是31号,那么碰到下个月只有30的时候,这样就会碰到点击下月,直接切换了两个月。更别说2月这个月份天数不固定的月份。所以这里又是一个问题了。

我的解决思路是:月份点击切换的时候,初始计算值设计为当前月的第一天。

/**
 * 以传入参数作为基准获取下个月的第一天日期
 * @param {*} firstDayOfCurrentMonth
 */

export const getFirstDayOfNextMonth = function (firstDayOfCurrentMonth: Date{
  return new Date(
    firstDayOfCurrentMonth.getFullYear(),
    firstDayOfCurrentMonth.getMonth() + 1,
    1,
  );
};

/**
 * 以传入参数作为基准获取上个月的第一天日期
 * @param {*} firstDayOfCurrentMonth
 */

export const getFirstDayOfPrevMonth = function (firstDayOfCurrentMonth: Date{
  return new Date(
    firstDayOfCurrentMonth.getFullYear(),
    firstDayOfCurrentMonth.getMonth() - 1,
    1,
  );
};


左右切换月份数据传递方式(观察者模式)

因为对于日历组件本身来说,header和body是属于同一个父组件的同级组件,数据传递可以依赖于父组件进行传递,这里我使用的是观察者模式实现。

/*
 * Subject
 * 内部创建了三个方法,内部维护了一个ObserverList。
 */


export class Subject {
  private _observers = new ObserverList();

  // addObserver: 调用内部维护的ObserverList的add方法
  public addObserver(observer: Observer) {
    this._observers.add(observer);
  }

  // removeObserver: 调用内部维护的ObserverList的removeat方法
  public removeObserver(observer: Observer) {
    this._observers.removeAt(this._observers.indexOf(observer, 0));
  }

  // notify: 通知函数,用于通知观察者并且执行update函数,update是一个实现接口的方法,是一个通知的触发方法。
  public notify(context: any) {
    let observerCount = this._observers.count();
    for (let i = 0; i < observerCount; i++) {
      (<Observer>this._observers.get(i)).update(context);
    }
  }
}

/*
 * ObserverList
 * 内部维护了一个数组,4个方法用于数组的操作,这里相关的内容还是属于subject,因为ObserverList的存在是为了将subject和内部维护的observers分离开来,清晰明了的作用。
 */

class ObserverList {
  private _observerList: Observer[] = [];

  public add(obj: Observer) {
    return this._observerList.push(obj);
  }

  public count() {
    return this._observerList.length;
  }

  public get(index: number) {
    if (index > -1 && index < this._observerList.length) {
      return this._observerList[index];
    }

    throw new Error(`_observerList ${index} 未知为null`);
  }

  public indexOf(obj: Observer, startIndex: number) {
    let i = startIndex;

    while (i < this._observerList.length) {
      if (this._observerList[i] === obj) {
        return i;
      }
      i++;
    }

    return -1;
  }

  public removeAt(index: number) {
    this._observerList.splice(index, 1);
  }
}

export class Observer {
  public update: Function = () => {};
}


CalendarBody观察者注册


CalendarHeader通知消息



文件结构

Calendar                   
├─ Components
│ ├─ CalendarBody.tsx
│ ├─ CalendarHeader.tsx
│ ├─ calenderBody.less
│ └─ calenderHeader.less
├─ lib
│ ├─ subject.ts
│ └─ utils.ts
└─ index.tsx


所有代码文件

// index.ts
import React from 'react';
import CalendarBody from './components/CalendarBody';
import CalendarHeader from './components/CalendarHeader';
import { initObserver } from './lib/utils';
import { Subject } from './lib/subject';

export default ({ weekLabelIndex = 1 }: { weekLabelIndex: number }) => {
  let calendarObserver: Subject = initObserver();

  return (
    <div>
      <CalendarHeader observer={calendarObserver} />
      <CalendarBody
        observer={calendarObserver}
        weekLabelIndex={weekLabelIndex}
      />
    </div>
  );
};
// lib/subject.ts
export class Subject {
  private _observers = new ObserverList();

  // addObserver: 调用内部维护的ObserverList的add方法
  public addObserver(observer: Observer) {
    this._observers.add(observer);
  }

  // removeObserver: 调用内部维护的ObserverList的removeat方法
  public removeObserver(observer: Observer) {
    this._observers.removeAt(this._observers.indexOf(observer, 0));
  }

  // notify: 通知函数,用于通知观察者并且执行update函数,update是一个实现接口的方法,是一个通知的触发方法。
  public notify(context: any) {
    let observerCount = this._observers.count();
    for (let i = 0; i < observerCount; i++) {
      (<Observer>this._observers.get(i)).update(context);
    }
  }
}

/*
 * ObserverList
 * 内部维护了一个数组,4个方法用于数组的操作,这里相关的内容还是属于subject,因为ObserverList的存在是为了将subject和内部维护的observers分离开来,清晰明了的作用。
 */

class ObserverList {
  private _observerList: Observer[] = [];

  public add(obj: Observer) {
    return this._observerList.push(obj);
  }

  public count() {
    return this._observerList.length;
  }

  public get(index: number) {
    if (index > -1 && index < this._observerList.length) {
      return this._observerList[index];
    }

    throw new Error(`_observerList ${index} 未知为null`);
  }

  public indexOf(obj: Observer, startIndex: number) {
    let i = startIndex;

    while (i < this._observerList.length) {
      if (this._observerList[i] === obj) {
        return i;
      }
      i++;
    }

    return -1;
  }

  public removeAt(index: number) {
    this._observerList.splice(index, 1);
  }
}

export class Observer {
  public update: Function = () => {};
}

// lib/utils.ts
import { Subject } from './Subject';

let transfer = function (thisany, fmt: string{
  let o: {
    [k: string]: string | number;
  } = {
    'M+'this.getMonth() + 1// 月份
    'd+'this.getDate(), // 日
    'h+'this.getHours(), // 小时
    'm+'this.getMinutes(), // 分
    's+'this.getSeconds(), // 秒
    'q+'Math.floor((this.getMonth() + 3) / 3), // 季度
    S: this.getMilliseconds(), // 毫秒
  };

  if (/(y+)/.test(fmt)) {
    fmt = fmt.replace(
      RegExp.$1,
      (this.getFullYear() + '').substr(4 - RegExp.$1.length),
    );
  }
  for (let k in o) {
    if (new RegExp('(' + k + ')').test(fmt)) {
      fmt = fmt.replace(
        RegExp.$1,
        RegExp.$1.length === 1
          ? o[k] + ''
          : ('00' + o[k]).substr(('' + o[k]).length),
      );
    }
  }

  return fmt;
};

/**
 * 用于format日期格式
 * @param {*} timeSpan
 * @param {*} fmt
 * @param {*} formatDateNullValue
 */

export const dateFormat = function (
  timeSpan: Date,
  fmt: string,
  formatDateNullValue?: string,
{
  if (!timeSpan) {
    if (formatDateNullValue) {
      return formatDateNullValue;
    }
    return '无';
  }

  let date = new Date(timeSpan);

  return transfer.call(date, fmt);
};

/**
 * 获取日历header内容 格式为:****年 **月
 * @param {*} date
 */

export const getHeaderContent = function (date: Date{
  let _date = new Date(date);

  return dateFormat(_date, 'yyyy年 MM月');
};

/**
 * 获取当前月的第一天
 * @param {*} date
 */

export const getFirstDayOfMonth = function (date: Date{
  let _date = new Date(date);
  _date.setDate(1);

  return _date;
};

/**
 * 获取当前月日历的第一天
 * @param {*} date
 */

export const getFirstDayOfCalendar = function (
  date: Date,
  weekLabelIndex: number,
{
  let _date = new Date(date);
  _date = new Date(
    _date.setDate(_date.getDate() - _date.getDay() + weekLabelIndex),
  );
  // 如果当前日期大于当前月第一天,则需要减去7天
  if (_date > date) {
    _date = new Date(_date.setDate(_date.getDate() - 7));
  }

  return _date;
};

/**
 * 根据传入index确认weeklabel的顺序
 * @param {*} weekIndexOfFirstWeekDay
 */

export const getWeekLabelList = function (weekIndexOfFirstWeekDay: number{
  let weekLabelArray: string[] = [
    '周日',
    '周一',
    '周二',
    '周三',
    '周四',
    '周五',
    '周六',
  ];

  for (let index = 0; index < weekIndexOfFirstWeekDay; index++) {
    let weekLabel = weekLabelArray.shift() || '';
    weekLabelArray.push(weekLabel);
  }

  return weekLabelArray;
};

/**
 * 启动观察者模式,并且初始化
 */

export const initObserver = function ({
  let subject = new Subject();

  return subject;
};

/**
 * 格式化日期为两个单词,例如:‘1’号 格式为 ‘01’
 * @param {*} dateNumber
 */

export const formatDayWithTwoWords = function (dateNumber: number{
  if (dateNumber < 10) {
    return '0' + dateNumber;
  }

  return dateNumber;
};

/**
 * 比较当前日期是否为本月日期,用于进行本月数据高亮显示
 * @param {*} firstDayOfMonth
 * @param {*} date
 */

export const isCurrentMonth = function (firstDayOfMonth: Date, date: Date{
  return firstDayOfMonth.getMonth() === date.getMonth();
};

/**
 * 比较当前日期是否为系统当前日期
 * @param {*} date
 */

export const isCurrentDay = function (date: Date{
  let _date = new Date();
  return (
    date.getFullYear() === _date.getFullYear() &&
    date.getMonth() === _date.getMonth() &&
    date.getDate() === _date.getDate()
  );
};

/**
 * 以传入参数作为基准获取下个月的第一天日期
 * @param {*} firstDayOfCurrentMonth
 */

export const getFirstDayOfNextMonth = function (firstDayOfCurrentMonth: Date{
  return new Date(
    firstDayOfCurrentMonth.getFullYear(),
    firstDayOfCurrentMonth.getMonth() + 1,
    1,
  );
};

/**
 * 以传入参数作为基准获取上个月的第一天日期
 * @param {*} firstDayOfCurrentMonth
 */

export const getFirstDayOfPrevMonth = function (firstDayOfCurrentMonth: Date{
  return new Date(
    firstDayOfCurrentMonth.getFullYear(),
    firstDayOfCurrentMonth.getMonth() - 1,
    1,
  );
};

// Components/CalendarHeader.tsx
import React, { useEffect, useCallback, useState } from 'react';
import { Subject } from '../lib/subject';
import {
  getHeaderContent,
  getFirstDayOfMonth,
  getFirstDayOfNextMonth,
  getFirstDayOfPrevMonth,
from '../lib/utils';
import './calenderHeader.less';

export default ({ observer }: { observer: Subject }) => {
  // 页面绑定数据
  const [headerContent, setHeaderContent] = useState<string>('');
  const [firstDayOfMonth, setFirstDayOfMonth] = useState<Date>(new Date());

  let leftArrow = '<';
  let rightArrow = '>';

  useEffect(() => {
    setHeaderContent(getHeaderContent(new Date()));
    setFirstDayOfMonth(new Date());
  }, []);

  /**
   * 主题发布信息,通知观察者
   */

  const observerNotify = (currentFirstDayOfMonth: Date) => {
    setHeaderContent(getHeaderContent(currentFirstDayOfMonth));
    observer.notify(currentFirstDayOfMonth);
  };

  /**
   * 页面操作
   */

  const goPrev = () => {
    const preFirstDayOfMonth = getFirstDayOfPrevMonth(firstDayOfMonth);
    setFirstDayOfMonth(preFirstDayOfMonth);
    observerNotify(preFirstDayOfMonth);
  };

  const goNext = () => {
    const nextFirstDayOfMonth = getFirstDayOfNextMonth(firstDayOfMonth);

    setFirstDayOfMonth(nextFirstDayOfMonth);
    observerNotify(nextFirstDayOfMonth);
  };

  return (
    <div className="calendar-header">
      <div className="header-center">
        <span className="prev-month" onClick={goPrev}>
          {leftArrow}
        </span>
        <span className="title">{headerContent}</span>
        <span className="next-month" onClick={goNext}>
          {rightArrow}
        </span>
      </div>
    </div>

  );
};

// Components/CalendarBody.tsx
import React, { useEffect, useCallback, useState } from 'react';
import { Subject } from '../lib/subject';
import {
  getFirstDayOfMonth,
  getFirstDayOfCalendar,
  formatDayWithTwoWords,
  isCurrentMonth,
  isCurrentDay,
  getWeekLabelList,
from '../lib/utils';
import './calenderBody.less';

interface DayItem {
  dateDate;
  monthDay: number | string;
  isCurrentMonth: boolean;
  isCurrentDay: boolean;
}

export default ({
  observer,
  weekLabelIndex = 1,
}: {
  observer: Subject;
  weekLabelIndex?: number;
}) => {
  const [firstDayOfMonth, setFirstDayOfMonth] = useState(new Date());
  const [weekList, setWeekList] = useState<DayItem[][]>([]);
  const [weekLabelArray, setWeekLabelArray] = useState<string[]>([]);

  useEffect(() => {
    // 注册观察者对象
    observer.addObserver({
      update: update,
    });

    // 设置当前月的第一天,用来数据初始话以及进行日期是否为当前月判断
    setFirstDayOfMonth(getFirstDayOfMonth(new Date()));

    // 设置每周label标识数据
    setWeekLabelArray(getWeekLabelList(weekLabelIndex));

    // 初始设置当前月日历数据
    setWeekListValue(getFirstDayOfMonth(new Date()));
  }, []);

  /**
   * 日历方法
   */

  // 点击日历
  const onClickDay = (dayItem: DayItem) => {
    // this.$emit('dayClick', dayItem)
  };

  // 设置weekList值
  const setWeekListValue = (firstDayOfmonth: Date) => {
    let newWeekList = [];
    let dayOfCalendar = getFirstDayOfCalendar(firstDayOfmonth, weekLabelIndex);

    // 遍历层数为6,因为日历显示当前月数据为6行
    for (let weekIndex = 0; weekIndex < 6; weekIndex++) {
      let weekItem = [];
      // 每一周为7天
      for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
        let dayItem: DayItem = {
          date: dayOfCalendar,
          monthDay: formatDayWithTwoWords(dayOfCalendar.getDate()),
          isCurrentMonth: isCurrentMonth(firstDayOfMonth, dayOfCalendar),
          isCurrentDay: isCurrentDay(dayOfCalendar),
        };
        weekItem.push(dayItem);

        // 当前日期加1,以此类推得到42条记录
        dayOfCalendar.setDate(dayOfCalendar.getDate() + 1);
      }

      newWeekList.push(weekItem);

      setWeekList(newWeekList);
    }
  };

  /**
   * 观察者模式相关方法
   */

  // 切换月份更新body数据
  const update = (content: Date) => {
    setFirstDayOfMonth(content);
    setWeekListValue(content);
  };

  /**
   * 工具方法
   */

  // 周六/周日标识红色字体
  const isShowRedColorForWeekLable = (index: number) => {
    return (
      index + weekLabelIndex === 6 ||
      index + weekLabelIndex === 7 ||
      (index === 0 && weekLabelIndex === 0)
    );
  };

  return (
    <div className="calendar-body">
      {/* <!-- 日历周label标识 --> */}
      <div className="calendar-body-week-label">
        {weekLabelArray.map((item, index) => (
          <div
            className={`calendar-body-week-label-day ${
              isShowRedColorForWeekLable(index) ? 'red-font: ''
            }`}
          >

            <span>{item}</span>
          </div>
        ))}
      </div>
      {/* <!-- 日历数据,遍历日历二位数组,得到每一周数据 --> */}
      {weekList.map((weekItem: DayItem[]) => (
        <div className="calendar-body-week">
          {/* <!-- 遍历每一周数据 --> */}
          {weekItem.map((dayItem: DayItem, index: number) => (
            <div
              className={`calendar-body-week-day ${
                dayItem.isCurrentMonth ? 'calendar-body-current-month: ''
              } ${dayItem.isCurrentDay ? 'calendar-body-current-day: ''} ${
                isShowRedColorForWeekLable(index) ? 'red-font: ''
              }`}
              onClick={() =>
 onClickDay(dayItem)}
            >
              <span>{dayItem.monthDay}</span>
            </div>
          ))}
        </div>
      ))}
    </div>

  );
};

❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  • 点个【在看】,或者分享转发,让更多的人也能看到这篇内容
  • 关注公众号【趣谈前端】,定期分享 工程化 可视化 / 低代码 / 优秀开源


Dooring可视化搭建平台数据源设计剖析

可视化搭建的一些思考和实践

基于Koa + React + TS从零开发全栈文档编辑器(进阶实战)

从零使用electron搭建桌面端Dooring


点个在看你最好看


浏览 26
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报