主题切换(Context + value 记忆化)Theme switch (Context plus a memoised value)
题面The problem
先把要求读完,再动手。Read every requirement before you start.
类型已给好。写出 context、Provider、自定义 hook 三部分。 检查器会查记忆化、函数式更新和守卫。
The types are given. Write all three parts: the context, the Provider, and the custom hook. The checker looks for the memoization, the updater form and the guard.
- 默认 light:卡片显示 light,按钮文字是 Switch to Dark;切到 dark 后变 Switch to LightThe default is light: the card shows light and the button reads Switch to Dark. After switching to dark the button reads Switch to Light
- 卡片底色跟着主题走 —— light → #fff,dark → #222The card background follows the theme: light uses #fff, dark uses #222
- 同一个 Provider 下的多个消费者一起变(这才是 Context 的意义)Several consumers under the same Provider all change together. That is the whole reason Context exists
- 没套 Provider 就调 useTheme() 必须立刻抛错,信息里要出现 ThemeProviderCalling useTheme() with no Provider around it must throw immediately, and the message must contain ThemeProvider
- toggleTheme 引用稳定:theme 变了它也不变(useCallback)toggleTheme keeps a stable identity: it stays the same function when theme changes (useCallback)
- theme 没变时 context value 不换新对象(useMemo)While theme has not changed, the context value must not be a new object (useMemo)
- 一次事件里连调两次 toggleTheme 要回到原点 —— 必须用函数式更新Calling toggleTheme twice inside one event must land back on the starting theme. That needs the updater form
预计 30 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 30 minutes. Overrunning on the first pass is normal; the second pass should fit.
工作区Workspace
工作区是一个真的浏览器沙箱:左边写代码,右边实时预览,下面一个「跑测试」按钮。测试和本机那套是同一批断言,转写成了浏览器里能跑的写法。The workspace is a real in-browser sandbox: edit on the left, live preview on the right, one Run button below. The assertions are the same ones that pass on a real machine, rewritten for the browser runner.
需要联网。Requires an internet connection. 打包器和 npm 依赖都在 CodeSandbox 的远程服务上(评估过程见 docs/sandpack-evaluation.md),断网这块就起不来 —— 那就照下面的命令在本机跑。The bundler and the npm packages come from CodeSandbox's remote service, so this panel needs network access.
展开讲解Walkthrough
下面是《变式五 · 主题切换:Context 怎么用》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “变式五 · 主题切换:Context 怎么用” — the same content as in the course, not a rewritten summary. Expand it when you stall.
展开《变式五 · 主题切换:Context 怎么用》(7 段 · 约 20 分钟)Expand “变式五 · 主题切换:Context 怎么用” (7 sections · ~20 min)
完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 变式五 · 主题切换:Context 怎么用。
为什么要 Context:props 传不动了Why Context exists: props cannot carry the value that far
Context 解决的是「跨很多层传同一个值」,不是「状态管理」。Context solves one problem: passing the same value through many levels. It is not state management.
前面讲过 props 往下流、事件往上报。 这套规则在两三层内很好用,但主题这种东西整棵树都要读:
中间那些组件根本不关心主题,却被迫接一个theme 再往下递。这叫props 层层透传(prop drilling)。 加一个 toggleTheme 就得再透传一次。
Context 的作用就是跳过中间层: Provider 在上面放一个值,子树里任何深度的组件直接取。
但别把它当状态管理器用。判断标准很简单:
- 该用:整棵树都要读、而且不常变的东西 —— 主题、当前用户、语言、路由。
- 不该用:只有两三层要用的(直接传 props); 或者每秒都在变的(context 一变,所有消费者都重渲染,会卡)。
Earlier we said props flow down, events report up. That rule works nicely for two or three levels, but something like a theme has to be readable by the whole tree:
The components in the middle do not care about the theme at all, yet they are forced to take a theme and hand it further down. That is prop drilling. Add a toggleTheme and you drill it all over again.
What Context does is skip the middle: a Provider puts a value up top, and a component at any depth in the subtree reads it directly.
But do not use it as a state manager. The test is simple:
- Use it for things the whole tree reads that rarely change — theme, current user, language, route.
- Do not use it for something only two or three levels need (pass props); or for something that changes every second (when a context changes, every consumer re-renders, and it drags).
Context 只有三个动作,加一个自定义 hookContext has only three moves, plus a custom hook
createContext 造管道、Provider 灌值、useContext 取值。第四步是自己包一层。createContext builds the pipe, Provider puts a value into it, useContext takes the value out. The fourth step is a wrapper you write yourself.
1. createContext—— 造一根管道。参数是「没有 Provider 时的默认值」。
2. <Ctx.Provider value={...}>—— 往管道里灌值。只有它的子树能取到。
3. useContext(Ctx)—— 取值。取到的是「最近的那个 Provider」给的值。
4. 包成 useTheme()。这一步不是可选的装饰,它有两个实际好处: 消费者不用 import 那个 context 对象, 以及可以在这里放守卫。
关于默认值,三种写法差别很大:
| 写法 | 忘了套 Provider 时 |
|---|---|
createContext()(题目给的参考结构) | 拿到 undefined,在解构那一行炸出一句 很难读的 Cannot destructure property… |
createContext({ theme: "light", toggleTheme: () => {} }) | 不报错,界面正常显示浅色, 点按钮没反应 —— 最难查的一种 |
createContext<T | undefined>(undefined)+ hook 里守卫 | 抛一句人能看懂的话:useTheme 必须在 <ThemeProvider> 里面用 |
第三种是推荐写法。TS strict 下它还有个附带好处:守卫之后类型自动收窄, 消费者拿到的是 ThemeContextValue 而不是 可能为 undefined 的联合类型,不用到处写 ?.。
1. createContext — build the pipe. Its argument is the value used when there is no Provider.
2. <Ctx.Provider value={...}> — pour a value into the pipe. Only its subtree can read it.
3. useContext(Ctx) — read the value. You get whatever the nearest Provider handed down.
4. Wrap it in useTheme(). This step is not optional decoration; it buys two real things: consumers never import the context object, and you get a place to put the guard.
About the default value, the options differ a lot:
| How it is written | When the Provider is missing |
|---|---|
createContext() (the reference shape in the question) | You get undefined and it throws on the destructuring line with an unreadable Cannot destructure property… |
createContext({ theme: "light", toggleTheme: () => {} }) | No error at all, the UI renders light happily, the button does nothing — the hardest kind to track down |
createContext<T | undefined>(undefined) plus a guard in the hook | Throws a sentence a human can read — the error text written into the useTheme guard |
The third one is the recommended shape. Under TS strict it has a bonus: the type narrows automatically after the guard, so consumers get ThemeContextValue instead of a union that might be undefined, and nobody has to sprinkle ?. everywhere.
toggleTheme:又是函数式更新toggleTheme: the updater function form again
和计时器那道题同一个道理,只是这次藏在 context 里。The same reason as in the timer question, only this time it is hidden inside the context.
toggleTheme 要读旧值算新值,所以必须用函数式更新:
写成 setTheme(theme === "light" ? "dark" : "light")在「点一下」这种场景下也能跑, 但只要一次事件里连调两次就露馅 —— 两次都读到同一个旧 theme,结果只翻转了一次。
测试里专门有一条抓这个:一个按钮的 onClick 里调两次toggleTheme(),正确实现应该原样回到 light。 这条测试是 DrillLab 加的,很多教程版本过不去。
另外 toggleTheme 要包useCallback(..., [])。 依赖是空数组 —— 因为它内部只用函数式更新,不读任何外部变量,所以永远不需要重建。 这一步是下一节 useMemo 生效的前提。
toggleTheme reads the old value to compute the new one, so it has to use the updater form:
Writing setTheme(theme === "light" ? "dark" : "light") also works for a single click, but call it twice inside one event and it gives itself away — both calls read the same old theme, so it only flips once.
One test targets exactly this: a button whose onClick calls toggleTheme() twice, and a correct implementation ends back on light. DrillLab added that test, and a lot of tutorial versions fail it.
toggleTheme also needs to be wrapped in useCallback(..., []). The dependency list is empty because the body only uses the updater form and reads no outside variable, so it never needs rebuilding. This step is what makes the useMemo in the next section work.
最容易漏的一步:value 必须记忆化The step people miss most: the value has to be memoized
这行代码看着无害,会让整棵子树每次都重渲染。This line looks harmless, and it makes the whole subtree re-render every time.
很多人这么写 Provider:
问题在于 { theme, toggleTheme } 是字面量对象 —— 每次 Provider 渲染都是一个新对象。 Context 判断「值变没变」用的是引用比较(Object.is), 新对象就是「变了」。
后果:只要 Provider 的父层因为任何别的原因重渲染, 所有 useTheme() 的组件都会跟着重渲染 —— 哪怕主题一动没动。子树越大越明显, 而且 React.memo 也挡不住 (memo 挡的是 props,context 走的是另一条路)。
正解是 useMemo, 配合上一节的 useCallback: 只有 theme 真的变了,才产生新的 value。
这条能被测出来 —— 测试里放一个探针组件, 记录每次拿到的 value,父层重渲染后断言引用没变。 把 useMemo 删掉真跑一遍:
Plenty of people write the Provider like this:
The problem is that { theme, toggleTheme } is an object literal — a brand new object on every Provider render. Context decides “did the value change” by reference (Object.is), and a new object counts as changed.
The result: whenever the Provider’s parent re-renders for any unrelated reason, every component calling useTheme() re-renders with it — even though the theme never moved. The bigger the subtree, the more obvious it gets, and React.memo cannot stop it either (memo guards props; context takes a different route).
The fix is useMemo, together with the useCallback from the last section: a new value only appears when theme really changes.
And this is testable — the test drops in a probe component that records every value it receives, then asserts the reference is unchanged after the parent re-renders. Delete the useMemo and run it for real:
两个消费者The two consumers
按钮和卡片都只做一件事:取值、用值。The button and the card each do one thing: read the value, then use it.
按钮的文字是「要切到哪」,不是「现在是哪」。当前 light,就显示 Switch to Dark。 这一点很多人会写反 —— 题目明确要求了,读题的时候就要把这句话圈出来。
onClick={toggleTheme} 直接传函数引用, 不用写 () => toggleTheme()—— 后者每次渲染造一个新函数,没必要。
卡片只解构 theme,不取toggleTheme——只拿自己要用的。
data-theme={theme} 这个属性是给测试用的: 比对 style 里的颜色值更稳, 改配色不用改测试。真实项目里也常这么干 (本站的深色模式就是靠document.documentElement.setAttribute("data-theme", next)驱动 CSS 变量的)。
The button’s label says where you are going, not where you are. On light it reads Switch to Dark. A lot of people get this backwards — the question states it outright, so circle that sentence while you read.
onClick={toggleTheme} passes the function reference straight through. No need for () => toggleTheme() — that builds a new function on every render for nothing.
The card destructures only theme and leaves toggleTheme alone — take only what you use.
The data-theme={theme} attribute is there for the tests: it is steadier than comparing colour values in style, and changing the palette does not mean changing the tests. Real projects do the same thing (dark mode on this site runs on document.documentElement.setAttribute("data-theme", next) driving CSS variables).
完整答案The complete answer
8 个测试全过。All 8 tests pass.
ThemeProvider 里 children 的类型是ReactNode—— 这是「任何能渲染的东西」的标准类型, 字符串、数字、元素、数组、null 都算。
Provider 必须包在最外层。只有它的子树能 useTheme();ThemeProvider 组件自己内部也不能用 (那时 Provider 还没渲染出来)。
Inside ThemeProvider, children is typed ReactNode — the standard type for “anything that can be rendered”: strings, numbers, elements, arrays, null.
The Provider has to wrap the outermost layer. Only its subtree can call useTheme(); the ThemeProvider component cannot use it inside itself either (at that point the Provider has not rendered yet).
怎么验证How to check it
Context 怎么测?测的是「消费者看到了什么」,不是 context 本身。How do you test Context? You test what a consumer sees, not the context itself.
关键心态:不要去测 context 对象, 测「套在 Provider 里的组件表现对不对」。 所以每个测试都自己组装一小棵树。
三个值得学的写法:
- 探针组件—— 在测试文件里现写一个只调
useTheme()然后把结果 push 进数组的组件。想断言「引用有没有变」 只能这么干。 - 断言抛错要静音 console—— React 渲染中抛错会额外打一堆
console.error,vi.spyOn(console, "error")临时挡掉,测完mockRestore()。 - 多消费者一起断言——
getAllByTestId拿一组,比较整个数组。 这条测的正是 Context 的核心价值:一处改、处处变。
The mindset that matters: do not test the context object, test whether the components wrapped in the Provider behave correctly. So every test assembles its own little tree.
Three techniques worth stealing:
- A probe component — write one right there in the test file that only calls
useTheme()and pushes the result into an array. If you want to assert “did the reference change”, this is the only way. - Silence the console when asserting a throw — React logs a pile of
console.errorwhen a render throws;vi.spyOn(console, "error")blocks it temporarily, andmockRestore()puts it back when the test is done. - Assert on several consumers at once —
getAllByTestIdgrabs the group and you compare the whole array. That test covers the actual point of Context: change it in one place, it changes everywhere.
参考答案Reference solution
提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.
这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。This answer really was run here and its tests passed. But write it yourself first — reading an answer and producing one are two different skills, and the exam tests the second.