原生JavaScript打造日历记事本:日期计算与localStorage持久化

发布时间:2026/9/8 2:29:20
原生JavaScript打造日历记事本:日期计算与localStorage持久化 简介日历记事本是一款适合Java初学者与课程设计场景的GUI小项目源码包将日历面板和记事本功能整合在同一界面左侧日历支持按年、按月前后翻动用户用鼠标单击任意日期即可添加或查看当天的记录若该日期已有内容系统会弹出对话框提示并询问是否显示。资源包共二十八个文件包括五个JAVA源文件、九个编译后的class字节码文件、十二张程序运行截图另附一份课程设计文档和一份文本说明整体大小约四百零五KB体量精简但结构完整便于逐文件对照学习。源码围绕日历显示、年份月份切换、日期点击事件和记事内容存取等核心逻辑展开配合运行截图可直观理解Swing界面布局与事件监听机制课程设计文档和文本说明中还包含作者的设计感想、实现思路与使用说明能帮助读者快速上手并完成自己的课设或练手项目。当前已有1004人学习或下载适合正在准备Java课程设计、需要参考完整源码与文档的开发者。 前两天我把手头一个一直想做的“日历记事本”小工具写完了。说白了这个项目就是一个带日历视图的本地记事本左边是月历右边是选中日期的记事编辑区点哪一天就能写哪天的记录还能顺手记一条当天的感想。整个项目不依赖任何框架纯 HTML CSS JavaScript源码量不算大但逻辑链路挺完整从日期计算、数据持久化到界面状态同步都覆盖到了非常适合当练手项目也适合放在自己工具箱里日常用。下面我把整个实现思路、核心源代码和踩过的坑一条条拆出来聊。1. 项目思路拆解这个工具到底该做成什么样1.1 需求场景与核心功能写日记或做日程记录这件事最常见的一个痛点是“找不到对应日期的入口”。手机备忘录是线性的翻记录全靠滚动而日历天生就是二维的时间索引每一天都有一个固定坐标。把记事本和日历绑定本质上就是让“日期”成为所有记录的索引键用户一眼就能看出这个月哪几天有记录、哪几天是空的。从实际使用场景出发我梳理出四个核心需求日历要能按月切换并且默认定位到今天。点击某一天后右侧面板能加载对应的记事内容。支持新增、编辑、删除某一天的记录。每条记录除了正文还要有一个“感想/心情”字段记录当天的主观感受。删除记录这件事有个细节值得说一下我在设计时没有把“删除”做成独立的按钮而是采用了一种更自然的交互——如果用户把正文和感想都清空了再点击保存就视为删除该日期的记录。这样做的好处是界面干净而且避免误删后找不到入口恢复的尴尬。后来实际用下来这个设计确实比单独放一个删除按钮更顺手。1.2 技术选型为什么要用原生 JS 而不是框架这个项目我一开始就决定不用 Vue 或 React理由很简单这是一个单页面、单人的轻量工具不需要组件复用、不需要状态管理库、不需要构建流程。用原生 JavaScript 反而能更清晰地看到整个数据流状态在哪儿、渲染逻辑在哪儿、事件绑定在哪儿所有代码一目了然。对于想通过项目巩固 JS 基础的同学来说这种“裸写”方式的价值远高于套一层框架。数据存储方面选用了 localStorage。很多新手第一反应是为什么不用数据库道理很直接这是本地个人工具数据量就是每天几条文本localStorage 的 5MB 容量绰绰有余而且数据以 JSON 字符串形式存在浏览器里导出备份、迁移到另一台电脑都很方便。当然 localStorage 也有它的坑比如只能存字符串、同步读取、容易被误清这些我在第 4 节会详细说处理方案。2. 页面结构与 UI 设计2.1 HTML 骨架日历区和记事区的组织方式页面结构我采用了左右双栏布局左边日历右边记事这样符合阅读习惯不需要任何路由跳转。先看核心的 HTML 结构div classapp-container div classcalendar-panel div idcalendar/div /div div classnote-panel div classnote-header span iddateLabel请选择日期/span /div textarea idnoteText placeholder写下今天的事.../textarea div classfeeling-row input typetext idfeelingInput placeholder今天的感想 / 心情 / /div div classnote-actions button idsaveBtn onclicksaveNote()保存记录/button button classclear-btn onclickclearEditor()清空/button /div div classnote-tip清空内容后保存即可删除当天记录/div /div /div整个结构里日历区域是一个空的div内容完全由 JavaScript 渲染生成。这不是偷懒而是因为日历网格的结构需要根据每个月的天数和首日星期动态变化与其在 HTML 里写死再反复修改 DOM不如直接在 JS 里拼字符串一次性生成。textarea 和感想输入框则是静态节点在选中日期时填充数据。这里有一个很容易被忽略的点textarea 和 input 的值在切换日期后一定要记得重置。我最初实现时只关注了“选中日期后加载对应数据”却忘了在切换月份时清空输入框结果用户看得见日期变了但右侧还留着上一天的记录非常容易造成混淆。所以每次切月、每次选日都要主动调用一次刷新面板的逻辑。2.2 样式与布局双栏自适应的实现思路样式层面我没有过度设计目标就两条信息层级清楚移动端勉强能用。布局上采用 flex 双栏默认情况下日历区和记事区按 1:1 分配宽度窄屏时则纵向堆叠。.app-container { display: flex; gap: 20px; max-width: 1000px; margin: 40px auto; padding: 0 20px; } .calendar-panel { flex: 1; background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } .note-panel { flex: 1; background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } media (max-width: 700px) { .app-container { flex-direction: column; } }日历网格本身用 CSS Grid 实现7 列正好对应一周七天。每个日期格子是正方形鼠标移上去有个轻微的阴影变化有记录的日期在右下角加一个小圆点作标记选中日期则整块高亮。这些视觉反馈看似是小事实际使用中非常重要——它们是用户判断“有没有记录”“选中了哪一天”的直接依据。3. 日历渲染核心逻辑3.1 当月天数计算与首日定位日历渲染最底层的问题只有一个这个月的格子到底怎么排。这需要两个关键数据当月总天数和当月第一天是星期几。这两个数据在 JavaScript 里都能用 Date 对象直接算出来function getDaysInMonth(year, month) { // month 是 0 起始所以 month 1 的 0 号就是下个月 1 号 return new Date(year, month 1, 0).getDate(); } function getFirstDayWeekday(year, month) { // getDay(): 0 是周日1 是周一以此类推 return new Date(year, month, 1).getDay(); }这里有个非常容易踩坑的细节new Date(year, month, 0)中的 day 参数传 0 时Date 对象会自动回退到上个月的最后一天。比如new Date(2024, 12, 0)得到的是 2024 年 12 月 31 日因为 2024 年 12 月的“第 0 天”就是 11 月最后一天。利用这个特性求当月天数是最简洁的写法但新手往往会被“month 从 0 开始”搞晕记成普通的自然月导致日历整体偏移一个月。拿到首日星期后还需要确定日历第一行前面要空几个格子。如果第一天是周三getDay() 返回 3那么 1 号前就要先展示上周日到周二的日期。我选择把上个月的日期直接渲染成灰色格子补齐这样六行网格永远是完整的视觉上更整齐。3.2 日历网格渲染与日期状态标记渲染逻辑我封装成一个renderCalendar()函数每次月份变化、选择日期、保存记录后都会重新调用它来刷新界面。核心代码如下const state { year: new Date().getFullYear(), month: new Date().getMonth(), selectedDate: null }; function renderCalendar() { const year state.year; const month state.month; const daysInMonth getDaysInMonth(year, month); const startWeekday getFirstDayWeekday(year, month); // 上个月总天数用来补齐前置空格 const prevMonthDays month 0 ? getDaysInMonth(year - 1, 11) : getDaysInMonth(year, month - 1); const today new Date(); const todayStr ${today.getFullYear()}-${String(today.getMonth() 1).padStart(2, 0)}-${String(today.getDate()).padStart(2, 0)}; let html ; html div classcalendar-header button onclickchangeMonth(-1)‹/button span${year} 年 ${month 1} 月/span button onclickchangeMonth(1)›/button /div; html div classweekdays span日/spanspan一/spanspan二/spanspan三/span span四/spanspan五/spanspan六/span /div; html div classdays-grid; // 补齐上个月的日期 for (let i 0; i startWeekday; i) { html div classday empty${prevMonthDays - startWeekday 1 i}/div; } // 渲染当月 for (let d 1; d daysInMonth; d) { const dateStr ${year}-${String(month 1).padStart(2, 0)}-${String(d).padStart(2, 0)}; const allNotes Store.getAll(); const hasNote allNotes[dateStr] ? has-note : ; const isToday dateStr todayStr ? today : ; const isSelected state.selectedDate dateStr ? selected : ; html div classday${hasNote}${isToday}${isSelected}>const STORAGE_KEY calendarNotes; const Store { getAll() { const raw localStorage.getItem(STORAGE_KEY); if (!raw) return {}; try { return JSON.parse(raw); } catch (e) { console.error(存储数据解析失败已重置为空对象, e); return {}; } }, save(all) { localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); }, getByDate(dateStr) { return this.getAll()[dateStr] || null; }, setByDate(dateStr, note) { const all this.getAll(); all[dateStr] note; this.save(all); }, removeByDate(dateStr) { const all this.getAll(); delete all[dateStr]; this.save(all); } };这里特别要讲一下getAll()里的 try-catch。localStorage 中的数据理论上应该始终是合法 JSON但实际使用中有两种情况会破坏它一是用户在浏览器控制台手动修改了存储内容二是上一次程序崩溃时写入了半个字符串。如果解析失败直接让程序抛错整个日历都会白屏。兜底返回空对象至少能让页面正常渲染这个容错逻辑对任何用 localStorage 的项目都适用。数据的存储结构是{ 2024-12-05: { text: ..., feeling: ..., updatedAt: ... } }。用日期字符串做 key查询一天的记录就是一次直接读取完全不需要遍历简单高效。with that said这个结构也有一个代价——无法按关键词搜索全部记录后续如果要加搜索功能就得改成数组结构。这是我在设计初就接受的取舍。4.2 记事保存、编辑与感想联动保存逻辑的核心是区分“新增/更新”和“删除”两种情况。用户点击保存时先读取正文和感想两个字段如果都为空调用removeByDate删除该日期记录只要有任意一个字段非空就写入对象并更新时间戳。function saveNote() { if (!state.selectedDate) { alert(请先选择一个日期); return; } const text document.getElementById(noteText).value.trim(); const feeling document.getElementById(feelingInput).value.trim(); if (!text !feeling) { Store.removeByDate(state.selectedDate); alert(记录已删除); } else { Store.setByDate(state.selectedDate, { text: text, feeling: feeling, updatedAt: new Date().toISOString() }); alert(已保存); } renderCalendar(); }感想字段我设计成一个单独的输入框而不是塞进正文里。原因很简单正文记录的是客观事件感想记录的是主观情绪两者混写在一条文本里日后回顾时要从头读一遍才能找到感想部分。分开存之后页面展示时可以非常自然地在正文下方用一个浅色区块显示“当天感想”信息呈现的层次感会好很多。选定日期时加载数据的函数selectDate也需要和渲染逻辑配合好function selectDate(dateStr, day) { state.selectedDate dateStr; document.getElementById(dateLabel).textContent ${state.year} 年 ${state.month 1} 月 ${day} 日; const note Store.getByDate(dateStr); document.getElementById(noteText).value note ? note.text : ; document.getElementById(feelingInput).value note ? note.feeling : ; renderCalendar(); }注意selectDate里先更新了输入框内容再调用renderCalendar()刷新日历的选中状态。这个顺序不能反过来否则renderCalendar内部重新生成的 DOM 不会影响右侧面板但如果你在渲染后再读取输入框就会拿到旧值。我在一开始就踩过这个顺序问题后来形成了统一习惯数据更新永远在界面刷新之前。5. 细节打磨与体验优化5.1 切换月份时保留已选日期初始版本里用户跨月浏览日历后如果之前选中过某一天右侧面板不会自动清空。这会导致一种很尴尬的视觉状态日历高亮停在 12 月 5 日但右侧记事项还停留着 11 月 20 日的内容。处理方式是在切换月份的函数里主动重置选中状态function changeMonth(delta) { state.month delta; if (state.month 0) { state.month 11; state.year--; } else if (state.month 11) { state.month 0; state.year; } state.selectedDate null; document.getElementById(dateLabel).textContent 请选择日期; document.getElementById(noteText).value ; document.getElementById(feelingInput).value ; renderCalendar(); }这个逻辑其实非常直白月份边界判断、重置状态、清空面板、重绘日历。但很多人容易漏掉第二步“重置 selectedDate”导致跨月后唯一选中态残留。把这个细节单独拎出来说是因为它在实际使用中直接影响用户对“当前选中项”的判断。5.2 空状态处理与首次使用引导首次打开页面时localStorage 里没有数据日历全月无圆点右侧面板也是一片空白。这是正常的但对新用户来说会有点不知所措。我在 dateLabel 区域默认显示“请选择日期”输入框的 placeholder 也做了引导性提示“写下今天的事...”“今天的感想 / 心情”。另外在数据为空时渲染日期格子也要注意类名的处理。Store.getAll()返回空对象时allNotes[dateStr]是 undefined自然也不会匹配到has-note类所以空数据状态不需要额外判断这得益于前面“用日期做 key”的存储结构。还有一个细节是跨年渲染。在renderCalendar里补上个月的日期时我只用了数字去展示天数没有处理“点击灰色日期跳转月份”的逻辑。如果你希望点灰色日期能自动切到上个月或下个月需要额外绑定事件。我在第一版就没做这个功能因为窄屏下用户通常更习惯用左上角的箭头按钮切换月份这个取舍可以根据个人喜好调整。6. 常见问题排查与避坑实录6.1 JSON 解析异常导致页面白屏症状是页面加载后日历区域什么都没有打开控制台能看到SyntaxError: Unexpected token之类的报错。原因通常是 localStorage 里的内容被手动修改或上一次写入时浏览器异常退出。解决方法就是我在 Store 封装里加的 try-catch解析失败时返回{}保住页面可用。另外建议每次再setItem之前可以先在控制台打印一下JSON.stringify(all)确认序列化没有问题。6.2 日历整体偏移一天或一个月这是新手最容易犯的典型错误。常见原因有两个错误类型原因正确写法月份偏移getMonth() 返回 0-11直接使用导致 12 月变成 1 月展示时用month 1存储用原值天数多算new Date(year, month, 0)用的是 0 号而写成 1 号day 参数传 0 表示上个月最后一天传 1 才是当月第一天调试这个问题最直接的办法是写一段测试代码打印new Date(year, month, 1).toString()看输出的日期对不对。如果输出的是当月 1 号且星期正确那问题基本都在渲染函数里。6.3 选中样式不消失每次点击日期后新选中的格子有高亮背景但旧的格子还保留着选中样式。根因是旧的.selected类没有被移除。解决思路有两种一是每次渲染前先清空所有格子的选中类二是像我的代码一样用state.selectedDate做唯一判断源渲染时只有匹配的格子才加上selected类。第二种方案明显更可靠因为它保证样式始终和数据状态同步绝不会出现“界面显示的选中项和实际记录不一致”的问题。6.4 保存后文本有空白换行textarea 中用户输入的换行会被原样保存但 HTML 默认渲染多个空格和换行会折叠。如果你在展示时直接插入文本会发现换行“消失”了。我的处理方案是保存前做trim()去掉首尾空白同时展示时用white-space: pre-wrapCSS 属性保留换行这样既能防止误删记录时的空白判断失误又能保留正文里的格式。7. 实操心得与扩展方向整个项目从构思到写完大概花了两个晚上写完自己用了一个多星期最大的感受是这类小工具的价值不在于功能多丰富而在于“打开就能用、用完不用管”。没有登录、没有同步、没有推送反而让我更愿意每天点开记录几笔。对我个人来说这个项目还验证了一个观点——原生 JavaScript 做小工具完全是够用的框架不是必需品清晰的数据设计才是。这个工具后续如果要扩展我给自己列了几个方向一是加一个按月份的记录列表方便快速翻阅本月所有记事二是支持导出 JSON 文件做备份防止浏览器清理缓存导致数据丢失三是给感想字段加上颜色标签比如开心绿色、平静蓝色、低落橙色这样月视图上会更直观。读者拿到源码后也可以根据自己的习惯往这几个方向改代码结构足够简单改起来不会有太多负担。本文还有配套的精品资源点击获取