Vue3+Pinia+Element plus+Express + Mysql实战登录注册

1,179 阅读12分钟

本篇将要分享的内容是从零到一开发一个完整的登录注册功能。看完本篇你会有哪些收获呢!

  1. express 搭建后端项目的过程
  2. mysql2 连接数据以及连接后的相关数据库操作
  3. post 请求方式的处理
  4. cors跨域处理
  5. MVC架构模式
  6. jwt鉴权,包括登录token的生成,登录后token的验证
  7. 使用 bcryptjs进行密码加密以及登录后密码的校验过程
  8. 使用dayjs 格式化时间
  9. 接口编写的过程
  10. Vite 搭建Vue3项目
  11. Pinia 进行token 管理以及用户信息管理
  12. vue-router 的使用,包括路由守卫进行登录前后的权限验证
  13. axios 的二次封装
  14. cookie 的封装
  15. element-plus 的使用,包括自动导入的实现,表单验证功能

需求分析

注册

  1. 用户输入表单相关信息
  2. 用户输入框失去焦点时,校验用户输的输入符不符合规则,不符合规则需要给到用户友好的提示
  3. 点击提交按钮,对整体表单进行校验,没有校验通过,阻止提交到后端,避免浪费宽带,给出用户提示哪些表单验证没通过
  4. 表单验证通过之后,准备提交到后端,在提交到后端之前需要将按钮置灰(防止用户网络差时频繁点击)
  5. 提交到后端
  6. 后端接收到请求,根据接收到的邮箱或者用户名查询用户表里面的数据,看注册用户存不存在,如果存在,需要返回对应的code码和消息提示告诉用户, 如果不存在,则将用户的密码加密后将提交的信息插入数据库。
  7. 插入数据库成功之后,返对应code码和消息提示告用户注册成功了
  8. 前端接收到后端返回的成功消息之后,帮用户跳转到登录页面

登录

  1. 用户输入表单相关信息
  2. 用户输入框失去焦点时,校验用户输的输入符不符合规则,不符合规则需要给到用户友好的提示
  3. 点击提交按钮,对整体表单进行校验,没有校验通过,阻止提交到后端,避免浪费宽带,给出用户提示哪些表单验证没通过
  4. 表单验证通过之后,准备提交到后端,在提交到后端之前需要将按钮置灰(防止用户网络差时频繁点击)
  5. 提交到后端
  6. 后端接收到请求,查询对应的用户名存不存在。如果存在则进行密码对比,密码不一样则返回对应code码和消息提示。 如果密码一样,则生成token, 将token、成功的code码、消息、用户信息返回给前端
  7. 前端接收到登录成功的返回后,将token 设置到cookie 中,将用户信息存到本地,
  8. 跳转到Dashbord

由上面的需求分析可以看到注册登录的最大区别是在提交到后端之后的的逻辑,包括后端处理完之后返回给前端,前端的处理逻辑。 当然在提交到后端之前也有一些区别的,主要是注册由邮箱和确认密码,而登录过程不需要了。

后端功能开发

1. 新建数据库vue_auth_login

2. 新建user 用户表

image.png

3. 创建后端工程项目, 目录结构如下

image.png

4. 安装相关项目所需依赖

npm install express cors jsonwebtoken bcryptjs mysql2 dayjs

这里解释下一些包的用处:

  • cors 处理跨域用的
  • jsonwebtoken 登录鉴权使用, 用户生成token, 和生成token之后调用接口用于判断是否登录了
  • bcryptjs 用于加密密码
  • mysql2 用于连接数据库
  • dayjs 处理注册时时间格式化

5. 连接数据库

config/database.js

const mysql = require("mysql2");

const pool = mysql.createPool({
  user: "root", // 数据库的用户名
  password: "123456", // 数据库的密码
  host: "localhost", // 数据服务地址
  port: "3306", // 数据库端口号
  database: "vue_auth_login", // 数据库名称
  dateStrings: true, // 规定查询时间字段的显示形式
});

module.exports = pool.promise();

