谨慎将处于同个useEffect dependences之中且有逻辑关联的state放在多个useEffect

1,148 阅读5分钟

前言

本文是个人在使用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的藏身之处!

CodeSandbox

当我们在”晴天“下切换活动时,一切正常地进行”汇报“,但是当我们从”晴天“切换到”雨天“时,发出了两次”汇报“!原因便是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]);

CodeSandbox

据此我们已经知道了错误的原因,接下来我们先探索解决方案,再对这种场景抽象一下,形成一个判断模式,谨防在更加复杂的代码设计中出现此类错误。

解决方案

以上我们知道错误原因可以说是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}
      >

CodeSandbox

这种方案的好处是简单明了,但是当业务逻辑复杂时(比如要修改很多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}
      >

CodeSandbox

场景抽象

知道解决方案后,我们想进一步抽象出一个比较明确的判断依据来辨识以上场景,谨防代码设计过程中失误,到实现时才发现问题。接下来我们拿代码实现1.0中出bug的代码进一步深究。

原来的代码中我们的本意是为了代码的可维护性和美观,把useEffect分别放在了ReportComWeatherSelect中,那么把这个封装“拆出来”,看看能不能进一步看出什么问题。

// 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的调用过程:

  1. weather改变,调用了第一个useEffect回调与第二个useEffect回调
  2. 第一个useEffect回调导致了activity的改变,再次调用了第二个useEffect回调,故有两次”汇报“

进一步究其原因:activity与weather在同一个useEffect的dependencies中,activity又通过useEffect逻辑依赖于weather,setActivity异步生效导致最终了两次”汇报“

那么根据这个原因进行抽象,则可以把同一个useEffect的dependencies归纳为一组,同组的值则需要谨慎在其他useEffect callback中有逻辑依赖关系。

据此,我们可以通过这种方式进行对场景的判断,在设计代码时可以用以上两种方案去避免隐藏的bug。

结语

因此,本文可以总结为一句话:谨慎将处于同个useEffect dependences之中且有逻辑关联的state放在多个useEffect

当然,可能某些业务有特殊的情况,所以采用谨慎一词。

本文是个人在写代码中的一些思考,若有不当之处,欢迎讨论~