uni-app 流式输出实战:enableChunked + SSE 打通小程序与 H5 大模型对话

2026-10-09 0 193

前段时间把一个 AI 陪聊形态的小程序从「转圈等 5 秒、然后一次性吐出一大坨字」改成了逐字往外冒。听起来只是换个请求方式,实际上从抓包到真机验证折腾了两天。中间最耗时的不是接 API,而是三件很小但很恶心的事:多字节汉字被切成两半、SSE 事件被拆到两个 chunk 里、以及每收到一个分片就改一次响应式数据导致列表抖成筛子。

这篇把整套管线拆开写一遍,代码可以直接抄走用。

问题出在哪:小程序没有 EventSource

浏览器里做流式输出很轻松,EventSource 或者 fetch 拿到 ReadableStream 就完事。但这两个东西小程序运行时里都没有。小程序也不是完全不给活路,它在基础库 2.20.2 之后给 RequestTask 加了一个 onChunkReceived 回调,配合请求参数 enableChunked: true,服务端每 flush 一次数据,你就能拿到一个 ArrayBuffer。

这就是全部家当了。没有 TextDecoder(老版本没有,新版本有但兼容性不齐),没有内置的 SSE 解析,拿到手的是一堆裸字节。

所以整条链路是这样的:

uni.request(enableChunked)
        ↓
   onChunkReceived(ArrayBuffer)
        ↓
   ① UTF-8 增量解码(处理跨包多字节字符)
        ↓
   ② SSE 帧切分(处理跨包事件分隔符)
        ↓
   ③ 单帧字段解析(data / event / [DONE])
        ↓
   ④ JSON 提取 delta.content
        ↓
   ⑤ 渲染节流(40ms 批量刷一次)
        ↓
      视图

下面一步一步来。

第一步:把 uni.request 变成一条不关闭的水管

关键参数就两个:enableChunked: true 和 responseType: 'arraybuffer'。前者打开分片能力,后者保证 success 回调里拿到的也是二进制而不是被提前转成字符串的残体。

const task = uni.request({
  url: 'https://your-api.com/v1/chat/completions',
  method: 'POST',
  enableChunked: true,
  responseType: 'arraybuffer',
  header: {
    'Content-Type': 'application/json',
    'Accept': 'text/event-stream'
  },
  data: {
    model: 'gpt-4o-mini',
    messages: [{ role: 'user', content: '你好' }],
    stream: true
  },
  success: () => {
    // 注意:这里的 res.data 是整包聚合结果
    // 我们已经通过 onChunkReceived 增量消费过了,不再处理
  },
  fail: (err) => {
    console.error('请求失败', err)
  }
})

if (typeof task.onChunkReceived !== 'function') {
  // 老基础库 / 部分小程序平台没有这个能力,走降级
  task.abort()
  throw new Error('当前环境不支持分块返回')
}

task.onChunkReceived((res) => {
  // res.data 是 ArrayBuffer,不是字符串
  console.log(res.data.byteLength)
})

有两个点容易忽略。一是 success 回调照样会触发,而且触发在最后,别在里面重复追加内容,否则整段回答会被写两遍。二是 enableChunked 目前主要在微信小程序端和 App 端可用,支付宝小程序、部分快应用上没有对应实现,所以能力探测这一步不能省。

第二步:UTF-8 增量解码,最容易翻车的地方

拿到 ArrayBuffer 之后第一反应通常是转成字符串。小程序的 TextDecoder 支持情况不稳定,很多人会写一个手动的转换:

// 这种写法在流式场景里一定会出问题
function ab2str(buffer) {
  return String.fromCharCode.apply(null, new Uint8Array(buffer))
}

问题在于,服务端 flush 的边界是按缓冲区大小来的,不是按字符边界来的。一个 UTF-8 汉字占 3 个字节,em 表情占 4 个字节,它们完全可能被劈成 [0xE4, 0xBD] 和 [0xA0] 两片分两次发过来。上面那段代码会把第一个分片解成一个乱码字符,第二个分片再解成一个乱码字符——屏幕上就会莫名多出几个「锟斤拷」。

正确做法是带上「残留字节」的状态机:遇到不完整的序列就把尾部字节存起来,等下一次分片拼上再解。

function createUTF8Decoder() {
  // 上一次没解完的尾部字节
  let carry = new Uint8Array(0)

  return {
    decode(arrayBuffer) {
      const incoming = new Uint8Array(arrayBuffer)

      // 把上次残留的字节拼到最前面
      let bytes
      if (carry.length) {
        bytes = new Uint8Array(carry.length + incoming.length)
        bytes.set(carry, 0)
        bytes.set(incoming, carry.length)
      } else {
        bytes = incoming
      }

      let out = ''
      let i = 0

      while (i < bytes.length) {
        const b = bytes[i]

        // ASCII 直接过
        if (b < 0x80) {
          out += String.fromCharCode(b)
          i += 1
          continue
        }

        // 判断这个首字节后面还跟着几个字节
        let extra = 0
        let cp = 0
        if ((b & 0xe0) === 0xc0) {        // 110xxxxx → 2 字节
          extra = 1
          cp = b & 0x1f
        } else if ((b & 0xf0) === 0xe0) { // 1110xxxx → 3 字节
          extra = 2
          cp = b & 0x0f
        } else if ((b & 0xf8) === 0xf0) { // 11110xxx → 4 字节
          extra = 3
          cp = b & 0x07
        } else {
          // 非法首字节,跳过,避免死循环
          i += 1
          continue
        }

        // 剩余字节不够,说明这个字符被切开了,留给下一次
        if (i + extra >= bytes.length) break

        let valid = true
        for (let k = 1; k <= extra; k++) {
          const nb = bytes[i + k]
          if ((nb & 0xc0) !== 0x80) {
            valid = false
            break
          }
          cp = (cp << 6) | (nb & 0x3f)
        }

        if (!valid) {
          // 不是合法的续字节,丢弃当前首字节继续
          i += 1
          continue
        }

        out += String.fromCodePoint(cp)
        i += extra + 1
      }

      // 把没消费完的尾部存起来,最多 3 个字节
      const rest = bytes.slice(i)
      carry = rest.length > 3 ? new Uint8Array(0) : rest

      return out
    },

    reset() {
      carry = new Uint8Array(0)
    }
  }
}

这段代码里 if (i + extra >= bytes.length) break 是整个函数的核心。它表示「这个字符的后半截还没到」,此时直接跳出循环,把从 i 开始的所有字节塞进 carry,下一次再拼上。

另外 carry 做了上限保护。正常情况下它最多存 3 个字节,如果超过说明流里出现了非法数据,直接清空避免无限堆积。

第三步:SSE 帧的切分与解析

解码出文本之后,不能直接 split('nn')。原因和上面一样——分隔符本身可能被拆到两个分片里,你这一片结尾只有 n,下一片开头才是另一个 n。

所以同样要用缓冲区:不够切就先攒着。

function createSSEParser() {
  let buffer = ''

  return {
    push(text) {
      buffer += text
      const frames = []

      while (true) {
        const a = buffer.indexOf('nn')
        const b = buffer.indexOf('rnrn')

        let idx = -1
        let sepLen = 2

        if (a !== -1 && b !== -1) {
          if (b < a) { idx = b; sepLen = 4 } else { idx = a; sepLen = 2 }
        } else if (a !== -1) {
          idx = a
          sepLen = 2
        } else if (b !== -1) {
          idx = b
          sepLen = 4
        } else {
          break
        }

        frames.push(buffer.slice(0, idx))
        buffer = buffer.slice(idx + sepLen)
      }

      return frames
    },

    reset() {
      buffer = ''
    }
  }
}

拿到一个完整帧之后,按 SSE 规范解析字段。data: 后面的内容才是载荷,冒号后如果有一个空格要去掉;以冒号开头的行是注释,很多服务端用它当心跳,直接忽略。

function parseFrame(frame) {
  const lines = frame.split(/r?n/)
  const dataLines = []
  let done = false

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]

    // 空行、心跳注释行跳过
    if (!line || line.charAt(0) === ':') continue

    const colon = line.indexOf(':')
    const field = colon === -1 ? line : line.slice(0, colon)
    let value = colon === -1 ? '' : line.slice(colon + 1)

    // SSE 规范:冒号后紧跟一个可选空格
    if (value.charAt(0) === ' ') value = value.slice(1)

    if (field === 'data') {
      if (value === '[DONE]') {
        done = true
        continue
      }
      dataLines.push(value)
    }
  }

  if (done) return { done: true, payload: null }
  if (!dataLines.length) return { done: false, payload: null }

  return { done: false, payload: dataLines.join('n') }
}

注意 dataLines.join('n') 这一句。SSE 允许一个事件里带多行 data:,规范要求用换行拼接。虽然 OpenAI 兼容格式的服务端基本不会这么发,但加上这行几乎没成本,遇上时也不至于解析失败。

第四步:把整条链路封装成 composable

上面三块是纯函数,可以单独放一个文件。真正组合起来的部分用一个 composable 收口,页面里只关心 content 和 loading。

// composables/useChatStream.js
import { ref } from 'vue'

export function useChatStream(options) {
  const {
    url,
    headers = {},
    model = '',
    extraBody = {}
  } = options || {}

  const content = ref('')
  const loading = ref(false)
  const errorMsg = ref('')

  let requestTask = null
  let abortController = null
  let decoder = null
  let parser = null
  let active = false

  // 渲染节流用的待刷缓冲区
  let pending = ''
  let flushTimer = null

  function scheduleFlush() {
    if (flushTimer) return
    flushTimer = setTimeout(() => {
      flushTimer = null
      if (!pending) return
      content.value += pending
      pending = ''
    }, 40)
  }

  function flushNow() {
    if (flushTimer) {
      clearTimeout(flushTimer)
      flushTimer = null
    }
    if (pending) {
      content.value += pending
      pending = ''
    }
  }

  function handleText(text) {
    const frames = parser.push(text)

    for (let i = 0; i < frames.length; i++) {
      const { done, payload } = parseFrame(frames[i])

      if (done) {
        finish()
        return
      }

      if (!payload) continue

      let json
      try {
        json = JSON.parse(payload)
      } catch (e) {
        // 极少数服务端会发非 JSON 的 data 行,丢掉即可
        continue
      }

      const choice = json.choices && json.choices[0]
      const delta = choice && choice.delta && choice.delta.content

      if (delta) {
        pending += delta
        scheduleFlush()
      }
    }
  }

  function finish() {
    if (!active) return
    active = false
    flushNow()
    loading.value = false
    requestTask = null
    abortController = null
  }

  function stop() {
    active = false
    flushNow()
    if (requestTask && typeof requestTask.abort === 'function') {
      try { requestTask.abort() } catch (e) {}
    }
    if (abortController) {
      try { abortController.abort() } catch (e) {}
    }
    requestTask = null
    abortController = null
    loading.value = false
  }

  function send(messages) {
    stop()

    content.value = ''
    errorMsg.value = ''
    pending = ''
    loading.value = true
    active = true

    decoder = createUTF8Decoder()
    parser = createSSEParser()

    const body = Object.assign({
      model,
      messages,
      stream: true
    }, extraBody)

    // #ifdef H5
    return sendByFetch(body)
    // #endif

    // #ifndef H5
    return sendByChunked(body)
    // #endif
  }

  function sendByChunked(body) {
    return new Promise((resolve) => {
      let settled = false
      const done = () => {
        if (settled) return
        settled = true
        finish()
        resolve()
      }

      requestTask = uni.request({
        url,
        method: 'POST',
        enableChunked: true,
        responseType: 'arraybuffer',
        header: Object.assign({
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream'
        }, headers),
        data: body,
        success: () => done(),
        fail: (err) => {
          errorMsg.value = (err && err.errMsg) || '请求失败'
          done()
        }
      })

      if (!requestTask || typeof requestTask.onChunkReceived !== 'function') {
        errorMsg.value = '当前运行环境不支持分块返回,请升级基础库'
        if (requestTask && requestTask.abort) requestTask.abort()
        done()
        return
      }

      requestTask.onChunkReceived((res) => {
        if (!active) return
        const text = decoder.decode(res.data)
        if (text) handleText(text)
      })
    })
  }

  async function sendByFetch(body) {
    abortController = new AbortController()

    try {
      const resp = await fetch(url, {
        method: 'POST',
        headers: Object.assign({
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream'
        }, headers),
        body: JSON.stringify(body),
        signal: abortController.signal
      })

      if (!resp.ok) throw new Error('HTTP ' + resp.status)

      const reader = resp.body.getReader()
      const textDecoder = new TextDecoder('utf-8')

      while (active) {
        const { done, value } = await reader.read()
        if (done) break
        const text = textDecoder.decode(value, { stream: true })
        if (text) handleText(text)
      }
    } catch (e) {
      if (active && e.name !== 'AbortError') {
        errorMsg.value = e.message || '请求失败'
      }
    } finally {
      finish()
    }
  }

  return { content, loading, errorMsg, send, stop }
}

H5 那边其实用原生 TextDecoder 就够了,它的 stream: true 参数本身就带残留字节处理能力,不需要自己写状态机。所以 #ifdef H5 分支里直接走 fetch + ReadableStream,逻辑更短也更稳。

第五步:渲染节流,别让视图跟着分片一起抖

真正跑起来之后你会发现,接口分片来得比想象的密。一次几百毫秒里能到十几个 chunk,每个 chunk 里可能就一两个字。如果每个 chunk 都直接 content.value += delta,Vue 的响应式系统会触发十几次组件更新,低端安卓机上的表现就是文字一跳一跳的,光标位置也跟着飘。

上面代码里的 scheduleFlush 就是在解决这个:所有增量先写进 pending 字符串,用一个 40ms 的定时器批量落到 content.value。40ms 大约是 25fps,人眼已经感觉不出卡顿,但渲染次数少了一个数量级。

流结束或者被中断的时候必须调一次 flushNow(),把最后那点还在 pending 里的字吐出来,否则回答会缺个尾巴。

页面里用起来是这样:

<template>
  <view class="page">
    <scroll-view
      class="chat"
      scroll-y
      :scroll-top="scrollTop"
      :scroll-with-animation="false"
    >
      <view v-for="(item, idx) in history" :key="idx" class="msg">
        <text>{{ item.content }}</text>
      </view>

      <!-- 流式中的消息单独一个节点,避免整列表重渲染 -->
      <view v-if="streaming" class="msg">
        <text>{{ content }}</text>
      </view>
    </scroll-view>

    <view class="input-bar">
      <input v-model="draft" placeholder="说点什么" />
      <button v-if="!loading" @click="onSend">发送</button>
      <button v-else @click="stop">停止</button>
    </view>
  </view>
</template>

<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { useChatStream } from '@/composables/useChatStream'

const draft = ref('')
const history = ref([])
const streaming = ref(false)
const scrollTop = ref(0)

let scrollCursor = 0

const { content, loading, errorMsg, send, stop } = useChatStream({
  url: 'https://your-api.com/v1/chat/completions',
  headers: { Authorization: 'Bearer sk-xxxx' },
  model: 'gpt-4o-mini'
})

// 内容每刷新一次,把滚动位置往下推
watch(content, () => {
  nextTick(() => {
    scrollCursor += 5000
    scrollTop.value = scrollCursor
  })
})

async function onSend() {
  const text = draft.value.trim()
  if (!text || loading.value) return

  draft.value = ''
  history.value.push({ role: 'user', content: text })

  streaming.value = true
  await send(history.value)

  history.value.push({ role: 'assistant', content: content.value })
  content.value = ''
  streaming.value = false

  if (errorMsg.value) {
    uni.showToast({ title: errorMsg.value, icon: 'none' })
  }
}

onUnmounted(() => {
  // 页面走了必须断流,否则请求会一直挂着
  stop()
})
</script>

注意模板里那个「流式消息单独一个节点」的处理。如果把正在流式输出的内容塞进 history 数组里,每次更新都会让 v-for 整段重新比对一遍,历史消息越多越卡。拆成单独节点之后,只有那一个 text 会重新渲染。

