🧑💻 Promptopia 开发笔记(2)——useState、useRef与异步逻辑
date
Jun 14, 2024
slug
Promptopia-dev-note-state-ref-and-async
status
Published
tags
React
Next.js
Website
JavaScript
summary
从一次 debug 经历出发,讲讲 JS 里的异步机制如何影响你的代码执行?
type
Post
前言
书接上回,我在实现了搜索功能后,打算进一步实现
tag
的点击功能,我的想法很简单,只需要在点击 tag
后将 searchText
这个变量设为 tag 的值,然后清除掉由 useEffect 设置的定时器,最后手动根据searchText
发个请求更新一下 feed 就完事了,没想到这个过程竟然让我遇到了异步这个大坑。不可预见和难以理解的异步行为
const [searchText, setSearchText] = useState(initialSearchText)
const [timeoutId, setTimeoutId] = useState(null)
// 根据搜索关键词获取 prompt
const fetchSearchPosts = async () => {
const response = await fetch('/api/prompt/search?searchText=' + searchText)
const data = await response.json()
setPosts(data)
}
// 防抖
useEffect(() => {
if (searchText.trim()) {
const newTimeout = setTimeout(() => {
console.log('fetch by useEffect, searchText now: ', searchText)
fetchSearchPosts()
}, 1000)
setTimeoutId(newTimeout)
console.log('set!', newTimeout)
return () => clearTimeout(newTimeout)
} else {
fetchPosts()
return () => clearTimeout(timeoutId)
}
}, [searchText])
const handleTagClick = tag => {
setSearchText(tag)
// 立即触发类型,清除定时器
setTimeout(() => {
console.log('clear by tag', timeoutId)
clearTimeout(timeoutId)
console.log('fetch by tag, searchText now: ', searchText)
fetchSearchPosts()
}, 0)
}
程序的执行入口是
handleTagClick
,所以我们先从这个部分看起,整段程序涉及两个setTimeout
,两个 state
变量,我们通过 console.log() 来搞清楚这些代码执行的顺序。setSearchText(tag)
触发useEffect
,首先执行useEffect
内的部分;
const newTimeout = setTimeout
成为下一个宏任务,暂时挂起;
setTimeoutId(newTimeout)
将timeoutId
更新为最新的计时器;
- 打印
‘set!’, 127
- 继续执行
handleTagClick
中剩余的代码
setTimeout(() => {
console.log('clear by tag', timeoutId)
clearTimeout(timeoutId)
console.log('fetch by tag, searchText now: ', searchText)
fetchSearchPosts()
}, 0)
打印
'clear by tag 120'
- 根据旧的
searchText
发送请求fetchSearchPosts()
- 等待 1s 的时间后,执行
const newTimeout = setTimeout(() => {
console.log('fetch by useEffect, searchText now: ', searchText)
fetchSearchPosts()
}, 1000)
内部的代码,根据最新的
searchText
发送请求我们可以发现,出现了两个没有预期的行为:
- 定时器清理失败
- 由于定时器清理失败,发送了两次请求,但由本不该发送的那次请求完成了功能(即
useEffect
内本来被清理的请求),而本来发挥作用的,在handleClick
内的请求却发送了错误的请求(searchText
更新前的内容,即点击 tag 前的文本框内容)
下面我们进行逐一分析
定时器清理失败
观察执行顺序我们可以看到:虽然
handleTagClick
中的setTimeout
更晚执行,但他获取到的却不是最新的timeoutId
!这是因为 JS 的闭包机制,使得handleTagClick
内的setTimeout
捕获了与它平级的作用域,也就是说即使它更晚执行,但获得的timeoutId
实际上跟紧跟在setSearchText(tag)
后获取的timeoutId
是一样的,这也就解释了为什么明明 set
在先,clear
在后,但 clear
的却不是该 clear
的那个计时器。state
变量的更新是异步的,获取到的值可能并不符合你的预期,如果你需要获取根据 state 变量的最新值进行操作,你永远应该使用 useEffect 来监听这个变量获得最新值。定位到了问题,我们该如何解决呢,其实在官方文档中,就已经推荐了使用
useRef
来存储timeoutId
,因为 state
的更新是异步的,但 useRef
的更新是同步的,你获取到的永远是最新的值!我们来看看 useRef 如何帮助我们突出闭包机制的重围:const [searchText, setSearchText] = useState(initialSearchText)
const timeoutId = useRef(null)
const handleTagClick = tag => {
setSearchText(tag)
// 需要等待searchText更新完毕,再清除副作用带来的定时器
setTimeout(() => {
console.log('clear by tag', timeoutId.current)
clearTimeout(timeoutId.current)
}, 0)
fetchSearchPosts() // 立即执行搜索
}
只在我们不关心侦听
searchText
的 useEffect
内发生了什么,我们只关心将 timeoutId
换用 useRef
存储后为什么就能解决问题?根据闭包机制,
handleTagClick>setTimeout
依旧捕获了跟setSearchText(tag)
平级的timeoutId
,这个变量依旧是不变的,但这个变量实际上指向了一个 ref
对象,我们通过其current
属性来访问它的值,因而在useEffect
内发生的改变,依旧作用到了这里的timeoutId
上,成功让我们获取到了最新的timeoutId
!useRef
永远是最新的!请求错误
在成功修复了定时器清理失效的问题后,我们也就只会发送一次请求了,也就是
handleTagClick
中的请求,但根据前文提到的闭包机制,这里fetchSearchPosts()
使用的依旧是旧的值,针对这个问题,我们可以简单的通过修改fetchSearchPosts()
来实现,我们让它不要再依赖外部的 searchText
来进行搜索,而是传入一个值,这里我们使用 tag 的值来进行搜索,修改后的代码如下:import { useState, useEffect, useRef } from 'react'
const useSearchPosts = (initialSearchText = '') => {
const [searchText, setSearchText] = useState(initialSearchText)
const [posts, setPosts] = useState([])
const timeoutId = useRef(null)
// 获取所有 prompt
const fetchPosts = async () => {
const response = await fetch('/api/prompt')
const data = await response.json()
setPosts(data)
}
// 根据搜索关键词获取 prompt
const fetchSearchPosts = async search => {
const response = await fetch('/api/prompt/search?searchText=' + search)
const data = await response.json()
setPosts(data)
}
useEffect(() => {
fetchPosts()
}, [])
// 防抖
useEffect(() => {
if (searchText.trim()) {
const newTimeout = setTimeout(() => {
fetchSearchPosts(searchText)
}, 1000)
timeoutId.current = newTimeout
console.log('set!', newTimeout)
return () => clearTimeout(newTimeout)
} else {
fetchPosts()
return () => clearTimeout(timeoutId.current)
}
}, [searchText])
const handleSearchChange = e => {
setSearchText(e.target.value)
}
const handleSubmit = e => {
e.preventDefault()
console.log('clear by submit', timeoutId.current)
clearTimeout(timeoutId.current)
fetchSearchPosts(searchText)
}
const handleTagClick = tag => {
setSearchText(tag)
// 需要等待searchText更新完毕,再清除副作用带来的定时器
setTimeout(() => {
console.log('clear by tag', timeoutId.current)
clearTimeout(timeoutId.current)
}, 0)
fetchSearchPosts(tag) // 立即执行搜索
}
return {
searchText,
posts,
fetchPosts,
handleSearchChange,
handleSubmit,
handleTagClick
}
}
export default useSearchPosts
原先的
fetchSearchPosts
完全依赖于 searchText
这个变量,不能根据传入参数动态决定请求参数,这样写并不合理;再加上我们点击 tag 后,我们设置searchText
完全是为了让搜索框能显示我们点击的内容,而发送请求就没必要冒着变量不是最新的风险用searchText
了,完全可以用 tag
来发送请求。