绕开层层 props 搬运:我把 useContext 和自定义 Hook 跑通了

126 阅读6分钟

先说说我为啥要折腾这玩意儿

之前写嵌套组件的时候最闹心的一件事,就是传数据。就拿主题色来说吧,最顶层的 App 里定义了 theme,Page 组件要用,Page 里面的 Child 组件也要用。按照以前的写法,就得 App 传给 Page,Page 再顺手传给 Child。这才两层,看着还行,可要是以后再加个 GrandChild、GreatGrandChild 呢?中间每一层都得干 “接过来、传下去” 的搬运活,改个变量名得改三四个文件,纯纯的体力劳动。

我就不信 React 没给这种场景留解决方案。翻了下文档,果然,useContext 就是干这个的。

Context 到底是个啥东西

我自己理解下来,Context 就像在组件树里放了一个公共储物柜。

  • 你在某一层放一个 Provider,相当于把数据存进柜子里
  • 这一层下面所有的子孙组件,不管隔了多少代,都能直接伸手从柜子里拿数据
  • 中间那些组件完全不用知情,不用再写 props 接力

说白了就是 “跨层级直通”,不用再层层中转。

我是怎么一步步搭起来的

第一步:先建一个上下文

首先得用 createContext 创建一个上下文对象,相当于先定制好这个储物柜的 “型号”。

// ThemeContext.jsx
import { createContext } from 'react';

// 创建一个主题上下文,默认值是 light
export const ThemeContext = createContext('light');

说实话,一开始我以为这个默认值是 “兜底用” 的 —— 比如 Provider 没传 value 的时候就用它。后来才发现我理解错了,这个后面踩坑的时候细说。

第二步:用 Provider 把数据 “放” 进去

有了上下文之后,得用它的 Provider 组件把需要共享数据的子树包起来,通过 value 属性把数据传进去。

    // App.jsx
    import { useState } from 'react';
    import { ThemeContext } from './ThemeContext';
    import Page from './components/Page';

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

      return (
        <ThemeContext.Provider value={theme}>
          <Page />
          <button onClick={() => setTheme('dark')}>切换主题</button>
        </ThemeContext.Provider>
      )
    }

    export default App;

我一开始还以为 Provider 只能包在整个应用的最根节点,后来才发现根本不是。你想在哪层提供数据,就包在哪层,作用范围就只有它下面的子组件。这点还挺灵活的,不是非得当全局变量用。

跑起来之后我打开 React DevTools 看了眼组件树,确实能看到 Context.Provider 这一层,右边清清楚楚显示着 value: "dark",下面的 Page、Child 都没接 props,数据直接透下去了。

f635e570e355711d3feca20d67e2d9a6.png

第三步:在子孙组件里拿数据

最开始我是直接在组件里用 useContext(ThemeContext) 拿数据,写了两个组件发现重复代码有点多。反正都是读同一个上下文,不如抽成一个自定义 Hook。

    // hooks/useTheme.js
    import { ThemeContext } from '../ThemeContext';
    import { useContext } from 'react';

    // 约定俗成:自定义 Hook 必须 use 开头
    export function useTheme() {
      return useContext(ThemeContext);
    }

封装完之后,不管在哪一层的组件,只要在 Provider 作用域内,直接调用 useTheme() 就能拿到主题值。

    // components/Page.jsx
    import Child from './Child';
    import { useTheme } from '../hooks/useTheme';

    const Page = () => {
      const theme = useTheme();
      return (
        <>
          Page {theme}
          <br />
          <Child/>
        </>
      )
    }
    export default Page;
    // components/Child.jsx
    import { useTheme } from '../hooks/useTheme';

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

    export default Child;

你看,Page 和 Child 之间完全没传 props,但都拿到了同一个 theme 数据。这要是再加个孙组件,照样直接调用 useTheme(),舒服多了。

我的项目目录长这样

我习惯把不同职责的文件分开,上下文单独一个文件,自定义 Hook 全放 hooks 目录,组件放 components 目录,找起来不费劲。

又试了个更直观的例子:监听鼠标位置

尝到自定义 Hook 的甜头之后,我又写了个 useMouse 来练手 —— 实时获取鼠标坐标。这种逻辑如果写在组件里,下次别的页面也要用就得复制粘贴。抽成 Hook 之后,一行 useMouse() 就搞定。

    // hooks/useMouse.js
    import { useState, useEffect } from 'react';

    export const useMouse = () => {
        const [x, setX] = useState(null);
        const [y, setY] = useState(null);

        useEffect(() => {
            function handleMouseMove(e) {
                setX(e.clientX);
                setY(e.clientY);
            }

            document.addEventListener('mousemove', handleMouseMove);
            
            // 注意这行!坑了我半小时
            return () => {
                document.removeEventListener('mousemove', handleMouseMove);
            }
        }, [])

        return {x, y}
    }

说个丢人的事,第一版我没写 return 里的移除事件。后来切页面的时候控制台飘了个警告,说内存泄漏。我才反应过来:事件监听、定时器这种东西,React 不会帮你自动清理,必须在 useEffect 的清理函数里手动拆掉,不然组件卸载了事件还挂在 document 上,迟早出问题。

用起来就很简单了:

    // App2.jsx
    import { useMouse } from "./hooks/useMouse";

    function App() {
      const {x, y} = useMouse();
      return (
        <div style={{height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
          {x && y ? `x: ${x}, y: ${y}` : '鼠标未移动'}
        </div>
      )
    }

    export default App;

跑起来的效果就是这样,鼠标一动坐标实时更新,逻辑全封装在 Hook 里,组件只管渲染。

屏幕录制 2026-08-11 110248.gif

踩过的几个坑,别再踩了

1. createContext 的默认值,不是你想的那样

我一开始以为默认值是 “Provider 没传 value 时的兜底”,这么说也不算错,但更准确的说法是:只有当组件外层完全找不到对应 Provider 时,才会用上这个默认值。只要外面包了 Provider,哪怕你 value 传了个 undefined,也不会走默认值。我之前对着默认值调了十分钟,纳闷为啥不生效,后来才搞懂这个规则。

小提示默认值更多是给脱离 Provider 单独测试组件用的,正常业务里基本都是靠 Provider 传值。

2. 自定义 Hook 必须以 use 开头

别不信邪,我一开始随手写了个 getTheme,结果 eslint 直接飘红,React 也不会把它当成 Hook 来做语法检查。这既是约定,也是规则。起名老老实实 useXxx,省得给自己找麻烦。

3. Provider 的 value 别随便传对象

这次 demo 我传的是字符串 'dark',没啥问题。但如果你要传一个对象,比如 value={{ theme, setTheme }},就要小心了。父组件每次渲染都会生成一个新的对象引用,会导致所有消费这个 Context 的组件全部跟着重渲染,层级深了性能掉得很明显。真要传对象,记得用 useMemo 包一下。

最后扯两句

跑通这两个例子之后,我对这俩东西的理解才算落地了:

  • Context 解决的是跨层级数据共享的问题,适合主题、用户信息、语言包这种 “全局通用、不频繁变动” 的数据。不用再写 props 接力代码,清爽很多。
  • 自定义 Hook 是真的香,它能把响应式状态 + 副作用逻辑打包成一个可复用的函数。比以前高阶组件、render props 那套写法直观太多,是 React 里复用逻辑的首选。

当然也不是万能的。Context 不适合放频繁更新的数据,不然容易引发大面积不必要的重渲染。真到了复杂状态管理的场景,该上状态管理库还是得上。

如果你也踩过 Context 或者自定义 Hook 的什么奇葩坑,或者有更好用的封装思路,评论区留个言,我也学习学习。