普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月16日首页

AI 打字跟随优化

2026年4月16日 09:37

前文提到通过API去监听滚动容器或容器尺寸去触发打字跟随,监听用户的滚动去读取造成重排的属性来实现用户是否跟随打字实际会造成浏览器多次重排。

重排

浏览器重排(回流)是浏览器对DOM元素计算位置、尺寸、布局的过程。 浏览器解析HTML合成DOM树,解析CSS合成CSSOM树,它们会一步步合成渲染树,进行布局。页面、结构、尺寸发生变化就会再走一遍布局的计算流程,称为重排。

为什么重排会造成性能开销?

  • 当一个元素变化后,可能影响父元素、子元素、兄弟元素等,浏览器需要进行递归遍历。
  • 重排需要在浏览器主线程发生,阻塞JS、渲染。
  • 频繁重排会造成浏览器卡顿,浏览器的刷新率是60fps,每帧近16ms,一次重排就会占据一部分时间;多次重排会导致掉帧。

哪些操作会导致重排?

  • 元素几何属性发生变化。
  • 增删、移动DOM。
  • 窗口变化(resize、scroll页面等)
  • 获取布局相关属性:
    • offsetTop / offsetLeft / offsetWidth / offsetHeight

    • scrollTop / scrollHeight

    • clientTop / clientWidth

    • getComputedStyle()

    • getBoundingClientRect()

IntersectionObserver 哨兵模式

这里直接取消滚动事件的监听,在容器的最底部放一个哨兵容器,通过 IntersectionObserver 去监听哨兵在监听的父元素在可视区域的交叉值来判断用户是否滚动。让哨兵通过 scrollIntoView 直接暴露在可视区域,实现打字跟随。

<div class="chat-scroll-container" ref="scrollContainerRef">
    <div class="chat-container" id="messagesRef"></div>
    <div class="scroll-sentinel" ref="sentinelRef"></div> // 哨兵
</div>

threshold: 1 // 1 :表示全部进入,0 :露头就秒

onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value);

  observer = new IntersectionObserver(
    (entries) => {
      const isIntersecting = entries[0].isIntersecting;
      enableAutoScroll.value = isIntersecting; 
    },
    {
      root: scrollContainerRef.value, // 监听父元素,默认为 root
      threshold: 1,       // 1表示全部进入,0 :露头就秒
      rootMargin: '10px', // 提前10px开始生效
    }
  );

  if (sentinelRef.value) observer.observe(sentinelRef.value);
});

  
  const scrollToBottom = () => {
  nextTick(() => {
    const el = sentinelRef.value;
    if (!el) return;
    if (enableAutoScroll.value) {
      sentinelRef.value.scrollIntoView({ behavior: 'instant' });
    }
  });
};

在实践过程中,如果使用 behavior: 'smooth' ,浏览器在触发 scrollToBottom 时发生的动画会频繁抖动,将哨兵挤到父容器的可视区域外,导致 IntersectionObserver 频繁触发,可能产生 bug,使用 instant 取消浏览器动画抖动,直接抵达底部,避免该情况产生。

昨天以前首页

AI聊天界面的布局细节和打字跟随方法

2026年4月14日 18:06

AI 问答界面如何布局?

在豆包的AI问答聊天界面,为什么输入框总是会跟随在最底部?左边有导航栏,无论怎么缩小放大屏幕都会在当前问答展示界面的水平线中间?

难道是通过 position: fixed; 来实现的?但是它怎么能够解决第二个问题呢?先打开控制台看看。

在问答界面,输入框是一直被挤在最下方的,通过检查控制台会发现输入框好像会一直跟随在屏幕最下方?

image.png

但是随着控制台一直向上拉长,输入框又会被控制台覆盖?

image.png

说明根本不是通过固定定位来实现的效果。

下面来实现一下它的这种效果:这里展示的是最外层容器的布局。

<!-- 根容器 -->
<div class="chat">
    <!-- 展示容器 -->
    <div v-show='!isChat' class="chat-content">
    </div>
    <!-- 对话界面 -->
    <div v-show='isChat' class="chat-scroll-container">
    </div>
    <!-- 输入框 -->
    <div class="input-section">
    </div>
</div>

