useSelector是如何触发更新的以及手写一个简单的useSelector

概述

useSelectorreact-redux@7中加入的hook,可以在不使用connect()的情况下将函数组件连接到redux,这样代码写起来会更加清晰,更加方便。

使用起来也很简单,我们写一个简单的加减数组件来看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore, Reducer } from 'redux';
import { Provider } from 'react-redux';
import Sub from './sub';

export interface StoreState {
count: number
}
export interface StoreAction {
type: 'change'
payload: StoreState
}

const reducer: Reducer<StoreState, StoreAction> = (state, action) => ({ ...state, ...action.payload });
const store = createStore(reducer, { count: 0 });

function App() {
return (
<Provider store={store}>
<Sub />
</Provider>
);
}

ReactDOM.render(<App />, document.getElementById('root'));

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Sub.tsx
import React from 'react';
import { StoreState, StoreAction } from './index';
import { useDispatch, useSelector } from 'react-redux';

export default function Sub() {
const count = useSelector<StoreState, number>((state) => state.count);
const dispatch = useDispatch<StoreAction>();
const customEqalityCount = useSelector<StoreState, number>((state) => state.count, (a, b) => a > b);

return (
<div>
<div>{count}</div>
<div onClick={() => dispatch(
{
type: 'change',
payload: { count: count + 1 },
},
)}>
点击增加
</div>
<div onClick={() => dispatch(
{
type: 'change',
payload: { count: count - 1 },
},
)}>
点击减少
</div>
<div>{customEqalityCount}</div>
</div>
);
}

index.tsx中创建了一个store,丢到Provider中,在子组件中使用useSelector获取store中最新的state, useDispatch更新store中的state,这样一个简单的加减数功能就完成了,注意在Sub中第二个useSelector使用了两个参数,向它传递了一个新旧count比较函数,只有该函数返回false的时候才会触发更新。在这里我传了一个(a, b) => a > b,意味着该值只能减少不能增加。

可以在sandbox里玩下试试

原理浅析

其实原理也不复杂,使用了useContext的特性,但看过源码后,发现直接想的一些细节很妙。
我们可以先想一下,实现一个useSelector有哪些问题需要解决:

  • useSelector如何获取store
  • 如何知道store中的state已经变了
  • 如何触发组件re-render
  • 如何记录变化前的state
  • 如何返回用户希望拿到的state

第一个问题最简单,直接使用useContext就可以拿到。怎么知道state已经变了呢,这里我一开始有个误区,以为直接把store或者把store.getState()获取的state放到useEffect的依赖里就可以知道了。可问题是store会变吗,答案是不会,store是一个对象,只要store通过createStore()创建,这个对象的引用就不会变。state确实会变,但这个变化react可以知道吗,state只是一个值,是一个闭包,而不是react通过useState创建的,react是不知道他是否变化的,换句话说state改变时不会通知react。

那么如何解决呢,答案就在谜面上,在store.subscribe()里订阅就可以了,我们可以在回调函数中比较变化前后的state,去触发更新。

OK,如何触发组件re-render呢,这个也比较简单,用 useStateuseReducer 记录一个无意义的状态,在需要重新渲染的时候,改变它就可以了。

如何记录变化前后的state呢,可以用useState useReducer吗,当然不可以,我们记录之前的state是为了与现在的state进行比较,从而决定是否触发组件更新,使用这两个api可能引起额外的非必要更新,那能记录状态且不会触发re-render的api只有useRef了。

如何返回用户想要的state呢,哈哈哈,自定义hook是个函数呀,直接返回就完事了。

手写一个简单的useSelector

原理差不多搞清楚了之后,我们就可以来试着模拟一个useSelector,实践一下。注:以下实现简化了源码中的很多细节

首先,我们需要有一个context

1
2
3
4
5
6
// context.ts
import { createContext } from "react";
import { AnyAction, Store } from "redux";

const StoreContext = createContext<Store<any, AnyAction>>(null as any);
export default StoreContext;

有了context就可以写provider跟useDispatch了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Provider.tsx
import React from 'react';
import { AnyAction, Store, Action } from 'redux';
import StoreContext from './context';

interface ProviderParams<T extends Action = AnyAction, S = any> {
store: Store<S, T>,
children: JSX.Element
}

export default function Provider
<T extends Action = AnyAction>({ store, children }: ProviderParams<T>) {
// @ts-ignore
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
}

// useDispatch.ts
import { useContext } from 'react';
import { Action, Dispatch } from 'redux';
import StoreContext from './context';

export default function useDispatch<T extends Action>(): Dispatch<T> {
const store = useContext(StoreContext);
return store.dispatch;
}

然后就可以将最开始index.tsx的代码改一下,引用我们自己文件。

1
2
3
4
5
6
7
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { createStore, Reducer } from 'redux';
import Provider from './Provider';
import Sub from './sub';
// ...

我们在这里创建store,通过context传下去。
接下来就可写useSelector了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import {
useContext, useEffect, useReducer, useRef,
} from 'react';
import StoreContext from './context';

type EqualityFn<T> = (a: T, b: T) => boolean;

export default function useSelector<T, Selected extends unknown>(
selector: (state: T) => Selected,
equalityFn?: EqualityFn<Selected>,
): Selected {
const store = useContext(StoreContext);
const [, forceRender] = useReducer((s) => s + 1, 0);

const latestStoreState = useRef<T>(store.getState());
const latestSelectedState = useRef<Selected>(selector(latestStoreState.current));

useEffect(() => {
function checkUpdate() {
const newState = store.getState();
if (newState === latestStoreState) return;

const newSelectedState = selector(newState);
if (!equalityFn) equalityFn = (a, b) => a === b;

if (!equalityFn(newSelectedState, latestSelectedState.current)) {
latestSelectedState.current = newSelectedState;
latestStoreState.current = newState;
forceRender();
}
}
const unsubscribe = store.subscribe(checkUpdate);
return () => unsubscribe();
}, [store]);

return latestSelectedState.current;
}

最后改下sub.tsx中的代码,引用我们自己的文件

1
2
3
4
5
import React from 'react';
import { StoreState, StoreAction } from './index';
import useDispatch from './useDispatch';
import useSelector from './useSelector';
//...

可以在sandbox中试一下,效果跟之前是一样的

尾巴

上面的原理是借鉴了react-redux@7中的实现,使用一个forceUpdate去触发re-render,但在@8-beta中,useSelector直接使用了React18提供的useSyncExternalStoreapi去做这件事,关于这个api可以在这里了解一下。

参考

How useSelector can trigger an update only when we want it to

React Redux Doc: Hooks

react-redux

redux


useSelector是如何触发更新的以及手写一个简单的useSelector
https://luoluoqinghuan.cn/2022/02/15/useSelector是如何触发更新的以及手写一个简单的useSelector/
作者
David Mu
发布于
2022年2月15日
许可协议