useContext 用是用了,但你真的用对了吗?——把 Context 封装进自定义 Hook

7 阅读7分钟

你用 useContext 的方式,可能一直是半吊子——Context + 自定义 Hook 的正确打开方式


开篇:两种写法,高下立判

你学会了 useContext,终于不用一层层传 props 了。于是你在每个需要读 Context 的组件里这样写:

// 组件 A
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';

function Page() {
  const theme = useContext(ThemeContext);
  return <div className={theme}>当前主题:{theme}</div>;
}

// 组件 B
function Child() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>按钮</button>;
}

// 组件 C
function Footer() {
  const theme = useContext(ThemeContext);
  return <footer className={theme}>页脚</footer>;
}

功能正常。主题切换,三个组件都跟着变。

但过两周你发现:每个组件文件顶部都躺着两条 import——useContextThemeContext。5 个消费组件就是 10 条重复 import。想加一层"主题切换时打印日志"?5 个文件得逐个改。

然后你看到了另一种写法:

// hooks/useTheme.js — 只有一个地方调 useContext
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';

export function useTheme() {
  return useContext(ThemeContext);
}

每个组件里变成:

import { useTheme } from '../hooks/useTheme';

function Page() {
  const theme = useTheme();  // 干净的调用,不需要知道背后是哪个 Context
  return <div className={theme}>当前主题:{theme}</div>;
}

这篇文章就是回答一个问题:这两种写法都能跑,但第二种到底好在哪?以及——为什么 React 社区把它列为"自定义 Hook 的最佳入门场景"。


核心概念:为什么要有"自定义 Hook"?

useContext 解决了"跨层级"的问题,但没解决"可维护性"的问题。自定义 Hook 就是给 Context 消费加一层自己的封装——组件只知道"我用了一个主题",不需要知道"主题来自哪个 Context"。

用生活场景理解:

直接调 useContext = 每次想吃饺子都自己揉面、擀皮、调馅。能做出来,但累。
自定义 Hook     = 你妈已经把饺子包好了放冰箱。你只需要煮一下。

关键区别:组件调用 useTheme() 时,它不关心主题数据是通过 Context 来的、从 localStorage 来的、还是从服务端 WebSocket 推过来的。它只关心"给我当前主题"。这就是封装的价值。


三层进化:组件通信的三个阶段

这是我项目中同一套代码的三种写法,从原始到优雅。

