React Hooks - 03 | 内置 Hooks

@一棵菜菜  June 17, 2021

说明

包含内容:
03|内置 Hooks(1):如何保存组件状态和使用生命周期?——useState,useEffect
04|内置 Hooks(2):为什么要避免重复定义回调函数?——useCallback,useMemo,useRef,useContext


React Hooks 核心原理

遇到需求时,是基于 Hooks 去考虑组件的实现,这会是一个非常不同的思路,你完全不用去关心一个组件的生命周期是怎样的。

特别是如果你已经习惯了类组件的开发,那么要做的,甚至是彻底忘掉那些生命周期方法。不要遇到一个需求,就映射到这个功能该在哪个生命周期中去做,然后又要去想原来的声明周期方法在函数组件中应该怎么用 Hooks 去实现。

我们新的思考方式应该是:
首先考虑这个组件有哪些状态(state),这些状态的变化是由什么触发的,从而将整个功能串联起来。


useState:让函数组件具有维持状态的能力

// example:
const [count, setCount] = useState(0);

优点

让函数组件具有维持状态的能力,即:在一个函数组件的多次渲染之间,这个 state 是共享的。便于维护状态。

缺点

一旦组件有自己状态,意味着组件如果重新创建,就需要有恢复状态的过程,这通常会让组件变得更复杂

用法

useState 这个 Hook 的用法总结出来就是这样的:

  1. useState(initialState) 的参数 initialState 是创建 state 的初始值

    它可以是任意类型,比如数字、对象、数组等等。
  2. useState() 的返回值是一个有着两个元素的数组。第一个数组元素用来读取 state 的值,第二个则是用来设置这个 state 的值。

    在这里要注意的是,state 的变量(例子中的 count)是只读的,所以我们必须通过第二个数组元素 setCount 来设置它的值。
  3. 如果要创建多个 state,那么我们就需要多次调用 useState

类组件中的 state & hooks的 useState

类组件中的 state 只能有一个。所以我们一般都是把一个对象作为 一个 state,然后再通过不同的属性来表示不同的状态。
而函数组件中用 useState 则可以很容易地创建多个 state,所以它更加语义化。

什么样的值应该保存在 state 中?【还需小结并理解下】

这是日常开发中需要经常思考的问题。通常来说,我们要遵循的一个原则就是:state 中永远不要保存可以通过计算得到的值。比如说:

  • 从 props 传递过来的值。有时候 props 传递过来的值无法直接使用,而是要通过一定的计算后再在 UI 上展示,比如说排序。那么我们要做的就是每次用的时候,都重新排序一下,或者利用某些 cache 机制,而不是将结果直接放到 state 里。
  • 从 URL 中读到的值。比如有时需要读取 URL 中的参数,把它作为组件的一部分状态。那么我们可以在每次需要用的时候从 URL 中读取,而不是读出来直接放到 state 里。
  • 从 cookie、localStorage 中读取的值。通常来说,也是每次要用的时候直接去读取,而不是读出来后放到 state 里。

useEffect:执行副作用

useEffect(fn, deps);

useEffect ,顾名思义,用于执行一段副作用。

什么是副作用?

通常来说,副作用是指一段和当前执行结果无关的代码。比如说要修改函数外部的某个变量,要发起一个请求,等等。

也就是说,在函数组件的当次执行过程中,useEffect 中代码的执行是不影响渲染出来的 UI 的

小结用法

总结一下,useEffect 让我们能够在下面四种时机去执行一个回调函数产生副作用:

  • 每次 render 后执行:不提供第二个依赖项参数。
比如useEffect(() => {})
  • 仅第一次 render 后执行:提供一个空数组作为依赖项。
比如useEffect(() => {}, [])
  • 第一次以及依赖项发生变化后执行:提供依赖项数组。
比如useEffect(() => {}, [deps])
  • 组件 unmount 后执行:返回一个回调函数。
比如useEffect() => { return () => {} }, [])

拓展:理解 Hooks 的依赖

Hooks 提供了让你监听某个数据变化的能力。这个变化可能会触发组件的刷新,也可能是去创建一个副作用,又或者是刷新一个缓存。

那么定义要监听哪些数据变化的机制,其实就是指定 Hooks 的依赖项

