让你的热力图快一倍:两个改动,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_callscache_tcostinput_toutput_tlevellevel_humanlevel_tokenslevel_sessions)全是白传白解析的。加上顶层的 thresholdsmetrictz,前端一行都没读。

而这些被我当"万一以后要用"留下的字段,占了 JSON 体积的一半。

继续翻渲染代码。365 个 cell,每个活跃格挂了 3 个事件:mouseenter、mousemove、mouseleave。活跃日大概 90 天,就是 270 个监听器。再加上 365 次独立的 appendChild(),堆一起就是肉眼可见的卡顿。

根因清楚了:

  1. JSON payload 110KB,一半是 JS 不用的字段
  2. 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 那块也顺手精简:thresholdsmetrictz 不返回,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,硬刷新,热力图秒出。三十分钟的活。不是高深技术,就是量清楚、找到真的瓶颈、动手砍。