使用
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会获得这样的输出
此外,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被调用,都会调用组件函数,如果前后状态一致,则不制造任何额外效果.