Jotai 理念与实践

Apr 01, 2024 ⋅ 30 minBack

坦白来说,我不是很喜欢我取的这个标题,或许是出自于以前错误学编程时抱着「xxx理念与实践」砖头书的恐惧, 每每看到介绍某种技术又取名为「xxx理念与技术」我都会想起那些砖头书的厚度和枯燥程度... 但这个标题又是我这篇文章想要表达的,所以就先这样吧。

这篇文章主要介绍我对 Jotai 的一些理解和实践。其实很多时候,我写技术类文章的目的并不是给别人看的,主要是为了自己梳理一遍思路, 同时也是为了记录,以便日后查阅。当然,如果这篇文章能对你有所启发和帮助,那就更好不过了。

由于个人能力和经验的局限,以下内容虽然是个人理解,但我不能保证完全正确,还请读者酌情阅读。


我最早用 Jotai 是在 23 年 12 月,当时我在写一个 AI Space 项目。那时候对「状态」的理解都不是很深,只是有个需求需要在组件间共享状态值,且这两个组件是处于「混乱关系」(组件间不是很明确的父子关系,且最近公共祖先节点极远),与此同时听说过 Jotai 这个库,所以就尝试用了下。

AI Space 中使用的 Jotai 相关代码:AI-Space Atoms

如果你点进去,会发现这个文件夹内定义了大量的全局变量 Atom (原子)。可以想象,当你的项目规模开始变大,你每次都需要在这个文件内定义一个全局变量,那么长此以往,你的代码会变得非常难以维护,因为这些全局变量的使用是分散的,同时你还需要考虑命名冲突的问题,更重要的是没有用到 Jotai 核心概念:Derived Atom(派生原子)。这些都是我当时写项目错误使用 Jotai 的点。

这两天又重新看了 Jotai 的文档,同时又看到知乎上的一篇文章 React生态:深入探讨Jotai和Bunshi的思想、实现与实践 写的极其出彩,所以又重新做了一些 Jotai 的相关实践。 这篇文章不会阐述「状态」这个概念在 React 项目中是如何重要的,如果你想在这方面了解更多,可以阅读上述的知乎文章,那里面有对状态汇总做了简单描述。我这篇文章只会聚焦于 Jotai,并结合官方文档、上述的知乎文章和我自己的实践,来谈谈我对 Jotai 的理解和实践。

这篇文章的代码可以在 lesenelir/jotai-related 中找到。


状态管理

简单写过 React 项目的人都知道,在 React 项目中,状态管理这个概念是非常重要的。可以说,很多项目的难点就在于对复杂状态的维护上。 而项目中的状态又不是仅仅由组件内部 state 来管理的。对于同一个状态值,我们可能需要把它在不同的组件中共享。如果当前需要共享状态的组件们是父子关系或可以通过props传递,即使传递的深度很深也没有太大问题,只要我们能明确每次交互的数据流转,那么我们写出来的 UI 都是可预测的。 但是如果这两个组件是处于上文描述的「混乱关系」(最近公共祖先节点极远)时,那么就需要用到一些状态管理库了。

这里有两个概念:「状态管理」和「全局状态管理」。

「状态管理」顾名思义就是管理状态,整合状态,使得状态更好维护。useReducer 就是一个状态管理 hooks。

「全局状态管理」则是在状态管理的基础上,把状态值放到全局,使得任何组件都可以访问这个状态值。这样极大方便的让处于「混乱关系」组件间共享状态值,做到状态值的一致性。 但全局状态管理也有它的缺点,比如状态值的改变会导致所有依赖这个状态值的组件都重新渲染,这样会导致性能问题。 useContext 就是一种全局状态管理的实现方式。 现在几乎所有的全局状态管理库目的都是为了解决减少组件 Re-Render 的问题。


Jotai 核心

Jotai 是一种状态管理的工具。但我们一般会把 Jotai 当作一种全局状态管理工具,因为我们能在任意组件中都可以拿到原子对应的状态。

但这其实是一种错误的说法

实际上,Jotai 原子对应的状态是归属于每一个组件,即每一个组件都维护一份自己的状态。只不过这些状态都有相同的状态值,而这些状态值是全局共享。 全局共享的不仅仅有状态值,还有每个组件对应这个状态值的 setter 方法。当你状态值改变后,就会去触发所有 setter 方法,这样既保证了这些组件一起 Re-Render,又保证了状态值的一致性。

