查看原文
其他

【第1866期】React Hooks的体系设计之二 - 状态粒度

张立理 前端早读课 2020-03-02

前言

体系设计系列来了。今日早读文章由@张立理授权分享。

正文从这开始~~

【第1859期】React Hooks的体系设计之一 - 分层

继续读基于hook的状态管理,毕竟状态无论什么时候都是react的重中之重。

在有了useState这东西之后,我们会发现状态被天生地“拆散”了,比如曾经有一个类组件:

  1. class TodoList extends Component {

  2. state = {

  3. dataSource: [],

  4. isLoading: true,

  5. filterText: '',

  6. filterType: 'all',

  7. };

  8. }

放到hook上,大概率就是这样子的了:

  1. const TodoList = () => {

  2. const [dataSource, setDataSource] = useState([]);

  3. const [isLoading, setLoading] = useState(true);

  4. const [filterText, filterByText] = useState('');

  5. const [filterType, filterByType] = useState('all');

  6. };

老实说这算好的了,至少还搞了发起名的艺术,没有啥都叫setFooBar。

上面的这个转换方式无疑是正确的,不过现实并不总这么友好,状态拆分的时候,容易出现粒度控制不好的情况。

粒度过细

如果按照标准的每一个状态对应一个useState的做法,自然是逻辑上正确的,但它容易造成状态粒度过细的问题。

讲一个故事:

做一个表格,带一个选中功能,其中一个点是“按住SHIFT的同时点击一行可以选中一个区域”。

为了实现这个功能,我们需要2套逻辑:

  • 当点击一行时,选中这一行。

  • 按SHIFT点击时,把上一次选中行(或第一行)到当前行都选中。

从这个场景我们能分析出一个结论:点击一行的时候,除了选中它,还需要记录最后一次选中的行。为了简化这个模型,代码中我先不管“取消选择”的效果:

  1. const SelectableList = () => {

  2. const [selection, setSelection] = useState([]);

  3. const [lastSelected, setLastSelected] = useState(0);

  4. const selectLine = useCallback(

  5. index => {

  6. setSelection(lines => lines.concat(index));

  7. setLastSelected(index);

  8. },

  9. []

  10. );

  11. };

仔细看useCallback中的部分,我们能看到它会连续调用2个状态的更新,这会造成什么情况呢?每一次状态更新都触发一次渲染,会导致多次渲染的浪费吗?

答案是并不会,虽然早期的版本存在这问题,但现在的react已经可以很好地次连续的状态更新合并到单次的渲染中。为此我创建了一个CodeSandbox示例来说明。

所以我们今天要谈的不是一个性能问题,而是一个代码的组织和可读性问题。

在class时代,我们会看到这样的代码:

  1. class SelectableList extends Component {

  2. selectLine = index => {

  3. this.setState(state => ({

  4. selection: state.selection.concat(index),

  5. lastSelected: index

  6. }));

  7. };

  8. }

我们并不会把这代码写成:

  1. class SelectableList extends Component {

  2. selectLine = index => {

  3. this.setState(state => ({selection: state.selection.concat(index)}));

  4. this.setState({lastSelected: index});

  5. };

  6. }

不否认class时代状态的集中管理是过于粗放的,但那个时代的状态更新粒度基本是没有问题的,所以在使用hook的时候千万不要太过暴力的拆分状态。过于细粒度的拆分状态会导致代码阅读者难以理解状态间的关系,无形提升代码维护的难度。

使用reducer管理状态更新

现在搞清楚了状态粒度太细是不好的,所以不妨将上面示例的状态重新再合并回来:

  1. const DEFAULT_SELECTION_STATE = {

  2. selection: [],

  3. lastSelected: 0,

  4. };


  5. const SelectableList = () => {

  6. const [selectionState, setSelectionState] = useState(DEFAULT_SELECTION_STATE);

  7. const selectLine = useCallback(

  8. index => {

  9. const updater = ({selection}) => {

  10. return {

  11. selection: selection.concat(index),

  12. lastSelected: index,

  13. };

  14. };

  15. setSelectionState(updater);

  16. },

  17. []

  18. );

  19. };

没什么难度,确实没什么难度。

但这种做法,依然会有一个问题:状态的更新与状态的声明距离过远。在这个示例中很难看出问题,因为总共也就20行,状态声明后立刻有useCallback的调用说明怎么更新它。但在现实中,我们很容易面对300+行的组件,面对状态的声明在第1行,状态的更新在第40行,甚至在最终JSX中的某个onClick里的一个箭头函数中。

在这样的代码里,阅读者想搞清楚一个状态被如何使用、如何更新是非常困难的,这不仅降低代码的可维护性,还给代码的学习者很大的挫败感,久而久之的影响就是谁也不愿意接手这代码。

解决这个问题通常有2种方法:

  • 把状态和更新封装到自定义的hook中,比如就叫useSelection。

  • 使用useReducer来实现。

