学习react-tracked源码

231 阅读5分钟

使用

react-tracked优化了react原生Context,可以更加细粒度地更新组件

import { useEffect, useState } from 'react';
import { createContainer } from 'react-tracked';
import { Input, Button } from 'antd';

const useMyState = () => useState({
    count: 0,
    text: 'hello',
})

const { Provider, useTracked } = createContainer(useMyState, { concurrentMode: true })

const App = () => {
    return (
        <Provider>
            <Count />
            <Text />
        </Provider>
    )
}

export default App

const Count = () => {
    const [state, setState] = useTracked()
    const count = state.count
    console.log('count')
    useEffect(() => {
        console.log('count effect')
    })
    return (
        <>
            <Button onClick={() => setState(state=>({ ...state, count: count + 1 }))}>{count}</Button>
            Count {Math.random()}
        </>

    )
}

const Text = () => {
    const [state, setState] = useTracked()
    const text = state.text
    console.log('text')
    useEffect(() => {
        console.log('text effect')
    })
    return (
        <>
            <Input value={text} onChange={e => setState(state=>({ ...state, text: e.target.value }))}></Input>
            <Comp></Comp>
            Text {Math.random()}
        </>
    )
}

const Comp = ()=>{
    console.log('Comp')
    useEffect(()=>{
        console.log('Comp effect')
    })
    return null
}

在这个示例中,点击Count会获得这样的输出
屏幕截图 2024-07-09 232224.png
此外,Count所展示的随机数会变更,而Text展示的不会.这说明,当此组件不使用的属性被修改后,不会带来组件的整体更新,而是仅调用组件函数,不引起子组件更新,不触发effect,不更新dom.

源码分析

use-context-selector和proxy-compare

这两个库是react-tracked的前置.

  • use-context-selector可以创建一个react上下文.通过指定的函数从这个上下文中获取部分属性,并仅在函数调用结果变化时重新渲染组件.这个库的createContext与useContextSelector成对使用.
  • proxy-compare可以根据原始对象创建一个proxy,记录它的访问和变更,保存它的副本,并判断其是否更改

createContainer

createContainer创建上下文和各类钩子.除了示例中使用的Provider、useTracked,还有 useTrackedState,useUpdate,useSelector.
这个函数的签名为

<State, Update extends AnyFunction, Props>(
  useValue: (props: Props) => readonly [State, Update],
  options?: Options<State, Update> | DeprecatedOption,
)=>{
    // Provider等
}

useValue是一个与useState具有类似功能的hook,可以取得状态和更新状态
options可以是对象或者布尔类型.对象options中的属性concurrentMode用于指示是否处于并发模式.布尔类型options含义与之相同.
createContainer创建了两个上下文,分别存储来自useValue的状态值和更新函数.需要注意的是状态context是use-context-selector的context,而更新函数context是react context

  // 状态 createContext来自use-context-selector
  const StateContext = createContext<State | undefined>(options?.defaultState);
  // 更新函数 createContextOrig是react createContext的重命名
  const UpdateContext = createContextOrig<Update | undefined>(
    options?.defaultUpdate,
  );

Provider

在createContainer中创建Provider.Provider用上述的两个context依次包裹组件.这里不使用jsx的写法,会与ts泛型冲突.此外,children实际上不是Provider的子组件,更新Provider的状态并不会导致它也更新(除非订阅了context).

  const Provider = (props: Props & { children: ReactNode }) => {
    const [state, update] = useValue(props);
    // 更新函数context包裹状态context 再包裹children
    return createElement(
      UpdateContext.Provider,
      { value: update },
      createElement(
        StateContext.Provider as ComponentType<{
          value: State;
        }>,
        { value: state },
        props.children,
      ),
    );
  };

useSelector

在createContainer中创建useSelector.状态context在这个钩子中被使用.以状态调用selector,它的返回值就是这个钩子的结果.

  const useSelector = <Selected>(selector: (state: State) => Selected) => {
    const selected = useContextSelector(
      StateContext as Context<State>,
      selector,
    );
    return selected;
  };

当selector的返回值不同时,重新渲染组件.比较不同使用Object.is.如果selector从状态创建一个对象一个对象,应当保存该对象,并在不需要重新渲染的时候将其返回.

useTrackedState

在createContainer中创建useTrackedState

const useTrackedState = createTrackedSelector(useSelector);

调用这个hook可以完整的得到共享的状态.
createTrackedSelector定义如下

