☰
TS+Express+Vue3+Element Plus+MySQL登录注册实战:从零搭建与避坑指南
2026/9/27 1:07:15 网站建设 项目流程

简介:这是一套面向前端与全栈初学者的用户登录注册全流程实战项目源码,基于 TypeScript、Express、Vue3、Element Plus 与 MySQL 搭建,适合想打通前后端接口联调、掌握鉴权业务逻辑的开发者参考学习。后端使用 ts+express+mysql 提供登录注册相关接口,前端以 ts+vue3+elementplus 实现管理界面及对应业务流程,覆盖从表单校验到接口请求的完整链路。压缩包共约 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.0+8.0 的默认认证插件是 caching_sha2_password,注意驱动兼容

MySQL 安装这块,Windows 上用官方 installer,Linux 上用 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 --init

tsconfig.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 vite@latest client -- --template vue-ts cd client npm i element-plus axios vue-router pinia npm i -D unplugin-auto-import unplugin-vue-components

Element 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个关键点:username加唯一索引,注册时的重复校验靠数据库兜底,别只靠代码里的SELECT判断——并发下两个请求同时查到「不存在」,然后都插入,就出重复数据了。password字段留 100 长度,因为 bcrypt 哈希后是 60 个字符,留余量。status用 TINYINT 而不是 BOOLEAN,MySQL 的 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 router

bcrypt.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 class="login-wrap"> <el-card class="login-card"> <template #header> <h2>用户登录</h2> </template> <el-form ref="formRef" :model="form" :rules="rules" label-width="0"> <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="密码" show-password /> </el-form-item> <el-form-item> <el-button type="primary" :loading="loading" @click="handleLogin">登录</el-button> <el-button @click="$router.push('/register')">去注册</el-button> </el-form-item> </el-form> </el-card> </div> </template> <script setup lang="ts"> 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 = ref<FormInstance>() 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 } } </script>

formRef.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 router

meta.requiresAuth标记需要登录的页面,守卫里统一判断。已登录用户访问登录页时重定向到 dashboard,避免重复登录。注意next()必须调用,否则导航会卡住,页面白屏。

4.4 注册页与登录页的差异点

注册页比登录页多一个「确认密码」字段,校验规则要用自定义 validator:

const 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_password:

ALTER USER 'root'@'localhost' 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用ref<FormInstance>(),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 快过期时自动刷新。简单做法是后端签发两个 Token:accessToken 短过期(2 小时),refreshToken 长过期(7 天),accessToken 过期时用 refreshToken 换新的。这套逻辑代码量不大,但能显著减少用户重新登录的次数。

最后说一个我自己的习惯:每次改完登录注册相关代码,一定手动走一遍完整流程——注册新用户、退出、用新用户登录、访问需要鉴权的页面、等 Token 过期再访问。自动化测试可以后面补,但这条手动路径必须每次过。我吃过亏,有一次改了密码加密的 salt rounds,注册能过但登录一直失败,因为老用户的哈希是用旧 rounds 生成的,新代码比对不上。后来我在bcrypt.compare外面包了一层,失败时再试一次旧 rounds,才把存量用户平滑过渡过去。这种问题不手动走一遍根本发现不了。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询