跪拜 Guibai
← Back to the summary

Streaming LLM Responses in Vue3 Without Third-Party Libraries

Now mainstream large model APIs all support SSE streaming output, which can achieve a typewriter-like real-time response effect, providing a much better experience than waiting for a complete result all at once. This article is based on Vue3 <script setup> Composition API, using native Fetch + ReadableStream without relying on third-party libraries, to fully implement dual-mode normal return / streaming switching for the DeepSeek interface, with line-by-line code breakdown and underlying principle explanations.

I. Overall Implementation Effect

  1. Enter question text, click submit to request the large model
  2. Check Streaming to enable typewriter-style streaming output; uncheck to return the complete answer all at once
  3. Display a "Thinking..." loading state during the request process
  4. Pure native JS implementation of stream parsing, with no extra dependencies like axios or sse.js
  5. Complete encapsulation of interface requests, binary stream decoding, and segmented text concatenation logic

II. Detailed Analysis of Segmented Code

3.1 Reactive Data Definition

const question = ref("Tell a story about a Chinese dragon");
const content = ref("");
const stream = ref(true);

3.2 Basic Request Encapsulation

const endpoint = "https://api.deepseek.com/chat/completions";
const headers = {
  "Content-Type": "application/json",
  Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
};
body: JSON.stringify({
  model: "deepseek-v4-flash",
  messages: [{ role: "user", content: question.value }],
  stream: stream.value,
})

Interface parameter specification:

3.3 Non-Streaming Logic (Synchronous One-Time Return)

const data = await response.json();
content.value = data.choices[0].message.content;

When streaming is off, the interface blocks and waits for the AI to fully generate the answer before returning JSON. It directly reads the complete text from message.content and assigns it. The downside is long wait times for long texts with no real-time feedback.

3.4 Streaming Output Core: ReadableStream Data Flow Processing

1. Get Binary Reader and Decoder

const reader = response.body?.getReader();
const decoder = new TextDecoder();

2. Loop Reading Fragments - In-Depth Line-by-Line Code Breakdown

while (!done) {
  const { value, done: doneReading } = await reader?.read();
  done = doneReading;
  const chunkValue = buffer + decoder.decode(value);
  buffer = "";
}

Overall Logic Overview

while (!done) is an infinite loop for reading binary data streams. As long as the server continues to return large model token fragments, the loop will not stop; it only stops when the server pushes the end identifier [DONE] or the stream reading is complete, making done become true and breaking the loop. The essence of the entire process: Pull a small chunk of binary data → concatenate with buffer → convert to string → parse text → continue pulling the next chunk.

Line-by-Line Breakdown

1. while (!done)

2. const { value, done: doneReading } = await reader?.read();

reader?.read() Optional Chaining ?.

