被广告逼疯后自建日历:本地优先PWA的无广告日历开发实践
大概是去年秋天的事。那段时间我手机里装的免费日历App已经进化到了一种让人哭笑不得的状态打开月视图上下各有一条横幅广告偶尔还会从底部弹出一个全屏页推荐什么炒股课程想给周五的会议加个提醒要先过一遍“精品推荐”列表更别提同步账号被要求绑定手机号明明只是一个看日程的功能愣是搞得像在注册一个社交平台。某天我盯着那个广告位愣了半分钟突然意识到一个很简单的道理日历这个工具我不需要它给我推荐任何东西我只需要它诚实地告诉我这周还剩几个可用时段。既然商业产品做不到这点那就自己写一个。坦白说我并不是那种看什么都想自己造轮子的开发者。但日历恰好是个例外它的核心逻辑足够经典数据结构足够清晰而且市面上真正能做到“本地优先、无广告、数据完全自控”的选择实在不多。这篇文章会把我的完整思路、技术选型、核心实现方案和一些踩过的坑写出来给同样被日历广告困扰、又恰好有点编程基础的朋友参考。哪怕你完全不打算写代码看完也能明白一个好用的日历到底该长什么样。1. 商业日历的三笔“隐性成本”广告只是表面1.1 我到底在抱怨什么大多数人提到“烦人的日历App”第一反应是弹窗广告、开屏推广、各种夸大的积分任务。但我真正介意的不是广告本身而是广告背后那套商业逻辑当一个应用免费提供日历服务你的日程数据就是它的收入来源之一。日历数据可能是手机里最“诚实”的数据之一。它记录了你几点起床、周末常去哪家医院、孩子的兴趣班在周三还是周五、下周二跟哪个客户开会。这些信息单独看没什么但累积起来就是一个极其精准的日常生活画像。商业日历App通常不会明说这些数据去向了哪里只会用“云同步体验更佳”这类话术让你心甘情愿把数据上传。我并不是主张所有人都要极端保护隐私但至少应该让用户有得选我可以不登录、不联网也照样正常使用日历的全部核心功能。广告和隐私之外还有一层隐形成本功能冗余。一个只需要记录日程和调用提醒的日历被塞进了社交动态、运动打卡、星座运势、购物频道。每次打开App都在跟这些无关功能做心理博弈这种注意力损耗比流量费贵多了。1.2 自己写的边界在哪里要说明的是自己写日历并不是对所有人都划算。如果你需要的是多人共享协作、团队会议室预定或者跟同事保持精确到分钟的日程对齐老老实实用商业产品更省事。自建方案适合的场景非常明确个人自用、数据敏感、功能边界清晰、你能接受一定程度的“糙”。我给自己划了几条判断标准不只一个人用放弃自建或另做共享方案。需要多人实时协作编辑日程放弃自建。对跨设备同步要求极高且不能接受手动操作认真评估后再决定。接受“周更新一次功能按需迭代”的节奏适合自建。我的实际情况恰好卡在“个人使用、跨设备要求不高、对隐私敏感”这个区间所以决定动手。2. 需求收敛从“想要”到“需要”的功能清单2.1 先定主场景写代码之前我没有急着搭界面而是先列了一组问题我每天打开日历最常做什么月视图翻日子记一个临时会议查看本周空闲时段偶尔查一下农历节气。就这么简单。真正高频的操作其实不超过五个而商业日历App用几十个功能来包装这五个操作本质上是把核心功能稀释了。所以我的第一版产品定义非常粗暴一个以月视图为主、能快速添加事件的本地日历。不支持多人协作不搞复杂权限不做社交不接任何第三方广告SDK。2.2 功能清单与优先级我按“必需、重要、加分”三档列了需求表后期开发基本就是照着这个表逐项推进优先级功能说明P1必需月视图浏览一眼看清一个月结构支持切换月份P1必需事件增删改标题、日期、时间、备注支持编辑和删除P1必需农历和法定节假日个人习惯看农历频率高于公历P2重要待办事项简单的勾选清单不用像专业GTD那么复杂P2重要提醒通知到点弹通知替代手机系统日历的提醒P2重要ICS导入导出方便从旧日历迁移数据也方便备份P3加分本地搜索按标题关键词检索历史事件P3加分跨设备同步后期再做不进入第一版这里面有一个关键取舍P1里没有“周视图”和“日视图”。我知道市面日历都有三种视图但对我的使用习惯来说月视图已经覆盖了95%的查询需求。省掉周视图和日视图让第一版的界面逻辑简单了一个量级也让我把精力集中到了更有意思的日历计算上。2.3 设定产品边界不做什么需求收敛里最难的部分不是选功能而是明确不做什么。我给自己写了两条铁律不收集任何使用数据不上报任何日志。不做账号体系不搞云端同步所有数据只存在本地。这两条铁律直接决定了后面的技术选型。3. 技术选型纯前端PWA加本地存储为什么这个组合最省心3.1 为什么拒绝后端对一个个人项目来说后端是最大的维护负担。你的日历数据本身不过几百KB根本不需要一台服务器在中间传话。一旦引入了数据库、用户体系、部署运维这个项目就从“一个周末能写完的工具”膨胀成了“一个长期伺候的系统”那时候就不是我写日历而是日历在写我了。所以我选择了典型的 local-first 架构数据全部保存在浏览器本地应用本身是一个离线优先的PWA。哪怕没有网络打开就是全部功能。所谓“同步”不是实时云端同步而是通过ICS文件导入导出手动搬运数据。3.2 技术栈一览下表中是我最终采用的方案不是唯一答案但对我这种“个人自用、快速迭代、不想维护后端”的场景来说比较顺手层级选型理由语言TypeScript日历数据涉及多种日期类型和状态类型安全能省掉大量低级bug构建Vite启动快、配置简单Dev体验舒服UI框架原生DOM渲染单页小应用硬上React/Vue属于杀鸡用牛刀本地存储IndexedDB能存结构化数据支持索引量级远超需求农历计算lunar-javascript开源库成熟度高没必要自己算农历日期处理自己写核心逻辑日历核心需要精准控制现成库反而容易在细节上不透明3.3 数据模型设计事件数据结构我设计了三层分别是CalendarEvent事件模板、Occurrence事件实例、TodoItem待办。核心是事件模板与实例分离这跟后面处理重复事件直接相关。interface CalendarEvent { id: string; // 唯一ID用 crypto.randomUUID() title: string; // 事件标题 date: string; // 本地日期格式 YYYY-MM-DD time?: string; // 可选格式 HH:mm endDate?: string; // 可选结束日期 notes?: string; // 备注 isRecurring: boolean; // 是否重复 recurrenceRule?: string; // 重复规则简化版RRULE color?: string; createdAt: number; updatedAt: number; }存储上我用了 IndexedDB 的一个对象仓库按日期建立索引。查询某个月的事件时只需要索引范围取数性能上没有压力。这里有一个细节所有日期统一用本地日期字符串保存不转UTC不存时间戳。因为日历应用要的是“这一天的日历单元格”而不是“某个精确的时点”一旦混入时区转换很容易出现“日期没变但值变了”的诡异问题。具体到代码里我封装了一个calendarStore模块// 简单封装 IndexedDB 的增删改查 const dbName my-calendar; const storeName events; async function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(dbName, 1); request.onupgradeneeded () { const db request.result; const store db.createObjectStore(storeName, { keyPath: id }); store.createIndex(date, date, { unique: false }); }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } async function addEvent(event: CalendarEvent) { const db await openDB(); return new Promise((resolve, reject) { const tx db.transaction(storeName, readwrite); tx.objectStore(storeName).put(event); tx.oncomplete () resolve(); tx.onerror () reject(tx.error); }); }4. 日历核心机制日期计算、重复事件与农历的取舍4.1 日期计算的四个边界日历应用的第一个技术难点绝不是界面而是“正确算出每一天属于哪一格”。我在第一版里就踩了四个很典型的坑逐个说一下。第一个是闰年规则。很多人只知道“四年一闰”但完整规则是普通年份能被4整除且不能被100整除或者能被400整除才是闰年。写成代码很短function isLeapYear(year: number): boolean { return (year % 4 0 year % 100 ! 0) || year % 400 0; }这个函数是月天数表的基础。二月份的天数不是写死的28而是要根据年份动态决定。第二个是每月天数的边界。公历一年中月份天数分布其实没规律最稳妥的做法是维护一个月天数对照表然后对2月单独处理function getDaysInMonth(year: number, month: number): number { const monthDays [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if (month 1 isLeapYear(year)) return 29; return monthDays[month]; }第三个是“一周从哪一天开始”。JS 的Date.getDay()返回0表示周日但我的日历默认从周一开始排这意味着要把返回值做一个映射转换// 转换为周一为基准的星期序号周一0周日6 function getMondayBasedDay(date: Date): number { return (date.getDay() 6) % 7; }第四个坑最常见也最阴险不要用new Date(2025-01-01)这样的字符串构造来生成日期。这种写法在某些环境里会被解析成UTC零点然后当你用本地时区去取getDate()时正好落在前一天或后一天。正确做法是把年月日分开逐个作为参数传入构造函数function parseLocalDate(ymd: string): Date { const [y, m, d] ymd.split(-).map(Number); return new Date(y, m - 1, d); // 注意 month 从0开始 }这四个问题单看都是小知识点但叠加起来足以让一个月的视图错乱得一塌糊涂。我印象很深的是第一次跑出完整月视图时7月1日莫名其妙出现在6月30日旁边排查半天才发现是字符串日期解析的时区问题。4.2 重复事件模板与实例分离日历里最容易被低估的是重复事件。如果只是单次事件数据库存一条记录就完事但“每周一19点健身”“每月5号还信用卡”“每年10月1日提醒国庆”这类需求一出来事情就变复杂了。第一版我犯过一个错误把重复事件的所有未来实例全部预生成写到数据库里。结果是一个“每天重复”的事件生成了几千条记录存储膨胀不说改一个规则还要先清理掉旧实例逻辑搞得一团乱。后来我改成事件模板与实例分离的方案数据库中只存模板渲染月视图或日视图时动态计算这个模板在目标时间范围内覆盖的所有日期。interface RecurrenceRule { freq: daily | weekly | monthly | yearly; interval: number; byDay?: number[]; // 每周几0-6用于weekly endDate?: string; // 结束日期 } function generateOccurrences(rule: RecurrenceRule, rangeStart: Date, rangeEnd: Date): Date[] { const result: Date[] []; // 这里按 freq 分情况计算daily最简单weekly按byDaymonthly按日期yearly按年月日 return result; }生成实例时还有一个优化点不需要从事件开始日期一直循环到今天而是直接从max(eventStart, rangeStart)开始计算避免重复生成大量无意义实例。这个优化在我的数据集上不明显但如果某天导入了一个十年前就开始的年度提醒差距就会非常大。4.3 农历借成熟工具不重复造轮子农历是所有日历开发者的一个心结。按理说农历的推算有完整的天文算法但公历转农历涉及复杂的置闰规则自己从头实现既费时又容易出错。我的方案是直接使用开源库lunar-javascript它提供了农历、节气、干支纪年等完整的转换能力import { Lunar } from lunar-javascript; const lunar Lunar.fromDate(someDate); console.log(lunar.getMonthInChinese()); // 例如返回 十月 console.log(lunar.getDayInChinese()); // 例如返回 十五 console.log(Lunar.getJieQi(someYear, someMonth)); // 获取当月节气这里要提醒一句农历库的准确性依赖天文算法和历法数据库选择时尽量挑维护年份长、用的人多的库。lunar-javascript的文档不算漂亮但数据校验相对扎实个人项目足够用了。5. 数据本地所有权存储、备份与防锁定5.1 IndexedDB 虽然好用但别把鸡蛋全放里面IndexedDB 作为本地存储在容量上完全没问题但它有一个隐患浏览器清理缓存、系统空间不足、误操作清站点数据都可能把日历数据直接清空。所以我从第一版就加入了一个“导出备份”按钮一键把全部事件序列化成JSON文件下载到本地。function exportBackup() { const allEvents await getAllEvents(); const blob new Blob([JSON.stringify(allEvents, null, 2)], { type: application/json }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download calendar-backup-${new Date().toISOString().slice(0, 10)}.json; a.click(); URL.revokeObjectURL(url); }导入反向操作即可。这里有个小教训第一版我只做了导出的JSON没做导入的JSON结果某次测试时手滑清空了数据又懒得从备份文件里手工恢复老实了好一会儿才把导入功能补上。5.2 ICS 兼容计划给自己留一条迁移通道既然要把日历从商业App迁出来就得有个标准格式用于数据交换。日历领域最通用的开放格式是ICSiCalendar主流日历App都支持导入导出。ICS 本身并不复杂一个最简单的 VEVENT 大概长这样BEGIN:VCALENDAR VERSION:2.0 PRODID:-//My Calendar//CN BEGIN:VEVENT UID:1234567890mycalendar DTSTART:20250901T090000 DTEND:20250901T100000 SUMMARY:项目评审会议 END:VEVENT END:VCALENDAR实现CSV导入导出时最重要的两点是UID 保持全局唯一便于后续更新追踪DTSTART 的格式要遵守ICS标准最好手动拼字符串而不是用Date.toString()去凑。ICS 的重复事件规则RRULE比较复杂我一个晚上只实现了日常重复的解析足够覆盖大部分使用场景了。导入导出功能写完整个“防锁定”逻辑就闭环了数据在本地格式是开放的随时可以迁走。这个价值只有当你在一个产品里积累了三个月以上的事件记录时才能体会——数据迁移能力就是选择自由。5.3 关于备份周期我给自己定的节奏是每个月导出一次JSON文件名带上日期存到一个专门的云盘目录。可能有人觉得手动备份太原始但对于一个没有重要商业数据的个人日历来说这已经足够了。真到哪一天IndexedDB意外清空最多损失一个月内记录的日程代价完全可控。6. 提醒与跨设备同步商业产品最强的地方恰是我最想绕开的地方6.1 PWA 通知的现实限制写完日历主体功能后我遇到了整个项目里最头疼的部分提醒。原生日历App的一个核心价值是“到点就该响”而且即使App完全没打开系统也会负责把通知弹出来。PWA 想做到这件事难度完全不同。Web Notification API 可以在用户授权后推送通知但前提是页面处于打开或服务工作者控制范围内。桌面浏览器上表现还可以移动端尤其 iOS Safari 的PWA通知支持非常有限更不要指望锁屏界面能像系统日历那样稳定弹出提醒。我现在的折中方案是网页运行时开启一个会话内提醒器用 setTimeout 扫描最近15分钟内的事件到了时间就弹 Web Notification。这覆盖了“我正开着日历页面工作”的场景也是整个项目里最实用的提醒方式。function startReminderLoop() { setInterval(async () { const now new Date(); const upcomingEvents await queryEventsBetween(now, new Date(now.getTime() 15 * 60 * 1000)); upcomingEvents.forEach(evt { if (evt.time !evt.reminded) { new Notification(evt.title, { body: 将在 ${evt.time} 开始 }); evt.reminded true; } }); }, 60 * 1000); }6.2 我的跨设备方案文件即同步对于跨设备同步我没有引入任何同步引擎而是选择了最“笨”但最可靠的方案设备A导出ICS文件通过网盘或本地文件传送到设备B再在设备B上导入。整个流程完全离线没有中间商也不会有同步冲突。这个方案当然不优雅但胜在结构简单不需要服务器不需要鉴权不需要处理分布式一致性。我发现一旦接受了“同步不是实时的”这个设定很多焦虑就消失了。日历这种数据晚几小时同步完全不影响生活我根本不需要一个后台常驻的同步服务。6.3 我对“实时同步”的重新评估用过一段时间自建日历后我对同步这件事有了新的理解大多数人并不需要两台设备之间镜像一致真正需要的是“任何一台设备打开时都能看到最近记得的日程”。一份备份文件偶尔手动导入已经能覆盖99%的真实需求。为了这一点需求去搭建一个带账号系统的云同步不仅成本高还把原本干净的数据架构重新拖回了商业产品的老路。写到这里我自己也完成了对这个项目的阶段复盘。回头看核心收获未必是“我拥有了一个无广告日历”而是“我真的想清楚了自己需要什么并用最小成本满足了它”。这个最小成本包括一个周末梳理需求两个晚上搞定核心日期逻辑再用一周的碎片时间完善导入导出和提醒。如果你也正被某个App的广告和冗余功能折磨不妨想一下你真正依赖的功能有哪几个那些功能值不值得你再忍受一年广告答案如果是不值得那也许下一个值得动手的项目已经在你的手机里了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →