深入浅出 React Hooks:从基础到自定义 Hook 的全面指南

376 阅读31分钟

前言

小编最近在重新系统性地学习 React 的过程中,我发现 Hook 的使用频率越来越高,尤其是 useStateuseEffectuseContext 等常见 Hook,它们极大地简化了我的代码。通过这些体验,我也逐渐认识到,Hooks 的最大优势并不在于单个 Hook 的功能,而是它们如何赋能函数组件,让函数组件具备了与类组件相同的强大功能。为了更好地巩固和复习这些知识,我决定将自己在学习过程中总结的经验与大家分享。希望通过本文的讲解,能帮助大家更好地掌握和应用 React Hooks。

下载.jpg

React Hooks 简介

在过去的 React 开发中,类组件一直是我们创建功能组件的主要方式。然而,随着 React 16.8 的发布,React 引入了 Hooks,一个全新的特性,让我们在函数组件中也能轻松使用状态和副作用等功能。通过 Hook,React 使函数组件变得更强大、更灵活,打破了类组件与函数组件的功能壁垒。React Hooks 的诞生,不仅简化了代码的书写,还改变了我们组织和管理代码的方式。useStateuseEffectuseContext 等常用 Hook 逐步成为我们日常开发中不可或缺的工具。

为什么 React 引入 Hooks

传统上,React 中的状态管理和生命周期方法都只能在 类组件 中使用。然而,类组件的设计往往会导致一些问题:

  • 冗长的代码:类组件需要编写较多的样板代码,例如构造函数、生命周期方法等,且这些代码容易变得复杂。
  • 逻辑复用困难:类组件中的状态和副作用逻辑往往是紧密耦合的,导致同一份代码需要在多个组件间重复编写,无法方便地复用。
  • 类组件难以测试和维护:类组件的状态和生命周期方法的管理使得单元测试变得复杂,尤其是在复杂组件中,开发者常常需要手动管理这些状态。

Hooks 的优势:函数组件的强大功能

随着 React 函数组件的崛起,Hooks 彻底改变了函数组件的应用场景,赋予了函数组件与类组件相同的强大功能。具体而言,Hooks 在以下几个方面展现了巨大的优势:

  1. 简化代码结构
    函数组件没有复杂的生命周期方法,也不需要构造函数。通过使用 useStateuseEffect,开发者能够在函数组件内部直接管理状态和副作用,避免了类组件中冗长的生命周期方法。
  2. 便捷的状态管理
    useStateuseReducer 等 Hook 让我们能够轻松管理组件的状态,而不再需要类组件中的 this.statethis.setState。对于更复杂的状态逻辑,useReducer 提供了类似于 Redux 的解决方案,使得状态管理更加清晰和可控。
  3. 副作用的集中管理
    useEffect 让我们可以集中处理副作用,例如数据请求、订阅、定时器等。这使得副作用逻辑变得更加明确且可控。与类组件相比,useEffect 避免了多重生命周期方法的混乱,减少了 bug 的发生。
  4. 自定义 Hook 提高代码复用性
    自定义 Hook 使得跨多个组件的共享逻辑变得简单。通过将逻辑抽离成 Hook,我们可以在不同组件中复用相同的功能,而无需重复编写相似的代码。自定义 Hook 提升了代码的模块化和可维护性。
  5. 更好的类型支持
    使用 TypeScript 时,React Hooks 可以与类型系统无缝集成。useStateuseReduceruseContext 等 Hook 的类型推导能力使得我们能够在开发过程中获得更好的类型安全性。
  6. 简化状态与副作用的测试
    在类组件中,管理组件的生命周期和状态通常使得测试变得复杂。而在函数组件中,useStateuseEffect 等 Hook 的使用,使得函数组件更容易进行单元测试。函数组件通过更小的职责分离,可以更方便地进行断言,保证代码的稳定性。
  7. React 社区的广泛支持
    自 React 16.8 引入 Hooks 以来,React 社区对 Hooks 的支持不断增加,越来越多的第三方库开始支持函数组件和 Hook 的使用。此外,React 官方也推荐开发者优先使用函数组件和 Hook 编写新项目,甚至逐渐鼓励将旧项目中的类组件转化为函数组件。

React 中常用的 Hooks

1. useState — 管理状态

1.1. 基本用法

useState 是 React 中最常用的 Hook,用于在函数组件中添加和管理状态。它接受一个初始状态值,并返回一个包含当前状态值和更新该状态的函数的数组。

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

