那天心血来潮,想给个人网站右上角加一个指针时钟,觉得不就是拿个
Date对象算角度,然后让三根针转起来吗?太简单了。结果一跑起来,秒针跟喝醉了似的在钟面上抽搐,我盯着屏幕愣了好一会儿。
我最初的"聪明"办法
一开始我脑子里冒出的方案特别直接:写三个 div 当指针,用 JS 每秒获取一次时间,分别算出秒、分、时的旋转角度,然后用 element.style.transform = 'rotate(' + deg + 'deg)' 往上一怼。HTML 几分钟就搭好了:
html
<div class="clock">
<div class="hand hour-hand"></div>
<div class="hand minute-hand"></div>
<div class="hand second-hand"></div>
</div>
CSS 大概长这样,给指针设了宽度、高度、背景色,绝对定位到钟面中心,当时以为这就齐活了:
css
.hand {
position: absolute;
left: 50%;
bottom: 50%;
transform-origin: 50% 100%; /* 我当时甚至没加这一行 */
/* ... */
}
JS 定时器一写,setInterval(updateClock, 1000),跑起来一看------指针确实是转了,但全都绕着钟面的左上角在转,而不是以钟面中心为轴。而且转的角度也不对,时针对的是小时数,但指向的位置乱七八糟。
截图:当时控制台没报错,但界面上指针完全脱轨,长这样:

说实话,那一刻我懵了,觉得"前端三权分立"这套东西怎么到我手里就失灵了。
别急着写 JS,先让 CSS 把家安好
我开始一行行排查,后来才意识到------我压根没理解 transform-origin 到底在干嘛。我看文档时,对这个属性的第一反应是:"哦,设置旋转中心点,百分比相对于元素自身宽高"。但 left: 50%; bottom: 50%; 只是让元素左上角定位到钟面中心,而指针元素默认旋转中心在自身正中间。一个细长的指针,中心在它自己身上,转起来当然歪到姥姥家去了。
正确的做法是:让指针底部作为旋转轴心。于是我把 transform-origin 设成 bottom center 或者 50% 100%,再把 bottom 调成 50%,让指针的底部刚好杵在钟面正中心。这一改,三根针总算老老实实绕着钟心转了。
css
.hand {
position: absolute;
left: 50%;
bottom: 50%;
transform-origin: bottom center; /* 这一句是灵魂 */
transition: transform 0.05s cubic-bezier(0.4, 2.3, 0.3, 1);
}
加上 transition 是想让指针转动时有细微的惯性摆动,视觉上灵动一点。但就是这个 transition,又给我后面埋了个大坑。
JS 只负责"算角度",CSS 负责"怎么动"
搞懂旋转中心之后,我开始重新看代码,琢磨出一个道理:这不就是前端的三权分立吗?HTML 只管结构------就是几个指针的 div,往钟面里一放;CSS 管样式,包括指针长啥样、怎么定位、旋转的过渡动画;JS 只负责一件纯粹的事------拿到真实时间,算出精确的角度,然后交给 CSS 去驱动视觉。
这样一来,JS 的代码就变得特别瘦,完全不用去操心 DOM 动画的细节:
javascript
const hourHand = document.querySelector('.hour-hand');
const minuteHand = document.querySelector('.minute-hand');
const secondHand = document.querySelector('.second-hand');
function updateClock() {
const now = new Date();
// 秒针:一秒 6 度 (360/60)
const secondsDeg = now.getSeconds() * 6;
// 分针:一分钟 6 度,但要加上秒针带来的微调
const minutesDeg = now.getMinutes() * 6 + now.getSeconds() * 0.1;
// 时针:一小时 30 度,加上分钟微调
const hoursDeg = now.getHours() * 30 + now.getMinutes() * 0.5;
secondHand.style.transform = `rotate(${secondsDeg}deg)`;
minuteHand.style.transform = `rotate(${minutesDeg}deg)`;
hourHand.style.transform = `rotate(${hoursDeg}deg)`;
}
setInterval(updateClock, 1000);
updateClock(); // 立即执行一次,避免开局空白一秒
你看,JS 函数体就这点东西,没有 style 的累赘,也没有手动改 left / top。当时我跑通这段,看着指针平滑地跟着系统时间走,内心特别舒畅,有种"这才叫各司其职"的醒悟。
刚得意没两分钟,秒针就给我上了一课
我以为故事到这里就结束了,结果就在我盯着秒针看的时候,注意到一个诡异的现象:秒针从 59 秒走到 0 秒那一瞬间,它没有平滑地往前走一小步,而是"唰"一下逆时针倒转了将近 360 度,像个被吓到的壁虎。
我第一反应是:transition 搞的鬼。因为我在 CSS 里写了 transition: transform 0.05s,浏览器会试图在两次角度更新之间插值过渡。从 59 秒的角度是 59 * 6 = 354deg,下一秒变成 0 秒的角度是 0deg,当时间从 59 秒跨到 0 秒,我的代码把角度从 rotate(354deg) 直接更新成了 rotate(0deg)。浏览器看到角度数值从 354 跳到了 0,按照 transition 的规则老老实实插值这两个数值,结果就是秒针逆时针旋转了 354 度------浏览器没有走顺时针 6 度的选项,因为 360 这个数值根本没出现在代码里,它无从得知 0 和 360 在几何意义上是同一个位置。视觉上就是倒着飞。
我当时立马去 Stack Overflow 翻了一通,发现解决方案有好几种。最干净的做法是:不要让角度从 354 直接掉到 0,而是持续累加,永远只增不减,这样浏览器永远在走最短的顺时针路径。也就是记录一个累加的角度偏移,与真实时间解耦:
javascript
let prevSeconds = -1;
let accumulatedSecondsDeg = 0;
function updateClock() {
const now = new Date();
const seconds = now.getSeconds();
// 处理从 59 到 0 的跨越
if (prevSeconds > seconds) {
accumulatedSecondsDeg += 360; // 进入下一圈,累加一整圈
}
prevSeconds = seconds;
const secondsDeg = seconds * 6 + accumulatedSecondsDeg;
secondHand.style.transform = `rotate(${secondsDeg}deg)`;
// 分针、时针也类似处理,不过跨度小,不加也没太大感知
}
但是,加了这段之后,另一个问题冒出来了:transition 还在,秒针从 354deg 跳到 360deg(实际视觉等同 0 度)时,transition 会把它理解成走了 6 度的顺时针动画,反而自然了。可长期跑下去,accumulatedSecondsDeg 会变成好几千,虽然不影响渲染,但总觉得不够优雅。于是我又换了个更"偏 CSS"的亡羊补牢法:干脆在秒针跨 0 的瞬间临时移除 transition,让它立刻跳变,然后再加回来。这个方案代码量稍多,但保持角度范围永远 0~360,逻辑更干净。
javascript
function updateClock() {
const now = new Date();
const seconds = now.getSeconds();
// 碰到 0 秒时,暂时关掉 transition 以避免倒转
if (seconds === 0) {
secondHand.style.transition = 'none';
} else {
secondHand.style.transition = 'transform 0.05s cubic-bezier(0.4, 2.3, 0.3, 1)';
}
secondHand.style.transform = `rotate(${seconds * 6}deg)`;
// ...更新其他指针
}
当时控制台打印出秒数,配合肉眼观察,确认那个倒转的鬼影终于消失了。
截图:修复后。

