去掉状态管理工具(formily)的 observer

avatar
@古茗科技

作者:李滨

背景

用过 React 的同学对状态管理工具应该都不会太陌生,reduxmobx 这些老牌的状态管理工具多多少少都应该使用过或者了解过一些。最近一段时间,我们也在项目中大量使用了 formily/reactive ,它本质上和其它状态管理工具类似。但是在大量的实践过程中,我发现了一些小小的不爽。以 formily/reactive 为例,在使用时需要在组件外层用 observer 包装一下。每当你忘记这步操作时,就会导致页面状态没有更新等一系列问题。本着 「看你不爽就干掉你」的原则,我就在想,有没有一种方式能让我们不使用 observer 的同时,还能让页面状态保持实时更新呢?

接下来我会以 formily/reactive 为例,和大家一起尝试着干掉 observer 🔥🔥

关于状态管理工具

美国五星上将麦克阿瑟将军曾说过 “干掉他之前需要先了解他”,因此我们先了解下响应式状态管理工具为什么需要类似 observer 这样的函数。

formily/reactive 是阿里巴巴开源表单解决方案 formily 下的一个响应式数据框架,主要用于 formily 表单模型中,处理表单字段之间的关联关系。也正是得益于它,formily 表单才能做到子组件级别的精准刷新。而 observer 相当于一个「观察者」,它配合 formily/reactive 能够知道哪个组件依赖了响应式状态(或是表单字段),随后当响应式状态(或是表单字段)改变时就能找到依赖它的具体组件,并让它主动刷新

observer 似乎看起来很合理,但是思来想后发现不对劲,我们知道 js 是单线程的,也就是说在某个时间点,最多只会有一个组件正在 render 中,只要 react 允许,我们应该是可以通过某种方法来获取当前正在渲染的组件,这样当触发响应式状态的 getter 时,我们就能知道哪个组件依赖了它,也能达到 observer 的效果了

run demo

我们先通过一个简单的 demo 来描述下我们最终想要的效果

import { model } from '@formily/reactive';

const reactiveData = model({
  data: 1,

  add() {
    this.data += 1;
  }
});

function App() {
  return (
    <div style={{ padding: 40 }}>
      <button onClick={() => reactiveData.add()}>{reactiveData.data}</button>
    </div>
  );
}

显然这个 demo 目前是达不到效果的:

demo1.gif

无论我们如何点击 button 按钮,上面的数字都没有累加,这是因为 reactiveData.data 数字虽然变动了,但是 App 组件没有重新渲染。

我们的核心目的就是让 App 组件能够在 reactive.data 变动时自动刷新,为此我们需要稍微了解 formily/reactivereact 一些基础性原理

reactive 响应式原理

当我们使用响应式工具的 API 创建一个状态对象时,默认会拦截对象的 setget。在 formily/reactive 中,正是通过 Proxy 来实现劫持的。

当某个组件渲染时使用到了 formily/reactive 创建的状态时,会触发劫持后的 get 函数,然后就把当前的组件放置到该状态的 「依赖筐」里,这样当这个状态发生改变时,也会触发劫持后的 set 函数,在函数中只需要把「依赖筐」里的组件取出依次渲染就行了。

如此看来,formily/reactive 已经帮我们做好大部分的工作了,我们只需要处理这个「依赖筐」即可。

另外,我们往「依赖筐」里放置的实体只需要是个能让组件刷新的函数即可,这里我们可以直接使用 ahooksuseUpdate 返回的函数。

最后我们还需要考虑最重要的一点,formily/reactive 如何获取到当前正在渲染的组件?这里我们通过源码得知 formily/reactive 在其内部 (formily/reactive/src/environment) 维护了一个变量 ReactionStack,当我们使用 observer 包裹组件时,会把能让组件刷新的 useForceUpdate(和 ahooksuseUpdate 类似) 函数 pushReactionStack 中,这样 formily/reactive 就能在 Proxy 劫持时通过访问 ReactionStack 获取当前正在渲染的组件。

Tips: ReactionStack 被设计成一个数组,是因为组件是会嵌套执行的,你可以把它想象成是一个栈,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架,里面有详细讲到具体原因,这里就不阐述了。

接下来,我们只需要通过 react 找到当前正在渲染的组件,然后把能让组件重新渲染的 useUpdate 函数 pushReactionStack 中就可以了,那么问题就抛给 react 了,我们如何得知当前正在渲染的组件?

react 的神奇属性

很遗憾的是,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,在这个子属性下面居然挂载了 react 所有 hooks 函数,更神奇的是在不同阶段,同一个 hooks 的实现居然不一样:

图片.png

图片.png

如图所示,useCallback 的实现居然是不一样的,为什么会出现这种情况的?

