一、你有没有遇到过“状态打架”的坑?

做前端开发的人,大概率都踩过这样的坑:一个页面里有好几个组件,比如一个购物车的“商品数量输入框”组件、“总价格展示”组件、“结算按钮”组件,这三个组件都要用到“当前选中商品的数量”这个数据。你一开始可能会把这个数据存在父组件里,再通过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的时候,只要记住几个核心点:创建仓库、组件从仓库拿数据、改数据的时候遵守不可变规则、合理用选择器优化性能,就能轻松搞定多组件的状态同步问题。