1-简介
概念
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
简单的用法如以下代码所示。
import React, { useState } from 'react';
function Example() {
// 声明一个新的叫做 “count” 的 state 变量
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
如上。我们可以用useState声明一个初始值为0的count变量,并且得到一个setCount方法,通过这个方法可以修改这个count变量。
动机
官方文档中提到,hooks解决了之前react存在的一些问题:
- 在组件之间复用状态逻辑很难: HOC、Render props都会导致组件嵌套层级过深;
- 复杂组件变得难以理解: 大型组件不易理解,很难拆分和重构;
- 难以理解的 class: this的指向问题;
简单来说,hooks让函数组件拥有了自己的内部状态,能让我们更好地进行代码逻辑复用。
2-原理分析
关于hooks规则的几个问题
官方文档中提到,hooks使用时需要以下遵循两条规则:
只在最顶层使用Hook:不要在循环,条件或嵌套函数中调用 Hook, 确保总是在你的 React 函数的最顶层以及任何 return 之前调用他们。遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。
只在 React 函数中调用 Hook:不要在普通的 JavaScript 函数中调用 Hook。
首先第二条可能比较好理解,因为我们可以通过作用域去控制哪里可以使用这些hooks, 但是第一条就不是那么直观地能理解的了。
基于这些使用规则,在使用了hooks之后,我总结了几点疑问:
| 问题 | 解答 |
|---|---|
| hooks如何存储数据? | ... |
| 为何不能在function component之外的其他地方使用hooks? | ... |
| 为何hooks不能放在条件语句中? | ... |
| hooks如何在不同的渲染中,返回最新的值? | ... |
| ... | ... |
下面将根据部分源码的理解,一一解答这些问题。
hooks的相关概念
首先要理解两个跟hooks相关概念。
第一个是 Fiber。
在React 15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。为了解决这个问题,React 16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要,于是,全新的Fiber架构应运而生。
每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的DOM节点等信息,保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)。
所以可以认为,React 16的Fiber概念就是对应之前的vDom概念。
那我们来看看Fiber的定义。
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// 作为静态数据结构的属性
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
// 用于连接其他Fiber节点形成Fiber树
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
// 作为动态的工作单元的属性
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;
// 指向该fiber在另一次更新时对应的fiber
this.alternate = null;
}
其中几个跟hooks有关的属性如下:
- Fiber.memorizedState: Function Component中存储Hooks,Class Component中存储State的地方;
- Fiber.alternate: currentFiber 和 workInProgressFiber 相互引用的地方;
- currentFiber: 当前已有的Fiber节点;
- workInProgressFiber: currentFiber更新时产生的节点;
可以看到Fiber.memorizedState就是存储hooks的地方,先理解这个概念,后面再根据源码分析为什么是存储在这里的。
第二个是hooks。
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
属性的解释如下:
- memorizedState: 不同类型hook的memoizedState保存不同类型数据,比如useState 对应的就是 state,useEffect 对应的就是 effect 对象,useRef 对应的就是 ref 对象;
- baseState: 本次更新前该Fiber节点的state,每次更新都基于该state计算得出更新后的state;
- baseQueue: 本次更新之前已有的待更新队列;
- queue: 本次更新需要增加的待更新队列,在计算state时,会将queue的环状链表合并到baseQueue,baseQueue基于baseState计算新的state;
- next: 指向下一个hook;
看到next这个属性,我们就可以知道hook是一个链表结构了。
如下所示的代码会生成以下数据结构:
function hello() {
const [num, setNum] = useState(0);
useEffect(() => {
console.error(num);
},[]);
const [str, setStr] = useState('str');
return (
...
);
}
useState的执行经过
我们用一个简单的例子来阐述说明hooks的执行过程。
为了简洁地说明一个hooks完整的执行过程,这里只以useState为例子,阐述首次挂载(onMount)和挂载之后的一次更新过程(onUpdate),嵌套渲染、更新优先级调度等不做深入解释。
function hello() {
const [name, setName] = useState('lufei');
const [age, setAge] = useState(8);
const [sex, setSex] = useState('male');
const btnTap = () => {
setName('wanglufei')
setAge(9)
setAge(10)
setSex('female')
}
return (
<button onClick={() => btnTap() }></button>
);
}
onMount
我们已经知道,react 16是通过Fiber节点来处理生成真实dom节点的;
当任务执行到首次更新Function component时,最后是调用renderWithHooks来处理生成函数式组件的Fiber节点的memorizedState属性的,如上述,hooks相关信息存储在这个属性上;
上述代码挂载过程会生成如下节点信息:
处理流程如下:
从renderWithHooks的参数中可以取出两个Fiber树,已经渲染在界面上的currentFiber, 和当前正在处理待更新节点的workInProgressFiber;
判断currentFiber不存在,处于mount阶段,将HooksDispatcherOnMount赋值给ReactCurrentDispatcher.current;
执行该Function Component,遇到第一个useState并执行,即ReactCurrentDispatcher.current.useState,通过第一步可以知道最终真实执行的是HooksDispatcherOnMount.useState,即mountState;
执行mountState,生成一个hook节点newHook,workInProgressHook指向上一个节点;若workInProgressHook为空,则newHook为首个节点,将newHook赋值给当前Fiber的memoizedState;若workInProgressHook不为空,则将newHook赋值给workInProgressHook.next;最后将workInProgressHook指向新生成的newHook;
将useState传进来的值赋值给newHook的memorizedState和baseState(若传进来的是函数,则执行后再赋值),初始化待更新队列queue;初始化queue.dispatch,将其与workInProgressFiber和queue绑定;
useState返回[newHook.memorizedState, queue.dispatch];
重复4-6执行完三个useState;
最后返回一个保存上述hook信息和其他节点渲染相关信息的children;
将ReactCurrentDispatcher.current置为ContextOnlyDispatcher,将workInProgressHook和currentHook置空;
简化后的主要代码如下(... 表示省略了一些dev和兜底逻辑,以及一些rerender阶段和调度优先级相关的代码):
renderWithHooks:对应流程1、2、3、8、9,确定不同阶段的ReactCurrentDispatcher.current的值,执行component获得当前节点的fiber信息;
/**
* ../react/src/ReactFiberHooks.js
*/
export function renderWithHooks<Props, SecondArg>(
current: Fiber | null,
workInProgress: Fiber,
Component: (p: Props, arg: SecondArg) => any,
props: Props,
secondArg: SecondArg,
nextRenderLanes: Lanes,
): any {
renderLanes = nextRenderLanes;
currentlyRenderingFiber = workInProgress;
...
workInProgress.memoizedState = null;
workInProgress.updateQueue = null;
workInProgress.lanes = NoLanes;
...
// 判断是挂载阶段还是更新阶段
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
let children = Component(props, secondArg);
...
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
...
renderLanes = NoLanes;
currentlyRenderingFiber = (null: any);
currentHook = null;
workInProgressHook = null;
...
return children;
}
mountState:对应流程5、6,初始化新增的hook节点的信息;
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
// $FlowFixMe: Flow doesn't like mixed types
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
const queue = (hook.queue = {
pending: null,
interleaved: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
});
const dispatch: Dispatch<
BasicStateAction<S>,
> = (queue.dispatch = (dispatchAction.bind(
null,
currentlyRenderingFiber,
queue,
): any));
return [hook.memoizedState, dispatch];
}
mountWorkInProgressHook:对应流程4,新增hook并生成hook链表;
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
// 判断是否是首个节点
if (workInProgressHook === null) {
// This is the first hook in the list
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
到这里,之前提出的问题中的前两个就能得到回答了。
| 问题 | 解答 |
|---|---|
| hooks如何存储数据? | hooks本质上是用一个object结构存储相关信息的(hookObject),使用时传入的数据存储在hookObject.memorizedState上,hooks以单链表的形式连接,hookObject.next指向下一个hook; |
| 为何不能在function component之外的其他地方使用hooks? | 在执行Function component之前,ReactCurrentDispatcher.current被赋值了不同阶段的执行对象(HooksDispatcherOnMount或HooksDispatcherOnUpdate),比如在挂载阶段,执行useState实际上是执行HooksDispatcherOnMount.mountState;在执行完Function component之后,ReactCurrentDispatcher.current被赋值了ContextOnlyDispatcher,这个对象的对应hooks调用都会提示报错(throwInvalidHookError); |
| ... | ... |
不同类型hook的memoizedState保存不同类型数据,具体如下:
useState:对于const [state, updateState] = useState(initialState),memoizedState保存state的值;
useReducer:对于const [state, dispatch] = useReducer(reducer, {});,memoizedState保存state的值;
useEffect:memoizedState保存包含useEffect回调函数、依赖项等的链表数据结构effect,你可以在这里看到effect的创建过程。effect链表同时会保存在fiber.updateQueue中;
useRef:对于useRef(1),memoizedState保存{current: 1};
useMemo:对于useMemo(callback, [depA]),memoizedState保存[callback(), depA];
useCallback:对于useCallback(callback, [depA]),memoizedState保存[callback, depA]。与useMemo的区别是,useCallback保存的是callback函数本身,而useMemo保存的是callback函数的执行结果;
有些hook是没有memoizedState的,比如:useContext;
总结起来就是,onMount阶段,每个hook都会生成一个hook节点,将初始化数据存储在节点的memorizedState上,并生成了待更新队列queue、dispatch等其他信息存储在这个节点上;所有的hook通过hook.next链接成链表,挂载在fiber.memorizedState上;
dispatch阶段
useState经过mount阶段,得到了一个变量和一个可以改变变量的dispatch,如name和setName。我们知道,例子中点击按钮,界面就会更新数据。
那么这个dispacth具体做了什么呢?其实每次调用dispatch时,并不会立刻对状态值进行修改,状态值的更新是异步的,react会创建一条修改操作——在对应hook.queue.pending挂载的链表上加一个新节点;以两个setAge为例,dipatch阶段生成了如下的数据结构:
大致处理流程如下:
生成一个update对象,将传进来的数据存在update.action;
将新生成的update链接成环,存在queue.pending上;
简化后的主要代码如下:
function dispatchAction(fiber, queue, action) {
// ...创建update
var update = {
eventTime: eventTime,
lane: lane,
suspenseConfig: suspenseConfig,
action: action,
eagerReducer: null,
eagerState: null,
next: null
};
// ...将update加入queue.pending
var alternate = fiber.alternate;
if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
// render阶段触发的更新
didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
const pending = queue.pending;
// 生成update环
if (pending === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
} else {
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
// ...fiber的updateQueue为空,优化路径
try {
var currentState = queue.lastRenderedState;
var eagerState = lastRenderedReducer(currentState, action);
update.eagerReducer = lastRenderedReducer;
update.eagerState = eagerState;
if (objectIs(eagerState, currentState)) {
return;
}
}
} else {
...
}
...
}
scheduleUpdateOnFiber(fiber, lane, eventTime);
}
其实这里还有一部分小优化处理。
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes))
这里的判断是,如果update实际上为该hook上第一个update,即首次执行(例子中第一次点击的setAge(9)),则计算state时也只依赖于该update,完全不需要进入任务调度阶段再计算state,可以优先计算出eagerState保存下来; 这样做的好处是:如果eagerState与该hook之前保存的state一致,那么完全不需要开启一次调度。即使eagerState与该hook之前保存的state不一致,在也可以进入任务调度阶段后直接使用已经计算出的eagerState;
==总结一下就是,在这个dispatch阶段,hook会将每一个更新--update收集起来并以链表的形式连接,等待异步调度执行。==
onUpdate
其实经过分析dispacth,可以猜测到update阶段主要所做的事情就是将dispatchAction生成的update链表按顺序依次执行,得到最终的state值,并返回。此时的ReactCurrentDispatcher.current的值是HooksDispatcherOnUpdate,最终这个阶段的usetState执行的是updateReducer;
处理流程如下:
执行到hook时,调用updateWorkInProgressHook取出当前的hook节点;取出的规则是:首次update时(如点击执行setAge(9)),从currentFiber.memoizedState中取出mount阶段生成的hooks链表,将currentHook指针指向第一个节点;从workInProgressFiber.memoizedState(即currentFiber.alternate.memoizedState)中取出mount阶段生成的hooks链表,将workInProgressHook指针指向第一个节点;此时workInProgressHook是空值,则用currentHook的值初始化workInProgressHook,并返回这个workInProgressHook;执行到下一个hook时,workInProgressHook和currentHook各往后移动一次,并继续用currentHook的值初始化workInProgressHook,然后返回workInProgressHook;
将第一个步骤生成的hook节点的baseQueue(因为优先级低而没有执行的更新)和queue.pending合并到baseQueue;
基于baseState依次执行baseQueue中的更新update得到newState;如果执行到将优先级不足的更新update,则将这个update存到newBaseQueueLast,并将此时此刻的newState存到newBaseState,等待下次执行;
将newState存在hook.memoizedState,将newBaseState存到hook.baseState,将newBaseQueueLast存到hook.baseQueue;最后返回[hook.memoizedState, queue.dispatch];
主要代码如下:
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
// 生成hook,对应流程1;
const hook = updateWorkInProgressHook();
const queue = hook.queue;
invariant(
queue !== null,
'Should have a queue. This is likely a bug in React. Please file an issue.',
);
queue.lastRenderedReducer = reducer;
const current: Hook = (currentHook: any);
let baseQueue = current.baseQueue;
// 将队列合并,对应流程2;
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
if (baseQueue !== null) {
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
// 执行更新,对应流程3;
if (baseQueue !== null) {
// We have a queue to process.
const first = baseQueue.next;
let newState = current.baseState;
let newBaseState = null;
let newBaseQueueFirst = null;
let newBaseQueueLast = null;
let update = first;
do {
const updateLane = update.lane;
// 将优先级不足的更新存起来,对应流程3;
if (!isSubsetOfLanes(renderLanes, updateLane)) {
const clone: Update<S, A> = {
lane: updateLane,
action: update.action,
eagerReducer: update.eagerReducer,
eagerState: update.eagerState,
next: (null: any),
};
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
updateLane,
);
markSkippedUpdateLanes(updateLane);
} else {
if (newBaseQueueLast !== null) {
const clone: Update<S, A> = {
lane: NoLane,
action: update.action,
eagerReducer: update.eagerReducer,
eagerState: update.eagerState,
next: (null: any),
};
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// 执行更新,对应流程3;
if (update.eagerReducer === reducer) {
newState = ((update.eagerState: any): S);
} else {
const action = update.action;
newState = reducer(newState, action);
}
}
update = update.next;
} while (update !== null && update !== first);
// 判断是否有跳过的更新,对应流程3;
if (newBaseQueueLast === null) {
newBaseState = newState;
} else {
newBaseQueueLast.next = (newBaseQueueFirst: any);
}
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
hook.memoizedState = newState;
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast;
queue.lastRenderedState = newState;
}
...
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
这里也有一些代码是直接对应update阶段提到的优化,如下;当是首次更新时,直接取计算好的eagerState,从而减少了一次reducer的计算:
if (update.eagerReducer === reducer) {
newState = ((update.eagerState: any): S);
} else {
const action = update.action;
newState = reducer(newState, action);
}
总结一下,update阶段是从mount阶段生成的Fiber中复制了hook的相关信息,并且依次执行了dispatch阶段存储在hook的update,计算得到最新的状态值并返回。
分析到这里,前面提出的剩下的问题也就得到解决了:
| 问题 | 解答 |
|---|---|
| ... | ... |
| 为何hooks不能放在条件语句中? | 因为在update阶段,hook是依据之前mount阶段创建的链表(通过两个指针移动)一一对应生成的,如果放在条件语句中,或者不放在最顶层使用hook,那么就有可能在某次执行时跳过某些hook,导致取错误到的hook节点; |
| hooks如何在不同的渲染中,返回最新的值? | hook会将dispatch阶段触发的更新存在hook节点的queue和baseQueue中,在update阶段中,会将这些更新取出来依次执行,得到最新的state并返回; |
3-源码调试
优秀框架源码的逻辑都是错综复杂的,因为其中包含了大量的开发和生产环境的区分和容错,还有各种逻辑兜底和异常处理;因此若是纯粹的只看代码,只从代码去直接理解和分析原理和流程,是比较难的。
所以我们需要借助一些工具来对源码进行调试。
有几种方式调试react源码:
第一种:参考链接
从facebook/react拉取最新的代码,用yarn安装依赖;
build出dev环境可以使用的cjs包,并执行yarn link创建软链react和react-dom;
使用yarn link将调试所使用的测试项目下的react react-dom指向软链react和react-dom;
在build出的cjs包中打log或者debugger进行调试
第二种:参考链接
使用VS code 插件debugger for chrome;
4-总结
通过了解react hooks的背后实现,能加深一些对hooks应用的理解,使用的时候就会更胸有成竹一些。当然本文只是通过useState的例子将hooks大概的结构流程梳理了一遍,其他类似useRef、useEffect的具体实现可能细节上有差异,但大致的原理是一致的。
这是第一次自己去翻读源码,也是第一次将自己的思考和理解记录成文章,可能某些细节理解的不是很到位,表达不是很清晰,还是需要多多揣摩,争取进一步提升自己的思维和技术水平。
最后分享一些我觉得比较好的参考文章和博客: