深入react hooks -复习

lxf2023-04-05 14:00:02

为什么会出现hooks?

  • 组件逐渐被函数式组件完全取代
    • 函数式组件捕获了渲染所用的值。
    • 函数组件更加符合设计理念、也更有利于逻辑拆分和复用
    • 相比类组件,函数组件编写更轻便,生成的代码也更少 hooks是完全为函数组件设计的,它能让函数组件拥有类组件的能力,并且保留轻量、优雅的特性。 探讨原理之前先看看如下几个问题?
  • 1 在无状态组件每一次函数上下文执行的时候,react用什么方式记录了hooks的状态?
  • 2 多个react-hooks用什么来记录每一个hooks的顺序的 ? 换个问法!为什么不能条件语句中,声明hookshooks声明为什么在组件的最顶部?
  • function函数组件中的useState,和 class类组件 setState有什么区别?
  • react 是怎么捕获到hooks的执行上下文,是在函数组件内部的?
  • useEffect,useMemo 中,为什么useRef不需要依赖注入,就能访问到最新的改变值?
  • useMemo是怎么对值做缓存的?如何应用它优化性能
  • 7 为什么两次传入useState的值相同,函数组件不更新?

function组件和class组件本质的区别

首先回顾一下function组件和class组件的区别

// class组件
class Index extends React.Component<any,any>{
    constructor(props){
        super(props)
        this.state={
            number:0
        }
    }
    handerClick=()=>{
       for(let i = 0 ;i<5;i++){
           setTimeout(()=>{
               this.setState({ number:this.state.number+1 })
               console.log(this.state.number) // 12345
           },1000)
       }
    }

    render(){
        return <div>
            <button onClick={ this.handerClick } >num++</button>
        </div>
    }
}
// 函数组件

function Index(){
    const [ num ,setNumber ] = React.useState(0)
    const handerClick=()=>{
        for(let i=0; i<5;i++ ){
           setTimeout(() => {
                setNumber(num+1)
                console.log(num) // 00000
           }, 1000)
        }
    }
    return <button onClick={ handerClick } >{ num }</button>
}

为什么会产生两种不一样的结果? 第一个类组件中,由于执行上setState没有在react正常的函数执行上下文上执行,而是setTimeout中执行的,批量更新条件被破坏。所以可以直接获取到变化后的state

这里的批处理条件是啥?

【React的更新机制】

生命周期函数和合成事件中:

  1. 无论调用多少次setState,都不会立即执行更新。而是将要更新的state存入'_pendingStateQuene',将要更新的组件存入'dirtyComponent';
  2. 当根组件didMount后,批处理机制更新为false。此时再取出'_pendingStateQuene'和'dirtyComponent'中的state和组件进行合并更新;

原生事件和异步代码中:

  1. 原生事件不会触发react的批处理机制,因而调用setState会直接更新;
  2. 异步代码中调用setState,由于js的异步处理机制,异步代码会暂存,等待同步代码执行完毕再执行,此时react的批处理机制已经结束,因而直接更新

在解释react-hooks原理的之前,我们要加深理解一下, 函数组件和类组件到底有什么区别 函数式组件捕获了渲染所使用的值*,并且我们还能进一步意识到:函数组件真正将数据和渲染紧紧的绑定到一起了React有一个经典公式:ui=f(data),即同样的输入必定有同样的 结果,函数式组件捕获了渲染时所用的值,因此组件每一次渲染都会有自己的props和state。每一次渲染都会有 自己的事件处理函数,而这里每次渲染都有独立的 state 上下文

为什么会出现hooks?

对于class组件,我们只需要实例化一次,实例中保存了组件的state等状态。对于每一次更新只需要调用render方法就可以。但是在function组件中,每一次更新都是一次新的函数执行,为了保存一些状态,执行一些副作用钩子,react-hooks应运而生,去帮助记录组件的状态,处理一些额外的副作用。

原理

概念

Hooks链表

无论是初次挂载还是更新,每调用一次hooks函数,都会产生一个hook对象与之对应。以下是hook对象的结构。

深入react hooks -复习 产生的hook对象依次排列,形成链表存储到fiber.memoizedState上。 在这个过程中,有个十分重要的指针:workInProgressHook,它通过记录当前生成(更新)的hook对象,可以间接反映在组件中当前调用到哪个hook函数了。每调用一次hook函数,就将这个指针的指向移到该hook函数产生的hook对象上。例如:

深入react hooks -复习

那么构建hook链表的过程,可以概括为下面这样 调用useState('A'):

深入react hooks -复习 调用useEffect:

深入react hooks -复习

memorizedState

memorizedState 就是保存 hooks 数据的地方。它是一个通过 next 串联的链表。与我们调用的hooks是按照调用hooks的顺序对应起来的。

深入react hooks -复习 这就是 hooks 存取数据的地方,执行的时候各自在自己的那个 memorizedState 上存取数据,完成各种逻辑。

这个 memorizedState 链表是什么时候创建的呢?

好问题,确实有个链表创建的过程,也就是 mountXxx。链表只需要创建一次(此时会调用ountWorkInProgressHook),后面只需要 update(此时会调用updateWorkInProgressHook)。 比如第一次调用 useState 会执行 mountState,后面再调用 useState 会执行 updateState。

创建 memorizedState 链表的过程

mountXxx 是创建 memorizedState 链表的过程,每个 hooks api 都是这样的:

深入react hooks -复习 其中mountXXX的过程就是创建对应的 memorizedState 对象,然后用 next 串联起来。

useRef

每个 useXxx 的 hooks 都有 mountXxx 和 updateXxx 两个阶段,比如 ref 就是 mountRef 和 updateRef。

 useRef<T>(initialValue: T): {|current: T|} {
      currentHookNameInDev = 'useRef';
      mountHookTypesDev();
      return mountRef(initialValue);
    }

深入react hooks -复习

其中:mountWorkInProgressHook :创建并返回 memorizedState 链表 updateWorkInProgressHook:更新。

Object.freeze vs Object.seal Object.seal:只要是可写的,就可以更改它们的值。

useRef的原理

在memorizedState上挂载了有 current 属性的对象,冻结了一下,后面 update 的时候,没有做任何处理,直接返回这个对象。 所以:useRef 可以保存一个数据的引用,这个引用不可变。

useRef跟直接在组件文件顶层定义一个对象的区别

前者在组件卸载的时候会释放,后者不会,当从组件A切换到组件B的时候假如import 组件A这一句还在,那么后者占用的内存依旧存在,原因是:ES6 module 每个模块的作用域都是独立的,不属于全局作用域 切换到组件B之后,组件A的引用还在,它所形成的局部作用域并未被销毁。

那如果在函数组件内部定义一个变量呢? 这样的每次function重新执行的时候它都会重新创建一次,它的引用一直都会变化,相比之下 useRef是不会的。

useCallback

深入react hooks -复习

useCallback的原理

useCallback 在 memorizedState 上放了一个数组,第一个元素是传入的回调函数,第二个是传入的依赖。 更新的时候把之前的那个 memorizedState 取出来,和新传入的 deps 做下对比,如果没变,那就返回之前的回调函数,也就是 prevState[0]。 如果变了,那就创建一个新的数组,第一个元素是传入的回调函数,第二个是传入的 deps。 所以,useCallback 的功能也就呼之欲出了:useCallback 可以实现函数的缓存,如果 deps 没变就不会创建新的,否则才会返回新传入的函数

useMemo

跟useCallback类似,只是函数变成值。 useMemo 也在 memorizedState 上放了个数组,第一个元素是传入函数的执行结果,第二个元素是依赖。 更新的时候也是取出之前的 memorizedState,和新传入的 deps 做下对比,如果没变,就返回之前的值,也就是 prevState[0]。

如果变了,创建一个新的数组放在 memorizedState,第一个元素是新传入函数的执行结果,第二个元素是依赖。

自定义hooks

其实就是个函数调用,没啥神奇的,我们可以把上面的 hooks 放到 xxx 函数里,然后在 function 组件里调用,对应的 hook 链表是一样的。 深入react hooks -复习

因此自定义hooks在不同的组件之间调用不可能共享state这些。

useState跟useEffect的原理

本质上就是闭包加上数组

