我们今天讲述的内容是来自于 redux 和 react-redux 这两个库。
虽然 Redux 并不是一个必须要配合 React 使用的状态管理库,但是它和 React 的结合是最常见的。
我们今天的计划:先不绑定任何框架,把 Redux 基本的 API 实现;然后再基于在 React 上的使用,继续完善。最终实现的结果就是一个可用的状态管理器。
基础实现
在开始之前,我们得先看一个例子,看它怎么在原生 JS 应用下使用。这有助于我们理解 Redux 最原始的 API。毕竟在 React 中使用也是继续在这个层次上封装。
且看下面这段代码,我是从官网拷贝过来的。网络允许的话,可以在线预览。
代码有点多,好在逻辑比较简单。大家重点关注 createStore 方法创造出来的 store:
- 使用 store.dispatch 去派发一个 action
- 可以使用 store.getState() 拿到最新的值
- 视图层使用 store.subscribe 订阅更新,等计算出新的值会调用订阅了更新的函数
import { createStore } from 'redux'
function counter(state, action) {
if (typeof state === 'undefined') {
return 0
}
switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
const store = createStore(counter)
const valueEl = document.getElementById('value')
function render() {
valueEl.innerHTML = store.getState().toString()
}
render()
store.subscribe(render)
document.getElementById('increment')
.addEventListener('click', function () {
store.dispatch({ type: 'INCREMENT' })
})
document.getElementById('decrement')
.addEventListener('click', function () {
store.dispatch({ type: 'DECREMENT' })
})
document.getElementById('incrementIfOdd')
.addEventListener('click', function () {
if (store.getState() % 2 !== 0) {
store.dispatch({ type: 'INCREMENT' })
}
})
document.getElementById('incrementAsync')
.addEventListener('click', function () {
setTimeout(function () {
store.dispatch({ type: 'INCREMENT' })
}, 1000)
})
明白了基本的使用,现在我们就来实现 createStore 方法:
入参是一个 reducer 对象,首先我们先定义入参类型:
export interface Action<T = any> {
type: T
}
export interface AnyAction extends Action {
[extraProps: string]: any
}
type Reducer<S = any, A extends Action = AnyAction> = (
state: S | undefined,
action: A
) => S
刚才我们也介绍了,createStore 返回的结果是一个 store 对象,来定义一下它的类型:
export interface Store<S = any, A extends Action = AnyAction> {
dispatch: Dispatch<A>;
getState: () => S;
subscribe(listener: () => void): Unsubscribe
// 返回值是取消订阅的函数,等会看怎么用,先写着
}
export interface Unsubscribe {
(): void
}
接下来我们来实现 createStore 内部的逻辑。
我们知道,Redux 采用的是单项数据流,当我们使用 dispatch 函数派发一个 action 的时候,它会把这个 action 入参给 reducer 函数,reducer 函数产生的返回值会作为新的 state。
也就是说,dispatch 函数的原理很简单,就是调用 reducer 产生新的 state ,如下:
function dispatch(action) => {
state = reducer(state, action);
}
那怎么解决数据更新完了通知 UI 的功能呢?我们可以在内部维护一个订阅者数组,初期把需要更新视图的函数加入进来。
当我们新的 state 产生完了,可以通知所有的订阅者,这就达到了更新视图的功能。添加订阅者就是通过 subscribe 函数。当组件注销的时候,我们就不必在通知了,就可以注销掉他们。
下面是整体的代码:
const randomString = () =>
Math.random().toString(36).substring(7).split('').join('.')
const ActionTypes = {
INIT: `@@redux/INIT${/* #__PURE__ */ randomString()}`,
}
export default function createStore<S, A extends Action>(reducer: Reducer<S, A>) {
let state: S;
let listeners: (() => void)[] = []
// 初始化派发一下不存在的 action,作用是为了让 state 不为空
// 这也就是我们必须要在 reducer 写 default 分支的原因。
dispatch({ type: ActionTypes.INIT } as A)
function dispatch(action: A) {
state = reducer(state, action);
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener()
}
return action;
}
function subscribe(listener: () => void): Unsubscribe {
// 添加订阅者
listeners.push(listener);
return () => {
// 返回的是取消订阅的函数,在销毁组件的时候用
const index = listeners.indexOf(listener)
listeners.splice(index, 1)
}
}
return {
getState() { return state; },
dispatch,
subscribe
}
}
真实的 createStore 依次接受三个参数:reducer、preloadedState、enhancer。
第一个我们刚才已经讲述了。第二个其实是为我们的 state 赋一个初始值,第三个,字面意思,就是增强 store,最常用的就是使用中间件。
举一个例子,我们想每次调用 dispatch 方法的时候都打印一下日志,那可以这么去做:
// 省略前面的代码...
// 这是我们的 createStore 方法
const newDispatch = function log(action) => {
console.log('日志')
dispatch(action)
};
return {
getState() { return state; },
dispatch: newDispatch,
subscribe
}
硬编码进肯定不合适,为了能让别人自定义的增强我们的 dispatch,可以把入参一个 enhancer,让它来做一些增加 dispatch 的事情:
const newDispatch = enhancer(dispatch)
这就是中间件的基本思路。实际实现考虑的细节会多一点,但是无关紧要。
提示:事实上,enhancer 并不是接受一个 dispatch ,它接受的是一组中间件,我这么写只是为了简单的表示它的原理。源代码地址在这里:enhancer 调用位置 。 实际使用中,enhancer 接受的是 applyMiddleware 这个函数的返回值,点我查看
上面就已经把 Redux 基本的原理都表示出来了,上面的那个例子也完全可以正常运行:
配合 React 使用
接下来,开始探讨如何能在 React 中更好的使用。如果,我们什么也不改,也可以直接在 React 中使用,但是需采用类似下面这种写法:
const store = createStore(counter)
const rootEl = document.getElementById('root')
const render = () => ReactDOM.render(
<Counter
value={store.getState()}
onIncrement={() => store.dispatch({ type: 'INCREMENT' })}
onDecrement={() => store.dispatch({ type: 'DECREMENT' })}
/>,
rootEl
)
render()
store.subscribe(render)
有什么问题呢?就在于 Counter 组件的入参 value,每次更新后,它拿到的都是一个新值,这就会使它及其它的子组件重新渲染。
除此之外,目前我们只是把 store 的值挂载到了顶级父组件上,子组件想使用的话,还是得通过 props 获取。当组件层级深了之后,这无疑很麻烦。
如何解决跨级传递难的问题呢?解决方案想必大家也都知道了,使用 context。这样就能在任意层级的子组件里取到值了。
Redux 中的使用是这样子的:
<Provider store={store}>
<Todos/>
</Provider>
在 Redux 中,Provider 还可以接受自定义 context。为了方便起见,我们这里实现的 Provider 函数就不接受指定自己的 context 了。我们直接在全局直接声明了一个 ReactReduxContext ,后面取值都会在这个 context 下面操作。
请你放心,原理都是一样的。
先根据用法实现一个最基础的版本:
interface ReactReduxContextValue<SS = any, A extends Action = AnyAction> {
store: Store<SS, A>
}
// 全局的 context
const ReactReduxContext = React.createContext<ReactReduxContextValue>(null as any);
interface ProviderProps {
store: Store;
children: React.ReactNode
}
function Provider({store, children}: ProviderProps) {
// 组装传递给 context 的值
const contextValue = {
store: store
}
const Context = ReactReduxContext;
return (
<Context.Provider value={contextValue}>
{children}
</Context.Provider>
)
}
export default Provider;
好了,数据我们是已经存好了,但是还有两大问题没有解决:
- 子组件怎么取数据
- 子组件怎么派发事件
下面我们就来解决这两个问题。解决了这两个问题,我们再去优化性能。
Redux 中是怎么用的呢?我们不采用 connect 高阶函数的方案了,而是采用自定义 Hooks 的方式,写法是这样子的:
export function Counter() {
// 从 store 中取数据
const count = useSelector(state => state.counter.value)
// 派发事件的函数
const dispatch = useDispatch()
return (
<div>
<div>
<button onClick={() => dispatch({ type: 'increment'})}>
Increment
</button>
<span>{count}</span>
<button onClick={ {type: 'decrement' }}>
Decrement
</button>
</div>
</div>
)
}
这段代码并不能直接运行,但是如果使用过 redux 应该就能大概了解它做了什么。
也就是说,我们:
- 使用 useDispatch 取到了 store 中的 dispatch 函数
- 使用 useSelector 取到了 store 中的 state 的数据
最开始的时候,我们定义了一个全局的 context,现在它就要派上用场了:
function useDispatch() {
const contextValue = useContext(ReactReduxContext)
return contextValue.store.dispatch;
}
function useSelector<TState, Selected extends unknown>(
selector: (state: TState) => Selected
): Selected {
const contextValue = useContext(ReactReduxContext);
return selector((contextValue.store.getState()))
}
但是仅仅这样还是不行,我们在派发事件后,没法收到更新。有一种最简单的办法,更新我们 Provider 最上层的 store,把它完全更新成新的,这样,所有读取此 context 的组件都会更新。
但这也是 React 中 context 的问题。
我们往往这样使用 context:
<MyContext.Provider value={/* some value */}>
function Button1() {
const name = useContext(MyContext);
retur <div>{name}</div>
}
function Button2() {
const name = useContext(MyContext);
retur <div>{name}</div>
}
当这个情况下, context 中 value 发生变化,使用当前上下文的组件都会重新渲染。
放到我们 Redux 中,如果我们贸然更改了 store 的引用,无疑会引起我们所有用到当前 context 的组件树都会重新渲染,这对性能来说是不能接受的,我们不想要这种效果。
那怎么办呢?想一想,我们是不是仅仅把使用了 useSelector 的组件加入订阅者不就好了?只有当 useSelector 上一次取到的值和这一次取到的值有变化我们才更新。
现在,我们需要完善一下 useSelector 这个自定义 hook 函数了。
function usePrevious<T>(value: T | undefined): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
function useSelector<TState, Selected extends unknown>(selector: (state: TState) => Selected): Selected {
const contextValue = useContext(ReactReduxContext);
const subscribe = contextValue.store.subscribe
const state = contextValue.store.getState();
const nextSelected = selector(state);
const prevSelected = usePrevious(nextSelected);
// 我们使用简单的计数器来触发组件的更新
// redux 源码中引用的是来自于
// react 一个内置 hook: `useSyncExternalStore`
// 地址:https://github.com/facebook/react/blob/ceee524a8f45b97c5fa9861aec3f36161495d2e1/packages/react-reconciler/src/ReactFiberHooks.new.js#L2633
// 原理基本一致。
const [_, setCount] = useState(0);
useEffect(() => {
// 增加当前组件到订阅者列表
const unsubscribe = subscribe(() => {
// 前后两次值不一样,走更新
if (prevSelected !== nextSelected) {
forceUpdate();
}
})
function forceUpdate() {
setCount((prev) => prev+1);
}
// 组件注销的时候,取消订阅
return () => {
unsubscribe();
}
}, [subscribe])
return nextSelected;
}
到这里我们基本就实现完了。实验一下下面这个例子,也能确实只更新变化的组件:
import React from 'react';
import Provider, {useDispatch, useSelector} from './Provider';
import createStore from './createStore';
interface State {
count: number
}
const store = createStore<State, any>(counter);
function counter(state: any, action: any) {
if (typeof state === 'undefined') {
return {
count: 0
};
}
switch (action.type) {
case 'INCREMENT':
return {
...state,
count: state.count + 1
};
default:
return state;
}
}
function A() {
return (
<Provider store={store}>
<B/>
<C/>
</Provider>
);
}
function B() {
console.log('b');
const count = useSelector<State, number>((state) => state.count);
const dispatch = useDispatch();
return (
<>
<button onClick={() => dispatch({type: 'INCREMENT'})}>
+
</button>
<div>{count}</div>
</>
);
}
function C() {
console.log('c');
return <div>hello world</div>;
}
别急,到这里还没完,我们还要再完善一下我们的 Provider 组件,有一种情况会导致我们 Provider 组件的 store 值重新计算,那就是它不在根节点:
function App() {
return (
<Provider> ... </Provider>
)
}
如果我在 App 级别做了更新,会触发Provider 的重新渲染,此时 contextValue 重新生成,我们刚才做到就全白做了。我们希望 Provider 不在根目录,也不让 store 重新渲染,怎么做呢?用 useMemo:
function Provider({store, children}: ProviderProps) {
const contextValue = useMemo(() => {
return {
store
};
}, [store]);
const Context = ReactReduxContext;
return (
<Context.Provider value={contextValue}>
{children}
</Context.Provider>
);
}
到这里,我们整个 redux 的核心源码就都涉及到了。不知道是否对你有帮助呢。
写这篇的时候,我的脑子有点懵,思路有点不清晰。有些地方看不懂还请留言,看到之后我会回复。
完整代码
// createStore.ts
export interface Action<T = any> {
type: T
}
export interface AnyAction extends Action {
[extraProps: string]: any
}
export type Reducer<S = any, A extends Action = AnyAction> = (
state: S | undefined,
action: A
) => S
export interface Dispatch<A extends Action = AnyAction> {
<T extends A>(action: T): T
}
export interface Store<S = any, A extends Action = AnyAction> {
dispatch: Dispatch<A>;
getState: () => S;
subscribe(listener: () => void): Unsubscribe
}
export interface Unsubscribe {
(): void
}
const randomString = () =>
Math.random().toString(36).substring(7).split('').join('.')
const ActionTypes = {
INIT: `@@redux/INIT${/* #__PURE__ */ randomString()}`,
}
export default function createStore<S, A extends Action>(reducer: Reducer<S, A>) {
let state: S;
let listeners: (() => void)[] = []
// 初始化派发一下不存在的 action,作用是为了让 state 不为空
// 这也就是我们必须要在 reducer 写 default 分支的原因。
dispatch({ type: ActionTypes.INIT } as A)
function dispatch(action: A) {
state = reducer(state, action);
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener()
}
return action;
}
function subscribe(listener: () => void): Unsubscribe {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener)
listeners.splice(index, 1)
}
}
return {
getState() { return state; },
dispatch,
subscribe
}
}
// provider.tsx
import React, {useContext, useEffect, useMemo, useRef, useState} from 'react';
import {Action, AnyAction, Dispatch, Store} from './createStore';
interface ReactReduxContextValue<SS = any, A extends Action = AnyAction> {
store: Store<SS, A>
}
const ReactReduxContext = React.createContext<ReactReduxContextValue>(null as any);
interface ProviderProps {
store: Store;
children: React.ReactNode
}
export function useDispatch() {
const contextValue = useContext(ReactReduxContext);
return contextValue.store.dispatch;
}
function usePrevious<T>(value: T | undefined): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
export function useSelector<TState, Selected extends unknown>(selector: (state: TState) => Selected): Selected {
const contextValue = useContext(ReactReduxContext);
const subscribe = contextValue.store.subscribe;
const state = contextValue.store.getState();
const nextSelected = selector(state);
const [_, setCount] = useState(0);
const prevSelected = usePrevious(nextSelected);
useEffect(() => {
const unsubscribe = subscribe(() => {
if (prevSelected !== nextSelected) {
forceUpdate();
}
});
function forceUpdate() {
setCount((prev) => prev + 1);
}
return () => {
unsubscribe();
};
}, [subscribe]);
return nextSelected;
}
function Provider({store, children}: ProviderProps) {
const contextValue = useMemo(() => {
return {
store
};
}, [store]);
const Context = ReactReduxContext;
return (
<Context.Provider value={contextValue}>
{children}
</Context.Provider>
);
}
export default Provider;