首页 / 文章 / 每个新的 React 项目都适用的可复用自定义钩子工具包

每个新的 React 项目都适用的可复用自定义钩子工具包

探索精心挑选的一组自定义 React 钩子——涵盖存储、防抖、点击事件处理以及数据获取功能——可帮助新项目避免重复的样板代码。

3840 词

在现代应用程序中,React hooks 已成为管理状态、副作用以及可复用逻辑的默认方式,但仅仅了解内置 hooks 还远远不够。同样重要的是拥有自己的一套经过充分测试的小型自定义 hooks 工具包,这样就可以将其直接应用到任何新项目中,无需从头重新实现相同的功能。本文首先介绍一组值得纳入入门工具包的 hooks,随后深入讲解这些 hooks 的底层 API 实际工作原理,帮助你不仅知道该复制哪些代码,还能理解其行为背后的原因。

在创建新的 React 项目时,总有一些钩子几乎每次都会出现。有些用于解决性能问题,有些则能提升用户体验,还有些则避免在每个代码库中重复编写相同的样板逻辑。经过多次项目实践后,这些工具便成了标准的入门组件:无需从零开始解决相同的问题,只需引入相应的钩子,根据项目需求稍作调整,即可继续开发实际功能。以下是十二个符合此描述的钩子。

第一个是 useLocalStorage,它非常实用,因为几乎每个应用都需要在页面重新加载后保留某些数据,无论是主题偏好、表单输入还是其他用户设置。

import { useState } from "react";
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    return saved ? JSON.parse(saved) : initialValue;
  });  const updateValue = (newValue) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  };  return [value, updateValue];
}

这种模式常用于存储深色模式偏好设置、认证令牌,或是任何需要在多次访问之间保持的配置。

接下来是useToggle,它适用于状态在truefalse之间切换的常见场景。

import { useState } from "react";
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);  const toggle = () => setValue(v => !v);  return [value, toggle];
}

它非常适合用于模态框、下拉菜单、侧边栏以及类似的可切换用户界面元素。

useDebounce则常用于搜索输入框,以避免每次按键时都发送请求。

import { useState, useEffect } from "react";
function useDebounce(value, delay = 500) {
  const [debounced, setDebounced] = useState(value);  useEffect(() => {
    const timer = setTimeout(() => {
      setDebounced(value);
    }, delay);    return () => clearTimeout(timer);
  }, [value, delay]);  return debounced;
}

通过将更新操作延迟到输入暂停时,可以减少不必要的API调用。

useWindowSize能让响应式布局获取当前的视口尺寸。

import { useState, useEffect } from "react";
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });  useEffect(() => {
    const resize = () =>
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });    window.addEventListener("resize", resize);    return () => window.removeEventListener("resize", resize);
  }, []);  return size;
}

每当需要将某个值与上一次渲染时的值进行比较时,usePrevious 非常实用。

import { useEffect, useRef } from "react";
function usePrevious(value) {
  const ref = useRef();  useEffect(() => {
    ref.current = value;
  }, [value]);  return ref.current;
}

它特别适用于动画处理,或用于检测是否有内容发生了实际变化。

useClickOutside 能在用户点击弹出框外的任意位置时关闭这些 UI 元素,这符合用户对这类组件的直观预期。

import { useEffect } from "react";
function useClickOutside(ref, callback) {
  useEffect(() => {
    function handleClick(e) {
      if (ref.current && !ref.current.contains(e.target)) {
        callback();
      }
    }    document.addEventListener("mousedown", handleClick);    return () =>
      document.removeEventListener("mousedown", handleClick);
  }, [ref, callback]);
}

它非常适合用于下拉菜单、弹出窗口以及移动端导航菜单。

useDocumentTitle 能使浏览器标签页的标题与当前显示内容保持同步,从而便于导航和定位。

import { useEffect } from "react";
function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

无需在多个组件中重复实现相同功能,只需在页面需要自定义标题的地方调用此钩子即可。

useFetch 将基本的数据获取功能封装成一个可重复使用的钩子。

import { useState, useEffect } from "react";
function useFetch(url) {
  const [data, setData] = useState(null);  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData);
  }, [url]);  return data;
}

对于规模较大的项目,React Query或SWR这类工具通常更为合适,但这个轻量级版本已足以帮助小型应用起步。

useCopyToClipboard 用于实现如今随处可见的复制按钮功能。

