浏览器记住密码导致忘记密码页面输入框回显错乱?看这篇就够了

问题背景

在开发忘记密码功能时,相信很多前端开发者都遇到过这样的困扰:用户在忘记密码页面输入手机号后,浏览器会自动将保存的账号密码填充到页面的输入框中,导致验证码输入框被填充了密码、密码输入框被错误回显等问题,严重影响用户体验。

问题复现

假设你的忘记密码页面有以下输入框:

  1. 账号(手机号)
  2. 验证码
  3. 新密码
  4. 确认新密码

浏览器会出现以下自动填充行为:

  • 账号输入框被自动填充了保存的用户名
  • 验证码输入框被自动填充了保存的密码(因为浏览器认为这是第二个输入框)
  • 新密码和确认密码输入框也可能被错误填充

原因分析

浏览器的自动填充机制基于以下规则:

  1. 基于输入框类型type="text" 被认为是用户名,type="password" 被认为是密码
  2. 基于表单顺序:浏览器会按照页面中输入框出现的顺序进行填充
  3. 基于 name 属性name="username"name="password" 会触发自动填充
  4. 基于 autocomplete 属性 :虽然 autocomplete="off" 理论上可以禁用,但现代浏览器(尤其是 Chrome)经常忽略这个设置

解决方案

下面介绍几种经过实战验证的解决方案,从简单到复杂排列。

方案一:使用隐藏的假输入框(推荐)

原理:在真正的输入框前面放置隐藏的假输入框,让浏览器优先填充这些假输入框。

html 复制代码
<!-- 隐藏的输入框,用于拦截浏览器的密码保存机制自动填充 -->
<div style="width: 0; height: 0; overflow: hidden; position: absolute; z-index: -1;">
  <input type="text" name="fake_username" autocomplete="username" />
  <input type="password" name="fake_password" autocomplete="current-password" />
</div>

关键点

  • 使用绝对定位将假输入框移出可视区域
  • 设置 autocomplete 属性引导浏览器填充到正确的假输入框
  • 假输入框的 name 属性使用常见的用户名/密码命名

方案二:欺骗浏览器的密码框

原理:在账号输入框之后、验证码输入框之前,放置一个隐藏的密码输入框,让浏览器将账号和这个隐藏密码框配对。

html 复制代码
<el-form-item label="账号" prop="account">
  <el-input v-model="params.account" ... />
</el-form-item>

<!-- 欺骗浏览器的密码框:让浏览器将账号和这个隐藏密码框配对,从而保护下面的验证码不被当成账号 -->
<input type="password" autocomplete="current-password" 
       style="position: absolute; left: -9999px; top: -9999px; opacity: 0;" 
       tabindex="-1" />

<el-form-item label="验证码" prop="smsCode">
  <el-input v-model="params.smsCode" ... />
</el-form-item>

关键点

  • 使用 left: -9999px 将输入框移出屏幕
  • 设置 tabindex="-1" 防止用户通过 Tab 键聚焦到这个输入框
  • 设置 opacity: 0 确保完全不可见

方案三:动态添加 readonly 属性(针对 Chrome)

原理 :Chrome 浏览器会忽略 autocomplete="off",但不会填充 readonly 的输入框。我们可以在页面加载时给输入框添加 readonly,在用户聚焦时移除。

typescript 复制代码
onMounted(() => {
  // 针对 Chrome 顽固的自动填充,使用原生 DOM 操作添加 readonly,
  // 这样 Element Plus 组件本身不知道是 readonly,也就不会隐藏显示密码的"眼睛"图标。
  setTimeout(() => {
    const inputs = document.querySelectorAll('.no-autofill .el-input__inner')
    inputs.forEach((input: any) => {
      input.setAttribute('readonly', 'readonly')
      input.addEventListener('focus', (e: any) => {
        e.target.removeAttribute('readonly')
      })
      input.addEventListener('blur', (e: any) => {
        e.target.setAttribute('readonly', 'readonly')
      })
    })
  }, 100)
})

关键点

  • 使用 setTimeout 确保 DOM 已经渲染完成
  • focus 时移除 readonly,保证用户可以正常输入
  • blur 时重新添加 readonly,防止后续自动填充
  • 使用 no-autofill 类名精确控制哪些输入框需要这个处理

方案四:正确使用 autocomplete 属性

原理:现代浏览器支持多种 autocomplete 属性值,可以精确控制自动填充行为。

html 复制代码
<!-- 账号输入框:禁用自动填充 -->
<el-input v-model="params.account" autocomplete="off" />

<!-- 验证码输入框:使用 one-time-code 提示浏览器这是一次性验证码 -->
<el-input v-model="params.smsCode" autocomplete="one-time-code" />