setCount(count + 1);

1.2. 初始状态的惰性求值

useState 可以接受一个函数作为初始状态的值,这个函数只有在第一次渲染时执行,从而实现惰性求值。它在组件初始化时执行一次并返回其结果,以减少不必要的计算,尤其是在计算复杂的初始状态时非常有用。

const [count, setCount] = useState(() => computeInitialValue());

1.3. 更新状态的注意事项

useState 提供的更新函数是异步的,且它会触发组件的重新渲染。当你调用 setState 时,React 会合并新状态与旧状态,而不是完全替换旧状态。在对象或数组的情况下,React 会使用浅比较来决定是否更新状态,因此需要小心避免直接修改状态对象,而是应当返回新对象。

setState(prevState => ({ ...prevState, value: newValue }));

原理

useState 本质上是在 React 内部为每个组件实例维持一个状态的快照。每次状态更新时,React 会保存新的状态值并触发重新渲染。它通过 React Fiber 渲染引擎来优化更新过程,确保状态的更新与组件渲染的同步性。


2. useEffect — 处理副作用

2.1. 基本用法

useEffect 用于处理副作用(例如数据请求、DOM 操作、定时器等)。它在每次渲染后执行,不会阻塞页面渲染。

useEffect(() => {
  // 副作用代码
}, [dependencies]);

2.2. 依赖项数组的使用

useEffect 接受第二个参数,即依赖项数组。该数组指定哪些值的变化会触发副作用函数。如果依赖项数组为空,副作用只会在组件挂载和卸载时执行一次。

useEffect(() => {
  // 数据请求等副作用操作
}, [count]);  // 当 count 改变时,副作用函数会执行

2.3. 清理副作用

副作用可能会带来资源泄漏问题,例如订阅事件或设置定时器。为了解决这个问题,useEffect 允许返回一个清理函数,在组件卸载或依赖项变化时清理副作用。

useEffect(() => {
  const timer = setInterval(() => console.log('tick'), 1000);
  return () => clearInterval(timer);  // 清理副作用
}, []);

2.4. 如何处理异步操作

useEffect 本身不能直接返回一个异步函数,因此我们通常会在副作用函数内部调用一个异步函数。

useEffect(() => {
  const fetchData = async () => {
    const response = await fetch('api/data');
    const data = await response.json();
    setData(data);
  };
  fetchData();
}, []);

原理

useEffect 的副作用函数是在每次渲染后执行的,React 会在 DOM 更新后异步执行该函数。它通过 React Fiber 中的调度机制,确保副作用不会阻塞渲染进程。如果依赖项发生变化,React 会重新执行副作用函数。


3. useContext — 使用上下文管理状态

3.1. 创建和使用上下文

useContext 是用来访问上下文数据的 Hook。它需要传入一个 React Context 对象,返回该上下文的当前值。

const theme = useContext(ThemeContext);

上下文值通常是通过 React.createContext 创建的。

const ThemeContext = createContext('light');

3.2. useContext 和性能优化

useContext 可以在多个组件之间共享数据,避免了 props 层层传递的问题。对于性能优化,React 会使用 React.memoshouldComponentUpdate 等机制,确保组件仅在上下文值变化时才重新渲染。

原理

useContext 本质上是通过 React Context API 来实现的。React 会通过 Context 传递数据,并在上下文值更新时触发使用该 Context 的组件重新渲染。


4. useReducer — 复杂状态管理

4.1. useReducer 的基本用法

useReducer 是处理复杂状态逻辑的 Hook,特别适用于状态依赖于多个值或需要根据不同的动作更新状态时。它与 useState 类似,但返回的是当前状态和一个 dispatch 函数,用来分发 action 更新状态。

const [state, dispatch] = useReducer(reducer, initialState);

4.2. useReduceruseState 的区别

useState 不同,useReducer 更适用于具有多个子状态的复杂组件,或者需要执行复杂更新逻辑的组件。useState 适用于简单的状态管理,而 useReducer 适合于具有多个状态更新的场景。

4.3. 多状态和复杂状态更新

通过 useReducer,可以使用一个单独的 reducer 函数来管理多个状态项,并通过派发不同的 action 更新状态,避免了多个 useState 的混乱。

const initialState = { count: 0, text: '' };
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    case 'setText':
      return { ...state, text: action.text };
    default:
      return state;
  }
}

原理

