useModel 源码如此简单

之前在项目中一直用到 umi 中 useModel 这种状态管理, 一起来探索一下它是如何实现的

使用

使用 umi.js 可以 在 src/models 下面新建一个文件夹,例如 countModel.ts

javascript 复制代码
import { useCallback, useState } from 'react';
​
export default function CounterModel() {
  const [counter, setCounter] = useState(0);
​
  const increment = useCallback(() => setCounter((c) => c + 1), []);
  const decrement = useCallback(() => setCounter((c) => c - 1), []);
​
  return { counter, increment, decrement };
}
​

在组件中使用 即可

javascript 复制代码
​
const Count = () => {
  
  const { add, minus, counter } = useModel('countModel', (model) => ({
    counter: model?.counter,
    add: model?.increment,
    minus: model?.decrement,
  }));
  
  return (
    <div>
       <div>
          <div>{counter}</div>
          <Button onClick={add}>加 1</Button>
          <Button onClick={minus}>减 1</Button>
        </div>
    </div>
  )
}

源码

useModel 本质上其实是使用了 context 来进行存储的,我们来看下代码,在 .umi 下的 plugin-model 文件夹中 包含下面三个文件

diff 复制代码
index.tsx
model.ts
runtime.tsx

我们可以看到,在 model.ts 中,其实他导出了我们业务代码里所有的 model, 并且namespace 是文件名称,model 的值实际上就是model 中我们导出的东西

model.ts

css 复制代码
// model.ts
​
import model_1 from '/home/admin/src/models/counterModel';
import model_2 from '/home/admin/src/models/global';
import model_3 from '/home/admin/src/.umi/plugin-initialState/@@initialState';
import model_4 from '/home/admin/src/.umi/plugin-qiankun-slave/qiankunModel';
​
export const models = {
  model_1: { namespace: 'counterModel', model: model_1 },
  model_2: { namespace: 'global', model: model_2 },
  model_3: { namespace: '@@initialState', model: model_3 },
  model_4: { namespace: '@@qiankunStateFromMaster', model: model_4 },
} as const

导出了这些 models 后,在 runtime.tsx 中进行使用,我们看下这个文件

runtime.tsx

javascript 复制代码
// runtime.tsx
​
import React  from 'react';
import { Provider } from './';
import { models as rawModels } from './model';
​
function ProviderWrapper(props: any) {
  const models = React.useMemo(() => {
    return Object.keys(rawModels).reduce((memo, key) => {
      memo[rawModels[key].namespace] = rawModels[key].model;
      return memo;
    }, {});
  }, []);
  return <Provider models={models} {...props}>{ props.children }</Provider>
}
​
export function dataflowProvider(container, opts) {
  return <ProviderWrapper {...opts}>{ container }</ProviderWrapper>;
}

我们看到,这个文件里面创建了一个 ProviderWrapper, 然后通过 Provider 将 models 传入到 children 中,最后导出了 dataflowProvider 这个参数,在 umi 内部,其实他是会将这个 ProviderWrapper 最终放到我们的组件最外层,也就是根组件进行包裹,这样里面所有的子组件都可以访问到 models, umi.js 在插件运行的时候,会执行 runtime.tsx 文件中导出的 dataflowProvider 方法,他会在给 react-dom 渲染根组件的时候,在外面包裹一层

这种方式会导致一个问题

当我们自己给根组件添加一个 provider 的时候,就会导致 ProviderWrapper 在 provider 里面,useModel 只有在 ProviderWrapper 内才可以使用,所以我们在自己定义的根 provider 里是无法使用 useModel 的

然后我们来看下 Provider, 他是从 index.tsx 中导出

Provider

ini 复制代码
const Context = React.createContext<{ dispatcher: Dispatcher }>(null);
​
export function Provider(props: {
  models: Record<string, any>;
  children: React.ReactNode;
}) {
  return (
    <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>
  );
}

创建一个 context, 然后传入 models, 还有 children, 以及 value={{ dispatcher }}。最后 返回 children ,以及 Executor 组件

我们知道,children 就是所有的子组件. Models 是我们写的所有的 model, 那么 我们将 namespace 和 model 都传给了 Executor 组件,并且还传入了一个 onUpdate 函数,这个函数执行了 dispatcher 的一些方法

