AI 对话应用之页面滚动交互的实现

0 阅读6分钟

需求背景

现代化的一个 AI 对话聊天应用,需要有一个对用户良好的一个交互处理。业务当中因为要简单的接入一个 AI 对话页面,因此这里是对市面上一些 AI 对话软件的交互进行参考分析,发现基本上都是类似的用户交互处理:

在 AI 思考对话返回当中不断的对 AI 思考内容的时候页面的默认逻辑是会不断的自动滚动到页面底部操作处理;

而当用户如果进行鼠标滚动或者进行页面触摸滚动之后,则对相关的默认页面自动滚动;当用户主动重新将页面滚动到当前页面靠近底部后则重新再次启动输出 AI 思考内容后自动进行页面滚动的逻辑。


具体实现

页面自动滚动

首先就是接口流式返回内容时候进行页面自动滚动的处理操作处理:

这里是通过 MutationObserver 这个 api 我们能够对对话监听页面内容自动进行页面滚动处理

MDN MutationObserver API: developer.mozilla.org/zh-CN/docs/…

与 IntersectionObserver 类似的用法,通过MutationObserver 这个 API 可以监听 DOM 节点树内容的变化,能够捕获多种类型的 DOM 变化,包括节点添加或删除、属性变化、文本内容变化等。

使用 MutationObserver 监听节点内容变化的基本步骤:

// 1. 创建一个 MutationObserver 实例,并传入回调函数:
const observer = new MutationObserver((mutationsList, observer) => {
  for (let mutation of mutationsList) {
    if (mutation.type === 'childList') {
      console.log('子节点发生变化');
    } else if (mutation.type === 'attributes') {
      console.log('属性发生变化');
    } else if (mutation.type === 'characterData') {
      console.log('文本内容发生变化');
    }
  }
});

// 2. 定义观察选项,指定要监听的变化类型:
const config = {
  attributes: true,
  childList: true,
  subtree: true,
  characterData: true  // 监听文本内容变化
};

// 3. 开始观察目标节点:
const targetNode = document.getElementById('target');
observer.observe(targetNode, config);

// 4. 当不需要继续监听时,可以停止观察:
observer.disconnect();

结合在 AI 对话项目页面当中相关逻辑:

let observer: MutationObserver
const messagesContainer: any = document.getElementById('AiChatContent')

observer = new MutationObserver(() => {
  messagesContainer.scrollTo({
    top: messagesContainer.scrollHeight,
    behavior: 'smooth', // 
  })
})

const stopObserverScroll = () => {
  observer && observer.disconnect()
}

const startObserverScroll = () => {
  observer && observer.observe(messagesContainer, {
    /** 观察器的配置(需要观察什么变动) **/
    childList: true, // 观察目标子节点的变化,是否有添加或者删除
    subtree: true, // 观察后代节点,默认为 false
    characterData: true, // 文本
    attributes: true, // 观察属性变动
  })
}

// 开始监听
startObserverScroll()

用户交互取消页面自动滚动或重新触发页面自动滚动

接着是当我们对手机移动端页面进行触摸滚动或者PC滑轮滚动查看前面的对话内容时候,需要暂停掉网页的自动滚动到页面底部的逻辑;

这就需要我们对页面触摸滚动的监听以及滚轮事件监听处理,而移动端的触摸事件是touchstarttouchend,PC 网页的鼠标滚轮事件则是wheel

  1. 因此我们需要在 AI 聊天页面初始化时候需要对这两个事件进行监听处理,当触发了鼠标滚轮或者移动端触摸事件时候需要对 AI 聊天消息容器 DOM 节点的变化监听进行暂停;
  • 可以在移动端触摸开始事件或者鼠标滚轮触发时候暂停掉监听并且设置一个用户主动滚动标识;
  • 改造前面的 MutationObserver 节点内容监听回调逻辑,判断根据这个用户主动滚动标识再进行是否需要进行 scrollTo 滚动处理;
  1. 当我们重新将页面滚动到内容底部的时候(对相关的触摸结束或者鼠标滚轮结束事件进行判断以及针对页面滚动),需要重新触发页面的流式数据返回内容自动滚动的逻辑;
  • 要重新触发内容自动滚动逻辑则需要重新启用 MutationObserver 对节点的监听处理
  • 并且将用户主动滚动标识设置为 false;
  1. 这里关于移动端触摸滑动有一个留意的交互点就是在触摸手势结束之后会有一个滑动的惯性(平滑滑动效果),如果平滑滚动到页面底部的情况下也是需要进行重新触发页面的流式数据返回内容自动滚动的逻辑;
  • 因此这里对节点的滚动事件(scroll)进行一个监听,并且优化如果是此时正在触摸滚动或者此时已经是自动滚动模式下都不进行判断
const AUTO_SCROLLING = 1;
const USER_SCROLLING = 2;
let SCROLLING_MODE: AUTO_SCROLLING | USER_SCROLLING = AUTO_SCROLLING;

const observer: MutationObserver = new MutationObserver(() => {
  // 避免用户主动触摸滚动时互斥的自动滚动到底部,互相抢滚动交互
  if (SCROLLING_MODE === USER_SCROLLING) {
    messagesContainer.scrollTo({
      top: messagesContainer.scrollHeight,
      behavior: pageHasInit ? 'smooth' : 'auto',
    })
  }
})