<!-- 新密码输入框:使用 new-password 提示浏览器这是新密码 -->
<el-input v-model="params.password" autocomplete="new-password" />

<!-- 确认密码输入框:同样使用 new-password -->
<el-input v-model="params.confirmPassword" autocomplete="new-password" />

常用的 autocomplete 值

  • off:禁用自动填充
  • on:启用自动填充(默认)
  • username:用户名
  • current-password:当前密码
  • new-password:新密码(用于注册或修改密码场景)
  • one-time-code:一次性验证码
  • email:邮箱地址
  • tel:电话号码

完整代码示例

下面是一个完整的忘记密码页面实现,整合了上述所有方案:

vue 复制代码
<script setup lang="ts">
import { Lock, User } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onUnmounted, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { FormInstance } from 'element-plus'
import { sendSmsCode, resetPasswordForMini } from '@/api/login'

const router = useRouter()

const formRef = ref<FormInstance>()
const isSending = ref(false)
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null

const params = reactive({
  account: '',
  smsCode: '',
  password: '',
  confirmPassword: '',
})

onMounted(() => {
  const account = sessionStorage.getItem('forgotPasswordAccount')
  if (account) {
    params.account = account
    sessionStorage.removeItem('forgotPasswordAccount')
  }

  // 针对 Chrome 顽固的自动填充,使用原生 DOM 操作添加 readonly
  setTimeout(() => {
    const inputs = document.querySelectorAll('.no-autofill .el-input__inner')
    inputs.forEach((input: any) => {
      input.setAttribute('readonly', 'readonly')
      input.addEventListener('focus', (e: any) => {
        e.target.removeAttribute('readonly')
      })
      input.addEventListener('blur', (e: any) => {
        e.target.setAttribute('readonly', 'readonly')
      })
    })
  }, 100)
})

