简介:这套基于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 class="todo-wrap"> <input v-model="newTodo" @keyup.enter="addTodo" placeholder="输入待办事项" /> <ul> <li v-for="todo in filteredTodos" :key="todo.id"> <input type="checkbox" v-model="todo.done" /> <span :class="{ done: todo.done }">{{ todo.text }}</span> <button @click="removeTodo(todo.id)">删除</button> </li> </ul> <p>剩余 {{ remainingCount }} 项</p> <div> <button @click="filter = 'all'">全部</button> <button @click="filter = 'active'">未完成</button> <button @click="filter = '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 name="fade"> <div v-if="visible" class="back-top" @click="scrollToTop"> <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) }) </script>threshold和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 vite@latest 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直接赋值时,这个属性会导致每次赋值都触发平滑滚动,频率高时性能急剧下降,这个坑在低端安卓机上特别明显。
本文还有配套的精品资源,点击获取