
1. 密码框小眼睛的实现远不止切个 type 属性很多人第一次接到给密码框加个小眼睛的需求时第一反应都是这不简单吗点一下把type从password改成text再换个图标就完事了。我当年也是这么想的结果上线第二天就收到反馈——切换的时候光标位置丢了用户输到一半点一下眼睛光标直接跳到最前面还有用户报告说在某些安卓机的输入法里切换后弹出的键盘布局都变了中文输入法直接被打断。所以这个看起来只有十几行代码的小功能实际上牵扯到光标管理、输入状态保持、无障碍访问、表单自动填充干扰等一堆细节值得单独拿出来讲透。本文要聊的就是如何用原生 JavaScript 实现一个体验过关的密码输入框显示/隐藏功能。核心是围绕input[typepassword]与input[typetext]的动态切换、图标状态同步、光标位置保持以及各类边界情况的处理。适合正在做登录注册页面的前端同学也适合想补齐表单交互细节的初中级开发者。我会先讲清楚最朴素的做法为什么不够用再一步步给出一个可以直接抄走的完整方案中间穿插我自己在实际项目里踩过的坑。需要提前说明的是这个功能本身没有多高深的技术门槛难点全在细节。写出来能跑只需要三分钟写得让用户觉得顺手、让测试挑不出毛病可能要花你半天。下面的内容就是把这半天该考虑的东西一次性交给你。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 做切换能规避光标问题但维护成本高有些老项目里能看到这种做法放一个typepassword的 input 和一个typetext的 input叠在同一个位置点击眼睛时切换显示哪一个同时把值互相同步。这样确实绕开了type变化导致的光标重置因为每个 input 自己没变过类型。代价是什么呢你需要手动同步value、name、disabled、readonly以及所有校验状态两个元素都得挂事件表单提交时还要保证只提交一个。如果项目里用了 React 或 Vue 这种受控组件状态会变得很难维护。除非你有非常特殊的兼容性需求否则我不建议走这条路。2.3 用 CSS 的 -webkit-text-security最取巧但兼容性堪忧还有一个思路是始终保持typetext用 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 变成typeemail或者typenumber时是不支持选区操作的所以用try...catch兜住别让一个次要功能把整个交互打挂。3.3 移动端键盘被收起的问题桌面端处理好光标基本就稳了移动端还有一层麻烦。在 iOS Safari 上如果你在切换type的过程中让 input 失焦软键盘会啪地收起用户得重新点一下输入框才能继续打字非常割裂。上面的代码里我特意加了hadFocus的判断只有在原本有焦点的情况下才执行focus()这样切换过程中元素不会真正失焦。但要注意input.type ...这一行本身在部分 iOS 版本上就会短暂触发失焦安全起见可以在切换后立刻同步调用focus({ preventScroll: true })preventScroll能防止页面因为聚焦而自动滚动尤其在表单较长的页面上很关键。input.focus({ preventScroll: true });提示preventScroll在非常老的浏览器上不生效此时可以通过记录window.scrollYfocus 之后再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 typebutton这样键盘用户可以用 Tab 聚焦、用 Enter 或空格触发屏幕阅读器也能正确识别。div classpassword-field input idpassword namepassword typepassword autocompletecurrent-password aria-describedbypwd-hint / button typebutton classeye-btn aria-label显示密码 aria-pressedfalse svg classicon-eye viewBox0 0 24 24 aria-hiddentrue.../svg svg classicon-eye-off viewBox0 0 24 24 aria-hiddentrue.../svg /button /div注意typebutton必须写。如果不写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 langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1 / 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 classpassword-field input idpassword namepassword typepassword autocompletecurrent-password aria-describedbypwd-hint / button typebutton classeye-btn aria-label显示密码 aria-pressedfalse svg classicon-eye width20 height20 viewBox0 0 24 24 fillnone strokecurrentColor stroke-width2 aria-hiddentrue path dM1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z/ circle cx12 cy12 r3/ /svg svg classicon-eye-off width20 height20 viewBox0 0 24 24 fillnone strokecurrentColor stroke-width2 aria-hiddentrue path dM17.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 dM1 1l22 22/ path dM9.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 idpwd-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切换前后的光标恢复二是眼睛按钮必须用带typebutton的原生 button三是图标状态以input.type为唯一事实来源。这三件事能挡掉绝大多数线上问题。剩下的无障碍属性和自动填充兜底属于加分项有精力就做收益也不小。最后提醒一句密码相关的功能改动之后上线前最好把 Chrome、Safari、以及安卓上主流的两个浏览器都过一遍重点是自动填充和中文输入法这两个场景。这两个地方的浏览器行为差异最大也最容易在测试环境漏掉。真在线上翻过车就知道为这么个小图标做一次全平台回归一点都不亏。