「Tokei 圆时」的开发简记
技术CSS, ui, SCSS

「Tokei 圆时」的开发简记

Published On
| 更新于
Updated On
21分钟阅读概述:作者分享了在开发 Tokei 过程中的一些简单技术细节

背景

CSS 时钟选题

几年之前,入门前端开发相关,过程当中可能都遇到过这样两个经典的练习选题:a. Todo List 待办清单; b. 纯 CSS 时钟。

前者可以巩固 JS 基础,或者快速入手成熟框架如 React 或 Vue 的 api,以及有相应的参考网站 TodoMVC.com,而后者其实没有什么实际意义,也带不来什么回报,或者最大的目的是为了增加新手入门时学习 CSS 的兴趣。(虽然吾辈开发至今还是认为:UI 样式的实现 / CSS 语句的书写总是繁琐且痛苦)

而吾辈开发「Tokei 圆时」显然并非纯 CSS 实现,时间的初始化,以及界面中更多繁复且相互影响的交互效果,都是最终通过 JS 实现。但本质的,这个作品更多的关注在 UI 层面, CSS(SCSS) 代码也占据了整个项目代码 60%,本质依然是 CSS-DEMO 项目。

性能影响说明

本站主要通过 CSS 语句:transform-style: preserve-3d; 开启 3D 效果演示,底层基于系统的 GPU 加速,对性能有高额使用,所以页面不建议长期打开停留,可以通过系统的任务管理器看到窗口处于活动状态下有明显的 GPU 使用占比

正文

移动端布局

两种主流的移动端布局方案

本文无意展开移动端布局方案,这里只简单提及。

吾辈将移动端布局笼统的归于两大类:“百分比布局” 和 “媒体查询布局”。

  1. “媒体查询布局” / 响应式布局

先讲讲“媒体查询布局”,基于 CSS 媒体查询语句(@media)实现。这样的布局方案,最大的优点体现在:布局自适应,可以一套前端代码同时适配移动端和 PC 端等宽屏设备上的界面 UI 展示,好的例子有:Github 官网TapTap 官网

(当下流行的原子类库 Tailwind CSS, 倡导的 UI 开发理念也是基于媒体查询布局,同时移动端优先,其提供了良好的媒体断点系统,极大的提升了相对比传统的基于 CSS 媒体查询语句来实现移动端布局的开发体验。本站点的响应式布局就受益于此)

  1. “百分比布局”

再来说说“百分比布局”,如果说“媒体查询布局”是响应设备分辨率而动态变化的,那么百分比布局方案实现的 UI 界面,用照片的角度来理解会更加直观,即:百分比布局一定是服务于移动端的,和当前设备所处分辨率直接相关,若直接百分比布局页面在 PC 端打开,那么得到的可能是如同“照片”一样,倍数级放大的的效果。(也因此,采用这样的布局方案,可能就意味着,在 UI 层面,需要两套相互独立的前端代码,一套服务于移动端,一套服务于宽屏 PC 端设备。

同样来看一个直观例子:来自小米商城的官网页面:PC 端的默认打开地址是 https://www.mi.com/shop ,而移动端的默认打开地址是:https://m.mi.com/ (该地址在 PC 端打开会强制跳转至 PC 版首页)

rem 布局

上面提及的“百分比布局”并不准确,只是吾辈开发时习惯使用 vw 这一百分比单位,来作布局参考,所以称为百分比布局。

在 vw 兼容性不佳(未获得普遍支持的古早浏览器系统中),那时基于 rem 的相对单位布局多是这样的,通过 JS 操作,定义 window.resize 事件回调函数,来不断修正根元素字号大小,其他元素基于此来定义宽高尺寸、方位、和字号大小。

(这里我们假定 UI 视觉稿宽度为 720px,它可以是典型的任何值,比如 365px 等)

<script>
  document.documentElement.style.fontSize =
    (window.innerWidth * 100) / 720 + "px";
  window.onresize = function () {
    document.documentElement.style.fontSize =
      (window.innerWidth * 100) / 720 + "px";
  };
</script>
<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
</body>

那么在 UI 设计稿中一个宽高分别为 300px, 400px 的元素,我们对其还原会在 CSS 语句中如此声明:

#some-ele {
  width: 3rem;
  height: 4rem;
}

上面代码中为何额外的乘以 100,这就涉及到 html 根元素字号设置最小到 12px 的限制(虽然我们可以通过浏览器提供的设置关闭这个最小字号限制,但是显然的,我们不能期望用户也会配合去更改浏览器本身的设置),来看下面的对照:

<script>
  document.documentElement.style.fontSize = window.innerWidth / 720 + "px";
  window.onresize = function () {
    document.documentElement.style.fontSize = window.innerWidth / 720 + "px";
  };