6. cors 配置

config/cors.js

const corsOptions = {
  origin: "http://localhost:5173", // 允许的请求源
  methods: ["GET", "POST", "PUT", "DELETE"], // 允许的请求头方式
  allowedHeaders: ["Content-Type", "Authorization", "token"], // 允许的请求头
  credentials: true, // 是否允许发送Cookie 和其他凭证
  optionsSuccessStatus: 204, // 对于浏览器的兼容性
};

module.exports = corsOptions;

7. 鉴权的key 配置

config/jwt.js

const SECRET_KEY =
  "da4478b7105d11e8b739184feea15170a8fbf028a76f0da4d615071675e66e7b";

module.exports = SECRET_KEY;

8. 数据库相关操作逻辑

modles/user.js

const dayjs = require("dayjs");
const db = require("../config/database");

class User {
  constructor(userInfo) {
    this.userInfo = userInfo;
  }
  /**
   *
   * @returns 注册结果
   */
  async register() {
    const { userName, email, password } = this.userInfo;
    const findUserSql = "SELECT * FROM user WHERE userName = ? OR email = ?";
    const data = await db.execute(findUserSql, [userName, email]);
    if (data[0].length > 0) {
      return {
        code: 402,
        msg: "用户已存在",
      };
    }
    const time = dayjs().format("YYYY-MM-DD HH:mm:ss");

    console.log(userName, email, password, time);
    const addUserSql =
      "INSERT INTO user (userName, email, password, time) VALUES (?, ?, ?, ?)";
    const Info = await db.execute(addUserSql, [
      userName,
      email,
      password,
      time,
    ]);

    return {
      code: 200,
      msg: "注册成功",
      data: {
        userName,
        email,
      },
    };
  }

  static async login(userName) {
    const sql = "SELECT * FROM user WHERE userName = ?";
    return db.execute(sql, [userName]);
  }

  static getUserInfo(userId) {
    const sql = "SELECT * FROM user WHERE id = ?";
    return db.execute(sql, [userId]);
  }
}

module.exports = User;

这个文件主要用于处理,和数据相关的操作,包括登录、注册、查询用户信息。

9. 路由配置

routes/user.js

const express = require("express");

const router = express.Router();
const verifyToken = require("../utils/jwt");

const { register, login, getUserInfo } = require("../controllers/user");

router.post("/api/register", register);
router.post("/api/login", login);
router.get("/api/userInfo", verifyToken, getUserInfo);

module.exports = router;

这个文件主要用来配置路由相关,有几点需要注意:

  • 这里路由地址就是前端请求接口的地址
  • 请求处理的方法逻辑会放在controllers 目录下面, 这是一种MVC的架构
  • 可以看到/api/userInfo 还用到了一个verifyToken 函数,这是用来验证登录token是否合法的

10. 控制器

controllers/user.js

const bcrypt = require("bcrypt");

const UserModel = require("../models/user");

const jwt = require("jsonwebtoken");

const SECRET_KEY = require("../config/jwt");

exports.register = async function (req, res) {
  console.log(req);
  const { userName, email, password } = req.body;
  console.log(userName, email, password, "接收参数");
  if (!userName || !email || !password) {
    res.send({
      code: 401,
      msg: "用户名、邮箱、密码为必填",
    });
  }
  const user = new UserModel({
    userName: userName,
    email,
    password: await bcrypt.hash(password, 10),
  });
  const userInfo = await user.register();
  res.send(userInfo);
};

exports.login = async function (req, res) {
  const { userName, password } = req.body;
  console.log(userName, password, "login");
  if (!userName || !password) {
    res.send({
      code: 200,
      msg: "用户名密码不能为空",
    });
  }

  const [users, info] = await UserModel.login(userName);
  if (users.length <= 0) {
    res.send({
      code: 401,
      msg: "用户名不存在",
    });
    return;
  }

  const user = users[0];
  console.log(user.password, bcrypt.hashSync(password, 10));
  if (!bcrypt.compareSync(password, user.password)) {
    res.send({
      code: 401,
      msg: "密码错误",
    });
    return;
  }

  const token = jwt.sign({ id: user.id }, SECRET_KEY, { expiresIn: "24h" });
  res.send({
    code: 200,
    msg: "登录成功",
    data: {
      token: token,
      email: user.email,
      userName: user.userName,
    },
  });
};

