变式五 · 主题切换:Context 怎么用Variation 5 · theme switching: how to use Context
createContext 三行就写完了。真正会挂的地方是「value 每次都是新对象」和「忘了套 Provider」。createContext takes three lines. What actually breaks is a value that is a new object every render, and a missing Provider.
这一页有什么On this page10
- 01 为什么要 Context:props 传不动了Why Context exists: props cannot carry the value that far
- 02 Context 只有三个动作,加一个自定义 hookContext has only three moves, plus a custom hook
- 03 toggleTheme:又是函数式更新toggleTheme: the updater function form again
- 04 最容易漏的一步:value 必须记忆化The step people miss most: the value has to be memoized
- 05 两个消费者The two consumers
- 06 完整答案The complete answer
- 07 怎么验证How to check it
- 练习 · 动手做Practice
- 常见错误Common mistakes
- 迁移模式Transfer
- 说清什么时候该上 Context、什么时候不该Say when Context is the right tool and when it is not
- 写出 createContext + Provider + useContext 这一套,并包成自定义 hookWrite the createContext + Provider + useContext set, and wrap it in a custom hook
- 解释 context value 为什么必须 useMemo、toggleTheme 为什么要 useCallbackExplain why the context value needs useMemo and why toggleTheme needs useCallback
- 看懂「忘了套 Provider」的真实报错,并知道怎么让它报得更清楚Read the real error you get when the Provider is missing, and know how to make that error clearer
主题切换是 Context 最常见的考法,同一套骨架换个壳就是「当前登录用户」「语言」「购物车」。源项目里一个 Context 都没有,所以前面没讲。这道题除了考 API 会不会写,更考两个细节:value 有没有记忆化、忘了 Provider 时错误信息够不够清楚 —— 这两点是区分「抄过教程」和「真写过」的地方。Theme switching is the most common way exams test Context. The same skeleton with a different label becomes the current user, the language, or the shopping cart. The source projects contain no Context at all, so no earlier lesson covered it. Beyond writing the API correctly, this question tests two details: whether the value is memoized, and whether the error is clear when the Provider is missing. Those two separate someone who copied a tutorial from someone who has really written this.
为什么要 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.
动手做Get your hands on it
填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.
四个空。第 3 个是最容易漏的那一步,第 4 个决定「忘了套 Provider」 时报错清不清楚。
Four blanks. The third is the step people miss most. The fourth decides how clear the error is when somebody forgets to wrap things in the Provider.
类型已给好。写出 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.
- theme 初始为 'light'theme starts as 'light'
- toggleTheme 在 light / dark 之间翻转,必须用函数式更新toggleTheme flips between light and dark, using the updater form
- context value 要记忆化,theme 不变时不产生新对象The context value has to be memoized, so no new object appears while theme is unchanged
- toggleTheme 引用要稳定(theme 变了它也不变)The reference of toggleTheme has to be stable, unchanged even when theme changes
- 没套 Provider 就用 useTheme() 时抛出一句能看懂的错误Calling useTheme() with no Provider above it throws a message a person can read
- 不许把 theme 存到组件外的全局变量里Do not keep theme in a global variable outside the component
看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。Before you open this, make sure you have written it yourself once. Following someone else's answer and producing your own are two different skills.
按钮好好的,卡片一渲染就整页白屏。这是真实报错。
The button is fine, and the moment the card renders the whole page goes blank. This is the real error.
初学者常见的几种写法错误Mistakes beginners actually make
下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.
用
useMemo。而且功能测试不会红,只有专门测引用的那条会红。Every render builds a new object. Context compares by reference, decides the value changed, and every consumer re-renders — even when the theme did not move.Use
useMemo. Note that the behaviour tests stay green; only a test written to check the reference turns red.这是最难查的一类 bug ——宁可炸,也别静默地对。With the Provider missing there is no error at all: the page shows the light theme as usual, the button calls that empty function, and nothing happens.
This is the hardest kind of bug to track down. Better to fail loudly than to look correct in silence.
useContext 找的是祖先里的 Provider。ThemeProvider 组件自己不是自己的祖先。Provider 内部要用 theme,直接用那个
useState的变量就行 —— 它就在手边。useContext looks for a Provider among its ancestors. The ThemeProvider component is not its own ancestor.If you need the theme inside the Provider, just use the
useState variable. It is right there.Switch to Dark。这条不涉及任何技术难点,纯粹是读题—— 而 assessment 里这种分丢得最不值。The task asks for where it will switch to: when the current theme is light, show
Switch to Dark.Nothing technical is involved here. It is purely reading the task, and in an exam these are the cheapest points to lose.
换一道题也能用Works on other problems too
考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.
- Context 解决跨层传值,适合「整棵树都读、又不常变」的东西;它不是状态管理器。Context solves passing a value across layers. It fits things the whole tree reads and that rarely change. It is not a state manager.
- 三个动作:createContext 造管道、Provider 灌值、useContext 取值;第四步自己包 hook 加守卫。Three moves: createContext builds the pipe, Provider puts a value in, useContext takes it out. The fourth step is your own hook with a guard in it.
- 默认值给 undefined + hook 里 throw,比给个假默认值好 —— 宁可炸也别静默地对。Pass undefined as the default and throw inside the hook. That is better than a fake default value: fail loudly rather than look correct in silence.
- toggleTheme 用函数式更新 + useCallback([]);一次事件连调两次也能正确翻回来。toggleTheme uses the updater function form plus useCallback with an empty dependency list, so two calls in one event still flip back correctly.
- value 必须 useMemo,否则所有消费者每次都重渲染,而功能测试全绿查不出来。The value must go through useMemo, or every consumer re-renders every time, and the behaviour tests stay green so nothing catches it.
- 按钮文字是「要切到哪」,不是「现在是哪」—— 这是读题分。The button text says where it will switch to, not what it is now. These are points for reading the task.