解决前端接收流式数据时,无法正确选择文本的问题

660 阅读3分钟

背景


这个问题源于在访问gpt官网时,发现当ai还在输出文本时,选中文本会出现错误

动画.gif

示例源码

现象


除非元素已完全加载完并且在一次事件循环内对该元素无其他操作,否则执行选中文本操作会把左边的内容也选择上

原因猜测

猜测1:因元素重新赋值会导致浏览器执行重排操作,而默认的选择文本行为会在重排后出现异常

开始模拟

服务端代码
import { createReadStream } from "node:fs";
import { createServer } from "node:http";

const server = createServer((req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  // 使接口以流的形式返还到前端
  createReadStream('./t.txt', { encoding: 'utf8', highWaterMark: 1 * 100 }).pipe(res);
});

server.listen(3002, () => console.log('is ok'));
直接使用nodemon index.js启动代码

image.png

前端代码
// 前端先采用官网fetch返还流的代码改一下做尝试
// [ReadableStream - Web API 接口参考 | MDN (mozilla.org)](https://developer.mozilla.org/zh-CN/docs/Web/API/ReadableStream)

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <button onclick="fff()">点击触发</button>
  <h3>测试:</h3>
</body>
<script>
  const h2 = document.querySelector('h3')
  const fff = () => {
    const textDecoder = new TextDecoder();
    fetch('http://127.0.0.1:3002').then(res => {
      const reader = res.body.getReader()
      new ReadableStream({
        start(controller) {
          async function push() {
            const { done, value } = await reader.read()
            if (done) {
              controller.close();
              return;
            }

            controller.enqueue(value);
            // 模拟操作
            h2.innerHTML += textDecoder.decode(value)
            push();
          }
          push();
        }
      });
    })
  }
</script>

</html>

设置节流模式,防止传输过快无法准确复现 image.png

demo复现效果

动画2.gif

这里可以看到已经最简单的复现出来(暂不考虑markdown)

解决思路

尝试使用dom原生api来替代innerhtml +=操作
            // h3.innerHTML += textDecoder.decode(value)
            // 模拟操作->替换
            const text = document.createTextNode(textDecoder.decode(value))
            h3.appendChild(text)

动画2.gif

可以看到这就是我们想要的效果,这里其实已经得出猜测1不成立,因为所有能引起布局变化的操作都会引起浏览器重排

猜测2:浏览器对于innerhtml和appendChild的渲染机制不相同,innerhtml为全量重渲染,而appendChild为增量渲染

开始论证

以下来自gpt3.5的回答(不可全信,但咨询过大佬大概就是这个原因)

innerHTML 和appendChild有什么区别,在浏览器渲染方面

g——innerHTML和appendChild都是用来修改HTML文档的DOM树,但它们的使用方式和影响不同: - innerHTML:可用于修改某个元素的HTML内容,但它会替换整个元素内部的HTML代码。如果该元素已经存在子节点,这些节点将被全部删除并且重新创建,因此使用innerHTML可能会导致浏览器重新渲染整个页面; - appendChild:必须先创建要插入的节点,然后通过appendChild方法将其插入到指定的父节点下面,不会影响父节点原有的HTML代码,所以不会导致浏览器重新渲染整个页面。 因此,appendChild通常比innerHTML能够更精确地管理DOM树,避免了不必要的浏览器重新渲染。

所以分别是全量渲染和增量渲染

g——是的,可以将innerHTML视为全量渲染,因为它会替换整个元素内部的HTML代码并重新创建子节点,而appendChild则是增量渲染,因为它只会在指定的父节点下添加一个新的子节点,并不会影响父节点原有的HTML代码。

全量渲染会导致文本选择异常吗

g——是的,当使用innerHTML修改某个元素的HTML内容时,如果该元素中包含有用户已经选择文本,那么这些文本选择可能会因为被替换而丢失,导致文本选择异常。 因此,如果需要修改某个元素的HTML内容而不影响用户已经选择的文本,可以考虑使用其他方式,比如通过DOM方法插入、删除、修改节点等操作,这些方式对于文本选择的影响较小。

结论:导致用户文本选择错位的原因就是浏览器对选中文本区域进行了全量渲染

解决方法:使用dom方法来操作,避免使用会导致全量渲染的操作

最后一题:渲染markdown

PS:这里只提供一个思路,因为我没做完

<script>
  // 模拟操作->替换
  // v1.0 not
  // h3.innerHTML += textDecoder.decode(value)
  // v2.0 not
  // h3.innerText += textDecoder.decode(value)
  // v3.0 work but not good
  // const text = document.createTextNode(textDecoder.decode(value))
  // h3.appendChild(text)
  // v4.0 try markdown
  const md = window.markdownit();
  const h3 = document.querySelector('h3')

  const fff = () => {
    const textDecoder = new TextDecoder();

    const parser = new DOMParser();
    // 状态机
    const state = {
      value: '',
      htmlStrList: [],
      tagList: []
    }
    window.state = state
    fetch('http://127.0.0.1:3002').then(res => {
      const reader = res.body.getReader()
      let decodedValue = ''
      new ReadableStream({
        start(controller) {
          async function push() {
            const { done, value } = await reader.read()
            if (done) {
              controller.close();
              return;
            }

            const decodedValue = md.render(textDecoder.decode(value));


            // 将解码后的文本逐个字符地添加到容器中
            for (let i = 0; i < decodedValue.length; i++) {
              const v = decodedValue[i];
              h3.appendChild(document.createTextNode(v));
              state.value += v;
              // 判断闭合标签

              const reg = /<\/(\w+)>/g;
              let match = reg.exec(state.value);
              while (match || isSingleClosureTag()) {
                handleTextContent(match, match ? false : isSingleClosureTag());
                match = reg.exec(state.value);
              }
            }

            // 延时并再次调用push方法,实现延迟加载
            requestIdleCallback(push)
          }
          push()
        }
      });
    })

    function isSingleClosureTag(match) {
      return match ? false : state.value.includes('<br>' || '<hr>')
    }

    function handleSingleClosureTag() {
      const template = document.createElement('template')
      template.innerHTML = htmlString
      h3.appendChild(template.content)
      state.value = ''
    }

    function handleTextContent(match, isSingleClosureTag = false) {
      if (!match && !isSingleClosureTag) return

      let lIndex = 0;

      const nodeValue = h3.innerHTML.replace(/&lt;|&gt;/g, function (match) {
        return { '&lt;': '<', '&gt;': '>' }[match];
      });

      if (isSingleClosureTag) {
        lIndex = nodeValue.lastIndexOf(state.value)
      } else {
        lIndex = nodeValue.lastIndexOf(`<${match[1]}`)
      }

      const htmlString = nodeValue.slice(lIndex)
      const template = document.createElement('template')
      template.innerHTML = htmlString

      state.htmlStrList.push(htmlString)

      let elementContent = ''
      for (let i = h3.childNodes.length - 1; i >= 0; i--) {
        if (elementContent === htmlString) {
          break
        }

        const node = h3.childNodes[i]
        if (node.nodeType === 3) {
          elementContent = node.textContent + elementContent
        } else {
          state.tagList.push({
            node,
            value: node.textContent
          })
        }
        h3.removeChild(node)
      }
      h3.appendChild(template.content)

      state.value = ''
    }
  }
</script>

感谢观看