》〉》源码请移步最后附件部分
国际惯例先看成品
条件说明
- num默认值为1
- 点击减号会将num-1,在两秒后结束自己的动作。
- 点击加号会等待减号动作结束,然后给num+1
动作过程
Redux工作原理
状态机的构成
一个基础的状态机只需要满足以下三点
- 状态仓库
实质就是一个存储数据的对象,但是该对象对外不可见,或者说是不可编辑。 - 可以触发状态改变动作
抛出一个方法,该方法可以可以对状态数据进行加工,并返回一个加工后的新的状态数据。 - 状态改变后触发相关的订阅事件
当状态改变后,需要让订阅状态的相关相关实体得到反馈,以完成最终的状态同步。
那么,基于以上三点要求,我们可以尝试通过很少的代码实现一个基础的状态机。
状态机的实现
1. 实现状态仓库
既然该状态仓库是不对外的,那么最好是应该将其放入某个非全局的作用域中,使其在作用域外无法直接获取。
同时需要可以将数据同步出来,可以通过导出一个函数,每次都返回状态仓库的克隆体,以防外界对数据直接操作。
一个最小状态仓库
const createStore = (preloadState = {}) => {
let currentState = preloadState;
const getState = () => clone(currentState);
return { getState };
};
但是这个时候状态仓库没有导出编辑的方法,那么可以导出一个dispatch方法,该方法可以触发改变状态
增加导出了一个dispatch方法,改方法可以实现每次调用都让状态库的num字段 + 1
可以触发状态改变的状态库
const createStore = (preloadState = {}) => {
// ...
const dispatch = ({ type, payload }) => {
// 每次调用都让状态库的num字段 + 1
currentState = { num: currentState.num + 1 };
};
// ...
return { dispatch, getState };
};
好像还不够,现在状态改变了似乎对使用该数据的实体没有什么作用。实际上当我们在改变数据后,应该通知对应实体,并让他们获取到最新的数据。
此时我们添加一个订阅器, 让每个依赖该状态机的实体都来订阅这个状态机的动作行为。
现在,我们给它加上subscribe方法,以供实体进行订阅。
状态改变后会通知订阅实体
const createStore = (preloadState = {}) => {
// ...
const listeners = [];
const dispatch = ({ type, payload }) => {
// 每次调用都让状态库的num字段 + 1
currentState = { num: currentState.num + 1 };
// 每次数据改变都通知订阅方
listeners.forEach((l) => l(currentState));
};
const subscribe = (listener) => {
// 添加订阅
if (listeners.findIndex((l) => l === listener) < 0) {
listeners.push(listener);
}
// 取消订阅
return () => {
const index = listeners.findIndex((l) => l === listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
};
// 。。。
return { dispatch, subscribe, getState };
};
此时一个基础的状态机就完成了。
当我们使用它的时候,总不能每个使用它的实体都来一遍订阅,然后再强制刷新渲染吧,那样使用起来也太不方便了,同时还会写出大量的重复代码
可不可以将这一部分功能封装一下呢?
当然可以,我们就提供一个工厂方法,只需要将实体传入,就可以导出自动监听,自动触发刷新的实体。
就命名为connect吧
const { dispatch, getState, subscribe } = createStore({
num: 1,
});
const connect = (component) => {
function ConnectWrapComponent(props) {
// 2. 通过设置state使实体进行重新渲染
const [, forceRender] = useState();
const state = getState();
useEffect(() => {
// 1. 订阅状态机,当状态改变时会执行 forceRender
return subscribe(forceRender);
}, [forceRender]);
const reduxProps = {state, dispatch}
// 3. 将状态数据融入组件的props中
return component instanceof Function
? component({ ...props, ...reduxProps})
: React.cloneElement(component, {
...component.props,
...props,
...reduxProps,
});
}
return ConnectWrapComponent;
};
export { connect }
好像忘了点什么…
现在的状态只能对num +1操作😂
那就再加一个处理用户自定义动作的方法吧,就命名为combineReducers
// 增加默认的 reducer传入
const createStore = (reducer, preloadState = {}) => {
// ...
const reducers = {};
// dispatch时根据传入的type来决定要出发哪个动作
const dispatch = ({ type, payload }) => {
if (!reducers[type]) return;
currentState = reducers[type](currentState, payload);
listeners.forEach((l) => l(currentState));
};
// 处理用户传入的动作
const combineReducers = (reducerObject) => {
for(let k in reducerObject) {
reducers[k] = reducerObject[k];
}
}
// ...
return { dispatch, subscribe, getState, combineReducers };
}
BINGO! 现在一个完整的可以存储状态、触发状态改变、状态改变后会触发订阅,同时支持用户操作状态改变 的 状态机就完成了!
\
等等… 好像还有一些问题。
那么是不是每次只要我改变任何一个字段, 无论我的实体是不是依赖它,都需要被刷新呢?
目前来看是的,当状态改变,所有的实体都会收到订阅并重新渲染…
2.只有依赖的状态改变,我才需要刷新
那么是不是可以让用户决定哪些状态变化才需要刷新呢?
当然可以!
当我们进行connect操作时,提供mapStateToProps方法,让用户决定需要依赖哪些字段
使用时传入mapStateToProps
const Page = () => <div>IndexPage</div>;
// 我选择只依赖 num
const mapStateToProps = (state) => {
return {num: state.num};
}
const ConnectPage = connect(mapStateToProps)(Page)
connect方法也需要进行更改,可以对比前后的状态值是否一致,来决定是不是要刷新页面
const connect = (mapStateToProps) => (component) => {
function ConnectWrapComponent(props) {
// 1. state 通过用户传入的 mapStateToProps 得出
const getCurrentState = () => {
const fullState = getState();
return mapStateToProps ? mapStateToProps(fullState) : fullState;
}
const [state, forceRender] = useState(getCurrentState);
const prevState = useRef(state);
useEffect(() => {
// 2. 通过对比状态改变前后的值来决定是否需要刷新
const shouldRender = () => {
const nextData = getCurrentState()
// 只有前后数据不同时才需要刷新数据
if (!isEqual(nextData, prevState.current)) {
forceRender({});
prevState.current = nextData;
}
}
return subscribe(shouldRender);
}, [forceRender, prevState]);
// ...
}
return ConnectWrapComponent
}
从此我不需要的状态我再也不用关心了,再也不用担心重复的无用渲染了。
当数据量很大时会不会因为给字段或者动作命名而烦恼呢?而且会不会让数据看上去很混乱…
那么为了解决这个问题,我们给每个业务都加个作用域吧,就用 namespace吧!
3. 给状态机加个namespace让数据管理井井有条
同时给状态、动作都加上namespace。
当处理reducer写入,及dispatch触发时,需要指定到对应namespace下。
const createStore = (reducer, preloadState = {}) => {
// ...
const dispatch = ({ type, payload }) => {
// type为 'namespace/actionName'格式
const [namespace, actionName] = type.split("/");
const nextScopeState = actionFunction(currentState[namespace], payload);
currentState = {...currentState, [namespace]: nextScopeState};
// ...
};
const combineReducers = (reducerObject) => {
const { namespace, reducer, initialState } = reducerObject;
if (!namespace) {return;}
currentState[namespace] = initialState || {};
reducers[namespace] = reducers[namespace] || {}
for(let k in reducer) {
reducers[namespace][k] = reducer[k];
}
}
// ...
}
4. 支持异步动作
此时只有对数据的同步操作,这对真实的业务来说还是不够的,异步往往是我们绕不过去的话题。
那么就做个优化吧,将实现可以异步操作的动作,期望可以这样使用。
const reducerObject = {
namespace: 'TEST',
reducers: {
'updateNum': (state, payload) => ({...state, num: payload})
},
actions: {
'addNum': async (payload, { dispatch }) => {
const num = await FetchApi();
dispatch({type: 'updateNum', payload: num})
}
}
}
如上,我们期望实现异步,并能更新数据。
在使用dispatch({type: 'updateNum', payload: num})没有使用namespace前缀。
为此,将对combineReducers及dispatch进行改造
- 在注入
reducers时就将namespace注入以实现内部对动作的直接调用 - 在调用dispatch时判断是否有namespace决定是哪个动作
- 将调用
action改为使用await,支持异步动作
const createStore = (reducer, preloadState = {}) => {
// ...
// 提前注入`namespace` (scopeNamespace)
const dispatch = (scopeNamespace) => async ({ type, payload }) => {
// 2. 在调用dispatch时判断是否有namespace决定是哪个动作
let [prevNamespace, prevActionName] = type.split("/");
const namespace = prevActionName ? prevNamespace : scopeNamespace;
const actionName = prevActionName || prevNamespace;
const actionExtends = actions[finalNameSpace][finalActionName];
if (actionExtends) {
const { namespace: selfNamespace, callback } = actionExtends();
// 3. 将调用`action`改为使用await,支持异步动作
await callback(payload, { dispatch: dispatch(selfNamespace), select });
forceRender();
return;
}
// ...
};
const combineReducers = (reducerObject) => {
const { namespace, reducer, initialState } = reducerObject;
if (!namespace) {return;}
// ...
// 1. 在注入`reducers`时就将`namespace`注入以实现内部对动作的直接调用
actions[namespace] = actions[namespace] || {}
for (let k in injectActions) {
actions[namespace][k] = () => ({ namespace, callback: injectActions[k] });
}
}
// ...
return {dispatch: dispatch(), ...};
}
此时动作已经支持了异步,但是即便使用了异步动作,外界现在是无感知的,或者说是不可监控的。
那么我们期望
- 在用户能够获取动作的loading状态
const mapStateToProps = (state, loading) => {
return {
num: state.TEST.num,
// 监控 'TEST/addNum' 的loading状态
loading: loading['TEST/addNum']
}
}
const Page = connect(mapStateToProps)(Component)
- 在动作内可以判断另一个动作是否结束,并等待另一个动作结束
const reducerObject = {
// ...
actions: {
'addNum': async (payload, { dispatch }) => {
const num = await FetchApi();
dispatch({type: 'updateNum', payload: num})
},
'wait': async (payload, {loading, take}) => {
// 此处当addNum在执行时,等待执行结束后再console
if (loading['TEST/addNum']) {
await take('TEST/addNum');
}
console.log('TEST/addNum load end.')
}
}
}
为此,我们需要做的是
- 需要存储动作的loading状态,
- 用户可以通过mapStateToProps函数拿到
- action内可以判断动作loading状态并等待结束
const createStore = (reducer, preloadState = {}) => {
// ...
// 1. 需要存储动作的loading状态
let currentLoading = {};
let loadingQueue = {};
// 给action提供选取数据的能力
const select = (selectFunction) => selectFunction(currentState);
// 动作阻塞
const take = (actionPath) => {
if (!currentLoading[actionPath]) {
return;
}
return new Promise((resolve) => {
if (!loadingQueue[actionPath]) {
loadingQueue[actionPath] = [];
}
loadingQueue[actionPath].push(resolve);
});
};
// 动作完成后触发阻塞完成
const fireTake = (actionPath) => {
if (loadingQueue[actionPath]?.length) {
loadingQueue[actionPath].forEach(r => r());
loadingQueue[actionPath] = [];
}
}
const dispatch = (scopeNamespace) => async ({ type, payload }) => {
// ...
if(actionExtends){
const { namespace: selfNamespace, callback } = actionExtends();
const actionFullPath = `${namespace}/${actionName}`;
// 动作开始前设置为loading为true
currentLoading = { ...currentLoading, [actionFullPath]: true };
forceRender();
await callback(payload, { dispatch: dispatch(selfNamespace), select, take, loading: currentLoading });
// 动作结束后设置为loading为false
currentLoading = { ...currentLoading, [actionFullPath]: false };
forceRender();
return;
}
}
// ...
const getLoading = () => ({...currentLoading})
return {getLoading, ...};
}
const connect = (mapStateToProps) => (component) => {
function ConnectWrapComponent(props) {
// ...
const getCurrentState = () => {
const fullState = getState();
return mapStateToProps
// 2. 用户可以通过mapStateToProps函数拿到, 传入loading
? mapStateToProps(fullState, getLoading())
: fullState;
}
// ...
}
return ConnectWrapComponent;
};
至此, 状态机设计完成。
(完)
以下为附件部分
状态机源码
import React, { useEffect, useState, useRef } from "react";
import { isEqual } from "lodash";
const createStore = (reducer, preloadState = {}) => {
const listeners = [];
const reducers = {};
const actions = {};
let currentState = preloadState;
let currentLoading = {};
let loadingQueue = {};
const select = (selectFunction) => selectFunction(currentState);
const forceRender = () => listeners.forEach((l) => l());
const take = (actionPath) => {
if (!currentLoading[actionPath]) {
return;
}
return new Promise((resolve) => {
if (!loadingQueue[actionPath]) {
loadingQueue[actionPath] = [];
}
loadingQueue[actionPath].push(resolve);
});
};
const fireTake = (actionPath) => {
if (loadingQueue[actionPath]?.length) {
loadingQueue[actionPath].forEach(r => r());
loadingQueue[actionPath] = [];
}
}
const dispatch =
(scopeNamespace) =>
async ({ type, payload }) => {
let [prevNamespace, prevActionName] = type.split("/");
const namespace = prevActionName ? prevNamespace : scopeNamespace;
const actionName = prevActionName || prevNamespace;
const actionExtends = actions[namespace][actionName];
const actionFullPath = `${namespace}/${actionName}`
if (actionExtends) {
const { namespace: selfNamespace, callback } = actionExtends();
currentLoading = { ...currentLoading, [actionFullPath]: true };
forceRender();
await callback(payload, {
dispatch: dispatch(selfNamespace),
select,
take,
loading: currentLoading,
});
currentLoading = { ...currentLoading, [actionFullPath]: false };
fireTake(actionFullPath);
forceRender();
return;
}
const reducerFunction = reducers[namespace][actionName];
if (reducerFunction) {
const nextScopeState = reducerFunction(
currentState[namespace],
payload
);
currentState = { ...currentState, [namespace]: nextScopeState };
forceRender();
}
};
const combineReducers = (reducerObject) => {
const {
namespace,
reducers: injectReducers,
actions: injectActions,
initialState,
} = reducerObject;
if (!namespace) {
return;
}
currentState[namespace] = initialState || {};
if (!reducers[namespace]) {
reducers[namespace] = {};
}
for (let k in injectReducers) {
reducers[namespace][k] = injectReducers[k];
}
if (!actions[namespace]) {
actions[namespace] = {};
}
for (let k in injectActions) {
actions[namespace][k] = () => ({ namespace, callback: injectActions[k] });
}
};
const subscribe = (listener) => {
if (listeners.findIndex((l) => l === listener) < 0) {
listeners.push(listener);
}
return () => {
const index = listeners.findIndex((l) => l === listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
};
const getState = () => currentState;
const getLoading = () => currentLoading;
combineReducers(reducer);
return {
dispatch: dispatch(),
subscribe,
getState,
getLoading,
combineReducers,
};
};
const { dispatch, getState, subscribe, getLoading, combineReducers } =
createStore({}, {});
const connect = (mapStateToProps) => (component) => {
function ConnectWrapComponent(props) {
const getCurrentState = () => {
const fullState = getState();
return mapStateToProps
? mapStateToProps(fullState, getLoading())
: fullState;
}
const [state, forceRender] = useState(getCurrentState());
const prevState = useRef(state);
useEffect(() => {
const shouldRender = () => {
const nextData = getCurrentState();
if (!isEqual(nextData, prevState.current)) {
forceRender(nextData);
prevState.current = nextData;
}
};
return subscribe(shouldRender);
}, [forceRender, prevState]);
const reduxProps = { ...state, dispatch };
return component instanceof Function
? component({ ...props, ...reduxProps })
: React.cloneElement(component, {
...component.props,
...props,
...reduxProps,
});
}
return ConnectWrapComponent;
};
export { connect, combineReducers };
演示源码
import { connect, combineReducers } from "./redux";
const reducers = {
namespace: "MATH",
initialState: {
num: 1,
},
reducers: {
ADD: (state, payload) =>({ ...state, num: state.num + 1 }),
SUB: (state, payload) => ({ ...state, num: state.num - 1 }),
},
actions: {
ADD_ACTION: async (payload, { dispatch, select, take }) => {
await take('MATH/SUB_ACTION');
dispatch({ type: "ADD" });
console.log("take('MATH/SUB_ACTION')");
},
SUB_ACTION: async (payload, { dispatch, select }) => {
return new Promise((resolve) => {
dispatch({ type: "SUB" });
setTimeout(() => {
resolve();
}, 2000);
});
},
},
};
combineReducers(reducers);
const Num = connect((state, loading) => ({
num: state.MATH.num,
addloading: loading["MATH/ADD_ACTION"],
subLoading: loading["MATH/SUB_ACTION"],
}))(({ num, addloading, subLoading }) => {
return (
<>
State.NUM: {num} <br /><br />
addloading: {String(addloading)} <br />
subLoading: {String(subLoading)} <br />
</>
);
});
const Actions = connect((state) => ({}))(({ dispatch }) => {
return (
<>
<button onClick={() => dispatch({ type: "MATH/ADD_ACTION" })}> + </button>
<button onClick={() => dispatch({ type: "MATH/SUB_ACTION" })}> - </button>
</>
);
});
function App() {
return (
<div className="App">
<h2>展示组件</h2>
<Num />
<br />
<h2>动作组件</h2>
<Actions />
</div>
);
}
export default App;