1. 从"改个密码"说起:为什么这个页面比想象中难做
"修改密码"这四个字,放在任何后台系统里都像是最不起眼的一个功能。产品经理画原型的时候可能只给了一个弹窗,前端拿到需求的第一反应往往是"半小时搞定"。但真做过几个中后台项目的人都知道,这个页面是典型的"看着简单、坑全在细节里"——它同时牵扯到表单校验、接口安全、全局异常处理、加载态管理、路由守卫这几条线,任何一条没处理好,用户就会遇到"点了没反应""报错看不懂""改完密码还能用旧密码登录"这类问题。
这篇内容围绕一个基于 Element UI 的修改密码页面展开,核心要解决三件事:页面本身怎么设计才既安全又好用、加载态怎么管理才不闪烁不卡顿、异常怎么全局捕捉才能让用户看到人话而不是一串堆栈。适合正在做中后台系统的前端开发者,尤其是用 Vue 2 + Element UI 这套经典组合的团队。哪怕你现在用的是 Vue 3 + Element Plus,思路也是通的,只是 API 名字换一换。
我先把结论摆前面:修改密码这个功能,前端能做的安全防护其实很有限,但体验优化和异常兜底的空间非常大。很多人把精力全花在"密码强度正则"上,却忽略了加载态和异常处理,结果就是用户改密码改得一肚子火。下面我按实际开发顺序,把每个环节拆开讲。
2. 修改密码页面的字段设计与校验逻辑
2.1 三个输入框背后的真实需求
一个标准的修改密码表单,通常是三个字段:原密码、新密码、确认新密码。看起来平平无奇,但每个字段的设计都有讲究。
原密码字段的作用不只是"验证身份",它还有一个隐藏价值:防止用户在公共设备上被他人恶意改密。所以这个字段必须要求输入,且不能做任何"记住上次输入"的处理,autocomplete属性要显式设置为off或者new-password,避免浏览器自动填充把旧密码暴露出来。
新密码字段是校验的重灾区。常见的强度规则包括:长度 8-20 位、必须包含大小写字母和数字、不能包含连续字符(如 123456、abcdef)、不能和原密码相同。这里有个经验:规则不要一次性全堆给用户看,而是在输入过程中动态提示。Element UI 的el-form配合自定义validator就能做到,用户每输入一个字符就实时反馈哪条规则还没满足,比提交后弹一堆红字友好得多。
确认密码字段的核心是一致性校验,但要注意一个细节:当用户修改新密码后,确认密码的校验状态需要重新触发。很多人只写了validator却忘了在newPassword变化时手动validateField('confirmPassword'),导致用户改了新密码、确认框还显示"通过",提交时才报错。
2.2 用自定义 validator 把校验写扎实
Element UI 的el-form校验依赖async-validator,内置规则能覆盖大部分场景,但密码强度这种复合规则必须自己写。下面是一段我实际项目里用过的校验逻辑,做了简化:
data() { const validateNewPassword = (rule, value, callback) => { if (!value) { return callback(new Error('请输入新密码')) } if (value === this.form.oldPassword) { return callback(new Error('新密码不能与原密码相同')) } const hasLetter = /[a-zA-Z]/.test(value) const hasNumber = /\d/.test(value) if (!hasLetter || !hasNumber) { return callback(new Error('密码需同时包含字母和数字')) } if (/(.)\1{2,}/.test(value)) { return callback(new Error('密码不能包含三个及以上连续相同字符')) } callback() } const validateConfirm = (rule, value, callback) => { if (value !== this.form.newPassword) { return callback(new Error('两次输入的密码不一致')) } callback() } return { form: { oldPassword: '', newPassword: '', confirmPassword: '' }, rules: { oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }], newPassword: [{ required: true, validator: validateNewPassword, trigger: 'blur' }], confirmPassword: [{ required: true, validator: validateConfirm, trigger: 'blur' }] } } }这里有个容易忽略的点:trigger用blur还是change。密码强度校验建议用blur,因为用户输入过程中频繁触发校验会让界面一直闪红字,体验很差;而确认密码的一致性校验可以用change,让用户改完立刻知道对不对。同一个表单里不同字段用不同 trigger,是提升体验的细节。
2.3 密码可见性切换与输入框的交互细节
Element UI 的el-input支持show-password属性,一行代码就能加上"小眼睛"切换。但要注意,show-password在旧版本里叫show-password,新版本 Element Plus 里也是这个名字,别写成showPassword或者password,否则不生效。
另外,三个密码框建议都加上maxlength="20",防止用户粘贴超长字符串导致后端截断或报错。clearable属性看情况加,原密码框不建议加,避免用户误点清空后还得重新输。
3. 加载态管理:别让用户对着白屏发呆
3.1 页面级加载与按钮级加载的区别
"加载页面"这个需求,很多人第一反应是加个v-loading就完事了。但实际场景里,加载态分两个层级:
页面级加载:进入修改密码页面时,可能需要先拉取一些初始化数据(比如用户的安全问题、密码最后修改时间)。这时候用v-loading盖住整个表单区域是合理的。
按钮级加载:用户点击"确认修改"后,请求发出到返回的这段时间,应该只让提交按钮进入loading状态,而不是整个页面转圈。因为整个页面转圈会让用户觉得"系统卡死了",而按钮转圈传达的是"正在处理你的请求",心理感受完全不同。
Element UI 的el-button自带loading属性,直接绑定一个布尔值即可:
<el-button type="primary" :loading="submitting" @click="handleSubmit" > {{ submitting ? '提交中...' : '确认修改' }} </el-button>注意loading状态下按钮会自动禁用点击,不需要额外写:disabled,否则可能出现样式冲突。
3.2 防止重复提交的三重保险
加载态最核心的作用其实是防止重复提交。用户网络慢的时候会疯狂点按钮,如果不做处理,可能发出五六个请求,后端如果没做幂等,密码就被改了多次,甚至出现"新密码是第几次提交的"这种诡异问题。
我的做法是三重保险:
- 按钮 loading:视觉上禁用,这是第一层。
- 方法入口拦截:在
handleSubmit开头判断if (this.submitting) return,这是第二层,防止键盘回车等绕过按钮的操作。 - 请求取消:用 axios 的
CancelToken或者 AbortController,在组件销毁或新请求发起时取消上一个未完成的请求,这是第三层。
第三层很多人不做,但在用户快速切换页面时特别有用。如果不取消,请求返回后组件已经销毁,回调里访问this可能报错,或者触发一些莫名其妙的全局提示。
3.3 加载态的最小展示时间
有个反直觉的经验:加载态不要一闪而过。如果接口响应特别快(比如内网 50ms 就返回),loading 图标闪一下反而让用户觉得"是不是没点上"。我一般会做一个最小展示时间,比如 300ms,用Promise.all配合一个延时 Promise 实现:
const minDelay = new Promise(resolve => setTimeout(resolve, 300)) const request = axios.post('/api/change-password', this.form) await Promise.all([request, minDelay])这样无论接口多快,loading 至少显示 300ms,视觉上更稳定。当然这个值别设太大,超过 500ms 用户就会觉得慢了。
4. 全局异常捕捉:让报错说人话
4.1 为什么需要全局异常处理
修改密码接口可能返回各种错误:原密码错误、新密码不符合后端规则、账号被锁定、token 过期、服务器 500。如果每个错误都在catch里写一遍this.$message.error(err.message),代码会又臭又长,而且不同开发者写的提示风格不统一,有的显示"请求失败",有的显示"Error: Request failed with status code 400",用户看了完全懵。
全局异常捕捉的价值就在于:统一拦截、统一转换、统一展示。前端拿到错误后,先判断错误类型,再决定是弹全局提示、跳登录页,还是交给业务代码自己处理。
4.2 axios 拦截器里的错误分类处理
我一般会在 axios 的响应拦截器里做第一层处理,按 HTTP 状态码和业务码分类:
axios.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { // 业务错误,交给全局提示 handleBusinessError(res) return Promise.reject(res) } return res }, error => { if (error.response) { const status = error.response.status if (status === 401) { // token 失效,跳登录 store.dispatch('user/logout') router.push('/login') Message.error('登录已过期,请重新登录') } else if (status === 403) { Message.error('没有权限执行此操作') } else if (status >= 500) { Message.error('服务器开小差了,请稍后重试') } else { Message.error(error.response.data.message || '请求失败') } } else if (error.code === 'ECONNABORTED') { Message.error('请求超时,请检查网络') } else { Message.error('网络异常,请稍后重试') } return Promise.reject(error) } )这里的关键是区分"能自动处理的错误"和"需要业务代码处理的错误"。比如 401 直接跳登录,业务代码不用管;但"原密码错误"这种业务错误,可能需要把焦点定位到原密码输入框,这就得让业务代码自己处理。所以拦截器里处理完通用错误后,仍然要reject,让业务层的catch有机会介入。
4.3 业务错误的提示策略
修改密码接口最常见的业务错误就是"原密码错误"。这个提示不能只弹一个 toast,因为用户很可能需要重新输入。我的做法是:toast 提示 + 清空原密码框 + 聚焦。
async handleSubmit() { if (this.submitting) return try { await this.$refs.form.validate() } catch (e) { return } this.submitting = true try { await changePassword(this.form) this.$message.success('密码修改成功,请重新登录') setTimeout(() => { this.$router.push('/login') }, 1000) } catch (err) { if (err.code === 'OLD_PASSWORD_WRONG') { this.form.oldPassword = '' this.$refs.oldPasswordInput.focus() } } finally { this.submitting = false } }注意finally里一定要把submitting置回 false,否则一旦出错按钮就永远转圈了。这是新手最容易犯的错误之一。
4.4 Vue 全局错误捕获兜底
除了接口错误,还有一类错误是代码运行时错误,比如某个组件里访问了 undefined 的属性。这类错误如果不捕获,整个页面可能白屏。Vue 提供了Vue.config.errorHandler做全局兜底:
Vue.config.errorHandler = function (err, vm, info) { console.error('全局捕获:', err, info) // 上报到监控平台 reportError(err, info) // 给用户一个友好提示,避免白屏 Message.error('页面出现异常,请刷新重试') }这个兜底不是万能的,它捕获不了 Promise 里未 catch 的错误,所以还需要监听unhandledrejection:
window.addEventListener('unhandledrejection', event => { console.error('未处理的 Promise 错误:', event.reason) reportError(event.reason) })两层加起来,基本能覆盖绝大多数异常场景。
5. 接口安全与前端能做的防护边界
5.1 前端加密到底有没有用
很多项目要求"密码传输前加密",常见做法是用 RSA 或 AES 在前端加密后再发给后端。这里必须说清楚:前端加密不能替代 HTTPS。如果没上 HTTPS,中间人可以直接抓包拿到加密后的密文,虽然不能直接还原明文,但可以重放攻击。
前端加密的真正价值是防止日志和监控系统记录明文密码。很多公司的接口日志会把请求体打出来,如果不加密,运维和开发就能在日志里看到用户密码,这是合规大忌。所以前端加密更多是"防内部泄露",而不是"防外部攻击"。
如果要做,推荐用 RSA 公钥加密,公钥由后端下发,私钥只在后端保存。前端用jsencrypt这类库即可,但要注意公钥的获取时机和缓存策略,别每次请求都去拉公钥。
5.2 修改成功后的强制下线
密码修改成功后,必须让当前 token 失效并强制重新登录。原因很简单:如果旧 token 还能用,那攻击者拿到旧 token 依然能操作,改密码就失去了意义。
实现上有两种方式:一是后端在改密成功后主动使该用户所有 token 失效,前端收到成功响应后跳登录页;二是前端在成功后主动调登出接口。推荐第一种,因为更安全,前端只需要负责跳转。
跳转前给用户一个明确的提示,比如"密码修改成功,即将重新登录",然后延迟 1-2 秒跳转,让用户有时间看到提示。直接瞬间跳转会让用户困惑"我刚才到底改成功没有"。
5.3 密码字段的自动填充与浏览器记忆
浏览器对type="password"的输入框有自动填充和"记住密码"的行为,这在修改密码场景里是负面的。除了设置autocomplete="new-password",还可以用一些技巧:
- 把原密码框的
name属性设成一个不常见的值,避免浏览器识别为登录表单。 - 在表单外层包一个
autocomplete="off"的 form。 - 如果还是被填充,可以在
mounted里延迟清空一次。
这些手段不能 100% 阻止浏览器行为,但能大幅降低误填充概率。
6. 踩坑实录:那些文档里不会写的细节
6.1 el-form 校验的异步陷阱
this.$refs.form.validate()返回的是一个 Promise,但如果你传了回调函数,它就不返回 Promise 了。很多人混用两种写法,导致await拿不到结果:
// 错误写法:传了回调,await 拿到的是 undefined await this.$refs.form.validate((valid) => { ... }) // 正确写法一:不传回调,用 try/catch try { await this.$refs.form.validate() } catch (e) { return } // 正确写法二:传回调,不用 await this.$refs.form.validate((valid) => { if (valid) { this.submit() } })这个坑我见过至少五个同事踩过,表现就是"校验没通过但请求还是发出去了"。
6.2 loading 与路由跳转的时序问题
改密成功后跳登录页,如果跳转太快,loading 状态还没重置,登录页可能继承一些奇怪的全局状态。我的做法是先重置 loading,再跳转,并且用setTimeout给一个缓冲:
this.submitting = false this.$message.success('修改成功') setTimeout(() => this.$router.push('/login'), 800)另外,如果用了 keep-alive 缓存了这个页面,跳转前记得把表单重置,否则用户下次进来还看到上次的密码(虽然密码框显示的是圆点,但值还在)。
6.3 全局异常处理与业务提示的冲突
有时候全局拦截器已经弹了一个错误提示,业务代码的catch里又弹了一个,用户就看到两个 toast 叠在一起。解决办法是在错误对象上打标记:
// 拦截器里 error.handled = true Message.error('...') // 业务层 catch (err) { if (!err.handled) { Message.error(err.message) } }这样全局处理过的错误,业务层就不再重复提示。这个模式在大型项目里几乎是标配。
6.4 密码强度提示的实时性
前面提到用blur触发校验,但强度提示(比如"还需包含数字")最好用input事件实时更新,而不是等失焦。可以单独维护一个strengthTips数组,在input时计算:
computed: { strengthTips() { const pwd = this.form.newPassword return [ { text: '至少8位', ok: pwd.length >= 8 }, { text: '包含字母', ok: /[a-zA-Z]/.test(pwd) }, { text: '包含数字', ok: /\d/.test(pwd) } ] } }然后在模板里用不同颜色渲染,用户输入时就能看到哪条变绿了,体验比提交后报错好太多。
7. 一套可复用的实现骨架
把上面的内容串起来,一个完整的修改密码页面大致包含这些部分:表单结构、校验规则、加载态管理、提交逻辑、异常处理、成功后的跳转。我把它整理成一个对照表,方便你按图索骥:
| 模块 | 关键点 | 常见坑 |
|---|---|---|
| 表单字段 | 原密码、新密码、确认密码 | 忘记关闭自动填充 |
| 校验规则 | 自定义 validator、动态 trigger | 改新密码后未重校验确认框 |
| 加载态 | 按钮级 loading、最小展示时间 | 出错后未重置 loading |
| 防重复提交 | 入口拦截 + 请求取消 | 只靠按钮禁用,回车可绕过 |
| 全局异常 | 拦截器分类处理、错误标记 | 全局与业务重复弹提示 |
| 成功后处理 | 强制下线、延迟跳转 | 跳转过快、状态未重置 |
这套骨架我在几个中后台项目里反复用过,基本能覆盖 90% 的修改密码场景。剩下的 10% 是各家的特殊需求,比如有的要求改密后发邮件通知,有的要求二次验证,那些就在这个骨架上加钩子即可。
最后分享一个我自己的习惯:修改密码页面写完,一定要用慢网络模拟测一遍。Chrome DevTools 里把网络调到 Slow 3G,然后疯狂点提交按钮,看看会不会重复请求、loading 会不会卡死、错误提示会不会叠。这个测试花不了五分钟,但能提前发现大部分加载态和异常处理的问题。比等到测试同学提 bug 再改,成本低得多。