React 的类组件的 "Hook" 和 state 的流转顺序是怎样的?

125 阅读5分钟

背景

后端👴提了一个 bug,看起来很普通,但是调试、定位 bug 的过程却很艰辛😭,因为我用的 React 18 的思维去套用在 React 16 就开始迭代的组件库,当写了很久函数式组件的我以为这个 bug 三下五除二就可以拿下时,看到组件库的组件用的是类组件写法时,我直接炸了

Pasted image 20250126155626.png

当“普通”的 bug遇上 React 18 加上类组件规范的组件库

PS: 以下示例可以在 codeSandbox 中找到源码

bug

进入正题,bug 描述如下

有一个 <Select />,编写方式用的嵌套组件模式(GPT 说的~),<Select.Option /> 的部分数据依赖外部 state,并且该部分的渲染不能够通过组件库的 props 传入 render 自定义渲染,必须以 JSX 的方式编写,然后发现搜的时候发现 <Select.Option /> 并没有和外部的 state 保持同步

代码如下

import { useMemo, useState } from "react";

const Demo = () => {
  const [sugInput, setSugInput] = useState('');

  return  (
      <Select
          filter={(sugInput, option) => {
              const groupLabel =
                option._parentGroup && option._parentGroup.label;
              const matchOption = option.value.includes(sugInput);
              const matchGroup =
                isString(groupLabel) &&
                groupLabel.toLowerCase().includes(sugInput);

              if (matchOption || matchGroup) {
                return true;
              }

            return false;
          }}
          style={{ width: 200 }}
          onSearch={setSugInput}
      >
          <Select.OptGroup label="Asia">
              <Select.Option value="abc">
                  <span>{`抖音 - ${sugInput}`}</span>
              </Select.Option>
              <Select.Option value="hotsoon"><span>{`火山 - ${sugInput}`}</span></Select.Option>
              <Select.Option value="jianying" disabled>
                  剪映
              </Select.Option>
              <Select.Option value="xigua"><span>{`西瓜视频 - ${sugInput}`}</span></Select.Option>
          </Select.OptGroup>
          <Select.OptGroup label="South America">
              <Select.Option value="c-1"><span>{`Peru - ${sugInput}`}</span></Select.Option>
          </Select.OptGroup>
      </Select>
  );
};

expect

Pasted image 20250126162420.png

actual

Pasted image 20250126162455.png

线上 bug 能修就修

一般人对于线上 bug 是不会第一时间去看是不是组件库的问题的,我第一个怀疑对象是

  1. state 是不是真的没更新,于是我看 React devtools、打日志,发现我的 sugInput 始终是和 <Select /> 内部的 sugInput 保持同步 ❌
  2. 难道是数据太多了,React 异步渲染和 semi 组件库的某些奇怪化学反应导致没获取到准确的 sugInput?随后开始放大招,使用 key 强制 <Select.Option /> 重新渲染,这次成功了 ✅

修了一个又来一个

美汁汁回归测试,准备跟后端👴说这个 bug 已经被我拿下了,然后又发现了一个 bug

Pasted image 20250126163428.png

我的筛选功能没了,数据是同步了,但是没筛选

Pasted image 20250126163500.png

解决方案 - 看下组件库

这下没招了,只能先看下组件库是咋弄的了,看到组件库是用的类组件写的,通过调试发现,key 能够成功是因为 <Select />在 React 的生命周期 componentDidUpdate 中去对 state 进行了判断,如果我的 <Select.OptGroup /> 的 key 发生了变化,整个选项列表都会重新渲染

那么 componentDidUpdate 是什么呢?简单 GPT 了一下大概等于 useEffect,本着大问题拆解成小问题的原则,把相关逻辑拆开了一下,用类组件描述大概是这样

import React from "react";

class ClassCom extends React.Component {
  constructor(props) {
    super(props);
    this.state = { num: 0 };
  }
  componentDidUpdate(prevProps, prevState) {
    console.log("@@@ componentDidUpdate num", this.state.num);
    this.state.num === 1 &&
      this.setState((state) => {
        return {
          num: state.num + 1,
        };
      });
  }
  render() {
    console.log("@@@ render num", this.state.num);
    return (
      <button onClick={() => this.setState({ num: this.state.num + 1 })}>
        + {this.state.num}
      </button>
    );
  }
}

