实现一个最简易 React 原子化状态管理 hooks

3,187 阅读6分钟

前言

最近看 jotai 的源码有感,发现其实在 React 中实现状态管理并不是一定要用 createContext + useContext 的形式,主要是这样实现的状态管理,一来必须要在祖先节点嵌入类似 Provider ,还得统一在 Provider 中写入一些更新状态的方法给子组件调用,这样写下来代码就容易变得 耦合

原本 hooks 的目的是为了更好的业务代码解耦,使开发者可以分离各个部分的关注点从而获得更佳的开发体验。又因此变成了一团乱麻,组件之间强绑着业务关系。

所以我个人而言一直不喜欢在业务中使用这种 api,无论是使用 React 还是 Vue,Provider 模式只适合写一些公共多层组件时,像 Form 配合 FormItem、RadioGroup 配合 Radio 这种组件模式就需要使用 Provider。

在这里我分享另外一种你可能从来没见过的 React 状态管理实现方式,在 jotai 中,他们被称之为 atoms(原子状态),并实现一个简易的原子化状态管理器。

在 React 中实现状态管理的关键

大家都知道使用状态管理的目的是为了各个祖孙组件、兄弟组件更方便通信,搬一张经典的图就是这样

image.png

但是这只是浅显的说明了状态管理的机制和便利,并不能表示出一个状态管理器应该怎么去实现,我理解在 React 中实现一个状态管理器应该做到的两点关键点——

  1. 一个位置定义,任意位置的节点使用
  2. 状态更新,会触发更新所有订阅该状态的节点

但就像前言说的,为了达到关注点分离的目的,我更希望不需要使用 Provider 的形式来入侵我们的代码,这样意味着你在复用一些组件的时候,还得考虑在使用这个组件时要在祖先节点嵌入该组件使用的 Provider 以及是否存在多个 Provider 的情况。

想想看,如果用这种方式做原子化状态,那你可能需要超级多的 Provider,管理就更麻烦了。

实现原子化状态管理的关键点分析

对于第一点,与传统 redux mobx 的状态管理器不同,原子化状态管理需要实现在任何位置声明或者导出之后,直接在组件里就能使用。如果需要各种 Root 或者 HOC 来辅助使用,便会加重开发使用负担。

export const bar = createStore('xekin')

function App () {
    const [bar, setBar] = useStore(bar)
    console.log(bar)  // 'xekin'
    // ....
}

关键是第二点,如何能做到触发状态后更新所有订阅该状态的组件。

这就要关系到 React 的更新机制了,其实很简单,调用一下 setState 就可以触发整个组件更新了,React 和 Vue 不同,React 组件更新是全量更新,这导致在 React Element 中每一个 state 都无法与整个组件剥离更新。于是我们只要在每个组件使用 useStore 之类的方法时,在方法里存下该组件的一个 setState 方法即可。

存入之后,还需要提供修改的 setStore 方法,并且在 setStore 时,触发所有之前存入的 setState 方法。

于是我们只需要这样——

function useUpdate () {
    const [,setState] = useState(Date.now())

    const update = useRef(() => setState(Date.now()))

    return update.current
}

function useStore () {
    const store = {}
    const update = useUpdate()

    const setStore = useRef(() => {
        // ...modify store value
        updates.forEach((func) => func())
    })

    useEffect(() => {
        saveUpdate(update)
        return () => {
            deleteUpdate(update)
        }
    }, [])

    return [store, setStore]
}

这就是基本的原子化 store 雏形了,这种方式其实是得益于 React 自身的组件更新机制

具体实现

来看一下具体的实现, 一共也就 45 行代码

import { useEffect, useRef, useState } from 'react'

type Update = () => any
type Store<T = unknown> = { value: T; _storeDeps: Set<Update> }

function createStore<T = unknown>(value: T): Store<T> {
  return {
    value,
    _storeDeps: new Set(),
  }
}

function effectStore(store: Store) {
  store._storeDeps.forEach((effect) => {
    effect()
  })
}

export function useUpdate() {
  const [, refresh] = useState(Date.now())

  const update = useRef(() => {
    refresh(Date.now())
  })

  return update.current
}

