问题背景
在开发忘记密码功能时,相信很多前端开发者都遇到过这样的困扰:用户在忘记密码页面输入手机号后,浏览器会自动将保存的账号密码填充到页面的输入框中,导致验证码输入框被填充了密码、密码输入框被错误回显等问题,严重影响用户体验。
问题复现
假设你的忘记密码页面有以下输入框:
- 账号(手机号)
- 验证码
- 新密码
- 确认新密码
浏览器会出现以下自动填充行为:
- 账号输入框被自动填充了保存的用户名
- 验证码输入框被自动填充了保存的密码(因为浏览器认为这是第二个输入框)
- 新密码和确认密码输入框也可能被错误填充
原因分析
浏览器的自动填充机制基于以下规则:
- 基于输入框类型 :
type="text"被认为是用户名,type="password"被认为是密码 - 基于表单顺序:浏览器会按照页面中输入框出现的顺序进行填充
- 基于 name 属性 :
name="username"、name="password"会触发自动填充 - 基于 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 属性 | 精确控制自动填充行为 | 现代浏览器 | ⭐⭐⭐ |
注意事项
- 方案组合使用:单一方案可能无法解决所有浏览器的问题,建议组合使用多种方案
- 时机问题 :DOM 操作需要在组件挂载后执行,使用
setTimeout确保 DOM 渲染完成 - Element Plus 兼容性:使用原生 DOM 操作而不是 Vue 的 ref,避免组件内部状态冲突
- 用户体验:确保在用户聚焦时能正常输入,不要影响正常的交互流程
结语
浏览器的自动填充机制虽然方便了用户,但也给开发者带来了不少困扰。通过以上几种方案的组合使用,我们可以有效地解决忘记密码页面的自动填充问题,提升用户体验。
如果你有更好的解决方案,欢迎在评论区留言讨论!
本文由 米饭同学i 原创,首发于稀土掘金。如果对你有帮助,欢迎点赞、收藏、转发!