缺口三 · 同一个 Todo 换成 Redux ToolkitGap 3 · the same Todo app, moved to Redux Toolkit
业务和变式一完全一样,换成 createSlice + selector —— 正好能对比出 Redux 到底多给了什么。The same app as variant one, rebuilt with createSlice and selectors — which makes it easy to see what Redux actually adds.
这一页有什么On this page6
- 01 createSlice:一次生成 reducer 和 actionscreateSlice: one call gives you the reducer and the actions
- 02 selector:这才是 Redux 比 Context 强的地方Selectors: this is where Redux beats Context
- 03 和变式一(useState 版)对比:换来了什么,代价是什么Compared with variant one, the useState version: what you gain and what it costs
- 练习 · 动手做Practice
- 常见错误Common mistakes
- 迁移模式Transfer
- 用 createSlice 写出一个完整的 slice,并说明 Immer 为什么不违反「state 只读」Write a complete slice with createSlice, and explain why Immer does not break the rule that state is read-only
- 解释 prepare 的作用以及为什么 id 不能在 reducer 里生成Explain what prepare is for, and why an id must not be generated inside a reducer
- 用 selector 做到「只订阅自己要的那部分」Use a selector so a component subscribes only to the part it needs
- 脱离 React 单测 reducerUnit test a reducer without React
「用 Redux Toolkit 做一个 Todo」是 Medium 里的常见题。它真正在考三件事:知不知道现在不该手写 action types 了、知不知道 Immer 的草稿是怎么回事、知不知道 selector 的意义。同一个业务和变式一对照着看,能清楚看出 Redux 换来了什么、代价是什么。Building a Todo app with Redux Toolkit is a common medium problem. It really tests three things: whether you know that action types are no longer written by hand, whether you know what an Immer draft is, and whether you know what a selector is for. Putting it next to variant one, which does the same job, shows clearly what Redux buys you and what it costs.
createSlice:一次生成 reducer 和 actionscreateSlice: one call gives you the reducer and the actions
手写 Redux 的那套样板已经过时了。The hand-written Redux boilerplate is out of date.
老写法要写三份: action types 常量、 action creators、 一个大 switch 的 reducer。createSlice把三份合成一份—— 你只写 reducers 对象, 它自动生成对应的 action creator 和 type (type 是 "todos/added"这样的 切片名/reducer 名)。
Immer 那一点必须说清:state.items.push(...)看起来违反了「state 只读」, 其实你拿到的是一个草稿代理(draft proxy)—— 你的修改被记录下来, Immer 最终产出一个新对象, 原 state 一个字节没动。测试里我用expect(next).not.toBe(empty)和「原 state 长度仍是 0」两条断言证明了这一点。
但有个边界要记住: 这个「能 mutate」的特权只在 createSlice /createReducer 里成立。 在组件里、在 selector 里、 在自己写的普通函数里,该守的不可变规则一条都不能少。
prepare 是这道题的加分点。生成 id 要用nanoid(), 而 reducer 必须是纯函数 —— 不能出现随机数和时间(否则同样的 state + action 会得到不同结果,时间旅行就废了)。prepare让你在创建 action 的时候生成 id,reducer 只负责把它放进去。
The old way needed three pieces: action type constants, action creators, and a reducer built around one big switch. createSlice folds the three into one — you only write the reducers object and it generates the matching action creator and type (the type is "todos/added", that is, sliceName/reducerName).
The Immer part has to be said clearly: state.items.push(...) looks like it breaks “state is read-only”, but what you hold is a draft proxy — your edits get recorded and Immer produces a new object at the end, leaving the original state untouched byte for byte. The test proves it with two assertions: expect(next).not.toBe(empty) and “the original state still has length 0”.
But there is a boundary to remember: this mutate-anyway privilege only holds inside createSlice / createReducer. In components, in selectors, in your own plain functions, every immutability rule still applies.
prepare is the bonus point of this question. Generating the id needs nanoid(), and a reducer has to be pure — no random numbers, no clock (otherwise the same state + action gives different results and time travel is dead). prepare lets you generate the id while the action is being created, so the reducer only puts it in place.
selector:这才是 Redux 比 Context 强的地方Selectors: this is where Redux beats Context
三个 useSelector 各自订阅一小块。Three useSelector calls, each subscribing to one small part.
组件里写了三个 useSelector: 可见列表、剩余条数、当前筛选。每一个只在自己那部分变化时触发重渲染。
对比 Context(变式五那道题): context value 一变,所有 useTheme()的组件全部重渲染, 哪怕它只用了 theme而变的是 toggleTheme。这就是八股 #349 里说的「Context 缺精细订阅」, 在这里能具体看到。
一个必须知道的坑: selector 不要返回新对象。useSelector默认用 === 比较结果, 返回 { a, b }这样的新对象每次都不相等, 于是每次 store 有任何变化都重渲染 —— 等于优化白做。
解法:拆成多个useSelector(本实现的做法,最简单), 或者用 createSelector做记忆化,或者传shallowEqual 当第二个参数。
注意 selectVisible是从 items 派生的, 筛选不改底层数据—— 和变式一里「写操作必须作用于完整数据」 是同一条规矩。测试里专门有一条验证「筛完底层仍是两条」。
The component has three useSelector calls: the visible list, the remaining count, the current filter. Each one re-renders only when its own slice changes.
Compare with Context (the variant 5 question): the moment the context value changes, every useTheme() component re-renders, even one that only uses theme while what changed was toggleTheme. That is the “Context has no fine-grained subscription” point from interview #349, in concrete form.
One trap you have to know: a selector must not return a new object. useSelector compares its result with === by default, and a fresh object like { a, b } is never equal, so any change anywhere in the store re-renders — the optimization cancels itself out.
Fixes: split it into several useSelector calls (what this implementation does, and the simplest), memoize with createSelector, or pass shallowEqual as the second argument.
Note that selectVisible is derived from items, and filtering does not touch the underlying data — the same rule as “writes have to act on the complete data” in variant 1. One test is there to verify that after filtering the underlying data still has two entries.
和变式一(useState 版)对比:换来了什么,代价是什么Compared with variant one, the useState version: what you gain and what it costs
| 变式一(useState) | 本题(Redux Toolkit) | |
|---|---|---|
| 状态放哪 | 组件内 | 全局 store |
| 谁能改 | 这个组件 | 任何组件 dispatch 就行 |
| 逻辑能不能脱离 React 测 | 不能(要 render) | 能—— reducer 是纯函数 |
| 调试 | console.log | DevTools 能看每个 action 和 state diff |
| 代码量 | 一个文件 | slice + store + 组件,三个文件 |
| 依赖 | 无 | 两个包 |
结论怎么说(面试里问「该不该上 Redux」时用):这道题的规模用 Redux 是过度设计—— 一个组件自己的列表,useState 就够。Redux 的价值要在 「同一份状态被很多不相关的组件读写」 或者「需要按 action 追溯 bug」 的时候才兑现。
能主动说出「这题我会用 useState, 但如果需求是 XX 我会上 Redux」, 比闷头写完 Redux 更能显示判断力。
顺带看得出的一个真实收益: 因为 reducer 是纯函数,八条测试里有五条完全不需要渲染任何组件—— 直接 reducer(state, action) 断言。 测试跑得更快、失败信息更准。
| Variant 1 (useState) | This one (Redux Toolkit) | |
|---|---|---|
| Where the state lives | inside the component | a global store |
| Who can change it | this component | any component that dispatches |
| Can the logic be tested without React | no (you have to render) | yes — the reducer is a pure function |
| Debugging | console.log | DevTools shows every action and the state diff |
| Amount of code | one file | slice + store + component, three files |
| Dependencies | none | two packages |
How to phrase the conclusion (for when they ask “should we bring in Redux?”): at this size Redux is over-engineering — one component’s own list is fine with useState. Redux pays off when the same state is read and written by many unrelated components, or when you need to trace a bug action by action.
Saying “I would use useState here, but if the requirement were XX I would reach for Redux” shows more judgment than silently writing the whole Redux setup.
One real benefit that falls out of this: because the reducer is a pure function, five of the eight tests render no component at all — they assert on reducer(state, action) directly. The tests run faster and the failure messages are more precise.
动手做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 个空写错会让 reducer 不纯。
Four blanks. The 3rd one is the bonus point of this question; get the 4th one wrong and the reducer is no longer pure.
初学者常见的几种写法错误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.
后果是时间旅行调试和 action 重放都失效—— 这正是 Redux 三大原则第 3 条要防的。用
prepare。The reducer is no longer pure: the same state and the same action produce a different result each time.Time-travel debugging and replaying actions both stop working — which is exactly what the third Redux principle protects. Use
prepare.useSelector 用=== 比结果, 新对象永远不相等 ——store 里任何东西变了这个组件都重渲染, selector 的意义完全没了。拆成多个
useSelector, 或者用 createSelector /shallowEqual。useSelector compares the result with ===, and a new object is never equal to the previous one — so this component re-renders whenever anything in the store changes, and the selector no longer does anything for you.Split it into several
useSelector calls, or use createSelector or shallowEqual.createSlice /createReducer 内部生效。在普通函数里这就是实实在在的 mutate, 而且返回的还是同一个引用 —— React 不会重渲染。Immer only applies inside createSlice and createReducer. In an ordinary function this changes the original object for real, and it returns the same reference — so React does not re-render.换一道题也能用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.
- createSlice 一次生成 reducer、action creators 和 types,老写法的三份样板全省。One createSlice call produces the reducer, the action creators and the types, replacing all three pieces of the old boilerplate.
- Immer 给的是草稿代理,push 也能产出新对象 —— 但这个特权只在 createSlice/createReducer 里有。Immer hands you a draft, so even push produces a new object — but that only holds inside createSlice and createReducer.
- id 要在 prepare 里生成,reducer 必须纯,否则时间旅行失效。Generate the id in prepare; the reducer has to stay pure or time travel stops working.
- selector 让组件只订阅自己那部分 —— 这是 Redux 比 Context 强的具体地方。A selector lets a component subscribe to just its own part — this is the concrete place where Redux beats Context.
- selector 不要返回新对象,否则每次 store 变都重渲染。Do not return a new object from a selector, or the component re-renders on every store change.
- 这道题的规模用 useState 就够;能说出「什么时候才该上 Redux」比写完更重要。At this size useState is enough; being able to say when Redux is worth it matters more than finishing the code.