构建用户界面的渐进式框架 Vue 入门教程(登录注册实战)

1. 初识 Vue

1.1 前端三大件与框架

前端的基础是三大件:HTML 标记语言、CSS 样式布局与 JavaScript 控制语言,有关介绍可见文章 前端基础

但就如 Python 的底层是 C++、MyBatis 底层是 JDBC 一样,开发者们为了追求开发效率,对这些底层逻辑不断进行封装,提供了更简单易用的框架,前端框架之一就是 Vue

1.2 Vue 是什么

Vue 是一种构建用户界面的、渐进式框架

构建用户界面的 :核心是数据驱动视图,数据变更自动更新页面;

渐进式 :可仅引入 Vue 做局部交互,也可完整搭建大型单页应用,按需使用、分段学习;

框架:提供语法糖、内置 API、生命周期、指令等,提升开发效率。

使用该框架可以极大减少代码量,开发效率高,免去了操作 DOM 的繁琐,只操作数据。

1.3 计数器:原生 JS vs Vue

假如要实现点击增加页面数值大小的功能:

使用原生三大件的代码:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>原生JS计数器</title>
  <style>
    .count-box { font-size: 24px; margin: 20px 0; }
    button { padding: 6px 16px; font-size: 18px; margin: 0 8px; }
  </style>
</head>
<body>
  <div class="count-box">当前数值:<span id="num">0</span></div>
  <button id="addBtn">+</button>
  <button id="subBtn">-</button>

  <script>
    // 1. 手动获取 DOM 元素
    const numDom = document.getElementById('num');
    const addBtn = document.getElementById('addBtn');
    const subBtn = document.getElementById('subBtn');
    // 2. 定义数据
    let count = 0;
    // 3. 加按钮事件
    addBtn.addEventListener('click', () => {
      count++;
      // 4. 手动更新 DOM
      numDom.innerText = count;
    })
    // 减按钮事件
    subBtn.addEventListener('click', () => {
      count--;
      // 手动更新 DOM
      numDom.innerText = count;
    })
  </script>
</body>
</html>

使用 Vue 仅需如下代码:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Vue计数器</title>
  <style>
    .count-box { font-size: 24px; margin: 20px 0; }
    button { padding: 6px 16px; font-size: 18px; margin: 0 8px; }
  </style>
  <!-- 引入 Vue 3 完整版(含开发提示,学习阶段用这个即可) -->
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
  <!--
    id="app":Vue 的挂载点
    只有在这个 div 内部的模板语法({{ }}、@click 等)才由 Vue 管理
  -->
  <div id="app">
    <!-- {{ count }}:插值表达式,把 data 里的 count 显示到页面上,Vue数据驱动,操作数据前端页面自动变化 -->
    <div class="count-box">当前数值:{{ count }}</div>
    <!-- @click 是 v-on:click 的简写,绑定点击事件,执行 count++ / count-- -->
    <button @click="count++">+</button>
    <button @click="count--">-</button>
  </div>

  <script>
    // 从全局 Vue 对象中解构 createApp,用于创建应用实例
    const { createApp } = Vue

    createApp({
      // data 返回响应式数据;count 变化时,模板中 {{ count }} 会自动更新
      data() {
        return {
          count: 0   // 计数器初始值
        }
      }
    }).mount('#app')   // 挂载到 id 为 app 的 DOM 元素上,Vue 开始接管该区域
  </script>
</body>
</html>

1.4 Vue 的优势

上面的代码看着好像差不多,但结构逻辑相差巨大,主要有以下几点优势:

  1. 无手动 DOM 操作 :只定义数据 count,页面通过 {``{ }} 自动渲染;
  2. 内置事件指令 @click ,不用写 addEventListener
  3. 数据驱动 :直接修改 count 变量,页面自动同步,不用操作 DOM;
  4. 代码量大幅缩减,结构、数据、逻辑分层清晰;
  5. 页面模板和业务数据绑定在一起,可读性更高。

2. 简单使用------创建 Vue 应用

2.1 Vue 官网与版本

Vue 官方文档:https://cn.vuejs.org/

版本 说明
Vue 2 旧项目常见,使用 new Vue()
Vue 3 当前主流,使用 createApp(),性能更好,Composition API 更灵活

新项目请选择 Vue 3。

2.2 CDN 引入与创建实例

创建 Vue 应用的流程如下:

  1. 准备容器 :指定 Vue 的管理范围,通常在一个 div 内,通过 id 绑定:

    html 复制代码
    <div id="app">{{ message }}</div>
  2. 引包:在 HTML 中引入 Vue。生产环境用压缩包(体积小),开发调试用完整包(含警告信息):

    javascript 复制代码
    <!-- 生产环境 -->
    <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
    <!-- 开发环境 -->
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  3. 创建实例并挂载

    javascript 复制代码
    	<script>
      const { createApp } = Vue
      createApp({  		// 创建实例
       	data() { 		// 响应式数据,同级别还有methods 方法、computed计算属性、watch监听和mounted生命周期
          return { message: 'Hello Vue!' }	// 返回消息,键值对
        }
      }).mount('#app')	// 挂载到 id 为 app 的 DOM 元素
    </script>

3. 基础语法

本章用一个通用的最小登录表单 Demo 串联语法。

3.1 模板与插值 {``{ }}

html 复制代码
<h2>{{ title }}</h2>
<button>{{ loading ? '登录中...' : '登录' }}</button>

把 JavaScript 中的变量 插入 到 HTML 中显示。数据变化时,页面自动更新,无需 innerText

