实现一个 React 状态管理

130 阅读4分钟

开始

手动实现一个状态管理,有哪些关注点

  1. 状态存储独立于组件之外(全局变量,闭包)
  2. 状态变更后,相关方能够感知到(发布订阅模式)
  3. 状态变更后,能够触发 UI 更新(setState)

完整代码

v1 版本

先使用发布订阅模式实现一个简易版,代码如下,Show 只负责展示 count,所以需要订阅 count 的变更,Operate 负责发布 count 的变更

// App.jsx
import { useEffect, useState, useRef } from "react";
import store from "./v1";

const App = () => {
  return (
    <div>
      <Show />
      <hr />
      <Operate />
    </div>
  );
};

const Show = () => {
  const [v, setV] = useState(false);
  const { count } = store.get();
  const { subscribe } = store;
  const update = () => {
    setV((v) => !v);
  };

  useEffect(() => {
    subscribe(update);

    return () => {
      unsubscribe(update);
    };
  }, []);

  return (
    <>
      <div>Show</div>
      <div>{count}</div>
    </>
  );
};

const Operate = () => {
  const count = useRef();
  const fetchCount = () => {
    count.current = store.get().count;
  };
  fetchCount();

  const { changeData } = store;
  const handleClick = () => {
    const res = count.current + 1;
    changeData({
      count: res,
    });
    fetchCount();
  };

  return (
    <>
      <div>Operate</div>
      <button onClick={handleClick}>handleClick</button>
    </>
  );
};

export default App;

下面是 store 的实现,核心就是发布订阅模式

const createStore = (initState) => {
  let data = initState;
  const deps = [];

  const get = () => {
    return data;
  };

  const subscribe = (cb) => {
    deps.push(cb);
  };

  const unsubscribe = (dep) => {
    const index = deps.findIndex((v) => v === dep);
    deps.splice(index, 1);
  };

  const _notify = (val) => {
    deps.forEach((cb) => cb(val));
  };

  const changeData = (val) => {
    data = val;
    _notify(val);
  };

  return { get, subscribe, changeData, unsubscribe };
};

const initState = {
  count: 1,
};

export default createStore(initState);

虽然可以满足需求,但是有几个问题

1、心智负担较重,接入方需要手动订阅变更

2、接入方来变更状态,可能会导致异常,比如 { count: 0 } 更新为 { count: { count: 0 }} ,导致结果不可预测,不可控

v2 版本

借鉴 reducer 的设计理念,准备一些纯函数,使用 state 和 action 来生成新的状态,外界只能通过提交不同的 action 来实现状态变更,将变更状态的操作下沉到 store 内部,如下图所示

暂时无法在飞书文档外展示此内容

有两个比较大的变更点

  1. 需要准备 reducer 用来变更状态,而且接入方也要修改调用方式

  2. 状态变更后,需要更新界面,这里会实现一个简单的 connect 组件

书写 redcuer

引入 reducer 之后,规范了变更状态的行为,并且还可以实现 payload 的校验,对两个属性分别实现一个 reducer,保证状态隔离,并且还需要一个 combineReducer 来合并 reducer

const countReducer = (state, action) => {
  switch (action.type) {
    case "ADD_COUNT": {
      return {
        ...state,
        count: state.count + 1,
      };
    }
    case "CHANGE_COUNT": {
      if (typeof action.payload !== "number") {
        throw new Error("count must be a number");
      }
      return {
        ...state,
        count: action.payload,
      };
    }
    default: {
      return state;
    }
  }
};

const ageReducer = (state, action) => {
  switch (action.type) {
    case "ADD_AGE": {
      return {
        ...state,
        age: state.age + 1,
      };
    }
    case "CHANGE_AGE": {
      if (typeof action.payload !== "number") {
        throw new Error("age must be a number");
      }
      return {
        ...state,
        age: action.payload,
      };
    }
    default: {
      return state;
    }
  }
};

const combineReducer = (reducers) => (state, action) => {
  let finalState = {};
  Object.keys(reducers).forEach((key) => {
    const reducer = reducers[key];
    const { [key]: res } = reducer(state, action);
    finalState[key] = res;
  });
  return finalState;
};

const createStore = (reducer, initState) => {
  let data = initState;
  const deps = [];

  const get = () => {
    return data;
  };

  const subscribe = (dep) => {
    deps.push(dep);
  };
  
  const _notify = () => {
    deps.forEach((dep) => dep(data));
  };

  const _set = (newState) => {
    data = newState;
  };

  const dispatch = (action) => {
    const state = get();
    const finalState = reducer(state, action);
    _set(finalState);
    _notify();
  };

  return { get, dispatch, subscribe };
};