不过需要注意的是,依赖项并不是内置 Hooks 的一个特殊机制,而可以认为是一种设计模式。有类似需求的 Hooks 都可以用这种模式去实现。

那么在定义依赖项时,我们需要注意以下三点:
依赖项中定义的变量一定是会在回调函数中用到的,否则声明依赖项其实是没有意义的。
依赖项一般是一个常量数组,而不是一个变量。因为一般在创建 callback 的时候,你其实非常清楚其中要用到哪些依赖项了。
React 会使用浅比较来对比依赖项是否发生了变化,所以要特别注意数组或者对象类型。如果你是每次创建一个新对象,即使和之前的值是等价的,也会被认为是依赖项发生了变化。这是一个刚开始使用 Hooks 时很容易导致 Bug 的地方。例如下面的代码:

function Sample() { // 这里在每次组件执行时创建了一个新数组 const todos = [{ text: 'Learn hooks.'}]; useEffect(() => { console.log('Todos changed.'); }, [todos]);}
代码的原意可能是在 todos 变化的时候去产生一些副作用,但是这里的 todos 变量是在函数内创建的,实际上每次都产生了一个新数组。所以在作为依赖项的时候进行引用的比较,实际上被认为是发生了变化的。

掌握 Hooks 的使用规则

Hooks 的使用规则包括以下两个:

  1. 只能在函数组件的顶级作用域使用;
即Hooks 不能在循环、条件判断或者嵌套函数内执行,而必须是在顶层。同时 Hooks 在组件的多次渲染之间,必须按顺序被执行
  1. 只能在函数组件或者其他 Hooks 中使用。
Hooks 作为专门为函数组件设计的机制,使用的情况只有两种,一种是在函数组件内,另外一种则是在自定义的 Hooks 里面。

即:第一,所有 Hook 必须要被执行到。第二,必须按顺序执行。

使用 ESLint 插件帮助检查 Hooks 的使用

它就是 eslint-plugin-react-hooks

要知道,这个插件几乎是 React 函数组件开发必备的工具,能够避免很多可能看上去很奇怪的错误。所以作为开始开发的第一步,一定要安装并配置好这个插件。


useCallback:缓存回调函数

useCallback(fn, deps);

为什么要使用useCallback?

在 React 函数组件中,每一次 UI 的变化,都是通过重新执行整个函数来完成的,这和传统的 Class 组件有很大区别:函数组件中并没有一个直接的方式在多次渲染之间维持一个状态。

function Counter() {
  const [count, setCount] = useState(0);
  const handleIncrement = () => setCount(count+1);
 // const handleIncrement = () => setCount((prevCont)=>prevCont+ 1);
  // ...
  return <button onClick={handleIncrement}>+</button>
}

思考下这个过程。每次组件状态发生变化的时候,函数组件实际上都会重新执行一遍。在每次执行的时候,实际上都会创建一个新的事件处理函数 handleIncrement【因为创建了新的执行环境】。

这个事件处理函数 handleIncrement 中呢,包含了 count 这个变量的闭包,以确保每次能够得到正确的结果。

【待确认】my 解析:setCount函数在其声明时所在的词法作用域之外被调用,且依然持有对该词法作用域的引用。——闭包!~~

my 补充:
函数被调用执行时,引擎会为该函数创建一个新的执行上下文,并将其推到当前执行栈的顶端,引擎会运行执行上下文在执行栈顶端的函数。
当此函数运行完成后,其对应的执行上下文将会从执行栈中弹出(并被销毁),栈顶指针下移,上下文控制权将移到当前执行栈的下一个执行上下文。

这也意味着,即使 count 没有发生变化,但是函数组件因为其它状态发生变化而重新渲染时(函数组件重新被执行),这种写法也会每次创建一个新的函数。创建一个新的事件处理函数,虽然不影响结果的正确性,但其实是没必要的。因为这样做不仅增加了系统的开销,更重要的是:每次创建新函数的方式会让接收事件处理函数的组件,需要重新渲染【这才是重点!】。

比如这个例子中的 button 组件,接收了 handleIncrement ,并作为一个属性。如果每次都是一个新的,那么这个 React 就会认为这个组件的 props 发生了变化,从而必须重新渲染。因此,我们需要做到的是:只有当 count 发生变化时,我们才需要重新定一个回调函数。而这正是 useCallback 这个 Hook 的作用。

提示:如果你确定子组件多次渲染也没有太大问题,特别是原生的组件,比如 button,那么不用 useCallback 也问题不大。但是比较好的实践是都 useCallback
import React, { useState, useCallback } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const handleIncrement = useCallback(
    () => setCount(count + 1),
    [count], // 只有当 count 发生变化时,才会重新创建回调函数
  );
  // ...
  return <button onClick={handleIncrement}>+</button>
}

