前言

在博客的页首区域,除了站点标题和一句副标题,还可以放置一些与访问时刻有关的信息。相比必须请求定位和第三方接口的天气组件,一个只依赖浏览器本地时间的“时光状态组件”更加轻量,也不会带来定位权限和接口失效问题。

这个组件会显示:

  • 访客设备所在时区的实时时间
  • 当前日期与星期
  • 随早晨、上午、中午、下午、晚上和深夜变化的问候
  • 今天已经过去的百分比
  • 根据白天或夜晚自动变化的色彩
  • 移动端布局与“减少动态效果”设置
  • PJAX 页面切换后的自动恢复

本站目前暂时停用了页首展示,因为我还在调整它最合适的位置;源码和功能仍然完整保留。你可以把它放在首页页首、侧栏、导航栏或页脚。

这个组件同样不是 Hexo 专属。核心只使用原生 HTML、CSS 和 JavaScript,任何能够引入静态文件的网页都可以使用。


一、组件的设计思路

1. 不依赖服务器时间

组件通过浏览器的 Date 获取访客设备时间:

1
2
var now = new Date();
var hour = now.getHours();

因此,中国访客看到北京时间,身处其他时区的访客则会看到自己的本地时间。组件不会请求服务器,也不需要配置时区。

2. 根据时段改变问候

一天被划分为多个时段,每个时段有不同图标、问候和配色。例如晚上会显示月亮,深夜会提醒访客注意休息。

3. 计算今日进度

将当前的时、分、秒转换为从零点开始经过的秒数,再除以一天的 86400 秒:

1
2
3
4
5
6
var secondsToday =
hour * 3600 +
now.getMinutes() * 60 +
now.getSeconds();

var progress = (secondsToday / 86400) * 100;

这个数值会同时更新进度条和右侧百分比。

4. 为什么暂时不加入天气

天气组件通常需要:

  • 请求浏览器定位权限,或者让访客手动选择城市
  • 调用 Open-Meteo、和风天气等第三方接口
  • 处理接口限额、跨域、网络错误和缓存
  • 向访客说明定位数据的用途

对于博客页首而言,这些成本可能超过天气信息本身带来的价值。因此当前版本只提供稳定、无权限请求的时间信息,天气适合作为后续可选模块。


二、源码下载

压缩包包含:

1
2
3
4
5
unmyic-header-moment/
├── clock.js
├── clock.css
├── butterfly-inject.yml
└── README.txt

为了便于直接复制,下面同时贴出完整源码。本站的长代码块会限制显示高度,可以点击展开或使用右上角复制按钮。


三、完整 JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
/**
* 首页页首时光状态组件。
* 时间使用访客设备的本地时区,不请求定位或第三方天气服务。
*/
(function () {
'use strict';

var root = null;
var timerId = null;

function pad(number) {
return String(number).padStart(2, '0');
}

function getPeriod(hour) {
if (hour >= 5 && hour < 9) {
return { greeting: '早上好,愿今天从容展开', icon: '🌅', name: 'morning' };
}
if (hour >= 9 && hour < 12) {
return { greeting: '上午好,保持好奇与专注', icon: '☀️', name: 'day' };
}
if (hour >= 12 && hour < 14) {
return { greeting: '中午好,记得稍作休息', icon: '🌤️', name: 'noon' };
}
if (hour >= 14 && hour < 18) {
return { greeting: '下午好,继续探索吧', icon: '⛅', name: 'afternoon' };
}
if (hour >= 18 && hour < 23) {
return { greeting: '晚上好,适合阅读与思考', icon: '🌙', name: 'evening' };
}
return { greeting: '夜深了,也要照顾好自己', icon: '✨', name: 'night' };
}

function createMoment() {
var siteInfo = document.getElementById('site-info');
if (!siteInfo) {
root = null;
return null;
}

var existing = document.getElementById('header-moment');
if (existing) {
root = existing;
return root;
}

root = document.createElement('section');
root.id = 'header-moment';
root.className = 'header-moment';
root.setAttribute('aria-label', '当前日期时间与今日进度');
root.title = '时间以你的设备时区为准';
root.innerHTML =
'<div class="hm-time-row">' +
'<span class="hm-period-icon" aria-hidden="true"></span>' +
'<time class="hm-clock" aria-label="当前时间"></time>' +
'</div>' +
'<div class="hm-context-row">' +
'<span class="hm-date"></span>' +
'<span class="hm-divider" aria-hidden="true"></span>' +
'<span class="hm-greeting"></span>' +
'</div>' +
'<div class="hm-progress-row" title="从今天 00:00 到当前时刻">' +
'<span class="hm-progress-label">今日进度</span>' +
'<span class="hm-progress-track" aria-hidden="true">' +
'<i class="hm-progress-fill"></i>' +
'</span>' +
'<span class="hm-progress-value"></span>' +
'</div>';

var socialIcons = siteInfo.querySelector('#site_social_icons');
if (socialIcons) {
siteInfo.insertBefore(root, socialIcons);
} else {
siteInfo.appendChild(root);
}

return root;
}

function updateMoment() {
if (!root || !root.isConnected) {
root = createMoment();
}
if (!root) return;

var now = new Date();
var hour = now.getHours();
var period = getPeriod(hour);
var secondsToday =
hour * 3600 +
now.getMinutes() * 60 +
now.getSeconds();
var progress = (secondsToday / 86400) * 100;

root.dataset.period = period.name;
root.querySelector('.hm-period-icon').textContent = period.icon;
root.querySelector('.hm-clock').textContent =
pad(hour) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds());
root.querySelector('.hm-date').textContent =
new Intl.DateTimeFormat('zh-CN', {
month: 'long',
day: 'numeric',
weekday: 'long'
}).format(now);
root.querySelector('.hm-greeting').textContent = period.greeting;
root.querySelector('.hm-progress-fill').style.width =
progress.toFixed(3) + '%';
root.querySelector('.hm-progress-value').textContent =
progress.toFixed(1) + '%';
}

function startMoment() {
updateMoment();
if (timerId === null) {
timerId = window.setInterval(updateMoment, 1000);
}
}

document.addEventListener('pjax:complete', startMoment);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) updateMoment();
});

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startMoment);
} else {
startMoment();
}
})();

