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 常见踩坑

  1. prop 和 rules 的 key 名字对不上 → 校验完全不触发,没反应

  2. **忘记给 el-form 绑定 **:rules="rules" → 写了规则也不生效

  3. trigger 拼错(比如写成 blue)→ 失去焦点不校验

  4. 规则顺序写反 → 应该先必填、后格式,不然空值也会报格式错误


三、账号密码登录: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 只是给每一项起的临时名字,你换成 itemrow、随便什么都行。

等价写法:

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_uristate(防 CSRF)都由后端控制

  • 方便切换:后端改配置就行,前端不用发版


五、游客登录:免登入口是怎么实现的?

5.1 代码

async function handleGuest() {
  try {
    await authStore.enterAsGuest()
    router.push('/')
  } catch {
    // 错误已在 request 拦截器中处理
  }
}

5.2 做了什么?

很简单,三步:

  1. 调用 Pinia 里的 enterAsGuest 方法,请求后端生成临时游客 token

  2. token 存进全局状态,整个网站识别当前是游客身份

  3. 成功就跳首页,报错由拦截器统一处理

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 技术栈编写,适用于前端初学者入门参考。

更多推荐