Files
AskaEth 4c61b9f1ec docs: 同步 README 与开发文档(来自 main)
两份文档是分支无关的:说明两个分支各是什么、怎么判断 Win7 兼容性、怎么构建。
win7 分支只多一行 Electron 版本差异。
2026-09-15 15:43:38 +08:00

19 KiB
Raw Permalink Blame History

ClockLab · 开发文档

面向要改这个项目的人(包括接手的新会话)。使用者请看 README.md。

接手先读这三处:第〇节(五分钟了解现状)→ 第六节(两个分支与 Windows 7 限制,最容易踩)→ 第八节(踩坑记录,能省几个小时)。


〇、接手先读

这是什么

一个用于演练/演示的桌面时钟:一个可调速的时钟(走快、走慢、暂停、倒着走、一键快进)+ 一个无边框的满屏时钟窗口。Electron 应用,两个窗口(控制窗口 / 时钟窗口),没有服务端、没有数据库。

当前状态(截至最后一次提交)

项 状态
功能 全部完成:两窗口、各自置顶、数字/表盘两种样式、实时设定时间、倍率(正负 0 小数)、暂停/继续、快进五档、跳转动画
测试 152 条全过(6 个文件,见第五节)
平台 Windows x64(含Windows 7,见第六节)、Linux ARM64
产物 见下面「已有的包」
未做 Windows 的 portable 单文件 exe(卡在第八节 ①);表盘刻度数字

已有的包

dist/ClockLab-1.0.0-win7-x64.zip              Windows 7/10/11 x64(Electron 22)★ 会议室那台用这个
dist/ClockLab-1.0.0-win-x64.zip               Windows 10/11 x64(Electron 38,主线)
dist/ClockLab-1.0.0-linux-arm64.tar.gz        Linux ARM64(飞腾 D2000 等)

dist/ 与 node_modules/ 都在 .gitignore 里,不进仓库 —— 包按需重新打(第七节)。

第一件事:跑测试

pnpm install --ignore-scripts     # ⚠️ 必须 --ignore-scripts,原因见第八节 ④
npm test                          # 期望:152 条全过、退出码 0

--ignore-scripts 不是可选项:本机网络下 electron 的 postinstall 会失败,进而把整个 install 拖垮。跳过脚本不影响跑测试(测试是纯 Node)。

目录一览

main.js  preload.js  renderer/  src/  test/  build/  README.md  DEVELOP.md

一、项目结构与分层

main.js                      主进程:建两个窗口、持有走时模型、注册全部 IPC、夹窗口尺寸
preload.js                   contextBridge 暴露 window.clockApi(contextIsolation:true,无 nodeIntegration)
renderer/
  control.html/.css/.js      控制窗口:三块分区(时钟窗口 / 显示样式 / 时间与流速)
  clock.html/.css/.js        时钟窗口:拖动区 + 8 条改大小热区 + 数字/表盘两种视图
  resize.js                  拖边缘改大小的纯几何计算(单独拆出来是为了能在 node 里跑用例)
  presented-time.js          呈现时间层:跳转动画(纯逻辑,浏览器/node 双向可加载)
src/
  clock-model.js             走时模型:基准时间 + 基准时刻 + 倍率(纯函数,可单测)
  selftest.js                GUI 自检(electron . --selftest),跑完出截图,只在开发机上用
test/                        6 个文件、152 条用例(见第五节)
build/icon.ico, icon.png     程序图标
.npmrc                       依赖镜像(electron 运行时下载也走它)
pnpm-workspace.yaml          pnpm 11 的依赖构建放行表(见第八节 ④,**别删**)

分层原则:凡是能用纯函数表达的(走时、改大小几何、呈现时间)都单独成模块,页面里只做接线。所以 152 条用例里有 103 条不需要浏览器环境就能跑(改这三块时不用启动 Electron)。


二、三个关键实现

2.1 走时模型:锚点式,不累加

src/clock-model.js 只有三个动作,每一个都是"重新锚定基准",没有一处累加:

