React状态管理:从Context API到Recoil

2,160 阅读6分钟

为何需要状态管理

在 React 项目开发中,随着应用复杂度的提升,组件拆分不断增多,不同组件间 state 的共享变得越来越难以管理,主要体现在两点:

  1. 子组件要访问父组件 state 时,需要层层传递:

  2. 非父子关系组件间的 state 共享时,需要先将 state 提升至公共父级并设置更新函数,然后再层层传递:

上面的写法使得组件之间耦合非常强,一旦组件结构需要发生变化,则需要大幅修改传递逻辑,灵活性和可维护性都十分低。此外,由于将 state 提升到了”全局“,那么当 state 改变后,所有的子组件,包括一些以前并没用到这些 state 的组件也要跟着 re-render,若子组件数量很多,则性能会大大降低。

基本的解决思路

一直都想要克服万物的人们,岂能被自己发明的东西所绊住。解决问题最好的方式就是解决它,于是不同的状态管理方案出炉了,每种方案都汇聚了他们独特的思想。目前最为熟知的两种方案便是 Redux 和 Mobx 。通过使用状态管理库,可以将需要共享的 state 提取出来放到全局,然后不同组件按需获取即可:

此时的我们在驯服 React 项目的道路上前进了一大步,但每当处在一个新的高度,总会碰到这个高度所带来的“缺氧”问题,虽然这些状态管理库很好的解决了最初的问题,但他们的学习成本和接入成本总会让人犹豫和止步,若总抱着“学就完事了”的态度,身心会越来越累,返璞归真才是最终极的奥义。

使用 Context API

React Context API 随后诞生了,此时我们不需引入第三方框架也可以较好的实现状态共享。比如现在要实现全局共享 nameage 两个 state,在不考虑任何优化的情况下可以写成这样:

未优化版的实现

第一步:Provider 组件的实现

import React, { useState, useContext, createContext } from 'react';
import ReactDOM from 'react-dom';

const ctx = createContext(null);
const Provider = (props) => {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);

  return (
    <ctx.Provider
      value={{
        name,
        age,
        setName,
        setAge,
      }}
    >
      {props.children}
    </ctx.Provider>
  );
};

第二部:子组件引用


const NameCmp = () => {
  const { name } = useContext(ctx);
  console.log('name component render');
  return <div>my name is {name}</div>;
};

const AgeCmp = () => {
  const { age } = useContext(ctx);
  console.log('age component render');
  return <div>my age is {age}</div>;
};

const ControlCmp = () => {
  console.log('control component render');
  const { setName, setAge } = useContext(ctx);

  const onChangeNameClick = () => {
    setName(`leo${Date.now()}`);
  };

  const onChangeAgeClick = () => {
    setAge((prev) => prev + 1);
  };

  return (
    <div>
      <button onClick={onChangeNameClick}>change name</button>
      <button onClick={onChangeAgeClick}>add age</button>
    </div>
  );
};

最后:App 实现

const App = () => {
  return (
    <Provider>
      <NameCmp />
      <AgeCmp />
      <ControlCmp />
    </Provider>
  );
};

ReactDOM.render(<App />, document.getElementById('root1'));

运行后,可看到仅仅修改 name 或者 age ,但三个组件却都发生了 re-render:

优化版的实现

由于没有第三方库的加持,我们此时需要手动进行优化来减少不必要的 re-render,修改后的实现如下:

在 Provider 的实现中,将原先的单个 context 拆分为了多个:

import React, { useState, useContext, createContext, useMemo, useCallback } from 'react';
import ReactDOM from 'react-dom';

const ctxNameState = createContext(null);
const ctxAgeState = createContext(null);
const ctxDispatch = createContext(null);
const Provider = (props) => {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);
  const memoDispatch = useMemo(() => {
    return {
      setName,
      setAge,
    };
  }, []);

  return (
    <ctxDispatch.Provider value={memoDispatch}>
      <ctxNameState.Provider value={name}>
        <ctxAgeState.Provider value={age}>{props.children}</ctxAgeState.Provider>
      </ctxNameState.Provider>
    </ctxDispatch.Provider>
  );
};

子组件在使用时,引入对应所需的 context :

