具有异步数据验证和竞争条件的 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
为第二个输入更新,而不是为第一个输入更新。
这是因为我正在使用 useEffect
和 clean 函数。我也尝试过使用 useState
但每次我 运行 进入竞争条件...
备注:
- 如果用户在第一个输入中输入了大猩猩,在他修改相同的输入并输入长颈鹿后的 1 秒。如果第一个异步调用还没有完成,我宁愿杀死它,只检索对同一输入进行的最新调用的验证(这就是为什么我在
useEffect
)[=61= 中使用干净函数的原因]
- 即使停止验证其他输入的问题得到解决,我认为在我的
useEffect
中总会存在竞争条件,我正在修改与我的每个输入关联的 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>
);
};
我是 React 的新手,我想实现一个表单,在该表单中,用户会收到 N 个输入,并被要求 copy/paste 其中的一些内容(他也可以单击 + 或 - 按钮来添加或删除一些输入,但现在让我们保持简单。
想法是,对于每个输入,用户将 copy/paste 一些数据,并且在每个输入上附加一个 onChange
侦听器,以便每次更改输入时,它都会触发调用 API 或后端服务来验证数据。验证数据后,它将 return 用户是否正确(例如,通过更改输入的背景颜色)。这是一张图片:
问题是,假设用户 copies/pastes gorilla 在第一个输入中 并且异步调用需要 10s , 但随后 2s 在第一个输入中 copied/pasted 大猩猩之后,他在第二个输入中 copies/pastes 宇宙飞船(这个异步调用需要 4s比方说 return)。我当前版本发生的情况是,由于第二次调用在第一次调用之前完成,因此 isValid
为第二个输入更新,而不是为第一个输入更新。
这是因为我正在使用 useEffect
和 clean 函数。我也尝试过使用 useState
但每次我 运行 进入竞争条件...
备注:
- 如果用户在第一个输入中输入了大猩猩,在他修改相同的输入并输入长颈鹿后的 1 秒。如果第一个异步调用还没有完成,我宁愿杀死它,只检索对同一输入进行的最新调用的验证(这就是为什么我在
useEffect
)[=61= 中使用干净函数的原因] - 即使停止验证其他输入的问题得到解决,我认为在我的
useEffect
中总会存在竞争条件,我正在修改与我的每个输入关联的 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>
);
};