const updateScrollState = () => {
  const { scrollHeight, clientHeight, scrollTop } = messagesContainer || {};

  // 简单判断滚动到底部了,是的话将用户主动触摸滚动的标识去除
  if (scrollTop + clientHeight >= (scrollHeight - 50)) {
    SCROLLING_MODE = AUTO_SCROLLING
    startObserverScroll() // 重新对节点内容进行监听处理
  }
}

const handleScroll = () => {
  if (SCROLLING_MODE === USER_SCROLLING) { // 优化,只有用户正在主动滚动时候才进行判断
    updateScrollState()
  }
}

const handleTouchStart = () => {
  stopObserverScroll() // 停止节点内容的监听
  SCROLLING_MODE = USER_SCROLLING
}

const handleTouchEnd = () => {
  updateScrollState()
}

const handleWheelEvent = () => {
  handleTouchStart()

  // 简单的实现一个去抖操作处理滚轮事件的结束时候触发
  wheelTimer && clearTimeout(wheelTimer)
  wheelTimer = setTimeout(() => {
    updateScrollState()
  }, 150)
}

messagesContainer.addEventListener('scroll', handleScroll)
messagesContainer.addEventListener('touchstart', handleTouchStart)
messagesContainer.addEventListener('touchend', handleTouchEnd)
messagesContainer.addEventListener('wheel', handleWheelEvent)

封装相关的 Hooks

笔者的 AI 对话项目使用的是 React 技术栈,这里就使用 React Hooks 作为例子(Vue 3.x 的技术栈也是类似的)

组件初始化时候添加相关的事件监听逻辑;

组件卸载时候将相关的事件监听进行取消处理;

import { useEffect } from 'react'

export const useChatContainer = (tagId: string) => {
  const AUTO_SCROLLING = 1;
  const USER_SCROLLING = 2;
  let SCROLLING_MODE: AUTO_SCROLLING | USER_SCROLLING = AUTO_SCROLLING;

  const messagesContainer: any = document.getElementById(tagId)

  const observer: MutationObserver = new MutationObserver(() => {
    // 避免用户主动触摸滚动时互斥的自动滚动到底部,互相抢滚动交互
    if (SCROLLING_MODE === USER_SCROLLING) {
      messagesContainer.scrollTo({
        top: messagesContainer.scrollHeight,
        behavior: pageHasInit ? 'smooth' : 'auto',
      })
    }
  })

  const updateScrollState = () => {
    const { scrollHeight, clientHeight, scrollTop } = messagesContainer || {};
  
    // 简单判断滚动到底部了,是的话将用户主动触摸滚动的标识去除
    if (scrollTop + clientHeight >= (scrollHeight - 50)) {
      SCROLLING_MODE = AUTO_SCROLLING
      startObserverScroll() // 重新对节点内容进行监听处理
    }
  }

  const handleScroll = () => {
    if (SCROLLING_MODE === USER_SCROLLING) { // 优化,只有用户正在主动滚动时候才进行判断
      updateScrollState()
    }
  }

  const handleTouchStart = () => {
    stopObserverScroll() // 停止节点内容的监听
    SCROLLING_MODE = USER_SCROLLING
  }

  const handleTouchEnd = () => {
    updateScrollState()
  }

  const handleWheelEvent = () => {
    handleTouchStart()
  
    // 简单的实现一个去抖操作处理滚轮事件的结束时候触发
    wheelTimer && clearTimeout(wheelTimer)
    wheelTimer = setTimeout(() => {
      updateScrollState()
    }, 150)
  }

  const startObserverScroll = () => {
    observer && observer.observe(messagesContainer, {
      /** 观察器的配置(需要观察什么变动) **/
      childList: true, // 观察目标子节点的变化,是否有添加或者删除
      subtree: true, // 观察后代节点,默认为 false
      characterData: true, // 文本
      attributes: true, // 观察属性变动
    })
  }

  const stopObserverScroll = () => {
    observer && observer.disconnect()
  }

  const startEventListener = () => {
    startObserverScroll()
    messagesContainer.addEventListener('scroll', handleScroll)
    messagesContainer.addEventListener('touchstart', handleTouchStart)
    messagesContainer.addEventListener('touchend', handleTouchEnd)
    messagesContainer.addEventListener('wheel', handleWheelEvent)
  }
  
  const removeEventListener = () => {
    stopObserverScroll()
    messagesContainer.removeEventListener('scroll', handleScroll)
    messagesContainer.removeEventListener('touchstart', handleTouchStart)
    messagesContainer.removeEventListener('touchend', handleTouchEnd)
    messagesContainer.removeEventListener('wheel', handleWheelEvent)
  }

  useEffect(() => {
    startEventListener()
    
    return () => {
      removeEventListener()
    }
  }, [])
}

hooks 使用的 eg:

import { forwardRef, useEffect, PropsWithChildren } from 'react'

import { useChatContainer } from '@/utils/chat/useChatContainer'

import './index.scss'

interface AiChatContainerProps {
}

const AiChatContainer = forwardRef(({}: PropsWithChildren<AiChatContainerProps>, ref) => {
  useChatContainer('AiChatContent');
  
  return (
    <div
      id='AiChatContent'
      className='ai-chat-container'
    >
      <!- AI 聊天内容等 ··· ->
    </ScrollView>
  )
})

AiChatContainer.defaultProps = {
}

export default AiChatContainer