“你附近有什么好吃的”这个问题,我每次到一个新城市都会被问住。大众点评上收录的店偏连锁化,小红书推荐的又太散,真正好吃的东西往往只存在于本地人的口中和记忆里,且没有一个人能给出一个带位置、带评价、带真实用户反馈的本地美食地图。这个项目就是为解决这个问题做的——一个基于 Node.js + Vue + ElementUI 的美食小吃分享系统,后端用 Express + MySQL,地图部分接入第三方地图 SDK,最终把分散在小巷子里的美食,用一张“用户自发标注的美食地图”串起来。
如果你正在找全栈练手项目,或者需要一套完整可跑通的毕业设计,这篇文章的思路完全可以复刻。我会从项目定位讲到环境搭建、数据库设计、后端接口、前端页面、联调部署,把每一步的关键逻辑和踩过的坑都写出来。就算你目前只懂 Vue 基础或者只写过简单 Node 接口,跟着这套流程也能把一个完整项目跑起来。
1. 项目定位:不是“增删改查”,是一张由用户共同维护的美食地图
1.1 这个系统到底解决了什么
市面上做美食推荐的平台很多,但大多有一个通病:数据来自商家入驻或编辑人工录入,真正的本地小店、街边摊、隐藏吃法很难被收录。而 UGC(用户生成内容)模式天然适合解决这个问题——让每个用户把自己发现的小吃分享出来,标注位置、上传图片、写推荐理由,其他人就能按图索骥。
这个项目把“分享”和“地图”绑定得很紧,用户看到的不是一张普通列表,而是一张带标记点的地图。这种设计有三个好处:
- 位置信息直观,用户一眼就知道哪个小吃离自己近。
- 数据越用越有价值,用户分享得越多,这张地图就越接近真实的“本地美食指南”。
- 技术上有足够的复杂度,能覆盖一个完整全栈项目的所有关键环节:用户认证、文件上传、地图交互、附近搜索、分页、评论、收藏等。
1.2 为什么是这套技术栈
很多人会问,做这种系统用 Java + Spring Boot 会不会更好?我的回答是:Java 生态没问题,但对个人项目来说太重了。Node.js + Express 的优势在于,前后端都是 JavaScript,你只需要掌握一门语言就能写完整个项目,排错时也能在前端 console 和后端 log 之间快速切换思路。
MySQL 则是“稳”字当头。美食信息的数据结构非常固定(标题、分类、经纬度、地址、封面图),是典型的二维表结构,用关系型数据库再合适不过。而且 MySQL 的资料和踩坑帖远比 MongoDB 多,真出问题你大概率能搜到现成解决方案。
前端选 Vue + ElementUI,核心原因是开发效率高。ElementUI 的好处是组件足够多,表格、弹窗、表单、分页、下拉多选开箱即用,平台型的后台管理系统里,你几乎不需要自己手写复杂交互组件。地图用腾讯地图 JavaScript SDK,因为这个项目里要展示标记点、拾取坐标、做信息窗口,腾讯地图这些能力都现成,而且申请 key 免费,对学习项目没资金压力。
需要提醒一下版本问题:如果你的项目从零起步,且没有旧代码包袱,建议用 Vue 3 + Element Plus;如果参考资料、课程要求或已有代码基于 Vue 2 + ElementUI,那也完全够用。本文里的地图集成、分页、弹窗拖拽等思路,在 Vue 2 和 Vue 3 下都是通用的。
1.3 功能模块划分
- 用户模块:注册、登录、个人信息。
- 美食分享模块:发布小吃(含地图选点)、图文信息、分类选择。
- 地图展示模块:地图标记、信息窗口、附近美食查询。
- 互动模块:评论、收藏。
- 列表与筛选模块:分页、关键词搜索、分类筛选。
这套功能设计完后,前后端的开发量大概是:后端接口约 15 个,前端页面约 8 个,不算多,但对入门者来说,每块都是实打实的锻炼。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建:最容易劝退新手的三个环节
2.1 Node.js 安装与环境变量:LTS 版本是最优选
Node.js 的安装本身不难,去官网下载 LTS 版本的安装包就行,但很多人在第一步就卡住了。所谓 LTS(Long Term Support)是长期维护版本,稳定性优先,适合绝大多数项目;Current 版本虽然新,但可能有兼容性问题。学习项目直接选 LTS。
环境变量的配置在一些系统上可能已经被安装包自动处理了,但建议你装完手动验证一下。打开终端,输入:
bash复制node -v
npm -v
能输出版本号就说明环境没问题。如果提示“node 不是内部或外部命令”,那通常是 PATH 里没有加 Node.js 的安装目录,常见路径是 C:\Program Files\nodejs\,把它加到系统环境变量的 Path 里即可。
另外提一个很多人忽略的点:Node.js 的版本会影响部分依赖的安装。比如某些旧版 Express 或构建工具在新 Node 版本下可能报错。我自己的习惯是先用 nvm(Node Version Manager)管理版本,安装一个 LTS 版本,既避免版本混乱,以后切换也方便。
2.2 npm.ps1 无法加载的两种解法
这个报错是 Windows 用户在 PowerShell 里运行 npm -v 或 npm install 时经常碰到的:
code复制npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本
原因不是 npm 坏了,而是 PowerShell 的脚本执行策略默认是 Restricted,禁止任何脚本运行,npm.ps1 本质上是一个 PowerShell 脚本,所以被拦住了。解决方案有两个:
方案一:以管理员身份打开 PowerShell,执行:
powershell复制Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
输入 Y 确认后,再运行 npm 命令就正常了。作用范围是当前用户的 PowerShell,限制是只允许本机脚本和经过签名的远程脚本,安全性可控。
方案二:如果不方便改动执行策略,就直接用 CMD 或 Git Bash 代替 PowerShell,npm 在 cmd 里没问题。
这两个方案我都试过,推荐方案一,一劳永逸,因为后续 npm 的很多全局命令(eg. vue create)在 PowerShell 里也会用到。
2.3 MySQL 8.x 的安装与初始化
MySQL 安装版本很多,建议用 MySQL Installer 装 8.x。安装时有两个关键点:
- root 密码设置后要记牢,后续连接数据库都会用到。
- 字符集选择 utf8mb4。如果安装时忘了,可以在
my.ini配置文件的[mysqld]下加:
ini复制character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
utf8mb4 是 utf8 的超集,能存表情符号,用户发布的评论里如果有 emoji 也不会乱码。
装好之后,创建一个项目数据库。建议先用 MySQL Workbench 或命令行执行一遍,把库和用户建好:
sql复制CREATE DATABASE food_share DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'food_app'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON food_share.* TO 'food_app'@'localhost';
FLUSH PRIVILEGES;
这里单独创建了一个 food_app 用户,而不是直接用 root 连项目。原因是后端代码里如果写 root 密码,一旦项目源码上传到 Git,数据库密码就泄露了;用最小权限的数据库账号更安全。
3. 后端从零到一:Express + MySQL 的设计与接口实现
3.1 表结构设计:从第一张表开始想清楚
这个系统我设计了 5 张核心表:
| 表名 | 主要字段 | 说明 |
|---|---|---|
| users | id, username, password, nickname, avatar, created_at | 用户表,密码用 bcrypt 加密存储 |
| categories | id, name, icon | 小吃分类,比如粉面、烧烤、甜品 |
| foods | id, title, description, cover, category_id, latitude, longitude, address, user_id, view_count, created_at | 美食主表,核心是 latitude/longitude 两个坐标字段 |
| comments | id, food_id, user_id, content, created_at | 评论表 |
| favorites | id, user_id, food_id, created_at | 收藏表,联合唯一索引 (user_id, food_id) 防止重复收藏 |
设计时最容易忽略的是 foods 表的地图坐标字段。很多人是在开发过程中临时想起要加地图功能,才回头补字段,这会导致后续的接口和前端都得跟着改。建议第一版表结构就把经纬度(DECIMAL(10,7))和地址字符串(VARCHAR(255))加上。
另一个经验是给 foods 表的 category_id 和 latitude 加上索引。分类筛选用得多,加索引会快很多;附近查询时会用经纬度做范围过滤,没有索引的话,数据量一大查询就会变慢。实测下来这条优化很值得提前做。
3.2 Express 项目结构与数据库连接池
项目结构我习惯这样组织:
code复制server/
app.js
config/
db.js
routes/
auth.js
foods.js
comments.js
favorites.js
controllers/
authController.js
foodController.js
middleware/
auth.js
package.json
app.js 是入口,负责挂载中间件和路由;config/db.js 中创建数据库连接池;controllers 中写业务逻辑;routes 中只做路由定义;middleware/auth.js 做登录态校验。
数据库连接池用 mysql2/promise 来写,和直接用 mysql 相比,它支持 Promise,配合 async/await 让代码可读性好很多:
js复制const mysql = require("mysql2/promise");
const pool = mysql.createPool({
host: "localhost",
user: "food_app",
password: "your_password",
database: "food_share",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
charset: "utf8mb4",
});
module.exports = pool;
连接池的作用是复用数据库连接,避免每次请求都重新建立一个连接。connectionLimit: 10 表示最多同时维护 10 个连接,一般项目足够。如果并发大,可以调高,但不要盲目调太多,操作系统默认文件描述符会限制你。
3.3 RESTful API 设计与登录鉴权
接口设计遵循 RESTful 风格,把资源名词放 URL 里,动作交给 HTTP 方法。项目里核心接口如下:
| 方法 | 路径 | 功能 | 是否需要登录 |
|---|---|---|---|
| POST | /api/auth/register | 用户注册 | 否 |
| POST | /api/auth/login | 用户登录 | 否 |
| GET | /api/foods | 分页获取美食列表 | 否 |
| GET | /api/foods/:id | 获取美食详情 | 否 |
| POST | /api/foods | 发布美食 | 是 |
| GET | /api/foods/nearby | 附近美食查询 | 否 |
| POST | /api/foods/:id/comment | 发表评论 | 是 |
| POST | /api/favorites/:foodId | 收藏/取消收藏 | 是 |
登录协议我用 JWT(JSON Web Token)。用户登录成功后,后端生成一个令牌返回给前端,前端存在 localStorage 里,之后每次请求都带上 Authorization: Bearer <token>,后端通过中间件解析 token 就能知道当前用户是谁。
middleware/auth.js 核心逻辑:
js复制const jwt = require("jsonwebtoken");
module.exports = function (req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({ code: 401, msg: "未登录" });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.userId;
next();
} catch (err) {
return res.status(401).json({ code: 401, msg: "登录状态已过期" });
}
};
JWT 的好处是无状态,后端不需要存 session,对扩展部署、多机部署很友好。但注意 JWT_SECRET 一定不要写死在代码里,项目里用 process.env.JWT_SECRET 从环境变量读取,部署时再配置。
3.4 附近美食查询:一段 SQL 的取舍
地图功能的核心接口是"附近美食查询"。我用的方法是给定当前经纬度和搜索半径,返回半径内的美食数据。计算距离最简单的方式是用 Haversine 公式,SQL 里可以直接写:
sql复制SELECT *,
(
6371 * acos(
cos(radians(?)) * cos(radians(latitude)) * cos(radians(longitude) - radians(?)) + sin(radians(?)) * sin(radians(latitude))
)
) AS distance
FROM foods
HAVING distance < ?
ORDER BY distance
LIMIT 50;
其中 ? 依次是:当前纬度、当前经度、当前纬度、搜索半径(单位公里)。6371 是地球平均半径公里数。
这段 SQL 在数据量小的时候没任何问题,但数据量大了以后,HAVING distance < ? 无法利用索引,会有全表扫描的性能风险。上线的系统里,建议先用一个简单的方形范围把数据从几万条过滤到几千条,再用 Haversine 精准计算排序,这种“粗筛 + 精排”的策略能在性能和准确度之间取得平衡。
发布时间这里还有个细节:查询列表时经常需要按发布时间倒序,created_at 字段建议默认值设为 CURRENT_TIMESTAMP,插入数据时就不用额外处理了。
4. 前端骨架:Vue + ElementUI 的工程化实现
4.1 用 Vue CLI 快速创建工程
前端我用 Vue CLI 创建:
bash复制npm install -g @vue/cli
vue create food-share-web
项目创建过程中选择 Vue 2 或者 Vue 3,按你的 ElementUI / Element Plus 选择来定。创建完成后安装核心依赖:
bash复制npm install element-ui axios vue-router
如果是 Vue 3,则安装 element-plus,代码里 import 的写法略有差别。这里默认按 Vue 2 + ElementUI 往下走。
4.2 ElementUI 引入方式:完整引入还是按需加载
ElementUI 的引入有两种方式。完整引入是在 main.js 里:
js复制import Vue from "vue";
import ElementUI from "element-ui";
import "element-ui/lib/theme-chalk/index.css";
Vue.use(ElementUI);
优点是简单,全项目所有组件直接可用。缺点是整个组件库的 JS 和 CSS 体积接近 1MB,首屏加载会受影响。按需引入需要借助 babel-plugin-component,配置完成后只打包用到的组件,体积能少一大半。
我个人的建议是:学习项目和毕业设计直接完整引入就够了,省事不出错;如果之后要做线上产品,再切换到按需引入优化体积。先用起来,比一上来就优化重要得多。
4.3 路由配置与页面骨架
前端页面我规划了这么几个视图:
- 首页(地图 + 推荐小吃列表)
- 列表页(分页展示,支持分类筛选和搜索)
- 详情页(展示小吃信息、评论、收藏按钮)
- 发布页(表单 + 地图选点)
- 个人中心(我的发布、我的收藏)
- 登录/注册页
路由配置需要注意两点。一是使用动态导入实现路由懒加载,让用户访问某个页面时才加载对应的 JS 文件:
js复制const routes = [
{
path: "/",
component: () => import("./views/Home.vue"),
},
{
path: "/foods",
component: () => import("./views/FoodList.vue"),
},
];
二是给需要登录的页面加导航守卫。比如发布美食必须登录,用户还没登录就直接跳去登录页:
js复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem("token");
if (to.meta.requiresAuth && !token) {
next({ path: "/login", query: { redirect: to.fullPath } });
} else {
next();
}
});
4.4 地图组件的集成:从初始化到坐标拾取
地图集成是前端最核心的部分。以腾讯地图为例,先在 public/index.html 里引入 SDK:
html复制<script src="https://map.qq.com/api/gljs?v=1.exp&key=你的key"></script>
key 需要去腾讯位置服务控制台申请,一般几分钟就能拿到。然后在 Vue 组件中初始化地图:
js复制let map = null;
export default {
mounted() {
map = new TMap.Map(this.$refs.mapContainer, {
center: new TMap.LatLng(39.90886, 116.39739),
zoom: 12,
});
},
beforeDestroy() {
// 销毁地图实例,避免内存泄漏
if (map) {
map = null;
}
},
};
展示美食标记点,可以用 TMap.MultiMarker 添加一组 marker,这样效率最高,几百个点也不会卡顿;点击每个 marker 时用信息窗口展示小吃的标题、评分和封面缩略图。
发布页里的“地图选点”功能,实现思路是:用户点击地图时,触发地图的 click 事件,把点击位置的经纬度记录下来,同时反向地理编码获取地址文字。地图 SDK 提供了 TMap.service.Geocoder,可以把经纬度转成地址,体验很流畅。
这里有一个我踩过的坑:地图 SDK 是外部加载的全局对象,Vue 打包构建时默认不会意识到这个全局依赖存在。所以初始化地图时要在 mounted 里加一个判断,确保 window.TMap 已经存在。如果页面加载早于 SDK 加载,偶尔会出现“TMap is not defined”的报错,稳妥做法是在 index.html 的 script 标签上加 defer,或者用动态加载的方式在组件里插入 script 再初始化。
4.5 分页组件:前后端分页的正确对接方式
列表页的分页是最典型的前后端分离场景。ElementUI 的分页组件写法:
html复制<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page.sync="page"
:page-size.sync="pageSize"
:page-sizes="[10, 20, 30]"
@current-change="loadData"
@size-change="loadData"
/>
前端把 page(当前页码,从 1 开始)和 pageSize(每页条数)传给后端,后端 SQL 里用 LIMIT offset, pageSize 实现分页,其中 offset = (page - 1) * pageSize:
js复制const page = parseInt(req.query.page) || 1;
const pageSize = parseInt(req.query.pageSize) || 10;
const offset = (page - 1) * pageSize;
const [rows] = await pool.query(
"SELECT * FROM foods ORDER BY created_at DESC LIMIT ?, ?",
[offset, pageSize]
);
const [countRows] = await pool.query("SELECT COUNT(*) AS total FROM foods");
接口返回的总条数 total 就是分页组件里的 total。这种设计的好处是,数据量大时不会一次性加载全部记录,页面加载速度稳定。
实测时要注意一个细节:切换每页条数后,如果当前页超过了总页数,列表会是空的。合理做法是在 size-change 事件里先重置 page = 1,再重新请求数据。
5. 交互细节与响应式优化:弹窗拖拽、多选全选和数据更新
5.1 让 el-dialog 支持拖拽和调整宽高
ElementUI 的 el-dialog 默认不支持拖拽,也不能缩放。但实际使用中,很多用户会习惯性地拖动弹窗。实现拖拽的方式是用自定义指令,在弹窗挂载后给它的头部元素绑定鼠标事件。
核心思路是:弹窗打开后,拿到 el-dialog header 对应的 DOM,在 mousedown 时记录鼠标起始位置和弹窗当前位置,mousemove 时计算位移并更新弹窗的 left / top,mouseup 时解绑事件。
宽高调整更简单一些,可以直接在 el-dialog 的内容区域绑定一个自定义 style,利用 CSS 的 resize 属性实现:
css复制.dialog-body {
resize: both;
overflow: auto;
min-width: 400px;
min-height: 300px;
}
手动拖拽右下角即可改变弹窗内容区大小。注意只对内容区生效,不会改变整个弹窗外层,但从用户体验角度已经够了。这个功能可以作为全局自定义指令放进项目里,所有弹窗直接复用。
5.2 ElementUI 下拉多选与全选
“下拉多选”在 ElementUI 里是一个 el-select 加 multiple 属性:
html复制<el-select v-model="selectedCategories" multiple placeholder="请选择分类">
<el-option
v-for="c in categories"
:key="c.id"
:label="c.name"
:value="c.id"
/>
</el-select>
要做到“全选”,最常见做法是在 categories 数组最前面增加一个“全选”的选项,value 设置为特殊值,例如 'all'。然后在 @change 事件里判断:如果选中项里出现了 'all',就把所有 categories 的 id 放入 selectedCategories;如果取消勾选 'all',就清空选择。
这里最容易踩坑的地方是 select 的 change 事件触发时机。在“全选”已选中状态下再点击某个具体分类,可能导致所有分类都被取消。解决方法是加一个旧的选中集合做对比,判断是新增还是删除,再决定要不要同步全选状态。虽然逻辑多了几行,但用户体验会好很多。
5.3 computed 属性在地图筛选中的经典用法
项目里 computed 用得最多的地方是地图页的分类筛选。原始的美食标记数组来自接口,用户点击分类标签时,我不想直接修改原始数组(会丢掉后续重新筛选的能力),就用 computed 派生一个过滤结果:
js复制computed: {
filteredMarkers() {
if (this.activeCategoryId === 0) {
return this.allMarkers;
}
return this.allMarkers.filter(
(item) => item.category_id === this.activeCategoryId
);
},
},
computed 和 methods 的区别在于缓存:只要依赖的 activeCategoryId 和 allMarkers 没有变化,多次访问 filteredMarkers 不会重复执行过滤逻辑,性能更优。在 marker 数据量大或计算逻辑复杂的场景,这个特性很实用。
5.4 数据更新页面不刷新的常见原因
Vue 2 的响应式有一个著名限制:直接通过索引设置数组元素,比如 this.foods[0].title = '新标题',页面不会自动更新。原因是 Vue 2 用 Object.defineProperty 拦截属性的 getter/setter,数组新增元素或直接按索引修改时,取值是正常的,但视图不会收到通知。
解决方法是用 this.$set 或者直接替换整个数组:
js复制// 错误:页面不刷新
this.foods[0].title = "新标题";
// 正确:用 $set
this.$set(this.foods, 0, { ...this.foods[0], title: "新标题" });
// 或更简单的做法:重新赋值整个数组
this.foods = [...this.foods];
对象新增属性同理,需要用 this.$set(this.obj, 'newKey', value)。这个问题“突然出现”时很让人抓狂,因为它不是报错,只是视图没变化。排查思路是:先在控制台打印数据,确认数据本身已经变了,再判断是不是响应式问题。
6. 联调与上线:从本地跑通到部署发布的完整闭环
6.1 跨域问题的两种解法
前后端分离开发时,前端的开发服务器(通常是 http://localhost:8080)和后端接口(http://localhost:3000)端口不同,浏览器会拦截跨域请求。解决方法有两个:
后端层面可以在 Express 里加 CORS 中间件:
js复制const cors = require("cors");
app.use(cors());
前端开发阶段更推荐用 Vue CLI 的代理配置。在 vue.config.js 里设置:
js复制module.exports = {
devServer: {
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
};
这样前端代码里请求 /api/foods 时,开发服务器会自动把请求转发给后端,绕过了浏览器同源策略。生产环境部署时,再用 Nginx 做反向代理,也是同样的思路。
两种方式各有适用场景:开发阶段建议用代理,因为配置简单且不需要改后端;联调和部署阶段建议后端开启 CORS,因为线上环境前端静态资源可能和后端接口不在同一个域名下。
6.2 部署方案:从 dist 到 Nginx + pm2
部署我一般分两步:前端构建,后端守护。
前端在项目根目录执行:
bash复制npm run build
生成 dist 目录,里面是纯静态文件。把这些文件放到 Nginx 的静态资源目录下,然后配置反向代理,把 /api 开头的请求转发到 Node 服务。
nginx复制server {
listen 80;
server_name your-domain.com;
root /var/www/food-share/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
注意 try_files $uri $uri/ /index.html; 这行非常关键 。Vue 是单页应用,前端路由由 JS 控制,如果没有这一行,刷新 /foods/1 这样的详情页,Nginx 会返回 404。
后端用 pm2 守护进程,保证服务崩溃后能自动重启:
bash复制npm install -g pm2
pm2 start app.js --name food-share-server
pm2 save
6.3 上线前值得检查的几件事
很多项目写完功能就急着上线,我建议在上线前花半小时检查这几处:
- 数据库连接密码不要写死在代码里,用环境变量读取。
- 把 JWT_SECRET 从代码里抽出来,否则 token 伪造风险和密码泄露风险都很高。
- 表单提交做好后端参数校验,前端校验只是体验优化,不能作为安全屏障。
- 发布美食接口里,用户传入的经纬度必须做范围校验,否则可能出现负数或超范围的脏数据。
- 给 MySQL 的
foods表补上必要索引,特别是经纬度和分类字段。
踩过几次坑之后,我现在的习惯是项目一开始就把环境变量文件(.env)建好,数据库密码、JWT_SECRET、端口号都放里面,而不是在代码里写死。后期不管是换服务器还是给别人跑代码,只需要改一个文件,比在源码里到处找密码要省心得多。这个项目涉及的内容比较全,Mysql建表、Express接口、Vue组件、ElementUI交互、地图SDK、部署,每一个环节单独拿出来都可以再加深。如果你正在做类似的东西,建完表、把接口跑通之后,先去处理地图模块,这是整个系统最有辨识度的地方,也会让你更容易把项目讲清楚。
