具有异步数据验证和竞争条件的 React JS 多输入

React JS multi-inputs with asynchronous data validation and race condition

我是 React 的新手,我想实现一个表单,在该表单中,用户会收到 N 个输入,并被要求 copy/paste 其中的一些内容(他也可以单击 + 或 - 按钮来添加或删除一些输入,但现在让我们保持简单。

想法是,对于每个输入,用户将 copy/paste 一些数据,并且在每个输入上附加一个 onChange 侦听器,以便每次更改输入时,它都会触发调用 API 或后端服务来验证数据。验证数据后,它将 return 用户是否正确(例如,通过更改输入的背景颜色)。这是一张图片:

问题是,假设用户 copies/pastes gorilla 在第一个输入中 并且异步调用需要 10s , 但随后 2s 在第一个输入中 copied/pasted 大猩猩之后,他在第二个输入中 copies/pastes 宇宙飞船(这个异步调用需要 4s比方说 return)。我当前版本发生的情况是,由于第二次调用在第一次调用之前完成,因此 isValid 为第二个输入更新,而不是为第一个输入更新。

这是因为我正在使用 useEffectclean 函数。我也尝试过使用 useState 但每次我 运行 进入竞争条件...

备注:

这是复制我当前行为的小样本:

import React, { useEffect, useState } from "react";

export default function MyComponent(props) {
  let delay = 10000; // ms
  const [data, setData] = useState(
    Array(5).fill({
      isValid: 0,
      url: null
    })
  );
  const [inputIdx, setInputIdx] = useState(-1);
  const updateData = (index, datum) => {
    const updatedData = data;
    updatedData[index] = datum;
    setData([...updatedData]);
    setInputIdx(index);
    delay /= 2;
  };
  useEffect(() => {
    let canceled = true;

    new Promise((resolve) => {
      setTimeout(() => resolve("done"), delay);
    }).then((e) => {
      if (canceled) {
        const updatedData = data;
        updatedData[inputIdx].isValid = true ^ updatedData[inputIdx].isValid;
        setData([...updatedData]);
      }
    });
    return () => {
      canceled = false;
    };
  }, [inputIdx]);
  const processData = data.map((datum, index) => {
    return (
      <input
        type="text"
        onChange={e =>
          updateData(index, {
            url: e.target.value === "" ? null : e.target.value,
            isValid: data[index].isValid,
          })
        }
        style={{display:"block"}}
      />
    );
  });
  return (
    <div>
      <div>{processData}</div>
      <div>{JSON.stringify({data})}</div>
    </div>
  );
}

这是一个CodeSandBox

如果您在第一个输入中输入 "a",然后在第二个输入中紧接着 "b" 并等待几个秒,你会看到 isValid 对于第二个输入变为 1 而不是对于第一个输入(这里我切换 isValid 所以如果它之前是 0 之后它将是 1,如果它是 1调用后将为 0)。 另外,在这里,为了模拟对后端的调用,我使用了 setTimeout,第一次调用为 10s,第二次调用为 5s,第三次调用为 5/2=2.5s,....

你知道我可以做些什么来解决我的问题吗?我想我应该改变架构本身,但由于我是 React 的新手并且没有发现任何类似的问题,我向你寻求帮助。

谢谢!

我认为您不需要 useEffect。您可以只听 paste event 的输入,运行 您的 API 呼叫,并做出相应的回应。我包括了一个最小的可验证示例。这是一个概念证明,但您可以更改实际应用程序中组件的边界和职责。 运行 代码并将每个单词粘贴到输入中以查看输出中反映的状态变化。

function App() {
  const [state, setState] = React.useState({
    "gorilla": "pending",
    "broccoli": "pending",
    "cage": "pending",
    "motorcycle": "pending"
  })
  const expect = expected => actual =>
    setState(s => ({...s, [expected]: expected == actual }))
  return <div>
    <p>gorilla spaceship cage bike</p>
    <ValidateInput valid={state.gorilla} validate={expect("gorilla")} />
    <ValidateInput valid={state.broccoli} validate={expect("broccoli")} />
    <ValidateInput valid={state.cage} validate={expect("cage")} />
    <ValidateInput valid={state.motorcycle} validate={expect("motorcycle")} />
    <pre>{JSON.stringify(state)}</pre>
  </div>
}

function ValidateInput({ valid, validate }) {
  function onPaste(event) {
    const pasted = event.clipboardData.getData("text/plain")
    someApiCall(pasted).then(validate).catch(console.error)
  }
  return <input className={String(valid)} onPaste={onPaste} />
}

function someApiCall(v) {
  return new Promise(r => setTimeout(r, Math.random() * 6000, v))
}

ReactDOM.render(<App/>, document.querySelector("#app"))
input { display: block; outline: 0; }
input.true { border-color: green; }
input.false { border-color: red; }
pre { padding: 0.5rem; background-color: #ffc; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.production.min.js"></script>
<div id="app"></div>

您应该跟踪 per-input 所有发出的请求。我建议 refactoring/abstracting 将异步请求视为正在更改的输入的“状态”。我处理 onChange 事件,发出异步请求,并在更新“状态”时从父级运行传递的 onChange 处理程序。

如果这些是网络请求,那么我建议在发出新请求时也使用取消令牌来取消任何 in-flight 请求。

示例:

const CustomInput = ({ delay = 10000, index, onChange, value }) => {
  const [internalValue, setInternalValue] = useState(value);

  const changeHandler = (e) => {
    setInternalValue((value) => ({
      ...value,
      url: e.target.value
    }));
  };

  useEffect(() => {
    onChange(index, internalValue);
  }, [index, internalValue, onChange]);

  useEffect(() => {
    let timerId = null;

    if (internalValue.url) {
      console.log("Start", internalValue.url);
      setInternalValue((value) => ({
        ...value,
        validating: true
      }));

      new Promise((resolve) => {
        timerId = setTimeout(resolve, delay, { isValid: Math.random() < 0.5 });
      })
        .then((response) => {
          console.log("Complete", internalValue.url);
          setInternalValue((value) => ({
            ...value,
            isValid: true ^ response.isValid
          }));
        })
        .finally(() => {
          setInternalValue((value) => ({
            ...value,
            validating: false
          }));
        });

      return () => {
        console.log("Cancel", internalValue.url);
        clearTimeout(timerId);
        setInternalValue((value) => ({
          ...value,
          validating: false
        }));
      };
    }
  }, [delay, internalValue.url]);

  return (
    <input
      type="text"
      onChange={changeHandler}
      style={{ display: "block" }}
    />
  );
};

家长:

function MyComponent(props) {
  const [data, setData] = useState(
    Array(5).fill({
      isValid: 0,
      url: null,
      validating: false
    })
  );

  const updateData = useCallback((index, datum) => {
    setData((data) => data.map((el, i) => (i === index ? datum : el)));
  }, []);

  return (
    <div>
      {data.map((datum, index) => (
        <div key={index}>
          <CustomInput index={index} onChange={updateData} value={datum} />
          <span>{JSON.stringify(datum)}</span>
        </div>
      ))}
    </div>
  );
}

这里 post 的作者,正如@Drew Reese 提到的,为了解决我的问题,我需要触发每个 per-input 的请求。我已经接受了那个作者的答案,因为它解决了我的问题,但这是我自己的解决方案以及更多的解释(请不要犹豫,在评论中纠正我,如果有任何问题,我会修改我的答案说错了,因为我 1 周前才开始 React。

首先,让我们解释一下标题:异步数据验证和竞争条件

简而言之,由于 JS 是一种 single-threaded 语言,我们不能真的有竞争条件。但是,使用异步函数我们可以得到看起来像竞争条件的东西,因为相同的数据可以通过 2 个不同的调用以异步方式更改。

基本上,在这里,我认为可以解释为竞争条件的是,如果用户在一个输入中键入一个字母(这将触发 异步调用 1a)然后很快在他在同一输入中键入另一个字母后(这将触发 异步调用 1b),在完成两个异步调用后我不想要的结果是 async call 1a 而不是 async call 1b(最新请求)。

为了避免这种情况,我们可以只处理per-input请求并使用一个标志(这里是canceled = True)来防止这种情况。为什么有效?因为 JS 是 single-threaded 所以在进行异步调用之前我们可以简单地设置一个标志变量。

然而,useEffect 会在每次渲染时触发(并且渲染,除其他外,每次我们改变由 useState 定义的变量时都会触发)。为避免为每个输入输入 useEffect 调用,我们可以指定一个参数(此处:inputUrl),以便只有与 ValidInput 元素相关联的 useEffect url已更改被触发。

另一个可能出现这种竞争条件问题的地方是在setData,因为setData在React中是异步的。为了防止我们需要将 lambda 运算符与 setData 一起使用,这确保了我们始终使用先前的状态作为输入(此处:updateInputs((previousInputs) => {...,其中 updateInputs 指向 setData函数由useState)

定义

如果我们把所有东西放在一起,我们就会有一些很好用的东西。

备注:

  • 在这里,我在代码中添加了if (allInputs.map((e) => e.url).every((e) => e === null)),只是为了模板版本避免在第一次渲染时为所有输入调用useEffect
  • 我本可以不使用 useEffect 但使用 useEffect 可以更容易地通过使用标志(这里称为 clean-up 函数)来避免这种竞争条件问题
  • 我添加了一些代码来添加输入和删除任何输入。
  • 我不是专业人士所以也许还有其他事情可以做得更好

代码:

import "./styles.css";
import React, { useEffect, useState } from "react";

export default function MyComponent(props) {
  const [data, setData] = useState(
    Array.from({ length: 5 }, () => {
      return { isValid: 0, url: null };
    })
  );
  const addEntry = () => {
    setData([
      ...data,
      {
        isValid: 0,
        url: null
      }
    ]);
  };
  const processData = data.map((datum, index) => {
    return (
      <ValidInput
        key={index}
        allInputs={data}
        updateInputs={setData}
        index={index}
      />
    );
  });
  return (
    <div>
      <div>{processData}</div>
      <div>
        <button onClick={addEntry}>ADD</button>
      </div>
      <div>{JSON.stringify({ data })}</div>
    </div>
  );
}

const ValidInput = ({ allInputs, updateInputs, index }) => {
  const inputUrl = allInputs[index].url;
  let delay = 10000;
  useEffect(() => {
    // Needed only for this template because the promise here
    // is mocked by a setTimeout that will be trigger on the first render
    if (allInputs.map((e) => e.url).every((e) => e === null)) {
      return;
    }
    let canceled = true;
    new Promise((resolve) => {
      delay /= 2;
      setTimeout(() => resolve("done"), delay);
    }).then((e) => {
      if (canceled) {
        updateInputs((previousInputs) => {
          const updatedInputs = [...previousInputs];
          updatedInputs[index] = {
            ...updatedInputs[index],
            isValid: updatedInputs[index].isValid ^ 1
          };
          return updatedInputs;
        });
      }
    });
    return () => {
      canceled = false;
    };
  }, [inputUrl]);
  const onChange = (e) => {
    updateInputs((previousInputs) => {
      const updatedInputs = [...previousInputs];
      updatedInputs[index] = {
        ...updatedInputs[index],
        url: e.target.value
      };
      return updatedInputs;
    });
  };
  const removeEntry = (index) => {
    updateInputs((previousInputs) => {
      return previousInputs.filter((_, idx) => idx !== index);
    });
  };
  return (
    <div key={index}>
      <input
        type="text"
        onChange={(e) => onChange(e)}
        value={inputUrl != null ? inputUrl : ""}
      />
      <button onClick={() => removeEntry(index)}>DEL</button>
    </div>
  );
};

here is the sandbox.