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:
- 为空:会话还没准备好,
setTimeout等200ms后重试(轮询)。 - 非空:会话已就绪,进入阶段二。
阶段二 · 加载图片:给 captcha.src 赋上
${CAPTCHA_URL}?t=${Math.random()},触发浏览器真正去拉验证码图片;同时监听图片的
load 事件,图片就绪后把 visibility 从 hidden 改为 visible。
<img> 一开始是 visibility: hidden,等 load 事件触发才显示。
这样加载过程中不会露出空白/裂图,用户看到的永远是「已经加载好的验证码」。
3.4 useCaptcha:生命周期与点击刷新
Hook 本体负责把 ref 和加载逻辑挂到组件生命周期上:
useRef<HTMLImageElement>:持有<img>元素的引用。-
useEffect(..., []):空依赖数组,只在组件挂载时执行一次——绑定click事件(点击换一张),并调用loadCaptcha启动首次加载。 - 返回
captchaRef,供调用方ref={captchaRef}绑定。
调用方用法大致如下:
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 用很小的代码量完成了验证码的完整生命周期管理,设计上的可取之处在于:
- 两阶段加载——先建会话再取图,正确处理了「有状态验证码」的时序依赖。
- 缓存击穿——用随机参数保证每次请求 URL 唯一,避免拿到旧缓存。
- 可见性控制——
load后才显示,规避空白/裂图。 - 轮询重试——会话未就绪时
200ms自动重试。
同时也暴露了几个工程化层面可以改进的点,集中在资源清理、重试上限、监听器管理三处。
有状态的验证码 = 先会话、后图片;把「建会话 → 取图 → 显示 → 刷新」串成一个 Hook, 再补上清理函数和重试上限,就是一个健壮的验证码封装。