const rules = reactive({
  account: [
    { required: true, message: '请输入账号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
  ],
  smsCode: [
    { required: true, message: '请输入验证码', trigger: 'blur' },
    { pattern: /^\d{6}$/, message: '验证码为6位数字', trigger: 'blur' },
  ],
  password: [
    { required: true, message: '请输入新密码', trigger: 'blur' },
    { min: 6, max: 20, message: '密码长度在6到20个字符', trigger: 'blur' },
  ],
  confirmPassword: [
    { required: true, message: '请再次输入新密码', trigger: 'blur' },
    {
      validator: (rule: any, value: string, callback: (error?: Error) => void) => {
        if (value !== params.password) {
          callback(new Error('两次输入密码不一致'))
        } else {
          callback()
        }
      },
      trigger: 'blur',
    },
  ],
})

const buttonText = computed(() => (countdown.value > 0 ? `${countdown.value}秒后重发` : '获取验证码'))
const canSendCode = computed(() => countdown.value === 0 && !isSending.value && !!params.account)

const resetCountdown = () => {
  if (timer) {
    clearInterval(timer)
    timer = null
  }
  countdown.value = 0
}

const startCountdown = () => {
  countdown.value = 60
  timer = setInterval(() => {
    countdown.value--
    if (countdown.value <= 0 && timer) {
      clearInterval(timer)
      timer = null
    }
  }, 1000)
}

onUnmounted(() => {
  resetCountdown()
})

const sendVerificationCode = async () => {
  if (!/^1[3-9]\d{9}$/.test(params.account)) {
    ElMessage.warning('请输入正确的手机号')
    return
  }
  isSending.value = true
  try {
    await sendSmsCode({
      phone: params.account,
      appId: 'wxb3ea2acd5debc43e',
    })
    startCountdown()
    ElMessage.success('验证码已发送')
  } catch (error) {
    ElMessage.error('验证码发送失败,请重试')
  } finally {
    isSending.value = false
  }
}

const resetPassword = async () => {
  try {
    const res = await resetPasswordForMini({
      phone: params.account,
      smsCode: params.smsCode,
      password: params.password,
      confirmPassword: params.confirmPassword,
    })
    if (res.code == 200) {
      ElMessage.success('密码重置成功')
      router.push('/user/login')
    }
  } catch (error) {
    // 错误处理
  }
}

const submitForm = (formEl: FormInstance | undefined) => {
  if (!formEl) return
  formEl.validate((valid) => {
    if (valid) {
      resetPassword()
    }
  })
}
</script>

<template>
  <div class="w-full h-full flex items-center justify-center">
    <div class="bg-white shadow-md rounded-sm p-8" style="width: 380px">
      <div class="mb-8 flex flex-col items-center justify-center gap-3">
        <div class="text-2xl font-medium tracking-widest">忘记密码</div>
        <div class="text-sm text-gray-500 tracking-wider text-center">
          输入绑定手机号,填写验证码,设置并确认新密码,提交即可完成重置
        </div>
      </div>

      <el-form 
        ref="formRef" 
        label-position="top" 
        label-width="auto" 
        size="large" 
        :model="params" 
        :rules="rules" 
        @keyup.enter.stop="submitForm(formRef)"
      >
        <!-- 方案一:隐藏的假输入框 -->
        <div style="width: 0; height: 0; overflow: hidden; position: absolute; z-index: -1;">
          <input type="text" name="fake_username" autocomplete="username" />
          <input type="password" name="fake_password" autocomplete="current-password" />
        </div>

        <el-form-item label="账号" prop="account">
          <el-input 
            v-model="params.account" 
            :prefix-icon="User" 
            style="width: 100%" 
            placeholder="请输入登录账号" 
            name="account" 
            autocomplete="off" 
          />
        </el-form-item>

        <!-- 方案二:欺骗浏览器的密码框 -->
        <input 
          type="password" 
          autocomplete="current-password" 
          style="position: absolute; left: -9999px; top: -9999px; opacity: 0;" 
          tabindex="-1" 
        />

        <el-form-item label="验证码" prop="smsCode">
          <div class="captcha-container">
            <el-input 
              v-model="params.smsCode" 
              class="no-autofill" 
              :prefix-icon="Lock" 
              style="width: 100%" 
              placeholder="请输入您的验证码" 
              autocomplete="one-time-code" 
            />
            <el-button 
              :disabled="!canSendCode" 
              :loading="isSending" 
              @click="sendVerificationCode" 
              class="send-btn"
            >
              {{ buttonText }}
            </el-button>
          </div>
        </el-form-item>

        <el-form-item label="新密码" prop="password">
          <el-input 
            v-model="params.password" 
            class="no-autofill" 
            :prefix-icon="Lock" 
            show-password 
            type="password" 
            style="width: 100%" 
            placeholder="请输入新密码" 
            autocomplete="new-password" 
          />
        </el-form-item>

        <el-form-item label="确认新密码" prop="confirmPassword">
          <el-input
            v-model="params.confirmPassword"
            class="no-autofill"
            :prefix-icon="Lock"
            show-password
            type="password"
            style="width: 100%"
            placeholder="请再次输入新密码"
            autocomplete="new-password" 
          />
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="w-full" @click="submitForm(formRef)">重置</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<style scoped lang="less">
.captcha-container {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.send-btn {
  background-color: #355cde;
  color: #ffffff;
}
</style>

方案总结

方案 原理 兼容性 推荐指数
隐藏假输入框 拦截浏览器自动填充 所有浏览器 ⭐⭐⭐⭐⭐
欺骗密码框 引导浏览器填充到错误位置 所有浏览器 ⭐⭐⭐⭐
readonly 动态切换 利用浏览器不填充 readonly 输入框 Chrome 效果最好 ⭐⭐⭐⭐
autocomplete 属性 精确控制自动填充行为 现代浏览器 ⭐⭐⭐

注意事项

  1. 方案组合使用:单一方案可能无法解决所有浏览器的问题,建议组合使用多种方案
  2. 时机问题 :DOM 操作需要在组件挂载后执行,使用 setTimeout 确保 DOM 渲染完成
  3. Element Plus 兼容性:使用原生 DOM 操作而不是 Vue 的 ref,避免组件内部状态冲突
  4. 用户体验:确保在用户聚焦时能正常输入,不要影响正常的交互流程

结语

浏览器的自动填充机制虽然方便了用户,但也给开发者带来了不少困扰。通过以上几种方案的组合使用,我们可以有效地解决忘记密码页面的自动填充问题,提升用户体验。

如果你有更好的解决方案,欢迎在评论区留言讨论!


本文由 米饭同学i 原创,首发于稀土掘金。如果对你有帮助,欢迎点赞、收藏、转发!

相关推荐
孤舟望月1 小时前
NestJS实战-后端开发-全局配置
前端
陆枫Larry1 小时前
从一个按钮间距,聊透 CSS 的 gap 属性
前端
北冥有鱼1 小时前
mqtt 测试
前端·后端
张鑫旭2 小时前
都AI时代了,我为何还在学习前端基础知识?
前端
swipe2 小时前
正则表达式入门到进阶:从表单校验到手写模板引擎
前端·javascript·面试
阿祖zu2 小时前
别再优化 RAG 了,适配 Agent 的 LLM Wiki 知识库理念
前端·后端·aigc
kyriewen2 小时前
前端错误监控最全指南:捕获 JS 异常、Promise 拒绝、资源加载失败,附上报代码
前端·javascript·监控
狗哥哥3 小时前
船队运营可视化技术方案
前端
大家的林语冰3 小时前
ESLint 近期动态大全,新版本正式发布,antfu 大佬推荐的插件也更新了!
前端·javascript·前端工程化