|
react-hooks原理解析
react-hooks原理解析
张三芳
贝壳产品技术
贝壳产品技术 “贝壳产品技术公众号”作为贝壳官方产品技术号,致力打造贝壳产品、技术干货分享平台,面向互联网/O2O开发/产品从业者,每周推送优质产品技术文章、技术沙龙活动及招聘信息等。欢迎大家关注我们。 242篇内容
2022年01月07日 17:18
一、引言hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。hook的诞生是为了解决以下几个痛点。1.在组件之间复用状态逻辑很难先来举个栗子,我们要监听滚动事件,滚动到600,展示 回到顶部 按钮,实现如下:constgetPosition=()=>{ left:document.body.scrollLeft, top:document.body.scrollTop}constBackToTop=(props)=>{const[position,setPosition]=useState(getPosition())useEffect(()=>{consthandler=()=>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。如下:constusePosition=()=>{const[position,setPosition]=useState(getPosition())useEffect(()=>{consthandler=()=>setPosition(getPosition())document.addEventListener("scroll",handler)return()=>{document.removeEventListener("scroll",handler)} },[]) returnposition}较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、useState1.1、示例解析importReact,{useState}from'react'functionApp(){const[count,setCount]=useState(0)return({count}{setCount(1)setCount((state)=>state+2)setCount((state)=>state+3)}}>加)}exportdefaultApp对于上面的示例,我们需要关注的点是第一次调用函数组件时做了什么事情?(首次渲染)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可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。所以hook很大一个功能是存储state。在class组件中state是存储在fiber.memoizedState字段的,是一个对象。同理在函数组件内hook(所有的hook,不止是useState)的信息也是存储在fiber.memoizedState字段.的。以上示例中,当第一次执行 const [count, setCount] = useState(0) 时,得到的fiber.memoizedState的数据结构如图所示:字段释义:hook={//保存本hook的信息,不同的hook存储的结构不一致,在useState上代表的是state值,在上例中就是0memoizedState:null,//每次更新时的基准state,大部分情况下和memoizedState一致,有异步更新时会有差别baseState:null,//记录更新的信息queue:{dispatchSetState,//在setCount时所执行的方法lastRenderedReducer,//每次计算新state时的方法lastRenderedState,//上一次state的值pending,//存储更新,讲到setCount时再讲一下其结构},//表示上一次计算新的state之后,剩下的优先级低的更新,会流入下一次任务中计算,结构同queue.pendingbaseQueue: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、示例constinitialState={count:0};functionreducer(state,action){switch(action.type){case'increment':return{count:state.count+1};case'decrement':return{count:state.count-1};default:thrownewError();}}functionCounter(){const[state,dispatch]=useReducer(reducer,initialState);return(Count:{state.count}dispatch({type:'decrement'})}>-dispatch({type:'increment'})}>+);}我们看到在用法上,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、示例解析importReact,{useState,useEffect}from'react'functionApp(){const[count,setCount]=useState(0);useEffect(()=>{console.log('useEffect:',count);return()=>{console.log('useEffectdestory:',count);}},[count])return({count}setCount(count+1)}>加1)}exportdefaultApp;上面的示例,我们需要关注的是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 vsuseEffect 标志位情况如下:其中这几个标志位是二进制,如下://flags相关exportconstUpdateEffect=0b000000000000000100;exportconstPassiveEffect=0b000000001000000000;exportconstPassiveStaticEffect=0b001000000000000000;exportconstMountLayoutDevEffect=0b010000000000000000;exportconstMountPassiveDevEffect=0b100000000000000000;//hook标志位相关exportconstHookHasEffect=0b001;exportconstHookPassive=0b100;exportconstHookLayout=0b010;exportconstNoHookEffect=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、示例解析importReact,{useState,useCallback}from'react'functionApp(){const{count,setCount}=useState(0);constmemoizedCallback=useCallback(()=>count,[count]);return({count}{memoizedCallback})}exportdefaultApp;得到的fiber数据结构如图:其中mountCallback就是将传入的方法返回,并且将[function, 依赖项数组]做为数组存储在hook.memoizedState上面。updateCallback查看依赖项是否和上次一致,如果一致,就返回function,如果不一致,就返回新传入的function,并且重新存储一下[function, 依赖项数组]。5、useMemo vs useCallback5.1、示例解析importReact,{useState,useMemo}from'react'functionApp(){const{count,setCount}=useState(0);constmemoizedMemo=useMemo(()=>count,[count]);return({count}{memoizedMemo})}exportdefaultApp;我们看下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了。importReact,{useState,useCallback,useEffect}from'react';functionParent(){const[count,setCount]=useState(1);const[val,setVal]=useState('');constcallback=()=>{returncount;};return
{count}
setCount(count+1)}>+setVal(event.target.value)}/>;}functionChild({callback}){const[count,setCount]=useState(()=>callback());useEffect(()=>{setCount(callback());},[callback]);return{count}}我们对callback做如下改造constcallback=useCallback(()=>count,[count])如此一来,只有当count改变的时候,callback才会重新赋值,当count不改变的时候,就会从内存中取值了。5.2.2、useMemoexportdefaultfunctionWithoutMemo(){const[count,setCount]=useState(1);const[val,setValue]=useState('');constexpensive=()=>{letsum=0;for(leti=0;i
{count}-{expensive}
{val}setCount(count+1)}>+c1setValue(event.target.value)}/>;}这里创建了两个state,然后通过expensive函数,执行一次昂贵的计算,拿到count对应的某个值。我们可以看到:无论是修改count还是val,由于组件的重新渲染,都会触发expensive的执行(能够在控制台看到,即使修改val,也会打印);但是这里的昂贵计算只依赖于count的值,在val修改的时候,是没有必要再次计算的。在这种情况下,我们就可以使用useMemo,只在count的值修改时,执行expensive计算:constexpensive=useMemo(()=>{ letsum=0; for(leti=0;i
|
|