displayMs(clock, now) = clock.baseMs + (now - clock.baseRealMs) * clock.rate

setRate(clock, now, rate)   // 倍率、暂停(0)、倒转(负)、小数都走这里
  clock.baseMs = displayMs(clock, now)   // 先把当前显示时间固化 → 切倍率不跳变
  clock.baseRealMs = now
  clock.rate = rate
  if (rate !== 0) clock.lastRate = rate  // 暂停时记住上次倍率,供「继续」用

shift(clock, now, deltaMs)              // 快进/快退:与倍率完全解耦
setTimeOfDay(clock, now, h, m, s)       // 设定时间

为什么这么写:如果用 setInterval 每次 +1 秒,倍率不是整数时会累积漂移,而且暂停/倒转都要另开分支。锚点式写法下,倍率、暂停、倒转、快进是同一套机制的四个用法,互不打架("先倒转、再暂停、再 +5 分钟"不会丢掉倒转的偏移)。

两个窗口各自用 Date.now()(同一台机器)跑同一个公式,画面 60fps 重算。

2.2 呈现时间层:跳转动画

renderer/presented-time.js。数字和指针都从"呈现时间"推导,而不是各做一套动画。

function jumpBetween(previous, next) {          // 同一真实时刻,两个参数算出的目标值之差
  const at = next.baseRealMs;
  return targetAt(next, at) - targetAt(previous, at);
}
const animate = layer.snapshot !== null && snapshot.rate !== 0
                && Math.abs(jump) > JUMP_THRESHOLD_MS;   // 1500ms

三个细节,别在重构时弄丢:

  1. 改倍率不会被误判成跳变 —— 比较的是"同一真实时刻的两个目标值",而模型在切倍率时先把当前显示时间固化进基准,两个公式在这一刻必然相等;
  2. 数字和指针永远同步 —— 它们读同一个呈现时间,不会出现"数字到了、指针还在半路";
  3. 不会跨 12 点倒着转一圈 —— 动画的是时间,指针角度照样按正常公式从时间算(359°→1° 自然往前扫)。这是这类动画最经典的坑,用"动画时间而不是动画角度"直接绕开。

阈值 1500ms、缓动 0.35s ease-out。动画没结束又来新跳变时,从当前呈现值重新出发(不是跳回旧值重来)。

2.3 无边框窗口:拖动与改大小都是自己实现的

frame: false 之后,Windows 上靠系统做边缘命中不可靠,所以:

  • 拖动移动:CSS -webkit-app-region: drag。整块 .stage 是拖动区,按钮和 8 条热区单独声明 no-drag —— 否则按钮会被拖动区盖住、点不动。
  • 拖边缘改大小:渲染进程热区接 pointerdown/move,用「按下瞬间的窗口 bounds + 鼠标位移」算新 bounds,走 IPC 让主进程 setBounds。几何计算在 renderer/resize.js(纯函数,17 条用例)。
    • pointerdown 时用 sendSync 取 bounds —— 必须在这一刻拿到精确基准;
    • 顶到最小尺寸后继续拖,被拖的那条边停住,而不是把整个窗口推走(resize.js 里有专门处理与用例);
    • 主进程 applyBounds 再夹一次最小值并挡掉 NaN。

最小尺寸 260×150(main.js 的 MIN_SIZE)。


三、环境与命令

pnpm install --ignore-scripts   # 依赖(见第〇节,必须带这个参数)
npm start                       # 起应用
npm test                        # 152 条用例(纯逻辑 + DOM 桩)
npm run selftest                # GUI 自检:electron . --selftest,跑完出截图
  • 本机(Linux)跑 npm start 要有显示环境:无头时用 Xvfb :99 + --no-sandbox,且 GPU 会刷一堆 MESA 报错 —— 那是正常的(没有显卡),只要主进程不抛 JS 异常就算过。
  • 纯逻辑测试不依赖显示环境,随时能跑。

