MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

React 自定义 Hook 与 Context 的结合

2022-05-292.2k 阅读

React 自定义 Hook 基础

在 React 开发中,Hook 是 React 16.8 引入的新特性,它允许我们在不编写类的情况下使用 state 以及其他 React 特性。自定义 Hook 则是一种将组件逻辑提取到可复用函数中的方式。

自定义 Hook 的创建

自定义 Hook 本质上是一个函数,其命名必须以 use 开头。例如,我们创建一个简单的自定义 Hook 用于跟踪组件的挂载和卸载:

import { useEffect } from 'react';

const useMountUnmount = () => {
  useEffect(() => {
    console.log('Component mounted');
    return () => {
      console.log('Component unmounted');
    };
  }, []);
};

const MyComponent = () => {
  useMountUnmount();
  return <div>My Component</div>;
};

在上述代码中,useMountUnmount 是一个自定义 Hook。useEffect 用于在组件挂载时执行副作用操作,返回的函数会在组件卸载时执行。通过将这段逻辑封装到自定义 Hook 中,我们可以在多个组件中复用。

自定义 Hook 的参数和返回值

自定义 Hook 可以接受参数并返回值。比如,我们创建一个用于监听窗口尺寸变化的自定义 Hook:

import { useState, useEffect } from 'react';

const useWindowSize = () => {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return windowSize;
};

const MyComponent = () => {
  const size = useWindowSize();
  return (
    <div>
      <p>Window width: {size.width}</p>
      <p>Window height: {size.height}</p>
    </div>
  );
};

这里 useWindowSize 自定义 Hook 没有参数,但返回了包含窗口宽高的对象。组件通过调用该 Hook 获取窗口尺寸并展示。

React Context 基础

React Context 提供了一种在组件树中共享数据的方式,而不必通过 props 一层一层地传递数据。

创建 Context

首先,我们使用 createContext 方法创建一个 Context 对象:

import React from'react';

const MyContext = React.createContext();

export default MyContext;

这里创建了一个名为 MyContext 的 Context 对象。

使用 Context

Context 有两种主要的使用方式:ProviderConsumer

Provider:用于在组件树中提供数据。

import React from'react';
import MyContext from './MyContext';

const data = { message: 'Hello from Context' };

const App = () => {
  return (
    <MyContext.Provider value={data}>
      {/* 组件树 */}
    </MyContext.Provider>
  );
};

export default App;

Provider 组件中,通过 value 属性传递要共享的数据。

Consumer:用于消费 Context 中的数据。

import React from'react';
import MyContext from './MyContext';

const MyConsumerComponent = () => {
  return (
    <MyContext.Consumer>
      {contextData => (
        <div>{contextData.message}</div>
      )}
    </MyContext.Consumer>
  );
};

export default MyConsumerComponent;

Consumer 组件接受一个函数作为子元素,该函数接收 Context 中的数据并进行渲染。

React 自定义 Hook 与 Context 结合的优势

将自定义 Hook 与 Context 结合使用,可以带来以下几个显著的优势:

增强数据共享的复用性

通过自定义 Hook 封装 Context 的消费逻辑,使得在多个组件中使用相同 Context 数据的逻辑可以复用。例如,假设我们有一个用户登录信息的 Context,在多个组件中都需要获取用户信息。如果不使用自定义 Hook,每个组件都需要重复编写 Consumer 的逻辑。而使用自定义 Hook,可以将获取用户信息的逻辑封装起来,多个组件直接调用该 Hook 即可。

import React from'react';
import UserContext from './UserContext';

const useUser = () => {
  const user = React.useContext(UserContext);
  return user;
};

const Component1 = () => {
  const user = useUser();
  return (
    <div>
      <p>Welcome, {user.name}</p>
    </div>
  );
};

const Component2 = () => {
  const user = useUser();
  return (
    <div>
      <p>User role: {user.role}</p>
    </div>
  );
};

在上述代码中,useUser 自定义 Hook 封装了从 UserContext 中获取用户信息的逻辑,Component1Component2 都可以复用该逻辑,避免了重复代码。

