Git Stash暂存功能在团队协作中的实战应用与常见陷阱
优化前:卡得不行
上个月上线一个数据看板,用 Stash 暂存用户筛选状态(比如时间范围、指标分组、排序字段),方便刷新后还原。结果一上线就被产品拉着连问三遍:“为啥点个筛选要等5秒才响应?”我打开控制台一看,Network 面板里 GET /api/dashboard 后面紧跟着一堆 POST /api/stash 请求——每次改一个下拉框,就发一次 stash 保存,而且是同步等它返回才更新 UI。更离谱的是,有些 stash 写入操作居然卡在 1.2s 以上。
本地跑还行,但测试环境走的是真实后端接口(不是 mock),Stash 的 POST 接口没加防抖、没做批处理、也没缓存本地变更,等于每敲一个字符、每点一个 checkbox,都在往服务器塞一条记录。我试了下“快速切三个筛选项”,页面直接卡住 3 秒多,DevTools 里看到主线程被一堆 fetch pending 堵死。这哪是暂存,这是自残。
找到瘼颈了!
先用 Performance 面板录了一段操作:从点击筛选 → 输入关键词 → 切换分组 → 点确定,耗时 4.8s。火焰图里一眼看到两块大红:一是 fetch 调用密集堆积(17 次 stash 请求),二是 JSON.stringify 占了 600ms —— 我们 stash 的数据结构里有个深嵌套的 filters 对象,每次保存都全量序列化,连带把 moment 对象、函数引用(虽然不该有但确实有)一起塞进 JSON,直接触发 V8 的 GC 尖峰。
又抓了下 Network,发现所有 stash 请求都是独立发的,没有合并,header 里连 X-Request-ID 都没对齐,后端日志里全是重复 trace。再看代码,stash 逻辑散落在七八个组件里,有的用 useEffect 监听 props 变更,有的在 onChange 里直调 saveToStash(),完全没统一入口。定位完:问题不在 Stash 本身,而在我们怎么用它。
核心优化:只存差异 + 异步队列 + 本地缓冲
试了几种方案:防抖(太暴力,用户点快了就丢状态)、节流(体验断层)、本地 localStorage 先存(但多 tab 同步不一致)。最后定下来三板斧:差异计算 + 批量提交 + 内存缓冲。重点不是“怎么存”,而是“什么时候存、存什么”。
先搞了个轻量级 diff 工具(不用 Lodash,自己写 40 行就够了),只对比 filters 和 viewConfig 这两个关键字段的浅层变化:
function getStashDiff(prev, next) {
const diff = {};
for (const key of ['timeRange', 'metric', 'groupBy', 'sort']) {
if (!deepEqual(prev[key], next[key])) {
diff[key] = next[key];
}
}
return Object.keys(diff).length ? diff : null;
}
然后把所有 stash 调用收口到一个 StashManager 类里,内部维护一个内存 buffer:
class StashManager {
constructor() {
this.buffer = {};
this.pending = null;
}
update(key, value) {
const diff = getStashDiff(this.buffer[key], value);
if (diff) {
this.buffer[key] = { ...this.buffer[key], ...diff };
this.flush();
}
}
flush() {
if (this.pending) return;
this.pending = setTimeout(() => {
const batch = { ...this.buffer };
this.buffer = {};
// 只有非空才发请求
if (Object.keys(batch).length) {
fetch('https://jztheme.com/api/stash/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: batch })
}).catch(() => {
// 失败也不阻塞,下次重试
this.buffer = { ...this.buffer, ...batch };
});
}
this.pending = null;
}, 800); // 800ms 防抖,够用户连续操作又不显延迟
}
}
这里注意我踩过好几次坑:第一,deepEqual 必须排除 undefined 和 null 的误判;第二,flush 里不能 await fetch,否则又卡主线程;第三,失败重试不能无限堆,加了个 retryCount 限制(代码里省略了,实际有)。
组件里原来这样写:
// ❌ 旧写法:每个 change 都发
useEffect(() => {
saveToStash('dashboard', { filters, viewConfig });
}, [filters, viewConfig]);
现在改成:
// ✅ 新写法:只通过 manager 更新
const stash = useRef(new StashManager()).current;
useEffect(() => {
stash.update('dashboard', { filters, viewConfig });
}, [filters, viewConfig]);
顺手干掉的次要问题
- 砍掉了所有
JSON.stringify(data)的裸调用,统一走JSON.stringify(data, (k, v) => k === 'momentObj' ? v.format() : v)过滤不可序列化字段 - 给 stash 接口加了
Cache-Control: no-cacheheader,避免 CDN 缓存脏数据(之前出现过用户切回老配置,UI 显示的却是上周 stash) - 加了个
clearStashOnLogout(),不然登出再登录会加载别人的历史 stash(真事,测试同学报的)
优化后:流畅多了
改完上线第二天,我就盯着 Sentry 的 Performance Dashboard 看。Stash 相关的平均响应时间从 1120ms 降到 190ms,失败率从 3.7% 降到 0.2%。最关键是用户感知:筛选操作基本无感,连续点五次下拉,页面没卡过一次。我拿自己笔记本测了下真实操作流(Chrome 无痕+禁用 cache):
- 优化前:从打开面板到完成三次筛选切换,平均耗时 4.6s(中位数)
- 优化后:同样操作,平均 780ms,快了将近 6 倍
而且后端日志里 stash 请求量从日均 12w 条降到 1.8w 条 —— 因为大部分变更被合并进了 batch 请求,单次请求体也小了 60%,Nginx access log 里 POST /api/stash/batch 的 avg_size 从 4.2KB 降到 1.7KB。
性能数据对比
这是上线前后三天的核心指标(生产环境,100% 流量):
| 指标 | 优化前 | 优化后 | 下降幅度 |
|---|---|---|---|
| Stash 平均响应时间 | 1120ms | 190ms | 83% |
| Stash 请求总量/天 | 121,430 | 17,920 | 85% |
| JS 主线程阻塞时长(per op) | 320ms | 22ms | 93% |
| 用户投诉相关工单数 | 7 | 0 | 100% |
最后一行不是开玩笑,是真的零投诉了。当然,还有个小尾巴:某些低配安卓机上,如果用户狂点十几次,buffer 里积压太多,第一次 flush 会略慢(约 1.1s),但我们评估过,这种场景极少,且后续 flush 都正常,就没再深挖 —— 毕竟不是金融交易系统,能接受这点不完美。
以上是我踩坑后的总结,希望对你有帮助
这个方案不是最优解(比如真要极致,可以搞 Service Worker 缓存 + IndexedDB 同步),但它是最快落地、风险最低、效果最明显的。核心就一句话:**Stash 不是 dump 工具,是状态同步通道,得管住入口、压住频率、精简载荷。**
如果你有更好的思路 —— 比如用 MutationObserver 监听表单变更、或者结合 React Query 的 mutation batch,欢迎评论区交流。这个技巧的拓展用法还有很多,比如把它封装成一个 useStash Hook,后续我也会继续分享这类实战博客。

暂无评论