前言:别从零开始了,偷个懒不好吗?
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.vue → Header.vue
→ utils/helper.ts
helper.ts 变了 → 只更新 App.vue(Header 不受影响)
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
| Vite | Webpack | |
|---|---|---|
| 更新速度 | 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 篇文档的时间。
如果你也想动手试试,只需要三步:
npm create vue@latest,或者直接去 GitHub 拉个模板- 把上面的代码贴进
App.vue - 配置好 API Key,
npm run dev
然后看着 AI 一个字一个字地给你讲故事吧。🐉