面向初学者:如何写一个日历组件

3,888 阅读5分钟

最近刚好给自己的组员讲解日历组件的开发,就顺便写下自己的心得。

这是苹果上的日历截图,从上面的内容来看,有公历,农历,24节气和节日。

遗憾的是目前还未找到 公历农历 转换的公式;现阶段能够找到的开源的日历组件,基本上都是存了一份 农历 1900-2100 的润大小信息表,有兴趣的可以阅读下 农历编算法则

而节日相对而言会受到地区等因素影响,需要定时维护,所以实际上,我们在日历上真正能够计算并使用的只有 公历24节气

写日历需要解决的问题

1. 每周从什么时候开始?

这是因为不同的地区,每周开始的时间是不一样的,可以参考知乎的 一周的第一天是周一还是周日?

平时在使用实体日历的时候,的确是有些印刷的日历会从 星期一 开始,而使用电子产品的时候,大多数都是从 星期天 开始的。

不过这个问题还是比较好解决的,首先看下日历的头部。

从截图上看出,不管是那天开始,整体的顺序不变,而且有没有发现,这个情况和无缝滚动的组件很像,内容滚出视图外,自动补充到末尾,就像一个圆环。

那我们基于无缝滚动的原理,就很容易获得一周7天的数字。

既然原理分析出了,那我们就可以很容易的写出代码:

// 在 js 中,0 是周日,0 ~ 6 代表周一至周日
const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]

// 一周只有7天,不会有第8天,所以只要拷贝一次就可以了
const DOUBLE_WEEKDAYS = WEEKDAYS.concat(WEEKDAYS)

/**
 * 获取获得一周7天的数字
 *
 * @param firstWeekDay 周开始时间
 *
 * @return 周数组
 */
function getWeekdays(firstWeekDay = 0) {
  if (firstWeekDay === 0) return WEEKDAYS
  return DOUBLE_WEEKDAYS.slice(firstWeekDay, firstWeekDay + 7)
}

我们执行之后,基本上达到我们的预期。

那我们再进一步,转化成日历的头部,顺便支持国际化

// 简体中文
const zh = {
  // 完整名称 
  weekdays: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
  // 短名称
  weekdaysShort: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
  // 缩写
  weekdaysAbbr: ['日', '一', '二', '三', '四', '五', '六']
}

// 英文
const en = {
  // 完整名称 
  weekdays: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
  // 短名称
  weekdaysShort: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
  // 缩写
  weekdaysAbbr: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
}

/**
 * 获取一星期的名称列表
 *
 * @param {Number[]}  weekdays 一周7天的数字
 *
 * @param {Object[]}
 */
function getWeekHead(weekdays, locale) {
  return weekdays.map(day => ({
    name: locale.weekdays[day],
    short: locale.weekdaysShort[day],
    abbr: locale.weekdaysAbbr[day],
    day: day
  }))
}

打开 Chrome devtools 执行下,基本额可以达到我们的预期

2. 每个月的第一天不是周的开始

这个很正常,周的开始都不确定,何况是月天数不固定。

我们从上面的图也可以看出来,月结束和开始都不固定,那我们该如何处理?

其实这个也非常容易解决,我们先不从程序的角度思考,单纯从图片上思考下,是不是只要知道每个月的1号在处在星期中的那一天,在往前减去多少天,就可以计算出 1号所在星期 开始的时间。

可能又点绕,我们用图片说话:

那我们知道如何计算之后,就可以开始准备数据了。

首先我们需要知道,1号是星期几,这个在 js 中,直接通过 Date#getDay() 就可以知道是星期几

但因为前面说过了,周的开始是不固定的,所以我们计算的时候,还需要前面的函数辅助才能知道应该减少几天。

到这里可能会有人疑惑,每个月份的天数都不固定,而且还有润月的问题;就算我们知道要减少几天,计算上一个月的最后一天也是一个麻烦事情,这个应该如何解决呢?

其实这个也非常简单,在 js 中 Date 对象已经帮帮我们处理好了。

到这里我们所有的条件都已经准备完毕了,剩下的就交给循环了。


/**
 * 获取月份日历
 *
 * @param {*} year  年
 * @param {*} month 月
 * @param {Object} options
 * @param {Number[]} options.weekdays     一周7天的数组
 * @param {Number} [options.firstWeekDay=0]      周开始时间
 * @param {Number} [options.visibleWeeksCount=6] 单个日历上显示的周数量
 */
function getMonthCalendar(year, month, options) {
  const weekdays = options.weekdays
  const cursor = new Date(year, month - 1, 1, 0, 0, 0, 0)

  const count = (options.visibleWeeksCount || 6) * 7

  // 让时间定位在周开始的时间
  cursor.setDate(cursor.getDate() - weekdays.indexOf(cursor.getDay()))

  const calendar = []

  let week = []
  for (let i = 0; i < count; i++) {
    if (!(i % 7)) {
      week = calendar[i / 7] = []
    }

    week.push(
      // 拷贝时间
      new Date(cursor)
    )

    cursor.setDate(cursor.getDate() + 1)
  }

  return calendar
}

function format(d) {
  return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, 0)}-${d.getDate().toString().padStart(2, 0)}`
}

我们看下执行结果

试试周一开始

目前看来,日期和星期都按我们预想的那样执行了,剩下的就是渲染问题。

渲染日历

其实当我们数据准备好的那一刻,基本上这个日历也就差不多完成了,剩下的是往上面堆功能。

我们只要数据处理好,那我们就可以使用任何框架,甚至在任何支持 Date 对象的 js 环境中运行了,比如在 nodejs 上运行。

所有代码都在这里:完整代码

nodejs >= 12.x 试试:


/**
 * 打印日历
 */
function printCalendar(year, month, firstWeekday) {
  const weekdays = getWeekdays(firstWeekday)
  const calendar = getMonthCalendar(year, month, { weekdays })
  const columns = getWeekHead(weekdays, zh).map((w) => w.abbr)

  const rows = calendar.map((dates) => {
    const row = {}
    columns.forEach((weekday, index) => {
      const date = dates[index]
      if (isCurrentMonth(date)) {
        row[weekday] = date.getDate()
      }
    })
    return row
  })

  function isCurrentMonth(d) {
    return d.getFullYear() === year && d.getMonth() + 1 === month
  }

  const time = `  ${year}-${month.toString().padStart(2, '0')}`
  console.log('')
  console.group(time)
  console.table(rows, columns)
  console.groupEnd(time)
}

打印 2021-01 月,以周日开始的日历

printCalendar(2021, 1, 0)

打印 2021-01 月,以周一开始的日历

printCalendar(2021, 1, 1)

渲染到 html 上

结束

农历和节假日可以通过数据的方式手动维护,不过节气可以维护到组件中。

写一个日历,也是一件比较麻烦的事情,幸好大部分前端用到的日历都不要求 农历24节气节日,不然头发都要多掉好几根。

如果自己维护日历数据的模块,可以试试我发布的包:@zhengxs/calendar-data,欢迎 fork

掘金年度征文 | 2020 与我的技术之路 征文活动正在进行中......