十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3实战案例合集:从响应式原理到状态管理的最佳实践

Vue3实战案例合集:从响应式原理到状态管理的最佳实践 简介这套基于Vue3的案例合集围绕搜索历史记录、购物车、TodoList、回到顶部、图书管理等五类常见业务场景展开适合正在学习Vue3或准备用其开发实际项目的开发者参考。资源共450个文件、压缩包仅3.17MB其中以121个vue组件、106个js脚本和59个json配置为主另有少量css、html及工程化配置文件代码结构清晰便于按模块阅读理解目前已有837人学习/下载。案例中可以看到Vuex对历史记录与购物车状态的统一管理、Composition API在TodoList中的复用逻辑、以及通过自定义指令封装回到顶部按钮的实现思路图书管理模块还展示了组件拆分、表单处理与异步接口联调方法。压缩包内还包含24个md说明文档和工程脚手架配置可直接在常见Vue CLI项目环境中参考运行节省搭建时间。这些内容既适合初学者模仿练习也能帮助有经验者快速梳理Vue3的典型用法。1. 这个案例合集其实是一套 Vue3 的最佳实践模板拿 Vue3 写案例的人很多但大部分都停在能跑的层面to do list 只有增删改查购物车只放两个按钮搜索历史压根没做持久化。真正到面试或者接手项目的时候才发现这些基础案例背后全是 Vue3 的核心机制——ref 和 reactive 到底怎么选、computed 的依赖收集什么时候失效、组件通信用 props 还是 inject、跨页面状态到底放 Pinia 还是 localStorage。这套案例大全的聪明之处是把搜索历史记录、购物车、todolist、回到顶部、图书管理这几个看似独立的小需求串成了一条完整的 Vue3 能力链。你照着敲一遍等于把 setup 语法糖、组合式函数、路由传参、状态管理、自定义指令全部过了一遍。这篇文章不打算逐个贴完整代码而是按案例背后的核心机制来拆。先搞清楚每个案例在训练什么能力再给出最小可运行的核心实现最后落到参数怎么调、坑在哪。适合两类人刚学完 Vue3 基础但不知道怎么写项目的人以及写过一段时间但总是靠复制粘贴、遇到响应式丢失就懵的开发者。不管是应对面试题还是真做后台管理系统这套案例都是最稳的起点。2. TodoList 不只是增删改查它把 setup 语法糖和响应式原理绑在了一起2.1 为什么 todoList 案例最能检验 Vue3 基本功很多人写 todoList 还在用 Options API 的 data、methods 那一套这其实没吃到 Vue3 的核心红利。Vue3 的 Composition API 之所以叫组合式是因为它允许你按逻辑关注点组织代码而不是按选项类型分散。一个待办列表的完整逻辑包括列表数据的响应式定义、新增和删除的处理函数、剩余未完成数量的计算、以及过滤条件的切换。用 setup 语法糖写出来这些逻辑可以集中在一个代码块里还能顺手抽出组合式函数复用。另外一个关键点是响应式原理。todoList 虽小却是考验 ref 和 reactive 选择的最佳场景。列表用 reactive 数组还是用 ref 包一层数组得到的操作方式完全不同。很多人在这里踩坑用 reactive 包数组然后整个替换数组时发现视图不更新。这不是 Vue3 的 bug而是 reactive 的代理机制决定的——数组整体赋值会丢失代理。这个坑在图书管理案例里会再次出现先在这里理解透后面就顺畅了。2.2 最小可运行的 todoList 核心代码下面这个实现覆盖了新增、删除、切换完成状态、过滤、持久化五个基本能力代码可以直接粘贴到 Vite 项目里跑。template div classtodo-wrap input v-modelnewTodo keyup.enteraddTodo placeholder输入待办事项 / ul li v-fortodo in filteredTodos :keytodo.id input typecheckbox v-modeltodo.done / span :class{ done: todo.done }{{ todo.text }}/span button clickremoveTodo(todo.id)删除/button /li /ul p剩余 {{ remainingCount }} 项/p div button clickfilter all全部/button button clickfilter active未完成/button button clickfilter done已完成/button /div /div /template script setup import { ref, reactive, computed, watch } from vue const newTodo ref() // 用 reactive 包数组操作 item 的属性天然具备响应性 const todos reactive(JSON.parse(localStorage.getItem(todos) || [])) const filter ref(all) // 生成唯一 id时间戳 随机数避免重复 const genId () Date.now().toString(36) Math.random().toString(36).slice(2, 6) function addTodo() { const text newTodo.value.trim() if (!text) return todos.push({ id: genId(), text, done: false }) newTodo.value } function removeTodo(id) { const index todos.findIndex(t t.id id) if (index ! -1) todos.splice(index, 1) } const filteredTodos computed(() { switch (filter.value) { case active: return todos.filter(t !t.done) case done: return todos.filter(t t.done) default: return todos } }) const remainingCount computed(() todos.filter(t !t.done).length) // 深度监听 todos 的变化自动写回 localStorage watch(todos, () { localStorage.setItem(todos, JSON.stringify(todos)) }, { deep: true }) /script注意几个关键点。v-model绑定 checkbox 时直接操作todo.done这是 reactive 数组元素的属性修改会被代理捕获并触发更新。delete按钮用splice而不是直接赋值为null这保证数组在代理下依然正确触发变更。filteredTodos是典型的多条件 computed 用法切换 filter 的值不用写任何事件分发逻辑computed 自动重算。最后的watch加deep: true监听数组内部变化这是 todoList 持久化的标准做法。这段时间你会发现一个经常被忽略的细节用reactive包数组时todos.push(...)这样的操作没问题但如果整个数组重新赋值比如从接口拿新列表就必然触发响应式丢失。图书管理案例里我会给出正确的替换方案。2.3 必调参数deep、flush 与 ref 数组的取舍watch的deep参数在监听嵌套对象时必须为true否则修改todo.done不会触发回调。但深度监听的开销会随着数组长度增长千条以内的 todoList 没有感知到图书管理那种几百条带字段的对象就需要权衡。另一个参数是flush默认pre表示在组件更新前执行回调改成post则在 DOM 更新后执行。持久化场景用pre没问题如果要在回调里操作新的 DOM 节点就要用post。对列表类数据还有个选择ref([])包数组配合todos.value [...]整体替换或者reactive([])包数组配合splice/push操作。社区里推荐 ref 的声音更多因为赋值方式统一类型推导也友好。我一般小型案例用 reactive 图省事涉及整体替换的场景用 ref。3. 搜索历史记录与购物车localStorage 封装和防抖是成对出现的3.1 搜索历史记录练的是输入 → 存储 → 展示三段式搜索历史记录这个案例放在 todoList 后面非常合适因为存储能力是延续的。但它的复杂度比 todoList 高一层todoList 只需要把整个数组存进去搜索历史则要处理去重、时间排序、单条删除、清空这四个额外动作。而且搜索框还有防抖这个天然需求——用户连续输入时不能每敲一个字符就写一次 localStorage。这个案例最值得学的是把存储逻辑抽成组合式函数。不要在每个页面直接写localStorage.getItem和setItem抽一个useLocalStorage出来在搜索页面和购物车页面复用。真正的项目里这就是自定义 hook 的雏形面试聊起来也有深度。3.2 可复用的 localStorage 组合式函数直接看代码这个函数可以同时服务搜索历史和购物车两个案例// src/composables/useLocalStorage.js import { ref, watch } from vue // key存储键名defaultValue初始值options序列化配置 export function useLocalStorage(key, defaultValue, options {}) { const { serialize JSON.stringify, deserialize JSON.parse } options // 读取时做容错拿到非法 JSON 直接回退默认值 let initial defaultValue try { const raw localStorage.getItem(key) if (raw ! null) { initial deserialize(raw) } } catch (e) { console.warn([useLocalStorage] 解析 ${key} 失败使用默认值, e) } const data ref(initial) watch(data, (val) { try { localStorage.setItem(key, serialize(val)) } catch (e) { console.warn([useLocalStorage] 写入 ${key} 失败, e) } }, { deep: true }) return data }调用方式很直接const searchHistory useLocalStorage(searchHistory, [])之后对这个 ref 做.push、.splice操作会自动写入 localStorage。如果哪天要换 sessionStorage只需要把存储介质传进去。这样写的好处是页面里看不到任何localStorage.getItem字样View 层只关心数据流。3.3 搜索历史的数据处理逻辑搜索历史的完整逻辑分为三步输入时按回车或者点搜索按钮 → 去重并插入到数组头部 → 渲染历史列表。核心处理函数function addHistory(keyword) { const text keyword.trim() if (!text) return // 已存在的先移除保证最新记录在头部 const existIndex historyData.value.findIndex(item item text) if (existIndex ! -1) { historyData.value.splice(existIndex, 1) } historyData.value.unshift(text) // 限制最多存 10 条避免 localStorage 无限膨胀 if (historyData.value.length 10) { historyData.value historyData.value.slice(0, 10) } } function removeHistory(index) { historyData.value.splice(index, 1) } function clearHistory() { historyData.value [] }注意最后一行clearHistory这里historyData是 ref 包的对象整体赋值[]是完全安全的。如果你这里用的是 reactive 包数组historyData.value []这个写法本身就不存在而且整体替换数组会直接丢响应性。两相对比ref 的优势就在这里。3.4 防抖函数与搜索请求的配合搜索记录本身不需要防抖但它常和搜索请求绑在一起。防抖的经典实现// src/utils/debounce.js export function debounce(fn, delay 300) { let timer null return function (...args) { if (timer) clearTimeout(timer) timer setTimeout(() { fn.apply(this, args) }, delay) } }在搜索框的watch里配合使用import { watch, reactive } from vue const state reactive({ keyword: , results: [] }) const debouncedSearch debounce(async (kw) { // 这里模拟接口调用实际项目替换为真正的 HTTP 请求 const res await mockSearchApi(kw) state.results res }, 400) watch(() state.keyword, (val) { if (val.trim()) debouncedSearch(val) })这里有一个容易忽略的细节debounce返回的函数在组件卸载时还残留闭包里的timer如果用户在延迟期间离开了页面回调依然会执行。严谨的做法是在onUnmounted里清除定时器。实际项目中搜索请求还需要加上竞态处理——用户先输入vue再输入vue3后者的响应可能比前者更快返回。解决办法是对比请求时的关键字和返回时的当前关键字是否一致不一致就丢弃结果。3.5 购物车案例把商品列表的响应式设计思路购物车的数据结构比 todoList 多一层商品有 sku、价格、数量、选中状态。设计购物车的 state 时最常见的错误是把所有字段平铺成数组。正确做法是商品数据静态保存、数量与选中态用 Map 维护这样改数量和算总价互不干扰。看这个购物车 state 设计import { ref, computed } from vue // 商品列表通常来自接口这里模拟静态数据 const products ref([ { id: 1, name: JavaScript 高级程序设计, price: 99, stock: 10 }, { id: 2, name: Vue3 实战, price: 69, stock: 5 }, { id: 3, name: CSS 揭秘, price: 79, stock: 0 } ]) // 购物车数据数量 选中标志 const cartMap ref({}) function getQuantity(productId) { return cartMap.value[productId]?.quantity || 0 } function addToCart(productId) { const product products.value.find(p p.id productId) if (!product || product.stock 0) return if (cartMap.value[productId]) { cartMap.value[productId].quantity } else { cartMap.value[productId] { quantity: 1, checked: true } } } function toggleChecked(productId) { const item cartMap.value[productId] if (item) item.checked !item.checked } // 总价 所有选中项的数量 × 商品单价 const totalPrice computed(() { let total 0 for (const [id, item] of Object.entries(cartMap.value)) { if (!item.checked) continue const product products.value.find(p p.id Number(id)) if (product) total product.price * item.quantity } return total.toFixed(2) }) // 选中商品数量 const checkedCount computed(() { let count 0 for (const item of Object.values(cartMap.value)) { if (item.checked) count item.quantity } return count })注意cartMap用的是普通对象包在 ref 里新增 key 时通过cartMap.value[productId] ...来触发视图更新。如果整个cartMap重新赋值ref 会正常捕获。这个设计比直接用数组更接近真实商城商品的元数据单独管理购物车只存 id 和数量后期加删除清空失效商品时改动面很小。购物车案例的关键参数在totalPrice的toFixed(2)这会把数字变成字符串后续如果还要做乘法运算就需要注意类型。另一个常见坑是接口返回的price是字符串99.00直接price * quantity没问题但toFixed前最好用Number()包一层避免精度问题。4. 回到顶部与图书管理一个考验组件封装一个考验跨页共享4.1 回到顶部不是window.scrollTo那么简单的回到顶部这个案例看起来最简单实际上考验的是对组件封装粒度的理解。很多人的实现是每个页面写一个按钮然后监听 scroll这显然是重复代码。正确的做法是封装成一个通用组件再考虑做自定义指令的版本。回顶组件要处理的核心问题有三个滚动事件监听与销毁、按钮显隐条件、平滑滚动实现。参考这个组件封装template transition namefade div v-ifvisible classback-top clickscrollToTop span↑/span /div /transition /template script setup import { ref, onMounted, onUnmounted } from vue // props 暴露配置项threshold 是显示的滚动距离阈值 const props defineProps({ threshold: { type: Number, default: 200 }, duration: { type: Number, default: 400 } }) const emit defineEmits([back-to-top]) const visible ref(false) function onScroll() { // 这里用 document.documentElement.scrollTop 是标准模式兼容 window.scrollY const scrollTop document.documentElement.scrollTop || document.body.scrollTop visible.value scrollTop props.threshold } function scrollToTop() { const start document.documentElement.scrollTop || document.body.scrollTop const startTime performance.now() // requestAnimationFrame 实现平滑滚动避免直接 jump const step (currentTime) { const progress Math.min((currentTime - startTime) / props.duration, 1) // easeInOutCubic 缓动前慢后快再慢滚动手感更好 const ease progress 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress 2, 3) / 2 document.documentElement.scrollTop start * (1 - ease) if (progress 1) { requestAnimationFrame(step) } else { emit(back-to-top) } } requestAnimationFrame(step) } onMounted(() { window.addEventListener(scroll, onScroll, { passive: true }) }) onUnmounted(() { window.removeEventListener(scroll, onScroll) }) /scriptthreshold和duration这两个 props 是组件的核心参数前者控制什么时候显示按钮后者控制滚动动画时长。监听器加passive: true是浏览器性能优化——告诉页面这个监听器不会调用preventDefault滚动过程不阻塞主线程。如果不加在低端机器上页面滚动会有肉眼可见的卡顿。这里顺带提一个自定义指令的进阶封装。回顶功能完全可以用一个v-back-top指令挂在任意元素上指令的mounted钩子里绑定点击事件这样就不需要每个页面去引用组件。两种方案对比组件适合全站统一按钮的诉求指令适合局部列表套用的场景比如弹窗内部的滚动容器。弹窗内部滚动时滚动容器不是document而是弹窗的 DOM 元素这也是回到顶部案例里最常被问到的边界条件。4.2 图书管理用 Pinia 串联跨页共享与增删改查图书管理是这个合集里最接近后台管理系统的模块它天然包含列表展示、新增、编辑、删除、搜索筛选这些完整功能。但它的价值不止于此关键在于它用了 Pinia 做跨页共享——从图书列表页到图书详情页再返回数据不能重新拉取这是后台管理系统的典型诉求。先看 store 的定义// src/stores/book.js import { defineStore } from pinia export const useBookStore defineStore(book, { state: () ({ books: [], currentBook: null, loading: false, searchKeyword: }), getters: { // 带搜索过滤的列表依赖 state 里的 searchKeyword filteredBooks: (state) { const kw state.searchKeyword.trim().toLowerCase() if (!kw) return state.books return state.books.filter(book book.title.toLowerCase().includes(kw) || book.author.toLowerCase().includes(kw) ) }, bookCount: (state) state.books.length }, actions: { async fetchBooks() { this.loading true try { // 实际项目替换为 GET 请求 this.books await mockFetchBooks() } finally { this.loading false } }, async addBook(bookData) { // 新增的书加到头部视觉上更容易看到 this.books.unshift({ ...bookData, id: Date.now() }) }, async updateBook(id, bookData) { const index this.books.findIndex(b b.id id) if (index ! -1) { this.books[index] { ...this.books[index], ...bookData } } }, async deleteBook(id) { const index this.books.findIndex(b b.id id) if (index ! -1) { this.books.splice(index, 1) } } } })关键在updateBook的实现。this.books[index] { ...this.books[index], ...bookData }是整体替换数组元素Pinia 的 state 是基于 reactive 的直接替换索引位置不会保证响应式吗答案是 Pinia 在这里做了特殊处理——它默认 state 是 deep reactive对数组索引赋值能被拦截。这是 Pinia 与裸 reactive 的一个不同点正因为这样updateBook才能安全地整体替换元素。图书管理没有采用 localStorage 持久化而是用 Pinia是一个刻意的设计图书列表在真实场景中来自后端接口数据是服务端权威而搜索历史和购物车则是纯客户端状态适合 localStorage。后台管理系统做 Vue3 时基本都遵循这个约定。4.3 组件通信从列表页到编辑页的参数传递图书管理里必然涉及列表页点击编辑跳转到表单页。这里有一个 Vue3 路由传参的常见误区。看下面这两种传递方式// 方式一param 传参路由配置里带 :id router.push({ name: book-edit, params: { id: book.id } }) // 方式二query 传参 router.push({ path: /book-edit, query: { id: book.id } })列表页跳转编辑页用params详情页跳转列表页带上筛选条件用query。如果只是编辑图书id 通过params传编辑页拿到 id 后不要重新调用fetchBooks而是直接通过 store 里的books数组找到对应项回填表单。这样就实现了列表页和编辑页共享同一个 store 实例状态不用二次加载。需要注意的是params传参时刷新页面参数会丢失除非在路由配置里明确写到 path 上编辑页面拿到参数后要做空值判断。这种从页面跳转 状态回填的模式就是 Vue3 后台管理系统里最常用的跨页共享套路。搜索关键字和筛选条件放在 store 里列表页和筛选组件不需要通过 props 逐层传递任何子组件都能读取和修改。5. 按 Vue3 面试题的维度把案例打磨成可验证的细节5.1 响应式丢失这个问题每个案例都有一份答案vue3 面试题里必考一道为什么 reactive 失去响应性。它在这几个案例里都有具体体现——todoList 里 replace 整个数组、购物车里给cartMap直接赋值整个新对象、图书管理的updateBook整体替换元素。区别在于ref 包对象时整体赋值安全reactive 包对象时只有深层属性修改安全。这是因为 ref 的实现是value本身经过reactive处理赋新值时 ref 的 setter 会重新建立代理关系而 reactive 直接返回代理对象对这个对象本身赋值等于让变量指向新对象旧代理自然脱钩。在受控环境中验证这一点很简单。打开图书管理页面的控制台在组件上下文中执行bookStore.books []页面不会变化改成bookStore.books.splice(0)视图立刻被清空。用这个案例去检查自己的 store 设计凡是看到this.xxx someArray这种写法就要思考组件里有没有引用旧数组的地方。5.2 手动验证用 localhost 服务器和 Vue Devtools 确认每个功能的边界直接在本地跑一遍这几个案例可以配一个最小的验证清单。第一步先跑npm create vitelatest vue3-cases -- --template vue初始化工程然后安装pinia和vue-router。第二步按顺序实现todolist 验证 ref、reactive、computed、watch搜索历史验证 useLocalStorage 的容错逻辑手动在 DevTools 的 Application 面板里把 localStorage 的值改成非法 JSON刷新页面后默认值要生效购物车验证总价对checked状态变化的响应回到顶部验证threshold参数改成 50 和 500 时按钮出现时机的差异图书管理验证刷新页面后 store 状态清空但 Pinia 不报错。用 Vue Devtools 的 Timeline 面板观察组件更新时机会比较直观地看到flush: post与flush: pre的差别。在 todoList 的watch回调里加一段访问document.querySelector(.todo-wrap).textContent的代码用pre时拿到的是更新前的节点内容改post后拿到的是更新后的。5.3 把设备像素比和滚动性能一起考虑移动端回顶的补充参数回到顶部在移动端有一个参数容易被忽略——设备像素比。window.scrollY在 Retina 屏幕上可能返回非整数直接赋值document.documentElement.scrollTop时浏览器自己会做像素对齐。如果滚动总跳不到位可以检查 CSS 里是否设置了scroll-behavior: smooth。这个属性会和requestAnimationFrame的动画打架导致滚动出现先慢后卡的观感。更好的做法是只保留 JS 动画CSS 里去掉smooth。相关的写法是html { scroll-behavior: smooth; }当组件通过scrollTop直接赋值时这个属性会导致每次赋值都触发平滑滚动频率高时性能急剧下降这个坑在低端安卓机上特别明显。本文还有配套的精品资源点击获取
返回列表