.chat {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.chat-container {
  width: 100%;
  max-width: 1000px;
  flex: 1;
  overflow-y: auto;
}
.chat-scroll-container {
  height: 100%;
  width: 100%;
  overflow-y: auto;
  flex: 1;
  /* 隐藏滚动条但保留功能 */
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE/Edge */
}
.input-section {
  width: 100%;
  height: 150px;
  flex-shrink: 0;
}

可以看到核心的实现,其实就是让输入框的兄弟容器将剩余空间全部占据,而 input-section 本身只需要不压缩自身的高度就可以,当屏幕缩小后,flex:1;能占据的空间变小,而输入框高度不变将一直在外层容器最下方,当空间展示不下时会出现的可视区域外。

flex-shrink

  • 父容器必须是弹性容器。
  • 默认表示子元素固定宽度,不被压缩。
  • 在父元素使用 flex-direction: column; 改变了弹性方向后,表示子元素固定高度,不被压缩。
  • 为 1 时,表示容器会适应父容器高度被压缩。

如何让视线跟随 AI 生成的内容

下方父容器为滚动容器,子元素为主要内容展示容器。以下介绍两种 AI 打字跟随的监听方法,控制跟随与用户操作停止跟随。

<div class="chat-scroll-container" ref="scrollContainerRef" @scroll="handleScroll">
    <div class="chat-messages" ref="chatMessagesRef">
    </div>
</div>

const chatMessagesRef = ref(null);
// 滚动容器引用
const scrollContainerRef = ref(null);
// 是否启用自动滚动跟随
const enableAutoScroll = ref(true);

// 上次滚动位置
let lastScrollTop = 0;

const handleScroll = throttle(() => {
  const el = scrollContainerRef.value;
  if (!el) return;
  const { scrollTop, scrollHeight, clientHeight } = el;
  // 判断当前是否已经在底部(留20px容差)
  const isAtBottom = scrollTop + clientHeight >= scrollHeight - 20;
  // 如果用户在向上滚动超过阈值,取消自动跟随
  if (isAtBottom === false && scrollTop < lastScrollTop) {
    const upDistance = lastScrollTop - scrollTop;
    if (upDistance > 10) {
      enableAutoScroll.value = false;
    }
  }
  // 如果滚动到底部,重新开启自动跟随
  if (isAtBottom) {
    enableAutoScroll.value = true;
  }

  // 记录本次滚动位置
  lastScrollTop = scrollTop;
}, 100);

.chat-scroll-container{
    height: 1000px; 
    .chat-messages{
    // 高度由内容支撑
    }
 }

MutationObserver

监听容器的变化,包括高度、内容变化、DOM的操作等等。大多都抛弃该做法

  • 触发次数极多

  • 性能开销

  • 容易抖动、重复触发

  • 性能不如ResizeObserver

let observer = null;
onMounted(() => {
  initObserver();
});

// 初始化 MutationObserver
const initObserver = () => {
  if (!scrollContainerRef.value) return;
  // 断开旧的 observer
  if (observer) {
    observer.disconnect();
  }
  observer = new MutationObserver(() => {
    if (!enableAutoScroll.value) return;
    // 内容变化时,自动滚动到底部
    scrollToBottom();
  });
  observer.observe(scrollContainerRef.value, {
    childList: true,
    subtree: true,
    characterData: true,
  });
};
// 滚动操作
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

ResizeObserver

监听容器是否发生尺寸变化,而不是滚动容器。操作跟随需要操作滚动容器。

// 启用监听
onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value); // 监听高度变化容器
});
// 滚动操作,操作滚动容器
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

能够插入 DOM 的输入框

2026年4月11日 11:00

简易富文本编辑器

使用input、textarea 这种输入框会出现一个问题,就是无法在其中写入 DOM 结构,浏览器不会把 DOM 进行渲染,这样的话在某些情况下使用他们只会浪费时间,复制粘贴半天,发现没办法放 UI 内容,无敌了孩子。

如果你的内容需要很多操作可以选择去使用富文本编辑器,这里就说一下怎么写一个简单的富文本编辑器。

     <div
        id="editor"
        contenteditable="true" // 赋予容器可编辑的能力
        ref="editorRef"
      ></div>

只要是 DOM 能放的结构,他都可以。

他也有一些缺点,就是没有input简便,好写,而且它只有一部分 input 对应的方法, 比如以下常见方法:

  • input
  • paste
  • blur、focus
  • keydown、keyup

如何插入 DOM(组件) 和文本

插入 DOM

const textNode = document.createTextNode(featureData.description); // 创建文本
const placeholder = document.createElement('span'); // 创建节点
placeholder.contentEditable = false; // 不可编辑
// 变量记录文本节点
featureData.lastTextNode = textNode;
featureData.lastTagHolder = placeholder; 
// 在编辑器最前方进行插入
editor.insertBefore(textNode, editor.firstChild);
editor.insertBefore(placeholder, editor.firstChild);

在vue的程序里面想要在普通函数中动态创建、挂载、操作组件可以通过vue提供的createApp去创建vue的节点

const app = createApp({
    render: () =>
      h(Tag, {
        text: featureData.title, // 组件 props 
        bgColor: featureData.bgColor, // 组件 props 
        onClose: () => {
          featureData.lastApp?.unmount();
          featureData.lastApp = null;
          featureData.lastTextNode?.remove();
          featureData.lastTagHolder?.remove();
          featureData.lastTextNode = null;
          featureData.lastTagHolder = null;
        },
      }),
  });
  app.mount(placeholder);
  featureData.lastApp = app; // 记录app实例进行卸载

h 函数

用于创建虚拟节点,可以渲染多个/嵌套/动态结构。

  1. 渲染组件 vnode 时 children 参数需要通过插槽函数书写,可以通过设置props为null避免将插槽识别为props。
  2. 渲染为 html 的节点 children 可以随意文本或者数组传递多个节点。
function h(
  type: string | Component,
  props?: object | null,
  children?: Children | Slot | Slots   // 为组件时需要通过插槽函数
): VNode

h( 
    组件 / 标签名, 
    属性、props、事件, 
    子节点/内容            // 子节点不是插槽就可以省略 props 书写
)
// 多个节点
h(
    'div'
    null,
    [
        h('div','文字') 
    ]
)
// 动态结构
h('div', isShow ? h(Tag) : h('span', '无标签') )

// 组件插槽传递 vnode
h(Components,null,{default:()=>'你的内容'})// 默认插槽

// html节点
h('div',null,['文字', h('span', '内容')])

鼠标选中区域

可以通过选中区域对文本区域进行记录,选中区域内容、获取选区范围等等,可以用于加粗、添加标题。

// 创建鼠标选区
  const range = document.createRange();
  // 设定鼠标选中区域
  range.setStartAfter(textNode); // 在 textNode 后面开始
  range.setEndAfter(textNode);   // 在 textNode 后面结束
  // 获取选区管理
  const sel = window.getSelection();
  // 获取选中文字
  const selectedText = sel.toString()
  // 获取第一个选区
  const range = sel.getRangeAt(0)
  // 移除先前选区
  sel.removeAllRanges();
  // 记录当前鼠标选区
  sel.addRange(range);
❌
❌