学习笔记:Vue3 + Pinia 实现微头条登录与状态管理

学习笔记:Vue3 + Pinia 实现微头条登录与状态管理

今天完成了一个基于 Vue3 + Vue Router + Pinia 的微头条小项目,实现了登录验证、用户状态管理和基础的新闻发布/展示功能,把整个学习过程和踩坑记录整理成笔记分享给大家。


一、项目背景与目标

这次的目标是在之前 Vue Router 路由的基础上,引入 Pinia 进行全局状态管理,解决用户登录状态共享的问题,实现以下核心功能:

  1. 登录页面:表单验证、模拟登录、错误提示
  2. 首页:展示用户信息、新闻列表、发布/删除/点赞功能
  3. 状态管理:用 Pinia 统一管理用户登录状态,退出登录时清除状态并跳转登录页

二、核心代码实现与踩坑记录

1. 登录页面(Login.vue)

核心功能

  • 用 ref 声明用户名、密码响应式变量,实现双向绑定
  • 模拟登录验证:输入用户名 w、密码 1 时登录成功
  • 登录成功后:保存 token 到 localStorage、调用 Pinia 的 setUser 方法存储用户信息、跳转到首页
  • 清空按钮一键重置表单内容

关键踩坑

  • ❌ 错误:一开始写 username_value 访问 ref 变量,一直报错
  • ✅ 解决:Vue3 中 ref 声明的变量必须用 .value 访问,正确写法是 username.value
  • ❌ 错误:忘记引入 Pinia 的 useUserStore,导致登录后无法存储用户状态
  • ✅ 解决:引入 useUserStore 并实例化,登录成功后调用 userStore.setUser()

完整代码

<template>
  <div class="login-page">
    <div class="login-card">
      <h2>微头条登录</h2>
      <input type="text" placeholder="用户名" v-model="username" />
      <input type="password" placeholder="密码" v-model="password" />
      <button @click="handleLogin">登录</button>
      <button @click="clearForm">清空</button>
      <div class="register-link">
        <a href="#">立即注册</a>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'

const username = ref('')
const password = ref('')
const router = useRouter()
const userStore = useUserStore()

const handleLogin = () => {
  if (username.value === 'w' && password.value === '1') {
    localStorage.setItem('token', 'fake-token')
    userStore.setUser(username.value, 'fake-token')
    router.push('/home')
  } else {
    alert('用户名或密码错误')
  }
}

const clearForm = () => {
  username.value = ''
  password.value = ''
}

onMounted(() => {
  document.body.style.overflow = 'hidden'
})

onUnmounted(() => {
  document.body.style.overflow = ''
})
</script>