所以,当我们说 Jotai 是一种全局状态管理工具时,其实是不准确的。因为 Jotai 并没有把状态放到全局,而是在每一个组件中都维护了一份状态。只不过 Jotai 是把这些状态对应相同的状态值放到了全局,所以我们可以把它看做一种全局的状态管理工具,但本质状态是由各个组件自己维护的。

上述描述的是 Jotai 的一个偏底层的实现,但在实际应用中,我们更应该关注 Jotai 的另一核心概念: Atom (原子)。

原子概念可以说是 Jotai 中的核心,具体来说我们可以把原子分为两类:

  • Primitive Atom 原生原子
  • Derived Atom 派生原子
    • Read-Only Derived Atom 只读派生原子
    • Write-Only Derived Atom 只写派生原子
    • Read Write Derived Atom 可读可写派生原子

原子与原子之间是可以存在依赖关系的。原生原子是没有依赖的,而派生原子则是依赖于原生原子,也可以依赖于其他派生原子。React生态:深入探讨Jotai和Bunshi的思想、实现与实践 一文中,把 Jotai 的依赖关系当作一个有向无环图。 那么,原生原子就是顶层的节点,派生原子就是指向原生原子的节点。 当原生原子的值发生变化时,会通知所有依赖于它的派生原子,这些派生原子会重新计算自己的值,然后通知依赖于它们的派生原子,这样一层层地传递下去,直到所有派生原子都计算完毕。所以,当派生原子依赖的原生原子越多时,则当前派生原子发生改变的几率也就越大。 如下图所示:

image in light mode

具体来说,当 primitive atom 1 的值发生改变时,会通知 derived atom 1derived atom 2derived atom 1derived atom 2 会重新计算自己的值,由于 derived atom 3 依赖于 derived atom 2,所以当 derived atom 2 的值发生改变时, derived atom 3 的值也会发生改变。同理,primitive atom 2 值发生改变,derived atom 2derived atom 3 也会重新计算自己的值。


read-only derived atom

在这么多原子的概念下,首先介绍只读派生原子。代码如下:

type TAnime = {
  name: string
  episodes: number
  watched: boolean
}

export const animeAtom = atom<TAnime[]>([
  { name: 'Naruto', episodes: 220, watched: true},
  { name: 'One Piece', episodes: 1000, watched: false },
  { name: 'Dragon Ball', episodes: 153, watched: true }
])

export const watchedAnimeAtom = atom<TAnime[]>((get) => {
  const anime = get(animeAtom)
  return anime.filter((a) => a.watched)
})

export default function DocumentPage() {
  const watchedAnime = useAtomValue(watchedAnimeAtom)
  const setAnime = useSetAtom(animeAtom)

  const handleAddClick = () => {
    setAnime((prev: TAnime[]) => [
      ...prev,
      { name: 'Cowboy Bebop', episodes: 26, watched: true }
    ])
  }

  return (
    <div className={'flex gap-2'}>
      <div>
        <h1 className={'font-medium text-lg'}>Watched Anime:</h1>
        <ul>
          {watchedAnime.map((anime, index) => (
            <li key={index}>
              {anime.name}
            </li>
          ))}
        </ul>
      </div>

      <button
        className={'bg-blue-500 text-white px-4 py-2 rounded-lg'}
        onClick={handleAddClick}
      >
        Add Cowboy Bebop
      </button>
    </div>
  )
}

这段代码是 官网文档 Primitive atoms 中某一个例子的扩展。

首先定义了一个 animeAtom 原生原子,其次再定义了一个 watchedAnimeAtom 派生原子,同时它也是一个只读派生原子。可以看出,watchedAnimeAtom 依赖于 animeAtom,它会过滤掉 animeAtomwatchedfalse 的元素,得到一个新的只读原子。 这个例子中,点击 button 触发 animeAtom 的值改变,watchedAnimeAtom 也会重新计算自己的值,触发组件 Re-Render。

对于只读派生原子而言,可以通过 useAtomValue 来获取它的值。需要注意的是:只能通过修改原生原子的值来触发只读派生原子状态值的改变。因为只读派生原子本身是没有 setter 方法,不能利用 useSetAtom(readOnlyDerivedAtom) 来修改它的值。