useReducer 是通过创建一个 reducer 函数 来处理状态更新的。reducer 函数接收当前状态和 action,返回新的状态。它通常用于替代 useState 来管理复杂状态和多个子状态。


5. useCallback — 缓存回调函数

5.1. useCallback 基本用法

useCallback 用来缓存函数实例,避免不必要的函数重建。它接受两个参数:一个回调函数和依赖项数组。只有在依赖项变化时,回调函数才会被重新创建。

const memoizedCallback = useCallback(() => { console.log('Hello'); }, []);

5.2. useCallbackuseMemo 的区别

useCallbackuseMemo 都是缓存机制,但它们的应用场景不同。useMemo 用来缓存计算结果,而 useCallback 专门用于缓存函数。简单来说,useCallback(fn, deps) 等价于 useMemo(() => fn, deps),但 useCallback 明确表示该缓存的是函数。

5.3. 性能优化的实际应用

useCallback 常用于避免在每次渲染时重新创建回调函数,尤其是在将回调函数传递给子组件时,可以有效减少不必要的渲染。

原理

useCallbackuseMemo 都利用 React 内部的调度机制来缓存值。通过 useCallback,React 会保存函数的引用,并仅在依赖项改变时更新该引用,避免每次渲染时创建新的函数。


6. useMemo — 缓存计算结果

6.1. useMemo 基本用法

useMemo 用来缓存计算结果,只有当依赖项发生变化时,才会重新计算结果。

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

6.2. 避免不必要的计算

useMemo 主要用于优化性能,尤其是对计算量较大的值进行缓存,避免每次渲染时都重新计算。

6.3. useMemo 与性能优化

通过缓存计算结果,useMemo 可以有效避免不必要的重复计算,尤其是在复杂计算时,提高性能,减少渲染过程中的资源消耗。

原理

useMemo 通过对依赖项的变化进行监控,确保只有在依赖项改变时才会重新计算结果。它会根据依赖项的变化来决定是否重新计算,并缓存上一次的计算结果。

7. useLayoutEffect — 处理布局和视图更新

7.1. useLayoutEffect 的基本用法

useLayoutEffect 是 React 提供的另一个 Hook,功能类似于 useEffect,但它与 useEffect 有一个关键的区别:useLayoutEffect 在所有 DOM 更新完成后,同步执行副作用函数,并且会阻塞浏览器绘制,直到副作用完成。

jsx
复制编辑
useLayoutEffect(() => {
  // 在 DOM 更新后同步执行的副作用代码
}, [dependencies]);

7.2. 与 useEffect 的区别

  • 执行时机
    useEffect 是在 DOM 更新后异步执行的,浏览器会先进行渲染,之后再执行副作用。而 useLayoutEffect 在所有 DOM 更新完成后同步执行,在浏览器绘制之前执行,因此它会阻塞浏览器的绘制,直到副作用函数执行完毕。通常这用于在视图更新之前执行某些操作,确保在渲染过程中进行必要的 DOM 操作。
  • 性能差异
    由于 useLayoutEffect 会同步执行副作用,阻塞渲染,所以在性能上通常不如 useEffect 高效。特别是在大型应用中,如果副作用不需要立即反映到视图上,使用 useLayoutEffect 可能会导致性能问题。因此,除非你确实需要控制 DOM 渲染的时机(如测量布局或修改样式),否则一般推荐使用 useEffect
jsx
复制编辑
// useEffect 示例(异步执行副作用)
useEffect(() => {
  console.log('useEffect executed');
}, []);

// useLayoutEffect 示例(同步执行副作用)
useLayoutEffect(() => {
  console.log('useLayoutEffect executed');
}, []);

7.3. 使用场景

useLayoutEffect 适用于那些需要在组件渲染之后,DOM 更新之前执行的操作,例如:

  • 读取布局信息(例如获取元素尺寸、位置)
  • 同步修改 DOM 样式,防止浏览器重绘前的不一致表现
  • 强制执行动画帧,确保界面更新是平滑的

一般来说,如果不需要“强制同步”的操作,使用 useEffect 即可,它能够更好地利用浏览器的渲染优化。

原理

useLayoutEffectuseEffect 都是通过 React Fiber 渲染引擎来调度副作用的执行。不同的是,useLayoutEffect 会确保副作用在 DOM 更新之后立即执行,但不会等待浏览器绘制。React 会等待布局和绘制完成之后才会调用 useLayoutEffect,从而确保更新的视觉效果在浏览器绘制前完成。