能写成表达式的方式都支持,如三元表达式 ? :toUpperCase() 转换大写等,但插值表达式不可在标签属性中使用 (需使用 v-bind

3.2 Vue指令

Vue指令是v-前缀的特殊标签属性,常用指令如下:

  1. 显示元素 v-show=表达式,表达式为true时显示,false隐藏。

    原理为通过切换元素 CSS 的 display 属性控制显隐,隐藏时元素仅不展示,DOM 节点依旧存在页面中,不会被删除。适用于频繁切换显隐的场景。

    html 复制代码
    <div v-show="isShow">我能显示或隐藏</div>
  2. 事件绑定 v-on 事件=方法名/内联语句

    html 复制代码
    <button v-on:click="handleLogin">登录</button>
    <input @keyup.enter="handleLogin" />
    • v-on:click 可简写为 @click ,即v-on:=@
    • @keyup.enter:在输入框按 Enter 键时触发登录
  3. 动态设置标签属性 v-bind:属性名=表达式

    用于动态绑定 HTML 标签原生属性,让属性值不再写死固定字符串,而是由 JS 变量、表达式控制,可简写为:

    html 复制代码
    <div id="app">
    <!-- 动态图片地址 -->
    <img v-bind:src="imgUrl" :title="tipText">

    不写死图片路径,通过变量动态设置图片地址。

  4. 双向绑定 v-model

    双向绑定数据和视图,一方变化另一方自动更新,方便获取与设置内容。

    html 复制代码
    <input v-model="form.username" placeholder="用户名" />
    <input v-model="form.password" type="password" placeholder="密码" />

    用户在输入框中打字 form.username / form.password 自动更新;反过来修改变量也会同步到输入框。登录页收集用户名和密码,最常用这一语法。

    本质是属性绑定 + 事件绑定合并,即:value + @input

  5. 条件渲染 v-if

    根据条件控制组件的创建和移除,与v-show的应用场景不同,适用于要么a要么b,不常切换、条件互斥的场景

    html 复制代码
    <p v-if="errorMsg" class="error">{{ errorMsg }}</p>

    仅当 errorMsg 非空时显示错误提示,登录失败时使用。

    可搭配v-elsev-else if判断渲染条件。

  6. 基于数据的循环 v-for

    根据数组 / 对象数据批量渲染相同结构的 DOM 元素,底层会自动遍历数据并生成对应节点,通常写为v-for = (item,index) in 数组,以遍历对象和下标。

    html 复制代码
    <!-- item 代表数组每一项,list 是响应式数组 -->
     <li v-for="item in list">{{ item }}</li>
     const list = ref(['苹果', '香蕉', '橙子'])

    循环通常需指定唯一标识 key,便于 Vue 进行列表项的正确排序与复用,不加key的默认行为会原地修改元素,就地复用,该key必须有唯一性,推荐使用id而非index,指定方法如下:

    html 复制代码
    <div v-for="user in userList" :key="user.id">

    删除元素的方法可写为:

    javascript 复制代码
    delUser = (delId) => {
      userList.value = userList.value.filter(item => item.id !== delId)
    }

    用过滤方法去除删除的id,不改变原数组。

3.3 简易登录注册 demo

下面是一个可运行的 Vue 3 登录表单,后文将逐段讲解其中的语法。

效果如下图:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>登录表单 Demo</title>
  <style>
    .login-box { max-width: 320px; margin: 40px auto; }
    .error { color: red; font-size: 14px; }
    .btn-loading { opacity: 0.6; }
    input { display: block; width: 100%; margin: 8px 0; padding: 8px; }
    button { padding: 8px 16px; }
  </style>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
  <div id="app">
    <div class="login-box">
      <h2>{{ title }}</h2>

      <input v-model="form.username" placeholder="用户名" @keyup.enter="handleLogin" />
      <input v-model="form.password" type="password" placeholder="密码" @keyup.enter="handleLogin" />

      <p v-if="errorMsg" class="error">{{ errorMsg }}</p>

      <button
        :disabled="!isFormValid || loading"
        :class="{ 'btn-loading': loading }"
        @click="handleLogin"
      >
        {{ loading ? '登录中...' : '登录' }}
      </button>
    </div>
  </div>

  <script>
    const { createApp, ref, reactive, computed } = Vue

    createApp({
      setup() {
        const title = ref('用户登录')
        const loading = ref(false)
        const errorMsg = ref('')

        const form = reactive({
          username: '',
          password: '',
        })

        const isFormValid = computed(() => {
          return form.username.trim() !== '' && form.password.length >= 6
        })

        async function handleLogin() {
          if (!isFormValid.value) return
          loading.value = true
          errorMsg.value = ''
          try {
            await new Promise(r => setTimeout(r, 800)) // 模拟网络请求
            if (form.password === '123456') {
              alert('登录成功!')
            } else {
              errorMsg.value = '用户名或密码错误'
            }
          } finally {
            loading.value = false
          }
        }

        return { title, loading, errorMsg, form, isFormValid, handleLogin }
      }
    }).mount('#app')
  </script>
</body>
</html>

4. 组件核心机制

当页面变复杂(多个页面、共享状态、请求后端),单个 HTML 文件难以维护。工程化 Vue 项目将代码拆分为多个 单文件组件.vue)。

4.1 单文件组件与 .vue 三段结构

单页应用程序(SPA,Single Page Application)是指整个项目只有一个 html 文件,路径变化时只更新特定组件,实现按需更新,性能高,用户体验好,但首屏加载慢,SEO搜索引擎优化差,多用于文档类网站和移动端。

使用 Vue 可以方便地编写 单文件组件(.vue),每个 .vue 文件由三段(template结构、script逻辑和style样式)组成,对应三大件结构如下:

html 复制代码
<template>
  <!-- HTML:页面结构 -->
  <!-- 可导入其他vue文件,便于复用 -->
  <div class="login-box">
    <input v-model="form.username" />
    <button @click="handleLogin">登录</button>
  </div>
</template>

<script setup>
// JavaScript:数据与逻辑
import { ref, reactive } from 'vue'

const form = reactive({ username: '', password: '' })
const loading = ref(false)

async function handleLogin() {
  loading.value = true
  // ...
  loading.value = false
}
</script>

<style scoped>
/* CSS:仅作用于本组件,不影响其他页面 */
.login-box { max-width: 320px; margin: 0 auto; }
</style>

4.2 项目工作流程------项目目录解析

新建 Vue 项目的工作目录及相关职责如下:

复制代码
my-vue-app/
├── index.html          # 整个网站唯一的 HTML 壳,内有 <div id="app">
├── package.json        # 项目依赖与 npm 脚本
├── vite.config.js      # Vite 构建配置
└── src/
    ├── main.js         # 程序入口:创建 Vue 应用、注册插件
    ├── App.vue         # 根组件
    ├── views/          # 页面级组件(登录页、注册页等)
    ├── components/     # 可复用小组件
    ├── router/         # 路由配置(URL → 页面)
    ├── stores/         # 跨页面共享的数据,如登录 token,通常使用 Pinia 全局状态
    └── api/            # 封装 HTTP 请求的函数,与 UI 分离

项目整体流程为:

4.3 响应式API

  1. 响应式基础 refreactive

    Vue3 的两大响应式 API,核心作用:让数据具备响应式特性,数据修改后页面视图自动同步更新,无需手动操作 DOM。

    响应式特性,即数据变化时视图自动更新,可在控制台中操作实例数据,检查页面显示。

    实际开发中根据业务逻辑修改数据即可,Vue封装了相关DOM操作。

    • ref:适用于基础类型,字符串、数字、布尔值;脚本内部读写需加.value,template模板会自动解包。
    • reactive:仅用于引用类型数据:普通对象、数组、表单对象。
    javascript 复制代码
    const loading = ref(false)           // 基本类型:用 ref
    const form = reactive({              // 对象:用 reactive
      username: '',
      password: '',
    })
  2. 异步函数 async/await

    网络请求等通信过程默认异步,需获取并处理结果的方法需使用async/await标记,async标记方法,await等待方法执行返回结果后再继续执行。

    javascript 复制代码
    async function handleLogin() {
      loading.value = true
      try {
        await fakeLoginApi()   // 等待接口返回
      } catch (err) {
        errorMsg.value = err.message
      } finally {
        loading.value = false  // 无论成功失败都关闭 loading
      }
    }
  3. 计算属性 computed

    根据已有数据派生 新值。比如当 form.usernameform.password 变化时,isFormValid 自动重新计算,用于控制按钮是否可点。

    javascript 复制代码
    const isFormValid = computed(() => {
      return form.username.trim() !== '' && form.password.length >= 6
    })

    相比methods方法有缓存特性,只有依赖项变化时才重新计算,效率更高。

  4. 侦听器 watch

    用于监听指定响应式数据的变化,数据变更后执行自定义业务逻辑;无缓存,每次数据修改都会触发回调,适合处理异步、复杂副作用操作。

    javascript 复制代码
    watch(
      () => form, // 监听整个表单
      // () => form.username,  // 监听一个变量
      // [() => form, () => form.username] // 监听多个对象
      (newVal, oldVal) => {  // 触发函数,参数是旧值和新值
        console.log('表单任意字段发生修改')
       // 可结合clearTimeout和setTimeout计时器实现延迟执行,防止页面抖动
      },
      {
        immediate: true, // 组件初始化完成立刻执行一次回调
        deep: true       // 开启深度监听,监听对象内部属性变化,任意属性变化触发
      }
    )

    可简写为

    javascript 复制代码
    watch(msg, (newVal, oldVal) => {
      console.log('新值', newVal, '旧值', oldVal)
    })

4.4 组件生命周期

Vue实例的生命周期分为:创建=>挂载=>更新=>销毁,完成的功能如下:

  1. 创建:组件实例初始化(DOM 还未生成),setup() 相当于beforeCreate + created:实例创建完成,所有响应式数据、方法初始化完毕;
  2. 挂载:虚拟 DOM 生成并渲染到真实页面,onBeforeMount:DOM 挂载前,模板编译完成但页面无真实 DOM;onMounted:DOM 挂载完成,页面渲染完毕
  3. 更新:监听响应式数据变更,对比新旧虚拟 DOM,onBeforeUpdate:数据变化后、页面重新渲染前执行;onUpdated:DOM 更新渲染完成
  4. 销毁:组件实例卸载、资源释放,onBeforeUnmount:组件销毁前,DOM 仍存在,可手动清除定时器、全局事件;onUnmounted:组件完全销毁,DOM 已移除。

使用示例如下:

javascript 复制代码
// 1. 创建阶段:实例创建完成
<script setup>
  创建阶段操作
</script>

// 2. 挂载阶段
onBeforeMount(() => {
  console.log('onBeforeMount:即将渲染DOM')
})
onMounted(() => {
  console.log('onMounted:页面挂载完成,可以获取DOM', box.value)
})

// 3. 更新阶段(修改count才会触发)
onBeforeUpdate(() => {
  console.log('onBeforeUpdate:数据变更,页面即将刷新')
})
onUpdated(() => {
  console.log('onUpdated:页面DOM更新完毕')
})

