IdbI

第08章—组件实战:Calendar日历组件(上)

上节我们实现了 mini calendar,为啥要加个 mini 呢?

因为它与真实用的 Calendar 组件相比,还是过于简单了。

这节我们再来写一个复杂一些的,真实项目用的 Calendar 组件:

用 cra 创建个项目:

npx create-react-app --template typescript calendar-component

先不着急写,我们先理一下思路:

日历组件的核心是什么?

是拿到每月的天数,每月的第一天是星期几。

比如这个月:

我们知道这个月有 30 天,第一天是周三,那就知道如何显示这个月的日历了。

那如何知道每月的天数呢?

上节讲过,用 Date 的 api 就可以。

当然,也可以用 dayjs,它封装了这些:

安装 dayjs:

npm install --save dayjs

在 test.js 写如下代码:

const dayjs = require('dayjs');

console.log(dayjs('2023-11-1').daysInMonth());

console.log(dayjs('2023-11-1').startOf('month').format('YYYY-MM-DD'));

console.log(dayjs('2023-11-1').endOf('month').format('YYYY-MM-DD'));

创建一个 dayjs 的对象,然后用 daysInMonth 方法可以拿到这个月的天数,用 startOf、endOf 可以拿到这个月的第一天和最后一天的日期。

跑一下:

这次 Calendar 组件我们用 dayjs 的 api 来实现。

很多组件库的 Calendar 组件都是基于 dayjs 设置和返回日期的。

比如 antd 的:

下面正式来写 Calendar 组件。

创建 src/Calendar/index.tsx

import './index.scss';

function Calendar() {
    return <div className="calendar">
        
    </div>
}

export default Calendar;

还有样式 src/Calendar/index.scss

.calendar {
    width: 100%;

    height: 200px;
    background: blue;
}

这里用到了 scss,需要安装下用到的包:

npm install --save sass

然后在 App.tsx 里引入 Calendar 组件:

import Calendar from './Calendar';

function App() {
  return (
    <div className="App">
      <Calendar></Calendar>
    </div>
  );
}

export default App;

跑一下:

npm run start

这样,sass 就引入成功了。

这个组件可以分为 Header 和 MonthCalendar 两个组件。

我们先写下面的 MonthCalender 组件:

首先是周日到周六的部分:

src/Calendar/MonthCalendar.tsx

function MonthCalendar() {

    const weekList = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];

    return <div className="calendar-month">
        <div className="calendar-month-week-list">
            {weekList.map((week) => (
                <div className="calendar-month-week-list-item" key={week}>
                    {week}
                </div>
            ))}
        </div>
    </div>
}

export default MonthCalendar;

先把周日到周一渲染出来,然后在 src/Calendar/index.scss 里写下样式:

.calendar {
    width: 100%;
}

.calendar-month {
    &-week-list {
        display: flex;
        padding: 0;
        width: 100%;
        box-sizing: border-box;
        border-bottom: 1px solid #ccc;

        &-item {
            padding: 20px 16px;
            text-align: left;
            color: #7d7d7f;
            flex: 1;
        }
    }
}

样式用 display:fex 加 flex:1,这样就是每个列表项平分剩余空间,然后加上 padding。

在 src/Calendar/index.tsx 里引入:

import MonthCalendar from './MonthCalendar';
import './index.scss';

function Calendar() {
    return <div className="calendar">
        <MonthCalendar/>
    </div>
}

export default Calendar;

这样,上面的 week list 就完成了:

然后是下面部分:

思路前面分析过了,就是拿到当前月份的天数和第一天是星期几,前后用上个月和下个月的日期填充。

我们给 Calendar 组件加一个 value 的 props,也就是当前日期。

value 我们选择用 Dayjs 类型,当然,用 Date 也可以。

import { Dayjs } from 'dayjs';
import MonthCalendar from './MonthCalendar';
import './index.scss';

export interface CalendarProps {
    value: Dayjs
}

function Calendar(props: CalendarProps) {
    return <div className="calendar">
        <MonthCalendar {...props}/>
    </div>
}

export default Calendar;

在 MonthCalendar 也加上 props:

interface MonthCalendarProps extends CalendarProps {

}

在 App.tsx 传入参数:

这样,MonthCalendar 就可以根据传入的 value 拿到当前的月份信息了。

我们加一个 getAllDays 方法,打个断点:

function getAllDays(date: Dayjs) {
    const daysInMonth = date.daysInMonth();
    const startDate = date.startOf('month');
    const day = startDate.day()    

}
const allDays = getAllDays(props.value);

然后创建个调试配置:

点击调试启动:

可以看到,拿到了这个月的天数,是 30 天。

接下来我们边调试边写。

不管这个月有多少天,我们日历都是固定 6 * 7 个日期:

所以创建一个 6 * 7 个元素的数组,这个月第一天之前的用第一天的日期 -1、-2、-3 这样计算出来:

function getAllDays(date: Dayjs) {
    const daysInMonth = date.daysInMonth();
    const startDate = date.startOf('month');
    const day = startDate.day()    

    const daysInfo = new Array(6 * 7);

    for(let i = 0 ; i < day; i++) {
        daysInfo[i] = {
            date: startDate.subtract(day - i, 'day').format('YYYY-MM-DD')
        }
    }
    
    debugger;

}

