uni-app三端同源实战:搭建58同城式生活服务App
2026/9/15 12:01:05 网站建设 项目流程

简介:面向需要快速搭建生活分类信息平台的开发者或创业者,压缩包内是一套跨平台客户端源代码,兼容 App、小程序、H5 等多个终端。整体业务对标 58 同城、赶集网,围绕信息发布、置顶、刷新、佣金分成和广告位构建商业化闭环,并内置社区论坛、房产、招聘、二手、拼车、生活服务等常用频道;类目与信息属性支持自定义,可覆盖多数行业场景。压缩包约 34.61MB,zip 格式便于下载后直接解压查看工程源码、目录结构与多端适配方式,适合已有前端或小程序基础的读者作为二次开发原型。目前已有 496 人浏览学习,尤其适合需要理解分类信息平台权限控制、信息发布流程、付费置顶与佣金结算逻辑的开发者,既能作为起步代码,也可据此快速改造成自营同城服务项目。

1. 一个生活服务App客户端,为什么值得做成跨平台三端同源

先看一个具体场景:一个区域生活服务平台,业务线覆盖房产、招聘、二手、家政、社区论坛,过去靠PC站+微信公众号两条腿走路。移动端需求一来,老板要“App、小程序、H5三端都要有”,而且要在三个月内跑通。如果安卓、iOS、微信小程序、H5各做一套原生或独立工程,光排期就够喝一壶。这类项目的最佳方案,就是用uni-app这类跨平台框架做一套Vue代码,编译到App(含iOS和Android)、微信小程序、H5三端,同时把分类信息、置顶、佣金、社区论坛、自定义栏目按模块化拆开。这篇就按一个一线工程师的落地路径,把这个项目的工程骨架、数据表、业务链路和三端差异讲清楚。

标题里“兼容58同城赶集网”不是在致敬UI,而是指业务模型:分类导航、信息列表、发布置顶、会员佣金、独立论坛、自定义栏目,这套结构才是这类项目真正的核心。下面直接从工程搭建和数据设计开始。

2. 跨平台工程怎么搭:从uni-app目录结构到分类信息的后端表设计

2.1 为什么选uni-app而不是Taro或Flutter

做跨平台优先看三件事:一套代码能到达的目的地、社区生态的成熟度、以及招聘市场上找到的人会不会用。uni-app基于Vue语法,一个项目通过HBuilderX或CLI编译能产出微信小程序、H5、App(打包成原生壳),而且它在中国开发者里的普及程度远高于Taro。Flutter虽然性能和动画体验更好,但Dart语法和UI写法与Web差异大,团队如果纯Web背景,学习成本会拉长交付周期。

另外一点很实际:这类生活服务项目通常需要大量表单页、列表页、地图选点和支付能力,uni-app都提供了现成API。尤其“58同城赶集网”这类分类信息站最典型的页面结构——顶部搜索、分类宫格、信息流列表、底部TabBar——用uni-app的组件模型几乎可以一比一还原,不需要像原生那样为每个平台单独维护一套UI。

2.2 初始化工程,先搭一个三端共用的页面骨架

用HBuilderX可视化创建项目和用CLI创建项目都可行。工程化习惯更好的是CLI方式,因为可以纳入Git版本管理,也能对接现有的CI流程。命令如下:

# 使用vue3+vite模板创建uni-app项目 npx degit dcloudio/uni-preset-vue#vite-ts life-service-client cd life-service-client npm install npm run dev:mp-weixin

创建完项目后,第一件事不是写业务,而是梳理pages.json的全局配置。pages.json是uni-app的页面路由和原生导航配置文件,三端公用。对一个分类信息平台来说,底部TabBar通常有四个页:首页(分类导航)、发布(信息发布)、社区(论坛)、我的(个人中心)。

{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "生活服务" } }, { "path": "pages/release/release", "style": { "navigationBarTitleText": "发布信息" } }, { "path": "pages/community/community", "style": { "navigationBarTitleText": "社区论坛" } }, { "path": "pages/my/my", "style": { "navigationBarTitleText": "我的" } } ], "tabBar": { "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/release/release", "text": "发布" }, { "pagePath": "pages/community/community", "text": "社区" }, { "pagePath": "pages/my/my", "text": "我的" } ] } }

