2
头图

context + hooks = 真香

文章首发于itsuki.cn个人博客

前言

hooks 打开了新世界的大门,本文教你如何使用 Context + hooks。

后面代码会比较多,主要是扩展思维,坚持看到最后一定会有帮助的!!!

什么是 Context

引用官方文档中的一句话:

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。

在 React 中当中, 数据流是从上而下的, 而 props 就是数据流中一个重要的载体, 通过 props 传递给子组件渲染出来的对应的视图, 但是如果嵌套层级过深, 需要一层层传递的 props 却显得力不从心, 就像上一篇文章说的prop drilling一样。

一个比较典型的场景就是: 应用程序进行主题、语言切换时, 一层层进行传递想象一下多么的痛苦, 而且你也不可能每一个元素都能够完全覆盖到,而 Context 就提供了一种可以在组件之间共享这些值的方法, 不需要再去显式的传递每一层 props。

基本使用

我们先来说说它的基本使用, 请注意我这里使用的是tsx, 我平时更加喜欢typescript + react, 体验感更好。

如果我们要创建一个 Context, 可以使用 createContext 方法, 它接收一个参数, 我们举一个简单的例子, 通过这个简单的例子来一点点掌握 context 的用法。

const CounterContext = React.createContext<number>(0);

const App = ({ children }) => {
  return (
    <CounterContext.Provider value={0}>
      <Counter />
    </CounterContext.Provider>
  );
};

const Counter = () => {
  const count = useContext(CounterContext);

  return <h1> {count} </h1>;
};

这样子我们的组件无论嵌套多深, 都可以访问到count这个 props。 但是我们只是实现了访问, 那我们如果要进行更新呢?

如何实现更新

那么我们首先得先改造下参数类型, 从之前的一个联合类型变成一个对象类型, 有两个属性:

  1. 一个是count表示数量。
  2. 另一个是setCount实现更新数量的函数。
export type CounterContextType = {
  count: number;
  setCount: Dispatch<SetStateAction<number>>;
};

const CounterContext = React.createContext<CounterContextType | undefined>(undefined);

初始化时, 使用默认参数undefined进行占位, 那我们的 App 组件也要进行相对应的修改。

最终的代码就是这样子:

const App = ({ children }) => {
  const [count, setCount] = useState(0);
  return (
    <CounterContext.Provider value={{ count, setCount }}>
      <Counter />
    </CounterContext.Provider>
  );
};

const Counter = () => {
  const { count, setCount } = useContext(CounterContext);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(count - 1)}>-1</button>
    </div>
  );
};

看起来很棒,我们实现了计数器。

那我们来看看加上 hooks 的 Context 可以怎么优化, 现在开始准备起飞了。

ContextProvider

我们可以先封装一下CounterContext.Provider, 我希望App组件只是将所有组件做一个组合, 而不是做状态管理, 想象一下你的App组件有 n 个 Context(UIContext、AuthContext、ThemeContext 等等), 如果在这里进行状态管理那是多么痛苦的事情, 而App组件也失去了组合的意义。

const App = ({ children }) => {
  const [ui, setUI] = useState();
  const [auth, setAuth] = useState();
  const [theme, setTheme] = useState();

  return (
    <UIContext.Provider value={{ ui, setUI }}>
      <AuthContext.Provider value={{ auth, setAuth }}>
        <ThemeProvider.Provider value={{ theme, setTheme }}>{children}</ThemeProvider.Provider>
      </AuthContext.Provider>
    </UIContext.Provider>
  );
};

太恐怖了, 现在我们来封装一下:

export type Children = { children?: React.ReactNode };

export const CounterProvider = ({ children }: Children) => {
  const [count, setCount] = useState(0);

  return <CounterContext.Provider value={{ count, setCount }}>{children}</CounterContext.Provider>;
};

然后我就可以直接使用CounterProvider进行提供 counter, 看起来很棒。

