在我们刚开始学习react时,react官方文档就提示我们react hook
必须在函数组件顶层使用,不能在条件语句或者循环结构中使用。
之所以会有这样的规定,这和react hook
的实现原理脱离不了关系。
本文就从源码的角度,帮助大家快速理解其中的原理。
虽然是以源码角度来解析,但本文并不会展示过多的源码,旨在于尽量简洁的说明其中的道理。
1,hooks的加载
首先准备一个函数组件案例:
js
export default function MyFun(props) {
// hook加载
const [count, setCount] = useState(1)
useEffect(() => {
console.log('useEffect DOM渲染之后执行')
}, [])
function handleClick() {
setCount(2)
}
return (
<div className='MyFun'>
<div>MyFun组件</div>
<div>state: {count}</div>
<button onClick={handleClick}>更新</button>
</div>
)
}
我们都知道在react应用中,函数组件加载的一个重点就是:会调用一次我们定义的函数,也就是案例中的MyFun
。
而在函数调用的过程中,就会遇到我们定义的hooks
,react内部就会开始执行hooks
的加载处理。
当然首先我们得知道,react组件的加载处于Fiber Reconciler
协调流程之中,这个流程的主要作用就是创建FiberTree
【虚拟DOM树】,Fiber
即react中的虚拟DOM,每一个函数组件都会创建与之对应的Fiber
节点,对于Fiber
节点你只需要理解为一个JS
对象,它有很多属性存储着与组件相关的信息,而与hook
相关的部分内容就存储在fiber.memoizedState
属性之中。
每一个hook
在首次加载时,都会执行一个mountWorkInProgressHook
方法:
比如useState
加载时:
js
function mountState(initialState) {
const hook = mountWorkInProgressHook();
...
}
这个方法内部会为我们在组件中定义的hook
创建一个对应的hook
对象:
js
// hook对象
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null, // 指向下一个hook对象
};
这个hook
对象的属性存储着我们组件中对应hook
的相关信息,但是在这里我们只需要关心它的next
属性即可:
在加载第一个hook
即useState
时,就会将第一个hook
对象直接赋值给当前函数组件对应的Fiber
节点的memoizedState
属性。
js
fiber.memoizedState = hook;
所以此时函数组件Fiber
节点的memoizedState
属性指向为:
加载第二个hook
即useEffect
时,就会将上一个hook
的next
属性指向当前新建的hook
对象。
所以此时函数组件Fiber
节点的memoizedState
属性指向为:
ini
useState => hook1
useEffect => hook2
在函数组件在加载完成后,fiber.memoizedState
属性形成了一个这样的单向链表。
2,hooks的更新
来到函数组件的更新过程:函数组件的更新同样会调用一次MyFun
,在这个过程中react内部就会开始执行hooks
的更新处理。
同理每一个hook
在更新时,都会执行一个updateWorkInProgressHook
方法:
js
function updateState(initialState) {
const hook = updateWorkInProgressHook();
...
}
重点来了: updateWorkInProgressHook
方法内部会引用current.memoizedState
属性的内容。
函数组件更新时,
current
代表旧的节点内容,其实就是新旧虚拟DOM概念,current
变成了旧的虚拟DOM。
所以hook
更新时会引用函数组件加载阶段 就已形成的hook
链表,会按照这个链表顺序来取出对应的原hook
对象,利用原hook
信息生成新的hook
对象参与计算或者更新。
这里取出顺序为:首先从current.memoizedState
取出第一个hook
对象,后面就是从hook对象的next
属性依次取出下一个更新的hook对象,hooks
的更新就可以按照顺序复用上一次的相关信息。
hooks
的更新会按照函数组件加载阶段就已经固定的hook
链表顺序,这就是hook
必须置于函数组件顶层使用的根本原因。
如果我们将hook
置于条件或者循环之中,在更新阶段就会出现无法与原来的hook
链表相匹配的问题,将会导致渲染出现异常。
比如我们在条件语句之中新增一个hook
:
js
export default function MyFun(props) {
const [count, setCount] = useState(1)
// 新增一个hook
if (count === 2) {
const [status, setStatus] = useState(false)
}
useEffect(() => {
console.log('useEffect DOM渲染之后执行')
}, [])
function handleClick() {
setCount(2)
}
return (
<div className='MyFun'>
<div>MyFun组件</div>
<div>state: {count}</div>
<button onClick={handleClick}>更新</button>
</div>
)
}
此时我们设置count === 2
时才会执行新增的hook
,count
默认为1
,所以在函数组件加载阶段时不会触发新增的hook
。即函数加载阶段形成的hook
链表中只有两个hook
对象,但是当我们一点击更新按钮,变量条件得到满足,组件更新时就会出现三个hook
,导致新增的useState
复用了原来的第二个useEffect
的hook
对象信息,这必然会导致渲染出错。即使新增的hook 为useEffect
,也同样会导致渲染出错,因为在第三个hook
更新时,找不到hook
链表对应的hook对象,此时react同样会抛出错误:
js
if (nextCurrentHook === null) {
throw new Error('Rendered more hooks than during the previous render.');
}
如果我们设置count === 1
就执行新增的hook
,则函数组件在加载阶段就会向我们发出一个error
提示:
sql
React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render
循环结构也一样的道理,循环的次数变了或者循环结构中有条件语句,都是相同的原理。
结束语
本文从源码的角度,以尽量简洁的语句向大家解释了react hook
必须于函数组件顶层使用的原因,想了解FiberTree
创建过程或者函数组件具体的加载过程可以阅读本系列的其他文章。