React Context 完美替代品 Jotai

Posted 王乐平

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了React Context 完美替代品 Jotai相关的知识,希望对你有一定的参考价值。

1. 前言

React 的属性透传场景 虽然有很多方式可以实现,但能做到代码写的少、re-render 轻松处理的方式并不多。

而状态管理工具 Jotai 却可以很好的解决这些问题。

最近的业务和组件场景里 也在用此方式实现。

2. React Context 的不足

常规解决数据透传通常使用 React 的 Context 来实现,但它却有一些不足的地方:

  • React Context.Provider 嵌套地狱问题。
  • Context value 修改时,触发消费 Context 组件 re-render 的问题(组件引用值未变化时)。

2.1 Provider 嵌套地狱

如下代码所示:

<Context1.Provider value=value1>
  <Context2.Provider value=value2>
    <Context3.Provider value=value3>
      <Context4.Provider value=value4>
        <Context5.Provider value=value5>
          <Context6.Provider value=value6>
            children
          </Context6.Provider>
        </Context5.Provider>
      </Context4.Provider>
    </Context3.Provider>
  </Context2.Provider>
</Context1.Provider>

2.2 Context re-render

此问题通常有三种方式来解决:

  1. 将包含多个属性的 Context 剥离成单个 Context 进行使用。但这样会导致 2.1 中嵌套地狱的问题发生。
function Button() 
  let theme = useContext(ThemeContext);  
  return <ExpensiveTree className=theme />;

  1. 将组件剥离成两个,剥离出来的用 React.memo 进行包裹,再消费。
function Button() 
  let appContextValue = useContext(AppContext);
  let theme = appContextValue.theme; 
  return <ThemedButton theme=theme />


const ThemedButton = memo(( theme ) =>  
  return <ExpensiveTree className=theme />;
);
  1. 在组件的 return 中,用 React.useMemo 包裹,将 Context 中消费的值,做为其依赖项。
function Button() 
  let appContextValue = useContext(AppContext);
  let theme = appContextValue.theme;  

  return useMemo(() =>  
    return <ExpensiveTree className=theme />;
  , [theme])

但这些方法,都会在使用时增加一些额外的工作量,变得繁琐和易出错。

3. 状态管理库 Jotai 的优势

刚好 Jotai 的其中一部分优势就能解决 React Context 中的这些问题。

Jotai 可以完美的解决嵌套地狱的问题,及精准 re-render 的问题。

简单写了一个例子,来看一下 Jotai re-render:[codesandbox] jotai 配合 React.memo re-render 测试

例子中 Jotai 的 Atom 及组件消费关系如下:

4. Jotai 核心

Jotai 的学习成本很低,源码也比较易读。核心就三个 API:

  • atom:创建原子相关方法。
  • useAtom:视图关联原子相关方法。
  • Provider:隔离和存储原子实例相关组件。

只需要熟悉这三个 API 的使用就可以轻松上手。

4.1 atom

atom 创建的其实是一份原子描述,并不会进行相关的存储。

可以创建原始原子及派生原子,用法如下:

// primitive atom
function atom<Value>(initialValue: Value): PrimitiveAtom<Value>

// read-only atom
function atom<Value>(read: (get: Getter) => Value | Promise<Value>): Atom<Value>

// writable derived atom
function atom<Value, Update>(
  read: (get: Getter) => Value | Promise<Value>,
  write: (get: Getter, set: Setter, update: Update) => void | Promise<void>
): WritableAtom<Value, Update>

// write-only derived atom
function atom<Value, Update>(
  read: Value,
  write: (get: Getter, set: Setter, update: Update) => void | Promise<void>
): WritableAtom<Value, Update>

  
const primitiveAtom = atom(initialValue)
const derivedAtomWithRead = atom(read)
const derivedAtomWithReadWrite = atom(read, write)
const derivedAtomWithWriteOnly = atom(null, write)

4.2 useAtom

用来将 atom 关联到视图中,形成响应关系。(atom 必须保持引用不变,否则会无限循环)

