Featured image of post 苹果风格网页时钟小组件:上线心得与技术细节

苹果风格网页时钟小组件:上线心得与技术细节

聊聊博客主页新上线的苹果风格双时区网页时钟:从时区换算、指针角度,到昼夜表盘、深浅色适配和无障碍设计。

作者: 小景

文章来源: https://jingyuan-zheng.github.io/zh/p/apple-style-web-clock-widget/

本文介绍博客主页新上线的苹果风格双时区网页时钟小组件,并分享它背后的设计取舍、时区计算和模拟表盘实现。

现在打开桌面版博客主页,可以在搜索框下面看到两个模拟时钟:左边是访客的“你的时间”,右边是站长的“我的时间”。界面没有“世界时钟”标题,也没有显示我的姓名或所在国家。

它看起来只是主页上的一个小装饰,真正动手之后却碰到了不少值得讨论的问题:浏览器怎样处理时区和夏令时、模拟指针如何计算角度、网站主题与表盘昼夜如何分离,以及一个实时变化的视觉组件怎样兼顾双语和无障碍体验。

一个浅色白天表盘和一个深色夜间表盘组成的苹果风格网页时钟小组件

可复用的源代码已经发布到 GitHub: Jingyuan-Zheng/apple-style-web-clock-widget。 仓库采用 MIT 许可证,包含可独立运行的演示、零框架 CSS 与 JavaScript、中英文说明、配置示例,以及时区和指针角度测试。

为什么选择苹果风格

这个组件的视觉语言明显受到苹果时钟界面的启发:圆形表盘、纤细指针、克制的数字、醒目的橙色秒针,以及带有柔和层次的圆角卡片。

我并不想逐像素复制某一个苹果应用,而是希望保留它最值得借鉴的部分:

  • 不看文字也能立刻认出这是时钟;
  • 两个时区属于同一组信息,但又能快速区分;
  • 文字只负责解释必要信息;
  • 阴影和材质服务于层次,而不是抢走表盘的注意力。

所以最后留下的可见文字只有 “你的时间”“我的时间”。“世界时钟”这个标题其实没有增加新信息,姓名和国家也不是完成时间比较所必需的内容。删掉它们以后,组件反而更像一个安静地融入主页的系统小部件。

同一个此刻,两种墙上时间

时区功能最重要的原则,是两个表盘必须来自同一个瞬间,而不是各自独立计时。

每次刷新都先创建同一个 Date

1
const now = new Date();

访客时区由浏览器提供:

1
2
const localZone =
    Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";

另一个表盘读取网站预设的 IANA 时区。两者都交给 Intl.DateTimeFormat,再通过 formatToParts() 拆出年、月、日、时、分、秒。

这里没有简单地给当前时间“加几个小时”。固定写死 +1+2 看似方便,一旦遇到夏令时切换就会出错。让浏览器根据 IANA 时区和当前日期计算,才能在不同季节得到正确结果。

怎样计算时差

显示两个时间并不难,真正容易出错的是下面那行“相差几小时”。

组件会把目标时区格式化后的年月日时分秒,暂时当作 UTC 时间重新组合:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const wallTimeAsUtc = Date.UTC(
    year,
    month - 1,
    day,
    hour,
    minute,
    second,
);

const offsetMinutes =
    Math.round((wallTimeAsUtc - currentSecond) / 60000);

得到目标时区相对 UTC 的分钟偏移后,再减去访客本地的偏移量:

1
targetOffsetMinutes - localOffsetMinutes

我保留了小数时差的格式化,因为真实世界并不是所有时区都相差整数小时。有些地区相差半小时,甚至四十五分钟。一个世界时钟如果默认全世界都按整点划分,看起来简洁,实际并不可靠。

模拟指针其实是一道几何题

一圈是 360 度,因此每分钟和每秒对应 6 度,每小时对应 30 度:

1
2
3
const minuteAngle = minute * 6;
const hourAngle = (hour % 12) * 30 + minuteAngle / 12;
const secondAngle = second * 6;

时针公式里的 minuteAngle / 12 很重要。没有它,时针只会在整点突然跳到下一个数字;加上分钟带来的偏移后,时针才会随着时间平滑地走过两个小时刻度之间的位置。

每根指针本质上都是一个绝对定位的细长元素,旋转中心放在底部中央:

1
2
transform: rotate(var(--clock-rotation));
transform-origin: bottom center;

JavaScript 只更新角度变量,指针的长度、粗细、颜色和位置都留给 CSS。这样比每秒拼接一大段行内样式更清楚,也让视觉调整不必改动计时逻辑。

十二个数字则由 Hugo 模板循环生成。CSS 先把每个数字容器旋转到对应位置,再对子元素做反向旋转,让数字沿圆周排列却始终保持正立。

网站深浅色和表盘昼夜不是一回事

这个组件其实有两套互不相同的颜色逻辑。

外层卡片跟随博客的亮色或暗色主题,让它在右侧栏里看起来属于同一个网站;表盘本身则根据各自显示的时间判断白天和夜晚:

  • 07:00–18:59 使用浅色表盘;
  • 19:00–06:59 使用深色表盘。

这个区分是后来特别调整的。如果表盘也只跟随网站主题,那么用户把博客切到暗色模式后,即使某个时区正值白天,表盘也会变黑,原本有意义的昼夜提示就消失了。

现在两个表盘会分别检查自己的当地小时,因此一个浅色、一个深色是完全正常的状态,也正是世界时钟最直观的价值之一。

每秒重算,而不是自己数秒

组件使用 setInterval 每秒刷新一次,但它不会在上一次结果上简单加一秒。

每次触发都会重新读取当前 Date,再计算两个时区、时差和全部指针角度。浏览器把后台标签页的定时器暂停或延迟后,下一次刷新仍会直接回到正确时间,不会把延迟逐渐累积成走时误差。

同样的思路也能自然处理系统时间变化和夏令时切换。这里的定时器只是提醒界面重新绘制,真正的时间来源始终是系统时钟。

在 Hugo 里怎样接入

我把时钟写成一个独立的 Hugo partial,并在主页 widget 配置中把它放到搜索框之后。这样没有修改搜索组件本身,以后想调整顺序或移除时钟,只需要改 widget 列表。

整个实现分成三层:

  • Hugo 模板负责结构、十二个数字和中英文标签;
  • SCSS负责卡片、表盘、指针、阴影以及明暗状态;
  • TypeScript负责时区解析、时差计算和每秒更新。

GitHub 开源版本去掉了 Hugo 依赖,并把相同思路整理成标准 data-* 属性。普通网页无需框架或构建工具,也可以直接嵌入和配置。

组件只出现在宽度不小于 1024 像素的桌面右侧栏,手机端不会显示,也不会占据正文空间。这不是“移动版还没做完”,而是有意的取舍。手机屏幕有限,导航和文章内容的优先级明显高于一个辅助性的时间对比。

双语和无障碍不能最后再补

会动的指针对屏幕阅读器没有意义,因此每个表盘都会获得一段实时更新的无障碍说明,包含表盘名称、数字时间和时差。页面上已经可见的时差文字则对屏幕阅读器隐藏,避免同一信息被重复朗读。

文字由当前 Hugo 语言决定:

  • 英文使用 “Your time”“My time”“hour / hours”;
  • 简体中文使用“你的时间”“我的时间”“小时”。

内部解析固定使用公历和拉丁数字,避免计算逻辑受浏览器显示语言影响;真正呈现给读者的标签再根据页面语言本地化。把计算格式和展示语言分开,双语实现会稳定很多。

这个小组件带来的心得

做完以后,我最大的感受是:小组件最需要的不是更多功能,而是更清楚的边界。

我先后去掉了组件标题、姓名、国家和移动端版本;又把网站主题与表盘昼夜拆成两套逻辑。时区规则交给浏览器,表盘绘制则尽量留在简单的 CSS 和几何计算里。

最后它只做一件事:用两个时钟完成一次时间比较。没有设置面板,没有城市列表,也没有复杂交互。正是这种克制,让它更像主页的一部分,而不是一个在侧边栏里争夺注意力的迷你应用。

越小的界面,设计和工程之间的距离往往越近。多一行文字、多一个颜色条件、多一个断点,都会立刻改变视觉结果。这次上线也提醒了我:所谓精致,很多时候并不是不断添加,而是认真决定哪些东西可以不出现。