// 4. 销毁阶段(切换路由/销毁组件触发)
onBeforeUnmount(() => {
  console.log('onBeforeUnmount:组件即将销毁,可手动清理定时器')
})
onUnmounted(() => {
  console.log('onUnmounted:组件完全销毁')
})

5. 工程配套(Router / Pinia / UI 库 / HTTP)

5.1 Vue Router------页面跳转

路由负责明确访问路径和组件的对应关系,用于实现页面跳转,跳转逻辑都写到main.js内会变得难以维护,故通常都写在router/index.js下,使用export default router导出,跳转逻辑中导入使用。

详细介绍可见官方文档:https://router.vuejs.org/zh/

最小示例如下

javascript 复制代码
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import LoginPage from './views/LoginPage.vue'
import HomePage from './views/HomePage.vue'

const routes = [
  { path: '/login', component: LoginPage, name: 'Login' },
  // meta元数据,是否需要授权登录,用于后续导航守卫
  { path: '/home', component: HomePage, name: 'Home',meta: { requiresAuth: true } },
]

const router = createRouter({
	// 路由地址无#,更简洁
  history: createWebHistory(), 
  // 路由映射
  routes,
})
// 导出路由,主容器允许导入
export default router

在组件中跳转:

javascript 复制代码
import { useRouter } from 'vue-router'

const router = useRouter()
router.push('/register')   // 去注册页
router.push({ name: 'Home', query: { id: 1001 } }) // 按路由名称跳转,携带参数id: 1001,route.query.id接收

// app.vue中需如下配置
<template>
  <RouterView />  // 路由页面的渲染容器
</template>

跳转有两种方式,声明式导航和编程式导航,分别用于点击超链接触发和逻辑自动跳转:

  • 声明式导航<router-link to="/home">首页</router-link>实际生成<a href="/home">首页</a>,点击跳转;
  • 编程式导航router.push({name:'Home'})随代码逻辑触发,可匹配定时器、监听变化实现自动跳转。

导航守卫 :导航守卫就是路由跳转全过程的拦截钩子函数,路径都先经过全局前置守卫放行才能跳转,用于访问拦截,最常用做全局登录鉴权,简单示例如下:

javascript 复制代码
// 全局前置导航守卫:每次路由跳转执行,统一做登录权限拦截、页面重定向
router.beforeEach((to, from, next) => {
  // to:目标路由对象,存放要跳转页面的路径、名称、meta元信息、参数等
  // 逻辑1:目标页面标记了需要登录权限,但用户无token未登录 → 拦截跳转登录页
  if (to.meta.requiresAuth ) {
    // 直接return路由对象等价next({name:'Login'}),重定向到登录页面
    return { name: 'Login' }
  }

  // 逻辑2:用户已有登录token(已登录),但要去登录/注册页 → 自动重定向到首页Generate
  if (!to.meta.requiresAuth  && (to.name === 'Login' || to.name === 'Register')) {
    // 已登录用户禁止重复进入登录、注册页面,直接跳转业务首页
    return { name: 'Main' }
  }
  // 无匹配拦截条件,默认放行,无需额外return,路由正常跳转目标页面
})

5.2 Pinia------全局状态

Pinia 是 Vue 里的全局状态管理库(Store) ,负责把多个页面都要用到的数据(比如登录状态、用户信息)放在一个统一的地方,任何页面都能读、都能改。

适用于某个状态在多个组件中使用,或多个组件共同维护一份数据的场景。

Store 存放跨页面的公共数据及配套逻辑,如登录注册操作(token、用户信息、登录标识),utils 只放无状态纯工具函数

详细介绍可见官方文档:https://pinia.vuejs.org/zh/

最小示例

javascript 复制代码
// 项目路径 stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 定义仓库名称,唯一标识user
export const useUserStore = defineStore('user', () => {
	// 响应式属性
  const token = ref('')
  const username = ref('')

  const isLoggedIn = computed(() => !!token.value)
	// 方法可指定为异步
  function login(name, jwt) {
    token.value = jwt
    username.value = name
  }

  function logout() {
    token.value = ''
    username.value = ''
  }
	// 暴露相关属性和方法
  return { token, username, isLoggedIn, login, logout }
})

在组件中使用:

javascript 复制代码
import { useUserStore } from '@/stores/user'
// 工厂函数会给标识包围useXXXXStore,用于获取指定仓库
const userStore = useUserStore()
// 调用仓库方法或属性
await userStore.login('alice', 'eyJhbG...')
console.log(userStore.isLoggedIn)  // true

登录场景 :登录页调用 store.login() 写入 token;其他页面读取 store.isLoggedInstore.username

5.3 Element Plus------UI 组件库

Element Plus 是一套基于 Vue 3 的 UI 组件库,提供了按钮、输入框、表单、弹窗、分页、标签等功能,提高开发效率。

详细介绍可见官方文档:https://element-plus.org/zh-CN/

该组件库可通过 vite.config.js 如下方式按需引入:

javascript 复制代码
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
})

使用方式为原标签前加el,如button=>el-button,使用示例如下:

html 复制代码
<template>
  <el-form :model="form" :rules="rules" ref="formRef">
    <el-form-item prop="username">
      <el-input v-model="form.username" placeholder="用户名" />
    </el-form-item>
    <el-form-item prop="password">
      <el-input v-model="form.password" type="password" placeholder="密码" />
    </el-form-item>
    <el-button type="primary" @click="handleSubmit">登录</el-button>
  </el-form>
</template>

<script setup>
import { ElMessage } from 'element-plus'

function handleSubmit() {
  ElMessage.success('登录成功')
}
</script>