好的,下面是重新整理后的内容,序号从 8 开始,并且去掉了与 useState 的区别部分:


8. useRef — 访问 DOM 和保存值

8.1. useRef 的基本用法

useRef 是 React 提供的一个 Hook,用来访问和操作 DOM 元素,同时也可以用来保持在不同渲染周期之间的值。useRef 返回一个可变的 ref 对象,这个对象可以保存任何类型的值,通常用来保持对 DOM 元素的引用,或者在渲染周期之间保存某个数据(比如计时器、标志位等)。

用法示例:

import { useRef } from 'react';

function FocusInput() {
  const inputRef = useRef(null);

  const focus = () => {
    inputRef.current.focus();
  };

  return (
    <div>
      <input ref={inputRef} />
      <button onClick={focus}>Focus the input</button>
    </div>
  );
}

在这个示例中,useRef 创建了一个 inputRef,并通过 ref={inputRef} 将其与输入框 DOM 元素关联。然后我们可以通过 inputRef.current 来直接访问该 DOM 元素,进行例如聚焦等操作。

8.2. 使用 useRef 保存跨渲染的值

useRef 最常见的应用之一是保持一个在渲染周期间不断更新的值,通常用于解决在异步操作或事件处理时访问上一次渲染的值的问题。

示例:

import { useState, useEffect, useRef } from 'react';

