Vue3 + Pinia 实现前端登录系统:从0到1的踩坑记录

34 阅读4分钟

前言

大家好!我是小凹。最近在做一个校园论坛项目,需要实现用户登录、注册功能。为了快速验证,我选择先用前端模拟的方式(localStorage + Pinia)把整个流程跑通,后续再接入真实后端。本文记录了这个过程中的技术选型、实现步骤以及遇到的各种“坑”,希望能给同样在路上的前端初学者一些帮助。

技术栈

  • Vue 3(Composition API)
  • Pinia(状态管理)
  • Vue Router(路由守卫)
  • localStorage(数据持久化)

功能需求

  1. 注册:学号(10位数字)、密码(字母+数字,8-16位)、姓名(非空),学号唯一性校验。
  2. 登录:学号 + 密码,验证成功后保存用户状态。
  3. 退出登录。
  4. 未登录时无法访问需要认证的页面(路由守卫)。

项目结构(仅展示关键部分)

src/
├── stores/
│   └── user.js          // Pinia store
├── views/
│   ├── Login.vue
│   └── Register.vue
├── router/
│   └── index.js         // 路由守卫
└── App.vue              // 导航栏登录/退出按钮

实现步骤

1. 创建 Pinia Store

在 stores/user.js 中定义用户状态、登录/注册/退出方法。

import { ref, watch } from 'vue'

export const useUserStore = defineStore('user', () => {
  const USER_KEY = 'forum-users'
  const CURRENT_USER_KEY = 'forum-current-user'

  const defaultUsers = [
    { sno: '6020240921', password: 'huhanyu62', name: '胡涵钰' },
    { sno: '6020240950', password: 'congjiu55', name: '冯语涵' }
  ]

  // 初始化用户列表
  let saved = null
  try {
    saved = JSON.parse(localStorage.getItem(USER_KEY))
  } catch (e) {
    console.warn(e)
  }
  const users = ref(saved || defaultUsers)

  // 自动持久化用户列表
  watch(users, (newUsers) => {
    localStorage.setItem(USER_KEY, JSON.stringify(newUsers))
  }, { deep: true })

  // 当前登录用户
  const currentUser = ref(null)
  const savedCurrent = localStorage.getItem(CURRENT_USER_KEY)
  if (savedCurrent) {
    currentUser.value = JSON.parse(savedCurrent)
  }
  watch(currentUser, (newUser) => {
    if (newUser) {
      localStorage.setItem(CURRENT_USER_KEY, JSON.stringify(newUser))
    } else {
      localStorage.removeItem(CURRENT_USER_KEY)
    }
  })

  // 登录
  function userLogin(snoId, numId) {
    const found = users.value.find(u => u.sno === snoId)
    if (!found) return { success: false, msg: '学号不存在!' }
    if (found.password !== numId) return { success: false, msg: '密码不正确!' }
    currentUser.value = { sno: found.sno, name: found.name }
    return { success: true, msg: '' }
  }

  // 注册
  function addUser(nameId, snoId, numId) {
 users.value.push({ sno: snoId, password: numId, name: nameId })
  }

  // 退出
  function logout() {
    currentUser.value = null
  }

  return { users, currentUser, userLogin, logout, addUser }
})

2. 注册页面(Register.vue)

关键点

  • 表单使用 @submit.prevent 阻止默认提交(避免页面刷新)。
  • 正则验证:学号10位数字,密码字母+数字组合,长度8-16。
  • 注册前检查学号是否已被注册。
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { ref } from 'vue'

const router = useRouter()
const userStore = useUserStore()
const nameId = ref('')
const snoId = ref('')
const numId = ref('')

function validateSno(s) {
  return /^\d{10}$/.test(s)
}
function validatePassword(p) {
  return /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,16}$/.test(p)
}

function handleRegister() {
  if (!validateSno(snoId.value)) {
    alert('学号必须是10位数字')
    return
  }
  if (!validatePassword(numId.value)) {
    alert('密码必须包含字母和数字,长度8-16位')
    return
  }
  // 注意:组件中访问 store 的 state 不需要 .value
  if (userStore.users.some(u => u.sno === snoId.value)) {
    alert('该学号已被注册!')
    return
  }
  userStore.addUser(nameId.value, snoId.value, numId.value)
  alert('注册成功!请重新登录!')
  router.push('/Login')
}
</script>

