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、以及安卓上主流的两个浏览器都过一遍,重点是自动填充和中文输入法这两个场景。这两个地方的浏览器行为差异最大,也最容易在测试环境漏掉。真在线上翻过车就知道,为这么个小图标做一次全平台回归,一点都不亏。