大家好,我是前端菜鸡木子
最近一段时间,公司里大范围的使用了 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