前言
本文是个人在使用React hook中地经验总结之一,也是对useEffect使用的思考之一。本文总结起来正如标题(确实有点长):
谨慎将处于同个useEffect dependences之中且有逻辑关联的state放在多个useEffect。
state也是指useState返回的state。接下来根据场景说明。
场景
假设有以下场景:
小明的爸爸妈妈出差两天,小明独自在家,需要及时向爸爸妈妈汇报天气及自己的活动,以防爸爸妈妈担心。 现在天气有两种:“晴天”和“雨天”;活动有三种:“踢球”、“写作业”和“郊游” 每当有一种变动,小明就需要向爸爸妈妈汇报。但是当天气变更为“雨天”时,就不能去“踢球”和“郊游”,只能“写作业“。
如此有两个下拉框,当其中有任何一个变动时都会进行汇报(真实场景中即发出请求)。如果天气原本是”晴天“,变更为”雨天“的话,活动选项值也应该变为”写作业“。
代码实现1.0
设计我们的组件实现,为了使ReportCom尽可能简洁,我们把天气与活动下拉框作为单独的组件,ReportCom仅保持需要的state。
当weather与activity任意一个state变化时,通过useEffect进行”汇报“。
// ReportCom
const ReportCom: FC = function () {
const [weather, setWeather] = useState('sun');
const [activity, setActivity] = useState('football');
useEffect(() => {
console.log('report!');
}, [weather, activity]);
return (
<div style={{ display: 'flex', marginTop: 20 }}>
<WeatherSelect weather={weather} setWeather={setWeather} setActivity={setActivity} />
<ActivitySelect activity={activity} setActivity={setActivity} weather={weather} />
</div>
);
};
// WeatherSelect
const WeatherSelect: FC<any> = function ({ weather, setWeather, setActivity }) {
const weatherOptions = [
{ value: 'sun', text: '晴天' },
{ value: 'rain', text: '雨天' },
];
useEffect(() => {
if (weather === 'rain') {
setActivity('homework');
}
}, [weather]);
return (
<div style={{ margin: '0 20px 0 20px' }}>
<span>天气:</span>
<select
onChange={(e) => {
setWeather(e.target.value);
}}
value={weather}>
{weatherOptions.map((item, i) => (
<option value={item.value} key={i}>
{item.text}
</option>
))}
</select>
</div>
);
};
// ActivitySelect
const ActivitySelect: FC<any> = function ({ activity, setActivity, weather }) {
const activityOptions = [
{ value: 'travel', text: '郊游', disabled: weather === 'rain' },
{ value: 'football', text: '踢球', disabled: weather === 'rain' },
{ value: 'homework', text: '写作业', disabled: false },
];
return (
<div>
<span>活动:</span>
<select
onChange={(e) => {
setActivity(e.target.value);
}}
value={activity}>
{activityOptions.map((item, i) => (
<option value={item.value} disabled={item.disabled} key={i}>
{item.text}
</option>
))}
</select>
</div>
);
};
ReportCom掌管主要逻辑,activity与weather一变就发出”汇报“。WeatherSelect与ActivitySelect掌管下拉内容、disable及weather对activity的变更影响(变为”雨天“时,活动只能变为”写作业“)。
乍一看似乎很美好,主要逻辑放在了ReportCom,其他属性也尽可能地放在了子组件,WeatherSelect似乎利用useEffect掌控了weather对activity的影响,似乎呼啸而出。但是WeatherSelect中的useEffect正是bug的藏身之处!。
当我们在”晴天“下切换活动时,一切正常地进行”汇报“,但是当我们从”晴天“切换到”雨天“时,发出了两次”汇报“!原因便是useEffect中的setActivity改变的state并非马上生效的,同时会再次触发一次ReportCom的useEffect。
我们可以在ReportCom的useEffect中打印看看
// ReportCom.tsx
useEffect(() => {
console.log("activity: ", activity);
console.log("report!");
}, [weather, activity]);
// WeatherSelect.tsx
useEffect(() => {
console.log("WeatherSelect useEffect");
if (weather === "rain") {
setActivity("homework");
}
}, [weather]);
据此我们已经知道了错误的原因,接下来我们先探索解决方案,再对这种场景抽象一下,形成一个判断模式,谨防在更加复杂的代码设计中出现此类错误。
解决方案
以上我们知道错误原因可以说是state的改变没有在“同一批次”,再次触发useEffect造成的,目前想到两种解决方案。
方案1:activity的改变不依赖于useEffect
这是最简单的方案,既然useEffect中setActivity是不同步的,那么可以换种方式来改变activity,使其在“同一批次”。
const WeatherSelect: FC<any> = function ({ weather, setWeather, setActivity }) {
....
// useEffect(() => {
// console.log("WeatherSelect useEffect");
// if (weather === "rain") {
// setActivity("homework");
// }
// }, [weather]);
return (
<div style={{ margin: "0 20px 0 20px" }}>
<span>天气:</span>
<select
onChange={(e) => {
const newWeather = e.target.value;
setWeather(newWeather);
if (newWeather === "rain") {// 把setActivity放在这里,与weather的改变“同一批次”
setActivity("homework");
}
}}
value={weather}
>
这种方案的好处是简单明了,但是当业务逻辑复杂时(比如要修改很多state,比如很多逻辑相似需要很多复制粘贴),这种方案可能会让代码不那么美观,维护性较差。那么来看看下一个方案。
方案2:使用useReducer
可以把这部分的逻辑处理放在reducer中,当业务复杂时,也可保持相对较好的维护性。
// ReportCom.tsx
const initState = {
weather: "sun",
activity: "football"
};
function reducer(
state: { weather: string; activity: string },
action: { type: string; payload: string }
) {
switch (action.type) {
case "weatherChange":
return {
...state,
weather: action.payload,
activity: action.payload === "rain" ? "homework" : state.activity // 此处判断“雨天”变为写作业
};
case "activityChange":
return { ...state, activity: action.payload };
default:
throw new Error();
}
}
const ReportCom: FC = function () {
const [state, dispatch] = useReducer(reducer, initState);
useEffect(() => {
console.log("activity: ", state.activity);
console.log("report!");
}, [state]);
return (
<div style={{ display: "flex", marginTop: 20 }}>
<WeatherSelect weather={state.weather} dispatch={dispatch} />
<ActivitySelect
activity={state.activity}
weather={state.weather}
dispatch={dispatch}
/>
</div>
);
};
// WeatherSelect.tsx
const WeatherSelect: FC<any> = function ({ weather, dispatch }) {
...
return (
<div style={{ margin: "0 20px 0 20px" }}>
<span>天气:</span>
<select
onChange={(e) => {
dispatch({ type: "weatherChange", payload: e.target.value });
}}
value={weather}
>
场景抽象
知道解决方案后,我们想进一步抽象出一个比较明确的判断依据来辨识以上场景,谨防代码设计过程中失误,到实现时才发现问题。接下来我们拿代码实现1.0中出bug的代码进一步深究。
原来的代码中我们的本意是为了代码的可维护性和美观,把useEffect分别放在了ReportCom与WeatherSelect中,那么把这个封装“拆出来”,看看能不能进一步看出什么问题。
// ReportCom.tsx
const ReportCom: FC = function () {
const [weather, setWeather] = useState("sun");
const [activity, setActivity] = useState("football");
// 从WeatherSelect“解封”出来的useEffect
useEffect(() => {
if (weather === "rain") {
setActivity("homework");
}
}, [weather]);
useEffect(() => {
console.log("report!");
}, [weather, activity]);
return (
<div style={{ display: "flex", marginTop: 20 }}>
{/* 从WeatherSelect”解封“出来的代码 */}
<div style={{ margin: "0 20px 0 20px" }}>
<span>天气:</span>
<select
onChange={(e) => {
setWeather(e.target.value);
}}
value={weather}
>
{weatherOptions.map((item, i) => (
<option value={item.value} key={i}>
{item.text}
</option>
))}
</select>
</div>
<ActivitySelect
activity={activity}
setActivity={setActivity}
weather={weather}
/>
</div>
);
};
以上的写法可以说是等价于代码实现1.0的,我们可以很明显地发现有两个useEffect,同时他们的dependencies都有weather,接着可以更加清楚地了解到useEffect的调用过程:
- weather改变,调用了第一个useEffect回调与第二个useEffect回调
- 第一个useEffect回调导致了activity的改变,再次调用了第二个useEffect回调,故有两次”汇报“
进一步究其原因:activity与weather在同一个useEffect的dependencies中,activity又通过useEffect逻辑依赖于weather,setActivity异步生效导致最终了两次”汇报“。
那么根据这个原因进行抽象,则可以把同一个useEffect的dependencies归纳为一组,同组的值则需要谨慎在其他useEffect callback中有逻辑依赖关系。
据此,我们可以通过这种方式进行对场景的判断,在设计代码时可以用以上两种方案去避免隐藏的bug。
结语
因此,本文可以总结为一句话:谨慎将处于同个useEffect dependences之中且有逻辑关联的state放在多个useEffect。
当然,可能某些业务有特殊的情况,所以采用谨慎一词。
本文是个人在写代码中的一些思考,若有不当之处,欢迎讨论~