context + hooks = 真香

文章首发于集体博客

前言

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.tsxconst CounterDisplay = () => {  const count = useCount();  return <h1>{count}</h1>;};// CounterAction.tsxconst 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.tsconst [ThemeProvider, useTheme, useThemeDispatch] = createCtx({ theme: 'dark' });// ui.tsconst [UIProvider, useUI, useUIDispatch] = createCtx({ layout: '' });// app.tsxconst 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 远比咱们设想中要弱小的多, 心愿这篇文章对你有所帮忙!!!

源代码在这里。

参考文章

  • react 新版官网文档
  • 魔术师卡颂-useContext 更佳实际
  • Context 读写拆散的益处