用Vue 3 + Pinia打造家庭专属点菜神器,零后端本地存储
简介这是一套面向前端开发者与Vue初学者的趣味化家居应用实战源码专为打造夫妻间私密点餐互动场景而设计解决家庭厨房中个性化菜单管理、实时点菜反馈与情感化交互需求。资源共290个文件压缩包仅1.07MB轻量易学含85个Vue组件覆盖菜品展示、订单提交、厨房看板等核心模块、85个JSON配置文件支撑菜品数据、状态定义与多端适配、47个Markdown文档详述开发思路、部署说明与使用指南、36个JavaScript逻辑脚本含uni-app兼容层与数据处理逻辑以及SCSS样式、PNG图标等配套资源。已有354人学习下载可直接运行于H5/小程序环境附带uni_modules插件支持、store状态管理目录及分包子目录goodSubPackage结构规范、模块解耦清晰是理解Vue组件化开发、跨端实践与轻量级家居软件设计的优质参考案例。 每天下班回家妻子问的第一句话永远是今天吃什么。一开始我还认真回答后来发现这根本不是一个问答题而是一个哲学题——你说吃什么她大概率摇头你让她说她又说随便。这种循环往复的对话持续了大半年直到有一天我实在扛不住了决定用自己最熟悉的Vue框架写一个老婆专属点菜神器。项目起名private_kitchen直译就是私家厨房。名字很贴切因为这套东西从设计到实现完全围绕一个人的口味习惯来定制没有通用产品的条条框框。断断续续写了一周多总共一千多行代码没有后端纯前端数据全部存在浏览器本地。现在这个神气已经稳定服役两三个月每天晚饭的决策时间从原来的十几分钟压缩到三十秒以内。这篇文章就把整个项目的设计思路、源码结构和关键实现细节完整梳理一遍希望对想用Vue做点家庭小工具的朋友有所帮助。1. 为什么会有一个点菜神器的需求从两难对话到需求分析先说清楚这个项目解决的是什么问题。表面上看是不知道吃什么但真正拆解下来需求其实是三个层次叠加的第一层是没有决策依据脑袋里一片空白说不出任何选项第二层是选择太多平时收藏的菜谱、吃过的外卖、看过的美食视频信息散落在各个地方没有一个统一的入口第三层是决策成本高两个人在一起吃饭除了要考虑自己想吃什么还要考虑对方能不能接受、今天适不适合吃辣、冰箱里还有哪些食材能用。传统的解决方案有很多比如翻菜谱App、看美食博主推荐、打开外卖平台刷一遍。但这些方案的共同痛点是内容太泛。菜谱App里几万道菜翻半个小时也决策不出来因为它的内容体系是为了学做菜设计的而不是为了今天吃什么设计的。外卖平台的排序逻辑是商业化的谁给的钱多谁排前面跟你想吃什么没什么关系。说白了我们需要的不是更多选择而是更小的选择集合。把需求梳理成产品语言其实就是几个功能点有一个可控的菜单库只收录那些两个人真正会吃、喜欢吃的菜支持分类筛选比如荤菜、素菜、汤、主食、快手菜有决策机制能从中随机选出一道菜或者排除掉某些选项后做随机能记录今天吃过的菜下次随机时自动避开防止连续几天吃同样的。这个需求用后端重模型做当然也行但明摆着小题大做了。一个只有两个人用的工具不需要用户系统不需要数据上报不需要并发处理甚至连服务器都不需要。用Vue做单页应用数据放localStorage完全够用。这也是我坚持纯前端、零后端的原因——少一个服务器就少一个维护成本少了潜在的宕机和数据安全问题。对一个家庭内部的小工具来说简单可靠比架构优雅重要得多。技术选型上Vue几乎是这个场景的最优解。Vue的核心优势就是轻量和渐进式一个单页应用加上响应式数据管理不需要引入Redux或者MobX这类状态管理库一个defineStore或者甚至一个reactive对象就能搞定全部状态。组件化开发让菜单卡片、筛选按钮、随机结果这些UI模块可以独立维护后续加功能也不会把代码搅成一锅粥。加上还有官方的Vite脚手架工具从初始化项目到开发完成整个工程链路非常顺滑。2. private_kitchen的源码架构与核心模块拆解整个项目是基于Vue 3 Vite搭建的使用Composition API风格编写原因有两个Composition API在逻辑组织上更自由可以把一个功能的所有状态和动作集中在一个函数里不用像Options API那样强制分散在data、methods、computed等区块中另一个原因是Vue 3的响应式系统基于Proxy实现相比Vue 2的defineProperty对数组、新增属性等操作支持得更完整在处理菜单列表这种频繁增删改查的数据结构时不会踩到响应式丢失的坑。先看整体的目录结构private_kitchen/ ├── index.html ├── package.json ├── vite.config.js ├── public/ │ └── favicon.ico └── src/ ├── main.js ├── App.vue ├── router/ │ └── index.js ├── stores/ │ └── menu.js ├── utils/ │ ├── random.js │ └── storage.js ├── components/ │ ├── MenuCard.vue │ ├── CategoryFilter.vue │ ├── RandomResult.vue │ └── DishForm.vue └── views/ ├── HomeView.vue ├── MenuManageView.vue └── HistoryView.vue这个结构是我在实际开发中反复调整后定下来的不是一上来就规划好的。组件和视图的拆分维度是页面需要的区块而不是功能复用维度——这跟做大型项目时的组件设计思路会有些区别。对于这种小工具我更倾向于让组件服务于页面结构的清晰度而不是追求抽象的通用性。2.1 Vue Router的配置与页面流转虽然项目很轻我还是引入了Vue Router因为从使用场景来说这个工具天然有三个不同的视图首页负责快速决策就是进来之后选分类、点随机、看结果整个过程应该在十秒内完成菜单管理页负责增删改菜这个是低频操作可能一周才用一两次历史记录页负责回顾看看最近都吃了什么给下周做菜计划提供参考。路由配置非常简单import { createRouter, createWebHashHistory } from vue-router import HomeView from ../views/HomeView.vue const router createRouter({ history: createWebHashHistory(), routes: [ { path: /, name: home, component: HomeView }, { path: /manage, name: manage, component: () import(../views/MenuManageView.vue) }, { path: /history, name: history, component: () import(../views/HistoryView.vue) } ] }) export default router这里我用的是hash模式而不是history模式。对于部署在GitHub Pages或者随便一个静态文件服务器上的小工具来说hash模式有一个非常实际的好处不用配置任何服务端路由回退。直接双击index.html都能跑这在局域网共享给手机访问的时候特别方便。菜单管理和历史记录两个页面用懒加载在访问到对应路由时才下载组件代码。虽然这两个页面的体积很小懒加载的收益微乎其微但这是一个好习惯尤其在项目慢慢变大的时候入口文件的体积控制要从早期就开始。2.2 Pinia状态管理为什么选了它而不是ref一把梭状态管理用了Pinia这是Vue 3官方推荐的状态管理库。有些人觉得小项目没必要用状态库一个reactive对象就能解决。这个观点没错但我选择Pinia有一个很实际的考量这个项目的状态要在多个页面和组件之间共享而且数据变化需要持久化到localStorage。Pinia的store天然就是一个单例对象以模块化的方式组织数据模型同时配合storeToRefs可以保证响应式解构不丢失关联比我自己手动管理ref对象要省心不少。menu这个store的完整代码如下import { defineStore } from pinia import { ref, computed } from vue import { loadState, saveState } from ../utils/storage export const useMenuStore defineStore(menu, () { // 核心数据菜品列表 const dishes ref(loadState(dishes, [])) // 今日已选记录 const history ref(loadState(history, [])) // 当前选中的分类 const currentCategory ref(all) // 是否启用最近不重复功能 const avoidRepeat ref(true) const categories computed(() { const set new Set([all]) dishes.value.forEach(dish { if (dish.category) set.add(dish.category) }) return [...set] }) const filteredDishes computed(() { if (currentCategory.value all) return dishes.value return dishes.value.filter(dish dish.category currentCategory.value) }) function addDish(dish) { dishes.value.push({ ...dish, id: Date.now().toString(36) Math.random().toString(36).slice(2, 8) }) saveState(dishes, dishes.value) } function removeDish(id) { const index dishes.value.findIndex(d d.id id) if (index -1) { dishes.value.splice(index, 1) saveState(dishes, dishes.value) } } function randomPick() { let pool filteredDishes.value if (avoidRepeat.value history.value.length 0) { const recentIds new Set(history.value.slice(-7).map(item item.dishId)) const available pool.filter(dish !recentIds.has(dish.id)) if (available.length 0) pool available } if (pool.length 0) return null const picked pool[Math.floor(Math.random() * pool.length)] history.value.push({ dishId: picked.id, name: picked.name, time: Date.now() }) saveState(history, history.value) return picked } function resetHistory() { history.value [] saveState(history, []) } return { dishes, history, currentCategory, avoidRepeat, categories, filteredDishes, addDish, removeDish, randomPick, resetHistory } })注意到一个设计细节菜品数据加载时直接用loadState(dishes, [])读取本地存储里的值作为初始值。这是Pinia setup store的技巧在store第一次被实例化的时候就会执行初始化逻辑所以数据持久化在store层就闭环了不需要在组件里额外调用加载函数。id的生成用了Date.now()加上随机字符串这样设计是因为客户端本地环境不需要保证全局唯一ID只要在本地足够随机、避免重复即可。如果以后要接后端这个字段也可以作为主键传给服务端。2.3 localStorage封装数据持久化的边界处理存储封装是所有本地优先应用的地基这里有很多细节要处理。我写了一个很薄的存储层把所有localStorage的读写集中封装起来避免在业务代码里到处散落localStorage.getItem之类的调用const PREFIX pk_ export function loadState(key, fallback null) { try { const raw localStorage.getItem(PREFIX key) if (raw null) return fallback return JSON.parse(raw) } catch (e) { console.warn(读取本地数据失败: ${key}, e) return fallback } } export function saveState(key, value) { try { localStorage.setItem(PREFIX key, JSON.stringify(value)) } catch (e) { console.warn(保存本地数据失败: ${key}, e) } }这里有几个小设计点值得解释。第一所有key都加了pk_前缀这是防止未来某天同一个域名下部署了另一个工具两个应用的localStorage数据互相串掉。第二JSON.parse包在try/catch里因为localStorage的数据可能因为各种原因损坏比如手动改过、版本更新导致结构不一致一旦解析失败整个应用崩溃那体验就很糟糕了。第三保存时也包了try/catch因为localStorage在某些场景下会抛异常最常见的两种情况是隐私模式下存储配额受限、或者存储空间满了捕获异常后应用至少不会直接白屏。还应该处理的一个边界情况是localStorage的容量限制大约是5MB对文本类型的菜品数据来说完全够用但如果以后要存图片base64甚至是音频这个容量很快就会告急。我的建议是如果要扩展图片功能可以先用URL地址而非base64或者把图片压缩后再存储。2.4 随机算法的体验优化公平与久别重逢随机算法的实现是整个项目中最有意思的部分。表面上看从数组里随机取一个元素太简单了const picked pool[Math.floor(Math.random() * pool.length)]但实际使用中会遇到一个体验问题纯随机的情况下一道菜可能会连续好几天被选中另一道菜可能一个月也轮不到一次。这不是算法bug而是随机分布的特性但从人的直觉感受来说这不公平。所以在randomPick里加了一个avoidRepeat逻辑默认启用不重复模式思路是每次随机时排除掉近7天已经吃过的菜。实现方式是维护一个history数组每次随机后把被选中的菜记录进去随机时取出最近7条的dishId用Set数据结构做O(1)的查重然后过滤掉这些菜。这个逻辑有一个很好的衍生效果历史记录数据本身也有了价值。以前下班回家想半天今天吃什么现在打开历史页看最近一周吃了什么马上就能知道冰箱里剩了什么食材下一顿可以做什么搭配。数据从去重依据变成了生活记录这是最初设计时没想到的。还有一个隐藏问题值得注意如果菜单库很小而且最近7天吃的都是不同类别的菜那么过滤后可用池可能为空。代码中做了判断如果过滤后为空就回退到不过滤的完整候选池保证永远能输出一个结果。这个兜底逻辑很重要我见过很多随机工具因为所有选项都被排除了而直接返回空结果用户面对一个今天什么都不能吃的界面那体验真的是灾难。3. 页面与组件实现从能用到好用的细节迭代很多个人项目在功能逻辑写完后就算完工了但实际使用中UI和交互的细节才是决定工具能不能被长期使用、愿不愿意每天打开的关键。我用了几天时间做核心功能又用了更多时间打磨交互细节。3.1 首页的十秒决策设计逻辑首页是使用频率最高的页面设计目标是从打开到得到答案不超过十秒。界面布局很简单顶部是分类筛选按钮中间是一个大按钮帮我想一个下面是最近一次随机结果展示。分类筛选按钮是横向滚动的胶囊标签选中态用不同的背景色和文字色区分比起下拉框来说少一次点击。这里有一个交互上的细节随机按钮做成了整个页面最醒目的视觉焦点比其他按钮大一圈颜色也更鲜艳。原因很简单——这是整个应用最高频的操作视觉重心应该集中在它上面。很多小工具UI的问题是所有元素一样重用户打开后不知道眼睛该往哪里放操作效率自然低下。分类筛选是即时响应的点击某个分类按钮后如果用户接着点随机就会在对应分类下随机。这个选择分类再随机的交互路径本质上是在随机这个动作前面加了一个缩小范围的选项比直接全库随机要实用得多。比如今天想吃鱼先选鱼类再点随机出来的结果一定是鱼不用赌概率。菜单卡片展示上我用了最简单的文字卡片包括菜名和分类标签。没有放图片这既是因为本地存储的容量限制也是因为实际使用中文字信息已经足够决策了——如果一道菜需要看图片才能想起来是什么说明这道菜对使用者来说还不够熟悉不如先把它加入菜单库养着等熟悉了再让它在随机中出现。3.2 菜单管理表单校验与批量操作的取舍菜单管理页面是第二个核心页面负责菜品的增删。新增菜品的表单字段只有三个菜名、分类、备注备注可选比如老公不吃香菜这类口味提示。三个字段的校验逻辑很简单菜名必填、不能超过20字分类必填但允许用户输入任意值。分类不需要预置固定选项第一次输入鱼类后这个分类就会自动出现在首页的筛选标签中这是categories计算属性动态收集的结果。删除操作做了二次确认使用的不是浏览器原生confirm弹窗而是一个自定义的轻量确认浮层。原生confirm的问题是样式丑、交互突兀而且不同操作系统下的表现不太一样有的还有延迟。自定义确认浮层虽然多一些代码但体验统一而且可以更明确地提示删除后果——删除后无法恢复这句话写在按钮旁边比弹一个确定要删除吗要有效得多。批量添加是后期加的功能。一次添加一个菜太慢了尤其是一开始录入菜单库的时候可能要录入几十道菜。我在表单区域加了一个textarea支持一行一个菜的批量输入前端用split(\n)把文本拆成数组然后循环调用addDish。分类和备注统一应用到这一批菜上。这个功能让初始菜单库的建立时间从十几分钟缩短到两三分钟。3.3 历史记录的回溯价值历史记录页展示了每次随机的结果和时间。这个页面的初期版本只是一张只读列表没有任何操作。后来发现一个使用场景每次吃完饭后妻子经常会说这个菜不错下周末可以再做一次。于是历史记录变成了决策参考——打开历史页看到上周四吃了酸菜鱼那这个周末就可以安排一个不重样的鱼。为了这个用途我给每一行历史记录加了一个加到菜单按钮。有时候随手把朋友推荐的一道菜记在历史页里后来不想只做一次性随机项想正式加入菜单库点一下这个按钮就完成了。这个微小的功能点在后来的使用中又引出了一个想法如果菜品出现在历史记录里的次数多了说明它做出来的概率高那它就是一道靠谱菜可以作为菜单库的信任权重数据。历史记录还支持按时间范围筛选最常用的是最近7天和最近30天。这个筛选不是一个下拉框而是两个预设按钮同样是基于减少思考成本的原则——日常使用根本不需要自由的日期范围选择器两个预置选项覆盖了90%的使用场景。4. 部署与日常使用从开发完成到顺手可用的最后一公里一个前端项目写完跑在本地开发服务器上距离日常能用还有一段距离。这里要解决的几个问题怎么部署、怎么让手机也能访问、数据备份怎么做。4.1 打包与静态部署方案Vite的构建配置几乎不需要额外改动一行命令就能打出生产环境的包npm install npm run builddist目录下就是全部的静态文件。我把这些文件放在了家里的NAS上通过Nginx提供服务。Nginx的配置很简单只需要注意hash路由模式下不需要try_files回退这在之前的代码部分已经提到过了。在Nginx上配置了一个server块监听80端口root指向dist目录gzip开启。gzip对Vue应用很有用首次加载的JavaScript和CSS文件体积大约几十KB开启gzip后能压缩掉60%以上首屏加载速度有明显提升。4.2 手机访问与家庭局域网共享最开始这个工具只在电脑上用但实际使用场景里两个人经常是窝在沙发上一起商量吃什么电脑不如手机方便。所以我把Nginx的监听地址绑定了局域网IP手机在同一WiFi下直接访问IP地址就能打开。这里需要注意一个细节如果手机和电脑不在同一个网段比如说电脑连着网线、手机连着WiFi需要检查Nginx监听的是不是局域网网卡的地址而不是只监听了回环地址。Nginx默认监听0.0.0.0服务本身没有问题但操作系统层的防火墙可能需要放行80端口尤其是Windows系统经常会有局域网无法访问的问题原因就是系统防火墙默认拦截了入站请求。如果家里有支持mDNS的路由器还可以给服务配一个.local域名这样就不需要记忆IP地址了。不过这个配置依赖具体网络环境我自己的实现只在NAS的hostname上做了配置不做展开。4.3 localStorage数据的备份思路既然数据都存在localStorage里那么它的数据安全问题就比服务端应用更脆弱。localStorage的数据是跟随浏览器存储的如果清理浏览器缓存、换电脑、换浏览器访问数据就从零开始。为此我做了一个很轻量的导出/导入功能在历史记录页底部放了一个导出按钮点击后把所有store数据序列化成一个JSON文件通过Blob下载到本地。导入功能反过来读取用户选择的JSON文件解析后写入localStorage。这个功能不到五十行代码但是解决了最重要的数据安全感问题。我现在每个月会手动导出一次菜单数据放在云盘里防止NAS坏了或者浏览器缓存被清理后大半年的菜单数据全部消失。JSON文件的内容结构很简单{ dishes: [ { id: xxx, name: 酸菜鱼, category: 鱼类, note: } ], history: [ { dishId: xxx, name: 酸菜鱼, time: 1710000000000 } ] }这个格式对用户是友好的可以直接用记事本打开查看修改。某种意义上它算是一个非常轻量级的后端数据备份方案。5. 踩过的坑与习惯性避坑指南开发这个项目的过程中有几个坑是我自己踩过或者看到别人踩过的这里集中整理一下。这些问题在大型项目里可能不容易遇到但小项目中一旦触发排查起来反而因为没有足够的线索而更浪费时间。5.1 Vue响应式丢失数组索引赋值的老问题在Vue 2时代直接通过索引修改数组元素arr[0] xxx不会触发响应式更新这是defineProperty的局限性。Vue 3用Proxy重新实现了响应式大部分场景下这个问题已经消失了。但有一个场景还是会遇到对数组整体重新赋值时如果赋值的是普通数组而原来数组中的元素对象带有响应式属性那么就可能导致旧的响应式连接丢失。我在removeDish的实现中用了splice方法而不是filter重新赋值一方面是为了正确触发响应式另一方面也是为了让UI的更新更直观。如果用了dishes.value dishes.value.filter(dish dish.id ! id)虽然同样会触发响应式更新但整个数组被替换掉了如果其他地方持有dishes数组的引用那么这些引用的对象就变成了旧的数组后续操作可能会产生诡异问题。在大项目中这个问题会被组件间的复杂引用关系放大所以在写Vue代码时我对替换整个数组这个操作非常谨慎。能原地修改就原地修改不能的话就显式管理引用关系。5.2 Date.now()作id的场景冲突新菜品id的生成用了Date.now()加随机字符串的组合。这个方案在本地环境验证了很长时间都没出问题但我仍然认为有必要提一下它的边界如果用户在一毫秒内连续添加两道菜而且随机字符串恰好也碰撞了概率极低但不是零理论上会出现两个菜品拥有相同id的情况导致后续删除和去重时出现误操作。对于这个本地小工具来说风险完全可以接受。但如果哪一天把数据结构升级为多端同步就应该改用UUID或GUIDpython里的uuid.uuid4()或者JavaScript的crypto.randomUUID()都可以。我自己后续扩展时优先考虑的是兼容性问题保证旧数据的id格式不冲突。5.3 随机函数的伪随机陷阱很多人用Math.random()做随机抽取但不清楚它的底层机制。JavaScript的Math.random()是一个伪随机数生成器它的随机性对于日常使用完全够用但如果你需要的是不可预测的均匀分布比如用于抽奖那么Math.random()就不够用了应该使用crypto.getRandomValues()等加密级随机源。在这个点菜场景中Math.random()没有任何问题但我还是在代码里留了一个可替换的接口如果未来要做抽奖模式比如朋友来家里聚餐时抽几道菜可以直接替换随机源而不用改动业务逻辑。这个设计思想是随机算法的实现细节不应该耦合在业务代码里抽成一个独立的工具函数会更灵活。5.4 页面滚动的顽固问题与处理有一个非常隐蔽的体验问题在首页滚动浏览菜单列表后切换到另一个页面再切回来滚动位置往往不在顶部。这个问题的原因有两个层面一个是浏览器自身的滚动回退机制另一个是SPA应用中组件重载时DOM重建导致的滚动位置丢失。解决方式很直接在每个路由切换后主动把window滚动到顶部。在App.vue里监听route对象的afterEach钩子router.afterEach(() { window.scrollTo(0, 0) })这个几行代码的小修改带来的体验提升非常明显算是SPA应用开发中最划算的优化之一。另外如果列表很长考虑用vue的KeepAlive包裹页面组件来保持滚动位置但在这个工具里没有这个需求因为菜品列表本身很短最多几十条。6. 后续可扩展的思路与实践建议目前这个工具已经完成了点菜随机决策的核心闭环但实际使用中我也开始琢磨一些新的需求方向。这些想法有些是妻子提出的有些是我在连续使用了两个月后主动发现的不一定都会实现但可以作为未来扩展方向的参考。一个方向是食材联动。前几天冰箱里还剩一小块五花肉妻子说今晚做个能用到五花肉的菜吧于是我在点菜时想到了食材过滤功能。在菜单管理里给每道菜打上主要食材标签随机时可以选择只用某个食材可做的菜。这样会把决策范围进一步缩小也更贴近真实厨房场景。这个功能的工程量不小主要是在录入和修改既有菜品时的数据维护成本高需要保证已有的每条菜单数据都被打上食材标签才能发挥功能价值否则过滤后可用池会感觉莫名变少。另一个方向是购物清单联动。每次随机选出菜品后自动生成需要用到的食材清单用户可以在应用中勾选哪些家里已有、哪些需要买。更进一步可以把清单同步到手机备忘录或者通过系统分享导出。这个功能的技术实现不难关键是食材数据结构的设计——每道菜需要的食材数量、单位、可替换项等都需要纳入模型。还有一个思路是引入每周菜单模式。既然目前已经有历史记录了可以进一步做周计划每周日晚上用历史记录里最近一周的数据结合本周剩余的食材自动生成下一周的晚餐计划。这个功能相当于从单餐决策升级到一周规划对通勤家庭的买菜计划会有很大帮助。最后想说的是这个项目给我的经验是不要因为工具很小、用户很窄甚至只有两个人就放弃工程化的好习惯。Vue把复杂度隔离得很好让Vue的基本功在这个小项目中得到了充分的实践——响应式数据的边界、状态管理库的选型、路由策略、本地存储封装边界这些知识点在大型项目中同样关键。而把一个零碎想法变成每天都在用的东西的过程比写一百个Demo都有价值。如果你也想做一个类似的小工具我的建议是先动手把最小的版本跑起来功能能少则少先满足能用然后让它进入真实的日常使用让实际体验来告诉你下一步该加什么。点菜神器如此其他家庭小工具也一样。本文还有配套的精品资源点击获取

相关新闻

最新新闻

日新闻

周新闻

月新闻