是时候放弃redux了,zustand是完美替代者(主要是源码分析)

12,382 阅读12分钟

前言

最近准备写react组件库展示的官网,因为要用到状态管理工具,想了一下redux这么难用,都2022年了,hooks都出来好几年了,而且react18也在今年闪亮登场,为啥不看看外面的世界,还有啥社区好评的状态管理库呢

注:其中react18的 Concurrent Mode模式,让很多第三方状态管理库不得不重新改写api。

Concurrent Mode模式会造成数据不一致的问题,比如A组件和B组件都获取了redux里的一个值。

假设字段是color:red,那么因为fiber树可以中断优先级比较低的任务,我们假设A组件此时正好render,开始fiber tree的数据更新,此时调度器中断了当前的任务,也就是中断了继续渲染B组件的任务,去执行优先级更高的任务。

优先级更高的任务把color的值改为green了,然后重新执行renderB组件的任务,此时就出现问题了数据撕裂的情况,B拿到的是color:green,A组件和B组件都是引用同样的redux里的值,居然拿到的数据不一样!

后来reudx解决了这一问题,但是我仍然认为有不少redux替代品可以尝试!

zustand最大的优点

我认为zustand最大的优点就是它跟本质上就是带了发布订阅模式的hooks。redux你存储的数据一直会在内存里面,你切换了路由数据还是在,但是zustand会跟着页面卸载数据会卸载,这就使得你存数据就很自然。(zustand也支持类似redux全局的store,也支持分散的store,是可以模拟redux的效果的)

那么你会说人家redux数据持久化啊,但是问题是你刷新应用还是会把redux存的数据重置对吧,而且zustand自带中间件,可以做真正的持久化,就是把数据存到loclastorage,这个过程你不用关心,框架实现的。

然后redux有的优点zustand全部具备,比如自定义中间件,Redux devtools支持等等。

最后一个我觉得zustand非常好的点在于单元测试,redux中的useSelector,测试起来还是有点麻烦,比如你可能要制造一个Provider的环境,zustand的也有类似useSelector的方法,它是一个纯函数,或者说是hooks,比redux好测试的多!

redux的问题

一行代码实现redux

redux本身其实说白了,就是纯纯的订阅发布模式,如果不考虑订阅发布模式的模板,我都可以用一个函数实现redux的核心功能,写成箭头函数也就是一行代码的事

function crateStore(state, reducer) {
  return {
    getState: () => state,
    dispatch: (action) => (state = action(state, reducer))
  };
}

redux真的很繁琐

用redux的开发体验非常差,为了一个功能又要写reducer又要写action,还要写一个文件定义actionType,显得很麻烦,当然啰嗦就是为了让一切清晰明确,但是当你的程序复杂度上去,变量加入有50个需要通信,相信我,你要写吐

还需要react-redux,reudx-saga,redux-thunk等等库辅助才能用

redux并不是开箱即用,首先连个异步都不是默认支持的,你说他有自己的一套哲学思想,但现实就是,纯用redux,你那哲学思想也不是多么好维护啊,后面我们讲到dva和redux-toolkit时会提到。

所以出来了各种异步支持的中间件,国内普遍用的是reudx-saga和redux-thunk。这里不多做介绍了,dva默认配置的就是redux-saga。

必须配置的就是react-redux,才能实现组件数据和reudx store的数据绑定,这里就有问题了,首先这就增大出bug的概率,之前react-redux一个比较知名的bug就是 僵尸child,大家有兴趣可以去搜。

而且这个库也要对redux的数据做各种判断,问题就来了,本质上就是一个存取数据,判断这次的数据和上次的数据是否一样,不一样就更新,一样就不更新就完事了!!!为啥整的这么多库,这么多代码去支持呢!

后续dva,redux-toolkit

dva不说了,typescript都不支持,我自己为项目组自研过一个类似dva的工具(起码是有ts提示的,然后异步用的自带的promise),详情请见下面的文章:

# 部门自研的封装redux的状态管理工具, 源码解析!

但是dva的思想还是可以的,有点类似ddd的思想,把写一个store的代码集中在一个文件,还有良好的插件机制。代码类似如下:

// Model
app.model({
  namespace: 'count',
  state: 0,
  reducers: {
    add(state) { return state + 1 },
    minus(state) { return state - 1 },
  },
});

redux-toolkit是redux官方升级版,跟dva差不多,都是方便了redux的使用,redux-toolkit的ts支持非常棒。整体也算是reudx的最佳实践了吧。

上面也讲了,我自研的状态管理工具其实也是参考很多类似dva的库,所以这类二次封装redux的库的原理我是比较熟悉的,核心代码也就不到100行。