这段配置决定了三端启动后看到的整体框架。tabBar的作用不只是UI层导航,它还决定了App打包后原生底栏的样式,以及小程序端的原生Tab切换体验。要注意图片资源路径问题:小程序TabBar的iconPath在App端可以接受本地相对路径,但在H5端需要确保图片能被正确打包,否则会出现图标丢失。最省事的做法是先用文字TabBar跑通业务,后续再添加图标。

与之配套的是一个session管理模块和request封装。所有端共用axios不现实,uni.request最适合。封装时把BASE_URL差异化处理:开发环境直连局域网IP,生产环境走线上域名。

// utils/request.js const BASE_URL = import.meta.env.VITE_API_BASE || 'https://api.example.com'; export function request({ url, method = 'GET', data = {}, token = true }) { return new Promise((resolve, reject) => { uni.request({ url: `${BASE_URL}${url}`, method, data, header: { 'Content-Type': 'application/json', 'Authorization': token ? uni.getStorageSync('token') : '' }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data); } else if (res.data.code === 401) { // 登录态失效,跳转登录页 uni.navigateTo({ url: '/pages/login/login' }); reject(res.data); } else { reject(res.data); } }, fail: (err) => reject(err) }); }); }

这里的关键参数是import.meta.env.VITE_API_BASE,因为不同端在请求域名上有限制:小程序端要求域名在后台配置白名单且必须HTTPS,H5端则没有这个限制但会面临跨域问题,App端在Android上默认允许HTTP但iOS从ATS策略上会拦截明文请求。开发期会在manifest.json里临时勾选“iOS不校验https”,上线前必须配置正式证书。

2.3 分类信息平台的后端表设计:category、info与置顶

前端骨架只是壳,真正撑起“58同城赶集网”这种分类信息模式的是数据库模型。核心有四张表:栏目分类表lc_category、信息表lc_info、论坛表lc_forum_post、佣金流水表lc_commission_log。先看前两张的核心结构。