四、完整 CSS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#header-moment {
--hm-accent: #8ed8ff;
width: min(430px, calc(100vw - 38px));
box-sizing: border-box;
margin: 18px auto 12px;
padding: 13px 17px 12px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(20, 42, 75, 0.34), rgba(34, 71, 118, 0.18));
box-shadow:
0 12px 34px rgba(5, 20, 46, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.9);
text-align: left;
text-shadow: 0 1px 8px rgba(0, 19, 48, 0.32);
backdrop-filter: blur(13px) saturate(125%);
-webkit-backdrop-filter: blur(13px) saturate(125%);
}

.hm-time-row {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
}

.hm-period-icon {
display: inline-grid;
width: 28px;
height: 28px;
place-items: center;
font-size: 20px;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.22));
}

.hm-clock {
color: #fff;
font-family:
"SFMono-Regular",
"Cascadia Code",
"Roboto Mono",
Consolas,
monospace;
font-size: clamp(25px, 4vw, 34px);
font-weight: 650;
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
line-height: 1.1;
}

.hm-context-row {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: 9px;
margin-top: 7px;
color: rgba(255, 255, 255, 0.78);
font-size: 12px;
}

.hm-divider {
width: 3px;
height: 3px;
flex: 0 0 3px;
border-radius: 50%;
background: var(--hm-accent);
box-shadow: 0 0 9px var(--hm-accent);
}

.hm-progress-row {
display: grid;
grid-template-columns: auto minmax(70px, 1fr) 38px;
align-items: center;
gap: 9px;
margin-top: 10px;
color: rgba(255, 255, 255, 0.68);
font-size: 10px;
}

.hm-progress-track {
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}

.hm-progress-fill {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #63c9ff, #c8efff);
box-shadow: 0 0 9px rgba(99, 201, 255, 0.7);
transition: width 0.4s ease;
}

.hm-progress-value {
font-variant-numeric: tabular-nums;
text-align: right;
}

#header-moment[data-period='evening'],
#header-moment[data-period='night'] {
--hm-accent: #b9b7ff;
background:
linear-gradient(135deg, rgba(17, 24, 58, 0.48), rgba(48, 43, 91, 0.25));
}

#header-moment[data-period='evening'] .hm-progress-fill,
#header-moment[data-period='night'] .hm-progress-fill {
background: linear-gradient(90deg, #8da8ff, #d8ccff);
box-shadow: 0 0 9px rgba(157, 165, 255, 0.68);
}

@media (max-width: 600px) {
#header-moment {
width: min(360px, calc(100vw - 30px));
margin-top: 14px;
padding: 11px 14px 10px;
border-radius: 16px;
}

.hm-context-row {
gap: 7px;
font-size: 11px;
}

.hm-greeting {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}

