本页目录(14 节)
我是怎么做这三个网页版桌面的
起因很简单:我想在自己的主页上放点不只是「看图」的东西。
三个桌面——Windows 11、macOS Sonoma、Ubuntu 22.04——都是纯手写的 HTML/CSS/JavaScript,没有框架、没有构建步骤、没有任何外部依赖。三个合起来约 59 万字节源码、260 多个手写内联 SVG 图标、7000 多行逻辑。
这篇文章记录它们是怎么做出来的,以及我踩过的坑。坑的那部分比实现的部分有价值,因为每一个都让我改了做事的方法。
三条硬约束#
动手之前我给自己定了三条规矩:
- 纯原生:不用 React/Vue,不用打包工具。这不是炫技,是因为这个站是个人备案的静态站,我不想让它依赖任何构建产物。
- 零外部依赖:不加载 CDN、不加载在线字体、不用远程图片。整个站在断网状态下必须完整可用。唯一允许出现的 URL 是 SVG 命名空间
http://www.w3.org/2000/svg——那不是网络请求。 - 图标全部手写内联 SVG:不用图标字体,更不用 emoji。emoji 在不同系统上长相完全不同,一个要还原操作系统的界面用 emoji 是自相矛盾的。
为什么三个桌面是三个独立文档#
一开始我把三个桌面做成同一个页面里的三块 DOM。很快就崩了:
- 三套全局样式互相打架,
body、html、滚动条都在抢 - 三套窗口管理器并存,
.window、.taskbar、.dock这些类名重复,选择器互相命中 - 键盘快捷键冲突:macOS 的
Cmd+W和 Ubuntu 的快捷键同时响应
改成三个独立 HTML 文档、用 iframe 嵌进站点之后,每个桌面都是一个能单独打开的完整页面。它们各自拥有自己的 html、body、样式表和事件循环,从根上不会互相污染。
代价是站点和桌面之间不能直接通信。对我这个需求来说这个代价可以接受。
窗口管理器#
三个桌面共用同一套能力,各自按自己系统的真实行为实现:
- 拖动:按住标题栏移动
- 8 向缩放:四条边 + 四个角
- 点击置顶:点哪个窗口哪个窗口到最前
- 最大化 / 还原:双击标题栏
- 贴靠半屏:拖到屏幕左/右边缘
- 最小化:带动画收进任务栏或程序坞
这里有两个实现细节值得说。
用 pointer 事件,不要用 mouse 事件#
鼠标事件在指针移出窗口后就收不到了。拖动窗口时指针很容易瞬间跑到窗口外面,用 mouse 事件会「丢手」——窗口卡在半路不跟了。
pointer 事件配合把监听挂在 document 上就没这个问题:
bar.addEventListener("pointerdown", function (e) {
if (e.button !== 0) return; // 只响应左键
var startX = e.clientX, startY = e.clientY;
var g0 = getGeo(el); // 记下起始位置
function move(ev) {
setGeo(el, {
left: g0.left + (ev.clientX - startX),
top: g0.top + (ev.clientY - startY)
});
}
function up() {
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
}
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
});
最小化用 FLIP,不用改宽高#
最朴素的写法是给窗口加过渡、然后改 width/height/left/top。问题是这四个属性每改一次都会触发布局重排,动画会卡。
FLIP 的思路是:
- First:记下动画前的位置和尺寸
- Last:立刻把元素设成动画后的状态
- Invert:算出两者差多少,用
transform把它「扳回」到起点 - Play:把
transform过渡到 0
这样整个动画只动 transform 和 opacity,全程不触发布局,能稳定跑在合成层上。
材质这件事,三个系统完全不同#
这是最容易被想当然做错的地方。
- Windows 11 用 Mica:半透明底 +
backdrop-filter的模糊与饱和度提升。开始菜单、任务栏、窗口标题栏的材质强度还不一样。 - macOS 用毛玻璃:模糊半径更大,而且窗口失焦时交通灯会变灰(
#D6D6D6)——这个细节不做,一眼假。 - Ubuntu 22.04 的 GNOME 面板根本没有模糊,只有 alpha 通道。它的 Dock 背景是
rgba(19,19,19,.75),backdrop-filter: none。如果照抄 macOS 给它加一层模糊,懂的人一眼就能看出不对。
同样反直觉的还有窗口按钮的位置和颜色:Ubuntu 的窗口按钮在右边,顺序是「最小化 → 最大化 → 关闭」,而且关闭按钮悬停是灰色不是红色——红色关闭是 Windows 的习惯,Yaru 主题不用。
这些都是查官方设计规范(Microsoft Fluent、Apple HIG、GNOME/Yaru)逐条对出来的,不是凭印象画的。
动效:为什么只用一条缓动曲线#
三个桌面各自只用一条缓动曲线,所有时长限制在 120–300ms 之间:
- Windows 11:
cubic-bezier(0.1, 0.9, 0.2, 1) - macOS:
cubic-bezier(0.32, 0.72, 0, 1) - Ubuntu:
cubic-bezier(0.25, 0.46, 0.45, 0.94)
这不是为了省事。只有一条曲线时,任意两个动画同时出现,节奏都是一致的;一旦混用三四种缓动,界面就会显得「各动各的」。时长卡在 120–300ms 也是同一个道理:短于 120ms 人眼来不及感知,长于 300ms 会开始觉得迟钝。
四个把我坑了很久的 bug#
这部分是我真正想写下来的。
一、一层看不见的东西把整个桌面挡住了#
三个桌面的 CSS 里都有这一条:
.win-layer { position: absolute; inset: 0; z-index: 100; }
.win-layer 是装窗口的容器,inset: 0 让它铺满整个桌面区域。桌面图标在 z-index: 1,所以真实鼠标点击全被这一层截走了。
现象是:任务栏点得动(它在 stage 外面),桌面图标怎么点都没反应。
修法是让这一层对指针透明,而窗口是它的直接子元素,单独恢复:
.win-layer { pointer-events: none; }
.win-layer > * { pointer-events: auto; }
但真正的问题是我的测试方法:我的自动化测试是直接往图标元素上派发事件的,而合成事件会绕过命中检测——不管上面盖着什么,它都会触发。真实鼠标不会。
所以那套测试一路绿灯,bug 却一直在。现在所有点击测试都改成先 document.elementFromPoint(x, y),再在返回的那个元素上派发。这样只要有东西遮挡,测试立刻失败。
二、CSS 精简把「只在特定状态下存在」的规则删掉了#
站点样式表膨胀到 94 KB 之后,我写了个脚本清理死代码:遍历所有 CSS 规则,用 querySelector 逐个判断有没有元素匹配,不匹配的当死规则删掉。90 KB 缩到 34 KB,看起来很美。
然后有人点联系方式图标——没反应。
原因是脚本的执行顺序。它为了「让动态状态也出现」,先点开社交弹窗(容器获得 .active),又点关闭(.active 被移除),然后才开始扫描规则。扫描那一刻 .active 已经不存在,于是 .modal-overlay.active 被判成死规则,删了。
结果:JS 完全正常,.active 也加上了,标题内容都写进去了——但没有任何样式让它从透明变成可见。用户看到的就是「点了没反应」。
教训是四个字:先破坏现场,再取证。现在这类脚本改用「运行时 class 白名单」保护状态规则——凡是 JS 会动态增删的 class,相关规则一律不参与删除判断。
三、跨源 iframe 里的 localStorage 会抛异常#
这个坑很隐蔽。桌面嵌在站点里时,如果页面是通过 file:// 打开的,iframe 是一个独立的 opaque origin,访问 localStorage 会直接抛 SecurityError。
而我所有 localStorage 访问都写在 boot() 的调用链里。只要有一处没包 try/catch,boot() 就在那里中断——后面的 wire()(事件绑定)根本不会执行。
表现是:桌面画得出来(那是 HTML 和 CSS 的活),但整个桌面点不动(那是 JS 的活)。
现在三个桌面里所有存储访问都在 try/catch 里。宁可功能降级,也不能让一个异常掀掉整个启动流程。
四、无头浏览器验证的两个陷阱#
我所有验证都是无头 Chrome + 注入测试脚本 + 读回断言结果。有两个坑反复咬我:
--virtual-time-budget 会冻结 CSS 过渡。 它让虚拟时钟快进,但过渡动画不会推进,getComputedStyle 读到的 opacity/transform 永远停在起始值。有一次我据此判断「弹窗没显示出来」,查了半天才发现是测试环境的问题。修法是断言几何或可见性之前,先注入 <style>*,*::before,*::after{transition:none !important;}</style>——只关 transition,不能关 animation,否则读不到真实的动画时长。
脚本文件要带 BOM。 我用 PowerShell 写的测试脚本如果存成不带 BOM 的 UTF-8,Windows 上的 PowerShell 会按 ANSI 读,中文全变乱码,脚本直接语法报错——而且报错信息本身也是乱码,很难看出真正的原因。
关于「看起来像不像」#
有件事必须说清楚:我看不到自己做的界面。 我没有读图能力,所有关于视觉效果的字句,我都是用几何数据(元素矩形、计算样式、对比度数值)间接验证的,不是用眼睛看的。
所以我做的是:把能查到的设计规范逐条落成数值,写断言去卡这些数值,然后请人看。间距好不好看、材质对不对味、字形换行是否舒服——这些只能由人来判断。
一点感想#
这三个桌面都在网页界面演示里,可以直接点着玩。
做完这三个桌面,我最大的收获不是「学会了怎么做毛玻璃」,而是发现自己的测试方法有漏洞时那种后背发凉的感觉。
一个错误的测试比没有测试更危险,因为它会给你虚假的信心。上面第一和第二个 bug 能藏那么久,都是因为我的测试方式本身就在掩盖它们。
现在我的习惯是:每当一个 bug 逃过了测试,先问「我的测试为什么没抓到它」,再问「代码哪里错了」。