阅读视图

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

前端性能加速器:Vue Router懒加载与组件分包的极致优化

引言:当应用变"重"时,我们需要更聪明的加载策略

在现代前端开发中,随着Vue应用功能日益丰富,组件数量不断增加,一个常见的问题逐渐浮现:初始加载时间越来越长。用户打开应用时,浏览器需要下载整个应用的JavaScript代码,包括那些用户可能永远不会访问的页面和功能。这不仅浪费了带宽,更直接影响了用户体验。有没有一种方法,可以"按需分配"资源,只在用户需要时才加载相应代码?这就是我们今天要探讨的Vue Router懒加载与组件分包优化的核心价值。

第一部分:传统路由加载的问题剖析

在深入了解优化方案前,让我们先看看传统路由配置的局限性:

// 传统的静态导入方式
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import Contact from '@/views/Contact.vue'
import Dashboard from '@/views/Dashboard.vue'
// ...可能还有几十个组件

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  { path: '/contact', component: Contact },
  { path: '/dashboard', component: Dashboard },
  // ...所有路由在应用初始化时就已经被加载
]

这种模式下,无论用户访问哪个页面,所有路由组件都会被打包进同一个JavaScript文件中。这意味着:

  1. 首屏加载时间长:用户需要等待所有代码下载完成
  2. 资源浪费严重:用户可能永远不会访问某些页面,但这些页面的代码已经被加载
  3. 缓存效率低:任何微小改动都会导致整个包需要重新下载

第二部分:懒加载路由——按需加载的艺术

Vue Router的懒加载功能通过动态导入(Dynamic Import)实现了路由组件的按需加载:

// 使用懒加载的动态导入语法
const routes = [
  { 
    path: '/', 
    component: () => import('@/views/Home.vue') 
  },
  { 
    path: '/about', 
    component: () => import('@/views/About.vue') 
  },
  { 
    path: '/contact', 
    component: () => import('@/views/Contact.vue') 
  },
  { 
    path: '/dashboard', 
    component: () => import('@/views/Dashboard.vue') 
  }
]

懒加载的工作原理

当用户访问应用时,只有首页(Home)组件会被加载。当用户点击导航到"/about"时,Vue Router才会动态请求About组件的代码块。这种机制带来了显著的优化效果:

  1. 减少初始包体积:首屏只需要加载必要的代码
  2. 并行加载能力:不同路由的代码可以并行下载
  3. 智能缓存策略:每个路由组件可以独立缓存

第三部分:高级分包策略——让优化更进一步

基本的懒加载已经带来了显著提升,但我们可以通过更精细的分包策略进一步优化:

1. Webpack魔法注释:命名与分组

const routes = [
  {
    path: '/user/profile',
    component: () => import(/* webpackChunkName: "user-pages" */ '@/views/user/Profile.vue')
  },
  {
    path: '/user/settings',
    component: () => import(/* webpackChunkName: "user-pages" */ '@/views/user/Settings.vue')
  },
  {
    path: '/admin/dashboard',
    component: () => import(/* webpackChunkName: "admin-module" */ '@/views/admin/Dashboard.vue')
  }
]

通过webpackChunkName注释,我们可以:

  • 相关组件打包到一起:减少HTTP请求数量
  • 创建有意义的文件名:便于调试和维护
  • 实现更精细的缓存控制

2. 预加载与预获取策略

Vue Router 4.x 提供了更智能的预加载机制:

// 配置预加载策略
const router = createRouter({
  routes,
  // 预加载视口内链接对应的路由
  scrollBehavior(to, from, savedPosition) {
    if (to.matched.length) {
      // 预加载路由组件
      to.matched.forEach(record => {
        if (typeof record.components.default === 'function') {
          record.components.default()
        }
      })
    }
    return savedPosition || { top: 0 }
  }
})

3. 按用户角色分包

针对不同用户角色进行代码分割:

// 根据用户角色动态加载不同的模块
const getUserRoleModule = (role) => {
  switch(role) {
    case 'admin':
      return () => import('@/modules/admin')
    case 'editor':
      return () => import('@/modules/editor')
    default:
      return () => import('@/modules/user')
  }
}

// 在路由守卫中应用
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAdmin) {
    const adminModule = getUserRoleModule('admin')
    adminModule().then(() => next())
  } else {
    next()
  }
})

第四部分:最佳实践与性能监控

分包优化检查清单

  1. 路由层级分包:按功能模块划分代码块
  2. 第三方库分离:将Vue、Vuex、Vue Router等库单独打包
  3. 公共组件提取:提取跨路由使用的组件到公共块
  4. 动态导入Vue组件:在组件内部也使用动态导入

性能监控与度量

// 添加路由加载性能监控
const loadStartTime = Date.now()

router.beforeEach((to, from, next) => {
  const startTime = performance.now()
  
  next()
  
  // 监控路由切换性能
  const loadTime = performance.now() - startTime
  if (loadTime > 1000) {
    console.warn(`路由 ${to.path} 加载时间过长: ${loadTime}ms`)
  }
})

第五部分:Vite环境下的优化差异

如果你使用Vite而非Webpack,懒加载的实现更加简洁:

// Vite中的动态导入(无需特殊配置)
const routes = [
  {
    path: '/dashboard',
    component: () => import('../views/Dashboard.vue')
  }
]

Vite利用原生ES模块特性,在开发环境中提供极快的热更新,在生产构建时自动进行代码分割。

结语:打造轻盈而强大的Vue应用

Vue Router懒加载与组件分包优化不仅是一种技术实现,更是一种用户体验至上的开发哲学。通过将"一次性加载"转变为"按需加载",我们不仅减少了初始加载时间,还创造了更加流畅、响应更快的应用体验。

记住,优化的核心在于平衡:代码分割的粒度越细,HTTP请求越多;分割的粒度越粗,加载冗余越多。优秀的开发者需要根据具体应用场景找到最佳平衡点。

在当今追求极致用户体验的时代,掌握懒加载与分包优化技术,意味着你能够打造出既功能丰富又响应迅速的前端应用。这不仅是技术能力的体现,更是对用户时间与体验的尊重。开始优化你的Vue应用吧,让每一个字节的加载都有其价值,让每一次用户交互都流畅自然。

优化之路,永无止境;用户体验,始终至上。

别等了!用 Vue 3 让 AI 边想边说,字字蹦到你脸上

🚀 Vue 3 流式输出实战:从零开始玩转LLM对话!

你以为AI对话边生成边输出是魔法?不!这是前端工程师的浪漫操作!✨


🌟 一、整体功能:让AI在你眼前“活”起来

想象一下:你输入"讲一个喜洋洋和灰太狼的故事,200字",然后每个字都像打字机一样蹦出来,而不是等整段答案蹦出来!这就是我们今天要实现的流式输出。用Vue 3 + DeepSeek API,打造一个能实时吐字的AI小助手,让科技感拉满!🎯

废话不多说,看效果:

QQ2025128-02424.gif


💻 二、项目初始化:Vite,前端脚手架的扛把子!

"Vite 是最近最优秀的前端脚手架" —— 这句话我反复念了10遍才敢信!😱

  1. npm init vite@latest

用最新版的 Vite 脚手架初始化一个新项目!执行这行命令后,终端会问你几个问题:

Project name: » my-ai-chat      ← 你起的项目名
Select a framework: » Vue       ← 选 Vue
Select a variant: » JavaScript  ← 用 JS(不是 TS// 选 **Vue 3** + **JavaScript**(别选TypeScript,新手友好!)

2. cd your-project + npm install + npm run dev

切换到刚刚创建的项目文件夹+下载项目需要的所有依赖(第三方库)+启动开发服务器在浏览器里看到网页

  1. 项目结构瞬间搭建好:src/ 是你的主战场!📁

所有组件、逻辑、样式都在这里写

my-ai-chat/
├── src/
│   ├── App.vue        ← 根组件(整个应用的入口)
│   └── main.js        ← 启动文件(把 App.vue 挂到 HTML 上)
├── index.html         ← 单页应用的 HTML 入口
├── vite.config.js     ← Vite 配置文件(比如代理设置)
└── package.json       ← 项目信息 + 依赖列表 + 脚本命令

💡 为什么Vite这么香?
传统脚手架要编译几秒,Vite直接秒开!就像从自行车升级到火箭🚀


📝 三、App.vue:三明治结构,前端的"灵魂"!

<script setup> <!-- 逻辑层(核心!) -->
// ja代码
</script>

<template> <!-- 视图层(UI) -->
    // html代码
  <div>输入框+按钮</div>
</template>

<style scoped> <!-- 样式层(美颜) -->
// css代码
</style>

三明治精髓
你只管写业务逻辑(script),Vue自动帮你管DOM(template)!
以前要写document.getElementById的苦日子,一去不复返!😭


💡 四、响应式数据:从DOM操作到"写代码像写日记"的革命

1、过去:手动操作 DOM 的“机械时代”:

在没有 Vue/React 的年代,想实现一个“点击按钮数字+1”的功能,你得这样写:

✅ 原生 JavaScript(HTML + JS 分离)

html

<!-- index.html -->
<div>
  <p id="count">0</p>
  <button id="btn">+1</button>
</div>

<script>
let count = 0; // 数据

// 1. 找到按钮和显示区域
const btn = document.getElementById('btn');
const countEl = document.getElementById('count');

// 2. 监听事件
btn.addEventListener('click', () => {
  // 3. 修改数据
  count++;
  // 4. 手动同步到 DOM
  countEl.innerText = count;
});
</script>

问题在哪?

  • 要先找 DOM 元素(getElementById
  • 数据变了,必须手动更新 UI
  • 如果有多个地方显示 count,你得改 N 次!
  • 代码像“操作手册”:先做A,再做B,最后做C……繁琐且毫无乐趣!

2、现在:Vue 3 的“响应式浪漫时代”:

在 Vue 3 中,同样的功能,只需关注数据本身,UI 自动跟着变!

✅ Vue 3 示例(使用 <script setup>

vue

<!-- App.vue -->
<script setup>
import { ref } from 'vue'

// 1. 定义响应式数据
const count = ref(0)

// 2. 定义业务逻辑(只关心数据!)
function increment() {
  count.value++ // 注意:要加 .value!
}
</script>

<template>
  <!-- 3. 模板直接消费数据 -->
  <div>
    <p>{{ count }}</p>          <!-- 显示数据 -->
    <button @click="increment">+1</button> <!-- 绑定事件 -->
  </div>
</template>

神奇之处:

  • 只写了 count.value++ ,没碰任何 DOM!
  • 页面上 {{ count }} 自动更新
  • 即使有 10 个地方用了 count,也全部同步,零额外代码

💖 这就是 “声明式编程” vs “命令式编程” 的魅力:

  • 旧时代: “你要怎么做” (步骤清单)
  • 新时代: “你想要什么” (描述状态)

🔥 五、对代码的深度解读:流式响应处理(重点!)

1️⃣ 请求三件套:向 LLM 发起“召唤”

js

const endpoint = '/api/chat/completions'
const headers = {
  'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  'Content-Type': 'application/json'
}
const body = { 
  model: 'deepseek-chat', 
  stream: true, 
  messages: [{ role: 'user', content: question.value }] 
}
🔑 关键点解释:
  1. /api/chat/completions

    • 这不是直接调 DeepSeek 官方 API!
    • 而是通过 Vite 开发服务器代理(在 vite.config.js 中配置)。
    • 为什么?→ 绕过浏览器 CORS 限制(后面详述)。
  2. stream: true

    • 告诉 LLM:“请一个字一个字地吐出来,别等全部生成完!”

    • 服务器会以 SSE(Server-Sent Events)  格式返回数据:

      text
      
      data: {"choices": [{"delta": {"content": "喜"}}]}
      data: {"choices": [{"delta": {"content": "羊"}}]}
      data: [DONE]
      
  3. VITE_DEEPSEEK_API_KEY

    • Vite 只会暴露以 VITE_ 开头的环境变量到前端。
    • ⚠️ 注意:生产环境中绝不能直接在前端用 API Key!
      (这里仅用于开发,真实项目应由后端代理)
  4. Content-Type

    • 这是 HTTP 请求头(Headers)中的一个字段,用于声明请求体(body)的数据格式

    • DeepSeek、OpenAI、Anthropic 等主流 LLM API 都遵循 OpenAI 兼容协议

    • 它们只接受 JSON 格式的请求体

    • 如果你不声明 Content-Type: application/json,服务器可能:

      • 拒绝请求(400 Bad Request)
      • 无法正确解析你的 body(当成纯文本处理)

2️⃣ 流式响应处理:核心循环逻辑

📌 核心代码(关键部分):
let buffer = '' // !核心变量!
while (!done) {
  const { value, done: doneReading } = await reader.read()
  done = doneReading
  const chunkValue = buffer + decoder.decode(value) // 拼接缓冲
  buffer = ''
  
  // 按行分割
  const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
  
  for (const line of lines) {
    const incoming = line.slice(6) // 去掉 "data: "
    if (incoming === '[DONE]') break
    
    try {
      const data = JSON.parse(incoming)
      content.value += data.choices[0].delta.content
    } catch {
      buffer += `data: ${incoming}` // !关键!存回缓冲
    }
  }
}
📦 初始化关键变量
js

let buffer = ''        // 👑 灵魂变量:暂存“没说完”的数据
let done = false       // 标记流是否结束
const reader = response.body.getReader()
const decoder = new TextDecoder() // 把二进制转成字符串

💡 response.body 是一个 ReadableStream,只能顺序读取。


🔁 主循环:一块一块读数据
js

while (!done) {
  const { value, done: doneReading } = await reader.read()
  done = doneReading
  • reader.read() 返回一个 Promise,解析后得到:

    • value: 当前 chunk(Uint8Array 二进制数据)
    • done: 是否已读完(最后一个 chunk 后为 true)

🧩 第一步:拼接缓冲区 —— buffer 登场!
js

const chunkValue = buffer + decoder.decode(value)
buffer = ''
  • 把 上一轮没解析完的数据(buffer)  + 当前新 chunk 拼成完整字符串。
  • 然后清空 buffer(准备接收下一轮“残片”)。

✅ 举例:

  • 上次剩:'data: {"choices": [{"delta": {"content": "喜羊羊'
  • 本次收到:'和灰太狼"}}]}\n'
  • 拼接后:'data: {"choices": [{"delta": {"content": "喜羊羊和灰太狼"}}]}\n' → 完整!

✂️ 第二步:按行分割,只处理有效行
js

const lines = chunkValue.split('\n')
  .filter(line => line.startsWith('data: '))
  • SSE 协议以 \n 分隔每一行。
  • 只保留以 data:  开头的行(忽略空行或其他控制信息)。

🧪 第三步:逐行解析 JSON
js

for (const line of lines) {
  const incoming = line.slice(6) // 去掉 "data: "
  
  if (incoming === '[DONE]') {
    done = true
    break
  }
  
  try {
    const data = JSON.parse(incoming) // 反序列化,将JSON字符串转化为真正的JavaScript对象
    // 若是一个完整的JSON字符串,这里就可以反序列化成功,否则进入catch
    const delta = data.choices[0].delta.content
    if (delta) content.value += delta
  } catch (err) {
    // ❗ 解析失败 → 说明这行不完整!
    buffer += `data: ${incoming}`
  }
}
🎯 关键细节:
  1. line.slice(6)

    • 去掉 data:  前缀,得到纯 JSON 字符串。
  2. [DONE] 终止信号

    • LLM 流结束时会发送 data: [DONE],此时退出循环。
  3. try...catch + buffer 回写

    • 如果 JSON.parse 失败(比如 incoming = '{"choices": [{"delta": {"conten'),说明这一行被截断了。
    • 于是把它原样加回 buffer(注意加上 data:  前缀),等下次 chunk 到达再拼!

💡 这就是 buffer 的魔法:永不丢弃任何数据片段,直到它变成合法 JSON!


📊 streamdonebuffer 的角色总览

变量 作用 通俗比喻
stream 是否开启流式 开/关"打字机模式"
done 流是否结束 "AI说完了没?"
buffer 暂存未完成的JSON "等你把话说完再听"

🎯 关键逻辑

  • 每次读取新数据 → 拼到 buffer
  • \n 分割 → 逐行解析
  • 解析失败 → 丢回 buffer(等待下次拼接)

🔄 六、流式 vs 非流式:体验大不同!

类型 体验 代码 适用场景
流式 每个字蹦出来(像真人打字) stream: true 需要实时反馈的场景(AI对话)
非流式 等整段答案出来 stream: false 简单查询(如天气API)

💡 为什么流式更香?
用户体验:等待3秒 vs 等3秒+实时看到"正在生成..."!
"思考中...""喜羊羊和灰太狼去野餐...""结果被红太狼发现了!"
体验感直接拉满!🎯


⚠️ 七、CORS 错误:浏览器的"安全门卫"在作妖!

错误提示
"浏览器阻止了从 http://localhost:5173 请求 api.deepseek.com"
🔥 原因:浏览器安全策略(CORS),阻止直接跨域请求!

❌ 为什么不能直接写?

fetch('https://api.deepseek.com/chat/completions') // ❌ 被浏览器拦了!

✅ 正确方案:Vite 代理!

// vite.config.js
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://api.deepseek.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  }
})

🌈 代理原理

  1. 前端请求 /api/chat/completions
  2. Vite 代理服务器转成 https://api.deepseek.com/chat/completions
  3. 浏览器只看到 localhost,不会触发CORS!
    安全 + 体验双杀! 🔥

💎 八、总结:从"写代码"到"写故事"的蜕变

项目 以前 现在
项目初始化 配置Webpack、Babel... npm init vite → 3秒搞定!
响应式 手动操作DOM ref + v-model → 业务优先!
流式输出 无法实现 buffer + ReadableStream → 字字清晰!
CORS 搞不定 Vite代理 → 一行配置搞定!

终极感悟
Vue 3 不是框架,是前端工程师的浪漫——
你只管写"我想让AI说'喜羊羊和灰太狼'...",
Vue默默帮你把"打字机效果"实现得丝滑到哭!
代码写得像写诗,体验做得像魔术! 🌟

Vue生命周期钩子详解与实战应用

image.png Vue的生命周期钩子是组件开发中的重要概念,它们允许我们在组件的不同阶段执行特定的逻辑。本文将详细介绍Vue 3中常用的生命周期钩子,并结合实际例子展示其应用场景。

什么是生命周期钩子?

生命周期钩子是在组件从创建到销毁过程中自动调用的特殊函数。它们为我们提供了在特定时间点执行代码的机会。

主要生命周期钩子

1. onMounted - 组件挂载完成

当组件的DOM被渲染完成后调用,通常用于:

  • 发起网络请求
  • 操作DOM元素
  • 初始化第三方库
<script setup>
import { ref, onMounted } from 'vue'

const userList = ref([])

onMounted(async () => {
  // 组件挂载后加载用户数据
  try {
    const response = await fetch('/api/users')
    userList.value = await response.json()
  } catch (error) {
    console.error('加载用户数据失败:', error)
  }
})
</script>

2. onBeforeMount - 组件挂载前

在DOM渲染之前调用,此时还不能访问DOM元素:

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

const isLoading = ref(true)

onBeforeMount(() => {
  // 在DOM渲染前做一些准备工作
  console.log('组件即将挂载...')
  isLoading.value = true
})
</script>

3. onBeforeUpdate - 数据更新前

在响应式数据改变后,DOM重新渲染前调用:

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

const count = ref(0)
const renderCount = ref(0)

onBeforeUpdate(() => {
  // 每次更新前增加渲染计数
  renderCount.value++
  console.log(`组件即将第${renderCount.value}次更新`)
})
</script>

4. onUpdated - 数据更新后

在DOM重新渲染完成后调用:

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

const message = ref('Hello')

onUpdated(() => {
  // DOM更新完成后可以访问最新的DOM
  console.log('DOM已更新完成')
})
</script>

5. onBeforeUnmount - 组件卸载前

在组件卸载之前调用,用于清理工作:

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

let timer = null

// 启动定时器
timer = setInterval(() => {
  console.log('定时任务执行中...')
}, 1000)

onBeforeUnmount(() => {
  // 组件卸载前清除定时器
  if (timer) {
    clearInterval(timer)
    timer = null
  }
})
</script>

6. onUnmounted - 组件卸载后

在组件卸载完成后调用:

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

onUnmounted(() => {
  // 组件完全卸载后的清理工作
  console.log('组件已被卸载')
})
</script>

实际应用案例

完整的用户管理组件示例

<template>
  <div class="user-management">
    <h2>用户管理系统</h2>
    <div v-if="loading">加载中...</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">
        {{ user.name }} - {{ user.email }}
      </li>
    </ul>
    <button @click="refreshData">刷新数据</button>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUpdate, onUnmounted } from 'vue'

const users = ref([])
const loading = ref(false)
const refreshCount = ref(0)
let pollingTimer = null

// 组件挂载时加载数据
onMounted(async () => {
  console.log('用户管理组件已挂载')
  await loadUsers()
  
  // 启动轮询定时器
  pollingTimer = setInterval(async () => {
    await loadUsers()
  }, 30000) // 每30秒刷新一次
})

// 更新前记录刷新次数
onBeforeUpdate(() => {
  refreshCount.value++
  console.log(`数据第${refreshCount.value}次更新`)
})

// 组件卸载前清理资源
onUnmounted(() => {
  console.log('用户管理组件正在卸载')
  if (pollingTimer) {
    clearInterval(pollingTimer)
    pollingTimer = null
  }
})

// 加载用户数据
const loadUsers = async () => {
  try {
    loading.value = true
    const response = await fetch('/api/users')
    users.value = await response.json()
  } catch (error) {
    console.error('加载用户失败:', error)
  } finally {
    loading.value = false
  }
}

// 手动刷新数据
const refreshData = () => {
  loadUsers()
}
</script>

最佳实践建议

  1. 合理选择钩子时机

    • 网络请求通常放在 onMounted
    • 资源清理放在 onBeforeUnmount
    • DOM操作避免在 onBeforeMountonBeforeUpdate
  2. 注意内存泄漏

    • 及时清理定时器、事件监听器等
    • 使用 onBeforeUnmount 进行资源回收
  3. 异步操作处理

    • 在组件卸载前取消未完成的异步请求
    • 避免在已卸载组件上设置状态

在大型项目中为什么更推荐Composition API?它解决了哪些工程化问题?

1. 更好的逻辑组织和复用

Options API 的问题

// Options API - 逻辑分散在不同选项中
export default {
  data() {
    return {
      users: [],
      loading: false,
      searchQuery: '',
      pagination: {
        page: 1,
        limit: 10,
        total: 0
      }
    }
  },
  
  computed: {
    filteredUsers() {
      // 用户筛选逻辑
    },
    totalPages() {
      // 分页计算逻辑
    }
  },
  
  methods: {
    async fetchUsers() {
      // 数据获取逻辑
    },
    handleSearch() {
      // 搜索逻辑
    },
    handlePageChange() {
      // 分页逻辑
    }
  },
  
  mounted() {
    this.fetchUsers()
  }
}

Composition API 的优势

// Composition API - 按功能组织代码
import { ref, computed, onMounted } from 'vue'
import { userApi } from '@/api/user'

export default {
  setup() {
    // 用户管理功能
    const { users, loading, fetchUsers, searchUsers } = useUserManagement()
    
    // 搜索功能
    const { searchQuery, filteredUsers } = useSearch(users)
    
    // 分页功能
    const { pagination, paginatedUsers, handlePageChange } = usePagination(filteredUsers)
    
    onMounted(() => {
      fetchUsers()
    })
    
    return {
      users: paginatedUsers,
      loading,
      searchQuery,
      pagination,
      handlePageChange,
      searchUsers
    }
  }
}

// 可复用的用户管理逻辑
function useUserManagement() {
  const users = ref([])
  const loading = ref(false)
  
  const fetchUsers = async () => {
    loading.value = true
    try {
      users.value = await userApi.getUsers()
    } finally {
      loading.value = false
    }
  }
  
  const searchUsers = async (query) => {
    // 搜索实现
  }
  
  return {
    users,
    loading,
    fetchUsers,
    searchUsers
  }
}

// 可复用的搜索逻辑
function useSearch(source) {
  const searchQuery = ref('')
  
  const filteredUsers = computed(() => {
    if (!searchQuery.value) return source.value
    return source.value.filter(user => 
      user.name.includes(searchQuery.value)
    )
  })
  
  return {
    searchQuery,
    filteredUsers
  }
}

2. 更好的 TypeScript 支持

Composition API 的完整类型推断

import { ref, computed, Ref } from 'vue'

interface User {
  id: number
  name: string
  email: string
  role: string
}

// 明确的类型定义
function useUserManagement() {
  const users: Ref<User[]> = ref([])
  const loading = ref(false)
  
  const adminUsers = computed(() => 
    users.value.filter(user => user.role === 'admin')
  )
  
  return {
    users,
    loading,
    adminUsers
  }
}

3. 更清晰的代码组织模式

基于功能的代码分割

// feature-based-composition.js
export default {
  setup() {
    // 每个功能独立封装
    const cart = useCart()
    const user = useUser()
    const products = useProducts()
    const notifications = useNotifications()
    
    // 功能间的依赖关系更清晰
    watch(() => user.isLoggedIn, (loggedIn) => {
      if (loggedIn) {
        cart.syncCart()
        notifications.fetchUnread()
      }
    })
    
    return {
      ...cart,
      ...user,
      ...products,
      ...notifications
    }
  }
}

4. 更好的可测试性

// 独立的逻辑函数易于测试
import { useUserManagement } from './userComposition'

describe('useUserManagement', () => {
  it('should fetch users correctly', async () => {
    const { users, fetchUsers } = useUserManagement()
    
    await fetchUsers()
    
    expect(users.value).toHaveLength(3)
  })
})

5. 解决的具体工程化问题

5.1 逻辑关注点分离

Options API 问题:相关逻辑分散在 datamethodscomputed 中
Composition API 解决:相关逻辑集中在一个组合函数中

5.2 代码复用性

Options API 问题:mixins 存在命名冲突和来源不清晰
Composition API 解决:明确的函数调用和返回值

5.3 类型推导

Options API 问题:复杂的 this 上下文类型推断
Composition API 解决:简单的变量和函数类型

5.4 可维护性

Options API 问题:组件越大,代码越难理解和维护
Composition API 解决:按功能拆分为小型、专注的组合函数

5.5 团队协作

// 大型项目中的团队协作示例
export default {
  setup() {
    // 团队A负责的用户模块
    const user = useUserModule()
    
    // 团队B负责的支付模块
    const payment = usePaymentModule()
    
    // 团队C负责的通知模块
    const notifications = useNotificationModule()
    
    // 清晰的模块边界和接口
    return {
      ...user,
      ...payment,
      ...notifications
    }
  }
}

6.总结

Composition API 在大型项目中更受推荐的主要原因:

  1. 更好的逻辑组织:按功能而非选项类型组织代码
  2. 更强的类型支持:完整的 TypeScript 集成
  3. 更高的复用性:逻辑可以轻松提取和复用
  4. 更清晰的代码结构:大型组件更易理解和维护
  5. 更好的可测试性:逻辑与组件实例解耦
  6. 更佳的团队协作:明确的模块边界和接口

Vue中的watch

深入理解 watch

watchVue 组合式 API (Composition API) 中的一个核心功能,它允许我们侦听一个或多个响应式数据源,并在数据源变化时执行一个回调函数。这对于执行异步操作或基于数据变化执行复杂逻辑非常有用。

1. 基本用法

最简单的用法是侦听一个 ref

  • 参数 :
    1. watch 的第一个参数可以是不同形式的“数据源”:它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:
    2. 数据变化时执行的回调函数,该函数接收新值 (newValue) 和旧值 (oldValue)。
import { ref, watch } from 'vue'
// 1. 定义一个响应式数据
const count = ref(0)
// 2. 侦听 count 的变化
watch(count, (newValue, oldValue) => {
 console.log(`count 从 ${oldValue} 变成了 ${newValue}`)
})
// 3. 修改数据以触发 watch
setTimeout(() => {
 count.value++ // 控制台将输出: "count 从 0 变成了 1"
}, 1000)

2. 侦听响应式对象

当侦听一个 reactive 对象时,watch 会隐式地创建一个深层侦听器。这意味着对象内部任何嵌套属性的变化都会触发回调。

注意:此时 newValueoldValue 将是同一个对象引用,因为它们都指向同一个 reactive 对象。

import { reactive, watch } from 'vue'

const state = reactive({
 id: 1,
 user: {
  name: 'Alice',
  age: 20
 }
})

watch(state, (newState, oldState) => {
 console.log('state 对象发生了变化')
 // 注意:newState === oldState
})
// 修改嵌套属性会触发 watch
state.user.age++ // 控制台将输出: "state 对象发生了变化"

3. 侦听 Getter 函数

为了更精确地控制侦听的目标,或者只侦听响应式对象中的某个属性,我们可以向 watch 传递一个 getter 函数 () => ...

这是最常用和推荐的方式之一,因为它具有更好的性能和更明确的意图。

// ...接上一个例子
// 只侦听 state.user.age 的变化
watch(
 () => state.user.age,
 (newAge, oldAge) => {
  console.log(`年龄从 ${oldAge} 变成了 ${newAge}`)
 }
)
state.user.age++ // 控制台将输出: "年龄从 20 变成了 21"
state.id++    // 不会触发这个 watch

相比侦听响应式对象,一个返回响应式对象的 getter 函数,只有在返回不同的对象时,才会触发回调:

watch(
  () => state.someObject,
  (newValue, oldValue) => {
    // 注意:`newValue` 此处和 `oldValue` 是相等的
    // *除非* state.someObject 被整个替换了
  },
  { deep: true }// 可以给上面这个例子显式地加上 deep 选项,强制转成深层侦听器:
)

4. 侦听多个数据源

watch 也可以同时侦听多个数据源,只需将它们放在一个数组中即可。回调函数接收的新旧值也将是数组。

const firstName = ref('John')
const lastName = ref('Doe')

watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
 console.log(`姓名从 ${oldFirst} ${oldLast} 变成了 ${newFirst} ${newLast}`)
})
firstName.value = 'Jane' // 控制台将输出: "姓名从 John Doe 变成了 Jane Doe"

5. 配置选项

watch 接受第三个参数,一个配置对象,用于自定义其行为。

  • immediate: true: 使侦听器在创建时立即执行一次回调。此时 oldValueundefined

    watch(count, (newValue) => {
     console.log(`当前 count 是: ${newValue}`)
    }, { immediate: true }) // 控制台会立即输出: "当前 count 是: 0"
    
  • deep: true: 强制开启深层侦听。当侦听一个 ref 包裹的对象时,必须使用此选项才能侦听到对象内部属性的变化。

    const state = ref({ nested: { count: 0 } })
    
    // 必须加 deep: true 才能侦听到 state.value.nested.count 的变化
    watch(state, (newState) => {
     console.log('state 内部发生了变化')
    }, { deep: true })
    state.value.nested.count++ // 控制台将输出: "state 内部发生了变化"
    

    在 Vue 3.5+ 中,deep 选项还可以是一个数字,表示最大遍历深度——即 Vue 应该遍历对象嵌套属性的级数。

    谨慎使用

    深度侦听需要遍历被侦听对象中的所有嵌套的属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。

  • once: true:每当被侦听源发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次

    watch(
      source,
      (newValue, oldValue) => {
        // 当 `source` 变化时,仅触发一次
      },
      { once: true }
    )
    
    • 仅支持 3.4 及以上版本
  • flush:调整回调函数的刷新时机。参考回调的刷新时机watchEffect()

    watch(source, callback, {
      flush: 'post'//'pre' | 'post' | 'sync' 默认:'pre'
    })
    // watchEffect的第二个参数
    watchEffect(callback, {
      flush: 'post'
    })
    

6. watch vs watchEffect

特性 watch watchEffect
依赖追踪 手动指定,更明确 自动追踪,更方便
执行时机 默认懒执行(数据变化后才执行) 立即执行一次,然后自动追踪
访问旧值 可以 不可以
使用场景 需要知道新旧值、需要精确控制依赖、执行异步或复杂逻辑 简单的副作用,如根据 A 的值更新 B,或打印日志

✨TIP

watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

总结
  • 当你需要精确控制侦听哪个数据时,使用 watch
  • 当你需要访问变化前后的值时,使用 watch
  • 当你需要在数据变化时执行异步操作或开销较大的操作时,watch 提供了更清晰的控制。
  • 对于简单的、需要立即执行且自动追踪依赖的副作用,可以考虑使用 watchEffect

7. 副作用清理

在侦听器中执行一个异步任务,但是在异步任务获取结果之前又重新触发了侦听器

例如通过 id 请求一个数据接口,再数据返回之前 id 变化了,又重新触发了新的请求理想情况下,我们希望能够在 id 变为新值时取消过时的请求。

watch 回调中处理异步操作时遇到的**竞态条件(Race Condition)**问题。

我们可以使用 onWatcherCleanup() (3.5+)API 来注册一个清理函数,当侦听器失效并准备重新运行时会被调用

侦听器何时生效?

  1. 源数据再次变化,导致侦听器准备重新运行时(这是为了处理竞态条件)。
  2. 侦听器被手动停止(调用 stop())。
  3. 侦听器因组件卸载而被自动停止
  4. 侦听器因 once: true 完成其唯一一次任务而被自动停止
import { watch, onWatcherCleanup } from 'vue'

watch(id, (newId) => {
  const controller = new AbortController()
  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // 回调逻辑
  })
  onWatcherCleanup(() => {
    // 终止过期请求
    controller.abort()
  })
})

请注意,onWatcherCleanup 仅在 Vue 3.5+ 中支持,并且必须在 watchEffect 效果函数或 watch 回调函数的同步执行期间调用:你不能在异步函数的 await 语句之后调用它。

作为替代,onCleanup 函数还作为第三个参数传递给侦听器回调,以及 watchEffect 作用函数的第一个参数:

watch(id, (newId, oldId, onCleanup) => {
  // ...
  onCleanup(() => {
    // 清理逻辑
  })
})

watchEffect((onCleanup) => {
  // ...
  onCleanup(() => {
    // 清理逻辑
  })

通过函数参数传递的 onCleanup 与侦听器实例相绑定,因此不受 onWatcherCleanup 的同步限制。

旧方式 (onInvalidate 参数) 新方式 (onWatcherCleanup API)
可用版本 Vue 3.0+ Vue 3.5+
用法 watch((v, ov, onInvalidate) => { onInvalidate(...) }) watch(() => { onWatcherCleanup(...) })
状态 仍然有效,但不再是首选。 推荐使用,是未来的方向。

8. 回调的触发时机

默认情况下,侦听器回调会在父组件更新 (如有) 之后、所属组件的 DOM 更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的 DOM,那么 DOM 将处于更新前的状态。

Post Watchers

如果想在侦听器回调中能访问被 Vue 更新之后的所属组件的 DOM,你需要指明 flush: 'post' 选项:

watch(source, callback, {
  flush: 'post'
})

watchEffect(callback, {
  flush: 'post'
})

后置刷新的 watchEffect() 有个更方便的别名 watchPostEffect()

import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})
同步侦听器

你还可以创建一个同步触发的侦听器,它会在 Vue 进行任何更新之前触发:

watch(source, callback, {
  flush: 'sync'
})

watchEffect(callback, {
  flush: 'sync'
})

同步触发的 watchEffect() 有个更方便的别名 watchSyncEffect()

import { watchSyncEffect } from 'vue'

watchSyncEffect(() => {
  /* 在响应式数据变化时同步执行 */
})

❗谨慎使用

同步侦听器不会进行批处理,每当检测到响应式数据发生变化时就会触发。可以使用它来监视简单的布尔值,但应避免在可能多次同步修改的数据源 (如数组) 上使用。

9. 停止侦听器

默认情况下,组件卸载时会自动停止侦听器,但是侦听器默认是在同步创建的,如果异步创建,必须手动停止,防止内存泄漏!!!

当你把 watchwatchEffect 的调用放在一个异步回调中时(例如 setTimeout, Promise.then, async/await 之后),情况就完全不同了。

  • 此时,组件的 setup() 函数已经执行完毕。
  • 当你的异步回调函数最终执行时,Vue 的执行上下文已经改变,getCurrentInstance() 会返回 null

侦听器在创建时,环顾四周发现:“我不知道我属于哪个组件!” 它无法找到一个可以“报到”的组件实例。

这个侦听器就成了一个**“孤儿”**,它独立于任何组件的生命周期之外。因此,当原来的组件被卸载时,Vue 的清理机制根本不知道这个“孤儿”侦听器的存在,自然也无法为它调用 stop()

<script setup>
import { watchEffect } from 'vue'
// 它会自动停止
watchEffect(() => {})

// ...这个则不会!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

要手动停止一个侦听器,请调用 watchwatchEffect 返回的函数:

const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()
暂停/恢复侦听器(3.5+)
const { stop, pause, resume } = watch(() => {})
// 暂停侦听器
pause()
// 稍后恢复
resume()
// 停止
stop()

const { stop, pause, resume } = watchEffect(() => {})
// 暂停侦听器
pause()
// 稍后恢复
resume()
// 停止
stop()

computed vs watch vs whenever

Vue 中,computedwatch 都是处理响应式数据变化的核心工具,但它们的应用场景和设计理念完全不同。VueUse 库则在 watch 的基础上提供了更具表达力的工具,如 whenever

1. computed vs watch:核心区别

简单来说:

  • computed:用于派生出一个新的、可缓存的响应式数据。它关心的是返回值
  • watch:用于观察一个数据的变化,并执行副作用(Side Effect)。它不关心返回值。
对比表格
特性 computed (计算属性) watch (侦听器)
本质 派生值 (Derivation) 副作用 (Side Effect)
返回值 必须有,返回一个可缓存的 ref 没有,执行一个回调函数
缓存 。依赖不变时,多次访问直接返回缓存结果 没有。每次数据变化都会执行回调
执行时机 懒执行。仅在被访问且依赖变化时才重新计算 默认懒执行。仅在数据变化后执行回调(可通过 immediate 配置立即执行)
异步操作 不支持。计算属性内部应该是同步的纯函数 支持。可以在回调中执行 API 请求等异步操作
使用场景 从现有数据计算新数据(如 fullName 数据变化时执行异步操作、更新非 Vue 管理的 DOM、或执行复杂逻辑
代码示例

computed 示例:

import { ref, computed } from 'vue';

const firstName = ref('John');
const lastName = ref('Doe');

// `fullName` 是一个派生出来的 ref,它依赖于 firstName 和 lastName
const fullName = computed(() => {
 console.log('计算 fullName...'); // 只有依赖变化时才打印
 return `${firstName.value} ${lastName.value}`;
});
// 在模板中使用 {{ fullName }} 即可

watch 示例:

import { ref, watch } from 'vue';

const userId = ref(1);
const userData = ref(null);

// 侦听 userId 的变化,然后执行获取数据的副作用
watch(userId, async (newId, oldId) => {
 console.log(`User ID 从 ${oldId} 变为 ${newId},正在获取新数据...`);
    
 const response = await fetch(`https://api.example.com/users/${newId}`);
 userData.value = await response.json();
}, { immediate: true }); // immediate: true 确保组件加载时就获取一次数据

2. watch vs whenever (来自 VueUse)

VueUse 是一个基于 Composition API 的实用工具集。 whenever 是它提供的一个 watch 的“语法糖”,让代码更具可读性。

核心区别
  • watch:无论新值是什么,只要数据源变化,回调总是执行。
  • whenever:只有当数据源的值变为真值 (truthy) 时,回调才会执行。它是一个带有内置条件判断的 watch

whenever 的内部其实就是使用 watchWithFilter 实现的,它等价于一个带有 filter 选项的 watch

对比表格
特性 watch whenever (VueUse)
执行条件 数据源变化就执行 数据源变化,且新值为真值 (truthy) 时才执行
可读性 需要在回调函数内部写 if 判断 非常高,语义清晰,代码即注释
使用场景 通用的侦听场景 当某个条件满足时执行一次性操作(如弹窗、登录成功后跳转)

使用 watch 的写法:

import { watch, ref } from 'vue'

const myValue = ref<string | null>(null)

watch(myValue, (newValue) => {
  // 每次 myValue 变化都会执行这里
  if (newValue) { // 需要自己加一个 if 判断
    // 只有当 newValue 是真值时才执行核心逻辑
    console.log(`值变成了: ${newValue}`)
  }
})

使用 whenever 的写法 (更优雅):

import { whenever } from '@vueuse/core'

const myValue = ref<string | null>(null)

whenever(myValue, (newValue) => {
  // 只有当 myValue 变为真值时,才会执行这里
  console.log(`值变成了: ${newValue}`)
})

现在我们来逐行分析它的 实现

/**
 * Shorthand for watching value to be truthy
 *
 * @see https://vueuse.org/whenever
 */
export function whenever<T>(source: WatchSource<T | false | null | undefined>, cb: WatchCallback<T>, options?: WheneverOptions) {
  const stop = watch(
    source,
    (v, ov, onInvalidate) => {
      if (v) {
        if (options?.once)
          nextTick(() => stop())
        cb(v, ov, onInvalidate)
      }
    },
    {
      ...options,
      once: false,
    } as WatchOptions,
  )
  return stop
}
// 1. 定义了函数的签名
export function whenever<T>(source: WatchSource<T | false | null | undefined>, cb: WatchCallback<T>, options?: WheneverOptions) {
  • source: 这是要侦听的源。可以是 refreactive 对象、getter 函数等。注意它的类型 T | false | null | undefined,这明确表示 whenever 就是为了处理可能为“假值 (falsy)”的源而设计的。
  • cb: 这是当 source 变为真值时要执行的回调函数。
  • options: 这是一个可选的配置对象,它继承了 watch 的所有选项(如 immediate, deep),并额外增加了一个 once 选项。
// 2. 函数的核心:调用 Vue 内置的 watch 函数
  const stop = watch(

whenever 的本质就是一个 watch。它返回 watchstop 函数,这意味着你可以像停止一个普通 watch 一样停止 whenever

// 3. watch 的回调函数,这是 whenever 的魔法所在
    source,
    (v, ov, onInvalidate) => {
      if (v) { // <--- 关键检查!
        if (options?.once)
          nextTick(() => stop())
        cb(v, ov, onInvalidate)
      }
    },

这是传递给内部 watch 的回调函数。每次 source 发生变化,这个函数都会被调用。

  • if (v): 这是整个函数的核心。vsource 的新值。这个 if 语句检查新值是否为真值(即不是 null, undefined, false, 0, ''NaN)。

  • 只有当 v 是真值时,才会执行 if 块内部的逻辑。

  • if (options?.once)

    : 如果用户设置了once: true,则执行 nextTick(() => stop())

    • stop(): 调用这个函数会停止当前的 watch,从而实现“只执行一次”的效果。

    • nextTick(): 为什么要用 nextTick

      ✨这是一个非常巧妙的细节。它确保了本次的回调函数 cb 能够完整执行完毕,然后在下一个 DOM 更新周期(tick)中再停止侦听。这避免了在回调函数执行过程中就销毁侦听器可能引发的潜在问题。

  • cb(v, ov, onInvalidate): 如果 v 是真值,就调用用户传入的原始回调函数 cb,并把 watch 的所有参数(新值、旧值、失效回调)都透传过去。

// 4. watch 的配置对象
    {
      ...options,
      once: false,
    } as WatchOptions,
  )

这里是传递给内部 watch 的配置对象。

  • ...options: 将用户传入的所有配置(如 immediate, deep)都传递给底层的 watch

  • once: false: 这是一个关键的覆盖操作。

    ✨Vue 3.4+ 的 watch 本身也支持 once 选项。但是 wheneveronce 逻辑是自己实现的(为了配合真值检查)。为了防止与 Vue 内置的 once 行为冲突,这里强制将传递给 watchonce 选项设置为 false,确保 whenever 自己的 once 逻辑能够正常工作。

// 5. 返回 stop 函数
  return stop
}

最后,将内部 watch 返回的 stop 函数返回给调用者,让用户可以随时手动停止侦听。

总结

  1. computed 是为了计算出一个新值。
  2. watch 是为了在数据变化时做事
  3. wheneverwatch 的一个变种,它通过巧妙地使用 nextTick 和覆盖 once 选项,实现了自定义的、与真值判断相结合的“执行一次”功能。

我是如何治理一个混乱的 Pinia 状态管理系统的

最近接手了一个 Vue 3 + TypeScript 的中大型项目,状态管理这块…怎么说呢,一言难尽。花了两周时间做了一次系统性的治理,踩了不少坑,也总结出一些经验,分享给同样在"屎山"中挣扎的朋友们。

背景:接手时的状况

项目用的是 Pinia,但打开 src/store 目录的那一刻,我沉默了:

src/store/
├── index.ts
├── user.ts               # Options API 风格
├── system.ts             # Options API 风格
├── loading.ts            # 半成品
├── keepAlive.ts          # 没有类型
├── point-to-point.ts     # Setup 风格 + 啥都往里塞
├── selection.ts          # 不知道干嘛的
├── xxx-name.ts           # 好几个类似的文件
└── ...还有一堆

十几个 Store 文件扁平地堆在一起,有的用 Options API,有的用 Setup 风格,有的用 TypeScript,有的满屏 any。更离谱的是,composables 目录里也有一套"状态管理",两边功能重叠,谁也不知道该用哪个。

问题诊断:到底哪出了问题

在动手之前,我花了半天时间梳理,把问题分成了三个等级。

P0 - 不治不行

1. 代码风格精神分裂

一半 Options API,一半 Setup 风格。Options API 是 Vue 2 时代的写法,在 Vue 3 + TypeScript 项目里用这个,类型推断很难受:

// 旧代码:Options API
export const useUserStore = defineStore('user', {
  state: () => ({
    userInfo: null as any,  // 到处都是 any
    permissions: [] as any[],
  }),
  getters: {
    getLocale: state => state.locale,  // 这种 getter 毫无意义
  },
  actions: {
    setUserInfoAction(info) {  // Action 后缀是什么鬼
      this.userInfo = info
    },
  },
})

2. 类型形同虚设

as anyas any[] 满天飞,TypeScript 成了摆设。有些复杂对象完全没有类型定义,全靠 IDE 猜。

3. 职责边界模糊

有个叫 point-to-point.ts 的文件,里面塞了:表单状态、字典数据、下拉选项、选中项管理、甚至还有一些工具函数。500 多行,谁都不敢动。

P1 - 迟早要改

  • 命名风格不统一point-to-pointappSystemadmin_user,三种命名法齐活
  • 持久化策略混乱:有的用 localStorage,有的用 sessionStorage,有的根本没做持久化但数据刷新就丢
  • 缓存逻辑重复:好几个 Store 都自己实现了一套"带过期时间的缓存",代码几乎一样

P2 - 代码洁癖

  • Getter 只是简单返回 state,完全多余
  • Action 命名带 Action 后缀,不符合社区习惯
  • 注释缺失,三个月后自己都看不懂

解决方案:怎么治

一、按业务域组织目录

扁平结构最大的问题是:项目一大,找文件全靠搜索。

重构后的目录结构按业务域划分:

src/store/
├── index.ts              # 统一导出
├── core/                 # 核心域:用户、系统、加载状态
│   ├── index.ts
│   ├── user.ts
│   ├── system.ts
│   └── loading.ts
├── basicData/            # 基础数据域:缓存、字典
│   ├── index.ts
│   ├── cache.ts
│   └── dict.ts
├── search/               # 搜索域:查询表单、收藏
│   ├── index.ts
│   ├── queryForm.ts
│   └── favorite.ts
├── order/                # 订单域:表单、草稿
│   ├── index.ts
│   ├── orderForm.ts
│   └── orderDraft.ts
└── types/                # 类型定义
    ├── index.ts
    └── user.types.ts

每个域一个目录,每个目录一个 index.ts 负责导出。使用时可以按域导入,也可以从根目录导入:

// 按域导入(推荐)
import { useUserStore } from '~/store/core'

// 根目录导入
import { useUserStore } from '~/store'

二、统一 Setup 风格 + 代码分块

所有 Store 统一用 Setup 风格重写,代码按 State → Getters → Actions → Return 分块组织:

/**
 * 用户状态管理
 * @description 管理用户信息、权限、Token
 */
export const useUserStore = defineStore(
  'user',
  () => {
    // ==================== State ====================

    /** 用户信息 */
    const userInfo = ref<UserInfo | null>(null)

    /** 权限列表 */
    const permissions = ref<string[]>([])

    /** Token */
    const token = ref('')

    // ==================== Getters ====================

    /** 是否已登录 */
    const isLoggedIn = computed(() => !!token.value && !!userInfo.value)

    /** 检查是否有指定权限 */
    const hasPermission = computed(() => (code: string) =>
      permissions.value.includes(code)
    )

    // ==================== Actions ====================

    /**
     * 加载用户信息
     */
    async function loadUserInfo(): Promise<void> {
      const res = await getUserInfo()
      userInfo.value = res.data
    }

    /**
     * 登出
     */
    function logout(): void {
      userInfo.value = null
      permissions.value = []
      token.value = ''
    }

    // ==================== Return ====================

    return {
      // State
      userInfo,
      permissions,
      token,
      // Getters
      isLoggedIn,
      hasPermission,
      // Actions
      loadUserInfo,
      logout,
    }
  },
  {
    persist: {
      key: 'app-user',
      storage: localStorage,
    },
  },
)

这个结构有几个好处:

  1. 注释分块,一目了然
  2. 返回值显式列出,知道 Store 暴露了什么
  3. 类型推断完美,不需要额外声明

三、统一持久化策略

之前的持久化很随意,现在统一规则:

数据类型 存储方式 理由
用户信息、Token localStorage 需要跨标签页、持久保存
系统配置、主题 localStorage 用户偏好需要持久
表单草稿 localStorage 防止意外关闭丢失
查询条件 sessionStorage 只在当前会话有效
加载状态 不持久化 实时状态,刷新归零

四、带 TTL 的缓存 Store

基础数据(比如字典、省市区)需要缓存,但不能无限期。写了一个通用的带过期时间的缓存 Store:

export const useDictStore = defineStore('dict', () => {
  /** 缓存数据 */
  const cache = ref<Map<string, CacheItem>>(new Map())

  /** 默认 TTL:30 分钟 */
  const DEFAULT_TTL = 30 * 60 * 1000

  /**
   * 获取字典数据(自动处理缓存)
   */
  async function getDict(type: string): Promise<DictItem[]> {
    const cached = cache.value.get(type)

    // 缓存有效,直接返回
    if (cached && Date.now() - cached.timestamp < cached.ttl) {
      return cached.data
    }

    // 缓存过期或不存在,重新请求
    const res = await fetchDictByType(type)
    cache.value.set(type, {
      data: res.data,
      timestamp: Date.now(),
      ttl: DEFAULT_TTL,
    })

    return res.data
  }

  /**
   * 清除指定缓存
   */
  function clearCache(type: string): void {
    cache.value.delete(type)
  }

  return { cache, getDict, clearCache }
})

调用方完全不用关心缓存逻辑,直接 await dictStore.getDict('CONTRACT_TYPE') 就行。

五、Store 和 Composable 的分工

这是很多人纠结的问题:什么时候用 Store,什么时候用 Composable?

我的原则很简单:

场景 用 Store 用 Composable
数据需要跨组件共享
数据需要持久化
数据是全局单例
只在单个组件内使用
封装可复用的逻辑
封装副作用(定时器、事件监听)

举个例子:

// Store:管理全局订单状态
export const useOrderStore = defineStore('order', () => {
  const currentOrder = ref<Order | null>(null)
  const draftList = ref<OrderDraft[]>([])
  return { currentOrder, draftList }
})

// Composable:封装订单表单逻辑
export function useOrderForm() {
  const store = useOrderStore()
  const { t } = useI18n()

  // 表单数据(组件级,不需要共享)
  const formData = ref<OrderFormData>({})
  const loading = ref(false)

  // 表单验证规则
  const rules = computed(() => ({
    productName: [{ required: true, message: t('order.productRequired') }],
  }))

  // 提交订单
  async function submit() {
    loading.value = true
    try {
      const result = await submitOrder(formData.value)
      store.currentOrder = result  // 更新全局状态
      return result
    } finally {
      loading.value = false
    }
  }

  return { formData, loading, rules, submit }
}

Store 负责"数据仓库",Composable 负责"业务逻辑",各司其职。

迁移过程:怎么平滑过渡

不可能一口气把所有 Store 都重写,项目还要正常迭代。我采用的策略是:

1. 向后兼容导出

旧的 Store 暂时保留,新的 Store 写在域目录里,统一在 index.ts 做兼容导出:

// src/store/index.ts

// 新的域导出(推荐使用)
export * from './core'
export * from './basicData'
export * from './search'

// 向后兼容(逐步废弃)
export { useUserStore } from './core/user'  // 旧路径的使用者不会报错

2. 逐步迁移

按优先级分批迁移:

  1. Week 1-2:核心域(user、system、loading)
  2. Week 3-4:基础数据域(缓存、字典)
  3. Week 5-8:业务域(按模块逐个迁移)

每次迁移完一个模块,跑一遍 TypeScript 检查和 E2E 测试,确保没问题再继续。

3. ESLint 规则护航

加了几条 ESLint 规则,防止"新代码写成老样子":

// eslint.config.js
{
  files: ['src/store/**/*.ts'],
  rules: {
    // 禁止在 Store 中使用 any
    '@typescript-eslint/no-explicit-any': 'error',
    // 强制导入排序
    'perfectionist/sort-imports': 'error',
  },
}

最终效果

两周后的 Store 目录:

  • ✅ 6 个业务域,结构清晰
  • ✅ 100% Setup 风格
  • ✅ 100% TypeScript 类型覆盖
  • ✅ 统一的持久化策略
  • ✅ 完善的 JSDoc 注释

维护成本从"看一眼就头疼"变成了"顺手就能改"。

一些心得

  1. 不要一步到位:重构最怕的是"大跃进",分批迁移、逐步验证才是正道
  2. 向后兼容很重要:老代码不可能一夜之间都改完,兼容层是必须的
  3. 规范先行:先定好规范,再动手写代码,不然迁移完了又是一坨新的屎山
  4. Store 和 Composable 别混用:想清楚每个东西的职责,别图方便什么都往 Store 里塞
  5. 类型是文档:好的类型定义比注释更有用,interface 写清楚了,代码自解释

以上就是这次 Pinia 治理的全过程。如果你也在维护一个"历史悠久"的 Vue 项目,希望这篇文章能给你一些参考。

有问题欢迎评论区交流 👋

从零开始:用 Vue 3 + Vite 打造一个支持流式输出的 AI 聊天界面

引言

适合人群:完全没写过代码的小白、刚学 HTML 的新手、对 AI 好奇的任何人
你将学会
✅ 什么是 LLM 流式输出?
✅ 如何用原生 JS 处理二进制流(Buffer)?
✅ 如何用 Vite 快速搭建 Vue 3 项目?
✅ 如何在 Vue 中调用 DeepSeek 等大模型 API 并实现“打字机”效果?


第一章:AI 的“打字机”——什么是流式输出?

想象你去问一个朋友:“讲个喜羊羊的故事”。

  • 非流式回答:他低头想 10 秒,然后一口气说完整个故事。你只能干等。
  • 流式回答:他一边想一边说:“从…前…有…一…只…灰…太…狼…” —— 你立刻就知道他在讲什么!

这就是 流式输出(Streaming Output)

 技术定义:
流式输出是指服务器在生成内容的过程中,边生成、边发送,而不是等全部生成完再一次性返回。

而要实现这种效果,浏览器必须能一块一块地接收数据,并实时拼成文字。这就引出了我们的主角:Buffer(缓冲区)


第二章:手把手拆解 buffer.html —— 二进制世界的“翻译官”

我们先来看这个看似简单的文件。它其实是在模拟:计算机如何把文字变成网络能传输的“0 和 1”,再变回来

<!DOCTYPE html>
<!-- 声明文档类型为 HTML5,确保浏览器以标准模式渲染页面 -->
<html lang="en">
<head>
  <!-- 设置字符编码为 UTF-8,支持中文等多语言字符 -->
  <meta charset="UTF-8">
  <!-- 设置视口(viewport),使页面在移动设备上正确缩放和显示 -->
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <!-- 页面标题,在浏览器标签页中显示 -->
  <title>HTML5 Buffer</title>
</head>
<body>
  <!-- 页面主标题,显示“Buffer” -->
  <h1>Buffer</h1>
  <!-- 用于动态显示 JavaScript 处理结果的容器 -->
  <div id="output"></div>

  <!-- 开始嵌入 JavaScript 脚本 -->
  <script>
    // 创建一个 TextEncoder 实例,用于将字符串编码为 UTF-8 格式的 Uint8Array(字节数组)
    // TextEncoder 是 Web API 的一部分,仅支持 UTF-8 编码(这是现代 Web 的标准)
    const encoder = new TextEncoder();
    console.log(encoder); // 在控制台输出 encoder 对象,便于调试(通常显示为 TextEncoder {})

    // 使用 encoder 将字符串 "你好 HTML5" 编码为 UTF-8 字节序列
    // 中文字符“你”和“好”在 UTF-8 中各占 3 字节,空格和 ASCII 字符(H/T/M/L/5)各占 1 字节
    // 总共:3 + 3 + 1 + 1 + 1 + 1 + 1 + 1 = 12 字节
    const myBuffer = encoder.encode("你好 HTML5");
    console.log(myBuffer); // 输出 Uint8Array(12) [228, 189, 160, 229, 165, 189, 32, 72, 84, 77, 76, 53]

    // 创建一个底层的二进制数据缓冲区(ArrayBuffer),大小为 12 字节
    // ArrayBuffer 本身不能直接读写,它只是一个固定长度的原始二进制数据存储区域
    const buffer = new ArrayBuffer(12);

    // 创建一个 Uint8Array 视图(Typed Array),用于以 8 位无符号整数(即字节)的方式操作 buffer
    // Uint8Array 是 ArrayBuffer 的“窗口”,允许我们按字节读写数据
    const view = new Uint8Array(buffer);

    // 将 myBuffer(来自 TextEncoder 的 Uint8Array)中的每个字节复制到 view 中
    // 因为 myBuffer 和 view 都是 Uint8Array 类型,可以直接通过索引赋值
    for (let i = 0; i < myBuffer.length; i++) {
      // 可选:取消注释下一行可在控制台查看每个字节值
      // console.log(myBuffer[i]); // 例如:228, 189, 160...
      view[i] = myBuffer[i]; // 将第 i 个字节从 myBuffer 复制到 view(即写入底层 buffer)
    }

    // 创建一个 TextDecoder 实例,用于将二进制数据(如 ArrayBuffer)解码回字符串
    // 默认使用 UTF-8 解码,与 TextEncoder 对应
    const decoder = new TextDecoder();

    // 使用 decoder 将整个 ArrayBuffer(buffer)解码为原始字符串
    // 注意:decoder.decode() 接受 ArrayBuffer 或 TypedArray 作为参数
    const originalText = decoder.decode(buffer);
    console.log(originalText); // 应输出:"你好 HTML5"

    // 获取页面中 id 为 "output" 的 div 元素,用于显示结果
    const outputdiv = document.getElementById("output");

    // 将 view(Uint8Array)转换为字符串形式并插入到 outputdiv 中
    // view.toString() 会输出类似 "228,189,160,229,165,189,32,72,84,77,76,53" 的逗号分隔列表
    // 使用模板字符串(反引号)实现多行或变量插值
    // 模板字符串中的表达式用 ${} 包裹,例如 ${view[0]} 表示插入 view 的第一个字节值
    outputdiv.innerHTML = `
    完整数据:[${view}] <br>
    第一个字节:${view[0]} <br>
    缓冲区的字节长度:${view.byteLength} <br>
    原来的文本:${originalText}
    `;
  </script>
</body>
</html>

第一步:文字 → 二进制(编码)

const encoder = new TextEncoder();

  • TextEncoder 是浏览器内置的一个“翻译工具”。
  • 它的作用:把人类能读的文字,翻译成计算机能传输的数字(字节)
  • 就像把中文翻译成摩斯电码。

小知识:所有网络传输的底层都是 0 和 1。文字、图片、视频最终都要变成数字才能发出去。

const myBuffer = encoder.encode("你好 HTML5");

  • 调用 encode() 方法,把字符串 "你好 HTML5" 转成一串数字。

  • 结果是一个 Uint8Array 对象(你可以把它想象成一个“数字数组”)。

  • 实际值是:[228, 189, 160, 229, 165, 189, 32, 72, 84, 77, 76, 53]

    • “你” → [228, 189, 160]
    • “好” → [229, 165, 189]
    • 空格 → [32]
    • “H” → [72],依此类推

为什么是 12 个数字?
因为 UTF-8 编码中:

  • 中文字符占 3 字节
  • 英文字母/数字/空格占 1 字节
    所以:3 + 3 + 1 + 1+1+1+1+1 = 12 字节。

第二步:准备一块“内存白板”

const buffer = new ArrayBuffer(12);

  • ArrayBuffer 是 JavaScript 提供的一种原始二进制数据容器
  • 它就像一张 12 格的空白表格,每格能放一个 0~255 的数字(1 字节)。
  • 但你不能直接往里面写字!它只是“预留空间”。

重要:ArrayBuffer 本身不能读写,必须通过“视图”(View)来操作。

const view = new Uint8Array(buffer);

  • Uint8Array 是一种“视图”,意思是:以 8 位无符号整数的方式看这块内存
  • view 现在就是一个长度为 12 的数组,初始值全是 0。
  • 你可以通过 view[0] = 228 这样的方式写入数据。

类比:

  • ArrayBuffer = 一张白纸
  • Uint8Array = 一支笔,让你能在纸上写字

第三步:把数据“抄”到白板上

循环复制

for (let i = 0; i < myBuffer.length; i++) {
  view[i] = myBuffer[i];
}
  • 这个循环的意思是:myBuffer 里的每个数字,依次写入 view 的对应位置
  • 比如:view[0] = 228, view[1] = 189, …, view[11] = 53
  • 现在,viewmyBuffer 内容完全一样了!

💡 为什么需要这一步?
在真实网络中,数据是一块一块到达的。我们需要一个地方(buffer)来临时存放这些碎片,直到拼完整。


第四步:二进制 → 文字(解码)

const decoder = new TextDecoder();

  • TextDecoderTextEncoder 的反向工具。
  • 它的作用:把数字序列还原成人类能读的文字

const originalText = decoder.decode(buffer);

  • 调用 decode(),传入我们准备好的 buffer
  • 浏览器会读取这 12 个字节,按 UTF-8 规则还原成 "你好 HTML5"
  • 成功!文字回来了!

✅ 验证:console.log(originalText) 会打印出 你好 HTML5


第五步:显示结果到网页

const outputdiv = document.getElementById("output");
outputdiv.innerHTML = `
完整数据:[${view}] <br>
第一个字节:${view[0]} <br>
缓冲区的字节长度:${view.byteLength} <br>
原来的文本:${originalText}
`;
  • document.getElementById("output"):找到网页中 id="output"<div>
  • innerHTML:设置这个 div 的内容
  •  完整数据:[${view}]:把 view 数组转成字符串,比如 [228,189,160,229,165,189,32,72,84,77,76,53]
  •  第一个字节:${view[0]}:插入 view 的第一个字节值,例如 228
  • 缓冲区的字节长度:${view.byteLength}:插入 view 的字节长度,即 12
  • 原来的文本:${originalText}:插入之前解码的字符串 "你好 HTML5"

最终效果:


第三章:用 Vite 创建 Vue 3 项目(超简单!)

打开终端(Mac 用 Terminal,Windows 用 CMD 或 PowerShell),输入:

npm create vite@latest my-ai-chat -- --template vue
cd my-ai-chat
npm install
npm run dev

解释:

  1. npm create vite...:用 Vite 脚手架创建一个叫 my-ai-chat 的 Vue 项目
  2. cd my-ai-chat:进入这个文件夹
  3. npm install:安装依赖(就像下载 App 所需的插件)
  4. npm run dev:启动开发服务器

浏览器会自动打开 http://localhost:5173,看到一个 Vue 欢迎页。


第四章:逐行详解 App.vue —— 让 AI “打字”给你看!

现在,我们把前面学到的 Buffer 知识,用到真正的 AI 聊天中!

先看整体结构

<script setup>
  // JavaScript 逻辑写在这里
</script>

<template>
  <!-- HTML 结构写在这里 -->
</template>

<style scoped>
  /* CSS 样式写在这里 */
</style>

这是 Vue 3 的 单文件组件(SFC) 格式,把逻辑、结构、样式放在一起,非常清晰。


第一部分:定义“会变的数据”(响应式)

import { ref } from 'vue'

const question = ref('讲一个喜羊羊与灰太狼的故事');
const stream = ref(true);
const content = ref('');
  • ref() 是 Vue 3 的魔法函数,用来创建“会自动更新页面的数据”。
  • 比如:当 content.value = "你好" 时,页面上显示 {{content}} 的地方会自动变成“你好”

 举个栗子:
question 就像一个“问题盒子”,初始装着“讲个故事”
content 就像一个“答案盒子”,初始是空的
当 AI 回答时,我们不断往“答案盒子”里加字,页面就自动更新!


第二部分:点击“提交”时做什么?——发起网络请求

const askLLM = async () => {
  if (!question.value) return;

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

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{ role: 'user', content: question.value }]
    })
  })
4.2.1 const askLLM = async () => { ... }:定义异步函数
  • askLLM 是一个函数的名字,意思是“向大语言模型(LLM)提问”。
  • async 关键字是关键!它告诉 JavaScript:“这个函数里面会有一些需要等待的操作(比如网络请求),但我希望你能聪明地处理,不要卡死整个页面。”

同步 vs 异步:煮咖啡的比喻

  • 同步:你走进咖啡店,点了一杯咖啡,然后站在柜台前一直等,直到咖啡做好。在这期间,你什么都不能做。
  • 异步:你点完咖啡后,拿到一个号码牌,然后你可以去逛书店、看手机。当咖啡好了,店员会叫你的号。你在这期间可以做其他事。

async/await 就是 JavaScript 实现“异步”的优雅方式。

4.2.2 if (!question.value) return;:防御性编程

这是一个很好的习惯。如果用户什么都没输入就点击“提交”,我们就直接退出函数,什么都不做。避免发送无效请求。

4.2.3 构建请求:URL、Headers 和 Body

网络请求有三个基本要素:去哪里(URL)带什么身份证明(Headers)说什么(Body)

  1. URL (endpoint)(请求行)

    const endpoint = 'https://api.deepseek.com/chat/completions';
    

    这是 DeepSeek API 的入口地址。所有请求都要发到这里。

  2. Headers (请求头)

    const headers = {
      'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
      'Content-Type': 'application/json'
    }
    
    • 'Authorization':这是你的“身份证”。API 需要验证你是谁,是否有权限使用服务。Bearer 是一种常见的认证方式。

    • 环境变量 import.meta.env.VITE_DEEPSEEK_API_KEY

      • 这是 Vite 框架提供的一个安全机制。
      • 你在项目根目录的 .env 文件里写 VITE_DEEPSEEK_API_KEY=sk-xxx...
      • 在代码中,通过 import.meta.env.VITE_... 来读取。
      • 为什么加 VITE_ 前缀?这是 Vite 的规定,只有以 VITE_ 开头的环境变量才会被嵌入到客户端代码中,防止你不小心泄露了服务器端的密钥。
      • 重要提醒:这种方式只适用于免费或测试用途。在生产环境中,API Key 绝对不应该暴露在前端代码里!应该由你自己的后端服务器来代理请求。
    • 'Content-Type':告诉服务器,“我发给你的数据是 JSON 格式的,请按 JSON 来解析”。

  3. Body (请求体)

    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{ role: 'user', content: question.value }]
    })
    
    • JSON.stringify():把一个 JavaScript 对象转换成 JSON 字符串。因为网络只能传输文本,不能直接传对象。
    • model: 指定要使用的 AI 模型。
    • stream: 这就是我们的“开关”!如果 stream.value 是 true,API 就会启用流式输出模式。
    • messages: 这是对话的历史记录。目前我们只有一条消息,角色是 user(用户),内容是用户输入的问题。
4.2.4 fetch():浏览器内置的“信使”

fetch 是现代浏览器提供的一个用于发起网络请求的全局函数。它返回一个 Promise 对象。

  • await fetch(...)await 会让代码在这里暂停,等待 fetch 的 Promise 完成(即收到服务器的响应),然后把响应对象赋值给 response 变量。
  • 关键点:即使是在 await 等待的时候,浏览器的 UI 线程依然是畅通无阻的,用户仍然可以滚动页面、点击按钮,这就是异步的威力!

第三部分:处理流式响应(核心中的核心!)

这才是实现“打字机”效果的真正战场。让我们进入 if (stream.value) 分支。

// 当stream.value为true时,开启流式模式:
if (stream.value) {
  // 清空上次的对话记录,准备接收新的流
  content.value = ""
  
  // 获取"数据流读取器" - 像接水管一样接收数据
  const reader = response.body?.getReader()
  
  // 创建解码器 - 把二进制流翻译成文字
  const decoder = new TextDecoder()
  
  let done = false  // 数据流是否结束?刚开始当然没结束
  let buffer = ''   // 临时缓冲区,存放未处理完的数据碎片
  
  // 开始接收数据流的魔法循环
  while(!done) {  // 只要没结束,就继续接收
    // 读取一块数据(await表示耐心等待数据到来)
    const { value, done: doneReading } = await reader?.read()
    // value: 二进制数据块,doneReading: 这次读取是否结束
    
    done = doneReading  // 更新整体结束状态
    
    // 把新数据块和之前未处理完的buffer合并
    const chunkValue = buffer + decoder.decode(value)
    // decoder.decode()把二进制变成字符串,就像把摩斯密码翻译成文字
    
    buffer = ''  // 清空临时缓冲区,准备重新使用
    
    // 把接收到的数据按行分割,只保留以"data: "开头的行
    const lines = chunkValue.split('\n')
      .filter(line => line.startsWith('data: '))
    
    // 逐行处理
    for (const line of lines) {
      const incoming = line.slice(6)  // 去掉"data: "前缀,只保留内容
      
      if (incoming === '[DONE]') {  // AI说:"我说完了"
        done = true  // 标记结束
        break  // 跳出循环
      }
      
      try {
        // 尝试解析JSON数据
        const data = JSON.parse(incoming)  // 把字符串变成JavaScript对象
        
        // 提取AI生成的内容片段
        const delta = data.choices[0].delta.content
        
        if (delta) {  // 如果有新内容
          content.value += delta  // 拼接到显示内容中
          // 这就是"边生成边显示"的魔法所在!
        }
      } catch(err) {
        // JSON解析失败(数据不完整),把数据放回buffer下次再试
        buffer += `data: ${incoming}`
      }
    }
  }
}
4.3.1 response.body?.getReader():获取数据流的“阅读器”
  • response.body 是一个 ReadableStream(可读流)对象。它代表了服务器正在源源不断发送过来的数据。
  • .getReader() 方法会返回一个 StreamReader(流阅读器)。这个阅读器提供了 read() 方法,让我们可以按需、分块地读取数据。

流(Stream) vs 普通响应:水管 vs 水桶

  • 普通响应:服务器把所有水(数据)装进一个大水桶(内存)里,等装满了才一次性倒给你。如果水很多,你会等很久,而且你的家(内存)可能放不下。
  • 流式响应:服务器打开一根水管,水(数据)一边产生一边流出来。你拿一个杯子(reader.read())在下面接,接到一点就可以用一点。这样既快又省空间。
4.3.2 new TextDecoder():二进制到文本的“翻译官”

正如我们在 buffer.html 中学到的,网络传输的底层是二进制(Uint8Array)。TextDecoder 的作用就是把这些冰冷的数字翻译回我们能读懂的文字。

4.3.3 主循环 while(!done):持续监听数据流

这个 while 循环会一直运行,直到数据流结束(done 变成 true)。

const { value, done: doneReading } = await reader?.read()
done = doneReading;
  • reader.read() 也是一个异步操作,它会返回一个 Promise。

  • 这个 Promise 解析后会得到一个对象 { value, done }

    • value: 就是我们期待的数据块,类型是 Uint8Array
    • done: 一个布尔值,表示数据流是否已经结束。
  • 我们用解构赋值 const { value, done: doneReading } 来提取这两个值,并将 done 重命名为 doneReading 以避免和外层的 done 变量冲突。

4.3.4 处理数据块

现在,我们拿到了一个数据块 valueUint8Array)。真正的挑战开始了。

const chunkValue = buffer + decoder.decode(value);
buffer = '';
  1. decoder.decode(value) :首先,把二进制数据块 value 翻译成字符串。
  2. buffer + ... :把上次循环中残留的不完整数据(buffer)和这次新来的数据拼在一起。这是处理网络碎片化的关键!
  3. buffer = '' :清空 buffer,准备迎接下一次可能的碎片。
4.3.5 解析 SSE 协议:理解服务器的语言

DeepSeek API 使用的是 SSE (Server-Sent Events) 协议。这是一种服务器向客户端推送事件的简单标准。

SSE 的数据格式非常固定:

data: {"some": "json"}\n\n
data: {"more": "json"}\n\n
data: [DONE]\n\n
  • 每条有效消息都以 data:  开头。
  • 消息之间用两个换行符 \n\n 分隔。
  • 最后一条消息通常是 data: [DONE],表示流已结束。

因此,我们的解析逻辑如下:

const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
  1. chunkValue.split('\n') :把整个字符串按换行符 \n 切分成一个数组。例如,"line1\nline2\n\n" 会被切成 ["line1", "line2", "", ""]
  2. .filter(...) :过滤掉所有不以 data:  开头的行。这能帮我们剔除空行和其他无关信息,只留下有效的数据行。
4.3.6 遍历有效行并提取内容
for (const line of lines) {
  const incoming = line.slice(6); // 去掉 "data: "
  if (incoming === '[DONE]') {
    done = true;
    break;
  }
  try {
    const data = JSON.parse(incoming);
    const delta = data.choices[0].delta.content;
    if (delta) {
      content.value += delta;
    }
  } catch(err) {
    buffer += `data: ${incoming}`
  }
}

让我们逐行分析这个精妙的处理过程:

  1. line.slice(6) : data: 这个前缀正好是 6 个字符。slice(6) 会返回从第 7 个字符开始到末尾的子字符串,也就是我们想要的纯 JSON 或 [DONE]

  2. if (incoming === '[DONE]') : 如果是结束信号,就把 done 设为 true,并跳出 for 循环。下一次 while 循环检查到 done 为真,就会退出整个主循环。

  3. try { ... } catch { ... } : 这是处理 JSON 解析错误的关键。为什么会有错误?

    • 原因:网络传输的不确定性。很可能一个完整的 JSON 字符串 {"choices": [...]} 被切成了两半,第一次只收到了 {"choic,第二次才收到 es": [...]}
    • JSON.parse(incoming) 会尝试把字符串解析成 JavaScript 对象。如果 incoming 不是一个完整的 JSON(比如 {"choic),就会抛出异常。
  4. catch 块里的 buffer += ... :

    • 当 JSON.parse 失败时,说明 incoming 是一个不完整的 JSON 片段
    • 我们不能丢弃它!必须把它存起来。
    • 注意,我们存回去的时候,重新加上了 data:  前缀。这是因为下一次循环开始时,我们会再次执行 split('\n') 和 filter,需要保证格式正确。
    • 这样,当下一个数据块到来时,buffer(不完整片段)和新数据拼接后,就可能形成一个完整的 JSON 字符串,从而成功解析。
  5. 成功解析后的处理:

    const data = JSON.parse(incoming);
    const delta = data.choices[0].delta.content;
    if (delta) {
      content.value += delta;
    }
    
    • data.choices[0].delta.content 就是本次新增的文本片段(可能是一个字、一个词,甚至为空)。

    • content.value += delta:这是魔法发生的最后一刻!我们将新片段追加到 content 这个 ref 上。Vue 的响应式系统立刻捕捉到这个变化,并驱动 DOM 更新,让用户看到文字一个接一个地出现。

总结这个循环的智慧: 整个过程就是一个鲁棒的、能应对网络不确定性的数据拼接和解析引擎。它完美地处理了以下问题:

  • 数据分块到达
  • 数据块边界切割了有效信息
  • 协议格式的解析
  • 实时更新 UI

这就是专业级流式处理的精髓所在。


第四部分:非流式模式(对比学习)

} else {
  // 等待所有数据到达,然后一次性解析
  const data = await response.json()  // 把整个响应变成JavaScript对象
  
  // 提取完整的回复内容
  content.value = data.choices[0].message.content
  // 一次性显示所有内容
}

这部分代码简洁明了,作为流式模式的对照组,更能凸显流式的优势。

  • response.json():这是一个便捷方法,它会等待整个响应体接收完毕,然后自动将其解析为 JSON 对象。

  • 特点

    • 简单:代码量少,逻辑清晰。
    • 延迟高:用户必须等待 AI 生成完整个回答后才能看到结果。
    • 内存占用高:整个回答必须先加载到内存中。
  • 适用场景:调试、获取短答案、或者后端处理等不需要实时反馈的场景。


第五部分:HTML 模板(用户界面)——连接逻辑与视觉

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input class="input" v-model="question"/>
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
        <div>{{content}}</div>
      </div>
    </div>
  </div>
</template>

模板部分虽然简短,但包含了 Vue 最强大的两个指令。

  1. v-model="question" :

    • 这是 双向数据绑定 的语法糖。
    • 它做了两件事: a. 将 input 元素的 value 属性绑定到 question.value。 b. 监听 input 元素的 input 事件,当用户输入时,自动更新 question.value
    • 效果question 和输入框的内容永远保持同步,无论变化来自哪一方。
  2. @click="askLLM" :

    • @ 是 v-on: 的缩写,用于监听 DOM 事件。
    • 当用户点击“提交”按钮时,askLLM 函数就会被调用。
  3. {{content}} :

    • 这是 插值表达式
    • Vue 会在此处插入 content.value 的当前值。
    • 由于 content 是响应式的,它的任何变化都会导致此处的文本自动更新。

第五章:运行你的 AI 聊天机器人!

在运行之前,请务必注意以下几点:

  1. API Key 安全:再次强调,.env.local 文件中的 Key 仅用于学习。切勿将包含真实 Key 的代码提交到 GitHub 等公共仓库。可以创建一个 .gitignore 文件,把 .env 加进去。
  2. CORS 问题:某些 API 可能会因为跨域资源共享(CORS)策略而拒绝来自 localhost 的请求。如果遇到 CORS error,通常意味着该 API 不允许直接从前端调用,你需要搭建一个自己的后端代理。
  3. 错误处理:我们的 askLLM 函数目前没有完善的错误处理。在生产代码中,你应该用 try...catch 包裹 fetch 调用,以捕获网络错误、认证失败等情况,并给用户友好的提示。

结语:你做到了!

通过这篇超万字的深度解析,你已经不仅仅是“会用”流式输出,而是真正理解了它背后每一行代码的意图和原理

你掌握了:

  • 原生 JavaScript 如何处理二进制数据(Buffer, TextEncoder/Decoder)
  • 现代 Web API 如何进行异步网络通信(fetch, ReadableStream)
  • 流式协议(SSE)的解析技巧
  • Vue 3 的核心概念(响应式 ref, 单文件组件, 指令 v-model

更重要的是,你体验到了从理论到实践的完整闭环。这种亲手构建、亲手理解的成就感,是任何教程都无法替代的。

下一步小挑战(升级版):

  • 添加加载状态:在 AI 思考时,显示一个“正在输入...”的提示。
  • 美化 UI:用 CSS 让聊天界面看起来更像 ChatGPT。
  • 保存对话历史:让用户能看到之前的问答记录。
  • 搭建后端代理:用 Node.js/Express 写一个简单的后端,将 API Key 保护起来,彻底解决安全问题。

编程不是魔法,而是逻辑的积木。而你,不仅搭出了第一座城堡,还学会了如何设计和制造每一块砖。未来的路,就在你脚下。继续前行吧! 🏰

Vue 3 实现 LLM 流式输出:从零搭建一个简易 Chat 应用

🌟 引言:为什么需要流式输出?

在调用大语言模型(如 DeepSeek、OpenAI 等)时,如果等待完整响应再一次性渲染,用户会经历漫长的“白屏”等待。而流式输出(Streaming) 能让模型生成的内容逐字/逐词返回,极大提升交互体验——就像你在和真人聊天一样。

本文将手把手带你用 Vue 3 + fetch + Server-Sent Events (SSE) 实现一个支持流式输出的简易 Chat 应用,并深入解析关键细节。


🛠️ 一、项目初始化

我们使用 Vite 快速搭建 Vue 3 项目:

bash
编辑
npm init vite@latest llm-stream-demo -- --template vue
cd llm-stream-demo
npm install

选择:

  • 框架:Vue
  • 变体:JavaScript

项目结构如下:

text
编辑
src/
├── App.vue       # 根组件
├── main.js
└── ...

💡 Vite 优势:启动快、热更新快、原生支持 ES 模块,非常适合现代前端开发。


🧩 二、核心逻辑:如何处理流式响应?

1. API 接口规范(以 DeepSeek 为例)

DeepSeek 的 /chat/completions 接口支持 stream: true 参数,返回格式为 SSE(Server-Sent Events)

text
编辑
data: {"choices": [{"delta": {"content": "你"}}]}
data: {"choices": [{"delta": {"content": "好"}}]}
data: [DONE]

每行以 data: 开头,最后以 [DONE] 结束。

2. 使用 fetch + ReadableStream 解析流

关键在于正确读取 response.body 这个 HTML5 ReadableStream。下面我们将重点剖析其中最核心的一行代码。


🔍 三、前置基础:response.body?.getReader() 全面解析

在进入 reader.read() 之前,必须先理解这行代码:

js
编辑
const reader = response.body?.getReader();

它是整个流式读取流程的起点。下面我们从 核心语法拆解 → 逐部分深度解析 → 完整示例 → 注意事项 → 适用场景 五个维度讲透。

一、核心语法拆解

语法片段 作用说明
response fetch() 返回的 Response 对象(HTTP 响应封装)
response.body Response 的 body 属性,类型为 ReadableStream(可读字节流)
?. 可选链操作符,防止 response.body 为 null/undefined 时报错
getReader() ReadableStream 的方法,返回 ReadableStreamDefaultReader(流读取器)
const reader 声明常量存储读取器实例,用于手动读取流中的数据块

二、逐部分深度解析

1. response:Fetch API 的响应对象

response 是调用 fetch(url) 后返回的 Promise 解析结果:

js
编辑
fetch('https://example.com/large-data')
  .then(response => {
    // 这里的 response 就是 Response 对象
    const reader = response.body?.getReader();
  });

它包含 HTTP 响应的元信息(状态码、头信息)和响应体(body)。

2. response.body:可读字节流(ReadableStream

  • 核心特性:代表流式数据,而非一次性加载的完整内容,适用于大文件、实时日志、视频流等场景。

  • 与 response.json() / .text() 的区别

    • json() / text() 会将整个响应体加载到内存 → 适合小数据;
    • body 支持逐块读取 → 避免内存溢出(如处理 1GB 文件)。
  • 可能的值

    • 正常响应:返回 ReadableStream 实例;
    • 无响应体(如 204 No Content)或跨域错误:返回 null

3. ?.:可选链操作符

  • 作用:若 response.body 为 null 或 undefined,则 response.body?.getReader() 直接返回 undefined,不会执行后续方法。

  • 避免错误:防止抛出 Cannot read properties of null (reading 'getReader')

  • 等价 ES5 写法

    js
    编辑
    const reader = response.body ? response.body.getReader() : undefined;
    

4. getReader():获取流读取器

调用后返回一个 ReadableStreamDefaultReader 实例(简称 reader),其核心方法包括:

方法 作用
reader.read() 异步读取下一个数据块,返回 Promise<{ done: boolean, value: Uint8Array }>
reader.releaseLock() 释放读取器对流的独占锁,允许其他读取器使用该流

⚠️ 注意:调用 getReader() 后,流被“锁定”,只能由该读取器读取,直到调用 releaseLock()


三、完整使用示例(逐块读取流)

js
编辑
async function readStreamData(url) {
  const response = await fetch(url);
  
  // 获取读取器(安全处理 body 为 null 的情况)
  const reader = response.body?.getReader();
  if (!reader) {
    console.log('响应无数据流');
    return;
  }

  const chunks = []; // 存储所有数据块

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break; // ✅ 流读取完毕
      chunks.push(value); // value 是 Uint8Array
    }
    
    // 合并所有块为完整 Uint8Array
    const fullLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
    const fullData = new Uint8Array(fullLength);
    let offset = 0;
    for (const chunk of chunks) {
      fullData.set(chunk, offset);
      offset += chunk.length;
    }
    
    // 转为字符串(根据实际编码调整)
    const text = new TextDecoder().decode(fullData);
    console.log('完整数据:', text);
  } catch (err) {
    console.error('读取流失败:', err);
  } finally {
    reader.releaseLock(); // ✅ 释放锁,避免内存泄漏
  }
}

// 调用
readStreamData('https://example.com/large-file.txt');

四、关键注意事项

  • 独占锁getReader() 会锁定流,其他读取器无法使用,必须调用 releaseLock()
  • 数据类型value 是 Uint8Array,需用 TextDecoder 转字符串,或按需解析为 JSON/二进制;
  • 错误处理:网络中断等异常会 reject read() 的 Promise,需 try/catch
  • 兼容性:浏览器全支持(IE 除外),Node.js 16+ 支持(18+ 更稳定);
  • 取消读取:可调用 reader.cancel() 主动终止流并释放资源。

五、适用场景

  • 大文件下载/上传(避免内存爆炸);
  • 实时数据推送(如 SSE、日志流);
  • 边下载边解析(JSON/CSV 分块处理);
  • 音视频流的前端处理。

总结
const reader = response.body?.getReader(); 是安全获取流读取器的关键一步,为处理海量或实时数据奠定基础。


🔥 四、深度解析:reader.read() 与 done 状态机制

在流式响应处理中,以下这行代码是整个机制的引擎

js
编辑
const { value, done: doneReading } = await reader?.read();

它看似简单,却融合了解构赋值、异步流读取、可选链等多个关键概念。下面我们从 逐行拆解 + 核心概念 + 实际场景 + 易错点 四个维度彻底讲透。

✅ 上下文回顾

典型流式读取循环如下:

js
编辑
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;

while (!done) {
  const { value, done: doneReading } = await reader?.read(); // ← 核心行
  done = doneReading;
  if (value) {
    const chunk = decoder.decode(value);
    content.value += chunk;
  }
}

📖 逐行拆解核心代码

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

部分 作用 详细说明
reader?.read() 异步读取流的「下一个数据块」 reader 是通过 response.body.getReader() 创建的流读取器; - ?. 是可选链,防止 reader 为 undefined 时报错(比如请求失败); - read() 每次只读一块,不会自动继续,需手动循环调用。
await 等待异步读取完成 read() 返回 Promise<{ value, done }>, 必须 await 才能拿到实际数据。
{ value, done: doneReading } 解构并重命名 value: 当前块的二进制数据(Uint8Array),流结束时为 undefined; - done: 布尔值,true 表示流已结束; - 重命名为 doneReading 是为了避免与外层 let done 变量冲突(否则循环无法终止!)。

done = doneReading;

  • 外层 let done = false 是控制 while 循环的全局状态
  • 每次读取后,将当前块的 done 状态同步给全局变量;
  • 一旦某次返回 done: true,循环终止,流读取完成。

🔑 补充深度:done 状态的本质与使用逻辑

你代码中的 done = doneReading,其实是在对 read() 返回的 done 字段做别名赋值。这个 doneReadableStream 读取完成的核心标识,理解它对正确实现流式逻辑至关重要。

一、done(即 doneReading)的核心含义

done 值 状态说明 value 对应值
false 流未读取完成,本次 read() 成功获取到了一段数据块 Uint8Array(非空字节数组)
true 流已完全读取完毕,没有更多数据 undefined

✅ 简单说:

  • done: true → “流读完了,没数据了”
  • done: false → “还有数据,这次拿到了一小段”

二、done: true 的触发时机

该状态由流的底层机制自动决定,不是前端手动设置的,常见触发场景包括:

  • 正常完成:服务器发送完全部响应体,且前端已读取完所有数据块;
  • 主动终止:调用 reader.cancel() 取消读取,后续 read() 返回 done: true
  • 响应无体:HTTP 状态码为 204 No Content 或 1xx 信息响应,response.body 为空;
  • 流被关闭:服务器或客户端主动断开连接(如 TCP 正常关闭)。

三、done 状态的典型使用逻辑

标准流读取循环通常这样写:

js
编辑
while (true) {
  const { done: doneReading, value } = await reader.read();
  
  // ✅ 关键判断:流是否读完?
  if (doneReading) {
    console.log('流读取完成');
    break; // 终止循环
  }
  
  // 处理当前数据块
  console.log('收到数据:', decoder.decode(value));
}

你代码中的 done = doneReading 正是为了实现这一逻辑——将局部 done 状态同步到循环控制变量,从而安全退出。

四、关键注意点

  • done: true 是最终状态:一旦出现,后续所有 read() 调用都会返回 { done: true, value: undefined },无需再读;
  • done: true ≠ 错误:它是正常结束信号,错误会通过 Promise reject 抛出(需 try/catch 捕获);
  • 必须终止循环:若忽略 done: true,会导致无限循环(虽然不报错,但浪费 CPU)。

五、对比理解:错误 vs 正常结束

场景 done 状态 处理方式
流正常读取完毕 true 退出循环,合并数据
读取到有效数据块 false 处理 value,继续循环
网络中断 / 流损坏 —(无返回) read() Promise 被 reject,需 catch 捕获

总结
done = doneReading 中的 doneReading 就是 read() 返回的原始 done 字段,它是判断流是否终结的唯一可靠依据true 表示“读完了”,false 表示“还有数据”。这是流式编程的基石。


🌐 核心概念:为什么流要这样读?

  • 普通响应:服务器拼好全部内容 → 一次性返回 → 前端 response.json() 拿到完整数据。
  • 流式响应:服务器边生成边返回(如大模型 token-by-token 输出)→ 数据分块传输 → 前端必须逐块读取

ReadableStream 的设计正是为了:

  • 避免一次性加载大文本(节省内存);
  • 支持实时渲染(提升用户体验);
  • 通过 done 明确告知“是否还有下一块”。

🎯 实际场景举例

假设你问:“1+1 等于几?”,流式读取过程如下:

读取次数 read() 返回值 doneReading 全局 done 操作
第1次 { value: Uint8Array("1+1"), done: false } false false 解码为 "1+1",拼接到页面
第2次 { value: Uint8Array("等于2"), done: false } false false 拼接为 "1+1等于2"
第3次 { value: undefined, done: true } true true 循环结束

⚠️ 易错点与注意事项

  1. 必须用 while 循环
    read() 是单次操作,只调一次只能拿到第一块!
  2. value 是二进制
    直接使用会看到 Uint8Array,必须用 TextDecoder.decode() 转字符串。
  3. 可选链 ?. 很重要
    response.body 为空(如 404 错误),readerundefined,不加 ?. 会直接报错。
  4. 避免变量名冲突
    如果写成 const { value, done } = ...,会覆盖外层 done,导致循环永不退出!
  5. 流只能读一次
    调用 getReader() 后,流被“锁定”,读完即销毁,无法重复读取。

💬 简化版“人话”逻辑

plaintext
编辑
准备一个读取器
流是否结束 = false

只要没结束,就一直读:
  等待读取下一块
  拿到数据块 和 “是否最后一块”
  更新“流是否结束”状态
  如果有数据,转成文字,拼到页面上

✅ 记住两个关键点:

  1. reader.read() 每次读一块,返回 { value, done }
  2. done = doneReading 是让循环知道“什么时候该停”。

🧠 五、关于 buffer 的真相:它真的多余吗?

在很多简化示例(包括本文前面的代码)中,你会看到类似这样的写法:

js
编辑
let buffer = '';
const chunk = buffer + decoder.decode(value, { stream: true });
buffer = '';
content.value += chunk;

乍一看,buffer 全程为空,拼接和清空操作“走了流程但没产生实际效果”——在这个特定场景下,确实如此。但这并不意味着 buffer 是多余的,而是你的示例处于“理想情况”:每次 decoder.decode() 都恰好返回完整的业务字符串,没有残段

我们分两层说清楚:

1. 在你的简化示例中:buffer 确实 “没用”

回顾逻辑:

js
编辑
let buffer = '';
const decoder = new TextDecoder('utf-8');

// 第一次:buffer 为空
const chunkValue1 = buffer + decoder.decode(Buffer.from('你好世'), { stream: true });
buffer = ''; // 清空 → 仍为空

// 第二次:buffer 仍为空
const chunkValue2 = buffer + decoder.decode(Buffer.from('界'), { stream: true });
buffer = ''; // 清空 → 无意义

这里 buffer 既没有被赋值为非空值,也没有为拼接提供任何有效数据,纯粹是“标准写法的惯性”——就像给空杯子擦桌子,动作做了,但杯子本来就干净,没产生实际价值

✅ 结论:在纯文本连续输出(如 LLM 流式回复)且无需按规则切分的场景下,buffer 可省略。


2. 但在真实业务场景中:buffer 是必不可少的

buffer 的核心价值,是处理 “业务层存在未完成的字符串残段” 的情况。例如:

  • 按 |\n} 等分隔符解析流数据;
  • 按固定长度截取协议帧;
  • 解析 JSON 流时遇到半截对象。

🌰 举个真实例子:按 | 分隔接收数据

目标完整数据你好|世界|123
实际传输被拆成两段

  • 第一段:Buffer.from('你好|世')
  • 第二段:Buffer.from('界|123')

如果没有 buffer 缓存残段,第一次会错误地认为 是一个完整字段,导致数据错乱。

正确处理(此时 buffer 发挥关键作用)

js
编辑
let buffer = '';
const decoder = new TextDecoder('utf-8');

// 第一次接收
const chunk1 = buffer + decoder.decode(Buffer.from('你好|世'), { stream: true });
// → chunk1 = '你好|世'
const parts1 = chunk1.split('|');
const complete1 = parts1.slice(0, -1);        // ['你好'] ← 可安全使用
buffer = parts1[parts1.length - 1];           // buffer = '世' ← 缓存残段!

// 第二次接收
const chunk2 = buffer + decoder.decode(Buffer.from('界|123'), { stream: true });
// → chunk2 = '世' + '界|123' = '世界|123'
const parts2 = chunk2.split('|');
const complete2 = parts2.slice(0, -1);        // ['世界', '123']
buffer = parts2[parts2.length - 1];           // buffer = '' ← 清空

// 最终完整字段:['你好', '世界', '123']

关键点buffer 缓存了第一次拆分后无法构成完整业务单元的残段 ,第二次拼接后才形成 世界——避免数据断裂


总结:buffer 的设计哲学

场景 buffer 作用 是否必要
简化 LLM 流式输出(连续文本) 无实质性作用,仅“标准写法惯性” ❌ 可省略
按分隔符/长度/协议解析流数据 缓存业务层残段,保证数据完整性 ✅ 必不可少

💡 最佳实践建议
即使当前场景不需要 buffer保留其声明和清空逻辑是一种“鲁棒性设计”——为未来可能的协议变更或数据格式扩展预留空间。属于“提前留好扩展接口”的工程思维。


🖼️ 六、完整组件实现(App.vue)

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

const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
const stream = ref(true)
const content = ref('')

const askLLM = async () => {
  if (!question.value) return
  
  content.value = stream.value ? '思考中...' : ''
  
  const endpoint = 'https://api.deepseek.com/chat/completions'
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{ role: 'user', content: question.value }]
    })
  })

  if (!response.ok) {
    content.value = '请求失败,请检查 API Key 或网络'
    return
  }

  if (stream.value) {
    content.value = ''
    const reader = response.body?.getReader() // 获取流读取器(可能为 undefined)
    const decoder = new TextDecoder()         // 用于二进制 → 字符串
    let done = false                          // 全局流结束标记

    while (!done) {
      // 🔥【核心行】逐块读取流数据
      // - reader?.read():安全调用 read(),防止 reader 为 undefined
      // - await:等待异步读取完成
      // - 解构并重命名 done → doneReading,避免与外层变量冲突
      const { value, done: doneReading } = await reader?.read()
      
      // 更新全局流状态:决定 while 循环是否继续
      // doneReading 即 read() 返回的 done 字段,true 表示流已读完
      done = doneReading
      
      if (value) {
        // 将二进制块解码为字符串,并拼接到响应内容
        const chunk = decoder.decode(value)
        content.value += chunk
      }
    }
  } else {
    const data = await response.json()
    content.value = data.choices?.[0]?.message?.content || '无内容'
  }
}
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input v-model="question" placeholder="请输入问题" />
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <label>
        <input type="checkbox" v-model="stream" /> 启用流式输出
      </label>
      <div class="content">{{ content }}</div>
    </div>
  </div>
</template>

<style scoped>
.container {
  padding: 20px;
  max-width: 600px;
  margin: 0 auto;
  font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
input, button {
  padding: 6px 10px;
  margin: 4px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.output {
  margin-top: 20px;
}
.content {
  margin-top: 10px;
  padding: 12px;
  background: #f9f9f9;
  border-radius: 6px;
  min-height: 100px;
  white-space: pre-wrap;
}
</style>

环境变量安全提示
VITE_DEEPSEEK_API_KEY 需在 .env.local 中定义(不要提交到 Git):

text
编辑
VITE_DEEPSEEK_API_KEY=your_api_key_here

📌 七、注意事项与最佳实践

  1. 错误处理必须完善

    • 检查 response.ok
    • 捕获 fetch 网络错误
    • 处理 JSON 解析异常
  2. 避免内存泄漏

    • 在组件卸载时取消未完成的流(可结合 AbortController
    • 虽然 Vue 组件销毁后 reader 会被 GC,但显式调用 reader.releaseLock() 更规范
  3. 用户体验优化

    • 显示“思考中...”加载态
    • 自动滚动到底部(可配合 nextTick + scrollIntoView
  4. 安全性

    • API Key 绝不能硬编码在前端
    • 生产环境应通过后端代理转发请求(避免暴露密钥)

🧠 八、拓展思考

如果不用 DeepSeek,换成 OpenAI 呢?

接口几乎一致!只需改:

  • endpoint → https://api.openai.com/v1/chat/completions
  • model → gpt-3.5-turbo 或 gpt-4
  • Header 中的 Bearer Token

✅ 说明:主流 LLM 的流式接口设计高度统一,迁移成本极低。

能否支持 Markdown 渲染?

可以!在 content.value += delta 后,用 marked.jsvue-markdown-plus 实时渲染:

js
编辑
import { marked } from 'marked'
// ...
const html = marked(content.value)
// 绑定到 v-html(注意 XSS 风险!)

⚠️ 警告v-html 有 XSS 风险,务必对内容进行过滤或使用可信来源。


✅ 总结要点

要点 说明
response.body?.getReader() 安全获取流读取器,是流式处理的起点
reader.read() 每次读取一个数据块,返回 { value, done }
done 状态 true 表示流读取完成,是循环终止的唯一依据
数据类型 value 是 Uint8Array,需用 TextDecoder 转字符串
buffer 的作用 在连续文本场景中可省略;在协议解析场景中必不可少,用于缓存业务残段
错误 vs 结束 done: true 是正常结束,错误需 catch 捕获
响应式更新 Vue 3 的 ref 自动触发 DOM 更新
安全第一 API Key 不应暴露在前端,建议走代理

AI打字机的秘密:一个 buffer 如何让机器学会“慢慢说话”

当AI像打字机一样说话:揭秘流式输出背后的魔法

你有没有过这样的体验:和ChatGPT对话时,它不是突然蹦出整段文字,而是像真人一样,一个字一个字地敲出来?这种"打字机效果"不仅让交互更自然,还大大提升了用户体验——你不再需要焦虑地等待,而是能实时感受到AI的"思考过程"。


话不多说,先奉上代码效果

lovegif_1765087971567.gif

今天,我们将一起探索这个神奇效果背后的原理,并亲手实现一个Vue 3 + DeepSeek API的流式对话界面。在开始代码之前,先让我们理解一个关键概念:缓冲区(Buffer)

🌊 为什么需要"缓冲区"?—— 流式输出的基础

LLM 流式接口返回的是 Server-Sent Events (SSE) 格式的数据流,例如

image.png 但底层传输使用的是 HTTP/1.1 Chunked Transfer Encoding 或 HTTP/2 流,数据被切成任意大小的 二进制块(chunks) 发送。

所以每一行的数据可能是不完整的,这就有可能造成数据的丢失从而无法解析完整的数据

这时,缓冲区(Buffer) 就登场了!它像一个临时存储区,把不完整的数据先存起来,等到拼出完整的一行(如data: {"choices":[{"delta":{"content":"好"}}]})再处理。

Buffer的核心作用:解决网络分包导致的JSON解析失败问题,确保每个字都能正确显示。

HTML5中的Buffer实现

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTML5 Buffer</title>
</head>
<body>
    <h1>HTML5 Buffer</h1>
    <div id="output"></div>
    <script>
        //JS 二进制、数组缓存
        //html5 编码对象
        const encoder = new TextEncoder();
        console.log(encoder);
        const myBuffer =encoder.encode("你好 HTML5");
        console.log(myBuffer);
        // 数组缓存 12 字节
        // 创建一个缓冲区
        const buffer = new ArrayBuffer(12);
        // 创建一个视图(View)来操作这个缓冲区 
        const view = new Uint8Array(buffer);
        for(let i=0;i<myBuffer.length;i++){
           //   console.log(myBuffer[i]);
           view[i] = myBuffer[i];   
        }
        const decoder = new TextDecoder();
        const originalText = decoder.decode(buffer);
        console.log(originalText);
        const outputDiv = document.getElementById("output");
        outputDiv.innerHTML =`
        完整数据:[${view}]<br>
        第一个字节:${view[0]}<br>
        缓冲区的字节长度${buffer.byteLength}<br>
        原始文本:${originalText}<br>

        `
    </script>
</html>

请看这样一张图

我们输入的文本是你好 HTML5但通过二进制传输就变成了这样的Uint8Array —— 无符号 8 位整数数组 image.png 实现的原理?
关键点解析:

  1. TextEncoder:文本->字节的转换器通过调用encode方法将文本(字符串)编码成计算机能读懂的二进制字节序列(Uint8Array),这就是网络传输中的原始数据
  2. TextDecoder:字节->文本,他是encode的逆向过程,把二进制的数据解读为文本
  3. Uint8Array的底层内存块:ArrayBuffer:- ArrayBuffer 是底层的内存区域,存储实际的二进制数据,而你可以认为Uint8Array是对ArrayBuffer一种解读方式(UTF-8)

🌐 为什么这对流式输出很重要?

当你调用 LLM 接口时:

  1. 服务器发送的是 二进制流(chunked transfer encoding)
  2. 浏览器收到的是 Uint8Array 形式的 chunk
  3. 你需要用 TextDecoder 将其解码为字符串
  4. 再用 buffer 拼接不完整的行(如 data: {"delta":...}

🚨 所以:TextEncoder/TextDecoder 是连接“文本世界”和“字节世界”的桥梁

现在你可以明白:当 AI 一个字一个字地输出时,背后正是这些 Uint8ArrayTextDecoder 在默默工作


🧩 现在,让我们用代码实现这个"打字机"效果

下面,我们将从零开始构建一个Vue 3应用,实现LLM流式输出。


1. 响应式数据定义:Vue 3的"心脏"

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

const question = ref('讲一个喜洋洋和灰太狼的故事,200字')
const stream = ref(true)   // 默认开启流式
const content = ref("")    // 用于显示模型回答
</script>

关键点解析:使用ref响应式数据,能够更方便的快速绑定数据,当变量改变时能够实时更新页面内容,这也是我们选择vue框架的原因


2. 调用LLM的核心函数:askLLM

const askLLM = async () => { 
  if (!question.value) {
    console.log('question 不能为空');
    return 
  }
  content.value = '思考中...';  // 提前反馈

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

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{ role: 'user', content: question.value }]
    })
  })

关键点解析:

  1. 使用.env文件存储apikey
  2. 当调用大模型时,初始化content的值为“思考中”,优化用户体验
  3. 使用stream控制大模型的流式输出
  4. 通过messsges给出大模型清晰的上下文

3. 非流式模式:简单但不够"丝滑"

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

产品设计的理念:
非流式模型的实现很简单,等待大模型完成所有的输出后,一次性将其输出到页面,但是这对用户来说是一个糟糕的体验,对于一个产品来说,能更快的显示出页面数据,减少用户的等待时间就能留住更多的用户,没有人喜欢看不见进度条的一直等待!!!

4. 流式模式:核心魔法所在(重点!)

  if (stream.value) {
    content.value = "";  // 清空上一次的输出
    const reader = response.body?.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = '';

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

      // 关键:用buffer拼接不完整的JSON行
      const chunkValue = buffer + decoder.decode(value);
      buffer = '';

      // 按行分割,只处理有效的data:行
      const lines = chunkValue.split('\n')
        .filter(line => line.startsWith('data: '));

      for (const line of lines) {
        const incoming = line.slice(6); // 移除"data: "

        if (incoming === '[DONE]') {
          done = true;
          break;
        }

        try {
          // 解析JSON,获取新增的文本片段(delta)
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) {
            content.value += delta; // 响应式更新!
          }
        } catch (err) {
          // JSON解析失败?存入buffer等待下一次拼接
          buffer += `data: ${incoming}`;
        }
      }
    }
  }

关键点解析:

  1. reder-->读取二进制流,decoder将二进制块解码为字符串
  2. const reader = response.body?.getReader();这是一条可选链。如果body不为空,则调用getReader();
  3. 这个 reader 有一个关键方法:read(),它返回一个 Promise,解析为 { value: Uint8Array, done: boolean }:我们通过const { value, done: doneReading } = await reader?.read();将value和done解构出来,同时为了避免done和我们上面定义的done冲突,我们采用重命名的方式解构,将他重命名为doneReading
  4. 什么是chunk?在计算机网络中,数据不是一次性全部发送的,而是被切成一个个小段,逐个发送。这些小段就叫 chunks(数据块) 。而在浏览器的fetchAPI中,chunk就是通过reader.read()读取到的一个一个对象中的value
  5. 数据过滤,通过filter和startWith筛选出以data:开头的有效数据,然后通过slice()方法,将所有筛选出的数据切割掉data: 部分,方便后续解析JSON
  6. 使用try/catch防止丢字:因为大模型一次给出的token是不确定的,而我们的data{}一次能不能传完也不确定,所以一行data{}可能会被拆成两部分,这就会导致这一段解析失败,那么解析失败的数据并不是我们不需要的,只是它不小心被分成了两部分,所以我们需要对它进行存储,你能想到什么?没错就是buffer,我们将能够解析的部分先拼接到content中显示到页面,解析失败的我们则倒退的到它的“初态”,将data:拼接回去,然后存入bufer,在读取下一行时,把它拼接到最前面,与自己丢失的部分匹配,然后进行新一轮的流式处理,当我们完成拼接后,需要把buffer清空,不然会影响到下一次的拼接
  7. 流式输出结束的标志[DONE]:当我们对字符串进行处理时,如果剩余部分是[DONE]则代表所有内容已经输出完毕,我们就设置done为true来结束读取;

image.png


5. 模板与交互:让UI活起来

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input v-model="question" />
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <label>Streaming</label>
      <input type="checkbox" v-model="stream" />
      <div>{{ content }}</div>
    </div>
  </div>
</template>

image.png

关键点解析:

  1. v-model:双向绑定表单数据,无论我们修改表单数据还是直接修改变量,另外一边也能同时进行更新
  2. @click ="":vue中不再需要像JS中一样机械的流程式去监听DOM元素,我们直接可以为DOM元素绑定事件,当触发事件时自动调用方法

✨ 为什么这个"打字机"效果如此重要?

传统模式 流式模式
等待完整回答(2-5秒) 逐字显示(0.1-0.5秒/字)
用户焦虑等待 实时反馈,感觉AI在"思考"
体验生硬 交互自然,像真人对话

💡 关键洞察:流式输出不是技术炫技,而是用户心理的深度优化——它让AI从"工具"变成了"对话伙伴"。 代码虽短,但蕴含了现代AI交互的核心思想。真正的技术不是写代码,而是理解用户在等待时的心理
最后的思考:当AI能"打字"时,它不再是冰冷的机器,而成了你对话中的伙伴。而你,已经掌握了创造这种对话的魔法。

使用 Vue 3 实现大模型流式输出:从零搭建一个简易对话 Demo

在当前 AI 应用快速发展的背景下,前端开发者越来越多地需要与大语言模型(LLM)进行交互。本文将基于你提供的 App.vue 代码和学习笔记,带你一步步理解如何使用 Vue 3 + Composition API 构建一个支持 流式输出(Streaming) 的 LLM 对话界面。我们将重点解析代码结构、响应式原理、流式数据处理逻辑,并确保内容通俗易懂,适合初学者或希望快速上手的开发者。


一、项目初始化与技术选型

项目是通过 Vite 初始化的:

npm init vite

选择 Vue 3 + JavaScript 模板。Vite 作为新一代前端构建工具,以其极速的冷启动和热更新能力,成为现代 Vue 项目的首选脚手架。

生成的项目结构简洁清晰,核心开发文件位于 src/ 目录下,而 App.vue 就是整个应用的根组件。


二、Vue 3 的“三明治”结构

.vue 文件由三部分组成:

  • <script setup>:逻辑层(使用 Composition API)
  • <template>:模板层(声明式 UI)
  • <style scoped>:样式层(作用域 CSS)

这种结构让代码职责分明,也便于维护。


三、响应式数据:ref 的核心作用

<script setup> 中,你使用了 ref 来创建响应式变量:

import { ref } from 'vue'

const question = ref('讲一个喜羊羊和灰太狼的小故事,不低于20字')
//控制是否启用流式输出(streaming) 默认开启
const stream = ref(true)
//声明content 单向绑定 用于呈现LLM输出的值
const content = ref('')

什么是 ref

  • ref 是 Vue 3 Composition API 提供的一个函数,用于创建响应式引用对象
  • 它内部包裹一个值(如字符串、数字等),并通过 .value 访问或修改。
  • 在模板中使用时,Vue 会自动解包 .value,所以你只需写 {{ content }} 而非 {{ content.value }}

关键点:当 ref 的值发生变化时,模板会自动重新渲染——这就是“响应式”的核心。

例如:

let count = ref(111)//此时count就为响应式对象
setTimeout(() => {
  count.value = 222 // 模板中绑定 {{ count }} 会自动更新为 222
}, 2000)

这避免了传统 DOM 操作(如 getElementById().innerText = ...),让开发者更专注于业务逻辑。


四、双向绑定:v-model 的妙用

在输入框中,你使用了 v-model

<input type="input" v-model="question" />

v-model 是什么?

  • 它是 Vue 提供的双向数据绑定指令。
  • 输入框的值与 question.value 实时同步:用户输入 → question 更新;question 变化 → 输入框内容更新。
  • 如果改用 :value="question",则只能单向绑定(数据 → 视图),无法实现用户输入自动更新数据。

这使得表单处理变得极其简单。


五、调用大模型 API:异步请求与流式处理

核心功能在 askLLM 函数中实现:

//调用大模型 async await 异步任务同步化
const askLLM = async () => {
  if (!question.value) {
    console.log('question 不能为空!')
    return
    //校验question.value 为空直接return 避免无意义地进行下一步操作
  }
  //提升用户体验 先显示'思考中...' 表示正在处理
  content.value = '思考中...'
  
  //发生请求的时候 首先发送 请求行(方法 url 版本)
  const endpoint = 'https://api.deepseek.com/chat/completions'
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }
  
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,//启用流式输出
      messages: [{ role: 'user', content: question.value }]
    })
  })

关键细节:

  1. 环境变量安全:API Key 通过 import.meta.env.VITE_DEEPSEEK_API_KEY 引入。Vite 要求以 VITE_ 开头的环境变量才能在客户端暴露,这是一种安全实践。
  2. 请求体结构:符合 OpenAI 兼容 API 标准,指定模型、是否流式、消息历史。
  3. 用户体验优化:请求发起后立即显示“思考中...”,避免界面卡顿感。

六、流式输出(Streaming)的实现原理

这是本文的重点。当 stream.value === true 时,采用流式处理:

if (stream.value) {
  content.value = ''//先把上一次的输出清空
  //html5 流式响应体 getReader() 响应体的读对象
  const reader = response.body?.getReader()
  const decoder = new TextDecoder()
  let done = false //用来判断流是否结束
  let buffer = ''

  while (!done) {
  //只要流还未结束 就一直拼接buffer
  //解构的同时  重命名 done-> doneReading
    const { value, done: doneReading } = await reader?.read()
    done = doneReading //当数据流结束后 赋值给外部的done 结束while
    const chunkValue = buffer + decoder.decode(value, { stream: true })
    buffer = ''

    const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
    for (const line of lines) {
      const incoming = line.slice(6) // 去掉 "data: "  去除数据标签
      if (incoming === '[DONE]') {
        done = true //将外部done改为true 结束循环
        break
      }
      try {
        const data = JSON.parse(incoming)
        const delta = data.choices[0].delta.content
        if (delta) {
          content.value += delta
        }
      } catch (err) {
      //JSON.parse解析失败  拿给下一次去解析
        buffer += `data: ${incoming}`
      }
    }
  }
}

流式输出的工作流程:

  1. 获取可读流response.body.getReader() 返回一个 ReadableStreamDefaultReader

  2. 逐块读取:每次 reader.read() 返回一个 { value, done } 对象,valueUint8Array(二进制数据)。

  3. 解码为字符串:使用 TextDecoder 将二进制转为文本。注意传入 { stream: true } 避免 UTF-8 截断问题。

  4. 按行解析 SSE(Server-Sent Events)

    • 服务端返回格式为多行 data: {...}\n
    • 每行以 data: 开头,末尾可能有 \n\n
    • 遇到 [DONE] 表示流结束。
  5. 拼接增量内容delta.content 是当前 token 的文本片段,不断追加到 content.value,实现“打字机”效果。

💡 为什么需要 buffer?
因为网络传输的 chunk 可能不完整(比如一个 JSON 被切成两半),所以未解析成功的部分暂存到 buffer,下次循环再拼接处理。


七、非流式模式的简化处理

如果不启用流式(stream = false),则直接等待完整响应:

else {
  const data = await response.json()
  content.value = data.choices[0].message.content
}

这种方式简单直接,但用户体验较差——用户需等待全部内容生成完毕才能看到结果。


八、模板与样式:简洁直观的 UI

<template>
  <div class="container">
    <div>
      <label>输入: </label>
      <input v-model="question" />
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <label>Streaming</label>
      <input type="checkbox" v-model="stream" />
      <div>{{ content }}</div>
    </div>
  </div>
</template>
  • 用户可切换流式/非流式模式。
  • 输出区域实时展示 LLM 的回复。

样式使用 flex 布局,确保在不同屏幕下良好显示。


九、总结与延伸

通过这个 Demo,我们实现了:

✅ 使用 ref 管理响应式状态
✅ 利用 v-model 实现表单双向绑定
✅ 调用 DeepSeek API 发起聊天请求
✅ 支持流式与非流式两种输出模式
✅ 处理 SSE 流式响应,实现逐字输出效果


结语

这个项目虽小,却涵盖了 Vue 3 响应式、异步请求、流式处理等核心概念。正如笔记所说:“我们就可以聚焦于业务,不用写 DOM API 了”。这正是现代前端框架的价值所在——让我们从繁琐的 DOM 操作中解放出来,专注于创造更好的用户体验。

希望这篇解析能帮助你在稀土掘金的读者快速理解代码逻辑,并激发更多关于 AI + 前端的创意!

从零构建Vue项目的完全指南:手把手打造现代化前端工程

从零构建Vue项目的完全指南:手把手打造现代化前端工程

一、项目构建整体流程图

让我们先看看完整的项目构建流程:

deepseek_mermaid_20251207_d1ddc8.png

二、详细构建步骤

步骤1:环境准备与项目初始化

首先确保你的开发环境已准备好:

# 检查Node.js版本(建议18+)
node -v

# 检查npm版本
npm -v

# 安装Vue CLI(如果还没有)
npm install -g @vue/cli

# 创建新项目
vue create my-vue-project

# 选择配置(推荐手动选择)
? Please pick a preset: 
  Default ([Vue 2] babel, eslint)
  Default (Vue 3) ([Vue 3] babel, eslint)
❯ Manually select features

# 选择需要的功能
? Check the features needed for your project:
 ◉ Babel
 ◉ TypeScript
 ◉ Progressive Web App (PWA) Support
 ◉ Router
 ◉ Vuex
 ◉ CSS Pre-processors
❯◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

步骤2:项目目录结构设计

一个良好的目录结构是项目成功的基础。这是我推荐的目录结构:

my-vue-project/
├── public/                    # 静态资源
│   ├── index.html
│   ├── favicon.ico
│   └── robots.txt
├── src/
│   ├── api/                  # API接口管理
│   │   ├── modules/         # 按模块划分的API
│   │   ├── index.ts         # API统一导出
│   │   └── request.ts       # 请求封装
│   ├── assets/              # 静态资源
│   │   ├── images/
│   │   ├── styles/
│   │   └── fonts/
│   ├── components/          # 公共组件
│   │   ├── common/         # 全局通用组件
│   │   ├── business/       # 业务组件
│   │   └── index.ts        # 组件自动注册
│   ├── composables/        # 组合式函数
│   │   ├── useFetch.ts
│   │   ├── useForm.ts
│   │   └── index.ts
│   ├── directives/         # 自定义指令
│   │   ├── permission.ts
│   │   └── index.ts
│   ├── layouts/            # 布局组件
│   │   ├── DefaultLayout.vue
│   │   └── AuthLayout.vue
│   ├── router/             # 路由配置
│   │   ├── modules/       # 路由模块
│   │   ├── index.ts
│   │   └── guard.ts      # 路由守卫
│   ├── store/              # Vuex/Pinia状态管理
│   │   ├── modules/       # 模块化store
│   │   └── index.ts
│   ├── utils/              # 工具函数
│   │   ├── auth.ts        # 权限相关
│   │   ├── validate.ts    # 验证函数
│   │   └── index.ts
│   ├── views/              # 页面组件
│   │   ├── Home/
│   │   ├── User/
│   │   └── About/
│   ├── types/              # TypeScript类型定义
│   │   ├── api.d.ts
│   │   ├── global.d.ts
│   │   └── index.d.ts
│   ├── App.vue
│   └── main.ts
├── tests/                   # 测试文件
├── .env.*                   # 环境变量
├── vite.config.ts          # Vite配置
├── tsconfig.json           # TypeScript配置
└── package.json

步骤3:核心配置详解

1. 配置Vite(vite.config.ts)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@components': path.resolve(__dirname, 'src/components'),
      '@views': path.resolve(__dirname, 'src/views'),
    },
  },
  server: {
    host'0.0.0.0',
    port3000,
    proxy: {
      '/api': {
        target'http://localhost:8080',
        changeOrigintrue,
        rewrite: (path) => path.replace(/^/api/, ''),
      },
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@import "@/assets/styles/variables.scss";`,
      },
    },
  },
})
2. 路由配置(router/index.ts)
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

const routesRouteRecordRaw[] = [
  {
    path'/',
    name'Home',
    component() => import('@views/Home/Home.vue'),
    meta: {
      title'首页',
      requiresAuthtrue,
    },
  },
  {
    path'/login',
    name'Login',
    component() => import('@views/Login/Login.vue'),
    meta: {
      title'登录',
    },
  },
  {
    path'/user/:id',
    name'User',
    component() => import('@views/User/User.vue'),
    propstrue,
  },
]

const router = createRouter({
  historycreateWebHistory(),
  routes,
})

// 路由守卫
router.beforeEach((to, from, next) => {
  document.title = to.meta.title as string || 'Vue项目'
  
  // 检查是否需要登录
  if (to.meta.requiresAuth && !localStorage.getItem('token')) {
    next('/login')
  } else {
    next()
  }
})

export default router
3. 状态管理(使用Pinia)
// store/user.ts
import { defineStore } from 'pinia'

interface UserState {
  userInfo: {
    namestring
    avatarstring
    rolesstring[]
  } | null
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    userInfonull,
  }),
  actions: {
    async login(credentials: { username: string; password: string }) {
      // 登录逻辑
      const response = await api.login(credentials)
      this.userInfo = response.data
      localStorage.setItem('token', response.token)
    },
    logout() {
      this.userInfo = null
      localStorage.removeItem('token')
    },
  },
  getters: {
    isLoggedIn(state) => !!state.userInfo,
    hasRole(state) => (role: string) => 
      state.userInfo?.roles.includes(role) || false,
  },
})

步骤4:核心工具库和插件选择

这是我在项目中推荐使用的库:

{
  "dependencies": {
    "vue""^3.3.0",
    "vue-router""^4.2.0",
    "pinia""^2.1.0",
    "axios""^1.4.0",
    "element-plus""^2.3.0",
    "lodash-es""^4.17.21",
    "dayjs""^1.11.0",
    "vxe-table""^4.0.0",
    "vue-i18n""^9.0.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue""^4.2.0",
    "@types/node""^20.0.0",
    "sass""^1.62.0",
    "eslint""^8.0.0",
    "prettier""^3.0.0",
    "husky""^8.0.0",
    "commitlint""^17.0.0",
    "vitest""^0.30.0",
    "unplugin-auto-import""^0.16.0",
    "unplugin-vue-components""^0.25.0"
  }
}

步骤5:实用的组件示例

1. 全局请求封装
// src/api/request.ts
import axios from 'axios'
import type { AxiosRequestConfigAxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'

const service = axios.create({
  baseURLimport.meta.env.VITE_API_BASE_URL,
  timeout10000,
})

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, data, message } = response.data
    
    if (code === 200) {
      return data
    } else {
      ElMessage.error(message || '请求失败')
      return Promise.reject(new Error(message))
    }
  },
  (error) => {
    if (error.response?.status === 401) {
      // 未授权,跳转到登录页
      localStorage.removeItem('token')
      window.location.href = '/login'
    }
    ElMessage.error(error.message || '网络错误')
    return Promise.reject(error)
  }
)

export default service
2. 自动导入组件配置
// vite.config.ts 补充配置
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    // 自动导入API
    AutoImport({
      imports: ['vue''vue-router''pinia'],
      dts'src/types/auto-imports.d.ts',
      resolvers: [ElementPlusResolver()],
    }),
    // 自动导入组件
    Components({
      dts'src/types/components.d.ts',
      resolvers: [ElementPlusResolver()],
      dirs: ['src/components'],
    }),
  ],
})
3. 实用的Vue 3组合式函数
// src/composables/useForm.ts
import { ref, reactive, computed } from 'vue'
import type { Ref } from 'vue'

export function useForm<T extends object>(initialData: T) {
  const formData = reactive({ ...initialData }) as T
  const errors = reactive<Record<stringstring>>({})
  const isSubmitting = ref(false)

  const validate = async (): Promise<boolean> => {
    // 这里可以集成具体的验证逻辑
    return true
  }

  const submit = async (submitFn: (data: T) => Promise<any>) => {
    if (!(await validate())) return
    
    isSubmitting.value = true
    try {
      const result = await submitFn(formData)
      return result
    } catch (error) {
      throw error
    } finally {
      isSubmitting.value = false
    }
  }

  const reset = () => {
    Object.assign(formData, initialData)
    Object.keys(errors).forEach(key => {
      errors[key] = ''
    })
  }

  return {
    formData,
    errors,
    isSubmittingcomputed(() => isSubmitting.value),
    validate,
    submit,
    reset,
  }
}

步骤6:开发规范与最佳实践

1. 代码提交规范
# 安装Git提交钩子
npx husky install
npm install -D @commitlint/config-conventional @commitlint/cli

# 创建commitlint配置
echo "module.exports = { extends: ['@commitlint/config-conventional'] }" > .commitlintrc.js

# 创建提交信息规范
# feat: 新功能
# fix: 修复bug
# docs: 文档更新
# style: 代码格式
# refactor: 重构
# test: 测试
# chore: 构建过程或辅助工具的变动
2. 环境变量配置
# .env.development
VITE_APP_TITLE=开发环境
VITE_API_BASE_URL=/api
VITE_USE_MOCK=true

# .env.production
VITE_APP_TITLE=生产环境
VITE_API_BASE_URL=https://api.example.com
VITE_USE_MOCK=false

步骤7:性能优化建议

// 路由懒加载优化
const routes = [
  {
    path'/dashboard',
    component() => import(/* webpackChunkName: "dashboard" */ '@/views/Dashboard.vue'),
  },
  {
    path'/settings',
    component() => import(/* webpackChunkName: "settings" */ '@/views/Settings.vue'),
  },
]

// 图片懒加载指令
// src/directives/lazyLoad.ts
import type { Directive } from 'vue'

const lazyLoadDirective = {
  mounted(el, binding) {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.src = binding.value
          observer.unobserve(el)
        }
      })
    })
    observer.observe(el)
  },
}

三、项目启动和常用命令

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
    "format": "prettier --write src/",
    "prepare": "husky install",
    "test": "vitest",
    "test:coverage": "vitest --coverage"
  }
}

四、总结与建议

通过以上步骤,你已经拥有了一个现代化、可维护的Vue项目基础。记住几个关键点:

  1. 1. 保持一致性 - 无论是命名规范还是代码风格
  2. 2. 模块化设计 - 功能解耦,便于维护和测试
  3. 3. 类型安全 - 充分利用TypeScript的优势
  4. 4. 自动化 - 尽可能自动化重复工作
  5. 5. 渐进式 - 不要一开始就追求完美,根据项目需求逐步完善

项目代码就像一座大厦,良好的基础决定了它的稳固性和可扩展性。希望这篇指南能帮助你在Vue项目开发中少走弯路!

深入浅出AI流式输出:从原理到Vue实战实现

深入浅出AI流式输出:从原理到Vue实战实现


在当前大模型(LLM)广泛应用的背景下,用户对“响应速度”的感知越来越敏感。当我们向AI提问时,传统“等待完整结果返回”的模式常常带来数秒甚至更久的空白界面——虽然实际网络请求可能只花了1秒,但用户的焦虑感却成倍放大。

流式输出(Streaming Output) 正是破解这一痛点的关键技术。它让AI像“边想边说”一样,把生成的内容逐字推送出来,极大提升了交互的流畅性与真实感。

本文将以 Vue 3 + Vite 为例,手把手带你实现一个完整的 AI 流式对话功能,并深入剖析底层原理,助你在自己的项目中快速落地。


一、为什么需要流式输出?对比两种交互模式

❌ 传统模式:全量响应 → 用户体验差

// 非流式请求
const response = await fetch('/api/llm', { ... });
const data = await response.json();
content.value = data.content; // 一次性赋值
  • ✅ 实现简单
  • ❌ 用户需等待全部内容生成完毕
  • ❌ 长文本场景下容易出现“卡死”错觉
  • ❌ 视觉反馈延迟高,降低信任感

✅ 流式模式:增量返回 → 用户体验飞跃

// 流式请求处理
const reader = response.body.getReader();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  const text = decoder.decode(value);
  content.value += parseChunk(text); // 实时拼接
}
  • ✅ 1~2秒内即可看到首个字符
  • ✅ 文字“打字机”效果增强沉浸感
  • ✅ 客户端可边接收边渲染,资源利用率更高
  • ✅ 更符合人类对话节奏,提升产品质感

📌 关键洞察:用户并不关心“是否真的快了”,而是关心“有没有动静”。流式输出的本质是用即时反馈对抗等待焦虑


二、技术基石:HTTP 分块传输与浏览器流 API

2.1 协议层支持:Transfer-Encoding: chunked

流式输出依赖于 HTTP/1.1 的 分块传输编码(Chunked Transfer Encoding)

  • 服务器无需知道总长度,可以一边生成数据一边发送
  • 数据被分割为多个“块”(chunk),每个块独立传输
  • 响应头中包含 Transfer-Encoding: chunked
  • 最终以一个空块(0\r\n\r\n)表示结束

这是实现流式响应的基础协议机制。

2.2 前端核心 API:ReadableStream 与 TextDecoder

现代浏览器提供了强大的原生流处理能力:

API 作用
response.body 返回一个 ReadableStream<Uint8Array>
getReader() 获取流读取器,用于逐块读取
TextDecoder 将二进制数据解码为字符串(UTF-8)

这些 API 不需要额外安装库,开箱即用,非常适合轻量级集成。


三、Vue 实战:一步步构建 AI 流式对话系统

我们使用 Vue 3 + Vite 构建一个极简的 Demo,接入 DeepSeek API 实现流式问答。

3.1 初始化项目

npm create vue@latest stream-demo
cd stream-demo
npm install

选择默认配置即可,确保启用 Vue 3 和 JavaScript 支持。

3.2 创建 .env 文件(安全存储密钥)

# .env
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxx

⚠️ 注意:不要提交 .env 到 Git!添加到 .gitignore


3.3 核心逻辑:App.vue

(1)响应式状态定义
<script setup>
import { ref } from 'vue'

// 用户输入的问题
const question = ref('讲一个喜羊羊和灰太狼的故事,20字')

// 是否启用流式输出
const stream = ref(true)

// 存储并展示模型返回内容
const content = ref('')
</script>

利用 ref 实现响应式更新,每次 content.value += delta 都会触发视图重绘。


(2)发送请求 & 处理流式响应
const askLLM = async () => {
  if (!question.value.trim()) {
    alert('请输入问题')
    return
  }

  // 显示加载提示
  content.value = '🧠 思考中...'

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

  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        model: 'deepseek-chat',
        stream: stream.value,
        messages: [
          { role: 'user', content: question.value }
        ]
      })
    })

    if (!response.ok) {
      throw new Error(`请求失败:${response.status}`)
    }

    // 区分流式 / 非流式
    if (stream.value) {
      await handleStreamResponse(response)
    } else {
      const data = await response.json()
      content.value = data.choices[0].message.content
    }
  } catch (error) {
    content.value = `❌ 请求失败:${error.message}`
    console.error(error)
  }
}

(3)流式处理核心函数
async function handleStreamResponse(response) {
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = '' // 缓存不完整 JSON 片段
  let done = false

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

    // 解码二进制数据
    const chunk = buffer + decoder.decode(value, { stream: true })
    buffer = ''

    // 按行处理 SSE 格式数据
    const lines = chunk.split('\n').filter(line => line.startsWith('data: '))

    for (const line of lines) {
      const raw = line.slice(6).trim() // 去除 "data: "

      if (raw === '[DONE]') {
        done = true
        break
      }

      try {
        const json = JSON.parse(raw)
        const delta = json.choices[0]?.delta?.content
        if (delta) {
          content.value += delta // 实时追加
        }
      } catch (e) {
        // 可能是不完整的 JSON,缓存起来等下一帧
        buffer = 'data:' + raw
      }
    }
  }

  reader.releaseLock()
}

🔍 重点说明:

  • decoder.decode(value, { stream: true }):启用流式解码,防止多字节字符被截断
  • buffer 缓存机制:解决因 TCP 分包导致的 JSON 被拆分问题
  • slice(6) 提取有效数据,过滤 data: 前缀
  • JSON.parsetry-catch 是必须的,避免解析失败中断整个流程

3.4 模板结构:简洁直观的 UI

<template>
  <div class="container">
    <!-- 输入区 -->
    <div class="input-group">
      <label>问题:</label>
      <input v-model="question" placeholder="请输入你想问的问题..." />
      <button @click="askLLM">发送</button>
    </div>

    <!-- 控制开关 -->
    <div class="control">
      <label>
        <input type="checkbox" v-model="stream" />
        启用流式输出
      </label>
    </div>

    <!-- 输出区 -->
    <div class="output-box">
      <h3>AI 回答:</h3>
      <p>{{ content }}</p>
    </div>
  </div>
</template>

3.5 样式美化(可选)

<style scoped>
.container {
  max-width: 800px;
  margin: 40px auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.input-group {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 20px;
}

input[type="text"] {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
}

button {
  padding: 10px 20px;
  background: #007aff;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}

button:hover {
  background: #005edc;
}

.control {
  margin: 16px 0;
  font-size: 14px;
}

.output-box {
  border: 1px solid #eee;
  padding: 20px;
  border-radius: 8px;
  min-height: 200px;
  background-color: #f9f9fb;
  white-space: pre-wrap;
  word-wrap: break-word;
}

.output-box p {
  line-height: 1.6;
  color: #333;
}
</style>

四、常见问题与优化策略

✅ Q1:为什么有时会出现乱码或解析错误?

原因:网络传输过程中,一个完整的 JSON 字符串可能被分成两个 Uint8Array 发送,导致单次 decode 得到的是半截字符串。

解决方案

  • 使用 buffer 缓存未完成的数据
  • 在下次读取时拼接后重新尝试解析
  • 设置 stream: true 参数给 TextDecoder.decode()

✅ Q2:如何防止频繁 DOM 更新影响性能?

虽然 Vue 的响应式系统很高效,但高频字符串拼接仍可能导致重排。

优化建议

// 使用 requestAnimationFrame 限制渲染频率
let pending = false
function scheduleUpdate(delta) {
  if (!pending) {
    requestAnimationFrame(() => {
      content.value += delta
      pending = false
    })
  }
}

适用于超高速输出场景(如代码生成)。


✅ Q3:如何支持取消请求?

引入 AbortController 即可:

const controller = new AbortController()

// 在 fetch 中传入 signal
const response = await fetch(url, {
  signal: controller.signal,
  // ...
})

// 提供取消按钮
const cancelRequest = () => controller.abort()

五、应用场景拓展

流式输出不仅限于 LLM 对话,还可用于:

场景 应用示例
🤖 聊天机器人 实时对话、客服系统
📄 文档生成 报告、合同、文案自动生成
💾 文件上传下载 显示进度条
📊 日志监控 实时日志流展示
🧮 数据分析 大批量计算结果逐步呈现

只要涉及“长时间任务 + 渐进式结果”,都可以考虑使用流式思想优化体验。


六、总结:掌握流式输出,让你的 AI 应用脱颖而出

流式输出不是炫技,而是一种以人为本的设计思维。它把“等待”变成了“参与”,让用户感受到系统的“思考过程”,从而建立更强的信任感。

通过本文的学习,你应该已经掌握了:

✅ 如何发起带 stream=true 的 LLM 请求
✅ 如何使用 ReadableStream 处理分块数据
✅ 如何用 TextDecoder 安全解析二进制流
✅ 如何在 Vue 中实现实时渲染
✅ 如何应对流式中的边界情况(如 JSON 截断)

❤️ 写在最后

随着 AIGC 的普及,前端工程师的角色正在从“页面搭建者”转向“智能交互设计师”。掌握流式输出这类核心技术,不仅能做出更好用的产品,也能在未来的技术浪潮中占据先机。

动手是最好的学习方式。 打开编辑器,运行一遍这个 Demo,亲自感受那种“文字跃然屏上”的丝滑体验吧!

Vue3-watchEffect

watchEffect 是 Vue 3 组合式 API 提供的另一个侦听器。它和 watch 目标一致(在数据变化时执行副作用),但使用方式更简洁,因为它会自动追踪依赖

1.核心理念

watchEffect 的核心理念是: “立即执行一次,并自动追踪函数内部用到的所有响应式数据,在它们变化时重新执行。”

它只需要一个参数:一个函数。

<template>
  <p>User ID: {{ userId }}</p>
  <button @click="changeUser">切换用户</button>
</template>

<script setup lang="ts">
import { ref, watchEffect } from 'vue';

const userId = ref(1);

// 1. 立即执行:
// watchEffect 会在 <script setup> 执行到这里时立即运行一次
// 它会自动“发现”内部用到了 userId.value
watchEffect(() => {
  // 当 watchEffect 运行时,它会追踪所有被“读取”的 .value
  console.log(`(watchEffect) 正在获取 ID: ${userId.value} 的数据...`);
  
  // 假设的 API 调用
  // fetchUserData(userId.value);
});

// 2. 自动追踪变化:
// 当 userId 变化时,上面的函数会 *自动* 重新运行
const changeUser = () => {
  userId.value++;
};
</script>

2.watchEffectwatch 的核心区别

特性 watch watchEffect
依赖源 手动指定 (必须明确告诉它侦听谁) 自动追踪 (它会侦听函数体内部用到的所有数据)
立即执行 默认不会 (需配置 { immediate: true }) 默认立即执行
访问旧值 可以 (回调参数 (newVal, oldVal)) 不可以 (回调不接收任何参数)
侧重点 适合精确控制 更“轻量级”,适合简单的、自动化的副作用

3.watchEffect 的高级用法

  • 停止侦听:watchEffect 同样会返回一个“停止句柄” (Stop Handle) 函数。

<script setup> 中,它也会自动绑定到组件生命周期,并在组件卸载时自动停止,所以你通常不需要手动停止。

import { watchEffect } from 'vue';

const stopHandle = watchEffect(() => {
  // ...
});

// 在未来的某个时刻
stopHandle(); // 停止这个 effect
  • 清除副作用 (onInvalidate):watchEffect 的回调函数可以接收一个 onInvalidate 函数作为参数。这个函数用于注册一个“失效”时的回调。

watchEffect 即将重新运行时(或在侦听器被停止时),onInvalidate 注册的回调会先执行。这在处理异步操作竞态时非常有用(例如,短时间内多次触发 API 请求)。

举个栗子

<template>
  <div>
    <h3>onInvalidate</h3>
    <p>在控制台中查看日志,并快速输入:</p>
    <input v-model="query" />
    <p>当前查询: {{ query }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref, watchEffect } from 'vue';

const query = ref('vue');

watchEffect((onInvalidate) => {
  // 1. 创建 AbortController
  const controller = new AbortController();
  const { signal } = controller;

  console.log(`(Fetch) 正在搜索: ${query.value}`);
  
  // 2. 使用 Open Library 的 API
  // 对 URL 进行了编码 (encodeURIComponent) 以处理特殊字符
  const url = `https://openlibrary.org/search.json?q=${encodeURIComponent(query.value)}`;

  fetch(url, { signal })
    .then(response => response.json()) // 将响应解析为 JSON
    .then(data => {
      // 3. 成功获取数据
      console.log(`(Success) 成功获取: ${query.value}`, data.docs.length, '条结果');
    })
    .catch(err => {
      // 4. 捕获错误
      if (err.name === 'AbortError') {
        //  预期的中止错误
        console.log(`(Abort) 已取消上一次请求: ${query.value}`);
      } else {
        // 其他网络错误
        console.error('Fetch 错误:', err);
      }
    });

  // 5. 注册“失效”回调
  onInvalidate(() => {
    console.log('...查询变化太快,Effect 即将失效,中止上一个请求...');
    // 6. 中止上一次的 fetch 请求
    controller.abort();
  });
});

