跪拜 Guibai
← Back to the summary

Streaming AI Output in Vue 3: The ReadableStream Pipeline Every Frontend Dev Needs

Frontend Must-Know: AI Streaming Output Principles and Vue 3 Practice 🚀

Why can ChatGPT and DeepSeek spit out answers word by word like a typewriter? As a frontend engineer, this is precisely your responsibility.


1. First, Experience It: Streaming vs. Non-Streaming

Open an AI application and ask "Write me a story about a Chinese dragon," then click send:

The total time is roughly the same, but the experience is worlds apart. The reason is—

The user sees words popping out → "It's working" → The perceived waiting time is greatly reduced.

This is the core value of streaming output: not shortening absolute time, but shortening the user's perceived waiting time. This is also why almost all chatbot and AI assistant products use streaming output—it is the "first impression" of an AI product.

📌 Quick Knowledge Points

Comparison Dimension Non-Streaming Output Streaming Output
Response Method All generated, returned at once Each generated token sent immediately
User Experience Blank wait → sudden appearance Character-by-character display, typewriter feel
Time to First Byte (TTFB) Wait until fully complete Almost instant
Frontend Handling response.json() done in one line Requires ReadableStream + TextDecoder
Applicable Scenarios Non-real-time scenarios AI conversations, real-time push

2. Underlying Principles—Connecting a Pipe 💧

2.1 The Water Pipe Analogy

The LLM server is a large reservoir, the chat client is your faucet. A water pipe (network connection) connects them in the middle.

Large Language Models (LLMs) generate content token by token—each time only inferring the next most likely token, not "thinking up" the entire paragraph at once:

"I" → "I am" → "I am very" → "I am very happy"

Each generated token is immediately sent out through the "water pipe." The entire pipeline:

LLM token-by-token inference → Server sends immediately → Network transmission → Frontend reads chunk → Appends to interface

2.2 Protocol Agreement: stream: true

Streaming output is an agreement between the client and the server:

body: JSON.stringify({
  model: 'deepseek-v4-flash',
  messages: [{ role: 'user', content: question.value }],
  stream: stream.value  // 🔑 Just this one parameter controls streaming/non-streaming
})

2.3 Data Format: SSE (Server-Sent Events)

Streaming output uses the SSE protocol at the HTTP level. The raw data looks like this:

data: {"choices":[{"delta":{"content":"I"}}]}

data: {"choices":[{"delta":{"content":"am"}}]}

data: {"choices":[{"delta":{"content":"very"}}]}

data: [DONE]

Four rules:

💡 Open Chrome DevTools → Network → Find this request → Look at the Response tab, the data "grows" segment by segment, rather than appearing all at once.


3. Why Build from Scratch? — Project Initialization in the Agent Era

Before writing code, let's talk about the ideological level.

3.1 Don't Write Vue Projects from Scratch

Entering the Agent development era, we don't need to start from a blank file writing index.html, App.vue. We should:

  1. Use npm create vite@latest to generate a project scaffold with one click
  2. Or pull a template project from GitHub

Leave the project initialization drudgery to tools, and save your energy for business logic.

3.2 Hot Reload

The Vite dev server provides hot reload capability: file modification → automatic partial refresh. Change HelloWorld.vue, and see the effect immediately in the browser without manually refreshing the page. This is the foundational experience of modern frontend development.


4. Vue 3 Quick Start (Beginner Friendly) 📗

Before looking at the core code, understand Vue 3 in 3 minutes.

4.1 The Three Blocks of a .vue Single File Component (SFC)

A .vue file is like a Lego brick, encapsulating HTML, CSS, and JS together to form a reusable Component.

┌────────────────────────────────┐
│  <template>   Template / HTML   │  ← Defines structure, can bind data {{ }}
│  </template>                   │     Can bind events @click
│                                │
│  <script setup>  Script / JS    │  ← Defines data and business logic
│  </script>                     │     ref() creates reactive data
│                                │
│  <style>   Style / CSS         │  ← Defines appearance
│  </style>                      │
└────────────────────────────────┘

A component is the smallest unit of work that makes up a page—no longer at the granularity of HTML tags, but at the granularity of "a functional building block."

4.2 Data-Driven Thinking

Old-school approach (manual DOM manipulation):

document.getElementById('result').innerHTML = 'New content'; // Manual change, tiring and error-prone