综上所述,对于只读派生原子而言:

  • 通过 get 方法获取派生原子依赖其他原子的状态值, get 用于订阅某种依赖关系。
  • 该类原子本身值不能修改,只能靠依赖的原生原子值变化来触发重新计算,所以不能通过 useSetAtom 来使用。

write-only derived atom

介绍只写派生原子。代码如下:

export const priceAtom = atom<number>(10)

export const writeOnlyPriceAtom = atom<
  null,
  [{type: string, data: number}],
  void
>(
  null,
  (get, set, update: {type: string, data: number}) => {
    // set(priceAtom, price => price + update.data)
    const primitivePrice = get(priceAtom)
    set(priceAtom, primitivePrice + update.data)
  }
)

export default function Page() {
  const setWriteOnlyPriceAtom = useSetAtom(writeOnlyPriceAtom)

  return (
    <div className={'flex flex-col gap-2'}>
      <button
        className={'bg-blue-500 text-white px-4 py-2 rounded-lg w-fit'}
        onClick={() => setWriteOnlyPriceAtom({ type: 'add', data: 10})}
      >
        Add
      </button>
    </div>
  )
}

atom 函数接受两个参数,这两个参数都是函数。第一个函数是 getter 函数,用于获取依赖的原生原子的状态值,该函数 return 出去的值是当前派生原子的状态值。 第二个函数是一个 setter 函数,get 参数用于获取其他原子的状态值,set 参数用于修改其他原子的状态值,第三个参数是 ...args,在源码中是用 args 数组进行接收。

上述代码,定义了一个 writeOnlyPriceAtom 只写派生原子,它依赖于 priceAtom 原生原子。writeOnlyPriceAtom 本身没有状态值,只有一个 setter 方法,用于修改 priceAtom 的值。

需要注意的是,只写派生原子是没有状态值的,它只有一个更新函数,定义了如何去更新其他原子的状态。这里引出一个问题:既然只写派生原子是不存在状态值的,那为什么还需要只写派生原子呢?

个人的理解,Jotai 是作为状态管理库,而不是全局状态管理库。如果是一个全局状态管理库,那肯定是需要每一个原子都有一个状态对应,但 Jotai 作为一个状态管理库,它的核心就是为了更好的方便管理状态。 例如,一次交互需要有多个状态更新,则可以将这些更新逻辑都封装到一个只写原子中,而不是在组件内部去处理这些逻辑。这样可以更好的把状态管理逻辑和 UI 逻辑分离,使得代码更加清晰。

比如,在上述的 writeOnlyPriceAtom 中只对一个原子的状态进行了更新,看似和直接调用 useSetAtom(priceAtom) 没有太大区别,但这里直接把更新逻辑封装到了一个原子中,而这部分逻辑就可以直接从组件中抽离出来。 尤其当你的某次交互需要更新多个原子状态,即 setter 函数中有多次 set 方法的调用,只写派生原子就显得更加方便了。

综上所述,对于只写派生原子而言:

  • 原子本身不存在状态值,主要用来更新别的原子状态值,目的是做一些状态管理。
  • 该类原子不能通过 useAtomValue 来获取状态值,只能通过 useSetAtom 来获取 setter 方法来调用更新逻辑。

read write derived atom

上述两小节分别介绍了只读派生原子和只写派生原子。这一节介绍可读可写派生原子。介绍可读可写派生原子前,对于一个原生原子,它既是可读原子,又是可写原子。

如何分辨某个原子是属于哪种类型的原子呢?这里有一个简单的规则:

如果一个 atom 函数只有第一个参数,那么它就是一个可读原子。如果一个 atom 函数只有第二个参数,那么它就是一个可写原子。如果一个 atom 函数同时有第一个和第二个参数函数或只有一个原始值参数,那么它就是一个可读可写原子。 对于只读原子,可以通过 useAtomValue 来获取它的状态值;对于只写原子,原子本身不包含状态,可以通过 useSetAtom 来获取它的 setter 方法。 对于可读可写原子,可以通过 useAtom 来获取它的状态值和 setter 方法。

具体的可读可写派生原子代码如下:

export const userScoreAtom = atom<number>(0)