</script>

当快速输入333时,控制台打印

image.png

检查网络,前两次的快速请求都被取消了,只有最后一次成功

image.png

4.总结

onInvalidate 适用于任何有“清理”需求的副作用

  1. 取消异步请求:(最常用) fetch, axios 等。
  2. 清除定时器:清除 setTimeoutsetInterval
  3. 解绑事件监听器:如果在 watchEffect 内部用 window.addEventListener 动态添加了一个事件,你可以在 onInvalidate 中用 window.removeEventListener 将其移除,防止内存泄漏。

两个关键区别:

  • 防抖/节流 (Debounce/Throttle) (用 watch):

    • 目标:减少执行次数
    • 逻辑:“等一等再执行”。
  • 清理副作用 (Cleanup) (用 watchEffect + onInvalidate):

    • 目标:防止旧操作干扰新操作
    • 逻辑:“开始新的之前,先确保旧的已经停止了”。

vite里postcss里@csstools/postcss-global-data的用法 (importFrom失效后的解决办法

Postcss-custom-properties 默认只能识别:root{} 和:html{}的变量
确保你的css变量符合规定 不然不会生效

图片.png

```postcss: {
            plugins: [
                postcssGlobalData({
                    files: [
                        path.resolve(process.cwd(), "从你vite.config.js到css的相对路径")
                    ]
                }),
                postcssPresetEnv()
            ]

        }
        //这个path和process都是node的 所以还要引入 const path = require("path")
        //这里vite里的postcss对象和postcss.config.js里默认导出的那个对象是一样的

gloabalData需要放到custom_properties前面 代码里的postcssPresetEnv是一堆插件的集合(pluginPackage)其中就包含了custom_properties,按需替换 图片.png

css3的变量不能用于less因为postcss在生命周期上要比less靠后(less先处理postcss后处理 但是其他的css文件都是能正常用的
目前vite模块化css要实现变量和代码解耦只有两种方式
1.全用less然后在preprocessorOptions: {less: {globalVars: 里去写全局变量 或者你单独写个less文件然后在其他文件里用@import url(./xxx.less)引入 globalvars可以和dotEnv联用

2.用css自带的变量写法 :root{ --aaa-bb:变量} 使用var(--aaa-bb)来引用配合global-data和custom-properties来实现\

虽然用less定义css3的变量 在普通的模块化css里也能引入也能正常生效 但是由于按需引入等问题
模块化less在vite里老是不生效 不知道是不是我环境的问题 还是他不支持.module.less的写法

注意:CSS Modules 设计初衷就是配合 JavaScript 使用,无法像传统 CSS 那样直接在 HTML 中通过 class 属性使用原始类名。这是 CSS Modules 实现样式作用域隔离的机制决定的。所以.module.css需要在main.js(入口文件)中引入他才会打包

vxe-gantt 甘特图实现产品进度列表,自定义任务条样式和提示信息

vxe-gantt 甘特图实现产品进度列表,自定义任务条样式和提示信息

查看官网:gantt.vxeui.com/
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

效果

image

代码

通过 task-view-config.viewStyle.cellStyle 设置任务视图单元格样式,使用 taskBar、taskBarTooltip 插槽来自定义模板

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions">
      <template #task-bar="{ row }">
        <div class="custom-task-bar" :style="{ backgroundColor: row.bgColor }">
          <div class="custom-task-bar-img">
            <vxe-image :src="row.imgUrl" width="60" height="60"></vxe-image>
          </div>
          <div>
            <div>{{ row.title }}</div>
            <div>开始日期:{{ row.start }}</div>
            <div>结束日期:{{ row.end }}</div>
            <div>进度:{{ row.progress }}%</div>
          </div>
        </div>
      </template>

      <template #task-bar-tooltip="{ row }">
        <div>
          <div>任务名称:{{ row.title }}</div>
          <div>开始时间:{{ row.start }}</div>
          <div>结束时间:{{ row.end }}</div>
          <div>进度:{{ row.progress }}%</div>
        </div>
      </template>
    </vxe-gantt>
  </div>
</template>

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

const ganttOptions = reactive({
  border: true,
  height: 600,
  cellConfig: {
    height: 100
  },
  taskViewConfig: {
    tableStyle: {
      width: 380
    },
    showNowLine: true,
    scales: [
      { type: 'month' },
      {
        type: 'day',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      },
      {
        type: 'date',
        headerCellStyle ({ dateObj }) {
          // 周日高亮
          if (dateObj.e === 0) {
            return {
              backgroundColor: '#f9f0f0'
            }
          }
          return {}
        }
      }
    ],
    viewStyle: {
      cellStyle ({ dateObj }) {
        // 周日高亮
        if (dateObj.e === 0) {
          return {
            backgroundColor: '#f9f0f0'
          }
        }
        return {}
      }
    }
  },
  taskBarConfig: {
    showTooltip: true,
    barStyle: {
      round: true
    }
  },
  columns: [
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, title: '任务1', start: '2024-03-03', end: '2024-03-10', progress: 20, bgColor: '#c1c452', imgUrl: 'https://vxeui.com/resource/productImg/product9.png' },
    { id: 10002, title: '任务2', start: '2024-03-05', end: '2024-03-12', progress: 15, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product8.png' },
    { id: 10003, title: '任务3', start: '2024-03-10', end: '2024-03-21', progress: 25, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product1.png' },
    { id: 10004, title: '任务4', start: '2024-03-15', end: '2024-03-24', progress: 70, bgColor: '#fad06c', imgUrl: 'https://vxeui.com/resource/productImg/product3.png' },
    { id: 10005, title: '任务5', start: '2024-03-20', end: '2024-04-05', progress: 50, bgColor: '#e78dd2', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10006, title: '任务6', start: '2024-03-22', end: '2024-03-29', progress: 38, bgColor: '#8be1e6', imgUrl: 'https://vxeui.com/resource/productImg/product7.png' },
    { id: 10007, title: '任务7', start: '2024-03-28', end: '2024-04-04', progress: 24, bgColor: '#78e6d1', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' },
    { id: 10008, title: '任务8', start: '2024-04-05', end: '2024-04-18', progress: 65, bgColor: '#edb695', imgUrl: 'https://vxeui.com/resource/productImg/product4.png' }
  ]
})
</script>

<style lang="scss" scoped>
.custom-task-bar {
  display: flex;
  flex-direction: row;
  padding: 8px 16px;
  width: 100%;
  font-size: 12px;
}
.custom-task-bar-img {
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  width: 70px;
  height: 70px;
}
</style>

gitee.com/x-extends/v…

Vue 3 Keep-Alive 深度实践:从原理到最佳实践

Vue 3 Keep-Alive 深度实践:从原理到最佳实践

前言

初入职场,我被安排用 Vue3 制作公司官网,有 5-6 个静态页面。开发完成后,领导在测试时提出一个问题:“为什么页面滑动后再切换到其它页面,返回时没有回到顶部?”调试后发现,是因为使用了 <keep-alive> 组件缓存页面导致的。这引发了我对 Vue 3 Keep-Alive 的浓厚兴趣。Keep-Alive 能帮助我们在页面间切换时保留组件的状态,使用户体验更加流畅。特别是在带有筛选和滚动列表的页面中,使用 Keep-Alive 可以在返回时保留用户之前的筛选条件和滚动位置,无需重新加载或初始化。

在本文中,我将结合实例,从基础到深入地解析 Vue 3 中的 Keep-Alive 组件原理、常见问题及最佳实践,帮助大家全面掌握这一功能。


一、了解 Keep-Alive:什么是组件缓存?

1.1 Keep-Alive 的本质

<keep-alive> 是 Vue 的内置组件,用于缓存组件实例,避免在切换时重复创建和销毁组件实例。换言之,当组件被包裹在 <keep-alive> 中离开视图时,它不会被销毁,而是进入缓存;再次访问时,该组件实例会被重新激活,状态依然保留。

示例场景:用户从列表页进入详情页后再返回列表页。

没有 Keep-Alive 的情况

  • 用户操作:首页 → 探索页 → 文章详情 → 探索页

  • 组件生命周期:

    • 首页:创建 → 挂载 → 销毁
    • 探索页:创建 → 挂载 → 销毁 → 重新创建 → 重新挂载
    • 文章详情:创建 → 挂载 → 销毁
    • 探索页(再次):重新创建 → 重新挂载(状态丢失)

有 Keep-Alive 的情况

  • 用户操作:首页 → 探索页 → 文章详情 → 探索页

  • 组件生命周期:

    • 首页:创建 → 挂载 → 停用(缓存)
    • 探索页:创建 → 挂载 → 停用(缓存)
    • 文章详情:创建 → 挂载 → 销毁
    • 探索页(再次):激活(从缓存恢复,状态保持)

使用 <keep-alive> 包裹的组件,在离开时不会销毁,而是进入「停用(deactivated)」状态;再次访问时触发「激活(activated)」状态,原先所有的响应式数据都仍然保留。这意味着,探索页中的筛选条件和滚动位置都还能保留在页面返回时显示,提高了用户体验。

1.2 Keep-Alive 的工作原理

Keep-Alive 通过以下机制来实现组件缓存:

  • 缓存机制:当组件从视图中被移除时,如果包裹在 <keep-alive> 中,组件实例不会被销毁,而是存放在内存中。下次访问该组件时,直接复用之前缓存的实例。
  • 生命周期钩子:被缓存组件在进入和离开时,会触发两个特殊的钩子 —— onActivated / onDeactivatedactivated / deactivated。可以在这些钩子中执行恢复或清理操作,例如刷新数据或保存状态。
  • 组件匹配<keep-alive> 默认会缓存所有包裹其中的组件实例。但如果需要精确控制,就会用到 includeexclude 属性,匹配组件的 name 选项来决定是否缓存。注意,这里的匹配依赖于组件的 name 属性,与路由配置无关。

1.3 核心属性

  • include:字符串、正则或数组,只有 name 匹配的组件才会被缓存。
  • exclude:字符串、正则或数组,name 匹配的组件将不会被缓存。
  • max:数字,指定最多缓存多少个组件实例,超过限制时会删除最近最少使用的实例。

注意:include/exclude 匹配的是组件的 name 选项。在 Vue 3.2.34 及以后,如果使用了 <script setup>,组件会自动根据文件名推断出 name,无需手动声明。


二、使用 Keep-Alive:基础到进阶

2.1 基础使用

最简单的使用方式是将动态组件放在 <keep-alive> 里面:

<template>
  <keep-alive>
    <component :is="currentComponent" />
  </keep-alive>
</template>

这样每次切换 currentComponent 时,之前的组件实例会被缓存,状态不会丢失。

2.2 在 Vue Router 中使用

在 Vue Router 配置中,为了让路由页面支持缓存,需要将 <keep-alive> 放在 <router-view> 的插槽中:

<template>
  <router-view v-slot="{ Component }">
    <keep-alive>
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

这样 <keep-alive> 缓存的是路由对应的组件,而非 <router-view> 自身。不要包裹整个 <router-view>,而是通过插槽嵌套其渲染的组件。

2.3 使用 include 精确控制

如果只想缓存特定组件,可利用 include 属性:

<template>
  <router-view v-slot="{ Component }">
    <keep-alive include="Home,Explore">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

include 中的名称必须与组件的 name 完全一致,否则不起作用。

2.4 滑动位置缓存示例

以“探索”列表页为例:用户在该页设置筛选条件并滚动列表后,跳转到文章详情页,再返回“探索”页。如果没有使用 Keep-Alive,列表页组件会被重新创建,筛选条件和滚动位置会重置。

使用 <keep-alive> 缓存“探索”页后,返回时组件从缓存中激活,之前的 ref 值和 DOM 滚动位置依然保留。这保证了用户回到列表页时,能够看到原先浏览到的内容和筛选状态。

可以在组件中配合路由导航守卫保存和恢复滚动条位置:

  • onBeforeRouteLeave 钩子中记录 scrollTop
  • onActivated 钩子中恢复滚动条位置。

三、使用中的问题:Name 匹配的陷阱

3.1 问题场景

我们经常希望缓存某些页面状态,同时让某些页面不被缓存,例如:

  • “探索”列表页:需要缓存。
  • 登录/注册页:不需要缓存。
  • 文章详情页:通常不缓存。

3.2 第一次尝试:手动定义 Name

<script setup>
defineOptions({ name: 'Explore' })
</script>

然后在主组件中使用 include 指定名称:

<router-view v-slot="{ Component }">
  <keep-alive include="Home,Explore,UserCenter">
    <component :is="Component" />
  </keep-alive>
</router-view>

理论上只缓存 HomeExploreUserCenter

3.3 问题出现:为什么 Include 不生效?

  • 组件名称不匹配:include/exclude 匹配的是组件自身的 name 属性,而非路由配置中的 name
  • 自动生成的 Name:Vue 3.2.34+ 使用 <script setup> 会自动根据文件路径生成组件名,手动写的 name 可能与自动生成冲突。
  • 路由包装机制:Vue Router 渲染组件时可能进行包装,导致组件实际名称与原始组件不同。

依赖组件名匹配容易出错,需要更灵活的方法。


四、解决方式:深入理解底层逻辑

4.1 理解组件 Name 的生成机制

Vue 3.2.34+ 使用 <script setup> 的单文件组件会自动根据文件名推断组件的 name

  • src/pages/Explore/index.vue → 组件名 Explore
  • src/pages/User/Profile.vue → 组件名 Profile

无需手动定义 name,避免与自动推断冲突。

4.2 问题根源分析

  • 自动 Name 与路由名不一致。
  • Router 的组件包装可能导致 <keep-alive> 无法捕获组件原始 name。

4.3 解决方案:路由 Meta 控制缓存

  1. 移除手动定义的 Name
<script setup lang="js">
// Vue 会自动根据路径生成 name
</script>
  1. 在路由配置中设置 Meta
const routes = [
  {
    path: '/explore',
    name: 'Explore',
    component: () => import('@/pages/Explore/index.vue'),
    meta: { title: '探索', keepAlive: true }
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/pages/Auth/index.vue'),
    meta: { title: '登录', keepAlive: false }
  },
  {
    path: '/article/:id',
    name: 'ArticleDetail',
    component: () => import('@/pages/ArticleDetail/index.vue'),
    meta: { title: '文章详情', keepAlive: false }
  }
]
  1. 在 App.vue 中根据 Meta 控制
<script setup lang="js">
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const shouldCache = computed(() => route.meta?.keepAlive !== false)
</script>

<template>
  <router-view v-slot="{ Component }">
    <keep-alive v-if="shouldCache">
      <component :is="Component" />
    </keep-alive>
    <component v-else :is="Component" />
  </router-view>
</template>

默认缓存所有页面,只有 meta.keepAlive 明确为 false 时才不缓存。

4.4 方案优势

  • 灵活性强:缓存策略直接写在路由配置中。
  • 可维护性好:缓存策略集中管理。
  • 避免匹配失败:不依赖手动 name。
  • 默认友好:设置默认缓存,仅对不需要缓存页面标记即可。

五、最佳实践总结

5.1 缓存策略建议

页面类型 是否缓存 缓存原因
首页(静态) ❌ 不缓存 内容简单,一般无需缓存
列表/浏览页 ✅ 缓存 保持筛选条件、分页状态、滚动位置等
详情页 ❌ 不缓存 每次展示不同内容,应重新加载
表单页 ❌ 不缓存 避免表单数据残留
登录/注册页 ❌ 不缓存 用户身份相关,每次重新初始化
个人中心/控制台 ✅ 缓存 保留子页面状态,提升体验

5.2 代码规范

  • 不要手动定义 Name,在 Vue 3.2.34+ 中自动推断。
<script setup>
// Vue 会自动推断 name
</script>
  • 使用路由 Meta 控制缓存。
  • 统一在 App.vue 中处理缓存逻辑。

5.3 生命周期钩子的使用

<script setup>
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  console.log('组件被激活(从缓存恢复)')
})

onDeactivated(() => {
  console.log('组件被停用(进入缓存)')
})
</script>

5.4 性能考虑

  • 内存占用:不要无限制缓存过多页面,可使用 max 限制。
  • 数据刷新:在 onActivated 中进行必要更新。
  • 缓存清理:登出或不常用页面可手动清除缓存。
  • 动画与过渡:确保 <keep-alive><transition> 嵌套顺序正确。

六、总结

6.1 关键要点

  • <keep-alive> 缓存组件实例,通过停用保留状态。
  • include/exclude 功能依赖组件 name
  • 推荐使用路由 meta.keepAlive 控制缓存。
  • 缓存组件支持 onActivated / onDeactivated 钩子。
  • 默认缓存大部分页面,只对需刷新页面明确禁用。

6.2 技术演进

手动定义 Name → 自动 Name → Meta 控制

  • 冗长易错 → 简化代码 → 灵活可靠

6.3 最终方案

  • 利用自动生成的组件名取消手动命名。
  • 通过路由 meta.keepAlive 控制缓存。
  • 在根组件统一处理缓存逻辑。
  • 默认缓存,明确例外。

这样既保持了代码简洁,又实现了灵活可控的缓存策略,确保用户在页面切换时能获得更好的体验。


参考资料

  • Vue 3 Keep-Alive 官方文档
  • Vue Router 官方文档
  • Vue 3.2.34 更新日志
❌