基于hooks实现类redux状态机

69 阅读9分钟

》〉》源码请移步最后附件部分

国际惯例先看成品

条件说明

  • num默认值为1
  • 点击减号会将num-1,在两秒后结束自己的动作
  • 点击加号会等待减号动作结束,然后给num+1

动作过程

step.png

Redux工作原理

redux.jpg

状态机的构成

一个基础的状态机只需要满足以下三点

  1. 状态仓库
    实质就是一个存储数据的对象,但是该对象对外不可见,或者说是不可编辑。
  2. 可以触发状态改变动作
    抛出一个方法,该方法可以可以对状态数据进行加工,并返回一个加工后的新的状态数据。
  3. 状态改变后触发相关的订阅事件
    当状态改变后,需要让订阅状态的相关相关实体得到反馈,以完成最终的状态同步。

那么,基于以上三点要求,我们可以尝试通过很少的代码实现一个基础的状态机。

状态机的实现

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! 现在一个完整的可以存储状态、触发状态改变、状态改变后会触发订阅,同时支持用户操作状态改变 的 状态机就完成了!


\

等等… 好像还有一些问题。

那么是不是每次只要我改变任何一个字段, 无论我的实体是不是依赖它,都需要被刷新呢?

目前来看是的,当状态改变,所有的实体都会收到订阅并重新渲染…

这不对啊.jpeg

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前缀。

为此,将对combineReducersdispatch进行改造

  1. 在注入reducers时就将namespace注入以实现内部对动作的直接调用
  2. 在调用dispatch时判断是否有namespace决定是哪个动作
  3. 将调用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(), ...};
}

此时动作已经支持了异步,但是即便使用了异步动作,外界现在是无感知的,或者说是不可监控的。
那么我们期望

  1. 在用户能够获取动作的loading状态
const mapStateToProps = (state, loading) => {
  return {
    num: state.TEST.num,
    // 监控 'TEST/addNum' 的loading状态
    loading: loading['TEST/addNum']
  }
}
const Page = connect(mapStateToProps)(Component)
  1. 在动作内可以判断另一个动作是否结束,并等待另一个动作结束
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.')
    }
  }
}

为此,我们需要做的是

  1. 需要存储动作的loading状态,
  2. 用户可以通过mapStateToProps函数拿到
  3. 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;