四、改代码的几条约定

  • 纯逻辑尽量往模块里放(src/clock-model.js、renderer/resize.js、renderer/presented-time.js),页面只接线 —— 这样测试不用浏览器。
  • 改行为就同时改用例,但改断言之前先想清楚:现有 152 条里有不少是钉边界的(例如"拖西边超过最小宽度时右边界不动"),它们是有意为之。
  • 数字输入的两个坑:
    1. HTML/框架里 type="number" 的值可能是 number 而不是字符串 —— 本项目一律显式 String(x) 再用;
    2. 补零:String(0) 是 "0",而时钟输入框按 HH:MM:SS 解析,所以必须 pad2。 ⚠️ 这个 bug 只在上午 9 点前才露出来(getHours() 返回个位数),白天怎么测都是绿的 —— 凡是跟"当前时间"耦合的逻辑,测试里都要当心(我们就在这里翻过一次车)。
  • 注释写为什么,不写"这里做了什么"。

五、测试(152 条)

文件 条数 覆盖
test/clock-model.test.js 26 走时:零漂移、倍率切换不跳变、暂停/继续、倒转、快进
test/resize.test.js 17 改大小几何:四边四角、最小尺寸夹紧
test/presented-time.test.js 30 跳变判定、阈值、重定向、暂停不缓动、换倍率不算跳变
test/renderer-clock.dom-test.js 24 时钟窗口 DOM 桩:渲染、样式切换、热区接线
test/renderer-clock-anim.dom-test.js 26 跳转动画全链路:参数下发 → 呈现时间 → 数字 + 指针
test/renderer-control.dom-test.js 29 控制窗口:各按钮发出的调用、输入夹紧、置顶开关

DOM 桩的做法是:把渲染进程的 JS 读进 vm 跑,喂一套最小 DOM 实现,断言它发出的 clockApi 调用。不必启动 Electron。


六、分支策略与 Windows 7 ★

两个分支,只差 Electron 版本

分支 Electron 给谁 备注
main 38.2.2 Windows 10 / 11、Linux ARM64 主线,功能开发在这里做
win7 22.3.27 会议室那台 Windows 7 x64 也照样能在 Win10/11 上跑

代码本身两个分支完全一样(连测试都是同一套)—— 只有 package.json 里的 Electron 版本不同。所以新功能在 main 上做完之后,把代码摘到 win7 上(cherry-pick 或直接合)重新出包即可,不需要维护两份逻辑。

为什么锁在 Electron 22

Electron 23 起彻底不支持 Windows 7 / 8 / 8.1。官方在 Windows 7/8/8.1 弃用公告 里写明:23 用的 Chromium 调用了 Win7 没有的系统 API,程序在 Win7 上一启动就报:

The procedure entry point GetPackageFamilyName could not be located
in the dynamic link library KERNEL32.dll

GetPackageFamilyName 这类是 Windows 8+ 才有的入口,Win7 的 kernel32.dll 里没有 → 加载器直接失败。

Electron 22.3.27 是最后一个支持 Win7 的版本(Chromium 108 / Node 16)。本项目要跑在会议室那台 Windows 7 x64 上,所以 win7 分支锁在这一版。

升级 Electron 之前先确认那台机器还需不需要 Win7 —— 还想保 Win7 就不能越过 22。

怎么判断一个包能不能在 Windows 7 上跑(三条,从易到难)

# ① 看包内的 Electron 版本(不是看 package.json!)
type  <解压目录>\version            # 期望:22.x.x

# ② 看 PE 的子系统版本(这条最直观)
file <解压目录>/ClockLab.exe
#   Electron 22 → "PE32+ executable for MS Windows 5.02 (GUI)"   ← 兼容到 XP 时代 ✓
#   Electron 38 → "PE32+ executable for MS Windows 10.00 (GUI)"  ← 声明需要 Win10 ✗

# ③ 看导入表里有没有 Win8+/Win10+ 专有 API(最硬的一条)
pip install --break-system-packages pefile        # 一次就够
python3 - <<'PY'
import pefile
pe = pefile.PE('ClockLab.exe')
names = {(e.dll.decode().lower(), i.name.decode()) for e in pe.DIRECTORY_ENTRY_IMPORT for i in pe.imports if i.name}
for t in ['GetPackageFamilyName','SetProcessDpiAwarenessContext','GetDpiForWindow','SetThreadDescription']:
    hits = [d for d, n in names if t in n]
    print(('FOUND  ' if hits else 'absent '), t, hits)
PY
# 22 版的实测结果:这四条全部 absent(644 个导入条目里一个都没有)

⚠️ 别用"在文件里搜字符串"判断 —— Chromium 会运行时动态解析大量 API,这些名字出现在文件字节里很正常,搜到不代表会在启动时被调用(我第一次就是这么误报的,见第八节 ⑥)。


七、构建与打包

7.1 Windows(在 Windows 构建机上)

必须在真 Windows 上打,不要在这台 Linux 上跨平台打。踩过的坑见第八节,能跑的流程:

:: 1) 依赖:必须 --ignore-scripts
pnpm install --ignore-scripts

:: 2) 运行时:把对应版本的 electron zip 手工解压到一个目录
::    ⚠️ node_modules/electron 是 pnpm 的**符号链接**,tar 拒绝"穿过符号链接"解压
::       → 先解到临时目录,再 robocopy 进去(或像下面这样直接用独立目录)
mkdir D:\tmp\electron-dist
cd /d D:\tmp\electron-dist && tar -xf D:\tmp\electron-v22.3.27-win32-x64.zip

:: 3) 出包:直接调 electron-builder 的 CLI(`pnpm run` 会先做依赖检查、可能 EPERM)
::    用 --config.electronDist 明确告诉它运行时在哪,别让它走"下载"那条分支
node node_modules\electron-builder\out\cli\cli.js --win --x64 ^
     --config.electronDist=D:\tmp\electron-dist

:: 4) 压缩:它的 zip target 会去下 7zip 工具(见第八节 ①),自己压更稳
cd dist && ren win-unpacked ClockLab-1.0.0-win7-x64
tar -a -c -f ClockLab-1.0.0-win7-x64.zip ClockLab-1.0.0-win7-x64
  • 第 3 步跑完,dist\win-unpacked\ 就是可运行的完整程序(第 4 步只是把它压成 zip);
  • 第 4 步里那个 ren 是必须的(第 4 步要在 dist 目录里执行)—— 顺手把目录名带上平台,免得两份包解压出来混淆。
  • 运行时下载:https://npmmirror.com/mirrors/electron/<版本>/electron-v<版本>-win32-x64.zip(浏览器/curl 都行;别依赖 ELECTRON_MIRROR 环境变量,它的安装脚本可能因为要抓校验文件而 404)。

7.2 Linux ARM64(在本机打)

手工拼,不要用 electron-builder(它在 Linux 上有一条必崩的路径,见第八节 ①):

cp -a node_modules/electron/dist/. dist/ClockLab-1.0.0-linux-arm64/     # 运行时
mv dist/ClockLab-1.0.0-linux-arm64/electron dist/ClockLab-1.0.0-linux-arm64/clocklab
mkdir -p dist/ClockLab-1.0.0-linux-arm64/resources/app                  # 应用代码
cp main.js preload.js package.json dist/ClockLab-1.0.0-linux-arm64/resources/app/
cp -a src renderer dist/ClockLab-1.0.0-linux-arm64/resources/app/
tar -C dist -czf dist/ClockLab-1.0.0-linux-arm64.tar.gz ClockLab-1.0.0-linux-arm64

验证:本机就是 arm64,解压后 ./clocklab 起得来即说明链接与资源都对(无头时配 Xvfb)。


八、踩坑记录

看这一节能省几个小时。 这些都是实际撞过的,不是转述。

① electron-builder 在"需要下载"这条分支上必崩

⨯ Cannot read properties of undefined (reading 'ReadWrite')
    at resolveCacheMode (app-builder-lib/src/util/electronGet.ts:131)

