从零写一个 AI 流式聊天应用,顺便搞懂了那些"说不清"的前端概念

0 阅读6分钟

前言:别从零开始了,偷个懒不好吗?

Agent 越来越像人了,那我们为什么还要从零写项目?

AI 时代有个悖论:AI 越来越强,但我们还是习惯性地 npm create vue@latest,然后手动删掉 HelloWorld.vue,再一个一个装依赖。

等等——既然 AI 擅长搬砖,为什么不把项目初始化甩给它?

我到 GitHub 拉了个 Vite + Vue 3 模板项目,然后让它帮我接入 DeepSeek 的流式输出 API。结果不仅项目跑起来了,还顺便把几个以前"似懂非懂"的概念彻底搞明白了。

这篇文章就是我的踩坑笔记。读完你会理解:

  • ref() 到底是什么,为什么 .value 这么烦人
  • 绑定 是 DOM 操作的"自动挡"
  • 热更新 为什么改一行代码页面就变了,状态还不丢
  • 流式输出 的底层究竟长什么样——从二进制到文字的完整旅程

文末有完整可运行的代码,直接 CV 就能跑。


一、ref() — 一个会通知的盒子

1.1 问题:为什么需要 ref?

先看一段原生 JS:

let count = 0
document.getElementById('btn').addEventListener('click', () => {
  count++
  document.getElementById('display').textContent = count  // 手动改 DOM
})

熟悉吗?数据变了,你得手动去更新 DOM。一个变量还好,十个呢?一百个呢?而且两个地方用到同一个变量怎么办?

你写的是"怎么做",而不是"是什么"。

1.2 ref 登场:数据驱动视图

import { ref } from 'vue'

const count = ref(0)  // 给数字穿上"响应式外衣"
// count.value++  →  页面上所有用到 count 的地方自动更新
ref() 的本质:
┌─────────────────────────┐
│  RefImpl 对象            │
│  ┌─────────────────────┐ │
│  │ _value: 0           │ │  ← 实际存的值
│  │ get value() {       │ │  ← 读取时:收集"谁在用我"
│  │   track(...)        │ │
│  │   return _value     │ │
│  │ }                   │ │
│  │ set value(newVal) { │ │  ← 写入时:通知"所有用我的人"
│  │   trigger(...)      │ │
│  │   _value = newVal   │ │
│  │ }                   │ │
│  └─────────────────────┘ │
└─────────────────────────┘

用大白话说:ref 是一个智能盒子,你往里面放东西它会记着,你改东西它会通知所有用到它的地方。

普通变量:let x = 1;  x = 2  →  无事发生
ref 变量:const x = ref(1);  x.value = 2  →  页面自动刷新 ✅

1.3 为什么 JS 里要 .value,模板里不用?

<script setup>
const name = ref('karina')
console.log(name.value)  // JS 里必须 .value
</script>

<template>
  <p>{{ name }}</p>      <!-- 模板里自动解包,不用 .value ✅ -->
</template>

模板里的 {{ }} 是 Vue 的地盘,Vue 帮你做了 ref.value 这一步。 JS 里你需要自己动手,因为 JS 不知道什么叫"响应式"。


二、绑定 — DOM 操作的"自动挡"

Vue 里有三种绑定,本质上都是把 JS 数据和 DOM 绑在一起,一方变,另一方自动跟着变

2.1 文本绑定 {{ }} — 数据 → 页面

<p>{{ content }}</p>  <!-- content.value 变了,<p> 自动更新 -->

以前你写 document.querySelector('p').textContent = newValue,现在只需要改数据。

2.2 属性绑定 : — 数据 → 属性

<img :src="imgUrl">        <!-- imgUrl 变了,图片自动换 -->
<div :class="{ active: isActive }">  <!-- isActive 为 true 就加 active 类 -->

:v-bind 的简写。凡是你想动态控制的 HTML 属性,前面加个冒号就行。

2.3 事件绑定 @ — 页面 → 数据

<button @click="update">发送</button>   <!-- 点了就调 update -->
<input @keydown="e => console.log(e.key)">  <!-- 按键事件 -->

@v-on 的简写。用户的操作(点击、输入、滚动...)通过事件传回 JS。

2.4 双向绑定 v-model — 数据 ↔ 页面

<textarea v-model="question"></textarea>
<!-- question 变了 → textarea 显示新内容 -->
<!-- 用户输入了 → question 自动更新 -->

本质是语法糖:

v-model="question"
  等价于
:value="question"        ← 数据→页面
@input="e => question = e.target.value"  ← 页面→数据

一张表总结

写法方向场景原生等价
{{ data }}数据→页面显示文字.textContent =
:attr="data"数据→页面动态属性.setAttribute()
@event="fn"页面→数据用户操作.addEventListener()
v-model双向表单上面两个加起来

绑定的核心思想:你不再操作 DOM,你只操作数据。页面只是数据的一面镜子。


三、热更新 HMR — 为什么改一行代码页面就变了?

3.1 没有热更新的时候

改代码 → 手动刷新浏览器 → 表单重填 → 状态丢失 → 回到刚才的页面位置 → 😤

3.2 有热更新

改代码 → 页面局部更新 → 状态完好 → 立刻看效果 → 😎

3.3 原理拆解

HMR 由 Vite 提供,流程如下:

你保存文件
  ↓
chokidar 监听到文件变更
  ↓
Vite 重编译这个模块(只编译这一个!不是整个项目)
  ↓
WebSocket 推送更新消息给浏览器:"/src/App.vue 变了"
  ↓
浏览器客户端接收消息,精确定位到受影响的模块
  ↓
原地替换模块,保留所有状态

关键点:

1. 依赖图:知道谁在用谁

Vite 维护了一张模块图:
App.vueHeader.vue
        → utils/helper.ts

helper.ts 变了 → 只更新 App.vueHeader 不受影响)

2. WebSocket 长连接:秒级推送

你保存文件的那一瞬间,Vite Server 就通过 WebSocket 告诉浏览器:
"喂,这个文件变了,新地址是 /src/App.vue?t=123456"

3. 为什么 CSS 热更新最丝滑?

// CSS 的 HMR:直接替换 <style> 标签内容
// 不需要重新执行 JS,不需要重新渲染组件
// 改颜色、改字号 → 所见即所得

4. Vue 组件的热更新:比你想的更聪明

// Vue HMR Runtime 做的事情:
// 1. 找到内存中的组件定义
// 2. 替换 render 函数
// 3. 保留组件的 data/state(表单内容、变量状态都在!)
// 4. 触发重新渲染
//
// 所以你在一个表单里填了 10 个字段,改了按钮样式后
// → 样式变了,表单内容还在!这就是 HMR 的魅力

一张表对比 Vite vs Webpack

ViteWebpack
更新速度O(1),项目再大也秒更O(n),项目越大越慢
原理浏览器原生 ESM,改哪个文件编译哪个需要先打包整个 bundle
启动毫秒级几十秒起步

四、流式输出 — 从二进制到文字的全过程

这是本文最硬核的部分,也是最爽的。我们先搞清楚概念,再一行行看代码。

4.1 为什么需要"流式"?

非流式:你发请求 → 等 10 秒 → 整段回复一次性显示
流式:  你发请求 → 立刻开始显示 → 一个字一个字往外蹦 → 像 ChatGPT 那样

用户感知上的差距就是 10 秒和 0.1 秒的差距。

4.2 数据到底是怎么"流"的?

DeepSeek 服务器
    ↓ (不停地往水管里灌数据)
    │  data: {"choices":[{"delta":{"content":"你"}}]}
    │  data: {"choices":[{"delta":{"content":"好"}}]}
    │  data: {"choices":[{"delta":{"content":"吗"}}]}
    │  data: [DONE]
    ↓
你的浏览器 ← 通过 response.body.getReader() 一口一口嘬数据

4.3 代码逐层拆解

第一层:发送请求

const response = await fetch(endpoint, {
  method: 'POST',
  headers: { Authorization: `Bearer ${API_KEY}` },
  body: JSON.stringify({
    model: 'deepseek-v4-flash',
    messages: [{ role: 'user', content: '讲一个故事' }],
    stream: true  // ← 这个字段说"我要流式返回"
  })
})

到这一步,你拿到了 response——但不是数据本身,而是一根水管

第二层:装水龙头

const reader = response.body.getReader()
// response.body  = 水管
// getReader()    = 在水管这头装个水龙头
// reader.read()  = 拧开水龙头,嘬一口

第三层:嘬数据

let buffer = ''  // 水桶,临时存没处理完的数据

