零依赖 Canvas 星轨时钟:从零实现一个可嵌入网页的动态时钟
为什么要写这个小东西
最近想给个人主页加一个装饰性的时钟组件,试过几个现成的库之后觉得都偏重:要么依赖额外的框架,要么打包后几十 KB。其实只要一个 Canvas 加几十行原生 JavaScript 就够了。本文记录从零实现一个「星轨时钟」的过程,最终产物是单个 HTML 文件,零依赖、双击即可运行,也可以直接嵌进任意页面。
最终效果:深色背景下是缓慢闪烁的星点,外圈为 12 小时刻度,时针、分针、秒针按真实时间平滑转动,中心叠加数字时间与日期。
一、整体结构
Canvas 是即时模式绘图,没有「修改某个元素」的说法,所以最可靠的做法是每帧清空后按层重画。整个实现分四层:
- 背景层:星点,带轻微的透明度呼吸效果;
- 刻度层:12 个刻度线与小时数字;
- 指针层:时、分、秒三根指针,按当前时间计算角度;
- 文字层:数字时间与日期。
所有绘制放进 requestAnimationFrame 循环,浏览器会按屏幕刷新率回调,比 setInterval 更稳,也不会在标签页隐藏时空转。
二、先解决高清屏发虚的问题
很多入门示例在 Retina 屏上会糊,原因是没有处理 devicePixelRatio。正确做法是让画布的像素尺寸等于 CSS 尺寸乘以 DPR,再用 setTransform 把绘图坐标系拉回 CSS 像素单位,之后所有坐标都可以按 CSS 像素来写:
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 绘图坐标系回到 CSS 像素
return { w, h };
}
window.addEventListener('resize', resize);
注意 canvas.width 是位图像素,而 clientWidth 是布局尺寸,两者混淆是发虚和变形的常见原因。
三、星空背景
星点用 0~1 的相对坐标保存,这样窗口尺寸变化时不需要重新生成位置。每颗星带一个随机相位和速度,用 sin 让透明度缓慢起伏,形成「呼吸」感:
const stars = Array.from({ length: 160 }, () => ({
x: Math.random(), y: Math.random(), // 相对坐标,窗口变化不重排
r: Math.random() * 1.2 + 0.3,
phase: Math.random() * Math.PI * 2,
speed: Math.random() * 0.0015 + 0.0005
}));
function drawStars(t, w, h) {
for (const s of stars) {
ctx.globalAlpha = 0.35 + 0.45 * Math.sin(t * s.speed + s.phase);
ctx.fillStyle = '#cfe4ff';
ctx.beginPath();
ctx.arc(s.x * w, s.y * h, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
这里只用了 160 个点,密度和性能都合适;如果想更华丽,可以把亮度分层,或者给少量星点加十字光芒。
四、指针角度与平滑转动
表盘一圈是 2π,时针每小时走 30°,分针和秒针每分钟走 6°。关键点是把小单位折算进大单位——分钟要加上秒数、小时要加上分钟数,并且把毫秒也算进秒里,指针才是连续滑动而不是每秒跳一格:
function drawHand(angle, length, width, color) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(0, length * 0.12); // 尾部留出一点配重
ctx.lineTo(0, -length);
ctx.stroke();
ctx.restore();
}
const now = new Date();
const sec = now.getSeconds() + now.getMilliseconds() / 1000;
const min = now.getMinutes() + sec / 60;
const hour = now.getHours() % 12 + min / 60;
drawHand(hour * Math.PI / 6, r * 0.50, 6, '#e8f0ff');
drawHand(min * Math.PI / 30, r * 0.72, 4, '#9dc4ff');
drawHand(sec * Math.PI / 30, r * 0.84, 2, '#ff7a7a');
ctx.save() / restore() 用来隔离每次平移与旋转,避免坐标系被多次叠加污染,这是 Canvas 绘图中很容易忽略的细节。
五、主循环
按层依次绘制即可,顺序决定遮挡关系:
function frame(t) {
ctx.clearRect(0, 0, w, h);
drawStars(t, w, h);
drawDial();
drawHands();
drawDigital();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
六、几个实用优化
- 暂停绘制:监听
visibilitychange,页面隐藏时取消动画帧,回到前台再恢复,省电也省 CPU; - 响应式:resize 时重算
w/h与圆心、半径,星点因使用相对坐标无需重建; - 兼容性:Canvas 2D 与
requestAnimationFrame在现代浏览器中均已支持,无需 polyfill; - 可扩展:把配色抽成主题变量、增加时区切换、或按鼠标位置做星点视差,都能在现有结构上小改动实现。
小结
完整实现不到 100 行,压缩后约 3 KB,没有任何外部依赖。相比引入一个时钟库,自己写一遍的好处是每一层都可控:想要节日配色、想要把星点换成粒子轨迹,改几行就行。如果你也需要在页面里放一个轻量又有辨识度的小组件,不妨从这个思路开始。
本文为原创实践记录,代码均可直接运行,欢迎交流改进思路。
- 点赞
- 收藏
- 关注作者
评论(0)