我们翻阅了 react 相关代码,在 react-reconciler 中有具体的实现逻辑,由于这块逻辑较多,这边不展开描述,只说下最终的结论。react 自身的 hooks 在组件渲染的不同阶段,其实现的函数也是不一样的,具体可以看下图:

yuque_diagram.png

这里我们找到几个关键点:

  • ContextOnlyDispatcher: 当组件渲染完成后,hooks 的实现便会指向这里。而 ContextOnlyDispatcher 下所有 hooks 的实现统一被赋值成了 throwInvalidHookError 函数。 函数内直接通过 throw Error 的形式告诉我们只能在组件或者 hooks 内使用 hooks
  • HooksDispatcherOnXXX: react 在不同环境 (开发环境、生产环境)、组价渲染不同阶段 (mountupdate) 下,对 hooks 的实现逻辑也是不一样的。这里我们不需要知道具体实现逻辑,只要了解会动态赋值对应的实现函数即可。比如 mountCallbackupdateCallback
  • HooksDispatcherOnMountWithHookTypesInDEV: 这里的目的是为了保证所有 hooks 在每次渲染时的执行顺序都是一致的,即 react 不希望我们在 if 这样的条件判断内使用 hooks。和我们本次的需求无关,暂且跳过
  • InvalidNestedHooksDispatcherOnXXX: 这里的目的主要是为了提醒我们不要在 hooks 内使用 hooks,和我们本次的需求无关,暂且跳过

通过上述分析我们可以得到一个结论,即 react 在未渲染、首次渲染、再次渲染阶段下, hooks 的实现是指向不同函数的,那么我们是否可以利用此特性来做些文章呢?

巧用 ReactCurrentDispatcher 和 Object.defineProperty

ReactCurrentDispatcherReactionStack 在上文中都已分析过了,接下来我们就要向我们的最终目标发起挑战了。

既然我们已经知道 ReactCurrentDispatcher 在组件渲染不同阶段的值不一样,我们是否可以通过劫持它的形式来感知组件渲染阶段呢?我们通过 Object.defineProperty 来尝试下

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() 的值就可以感知组件不同阶段了。我们继续补充一个获取当前组件阶段的函数 getDispatcherType:

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: 再次渲染

最后我们在 set 内分别获取 currentDispatchernextDispatcherdispatcherType,再横向比较即可:

// set 拦截器内
const currentDispatcherType = getDispatcherType(currentDispatcher);
const nextDispatcherType = getDispatcherType(nextDispatcher);

currentDispatcher = nextDispatcher;

if (currentDispatcherType === 'invalid' && ['mount', 'update'].includes(nextDispatcherType)) {
  // 进入组件开始渲染了
} else if (['mount', 'update'].includes(currentDispatcherType) && nextDispatcherType === 'invalid') {
  // 渲染完成离开组件了
}

完成和 formily 的对接

在上面的阶段,我们已经可以感知到【进入组件开始渲染】和【渲染完成离开组件】阶段了,接下来我们就要在这两个阶段内完成和 formily 的对接

在 [reactive 响应式原理] 章节中我们已经知道,最终需要把能让组件刷新的函数放到 ReactionStack 内。而这个函数可以用 ahooksuseUpdate 来实现,因此我们需要添加以下代码:

import { ReactionStack } from '@formily/reactive/esm/environment';

function _useWatcher() {
  const trackRef = React.useRef();
  const update = useUpdate();

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

// 进入组件开始渲染阶段调用 _useWatch
_useWatcher();

// 渲染完成离开组件阶段
ReactionStack.pop();

这样子,我们就可以完成对当前组件的响应式跟踪了

这里肯定会有小伙伴诧异了,为什么可以在组件或者 hooks 外使用 _useWatcher 内?其实具体的原因在上文讲述过了。react 会在组件渲染不同阶段给 hooks 赋值不同的实现函数,当你在组件或者 hooks 外使用 hooks 时会通过 throwInvalidHookError 函数抛出错误提示。但是此时我们拦截了 ReactCurrentDispatcher 属性,判断并在【进入组件开始渲染阶段】时才调用了 _useWatcher,所以才不会触发错误提示

结尾

我们通过一个简单 demo 的形式完成了一次去掉 observer 的尝试,但其实还有很多场景我们没有考虑到,包括但不限于:set 拦截器内的竞态问题处理、getDispatcherType 没有考虑服务端渲染以及 production 环境下的一些潜在差异、判断进入和离开组件渲染时没有考虑一些异常情况。

不建议大家在生产环境使用这个能力,毕竟用的都是框架的内部属性(没有直接暴露给开发者),最后用一个词语进行收尾:

DO_NOT_USE_OR_YOU_WILL_BE_FIRED

小茗推荐

最后

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享。