React Hook 指南(上)

Hook开发的好处

更灵活的状态和副作用管理

在传统的类组件中,状态的管理和生命周期方法紧密耦合在一起。这种耦合可能导致代码逻辑在不同生命周期方法之间分散,难以理解和维护。当组件逻辑变得复杂时,这种分散的状态和生命周期方法会增加代码的复杂性。

在使用 Hook 时,你可以在函数组件内部使用多个 useState 和 useEffect。每个 useState 管理一个特定的状态,而 useEffect 可以处理特定的副作用。这种分离使得每部分逻辑更加独立,易于理解和维护。

提高组件的可复用性

使用 Hook 可以将状态逻辑抽离到自定义 Hook 中,使得状态逻辑更易于复用。例如,你可以创建一个 useFetch Hook 用于处理数据获取逻辑,然后在多个组件中重复使用,而不需要每次都编写相似的逻辑。

更好的性能优化和逻辑封装

Hook 可以帮助 React 进行性能优化。例如,useMemouseCallback 可以用于缓存计算结果,useMemo 可以在依赖不变的情况下避免不必要的重新计算,useCallback 可以避免函数的重复创建。

Hook开发注意事项

  • 仅在 React 函数组件或自定义 Hook 中调用 Hooks,确保在函数组件的顶层使用 Hook,不要在循环、条件语句或嵌套函数中使用。
  • 不要在普通的 JavaScript 函数中使用 Hook。
  • 自定义 Hook 的名称必须以 use 开头,这是 React 的约定,它告诉开发者这是一个自定义 Hook。

useState

用法

useState 是 React Hooks 中的一个核心 Hook,用于在函数组件中添加状态。它返回一个状态变量和一个更新该状态的函数,通常用一个数组接收:

在这个例子中,通过 useState 定义了一个名为 count 的状态变量,初始值为 0。useState 返回一个数组,第一个元素是当前的状态值(在这里是 count),第二个元素是更新该状态的函数(在这里是 setCount)。当按钮被点击时,我们调用 setCount 函数来更新 count 的状态,使其加一。

javascript 复制代码
import React, { useState } from 'react';

function ExampleComponent() {
  // 定义一个名为 count 的状态变量,初始值为 0
  const [count, setCount] = useState(0);

  // 在函数组件中使用 count 状态变量和 setCount 函数
  return (
    <div>
      <p>You clicked {count} times</p>
      {/* 当按钮被点击时,调用 setCount 函数更新 count 状态 */}
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useState 还可以接受一个函数作为参数,当初始化组件时,React 将调用初始化函数,并将其返回值存储为初始状态。

useEffect

副作用

React中副作用是指不直接与组件渲染相关的操作,比如数据获取、订阅、手动操作DOM。举个例子,当一个组件需要从服务器获取数据时,这个数据请求就是一个副作用。这个副作用可能会在组件渲染时触发,但它不是直接与渲染UI相关的。

Effect基本用法

useEffect 是 允许在函数组件中执行副作用操作,比如数据获取、订阅、或者手动操作 DOM。useEffect 在组件渲染到屏幕上或组件重新渲染时都会执行。

useEffect 接受两个参数:一个包含副作用操作的函数和一个依赖数组(可选)。在函数组件的每次渲染周期中,React 都会在执行完所有的 DOM 变更之后运行 useEffect 中的函数


在下面例子中,useEffect 用于更新页面标题,它接受一个函数作为第一个参数,在这个函数内部进行了副作用操作。useEffect 中的函数在每次组件渲染之后都会执行。

第二个参数是依赖数组,它指定了 useEffect 的依赖项。当依赖项发生变化时,useEffect 会被重新执行。在上面的例子中,我们传入了 [count],所以当 count 发生变化时,useEffect 会重新运行。

javascript 复制代码
import React, { useEffect, useState } from 'react';

function ExampleComponent() {
  const [count, setCount] = useState(0);

  // 使用 useEffect 进行副作用操作
  useEffect(() => {
    document.title = `You clicked ${count} times`; // 更新页面标题
    // 进行其他副作用操作,例如数据获取、订阅等

    // 清理函数(可选),用于清理副作用
    return () => {
      // 在组件卸载或依赖项变化时执行清理操作
      // 例如取消订阅、清除定时器等
    };
  }, [count]); // 依赖数组中的变量,当其中的变量发生变化时,useEffect 会重新运行

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

使用useEffect可以确保副作用操作不会影响到组件的渲染性能,而且在组件卸载或者依赖项发生变化时,可以在useEffect中返回一个清理函数,用于清理副作用。

清除Effect

使用 useEffect Hook 可以处理副作用操作。有时,当组件卸载或依赖项发生变化时,希望清理之前设置的副作用,以免引发内存泄漏或其他不必要的问题。React 的 useEffect 提供了清除 Effect 的机制。

在 useEffect 中,可以返回一个清理函数,它会在组件卸载时(或者依赖项发生变化时)执行。这个清理函数用于清除之前设置的副作用,比如取消订阅、清除定时器等。

在这个例子中,useEffect 用于监听Redux数据的变化。当组件渲染到屏幕上后,useEffect 中的回调函数会被执行。在这里,它输出 "监听redux数据变化"。同时,useEffect 的返回函数会在组件卸载时执行,可以在这里执行取消监听的相关操作。

javascript 复制代码
import React, { memo, useEffect, useState } from "react";

const App = memo(() => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log("监听redux数据变化");
    // Effect 返回清除函数:组件重新渲染或组件卸载时执行,清除副作用
    return () => {
      console.log("取消redux中数据监听");
    };
  });
  return (
    <div>
      <h3>计数:{count}</h3>
      <button onClick={(e) => setCount(count + 1)}>+1</button>
    </div>
  );
});