基本用法:

// primitive or writable derived atom
function useAtom<Value, Update>(
  atom: WritableAtom<Value, Update>,
  scope?: Scope
): [Value, SetAtom<Update>]

// read-only atom
function useAtom<Value>(atom: Atom<Value>, scope?: Scope): [Value, never]

如果“只读”或“只写”,建议直接使用:

  • useAtomValue
  • useSetAtom

4.3 Provider

使用上和 React Provider 很相似,当然底层也是用 React Context.Provider 包装的。

主要用来隔离 atom 实例 及存储 atom 实例用的。

有隔离需求可以包裹在视图中,无隔离需求可以不添加,和 React Context.Provider 一样。

基本用法:

const Provider: React.FC<
  initialValues?: Iterable<readonly [AnyAtom, unknown]>
  scope?: Scope
>

5. Jotai 实现原理

下面看一下内部实现的基本原理。

5.1 Provider

Provider 的创建源码:

const ScopeContainerContext = getScopeContext(scope)

return createElement(
  ScopeContainerContext.Provider,
  
    // 创建 atoms 的 weakMap 等存储,挂载在 Provider 中
    value: scopeContainerRef.current,
  ,
  children
)
const ScopeContextMap = new Map<Scope | undefined, ScopeContext>()

export const getScopeContext = (scope?: Scope) => 
  if (!ScopeContextMap.has(scope)) 
    ScopeContextMap.set(scope, createContext(createScopeContainer()))
  

  return ScopeContextMap.get(scope) as ScopeContext

const scopeContainerRef = useRef<ScopeContainer>()
// 从此处可以看到,Provider 仅作为隔离数据使用,后期不会发生任何 value 层面的变化,
// 所有的触发,都由 内部的 某个 atom 的 listeners 去调用渲染
if (!scopeContainerRef.current) 
  // lazy initialization
  const scopeContainer = createScopeContainer(
    // initialValues 仅作用一次,后期的任何变化,将不会有任何效果
    initialValues,
    unstable_createStore
  )
  if (unstable_enableVersionedWrite) 
    let retrying = 0
    scopeContainer.w = (write) => 
      setVersion((parentVersion) => 
        const nextVersion = retrying ? parentVersion :  p: parentVersion 
        write(nextVersion)
        return nextVersion
      )
    
    scopeContainer.v = version
    scopeContainer.r = (fn) => 
      ++retrying
      fn()
      --retrying
    
  
  scopeContainerRef.current = scopeContainer

可以清楚的看到,主要就做了:

  • Provider 隔离。
  • 在 Provider 的 value 中挂载 atom 实例。
  • 通过 scope 缓存 createContext 实例。

另外,创建完 Provider 后,未来的所有变化都不会导致其内部的 value 发生变化,所以由 Provider 导致的渲染是不存在的。且 initialValues 也只会消费一次,未来变化也不会响应。

通过 scope 的设置,可以实现和原生 React Context.Provider 一样的效果,进行分割及多层嵌套。

5.2 atom

atom 的生成很简单,就是一份描述:

export function atom<Value, Args extends unknown[], Result>(
  read: Value | Read<Value, SetAtom<Args, Result>>,
  write?: Write<Args, Result>
) 
  const key = `atom$++keyCount`
  const config = 
    toString: () => key,
   as WritableAtom<Value, Args, Result> &  init?: Value 
  if (typeof read === 'function') 
    config.read = read as Read<Value, SetAtom<Args, Result>>
   else 
    config.init = read
    config.read = (get) => get(config)
    config.write = ((get: Getter, set: Setter, arg: SetStateAction<Value>) =>
      set(
        config as unknown as PrimitiveAtom<Value>,
        typeof arg === 'function'
          ? (arg as (prev: Value) => Value)(get(config))
          : arg
      )) as unknown as Write<Args, Result>
  
  if (write) 
    config.write = write
  
  return config

生成完成后,会挂载在 Provider 的 store 中。store 由 WeakMap 维护,所以不用担心内存泄漏的问题。

store 中的派生 atom 的触发变化和渲染,主要由 dependencieslisteners 实现。

5.3 useAtom

export function useAtomValue<Value>(atom: Atom<Value>, scope?: Scope) 
  const ScopeContext = getScopeContext(scope)
  const scopeContainer = useContext(ScopeContext)
  // ...

可以看到 useAtom 中的信息也是通过读取 Context 实现,scope 用来标识与 Provider 匹配。

const [[version, valueFromReducer, atomFromReducer], rerenderIfChanged] =
  useReducer<
    Reducer<
      readonly [VersionObject | undefined, Awaited<Value>, Atom<Value>],
      VersionObject | undefined
    >,
    VersionObject | undefined
  >(
    (prev, nextVersion) => 
      const nextValue = getAtomValue(nextVersion)
      if (Object.is(prev[1], nextValue) && prev[2] === atom) 
        return prev // bail out
      
      return [nextVersion, nextValue, atom]
    ,
    versionFromProvider,
    (initialVersion) => 
      const initialValue = getAtomValue(initialVersion)
      return [initialVersion, initialValue, atom]
    
  )

基于useAtom 创建的值,由 useReducer 实现,可以有效优化部分 re-render 的问题。

useEffect(() => 
  const  v: versionFromProvider  = scopeContainer
  if (versionFromProvider) 
    store[COMMIT_ATOM](atom, versionFromProvider)
  
  // Call `rerenderIfChanged` whenever this atom is invalidated. Note
  // that derived atoms may not be recomputed yet.
  const unsubscribe = store[SUBSCRIBE_ATOM](
    atom,
    rerenderIfChanged,
    versionFromProvider
  )
  rerenderIfChanged(versionFromProvider)
  return unsubscribe
, [store, atom, scopeContainer])
const subscribeAtom = (
  atom: AnyAtom,
  callback: (version?: VersionObject) => void,
  version?: VersionObject
) => 
  const mounted = addAtom(version, atom)
  const listeners = mounted.l
  listeners.add(callback)
  return () => 
    listeners.delete(callback)
    // TODO should version be `undefined` for delAtom?
    delAtom(version, atom)
  

useRender 的 dispatch 会添加到 atom 的订阅列表中,当 atom 发生变化时,会执行订阅列表中的 dispatch 实现响应。

6. 开发中的注意

如果 Jotai 使用在应用级的全局状态中,不太需要注意。但在开发的组件中使用 Jotai 还是需要注意的。

6.1 开发组件的注意

最根本的一点就是 atom 的隔离问题,不然组件内的数据用的是同一份,会导致干扰。

所以需要额外包裹 Provider 进行隔离:

const ComponentA = () => 
  return <div>A</div>;
;

export default () => 
  return (
    <Provider>
      <ComponentA />
    </Provider>
  );
;

如果原组件有 memo,需要把它移到 Provider 这一层:

export default memo(() => 
  return (
    <Provider>
      <ComponentA />
    </Provider>
  );
);

如果有 forwardRef 之类的,基本要写两遍:

const ComponentA = forwardRef(( onChange, defaultValue , ref) => 
  useImperativeHandle(ref, () => ( changeStyle ));
  return <div>A</div>;
);

export default memo(
  forwardRef((props, ref) => 
    useImperativeHandle(ref, () => (
      changeStyle: _ref.current.changeStyle,
    ));

    const _ref = useRef();

    return (
      <Provider>
        <ComponentA ref=_ref />
      </Provider>
    );
  )
);

🌟 Github 原文地址

以上是关于React Context 完美替代品 Jotai的主要内容,如果未能解决你的问题,请参考以下文章

轻量且高性能的 React 状态管理库 Jotai

新的 React Context API + LocalStorage + Subscribe(替代 Redux)

运行 Jest 时替换 `require.context`

状态管理 Jotai 首个中文文档上线了

有啥完美的方法替代java中的 if-else,switch-case

TypeScript 的 `readonly` 可以完全替代 Immutable.js 吗?