我准备给 formily 去掉 observer

大家好,我是前端菜鸡木子

最近一段时间,公司里大范围的使用了 formily 表单架构,不得不说 formily + schema 为中后台表单提供了新的编码规范。但是在大量的实践过程中,我发现了一些小小的不爽,就是当你的组件依赖了 formily/reactive 声明的响应式状态时,组件的外层需要用 observer 包装下,有时忘记使用 observer 会产生页面状态没更新等一系列问题。本着 「看你不爽就干掉你」的原则,我就想有没有一种方式能够不用 observer 还能让组件自动刷新呢?

接下来就和大家一起干掉 observer

前言

本文适合对 formilyreact 有一定使用经验的小伙伴。另外,不建议大家在生产环境中使用下面的代码

关于 formily/reactive

formily/reactive 是一个响应式的状态管理工具,正因为它,formily 表单才能做到子组件级别的精准刷新

formily 会在表单的每个子组件上套一层 observerobserver 相当于一个 「观察者」,它配合 formily/reactive 能够知道当前组件依赖了哪些表单字段,所以当表单字段更新时,也就能找到依赖它的具体子组件,这样字段变更时就能够做到精准刷新

antdForm 组件是全局刷新(无论哪个字段变了,整个表单组件都会刷新)

这样看起来,似乎使用 observer 是很合理。但是我们知道 react 是单线程的,也就是说每个时间点最多只会有一个组件正在 render。按理说即使没有 observer ,我们也应该能做到类似 observer 的能力。

从一个 DEMO 说起

我们先写一个使用 formily/reactve 的简单 DEMO:

js 复制代码
import { model } from '@formily/reactive';
import React from 'react';

const count = model({
  value: 0,
});

export default () => {
  return <button onClick={() => count.value++}>{count.value}</button>;
};

在这个 DEMO 中,我们没有使用 observer,结果就是无论如何点击 button 按钮,界面上永远显示的是 0

我们的目的就是不加 observer 也能让界面实时更新。

react 的神奇属性

当我翻遍无数文档后,发现了一个 react 的神奇属性 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,这个变量的名字看起来就很刺激。

__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIREDreact 内部架构的桥梁,它做了两件事情:

1、维护了 hooks 的具体实现,我们在代码中使用到的各种 reacthooks 就是挂在这上面的

2、维护了 react 内部的状态

__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 的源码在 react-reconciler 中,感兴趣的小伙伴可以去看下。

我在控制台里打印了下 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 的值,发现有个属性叫做 ReactCurrentDispatcher, 它下面挂载了所有的 hooks 函数。但是在不同阶段 ReactCurrentDispatcher 下同一个 hook 打印出来的值是不一样的:

为什么会有这种情况呢?随后我翻阅了 react 的相关源码,在 react-reconciler 中有具体的实现逻辑,这里就不展开描述了,只是说下最后的结论。

react hooks 在不同的阶段会有不同的实现逻辑:

  • 如果在组件之外的地方使用:所有 hooks 均指向同一个函数 throwInvalidHookError,这个函数里会直接抛出一个 error ,这个 error 的信息就是:Invalid hook call. Hooks can only be called inside of the body of a function component..... 有没有感觉很熟悉的,这就是 react 提示你只能在组件内部使用 hooks
  • 在组件 mount 阶段:同一个 hook,在组件首次 renderrerender 时是指向不同的函数,内部实现上有差异,但不是本次要探讨的内容,我们只需要知道是指向不同的函数即可
  • 在组价 updatererender 阶段:区别于 mount 阶段,指向不同的函数,函数内主要是通过比较 deps 来决定是否使用缓存。

既然我们已经知道 ReactCurrentDispatcher 下的 hooks 在组件不同阶段有不同的表现,那么我们可以利用它来感知渲染的流程了,真是柳暗花明又一村。

formily/reactive 响应式原理

这时需要返回到本次的核心诉求:「我们需要不通过 observer 就能知道哪些组件依赖了 formily/reactive 声明的响应式状态」,这里需要先了解下 formily/reactive 响应式的原理:

我们在使用 formily/rectivemodel 方法时,其内部会通过 Proxy 劫持我们传入给 model 的对象,当我们在组件内使用这个对象的某个属性时,便会触发劫持后的 get 函数,这个时候 formily 会把让当前组件刷新的方法放置到这个对象对应 key 的依赖框筐里。后续当这个属性改变时,自动触发 set 函数,然后在 set 函数里只要把对应依赖筐里的方法取出来,挨个执行即可。

看来 formily/reactive 已经为我们做了大部分的事情了,我们只需要处理这个 依赖筐 逻辑就行,至于组件刷新,就直接用 ahooks 提供的 useUpdate 函数即可。

那么我们该如何把对应的函数放到 依赖框 里呢?这里还需要再深入看下 formily/reactive 实现。由于 js 是单线程的,所以同一时间只会有一个函数在执行(组件在 render ),我们只需要声明一个全局变量,这个变量就等于正在执行的函数(组件的 render)就行了。而 formily/reactive 也正是这么实现的,在 formily/reactive/src/environment 文件里就维护了这么一个变量 ReactionStack。也就是说我们只需要把能够刷新当前组件的方法 (ahooksuseUpdate) 赋值给 ReactionStack 就行了。

额外说明下,ReactionStack 其实是个数组,因为组件是会嵌套执行的,你可以把它想象成是一个栈,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架

巧用 Object.defineProperty 和 ReactCurrentDispatcher

到这里我们已经具备了所有的理论基础了(ReactCurrentDispatcherformily/reactive),接下来就要看如何去使用他们了

首先,为了知道哪个组件依赖了 formily/reactive 的响应式变量,我们需要感知组件的生命周期(进入渲染和和完成渲染),结合上面讲的 ReactCurrentDispatcher 特性,我们可以尝试着去劫持他,同时为了能让 react 能够正确的处理自己的逻辑,我们还得保证劫持后能够正常的返回,那么就有了以下代码:

js 复制代码
import * as React from 'react';
let currentDispatcher;

const { __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactInternals } = React;

function init() {
  Object.defineProperty(ReactInternals.ReactCurrentDispatcher, 'current', {
    get() {
      return currentDispatcher;
    },

    set(nextDispatcher) {
      currentDispatcher = nextDispatcher;
    },
  });
}

init();

现在我们就能在 set 阶段通过读取 currentDispatchernextDispatcher 来感知组件的生命周期了,以 useCallback 为例,我们分析下:

  • 在组件外使用时:useCallback 指向了 throwInvalidHookError 函数
  • mount 时:useCallback 指向了 mountCallback 函数
  • rerender 时:useCallback 指向了 updateCallback 函数

很明显我们能通过判断 useCallback.toString() 的值,就可以知道组件的进入渲染和完成渲染阶段了,我们继续补充一个判断 dispatcher 类型的函数

js 复制代码
const getDispatcherType = (disptcher) => {
  if (!disptcher) {
    return 'invalid';
  }

  const useCallbackImpl = disptcher.useCallback.toString();

  if (/Invalid/.test(useCallbackImpl)) {
    return 'invalid';
  }

  if (/mountCallback/.test(useCallbackImpl)) {
    return 'mount';
  }

  return 'update';

};

这个函数返回三个值:invalid 代表非渲染时、mount 代表首次渲染、update 代表 rerender,紧接着我们可以通过比较 currentDispatchernextDispatcher 的对应 type 来感知是组件的进入渲染阶段还是完成渲染阶段:

js 复制代码
import * as React from 'react';
let currentDispatcher;

const { __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactInternals } = React;

function init() {
  Object.defineProperty(ReactInternals.ReactCurrentDispatcher, 'current', {
    get() {
      return currentDispatcher;
    },

    set(nextDispatcher) {
      const currentDispatcherType = getDispatcherType(currentDispatcher);
      const nextDispatcherType = getDispatcherType(nextDispatcher);

        currentDispatcher = nextDispatcher;

      if (currentDispatcherType === 'invalid' && ['mount','update'].includes(nextDispatcherType)) {
        // 这里便是进入渲染阶段了
      }
    },
  });
}

