从零实现桌面日历系统:Electron窗口、日历算法与提醒调度实战

发布时间:2026/10/25 7:26:26
从零实现桌面日历系统:Electron窗口、日历算法与提醒调度实战

做桌面日历系统这个念头,最早来自一个很具体的场景:我在一台显示器上干活,旁边贴着一张纸质月历,看日期要抬头,记事情要另开便签软件,两件事来回切,效率低得难受。后来索性自己动手写了一个常驻桌面的日历小工具,做完之后发现,桌面日历系统这个看起来“简单到不值一提”的东西,里面的门道比想象中多得多——窗口形态、日期算法、提醒调度、性能稳定性,每一块都有坑。这篇就把我整套实现思路和踩过的坑摊开讲,从需求拆解、技术选型、日历核心算法、桌面窗口特性,一直讲到数据存储、提醒机制和打包分发。适合想自己撸一个桌面小工具练手的同学,也适合正在做类似常驻型桌面应用、被透明窗口和定时器折磨过的朋友。

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字符串,不用时间戳。

原因很直接。日历事件的语义是“某一天有一件事”,而不是“某个绝对时刻有一件事”。如果存时间戳,用户在 UTC+8 存了 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-theme="dark"] { --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 缩放、多屏信息这些环境相关的能力在启动时探一遍,日志里记下来,出问题时一眼就能看出是环境问题还是代码问题。

写到这里,桌面日历系统这套东西基本摊完了。从最初觉得“这东西两三天就能搞定”,到后来花了差不多三周时间反复打磨窗口形态和提醒机制,最大的感受是:桌面常驻型应用的难度不在功能,而在边界情况。功能写出来只是及格线,真正决定好不好用的是休眠唤醒之后闹钟还响不响、拔掉副屏之后窗口还在不在、开一整天之后内存涨不涨。这些地方没有任何捷径,只能一条一条踩过去。我自己现在还在用的那条经验是:每加一个新特性,先问一句“它在系统休眠、时间跳变、多屏切换这三种情况下会怎样”,问完再动手写,返工能少一大半。