11 月 1 日是星期三:

那也就是要在之前填充星期日、星期一、星期二,这 3 天的日期:

这里用 dayjs 的 subtract 方法就可以计算当前日期 -1、-2、-3 的日期。

再写一段逻辑,点击刷新:

function getAllDays(date: Dayjs) {
    const daysInMonth = date.daysInMonth();
    const startDate = date.startOf('month');
    const day = startDate.day()    

    const daysInfo = new Array(6 * 7);

    for(let i = 0 ; i < day; i++) {
        daysInfo[i] = {
            date: startDate.subtract(day - i, 'day').format('YYYY-MM-DD')
        }
    }

    for(let i = day ; i < daysInfo.length; i++) {
        daysInfo[i] = {
            date: startDate.add(i - day, 'day').format('YYYY-MM-DD')
        }
    }

    debugger;

}

这个循环就是填充剩下的日期的,从 startDate 开始 +1、+2、+3 计算日期。

hover 上去可以看到,计算的结果是对的:

然后把 format 删掉,这里不需要格式化。再添加一个属性标识是否是当前月份的。

function getAllDays(date: Dayjs) {
    const startDate = date.startOf('month');
    const day = startDate.day()    

    const daysInfo = new Array(6 * 7);

    for(let i = 0 ; i < day; i++) {
        daysInfo[i] = {
            date: startDate.subtract(day - i, 'day'),
            currentMonth: false
        }
    }

    for(let i = day ; i < daysInfo.length; i++) {
        const calcDate = startDate.add(i - day, 'day');

        daysInfo[i] = {
            date: calcDate,
            currentMonth: calcDate.month() === date.month()
        }
    }

    return daysInfo;
}

就是先 -1、-2、-3 计算本月第一天之前的日期,然后从第一天开始 +1、+2、+3 计算之后日期。

返回值处打个断点,刷新下:

当前月份的日期、之前几天的日期、之后几天的日期都有了。

这样,日历的数据就准备好了。

其实上一节我们也是这么做的,只不过用的是 Date 的 api,而这节换成 dayjs 的 api 了。

再声明下返回的数组的类型:

const daysInfo: Array<{date: Dayjs, currentMonth: boolean}> = new Array(6 * 7);

数据准备好了,接下来就可以渲染了:

<div className="calendar-month-body">
    {
        renderDays(allDays)
    }
</div>
function renderDays(days: Array<{ date: Dayjs, currentMonth: boolean}>) {
    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.date.date()}</div>
        }
        rows.push(row);
    }
    return rows.map(row => <div className="calendar-month-body-row">{row}</div>)
}

这里就是把 6 * 7 个日期,按照 6 行,每行 7 个来组织成 jsx。

scss 部分如下:

image.png

&-body {
    &-row {
        height: 100px;
        display: flex;
    }
    &-cell {
        flex: 1;
        border: 1px solid #eee;
        padding: 10px
    }

}

每行的每个单元格用 flex:1 来分配空间,然后设置个 padding。

渲染出来是这样的:

然后当前月和其他月份的日期加上个不同颜色区分:

function renderDays(days: Array<{ date: Dayjs, currentMonth: boolean}>) {
    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' : '')
            }>{item.date.date()}</div>
        }
        rows.push(row);
    }
    return rows.map(row => <div className="calendar-month-body-row">{row}</div>)
}

color: #ccc;
&-current {
    color: #000;
}

这样,我们的日历就基本完成了:

切换日期是在 Header 部分做的,接下来写下这部分:

写下 src/Calendar/Header.tsx:

function Header() {
    return <div className="calendar-header">
        <div className="calendar-header-left">
            <div className="calendar-header-icon">&lt;</div>
            <div className="calendar-header-value">2023 年 11 月</div>
            <div className="calendar-header-icon">&gt;</div>
            <button className="calendar-header-btn">今天</button>
        </div>
    </div>
}

export default Header;

还有对应的样式:

.calendar-header {
    &-left {
        display: flex;
        align-items: center;

        height: 28px;
        line-height: 28px;
    }

    &-value {
        font-size: 20px;
    }

    &-btn {
        background: #eee;
        cursor: pointer;
        border: 0;
        padding: 0 15px;
        line-height: 28px;

        &:hover {
            background: #ccc;
        }
    }
  
    &-icon {
        width: 28px;
        height: 28px;

        line-height: 28px;
    
        border-radius: 50%;
        text-align: center;
        font-size: 12px;

        user-select: none;
        cursor: pointer;

        margin-right: 12px;
        &:not(:first-child) {
            margin: 0 12px;
        }

        &:hover {
            background: #ccc;
        }
    }
  
}

这部分就是用 flex + margin 来实现布局,就不展开讲了。

渲染出来是这样的:

这样我们就完成了布局部分。

案例代码上传了小册仓库

总结

这节我们开始实现一个真实的 Calendar 组件。

我们不再用 Date 获取当前月、上个月、下个月的天数和星期几,而是用 dayjs 的 api。

我们完成了布局部分,包括用于切换月份的 Header 和每个月的日期 MonthCalender 组件。

我们使用 sass 来管理样式。

上面的周几、下面的日期我们都是用的 flex 布局,这样只要外层容器大小变了,内层就会跟着变。

下节我们来实现具体的组件逻辑。

返回上级