作者:李滨
背景
用过 React
的同学对状态管理工具应该都不会太陌生,redux
、mobx
这些老牌的状态管理工具多多少少都应该使用过或者了解过一些。最近一段时间,我们也在项目中大量使用了 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
来描述下我们最终想要的效果
jsx
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
目前是达不到效果的:
无论我们如何点击 button
按钮,上面的数字都没有累加,这是因为 reactiveData.data
数字虽然变动了,但是 App
组件没有重新渲染。
我们的核心目的就是让 App
组件能够在 reactive.data
变动时自动刷新,为此我们需要稍微了解 formily/reactive
和 react
一些基础性原理
reactive 响应式原理
当我们使用响应式工具的 API
创建一个状态对象时,默认会拦截对象的 set
和 get
。在 formily/reactive
中,正是通过 Proxy
来实现劫持的。
当某个组件渲染时使用到了
formily/reactive
创建的状态时,会触发劫持后的get
函数,然后就把当前的组件放置到该状态的 「依赖筐」里,这样当这个状态发生改变时,也会触发劫持后的set
函数,在函数中只需要把「依赖筐」里的组件取出依次渲染就行了。
如此看来,formily/reactive
已经帮我们做好大部分的工作了,我们只需要处理这个「依赖筐」即可。
另外,我们往「依赖筐」里放置的实体只需要是个能让组件刷新的函数即可,这里我们可以直接使用 ahooks
的 useUpdate
返回的函数。
最后我们还需要考虑最重要的一点,formily/reactive
如何获取到当前正在渲染的组件?这里我们通过源码得知 formily/reactive
在其内部 (formily/reactive/src/environment
) 维护了一个变量 ReactionStack
,当我们使用 observer
包裹组件时,会把能让组件刷新的 useForceUpdate
(和 ahooks
的 useUpdate
类似) 函数 push
到 ReactionStack
中,这样 formily/reactive
就能在 Proxy
劫持时通过访问 ReactionStack
获取当前正在渲染的组件。
Tips:
ReactionStack
被设计成一个数组,是因为组件是会嵌套执行的,你可以把它想象成是一个栈,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架,里面有详细讲到具体原因,这里就不阐述了。
接下来,我们只需要通过 react
找到当前正在渲染的组件,然后把能让组件重新渲染的 useUpdate
函数 push
到 ReactionStack
中就可以了,那么问题就抛给 react
了,我们如何得知当前正在渲染的组件?
react 的神奇属性
很遗憾的是,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
,在这个子属性下面居然挂载了 react
所有 hooks
函数,更神奇的是在不同阶段,同一个 hooks
的实现居然不一样:
如图所示,useCallback
的实现居然是不一样的,为什么会出现这种情况的?
我们翻阅了 react
相关代码,在 react-reconciler 中有具体的实现逻辑,由于这块逻辑较多,这边不展开描述,只说下最终的结论。react
自身的 hooks
在组件渲染的不同阶段,其实现的函数也是不一样的,具体可以看下图:
这里我们找到几个关键点:
- ContextOnlyDispatcher: 当组件渲染完成后,
hooks
的实现便会指向这里。而ContextOnlyDispatcher
下所有hooks
的实现统一被赋值成了throwInvalidHookError
函数。 函数内直接通过throw Error
的形式告诉我们只能在组件或者hooks
内使用hooks
- HooksDispatcherOnXXX:
react
在不同环境 (开发环境、生产环境)、组价渲染不同阶段 (mount
、update
) 下,对hooks
的实现逻辑也是不一样的。这里我们不需要知道具体实现逻辑,只要了解会动态赋值对应的实现函数即可。比如mountCallback
和updateCallback
, - HooksDispatcherOnMountWithHookTypesInDEV: 这里的目的是为了保证所有
hooks
在每次渲染时的执行顺序都是一致的,即react
不希望我们在if
这样的条件判断内使用hooks
。和我们本次的需求无关,暂且跳过 - InvalidNestedHooksDispatcherOnXXX: 这里的目的主要是为了提醒我们不要在
hooks
内使用hooks
,和我们本次的需求无关,暂且跳过
通过上述分析我们可以得到一个结论,即 react
在未渲染、首次渲染、再次渲染阶段下, hooks
的实现是指向不同函数的,那么我们是否可以利用此特性来做些文章呢?
巧用 ReactCurrentDispatcher 和 Object.defineProperty
ReactCurrentDispatcher
和 ReactionStack
在上文中都已分析过了,接下来我们就要向我们的最终目标发起挑战了。
既然我们已经知道 ReactCurrentDispatcher
在组件渲染不同阶段的值不一样,我们是否可以通过劫持它的形式来感知组件渲染阶段呢?我们通过 Object.defineProperty
来尝试下
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()
的值就可以感知组件不同阶段了。我们继续补充一个获取当前组件阶段的函数 getDispatcherType
:
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
: 再次渲染
最后我们在 set
内分别获取 currentDispatcher
和 nextDispatcher
的 dispatcherType
,再横向比较即可:
js
// 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
内。而这个函数可以用 ahooks
的 useUpdate
来实现,因此我们需要添加以下代码:
js
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前端团队」,获取更多干货实践,欢迎交流分享。