@media (prefers-reduced-motion: reduce) {
.hm-progress-fill {
transition: none;
}
}

五、部署到 Hexo + Butterfly

1. 放置文件

1
2
3
4
5
6
你的博客/
└── source/
├── css/
│ └── clock.css
└── js/
└── clock.js

2. 注入主题

_config.butterfly.yml 中加入:

1
2
3
4
5
inject:
head:
- <link rel="stylesheet" href="/css/clock.css">
bottom:
- <script defer src="/js/clock.js"></script>

修改后运行:

1
2
3
npm run clean
npm run build
npm run server

Butterfly 首页中存在 #site-info,脚本会把组件插入社交图标之前,不需要修改主题模板。

3. 停用组件

不需要删除源码,只需注释两条注入配置:

1
2
# - <link rel="stylesheet" href="/css/clock.css">
# - <script defer src="/js/clock.js"></script>

这也是本站当前采用的停用方式。


六、部署到普通 HTML 页面

在 HTML 中准备挂载区域并引入资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
<head>
<link rel="stylesheet" href="/assets/clock.css">
</head>
<body>
<header>
<div id="site-info">
<h1>我的网站</h1>
<div id="site_social_icons"></div>
</div>
</header>

<script defer src="/assets/clock.js"></script>
</body>

如果网站没有 #site-info,可以修改 JavaScript:

1
var siteInfo = document.querySelector('.你自己的页首选择器');

因此它可以迁移到 Hugo、MkDocs、WordPress、Vue 或 React。对于 Vue/React,更推荐将内部 HTML 改写成框架组件,并在最外层布局中挂载。


七、PJAX 为什么需要额外处理

Butterfly 开启 PJAX 后,站内跳转不一定重新加载全部 JavaScript。代码监听:

1
document.addEventListener('pjax:complete', startMoment);

每次切换完成后,它会重新寻找 #site-info。同时通过 #header-moment 判断组件是否已经存在,避免重复插入多个时钟。

页面从后台恢复时也会立即校准:

1
2
3
document.addEventListener('visibilitychange', function () {
if (!document.hidden) updateMoment();
});

八、调整组件位置

当前代码把组件插在社交图标之前:

1
siteInfo.insertBefore(root, socialIcons);

如果觉得页首太显眼,可以考虑:

  • 放到侧栏作者信息下方
  • 缩小为导航栏右侧的一行时间
  • 放入页脚,与网站运行时间并列
  • 只显示时间,隐藏问候与今日进度
  • 桌面端显示完整组件,移动端只显示日期和时间

例如放到页脚:

1
var siteInfo = document.getElementById('footer');

更换挂载位置时,CSS 中的宽度、背景透明度和字体大小也应一同调整。


九、常见问题

1. 页面没有显示组件

检查:

  • CSS 和 JS 是否已经生成到 public/
  • _config.butterfly.yml 的 YAML 缩进是否正确
  • 页面是否存在 #site-info
  • 浏览器是否仍在使用旧缓存

修改资源后可以增加版本号:

1
2
- <link rel="stylesheet" href="/css/clock.css?v=2">
- <script defer src="/js/clock.js?v=2"></script>

2. 切换页面后出现两个组件

确保创建前检查:

1
document.getElementById('header-moment');

并避免在多个配置文件中重复注入脚本。

3. 时间与北京时间不一致

当前组件显示访客设备时间,而不是固定北京时间。如果希望始终显示中国时区,可以使用:

1
2
3
new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai'
});

4. 为什么不直接使用服务器时间

时钟每秒都需要更新。通过浏览器本地计算可以避免持续请求服务器,也不需要后端程序。对于装饰性博客时钟,这通常已经足够。


十、隐私与性能

当前组件:

  • 不读取地理位置
  • 不写入 Cookie 或本地存储
  • 不请求第三方接口
  • 不上传访客时间信息
  • 只保留一个每秒触发的计时器

当标签页重新可见时会主动校准时间,避免浏览器后台节流造成显示滞后。


结语

一个博客时钟真正需要处理的并不只是每秒修改文字,还包括挂载位置、移动端空间、PJAX 生命周期、后台标签页校准和访客隐私。

如果只是想让网站多一点与访问时刻有关的氛围,本地时间、时段问候和今日进度已经足够。天气、节气、日出日落等信息可以在确认位置与视觉层级后,再作为独立模块逐步加入。


AI 内容声明

本文包含 AI 辅助生成内容。文章结构、部分文字表述与组件代码由 AI 协助完成,并经过作者审阅、修改和本地构建验证。