vue3版本uniapp nvue实现多端聊天,太丝滑了!

大家好,本文要介绍的是我在去年初开发的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>
相关推荐
萌萌哒草头将军5 小时前
⚡⚡⚡尤雨溪宣布开发 Vite Devtools,这两个很哇塞 🚀 Vite 的插件,你一定要知道!
前端·vue.js·vite
小彭努力中5 小时前
7.Three.js 中 CubeCamera详解与实战示例
开发语言·前端·javascript·vue.js·ecmascript
浪裡遊6 小时前
跨域问题(Cross-Origin Problem)
linux·前端·vue.js·后端·https·sprint
LinDaiuuj6 小时前
判断符号??,?. ,! ,!! ,|| ,&&,?: 意思以及举例
开发语言·前端·javascript
敲厉害的燕宝6 小时前
Pinia——Vue的Store状态管理库
前端·javascript·vue.js
Aphasia3117 小时前
react必备JavaScript知识点(二)——类
前端·javascript
玖玖passion7 小时前
数组转树:数据结构中的经典问题
前端
呼Lu噜7 小时前
WPF-遵循MVVM框架创建图表的显示【保姆级】
前端·后端·wpf
珠峰下的沙砾7 小时前
Vue3 里 CSS 深度作用选择器 :global
前端·javascript·css
航Hang*7 小时前
WEBSTORM前端 —— 第2章:CSS —— 第3节:背景属性与显示模式
前端·css·css3·html5·webstorm