likes
comments
collection
share

jotai + jotai-immer + jotai-signal 初探

作者站长头像
站长
· 阅读数 17

前言

目前,前端流行的状态管理有很多,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原理

jotai + jotai-immer + jotai-signal 初探

  • 解决办法
    • 我们将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>
  </>
);

jotai + jotai-immer + jotai-signal 初探 输入变化,Lengh 跟着变化,但是Uppercase没变化,因为 Provider包裹了Uppercase组件,它的状态是独立的,不受外部更新的影响,或者给Input与CharCount用Provider包裹,效果也是一样的

例2

在上面代码的基础上修改

export default () => (
  <>
    <Input />
    <CharCount />
    <Uppercase />
    <hr />
    <Provider>
      <Input />
      <CharCount />
      <Uppercase />
    </Provider>
    <hr />
  </>
);

jotai + jotai-immer + jotai-signal 初探

两份相同的组件用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 + jotai-immer + jotai-signal 初探

jotai-signal

  • 最小粒度的局部渲染

  • 比如:一个组件中,当状态更新时,我只想让某一行,重新渲染,而不是整个组件都重新渲染,比如组件中有两个div,div1和div2,我只想让div1重新渲染,而div2不重新渲染,这能做到么?答案是肯定的,jotai-signal 做到了,直接上代码,看效果

  • 注意:使用 signal,注意在文件顶部,写上/** @jsxImportSource jotai-signal */,否则不生效

  • 两种写法: jotai + jotai-immer + jotai-signal 初探 jotai + jotai-immer + jotai-signal 初探

  • 看效果

jotai + jotai-immer + jotai-signal 初探

根据效果,我们可以看到,身处在同一个组件内,点击 with atom 中按钮,页面会整体更新,而在with signal中,当点击时,只发生了局部更新,只有用signal包裹的状态才发生更新。 That's amazing。Just Wonderful!

总结

在实际项目开发中,使用jotai + jotai-immer + jotai-signal,将会带来美妙的开发体验和性能飞起。Let‘s do it!