const initState = {
  count: 1,
  age: 18,
};

const reducer = combineReducer({
  count: countReducer,
  age: ageReducer,
});

export default createStore(reducer, initState);

使用时也很清爽,不需要任何 useAPI,修改时只需要 dispatch 即可

// App.js
import store from './v1'
const App = () => {
  return (
    <div>
      <Show />
      <hr />
      <Operate />
    </div>
  );
};

const Show = () => {
  const { count, age } = store.get()
  return (
    <>
      <div>Show</div>
      <div>{count}</div>
      <div>{age}</div>
    </>
  );
};

const Operate = () => {
  const addCount = () => {
      dispatch({
        type: "ADD_COUNT",
      });
  }
  const assignCount = () => {
      dispatch({
        type: "CHANGE_COUNT",
        payload: 10,
      });
  }
  const addAge = () => {
      dispatch({
        type: "ADD_AGE",
      });
  }
  const assignAge = () => {
      dispatch({
        type: "CHANGE_AGE",
        payload: 30,
      });
  }
  return (
    <>
      <div>Operate</div>
      <button onClick={addCount}>count++</button>
      <button onClick={assignCount}>count = 10</button>
      <button onClick={addAge}>age++</button>
      <button onClick={assignAge}>age = 30</button>
    </>
  );
};

export default App;

运行后发现,虽然 count 的值被改变了,但是页面并没有更新,因为并没有通知 react,更新界面,必须调用 setState,能不能把这个事情变成接入层无感知的呢

实现 connect

可以尝试一个思路,在最外层建立一个组件,在其内部完成 setState,由于 react 默认会更新其后代,可以利用这个特性来实现接入层无感知的更新,先来看看 connect 的用法,从参数可知,这个组件可以让接入层通过 props 来接收 state 和 dispatch

connect(mapStateToProps, mapDispatchToProps)(App)

主要功能有两个

  1. 实现 state,disptach 的映射
  2. 自动更新

使用 useContext 来实现 store 的透传,并且订阅状态的变更,实现界面更新,将 state 和 dispatch 作为 props 进行分发,完整代码如下

// connect.jsx
import { useContext } from "react";
import { useState, useEffect } from "react";
import ReduxContext from "./v1/context";

const connect = (mapStateToProps, mapDispatchToProps) => (Component) => {
  return (props) => {
    mapStateToProps = mapStateToProps || (() => ({}));
    mapDispatchToProps = mapDispatchToProps || (() => ({}));

    const [v, setV] = useState(false);

    const store = useContext(ReduxContext)
    useEffect(() => {
      store.subscribe(() => {
        setV((v) => !v);
      });
    }, []);

    return (
      <Component
        {...props}
        {...mapStateToProps(store.get())}
        {...mapDispatchToProps(store.get(), store.dispatch)}
      />
    );
  };
};

export default connect;
// App.jsx
import connect from "./connect.jsx";

const App = () => {
  return (
    <div>
      <Show />
      <hr />
      <Operate />
    </div>
  );
};

const _Show = ({ count, age }) => {
  return (
    <>
      <div>Show</div>
      <div>{count}</div>
      <div>{age}</div>
    </>
  );
};

const mapStateToProps = (state) => ({
  count: state.count,
  age: state.age,
});
const Show = connect(mapStateToProps)(_Show);

const mapDispatchToProps = (state, dispatch) => {
  return {
    addCount: () => {
      dispatch({
        type: "ADD_COUNT",
      });
    },
    assignCount: (count) => {
      dispatch({
        type: "CHANGE_COUNT",
        payload: count,
      });
    },
    addAge: () => {
      dispatch({
        type: "ADD_AGE",
      });
    },
    assignAge: (age) => {
      dispatch({
        type: "CHANGE_AGE",
        payload: age,
      });
    },
  };
};

const _Operate = ({ addCount, assignCount, addAge, assignAge }) => {
  return (
    <>
      <div>Operate</div>
      <button onClick={addCount}>count++</button>
      <button onClick={() => assignCount(10)}>count = 10</button>
      <button onClick={addAge}>age++</button>
      <button onClick={() => assignAge(30)}>age = 30</button>
    </>
  );
};
const Operate = connect(() => ({}), mapDispatchToProps)(_Operate);

export default App;
// context.js
import { createContext } from "react";

const ReduxContext = createContext({});

export default ReduxContext;
// main.jsx
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import ReduxContext from "./v1/context.js";
import store from "./v1/index.js";

createRoot(document.getElementById("root")).render(
  <ReduxContext.Provider value={store}>
    <App />
  </ReduxContext.Provider>
);