零依赖 Canvas 星轨时钟:从零实现一个可嵌入网页的动态时钟

举报
yd_246800664 发表于 2026/11/08 00:44:07 2026/11/08
【摘要】 用原生 Canvas 2D 与 requestAnimationFrame 实现一个零依赖的星轨时钟:从 devicePixelRatio 高清适配、星点呼吸动画,到时分秒指针的角度插值与平滑转动。全文给出关键代码与完整思路,最终产物是单个 HTML 文件,双击即可运行,适合作为个人主页的轻量装饰组件。

为什么要写这个小东西

最近想给个人主页加一个装饰性的时钟组件,试过几个现成的库之后觉得都偏重:要么依赖额外的框架,要么打包后几十 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,没有任何外部依赖。相比引入一个时钟库,自己写一遍的好处是每一层都可控:想要节日配色、想要把星点换成粒子轨迹,改几行就行。如果你也需要在页面里放一个轻量又有辨识度的小组件,不妨从这个思路开始。

本文为原创实践记录,代码均可直接运行,欢迎交流改进思路。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。