const App = () => {
  return (
    <CounterProvider>
      <Counter />
    </CounterProvider>
  );
};

read context hook

第二个可以优化的地方是: 提取出读取 context 的 hook, 有两个理由:

  1. 不想每次都导入一个useContext和一个CounterProvider读取 counter 的值, 代码变得更加精简。
  2. 我想要我的代码看起来是我做了什么, 而不是我怎么做(声明式代码)

所以这里我们可以使用自定义 hook 来实现这个功能。

export const useCounter = () => {
  const context = useContext(CounterProvider);
  return context;
};

Counter组件中可以直接使用这个 hook 来读取 CounterContext 的值。

const { count, setCount } = useCounter();
// Property 'count' does not exist on type 'CounterContextType | undefined'
// Property 'setCount' does not exist on type 'CounterContextType | undefined'

但是我们直接使用的时候发现有类型错误, 再仔细一想, 在createContext中声明了有两个类型CounterContextTypeundefined, 虽然我们在ContextProvider的时候已经注入了 countsetCount 。 但是 ts 并不能保证我们一定会有值, 所以我们怎么办呢?

我们可以在useCounter中做一层类型保护, 通过类型保护来缩小我们的类型, 从而知道是什么类型了。

export const useCounter = () => {
  const context = useContext(CounterContext);
  if (context === undefined) {
    throw new Error('useCounter must in CounterContext.Provider');
  }
  return context;
};

我们使用context === undefined来实现缩小类型,其实它还有另外一个更加重要的作用: 检测当前使用useCounter hook的组件是否被正确的使用

使用 Context 会受到约束, 也就是说, 如果使用了 useCounter hook 的组件在没有包裹在 CounterProvider 组件树中 , 那么读取到的值其实就是createContext时候的初始值(在这里的例子中也就是undefined)。 undefined 再去解构赋值是无法成功的, 所以这个时候就可以通过这一判断来防止这个问题。

读写分离

在讲解为什么进行读写分离时, 我们先来修改一下代码。

代码

export type Children = { children?: React.ReactNode };
export type CounterContextType = {
  count: number;
  setCount: Dispatch<SetStateAction<number>>;
};

const CounterContext = React.createContext<CounterContextType['count'] | undefined>(undefined);

const CounterDispatchContext = React.createContext<CounterContextType['setCount'] | undefined>(
  undefined,
);

export const CountProvider = ({ children }: Children) => {
  const [count, setCount] = useState(0);

  return (
    <CounterDispatchContext.Provider value={setCount}>
      <CounterContext.Provider value={count}>{children}</CounterContext.Provider>;
    </CounterDispatchContext.Provider>
  );
};

export default CounterContext;

我们再提取出对应的读取 context 的 hooks。

export const useCountDispatch = () => {
  const context = useContext(CounterDispatchContext);
  if (context === undefined) {
    throw new Error('useCountDispatch must be in CounterDispatchContext.Provider');
  }
  return context;
};

export const useCount = () => {
  const context = useContext(CounterContext);
  if (context === undefined) {
    throw new Error('useCount must be in CounterContext.Provider');
  }
  return context;
};

然后我们现在有两个组件:

  • CounterDisplay: 负责展示。
  • CounterAction: 负责更新。

为什么需要读写分离?

进行读写分离有两个好处:

  1. 逻辑与状态分离。
  2. Context 更新的时候所有订阅的子组件会 rerender, 减少不必要的 rerender

首先来说说逻辑与状态分离: 一个展示的组件只需要 count 就好了, 我不需要读取多余的 setCount 更新函数, 这对我来说是没有意义的, 而对于更新操作来说, 我可以通过setCount(v => v + 1)的方式来读取之前的值, 不需要再访问count

更重要的其实是后面一点: Context 的更新方法setCount往往不会改变, 而更新方法setCount控制的状态count却会改变。 如果你创建一个同时提供状态值和更新方法时, 那么所有订阅了该上下文的组件都会进行 rerender, 即使它们只是访问操作(可能还没有执行更新)。

