用了 umi 一段时间后,好奇这 useModel 到底用的是什么黑魔法?🤔

2,320 阅读19分钟

扯皮

因为之前一直是 Vue 技术栈,进公司后全面转向 React,首先到来的就是比较知名的 Umi 框架,别的不说,里面集成的常见解决方案还挺好用:路由给你配好、状态管理给你提供方案、样式也有多种使用方式

只能说个人项目有这些已经能够做到开箱即用了,缺点是用着用着就开始不动脑子了🤡,工程化什么的也都不需要你操心,如果没有特殊定制要求基本上只需要关注业务,慢慢就成为了业务仔,不过想想框架不就是用来干这事的么🤪

正文

废话不多说,这次我们来看看 Umi 的 useModel —— 一个特别轻量的状态管理方案。众所周知 React 的状态管理百花齐放,作为一名患有选择恐惧症的人真是难受,最关键是起的名字也花里胡哨压根不会读😭,每次和别人提到一些状态管理库都是拼出来的,真是难绷🤣,不像 Vue3 的 pinia 直接一统天下

所以转到 React 之后第一时间想到的状态管理不是什么 redux,而是看有没有 pinia 平替,当时使用的是 Zustand,现在接触了 useModel 后发现它比 Zustand 更轻量,虽然能力较弱但使用起来爽啊,但是扒完源码后又发现有不少坑,下面就来慢慢总结

基本使用和优化

跟着 Umi 的官方文档 步骤来就行,只不过要注意 Umi4 被分为了 Umi 和 Umi Max,像 useModel 这类的插件功能都直接集成到了 Umi Max 中,而如果安装的是 Umi 还需要再手动安装对应的插件,这部分的内容在这里:插件 (umijs.org)

安装完成再进行配置后就能够顺利使用 useModel 了,它的最大特点是只需在指定位置创建一个 ts 文件,之后在里面编写一个自定义 hook 即可,这个 hook 内部的状态就已经成为了全局状态,严格意义上来讲还算不上自定义 hook,因为也没有强制命名以 use 开头🤨:

// src/models/userInfo.ts
import { useState } from "react";

const userInfoModel = () => {
  const [name, setName] = useState("abc");
  const [age, setAge] = useState(20);

  return {
    name,
    age,
    setName,
    setAge,
  };
};

export default userInfoModel;

现在我们就可以在任意一个地方使用 Umi 提供的 useModel 来拿到上面 model 中的状态,十分神奇:

// src/pages/index.tsx
import { useModel } from "umi";

const Child1 = () => {
  const { setName } = useModel("userInfo");
  return (
    <div>
      child1 component:
      <button onClick={() => setName((pre) => pre + "s")}>update name</button>
    </div>
  );
};

const Child2 = () => {
  const { setAge } = useModel("userInfo");
  return (
    <div>
      child2 component:
      <button onClick={() => setAge((pre) => pre + 1)}>update age</button>
    </div>
  );
};

const Child3 = () => {
  const { name } = useModel("userInfo");
  return (
    <div>
      child3 component:
      <span>{name}</span>
    </div>
  );
};

const Child4 = () => {
  const { age } = useModel("userInfo");
  return (
    <div>
      child4 component:
      <span>{age}</span>
    </div>
  );
};

export default function HomePage() {
  return (
    <>
      <Child1 />
      <Child2 />
      <Child3 />
      <Child4 />
    </>
  );
}

useModel 效果.gif

但作为之前用惯 Zustand 的我就开始好奇了,在业务组件中使用的方式几乎就跟 Zustand 一样,那是否会存在未使用依赖也跟着渲染的问题呢?🤔

上面的例子试试就知道了,给所有 child 都打上日志再来看看:

useModel render.gif

果不其然跟 Zustand 是一个尿性,通过这种直接获取或者解构的方式来拿到的状态,其中一旦有一个状态发生改变触发渲染其他的也会跟渲染

在官方文档中也提到了这个问题的优化,传入第二个参数 selector 即可,简单总结就是需要什么就用什么:

