之前在项目中一直用到 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}>加 1Button>
<Button onClick={minus}>减 1Button>
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;
children: React.ReactNode;
}) {
return (
value ={{ dispatcher }}>
{Object.keys(props.models).map((namespace) => {
return (
key={namespace}
hook={props.models[namespace]}
namespace={namespace}
onUpdate={(val) => {
dispatcher.data[namespace] = val;
dispatcher.update(namespace);
}}
/>
);
})}
{props.children}
);
}
创建一个 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 函数,所以也不会执行
所以,
实际上就是为了给 dispatcher 设置值,每次 Executor
重新渲染,都会调用 updateRef.current(data)
设置值
那 Executor
是如何重新渲染的呢?其实就是 hook() 执行, data = hook()
, 每当 hook 中的状态改变后(也就是我们写的model) 都会导致
重新渲染
所以我们可以看出 useModel
的一个问题, 定义 model 后,即使没有使用,源码中的 model hook 也会被自动执行,这样一些异步请求的操作,就会在没使用就被触发
然后我们就可以看 useModel
是如何进行使用的了
ini 代码解读复制代码export function useModel(
namespace: N,
selector?: Selector,
): SelectedModel {
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(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}>加 1Button>
<Button onClick={minus}>减 1Button>
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 中,然后调用 dispatcher 的 update
方法,update
会进行调用每一个回调 cb
将最新的值传入回调 handler
中去,然后 通过 state
和 上一次的 stateRef
来进行对比,如果不一样,就调用 useModel
函数中的 setState
设置最新的 state
,触发组件重新渲染。这就是整个源码流程
评论记录:
回复评论: