作者:来对鸡翅谢谢
文章作者授权本账号发布,未经允许请勿转载
本篇文章主要从 React 源码的角度探索底层 Hooks 系统的设计,主要探索方向为 Hooks 的底层运行机制以及 React 是如何处理 Hooks的,探索过程中也会解释一些 Hooks 的使用误区和可能会遇到的坑。源码分析对应 React 16.13版本。
一、Hooks 简介
从概念上讲,Hooks
是 React 16.8
才全面支持的一个新特性,全面拥抱函数,能够使开发者在不使用 Class
的前提下使用状态和 React
其他的功能,从此函数式组件的地位直线飙升,不再仅仅是纯函数。
从写法上讲,Hooks
是一堆名为 useXXX
的函数,且这些函数不可动态调用。
优点
关于 Hooks
的优点官网阐述的很详细,体现为以下几点:
- 抽离公共逻辑。使用自定义
Hook
代替传统的HOC
、renderProps
等,自定义Hook
可以理解为通用函数,但是区别于普通通用函数的是它可以帮组件维护一些状态,而不是简单的接收输入返回输出,带来的额外收益是组件的嵌套结构非常的清晰,避免了大量的HOC
带来的嵌套地狱。 - 轻量,代码量少。相对于夹杂一堆生命周期的
Class
组件,函数式组件有着天然的优势,组件变的很轻量,更容易抽离出细小通用的组件,同时,代码中没有任何this
指向的干扰
缺点
事情是两面性的,Hooks
也存在一些缺点,主要体现为以下两点:
- 旧状态。轻量级的函数带来的一个显著问题是作用域闭包的问题,也就是
Hooks
的deps
问题,需要开发者把握需要添加的依赖项,把握不准确的话会导致我们获取到的是旧的状态,盲目添加eslint
提示的所有的依赖也是不可取的,会增加维护成本,也会导致代码不直观。 useEffect
的执行时机。对于没有深入了解过React
底层运行机制的开发者,往往会默认为是异步的,会在render
后执行,对其具体的运行时机把握并不准确。
建议
-
不滥用"记忆性"的
Hooks
。典型的代表就是useMemo
useCallback
,主要有两方面需要注意:- 仔细思考依赖。在使用这两种类型
Hooks
时,依赖问题尤其重要,往往少几个依赖,就会导致在计算时或者执行记忆函数时拿到旧的值。 - 性能优化是有代价的。如果计算不复杂,或者不需要将函数当成
props
传递给其他的组件的话,往往是没有任何必要去使用的,因为执行Hooks
本身需要一定的消耗,用和不用之间相差的消耗完全可以忽略不计,并且实际代码是被Hooks
包裹起来的,本身就会不直观。
该怎么使用?
-
计算耗时较长,请使用
useMemo
并仔细思量依赖问题,且如果依赖项数组为空,不如使用useRef
代替。// 依赖为空的话没有必要使用 `useMemo` const computed = useRef(data.map(item => item)).current;
-
函数需要作为
props
传递给其他的组件 且 组件的渲染成本较高,一定要使用useCallback
包裹。如果函数只是在当前组件内定义且在当前组件消费,而不是被当成一个props
,没有任何必要使用,其中相差的性能完全可以忽略不计。在需要传递给其他组件使用且接收函数的组件渲染成本不高,是否使用取决于开发者,使用的话代码少一点直观,但是性能会好一点。
- 仔细思考依赖。在使用这两种类型
-
组合
state
。产生这个问题的原因是在异步回调中调用setState
不会批处理,常见的场景为表格组件,我们在得到数据请求的响应后,往往会关闭表格的加载态、设置数据源、设置数据总数以便于分页。而这几个数据如果分散存储的话,就会导致表格组件render
3 次。当然拆分状态会导致组件逻辑非常的清晰,但是也需要考虑在异步回调里的setState
,就像上述表格的场景就应该将表格的相关状态合为一个state
。 -
useEffect
空依赖不是真正的componentDidMount
。useEffect
是开发者经常使用的一个Hooks
,我们往往会习惯使用其去模拟componentDidMount
生命周期,但是需要谨记,useEffect
并不能模拟,只是大多数情况下,它的执行时机看上去像是didMount
,在和Class
组件混合使用且需要父子组件在初始化时进行通信的情况下尤其需要注意。至于useEffect
真正的执行时机,后续会进行介绍。
二、如何处理Hooks
在说 React
如何处理前,我们首先需要知道 React
是怎么区分函数式组件和 Class
组件的。之所以会有这个问题是因为 babel
将 class
语法转换为 es5
时,得到的也是一个 Function
,只不过这个函数等价于 Class
的 constructor
。在这个前提下, React
区分的方法就很简单了。
// ...
const value = Component(props, secondArgs);
if (
typeof value === 'object' &&
value !== null &&
typeof value.render === 'function' &&
// $$typeof 是 ReactElement 对象独有的属性,React会调用 createElement 方法为每一个DOM,每一个文本创建一个 ReactElement,而 $$typeof 标记着这是一个 React元素
value.$$typeof === undefined
) {
// ClassComponent
} else {
// FunctionComponent
}
// ...
对于 Class
组件得到的 value
是组件实例,通俗点讲就是构造函数的 this
指针,其原型链上必定有一个 render
函数,对于函数式组件而言,返回值就有很多的可能性了,可能是一个 ReactElement
,也可能是一个字符串。 React
就是通过上述的几个判断来判定该组件是哪种类型的组件。接下来,我们来看一下 React
是如何处理 Hooks
的。
Dispatcher
在 React
内部会维护一个名为 Dispatcher
的对象,这是所有 Hooks API
的调度中心,每当 React
执行到 Hooks API
的时候,都会解析当前的调度对象,之后使用当前调度对象内部对应的 Hooks API,
因此也可以理解为这是调用 Hooks
的入口。
也正是因为这一设计, React
才可以更加优雅的检测 Hooks
是否在函数式组件外使用以及是否嵌套使用, Dispatcher
会随着 React
不同的执行环境被赋予不同的值 ,分为如下几种:
ContextOnlyDispatcher
:大多数情况下都是这种,用来检测 Hooks 的执行环境是不是函数式组件内,当调用ContextOnlyDispatcher.useXXX
时,React
就会抛出一个异常。HooksDispatcherOnMount
:用于挂载阶段HooksDipatcherOnUpdate
:用于更新阶段HooksDispatcherOnRerender
:用于 rerender 阶段
在开发阶段,还有一种特殊的 Dispatcher
, InvalidNestedHooksDispatcherOnMountInDEV
,命名中的 onMount
对应着不同的阶段,和生产环境的三种阶段保持一致,这种类型的调度器专用于检测 Hooks
是否嵌套使用。
Hook
当解析到当前的调度器后, React
才开始执行 Hooks
内部的逻辑,挂载阶段时,每一个 Hooks API
内部都会首先创建一个 hook
对象,而一些状态、缓存函数、副作用都会绑定在这个 hook
对象上来实现组件状态的存储,这边以 useMemo
为例。
function mountMemo(nextCreate, deps) {
// 创建一个 hook
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// 生成计算值
const nextValue = nextCreate();
// 缓存当前计算值及依赖
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
显然, React
有一个专门的函数来生成 hook
,名为 mountWorkInProgressHook
。
function mountWorkInProgressHook() {
const hook = {
// 用于存储 `state` 、计算值及依赖、缓存函数、Effect对象等。
memoizedState: null,
// useState 用到的更新队列、缓存批处理的state等
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
// 逐个挂载到 hook 链表上
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
一个 hook
对象的结构非常的简单,从 next
属性就可以看出每次都会生成一个 hook
都会挂载到一个链表上,而光有一个孤独的链表是无法生存的,它还需要 Fiber
的支持, React
会为每一个组件生成一个 Fiber
,我们这里的 currentlyRenderingFiber
就标记着当前正在渲染的组件,而Fiber.memoizedState
就保存了 hook
链表的头节点。
在更新阶段时, React
不会再去生成 hook
,为维护一个指针从当前组件保存的 hook
链表中依次取出 hook
节点。
Tips:为什么不可以改变 hooks
调用顺序?
我们来看一个 base case
来看一下改变 hooks
的调用顺序会导致什么样的问题。
// bad case
const App = () => {
const [count, setCount] = useState(0);
if (count === 0) {
useEffect(() => { console.log('effect2'); }, [count]);
}
const hasCount = useMemo(() => count > 0, [count])
return (...)
}
这是一个简单的组件,经过挂载后,我们可以得到一个 hook
链表,并且其挂载在当前 Fiber.memoizedState
当 count
变化且不为 0 时, App
组件需要进入更新阶段,当前 Dispatcher
赋值为 HooksDipatcherOnUpdate
,当执行到第 3 行时, React
执行 HooksDipatcherOnUpdate.useState
,会从当前挂载的 hooks
链表中取出第一个节点并执行 updateState
逻辑,同时 workInProgressHook
指针下移 ,至此是没有任何问题的,因为当前 count
不为 0,所以跳过 5-7行,当执行到第 9 行时,执行 HooksDipatcherOnUpdate.useState
,这时取出来的 hook
是 Effect Hook
,并使用该 hook
对象执行 updateMemo
的逻辑,这显然是不对的, memoHook.memoizedState
存储的是一个数组,而 effectHooks.memoizedState
存储的是一个 effect
对象,下面为 updateMemo
的处理逻辑。
function updateMemo<T>(nextCreate, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// memoHook 中的 memoizedState 保留了上一次的计算值及上一次的依赖,并且这是一个数组。[prevValue, prevDeps]
const prevState = hook.memoizedState;
// 1. 比较依赖,依赖没变的话,直接返回上一次的计算值
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
// 2. 依赖变化的话,执行 create 重新生成计算值,并将新的值及依赖存储在 memoizedState 上
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
Effect
在处理 Hooks API
时,有两个 hooks
比较特殊, useEffect
以及 useLayoutEffect
,专用于处理副作用的 hooks
,在执行副作用 hooks
时, React
会额外创建一个 effect
对象。
// 生成一个 effect 并挂载到 effect 链表的末尾
function pushEffect(tag, create, destroy, deps) {
const effect = {
tag,
create,
destroy,
deps,
// Circular
next: (null: any),
};
// currentlyRenderingFiber.updateQueue.lastEffect 永远指向最后一个 effect 对象,
let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
// 将 updateQueue 整理成 { lastEffect: null } 的结构
if (componentUpdateQueue === null) {
componentUpdateQueue = { lastEffect: null };
currentlyRenderingFiber.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// 单向循环链表新增一个节点
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
简单分析一下这段代码,我们可以了解到如下几点:
-
effect
的结构,属性不多,值得关注的是tag
属性,该属性不仅仅标记着effect
的类型,也标记着effect
是否需要被刷新,这里的被刷新指的是是否需要执行create
也就是我们传入的第一个参数。effect.tag
取值大致分为如下几种:export const NoEffect = /* */ 0b000; // Represents whether effect should fire. export const HasEffect = /* */ 0b001; // 1 // useLayoutEffect 生成的 effect 被称为 Layout export const Layout = /* */ 0b010; // 2 // useEffect 生成的 effect 被称为 Passive,因为 useEffect 常常是说成被动刷新,主动setState,被动刷新 effect export const Passive = /* */ 0b100; // 4 // 如果一个 LayoutEffect 需要被刷新,那么生成的 effect.tag = HasEffect | Layout // PassiveEffect 同理 // React 在遍历 effect 链表时,就通过 effect.tag === HasEffect | Layout 来判定当前 effect 是否需要被刷新
-
单个组件内生成的所有
effect
会形成一个单向循环链表并关联在当前Fiber
上,Fiber
上具有一个updateQueue
属性,对于函数式组件而言,该属性内部会维护一个lastEffect
变量,该变量永远指向最后一个effect
。
但是 updateQueue
会在组件执行完调度后被清空掉,这意味着 effect
链表不仅仅是只挂载到 Fiber
上,它还需要存储在一个不会被清空的对象上,也就是我们上文说的 hook.memoizedState
字段。
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps) {
// 生成 hook
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// 标记 Fiber 具有副作用
currentlyRenderingFiber.effectTag |= fiberEffectTag;
// 将生成的 effect 挂载到 hook.memoizedState 上,用于更新阶段判断是否依赖发生变化
hook.memoizedState = pushEffect(
HookHasEffect | hookEffectTag,
create,
undefined,
nextDeps,
);
}
Tips:为什么是单向循环
有同学可能会好奇,为什么 effect
链表采用单向循环的结构,主要考虑两个逻辑,一个是新增节点一个是从头部开始遍历链表,单向不循环链表想要实现这两者需要维护两个变量,一个指向第一个节点来保证可以从头部遍历,也就是 fiber.memoizedState
,一个游标指向最后一个节点,也就是 workInProgresshook
,而单向循环链表实现只需要维护一个变量,也就是游标,始终指向最后一个节点,也就是 fiber.updateQueue.lastEffect
针对以上的描述, React
处理 Hooks
的一个完成流程图为:
最后以一段代码为例
const App = () => {
const [count, setCount] = useState(0);
const computedVal = useMemo(() => count + 1, [count]);
useLayoutEffect(() => {
console.log('did mount');
}, []);
useEffect(() => {
console.log(count);
}, [count]);
return <span>{ count }</span>
}
上述代码经过第一次挂载之后,完整的图示为
三、总结
上述主要讲解了 Hooks
基本的运行机制, React
首先会判断当前组件是否是函数式组件,之后会通过 Dispatcher
调用 Hook
,创建相应的 hook
链表,对于具有副作用的 Hooks
,还会额外创建 effect
, 而 Fiber
将承载这两个链表来保存函数式组件的状态。
对于 Hooks
的使用,本文也给出了几点建议,其中 useEffect
的刷新时机我们将在下篇文章中通过完整的 commit
阶段来给大家详细介绍,敬请期待。