while (true) {
  const { done, value } = await reader.read()
  // done:  服务器那边关水龙头了吗?
  // value: 这一口嘬到的数据(Uint8Array 二进制)

  if (done) break  // 没水了,结束

第四层:二进制 → 文字

  buffer += decoder.decode(value, { stream: true })
  // value 是 Uint8Array,比如 [104, 101, 108, 108, 111]
  // decode 后变成 "hello"
  // { stream: true } 告诉翻译器:"后面还有,别急着判断这句话完了"

第五层:拆解 SSE 格式

服务器返回的数据长这样:

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

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

data: [DONE]

处理逻辑:

  const lines = buffer.split('\n')         // 按换行切分
  buffer = lines.pop() || ''              // 最后一行可能不完整,放回桶里

  for (const line of lines) {
    if (!line.trim().startsWith('data:')) continue  // 不是 data: 开头,跳过

    const jsonStr = line.trim().slice(5).trim()     // 去掉 "data:" 前缀
    if (jsonStr === '[DONE]') break                 // 结束信号

    const parsed = JSON.parse(jsonStr)              // JSON → JS 对象
    const delta = parsed.choices?.[0]?.delta?.content  // 取出新增文字
    if (delta) {
      content.value += delta  // ← 追加到页面上!
    }
  }
}

4.4 一图胜千言

二进制流                              SSE 文本                           页面
─────────                            ────────                           ────
Uint8Array[68, 97, 116, ...]  →     data: {"choices":...}     →       "你好"
Uint8Array[...]               →     data: {"choices":...}     →       "你好吗"
Uint8Array[...]               →     data: [DONE]              →       (结束)

翻译器: TextDecoder                 手动解析: split/slice/JSON.parse     响应式更新: ref

4.5 为什么需要一个 buffer?

因为二进制数据是分块到达的,一块不一定等于一行完整的 SSE 数据。

第一口嘬到: "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\ndata: {\"ch"
                                                               这里断了!
第二口嘬到: "oices\":[{\"delta\":{\"content\":\"世界\"}}]}\n"

buffer 的作用:第一口的不完整行暂存在桶里,等第二口到了拼成完整行再处理

五、完整代码

把上面的所有概念串起来,就是一个 90 行能跑的 AI 聊天应用:

<script setup>
import { ref } from 'vue'

const question = ref('你好,请讲一个故事')
const content = ref('')
const streaming = ref(true)

const decoder = new TextDecoder()  // 二进制→文字翻译器,复用

const update = async () => {
  if (!question.value) return
  content.value = '思考中...'

  const response = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',
    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: streaming.value
    })
  })

  if (!response.ok) {
    content.value = `请求失败: ${response.status}`
    return
  }

  if (streaming.value) {
    content.value = ''
    const reader = response.body?.getReader()
    if (!reader) return

    let buffer = ''
    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n')
      buffer = lines.pop() || ''

      for (const line of lines) {
        const trimmed = line.trim()
        if (!trimmed || !trimmed.startsWith('data:')) continue
        const jsonStr = trimmed.slice(5).trim()
        if (jsonStr === '[DONE]') break
        try {
          const delta = JSON.parse(jsonStr).choices?.[0]?.delta?.content
          if (delta) content.value += delta
        } catch {}
      }
    }
  } else {
    const data = await response.json()
    content.value = data.choices?.[0]?.message?.content || '无返回内容'
  }
}
</script>

<template>
  <div class="container">
    <textarea v-model="question" rows="3" placeholder="输入你的问题"></textarea>
    <button @click="update">发送</button>
    <div class="output">{{ content }}</div>
  </div>
</template>

<style>
.container {
  display: flex; flex-direction: column; align-items: start;
  height: 100vh; padding: 20px; box-sizing: border-box; font-size: 0.85rem;
}
textarea { width: 100%; max-width: 500px; margin-bottom: 10px; padding: 8px; }
.output { margin-top: 20px; width: 100%; max-width: 500px; white-space: pre-wrap; line-height: 1.6; }
button { padding: 6px 16px; cursor: pointer; }
</style>

记得在项目根目录创建 .env 文件,写入 VITE_DEEPSEEK_API_KEY=你的APIKey


六、总结

这篇文章我们一路走下来,把这些概念串成了一条线:

ref() 响应式数据     →  数据变了,页面自动变
绑定 ({{ }} : @ v-model)  →  你不再操作 DOM,只操作数据
热更新 HMR          →  改代码瞬间看效果,状态不丢失
流式输出            →  二进制 → 翻译 → SSE 解析 → 逐字显示

技术的本质往往不难,难的是有人用大白话把它讲清楚。 希望这篇文章帮你省掉了看 10 篇文档的时间。

如果你也想动手试试,只需要三步:

  1. npm create vue@latest,或者直接去 GitHub 拉个模板
  2. 把上面的代码贴进 App.vue
  3. 配置好 API Key,npm run dev

然后看着 AI 一个字一个字地给你讲故事吧。🐉