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,触发组件重新渲染。这就是整个源码流程

相关推荐
布兰妮甜3 分钟前
使用Svelte构建轻量级应用详解
前端·javascript·框架·svelte
快乐点吧40 分钟前
【前端面试】前端工程化
前端·面试·职场和发展
街尾杂货店&43 分钟前
webpack说明
前端·webpack·node.js
我是唐赢1 小时前
微信小程序混入Behavior,实现Vue mixins同样功能
javascript·vue.js·微信小程序
知忆_IS1 小时前
【GIS教程】使用GDAL-Python将tif转为COG并在ArcGIS Js前端加载-附完整代码
前端·javascript·arcgis
Domain-zhuo1 小时前
如何理解React State不可变性的原则
前端·javascript·react native·react.js·前端框架·ecmascript
开心工作室_kaic1 小时前
springboot422甘肃旅游服务平台代码-(论文+源码)_kaic
前端·spring boot·旅游
Summer_Uncle1 小时前
【TS语法学习】ts中的断言运算符
开发语言·前端·typescript
键盘不能没有CV键1 小时前
【AI】⭐️搭建一个简单的个人问答网页
前端·spring boot