根因:ElectronDownloadCacheMode 在运行环境里是 undefined,代码却直接读它的 .ReadWrite。26.15.3 与 26.16.1 都有,Windows 与 Linux 都复现。触发点是"它认为需要下载 Electron 运行时 / 7zip 工具",所以:

  • 用 --config.electronDist=<运行时目录> 让它不需要下载;
  • 打 zip 时它仍要 7zip 工具,那条路也会崩 → 压缩自己来。

② 手工放运行时还不够,得让它"知道"运行时在哪

  • electron 包装里的 path.txt 不是 electron-builder 的判据(它走 @electron/get 的缓存机制);
  • 正解是给它 electronDist。

③ 网络:下 Electron 运行时的几种办法与各自的坏法

  • 直连 GitHub:可能失败(%TEMP% 里堆一排 electron-download-* 重试目录);但也不一定 —— 我们后来实测同一条网络两个源都通 200,所以先试直连,失败再绕;
  • ELECTRON_MIRROR 指 npmmirror:可能 404(它的安装脚本会去抓校验文件,镜像上没有);
  • gh 加速站:实测可能 403;
  • 最稳的兜底:在能上网的机器上把 zip 下好(下载跟平台无关,Windows 版的 zip 在 Linux 上下是一模一样的),再传过去手工放。

④ pnpm 11 的 strictDepBuilds

只要有依赖的构建脚本被跳过,pnpm install 会直接以退出码 1 失败(ERR_PNPM_IGNORED_BUILDS),而 electron 的 postinstall 正是下载运行时那一步。放行写在 pnpm-workspace.yaml 的 allowBuilds(package.json 的 pnpm 字段与 .npmrc 的 onlyBuiltDependencies 在 pnpm 11 已不生效)—— 这个文件别删。

⑤ pnpm run 会先做依赖状态检查

pnpm run dist:win 可能触发一次 install,在 node_modules 被动过的目录上会 ERR_PNPM_EPERM(rename 失败)。直接调 CLI(node node_modules/electron-builder/out/cli/cli.js …)可绕过。

⑥ 判断 Win7 兼容性:别搜文件字节,要读导入表

第一次我用"在整个 exe 里搜 GetPackageFamilyName"来判断,误报了 —— 那个字符串确实在文件里,但不在导入表里。Chromium 大量使用运行时动态解析(GetProcAddress)来兼容老系统,字符串出现很正常。

判据是导入表(见第六节末尾那段脚本),再配上 file 读出的 PE 子系统版本,两条一起看。

⑦ 时间相关的 bug 只在特定时段露头

String(0) 是 "0" 而输入框要 HH:MM:SS —— 这个 bug 白天测全绿、上午 9 点前必挂(getHours() 返回个位数)。凡是与"当前时间"耦合的逻辑,写测试时要格外小心(要么注入时钟,要么至少知道它在什么时段会失败)。


九、已知可以再做的

  • Windows 的 portable 单文件 exe 还没做成(卡在 ⑧① 的 7zip 工具那条路)—— 绿色版 zip 功能等价,只是多一步解压;
  • 表盘没有刻度数字(只有刻度线与指针);
  • main 与 win7 的代码同步目前靠手工 —— 如果以后两边都要频繁改,可以考虑用一个构建期替换(例如把 Electron 版本做成环境变量)来避免分叉;
  • 飞腾/龙芯等国产平台的适配:本项目的 Linux 包是 ARM64(飞腾 D2000 可用);龙芯是 LoongArch,Electron 没有那个版本,跑不了。

十、变更记录(简)

时间 内容
2026-09-14 深夜 项目建立:两窗口、走时模型、无边框窗口的拖动与改大小、152 条测试;Linux ARM64 包
2026-09-15 加了跳转动画(呈现时间层,数字与指针共用);修了 pad2 那个时段性 bug
2026-09-15 得知会议室是 Windows 7 → 开 win7 分支降到 Electron 22.3.27 并出包(main 保持 38 不变)