简介这是一套面向前端开发者与Vue初学者的实战型点菜应用源码专为家庭场景定制解决情侣/夫妻间私房菜点单、口味偏好记录与厨房协作效率低等实际问题兼具趣味性与实用性。资源共290个文件压缩包仅1.07MB包含85个Vue组件实现菜单展示、订单管理、口味筛选等核心功能、85个JSON配置文件支撑菜品数据、用户偏好及多端适配、47个Markdown文档详述设计思路、部署说明与使用指南、36个JavaScript逻辑脚本含uni-app跨端兼容处理、14个SCSS样式文件模块化主题定制及PNG图标等静态资源目录中可见uni_modules、store状态管理、goodSubPackage分包结构体现工程化开发规范。已有354人学习下载可直接运行调试完整掌握Vue组件化开发、uni-app多端构建、Pinia状态管理及SCSS样式组织等关键技术实践路径。1. 项目背景与需求拆解1.1 为什么做这个项目这个项目的起因特别简单每天下班回到家打开冰箱我俩对着里面一堆食材面面相觑谁也不知道该做什么。外卖翻来覆去就那几家做饭又总想不出新花样。相信很多家庭都有这种经历——日子过久了吃饭成了一件既日常又头疼的事。其实市面上的菜谱App不少下厨房、豆果美食、小红书收藏夹但那些都是面向所有人的通用产品菜谱多到挑花眼反而加剧了选择困难。而且这些App有个共同的问题它们是给一个人用的不是给一个家用的。所以我想了很久与其在别人的App里迷路不如自己写一个真正属于我们俩的点菜神器。于是就有了这个项目——private_kitchen一个基于Vue 3开发的私人厨房点菜应用。它不追求菜谱数量只收录我们俩爱吃、常做、拿得出手的菜它不搞社交不搞推荐只解决一件事今天吃什么。这个项目从需求阶段就是围绕家庭场景设计的核心用户就两个人但做出来的东西却可以成为一个完整的前端实战案例。我从技术选型到代码结构都刻意按生产级标准来写这套代码放在GitHub上可以直接跑也可以作为Vue 3进阶练习的参考项目。1.2 核心功能需求梳理在动工之前我认真梳理了我们家的日常使用场景把功能需求分成了四个优先级这也是我建议所有个人项目动手前的第一步——先别急着写代码把需求掰开揉碎。第一优先级是菜谱管理。得有个地方把常做的菜都存起来包括菜名、食材清单、做法步骤、烹饪时长、难度等级、菜品分类荤菜/素菜/汤/主食/甜品、图片。这个模块本质上是标准的信息管理涉及增删改查是任何管理系统的基础功。第二优先级是智能点菜。这是整个项目的灵魂。模式一叫随机点菜点一次按钮系统从全部菜谱里随机出一道菜解决选择困难模式二叫分类点菜按食材或菜系筛选后随机选比如今晚只吃鱼、只吃辣、只吃半小时内能搞定的菜模式三叫组合配餐一次随机出一荤一素一汤直接给出一顿饭的菜单方案。第三优先级是购物清单联动。点完菜之后自动根据选中的菜品把食材清单汇总生成一份购物清单。这个功能极大提升了实际使用频率因为很多人在点完菜后还要想冰箱里缺什么这个功能直接把这个步骤也省了。第四优先级是今日菜单和历史记录。选完菜后生成今天的菜单卡片记录每天吃了什么。过段时间回看能发现翻来覆去就是那几道菜提醒我们该开发新菜了。这些功能听起来简单但真正实现起来涉及状态管理、路由设计、本地持久化、组件通信等一整套Vue开发链路麻雀虽小五脏俱全很适合用来打通Vue项目的整体思维。2. 技术选型与项目架构设计2.1 Vue 3 Vite Pinia 的组合选型技术上我选的组合是Vue 3.2 Vite 4 Vue Router 4 Pinia 2 Element Plus Sass。这套组合在2024年的前端圈已经算是最成熟主流的方案了。为什么选Vue 3而不是Vue 2这个没什么好纠结的。Vue 3的组合式APIComposition API让代码复用和逻辑组织的方式产生了质变。比如点菜逻辑里随机算法、筛选条件、历史记录这三块逻辑在选项式API里会散落在不同生命周期钩子里但在组合式API里可以按业务维度封装成独立的函数或者组合式函数composable阅读和调试体验完全不同。Vue 3的响应式系统重写为Proxy之后性能更好也解决了Vue 2里新增属性不触发更新的历史问题。Vite 4就不多说了启动速度秒杀Webpack开发体验完全是另一个时代。尤其做这种个人项目改一行代码热更新瞬间生效效率提升非常明显。如果你还在用Webpack配Vue项目我强烈建议你试试Vite你会发现前端开发原来可以这么爽。Pinia是Vue官方推荐的下一代状态管理方案比Vuex简单太多。没有了mutations那一层直接改state就行TypeScript支持也是原生级别的。对于这个项目菜谱数据、当天菜单、购物清单都需要跨组件共享Pinia刚好是最合适的落点。Element Plus负责UI层表格、表单、弹窗、消息提示这些通用组件直接拿来用省去大量手写基础组件的时间。要注意的是Element Plus默认是按需引入需要在vite.config里配置unplugin-auto-import和unplugin-vue-components不然打包体积会大很多。2.2 项目目录结构与模块划分目录结构我按业务模块做了划分这个结构是做了几个项目之后总结出的比较顺手的方式private_kitchen/ ├── src/ │ ├── api/ # 接口请求层预留 │ ├── assets/ # 静态资源图片、公共样式 │ ├── components/ # 公共组件 │ │ ├── RecipeCard.vue # 菜谱卡片 │ │ ├── RecipeModal.vue # 菜谱详情弹窗 │ │ ├── DishPicker.vue # 点菜转盘/随机展示 │ │ └── ShoppingList.vue # 购物清单弹窗 │ ├── composables/ # 组合式函数 │ │ ├── useRandomPick.js # 随机算法 │ │ └── useStorage.js # 本地存储封装 │ ├── router/ # 路由配置 │ ├── stores/ # Pinia状态管理 │ │ ├── recipe.js # 菜谱模块 │ │ ├── menu.js # 今日菜单模块 │ │ └── cart.js # 购物清单模块 │ ├── views/ # 页面组件 │ │ ├── HomeView.vue # 首页/点菜页面 │ │ ├── RecipeListView.vue # 菜谱列表 │ │ ├── RecipeEditView.vue # 菜谱编辑 │ │ └── MenuDetailView.vue # 菜单详情 │ ├── App.vue │ └── main.js ├── vite.config.js └── package.json这个结构的核心思路是components放通用组件、views放页面级组件、composables放可复用的逻辑函数、stores放全局状态。很多Vue新手容易犯的错误是把所有东西都堆在组件里一个SFC几千行改起来提心吊胆。合理的组件拆分和逻辑抽离是Vue项目能持续维护的根基。3. 核心功能模块设计与实现3.1 菜谱数据模型与表单设计菜谱的字段设计直接决定了整个应用的功能边界。我定义的数据模型是这样的// 菜谱数据模型 { id: uuid, // 唯一标识 name: 红烧排骨, // 菜名 category: meat, // 分类meat/veggie/soup/staple/dessert difficulty: 2, // 难度系数 1-5 cookTime: 40, // 烹饪时长分钟 ingredients: [ { name: 排骨, amount: 500g }, { name: 冰糖, amount: 20g }, { name: 生抽, amount: 2勺 } ], steps: [排骨焯水, 炒糖色, 下排骨翻炒, 加水焖煮40分钟, 收汁装盘], image: data:image/jpeg;base64,..., // 图片数据 tags: [红烧, 下饭, 周末], createdAt: 1690000000000, updatedAt: 1690000000000 }食材清单我用了数组而不是字符串这一点很重要。因为要做购物清单汇总就必须结构化存储。如果你把食材写成排骨500g、冰糖20g、生抽2勺这种纯文本字符串后面做联动提取的时候就得写正则去匹配非常痛苦。表单设计这块我用了一个动态表单来处理食材和步骤这两组重复项。Element Plus的el-form配合v-for循环每一项都可以动态增删。这里有个细节需要给每个动态条目设置独立的key最好用Date.now() index这种组合方式避免删除中间项时出现key冲突。图片上传我用了FileReaderbase64的方案前端直接压缩后转成base64存在localStorage里。考虑到localStorage只有5MB的空间限制图片上传前做了canvas压缩长边限制在800px质量0.7。这个方案对个人项目够用了不需要搭后端服务。之前测试过这个压缩策略下每张图大约50-150KB个人应用存上百道菜也没压力。3.2 随机点菜算法的三种模式随机点菜是这个项目的灵魂功能算法看着简单但实现时有一些细节值得细说。先看最简单的随机抽一道菜模式。从全部菜谱里随机抽一个就行function randomPickOne(recipes) { if (!recipes.length) return null const index Math.floor(Math.random() * recipes.length) return recipes[index] }这行代码本身没什么技术含量但实际场景中用户的需求往往更复杂。今天想吃快一点的、不想吃肉了、来个汤这些需求对应的就是带条件的随机。所以我把点菜模式分成了三种模式一是随机一道就是上面那个函数。模式二是条件随机——先按分类、标签、最大烹饪时长筛选出候选菜品再从候选中随机抽function filteredRandomPick(recipes, filters) { const candidates recipes.filter(recipe { // 分类筛选 if (filters.category recipe.category ! filters.category) return false // 最大烹饪时长筛选 if (filters.maxTime recipe.cookTime filters.maxTime) return false // 标签筛选 if (filters.tags filters.tags.length) { if (!filters.tags.every(tag recipe.tags.includes(tag))) return false } return true }) if (!candidates.length) return null const index Math.floor(Math.random() * candidates.length) return candidates[index] }模式三是组合配餐随机出一荤一素一汤加个主食直接生成一顿饭的方案。这个的实现技巧是按分类分别随机然后组合成一个菜单对象function generateComboMenu(recipes) { const meat filteredRandomPick(recipes, { category: meat }) const veggie filteredRandomPick(recipes, { category: veggie }) const soup filteredRandomPick(recipes, { category: soup }) const staple filteredRandomPick(recipes, { category: staple }) return { meat, veggie, soup, staple } }这里需要注意一个边界当某一分类下没有任何菜品时对应的字段会是null前端展示时要做好空态判断。我最初没做这个判断结果在菜谱很少的时候点菜直接给老婆大人展示了四个undefined场面一度很尴尬。另外我还加了再来一次的交互逻辑。点一次不满意点第二次换个结果跟摇骰子一样。核心就是Math.random()每次都会重新执行所以每次点击都会得到不同的结果。这个逻辑天然满足选择困难症的需求——不是要一次选对而是让你有掌控感。3.3 购物清单自动汇总实现点菜完成后自动生成购物清单这个功能是实用性的关键。实现逻辑很简单遍历今日菜单里每个菜品的ingredients数组按食材名称做聚合数量累加。这里有个数据结构上的小坑食材的单位不一致的话没法直接累加。比如一道菜写排骨500g另一道菜写排骨300g可以合并成800g。但如果一个写生抽2勺、另一个写生抽适量就没法合并了。我的解决方案是在数据结构里增加一个unit字段单位相同才合并单位不同就分开显示在清单里function generateShoppingList(menuRecipes) { const list [] const map {} menuRecipes.forEach(recipe { recipe.ingredients.forEach(ing { const key ${ing.name}_${ing.unit} if (map[key]) { // 数字型数量累加非数字型(如适量)直接拼接 const oldAmount parseFloat(map[key].amount) const newAmount parseFloat(ing.amount) if (!isNaN(oldAmount) !isNaN(newAmount)) { map[key].amount ${oldAmount newAmount}${ing.unit} } else { map[key].amount ${map[key].amount} ${ing.amount}${ing.unit} } } else { map[key] { name: ing.name, amount: ${ing.amount}${ing.unit}, unit: ing.unit } } }) }) return Object.values(map) }这个函数用了一个对象做临时map来分组key是食材名单位。这个模式非常常用在JavaScript里做分组统计时比用数组的indexOf或findIndex逐个查找要高效很多。购物清单生成后每一项前面放一个checkbox可以手动勾选已购买的食材。勾选状态存在Pinia里这样在页面切换后勾选状态不会丢失。3.4 本地持久化方案localStorage封装因为不打算做后端所以数据持久化选了localStorage方案。但我没有直接在组件里调用localStorage API而是封装了一个带命名空间和JSON序列化的storage工具// composables/useStorage.js const PREFIX private_kitchen_ export function useStorage() { const get (key) { const raw localStorage.getItem(PREFIX key) try { return raw ? JSON.parse(raw) : null } catch (e) { console.error(解析存储数据失败: ${key}, e) return null } } const set (key, value) { localStorage.setItem(PREFIX key, JSON.stringify(value)) } const remove (key) { localStorage.removeItem(PREFIX key) } return { get, set, remove } }做这个封装的几个好处一是统一的命名空间前缀避免和其他项目的localStorage数据冲突二是所有数据都是JSON序列化存取自动做了转换三是将来如果切换到后端API只需要改这个文件组件层完全不用动。同时我在Pinia的store里做了watch监听状态一变就自动写localStorage这样页面刷新后数据不丢// stores/recipe.js export const useRecipeStore defineStore(recipe, () { const recipes ref([]) // 初始化时从本地存储读取 const storage useStorage() const savedRecipes storage.get(recipes) if (savedRecipes) { recipes.value savedRecipes } // 监听变化自动持久化 watch(recipes, (val) { storage.set(recipes, val) }, { deep: true }) // 添加菜谱 const addRecipe (recipe) { recipe.id Date.now().toString(36) Math.random().toString(36).slice(2) recipe.createdAt Date.now() recipe.updatedAt Date.now() recipes.value.push(recipe) } // 更新菜谱 const updateRecipe (id, data) { const index recipes.value.findIndex(r r.id id) if (index ! -1) { recipes.value[index] { ...recipes.value[index], ...data, updatedAt: Date.now() } } } // 删除菜谱 const removeRecipe (id) { recipes.value recipes.value.filter(r r.id ! id) } return { recipes, addRecipe, updateRecipe, removeRecipe } })用组合式写法定义Pinia store整体逻辑看起来就像一个自定义hook没有Vuex那些getters/mutations/actions的条条框框用起来舒服很多。watch监听来触发持久化省去了在每个操作函数里手动调storage的麻烦。4. 路由设计与页面交互流程4.1 Vue Router 4路由配置这个项目虽然功能不复杂但还是分了四个页面所以要用到Vue Router。路由配置很简单用createWebHistory模式做的单页应用// router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: home, component: () import(/views/HomeView.vue), meta: { title: 今天吃什么 } }, { path: /recipes, name: recipe-list, component: () import(/views/RecipeListView.vue), meta: { title: 我的菜谱 } }, { path: /recipes/new, name: recipe-new, component: () import(/views/RecipeEditView.vue), meta: { title: 添加菜谱 } }, { path: /recipes/:id/edit, name: recipe-edit, component: () import(/views/RecipeEditView.vue), meta: { title: 编辑菜谱 } }, { path: /menu, name: menu-detail, component: () import(/views/MenuDetailView.vue), meta: { title: 今日菜单 } } ] const router createRouter({ history: createWebHistory(), routes }) // 路由切换时更新页面标题 router.afterEach((to) { document.title to.meta.title ? ${to.meta.title} - private_kitchen : private_kitchen }) export default router这里有两个细节值得说说。第一是使用了路由懒加载。页面级组件全部用动态import这样首屏只加载HomeView其他页面按需加载。Vite打包时会自动做代码分割每个页面生成独立的chunk。对于移动端访问的场景这个优化很关键。第二是路由切换后的标题同步。单页应用如果不手动设置document.title浏览器标签页始终显示index.html里的固定标题。通过afterEach钩子每次路由切换后把页面对应的标题更新到浏览器标签上这个细节虽然小但体验提升很明显。4.2 keep-alive与el-table滚动位置恢复这是我实际开发中踩过的一个大坑也是很多Vue开发者会遇到的问题。场景是这样的菜谱列表页用el-table展示所有菜品菜谱多了以后需要滚动才能看到底部的菜。用户向下滚动到第十道菜点击编辑进入编辑页改完内容返回列表页时el-table的滚动位置回到了顶部用户不得不重新往下滚。这个问题根源是Vue Router默认在组件销毁时组件内部的DOM状态包括滚动位置会一并丢失。所以需要keep-alive把列表页的组件实例缓存起来!-- App.vue -- template router-view v-slot{ Component } keep-alive component :isComponent / /keep-alive /router-view /template加了keep-alive之后从列表页跳走时组件不会销毁状态保留返回时滚动位置自然就还在。但是问题来了——keep-alive会缓存所有路由的组件这意味着编辑页也不会销毁下次进入编辑页时会看到上一次编辑的残留内容。解决方法有两个。一是通过keep-alive的include/exclude属性精确控制哪些组件需要缓存keep-alive :exclude[RecipeEditView] component :isComponent / /keep-alive二是用路由meta配置配合v-if控制。我选的是方案一简单直接。再补充一个Vue 3组合式API下配合keep-alive的生命周期钩子onActivated和onDeactivated。如果需要在从缓存中恢复时执行某些操作比如重新拉取数据用onActivated替代onMounted。注意在keep-alive缓存下onMounted只在第一次创建时执行之后都用onActivated。5. 页面效果与交互体验优化5.1 首页点菜交互设计首页是这个产品的门面也是我花心思最多的地方。整体布局分三个区域顶部是今日日期和问候语中间是点菜结果展示区底部是三个操作按钮。点菜结果的展示我参考了转盘动画的思路做了个简易的卡片翻转效果。点击随机点菜按钮后结果卡片会先快速切换几个菜名制造一种滚动感然后定格在最终结果上。这其实是个很简单的setInterval换内容定时停止的效果// 抽菜动画逻辑 function startPickAnimation(candidates) { let count 0 const maxCount 10 const timer setInterval(() { const randomIndex Math.floor(Math.random() * candidates.length) currentDisplay.value candidates[randomIndex] count if (count maxCount) { clearInterval(timer) // 最终定格 currentDisplay.value candidates[Math.floor(Math.random() * candidates.length)] resultVisible.value true } }, 80) }这个动画看起来像滚轮彩票实际上就是80毫秒切换一次内容切10次后定格。代码很简单但交互上的仪式感很强老婆大人第一次用的时候觉得很有意思。做完点菜结果定格的逻辑之后我加了个不满意换一道按钮这个按钮本质就是重新执行一次随机算法。这样做一方面是交互自然另一方面也避免了一次点击结果不满意后用户得重新走一遍流程的挫败感。5.2 移动端适配与手势优化虽然这个项目是给家用电脑用的但实际使用场景经常是做饭时用手机看菜谱所以我从开发一开始就做了移动端适配。布局上用的是全屏Flex布局rpx参考方案被我舍弃了因为Vue项目里移动端适配更适合用vw/vh单位配合rem。我配置了postcss-px-to-viewport插件开发时直接写px打包时自动转成vw// vite.config.js import postcssPxToViewport from postcss-px-to-viewport export default { css: { postcss: { plugins: [ postcssPxToViewport({ viewportWidth: 375, // 设计稿宽度按iPhone尺寸 unitPrecision: 5, viewportUnit: vw, selectorBlackList: [.ignore], minPixelValue: 1, mediaQuery: false }) ] } } }这个插件的效果是你写width: 375px他会自动转成width: 100vw375/375×100100。这样在不同屏幕宽度的手机上都按比例缩放比手写rem系换算方便很多。移动端的另一个优化点是大按钮的点击区域。点菜按钮我设成了56px高这个尺寸是参考了苹果人机交互指南里的最小可点击区域建议。如果按PC端习惯只做32px手机上手指很容易点偏。6. 常见问题与排查技巧实录6.1 Vue 3版本兼容性问题开发过程中遇到的最典型问题是Element Plus版本和Vue版本不匹配导致组件不渲染。早期版本Element Plus 2.x只适配Vue 3.2.x如果你用了Vue 3.4的版本部分组件会出现控制台警告甚至白屏。排查思路很简单第一步看控制台报错——Vue警告信息通常会明确指出是版本问题还是使用方式问题第二步比对package.json里vue和element-plus的版本号第三步去Element Plus官网看当前版本的适配要求。这类问题最好的解决方法是创建项目时直接用官方脚手架把版本交给工具去处理。Vue官方推荐的npm create vuelatest会自动帮你装好匹配的版本组合比自己手动安装稳妥很多。如果你像我早期一样习惯手动npm install记得先装vue再装element-plus安装顺序和依赖解析会有细微差别。6.2 菜谱图片base64存储超限这是我踩过最实在的一个坑。localStorage的单域名存储上限大约是5MB一开始我把原图base64直接存进去才存了十二道菜就提示QuotaExceededError错误。排查过程是这样的首先我在控制台实测localStorage的剩余空间发现已经满了。然后我把存储的key全部列出来发现image字段占的空间最大。最后定位到问题是base64编码会让体积膨胀约33%再加上原图可能有一两MB吃空间特别快。解决方法就是做canvas压缩。把图片读入canvas等比例缩小到长边800px再用canvas.toDataURL(image/jpeg, 0.7)输出压缩后的base64。压缩后每张图片大约在50KB到150KB之间200道菜也占不到5MB。这个技巧不仅适用于localStorage也适用于上传到服务端前的图片预处理。前端压缩能减少上传流量和服务端带宽消耗是Web开发基本功。6.3 组合式函数重复调用导致的状态污染这个坑有点隐蔽。我在首页和菜单详情页都用到了useStorage这个组合式函数一开始没注意在同一个页面重复调用时发现第二个实例读到的数据和第一个是同一份——但明明是两个不同的key。排查后发现问题出在模块级作用域。我把storage的key定义在了useStorage函数外面// 错误示范 const storage { recipes: recipes, menu: menu, cart: cart } export function useStorage(key) { return localStorage.getItem(storage[key]) }这看起来没毛病但如果我在一个组件里同时用两个key或者模块被不同组件分别引入所有实例共享同一个模块级对象就可能出现状态交叉。更稳妥的做法是让key作为参数传入或者直接导出一个创建新实例的工厂函数// 正确做法 export function useStorage(key) { const prefix private_kitchen_ const fullKey prefix key function getStorage() { const raw localStorage.getItem(fullKey) try { return raw ? JSON.parse(raw) : null } catch(e) { return null } } function setStorage(value) { localStorage.setItem(fullKey, JSON.stringify(value)) } return { get: getStorage, set: setStorage } }这其实体现了组合式函数的编程习惯一个组合式函数应该像一个独立的实例工厂每次调用都返回独立的功能而不是依赖外部共享状态。写出可复用的组合式函数这个思维必须贯穿始终。6.4 随机算法看起来不随机的问题用户反馈说随机点菜经常连续点到同一道菜怀疑是不是随机算法有问题。我简化了一下问题Math.random()本身是均匀分布的伪随机数在菜谱数量少比如只有五道菜时连续两次抽到同一道菜的概率是1/5这在数学上很正常。但人的直觉会觉得每次都来这菜是不是坏了。解决方法是给随机算法加一个去重机制——记录上一次的结果如果当前结果和上一次相同就重新抽一次。更进阶的做法是洗牌式随机把所有候选菜谱打乱顺序然后依次取出取完一轮再重新洗牌。这样既保证了随机性又避免了重复// 洗牌算法Fisher-Yates function shuffle(arr) { const result [...arr] for (let i result.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)) ;[result[i], result[j]] [result[j], result[i]] } return result }Fisher-Yates洗牌算法是生成均匀随机排列的经典算法原理是倒序遍历数组每轮把当前元素和它之前包括自己的随机一个元素交换位置。这个算法常见于抽奖系统、答题系统、音乐播放器随机播放等场景值得背下来。7. 项目扩展方向与后续优化建议做完这个项目之后我一直在思考它还能往哪些方向扩展。有几个方向我觉得很值得做也符合实际的使用场景。第一个方向是PWA化。用vite-plugin-pwa插件把应用变成可安装的PWA应用手机上能加到桌面离线也能看菜谱。这个改造对个人工具类项目非常合适成本低、体验提升明显。做法是装插件后在vite.config.js里加一行配置生成manifest和service worker再把图标资源备好就行。第二个方向是接入真实后端。目前数据存在localStorage换设备数据就断了。可以做一个轻量后端写几个简单的REST API前端用axios对接。后端技术栈可以用Node.js Express也可以试试Python FastAPI个人项目选自己加班少的技术就好。第三个方向是增加纪念日推荐功能。给菜谱打上纪念日标签到特定日期的时候自动推荐适合的菜。这个功能的情感价值大于实用价值但老婆大人应该会喜欢。技术上只需要在筛选逻辑里加一个日期判断。第四个方向是导入导出菜谱数据。把菜谱数据导出成JSON文件再从JSON文件导入。这样换手机或者重新部署的时候数据不会丢失。实现也很简单用Blob生成下载链接导入时用FileReader读取文件再解析。最后说说我个人对这类项目的体会。做工具类产品尤其是给身边人做的东西最大的收获不是技术能力提升而是对需求的理解。我一开始按自己的想法做了一堆功能老婆根本不用后来认真观察她做饭的习惯才发现她最需要的是分类点菜和购物清单这两个功能。做技术的人容易掉进炫技的陷阱但用户真正在意的永远是用得顺手。这个项目从立项到完成用了大概两周的业余时间。如果你也在学Vue我建议你找一个自己真正需要的场景来做项目——给女朋友做饭的、给自己记账的、帮家里管绿植的都可以。带着真实需求写代码你能学到的东西比刷一百遍教程都多。整个项目源码我已整理发布需要的可以直接对照本文的实现思路来研究遇到问题欢迎交流。本文还有配套的精品资源点击获取