<template>
  <div class="Registerarea">
    <h2>请完成注册</h2>
    <div class="Register_form">
      <form @submit.prevent="handleRegister">
        <ul>
          <li><label>姓名:</label><input type="text" v-model="nameId" /></li>
          <li><label>学号:</label><input type="text" v-model="snoId" /></li>
          <li><label>密码:</label><input type="text" v-model="numId" /></li>
          <li><button type="submit">确认注册</button></li>
        </ul>
      </form>
    </div>
  </div>
</template>

3. 登录页面(Login.vue)

登录时只做非空校验,具体错误由 store 返回。

import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { ref } from 'vue'

const router = useRouter()
const userStore = useUserStore()
const snoId = ref('')
const numId = ref('')

function handleLogin() {
  if (!snoId.value || !numId.value) {
    alert('学号和密码不能为空')
    return
  }
  const result = userStore.userLogin(snoId.value, numId.value)
  if (result.success) {
    router.push('/')
  } else {
    alert(result.msg)
  }
}
</script>

<template>
  <div class="loginarea">
    <h2>请完成登录</h2>
    <div class="Login_form">
      <form @submit.prevent="handleLogin">
        <ul>
          <li><label>学号:</label><input type="text" v-model="snoId" /></li>
          <li><label>密码:</label><input type="text" v-model="numId" /></li>
          <li><button type="submit">确认登录</button></li>
          <li>没有账号?去<router-link to="/Register">注册</router-link></li>
        </ul>
      </form>
    </div>
  </div>
</template>

4. 路由守卫(router/index.js)

保护需要登录的页面,未登录自动跳转登录页。

import { useUserStore } from '@/stores/user'
// ... 导入组件

const routes = [
  { path: '/', component: Home, meta: { requiresAuth: true } },      // 需要登录
  { path: '/about', component: About, meta: { requiresAuth: true } },
  { path: '/Login', component: Login },
  { path: '/Register', component: Register }
]

const router = createRouter({ history: createWebHistory(), routes })

router.beforeEach((to, _, next) => {
  const userStore = useUserStore()
  if (to.meta.requiresAuth && !userStore.currentUser) {
    next('/Login')
  } else {
    next()
  }
})

export default router

5. 导航栏显示登录/退出按钮(App.vue)

  <div>
    <div v-if="!userStore.currentUser">
      <router-link to="/Login">登录</router-link>
    </div>
    <div v-else>
      <button @click="userStore.logout">退出登录</button>
    </div>
  </div>
</template>

<script setup>
import { useUserStore } from './stores/user'
const userStore = useUserStore()
</script>

遇到的坑 & 解决方案

坑1:表单提交后页面刷新,输入框被清空

原因<form> 内没有阻止默认提交行为,导致页面重新加载。

解决:在 <form> 上添加 @submit.prevent

坑2:Pinia 中访问 state 时误用 .value

现象Cannot read properties of undefined (reading 'some')

原因:在组件中通过 useUserStore() 获取 store 后,Pinia 会自动解包所有 state(ref 变成普通值),但初学者容易惯性加上 .value,导致 store.users.value 为 undefined

解决:记住:在组件中直接使用 store.users,不要加 .value;在 store 内部(如 addUser)才需要 users.value.push(...)

坑3:注册时学号唯一性检查不生效

原因:检查逻辑写反了 —— if (!userStore.users.some(...)) 变成了“学号不存在时提示”。

解决:去掉取反,改为 if (userStore.users.some(...))

坑4:路由守卫中未使用的参数

原因beforeEach 回调签名为 (to, from, next)from 未使用。

解决:用下划线占位:(to, _, next),避免 ESLint 警告。

坑5:localStorage 中存储的数据格式错误

原因JSON.parse 时如果数据损坏或不存在会抛出异常。

解决:使用 try...catch 包裹,提供默认值。

总结

通过这个小项目,我巩固了 Pinia 的使用、理解了 Vue 路由守卫、熟悉了表单验证和本地存储。更重要的是,亲手踩了这些坑之后,下次再遇到就能快速定位。接下来我会把这个登录系统接入真实后端(Node.js + MongoDB),实现真正的用户认证。

最后分享一个感悟:不要怕犯错,每一次报错都是升级的机会。

希望这篇文章对你有所帮助!如果觉得不错,点个赞鼓励一下~