我在开发过程中遇到一个非常奇怪的问题,事情是这样的。
我有一个表单,像下面这样:
当 q 为 Closed 的时候会展示下面的样子,也就是会多出 a 和 s,需求是这样的,正常填写表单,只提交展现的字段到后台,所以隐藏的如果之前有数据,那么消失的时候就需要将值清空。长下面这样:
其中这个 Select 组件使用数据结构如下:
[
{ title: 'Not Identified', value: '1' },
{ title: 'Closed', value: '2' },
{ title: 'Communicated', value: '3' },
{ title: 'Identified', value: '4' },
{ title: 'Resolved', value: '5' },
{ title: 'Cancelled', value: '6' },
]
由于是 demo ,所以这几个都是使用的这个数据结构。当将 q 切换成 Closed 的时候,正常显示 a 和 s ,但是当我将 q 切换成其他的时候就出现切换不过去。看看下面的动画:
根据上面的需求,我的实现是让组件自身去清除自己(从出现有值到消失没值),而且我想让组件统一的使用 onValueChange 函数,所以我在组件卸载之前将值清空。先见组件 Select :
import { useCallback, useEffect } from 'react';
import { Select as AntdSelect } from 'antd';
const { Option } = AntdSelect;
const Select = ({ onValueChange, command, value, label, data }) => {
useEffect(
() => () => {
// 卸载之前清空之前的值
onValueChange?.(command, null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const handleChange = useCallback(
(e) => {
onValueChange?.(command, e);
},
[onValueChange, command]
);
return (
<div style={{ flexDirection: 'row', display: 'flex' }}>
<h2 style={{ marginRight: 20 }}>{label}</h2>
<AntdSelect
showSearch
style={{ width: 200 }}
placeholder="Search to Select"
optionFilterProp="children"
onChange={handleChange}
value={value}
>
{data.map((item) => {
return <Option key={item.value} value={item.value}>{item.title}</Option>;
})}
</AntdSelect>
</div>
);
};
export default Select;
这样我甚至都不需要在外面处理当某个属性消失另外做处理的逻辑,非常好,其实这里我采用的是分治的思想;就相当于领导新建了一个表格,让手下的人各自把工作进度填写进去一样,一种做法就是领导全程控制,比如领导明天开会,让大家说工作进度,然后领导填写到表格中;而我的这种思想就是让手下的人自己填写工作进度到表格,这样领导就不用关心这些,要想了解进度不需要开会,只需要看进度的表格就了解目前处于什么状况。
首先我得数据是使用 Context 统一管理的,可以看 Context 代码:
import React, { useCallback, useContext, useMemo, useReducer } from 'react';
export const COUNT_ADD = Symbol();
export const RANDOM = Symbol();
export const CHNAGE_PARAMS_A = Symbol();
export const CHNAGE_PARAMS_B = Symbol();
export const CHNAGE_PARAMS_C = Symbol();
const initial = {
count: 0,
randoms: 0,
params: {
a: {},
b: {},
c: {},
},
};
let currentState = null;
const reducer = (state = initial, action) => {
switch (action.type) {
case COUNT_ADD:
state.count++;
break;
case RANDOM:
state.randoms = Math.random() * 1000;
break;
case CHNAGE_PARAMS_A:
state.params = {
...state.params,
a: action.payload,
};
break;
case CHNAGE_PARAMS_B:
state.params = {
...state.params,
b: action.payload,
};
break;
case CHNAGE_PARAMS_C:
state.params = {
...state.params,
c: action.payload,
};
break;
default:
}
const nextState = { ...state };
currentState = nextState
return nextState;
};
export function getState() {
return currentState;
}
const Context = React.createContext({});
export const Provider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initial);
return (
<Context.Provider value={{ state, dispatch }}>{children}</Context.Provider>
);
};
export const useSelector = (extractor) => {
const { state } = useContext(Context);
if (typeof extractor === 'function') {
return extractor(state);
} else {
return state;
}
};
export const useDispatch = () => {
const { dispatch } = useContext(Context);
return dispatch;
};
export const useParams = (key) => {
const params = useSelector((state) => state.params[key]);
const dispatch = useDispatch();
const type = useMemo(() => {
switch (key) {
case 'a':
return CHNAGE_PARAMS_A;
case 'b':
return CHNAGE_PARAMS_B;
case 'c':
return CHNAGE_PARAMS_C;
default:
}
}, [key]);
const setParams = useCallback(
(handle) => {
dispatch({
type,
payload: handle(params),
});
},
[dispatch, params, type]
);
return [
params, setParams];
};
由于好多组件都需要处理 params 的情况,于是我就新建了 useParams 来进行统一处理,然后外面的使用就跟 useState 一样,只不过我只写了传入回调的情况。其实根本的原因是我改代码的时候由于之前已经按 useState 来写了,如果不这样很多地方都得修改,我比较偷懒,所以想到使用这种方式来进行,这样只需要将原来 useState 的地方替换成 useParams 即可,非常的简单方便,改动非常的小。
现在我们来看看使用的父组件:
import { useCallback } from 'react';
import { useParams } from './context';
import Select from './Select';
const data = [
{ title: 'Not Identified', value: '1' },
{ title: 'Closed', value: '2' },
{ title: 'Communicated', value: '3' },
{ title: 'Identified', value: '4' },
{ title: 'Resolved', value: '5' },
{ title: 'Cancelled', value: '6' },
];
const Home = () => {
const [params, setParams] = useParams('a');
const onValueChange = useCallback(
(command, value) => {
setParams((oldParams) => {
const temp = { ...oldParams };
temp[command] = value;
return temp;
});
},
[setParams]
);
return (
<div
style={{
width: '100%',
height: '100vh',
flexDirection: 'column',
display: 'flex',
alignItems: 'center',
}}
>
<Select
onValueChange={onValueChange}
command={'q'}
label={'q'}
value={params['q']}
data={data}
/>
<Select
onValueChange={onValueChange}
command={'w'}
label={'w'}
value={params['w']}
data={data}
/>
<Select
onValueChange={onValueChange}
command={'e'}
label={'e'}
value={params['e']}
data={data}
/>
{params['q'] === '2' && (
<>
<Select
onValueChange={onValueChange}
command={'a'}
label={'a'}
value={params['a']}
data={data}
/>
<Select
onValueChange={onValueChange}
command={'s'}
label={'s'}
value={params['s']}
data={data}
/>
</>
)}
</div>
);
};
export default Home;
出现上面的问题,我打印日志,也就是打印每次渲染之前的 oldParams 的值,我发现卸载组件调用这个函数的时候 oldParams 的值是之前的值,所以我判定是由于子组件没有拿到最新的 onValueChange 函数,所以我尝试修改 Select 组件的卸载方法:
useEffect(
() => () => {
onValueChange?.(command, null);
},
[command, onValueChange]
);
发现修改成这样根据就是不行的,因为当 command 和 onValueChange 改动都会调用 onValueChange?.(command, null) 是不合理的,我暂时没想到好的方法,于是将 Select 改成 Component ,也就是使用类的方式来实现,结果还是不对;但是我还是坚定是因为方法本身没更新到最新导致的,为了验证我的想法,我想不更新 onValueChange 可能是因为没有更新 setParams ,于是我尝试保存起来看看:
我发现这里当不涉及到组件消失的时候一直都是返回 true ,但是当涉及到组件销毁的时候就会是 false ,于是我就重点看什么 Select 的代码,我并没有看出什么,因为我的写法本身很简单,似乎并没有问题,但是当我仔细分析代码的时候,也就是当值发生变化的时候,也就是从显示到消失,由于 params 的值改变了,组件渲染的逻辑是先卸载子组件,再渲染父组件,也就是消失的组件在调用卸载方法前不会先渲染的,因为父组件决定了子组件直接消失,消失的逻辑并不在子组件,由于 params 改变 => setParams 改变 => onValueChange 改变;由于本次渲染不会再渲染消失的组件,故而拿不到最新的 onValueChange 的值,那么执行卸载函数拿到的 onValueChange 就是之前的,故而取的 params 也是之前的。
现在知道了原因,也就是在组件卸载之前其他 props 会保持上一次的(仅 Hooks ),导致取到的数据也是之前的;那么我们应该怎样解决这个问题呢,其实很简单,只需要保证每一次卸载之前拿到的 onValueChange 函数都是最新的,由于渲染逻辑,所以不可能通过现有方式解决,要考虑的是让 onValueChange 不变,也就是一定固定一直都不会改变,这样就能避免上面的问题,可是以我现在的实现怎样保证呢,我们知道 setParams 改变的原因是因为 params ,也就是我在里面使用了这个,导致它变了,也就跟着,主要的是 setParams 和 params 还是强关联的关系。所以我们需要改变的是让 setParams 不依赖 params ,其实我们这个地方使用的目的就在于拿到最新的 params 数据,所以我们不通过里面的 params 也是可以拿到的,如果你是使用 redux ,那么可以使用使用 getStore 方法来拿到最新的 params 数据,我这里由于没有这个,所以我手动写一个就可以解决:
let currentState = null;
export function getState() {
return currentState;
}
其中 currentState 每一次更新都将最新的值保存在起来:
const reducer = (state = initial, action) => {
// ...其他逻辑
const nextState = { ...state };
currentState = nextState
return nextState;
};
这样就完美解决了这个问题,当然除了这种方法解决,还有其他的,如果你看到了,你也可以使用其他方式来解决,只不过我更倾向于这种,其他的我觉得只是解决问题了,而我这种实现,能在一定程度上避免错误,因为一旦我选择这样, useParams 的作用就完全跟 useState ,所以外面使用的时候考虑 useState 的事情就可以,而不需要考虑 useParams 本身的实现。