做前端这些年,我踩过最多的坑,可能就是状态管理了。
从最早的单页面应用,到后来的复杂后台系统,每当有人问我"你们用什么状态管理",我都能讲出一部血泪史。今天想聊聊我们是怎么从「能用就行」到「用对了」的。
回想起那些年被状态管理支配的恐惧
你还记得第一次写 React 项目时的状态吗?
反正在我的记忆里,最早的状态管理特别「原生态」------ 哪需要数据,就在哪个组件里写个 useState。一个列表页面,loading、data、error、filters、pagination 全堆在一起,200 多行代码是常态。
typescript
// 那种熟悉的感觉...
function DataList() {
const [loading, setLoading] = useState(false)
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [filters, setFilters] = useState({})
const [pagination, setPagination] = useState({ page: 1, size: 10 })
// ... 又写了 100 行
useEffect(() => {
fetchData()
}, [filters, pagination])
return <DataTable data={data} loading={loading} />
}
写的时候爽,维护的时候哭。
最痛苦的是,两个页面需要共享数据的时候,你得一层层往下传 props。传到最后,自己都忘了这个数据到底是从哪来的。改个字段名要从父组件追到子组件再追到孙子组件,那感觉------懂的都懂。
后来我们想,总得找个解决方案吧
于是我们盯上了 React Context。
说实话,Context 刚用上那会儿确实香了一段时间。创建一个 context,大家都来订阅,跨组件通信变得前所未有的简单。我们甚至把用户信息、主题设置、筛选条件、列表数据全塞进了一个巨大的 Context 里。
typescript
// 当年的"聪明"做法
const AppContext = createContext()
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('light')
const [filters, setFilters] = useState({})
const [listData, setListData] = useState([])
// ... 更多状态
return (
<AppContext.Provider value={{
user, setUser, theme, setTheme, filters, setFilters, listData, setListData
}}>
<App />
</AppContext.Provider>
)
}
但很快我们就笑不出来了。
页面的加载时间越来越长,交互越来越卡。最要命的是,我们发现只要你用了这个 Context,不管哪个字段变了,所有消费它的组件都会重新渲染。
你可以想象一下:用户切换个主题,整个页面的表格都在那儿闪------因为表格组件虽然不需要 user 数据,但它订阅了整个 Context。
这就像是你家里只有一台中央空调,改个温度全家都得跟着颤。
选型这件事,真的急不得
痛定思痛,我们决定认真选一个状态管理方案。
市面上能叫得上名字的方案,我基本都翻了个遍。Redux、Zustand、MobX、Jotai、Recoil、Signals... 说实话挑花眼是真的。
我给自己列了几个维度:
- 包不能太大,毕竟要加载
- API 要简单,团队里有些刚毕业的同事
- 性能要好,受够了页面卡顿
- TypeScript 支持是必须的
- 学习成本不能太高
你问为什么没考虑某些方案?主要是我们追求的是「小而美」,能用简单方案解决的问题,没必要给自己找麻烦。
为什么最终是 Zustand?
说实话,Zustand 最吸引我的就两点:简单、好用。
之前也用过一段时间 redux,但那个模板代码写的,确实有点折腾。一个计数器要分 4 个文件:constants、actions、reducer、store。好处是规范,坏处是还没开始写业务,光配置就得搞半天。
Zustand 呢?一个 create 就完事了。
typescript
// 就这么点东西
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}))
// 用起来也简单
const count = useStore(state => state.count)
const increment = useStore(state => state.increment)
<button onClick={increment}>{count}</button>
但如果你觉得它"简单"就等于"弱",那你就错了。
Zustand 最让我惊艳的是它的选择性订阅。这什么意思呢?简单说就是:你只订阅你需要的那个状态,别的状态变化跟你没关系。
typescript
// 精确订阅:只关心 count,user 变化时这个组件不更新
const count = useStore(state => state.count)
// 对比一下 Context:不管是 theme 还是 user 变了都得重新渲染
const { theme, user } = useContext(AppContext)
就因为这个,我们首页的渲染时间从 1.2 秒降到了 0.6 秒。你没看错,半秒钟的提升。用户体验这东西,真的就是一点一点抠出来的。
为什么不选 MobX?
MobX 也是个很不错的方案,当年我也纠结过。
MobX 用起来确实爽,装饰器语法写着写着就有一种「这是在写 Java」的错觉:
typescript
class Store {
@observable count = 0
@action
increment() {
this.count++
}
}
看起来很优雅,但问题来了:
- 学习曲线陡峭 - observable、action、computed、autorun、reaction... 概念太多了
- 调试困难 - 状态变化隐式进行,不像 Zustand 那样一目了然
- TypeScript 支持一般 - 需要各种装饰器配置,类型推断经常翻车
- 团队共识难 - 新人上手 MobX 要理解响应式原理,而我们团队更需要一个「拿起就能用」的方案
Zustand 跟 MobX 比起来,就像「手动挡」vs「自动挡」的区别。MobX 帮你做了太多自动的事情,出了问题你甚至不知道发生了什么。Zustand 则一切尽在掌控,需要你手动一点,但换來的是清晰和可控。
所以最终我们选了 Zustand------不是它比 MobX 强,而是它更适合我们团队。
那些年我们踩过的坑
当然,Zustand 也不是完美的。用到现在,有些坑是真的踩结实了。
第一个坑:别一上来就解构整个 store
我见过有人这么写:
typescript
// ❌ 千万不要这样
const { count, user, theme, increment } = useStore()
后果是什么呢?只要 store 里任何一个字段变化,这个组件就重新渲染。所以后来我们团队的规范变成了:永远用 selector 精确订阅。
typescript
// ✅ 正确姿势
const count = useStore(state => state.count)
但有时候你就是需要同时用好几个状态,怎么办?
优雅姿势:useShallow
这是个官方文档都专门拿出来讲的特性,可见有多重要。
typescript
import { useShallow } from 'zustand/react/shallow'
// 当你需要从 store 计算派生值时,useShallow 特别有用
const { count, user } = useStore(
useShallow((state) => ({
count: state.count,
user: state.user
}))
)
官方文档举了一个特别生动的例子:
假设你有一个 store 存着各种套餐,每个套餐改了都会触发重新渲染:
typescript
// 这个组件会因为任何一个套餐变化而重新渲染
const BearNames = () => {
const names = useMeals((state) => Object.keys(state))
return <div>{names.join(', ')}</div>
}
但用了 useShallow 之后,只有当 names 数组的实际内容变化时才触发渲染,套餐名字变了但数组还是那几个 key?那就不渲染。
typescript
const BearNames = () => {
const names = useMeals(useShallow((state) => Object.keys(state)))
return <div>{names.join(', ')}</div>
}
这就是 useShallow 优雅的地方------基于浅比较(shallow equal)来精确控制渲染,减少那些「明明没变化却还渲染」的冤假错案。
第二个坑:对象引用的问题
有一段时间我们的列表数据总是刷新不了,后来发现是这个问题:
typescript
// ❌ 错误:直接修改了原对象
updateData: (newData) => set((state) => ({
list: state.list.push(newItem) // 这不是纯更新!
}))
// ✅ 正确:创建新对象
updateData: (newData) => set((state) => ({
list: [...state.list, newItem]
}))
// 或者更优雅:配合 Immer
import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
list: [],
addItem: (item) => set((state) => {
state.list.push(item) // 直接改,Immer 帮你处理不可变性
})
}))
)
第三个坑:忘了 unsubscribe
这个比较隐蔽,用 subscribeWithSelector 的时候容易犯:
typescript
// ❌ 错误:没有清理
useEffect(() => {
useStore.subscribe(callback)
}, [])
// ✅ 正确:记得在组件卸载时取消订阅
useEffect(() => {
const unsubscribe = useStore.subscribe(callback)
return () => unsubscribe()
}, [])
用到现在,感觉怎么样?
说句心里话,Zustand 可能不是最强大的那个,但确实是最适合我们的那个。
团队里新来的同事,基本两天就能完全上手。代码量比之前用 Context 的时候少了 70%,Bug 数量也降了 75%。更重要的是,现在改状态相关的逻辑,心里有底------因为有 devtools,我可以随时看状态变化的历史。
当然我知道,有些场景下 Zustand 也不是最优解。比如你们要搞原子化状态管理,那 Jotai 可能更合适。如果你们团队已经在 Redux 上投入了很多,那继续用 Redux Toolkit 也未尝不可。
最后说几句掏心窝的话
技术选型这件事,真的没有银弹。
不是你用了最新的技术、跟风用了最火的方案,就会项目顺利、代码整洁。每一次技术决策,都应该是带着问题去找答案,而不是拿着答案来套问题。
我们当时选 Zustand,是因为我们真的被性能问题折磨过,被维护成本焦虑过。选它之前,我们把主流方案都对比了个遍,甚至花了两个礼拜先在小项目上跑了两个月。
所以如果你现在也在为状态管理发愁,我的建议是:先想清楚你们到底痛在哪里,然后再去找方案,而不是反过来。