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
- Enter question text, click submit to request the large model
- Check Streaming to enable typewriter-style streaming output; uncheck to return the complete answer all at once
- Display a "Thinking..." loading state during the request process
- Pure native JS implementation of stream parsing, with no extra dependencies like axios or sse.js
- 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);
ref: Vue3's basic reactive API, handles primitive types like strings and numbers; the underlying instance isRefImpl, reading/writing the original value via.valuequestion: Two-way bound input box, stores the user's questioncontent: AI answer rendering container, continuously appends text in streaming modestream: Boolean switch, controls the interfacestreamparameter, toggling between the two return modes
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}`,
};
- DeepSeek official chat interface address, authentication uses Bearer Token
import.meta.env.VITE_XXX: Vite environment variable, stores the API key in a.envfile to avoid hardcoding and leaking the key
body: JSON.stringify({
model: "deepseek-v4-flash",
messages: [{ role: "user", content: question.value }],
stream: stream.value,
})
Interface parameter specification:
model: Specifies the model to callmessages: Array of conversation context,role: userrepresents the user's questionstream: Boolean value,trueenables SSE streaming fragmented return,falsereturns the complete result synchronously
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();
response.body: The Fetch response's built-inReadableStreambinary stream object, where the server continuously pushes binary data fragmentsgetReader(): Creates a stream reader, providing aread()method to pull data chunk by chunkTextDecoder: Browser built-in API, converts binary Uint8Array to UTF-8 strings, solving Chinese character garbled text issues
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)
done: Custom boolean flag, initiallyfalse, representing "whether the stream has finished reading"Loop condition: As long as the stream hasn't ended, continue executing the reading logic
Termination condition: Two scenarios will make
done = truereader.read()returnsdoneReading = true: All server data push is complete, the stream naturally closes- Manually catching the
data: [DONE]end marker, actively settingdone = trueto break out of the loop
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
}
value: The binary raw data pulled from the server this time, stored in aUint8Arraytype array. Chinese, English, and symbols all exist in binary byte form;done: doneReading: Renamed variable to prevent conflict with the outer customdone.true: The server data stream is completely closed, no more fragments will come;false: There is subsequent data, continue the loop to read.
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:
- Converts binary bytes to standard UTF-8 strings, perfectly solving the garbled text problem of multi-byte Chinese characters;
- Decoding a single chunk of
valuealone might produce truncated incomplete strings (network fragmentation cuts off in the middle of a Chinese character / a JSON line).
② 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
- Initial:
done = false,buffer = "", enter loop await reader.read()waits for the server to send a binary chunk, getsvaluebinary,doneReading = falsedone = false, loop continues- Binary converted to string, concatenated with empty buffer to get complete
chunkValue - Clear buffer, parse all
data:lines withinchunkValue - If there's an incomplete JSON during parsing, store the incomplete fragment in buffer
- 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:
- Packet 1:
data: {"delta":{"content":"I am goi - Packet 2:
ng to the park"}}\nParsing only a single packet would cause a JSON.parse error. Relying onbufferto concatenate fragments across loops ensures a complete JSON string is obtained before parsing, which is an essential fault-tolerance logic for stream processing.
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;
}
}
data: [DONE]: Server stream end identifier, terminates the reading loopdelta.content: Streaming-specific incremental field, returns only newly added text each time, continuously appending tocontentto achieve the typewriter effecttry/catchfault tolerance: JSON parsing failure means the fragment is incomplete, store it in buffer to wait for the next round's merge
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
- The input box uses
v-modelfor two-way binding withquestion, the button binds a click event to trigger the request - The checkbox
v-modelbinds tostream, toggling the streaming switch with one click {{ content }}reactively renders the AI answer, the page automatically refreshes when the data stream updates
Style
- Uses flex vertical layout, adapting to the full page height
- The output area sets a minimum height, reserving space for answer display
- Basic size and margin optimizations ensure basic interactive appearance
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
- CORS Error Directly requesting the DeepSeek interface from local frontend development will trigger CORS. Solutions:
- Configure a local proxy in Vite to forward interface requests
- Build a backend relay interface; the frontend requests its own service, which then calls the large model API
- Chinese Garbled Text Must use
TextDecoderto parse binary fragments; cannot directly convert to string, as binary fragments cannot correctly identify multi-byte Chinese characters. - JSON Parsing Error Network fragmentation truncates a single line of JSON. Rely on the
bufferto cache fragments; do not directly discard lines that fail to parse. - Stream Reading No Response Check if
response.bodyexists. Older browser versions do not supportReadableStream; a fallback logic can be added to switch to non-streaming mode.
VI. Core Technical Principle Summary
- 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
- ReadableStream: Browser native stream processing API, no need to load the entire response data at once, fragment processing saves memory
- Vue Reactive Updates: Each time
deltaincremental text is appended,reftriggers a partial DOM update, achieving a real-time typing effect - 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