export const readWriteUserLevelAtom = atom<
  string,
  ['+' | '-'],
  void
>(
  (get) => {
    const score = get(userScoreAtom)
    if (score < 60) {
      return 'Beginner'
    } else if (score < 80) {
      return 'Intermediate'
    } else {
      return 'Expert'
    }
  },
  (get, set, update: '+' | '-') => {
    const currentScore = get(userScoreAtom)

    let newScore

    switch (update) {
      case "+":
        newScore = currentScore + 10
        break
      case "-":
        newScore = currentScore - 10
        break
      default:
        newScore = currentScore
    }
    set(userScoreAtom, newScore)
  }
)

export default function Page() {
  const userScore = useAtomValue(userScoreAtom)
  const [userLevel, setUserLevel] = useAtom(readWriteUserLevelAtom)

  return (
    <div className={'p-2 flex flex-col gap-2'}>
      <p>{userScore}</p>
      <p>{userLevel}</p>

      <button
        className={'w-fit bg-blue-500 text-white px-4 py-2 rounded-lg'}
        onClick={() => setUserLevel('+')}
      >
        +
      </button>

      <button
        className={'w-fit bg-blue-500 text-white px-4 py-2 rounded-lg'}
        onClick={() => setUserLevel('-')}
      >
        -
      </button>
    </div>
  )
}

可以看到 readWriteUserLevelAtom 中,定义了一个可读可写派生原子。它依赖于 userScoreAtom 原生原子。 readWriteUserLevelAtom 有一个 getter 函数,该原子会依赖于 userScoreAtom 的状态值,根据 userScoreAtom 的值来计算当前用户等级,并将这个值作为该可读可写派生原子的状态值。 同时它有一个 setter 函数,用于执行该原子的更新逻辑。在这个例子中,点击 + 按钮会使 userScoreAtom 的值加 10,点击 - 按钮会使 userScoreAtom 的值减 10。

你可能会问,为什么在 readWriteUserLevelAtom 中,需要 setter 函数呢?为什么不能直接在组件中调用 useSetAtom(userScoreAtom) 来更新 userScoreAtom 的值呢?

其实,这个问题和上一节介绍的只写派生原子是一样的。只写派生原子是为了把更新逻辑封装到一个原子中,使得代码更加清晰。而可读可写派生原子也是一样的,它是为了把计算逻辑和更新逻辑封装到一个原子中,使得代码更加清晰。 在这里就不需要在组件内再写 useSetAtom(userScoreAtom) 中的更新逻辑,做到了组件内部尽可能关注 UI 逻辑,而状态管理逻辑则封装到了原子中。


atom 实践

这一节会结合上述撰写若干的派生原子概念,实现一个简单的登录相关的状态管理。

在写这个例子前,需要知道 atom 函数中的两个参数函数可以是异步的。这意味着你可以在 getter 函数中执行异步操作,比如请求后端数据,将返回的数据作为当前原子的状态值。 也可以在 setter 函数中也可以执行异步操作,比如请求后端接口,拿到数据后再更新若干个原子的状态值。

下面是一个简单的登录状态管理的例子:

export type TUserInfo = {
  username: string
  password: string
}

const loginService = async (
  username: string,
  password: string
): Promise<{
  userInfo: TUserInfo,
  authToken: string
}> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        userInfo: {
          username,
          password
        },
        authToken: 'fake-auth-token'
      })
    }, 3000)
  })
}

export const userInfoAtom = atom<TUserInfo | null>(null)

export const authTokenAtom = atom<string | null>(null)

export const isLoginAtom = atom<boolean>(false)

export const writeOnlyLoginAtom = atom<
  null,
  [{username: string, password: string}],
  void
>(
  null,
  async (_, set, update: {username: string, password: string}) => {
    try {
      set(isLoginAtom, true)
      const {
        userInfo,
        authToken
      } = await loginService(update.username, update.password)
      set(isLoginAtom, false)
      set(userInfoAtom, userInfo)
      set(authTokenAtom, authToken)
    } catch (e) {
      console.error('Login failed!', e)
    }
  }
)

export const writeOnlyLogoutAtom = atom<null, [], void>(
  null,
  (_, set) => {
    set(userInfoAtom, null)
    set(authTokenAtom, null)
    // 可以做一些其他登出的操作,比如清理 localStorage ...
  }
)