Vue approach:

<div>{{ content }}</div>   <!-- Bind data. When data changes, the interface automatically updates -->
const content = ref('');     // Create a reactive data item
content.value = 'New content';     // Only change the data! Don't worry about the DOM! Vue updates the interface for you

This is data-driven views—you just manage the data, and the interface automatically follows.

4.3 Reactive

Data wrapped by ref() is reactive. ref(0) returns a RefImpl object, with the actual value stored in .value. When .value changes, all places on the page bound to this data automatically update locally.

It's the same principle as an Excel formula: change a source cell, and all cells referencing it automatically recalculate.

⚠️ Note: In <script>, access data with .value (e.g., content.value), but in <template>, Vue automatically unwraps it, so use {{ content }} directly.

4.4 One-Way vs. Two-Way Binding

In Vue, most data flow is one-way: Data → Interface. Using {{ }} interpolation:

<div>{{ content }}</div>  <!-- One-way: data changes, div auto-updates -->

But form elements are an exception—users need to input content, and the input must be written back to the data layer. v-model does exactly this:

<input v-model="question" />
<!-- Two-way →
<!-- User types "Hello" → question.value automatically becomes "Hello" -->
<!-- question.value changed to "hi" → input box immediately shows "hi" -->

v-model does two things simultaneously: displays data (Data → Interface) + writes back input (Interface → Data).


5. Project Practice—Hand-Write a Streaming Chat 🔨

5.1 Project Structure

stream-demo/
├── index.html              # Entry HTML, provides mount point #app
├── .env.local              # 🔒 Environment variables (API Key goes here, don't commit!)
├── .gitignore              # Git ignore rules
├── package.json            # Vue 3.5 + Vite 8
├── vite.config.js          # Vite config (registers Vue plugin)
├── readme.md               # Notes
└── src/
    ├── main.js             # App entry: createApp(App).mount('#app')
    ├── App.vue             # Root component, wraps HelloWorld
    ├── style.css           # Global styles (margin/padding reset)
    ├── 1.js                # Encoding/decoding demo: TextEncoder / TextDecoder
    └── components/
        └── HelloWorld.vue  # 🔥 Core component! All streaming output logic

5.2 Environment Variables: .env.local

VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
VITE_DEEPSEEK_API_BASE_URL=https://api.deepseek.com
VITE_DEEPSEEK_MODEL=deepseek-v4-flash

Vite automatically reads variables starting with VITE_, accessed in code via import.meta.env.VITE_DEEPSEEK_API_KEY.

⚠️ .env.local must be added to .gitignore! An API Key is like your house key; committing it to Git is like hanging the key on your door.

5.3 Core Code Line-by-Line Breakdown

Step 1: Define Three Reactive Data Items

import { ref } from 'vue';

const question = ref('Tell a story about a Chinese dragon');  // User's input question
const content = ref('');                     // AI reply content
const stream = ref(true);                    // Whether to stream (default on, bound to checkbox)

stream defaults to true—because this is a streaming demo, it showcases the streaming experience by default.

Step 2: The update() Function—Send Request

const update = async () => {
  if (!question.value) return;        // Empty check
  content.value = 'Thinking...';         // 🔔 Give user immediate feedback, indicating "I'm working on it"

  const endpoint = 'https://api.deepseek.com/chat/completions';
  const headers = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
  };

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value   // 🔑 Streaming switch
    })
  });
  // ...subsequent branching
}

A few key knowledge points:

Step 3: Non-Streaming Branch (Simple)

if (!stream.value) {
  const data = await response.json();
  content.value = data.choices[0].message.content;
}

Traditional API call method: fetchresponse.json() → get choices[0].message.content → assign. Done in one line, the downside is the user waits dry.

Step 4: Streaming Branch (Core!) 🔥

if (stream.value) {
  content.value = '';                          // Clear old content

  const reader = response.body?.getReader();   // ① Get the "faucet"
  const decoder = new TextDecoder();           // ② Create the "translator"
  let done = false;                            // ③ Flag variable, controls loop
  let buffer = '';                             // ④ Buffer area

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

    // ⑤ Decode + concatenate buffer
    const chunkValue = buffer + decoder.decode(value);
    buffer = '';  // Buffer has been concatenated into chunkValue, clear it

    // ⑥ Split by line, filter out SSE "data:" lines
    const lines = chunkValue.split('\n')
      .filter((line) => line.startsWith('data: '));

    // ⑦ Parse each data line, extract delta.content → append to content.value
  }
}