function TimerWithRef() {
  const [count, setCount] = useState(0);
  const previousCountRef = useRef();

  useEffect(() => {
    previousCountRef.current = count;
  }, [count]);

  return (
    <div>
      <p>Current count: {count}</p>
      <p>Previous count: {previousCountRef.current}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

在这个示例中,previousCountRef 使用 useRef 来保存 count 的上一个值。每次 count 更新时,useEffect 会更新 previousCountRef.current,但是由于 useRef 不会触发重新渲染,因此 previousCountRef.current 可以在渲染间保持跨渲染的值。

8.3. useRef 用法中的性能优势

  • 避免不必要的渲染: 由于 useRef 不会触发重新渲染,它适合用来存储不需要影响渲染的值,例如保存 DOM 元素的引用,或是保存跨渲染的值(如定时器、事件监听器等)。
  • 保持稳定的引用: 在每次渲染中,useRef 会返回同一个对象引用,因此可以避免因新创建的对象导致的不必要的计算或渲染。

8.4. 总结

useRef 是 React 中非常有用的工具,可以帮助我们访问 DOM 元素或在不同渲染周期中保持某些值。它的主要优势在于不会触发组件重新渲染,因此适合用来保存那些不需要参与渲染更新的数据,如定时器ID、DOM引用等。对于跨渲染的引用值,useRef 是一个高效且稳定的选择。

React Hook 使用规则与最佳实践

虽然hook可以极大地提高我们地开发效率,但是它有一定的要求,比如使用层面上的话也要遵循其相关语法规范,以下是一些关于hook的实践总结:

1. React Hook 的基本规则

1.1 只在函数组件和自定义 Hook 中调用

React 的 Hook 只能在函数组件或自定义 Hook 内部调用。这个规则是为了保证 Hook 的行为符合 React 的渲染模型。React 需要在每次渲染时确保 Hook 的执行顺序和调用次数是固定的,只有在函数组件或自定义 Hook 中调用,React 才能按预期的顺序进行状态更新和副作用处理。

原理: React 的调度和渲染机制依赖于每个 Hook 在每次渲染中的稳定性。如果在普通的 JavaScript 函数中使用 Hook,React 就无法确保它们按照渲染的顺序执行,也就无法追踪各个 Hook 的状态。

最佳实践

  • 在普通 JavaScript 函数中不要调用 React Hook。
  • 自定义 Hook 应该遵循相同的规则:只在函数组件中调用或其他自定义 Hook 中调用。
// 正确的做法:自定义 Hook 只能在函数组件内部或其他自定义 Hook 中调用
function useCustomHook() {
  const [count, setCount] = useState(0);
  return [count, setCount];
}

1.2 只在顶层调用 Hook,避免在条件语句或循环中调用

React Hook 必须在组件的顶层调用,不得放在条件语句、循环或任何可能改变执行顺序的地方。这是因为 React 依赖 Hook 的调用顺序来管理它们的状态。如果 Hook 的调用位置不稳定,React 无法确定每个 Hook 对应的状态,从而会导致错误。

原理: React 在每次渲染时会按照顺序分配索引来追踪每个 Hook。如果在条件语句中调用 Hook,可能会导致渲染期间 Hook 的调用顺序发生变化,从而打乱 React 的索引管理。

最佳实践

  • 将 Hook 调用放在组件的顶层,确保每次渲染时它们的调用顺序是相同的。
// 错误的做法:在条件语句内调用 Hook
if (condition) {
  useState();
}

1.3 每次渲染时,Hook 的调用顺序不能改变

React 要求 Hook 在每次渲染时的调用顺序保持一致。即使在不同的渲染周期中,Hook 的调用顺序也不能变动。这样,React 才能确保每个 Hook 和它所对应的状态相匹配。

原理: React 通过内部的 Hook 索引来追踪每个 Hook 的状态。如果在不同的渲染中改变 Hook 的调用顺序,React 将无法正确地管理这些状态,导致状态不一致或报错。

最佳实践

  • 始终保证每次渲染时 Hook 的调用顺序不变。
// 错误的做法:在条件语句中修改 Hook 的调用顺序
const [state, setState] = condition ? useState() : useState();

2. useEffect 依赖项的使用技巧

2.1 为什么不应该忽略依赖项

useEffect 中,依赖项数组(第二个参数)决定了副作用何时被重新执行。如果依赖项数组为空,useEffect 只会在组件挂载时执行一次;如果依赖项发生变化,useEffect 会重新执行。如果忽略依赖项,useEffect 可能不会在正确的时机执行,导致不可预期的行为。

原理: React 在每次渲染时会根据依赖项的变化来决定是否需要重新执行 useEffect。如果依赖项没有传递或传递错误,React 无法正确检测到依赖变化,可能导致副作用没有及时执行。

最佳实践

  • 始终正确地指定 useEffect 的依赖项,确保副作用在适当的时机执行。
useEffect(() => {
  // 执行副作用
}, [dependency]); // 确保依赖项正确

2.2 如何避免陷入无限循环

在某些情况下,副作用可能会触发状态更新,从而导致 useEffect 被重新执行,造成无限循环。为避免这一问题,确保只在必要时才更新状态,或者使用条件判断来决定是否进行状态更新。

原理: 每次执行副作用时,都会根据依赖项更新状态。如果状态更新导致依赖项发生变化,而 useEffect 中又包含该依赖项,就会导致副作用不断触发,进入无限循环。

最佳实践

  • 使用条件判断来确保只有在必要时才更新状态,避免不必要的渲染。
useEffect(() => {
  if (state !== newState) {
    setState(newState);
  }
}, [state]); // 只有在 state 改变时才会更新

3. 性能优化:useCallback 和 useMemo

3.1 防止不必要的渲染

useCallbackuseMemo 都可以帮助防止不必要的渲染,特别是在组件中有高频更新的情况。useCallback 缓存回调函数,useMemo 缓存计算结果。这样可以避免每次渲染时都重新创建这些函数或计算结果,提升性能。

原理: 每次渲染时,React 会重新创建所有函数和计算结果。如果这些函数和结果在多个渲染中保持不变,使用 useCallbackuseMemo 可以缓存它们的值,避免不必要的计算和重新创建。

最佳实践

  • 对于函数和计算结果,使用 useCallbackuseMemo 来优化性能,避免重复计算和创建。
const memoizedValue = useMemo(() => computeExpensiveValue(input), [input]);
const memoizedCallback = useCallback(() => { /* callback code */ }, [dependency]);

4. useReducer 的最佳实践

4.1 适合何种场景

useReduceruseState 更适合用于复杂的状态管理,尤其是当状态逻辑变得复杂时,例如多个状态交互,或者需要进行复杂的状态更新操作时。

原理useReducer 提供了一个更结构化的方式来管理状态,通过 reducer 函数来处理状态的变更,使得状态的更新更加可预测和可维护。

最佳实践

  • 当状态逻辑复杂时,考虑使用 useReducer 代替 useState,尤其是当状态需要基于先前的状态进行更新时。
const [state, dispatch] = useReducer(reducer, initialState);

5. 避免副作用的副作用:useEffect 中的异步操作

5.1 如何处理异步数据请求

useEffect 中进行异步操作时,可能会遇到组件卸载后依旧尝试更新状态的情况。通过在副作用中进行清理操作来避免这些问题。

原理useEffect 会在组件卸载时进行清理操作,避免内存泄漏和不必要的状态更新。

最佳实践

  • 在异步操作中确保只有在组件挂载时才更新状态,防止组件卸载后访问状态。
useEffect(() => {
  let isMounted = true;
  fetchData().then(data => {
    if (isMounted) {
      setData(data);
    }
  });
  return () => { isMounted = false; };  // 清理函数
}, []);

总结

合理使用 React Hook 可以提升组件的可维护性和性能,但需要遵循最佳实践来避免常见的陷阱和性能问题。掌握 useEffect 的依赖项、useCallbackuseMemo 的使用场景、useReducer 的复杂状态管理等技巧,能让我们更加高效地开发 React 应用。

如何编写自定义 Hook(custom hook)

学会使用react内置的hook其实还不够,在一些复杂的业务场景我们甚至可以编写自定义hook:

1. 什么是自定义 Hook?

1.1 自定义 Hook 的概念

自定义 Hook 是一种函数,它允许你将组件中重复的逻辑提取到一个可复用的地方。它是 React 内置 Hook 的封装,通常由一个或多个内置 Hook 组合而成,能够在多个组件中复用。自定义 Hook 不是一个 UI 组件,而是一个实现逻辑的工具,它帮助我们把逻辑抽离到更简洁、更可维护的代码结构中。

原理: 自定义 Hook 本质上是一个函数,它可以使用 React 内置的 Hook(如 useState, useEffect, useReducer 等)来实现所需的功能,并且返回需要共享的值和操作。通过这个方法,我们可以把复杂的逻辑提取到自定义 Hook 中,组件仅关注 UI 的渲染。

1.2 自定义 Hook 的优势
  1. 提高代码复用性:自定义 Hook 可以将重复的逻辑提取到一个地方,不需要在多个组件中复制相同的代码。
  2. 简化组件结构:通过将副作用或逻辑操作封装在自定义 Hook 中,组件只需要关注如何展示 UI,从而提高组件的可读性和可维护性。
  3. 逻辑解耦:自定义 Hook 可以将逻辑和视图分离,提高代码的可测试性。测试时可以单独测试 Hook 的逻辑,而不需要涉及到组件的渲染。

2. 自定义 Hook 的基本规则

2.1 以 use 开头命名

React 要求自定义 Hook 的名称必须以 use 开头,这是为了保持一致性并明确它是一个 Hook。React 根据这个规则来判断是否应该在其内部使用 Hook 管理状态或副作用。

最佳实践

  • 自定义 Hook 的名称始终以 use 开头,例如:useFetch, useLocalStorage
// 正确的做法
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });
  
  const setValue = value => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue];
}

2.2 调用其他内置 Hook

自定义 Hook 可以在其内部调用 React 的内置 Hook。这是它最强大的特性之一。通过组合 React 内置 Hook,能够在自定义 Hook 中实现各种逻辑,如状态管理、生命周期管理等。

最佳实践

  • 自定义 Hook 内部可以调用 useState, useEffect, useRef 等内置 Hook,但不能在条件语句中调用它们,保持调用顺序的一致性。
// 使用 useState 和 useEffect 的例子
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(url);
      const result = await response.json();
      setData(result);
      setLoading(false);
    };
    
    fetchData();
  }, [url]);

  return { data, loading };
}

2.3 返回值的选择

自定义 Hook 应该返回一些可以被组件使用的数据和函数。返回值可以是状态、函数或任何需要与外部共享的逻辑。返回值的选择应根据自定义 Hook 的功能而定,确保外部组件能够访问和控制所需的数据。

最佳实践

  • 返回对外暴露的 API,如状态变量、更新函数等。
  • 如果需要管理多个状态,可以返回一个对象,封装所有需要暴露的内容。
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = value => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

3. 如何设计和实现自定义 Hook

3.1 从组件逻辑提取共用部分

自定义 Hook 的最大优势之一就是提取组件中的共用逻辑。许多组件都可能需要类似的功能,比如处理表单输入、请求数据、管理状态等。通过将这些逻辑提取成自定义 Hook,多个组件可以共享相同的逻辑而不需要重复实现。

实践

  • 提取具有共用逻辑的部分,并将其封装成自定义 Hook。
  • 保持 Hook 的单一职责原则,避免将过多的逻辑塞进一个 Hook。
3.2 抽象副作用逻辑

副作用操作是组件中常见的复杂逻辑,如异步请求、事件监听、定时器等。将这些副作用抽象成自定义 Hook,可以让组件的逻辑更加简洁,同时保持副作用逻辑的可重用性和清晰性。

