阅读视图

发现新文章,点击刷新页面。

如何实现流式输出?一篇文章手把手教你!

本文代码github仓库地址——github.com/Objecteee/a…

一、什么是流式输出?

流式输出是一种数据传输模式,在这种模式下,数据不是作为一个完整的、单一的包裹在一次响应中发送给客户端,而是被分成许多小的数据块 (chunks) ,并在服务器端生成的同时,持续不断、逐块地推送到客户端。例如下面的Gemini回答过程——

GIF 2025-12-15 19-26-17.gif

二、为什么我们需要流式输出?

流式输出的核心价值在于改变了用户对延迟的感知,将原本漫长的等待转化为即时的内容消费。

1.极大地提升用户体验 (UX):

在传统的 请求-响应模型中,用户必须等待服务器生成并返回全部数据才能看到结果。例如,一个 AI 回复可能需要 5-10 秒。

流式输出则实现了内容逐块、逐字到达和显示。用户感知到的延迟从 “总生成时间” 缩短为 “首个数据块到达时间” 。这种实时反馈机制让用户感觉程序在即时响应,显著降低了等待的焦虑感。

在 AI/LLM 场景中, 流式输出是必需品,它将数秒的枯燥等待变成了持续的阅读过程,是用户留存和产品体验的基石。

2.提高系统和网络效率:

流式输出技术(尤其是 SSEWebSocket)通过建立持久连接,减少了重复建立和关闭 HTTP 连接的开销。 数据生成多少推送多少,网络带宽得到更有效的利用,特别是在处理大量异步或长时间运行的任务时,效率优势更为明显。

简单来说,流式输出的重要性在于:它把 “等待” 变成了 “消费” ,是现代交互式应用和实时数据平台的标配

三、主流的流式输出实现

流式输出主要有两种方案,第一种是SSE(基于HTTP)的单向流式输出;第二种是基于WebSocket的双向流式输出。

1.基于SSE的单向流式输出

GIF 2025-12-15 22-02-13.gif

(1)后端实现

SSE 流式输出的后端代码是有固定的格式和严格的要求。这种格式是 Server-Sent Events 规范的核心,也是客户端浏览器能够正确解析流的关键。

A. 基础格式

这是最常用、最核心的格式,用于传输数据块。

字段 格式 作用 示例
Data data: [内容] 包含要发送的实际数据。客户端 event.data 接收到的就是 : 后面的内容。 data: Hello world\n\n
分隔符 \n\n 至关重要! 必须以两个换行符标记一个事件块的结束。
B. 完整格式

SSE 规范还允许其他可选字段,用于更复杂的流控制:

字段 格式 作用 示例
Event event: [事件名] 允许发送不同类型的事件,客户端可以通过 eventSource.addEventListener('事件名', ...) 监听。 event: update\n
ID id: [唯一标识] 允许为每个事件分配一个唯一 ID。如果客户端断线重连,它会发送 Last-Event-ID,服务器可以从该 ID 恢复推送。 id: 12345\n
Retry retry: [毫秒数] 全局设置。 客户端断开连接后,浏览器等待该毫秒数后再尝试重连。 retry: 10000\n\n

一个包含所有字段的完整事件块示例如下:

event: system-update
id: 999
retry: 5000
data: {"status": "ok", "progress": 80}

注意: 即使使用了多个字段,最后也必须以 \n\n 结束

const express = require('express');
const app = express();
const PORT = 3000;

app.get('/api/stream-sse', (req, res) => {
    // ------------------------------------------------------------------
    // A. 固定的 HTTP 响应头设置 (关键!)
    // ------------------------------------------------------------------
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    // 允许跨域访问
    res.setHeader('Access-Control-Allow-Origin', '*'); 

    // 可选:设置断线重连时间间隔(单位:毫秒)
    // res.write('retry: 5000\n\n'); 

    console.log('--- SSE Connection Established ---');

    // 模拟数据流式生成
    let counter = 0;
    const intervalId = setInterval(() => {
        if (counter >= 8) {
            // 4. 结束流:发送 [DONE] 标记(常见实践)并关闭连接
            res.write('data: [DONE]\n\n');
            res.end();
            clearInterval(intervalId);
            console.log('--- SSE Stream Ended ---');
            return;
        }

        counter++;
        const payload = `这是第 ${counter} 块数据,时间:${new Date().toLocaleTimeString()}`;
        
        // ------------------------------------------------------------------
        // B. 固定的数据体格式 (严格要求!)
        // ------------------------------------------------------------------
        const sseData = 
        // 1. data 字段:必须以 "data: " 开头
        `data: ${payload}\n` + 
        // 2. 两个换行符:必须以 "\n\n" 结尾,标志一个事件的结束
        `\n`; 

        // 3. 实时写入数据到响应流
        res.write(sseData);
        console.log(`Pushed: ${payload}`);

    }, 1500); // 每 1.5 秒推送一次

    // 处理客户端断开连接的清理工作
    req.on('close', () => {
        clearInterval(intervalId);
        console.log('Client closed connection.');
    });
});

app.listen(PORT, () => {
    console.log(`SSE Server running on http://localhost:${PORT}`);
});
  1. 设置正确的 Header: 必须设置 Content-Type: text/event-stream
  2. 实时写入: 使用 Node.js 的 res.write() 方法,而不是等待数据全部收集完毕后使用 res.send()res.send() 会尝试关闭连接,不适用于流式输出。
  3. 遵守 data: ...\n\n 格式: 任何偏离这个格式的输出都可能导致客户端 EventSource 无法正确触发 onmessage 事件。
  4. 连接清理: 监听 req.on('close', ...) 事件,确保在客户端断开连接时,服务器能停止不必要的定时器或资源占用。

(2)前端实现

A.浏览器原生实现(EventSource)

EventSource 是浏览器专为 SSE 规范设计的高级 API。它自动处理了底层 HTTP 连接、数据格式解析、事件触发,以及断线重连等所有复杂逻辑。

优势
  • 最简洁: 只需要几行代码即可开始监听流。
  • 可靠性高: 内置自动重连机制,非常健壮。
  • 无需手动解析: 自动将 data: 字段的内容提取出来,作为 event.data
局限性
  • 只能 GET 请求。
  • 不支持自定义 Header: 无法在请求头中传递 Token(只能通过 URL query 参数)。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>EventSource 客户端示例</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; }
        h1 { color: #333; }
        #status { font-weight: bold; margin-bottom: 15px; padding: 8px; border-radius: 4px; }
        .status-connecting { color: orange; background-color: #fff3e0; }
        .status-open { color: green; background-color: #e8f5e9; }
        .status-closed { color: red; background-color: #ffebee; }
        #output-area { 
            border: 1px solid #ddd; 
            padding: 15px; 
            min-height: 150px; 
            white-space: pre-wrap; /* 保持换行和空格 */
            background-color: #fcfcfc;
            overflow-y: auto;
        }
        .chunk { margin-right: 5px; color: #00796b; }
        .divider { color: #bdbdbd; }
    </style>
</head>
<body>
    <h1>EventSource 实时流接收端</h1>
    <p id="status" class="status-connecting">连接状态: 正在初始化...</p>
    
    <h3>接收到的消息流:</h3>
    <div id="output-area"></div>

    <button onclick="closeStream()">🛑 停止接收流</button>

    <script>
        // 请确保这里的 URL 与您的 Node.js 后端 SSE 路由匹配
        const STREAM_URL = 'http://localhost:3000/api/stream-sse'; 
        
        const statusElement = document.getElementById('status');
        const outputElement = document.getElementById('output-area');
        
        let eventSource;

        function updateStatus(text, className) {
            statusElement.textContent = text;
            statusElement.className = '';
            statusElement.classList.add(className);
        }

        function initializeSSE() {
            outputElement.innerHTML = ''; // 清空旧内容
            
            // 1. 创建 EventSource 实例并建立连接
            eventSource = new EventSource(STREAM_URL);
            updateStatus('连接状态: 正在连接...', 'status-connecting');

            // 2. 监听连接打开事件
            eventSource.onopen = () => {
                updateStatus('连接状态: ✅ 已建立', 'status-open');
                console.log('SSE connection opened successfully.');
            };

            // 3. 监听接收到数据事件 (核心逻辑)
            eventSource.onmessage = (event) => {
                const chunk = event.data;
                console.log('Received chunk:', chunk);

                // 检查结束标记(与后端定义的 "[DONE]" 匹配)
                if (chunk === '[DONE]') {
                    eventSource.close();
                    updateStatus('连接状态: 🟢 流已完成并关闭', 'status-closed');
                    console.log('Stream finished and closed by server.');
                    return;
                }

                // 实时追加数据到 UI
                // 使用 innerHTML 追加,可以实现更丰富的样式
                outputElement.innerHTML += `<span class="chunk">${chunk}</span><span class="divider"> | </span>`;

                // 确保滚动到底部以查看最新内容
                outputElement.scrollTop = outputElement.scrollHeight;
            };

            // 4. 监听错误事件(处理断线等)
            eventSource.onerror = (error) => {
                console.error('EventSource encountered an error:', error);
                
                if (eventSource.readyState === EventSource.CLOSED) {
                    updateStatus('连接状态: ❌ 已关闭或断开', 'status-closed');
                } else {
                    // EventSource 默认会尝试自动重连
                    updateStatus('连接状态: ⚠️ 发生错误,正在尝试重连...', 'status-connecting');
                }
            };
        }

        // 5. 客户端主动关闭流的函数
        function closeStream() {
            if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
                eventSource.close();
                updateStatus('连接状态: 🛑 用户手动关闭', 'status-closed');
                console.log('User manually closed the stream.');
            }
        }

        // 页面加载完成后立即启动 SSE
        window.onload = initializeSSE;
    </script>
</body>
</html>
阶段一:连接初始化与建立

此阶段旨在建立一个持久化的 HTTP 连接,并开始监听。

步骤 动作描述 核心代码 关键点
1. 创建 EventSource 实例 使用 EventSource 构造函数传入 SSE 接口 URL,向服务器发起连接请求。 const es = new EventSource(url); 浏览器自动处理 底层 HTTP GET 请求。
2. 监听连接打开 监听 onopen 事件,确认与服务器的连接已成功建立。 es.onopen = () => { ... } 此时流式数据传输通道已打开。
3. 更新 UI 状态 onopen 中,更新界面状态,提示用户数据流已开始。 setStatus('已连接'); 提升用户体验。
阶段二:数据接收与处理(核心)

此阶段是持续接收服务器推送的数据,并实时更新 UI。

步骤 动作描述 核心代码 关键点
4. 监听消息事件 监听 onmessage 事件。每当服务器推送一个完整的 data:...\n\n 事件块时,此回调函数就会触发。 es.onmessage = (event) => { ... } event.data 包含了服务器推送的实际内容。
5. 实时数据追加 将接收到的数据 (event.data) 追加到当前展示的文本末尾(而不是替换)。 currentMsg += event.data; 这是实现“打字机”效果的关键。
6. 处理自定义事件 如果后端使用了 event: [name] 字段,前端可以通过 es.addEventListener('name', callback) 针对性地处理不同类型的事件。 es.addEventListener('update', ...) 适用于需要区分不同业务类型数据的场景。
阶段三:连接维护、关闭与错误处理

此阶段处理流的正常结束、网络错误和重试逻辑。

步骤 动作描述 核心代码 关键点
7. 监听流结束标记 根据与后端约定的结束标记(如 [DONE]),判断流是否完成。 if (event.data === '[DONE]') { ... } 流的正常结束, 避免无限等待。
8. 关闭连接 当流结束或用户主动点击“停止”按钮时,主动调用 close() 方法。 es.close(); 释放客户端资源。
9. 监听错误与重连 监听 onerror 事件。当连接出错时,浏览器会根据服务器设置的 retry: 时间间隔自动尝试重连。 es.onerror = (error) => { ... } 自动重试 是 SSE 相比于手动 fetch 的巨大优势。
B.Fetch API实现

当需要更强大的控制力、自定义请求头,或处理非标准 SSE 格式的流时,fetch 是最佳选择。

利用 fetch 返回的 response.body,它是一个 ReadableStream 对象。开发者需要获取这个流的 Reader,进入一个循环,手动读取数据块、解码,并根据 \n\n 规则手动解析 SSE 格式。

优势
  • 控制力强: 可以发送 POST、PUT 等请求,并设置自定义 Header(用于鉴权)。
  • 通用性: 可以处理任何基于 HTTP 的流式数据,不限于 SSE 格式。
  • 新标准: 符合现代 Web 标准,浏览器支持良好。
挑战
  • 实现复杂: 需要手动编写数据解析和错误重连的代码。
  • 性能考量: 频繁的循环读取和字符串拼接/解码操作需要谨慎优化。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Fetch API SSE 客户端示例</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; }
        #status { font-weight: bold; margin-bottom: 15px; color: orange; }
        #output-area { 
            border: 1px solid #ddd; 
            padding: 15px; 
            min-height: 150px; 
            white-space: pre-wrap;
            background-color: #fcfcfc;
            overflow-y: auto;
        }
    </style>
</head>
<body>
    <h1>🔗 Fetch API 模拟 SSE 接收端</h1>
    <p id="status">连接状态: 待启动</p>
    
    <h3>接收到的消息流:</h3>
    <div id="output-area"></div>

    <button onclick="startStream()">▶️ 启动流</button>
    <button onclick="stopStream()">🛑 停止接收流</button>

    <script>
        const STREAM_URL = 'http://localhost:3000/api/stream-sse'; // 确保 URL 正确
        
        const statusElement = document.getElementById('status');
        const outputElement = document.getElementById('output-area');
        
        let controller = null; // 用于控制请求中止 (AbortController)

        function updateStatus(text, color) {
            statusElement.textContent = text;
            statusElement.style.color = color;
        }

        async function startStream() {
            if (controller) stopStream(); // 确保前一个流已停止

            controller = new AbortController();
            const signal = controller.signal;
            let textDecoder = new TextDecoder('utf-8');
            let buffer = ''; // 缓冲区,用于存储不完整的事件块

            outputElement.innerHTML = '';
            updateStatus('连接状态: 正在连接...', 'orange');

            try {
                // 1. 发起 Fetch 请求,设置 signal 用于中止
                const response = await fetch(STREAM_URL, {
                    method: 'GET',
                    headers: { 'Accept': 'text/event-stream' },
                    signal: signal 
                });

                if (!response.body) {
                    throw new Error("响应体不是一个可读流。");
                }

                // 2. 获取 ReadableStream 的 Reader
                const reader = response.body.getReader();

                // 3. 循环读取流数据
                while (true) {
                    const { done, value } = await reader.read();
                    
                    if (done) {
                        updateStatus('连接状态: 🟢 流已完成', 'green');
                        console.log('Stream finished.');
                        break;
                    }

                    // 4. 解码字节数据并追加到缓冲区
                    // { stream: true } 允许在流继续时进行解码
                    buffer += textDecoder.decode(value, { stream: true });

                    // 5. 手动解析 SSE 消息块
                    // SSE 消息以 \n\n 结束,分割缓冲区
                    let messages = buffer.split('\n\n');
                    buffer = messages.pop(); // 最后一个不完整的块留在缓冲区

                    messages.forEach(message => {
                        if (message) {
                            // 6. 提取 data: 字段内容
                            const dataMatch = message.match(/data: (.*)/);
                            if (dataMatch && dataMatch[1]) {
                                const data = dataMatch[1].trim();

                                if (data === '[DONE]') {
                                    controller.abort(); // 接收到结束标记,主动中止请求
                                } else {
                                    // 7. 处理接收到的数据
                                    outputElement.innerHTML += `<span class="chunk">${data}</span><span class="divider"> | </span>`;
                                    outputElement.scrollTop = outputElement.scrollHeight;
                                }
                            }
                        }
                    });
                }
            } catch (error) {
                if (error.name === 'AbortError') {
                    updateStatus('连接状态: 🛑 流被用户/DONE标记中止', 'gray');
                    console.log('Fetch request aborted successfully.');
                } else {
                    updateStatus(`连接状态: ❌ 错误: ${error.message}`, 'red');
                    console.error('Fetch SSE 错误:', error);
                }
            }
        }

        function stopStream() {
            if (controller) {
                controller.abort();
                controller = null;
            }
        }
        
        // 页面加载完成后,设置初始状态
        window.onload = () => updateStatus('连接状态: 待启动', 'black');
    </script>
</body>
</html>

Fetch API 实现流式输出是高度可定制但相对复杂的技术方案,它要求开发者手动处理数据的接收、解码和 SSE 格式的解析。整个流程可以清晰地划分为三个主要阶段。

阶段一:初始化请求与准备工作

此阶段的目标是发起请求并设置流读取所需的环境。

步骤 动作描述 关键技术点
1. 准备控制器 创建 AbortController 实例。用于在流结束或需要中断时,能够中止 fetch 请求。 new AbortController()
2. 发起 Fetch 请求 发起 fetch 请求,将 controller.signal 附加到请求配置中。可在 headers 中设置鉴权信息。 fetch(url, { signal: controller.signal, headers: {...} })
3. 获取流读取器 检查响应是否成功 (response.ok),然后通过 response.body.getReader() 获取 reader 对象。 response.body.getReader()
4. 准备解码器与缓冲区 实例化 TextDecoder 用于将字节解码为字符串,并初始化一个 buffer 变量用于暂存不完整的数据片段。 new TextDecoder('utf-8'), let buffer = ''

阶段二:循环读取、解码与解析(核心)

此阶段是整个流程最复杂的部分,需要持续从流中拉取数据并手动解析 SSE 格式。

步骤 动作描述 关键技术点
5. 启动读取循环 使用 while (true) 或类似的循环结构开始持续读取数据。 while (true)
6. 读取数据块 调用 await reader.read()。它会暂停执行,等待服务器推送新的数据块 (value: Uint8Array)。 const { done, value } = await reader.read()
7. 判断流结束 检查 done 属性。如果为 true,跳出循环,进入清理阶段。 if (done) break;
8. 解码与缓冲 使用 TextDecoder 将原始字节 value 解码为字符串,并将其追加到缓冲区 (buffer) 中。 buffer += decoder.decode(value, { stream: true })
9. 手动解析 SSE 使用 buffer.split('\n\n') 将缓冲区内容分割成潜在的 SSE 消息数组。然后将数组中最后一个不完整的块重新放回缓冲区。 buffer.split('\n\n'), buffer = messages.pop()
10. 提取数据 遍历其余完整的消息块,使用正则表达式(如 /data: (.*)/)手动提取 data: 字段后的实际内容。 message.match(/data: (.*)/)
11. 实时处理 将提取到的数据追加到 UI 界面,并执行所需的业务逻辑。 UI 更新、滚动到底部

阶段三:连接清理与错误处理

此阶段确保流的正确终止和资源释放。

步骤 动作描述 关键技术点
12. 处理结束标记 在数据处理逻辑中,如果检测到服务器推送的结束标记(如 [DONE]),则立即调用 controller.abort() 终止流。 controller.abort()
13. 错误捕获 将整个 fetch 和流读取循环包裹在 try...catch 块中。捕获网络错误和因 controller.abort() 产生的 AbortError try...catch (error)
14. 释放资源 无论是正常结束还是出错,确保所有相关资源(如 reader)得到释放。 AbortController 自动处理了请求的终止。

通过这种手动控制的方式,Fetch API 提供了对流的最高级别控制,但代价是代码实现更为复杂,且需要开发者手动处理自动重连等健壮性机制。

C.Axios API实现

使用 axios 实现流式输出主要依赖其配置项,但在浏览器环境下的表现不如 fetchEventSource

axios 的配置中,需要显式设置 responseType: 'stream'。在 Node.js 环境中,这会返回一个 Node.js 的流,处理起来比较方便。但在浏览器环境中,axios 对流的封装不如 fetch 原生。

优势
  • Node.js 友好: 在 Node.js 环境中处理流式数据很方便。
  • 代码统一: 如果应用大量依赖 axios,可以避免引入 fetch
挑战
  • 浏览器兼容性/稳定性: 浏览器端对 axios 流的稳定支持不如 fetchEventSource
  • 解析依然需要手动:fetch 一样,您仍然需要获取流并手动进行 SSE 格式的解析和解码。
  • 潜在的内存问题: 如果流处理不当,可能会导致内存占用过高。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Axios 客户端模拟 SSE 示例</title>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; }
        #status { font-weight: bold; margin-bottom: 15px; color: orange; }
        #output-area { 
            border: 1px solid #ddd; 
            padding: 15px; 
            min-height: 150px; 
            white-space: pre-wrap;
            background-color: #fcfcfc;
            overflow-y: auto;
        }
        .chunk { margin-right: 5px; color: #00796b; }
        .divider { color: #bdbdbd; }
    </style>
</head>
<body>
    <h1>🛠️ Axios 模拟 SSE 接收端</h1>
    <p id="status">连接状态: 待启动</p>
    
    <h3>接收到的消息流:</h3>
    <div id="output-area"></div>

    <button onclick="startStream()">▶️ 启动流</button>
    <button onclick="stopStream()">🛑 停止接收流</button>

    <script>
        const STREAM_URL = 'http://localhost:3000/api/stream-sse';
        
        const statusElement = document.getElementById('status');
        const outputElement = document.getElementById('output-area');
        
        let cancelTokenSource = null; // 用于取消请求的 Axios Token
        let lastProcessedIndex = 0;   // 追踪上次处理到的数据索引
        let buffer = '';              // 用于存放不完整的 SSE 事件块
        let isConnected = false;      // 标记是否已建立连接

        function updateStatus(text, color) {
            statusElement.textContent = text;
            statusElement.style.color = color;
        }

        async function startStream() {
            if (cancelTokenSource) stopStream();

            cancelTokenSource = axios.CancelToken.source();
            lastProcessedIndex = 0;
            buffer = '';
            isConnected = false;
            outputElement.innerHTML = '';
            updateStatus('连接状态: 正在连接...', 'orange');

            try {
                await axios.get(STREAM_URL, {
                    responseType: 'text',
                    cancelToken: cancelTokenSource.token,
                    onDownloadProgress: (progressEvent) => {
                        let xhr = progressEvent.currentTarget || progressEvent.target;
                        if (progressEvent.event && progressEvent.event.target) {
                            xhr = progressEvent.event.target;
                        }
                        
                        if (!xhr || typeof xhr.responseText === 'undefined') {
                            return;
                        }
                        
                        const responseText = xhr.responseText;
                        const newChunk = responseText.substring(lastProcessedIndex);
                        lastProcessedIndex = responseText.length;
                        buffer += newChunk;

                        // 收到数据时更新连接状态
                        if (!isConnected && newChunk.length > 0) {
                            isConnected = true;
                            updateStatus('连接状态: ✅ 已连接,正在接收数据...', 'green');
                        }

                        let messages = buffer.split('\n\n');
                        buffer = messages.pop() || '';

                        messages.forEach(message => {
                            if (message.trim()) {
                                const dataMatch = message.match(/data: (.*)/);
                                if (dataMatch && dataMatch[1]) {
                                    const data = dataMatch[1].trim();
                                    if (data === '[DONE]') {
                                        stopStream(true);
                                    } else {
                                        outputElement.innerHTML += `<span class="chunk">${data}</span><span class="divider"> | </span>`;
                                        outputElement.scrollTop = outputElement.scrollHeight;
                                    }
                                }
                            }
                        });
                    }
                });
            } catch (error) {
                if (axios.isCancel(error)) {
                    updateStatus('连接状态: 🛑 流已中止', 'gray');
                } else {
                    updateStatus(`连接状态: ❌ 错误: ${error.message}`, 'red');
                    console.error('Axios SSE 错误:', error);
                }
            }
        }

        function stopStream(isDone = false) {
            if (cancelTokenSource) {
                cancelTokenSource.cancel('Stream terminated.');
                cancelTokenSource = null;
                if (isDone) {
                    updateStatus('连接状态: 🟢 流已完成并关闭', 'green');
                }
            }
        }
        
        window.onload = () => updateStatus('连接状态: 待启动', 'black');
    </script>
</body>
</html>

Axios 并非为 SSE 设计的,因为它基于传统的 XMLHttpRequest (XHR) 模型,或者在 Node.js 中基于 http 模块。在浏览器环境中,Axios 没有原生 EventSourceReadableStream 的支持。

因此,使用 Axios 实现流式输出的标准工作流程是利用 XHR 的 onprogress 事件监听机制(在 Axios 中表现为 onDownloadProgress 回调),手动获取每次新增的数据块,并手动进行 SSE 格式的解析。

  1. 没有自动解析: 必须手动解析 data: 字段和 \n\n 分隔符。
  2. 手动追踪数据: 每次 onDownloadProgress 触发时,返回的是当前已接收的全部数据,而不是新增的数据。您需要一个指针来追踪上次处理到了哪里,并提取出新的数据块。
  3. 不支持自动重连: 必须手动实现错误检测和重连逻辑(本示例不包含重连,但需注意)。
流式输出技术的选择
1. 🥇 EventSource API

这是实现 SSE 的标准和首选方案。

  • 实现复杂度: 最低。 浏览器原生支持,代码量最少。

  • 核心优势: 内置健壮性。 自动处理 SSE 数据格式解析、消息事件 (onmessage) 触发,并内置了断线重连机制(支持服务器设置 retry: 间隔)。

  • 主要局限:

    • 只能 GET 请求。
    • 不支持自定义 Header 鉴权。 只能通过 URL 参数传递 Token,安全性相对较低。
  • 选择原则: 强烈推荐用于大多数场景,尤其是在安全性要求不涉及请求 Header(例如,使用 Session Cookie 或 URL Token 鉴权)且只需要单向数据推送时。

2. 🥈 Fetch API + ReadableStream

这是在需要高控制度时使用的现代标准方案。

  • 实现复杂度: 中高。 需要手动获取流阅读器 (getReader()),并进入循环,手动进行 SSE 数据解析(解码字节、查找 \n\n、提取 data:)。

  • 核心优势: 控制力最强。

    • 支持自定义 HTTP Header(适用于传递 JWT 等鉴权信息)。
    • 支持 POST 等其他 HTTP 方法。
    • 可以处理非标准的流格式。
  • 主要局限: 必须手动编写复杂的 SSE 格式解析逻辑和错误重连机制。

  • 选择原则: 当您必须在请求 Header 中传递鉴权信息(例如 JWT),或者需要处理非标准的流格式时,应选择此方案。

3. 🥉 Axios + onDownloadProgress

这是基于旧的 XHR 机制的模拟方案,通常不推荐用于现代 Web 应用的 SSE。

  • 实现复杂度: 高。 依赖 onDownloadProgress 事件,每次事件触发返回的是全部已接收数据,因此需要额外的逻辑来追踪上次处理的索引,以提取新增的数据块。

  • 核心优势: 如果您的项目大量依赖 Axios,可以保持技术栈的统一。

  • 主要局限:

    • 实现流程复杂且易错。
    • 性能不如原生 API,且不支持原生的自动重连。
    • 在浏览器环境中,缺乏对流的标准化支持。
  • 选择原则: 仅在需要兼容旧环境或因特殊限制无法使用 EventSourcefetch 的情况下考虑。

2.基于WebSocket的双向流式输出实现

WebSocket 协议与 SSE(单向流)和传统的 HTTP 请求(一次性传输)有着本质的区别,它在客户端和服务器之间建立了一个持久的、全双工(双向) 的通信通道,非常适合需要高频率、低延迟交互的场景。

1. 协议升级

WebSocket 连接的建立始于一个标准的 HTTP 请求,这个请求包含了特殊的 Header,用于请求将连接从 HTTP 升级(Upgrade) 到 WebSocket 协议。一旦升级成功,连接就不再受限于传统的 HTTP 请求-响应模型。

2. 全双工通信

一旦连接建立,服务器和客户端可以独立、同时地互相发送数据帧。

  • 客户端 \to 服务器: 客户端可以随时发送输入、控制命令或心跳包。
  • 服务器 \to 客户端: 服务器可以随时发送流式数据、状态更新或响应。

这种双向性使得 WebSocket 不仅可以用于流式输出(服务器推数据),还可以用于实时接收用户输入,完美支持 实时聊天协作编辑 等场景。

WebSocket bidirectional communication diagram的图片

3. 基于帧的传输

WebSocket 的数据传输是基于的,而不是基于文本流或 HTTP 请求。这使得传输效率更高,延迟更低。帧可以包含文本数据(Text Frame)或二进制数据(Binary Frame)。


基于 WebSocket 的双向流式输出需要前端和后端分别使用专门的库和 API 进行实现。

GIF 2025-12-15 22-03-28.gif

1. 后端实现 (Node.js/ws 或 Socket.IO)

后端需要一个专门的 WebSocket 服务器来管理连接和发送数据帧。

步骤 动作描述 关键技术点
1. 握手与连接 监听 HTTP 升级请求,接受连接,并分配一个唯一的 WebSocket 连接对象。 ws.on('connection', (socket) => { ... })
2. 接收客户端输入 监听客户端发送过来的数据帧,用于触发 AI 任务或控制流。 socket.on('message', (input) => { // Process input })
3. 流式生成与推送 在 AI 模型生成数据的过程中,将数据块封装成 WebSocket 帧并实时发送。 socket.send(data_chunk)
4. 维护连接 维护连接状态,处理心跳包(Ping/Pong),并在客户端断开时清理资源。 socket.on('close', ...)
const WebSocket = require('ws');

// 创建 WebSocket 服务器,监听 8080 端口
const wss = new WebSocket.Server({ port: 8080 });

console.log('WebSocket Server running on ws://localhost:8080');

// 监听客户端连接事件
wss.on('connection', function connection(ws) {
    console.log('--- Client Connected ---');
    
    // 1. 监听客户端发来的消息(双向输入)
    ws.on('message', function incoming(message) {
        const clientMessage = message.toString();
        console.log(`Received message from client: ${clientMessage}`);

        try {
            const data = JSON.parse(clientMessage);
            
            if (data.command === 'START') {
                // 客户端请求开始流式输出
                startStreaming(ws, data.prompt);
            } else if (data.command === 'STOP') {
                // 客户端请求停止流式输出 (实时控制)
                stopStreaming(ws);
                ws.send(JSON.stringify({ status: 'info', message: 'Streaming stopped by client.' }));
            }
        } catch (e) {
            console.error('Error parsing client message:', e);
            ws.send(JSON.stringify({ status: 'error', message: 'Invalid JSON format.' }));
        }
    });

    // 监听连接关闭事件
    ws.on('close', function close() {
        console.log('--- Client Disconnected ---');
        // 清理资源,停止该连接上的所有流
        stopStreaming(ws);
    });

    // 初始问候
    ws.send(JSON.stringify({ status: 'ready', message: 'Welcome! Send {"command": "START", "prompt": "..."} to begin streaming.' }));
});

// 存储当前正在流式输出的连接和定时器
const activeStreams = new Map();

/**
 * 模拟 AI 模型流式输出的函数
 * @param {WebSocket} ws - 当前连接的 WebSocket 实例
 * @param {string} prompt - 客户端提供的输入提示
 */
function startStreaming(ws, prompt) {
    if (activeStreams.has(ws)) {
        ws.send(JSON.stringify({ status: 'info', message: 'Stream is already active.' }));
        return;
    }

    let counter = 0;
    const intervalId = setInterval(() => {
        counter++;
        const chunk = `[Chunk ${counter}] Output for "${prompt.substring(0, 15)}...": Data block ${counter}.`;
        
        // 2. 实时将数据块作为 WebSocket 帧发送给客户端
        // 使用 JSON 格式封装数据,方便客户端解析
        const dataToSend = JSON.stringify({ 
            type: 'data', 
            content: chunk,
            count: counter
        });
        
        // 检查连接是否仍然打开
        if (ws.readyState === WebSocket.OPEN) {
            ws.send(dataToSend);
            console.log(`Pushed data chunk ${counter}`);
        } else {
            // 如果连接关闭,停止流
            clearInterval(intervalId);
            activeStreams.delete(ws);
        }

        if (counter >= 15) {
            // 3. 流结束,发送结束标记
            clearInterval(intervalId);
            activeStreams.delete(ws);
            if (ws.readyState === WebSocket.OPEN) {
                 ws.send(JSON.stringify({ type: 'done', message: 'Streaming complete.' }));
            }
            console.log('--- Streaming finished ---');
        }
    }, 500); // 每 500 毫秒发送一个数据块

    activeStreams.set(ws, intervalId);
}

/**
 * 停止指定连接的流式输出
 */
function stopStreaming(ws) {
    if (activeStreams.has(ws)) {
        clearInterval(activeStreams.get(ws));
        activeStreams.delete(ws);
    }
}

2. 前端实现 (Browser WebSocket API)

前端使用浏览器原生的 WebSocket API 来建立连接和处理双向通信。

步骤 动作描述 关键技术点
1. 建立连接 使用 new WebSocket(ws://url) 建立连接。注意协议是 ws://wss:// new WebSocket('ws://localhost:8080')
2. 监听连接状态 监听 onopen(连接成功)、onerroronclose 事件。 ws.onopen = () => {...}
3. 接收流式输出 监听 onmessage 事件,接收服务器推送的流式数据帧,并实时追加到 UI。 ws.onmessage = (event) => { // Append event.data }
4. 发送客户端输入 当用户有新输入或需要发送控制命令时,通过 send() 方法发送数据帧到服务器。 ws.send(JSON.stringify({command: 'stop'}))

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>WebSocket 双向流式输出</title>
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        #status { font-weight: bold; margin-bottom: 15px; }
        #output-area { border: 1px solid #ccc; padding: 15px; min-height: 150px; background-color: #f9f9f9; white-space: pre-wrap; overflow-y: scroll; }
        .control-panel { margin-top: 20px; }
    </style>
</head>
<body>
    <h1>💬 WebSocket 双向流式交互</h1>
    <p id="status" style="color: gray;">连接状态: 待连接...</p>
    
    <h3>服务器输出流:</h3>
    <div id="output-area"></div>

    <div class="control-panel">
        <label for="prompt">输入提示:</label>
        <input type="text" id="prompt" value="请给我写一篇关于AI流式输出的文章" style="width: 300px;">
        <button onclick="connectWebSocket()">建立连接</button>
        <button onclick="startStream()">▶️ 启动流式输出</button>
        <button onclick="stopStream()">🛑 实时停止流</button>
        <button onclick="closeWebSocket()">断开连接</button>
    </div>

    <script>
        const WS_URL = 'ws://localhost:8080';
        let ws = null;

        const statusElement = document.getElementById('status');
        const outputElement = document.getElementById('output-area');
        const promptInput = document.getElementById('prompt');

        function log(message) {
            outputElement.innerHTML += `<p style="margin: 0; padding: 2px 0;">${message}</p>`;
            outputElement.scrollTop = outputElement.scrollHeight;
        }
        
        function updateStatus(text, color) {
            statusElement.textContent = text;
            statusElement.style.color = color;
        }

        // 1. 建立 WebSocket 连接
        function connectWebSocket() {
            if (ws && ws.readyState === WebSocket.OPEN) {
                updateStatus('连接已存在', 'blue');
                return;
            }

            ws = new WebSocket(WS_URL);
            updateStatus('正在连接...', 'orange');
            outputElement.innerHTML = '';

            // 监听连接成功事件
            ws.onopen = () => {
                updateStatus('连接状态: ✅ 已建立', 'green');
                log('--- WebSocket Connection Opened ---');
            };

            // 2. 监听接收到的数据帧 (核心)
            ws.onmessage = (event) => {
                try {
                    const data = JSON.parse(event.data);
                    
                    if (data.type === 'data') {
                        // 实时追加流式输出的内容
                        log(`[Server Stream]: ${data.content}`);
                    } else if (data.type === 'done') {
                        log(`[INFO]: ${data.message}`);
                        updateStatus('流已完成', 'blue');
                    } else if (data.status === 'info') {
                        log(`[INFO]: ${data.message}`);
                    }
                } catch (e) {
                    // 处理非 JSON 格式的消息
                    log(`[RAW MESSAGE]: ${event.data}`);
                }
            };

            // 监听错误事件
            ws.onerror = (error) => {
                updateStatus('连接状态: ❌ 发生错误', 'red');
                console.error('WebSocket Error:', error);
            };

            // 监听连接关闭事件 (连接断开)
            ws.onclose = () => {
                updateStatus('连接状态: 🛑 已断开', 'red');
                log('--- WebSocket Connection Closed ---');
                ws = null;
            };
        }

        // 3. 客户端发送命令:启动流式输出
        function startStream() {
            if (ws && ws.readyState === WebSocket.OPEN) {
                const prompt = promptInput.value || "Default prompt";
                const command = {
                    command: 'START',
                    prompt: prompt
                };
                // 发送 JSON 格式的启动命令
                ws.send(JSON.stringify(command));
                updateStatus('流式输出已启动...', 'purple');
                log(`[Client Command]: Starting stream for prompt: "${prompt}"`);
            } else {
                updateStatus('请先建立连接', 'red');
            }
        }

        // 4. 客户端发送命令:实时停止流
        function stopStream() {
            if (ws && ws.readyState === WebSocket.OPEN) {
                const command = {
                    command: 'STOP'
                };
                // 发送 JSON 格式的停止命令
                ws.send(JSON.stringify(command));
                updateStatus('发送停止命令', 'purple');
                log(`[Client Command]: Sending STOP command.`);
            } else {
                updateStatus('请先建立连接', 'red');
            }
        }
        
        function closeWebSocket() {
             if (ws && ws.readyState === WebSocket.OPEN) {
                 ws.close();
             }
        }

        // 页面加载后自动尝试连接
        window.onload = connectWebSocket;
    </script>
</body>
</html>
特性 WebSocket 双向流 SSE 单向流
通信方向 双向(全双工) 单向(服务器 \to 客户端)
协议基础 独立于 HTTP 的 TCP 协议 基于 HTTP 协议
适用场景 实时聊天、协作编辑、AI 实时交互控制 纯粹的 AI 流式输出、新闻推送
实现复杂度 较高(需独立服务器支持) 较低(基于标准 HTTP)
自动重连 无原生支持,需要手动实现 浏览器原生支持

如果你的 AI 应用只需要将 LLM 的结果流式输出给用户,SSE 是更简单、更健壮的选择。但如果您的应用需要用户在流式输出过程中实时中断、修改输入、或者实现多人协作WebSocket 是唯一的选择

一种基于 Service Worker 的渐进式渲染方案的基本原理

前言

笔者前面发过两篇关于流式SSR的文章:

流式SSR就是一种渐进式渲染,在传统的页面加载流程是:请求 → 等待 → 渲染。而渐进式渲染的思路是:

  1. 立即展示缓存的页面快照(即使是旧内容)
  2. 后台请求最新的页面内容
  3. 无缝替换为最新内容

这样用户感知到的加载时间接近于零,体验类似于原生 App。

前面笔者的文章中,提到关于H5页面的快照是客户端做的。本篇文章讲述一种基于 Service Worker 的渐进式渲染方案的原理,简单来讲就是将客户端的工作挪到了service worker中。通过给站点开启一个后台运行的service worker(service worker可以独立于webview运行在后台),在service worker中劫持包括主文档在内的网络请求,对文档内容进行存储,并修改返回。

技术方案设计

整体架构

┌─────────────┐
│  用户访问    │
└──────┬──────┘
       │
       ▼
┌─────────────────┐
│ Service Worker  │ ◄─── 拦截请求
└────┬────────┬───┘
     │        │
     │        └─────────┐
     ▼                  ▼
┌─────────┐      ┌──────────┐
│ 缓存快照 │      │ 网络请求  │
└────┬────┘      └─────┬────┘
     │                 │
     └────────┬────────┘
              ▼
       ┌─────────────┐
       │  流式替换    │
       └─────────────┘

核心代码实现

1. HTML 页面注册 Service Worker

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>渐进式渲染示例</title>
</head>
<body>
  <h1>Hello World</h1>
  
  <script data-snapshot>
    (function () {
      const swEnabled = location.search.indexOf('x-sw=false') < 0;

      // 注册 Service Worker
      swEnabled && navigator.serviceWorker && navigator.serviceWorker.register('/sw.js')
        .then(function (registration) {
          console.log('Service Worker 注册成功:', registration);
        })
        .catch(function (error) {
          console.log('Service Worker 注册失败:', error);
        });

      // 如果禁用,则注销 Service Worker
      !swEnabled && navigator.serviceWorker && navigator.serviceWorker.getRegistration(location.href).then((r) => {
        r && r.unregister();
      });
    }());
  </script>
</body>
</html>

关键点说明:

  • data-snapshot 属性标记这是快照阶段需要保留的脚本
  • 支持通过 ?x-sw=false 参数禁用 Service Worker
  • 禁用时会自动注销已注册的 Service Worker

2. Service Worker 核心逻辑

// sw.js
self.addEventListener('fetch', (event) => {
  // 只拦截主文档请求
  if (event.request.destination !== 'document') {
    return;
  }

  // 支持禁用功能
  if (event.request.url.indexOf('x-sw=false') >= 0) {
    event.waitUntil(caches.delete('my-cache'));
    return;
  }

  event.respondWith(handleFetch(event.request));
});

self.addEventListener('install', (event) => {
  console.log('Service Worker 安装');
  self.skipWaiting(); // 立即激活
});

3. 脚本过滤策略

function replaceScripts(text, regularStream) {
  return text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, (match) => {
    // 快照阶段:只保留 data-snapshot 脚本
    // 正式阶段:只保留普通脚本
    if (match.indexOf('data-snapshot') >= 0) {
      return regularStream ? '' : match;
    }
    return regularStream ? match : '';
  });
}

为什么要过滤脚本?

  • 快照阶段:避免执行业务逻辑脚本(可能依赖未加载的资源)
  • 正式阶段:避免重复执行初始化脚本

4. 流式渲染核心

function withSnapshot(snapshot, request) {
  return new Response(new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();
      const decoder = new TextDecoder();
      
      // 第一步:立即输出快照
      controller.enqueue(encoder.encode(snapshot));

      let firstStream = true;
      
      // 第二步:请求最新内容
      fetchAndStore(request).then((response) => {
        const reader = response.body.getReader();

        function push() {
          reader.read().then(({ done, value }) => {
            if (done) {
              controller.close();
              return;
            }

            if (firstStream) {
              firstStream = false;
              
              // 第三步:清空页面
              controller.enqueue(encoder.encode(
                '<script>document.head.innerHTML = "";document.body.innerHTML = "";</script>'
              ));

              // 第四步:注入最新内容
              const text = decoder.decode(value);
              const head = text.match(/<head>([\s\S]*?)<\/head>/i);
              const body = text.match(/<body>([\s\S]*?)<\/body>/i);

              if (head && body) {
                controller.enqueue(encoder.encode(
                  `<script>document.head.innerHTML = '${head[1].trim().replace(/\n/g, '')}'</script>`
                ));
                controller.enqueue(encoder.encode(replaceScripts(body[1], true)));
              }
            } else {
              controller.enqueue(value);
            }
            push();
          });
        }

        push();
      });
    },
  }));
}

为什么要清空 DOM?

  • 快照内容和最新内容可能结构不同
  • 直接追加会导致内容重复
  • 清空后重新注入,确保页面状态一致

为什么用 innerHTML 注入?

  • 流式响应中,我们无法直接操作 DOM
  • 只能通过推送 <script> 标签让浏览器执行 JavaScript
  • innerHTML 是最简单的 DOM 替换方式

5. 缓存管理

Service Worker 的缓存存储在 Cache Storage API 中,这是浏览器提供的专门用于 Service Worker 的持久化存储空间。实际上,不需要关心物理位置,因为浏览器完全管理这些文件。

function fetchAndStore(request) {
  return fetch(request)
    .then((networkResponse) => {
      if (networkResponse.ok) {
        // 克隆响应用于缓存
        const cacheResponse = networkResponse.clone();
        caches.open('my-cache').then((cache) => {
          cache.put(request, cacheResponse);
        });
      }
      return networkResponse;
    });
}

function handleFetch(request) {
  return caches.match(request)
    .then((response) => {
      if (response) {
        // 有缓存:先展示快照,再更新
        return readResponseText(response).then((snapshot) => {
          return withSnapshot(snapshot, request);
        });
      }
      // 无缓存:直接请求
      return fetchAndStore(request);
    });
}

为什么要 clone 响应?

  • Response 对象的 body 只能读取一次(流的特性)
  • 需要一份给缓存,一份给浏览器
  • clone() 创建独立的副本

工作流程详解

首次访问(无缓存)

用户访问 → Service Worker 拦截 → 无缓存 → 网络请求 → 返回内容 → 存入缓存

二次访问(有缓存)

用户访问
  ↓
Service Worker 拦截
  ↓
读取缓存快照(去除普通脚本)
  ↓
立即返回快照内容 ← 用户看到页面
  ↓
后台发起网络请求
  ↓
清空 DOM
  ↓
注入最新 head 和 body
  ↓
更新缓存

注意事项

上述只讲述了该方案的基本原理,实际应用要考虑更多的因素如App 环境兼容性、缓存策略、基础设施依赖等,下面是方案对比:

维度 客户端方案 Service Worker 方案
首次访问拦截 ✅ 可以拦截 ❌ 无法拦截
跨平台能力 ❌ 需要各端适配 ✅ Web 标准,通用
更新速度 ⚠️ 需要发版 ✅ 实时生效
开发成本 ⚠️ 需要端上开发 ⚠️ 需要 Web 开发
维护成本 ❌ 多端维护 ✅ 单一维护
灵活性 ⚠️ 受限于客户端版本 ✅ 完全可控
降级能力 ⚠️ 需要发版回滚 ✅ 秒级降级

总结:

  • 如果你的业务是纯 Web 应用(PWA) → Service Worker 是最佳选择
  • 如果你的业务在 App 内 → 优先考虑客户端方案

参考:

别再“苦力”写后台,Spec Coding “跑” 起来

从三个疑问谈谈我们的核心理念

AI能写代码早已不是新鲜话题,但当它被引入真实的日常开发流程时,工程层面的问题才真正开始暴露:

  • 为什么在多数团队里,AI 写代码仍然时好时坏?
  • 为什么同样一句指令,有时像资深工程师,有时却像实习生?
  • 为什么生成速度上来了,代码却越来越难维护?

这些困惑并不意外——它们几乎是当前 AI Coding 的“常态体验”。 但是在我们的中后台系统中,AI Coding 却能持续稳定地高质量输出,同时我们还落地了相关的一套工程体系,效果非常直接:

原来一个页面前端编码需要半天,现在只需要一句话 → 5~10分钟就自动生成、可直接运行。

不仅速度快,质量还稳得惊人——同一个需求,不同人生成的代码结构完全一致,像是资深工程师写出来的。

这意味着:

  • 研发从大量重复编码中解放出来,把精力投入到复杂问题、架构设计与体验打磨上;
  • 新人入职即可交付,无需先花时间熟悉庞大的框架细节;
  • 团队整体交付速度成倍提升,质量更可控。

目前雪球内部近60% 的中后台新页面由 AI Coding 自动生成。

那么问题来了:

为什么同样使用 AI,差距会如此之大?

答案,不在模型本身,而在于——有没有建立一套人与 AI 的标准化沟通方式。自然语言本身具有模糊性,而软件开发却高度依赖精确表达,这种张力正是差距产生的根源。

对此,亚马逊云科技首席软件工程师 Claire Liguori 在其关于 Kiro IDE 的实践中指出,开发者要想让 AI 生成符合预期的软件,就必须采用一种更加结构化、精确的沟通方式,也就是我们遵循的核心理念:Spec-driven development(规范驱动开发),通过标准化、可验证的规范,将模糊需求转化为可执行、可追溯的工程过程。

亚马逊 CTO Werner Vogels 也曾更进一步强调,Spec-driven development 不是官僚主义,而是一种工程纪律;Apollo 登月项目的成功,在很大程度上正是建立在这种纪律之上。

那我们具体是如何践行 Spec-driven development 的呢?接下来,我们先从 Spec Coding 与 Vibe Coding 的区别 说起。

一、为什么是 Spec Coding,而不是 Vibe Coding

1、Vibe Coding:看起来很聪明,用起来很心虚

2025年2月,OpenAI 联合创始人、AI 研究者 Andrej Karpathy 首次提出 Vibe Coding(氛围编程),引发了编程的新变革。例如,你给它一句指令:

“做一个有列表和搜索的用户管理页”

它会写代码,写得还挺像回事。

但一旦进入真实工程,就会暴露问题:

  • 易出现理解偏差:接口、字段、业务逻辑经常“凭空被 AI 想象出来”;
  • 风格不统一:每次生成结构都不一样,“像每个工程师风格都完全不同”;
  • 越复杂越不稳:跨模块、校验逻辑、多状态处理、复杂组件等情况容易崩溃;
  • 维护困难:代码不是不能用,但半年后团队没人敢动。

一句话总结:

Vibe Coding = 即兴发挥,爽在当下,痛在维护。

适合 Demo、不严谨的业务评审、原型验证。但无法成为工程体系,难以支撑企业大规模真实项目。

2、Spec Coding:让 AI 按“规格”工作,而不是靠感觉

2025 年下半年亚马逊、OpenAI等头部大厂相继提出 Spec Coding 相关理念,即“规格驱动开发”,逐渐被社区和平台采纳 。Spec 的本质是:指令不是“感觉式”,而是“结构化、可解析、零歧义”。

举例:

“生成一个标准的用户管理页

数据接口:/user/list(GET)

包含搜索:name(输入框)、status(下拉)

列表字段:name、status、createTime 支持分页、批量删除。”

这是 AI 最擅长处理的输入:固定格式、强结构化、有明确上下文。

好处也极其显著:

  • 完全避免幻觉:所有字段、接口、组件都从规范解析;
  • 代码质量稳定如同资深工程师:结构统一、抽象一致、可维护;
  • 跨项目大规模复用:规范体系 + Agent能力 = 自动生成能力;
  • 越做越强:规范体系的积累与完善会持续增强编程能力。

一句话总结:

Spec Coding = 面向工程体系的 AI 生产方式。

它关注的不是“一次写得有多快”,而是长期是否稳定、是否可控、是否能规模化落地

小结:程序员不是被替代,而是完成了一次角色升级

在 AI 时代,更有价值的能力,不再只是“把代码写出来”,而是“精准表达意图的沟通”。将目标、约束与成功标准等内容严密通过规范结构化的表达写进规范,代码只是沟通与决策后的一种下游产物。程序员的价值由写代码转为写规范,即程序员负责战略(定义做什么以及为什么),AI负责战术(精确地实现如何做)。

这也是为什么我们中后台系统选择 Spec Coding,而不是 Vibe Coding。那我们是如何在中后台体系落地的呢?

二、AI Coding 的现实挑战与中后台系统的契合点

在经历了从代码补全到智能生成的快速演进后,AI Coding 的能力已经进入“可用但不稳定”的阶段。它能理解自然语言生成结构化代码,显著提升研发效率,却也在工程落地中暴露出一系列问题:

  • 理解偏差与幻觉问题:模型可能凭空生成不存在的函数或接口,或因需求描述模糊而误解逻辑,导致输出结果看似正确却无法运行;

  • 代码质量与规范不统一:生成代码易出现冗余膨胀、风格不统一、缺乏抽象与复用,短期可用,长期维护成本高;

  • 复杂场景难把握:在复杂场景下容易出现逻辑不一致、上下文理解不完整、代码结构混乱等问题,难以稳定生成可直接落地的工程级方案。

可以说,当前的 AI Coding 处理“模板化”的场景更精准,而在涉及复杂业务逻辑时问题会显著增多。当任务描述清晰、结构明确时,AI 能高效生成高质量代码;但面对抽象、依赖上下文理解的业务需求时,往往容易出现偏差或理解错误。

中后台系统相对于C端需求而言,有着更强的“规律性”和“结构化”特征,这与 AI Coding 在规则明确、结构清晰场景中的优势高度契合。中后台场景具备以下天然优势:

  • 结构化且规范化:中后台页面以表格、表单、搜索等模块为主,功能模式固定、组件体系规范且完善。能有效约束生成结果,降低“幻觉”风险;
  • 规模大且可控:中后台业务页面数量多、相似度高,配合统一模板和规范体系,AI 能在此类场景中实现高效、批量的代码生成与维护,加速需求交付的全流程。

小结:中后台开发场景兼具“高重复性”与“强规范性” ,非常适合通过 Spec Coding 实现 AI Coding 的第一落地场景。它让团队在确保可维护性的同时,快速积累高质量的 AI 生成样本,并逐步向更复杂的业务逻辑延伸。

三、从指令到交付:程序员与AI是如何沟通的?

要让人与 AI 之间实现真正高效的沟通,我们没有设计复杂的指令,而是极简的一句话

“帮我根据 [需求文档地址] 生成页面,并对接[具体环境]的中央权限系统”

即可触发完整工作流。为此,我们打造了三大 Spec 利器,并以 Cursor 作为承载这一切的工作台,把需求内容、接口信息、代码规范等全部结构化,让 AI 能在一轮沟通中就“听懂”要做什么,从而稳定产出可靠的工程级结果。

Cursor 编辑器:在 Cursor 中,我们用一句自然语言指令即可触发完整工作流:读取需求文档、调用 MCP 获取接口语义、参考UI规范并遵循 rules,生成符合工程标准的页面代码,并最终对接中央权限系统进行权限管理。Cursor 将分散的能力整合为一个连续的开发体验,让“需求→代码→调试”形成闭环,使开发者从编写代码转向指导 AI 生产代码。

三大 Spec 利器:UI 规范确保页面展示与交互统一,MCP 补充项目语义上下文,Rules 体系保障生成质量。

利器一:UI 规范为 AI 编码标准化提供基础

中后台系统的界面虽然以业务为主,但为了保证美观与一致的交互体验,我们在初期就建立了系统化的 UI 规范,并整理成 UI Spec,为后续的 AI Coding 提供了可复用、可生成的基础。

这套规范主要包含两大方面:

  • 视觉与交互标准:统一主题、字体、间距、页面模块布局、操作交互等规则,使整个项目保持一致的观感与操作体验;
  • 组件化体系:常用模块(搜索框、列表、弹窗表单等)均来自公共组件库,AI 在生成页面时可以直接复用现有标准组件而非重新造轮子。

基于这套体系,AI Coding 不再是“随意绘制界面”,而是在约束下生成标准化的 UI。这既能保证所有界面的一致性,也为自动化开发提供坚实基础。

利器二:MCP 是 AI 编码的“信息中枢”

MCP(Model Context Protocol)能连接到外部系统和数据,让模型真正理解项目语境,具备“看得见、听得懂、做得准”的能力,是 AI 编码中重要的“信息中枢”。我们核心 MCP 包含以下两个:

1. YApi MCP

YApi 是一个接口管理与文档平台,前后端等角色可在同一平台查看并对接接口,避免沟通混乱与信息不一致等问题。得益于后端团队长期、规范地维护 YApi 文档,接口信息始终保持准确、完整。

在此基础上,YApi MCP 自动获取接口的请求地址、参数、响应字段等内容,为模型提供权威、最新的 Api Spec,从源头上避免大模型凭猜测生成接口信息。

2. Auth-center MCP

中央权限系统是公司内部所有中后台系统统一管理权限的平台。

Auth-center MCP 通过接口调用的方式快速接入权限系统,包括接口自动定级、资源自动新增、页面接口绑定等权限管理全流程配置,既能提高接入效率,更能避免人工操作失误。

基本的 MCP 配置如下:

{
      "mcpServers": {
        "auth-center-mcp": {
          "command": "npx",
          "args": [
            "-y",
            "auth-center-mcp",
            "--username=***",
            "--password=***"
          ]
        },
        "yapi-mcp": {
          "command": "npx",
          "args": [
            "-y",
            "yapi-mcp",
            "--yapiHost=***",
            "--username=***",
            "--password=***"
          ]
        }
      }
    }

通过 MCP 的接入,模型像是装上了“千里眼”,不再是一个“闭门造车”的助手,而是能真正读懂上下文的团队成员。

利器三:Rules 让生成代码具备“专业级水准”

大模型虽然强大,但它容易幻觉生成冗余且不易维护的代码,为了控制它的输出质量,我们制定了一系列 Spec 并沉淀到 cursor的 rules 文件中,在编程过程中提供持久的、可复用的上下文,这些 Spec 既是工程的底线,也是 AI 的知识库。

在众多rules文件中,有6个对生成的代码质量至关重要:

 ---
    description:
    globs:
    alwaysApply: true
    ---

    ## 文件组织

    项目开发规范已按功能模块拆分到 `./rules/` 目录中:

    - `project-overview.mdc` - 项目概述和核心开发原则
    - `requirements-reading.mdc` - 读取需求文档的规范
    - `api-development.mdc` - 接口调用规范和数据格式处理规范
    - `component-usage.mdc` - 公共组件使用规范
    - `forbidden-practices.mdc` - 禁止事项和常见错误预防
    - `auth-center.mdc` - 接入中央权限规范
    ... 其他rules文件
  • project-overview.mdc:定义项目的核心开发原则与工程约束,提供标准CRUD的最佳实践,是所有生成任务的总纲;

  • requirements-reading.mdc:指导大模型从需求文档中,精准读取页面相关需求内容;

  • api-development.mdc:规范接口请求方式,确保接口调用及数据处理相关代码符合团队约定;

  • component-usage.mdc:指导大模型遵循设计规范,并正确使用公共组件,在避免重复造轮子的同时,还能在生成阶段对齐UI规范;

  • auth-center.mdc:提示大模型根据代码找出页面所有操作按钮及对应接口,组装成特定schema数据,并调用对应mcp,确保权限按需接入中央权限系统;

  • forbidden-practices.mdc:列出禁止写法与常见错误,比如直接操作 DOM、跳过 YApi 文档直接开发等。

AI 生成的代码会自动对照这些 rules 进行校验,不符合规范的部分会触发二次修正,最终让 AI 的输出更接近资深开发者的专业级水准。

四、后续展望:我们还想做更多

目前只是 AI Coding 的第一步,我们还将基于规范驱动开发继续探索以下方向:

  • 扩大覆盖面:目前主要支持部分中后台项目的常规页面,后续会逐步提升中后台项目使用占比;同时扩展到支持复杂交互页面;更进一步,做到走出中后台项目,支持要求更严格、形式更多变的C端系统;
  • 更细粒度的回溯与优化:让 AI 输出过程具备“可回放”、“可解释”、“可优化”的能力;
  • 全流程智能化: 从上游提出业务需求(想法),智能生成需求文档,再到自动 coding、测试,最后完成自动提交和部署。

移动端H5弹窗“滚动穿透”的终极解决方案:为什么 overflow: hidden 没用?

移动端弹窗“滚动穿透”的终极解决方案:为什么 overflow: hidden 没用?

在移动端 H5 开发中,“滚动穿透”(Scroll Chaining / Ghost Scroll)绝对是让无数前端开发者血压升高的经典 Bug 之一。

什么是“滚动穿透”?

场景很简单:

  1. 你打开了一个全屏弹窗(Modal/Popup)。
  2. 弹窗下面有一层长列表背景。
  3. 当你在弹窗上滑动手指时,底下的背景页面竟然跟着一起滚动了!

这不仅体验极差,还容易导致弹窗错位或用户迷失上下文。

image.png

常见误区:以为 CSS 就能搞定

大多数人的第一反应是:“这还不简单?给 body 加个 overflow: hidden 不就行了?”

/* ❌ 只有 PC 端有效,移动端经常翻车 */ body.modal-open { overflow: hidden; }为什么失效? 在 PC 端,overflow: hidden 确实能隐藏滚动条并禁止滚动。但在移动端(特别是 iOS Safari),浏览器认为 body 的滚动是“视口(Viewport)”级别的特性。即使你禁用了 body 的溢出,用户手指在屏幕上拖拽(Touch Events)时,浏览器依然会触发默认的滚动行为,甚至引发橡皮筋效果。

进阶方案:阻止 touchmove(有副作用)

第二种常见的方案是直接阻止弹窗遮罩层的触摸事件:

// ❌ 这种一刀切的方案会导致弹窗内部也无法滚动 modal.addEventListener('touchmove', (e) => { e.preventDefault(); }, { passive: false });

局限性: 如果你的弹窗内部本身就需要滚动(比如一个长长的语言选择列表),这行代码会把弹窗内部的滚动也一并杀掉,导致“死锁”。虽然可以通过判断 target 来优化,但逻辑非常繁琐。

终极解决方案:Body 固定定位法(Position Fixed)

目前业界(包括 Ant Design Mobile, Vant 等主流组件库)公认的最稳妥方案,就是**“Body 固定定位法”**。

核心原理

既然禁止滚动失效,那我们就从物理上切断滚动的可能。 当弹窗打开时,我们将 body 设置为 position: fixed。一个固定定位的元素,天然就是死死钉在屏幕上的,无论你怎么滑,它都不可能动。

带来的新问题:页面跳顶

单纯设置 position: fixed 会导致一个严重的副作用:页面会瞬间跳回到顶部。因为脱离文档流后,scrollTop 丢失了。

完整代码实现

为了解决跳顶问题,我们需要在“锁定”前记录当前的滚动位置,并在“解锁”后恢复它。

这正是我们项目 Popup 组件中那段 useEffect 代码的逻辑:

// React Hook 示例

useEffect(() => {
  if (visible) {
    // 1. 🔒 锁定:记录位置并固定 Body
    const scrollTop = window.scrollY || document.documentElement.scrollTop;
    
    document.body.style.position = 'fixed';
    document.body.style.top = `-${scrollTop}px`; // 把页面“拉”回原来的视觉位置
    document.body.style.width = '100%';
    
    // 存起来,一会儿还要用
    document.body.dataset.scrollY = scrollTop.toString();
    
  } else {
    // 2. 🔓 解锁:恢复样式并滚动回去
    const scrollTop = parseInt(document.body.dataset.scrollY || '0', 10);
    
    document.body.style.position = '';
    document.body.style.top = '';
    document.body.style.width = '';
    
    // 恢复滚动位置,让用户无感知
    window.scrollTo(0, scrollTop);
  }
}, [visible]);

代码解析

  1. document.body.style.position = 'fixed':这是核心,强制禁止滚动。
  2. top = -${scrollTop}px:这是精髓。假设你滚到了 500px 的位置,为了防止变为 fixed 后跳回 0px,我们给 body 一个 -500px 的偏移量,视觉上页面就纹丝不动了。
  3. window.scrollTo(0, scrollTop):关闭弹窗时,解除 fixed,此时页面真的回到了 0px,我们必须立即用 JS 把它滚回到之前的 500px,实现无缝衔接。

总结

虽然这段 JS 代码看起来有点“重”,甚至操作了 DOM,但它目前是解决移动端(尤其是 iOS)滚动穿透问题兼容性最好、副作用最小的方案。

下次遇到弹窗滚动穿透,别再纠结 overflow: hidden 了,直接上“Body 固定定位法”吧!


❌