export default function LoginPage() {
  const userInfo = useAtomValue(userInfoAtom)
  const isLoading = useAtomValue(isLoginAtom)
  const authToken = useAtomValue(authTokenAtom)
  const setWriteOnlyLogin = useSetAtom(writeOnlyLoginAtom)
  const setWriteOnlyLogout = useSetAtom(writeOnlyLogoutAtom)
  const usernameRef = useRef<HTMLInputElement>(null)
  const passwordRef = useRef<HTMLInputElement>(null)

  const handleLoginSubmit = (e: FormEvent) => {
    e.preventDefault()

    if (!usernameRef.current || !passwordRef.current) {
      return
    }

    const username = usernameRef.current.value
    const password = passwordRef.current.value

    setWriteOnlyLogin({ username, password })

    usernameRef.current.value = ''
    passwordRef.current.value = ''
  }

  const handleLogoutClick = () => {
    setWriteOnlyLogout()

    if (!usernameRef.current || !passwordRef.current) {
      return
    }

    usernameRef.current.value = ''
    passwordRef.current.value = ''
  }

  return (
    <div className={'flex flex-col gap-2 p-4'}>
      {
        isLoading ? (
          <div className={'bg-gray-100 p-2 rounded-lg w-fit'}>
            Loading...
          </div>
        ) : (
          <>
            <p>username: {userInfo?.username || 'empty'}</p>
            <p>password: {userInfo?.password || 'empty'}</p>
            <p>authToken: {authToken || 'empty'}</p>
          </>
        )
      }

      <form
        onSubmit={handleLoginSubmit}
        className={'border w-fit p-2 flex flex-col gap-2'}
      >
        <label htmlFor="username">
          <input
            type="text"
            ref={usernameRef}
            id={'username'}
            placeholder={'username'}
            className={'p-2 border'}
          />
        </label>

        <label htmlFor="password">
          <input
            type="password"
            ref={passwordRef}
            id={'password'}
            placeholder={'password'}
            className={'p-2 border'}
          />
        </label>

        <div className={'flex justify-between'}>
          <button
            type={'submit'}
            className={'w-fit bg-blue-500 rounded-lg text-white'}
          >
            Login
          </button>

          <button
            type={'button'}
            className={'w-fit bg-red-500 rounded-lg text-white'}
            onClick={handleLogoutClick}
          >
            Logout
          </button>
        </div>
      </form>
    </div>
  )
}

这个例子中,首先定义了一个 loginService 异步函数,模拟了一个登录请求。然后定义了三个原生原子:userInfoAtomauthTokenAtomisLoginAtom。 接着定义了两个只写派生原子:writeOnlyLoginAtomwriteOnlyLogoutAtom,在这两个只写派生原子中,把登录和登出的逻辑都封装到了原子中。

如果没有这两个只写派生原子,则需要把登录和登出的逻辑分别写在组件事件处理函数中,即在事件处理函数中去做数据请求和更新状态。 现在有了 Jotai,就可以把事件处理函数中的逻辑封装到原子中。这也是 Jotai 的最大优势之一,把状态管理逻辑和 UI 逻辑分离开来,使得组件内部只关注 UI 逻辑,从而让组件内部代码更加的清晰。


provider

上文介绍了 Jotai 是一个状态管理库,而不是一个全局状态管理库。这其中不单单是 Jotai 中的状态是由各个组件内部维护,只是原子是全局,状态值是全局。 还有一个原因是 Jotai 中的 Provider。Provider 是 Jotai 中的一个 React 组件,它的作用是包裹组件树,使得组件树中的所有组件都能访问到 Jotai 的原子。

对于 Provider 组件一个比较重要的功能是进行状态作用域的隔离。 默认情况下,一般都会在根组件中使用 Provider 组件,来共享所有原子的状态值。但也可以在组件树的不同部分使用多个 Provider 组件,每个 Provider 组件各自管理子组件树的状态,这样就实现了状态作用域的隔离。

具体的相关代码如下:

export const countAtom = atom<number>(0)

function Counter() {
  const [count, setCount] = useAtom(countAtom)

  return (
    <>
      <p>{count}</p>
      <button
        className={'bg-blue-500 text-white px-4 py-2 rounded-lg'}
        onClick={() => setCount((c) => c + 1)}
      >
        Add
      </button>
    </>
  )
}