useCallback 优点【理解】

  1. 只有依赖项(如count)发生变化的时候,才需要重新创建一个回调函数,这样就保证了组件不会创建重复的回调函数
  2. 而接收这个回调函数作为属性的组件,也不会频繁地需要重新渲染。——性能优化啦
使用useCallback主要是为了避免函数重新生成导致接受函数作为参数的组件也重新渲染。所以像一些事件监听的函数,比如监听下拉框的变化,按钮的点击回调,则没有必也要用useCallback包裹。

复习闭包

  function useState(name) {
    const nameVal = name||'caicai';
    function print() {  // 闭包
      console.log(nameVal);
    }

    return print;
  }
  
  var print1 = useState('shitou');
  print1(); // shitou
  print1(); // shitou
 
   var print2 = useState();
  print2(); // caicai 

useMemo:缓存计算的结果

useMemo(fn, deps);

这里的 fn 是产生所需数据的一个计算函数。通常来说,fn 会使用 deps 中声明的一些变量来生成一个结果,用来渲染出最终的 UI

这个场景应该很容易理解:如果某个数据是通过其它数据计算得到的,那么只有当用到的数据,也就是依赖的数据发生变化的时候,才应该需要重新计算。

userMemo 的好处

  1. 避免重复计算
通过 useMemo 这个 Hook,可以避免在用到的数据没发生变化时进行的重复计算。虽然例子展示的是一个很简单的场景,但如果是一个复杂的计算,那么对于提升性能会有很大的帮助。
  1. 避免子组件的重复渲染
如果每次都需要重新计算来得到 usersToShow 变量,那么对于 UserList 这个组件而言,就会每次都需要刷新,因为它将 usersToShow 作为了一个属性。而一旦能够缓存上次的结果,就和 useCallback 的场景一样,可以避免很多不必要的组件刷新。<UserList user={usersToShow} />

useCallback 的功能其实是可以用 useMemo 来实现的:

 const myEventHandler = useMemo(() => {
   // 返回一个函数作为缓存结果
   return () => {
     // 在这里进行事件处理
   }
 }, [dep1, dep2]);

注意

【官方文档】传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于 useEffect 的适用范畴,而不是 useMemo。

如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值。


useRef:在多次渲染之间共享数据

我们可以把 useRef 看作是在函数组件之外创建的一个容器空间。在这个容器上,我们可以通过唯一的 current 属设置一个值,从而在函数组件的多次渲染之间共享这个值。

useRef 的重要的功能

1. 存储跨渲染的数据

使用 useRef 保存的数据一般是和 UI 的渲染无关的,因此当 ref 的值发生变化时,是不会触发组件的重新渲染的,这也是 useRef 区别于 useState 的地方

如:

 const [time, setTime] = useState(0);
 // 定义 timer 这样一个容器用于在跨组件渲染之间保存一个变量 
 const timer = useRef(null);

  const handleStart = useCallback(() => {
    // 使用 current 属性设置 ref 的值
    timer.current = window.setInterval(() => { setTime((time) => time + 1); }, 100);
  }, []);

2. 保存某个 DOM 节点的引用

是在某些场景中,我们必须要获得真实 DOM 节点的引用,所以结合 React 的 ref 属性和 useRef 这个 Hook,我们就可以获得真实的 DOM 节点,并对这个节点进行操作。

react官方例子:

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // current 属性指向了真实的 input 这个 DOM 节点,从而可以调用 focus 方法
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}
【理解】可以看到ref 这个属性提供了获得 DOM 节点的能力,并利用 useRef 保存了这个节点的应用。这样的话,一旦 input 节点被渲染到界面上,那我们通过 inputEl.current 就能访问到真实的 DOM 节点的实例了。