</script>
<style>
  /* UI 设计稿中一个宽高分别为 `300px`, `400px` 的元素 */
  #some-ele {
    width: 300rem;
    height: 400rem;
  }
</style>

显然的,width: 300rem;height: 400rem; 较之上面的 width: 3rem;height: 4rem; 来得更加直观,数值直接就是和 UI 设计稿中的 px 像素数值是 1:1 的关系。 但这里,我们关注对根元素字号设置的声明:window.innerWidth / 720 + "px",假若终端实际分辨率就是 window.innerWidth === 720px, 那么这个时候我们等效的设置了 document.documentElement.style.fontSize = 1px,

我们如此期望:在根元素 1px 字号大小设置下,width: 300rem; height: 400rem; 生效就是将元素的宽高设置为了 300px 宽, 400px 高。但是现实违背预期,原因就是上面提到的根元素字号最小 12px 的限制。所以当实际人为设置的根元素字号小于 12px 时,rem 相对单位总是以 12px 为准。

所以常见的,我们为了绕过这样的限制,人为的将比例扩大一百倍,数学式子里即表现为:window.innerWidth / 720 * 100 + "px",那么此时根元素的字号一般不会到达最小字号的限制。

而后在子元素的 CSS 声明中,手动的将 UI 稿的像素数值缩小 100 倍,即:

#some-ele {
  width: 3rem; // 300px
  height: 4rem; // 400px
}

rem + vw 布局

但是当下现代浏览器中,显然不用再考虑这样的兼容性,所以不再依赖 JS 来动态调整根元素字号大小。

仍然假定 UI 视觉稿宽度为 720px,我们非常便捷的如下设置:

global.css
:root {
  font-size: calc(100vw / 720 * 100);
}

#some-ele {
  width: 3rem;
  height: 4rem;
}

其中额外乘以 *100 的原因上面已经提及,是绕过根元素最小字号 12px 的限制;

这里需要说明的一点,无论对根元素设置的是什么单位:px, vw, vmax 等单位,浏览器对于相对单位 rem 的处理,最终都是回归到对根元素 px 单位为参照,所以我们也才关注更加直观的 Computed 一栏。

分辨率“边界限制”

设置好根元素的 vw 字号大小时,我们还有必要考虑到这样的情况,因为是取决于设备分辨率的百分比布局,页面的元素会出现过大或者过小的情况(比如用户设备是一款 4k 分辨率的带鱼屏,那么元素就会过大,造成页面 UI 异常问题),

所以我们如下处理十分有必要,对分辨率的上下界限作出限制,超过限制即使用常规的 px 单位,而不再使用 vw 百分比单位。

global.scss
$minBodyWidth: 380px;
$maxBodyWidth: 1200px;

:root {
  font-size: calc(100vw / 720 * 100);

  @media screen and (max-width: $minBodyWidth) {
    font-size: calc($minBodyWidth / 720 * 100);
  }

  @media screen and (min-width: $maxBodyWidth) {
    font-size: calc($maxBodyWidth / 720 * 100);
  }
}

#some-ele {
  width: 3rem;
  height: 4rem;
}

SCSS 进阶使用

SCSS 作为 CSS 的预处理器,其提供了强大的函数支持特性。

Tokei 的 UI 样式定义也是完全使用 SCSS,以下罗列一些吾辈在 Tokei 开发中用到的 SCSS 提供特性:

自定义工具函数

比如对于上面的 "rem + vw" 百分比布局方案,如果我们嫌弃 width: 3rem; font-size: .24rem 这样的语句不够直观,那么我们可以参照 Sass 文档 @function 来定义如下工具函数:

px.scss

在样式声明语句中如下调用:

@import @import "path/to/px.scss";

#some-ele {
  width: px(300);
  height: px(400);
}

(可在 Sass 官网 Playground 运行上述代码测试其效果) 当然吾辈并未过多使用 Sass@function,对于 width: 3rem 这样的 100:1 比值的赋值风格也能接受,上面代码只是提供参考。

循环遍历

如下的代码片段是对 Tokei 指针刻度的旋转处理:

for-usage-demo

使用 @mixin @include

Sass 提供了两种重用样式的手段: @extend@mixin + @include,文档中对选择哪种手段提供了参考:Extends or Mixins?

在 Tokei 大量的 SCSS 代码中,也离不开依赖 @mixin 对样式的封装复用,来看下面的示例代码片段:

mixin-usage-demo

时间同步的处理

修正周期调用下误差

一般的,页面显示时间有两个场景:倒计时,以及当前时刻,而两者如果都精确到秒的话,本质即是显示时间与系统时间的(尽可能趋于)同步问题。

