前言
在前文# React Hooks诞生动机是什么?介绍了关于Hooks
的动机,即回答了它解决了什么问题,但是要求开发者必须严格遵守以下两点规则:
- 只能在
React
函数中调用Hook
。 - 不要在循环、条件或嵌套函数中调用
Hook
。
到底是因为什么让React
有这些规则呢?尤其是第二点,如果非要打破这个规则,那将会怎么样?
要想回答以上问题,那就需要搞懂Hooks
的运行机制。
规则解释
至于第一条只能在React
函数中调用Hook
比较好理解,因为在普通函数中调用Hooks
没有意义,来测试一下:
jsx
import React, { useState } from "react";
import { Button } from "antd";
function testFunction() {
const [status] = useState(0)
console.log('status', status);
return status
}
function TestComponent() {
const status = testFunction()
return (
<div>
</div>
);
}
export default TestComponent
上面代码中,在TestComponent
中调用普通函数testFunction
,而普通函数中调用useState
钩子获取状态,运行结果报错,它要求必须要函数式组件中或者自定义Hooks
中调用。
而至于第二条,还不能让我在循环或条件语句、嵌套函数中调用?
是的,没错!React
必须要保证每次渲染时 Hook
调用的顺序是一致的。
为什么顺序必须是一致呢?
因为在渲染的时候存在多个state
,为了能够让state
和useState
对应上,从而能够渲染出正确的UI,React
根据固定Hooks
顺序来保证每次渲染是正确的,这不就意味着React
的改变会导致整个vdom
变化吗?
没错!正是因为这样,React16
引入了fiber
架构。
好了,我们来测试一下按照正确顺序使用hooks
的方式:
jsx
import React, { useState } from "react";
import { Button } from "antd";
function TestComponent() {
const [name, setName] = useState("mouse");
const [number] = useState(0);
const [blog] = useState("奶酪");
return (
<div style={{padding: 15}}>
<p>博主:{name}</p>
<p>数量:{number}</p>
<p>博客名:{blog}</p>
<Button
onClick={() => {
setName("jmin");
}}
>
修改名称
</Button>
</div>
);
}
export default TestComponent
上面代码中,通过定义三个状态,分别name
、number
、blog
,点击修改名称,可以修改name
属性:
好,点击按钮可以正常修改名称:
接下来我们稍微修改一下代码,加上条件语句限制:
jsx
import React, { useState } from "react";
import { Button } from "antd";
let isClick = false;
function TestComponent() {
let name, setName, number;
if (!isClick) {
// eslint-disable-next-line
[name, setName] = useState("mouse");
// eslint-disable-next-line
[number] = useState(0);
isClick = true;
}
const [blog] = useState("奶酪");
console.log('blog: ', blog);
return (
<div style={{padding: 15}}>
<p>博主:{name}</p>
<p>数量:{number}</p>
<p>博客名:{blog}</p>
<Button
onClick={() => {
setName("jmin");
}}
>
修改名称
</Button>
</div>
);
}
export default TestComponent
通过条件判断,在二次渲染的时候我减少了渲染的钩子数量,点击按钮之后控制台应该显示blog
为奶酪
才对呀,怎么会先是setName
的内容呢?并且也报了一个错,说渲染的hooks
比预期的少!!!
所以,是因为我通过条件判断减少了React
渲染时候的钩子数量,导致它在渲染的时候没有按照原本顺序执行,显然第三个state
跑到第一个位置去了。
来看看React
源码是怎么处理的,首先从setState
开始
jsx
export function useState(initialState) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
获取一下当前的 dispatcher
,然后再调用 dispatcher.useState
方法并且传入 useState
的初始值。
那么当前的 dispatcher
又是什么呢?通过研究源码发现,当函数组件进入 render 阶段
的时候,如果发现组件内存在 Hooks
,那么会调用 renderWithHooks方法,在这个方法中会根据不同渲染情况对当前的 dispatcher
进行赋值,如下:
jsx
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null ?
HooksDispatcherOnMount :
HooksDispatcherOnUpdate;
从代码中可以看到,首先使用 current === null || current.memoizedState === null
区分当前组件是在 mount
还是 update
,然后把不同情况的 dispatcher
赋值给全局变量 ReactCurrentDispatcher
的 current
属性,赋值完以后,在 useState
定义里通过调用 resolveDispatcher
方法就能获取到当前的 dispatcher
了。
从这里我们就知道了, Hooks
在首次渲染和更新渲染时使用的是不同的 dispatcher
,从而执行的是不用的逻辑。
首次渲染
从上文分析得知,首次渲染使用的 dispatcher
是 HooksDispatcherOnMount (opens new window),其代码如下:
jsx
const HooksDispatcherOnMount: Dispatcher = {
readContext,
useCallback: mountCallback,
useContext: readContext,
useEffect: mountEffect,
useImperativeHandle: mountImperativeHandle,
useLayoutEffect: mountLayoutEffect,
useMemo: mountMemo,
useReducer: mountReducer,
useRef: mountRef,
useState: mountState,
useDebugValue: mountDebugValue,
useDeferredValue: mountDeferredValue,
useTransition: mountTransition,
useMutableSource: mountMutableSource,
useOpaqueIdentifier: mountOpaqueIdentifier,
unstable_isNewReconciler: enableNewReconciler,
};
在首次渲染阶段调用 useState
实际上调用的是 HooksDispatcherOnMount.useState
即 mountState
方法,我们继续追 mountState
的内部实现,它是这样定义的:
jsx
function mountState(initialState) {
// 将新的 hook 对象追加进链表尾部
const hook = mountWorkInProgressHook();
// initialState 可以是一个回调,若是回调,则取回调执行后的值
if (typeof initialState === 'function') {
initialState = initialState();
}
// 将 initialState 作为一个"记忆值"存下来
hook.memoizedState = hook.baseState = initialState;
// 创建当前 hook 对象的更新队列,这一步主要是为了能够依序保留 dispatch
const queue = (hook.queue = {
pending: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
});
const dispatch = (queue.dispatch = (dispatchAction.bind(
null,
currentlyRenderingFiber,
queue,
): any));
// 返回目标数组,dispatch 其实就是常常见到的 setXXX 这个函数
return [hook.memoizedState, dispatch];
}
从这段源码中我们可以看出,mounState 的主要工作是初始化 Hooks 。在整段源码中,最需要关注的是 mountWorkInProgressHook
方法,它为我们道出了 Hooks
背后的数据结构组织形式。以下是 mountWorkInProgressHook
方法的定义:
jsx
function mountWorkInProgressHook() {
// 注意,单个 hook 是以对象的形式存在的
const hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// 若正在处理的 hook 链表为空,则将上面定义好的 hook 作为链表的头节点
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// 若链表不为空,则将 hook 追加到链表尾部
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
到这里可以看出,hook 相关的所有信息收敛在一个 hook 对象里,而 hook 对象之间以单向链表的形式相互串联。
所以,组件中的所有 Hooks
之间都是以单向链表的形式串联。
那么,假如链接中间有一环丢失了,那么这条链表的顺序就会发生变化,从而有可能导致还存在在链表之中的 Hook
状态与之前无法对应的情况,从而导致 hook
状态紊乱。
那么真相到底是不是如我们猜想的这般呢?别急,我们继续往下看,看完了首次渲染的情况,我们继续看更新渲染时都干了些什么。
更新渲染
更新渲染使用的 dispatcher
是 HooksDispatcherOnUpdate,代码如下:
jsx
const HooksDispatcherOnUpdate: Dispatcher = {
readContext,
useCallback: updateCallback,
useContext: readContext,
useEffect: updateEffect,
useImperativeHandle: updateImperativeHandle,
useLayoutEffect: updateLayoutEffect,
useMemo: updateMemo,
useReducer: updateReducer,
useRef: updateRef,
useState: updateState,
useDebugValue: updateDebugValue,
useDeferredValue: updateDeferredValue,
useTransition: updateTransition,
useMutableSource: updateMutableSource,
useOpaqueIdentifier: updateOpaqueIdentifier,
unstable_isNewReconciler: enableNewReconciler,
};
在更新渲染阶段调用 useState
实际上调用的是 HooksDispatcherOnUpdate.useState
即 updateState
方法,它是这样定义的:
jsx
function updateState(initialState) {
return updateReducer(basicStateReducer, initialState);
}
可以看到, updateState
方法最终会落到 updateReducer
这个方法中。
其实 updateState
做的的事情很容易理解,那就是:按顺序去遍历之前构建好的链表,取出对应的数据信息进行渲染。
总结
现在,我们知道了:
mountState
(首次渲染)构建链表并渲染;
updateState
(更新渲染)依次遍历链表并渲染。
hooks 的渲染是通过「依次遍历」来定位每个 hooks 内容的。如果前后两次读到的链表在顺序上出现差异,那么渲染的结果自然是不可控的。
所以,这就是为什么React
必须要求开发者保证遵守上面的规则,用于所有 Hook
在每次渲染中都按照同样的顺序被调用!