React 状态"一直正常"——直到同时打开两个标签页
一个副作用项目的收藏文章功能几周来一直正常:useState 存列表、保存按钮往里推、侧边栏渲染。每个手动测试都通过。然后用户报告:在一个标签页保存文章,另一个标签页的侧边栏里它就消失了——有时刚取消保存的文章几秒后又回来,像应用拿不定主意。作者花了一天复现不了:他只有一个标签页开着。这本来就该是第一条线索。
看起来没问题的写法
收藏状态放在每个 React 教程都会告诉你的地方:
function useSavedArticles() {
const [saved, setSaved] = useState(() => {
const raw = localStorage.getItem("saved-articles");
return raw ? JSON.parse(raw) : [];
});
const save = (article) => {
setSaved((prev) => {
const next = [...prev, article];
localStorage.setItem("saved-articles", JSON.stringify(next));
return next;
});
};
return { saved, save };
}
挂载时从 localStorage 读,每次变化都写。这是从十几篇"用 localStorage 持久化状态"文章里复制粘贴来的模式,它有效——恰好对一个标签页有效。
在哪里坏
localStorage 在同一个源的所有标签页间共享。useState 不是。每个标签页有自己的 React 树、自己的 saved 状态、自己在内存里的数组副本。
破坏序列长这样:标签页 A 保存文章 1 → A 的内存 saved=[1],localStorage 现在是 [1]。标签页 B 更早打开,它的 saved 状态在首次挂载时从 localStorage 初始化——那时是空的。B 的内存里 saved=[]。标签页 B 里用户没做任何事,然后保存文章 2:B 用自己的旧 prev([])计算 next,写 [2] 进 localStorage——静默覆盖了文章 1。标签页 A 在自己内存里仍显示 [1],本地看"正确"。刷新 A,文章 1 没了,因为 localStorage 里只有 [2]。
单独看任何一个标签页都没有错。bug 不是逻辑错误——是两个独立真相源在测试时碰巧一致、真实使用中分道扬镳,因为作者总用一个标签页测试,而用户总开着好几个。
修复:监听你自己正在引发的事件
浏览器其实会告诉你另一个标签页改了 localStorage——只是这个事件大多数人不接,因为单标签页测试永远不会触发它。window 在除发起写入的那个标签页外的每个标签页上发出 storage 事件:
function useSavedArticles() {
const [saved, setSaved] = useState(() => readSaved());
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === "saved-articles") {
setSaved(readSaved());
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
const save = (article) => { /* ...同前... */ };
return { saved, save };
}
function readSaved() {
const raw = localStorage.getItem("saved-articles");
return raw ? JSON.parse(raw) : [];
}
现在 A 写入时,B 的 storage 监听器触发并从 localStorage 重读,替换掉它过期的内存副本,而不是从从未更新的 state 计算新值。上面破坏序列的第三步现在基于 B 的当前数据写入,而不是首次挂载时的快照。
BroadcastChannel 是更新的干净替代——想要结构化消息而不是解析 storage key 里随便什么内容时用。但简单"外部变更时重同步"场景,storage 事件不需要额外 API,在支持 localStorage 的每个浏览器里都能用。
真正的教训
useState 限定在组件树。组件树限定在标签页。localStorage 限定在源。当持久化层比状态的作用域更宽,"保持它们同步"就不再是可选项——从你选择 localStorage 那一刻起它就是义务,不管你有没有注意到这个选择。作者没注意到:他一直在测保存按钮,而不是测它本该保持一致的那两样东西。跨标签页同步的替代方案(BroadcastChannel、shared worker)欢迎交流。
来源:Why My React State Kept "Working" — Until Two Tabs Opened at Once - DEV Community