那么 这个dispatcher 到底是什么呢?我们来看下他的定义

typescript 复制代码
class Dispatcher {
  callbacks: Record<Namespaces, Set<Function>> = {};
  data: Record<Namespaces, unknown> = {};
  update = (namespace: Namespaces) => {
    if (this.callbacks[namespace]) {
      this.callbacks[namespace].forEach((cb) => {
        try {
          const data = this.data[namespace];
          cb(data);
        } catch (e) {
          cb(undefined);
        }
      });
    }
  };
}
​
const dispatcher = new Dispatcher();

dispatcher 其实就是 Dispatcher 的一个实例,而 Dispatcher 不就类似于发布订阅吗? update 将存入的 callbacks 全部取出来,然后传入 data 数据。这里我们先暂留一个疑问就是, callbacks 的回调是什么时候被放入进去的呢?我们接着往下看

Provider 内部还写了一个 Executor,它是用来干什么的呢?

ini 复制代码
function Executor(props: ExecutorProps) {
  const { hook, onUpdate, namespace } = props;
​
  const updateRef = useRef(onUpdate);
  const initialLoad = useRef(false);
​
  let data: any;
  try {
    data = hook();
  } catch (e) {
    console.error(
      `plugin-model: Invoking '${namespace || 'unknown'}' model failed:`,
      e,
    );
  }
​
  // 首次执行时立刻返回初始值
  useMemo(() => {
    updateRef.current(data);
  }, []);
​
  // React 16.13 后 update 函数用 useEffect 包裹
  useEffect(() => {
    if (initialLoad.current) {
      updateRef.current(data);
    } else {
      initialLoad.current = true;
    }
  });
​
  return null;
}

hook 是什么?不就是我们写的 model 函数吗, 当调用首次执行 data = hook(); 拿到我们写的 model 函数的初始值的时候,然后调用 updateRef.current(data),将 data 传入到 onUpdate 函数中

ini 复制代码
 onUpdate={(val) => {
  dispatcher.data[namespace] = val;
  dispatcher.update(namespace);
}}

这样就将数据传入到了我们的 dispatcher.data 中去了,而 dispatcher.update(namespace) 一开始调用的时候,还没有 callback 函数,所以也不会执行

所以,<Executor /> 实际上就是为了给 dispatcher 设置值,每次 Executor 重新渲染,都会调用 updateRef.current(data) 设置值

Executor 是如何重新渲染的呢?其实就是 hook() 执行, data = hook(), 每当 hook 中的状态改变后(也就是我们写的model) 都会导致 <Executor /> 重新渲染

所以我们可以看出 useModel 的一个问题, 定义 model 后,即使没有使用,源码中的 model hook 也会被自动执行,这样一些异步请求的操作,就会在没使用就被触发

然后我们就可以看 useModel 是如何进行使用的了

ini 复制代码
export function useModel<N extends Namespaces, S>(
  namespace: N,
  selector?: Selector<N, S>,
): SelectedModel<N, typeof selector> {
  const { dispatcher } = useContext<{ dispatcher: Dispatcher }>(Context);
  const selectorRef = useRef(selector);
  selectorRef.current = selector;
  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)
          : data;
        const previousState = stateRef.current;
        if (!isEqual(currentState, previousState)) {
          // 避免 currentState 拿到的数据是老的,从而导致 isEqual 比对逻辑有问题
          stateRef.current = currentState;
          setState(currentState);
        }
      }
    };
​
    dispatcher.callbacks[namespace] ||= new Set() as any; // rawModels 是 umi 动态生成的文件,导致前面 callback[namespace] 的类型无法推导出来,所以用 as any 来忽略掉
    dispatcher.callbacks[namespace].add(handler);
    dispatcher.update(namespace);
​
    return () => {
      dispatcher.callbacks[namespace].delete(handler);
    };
  }, [namespace]);
​
  return state;
}
​

通过 useContext 拿到 dispatcher. 然后通过 dispatcher.data[namespace] 拿到我们自定义store里面的返回值。并且不只在State中进行了存储,还利用 useRef(data) 在 StateRef 中存储了一份