常用组件:el-formel-inputel-button;消息提示:ElMessage.success() / ElMessage.error()

登录场景 :项目登录页用 Element Plus 替代原生 <input>,并自带表单校验能力。

5.4 fetch 与 HTTP 请求

前端消息需与后端交互,常用方法有fetch、axios等,相比axios,fetch为浏览器原生,简单需求更轻量化。

通过配置文件配置前端与后端对应端口:

javascript 复制代码
// 访问前端/api时对应本地8083接口
proxy: {
  '/api': { target: 'http://localhost:8083', changeOrigin: true }
}

使用fetch交互的示例如下:

javascript 复制代码
/**
 * 登录请求接口
 * @param {string} username - 用户账号
 * @param {string} passwordMd5 - MD5加密后的用户密码(前端加密,不传输明文)
 * @returns {Promise<Object>} 后端返回完整JSON数据,格式 { code: 0, data: { token, id, username } }
 * @throws {Error} HTTP请求异常 / 后端返回错误信息时抛出错误,上层try/catch捕获
 */
async function loginApi(username, passwordMd5) {
  // 发起fetch POST登录请求,携带JSON格式账号密码参数
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, passwordMd5 }),
  })

  // 一次性解析响应JSON,避免重复读取请求流报错
  const resData = await response.json()

  // HTTP状态码异常则抛出错误
  if (!response.ok) {
    throw new Error(resData.message || '请求失败')
  }

  return resData
  // 典型返回结构:{ code: 0, data: { token, id, username } }
}

工程化场景需解耦,将通用 fetch 逻辑抽成独立 request 工具文件,auth 登录等接口层负责具体业务逻辑,而不关心底层网络格式,故通常业务逻辑结构如下:

复制代码
src
├─ api
│  ├─ request.js    // 通用fetch底层封装(所有接口统一调用)
│  └─ auth.js       // 登录、注册业务接口,只传路径和参数
└─ stores
   └─ auth.js       // pinia仓库,调用api/auth里的loginApi

6. 简单实战------登录注册实战

6.1 登录页

实现 Login.vue 三段式接口的 template 主要组织页面布局、组件和格式等,其中用到 Element Plus 可简化开发过程,如登录表单、密码输入框等,代码如下:

javascript 复制代码
<template>
  <!-- 登录页面最外层深色背景容器 -->
  <div class="auth-bg">
    <!-- 三层渐变模糊光晕装饰背景 -->
    <div class="bg-orb orb-1" />
    <div class="bg-orb orb-2" />
    <div class="bg-orb orb-3" />

    <!-- 登录玻璃态卡片主体 -->
    <div class="auth-card">
      <!-- 项目品牌LOGO、名称、特性标签区域 -->
      <div class="brand">
        <div class="brand-icon">
          <el-icon size="32"><Lock /></el-icon>
        </div>
        <h1 class="brand-name">Login</h1>
        <p class="brand-en">Login test</p>
        <p class="brand-sub">登录项目实战</p>
      </div>

      <!-- Element Plus 登录表单,回车直接触发登录 -->
      <el-form
        ref="formRef"
        :model="form"
        :rules="rules"
        class="auth-form"
        size="large"
        @keyup.enter="handleLogin"
      >
        <!-- 用户名输入框 -->
        <el-form-item prop="username">
          <el-input
            v-model="form.username"
            placeholder="用户名 / 邮箱"
            :prefix-icon="User"
            clearable
            class="auth-input"
          />
        </el-form-item>

        <!-- 密码输入框,支持显示/隐藏密码 -->
        <el-form-item prop="password">
          <el-input
            v-model="form.password"
            type="password"
            placeholder="密码"
            :prefix-icon="Lock"
            show-password
            class="auth-input"
          />
        </el-form-item>

        <!-- 登录提交按钮,加载态控制 -->
        <el-form-item>
          <el-button
            type="primary"
            class="auth-btn"
            :loading="loading"
            @click="handleLogin"
          >
            {{ loading ? '登录中...' : '登 录' }}
          </el-button>
        </el-form-item>
      </el-form>

      <!-- 底部跳转注册入口 -->
      <div class="auth-footer">
        <span class="footer-text">还没有账号?</span>
        <el-button link type="primary" class="footer-link" @click="goRegister">
          立即注册
        </el-button>
      </div>
    </div>
  </div>
</template>

控制部分逻辑为登录表单绑定数据,点击登录后调用 Pinia 仓库的登录方法,返回成功后跳转到后续页面,具体代码如下:

javascript 复制代码
<script setup>
// Vue 基础响应式API
import { ref, reactive } from 'vue'
// 路由
import { useRouter } from 'vue-router'
// Element Plus 内置图标
import { User, Lock } from '@element-plus/icons-vue'
// Pinia 全局登录状态仓库
import { useAuthStore } from '@/stores/auth'

// 路由实例
const router = useRouter()
// 全局认证仓库实例
const authStore = useAuthStore()

// 表单DOM实例,用于调用表单校验方法
const formRef = ref(null)
// 登录按钮加载状态
const loading = ref(false)

// 登录表单双向绑定数据
const form = reactive({
  username: '',
  password: '',
})

// 表单校验规则
const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少 6 位', trigger: 'blur' },
  ],
}

/**
 * 登录提交处理函数
 * 1. 先执行全局表单校验
 * 2. 校验通过调用Pinia登录接口
 * 3. 成功跳转生成页面,失败弹出错误提示
 */