export function useStore<T = unknown>(store: Store<T>) {
  const update = useUpdate()

  const setState = useRef((newValue: T) => {
    store.value = newValue
    effectStore(store)
  })

  useEffect(() => {
    store._storeDeps.add(update)
    return () => {
      store._storeDeps.delete(update)
    }
  }, [])

  return [store.value, setState.current]
}

看到这有聪明的小伙伴就要说了,在使用 useContext的时候,一旦某个状态更新,所有订阅该 Context 的组件都会更新,导致产生很多不必要的 rerender,你这样写不也一样嘛,我所有用 useStore 的组件都会可能因为某个与这个组件无关但是因为订阅了该 store 而被更新。

image.png

其实注意理解这个过程,你会发现如果每个 store 都只存一个状态,那这个 store 自然就变成一个原子化的 state 了,我们并不需要所有状态都存进一个 store 里。这样一个 store 就是一个 state,就没有区分不同 state 触发更新的问题了。

有的小伙伴说了,jotai 里除了 useAtom,更喜欢用 useSetAtomuseAtomValue 这两个 hook,其实这个非常容易实现,只要把 useStore 拆开就好了,这和 jotai 的源码里几乎是一样的。

export function useSetStore<T = unknown>(store: Store<T>) {
    const setStore = useRef((newValue: T) => {
        store.value = newValue
        effectStore(store)
    )
    return setStore.current
}

export function useStoreValue<T = unknown>(store: Store<T>) {
  const update = useUpdate()
  
  useEffect(() => {
    store._storeDeps.add(update)
    return () => {
      store._storeDeps.delete(update)
    }
  }, [])
  
  return store.value
}
    
export function useStore<T = unknown>(store: Store<T>) {
  const state = useStoreValue<T>(store)
  const setState = useSetStore<T>(store)

  return [state, setState]
}

可以看看下面使用起来会是什么样。

使用

// store.ts
import { useStore } from 'store';

export const nameState = createStore('tom');

export const arrState = createStore([1,2,3]);

// app.tsx
import { nameState, arrState } from 'store.ts'

function Bar () {
    const [name, setName] = useStore(nameState)
    
    useEffect(() => {
        setName('xekin')
    }, [])

    return (<div>{name}</div>)
}

function Foo() {
    const [arr] = useStore(arrState)
    return (<div>{arr.map(item => ( <span>{item}</span> ))}</div>)
}

function Car() {
    const [name] = useStore(nameState)
    const [state, setState] = useState('')
    useEffect(() => {
        setState(name)
    }, [name])
    
    return (<div>{state}</div>)
}

function App() {
    return (
        <>
            <Bar />
            <Foo />
            <Car />
        </>
    )
}

可以在码上掘金直接查看效果。

拓展性

其实这个状态管理器的雏形有着非常多的拓展方向,比如可以结合 localStorage 或者 url 做成持久化状态管理,代码量也完全不需要改动多少,相比 Provider 模式更加轻便、好理解。

举个很有意思的例子,把异步接口和状态融合在一起,比如这样

const api = (id: string) => fetch('/xxxx?id=' + id)

const apiValue = createStore({})

function compose (api, store) {
    return () => {
        const [state, setState] = useStore(store)
        const getValue = (...args: any[]) => {
            api(...args).then(data => {
                setState(data.json())
            })
        }
        return [state, getValue]
    }
}

const useApiState = compose(api, apiValue)

function App() {
    const [value, get] = useApiState()
    
    useEffect(() => {
        get('xekin')
    }, [])

    return (<div>{value}</div>)
}

这样还可以直接在最上层对调用接口进行处理,例如节流防抖等等,用起来绝对爽爆。

One More Thing

比起一些业务组合,我其实发现一个更有意思的点,那就是这种状态是可以脱离 react 上下文去更新的。

const nameState = createStore('xekin')

window.addEventListener('message', (e) => {
    nameState.value = e.data
    effectStore(nameState)
})

function App() {
    const [name] = useStore(nameState)
    return <div>{name}</div>
}

最后

这里是 Xekin(/zi:kin/),以上这就是本篇文章分享的全部内容了,喜欢的掘友们可以点赞关注点个收藏~

最近摸鱼时间比较多,写了一些奇奇怪怪有用但又不是特别有用的工具,不过还是非常有意思的,之后会一一写文章分享出来,感谢各位支持。

我还是喜欢写没人写过的东西~