一、前言
这个 AI 聊天涉及的东西还是比较多的,接口请求、页面布局、组件封装、点击交互等等。我想我得分几篇文章来说明,这篇文章主要是针对这个布局的实现。
二、布局分析
这是总体的UI布局,大致可以分为 顶部显示栏、中间内容区域和底部键盘编辑区。

这个页面可初步这么设计,考虑到不同尺寸的手机长度不一样,加上当键盘弹起时,需要给外层元素一个 padding-bottom
, 因此中间的内容区域的高度固定则显得不太合理,所以使用 flex
布局,中间内容区域占据剩余宽度。
html
<template>
<view class="page">
<u-header />
<view class="content"></view>
<u-keyboard @send="onSend"></u-keyboard>
</view>
</template>
scss
.page {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
background-color: #f4f5f7;
.content{
flex: 1;
}
}
三、布局交互
这才是 AI聊天页面中的重要部分。 content
的高度表现为一个固定值,在交互上有几点需要考虑。
- 滚动:在
content
区域内是要能够上下滚动的。 - 消息插入:最初消息是从顶部挨个插入的,当填满整个
content
区域后,消息从底部插入,向上推动其他内容,最新的一条保持在区域的底部(参考如微信等聊天页面) - 历史记录:历史记录是从上面加载的,向下滑动屏幕,平滑加载聊天记录。
1. 滚动
普通元素也能滚动,但为了贴合小程序和使用一些API,则初步确定了使用 scroll-view
来实现。那我的实现是这样的, content
是一个容器,其中子元素为 scroll-view
, 消息在 scroll-view
滚动。
html
<template>
<view class="page">
<u-header />
<view class="content">
<scroll-view class="chat-list">
<view class="chat-inner"></view>
</scroll-view>
</view>
<u-keyboard @send="onSend"></u-keyboard>
</view>
</template>
scss
.page {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
background-color: #f4f5f7;
.content {
box-sizing: border-box;
width: 100vw;
flex: 1;
overflow: auto;
.chat-list {
box-sizing: border-box;
height: 100%;
.chat-inner {
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 0 30rpx;
}
}
}
}
当消息内容很多的时候,.chat-inner
即被撑开,在 .chat-list
里滚动,同时滚动到顶部/底部(content的顶部/底部)的时候,能触发 scroll-view
的方法。
2. 消息插入
这是我摸索时间最长的一个模块。在默认情况下,消息能从顶部依次往下插入,但当消息到达 content
底部时,最新的消息并不会往上推动其他消息。
使用 数组 渲染消息,ai回复的消息在左边,用户发送的消息在右边。当发送消息时,向 数组 插入一条消息。
所以,我们最直观的想法是 手动 移动页面,正好 scroll-view
有提供这样的方法,scroll-top 设置滚动条的位置。可以简单理解 scroll-top:0
,就会滚动到顶部。那要滚动到底部,只需要设置足够大的值就可以。所以在最初的时候,当插入消息时,就设置:scroll-top:99999999999999
。确实,在最初的逻辑交互中,这种方式能实现目标。
2.1 用户消息
以上的方法就能满足用户消息插入的交互,这里打字机效果了,用户消息没有此效果,因此是突然移动页面。
2.2 AI 消息
简单的打字机效果,当识别到是同一种消息类型时,就想文本进行拼接。如第一条:"今天",第二条:"是星期三",则把这两条拼接起来,然后使用定时器输出内容(这在后面的md组件中再详细说明)
AI 消息是流式返回的,当然在接收到流式消息后,前端需要将消息进行拼接,然后使用定时器均匀的输出文本。此时就很难在接收消息的时候设置 scroll-top:99999999999999
。比如流式返回三条数据,那就要设置三次,但此时页面上的消息还没渲染出来,哪怕是渲染出来了,也是一个字一个字的出现。
我当时的思路是,在AI返回的消息中,添加时间戳为这条消息的ID,即id: Date.now()
。在ai消息打字输出的时候,调用方法设置 scroll-top
的值为这条消息的 ID。
正如我所想,该方法确实能实现,AI 消息刚冒头时,content
还是停留在之前的位置,随着内容打字输出,content
慢慢向上移动,保持该条消息处于底部。
3. 历史记录
这个需要触顶操作,当滚动到顶部时,会触发 scroll-view
的方法。在这个方法里,获取一定的历史数据,向数组前面插入这些数据。这里元素有个默认行为,插入元素后,页面会直接 跳到顶部,这并不符合我们的预期。
同样是最直观的想法,把之前第一条消息的id记录下来,当插入更多数据后,重新设置 scroll-top
的值为记录的id,则页面停留在之前加载更多的位置。
很遗憾,页面确实能停留,但只能说是"附近",而且页面会跳动一下,体验并不是很好,与一些聊天软件相比,出入非常大。 再次查看文档,注意到这个属性 scroll-into-view,也是动手尝试了。
scroll-into-view: 值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
通过这个介绍,无疑是完美的,但是尝试了之后,依然是存在跳动的现象。
在开发过程中,借助了 ChatGpt 、DeepSeek 、cursor、豆包 、 通义千问、Trae 等ai工具,大差不差地都是推荐 scroll-top,随后又推荐 scroll-into-view。 对于闪动问题,提到了页面重新渲染导致,使用 nextTick 处理。但结果都是,无法解决。
四、不一样的灵感
目前的难点都是消息插入与定位,消息从底部平滑插入,历史记录在顶部平滑加载。平常的情况与元素默认行为是,向页面插入内容时,能从顶部平滑插入。底部加载更多时,页面不会跳动,能平滑加载。
等等,灵感来了,那此时把手机倒过来,默认的行为岂不就是我们需要的效果了吗?
因此,尝试着什么都反着来,将页面先翻转180°,插入消息,从原来的 push
变为 unshift
。历史记录,从原来的插入前面,变成插入后面。
scss
.page {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
background-color: #f4f5f7;
.content {
box-sizing: border-box;
width: 100vw;
flex: 1;
overflow: auto;
.chat-list {
box-sizing: border-box;
height: 100%;
transform: rotateX(-180deg); /* scroll-view 翻转 */
.chat-inner {
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end; /* 最初的消息出现在顶部 */
padding: 0 30rpx;
}
}
.message-wrapper {
box-sizing: border-box;
display: flex;
transform: rotateX(-180deg); /* 将消息盒子 回正 */
}
.from-user {
justify-content: flex-end;
margin: 30rpx 0;
}
.from-ai {
justify-content: flex-start;
}
}
}
到此,页面的布局可以说是完美了,不需要什么 scroll-into-view
和 scroll-top
,不需要动态设置什么位置,全靠默认行为,消息一插入就能出现在底部。加载更多消息时,实际上是使用 @scrolltolower
进行触底加载,能平滑加载。
html
<template>
<view class="page">
<u-header />
<view class="content">
<scroll-view class="chat-list" @scrolltolower="handleScrollTolower">
<view class="chat-inner">
<view class="['message-wrapper',item.role === 'user' ? 'from-user' : 'from-ai]"
v-for="(item, index) in messages"
:key="index"
:id="item.id">
</view>
</view>
</scroll-view>
</view>
<u-keyboard @send="onSend"></u-keyboard>
</view>
</template>