所以我们可以避免这种情况, 将更新操作和状态值拆成两个上下文, 这样子依赖于更新操作的组件CounterAction不会因为状态值count的变化而进行没有必要的渲染

我们来看看代码:

// CounterDisplay.tsx
const CounterDisplay = () => {
  const count = useCount();
  return <h1>{count}</h1>;
};

// CounterAction.tsx
const CounterAction = () => {
  const setCount = useCountDispatch();

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);

  // 只会执行一遍
  useEffect(() => {
    console.log('CounterAction render');
  });

  return (
    <div>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
    </div>
  );
};

Context rerender 的解决方案还有其他的,但是我自己个人是更加喜欢这种, 具体可以看看这个 issue,也可以看看这个知乎大佬的回答

createCtx

现在看看上面的代码, 一个 Context 进行读写分离拆成两个 Context 就这么多模版代码了, 显然这不符合我们的初衷, 那么我们可以使用一个函数createCtx创建 Context。

function createCtx<T>(initialValue: T) {
  const storeContext = createContext<T | undefined>(undefined);
  const dispatchContext = createContext<Dispatch<SetStateAction<T>> | undefined>(undefined);

  const useStore = () => {
    const context = useContext(storeContext);
    if (context === undefined) {
      throw new Error('useStore');
    }
    return context;
  };

  const useDispatch = () => {
    const context = useContext(dispatchContext);
    if (context === undefined) {
      throw new Error('useDispatch');
    }
    return context;
  };

  const ContextProvider = ({ children }: PropsWithChildren<{}>) => {
    const [state, dispatch] = useState(initialValue);

    return (
      <dispatchContext.Provider value={dispatch}>
        <storeContext.Provider value={state}>{children}</storeContext.Provider>
      </dispatchContext.Provider>
    );
  };

  return [ContextProvider, useStore, useDispatch] as const;
}
export default createCtx;

然后如何使用呢?

import createCtx from './createCtx';

const [CountProvider, useCount, useCountDispatch] = createCtx(0);

export { CountProvider, useCount, useCountDispatch };

其他地方一行也不要改就能实现!!!

多上下文

在真实项目中, 我们不止一个 Context, 那么怎么组合呢? 有了上面这个工具函数就很简单了。

// theme.ts
const [ThemeProvider, useTheme, useThemeDispatch] = createCtx({ theme: 'dark' });
// ui.ts
const [UIProvider, useUI, useUIDispatch] = createCtx({ layout: '' });

// app.tsx
const App = ({ children }) => {
  return (
    <ThemeProvider>
      <UIProvider>{children}</UIProvider>
    </ThemeProvider>
  );
};

美滋滋有木有!!!

action hooks

你以为到这里就结束了吗, nonono, 还有呢!

我们看到CounterAction组件。

const setCount = useCountDispatch();

const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);

这是不是很眼熟, 我们来把它封装成一个 action hook。

const useIncrement = () => {
  const setCount = useCountDispatch();
  return () => setCount(c => c + 1);
};

const useDecrement = () => {
  const setCount = useCountDispatch();
  return () => setCount(c => c - 1);
};

然后CounterAction组件就变成了:

const CounterAction = () => {
  const increment = useIncrement();
  const decrement = useDecrement();

  return (
    <div>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
    </div>
  );
};

是不是很有意思, 假设我们还需要请求后端, 我们可以写一个 async action hook。

const useAsyncIncrement = () => {
  // 复用上面的hook
  const increment = useIncrement();

  return () =>
    new Promise(resolve =>
      setTimeout(() => {
        increment();
        resolve(true);
      }, 1000),
    );
};
const CounterAction = () => {
  // ...
  const asyncIncrement = useAsyncIncrement();

  return (
    <div>
      {/* ... */}
      <button onClick={asyncIncrement}> async + 1 </button>
    </div>
  );
};

createReducerCtx

使用 Context + useReducer 我个人觉得更加适合我上面所说的: 我想知道我做了什么, 而不是我如何做。

dispatch({ type: 'increment' });

setCount(v => v + 1);

这两个我更喜欢第一种, 表达力更强。

当 Context value "复杂"时, 不妨试试使用 useReducer 来进行管理, 我们可以改造一下createCtx来实现创建一个createReducerCtx

function createReducerCtx<StateType, ActionType>(
  reducer: Reducer<StateType, ActionType>,
  initialValue: StateType
) {
  const stateContext = createContext<StateType | undefined>(undefined);
  const dispatchContext = createContext<Dispatch<ActionType> | undefined>(undefined);

  const useStore = () => {
    // ...
  };
  const useDispatch = () => {
    // ...
  };

  const ContextProvider = ({ children }: PropsWithChildren<{}>) => {
    const [store, dispatch] = useReducer(reducer, initialValue);

    return (
      // ...
    );
  };

  return [ContextProvider, useStore, useDispatch] as const;
}

总体上和之前的差不多, 只是有一点点不同, 我们看看怎么使用。

type CounterActionTypes = { type: 'increment' } | { type: 'decrement' };

function reducer(state = 0, action: CounterActionTypes) {
  switch (action.type) {
    case 'increment':
      return state + 1;
    case 'decrement':
      return state - 1;
    default:
      return state;
  }
}

const [ReducerCounterProvider, useReducerCount, useReducerCountDispatch] = createReducerCtx(
  reducer,
  0,
);

export const useIncrement = () => {
  const setCount = useReducerCountDispatch();
  return () => setCount({ type: 'increment' });
};

export const useDecrement = () => {
  const setCount = useReducerCountDispatch();
  return () => setCount({ type: 'decrement' });
};

export const useAsyncIncrement = () => {
  const increment = useIncrement();

  return () =>
    new Promise(resolve =>
      setTimeout(() => {
        increment();
        resolve(true);
      }, 1000),
    );
};

完美!!!

性能

当 Context value 进行更新的时候, 所有依赖于 Context value 的组件都会进行 rerender , 所以有可能会出现性能的问题。当我们的组件因为 Context value 出现了性能问题的时候, 我们得看看有多少组件因为这个改变需要重新渲染, 然后判断这个 Context value 改变的时候, 它们是否需要真的被重新渲染。

就像上面的CounterAction是可以避免 rerender 的, 我们通过避免读取count这个 Context value 来达到目的, 而更新函数其实一直都是不变的, 这样子即使 Context value 改变也与我无关.

但是如果我们的组件渲染不会涉及到 Dom 更新以及请求接口等副作用时, 即使这些组件可能在进行无意义的渲染, 在 React 当中是很常见的, 这是由 React 的协调算法决定的。它本身通常不是问题(这也是我们所说的不要过早地进行优化), 所以我们可以允许 rerender , 但是应该尽量避免不必要的 rerender。

如果真的出现了问题, 不妨试试下面的方法:

  1. 拆分, 将复合状态放到多个 Context 中, 这样子只是一个 Context value 的改变只会影响到依赖的那部分组件, 而不是全部组件。
  2. 优化 ContextProvider, 很多时候我们不必将 ContextProvider 放到全局, 它可能是一个局部的状态, 尽可能地把 Context value 和需要它的地方放的近一些。
  3. 不应该通过 Context 来解决所有的状态共享问题,在大型项目中使用 Redux 或者其他库会让状态管理更加得心应手。

总结

虽然说的是 Context + hooks 如何使用, 但是本质上是在说如何用 hooks 一点点的抽离出公用的逻辑, 我们可能还在使用 Class 的思维方式去写 hooks, 只是用 hooks 来减少一些样式代码, 但是 hooks 远比我们想象中要强大的多, 希望这篇文章对你有所帮助!!!

源代码在这里。

参考文章


五块木头
12 声望1 粉丝