jotai + jotai-immer + jotai-signal 初探
前言
目前,前端流行的状态管理有很多,Redux、Mobx、Mobx-lite、Recoil、jotai、xstate等等,根据多年开发经验,没有所谓的最好,重要的是要适合自己的项目和团队风格,本文主要简述jotai的基本用法和工程实践
前端数据状态库简介
Redux
这个就不用多述了,老Store了
- 中心化、组件外状态库,使用复杂,反正个人十分不喜欢,有点闹挺儿~
- Redux的作者在加入Reack团队后,在发布React17版本时,把Redux这一套直接融进了React中了,增加了 useReducer,Provider + inject模式,会造成 provider 嵌套地狱,因此用的人不多
Mobx
个人非常喜欢的半中心化,组件外状态库
- 由 Class Componets(装饰器写法) 过度到 Function Component(hooks用法),个人经历了很多大型项目落地,已验证其可靠性,使用简单方便,几乎无上手成本,团队接受度高
- 有兴趣可以阅读:
- 它去掉了Redux的miiddleware、dispatch等操作,使用更加简洁
Mobx-lite
API变更太频繁,废了,而且也没感觉比mobx好在哪,只能说是在mobx没出hooks版本之前的过渡替代品吧(仅代表个人见解)
recoil
原子化、组件内状态,但是api较多,它通过将原子状态进行派生、组合成新的状态(类似vue的computed),上手成本有点高
- 所谓组件内状态,其实就是内部使用了useState,state变化,触发更新渲染罢了
- 有兴趣可以阅读手动实现Recoil原理
jotai
原子化、组件内状态库,api不多,可以理解为recoil的简约版
jotai 使用
atom、派生、useAtom
import { FC } from 'react';
import { atom, useAtom } from 'jotai';
const valueAtom = atom(0);
// 派生1
const doubleCount = atom(get => get(valueAtom) * 2);
// 派生2:组合atom
const count1 = atom(1);
const count2 = atom(2);
const sumAtom = atom(get => get(count1) + get(count2));
// 派生3:组合atom
const atoms = [count1, count2];
const sumAtom2 = atom(get => atoms.map(get).reduce((acc, count) => acc + count));
const Text = () => {
const [value] = useAtom(valueAtom);
const [dobueValue] = useAtom(doubleCount);
const [sum] = useAtom(sumAtom);
const [sum2] = useAtom(sumAtom2);
return (
<div>
<div>{value}</div>
<div>{dobueValue}</div>
<hr />
<div>{sum}</div>
<div>{sum2}</div>
<hr />
</div>
);
};
const Button = () => {
const [, setValue] = useAtom(valueAtom);
const add = () => setValue(prev => prev + 1);
const dec = () => setValue(prev => prev - 1);
const reset = () => setValue(0);
return (
<div>
<button onClick={add}>add</button>
<button onClick={dec}>dec</button>
<button onClick={reset}>reset</button>
</div>
);
};
const Demo1: FC = () => {
return (
<>
<Text />
<Button />
</>
);
};
异步派生
- 注意:它会影响页面的渲染,只有当异步派生操作结束后,整个页面才会render,它阻塞了主线程,不建议这么使用
import { FC } from 'react';
import { atom, useAtom } from 'jotai';
const getData = (url: string): Promise<string> =>
new Promise(resolve => setTimeout(() => resolve('async data'), 3000));
// 派生异步原子
const urlAtom = atom('https://json.host.com');
const fetchUrlAtom = atom(async get => {
const response = await getData(get(urlAtom));
return await response;
});
const Text = () => {
const [result] = useAtom(fetchUrlAtom);
return (
<div>
<p>{result}</p>
</div>
);
};
atom 内更新方式
import { FC } from 'react';
import { atom, useAtom } from 'jotai';
const countAtom = atom(5);
const decrementCountAtom = atom(
get => get(countAtom),
(get, set, _arg) => {
set(countAtom, get(countAtom) - 1);
}
);
const multiplyCountAtom = atom(null, (get, set, by: number) => set(countAtom, get(countAtom) * by));
const Test1 = () => {
const [count, decrement] = useAtom(decrementCountAtom);
const [, multiply] = useAtom(multiplyCountAtom);
return (
<>
<hr />
<div>
<span>{count}</span>
<button onClick={decrement}>Decrease</button>
<button onClick={() => multiply(3)}>triple</button>
</div>
</>
);
};
async action
- 定一个dataAtom,用于接收异步任务返回的值
- 通过调用派生实现异步请求
import { FC } from 'react';
import { atom, useAtom } from 'jotai';
const getMockData = (): Promise<string> =>
new Promise(resolve => setTimeout(() => resolve('random-' + Math.random()), 2000));
const dataAtom = atom('');
const fetchDataAtom = atom(
get => get(dataAtom),
async (_get, set, url) => {
const data = await getMockData();
set(dataAtom, data);
}
);
const AsyncTest = () => {
const [asyncData, getAsyncData] = useAtom(fetchDataAtom);
return (
<>
<hr />
<span>Hi: {asyncData}</span>
<button onClick={() => getAsyncData('http://test.com')}>getAsyncData</button>
</>
);
};
useAtomValue、useSetAtom
- 如果有的组件只需要监听状态的变化值,而没有更新操作,请使用
useAtomValue
- 如果仅更新操作,而无需渲染状态的组件,请使用
useSetAtom
- 这么做的目的:防止无意义的渲染 下面我们看一个例子
import { FC } from 'react';
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
const countAtom = atom(0);
const A = () => {
const [, setCount] = useAtom(countAtom);
console.log('组件A渲染');
return (
<div>
<button onClick={() => setCount(prev => prev + 1)}>add</button>
</div>
);
};
const B = () => {
const [count] = useAtom(countAtom);
console.log('组件B渲染');
return (
<div>
<p>组件B:{count}</p>
</div>
);
};
const Demo1: FC = () => {
return (
<>
<A />
<B />
</>
);
};
当我们点击add时,发现A和B都重新渲染了,但是问题来了,A为什么要重新渲染呀,A只是触发了更新操作,并不需要重新渲染呀???
- 这是因为,如果使用useAtom,即使你不引入它的value值,但它由useAtomValue包裹,当更新时,useAtomValue会触发订阅事件,从而触发渲染,如果不理解可以阅读 Recoil原理
- 解决办法
- 我们将A组件中的useAtom 替换成 useSetAtom,B组件的useAtom替换成useAtomValue
- 同时,代码语意更好
// A组件
// const [, setCount] = useAtom(countAtom);
const setCount = useSetAtom(countAtom);
// B组件
// const [count] = useAtom(countAtom);
const count = useAtomValue(countAtom);
修改后,发现点击add时,A组件不会重新渲染了
It's wonderful!
Provider
- 正常情况下,无需用Provider包裹组件
- 但如果需要控制某些组件的状态不发生更新,可以用Provider包裹
- 或者说,用Provider包裹的组件,状态是独立的,不受外部影响,同时也不影响外部,即使大家共用同一个atom状态
例1
import { Provider, atom, useAtom, useAtomValue } from 'jotai';
const textAtom = atom('hello');
const textLenAtom = atom(get => get(textAtom).length);
const uppercaseAtom = atom(get => get(textAtom).toUpperCase());
const Input = () => {
const [text, setText] = useAtom(textAtom);
return <input type="text" value={text} onChange={e => setText(e.target.value)} />;
};
const CharCount = () => {
const len = useAtomValue(textLenAtom);
return <div>Length: {len}</div>;
};
const Uppercase = () => {
const upper = useAtomValue(uppercaseAtom);
return <div>Uppercase: {upper}</div>;
};
export default () => (
<>
<Input />
<CharCount />
<Provider>
<Uppercase />
</Provider>
</>
);
输入变化,Lengh 跟着变化,但是Uppercase没变化,因为 Provider包裹了Uppercase组件,它的状态是独立的,不受外部更新的影响,或者给Input与CharCount用Provider包裹,效果也是一样的
例2
在上面代码的基础上修改
export default () => (
<>
<Input />
<CharCount />
<Uppercase />
<hr />
<Provider>
<Input />
<CharCount />
<Uppercase />
</Provider>
<hr />
</>
);
两份相同的组件用Provider包裹了,那么他们互不影响,各玩各的
- 应用
- 比如对比看板,一个看板作为初始观察,另一个看板做编辑更新,感觉跟抄作业似的
store
在实际开发中,我们会将那些atom放到Store文件夹内,export出去就可以了,这也没什么可说的了... No No No, 它有大用
- createStore 创建一个空store
- set、get
// store.ts
import { createStore, atom } from 'jotai';
const myStore = createStore();
export const countAtom = atom(0);
export const statusAtom = atom(false);
// 监听发生变化
myStore.sub(countAtom, () => {
console.log('countAtom value is changed to', myStore.get(countAtom));
myStore.set(statusAtom, myStore.get(countAtom) % 2 !== 0);
});
export default myStore;
// App.ts
import { getDefaultStore, useAtom, useAtomValue } from 'jotai';
import { countAtom, statusAtom } from './store';
export default () => {
const [count, setCount] = useAtom(countAtom);
const status = useAtomValue(statusAtom);
return (
<>
<p>Default Count: {getDefaultStore().get(countAtom)}</p>
<p style={{ background: status ? 'pink' : 'transparent' }}>Count: {count}</p>
<button onClick={() => setCount(x => x + 1)}>add</button>
</>
);
};
点击add,当count为奇数时,背景变色
这不多此一举么?我们直接用派生的方式不就行了么??? 这么做的好处是,store通过监听某一个状态发生变化时,可以批量更新多个状态,而派生的话,就不方便了,举个例子
- 当 A 变化时,B、C跟着一起变化,那么B和C都要写派生,但如果是10个,50个呢,写起来不麻烦,主要是,数据流就不太清晰了,维护起来有些分散,维护成本高
jotai-immer
如果不了解immer,可以阅读React渲染优化之useImmer
jotai-signal
-
最小粒度的局部渲染
-
比如:一个组件中,当状态更新时,我只想让某一行,重新渲染,而不是整个组件都重新渲染,比如组件中有两个div,div1和div2,我只想让div1重新渲染,而div2不重新渲染,这能做到么?答案是肯定的,jotai-signal 做到了,直接上代码,看效果
-
注意:使用 signal,注意在文件顶部,写上
/** @jsxImportSource jotai-signal */
,否则不生效 -
两种写法:
-
看效果
根据效果,我们可以看到,身处在同一个组件内,点击 with atom 中按钮,页面会整体更新,而在with signal中,当点击时,只发生了局部更新,只有用signal包裹的状态才发生更新。 That's amazing。Just Wonderful!
总结
在实际项目开发中,使用jotai + jotai-immer + jotai-signal,将会带来美妙的开发体验和性能飞起。Let‘s do it!
转载自:https://juejin.cn/post/7230826178914746429