Skip to content

玄学放置 · 公屏聊天粘底滚动

一个常见但容易做错的小问题

第一版公屏聊天的滚动逻辑是"每次有新消息就滚到底"。这种实现写起来一行就够:

js
watch(() => props.messages.length, async () => {
  await nextTick()
  listRef.value.scrollTop = listRef.value.scrollHeight
})

但用一会儿就会发现问题:玩家想往上翻看历史消息时,每隔几秒就会被一条新消息「拽回」底部,没法稳定阅读。这是几乎所有 IM 类组件都要解决的体验细节。

正确的行为模型

参考 QQ、微信、Telegram 这类成熟的聊天界面,标准行为是:

  • 用户在底部 → 来新消息自动跟随
  • 用户向上回看 → 不强制滚动,但要让用户知道"有 N 条新消息"
  • 用户自己滚回底部 / 主动点击未读提示 → 立即对齐底部,未读清零

这套行为的关键是「粘底」概念——用户的当前位置决定了新消息要不要追加滚动,而不是无脑跟随。

实现:判断"是否粘底"

判断滚动条是否在底部,看的是 scrollTopscrollHeight - clientHeight 的差:

js
const STICK_THRESHOLD = 32 // 像素

function checkAtBottom() {
  const el = listRef.value
  const distance = el.scrollHeight - el.scrollTop - el.clientHeight
  isAtBottom.value = distance <= STICK_THRESHOLD
}

这里的 STICK_THRESHOLD = 32 不是随手写的常量,是有意留的缓冲。原因是:

  • 容器高度可能因父元素 resize 而轻微抖动
  • 浏览器在不同 DPI 下 scrollTop 可能有亚像素偏差
  • 一些浏览器在 momentum scroll 结束时会上报一个非整数的 scrollTop

如果阈值用 0,会导致"是否粘底"的状态在边界附近反复翻转,未读按钮疯狂闪烁。32px 大约是一行半消息的高度,人眼几乎察觉不到偏移,但能稳住状态。

监听新消息 + 累计未读

js
const isAtBottom = ref(true)
const unreadCount = ref(0)

watch(
  () => props.messages.length,
  async (newLen, oldLen) => {
    await nextTick()
    if (isAtBottom.value) {
      scrollToBottom()
      unreadCount.value = 0
    } else {
      const delta = newLen - oldLen
      if (delta > 0) unreadCount.value += delta
    }
  }
)

监听消息数组长度而不是数组本身有两个好处:

  • 性能:长度比较是 O(1),深比较是 O(n)
  • 不会被消息内部字段更新(比如已读状态 toggle)误触发滚动

nextTick 是必须的:Vue 的 DOM 更新是异步的,新消息的 DOM 必须先渲染出来,scrollHeight 才会反映新值。

监听用户滚动行为

js
function onScroll() {
  checkAtBottom()
  if (isAtBottom.value) unreadCount.value = 0
}

用户每次滚动都会更新 isAtBottom。当用户从中部滚回底部时,unreadCount 自动清零——不需要让用户去点"标记已读"。

浮动按钮的定位

未读提示按钮要浮在消息列表的右下角,不能挡住输入框、要随列表滚动而保持在视图底部。一个常见的写法是绝对定位 + 父级 position: relative,但更简洁的做法是 position: sticky

scss
.chat-panel__list {
  position: relative; // sticky 锚点
}

.chat-panel__jump {
  position: sticky;
  bottom: 6px;
  align-self: center;
  margin-top: auto;
}

sticky 在这里等价于"滚动到底部时贴在底部,被列表内容顶起来时跟着滚动"。配合 align-self: center 让按钮水平居中,整体效果是用户回看时按钮始终浮在视口下沿。

首次挂载也要滚到底

容易漏掉的一步:组件首次挂载时,初始消息列表如果不滚到底,第一条 mock feed 推送过来就会触发"用户在历史中"的逻辑,按钮立即显示。

js
onMounted(async () => {
  await nextTick()
  scrollToBottom()
  isAtBottom.value = true
})

这是「乐观初始化」:默认假设用户进来就是想看最新的,主动把状态拉到底部,避免冷启动时的状态歧义。

跳转按钮的微动效

按钮本身的样式做了几个细节:

scss
.chat-panel__jump {
  background: var(--grad-progress);   // 青→紫渐变
  border-radius: 999px;                // 胶囊形
  box-shadow: var(--glow-cyan);        // 发光
  z-index: 2;

  &:hover {
    filter: brightness(1.1);
    transform: translateY(-1px);
  }
}

胶囊形 + 发光是项目主 CTA 的统一语言,和顶部的"队列"按钮风格一致;hover:translateY(-1px) 提供轻微的抬升反馈,让按钮"看起来可点击"。

收获

聊天滚动这种功能看似一行代码搞定,实际上要做对需要分清几个独立的状态:

  • 粘底状态isAtBottom)由用户滚动行为决定
  • 未读数unreadCount)由消息增量 + 粘底状态共同决定
  • 跳转按钮可见性showJumpBtn)是前两者的派生

把这三件事独立维护、用 watch 串起来,逻辑就清晰了。32px 阈值这种工程经验是真正写过类似组件才会知道的;它不是性能优化,而是消除"边界抖动"这种隐性体验问题的关键。

下一篇会回到任务队列——这次重点说一个比较折腾的过程:从 v1 单任务、误解为并发 3、再回到单线程顺序队列、最后做成气泡化的极简交互,期间还顺手接入了 SCSS、UnoCSS 和 Element Plus。