// 一个自定义 Hook 用于处理异步请求
function useFetch(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchData() {
      try {
        const response = await fetch(url);
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err);
      }
    }
    fetchData();
  }, [url]);

  return { data, error };
}
3.3 处理外部依赖(如 localStorage、window 等)

自定义 Hook 还可以用来处理外部依赖,例如浏览器的 localStoragewindow 对象等。通过自定义 Hook 可以将这些操作抽象为一组清晰、可复用的逻辑,避免每次都直接在组件中编写处理外部依赖的代码。


4. 示例:useLocalStorage Hook

4.1 useLocalStorage 的实现

useLocalStorage 是一个常见的自定义 Hook,用来将组件的状态持久化到浏览器的 localStorage 中。每次组件重新加载时,可以从 localStorage 中恢复之前的状态。

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = value => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}
4.2 如何在组件中使用
function MyComponent() {
  const [name, setName] = useLocalStorage('name', 'John Doe');
  
  return (
    <div>
      <input value={name} onChange={e => setName(e.target.value)} />
      <p>{name}</p>
    </div>
  );
}
4.3 扩展 useLocalStorage 实现多种存储策略

可以根据不同的需求,扩展 useLocalStorage,让它支持其他存储机制(如 sessionStoragecookies)。

function useStorage(key, initialValue, storageType = 'localStorage') {
  const storage = window[storageType];
  
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = storage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = value => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      storage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

5. 示例:usePrevious Hook

5.1 usePrevious 的实现

Previous 的实现** usePrevious 是另一个常用的自定义 Hook,它可以返回组件的前一个值。

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  }, [value]);
  
  return ref.current;
}
5.2 在组件中使用 usePrevious
function Counter() {
  const [count, setCount] = useState(0);
  const previousCount = usePrevious(count);

  return (
    <div>
      <p>Current Count: {count}</p>
      <p>Previous Count: {previousCount}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

6. 总结

编写自定义 Hook 让你的代码更加清晰、简洁,并提高逻辑的复用性。通过合理地抽象和封装,减少组件中的重复代码,使得复杂的逻辑处理更为可维护。

常见问题与疑惑

小编总结了个人使用hook时的一些问题与疑惑:

1. React Hook 调用顺序问题

问题背景:
React Hook 的调用顺序是至关重要的。如果我们在组件的不同渲染周期中更改 Hook 的调用顺序,React 将无法正确地管理和追踪各个 Hook 的状态,导致错误或意外行为。

为什么如此重要?
React 使用一个内部的机制(通过索引管理 Hook)来确保每次渲染都能正确地恢复每个 Hook 的状态。如果 Hook 的调用顺序发生变化,React 就无法正确匹配状态和对应的 Hook,从而导致渲染错误。React 强烈要求在每次渲染中以相同的顺序调用 Hook。

具体规则:

  1. 只在顶层调用 Hook:不要在循环、条件语句、嵌套函数等中调用 Hook。这是为了确保每次渲染时,Hook 的调用顺序保持一致。
  2. 保持调用顺序一致:无论何时都要确保每个渲染周期中 Hook 的调用顺序和上一轮渲染一致。

举个例子:

// 错误示范:条件语句内调用 Hook
if (isConditionMet) {
  const [state, setState] = useState(0); // 错误
}

// 正确做法:始终在顶层调用 Hook
const [state, setState] = useState(0);
if (isConditionMet) {
  // 处理逻辑
}

2. 如何优化 useEffect 的性能

问题背景:
useEffect 是用来处理副作用的 Hook,它会在组件渲染后执行。通常,我们会利用 useEffect 进行数据请求、订阅、事件监听等操作,但如果依赖项不合理,或者 useEffect 逻辑过于复杂,可能会导致性能问题。

优化方法:

  1. 正确设置依赖项:确保 useEffect 的依赖项数组正确配置,不要遗漏必要的依赖项,也不要过多依赖不必要的项。错误的依赖项会导致 useEffect 频繁重新执行,影响性能。

    • 不要遗漏依赖项:每次 useEffect 执行时,React 会根据依赖项的变化来判断是否需要重新执行副作用。如果你忽略了某个依赖,副作用的行为可能会不可预测。
    • 不要过多依赖项:如果依赖项数组包括了不必要的值,useEffect 会在这些值变化时重新执行,可能导致性能瓶颈。
  2. 使用 useMemouseCallback:如果 useEffect 中的某些函数或数据是由其他计算结果或回调生成的,可以使用 useMemouseCallback 来避免每次渲染时重新计算或重新创建函数。

    • useMemo:缓存计算结果,避免不必要的重新计算。
    • useCallback:缓存函数,避免每次渲染时重新生成新函数。
  3. 优化副作用的清理操作:如果副作用操作涉及到清理工作(如事件监听、定时器等),请确保在组件卸载或依赖项变化时清理干净。未清理的副作用会导致内存泄漏。

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Tick');
  }, 1000);

  return () => {
    clearInterval(timer); // 清理副作用,防止内存泄漏
  };
}, []); // 依赖项为空,确保只在组件挂载时设置定时器