function Component1() {
  return (
    <Provider>
      <Counter />
    </Provider>
  )
}

function Component2() {
  return (
    <Provider>
      <Counter />
    </Provider>
  )
}

export default function Page() {
  return (
    <>
      <Component1/>
      <Component2/>
    </>
  )
}

上述代码做到了 atom 原子的状态作用域隔离。 Provider 会创建一个新的上下文,所以在不同的 Provider 中使用相同的原子,它们的状态值是不同的, 即 countAtom 原子在 Component1Component2 中的状态值是不同的。

不同 Provider 中相同的原子状态值是不同的。


suspense fetch

上文提到,atom 函数有两个函数,这两个函数都可以是异步函数。在 Jotai 中,可以实现 suspense fetch,即在组件中使用 useAtom 获取原子的状态值,同时在原子的 getter 函数中执行异步操作,返回一个 Promise,这样就可以在组件中使用 useAtom 获取到异步操作的结果。

实现具体代码如下:

export type TUser = {
  id: number
  name: string
  phone: string
  username: string
  website: string
  company: {
    bs: string
    catchPhrase: string
    name: string
  }
  address: {
    city: string
    street: string
    suite: string
    zipcode: string
    geo: {
      lat: string
      lng: string
    }
  }
}

export const userIdAtom = atom<number>(1)

export const userAtom = atom<Promise<TUser>>(async (get) => {
  const userId = get(userIdAtom)
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  )
  return response.json()
})

function User() {
  const user = useAtomValue(userAtom)

  return (
    <>
      {user.name}
    </>
  )
}

export default function FetchPage() {
  const setUserId = useSetAtom(userIdAtom)

  return (
    <div className={'flex flex-col gap-2 p-2'}>
      <h1>Fetch Page</h1>

      <Suspense fallback={<div>Loading...</div>}>
        <User/>
      </Suspense>

      <button
        className={'bg-blue-500 text-white p-2 rounded-lg w-fit'}
        onClick={() => setUserId(prev => prev + 1)}
      >
        Add
      </button>
    </div>
  )
}

上述代码,如果未请求到内容,会显示 Suspense 组件的 fallback 内容,直到请求到内容后,再显示 User 组件的内容。这就是 data fetching with suspense 功能。


atom query & atom cache

Jotai 可以和 React Query 结合使用,实现 atom query。但为什么需要把 Jotai 和 React Query 进行结合呢?

解答这个问题前,需要先了解状态的分类。一般状态都可以分为:

  • 客户端状态

  • 服务端状态

  • 状态间依赖

对于一个项目而言,肯定是包含了这三类状态,而 Jotai 是可以做到客户端状态管理和状态依赖的管理, 但对于第二种服务端状态的管理,虽然 Jotai 在 atom 函数中提供了异步的参数函数,来做数据请求,但还是做不到位。 因为 React Query 不仅可以做数据的请求,还可以做数据的缓存,数据的更新,数据的失效等等功能。

例如,当你在一个页面中请求了一个数据,然后跳转到另一个页面,再次回到这个页面时,你希望这个数据还在,而不是重新请求一遍。 例如,当你在一个页面中请求了一个数据,然后在另一个页面中修改了这个数据,你希望这个页面中的数据也能同步更新。 这些都是 React Query 能做到,而 Jotai 做不到。

举例来说:

export const userIdAtom = atom<number>(1)

export const userAtom = atom<Promise<TUser>>(async (get) => {
  const userId = get(userIdAtom)
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  )
  return response.json()
})

export const userCacheAtom = atomWithCache<
  Promise<TUser>
>(async (get) => {
  const userId = get(userIdAtom)
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  )
  return response.json()
})

在上述代码中,如果 userIdAtom 的值从 1 变为 2,那么 userAtom 的值进行一次新的网络请求,获取新的状态值。 但如果现在 userIdAtom 的值从 2 又变为了 1,由于原子只能保存当前最新的状态值,那么 userAtom 的值又会重新请求一次,但这一次请求是之前请求过,这会导致数据的重复请求问题,这不是我们想要的结果。

Jotai 作者 Daishi KatoYou Might Not Need React Query for Jotai 一文中写到:

