背景
后端👴提了一个 bug,看起来很普通,但是调试、定位 bug 的过程却很艰辛😭,因为我用的 React 18 的思维去套用在 React 16 就开始迭代的组件库,当写了很久函数式组件的我以为这个 bug 三下五除二就可以拿下时,看到组件库的组件用的是类组件写法时,我直接炸了
当“普通”的 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
actual
线上 bug 能修就修
一般人对于线上 bug 是不会第一时间去看是不是组件库的问题的,我第一个怀疑对象是
- state 是不是真的没更新,于是我看
React devtools
、打日志,发现我的sugInput
始终是和<Select />
内部的sugInput
保持同步 ❌ - 难道是数据太多了,React 异步渲染和 semi 组件库的某些奇怪化学反应导致没获取到准确的
sugInput
?随后开始放大招,使用 key 强制<Select.Option />
重新渲染,这次成功了 ✅
修了一个又来一个
美汁汁回归测试,准备跟后端👴说这个 bug 已经被我拿下了,然后又发现了一个 bug
我的筛选功能没了,数据是同步了,但是没筛选
解决方案 - 看下组件库
这下没招了,只能先看下组件库是咋弄的了,看到组件库是用的类组件写的,通过调试发现,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 都叫生命周期了,那么上面👆三个方法一定是从上到下执行的,简单打两条日志看看
- 初始化
- onClick
结果被现实狠狠打脸😭
实际上顺序如下
- constructor
- render
- 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>;
};
看下日志的输出
后半截基本是能够对上的,唯一的出入就是组件初始化的时候 useEffect 也执行了一次副作用,看了下文档这是符合预期的,如果有用过 ahooks 的朋友就能对应上了,componentDidUpdate 和 useUpdateEffect 更相似
因此对于 React 类组件的学习大概有了一个模型,生命周期如下
函数式组件和类组件的类比如下
怪不得建议用函数式组件,一下子少了一大截代码😂
如果结合事件循环来理解,类组件大致如下
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 截图实际的作用流程如下
- getOptionsFromGroup -> originalOption
- 输入 c
- filter -> originalOption.includes(c) -> filteredOptions
- updateOptions(filteredOptions)
- render -> filteredOptions
- key update
- componentDidUpdate
- getOptionsFromGroup -> originalOption
- updateOptions(originalOption)
- render -> originalOption
getOptionsFromGroup 通过 props.children 获取 options,但因为 React 具有 setState 合并的优化,因此在第 7 步中无法拿到 filteredOptions,这也就是为什么筛选功能失效了
setState(prev => prev + 1)
setState(prev => prev + 1)
=>
setState(prev => prev + 2)
而不添加 key 但是能够筛选的场景,bug 截图实际的作用流程如下
- getOptionsFromGroup
- 输入 c
- filter
- updateOptions(filteredOptions)
- onSearch
- render -> filteredOptions
因此对于 sugInput 的输入的更新是要晚于 filterOptions 的 render 的,也就是 filteredOptions 获取
解决方案终极版 - 乱改组件库
综上可以知道,要让 <Select.Option />
能够正确获取外部 state 有以下几种方案
- filter 后同步渲染,使得后续的 getOptionsFromGroup 能够获取到正确更新的 children,但是这个方案太卡了不合适业务 ❌
- 支持 optionsList 传入 render 自定义渲染(也就是改组件库)✅