response.body?.getReader() might return undefined (older browsers don't support ReadableStream). Using optional chaining avoids a read() is not a function error, providing a compatibility fallback.

read() is an asynchronous method, must use await

reader.read() will block and wait until the browser receives one binary fragment sent by the server before resolving and returning a result. If there is no new data currently, the code will suspend and wait, not run an infinite empty loop, and not consume CPU.

③ Return Value Destructuring { value, done: doneReading }

After execution, read() returns a Promise, which upon success yields an object:

{
  value: Uint8Array | undefined, // The binary byte array obtained this time
  done: boolean // Native stream end marker, renamed to doneReading to distinguish from the custom done variable
}

3. done = doneReading;

Synchronizes the native stream's end state to the outer loop marker done. When the server transmission is complete, doneReading is true. After assignment, the next round's while (!done) condition is false, and the loop terminates.

4. const chunkValue = buffer + decoder.decode(value);

decoder.decode(value)

TextDecoder, a browser native API, specifically parses Uint8Array binary arrays:

buffer + decoded string

buffer is a global cache variable, specifically used to store incomplete, unparseable string fragments left over from the previous round. Example scenario: The end of the last fragment only read {"delta":{"content":"Hello, the JSON is incomplete, parsing fails, and it's stored in buffer; The beginning of the new fragment this round is World"}}, after concatenation it becomes {"delta":{"content":"Hello World"}}, forming a complete, parseable JSON.

5. buffer = "";

After the current round's buffer + new fragment are concatenated into chunkValue, clear the cache. If incomplete JSON is encountered again during the subsequent parsing of chunkValue, the incomplete fragment is written back into buffer, waiting for the next round's concatenation.

Complete Operation Flow Example

  1. Initial: done = false, buffer = "", enter loop
  2. await reader.read() waits for the server to send a binary chunk, gets value binary, doneReading = false
  3. done = false, loop continues
  4. Binary converted to string, concatenated with empty buffer to get complete chunkValue
  5. Clear buffer, parse all data: lines within chunkValue
  6. If there's an incomplete JSON during parsing, store the incomplete fragment in buffer
  7. Return to the beginning of the loop, pull the next binary fragment again, automatically concatenating with the previous round's incomplete cache

Key Design Purpose: buffer Fragment Fault Tolerance

During network transmission, one complete line of SSE data from the server might be split into 2-3 binary packets sent separately:

3. Filter SSE Standard Data Lines

const lines = chunkValue
  .split("\n")
  .filter((line) => line.startsWith("data: "));

Large model SSE protocol specification: Each piece of data starts with data: , separated by newlines. Filters out irrelevant empty lines and comment lines, keeping only valid business data.

4. JSON Parsing and Incremental Text Concatenation

for (const line of lines) {
  if (line === "data: [DONE]") {
    done = true;
    break;
  }
  const jsonStr = line.replace("data: ", "");
  try {
    const resJson = JSON.parse(jsonStr);
    const deltaText = resJson.choices[0].delta.content || "";
    content.value += deltaText;
  } catch (err) {
    buffer += line;
  }
}

III. Complete Runnable Code

<script setup>
// vue3 composition api
// Business logic aggregation, distinct from Vue2's options-based scattered writing
import { ref } from "vue";

// Reactive data
const question = ref("Tell a story about a Chinese dragon"); // User question
const content = ref(""); // AI returned content
const stream = ref(true); // Streaming output toggle switch

// Core request function
const update = async () => {
  // Empty input interception
  if (!question.value) return;
  content.value = "Thinking...";

  // DeepSeek interface address
  const endpoint = "https://api.deepseek.com/chat/completions";
  // Request headers, carrying authentication key
  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  };

  // Initiate POST request
  const response = await fetch(endpoint, {
    method: "POST",
    headers,
    body: JSON.stringify({
      model: "deepseek-v4-flash",
      messages: [{ role: "user", content: question.value }],
      stream: stream.value, // Switch between streaming/one-time return based on toggle
    }),
  });

  // Branch 1: Streaming output processing logic
  if (stream.value) {
    content.value = "";
    // Get response binary reader
    const reader = response.body?.getReader();
    // Binary to UTF-8 text decoder
    const decoder = new TextDecoder();
    let done = false;
    let buffer = ""; // Fragment buffer, prevents single-line data truncation

    // Loop to read data stream until the stream ends
    while (!done) {
      // Read one chunk of binary fragment
      const { value, done: doneReading } = await reader?.read();
      done = doneReading;
      // Concatenate buffer + current fragment, convert to string
      const chunkValue = buffer + decoder.decode(value);
      buffer = "";

      // Filter SSE standard data: prefix lines
      const lines = chunkValue
        .split("\n")
        .filter((line) => line.startsWith("data: "));

      // Iterate through each line of fragmented JSON
      for (const line of lines) {
        // Server end identifier, terminate loop
        if (line === "data: [DONE]") {
          done = true;
          break;
        }
        // Extract the JSON string after "data: "
        const jsonStr = line.replace("data: ", "");
        try {
          const resJson = JSON.parse(jsonStr);
          // Append incremental text delta to page content
          const deltaText = resJson.choices[0].delta.content || "";
          content.value += deltaText;
        } catch (err) {
          // JSON parsing failed, store in buffer for next round concatenation
          buffer += line;
        }
      }
    }
  } else {
    // Branch 2: Non-streaming, get complete JSON at once
    const data = await response.json();
    content.value = data.choices[0].message.content;
  }
};
</script>

<template>
  <div class="container">
    <!-- Question input area -->
    <div>
      <label>Input:</label>
      <input class="input" v-model="question" />
      <button @click="update">Submit</button>
    </div>

    <!-- Output control and display area -->
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
      </div>
      <div>{{ content }}</div>
    </div>
  </div>
</template>

<style>
.container {
  /* flex vertical layout, conforms to document flow */
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
</style>

IV. Template and Style Explanation

Template

  1. The input box uses v-model for two-way binding with question, the button binds a click event to trigger the request
  2. The checkbox v-model binds to stream, toggling the streaming switch with one click
  3. {{ content }} reactively renders the AI answer, the page automatically refreshes when the data stream updates

Style

V. Environment Configuration and Pitfall Guide

5.1 Environment Variable Configuration

Create a .env file in the project root directory:

VITE_DEEPSEEK_API_KEY=Your DeepSeek Key

After restarting the project, import.meta.env can read the key normally.

5.2 Common Problem Solutions

  1. CORS Error Directly requesting the DeepSeek interface from local frontend development will trigger CORS. Solutions:
  1. Chinese Garbled Text Must use TextDecoder to parse binary fragments; cannot directly convert to string, as binary fragments cannot correctly identify multi-byte Chinese characters.
  2. JSON Parsing Error Network fragmentation truncates a single line of JSON. Rely on the buffer to cache fragments; do not directly discard lines that fail to parse.
  3. Stream Reading No Response Check if response.body exists. Older browser versions do not support ReadableStream; a fallback logic can be added to switch to non-streaming mode.

VI. Core Technical Principle Summary

  1. SSE Streaming Communication: The server maintains a long connection to continuously push incremental data, which is more suitable for large model text output scenarios compared to polling or WebSocket
  2. ReadableStream: Browser native stream processing API, no need to load the entire response data at once, fragment processing saves memory
  3. Vue Reactive Updates: Each time delta incremental text is appended, ref triggers a partial DOM update, achieving a real-time typing effect
  4. Composition API Advantages: Interface requests, stream processing, and state control logic are all aggregated in one place, making the code easier to maintain and reuse compared to Vue2's Options API