export default ClassCom;

这个结构很容易带入主观臆断,即然 componentDidUpdate 都叫生命周期了,那么上面👆三个方法一定是从上到下执行的,简单打两条日志看看

  1. 初始化
  2. onClick

结果被现实狠狠打脸😭

Pasted image 20250126165438.png

实际上顺序如下

  1. constructor
  2. render
  3. componentDidUpdate

如果用 React 18 的 useEffect Hook 的视角来看,它就是符合预期的,useEffect 为异步响应,就像 Promise,比如 Promise.resolve(() => render()).then(() => useEffect()),如果换成函数式组件的写法大概是这样

import { useEffect, useState } from "react";

export const FunctionalCom = () => {
  const [num, setNum] = useState(0);

  useEffect(() => {
    console.log("@@@ useEffect", num);
    num === 1 && setNum((prev) => prev + 1);
  }, [num]);

  console.log("@@@ render", num);

  return <button onClick={() => setNum((prev) => prev + 1)}>+ {num}</button>;
};

看下日志的输出

Pasted image 20250126170021.png

后半截基本是能够对上的,唯一的出入就是组件初始化的时候 useEffect 也执行了一次副作用,看了下文档这是符合预期的,如果有用过 ahooks 的朋友就能对应上了,componentDidUpdate 和 useUpdateEffect 更相似

因此对于 React 类组件的学习大概有了一个模型,生命周期如下

Pasted image 20250126170437.png

函数式组件和类组件的类比如下

Pasted image 20250126170602.png

怪不得建议用函数式组件,一下子少了一大截代码😂

如果结合事件循环来理解,类组件大致如下

Pasted image 20250126171045.png

bug 真正的原因

上面👆分析了一大堆,其实和 bug 没有太多关系,真正的原因是 filter 的执行时间要早于 key 的执行时间,并且 options 在这个过程中都没有发生改变,这也就是为什么上面要列出基于事件循环模型的原因

semi 组件库里获取 options 的原理是通过 props.children 获取,如下

const getOptionsFromGroup = (selectChildren: React.ReactNode) => {
    let optionGroups: OptionGroupProps[] = [];
    let options: OptionProps[] = [];

    const emptyGroup: {
        label: string;
        children: OptionProps[];
        _show: boolean
    } = { label: '', children: [], _show: false };

    // avoid null
    // eslint-disable-next-line max-len
    let childNodes = React.Children.toArray(selectChildren) as React.ReactElement[];
    childNodes = childNodes.filter((childNode) => childNode && childNode.props);    

    let type = '';
    let optionIndex = -1;

    childNodes.forEach((child: React.ReactElement<any, any>) => {
      ...
    });
    if (type === 'option') {
        optionGroups = [emptyGroup] as OptionGroupProps[];
    }
    return { optionGroups, options };
};

bug 截图实际的作用流程如下

  1. getOptionsFromGroup -> originalOption
  2. 输入 c
  3. filter -> originalOption.includes(c) -> filteredOptions
  4. updateOptions(filteredOptions)
  5. render -> filteredOptions
  6. key update
  7. componentDidUpdate
  8. getOptionsFromGroup -> originalOption
  9. updateOptions(originalOption)
  10. render -> originalOption

getOptionsFromGroup 通过 props.children 获取 options,但因为 React 具有 setState 合并的优化,因此在第 7 步中无法拿到 filteredOptions,这也就是为什么筛选功能失效了

setState(prev => prev + 1)
setState(prev => prev + 1)
=>
setState(prev => prev + 2)

而不添加 key 但是能够筛选的场景,bug 截图实际的作用流程如下

  1. getOptionsFromGroup
  2. 输入 c
  3. filter
  4. updateOptions(filteredOptions)
  5. onSearch
  6. render -> filteredOptions

因此对于 sugInput 的输入的更新是要晚于 filterOptions 的 render 的,也就是 filteredOptions 获取

解决方案终极版 - 乱改组件库

综上可以知道,要让 <Select.Option /> 能够正确获取外部 state 有以下几种方案

  1. filter 后同步渲染,使得后续的 getOptionsFromGroup 能够获取到正确更新的 children,但是这个方案太卡了不合适业务 ❌
  2. 支持 optionsList 传入 render 自定义渲染(也就是改组件库)✅