大家好,我是前端菜鸡木子
最近一段时间,公司里大范围的使用了 formily 表单架构,不得不说 formily + schema 为中后台表单提供了新的编码规范。但是在大量的实践过程中,我发现了一些小小的不爽,就是当你的组件依赖了 formily/reactive 声明的响应式状态时,组件的外层需要用 observer 包装下,有时忘记使用 observer 会产生页面状态没更新等一系列问题。本着 「看你不爽就干掉你」的原则,我就想有没有一种方式能够不用 observer 还能让组件自动刷新呢?
接下来就和大家一起干掉 observer
前言
本文适合对 formily 和 react 有一定使用经验的小伙伴。另外,不建议大家在生产环境中使用下面的代码。
关于 formily/reactive
formily/reactive 是一个响应式的状态管理工具,正因为它,formily 表单才能做到子组件级别的精准刷新
formily会在表单的每个子组件上套一层observer,observer相当于一个 「观察者」,它配合formily/reactive能够知道当前组件依赖了哪些表单字段,所以当表单字段更新时,也就能找到依赖它的具体子组件,这样字段变更时就能够做到精准刷新
antd的Form组件是全局刷新(无论哪个字段变了,整个表单组件都会刷新)
这样看起来,似乎使用 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_FIRED是react内部架构的桥梁,它做了两件事情:1、维护了
hooks的具体实现,我们在代码中使用到的各种react的hooks就是挂在这上面的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,在组件首次render和rerender时是指向不同的函数,内部实现上有差异,但不是本次要探讨的内容,我们只需要知道是指向不同的函数即可 - 在组价
update和rerender阶段:区别于mount阶段,指向不同的函数,函数内主要是通过比较deps来决定是否使用缓存。
既然我们已经知道 ReactCurrentDispatcher 下的 hooks 在组件不同阶段有不同的表现,那么我们可以利用它来感知渲染的流程了,真是柳暗花明又一村。
formily/reactive 响应式原理
这时需要返回到本次的核心诉求:「我们需要不通过 observer 就能知道哪些组件依赖了 formily/reactive 声明的响应式状态」,这里需要先了解下 formily/reactive 响应式的原理:
我们在使用
formily/rective的model方法时,其内部会通过Proxy劫持我们传入给model的对象,当我们在组件内使用这个对象的某个属性时,便会触发劫持后的get函数,这个时候formily会把让当前组件刷新的方法放置到这个对象对应key的依赖框筐里。后续当这个属性改变时,自动触发set函数,然后在set函数里只要把对应依赖筐里的方法取出来,挨个执行即可。
看来 formily/reactive 已经为我们做了大部分的事情了,我们只需要处理这个 依赖筐 逻辑就行,至于组件刷新,就直接用 ahooks 提供的 useUpdate 函数即可。
那么我们该如何把对应的函数放到 依赖框 里呢?这里还需要再深入看下 formily/reactive 实现。由于 js 是单线程的,所以同一时间只会有一个函数在执行(组件在 render ),我们只需要声明一个全局变量,这个变量就等于正在执行的函数(组件的 render)就行了。而 formily/reactive 也正是这么实现的,在 formily/reactive/src/environment 文件里就维护了这么一个变量 ReactionStack。也就是说我们只需要把能够刷新当前组件的方法 (ahooks 的 useUpdate) 赋值给 ReactionStack 就行了。
额外说明下,
ReactionStack其实是个数组,因为组件是会嵌套执行的,你可以把它想象成是一个栈,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架
巧用 Object.defineProperty 和 ReactCurrentDispatcher
到这里我们已经具备了所有的理论基础了(ReactCurrentDispatcher 和 formily/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 阶段通过读取 currentDispatcher 和 nextDispatcher 来感知组件的生命周期了,以 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,紧接着我们可以通过比较 currentDispatcher 和 nextDispatcher 的对应 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'] 其中之一时,同时 nextDispatcherType 为 invalid
最后我们只需要在进入渲染阶段,把能够让当前组件刷新的函数 (ahooks 的 useUpdate) 赋值给 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