exports.getUserInfo = async (req, res) => {
  const userId = req.userId;
  console.log(userId, "userId");
  const [users, rowInfo] = await UserModel.getUserInfo(userId);
  if (users.length <= 0) {
    res.status(404).send({
      code: 200,
      msg: "用户不存在",
    });
  }
  const { userName, email } = users[0];
  res.send({
    code: 200,
    msg: "成功获取用户信息",
    data: {
      userName,
      email,
    },
  });
};

在controllers 里面需要注意的是:

  • 使用了bcrypt.hash(password, 10) 对密码进行加密
  • 登录时使用 bcrypt.compareSync(password, user.password) 进行密码的对比。password 是用户登录提交过来的密码是没有加密的, user.password 是根据登录前端传过来的用户名查出来的密码。
  • 登录时使用jwt.sign({ id: user.id }, SECRET_KEY, { expiresIn: "24h" }) 生成token

11. jwt 验证token 的工具方法

jwt 验证token 是否合法工具方法,也是一个中间件 utils/jwt.js

const jwt = require("jsonwebtoken");
const SECRET_KEY = require("../config/jwt");

function verifyToken(req, res, next) {
  const token = req.headers["authorization"];
  console.log("verifyToken执行", token);
  if (!token) {
    return res.status(403).send({ msg: "token 为空" });
  }
  console.log(SECRET_KEY, "----SECRET_KEY");
  jwt.verify(token, SECRET_KEY, (err, decoded) => {
    if (err) {
      return res.status(401).send("token 未授权");
    }
    req.userId = decoded.id;
  });
  next();
}
module.exports = verifyToken;

12. 后端入口文件

index.js

const express = require("express");
const app = express();
const cors = require("cors");
const corsOptions = require("./config/cors");
const router = require("./routes/user");

app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(cors(corsOptions));
app.use(router);

app.listen(9000, () => {
  console.log("http://localhost:9000");
});

到这里后端接口就开发完成了。 可以用Postman 测试一下

13. 接口测试

先来测下注册接口:

image.png

可以看到接口返回成功了,我们在到数据库里面查看下是否注册成功了。

image.png 可以看到数据库里面也插入成功了。

测试登录接口:

image.png 可以看到登录接口成功了。

测试获取用户的接口:

image.png

可以看到获取用户信息的接口也能正常返回用户信息。到这里后端就开发测试完成了。接下来开始前端开发

前端功能开发

1. 使用vite创建Vue3项目:

pnpm create vite vue3login --template vue

2. 安装依赖

pnpm i vue-router pina axios element-plus

pnpm i unplugin-auto-import unplugin-vue-components sass -D

3. 创建目录结构

image.png

4. 封装coookie 相关方法

utils/cookie.js

const CookieUtil = {
  /**
   * 获取所有 Cookies,并将其转换为对象
   * 使用 reduce 解析 cookie 字符串
   */
  getCookies: function () {
    return document.cookie.split("; ").reduce((prev, cookieStr) => {
      const [key, value] = cookieStr.split("=");
      prev[decodeURIComponent(key)] = decodeURIComponent(value);
      return prev;
    }, {});
  },

  /**
   * 设置一个 Cookie
   * @param {string} name - Cookie 名称
   * @param {string} value - Cookie 值
   * @param {number} [days] - 过期天数
   * @param {string} [path] - Cookie 路径
   */
  setCookie: function (name, value, days, path = "/") {
    let cookieStr = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;

    if (days) {
      const expirationDate = new Date();
      expirationDate.setTime(
        expirationDate.getTime() + days * 24 * 60 * 60 * 1000
      );
      cookieStr += `; expires=${expirationDate.toUTCString()}`;
    }

    cookieStr += `; path=${path}`;
    document.cookie = cookieStr;
  },

  /**
   * 获取单个 Cookie 的值
   * @param {string} name - Cookie 名称
   * @returns {string | null} - Cookie 值或 null
   */
  getCookie: function (name) {
    const cookies = this.getCookies();
    return cookies[name] || "";
  },

  /**
   * 删除一个 Cookie
   * @param {string} name - Cookie 名称
   * @param {string} [path] - Cookie 路径
   */
  deleteCookie: function (name, path) {
    this.setCookie(name, "", -1, path);
  },
};

export { CookieUtil };

5. 封装axios 请求工具方法

utils/request.js

import axios from "axios";
import { CookieUtil } from "./cookie";

axios.defaults.baseURL = "http://localhost:9000";
axios.interceptors.request.use(
  function (config) {
    if (CookieUtil.getCookie("token")) {
      config.headers["Authorization"] = CookieUtil.getCookie("token");
    }
    // 在发送请求之前做些什么
    return config;
  },
  function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
  }
);
export function get(url, params = {}) {
  return new Promise((resolve, reject) => {
    axios
      .get(url, {
        params: params,
      })
      .then((res) => {
        resolve(res.data);
      })
      .catch((err) => {
        reject(err.data);
      });
  });
}

export function post(url, params) {
  return new Promise((resolve, reject) => {
    axios
      .post(url, params)
      .then((res) => {
        resolve(res.data);
      })
      .catch((err) => {
        reject(err.data);
      });
  });
}

6. 封装userStore

store/user.js

import { defineStore } from "pinia";
import { CookieUtil } from "@/utils/cookie";
import router from "@/router";

const useUserStore = defineStore("userStore", {
  state: () => {
    const user = localStorage.getItem("userInfo");
    const userInfo = user ? JSON.parse(user) : {};
    return {
      token: CookieUtil.getCookie("token"),
      userInfo,
    };
  },
  actions: {
    setToken(token) {
      // 设置token到cookie
      this.token = token;
      CookieUtil.setCookie("token", token);
    },
    setUserInfo(userInfo) {
      // 设置用户信息
      this.userInfo = userInfo;
      localStorage.setItem("userInfo", JSON.stringify(this.userInfo));
    },
    loginOut() {
      // 退出登录
      CookieUtil.deleteCookie("token");
      localStorage.removeItem("userInfo");
      router.push({ name: "Login" });
    },
  },
});

export default useUserStore;

7. 注册页面开发

views/Register.vue

<template>
  <div class="form-wraper">
    <div class="form-content">
      <el-form
        ref="ruleFormRef"
        style="max-width: 600px"
        :model="ruleForm"
        :rules="rules"
        class="demo-ruleForm"
        label-width="auto"
        status-icon
      >
        <el-form-item label="邮箱" prop="email">
          <el-input v-model="ruleForm.email" />
        </el-form-item>
        <el-form-item label="用户名" prop="userName">
          <el-input v-model="ruleForm.userName" />
        </el-form-item>
        <el-form-item label="密码" prop="password">
          <el-input v-model="ruleForm.password" type="password" />
        </el-form-item>
        <el-form-item label="确认密码" prop="confirmPassword">
          <el-input v-model="ruleForm.confirmPassword" type="password" />
        </el-form-item>
        <el-form-item class="btn-wrap">
          <el-button type="primary" @click="submitForm(ruleFormRef)" :disabled="isSubmit">
            注册
          </el-button>
        </el-form-item>
        <p>已有账号!直接<RouterLink to="/login" class="link">登录</RouterLink></p>
      </el-form>
    </div>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { post } from '@/utils/request'

const router = useRouter()

// 表单数据
const ruleForm = reactive({
  userName: '',
  email: '',
  password: '',
  confirmPassword:''
})

// form 表单组件实例
const ruleFormRef = ref(null)

// 自定义验证密码的方法
const validateConfirmPass = (rule, value, callback) => {
  if (value !== ruleForm.password) {
    callback(new Error('两次输入密码不一样'))
  }
  callback()
}

