uniapp+Vue3实战:从0到1开发露营App完整指南
简介基于uni-app与Vue框架开发的《露营》App完整项目源码包面向需要学习移动端与后台管理开发的初级、中级开发者。项目在HBuilder X平台下实现分为用户前端和管理后台前端覆盖首页、露营信息、露营教程、个人中心等模块后台支持类型管理、用户管理、露营信息管理、订单信息管理及系统管理并均可完成增删改查操作。压缩包共1015个文件其中216个vue页面组件、139个js脚本、111个java接口类构成核心逻辑配合231个png及162个svg处理界面视觉整体大小约17.75MB文件结构清晰便于按模块查阅。已有199人学习浏览适合作为课程设计、毕业设计或入门实战项目参考有助于快速掌握uni-app跨端开发与后台协同的完整流程。1. 露营这件小事为什么用uniappVue来写App露营是这两年最具体的周末场景之一找营地、查装备、约好友、看天气每个需求单拎出来都不算复杂但要把它们串成一个能用的 App涉及页面路由、地图定位、多端打包一整套工程决策。用原生安卓写一遍再给微信小程序重写一遍成本直接翻倍这正是 uniapp Vue 组合存在的理由。在这套技术栈里Vue 负责组件化和状态组织uniapp 把微信小程序、H5、安卓和 iOS 的差异收敛到同一套 API 后面。这篇文不是某个现成项目的说明书而是顺着《露营》这类工具型 App 最常见的落地路径把工程结构、核心功能、打包上架和真机验证讲清楚哪怕你手上只有一个打包好的 zip 工程也能拿它当排查地图用。2. 露营App的Vue3页面骨架与pages.json路由配置露营类 App 的页面通常不会太多营地列表、营地详情、装备清单再加一个个人中心。页面少反而要求把路由和目录一次定清楚后面加“我的足迹”“路书”这类功能时不用回头挪结构。这一章先把工程的骨架立住再落到 Vue3 组合式 API 的页面写法上顺带把 Vue2 老工程迁移时最容易踩的三个差异说透。2.1 先分清 pages.json、manifest.json 和 static 的职责uniapp 工程里pages.json 管路由和导航栏配置manifest.json 管应用级配置static 目录放静态资源。新手最常犯的错是把页面跳转逻辑放在某个全局变量里或者在 pages.json 里写业务字段。下表是这几个地方的职责边界照着划分就不会乱文件/目录职责常见误用src/pages/页面组件把业务组件也塞进 pages 目录导致每个分包多编译一份src/static/图片、字体、本地 JSON用字符串动态拼路径小程序端编译后找不到资源pages.json路由、tabBar、导航栏、分包在 style 里写 uniapp 不支持的字段真机静默失效manifest.jsonappid、模块权限、隐私声明改完不重新打包以为热重载会带新配置uni.scss全局 scss 变量页面里散落重复颜色值换主题时逐个改pages.json 是路由的唯一入口下面的配置可以直接抄进工程{ pages: [ { path: pages/index/index, style: { navigationBarTitleText: 营地列表 } }, { path: pages/checklist/checklist, style: { navigationBarTitleText: 装备清单 } }, { path: pages/detail/detail, style: { navigationBarTitleText: 营地详情 } } ], globalStyle: { navigationBarTextStyle: white, navigationBarBackgroundColor: #3A6B35, backgroundColor: #F6F7F8 }, tabBar: { color: #8C8C8C, selectedColor: #3A6B35, list: [ { pagePath: pages/index/index, text: 找营地 }, { pagePath: pages/checklist/checklist, text: 装备 } ] } }pages 数组里第一个页面就是冷启动首页tabBar 的 pagePath 必须同时存在于 pages 数组否则编译阶段直接报错。navigationBarBackgroundColor 只接受十六进制色值写成 rgb() 在安卓端会不生效。tabBar 列表建议控制在 2 到 5 项超过 5 项在部分安卓机型上会挤压文字区域。2.2 用Vue3 script setup写营地列表uniapp路由传参一次说清页面骨架定好后列表页是最有代表性的写法请求数据、搜索过滤、点击跳详情。下面这段覆盖了 uniapp 里 Vue3 组合式 API 的主要用法template view classcamp-list input v-modelkeyword placeholder搜索营地名称 classsearch / view v-forsite in filteredList :keysite.id classcamp-item clickgoDetail(site.id) image classcover :srcsite.cover modeaspectFill / view classbody text classname{{ site.name }}/text text classprice¥{{ site.price }}/晚/text /view /view view v-ifloading classloading加载中.../view /view /template script setup import { ref, computed } from vue import { onLoad, onPullDownRefresh } from dcloudio/uni-app const list ref([]) const keyword ref() const loading ref(false) const filteredList computed(() keyword.value ? list.value.filter((item) item.name.includes(keyword.value)) : list.value ) onLoad(async () { loading.value true try { const res await uni.request({ url: https://api.camp.example.com/sites, method: GET, data: { page: 1, size: 20 } }) list.value res.data.list } catch (e) { uni.showToast({ title: 营地列表拉取失败, icon: none }) } finally { loading.value false } }) onPullDownRefresh(async () { await loadSites() uni.stopPullDownRefresh() }) const goDetail (id) { uni.navigateTo({ url: /pages/detail/detail?id${id} }) } /scriptonLoad 是 uniapp 的页面生命周期来源是 dcloudio/uni-app 而不是 vue 包页面里同时存在 Vue 自身生命周期时两者按各自语义执行不要混写。filteredList 用 computed 缓存过滤结果避免每次渲染都重算全量数组。onPullDownRefresh 要生效必须在 pages.json 对应页面的 style 里加 enablePullDownRefresh: true。跳详情用 navigateTo 拼接查询参数详情页在 onLoad(options) 里取 options.id这就是 uniapp 形态的路由参数传递——vue-router 的 query 在这里变成 URL 查询串传对象时要先 JSON.stringify接收端再 parse。2.3 从Vue2迁到Vue3main.js、全局属性和过滤器的差异很多露营小程序的存量工程还是 Vue2 写法。uniapp 对 Vue3 的支持已经稳定迁移时不必重写所有页面把入口、全局属性和过滤器处理好就能跑通。三处改动的对照如下// Vue2 时代的 main.js import Vue from vue import App from ./App.vue Vue.config.productionTip false Vue.prototype.$toast (msg) uni.showToast({ title: msg }) new Vue({ render: (h) h(App) }).$mount(#app)// Vue3 时代的 main.js import { createSSRApp } from vue import App from ./App.vue export function createApp() { const app createSSRApp(App) app.config.globalProperties.$toast (msg) uni.showToast({ title: msg }) return { app } }uniapp 的 Vue3 入口要求导出 createApp内部用 createSSRApp 保证 App 端渲染正常这个函数名不能换成 createApp 后直接挂载。Vue2 的 prototype 挂载改为 globalProperties页面里通过 getCurrentInstance().appContext.config.globalProperties 取用。Vue3 移除了 filters价格格式化这类逻辑改成 computed 或独立函数模板里直接调用即可。迁移后如果遇到样式错位优先检查是否用了 Vue2 时代的深度选择器 /deep/统一改成 :deep() 语法。3. 露营核心功能落地uniapp定位、本地存储与请求API的实战写法露营 App 的差异化在“现场感”营地在地图上的位置、出发前要带的装备、目的地天气。这三块分别对应定位、本地存储和网络请求也是 uniapp 跨端差异最明显的地方。只要有户外使用场景就不能假设手机永远在线所以这一章的代码都按弱网和权限受限的情况做了兜底。3.1 map组件和uni.getLocation的坐标陷阱地图页是露营 App 的入口级功能。uniapp 内置 map 组件在微信小程序和 App 端都能直接用但坐标体系有一个必须处理的细节定位类型要选 gcj02也就是高德和腾讯地图使用的国测局坐标。如果拿到 wgs84 原始坐标直接丢给 map 组件在真实户外会偏移几十米室内看不出来一到空旷营地就露馅。template map idcampMap classmap :latitudecenter.lat :longitudecenter.lng :markersmarkers :scale12 markertaponMarkerTap / /template script setup import { reactive, ref } from vue import { onLoad } from dcloudio/uni-app const center reactive({ lat: 39.9042, lng: 116.4074 }) const markers ref([]) const onMarkerTap (e) { const site markers.value.find((m) m.id e.detail.markerId) if (site) uni.navigateTo({ url: /pages/detail/detail?id${site.id} }) } onLoad(async () { try { const loc await uni.getLocation({ type: gcj02, isHighAccuracy: true, highAccuracyExpireTime: 4000 }) center.lat loc.latitude center.lng loc.longitude const res await uni.request({ url: https://api.camp.example.com/nearby, data: { lat: center.lat, lng: center.lng, radius: 20000 } }) markers.value res.data.map((s) ({ id: s.id, latitude: s.lat, longitude: s.lng, title: s.name, iconPath: /static/marker.png, width: 28, height: 34 })) } catch (e) { uni.showToast({ title: 定位失败请检查权限, icon: none }) } }) /scriptisHighAccuracy 和高精度超时只在部分安卓机型生效低端机可能直接走普通定位失败时要用上一次缓存的坐标做降级而不是弹窗打断用户。markertap 事件的 detail.markerId 是数字和构造 markers 时写入的 id 对应不要拿数组索引去匹配。H5 端的 map 组件支持不完整常见做法是用平台判断小程序和 App 走内置 mapH5 引入腾讯地图 JS SDK 单独渲染。提示微信小程序端必须在 manifest.json 的 mp-weixin 节点里声明 requiredPrivateInfos: [getLocation]否则真机一进来就报 getLocation:fail the api need to be declared这个报错直接搜错误文案就能定位到问题。3.2 装备清单用uni.setStorageSync实现离线可用营地的信号盲区是真实痛点装备清单必须离线可用。uniapp 的本地存储 API 在四端行为一致同步接口在小数据量场景下性能足够不需要引入额外的存储库template view classgear input v-modeldraft placeholder输入装备例如防潮垫 confirmaddItem / view v-foritem in items :keyitem.id classrow clicktoggle(item) text :class{ done: item.done }{{ item.name }}/text /view /view /template script setup import { ref, watch } from vue import { onShow } from dcloudio/uni-app const STORAGE_KEY camp_gear const items ref(uni.getStorageSync(STORAGE_KEY) || []) const draft ref() watch( items, (val) { uni.setStorageSync(STORAGE_KEY, val) }, { deep: true } ) const addItem () { const name draft.value.trim() if (!name) return items.value.push({ id: Date.now(), name, done: false }) draft.value } const toggle (item) { item.done !item.done } onShow(() { items.value uni.getStorageSync(STORAGE_KEY) || [] }) /scriptwatch 深监听数组变化后同步写入存储比每次增删手动调 setStorageSync 少写出错分支。onShow 里重新读取是为了处理从其他页面返回后的数据同步避免多页面共用一份清单时看到旧数据。storage 适合放 JSON 小对象图片、音频这类资源不要往里塞Android 端单 key 超过 1MB 后读写延迟会明显上升。如果清单数据还要多人协作再把读写逻辑抽进 pinia store用 defineStore 管理本地存储只做兜底缓存。维度本地存储pinia适用场景读取速度磁盘级内存级单人装备清单用本地存储足够跨页面共享需自己监听同步天然响应式多页面同改一份数据用 pinia持久化默认持久不持久pinia 需要搭配 persist 插件再落盘3.3 天气请求的timeout与缓存兜底露营计划页里通常会放一张天气卡片常见做法是接和风天气或高德天气这类接口。第三方接口在弱网下最容易超时uni.request 的 timeout 默认 60000 毫秒对户外场景太长了必须显式调小function fetchWeather(lat, lng) { return uni.request({ url: https://api.camp.example.com/weather, data: { lat, lng, key: WEATHER_KEY }, timeout: 8000, method: GET }) } async function safeLoadWeather() { try { const res await fetchWeather(center.lat, center.lng) return res.data.now } catch (e) { return { text: 晴, temp: -- } } }timeout 参数对大多数平台生效但微信小程序的基础库对超时支持有差异真机弱网测试时如果发现超时不生效就用 Promise.race 包一层做兜底。天气数据变化频率低拉取成功后缓存 15 分钟避免每次进页面都发请求。WEATHER_KEY 这类密钥放前端会被直接抓包看到常见做法是让服务端代理天气请求小程序端只传坐标由后端拼 key。4. manifest配置、uniapp多端打包与3个高频坑功能写完打包和上架才是真正的分岔路。uniapp 打包微信小程序、H5 和安卓 App 走的是三条链路manifest.json 是这三条链路的统一入口。这一章把配置、命令和三个高频报错一起讲清楚打包阶段少走弯路。4.1 manifest.json里的定位权限与隐私声明manifest.json 在 HBuilderX 里可以可视化编辑但多人协作的仓库里应该以源码为准。定位权限和隐私声明是打包前必须确认的两项{ name: 露营, appid: __UNI__CAMP001, mp-weixin: { appid: wx你的小程序appid, requiredPrivateInfos: [getLocation, chooseLocation], permission: { scope.userLocation: { desc: 用于在营地地图上显示你的位置 } } }, app-plus: { distribute: { android: { permissions: [ uses-permission android:name\android.permission.ACCESS_FINE_LOCATION\/, uses-permission android:name\android.permission.ACCESS_COARSE_LOCATION\/ ] } } } }mp-weixin 的 requiredPrivateInfos 是微信收紧隐私接口后必须补的声明2023 年后更新过一轮规则真机报 getLocation 相关错误时第一件事就是回这里检查而不是反复改代码。app-plus 的 permissions 是安卓权限列表云打包时 HBuilderX 会合并这些声明不要在页面里动态申请未声明的权限。改完 manifest 必须重新打包热重载不会带上新配置。注意appid 属于应用标识提交到公开仓库前确认是否要脱敏。HBuilderX 的测试包用公共证书即可上架安卓应用市场必须换成自己的 keystore 签名。4.2 微信小程序、H5和安卓的打包链路与产物用 vue-cli 或 vite 方式创建的 uniapp 工程可以用命令行出包对 CI 友好# 安装依赖注意 node 版本要和工程要求匹配 npm install # 微信小程序产物在 dist/build/mp-weixin npm run build:mp-weixin # H5产物在 dist/build/h5 npm run build:h5微信小程序产物用微信开发者工具导入 dist/build/mp-weixin 目录上传前在详情里确认 appid 正确。H5 产物是纯静态文件可以部署到任意静态服务器接口跨域需要在服务端配置 CORS。安卓 App 的常见做法是 HBuilderX 菜单栏“发行 - 原生App-云打包”不需要本地安卓环境测试包选公共证书上架时必须用自己生成的 keystore并保持各市场包名一致。目标端命令/入口产物位置最常见的失败原因微信小程序npm run build:mp-weixindist/build/mp-weixin没填小程序 appid编译后页面空白H5npm run build:h5dist/build/h5接口跨域需后端加 CORS 头AndroidHBuilderX 云打包apk/aab证书过期或权限声明冲突vue2 老工程迁到 vue3 后首次打包容易遇到 HBuilderX 版本过老导致编译中断Vue3 模板需要 HBuilderX 3.2.5 以上的版本升级前先备份工程。4.3 打包后布局异常和app is not defined的排查打包后的报错和开发期不完全一样下面这几个是社区里出现频率最高的报错或现象常见根因处理办法打包后样式错位、字体大小不一致px 和 rpx 混用或字体文件没走 static 目录统一用 rpx字体放 /static/fonts 并在 App.vue 引入app is not defined在微信小程序端引用了 App 全局对象或 window改用 uni.getApp()或把逻辑放进 APP-PLUS 条件编译修改刚进入的加载页面不生效改的是 pages.json 的 globalStyle冷启动页是原生层App 端换启动图去 manifest 的启动图配置H5 端改 index.html微信小程序定位失败requiredPrivateInfos 未声明补声明后在开发者工具重新编译条件编译是解决这类平台差异的核心手段写法如下// #ifdef APP-PLUS console.log(只在 App 端编译执行) // #endif // #ifdef MP-WEIXIN console.log(只在微信小程序端编译执行) // #endif条件编译按平台注释在编译期剔除代码不是运行时判断写在 template、script、style 里都生效。布局异常还有一个高发点iPhone 底部被安全区横条遮挡tabBar 页面要加 padding-bottom: constant(safe-area-inset-bottom)否则列表最后一项会被顶住。5. 用uniapp自定义分享和扫码把露营闭环搭完露营 App 真正的闭环不是看营地而是“创建活动、分享好友、到场签到”。这一章把 uniapp 的自定义分享和扫码串起来这也是上架前最值得真机验证的两个能力。script setup import { ref } from vue import { onShareAppMessage, onLoad } from dcloudio/uni-app const activityId ref() onLoad((options) { activityId.value options.id || }) // 自定义分享好友点进来直接落在活动页 onShareAppMessage(() ({ title: 一起露营地点我已选好, path: /pages/activity/activity?id${activityId.value} })) // 现场签到扫场地二维码核销 const scanCheckIn () { uni.scanCode({ onlyFromCamera: false, scanType: [qrCode], success: (res) { const code res.result uni.request({ url: https://api.camp.example.com/checkin, method: POST, data: { code } }) } }) } /scriptonShareAppMessage 在微信小程序端生效返回的 path 必须带完整页面路径和参数漏了 id 会导致好友打开后看不到对应活动。H5 端要接各平台的分享 SDK常见做法是页面右上角引导复制链接不依赖微信的分享回调。uni.scanCode 的 res.result 是二维码原始内容建议把营地 id 编码进 URL签到接口只收 code前端扫码组件不需要引入三方库。把这个页面在真机上按四项自测户外关 Wi-Fi 跑定位marker 与实际位置偏差控制在 30 米内微信开发者工具里点右上角转发确认好友打开后能落到活动页打印二维码贴到营位扫码后接口能返回核销成功杀掉进程重新冷启动加载页之后 2 秒内能进首页。如果场地不允许贴二维码就把签到码改成口令输入框uni.scanCode 换成 input 的 confirm 事件核销接口不用改。本文还有配套的精品资源点击获取