export default App;

清除 Effect 的机制有助于确保在组件销毁时进行资源清理,避免内存泄漏和不必要的性能损耗。

多个Effect

在开发函数组件时,内部往往包含多个用途的逻辑代码 ,开发者可以同时使用多个 useEffect。每个 useEffect 包含特定用途的副作用逻辑。当在组件内使用多个 useEffect 时,React 会按照它们在代码中的顺序依次调用每个 useEffect。

这种特性使得将不同的副作用逻辑分离并组织得更好。例如,一个 useEffect 可能用于数据获取,另一个可能用于订阅外部事件。

举个例子,考虑一个在线聊天应用的组件。可以通过其中一个useEffect 订阅聊天消息,另一个 useEffect 检查用户是否在线,还有一个 useEffect 用于定时发送心跳包以保持连接。这样,你可以在组件内部根据逻辑关系将不同的副作用操作分离开来,使得代码更加清晰易读。

例如下面例子,可以考虑将不同的副作用逻辑分离在多个Effect Hook当中。

javascript 复制代码
import React, { memo, useEffect, useState } from "react";

const App = memo(() => {
  const [count, setCount] = useState(10);
  useEffect(() => {
    console.log("1.进行网络请求");
  });
  useEffect(() => {
    console.log("2.进行事件监听");
    return () => {
      // 取消事件监听
    };
  });
  useEffect(() => {
    console.log("3.执行数据处理逻辑");
  });
  return (
    <div>
      <h2>计数:{count}</h2>
      <button onClick={(e) => setCount(count + 1)}>+1</button>
    </div>
  );
});

export default App;

Effect执行机制

默认情况下,useEffect的回调函数在每次渲染时都会重新执行,然而很多副作用(例如网络请求)只希望执行一次,所以可以通过useEffect的第二个参数依赖数组来控制Effect的执行。

因此如果希望在组件第一次渲染时执行某个副作用操作(比如网络请求),但在后续的渲染中不再执行,可以将依赖项数组设置为空数组 []。这样,useEffect 就只会在组件第一次渲染时执行,之后就不再执行。

scss 复制代码
useEffect(() => {
  // 这里的副作用操作只在组件第一次渲染时执行
  fetchData();
}, []); // 传递空数组作为依赖项

此外,如果希望某个副作用仅仅在依赖的数据发生变化时才执行,则可以为依赖数组内添加具体的值

scss 复制代码
useEffect(() => {
  // ...
}, [count, title]); // 如果 count 或 title 发生变化则会再次运行

useContext

在类组件中,可以通过类名.contextType = xxxContext的方式在类组件中获取context,例如如下案例:

通过HomeInfo.contextType = ThemeContext;,可以在组件中使用ThemeContext中的值,下方是类组件中Context的使用方式

scala 复制代码
export class HomeInfo extends Component {
  render() {
    // log打印出使用Context传递的数据
    console.log("context", this.context);
    return (
      <div>
        HomeInfo: {this.context.color}
      </div>
    );
  }
}

// 3.设置组件的contextType为某一个具体的Context
HomeInfo.contextType = ThemeContext;
export default HomeInfo;

然而在函数式组件中,可以通过useContexthook来访问context的值

1.创建上下文,使用 React.createContext() 创建上下文对象

javascript 复制代码
import { createContext } from "react";

const UserContext = createContext()
const ThemeContext = createContext()

export {
    UserContext,
    ThemeContext
}