This is the soul of the entire demo. Let's peel back the layers:


Layer ①: response.body?.getReader() — Get the Faucet

response.body is a ReadableStream object, the "water pipe" mentioned earlier. .getReader() returns a reader, equivalent to installing a faucet on the water pipe—each turn (calling .read()) lets out a chunk of data.

?. is the Optional Chaining operator: if response.body is null or undefined, it doesn't error, just returns undefined.


Layer ②: new TextDecoder() — The Translator

What flows in the pipe is binary data (Uint8Array, an array of 8-bit unsigned integers, each value 0-255). Machines can read it, but to human eyes, it's gibberish.

TextDecoder is the translator, converting binary bytes into human-readable UTF-8 text.

The project also has a standalone demo file src/1.js specifically for this:

// src/1.js — Encoding/Decoding Demo
const encoder = new TextEncoder();
const bytes = encoder.encode('你好');     // String → Uint8Array
console.log(bytes);                       // e.g., [228, 189, 160, 229, 165, 189]

const decoder = new TextDecoder();
const str = decoder.decode(bytes);        // Uint8Array → '你好'
console.log(str);                          // 你好

💡 TextEncoder and TextDecoder are a pair—one encodes (String→Binary), one decodes (Binary→String). Streaming scenarios only use decoding.


Layer ③: The done Flag Variable

let done = false;  // Initially false, becomes true after reading [DONE], loop exits

This is a state flag—controls when the while loop stops.


Layer ④: The buffer Cache

let buffer = '';

Why a buffer? Because network transmission is variable-length—a chunk can be cut off at any position. For example, one line of SSE data: {"choices"...} might be split in half, landing in two different chunks. The buffer is used to temporarily store "incomplete half-lines," waiting for the next chunk to arrive to piece them together.


Layer ⑤: Decode + Concatenate Buffer

const chunkValue = buffer + decoder.decode(value);
buffer = '';  // Buffer has been concatenated into chunkValue, clear for next round

First, prepend any residual buffer from the previous round (if any) to the current decoded result, forming a complete segment. Then clear the buffer, as it has been used.


Layer ⑥: Split by Line + Filter data: Lines

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

In SSE format, each line of useful data starts with data:. split('\n') splits by line, filter keeps only lines starting with data:, filtering out all blank lines and metadata lines.


Layer ⑦: Parse JSON, Extract Token

for (const line of lines) {
  const dataStr = line.slice(6);  // Remove "data: " prefix (6 characters)
  if (dataStr === '[DONE]') continue;  // End signal, skip

  try {
    const json = JSON.parse(dataStr);
    const token = json.choices[0]?.delta?.content || '';
    content.value += token;  // 🔥 Append a token each time, triggering view update!
  } catch (e) {
    // JSON parse failed, ignore this line
  }
}

What follows data: is a piece of JSON. We:

  1. Cut off the "data: " prefix (6 characters)
  2. Skip if encountering [DONE] (this is the end signal)
  3. JSON.parse to parse the object
  4. Navigate the path choices[0].delta.content to extract the token text
  5. content.value += token — Append to the reactive data

Each time content.value changes, Vue's reactivity system automatically triggers an interface update—the user sees the text popping out character by character.

5.4 Template Section

<template>
<div class="container">
  <div>
    <label>Input:</label>
    <input class="input" v-model="question" />   <!-- Two-way binding input box -->
    <button @click="update">Submit</button>          <!-- Click triggers update -->
  </div>
  <div class="output">
    <div>
      <label>Streaming</label>
      <input type="checkbox" v-model="stream"/>   <!-- Two-way binding checkbox -->
    </div>
    <div>{{ content }}</div>                       <!-- One-way binding, display AI reply -->
  </div>
</div>
</template>

Three binding methods at a glance:

5.5 CSS Layout

.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;  /* Mobile adaptation: scales proportionally relative to html root element */
}

Uses Flexbox layout. CSS document flow is the basis of page layout: default arrangement is top-to-bottom, left-to-right. display: flex opens a new Formatting Context, allowing child elements to be arranged according to flex-direction: column (vertically).