In the early days, I wanted to have data fetching solution, but didn’t want to complicate Jotai itself. So, jotai/query package was created.It’s an integration with React Query v3. It went well, but I noticed a certain mismatch with users. While my mental model was to create a data fetching API for Jotai, people want full features from React Query. I had been struggling with the mismatch, and ended up with creating a new library jotai-tanstack-query.It integrates TanStack Query v4 and covers full features.

atomWithCache is something I wanted when I created jotai/query.

作者最初是想让 Jotai 在 core 代码外可以做一个 data fetching 的功能,所以实现了 jotai/query 的方案。 但社区用户们希望 Jotai 能够集成 React Query 的全部功能,所以就演变出了 jotai-tanstack-query。 但在第二个引言中,作者提到了 atomWithCache 是他最初创建 jotai/query 时想要的功能。 我猜想,作者在实现完 atom 参数函数是异步可以做数据请求后,又想做一个数据请求方案,这个方案主要是能够缓存异步函数的结果,这样就可以将 Jotai 和数据请求彻底结合起来。 但作者的初衷其实就是为了将 async 函数的结果缓存,这样就可以起到将一些数据请求结果缓存的功效,所以后续就又有了 atomWithCache。 这样当 userIdAtom 的值从 2 变为 1 后,userCacheAtom 不会重新发起一个新的请求,而是直接从缓存中获取值。

这样就存在了两种 Jotai 的数据请求方案:Jotai Query 和 Jotai Cache with Atom。


Jotai 实现理念

这一节会介绍 Jotai 状态值全局共享的实现理念,下一节会在实现理念基础上用代码实现 Jotai 第一个版本的代码。

let callback: (() => void) | null

export default function DarkPage() {
  const [isDark, setIsDark] = useState<boolean>(false)

  return (
    <div className={'flex flex-col gap-2'}>
      <SomeOtherComponent>
        <ChildText isDark={isDark}/>
      </SomeOtherComponent>

      <SomeOtherComponent>
        <ChildDiv isDark={isDark}/>
      </SomeOtherComponent>
    </div>
  )
}

现在有一个需求,需要在 ChildText 组件和 ChildDiv 组件中共享一个状态值 isDark。 基础做法是找到这两个组件的最近公共父组件,在它们最近公共父组件中定义 isDark 状态值,再通过 props 传递到这两个组件中。 但这里会进行性能上的考量,如果 isDark 状态值在 DarkPage 组件中发生改变,那么这个组件内的所有子组件都会进行 Re-Render,具体来说,SomeOtherComponent 组件也会 Re-Render。 尤其是当最近公共组件特别顶层时,这种 Re-Render 会导致大量组件重新渲染,从而影响性能问题。

所以现在希望有一种方案,只让 ChildTextChildDiv 这两个组件能同步状态值,让 SomeOtherComponent 组件不受影响。怎么做呢?

答案是使用 EventEmitter 思想:

function SomeOtherComponent({children}: {children: React.ReactNode}) {
  return (
    <>
      {children}
    </>
  )
}

let callback: (() => void) | null

function ChildText() {
  const [isDark, setIsDark] = useState<boolean>(false)

  const handleChangeClick = () => {
    setIsDark(prev => !prev)
    // 调用注册的回调函数,以触发其他组件的状态变化
    callback && callback()
  }

  return (
    <div className={'border p-2'}>
      <p
        className={clsx(
          'text-black',
          isDark && 'bg-black text-gray-50'
        )}
      >
        child text
      </p>

      <button
        className={'bg-blue-500 text-white rounded-lg p-1 w-fit'}
        onClick={handleChangeClick}
      >
        change
      </button>
    </div>
  )
}

function ChildDiv() {
  const [isDark, setIsDark] = useState<boolean>(false)

  // 组件第一次挂载的时候在全局注册回调
  useEffect(() => {
    callback = () => {
      setIsDark(prev => !prev)
    }

    return () => {
      callback = null
    }
  }, [])

  return (
    <div className={'p-2'}>
      <p
        className={clsx(
          'text-black',
          isDark && 'bg-black text-gray-50'
        )}
      >
        child div
      </p>
    </div>
  )
}

