记录一个 React 表单的小坑:缓存节流导致页面刷新

写了个登录页面,需求很简单,防止用户快速重复点击提交按钮。

很自然的想到了用 throttle 节流,配合 React 的 useMemo 缓存一下。代码写完测试了一下,第一次点正常,第二次点页面直接刷新了。

排查了一下,发现是 useMemo 和 throttle 共同作用的结果。

错误代码

jsx 复制代码
import { useCallback, useMemo } from 'react'
import { throttle } from 'lodash'

export default function Login() {
  const [loading, setLoading] = useState(false)
  
  const handleSubmit = useCallback(async (e) => {
    e.preventDefault()
    if (loading) return
    setLoading(true)
    // 登录请求...
    await login()
    setLoading(false)
  }, [])
  
  // 缓存节流函数
  const throttledSubmit = useMemo(
    () => throttle(handleSubmit, 2000, { leading: true }),
    [handleSubmit]
  )
  
  return (
    <form onSubmit={throttledSubmit}>
      <button type="submit">登录</button>
    </form>
  )
}

看起来没问题,但实际第一次点击正常发起请求,2秒内第二次点击页面直接刷新了。

原因分析

throttle 工作原理

throttle 内部维护了一个 timer 变量,记录当前是否处于冷却期:

javascript 复制代码
function throttle(fn, delay) {
  let timer = null
  return function(...args) {
    if (timer) return  // 冷却期内直接返回
    timer = setTimeout(() => {
      timer = null
    }, delay)
    fn.apply(this, args)
  }
}

冷却期内被拦截的调用会直接 return,不会执行原函数。

useMemo 导致实例唯一

jsx 复制代码
const throttledSubmit = useMemo(
  () => throttle(handleSubmit, 2000),
  [handleSubmit]
)

useMemo 保证 throttledSubmit 在整个组件生命周期中只有一个实例,timer 变量只初始化一次,节流状态在整个组件中共享,不会因为组件重渲染而重置。

两次点击的执行流程

第一次点击:

scss 复制代码
用户点击 → 表单 onSubmit → throttledSubmit(e) 执行
→ timer === null,通过检查
→ 执行 handleSubmit(e)
→ e.preventDefault() 执行
→ 设置 timer,开始 2 秒冷却
→ 页面不刷新

第二次点击(2秒内):

kotlin 复制代码
用户点击 → 表单 onSubmit → throttledSubmit(e) 执行
→ timer !== null,直接 return
→ handleSubmit 根本没进去
→ e.preventDefault() 没有执行
→ 浏览器执行表单默认行为
→ 页面刷新

throttle 不仅拦截了业务逻辑,也拦截了 e.preventDefault()。被拦截的调用中,没有任何代码能够阻止表单的默认提交行为。

解决方案

在 throttle 外部调用 preventDefault:

jsx 复制代码
<form onSubmit={e => {
  e.preventDefault()
  throttledSubmit(e)
}}>

这样无论 throttle 是否拦截,preventDefault 都会先执行。

或者不用 throttle,用状态锁:

jsx 复制代码
const [loading, setLoading] = useState(false)

const handleSubmit = async (e) => {
  e.preventDefault()
  if (loading) return
  setLoading(true)
  try {
    await login()
  } finally {
    setLoading(false)
  }
}
相关推荐
codeGoogle15 分钟前
自研 IM 还是选择第三方 SDK?企业开发者应该如何权衡?
前端·后端·程序员
zzzzzz3101 小时前
从 react-bits 看动效组件化:别把视觉效果写成一次性页面代码
javascript·react.js·开源
用户938515635071 小时前
React Context 与自定义 Hook 从底层到实践:「跨层级通信 + 副作用封装」全解析
前端·javascript·react.js
滴滴答答哒2 小时前
VUE3+element-plus MultiSelect 多选下拉组件
前端·javascript·vue.js
其美杰布-富贵-李2 小时前
04 watch 与 Vue 响应式数据流
前端·javascript·vue.js
赵大仁4 小时前
生成式 UI 实战:用 JSON Schema + React 动态渲染 AI 界面
前端·ai·react·next.js·前端架构·生成式ui
kyriewen5 小时前
我排查了一个React内存泄漏——罪魁祸首是这3个被忽略的清理函数
前端·javascript·面试
IT_陈寒5 小时前
我又被JavaScript的隐式类型转换坑了
前端·人工智能·后端
其美杰布-富贵-李5 小时前
03 ref、reactive 与 computed 响应式数据
前端·javascript·vue.js