R31第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

同步外部数据源

同步外部数据源 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

主题偏好、登录信息、播放器进度这类「React 之外的数据」需要同步进组件。这一课你会用 useSyncExternalStore 写出订阅三件套,理解快照稳定性这条硬规矩,并验证多个组件读取时永远一致。

问题场景

学习进度存在模块级变量 store.progress,两个组件各自用 useState 复制了一份,再想办法同步:

// 组件 A
const [progress, setProgress] = useState(store.progress);
useEffect(() => {
  const id = setInterval(() => setProgress(store.progress), 1000); // 轮询兜底
  return () => clearInterval(id);
}, []);

// 组件 B:又复制了一份,忘了轮询

结果:A 显示「3/10」,B 还停在「2/10」。同一个数据,两份真相,必然漂移。

核心解释

这类数据的共同点:owner 在 React 外面——模块变量、localStorage、浏览器连接状态、第三方播放器。它们可变,也不提供 React 认识的更新通知;用 state 复制加 Effect 轮询,等于到处造假副本。

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) 是官方为此准备的接口:

  • getSnapshot:读当前值的纯函数。核心纪律:同一状态必须返回同一个引用。要返回缓存的对象,而不是每次现场构造 { done: 3 }——新引用会被 React 判定「快照变了」,触发渲染、再读快照、又是新引用,无限循环直到页面卡死。
  • subscribe(callback):登记监听并返回取消函数。状态变化时调用 callback,React 收到通知后重新读快照、按需渲染。
  • getServerSnapshot:服务端渲染时的取值。纯浏览器应用可以省略;SSR 项目必传,否则水合时报错。

多个组件订阅同一个 store 时,React 内部只维护一份快照,谁读到都一样——一致性由「单一数据源」保证,不靠运气。

示例拆解

// 模块级 store:单一数据源(真实项目通常放单独文件)
let progress = { done: 3, total: 10 };
const listeners = new Set();

const courseStore = {
  subscribe(callback) {
    listeners.add(callback);
    return () => listeners.delete(callback); // 取消订阅
  },
  getSnapshot() {
    return progress; // 返回缓存的同一个对象
  },
  finishNext() {
    progress = { ...progress, done: Math.min(progress.done + 1, progress.total) };
    listeners.forEach((l) => l()); // 通知所有订阅者
  },
};

// 组件里一行接入
function ProgressPanel() {
  const p = useSyncExternalStore(courseStore.subscribe, courseStore.getSnapshot);
  return <p>进度:{p.done}/{p.total}</p>;
}
  • finishNext 用展开运算生成新对象再整体替换——快照不可变,React 才能靠引用比较发现变化。
  • 直接 progress.done++ 而不换引用,React 读快照发现引用没变,会认为「没更新」。
  • 订阅通知只应在外部事件里发起(点击、推送、storage 事件),不要在渲染期间调用。

动手任务

打开本课实验区:

  1. 点「完成下一课(通知)」,验证进度条和文字面板两个组件同时更新且数字一致。
  2. 点「改 store 但不通知」,观察界面不动——快照变了但没有订阅通知,React 不知道;再点一次会通知的按钮,两块面板一起跳到最新值。
  3. 给 store 加 reset():恢复初始进度并通知,验证两个面板回到同一状态。

常见错误

  • getSnapshot 返回新对象或新数组:无限渲染、页面卡死;快照必须缓存,变化时整体替换。
  • 改可变字段而不换引用:snapshot.done++ 不触发任何更新;用展开运算生成新对象。
  • 订阅函数每次渲染都是新的:传模块层的 store.subscribe 没问题,但传内联箭头函数 (cb) => store.sub(cb) 会导致每次渲染都退订重订;把订阅函数也定义在模块层。
  • SSR 项目漏掉 getServerSnapshot:服务端渲染直接报错;纯客户端应用不受影响。

检查与复盘

完成下方检查题确认理解。自查清单:

  • 能说出三件套各自的职责,以及 subscribe 的返回值要求
  • 知道 getSnapshot 的引用稳定性是硬要求,能解释违反的后果
  • 实验里「多面板一致」和「不通知就不更新」两点都验证过

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1单选中等

下列哪个场景最适合用 useSyncExternalStore?

Q2读代码较难

阅读代码(React 18+)。组件订阅位置数据后,控制台持续警告「The result of getSnapshot should be cached to avoid an infinite loop」,页面反复重渲染。罪魁祸首是哪一行?

const position = useSyncExternalStore(
  subscribeToWatcher,                    // 行 1:订阅位置变化
  () => ({ x: watcher.x, y: watcher.y })  // 行 2
);

// 正确写法参考:
// function getSnapshot() {
//   return watcher.cachedPosition; // 返回外部缓存的同一对象引用
// }

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...