阅读视图

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

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 + 前端的创意!

vxe-gantt vue table 甘特图子任务多层级自定义模板用法

vxe-gantt vue table 甘特图子任务多层级自定义模板用法,通过树结构来渲染多层级的子任务,将数据带有父子层级的数组进行渲染

查看官网:gantt.vxeui.com/
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

安装

npm install xe-utils@3.8.0 vxe-pc-ui@4.10.45 vxe-table@4.17.26 vxe-gantt@4.1.2
// ...
import VxeUIBase from 'vxe-pc-ui'
import 'vxe-pc-ui/es/style.css'

import VxeUITable from 'vxe-table'
import 'vxe-table/es/style.css'

import VxeUIGantt from 'vxe-gantt'
import 'vxe-gantt/lib/style.css'
// ...

createApp(App).use(VxeUIBase).use(VxeUITable).use(VxeUIGantt).mount('#app')
// ...

效果

image

代码

树结构由 tree-config 和 column.tree-node 参数开启,支持自动转换带有父子层级字段的平级列表数据,例如 { id: 'xx', parentId: 'xx' }。只需要设置 tree-config.transform 就可以开启自动转换,通过 task-view-config.scales.headerCellStyle 自定义列头样式

<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
  },
  treeConfig: {
    transform: true,
    rowField: 'id',
    parentField: 'parentId'
  },
  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: '任务名称', treeNode: true },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, parentId: null, title: '任务1', start: '2024-03-03', end: '2024-03-10', progress: 20, bgColor: '#c1c452', imgUrl: 'https://vxeui.com/resource/productImg/product9.png' },
    { id: 10002, parentId: 10001, title: '任务2', start: '2024-03-05', end: '2024-03-12', progress: 15, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product8.png' },
    { id: 10003, parentId: 10001, title: '任务3', start: '2024-03-10', end: '2024-03-21', progress: 25, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product1.png' },
    { id: 10004, parentId: 10002, title: '任务4', start: '2024-03-15', end: '2024-03-24', progress: 70, bgColor: '#fad06c', imgUrl: 'https://vxeui.com/resource/productImg/product3.png' },
    { id: 10005, parentId: 10003, title: '任务5', start: '2024-03-20', end: '2024-04-05', progress: 50, bgColor: '#e78dd2', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10006, parentId: null, title: '任务6', start: '2024-03-22', end: '2024-03-29', progress: 38, bgColor: '#8be1e6', imgUrl: 'https://vxeui.com/resource/productImg/product7.png' },
    { id: 10007, parentId: null, title: '任务7', start: '2024-03-28', end: '2024-04-04', progress: 24, bgColor: '#78e6d1', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' },
    { id: 10008, parentId: 10007, title: '任务8', start: '2024-05-18', end: '2024-05-28', progress: 65, bgColor: '#edb695', imgUrl: 'https://vxeui.com/resource/productImg/product4.png' },
    { id: 10009, parentId: 10008, title: '任务9', start: '2024-05-05', end: '2024-05-28', progress: 78, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product6.png' },
    { id: 10010, parentId: 10008, title: '任务10', start: '2024-04-28', end: '2024-05-17', progress: 19, bgColor: '#92c1f1', imgUrl: 'https://vxeui.com/resource/productImg/product5.png' },
    { id: 10011, parentId: 10009, title: '任务11', start: '2024-04-01', end: '2024-05-01', progress: 100, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product4.png' },
    { id: 10012, parentId: 10009, title: '任务12', start: '2024-04-09', end: '2024-04-22', progress: 90, bgColor: '#fd9393', imgUrl: 'https://vxeui.com/resource/productImg/product8.png' },
    { id: 10013, parentId: 10010, title: '任务13', start: '2024-03-22', end: '2024-04-05', progress: 86, bgColor: '#fad06c', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10014, parentId: null, title: '任务14', start: '2024-04-05', end: '2024-04-18', progress: 65, bgColor: '#8be1e6', imgUrl: 'https://vxeui.com/resource/productImg/product6.png' },
    { id: 10015, parentId: 10014, title: '任务15', start: '2024-03-05', end: '2024-03-18', progress: 48, bgColor: '#edb695', imgUrl: 'https://vxeui.com/resource/productImg/product11.png' },
    { id: 10016, parentId: null, title: '任务16', start: '2024-03-01', end: '2024-03-28', progress: 28, bgColor: '#e78dd2', imgUrl: 'https://vxeui.com/resource/productImg/product12.png' },
    { id: 10017, parentId: null, title: '任务17', start: '2024-03-19', end: '2024-04-02', progress: 36, bgColor: '#c1c452', imgUrl: 'https://vxeui.com/resource/productImg/product5.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项目的完全指南:手把手打造现代化前端工程

从零构建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 更新日志

Vue3 + Keep-Alive:实习中遇到的 window 滚动问题与实践

Vue3 + Keep-Alive:实习中遇到的 window 滚动问题与实践

前景:实习项目中的困扰

在实习期间,我参与了公司项目的前端开发,页面主要包括首页(Home)和探索页(Explore)。在项目中,这两个页面都使用 window 作为滚动容器。测试时发现一个问题:

首页和探索页都使用 window 作为滚动容器
↓
它们共享同一个 window.scrollY(全局变量)
↓
用户在探索页滚动到 500px
↓
window.scrollY = 500(全局状态)
↓
切换到首页(首页组件被缓存,状态保留)
↓
但 window.scrollY 仍然是 500(全局共享)
↓
首页显示时,看起来也在 500px 的位置 ❌

这个问题的原因在于:

  • <keep-alive> 只缓存组件实例和 DOM,不管理滚动状态。
  • window.scrollY 是全局浏览器状态,不会随组件缓存自动恢复。
  • 结果就是组件被缓存后,滚动位置被错误共享,导致用户体验不佳。

我的思路:滚动位置管理工具

为了在自己的项目中解决类似问题,我考虑了手动管理滚动位置的方案:

/**
 * 滚动位置管理工具
 * 用于在 keep-alive 缓存页面时,为每个路由独立保存和恢复滚动位置
 */
const scrollPositions = new Map()

export function saveScrollPosition(routePath) {
  const y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop
  scrollPositions.set(routePath, y)
}

export function restoreScrollPosition(routePath, defaultY = 0) {
  const saved = scrollPositions.get(routePath) ?? defaultY
  requestAnimationFrame(() => {
    window.scrollTo(0, saved)
    document.documentElement.scrollTop = saved
    document.body.scrollTop = saved
  })
}

在组件中配合 Vue 生命周期钩子使用:

import { onActivated, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { saveScrollPosition, restoreScrollPosition } from './scrollManager'

export default {
  setup() {
    const route = useRoute()

    // 组件激活时恢复滚动
    onActivated(() => {
      restoreScrollPosition(route.path, 0)
    })

    // 组件离开前保存滚动
    onBeforeUnmount(() => {
      saveScrollPosition(route.path)
    })
  }
}

公司项目的简化处理

在公司项目中,由于页面结构简单,不需要为每个路由保存独立滚动位置,因此我采用了统一重置滚动到顶部的方式:

// 路由切换后重置滚动位置
router.afterEach((to, from) => {
  if (to.path !== from.path) {
    setTimeout(() => {
      window.scrollTo(0, 0)
      document.documentElement.scrollTop = 0
      document.body.scrollTop = 0
    }, 0)
  }
})

这样可以保证:

  • 切换页面时始终从顶部开始。
  • 简单易维护,符合公司项目需求。
  • 避免了 Keep-Alive 缓存滚动穿透的问题。

总结

  1. <keep-alive> 缓存组件实例,但不管理 window 滚动状态,导致全局滚动共享问题。
  2. 自己项目中,可以通过滚动位置管理工具为每个路由独立保存和恢复滚动。
  3. 公司项目中,为简化处理,只需在路由切换后重置滚动到顶部即可。
  4. 总体经验:滚动管理要根据项目复杂度和需求选择方案,既保证用户体验,又保证可维护性。

ElementUI组件出现大量重复样式

情况

image.png

点进去,是一个style标签,里面有六万多行样式 进去使用正则查找,发现有11处一模一样的样式

^.el-textarea__inner \{

image.png

过程

经过简单排查,发现问题在于element-variables.scss这个文件中,我框选的这一条代码。
image.png

但是把它注释掉,样式就没了,因为项目引入样式的方式是scss。
于是乎去查看官方文档,确实没啥问题。

image.png

于是我起了一个新的vue2+element-ui+scss项目,用同样的方式引入。
结果发现,是一样的,也有重复的样式说明这是Element的问题。

image.png

原因

element官方的scss文件中重复定义了样式 比如我引入以下样式 image.png 可以发现有两个重复样式

image.png

解决方法

Element早已停更,假如你不是迫不得已,应该停止使用这个UI库。
以下的所有方法都并不是一种优雅的解决方式,但是他们可以解决当前的问题。
解决方法来自github,但是位于以下文章的引用让我发现这个问题。
[vue.js - ElementUI重复引入样式问题 - 学习前端历程 - SegmentFault 思否] (segmentfault.com/a/119000002…)
令人遗憾的是,这篇文章里的方法根本不起作用。

postcss的cssnano(推荐)

github.com/ElemeFE/ele…
你只需要创建postcss.config.js文件,添加cssnano: {}即可去掉重复的样式。

// postcss.config.js
module.exports = {
  plugins: {
    autoprefixer: {},
    cssnano: {}
  },
};

编译出css避开问题(不推荐)

假如我要新加一个scss变量呢?
不推荐这种削足适履的方式

我没有尝试这种方式,但这种方式在原理上是可行的,因为他完全避开了问题,当使用css文件时,就不会编译,自然也就不会引发重复样式的问题。

github.com/ElemeFE/ele…
github.com/ElemeFE/ele…

fast-sass-loader(不推荐)

更换依赖为项目引入了额外的复杂性,所以这并不是推荐的方法

核心在于chainWebpack的配置,代码来自如下链接。
github.com/yibn2008/fa…
忽略下面的注释,这是我之前做的尝试。

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  chainWebpack: (config) => {
    config.module.rules.delete('scss')

    let scssRule = config.module.rule('scss')
      .test(/\.scss$/);

    [
      { name: 'vue-style-loader' },
      { name: 'css-loader' },
      { name: 'postcss-loader' },
      { name: 'fast-sass-loader' }
    ].forEach((load) => {
      scssRule
        .use(load.name)
        .loader(load.loader || load.name)
        .options(load.options || {})
    })
  },
  // configureWebpack: {
  //   module: {
  //     rules: [
  //       {
  //         test: /\.(scss|sass)$/,
  //         use: [
  //           'css-loader',
  //           {
  //             loader: 'fast-sass-loader',
  //             options: {
  //               // includePaths: [... ]
  //             }
  //           }
  //         ]
  //       },
  //       // other loaders ...
  //     ]
  //   }
  // }
})

fast-sass-loader解决了这个问题,但是官方并没有给出vue-cli中的合理使用方式。
我找了很久如何在vue中使用这个东西。
当我直接修改vue中的webpack配置,卸载了sass-loader,完全没有作用。
包括github issue中有部分人也尝试使用这个工具,他们的配置也失败了,说明这不是个例。
image.png

  • 不支持~@别名
Syntax Error: Error: import file cannot be resolved: "@import "~@/assets/styles/mixin.scss";"
  • 4年未更新,基本可以认为弃坑

image.png

image.png

  • 不支持source Map

image.png

总结

如果可以,我真不想用vue2和element。

全栈项目:宠物用品购物系统及后台管理

基于Vue3和Node.js的宠物用品购物系统设计与实现

一、项目描述

随着互联网技术的快速发展和宠物经济的持续升温,宠物用品电商平台已成为宠物主人购买宠物用品的主要渠道。设计并实现了一个基于Vue3和Node.js的全栈宠物用品购物系统,该系统采用前后端分离架构,包含用户购物系统和后台管理系统两个子系统。

系统前端采用Vue 3框架,结合TypeScript、Pinia状态管理、Vue Router路由管理和Element UI Plus组件库,实现了响应式的用户界面和流畅的交互体验。后端采用Node.js和Express框架,使用MongoDB作为数据库,通过JWT实现用户身份认证,构建了RESTful风格的API接口。系统实现了用户注册登录、商品浏览搜索、购物车管理、订单处理、社交互动、后台管理等核心功能。

1. 项目截图

2. 技术栈

前端

  • Vue 3 + TypeScript
  • Vue Router 4 (路由管理)
  • Pinia (状态管理)
  • Element UI Plus (UI组件库)
  • Axios (HTTP请求)

后端

  • Node.js + Express (服务器框架)
  • MongoDB + Mongoose (数据库)
  • JWT (身份验证)
  • Multer (文件上传)
  • Bcryptjs (密码加密)

二、项目启动

前置要求

  • Node.js >= 16
  • pnpm >= 8
  • MongoDB >= 5.0

1.安装依赖

# 安装根目录依赖
pnpm install

2. 启动 MongoDB

确保 MongoDB 服务已启动并运行在 localhost:27017

3. 导入测试数据

pnpm run import

这将自动导入:

  • ✅ 4个测试用户(1个管理员 + 3个普通用户)
  • ✅ 完整的商品分类体系
  • ✅ 10个示例商品
  • ✅ 用户地址数据
  • ✅ 订单数据
  • ✅ 社交帖子数据

4. 启动开发服务器

pnpm run dev

启动后访问:

三、项目总体设计

1. 系统架构设计

1.1 架构模式选择

本系统采用前后端分离的架构模式,具有以下优势:

1. 职责分离

  • 前端专注于用户界面和交互体验
  • 后端专注于业务逻辑和数据处理
  • 前后端可以独立开发、测试、部署

2. 技术独立

  • 前端可以选择最适合的框架和技术
  • 后端可以选择最适合的语言和框架
  • 技术栈升级互不影响

3. 团队协作

  • 前端团队和后端团队可以并行开发
  • 通过API接口约定进行协作
  • 提高开发效率

4. 可扩展性

  • 前端和后端可以独立扩展
  • 支持多端应用(Web、移动端、小程序)
  • 便于向微服务架构演进
1.2 系统架构图
┌─────────────────────────────────────────────────────────┐
│                      客户端层                             │
│  ┌──────────────┐         ┌──────────────┐              │
│  │  用户系统     │         │  管理系统     │              │
│  │  (Vue 3)     │         │  (Vue 3)     │              │
│  └──────────────┘         └──────────────┘              │
└─────────────────────────────────────────────────────────┘
                          │
                          │ HTTP/HTTPS
                          │ RESTful API
                          ▼
┌─────────────────────────────────────────────────────────┐
│                      服务端层                             │
│  ┌──────────────────────────────────────────────────┐   │
│  │              Express 应用服务器                    │   │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐       │   │
│  │  │ 路由层    │  │ 中间件层  │  │ 控制器层  │       │   │
│  │  └──────────┘  └──────────┘  └──────────┘       │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘
                          │
                          │ Mongoose ODM
                          ▼
┌─────────────────────────────────────────────────────────┐
│                      数据层                               │
│  ┌──────────────────────────────────────────────────┐   │
│  │              MongoDB 数据库                        │   │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐       │   │
│  │  │ 用户集合  │  │ 商品集合  │  │ 订单集合  │       │   │
│  │  └──────────┘  └──────────┘  └──────────┘       │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘
1.3技术架构

前端架构

用户系统 / 管理系统
├── Vue 3 (核心框架)
├── TypeScript (类型系统)
├── Pinia (状态管理)
├── Vue Router (路由管理)
├── Element UI Plus (UI组件库)
├── Axios (HTTP客户端)
└── Vite (构建工具)

后端架构

API服务器
├── Node.js (运行环境)
├── Express (Web框架)
├── MongoDB (数据库)
├── Mongoose (ODM)
├── JWT (身份认证)
├── Bcrypt (密码加密)
└── Multer (文件上传)

2. 系统功能模块设计

用户购物系统功能模块

用户购物系统
├── 用户管理模块
│   ├── 用户注册
│   ├── 用户登录
│   ├── 个人信息管理
│   └── 收货地址管理
├── 商品展示模块
│   ├── 首页展示
│   ├── 商品列表
│   ├── 商品详情
│   └── 商品搜索
├── 购物功能模块
│   ├── 购物车管理
│   ├── 订单创建
│   ├── 订单查询
│   └── 订单评价
└── 社交功能模块
    ├── 动态发布
    ├── 动态浏览
    ├── 点赞评论
    └── 用户关注

后台管理系统功能模块

后台管理系统
├── 系统概览模块
│   ├── 数据统计
│   ├── 销售图表
│   └── 订单统计
├── 商品管理模块
│   ├── 商品列表
│   ├── 商品编辑
│   ├── 分类管理
│   └── 库存管理
├── 订单管理模块
│   ├── 订单列表
│   ├── 订单详情
│   ├── 发货处理
│   └── 退款处理
├── 用户管理模块
│   ├── 用户列表
│   ├── 用户详情
│   └── 用户状态管理
└── 数据统计模块
    ├── 销售统计
    ├── 商品排行
    └── 用户分析

3. 数据库设计

系统主要包含以下实体:

  1. 用户(User) :存储用户基本信息和统计数据
  2. 商品(Product) :存储商品信息、价格、库存等
  3. 订单(Order) :存储订单详情、支付信息、物流状态
  4. 动态(Post) :存储用户发布的社交动态
  5. 评论(Comment) :存储动态评论信息
  6. 地址(Address) :存储用户收货地址

实体关系:

  • 一个用户可以有多个订单(1:N)
  • 一个订单包含多个商品(N:M)
  • 一个用户可以发布多个动态(1:N)
  • 一个动态可以有多个评论(1:N)
  • 一个用户可以有多个收货地址(1:N)

四、用户认证模块设计

1. 功能流程图

用户注册流程:
用户填写信息 → 前端验证 → 发送注册请求 → 后端验证 → 密码加密 → 
存入数据库 → 生成 Token → 返回用户信息和 Token → 前端存储 Token → 
自动登录 → 跳转首页

用户登录流程:
用户输入账号密码 → 前端验证 → 发送登录请求 → 后端查询用户 → 
验证密码 → 生成 Token → 返回用户信息和 Token → 前端存储 Token → 
跳转首页

核心技术点:

  1. 密码加密(bcrypt)

bcrypt 是一种专门为密码存储设计的哈希算法,具有以下特点:

  • 加盐(Salt) :自动生成随机盐值,防止彩虹表攻击
  • 慢速哈希:计算速度慢,增加暴力破解难度
  • 自适应:可调整计算复杂度,应对硬件性能提升
// 密码加密实现
import bcrypt from 'bcryptjs';

// 注册时加密密码
const hashPassword = async (password) => {
  // 生成盐值,10 是成本因子(cost factor)
  // 成本因子越高,计算越慢,安全性越高
  const salt = await bcrypt.genSalt(10);
  
  // 使用盐值加密密码
  const hashedPassword = await bcrypt.hash(password, salt);
  
  return hashedPassword;
};

// 登录时验证密码
const verifyPassword = async (inputPassword, storedPassword) => {
  // bcrypt.compare 会自动提取盐值进行比较
  const isMatch = await bcrypt.compare(inputPassword, storedPassword);
  
  return isMatch;
};

为什么不使用 MD5 或 SHA?

  • MD5 和 SHA 是快速哈希算法,容易被暴力破解
  • 没有内置盐值机制,需要手动实现
  • bcrypt 专为密码设计,更安全

2. JWT 身份认证

JWT(JSON Web Token)是一种无状态的身份认证方案,特别适合前后端分离架构。

JWT 结构:

JWT = Header.Payload.Signature

Header(头部):
{
  "alg": "HS256",  // 签名算法
  "typ": "JWT"     // Token 类型
}

Payload(载荷):
{
  "userId": "64f8a1b2c3d4e5f6a7b8c9d0",
  "username": "testuser",
  "role": "user",
  "iat": 1704067200,  // 签发时间
  "exp": 1704672000   // 过期时间
}

Signature(签名):
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

JWT 工作流程:

1. 用户登录成功
   ↓
2. 服务器生成 JWT Token
   - 将用户信息编码到 Payload
   - 使用密钥签名,防止篡改
   ↓
3. 返回 Token 给客户端
   ↓
4. 客户端存储 Token(localStorage 或 sessionStorage)
   ↓
5. 后续请求携带 Token
   - 在 HTTP Header 中添加:Authorization: Bearer <token>6. 服务器验证 Token
   - 验证签名是否有效
   - 检查是否过期
   - 提取用户信息
   ↓
7. 处理业务逻辑

JWT 实现代码:

import jwt from 'jsonwebtoken';

// 密钥(生产环境应使用环境变量)
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

// 生成 Token
const generateToken = (user) => {
  const payload = {
    userId: user._id,
    username: user.username,
    role: user.role
  };
  
  // 签发 Token,设置 7 天过期
  const token = jwt.sign(payload, JWT_SECRET, {
    expiresIn: '7d'
  });
  
  return token;
};

// 验证 Token 中间件
const authenticateToken = async (req, res, next) => {
  try {
    // 从请求头获取 Token
    const authHeader = req.headers.authorization;
    const token = authHeader && authHeader.split(' ')[1]; // Bearer <token>
    
    if (!token) {
      return res.status(401).json({
        success: false,
        message: '访问令牌缺失'
      });
    }
    
    // 验证 Token
    const decoded = jwt.verify(token, JWT_SECRET);
    
    // 查询用户是否存在且状态正常
    const user = await User.findById(decoded.userId);
    if (!user || user.status !== 'active') {
      return res.status(401).json({
        success: false,
        message: '用户不存在或已被禁用'
      });
    }
    
    // 将用户信息附加到请求对象
    req.user = {
      userId: decoded.userId,
      username: decoded.username,
      role: decoded.role
    };
    
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        success: false,
        message: '令牌已过期,请重新登录'
      });
    }
    
    return res.status(401).json({
      success: false,
      message: '无效的访问令牌'
    });
  }
};

JWT vs Session 对比:

特性 JWT Session
存储位置 客户端 服务器
扩展性 好(无状态) 差(需要共享 Session)
性能 好(无需查询) 一般(需要查询 Session)
安全性 一般(Token 泄露风险) 好(服务器控制)
适用场景 前后端分离、微服务 传统 Web 应用

JWT 安全注意事项:

  1. 不要在 Payload 中存储敏感信息(密码、信用卡号等)
  2. 使用 HTTPS 传输,防止 Token 被窃取
  3. 设置合理的过期时间
  4. 实现 Token 刷新机制
  5. 考虑使用 Refresh Token 提升安全性

el-button源码解读4——props color和native-type

  <component
    :is="tag"
    ref="_ref"
    v-bind="_props"
    :class="buttonKls"
    :style="buttonStyle"
    @click="handleClick"
  >

:style="buttonStyle":用于在设置了 color 时,自动计算并应用按钮各状态(默认、悬停、激活、禁用)的颜色样式,无需手动设置每个状态的颜色。

const buttonStyle = useButtonCustomStyle(props)
/**
 * 获取实例中props为name的值
 */
export const useProp = <T>(name: string): ComputedRef<T | undefined> => {
  const vm = getCurrentInstance()
  return computed(() => (vm?.proxy?.$props as any)?.[name])
}


/**
 * 获取表单的disabled状态
 * @param fallback 默认值
 * @returns 表单的disabled状态
 */
export const useFormDisabled = (fallback?: MaybeRef<boolean | undefined>) => {
  const disabled = useProp<boolean>('disabled')
  const form = inject(formContextKey, undefined)
  // 如果是表单内部的button那么是有值的,如果是外部的button那么是undefined
  console.log('form', form)
  /**
   * 组件自身的 disabled prop 
      ↓ (如果没有)
      传入的 fallback 参数
      ↓ (如果没有)
      表单的 disabled 状态
      ↓ (如果没有)
      默认值 false
   */
  return computed(
    () => disabled.value || unref(fallback) || form?.disabled || false
  )
}

/**
 * 获取按钮自定义样式
 * @param props 
 * @returns 
 */
export function useButtonCustomStyle(props: ButtonProps) {
  // 获取按钮的disabled状态
  const _disabled = useFormDisabled()
  // 获取按钮的命名空间
  const ns = useNamespace('button')

  // calculate hover & active color by custom color
  // only work when custom color
  return computed(() => {
    let styles: Record<string, string> = {}

    let buttonColor = props.color

    if (buttonColor) {
      // 检测buttonColor是否为CSS变量格式 ,并提取变量名 如 var(--el-color-primary)
      const match = (buttonColor as string).match(/var\((.*?)\)/)
      if (match) {
        buttonColor = window
          .getComputedStyle(window.document.documentElement)
          .getPropertyValue(match[1])
      }
      // TinyColor: Fast, small color manipulation and conversion for JavaScript
      const color = new TinyColor(buttonColor)
      console.log('color', color)
      // tint - 变亮(添加白色)变亮20%
      // darken - 变暗(添加黑色)变暗20%
      const activeBgColor = props.dark
        ? color.tint(20).toString()
        : darken(color, 20)

      if (props.plain) {
        styles = ns.cssVarBlock({
          'bg-color': props.dark
            ? darken(color, 90)
            : color.tint(90).toString(),
          'text-color': buttonColor,
          'border-color': props.dark
            ? darken(color, 50)
            : color.tint(50).toString(),
          'hover-text-color': `var(${ns.cssVarName('color-white')})`,
          'hover-bg-color': buttonColor,
          'hover-border-color': buttonColor,
          'active-bg-color': activeBgColor,
          'active-text-color': `var(${ns.cssVarName('color-white')})`,
          'active-border-color': activeBgColor,
        })

        if (_disabled.value) {
          styles[ns.cssVarBlockName('disabled-bg-color')] = props.dark
            ? darken(color, 90)
            : color.tint(90).toString()
          styles[ns.cssVarBlockName('disabled-text-color')] = props.dark
            ? darken(color, 50)
            : color.tint(50).toString()
          styles[ns.cssVarBlockName('disabled-border-color')] = props.dark
            ? darken(color, 80)
            : color.tint(80).toString()
        }
      } else {
        const hoverBgColor = props.dark
          ? darken(color, 30)
          : color.tint(30).toString()
        const textColor = color.isDark()
          ? `var(${ns.cssVarName('color-white')})`
          : `var(${ns.cssVarName('color-black')})`
        styles = ns.cssVarBlock({
          'bg-color': buttonColor,
          'text-color': textColor,
          'border-color': buttonColor,
          'hover-bg-color': hoverBgColor,
          'hover-text-color': textColor,
          'hover-border-color': hoverBgColor,
          'active-bg-color': activeBgColor,
          'active-border-color': activeBgColor,
        })

        if (_disabled.value) {
          const disabledButtonColor = props.dark
            ? darken(color, 50)
            : color.tint(50).toString()
          styles[ns.cssVarBlockName('disabled-bg-color')] = disabledButtonColor
          styles[ns.cssVarBlockName('disabled-text-color')] = props.dark
            ? 'rgba(255, 255, 255, 0.5)'
            : `var(${ns.cssVarName('color-white')})`
          styles[ns.cssVarBlockName('disabled-border-color')] =
            disabledButtonColor
        }
      }
    }

    return styles
  })
}


==========================================

props:native-type
export const buttonNativeTypes = ['button', 'submit', 'reset'] as const

props:
  /**
   * @description native button type
   */
  nativeType: {
    type: String,
    values: buttonNativeTypes,
    default: 'button',
  },  

Vue Router 组件内路由钩子全解析

一、什么是组件内路由钩子?

在 Vue Router 中,组件内路由钩子(也称为导航守卫)是在路由变化时自动调用的特殊函数,它们允许我们在特定时机执行自定义逻辑,比如:

  • • 权限验证(是否登录)
  • • 数据预加载
  • • 页面离开确认
  • • 滚动行为控制
  • • 动画过渡处理
// 一个简单的示例
export default {
  name'UserProfile',
  beforeRouteEnter(to, from, next) {
    console.log('组件还未创建,但即将进入...')
    next()
  }
}

二、三大核心钩子函数详解

Vue Router 提供了三个主要的组件内路由钩子,它们组成了一个完整的导航生命周期:

1. beforeRouteEnter - 进入前的守卫

调用时机:在组件实例被创建之前调用,此时组件还未初始化。

特点

  • • 不能访问 this(因为组件实例还未创建)
  • • 可以通过回调函数访问组件实例
export default {
  beforeRouteEnter(to, from, next) {
    // ❌ 这里不能使用 this
    console.log('from'from.path// 可以访问来源路由
    
    // ✅ 通过 next 的回调访问组件实例
    next(vm => {
      console.log('组件实例:', vm)
      vm.loadData(to.params.id)
    })
  },
  
  methods: {
    loadData(id) {
      // 加载数据逻辑
    }
  }
}

适用场景

  • • 基于路由参数的权限验证
  • • 预加载必要数据
  • • 重定向到其他页面

2. beforeRouteUpdate - 路由更新守卫

调用时机:在当前路由改变,但组件被复用时调用。

常见情况

  • • 从 /user/1 导航到 /user/2
  • • 查询参数改变:/search?q=vue → /search?q=react
export default {
  data() {
    return {
      usernull
    }
  },
  
  beforeRouteUpdate(to, from, next) {
    // ✅ 可以访问 this
    console.log('路由参数变化:'from.params.id'→', to.params.id)
    
    // 重新加载数据
    this.fetchUserData(to.params.id)
    
    // 必须调用 next()
    next()
  },
  
  methods: {
    async fetchUserData(id) {
      const response = await fetch(`/api/users/${id}`)
      this.user = await response.json()
    }
  }
}

实用技巧:使用这个钩子可以避免重复渲染,提升性能。

3. beforeRouteLeave - 离开前的守卫

调用时机:在离开当前路由时调用。

重要特性

  • • 可以阻止导航
  • • 常用于保存草稿或确认离开
export default {
  data() {
    return {
      hasUnsavedChangesfalse,
      formData: {
        title'',
        content''
      }
    }
  },
  
  beforeRouteLeave(to, from, next) {
    if (this.hasUnsavedChanges) {
      const answer = window.confirm(
        '您有未保存的更改,确定要离开吗?'
      )
      
      if (answer) {
        next() // 允许离开
      } else {
        next(false// 取消导航
      }
    } else {
      next() // 直接离开
    }
  },
  
  methods: {
    onInput() {
      this.hasUnsavedChanges = true
    },
    
    save() {
      // 保存逻辑
      this.hasUnsavedChanges = false
    }
  }
}

三、完整导航流程图

让我们通过一个完整的流程图来理解这些钩子的执行顺序:

是

否

是

next

next false

beforeRouteEnter 特殊处理
无法访问 this通过 next 回调访问实例开始导航组件是否复用?调用 beforeRouteUpdate调用 beforeRouteEnter组件内部处理确认导航 next创建组件实例执行 beforeRouteEnter 的回调渲染组件用户停留页面用户触发新导航?调用 beforeRouteLeave允许离开?执行新导航停留在当前页面

四、实际项目中的应用案例

案例1:用户权限验证系统

// UserProfile.vue
export default {
  beforeRouteEnter(to, from, next) {
    // 检查用户是否登录
    const isAuthenticated = checkAuth()
    
    if (!isAuthenticated) {
      // 未登录,重定向到登录页
      next({
        path: '/login',
        query: { redirect: to.fullPath }
      })
    } else if (!hasPermission(to.params.id)) {
      // 没有权限,重定向到403页面
      next('/403')
    } else {
      // 允许访问
      next()
    }
  },
  
  beforeRouteLeave(to, from, next) {
    // 如果是管理员,记录操作日志
    if (this.user.role === 'admin') {
      logAdminAccess(from.fullPath, to.fullPath)
    }
    next()
  }
}

案例2:电商商品详情页优化

// ProductDetail.vue
export default {
  data() {
    return {
      productnull,
      relatedProducts: []
    }
  },
  
  beforeRouteEnter(to, from, next) {
    // 预加载商品基础信息
    preloadProduct(to.params.id)
      .then(product => {
        next(vm => {
          vm.product = product
          // 同时开始加载相关商品
          vm.loadRelatedProducts(product.category)
        })
      })
      .catch(() => {
        next('/404'// 商品不存在
      })
  },
  
  beforeRouteUpdate(to, from, next) {
    // 商品ID变化时,平滑过渡
    this.showLoading = true
    this.fetchProductData(to.params.id)
      .then(() => {
        this.showLoading = false
        next()
      })
      .catch(() => {
        next(false// 保持当前商品
      })
  },
  
  methods: {
    async fetchProductData(id) {
      const [product, related] = await Promise.all([
        api.getProduct(id),
        api.getRelatedProducts(id)
      ])
      this.product = product
      this.relatedProducts = related
    },
    
    loadRelatedProducts(category) {
      // 异步加载相关商品
    }
  }
}

五、高级技巧与最佳实践

1. 组合式API中的使用

import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

export default {
  setup() {
    const unsavedChanges = ref(false)
    
    // 使用组合式API守卫
    onBeforeRouteLeave((to, from) => {
      if (unsavedChanges.value) {
        return confirm('确定要离开吗?')
      }
    })
    
    onBeforeRouteUpdate(async (to, from) => {
      // 处理路由参数更新
      await loadData(to.params.id)
    })
    
    return { unsavedChanges }
  }
}

2. 异步操作的优雅处理

export default {
  beforeRouteEnter(tofromnext) {
    // 使用async/await
    const enterGuard = async () => {
      try {
        const isValid = await validateToken(to.query.token)
        if (isValid) {
          next()
        } else {
          next('/invalid-token')
        }
      } catch (error) {
        next('/error')
      }
    }
    
    enterGuard()
  }
}

3. 避免常见的坑

坑1:忘记调用 next()

// ❌ 错误示例 - 会导致导航挂起
beforeRouteEnter(to, from, next) {
  if (checkAuth()) {
    // 忘记调用 next()
  }
}

// ✅ 正确示例
beforeRouteEnter(to, from, next) {
  if (checkAuth()) {
    next()
  } else {
    next('/login')
  }
}

坑2:beforeRouteEnter 中直接修改数据

// ❌ 错误示例
beforeRouteEnter(to, from, next) {
  next(vm => {
    // 避免直接修改响应式数据
    vm.someData = 'value' // 可能导致响应式问题
  })
}

// ✅ 正确示例
beforeRouteEnter(to, from, next) {
  next(vm => {
    vm.$nextTick(() => {
      vm.someData = 'value' // 在下一个tick中修改
    })
  })
}

六、与其他导航守卫的配合

组件内守卫还可以与全局守卫、路由独享守卫配合使用:

// 全局前置守卫
router.beforeEach((to, from, next) => {
  console.log('全局守卫 → 组件守卫')
  next()
})

// 路由配置中的独享守卫
const routes = [
  {
    path'/user/:id',
    componentUserProfile,
    beforeEnter(to, from, next) => {
      console.log('路由独享守卫 → 组件守卫')
      next()
    }
  }
]

执行顺序

    1. 导航被触发
    1. 调用全局 beforeEach
    1. 调用路由配置中的 beforeEnter
    1. 调用组件内的 beforeRouteEnter
    1. 导航被确认
    1. 调用全局的 afterEach

七、性能优化建议

1. 懒加载守卫逻辑

export default {
  beforeRouteEnter(to, from, next) {
    // 按需加载验证模块
    import('@/utils/auth').then(module => {
      if (module.checkPermission(to.meta.requiredRole)) {
        next()
      } else {
        next('/forbidden')
      }
    })
  }
}

2. 缓存验证结果

let authCache = null

export default {
  beforeRouteEnter(to, from, next) {
    if (authCache === null) {
      // 首次验证
      checkAuth().then(result => {
        authCache = result
        handleNavigation(result, next)
      })
    } else {
      // 使用缓存结果
      handleNavigation(authCache, next)
    }
  }
}

总结

Vue Router 的组件内路由钩子为我们提供了强大的导航控制能力。通过合理使用这三个钩子函数,我们可以:

  1. 1. beforeRouteEnter:在组件创建前进行权限验证和数据预加载
  2. 2. beforeRouteUpdate:优化动态参数页面的用户体验
  3. 3. beforeRouteLeave:防止用户意外丢失未保存的数据

记住这些钩子的调用时机和限制,结合实际的业务需求,你就能构建出更加健壮、用户友好的单页应用。

全栈项目:闲置二手交易系统(二)

四、系统架构图

1. 系统架构图

┌─────────────────────────────────────────────────────────┐
│                      用户浏览器                          │
│                    (Vue 3 + Vite)                       │
└────────────────┬────────────────────────────────────────┘
                 │
                 │ HTTP/WebSocket
                 │
┌────────────────▼────────────────────────────────────────┐
│                    Nginx (可选)                          │
│                   反向代理/负载均衡                       │
└────────────────┬────────────────────────────────────────┘
                 │
        ┌────────┴────────┐
        │                 │
┌───────▼──────┐  ┌──────▼────────┐
│  前端服务     │  │   后端服务     │
│  (Port 3000) │  │  (Port 5000)  │
│              │  │   Express     │
└──────────────┘  └───────┬───────┘
                          │
                  ┌───────┼───────┐
                  │       │       │
          ┌───────▼──┐ ┌──▼────┐ ┌▼────────┐
          │ MongoDB  │ │Socket │ │ 文件存储 │
          │ 数据库   │ │  IO   │ │ /uploads│
          └──────────┘ └───────┘ └─────────┘

2. 前端架构

目录结构

frontend/
├── src/
│   ├── components/          # 可复用组件
│   │   ├── admin/          # 管理后台组件
│   │   ├── AppNavbar.vue   # 导航栏
│   │   ├── ChatBox.vue     # 聊天框
│   │   ├── ChatList.vue    # 聊天列表
│   │   ├── ProductCard.vue # 商品卡片
│   │   └── ...
│   ├── views/              # 页面组件
│   │   ├── Home.vue        # 首页
│   │   ├── Login.vue       # 登录页
│   │   ├── Products.vue    # 商品列表
│   │   ├── ProductDetail.vue # 商品详情
│   │   ├── Chat.vue        # 聊天页
│   │   ├── Admin.vue       # 管理后台
│   │   └── ...
│   ├── stores/             # 状态管理
│   │   ├── user.ts         # 用户状态
│   │   └── product.ts      # 商品状态
│   ├── router/             # 路由配置
│   │   └── index.ts
│   ├── utils/              # 工具函数
│   │   ├── api.ts          # API封装
│   │   ├── validation.ts   # 表单验证
│   │   └── dateUtils.ts    # 日期工具
│   ├── types/              # TypeScript类型
│   │   └── index.ts
│   ├── test/               # 测试文件
│   ├── App.vue             # 根组件
│   └── main.ts             # 入口文件
├── public/                 # 静态资源
├── package.json
└── vite.config.ts

组件设计原则

  1. 单一职责:每个组件只负责一个功能
  2. 可复用性:通用组件抽离到components目录
  3. Props验证:使用TypeScript进行类型约束
  4. 事件命名:使用kebab-case命名自定义事件
  5. 样式隔离:使用scoped样式

Vue 3 核心特性深度解析

Composition API 的设计理念:

Vue 3 引入 Composition API 是为了解决 Options API 在大型项目中的几个痛点:

  1. 逻辑复用困难 - Options API 中相关逻辑分散在不同选项中
  2. 类型推导不友好 - TypeScript 支持不够完善
  3. 代码组织混乱 - 大组件中相关代码被迫分离
// 使用 <script setup> 语法 - 这是 Vue 3.2+ 的语法糖
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'

// 1. 响应式数据 - ref 用于基本类型
// ref 会返回一个响应式的引用对象,通过 .value 访问值
const count = ref(0)

// 2. 计算属性 - 自动追踪依赖,缓存结果
// 只有当依赖的响应式数据变化时才会重新计算
const doubleCount = computed(() => count.value * 2)

// 3. 生命周期钩子 - 在 setup 中直接调用
// 相比 Options API,名称前加了 'on' 前缀
onMounted(() => {
  console.log('组件已挂载')
  // 这里可以进行 DOM 操作、发起 API 请求等
})
</script>

响应式系统深入理解:

Vue 3 使用 Proxy 实现响应式,相比 Vue 2 的 Object.defineProperty 有以下优势:

  • 可以监听数组索引和长度变化
  • 可以监听对象属性的添加和删除
  • 性能更好,不需要递归遍历所有属性
// ref() - 用于基本类型的响应式
// 原理:将值包装在一个对象中,通过 .value 访问
const count = ref(0)
count.value++ // 触发响应式更新

// reactive() - 用于对象的响应式
// 原理:使用 Proxy 代理整个对象
const state = reactive({
  user: { name: 'John', age: 25 },
  products: []
})
state.user.name = 'Jane' // 直接修改,自动触发更新

// computed() - 计算属性
// 特点:1. 惰性求值 2. 缓存结果 3. 自动依赖追踪
const fullName = computed(() => {
  console.log('计算执行') // 只在依赖变化时执行
  return `${state.user.name} (${state.user.age})`
})

// watch() - 侦听器,用于执行副作用
// 可以侦听单个或多个响应式数据源
watch(count, (newVal, oldVal) => {
  console.log(`count 从 ${oldVal} 变为 ${newVal}`)
  // 可以在这里执行异步操作、API 调用等
})

// watchEffect() - 自动追踪依赖的侦听器
// 立即执行,自动收集依赖
watchEffect(() => {
  console.log(`当前 count: ${count.value}`)
  // 任何在这里使用的响应式数据变化都会触发重新执行
})

3. 后端架构

目录结构

backend/
├── models/                 # 数据模型
│   ├── User.js            # 用户模型
│   ├── Product.js         # 商品模型
│   ├── Order.js           # 订单模型
│   └── Message.js         # 消息模型
├── routes/                # 路由处理
│   ├── auth.js            # 认证路由
│   ├── products.js        # 商品路由
│   ├── orders.js          # 订单路由
│   ├── messages.js        # 消息路由
│   ├── users.js           # 用户路由
│   └── admin.js           # 管理员路由
├── middleware/            # 中间件
│   ├── auth.js            # 认证中间件
│   ├── admin.js           # 管理员中间件
│   └── upload.js          # 文件上传中间件
├── socket/                # Socket.IO处理
│   └── socketHandler.js   # Socket事件处理
├── utils/                 # 工具函数
│   └── helpers.js
├── scripts/               # 脚本文件
│   ├── init-admin.js      # 初始化管理员
│   └── import-data.js     # 导入测试数据
├── uploads/               # 文件上传目录
├── server.js              # 服务器入口
├── .env                   # 环境变量
└── package.json

后端技术知识点

Express 框架

基础路由:

const express = require('express')
const app = express()

// 中间件
app.use(express.json())
app.use(express.urlencoded({ extended: true }))

// 路由
app.get('/api/products', async (req, res) => {
  try {
    const products = await Product.find()
    res.json({ success: true, data: products })
  } catch (error) {
    res.status(500).json({ success: false, message: error.message })
  }
})

中间件系统:

// 日志中间件
app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`)
  next()
})

// 错误处理中间件
app.use((err, req, res, next) => {
  console.error(err.stack)
  res.status(500).json({ message: '服务器错误' })
})

4. 数据库设计

数据模型关系图

┌─────────────┐         ┌─────────────┐
│    User     │◄───────►│   Product   │
│             │ 1     * │             │
│  - _id      │         │  - _id      │
│  - username │         │  - title    │
│  - password │         │  - price    │
│  - email    │         │  - seller   │
│  - avatar   │         │  - status   │
│  - role     │         └─────────────┘
│  - followers│                │
│  - following│                │ *
│  - favorites│                │
└─────────────┘                │
       │ 1                     │
       │                       │
       │ *                     │ 1
┌─────────────┐         ┌─────────────┐
│   Message   │         │    Order    │
│             │         │             │
│  - _id      │         │  - _id      │
│  - sender   │         │  - buyer    │
│  - receiver │         │  - seller   │
│  - content  │         │  - product  │
│  - isRead   │         │  - status   │
└─────────────┘         │  - amount   │
                        └─────────────┘

为什么选择 MongoDB:

MongoDB 是一个 NoSQL 文档数据库,特别适合本项目的原因:

  1. 灵活的数据模型 - 文档结构可以随需求变化,不需要预定义严格的表结构
  2. 嵌套文档支持 - 可以直接存储复杂的嵌套数据(如商品评论、用户关注列表)
  3. 水平扩展 - 支持分片,易于扩展
  4. JSON 格式 - 与 JavaScript 天然契合
  5. 高性能 - 对于读多写少的场景性能优秀

Mongoose Schema 设计原理:

Mongoose 是 MongoDB 的 ODM(Object Document Mapping),提供了数据建模、验证、查询构建等功能。

五、 快速启动指南 🚀

前置要求

  • Node.js >= 16.0.0
  • pnpm >= 8.0.0
  • MongoDB(需要启动服务)

三步启动项目

第一步:安装依赖

pnpm install

第二步:导入测试数据

pnpm run import

输出示例:

✅ MongoDB连接成功
✅ 数据库已清空
✅ 创建了 5 个用户
✅ 创建了 15 个商品
✅ 创建了 5 个订单
✅ 创建了 7 条消息

✅ 数据导入完成!
📊 数据统计:
   - 用户: 5
   - 商品: 15
   - 订单: 5
   - 消息: 7

💡 测试账号:
   管理员: admin / admin123
   普通用户: 张三 / 123456
   普通用户: 李四 / 123456

第三步:启动开发服务器

pnpm run dev

这会同时启动:

❌