简介:这是一份美食菜谱微信小程序完整源码项目,面向小程序初学者和想快速搭建菜谱类应用的开发者。压缩包内含项目全部工程文件,可直接导入微信开发者工具运行,也可作为源码模板修改二次开发。资源共49个文件,以js、json、wxml、wxss等小程序核心代码文件为主,配有16张png素材和1张jpg图,涵盖全局配置、工具函数及首页、菜谱详情、添加菜谱、搜索、个人中心等页面模块,整体仅463KB,结构轻量清晰。目前已有522人学习浏览。通过阅读项目,可以掌握小程序从全局配置到页面交互的实现思路,学习首页展示、菜谱详情、增删菜谱、搜索筛选等常见功能的编码方式,同时理解utils工具模块的复用设计与页面样式组织方法。对于正在做课程设计或毕业设计的学生来说,这份源码能提供完整的参考实例;对于希望快速上线菜谱类小程序的开发者,也可在此基础上直接扩展菜谱数据与界面风格,降低从零搭建的成本。
1. 这个美食菜谱微信小程序源码包里,最有价值的部分不是页面
拿到美食菜谱微信小程序.rar,解压后能看到一个标准得几乎像官方模板的微信小程序项目:app.js、app.json、app.wxss、pages 下 7 个页面,再加 20 多张本地图片和一个 util.js。它没有云开发数据库,也没有复杂后端,核心链路是“首页读本地缓存 → 详情传 id → 新增菜谱写回 storage → 搜索页再过滤同一份缓存”。对想学习微信小程序项目实例的人来说,这个体量刚好卡在“能看懂”和“有东西可改”之间;对想要快速搭个人工具型小程序的人来说,它又比空模板多了完整交互闭环。Android 标签在这里指的是真机调试环境,微信小程序本身在 iOS 和 Android 的微信里都能跑。下面按启动顺序拆,把每个文件在数据流里扮演的角色说清楚,并给出可以直接抄的改法。
2. 从 app.json 的路由设计到全局样式:先看懂小程序的启动顺序
在微信小程序里,最先被执行的文件不是第一个页面,而是项目根目录的 app.js。然后是 app.json 去注册页面、app.wxss 去铺全局样式,最后才轮到 pages 里被注册的第一个页面。这个顺序决定了:全局数据初始化、本地缓存预读取这类操作只能放 app.js,不能放首页的 onLoad。
2.1 页面注册顺序决定首屏,目录结构决定数据流
app.json 是一个小程序能不能被微信识别的关键,pages 数组的第一项就是启动后默认加载的页面。这个包把 index 放在第一位,所以打开就是菜谱首页。
{ "pages": [ "pages/index/index", "pages/logs/logs", "pages/detailFood/detailFood", "pages/addFood/addFood", "pages/select/select", "pages/user/user", "pages/searchList/searchList" ], "window": { "navigationBarTitleText": "美食菜谱", "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black" }, "style": "v2" }pages 数组顺序不能乱调,首页必须放在第一位;window 里的 navigationBarTitleText 是全局默认标题,各个页面自己的 .json 里可以覆盖。logs 页是官方模板残留,实际业务里没有入口,建议直接删掉,或者改成“我的收藏”,否则页面包会一直带着一份无用的启动日志逻辑。
| 路径 | 角色 |
|---|---|
| pages/index/index | 菜谱列表首页,负责展示和入口 |
| pages/detailFood/detailFood | 菜谱详情,展示步骤和食材 |
| pages/addFood/addFood | 新增菜谱表单 |
| pages/select/select | 分类选择或筛选页 |
| pages/user/user | 个人中心和登录占位 |
| pages/searchList/searchList | 关键词搜索结果页 |
| pages/logs/logs | 模板残留日志页,可删 |
| utils/util.js | formatTime 等通用函数 |
2.2 app.js 的 onLaunch 与 globalData:缓存初始化放这里
app.js 里最常见的写法是初始化一份空数据,避免首页第一次读 storage 时拿到 undefined:
App({ onLaunch() { const foodList = wx.getStorageSync('foodList') if (!foodList) { wx.setStorageSync('foodList', []) } }, globalData: { currentFood: null } })onLaunch只在冷启动时执行一次,适合做本地缓存兜底。globalData 适合放当前选中的菜谱这类临时状态,不适合放需要长期保留的数据,因为小程序切后台再回前台时 globalData 还在,但 App 一旦被系统销毁重建就没了。真正的持久化数据要写进wx.setStorageSync。如果你接的是后端接口,这里也可以先wx.login拿 code,再连同全局配置一起发给服务端。
2.3 自定义导航栏高度:状态栏与胶囊按钮的测量
这个小程序用的是默认导航栏,但很多拿到源码的人会改成自定义导航,以便把搜索框放进去。自定义导航的第一个坑就是高度计算:
const menu = wx.getMenuButtonBoundingClientRect() const windowInfo = wx.getWindowInfo() const navBarHeight = (menu.top - windowInfo.statusBarHeight) * 2 + menu.heightmenu是右上角胶囊按钮的位置,statusBarHeight是手机状态栏高度。胶囊按钮的中心点通常就是自定义导航栏的中心点,所以用(胶囊top - 状态栏高度) * 2 + 胶囊高度就能推出整个导航栏高度。老项目里用wx.getSystemInfoSync()也能算,但新基础库更推荐wx.getWindowInfo()。这个小程序后续如果要加顶部搜索入口,把这段代码放到 app.js 的 globalData 里,所有页面都能读。
3. index 首页的列表数据流:加载状态、WXML 渲染与卡片样式
首页要解决三个问题:什么时机读数据、读哪份数据、列表怎么渲染。很多人刚接触微信小程序项目实例时,习惯把数据请求写在 onLoad 里,但在这个项目里你会发现从新增菜谱页返回后,onLoad 不会重新执行。所以合理做法是把数据读取放到 onShow 里,让页面每次可见都拿到最新缓存。
3.1 onLoad 与 onShow 的职责划分
const util = require('../../utils/util.js') Page({ data: { foodList: [], loading: true }, onShow() { this.loadFoodList() }, loadFoodList() { const list = wx.getStorageSync('foodList') || [] const foodList = list.map(item => ({ ...item, updateTimeText: util.formatTime(new Date(item.updateTime)) })) this.setData({ foodList, loading: false }) }, goDetail(e) { const id = e.currentTarget.dataset.id wx.navigateTo({ url: `/pages/detailFood/detailFood?id=${id}` }) } })onShow在页面首次显示和从后台回到前台时都会触发;这里用一个函数同时承担初始化加载和返回刷新。updateTime是新增菜谱时写入的毫秒时间戳,利用 util.js 里的 formatTime 统一转成给人看的文本。e.currentTarget.dataset.id取自 WXML 上的><view class="page"> <view wx:if="{{loading}}" class="loading">菜谱加载中...</view> <view wx:elif="{{foodList.length == 0}}" class="empty">还没有菜谱,去添加一条</view> <view wx:else> <view class="food-card" wx:for="{{foodList}}" wx:key="id" bindtap="goDetail" >.food-card { display: flex; padding: 20rpx; margin: 20rpx 24rpx; background: #ffffff; border-radius: 16rpx; box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04); } .food-card .cover { width: 160rpx; height: 160rpx; border-radius: 12rpx; background: #f5f5f5; } .food-card .info { flex: 1; margin-left: 20rpx; }
| 状态 | 判断条件 | 展示内容 |
|---|---|---|
| 加载中 | data.loading === true | 加载提示或骨架屏 |
| 空列表 | foodList.length == 0 | 引导添加 |
| 正常列表 | foodList.length > 0 | wx:for 渲染卡片 |
rpx会根据屏幕宽度自动缩放,适合双端;真机上如果卡片阴影过深,可以降低 rgba 的透明度。图片路径如果来自本地 img 目录,尽量用绝对路径如/img/food.png,页面级相对路径在自定义导航或分包场景下容易失效。
4. 详情页参数传递与新增菜谱表单:从 id 到 storage 的完整闭环
详情页和新增页面是这套源码里最能学到东西的部分,因为它们把小程序最常见的“列表→详情→新增→回列表”闭环走通了。关键点不是 setData 本身,而是数据从哪来、存到哪、页面之间怎么同步。
4.1 用 id 从缓存里找回详情,不把整个对象塞给 URL
detailFood 页面接受首页跳转时带过来的 id,再自己去 storage 里捞数据。这是最稳的做法,因为把整个对象拼到 URL 里很可能被编码撑爆,而且用户从搜索页跳进来时,globalData 里可能还是上一次点击的菜。
Page({ data: { food: null }, onLoad(options) { const list = wx.getStorageSync('foodList') || [] const id = decodeURIComponent(options.id || '') const food = list.find(item => String(item.id) === String(id)) this.setData({ food: food || null }) } })options.id来自 URL query,永远都是字符串;存 id 时如果用数字,这里必须用String()比较,否则类型不匹配会找不到。decodeURIComponent是为了兼容首页对中文和特殊字符做的编码。拿到food之后,详情页的 WXML 直接渲染food.name、food.cover、food.desc和步骤列表。如果在 onLoad 里没找到,常见原因有两个:一是 storage 里真的没有这条数据,二是 id 被页面路径里的/或?截断。排查时在详情页console.log(options),再对照 storage 里的 id 看看格式。
4.2 addFood 表单:radio-group、chooseMedia 和图片持久化
新增菜谱页一般至少要有菜名、分类、封面图、简介。分类用单选框比输入框更可控,尤其是做筛选页时,分类字段必须统一字典值。常用写法:
<radio-group bindchange="onCategoryChange"> <label wx:for="{{categories}}" wx:key="*this"> <radio value="{{item}}" checked="{{item == category}}" /> <text>{{item}}</text> </label> </radio-group>const DEFAULT_COVER = '/img/food.png' Page({ data: { name: '', category: '家常菜', desc: '', cover: DEFAULT_COVER, categories: ['家常菜', '素菜', '汤粥', '小吃'] }, onNameInput(e) { this.setData({ name: e.detail.value }) }, onCategoryChange(e) { this.setData({ category: e.detail.value }) }, chooseCover() { wx.chooseMedia({ count: 1, mediaType: ['image'], success: res => { this.setData({ cover: res.tempFiles[0].tempFilePath }) } }) }, saveFood() { if (!this.data.name) { wx.showToast({ title: '菜名不能为空', icon: 'none' }) return } const list = wx.getStorageSync('foodList') || [] const now = Date.now() list.unshift({ id: `food_${now}`, name: this.data.name, category: this.data.category, desc: this.data.desc, cover: this.data.cover, updateTime: now }) wx.setStorageSync('foodList', list) wx.navigateBack() } })wx.chooseMedia是现在主选方案,老的wx.chooseImage还能跑但不推荐。从相册选的临时文件只在本次会话有效,如果你希望小程序重启后封面还在,必须把临时文件转存到本地用户目录:
const fs = wx.getFileSystemManager() fs.saveFile({ tempFilePath: res.tempFiles[0].tempFilePath, success: saved => { this.setData({ cover: saved.savedFilePath }) } })wx.env.USER_DATA_PATH可以拼出更明确的存储路径,保存附件时也用同样的思路。图片旋转问题在这个环节会暴露:部分手机相册照片带 EXIF 方向信息,直接渲染会横过来。常见做法是拿到临时文件后用wx.compressImage或 canvas 重绘一次,把图片归一化为正向再存。当前这个项目用本地默认图片不会触发,但只要你接 chooseMedia 就一定要考虑。
4.3 保存后的数据同步:navigateBack 之后首页怎么感知
新增页保存完成后,wx.navigateBack()会回到上一个页面。首页因为用 onShow 读取数据,所以返回时自动刷新列表。这里有个容易踩的坑:如果首页同时写了 onLoad 和 onShow 的读取逻辑,首次进入会重复读两次,建议只保留一处。
storage 里存的数据结构也需要固定,下面这个字段表可以当成数据结构文档:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 唯一标识,生成后不变 |
| name | string | 菜名 |
| category | string | 分类,与 select 页字典一致 |
| desc | string | 简介 |
| cover | string | 封面路径 |
| updateTime | number | 时间戳,用于排序和显示 |
用list.unshift把新菜放到最前面,首页和搜索页都按数组顺序展示,这样一眼就能看到新增结果。真正接后端时,可以用wx.request替换wx.getStorageSync和wx.setStorageSync,但页面结构不需要大改。
5. searchList 搜索过滤、user 登录态与缓存一致性验证技巧
searchList 页和 user 页在这个项目里更像一个延伸。searchList 不能直接调接口,因为数据源还是本地 storage;user 页也没有真实登录逻辑,只有 wx.login 的壳。但它俩正好演示了小程序里最常见的两种状态同步问题:搜索过滤结果和缓存数据不同步,用户身份和本地数据不同步。
5.1 搜索页的关键词过滤与防抖
let searchTimer = null Page({ data: { keyword: '', result: [] }, onKeywordInput(e) { const keyword = e.detail.value.trim() this.setData({ keyword }) if (searchTimer) clearTimeout(searchTimer) searchTimer = setTimeout(() => this.doSearch(keyword), 300) }, doSearch(keyword) { const list = wx.getStorageSync('foodList') || [] const result = list.filter(item => { return item.name.includes(keyword) || (item.desc && item.desc.includes(keyword)) }) this.setData({ result }) } })Input 事件每敲一个字符都会触发,用 300ms 防抖避免频繁 setData。过滤条件先查菜名,再查简介;如果后续加了食材字段,可以一并放到 includes 里。select 分类筛选页也是同一套逻辑,只不过 doSearch 里换成item.category === selectedCategory。想给列表加长按拖拽滚动,不要把 scroll-view 和 movable-area 直接套在一起,常见做法是先 bindlongpress 标记当前项,进入拖拽状态后再渲染 movable-area,拖动结束统一写回 foodList 并同步 storage,避免长按和滚动手势冲突。
5.2 user 页登录态和缓存凭据
user 页通常会把 wx.login 的 code 发给后端换 openid。这个项目没有后端,但代码结构可以先留好:
wx.login({ success(res) { if (res.code) { wx.request({ url: 'https://your.server.com/login', data: { code: res.code }, success() {} }) } } })真机上 res.code 是一次性凭证,后端拿它换 openid 和 session_key;如果只是本地 demo,用 code 换不到用户信息时,可以直接把添加菜谱数量展示出来,代表一个“本地用户维度”。小程序登录和后端无关时,不要把 code 存到 storage,它 5 分钟就会失效,存了也没意义。
5.3 验证缓存一致性的实用技巧
首页、详情页、搜索页读的是同一份 foodList,最容易出现的 bug 是某个页面读了 globalData,另一个页面读了 storage。验证方法很简单,在调试器 Console 里执行:
console.table(wx.getStorageSync('foodList'))看到字段完整后,再分别进首页、详情页、搜索页各打印一次this.data.foodList。如果三个页面的 id 列表一致,说明数据流是通的;如果不一致,把全局读取收敛到 utils 里一个getFoodList()函数,所有页面统一 require,禁止页面里直接写wx.getStorageSync。改完之后在 user 页的 onShow 里重新拉一遍 storage,并手动触发首页刷新,整个缓存状态就随着页面切换自动对齐了。
本文还有配套的精品资源,点击获取