乐趣区

关于前端:context-hooks-真香

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.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 远比咱们设想中要弱小的多,心愿这篇文章对你有所帮忙!!!

源代码在这里。

参考文章

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