const MyReact = (function() {
   let hooks = [],
     currentHook = 0 // hooks数组, 和一个iterator!
   return {
     // 注意这里的render相当于ReactDOM的render而不是rerender!所以要复位
     render(Component) {
       // rerender是不会重新执行组件的
       const Comp = Component() // 运行 effects
       Comp.render()
       currentHook = 0 // 复位,为下一次render做准备
       return Comp
     },
     // 注意这里只是callback不会每次rerender的时候运行,useEffect还是会的,
     // 所以每次的depArray都是当前依赖最新的值
     useEffect(callback, depArray) {
       const hasNoDeps = !depArray
       // 闭包变量来啦,它记录的是初始化或者上一次依赖变化时候依赖的值
       const deps = hooks[currentHook] // type: array | undefined
       // console.log('deps::: ', deps,depArray);
       const hasChangedDeps = deps ? !depArray.every((el, i) => el === deps[i]) : true
       // 如果是初始化或者是依赖变了 执行回调函数
       if (hasNoDeps || hasChangedDeps) {
         callback()
         hooks[currentHook] = depArray
       }
       currentHook++ // 本hook运行结束
     },
     useState(initialValue) {
       // 上一次调用useState currentHook++了,所以第二调用的时候,往hooks里push新的[0] -> [0,'foo']
       // hooks[currentHook] = hooks[currentHook] || initialValue // type: any
       // currentHook++不能放在这里只能放在最后一行,因为如果放在这里就是:hooks[0]=initvalue 但是返回的却是[hooks[1]]
       hooks[currentHook]=initialValue
       const setStateHookIndex = currentHook // 给setState的闭包准备的变量!因为这里的useState只执行一次,所以这里的
       // setStateHookIndex永远等于该useState的值在hooks中对应的位置,否则的话每次render的时候currentHook都会变化,导致setState修改错位置
       const setState = newState => {
         console.log('newState::: ', setStateHookIndex,newState,currentHook,hooks);
         return (hooks[setStateHookIndex] = newState)
       }
        // 调用useState第一次:[0] 调用第二次 [0,'foo]
       console.log(hooks,currentHook)
       // 注意这里是currentHook++而不是++currentHook,所以第一次currentHook++=0
       return [hooks[currentHook++], setState]
     }
   }
 })()

 function Counter() {
   const [count, setCount] = MyReact.useState(0)
   const [text, setText] = MyReact.useState('foo') // 第二个 state hook!
   // MyReact.useEffect(() => {
   //   console.log('effect', count, text)
   // }, [count, text])
   return {
     click: () => setCount(count + 1),
     type: txt => setText(txt),
     noop: () => setCount(count),
     render:()=>{}
     // render: () => console.log('render', { count, text })
   }
 }
 let App
 App = MyReact.render(Counter)
 // effect 0 foo
 // render {count: 0, text: 'foo'}
 App.click()
 App = MyReact.render(Counter)
 // effect 1 foo
 // render {count: 1, text: 'foo'}
 App.type('bar')
 // App.type('ss')
 // App = MyReact.render(Counter)
 // // effect 1 bar
 // // render {count: 1, text: 'bar'}
 // App.noop()
 // App = MyReact.render(Counter)
 // // // no effect run
 // // render {count: 1, text: 'bar'}
 // App.click()
 // App = MyReact.render(Counter)

总结: 以下的私有闭包变量就是说内层函数变量 它相当于这个内层函数的私有变量,多次内层函数调用,每次的私有变量相互独立。

  1. 闭包套闭包,第一个闭包记住hooks们要用到的状态,存放在hooks数组中,以避免每次rerender之后丢失,这里的hooks数组按照调用useEffect useState等钩子的顺序存放state或者是useEffect的依赖项的值
  2. useState中利用私有闭包变量记住自己在hooks数组中的位置,然后每次更新的时候用这个闭包变量作为索引去找到自己在数组中的位置然后更新
  3. useeffect中利用私有闭包变量记住自己的依赖项的值,每次更新的时候调用一次useEffect,注意是调用useEffect而不是它的回调函数,然后比较一下最新的依赖的值跟之前的依赖项的值,如果不一样就更新闭包变量记录的当前依赖项的值以供下次使用,并且执行回调函数。

常见问题

为什么不能将hooks放在条件语句

在组件第一次渲染的时候,为每个hooks都创建了一个对象

type Hook = {
  memoizedState: any,
  baseState: any,
  baseUpdate: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null,
  next: Hook | null,
};

