news 2026/10/1 3:52:31

原生 JavaScript 实现密码框小眼睛:光标恢复与无障碍

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
原生 JavaScript 实现密码框小眼睛:光标恢复与无障碍

1. 密码框小眼睛的实现远不止切个 type 属性

很多人第一次接到"给密码框加个小眼睛"的需求时,第一反应都是:这不简单吗,点一下把type从password改成text,再换个图标就完事了。我当年也是这么想的,结果上线第二天就收到反馈——切换的时候光标位置丢了,用户输到一半点一下眼睛,光标直接跳到最前面;还有用户报告说在某些安卓机的输入法里,切换后弹出的键盘布局都变了,中文输入法直接被打断。所以这个看起来只有十几行代码的小功能,实际上牵扯到光标管理、输入状态保持、无障碍访问、表单自动填充干扰等一堆细节,值得单独拿出来讲透。

本文要聊的就是如何用原生 JavaScript 实现一个体验过关的密码输入框显示/隐藏功能。核心是围绕input[type=password]与input[type=text]的动态切换、图标状态同步、光标位置保持以及各类边界情况的处理。适合正在做登录注册页面的前端同学,也适合想补齐表单交互细节的初中级开发者。我会先讲清楚最朴素的做法为什么不够用,再一步步给出一个可以直接抄走的完整方案,中间穿插我自己在实际项目里踩过的坑。

需要提前说明的是,这个功能本身没有多高深的技术门槛,难点全在细节。写出来能跑只需要三分钟,写得让用户觉得"顺手"、让测试挑不出毛病,可能要花你半天。下面的内容就是把这半天该考虑的东西一次性交给你。

2. 五种主流实现思路的取舍分析

在动手之前,有必要把市面上常见的几种做法摆出来对比。不同的实现方式直接决定了后续能不能优雅地处理光标、自动填充和图标状态,选错了后面全是补丁。

2.1 直接修改 type 属性:最通用但也最容易出问题

这是绝大多数人的第一选择,代码大概是这样的:

const input = document.querySelector('#password'); const toggle = document.querySelector('.eye-icon'); toggle.addEventListener('click', () => { const isPassword = input.type === 'password'; input.type = isPassword ? 'text' : 'password'; });

能跑,逻辑也直白。但它有一个被严重低估的问题:每当type属性发生变化,浏览器会把这个 input 当作一个"新元素"来对待。具体表现是光标位置会重置、部分浏览器会清空当前选区、安卓上输入法状态会被打断。同时,如果这个 input 上挂了第三方密码管理器的自动填充,切换瞬间可能触发填充逻辑重新执行。

所以这个方案的定位应该是"可用但需要补丁",补丁内容就是第 3 章要讲的光标保持逻辑。

2.2 叠加两个 input 做切换:能规避光标问题但维护成本高

有些老项目里能看到这种做法:放一个type=password的 input 和一个type=text的 input,叠在同一个位置,点击眼睛时切换显示哪一个,同时把值互相同步。这样确实绕开了type变化导致的光标重置,因为每个 input 自己没变过类型。

代价是什么呢?你需要手动同步value、name、disabled、readonly以及所有校验状态,两个元素都得挂事件,表单提交时还要保证只提交一个。如果项目里用了 React 或 Vue 这种受控组件,状态会变得很难维护。除非你有非常特殊的兼容性需求,否则我不建议走这条路。

2.3 用 CSS 的 -webkit-text-security:最取巧但兼容性堪忧

还有一个思路是始终保持type=text,用 CSS 属性-webkit-text-security: disc来把字符显示成圆点。切换时只需要改这个 CSS 属性,完全不动type,光标和输入法状态都不会受影响。

.masked { -webkit-text-security: disc; }

问题在于这个属性是 WebKit 私有的,Firefox 早期不支持,虽然现在 Firefox 也跟进了,但移动端的部分国产浏览器内核版本较旧,表现不一致。另外它只是"看起来像密码",浏览器的密码管理器不会识别它,自动填充和"记住密码"的提示都不会出现。对于登录页这种强依赖密码管理器体验的场景,这个方案是减分项。

2.4 综合方案对比表