问题在哪呢,还是项目状态太多的时候,我们说的把store写在一个文件里会显得非常臃肿,redux-toolkit是支持把数据仓库继续划分的,但是dva和一些类似的库是不支持的。

所以如果你真的很喜欢或者不想替换redux,redux-toolkit是第一选择,没有别的了。

后起之秀zustand

一个小型、快速、可扩展的状态管理解决方案,基于简化的 flux 原则。它使用轻量级、可组合的函数来管理状态,并通过使用 Hooks API 在 React 应用程序中进行集成。

与其他状态管理库(如 Redux)不同,Zustand 使用函数式编程风格,并且可以轻松地与其他函数库(如 React Hooks)组合使用。这使得它非常灵活,并且易于学习和使用。

要使用 Zustand,国际惯例install一下,例如npm安装

npm install zustand

然后,你可以使用 create 函数来创建一个状态管理器,并使用 useStore Hook 来访问状态:

import { create, useStore } from 'zustand';

const [useStore] = create(set => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 }))
}));

function MyComponent() {
  const { count, increment } = useStore();
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

在这个例子中,我们创建了一个状态管理器,其中包含一个计数器变量 count 和一个用于增加计数器的函数 increment。然后,我们使用 useStore Hook 来访问这些变量和函数,并在组件中使用它们。

支持异步也很简单,声明的函数是async即可,

const useStore = create((set) => ({
  fishies: {},
  fetch: async (pond) => {
    const response = await fetch(pond);
    set({ fishies: await response.json() });
  },
}));

也支持react-redux中的useSelector那样的浅比较,或者自定义比较。

总之,redux有的功能,它都有,还额外redux不具备的,api很简单,没有啥学习成本,我认为性能比redux更好,因为redux借助react-redux实现的从上到下的数据传递,没有zustand这样灵活。

源码分析

这部分适合看过zuatand文档的同学,中文文档链接(zhuanlan.zhihu.com/p/475571377)

通过窥探源码可知实现原理为:发布订阅模式,要说所有设计模式当中最实用的就是它,其他的很多都平时要么用不到,要不已经融入到js的api里不用学。

代码分析

以下代码是zustand截止2022年12月底最新的源码。

其实源码挺简单的,不需要调试,直接看就行了。

首先,我们看下createStore方法,对应的是我们案例中使用的

  • create 方法

相当于就是发布订阅模式的发布函数,这个就很好记了!发布函数就是把存储的回调函数调用一遍嘛。(如果你有发布订阅模式的基础的话,源码好理解到爆)

我们简单看下create方法一般怎么用(用来创建store的):

注意,一般都是会传一个函数给create

import create from "zustand";

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}));

我们简化了很多不必要的逻辑,直接看核心:

我们从ts的定义就能看到很多细节:

  • 从类型可以知道createStore返回值就是createState函数调用的返回值
  • createState是是啥呢,你看上面的案例,是不是就是下面这部分
(set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
})

这个函数传入了一个set函数,用来触发整个react组件重新渲染,在react里其实就是一个hooks而已。

大家知道为什么自定义hooks调用会引起react组件的重新渲染吗?这就需要你去看react的简易实现的文章了,我这里简单说一下,react的fiber会在浏览器空闲期间,什么叫空闲期间,你就要了解渲染一帧画面,经历了哪些步骤,比如先会监听一些事件是否触发,比如input,然后处理js代码逻辑,最后让UI线程去合成和渲染画面,当此时离下一帧的渲染还有空闲的话,就是空闲时间。

因为js和ui的渲染是互斥的,所以react采用fiber的可中断的结构(UI渲染不会中断,会一次性递归渲染整个页面,中断的是js计算和计算dom,比如生成div,绑定事件,绑定style属性)

自定义hooks都是使用了react的比如useState,或者useEffect等等官方hooks api,这些自定义hooks的fiber节点本身就有一个属性去记录这个节点上所有hooks,所以自定义hooks也有自己附属的react组件。

自定义 Hooks 将与使用它们的组件一起被渲染,并且它们也会在同一个 fiber 节点上运行。

所以自定义hooks调用,意味着fiber节点会作为渲染的根节点交给react的调度器,引起该组件和子组件的重新渲染。

接着看源码

