你用 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------useContext 和 ThemeContext。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 推过来的。它只关心"给我当前主题"。这就是封装的价值。
三层进化:组件通信的三个阶段
这是我项目中同一套代码的三种写法,从原始到优雅。
阶段一: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 每个文件都导入了 useContext 和 ThemeContext。如果未来要做以下任一改动,你都得改所有消费组件:
- 给主题加一个 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 内部用了 useState、useEffect、addEventListener。它只关心"给我鼠标坐标"。
自定义 Hook 的本质 :把 React 的响应式逻辑(state + effect)封装成可复用的函数。和普通工具函数的区别是------自定义 Hook 内部可以使用
useState、useEffect、useContext等所有 React Hooks。
自定义 Hook 的最佳切入点,就是封装 useContext。 理由很简单:
- 不会增加新概念------你已经在用 useContext
- 代码改动极小------就是把一行
useContext(XXX)移到独立文件 - 收益立竿见影------组件 import 变少、代码变干净
组件通信全景图
学到这里,你已经掌握了 React 组件通信的全部手段。一张图总结:
选择原则:
- 只传 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 从踩坑到精通 系列:
- useState 的三个反直觉行为 ---
setCount(count+1)三次只加一次? - useRef 的本质 --- 不只是绑定 DOM
- React 状态提升 --- TodoList 实战 --- 状态该放哪个组件
- ← 本文:useContext + 自定义 Hook --- 你在这里