一般的,我们在 JS 中可通过 window#setInterval如下设置一个周期动作:

window.setInterver(() => {
  console.log("action");
}, 1000);

但是 setInterval 能直接用在对时间的周期更新上吗,每隔 1s 更新页面的显示时间一次?答案是不能,JS 是单线程执行,众多任务都交由 JS 的单线程处理,而对于如 setIntervalsetTimeout 声明的延时任务,对于函数的第二参数 ms,假定赋值 3000,真实作用是:只能保证让线程在“最短” 3000ms 后回调延时任务,

也因此,在对时间的周期更新处理上,我们还可以考虑使用 setTimeout 来不断的修正下一次执行动作时间,而不是简单的使用 setInterval,同样指定期望执行周期 1s。来看如下代码示例(参考自: 由一个钟表引发的思考):

const action = () => {
  console.log(`@action`);
};
action(); // 期望每隔 1s 左右执行的周期性动作,比如当前时刻的更新;或者倒计时更新

let count = 0;
let t = window.performance.now();

window.timerId = window.setTimeout(function loop() {
  count++;
  action();
  const now = window.performance.now();
  const offset = (now - t) / count / 1000;
  // console.log(`do action again()`, 1000 - offset);
  window.timerId = setTimeout(loop, 1000 - offset);
}, 1000);

秒针“起跳”的处理

上文提及:“为了追求时间的尽量准确性,我们总是通过 JS 来同步时间。”,而对于 Tokei 这样一个 "PURE-CSS-UI" 作品,其实并不关注时间的准确性,所以主要依赖了 CSS 原生属性 CSS#animation-duration 来指定“指针元素”的动画效果,从而实现时间的“走动”(JS 在此时只负责提供初始时刻即可) 。

但同样的,其也有“秒的跳动问题”,更直接的说,即是对秒针“初始起跳”的处理,在获取到系统的初始时间后,因为 1s 之间有着 1000ms 的跨度(挺废话的),所以初始 JS 读取系统时间时,显然的,并不一定刚好在 1000ms 的整数倍上,

如果我们直接在读取到系统时间后即开始动画,那么可能有这样的视觉效果:页面上秒针的跳动并没有和系统的秒时间的跳动保持严格一致

所以我们应该对 1000ms 取余,以此作为实际秒针起跳的时间点,这样,在视觉效果上,页面上秒针的跳动和系统的秒时间的跳动更加趋向一致。

如下处理:

const htmlEle = document.documentElement;
const necDiff = 1000 - (Date.now() % 1000);

setTimeout(() => {
  const curDate = new Date();
  const h = curDate.getHours();
  const m = curDate.getMinutes();
  const s = curDate.getSeconds();
  htmlEle.style.setProperty("--ds", String(s));
  htmlEle.style.setProperty("--dm", String(m * 60 + s));
  htmlEle.style.setProperty("--dh", String(h * 3600 + m * 60 + s));
}, necDiff);

延伸

项目开源

  • Tokei 时钟开源分享,不过吾辈花去更多心思的是在一份冗长且各元素相互间充满繁复数值关系的 SCSS 代码,对此并不开源,而是开源 sass#cli 处理 .scss 文件生成的 CSS 代码。
  • 同时的,分别提供了 a.基于原生 DOM 操作 和 b.使用 Solid.js 作为 UI 层渲染框架` 的两个版本实现,前者更加轻量,无需打包。
  • 提供 .exe 安装包下载,由 Tauri 构建。

INSPIRED | 机械钟表的真实结构

⭐⭐⭐⭐⭐ Mechanical Watch,质量很高的博文,有趣且直观的交互性动画,了解机械表的内里实际构造。

最前

本文伴生:谁能凭回忆 要时光去逆流,更多的从感性和情绪的角度出发,阐述了吾辈为什么开发这么一款仿真钟表;本文则主要从理性和技术的角度出发,简要提及吾辈在开发「Tokei 圆时」过程中涉及到的一些技术细节点。

诚然上面谈到的一些“技术点”,实际分享价值并不大,但吾辈私心:是为这么个简单且美好的作品留下份真诚的文字记录,同时,也在全站整个博文系列中,拥有这么一组伴生文章,一篇感性又矫情,一篇理性且乏味,呼应对照,期望平衡。

「Tokei 圆时」的开发简记

https://blog.ninoh.cc/blog/a5-tokei-dev[Copy]
本文作者
ninohx96
创建/发布于
Published On
更新/发布于
Updated On
许可协议
CC BY-NC-SA 4.0

转载或引用本文时请遵守“署名-非商业性使用-相同方式共享 4.0 国际”许可协议,注明出处、不得用于商业用途!分发衍生作品时必须采用相同的许可协议。