SPARROW ZOO · 前端组件

useCaptcha 验证码 Hook源码解析

拆解前端验证码自定义 Hook 的「两阶段加载」设计、会话与图片的先后时序, 以及缓存击穿、事件监听累积等值得注意的隐患。

目录 CONTENTS
  1. 结论先行:这个 Hook 做了什么
  2. 问题背景:验证码为什么不能直接设 src
  3. 详细内容:源码逐段拆解
  4. 时序关系:会话与图片谁先谁后
  5. 隐患与改进:五个值得注意的点
  6. 总结归纳

01结论先行:这个 Hook 做了什么

useCaptcha 是一个 React 自定义 Hook,负责管理验证码图片的 完整生命周期:初始化会话 → 拉取图片 → 显示图片 → 点击刷新。 它返回一个 ref,把它绑定到 <img> 元素即可,其余逻辑全部由 Hook 内部完成。

🧭一句话结论

核心设计是「两阶段加载」:先用 session=true 请求初始化服务端会话(携带 Cookie), 等会话就绪后再给 <img> 设置 src 加载图片,避免 「图片先于会话就绪」导致的验证码错乱。

02问题背景:验证码为什么不能直接设 src

验证码服务通常是有状态的:服务端要先为「当前会话」生成一个验证码答案并保存起来 (通常绑定在 Session / Cookie 上),之后用户提交答案时才能对得上。 如果前端直接给 <img> 写死图片地址,会踩到两个坑:

因此需要一个封装,把「先建会话、再取图片、加载完成后才显示、点击可换一张」这一整套步骤串起来, useCaptcha 就是干这件事的。

03详细内容:源码逐段拆解

3.1 完整源码

import { useEffect, useRef } from "react";
import { CAPTCHA_URL } from "@/common/lib/Env";
import Fetcher from "@/common/lib/Fetcher";
import Result from "@/common/lib/protocol/Result";

function loadCaptcha(captcha: HTMLImageElement) {
  const url = `${CAPTCHA_URL}?session=true&t=${Math.random()}`;
  Fetcher.get({
    url: url,
    withCookie: true
  }).then((res: Result) => {
    if (!res.data) {
      setTimeout(() => {
        loadCaptcha(captcha);
      }, 200);
      return;
    }
    captcha.src = `${CAPTCHA_URL}?t=${Math.random()}`;
    console.log("Captcha loading ...");
    captcha.addEventListener("load", () => {
      console.log("Captcha loaded");
      captcha.style.visibility = "visible";
    });
  });
}

export default function useCaptcha() {
  const captchaRef = useRef<HTMLImageElement>(null);
  useEffect(() => {
    const captcha = captchaRef.current;
    if (captcha) {
      captcha.addEventListener("click", () => {
        captcha.src = `${CAPTCHA_URL}?${Math.random()}`;
      });
      loadCaptcha(captcha);
    }
  }, []);
  return captchaRef;
}

3.2 三个依赖

依赖来源作用
CAPTCHA_URL @/common/lib/Env 验证码接口地址(环境配置),所有验证码请求都基于它拼接
Fetcher @/common/lib/Fetcher 项目的 HTTP 封装;get({ url, withCookie }) 发起 GET,withCookie: true 表示携带 Cookie
Result @/common/lib/protocol/Result 通用响应协议类型;res.data 是业务数据字段,这里用来判断「会话是否就绪」

3.3 loadCaptcha:两阶段加载

这是整个 Hook 的核心。它把「拿验证码」拆成两个阶段:

阶段一 · 会话预热:请求 ${CAPTCHA_URL}?session=true&t=${Math.random()} 并携带 Cookie。 其中 session=true 通知后端初始化验证码会话,t=${Math.random()} 是 缓存击穿参数(让每次请求 URL 都不同,绕开浏览器/网关缓存)。请求回来后检查 res.data:

阶段二 · 加载图片:给 captcha.src 赋上 ${CAPTCHA_URL}?t=${Math.random()},触发浏览器真正去拉验证码图片;同时监听图片的 load 事件,图片就绪后把 visibility 从 hidden 改为 visible。

⚠️为什么图片要先隐藏

<img> 一开始是 visibility: hidden,等 load 事件触发才显示。 这样加载过程中不会露出空白/裂图,用户看到的永远是「已经加载好的验证码」。

3.4 useCaptcha:生命周期与点击刷新

Hook 本体负责把 ref 和加载逻辑挂到组件生命周期上:

调用方用法大致如下:

function LoginForm() {
  const captchaRef = useCaptcha();
  return (
    <img
      ref={captchaRef}
      style={{ visibility: "hidden" }}
      alt="验证码"
    />
  );
}

04时序关系:会话与图片谁先谁后

把上面两步串起来,完整流程如下:

首次挂载
  └─ useEffect(仅一次)
       ├─ 绑定 click 监听
       └─ loadCaptcha(captcha)
            ├─ GET ?session=true&t=…   (携带 Cookie)
            │    ├─ res.data 为空 → 200ms 后重试 ↺
            │    └─ res.data 非空 ↓
            └─ 设置 img.src = ?t=…
                 └─ img load 事件 → visibility = visible

点击图片
  └─ click 监听 → img.src = ?随机数   (跳过会话预热,直接刷新图片)

两条路径的对比:

时机是否先建会话URL 形态说明
首次加载 是(session=true) ?session=true&t=随机 先预热会话,就绪后再取图
点击刷新 否 ?随机数 会话已存在,直接换一张新图

05隐患与改进:五个值得注意的点

#隐患现象 / 风险改进建议
1 load 监听器累积 每次加载成功都会 addEventListener("load"),多次刷新后监听器越绑越多;且监听器在设置 src 之后才注册,存在「缓存同步加载先于监听」的竞态 先注册监听再设 src;或用覆盖式赋值 captcha.onload = ... 避免累积
2 useEffect 无清理 组件卸载时 click / load 监听器不会移除;React StrictMode 下 effect 执行两次会绑两个 click 返回 cleanup 函数,removeEventListener 解绑
3 无限轮询无上限 res.data 持续为空(接口异常 / 配置错误)时,会每 200ms 无限重试 加最大重试次数或退避策略,超限后报错
4 缓存参数格式不一致 会话请求用 t=${random},点击刷新用 ?${random}(无参数名),功能上都击穿缓存但不统一 统一为 t=${Math.random()}
5 可见性只进不退 图片 visible 后,点击刷新时不再隐藏,加载新图期间旧图残留,失败也无降级提示 刷新前先 hidden,加载完成再 visible
⛔最需要优先处理

第 2 点(无清理)与第 3 点(无限轮询)风险最高: 前者在 StrictMode / 频繁挂载下会导致重复请求和重复监听,后者在服务端异常时会造成持续的无效轮询。

06总结归纳

useCaptcha 用很小的代码量完成了验证码的完整生命周期管理,设计上的可取之处在于:

同时也暴露了几个工程化层面可以改进的点,集中在资源清理、重试上限、监听器管理三处。

✅一句话记住

有状态的验证码 = 先会话、后图片;把「建会话 → 取图 → 显示 → 刷新」串成一个 Hook, 再补上清理函数和重试上限,就是一个健壮的验证码封装。