<style scoped>
.login-page {
  height: 100vh;
  width: 100%;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  display: flex;
  justify-content: center;
  align-items: center;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.login-card {
  width: 360px;
  background: white;
  padding: 30px;
  border-radius: 16px;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}

.login-card h2 {
  text-align: center;
  margin-bottom: 24px;
  color: #333;
}

.login-card input {
  width: 100%;
  padding: 12px;
  margin-bottom: 16px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 14px;
  box-sizing: border-box;
  transition: border-color 0.2s;
}

.login-card input:focus {
  outline: none;
  border-color: #667eea;
}

.login-card button {
  width: 100%;
  padding: 12px;
  border: none;
  border-radius: 8px;
  font-size: 16px;
  cursor: pointer;
  transition: background 0.3s;
}

.login-card button:first-of-type {
  background: #667eea;
  color: white;
}

.login-card button:first-of-type:hover {
  background: #5a67d8;
}

.login-card button:last-of-type {
  background: #e2e8f0;
  color: #333;
  margin-top: 8px;
}

.login-card button:last-of-type:hover {
  background: #cbd5e0;
}

.register-link {
  text-align: center;
  margin-top: 20px;
  font-size: 14px;
  color: #666;
}

.register-link a {
  color: #667eea;
  text-decoration: none;
}

.register-link a:hover {
  text-decoration: underline;
}
</style>

2. 首页(Home.vue)

核心功能

  • 从 Pinia 中读取 userStore.username,在页面显示欢迎语
  • 退出登录按钮:调用 Pinia 的 logout 方法,清除用户状态并跳转到登录页
  • 新闻列表功能:发布、删除、点赞新闻
  • 响应式布局:固定头部,内容区域可滚动

关键踩坑

  • ❌ 错误:一开始直接操作 localStorage.removeItem('token') 退出登录,状态没有同步更新
  • ✅ 解决:改为调用 userStore.logout(),由 Pinia 统一处理状态清除,保证所有组件的状态同步
  • ❌ 错误:忘记引入 useUserStore,导致页面无法读取用户名
  • ✅ 解决:引入 useUserStore 并实例化,在模板中直接使用 {{ userStore.username }}

完整代码

<template>
    <div class="home-container">
        <div class="header">
            <h2>微头条首页</h2>
            <div>欢迎, {{ userStore.username }}</div>
            <button @click="logout">退出登录</button>
        </div>

        <div class="scroll-area">
            <div class="content-wrapper">
                <div class="publish-area">
                    <input type="text" v-model="newTitle" placeholder="新闻标题" @keyup.enter="addNews" />
                    <input type="text" v-model="newContent" placeholder="新闻内容" />
                    <button @click="addNews">+ 发布微头条</button>
                </div>

                <div class="news-list">
                    <div v-if="newsList.length === 0" class="empty">
                        暂无新闻,快去发布一条吧
                    </div>
                    <div v-for="item in newsList" :key="item.id" class="news-item">
                        <h3>{{ item.title }}</h3>
                        <div class="time">{{ item.time }}</div>
                        <div class="content">{{ item.content }}</div>
                        <div class="actions">
                            <a href="javascript:;">📚 查看详情</a>
                            <button @click="deleteNews(item.id)">🗑 删除</button>
                            <button @click="likeNews(item.id)">💗 点赞 {{ item.likes }}</button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
import { useRouter } from 'vue-router'
import { ref, onMounted, onUnmounted } from 'vue'
import { useUserStore } from '../stores/user'

const router = useRouter()
const userStore = useUserStore()

const newsList = ref([
    {
        id: 1,
        title: '微头条1.0 正式发布',
        time: '2025-06-01 10:30',
        content: '微头条是一个轻量级信息分享平台,采用 Vue3 + SpringBoot 技术栈。',
        likes: 0
    },
    {
        id: 2,
        title: 'Vue3 组合式 API 学习心得',
        time: '2025-06-02 15:20',
        content: 'setup 语法糖非常简洁,ref和reactive让数据响应式变得容易。',
        likes: 2
    }
])

const newTitle = ref('')
const newContent = ref('')
let nextId = 3

const addNews = () => {
    if (!newTitle.value.trim()) {
        alert('请输入标题')
        return
    }
    const newNews = {
        id: nextId++,
        title: newTitle.value,
        content: newContent.value || '暂无内容',
        time: new Date().toLocaleString(),
        likes: 0
    }
    newsList.value.unshift(newNews)
    newTitle.value = ''
    newContent.value = ''
}

const deleteNews = (id) => {
    newsList.value = newsList.value.filter(item => item.id !== id)
}

const likeNews = (id) => {
    const item = newsList.value.find(item => item.id === id)
    if (item) {
        item.likes++
    }
}

const logout = () => {
    userStore.logout()
    router.push('/login')
}

onMounted(() => {
    document.body.style.overflow = 'hidden'
})

onUnmounted(() => {
    document.body.style.overflow = ''
})
</script>

<style scoped>
.home-container {
    height: 100vh;
    display: flex;
    flex-direction: column;
    background: #f5f5f5;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.header {
    flex-shrink: 0;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 16px 24px;
    background: #ffffff;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
    border-bottom: 5px solid #e0e0e0;
}

.header h2 {
    margin: 0;
    color: #333;
}

.header button {
    background: #ff4d4f;
    color: white;
    border: none;
    border-radius: 20px;
    padding: 6px 16px;
    cursor: pointer;
}

.header button:hover {
    background: #ff7875;
}

.scroll-area {
    flex: 1;
    overflow-y: auto;
    padding: 20px;
}

.content-wrapper {
    max-width: 800px;
    margin: 0 auto;
}

.publish-area {
    display: flex;
    gap: 10px;
    margin-top: 20px;
    margin-bottom: 20px;
}

.publish-area input {
    flex: 1;
    padding: 10px;
    border: 1px solid #ddd;
    border-radius: 24px;
}

.publish-area button {
    display: block;
    width: 180px;
    background: #1890ff;
    color: white;
    border: none;
    border-radius: 24px;
    padding: 10px 0;
    font-size: 16px;
    cursor: pointer;
    transition: background 0.3s;
}

.publish-area button:hover {
    background: #0c7bdf;
}

.news-list {
    margin-top: 20px;
}

.news-item {
    background: white;
    border-radius: 12px;
    padding: 20px;
    margin-bottom: 16px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
    transition: transform 0.2s, box-shadow 0.2s;
}

.news-item:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.news-item h3 {
    margin-bottom: 8px;
    color: #333;
}

.time {
    font-size: 12px;
    color: #999;
    margin-bottom: 12px;
}

.content {
    font-size: 14px;
    color: #666;
    line-height: 1.5;
    margin-bottom: 12px;
}

.actions a {
    color: #1890ff;
    text-decoration: none;
    margin-right: 10px;
    font-size: 14px;
}

.actions a:hover {
    text-decoration: underline;
}

.actions button {
    margin-right: 10px;
    background: none;
    border: none;
    color: #e5152d;
    cursor: pointer;
}

.actions button:hover {
    text-decoration: underline;
}

.actions button:last-child {
    color: #0ae411;
}

.empty {
    text-align: center;
    color: #999;
    padding: 40px;
}
</style>

3. Pinia 用户状态管理(stores/user.js)

核心功能

  • 用 defineStore 创建用户仓库,定义 username 和 token 状态
  • 实现 setUser 方法:登录时保存用户信息和 token
  • 实现 logout 方法:退出时清除状态和 localStorage 中的 token

完整代码

import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    username: '',
    token: ''
  }),
  actions: {
    setUser(username, token) {
      this.username = username
      this.token = token
    },
    logout() {
      this.username = ''
      this.token = ''
      localStorage.removeItem('token')
    }
  }
})

三、学习总结与心得

  1. Pinia 状态管理真香:相比 Vuex,Pinia 的 API 更简洁,没有 mutations 的概念,直接在 actions 中修改状态,组件中直接调用即可,代码更清爽,状态同步也更方便。
  2. Vue3 组合式 API 细节要注意:ref 变量必须用 .value 访问,这个小细节踩了好几次坑,一定要记牢。
  3. 状态和路由配合的重要性:登录状态需要跨组件共享,用 Pinia 统一管理后,退出登录时所有组件的状态都会同步更新,不会出现“页面退出了但状态没清掉”的问题。
  4. 项目结构更清晰了:把状态管理单独放在 stores 文件夹里,组件只负责页面渲染和业务逻辑,代码职责更分明,维护起来更方便。

这次的项目让我对 Vue3 + Pinia 的状态管理有了更直观的理解,从基础的登录验证到全局状态共享,一步步踩坑、解决问题,收获满满!后续可以再优化一下路由守卫,实现未登录自动跳转到登录页的功能,让项目更完整。


posted @ 2026-06-11 10:29  05春  阅读(19)  评论(1)    收藏  举报