文章摘要
豆包AI
拼命加载中...

依旧写前言

前几天看到莫忘的博客有一个站点性能侧边栏,自己也想搞一个。

试着捣鼓了一下,搞出来了一个差不多的劣质品。这里把教程分享给大家。

最近更新

2026.8.9:心脏线最右端抽搐

根因
site-performance.jsupdateChart(phase) 原先用

1
var x = SVG_WIDTH - ((CHART_POINTS - 1 - i) + phase) * step;

整根折线随 phase 一起向左平移。于是代表“最新值”的最右端点在每个150ms采样周期内会从x=100退到x≈97.4,新采样一来又瞬间弹回100,导致心脏线最右端抽搐。
解决
updateChart 里的x映射改成最新点死死钉在SVG右边界,旧点向左滚动。

1
2
3
4
var dist = CHART_POINTS - 1 - i;
var x = (i === CHART_POINTS - 1)
? SVG_WIDTH
: SVG_WIDTH - (dist + phase) * step;
  • 最新点始终位于x=100,不会再水平弹回。
  • 其他点继续随phase左滚,保持心脏线的连续滚动感。
  • 最右一段线会随采样轻微伸缩,但配合已有的EMA平滑(新值70%+旧值30%),几乎看不到。

Tips:本次修复借助了AI工具。

效果

效果图

指标 含义 数据来源
实时心跳 折线图,反映页面流畅度(60fps≈16.7ms) 每帧 requestAnimationFrame 间隔
页面加载 整页加载耗时 performance.getEntriesByType('navigation')
占用内存 JS 堆内存 performance.memory(仅Chromium内核浏览器)

占用内存只在Chromium内核(Chrome/Edge)显示,Firefox/Safari等浏览器显示“—”属正常现象(为什么?因为我懒)。

前置条件

Hexo博客,SOlitude主题。

教程

js文件

新建source/js/site-performance.js

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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
// 站点性能卡片
(function () {
'use strict';

var CHART_POINTS = 40;
var SAMPLE_INTERVAL = 150;
var SVG_WIDTH = 100;
var SVG_HEIGHT = 30;

var heartbeatValue = document.getElementById('perf-heartbeat-value');
var heartbeatLine = document.getElementById('perf-heartbeat-line');
var loadValue = document.getElementById('perf-load-value');
var memoryValue = document.getElementById('perf-memory-value');

if (!heartbeatValue || !heartbeatLine || !loadValue || !memoryValue) return;

var data = new Array(CHART_POINTS).fill(SAMPLE_INTERVAL);
var lastSampleTime = performance.now();
var nextSampleTime = lastSampleTime + SAMPLE_INTERVAL;
var lastFrameTime = performance.now();
var frameDeltas = [];
var rafId = null;
var warmupId = null;

function formatLoad(ms) {
if (!ms || ms <= 0) return '—';
if (ms < 1000) return Math.round(ms) + ' MS';
return (ms / 1000).toFixed(2) + ' S';
}

function formatMemory(bytes) {
if (!bytes || bytes <= 0) return '—';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}

function updateLoadTime() {
var nav = null;
try {
var entries = performance.getEntriesByType && performance.getEntriesByType('navigation');
if (entries && entries.length) nav = entries[0];
} catch (e) {}

var ms = 0;
if (nav && nav.loadEventEnd && nav.loadEventEnd > 0) {
ms = nav.loadEventEnd - (nav.startTime || 0);
} else if (window.performance && window.performance.timing) {
var t = window.performance.timing;
if (t.loadEventEnd && t.loadEventEnd > 0 && t.loadEventEnd > t.navigationStart) {
ms = t.loadEventEnd - t.navigationStart;
}
}
loadValue.textContent = formatLoad(ms);
}

function updateMemory() {
var mem = null;
if (window.performance && window.performance.memory) {
mem = window.performance.memory;
}
memoryValue.textContent = mem ? formatMemory(mem.usedJSHeapSize) : '—';
}

function updateChart(phase) {
var min = Math.min.apply(null, data);
var max = Math.max.apply(null, data);
var range = Math.max(max - min, SAMPLE_INTERVAL);
var step = SVG_WIDTH / (CHART_POINTS - 1);

var coords = [];
for (var i = 0; i < CHART_POINTS; i++) {
var ratio = 1 - (data[i] - min) / range;
var y = Math.max(1, Math.min(SVG_HEIGHT - 1, ratio * SVG_HEIGHT));
var dist = CHART_POINTS - 1 - i; // 最新修复
var x = (i === CHART_POINTS - 1)
? SVG_WIDTH
: SVG_WIDTH - (dist + phase) * step;

coords.push(x.toFixed(2) + ',' + y.toFixed(2));
}

heartbeatLine.setAttribute('points', coords.join(' '));
}

function tick() {
var now = performance.now();

var frameDelta = now - lastFrameTime;
lastFrameTime = now;
frameDeltas.push(frameDelta);

while (now >= nextSampleTime) {
var avgDelta = frameDeltas.length
? frameDeltas.reduce(function (a, b) { return a + b; }, 0) / frameDeltas.length
: SAMPLE_INTERVAL;
frameDeltas = [];
lastSampleTime = nextSampleTime;
nextSampleTime += SAMPLE_INTERVAL;
// 轻量EMA平滑,避免末端因单帧卡顿突然翘起
var prev = data[CHART_POINTS - 1] || avgDelta;
var smoothed = prev * 0.3 + avgDelta * 0.7;
data.shift();
data.push(Math.max(1, Math.round(smoothed)));
}

var phase = Math.min(1, (now - lastSampleTime) / SAMPLE_INTERVAL);
heartbeatValue.textContent = Math.round(data[CHART_POINTS - 1]);
updateChart(phase);

rafId = requestAnimationFrame(tick);
}

function startLoop() {
if (rafId !== null) return;
var now = performance.now();
lastFrameTime = now;
lastSampleTime = now;
nextSampleTime = now + SAMPLE_INTERVAL;
frameDeltas = [];
rafId = requestAnimationFrame(tick);
}

function warmUp() {
var samples = [];
var start = performance.now();
heartbeatValue.textContent = '…';

function step() {
var now = performance.now();
var delta = now - lastFrameTime;
lastFrameTime = now;
if (delta > 0 && delta < 100) samples.push(delta);

if (now - start < 200 && samples.length < 20) {
warmupId = requestAnimationFrame(step);
return;
}

var avg = samples.length
? samples.reduce(function (a, b) { return a + b; }, 0) / samples.length
: SAMPLE_INTERVAL;
var initial = Math.max(1, Math.round(avg));
data = new Array(CHART_POINTS).fill(initial);
lastSampleTime = performance.now();
nextSampleTime = lastSampleTime + SAMPLE_INTERVAL;
frameDeltas = [];
startLoop();
}

warmupId = requestAnimationFrame(step);
}

function stopLoop() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (warmupId !== null) {
cancelAnimationFrame(warmupId);
warmupId = null;
}
}

function init() {
updateLoadTime();
updateMemory();
var now = performance.now();
lastSampleTime = now;
nextSampleTime = now + SAMPLE_INTERVAL;
lastFrameTime = now;
frameDeltas = [];
warmUp();

if (document.readyState !== 'complete') {
window.addEventListener('load', updateLoadTime, { once: true });
}
}

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

// pjax页面切换后刷新加载时间与内存(心跳保持运行)
if (!window.__sitePerformancePjaxBound) {
window.__sitePerformancePjaxBound = true;
document.addEventListener('pjax:complete', function () {
updateLoadTime();
updateMemory();
});
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) stopLoop();
else startLoop();
});
})();

增加样式

themes/solitude/source/css/_layout/aside.styl在文件末尾追加:

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
.card-performance
position relative
padding .8rem 1rem

.perf-heartbeat
position relative
padding .7rem .8rem
margin-bottom .75rem
background var(--glass-bg)
border var(--glass-border)
border-radius 12px
box-shadow var(--glass-shadow), var(--glass-highlight)
-webkit-backdrop-filter blur(12px)
backdrop-filter blur(12px)
overflow hidden

.perf-heartbeat-top
display flex
justify-content space-between
align-items baseline
margin-bottom .4rem

.perf-label
font-size 12px
color var(--efu-secondtext)

.perf-value
font-size 18px
font-weight 700
color var(--efu-fontcolor)

.perf-unit
font-size 11px
font-weight 500
color var(--efu-secondtext)
margin-left 2px

.perf-heartbeat-chart
width 100%
height 34px
color var(--efu-main)
opacity .85

.perf-grid
display grid
grid-template-columns 1fr 1fr
gap 10px

.perf-cell
padding .6rem .7rem
background var(--glass-bg)
border var(--glass-border)
border-radius 12px
box-shadow var(--glass-shadow), var(--glass-highlight)
-webkit-backdrop-filter blur(10px)
backdrop-filter blur(10px)

.perf-metric
display flex
align-items center
gap 8px
margin-top 4px
font-size 16px
font-weight 700
color var(--efu-fontcolor)

i
font-size 18px
color var(--efu-secondtext)
opacity .7

+maxWidth768()
.perf-grid
gap 8px

.perf-metric
font-size 14px

i
font-size 16px

如果想要升级不丢,可改成放source/css/performance.css,注意用css语法,记得手动引入。

增加 performance 组件

source/_data/aside.yml(没有就新建)末尾添加:

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
- name: performance
title: 站点性能
class: card-performance
icon: fas fa-gauge
content_id: card-performance
content_html: |
<div class="perf-heartbeat">
<div class="perf-heartbeat-top">
<span class="perf-label">实时心跳</span>
<span class="perf-value"><span id="perf-heartbeat-value">—</span> <span class="perf-unit">MS</span></span>
</div>
<svg class="perf-heartbeat-chart" viewBox="0 0 100 30" preserveAspectRatio="none">
<polyline id="perf-heartbeat-line" points="0,15 100,15" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
</svg>
</div>
<div class="perf-grid">
<div class="perf-cell">
<div class="perf-label">页面加载</div>
<div class="perf-metric"><i class="far fa-clock"></i><span id="perf-load-value">—</span></div>
</div>
<div class="perf-cell">
<div class="perf-label">占用内存</div>
<div class="perf-metric"><i class="fas fa-memory"></i><span id="perf-memory-value">—</span></div>
</div>
</div>

引入资源

主题配置文件_config.Solitude.yml

1
2
3
4
5
extends:
head:
# - <link rel="stylesheet" href="/css/performance.css">(用css的话就加上)
body:
- <script defer src="/js/site-performance.js"></script>

让卡片出现在侧边栏

仅实例,根据自己的喜好来填。

1
2
3
4
5
6
7
aside:
home: # 首页
noSticky: "about,performance"
post: # 文章页
noSticky: "about"
page: # 独立页面
noSticky: "about"

6.hexo三连击

1
2
3
hexo clean
hexo generate
hexo server

常见问题

  • 占用内存显示“—”:正常,Firefox/Safari不支持performance.memory,仅Chrome / Edge有。
  • 页面加载显示“—”:loadEventEnd偶尔没拿到,刷新或等页面完全加载后会补上。

最后

这个教程是适配我的博客的,之前我大改过我的博客,所以我无法保证在Solitude主题上可以正常使用,请大家谅解(哭哭)。
有任何问题可以找ai询问,如果ai询问无果,可以在评论区里询问(有时间我就看。重点是看心情)。