你看,一个看似简单的时钟,稍微往深了踩一脚,CSS 过渡和角度计算之间的配合,全是细节。
顺手看了眼规范,才懂"为什么"
后来我去翻了 CSS Transitions 规范里关于 transform 插值的部分,才彻底搞明白浏览器到底是怎么想的。
规范说的是:rotate() 角度值的插值,就是两个数值之间的线性过渡,没有任何几何上的"智能"。浏览器不管 354 度和 0 度在圆上是不是同一个位置,它只看数值------从 354 变到 0,数值在减小,那就逆时针减回去;从 354 变到 360,数值在增大,那就顺时针加上去。
所以我看到秒针倒转,根本不是浏览器算错了,恰恰是它算得太"对"了。我的代码写的是 rotate(0deg) 而不是 rotate(360deg),浏览器就老老实实地从 354 往 0 做线性插值,逆时针走完一整圈。这个行为跟"最短路径"一毛钱关系都没有------浏览器压根没想过要在圆上找近路,它只会在数值之间找直线。
这也解释了为什么累加数值和临时掐掉 transition 两种方案都能用。累加数值就是让角度只增不减,不给浏览器任何"数值回落"的机会;掐掉 transition 就是让这次更新不走插值逻辑,直接跳变。两种方案都是在跟同一个事实打交道:浏览器不是物理引擎,它不认识圆,它只认识数。
这一趟看下来,虽然花了点时间,但把 transition 和 transform 之间的配合彻底搞明白了,以后再遇到类似的进度环、旋钮组件,心里就有底了。
搞完这个时钟,我攒下了三个实在的收获
不用列提纲我也能顺着给你唠几句。第一个收获,transform-origin 这东西比我想象的重要得多,不是随便设个 50% 就完事,得先想清楚"你想让元素绕着哪个点转",再反过来用定位把那个点放到该在的位置。第二个收获,前端的三权分立真不是空话。HTML、CSS、JS 各干各的,一旦混在一起互相扯皮,Bug 就藏得深。第三个,transition 搭配旋转角度时,一定要检查跨 0 点的行为,要么累加数值,要么动态开关 transition,没有第三个偷懒的法子。
当然,这种用 setInterval + CSS 过渡的时钟不是万能的。如果你需要精准到毫秒级的计时,或者做秒表、倒计时,最好直接用 requestAnimationFrame 高频更新,或者干脆用 <canvas> 绘制。这个方案的可爱之处在于简单、好维护,特别适合装饰性时钟或教学场景。
顺手捡几个笔记里的碎片
写 HTML 结构那会儿,其实我只敲了一行:.clock>.hand*3,然后按一下 Tab,整个结构就出来了。这是 Emmet 的快捷语法,类名选择器写 .clock,子元素用 >,重复用 *3,连 div 标签都能省略------Emmet 默认生成的就是 div。说白了,HTML 就是一棵盒子套盒子的树,用选择器描述比手写标签快得多。这个技能属于那种"不用的时候没感觉,一旦用上就回不去"的类型,如果你还没用过,找个编辑器试一下就懂了。
CSS 文件我是在 <head> 里用 <link> 引入的,而不是扔到底部。笔记里特意记了一句:CSS 放头部加载,让样式尽早和 HTML 结合,页面不会裸奔 ,用户打开的第一眼就能看见正常的界面,这个体感差距很微妙但很重要。等静态页面渲染完,再让 JS 去添加行为------所以 <script> 我放在了 </body> 之前。如果把它塞进 <head>,浏览器会停下来去加载和执行脚本,阻塞后面 HTML 和 CSS 的解析,时钟半天出不来,用户早就关页面了。
说到这里想到一个笔记里记的夸张数字:网页每快 0.1 秒,用户满意度和付费转化率都可能往上蹿一截,对大厂来说甚至值上千万美金。这当然不是什么精确算法,但它提醒我一件事------用户等不了。时钟就算是个小玩意儿,加载顺序也得讲究,不然转得再漂亮,用户没耐心等到它出现也白搭。
这些点单独拿出来都不算高深,但凑在一起就撑起了"前端专业度"的那个底。HTML、CSS、JS 各司其职,连引入顺序都有门道,不是能用就行。以后写任何页面,我都会条件反射地先想结构,再铺样式,最后挂行为,文件拆开,link 往上丢,script 往下放。
好了,就这么多。搞懂了记得回来留个言,我也想知道你在这个时钟上踩过什么不一样的坑,或者有更巧妙的处理方式。