useContext:定义全局状态

为什么要使用 useContext?【理解】

因:React 组件之间的状态传递只有一种方式,那就是通过 props。缺点:这种传递关系只能在父子组件之间进行。

那么问题出现:跨层次,或者同层的组件之间要如何进行数据的共享?这就涉及到一个新的命题:全局状态管理

果:react提供的解决方案:Context 机制。

context 美 /ˈkɑːntekst/ n. 环境;上下文

具体原理【理解】

React 提供了 Context 这样一个机制,能够让所有在某个组件开始的组件树上创建一个 Context。这样这个组件树上的所有组件,就都能访问和修改这个 Context 了

那么在函数组件里,我们就可以使用 useContext 这样一个 Hook 来管理 Context。

使用

原为官方例子

拓展修改如下:
App.jsx:

// 创建一个 Context 的 API:React.createContext(themes.light)

// 按照自顶向下的顺序,先在根组件 App 中配置好需要的 theme 上下文。
// 1. 创建一个 theme 的 Context,初始值为 themes.light(可以不设置的)。用来向子组件传递 theme
export const ThemeContext = React.createContext(themes.light);

function App() {
  // 使用 state 来保存 theme 从而可以实现动态切换主题的效果
  const [theme, setTheme] = useState("dark");

  // 切换 theme 的回调函数
  const handleToggleTheme = useCallback(() => {
    setTheme((prevTheme) => {
      return prevTheme === "light" ? "dark" : "light";
    });
  }, []);

  // 2. 整个应用使用 ThemeContext.Provider 作为根组件,则其所有子组件都有能力访问到其值,即theme.
  return (
    // value:使用 theme state 作为当前 Context 值
    <ThemeContext.Provider value={themes[theme]}>
      <div>
        <button onClick={handleToggleTheme}>switch theme</button>
      </div>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

export default App;

Toolbar.jsx:

import React from 'react';
import ThemedButton from './ThemedButton';

function Toolbar() {
  return <ThemedButton></ThemedButton>;
}

export default Toolbar;

ThemedButton.jsx:

import React, { useContext } from 'react';
import {ThemeContext} from './App';

function ThemedButton() {
  // 3. 在函数组件中通过 useContext 获取指定的 ThemeContext
  const theme = useContext(ThemeContext);

  return (
    <button style={{ color: theme.foreground, background: theme.background }}>
      I am styled by theme context!
    </button>
  );
}

export default ThemedButton;

优点

Context 提供了一个方便在多个组件之间共享数据的机制。【牢记】

my: 如果数据传递层次比较深,就不需要像是用props那样一层层往下传递数据啦。

缺点【理解】

Context 相当于提供了一个定义 React 世界中全局变量的机制,而全局变量则意味着两点:

  1. 会让调试变得困难,因为你很难跟踪某个 Context 的变化究竟是如何产生的。【my:所以 Redux 更胜一筹,因为 Redux 提供了可预测的状态管理机制】
  2. 组件的复用变得困难,因为一个组件如果使用了某个 Context,它就必须确保被用到的地方一定有这个 Context 的 Provider 在其父组件的路径上。

实际应用场景【熟悉】

由于以上缺点,所以在 React 的开发中,除了像 ThemeLanguage一目了然的需要全局设置的变量外(my: 只读取,不需要在项目里手动触发修改的),我们很少会使用 Context 来做太多数据的共享。需要再三强调的是,Context 更多的是提供了一个强大的机制,让 React 应用具备定义全局的响应式数据的能力。

此外,很多状态管理框架,比如 Redux,正是利用了 Context 的机制来提供一种更加可控的组件之间的状态管理机制。因此,理解 Context 的机制,也可以让我们更好地去理解 Redux 这样的框架实现的原理。

小结

事实上,每一个 Hook 都是为了解决函数组件中遇到的特定问题:

  • useState():让函数组件具有维持状态的能力,在函数组件多次渲染之间共享(状态)数据。
  • useCallback(fn, deps):缓存回调函数。
  • useMemo(fn, deps):缓存计算的结果;fn 会在渲染期间执行,所以fn函数内部是要执行与渲染相关的操作!
  • useRef:在函数组件多次渲染之间共享数据(即:存储跨渲染的数据;保存某个 DOM 节点的引用 )。保存的数据一般是和 UI 的渲染无关的(则不会触发组件重新渲染执行)!
  • useContext:定义全局状态。解决了 react 只能父子组件进行状态传递的困局。提供了一个方便在多个组件之间共享数据的机制。【my】缺点:相比Redux,是难追踪、无法预测、组件难复用的状态管理。

useCallbackuseMemo,从本质上来说,它们只是做了同一件事情:建立了一个绑定某个结果到依赖数据的关系。只有当依赖变了,这个结果才需要被重新得到

useRefuseState的区别:useRef保存的数据一般是和 UI 的渲染无关的,所以当 ref 的值发生变化时,是不会触发组件的重新渲染的。但当state变化时必定会触发组件重新渲染执行。

const handleClick = useCallback(fn, deps); // 相当于 useMemo(() => fn, deps)

const data = useMemo(fn, deps);

const myRefContainer = useRef(initialValue); // myRefContainer.current = ...
// 如:
myRefContainer.current = setInterval(...)
// 1. 创建一个 Context
export const MyContext = React.createContext(initialValue);
// 2. 整个应用使用 MyContext.Provider 作为根组件,则其所有子组件都有能力访问到其值,即data.
<MyContext.Provider value={data}>
    ...
</MyContext.Provider>

// 3. 子组件中使用指定的 Context
const value = useContext(MyContext);

提问摘抄

问题1:函数最好都用useCallback包裹吗?【牢记】

是任何场景 函数都用useCallback 包裹吗?那种轻量的函数是不是不需要?

作者回复: 确实不是,useCallback 可以减少不必要的渲染,主要体现在将回调函数作为属性传给某个组件。如果每次都不一样就会造成组件的重新渲染。但是如果你确定子组件多次渲染也没有太大问题,特别是原生的组件,比如 button,那么不用 useCallback 也问题不大。所以这和子组件的实现相关,和函数是否轻量无关。但是比较好的实践是都 useCallback。

官方文档:当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

useMemo

useMemo 其实除了解决自身计算的性能问题之外,还有就是可以避免 接收这个数据的组件过多的重新渲染,以及依赖这个数据的其它 hooks 多余的计算。所以即使简单的计算,最好也是用 useMemo。

问题:【我犯的的常见错误...】

如果通过useMemo,依赖数组是容器组件的state,来优化展示组件的渲染,这个方案可行吗?

 const ItemRender = useMemo(() =><ComA {...props}/>},[state])
...
{ItemRender}
作者回复: 没有必要,因为虚拟 DOM 会帮你做这个优化。这种写法,其实虚拟 DOM 还是需要去整体进行 diff 计算的。
其他student::感觉之前为了想减少UI组件渲染,都这么写。。。害,不用优化的地方优化。

问题2

const handleIncrement = useCallback(() => setCount(count + 1), [count]);
const handleIncrement = useCallback(() => setCount(q => q + 1), []);

在性能方面是否后者优于前者?

作者回复: 严格来说,后者确实优于前者,因为前者会在count变化的时候创建新的回调函数,后者在 count 变化时不会创建新的 handleIncrement 这样的 callback,这样接收这个属性的组件就不需要重新刷新。但是对于简单的场景,可以忽略这种差异。

只有需要触发 UI 更新的状态才需要放到 state 里。这里的 timer 其实只是临时存放一个变量,无需用 state 保存。否则会造成不必要的渲染。

问题3

useEffectuseCallbackuseMemo的依赖机制一样吗?都是浅比较吗?

作者回复: 是的,所以依赖比较都是浅比较

问题4:useCallback、箭头函数

<button onClick={() => handleClick('hi')}></button>

老师,上面这种写法,直接将箭头函数作为 props 传递给 button,是不是每次 render 的时候,也会生成一个新的箭头函数?如果是的话,怎么避免呢?

作者回复: 是的,这种问题不大,因为 button 没有子节点,性能问题可以忽略。要避免的话就是用 useCallback。参数的话是可以在 useCallback 里处理的。

添加新评论