简介这是一套面向前端与全栈初学者的用户登录注册全流程实战项目源码基于 TypeScript、Express、Vue3、Element Plus 与 MySQL 搭建适合想打通前后端接口联调、掌握鉴权业务逻辑的开发者参考学习。后端使用 tsexpressmysql 提供登录注册相关接口前端以 tsvue3elementplus 实现管理界面及对应业务流程覆盖从表单校验到接口请求的完整链路。压缩包共约 2000 个文件以 1100 个 js 脚本、764 个 md 说明文档、129 个 json 配置及少量 txt 为主整体约 58.57MB目录结构清晰便于按模块检索与二次开发。目前已有 1104 人学习下载读者可从中获取可运行的前后端工程模板、接口定义与页面组织方式并对照配套开发视频与详细文档理解登录注册的完整实现思路快速应用到自己的项目中。1. 从零搭一套 TS Express Vue3 Element Plus MySQL 的登录注册这套组合到底值不值得做如果你正在搜「vue3后台管理系统」「express框架」「mysql安装配置教程」大概率你手上已经有一个要交付的小项目可能是内部工具、课程设计或者一个准备长期迭代的 MVP。这套 TS Express Vue3 Element Plus MySQL 的组合是目前国内中小团队做后台系统最稳的一条路——前端 Vue3 配 Element Plus 出活快后端 Express 轻量、上手成本低TypeScript 把前后端类型串起来MySQL 负责持久化。它不追求架构上的花哨追求的是「一个人一周能把登录注册跑通两周能长出业务模块」。这篇文章不讲空泛的选型对比只讲一条能复现的路径环境怎么装、数据库怎么建、后端接口怎么写、前端页面怎么接、Token 怎么存、跨域和类型报错怎么排。适合两类人一是刚学完 Vue3 想找个完整项目练手的二是接了个小后台、需要快速把用户体系立起来的。中间会穿插我踩过的坑比如 mysql 连接池耗尽、Element Plus 表单校验不触发、TS 类型在前后端对不上这些血泪经验。2. 环境与依赖把 mysql、express、vue3 三端装到能互相说话2.1 先定版本别让版本号成为第一道坎这套组合最容易翻车的地方不是代码是版本。Node 版本、TypeScript 版本、Vue3 的构建工具版本三者之间互相牵制。我一般会锁定一套经过验证的组合避免「昨天还能跑今天 npm i 就崩」。组件推荐版本区间说明Node.js18.x LTS 或 20.x LTS别用奇数版本某些原生模块编译会出问题TypeScript5.x4.x 对 Vue3 的 defineProps 泛型支持不完整Express4.x5.x 的中间件签名有变动生态还没跟上Vue33.4组合式 API 稳定defineModel 可用Element Plus2.7表单校验和表格组件的 bug 修得比较干净MySQL8.08.0 的默认认证插件是 caching_sha2_password注意驱动兼容MySQL 安装这块Windows 上用官方 installerLinux 上用 apt 或 yum 装完记得mysql_secure_installation。装完先别急着写代码用命令行连一次确认账号密码能进mysql -u root -p # 输入密码后进入 mysql 命令行 CREATE DATABASE user_auth DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; SHOW DATABASES;这里utf8mb4是必须的别用utf8否则用户昵称里带 emoji 会直接报错。字符集这个问题在注册接口里特别隐蔽——本地测试用英文名没事一上线用户输入中文昵称就乱码。2.2 后端工程初始化Express TS 的最小骨架后端我一般用ts-node-dev做热重载比 nodemon 配 ts 更省心。先建目录再装依赖mkdir server cd server npm init -y npm i express mysql2 cors jsonwebtoken bcryptjs dotenv npm i -D typescript ts-node-dev types/express types/cors types/jsonwebtoken types/bcryptjs npx tsc --inittsconfig.json里重点改这几项其他保持默认{ compilerOptions: { target: ES2020, module: commonjs, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, resolveJsonModule: true }, include: [src/**/*.ts] }strict: true一定要开。很多人为了省事关掉 strict结果就是接口返回的user可能是null前端拿到null去取user.name直接白屏。类型检查帮你提前拦住这类问题代价只是多写几个if。package.json的 scripts 加两条{ scripts: { dev: ts-node-dev --respawn --transpile-only src/app.ts, build: tsc node dist/app.js } }--transpile-only是跳过类型检查直接转译开发时快很多类型检查交给编辑器和npm run build。这个习惯能让你在改代码时不用等 tsc 全量扫描。2.3 前端工程初始化Vue3 Element Plus TS前端用 Vite 起比 webpack 快一个数量级npm create vitelatest client -- --template vue-ts cd client npm i element-plus axios vue-router pinia npm i -D unplugin-auto-import unplugin-vue-componentsElement Plus 按需引入是标配全量引入会让首屏包体积多出几百 KB。vite.config.ts配置import { defineConfig } from vite import vue from vitejs/plugin-vue 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()] }) ], server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } })proxy这段是解决跨域最省事的方式。开发阶段前端跑在 5173后端跑在 3000浏览器同源策略会拦请求。用 Vite 的 proxy 把/api转发到后端前端代码里直接写/api/login不用配 CORS 也能通。生产环境再用 Nginx 做同样的转发前后端代码都不用改。3. 数据库与后端接口用户表设计、密码加密、JWT 签发3.1 用户表怎么建字段、索引、默认值用户表看着简单但字段类型和索引设计错了后面改起来很痛苦。我一般这样建CREATE TABLE users ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, nickname VARCHAR(50) DEFAULT NULL, email VARCHAR(100) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username), KEY idx_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个关键点username加唯一索引注册时的重复校验靠数据库兜底别只靠代码里的SELECT判断——并发下两个请求同时查到「不存在」然后都插入就出重复数据了。password字段留 100 长度因为 bcrypt 哈希后是 60 个字符留余量。status用 TINYINT 而不是 BOOLEANMySQL 的 BOOLEAN 本质就是 TINYINT(1)不如直接写清楚。3.2 连接池配置别每次请求都新建连接MySQL 连接是稀缺资源每个请求mysql.createConnection再end并发一上来就报Too many connections。正确做法是用连接池// src/db.ts import mysql from mysql2/promise import dotenv from dotenv dotenv.config() export const pool mysql.createPool({ host: process.env.DB_HOST || localhost, user: process.env.DB_USER || root, password: process.env.DB_PASSWORD || , database: process.env.DB_NAME || user_auth, waitForConnections: true, connectionLimit: 10, queueLimit: 0, charset: utf8mb4 })connectionLimit: 10是经验值小项目够用。queueLimit: 0表示排队不限制请求超过 10 个时后面的等着而不是直接报错。charset必须写utf8mb4否则中文昵称存进去变问号。这里有个坑mysql2的createPool是同步返回的但pool.query是异步的别在模块顶层直接await会阻塞启动。3.3 注册接口bcrypt 加密与唯一性校验注册的核心是「先查重、再加密、后插入」三步顺序不能乱// src/routes/auth.ts import { Router } from express import bcrypt from bcryptjs import { pool } from ../db const router Router() router.post(/register, async (req, res) { const { username, password, nickname } req.body if (!username || !password) { return res.status(400).json({ code: 400, msg: 用户名和密码不能为空 }) } if (password.length 6) { return res.status(400).json({ code: 400, msg: 密码至少6位 }) } try { const [rows] await pool.query( SELECT id FROM users WHERE username ?, [username] ) if ((rows as any[]).length 0) { return res.status(409).json({ code: 409, msg: 用户名已存在 }) } const hash await bcrypt.hash(password, 10) const [result] await pool.query( INSERT INTO users (username, password, nickname) VALUES (?, ?, ?), [username, hash, nickname || username] ) res.json({ code: 0, msg: 注册成功, data: { id: (result as any).insertId } }) } catch (err) { console.error(register error:, err) res.status(500).json({ code: 500, msg: 服务器内部错误 }) } }) export default routerbcrypt.hash(password, 10)的 10 是 salt rounds值越大越安全但越慢。10 是平衡点单次哈希约 100ms能挡住暴力破解又不至于拖垮接口。pool.query用?占位符别用字符串拼接否则就是 SQL 注入的活靶子。返回的code字段用 0 表示成功、非 0 表示失败这是国内后台系统的常见约定前端拦截器好处理。3.4 登录接口JWT 签发与过期策略登录成功后签发 Token前端存起来后续请求带上import jwt from jsonwebtoken router.post(/login, async (req, res) { const { username, password } req.body try { const [rows] await pool.query( SELECT id, username, password, nickname, status FROM users WHERE username ?, [username] ) const users rows as any[] if (users.length 0) { return res.status(401).json({ code: 401, msg: 用户名或密码错误 }) } const user users[0] if (user.status ! 1) { return res.status(403).json({ code: 403, msg: 账号已被禁用 }) } const match await bcrypt.compare(password, user.password) if (!match) { return res.status(401).json({ code: 401, msg: 用户名或密码错误 }) } const token jwt.sign( { id: user.id, username: user.username }, process.env.JWT_SECRET || dev_secret, { expiresIn: 7d } ) res.json({ code: 0, msg: 登录成功, data: { token, user: { id: user.id, username: user.username, nickname: user.nickname } } }) } catch (err) { console.error(login error:, err) res.status(500).json({ code: 500, msg: 服务器内部错误 }) } })注意「用户名或密码错误」这个提示不要分开写「用户不存在」和「密码错误」——那等于告诉攻击者哪些用户名是有效的。expiresIn: 7d是后台系统的常见时长太短用户老要重新登录太长被盗了风险大。JWT_SECRET必须放环境变量别硬编码在代码里提交到 Git 就等于泄露。3.5 鉴权中间件把 Token 校验抽出来需要登录才能访问的接口统一走中间件import { Request, Response, NextFunction } from express import jwt from jsonwebtoken export interface AuthRequest extends Request { user?: { id: number; username: string } } export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) { const header req.headers.authorization if (!header || !header.startsWith(Bearer )) { return res.status(401).json({ code: 401, msg: 未登录 }) } const token header.slice(7) try { const payload jwt.verify(token, process.env.JWT_SECRET || dev_secret) as any req.user { id: payload.id, username: payload.username } next() } catch (err) { return res.status(401).json({ code: 401, msg: Token 无效或已过期 }) } }AuthRequest扩展了 Express 的Request类型把user挂上去后面的路由处理函数就能直接req.user.id。这是 TS 在 Express 里最实用的一个模式比(req as any).user干净得多。header.slice(7)是去掉Bearer前缀长度是 7别写成 6。4. 前端页面与联调Element Plus 表单、Axios 封装、路由守卫4.1 Axios 封装统一处理 Token 和错误前端所有请求走一个封装好的 axios 实例别在每个组件里裸调// src/utils/request.ts import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( (response) { const { code, msg, data } response.data if (code ! 0) { ElMessage.error(msg || 请求失败) return Promise.reject(new Error(msg)) } return data }, (error) { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) ElMessage.error(登录已过期请重新登录) } else { ElMessage.error(error.response?.data?.msg || 网络错误) } return Promise.reject(error) } ) export default request响应拦截器里判断code ! 0就弹错误这样业务代码里不用每次都写if (res.code 0)。401 统一跳登录页用户不会卡在一个空白页上不知道发生了什么。baseURL: /api配合 Vite 的 proxy开发和生产都不用改代码。4.2 登录页Element Plus 表单校验的触发时机Element Plus 的el-form校验有个常见坑规则写了但点按钮不触发。原因是ref没绑对或者prop和model的字段名不一致template div classlogin-wrap el-card classlogin-card template #header h2用户登录/h2 /template el-form refformRef :modelform :rulesrules label-width0 el-form-item propusername el-input v-modelform.username placeholder用户名 / /el-form-item el-form-item proppassword el-input v-modelform.password typepassword placeholder密码 show-password / /el-form-item el-form-item el-button typeprimary :loadingloading clickhandleLogin登录/el-button el-button click$router.push(/register)去注册/el-button /el-form-item /el-form /el-card /div /template script setup langts import { ref, reactive } from vue import { useRouter } from vue-router import { ElMessage, type FormInstance, type FormRules } from element-plus import request from ../utils/request const router useRouter() const formRef refFormInstance() const loading ref(false) const form reactive({ username: , password: }) const rules: FormRules { username: [ { required: true, message: 请输入用户名, trigger: blur }, { min: 3, max: 20, message: 长度 3 到 20 位, trigger: blur } ], password: [ { required: true, message: 请输入密码, trigger: blur }, { min: 6, message: 密码至少 6 位, trigger: blur } ] } async function handleLogin() { if (!formRef.value) return const valid await formRef.value.validate().catch(() false) if (!valid) return loading.value true try { const data await request.post(/login, form) localStorage.setItem(token, data.token) localStorage.setItem(user, JSON.stringify(data.user)) ElMessage.success(登录成功) router.push(/dashboard) } finally { loading.value false } } /scriptformRef.value.validate()返回 Promise校验失败会 reject所以用.catch(() false)转成布尔值。trigger: blur表示失焦时校验也可以写change。FormInstance和FormRules这两个类型从 Element Plus 导入别自己手写版本升级时类型定义会跟着变。4.3 路由守卫未登录不能进后台页前端路由守卫是第二道防线第一道是后端的鉴权中间件。两者都要有不能只靠前端// src/router/index.ts import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(), routes: [ { path: /login, component: () import(../views/Login.vue) }, { path: /register, component: () import(../views/Register.vue) }, { path: /dashboard, component: () import(../views/Dashboard.vue), meta: { requiresAuth: true } } ] }) router.beforeEach((to, _from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if ((to.path /login || to.path /register) token) { next(/dashboard) } else { next() } }) export default routermeta.requiresAuth标记需要登录的页面守卫里统一判断。已登录用户访问登录页时重定向到 dashboard避免重复登录。注意next()必须调用否则导航会卡住页面白屏。4.4 注册页与登录页的差异点注册页比登录页多一个「确认密码」字段校验规则要用自定义 validatorconst validateConfirm (_rule: any, value: string, callback: any) { if (value ! form.password) { callback(new Error(两次输入的密码不一致)) } else { callback() } } const rules: FormRules { username: [{ required: true, message: 请输入用户名, trigger: blur }], password: [{ required: true, min: 6, message: 密码至少 6 位, trigger: blur }], confirmPassword: [ { required: true, message: 请再次输入密码, trigger: blur }, { validator: validateConfirm, trigger: blur } ] }注册成功后不要自动登录跳回登录页让用户手动登一次。这样用户能确认自己记住的密码是对的也避免注册接口和登录接口的 Token 逻辑耦合在一起。5. 避坑与排查这套组合最容易翻车的 5 个地方5.1 现象接口报ER_NOT_SUPPORTED_AUTH_MODE原因MySQL 8.0 默认用caching_sha2_password认证插件老版本的mysql驱动不支持。解决换mysql2驱动或者在 MySQL 里把用户改成mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码; FLUSH PRIVILEGES;我一般直接用mysql2它原生支持caching_sha2_password不用改数据库配置。5.2 现象前端请求 404但后端日志没有记录原因Vite proxy 没生效或者baseURL和 proxy 的匹配规则对不上。检查vite.config.ts里proxy的 key 是不是/api前端baseURL是不是也是/api。如果后端路由挂载在/api/auth那前端请求/api/login就会 404应该是/api/auth/login。这个路径拼接错误在新手里特别常见因为两边都写了/api容易以为是同一个。5.3 现象Element Plus 表单校验不触发点按钮没反应原因el-form的ref名字和script里的变量名不一致或者el-form-item的prop和model字段对不上。还有一种情况是rules用了reactive包裹Element Plus 读不到。解决rules用普通对象或FormRules类型标注ref用refFormInstance()prop严格对应form的 key。5.4 现象bcrypt.compare 总是返回 false原因注册时存的是明文或者存的时候被截断了。检查数据库里password字段的长度bcrypt 哈希是 60 字符如果字段是VARCHAR(50)就会被截断登录时比对必然失败。另外确认注册和登录用的是同一个bcrypt库bcryptjs和bcrypt的哈希格式不通用。5.5 现象Token 过期后前端一直弹「未登录」但用户已经重新登录了原因axios 拦截器里 401 跳登录页但 localStorage 里的旧 Token 没清干净或者多个请求同时 401 触发多次跳转。解决401 时先localStorage.removeItem(token)再用一个标志位防止重复跳转let isRedirecting false if (error.response?.status 401 !isRedirecting) { isRedirecting true localStorage.removeItem(token) router.push(/login).finally(() { isRedirecting false }) }6. 进阶技巧把登录态做得更稳的几个细节Token 存 localStorage 是最简单的方案但有个天然缺陷XSS 攻击能直接读走。如果项目对安全要求高可以换成 httpOnly Cookie后端res.cookie(token, token, { httpOnly: true, sameSite: strict })前端不用手动带 Token浏览器自动带上。代价是跨域配置更麻烦而且前端拿不到 Token 就没法解析用户信息得再加一个/api/me接口。另一个细节是 Token 续期。7 天过期对活跃用户不友好可以在拦截器里判断 Token 快过期时自动刷新。简单做法是后端签发两个 TokenaccessToken 短过期2 小时refreshToken 长过期7 天accessToken 过期时用 refreshToken 换新的。这套逻辑代码量不大但能显著减少用户重新登录的次数。最后说一个我自己的习惯每次改完登录注册相关代码一定手动走一遍完整流程——注册新用户、退出、用新用户登录、访问需要鉴权的页面、等 Token 过期再访问。自动化测试可以后面补但这条手动路径必须每次过。我吃过亏有一次改了密码加密的 salt rounds注册能过但登录一直失败因为老用户的哈希是用旧 rounds 生成的新代码比对不上。后来我在bcrypt.compare外面包了一层失败时再试一次旧 rounds才把存量用户平滑过渡过去。这种问题不手动走一遍根本发现不了。希望帮到你。本文还有配套的精品资源点击获取