滚动那块用的是一个取巧但很管用的办法:scroll-top 每次都在上一次的值上再加 5000,保证数值一直在变,从而每次都会触发滚动到底部。如果只给一个固定的大数字,第二次赋值时值没变,scroll-view 不会响应。再配合 :scroll-with-animation="false",就不会出现滚到一半又被新内容拽回去的抽搐感。

第六步:降级方案也得留一条

不是所有端都有 enableChunked。支付宝小程序、部分老版本基础库、还有一些 App 的旧 WebView,都不支持。这时候不能直接白屏,得有条退路。

最简单的降级是退回普通请求,等完整响应回来之后,用定时器把字符串按固定速度往外吐,模拟出逐字的效果。视觉体验差一点,但功能是完整的。

function fallbackRequest(body) {
  return new Promise((resolve) => {
    uni.request({
      url,
      method: 'POST',
      header: Object.assign({
        'Content-Type': 'application/json'
      }, headers),
      data: body,
      success: (res) => {
        const full = res.data && res.data.choices
          && res.data.choices[0]
          && res.data.choices[0].message
          && res.data.choices[0].message.content || ''

        let i = 0
        const timer = setInterval(() => {
          if (i >= full.length) {
            clearInterval(timer)
            finish()
            resolve()
            return
          }
          // 一次吐 2 个字符,比逐字更接近真实感
          content.value += full.slice(i, i + 2)
          i += 2
        }, 30)
      },
      fail: () => {
        errorMsg.value = '请求失败'
        finish()
        resolve()
      }
    })
  })
}

在 send 里判断一下 onChunkReceived 是否可用,不可用就走这条。用户基本感知不到差别,只是第一句话出现得晚一点。

真机上的几个坑

1. 开发者工具和真机行为不完全一致。 开发者工具里 onChunkReceived 的分片粒度通常更细,真机上(尤其 iOS)可能一次给你几百字节。所以测试时务必在真机上跑一遍,看有没有乱码和吞字。

2. 后端的缓冲策略会毁掉整个体验。 有些网关和 Nginx 默认会攒够一定字节才转发,导致你以为自己接的是流式,实际上是分两三批一次性到齐。服务端记得加 X-Accel-Buffering: no,响应头里也要有 Cache-Control: no-cache 和 Content-Type: text/event-stream。

3. 别在 success 里再拼一次内容。 前面提过,但还是有人踩。success 的 res.data 是整个响应的聚合结果,你已经在 onChunkReceived 里消费过了,再拼一次就是双份。

4. 页面卸载必须断流。 用户点了返回,请求还挂在那儿,回调还在往已经销毁的组件里写数据,轻则内存泄漏,重则报「Cannot read property of undefined」。onUnmounted 里调一次 stop(),task.abort() 在小程序端是有效的。

5. JSON 解析失败不要抛异常。 服务端偶尔会塞一些非标准的心跳或统计行,JSON.parse 直接 try/catch 掉继续就行。整条流因为一行脏数据崩掉是最亏的。

6. 长对话要裁剪历史。 content 会随着回答越来越长,如果多轮对话把全量历史都堆在页面上,滚动和重排都会变慢。一般保留最近 10 到 20 轮就够,更早的可以折叠或者直接丢掉。

写在最后

这套方案的核心其实就三件事:让请求别提前关闭、让解码器记住没解完的字节、让 SSE 解析器记住没切完的分片。状态机一旦立住,剩下的都是包装。

代码我尽量写得能直接落地,useChatStream 那个文件复制到项目里,改改 URL 和 header 就能跑。真正需要你自己调的通常是节流间隔(40ms 是个比较稳的值)和滚动策略,这两个跟你的页面结构关系比较大。

如果在真机上遇到什么奇怪的表现,优先怀疑两件事:服务端有没有真的在 flush,以及你的解码器有没有正确处理 carry。这两处占了流式输出里九成的疑难杂症。

uni-app 流式输出实战:enableChunked + SSE 打通小程序与 H5 大模型对话
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 流式输出实战:enableChunked + SSE 打通小程序与 H5 大模型对话 https://www.taomawang.com/web/uniapp/2914.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务