一、引言
hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。hook的诞生是为了解决以下几个痛点。
1.在组件之间复用状态逻辑很难
先来举个栗子,我们要监听滚动事件,滚动到600,展示 回到顶部 按钮,实现如下:
const getPosition = () => {
left: document.body.scrollLeft,
top: document.body.scrollTop
}
const BackToTop = (props) => {
const [position, setPosition] = useState(getPosition())
useEffect(() => {
const handler = () => setPosition(getPosition())
document.addEventListener("scroll", handler)
return () => {
document.removeEventListener("scroll", handler)
}
}, [])
return {position.top > 600 ? '返回顶部' : '' }
}
假设现在我们要监听滚动事件,顶部有固定的tab标签,滚动到某个标签的内容处,tab指向那个标签。
对于以上两个例子来说,监听滚动事件逻辑是完全一致的,毫无疑问,我们想要复用这一逻辑。如果你使用过 React 一段时间,你也许会熟悉一些解决此类问题的方案,比如 render props 和 高阶组件。但是这类方案需要重新组织你的组件结构,这可能会很麻烦,使你的代码难以理解,由 providers,consumers,高阶组件,render props 等其他抽象层组成的组件会形成“嵌套地狱”。
我们可以用自定义hook的方式来 复用状态逻辑,自定义 Hook 是一个函数,其名称以 “use” 开头,函数内部可以调用其他的 Hook。如下:
const usePosition = () => {
const [position, setPosition] = useState(getPosition())
useEffect(() => {
const handler = () => setPosition(getPosition())
document.addEventListener("scroll", handler)
return () => {
document.removeEventListener("scroll", handler)
}
}, [])
return position
}
较render props和高阶组件,自定义hook简单、容易理解、学习成本低、易于维护、没有嵌套地狱等。
2、复杂组件变得难以理解
我们经常维护一些组件,组件起初很简单,但是逐渐会被状态逻辑和副作用充斥。每个生命周期常常包含一些不相关的逻辑。例如,组件常常在 componentDidMount 和 componentDidUpdate 中获取数据。但是,同一个 componentDidMount 中可能也包含很多其它的逻辑,如设置事件监听,而之后需在 componentWillUnmount 中清除。相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起。如此很容易产生 bug,并且导致逻辑不一致。
用hook你可以将一个功能放在同一个useEffect(或其他hook)内,可以使用多个useEffect,对于代码可读性、复杂性和可维护性都有很大提升。
3、难以理解的class
除了代码复用和代码管理会遇到困难外,我们还发现 class 是学习 React 的一大屏障。你必须去理解 javascript 中 this 的工作方式,要了解React.Component的api等,class 不能很好的压缩,会使热重载出现不稳定的情况。
因此,react要提供一个使代码更易于优化的 API,这就是hook。接下来分析一下常用hook的实现原理:useState、useReducer、useEffect、useLayoutEffect、useCallback、useMemo。
二、原理解析
1、useState
1.1、示例解析
import React, { useState } from 'react'
function App() {
const [count, setCount] = useState(0)
return (
<div>
<div>{count}</div>
<div
onClick={() => {
setCount(1)
setCount((state) => state + 2)
setCount((state) => state + 3)
}}
>加</div>
</div>
)
}
export default App
对于上面的示例,我们需要关注的点是第一次调用函数组件时做了什么事情?(首次渲染)setCount时做了什么事情?再次执行函数组件时做了什么事情?(再次渲染)
在了解这个之前,先聊三个基础知识:
(1)react16+将dom节点以fiber节点的形式进行存储,具体可参见源码。
(2)react16+架构可以分为三层:
调度阶段 -- 调度任务的优先级,高优任务优先进入render阶段。
render阶段 -- 负责找出变化的组件,生成effectList。
commit阶段 -- 根据effectList,将变化的组件渲染到页面上,并且执行生命周期、useEffect、useLayoutEffect回调函数等。
(3)函数组件是在commit阶段执行的。
现在我们再来看 首次渲染 – setCount - 再次渲染 做了什么事情:
(1)首次渲染主要是初始化hook,将初始值存入hook内,将hook插入到fiber.memoizedState的末尾。
(2)setCount主要是将更新信息插入到hook.queue.penging的末尾。这里注意一下为什么没有直接更新hook.memoizedState呢?答案是react是批量更新的,需要将更新信息先存储下来,等到合适的时机统一计算更新。
(3)再次渲染主要是根据setCount存储的更新信息来计算最新的state。
那具体数据都是怎么流转的呢?react-fiber主要是围绕着fiber数据结构做一些更新存储计算等操作,那在这几个过程中fiber都经历了什么呢?带着这两个问题,我们来做一下讲解。
1.1.1、首次渲染
字段释义:
hook = {
// 保存本hook的信息,不同的hook存储的结构不一致,在useState上代表的是state值,在上例中就是0
memoizedState: null,
// 每次更新时的基准state,大部分情况下和memoizedState一致,有异步更新时会有差别
baseState: null,
// 记录更新的信息
queue: {
dispatchSetState, // 在setCount时所执行的方法
lastRenderedReducer, // 每次计算新state时的方法
lastRenderedState, // 上一次state的值
pending, // 存储更新,讲到setCount时再讲一下其结构
},
// 表示上一次计算新的state之后,剩下的优先级低的更新,会流入下一次任务中计算,结构同queue.pending
baseQueue: null
// 指向下一个hook对象。
next: null,
};
hook都是这个数据结构,useReducer和useState完全一样,其他的hook只用到了memoizedState字段。
1.1.2、setCount
当我们点击按钮,在执行
setCount(1)
setCount((state) => state + 2)
setCount((state) => state + 3)
时,得到的fiber的数据结构如图所示,其中hook.queue.pengding为环状链表:
解读一下queue.pending的数据结构,baseQueue和此结构保持一致
pending = {
action: action, // setCount传递的值,可能function、常量或对象
eagerReducer: null, // 如果是第一个更新,在dispatchSetState的时候就计算出来存储在这里
eagerState: null, // 如果是第一个更新,在dispatchSetState的时候就存储reducer
lane: lane, // 更新的优先级
next: null, // 指向下一个更新
}
Q:关于更新队列为什么是环状?
A:这是因为方便定位到链表的第一个元素。pending指向它的最后一个update,pending.next指向它的第一个update。试想一下,若不使用环状链表,pending指向最后一个元素,需要遍历才能获取链表首部。即使将pending指向第一个元素,那么新增update时仍然要遍历到尾部才能将新增的接入链表。而环状链表,只需记住尾部,无需遍历操作就可以找到首部。
1.1.3、再次渲染
执行了setCount之后,react会再次进入render阶段,执行函数组件所对应的方法,再次渲染,react需要计算最新的值。计算的方法就是 看传递给setCount的参数是不是一个方法,是的话就执行(参数为上一次计算出来的最新的state)计算新值,否则传进来的参数赋值给新值。将新值赋值在hook.memoizedState上。
我们的例子中,setCount(1),新值为1;setCount((state) => state + 2),新值为1+2=3;setCount((state) => state + 3),新值为3+3=6。新值6赋值给hook.memoizedState,得到fiber结构如下图所示:
1.2、源码实现
当函数组件进入render阶段 时,会调用renderWithHooks方法,该方法内部会执行函数组件对应函数(即App())。
我们来看一个流程图,此图对 首次渲染 – setCount - 再次渲染 进行了总结。
2、useReducer vs useState
上面讲解了useState,有一个和他作用比较相似的hook,它就是useReducer,也是用来存储状态的,不同的是,计算新的状态的时候,是用户自己计算的,可以支持更复杂的场景,我们先来看一下它的用法。
2.1、示例
const initialState = {count: 0};
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
我们看到在用法上,useReducer和useState的返回值是一致的,区别是useReducer.
第一个参数是一个function,是用于计算新的state所执行的方法,对标useState的basicStateReducer。我们看到reducer的实现和redux很相似,原因是Redux的作者Dan加入React核心团队,其一大贡献就是“将Redux的理念带入React”。
第二个参数是初始值。
第三个参数是计算初始值的方法,其执行时候的参数是上述第二个参数。
2.2、使用场景
所有用useState实现的场景都可以用useReducer来实现,像如下复杂的场景更适合用useReducer,比如state逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。
3、useEffect vs useLayoutEffect
上面讲了存储状态相关的两个hook,接下来讲解下类比class组件生命周期的两个hook,useEffect和useLayoutEffect相当于class组件的以下生命周期:componentDidMount、componentDidUpdate、componentWillUnMount,二者使用方式完全一样,不同的点是调用的时机不同,以useEffect为示例来说明一下。
3.1、示例解析
import React, { useState, useEffect } from 'react'
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('useEffect:', count);
return () => {
console.log('useEffect destory:', count);
}
}, [count])
return (
<div>
<div>{count}</div>
<div onClick={() => setCount(count + 1) }>加1<?div>
</div>
)
}
export default App;
上面的示例,我们需要关注的是useEffect的回调函数和其返回的函数,是什么时机执行的?又是通过什么机制来判定要不要执行呢?fiber的结构又是怎么变化的呢?
这里先概括一下:useEffect的实现,是在render阶段给fiber和hook设置标志位,在commit阶段根据标志位来执行回调函数和销毁函数,后面将按照 render阶段 - commit阶段 来进行讲解,commit阶段又分为3个阶段,分别是before mutation阶段(执行dom操作前)、mutation阶段(执行dom操作)、layout阶段(执行dom操作后)
上述示例中首次渲染执行到useEffect之后,挂载到fiber.memoizedState的数据结构如下:
再次渲染结果,此时destory是有值的,其他不变。结果如下:
数据结构解读:
effectMemoized = {
create: create, // useEffect的回调函数
destroy: destroy,// useEffect的回调函数的返回值(即后面所说的销毁函数)
deps: deps,// 依赖的数组
tag: tag,// hook的标志位,commit阶段会根据这个标志来决定是不是要执行回调函数和销毁函数
next: null, // 下一个hook
}
在commit阶段就是根据fiber.flags和hook.tag来判段是否执行create或者destory。
每个阶段分别作了什么事情,我们来看一下,render阶段流程图:
commit阶段流程图:
对上述两图做一下讲解
3.1.1、render阶段
A、首次渲染
给fiber设置标志位;
将生成的effect = {tag, create, destroy: undefined, deps, netx: null},插入到fiber.updateQueue末尾;
将effect插入到hook.memoizedState末尾。
B、再次渲染
比较本次更新的依赖项和上一次的依赖项是否一致,如果一致则将生成的effect = {tag, create, destroy, deps, netx: null},插入到fiber.updateQueue末尾,设置的tag标志位与依赖项不一致时设置的不同;
如果依赖项不一致,重复首次渲染的步骤。
useEffectLayout vs useEffect 标志位情况如下:
其中这几个标志位是二进制,如下:
// flags相关
export const UpdateEffect = 0b000000000000000100;
export const PassiveEffect = 0b000000001000000000;
export const PassiveStaticEffect = 0b001000000000000000;
export const MountLayoutDevEffect = 0b010000000000000000;
export const MountPassiveDevEffect = 0b100000000000000000;
// hook标志位相关
export const HookHasEffect = 0b001;
export const HookPassive = 0b100;
export const HookLayout = 0b010;
export const NoHookEffect = 0b000;
我们看到,在设置标志位的时候,都是用的逻辑或,即是在某一位上添加上1,在判断的时候,我们只需要判断fiber或者hook上在某一位上是不是1即可,这时候应该用逻辑与来判断。
3.1.2、commit阶段
A、before mutation阶段(执行DOM操作前)
异步调度useEffect(为什么要异步调度?原理是什么?)。
B、mutation阶段(执行DOM操作)
根据flags分别处理,对dom进行插入、删除、更新等操作;
flags为Update时,function函数组件执行useLayoutEffect的销毁函数;
flags为Deletion,class组件调用componentWillUnmount,function组件调度useEffect的销毁函数。
C、layout阶段
class组件调用componentDidMount或componentDidUpdate;
function组件调用useLayoutEffect的回调函数。
异步调度的原理:如下图,需要注意的是GUI线程和js引擎线程是互斥的,当js引擎执行时,GUI线程会被挂起,相当于被冻结了,GUI更新会被保存在一个队列中,等js引擎空闲时(可以理解js运行完后)立即被执行。
如上图,我们的调度可以简单的理解为是类似setTimeout的宏任务,当然其内部实现要比这个复杂多了。当commit阶段整个执行完毕之后,浏览器会启动 GUI渲染引擎 进行一次绘制,绘制完毕之后,react会取出一个宏任务来执行(react会保证我们异步调度的useEffect的函数会在下一次更新之前执行完毕)。因此,在mutaiton阶段,我们已经把发生的变化映射到真实 DOM 上了,但由于 JS 线程和浏览器渲染线程是互斥的,因为 JS 虚拟机还在运行,即使内存中的真实 DOM 已经变化,浏览器也没有立刻绘制到屏幕上。
commit 阶段是不可打断的,会一次性把所有需要 commit 的节点全部 commit 完,至此 react 更新完毕,JS 停止执行。GUI渲染线程把发生变化的 DOM 绘制到屏幕上,到此为止 react 把所有需要更新的 DOM 节点全部更新完成。
绘制完成后,浏览器通知 react 自己处于空闲阶段,react 开始执行自己调度队列中的任务,此时才开始执行异步调度的函数( 也就是去执行useEffect(create, deps) 的产生的函数)。
3.2、使用场景
useEffect: 适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,不会在函数中执行阻塞浏览器更新屏幕的操作。
useLayoutEffect: 适用于在浏览器执行下一次绘制前,用户可见的 DOM 变更就必须同步执行,这样用户才不会感觉到视觉上的不一致。
4、useCallback
上面讲述了类似class组件生命周期相关的hook,这里讲一下性能优化相关的hook,useCallback和useMemo。
4.1、示例解析
import React, { useState, useCallback } from 'react'
function App() {
const {count, setCount} = useState(0);
const memoizedCallback = useCallback(() => count, [count]);
return (
<div>
<div>{count}</div>
<div>{memoizedCallback}</div>
</div>
)
}
export default App;
得到的fiber数据结构如图:
其中mountCallback就是将传入的方法返回,并且将[function, 依赖项数组]做为数组存储在hook.memoizedState上面。updateCallback查看依赖项是否和上次一致,如果一致,就返回function,如果不一致,就返回新传入的function,并且重新存储一下[function, 依赖项数组]。
5、useMemo vs useCallback
5.1、示例解析
import React, { useState, useMemo } from 'react'
function App() {
const {count, setCount} = useState(0);
const memoizedMemo = useMemo(() => count, [count]);
return (
<div>
<div> {count}</div>
<div>{memoizedMemo}</div>
</div>
)
}
export default App;
我们看下useMemo和useCallback的区别,用法一样,返回值useMemo是返回的执行方法之后得到的结果,memoizedState存储的第一项也是执行方法之后得到的结果。
5.2、使用场景
class组件一个性能优化的点:shouldComponentUpdate,function组件没有shouldComponentUpdate,有较大的性能损耗,useMemo 和useCallback就是解决性能问题的杀手锏。
5.2.1、useCallback
如下面的例子,父组件传递给子组件一个callback函数,那么当input框内有变化时,都会触发更新渲染操作,Parent方法组件都会执行,每次callback都是新定义的一个方法变量,那每次指针也都是不一致的,所以每次也会触发Child方法组件的更新,而我们看到Child组件只是用到了count,并没有用到name,所以我们希望的是input有变化(也就是name变化时)不重新渲染Child,这个时候就可以用useCallback了。
import React, { useState, useCallback, useEffect } from 'react';
function Parent() {
const [count, setCount] = useState(1);
const [val, setVal] = useState('');
const callback = () => {
return count;
};
return <div>
<h4>{count}</h4>
<Child callback={callback}/>
<div>
<button onClick={() => setCount(count + 1)}>+</button>
<input value={val} onChange={event => setVal(event.target.value)}/>
</div>
</div>;
}
function Child({ callback }) {
const [count, setCount] = useState(() => callback());
useEffect(() => {
setCount(callback());
}, [callback]);
return <div> {count} </div>
}
我们对callback做如下改造
const callback = useCallback(() => count, [count])
如此一来,只有当count改变的时候,callback才会重新赋值,当count不改变的时候,就会从内存中取值了。
5.2.2、useMemo
export default function WithoutMemo() {
const [count, setCount] = useState(1);
const [val, setValue] = useState('');
const expensive = () => {
let sum = 0;
for (let i = 0; i < count * 100; i++) {
sum += i;
}
return sum;
};
return <div>
<h4>{count}-{expensive}</h4>
{val}
<div>
<button onClick={ () => setCount(count + 1)}>+c1</button>
<input value={val} onChange={ event => setValue(event.target.value)} />
</div>
</div>;
}
这里创建了两个state,然后通过expensive函数,执行一次昂贵的计算,拿到count对应的某个值。我们可以看到:无论是修改count还是val,由于组件的重新渲染,都会触发expensive的执行(能够在控制台看到,即使修改val,也会打印);但是这里的昂贵计算只依赖于count的值,在val修改的时候,是没有必要再次计算的。在这种情况下,我们就可以使用useMemo,只在count的值修改时,执行expensive计算:
const expensive = useMemo(() => {
let sum = 0;
for (let i = 0; i < count * 100; i++) {
sum += i;
}
return sum;
}, [count]);
上面我们可以看到,使用useMemo来执行昂贵的计算,然后将计算值返回,并且将count作为依赖值传递进去。这样,就只会在count改变的时候触发expensive执行,在修改val的时候,返回上一次缓存的值。
小结:
1、如果有函数传递给子组件,使用useCallback
2、缓存一个组件内的复杂计算逻辑需要返回值时,使用useMemo
3、如果有值传递给子组件,使用useMemo
三、小结
以上两章讲解了hook出现解决了现有的痛点,以及常用的hook如何使用、原理和使用场景,总结一下这几个hook的整体流程,其中相似的hook我们只讲一个,如下表:
注:源码部分,是以17.0.0-dev分支展开。
1、renderWithHooks和各个hook的实现在:https://github.com/facebook/react/blob/17.0.0-dev/packages/react-reconciler/src/ReactFiberHooks.new.js
2、commit阶段的入口在:https://github.com/facebook/react/blob/17.0.0-dev/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L1775