第一种方法自不必说,能不能找到合适的粒度来实现自定义hook就是对react开发者的素质的考验。但不少时候自定义hook作为一种解决方案还是过于重量级,虽然它仅仅是一个函数,但依然会需要阅读者去理解输入输出,使用TypeScript还可能造成类型定义上的额外工作。

使用useReducer可以在不少轻量级的场景里快速地将状态声明和状态更新放在一起,比如上面的例子:

  1. const SelectableList = () => {

  2. const [selectionState, dispatchSelectionState] = useReducer(

  3. (state, action) => {

  4. switch (action.type) {

  5. case 'select':

  6. return {

  7. selection: state.selection.concat(action.payload),

  8. lastSelected: action.payload,

  9. };

  10. default:

  11. return state;

  12. }

  13. },

  14. {selection: [], lastSelected: 0}

  15. );

  16. };

可以看到的是,通过useReducer我们传递一个函数,这个函数清晰地表达了'select'这个类型的操作,以及对应的状态更新。useReducer的第二个参数也很好地说明了状态的结构。

当然如果使用本系列前一篇中讲述的useImmer或useMethods会更容易实现:

  1. const SelectableList = () => {

  2. const [selectionState, {select}] = useMethods(

  3. methods,

  4. {selection: [], lastSelected: 0}

  5. );

  6. };

状态过粗

反过来说,状态也可能太粗,比如我们硬是将整个class的状态移到一个useState里:

  1. const DEFAULT_STATE = {

  2. dataSource: [],

  3. isLoading: true,

  4. filterText: '',

  5. filterType: 'all',

  6. };


  7. const TodoList = () => {

  8. const [state, setState] = useState(DEFAULT_STATE);

  9. };

我个人是不太能想象谁会去写这样的代码的,也许背了一个“把class全部变成hook”的KPI的可怜孩子会玩这一套……

不过在此依然需要提一下状态过粗的代价,试想这样的组件:

  1. class UserInfo extends Component {

  2. state = {

  3. isBaseLoading: true,

  4. isDetailLoading: false,

  5. baseInfo: null,

  6. detailInfo: null,

  7. isDetailVisible: false,

  8. };


  9. async showDetail = () => {

  10. if (this.state.detailInfo) {

  11. this.setState({isDetailVisible: true});

  12. }

  13. else {

  14. this.setState({isDetailLoading: true});

  15. const detail = await fetchDetail();

  16. this.setState({

  17. isDetailLoading: false,

  18. detailInfo: detail,

  19. isDetailVisible: true,

  20. });

  21. }

  22. }

  23. }

然后我们还有一个这样的组件:

  1. class TodoList {

  2. state = {

  3. filterText: '',

  4. filterType: 'all',

  5. showFilterPanel: false,

  6. };


  7. toggleFilterPanel = () => {

  8. this.setStae(s => ({showFilterPanel: !s.showFilterPanel}));

  9. };

  10. }

这有什么问题呢?仔细去看2个组件,我们会发现它们其实是有共同的部分的:

  • 有一个能展开/收起的状态,一个叫isDetailVisible一个叫showFilterPanel。

  • 有多个和异步过程有关的状态,比如isBaseLoading和isDetailLoading。

  • 有异步状态与结果的成对出现,比如isBaseLoading配对baseInfo,isDetailLoading配对detailInfo。

但能得到这些结论,很大程度上归功于我给的代码过于精简,以及给了阅读者明确的“去发现”的目的。

试想你有一个超过10万行代码的项目,里面有800多个组件,有些组件有1200多行,你作为一个技术负责人空降到项目中,有信心去发现这些东西吗?反正我作为一个所谓的高T,很实诚地说我做不到。

所以状态粒度过粗的问题就在于,它会隐藏掉可以复用的状态,让人不知不觉通过“行云流水地重复编码”来实现功能,离复用和精简越来越远。

当然,有时候保持一定程度上的重复是有意义的,比如使代码更具语义化,让人更看得懂代码在干啥,这在class时代特别明显。在class明代能解决这一问题的办法就是HOC,比如我们做withLoading、withToggle、withRemoteData等等……

然后就会变成这样:

图片来源网络


好在hook能比较合理地去解决这种嵌套问题。

合理设计粒度

本章讲了2个主要的论述:状态粒度太细不好,粒度太粗也不好。

在实际的业务里,比这复杂的多的事情天天在发生,远不是太细了合一合、太粗了分一分这么简单,大部分时候我们面对的是这样的情况:


5个状态4个组合操作,怎么设计粒度更合理,就慢慢折腾去吧。

最后送一个本文中提到的行选中功能的完整实现:https://gist.github.com/otakustay/9b59153da2e124f0637732fef5c71c6a

关于本文 作者:@张立理 原文:https://zhuanlan.zhihu.com/p/108432109

@张立理曾分享过


【第1859期】React Hooks的体系设计之一 - 分层


为你推荐


【第1836期】Remax - 使用 React 开发小程序


【第1795期】SWR:最具潜力的 React Hooks 数据请求库

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存