React惯用的受控输入(useCallback、props和scope)



我正在构建一个很好的旧的读取-获取建议查找栏,当我发现我的输入在每个按键上都失去了焦点。

我了解到,因为我的输入组件是在封装它的头组件中定义的,对状态变量的更改触发了父组件的重新呈现,而父组件又重新定义了输入组件,这导致了这种行为。使用useCallback来避免这种情况。

现在我这样做了,状态值仍然是一个空字符串,即使它的回调被调用(正如我在console.log中看到的按键)

通过将状态和状态设置器作为props传递给输入组件来解决这个问题。但是我不太明白为什么。我猜useCallback中包含的状态和setter会"断开连接">

我很高兴看到一个解释澄清这一点。为什么它是一种方式而不是另一种方式?当使用useCallback时,封闭作用域是如何处理的?

代码如下:

export const Header = () => {
const [theQuery, setTheQuery] = useState("");
const [results, setResults] = useState<ResultType>();
// Query
const runQuery = async () => {
const r = await fetch(`/something`);
if (r.ok) {
setResults(await r.json());
}
}
const debouncedQuery = debounce(runQuery, 500);
useEffect(() => {
if (theQuery.length > 3) {
debouncedQuery()
}
}, [theQuery]);

const SearchResults = ({ results }: { results: ResultType }) => (
<div id="results">{results.map(r => (
<>
<h4><a href={`/linkto/${r.id}`}>{r.title}</a></h4>
{r.matches.map(text => (
<p>{text}</p>
))}
</>
))}</div>
)
// HERE
// Why does this work when state and setter go as 
// props (commented out) but not when they're in scope?
const Lookup = useCallback((/* {theQuery, setTheQuery} : any */) => {
return (
<div id='lookup_area'>
<input id="theQuery" value={theQuery}
placeholder={'Search...'}
onChange={(e) => {
setTheQuery(e.target.value);
console.log(theQuery);
}}
type="text" />
</div>
)
}, [])
return (
<>
<header className={`${results ? 'has_results' : ''}`}>
<Lookup /* theQuery={theQuery} setTheQuery={setTheQuery} */ />
</header>
{results && <SearchResults results={results} />}
</>
)
}

将本质上是组件定义的内容放在另一个组件的呈现函数中通常不是一个好主意,因为您得到了您所表达的所有挑战。但我将回到这个问题并回答你最初的问题。

当您执行以下操作时:

const Lookup = useCallback((/* {theQuery, setTheQuery} : any */) => {
return (
<div id='lookup_area'>
<input id="theQuery" value={theQuery}
placeholder={'Search...'}
onChange={(e) => {
setTheQuery(e.target.value);
console.log(theQuery);
}}
type="text" />
</div>
)
}, [])

你基本上是说,当Header组件挂载时,存储一个返回Lookup内部JSX的函数,并将其放在组件的挂载上的缓存中,并且在随后的渲染中从不刷新它。对于后续呈现,react将从初始呈现中提取定义,而不是重新定义函数——这称为记忆。这意味着Lookup将在所有渲染之间保持参考稳定。

定义它只在挂载上的是深度数组[]。deps数组是一个列表,当在渲染之间改变时,将触发回调被重新定义,同时将从当前渲染中拉入其包含的组件的新范围。由于您没有从deps数组的父作用域中列出回调函数内部使用的所有内容,因此您实际上是在处理一个错误,如果使用适当的解除规则,该错误将被标记。这是一个bug,因为如果像theQuerysetTheQuery这样的内部使用的东西发生了变化,那么Lookup回调将不会刷新/被重新定义——它将使用存储在mount上的本地缓存中的回调,这反过来引用这些值的过时副本。这是一个非常常见的bug来源。

也就是说,既然你这样做了,Lookup保持稳定,不会刷新。正如您所说的,如果它确实刷新了,您将看到它被重新安装,并且它的隐式DOM状态(焦点)之类的东西丢失了。在react中,组件定义需要是引用稳定的。你的useCallback基本上是组件定义,但在另一个组件渲染,所以它是留给你来处理棘手的记忆业务,以"防止";它不会在每次渲染时被重新定义。我将很快回到如何正确地解决这个问题。

当您添加theQuery={theQuery} setTheQuery={setTheQuery}时,您正在通过将此数据传递给来自父作用域的回调来解决实际的根本问题,以便它不需要使用它从初始呈现中拥有的陈旧数据。

但是,您所做的实际上是在另一个组件中编写一个组件,这使得事情变得更少封装,从而导致了您所看到的问题。您只需要简单地将Lookup定义为它自己的组件。还有SearchResults.


const Lookup = ({theQuery, setTheQuery}: any)) => {
return (
<div id='lookup_area'>
<input id="theQuery" value={theQuery}
placeholder={'Search...'}
onChange={(e) => {
setTheQuery(e.target.value);
console.log(theQuery);
}}
type="text" />
</div>
)
}
const SearchResults = ({ results }: { results: ResultType }) => (
<div id="results">{results.map(r => (
<>
<h4><a href={`/linkto/${r.id}`}>{r.title}</a></h4>
{r.matches.map(text => (
<p>{text}</p>
))}
</>
))}</div>
)

export const Header = () => {
const [theQuery, setTheQuery] = useState("");
const [results, setResults] = useState<ResultType>();
// Query
const runQuery = async () => {
const r = await fetch(`/something`);
if (r.ok) {
setResults(await r.json());
}
}
const debouncedQuery = debounce(runQuery, 500);
useEffect(() => {
if (theQuery.length > 3) {
debouncedQuery()
}
}, [theQuery]);

return (
<>
<header className={`${results ? 'has_results' : ''}`}>
<Lookup theQuery={theQuery} setTheQuery={setTheQuery} />
</header>
{results && <SearchResults results={results} />}
</>
)
}

由于组件是在渲染之外定义的,因此它们根据定义定义一次,并且它们不能直接访问Header组件的作用域,除非您通过props将其传递给它。这是一个正确封装和参数化的组件,它消除了陷入作用域和记忆混乱的机会。

仅仅为了访问作用域而将组件封装在组件中是不可取的。这是一种错误的心态。您应该尽可能多地拆分组件。您总是可以在一个文件中包含多个组件。在React中,组件不仅是一个重用单元,也是封装逻辑的主要方式。应该在这种情况下使用。

最新更新