ARTICLE DETAIL

资讯详情

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

原生JS微信小程序实现食物图像识别与动态饮食计划

原生JS微信小程序实现食物图像识别与动态饮食计划 简介这是一套面向前端开发者与健康管理类小程序学习者的完整微信小程序源码聚焦减脂目标管理与日常健康干预场景适用于具备JavaScript基础、希望实践小程序开发全流程的中初级开发者。资源共91个文件包含17个核心JS逻辑文件、16个WXML页面结构文件、16个WXSS样式文件、16个JSON配置文件以及PNG/JPG图片资源和Markdown说明文档整体压缩包仅3.49MB轻量易上手。已有543人下载学习体现了其在轻量化健康应用开发中的实用热度。开发者可直接运行项目掌握用户画像构建、食谱分类展示、拍照识热量功能集成含图像上传与后端交互模拟、目标进度可视化等关键模块实现目录结构清晰含pages、utils、colorui组件库及完整sitemap与project配置还附有需求分析导图、价值主张画布等设计文档兼顾技术实现与产品思维训练。1. 这不是又一个“健康打卡”小程序它用纯原生 JavaScript 实现了摄食图像识别热量估算 动态饮食计划生成适合想快速落地减脂管理 MVP 的开发者你见过多少个标榜“智能减脂”的小程序点开一看不过是静态食谱列表手动打卡体重曲线图。而这个源码包从第一行app.js就没碰过任何框架——它用原生 JavaScript 在微信小程序环境里把「拍食物→识图→估热量→匹配当日剩余热量缺口→生成三餐建议」这条链路跑通了。核心不是炫技是解决真实场景里的三个卡点用户懒得手动输食物所以做了轻量级 OCR 前置过滤、营养师排计划太慢所以内置了基于中国居民膳食指南的规则引擎、数据孤岛难打通所以设计了本地缓存微信云开发双写策略。它不追求大而全但每个模块都经得起真机调试图片上传走wx.chooseImagewx.uploadFile热量识别逻辑封装在utils/foodRecognizer.js里连“水煮西兰花 vs 清炒西兰花”的热量差都做了系数校正。如果你正在做健身类 SaaS 的私域工具、营养师个人品牌小程序或者需要一个可二次开发的健康管理基座——这份源码不是玩具是能直接改接口、换 UI、接自有数据库的生产级起点。2. 从零跑通核心流程食谱数据结构设计、图像识别逻辑与动态计划生成三步闭环2.1 食物数据库不是 Excel 表格而是带权重关系的 JSON 树状结构这个项目没用云数据库存食物库而是把data/foodDB.json设计成三层嵌套结构一级是大类如“蔬菜类”“肉类”二级是具体食材如“西兰花”“鸡胸肉”三级才是烹饪方式变体如“水煮西兰花”“清炒西兰花”“蒜蓉西兰花”。每个变体节点包含caloriePer100g每百克热量、protein蛋白质克数、fat脂肪克数、carb碳水克数和关键字段cookingWeightRatio烹饪后重量变化系数。比如生西兰花 100g 热量 34kcal但水煮后吸水增重约 1.8 倍实际摄入热量需按34 / 1.8 ≈ 18.9kcal/100g熟重计算。这个系数不是拍脑袋定的而是参考《中国食物成分表》标准版中“水分含量变化率”推导而来。项目里所有热量计算都基于熟重避免用户称生重却按熟重估算的常见误差。{ vegetables: { broccoli: { boiled: { caloriePer100g: 34, cookingWeightRatio: 1.8, protein: 2.8, fat: 0.4, carb: 6.6 }, stir_fried: { caloriePer100g: 34, cookingWeightRatio: 1.2, oilAddition: 8, protein: 2.8, fat: 8.4, carb: 6.6 } } } }提示cookingWeightRatio和oilAddition是业务关键参数。前者影响热量密度计算后者直接增加脂肪摄入。你在替换食物库时必须同步校准这两个值否则热量估算偏差会超过 ±25%。2.2 摄食图像识别不是调用腾讯云 API而是本地规则引擎 轻量 OCR 结果后处理项目没走“上传图片→云端识别→返回结果”的高延迟路径而是采用混合策略先用wx.scanCode或wx.chooseImage获取图片再调用wx.cloud.callFunction调用云函数ocrFood该函数内部调用腾讯云 OCR 文字识别但关键在客户端后处理。云函数只返回 OCR 识别出的文字数组真正的“食物匹配”逻辑在pages/scan/scan.js里完成// pages/scan/scan.js const foodDB require(../../data/foodDB.json); Page({ data: { matchedFoods: [] }, onOcrResult: function(ocrTexts) { const candidates []; // 步骤1清洗OCR文本去空格、标点、转小写 const cleanTexts ocrTexts.map(t t.trim().toLowerCase().replace(/[^\w\u4e00-\u9fa5]/g, )); // 步骤2关键词模糊匹配支持错别字容错 cleanTexts.forEach(text { Object.keys(foodDB).forEach(category { Object.keys(foodDB[category]).forEach(foodName { if (this.fuzzyMatch(text, foodName) || this.fuzzyMatch(text, foodDB[category][foodName].alias)) { candidates.push({ category, foodName, confidence: this.calcConfidence(text, foodName) }); } }); }); }); // 步骤3按置信度排序取 Top3 this.setData({ matchedFoods: candidates.sort((a,b) b.confidence - a.confidence).slice(0,3) }); }, fuzzyMatch: function(str, keyword) { // 使用编辑距离简化版允许1个字符差异如“西兰化”→“西兰花” if (Math.abs(str.length - keyword.length) 1) return false; let diff 0; for (let i 0; i Math.min(str.length, keyword.length); i) { if (str[i] ! keyword[i]) diff; if (diff 1) return false; } return true; }, calcConfidence: function(text, keyword) { // 短文本匹配优先给高分如“鸡胸”比“鸡胸肉”更可能是用户输入 return 100 - Math.abs(text.length - keyword.length) * 5; } });这段代码的核心价值在于它把“识别不准”转化成“可干预的候选列表”。用户拍一张模糊的“红烧肉”照片OCR 可能返回“红绕肉”“红少肉”“红少内”但fuzzyMatch能同时匹配到“红烧肉”和“红绕肉”后者是常见错别字再通过calcConfidence给“红烧肉”更高分。最终界面展示三个选项供手动确认而不是强制接受一个错误结果——这是减脂场景下用户体验的生死线。2.3 动态饮食计划生成不是随机拼凑而是基于 TDEE 公式 用户目标的约束求解计划生成逻辑在utils/dietPlanner.js中它不依赖外部 API所有计算都在本地完成。输入是用户基础信息性别、年龄、身高、体重、活动量和目标减脂速率单位kg/周输出是三餐热量分配 食物组合建议。关键步骤如下计算 TDEE每日总能量消耗使用 Mifflin-St Jeor 公式而非粗略的“体重×30”// Mifflin-St Jeor 公式单位kcal/天 const bmr gender male ? 10 * weight 6.25 * height - 5 * age 5 : 10 * weight 6.25 * height - 5 * age - 161; const tdee bmr * activityFactor; // activityFactor: 1.2~1.9确定热量缺口减脂速率 0.5kg/周 ≈ 缺口 3500kcal/周 ≈ 500kcal/天。项目将缺口上限设为 750kcal/天对应 0.75kg/周避免激进节食。三餐热量分配按 3:4:3 分配早:午:晚但加入弹性规则若用户选择“跳过早餐”则午餐晚餐各加 15%并提示“可能影响代谢稳定性”。食物组合生成不是随机选菜而是贪心算法遍历foodDB优先选择高蛋白≥30g/餐、中低碳水≤40g/餐、低脂肪≤15g/餐的食物同一大类不重复如午餐不同时出现“鸡胸肉”和“瘦牛肉”烹饪方式分散避免三餐全是“水煮”。生成结果存入wx.setStorageSync(dailyPlan, plan)供首页index.wxml渲染。整个过程无网络请求秒级响应。3. 微信小程序登录与手机号获取从 wx.login 到 getPhoneNumber 的完整链路与权限降级方案3.1 登录态设计code2Session 不是终点而是用户身份锚点的起点很多开发者把wx.login()拿到 code 后直接调用code2Session就完事但这个项目把登录态拆成了三层临时凭证层wx.login()获取 code立即传给云函数login该函数调用code2Session并返回openid 自定义tokenJWT有效期 7 天用户信息层首次登录时云函数自动创建用户文档cloud.database().collection(users).add()存入openid、createTime、lastLoginTime扩展属性层用户主动填写昵称、头像、身高体重等存入独立profile子集合与登录态解耦。这样设计的好处是即使用户更换手机或重装微信只要 openid 不变历史减脂记录、饮食日志全部保留而 profile 信息可随时重填不影响核心数据连续性。// utils/auth.js const login () { return new Promise((resolve, reject) { wx.login({ success: res { wx.cloud.callFunction({ name: login, data: { code: res.code }, success: cloudRes { const { token, openid } cloudRes.result; wx.setStorageSync(authToken, token); wx.setStorageSync(openid, openid); resolve({ token, openid }); }, fail: reject }); }, fail: reject }); }); };3.2 手机号获取getPhoneNumber 不是银弹必须搭配 fallback 流程微信要求getPhoneNumber按钮必须绑定bindgetphonenumber事件且用户点击后才触发。但项目做了两层兜底第一层 fallback若用户拒绝授权页面显示“手动输入手机号”表单提交后调用云函数bindPhone该函数验证短信验证码使用腾讯云短信 SDK后将手机号与 openid 绑定第二层 fallback若用户既不点按钮也不填表单则进入“游客模式”——所有数据仅存本地wx.setStorageSync下次打开清空但首页仍显示“请授权手机号以同步数据”提示。关键代码在pages/auth/bindPhone.js// pages/auth/bindPhone.js Page({ data: { showManualInput: false, phone: , code: , countdown: 0 }, getPhoneNumber: function(e) { if (e.detail.code) { wx.cloud.callFunction({ name: getPhoneNumber, data: { encryptedData: e.detail.encryptedData, iv: e.detail.iv }, success: res { // 解密成功绑定手机号 this.bindPhoneToUser(res.result.phoneNumber); } }); } else { // 用户拒绝显示手动输入 this.setData({ showManualInput: true }); } }, sendCode: function() { if (this.data.countdown 0) return; wx.cloud.callFunction({ name: sendSmsCode, data: { phone: this.data.phone }, success: () { this.startCountdown(); } }); }, startCountdown: function() { let count 60; this.setData({ countdown: count }); const timer setInterval(() { count--; this.setData({ countdown: count }); if (count 0) { clearInterval(timer); this.setData({ countdown: 0 }); } }, 1000); } });注意sendSmsCode云函数必须配置腾讯云短信模板 ID并在云开发控制台开通短信服务。未配置时sendCode会静默失败——这是新手最常踩的坑务必在上线前实测。3.3 权限降级与体验平滑从“必须授权”到“渐进式信任”项目在app.js的onLaunch中做了权限检查App({ onLaunch: function() { // 检查是否已登录 const token wx.getStorageSync(authToken); if (!token) { wx.redirectTo({ url: /pages/auth/login }); return; } // 检查是否已绑定手机号非强制但影响功能 const phoneBound wx.getStorageSync(phoneBound); if (!phoneBound) { // 仅在首页显示一次提示不阻断流程 wx.setStorageSync(showPhoneHint, true); } } });首页index.wxml中用wx:if{{showPhoneHint}}控制提示条显隐并提供“稍后提醒”按钮点击后wx.setStorageSync(showPhoneHint, false)。这种“非阻断式引导”比弹窗强退更符合微信生态规范也降低了用户流失率。4. 避坑指南五个真实翻车现场与血泪修复方案4.1 现象OCR 识别结果为空数组但图片明明很清晰原因腾讯云 OCR 接口对图片尺寸有硬性要求——宽高均需 ≥ 100px 且 ≤ 3000px且长边不能超过短边的 3 倍。项目默认用wx.chooseImage的sizeType: [compressed]压缩后部分手机如 iPhone 12生成的图片长宽比达 4:3触发腾讯云的尺寸校验失败。解决在pages/scan/scan.js的chooseImage回调中强制添加尺寸校验与裁剪wx.chooseImage({ count: 1, sizeType: [original, compressed], sourceType: [album, camera], success: res { const tempFilePath res.tempFilePaths[0]; wx.getImageInfo({ src: tempFilePath, success: info { const { width, height } info; // 强制缩放到长边≤2000px宽高比保持 const scale Math.min(2000 / Math.max(width, height), 1); if (scale 1) { // 调用 canvas 裁剪此处省略具体 canvas 代码需自行实现 this.scaleAndUpload(tempFilePath, scale); } else { this.uploadImage(tempFilePath); } } }); } });4.2 现象用户修改身高体重后TDEE 计算值不变原因utils/dietPlanner.js中的calculateTDEE函数被设计为纯函数但调用方如pages/profile/edit.js在保存新数据后没有触发计划重生成。用户看到的仍是旧计划。解决在pages/profile/edit.js的saveProfile方法末尾强制清除旧计划缓存并触发重生成saveProfile: function() { // ... 保存 profile 到云数据库 wx.cloud.callFunction({ name: updateProfile, data: this.data.profile }) .then(() { // 清除本地计划缓存 wx.removeStorageSync(dailyPlan); // 触发首页重新计算通过自定义事件 wx.$emit(profileUpdated); wx.showToast({ title: 资料已更新, icon: success }); }); }并在pages/index/index.js的onLoad中监听该事件onLoad: function() { wx.$on(profileUpdated, () { this.generateTodayPlan(); // 重新调用计划生成函数 }); }4.3 现象iOS 真机上getPhoneNumber按钮点击无反应安卓正常原因iOS 微信 8.0.30 版本对button组件的open-typegetPhoneNumber增加了 DOM 层级限制——按钮必须是view的直接子元素且不能被cover-view、movable-view等原生组件包裹。项目中该按钮被嵌套在uni-app风格的u-card组件内导致 iOS 失效。解决重构授权页结构移除所有第三方组件包裹用原生viewbutton实现!-- pages/auth/bindPhone.wxml -- view classauth-container view classtitle授权手机号解锁完整功能/view button classphone-btn open-typegetPhoneNumber bindgetphonenumbergetPhoneNumber 一键获取手机号/button view classmanual-tip wx:if{{!showManualInput}} 或 text classlink bindtapshowManualInput手动输入/text /view /view4.4 现象云函数login调用频繁报错Error: errCode: -404011 cloud function execution timeout原因云函数默认超时时间 5 秒但code2Session在高并发时偶尔耗时 4.5 秒加上 JWT 签名、数据库写入极易超时。解决在云开发控制台将login函数超时时间手动改为 10 秒并在代码中添加重试机制// cloud/functions/login/index.js exports.main async (event, context) { const MAX_RETRY 2; for (let i 0; i MAX_RETRY; i) { try { const wxContext cloud.getWXContext(); const { code } event; const { OPENID, APPID, SECRET } cloud.env; const res await axios.get( https://api.weixin.qq.com/sns/jscode2session?appid${APPID}secret${SECRET}js_code${code}grant_typeauthorization_code ); if (res.data.openid) { // 生成 JWT Token const token jwt.sign({ openid: res.data.openid }, your-secret-key, { expiresIn: 7d }); // 写入数据库 await db.collection(users).doc(res.data.openid).set({ data: { createTime: new Date(), lastLoginTime: new Date() } }); return { token, openid: res.data.openid }; } } catch (e) { if (i MAX_RETRY) throw e; await new Promise(r setTimeout(r, 1000)); // 重试前等待1秒 } } };4.5 现象用户反馈“水煮西兰花热量比生的还低”数据明显异常原因foodDB.json中cookingWeightRatio字段被误设为1.8表示熟重是生重的 1.8 倍但热量计算逻辑写成了caloriePer100g / cookingWeightRatio即34 / 1.8 ≈ 18.9这其实是“每百克熟重”的热量而用户直觉认为是“每百克生重”。但问题在于项目在 UI 上显示的是“您摄入了 100g 水煮西兰花约 18.9kcal”并未注明是熟重导致误解。解决在pages/scan/result.js的渲染逻辑中强制标注单位// pages/scan/result.js formatCalorieDisplay: function(calorie, weight, ratio) { // 显示为 “100g熟重≈ 18.9kcal” return ${weight}g熟重≈ ${calorie.toFixed(1)}kcal; }并在README.md中明确说明“所有热量值均按烹饪后实际摄入重量计算数据库中的cookingWeightRatio用于将生重换算为熟重”。5. 进阶技巧如何把这套逻辑迁移到自有服务器彻底摆脱云开发依赖5.1 替换云函数用 Express 构建轻量后端 API 代理层项目当前所有云函数login、getPhoneNumber、sendSmsCode、updateProfile均可替换为自有 Node.js 服务。核心是复用现有云函数逻辑仅修改请求入口和数据库连接。以login为例// server/routes/auth.js const express require(express); const router express.Router(); const axios require(axios); const jwt require(jsonwebtoken); const { MongoClient } require(mongodb); const client new MongoClient(process.env.MONGODB_URI); let db; router.post(/login, async (req, res) { try { await client.connect(); db client.db(fitness_db); const { code } req.body; const appid process.env.WX_APPID; const secret process.env.WX_SECRET; const wxRes await axios.get( https://api.weixin.qq.com/sns/jscode2session?appid${appid}secret${secret}js_code${code}grant_typeauthorization_code ); if (!wxRes.data.openid) { return res.status(400).json({ error: Invalid code }); } const token jwt.sign( { openid: wxRes.data.openid }, process.env.JWT_SECRET, { expiresIn: 7d } ); // 写入 MongoDB await db.collection(users).updateOne( { openid: wxRes.data.openid }, { $set: { lastLoginTime: new Date() } }, { upsert: true } ); res.json({ token, openid: wxRes.data.openid }); } catch (err) { res.status(500).json({ error: err.message }); } }); module.exports router;然后在小程序端把wx.cloud.callFunction全部替换成wx.request// utils/auth.js修改后 const login () { return new Promise((resolve, reject) { wx.login({ success: res { wx.request({ url: https://your-domain.com/api/login, method: POST, data: { code: res.code }, success: httpRes { const { token, openid } httpRes.data; wx.setStorageSync(authToken, token); wx.setStorageSync(openid, openid); resolve({ token, openid }); }, fail: reject }); }, fail: reject }); }); };关键点process.env.WX_APPID和process.env.WX_SECRET必须在服务器环境变量中配置绝不能硬编码在前端或小程序代码中。这是安全红线。5.2 替换云数据库MongoDB 聚合管道实现动态饮食计划生成原dietPlanner.js的计划生成逻辑可完全迁移至 MongoDB 聚合管道实现服务端计算降低小程序端压力。例如生成“高蛋白午餐”可写为// server/routes/plan.js router.get(/plan/lunch, async (req, res) { const { openid } req.headers; // 从 Authorization header 解析 token 获取 openid const user await db.collection(users).findOne({ openid }); // 基于用户信息计算 TDEE此处省略公式直接用变量 tdee const tdee calculateTDEE(user.profile); const lunchTarget tdee * 0.4; // 午餐占 40% // MongoDB 聚合查找高蛋白、中低碳水的食物组合 const foods await db.collection(foodDB).aggregate([ { $match: { nutrients.protein: { $gte: 30 }, nutrients.carb: { $lte: 40 }, nutrients.fat: { $lte: 15 } } }, { $addFields: { score: { $add: [ { $multiply: [{ $subtract: [lunchTarget, $nutrients.calorie] }, 0.1] }, $nutrients.protein, { $subtract: [40, $nutrients.carb] } ] } } }, { $sort: { score: -1 } }, { $limit: 3 } ]).toArray(); res.json({ foods }); });这样小程序端只需调用/api/plan/lunch拿到预计算好的三餐建议无需本地执行复杂逻辑。5.3 图像识别离线化用 TensorFlow.js 在小程序端运行轻量模型若想彻底摆脱 OCR 云调用可引入tensorflow/tfjs的移动端优化模型。项目已预留utils/tfjsRecognizer.js文件但未启用。启用步骤如下下载轻量版食物分类模型如 MobileNetV2 量化版2MB在app.js中初始化App({ onLaunch: async function() { await tf.ready(); // 等待 TF.js 加载 this.foodModel await tf.loadLayersModel(https://your-domain.com/models/food-mobilenetv2.json); } });在pages/scan/scan.js中替换 OCR 调用recognizeByTFJS: function(imagePath) { const img wx.createImage(); img.src imagePath; img.onload () { const tensor tf.browser.fromPixels(img) .resizeNearestNeighbor([224, 224]) .expandDims(0) .cast(float32) .div(255.0); const prediction this.app.foodModel.predict(tensor); const scores prediction.dataSync(); const topClass scores.indexOf(Math.max(...scores)); const foodName FOOD_CLASSES[topClass]; // FOOD_CLASSES 是映射数组 this.setData({ recognizedFood: foodName }); }; }注意TensorFlow.js 在低端安卓机上可能内存溢出。必须在onLoad中检测wx.getSystemInfoSync().platform ! ios且wx.getSystemInfoSync().memorySize 2048才启用否则回退到 OCR 方案。这是保底策略。从那以后我每次接手健康类小程序项目都会先检查三点食物库有没有cookingWeightRatio、OCR 结果有没有人工确认环节、TDEE 计算有没有用 Mifflin-St Jeor 公式。这三个点踩中任何一个后续所有“智能推荐”都是空中楼阁。这份源码的价值不在于它多炫酷而在于它把减脂管理中最容易被忽略的细节用可调试、可验证、可替换的代码写了出来。希望帮到你。本文还有配套的精品资源点击获取
返回列表