大家好,本文要介绍的是我在去年初开发的uniapp聊天功能。
Uniapp多端的适配、体验让大家都非常头痛,我这篇文章主要介绍的就是长列表聊天如何进行多端适配及优化。
本文不但解决了聊天室的微信小程序、APP、多版本浏览器下的兼容问题,且聊天的操作体验极其丝滑~
微信小程序效果图: 注思路:这里的文字发送有些许延迟,是因为我在发送前将发送的内容阻塞进行了违规校验;图片会优先展示本地url,校验、上传完成后替换为线上url且改变消息状态

创建nvue页面
创建页面有一个优化点,在软件开发聊天模块通常是一个体积比较大、组件内容比较多的模块,所以我在微信小程序中进行了分包处理。
我在配置里禁止了页面的下拉刷新,因为消息列表和输入区域铺满屏幕,不需要下拉刷新。
![7KLC%RQ0EDO{ <math xmlns="http://www.w3.org/1998/Math/MathML"> ' Z `Z </math>'ZBCD`QY.png
H5及app
首先是H5和APP的页面配置,因为不支持分包加载,所以配置在pages属性内。
javascript
// pages.json
{
"pages": [
// #ifndef MP-WEIXIN
{
"path": "packageA/chat/index",
"style": {
"navigationBarTitleText": "采购消息",
"enablePullDownRefresh": false
}
}
// #endif
]
}
微信小程序
微信小程序分包加载。
preloadRule属性配置了packageA资源预加载的页面。
javascript
// pages.json
{
"subPackages": [{
"root": "packageA",
"pages": [
// #ifdef MP-WEIXIN
{
"path": "chat/index",
"style": {
"navigationBarTitleText": "采购消息",
"enablePullDownRefresh": false
}
}
// #endif
]
}],
"preloadRule": {
"pages/index/chat": {
"network": "all",
"packages": ["packageA"]
}
}
}
聊天室首页的布局思路
packageA/chat/index
template部分
page为整个页面区域;
message__list为信息列表区域;> custom-list是整个聊天列表做了抽离,因为内部有兼容代码,比较多。
chatbar__wrap为输入区域;
vue
<view class="page">
<view class="message__list" @touchmove="onListTouchMove" @click="onListClick">
<custom-list class="custom-list" ref="customLister"></custom-list>
</view>
<view class="chatbar__wrap">
<view class="chatbar__input-box">
<textarea
class="chatbar__input"
v-model="data.inputText"
:focus="data.focus"
@click="onClickInput"
@focus="onFocus"
@keyboardheightchange="onKeyboardHeightChange"
:enableNative="false"
:auto-height="true"
:show-count="false"
:adjust-position="false"
:disable-default-padding="true"
:confirm-hold="true"
:hold-keyboard="true"
:show-confirm-bar="false"
:style="getStyle"
></textarea>
</view>
<image
class="chatbar__icon-box icon_expand"
src="/static/images/chat/expand.png"
v-if="!data.inputText && !data.showExpands"
@click="onExpandIcon"
></image>
<image
class="chatbar__icon-box icon_expand"
src="/static/images/chat/keyboard.png"
v-if="!data.inputText && data.showExpands"
@click="onKeyboardIcon"
></image>
<view class="chatbar__icon-box icon_send" v-if="data.inputText" @click="onSendText">
<text class="text">发送</text>
</view>
</view>
<view class="foot-expand" v-if="data.showExpands">
<view class="content">
<view
class="expand-item"
v-for="(item, index) in CHAT_EXPAND"
:key="item.name"
@click="onExpandItem(index)"
>
<view class="expand-icon">
<image class="img" :src="item.iconPath" mode="widthFix" />
</view>
<view class="expand-name">{{ item.name }}</view>
</view>
</view>
</view>
<view :style="{ height: data.keyHeight }"></view>
</view>
style部分
样式区域分别对各端的特殊情况做了兼容处理,有些代码看起来是多余的,实则每一行都不可缺少,nvue对样式的兼容不是很完美。
sass
<style lang="scss" scoped>
/* #ifndef APP-NVUE */
view {
display: flex;
flex-direction: column;
box-sizing: border-box;
}
page {
overflow-anchor: auto;
}
/* #endif */
.page {
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
/* #ifdef MP-WEIXIN */
height: 100vh;
/* #endif */
/* #ifdef H5 */
height: calc(100vh - var(--window-top));
/* #endif */
}
.message__list {
flex: 1;
/* #ifdef MP-WEIXIN || H5 */
overflow-y: scroll;
/* #endif */
background-color: transparent;
}
.custom-list {
flex: 1;
height: 100%;
}
.chatbar__wrap {
/* #ifndef APP-NVUE */
width: 100%;
display: flex;
/* #endif */
padding: 6px 0px;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
background: #f8f8f8;
}
.chatbar__input-box {
/* #ifndef APP-NVUE */
width: 100%;
display: flex;
/* #endif */
flex-direction: row;
flex: 1;
margin-left: 30rpx;
position: relative;
}
.chatbar__input {
/* #ifndef APP-NVUE || MP-ALIPAY || MP-QQ */
width: 100%;
min-height: 32rpx;
box-sizing: content-box;
padding: 20rpx;
line-height: 32rpx;
/* #endif */
/* #ifdef MP-ALIPAY || MP-QQ */
line-height: 1;
min-height: 72rpx;
/* #endif */
/* #ifdef APP-NVUE */
/* min-height 写入css有警告 */
// min-height: 72rpx;
flex: 1;
height: 72rpx;
padding: 16rpx 20rpx;
/* #endif */
border-radius: 8rpx;
font-size: 32rpx;
background: #fff;
}
.chatbar__icon-box {
height: 72rpx;
/* #ifndef APP-NVUE */
display: flex;
flex-shrink: 0;
/* #endif */
align-items: center;
justify-content: center;
/* #ifdef H5 */
cursor: pointer;
/* #endif */
}
.chatbar__icon-box:active {
opacity: 0.5;
}
.icon_expand {
width: 72rpx;
height: 72rpx;
margin: 0rpx 30rpx 0rpx 15rpx;
}
.icon_send {
padding: 0rpx 30rpx 0rpx 20rpx;
.text {
color: #465cff;
font-size: 30rpx;
}
}
//拓展内容区域
.foot-expand {
height: 270px;
padding: 15px;
background-color: #ededed;
.content {
display: flex;
flex-direction: row;
flex-wrap: wrap;
position: relative;
.expand-item {
width: 90px;
height: 90px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.expand-icon {
width: 60px;
height: 60px;
border-radius: 6px;
background-color: #ffffff;
border: 1px solid #ffffff;
display: flex;
justify-content: center;
align-items: center;
.img {
width: 30px;
height: 30px;
}
}
.expand-hover {
background-color: #f2f2f2;
}
.expand-name {
font-size: 12px;
margin-top: 4px;
}
}
}
}
</style>
script部分
该部分我删除了消息发送、socket连接部分的逻辑代码,这个需要按照业务不同进行调整;
重要思路:
1、设置输入框adjust-position="false",这样键盘不会顶起页面,然后监听键盘高度变化,在输入区域下方插入相同的高度(方法很多下padding也行)。
2、禁止textarea的内边距disable-default-padding="false"。
3、textarea区域的属性、样式很重要,要保证输入框自动高度且可以上下滑动。
4、customLister.value.scrollToView() 该方法是列表组件滚动到指定id元素的方法,参数为空是滚动到底部。
javascript
<script setup>
import { ref, reactive, computed, nextTick } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import utils from '@/utils/index.js';
import customList from './custom-list.vue';
const customLister = ref();
const uploader = ref();
let merchantId = '';
// 页面加载
onLoad((options) => {
merchantId = options.merchantId;
if (options.chatName) {
uni.setNavigationBarTitle({
title: options.chatName,
});
}
});
// 页面数据
const data = reactive({
keyHeight: '0px',
inputText: '',
// #ifndef APP-NVUE
focus: false,
// #endif
// #ifdef APP-NVUE
focus: true,
// #endif
showExpands: false,
});
// 上传组件的配置
const uploadOptions = reactive({
url: 'https://up.qiniup.com',
name: 'file',
formData: {
token: '',
key: '',
},
});
// 表单样式兼容
const getStyle = computed(() => {
// #ifdef APP-NVUE
return `min-height:72rpx;`;
// #endif
return '';
});
// 键盘高度变化
const onKeyboardHeightChange = (e) => {
console.log('键盘高度变化', e);
data.keyHeight = e.detail.height + 'px';
customLister.value.scrollToView();
};
// 点击拓展图标
const onExpandIcon = () => {
// #ifndef APP-NVUE
data.focus = false;
// #endif
uni.hideKeyboard();
data.showExpands = true;
};
// 点击键盘图标
const onKeyboardIcon = () => {
// #ifndef APP-NVUE
data.focus = true;
// #endif
uni.showKeyboard();
data.showExpands = false;
};
// 表单聚焦
const onFocus = () => {
data.showExpands = false;
};
// 点击表单
const onClickInput = () => {
// #ifdef APP-NVUE
data.showExpands = false;
// #endif
};
// 触摸列表滑动
const onListTouchMove = () => {
// #ifndef APP-NVUE
data.focus = false;
// #endif
uni.hideKeyboard();
data.showExpands = false;
};
// 点击消息列表区域
const onListClick = () => {
// #ifdef H5
data.showExpands = false;
// #endif
};
// 暴露滚动到底部函数
utils.getPage().scrollToView = (a, b) => customLister.value.scrollToView(a, b);
let sendTexting = false
// 发送文本信息
const onSendText = async () => {
};
// 点击拓展功能
const onExpandItem = (index) => {
index === 0 && onEpandImage('album');
index === 1 && onEpandImage('camera');
index === 2 && onExpandFile();
};
// 选择图片发送
const onEpandImage = (type) => {
};
// 选择文件发送
const onExpandFile = () => {
};
</script>
消息列表组件
关键点:
1、APP端使用nvue中的list组件进行原生渲染,并且开启render-reverse="true"颠倒渲染;因为list组件无法监听滚动到顶部的事件,所以在顶部插入一个元素,监听apper<出现到视口区域>事件加载更多。
2、非APP端使用scroll-view进行渲染,通过scrolltoupper监听加载更多。
3、要注意app端浏览器及微信小程序加载更多时页面抖动重绘问题。这里我对loadmore进行了单独封装,apple设备下点击加载更多才进行加载。
4、发送消息的思路:优先创建本地消息并设置状态为发送状态,发送成功设置为未读<如果对方也在聊天室收到消息会发送一条已读通知,会自动变为已读> 发送失败设置为失败状态。
5、message组件下渲染不同消息类型。
vue
<template>
<view class="custom-list" id="custom-list">
<!-- #ifdef APP-NVUE -->
<list class="list" :bounce="false" :render-reverse="true" @scroll="onAppScroll">
<cell v-if="data.rendered" @appear="onLoadMore"><loadmore :isComplete="data.isComplete" /></cell>
<cell :keep-scroll-position="true" v-for="(item, index) in chatStore.msgList" :key="item.msgId">
<message :message="item" v-if="item.convention"></message>
</cell>
<!-- 解决APP端的滚动锚定问题,在最后一个cell 设置 keep-scroll-position 和 render-reverse-position -->
<cell class="im-list-last-item" :keep-scroll-position="true" :render-reverse-position="true" ref="lastItemRef"></cell>
</list>
<!-- #endif -->
<!-- #ifndef APP-NVUE -->
<scroll-view
class="scroll-view"
@scroll="scroll"
@scrolltoupper="onLoadMore"
:scroll-into-view="data.scrollIntoView"
:scroll-with-animation="data.animated"
:upper-threshold="30"
:bounces="false"
:enhanced="true"
:fast-deceleration="true"
:scroll-anchoring="true"
:enable-passive="true"
:scroll-y="true"
:enable-flex="true"
>
<loadmore :isComplete="data.isComplete" :isApple="isApple" @load="onSafariLoadMore" />
<view v-for="(item, index) in chatStore.msgList" :key="item.msgId" :id="'item-' + item.msgId">
<message :message="item" v-if="item.convention"></message>
</view>
<view class="im-list-last-item" id="im-list-last-item"></view>
</scroll-view>
<!-- #endif -->
<fui-fab :zIndex="10" :width="90" :distance="30" :bottom="200" background="#6831FF" v-if="data.showJump" @click="scrollToView(undefined, true)">
<fui-icon name="dropdown" color="#ffffff"></fui-icon>
</fui-fab>
</view>
</template>
<script setup>
import { ref, reactive, nextTick, getCurrentInstance } from 'vue';
import { onLoad, onUnload } from '@dcloudio/uni-app';
import { ChatStore, AppStore } from '@/store/index.js';
import { MSG_TYPE } from '@/common/constants.js';
import hooks from '@/hooks/index.js';
import utils from '@/utils/index.js';
import message from './message/index.vue';
import loadmore from './loadmore/index.vue';
const appStore = AppStore();
const chatStore = ChatStore();
const instance = getCurrentInstance();
const { sendMessage } = hooks.useChat();
// 对方的id
let merchantId = '';
// 每页的长度
let rowLength = 15;
// 当前页码记录
let currentPage = 1;
// 上一页最上边一条的id
let prevTopId = '';
// 当前列表组件的高度
let listHeight = 0;
// 页面数据
const data = reactive({
animated: false,
scrollIntoView: '',
isComplete: false,
rendered: false,
showJump: false
});
const lastItemRef = ref();
const systemInfo = uni.getSystemInfoSync();
// 苹果的web端和小程序端加载锚点错误白屏问题
const isApple = (systemInfo.platform === 'macos' || systemInfo.platform === 'ios') && systemInfo.deviceType === 'phone' && systemInfo.uniPlatform !== 'app';
// #ifdef APP-NVUE
const nativePluginDom = uni.requireNativePlugin('dom');
// #endif
// 获取消息列表
const getMsgList = async () => {
uni.showLoading({ title: '加载更多', mask: true });
try {
const result = await uni.$api.queryMsgList({
merchantId: merchantId,
dealerId: appStore.id,
num: chatStore.msgList.length,
rows: rowLength
});
console.log('我是消息列表', result);
result.list.reverse().forEach((item) => {
item.msgInfo = JSON.parse(item.msgInfo);
item.mine = item.fromUser.slice(1) == appStore.id;
item.convention = MSG_TYPE.includes(item.msgType);
});
chatStore.msgList = result.list.concat(chatStore.msgList);
data.isComplete = result.totalSize <= chatStore.msgList.filter((item) => MSG_TYPE.includes(item.msgType)).length;
nextTick(() => {
if (currentPage === 1) {
// 第一页渲染完毕就设置成rendered,这样@appear就不会瞬时触发
data.rendered = true;
utils.getRect('#custom-list', instance.ctx).then((res) => {
listHeight = res.height;
});
rowLength = 14;
scrollToView();
// 如果有未读消息,设置为已读
const noreadIds = chatStore.msgList.filter((item) => item.fromUser.slice(1) == merchantId && item.isRead == 0).map((item) => item.msgId);
if (noreadIds.length) {
sendMessage({
msgId: noreadIds,
msgType: 21,
targetId: merchantId
});
}
} else {
scrollToView(prevTopId);
}
currentPage += 1;
});
} catch (e) {
console.log('我错误了', e);
} finally {
nextTick(() => {
uni.hideLoading();
});
}
};
// 组件加载
onLoad((options) => {
merchantId = options.merchantId;
chatStore.targetId = options.merchantId;
getMsgList();
});
// 组件卸载
onUnload(() => {
chatStore.targetId = '';
chatStore.msgList = [];
});
// 滚动到指定元素
const scrollToView = (id = undefined, animated = false) => {
data.scrollIntoView = '';
data.animated = animated;
nextTick(() => {
data.scrollIntoView = id !== undefined ? `item-${id}` : 'im-list-last-item';
// #ifdef APP-NVUE
nativePluginDom.scrollToElement(lastItemRef.value, {
animated: animated
});
// #endif
});
};
// 小程序H5列表滚动
const scroll = (e) => {
// 小程序H5是否展示滚动到底部
data.showJump = e.detail.scrollHeight - e.detail.scrollTop >= listHeight * 2;
};
// app滚动
const onAppScroll = (e) => {
// app是否展示滚动到底部
data.showJump = e.contentSize.height + e.contentOffset.y >= listHeight * 2;
};
// 除了safari-h5之外的加载更多
const onLoadMore = () => {
if (isApple) return;
if (data.isComplete) return;
// 记录上一页的顶部top,在下一页渲染完毕后定位到该位置
prevTopId = chatStore.msgList[0].msgId;
utils.throttle(() => getMsgList(), 150);
};
// safari-h5加载更多
const onSafariLoadMore = () => {
if (data.isComplete) return;
// 记录上一页的顶部top,在下一页渲染完毕后定位到该位置
prevTopId = chatStore.msgList[0].msgId;
utils.throttle(() => getMsgList(), 150);
};
// 暴露模块
defineExpose({
scrollToView
});
</script>
<style lang="scss" scoped>
/* #ifndef APP-NVUE */
.scroll-view {
overflow-anchor: auto;
height: 100%;
}
/* #endif */
.custom-list,
.list {
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
height: 100%;
}
.im-list-last-item {
height: 20rpx;
}
</style>
message组件
目录结构:

关键点:
1、判断对方或自己发送的消息。
2、通过provide将消息传递下去。
3、特别注意nvue下的样式兼容问题。
message组件内代码
vue
<template>
<view :class="['message-wrapper', { mine: message.mine }]">
<image class="avatar" :src="message.fromAvatar" mode="aspectFill" v-if="!message.mine" />
<status v-if="message.mine" />
<view class="content" style="max-width: 530rpx">
<message-text v-if="message.msgType == 1" />
<message-image v-if="message.msgType == 2" />
<message-file v-if="message.msgType == 5" />
<message-prod v-if="message.msgType == 6" />
<message-rfq v-if="message.msgType == 7" />
</view>
<image class="avatar" :src="message.fromAvatar" mode="aspectFill" v-if="message.mine" />
</view>
</template>
<script setup>
import { ref, provide } from 'vue';
import status from './status.vue';
import messageText from './components/message-text.vue';
import messageImage from './components/message-image.vue';
import messageFile from './components/message-file.vue';
import messageProd from './components/message-prod.vue';
import messageRfq from './components/message-rfq.vue';
const props = defineProps({
message: Object,
});
provide('message', props.message);
</script>
<style scoped lang="scss">
.message-wrapper {
display: flex;
flex-direction: row;
width: 100%;
padding: 25rpx 0px;
/* #ifdef APP-NVUE */
width: 750rpx;
/* #endif */
&.mine {
justify-content: flex-end;
.content {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
}
.avatar {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
margin: 0 20rpx;
background-color: #ffffff;
}
.content {
padding-top: 10rpx;
}
}
</style>