image.png

感觉和 Zustand 比起来还是比较友好的,毕竟 Zustand 这种方式还需要借助 shallow 才能实现🙃

但是使用起来还是很难受,毕竟想要针对某个状态进行优化基本上得重复写三次,很难绷的住😅:

image.png

这时候突然想到 useModel 和 Zustand 这么像,之前看到小付大佬针对于 Zustand 的实践文章:

关于zustand的一些最佳实践 - 掘金 (juejin.cn)

这里的两种优化方式感觉完全可以套到 useModel 当中,一起来看看😁

封装 selector 工具函数

useModel 的优化方式跟 Zustand 还不太一样,可以看到官方文档中 Zustand 的 useShallow 是包裹到 useStore 中的,useModel 可做不到这一点🤔:

image.png

从小付的文章中可以看到是通过手动封装的一个 useSelector 函数来简化代码:

image.png

这种方式将原来 selector 需要写三次状态改写了为两次,且第二个参数以数组的形式传入更加简洁,所以我们照着这个思路也写一个 useModel 的🧐

本质就是修 selector 传入的参数呗:

function useSelector<S extends object, P extends keyof S>(path: P[]): (state: S) => Pick<S, P> {
  return (state: S) => {
    return path.reduce(
      (prev, key) => {
        if (prev[key]) return prev;
        prev[key] = state[key];
        return prev;
      },
      {} as Pick<S, P>
    );
  };
}

在组件中使用下看看,可以看到也有类型提示:

const Child3 = () => {
  const { name } = useModel("userInfo", useSelector(["name"]));
  console.log("child3 render");
  return (
    <div>
      child3 component:
      <span>{name}</span>
    </div>
  );
};

image.png

再来看看效果,这里的 Child3 不再因为 age 的更新而被拉着一块重新渲染,说明我们的优化起效果了:

useSelector 封装.gif

但是总觉得这样封装使用体验还是不太好,Zustand 是需要导入对应的 store 并 useXxxStore,而 useModel 在业务代码中始终都是使用这一个 hook🤔

所以我们可以针对于 useModel 的封装,封装一个 usePerformanceModel 来代替之前 useModel 的使用,原理和使用方式和 useSelector 一样:

function usePerformanceModel(modelName: any, keys: any[]) {
  return useModel(modelName, (state: any) => {
    return keys.reduce((prev, key) => {
      if (prev[key]) return prev;
      prev[key] = state[key];
      return prev;
    }, {});
  });
}
const Child3 = () => {
  const { name } = usePerformanceModel("userInfo", ["name"]);
  console.log("child3 render");
  return (
    <div>
      child3 component:
      <span>{name}</span>
    </div>
  );
};

useModel 优化.gif

但 useModel 源码玩了一手好的类型体操有完整类型提示,我们的 usePerformanceModel 都馋哭了🥺,为了快速实现功能我们当前的 usePerformanceModel 类型定义全是 any 😑

image.png

image.png

既然你的 useModel 的类型提示这么友好,我的 usePerformanceModel 又是包裹你的,我有一计☝️🤓,把你的类型全部偷过来给我不就好了,这时候类型工具就帮大忙了,简单玩玩类型体操拿下:

import { useModel } from "umi";

// Parameters 利用提取 useModel 第一个参数,来取到所有的 ModelName
type ModelNames = Parameters<typeof useModel>[0];
// 根据指定的 ModelName 来拿到 useModel 的返回值(对应的 model state)
type ModelState<N extends ModelNames> = ReturnType<typeof useModel<N>>;

function usePerformanceModel<N extends ModelNames, K extends keyof ModelState<N>>(modelName: N, keys: K[]) {
  return useModel(modelName, (state) => {
    return keys.reduce(
      (prev, key) => {
        if (prev[key]) return prev;
        prev[key] = state[key];
        return prev;
      },
      {} as Pick<ModelState<N>, K>
    );
  });
}

现在再来看看,对胃了:

image.png

image.png

image.png

后续只需要把 usePerformanceModel 放到 utils 中随意导入使用即可,代替原来的 useModel 不是问题😘

利用 babel 自动生成 selector

以上虽然简化了基本使用,但也只是将书写次数从三次简化为两次而已,我们希望实现一种自动转换的效果:

const { name } = useModel("userInfo");
// auto transform 👇
const { name } = useModel("userInfo", (state) => ({ name: state.name }));

提到“自动”一词就要想到利用黑魔法了,这时候就需要 babel 登场了,根据小付的文章来看他通过安装 babel 相关工具并编写一个 Vite 插件来实现转换:

image.png

但在查阅 Umi 官方文档后可以发现它支持编写 babel 插件:

image.png

所以我们的思路是编写一个 babel 插件来实现上述的转换,我们知道 babel 的三大步骤:parse、transform、generate,这里 Babel 就不再过多介绍了,其插件相关内容可以参考文档:

babel-handbook/translations/zh-Hans/plugin-handbook.md at master · jamiebuilds/babel-handbook

实际上需求就是对解析出来的 AST 树做些文章,先来写一个 Demo 来看看 AST 长什么样: AST explorer

image.png

简单梳理下实现步骤:

  1. 找到 useModel 的位置
  2. 提取前面的 state 变量
  3. 根据提取的 state 生成 useModel 的 selector
  4. 将生成 selector 放入 useModel 的第二个参数中

根据 AST 我们可以定位到 useModel:

image.png

我们可以通过 visitor 拿到:

// plugins/transformModel.js
module.exports = function transformModel() {
  return {
    visitor: {
      CallExpression(path) {
        if (path?.node?.callee?.name === "useModel") {
          console.log("check:", path.node);
        }
      },
    },
  };
};

// .umirc.ts
import { defineConfig } from "umi";

export default defineConfig({
  routes: [
    { path: "/", component: "index" },
    { path: "/docs", component: "docs" },
  ],
  npmClient: "pnpm",

  plugins: ["@umijs/plugins/dist/model"],
  // 使用自定义 babel 插件
  extraBabelPlugins: ["./plugins/transformModel"],
  model: {},
});

image.png

下面就是找它前面的参数了,结合 AST 来看它应该需要向上去找它的 parent 节点才能找到,直接上代码和效果,以这段 useModel 为例:

const Child1 = () => {
  const { name, age } = useModel("userInfo");

  return <div>child1 component:{name}</div>;
};
module.exports = function transformModel() {
  return {
    visitor: {
      CallExpression(path) {
        if (path?.node?.callee?.name === "useModel") {
          const declarations = path?.parentPath?.parentPath?.node?.declarations;
          console.log(
            "keys:",
            declarations?.map((item) => item?.init?.property?.name)
          );
        }
      },
    },
  };
};

image.png

看来还需要过滤一下,应该是有其他内容干扰,不过问题不大🤔,总之前面的变量是拿到了,下面结合变量生成 selector,并把这部分给添加到 useModel 的第二个参数中

当然这里肯定不是把字符串直接无脑塞进去,我们本质上还是对 AST 进行操作,所以这里需要把这段 selectorCode 也转换为 AST 结构,这里就要用到插件函数传入的参数了,帮我们生成 AST 结构:

module.exports = function transformModel({ parse }) {
  return {
    visitor: {
      CallExpression(path) {
        if (path?.node?.callee?.name === "useModel") {
          const declarations = path?.parentPath?.parentPath?.node?.declarations;
          const keys = declarations?.map((item) => item?.init?.property?.name).filter(Boolean);
          if (keys && keys.length) {
            const selectorCodeStr = `(state) => ({ ${keys.map((key) => `${key}: state.${key}`)} })`;
            const selectorAST = parse(selectorCodeStr);
            console.log("selector:", selectorAST);
          }
        }
      },
    },
  };
};

image.png

注意看转换后的 AST 也不是直接能用的,我们要的应该是箭头函数这部分内容:

image.png

将这部分内容给塞到 useModel 的第二个参数即可,我们的转换就完成了:

module.exports = function transformModel({ parse }) {
  return {
    visitor: {
      CallExpression(path) {
        if (path?.node?.callee?.name === "useModel") {
          const declarations = path?.parentPath?.parentPath?.node?.declarations;
          const keys = declarations?.map((item) => item?.init?.property?.name).filter(Boolean);
          if (keys && keys.length) {
            const selectorCodeStr = `(state) => ({ ${keys.map((key) => `${key}: state.${key}`)} })`;
            const selectorAST = parse(selectorCodeStr);
            path?.node?.arguments?.push(selectorAST?.program?.body?.[0]?.expression);
          }
        }
      },
    },
  };
};

见证奇迹的时刻!下面就写 demo 来验证看是否有效,还是之前的 demo,只不过这次我们所有的 useModel 都不传第二个参数:

import { useModel } from "umi";

const Child1 = () => {
  const { setName } = useModel("userInfo");
  console.log("child1 render");

  return (
    <div>
      child1 component:
      <button onClick={() => setName((pre) => pre + "s")}>update name</button>
    </div>
  );
};

const Child2 = () => {
  const { setAge } = useModel("userInfo");
  console.log("child2 render");
  return (
    <div>
      child2 component:
      <button onClick={() => setAge((pre) => pre + 1)}>update age</button>
    </div>
  );
};

const Child3 = () => {
  const { name } = useModel("userInfo");

  console.log("child3 render");
  return (
    <div>
      child3 component:
      <span>{name}</span>
    </div>
  );
};

const Child4 = () => {
  const { age } = useModel("userInfo");
  console.log("child4 render");
  return (
    <div>
      child4 component:
      <span>{age}</span>
    </div>
  );
};

export default function HomePage() {
  return (
    <>
      <Child1 />
      <Child2 />
      <Child3 />
      <Child4 />
    </>
  );
}

babel插件生效.gif

看起来效果不错,如果还不放心就打个包看看:

image.png

还没完,我们需要考虑一种特殊情况:

const Child = () => {
  const name = useModel("userInfo", (state) => state.userInfo.name);
  return (
    <div>
      child component:
      <span>{name}</span>
    </div>
  );
};

useModel 中存的状态如果存在多层嵌套,那 babel 基本上是无能为力了,毕竟从编译角度去取对应 model 中有什么状态以及它嵌套情况是十分困难的,所以不如直接摆烂😇:

module.exports = function transformModel({ parse }) {
  return {
    visitor: {
      CallExpression(path) {
        // 💡: 判断 useModel 是否只有一个参数
        if (path?.node?.callee?.name === "useModel" && path?.node?.arguments?.length === 1) {
          const declarations = path?.parentPath?.parentPath?.node?.declarations;
          const keys = declarations?.map((item) => item?.init?.property?.name).filter(Boolean);
          if (keys && keys.length) {
            const selectorCodeStr = `(state) => ({ ${keys.map((key) => `${key}: state.${key}`)} })`;
            const selectorAST = parse(selectorCodeStr);
            path?.node?.arguments?.push(selectorAST?.program?.body?.[0]?.expression);
          }
        }
      },
    },
  };
};

源码解析

基本使用过完了,下面就开始研究下 useModel 的源码,实际上它的核心逻辑没多少内容,除去 TS 类型更少了🤐

前置内容

首先 useModel 一开始是作为插件注入到 umi 中的,如果要细究这部分内容还需要去看看 umi 的插件开发,这部分的源码在这里:umi/packages/plugins/src/model.ts at master · umijs/umi

简单来讲就是借助插件暴露的 api 去提取业务代码中的 model,并生成 useModel 的核心逻辑代码,涉及到一系列的路径处理和文件操作,这部分就不再细看了,对 umi 插件开发感兴趣的可以自行研究下文档

通过插件生成的 useModel 源码在这个位置,我们重点就是研究这三部分内容:

image.png

因为主要是讲解,所以就不粘贴代码了,直接粘图片!🤩

首先来看 model.ts,这部分实际上就是插件中读取我们业务代码里的所有 model 汇总并统一导出:

Snap.png

对应我们源码中的这两个 model:

image.png

导出的 model 在哪用到呢?就在 runtime.tsx 中:

Snap.png

看到 Provider 的字样其实大概就能够猜到了,useModel 本质上还是借助 Context 存储的,这里还做了一层转换将上面导出的 model 改为了以 namespace 作为 key,model 作为 value,看来是方便内部 Provider 进行取值:

image.png

为什么说 useModel 属于黑魔法呢?就是因为在业务代码中我们全程只使用 useModel,但压根就不知道 model 中的状态是怎么传进来的。看到 dataflowProvider 就大概明白了,肯定是 umi 内部帮我们做了处理,将这层 Provider 帮我们进行了包裹,当然这样做存在一定的心智负担,等后面在弊端中会谈到

问题又来了,上面的 Provider 又是从哪来的?下面就进入到重头戏 index.tsx 中了,先来看这里的 Provider 实现:

Provider

Snap.png

大体上来看和我们正常封装 Context 的思路一样,创建 context -> 提供 value(dispatcher) -> 摆放 props.children -> 结束

但这里比较疑惑的地方有两点:Dispatcher 和 Executor,我们来看 Dispatcher 的结构:

Snap.png

很显然这是一种发布订阅模式,具体怎么用的还要结合下面的代码来看,但是这种先存 cb 回调,之后调用 update 取出所有 cb 回调执行的逻辑可太熟悉了

再来看看 Executor,它实际上是一个返回为 null 的组件,和它的命名一样就是起到“执行”的作用:

Snap.png

我们先关注传进来的 props,namespace 不用说了就是每个 model 名字,hook 就是业务代码中创建的 model hook,所以说我们创建的 model hook 实际上并不是在对应的业务组件代码中调用,而是全部集中到了 Executor 里

这里在开发中可能就会踩一些坑,比如我创建了 model hook 但还没有在业务代码里使用 useModel 取里面的状态,它居然自动执行了?🤔

下面就是针对于 onUpdate 的调用,回顾一下 Provider 里是怎么传递的:

Snap.png

这里回调拿到的 val 就是 model hook 的返回值,onUpdate 的操作就是将该值存入 dispatcher 中,同时执行一次 update 逻辑,只不过初始化的时候里面还没有存 cb 函数,所以没什么作用

分析这几个参数之后我们回到 Executor 的内部逻辑顺着梳理一遍,重点在这部分,updateRef 保存的是一开始 props 传入的 onUpdate:

Snap.png

这里通过 useMemo 执行初始化逻辑秀到我了,设置空依赖项来保证只在初始化执行,只不过这里 useMemo 没有返回值有点反直觉,但这种初始化执行操作时机可要比 useLayoutEffect、useEffect 的回调早的多,毕竟这时候是真的连 DOM 都还没有

之后的逻辑就是后面的 useEffect 了,由于没有设置依赖项,所以只要 Executor 重新渲染都会使 updateRef 执行

那么问题来了,Executor 是怎么触发重新渲染的?我们知道触发 React 组件重新渲染的操作其实只有三个:自身状态改变、props 改变、context 改变,总之都是状态改变

但这里 Executor 我是看了半天才明白😑,排除法:它没用 context 状态,传过来的 props 看 Provider 的逻辑也不太会频繁更改,只剩下自身状态了,但是看源码你找不到 useState 和 useReducer...😶

其实是在这里,注意这里的 hook 就是 model hook,它内部可能会有开发者写的状态:

Snap.png

只不过比较怪异,hook 这样简单粗暴命名导致我没有第一时间想到这是一个自定义 hook 🙃,这里的逻辑简化一下套用一个自定义 hook 它就原形毕露了,相当于下面这个例子:

Snap.png

所以说每当 hook 中的状态改变后都会导致 Executor 重新渲染,即使 hook 里的状态修改逻辑并不是在 Executor 中触发的,比如这样也会导致 Executor 重新渲染,十分神奇,但想想又比较合理,毕竟这里的自定义 hook 一旦执行它的 state 就都绑定到了该组件的 Fiber 上:

Snap.png

理清这个之后就可以向最后的 useModel 进发了,两者结合就是这个状态管理的最终方案

useModel

useModel 本质上就是一个 hook,我们先不考虑传第二个参数 selector 和 TS 函数重载,简化后的代码是这样的:

Snap.png

可以看到首先从 context 里取出 dispatcher,根据上面分析我们已经知道这里存的都是 model hook 的返回值,而 dispatcher.data[namespace] 就对应着开发者使用 useModel 指定 namespace 的 hook 返回值,因为会牵扯到前后状态比较来决定是否渲染,所以不仅使用了 state 存储,还用 ref 存了一份

紧接着是 isMount Ref + useEffect 没什么好说的,重点在第二个 useEffect,它的依赖数组里放的是 namespace,一般情况下我们在业务开发时 useModel 的 namespace 很少是动态的(特殊需求除外) 所以姑且先认为只初始化执行一次

里面的 handler 函数比较长,我们先看下面这部分:

image.png

之前讲 Executor 的时候说初次调用 updateRef 时没什么用,因为 dispatcher 里还没有存储 cb 回调函数,这不就来了么,相当于在业务组件里使用 useModel 后都会先把这里的 handler 与 namespace 映射存储至 dispatcher 中,紧接着调用了一次 update,而这回 dispatcher 里就有 cb 了:handler,我们来看看 handler 具体干了啥:

image.png

isMount 在初次挂载时会被赋值为 true,所以我们直接看 else 分支,首次执行时这里的 currentState 与 previousState 是相等的,所以不会再往下走了

isEqual 是 umi 使用的 fast-deep-equal 包提供的方法,感兴趣的可以自行扒下源码,就是对一些特殊引用值的比较,比如针对于两个对象会递归遍历 key 进行比较,有点深拷贝那味了

我们回想 useModel 的使用过程再结合分析的内容一下就清晰了,比如我们一开始写的 demo:

const Child1 = () => {
  const { setName } = useModel("userInfo");
  console.log("child1 render");

  return (
    <div>
      child1 component:
      <button onClick={() => setName((pre) => pre + "s")}>update name</button>
    </div>
  );
};

这里的 useModel 返回值就是 model hook 的返回值(由 dispatcher 中取得),当点击按钮调用 setName 时会修改 model hook 里的 name state,这会导致 Executor 组件重新渲染,而 Executor 的 useEffect 回调紧接着执行 onUpdate 方法,它干了两件事:

  1. 存储 model hook 新的返回值至 dispatcher 中
  2. 调用对应 namespace 的 update 方法

而 update 中存储的 cb 就是 useModel 里的 handler,注意 handler 是有一个 data 参数的,它拿到的就是 model hook 最新的返回值,这时候的 stateRef 还保留着上一次 model hook 的返回值,新旧返回值会进行比较

setName 会导致 name 状态更改,由于新 model hook 返回值与旧 model hook 返回值的 name 属性 value 不同,所以走到 if 判断内部逻辑,更新 stateRef 并调用 useModel 中的 setState,由于 useModel 是在业务组件中使用,它的 state 已经绑定到该业务组件 Fiber 节点上,所以 setState 会导致业务组件重新渲染,进而更新视图

这就是整个 useModel 的状态更新流程,也就是说 model hook 中的 state 发生改变并不会直接导致业务组件重新渲染,而是由 Executor 派发 update 更新,进而再间接触发业务组件渲染

那么为什么传入 selector 就能避免重复渲染的问题呢?我们现在把 selector 逻辑加上并结合下面这个 demo 看一下:

Snap.png

const Child1 = () => {
  const { setName } = useModel("userInfo");
  console.log("child1 render");
  return (
    <div>
      child1 component:
      <button onClick={() => setName((pre) => pre + "s")}>update name</button>
    </div>
  );
};

const Child2 = () => {
  const { age } = useModel("userInfo", (state) => ({ age: state.age }));
  console.log("child2 render");
  return (
    <div>
      child2 component:
      <span>{age}</span>
    </div>
  );
};

当 Child1 和 Child2 组件都挂载时根据 useModel 里 useEffect 的逻辑会向 dispatcher 中针对于 userInfo namespace 增加两个 cb(handler1、handler2)

在 Child1 中点击 button 按钮,handler1 和 handler2 最终都会执行,针对于 handler1,由于 setName 导致 name 更新,所以新旧 model hook 返回值不一致,进而触发 useModel 中的 setState,导致组件重新渲染

而 Child2 传入了 selector,虽然 name 属性发生改变,但都经过了 selector 筛选,Child2 每次都筛出来 age 属性进行比较,由于这两个值始终没有发生变化,所以不会走 useModel 中的 setState 逻辑,避免多余渲染

我们在 useModel 里打印 currentState 和 previousState 也能看得出来:

console.log(currentState, previousState, isEqual(currentState, previousState));

image.png

弊端

明白 useModel 的整个底层逻辑后其实就已经暴露出很多问题了,虽然在使用上几乎没有任何心智负担,只需定义 model 后在业务组件里使用 useModel 即可,但帮我们做了太多事导致的结果就是不可控,我们一点点来看🧐:

问题一:定义 model 后即使不在业务代码中使用 model hook 也会自动执行

虽然一般情况下谁会闲着没事建了 model 之后不用它啊,但当一个项目开始堆积屎山时就会暴露出各式各样的奇葩问题😊,就说一点,比如因为业务变动我需要把原来的 model 删除,但为了方便后续调整就只是删除了业务代码中的 useModel,这时候如果 model 里有一些常见的异步请求操作等,你会发现我把 useModel 都删了它居然还能发请求?你找遍了代码都没有 useModel 的使用痕迹,这不科学,不过看完它的底层实现逻辑后就科学了🤗

包括我在 umi 的 issues 中还真发现了有人提到这个问题:

image.png

问题二:Context.Provider 层级不可控

useModel 本质上还是借助 context 实现的,但是这里的 Provider 是 umi 帮我们包裹到全局的,所以 Provider 的层级关系表面上对于开发者来说是不可见的,导致什么问题呢?上代码看效果:

Snap.png

image.png

可以看到代码直接就崩掉了,原因就是我们使用了 umi 提供的 useLocation hook,它本质上就是 react-router-dom 的 useLocation,报错提示很明显了,这说明 router 的 Provider 是在 useModel Provider 的里面,使用 react devtools 清晰明了:

image.png

而且有一个更逆天的问题,umi 支持定义 rootContainer,也就是说允许我们在这里定义一些全局 Provider,但这里的 Provider 居然是包裹在最外面的,它真的是 root 😅:

image.png

也就是说我们在自己定义的全局 Provider 里是无法使用 useModel 的,就是因为它在最外层...

image.png

问题三:不支持使用 es6 的对象(Map / Set 等)进行渲染条件筛选

该问题来自 issues:useModel导出的Map结构更新后页面不会重新render · Issue #11634 · umijs/umi

来看这里的 demo:

Snap.png

特殊数据类型.gif

这里主要在于 selector 的比较方法 fast-deep-equal 不支持,emm 但也并不是完全不支持,因为 fast-deep-equal 提供了 ES6 的 react 包有专门对这些特殊类型做兼容:

image.png

但是 umi 并没有用这个版本的,至于为什么咱就不清楚了🤪

End

不管怎么说 useModel 就正如文档里描述的那样属于轻量级的状态管理方案,那它就应该对应着比较“轻量”的项目🤔

即使上面介绍了不少缺陷,但在我们公司内大大小小的项目中都有实践,emm 只能说这些缺陷可能都没遇到吧,就怕到时候遇到了不知道怎么调整,所以正如一位维护 umi 的同学所说,小项目玩玩得了,大项目最好还是换一个方案吧:

image.png