export const createTrackedSelector = <State>(
  useSelector: <Selected>(selector: (state: State) => Selected) => Selected,
) => {
  //根据useSelector创建useTrackedState
  const useTrackedSelector = () => {
    //用于更新组件
    const [, forceUpdate] = useReducer((c) => c + 1, 0);
    // 记录组件引用了共享状态的哪些属性
    const affected = useMemo(() => new WeakMap(), []);
    // 共享状态的某个版本 确保组件引用的属性都是最新的
    const prevState = useRef<State>();
    // 最新的共享状态
    const lastState = useRef<State>();
    // 组件引用的属性更新了 则更新组件和prevState
    useEffect(() => {
      if (
        prevState.current !== lastState.current &&
        isChanged(prevState.current, lastState.current, affected, new WeakMap())
      ) {
        prevState.current = lastState.current;
        forceUpdate();
      }
    });
    // 在affected记录的属性不变时 返回prevState保存的副本 确保组件不重新渲染
    const selector = useCallback(
      (nextState: State) => {
        // lastState始终引用最新的共享状态
        lastState.current = nextState;
        // 如果组件引用的属性没发生变化 返回prevState(上次获取的状态)
        if (
          prevState.current &&
          prevState.current !== nextState &&
          !isChanged(prevState.current, nextState, affected, new WeakMap())
        ) {
          return prevState.current;
        }
        // 否则 更新prevState并返回最新状态
        prevState.current = nextState;
        return nextState;
      },
      [affected],
    );
    const state = useSelector(selector);
    const proxyCache = useMemo(() => new WeakMap(), []);
    // createProxy来自proxy-compare 可以为一个对象创建代理 
    // 将对它的属性访问记录在affected内
    // proxyCache保存了过去创建的代理 同一对象只创建统一代理
    return createProxy(state, affected, proxyCache);
  };

  return useTrackedSelector;
};

useTrackedState存储共享状态,返回它的代理,记录组件使用了共享状态的哪些属性.每次调用此钩子都返回上次保存的共享状态的副本的代理.如果组件使用的属性发生变化或者组件使用了新的属性,则改为存储最新的共享状态的副本,更新组件.

useUpdate

在createContainer中创建useUpdate.根据是否使用并发模式,useUpdate的行为有所不同.如果处于并发模式,使用use-context-selector对状态进行更新.否则直接使用useValue提供的更新函数.

  const useUpdate = concurrentMode
    ? () => {
        // 并发模式需要用此函数包裹更新行为
        const contextUpdate = useContextUpdate(
          StateContext as Context<unknown>,
        );
        const update = useContextOrig(UpdateContext as ContextOrig<Update>);
        return useCallback(
          (...args: Parameters<Update>) => {
            let result: ReturnType<Update> | undefined;
            contextUpdate(() => {
              result = update(...args);
            });
            return result as ReturnType<Update>;
          },
          [contextUpdate, update],
        );
      }
    : // not concurrentMode
      () => {
        return useContextOrig(UpdateContext as ContextOrig<Update>);
      };

useContextUpdate所返回的更新函数可以拥有一个额外参数{ suspense: boolean }.如果suspense为true,那么组件的变更将不会平滑变更.即,外层包裹的Suspense会显示fallback,然后再显示新的组件.

useTracked

useTracked就是useTrackedState和useUpdate的组合

const useTracked = () =>
    [useTrackedState(), useUpdate()] as [
      ReturnType<typeof useTrackedState>,
      ReturnType<typeof useUpdate>,
    ];

源码之外的

react-tracked确实仅在引用属性变更的时候重新渲染组件,可是它也有预期之外的行为.

组件函数比预期重复触发一次

使用例中,点击按钮后,text和count都输出了两次.这与use-context-selector的更新策略有关.
调用createContainer时设置concurrentMode为true,这种情况下更新状态会通过use-context-selector进行状态更新.无论更新时是否设置suspense为true,状态contxet都会发起一次更新.此外,setState也会使状态contxet进行一次更新,一共使组件函数调用了两次.

订阅状态的组件,函数都会被调用,但不触发任何额外效果

Text组件没有使用count属性,其函数却被调用了,调用之后却没有任何额外效果,effect、子组件、渲染的dom都没发生变化.
这是因为use-context-selector进行的一次更新是通过useReducer,而通过 这里可以看到useReducer的特性:只要dispatch被调用,都会调用组件函数,如果前后状态一致,则不制造任何额外效果.