在某个组件的父组件中使用 MyContext.Provider 提供context的值 。在子组件中,可以使用 useContext Hook 来获取上下文的值:

ini 复制代码
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <UserContext.Provider value={{ name: "weihy", school: "ustb" }}>
    <ThemeContext.Provider value={{ color: "orange", size: "22px" }}>
      <Provider store={store}>
        <App />
      </Provider>
    </ThemeContext.Provider>
  </UserContext.Provider>
);

如下,通过useContext来直接在函数式组件中获取context的值。

UserContextThemeContexd中分别获取了当前用户信息和主题样式。这些上下文对象应该是在组件的上层组件中(通常是在应用的根组件中)通过<UserContext.Provider><ThemeContext.Provider>提供的。

ini 复制代码
const App = memo(() => {
  const user = useContext(UserContext);
  const theme = useContext(ThemeContext);
  return (
    <div>
      <h2>
        name: {user.name} school: {user.school}
      </h2>
      <div style={{ fontSize: theme.size, color: theme.color }}>文字样式</div>
    </div>
  );
});

最终运行效果如下


需要注意的是

  • 如果一个组件使用了 useContext(),那么相应的 <Context.Provider> 必须在这个组件的上层,确保 useContext() 能够找到相应的context提供者。
  • memo不影响新的上下文值的传递:即使在组件内使用了 memo,当上下文提供的值变化时,使用了这个上下文的子组件仍然会接收到新的上下文值。memo只是帮助组件避免不必要的渲染,但并不影响新的上下文值的传递。
  • useContext 返回的值是实时的,即使上下文的值发生变化,useContext 获取的值也会随之更新。当上下文的值发生变化时,React 会自动重新渲染使用了该上下文的组件,确保它们使用的上下文值是最新的。
  • 如果在组件的上层没有匹配的 Provider,那么 useContext(SomeContext) 将返回在创建上下文时通过 createContext 函数传递的默认值。

useReducer

用法

useReducer 是 React Hooks 中的一个 Hook,用于处理具有复杂状态逻辑的组件。它是useState的替代方案。与useState的不同之处在于,它可以在组件外部定义一个 reducer 函数,该函数描述了状态如何更新。然后,将这个 reducer 函数传递给 useReducer,在组件内部使用返回的 dispatch 函数来触发状态的更新。

scss 复制代码
const [state, dispatch] = useReducer(reducer, initialState);
  • reducer 是一个函数,它接收当前的 state 和一个 action,并返回新的 state。action 是一个描述发生了什么事情的对象,它通常包含一个 type 属性,用于表示操作的类型,以及可选的 payload 属性,用于携带操作所需的数据。
  • initialState 是状态的初始值。
  • state 是当前的状态值。
  • dispatch 是一个触发操作的函数,它用于发送一个 action,触发 reducer 的执行,从而更新 state。

案例

下列代码演示了如何使用 useReducer 处理状态逻辑,通过 reducer 函数控制状态的变化。当点击按钮时,会触发 increment 操作,使得 count 的值逐渐增加,但不会超过指定的最大值 10,也不会小于最小值 1。

const [count, dispatch] = useReducer(reducer, 5);:使用 useReducer 创建了一个名为 count 的状态变量,初始值为 5,并且提供了上面定义的 reducer 函数来处理状态更新。dispatch 是一个触发状态更新的函数。

function reducer(state, action) { ... }:定义了一个 reducer 函数,该函数接收两个参数:state(当前状态)和 action(触发的动作)。根据 action.type 的不同,它决定了如何更新状态。

javascript 复制代码
function reduer(state, action) {
  switch (action.type) {
    case "increment":
      return state < action.max ? state + 1 : action.min;
    default:
      return state;
  }
}
const App = memo(() => {
  // 初始值设置为5
  const [count, dispatch] = useReducer(reduer, 5);
  return (
    <div>
      <h2>点击{count}次</h2>
      <button onClick={(e) => dispatch({ type: "increment", max: 10, min: 1 })}>
        点击
      </button>
    </div>
  );
});

useReducer 提供了一种更结构化的方式来管理和更新组件状态,尤其适用于需要处理复杂状态逻辑或多步操作的场景。

需要注意的是:

  • dispatch 函数是为下一次渲染而更新 state:当你调用 dispatch 函数时,它并不会立即更新状态,而是为下一次组件的渲染更新状态。因此,在调用 dispatch 函数后立即读取 state 并不会得到更新后的值,而是获取到调用 dispatch 函数之前的状态。
  • 相同的新值会跳过重新渲染:如果提供的新值(在 dispatch 中传入的值)与当前的 state 相同(使用 Object.is 比较),React 会跳过组件和子组件的重新渲染。这是一种优化手段,避免不必要的渲染操作,提高性能。

