1. 原生 JS 光标闪烁到底难在哪:从 setInterval 抖动说起
光标闪烁这个需求,第一次听到的人多半会觉得「不就是让一根竖线一亮一灭吗」。真动手写才发现,它牵扯的东西比想象中多:定时器精度、主线程阻塞、长列表里几十个输入框同时闪、页面切到后台后定时器被节流、CSS 动画和 JS 状态不同步导致「闪到一半卡住」。这些问题在单输入框 Demo 里根本看不出来,一旦放进真实业务——比如财务系统里一屏 30 个金额输入格、聊天窗口里多个可编辑气泡——就会集中爆发。
我先把结论摆前面:setInterval 适合快速验证和低频场景,CSS animation 配合 JS 切换适合绝大多数生产环境,requestAnimationFrame 适合需要和渲染节奏严格对齐、或者闪烁频率要动态计算的场景。三种方案没有绝对优劣,关键看你是否理解它们各自的调度机制。
先解释一下「光标闪烁」在浏览器里到底是什么。原生<input>和<textarea>的光标由浏览器内核绘制,你没法直接控制它的闪烁节奏,也没法改颜色和粗细。所以业务里说的「自定义光标闪烁」,本质是隐藏原生光标(caret-color: transparent),然后用一个绝对定位的<span>或伪元素模拟一根竖线,自己控制它的显隐。这就把问题从「控制浏览器光标」变成了「控制一个 DOM 元素的可见性」,而可见性切换的调度方式,正是三种方案的分水岭。
为什么 setInterval 会抖?因为setInterval(fn, 500)的语义是「至少间隔 500ms 把 fn 推进任务队列」,而不是「精确每 500ms 执行」。如果主线程正在跑一段 200ms 的同步计算,你的回调就会被推迟,两次闪烁之间的实际间隔变成 700ms,视觉上就是「忽快忽慢」。更麻烦的是,多个 setInterval 之间没有协调,30 个输入框就是 30 个独立定时器,浏览器要分别调度,CPU 占用随数量线性上升。
CSS animation 的思路完全不同:把闪烁交给合成器(compositor),用@keyframes定义 0% 到 100% 的 opacity 变化,浏览器可以在不占用主线程的情况下跑动画。JS 只负责在「聚焦/失焦」时切换 class,把动画的启停权交给 CSS。这样即使主线程卡顿,光标该闪还是闪,稳定性直接上一个台阶。
requestAnimationFrame 则是跟着屏幕刷新率走,通常 60fps 就是每 16.7ms 一次回调。你可以用时间戳累加来判断「是否到了该切换的时机」,从而实现任意频率的闪烁,而且天然和渲染帧对齐,不会出现撕裂。代价是它每帧都要执行回调,虽然单次开销极小,但在几十个实例同时跑的时候,累加起来也需要留意。
理解了这三者的调度差异,后面的代码和性能对比才有意义。下面先讲怎么把环境准备好,再逐个给可复制的实现。
2. TaoToken 前置准备:把模型对话和 API Key 配好再动手
写代码之前,我习惯先把调试和验证用的工具链搭好。这次要对比三种方案的性能,光靠肉眼看「闪得顺不顺」不够,得用 Performance 面板抓火焰图,还得能快速生成测试用的长列表代码。这时候有个顺手的模型对话入口会省很多事——比如让模型帮你生成 50 个输入框的测试页面、解释火焰图里某段长任务的来源、或者对比不同写法的内存占用。
TaoToken 这边我主要用两个能力:一个是网页端的模型对话,用来快速问「这段 rAF 逻辑为什么在后台标签页停了」这类具体问题;另一个是 API Key,方便我把一些重复的代码生成、批量改写接到自己的脚本里。整个准备过程不复杂,跟着做就行。
第一步,打开模型对话页面,地址是:
https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model_chat进去之后你可以直接问技术问题,比如「setInterval 在 Chrome 后台标签页最小间隔是多少」,它会给你一个可验证的答案,比翻文档快。我实测下来,问「rAF 和 setInterval 在长列表输入场景下的调度差异」这类问题,回答质量足够支撑你写对比代码。
第二步,如果你想把代码生成接到脚本里,需要去控制台创建 API Key。控制台地址:
https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=console在控制台里找到 API Keys 管理,新建一个 Key,复制出来保存好。注意 Key 只在创建时完整显示一次,关掉页面就看不到了,建议直接存进密码管理器。
第三步,拿到 Key 之后,如果你用的是 Claude Code 这类命令行工具做代码辅助,可以走 Coding Plan 的接入方式:
https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding_plan接入的时候三个要素必须齐全,缺一个都连不上:Base URL、API Key、Model ID。Base URL 用https://taotoken.net/api,Key 就是刚才创建的那串,Model ID 按你实际要用的模型填。很多人卡在「连不上」,九成是这三件套里少填了一个,或者 Base URL 多加了斜杠。
第四步,如果你要查具体的接口参数、请求格式、返回字段,看接入文档:
https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc文档里对请求头和 body 结构写得比较清楚,照着填不会错。API Keys 的直达入口也放一下,方便你快速跳转:
https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=api_keys环境准备好之后,你就可以一边写代码一边让模型帮你 review 了。接下来进入正题,先给三种方案的完整可复制实现。
3. 三种方案的可复制配置与完整代码
这一节是全文的核心,每个方案我都给完整的 HTML + CSS + JS,你可以直接存成.html文件在浏览器打开。为了公平对比,三种方案共用同一套 DOM 结构和光标样式,只替换调度逻辑。
先看公共部分。光标用一个<span class="cursor">模拟,原生光标隐藏掉:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>光标闪烁方案对比</title> <style> .field { position: relative; display: inline-block; border: 1px solid #ccc; padding: 6px 8px; min-width: 200px; font-size: 16px; line-height: 24px; } .field input { border: none; outline: none; font-size: 16px; line-height: 24px; caret-color: transparent; /* 隐藏原生光标 */ background: transparent; width: 160px; } .cursor { position: absolute; top: 6px; width: 2px; height: 24px; background: #333; pointer-events: none; } </style> </head> <body> <div class="field"> <input type="text" id="inputPrice" placeholder="输入金额"> <span class="cursor" id="cursor"></span> </div> </body> </html>注意caret-color: transparent这行,没有它你会看到两根光标重叠,一根原生一根模拟,闪起来像鬼畜。这是新手最容易漏的一步。
3.1 方案一:setInterval 定时切换
最直观的写法,每 500ms 切换一次visibility:
(function () { const input = document.getElementById('inputPrice'); const cursor = document.getElementById('cursor'); let timer = null; function startBlink() { if (timer) return; timer = setInterval(() => { cursor.style.visibility = cursor.style.visibility === 'hidden' ? 'visible' : 'hidden'; }, 500); } function stopBlink() { clearInterval(timer); timer = null; cursor.style.visibility = 'hidden'; } input.addEventListener('focus', startBlink); input.addEventListener('blur', stopBlink); })();这段代码能跑,但有两个隐患。第一,cursor.style.visibility初始是空字符串,第一次判断会走到visible分支,逻辑上没问题但不够清晰,建议初始化时显式设成visible。第二,setInterval在页面切到后台后会被浏览器节流到最低 1000ms,切回来时可能出现「闪到一半」的状态,需要额外处理visibilitychange事件。
如果你要在一屏放多个输入框,每个都 new 一个 setInterval,30 个就是 30 个定时器。我实测过,30 个 setInterval 同时跑,Performance 面板里能看到主线程每秒被唤醒 60 次左右,虽然单次开销小,但累积起来在低端机上会有可感知的卡顿。
3.2 方案二:CSS animation 配合 JS 切换 class
把闪烁逻辑写进@keyframes,JS 只负责加/删 class:
@keyframes blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } } .cursor.blinking { animation: blink 1s step-end infinite; }(function () { const input = document.getElementById('inputPrice'); const cursor = document.getElementById('cursor'); input.addEventListener('focus', () => { cursor.classList.add('blinking'); }); input.addEventListener('blur', () => { cursor.classList.remove('blinking'); }); })();这里用step-end是关键。如果默认用ease或linear,opacity 会渐变,看起来像呼吸灯而不是光标闪烁。step-end让它在关键帧之间瞬间跳变,才是我们要的「一亮一灭」。
这个方案的最大优势是动画跑在合成器线程,主线程再忙也不影响闪烁节奏。我用 Performance 面板对比过,主线程故意跑一段 300ms 的同步循环,setInterval 方案的光标明显卡了一下,CSS animation 方案完全不受影响。代价是闪烁频率被写死在 CSS 里,要动态改频率得改animation-duration,稍微麻烦一点。
3.3 方案三:requestAnimationFrame 按帧调度
用时间戳累加控制切换时机,频率可以动态算:
(function () { const input = document.getElementById('inputPrice'); const cursor = document.getElementById('cursor'); const INTERVAL = 500; // 闪烁半周期,单位 ms let rafId = null; let lastToggle = 0; let visible = true; function loop(ts) { if (!lastToggle) lastToggle = ts; if (ts - lastToggle >= INTERVAL) { visible = !visible; cursor.style.opacity = visible ? '1' : '0'; lastToggle = ts; } rafId = requestAnimationFrame(loop); } function start() { if (rafId) return; lastToggle = 0; rafId = requestAnimationFrame(loop); } function stop() { cancelAnimationFrame(rafId); rafId = null; cursor.style.opacity = '0'; } input.addEventListener('focus', start); input.addEventListener('blur', stop); })();rAF 的好处是天然和屏幕刷新对齐,不会出现「定时器到点了但这一帧还没渲染」的错位。而且它每帧回调一次,你可以顺便做别的事,比如根据输入内容动态调整光标位置。缺点是页面切到后台时 rAF 会暂停,切回来需要重新校准lastToggle,否则会立刻闪一下——上面代码里start()重置lastToggle = 0就是处理这个的。
三种方案的代码都给全了,你可以直接复制到一个文件里,用不同 id 区分,同时打开对比。下一节讲怎么用 Performance 面板验证它们的实际表现。
4. 验证请求与成功结果:用 Performance 面板抓真实数据
代码写完不算完,得用数据说话。这一节我带你走一遍完整的验证流程,包括怎么构造长列表测试场景、怎么抓火焰图、怎么看关键指标。
先构造测试页面。把上面三种方案的输入框各复制 10 份,一共 30 个输入框,模拟「一屏 30 个金额输入格」的真实业务。你可以让模型帮你生成这段重复 DOM,或者直接手写循环:
const container = document.body; for (let i = 0; i < 10; i++) { ['interval', 'css', 'raf'].forEach(type => { const div = document.createElement('div'); div.className = 'field'; div.innerHTML = ` <input type="text">let last = performance.now(); setInterval(() => { const now = performance.now(); console.log('实际间隔:', (now - last).toFixed(2), 'ms'); last = now; }, 500);跑一会儿你会看到,setInterval 的实际间隔在 500ms 上下浮动,偶尔跳到 520ms 甚至 600ms,这就是主线程阻塞的证据。CSS animation 你没法直接打点,但可以用getComputedStyle读 opacity 变化,或者干脆相信合成器。
验证成功的标志是:在 30 个输入框同时聚焦、且主线程故意跑一段同步计算的情况下,CSS animation 方案的光标依然稳定闪烁,而 setInterval 方案出现可感知的卡顿。如果你测出来是这个结果,说明三种方案的差异你已经亲手验证过了。
5. 本篇常见错排查:401、local proxy failed 与光标不闪
这一节把我在实操中踩过的坑集中列一下,包括接入层面的报错和代码层面的问题。
报错一:401 Unauthorized。这个通常出现在你调 API 的时候。原因就三个:Key 没填、Key 填错、Key 过期。检查顺序是先确认请求头里Authorization: Bearer <你的Key>格式对不对,注意 Bearer 后面有个空格。然后确认 Key 是从控制台完整复制的,没有多余空格或换行。如果还不行,去控制台重新生成一个 Key。三件套 Base URL、API Key、Model ID 必须同时正确,缺一个都会 401 或 404。
报错二:local proxy failed。这个报错一般出现在你本地配了某些网络工具,或者 Base URL 填成了localhost之类。先检查你的 Base URL 是不是https://taotoken.net/api,不要自己加端口或路径。然后确认本地没有残留的代理配置影响请求。如果你用的是 Claude Code 或 Cline 这类工具,检查它们的配置文件里 Base URL 有没有被改错。
报错三:reading 'choices' of undefined。这是解析响应时拿不到choices字段。原因通常是请求根本没成功,返回的是一个错误对象,但你的代码直接去读data.choices[0]。修复方法是先判断data.error是否存在,或者打印完整响应看看实际返回了什么。常见触发场景是 Model ID 填错,服务端返回错误但你没处理。
报错四:OAuth 相关错误。如果你用 Claude Code 的 OAuth 登录方式,可能会遇到 token 过期或回调失败。这种情况建议改用 API Key 方式接入,配置更直接,不容易出问题。OAuth 的坑主要在回调地址和 token 刷新,排查起来比较费时间。
代码层面的坑:光标不闪。按可能性排序:第一,忘了写caret-color: transparent,原生光标和模拟光标重叠,看起来像没生效。第二,.cursor的position没设成absolute,或者父容器没设position: relative,导致光标跑到别的地方。第三,CSS animation 方案里忘了加step-end,opacity 渐变看起来像呼吸灯。第四,rAF 方案里lastToggle没重置,切后台再切回来不闪。第五,setInterval 方案里timer变量作用域不对,多次 focus 创建了多个定时器。
性能层面的坑:长列表卡顿。如果你有 50 个以上输入框,setInterval 方案基本不可用,建议直接上 CSS animation。如果非要用 JS 控制,考虑用一个全局定时器统一管理所有光标,而不是每个输入框一个定时器。rAF 方案在实例多的时候也要注意,每帧回调次数等于实例数,50 个实例就是每帧 50 次回调,虽然每次很轻,但累加起来不容忽视。
排查的时候有个通用技巧:先在单个输入框上验证逻辑正确,再扩展到多个。很多问题在单实例下不暴露,一上量就崩。另外,Performance 面板录制时记得勾选「Screenshots」,能看到每一帧的实际画面,对判断「闪到一半卡住」特别有用。
6. 按需选型:三种方案怎么选,以及后续怎么深入
把三种方案横向对比一下,方便你按场景选。
| 维度 | setInterval | CSS animation | requestAnimationFrame |
|---|---|---|---|
| 实现复杂度 | 低 | 低 | 中 |
| 主线程占用 | 高(随实例线性增长) | 极低(合成器线程) | 中(每帧回调) |
| 闪烁稳定性 | 差(受主线程阻塞影响) | 好 | 好 |
| 频率动态调整 | 容易 | 需改 CSS 变量 | 容易 |
| 后台标签页行为 | 被节流到 1000ms | 继续跑(部分浏览器暂停) | 暂停 |
| 适合场景 | Demo、单输入框 | 生产环境、长列表 | 需帧对齐、动态频率 |
我的建议很直接:新项目一律优先 CSS animation,它用最少的代码拿到最好的稳定性,长列表场景优势尤其明显。只有当闪烁频率需要根据业务动态计算(比如根据输入速度调整),或者需要和 canvas 渲染严格对齐时,才考虑 rAF。setInterval 留给快速验证和教学演示,生产环境能不用就不用。
如果你想把这块做深,有几个方向可以继续:一是把光标封装成 Web Component,内部用 CSS animation,对外暴露频率、颜色、粗细等属性,团队里复用。二是研究caret-color和::selection的配合,做出更接近原生体验的光标。三是用IntersectionObserver只让可视区域内的输入框闪烁,屏幕外的暂停,进一步省资源。
代码辅助这块,如果你想让模型帮你 review 上面的实现,或者生成更多测试用例,可以走模型对话入口:
https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model_chat长期做前端编码、需要 Agent 辅助的,可以看 Coding Plan:
https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding_plan接口细节和参数以接入文档为准:
https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc最后留一个我实测出来的小技巧:CSS animation 方案里,把animation-duration设成1s配合step-end,闪烁节奏最接近系统原生光标。如果你觉得太快或太慢,调这个值就行,不用动 JS。另外记得给.cursor加will-change: opacity,能让合成器提前优化,长列表下更稳。