CREATE TABLE `lc_category` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '栏目名,如房产/招聘/二手', `parent_id` int(11) NOT NULL DEFAULT 0 COMMENT '父级ID,0为顶级分类', `icon_url` varchar(255) DEFAULT '' COMMENT '分类图标', `form_config` text COMMENT 'JSON:发布表单的字段配置', `sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`), KEY `idx_parent_sort` (`parent_id`, `sort`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `lc_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `category_id` int(11) NOT NULL COMMENT '所属栏目', `user_id` bigint(20) NOT NULL COMMENT '发布者ID', `title` varchar(120) NOT NULL, `content` text COMMENT '详细描述', `price` decimal(10,2) DEFAULT 0.00 COMMENT '价格/租金等', `images` text COMMENT 'JSON数组,最多9张图', `address` varchar(255) DEFAULT '' COMMENT '地址(冗余展示用)', `lat` decimal(10,7) DEFAULT 0 COMMENT '纬度', `lng` decimal(10,7) DEFAULT 0 COMMENT '经度', `is_top` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0普通 1置顶', `top_expire_at` datetime DEFAULT NULL COMMENT '置顶到期时间', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1展示 0下架', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_category_status_time` (`category_id`, `status`, `create_time`), KEY `idx_user_time` (`user_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两张表配合使用:首页分类导航从lc_category读取,信息列表从lc_infocategory_id分页查询。置顶信息很关键,列表查询时ORDER BY is_top DESC, create_time DESC,即置顶记录优先展示,同级别按发布时间倒序。这里藏着一个性能优化点:如果置顶记录多,全表排序还是会导致扫描范围变大,正确做法是把置顶单独用Redis缓存一个列表,普通信息走MySQL分页,两边合并后返回。不过初期数据量不大,单表同样可以跑得很顺。

栏目表里的form_config字段是整个“自定义栏目”能力的来源。它存一个JSON数组,定义了发布信息时这个栏目需要填写哪些字段,比如房产要填面积、朝向、户型,二手要填成色、原价、转让价。不同栏目用不同的动态表单,而不是为每个栏目单独写前端页面,这就是“自定义栏目”的核心落地方式。下一章把这条链路完整串起来。

3. 信息发布、置顶与佣金:核心业务链路的完整实现

3.1 动态表单:用form_config配置驱动发布页

发布页的逻辑是:先选一级栏目,再选二级栏目,确定category_id后向后端请求栏目的form_config,前端根据JSON渲染表单。form_config结构设计如下:

[ { "key": "title", "label": "标题", "type": "input", "required": true, "maxlength": 50 }, { "key": "price", "label": "价格", "type": "number", "required": false }, { "key": "area", "label": "面积", "type": "input", "required": true, "placeholder": "输入建筑面积" }, { "key": "orientation", "label": "朝向", "type": "picker", "options": ["东", "南", "西", "北", "南北"] } ]

前端用一个循环渲染所有配置项:

<template> <view v-for="field in formConfig" :key="field.key"> <input v-if="field.type === 'input' || field.type === 'number'" v-model="formData[field.key]" :placeholder="field.placeholder || '请输入' + field.label" /> <picker v-else-if="field.type === 'picker'" :range="field.options" @change="onPickerChange(field, $event)" > <view>{{ formData[field.key] || '请选择' + field.label }}</view> </picker> </view> </template>

提交前校验所有required字段,把formData连同category_id,以及后续要讲的位置、图片一起POST到/api/info/create。校验逻辑要和后端校验规则保持完全一致,否则会出现H5能提交、小程序被后端拒绝的奇怪问题。动态表单的价值不只是省代码,它让运营同学在后台新增栏目、调整字段时不需要重新发版。这是这类项目比纯静态页面更“长久”的原因。

3.2 图片上传和定位:三端兼得的API怎么用

信息发布离不开图片,生活服务里房子的户型图、二手物品实物图都是刚需。uni-app提供了uni.chooseImage和一个关键APIuni.compressImage。原生端和Web端的文件对象差异很大,统一的做法是:先选图、然后用tempFilePaths里的路径直接上传,而不是在前端做过多兼容。

async function uploadImages() { const res = await uni.chooseImage({ count: 9, sizeType: ['compressed'] }); const uploadTasks = res.tempFilePaths.map((filePath) => { return new Promise((resolve, reject) => { uni.uploadFile({ url: `${BASE_URL}/api/upload`, filePath, name: 'file', success: (res) => resolve(JSON.parse(res.data)), fail: (err) => reject(err) }); }); }); const results = await Promise.all(uploadTasks); that.formData.images = results.map((r) => r.data.url); }

sizeType: ['compressed']很关键。App端和美团的拍照场景不同,生活服务用户大多是随手拍,原图可能5MB以上,压缩后能显著减少上传时间和服务器带宽消耗。后端收到文件后要再次校验扩展名和大小,我在实际项目中遇到过快图秀秀生成的图片伪造扩展名导致安全问题的情况。

定位用uni.getLocation。注意三端差异:微信小程序需要用户授权且manifest.json里声明requiredPrivateInfos中的getLocation,App端需要在原生打包时申请定位权限,H5端则走浏览器Geolocation。如果H5端用户拒绝授权,定位失败时应该降级为手动输入地址,而不是直接阻断发帖流程。

3.3 置顶逻辑:到期自动下架是怎么实现的

置顶是这类平台的核心变现手段。用户发布信息后是普通状态,付费购买置顶服务后is_top变为1,同时写入top_expire_at。前端购买置顶后调用支付接口,支付回调里更新这两项字段。

UPDATE lc_info SET is_top = 1, top_expire_at = DATE_ADD(NOW(), INTERVAL #{days} DAY) WHERE id = #{infoId}

有人会问:到期后的自动下架要不要写定时任务?看数据量。信息量少时用定时任务扫描也可以,但更稳的做法是在列表查询时动态判断:WHERE is_top = 1 AND top_expire_at > NOW()。这个条件有两个效果:一是过滤掉已过期的置顶记录,二是让它们自然降级为普通信息,不需要额外更新操作。问题在于is_top字段此时语义不对——它表示“购买过置顶服务”,而不代表“当前处于置顶状态”。我习惯保留这个字段做统计,而在查询状态时需要加时间条件去判断当前是否生效,这和标题里“二手房源到期自动落下”的逻辑是同一套。

3.4 佣金结算:从订单到分账流水

佣金机制在标题里和“置顶”并列,指的是平台撮合交易后对商家收取的服务费比例。业务模型是:商家发布服务时设置佣金比例,用户通过平台下单后,订单金额冻结,确认服务完成后按比例自动分账。

数据库里建一张commission_log表:

CREATE TABLE `lc_commission_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_id` varchar(64) NOT NULL COMMENT '订单号', `info_id` bigint(20) NOT NULL, `from_user_id` bigint(20) NOT NULL COMMENT '买家/服务方', `to_user_id` bigint(20) NOT NULL COMMENT '导流对象或服务方', `amount` decimal(10,2) NOT NULL COMMENT '订单金额', `commission_rate` decimal(5,2) NOT NULL DEFAULT 0 COMMENT '佣金比例', `commission_amount` decimal(10,2) NOT NULL COMMENT '佣金金额', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0待结算 1已结算 2已退款', `create_time` datetime NOT NULL, `settle_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

结算时点不是一个绝对标准,常见做法是“确认收货后T+1自动结算”。把佣金计算从订单流程里独立出来,不要在用户下单时直接改余额,而是先生成待结算流水,等定时任务或事件触发后再更新为已结算。这样做的好处是:退款或售后发生时直接标记2已退款,不会出现资金先出去又追回来的尴尬。

4. 社区论坛与自定义栏目:组件化思路下的跨端适配

4.1 帖子列表、回复、点赞的跨端组件封装

论坛模块在这个项目里和第二方分类信息不同,它的互动性更强。列表页要做三件事:分页加载、下拉刷新、滚动位置保持。uni-app中分页写在onReachBottom页面生命周期里,它与Vue的mounted不同,在不同端触发机制有差异,但大多数情况下行为一致。一个帖子列表卡片往往是复用度最高的组件,也最容易出现样式漂移。小程序端盒子模型渲染与H5的Web引擎差异,导致了同一个padding: 10rpx在两端产生几像素的偏差。

<template> <view class="post-card" @click="goDetail(post.id)"> <view class="post-header"> <text class="nickname">{{ post.nickname }}</text> <text class="time">{{ formatTime(post.create_time) }}</text> </view> <view class="post-title">{{ post.title }}</view> <view class="post-content rich-text-content"> <rich-text :nodes="post.content"></rich-text> </view> <view class="post-actions"> <view class="action-item" @click.stop="likePost(post.id)"> <text>{{ post.likeCount }} 赞</text> </view> <view class="action-item" @click.stop="toggleReply(post.id)"> <text>回复</text> </view> </view> </view> </template>

富文本是论坛最需要小心的地方。网络热词里反复出现“h5游戏逆向”“尝试新的跨平台powershell”之类的内容,和论坛直接相关的是:很多用户发帖时从第三方复制粘贴内容,HTML的标签在小程序端不能用v-html渲染,要么用rich-text组件,要么用后端过滤后的JSON节点。rich-text支持常用标签但不支持事件绑定和视频播放,如果要发视频帖子,小程序端用video组件,H5端集成video.js,这是组件化时要提前拆分的版块。

点赞和收藏用防重复请求:

function likePost(postId) { if (likeLoading) return; likeLoading = true; request({ url: '/api/forum/like', method: 'POST', data: { postId } }) .then(() => { uni.showToast({ title: '感谢点赞', icon: 'success' }); }) .finally(() => { likeLoading = false; }); }

4.2 自定义栏目:运营后台配置,前端动态渲染

“自定义栏目”应该是这个标题里最好实现也最容易做差的一块。实现方式是建立column表,包含column_namecolumn_typebind_category_idfields_config字段。以二手交易为例,运营在后台配置“闲置转让”栏目的字段集合,发布页通过fields_config生成表单,列表页根据list_fields决定展示哪些列。

一个实用参数是sort_mode:置顶优先、最新优先、价格升序、价格降序。这个排序参数直接决定列表页的查询语句拼接,前后端要统一定义枚举值,我见过前端传price_asc、后端判断priceAsc导致排序不生效的线上事故。

当栏目数量超过20个时,前端不可能为每个栏目写独立模块。统一用“栏目配置JSON + 通用列表组件”的方案,后期新增“拼车”、“宠物”之类栏目,运营后台配置完即可上线,这个模式就是标题里“自定义栏目等”的实际落地。

4.3 H5、小程序、App的三端运行时差异清单

这是这个项目最容易翻车的地方,提前列一张差异清单:

能力微信小程序H5App
登录态wx.login + code换tokenJWT + localStorage原生SDK/JWT
本地存储uni.setStorageSync 走微信存储localStorageSQLite/文件
定位需在manifest声明需用户授权HTTPS原生权限弹窗
支付微信支付微信H5支付/支付宝原生App支付
页面刷新没有原生刷新机制浏览器刷新即重载原生生命周期
富文本rich-text组件v-htmlrich-text(App端是webview渲染,支持度不同)

登录态差异影响最大。小程序里uni.setStorageSync存的token在App端也能用,但App端如果同时打包了iOS和Android,两个端的存储是隔离的。H5端用户浏览器清缓存会导致token丢失,App端则相对稳定。建议所有端都用自己的机制,不要试图把token同步到localStorageuni.setStorageSync两套方案里去。

论坛里的“房产招聘二手”这三个核心栏目往往被设计成自定义栏目的预置实例,而不是单独写死页面。列表页和详情页做成一个通用页面,通过路由参数categoryId区分加载哪一类数据,配不同的form_config和列表字段,这是在工程上“兼容58同城赶集网”最经济的方式。

5. 三端联调与发布:几个必须提前避开的坑

先说调试。H5端最轻松,浏览器DevTools直接断点。小程序端用微信开发者工具,但经常遇到“当前不会命中断点”的提示。这个问题的多数原因是代码经过了压缩混淆或sourcemap没有正确关联。解决方法:开发者工具里把“ES6转ES5”暂时关掉,编译模式选“开发版”,如果还不行就清理编译缓存重新编译。另一种情况是想调试onLaunch里的逻辑,但断点打在回调函数内部,异步代码的栈已经被回收,这个断点自然命中不了。

App端调试用HBuilderX内置的“运行到手机或模拟器”。要注意真机调试时,手机和电脑必须连同一个局域网,否则HBuilderX会卡在“正在同步文件”阶段。App端的网络请求如果出现“网络请求失败”,十有八九是域名没有在manifest.json的“App SDK配置”里声明或没有配置合法域名。H5端跨域则在服务端配置CORS:Access-Control-Allow-Origin: 你的域名

小程序端有个更隐蔽的坑:域名必须在微信公众平台的“开发管理-服务器域名”里加白名单,否则request请求直接fail;在真机上需要刷新缓存才生效。开发期可以在开发者工具右上角勾选“不校验合法域名”,但真机预览会强制校验。

发布流程上,H5打包到服务器后,微信内访问如果要用定位,需要单独接入微信公众号JS-SDK,用wx.getLocation而不是uni.getLocation。这里有一个常见错误:H5页面在微信内置浏览器中直接调用HTML5 Geolocation,会弹出许可但只有部分安卓机型和微信版本支持,低版本会静默失败。封装一个带降级策略的位置获取方法:

function getH5Location() { return new Promise((resolve, reject) => { if (isWeChatBrowser()) { // 通过后台接口注入wx.config,然后调用wx.getLocation wx.ready(() => { wx.getLocation({ type: 'gcj02', success: (res) => resolve({ lat: res.latitude, lng: res.longitude }), fail: reject }); }); } else { uni.getLocation({ success: resolve, fail: reject }); } }); }

发布App时,iOS和Android的证书、包名、图标、启动图都必须在打包前准备好。iOS还涉及Apple开发者账号的Bundle Identifier证书Profile的匹配问题,任何一个不匹配都会被App Store Connect拒绝。常见做法是HBuilderX云打包生成ipa或apk,但这需要DCloud账号的打包次数,团队内要提前规划。

最后一个技巧:三端共用一套接口,但线上环境建议给不同端的请求头加一个X-Client-Type: mp-weixin / h5 / app。后端可以根据端类型返回不同粒度数据,比如小程序端图片尺寸压缩、H5端返回完整HTML富文本、App端返回JSONNode。这个头能帮助你快速排查线上问题——最怕的是用户说“App打不开某个页面”,结果你复现不出来,最后发现是H5入口转发到了App没覆盖到的路由。

整个项目的开发顺序建议是:先做H5端跑通全部业务逻辑,因为调试成本最低;再编译到小程序端适配差异;最后打包App壳——这样能把跨端问题拆分到不同阶段,避免三端同步瞎忙。

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

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

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

立即咨询