umi @umi/max 数据流管理实战:model 插件、useModel 与全局初始状态
【免费下载链接】umiA framework in react community ✨项目地址: https://gitcode.com/GitHub_Trending/um/umi
@umi/max内置了基于 hooks 范式的轻量级数据流管理方案,可以在 Umi 项目中以约定式目录的方式定义全局共享 Model,并通过useModel()在任意组件中消费;同时它还内置了全局初始状态(@@initialState)和 Qiankun 微前端父子应用通信能力。读完本文,你将掌握 Model 的创建规范与命名空间规则、model配置项(extraModels、sort)的用法、useModel的 selector 性能优化技巧、getInitialState的全局初始状态机制,以及这套方案在仓库源码中的文件生成与运行时订阅实现原理。
配置
数据流管理插件通过model配置项开启,一个典型的配置示例如下(e.g.):
// config/config.ts export default { model: { extraModels: ['src/models/userModel.ts'], sort: (a, b) => a.namespace.localeCompare(b.namespace), }, };对应地看插件入口 packages/plugins/src/model.ts,其api.describe中通过 zod 定义的 schema 与文档完全一致:extraModels为string[],sort为可选函数;并且enableBy: api.EnableBy.config意味着该插件只有显式配置model后才会启用。
extraModels
- Type:
string[] - Default:
[]
配置extraModels后,插件会自动将这些 Model 文件添加到数据流管理中。
从源码结构看,extraModels并不是简单地追加路径。在 packages/plugins/src/model.ts 的getAllModels()中,它还会先执行api.applyPlugins({ key: 'addExtraModels' }),把其它插件(例如全局初始状态插件)注入的 Model 一并收集后合并:
// packages/plugins/src/model.ts(节选) return new ModelUtils(api, { astTest({ node }) { return t.isArrowFunctionExpression(node) || t.isFunctionDeclaration(node); }, }).getAllModels({ sort: {}, extraModels: [...extraModels, ...(api.config.model.extraModels || [])], });也就是说,官方内置的@@initialState等“系统 Model”正是通过addExtraModels这个插件钩子被纳入同一个数据流体系的。
sort
- Type:
(a: Model, b: Model) => number - Default:
(a, b) => a.namespace.localeCompare(b.namespace)
配置sort后,插件会根据sort函数返回的值对 Model 进行排序。实现上,在 packages/plugins/src/model.ts 的onGenerateFiles钩子中:
// packages/plugins/src/model.ts(节选) api.onGenerateFiles(async () => { const models = await getAllModels(api); if (api.userConfig.model?.sort) { models.sort(api.userConfig.model.sort); } // 随后生成 model.ts / index.tsx / runtime.tsx 三个临时文件 ... });值得注意的是,ModelUtils.getAllModels内部还会基于 Model 之间useModel()调用关系做一次拓扑排序(见 packages/plugins/src/utils/modelUtils.ts 中的topologicalSort静态方法,它会遍历 Model 文件 AST 收集useModel('xxx')字面量依赖,若存在循环依赖会抛出Circle dependency detected in models错误)。用户配置的sort是在这之后覆盖最终顺序的,可用于控制多个 Model 的执行次序。
开始使用
创建 Model
数据流管理插件采用约定式目录结构,我们约定可以在src/models、src/pages/xxxx/models/目录中,以及src/pages/xxxx/model.{js,jsx,ts,tsx}文件引入 Model 文件。Model 文件允许使用.(tsx|ts|jsx|js)四种后缀格式,命名空间(namespace)生成规则如下。
| 路径 | 命名空间 | 说明 |
|---|---|---|
src/models/count.ts | count | src/models目录下不支持目录嵌套定义 model |
src/pages/pageA/model.ts | pageA.model | |
src/pages/pageB/models/product.ts | pageB.product | |
src/pages/pageB/models/fruit/apple.ts | pageB.fruit.apple | pages/xxx/models下 model 定义支持嵌套定义 |
命名空间的生成逻辑可以直接在 packages/plugins/src/utils/modelUtils.ts 的getNamespace()函数中找到印证:
// packages/plugins/src/utils/modelUtils.ts(节选) export function getNamespace(absFilePath: string, absSrcPath: string) { const relPath = winPath(relative(winPath(absSrcPath), winPath(absFilePath))); const parts = relPath.split('/'); const dirs = parts.slice(0, -1); const file = parts[parts.length - 1]; // src/pages/foo/models/bar > foo/bar const validDirs = dirs.filter( (dir) => !['src', 'pages', 'models'].includes(dir), ); let normalizedFile = basename(file, extname(file)); // foo.model > foo if (normalizedFile.endsWith('.model')) { normalizedFile = normalizedFile.split('.').slice(0, -1).join('.'); } return [...validDirs, normalizedFile].join('.'); }可以看到,实现上会过滤掉src、pages、models三段目录,再把剩余目录与文件名(去掉扩展名,并剥掉.model后缀)用.连接——这与上表的规则一一对应。
所谓的 Model,就是一个自定义的hooks,没有任何使用者需要关注的“黑魔法”。当我们需要获取 Model 中的全局数据时,调用该命名空间即可。例如,对于 Model 文件userModel.ts,它的命名空间为userModel。
编写一个默认导出的函数:
// src/models/userModel.ts export default () => { const user = { username: 'umi', }; return { user }; };这就是一个 Model。插件所做的工作就是将其中的状态或数据变成了全局数据,不同的组件在使用该 Model 时,拿到的是同一份状态或数据。
提示:Model 文件需要默认导出一个函数,此函数定义了一个
hook。对于不符合此规范的文件,将会被过滤掉,并无法通过命名空间调用。
这一点在源码中有明确体现:packages/plugins/src/model.ts 构造ModelUtils时传入了astTest,而ModelUtils.isModelValid(packages/plugins/src/utils/modelUtils.ts)会用 esbuild 先转译文件内容、再用 Babel 遍历 AST,仅当ExportDefaultDeclaration是箭头函数或函数声明时才判定为合法 Model。此外getModels还会直接过滤掉.d.ts文件和.test/.e2e/.spec测试文件,并检查命名空间是否重复(重复时抛出Duplicate namespace in models错误)。
Model 中允许使用其它hooks,以计数器为例:
// src/models/counterModel.ts import { useState, useCallback } from 'react'; export default function Page() { const [counter, setCounter] = useState(0); const increment = useCallback(() => setCounter((c) => c + 1), []); const decrement = useCallback(() => setCounter((c) => c - 1), []); return { counter, increment, decrement }; };在项目实践中,我们通常需要请求后端接口,来获取所需的数据。现在让我们来扩展前面获取用户信息的例子:
// src/models/userModel.ts import { useState, useEffect } from 'react'; import { getUser } from '@/services/user'; export default function Page() { const [user, setUser] = useState({}); const [loading, setLoading] = useState(true); useEffect(() => { getUser().then((res) => { setUser(res); setLoading(false); }); }, []); return { user, loading, }; };如果你项目中已经引入了 ahooks,可以像这样组织代码:
// src/models/userModel.ts import { useRequest } from 'ahooks'; import { getUser } from '@/services/user'; export default function Page() { const { data: user, loading: loading } = useRequest(async () => { const res = await getUser(); if (res) { return res; } return {}; }); return { user, loading, }; };Model 之间也可以互相调用。仓库中的示例 examples/with-use-model/models/count.ts 就展示了一个 Model 内部消费另一个 Model 与全局初始状态的写法:
// examples/with-use-model/models/count.ts import { useModel } from 'umi'; export default function () { const { todos } = useModel('todo'); const initialState = useModel('@@initialState'); console.log('todos length', todos); console.log('initialState', initialState); return { total: 123, }; }使用 Model
现在,您想要在某个组件中使用全局的 Model。以用户信息为例,只需要调用useModel这一钩子函数:
// src/components/Username/index.tsx import { useModel } from 'umi'; export default function Page() { const { user, loading } = useModel('userModel'); return ( {loading ? <></>: <div>{user.username}</div>} ); }其中,useModel()方法传入的参数为 Model 的命名空间。
提示:如果您使用 VSCode 作为 Umi 项目开发的 IDE,推荐搭配
@umijs/plugin-model插件使用。它允许您快速跳转到定义 Model 的文件,便于在大型项目中按命名空间定位源码。
性能优化
useModel()方法可以接受可选的第二个参数,当组件只需要使用 Model 中的部分参数,而对其它参数的变化不感兴趣时,可以传入一个函数进行过滤。以实现计数器的操作按钮为例:
// src/components/CounterActions/index.tsx import { useModel } from 'umi'; export default function Page() { const { add, minus } = useModel('counterModel', (model) => ({ add: model.increment, minus: model.decrement, })); return ( <div> <button onClick={add}>add by 1</button> <button onClick={minus}>minus by 1</button> </div> ); };上面的组件并不关心计数器 Model 中的counter值,只需要使用 Model 提供的increment()和decrement()方法。于是我们传入了一个函数作为useModel()方法的第二个参数,该函数的返回值将作为useModel()方法的返回值。
这样,我们过滤掉了counter这一频繁变化的值,避免了组件重复渲染带来的性能损失。
这个优化在实现层面有真实依据。packages/plugins/libs/model.tsx 中useModel的订阅回调里,每次全局 data 更新时都会先执行selector,再用fast-deep-equal对 selector 结果做深度比较:
// packages/plugins/libs/model.tsx(节选) const currentState = selectorRef.current ? selectorRef.current(data) : data; const previousState = stateRef.current; if (!isEqual(currentState, previousState)) { stateRef.current = currentState; setState(currentState); }也就是说,即使 Model 整体数据(例如counter)频繁变化,只要 selector 投影出的{ add, minus }深比较相等,组件就不会触发setState,从而跳过重渲染。
全局初始状态
@umi/max内置了全局初始状态管理插件(实现见 packages/plugins/src/initial-state.ts),允许您快速构建并在组件内获取 Umi 项目全局的初始状态。
全局初始状态是一种特殊的 Model。它在整个 Umi 项目的最开始创建。编写src/app.ts的导出方法getInitialState(),其返回值将成为全局初始状态。例如:
// src/app.ts import { fetchInitialData } from '@/services/initial'; export async function getInitialState() { const initialData = await fetchInitialData(); return initialData; }仓库中的示例 examples/with-use-model/app.ts 给出了一个可直接运行的最小版本:
// examples/with-use-model/app.ts async function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } export async function getInitialState() { await delay(500); return { name: 'Big Fish', size: 'big', color: 'blue', mood: 'happy', food: 'fish', location: 'sea', }; }现在,各种插件和您定义的组件都可以通过useModel('@@initialState')直接获取到这份全局的初始状态,如下所示:
import { useModel } from 'umi'; export default function Page() { const { initialState, loading, error, refresh, setInitialState } = useModel('@@initialState'); return <>{initialState}</>; };示例页面 examples/with-use-model/pages/index.tsx 同时消费了自定义 Model 与初始状态:
export default function HomePage() { const { todos } = useModel('todo'); const { total } = useModel('count'); // ... }| 对象属性 | 类型 | 介绍 |
|---|---|---|
initialState | any | 导出的getInitialState()方法的返回值 |
loading | boolean | getInitialState()或refresh()方法是否正在进行中。在首次获取到初始状态前,页面其他部分的渲染都会被阻止 |
error | Error | 如果导出的getInitialState()方法运行时报错,报错的错误信息 |
refresh | () => void | 重新执行getInitialState方法,并获取新的全局初始状态 |
setInitialState | (state: any) => void | 手动设置initialState的值,手动设置完毕会将loading置为false |
从源码实现看,@@initialState本身就是通过addExtraModels钩子注册进 model 体系的(packages/plugins/src/initial-state.ts 中api.register({ key: 'addExtraModels' })),命名空间为@@initialState。当src/app.ts中导出了getInitialState时,插件会在临时目录生成对应的 Model 实现,其核心逻辑即:useState持有{ initialState, loading, error }三元组,refresh为useCallback包裹的重新执行函数(先置loading: true,成功后写入initialState,失败则记录error),setInitialState支持直接赋值或函数式更新。
此外,该插件还会生成一个InitialStateProvider(Provider.tsx):在loading为true且应用尚未完成首次加载时,它会渲染加载占位组件并阻止props.children渲染,这正是属性表中“首次获取到初始状态前页面渲染被阻止”的实现来源。initialState配置项还支持loading字段(Type:string),用于指定自定义的 Loading 组件路径;不配置时使用内置的空div占位。若src/app.ts未导出getInitialState,生成的 Model 会退化为() => ({ loading: false, refresh: () => {} }),useModel('@@initialState')依然可安全调用。
Qiankun 父子应用间通信
@umi/max内置了Qiankun 微前端插件(实现见 packages/plugins/src/qiankun.ts),当使用数据流插件时,它允许微应用通过useModel('@@qiankunStateFromMaster')方法获取父应用传递给子应用的数据 Model,进而实现父子应用间的通信。
从源码结构看,子应用侧的@@qiankunStateFromMasterModel 由 packages/plugins/libs/qiankun/slave/qiankunModel.ts 提供,它是一个极简的useState封装:父应用每次下发新 state 时,通过模块级的setModelState触发setState,子应用组件随之以响应式方式更新:
// packages/plugins/libs/qiankun/slave/qiankunModel.ts let initState: any; let setModelState = (val: any) => { initState = val; }; export default () => { const [state, setState] = useState(initState); setModelState = (val: any) => { initState = val; setState(val); }; return state; }; export { setModelState };具体的使用方法请查阅 微前端的父子应用通信章节。
API
useModel
useModel()是一个钩子函数,提供了使用 Model 的能力。它接受两个参数:
| 参数 | 类型 | 介绍 |
|---|---|---|
namespace | String | Model 文件的命名空间 |
updater | (model: any) => any | 可选参数。传入一个函数,函数的返回值为当前组件中需要使用到的 Model 状态或数据,并作为useModel()方法的返回值。对优化组件性能具有重要意义。 |
// src/components/AdminInfo/index.tsx import { useModel } from 'umi'; export default function Page() { const { user, fetchUser } = useModel('adminModel', (model) => ({ user: model.admin, fetchUser: model.fetchAdmin, })); return <>hello</>; };值得一提的是,useModel的实现(packages/plugins/libs/model.tsx)基于编译期生成的 Model 注册表提供了类型推导:Namespaces类型由所有已注册 Model 的namespace字段联合而成,useModel<N>(namespace)的返回类型即对应 Model hook 的ReturnType,因此命名空间拼写错误会得到类型提示,updater参数的入参也是精确的 Model 返回结构。
底层实现速览:从 Model 文件到运行时 Provider
将上述各部分串起来,可以完整还原这套数据流方案的工作链路,便于理解它为什么是“无黑魔法”的:
- 收集:
onGenerateFiles阶段按三个约定路径 glob 扫描 Model 文件(src/models/**、src/pages/**/models/**、src/pages/**/model.*,扩展名ts/tsx/js/jsx),叠加extraModels与addExtraModels钩子注入的 Model(如@@initialState),并完成“默认导出函数”校验、重复命名空间检查与拓扑排序(packages/plugins/src/utils/modelUtils.ts)。 - 生成:插件在临时目录生成三个文件(packages/plugins/src/model.ts):
model.ts:静态导入全部 Model,导出{ [id]: { namespace, model } }注册表;index.tsx:即 packages/plugins/libs/model.tsx,包含Provider、Executor、Dispatcher与useModel运行时;runtime.tsx:导出dataflowProvider(container, opts),以useMemo将注册表按namespace建索引后注入Provider,并作为运行时插件挂载到应用外层。
- 运行:
Provider为每个 Model 渲染一个Executor组件——它调用 Model hook,将返回值写入Dispatcher.data[namespace],并在 state 变化时通过dispatcher.update(namespace)通知该命名空间下的所有订阅者;useModel则通过 React Context 拿到Dispatcher,注册回调并按 selector 投影 + 深比较决定是否更新本地 state。 - 热更新:
api.addTmpGenerateWatcherPaths将src/models目录加入监听,在开发模式下新增/删除 Model 文件会触发临时文件重新生成。
由于 Model 本质就是普通 hook、共享状态由一个模块级Dispatcher单例承载,整套方案没有引入额外的状态库依赖,也没有跨组件的状态转发魔法——组件读到的数据始终来自同一个 Model 实例的返回值,这正是文档开头所说“不同的组件在使用该 Model 时,拿到的是同一份状态或数据”的源码级解释。
小结
- 数据流插件需在
model配置中显式开启,extraModels可补充约定目录之外的 Model 文件,sort可覆盖 Model 的默认排序; - Model 即“默认导出函数的 hook 文件”,命名空间由约定式路径推导,
src/models下不支持目录嵌套而pages/xxx/models下支持; - 用
useModel(namespace, updater)消费数据时,利用 selector 投影可跳过不关心字段的重复渲染; - 全局初始状态通过
src/app.ts的getInitialState()提供,useModel('@@initialState')可获取initialState / loading / error / refresh / setInitialState,首次加载完成前页面渲染会被 Loading Provider 阻止; - Qiankun 子应用可经
useModel('@@qiankunStateFromMaster')与父应用通信,详见 微前端章节。
如果你希望快速验证以上行为,仓库中的 examples/with-use-model 示例提供了getInitialState(app.ts)、Model 间相互引用(models/count.ts)与页面消费(pages/index.tsx)的完整可运行参照。
【免费下载链接】umiA framework in react community ✨项目地址: https://gitcode.com/GitHub_Trending/um/umi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考