一、你有没有遇到过“状态打架”的坑?
做前端开发的人,大概率都踩过这样的坑:一个页面里有好几个组件,比如一个购物车的“商品数量输入框”组件、“总价格展示”组件、“结算按钮”组件,这三个组件都要用到“当前选中商品的数量”这个数据。你一开始可能会把这个数据存在父组件里,再通过props传给每个子组件,改的时候也在父组件改,看起来挺顺的。
但项目做大了之后,问题就来了:比如商品数量这个数据,除了购物车页面要用,用户点“加入收藏”的时候,收藏弹窗里的组件也要用到这个数据,这时候父组件可能隔了好几个层级,传props要传好几层,写起来又麻烦又容易乱;或者你干脆给每个组件都存一份自己的数量数据,结果改了一个组件的数量,另一个组件没更新,出现“输入框改了数量,总价格还是旧的”这种bug。
这种“多个组件要用到同一份数据,要么传参麻烦,要么数据不同步”的问题,就是前端的“状态管理”问题。今天要讲的Zustand,就是专门解决这类问题的工具,核心就是帮你把需要共享的数据存在一个“统一的仓库”里,所有要用的组件都从这个仓库拿数据、改数据,保证大家拿到的都是同一份。
二、Zustand到底怎么解决状态同步?
2.1 先搞懂Zustand的核心逻辑
你可以把Zustand的“仓库”想象成小区的公告栏:所有需要知道某个信息的人(组件),都去公告栏看(拿数据);如果有人要改信息,就直接改公告栏上的内容(改数据),改完之后,所有之前看过公告栏的人(组件),都会自动看到新内容(数据同步)。
和传统的Redux比,Zustand的好处就是不用写一堆模板代码,比如不用写action、reducer这些复杂的东西,写起来简单,学起来也快。
2.2 第一步:安装Zustand
要用到Zustand,首先得把它装到项目里,打开项目的终端,输入下面的命令就行:
# 安装Zustand,npm、yarn、pnpm三选一
npm install zustand
# 或者yarn add zustand
# 或者pnpm add zustand
2.3 第二步:创建你的第一个状态仓库
我们用React来做示例,所有示例的技术栈统一是:React 18 + Zustand 4。
先举个最基础的例子:创建一个存“商品数量”的仓库,这个仓库里不仅有数量数据,还有改数量的方法。
// 技术栈:React 18 + Zustand 4
// 导入Zustand的核心方法create,用来创建仓库
import { create } from 'zustand';
// 创建一个叫useProductStore的仓库,所有要用的组件都要用到这个名字
const useProductStore = create((set) => ({
// 仓库里存的状态:初始商品数量是1
productCount: 1,
// 仓库里的方法:用来修改商品数量,参数newCount是新的数量
// set是Zustand提供的方法,用来更新仓库里的状态
setProductCount: (newCount) => set({ productCount: newCount }),
}));
// 把这个仓库导出,其他组件才能用
export default useProductStore;
这个仓库的作用很简单:存一个数字productCount,还有一个改这个数字的方法setProductCount。
2.4 第三步:多个组件同时用这个仓库
现在我们写三个组件,分别是数量输入框、总价格展示、结算按钮,这三个组件都从这个仓库拿数据、改数据,看看会不会同步。
第一个组件:数量输入框,用来让用户改商品数量
// 技术栈:React 18 + Zustand 4
// 导入我们刚才创建的仓库
import useProductStore from './useProductStore';
function CountInput() {
// 从仓库里拿到数量数据和改数量的方法
// 这里的useProductStore是个钩子,括号里的函数指定要拿仓库里的什么数据
const { productCount, setProductCount } = useProductStore(state => ({
productCount: state.productCount,
setProductCount: state.setProductCount,
}));
// 输入框的值变了的时候,调用仓库的方法改数量
const handleChange = (e) => {
// 把输入框的字符串转成数字,避免输入非数字的情况
const newCount = Number(e.target.value);
// 调用仓库的方法更新数量
setProductCount(newCount);
};
return (
<div>
<label>商品数量:</label>
{/* 输入框的值绑定仓库里的productCount,改的时候调用handleChange */}
<input
type="number"
value={productCount}
onChange={handleChange}
min={1} // 数量最少是1
/>
</div>
);
}
export default CountInput;
第二个组件:总价格展示,假设每个商品10块钱,总价格=数量×10
// 技术栈:React 18 + Zustand 4
import useProductStore from './useProductStore';
function TotalPrice() {
// 只从仓库里拿productCount就行,不需要改,所以不用拿改的方法
const productCount = useProductStore(state => state.productCount);
// 计算总价格
const total = productCount * 10;
return (
<div>
总价格:{total} 元
</div>
);
}
export default TotalPrice;
第三个组件:结算按钮,点击的时候弹出当前的商品数量
// 技术栈:React 18 + Zustand 4
import useProductStore from './useProductStore';
function CheckoutButton() {
// 从仓库里拿数量数据
const productCount = useProductStore(state => state.productCount);
const handleClick = () => {
alert(`您要结算的商品数量是:${productCount}`);
};
return (
<button onClick={handleClick}>结算</button>
);
}
export default CheckoutButton;
最后把这三个组件放到一个页面里:
// 技术栈:React 18 + Zustand 4
import CountInput from './CountInput';
import TotalPrice from './TotalPrice';
import CheckoutButton from './CheckoutButton';
function ProductPage() {
return (
<div style={{ padding: '20px' }}>
<CountInput />
<TotalPrice />
<CheckoutButton />
</div>
);
}
export default ProductPage;
现在你运行这个页面,会发现:输入框改数量的时候,总价格会自动跟着变;点结算按钮,弹出的数量也是最新的。三个组件用的是同一份数据,没有传props,也没有重复存数据,完美解决了状态同步的问题。
三、更复杂的状态:对象类型的同步
刚才的例子里,仓库存的是简单的数字,实际项目里,我们经常要存更复杂的数据,比如一个商品的完整信息,包括名称、价格、数量,这时候状态是一个对象,改的时候要注意什么?
先创建一个存商品信息的仓库:
// 技术栈:React 18 + Zustand 4
import { create } from 'zustand';
const useGoodsStore = create((set) => ({
// 仓库里存的状态是一个对象:商品信息
goodsInfo: {
name: '纯棉T恤',
price: 10,
count: 1,
},
// 改商品数量的方法:只改对象里的count属性
setGoodsCount: (newCount) => set((state) => ({
// 这里的写法是把原来的goodsInfo展开,再覆盖count属性
goodsInfo: { ...state.goodsInfo, count: newCount },
})),
// 改商品价格的方法:只改对象里的price属性
setGoodsPrice: (newPrice) => set((state) => ({
goodsInfo: { ...state.goodsInfo, price: newPrice },
})),
}));
export default useGoodsStore;
这里要注意一个坑:如果你直接改对象的属性,比如写成set((state) => { state.goodsInfo.count = newCount; }),Zustand是不会检测到状态变化的,组件也不会更新。必须像上面的例子那样,把原来的对象展开,生成一个新的对象,再赋值给goodsInfo,这样Zustand才知道状态变了,才会同步给所有组件。
我们再写一个组件来测试这个仓库:
// 技术栈:React 18 + Zustand 4
import useGoodsStore from './useGoodsStore';
function GoodsDemo() {
// 从仓库里拿到商品信息和改数量的方法
const { goodsInfo, setGoodsCount } = useGoodsStore(state => ({
goodsInfo: state.goodsInfo,
setGoodsCount: state.setGoodsCount,
}));
const handleChangeCount = (e) => {
const newCount = Number(e.target.value);
setGoodsCount(newCount);
};
return (
<div>
<div>商品名称:{goodsInfo.name}</div>
<div>商品价格:{goodsInfo.price} 元</div>
<input
type="number"
value={goodsInfo.count}
onChange={handleChangeCount}
min={1}
/>
<div>总价格:{goodsInfo.price * goodsInfo.count} 元</div>
</div>
);
}
export default GoodsDemo;
运行这个组件,改数量的时候,总价格会自动更新,说明对象类型的状态也能正常同步。
四、状态同步的进阶:避免不必要的更新
刚才的例子里,每个组件从仓库拿数据的时候,都会把整个仓库的状态都拿过来,其实这样会有一个小问题:如果仓库里有很多状态,比如有商品信息、用户信息、订单信息,而一个组件只用到商品信息里的数量,那用户信息或者订单信息变的时候,这个组件也会跟着重新渲染,浪费性能。
怎么解决这个问题?我们可以用Zustand的“选择器”,让组件只监听自己用到的状态变化。
还是拿刚才的useGoodsStore举例子,我们改一下组件的写法:
// 技术栈:React 18 + Zustand 4
import useGoodsStore from './useGoodsStore';
function OnlyCountComponent() {
// 这里的选择器只返回goodsInfo里的count属性
// 也就是说,这个组件只会监听count的变化,其他属性变了不会重新渲染
const count = useGoodsStore(state => state.goodsInfo.count);
return (
<div>
我只关心商品数量:{count}
</div>
);
}
export default OnlyCountComponent;
这个组件只会在count变化的时候才会重新渲染,商品价格、名称变的时候,它不会动,性能更好。
如果组件需要拿多个状态,又不想监听整个仓库的变化,可以这么写:
// 技术栈:React 18 + Zustand 4
import useGoodsStore from './useGoodsStore';
function PriceAndCountComponent() {
// 选择器返回一个对象,里面只有price和count两个属性
// 这个组件只会在price或者count变化的时候才会重新渲染
const { price, count } = useGoodsStore(state => ({
price: state.goodsInfo.price,
count: state.goodsInfo.count,
}));
return (
<div>
商品价格:{price} 元,数量:{count}
</div>
);
}
export default PriceAndCountComponent;
五、Zustand状态同步的应用场景、优缺点和注意事项
5.1 应用场景
Zustand适合哪些场景用? 第一,多组件共享状态的场景,比如购物车的商品数量、用户的登录状态、全局的主题色,这些数据需要在多个组件里用到,用Zustand存起来,比传props方便很多。 第二,大型项目的状态管理,项目大了之后,组件层级深,传props会很麻烦,用Zustand可以把状态统一管理,方便维护。 第三,需要状态持久化的场景,比如用户的登录状态,刷新页面之后不想重新登录,Zustand可以配合本地存储把状态存起来,下次打开页面的时候自动恢复。
5.2 技术优缺点
优点: 第一,简单易上手,不用写复杂的模板代码,比如Redux的action、reducer,Zustand的写法和普通的React组件逻辑差不多,新手也能快速学会。 第二,性能好,支持选择器,组件只会监听自己用到的状态变化,不会因为其他状态变化而重新渲染。 第三,灵活,既可以存简单的状态,也可以存复杂的对象,还支持中间件,比如持久化、调试等。
缺点: 第一,没有强制的规范,因为太灵活了,团队开发的时候,可能每个人写仓库的方式不一样,有的把所有状态都放在一个仓库里,有的分很多仓库,时间长了项目会乱。 第二,调试稍微麻烦一点,虽然有调试工具,但不如Redux的调试工具强大,复杂的状态变化不好追踪。
5.3 注意事项
第一,改状态的时候要注意“不可变”,尤其是对象类型的状态,不能直接改原来的对象,必须生成新的对象,否则Zustand检测不到状态变化,组件不会更新。 第二,不要把所有状态都放在一个仓库里,最好按功能分仓库,比如用户相关的状态放一个仓库,商品相关的放一个仓库,这样维护起来更方便。 第三,合理使用选择器,不要让组件监听整个仓库的状态,避免不必要的重新渲染,影响性能。
六、总结
前端的状态同步问题,本质上就是“多个组件需要同一份数据,怎么保证大家拿到的都是最新的”。Zustand通过“统一仓库”的思路,把共享的数据存在一个地方,所有组件都从这个地方拿数据、改数据,改完之后自动同步给所有用到的组件,完美解决了传props麻烦、数据不同步的问题。
用Zustand的时候,只要记住几个核心点:创建仓库、组件从仓库拿数据、改数据的时候遵守不可变规则、合理用选择器优化性能,就能轻松搞定多组件的状态同步问题。
评论
围绕“Zustand的状态同步问题:确保多组件间状态一致”参与讨论