为何需要状态管理
在 React 项目开发中,随着应用复杂度的提升,组件拆分不断增多,不同组件间 state 的共享变得越来越难以管理,主要体现在两点:
-
子组件要访问父组件 state 时,需要层层传递:
-
非父子关系组件间的 state 共享时,需要先将 state 提升至公共父级并设置更新函数,然后再层层传递:
上面的写法使得组件之间耦合非常强,一旦组件结构需要发生变化,则需要大幅修改传递逻辑,灵活性和可维护性都十分低。此外,由于将 state 提升到了”全局“,那么当 state 改变后,所有的子组件,包括一些以前并没用到这些 state 的组件也要跟着 re-render,若子组件数量很多,则性能会大大降低。
基本的解决思路
一直都想要克服万物的人们,岂能被自己发明的东西所绊住。解决问题最好的方式就是解决它,于是不同的状态管理方案出炉了,每种方案都汇聚了他们独特的思想。目前最为熟知的两种方案便是 Redux 和 Mobx 。通过使用状态管理库,可以将需要共享的 state 提取出来放到全局,然后不同组件按需获取即可:
此时的我们在驯服 React 项目的道路上前进了一大步,但每当处在一个新的高度,总会碰到这个高度所带来的“缺氧”问题,虽然这些状态管理库很好的解决了最初的问题,但他们的学习成本和接入成本总会让人犹豫和止步,若总抱着“学就完事了”的态度,身心会越来越累,返璞归真才是最终极的奥义。
使用 Context API
React Context API 随后诞生了,此时我们不需引入第三方框架也可以较好的实现状态共享。比如现在要实现全局共享 name 和 age 两个 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 中,除了类似 useState 的 useRecoilState,还提供了 useRecoilValue 和 useSetRecoil ,三者的返回值分别如下:
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 的不断演进,有朝一日必能成为”家喻户晓“的主流状态管理库。