分离业务逻辑与渲染逻辑

自定义 Hook 与 Context 结合可以将业务逻辑从组件的渲染逻辑中分离出来。以一个电商应用为例,购物车数据通过 Context 共享,而处理购物车的添加商品、移除商品等逻辑可以封装在自定义 Hook 中。组件只需要关心如何展示购物车信息,而具体的业务操作逻辑在自定义 Hook 中实现。

import React from'react';
import CartContext from './CartContext';

const useCart = () => {
  const cart = React.useContext(CartContext);

  const addItemToCart = (item) => {
    // 处理添加商品到购物车的逻辑
    cart.items.push(item);
  };

  const removeItemFromCart = (itemId) => {
    // 处理从购物车移除商品的逻辑
    cart.items = cart.items.filter(item => item.id!== itemId);
  };

  return {
    cart,
    addItemToCart,
    removeItemFromCart
  };
};

const CartComponent = () => {
  const { cart, addItemToCart, removeItemFromCart } = useCart();
  return (
    <div>
      <h2>Cart</h2>
      {/* 渲染购物车商品列表 */}
      <button onClick={() => addItemToCart({ id: 1, name: 'Product 1' })}>Add Product 1</button>
      <button onClick={() => removeItemFromCart(1)}>Remove Product 1</button>
    </div>
  );
};

在这个例子中,useCart 自定义 Hook 分离了购物车业务逻辑,CartComponent 只专注于渲染和调用 Hook 提供的方法。

提高代码的可维护性和测试性

将 Context 相关的逻辑封装在自定义 Hook 中,使得代码结构更加清晰,易于维护。当 Context 的数据结构或消费逻辑发生变化时,只需要修改自定义 Hook 中的代码,而不需要在每个使用 Context 的组件中进行修改。同时,自定义 Hook 也更容易进行单元测试,因为其逻辑相对独立。

// 假设这是自定义 Hook 的测试代码
import { renderHook } from '@testing-library/react-hooks';
import useUser from './useUser';
import UserContext from './UserContext';

describe('useUser hook', () => {
  it('should return user data from context', () => {
    const user = { name: 'John', role: 'admin' };
    const { result } = renderHook(() => useUser(), {
      wrapper: ({ children }) => (
        <UserContext.Provider value={user}>
          {children}
        </UserContext.Provider>
      )
    });
    expect(result.current).toEqual(user);
  });
});

上述测试代码验证了 useUser 自定义 Hook 能够正确从 Context 中获取用户数据,这种测试方式简洁且有效,提高了代码的可靠性。

实现 React 自定义 Hook 与 Context 结合

接下来,我们通过一个实际的例子来详细展示如何实现 React 自定义 Hook 与 Context 的结合。假设我们正在开发一个多语言应用,需要在不同组件中共享当前语言设置并能够切换语言。

创建 Context

首先,创建一个 LanguageContext

import React from'react';

const LanguageContext = React.createContext();

export default LanguageContext;

创建自定义 Hook

然后,创建一个自定义 Hook 用于消费和操作 LanguageContext

import { useState, useContext } from'react';
import LanguageContext from './LanguageContext';

const useLanguage = () => {
  const [language, setLanguage] = useState('en');

  const changeLanguage = (newLanguage) => {
    setLanguage(newLanguage);
  };

  const contextValue = {
    language,
    changeLanguage
  };

  return {
    language,
    changeLanguage,
    contextValue
  };
};

export default useLanguage;

在这个 useLanguage 自定义 Hook 中,我们使用 useState 来管理当前语言状态,changeLanguage 函数用于切换语言。contextValue 包含了语言状态和切换语言的函数,以便通过 Provider 传递给 Context。

使用自定义 Hook 和 Context

在应用的顶层组件中,使用 Provider 提供 Context:

import React from'react';
import LanguageContext from './LanguageContext';
import useLanguage from './useLanguage';

const App = () => {
  const { contextValue } = useLanguage();
  return (
    <LanguageContext.Provider value={contextValue}>
      {/* 应用的其他组件 */}
    </LanguageContext.Provider>
  );
};

export default App;