init();

同理,完成渲染可以理解为 currentDispatcherType['mount','update'] 其中之一时,同时 nextDispatcherTypeinvalid

最后我们只需要在进入渲染阶段,把能够让当前组件刷新的函数 (ahooksuseUpdate) 赋值给 ReactionStack 就行了, 我们可以用一个 hooks 来处理这部分逻辑:

js 复制代码
function _useWatcher() {
  const trackRef = React.useRef();
  const update = useUpdate();

  if (!trackRef.current) {
    ReactionStack.push(update);
  }
}

最后

整合下上面的所有代码:

js 复制代码
import { ReactionStack } from '@formily/reactive/esm/environment';
import { useUpdate } from 'ahooks';
import * as React from 'react';

const { __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactInternals } = React as any;
let currentDispatcher;
let lock = false;

const getDispatcherType = (disptcher) => {
  if (!disptcher) {
    return 'invalid';
  }

  const useCallbackImpl = disptcher.useCallback.toString();

  if (/Invalid/.test(useCallbackImpl)) {
    return 'invalid';
  }

  if (/mountCallback/.test(useCallbackImpl)) {
    return 'mount';
  }

  return 'update';
};

function _useWatcher(): any {
  const trackRef = React.useRef<any>();
  const update = useUpdate();

  if (!trackRef.current) {
    ReactionStack.push(update);
  }
}

function init() {
  Object.defineProperty(ReactInternals.ReactCurrentDispatcher, 'current', {
    get() {
      return currentDispatcher;
    },

    set(nextDispatcher) {
      if (lock) {
        currentDispatcher = nextDispatcher;
        return;
      }

      const currentDispatcherType = getDispatcherType(currentDispatcher);
      const nextDispatcherType = getDispatcherType(nextDispatcher);

      currentDispatcher = nextDispatcher;

      if (currentDispatcherType === 'invalid' && ['mount', 'update'].includes(nextDispatcherType)) {
        lock = true;
        _useWatcher();
        lock = false;
      }
    },
  });
}

init();

用这个代码其实可以跑 DEMO 了,但是受限于篇幅和时间原因,很多地方没能展开来讲,同时也有很多地方还未完善。我们只能说 「给 formily 去掉 observer」 是可以实现的。

其实已经有类似的 react 状态管理工具实现了这个能力 (自动监听依赖),如果大家感兴趣可以去看下 @preact/signal-react

最后还是要说下,在没做好充分的准备之前 不建议大家在生产环境中使用 ,因为无论是 react.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 还是 formily/reactive.ReactionStack 都是他们内部维护的变量,没有直接对外使用,所以

DO_NOT_USE_OR_YOU_WILL_BE_FIRED

相关推荐
LuciferHuang3 小时前
震惊!三万star开源项目竟有致命Bug?
前端·javascript·debug
GISer_Jing3 小时前
前端实习总结——案例与大纲
前端·javascript
天天进步20153 小时前
前端工程化:Webpack从入门到精通
前端·webpack·node.js
姑苏洛言4 小时前
编写产品需求文档:黄历日历小程序
前端·javascript·后端
知识分享小能手4 小时前
Vue3 学习教程,从入门到精通,使用 VSCode 开发 Vue3 的详细指南(3)
前端·javascript·vue.js·学习·前端框架·vue·vue3
姑苏洛言5 小时前
搭建一款结合传统黄历功能的日历小程序
前端·javascript·后端
你的人类朋友6 小时前
🤔什么时候用BFF架构?
前端·javascript·后端
知识分享小能手6 小时前
Bootstrap 5学习教程,从入门到精通,Bootstrap 5 表单验证语法知识点及案例代码(34)
前端·javascript·学习·typescript·bootstrap·html·css3
一只小灿灿6 小时前
前端计算机视觉:使用 OpenCV.js 在浏览器中实现图像处理
前端·opencv·计算机视觉