做桌面日历系统这个念头最早来自一个很具体的场景我在一台显示器上干活旁边贴着一张纸质月历看日期要抬头记事情要另开便签软件两件事来回切效率低得难受。后来索性自己动手写了一个常驻桌面的日历小工具做完之后发现桌面日历系统这个看起来“简单到不值一提”的东西里面的门道比想象中多得多——窗口形态、日期算法、提醒调度、性能稳定性每一块都有坑。这篇就把我整套实现思路和踩过的坑摊开讲从需求拆解、技术选型、日历核心算法、桌面窗口特性一直讲到数据存储、提醒机制和打包分发。适合想自己撸一个桌面小工具练手的同学也适合正在做类似常驻型桌面应用、被透明窗口和定时器折磨过的朋友。1. 先把需求拆明白桌面日历系统不是“画个表格”1.1 三类使用场景决定了三种完全不同的产品形态我见过也做过不少类似的小工具回过头看桌面日历基本可以归到三类场景里而这三类场景对功能的要求差得很远。第一类是极简读日期型。用户就想要桌面上有个东西告诉他今天几号、周几、这个月还剩几天。这类需求的核心是“不打扰”窗口要小、要透明、要能穿透鼠标最好完全融进壁纸。功能越少越好多一个按钮都是负担。第二类是轻量日程管理型。这类用户会往上记事情比如“周三下午交材料”“下个月五号有个约”需要事件录入、到点提醒、当天待办展示。功能上开始接近正经的效率工具但体量还得压住不能变成第二个臃肿的办公套件。第三类是桌面装饰型。用户要的是好看——主题、字体、毛玻璃、动效甚至要能换皮肤。这类产品对视觉打磨的要求远高于功能性能反而可以放宽。我自己做的那版落在第一类和第二类之间默认是极简月视图鼠标悬停才展开当天事件区。这个取舍很关键因为它直接决定了后面技术选型的权重——功能不多但常驻性极强所以资源占用和长期稳定性必须排在功能丰富度前面。1.2 常驻桌面带来的三条硬约束不常驻桌面的程序你写完能跑就算成功一半。常驻型的东西完全是另一套标准我总结了三条约束后面所有设计决策基本都绕着这三条转。第一条是资源占用。一个开机自启、从早开到晚的程序内存涨到 400MB 用户就会有明显感知CPU 偶尔飙一下风扇就会响。所以定时器的粒度、渲染的频率、日志的写入方式都得按“要跑一整天”的标准来设计不能按“跑五分钟看看效果”的标准写。第二条是绝不抢焦点。这是桌面日历最容易翻车的地方。窗口一弹出就把用户的输入焦点抢走正在打字的人一句话打到一半光标没了这种体验是致命的。所有弹窗、提醒、刷新都得走“不激活窗口”的路径。第三条是生命周期要长。窗口可能连续开着几十个小时中间经历系统休眠、唤醒、分辨率切换、多屏拔插。这些边界情况在短时间测试里根本暴露不出来但用户用一周就会遇到。所以容错和状态恢复的逻辑得当成核心功能来写而不是事后补丁。1.3 首版功能清单的取舍我把当时列的功能做了一张取舍表这张表后来帮我省掉了大量返工。判断标准只有一条这个功能是否影响“每天打开就能用”这个核心体验。功能模块首版是否实现取舍理由月视图网格必做核心展示没有它就没有产品今日高亮与周数显示必做成本极低感知极强事件录入与展示必做第二类场景的入口到点提醒必做没有提醒的日历等于纸质月历系统托盘常驻必做窗口关掉后还能唤回是常驻型应用的基本盘开机自启必做决定用户第二天还会不会打开它主题换肤延后视觉打磨可以迭代不影响可用性农历与节假日标注延后数据维护成本高先留好扩展位多视图周/日/年延后会和极简定位冲突容易把产品做臃肿云同步不做引入账号体系和服务端成本与收益严重不匹配这张表里最值得说的是“多视图延后”这一条。我一开始很想做周日视图觉得功能更完整但真做出来之后发现桌面日历的窗口尺寸本来就小塞进日视图之后每一条事件只能显示几个字反而更难用。后来砍掉界面立刻清爽了。在常驻型小工具里“少而顺”永远打得过“多而挤”。2. 技术选型我为什么最后落在 Electron 这套组合上2.1 四条主流技术路线的横向对比桌面日历这类应用可选的技术路线其实挺多我把当时认真考虑过的几条列出来做了对比。这张表里的数字是我自己实测的粗略值不同项目会有出入但相对关系基本可信。路线上手成本安装包体积常驻内存跨平台生态与 UI 表现力Electron 前端框架低会前端就能做偏大60-120MB较高150-300MBWindows/macOS/Linux最强CSS 想怎么画怎么画Tauri 前端中要碰一点 Rust很小5-15MB低50-100MB三平台强但部分系统能力要自己封装Python Qt 系中中40-80MB中80-150MB三平台中样式定制偏费劲C# WPF/WinUI中中低基本只 Windows强但平台锁定纯 Web 浏览器新标签页极低无取决于浏览器全平台中拿不到系统能力这张表如果只看体积和内存Tauri 明显更优我一度也很心动。但最后没选它原因在下一节。2.2 我的选型逻辑权重给错了选型就会跑偏选型最容易犯的错是拿一堆维度平铺着打分最后得出一个“综合最优”的结论。但真实项目里各维度权重根本不一样得按自己的场景重新排。对我来说第一权重是“能不能快速把想法变成能看的东西”。桌面日历这种小工具最大的成本不在写代码而在反复调整视觉细节——间距差 2px、圆角大一点、透明度调到多少才不糊。这类调整必须能即时预览改完保存立刻看到效果。前端技术栈在这一点上几乎没有对手。第二权重是系统能力的覆盖度。托盘、开机自启、系统通知、多屏信息、休眠唤醒事件这些是桌面日历的骨架。Electron 把这些都封装成了现成接口文档齐全遇到问题搜一下基本都有答案。Tauri 也能做但部分能力要自己写插件或者调底层对我这种想快速出成品的人来说学习成本不划算。第三权重才是体积和内存。这两个指标用户确实在意但可以通过工程手段压下去——打包时排除开发依赖、压缩资源、控制渲染频率实际做下来安装包能压到 60MB 出头常驻内存稳定在 180MB 左右。对一台 16GB 内存的机器来说这个数字可以接受。所以我的结论是如果这个工具是给一群人用的产品体积和内存的权重会往上提Tauri 更合适如果是给自己或者小范围用Electron 的效率优势压倒一切。这不是技术优劣问题是场景匹配问题。2.3 工程目录与三个进程的职责划分Electron 的结构看着简单但职责划分不清代码很快就会烂成一锅粥。我采用的是“主进程管系统、渲染进程管界面、preload 管桥接”的三段式目录大致长这样desktop-calendar/ ├── src/ │ ├── main/ # 主进程 │ │ ├── index.js # 入口应用生命周期 │ │ ├── window.js # 窗口创建与位置管理 │ │ ├── tray.js # 托盘与菜单 │ │ ├── reminder.js # 提醒调度器 │ │ ├── store.js # 数据读写封装 │ │ └── logger.js # 本地日志 │ ├── preload/ │ │ └── index.js # 暴露受限 API 给渲染进程 │ └── renderer/ # 渲染进程 │ ├── index.html │ ├── styles/ │ │ ├── base.css │ │ └── theme.css │ └── scripts/ │ ├── calendar.js # 日期网格生成 │ ├── view.js # 渲染与事件绑定 │ └── ipc.js # 与主进程通信 ├── assets/ │ └── icons/ └── package.json划分原则很明确凡是碰系统的东西一律放主进程。窗口、托盘、通知、文件读写、开机自启全在主进程里。渲染进程只负责画界面和收用户输入需要数据就通过 IPC 要。preload 里只暴露白名单方法绝不开nodeIntegration也不关contextIsolation。这么划分的好处在后期加功能的时候特别明显。比如后来加“事件提醒”我只在reminder.js里加调度逻辑渲染进程那边什么都不用改只是多了一个接收通知的通道。反过来如果一开始把数据读写写在渲染进程里后面想加个后台扫描就得大改。注意contextIsolation: true和nodeIntegration: false这两项在新版本里已经是默认值但很多老教程还在教人关掉它们图省事。桌面日历虽然体量小但它有文件读写能力一旦渲染进程被注入恶意脚本关掉隔离等于把门敞开。3. 日历核心算法一个月有几天这事没那么简单3.1 月份网格生成从“这个月第一天是周几”说起日历界面的本质是一个二维网格。要做的事就三步算出这个月第一天是周几、算出这个月有几天、把上个月尾巴和下个月开头补齐填满网格。听着简单但每一步都有细节。先说一个我踩过的坑。很多人第一反应是手写闰年判断然后写一堆if (month 2)的分支。其实 JS 的Date对象有个非常巧的用法可以直接拿到天数把日期设成“下个月的第 0 天”它会自动回退到本月最后一天。这个技巧背后的原理是Date构造器对越界日期的自动进位处理知道原理之后用起来心里有底。/** * 生成一个月视图的日期网格 * param {number} year 年份如 2024 * param {number} month 月份1-12 * param {number} weekStart 一周起始日0周日1周一 * returns {Array} 长度为 rows*7 的数组每项含 date/isCurrentMonth/isToday */ function buildMonthGrid(year, month, weekStart 1) { // 本月第一天 const firstDay new Date(year, month - 1, 1); // 本月天数传下个月的第 0 天自动回退到本月最后一天 const daysInMonth new Date(year, month, 0).getDate(); // 第一天是周几0周日 ... 6周六 const rawWeekday firstDay.getDay(); // 换算成相对 weekStart 的偏移量 const leading (rawWeekday - weekStart 7) % 7; const cells []; // 用本月第一天的日期做基准向前后推算最稳妥 const cursor new Date(year, month - 1, 1 - leading); for (let i 0; i 42; i) { const d new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() i); cells.push({ date: formatDate(d), // YYYY-MM-DD day: d.getDate(), isCurrentMonth: d.getMonth() month - 1, weekday: d.getDay() }); } return cells; }这里有个设计决策值得说我固定生成42 格6 行而不是按实际需要生成 5 行或 6 行。理由很实在——行数变化会导致整个面板高度跳动切月份的时候界面一抖观感很差。固定 42 格多余的格子用上个月或下个月的日期填上并做淡化处理布局永远稳定。代价是有些月份会多出一整行“下个月日期”看起来有点冗余。我的处理是把这些格子的透明度降到 30% 左右鼠标悬停才恢复视觉上很自然。心得new Date(year, month, day)这组参数里month 是 0 基的0 是一月但很多业务代码习惯用 1-12。我一开始在函数边界上没统一导致有一个月整体错位了一格排查了半天。后来定死规矩对外接口一律用 1-12进函数第一件事就减 1这类错位问题就再没出现过。3.2 周起始日与本地化一个被严重低估的开关一周从周几开始这件事看着小事但在实际使用里影响不小。国内习惯周一开头部分欧美地区习惯周日开头。如果这个值写死在代码里以后想扩展就是一次重构。所以我把weekStart做成一个可以配置的参数存到设置里默认值是 1周一。实现上就是上面代码里那行取模运算const leading (rawWeekday - weekStart 7) % 7;加 7 再取模是为了处理负数。比如weekStart 1、当天是周日rawWeekday 0时0 - 1 -1不加 7 会得到 -1网格就错位了。这里还有个隐藏问题周数的计算。ISO 8601 规定周一为一周开始且包含当年第一个周四的那一周算作第 1 周。这个定义和“1 月 1 日所在周就是第 1 周”不一样年底年初会出现跨年周。我一开始自己写了一段周数计算结果 12 月 31 日那天显示成第 53 周和系统日历对不上。后来换成标准的 ISO 周数算法才对齐。如果只是想在界面上显示“第几周”而且不追求和系统日历完全一致自己算也无所谓。但只要用户会拿它和别处对照就必须按标准来否则会让人觉得“你这日历不准”。3.3 农历与节假日标注用数据驱动别用代码硬编码日历类应用很容易被要求加农历和节日标注。这块我的建议很明确算法归算法数据归数据两件事分开做。农历转换本身是一套固定的天文历法推算逻辑实现方式有现成的算法可以移植核心是一张按年编排的编码表加上换算规则。这部分可以做成一个纯函数模块输入公历日期输出农历月日、是否闰月、节气等信息。它不依赖任何外部数据逻辑一旦验证正确就不会再变。节假日和调休标注则完全相反它是逐年变化的数据而且每个地区规则不同。这类东西绝对不能在代码里写if (date 10-01)这种判断必须走配置表。我的做法是放一个 JSON 数据文件结构大致是{ 2024: { 2024-01-01: { type: holiday, label: 元旦 }, 2024-02-10: { type: holiday, label: 春节 }, 2024-02-04: { type: workday, label: 调休上班 } } }界面上渲染的时候先按日期字符串查这张表查到就加对应的标记样式。这样每年只需要替换一个数据文件代码一行都不用改。这个设计也带来一个附加好处用户可以自己往里加东西。我把入口做成“自定义标注”用户想标纪念日、朋友生日都往这张表里塞跟内置数据走同一套渲染逻辑不用为“自定义”单独写一套代码路径。同一个渲染逻辑吃两种数据源是省代码的经典做法。3.4 时区与夏令时用字符串当业务主键别用时间戳这是我在数据设计上最关键的一个决定所有日历事件业务主键用YYYY-MM-DD字符串不用时间戳。原因很直接。日历事件的语义是“某一天有一件事”而不是“某个绝对时刻有一件事”。如果存时间戳用户在 UTC8 存了 3 月 1 日的事件换到 UTC-5 时区打开可能就变成 2 月 29 日了。虽然桌面日历用户一般不会频繁换时区但系统自动同步时间、夏令时切换这类情况是真实存在的。用字符串存日期就是日期不受任何时区换算影响。什么时候需要绝对时刻只有提醒那一个环节。所以我的做法是日期部分用字符串提醒时刻在运行时用“日期字符串 时间字符串”现拼成 Date 对象。function buildReminderTime(dateStr, timeStr) { // dateStr: 2024-03-01 timeStr: 14:30 const [y, m, d] dateStr.split(-).map(Number); const [hh, mm] timeStr.split(:).map(Number); // 用本地时间构造避免 UTC 偏移 return new Date(y, m - 1, d, hh, mm, 0, 0).getTime(); }这段代码看着平淡但new Date(y, m-1, d, hh, mm)这个重载非常关键——它按本地时区解释参数。如果写成new Date(2024-03-01T14:30:00)某些环境下会被当成 UTC 解析差出整整 8 小时提醒全部错位。这个坑我踩过一次测试的时候全对上线之后用户反馈“提醒早了八小时”查了很久才发现是字符串解析的时区歧义。4. 桌面窗口形态透明、置顶、托盘这些细节怎么做4.1 无边框透明窗口的关键配置桌面日历的视觉基调取决于窗口本身。要做到“像贴在壁纸上”需要无边框、透明背景、无阴影这几项配合。核心配置大概是这样const { BrowserWindow } require(electron); const path require(path); const win new BrowserWindow({ width: 420, height: 560, frame: false, // 去掉系统标题栏 transparent: true, // 允许透明 backgroundColor: #00000000, // 全透明兜底别用默认色 hasShadow: false, // 关掉阴影否则透明边缘会有灰边 resizable: false, skipTaskbar: true, // 不出现在任务栏 alwaysOnTop: false, // 是否置顶做成可配置 show: false, // 先隐藏加载完再显示防闪烁 webPreferences: { preload: path.join(__dirname, ../preload/index.js), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false // 关键失焦后不节流定时器 } }); win.once(ready-to-show, () win.show());这里有三处值得单独说。第一处是backgroundColor: #00000000。很多人设了transparent: true但没设背景色在某些显卡驱动下窗口会渲染成纯黑。显式给一个全透明的背景色能规避掉大部分这类问题。第二处是hasShadow: false。系统阴影是按矩形窗口算的一旦背景透明阴影会在圆角外面露出一圈灰边非常难看。想要阴影效果用 CSS 的box-shadow在页面内部画可控性高得多。第三处是backgroundThrottling: false。这个值默认是true意思是窗口失去焦点或者被遮挡时渲染进程的定时器会被降频到每秒一次甚至更低。对普通页面无所谓但对日历来说是灾难——用户切到别的窗口干活你的提醒定时器被节流提醒就可能延迟几十秒甚至更久。我曾经遇到过“提醒晚了一分钟”的问题查了两天才定位到这里。4.2 系统托盘与开机自启让用户第二天还会打开它常驻型应用没有托盘基本等于废了。窗口一关用户找不到入口第二天就不会再打开。托盘的实现不复杂const { Tray, Menu } require(electron); let tray null; function createTray(win) { tray new Tray(path.join(__dirname, ../../assets/icons/tray.png)); tray.setToolTip(桌面日历); const menu Menu.buildFromTemplate([ { label: 显示/隐藏, click: () { win.isVisible() ? win.hide() : win.show(); } }, { label: 置顶显示, type: checkbox, checked: false, click: (item) win.setAlwaysOnTop(item.checked) }, { type: separator }, { label: 开机自启, type: checkbox, checked: false, click: (item) { app.setLoginItemSettings({ openAtLogin: item.checked, openAsHidden: true // 启动时不弹窗只进托盘 }); } }, { label: 退出, click: () { app.isQuitting true; app.quit(); } } ]); tray.setContextMenu(menu); // 点击托盘图标也切换窗口 tray.on(click, () { win.isVisible() ? win.hide() : win.show(); }); }这里有几个实战细节。openAtLogin在 Windows 和 macOS 上表现不完全一样。Windows 下它是往注册表的启动项里写macOS 下走的是登录项。开发模式下调用会指向 Electron 的可执行文件而不是你的程序所以一定要用app.isPackaged判断只在打包后生效否则开发时每次开机都弹出一堆调试窗口。另外“关闭窗口”和“退出程序”要区分开。我最初的实现里用户点关闭直接win.close()结果window-all-closed事件触发程序整个退出了。正确做法是拦截close事件改成隐藏win.on(close, (e) { if (!app.isQuitting) { e.preventDefault(); win.hide(); } });加一个app.isQuitting标志位从托盘菜单走退出时才真正关闭。这个小逻辑看起来琐碎但它决定了用户对这个程序的印象是“关不掉的流氓软件”还是“懂事的常驻工具”。4.3 多屏与 DPI 缩放拔掉副屏之后窗口去哪了多屏是桌面应用逃不开的坑。我在测试阶段就遇到过一次把窗口拖到副屏然后拔掉副屏再启动程序窗口出现在一个不存在的位置完全看不见只能改配置文件恢复。解决办法是保存窗口位置时不要只存绝对坐标要存“所在显示器的标识 相对该显示器工作区的偏移”。恢复时先按标识找到显示器找不到就退回主屏。const { screen } require(electron); function saveWindowBounds(win) { const bounds win.getBounds(); const display screen.getDisplayMatching(bounds); return { displayId: display.id, offsetX: bounds.x - display.workArea.x, offsetY: bounds.y - display.workArea.y, width: bounds.width, height: bounds.height }; } function restoreWindowBounds(saved) { const displays screen.getAllDisplays(); const target displays.find(d d.id saved.displayId) || screen.getPrimaryDisplay(); return { x: target.workArea.x saved.offsetX, y: target.workArea.y saved.offsetY, width: saved.width, height: saved.height }; }用workArea而不是bounds也很重要。bounds包含任务栏占用的区域workArea是扣掉任务栏之后的可用区域。用bounds算窗口贴底的时候会被任务栏盖住一截。DPI 缩放是另一个隐性坑。在 125% 或 150% 缩放的屏幕上window.devicePixelRatio会变成 1.25 或 1.5如果用固定的像素值去做布局字会糊、边框会虚。我的做法是全部尺寸用 CSS 的相对单位rem、em、百分比需要精确像素的地方用devicePixelRatio换算并且监听display-metrics-changed事件在缩放变化时重新计算布局。4.4 鼠标穿透怎么做到“看得见但点不到”“鼠标穿透”是桌面日历里一个很有价值的能力。开启之后鼠标点在这个区域上会直接落到下面的桌面或其他窗口上日历变成纯展示层。这个功能在用户只需要看日期、不需要交互的时候特别有用。Electron 提供了对应 API// 开启穿透forward 让窗口仍然能收到 mousemove 事件 win.setIgnoreMouseEvents(true, { forward: true });但这里有个细节坑forward选项只在部分平台和版本上生效也就是开启穿透之后页面可能收不到鼠标移动事件你就没法判断“鼠标是不是移到了可交互区域上”。我的解决方案是分层处理。窗口默认开启穿透但我在页面里铺了一层透明的“热区”监听同时用主进程的screen.getCursorScreenPoint()做定时轮询间隔 100ms 左右判断光标是否落在需要交互的元素区域内。是的话就临时关掉穿透离开再打开。setInterval(() { const point screen.getCursorScreenPoint(); const bounds win.getBounds(); const inside point.x bounds.x point.x bounds.x bounds.width point.y bounds.y point.y bounds.y bounds.height; // 简化写法实际还要判断是否落在具体控件上 win.setIgnoreMouseEvents(!inside, { forward: true }); }, 100);这个轮询看着有点糙但实测很稳而且 100ms 的间隔对 CPU 的压力几乎可以忽略。比起折腾平台相关的底层窗口属性这条路省心得多。注意穿透开启之后窗口上的按钮就点不到了。一定要留一个“关闭穿透”的入口而且这个入口本身不能在被穿透的区域里否则用户开了之后就再也关不掉。我第一版就犯了这个错最后是通过托盘菜单救回来的。5. 数据层与提醒机制休眠之后闹钟不能失声5.1 本地存储的表结构设计桌面日历的数据量极小用不上服务端数据库本地 SQLite 完全够用。它的好处是单文件、零配置、支持 SQL 查询打包进应用也不用额外装服务。表结构我设计得很简单-- 事件表 CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, -- UUID title TEXT NOT NULL, event_date TEXT NOT NULL, -- YYYY-MM-DD start_time TEXT, -- HH:mm全天事件为 NULL end_time TEXT, all_day INTEGER DEFAULT 1, repeat_rule TEXT, -- 简单重复规则如 weekly remind_offset INTEGER DEFAULT -1, -- 提前多少分钟提醒-1 表示不提醒 color TEXT, note TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_events_date ON events(event_date); -- 设置表键值对形式 CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT );设计上有两个决定值得解释。一是event_date用 TEXT 存日期字符串。前面说过日期语义用字符串最稳SQLite 对字符串索引支持也很好查询“某月所有事件”用LIKE 2024-03%或者范围查询都很快。二是加索引。事件表数据量可能就几千条不加索引也感觉不出差别。但日历每次切月份都要重新查一次一天切几十次加上索引能把这个开销压到几乎为零。数据读写的封装我放在主进程渲染进程通过 IPC 调用。这样做的另一个好处是数据一致性只在一个进程里维护不用担心多处写冲突。// preload 里暴露白名单方法 const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(calendarApi, { getEventsByMonth: (year, month) ipcRenderer.invoke(events:byMonth, { year, month }), addEvent: (payload) ipcRenderer.invoke(events:add, payload), updateEvent: (payload) ipcRenderer.invoke(events:update, payload), removeEvent: (id) ipcRenderer.invoke(events:remove, id), getSettings: () ipcRenderer.invoke(settings:get), saveSettings: (patch) ipcRenderer.invoke(settings:set, patch) });5.2 提醒调度为什么我不建议用 setTimeout 做核心调度提醒是桌面日历里最容易出错的功能。我一开始的实现思路很直觉给每个事件算一个触发时间然后setTimeout到点执行。写完之后测了几个事件都正常就以为完事了。问题出在长时间运行上。setTimeout的计时依赖系统单调时钟一旦系统休眠这段时间的计时会停滞唤醒之后不会自动补齐。比如设了一个 3 小时后的提醒中途笔记本合盖休眠 2 小时唤醒后这个定时器可能还要从暂停的地方继续走实际触发时间就晚了。换成setInterval轮询之后问题解决了一大半。思路是不做事先排程改成周期性扫描“当前时刻应该触发哪些提醒”。const POLL_INTERVAL 30 * 1000; // 30 秒扫一次 let lastScanAt 0; function scanDueReminders() { const now Date.now(); const windowStart lastScanAt || now - POLL_INTERVAL; // 查出所有设置了提醒的事件逐条判断是否落在 (windowStart, now] 区间内 const candidates db.prepare( SELECT id, title, event_date, start_time, remind_offset FROM events WHERE remind_offset 0 ).all(); for (const ev of candidates) { if (!ev.start_time) continue; const eventTime buildReminderTime(ev.event_date, ev.start_time); const fireAt eventTime - ev.remind_offset * 60 * 1000; if (fireAt windowStart fireAt now) { // 查重同一个事件同一天只提醒一次 if (!hasFired(ev.id, ev.event_date)) { showNotification(ev); markFired(ev.id, ev.event_date); } } } lastScanAt now; } setInterval(scanDueReminders, POLL_INTERVAL);这个方案有三个好处。第一30 秒粒度对日历提醒完全够用误差最多 30 秒人感知不到。第二系统休眠之后唤醒下一次扫描会自动把休眠期间应该触发的提醒补上因为windowStart到now的区间覆盖了这段时间。第三天然幂等用“事件 id 日期”做去重键重复扫描也不会重复提醒。代价是需要一个去重记录。我直接存在内存里一个 Set程序重启会清空但重启之后lastScanAt也归零不会重复补发逻辑上是自洽的。5.3 休眠唤醒与时间跳变两类异常要分开处理轮询方案能解决“定时器停滞”的问题但还有两类异常要单独处理。第一类是系统休眠唤醒。虽然轮询能补上但唤醒的瞬间如果刚好在两次扫描之间用户可能要多等半分钟才能看到提醒。所以我会监听电源事件唤醒之后立刻手动触发一次扫描const { powerMonitor } require(electron); powerMonitor.on(resume, () { logger.info(system resumed, force scan); lastScanAt Date.now() - 10 * 60 * 1000; // 回补 10 分钟窗口 scanDueReminders(); });把lastScanAt往前推 10 分钟是为了覆盖“休眠前后刚好跨越触发点”的情况。如果不回补休眠前一秒应该触发的提醒就永远丢了。第二类是系统时间被手动调整。用户可能为了测试改了系统时钟或者对时服务校正了时间。如果时间被往回调lastScanAt可能比now还大区间变成负数扫描就全乱了。所以我在扫描开头加了保护if (now lastScanAt || now - lastScanAt 30 * 60 * 1000) { // 时间回跳或者跳得太远不做补发直接重置锚点 lastScanAt now; return; }这条规则的意思是正常情况 30 秒扫一次如果发现距上次扫描超过 30 分钟说明中间发生了异常休眠、时间调整、进程卡死这种情况不做批量补发避免一下子弹出几十条提醒把用户淹没。“错过的提醒怎么处理”这个决策比“怎么提醒”更重要。我的选择是超过 30 分钟的提醒不再弹窗而是在界面顶部展示一个“有 3 条错过的提醒”的折叠条点开才看。这样既不会骚扰用户也不会让信息彻底丢失。6. 渲染与视觉让日历看起来不像后台管理系统6.1 避免整月重绘一天的格子不该重建 42 次日历界面有个特点切月份的时候要更新 42 个格子但大部分内容其实没变只是日期数字变了。如果用“先清空容器再重建所有节点”的写法每次切换都是一次完整的 DOM 重建42 个节点加上事件绑定在低配机器上会有肉眼可见的卡顿。我的做法是节点复用。初始化的时候建好 42 个格子节点之后切月份只更新每个格子的文本内容和 class不重新创建节点。const cells []; // 初始化时创建一次 function renderMonth(year, month) { const grid buildMonthGrid(year, month, settings.weekStart); for (let i 0; i 42; i) { const cell cells[i]; const data grid[i]; // 只在文本真的变了才写 DOM避免无谓的重排 if (cell.dateEl.textContent ! String(data.day)) { cell.dateEl.textContent data.day; } cell.el.classList.toggle(is-other-month, !data.isCurrentMonth); cell.el.classList.toggle(is-today, data.date todayStr); if (cell.dataset.date ! data.date) { cell.dataset.date data.date; } } }用classList.toggle而不是直接赋值className能避免样式被整体覆盖。用dataset存日期比在闭包里维护映射表更直观也方便事件委托时直接读到。事件绑定我全部走委托只在最外层容器上挂一个点击监听通过event.target.closest(.cell)找到被点的格子。42 个格子挂 42 个监听器和挂 1 个监听器内存和事件分发开销差得很远。gridContainer.addEventListener(click, (e) { const cell e.target.closest(.day-cell); if (!cell) return; openEventPanel(cell.dataset.date); });6.2 主题系统用 CSS 变量把换肤成本降到最低主题功能我延后到第二版才做但第一版就留好了口子。核心思路是把所有颜色、圆角、间距抽成 CSS 变量主题切换只改一组变量值。:root { --bg-main: rgba(255, 255, 255, 0.82); --bg-today: rgba(64, 128, 255, 0.14); --text-primary: #1f2328; --text-secondary: #6b7280; --text-muted: rgba(31, 35, 40, 0.35); --border-radius: 12px; --cell-radius: 8px; --font-size-day: 15px; --gap: 4px; } [data-themedark] { --bg-main: rgba(28, 30, 34, 0.86); --bg-today: rgba(90, 160, 255, 0.22); --text-primary: #e6e8eb; --text-secondary: #9ba3ae; --text-muted: rgba(230, 232, 235, 0.32); }切换主题只需要在根节点上改>function applyTheme(name) { document.documentElement.dataset.theme name; window.calendarApi.saveSettings({ theme: name }); }背景透明度这块有个细节。半透明背景在 Electron 里会叠加窗口本身的透明如果两个透明度不协调会在某些壁纸上显得发灰。我实测下来浅色主题用 0.82 左右的背景透明度比较舒服既能看清内容又能透出一点壁纸纹理。毛玻璃效果backdrop-filter: blur()看起来很美但它在 Electron 里的性能开销不小尤其是窗口一直开着的时候 GPU 占用会明显上升。我的策略是默认关闭毛玻璃做成可选开关开启时把模糊半径控制在 8px 以内。6.3 字体与排版日期数字对齐是基本功日历的排版里有个容易被忽视的细节数字对齐。日期从 1 号到 31 号位数不同如果用比例字体数字的宽度不一样网格里的日期会左右跳看起来很不规整。解决方法是开启等宽数字特性.day-cell .num { font-variant-numeric: tabular-nums; font-feature-settings: tnum 1; text-align: center; line-height: 1; }这行 CSS 的作用是让每个数字占据相同的宽度1 和 11 和 31 的宽度一致视觉上就整齐了。很多日历应用没做这一步凑近看会觉得日期有点歪。字号层级也要克制。桌面日历窗口小字号超过三档就会显得杂乱。我的配置是日期数字 15px、星期表头 11px、事件文字 12px再加一个“今天”的徽标 11px。整块面板只有四级字号层次清楚又不花哨。行高也很关键。日历格子里同时有日期数字和事件缩略文字行高设小了挤在一起设大了格子撑不住。我用的是 1.3 倍行高配min-height让格子高度由内容撑开但有下限这样有事件和没事件的格子高度不会差太多。7. 打包分发与长期维护最后一步最容易翻车7.1 安装包体积怎么压下来第一版打出来的安装包有 118MB用户下载要等看着也不专业。我做了几轮优化最后压到 62MB 左右过程不算复杂。优化手段效果说明排除开发依赖减少 20MB 左右electron-builder默认只打包dependencies但要确认工具链没混进去启用 asar 打包体积变化不大但启动更快把源码打成单文件省掉大量小文件 IO压缩图片资源减少 8MB 左右图标统一转成合适尺寸别塞 1024px 的原图移除未使用的语言包减少 10MB 左右Electron 自带的本地化文件按需保留关闭 source map减少 5MB 左右生产构建不需要其中“移除未使用的语言包”这一项效果最明显但要注意别删过头否则系统菜单会变成空白。我的做法是保留界面用到的语言其余删掉。7.2 数据迁移表结构改了之后老用户怎么办第二版我加了个tags字段如果直接改表结构老用户的数据库不会自动加列程序一读就报错。所以必须有迁移机制。我的做法是在设置表里存一个schema_version启动时对比当前版本低于目标版本就按顺序执行迁移脚本const MIGRATIONS [ { version: 1, run: (db) { /* 建初始表 */ } }, { version: 2, run: (db) { db.exec(ALTER TABLE events ADD COLUMN tags TEXT DEFAULT ); } }, { version: 3, run: (db) { db.exec(CREATE INDEX IF NOT EXISTS idx_events_tags ON events(tags)); } } ]; function migrate(db) { const current Number( db.prepare(SELECT value FROM settings WHERE key schema_version).get()?.value || 0 ); for (const m of MIGRATIONS) { if (m.version current) { m.run(db); db.prepare(INSERT OR REPLACE INTO settings (key, value) VALUES (schema_version, ?)) .run(String(m.version)); } } }关键点是迁移脚本只增不改。已经发布的迁移不能回头修改否则老用户和新用户的数据库状态会不一致。这个规矩在数据库圈子里是老生常谈但在小工具项目里经常被忽略。7.3 日志出问题的时候你只有它桌面应用的 bug 很难复现用户描述往往是“有时候会消失”“偶尔不提醒”。没有日志排查全靠猜。我在主进程里写了个极简的日志模块按天切分文件单个文件超过 2MB 就轮转最多保留 7 天。日志内容只记关键事件应用启动、窗口位置恢复、提醒触发、异常捕获。日常运行的细节不记避免文件迅速膨胀。const fs require(fs); const path require(path); const LOG_DIR path.join(app.getPath(userData), logs); const MAX_SIZE 2 * 1024 * 1024; const KEEP_DAYS 7; function log(level, message) { const line [${new Date().toISOString()}] [${level}] ${message}\n; const file path.join(LOG_DIR, ${todayStr()}.log); try { const stat fs.existsSync(file) ? fs.statSync(file) : null; if (stat stat.size MAX_SIZE) return; // 超限就丢弃当天的后续日志 fs.appendFileSync(file, line); } catch (e) { // 日志失败不能影响主流程 } }最后那行注释很重要。日志模块本身出问题磁盘满、权限不足绝对不能把主程序带崩。所有日志写入都包在 try/catch 里吞掉异常。8. 常见问题与排查实录8.1 问题速查表这张表是我这几个月攒下来的按“现象”查基本能覆盖八成问题。现象可能原因排查方法解决思路窗口背景显示成纯黑没设透明背景色或显卡驱动不兼容先改背景色测试显式设backgroundColor: #00000000提醒延迟几十秒到几分钟窗口失焦导致定时器被节流打日志看触发时刻关掉backgroundThrottling休眠唤醒后提醒丢失定时器在休眠期间停摆记录休眠前后时间戳改轮询 监听resume事件补扫关窗口后程序直接退出没拦截close事件观察window-all-closed拦截close改成hide()拔掉副屏后窗口不见了保存了绝对坐标打印保存的坐标存显示器 id 相对偏移开机自启弹出调试窗口开发模式下调用了自启接口看是不是打包版本用app.isPackaged判断字体发虚、边框模糊DPI 缩放没适配看devicePixelRatio尺寸用相对单位监听缩放变化切月份时界面抖动网格行数不固定数一下每月行数固定 42 格日期整体错位一个月月份 0 基和 1 基混用在函数入口打印参数对外统一 1-12进函数减 1提醒早或晚整整 8 小时日期字符串被当 UTC 解析打印getTime()对比用new Date(y, m-1, d, hh, mm)8.2 几个印象最深的坑第一个坑是透明窗口在特定机器上失效。我自己的两台机器上都正常交付给一个同事之后他那台机器上窗口背景是黑的内容完全看不见。查下来是显卡驱动对透明窗口的支持有差异某些驱动版本下transparent: true会退化。最后的兜底方案是启动时检测透明是否生效用一个已知颜色的像素采样判断失效就自动切到“不透明 圆角遮罩”的降级模式。虽然不好看但至少能用。第二个坑是提醒重复触发。有一段时间用户反馈“同一个事件提醒了两次”。原因是扫描逻辑里用了和双闭区间如果两次扫描的边界刚好落在同一个触发时刻上就会被算两次。改成左开右闭(windowStart, now]再加上“事件 id 日期”的去重键问题彻底解决。区间边界用开还是闭一定要想清楚语义这种错在小逻辑里特别隐蔽。第三个坑是开机自启路径带空格。Windows 下如果程序装在Program Files这类带空格的目录里注册表启动项里的路径不加引号会被截断程序启动失败。这个问题只在特定安装路径下出现测试时很难覆盖到。后来我在设置自启时手动给路径加了引号并且在设置完之后读回来校验一次。第四个坑是托盘图标在某些 Linux 桌面环境下不显示。这个属于平台差异Electron 在 Linux 上的托盘依赖桌面环境的支持部分环境需要额外的库。我的处理是在托盘创建失败时不阻断主流程只是记一条日志程序继续以窗口形式运行。心得这四类坑有个共同点——都只在特定环境或长时间运行后才暴露。所以桌面应用的测试不能只在开发机上跑一遍得多找几台不同配置的机器放置一两天观察。有条件的话写个简单的自检脚本把透明支持、托盘可用性、DPI 缩放、多屏信息这些环境相关的能力在启动时探一遍日志里记下来出问题时一眼就能看出是环境问题还是代码问题。写到这里桌面日历系统这套东西基本摊完了。从最初觉得“这东西两三天就能搞定”到后来花了差不多三周时间反复打磨窗口形态和提醒机制最大的感受是桌面常驻型应用的难度不在功能而在边界情况。功能写出来只是及格线真正决定好不好用的是休眠唤醒之后闹钟还响不响、拔掉副屏之后窗口还在不在、开一整天之后内存涨不涨。这些地方没有任何捷径只能一条一条踩过去。我自己现在还在用的那条经验是每加一个新特性先问一句“它在系统休眠、时间跳变、多屏切换这三种情况下会怎样”问完再动手写返工能少一大半。