为什么后见之明让我们重新思考全球研究背景
Source: Dev.to
(请提供需要翻译的正文内容,我将把它完整地译成简体中文,并保留原有的 Markdown 格式。)
单一 Context 的问题
我们把所有东西都放进一个 React context —— streak(连胜)、topic strengths(主题强度)、mistakes(错误)、quiz history(测验历史)、retry state(重试状态)、exam date(考试日期)、weekly scores(每周得分) —— 长时间以来,这看起来像是良好的架构。所有学习状态集中在一个地方。导入简洁。没有属性钻取。
然后我们使用 Hindsight 开始追踪系统行为,意识到我们构建了一个单体:
- 每次答题后都会重新渲染整个应用。
- 每个会话结束后,悄悄地把已掌握的主题计数错误。
- 结构上导致无法在不引入陈旧闭包错误的情况下连接相关的状态片段。
Context 本身并没有错;它承担了太多职责,而我们只有在拥有一个能够跨会话观察行为,而不是一次渲染一次观察的工具后,才看到了后果。
Source: …
上下文:它包含的内容
src/context/StudyContext.tsx 是整个应用的核心。所有显示学习数据的页面都会从中读取;所有生成学习数据的页面都会向其写入。下面是它管理的完整结构:
interface StudyState {
// Data
streak: number;
topicsMastered: number;
weakTopicsCount: number;
totalQuizzesTaken: number;
totalCorrect: number;
totalQuestions: number;
quizHistory: QuizResult[];
mistakes: MistakeEntry[];
topicStrengths: TopicStrength[];
weeklyScores: { day: string; score: number; quizzes: number }[];
examDate: Date;
// Actions
addQuizResult: (result: QuizResult, newMistakes: MistakeEntry[]) => void;
markMistakeReviewed: (id: number) => void;
retryMistake: (id: number) => void;
retryingMistakeId: number | null;
clearRetry: () => void;
}十四个字段,五个操作。提供者在 src/main.tsx 中包裹整个应用,这意味着 任何使用该上下文的组件只要这十四个字段中的任意一个发生变化,都会重新渲染。
学生回答测验题目时会触发 addQuizResult,该函数会更新:
streaktotalQuizzesTakentotalCorrecttotalQuestionsquizHistorymistakestopicStrengthstopicsMasteredweeklyScores
——一次函数调用中进行九项状态更新,每一次更新都可能导致 仪表盘、测验、错题 和 计划 页面中的所有消费者同时重新渲染。
单体 Context 引发的 Bug
在 addQuizResult 中,我们会更新主题强度,然后根据这些强度推导出已掌握的主题数量:
const addQuizResult = useCallback(
(result: QuizResult, newMistakes: MistakeEntry[]) => {
// ... other updates ...
// Correct: functional updater reads fresh state
setTopicStrengths((ts) =>
ts.map((t) => {
if (result.weakTopics.includes(t.topic)) {
return { ...t, strength: Math.max(10, t.strength - 8) };
}
return { ...t, strength: Math.min(100, t.strength + 2) };
})
);
// Bug: reads topicStrengths from stale closure
setTopicsMastered((prev) => {
const newStrengths = topicStrengths.map((t) =>
result.weakTopics.includes(t.topic) ? t.strength - 8 : t.strength + 2
);
return newStrengths.filter((s) => s >= 80).length;
});
},
[topicStrengths] // topicStrengths in deps — but still stale inside the callback
);- 第一个
setTopicStrengths使用 函数式更新器 形式 (ts => …) 来读取最新的状态。 - 第二个
setTopicsMastered直接从闭包中读取topicStrengths。
React 会批量处理状态更新,因此在 setTopicsMastered 执行时,setTopicStrengths 尚未刷新。结果是,已掌握主题的计数总是基于前一次测验的强度来计算。每完成一次测验后,仪表盘上的主题热力图会正确更新,而 “已掌握主题” 计数则会滞后一轮测验。
事后回顾对上下文设计的启示
上下文应如何呈现
// Remove stored state
// const [topicsMastered, setTopicsMastered] = useState(8);
// Derive it instead
const topicsMastered = topicStrengths.filter((t) => t.strength >= 80).length;一行代码。没有同步问题。该值始终与 topicStrengths 保持一致,因为它 在每次渲染时计算。
相同的模式已经用于 weakTopicsCount:
const weakTopicsCount = topicStrengths.filter((t) => t.strength < 50).length;topicsMastered 应该遵循该模式。单独存储派生值会导致不一致,从而隐藏 bug。
建议的上下文拆分
| 领域 | 包含 |
|---|---|
| QuizContext | quizHistory, totalQuizzesTaken, totalCorrect, totalQuestions, weeklyScores |
| StudyProgressContext | streak, topicStrengths, examDate |
| MistakeContext | mistakes, retryingMistakeId + actions (markMistakeReviewed, retryMistake, clearRetry) |
这在当前规模下并不是性能优化——而是一次 可读性优化。仅需要错误数据的组件在 streak 变化时不应重新渲染,而仅需要主题强度的组件也不应接触重试状态。更窄的上下文消费者可以使依赖关系明确,并且因为回调的依赖数组更小、更易审计,从而降低陈旧闭包 bug 的出现概率。
更广泛的模式与经验教训
派生状态不应作为存储状态。
如果一个值可以从其他状态计算得出,就直接计算它。存储派生值会产生同步义务,最终会被违反。topicsMastered和weakTopicsCount都应该在每次渲染时从topicStrengths派生得到。useCallback的依赖数组是气味检测器。
如果回调的依赖数组中包含了回调本身会更新的状态片段,要把它当作一个警告:两个本应一起更新的东西被拆开了。addQuizResult中的陈旧闭包错误正是通过依赖数组中的[topicStrengths](而该函数同样会调用setTopicStrengths)被提示出来的。单块 Context 隐藏了隐式依赖。
当所有内容都放在同一个 Context 中时,topicStrengths与topicsMastered之间的关系在结构上不可见——它们只是平面对象中的两个字段。将其拆分为领域特定的 Context,迫使你决定每个字段归属哪个 Context,从而使派生关系变得显式。
通过 尽可能派生、按领域拆分 Context,以及 保持回调依赖最小化,我们可以消除陈旧闭包错误,减少不必要的重新渲染,并让应用中的数据流更易于理解和维护。
跨会话观察
跨会话观察捕捉到单次渲染测试遗漏的内容。mastery‑counter(掌握计数器)错误通过了所有手动测试,因为在任何单次渲染中,它看起来是正确的。只有跨会话时,单个测验的延迟才会显现。
这就是为什么 Hindsight 的纵向追踪是此类错误的正确工具——而不是更好的单元测试。