useRef 原理
# 目录
# useRef 定义
首先我们来看一下 useRef 在 React 中的定义代码 (react package):
export function useRef<T>(initialValue: T): {current: T} {
const dispatcher = resolveDispatcher();
return dispatcher.useRef(initialValue);
}
2
3
4
可以看出如下信息:
- 接受初始值 initialValue,返回值携带在 current 上。
- useRef 接受 dispatcher 的调度,在不同的环境可能有不同的实现。
下面来看一下 useRef 在 dispatcher 上是如何实现的:
在 HooksDispatcherOnMount 中 useRef 的实现为 mountRef,在 HooksDispatcherOnUpdate 中 useRef 实现为 updateRef。看来大多 hook 的实现模式与此类似。
# mountRef: useRef on mount phrase
mountRef 的实现很简单,基本上只是做初始化的工作。
function mountRef<T>(initialValue: T): {current: T} {
// 获取当前正在执行的 hook
const hook = mountWorkInProgressHook();
const ref = {current: initialValue};
if (__DEV__) {
Object.seal(ref);
}
// 初始化 current 值到 hook
hook.memoizedState = ref;
return ref;
}
2
3
4
5
6
7
8
9
10
11
# updateRef: useRef on update phrase
在更新阶段,只需将缓存的值取出即可,缓存的值存在 memoizedState 中。
function updateRef<T>(initialValue: T): {current: T} {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
2
3
4
# Q&A
看到这里,可能有以下几个问题:
- 既然 useRef 只是在 render 过程中去缓存值,那么完全可以将之以变量的方式定义在组件前面,那个他存在的意义是什么?两者又有什么区别?
首先,我们需要知道的是,useRef 其实是解决了 useState 闭包陷阱的问题。useState 一定能够更新值,但是有一种特例会使代码得不到 useState 更新后的值,那就是闭包环境,这种特例叫做闭包陷阱。
这种现象的产生主要与 useState 更新 primitive value 有关,而更新 object 则不存在这种问题。主要原因是 object 是存在堆中的,变量的保存的只是 object 的引用,而 primitive value 则不同。
闭包陷阱是如何产生的呢?请看下面的代码。
function App(){
const [count, setCount] = useState(1);
useEffect(()=>{
setInterval(()=>{
console.log(count)
}, 1000)
}, [])
}
2
3
4
5
6
7
8
在这种情况下,无论 setCount 怎么执行,打印出的 count 值都是 1。我们来分析下程序执行的过程:
首先,在 mount 阶段程序执行到 useState 会将 count 的初始值设置为 1,然后执行到 useEffect,则设置定时器。由于 useEffect 的依赖数组为 [],只会在 mount 时执行一次。然后通过应用的某些操作触发 setCount,count 的改变,因此在非闭包的环境下,count 的值更新无误,然而在定时器中由于形成的闭包环境,会记录 count 的值为 1,没有感知的 count 值的变化。如果此处将 count 写成对象的方式,在 setCount 时使用 Object.merge 不改变对象的引用,则即使在对象中也能感知到 count 的变化。
那么闭包环境是如何形成的呢?
Closures (opens new window) from MDN:
A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function’s scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
核心理解
useRef 是如何巧妙避免闭包陷阱的呢?原理正在 object 在变量中只保存引用,因此 useRef 正是在 React 内部维持了 {current: value}
的对象,我们在使用 current 中的值或者是给 current 赋值时,都不会导致包裹 current 的外层对象的引用变化,这就保证了外层的包括对象永远只存在内存的统一地方,而 current 作为引用永远会指向我们赋给 current 的任何值。
回到主题,避免闭包陷阱有两种方式,一种是使用 useRef,另外一种就是使用组件外的变量。useRef 能避免闭包陷阱的原因上述已经解释清楚了,那么组件外的变量又为什么能解决这种问题呢?我们知道 FC 本质是函数,React 正在是靠执行 FC 来完成 render 过程的(从 renderWithHook 函数中 children = Component(props, refOrContext);
可以看出这一点)。我们可以把 React 的 render 过程看成是视频播放的帧。既然 React 是执行 FC 达到 render 的目的,而组件外层的变量则不会在 render 的过程中被反复执行,因此这些变量只执行一次,确实是可以达到缓存变量的目的。
核心理解
但是需要注意的是,useRef 的不可替代性正是体现在下面的两点:
- useRef 的缓存作用,且不会引起 re-render;
- useRef 是与组件实例挂钩的,不同组件实例中 useRef 互不干扰。
我们知道,组件存在的最大目的就可复用性。组件从面向对象的层面思考就是一个对象,而组件的引用可以理解为一个对象实例。相同对象中不同实例之间的变量和函数互不影响。实际上,这正是由闭包机制形成的。
- 为什么要把缓存的结构设置为
{current: value}
的结构?这和 vue3 中 ref () 的结构类似,两者有什么异同?
从上一问题中,我们已经知道 useRef 解决闭包陷阱的关键就在于其对象结构,因此这里使用 primitive value 这种结构是绝对不可以的。因此写 current 也是十分必要的。
至于 vue3 中 ref 需要使用 .value 也是类似的原因。虽然 Proxy API 支持 object 属性的 get、set 的监听,但是 ref 其实并不是 Proxy 实现的,通过源码可知,ref 是以 class 实现的 ref 对象,并且自定义拦截了 get 和 set 方法。因此 ref 一方面要保证 ref 对象的地址不会变化,以供我们随时的引用,同时提供了对 ref.value
属性做 get、set 的监听。 value
属性的 get 操作会被 track,其 set 操作则会被 trigger。相比于 React 中 useRef,vue3 中的 ref 通过实现对 .value 属性的 track 和 trigger 以实现其响应式。