学习 React Hooks
React Hook 或许是 React 中最有趣的玩意了,相较于隔壁 vue 的“竞品” setup,hook 的行为和普通函数的行为非常相像(我觉得这是它最棒的一点——对函数的心智模型可以被继续沿用),且更容易学习(vue 里一万个概念能够把你绕晕十次甚至九次),更容易避免引用泄漏相关的 bug。最近进行了很多学习,是时候该进行一个笔记的做了。
perspective
理解 Hook 的运行原理的关键在于:对于使用 Hook 的函数式组件,它的每一次执行都有着自己的 props 和 state,且对于每一次执行,它的 props 和 state 对这一趟而言都是常量。这意味着什么呢?这意味着我们在思考其运行机制时,可以使用所谓的代换模型——把 props 和 state 用它的值去替代。
容易发现,这和传递值类型给函数的行为是一致的
代换模型实际上只对 props,state 等生效,对于 setState,useRef,dispatch 等是无效的——它们在多次执行中保持的都是同一个引用,但我认为它仍有使用的价值。
考虑一个最经典的例子。
1 |
|
会发生什么呢?其实即使没学过 hook,只要学过闭包就能得到答案了——counter 将始终为 1,这是因为对于这一趟的执行,它看到的 counter 为常量 0,我们可以用代换模型去描述:
1 |
|
That’s it!这个组件在之后或许还会被渲染数次(代码中没体现),但这个 interval 函数看到的永远是这一趟的 counter,即 0。这个问题有数种解决方案,比如在依赖数组中加入 counter(这样 counter 每次改变时旧的 interval 会被销毁,新的计数器会被创建,看着有点怪,但确实有用),或者使用传递函数的 setState,即setCounter(counter => counter + 1)
,也可以使用 useRef(虽然它并没意义用于此,且会引入新的复杂度,因为 useRef 的值的改变不会自动触发重渲染)。
另外,对于 useEffect,不应当考虑其为类组件生命周期的模拟,而是认为它是一种将 props 和 state 和 DOM 之外的事物进行同步的手段。下面该挨个过堂。
setState 的函数式更新,即
setCounter(counter => counter + 1)
是同步的,这意味着可以通过某些方式把这时候的 counter 取出来,从而避免上面的问题:
1
2
3
4
5
let c
setCounter(counter => {
c = counter
return counter
})但在实践中绝不应该使用这个操作:它是“未定义”的,在不同的版本中可能会有不同的表现,这里只是随便提一嘴。
useState
useState 即创建一个状态,它其实并没有什么可说的。下面是一个示例,这里将 useState 的第一个返回值称为 state,第二个称为 setState。
1 |
|
useState 的行为是被明确说明了的:第一次渲染时,其会被赋予初始值即 0,之后通过 setCount 去修改它的值。
这里有一个有趣的地方在于,函数式组件的每一次渲染都是一次普通的函数调用,因此这里的useState<number>(0)
也只是一次普通的函数调用(其它 hook 亦然)。
既然是普通的函数调用,那它的计算模型当然和普通的函数一致——先计算参数值,再传给函数去求值,因此这里的 0 每次都会被求值。在这里这并非是个问题,但倘若求初始值是一个很昂贵的操作呢?比如从 localStorage 中取一个值?倘若仍旧按原来的写法,那每一次渲染都会去拿一次 localStorage。性能被无意义地损耗了。
React 显然知道这个问题,因此它允许通过一个函数去创建初始值,这个函数显然只会被 React 调用一次,这显然运用了惰性求值/传名调用(call by name)——随你怎么说——的模式:
1 |
|
useState 另一个需要关注的地方在于,state 在每一趟调用中是不同的——每一趟渲染中看到的 state 都是这一趟看到的,而 setState 在每一趟中都是相同的,因此 setState 不需要放在 useEffect 等的依赖数组中。
useEffect
useEffect,给予函数式组件制造副作用的能力。
useEffect 的行为和 useState 的很类似:每一趟渲染时,这一趟的 effect 看到的是这一趟的 props 和 state,这等于是说,每次渲染都有它自己的 Effects,考虑下面的代码:
1 |
|
在这 5 秒内,无论我们点击多少次按钮,最后输出的永远是 0,因为这一次 effect 的执行看到的仅是这一次的 count。
同步,而非生命周期
这篇文章的这一节《同步,而非生命周期》 我认为很好地诠释了对 Effect 的心智模型:React 根据把组件当前的 props 和 state 同步给 DOM,而 Effect 则是根据 props 和 state 同步 DOM 以外的东西。在这个心智模型中,不存在所谓的生命周期,只有 props 和 state 到特定事物的一个映射,我们关注目的,而非过程。容易类比,这也是声明式编程相较于命令式编程,react 相较于 jquery 的不同:关注要做什么(目的),而非如何做(过程)。
比如,我们可以把网页的 title 和组件的一个 state 去同步:
1 |
|
但这种同步并非是单向的,React 也需要接受来自 DOM(以及其他地方)的事件去更新 state,Effect(以及事件处理器)有时候也是依据 DOM 之外的其它东西去修改 state,比如 HTTP 请求,下面是来自于 这篇文章 的一个比较复杂的例子,它通过请求来更新 state。
1 |
|
这段代码用这里的心智模型怎么理解呢?我们关注目的,所以我们说我们将这个 http 接口(以及 query 这个状态)和 data 这个 state 进行同步。
显然,这种同步和这个接口的状态以及 query 状态相关,每当 query 改变,或者接口状态改变,data 这个 state 也应当进行改变;但后者显然是 react 监视不到的,或者说,后者的变化无法触发组件的重新渲染(似乎只有 setState,setReducer 以及某些不可名状的其它情况会触发重新渲染)。
这里会有另一个有趣的问题——从远程获取数据时,是在事件监听器去直接获取,还是去修改相应状态,在 useEffect 中去获取?显然后者是更加接近这里的心智模型的,但究竟孰优孰劣?亦或是两者都有适用场景?
关于清理函数
要理解清理函数的行为,必须理解 Effect 的执行流程。Effect 在每次渲染之后执行,在这一次的 Effect 执行之前,会先执行上一次 Effect 的清理函数,顺序为:
- 渲染完成
- 上一次的清理函数执行
- 这一次的 Effect 执行
这里说的是“次”而非“趟”——只有这个Effect确实执行了才算数,比如下面这个 Effect 的清理函数只在组件 unmount 时才执行
1 |
|
比如下面这个 Effect 的清理函数只在重渲染且 data 这个变量改变时才执行:
1 |
|
考虑这样一个场景,组件有一个 id prop,我们希望用这个 id 去订阅某个聊天室,每次 id 改变时,我们都希望取消之前的订阅,再订阅新的聊天室,这个怎么实现呢?这个问题来自于 React FAQ,这篇文章 也有描述。
实际上什么都不需要做:
1 |
|
这仍旧可以用代换模型来解决,比如 id 从 10 变成 20,两次的代码是这样:
1 |
|
上一趟的清理函数看到的是上一趟的 id,这一趟看到的是这一趟的 id。
这里有另一个有趣的场景,假设在某个 Effect 里执行某个异步操作,而且这个 Effect 连续执行了两次,而且旧的请求到达的更晚……如果没有任何操作的话,则新的请求的结果会被旧的请求的结果去覆盖掉,这不是我们想要的,为此我们要么去在执行新的请求的时候销毁旧的请求,要么让它“失能”,后者的操作更加通用,可以归结为一个模式:
1 |
|
这个 Effect 若执行了下一次,则这一次的清理函数会被调用,ignore 会设置为 true,因此这个请求的结果会被忽略。
关于依赖数组
useEffect 以及其它 hook 的依赖数组也是非常有趣的部分,它汗 useCallback,useMemo 等都可以认为都是对上面所说的同步的优化。
依赖数组的原理十分简单——每次重新渲染的时候,React 会比较当前依赖数组中的值和上一次的值是否有修改(使用Object.is
,行为类似===
),若有修改才去触发 Effect。
React 的官方文档以及其它文章都会警告你,说你应当在依赖数组中包含你依赖的所有 props,state 以及其它可能会有修改的量(包括在函数组件顶级作用域中定义的变量,函数),否则这是不安全的,这在某些情景下会让人感觉很奇怪,但其实他们的意思是说,它的行为将可能会和你的预期不符。
这里相关的具体细节还是直接看相关的 文章 更合适,但无论如何,持续维护依赖数组(并且安装 react 提供的 lint 插件)总是个好主意。
useReducer
倘若对一个状态的更新的逻辑依赖其他状态的值,这可能就是用 useReducer 的时候了。useReducer 以纯函数的形式统一了更新一系列状态的接口,并枚举出所有对这一系列状态所做的动作(action),以方便管理和维护复杂状态。
在 Typescript 中使用 reducer 是非常舒服的,下面是一个简单的实例,其中 Action 中的 FIELD 事件表示对特定状态进行更新,这个操作过于通用,因此不应当使用(倘若某个 reducer 提供这个 action 还能正常作用,那估计根本没必要去使用 reducer):
1 |
|
顺带一提,useReducer 还能这么玩:
1 |
|
我对 useReducer 了解不多,就这样了。十分好奇为何 useReducer 的初始化函数放到第三个参数,没有和 useState 去统一。
useCallback & useMemo
有时候,组件的状态粒度过细,无法直接利用,我们就会尝试编写函数和定义变量来先进行一些操作,比如下面的实例:
1 |
|
第一印象还好,但有如下几个问题:
- 它们每次重新渲染时都会被执行,而执行过程可能是昂贵的
- 倘若定义的是复杂对象或者函数,则它们每次执行时都会改变(使用 Object.is 不相等),因此无法加到依赖数组中
为此,React 提供了 useMemo 和 useCallback 两个 hook 来解决此问题。
useMemo 的性质就像 vue 的计算属性(但是是只读的),它会缓存计算值,并只在它的依赖有改变的时候重新计算,未重新计算时,拿到的值总是同一个,即用 Object.is 能比较相等。
1 |
|
useCallback 不会缓存计算值,而是缓存计算的函数,以保证在依赖项未改变时仍旧为同一个函数,或者说使函数本身只在需要的时候才改变。若想在 Effect 中去引用定义在组件顶级作用域的函数且不违背依赖数组,必须使用 useCallback,见 这里。
1 |
|
useRef & useImperactiveHandle
ref 在 React 中有两种功能:
- 用于操作 DOM 元素
- 子组件暴露给父组件操作/值的方式,常用于自定义不受管组件时
函数式组件中则有第三种功能:
- 实现函数式组件的“实例变量”——对于每一次渲染,其值都引用同一个对象,这使历史的渲染也能够影响当前的该对象
操作 DOM 是我们都比较熟悉的,想要暴露给父组件操作的话子组件需要使用 forwardRef 函数(HOC?)包装,并且使用 useImperativeHandle 去注册操作/值,下面是一个简单的示例,子组件 Counter 暴露给父组件操作以清空它的值:
1 |
|
参考资料
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!