最终形成了一个链表

深入react hooks -复习 memoizedState属性就是用来存储组件上一次更新后的 state,next指向下一个hook对象。在组件更新的过程中,hooks函数执行的顺序是不变的,如果放到条件语句中可能会导致顺序错乱,导致当前hooks拿到的不是自己对应的Hook对象。

为什么useState useEffect这里要使用闭包

首先回顾一下闭包的特性

1.函数内再嵌套函数
2.内部函数可以引用外层的参数和变量
3.参数和变量不会被垃圾回收机制回收

如果这里这样写useState:

useState(initialValue) {
        var _val = initialValue
        // 不使用state()函数
        function setState(newVal) {
          _val = newVal
        }
        return [_val, setState] // 直接对外暴露_val
      }

那么每次rerender的时候都会走一遍:

var _val = initialValue

这样的话每次rerender之后,state的值都变成初始值了,这就是为什么要用到闭包变量hooks的原理

闭包陷阱

import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
function App() {
  const [count, setCount] = useState(0);
  const clickCb = () => {
  // 这里拿到的count始终是初始值
    setCount(count + 1);
  };
  useEffect(() => {
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, []);
  return (
    <>
      <button>+1</button>
      <span>count:{count}</span>
    </>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

因为useEffect的回调只执行了一次,这个回调函数作用域中的闭包永远引用着App函数第一次执行时创建的作用域。

深入react hooks -复习

怎么解决

方式一:useLayoutEffect + useRef

function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);
  const handleBtnClick = () => {
    setCount(count + 1);
  };
  const clickCb = () => {
    console.log("clickCb countRef.current", countRef.current);
  };
  useLayoutEffect(() => {       // +
    countRef.current = count;   // + 这里是每当count改变自动同步更新countRef的值
  }, [count]);                  // +
  useEffect(() => {
  
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, []);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}
为什么不是useEFfect?
useEffect的执行时机 vs useEffectLayout

useEffect 在每轮渲染之后执行 useEffect确实相当于异步执行,它是深度优先,且从最底层的子组件开始执行,冒泡到最父级组件,但是也不是完全的异步。每个组件内的useEffect也是按顺执行。 useLayoutEffect类似于useEffect,深度优先,但执行优先级高于useEffect,而且没有阻塞dom render 。 useLayoutEffect的是在渲染器执行当前渲染界面任务时,同步执行。 在当前一轮的Reconciler任务调度过程中,在渲染器执行完当前任务后,才会异步调用useEffect。 useLayoutEffect先于useEffect执行,并且子组件优先执行。 如果是useEffect的话,还没执行它就触发了click事件打印了值

方式二:直接在外面更新useRef的值

export function useLatest(value) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}
function App() {
  const [count, setCount] = useState(0);
  const latestCountRef = useLatest(count);
  const handleBtnClick = () => setCount(count + 1);
  const clickCb = () => {
    console.log("clickCb latestCountRef", latestCountRef.current);
  };
  useEffect(() => {
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, []);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}
ReactDOM.render(<App />, document.getElementById("root"));

为什么是useRef? countRef对象和函数App多次重新render时操作的countRef对象是同一个内存地址(useRef在hook.memoizedState保存着),所以是可以获取到预期的值的。 方式三:直接修改state为对象,然后更改state的时候:

方式四:增加依赖项:count让回调函数重新执行

useEffect

Hooks 是否因为在渲染中创建函数而变慢?

不会。在现代浏览器中,除了在极端情况下,闭包的原始性能与类相比没有显着差异。

此外,考虑到 Hooks 的设计在以下几个方面更高效:

  • 挂钩避免了类所需的大量开销,例如在构造函数中创建类实例和绑定事件处理程序的成本。
  • 使用 Hooks 的惯用代码不需要在使用高阶组件、渲染道具和上下文的代码库中普遍存在的深层组件树嵌套。使用更小的组件树,React 要做的工作更少

如何测量 DOM 节点?

function MeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback(node => {    if (node !== null) {      setHeight(node.getBoundingClientRect().height);    }  }, []);
  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}

参考

  • zhuanlan.zhihu.com/p/66923924
  • www.51cto.com/article/704…
  • cooperhu.com/2020/09/03/…