React 闭包陷阱:一个空依赖数组,毁了我的数据
摘要:开发者在为SkillLauncher工具打包时发现使用记录消失的问题。原因一是数据保存逻辑直接覆盖文件而非合并已有数据,二是React闭包陷阱导致状态判断失效。解决方案包括:1)采用先读后写的合并保存策略;2)使用useRef替代useState避免闭包问题。文章总结了数据持久化最佳实践和React Hooks常见陷阱,强调开发时应多考虑错误处理场景。(149字)

前天晚上,我正在给自己的开源项目 SkillLauncher Windows 版本收尾。
这是一个帮助开发者快速启动 Claude Code Skills 的桌面工具。功能很简单:点击某个技能卡片,工具就会自动把技能名字复制到剪贴板,同时记录下你使用了哪些技能、用了多少次。
测试的时候一切正常。我用了一次「commit」,再刷新页面,使用记录还在。我又用了「pdf」,刷新,也还在。
完美打包,准备发版。
第二天早上打开一测——
所有的使用记录都不见了。
空空如也的列表盯着我的脸,那一刻我甚至怀疑自己是不是做梦记错了。
问题一:消失的数据
我打开开发者工具,定位到数据文件:C:\Users\admin\AppData\Local\com.skillLauncher.app\skill-usage.json
文件存在,但内容是空的。
为什么会这样?让我带你看下原来的代码:
setUsageData((currentData) => {
const newData = { usage: newUsage };
// 异步保存
(async () => {
await writeFile(filePath, encoder.encode(JSON.stringify(newData)));
})();
return newData;
});
发现问题了吗?
直接覆盖写入,没有考虑文件中已有的数据。
但这还不是最致命的。更严重的问题在于加载逻辑:
async function loadUsageData() {
try {
const data = JSON.parse(jsonStr);
setUsageData(data);
} catch (err) {
// 任何读取失败都会导致空数据
setUsageData({ usage: [] }); // 危险!
}
}
这是一个时序炸弹:
loadUsageData读取失败(可能是临时权限问题、文件被占用、JSON 解析错误)- 内存状态被设为
{ usage: [] } - 用户点击技能,触发保存
- 保存逻辑基于空数据计算,然后用空数据覆盖文件
- 文件中的有效数据,永久丢失
教训:永远不要用空数据去覆盖可能有数据的文件。
解决方案:合并写入
修复后的代码遵循一个原则:先读后写,合并而非覆盖。
// 保存前先读取现有文件,合并后再写入
try {
const { readFile } = await import("@tauri-apps/plugin-fs");
const existingContents = await readFile(filePath);
const existingData = JSON.parse(decoder.decode(existingContents));
if (existingData?.usage?.length > 0) {
// 使用 Map 合并数据(新记录覆盖旧的同名记录)
const mergedMap = new Map<string, SkillUsageRecord>();
// 先添加现有记录
existingData.usage.forEach(record => {
if (record?.name) {
mergedMap.set(record.name, record);
}
});
// 再添加新记录(自动覆盖同名的)
newUsage.forEach(record => {
if (record?.name) {
mergedMap.set(record.name, record);
}
});
const mergedData = { usage: Array.from(mergedMap.values()) };
await writeFile(filePath, encoder.encode(JSON.stringify(mergedData, null, 2)));
return; // 合并成功,直接返回
}
} catch (readErr) {
// 文件不存在或读取失败,创建新文件
}
// 正常保存新文件
await writeFile(filePath, encoder.encode(jsonStr));
核心思想很简单:保存前先读出文件里的旧数据,和内存中的新数据合并,然后再写回去。这样即使加载时出了问题,文件里的数据也不会丢。
问题二:永远走不进去的 if
修复完数据问题,我以为可以收工了。
结果又发现一个 bug:我的「等待加载完成」逻辑从来没生效过。
const [loadCompleted, setLoadCompleted] = useState(false);
const recordUsage = useCallback(async (skillName: string) => {
// 检查加载状态
if (!loadCompleted) {
console.log("等待加载完成...");
// 等待逻辑
}
// ...
}, []); // 空依赖数组!
这段代码看起来没问题吧?
但实际运行时,loadCompleted 永远是 false。即使我调用了 setLoadCompleted(true),recordUsage 函数里读到的还是 false。
这就是经典的 React 闭包陷阱。
为什么会这样?
useCallback 的依赖数组为空时,回调函数只在组件挂载时创建一次:
// 组件挂载时,loadCompleted = false
const recordUsage = useCallback(() => {
console.log(loadCompleted); // 闭包捕获了 false
}, []); // 空依赖,永不更新
// 即使后来 setLoadCompleted(true)
// recordUsage 内部的 loadCompleted 仍然是 false
JavaScript 闭包捕获的是变量值而非变量引用。这就像你拍了一张照片,之后无论被拍摄的人怎么换衣服,照片里的样子永远不会变。
解决方案:useRef
最简单的修复方式是用 useRef:
// useRef 返回的对象在整个组件生命周期内保持不变
const loadCompletedRef = useRef(false);
// 修改值通过 .current
loadCompletedRef.current = true;
// useCallback 内部访问 .current
const recordUsage = useCallback(async (skillName: string) => {
if (!loadCompletedRef.current) { // 总是获取最新值
// 等待逻辑
}
}, []);
useRef 返回的是一个可变对象 { current: ... }。对象的引用在闭包中保持不变,但通过 .current 访问的始终是最新值。
这就像拍了一张视频而不是照片,内容会实时更新。
总结
这两个问题花了我大半夜时间,但也总结出一些经验:
数据持久化最佳实践:
- 先读后写:保存前读取现有数据,合并后再写入
- 防御性检查:验证数据格式,避免用空数据覆盖
- 错误恢复:写入失败时保留原有数据
React Hooks 避坑指南:
| 场景 | 问题 | 解决方案 |
|---|---|---|
useCallback 中变量值过期 |
空依赖数组导致闭包捕获初始值 | 使用 useRef |
setInterval 中 state 过期 |
定时器回调捕获初始 state | 使用 useRef |
| 事件监听器中 state 过期 | 监听器闭包捕获旧值 | 每次更新时重新添加监听器 |
其实这些都不是什么高深的技巧,只是在写代码时多想一步:如果这里出错了,会发生什么?
开源项目 SkillLauncher 已经发布,欢迎体验:
https://github.com/gxj1134506645/skillLauncher-windows
欢迎关注公众号 FishTech Notes,一块交流使用心得!
更多推荐


所有评论(0)