方案光标保持自动填充兼容兼容性实现复杂度推荐场景
修改 type 属性需手动处理好全部低绝大多数场景
双 input 叠加天然保持一般全部高特殊兼容需求
-webkit-text-security天然保持差WebKit 为主低内部工具、非登录页
contenteditable 模拟需大量处理差全部极高不推荐
修改 type + 光标恢复好好全部中本文推荐方案

2.5 为什么我最终选"修改 type + 光标恢复"

综合下来,改type的方案在自动填充、无障碍、表单语义上都是最正统的,唯一短板是光标,而光标问题是可以精确解决的。后面几章就围绕这个组合展开,把短板补齐,把细节做扎实。选型理由总结成一句话:用浏览器的原生语义承担主要工作,用 JS 只负责修补它的副作用。

3. 光标跳位与选区的精确恢复

这一章是整个功能里技术含量最高的部分,也是决定"能不能用"和"好不好用"的分水岭。

3.1 光标为什么会跳位:一次浏览器内部的重建

要理解光标跳位,得先知道修改type时浏览器做了什么。密码框和文本框在浏览器内部走的是不同的渲染和编辑路径,type一变,编辑宿主(editing host)实际上被重建了。重建之后,selectionStart和selectionEnd会被重置为 0 或者直接变成新元素的默认值。这就是为什么用户输到一半点眼睛,光标会突然跑到最前面。

知道原因之后解决方案就很清晰了:在切换之前把当前的 selection 记下来,切换之后再写回去。

3.2 记录与恢复的完整代码