useCallback

useCallback 用于记忆化函数,以便在依赖项不变的情况下,避免函数的重复创建。通常用于性能优化,特别是在将函数作为 prop 传递给子组件时。

ini 复制代码
const memoizedCallback = useCallback(() => {
  // 这里放置你的回调函数的逻辑
}, [依赖数组]);
  • 第一个参数是回调函数,即希望记忆化的函数
  • 第二个参数是依赖数组。当这个数组中的任意一个依赖项发生变化时,useCallback 返回的函数就会被重新创建。如果依赖项数组为空[],则表示回调函数永远不会重新创建。

闭包陷阱问题

在计数器中有如下案例,为button按钮绑定increment事件,点击加一。但是执行一次+1后继续点击按钮,count数值不会变大

ini 复制代码
const increment = useCallback(function () {
  setCount(count + 1);
}, []);

这是因为useCallback 的依赖项是一个空数组 [],这表示 increment 函数在整个组件的生命周期内只会创建一次 ,不会因为任何状态或属性的变化而重新创建。在这种情况下,count 并不是 useCallback 的依赖项,所以 increment 函数内部引用的 count 始终是创建时的那个值

因为 increment 函数被闭包捕获了创建时的 count 的值,而不是动态获取的最新值,所以无论 count 发生多少次变化,increment 函数内部的 count 始终保持最初创建时的值,这就是闭包陷阱。

基本案例1.将函数传递给子组件时

默认情况下,当一个组件重新渲染时, React 将递归渲染它的所有子组件。当需要将一个回调函数传递给子组件时,如果该回调函数在每次渲染时都重新创建,可能会导致子组件不必要的重新渲染。

以具体的例子说明,现在拥有父组件App以及子组件Home,为子组件传递一个函数increment,Home中的button按钮绑定了这一事件,可以让App组件的count+1。

Home组件构成如下:

javascript 复制代码
const Home = memo((props) => {
  const { increment } = props;
  console.log("Home render");
  return (
    <div>
      <button onClick={increment}>Home组件+1</button>
    </div>
  );
});

同时父组件还展示message,通过父组件的按钮也可以对message进行更新

现在如果为Home组件传递的increment函数是一个普通的函数(无记忆化版本),

ini 复制代码
const increment = () => setCount(count + 1);

那么在点击更新message按钮 时,页面的message发生变化,会触发App组件页面重新渲染。那么会重新生成increment函数,子组件传入的参数发生变化,因此Home组件也会重新渲染。

幸运的是,我们的Home组件不含有大量计算的操作,因此不会造成很大的影响。然而如果Home组件重新渲染需要花费很长时间的话,那么更改message导致Home组件渲染是非常不值当的。

因此可以考虑在为子组件传递函数时,采用useCallback进行记忆化。 这样即使message发生变化,父组件发生重新渲染,子组件仍然会使用相同的 increment 函数引用。子组件在比较 props 的时候,由于 increment 函数在相同的 count 下是相同的引用,子组件会认为它没有变化,因此不会重新渲染。

ini 复制代码
const increment = useCallback(
  function () {
    setCount(count + 1);
  },
  [count]
);

通过useRef优化useCallback

在上面的基本案例中,通过useCallback避免了因message更新而导致Home子组件渲染的问题,然而如果对count进行更新,依然会导致Home子组件的渲染,运行如下:

在本小节,计划采用useRef进一步优化,该hook会返回一个ref对象,这个ref对象在组件的整个生命周期内保持不变。

使用了 useRef 来缓存了 count 的值,然后在 useCallback 内部,通过 countRef.current 获取到了当前的 count 值,而且依赖数组为空([])。这种做法确保了在 increment 函数内部使用的 count 始终是最新的值,但是同时也避免了 useCallback 函数的重新创建

ini 复制代码
const [count, setCount] = useState(0);
const countRef = useRef();
countRef.current = count;
const increment = useCallback(function () {
  setCount(countRef.current + 1);
}, []);

因此,利用了 useRef 来保存 count 的值,既保证了函数的正常执行,又避免了不必要的组件重新渲染。

useMemo

用法

useMemo 返回一个记忆化的值,只有当依赖项数组中的值发生变化时,才会重新计算。如果依赖项数组为空,它仅在组件的初始化阶段计算一次。

scss 复制代码
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

useMemo 接受两个参数:一个计算函数和一个依赖数组。它的作用是根据依赖数组中的变量,缓存计算函数的返回值,并在依赖发生变化时重新计算。

  • 第一个参数是一个函数,该函数用于计算需要被记忆化的值。
  • 第二个参数是依赖项数组,当数组中的任意一个值发生变化时,useMemo 重新执行计算函数。如果依赖项数组为空([]),则表示该值永远不会改变。

useMemo与useCallback的关系

两者核心思想都是在依赖项发生变化时,避免重复计算或函数创建,以提高性能。

useMemo 用于记忆化计算结果,它接受一个计算函数和一个依赖项数组。当依赖项数组中的任意值发生变化时,useMemo 会重新计算并返回计算结果。

useCallback 则是用来记忆化函数 的。它接受一个函数和一个依赖项数组,并返回一个记忆化后的函数。当依赖项数组中的任意值发生变化时,useCallback 会返回一个新的函数引用。

因此,下列两种写法是等价的。

const memorizedFn = useCallback(fn, []),通过useCallback返回一个记忆化的函数

const memorizedFn2 = useMemo(() => fn, []) ,在这种写法中,useMemo 接受一个计算函数和一个依赖项数组,计算函数返回fn,因此通过useMemo返回一个记忆化的函数返回值,只不过这个返回值是一个函数引用


scss 复制代码
const memorizedFn = useCallback(fn,[])
const memorizedFn2 = useMemo(()=> fn, [])

基本案例1.避免重复执行计算操作

存在一个比较复杂的计算函数,并且要在页面中渲染该函数的计算结果。

具体计算函数如下

ini 复制代码
function calcNumTotal(num) {
  console.log("calcNumTotal过程调用");
  let total = 0;
  for (let i = 1; i <= num; i++) {
    total += i;
  }
  return total;
}

页面结构如下

javascript 复制代码
const App = memo(() => {
  const [message, setMessage] = useState("hello");
  const result = calcNumTotal(50);
  return (
    <div>
      {/* 执行大量计算操作时,是否需要每次渲染都重新计算? */}
      <h2>计算结果:{result}</h2>
      <h2>message:{message}</h2>
      <button onClick={(e) => setMessage(new Date().getTime())}>
        修改message
      </button>
    </div>
  );
});

页面效果如下

显然count的更新与计算函数无关系,但是执行+1操作时,每次都会重新渲染页面,并重新执行大量的计算操作。这样显然会对页面性能造成影响

为了避免重复进行大量的计算操作,可以使用useMemo进行优化,那么在进行与计算无关的操作触发页面重新渲染时,不会重新调用calcNumTotal函数。如下,生成了一个记忆化的calcNumTotal函数返回值。那么在进行count更新时,就不会重新执行计算操作了。

ini 复制代码
const result = useMemo(() => {
  return calcNumTotal(50);
}, []);

基本案例2.为子组件传递相同内容的对象时

为子组件传递相同内容的对象时,如果页面重新渲染,那么会重新生成这个对象,并且会导致子组件重新渲染

如下图,页面渲染时,会重新触发子组件渲染

Home组件代码如下

javascript 复制代码
const Home = memo((props) => {
  const { name, school } = props.infoObj;
  console.log("Home render");
  return (
    <div>
      Home Info {name}={school}
    </div>
  );
});

useMemo 中的函数返回的对象 { name: "weihy", school: "ustb" }会被记忆。如果父组件重新渲染,useMemo 不会重新计算这个对象,而是直接返回之前记忆的对象。因此,无论父组件如何重新渲染,info 对象的引用都会保持不变

ini 复制代码
const infoObj = useMemo(() => {
  return { name: "weihy", school: "ustb" };
}, []);

因此无论父组件如何渲染,Home子组件都只会渲染一次

相关推荐
J不A秃V头A41 分钟前
Vue3:编写一个插件(进阶)
前端·vue.js
光影少年1 小时前
usemeno和usecallback区别及使用场景
react.js
司篂篂1 小时前
axios二次封装
前端·javascript·vue.js
姚*鸿的博客2 小时前
pinia在vue3中的使用
前端·javascript·vue.js
宇文仲竹2 小时前
edge 插件 iframe 读取
前端·edge
Kika写代码2 小时前
【基于轻量型架构的WEB开发】【章节作业】
前端·oracle·架构
天下无贼!3 小时前
2024年最新版Vue3学习笔记
前端·vue.js·笔记·学习·vue
Jiaberrr3 小时前
JS实现树形结构数据中特定节点及其子节点显示属性设置的技巧(可用于树形节点过滤筛选)
前端·javascript·tree·树形·过滤筛选
赵啸林3 小时前
npm发布插件超级简单版
前端·npm·node.js
我码玄黄4 小时前
THREE.js:网页上的3D世界构建者
开发语言·javascript·3d