🧑‍💻 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() 来搞清楚这些代码执行的顺序。
  1. setSearchText(tag) 触发useEffect ,首先执行useEffect 内的部分;
  1. const newTimeout = setTimeout 成为下一个宏任务,暂时挂起;
  1. setTimeoutId(newTimeout)timeoutId 更新为最新的计时器;
  1. 打印 ‘set!’, 127
  1. 继续执行handleTagClick 中剩余的代码
    1. setTimeout(() => {
        console.log('clear by tag', timeoutId)
        clearTimeout(timeoutId)
        console.log('fetch by tag, searchText now: ', searchText)
        fetchSearchPosts()
      }, 0)
      打印'clear by tag 120'
  1. 根据旧的 searchText 发送请求fetchSearchPosts()
  1. 等待 1s 的时间后,执行
    1. const newTimeout = setTimeout(() => {
        console.log('fetch by useEffect, searchText now: ', searchText)
        fetchSearchPosts()
      }, 1000)
      内部的代码,根据最新的searchText 发送请求
我们可以发现,出现了两个没有预期的行为:
  1. 定时器清理失败
  1. 由于定时器清理失败,发送了两次请求,但由本不该发送的那次请求完成了功能(即 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() // 立即执行搜索
}
只在我们不关心侦听searchTextuseEffect 内发生了什么,我们只关心将 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 来发送请求。

© Musher 2019 - 2024

powered by nobelium