技术咨询、项目合作、广告投放、简历咨询、技术文档下载 点击这里 联系博主

# 基于 React.Context 的轻量化状态管理 umi useModel 分析

# 前言

在阅读 umi 文档的时候发现一个 useModel (opens new window) 获取数据流的方式;其基于 约束 + namespace 的方式 令人耳目一新。为了一探究竟,看看 useModel 的魔法,遂翻看了其源码;发现 其 核心 基于 React.Context + useContext + 自定义hooks 的方式 着实轻量 且 实用

在日常的开发中做状态管理,较多使用 reducer / React 自带的 useReducer / mobx; 其 reducer/useReducer 需要新同学有一定的前置知识才可以(比如 dispatch/action/store 等); mobx 对于 从 vue 转过来的同学较为友好。

useModel 是一个更加轻量的,不需要较多的学习成本即可学会状态管理(会 hooks 就行),能够快速的让新同学上手,且没有额外的依赖库引入

聊聊源码前 先看看 useModel 的使用方式吧

定义

// src/models/userModel.ts
import { useState } from "react";
import { getUser } from "@/services/user";

export default () => {
  const [user, setUser] = useState({});
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    getUser().then((res) => {
      setUser(res);
      setLoading(false);
    });
  }, []);

  return {
    user,
    loading,
  };
};

使用

// src/components/Username/index.tsx
import { useModel } from 'umi';

export default () => {
  const { user, loading } = useModel('userModel');

  return (
    {loading ? <></>: <div>{user.username}</div>}
  );
}

# 源码分析

那么 useModel 是如何实现 基于 namespace 的 跨组件 通信方式呢?

有兴趣的同学可以点击这里查看详细的源码 (opens new window)

其核心思路 大致如下:

  1. 基于 React.Context 创建一个全局的 Context;
const Context = React.createContext<{ dispatcher: Dispatcher }>(null);

class Dispatcher {
  // 由于同一个 namespace 可以 在多处调用,所以意味着 针对单个namespace 存在多个依赖,在数据更新时需要更新多个依赖
  callbacks: Record<Namespaces, Set<Function>> = {};
  //   某个namespace 具体的model 值
  data: Record<Namespaces, unknown> = {};
  // 某一个namespace 的数据更新,需要同步所有的依赖地方
  update = (namespace: Namespaces) => {
    if (this.callbacks[namespace]) {
      this.callbacks[namespace].forEach((cb) => {
        try {
          const data = this.data[namespace];
          cb(data);
        } catch (e) {
          cb(undefined);
        }
      });
    }
  };
}
  1. 使用 Context.Provider 包裹跟组件,并执行所有的useModel 对应的 hooks
// 初始化一个具体的 Dispatcher 实例
const dispatcher = new Dispatcher();

export function Provider(props: {
  models: Record<string, any>;
  children: React.ReactNode;
}) {
  return (
    // 这样在 react 的所有子组件中 可以使用 useContext 获取到全局 dispatcher
    <Context.Provider value={{ dispatcher }}>
      {Object.keys(props.models).map((namespace) => {
        return (
          <Executor
            key={namespace}
            hook={props.models[namespace]}
            namespace={namespace}
            onUpdate={(val) => {
              dispatcher.data[namespace] = val;
              dispatcher.update(namespace);
            }}
          />
        );
      })}
      {props.children}
    </Context.Provider>
  );
}

interface ExecutorProps {
  hook: () => any;
  onUpdate: (val: any) => void;
  namespace: string;
}

function Executor(props: ExecutorProps) {
  const { hook, onUpdate, namespace } = props;

  const updateRef = useRef(onUpdate);
  const initialLoad = useRef(false);

  let data: any;
  try {
    // 执行 对应namespace 的 hooks 函数
    data = hook();
  } catch (e) {
    console.error(
      `plugin-model: Invoking '${namespace || "unknown"}' model failed:`,
      e
    );
  }

  // 首次执行时立刻返回初始值
  useMemo(() => {
    updateRef.current(data);
  }, []);

  // React 16.13 后 update 函数用 useEffect 包裹
  useEffect(() => {
    // 保证初始化 之后 一直更新数据,那么可以通过 onUpdate 获取到 对应 model 的 最新数据
    if (initialLoad.current) {
      updateRef.current(data);
    } else {
      initialLoad.current = true;
    }
  });

  return null;
}
  1. useModel 函数中通过 useContext 获取到 dispatcher, 并更新数据

tips: useModel 本身其实就是一个 Hooks 函数

  • step1: 使用第一步创建的 Context 获取到 dispatcher;
  • step2: 通过 dispatcher 初始化当前的 namespace 的 值,并使用 state 记录;
  • step3: 由于将当前组件使用到了 某个 namespace 的值,所以需要 将当前组件 依赖记录到 对应 namespace 下面的 依赖列表中(callbacks, 其实我觉得叫 deps 似乎更合适)
  • step4: 通过触发 dispatcherupdate 方法,给对应 namespace 下面的 所有依赖更新数据
  • step5: 通过 useRef 记录之前的值和当前的值,如果有变化则 setState; 最后 返回 state
export function useModel<N extends Namespaces, S>(
  namespace: N,
  selector?: Selector<N, S>
): SelectedModel<N, typeof selector> {
  // 使用第一步创建的Context 获取到 dispatcher
  const { dispatcher } = useContext<{ dispatcher: Dispatcher }>(Context);
  const selectorRef = useRef(selector);
  selectorRef.current = selector;
  //  通过dispatcher 初始化当前的 namespace 的 值
  const [state, setState] = useState(() =>
    selectorRef.current
      ? selectorRef.current(dispatcher.data[namespace])
      : dispatcher.data[namespace]
  );
  const stateRef = useRef<any>(state);
  stateRef.current = state;

  // 判断当前组件是否挂载,保证只使用一次
  const isMount = useRef(false);
  useEffect(() => {
    isMount.current = true;
    return () => {
      isMount.current = false;
    };
  }, []);

  useEffect(() => {
    const handler = (data: any) => {
      // 保证值调用一次
      if (!isMount.current) {
        // 如果 handler 执行过程中,组件被卸载了,则强制更新全局 data
        // TODO: 需要加个 example 测试
        setTimeout(() => {
          dispatcher.data[namespace] = data;
          dispatcher.update(namespace);
        });
      } else {
        const currentState = selectorRef.current
          ? selectorRef.current(data) // 用户可能对useModel 设置了第二个参数,需要挑选部分值
          : data;
        const previousState = stateRef.current;
        if (!isEqual(currentState, previousState)) {
          // 避免 currentState 拿到的数据是老的,从而导致 isEqual 比对逻辑有问题
          stateRef.current = currentState;
          // 设置最新的 namespace 的值给state
          setState(currentState);
        }
      }
    };

    dispatcher.callbacks[namespace] ||= new Set() as any; // rawModels 是 umi 动态生成的文件,导致前面 callback[namespace] 的类型无法推导出来,所以用 as any 来忽略掉

    // 由于将当前组件使用到了 某个 namespace 的值,所以需要 将当前组件 依赖记录到 对应 namespace 下面的 依赖列表中(callbacks, 其实我觉得叫 deps 似乎更合适)
    dispatcher.callbacks[namespace].add(handler);
    dispatcher.update(namespace);

    return () => {
      dispatcher.callbacks[namespace].delete(handler);
    };
  }, [namespace]);

  return state;
}
【未经作者允许禁止转载】 Last Updated: 2/4/2024, 6:06:40 AM