在需要使用语言设置的组件中,通过自定义 Hook 获取语言信息:

import React from'react';
import useLanguage from './useLanguage';

const LanguageComponent = () => {
  const { language, changeLanguage } = useLanguage();
  return (
    <div>
      <p>Current language: {language}</p>
      <button onClick={() => changeLanguage('fr')}>Switch to French</button>
    </div>
  );
};

export default LanguageComponent;

在上述代码中,LanguageComponent 通过 useLanguage 自定义 Hook 获取当前语言并提供一个按钮用于切换语言。整个应用通过自定义 Hook 和 Context 实现了语言设置的共享和操作。

注意事项

在使用 React 自定义 Hook 与 Context 结合时,有一些注意事项需要关注:

Context 数据更新引起的重新渲染

Context 的 Provider 组件的 value 属性发生变化时,所有使用该 Context 的组件都会重新渲染。在自定义 Hook 中,如果频繁更新 Context 的 value,可能会导致不必要的性能开销。例如,在上述语言切换的例子中,如果 contextValue 中的对象每次都重新创建(比如在 useLanguage 中直接返回 { language, changeLanguage } 而不是先定义 contextValue 变量),就会导致 Providervalue 每次都不同,从而引起不必要的重新渲染。为了避免这种情况,可以使用 useMemo 来缓存 contextValue

import { useState, useContext, useMemo } from'react';
import LanguageContext from './LanguageContext';

const useLanguage = () => {
  const [language, setLanguage] = useState('en');

  const changeLanguage = (newLanguage) => {
    setLanguage(newLanguage);
  };

  const contextValue = useMemo(() => ({
    language,
    changeLanguage
  }), [language]);

  return {
    language,
    changeLanguage,
    contextValue
  };
};

export default useLanguage;

这样,只有当 language 发生变化时,contextValue 才会重新计算,减少了不必要的重新渲染。

自定义 Hook 的依赖管理

在自定义 Hook 中,如果使用了 useEffectuseCallbackuseMemo 等依赖数组相关的 Hook,要正确管理依赖。比如在 useWindowSize 自定义 Hook 中,如果在 handleResize 函数中使用了外部变量,而没有将其添加到 useEffect 的依赖数组中,可能会导致变量在闭包中引用的是旧值。例如:

import { useState, useEffect } from'react';

const useWindowSize = () => {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  let counter = 0;
  useEffect(() => {
    const handleResize = () => {
      counter++;
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
      console.log('Counter:', counter);
    };
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return windowSize;
};

const MyComponent = () => {
  const size = useWindowSize();
  return (
    <div>
      <p>Window width: {size.width}</p>
      <p>Window height: {size.height}</p>
    </div>
  );
};

在上述代码中,counter 变量在 handleResize 函数中使用,但没有添加到 useEffect 的依赖数组中。这会导致每次窗口 resize 时,counter 始终为 0,因为它引用的是闭包中的旧值。正确的做法是将 counter 添加到依赖数组中:

import { useState, useEffect } from'react';

const useWindowSize = () => {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  let counter = 0;
  useEffect(() => {
    const handleResize = () => {
      counter++;
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
      console.log('Counter:', counter);
    };
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, [counter]);

  return windowSize;
};

const MyComponent = () => {
  const size = useWindowSize();
  return (
    <div>
      <p>Window width: {size.width}</p>
      <p>Window height: {size.height}</p>
    </div>
  );
};

这样,counter 会随着每次 resize 正确递增。

命名冲突

由于自定义 Hook 命名必须以 use 开头,在项目中可能会出现命名冲突的情况。为了避免这种情况,建议在命名自定义 Hook 时使用有意义的、描述性的名称,并且遵循项目的命名规范。例如,如果在一个电商项目中有处理商品列表的自定义 Hook,可以命名为 useProductList,而不是简单的 useList,这样可以减少命名冲突的可能性。

通过合理结合 React 自定义 Hook 和 Context,并注意上述事项,可以构建出更高效、可维护的前端应用。在实际开发中,根据项目的需求和规模,灵活运用这两个特性,能够显著提升开发效率和代码质量。