简介:仿青藤之恋的《欧几里》社交交友项目提供微信小程序、手机客户端与网页端三端通用源码,面向社交产品开发者和全栈学习者,围绕双向喜欢解锁聊天、即时通讯与高学历人群交友场景,覆盖从用户认证、实时消息传送到多端同步的完整链路。压缩包共512个文件,以252个JavaScript脚本和146个Vue组件为主体,另有80张图片、17个样式表以及若干配置文件,整体约2.01MB,目录清晰,便于阅读和二次开发。目前已有682人学习下载。项目中内置表单校验、日历选择、数据解析、网络状态检测等通用模块,并通过package.json集中声明运行时依赖与构建脚本;结合聊天室的匹配解锁逻辑,可以直观理解社交产品中匹配、消息收发与状态管理的实现方式,适合作为社交类小程序或跨端应用的起步参考和扩展基底。
1. 仿恋爱交友软件里的“三端通用”,不是把界面搬三次的问题
当你点开标题看到“社交交友软件 + 即时通讯 + 微信小程序 / App / H5 三端通用”这一串关键词时,第一反应可能是“我要做三个客户端”。我见过不少开发者都在这个点上翻车:App 一套代码、小程序一套、H5 又一套,最后匹配逻辑和聊天消息在三个端上各写各的,服务端接口也被迫塞满了分支判断。这类恋爱交友产品真正的难点不是“仿界面”,而是账号体系、推荐逻辑和聊天长连接在一套代码里同时跑通,还要应付微信小程序的审核规则、H5 切后台就断线、App 消息到达率这几个完全不同的问题。
这个方向适合谁?独立开发者、三五人小团队,或者公司想低成本验证一个恋爱交友玩法。三端通用如果选对技术底座,能把开发成本压到同类项目的三分之一。但这篇文章不会讲“抄一遍 UI”,而是从工程选型、匹配会话、IM 长连接,到真正会卡住你的审核和消息时序问题,一步步讲清楚怎么落地上线。新手能照着搭出骨架,熟手可以重点看第 5 章的坑。
2. 三端通用的技术底座:uni-app 工程骨架与三端编译的最小命令
2.1 跨端框架选型:为什么先用 uni-app
先讨论一个核心问题:选哪个框架来承载三端。行业里常见的方案有三种:原生三套、uniapp、Taro。原生三套的开发成本很明显是三个前端加一个后端,但即时通讯的 WebSocket API 在三个端上差异很大,工作量不仅要乘三,聊天协议和消息回调还得分别维护。如果你只是做产品验证,这个成本基本撑不到上线。Taro 在 React 技术栈里很好用,编译到小程序很成熟,但它的 App 端能力依赖 React Native 适配,遇到相机、相册、推送这类原生插件时,调试成本比 uni-app 高。
我通常在这个场景下选 uni-app,原因很直接:它把 H5、微信小程序、App 三条编译链路收拢成一套 Vue 代码,而且 App 端走的是 WebView 加原生 bridge 的能力,做聊天页这种强交互页面够用。对恋爱交友产品来说,最重的两个模块是推荐卡片和聊天窗口,它们本质上都是列表加模态,crash 概率和性能损耗都远低于原生需求。Taro 也能做,但如果你团队里只有 Vue 熟练的人,uni-app 的上手速度是最快的。
| 方案 | 一套代码覆盖三端 | IM 长连接适配 | 团队要求 | 适合阶段 |
|---|---|---|---|---|
| 三套原生 | 否 | 好但不通用 | 至少三个端熟练 | 产品稳定后重做 |
| Taro | 是 | 需自己封装 | React 技术栈 | React 团队选择 |
| uni-app | 是 | 需自己封装但跨端抹平 | Vue 技术栈 | 项目启动到中期都合适 |
还有一种常见的疑问:uni-app 是不是只适合做表单和展示?不是。即时通讯相关的 WebSocket 在微信小程序里走uni.connectSocket,H5 里走浏览器原生 WebSocket,App 里走 plus bridge 封装,uni-app 把这些 API 统一了,底层虽然还是各端的 socket,但你只需要写一份业务逻辑。这点在 4.2 节会给出封装代码。
2.2 工程骨架与三端编译的最小命令
很多教程喜欢直接贴一个“完整项目”,然后让你下载。我更建议从官方 CLI 模板开始,因为后续升级依赖时你才知道自己改过什么。下面这套命令是我每次做这类项目时的标准起步动作,适合在微信小程序、H5、App 三个目标都在同一个仓库里维护的情况。
# 用 Vue3 + Vite 的 uni-app 模板初始化项目 npx degit dcloudio/uni-preset-vue#vite-ts love-match-app cd love-match-app npm install # H5 预览:本地会起一个 Vite 服务 npm run dev:h5 # 编译到微信小程序,产物在 dist/dev/mp-weixin npm run dev:mp-weixin # 生产打包:H5 产物到 dist/build/h5,小程序到 dist/build/mp-weixin npm run build:h5 npm run build:mp-weixin这里要解释几个参数:vite-ts表示用 TypeScript 模板,建议保留,因为后续消息数据结构、推荐接口返回值都会用类型定义来约束,三个端调用同一个类型定义能避免字段名不一致的玄学问题。dev:mp-weixin只是编译产物,真正的运行预览要打开微信开发者工具,导入上面注释里的dist/dev/mp-weixin目录。App 端打包不在命令行里,在 HBuilderX 里打开同一个工程,选择“运行到手机或模拟器”即可,因为 App 打包涉及证书和原生 SDK 配置,图形界面比命令行直观得多。
初始目录我会按照这样一个结构来拆,避免页面和工具函数混在一起:
src/ ├── pages/ │ ├── login/login.vue │ ├── home/home.vue # 推荐卡片流 │ ├── chat/chat.vue # 聊天窗口 │ └── profile/profile.vue # 个人资料编辑 ├── utils/ │ ├── ws.js # 长连接统一封装 │ ├── request.js # HTTP 接口封装 │ └── auth.js # 登录态管理 ├── types/ │ └── message.ts # 消息、会话、用户类型定义 └── api/ ├── recommend.ts └── chat.tsroutes 由 pages.json 管理,这个文件是小程序端路由和页面样式的唯一来源。App 和 H5 的页面跳转会沿用同一份配置,所以新增一个聊天页面时只需要改 pages.json,不用在三个端分别配路由。
3. 交友匹配与聊天入口:从推荐卡片到建立会话的数据流
3.1 匹配接口与推荐卡片的数据流
恋爱交友产品最先被人体验的就是推荐卡片。仿写法上常见的是左右滑、点红心、超级喜欢几种动作。后端推荐接口的关键不是在 SQL 上炫技,而是“别把用户已经划过的人再捞出来”,同时让活跃度高的用户排在前面。下面是我常用的一个推荐接口伪代码,用条件查询而不是内存过滤,因为线上用户量上千之后,内存过滤会明显变慢。
async function getRecommendations(uid: number, cursor: number, limit = 10) { const user = await userRepo.get(uid); const rows = await db.query( `SELECT u.id, u.nickname, u.avatar, u.bio, u.age FROM users u WHERE u.gender = $1 AND u.age BETWEEN $2 AND $3 AND u.id NOT IN ( SELECT target_id FROM likes WHERE uid = $4 ) AND u.id > $5 ORDER BY u.last_online_at DESC, u.id ASC LIMIT $6`, [oppositeGender(user.gender), user.age - 5, user.age + 5, uid, cursor, limit] ); return rows; }这个接口里cursor是上一页返回的最大用户 ID,用来做增量分页;NOT IN子查询把划过的人排除掉,把已喜欢、已跳过都写进同一张 likes 表,走一个统一的动作记录。last_online_at排序能让最近活跃的人先出现,这是刺激互动的关键。
不过分页有个反直觉的细节:用户滑到第 3 页的时候,第 1 页里已经有人“喜欢了你”,如果每次都按uid > cursor分页,新进来的喜欢可能永远排不上。所以在线上我一般会加一层“回捞”逻辑:每次返回的 10 条里,留 2-3 条给最近对你表达过喜欢但还没被你处理的人。这样匹配意愿更高,用户也更容易感受到即时反馈。
前端卡片的交互在 uni-app 里要小心 touch 事件。很多新手直接用@touchmove做跟手滑动,在小程序里会频繁触发 setData,造成掉帧。常见做法是监听@touchstart、@touchmove、@touchend,计算位移后仅更新卡片的transform: translate,最后松手时才判断是否满足滑动阈值。我一般还会预先加载下一张推荐卡的头像图片,这样滑完不出现白屏。
<template> <view class="card" :style="{ transform: `translate(${offsetX}px, 0)` }" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" > <image :src="currentProfile.avatar" mode="aspectFill" /> <text>{{ currentProfile.nickname }}</text> </view> </template>关键参数是offsetX,它只表示当前手指移动的位置;距离阈值我按屏幕宽度的 35% 来定。小于阈值的回弹,大于阈值的触发喜欢或跳过,超过阈值后还要做一小段惯性动画。这样手势判断完全在前端完成,后端只在动作确定后批量上报匹配结果,能减少对推荐接口的无效请求。
3.2 聊天入口:如何让“互相喜欢”建立会话
聊天不是点开就有的。在三端通用产品里,最容易做错的是“会话创建时机”。如果在用户第一次发消息时才创建会话,服务端的未读数逻辑会变得很绕;如果一方点了喜欢就创建会话,又会生成大量空会话。正确做法是:双方互相喜欢的那一刻创建会话,并且保证并发情况下不会重复建。
async function handleLike(uid: number, targetId: number) { await db.query( `INSERT INTO likes (uid, target_id, action, created_at) VALUES ($1, $2, 'like', now()) ON CONFLICT (uid, target_id) DO NOTHING`, [uid, targetId] ); const matched = await db.query( `SELECT 1 FROM likes WHERE uid = $1 AND target_id = $2 AND action = 'like'`, [targetId, uid] ); if (matched) { const cid = await chatRepo.createConversation(uid, targetId); notifyUser(targetId, { event: 'matched', conversationId: cid }); return { matched: true, conversationId: cid }; } return { matched: false }; }这段代码里ON CONFLICT DO NOTHING是防重复插入的关键,likes 表上要有(uid, target_id)的唯一索引,否则两个人同一毫秒互相点喜欢,会插入两条记录,二次查询就可能判断不出相互喜欢。createConversation里也要用(uid, targetId)的唯一约束,先查后插,避免多端同时触发建立两个会话。实践中的做法是建一个conversation_key字段,值为min(uid,targetId)_max(uid,targetId),保证任何顺序查询都命中同一条。
会话建好之后,聊天入口才能用。前端拿到matched事件后,无论是 App 还是小程序,都要跳转到同一个chat.vue页面,并通过conversationId拉取历史消息列表。这里要注意:通知对方“你们匹配了”不能只在当前端做 toast,要让对方在切换回 App 或小程序时也能看到。所以推荐方案是发给对方一个服务端事件,对方的长连接收到事件后自动刷新会话列表,刷新时机则统一放在onShow生命周期里。
4. 即时通讯在三端的落地:WebSocket 封装、心跳与消息可靠
4.1 长连接选型:自建 WebSocket 还是第三方 IM
做聊天功能,第一道选择题是“用自建 WebSocket 还是第三方 IM 服务”。很多教程默认你直接加一个第三方 SDK,但恋爱交友这类产品有个特点:互动高峰在晚上,白天流量不高,第三方 IM 的免费配额可能够用,一旦上线后做活动又容易被限流。自建方案的成本主要是 WebSocket 网关和消息存储,如果团队没有人维护高并发,风险反而更高。
| 对比项 | 自建 WebSocket | 第三方 IM 服务 |
|---|---|---|
| 前期成本 | 低,只有服务端连接 | 接入快,但按日活付费 |
| 消息可靠性 | 自己实现 ack 和重连 | 服务商已处理 |
| 数据安全 | 消息完全在自己手里 | 依赖服务商的合规承诺 |
| 高并发扩展 | 要自己设计网关集群 | 服务商兜底 |
| 典型适合阶段 | 日活 5 万以下的验证期 | 已跑通模式、规模化阶段 |
我的经验是:项目从 0 到 1 先自建 WebSocket,功能跑通了再切第三方。原因很简单,自建能让你彻底理解消息从哪里进来、往哪里去、失败后怎么补发,这些知识在调试三端问题时非常有用。但注意,自建方案里客户端断线重连的细节必须提前做好,否则第三天就会出现大批“消息没收到”的投诉。
4.2 一套客户端长连接封装适配三端
uni-app 的uni.connectSocket抹平了三端的连接创建差异,但业务层的心跳、重连、离线补偿还是得自己写。下面是我项目里utils/ws.js的简化版,这套封装直接在 Vue 组件之外使用,聊天页、会话列表页共用同一个连接,避免每个页面各建一条 socket。
let socket = null; let heartbeatTimer = null; let reconnectTimer = null; let reconnectCount = 0; let pendingMessages = []; const HEARTBEAT_INTERVAL = 30000; const RECONNECT_MAX = 5; export function connectWs(token, handlers) { const url = `wss://api.example.com/ws?token=${token}`; socket = uni.connectSocket({ url }); socket.onOpen(() => { reconnectCount = 0; startHeartbeat(); flushPending(); }); socket.onMessage((msg) => { // 小程序里 msg.data 是字符串,App 端可能是 ArrayBuffer const payload = typeof msg.data === 'string' ? JSON.parse(msg.data) : JSON.parse(new TextDecoder().decode(msg.data)); handlers.onMessage(payload); }); socket.onClose(() => { stopHeartbeat(); scheduleReconnect(); }); socket.onError(() => socket.close()); } function scheduleReconnect() { if (reconnectCount >= RECONNECT_MAX) return; const delay = reconnectCount === 0 ? 1000 : 2000 * reconnectCount; reconnectCount += 1; reconnectTimer = setTimeout(() => connectWs(getToken(), this.handlers), delay); }这段代码里有两个参数要特意说明。第一,HEARTBEAT_INTERVAL设置为 30 秒,这是多数服务端空闲连接的常见阈值。低于 20 秒会频繁产生无意义心跳,大于 60 秒又容易被网关在中间掐断。心跳包通常是{"type":"ping"},服务端回{"type":"pong"};如果连续两次心跳没收到 pong,客户端要主动 close 并触发重连。第二,RECONNECT_MAX设成 5 是避免服务端宕机时客户端无限重连,损耗手机电量。达到上限后应该置offline标志,并在前台回到页面时重新调用connectWs。
4.3 消息结构与消息可靠排序
聊天消息的字段设计直接决定三端能不能正确渲染。我见过最省事的做法是把消息存成一个 JSON 字符串塞进数据库,但这样时间一长,客户端处理后端落库消息时就全靠猜字段类型。建议从一开始就把消息结构固定成下面这样:
{ "msgId": "a1b2c3d4-...", "conversationId": 1024, "fromUid": 1001, "toUid": 1002, "type": "text", "content": "周末有空吗", "timestamp": 1700000000, "clientSeq": 23, "status": "sent" }msgId是客户端生成的全局限定 UUID,服务端直接用它做主键去重;clientSeq是发送方本地会话内的自增序号,用于消息排序。为什么要两个字段?因为msgId负责幂等,而clientSeq能帮你解决“同一秒发了两条消息顺序反了”的体验问题。服务端收到两条消息时,不是直接落库,而是按fromUid + conversationId维度维护lastSeq,重复消息直接丢弃;客户端收到消息后对比本地clientSeq,如果是旧的就丢弃。
这里的核心坑是:客户端不能拿服务端返回的timestamp排序,因为分布式服务端的当前时间可能不一致,而且后端处理顺序不等于用户发送顺序。排序一律以clientSeq为准,timestamp只用于展示“几点几分”。
5. 三端聊天避坑:审核、断线与消息时序的三道坎
5.1 小程序审核被拒:问题通常出在“随机陌生人聊天”
现象:小程序提审后很快被驳回,平台给出的原因是“你的小程序涉及社交-交友类目,需要提供平台要求的资质材料”,而且第一次提交近乎必被拒。
原因:微信小程序对陌生人社交的管控逻辑很严格。你的标题写着“仿交友软件”,但审核员看到的不是代码,而是首次打开后能不能立刻进入“匹配陌生人的聊天”。只要首页有类似“随机匹配”“附近的人”入口,就会按陌生社交来审查。
解决:我的做法是把“用户之间互相喜欢建立会话后再聊天”作为小程序端的唯一入口,并且在小程序版本里隐藏“随机匹配推荐”的自动弹窗,用户至少要完成注册、设置资料、浏览卡片、主动表达喜欢,才能和对方建立会话。H5 和 App 可以保留更开放的匹配玩法,小程序端先求稳。审核被驳回不可怕,按平台的拒绝理由把入口藏起来,重新提审通常能过。
5.2 H5 切后台收不到消息:这不是网络问题,是页面生命周期问题
现象:用户在用 H5 版聊天,切到微信聊天窗口再返回,发现刚才对方发的两三条消息一直没出现,直到手动下拉才刷新出来。
原因:浏览器在页面切到后台时会冻结 WebSocket,或者资源受限导致连接断开。尤其 iOS 的 Safari 对后台页面限制很严格,H5 几乎没有真正意义上的“后台保活”。
解决:不要把 H5 当成 App 来对待。我一般会在chat.vue和会话列表页的onShow生命周期里拉一次增量消息,以本地最新一条clientSeq作为参数请求服务端未读消息接口。这个策略同时解决了三个问题:首次进入页面时的历史加载、从后台切回时的增量补发、以及长连接刚建立时未读消息的兜底。
// chat.vue 的 onShow 里执行 const latestSeq = chatStore.getLatestSeq(this.conversationId); this.fetchUnread(this.conversationId, latestSeq);这个fetchUnread返回值同样是标准消息结构数组,前端把它们追加到消息列表时,继续按clientSeq排序和去重。这样即使 WebSocket 断了十分钟,用户回到页面也不会发现消息丢失,只是感知上比实时晚了一点。对小团队产品来说,这个体验可接受。
5.3 重连后消息重复与乱序:clientSeq 的另一种用法
现象:用户在弱网下发送一条消息,App 显示已发送,但对方没收到。用户又点了一次发送,结果对方收到了两条相同内容,而且消息顺序变成了“后发先至”。
原因:弱网下第一条请求其实到了服务端,但客户端没有等到 ack 就超时了。用户重发时,服务端把两条消息都当成新消息落库,又把两条都推给对方,于是重复了。而乱序则是因为两条消息落库的时间相同,数据库自增主键不稳定,客户端按这个主键排序就会出错。
解决:第一步,客户端发送每条消息时生成msgId和clientSeq,重发时保持同一条消息的msgId和clientSeq不变。第二步,服务端收到消息后先查msgId是否存在,存在就直接返回旧消息的确认 ack,不再重复落库和广播。第三步,前端展示消息时永远按clientSeq升序排列,不信任后端的插入时间。这套组合拳能稳定解决弱网重发和并发排序问题,也是我所有聊天相关项目里收益最高的一次改动。
5.4 卡片列表和聊天记录的滚动性能
现象:用户快速滑动推荐卡片时图片半天加载不出来,聊天页面滚动 30 条消息后明显掉帧,尤其在微信小程序里更明显。
原因:推荐卡片页在一个<view>里同时渲染了 10 张高分辨率头像,图片没有懒加载;聊天消息列表把整个消息历史一次性渲染成节点,小程序 setData 更新频繁。
解决:推荐卡片只在当前卡片和下一张卡片的v-if中渲染image,后面的卡片仅渲染一个占位背景。聊天页面用分页加载,每次只渲染最近 50 条,向上滚动时再拉取更早的消息,并用<scroll-view>的scroll-with-animation控制跳到底部。头像统一走?imageView2/0/w/300/h/300这类图片裁剪参数,减少小程序包的图片解码压力。这些改动没有算法门槛,但能让三端的帧率感知完全不同。
6. 出一份三端联调验收清单:检查这 6 个点再上线
很多项目失败不是功能没做出来,而是“每个端单独看着都正常,联起来就出问题”。我整理了一份固定的联调清单,每次发版前按这个顺序跑一遍,能省下大量线上排障时间。
| 检查点 | 操作步骤 | 预期结果 |
|---|---|---|
| 用户注册登录 | H5 注册,App 登录同一账号 | 两边资料、会话完全一致 |
| 互相喜欢建会话 | H5 里喜欢一个账号,App 里也喜欢另一个账号 | 两边同时出现新的会话卡片 |
| 长连接连通性 | 打开三端的网络面板,查看 WebSocket 状态 | 都是 connected,无反复重连 |
| 消息双向送达 | App 发给 H5 一条消息,H5 发给小程序一条消息 | 任意端都收到,顺序与发送一致 |
| 后台切回补拉 | H5 切后台 1 分钟再回来 | 缺的消息在 onShow 后自动补出 |
| 弱网重发 | 用开发者工具断网,连发 2 条消息,恢复网络 | 服务端只有 2 条,客户端无重复展示 |
联调时最常用的一招是“自检消息”:在聊天窗口的调试模式里放一个按钮,发送一条type: 'ping'的特殊消息,对端收到后自动回一条type: 'pong'。这样能快速区分问题出在 WebSocket 网关、消息存储还是前端渲染,而不是靠肉眼盯着两个屏幕猜。
function sendSelfCheckMessage() { const checkMsg = { msgId: `check-${Date.now()}`, conversationId: currentConversationId, type: 'ping', content: 'connectivity-check', timestamp: Date.now(), clientSeq: 0 }; send(checkMsg); setTimeout(() => { if (!receivedPong) { console.log('自检超时:请检查 WebSocket 连接状态'); } }, 5000); }用自检消息做联调时,注意clientSeq设为 0,避免干扰正常消息排序。如果 5 秒内收到 pong,说明连接层面没问题;如果 pong 回来了但聊天页面没有本地展示这条消息,说明渲染逻辑有 bug;如果 pong 都没收到,重点查服务端的消息路由和广播逻辑。
我没有把三端通用想成“一份代码到处发布”的银弹,它更像是一套公共地基:账号、匹配、会话、消息都收敛在统一协议里,端只是表现层。唯一让我印象深刻的教训是,小程序的审核比技术更难预测,我最后靠“先建立关系,再开放聊天”的产品规则才把审核这关走通。在做你的产品之前,先把这一条写在需求文档里,能少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取