做日程管理功能时,很多人第一反应是找现成的日历组件,但真正决定体验好坏的其实是数据层:日程存在哪里、怎么读写、刷新后会不会丢。如果只是内部工具或者轻量应用,直接用浏览器自带的localStorage就能把持久化问题解决掉,不需要后端数据库,也不需要引入额外的依赖。这篇文章就带你从零实现一个基于原生JavaScript的日程调度器,重点讲清楚数据结构怎么设计、读写怎么封装、渲染和清理怎么做。

为什么选localStorage以及数据结构设计
浏览器的存储方案不止一种,先简单对比一下。sessionStorage的生命周期只有标签页会话,关掉标签数据就没了,明显不适合日程这种需要长期保留的场景。IndexedDB容量大、支持复杂查询,但对于几百条日程数据来说属于杀鸡用牛刀,代码复杂度会明显上升。而localStorage容量一般在5MB左右,同步读写,API简单,对于个人日程管理完全够用。
localStorage只能存字符串,所以日程对象需要通过JSON.stringify序列化后写入,读取时再用JSON.parse还原。为了让数据结构清晰,建议用一个统一的存储键,比如scheduler_events,里面存一个数组,每条日程包含id、标题、日期、时间、描述、是否完成这几个核心字段。
// 单条日程的数据结构
{
id: 'evt_1718000000000_123', // 唯一标识
title: '项目周会', // 日程标题
date: '2025-06-15', // 日期,格式统一为 YYYY-MM-DD
time: '14:00', // 时间,格式统一为 HH:mm
desc: '同步本周进度', // 备注
done: false // 是否完成
}id的生成可以用时间戳加随机数拼接,这样即使同一毫秒创建多条日程也不会冲突。日期和时间统一格式非常重要,后续做分组排序、过期判断都依赖这个约定,如果一开始格式不统一,后面会到处写兼容逻辑,得不偿失。
封装存储层:增删改查一个都不能少
直接在业务代码里到处调localStorage.setItem是糟糕的做法,一旦存储键名或者数据结构要调整,改动会散落在各个文件里。正确的做法是封装一个存储模块,对外暴露增删改查接口,内部统一处理序列化和异常。
const STORE_KEY = 'scheduler_events';
const store = {
// 读取全部日程,存储为空或数据损坏时返回空数组
load() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (!raw) return [];
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch (e) {
console.warn('日程数据解析失败,已重置', e);
return [];
}
},
// 保存整个日程数组
save(list) {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(list));
return true;
} catch (e) {
// 容量超限或隐私模式都会抛异常
console.error('日程保存失败', e);
return false;
}
},
// 新增一条日程
add(event) {
const list = this.load();
list.push(event);
return this.save(list);
},
// 按id删除
remove(id) {
const list = this.load().filter(e => e.id !== id);
return this.save(list);
},
// 按id更新,patch为要合并的字段
update(id, patch) {
const list = this.load().map(e =>
e.id === id ? Object.assign({}, e, patch) : e
);
return this.save(list);
}
};这段代码有几个细节值得注意。一是load里的try-catch,用户可能手动改过localStorage,或者旧版本数据格式不兼容,解析失败时降级返回空数组比直接抛错让整个页面崩掉要稳妥得多。二是save返回布尔值,调用方可以据此提示用户保存是否成功,而不是默默失败。
另一个容易踩的坑是容量问题。localStorage只有约5MB空间,JSON序列化后的中文日程大约每条几百字节,正常使用很难超限,但如果日程里塞了大段备注或者附件base64,就可能触发QuotaExceededError。所以在save里捕获异常是必须的,捕获到之后可以提示用户清理旧日程,或者考虑迁移到IndexedDB。
渲染层:按日期分组并排序展示
存储层就绪后,接下来是把日程渲染出来。最实用的展示方式是按日期分组,同一天内的日程再按时间升序排列。分组可以用一个以日期字符串为键的对象来实现。
function groupByDate(list) {
const map = {};
// 先整体排序:日期升序,同日期内按时间升序
const sorted = list.slice().sort((a, b) => {
const ka = a.date + ' ' + a.time;
const kb = b.date + ' ' + b.time;
return ka < kb ? -1 : ka > kb ? 1 : 0;
});
sorted.forEach(e => {
if (!map[e.date]) map[e.date] = [];
map[e.date].push(e);
});
return map;
}
function render() {
const list = store.load();
const grouped = groupByDate(list);
const container = document.getElementById('eventList');
container.innerHTML = '';
Object.keys(grouped).sort().forEach(date => {
const h3 = document.createElement('h3');
h3.textContent = date;
container.appendChild(h3);
grouped[date].forEach(e => {
const p = document.createElement('p');
p.textContent = e.time + ' ' + e.title + (e.done ? '(已完成)' : '');
p.dataset.id = e.id;
container.appendChild(p);
});
});
}这里用date + ' ' + time拼成字符串再比较,因为格式统一为YYYY-MM-DD HH:mm,字典序恰好等于时间序,就不需要转成Date对象再去比较,性能和代码都更简洁。渲染时统一走一个render函数,任何增删改操作完成后调用它重新绘制列表,逻辑简单不容易出错。
如果日程量大,全量重绘会有性能顾虑,可以改成按id精确更新对应的DOM节点。但对于几百条以内的数据量,全量重绘的开销可以忽略,优先保证代码可读性。
过期清理与提醒机制的实现
日程调度器和普通列表最大的区别在于调度二字:它需要感知时间。两件事必须做,一是清理过期数据,二是到期提醒。
清理策略建议保守一点,只删除完成且超过一定天数的日程,未完成的过期日程保留下来反而有提醒价值。可以在页面加载时执行一次清理:
function cleanExpired(days = 30) {
const limit = Date.now() - days * 24 * 3600 * 1000;
const list = store.load().filter(e => {
const ts = new Date(e.date + 'T' + e.time).getTime();
// 已完成且早于限期的删除,其余保留
return !(e.done && ts < limit);
});
store.save(list);
}到期提醒可以用setInterval每分钟轮询一次,找出当前时间之后五分钟内将要开始的日程触发通知。如果页面可能长时间停留在后台,用document.hidden判断一下可见性,切回前台时立即检查一次,避免后台计时不准的问题。更进一步可以接入浏览器的Notification API实现系统级推送,体验会好很多。
写在最后
这套方案的核心思路其实很简单:把localStorage当成一个只存字符串的数据库,用封装好的存储层隔离细节,用统一的数据格式保证排序和比较的可靠性,再加上清理和提醒让静态列表变成真正的调度器。当数据规模超出localStorage的承载能力时,存储层的好处就体现出来了,只要替换load和save的内部实现,业务代码一行都不用改,可以平滑迁移到IndexedDB甚至后端接口。
JavaScript日程调度器localStorage修改时间:2026-09-11 12:36:43