在全局定义一个 callback 变量,当 ChildDiv 组件挂载时,注册一个回调函数到全局变量 callback 中。当 ChildText 组件中的按钮点击时,不仅修改自己组件内的状态值,还调用 callback 变量回调函数,从而触发 ChildDiv 组件的状态值改变。

这样就实现了 ChildTextChildDiv 组件的状态值同步,而 SomeOtherComponent 组件不受影响,减少了不必要的 Re-Render。这也是很多状态管理库的目的:减少不必要的 Re-Render,提高应用性能。


Jotai 简易实现

Jotai 官方文档 Jotai Core internals 章节中介绍了 Jotai 的实现。

export interface IAtom<T> {
  init: T
}

export interface IAtomState<T> {
  value: T
  listeners: Set<() => void>
}

// keep track of the state of the atom
export const atomStateMap = new WeakMap<
  IAtom<unknown>,
  IAtomState<unknown>
>()

// atom is a function that returns an object with an init property
export const atom = <T>(initialValue: T): IAtom<T> => {
  return {
    init: initialValue
  }
}

// give an atom and get the value of atomStateMap
export const getAtomState = <T>(atom: IAtom<T>): IAtomState<T> => {
  let atomState = atomStateMap.get(atom)

  if (!atomState) {
    atomState = { value: atom.init, listeners: new Set<() => void>() }
    atomStateMap.set(atom, atomState)
  }

  return <IAtomState<T>>atomState
}


export const useAtom = <T>(atom: IAtom<T>)
:[T, Dispatch<SetStateAction<T>>]  => {
  const atomState = getAtomState(atom)
  const [value, setValue] = useState<T>(atomState.value)

  useEffect(() => {
    const callback = () => setValue(atomState.value)

    atomState.listeners.add(callback)
    callback()

    return () => {
      atomState.listeners.delete(callback)
    }
  }, [atomState])

  const setAtom = (nextValue: T | ((prevValue: T) => T)) => {
    atomState.value = typeof nextValue === 'function'
      ? (nextValue as (prevValue: T) => T)(atomState.value)
      : nextValue

    // all the subscribed components know atom's state changes
    atomState.listeners.forEach((l: () => void) => l())
  }

  return [value, setAtom]
}

这份代码是 Jotai 的 First Version 版本,主要是以减少不必要 Re-Render 为目的,实现了全局状态值共享。但这里的 atom 并没有实现参数函数,所以也没有实现原子依赖的功能。

但这份代码已经实现了 Jotai 的核心思想:全局状态值共享。以在全局维护一张 WeakMap,其中键是 atom 原子,值是一个对象,对象中有一个 value 属性代表当前原子的状态值, 还有一个 listeners Set 集合,集合中存放各个组件对应该状态的 setter 方法。

需要特别关照的是,这里有一个自定义 hooks: useAtom,它接受一个 atom 原子,返回一个数组,数组的第一个元素是原子的状态值,第二个元素是一个 setter 方法,用于修改原子的状态值。 由于 useAtom hooks 最终会在某一个组件内部调用,这个 hooks 内部定义了一个 state,所以对于 Jotai 来说,useAtom 定义的 state 是属于调用该 hooks 的组件内部的 state,而不是全局的 state。 也就是说,在 Jotai 中,状态是组件内部自己维护的,全局共享的只不过是状态值。这也符合上文一直强调的点。


Wrapping Up

以上就是我对 Jotai 的一些理解和实践,希望对你有所帮助。Jotai 是一个非常优秀的状态管理库,如果你用好了它,可以让你的代码组织更加的清晰,也能写出更加艺术的代码。

当然,写出艺术的代码并非一件简单的事情,就像 React生态:深入探讨Jotai和Bunshi的思想、实现与实践 说的那样,写好 Jotai 需要对数据建模能力有较高的要求,这也是使用者包括我在内,需要思考和提升的地方。 我一直相信,对于代码而言,你写不出来的原因并非你能力不行,而是你写的太少、见识太少的缘故。所以,多写多看应该是一个比较不错的学习方法,尤其是对于大型模块 Jotai 的状态管理,更是如此。

最后,还是感谢 Jotai 的作者 Daishi KatoReact生态:深入探讨Jotai和Bunshi的思想、实现与实践 作者。

[本文谢绝一切转载,谢谢]

Lesenelir