扯皮
最近在业务中遇到了这样的一个需求,在全局 store 中存储了一个 ref 变量,在任意组件中会修改这里的 ref 变量,而在其他组件中可能依赖这里的 ref 变化产生一些副作用操作🤔
至于为什么一开始不使用 state 而是 ref 是因为它在功能上不做视图上的渲染只是对业务逻辑产生影响,所以没必要用,后来想了想其实这种场景更好的解法应该使用事件总线
那为什么还要写这篇小短文呢?是因为我想起很早之前读 ahooks 源码的时候发现它的内部还有这样的方法:useEffectWithTarget
,它属于一个内置的工具 hook 没有在文档里介绍,但在 DOM 篇的 hooks 中被广泛使用,但是跟我上面描述的业务场景还是有很大差别的,所以这次我们一并来研究一下🧐
正文
Why
首先简单来讲讲为什么 useEffect 不能将 ref 作为依赖项,其实也并非不能,你这样设置也没人拦你,而是可能达不到我们想要的效果,eslint 已经给提示出来了:
所以就像这样的 demo,我们照着跟 state 一样使用就没有任何效果:
本质上就是 ref 和 state 的区别,而且一般我们也不会将 ref 值渲染在视图上,它通常就是用来控制内部逻辑或者获取 DOM/组件方法用的:
其实根本不需要去了解它的底层原理,我们知道函数组件的 state 更新能够做到更新视图的本质就是函数重新执行后取得最新的 state,再将其进行渲染
很明显 ref 的更新并不会导致函数组件的重新执行,所以要想实现这样的功能你可能需要补充一些逻辑来让它强制渲染一下:
现在你会发现你的视图更新了,useEffect 中的回调也执行了,就是这么简单:
但正常开发真有上面的场景还用个毛 ref 啊,直接 state 一把刷了😅,而且这样的写法也并没有满足文章一开始提到的业务场景,因为我的 ref 是在全局 store 中的,这里的 update 只聚焦在当前组件,很明显不合适
useEffectWithRef + useProxyRef
其实我们可以先跳出 React 这个背景来思考这个业务场景,这样就简单的多了
ref 本质上就是一个普通的 JS 对象带一个 current 属性,我现在想要实现的功能是当修改这里的 current 属性时需要执行对应的副作用函数
这样一描述就对味了,这不就是 Vue 的响应式数据么🤣,在 React 里套用 Vue 那一套还挺有意思的,所以我们要做的工作就两个:
- 依赖收集
- 修改时触发收集的副作用函数
与 Vue 不同的是依赖收集的时机不再是 get 访问了,应该提供一种注册机制来收集,既然用在了 React 里,就封装自定义 hook 了:
先来看第一个 hook:useProxyRef
,它的作用其实就是基于 ref 创建响应式数据,劫持用户修改 current 操作,触发收集的副作用函数,这里我们用一个全局的 Map 来存储这些副作用函数吧:
这里有两个细节要注意一下:
为什么要把 useRef 初始为 null,并且进行判断之后再创建 Proxy?可以参考 React 文档中这部分的介绍:
其次注意我们返回的对象是 proxyRef.current而不是 proxyRef,Map 中存储的也是这里的 Proxy 对象
之后就是 useEffectWithRef
,我们尽量让它的 API 和 useEffect 类似,与 useEffect 不同的是它只是用来收集副作用函数的:
需要注意我们使用了 useMemoizedFn,因为考虑到每次组件重新渲染时这里传入的 effect 函数都是一个新的,那每次都会被添加到 Set 里面,而 useMemoizedFn 能够很好的解决这个问题
现在我们来写个 Demo 试一下,效果看着还是不错的:
但是还不够完美,像 useEffect 中的回调是能取到最新 state 的,但我们的 ref 还做不到,它拿到的都是上一次的 ref 值:
这是因为在 Proxy 代理触发副作用函数是在 set 之前,所以没法拿到最新值,但我们可以改造一下,比如在 useProxyRef
这里给它传进去:
那相应的 useEffectWithRef
也要做出对应的修改,针对于第二个依赖项参数就不再设置成数组了,只监听一个 ref 即可,这样类型提示也友好一些:
我们再写一个稍微有些组件层级的 demo 看看效果,非常不错:
实际上我们真实项目里也没有这样实践,真要考虑使用的话还是需要补充一些细节的,这只是自己周末在家无聊操练一下🤪,所以这两个 hook 仅供娱乐:
import { useMemoizedFn } from "ahooks";
import { useRef } from "react";
const effectMap = new Map<{ current: any }, Set<(value: any) => void>>();
export function useEffectWithRef<T>(effect: (value: T) => void, ref: { current: T }) {
const effectFn = useMemoizedFn(effect);
if (effectMap.has(ref)) {
const setFn = effectMap.get(ref)!;
setFn.add(effectFn);
effectMap.set(ref, new Set(setFn));
} else {
effectMap.set(ref, new Set([effectFn]));
}
}
export function useProxyRef<T>(value: T) {
const proxyRef = useRef<null | { current: T }>(null);
if (proxyRef.current === null) {
proxyRef.current = new Proxy(
{ current: value },
{
set(target, key, value) {
if (key === "current") {
effectMap.get(proxyRef.current!)?.forEach((effect) => effect(value));
target[key] = value;
}
return true;
},
}
);
}
return proxyRef.current as { current: T };
}
关于 useEffectWithTarget
其实上面的实现的灵感也是来自于 ahooks 这里的 useEffectWithTarget
,这个 hook 的应用可就太多了,比如我这里查找出来的就有这些 hooks 有用到:
就拿我们比较熟悉的 useEventListener
来举例,它的第三个参数配置项通常会配置 target 来指定要监听的 DOM 元素,这里既可以直接获取 DOM 传入,也可以传入 ref 值:
关键在于要考虑到如果这里的 target 在一个组件周期内发生变化应该怎么办?需要进行两个操作:
- 把之前 target 监听的事件全部移除
- 给新的 target 添加事件
这两个操作其实正好对应着 useEffect 的执行过程,只不过这里相当于我要监听 ref 的变化,所以内部才会封装一个 useEffectWithTarget
进行使用,直接来粘源码来看:
整体来看还是比较清晰的,从入参来看与 useEffect 相比只是多了第三个参数 target 配置,而内部主要使用普通的 useEffect 且不设置依赖项,这样每次重新渲染都会重新执行副作用函数
关键在于这里的副作用函数从外部传入,所以它是可控的,也就是说我们可以手动进行依赖项比较,来决定是否执行副作用函数
所以需要额外的 ref 变量来存储"上一次"的变量值,之后两者再使用 depsAreSame 进行比较,内部也是简单粗暴的使用了 Object.is:
可以看到为了模拟 useEffect 完整执行流程还要考虑卸载功能,也是用了额外的 unLoadRef 保存副作用函数的返回值,之后每次执行时先执行上一次的销毁函数 unLoadRef,再执行新的副作用函数
最终组件卸载时还补充了 unLoadRef 的执行,还是蛮细节的
End
以上就是整篇文章全部内容了,虽然最终也没有将这俩 hook 应用到项目里,但练练手还是不错的,实现的都不难但是如果是 React 刚入门的话还是有些细节的
这里还是推荐入门多看看 ahooks 源码,记得我从入职第一天就开始看了,确实能学到不少东西,但是懒癌发作一直拖到现在还没看完🙃,后面一定抽时间把全部的 hook 过一遍