做前端这几年,我越来越觉得日历这种组件是典型的“看着简单,写起来全是细节”。最近给某内部项目做一个轻量工具面板,里面需要一套系统风格的日历,我没有引入第三方日历库,而是用原生JS从零手写了一个,整体交互和视觉参考了Windows日历。写完之后最大的感受是:这个组件比想象中更适合用来练手,它同时涉及日期计算、网格布局、状态管理和DOM更新,规模又刚好控制在一个文件能装下。这篇文章是系列的第一篇,先把最核心的骨架讲透——日期计算、网格布局、渲染切换和第一版踩过的坑。无论你是想给项目加一个轻量日历,还是想借这个小项目理解日期处理和DOM渲染,这篇都适合你。
1. 先拆解目标:Windows日历到底有哪些“非做不可”的部分
1.1 从界面拆解到功能清单
动手写代码之前,我先把Windows日历应用打开,盯着它看了几分钟,把界面元素一个个拆出来。这一步很多人会跳过,但在我看来,它决定了后面代码的边界。你一眼扫过去,这个日历无非是一个带导航的网格,可一旦拆开,里面至少有这样几层:
- 顶部导航区:左侧是“今天”快捷按钮,中间是当前年月的标题,右侧是前后翻月的箭头按钮。
- 星期行:固定七列,从周一到周日,中文环境默认周一开头。
- 正文网格:每个日期格子里只放一个数字,排列成多行。
- 格子状态:当月的日期是正常颜色,前后月份的日期是淡色;今天的日期有蓝色圆形背景;被选中的日期有蓝色描边;鼠标悬浮的格子有浅灰背景。
把这些界面元素翻译成第一版本要做的功能清单,就是四条:渲染一个指定年月的完整网格、切换前后月份、点击任意日期选中并更新高亮、点击“今天”跳回当前月份。至于后续版本的农历、日程提醒、周视图、年份选择器,都是后话。
我建议你也养成这种习惯:先花十分钟观察参考产品,把看到的每一个视觉状态都写成一条功能项,再对着功能清单开发。很多组件写着写着就跑偏,就是因为一开始脑子里只有“网格”和“高亮”两个模糊概念,没有把“今天、选中、非本月、悬浮”这些状态分别定义清楚。
1.2 为什么这种“小构件”值得用原生JS手写
有现成的组件库不用,非要从零造轮子,是不是有点傻?我详细说说这次选择的理由。第一,项目里只需要一个日历,为了它引入一整个UI库,体积和样式覆盖成本都不划算。第二,现成日历组件的定制空间往往不如自己写的舒服,尤其是“今天高亮、选中描边、非本月淡色”这种细节,改第三方样式往往要写一堆覆盖代码。第三,日历本身是个极好的学习样本,它逼迫你把“日期怎么算”“数据怎么组织”“DOM怎么更新”三个问题都想清楚。
当然,我不反对在大型项目里用成熟组件,但我一直觉得,像日历、轮播、下拉选择器这类经典组件,至少要亲手写一遍。这就像你会用计算器,但考试时还是要会手算一样。写一遍之后,再看那些组件库的源码,你会突然看懂它们为什么那么设计,排查问题时心里也有底。
2. 绕不开的Date对象:日历计算的核心三招
2.1 每月天数:让Date的溢出机制帮你算
日历第一道坎是“某个月到底有多少天”。你当然可以把每个月的天数背下来,再单独处理闰年二月,但那太容易出错了。我用的方法非常省心:
function getDaysInMonth(year, month) { // month 是 0 索引:0 表示 1 月,11 表示 12 月 return new Date(year, month + 1, 0).getDate(); }这里有两个关键点。第一,Date构造函数的month参数接受0到11,但如果你传12,它不会报错,而是自动进位成下一年的1月。第二,日期参数传0,表示“上个月的最后一天”。
代入看一下:我想知道2025年2月有多少天,就调用new Date(2025, 3, 0),因为month+1传了3,那是2025年4月,再取4月的第0天,也就是3月的最后一天,它的getDate()就是31——3月有31天。那2月呢?new Date(2025, 2, 0)是3月第0天,也就是2月最后一天,getDate()返回28或29。闰年完全不用手写判断。
这个技巧的本质是Date对象的“溢出回退”机制:任何超出范围的时间部分都会被自动换算。理解这一点之后,很多日历算法都会变得异常简洁,后面说跨年切换时你还会再次看到它的威力。
2.2 星期偏移:从getDay()换算到“周一开头”
第二个问题是:某月1号是星期几?JavaScript的Date对象提供了一个getDay()方法,返回0到6,但这里有个坑——0表示周日。如果日历按周日开头,直接用它当列偏移就行;但我们参考的Windows日历中文版是周一开头,这就需要一个换算。
我直接用公式:(getDay() + 6) % 7,得到的结果中,周一为0,周二为1,依次类推,周日为6。这个公式不用记,你可以推一遍:JS的getDay()里周一等于1,周一作为第一列应该对齐到0,把1映射成0需要+6再模7,得到(1+6)%7=0;周日getDay()等于0,应该是最后一列6,(0+6)%7=6。每个值都不越界。
下面是完整的换算表,建议收藏:
| getDay() 返回值 | 含义 | 周一开头偏移 |
|---|---|---|
| 0 | 周日 | 6 |
| 1 | 周一 | 0 |
| 2 | 周二 | 1 |
| 3 | 周三 | 2 |
| 4 | 周四 | 3 |
| 5 | 周五 | 4 |
| 6 | 周六 | 5 |
有了这个偏移量,你才能知道第一天前面要空几个格子。比如2025年1月1日是周三,周一开头,偏移量就是2,意味着网格前两格应该放12月30日和12月31日。
2.3 警惕0索引月份:拼日期时最容易翻车的地方
再说一个很多人第一次写都会掉进去的坑:Date对象里月份是0索引的。new Date(2025, 0, 1)是2025年1月1日,new Date(2025, 1, 1)是2月1日。这个设计有几个连锁影响。
第一个影响是,当你显示标题“2025年1月”时,从state里取出的month是0,直接显示会变成“2025年0月”。所以标题渲染必须做month + 1。我通常这样写:
const title = `${year}年${month + 1}月`;第二个影响是,当你手动拼接日期字符串做格式化时,很容易拿到“2025-1-1”这种缺零的字符串。虽然很多场景下没问题,但一旦你用它做字典键、做比较,就会出现“2025-01-01”和“2025-1-1”对不上的糟心事。我的建议是,内部统一用year、month、date三个数字字段存日期,不要在临时变量里拼字符串。
第三个影响是,跨年边界的月份计算要格外小心。比如1月的month是0,它上一个月的month是-1。Date构造函数能接受-1,会自动换成前一年的12月,但如果你在代码里直接用month - 1去访问一个数组,就会踩到undefined。这一点在生成网格数据时尤其重要,我先埋个伏笔,后面专门讲。
3. 42个格子的布局:从偏移量到完整网格数据
3.1 用偏移量倒推“网格起点”
有了偏移量,接下来就是把日期填进一个7列的网格里。我先生成一个从“网格起点”到“网格终点”的线性序列。
假设我们要渲染2025年1月。已知1月有31天,偏移量是2(周三开头,前面空两格)。那网格的起点日期值就是1 - 2 = -1,用循环变量i从0到41,每个格子的“日期数”就是i - offset + 1。这么写出来,前两个格子分别是0和-1,它们不对应真实日期,需要换算成上个月的最后两天。
换算逻辑也很清楚:
- 如果日期数小于1,说明它是上个月的日期,真实日期等于上个月天数加上当前日期数。比如
0那是上个月倒数第1天,-1是倒数第2天。 - 如果日期数大于当月天数,说明它是下个月的日期,直接用日期数减当月天数。
- 其他情况,它就是当月的正常日期。
这套“先算网格位,再回填日期”的思路,比单独维护三个数组要清爽得多。生成网格数据的函数可以这样写:
function generateCalendarData(year, month) { const daysInMonth = getDaysInMonth(year, month); const offset = getFirstDayOffset(year, month); const daysInPrevMonth = getDaysInMonth(year, month - 1); const items = []; for (let i = 0; i < 42; i++) { const diff = i - offset + 1; let date, cellYear, cellMonth, isCurrentMonth; if (diff < 1) { date = daysInPrevMonth + diff; cellYear = month === 0 ? year - 1 : year; cellMonth = month === 0 ? 11 : month - 1; isCurrentMonth = false; } else if (diff > daysInMonth) { date = diff - daysInMonth; cellYear = month === 11 ? year + 1 : year; cellMonth = month === 11 ? 0 : month + 1; isCurrentMonth = false; } else { date = diff; cellYear = year; cellMonth = month; isCurrentMonth = true; } items.push({ date, year: cellYear, month: cellMonth, isCurrentMonth }); } return items; }注意,我对每个格子额外记录了它真实对应的year和month,而不只是date。这个细节太重要了,因为它让后面的“判断今天”“判断选中”都变得无比简单——你永远不需要根据位置反推日期,直接比较三个字段就行。
3.2 固定6行还是动态行数:我为什么选42格
日历的网格行数有两种方案:动态计算需要几行,比如4行、5行或6行;固定统一生成42格,也就是6行。这两种方案各有取舍,我列个表对比一下:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 动态行数 | 高度紧凑,没有多余的空白 | 切换月份时容器高度会跳变;需要额外的行数计算逻辑 |
| 固定42格 | 容器高度恒定,切换月份不跳动;算法简单,循环固定42次 | 某些月份最后一行是下个月的日期,底部看起来有一排淡色数字 |
我这次选固定42格,主要原因是稳定性。你想象一下,切换月份的时候,如果这个月是4行,下个月突然变成6行,所有内容上下跳一下,视觉上非常廉价。Windows日历本身也是固定高度,每行不会因为月份变化而增删。40多格的DOM数量对现代浏览器来说完全可以忽略,性能上没有压力。
那为什么不选35个格子即5行?因为有些月跨度会达到6行。如果固定5行,那部分月份的最后一行会被直接截掉,用户看不到完整的日期,这是不可接受的。
3.3 生成数据时顺手记录“真实年月”,避免判断混乱
我在第一版里犯过一个错:网格数据只存了一个date数值,然后判断“今天”时用位置瞎猜。结果就是,当天的判断总是差一天,或者选中非本月日期时状态错乱。后来我把每个格子所对应的真实year、month、date三个字段都存下来,所有判断都基于这三个字段。
这样一来,判断某个格子是不是今天只需要一次比较:
function isSameDay(year, month, date, target) { return year === target.year && month === target.month && date === target.date; }判断点击格子时,我也可以直接读出它的真实年月,然后从容决定要不要跳转月份。类似“这个格子显示的是上个月的30号,用户点了它,状态应该切到上个月”这种逻辑,就不再需要任何复杂的位置换算。数据自描述,这是很多前端小工具提升可维护性的关键。
4. 把骨架搭成Windows的样子:HTML和CSS的实现细节
4.1 组件结构:三块区域的语义化拆解
HTML结构我分成三块:头部导航、星期行、日期网格。星期行和日期网格必须分开,因为它们宽度虽然对齐,但语义不同,混在一起会让样式和事件处理都变麻烦。
<div class="pkg-cal" id="calendar"> <div class="pkg-cal-header"> <button class="cal-today-btn">.pkg-cal { width: 320px; font-family: "Segoe UI", "Microsoft YaHei", sans-serif; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff; padding: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); user-select: none; } .cal-weekdays, .cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); } .cal-weekdays span { text-align: center; font-size: 12px; color: #707070; padding: 8px 0; } .cal-grid { margin-top: 4px; } .cal-cell { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #1b1b1b; cursor: pointer; border-radius: 50%; transition: background-color 0.15s ease; } .cal-cell:hover { background-color: #f0f0f0; } .cal-cell.is-muted { color: #bfbfbf; } .cal-cell.is-today { background-color: #0067c0; color: #fff; font-weight: 600; } .cal-cell.is-selected { box-shadow: inset 0 0 0 2px #0067c0; color: #1b1b1b; } .cal-cell.is-today.is-selected { box-shadow: inset 0 0 0 2px #fff; }这里最值得说清楚的,是四种状态的层叠关系。一开始我只写了简单规则,结果选中今天的格子时,蓝色背景和蓝色边框叠在一起看不太清。我的处理思路是:今天用实心蓝色背景,选中态用内描边,两者不争抢同一个视觉通道。如果同时成立,今天的背景优先级最高,描边跟随背景色走白色。
非本月的日期通过is-muted类降低对比度,但保留hover效果,这样才能明确告诉用户“这些格子也可以点”,Windows日历也是这样处理的。
4.3 尺寸和交互细节:圆形高亮、留白、hover反馈
在还原Windows日历观感时,有三个交互细节值得单独说。第一个是圆形的使用:Windows日历把今天和选中态都做成正圆形,而不是撑满整个格子。所以我把格子本身设为flex居中,颜色和描边都带border-radius: 50%。这样悬浮的浅灰背景也会是圆形,视觉上更统一。
第二个是格子的hover反馈。Windows日历的hover背景是一个非常浅的灰色,没有过渡时间或者过渡极短。我加了一个0.15秒的ease过渡,让鼠标滑过时稍微柔和一点,但又不会显得迟缓。
第三个是整体的留白节奏。头部按钮之间、星期行与网格之间,都要有足够的间距。我踩过一个坑:一开始把padding压得很小,整个组件显得很挤,后来把外层padding、星期行上下间距、网格间距都放宽到8到12px,看起来才舒展。日历组件是纯信息陈列,留白就是它的呼吸感,不要为了省空间把间距压掉。
5. 从状态到DOM:渲染与月份切换的完整闭环
5.1 状态对象设计:视图和选择分开存
日历的逻辑核心是一个状态对象。我把它定义为两部分:当前视图的年月,以及用户选中的具体日期。
const state = { year: new Date().getFullYear(), month: new Date().getMonth(), selectedDate: { year: new Date().getFullYear(), month: new Date().getMonth(), date: new Date().getDate() } };这里的重点是:视图状态和选中状态是两回事。切换月份并没有改变用户之前选中的那个日期,只是视图跳走了。反过来,用户点击一个日期,会同时改选中状态和视图状态。一开始如果把这两个搅在一起,你会发现“点今天”和“翻月”这两个操作会互相干扰。
初始化时,我默认把今天设为选中,这样用户打开组件的瞬间,就能看到今天高亮,同时“今天”按钮也有意义。
5.2 全量重绘与事件代理:42个节点怎么更新最省心
渲染策略上,我选择了最简单的全量重绘,而不是局部更新。每个月切换后重新跑一次render函数,把网格的innerHTML整体替换掉。有人可能会说这样性能差,但40多个div的创建和替换,在现在的主流浏览器上根本感知不到。
render函数的核心逻辑分三步:读取状态、调用generateCalendarData生成网格数据、拼装HTML插入DOM。我把拼装cell的逻辑收敛到一个模板函数里,避免 render 函数本身变得臃肿:
function render() { const { year, month } = state; const title = `${year}年${month + 1}月`; titleEl.textContent = title; const items = generateCalendarData(year, month); const html = items.map((item, index) => { const classes = ['cal-cell']; if (!item.isCurrentMonth) classes.push('is-muted'); if (isToday(item.year, item.month, item.date)) classes.push('is-today'); if (isSameDate(item, state.selectedDate)) classes.push('is-selected'); return `<div class="${classes.join(' ')}" >gridEl.addEventListener('click', (e) => { const cell = e.target.closest('.cal-cell'); if (!cell) return; const year = Number(cell.dataset.year); const month = Number(cell.dataset.month); const date = Number(cell.dataset.date); state.selectedDate = { year, month, date }; if (month !== state.month || year !== state.year) { state.month = month; state.year = year; } render(); });点击非本月日期时,我会马上把视图切过去。这一条是模仿Windows日历的行为:你不会停留在原月看到一个淡色日期被选中,而是直接跳到那个月去。
5.3 跨年切换:利用Date构造函数的自动进位
月份切换函数是状态闭环里最优雅的一部分。我只需要创建一个临时Date对象,把目标月份加进去,然后让Date自己去处理进位和退位:
function changeMonth(delta) { const date = new Date(state.year, state.month + delta, 1); state.year = date.getFullYear(); state.month = date.getMonth(); render(); }结合第2节讲的Date溢出机制,这段代码能同时处理四种情况:从12月切到1月、从1月切到12月、普通月份递增、普通月份递减。不需要手写任何分支判断。
这里我想多说一句:工具函数之所以好用,是因为它把“不变量”封装住了。在这个组件里,不变量是“year和month永远表示真实存在的年月”,临时Date对象负责保证这个不变量,我们的状态就永远不会出现“month=12”这种非法视图。
5.4 选中锁存与“今天”按钮:三个字段全相等才算同一天
“今天”按钮的逻辑也很直接,但不只是跳到当月就完事。我让它在跳回当月的同时,把选中状态重置到今天,这样用户随时可以一键回到初始状态:
function goToToday() { const now = new Date(); state.year = now.getFullYear(); state.month = now.getMonth(); state.selectedDate = { year: now.getFullYear(), month: now.getMonth(), date: now.getDate() }; render(); }判断格子里某天是否是今天的函数,我要求年月日三个字段全部相等。只比月份和日期,忽略年份,会在跨年时出错;只比日期,显然更不行。三个字段全等看起来笨,但它最可靠,而且可读性极强。
到这里,第一版日历的完整闭环已经跑通了:初始化渲染当前月,点击箭头切换月份,点击日期选中并跳转,点击今天回到当前月。
6. 第一版实测记录:三个坑和一个预告
6.1 坑一:循环里复用一个Date对象,返回了“幽灵数据”
第一版我为了省事,在生成网格数据时复用了同一个Date实例,想着每次改一下setDate再getDay就行。结果发现,某些日期会出现偏移错位,肉眼看起来就是“这个月1号怎么跑到周日的格子里了”。
原因其实不复杂:Date对象是可变对象,你在一轮循环里对它执行setDate、getDay,得到的已经是上一次操作后被Date内部的进位逻辑修正过的结果,并不是你想象中的那个日期。getDay()返回的是当前真实星期,而你的setDate参数可能因为溢出被悄悄改变了。
从那以后,我在日历组件里生成日期数据时,一律使用数字字段计算,不反复修改同一个Date实例。只有在需要计算“某月第一天星期几”和“某月总天数”时才创建Date对象,用完即弃。
6.2 坑二:网格数据只有date没有真实年月,今天和选中全算错
第一版的网格数据只存了{ date, isCurrentMonth },我当时觉得,判断今天只要看date是不是等于今天的日号不就行了?结果非本月日期也显示出了今天样式,因为上个月也有一个“15号”。
这个问题在测试时足够明显,但我希望你能从根上避开:网格里显示的“15号”有两种,当月15号是真实15号,上月15号是“显示为15号但真实日期是上月某一天”。如果你不记录真实年月,就只能靠位置和isCurrentMonth反推,反推逻辑一旦复杂起来,后续加需求就全是补丁。
所以,每个格子的数据至少是{ year, month, date, isCurrentMonth }四个字段。多花一点点空间,换来的是所有判断逻辑的直白和安全。
6.3 坑三:周一开头的公式,我挨个验了一遍才敢用
网上很多代码直接写(getDay() + 6) % 7,我第一次用的时候心里其实打鼓:加6为什么不是加7?模7为什么不是模6?我建议你拿到这个公式后,把0到6七个值代进去手算一遍,确认它输出的确实是“周一0、周二1、周三2、周四3、周五4、周六5、周日6”。
这个验证花不了两分钟,但能帮你建立对代码的信任感。我自己后来测试所有月份的时候,还专门写了一个断言循环,把一年里每月1号的偏移量打出来检查,确保没有漏掉某个月的边界情况。
| 测试月份 | 每月1号 getDay() | 周一开头偏移 |
|---|---|---|
| 2025年1月 | 3(周三) | 2 |
| 2025年2月 | 6(周六) | 5 |
| 2025年3月 | 6(周六) | 5 |
| 2025年4月 | 2(周二) | 1 |
6.4 第二篇预告:强化版交互、周视图和事件提醒
这篇先把第一版完整跑了,DOM渲染、月份切换、今天高亮、选中描边都正常。如果你跟着写到这里,你已经拥有一个足够干净、够用的日历组件了。后续我打算在第二篇里做三件事:第一,把点击年份标题改成弹出年份选择器,也就是Windows日历里点击“2025年1月”会出现的那个视图;第二,增加周视图切换,默认月视图和周视图通过头部按钮互切;第三,做一个简易的事件标记系统,在日期格子上渲染小圆点,并把事件数据存到本地。
坦白讲,第一版写完后的成就感是最强的,因为你会发现,一切复杂的交互都是从这颗小骨架生长出来的。我自己的体会是:像日历这样的小组件,恰恰值得反复打磨,第一版求稳、求逻辑清楚,第二版再去追交互和扩展。下一篇我们接着做。