// 验证规则
const rules = reactive({
  email: [
    {
      required: true,
      message: '请输入邮箱',
      trigger: 'blur',
    },
    {
      type: 'email',
      message: '邮箱格式不正确',
      trigger: ['blur', 'change'],
    },
  ],
  userName: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 2, max: 16, message: '用户名只能在2到16个字之间', trigger: 'blur' },
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, max: 16, message: '密码只能在6到16个字之间', trigger: 'blur' },
  ],
  confirmPassword: [
    { required: true, message: '请输入确认密码', trigger: 'blur' },
    { min: 6, max: 16, message: '密码只能在6到16个字之间', trigger: 'blur' },
    { validator:validateConfirmPass,  trigger: 'blur' },
  ]
})

// 是否在提交过程中
const isSubmit = ref(false)
const submitForm = (formEl) => {
  if (!formEl) return
  formEl.validate((valid) => {
    if (valid) {
      console.log('submit!')
      const data = {
        email: ruleForm.email,
        userName: ruleForm.userName,
        password: ruleForm.password
      }
      isSubmit.value = true
      post('/api/register', data).then(res => {
        if (res.code === 200) {
          ElMessage({ type: 'success', message: '注册成功'})
          router.push({ name: 'Login'})
        } else {
          ElMessage({ type: 'error', message: res.msg})
        }
      }).catch(err => {
        ElMessage({ type: 'error', message: err})
      }).finally(() => {
        isSubmit.value = false
      })
    } else {
      console.log('error submit!')
    }
  })
}
</script>

<style src="@/assets/css/login.scss" lang="scss" scoped>
</style>

8. 登录页面

views/Login.vue

<template>
  <div class="form-wraper">
    <div class="form-content">
      <h2>登录</h2>
      <el-form
        ref="ruleFormRef"
        style="max-width: 600px"
        :model="ruleForm"
        :rules="rules"
        class="demo-ruleForm"
        status-icon
        label-width="auto"
      >
        <el-form-item label="用户名" prop="userName">
          <el-input v-model="ruleForm.userName" />
        </el-form-item>
        <el-form-item label="密码" prop="password">
          <el-input v-model="ruleForm.password" type="password" />
        </el-form-item>
        <el-form-item label="" class="btn-wrap">
          <el-button type="primary" @click="submitForm(ruleFormRef)" :disabled="isSubmit">
            登录
          </el-button>
        </el-form-item>
        <p class="desc">还没有账号!去<RouterLink to="/register" class="link">注册</RouterLink></p>
      </el-form>
    </div>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router'
import { post } from '@/utils/request'
import useUserStore from '@/store/user'

const userStore = useUserStore()
const router = useRouter()
// 表单数据
const ruleForm = reactive({
  userName: '',
  password: ''
})

// form 表单组件实例
const ruleFormRef = ref(null)

// 表单验证规则
const rules = reactive({
  userName: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 2, max: 16, message: '用户名只能在2到16个字之间', trigger: 'blur' },
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, max: 16, message: '密码只能在6到16个字之间', trigger: 'blur' },
  ]
})

// 是否在提交过程中
const isSubmit = ref(false)
const submitForm = (formEl) => {
  if (!formEl) return
  formEl.validate((valid) => {
    if (valid) {
      console.log('submit!')
      const data = {
        userName: ruleForm.userName,
        password: ruleForm.password
      }
      isSubmit.value = true
      post('/api/login', data).then(res => {
        if (res.code === 200) {

          // 提示用户登录成功
          ElMessage({ type: 'success', message: '登录成功'})

          const { userName, email, token } = res.data
          // 设置token
          userStore.setToken(token)

          //设置用户信息
          userStore.setUserInfo({
            userName,
            email
          })

          // 登录成功跳转到Dashbord
          router.push({ name: 'Dashbord'})

        } else {

          // 登录失败提示
          ElMessage({ type: 'error', message: res.msg })
        }
        
      }).catch(err => {
        ElMessage({ type: 'error', message: err })
      }).finally(() => {
        isSubmit.value = false
      })
    } else {
      console.log('error submit!')
    }
  })
}
</script>

