桌面日歷系統(tǒng):Electron窗口、日歷算法與提醒調(diào)度實戰(zhàn))
做桌面日歷系統(tǒng)這個念頭最早來自一個很具體的場景我在一臺顯示器上干活旁邊貼著一張紙質(zhì)月歷看日期要抬頭記事情要另開便簽軟件兩件事來回切效率低得難受。后來索性自己動手寫了一個常駐桌面的日歷小工具做完之后發(fā)現(xiàn)桌面日歷系統(tǒng)這個看起來“簡單到不值一提”的東西里面的門道比想象中多得多——窗口形態(tài)、日期算法、提醒調(diào)度、性能穩(wěn)定性每一塊都有坑。這篇就把我整套實現(xiàn)思路和踩過的坑攤開講從需求拆解、技術(shù)選型、日歷核心算法、桌面窗口特性一直講到數(shù)據(jù)存儲、提醒機(jī)制和打包分發(fā)。適合想自己擼一個桌面小工具練手的同學(xué)也適合正在做類似常駐型桌面應(yīng)用、被透明窗口和定時器折磨過的朋友。1. 先把需求拆明白桌面日歷系統(tǒng)不是“畫個表格”1.1 三類使用場景決定了三種完全不同的產(chǎn)品形態(tài)我見過也做過不少類似的小工具回過頭看桌面日歷基本可以歸到三類場景里而這三類場景對功能的要求差得很遠(yuǎn)。第一類是極簡讀日期型。用戶就想要桌面上有個東西告訴他今天幾號、周幾、這個月還剩幾天。這類需求的核心是“不打擾”窗口要小、要透明、要能穿透鼠標(biāo)最好完全融進(jìn)壁紙。功能越少越好多一個按鈕都是負(fù)擔(dān)。第二類是輕量日程管理型。這類用戶會往上記事情比如“周三下午交材料”“下個月五號有個約”需要事件錄入、到點提醒、當(dāng)天待辦展示。功能上開始接近正經(jīng)的效率工具但體量還得壓住不能變成第二個臃腫的辦公套件。第三類是桌面裝飾型。用戶要的是好看——主題、字體、毛玻璃、動效甚至要能換皮膚。這類產(chǎn)品對視覺打磨的要求遠(yuǎn)高于功能性能反而可以放寬。我自己做的那版落在第一類和第二類之間默認(rèn)是極簡月視圖鼠標(biāo)懸停才展開當(dāng)天事件區(qū)。這個取舍很關(guān)鍵因為它直接決定了后面技術(shù)選型的權(quán)重——功能不多但常駐性極強(qiáng)所以資源占用和長期穩(wěn)定性必須排在功能豐富度前面。1.2 常駐桌面帶來的三條硬約束不常駐桌面的程序你寫完能跑就算成功一半。常駐型的東西完全是另一套標(biāo)準(zhǔn)我總結(jié)了三條約束后面所有設(shè)計決策基本都繞著這三條轉(zhuǎn)。第一條是資源占用。一個開機(jī)自啟、從早開到晚的程序內(nèi)存漲到 400MB 用戶就會有明顯感知CPU 偶爾飆一下風(fēng)扇就會響。所以定時器的粒度、渲染的頻率、日志的寫入方式都得按“要跑一整天”的標(biāo)準(zhǔn)來設(shè)計不能按“跑五分鐘看看效果”的標(biāo)準(zhǔn)寫。第二條是絕不搶焦點。這是桌面日歷最容易翻車的地方。窗口一彈出就把用戶的輸入焦點搶走正在打字的人一句話打到一半光標(biāo)沒了這種體驗是致命的。所有彈窗、提醒、刷新都得走“不激活窗口”的路徑。第三條是生命周期要長。窗口可能連續(xù)開著幾十個小時中間經(jīng)歷系統(tǒng)休眠、喚醒、分辨率切換、多屏拔插。這些邊界情況在短時間測試?yán)锔颈┞恫怀鰜淼脩粲靡恢芫蜁龅?。所以容錯和狀態(tài)恢復(fù)的邏輯得當(dāng)成核心功能來寫而不是事后補(bǔ)丁。1.3 首版功能清單的取舍我把當(dāng)時列的功能做了一張取舍表這張表后來幫我省掉了大量返工。判斷標(biāo)準(zhǔn)只有一條這個功能是否影響“每天打開就能用”這個核心體驗。功能模塊首版是否實現(xiàn)取舍理由月視圖網(wǎng)格必做核心展示沒有它就沒有產(chǎn)品今日高亮與周數(shù)顯示必做成本極低感知極強(qiáng)事件錄入與展示必做第二類場景的入口到點提醒必做沒有提醒的日歷等于紙質(zhì)月歷系統(tǒng)托盤常駐必做窗口關(guān)掉后還能喚回是常駐型應(yīng)用的基本盤開機(jī)自啟必做決定用戶第二天還會不會打開它主題換膚延后視覺打磨可以迭代不影響可用性農(nóng)歷與節(jié)假日標(biāo)注延后數(shù)據(jù)維護(hù)成本高先留好擴(kuò)展位多視圖周/日/年延后會和極簡定位沖突容易把產(chǎn)品做臃腫云同步不做引入賬號體系和服務(wù)端成本與收益嚴(yán)重不匹配這張表里最值得說的是“多視圖延后”這一條。我一開始很想做周日視圖覺得功能更完整但真做出來之后發(fā)現(xiàn)桌面日歷的窗口尺寸本來就小塞進(jìn)日視圖之后每一條事件只能顯示幾個字反而更難用。后來砍掉界面立刻清爽了。在常駐型小工具里“少而順”永遠(yuǎn)打得過“多而擠”。2. 技術(shù)選型我為什么最后落在 Electron 這套組合上2.1 四條主流技術(shù)路線的橫向?qū)Ρ茸烂嫒諝v這類應(yīng)用可選的技術(shù)路線其實挺多我把當(dāng)時認(rèn)真考慮過的幾條列出來做了對比。這張表里的數(shù)字是我自己實測的粗略值不同項目會有出入但相對關(guān)系基本可信。路線上手成本安裝包體積常駐內(nèi)存跨平臺生態(tài)與 UI 表現(xiàn)力Electron 前端框架低會前端就能做偏大60-120MB較高150-300MBWindows/macOS/Linux最強(qiáng)CSS 想怎么畫怎么畫Tauri 前端中要碰一點 Rust很小5-15MB低50-100MB三平臺強(qiáng)但部分系統(tǒng)能力要自己封裝Python Qt 系中中40-80MB中80-150MB三平臺中樣式定制偏費勁C# WPF/WinUI中中低基本只 Windows強(qiáng)但平臺鎖定純 Web 瀏覽器新標(biāo)簽頁極低無取決于瀏覽器全平臺中拿不到系統(tǒng)能力這張表如果只看體積和內(nèi)存Tauri 明顯更優(yōu)我一度也很心動。但最后沒選它原因在下一節(jié)。2.2 我的選型邏輯權(quán)重給錯了選型就會跑偏選型最容易犯的錯是拿一堆維度平鋪著打分最后得出一個“綜合最優(yōu)”的結(jié)論。但真實項目里各維度權(quán)重根本不一樣得按自己的場景重新排。對我來說第一權(quán)重是“能不能快速把想法變成能看的東西”。桌面日歷這種小工具最大的成本不在寫代碼而在反復(fù)調(diào)整視覺細(xì)節(jié)——間距差 2px、圓角大一點、透明度調(diào)到多少才不糊。這類調(diào)整必須能即時預(yù)覽改完保存立刻看到效果。前端技術(shù)棧在這一點上幾乎沒有對手。第二權(quán)重是系統(tǒng)能力的覆蓋度。托盤、開機(jī)自啟、系統(tǒng)通知、多屏信息、休眠喚醒事件這些是桌面日歷的骨架。Electron 把這些都封裝成了現(xiàn)成接口文檔齊全遇到問題搜一下基本都有答案。Tauri 也能做但部分能力要自己寫插件或者調(diào)底層對我這種想快速出成品的人來說學(xué)習(xí)成本不劃算。第三權(quán)重才是體積和內(nèi)存。這兩個指標(biāo)用戶確實在意但可以通過工程手段壓下去——打包時排除開發(fā)依賴、壓縮資源、控制渲染頻率實際做下來安裝包能壓到 60MB 出頭常駐內(nèi)存穩(wěn)定在 180MB 左右。對一臺 16GB 內(nèi)存的機(jī)器來說這個數(shù)字可以接受。所以我的結(jié)論是如果這個工具是給一群人用的產(chǎn)品體積和內(nèi)存的權(quán)重會往上提Tauri 更合適如果是給自己或者小范圍用Electron 的效率優(yōu)勢壓倒一切。這不是技術(shù)優(yōu)劣問題是場景匹配問題。2.3 工程目錄與三個進(jìn)程的職責(zé)劃分Electron 的結(jié)構(gòu)看著簡單但職責(zé)劃分不清代碼很快就會爛成一鍋粥。我采用的是“主進(jìn)程管系統(tǒng)、渲染進(jìn)程管界面、preload 管橋接”的三段式目錄大致長這樣desktop-calendar/ ├── src/ │ ├── main/ # 主進(jìn)程 │ │ ├── index.js # 入口應(yīng)用生命周期 │ │ ├── window.js # 窗口創(chuàng)建與位置管理 │ │ ├── tray.js # 托盤與菜單 │ │ ├── reminder.js # 提醒調(diào)度器 │ │ ├── store.js # 數(shù)據(jù)讀寫封裝 │ │ └── logger.js # 本地日志 │ ├── preload/ │ │ └── index.js # 暴露受限 API 給渲染進(jìn)程 │ └── renderer/ # 渲染進(jìn)程 │ ├── index.html │ ├── styles/ │ │ ├── base.css │ │ └── theme.css │ └── scripts/ │ ├── calendar.js # 日期網(wǎng)格生成 │ ├── view.js # 渲染與事件綁定 │ └── ipc.js # 與主進(jìn)程通信 ├── assets/ │ └── icons/ └── package.json劃分原則很明確凡是碰系統(tǒng)的東西一律放主進(jìn)程。窗口、托盤、通知、文件讀寫、開機(jī)自啟全在主進(jìn)程里。渲染進(jìn)程只負(fù)責(zé)畫界面和收用戶輸入需要數(shù)據(jù)就通過 IPC 要。preload 里只暴露白名單方法絕不開nodeIntegration也不關(guān)contextIsolation。這么劃分的好處在后期加功能的時候特別明顯。比如后來加“事件提醒”我只在reminder.js里加調(diào)度邏輯渲染進(jìn)程那邊什么都不用改只是多了一個接收通知的通道。反過來如果一開始把數(shù)據(jù)讀寫寫在渲染進(jìn)程里后面想加個后臺掃描就得大改。注意contextIsolation: true和nodeIntegration: false這兩項在新版本里已經(jīng)是默認(rèn)值但很多老教程還在教人關(guān)掉它們圖省事。桌面日歷雖然體量小但它有文件讀寫能力一旦渲染進(jìn)程被注入惡意腳本關(guān)掉隔離等于把門敞開。3. 日歷核心算法一個月有幾天這事沒那么簡單3.1 月份網(wǎng)格生成從“這個月第一天是周幾”說起日歷界面的本質(zhì)是一個二維網(wǎng)格。要做的事就三步算出這個月第一天是周幾、算出這個月有幾天、把上個月尾巴和下個月開頭補(bǔ)齊填滿網(wǎng)格。聽著簡單但每一步都有細(xì)節(jié)。先說一個我踩過的坑。很多人第一反應(yīng)是手寫閏年判斷然后寫一堆if (month 2)的分支。其實 JS 的Date對象有個非常巧的用法可以直接拿到天數(shù)把日期設(shè)成“下個月的第 0 天”它會自動回退到本月最后一天。這個技巧背后的原理是Date構(gòu)造器對越界日期的自動進(jìn)位處理知道原理之后用起來心里有底。/** * 生成一個月視圖的日期網(wǎng)格 * param {number} year 年份如 2024 * param {number} month 月份1-12 * param {number} weekStart 一周起始日0周日1周一 * returns {Array} 長度為 rows*7 的數(shù)組每項含 date/isCurrentMonth/isToday */ function buildMonthGrid(year, month, weekStart 1) { // 本月第一天 const firstDay new Date(year, month - 1, 1); // 本月天數(shù)傳下個月的第 0 天自動回退到本月最后一天 const daysInMonth new Date(year, month, 0).getDate(); // 第一天是周幾0周日 ... 6周六 const rawWeekday firstDay.getDay(); // 換算成相對 weekStart 的偏移量 const leading (rawWeekday - weekStart 7) % 7; const cells []; // 用本月第一天的日期做基準(zhǔn)向前后推算最穩(wěn)妥 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; }這里有個設(shè)計決策值得說我固定生成42 格6 行而不是按實際需要生成 5 行或 6 行。理由很實在——行數(shù)變化會導(dǎo)致整個面板高度跳動切月份的時候界面一抖觀感很差。固定 42 格多余的格子用上個月或下個月的日期填上并做淡化處理布局永遠(yuǎn)穩(wěn)定。代價是有些月份會多出一整行“下個月日期”看起來有點冗余。我的處理是把這些格子的透明度降到 30% 左右鼠標(biāo)懸停才恢復(fù)視覺上很自然。心得new Date(year, month, day)這組參數(shù)里month 是 0 基的0 是一月但很多業(yè)務(wù)代碼習(xí)慣用 1-12。我一開始在函數(shù)邊界上沒統(tǒng)一導(dǎo)致有一個月整體錯位了一格排查了半天。后來定死規(guī)矩對外接口一律用 1-12進(jìn)函數(shù)第一件事就減 1這類錯位問題就再沒出現(xiàn)過。3.2 周起始日與本地化一個被嚴(yán)重低估的開關(guān)一周從周幾開始這件事看著小事但在實際使用里影響不小。國內(nèi)習(xí)慣周一開頭部分歐美地區(qū)習(xí)慣周日開頭。如果這個值寫死在代碼里以后想擴(kuò)展就是一次重構(gòu)。所以我把weekStart做成一個可以配置的參數(shù)存到設(shè)置里默認(rèn)值是 1周一。實現(xiàn)上就是上面代碼里那行取模運算const leading (rawWeekday - weekStart 7) % 7;加 7 再取模是為了處理負(fù)數(shù)。比如weekStart 1、當(dāng)天是周日rawWeekday 0時0 - 1 -1不加 7 會得到 -1網(wǎng)格就錯位了。這里還有個隱藏問題周數(shù)的計算。ISO 8601 規(guī)定周一為一周開始且包含當(dāng)年第一個周四的那一周算作第 1 周。這個定義和“1 月 1 日所在周就是第 1 周”不一樣年底年初會出現(xiàn)跨年周。我一開始自己寫了一段周數(shù)計算結(jié)果 12 月 31 日那天顯示成第 53 周和系統(tǒng)日歷對不上。后來換成標(biāo)準(zhǔn)的 ISO 周數(shù)算法才對齊。如果只是想在界面上顯示“第幾周”而且不追求和系統(tǒng)日歷完全一致自己算也無所謂。但只要用戶會拿它和別處對照就必須按標(biāo)準(zhǔn)來否則會讓人覺得“你這日歷不準(zhǔn)”。3.3 農(nóng)歷與節(jié)假日標(biāo)注用數(shù)據(jù)驅(qū)動別用代碼硬編碼日歷類應(yīng)用很容易被要求加農(nóng)歷和節(jié)日標(biāo)注。這塊我的建議很明確算法歸算法數(shù)據(jù)歸數(shù)據(jù)兩件事分開做。農(nóng)歷轉(zhuǎn)換本身是一套固定的天文歷法推算邏輯實現(xiàn)方式有現(xiàn)成的算法可以移植核心是一張按年編排的編碼表加上換算規(guī)則。這部分可以做成一個純函數(shù)模塊輸入公歷日期輸出農(nóng)歷月日、是否閏月、節(jié)氣等信息。它不依賴任何外部數(shù)據(jù)邏輯一旦驗證正確就不會再變。節(jié)假日和調(diào)休標(biāo)注則完全相反它是逐年變化的數(shù)據(jù)而且每個地區(qū)規(guī)則不同。這類東西絕對不能在代碼里寫if (date 10-01)這種判斷必須走配置表。我的做法是放一個 JSON 數(shù)據(jù)文件結(jié)構(gòu)大致是{ 2024: { 2024-01-01: { type: holiday, label: 元旦 }, 2024-02-10: { type: holiday, label: 春節(jié) }, 2024-02-04: { type: workday, label: 調(diào)休上班 } } }界面上渲染的時候先按日期字符串查這張表查到就加對應(yīng)的標(biāo)記樣式。這樣每年只需要替換一個數(shù)據(jù)文件代碼一行都不用改。這個設(shè)計也帶來一個附加好處用戶可以自己往里加?xùn)|西。我把入口做成“自定義標(biāo)注”用戶想標(biāo)紀(jì)念日、朋友生日都往這張表里塞跟內(nèi)置數(shù)據(jù)走同一套渲染邏輯不用為“自定義”單獨寫一套代碼路徑。同一個渲染邏輯吃兩種數(shù)據(jù)源是省代碼的經(jīng)典做法。3.4 時區(qū)與夏令時用字符串當(dāng)業(yè)務(wù)主鍵別用時間戳這是我在數(shù)據(jù)設(shè)計上最關(guān)鍵的一個決定所有日歷事件業(yè)務(wù)主鍵用YYYY-MM-DD字符串不用時間戳。原因很直接。日歷事件的語義是“某一天有一件事”而不是“某個絕對時刻有一件事”。如果存時間戳用戶在 UTC8 存了 3 月 1 日的事件換到 UTC-5 時區(qū)打開可能就變成 2 月 29 日了。雖然桌面日歷用戶一般不會頻繁換時區(qū)但系統(tǒng)自動同步時間、夏令時切換這類情況是真實存在的。用字符串存日期就是日期不受任何時區(qū)換算影響。什么時候需要絕對時刻只有提醒那一個環(huán)節(jié)。所以我的做法是日期部分用字符串提醒時刻在運行時用“日期字符串 時間字符串”現(xiàn)拼成 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); // 用本地時間構(gòu)造避免 UTC 偏移 return new Date(y, m - 1, d, hh, mm, 0, 0).getTime(); }這段代碼看著平淡但new Date(y, m-1, d, hh, mm)這個重載非常關(guān)鍵——它按本地時區(qū)解釋參數(shù)。如果寫成new Date(2024-03-01T14:30:00)某些環(huán)境下會被當(dāng)成 UTC 解析差出整整 8 小時提醒全部錯位。這個坑我踩過一次測試的時候全對上線之后用戶反饋“提醒早了八小時”查了很久才發(fā)現(xiàn)是字符串解析的時區(qū)歧義。4. 桌面窗口形態(tài)透明、置頂、托盤這些細(xì)節(jié)怎么做4.1 無邊框透明窗口的關(guān)鍵配置桌面日歷的視覺基調(diào)取決于窗口本身。要做到“像貼在壁紙上”需要無邊框、透明背景、無陰影這幾項配合。核心配置大概是這樣const { BrowserWindow } require(electron); const path require(path); const win new BrowserWindow({ width: 420, height: 560, frame: false, // 去掉系統(tǒng)標(biāo)題欄 transparent: true, // 允許透明 backgroundColor: #00000000, // 全透明兜底別用默認(rèn)色 hasShadow: false, // 關(guān)掉陰影否則透明邊緣會有灰邊 resizable: false, skipTaskbar: true, // 不出現(xiàn)在任務(wù)欄 alwaysOnTop: false, // 是否置頂做成可配置 show: false, // 先隱藏加載完再顯示防閃爍 webPreferences: { preload: path.join(__dirname, ../preload/index.js), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false // 關(guān)鍵失焦后不節(jié)流定時器 } }); win.once(ready-to-show, () win.show());這里有三處值得單獨說。第一處是backgroundColor: #00000000。很多人設(shè)了transparent: true但沒設(shè)背景色在某些顯卡驅(qū)動下窗口會渲染成純黑。顯式給一個全透明的背景色能規(guī)避掉大部分這類問題。第二處是hasShadow: false。系統(tǒng)陰影是按矩形窗口算的一旦背景透明陰影會在圓角外面露出一圈灰邊非常難看。想要陰影效果用 CSS 的box-shadow在頁面內(nèi)部畫可控性高得多。第三處是backgroundThrottling: false。這個值默認(rèn)是true意思是窗口失去焦點或者被遮擋時渲染進(jìn)程的定時器會被降頻到每秒一次甚至更低。對普通頁面無所謂但對日歷來說是災(zāi)難——用戶切到別的窗口干活你的提醒定時器被節(jié)流提醒就可能延遲幾十秒甚至更久。我曾經(jīng)遇到過“提醒晚了一分鐘”的問題查了兩天才定位到這里。4.2 系統(tǒng)托盤與開機(jī)自啟讓用戶第二天還會打開它常駐型應(yīng)用沒有托盤基本等于廢了。窗口一關(guān)用戶找不到入口第二天就不會再打開。托盤的實現(xiàn)不復(fù)雜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: 開機(jī)自啟, type: checkbox, checked: false, click: (item) { app.setLoginItemSettings({ openAtLogin: item.checked, openAsHidden: true // 啟動時不彈窗只進(jìn)托盤 }); } }, { label: 退出, click: () { app.isQuitting true; app.quit(); } } ]); tray.setContextMenu(menu); // 點擊托盤圖標(biāo)也切換窗口 tray.on(click, () { win.isVisible() ? win.hide() : win.show(); }); }這里有幾個實戰(zhàn)細(xì)節(jié)。openAtLogin在 Windows 和 macOS 上表現(xiàn)不完全一樣。Windows 下它是往注冊表的啟動項里寫macOS 下走的是登錄項。開發(fā)模式下調(diào)用會指向 Electron 的可執(zhí)行文件而不是你的程序所以一定要用app.isPackaged判斷只在打包后生效否則開發(fā)時每次開機(jī)都彈出一堆調(diào)試窗口。另外“關(guān)閉窗口”和“退出程序”要區(qū)分開。我最初的實現(xiàn)里用戶點關(guān)閉直接win.close()結(jié)果window-all-closed事件觸發(fā)程序整個退出了。正確做法是攔截close事件改成隱藏win.on(close, (e) { if (!app.isQuitting) { e.preventDefault(); win.hide(); } });加一個app.isQuitting標(biāo)志位從托盤菜單走退出時才真正關(guān)閉。這個小邏輯看起來瑣碎但它決定了用戶對這個程序的印象是“關(guān)不掉的流氓軟件”還是“懂事的常駐工具”。4.3 多屏與 DPI 縮放拔掉副屏之后窗口去哪了多屏是桌面應(yīng)用逃不開的坑。我在測試階段就遇到過一次把窗口拖到副屏然后拔掉副屏再啟動程序窗口出現(xiàn)在一個不存在的位置完全看不見只能改配置文件恢復(fù)。解決辦法是保存窗口位置時不要只存絕對坐標(biāo)要存“所在顯示器的標(biāo)識 相對該顯示器工作區(qū)的偏移”?;謴?fù)時先按標(biāo)識找到顯示器找不到就退回主屏。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包含任務(wù)欄占用的區(qū)域workArea是扣掉任務(wù)欄之后的可用區(qū)域。用bounds算窗口貼底的時候會被任務(wù)欄蓋住一截。DPI 縮放是另一個隱性坑。在 125% 或 150% 縮放的屏幕上window.devicePixelRatio會變成 1.25 或 1.5如果用固定的像素值去做布局字會糊、邊框會虛。我的做法是全部尺寸用 CSS 的相對單位rem、em、百分比需要精確像素的地方用devicePixelRatio換算并且監(jiān)聽display-metrics-changed事件在縮放變化時重新計算布局。4.4 鼠標(biāo)穿透怎么做到“看得見但點不到”“鼠標(biāo)穿透”是桌面日歷里一個很有價值的能力。開啟之后鼠標(biāo)點在這個區(qū)域上會直接落到下面的桌面或其他窗口上日歷變成純展示層。這個功能在用戶只需要看日期、不需要交互的時候特別有用。Electron 提供了對應(yīng) API// 開啟穿透forward 讓窗口仍然能收到 mousemove 事件 win.setIgnoreMouseEvents(true, { forward: true });但這里有個細(xì)節(jié)坑forward選項只在部分平臺和版本上生效也就是開啟穿透之后頁面可能收不到鼠標(biāo)移動事件你就沒法判斷“鼠標(biāo)是不是移到了可交互區(qū)域上”。我的解決方案是分層處理。窗口默認(rèn)開啟穿透但我在頁面里鋪了一層透明的“熱區(qū)”監(jiān)聽同時用主進(jìn)程的screen.getCursorScreenPoint()做定時輪詢間隔 100ms 左右判斷光標(biāo)是否落在需要交互的元素區(qū)域內(nèi)。是的話就臨時關(guān)掉穿透離開再打開。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);這個輪詢看著有點糙但實測很穩(wěn)而且 100ms 的間隔對 CPU 的壓力幾乎可以忽略。比起折騰平臺相關(guān)的底層窗口屬性這條路省心得多。注意穿透開啟之后窗口上的按鈕就點不到了。一定要留一個“關(guān)閉穿透”的入口而且這個入口本身不能在被穿透的區(qū)域里否則用戶開了之后就再也關(guān)不掉。我第一版就犯了這個錯最后是通過托盤菜單救回來的。5. 數(shù)據(jù)層與提醒機(jī)制休眠之后鬧鐘不能失聲5.1 本地存儲的表結(jié)構(gòu)設(shè)計桌面日歷的數(shù)據(jù)量極小用不上服務(wù)端數(shù)據(jù)庫本地 SQLite 完全夠用。它的好處是單文件、零配置、支持 SQL 查詢打包進(jìn)應(yīng)用也不用額外裝服務(wù)。表結(jié)構(gòu)我設(shè)計得很簡單-- 事件表 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, -- 簡單重復(fù)規(guī)則如 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); -- 設(shè)置表鍵值對形式 CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT );設(shè)計上有兩個決定值得解釋。一是event_date用 TEXT 存日期字符串。前面說過日期語義用字符串最穩(wěn)SQLite 對字符串索引支持也很好查詢“某月所有事件”用LIKE 2024-03%或者范圍查詢都很快。二是加索引。事件表數(shù)據(jù)量可能就幾千條不加索引也感覺不出差別。但日歷每次切月份都要重新查一次一天切幾十次加上索引能把這個開銷壓到幾乎為零。數(shù)據(jù)讀寫的封裝我放在主進(jìn)程渲染進(jìn)程通過 IPC 調(diào)用。這樣做的另一個好處是數(shù)據(jù)一致性只在一個進(jìn)程里維護(hù)不用擔(dān)心多處寫沖突。// 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 提醒調(diào)度為什么我不建議用 setTimeout 做核心調(diào)度提醒是桌面日歷里最容易出錯的功能。我一開始的實現(xiàn)思路很直覺給每個事件算一個觸發(fā)時間然后setTimeout到點執(zhí)行。寫完之后測了幾個事件都正常就以為完事了。問題出在長時間運行上。setTimeout的計時依賴系統(tǒng)單調(diào)時鐘一旦系統(tǒng)休眠這段時間的計時會停滯喚醒之后不會自動補(bǔ)齊。比如設(shè)了一個 3 小時后的提醒中途筆記本合蓋休眠 2 小時喚醒后這個定時器可能還要從暫停的地方繼續(xù)走實際觸發(fā)時間就晚了。換成setInterval輪詢之后問題解決了一大半。思路是不做事先排程改成周期性掃描“當(dāng)前時刻應(yīng)該觸發(fā)哪些提醒”。const POLL_INTERVAL 30 * 1000; // 30 秒掃一次 let lastScanAt 0; function scanDueReminders() { const now Date.now(); const windowStart lastScanAt || now - POLL_INTERVAL; // 查出所有設(shè)置了提醒的事件逐條判斷是否落在 (windowStart, now] 區(qū)間內(nèi) 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 秒人感知不到。第二系統(tǒng)休眠之后喚醒下一次掃描會自動把休眠期間應(yīng)該觸發(fā)的提醒補(bǔ)上因為windowStart到now的區(qū)間覆蓋了這段時間。第三天然冪等用“事件 id 日期”做去重鍵重復(fù)掃描也不會重復(fù)提醒。代價是需要一個去重記錄。我直接存在內(nèi)存里一個 Set程序重啟會清空但重啟之后lastScanAt也歸零不會重復(fù)補(bǔ)發(fā)邏輯上是自洽的。5.3 休眠喚醒與時間跳變兩類異常要分開處理輪詢方案能解決“定時器停滯”的問題但還有兩類異常要單獨處理。第一類是系統(tǒng)休眠喚醒。雖然輪詢能補(bǔ)上但喚醒的瞬間如果剛好在兩次掃描之間用戶可能要多等半分鐘才能看到提醒。所以我會監(jiān)聽電源事件喚醒之后立刻手動觸發(fā)一次掃描const { powerMonitor } require(electron); powerMonitor.on(resume, () { logger.info(system resumed, force scan); lastScanAt Date.now() - 10 * 60 * 1000; // 回補(bǔ) 10 分鐘窗口 scanDueReminders(); });把lastScanAt往前推 10 分鐘是為了覆蓋“休眠前后剛好跨越觸發(fā)點”的情況。如果不回補(bǔ)休眠前一秒應(yīng)該觸發(fā)的提醒就永遠(yuǎn)丟了。第二類是系統(tǒng)時間被手動調(diào)整。用戶可能為了測試改了系統(tǒng)時鐘或者對時服務(wù)校正了時間。如果時間被往回調(diào)lastScanAt可能比now還大區(qū)間變成負(fù)數(shù)掃描就全亂了。所以我在掃描開頭加了保護(hù)if (now lastScanAt || now - lastScanAt 30 * 60 * 1000) { // 時間回跳或者跳得太遠(yuǎn)不做補(bǔ)發(fā)直接重置錨點 lastScanAt now; return; }這條規(guī)則的意思是正常情況 30 秒掃一次如果發(fā)現(xiàn)距上次掃描超過 30 分鐘說明中間發(fā)生了異常休眠、時間調(diào)整、進(jìn)程卡死這種情況不做批量補(bǔ)發(fā)避免一下子彈出幾十條提醒把用戶淹沒。“錯過的提醒怎么處理”這個決策比“怎么提醒”更重要。我的選擇是超過 30 分鐘的提醒不再彈窗而是在界面頂部展示一個“有 3 條錯過的提醒”的折疊條點開才看。這樣既不會騷擾用戶也不會讓信息徹底丟失。6. 渲染與視覺讓日歷看起來不像后臺管理系統(tǒng)6.1 避免整月重繪一天的格子不該重建 42 次日歷界面有個特點切月份的時候要更新 42 個格子但大部分內(nèi)容其實沒變只是日期數(shù)字變了。如果用“先清空容器再重建所有節(jié)點”的寫法每次切換都是一次完整的 DOM 重建42 個節(jié)點加上事件綁定在低配機(jī)器上會有肉眼可見的卡頓。我的做法是節(jié)點復(fù)用。初始化的時候建好 42 個格子節(jié)點之后切月份只更新每個格子的文本內(nèi)容和 class不重新創(chuàng)建節(jié)點。const cells []; // 初始化時創(chuàng)建一次 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存日期比在閉包里維護(hù)映射表更直觀也方便事件委托時直接讀到。事件綁定我全部走委托只在最外層容器上掛一個點擊監(jiān)聽通過event.target.closest(.cell)找到被點的格子。42 個格子掛 42 個監(jiān)聽器和掛 1 個監(jiān)聽器內(nèi)存和事件分發(fā)開銷差得很遠(yuǎn)。gridContainer.addEventListener(click, (e) { const cell e.target.closest(.day-cell); if (!cell) return; openEventPanel(cell.dataset.date); });6.2 主題系統(tǒng)用 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); }切換主題只需要在根節(jié)點上改>function applyTheme(name) { document.documentElement.dataset.theme name; window.calendarApi.saveSettings({ theme: name }); }背景透明度這塊有個細(xì)節(jié)。半透明背景在 Electron 里會疊加窗口本身的透明如果兩個透明度不協(xié)調(diào)會在某些壁紙上顯得發(fā)灰。我實測下來淺色主題用 0.82 左右的背景透明度比較舒服既能看清內(nèi)容又能透出一點壁紙紋理。毛玻璃效果backdrop-filter: blur()看起來很美但它在 Electron 里的性能開銷不小尤其是窗口一直開著的時候 GPU 占用會明顯上升。我的策略是默認(rèn)關(guān)閉毛玻璃做成可選開關(guān)開啟時把模糊半徑控制在 8px 以內(nèi)。6.3 字體與排版日期數(shù)字對齊是基本功日歷的排版里有個容易被忽視的細(xì)節(jié)數(shù)字對齊。日期從 1 號到 31 號位數(shù)不同如果用比例字體數(shù)字的寬度不一樣網(wǎng)格里的日期會左右跳看起來很不規(guī)整。解決方法是開啟等寬數(shù)字特性.day-cell .num { font-variant-numeric: tabular-nums; font-feature-settings: tnum 1; text-align: center; line-height: 1; }這行 CSS 的作用是讓每個數(shù)字占據(jù)相同的寬度1 和 11 和 31 的寬度一致視覺上就整齊了。很多日歷應(yīng)用沒做這一步湊近看會覺得日期有點歪。字號層級也要克制。桌面日歷窗口小字號超過三檔就會顯得雜亂。我的配置是日期數(shù)字 15px、星期表頭 11px、事件文字 12px再加一個“今天”的徽標(biāo) 11px。整塊面板只有四級字號層次清楚又不花哨。行高也很關(guān)鍵。日歷格子里同時有日期數(shù)字和事件縮略文字行高設(shè)小了擠在一起設(shè)大了格子撐不住。我用的是 1.3 倍行高配min-height讓格子高度由內(nèi)容撐開但有下限這樣有事件和沒事件的格子高度不會差太多。7. 打包分發(fā)與長期維護(hù)最后一步最容易翻車7.1 安裝包體積怎么壓下來第一版打出來的安裝包有 118MB用戶下載要等看著也不專業(yè)。我做了幾輪優(yōu)化最后壓到 62MB 左右過程不算復(fù)雜。優(yōu)化手段效果說明排除開發(fā)依賴減少 20MB 左右electron-builder默認(rèn)只打包dependencies但要確認(rèn)工具鏈沒混進(jìn)去啟用 asar 打包體積變化不大但啟動更快把源碼打成單文件省掉大量小文件 IO壓縮圖片資源減少 8MB 左右圖標(biāo)統(tǒng)一轉(zhuǎn)成合適尺寸別塞 1024px 的原圖移除未使用的語言包減少 10MB 左右Electron 自帶的本地化文件按需保留關(guān)閉 source map減少 5MB 左右生產(chǎn)構(gòu)建不需要其中“移除未使用的語言包”這一項效果最明顯但要注意別刪過頭否則系統(tǒng)菜單會變成空白。我的做法是保留界面用到的語言其余刪掉。7.2 數(shù)據(jù)遷移表結(jié)構(gòu)改了之后老用戶怎么辦第二版我加了個tags字段如果直接改表結(jié)構(gòu)老用戶的數(shù)據(jù)庫不會自動加列程序一讀就報錯。所以必須有遷移機(jī)制。我的做法是在設(shè)置表里存一個schema_version啟動時對比當(dāng)前版本低于目標(biāo)版本就按順序執(zhí)行遷移腳本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)); } } }關(guān)鍵點是遷移腳本只增不改。已經(jīng)發(fā)布的遷移不能回頭修改否則老用戶和新用戶的數(shù)據(jù)庫狀態(tài)會不一致。這個規(guī)矩在數(shù)據(jù)庫圈子里是老生常談但在小工具項目里經(jīng)常被忽略。7.3 日志出問題的時候你只有它桌面應(yīng)用的 bug 很難復(fù)現(xiàn)用戶描述往往是“有時候會消失”“偶爾不提醒”。沒有日志排查全靠猜。我在主進(jìn)程里寫了個極簡的日志模塊按天切分文件單個文件超過 2MB 就輪轉(zhuǎn)最多保留 7 天。日志內(nèi)容只記關(guān)鍵事件應(yīng)用啟動、窗口位置恢復(fù)、提醒觸發(fā)、異常捕獲。日常運行的細(xì)節(jié)不記避免文件迅速膨脹。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; // 超限就丟棄當(dāng)天的后續(xù)日志 fs.appendFileSync(file, line); } catch (e) { // 日志失敗不能影響主流程 } }最后那行注釋很重要。日志模塊本身出問題磁盤滿、權(quán)限不足絕對不能把主程序帶崩。所有日志寫入都包在 try/catch 里吞掉異常。8. 常見問題與排查實錄8.1 問題速查表這張表是我這幾個月攢下來的按“現(xiàn)象”查基本能覆蓋八成問題。現(xiàn)象可能原因排查方法解決思路窗口背景顯示成純黑沒設(shè)透明背景色或顯卡驅(qū)動不兼容先改背景色測試顯式設(shè)backgroundColor: #00000000提醒延遲幾十秒到幾分鐘窗口失焦導(dǎo)致定時器被節(jié)流打日志看觸發(fā)時刻關(guān)掉backgroundThrottling休眠喚醒后提醒丟失定時器在休眠期間停擺記錄休眠前后時間戳改輪詢 監(jiān)聽resume事件補(bǔ)掃關(guān)窗口后程序直接退出沒攔截close事件觀察window-all-closed攔截close改成hide()拔掉副屏后窗口不見了保存了絕對坐標(biāo)打印保存的坐標(biāo)存顯示器 id 相對偏移開機(jī)自啟彈出調(diào)試窗口開發(fā)模式下調(diào)用了自啟接口看是不是打包版本用app.isPackaged判斷字體發(fā)虛、邊框模糊DPI 縮放沒適配看devicePixelRatio尺寸用相對單位監(jiān)聽縮放變化切月份時界面抖動網(wǎng)格行數(shù)不固定數(shù)一下每月行數(shù)固定 42 格日期整體錯位一個月月份 0 基和 1 基混用在函數(shù)入口打印參數(shù)對外統(tǒng)一 1-12進(jìn)函數(shù)減 1提醒早或晚整整 8 小時日期字符串被當(dāng) UTC 解析打印getTime()對比用new Date(y, m-1, d, hh, mm)8.2 幾個印象最深的坑第一個坑是透明窗口在特定機(jī)器上失效。我自己的兩臺機(jī)器上都正常交付給一個同事之后他那臺機(jī)器上窗口背景是黑的內(nèi)容完全看不見。查下來是顯卡驅(qū)動對透明窗口的支持有差異某些驅(qū)動版本下transparent: true會退化。最后的兜底方案是啟動時檢測透明是否生效用一個已知顏色的像素采樣判斷失效就自動切到“不透明 圓角遮罩”的降級模式。雖然不好看但至少能用。第二個坑是提醒重復(fù)觸發(fā)。有一段時間用戶反饋“同一個事件提醒了兩次”。原因是掃描邏輯里用了和雙閉區(qū)間如果兩次掃描的邊界剛好落在同一個觸發(fā)時刻上就會被算兩次。改成左開右閉(windowStart, now]再加上“事件 id 日期”的去重鍵問題徹底解決。區(qū)間邊界用開還是閉一定要想清楚語義這種錯在小邏輯里特別隱蔽。第三個坑是開機(jī)自啟路徑帶空格。Windows 下如果程序裝在Program Files這類帶空格的目錄里注冊表啟動項里的路徑不加引號會被截斷程序啟動失敗。這個問題只在特定安裝路徑下出現(xiàn)測試時很難覆蓋到。后來我在設(shè)置自啟時手動給路徑加了引號并且在設(shè)置完之后讀回來校驗一次。第四個坑是托盤圖標(biāo)在某些 Linux 桌面環(huán)境下不顯示。這個屬于平臺差異Electron 在 Linux 上的托盤依賴桌面環(huán)境的支持部分環(huán)境需要額外的庫。我的處理是在托盤創(chuàng)建失敗時不阻斷主流程只是記一條日志程序繼續(xù)以窗口形式運行。心得這四類坑有個共同點——都只在特定環(huán)境或長時間運行后才暴露。所以桌面應(yīng)用的測試不能只在開發(fā)機(jī)上跑一遍得多找?guī)着_不同配置的機(jī)器放置一兩天觀察。有條件的話寫個簡單的自檢腳本把透明支持、托盤可用性、DPI 縮放、多屏信息這些環(huán)境相關(guān)的能力在啟動時探一遍日志里記下來出問題時一眼就能看出是環(huán)境問題還是代碼問題。寫到這里桌面日歷系統(tǒng)這套東西基本攤完了。從最初覺得“這東西兩三天就能搞定”到后來花了差不多三周時間反復(fù)打磨窗口形態(tài)和提醒機(jī)制最大的感受是桌面常駐型應(yīng)用的難度不在功能而在邊界情況。功能寫出來只是及格線真正決定好不好用的是休眠喚醒之后鬧鐘還響不響、拔掉副屏之后窗口還在不在、開一整天之后內(nèi)存漲不漲。這些地方?jīng)]有任何捷徑只能一條一條踩過去。我自己現(xiàn)在還在用的那條經(jīng)驗是每加一個新特性先問一句“它在系統(tǒng)休眠、時間跳變、多屏切換這三種情況下會怎樣”問完再動手寫返工能少一大半。