
简介本资源面向具备一定 TypeScript 基础、希望打通前后端全栈登录注册流程的开发者提供一套基于 tsexpressmysql 与 tsvue3elementplus 的完整实例。后端负责搭建服务并输出登录、注册相关接口前端则实现管理界面及对应业务流程适合课程设计、毕业项目或全栈入门练手。压缩包共约 2000 个文件以 1100 个 js 构建与运行脚本、764 个 md 说明文档、129 个 json 配置及少量 txt 为主整体约 58.57MB目录结构清晰便于按前后端模块检索。目前已有 1104 人学习下载。读者可从中获取接口设计思路、前端页面组织方式与项目依赖配置对照博客中的后端与前端开发记录快速理解登录注册从请求到响应的完整链路并在此基础上扩展权限校验、表单验证等常见功能。1. 从零搭一套 TS Express Vue3 Element Plus MySQL 的登录注册这套组合到底值不值得做如果你正在搜「ts express vue3 elementplus mysql 实现用户登录注册实例」大概率不是想听概念而是想找一套能跑起来、能改、能直接塞进自己项目里的最小闭环。登录注册是所有后台管理系统的第一块砖看着简单真动手就会撞上一堆问题前端表单校验和接口报错怎么对齐、密码到底怎么存、token 放哪、跨域怎么处理、MySQL 连接池什么时候该建。这套组合的价值就在于它足够主流——Vue3 加 Element Plus 负责交互Express 加 TypeScript 负责接口MySQL 负责持久化每一层都有成熟方案拼起来就是一个能上生产的骨架。适合谁看会一点前端、想补后端接口的开发者想从 JS 迁到 TS 但不知道怎么落地的人以及需要一套干净登录注册模板做二次开发的人。下面按「先立住原理、再动手复现、最后讲坑」的顺序走每一步都给能抄的代码和参数说明不玩虚的。2. 技术选型与项目骨架为什么是这套组合目录怎么分2.1 每一层为什么选它替代方案差在哪先把选型理由说清楚不然后面改需求时你会不知道自己为什么被绑住。前端选 Vue3 而不是 Vue2核心是组合式 API 对 TypeScript 的类型推导友好太多ref、reactive配合接口类型能直接拿到字段提示Vue2 的选项式 API 在 TS 下经常要靠this断言写起来别扭。Element Plus 是 Vue3 生态里表单组件最全的el-form自带校验规则登录注册这种强表单场景能省掉大量手写校验。后端选 Express 而不是 Nest是因为这个实例的体量不需要依赖注入和装饰器那一套Express 中间件模型足够直白加 TS 只是给请求体和响应体加类型。数据库选 MySQL 而不是 MongoDB是因为用户表结构固定、需要唯一索引约束用户名和邮箱关系型数据库的UNIQUE约束能直接在数据库层兜底比应用层查重更可靠。提示选型没有绝对对错但如果你的团队已经在用某套栈优先复用别为了这个实例强行换技术。2.2 前后端目录结构怎么分一个清晰的目录能让后面加功能不抓瞎。我一般这样分project/ ├── server/ # 后端 │ ├── src/ │ │ ├── config/ # 数据库配置、环境变量 │ │ ├── controllers/ # 业务逻辑 │ │ ├── routes/ # 路由定义 │ │ ├── middlewares/ # 鉴权、错误处理 │ │ ├── types/ # TS 类型定义 │ │ └── app.ts # 入口 │ ├── tsconfig.json │ └── package.json └── web/ # 前端 ├── src/ │ ├── api/ # 接口封装 │ ├── views/ # 页面 │ ├── router/ │ ├── stores/ # 状态管理 │ └── main.ts ├── vite.config.ts └── package.json前后端分目录而不是放一起是为了部署时能独立打包。后端tsconfig.json里target设ES2020、module设commonjs因为 Express 生态对 ESM 支持还不算顺滑用 commonjs 少踩坑。前端用 Vite 起项目vite.config.ts里配server.proxy把/api转发到后端端口开发阶段就不用折腾跨域。2.3 依赖装哪些版本怎么锁后端核心依赖express、mysql2、bcryptjs、jsonwebtoken、cors、dotenv开发依赖加typescript、ts-node-dev、types/express、types/jsonwebtoken、types/bcryptjs。注意mysql2而不是mysql前者支持 Promise 和连接池写起来干净。cd server npm init -y npm i express mysql2 bcryptjs jsonwebtoken cors dotenv npm i -D typescript ts-node-dev types/express types/cors types/jsonwebtoken types/bcryptjs types/node前端用 Vite 创建 Vue3 TS 模板再装 Element Plus、axios、pinia、vue-routernpm create vitelatest web -- --template vue-ts cd web npm i element-plus axios pinia vue-routerts-node-dev的作用是改完 TS 自动重启比每次手动编译再跑省事。bcryptjs是纯 JS 实现不依赖本地编译工具Windows 上装它比bcrypt少一堆 node-gyp 报错这是血泪经验。3. 后端接口实现从数据库表到注册登录两个接口3.1 用户表怎么建字段和索引怎么定表结构决定了后面接口能写多顺。用户表至少要有自增主键、用户名、密码哈希、邮箱、创建时间。用户名和邮箱都要加唯一索引防止并发注册时插入重复数据。CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT bcrypt哈希, email VARCHAR(64) DEFAULT NULL COMMENT 邮箱, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;password字段给到 100 长度因为 bcrypt 哈希固定 60 字符留余量。utf8mb4是为了支持 emoji 用户名别用utf8那个在 MySQL 里其实是三字节的残缺实现。唯一索引命名带uk_前缀方便后面排查冲突时一眼看出是哪个约束报的错。3.2 数据库连接池怎么配参数怎么调不要每次请求都createConnection那样连接数会爆。用连接池全局建一次。// src/config/db.ts import mysql from mysql2/promise; import dotenv from dotenv; dotenv.config(); export const pool mysql.createPool({ host: process.env.DB_HOST || 127.0.0.1, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER || root, password: process.env.DB_PASSWORD || , database: process.env.DB_NAME || demo, waitForConnections: true, connectionLimit: 10, // 连接池上限 queueLimit: 0, // 排队请求不限制 charset: utf8mb4, });connectionLimit设 10 是单机小项目的稳妥值设太大反而会拖垮 MySQL 的max_connections。queueLimit设 0 表示连接用满时请求排队而不是直接报错配合waitForConnections: true使用。密码这类敏感信息走.env别硬编码进代码.env记得加进.gitignore。3.3 注册接口密码哈希和唯一冲突怎么处理注册的核心是「先校验、再哈希、再插入、捕获唯一冲突」。// src/controllers/auth.ts import { Request, Response } from express; import bcrypt from bcryptjs; import { pool } from ../config/db; import { RowDataPacket } from mysql2; export async function register(req: Request, res: Response) { const { username, password, email } req.body; if (!username || !password) { return res.status(400).json({ code: 1, msg: 用户名和密码不能为空 }); } if (password.length 6) { return res.status(400).json({ code: 1, msg: 密码至少6位 }); } try { const hash await bcrypt.hash(password, 10); // 10为salt轮数 await pool.execute( INSERT INTO user (username, password, email) VALUES (?, ?, ?), [username, hash, email || null] ); return res.json({ code: 0, msg: 注册成功 }); } catch (err: any) { if (err.code ER_DUP_ENTRY) { return res.status(409).json({ code: 1, msg: 用户名或邮箱已存在 }); } return res.status(500).json({ code: 1, msg: 服务器错误 }); } }bcrypt.hash第二个参数是 salt 轮数10 是性能和安全的平衡点调到 12 更安全但每次登录会明显变慢。用pool.execute而不是pool.query前者走预编译语句能防 SQL 注入。捕获ER_DUP_ENTRY错误码来识别唯一冲突比先查一次再插入更可靠因为查和插之间有并发窗口。3.4 登录接口校验密码并签发 token登录流程是「查用户、比对哈希、签发 JWT」。// src/controllers/auth.ts续 import jwt from jsonwebtoken; import { RowDataPacket } from mysql2; export async function login(req: Request, res: Response) { const { username, password } req.body; const [rows] await pool.executeRowDataPacket[]( SELECT id, username, password FROM user WHERE username ?, [username] ); if (rows.length 0) { return res.status(401).json({ code: 1, msg: 用户名或密码错误 }); } const user rows[0]; const match await bcrypt.compare(password, user.password); if (!match) { return res.status(401).json({ code: 1, msg: 用户名或密码错误 }); } const token jwt.sign( { id: user.id, username: user.username }, process.env.JWT_SECRET as string, { expiresIn: 2h } ); return res.json({ code: 0, msg: 登录成功, data: { token } }); }用户不存在和密码错误都返回同一句提示避免攻击者通过错误信息枚举出哪些用户名存在。expiresIn设 2 小时是后台系统的常见值太长有安全风险太短用户频繁掉线。JWT_SECRET必须放环境变量泄露了别人就能伪造任意用户的 token。3.5 鉴权中间件和路由挂载受保护接口靠中间件校验 token。// src/middlewares/auth.ts import { Request, Response, NextFunction } from express; import jwt from jsonwebtoken; export function authGuard(req: Request, res: Response, next: NextFunction) { const header req.headers.authorization; if (!header || !header.startsWith(Bearer )) { return res.status(401).json({ code: 1, msg: 未登录 }); } const token header.slice(7); try { const payload jwt.verify(token, process.env.JWT_SECRET as string); (req as any).user payload; next(); } catch { return res.status(401).json({ code: 1, msg: token无效或已过期 }); } }路由里把注册登录放公开其他接口挂authGuard// src/routes/auth.ts import { Router } from express; import { register, login } from ../controllers/auth; const router Router(); router.post(/register, register); router.post(/login, login); export default router;header.slice(7)是切掉Bearer这 7 个字符注意Bearer后面有个空格。jwt.verify失败会抛异常用 try/catch 包住统一返回 401别让异常冒到全局错误处理里变成 500。4. 前端页面实现Element Plus 表单校验与接口联调4.1 用 el-form 搭登录注册表单校验规则怎么写Element Plus 的el-form通过rules做校验配合ref拿到表单实例调validate。template el-form refformRef :modelform :rulesrules label-width80px el-form-item label用户名 propusername el-input v-modelform.username placeholder请输入用户名 / /el-form-item el-form-item label密码 proppassword el-input v-modelform.password typepassword show-password / /el-form-item el-form-item el-button typeprimary :loadingloading clickonSubmit登录/el-button /el-form-item /el-form /template script setup langts import { reactive, ref } from vue; import type { FormInstance, FormRules } from element-plus; const formRef refFormInstance(); const loading ref(false); const form reactive({ username: , password: }); const rules: FormRules { username: [ { required: true, message: 请输入用户名, trigger: blur }, { min: 3, max: 32, message: 长度3到32位, trigger: blur }, ], password: [ { required: true, message: 请输入密码, trigger: blur }, { min: 6, message: 密码至少6位, trigger: blur }, ], }; async function onSubmit() { if (!formRef.value) return; await formRef.value.validate(async (valid) { if (!valid) return; loading.value true; try { // 调接口 } finally { loading.value false; } }); } /scriptFormRules类型从 Element Plus 导入这样写规则时字段名有提示写错字段名 TS 会报错。trigger: blur表示失焦时校验也可以设change实时校验但实时校验在输入过程中频繁触发体验略吵登录注册场景用 blur 更稳。loading状态防止用户连点提交按钮造成重复请求。4.2 axios 封装与请求拦截器统一封装 axios把 token 自动带上把响应结构统一处理。// src/api/request.ts import axios from axios; import { ElMessage } from element-plus; 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( (res) { if (res.data.code ! 0) { ElMessage.error(res.data.msg || 请求失败); return Promise.reject(res.data); } return res.data; }, (err) { if (err.response?.status 401) { localStorage.removeItem(token); // 跳登录页 } ElMessage.error(err.response?.data?.msg || 网络错误); return Promise.reject(err); } ); export default request;baseURL设/api配合 Vite 的 proxy 转发到后端开发时不用写完整域名。请求拦截器从localStorage取 token 塞进 header响应拦截器统一判断业务code非 0 就弹错误提示。401 时清 token 并跳登录这是处理 token 过期的标准动作。4.3 登录注册接口调用与 token 存储// src/api/auth.ts import request from ./request; export interface LoginParams { username: string; password: string; } export interface LoginResult { token: string; } export function login(data: LoginParams) { return request.post{ data: LoginResult }(/auth/login, data); } export function register(data: LoginParams { email?: string }) { return request.post(/auth/register, data); }调用处const res await login({ username: form.username, password: form.password }); localStorage.setItem(token, res.data.token); router.push(/dashboard);token 存localStorage简单直接但要注意 XSS 风险生产环境更稳的是存 httpOnly cookie。这里作为实例先用 localStorage 跑通后面要加固再换。接口返回类型用泛型标注res.data.token就有类型提示不会拼错字段名。5. 避坑与排查这套组合最容易翻车的五个地方5.1 跨域报错 CORS 但后端明明配了现象浏览器控制台报Access-Control-Allow-Origin相关错误但后端cors()已经加了。原因通常是前端请求带了自定义 header比如Authorization触发了预检请求 OPTIONS而后端没处理 OPTIONS 或者 cors 配置没允许该 header。解决app.use(cors({ origin: http://localhost:5173, credentials: true }))并确保 OPTIONS 请求能正常返回 204。开发阶段更省事的做法是走 Vite proxy让请求同源直接绕开跨域。5.2 bcrypt 比对一直返回 false现象注册成功登录时bcrypt.compare死活不通过。原因多半是注册时存进数据库的密码被截断了比如字段长度设成了VARCHAR(50)而 bcrypt 哈希是 60 字符存进去被截掉尾部。解决把password字段长度改到 100 以上重新注册测试。另一个可能是注册时对密码做了 trim 而登录时没做两边处理不一致。5.3 MySQL 报 ER_NOT_SUPPORTED_AUTH_MODE现象连接 MySQL 8 时报Client does not support authentication protocol。原因是 MySQL 8 默认用caching_sha2_password认证插件老版本客户端驱动不支持。解决用mysql2而不是mysql前者支持新认证方式或者改用户认证插件ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码。推荐前者别动数据库用户配置。5.4 token 过期后前端一直弹错误但不跳登录现象token 过期后每个请求都报 401页面卡在原地。原因是响应拦截器只弹了错误提示没做跳转。解决在拦截器 401 分支里清 token 并router.push(/login)注意要引入 router 实例别在拦截器里用window.location硬跳那样会丢失 SPA 的路由状态。5.5 前端表单校验通过但接口报参数为空现象validate返回 true但后端收到username是 undefined。原因通常是reactive对象被解构后失去响应性或者提交时传的是form的浅拷贝丢了字段。解决提交时直接传form对象本身别解构如果用了toRefs要确认引用正确。另一个隐蔽原因是 axios 默认Content-Type是application/json但后端没挂express.json()中间件导致req.body为空。检查app.use(express.json())是否在路由之前。6. 进阶技巧把登录态做得更稳的几个细节跑通最小闭环只是开始真正上线前还有几个细节值得补。第一是刷新 token 机制access token 设短一点比如 15 分钟另发一个有效期长的 refresh tokenaccess 过期时用 refresh 换新的用户无感知。实现上就是登录时返回两个 token加一个/auth/refresh接口前端在拦截器里判断 401 且是 token 过期时自动调刷新接口重试原请求。第二是登录失败次数限制。在用户表加fail_count和lock_until两个字段每次密码错误fail_count加一超过 5 次锁定 15 分钟。这能挡住暴力破解实现就是在登录接口比对密码失败的分支里更新这两个字段登录成功时清零。第三是密码强度校验放到后端也做一遍。前端校验只是体验绕过前端直接调接口照样能提交弱密码所以后端register里必须再校验一次长度和复杂度。别信前端。加固项参数建议作用access token 有效期15 分钟降低泄露风险refresh token 有效期7 天平衡体验与安全登录失败锁定阈值5 次挡暴力破解bcrypt salt 轮数10 到 12安全与性能平衡最后说个验证方法写完接口别只用浏览器点用 curl 或 Postman 直接打接口确认参数校验、错误码、token 签发都符合预期再联调前端。我踩过最深的坑就是前端一切正常、后端接口单独测才发现唯一索引没生效因为建表时手滑把UNIQUE写成了普通索引。养成「接口先单独测通再联调」的习惯能省掉大量前后端互相甩锅的时间。希望帮到你。本文还有配套的精品资源点击获取