function togglePasswordVisibility(input, show) { // 1. 记录切换前的选区状态 const start = input.selectionStart; const end = input.selectionEnd; const direction = input.selectionDirection; const hadFocus = document.activeElement === input; // 2. 执行 type 切换 input.type = show ? 'text' : 'password'; // 3. 恢复选区 if (hadFocus) { input.focus(); try { input.setSelectionRange(start, end, direction || 'none'); } catch (e) { // 某些 type 组合下 setSelectionRange 会抛错,静默忽略 } } }

这几行就是核心。有三点需要特别说明。

第一,selectionDirection一定要一起记录和恢复。用户在输入框里按住 Shift 向左扩选字符时,方向信息会丢失,恢复时如果不带上,选区高亮会跑到反方向去,体验很怪。

第二,focus()必须放在setSelectionRange之前。如果元素当前没有焦点,直接调用setSelectionRange在部分浏览器上是无效的,因为无焦点的输入框没有活跃的选区上下文。

第三,setSelectionRange在某些情况下会抛异常,比如 input 变成type=email或者type=number时是不支持选区操作的,所以用try...catch兜住,别让一个次要功能把整个交互打挂。

3.3 移动端键盘被收起的问题

桌面端处理好光标基本就稳了,移动端还有一层麻烦。在 iOS Safari 上,如果你在切换type的过程中让 input 失焦,软键盘会"啪"地收起,用户得重新点一下输入框才能继续打字,非常割裂。

上面的代码里我特意加了hadFocus的判断,只有在原本有焦点的情况下才执行focus(),这样切换过程中元素不会真正失焦。但要注意,input.type = ...这一行本身在部分 iOS 版本上就会短暂触发失焦,安全起见可以在切换后立刻同步调用focus({ preventScroll: true }),preventScroll能防止页面因为聚焦而自动滚动,尤其在表单较长的页面上很关键。

input.focus({ preventScroll: true });

提示:preventScroll在非常老的浏览器上不生效,此时可以通过记录window.scrollY,focus 之后再window.scrollTo(0, savedY)来兜底,但一般现代项目用不到。

3.4 中文输入法组合态下的处理

还有一个容易被忽略的场景:用户正在用中文输入法打拼音,还没上屏,这时候点眼睛切换。某些浏览器会因为输入法组合态被中断而丢失正在输入的内容。

稳妥的做法是在compositionstart到compositionend之间暂时禁用切换按钮,或者在切换前先input.blur()结束组合再重新聚焦。我个人的做法是加一个标记位:

let isComposing = false; input.addEventListener('compositionstart', () => isComposing = true); input.addEventListener('compositionend', () => isComposing = false); toggle.addEventListener('click', () => { if (isComposing) return; // 输入法组合中不切换 });

这样虽然用户在打字中途点眼睛没反应,但比起丢失内容,体验反而更好,而且这种情况极少发生。

4. 图标状态、无障碍与自动填充的协同处理

功能跑通了不代表做完了,还得让它对屏幕阅读器友好、和密码管理器和平共处、图标状态不乱。

4.1 图标的三种状态与切换逻辑

小眼睛图标通常有三种视觉状态:显示(睁眼)、隐藏(闭眼带斜杠)、以及 hover 或按下时的反馈态。状态管理要和 input 的真实type严格绑定,不能靠一个独立的布尔变量来记,否则一旦有别的逻辑改了type,图标就会和实际状态脱节。

我倾向于让 DOM 成为唯一事实来源:

function syncIcon(input, iconEl) { const visible = input.type === 'text'; iconEl.classList.toggle('is-visible', visible); iconEl.setAttribute('aria-label', visible ? '隐藏密码' : '显示密码'); iconEl.setAttribute('aria-pressed', String(visible)); }

每次type变化后都调一次syncIcon,图标永远跟着 input 走。aria-pressed是个很有用的 ARIA 状态,能告诉屏幕阅读器这个按钮当前是"已按下"状态,很多人不知道这个属性,加上了无障碍体验会明显提升。

图标本身建议用 SVG 内联而不是字体图标或图片。字体图标需要付出发光字体图标文件的代价,图片在高分屏上容易糊,内联 SVG 可以通过currentColor跟随文字颜色,还能用 CSS 直接控制描边粗细,是当前最省心的方案。

4.2 用 button 而不是 span 来承载点击

很多实现里眼睛图标就是个<span>或<i>加个 onclick,这在无障碍上是扣分的。正确做法是用<button type="button">,这样键盘用户可以用 Tab 聚焦、用 Enter 或空格触发,屏幕阅读器也能正确识别。

<div class="password-field"> <input id="password" name="password" type="password" autocomplete="current-password" aria-describedby="pwd-hint" /> <button type="button" class="eye-btn" aria-label="显示密码" aria-pressed="false" > <svg class="icon-eye" viewBox="0 0 24 24" aria-hidden="true">...</svg> <svg class="icon-eye-off" viewBox="0 0 24 24" aria-hidden="true">...</svg> </button> </div>

注意type="button"必须写。如果不写,button 在表单里的默认类型是submit,用户点一下眼睛直接把表单提交了,这个坑我见过不止一次,排查起来还容易懵。

另外autocomplete属性要写对。登录页密码框用current-password,注册页用new-password。写对了浏览器才知道该不该触发密码管理器,写错了或者乱写,自动填充体验会很差甚至完全不触发。

4.3 自动填充打乱状态时的兜底

浏览器填充密码是直接改input.value的,不会触发普通的用户输入事件序列。有的浏览器会派发input事件,有的只派发change。如果你的图标状态或某些校验逻辑依赖这些事件,可能会出现填充完之后界面没反应的情况。

兜底办法有两个:一是在DOMContentLoaded之后延迟一小段时间主动读一次input.value并同步一次状态;二是监听animationstart事件,因为部分浏览器(比如基于 WebKit 的)在自动填充时会触发一个名为-webkit-autofill的动画,可以借此捕获填充时机。后者偏 hack,前者更稳,我一般两个都加上。

window.addEventListener('DOMContentLoaded', () => { setTimeout(() => syncIcon(input, iconEl), 200); });

4.4 防止浏览器把切换后的内容"记住成明文"

这是个安全相关的细节。当type被切成text时,如果用户此刻提交表单,某些实现粗糙的页面可能会把明文值写进日志或历史。要保证表单的提交逻辑永远以name属性为准,提交前把type强制改回password再提交,避免明文通过某些渠道泄漏。

form.addEventListener('submit', () => { input.type = 'password'; });

这一行几乎没成本,但能避免一个真实存在的隐患。密码这个东西,凡是能少暴露一次就少暴露一次。

5. 完整代码、工程化封装与实测问题排查

前面拆开讲了原理,这一章把东西拼成可以直接用的成品,并给出封装和排错建议。

5.1 可直接复制的完整实现

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>密码显示切换示例</title> <style> .password-field { position: relative; display: inline-block; width: 280px; } .password-field input { width: 100%; height: 40px; padding: 0 40px 0 12px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; } .eye-btn { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 32px; height: 32px; border: 0; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #888; } .eye-btn:hover { color: #333; } .eye-btn .icon-eye-off { display: none; } .eye-btn.is-visible .icon-eye { display: none; } .eye-btn.is-visible .icon-eye-off { display: block; } </style> </head> <body> <div class="password-field"> <input id="password" name="password" type="password" autocomplete="current-password" aria-describedby="pwd-hint" /> <button type="button" class="eye-btn" aria-label="显示密码" aria-pressed="false"> <svg class="icon-eye" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <circle cx="12" cy="12" r="3"/> </svg> <svg class="icon-eye-off" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/> <path d="M1 1l22 22"/> <path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/> </svg> </button> </div> <p id="pwd-hint" hidden>密码区分大小写</p> <script> (function () { const input = document.getElementById('password'); const btn = document.querySelector('.eye-btn'); let isComposing = false; input.addEventListener('compositionstart', () => { isComposing = true; }); input.addEventListener('compositionend', () => { isComposing = false; }); function syncIcon() { const visible = input.type === 'text'; btn.classList.toggle('is-visible', visible); btn.setAttribute('aria-label', visible ? '隐藏密码' : '显示密码'); btn.setAttribute('aria-pressed', String(visible)); } function toggle() { if (isComposing) return; const start = input.selectionStart; const end = input.selectionEnd; const dir = input.selectionDirection; const hadFocus = document.activeElement === input; input.type = input.type === 'password' ? 'text' : 'password'; if (hadFocus) { input.focus({ preventScroll: true }); try { input.setSelectionRange(start, end, dir || 'none'); } catch (e) {} } syncIcon(); } btn.addEventListener('click', function (e) { e.preventDefault(); toggle(); }); // 触屏上防止点按钮时输入框失焦导致键盘收起 btn.addEventListener('mousedown', function (e) { e.preventDefault(); }); syncIcon(); })(); </script> </body> </html>

这份代码里有一个容易被忽略但很关键的点:mousedown上阻止默认行为。因为在桌面浏览器里,点击按钮的瞬间输入框会先失焦,等你切完再 focus 回去,过程里可能闪一下,连光标位置都不稳。preventDefault阻止焦点从 input 上移开,配合前面的选区恢复,整个切换过程就丝滑了。

5.2 封装成一个可复用的模块

如果在项目里要放好几个密码框(比如"修改密码"页面有原密码、新密码、确认密码三个),把上面的逻辑封装成一个初始化函数更省事:

function initPasswordToggle(root, options = {}) { const input = root.querySelector('input'); const btn = root.querySelector('[data-eye]'); if (!input || !btn) return; const labels = Object.assign( { show: '显示密码', hide: '隐藏密码' }, options.labels || {} ); let composing = false; input.addEventListener('compositionstart', () => composing = true); input.addEventListener('compositionend', () => composing = false); function sync() { const v = input.type === 'text'; btn.classList.toggle('is-visible', v); btn.setAttribute('aria-label', v ? labels.hide : labels.show); btn.setAttribute('aria-pressed', String(v)); } btn.addEventListener('mousedown', e => e.preventDefault()); btn.addEventListener('click', e => { e.preventDefault(); if (composing) return; const s = input.selectionStart, t = input.selectionEnd; const d = input.selectionDirection; const focused = document.activeElement === input; input.type = input.type === 'password' ? 'text' : 'password'; if (focused) { input.focus({ preventScroll: true }); try { input.setSelectionRange(s, t, d || 'none'); } catch (err) {} } sync(); }); sync(); return { sync }; } // 批量初始化 document.querySelectorAll('.password-field').forEach(el => initPasswordToggle(el));

调用方只需要保证 DOM 结构统一,每个密码框里有个 input 和带>.eye-btn { touch-action: manipulation; }

我在一个用户量不小的项目里加了这个样式之后,安卓上点击眼睛的响应明显更干脆,那种"点了要等半秒才有反应"的迟滞感基本消失。

6. 关于这个小功能的几句实在话

做了这么多年前端,密码框的小眼睛算是那种"看起来很小白,做起来能翻车"的典型代表。我的体会是,越是这种边角功能,越能看出一个实现是"能跑"还是"耐用"。能跑的实现三五行代码就够了,耐用的实现要考虑光标、焦点、输入法组合态、无障碍、自动填充、移动端键盘这一整套东西。

如果时间有限,至少把三件事做到位:一是type切换前后的光标恢复,二是眼睛按钮必须用带type="button"的原生 button,三是图标状态以input.type为唯一事实来源。这三件事能挡掉绝大多数线上问题。剩下的无障碍属性和自动填充兜底属于加分项,有精力就做,收益也不小。

最后提醒一句,密码相关的功能改动之后,上线前最好把 Chrome、Safari、以及安卓上主流的两个浏览器都过一遍,重点是自动填充和中文输入法这两个场景。这两个地方的浏览器行为差异最大,也最容易在测试环境漏掉。真在线上翻过车就知道,为这么个小图标做一次全平台回归,一点都不亏。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 3:52:23

Win系统下U盘不显示在BIOS启动项的三重门禁解析

1. 问题本质与真实场景还原&#xff1a;这不是BIOS“丢了U盘”&#xff0c;而是启动链路上的三重门禁被同时锁死你按下开机键&#xff0c;狂敲Del/F2/F12&#xff0c;屏幕一亮&#xff0c;进入那个蓝灰相间、字体古板的BIOS/UEFI界面——手指在Boot&#xff08;启动&#xff09…

作者头像 李华
网站建设 2026/10/1 3:52:22

612张肠道息肉检测数据集:VOC/YOLO格式解析与YOLOv8训练实战

简介&#xff1a;面向计算机视觉目标检测入门及医学影像AI研究者的肠道息肉检测数据集&#xff0c;包含612张肠道内镜图像&#xff0c;由labelImg人工绘制矩形框统一标注为单类别“xirou”&#xff0c;共712个有效目标框&#xff0c;可用于YOLO、Faster R-CNN、SSD等主流检测框…

作者头像 李华
网站建设 2026/10/1 3:51:42

MySQL创建用户与授权:从GRANT到权限回收全解析

最近有几个朋友问我同一个问题&#xff1a;“MySQL 里怎么创建一个新用户&#xff0c;然后只给他某一个库的权限&#xff1f;”一开始我以为是新手才问这个&#xff0c;后来发现有不少写过一两年 SQL 的人&#xff0c;也会在这个地方翻车——要么是GRANT写错了语法&#xff0c;…

作者头像 李华
网站建设 2026/10/1 3:51:10

iozone磁盘性能测试完全指南:从安装到实战避坑

磁盘读写测试这块&#xff0c;iozone是绕不开的一个老牌工具。它能在文件系统层面测出顺序读写、随机读写、重写、复读等一整套性能数据&#xff0c;命令行参数多但逻辑清晰。这篇文章就把iozone的下载安装、核心命令参数、完整测试流程和常见坑一次性讲透&#xff0c;适合做运…

作者头像 李华
网站建设 2026/10/1 3:51:10

基于ResNet迁移学习的真假图片识别实战:从数据组织到频域增强

简介&#xff1a;这份资源面向具备一定Python与PyTorch基础、希望入门图像真伪识别实战的开发者与学习者&#xff0c;提供了一套基于ResNet与CNN训练识别真假图片的完整代码方案。压缩包共7个文件&#xff0c;约190KB&#xff0c;包含3个py脚本、2张示例提示图、1个txt依赖文件…

作者头像 李华
网站建设 2026/10/1 3:51:09

Python新闻评论情感分析实战:从爬虫到可视化大屏

做新闻舆论情感分析这个项目&#xff0c;其实是被一个需求逼出来的。当时领导丢给我一句话&#xff1a;“把网易新闻某个板块的评论情绪摸一摸&#xff0c;做个可视化页面出来。”听起来简单&#xff0c;真正上手才发现&#xff0c;这活儿把Python爬虫、文本清洗、自然语言处理…

作者头像 李华