普通视图
使用 Vue 3 实现大模型流式输出:从零搭建一个简易对话 Demo
vxe-gantt vue table 甘特图子任务多层级自定义模板用法
从零构建Vue项目的完全指南:手把手打造现代化前端工程
从零构建Vue项目的完全指南:手把手打造现代化前端工程
一、项目构建整体流程图
让我们先看看完整的项目构建流程:
![]()
二、详细构建步骤
步骤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',
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
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 routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@views/Home/Home.vue'),
meta: {
title: '首页',
requiresAuth: true,
},
},
{
path: '/login',
name: 'Login',
component: () => import('@views/Login/Login.vue'),
meta: {
title: '登录',
},
},
{
path: '/user/:id',
name: 'User',
component: () => import('@views/User/User.vue'),
props: true,
},
]
const router = createRouter({
history: createWebHistory(),
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: {
name: string
avatar: string
roles: string[]
} | null
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
userInfo: null,
}),
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 { AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
// 请求拦截器
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<string, string>>({})
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,
isSubmitting: computed(() => 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 lazyLoad: Directive = {
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. 保持一致性 - 无论是命名规范还是代码风格
- 2. 模块化设计 - 功能解耦,便于维护和测试
- 3. 类型安全 - 充分利用TypeScript的优势
- 4. 自动化 - 尽可能自动化重复工作
- 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.parse加try-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.watchEffect 与 watch 的核心区别
| 特性 | 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时,控制台打印
![]()
检查网络,前两次的快速请求都被取消了,只有最后一次成功
![]()
4.总结
onInvalidate 适用于任何有“清理”需求的副作用:
-
取消异步请求:(最常用)
fetch,axios等。 -
清除定时器:清除
setTimeout或setInterval。 -
解绑事件监听器:如果在
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变量符合规定 不然不会生效
![]()
```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,按需替换
![]()
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…
效果
![]()
代码
通过 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>
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/onDeactivated或activated/deactivated。可以在这些钩子中执行恢复或清理操作,例如刷新数据或保存状态。 -
组件匹配:
<keep-alive>默认会缓存所有包裹其中的组件实例。但如果需要精确控制,就会用到include、exclude属性,匹配组件的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>
理论上只缓存 Home、Explore、UserCenter。
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 控制缓存
- 移除手动定义的 Name
<script setup lang="js">
// Vue 会自动根据路径生成 name
</script>
- 在路由配置中设置 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 }
}
]
- 在 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 缓存滚动穿透的问题。
总结
-
<keep-alive>缓存组件实例,但不管理 window 滚动状态,导致全局滚动共享问题。 - 自己项目中,可以通过滚动位置管理工具为每个路由独立保存和恢复滚动。
- 公司项目中,为简化处理,只需在路由切换后重置滚动到顶部即可。
- 总体经验:滚动管理要根据项目复杂度和需求选择方案,既保证用户体验,又保证可维护性。
ElementUI组件出现大量重复样式
情况
![]()
点进去,是一个style标签,里面有六万多行样式 进去使用正则查找,发现有11处一模一样的样式
^.el-textarea__inner \{
![]()
过程
经过简单排查,发现问题在于element-variables.scss这个文件中,我框选的这一条代码。![]()
但是把它注释掉,样式就没了,因为项目引入样式的方式是scss。
于是乎去查看官方文档,确实没啥问题。
![]()
于是我起了一个新的vue2+element-ui+scss项目,用同样的方式引入。
结果发现,是一样的,也有重复的样式说明这是Element的问题。
![]()
原因
element官方的scss文件中重复定义了样式
比如我引入以下样式
可以发现有两个重复样式
![]()
解决方法
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中有部分人也尝试使用这个工具,他们的配置也失败了,说明这不是个例。![]()
- 不支持
~@别名
Syntax Error: Error: import file cannot be resolved: "@import "~@/assets/styles/mixin.scss";"
- 4年未更新,基本可以认为弃坑
![]()
![]()
- 不支持source Map
![]()
总结
如果可以,我真不想用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
启动后访问:
- 👤 用户系统:http://localhost:5173
- 🎛️ 管理系统:http://localhost:5174
- 🔗 后端API:http://localhost:3000
三、项目总体设计
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. 数据库设计
系统主要包含以下实体:
- 用户(User) :存储用户基本信息和统计数据
- 商品(Product) :存储商品信息、价格、库存等
- 订单(Order) :存储订单详情、支付信息、物流状态
- 动态(Post) :存储用户发布的社交动态
- 评论(Comment) :存储动态评论信息
- 地址(Address) :存储用户收货地址
实体关系:
- 一个用户可以有多个订单(1:N)
- 一个订单包含多个商品(N:M)
- 一个用户可以发布多个动态(1:N)
- 一个动态可以有多个评论(1:N)
- 一个用户可以有多个收货地址(1:N)
四、用户认证模块设计
1. 功能流程图
用户注册流程:
用户填写信息 → 前端验证 → 发送注册请求 → 后端验证 → 密码加密 →
存入数据库 → 生成 Token → 返回用户信息和 Token → 前端存储 Token →
自动登录 → 跳转首页
用户登录流程:
用户输入账号密码 → 前端验证 → 发送登录请求 → 后端查询用户 →
验证密码 → 生成 Token → 返回用户信息和 Token → 前端存储 Token →
跳转首页
核心技术点:
- 密码加密(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 安全注意事项:
- 不要在 Payload 中存储敏感信息(密码、信用卡号等)
- 使用 HTTPS 传输,防止 Token 被窃取
- 设置合理的过期时间
- 实现 Token 刷新机制
- 考虑使用 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 {
user: null
}
},
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 {
hasUnsavedChanges: false,
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 {
product: null,
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(to, from, next) {
// 使用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',
component: UserProfile,
beforeEnter: (to, from, next) => {
console.log('路由独享守卫 → 组件守卫')
next()
}
}
]
执行顺序:
-
- 导航被触发
-
- 调用全局
beforeEach
- 调用全局
-
- 调用路由配置中的
beforeEnter
- 调用路由配置中的
-
- 调用组件内的
beforeRouteEnter
- 调用组件内的
-
- 导航被确认
-
- 调用全局的
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. beforeRouteEnter:在组件创建前进行权限验证和数据预加载
- 2. beforeRouteUpdate:优化动态参数页面的用户体验
- 3. beforeRouteLeave:防止用户意外丢失未保存的数据
记住这些钩子的调用时机和限制,结合实际的业务需求,你就能构建出更加健壮、用户友好的单页应用。
如何使用 vxe-gantt table 甘特图来实现多个维度视图展示,支持切换年视图、月视图、周视图等
全栈项目:闲置二手交易系统(二)
四、系统架构图
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
组件设计原则
- 单一职责:每个组件只负责一个功能
- 可复用性:通用组件抽离到components目录
- Props验证:使用TypeScript进行类型约束
- 事件命名:使用kebab-case命名自定义事件
- 样式隔离:使用scoped样式
Vue 3 核心特性深度解析
Composition API 的设计理念:
Vue 3 引入 Composition API 是为了解决 Options API 在大型项目中的几个痛点:
- 逻辑复用困难 - Options API 中相关逻辑分散在不同选项中
- 类型推导不友好 - TypeScript 支持不够完善
- 代码组织混乱 - 大组件中相关代码被迫分离
// 使用 <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 文档数据库,特别适合本项目的原因:
- 灵活的数据模型 - 文档结构可以随需求变化,不需要预定义严格的表结构
- 嵌套文档支持 - 可以直接存储复杂的嵌套数据(如商品评论、用户关注列表)
- 水平扩展 - 支持分片,易于扩展
- JSON 格式 - 与 JavaScript 天然契合
- 高性能 - 对于读多写少的场景性能优秀
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
这会同时启动:
- 🎨 前端开发服务器:http://localhost:3000
- 🔧 后端API服务器:http://localhost:5000