async function handleLogin() {
  // 表单校验,校验失败捕获异常返回false
  const valid = await formRef.value?.validate().catch(() => false)
  if (!valid) return

  loading.value = true
  try {
    // 调用仓库登录方法,内部封装fetch请求
    await authStore.login({ username: form.username, password: form.password })
    ElMessage.success('登录成功,欢迎回来!')
    // 跳转到隐写生成主页
    router.push({ name: 'Generate' })
  } catch (err) {
    // 接口异常弹窗提示
    ElMessage.error(err.message || '登录失败,请重试')
  } finally {
    // 无论成功失败都关闭loading
    loading.value = false
  }
}

/**
 * 跳转到注册页面
 */
function goRegister() {
  router.push({ name: 'Register' })
}
</script>

样式页面,完全AI生成,美感还行,这对后端来说基本无从下手了,幸亏不影响逻辑,其实也无所谓。

javascript 复制代码
<style scoped>
/* 页面根容器:全屏垂直水平居中 */
.auth-bg {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #0d0d1a;
  position: relative;
  overflow: hidden;
}

/* 背景模糊光晕通用样式 */
.bg-orb {
  position: absolute;
  border-radius: 50%;
  filter: blur(80px);
  opacity: 0.35;
  pointer-events: none;
}
/* 左上紫色光晕 */
.orb-1 {
  width: 500px;
  height: 500px;
  background: radial-gradient(circle, #7c3aed, transparent 70%);
  top: -120px;
  left: -100px;
}
/* 右下蓝色光晕 */
.orb-2 {
  width: 400px;
  height: 400px;
  background: radial-gradient(circle, #2563eb, transparent 70%);
  bottom: -80px;
  right: -80px;
}
/* 中间粉色光晕 */
.orb-3 {
  width: 300px;
  height: 300px;
  background: radial-gradient(circle, #db2777, transparent 70%);
  top: 50%;
  left: 60%;
  transform: translate(-50%, -50%);
}

/* 玻璃态登录卡片主容器 */
.auth-card {
  position: relative;
  z-index: 10;
  width: 400px;
  padding: 48px 40px 36px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 20px;
  backdrop-filter: blur(20px);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
}

/* 品牌标题区域整体居中 */
.brand {
  text-align: center;
  margin-bottom: 36px;
}
/* 品牌渐变图标容器 */
.brand-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 64px;
  height: 64px;
  border-radius: 16px;
  background: linear-gradient(135deg, #7c3aed, #2563eb);
  margin-bottom: 16px;
  color: #fff;
}
.brand-name {
  font-size: 26px;
  font-weight: 700;
  color: #f1f5f9;
  margin: 0 0 6px;
  letter-spacing: 1px;
}
.brand-sub {
  font-size: 13px;
  color: #94a3b8;
  margin: 0 0 12px;
  letter-spacing: 1px;
}
.brand-en {
  font-size: 11px;
  color: #64748b;
  margin: 0 0 6px;
  line-height: 1.4;
}
/* 功能标签弹性布局,自动换行居中 */
.brand-features {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
}

/* 表单整体间距 */
.auth-form {
  margin-bottom: 8px;
}
/* 深度修改Element Plus原生表单项间距 */
.auth-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
/* 自定义输入框外壳:半透明深色玻璃风格 */
.auth-form :deep(.el-input__wrapper) {
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 10px;
  box-shadow: none !important;
  padding: 0 14px;
}
/* hover/聚焦输入框边框变色 */
.auth-form :deep(.el-input__wrapper:hover),
.auth-form :deep(.el-input__wrapper.is-focus) {
  border-color: #7c3aed;
  background: rgba(124, 58, 237, 0.08);
}
/* 输入文字颜色 */
.auth-form :deep(.el-input__inner) {
  color: #e2e8f0;
  height: 44px;
  font-size: 14px;
}
/* 占位符灰色 */
.auth-form :deep(.el-input__inner::placeholder) {
  color: #475569;
}
/* 前缀图标浅灰色 */
.auth-form :deep(.el-input__prefix-icon) {
  color: #64748b;
}

/* 登录渐变提交按钮 */
.auth-btn {
  width: 100%;
  height: 48px;
  border-radius: 10px;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 4px;
  background: linear-gradient(135deg, #7c3aed, #2563eb);
  border: none;
  transition: opacity 0.2s, transform 0.1s;
}
.auth-btn:hover {
  opacity: 0.9;
  transform: translateY(-1px);
}
.auth-btn:active {
  transform: translateY(0);
}

/* 底部注册跳转区域 */
.auth-footer {
  text-align: center;
  padding-top: 8px;
}
.footer-text {
  font-size: 13px;
  color: #64748b;
}
.footer-link {
  font-size: 13px;
  font-weight: 600;
  padding: 0 4px;
}
</style>

6.2 注册页

注册相关代码如下:

javascript 复制代码
<template>
  <div class="auth-bg">
    <div class="bg-orb orb-1" />
    <div class="bg-orb orb-2" />
    <div class="bg-orb orb-3" />

    <div class="auth-card">
      <div class="brand">
        <div class="brand-icon">
          <el-icon size="32"><UserFilled /></el-icon>
        </div>
        <h1 class="brand-name">创建账号</h1>
        <p class="brand-sub">Join AGS Studio</p>
      </div>

      <el-form
        ref="formRef"
        :model="form"
        :rules="rules"
        class="auth-form"
        size="large"
      >
        <el-form-item prop="username">
          <el-input
            v-model="form.username"
            placeholder="用户名"
            :prefix-icon="User"
            clearable
          />
        </el-form-item>

        <el-form-item prop="email">
          <el-input
            v-model="form.email"
            placeholder="邮箱(可选)"
            :prefix-icon="Message"
            clearable
          />
        </el-form-item>

        <el-form-item prop="password">
          <el-input
            v-model="form.password"
            type="password"
            placeholder="密码(至少 6 位)"
            :prefix-icon="Lock"
            show-password
          />
        </el-form-item>

        <el-form-item prop="confirmPassword">
          <el-input
            v-model="form.confirmPassword"
            type="password"
            placeholder="确认密码"
            :prefix-icon="Lock"
            show-password
          />
        </el-form-item>

        <el-form-item>
          <el-button
            type="primary"
            class="auth-btn"
            :loading="loading"
            @click="handleRegister"
          >
            {{ loading ? '注册中...' : '注 册' }}
          </el-button>
        </el-form-item>
      </el-form>

      <div class="auth-footer">
        <span class="footer-text">已有账号?</span>
        <el-button link type="primary" class="footer-link" @click="goLogin">
          返回登录
        </el-button>
      </div>
    </div>
  </div>
</template>

控制逻辑为:校验两次输入密码是否一致,注册按钮事件为仓库的注册方法,后端交互,注册成功后跳转到登录界面,代码如下:

javascript 复制代码
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock, Message, UserFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'

const router = useRouter()
const authStore = useAuthStore()

const formRef = ref(null)
const loading = ref(false)

const form = reactive({
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
})

const validateConfirmPassword = (rule, value, callback) => {
  if (value !== form.password) {
    callback(new Error('两次密码输入不一致'))
  } else {
    callback()
  }
}

const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 2, max: 20, message: '用户名长度 2~20 位', trigger: 'blur' },
  ],
  email: [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少 6 位', trigger: 'blur' },
  ],
  confirmPassword: [
    { required: true, message: '请再次输入密码', trigger: 'blur' },
    { validator: validateConfirmPassword, trigger: 'blur' },
  ],
}

async function handleRegister() {
  const valid = await formRef.value?.validate().catch(() => false)
  if (!valid) return

  loading.value = true
  try {
    await authStore.register({
      username: form.username,
      email: form.email,
      password: form.password,
    })
    ElMessage.success('注册成功!请登录')
    router.push({ name: 'Login' })
  } catch (err) {
    ElMessage.error(err.message || '注册失败,请重试')
  } finally {
    loading.value = false
  }
}

function goLogin() {
  router.push({ name: 'Login' })
}
</script>

<style scoped>
.auth-bg {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #0d0d1a;
  position: relative;
  overflow: hidden;
}

.bg-orb {
  position: absolute;
  border-radius: 50%;
  filter: blur(80px);
  opacity: 0.35;
  pointer-events: none;
}
.orb-1 {
  width: 500px;
  height: 500px;
  background: radial-gradient(circle, #2563eb, transparent 70%);
  top: -120px;
  right: -100px;
}
.orb-2 {
  width: 400px;
  height: 400px;
  background: radial-gradient(circle, #7c3aed, transparent 70%);
  bottom: -80px;
  left: -80px;
}
.orb-3 {
  width: 300px;
  height: 300px;
  background: radial-gradient(circle, #0ea5e9, transparent 70%);
  top: 40%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.auth-card {
  position: relative;
  z-index: 10;
  width: 420px;
  padding: 44px 40px 32px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 20px;
  backdrop-filter: blur(20px);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
}

.brand {
  text-align: center;
  margin-bottom: 32px;
}
.brand-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 64px;
  height: 64px;
  border-radius: 16px;
  background: linear-gradient(135deg, #2563eb, #0ea5e9);
  margin-bottom: 16px;
  color: #fff;
}
.brand-name {
  font-size: 24px;
  font-weight: 700;
  color: #f1f5f9;
  margin: 0 0 6px;
}
.brand-sub {
  font-size: 12px;
  color: #64748b;
  margin: 0;
  letter-spacing: 2px;
  text-transform: uppercase;
}

.auth-form {
  margin-bottom: 8px;
}
.auth-form :deep(.el-form-item) {
  margin-bottom: 16px;
}
.auth-form :deep(.el-input__wrapper) {
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 10px;
  box-shadow: none !important;
  padding: 0 14px;
}
.auth-form :deep(.el-input__wrapper:hover),
.auth-form :deep(.el-input__wrapper.is-focus) {
  border-color: #2563eb;
  background: rgba(37, 99, 235, 0.08);
}
.auth-form :deep(.el-input__inner) {
  color: #e2e8f0;
  height: 44px;
  font-size: 14px;
}
.auth-form :deep(.el-input__inner::placeholder) {
  color: #475569;
}
.auth-form :deep(.el-input__prefix-icon) {
  color: #64748b;
}

.auth-btn {
  width: 100%;
  height: 48px;
  border-radius: 10px;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 4px;
  background: linear-gradient(135deg, #2563eb, #0ea5e9);
  border: none;
  transition: opacity 0.2s, transform 0.1s;
}
.auth-btn:hover {
  opacity: 0.9;
  transform: translateY(-1px);
}
.auth-btn:active {
  transform: translateY(0);
}

.auth-footer {
  text-align: center;
  padding-top: 8px;
}
.footer-text {
  font-size: 13px;
  color: #64748b;
}
.footer-link {
  font-size: 13px;
  font-weight: 600;
  padding: 0 4px;
}
</style>

6.3 路由配置

访问页面/时跳转到login,绑定组件LoginView.vue,对应register绑定组件RegisterView,配置元数据requiresAuth用于路由守卫校验规则,示例代码如下:

javascript 复制代码
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    redirect: '/login',
  },
  {
    path: '/login',
    name: 'Login',
    // @标识src根目录,避免相对路径
    component: () => import('@/views/LoginView.vue'),
    meta: { requiresAuth: false },
  },
  {
    path: '/register',
    name: 'Register',
    component: () => import('@/views/RegisterView.vue'),
    meta: { requiresAuth: false },
  },
  ]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

// 全局前置路由守卫:每次路由跳转前都会执行
router.beforeEach((to) => {
  // 1. 从本地存储读取登录凭证token,相关知识下次介绍
  const token = localStorage.getItem('token')

  // 场景1:当前页面需要登录权限,但用户无token(未登录)
  if (to.meta.requiresAuth && !token) {
    // 直接返回路由对象,强制重定向到登录页Login
    return { name: 'Login' }
  }

  // 场景2:用户已登录(有token),却访问登录/注册页面(无需权限页面)
  if (!to.meta.requiresAuth && token && (to.name === 'Login' || to.name === 'Register')) {
    // 直接返回路由对象,强制跳转到首页Generate
    return { name: 'Generate' }
  }

  // 场景3:不满足以上两种拦截规则,无return语句,默认放行当前路由
})

export default router

6.4 fetch网络请求

使用二级策略,request封装基本请求方法,auth实现业务逻辑,网络请求方法如下:

javascript 复制代码
// 读取Vite环境变量配置的后端基础地址,无配置则默认兜底 /api
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'

/**
 * 全局通用网络请求封装(原生fetch)
 * @param {string} url 接口相对路径,如 /auth/login
 * @param {Object} options fetch原生配置:method、body、headers等
 * @returns {Promise<Object>} 后端返回的JSON数据
 * @throws {Error} HTTP状态异常/后端返回错误时抛出异常,上层try/catch捕获
 */
export async function request(url, options = {}) {
  // 从本地存储取出登录凭证token,用于接口鉴权
  const token = localStorage.getItem('token')

  // 请求头合并逻辑
  const headers = {
    // 默认请求头:告诉后端当前请求体是JSON格式
    'Content-Type': 'application/json',
    // ... 展开运算符:如果token存在,把 { Authorization: 'Bearer xxx' } 合并进headers对象
    ...(token && { Authorization: `Bearer ${token}` }),
    // ... 展开运算符:把外部传入的自定义headers全部合并,可覆盖默认头
    ...options.headers,
  }

  // 发起fetch请求
  const response = await fetch(`${BASE_URL}${url}`, {
    // ... 展开运算符:将外部传入的method、body等配置全部合并到fetch参数
    ...options,
    headers,
  })

  // 判断HTTP响应状态码非2xx(404/401/500等),进入错误处理
  if (!response.ok) {
    // 解析后端返回的错误JSON,解析失败则兜底默认错误对象
    const error = await response.json().catch(() => ({ message: '请求失败' }))
    // 抛出错误,交给调用方捕获弹窗提示
    throw new Error(error.message || '请求失败')
  }

  // 请求正常,解析并返回后端JSON数据
  return response.json()
}

具体业务逻辑如下:

javascript 复制代码
import { request } from './request'

/**
 * 登录接口
 * @param {{ username: string, passwordMd5: string }} credentials
 */
export function loginApi(credentials) {
  return request('/auth/login', {
    method: 'POST',
    body: JSON.stringify(credentials),
  })
}

6.5 Pinia 状态管理

具体登录逻辑由 Pinia 实现,登录页 Vue 调用的即此处,实现代码如下:

javascript 复制代码
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import md5 from 'js-md5'
import { loginApi, registerApi } from '@/api/auth'

/**
 * 密码MD5加密工具
 * @param {string} password 原始明文密码
 * @returns {string} MD5哈希字符串
 */
function hashPassword(password) {
  return md5(password)
}

export const useAuthStore = defineStore('auth', () => {
  // 仅存储用户基础信息,移除token相关状态
  const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))

  // 根据是否存在用户信息判断登录状态
  const isLoggedIn = computed(() => !!user.value)

  /**
   * 登录方法:调用接口、保存用户信息到本地
   * @param {Object} credentials 账号密码表单
   */
  async function login(credentials) {
    const res = await loginApi({
      username: credentials.username,
      passwordMd5: hashPassword(credentials.password),
    })
    // 后端返回 { code: 0, data: { id, username } }
    user.value = { id: res.data.id, username: res.data.username }
    // 只持久化用户信息,移除token存储
    localStorage.setItem('user', JSON.stringify(user.value))
    return res
  }

  /**
   * 注册方法,无状态存储,仅返回接口结果
   * @param {Object} data 注册账号密码
   */
  async function register(data) {
    const res = await registerApi({
      username: data.username,
      passwordMd5: hashPassword(data.password),
    })
    return res
  }

  /**
   * 退出登录:清空用户信息本地缓存
   */
  function logout() {
    user.value = null
    localStorage.removeItem('user')
  }

  return { user, isLoggedIn, login, register, logout }
})

整体调用链路如下:

总结

第一篇让ai先实现效果,边学技术栈边写的文档,而不是系统学习后输出文档再实战的常规流程,可能这也是以后学习的常态。

到这里对AI还是有了更新的认识,起码不能完全依赖AI,它较难把握用户的真实需求,用户在认识不达到一定深度也很难准确说出自己的想法和需求;另外他会针对需求只采用最简单的实现手段,忽略后续扩展性及相关架构设计,开发者必须对基础知识有一定了解才能驾驭AI。

历时三天左右,学了Vue相关基础语法,结合AI能够阅读基本源码,不至于被他忽悠了,以后要是真要搞全栈,黑马的课还是要全看完才踏实。