The rem in font-size: 0.85rem is a relative unit—relative to the <html> root element's font size. The benefit is mobile adaptation: just change the root element font size, and the entire page's fonts scale proportionally.


6. Advanced: SSE vs WebSocket—High-Frequency Interview Question 🔥

Comparison Dimension SSE WebSocket
Communication Direction One-way (Server → Client) Two-way (Full-duplex)
Protocol Based on HTTP/HTTPS, simple Independent protocol ws://, requires handshake upgrade
Reconnection Browser built-in automatic reconnection Requires manual implementation
Implementation Complexity Low (EventSource or manual fetch) High
Applicable Scenarios AI streaming, stock quotes, notifications Chat rooms, collaborative editing, online games

AI chat is a typical "client sends request → server continuously pushes reply"—one-way flow. SSE is perfectly suitable, no need for WebSocket's bidirectional capability.


7. Common Pitfalls and Best Practices ⚠️

7.1 Error Handling

try {
  const response = await fetch(endpoint, { ... });
  if (!response.ok) {
    content.value = `Request failed: ${response.status}`;
    return;
  }
  // ...streaming read...
} catch (error) {
  content.value = `Network error: ${error.message}`;
}

7.2 Canceling Requests

A user might initiate a new request midway or close the page, requiring cancellation of the old request to free resources:

const controller = new AbortController();

fetch(endpoint, {
  signal: controller.signal,  // Bind signal
  // ...
});

// Before user initiates a new request
controller.abort();  // Abort the old request

7.3 Compatibility of reader?.read()

The code uses reader?.read() instead of reader.read()?. optional chaining protection. Older browsers might not support ReadableStream, writing this way prevents a white screen error. Feature detection is recommended for production environments.

7.4 Displaying a "Typing" Status

<div>{{ content }}<span v-if="isLoading" class="cursor">|</span></div>
.cursor { animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }

A blinking cursor tells the user "AI is working," instantly improving the experience.


8. A Diagram Summarizing the Whole Article 🗺️

┌──────────────────────────────────────────────────────────────┐
│                       Streaming Output Panorama               │
│                                                              │
│   DeepSeek Server                                             │
│   ┌──────────────┐      stream: true                         │
│   │  Token-by-token │────── SSE ──────→  ┌─────────────────┐  │
│   │  generation   │   data: {...}      │  Vue 3 Frontend   │  │
│   │  Send one as  │   data: {...}      │                 │  │
│   │  generated    │   data: [DONE]     │ response.body    │  │
│   └──────────────┘                    │   .getReader()   │  │
│                                       │       ↓          │  │
│   Non-Streaming (comparison)           │  reader.read()   │  │
│   ┌──────────────┐                    │       ↓          │  │
│   │  Fully        │──── JSON ────→    │  TextDecoder     │  │
│   │  generated,   │  {choices:[...]}   │       ↓          │  │
│   │  one-time     │                    │ split+filter     │  │
│   │  return       │                    │   data: lines    │  │
│   └──────────────┘                    │       ↓          │  │
│                                       │ JSON.parse       │  │
│                                       │       ↓          │  │
│                                       │ content += token │  │
│                                       │       ↓          │  │
│                                       │ UI updates       │  │
│                                       │ character by     │  │
│                                       │ character        │  │
│                                       └─────────────────┘  │
└──────────────────────────────────────────────────────────────┘

9. Three Core APIs at a Glance

The entire streaming output relies on these three APIs:

API Function One Sentence
response.body.getReader() Get stream reader Install a faucet on the water pipe
new TextDecoder() Create text decoder Binary → Human-readable text
reader.read() Read next data chunk Turn the faucet to catch a cup of water

Remember them, and you've mastered the core of streaming output.


10. Final Words

  1. Why: Optimize user-perceived waiting time, the core experience of AI products
  2. What: stream: true protocol agreement + SSE data format + ReadableStream
  3. How: getReader()TextDecoderreader.read() loop → SSE parsing → JSON extraction → Vue reactive update
  4. How to do it well: Buffer handling across chunks, error handling, request cancellation, blinking cursor

As a frontend engineer, streaming output is not only a must-answer interview question, but also a fundamental skill for building excellent AI products.

The water pipe is connected, the water is coming, how to elegantly catch and display the water—this is the bounden duty of the frontend.