2026/10/8 6:17:41

TaoToken 实战:用 JS 实现光标闪烁的 3 种方案与性能对比

TaoToken 实战:用 JS 实现光标闪烁的 3 种方案与性能对比 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel_chat进去之后你可以直接问技术问题比如「setInterval 在 Chrome 后台标签页最小间隔是多少」它会给你一个可验证的答案比翻文档快。我实测下来问「rAF 和 setInterval 在长列表输入场景下的调度差异」这类问题回答质量足够支撑你写对比代码。第二步如果你想把代码生成接到脚本里需要去控制台创建 API Key。控制台地址https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentconsole在控制台里找到 API Keys 管理新建一个 Key复制出来保存好。注意 Key 只在创建时完整显示一次关掉页面就看不到了建议直接存进密码管理器。第三步拿到 Key 之后如果你用的是 Claude Code 这类命令行工具做代码辅助可以走 Coding Plan 的接入方式https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding_plan接入的时候三个要素必须齐全缺一个都连不上Base URL、API Key、Model ID。Base URL 用https://taotoken.net/apiKey 就是刚才创建的那串Model ID 按你实际要用的模型填。很多人卡在「连不上」九成是这三件套里少填了一个或者 Base URL 多加了斜杠。第四步如果你要查具体的接口参数、请求格式、返回字段看接入文档https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc文档里对请求头和 body 结构写得比较清楚照着填不会错。API Keys 的直达入口也放一下方便你快速跳转https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi_keys环境准备好之后你就可以一边写代码一边让模型帮你 review 了。接下来进入正题先给三种方案的完整可复制实现。3. 三种方案的可复制配置与完整代码这一节是全文的核心每个方案我都给完整的 HTML CSS JS你可以直接存成.html文件在浏览器打开。为了公平对比三种方案共用同一套 DOM 结构和光标样式只替换调度逻辑。先看公共部分。光标用一个span classcursor模拟原生光标隐藏掉!DOCTYPE html html langzh-CN head meta charsetUTF-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 classfield input typetext idinputPrice placeholder输入金额 span classcursor idcursor/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 一个 setInterval30 个就是 30 个定时器。我实测过30 个 setInterval 同时跑Performance 面板里能看到主线程每秒被唤醒 60 次左右虽然单次开销小但累积起来在低端机上会有可感知的卡顿。3.2 方案二CSS animation 配合 JS 切换 class把闪烁逻辑写进keyframesJS 只负责加/删 classkeyframes 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或linearopacity 会渐变看起来像呼吸灯而不是光标闪烁。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 typetext>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-endopacity 渐变看起来像呼吸灯。第四rAF 方案里lastToggle没重置切后台再切回来不闪。第五setInterval 方案里timer变量作用域不对多次 focus 创建了多个定时器。性能层面的坑长列表卡顿。如果你有 50 个以上输入框setInterval 方案基本不可用建议直接上 CSS animation。如果非要用 JS 控制考虑用一个全局定时器统一管理所有光标而不是每个输入框一个定时器。rAF 方案在实例多的时候也要注意每帧回调次数等于实例数50 个实例就是每帧 50 次回调虽然每次很轻但累加起来不容忽视。排查的时候有个通用技巧先在单个输入框上验证逻辑正确再扩展到多个。很多问题在单实例下不暴露一上量就崩。另外Performance 面板录制时记得勾选「Screenshots」能看到每一帧的实际画面对判断「闪到一半卡住」特别有用。6. 按需选型三种方案怎么选以及后续怎么深入把三种方案横向对比一下方便你按场景选。维度setIntervalCSS animationrequestAnimationFrame实现复杂度低低中主线程占用高随实例线性增长极低合成器线程中每帧回调闪烁稳定性差受主线程阻塞影响好好频率动态调整容易需改 CSS 变量容易后台标签页行为被节流到 1000ms继续跑部分浏览器暂停暂停适合场景Demo、单输入框生产环境、长列表需帧对齐、动态频率我的建议很直接新项目一律优先 CSS animation它用最少的代码拿到最好的稳定性长列表场景优势尤其明显。只有当闪烁频率需要根据业务动态计算比如根据输入速度调整或者需要和 canvas 渲染严格对齐时才考虑 rAF。setInterval 留给快速验证和教学演示生产环境能不用就不用。如果你想把这块做深有几个方向可以继续一是把光标封装成 Web Component内部用 CSS animation对外暴露频率、颜色、粗细等属性团队里复用。二是研究caret-color和::selection的配合做出更接近原生体验的光标。三是用IntersectionObserver只让可视区域内的输入框闪烁屏幕外的暂停进一步省资源。代码辅助这块如果你想让模型帮你 review 上面的实现或者生成更多测试用例可以走模型对话入口https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel_chat长期做前端编码、需要 Agent 辅助的可以看 Coding Planhttps://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding_plan接口细节和参数以接入文档为准https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdoc最后留一个我实测出来的小技巧CSS animation 方案里把animation-duration设成1s配合step-end闪烁节奏最接近系统原生光标。如果你觉得太快或太慢调这个值就行不用动 JS。另外记得给.cursor加will-change: opacity能让合成器提前优化长列表下更稳。