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

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


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

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

jsx 复制代码
// 组件 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 个文件得逐个改。

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

jsx 复制代码
// hooks/useTheme.js --- 只有一个地方调 useContext
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';

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

每个组件里变成:

jsx 复制代码
import { useTheme } from '../hooks/useTheme';

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

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


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

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

用生活场景理解:

ini 复制代码
直接调 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 下去:

jsx 复制代码
// 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 后,中间层解放了:

jsx 复制代码
// 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 拿数据:

jsx 复制代码
// Page.jsx --- 中间组件,不用再接 props
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';

function Page() {
  const theme = useContext(ThemeContext);
  return (
    <>
      <div>当前主题:{theme}</div>
      <Child />
    </>
  );
}
jsx 复制代码
// 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 里,对外只暴露一个干净的接口。

jsx 复制代码
// hooks/useTheme.js --- 🔑 唯一的 useContext 调用点
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';

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

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

所有组件现在只需要:

jsx 复制代码
// 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 里的主题

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

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

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

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

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

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

需求 3:单元测试

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

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

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


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

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

jsx 复制代码
// 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;
}

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

jsx 复制代码
// 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 从踩坑到精通 系列:


相关推荐
BreezeJiang1 小时前
Web Worker 不负责渲染:React 中的线程分工与消息闭环
javascript·react.js
做前端的娜娜子1 小时前
JavaScript 闭包
前端·javascript·掘金·金石计划
xiaominlaopodaren1 小时前
three.js地图数学基础(一)
前端·three.js
何时梦醒1 小时前
🎯 从零彻底搞懂 React Context API —— 一篇带你穿越"组件树"的状态共享方案
前端·javascript·react.js
渣波1 小时前
拒绝页面假死!React 并发编程实战:Web Worker + useRef 深度解析与高性能计算架构
前端·javascript
用户2930750976691 小时前
Web Worker:让 JavaScript 拥有"多线程"能力
前端
BreezeJiang1 小时前
不要把 Context 当万能状态库:主题共享和鼠标 Hook 应该这样拆
javascript·react.js
渣波1 小时前
赋予 AI “灵魂”:LangChain.js 中临时与长期记忆的终极实战指南
前端·javascript
想要成为糕糕手1 小时前
🧵 浏览器里的第二大脑:Web Worker
javascript·react.js·浏览器