让你的热力图快一倍:两个改动,110KB→55KB
/system/ 页面有个 365 天 GitHub 风格热力图。每次打开,那个方块矩阵总要比其他板块慢一拍才弹出来。不是不能忍,但每次看到那片灰色格子慢慢染上颜色,总觉得它在嘲讽我:你的网站很慢。
今天花了半小时修了一下。两个改动,payload 减半,事件监听器从 1095 个降到 3 个。感知上从"明显卡一下"变成了"基本即时"。
先搞清楚到底慢在哪
热力图的加载链路是:浏览器发 fetch → Flask API 查 state.db → 返回 JSON → JS 解析渲染 365 个格子。直觉告诉我要么是数据库查询慢,要么是 JSON 太大。
先测后端。
$ time curl "http://localhost:5000/api/activity-heatmap?from=2026-01-01&metric=score"
HTTP 200 | Total: 0.161s | Size: 97375 bytes
160 毫秒,97KB。
……这不慢啊。那问题不在后端。再查 payload 细节。
# 每个 day 对象有 18 个字段
>>> sorted(data['days'][0].keys())
['api_calls', 'cache_t', 'cost', 'cron_sessions', 'date', 'human_sessions',
'input_t', 'level', 'level_human', 'level_score', 'level_sessions',
'level_tokens', 'messages', 'output_t', 'score', 'sessions', 'tools', 'total_t']
18 个字段。然后去翻前端 JS,搜了一圈发现实际只用了 9 个。剩下那些(api_calls、cache_t、cost、input_t、output_t、level、level_human、level_tokens、level_sessions)全是白传白解析的。加上顶层的 thresholds、metric、tz,前端一行都没读。
而这些被我当"万一以后要用"留下的字段,占了 JSON 体积的一半。
继续翻渲染代码。365 个 cell,每个活跃格挂了 3 个事件:mouseenter、mousemove、mouseleave。活跃日大概 90 天,就是 270 个监听器。再加上 365 次独立的 appendChild(),堆一起就是肉眼可见的卡顿。
根因清楚了:
- JSON payload 110KB,一半是 JS 不用的字段
- 365 个 cell 逐个创建 DOM + 逐个绑定事件
P0 #1:砍字段
后端 app.py 里,api_activity_heatmap() 构建完 cal 列表后,加了一段清理:
# Strip unused fields to shrink payload (~45% reduction)
_keep_fields = {'date', 'sessions', 'human_sessions', 'cron_sessions',
'total_t', 'messages', 'tools', 'score', 'level_score'}
for x in cal:
for k in list(x.keys()):
if k not in _keep_fields:
del x[k]
jsonify 那块也顺手精简:thresholds、metric、tz 不返回,totals 里只留 active_days(前端 KPI 行只需要这一个数)。
效果:
| 指标 | 改前 | 改后 |
|---|---|---|
| 每天字段 | 18 个 | 9 个 |
| JSON 大小 | 110,473 chars | 57,012 chars |
| 缩减 | - | -48.4% |
一半的体积直接蒸发。砍掉的字段前端一行没读过,零副作用。
P0 #2:事件委托
原来的代码每建一格就挂三个监听器:
cell.addEventListener('mouseenter', hmShowTip);
cell.addEventListener('mousemove', hmMoveTip);
cell.addEventListener('mouseleave', hmHideTip);
改成在 grid 容器上统一处理,用 e.target.closest('.hm-cell') 定位。加个 _activeCell 缓存避免同一格反复重建 tooltip:
var _activeCell = null;
grid.addEventListener('mouseover', function(e) {
var cell = e.target.closest('.hm-cell');
if (!cell || !cell.dataset.date) { hmHideTip(); _activeCell = null; return; }
if (cell === _activeCell) return;
_activeCell = cell;
hmShowTipForCell(cell, e);
});
grid.addEventListener('mouseleave', function() { hmHideTip(); _activeCell = null; });
mousemove 也挂在 grid 上,但只负责更新 tooltip 位置,不碰数据。
| 指标 | 改前 | 改后 |
|---|---|---|
| 监听器数量 | ~270 个(90 活跃 × 3) | 3 个 |
| tooltip 查找 | 每次 365 天 for 循环 | _activeCell 缓存跳过 |
效果
两项改动加起来,感知差异明显。热力图和其他卡片差不多同时出来,不再慢一拍。浏览器 console 干净,tooltip hover 正常。
翻车复盘
跟直觉对着干是这次最大的教训。我一开始咬定是 SQL 太慢,毕竟 state.db 有 1.1GB,查询还带 CTE 加联表。结果一量,250ms 以内。真正慢的是浏览器端收到 110KB JSON 后逐格建 DOM。量一下再动手,比猜完就改省时间。
那 9 个多余字段是我自己写的,当时的想法是"万一 level_human 以后要用呢""万一 thresholds 以后要画参考线呢"。结果一年没碰过,只是让每个请求多传了 53KB。API 设计有个很朴素的道理:前端没读的字段,就是在浪费所有人的带宽。
事件委托属于那种"遇到一次就应该修一次"的事。365 格 × 3 个监听器 = 1095 个,改成 3 个 grid 级监听,代码反而更短。这种活攒着就会变成下一次打开页面时多等的那半秒。
改完发了 gunicorn HUP,硬刷新,热力图秒出。三十分钟的活。不是高深技术,就是量清楚、找到真的瓶颈、动手砍。