use-context-selector 源码讲解
本文基于1.4.1 版本
use-context-selector 介绍
use-context-selector是为了解决 react 提供的useContext hook 的这个问题:一旦 context 的某个 value 更新了(如 context.a),所有使用 useContext 的组件都会更新,即便它只订阅了 context.b
use-context-selector 的使用方式和 useContext 一模一样,下面的是官方示例,从示例中可以注意到一个问题: StateProvider 组件生成 value 用的是 setState,那在 Counter1 组件里更新 count1 的时候会触发 StateProvider 里的 state 更新,进而导致 StateProvider 更新,react 里父组件渲染会导致所有的子组件也重新渲染,那这样所有的子组件(Count1 Count2)不就都会更新了吗?
实际测试下来发现并不会,原因的话可以看看这篇post,讲的非常详细,对应的问题也都描述的很清楚。
简单的给个结论:子组件写在哪个组件里,那就是哪个组件的子组件(如果是使用{()=><Child/>}形式在 父组件里写入的,则不是);在下面的例子里,对 StateProvider 来说,Count1 Count2 并不是它的子组件,它们是 App 的子组件,所以不会因为 StateProvider 的 re-render 而导致包含的所有的子组件都 re-render
import { createContext, useContextSelector } from "use-context-selector";
const context = createContext(null);
const Counter1 = () => {
const count1 = useContextSelector(context, (v) => v[0].count1);
const setState = useContextSelector(context, (v) => v[1]);
const increment = () =>
setState((s) => ({
...s,
count1: s.count1 + 1,
}));
return (
<div>
<span>Count1: {count1}</span>
<button type="button" onClick={increment}>
+1
</button>
{Math.random()}
</div>
);
};
const Counter2 = () => {
const count2 = useContextSelector(context, (v) => v[0].count2);
const setState = useContextSelector(context, (v) => v[1]);
const increment = () =>
setState((s) => ({
...s,
count2: s.count2 + 1,
}));
return (
<div>
<span>Count2: {count2}</span>
<button type="button" onClick={increment}>
+1
</button>
{Math.random()}
</div>
);
};
const StateProvider = ({ children }) => (
<context.Provider value={useState({ count1: 0, count2: 0 })}>
{children}
</context.Provider>
);
const App = () => (
<StateProvider>
<Counter1 />
<Counter2 />
</StateProvider>
);
ReactDOM.render(<App />, document.getElementById("app"));
源码讲解
use-context-selector 的实现思想也是订阅模式,为什么这样说呢,且听我娓娓道来。 先说下整体的思路:基于原生的 createContext、useContext 和 Provider 进行封装,把 Provider 的 value 改成 ref 的形式(contextValue),这样可以保证 useContext 获取到的引用不变,然后根据用户自身实现的 selector 获取到对应的值,如果前后对比值有变动就调用 useReducer 的 dispatch 触发组件的渲染。 之所以说是发布订阅模式,是因为在使用 useContextSelector 之后会将对应组件里的 dispatch 放到 contextValue 的 listener 里,当 context 的 value 更新后,会触发 listener 里的 dispatch,从而实现各组件的更新
createProvider
封装后的 contextValue 是下面的形式,update 函数用来进行batch update,listeners 存放各个调用 useContextSelector 后存入的 dispatch,用来在 ContextProvider 的 value 更新的时触发各组件的更新
contextValue.current = {
[CONTEXT_VALUE]: {
/* "v"alue */ v: valueRef,
/* versio"n" */ n: versionRef,
/* "l"isteners */ l: listeners,
/* "u"pdate */ u: update,
},
};
const createProvider = <Value>(ProviderOrig: Provider<ContextValue<Value>>) => {
const ContextProvider = ({
value,
children,
}: {
value: Value;
children: ReactNode;
}) => {
const valueRef = useRef(value);
const versionRef = useRef(0);
// 为了和suspense更好的结合而引入的,下文会讲
const [resolve, setResolve] = useState<((v: Value) => void) | null>(null);
if (resolve) {
resolve(value);
setResolve(null);
}
const contextValue = useRef<ContextValue<Value>>();
// 初始化
if (!contextValue.current) {
const listeners = new Set<Listener<Value>>();
// 这个的作用是强制采用batch update,不需要的时候不用调用这个update
const update = (thunk: () => void, options?: { suspense: boolean }) => {
batchedUpdates(() => {
// do xxx
listeners.forEach((listener) => listener(action));
thunk();
});
};
contextValue.current = {
[CONTEXT_VALUE]: {
/* "v"alue */ v: valueRef,
/* versio"n" */ n: versionRef,
/* "l"isteners */ l: listeners,
/* "u"pdate */ u: update,
},
};
}
// 每次value更新就调用注入的listener, 也就是通过useContextSelector注入的dispatch
useIsomorphicLayoutEffect(() => {
valueRef.current = value;
versionRef.current += 1;
// 通过react的scheduler封装的函数,按照优先级调用listener,可以不关注
runWithNormalPriority(() => {
(contextValue.current as ContextValue<Value>)[CONTEXT_VALUE].l.forEach(
(listener) => {
listener({ n: versionRef.current, v: value });
}
);
});
}, [value]);
return createElement(
ProviderOrig,
{ value: contextValue.current },
children
);
};
return ContextProvider;
};
update 函数
Provier 里有个[resolve, setResolve]还有 update 函数,update 函数可以将多次 setState 合并成一次 render,这样避免了不必要的性能损耗(react18 自动携带了这个特性,17 及之前在 promise 和 setTimeout 之类的函数里不会合并,event handler 里还是自动合并的)。
setResolve这个只在开了{suspense:true}的时候才会使用,这是为了更好的和Suspense组件结合,在 dispatch 也就是调用listener(action)的时候,如果存在action.p,则会throw action.p。Supsense接收到抛出的Promise后会渲染 fallback,当这个Promise被 resolve 后会渲染真正的组件。
const update = (thunk: () => void, options?: { suspense: boolean }) => {
batchedUpdates(() => {
versionRef.current += 1;
const action: Parameters<Listener<Value>>[0] = {
n: versionRef.current,
};
// 开启这个后所有useSelector对应的父suspense都fallback了
if (options?.suspense) {
action.n *= -1; // this is intentional to make it temporary version
action.p = new Promise<Value>((r) => {
setResolve(() => (v: Value) => {
action.v = v;
delete action.p;
r(v);
});
});
}
listeners.forEach((listener) => listener(action));
thunk();
});
};
createContext
这个函数作用就是用 react 的createContext生成 context 之后,把 Provider 改成封装的,然后删除 Consumer 函数,不支持类组件
export function createContext<Value>(defaultValue: Value) {
// 这个初始默认值不会被修改,只有在useContext的上层不存在Provider的时候才会读取这个默认值
const context = createContextOrig<ContextValue<Value>>({
[CONTEXT_VALUE]: {
/* "v"alue */ v: { current: defaultValue },
/* versio"n" */ n: { current: -1 },
/* "l"isteners */ l: new Set(),
/* "u"pdate */ u: (f) => f(),
},
});
(
context as unknown as {
[ORIGINAL_PROVIDER]: Provider<ContextValue<Value>>;
}
)[ORIGINAL_PROVIDER] = context.Provider;
(context as unknown as Context<Value>).Provider = createProvider(
context.Provider
);
// 只支持函数式组件,hooks写法
delete (context as any).Consumer; // no support for Consumer
return context as unknown as Context<Value>;
}
useContextSelector
这部分的主要逻辑就是取出 contextValue 这个 ref,然后用 selector 计算出最新的 selected,和当前存放的 state 进行对比,不相等立刻调用 dispatch 重新渲染
这里有一个问题,如果是 context 的 value 改变,然后调用 dispatch(action)触发的渲染,即便计算出的结果是 selected 没有改变,在 react18 的时候也会重新运行一遍组件里的函数,除了不会真的进行渲染,也就是触发useEffect、useLayoutEffect。
export function useContextSelector<Value, Selected>(
context: Context<Value>,
selector: (value: Value) => Selected
) {
const contextValue = useContextOrig(
context as unknown as ContextOrig<ContextValue<Value>>
)[CONTEXT_VALUE];
if (typeof process === "object" && process.env.NODE_ENV !== "production") {
if (!contextValue) {
throw new Error("useContextSelector requires special context");
}
}
const {
/* "v"alue */ v: { current: value },
/* versio"n" */ n: { current: version },
/* "l"isteners */ l: listeners,
} = contextValue;
const selected = selector(value);
const [state, dispatch] = useReducer(
(
prev: readonly [Value, Selected],
action?: Parameters<Listener<Value>>[0]
) => {
if (!action) {
// case for `dispatch()` below
return [value, selected] as const;
}
// FIXME 不允许有Promise value
// suspense接收到一个throw 的promise就会fallback
if ("p" in action) {
throw action.p;
}
if (action.n === version) {
if (Object.is(prev[1], selected)) {
return prev; // bail out
}
// 这种也是新值了,应该要render,不过selected都不等了,render也正常
return [value, selected] as const;
}
try {
if ("v" in action) {
// context更新后,需要对比最新的value action.v 和contextselector获取到的value是否一致,否则需要在selected改变时更新下
if (Object.is(prev[0], action.v)) {
return prev; // do not update
}
const nextSelected = selector(action.v);
if (Object.is(prev[1], nextSelected)) {
return prev; // do not update
}
return [action.v, nextSelected] as const;
}
} catch (e) {
// ignored (stale props or some other reason)
}
return [...prev] as const; // schedule update
},
[value, selected] as const
);
if (!Object.is(state[1], selected)) {
// schedule re-render
// this is safe because it's self contained
dispatch();
}
// 这里的listeners正常使用不会变,所以也就挂载的时候调用一次,add dispatch
useIsomorphicLayoutEffect(() => {
listeners.add(dispatch);
return () => {
listeners.delete(dispatch);
};
}, [listeners]);
return state[1];
}