3. 如何避免副作用中的内存泄漏问题

问题背景:
内存泄漏发生在组件卸载后,仍然尝试访问或操作已经不再存在的状态或副作用。常见的内存泄漏情况包括:异步操作未清理、事件监听未移除、定时器未清除等。

避免内存泄漏的方法:

  1. 清理副作用:对于副作用,如定时器、事件监听、异步请求等,确保在 useEffect 中提供清理函数来移除副作用。useEffect 返回的清理函数会在组件卸载时执行,或在依赖项变化时执行。
  2. 处理异步操作:如果在 useEffect 中进行异步请求,确保在组件卸载后不再更新组件的状态。这可以通过引入 isMounted 标志或使用 AbortController 来控制。
useEffect(() => {
  let isMounted = true; // 设置标志避免卸载后更新状态

  fetchData().then(data => {
    if (isMounted) {
      setData(data); // 只有在组件未卸载时更新状态
    }
  });

  return () => {
    isMounted = false; // 在组件卸载时设置标志
  };
}, []);

3. 使用 AbortController 取消异步请求:通过 AbortController 可以中断未完成的网络请求,防止请求返回时组件已卸载。

useEffect(() => {
  const controller = new AbortController();
  const signal = controller.signal;

  fetch('https://api.example.com/data', { signal })
    .then(response => response.json())
    .then(data => setData(data))
    .catch(error => {
      if (error.name !== 'AbortError') {
        console.error(error);
      }
    });

  return () => controller.abort(); // 组件卸载时取消请求
}, []);

4. useReducer 和 useState 的选择

问题背景:
useStateuseReducer 都可以用来管理组件的状态,通常在状态简单时使用 useState,而在处理复杂的状态更新逻辑时使用 useReducer。那么如何选择在实际开发中使用哪一个呢?

选择标准:

  1. 状态逻辑简单时使用 useState
    当组件的状态较为简单,只有少量的值或更新操作时,使用 useState 更为直观。useState 适用于管理简单的值(例如,布尔值、数字、字符串等),且不需要过多的状态变化逻辑。
  2. 复杂的状态逻辑使用 useReducer
    当状态逻辑变得复杂,例如存在多个依赖的状态、多个操作更新同一状态,或者状态更新涉及到多步操作时,useReducer 更为合适。useReducer 通过 reducer 函数将状态变化的逻辑与组件的渲染分离,使得状态更新过程更为可控。

比较:

  • useState

    • 简单,适合管理单一的状态。
    • 不适合复杂的状态变化。
  • useReducer

    • 适合复杂的状态逻辑和多个值依赖的状态管理。
    • 更加结构化和可维护。
    • 更容易进行状态的调试和测试。
// useState 示例
const [count, setCount] = useState(0);

// useReducer 示例
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();
  }
}
const [state, dispatch] = useReducer(reducer, initialState);

选择建议:

  • 如果只是单一值的状态,useState 更简单方便。
  • 如果有复杂的状态更新逻辑,或状态之间存在复杂关系,使用 useReducer 可以让状态管理更清晰和可维护。

结语

React Hooks 的引入为函数组件带来了强大的功能,使得我们能够更简洁、高效地管理组件的状态和副作用。通过掌握常用的 Hook,如 useStateuseEffectuseRefuseMemo 等,我们可以更灵活地处理复杂的组件逻辑。同时,理解 Hook 的基本规则和最佳实践有助于提升代码的可维护性与性能优化。在学习和实践中,自定义 Hook 让我们能够将逻辑抽离,提升代码的复用性和模块化。希望这篇文章能够帮助你更深入地理解 React Hooks,提升你的开发技能,创作不易,礼貌集赞

images.jpg