<style lang="scss" src="@/assets/css/login.scss" scoped>
</style>

9. Dashbord 页面

views/Dashbord.vue

<template>
  <div class="dashbard">
    <p>欢迎来到Dashbord,下面是你的人信息</p>
    <p>用户名: <b>{{ userInfo.userName }}</b></p>
    <p>邮箱: <b>{{ userInfo.email }}</b></p>
    <div class="btn-wrap">
      <el-button @click="userStore.loginOut()">退出登录</el-button>
    </div>
  </div>
</template>

<script setup>
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import useUserStore from '@/store/user'
import { get } from '@/utils/request'

const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)

function updateUserInfo () {
  if (userInfo.value.userName) {
    return
  }
  get('api/userInfo').then(res => {
    if (res.code === 200) {
      const { userName, email } = res.data
      userStore.setUserInfo({ userName, email })
    }
  })
}
onMounted(() => {
  updateUserInfo()
})

</script>

<style lang="scss" scoped>
.dashbard {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  margin-top: 100px;
  p{
    margin-top: 20px;
    font-size: 20px;
  }

  .btn-wrap {
    margin-top: 20px;
  }
}
</style>

10. 登录页面和注册页面样式

assets/css/login.scss

.form-wraper{
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  height:100vh;
  box-sizing: border-box;
  background: url('@/assets/images/login-bg.png') no-repeat center;
  background-size: cover;

  h2{
    margin-bottom: 20px;
  }
}
.form-content {
  background: #fff;
  border-radius: 10px;
  padding: 40px 60px;
  box-shadow: 0 0 10px 10px rgba(0, 0, 0, 0.5);

  .btn-wrap ::v-deep(.el-form-item__content){
    display: flex;
    justify-content: center;
    .el-button--primary{
      width: 100%;
    }
  }
  .desc {
    text-align: center;
  }
  .link {
    color: #409eff
  }
}

11. 路由配置

router/index.js

import { createRouter, createWebHistory } from "vue-router";
import { CookieUtil } from "../utils/cookie";

const routes = [
  {
    path: "/Register",
    name: "Register",
    component: () => import("@/views/Register.vue"),
  },
  {
    path: "/login",
    name: "Login",
    component: () => import("@/views/Login.vue"),
  },
  {
    path: "/dashbord",
    name: "Dashbord",
    component: () => import("@/views/Dashbord.vue"),
    meta: {
      auth: true,
    },
  },
];

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

router.beforeEach((to, from, next) => {
  if (!CookieUtil.getCookie("token") && to.meta.auth) {
    // 非登录状态下进入需要登录的页面重定向到登录页面
    next({ name: "Login" });
  } else if (
    CookieUtil.getCookie("token") &&
    ["Login", "Register"].includes(to.name)
  ) {
    // 登录状态下,进入到登录注册页面,重定向到Dashbord
    next("Dashbord");
  } else {
    next();
  }
});

export default router;

这里主要用户了router.beforeEach 进行登录前后权限的判断进行相应的跳转。

12. 跟组件

App.vue

<template>
  <div>
    <RouterView></RouterView>
  </div>
</template>

<script setup>
</script>

<style>
*{
  margin: 0;
  padding: 0;
}
</style>

13. 入口文件

main.js

import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router/index";
import App from "./App.vue";

const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

到这里登录注册功能前后端功能我们都开发完了,现在我们来看下运行效果吧!

效果展示

login_register.gif

总结

本篇通过 Express、MySQL2、JWT、bcryptjs、dayjs、CORS 完成了后端的开发,通过Vue3、Vite、Pinia、Vue Router、Axios、Element Plus 完成了前端功能的开发,通过本篇相信你已经学会了注册登录的整个过程。并且学习到了很多知识点。比如鉴权,密码加密,路由导航守卫实现权限管理,跨域处理等。