function useCopyToClipboard() {
  const copy = (text) => {
    navigator.clipboard.writeText(text);
  };
  return copy;
}

它非常适合用于一键分享邀请链接、优惠码或API密钥。

useOnlineStatus 能让应用在网络连接断开或恢复时做出相应反应。

import { useState, useEffect } from "react";
function useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);  useEffect(() => {
    window.addEventListener("online", () => setOnline(true));
    window.addEventListener("offline", () => setOnline(false));
  }, []);  return online;
}

虽然只是一个小功能,但它显著提升了在网络连接不稳定时的应用使用体验。

useDarkMode 负责处理现代应用用户普遍期望的深色模式切换功能。

import { useEffect } from "react";
function useDarkMode(enabled) {
  useEffect(() => {
    document.body.classList.toggle("dark", enabled);
  }, [enabled]);
}

它通常与 useLocalStorage 配合使用,以便所选模式在不同会话之间保持一致。

最后,useTimeout 使得处理延迟操作比在组件中散布原始的 setTimeout 调用更为整洁。

import { useEffect } from "react";
function useTimeout(callback, delay) {
  useEffect(() => {
    const timer = setTimeout(callback, delay);    return () => clearTimeout(timer);
  }, [callback, delay]);
}

它适用于通知、启动画面以及任何需要在延迟后触发的操作。

在众多 React 项目中,有一个经验始终适用:无需每次都从零开始重新构建一切。保留一小批可复用的钩子能加快开发速度,让组件更易于理解,同时避免重复代码。这十二个钩子本身并不特别复杂,但合在一起能在整个项目周期中节省大量时间。随着你自己的项目不断增多,很可能会积累类似的个人钩子集合——重要的并非钩子的总数,而是它们能否解决你反复遇到的问题。今天为某个项目编写的小工具,往往会在之后的每个项目中都被频繁使用。

了解自定义钩子的功能有助于日常开发,但理解其背后的机制同样重要——即每个核心钩子解决什么问题、何时使用以及在不同渲染过程中的表现。只有具备这样的深入理解,才能让钩子的使用从简单的复制粘贴转变为真正的掌握。

在 Hooks 出现之前,函数组件无法存储状态或执行副作用,因此相关逻辑都存在于带有 state 对象和生命周期方法的类组件中:

class Counter extends React.Component {
  state = {
    count: 0
  };

  increment = () => {
    this.setState({
      count: this.state.count + 1
    });
  };

  render() {
    return (
      <button onClick={this.increment}>
        {this.state.count}
      </button>
    );
  }
}

使用 Hooks 后,同一个组件就可以直接调用 useState 的普通函数形式:

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

  return (
    <button onClick={() => setCount(c => c + 1)}>
      {count}
    </button>
  );
}

函数式版本通常更易于阅读和复用。

使用 Hooks 需遵循两条规则。首先,始终在顶层调用它们——绝不能在条件语句、循环或嵌套函数内部调用。应避免以下情况:

if (isLoggedIn) {
  useEffect(() => {
    // ❌
  }, []);
}


for (...) {
  useState(0); // ❌
}

应保持 Hook 调用无条件,将条件逻辑放在其后:

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

  if (count > 10) {
    // Normal conditional logic is fine
  }

  return ...;
}

这条规则的存在是因为 React 是按调用顺序而非名称来跟踪状态的。假设有两次 useState 调用:

function Component() {
  const [count] = useState(0);
  const [name] = useState("Salim");

  return ...;
}

React 会按位置顺序排列这些调用:

Hook #1 → count
Hook #2 → name

如果第一次调用变为条件性的:

if (condition) {
  useState(0);
}

useState("Salim");

那么在每次渲染时它们的顺序都可能发生变化:

Render 1:
Hook #1 → count
Hook #2 → name

Render 2:
Hook #1 → name

一旦出现这种情况,React 就无法将存储的状态与正确的调用对应起来,因此每次渲染时顺序都必须保持不变。

useState 允许组件在多次渲染之间记住某个值:

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

它返回一个值对:

count     → current state
setCount  → state update function

一个简单的计数器就能体现这一模式:

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

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

点击按钮会触发这一系列操作:

Click
 ↓
setCount()
 ↓
React schedules update
 ↓
Component renders again
 ↓
New state is calculated
 ↓
DOM is updated if necessary

状态并非像 let count = 0 这样的普通变量,因为后者在函数重新执行时不会保留。React会将状态存储在组件外部,并在每次渲染时提供正确的值:

Render 1
count = 0

Render 2
count = 1

Render 3
count = 2

组件会重新执行,但React会在多次执行之间保留状态。

当在一个处理函数中多次更新状态时,这一点非常重要:

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

如果渲染开始时 count 的值为 0,那么这三行代码都会使用同一个状态快照,从而导致:

setCount(1)
setCount(1)
setCount(1)

而不是真正进行三次递增。解决方法是通过函数式更新来实现:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

现在React会按顺序应用每次更新,因为每个更新函数都会收到最新的状态值——这对于新状态依赖于旧状态的情况非常有用。

useEffect 可以让组件与React之外的内容保持同步:

useEffect(() => {
  // synchronization logic
}, [dependencies]);

常见场景包括 API 调用、定时器、事件监听器、WebSockets、其他浏览器 API、订阅功能以及第三方库。示例:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);

  return <h1>{user?.name}</h1>;
}

该效果会与 userId 同步;当其发生变化时,必须重新执行该效果。

这种行为源自依赖数组。假设:

useEffect(() => {
  console.log(count);
}, [count]);

React 是在概念层面比较依赖项的:

Previous dependencies
        ↓
New dependencies
        ↓
Compare
        ↓
Changed?

如果依赖项不同,则会再次执行该效果:

Previous: [1]
New:      [2]

→ Effect needs to run

如果依赖项相同,则会跳过该效果:

Previous: [2]
New:      [2]

→ Effect can be skipped

比较是使用 Object.is 进行的,而非深度相等比较。

效果函数可以返回一个清理函数,在下一次效果执行之前以及组件卸载时运行:

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

  return () => {
    clearInterval(timer);
  };
}, []);

清理功能对于以下情况非常重要:

Timers
Event listeners
Subscriptions
WebSocket connections
Abortable requests

当依赖项发生变化时,React会在执行新的效应之前清理旧的效应;组件卸载时也会进行清理。

搜索输入框就能体现这一点——每次按键都可能触发请求:

react
react hooks
react performance

由于过时的响应可能会覆盖新的响应,因此需要取消之前的请求:

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

  fetch(`/api/search?q=${query}`, {
    signal: controller.signal
  })
    .then(res => res.json())
    .then(setResults)
    .catch(error => {
      if (error.name !== "AbortError") {
        console.error(error);
      }
    });

  return () => {
    controller.abort();
  };
}, [query]);

其生命周期如下所示:

query changes
     ↓
cleanup previous effect
     ↓
abort previous request
     ↓
start new request

实际的搜索输入框通常会结合防抖功能使用这一机制。

一个常见的错误是对于可以直接获取的值却使用useEffect

const [fullName, setFullName] = useState("");

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

由于fullName直接来自现有值,因此完全可以跳过效应和状态的处理:

const fullName = `${firstName} ${lastName}`;

使用效应版本会增加不必要的同步操作以及额外的渲染次数。经验法则:如果能在渲染过程中计算出该值,就无需使用效应。

useRef会提供一个稳定的对象,其.current在多次渲染中保持不变,即便该值发生变化也不会触发重新渲染:

const ref = useRef(initialValue);

常见的用法是引用DOM节点,比如让输入框获得焦点:

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

  function focusInput() {
    inputRef.current?.focus();
  }

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

useRef也适用于存储那些根本不应出现在渲染结果中的可变值,例如定时器标识符:

const timerRef = useRef(null);

你可以像操作普通对象属性一样直接为其赋值:

timerRef.current = setInterval(...);

修改

ref.current

本身并不会触发重新渲染。请对比这两种思维模式:

useState
→ update → render

useRef
→ mutate .current → no render

正因如此,useRef非常适合那些需要在多次渲染间保持不变、但又不应影响屏幕上显示内容的值。

useMemo采取了不同的方法:它缓存的是计算结果而非DOM引用。

const result = useMemo(() => {
  return expensiveCalculation(data);
}, [data]);

可以这样理解:useMemo = 缓存计算结果。其典型用法是在底层数据真正发生变化时才对列表进行过滤:

function ProductList({ products, search }) {
  const filteredProducts = useMemo(() => {
    return products.filter(product =>
      product.name
        .toLowerCase()
        .includes(search.toLowerCase())
    );
  }, [products, search]);

  return <ProductGrid products={filteredProducts} />;
}

如果某些无关的状态发生了变化,只要依赖项保持不变,React就可以直接返回之前计算好的值。从概念上讲,React会在每次调用该钩子时保存一份简短的记录:

Hook
 ├── memoized value
 └── dependencies

在首次渲染时:

data = A

calculate(A)
 ↓
result = X

在后续渲染中,如果data仍然是A,则依赖项比较通过,React会直接复用X而无需重新计算。只有当data变为类似B的值时,React才会重新进行计算。

尽管如此,useMemo很容易被过度使用。像这样包裹代码:

const fullName = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
);

几乎没什么意义——相关计算非常简单,而且记忆化本身并非免费服务;它会增加开销并带来额外的代码处理工作。只有当计算确实耗时、需要对象的稳定引用或数组的稳定引用,或者通过性能分析(或逻辑推导)证明它确实能带来帮助时,才应使用useMemo

useCallback则是用于函数引用而非值的对应工具。

const handleClick = useCallback(() => {
  doSomething();
}, []);

这一点很重要,因为普通函数会在每次渲染时都被重新创建:

function Parent() {
  const handleClick = () => {
    console.log("clicked");
  };

  return <Child onClick={handleClick} />;
}

在第一次渲染时,handleClick指向一个函数实例;在第二次渲染时则指向另一个不同的函数实例,因此尽管这两个函数的功能相同,但它们并不相等。

当子组件被包裹在 React.memo 中时,这种不等式就会成为问题:

const Child = React.memo(function Child({ onClick }) {
  console.log("Child rendered");

  return (
    <button onClick={onClick}>
      Click
    </button>
  );
});

而父组件的结构如下:

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

  const handleClick = useCallback(() => {
    console.log("clicked");
  }, []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>
        {count}
      </button>

      <Child onClick={handleClick} />
    </>
  );
}

count 发生变化时:

Parent renders
      ↓
handleClick reference remains stable
      ↓
React.memo checks Child props
      ↓
onClick unchanged
      ↓
Child can skip rendering

如果没有使用 useCallback,新创建的函数引用会被被缓存的子组件视为“新的”,从而导致它重新渲染,尽管实际上并没有任何变化。

不过,默认情况下并不应该对每个函数都使用 useCallback。在包裹处理函数之前,先考虑是否有必要通过稳定的引用带来好处——例如:

React.memo child
Dependency array
Expensive downstream computation

如果以上情况都不适用,那么让函数正常重新创建通常也是可以的。

这两个钩子的区别在于它们缓存的内容:

useMemo
→ memoizes a VALUE

useCallback
→ memoizes a FUNCTION

Conceptually:

useMemo(() => calculateValue(), deps);

useCallback(() => doSomething(), deps);

从以性能为导向的钩子函数转向结构化方案后,Context API解决了另一个问题:如何在多层嵌套组件之间传递数据。如果没有它,像当前用户这样的值可能就需要遍历中间的每一层:

App
 ↓
Navbar
 ↓
UserMenu
 ↓
Profile
 ↓
Avatar

从而导致类似以下的情况:

<App user={user} />
<Navbar user={user} />
<UserMenu user={user} />
<Profile user={user} />
<Avatar user={user} />

这种将属性转发给并不需要它们的组件的模式被称为prop drilling

设置上下文首先需要调用创建函数:

const UserContext = createContext(null);

然后通过提供者函数来设置值:

function App() {
  const user = {
    name: "Salim",
    role: "Developer"
  };

  return (
    <UserContext.Provider value={user}>
      <Dashboard />
    </UserContext.Provider>
  );
}

并在需要时读取该值:

function Profile() {
  const user = useContext(UserContext);

  return <h1>Hello {user.name}</h1>;
}

Profile不再需要通过每个中间组件传递user值。

一个常见的实际应用场景是主题设置:

const ThemeContext = createContext(null);

function App() {
  const [theme, setTheme] = useState("light");

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Dashboard />
    </ThemeContext.Provider>
  );
}

任何组件都可以直接读取当前主题:

function Button() {
  const { theme } = useContext(ThemeContext);

  return (
    <button className={theme}>
      Submit
    </button>
  );
}

生成的结构大致呈树形:

App
 │
 └── ThemeProvider
       │
       └── Dashboard
             │
             └── Button

Button无需通过层层传递属性即可获取主题值。

需要明确的是,Context并非自动完整的状态管理解决方案。它仅能回答一个具体问题:如何让树结构深处的组件能够访问某个值?它并不提供专用库所具备的额外功能——如选择器、中间件、结构化更新等。对于更复杂的状态管理需求,可以考虑:

Redux
Zustand
Jotai
Reducer + Context

Context最好被视作一种值分发机制,而非状态管理器。

它对渲染过程也有影响。假设:

<ThemeContext.Provider value={theme}>

每当该上下文的值发生变化时,使用它的组件就可以重新渲染。上下文并不能神奇地防止重复渲染——将一个庞大且频繁变化的对象放入被广泛使用的上下文中,实际上可能会造成不必要的开销。更适合放入上下文的值是那些变化不频繁的,例如:

Theme
Locale
Authentication information
Feature flags
Application configuration

自定义 Hook 允许你将基于内置 Hook 构建的可重用逻辑封装起来。一个最简单的示例:

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

  function increment() {
    setCount(c => c + 1);
  }

  return {
    count,
    increment
  };
}

使用方式如下:

function Counter() {
  const { count, increment } = useCounter();

  return (
    <button onClick={increment}>
      Count: {count}
    </button>
  );
}

这里的真正价值在于逻辑的重用,而非标记的重用。

需要特别强调的是:自定义 Hook 共享的是逻辑,而非状态。如果两个独立的组件各自调用了同一个自定义 Hook:

const counterA = useCounter();
const counterB = useCounter();

它们并不会共享同一个 count 值。每次调用都会拥有独立的状态。从概念上来说:

Component A
 └── useCounter
      └── useState → State A

Component B
 └── useCounter
      └── useState → State B

自定义 Hook 用于封装行为逻辑,而非共享数据存储。

举个具体例子,假设某个应用需要显示用户当前是否有网络连接。无需在每个需要该功能的组件中重复实现浏览器事件监听逻辑,只需将其封装在自定义 Hook 中即可:

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine
  );

  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }

    function handleOffline() {
      setIsOnline(false);
    }

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return isOnline;
}

现在,任何需要获取连接状态的组件都可以直接使用它:

function Navbar() {
  const isOnline = useOnlineStatus();

  return (
    <span>
      {isOnline ? "🟢 Online" : "🔴 Offline"}
    </span>
  );
}

另一个组件则可以利用同一个 Hook 来彻底改变自身的渲染方式:

function Checkout() {
  const isOnline = useOnlineStatus();

  if (!isOnline) {
    return <p>You are offline.</p>;
  }

  return <PaymentForm />;
}

对于快速变化的输入值,也可采用类似的模式进行处理:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

可像这样在搜索功能中使用它:

function Search() {
  const [query, setQuery] = useState("");

  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (!debouncedQuery) return;

    // Search API
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
    />
  );
}

这将产生如下一系列事件:

User types
    ↓
query changes
    ↓
500ms wait
    ↓
debouncedQuery changes
    ↓
API request

这些基础组件应当被组合使用,而非单独使用:

                 React Component
                       │
       ┌───────────────┼────────────────┐
       │               │                │
   useState        useEffect        useContext
       │               │                │
    UI state       External systems   Shared data
       │
       └───────────────┐
                       │
                 Custom Hook
                       │
             ┌─────────┼─────────┐
             ▼         ▼         ▼
         useState   useEffect   useMemo

例如,一个自定义的 useProducts() Hook 在内部可能会依赖:

useState
+
useEffect
+
useMemo

useContext 中获取认证状态。重新渲染会遵循可预测的路径:

State / Props / Context change
          ↓
      React update
          ↓
       Render
          ↓
   Reconciliation
          ↓
       Commit
          ↓
      DOM update
          ↓
      Browser paint

每个 Hook 都承担着不同的角色,具体如下:

useState
→ provides state + schedules updates

useMemo
→ calculates/reuses values during render

useCallback
→ calculates/reuses function references during render

useContext
→ reads context during render

useEffect
→ synchronizes with external systems after commit

相关阅读

  • React组件入门:构建可复用、易维护的UI组件 — 了解为何将UI拆分为小型React组件能提升复用性、可读性以及团队协作效率,随后动手创建你的第一个功能组件。
  • 为何在JavaScript中catch ()会抛出SyntaxError — 了解为何空的catch参数列表会导致JavaScript解析彻底失败,并查看两种语法正确的无参数catch块写法。
  • 停止使用 useEffect 同步状态:一种更安全的 React 编程模式 — 了解为何使用 useEffect 同步派生状态会导致竞态条件及多余的渲染,以及如何用运行时派生和 key 属性来替代它。