是时候放弃redux了,zustand是完美替代者(主要是源码分析)
前言
最近准备写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),详情请见下面的文章:
但是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)
转载自:https://juejin.cn/post/7178318352174022717