const createStore = (createState) => {
  
 
  // 类型是ReturnType<typeof createState>
  //  state = createState(setState, getState, api)
  // 这里的state主要利用闭包,setState时会判断新的state和闭包里之前的state是否一样
  // 这样可以避免不必要的更新
  let state: TState
  
  // 类型是Listener: (state: TState, prevState: TState) => void的
  // 所以订阅的回调函数的参数就是当前的state和之前的state
  const listeners: Set<Listener> = new Set()

 // setState本质就是调用订阅的函数,在react环境里,触发自定义hooks引起重新渲染
  const setState = (partial, replace) => {
    const nextState =
      typeof partial === 'function'
        ? (partial as (state: TState) => TState)(state)
        : partial
    if (!Object.is(nextState, state)) {
      const previousState = state
      state =
        replace ?? typeof nextState !== 'object'
          ? (nextState as TState)
          : Object.assign({}, state, nextState)
      listeners.forEach((listener) => listener(state, previousState))
    }
  }

  // 获取当前state 
  const getState = () => state
  
  // 订阅函数
  const subscribe = (listener) => {
    listeners.add(listener)
    // Unsubscribe
    return () => listeners.delete(listener)
  }

  const destroy = () => listeners.clear()
  const api = { setState, getState, subscribe, destroy }
  state = createState(setState, getState, api)
  return api as any
}

接着看跟react相关的useStore的核心实现,里面涉及两个陌生的函数,都是react官方提供的

  • useDebugValue
  • useSyncExternalStoreWithSelector

先讲一下useDebugValue。

useDebugValue 是一个 React 自带的 Hook,它可以帮助你在调试工具中显示当前使用的值。它接受一个参数,即要在调试工具中显示的值。

通常,你可以在你自定义的 Hook 函数的末尾使用 useDebugValue,以便在你的组件内部使用该 Hook 时,你可以在调试工具中看到当前的值。这可以帮助你更好地理解你的组件的内部状态,并有助于调试问题。

例如,假设你有一个名为 useCounter 的自定义 Hook,它可以给你的组件添加一个计数器。你可以在你的 Hook 函数的末尾使用 useDebugValue,以便在你的组件内部使用该 Hook 时,你可以在调试工具中看到当前的计数器值。

下面是一个示例,展示了如何在自定义 Hook 中使用 useDebugValue

import { useDebugValue } from 'react'

function useCounter() {
  const [count, setCount] = useState(0)
  useDebugValue(count)
  return [count, setCount]
}

在这个示例中,useCounter Hook 使用 useDebugValue 来显示当前的计数器值。当你在组件内部使用这个 Hook 时,你就可以在调试工具中看到当前的计数器值。

再看看useSyncExternalStoreWithSelector,为什么要有这个函数呢,主要是为了解决react 18引起的数据撕裂问题,在文章的开头我们已经介绍过了,所以react官方推出了一系列类似可以防止数据不一致的官方工具hook。

我们举一个例子,假如下方的就是一个简单的redux:

const store = {
  state: {
    count: 0,
    text: "milkmidi",
    someData: ["vue", "react"]
  },
  setState: (newState) => {
    store.state = {
      ...store.state,
      ...newState
    };
    store.listeners.forEach((listener) => {
      listener();
    });
  },
  listeners: new Set(),
  subscribe: (callback) => {
    store.listeners.add(callback);
    return () => {
      store.listeners.delete(callback);
    };
  },
  getSnapshot: () => store.state
};
export default store;

要对数据增删改查,可以这样写:

import store from './store';
cont App = ()=> {
  // 取 state 的值
  const state = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot);
 return (
   <button
     // 更新
     onClick={()=> { store.setState({count: 9999})} }
   >increment</button>
 )
}

也就是官方在执行react18的并发模式时,出现数据撕裂就会帮你重新更新reudx里store的数据

好了,我们接着看zustand里useStore的实现:

import { useDebugValue } from 'react'
// import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
// This doesn't work in ESM, because use-sync-external-store only exposes CJS.
// See: https://github.com/pmndrs/valtio/issues/452
// The following is a workaround until ESM is supported.
// 蛋疼就是use-sync-external-store/shim/with-selector不支持动态引入,因为是cjs模块
import useSyncExternalStoreExports from 'use-sync-external-store/shim/with-selector'
import createStore from './vanilla'

export function useStore<TState, StateSlice>(
  api: WithReact<StoreApi<TState>>,
  selector: (state: TState) => StateSlice = api.getState as any,
  equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getServerState || api.getState,
    selector,
    equalityFn
  )
  useDebugValue(slice)
  return slice
}

官方的api会帮你自行判断是否这次的state和上次的不一致,以避免无效渲染,当然你可以自定义compare函数。zustand核心就是这么简单!

本文结束!

如果对复杂案例有兴趣的朋友,可以参考这篇支付宝大哥写的文章 (zhuanlan.zhihu.com/p/592383756)