做旅游类项目这些年,我发现一个很尴尬的现象:大量景点网站做得跟黄页一样,景点列表摆在那,用户翻两页就关了。真正让用户留下来、让平台有价值的东西,其实是“推荐”——用户不知道去哪儿玩的时候,系统能不能给出他真正感兴趣的答案。
这个项目就是干这件事的:基于nodejs+vue+express的旅游景点推荐系统。后端用Node.js跑Express提供接口和推荐逻辑,前端用Vue做单页应用,中间通过RESTful API串联。系统解决的问题很明确:用户初次打开不知道逛什么,平台不知道给谁推什么,冷启动阶段一片空白。它适合刚开始做全栈项目的开发者、想入门推荐系统但不想一头扎进Spark和Python重型方案的人,也适合作为毕业设计或者个人作品集的实战项目。我这次把完整思路、代码、坑都拆开讲,尽量让一个零基础的人也能照着做出来。
1. 项目概述与整体设计思路
1.1 这个推荐系统到底解决什么问题
先聊一个真实的场景。你在某旅游平台搜“杭州”,出来的结果是断桥、灵隐寺、西湖,每个都标着5A景区。但你可能带着孩子、或者喜欢人文拍照、或者就想找个冷门地方待一下午——一套静态列表根本满足不了这些需求。推荐系统的价值不是把热门景点排一遍,而是根据用户的行为和历史偏好,把匹配度最高的景点推到他面前。
所以这个系统我拆成三个核心环节:
- 用户怎么表达偏好:通过浏览、收藏、评分这些行为隐式或显式地表达。
- 系统怎么判断相似:景点之间靠标签(自然风光、历史古迹、亲子、美食等)建立关联。
- 怎么生成推荐结果:用混合策略,行为少的走内容匹配,行为多的走协同过滤。
这套设计不算复杂,但它是能跑通业务闭环的。用户进来看到热门列表,浏览了几个景点,点了收藏,再刷新推荐页,结果开始变了。这个“变化”就是推荐系统的存在感。
1.2 技术选型:为什么是nodejs+vue+express
选这套技术栈不是拍脑袋,而是因为它在一个非常合适的复杂度区间。Node.js做后端的好处是前后端统一语言,你不需要在JavaScript和Java之间来回切换思维。Express是Node生态里最经典的Web框架,路由、中间件、请求处理都足够轻量。Vue则负责前端视图层,组件化的写法对小型项目来说开发效率极高。
有人会问:推荐系统用Python不是更成熟吗?确实,scikit-learn里有现成算法,但那是另一条路。这个项目的推荐逻辑完全可以自己实现,不依赖重型依赖库,跑在Node里毫无压力。而且对于教学或者毕设场景,面试官/老师更看重的是你懂不懂原理、能不能手写一个计算过程,而不是会不会调包。
整套项目的前后端结构是这样的:
travel-recommend/ ├── client/ # Vue前端工程 │ ├── src/ │ │ ├── views/ # 页面组件 │ │ ├── router/ # 路由配置 │ │ └── api/ # 接口请求封装 └── server/ # Express后端 ├── routes/ # 路由定义 ├── models/ # 数据模型 └── server.js # 入口文件1.3 功能模块与页面结构拆解
推荐系统不是只有一个推荐列表,它需要一套完整的功能闭环。我列一下这个项目的功能模块:
| 模块 | 功能说明 | 对应页面/接口 |
|---|---|---|
| 景点浏览 | 按城市、分类筛选景区列表 | 首页 / GET /api/spots |
| 景点详情 | 展示介绍、标签、评分 | 详情页 / GET /api/spots/:id |
| 用户行为 | 浏览、收藏、评分 | POST /api/behaviors |
| 推荐中心 | 个性化推荐结果 | 推荐页 / GET /api/recommend/:userId |
| 热门榜单 | 无行为时的兜底推荐 | 首页热门 / GET /api/spots/hot |
页面结构也简单:首页是景点列表,详情页展示单点信息,推荐页呈现个性化结果,个人中心看自己的行为记录。页面不多,但每一块都对应推荐系统的数据输入或输出。
2. 开发环境搭建与避坑指南
2.1 Node.js安装与环境变量配置
这个步骤看起来基础,实际上拦住了不少人。很多同学卡在node不是内部或外部命令这一步,十有八九是环境变量没配好。
我建议装LTS版本,别追最新的尝鲜版,稳定性优先。Windows下的安装包是.msi,一步步点Next就行,安装目录建议保持默认,比如C:\Program Files\nodejs,这会直接影响后面的环境变量配置。装完后需要验证两个东西:
node -v npm -v如果提示找不到命令,手动配置环境变量。在系统变量Path里加入Node.js安装目录,比如C:\Program Files\nodejs。这里有个细节:Node.js在安装时会自动把路径写进用户变量的Path,但如果你的系统开了UAC或者用了非管理员账户,可能不会生效,手动加一次最保险。
还有坑要提前说:Express 4版本的req.body默认是undefined,需要装body-parser中间件。Express 4.16之后这个功能内置了,直接app.use(express.json())就行,不用额外装。不同版本API有差异,踩过才知道多疼。
2.2 创建Vue前端工程
Vue项目初始化方式推荐用npm init vue@latest,这个命令会走Vite构建工具,速度比Webpack时代的vue create快很多,模板也更清爽。
npm init vue@latest # 按提示输入项目名,选上 Router、Pinia cd client npm install npm run dev项目跑起来后,默认端口5173。开发和联调阶段我会用Vite的代理转发API请求,前端请求写/api/xxx,代理转发到Express的3000端口,这样能绕开跨域问题。这一步配置放到后面API章节讲。
Vue的组件结构我尽量精简:
src/ ├── views/ │ ├── HomeView.vue # 景点列表 │ ├── DetailView.vue # 景点详情 │ ├── RecommendView.vue # 推荐结果 │ └── ProfileView.vue # 个人中心 ├── router/index.js └── api/index.js2.3 搭建Express后端骨架
后端我习惯用手动初始化而不是脚手架,因为Express项目不大,几行代码就完成了,脚手架反而多一层封装干扰理解。
mkdir server cd server npm init -y npm install express mysql2 cors一个最小可用的后端是这样的:
const express = require('express'); const cors = require('cors'); const app = express(); app.use(cors()); app.use(express.json()); app.get('/', (req, res) => { res.json({ message: 'api is running' }); }); app.listen(3000, () => { console.log('server running at http://localhost:3000'); });然后node server.js就好。注意监听端口别和前端Vite的5173冲突,一个3000一个5173,分工明确。
2.4 npm脚本执行权限报错处理
这个坑我当年踩过,相信你也早晚会遇到。Windows下用PowerShell跑npm命令,报错长这样:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本原因很简单:PowerShell的执行策略默认是Restricted,不允许运行.ps1脚本。npm的npm.ps1就是一个PowerShell脚本文件,自然被拦了。
解决方案有两种:
方案一:改执行策略(推荐)
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser按Y确认就行。这个命令只对当前用户生效,不会影响系统其他账户。
方案二:绕开PowerShell
在cmd命令行里跑npm,或者在VS Code里把默认终端切成cmd。两种都行,但治标不治本,下次用PowerShell还会遇到。
顺手提一句:执行策略改成RemoteSigned意味着本地脚本可运行,远程下载的脚本需要签名,这是安全设计,不要直接改成Unrestricted,没必要拿机器的安全性开玩笑。
3. 推荐系统核心设计与算法实现
3.1 推荐思路:混合推荐策略
推荐系统这个名词看起来高大上,拆开看无非三类:基于内容的推荐、协同过滤、混合推荐。这里我用的就是混合策略,不过度设计。
- 新用户(没行为):走热门兜底,按浏览量和评分排序。
- 有少量行为:基于内容的推荐,核心是把用户行为映射成标签偏好,找标签匹配度高的景点。
- 有较多行为:协同过滤,核心是找相似用户或相似物品,推你没碰过的东西。
为什么不做纯协同过滤?因为冷启动问题。一个刚注册的用户一条行为记录都没有,协同过滤算不出来,只能推热门。这是所有推荐系统都绕不开的先天问题,处理策略就是在算法前面加一个行为量阈值。我实测下来的经验是:行为记录少于5条,内容推荐就够了;超过10条,协同过滤效果明显好。
3.2 景点标签与用户偏好建模
数据建模是整个推荐质量的地基。景点数据我设计了tags字段,用逗号分隔多个标签,比如“西湖:湖泊,历史,摄影,亲子”“故宫:历史,文化,建筑”。标签体系不要太多太杂,控制在8-10个核心标签以内,不然向量稀疏,算相似度全是为零,没有参考价值。
用户偏好怎么建?我采用行为加权的方式:
| 行为类型 | 权重 | 含义 |
|---|---|---|
| 浏览(view) | 1 | 兴趣微弱 |
| 收藏(collect) | 3 | 兴趣明显 |
| 评分(rating) | 5 | 兴趣强烈 |
用户对某个标签的偏好分,就是所有行为权重的累加。比如用户浏览了“西湖”(湖泊+1,历史+1),收藏了“故宫”(历史+3,文化+3),那他的偏好向量就是{湖泊:1, 历史:4, 文化:3}。这个向量就是后面推荐打分的数据源。
3.3 基于物品的协同过滤实现
为什么选基于物品而不是基于用户?因为项目初期用户量小,基于用户的协同过滤矩阵极度稀疏,算出来的相似度全是噪音。基于物品的协同过滤稳定性更好,景点数量远少于用户数量,标签相似度可以直接计算,可解释性也强(“因为你喜欢故宫,所以推荐颐和园”听起来就很合理)。
物品相似度的计算我用余弦相似度,两个景点的标签向量越接近,相似度越高。代码实现不复杂:
function computeSimilarity(spotA, spotB) { const tagsA = spotA.tags.split(','); const tagsB = spotB.tags.split(','); const union = new Set([...tagsA, ...tagsB]); let dotProduct = 0; let normA = 0; let normB = 0; union.forEach(tag => { const a = tagsA.includes(tag) ? 1 : 0; const b = tagsB.includes(tag) ? 1 : 0; dotProduct += a * b; normA += a * a; normB += b * b; }); if (normA === 0 || normB === 0) return 0; return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }有了相似度,推荐过程就三步:取用户行为过的景点 → 计算所有未交互景点与它们的相似度 → 按相似度加权排序取Top N。要加权重也行:用户评分5星的景点,它的相似景点排名应该更靠前,所以最后排序得分可以乘上用户对该景点的行为权重。
3.4 冷启动问题的处理方案
冷启动分成两种:用户维度和物品维度。
用户冷启动:新用户没行为,直接推热门榜。热门榜的排序规则我用加权公式:
热门分 = 0.6 × (浏览数/最大浏览数) + 0.4 × (评分/最高评分)两层指标归一化到0-1区间再加权,避免一个景点因为浏览多但评分低而霸榜。
物品冷启动:新景点没人浏览没评分,推荐系统默认分数偏低,容易被埋没。我的处理是给新景点一个“新鲜度加成”,在排序阶段加入时间衰减因子——发布7天内的景点,热度分上浮15%。虽然简单,但能让新内容获得曝光机会,不至于永远沉底。
4. 后端API与数据存储实现
4.1 数据库表设计
数据库我用MySQL,轻量够用。三张核心表就够了:
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, preferences VARCHAR(255) DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE spots ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, city VARCHAR(50), category VARCHAR(50), tags VARCHAR(255), description TEXT, image_url VARCHAR(255), avg_score DECIMAL(2,1) DEFAULT 0, visit_count INT DEFAULT 0 ); CREATE TABLE behaviors ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, spot_id INT NOT NULL, behavior_type ENUM('view','collect','rating') NOT NULL, rating TINYINT DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_time (user_id, created_at) );设计要点:behaviors表建立(user_id, created_at)联合索引,因为推荐逻辑最频繁的查询是“某个用户最近的行为记录”,没有索引的话数据量上来后查询效率断崖式下降。景点表的tags字段很重要的,推荐计算的输入就是它。
4.2 Express路由与接口定义
后端路由我按资源来划分,每个功能一个文件,结构清晰:
// routes/spots.js const express = require('express'); const router = express.Router(); const pool = require('../models/db'); // 景点列表(支持城市、分类筛选) router.get('/', async (req, res) => { const { city, category, keyword } = req.query; let sql = 'SELECT * FROM spots WHERE 1=1'; const params = []; if (city) { sql += ' AND city = ?'; params.push(city); } if (category) { sql += ' AND category = ?'; params.push(category); } if (keyword) { sql += ' AND name LIKE ?'; params.push(`%${keyword}%`); } const [rows] = await pool.query(sql, params); res.json(rows); }); // 景点详情 router.get('/:id', async (req, res) => { const { id } = req.params; const [rows] = await pool.query('SELECT * FROM spots WHERE id = ?', [id]); if (rows.length === 0) { return res.status(404).json({ message: '景点不存在' }); } res.json(rows[0]); }); module.exports = router;连接池的写法值得注意。每次请求都新建连接太浪费,直接用一个mysql2/promise连接池:
// models/db.js const mysql = require('mysql2/promise'); const pool = mysql.createPool({ host: 'localhost', user: 'root', password: '123456', database: 'travel_db', waitForConnections: true, connectionLimit: 10 }); module.exports = pool;connectionLimit: 10的意思是连接池最多保持10个连接,高并发场景这个值要调大,但本地开发调试10个足够,池子太大会浪费MySQL的连接数。
4.3 推荐接口完整实现
这是整个项目的核心接口,我放在routes/recommend.js。逻辑分三步走:查行为、算偏好、生成推荐。
const express = require('express'); const router = express.Router(); const pool = require('../models/db'); const WEIGHT_MAP = { view: 1, collect: 3, rating: 5 }; async function hotSpots(limit = 10) { const [rows] = await pool.query(` SELECT id, name, city, tags, visit_count, avg_score, (0.6 * visit_count / (SELECT MAX(visit_count) FROM spots) + 0.4 * avg_score / 5) AS hot_score FROM spots ORDER BY hot_score DESC LIMIT ? `, [limit]); return rows; } // 基于内容推荐 router.get('/byContent/:userId', async (req, res) => { const { userId } = req.params; const [behaviors] = await pool.query( `SELECT spot_id, behavior_type FROM behaviors WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)`, [userId] ); if (behaviors.length === 0) { return res.json(await hotSpots()); } const spotIds = behaviors.map(b => b.spot_id); const [spots] = await pool.query(`SELECT id, tags FROM spots WHERE id IN (?)`, [spotIds]); const spotTags = {}; spots.forEach(s => spotTags[s.id] = s.tags.split(',')); const pref = {}; behaviors.forEach(b => { const tags = spotTags[b.spot_id] || []; const weight = WEIGHT_MAP[b.behavior_type] || 1; tags.forEach(tag => pref[tag] = (pref[tag] || 0) + weight); }); const interacted = new Set(behaviors.map(b => b.spot_id)); const [allSpots] = await pool.query('SELECT * FROM spots'); const scored = allSpots .filter(s => !interacted.has(s.id)) .map(spot => { let score = 0; spot.tags.split(',').forEach(tag => { score += pref[tag] || 0; }); return { ...spot, score }; }); scored.sort((a, b) => b.score - a.score); res.json(scored.slice(0, 10).map(({ score, ...spot }) => spot)); }); module.exports = router;这里有个细节:时间窗口过滤INTERVAL 30 DAY。用户三个月前喜欢滑雪,现在夏天还推荐滑雪就离谱了,行为数据越新越有参考价值,加上时间窗口能让推荐结果跟着季节变。
主入口把路由挂上去:
app.use('/api/spots', require('./routes/spots')); app.use('/api/recommend', require('./routes/recommend')); app.use('/api/behaviors', require('./routes/behaviors'));5. 前端Vue页面与交互实现
5.1 景点列表与推荐展示页
首页的核心功能是景点展示和筛选。接口返回什么,页面就渲染什么,但要注意数据加载状态的管理。我习惯用一个简单的loading标志:
<script setup> import { ref, onMounted } from 'vue'; import { getSpots, recommendByContent } from '../api'; import { useUserStore } from '../stores/user'; const spots = ref([]); const loading = ref(true); const activeCity = ref('全部'); async function loadSpots() { loading.value = true; try { spots.value = await getSpots({ city: activeCity.value }); } finally { loading.value = false; } } async function loadRecommend() { const userStore = useUserStore(); if (!userStore.loggedIn) { spots.value = await getSpots({ sort: 'hot' }); return; } spots.value = await recommendByContent(userStore.id); } onMounted(loadSpots); </script>模板上用v-for渲染卡片列表,每张卡片点击跳详情页。卡片上要展示名字、城市、标签、评分。图片加载失败时给一个默认占位图,这个细节不处理的话线上会显示一堆裂图图标,很掉档次。
5.2 Vue Router与页面跳转
路由配置虽然简单,但也有些容易出错的地方。如果用了createWebHistory模式,部署到服务器上刷新会404,因为服务器上没有对应的物理路径。本地开发没感觉,一部署就露馅。解决方式是后端配fallback,Express里加一句:
app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../dist/index.html')); });路由跳转用router.push或者<router-link>。景点详情页接收参数的方式:
<script setup> import { useRoute } from 'vue-router'; const route = useRoute(); const spotId = route.params.id; </script>这个spotId传给API接口,拉取详情数据。记得在watch里监听route.params.id的变化,因为从详情页A跳详情页B时,组件实例是复用的,不监听的话页面数据不会刷新。
5.3 Axios请求封装与跨域处理
前端所有请求统一经过axios实例,好处是拦截器统一处理token和错误提示:
// api/index.js import axios from 'axios'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.response.use( res => res.data, err => { console.error('接口请求失败:', err.message); return Promise.reject(err); } ); export const getSpots = params => request.get('/spots', { params }); export const getSpotDetail = id => request.get(`/spots/${id}`); export const recommendByContent = userId => request.get(`/recommend/byContent/${userId}`); export const addBehavior = data => request.post('/behaviors', data);跨域在开发环境用Vite代理解决,前面提到过:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } });有了代理,前端代码里写/api/spots,Vite会把请求转发到http://localhost:3000/api/spots。生产环境部署时,Nginx里也要做同样的转发,这就是为什么baseURL写成/api而不是完整的http://localhost:3000/api——换环境不用改代码。
6. 常见问题与排查实录
6.1 npm.ps1禁止运行脚本的完整排查
这个问题的排查思路我建议理解透,因为它是环境问题里最高频的。前面讲了通过PowerShell执行策略去改,但还有一次我改了策略依然报错——原因很隐蔽:VS Code的终端没有重新加载。执行策略修改后,已打开的PowerShell会话不会自动生效,必须新开一个终端窗口。如果你改了策略还在报错,先别怀疑官方文档,关掉终端重开再说。
另外有人图省事,直接删除npm.ps1文件绕过报错,这是馊主意。npm.ps1是官方脚本,删了npm命令虽然还能跑(实际走的是npm.cmd),但一些依赖脚本的高级功能会异常。我在一个项目里遇到过npm run dev能跑但npm run build诡异的失败,最后查下来就是被人删过npm.ps1,重装了Node才恢复。
6.2 端口被占用EADDRINUSE
Express启动报EADDRINUSE,说明3000端口被占用了。排查命令:
netstat -ano | findstr :3000 taskkill /PID 占用进程的PID /F但治标要治本:为什么端口会被占?最常见的是之前启动的后端进程没有关干净,Ctrl+C没杀掉子进程,又开了一个新后端。我的习惯是启动命令前先查一下端口,写一个简单的批量处理:先kill旧进程再启动,省得每次手工查PID。
6.3 中文乱码与编码问题
Vue前端请求Express接口,返回的中文有时候会变成乱码。常见的两个原因:
一是MySQL连接配置没指定utf8mb4。MySQL 8默认是utf8mb4,但如果是老库,表结构用的latin1,那查出来就是乱码。连接串里要显式指定charset=utf8mb4:
const pool = mysql.createPool({ // ... charset: 'utf8mb4' });二是接口响应头缺失charset。虽然是JSON响应,但Express没有明确告诉浏览器编码格式,某些浏览器会猜测成GBK。加一行统一的中间件:
app.use((req, res, next) => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); next(); });这招在开发时经常被人忽略,线上踩坑概率不低。
6.4 推荐结果太少的排查
系统上线跑了一周,运营反馈说“推荐来推荐去就是那10个景点”。这个问题的根源多半在标签体系太粗糙。如果所有景点都挂了“热门”“经典”这类标签,相似度假如100%,协同过滤算出来全是相似景点,没有多样性。
排查步骤:先从数据库统计标签分布:
SELECT tags, COUNT(*) FROM spots GROUP BY tags;如果发现某些标签覆盖了80%景点,说明标签体系需要拆细。我从实践中总结的经验:每个景点打3-5个标签,其中至少要有一个是“区分性”标签(比如“适合徒步”“夜景”“避开人群”),这样推荐结果才有差异化和惊喜感。
6.5 前端页面白屏的排查思路
白屏是最难定位的问题之一,因为原因太多。先确定是哪个环节挂了:打开浏览器控制台看Network面板,API有没有返回?返回了看Console有没有报错?报错是404说明路由问题,500说明后端异常。
一个我印象很深的案例:项目跑得好好的,某天早上来一看,首页全白。控制台什么都没报。最后发现是Vite的开发服务器接口代理挂了,代理的目标从3000端口变成了某个不存在的端口。排查这类问题有个笨办法但很有效:直接把代理的目标地址从http://localhost:3000改成http://127.0.0.1:3000试试。某些Windows环境下,Node的DNS解析对localhost返回了IPv6地址::1,而Express监听的是IPv4,代理就转发失败。这种网络层的小毛病,不动代码完全想不到。
7. 这套系统后续还能怎么扩展
项目做完之后,我觉得最值得扩展的方向有三个。
第一,把协同过滤做得更完整。当前实现偏基于内容,等用户行为数据积累上万条之后,可以加上基于用户的协同过滤,两种策略做加权融合。简单公式:最终得分 = 0.6 × 内容得分 + 0.4 × 协同得分,权重可以按日活调整。这个升级不需要改前端,后端加一个策略层就行。
第二,引入地理位置因素。旅游是典型的位置敏感场景。用户最近浏览的景点在哪个城市,推荐结果里就优先推同城或者邻近城市的景点。实现方式也不难,景点表加一个region字段,推荐排序时做一次分组加权。
第三,把推荐解释出来。推荐系统被用户信任的关键在于可解释性。Vue前端推荐卡片上可以加一句推荐理由:因为你浏览了“西湖”,所以推荐“千岛湖”。这需要后端在推荐结果里附带reason字段,前端渲染时展示出来。数据上多存一行,体验上高一个台阶。
我在实际开发中的体会是:推荐系统这个项目,难点不在算法多高深,而在数据链路完整、反馈闭环顺畅。一个能跑通、能解释、能持续优化的简化版系统,比一个调了一堆库却不知道原理的“豪华版”有价值得多。如果你正在做类似的项目,先把行为采集做好,把标签体系设计清楚,这比纠结怎么调协同过滤参数重要一百倍。这套代码量不大,但整条链路走一遍,对全栈开发的认知会上一个台阶。