const NameCmp = () => {
  console.log('name component render');
  const name = useContext(ctxNameState);
  return <div>my name is {name}</div>;
};

const AgeCmp = () => {
  console.log('age component render');
  const age = useContext(ctxAgeState);
  return <div>my age is {age}</div>;
};

const ControlCmp = () => {
  console.log('control component render');

  const { setName, setAge } = useContext(ctxDispatch);

  const onChangeNameClick = () => {
    setName(`leo${Date.now()}`);
  };

  const onChangeAgeClick = () => {
    setAge((prev) => prev + 1);
  };

  return (
    <div>
      <button onClick={onChangeNameClick}>change name</button>
      <button onClick={onChangeAgeClick}>add age</button>
    </div>
  );
};

运行后可以看到,修改 name 仅 name 组件变化,其他两个组件不会 re-render,修改 age 同理:

但上面的写法的一个明显弊端就是,为了尽可能减少不必要的 re-render ,我们需要为每个 state 单独创建一个 context 从而避免相互之间的影响,但随着全局 state 越来越多,我们的代码就会变成这个样子:

const ctx1 = createContext(null);
const ctx2 = createContext(null);
// ...
const ctxN = createContext(null);

const Provider = (props) => {
  const xx1 = useState(x);
  const xx2 = useState(x);
  // ...
  const xxN = useState(x);
  return (
    <ctx1.Provider value={xx1}>
      <ctx2.Provider value={xx2}>
        <ctx3.Provider value={xx3}>
          <ctxN.Provider value={xxN}>
            {props.children}
          </ctxN.Provider>
        </ctx3.Provider>
      </ctx2.Provider>
    </ctx1.Provider>
  );
};

即使你能忍受上面的写法,但随着 Provider 包裹的不断增加,如果 context 之间还有相互依赖时,比如 ctx9 里使用到了 ctx3 的 state,则必须确保 ctx3.Provider 是包裹在 ctx9.Provider 外的,因此每次新增一个全局 state,我们需要先去梳理一遍嵌套关系,否则会出现异常。如何扁平化 Provider 似乎难以处理。

使用 Recoil

Recoil 是 Facebook 开源的一款 React 状态管理库,如其 官网 所描述:为了保证兼容性以及简洁性,最好的方式是使用 React 内置的状态管理能力,而不是第三方全局状态管理,但 React 在这方面有有着很多的不足:(略,见官网描述),我们想要提升这一点,同时尽可能保证和 React 的风格更为一致。

Recoil 版实现

那么使用 Recoil 如何实现上述功能呢:

首先安装 Recoil:

npm i recoil

然后配置 Recoil:将 RecoilRoot 组件包裹在最外层即可,类似 Provider 的用法:

import React from 'react';
import ReactDOM from 'react-dom';
import { RecoilRoot, atom, useRecoilState } from 'recoil';

const App = () => {
  return (
    <RecoilRoot>
      <NameCmp />
      <AgeCmp />
      <ControlCmp />
    </RecoilRoot>
  );
};

定义哪些内容需要共享,在 Recoil 中这个概念被叫做 atom :


const nameAtom = atom({
  key: 'name',
  default: '',
});
const ageAtom = atom({
  key: 'age',
  default: 0,
});

ReactDOM.render(<App />, document.getElementById('root3'));

子组件通过 recoil 提供的 useRecoilState 来使用上面的 atom ,该 hook 的参数是 atom,返回值和 useState 类似:

const NameCmp = () => {
  console.log('name component render');
  const [name] = useRecoilState(nameAtom);
  return <div>my name is {name}</div>;
};

const AgeCmp = () => {
  console.log('age component render');
  const [age] = useRecoilState(ageAtom);
  return <div>my age is {age}</div>;
};

const ControlCmp = () => {
  console.log('control component render');
  const [, setName] = useRecoilState(nameAtom);
  const [, setAge] = useRecoilState(ageAtom);

  const onChangeNameClick = () => {
    setName(`leo${Date.now()}`);
  };

  const onChangeAgeClick = () => {
    setAge((prev) => prev + 1);
  };

  return (
    <div>
      <button onClick={onChangeNameClick}>change name</button>
      <button onClick={onChangeAgeClick}>add age</button>
    </div>
  );
};

从上述例子可看出,Recoil 的接入和使用都是非常容易的,且非常的 React-Style 。

什么是 selector

Recoil 中还有一个重要的概念是 selector,它和 atom 的关系类似 selector = f(atomA, atomB, ...),每当 selector 依赖的 atom 的值发生变都会自动触发更新:

import { atom, selector } from 'recoil';

const ageAtom = atom({
  key: 'age',
  default: 0,
});

export const ageLabelSelector = selector({
	key: 'ageLabelSelector',
	get({get}) {
		// 该selector依赖ageAtom,每当ageAtom值发生变化,该selector会自动更新
		const ageState = get(ageAtom);
		return `${ageState}岁`
	}
})

通过使用 selector ,可以尽可能保证 atom 的原始性。

封装自定义 Hook

对于熟悉 Redux 的同学会问到,如何进行类似 action 的操作呢?答案是封装自定义 hook 即可,以一个 todo-app 举例:

第一步:定义全局 state 用来存放所有的 todo items

const todoListAtom = atom({
  key: "todoList",
  default: [],
});

第二部:封装 hook


function useTodo() {
  const [list, setList] = useRecoilState(todoListAtom);

  const dispatch = ({ type, payload }) => {
    switch (type) {
      case 'ADD':
        setList((prev) => {
          return prev.concat({ id: Date.now().toString(), content: payload.content });
        });
      case 'DELETE':
        setList((prev) => prev.filter((l) => l.id !== payload.id));
    }
  };
  return {
    dispatch,
    list,
  };
}

最后一步:使用

const TODO = () => {
  const { dispatch, list } = useTodo();
  const [input, setInput] = useState('');

  const onInputChange = (ev) => {
    setInput(ev.target.value);
  };

  return (
    <>
      <input type="text" value={input} onChange={onInputChange} />
      <button
        onClick={() => {
          dispatch({
            type: 'ADD',
            payload: {
              content: input,
            },
          });
        }}
      >
        添加
      </button>

      {list.map((i) => {
        return (
          <div key={i.id}>
            <span>{i.content}</span>
            <button
              onClick={() => {
                dispatch({
                  type: 'DELETE',
                  payload: {
                    id: i.id,
                  },
                });
              }}
            >
              删除
            </button>
          </div>
        );
      })}
    </>
  );
};

const App = () => {
  return (
    <RecoilRoot>
      <TODO />
    </RecoilRoot>
  );
};

ReactDOM.render(<App />, document.getElementById('root'));

三个相似的 API / Hooks

Recoil 提供的 API 中,除了类似 useStateuseRecoilState,还提供了 useRecoilValueuseSetRecoil ,三者的返回值分别如下:

const [name, setName] = useRecoilState(nameAtom)
const name = useRecoilValue(nameAtom)
const setName = useSetRecoil(nameAtom)

为何要提供后面两种 hooks 呢?在前面的优化版的 react context api 的 demo 中可以看到,set函数 和 state 都单独使用了 context,如果 set函数和 state 是放在一起的,那么每当 state 更新后,其他用到了set函数但没有使用 state 的组件也会随之更新,造成了不必要的 re-render,因此 recoil 提供了这两个 hook 来进行”解耦“,在合适的地方使用合适的 hook 来降低 re-render 从而提升页面性能。

Recoil 小结

Recoil 除了上述的基本能力之外,还支持 异步 atom/seletor、suspense、atom effect 等功能,让状态管理变得十分好用和有趣。目前 Recoil 仍是 experimental 阶段,一些 API 仍是不稳定的,但并不涉及主要功能,完全可以先在一些中小型项目内进行实践,此外 atom 调试工具官方还在开发之中,目前还没有像 Redux 的 devtools 那样的可视化工具来追踪调试 state 变化,不过官方提供了相关 API 可以自行进行 简单的实现

总之我们平时如何使用 React 的 hooks,就如何使用 Recoil ,唯一的区别就是前者 state 存活于当前组件里,后者则"永久存活"在了组件外的任何一个地方。随着 Recoil 的不断演进,有朝一日必能成为”家喻户晓“的主流状态管理库。

参考