graph TD
    A[&#34;阶段一:props 层层传递&#34;] --> B[&#34;阶段二:useContext 直接消费&#34;]
    B --> C[&#34;阶段三:自定义 Hook 封装 Context&#34;]
    A -.-> D[&#34;❌ 层级越深越痛苦&#34;]
    B -.-> E[&#34;⚠️ 能用但散落各处&#34;]
    C -.-> F[&#34;✅ 测试友好、改得动&#34;]

阶段一:props drilling(原始社会)

没有 Context 的时候,祖先组件的数据要传给曾孙,只能一路 props 下去:

// App → Page → Child,每层都要接一下
function App() {
  const [theme, setTheme] = useState('light');
  return <Page theme={theme} />;  // 传给 Page
}

function Page({ theme }) {
  return <Child theme={theme} />;  // Page 自己不用,但必须接住再往下传
}

function Child({ theme }) {
  return <button className={theme}>按钮</button>;  // 终于用上了
}

Page 组件自己不用 theme,却被迫声明 { theme } 接收、再传给 Child。这就是典型的 props 搬运工——组件层级越深,搬运工作越繁琐。


阶段二:useContext 直接消费(现代但不够好)

引入 Context 后,中间层解放了:

// ThemeContext.jsx — 创建一个"数据通道"
import { createContext } from 'react';
export const ThemeContext = createContext('light');

// App.jsx — 提供者:把数据放进 Context
function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={theme}>
      <Page />
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        切换主题
      </button>
    </ThemeContext.Provider>
  );
}

Page 和 Child 直接从 Context 拿数据:

// Page.jsx — 中间组件,不用再接 props
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';

function Page() {
  const theme = useContext(ThemeContext);
  return (
    <>
      <div>当前主题:{theme}</div>
      <Child />
    </>
  );
}
// Child.jsx — 深层组件,直接消费 Context
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';

function Child() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>按钮</button>;
}

问题在哪? Page 和 Child 每个文件都导入了 useContextThemeContext。如果未来要做以下任一改动,你都得改所有消费组件:

  • 给主题加一个 fallback(比如 Context 没 Provider 时用 localStorage)
  • 切换时打印日志或上报埋点
  • 把主题数据从 Context 换成全局状态库(Zustand/Jotai)

⚠️ 核心痛点useContext(ThemeContext) 这句话暴露了两个实现细节给组件——"我用的是 React Context" 和 "数据源叫 ThemeContext"。组件不需要知道这些。


阶段三:自定义 Hook 封装 Context(正确姿势)

useContext 的调用抽到一个自定义 Hook 里,对外只暴露一个干净的接口。

// hooks/useTheme.js — 🔑 唯一的 useContext 调用点
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';

export function useTheme() {
  return useContext(ThemeContext);
}

这个文件很小,但它做的事情非常重要:它把"如何获取主题数据"的知识,从 5 个组件收拢到 1 个文件里。

所有组件现在只需要:

// Page.jsx — 组件只知道 useTheme,不知道 ThemeContext 的存在
import { useTheme } from '../hooks/useTheme';

function Page() {
  const theme = useTheme();
  return (
    <>
      <div>当前主题:{theme}</div>
      <Child />
    </>
  );
}

变化一目了然:

阶段二(直接 useContext)阶段三(自定义 Hook)
每组件 import 行数2 行(useContext + ThemeContext)1 行(useTheme)
组件知道数据来源吗?知道——依赖 ThemeContext不知道——只依赖 useTheme
换数据源改多少文件?所有消费组件只改 useTheme.js
能加中间逻辑吗?每个组件自己加在 useTheme 里一次加完
测试友好度需要 mock Context只需 mock useTheme

实战:需求变更见真章

假设产品给你加了三个需求,看看两种写法改动的范围:

需求 1:当 Context 没有 Provider 包裹时,回退到 localStorage 里的主题

阶段二写法(噩梦)——每个组件都要改:

// ❌ 5 个组件文件,每个都要加这段逻辑
function Page() {
  const ctxTheme = useContext(ThemeContext);
  const theme = ctxTheme || localStorage.getItem('theme') || 'light';  // 每个文件重复
  // ...
}

阶段三写法(爽)——只改 1 个文件:

// ✅ hooks/useTheme.js — 只改这里,所有组件自动生效
export function useTheme() {
  const ctxTheme = useContext(ThemeContext);
  return ctxTheme || localStorage.getItem('theme') || 'light';
}

需求 2:主题切换时上报埋点

阶段三只需要在 useTheme 里加一个 useEffect 或回调,所有组件无感知。

需求 3:单元测试

// 阶段二:测试 Page 组件时,必须用 ThemeContext.Provider 包裹
render(
  <ThemeContext.Provider value="dark">
    <Page />
  </ThemeContext.Provider>
);

// 阶段三:直接 mock useTheme 这一个函数
jest.mock('../hooks/useTheme', () => ({
  useTheme: () => 'dark'
}));

改动的文件数从 N 变成了 1。 这就是封装的威力。


不只 Context——自定义 Hook 的真正定位

项目中还有另一个自定义 Hook:

// hooks/useMouse.js — 监听鼠标位置
import { useState, useEffect } from 'react';

export function useMouse() {
  const [position, setPosition] = useState({ x: null, y: null });

  useEffect(() => {
    // 🔑 事件处理函数定义在 useEffect 内部——可以正确引用到 setter
    // ⚠️ 易错:如果函数定义在外面,每次渲染都会重新创建引用,还可能导致闭包过期
    const handleMouseMove = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };

    document.addEventListener('mousemove', handleMouseMove);

    return () => {
      // 🔑 清理函数:组件卸载时移除事件监听,防止内存泄漏
      // 不清理的话,即使组件卸载了,每次鼠标移动还是会触发 setState
      // → React 会在控制台打印警告:"Can't perform a React state update on an unmounted component"
      document.removeEventListener('mousemove', handleMouseMove);
    };
  }, []);

  return position;
}

在组件中使用,一行就够了:

// App.jsx — useMouse 自定义 Hook 的消费者
import { useMouse } from './hooks/useMouse';

function App() {
  const { x, y } = useMouse();

  return (
    <div>
      {x && y ? `鼠标 X:${x},鼠标 Y:${y}` : '鼠标未移动'}
    </div>
  );
}

App 组件完全不需要知道 useMouse 内部用了 useStateuseEffectaddEventListener。它只关心"给我鼠标坐标"。

自定义 Hook 的本质:把 React 的响应式逻辑(state + effect)封装成可复用的函数。和普通工具函数的区别是——自定义 Hook 内部可以使用 useStateuseEffectuseContext 等所有 React Hooks。

自定义 Hook 的最佳切入点,就是封装 useContext。 理由很简单:

  • 不会增加新概念——你已经在用 useContext
  • 代码改动极小——就是把一行 useContext(XXX) 移到独立文件
  • 收益立竿见影——组件 import 变少、代码变干净

组件通信全景图

学到这里,你已经掌握了 React 组件通信的全部手段。一张图总结:

graph TD
    A[&#34;组件通信&#34;] --> B[&#34;父子关系&#34;]
    A --> C[&#34;爷孙/深层关系&#34;]
    A --> D[&#34;陌生人关系&#34;]

    B --> B1[&#34;props 传数据&#34;]
    B --> B2[&#34;回调函数(子→父)&#34;]

    C --> C1[&#34;useContext + 自定义 Hook&#34;]
    C --> C2[&#34;状态管理库(Redux/Zustand)&#34;]

    D --> D1[&#34;状态管理库&#34;]
    D --> D2[&#34;URL 参数&#34;]
    D --> D3[&#34;localStorage / 事件总线&#34;]

选择原则

  • 只传 1-2 层 → props,简单直接
  • 传 3 层以上但范围可控 → useContext + 自定义 Hook,轻量高效
  • 全局共享、复杂更新逻辑 → 状态管理库,专业方案

结尾:回到开篇的问题

开篇问:两种写法都能跑,封装成自定义 Hook 好在哪?

好在"未来要改的时候,你只需要改一个文件,而不是 N 个文件。"

用一句话记住:把你的 useContext(XXXContext) 全换成 useXXX()——Context 负责"存数据",自定义 Hook 负责"取数据"。组件只管"用数据"。

下次写 Context 时,就做这一件事

  • 创建 Context 后,顺手写一个 use[Name] 的自定义 Hook
  • 所有组件只调自定义 Hook,不直接调 useContext
  • 如果有组件写了 useContext(ThemeContext),把它移到 useTheme

这个习惯简单到不可能失败,但它会让你的代码从"能用"变成"好维护"。

开放问题:你项目中 Context 是直接 useContext 还是封装了自定义 Hook?有没有遇到过因为没封装导致改 N 个文件的惨痛经历?或者你对"过度封装"有什么看法——比如 Context 就一个地方消费,还值得封装吗?评论区聊聊。


本系列其他文章

这个 Demo 项目属于 React Hooks 从踩坑到精通 系列: