等待服务端响应请求

前端加载大文件等待的时间

在传统模式(古早)下前端加载的数据量较大时候等待时间比较长,影响体验。。

这里的代码,在调试工具限制网速下使用的

  function ajax(){
    $.ajax({
    url:"http://localhost/novel.txt",
    data:{},
    dataType:"TEXT",
    type:"GET",
    success:function(result){
      console.log(result);
    }
  })
  }
  ajax();

等待响应输出完毕,时间较长,白屏等待时间长

请求响应流程图

示意图.jpg

fetch api进行流处理

流处理概念

流处理在后端开发中也是一个常见的概念,它广泛应用于各种编程语言和服务器中,但是在前端js中实现的比较晚在2015年主流浏览器才支持

流处理(Stream processing)是指对数据流(数据在时间上连续产生的序列)的实时处理。在编程中,流可以包含多种数据元素,比如字节、字符、比特或更高层次的数据结构。

打比方go或php语言中。数据流存储到buffer里面

    // 读取客户端发送的数据
    var buf []byte
    tmp := make([]byte, 512) // 临时缓冲区,每次读取的数据先暂存到这里
    for {
        // 读取数据到临时缓冲区
        n, err := conn.Read(tmp)
        if err != nil {
            if err != io.EOF {
                log.Println("Failed to read from socket:", err)
            }
            break
        }
        // 将临时缓冲区的数据追加到 buf 中
        buf = append(buf, tmp[:n]...)
        // 如果读取的数据长度小于缓冲区大小,则说明数据已经读取完毕
        if n < len(tmp) {
            break
        }
    }

使用fetch进行流处理

下面代码使用了async和await

  async function load() {
    const url = "http://localhost/novel.txt";
    const resp = await fetch(url);
    const reader = resp.body.getReader();
    const decoder = new TextDecoder();

    let result = '';

    for (; ;) {
      const {value, done} = await reader.read();
      if (done) {
        break;
      }
      const text = decoder.decode(value);
      result += text;
      console.log(text);
    }

    // 确保将解码器的内部缓冲区中的任何剩余输入刷新为字符串
    result += decoder.decode();

  }

结果输出为

查看段落末尾

段落的末尾和开头看到了?字符、也就是乱码。
如何产生的?

原因就是流处理的最小单位是字节。而现在的文本基本格式是utf8编码,而utf8编码是可变字符长度最多4个长度。而流处理读到了这部分没有读完整。

解决办法也简单修过fetchoptions即可解决

图片经过删减

  async function loadNovel() {
    const url = "http://localhost/novel.txt";
    const resp = await fetch(url);
    const reader = resp.body.getReader();
    const decoder = new TextDecoder();

    let result = '';

    for (; ;) {
      const {value, done} = await reader.read();
      if (done) {
        break;
      }
      // 注意:此处传递 { stream: true } 以确保跨数据块的字符不会导致乱码
      const text = decoder.decode(value, {stream: true});
      result += text;
      console.log(text);
    }

    // 确保将解码器的内部缓冲区中的任何剩余输入刷新为字符串
    result += decoder.decode();

    console.log(result);
  }

配置{stream: true}解决了 ,但是这不是我要说的重点

注意!! 这不是重点
注意!! 这不是重点
注意!! 这不是重点

我想说的重点是如何不用{stream:true}来解决,这样能更好方便理解utf8和二进制字符读取

使用Uint8Array来处理utf8多字节字符

utf8编码

UTF-8 编码中,一个 "字符" 可能由一个或多个字节组成。UTF-8 使用以下比特模式作为字节的前缀,以标识字符的开始以及随后的字节:

  • 单字节字符(兼容 ASCII):0xxxxxxx
  • 多字节字符的开始字节:

    • 2 字节字符:110xxxxx
    • 3 字节字符:1110xxxx
    • 4 字节字符:11110xxx
    • 多字节字符的后续各字节均以:10xxxxxx 开始

了解 UTF-8 的这些编码规则使我们能够根据字节模式确定一个字符的边界

拿4字节字符的起始字节以11110xxx为例子

  • 起始字节:标记为11110xxx,其中xxx占3位,是该字符编码的一部分。
  • 后续字节:接下来的三个字节都将以10xxxxxx的形式出现,其中的xxxxxx各自占6位,均为字符编码的一部分。

一个字节(Byte)即1Byte 1Byte=8bit 一个字节等于8位等于8bit。

一个4字节字符的完整形式会是这样的:

11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

每个x代表一个二进制位,可以是0或1。4字节字符允许UTF-8编码较大的Unicode码点,可以涵盖更多的字符,包括许多亚洲文字符号以及各种表情符号。这种设计确保了UTF-8编码的灵活性和扩展性,能够表示成千上万的不同字符

Uint8Array使用

Uint8Array 用于两个主要目的:

  • 存储及拼接字节数据: Uint8Array 是一种类型化数组,用于表示一个8位无符号整数的数组。在这段代码中,它用于存储来自数据流的字节和处理 UTF-8 编码的文本数据。当有剩余的字节(即之前的数据块的末尾和新数据块的开始可能包含一个字符的一部分)时,Uint8Array 用于将前面块中剩余的字节和当前块的字节拼接在一起,以确保字符的完整性并避免乱码。
  • 找到完整的 UTF-8 字符边界: UTF-8 编码的多字节字符可能会分布在数据块的边界上。这个代码片段使用 Uint8Array 来检查字节流,并确定一个字符的起始点。UTF-8 编码中,一个字符由一个单字节(对于ASCII字符)或多个字节组成(对于非ASCII字符)。

因为一个字节等于8bit所以用Uint8Array更方便

代码实现如下:

  async function loadTxt() {
    const url = "http://localhost/novel.txt";
    const resp = await fetch(url);
    const reader = resp.body.getReader();
    const decoder = new TextDecoder('utf-8');
    let leftoverBytes;

    while (true) {
      const {value, done} = await reader.read();
      if (done) {
        if (leftoverBytes) {
          console.log(decoder.decode(leftoverBytes));
        }
        break;
      }

      let currentUint8Array = value;
      if (leftoverBytes) {
        console.log(leftoverBytes.length + value.length);
        //传递字节数
        currentUint8Array = new Uint8Array(leftoverBytes.length + value.length);
        currentUint8Array.set(leftoverBytes);
        currentUint8Array.set(value, leftoverBytes.length);
      }
      console.log(value);
      // 确定最后一个完整字符的结尾
      let lastCompleteCharIndex = currentUint8Array.length;
      // 从末尾开始逐字节减少搜索范围直到找到一个文本字符的起始字节
      for (let i = currentUint8Array.length - 1; i >= 0; i--) {
        if ((currentUint8Array[i] & 0xC0) !== 0x80) { // 寻找非后续字节
          lastCompleteCharIndex = i;
          break;
        }
      }

      // 解码最后一个完整字符以前的所有字符
      const completeValue = currentUint8Array.slice(0, lastCompleteCharIndex);
      // 由于用uint8array处理后,就不用{stream: true}这个配置了
      console.log(decoder.decode(completeValue));

      // 存储剩余的没有解码的字节
      leftoverBytes = currentUint8Array.slice(lastCompleteCharIndex);
    }
  }

UTF-8 的一个重要特性是它的自同步:多字节字符的第一个字节以二进制的“11”开始,后续字节都以“10”开始。检查字节流的 for 循环利用这个属性来确定最后一个完整字符的边界,避免将数据切成半个字符,从而导致解码错误。

使用 Uint8Array 是处理二进制数据的有效方式,特别是在需要精确控制字节级操作的场景中。在这种情况下,使用 Uint8Array 可以精确地处理和解析字节流中的 UTF-8 编码字符。通过正确识别字符边界,确保解码操作能够在数据块之间正确地恢复完整的文本信息。

关键代码解读

1


//length这个长度是字节
currentUint8Array = new Uint8Array(leftoverBytes.length + value.length);

//创建了一个新的 Uint8Array,其长度是 leftoverBytes 数组的长度加上新读取的 value 数组的长度。这样确保了有足够的空间来存储两者的数据。----- 单位 字节

currentUint8Array.set(leftoverBytes);
//将一个数组复制到 TypedArray。此行代码将 leftoverBytes 中的数据复制到新创建的 currentUint8Array 的开头位置

currentUint8Array.set(value, leftoverBytes.length);
//同样使用 set() 方法,但此时是将新读取的 value 数组的内容复制到 currentUint8Array 中,紧随 leftoverBytes 存储的数据之后。参数 leftoverBytes.length 指定了 value 复制内容的起始位置 

简单来说,这些代码的目的是将前一次读取操作剩余的部分字节和这一次新读取的字节合并在一起,形成一个连续的字节序列。这样处理主要是为了确保多字节字符不会被拆分,每次处理时都能够处理完整的字符。
假设 leftoverBytes 中包含了上一次读取操作剩余的字节,而 value 包含了这一次读取操作的字节。使用这种方法可以确保所有字节都能被适当地解码成字符串,即便某些多字节字符跨越了多次读取操作。

2


        if ((currentUint8Array[i] & 0xC0) !== 0x80) { // 寻找非后续字节
          lastCompleteCharIndex = i;
          break;
        }

currentUint8Array[i] & 0xC0实际上是将当前字节与11000000(即0xC0)进行按位与操作 。这个操作会保留当前字节的最高两位,其他位则变为0。

& 按位与(AND)运算是一个二进制操作,它对两个数中的对应位进行逻辑与操作,如果两位都为1,则结果位为1,否则为0。


  • 如果这个字节是随后字节(后续字节),其最高两位将会是10,与0xC0进行按位与操作的结果将会是0x80(即10000000)。
  • 如果这个字节是起始字节,其最高两位不会是10,与0xC0进行按位与操作的结果将不会是0x80(即10000000)。

所以,这个条件判断用来寻找起始字节,这意味着找到了一个完整字符的开始。当条件成立时(即!== 0x80),i指向的位置就是最后一个完整字符的起始字节,然后将其索引(lastCompleteCharIndex)记录下来,用于截取解码的字节序列。

Last modification:March 15, 2024
如果觉得我的文章对你有用,请随意赞赏