最近用nodejs加微信小程序做了一款厦门周边游平台,从后端接口到小程序前端,再到上线发布,整个过程踩了不少坑。这个项目最大的价值不在于“又做了一个旅游App”,而在于它把厦门本地化的周边游信息和微信生态的便捷入口结合在了一起,游客不用下载App,扫码就能用,本地人也能快速找到周末去处。如果你是刚接触微信小程序开发,或者正在用nodejs写后端接口,又或者想做一个带真实业务的小程序练手,这篇内容应该能帮你省下不少时间。
我尽量把开发过程中的真实决策、代码细节、故障排查都写出来,而不是只给一个“项目展示”。文章里没有那些绕来绕去的理论,全是实际动手时用得上的东西。
1. 项目定位与整体设计思路
1.1 项目背景:为什么做厦门周边游平台
做这个项目之前,我观察到一个很明显的需求分化。外地游客来厦门,目标很集中,就是鼓浪屿、南普陀、环岛路、曾厝垵这些老牌景点。但厦门本地人和周边城市的游客,周末更想去的是同安的汀溪、翔安的香山、海沧的大屏山,或者漳州、泉州这种一两小时车程内的目的地。这些地方的交通怎么走、有什么特色民宿、哪家海鲜排档靠谱,信息非常分散,大多数只能靠小红书或者本地群打听。
厦门周边游平台就是想把这部分需求收拢到一个微信小程序里。用户打开小程序,先看他所在的位置,然后推荐附近的一日游路线、半日游路线,按季节推送采摘、露营、赶海这类主题玩法。后端用nodejs做接口,前端用微信小程序做载体,好处是免安装、传播快,分享给朋友一个卡片就能打开。整个项目定位是一个轻量级的周边游信息服务平台,先不做复杂的支付和订单流程,重点把内容展示和路线推荐做好,后期可以再加民宿预订和门票购买。
1.2 核心功能拆解与用户角色
我按用户角色把功能分成了三类。游客端主要看:景点列表、景点详情、路线推荐、美食推荐、民宿展示、攻略文章。注册用户还能做收藏、点赞、评论,以及生成自己的游玩计划。运营后台的功能我一开始没有单独做网页,而是直接在小程序里放了几个管理入口,方便自己添加和更新内容,后来才拆出了一个简单的web管理端。
具体功能清单大概是这样的:
- 景点浏览:按区域、标签、热度筛选,支持关键词搜索
- 路线推荐:根据用户选择的天数、出发地点、偏好主题,生成推荐路线
- 内容展示:景点介绍、门票参考价、开放时间、交通指引,都用富文本图片展示
- 用户中心:微信授权登录、我的收藏、我的足迹、我的评论
- 信息发布:管理员可以发布景点、编辑路线、更新攻略
整个项目大概花了三周时间,前端小程序和后端接口同步开发。最开始的版本没有做数据库,而是用json文件模拟数据,先把页面和交互跑通,后来才切换成MySQL。这个做法我建议你也试试,特别是时间紧张的时候,先把核心流程打通,比一上来就设计五张表要高效得多。
1.3 技术选型:为什么选nodejs + 微信小程序
很多人问我,后端为什么不用Java或者Python?其实在这个项目场景下,nodejs有几个天然优势。第一,前端是微信小程序,用的JavaScript,后端如果也用JavaScript,很多数据结构和逻辑都可以复用,比如日期格式化、数组处理、对象深拷贝这类工具函数,我直接在前端和后端各写了一份,思路完全一样。第二,nodejs的启动速度很快,在小团队开发、快速迭代的场景下非常合适,改完代码重启一下,几秒钟就能看到效果。第三,nodejs生态里有很多现成的库,做接口用Express或者Koa,做数据库操作有Sequelize或者Prisma,文件上传、定时任务、邮件发送都有成熟的包。
微信小程序这边,我选的是原生开发,没有用uni-app或者Taro。原因是这个项目需要用到地图、定位、转发分享这些原生能力,原生小程序对这些组件的支持最直接,文档也最全。如果你要同时跑微信和支付宝两个平台,那用uni-app是合理的,但我的目标用户就在微信里,没必要多引入一层框架。另外,原生小程序的体积控制也更好,首屏加载会快一些。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Nodejs后端环境搭建与接口开发
2.1 Nodejs安装与环境变量配置
这个项目一开始就卡在了环境安装上。我当时下载的是nodejs官方网站的LTS版本,版本号是18.x。安装包一路下一步就行,但有一点要特别注意:安装路径不要带空格和中文。我看到很多教程喜欢把nodejs装在D:\Program Files (x86)这种目录里,后面很容易出现权限问题,尤其是npm全局安装包的时候,可能会因为权限不足导致各种奇怪的报错。我自己后来统一装在D:\nodejs或者C:\nodejs这种简单路径下。
安装完成后,打开命令行,输入node -v和npm -v验证,如果能看到版本号就说明装好了。nodejs安装包默认会把node和npm的路径写入系统环境变量,正常情况下不需要手动配置。但如果你用的是zip绿色版,那就要手动把解压目录加到PATH环境变量里。配置完之后,最好重启一下命令行终端,不然环境变量不会生效。
2.2 npm命令执行报错:npm.ps1无法加载的修复
我相信只要你在Windows上用过nodejs,大概率遇到过这个报错:
bash复制npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
我当时第一次运行npm install就被这个卡住了。原因是Windows PowerShell的脚本执行策略默认是Restricted,不允许执行.ps1脚本文件。解决办法有两种,一种是换个终端,用cmd或者Windows Terminal里的Command Prompt,不用PowerShell就不会触发这个限制。另一种是修改执行策略,以管理员身份打开PowerShell,执行:
powershell复制Set-ExecutionPolicy RemoteSigned
这个命令的意思是,本地创建的脚本可以运行,从网上下载的脚本必须经过数字签名才能运行。输入之后会问你确认,选Y即可。注意,这个修改只对当前用户生效,不会影响系统安全设置。如果你不想手动改,也可以在PowerShell里运行npm命令时用npm.cmd来替代,比如npm.cmd install,这个文件不会触发ps1的脚本策略。
2.3 用Express搭建RESTful接口
后端框架我选的是Express,它足够轻量,周边游平台这种规模的接口,Express完全能hold住。项目目录结构大概是这样的:
text复制server/
├── app.js # 入口文件
├── routes/ # 路由
│ ├── scenic.js # 景点路由
│ ├── route.js # 路线路由
│ ├── user.js # 用户路由
│ └── comment.js # 评论路由
├── controllers/ # 控制器
├── models/ # 数据模型
├── config/ # 配置文件
└── package.json
入口文件app.js里,核心就是创建应用、挂载路由、监听端口:
javascript复制const express = require('express');
const app = express();
const port = 3000;
app.use(express.json());
app.use('/api/scenic', require('./routes/scenic'));
app.use('/api/route', require('./routes/route'));
app.use('/api/user', require('./routes/user'));
app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
这里我特别注意了跨域问题。微信小程序在真机调试时,请求后端接口是不存在跨域限制的,但在模拟器和开发者工具里,如果没有在微信公众平台配置request合法域名,就会报url not in domain list。本地开发的时候,我一般会在开发者工具中勾选“不校验合法域名”,但上线前一定要在微信公众平台后台配置好域名和SSL证书。
2.4 数据存储与模型设计
数据存储我选的是MySQL,因为我的服务器上已经装了一个MySQL实例,不需要额外引入MongoDB。表结构在开发初期反复改过几次,最后稳定下来有这几张表:
scenic:景点表,字段包括id、名称、描述、封面图、区域、标签、门票、开放时间、交通信息、经纬度route:路线表,字段包括路线名称、天数、主题、适合人群、途经景点id列表、详细行程article:攻略文章表user:用户表,保存微信openid、昵称、头像favorite:收藏表,用户和景点的多对多关系
用Sequelize操作MySQL非常方便,举个例子,定义景点模型:
javascript复制const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const Scenic = sequelize.define('Scenic', {
name: {
type: DataTypes.STRING,
allowNull: false,
},
area: {
type: DataTypes.STRING,
comment: '所在区域,如思明、湖里、同安',
},
tags: {
type: DataTypes.JSON,
comment: '标签数组,如[海边,亲子,露营]',
},
latitude: DataTypes.DECIMAL(10, 7),
longitude: DataTypes.DECIMAL(10, 7),
}, {
tableName: 'scenic',
});
module.exports = Scenic;
接口返回数据的时候,我一般会做一层格式化,把时间戳转成时间字符串,把JSON字段转成数组,避免前端拿到的是一个字符串再手动parse。
3. 微信小程序端从零到上线的关键环节
3.1 小程序注册与AppID配置
开发微信小程序,第一步是到微信公众平台注册一个小程序账号。个人主体和企业主体能用的功能有差别,比如个人主体不支持微信支付,部分类目也受限。厦门周边游这个项目当时用的是企业主体,因为后期要接入民宿预订和门票购买,个人主体没办法做这些。
注册完成后,在“开发管理-开发设置”里可以看到AppID和AppSecret。AppID是公开的,会写在小程序代码里,AppSecret是私密的,绝对不能放在小程序端,必须保存在后端nodejs环境变量里。小程序端调用后端接口时,后端再用AppSecret去微信换取用户身份。
在微信开发者工具里新建项目时,填入AppID,这样模拟器和真机调试才能正常识别你这个小程序。如果你有多个小程序,很容易搞混AppID。我建议在项目的project.config.json里保存对应的appid,并且在代码里用环境变量区分,避免提交代码的时候带上错误的AppID。
3.2 登录态处理:wx.login获取openid的完整流程
这是微信小程序开发最核心的一块。官方推荐的登录流程是这样的:
- 小程序端调用
wx.login(),获取到一个临时code - 小程序把code发给自己的后端接口
- 后端用code加上AppSecret,请求微信官方接口,换取openid和session_key
- 后端用自己的方式生成一个自定义登录态(比如JWT),返回给小程序
- 小程序后续请求都带上这个自定义登录态
我当时在实现时,后端用nodejs写了一个登录接口:
javascript复制const axios = require('axios');
async function getWechatSession(code) {
const { appid, secret } = require('../config/wechat');
const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`;
const res = await axios.get(url);
if (res.data.errcode) {
throw new Error(res.data.errmsg);
}
return res.data; // { openid, session_key, unionid? }
}
拿到openid后,我去数据库里查一下是否已有这个用户,没有就新注册一个,然后签发一条JWT给小程序端。注意,session_key应该在后端保存,但不要下发,因为它是用来解密手机号等敏感数据的,前端用不到。
3.3 首页与景点列表的快速实现
首页我采用的是“搜索栏 + 轮播图 + 分类导航 + 推荐景点列表”结构。分类导航按厦门本岛、同安、翔安、海沧、集美等区域划分,点击之后跳转到对应列表页。列表页用的是scroll-view实现下拉刷新和上拉加载更多,后端接口支持page和pageSize分页参数。
小程序请求后端接口,我封装了一个request工具:
javascript复制const request = (url, method = 'GET', data = {}) => {
return new Promise((resolve, reject) => {
wx.request({
url: `https://api.example.com${url}`,
method,
data,
header: {
'Content-Type': 'application/json',
'Authorization': wx.getStorageSync('token'),
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data);
} else if (res.statusCode === 401) {
wx.navigateTo({ url: '/pages/login/index' });
} else {
reject(res);
}
},
fail: reject,
});
});
};
景点详情页包含了图片轮播、基本信息、详细介绍、地图定位、相关路线推荐。地图定位用的是微信小程序的map组件,传入经纬度即可。这里的经验是,不要把整个大段文案一次性渲染,而是等图片懒加载完成后,再渲染文字部分,体验会好很多。
3.4 周边游特色功能:位置定位与路线推荐
周边游和普通旅游App的最大区别在于“周边”两个字。小程序通过wx.getLocation拿到用户当前经纬度,然后根据距离计算推荐附近的景点。后端接口我传的是用户经纬度,SQL里用一个计算距离的公式:
sql复制SELECT * FROM scenic
ORDER BY (POW((latitude - :lat) * 111, 2) + POW((longitude - :lng) * 111 * COS(:lat * PI() / 180), 2))
LIMIT 20
这个公式简化了地球球面距离计算,在厦门这种不大范围场景下足够准确。如果你想更精确,可以用Haversine公式,但考虑到景点经纬度小数点后6位,实际偏差可以忽略不计。
路线推荐这里我设计了一个比较简单的规则引擎。路线表里存了“适合人群”和“主题标签”,比如亲子游、毕业旅行、徒步探险、海鲜美食。用户选择感兴趣的主题后,后端拉取对应标签的路线并按照评分排序。这个评分是后台人工维护的,前期没有用户行为数据,人工打分是最快的方式。
3.5 发布上线前必须处理的几个细节
小程序上线远比网页严格。我第一次提交审核时,被驳回的原因有好几个,印象最深的是“页面存在测试数据”和“类目选择不一致”。后来认真检查了这些问题:
- 所有接口必须换成https,并且域名要备案,域名证书必须有效
- 小程序后台要配置request合法域名、uploadFile合法域名
- 上线前移除所有console.log和调试弹窗
- 页面里不能出现“测试”、“mock”、“占位”等字眼
- 用户隐私协议、用户授权逻辑要完整,尤其是getLocation授权
还有一个小细节,小程序的体积不能超过2MB,不然上传的时候会提示。我一开始放了很多原图,包体积直接飙到5MB,后来把所有图片全部压缩,并改成cdn链接,最终包体积控制在1.2MB左右。
4. 厦门周边游业务数据的组织与展示
4.1 数据模型设计:景点、路线、攻略的关系
这个项目的数据量不大,但关系还算复杂。一开始我草草地设计表结构,后来发现一个景点可能出现在多条路线里,一条路线也包含多个景点,这就是典型的多对多关系。我建了一张中间表route_scenic,用来保存路线和景点的关联关系。这样在查询一条路线时,可以一次性把途经景点查出来,并排序。
景点、路线、攻略三类内容的关联逻辑是这样的:
- 路线关联景点,通过
route_scenic表 - 攻略关联景点,通过
article.scenic_id字段 - 一个景点可以有多条攻略,一个攻略也可以关联多个景点,但为了简单,我用的是一对多
数据填充时,我参考了当前的公开旅游信息,整理出厦门及周边的50个主要景点、20条推荐路线和30篇攻略。为了真实效果,每个景点都配了封面图、简介、经纬度、开放时间。这个过程比较费时,但项目质量很大程度上取决于数据质量,尤其是旅游类项目,内容空洞的话功能再强也没用。
4.2 简单的推荐逻辑:按标签和距离双重筛选
推荐逻辑我没有上机器学习,而是用标签匹配加距离加权。每个景点有一个或者多个标签,例如“海边”、“亲子”、“露营”、“历史古迹”。用户第一次进入首页时会请求获取位置,然后后端接口返回以用户当前位置为中心、半径30公里内的景点,同时按照“标签匹配度+距离”做综合排序。
用SQL来表达,大概是:
sql复制SELECT s.*,
GROUP_CONCAT(st.tag_name) AS tags,
(POW((s.latitude - :lat) * 111, 2) + POW((s.longitude - :lng) * 111 * COS(:lat * PI() / 180), 2)) AS dist
FROM scenic s
LEFT JOIN scenic_tag st ON s.id = st.scenic_id
WHERE s.status = 1
GROUP BY s.id
ORDER BY dist ASC
LIMIT :limit
如果用户明确选择了“亲子”主题,我就在应用层对结果集做一次过滤,优先返回包含“亲子”标签的景点,再按照距离排序。这种做法虽然简单,但在地域型的应用中比单纯按热度排序更符合直觉,因为周边游用户第一诉求是“近”,第二才是“好玩”。
4.3 前端数据渲染与接口返回结构约定
接口返回格式,我统一封装成:
json复制{
"code": 0,
"message": "success",
"data": {
"list": [],
"total": 100,
"page": 1,
"pageSize": 10
}
}
这样前端处理起来非常统一。小程序端拿到data.list后,直接setData渲染。有个容易踩的坑是setData的数据量限制,小程序setData一次最大只能传1MB,所以列表页千万不能一次性返回所有数据,必须分页。我在列表页用onReachBottom事件触底加载下一页,配合isLoading标志位防止重复请求。
5. 常见问题与排查技巧实录
5.1 微信小程序单选框组件为什么会失效
我做一个筛选弹窗时,用了radio-group,但发现点击后选中状态不更新。排查了半天,发现是因为我把checked属性写死在数据里,没有在bindchange事件里更新。正确做法是:
html复制<radio-group bindchange="onFilterChange">
<label wx:for="{{filterOptions}}" wx:key="value">
<radio value="{{item.value}}" checked="{{item.checked}}" />
{{item.label}}
</label>
</radio-group>
然后在js里监听change事件,更新对应选项的checked状态。如果你的选项是动态渲染的,记得每次点击后重新setData整个选项数组,不然旧状态会残留。
5.2 小程序获取登录后的微信用户失败
这个错误信息最早是wx1cb4398e1413dce7之类的字符串,其实它不是一个人类可读的错误,而是微信的登录凭证过期或者code被重复使用时产生的。我遇到的情况是,我在页面onLoad时调了一次wx.login,然后在另一个生命周期里又调了一次,导致第一次的code已经无效。解决方法是把wx.login提前到App.js的onLaunch里只执行一次,或者确保每次请求登录接口用的都是最新的code。另外,如果后端模拟请求微信接口失败,也会报这个错,这时候要看后端日志,检查AppSecret是否正确。
5.3 运行到微信小程序模拟器中,小程序ID还是原来的
这个问题在我用HBuilderX开发uniapp时尤其明显。我在HBuilderX里改了小程序AppID,但运行到模拟器里还是老的项目ID。原因是微信开发者工具里缓存了之前的项目配置,没有刷新。解决办法是:在微信开发者工具中,点击“详情-基本信息”,手动修改AppID,或者把项目目录下的project.config.json里的appid改成新值,然后关闭开发者工具重新打开。如果你是用原生微信开发者工具,这个问题不常见,但也要注意第一次创建项目时选择的目录是否残留了旧项目的配置文件。
5.4 顶部导航栏高度适配和吸顶效果
周边游平台的小程序里,分类导航条要做成吸顶效果。但不同手机上胶囊按钮的位置不同,顶部导航栏高度也不一样。如果直接用固定的像素高度,在刘海屏上会错位。正确做法是利用小程序的胶囊按钮高度和状态栏高度动态计算。可以用:
javascript复制const sys = wx.getWindowInfo();
const menuButtonRect = wx.getMenuButtonBoundingClientRect();
const navBarHeight = (menuButtonRect.top - sys.statusBarHeight) * 2 + menuButtonRect.height;
这段代码能获取到自定义导航栏的整体高度。然后给自定义导航栏设置style属性,动态调整顶部占位view的高度。
5.5 为什么开发的微信小程序不能上传
很多新手在小程序开发者工具里点了“上传”,发现按钮是灰色的或者没有反应。首先确认你用的是正式版开发者工具,而不是测试版。其次,在上传之前需要先登录,且当前账号必须是小程序的管理员或者有开发权限的成员。我遇到的最隐蔽的问题,是项目的AppID和当前登录账号名下的小程序不匹配。简单说,你不能把A小程序的代码上传到B小程序里去,必须要用对应的AppID。在project.config.json里确认appid,并和微信公众平台后台的AppID一一对应。
还有一个容易忽略的原因:代码包体积超限。项目里面如果有大图片或者不必要的依赖,包超过2MB就会上传失败。上传前在开发者工具的“详情-基本信息”里看一下代码包大小,如果超标就把无关图片移到cdn,或者用压缩工具处理一下再传。
5.6 解决微信小程序调试时一直卡在paused in debugger
这个不是我们这个项目独有的,但真机调试时很常见。小程序调试器如果停在paused in debugger,一般是因为开发者工具里的“Sources”面板设置了断点,或者在代码里写了debugger语句。我当时的代码里并没有断点,后来发现是开发者工具自动在第一个执行行暂停了。解决办法是在调试器里点击“Resume script execution”按钮,或者关闭“自动暂停”选项。如果还是频繁暂停,就在开发者工具里清除断点,或者重启一下调试会话。
以上这些问题,是我从开发到上线这一个月里真实遇到过的。每解决一个,都能对小程序的运行机制理解得更深一点。尤其是nodejs后端环境问题,看起来很简单,但项目真正跑起来的时候,这些小坑最容易浪费半天时间。如果你也正在做类似的周边游或者本地生活类小程序,希望这些经验能帮你绕开我之前走过的弯路。最后再分享一个我自己的习惯:从第一天开始,就把后端接口的请求日志和错误日志分开输出,出问题的时候排查效率会提升好几倍。
