Vue3登录页入门实战指南
Vue3登录页入门实战指南
从逐行拆解到完整实现:Vue3 登录页入门实战指南
本文从一个真实的登录页代码出发,逐行拆解每一个概念,带你搞懂 Vue3 + Element Plus + Pinia 实现登录功能的完整链路。适合刚接触 Vue3 的初学者阅读。
前言
登录页几乎是每一个前端项目的第一站。但很多初学者对着一整段登录代码只会"复制粘贴能用就行",真正问起来:formRef 哪来的?Pinia 是数据库吗?provider 放哪了?往往一头雾水。
这篇文章就从一段真实的 Vue3 登录页代码入手,把每一个变量、每一行逻辑、每一个概念都拆解开,让你不仅会用,还能讲明白为什么这么写。
一、整体技术栈一览
我们要拆解的登录页,用到了这些技术:
| 技术 | 作用 |
|---|---|
Vue3 <script setup> |
组合式 API 写法,代码更简洁 |
| Element Plus | UI 组件库,提供表单、按钮、消息提示 |
| Pinia | 全局状态管理,存登录状态、用户信息 |
| Vue Router | 路由跳转,登录成功跳首页 |
| Axios | 网络请求,统一封装拦截器 |
| OAuth 2.0 | 第三方登录(GitHub / QQ / 微信等) |
二、表单校验:rules 和 formRef 是怎么工作的?
2.1 先看代码
import { ref, reactive } from 'vue'
const formRef = ref(null)
const form = reactive({
email: '',
password: ''
})
const rules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
]
}
模板里:
<el-form ref="formRef" :model="form" :rules="rules">
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" />
</el-form-item>
</el-form>
2.2 formRef 是哪来的?
很多初学者看到 formRef.value.validate() 第一反应就是:这变量哪冒出来的?
答案分三步:
第一步:script 里手动声明
const formRef = ref(null)
先创建一个空的 ref 变量,此时 formRef.value = null,还没绑定任何东西。
第二步:模板里用 ref 属性绑定
<el-form ref="formRef" ...>
Vue 会自动把这个 el-form 组件的实例,赋值给同名的 formRef.value。
第三步:JS 里通过 .value 调用组件方法
formRef.value.validate() // 触发表单校验
formRef.value.clearValidate() // 清空校验提示
formRef.value.resetFields() // 重置表单
💡 记住:模板里
ref="xxx",script 里就用const xxx = ref(null)来接,这是 Vue3 获取 DOM / 组件实例的标准写法。
2.3 rules 校验规则怎么读?
rules 是一个对象,基于 async-validator 库实现,Element Plus 内置了,不用额外装。
结构是这样的:
rules
├── email (字段名,必须和 form 里的属性、prop 一致)
│ ├── 规则1:必填
│ └── 规则2:邮箱格式
└── password
└── 规则1:必填
每个规则对象里常用的字段:
| 字段 | 含义 | 示例 |
|---|---|---|
required |
是否必填 | true / false |
message |
校验失败的提示文字 | '请输入邮箱' |
trigger |
触发时机 | 'blur' 失焦 / 'change' 输入时 |
type |
内置类型校验 | 'email' 邮箱 / 'url' 链接 |
min / max |
长度范围 | min: 6, max: 16 |
pattern |
正则校验 | /^1[3-9]\d{9}$/ 手机号 |
2.4 常见踩坑
-
prop 和 rules 的 key 名字对不上 → 校验完全不触发,没反应
-
**忘记给 el-form 绑定 **
:rules="rules"→ 写了规则也不生效 -
trigger 拼错(比如写成
blue)→ 失去焦点不校验 -
规则顺序写反 → 应该先必填、后格式,不然空值也会报格式错误
三、账号密码登录:handleLogin 完整流程拆解
3.1 代码全貌
async function handleLogin() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.login(form.email, form.password)
ElMessage.success('登录成功')
router.push('/')
} catch {
// 错误已在 request 拦截器中处理
} finally {
loading.value = false
}
}
3.2 逐行拆解
第 1 行:****async function handleLogin()
因为里面有异步操作(表单校验、发请求),必须加 async 才能用 await 等待结果。
第 2 行:表单校验的简写写法
const valid = await formRef.value.validate().catch(() => false)
Element Plus 的 validate() 返回一个 Promise:
-
校验全部通过 → Promise 成功,返回
true -
有任何一项不通过 → Promise 直接 reject,抛出错误
.catch(() => false) 的作用就是:校验失败时,不抛异常,直接返回 false。
等价的完整写法:
let valid
try {
valid = await formRef.value.validate()
} catch {
valid = false
}
第 3 行:校验不通过直接走人
if (!valid) return
不往下执行发请求的逻辑。
第 5 行:开 loading
loading.value = true
一般按钮上会绑定 :loading="loading",防止用户重复点。
第 6-10 行:try 块,正常登录流程
try {
await authStore.login(form.email, form.password) // 调登录接口
ElMessage.success('登录成功') // 弹成功提示
router.push('/') // 跳首页
}
这里调用的是 Pinia 仓库里的 login 方法,不是直接写 axios 请求,后面会细讲 Pinia。
第 11-13 行:catch 块,捕获报错
catch {
// 错误已在 request 拦截器中处理
}
为什么这里空的?因为项目一般会在 axios 的响应拦截器里统一处理错误:
-
401 未授权 → 弹"登录已过期"
-
500 服务器错误 → 弹"服务器开小差了"
-
账号密码错误 → 弹后端返回的 message
统一处理后,每个页面就不用重复写 ElMessage.error(...) 了,代码更干净。
第 14-16 行:finally 块,必执行的兜底
finally {
loading.value = false
}
finally 的特点是:不管 try 成功还是 catch 报错,最后一定会执行。
这很重要——如果登录接口报错了,没有 finally 的话,loading 就一直是 true,按钮永远转圈卡死。
3.3 完整执行流程图
点击登录
↓
表单校验 ──失败──→ return,不发请求
↓ 成功
开 loading
↓
调登录接口 ──成功──→ 弹提示 + 跳首页 ──┐
↓ 失败 ↓
拦截器弹错误提示 ──────────────────────→ 关 loading(finally 兜底)
四、第三方登录:OAuth 是怎么跑通的?
4.1 先看代码全貌
import { ref, onMounted } from 'vue'
import { getAuthorizeUrl, getOAuthProviders } from '@/api/oauth'
const oauthProviders = ref([])
const oauthConfigured = ref({})
// 图标映射
const providerIcons = {
github: '🐙',
google: '🔵',
wechat_open: '💚',
qq: '🐧',
weibo: '🔴'
}
// 名称映射
const providerLabels = {
github: 'GitHub',
google: 'Google',
wechat_open: '微信',
qq: 'QQ',
weibo: '微博'
}
// 页面加载时拉取支持的渠道
onMounted(async () => {
try {
const data = await getOAuthProviders()
oauthProviders.value = data.map(d => d.provider)
data.forEach(d => {
oauthConfigured.value[d.provider] = d.configured
})
} catch {}
})
// 点击第三方登录按钮
async function handleOAuth(provider) {
if (!oauthConfigured.value[provider]) {
ElMessage.warning(providerLabels[provider] + ' 登录尚未配置')
return
}
try {
const data = await getAuthorizeUrl(provider)
window.location.href = data.authorizeUrl
} catch {
ElMessage.error('获取授权失败')
}
}
模板里:
<div v-if="oauthProviders.length > 0">
<p>其他方式登录</p>
<div>
<span
v-for="provider in oauthProviders"
:key="provider"
@click="handleOAuth(provider)"
>
{{ providerIcons[provider] }} {{ providerLabels[provider] }}
</span>
</div>
</div>
4.2 初学者最容易懵的几个问题
问题一:provider 放哪了?d 是哪来的?
先看后端返回的数据结构:
// 后端返回的 data 是一个对象数组
[
{ provider: "github", configured: true },
{ provider: "qq", configured: true },
{ provider: "google", configured: false }
]
然后看这行:
oauthProviders.value = data.map(d => d.provider)
map 是数组的遍历方法,会循环数组里的每一项。d 只是给每一项起的临时名字,你换成 item、row、随便什么都行。
等价写法:
oauthProviders.value = data.map((item) => {
return item.provider
})
执行完之后,oauthProviders 就变成了纯字符串数组:
["github", "qq", "google"]
💡 一句话:原始是「对象数组」,map 之后变成了「字符串数组」,只提取我们需要的 provider 标识。
问题二:provider 和 providerIcons 名字不一样,怎么匹配上的?
很多人会混淆:provider 是变量名,providerIcons 是对象名,这俩根本不是一类东西,怎么比?
真正匹配的是「里面的值」,不是外层变量名。
举个例子:
-
循环里
provider这个变量存的值是字符串"github" -
providerIcons对象里有一个 key 叫"github",对应值是'🐙'
然后模板里写:
providerIcons[provider]
等价于:
providerIcons["github"]
结果就是 🐙。
💡 记住:变量名只是容器,真正匹配的是容器里装的字符串内容,和对象的 key 文字完全一致就能取到值。
问题三:后端新增一个渠道,前端要改很多吗?
不用。后端接口多返回一条 { provider: "gitee", configured: true },前端只需要在两个映射对象里加一行:
const providerIcons = {
// ... 原有
gitee: '📦'
}
const providerLabels = {
// ... 原有
gitee: 'Gitee'
}
模板循环不用改,刷新页面自动多出 Gitee 按钮。这就是数据驱动的好处。
4.3 OAuth 完整授权流程
第三方登录走的是 OAuth 2.0 授权码模式,完整链路是这样的:
1. 页面加载 → 请求后端拿支持的渠道列表
2. 用户点 GitHub 按钮
3. 前端请求后端 → 后端生成授权地址(带 state、redirect_uri)
4. 前端跳转到 GitHub 授权页
5. 用户点「同意授权」
6. GitHub 回调后端的 /oauth/callback?code=xxx
7. 后端用 code 换 access_token,创建/绑定系统账号
8. 后端生成业务 token,带着跳回前端
9. 前端存 token,登录完成,跳首页
为什么授权地址要后端生成,不直接前端写死?
-
更安全:
client_secret不能暴露在前端 -
统一管理:
redirect_uri、state(防 CSRF)都由后端控制 -
方便切换:后端改配置就行,前端不用发版
五、游客登录:免登入口是怎么实现的?
5.1 代码
async function handleGuest() {
try {
await authStore.enterAsGuest()
router.push('/')
} catch {
// 错误已在 request 拦截器中处理
}
}
5.2 做了什么?
很简单,三步:
-
调用 Pinia 里的
enterAsGuest方法,请求后端生成临时游客 token -
token 存进全局状态,整个网站识别当前是游客身份
-
成功就跳首页,报错由拦截器统一处理
5.3 和正式账号的区别
| 对比项 | 正式账号登录 | 游客登录 |
|---|---|---|
| 身份 | 绑定真实用户 | 临时匿名身份 |
| 权限 | 完整功能 | 受限(不能评论、下单等) |
| Token 时效 | 较长 | 短期有效 |
| 数据持久化 | 存在用户账号里 | 退出/过期就没了 |
六、状态管理:Pinia 到底是什么?
6.1 一句话讲明白
Pinia 是 Vue3 官方推荐的全局状态管理库,用来存跨页面、跨组件共用的数据。
它不是数据库,也不是什么神秘的东西,就是一个全局公共的内存仓库。
6.2 和组件内部数据的区别
| 数据类型 | 作用范围 | 刷新页面 |
|---|---|---|
组件内 ref/reactive |
只有当前组件能用 | 清空 |
| Pinia | 整个网站所有页面、组件都能用 | 默认清空 |
| Pinia + 持久化插件 | 整个网站都能用 | 保留(存在 localStorage) |
6.3 很多人问:Pinia 相当于数据库吗?
完全不是。 区别很大:
-
数据库(MySQL 等):存在后端服务器硬盘上,永久保存,所有用户共用
-
Pinia:存在浏览器内存里,一关页面就没,只给当前用户当前页面用
-
localStorage:存在浏览器本地硬盘,刷新不丢,但只是小容量缓存,明文存储
💡 通俗类比:
-
后端数据库 = 公司档案室(永久存所有人的资料)
-
localStorage = 你桌面的记事本(本机存点小东西,只有你能看)
-
Pinia = 电脑的内存剪贴板(各个软件共用,关机就没,但存取极快)
6.4 Pinia 的三大核心
每个 Store(仓库)只有三块内容,比 Vuex 简单多了:
1. state:存数据
state: () => ({
token: '',
user: null
})
2. getters:计算属性(可选)
基于 state 派生出来的数据,类似 computed。
3. actions:方法
同步、异步都写在这里,不用像 Vuex 那样区分 mutations 和 actions。
actions: {
async login(email, password) {
const res = await loginApi({ email, password })
this.token = res.token
this.user = res.user
localStorage.setItem('token', res.token)
},
async enterAsGuest() {
const res = await guestLoginApi()
this.token = res.token
}
}
6.5 完整的 auth 仓库示例
// stores/auth.js
import { defineStore } from 'pinia'
import { loginApi, guestLoginApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: '',
user: null
}),
actions: {
// 账号密码登录
async login(email, password) {
const res = await loginApi({ email, password })
this.token = res.token
this.user = res.user
localStorage.setItem('token', res.token)
},
// 游客登录
async enterAsGuest() {
const res = await guestLoginApi()
this.token = res.token
localStorage.setItem('token', res.token)
},
// 退出登录
logout() {
this.token = ''
this.user = null
localStorage.removeItem('token')
}
},
persist: true // 持久化插件,刷新不丢
})
6.6 页面里怎么用?
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
// 读数据
console.log(authStore.token)
console.log(authStore.user)
// 调方法
await authStore.login(email, password)
6.7 另一个常见问题:不同用户的 Pinia 不一样吗?
分情况:
-
同一浏览器同一标签页,切换账号 → Pinia 数据会被覆盖,永远只存当前登录用户
-
同一浏览器两个标签页,开了持久化 → 共用 localStorage,一个切账号另一个也会变
-
同一浏览器两个标签页,没开持久化 → 各自独立,可以登不同账号,但刷新就丢
-
不同浏览器 / 无痕窗口 / 不同设备 → 完全隔离,互不影响
Pinia 本身没有「多用户分区」的能力,同一运行环境同一时间只能存一套数据。
七、常见踩坑与优化建议
7.1 常见踩坑
| 坑 | 现象 | 解决 |
|---|---|---|
| prop 和 rules 对不上 | 校验不触发 | 检查字段名完全一致 |
| 忘记绑定 :rules | 写了规则没用 | el-form 上加上 :rules=“rules” |
| 没有 finally 关 loading | 接口报错后按钮一直转 | 用 finally 兜底 |
| OAuth 回调页没做 | 授权完回不来 | 单独建一个 callback 路由页 |
| Pinia 没持久化 | 刷新就退出登录 | 加 persist 插件 |
7.2 优化建议
1. 登录成功后跳回来源页,不要固定跳首页
// 从路由 query 里拿 redirect,没有就跳首页
router.push(route.query.redirect || '/')
2. 回车提交登录
<el-input @keyup.enter="handleLogin" />
3. OAuth 加 state 防 CSRF
后端生成 state 参数,前端存 sessionStorage,回调时比对,防止跨站伪造授权。
4. 按钮防抖
防止用户短时间疯狂点击,重复发请求。
5. 加 TypeScript 类型
给表单、用户信息、OAuth 渠道都定义类型,开发体验更好。
八、完整代码汇总
8.1 Login.vue
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { getAuthorizeUrl, getOAuthProviders } from '@/api/oauth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 表单相关
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
email: '',
password: ''
})
const rules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 16, message: '密码6-16位', trigger: 'blur' }
]
}
// 第三方登录相关
const oauthProviders = ref([])
const oauthConfigured = ref({})
const providerIcons = {
github: '🐙',
google: '🔵',
wechat_open: '💚',
qq: '🐧',
weibo: '🔴'
}
const providerLabels = {
github: 'GitHub',
google: 'Google',
wechat_open: '微信',
qq: 'QQ',
weibo: '微博'
}
onMounted(async () => {
try {
const data = await getOAuthProviders()
oauthProviders.value = data.map(d => d.provider)
data.forEach(d => {
oauthConfigured.value[d.provider] = d.configured
})
} catch {}
})
// 账号密码登录
async function handleLogin() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.login(form.email, form.password)
ElMessage.success('登录成功')
router.push(route.query.redirect || '/')
} catch {
// 错误由拦截器统一处理
} finally {
loading.value = false
}
}
// 第三方登录
async function handleOAuth(provider) {
if (!oauthConfigured.value[provider]) {
ElMessage.warning(providerLabels[provider] + ' 登录尚未配置')
return
}
try {
const data = await getAuthorizeUrl(provider)
window.location.href = data.authorizeUrl
} catch {
ElMessage.error('获取授权失败')
}
}
// 游客登录
async function handleGuest() {
try {
await authStore.enterAsGuest()
router.push('/')
} catch {}
}
</script>
<template>
<div class="login-container">
<el-card class="login-card">
<h2 class="login-title">欢迎登录</h2>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0">
<el-form-item prop="email">
<el-input
v-model="form.email"
placeholder="请输入邮箱"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
block
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<div v-if="oauthProviders.length > 0" class="oauth-section">
<div class="divider"><span>其他登录方式</span></div>
<div class="oauth-buttons">
<div
v-for="provider in oauthProviders"
:key="provider"
class="oauth-btn"
:class="{ disabled: !oauthConfigured[provider] }"
@click="handleOAuth(provider)"
>
<span class="icon">{{ providerIcons[provider] }}</span>
<span class="label">{{ providerLabels[provider] }}</span>
</div>
</div>
</div>
<div class="guest-section">
<el-button text @click="handleGuest">游客访问 →</el-button>
</div>
</el-card>
</div>
</template>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
}
.login-card {
width: 400px;
padding: 20px;
}
.login-title {
text-align: center;
margin-bottom: 30px;
}
.oauth-section {
margin-top: 20px;
}
.divider {
text-align: center;
color: #999;
font-size: 12px;
margin-bottom: 15px;
position: relative;
}
.divider::before,
.divider::after {
content: '';
position: absolute;
top: 50%;
width: 35%;
height: 1px;
background: #eee;
}
.divider::before { left: 0; }
.divider::after { right: 0; }
.oauth-buttons {
display: flex;
justify-content: center;
gap: 20px;
}
.oauth-btn {
cursor: pointer;
text-align: center;
font-size: 12px;
color: #666;
}
.oauth-btn .icon {
font-size: 28px;
display: block;
margin-bottom: 4px;
}
.oauth-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.guest-section {
text-align: right;
margin-top: 10px;
}
</style>
8.2 stores/auth.js
import { defineStore } from 'pinia'
import { loginApi, guestLoginApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: '',
user: null
}),
actions: {
async login(email, password) {
const res = await loginApi({ email, password })
this.token = res.token
this.user = res.user
localStorage.setItem('token', res.token)
},
async enterAsGuest() {
const res = await guestLoginApi()
this.token = res.token
localStorage.setItem('token', res.token)
},
logout() {
this.token = ''
this.user = null
localStorage.removeItem('token')
}
},
persist: true
})
8.3 api/oauth.js
import request from '@/utils/request'
// 获取支持的第三方登录渠道
export function getOAuthProviders() {
return request.get('/oauth/providers')
}
// 获取授权跳转地址
export function getAuthorizeUrl(provider) {
return request.get(`/oauth/authorize/${provider}`)
}
九、写在最后
登录页虽然看起来简单,但里面涉及的知识点其实不少:
-
Vue3 的 ref 绑定组件实例
-
Element Plus 表单校验的底层机制
-
Promise 的 try/catch/finally 用法
-
OAuth 2.0 的完整授权流程
-
Pinia 全局状态管理的设计思想
把这些东西一个个拆透、搞懂,你就不只是"会写登录页",而是真正理解了 Vue3 项目里数据是怎么流转的。这对你后面做更复杂的业务,帮助会很大。
希望这篇文章对你有帮助。如果有哪里没讲清楚,欢迎留言交流。
本文基于 Vue 3 + Element Plus + Pinia 技术栈编写,适用于前端初学者入门参考。
更多推荐


所有评论(0)