然后我们看到 这里 dispatcher.callbacks[namespace] 是一个set, 然后往里面放了 callback 回调,也就是handler 函数,然后调用了 updae(namespace) 这个函数

scss 复制代码
  useEffect(() => {
    .....,
    dispatcher.callbacks[namespace] ||= new Set() as any; 
    dispatcher.callbacks[namespace].add(handler);
    dispatcher.update(namespace);
​
    return () => {
      dispatcher.callbacks[namespace].delete(handler);
    };
  }, [namespace]);

handler 函数做了什么呢?

ini 复制代码
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)
      : data;
    const previousState = stateRef.current;
    if (!isEqual(currentState, previousState)) {
      // 避免 currentState 拿到的数据是老的,从而导致 isEqual 比对逻辑有问题
      stateRef.current = currentState;
      setState(currentState);
    }
  }
};

handle 会接收一个data 参数,这个参数是最新的值,因为在 const data = this.data[namespace]; cb(data); 的时候,在 Executor 每次渲染的是,将 store 中最新的data 通过 onUpdate 传入, isMount.current 初次进来会变为true,我们可以看 else 分支, 其实这里就是对新老数据通过 isEqual 进行了一个对比,然后调用 setState 更新最新的State, useModel 在组件中使用,则调用 setState 会进行渲染业务组件更新视,也就是说usemodel 中的状态发生改变,会由 Executor 执行 update 更新, 然后重新渲染页面

到这里,其实 useModel 的源码就已经结束了,我们在整体来捋一遍

javascript 复制代码
const Count = () => {
  
  const { add, minus, counter } = useModel('countModel', (model) => ({
    counter: model?.counter,
    add: model?.increment,
    minus: model?.decrement,
  }));
  
  return (
    <div>
       <div>
          <div>{counter}</div>
          <Button onClick={add}>加 1</Button>
          <Button onClick={minus}>减 1</Button>
        </div>
    </div>
  )
}
  • umi.js 会创建一个 provider 放到根组件下,之后传入所有的 models
  • 创建一个 Dispatcher 来存放 callbacks 以及 data, 以及提供一个更新函数 update
  • Update 会通过调用 cb 来进行判断,cb 其实就是 handle 来进行判断状态前后有没有变化,有变化就进行 setState 来刷新视图
  • 创建一个 context, 用来给子组件提供 dispatcher
  • 通过 Executor 来更新 dispatcher 中的数据

总结

当我们调用 add 这个函数,修改了 model 中的状态 counter, 这个时候在 Executor 会执行 data=hook(), 会导致 Executor 组件重新渲染,然后将他调用了 onUpdate 方法,将最新的返回值设置到 dispatcher 中,然后调用 dispatcherupdate 方法,update 会进行调用每一个回调 cb 将最新的值传入回调 handler 中去,然后 通过 state 和 上一次的 stateRef 来进行对比,如果不一样,就调用 useModel 函数中的 setState 设置最新state,触发组件重新渲染。这就是整个源码流程

相关推荐
bin915335 分钟前
DeepSeek 助力 Vue 开发:打造丝滑的复制到剪贴板(Copy to Clipboard)
前端·javascript·vue.js·ecmascript·deepseek
晴空万里藏片云2 小时前
elment Table多级表头固定列后,合计行错位显示问题解决
前端·javascript·vue.js
曦月合一2 小时前
html中iframe标签 隐藏滚动条
前端·html·iframe
奶球不是球2 小时前
el-button按钮的loading状态设置
前端·javascript
kidding7232 小时前
前端VUE3的面试题
前端·typescript·compositionapi·fragment·teleport·suspense
无责任此方_修行中4 小时前
每周见闻分享:杂谈AI取代程序员
javascript·资讯
Σίσυφος19004 小时前
halcon 条形码、二维码识别、opencv识别
前端·数据库
学代码的小前端4 小时前
0基础学前端-----CSS DAY13
前端·css
dorabighead5 小时前
JavaScript 高级程序设计 读书笔记(第三章)
开发语言·javascript·ecmascript
css趣多多5 小时前
案例自定义tabBar
前端