去年秋天接了个活儿一家做轻食外卖的店找到我说想把菜单墙上那一大张花花绿绿的图片换掉做成手机能打开、能滑动、能直接加购物车的页面。前后折腾了一个多月最后落地的这套东西我给它起了个名字叫“Menu Adobe”。名字里的Adobe并不是某家设计软件公司而是西班牙语里“土坯”的意思——这项目没用什么重型框架、没有复杂平台就是一个个基础模块像砌土坯房一样垒起来的所以叫Menu Adobe再合适不过。这个项目的核心成果是一份可复用的数字菜单方案从菜单信息的数据结构设计、视觉切图规范到前端H5交互、多端适配再到上线后被真实用户磨出来的各种细节坑全部串成了一条完整的链路。无论你是准备给自己餐厅做数字化菜单的老板还是接到“帮客户做数字菜单”这类需求的设计师、前端开发者这篇都值得从头到尾看一遍。1. 为什么叫“Menu Adobe”一次只想快速交付的菜单项目1.1 项目缘起一份手写在微信里的菜单这家轻食店原本的点菜方式非常原始顾客扫桌上的码跳出来是一张PDF菜单门店没有POS系统老板每天靠微信手写接单。中午高峰期顾客加购、改备注、催单的信息挤在一起漏单错单是常事。老板最初的想法只是“做一个能点餐的页面”但聊深了以后发现背后真正的问题是菜单信息没有一个结构化的载体所有数据都散落在人脑和聊天记录里。所以我先没有急着写代码而是把这家店现有的菜单全部翻了一遍哪些是常规菜品、哪些是季节限定、哪些经常缺货、哪些毛利高适合放推荐位光整理这些就花了两天。这件事给我最大的感受是数字菜单类项目文档整理和需求梳理阶段往往比写代码更耗时前面偷懒后面上线全是灾难。1.2 和现成点餐系统相比为什么还要自己做市面上能直接用的点餐SaaS产品很多我也和老板聊过要不要买现成的省时省力。但他的需求卡在两个点上一是年费觉得贵小门店一个月流水扛不住二是菜单样式被平台模板锁死门店的视觉风格一点也体现不出来他觉得和门店装修不搭。换我在他不会做选择我会直接推荐SaaS。但站在他的立场菜单本来就是他店里最重要的“门面”之一被一套通用模板框住确实难受。于是我们定了方案自研一套轻量H5菜单系统数据自己管、样式自己定、不用被平台绑死后续想接小程序、接自助机都有余地。这种“自己造轮子”的决策不一定适合所有人但适合预算有限、又对个性展示有要求的门店。菜单、简介、图片、价格这些信息量不大数据结构不复杂自研的维护成本远没有想象中高。1.3 技术路线不追新只选稳很多朋友一听要做菜单第一反应就是上框架React或Vue起步再配个UI组件库。但这个项目我选了最朴素的一条路前端原生HTML CSS JavaScript不引入任何框架后端简单Node.js接口提供JSON数据数据静态JSON文件起步后续再考虑数据库部署直接放一台静态服务器Nginx托管选择原生方案不是因为“炫技”而是算过一笔账菜单页面最核心的指标是首屏加载速度顾客扫码后往往没耐心等3秒内打不开就可能直接放弃。原生页面打开一个文件无框架依赖请求数少首屏体验是最好的。数据量就几十条菜品框架带来的状态管理优势根本用不上反倒增加了体积和心智负担。这套技术路线也有明确边界如果要做的是一套包含订单管理、库存管理、配送调度的完整连锁点餐系统那必须引入后端数据库和框架。但Menu Adobe的定位一直很清晰——解决一家门店的基础展示和加购需求够用、好用、能自己掌控。2. 菜单不只是菜品列表信息结构与数据建模2.1 从纸质菜单到数据模型纸质菜单上一道菜就是“图片 菜名 价格 描述”翻译成数字菜单时很多人直接照搬这几个字段结果后面想加“新品”标签又要改结构。我在做数据建模时学到的教训是先把菜单信息拆成实体和属性再决定字段。一道菜品在这个系统里涉及的信息可以拆成几类基础信息名称、编号、描述价格信息单份价格、多规格价格、单位分类信息属于哪个一级分类、二级分组营销信息是否推荐、是否有标签新品、热销、微辣状态信息是否售罄、是否下架展示信息图片URL、排序权重这些信息不一定要全部做成字段但必须在建模阶段就考虑到否则上线后加一个“是否素食”的筛选功能可能要把整张表改一遍。2.2 分类、标签与推荐位的设计取舍菜单分类是这种项目里最容易剑走偏锋的地方。我见过有人把分类做成三级树结果移动端一屏根本展示不完也有人不分分类几十道菜全部平铺顾客翻到手指抽筋。Menu Adobe采用的是两层分类结构一层是顶部Tab栏的一级分类比如“人气套餐”“沙拉”“饮品”“小食”二层是每个一级分类下的分组标签比如“沙拉”下面再分“蔬菜沙拉”“水果沙拉”“高蛋白沙拉”。这样做的原因是两层刚好匹配移动端的交互习惯——顶部Tab切换大类列表内用细分组制造阅读节奏再深就影响负载和体验了。标签字段我单独设计成一个数组而不是用多个布尔值硬编码。因为布尔值字段是“写死的属性”比如isNew: true只能表达“是否是新品”以后再加“是否是招牌”就要再添一个字段。用标签数组tags: [新品, 微辣]让前端自己决定每个标签怎么渲染扩展性会好很多。推荐位是运营向的功能。我的设计思路是每个菜品加一个priority字段数值越小越靠前同时再加一个isRecommended布尔值只有这个字段为真时才进入“主厨推荐”区域。这样既保证默认排序稳定又给运营留了一个独立控制的位置。2.3 一份可直接落地的JSON示例光说理论没有用直接给一份当时项目里用的JSON结构字段被我简化过但保留了核心设计。{ store: { name: 青柠轻食, notice: 午市高峰期出餐约15分钟请耐心等待 }, categories: [ { id: meal, name: 人气套餐, groups: [ { id: hot, name: 热销套餐 }, { id: new, name: 本周新品 } ] }, { id: salad, name: 沙拉, groups: [ { id: veg, name: 蔬菜沙拉 }, { id: fruit, name: 水果沙拉 } ] } ], dishes: [ { id: d001, name: 鸡胸肉能量碗, desc: 烤鸡胸、藜麦、牛油果、紫甘蓝、油醋汁, price: 32, unit: 份, categoryId: meal, groupId: hot, tags: [高蛋白, 热销], isRecommended: true, soldOut: false, image: https://cdn.example.com/menu/d001.jpg, priority: 1 }, { id: d002, name: 芒果鲜虾沙拉, desc: 鲜虾、芒果、生菜、小番茄、千岛酱, price: 28, unit: 份, categoryId: salad, groupId: fruit, tags: [新品], isRecommended: false, soldOut: false, image: https://cdn.example.com/menu/d002.jpg, priority: 10 } ] }这套JSON的核心思想是展示结构categories和内容数据dishes分离分类只管层级关系菜品只挂到对应ID上。前端渲染时先遍历categories生成Tab再根据当前分类筛选dishes。这样加菜、调序都不需要动页面代码改一行数据即可。3. 视觉落地先画草图再切图把菜单做“好看”也要做“好切”3.1 设计规范的确定字号、栅格、色彩很多人做菜单设计会犯一个错——在手机模拟器里看得好好的一放到真机上文字挤成一团或者边距大得离谱。问题根源在于没有提前确定设计栅格和字号体系。这个项目在做视觉稿之前我先定了一套以750px宽度为基准的设计规范元素参数说明页面宽度基准750px以主流设备逻辑宽度为基准翻倍方便切图外边距32px左右两侧统一留白卡片贴边距保持一致卡片间距24px纵向卡片之间的空隙一级分类Tab字体32px约等于视觉稿里的“大字”菜品名称字体30px约16号字效果用于核心信息辅助说明字体24px约12号字效果描述、备注类圆角规范16px卡片统一圆角避免风格杂乱主色#3B7A57轻食绿所有按钮、选中态统一这些参数不是拍脑袋定的核心逻辑是**菜单页面80%是“浏览”行为20%是“决策”行为。**浏览要信息密度高、节奏清晰所以字号要分级明确决策要减少干扰所以主色只能用在一个强调位比如加购按钮、选中态其他地方都必须克制。设计规范确定后视觉稿反而画得很快。一版页面包含顶部门店公告、一级分类Tab、菜品卡片列表、底部购物车栏四个区域几乎不需要额外发挥规范已经把布局框架定死了。3.2 切图的关键位图裁剪与矢量导出视觉稿完成后进入最容易被低估的切图环节。菜单页面里的图片分成两类菜品照片和图标元素处理方式完全不同。菜品照片是位图原则是统一裁剪比例。我在这套系统里统一采用3:4竖图这样菜名即使有两行卡片也不会忽高忽低。裁剪时注意保留菜品主体居中不要裁掉盘边装饰让每个菜品的视觉重心一致。图标元素比如购物车、加号、返回箭头能矢量化就矢量化。导出时命名规则采用“模块_状态”的方式比如btn_add_default.png、btn_add_active.png对应普通态和点击态。这套命名习惯看似啰嗦但真到写前端代码时能省大量沟通时间——前端拿到的每一张图都知道该放在哪里、什么状态下用。还有一个小细节导出位图时一定要用2倍图或3倍图Menu Adobe用的是2x和3x双套导出前端根据设备DPR自动切换。安卓千奇百怪的屏幕密度单靠一张图要么发虚要么下载浪费流量。3.3 交互视觉边界哪些效果要在前端做哪些要设计先给纯静态设计稿完成只是第一步真正考验设计功底的是状态稿。我第一次接数字菜单项目时没做状态稿结果前端实现“点击加购”效果时不知道按钮按下后要变什么颜色、加载中显示什么、售罄时置灰成什么样全靠现场临时商量效率极低。这次我提前定义了四个必须有的状态默认态正常展示按钮是主色点击态按钮按下的瞬间颜色加深或缩小反馈要快加载态图片加载中显示灰色占位售罄态整张卡片半透明按钮变成“已售罄”灰字状态稿不用每张都做成高保真一张卡片做四个版本的示意图就行。前端拿到示意图加上设计规范里的颜色参数就能完整还原交互细节。4. 前端实现从设计稿到可点击的H54.1 页面结构头部、分类区、菜品区拿到设计稿以后我先把页面拆成了三个核心区域顶部公告、中间分类导航、菜品列表区。这个结构不复杂但有一个布局上的关键点——分类导航要吸顶菜品列表要能独立滚动。实现时用的是很传统的方案整个页面是一个主滚动容器分类导航是position: sticky粘性定位。具体结构如下div classmenu-page header classstore-notice午市高峰期出餐约15分钟/header nav classcategory-nav idcategoryNav button>function renderDishes(categoryId) { const dishes menuData.dishes.filter(d d.categoryId categoryId); const html dishes.map(d div classdish-card ${d.soldOut ? sold-out : } img classdish-img src${d.image} alt${d.name} loadinglazy / div classdish-info h3${d.name}/h3 p${d.desc}/p div classdish-tags${renderTags(d.tags)}/div div classdish-bottom span classdish-price¥${d.price.toFixed(2)}/span button classadd-btn/button /div /div /div ).join(); document.getElementById(dishList).innerHTML html; }这种“JSON驱动渲染”的小函数是后面一切改动的基础加菜、改价、调顺序都只需改JSON完全不用碰结构。4.2 吸顶分类导航与滚动联动的实现思路分类导航光吸顶还不够更近一步的需求是当顾客滚动到“沙拉”区域时顶部的Tab要自动高亮“沙拉”。这个功能看起来简单但用传统的scroll事件监听加offsetTop计算代码量不小且容易抖动。我用了一个更现代的API——IntersectionObserver。思路是监听每个菜品分组区块和可视区的交叉比例哪个区块最“显眼”就把对应的Tab置为高亮const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const activeGroupId entry.target.dataset.groupId; setActiveTab(activeGroupId); } }); }, { rootMargin: -20% 0px -70% 0px }); document.querySelectorAll([data-group-id]).forEach(el observer.observe(el));这里的rootMargin是关键参数它定义了一个“判断窗口”页面顶部往下20%到30%的这个水平条区域进入这个区域的区块就会被判定为当前活跃区块。如果觉得交叉逻辑不好调也可以用传统方案监听页面滚动事件计算每个区块的offsetTop和当前位置的距离取绝对值最小的那个。传统方案兼容性最好但性能比IntersectionObserver差一点。移动端页面滚动频繁scroll监听容易导致掉帧。建议优先用IntersectionObserver老旧浏览器再降级到滚动监听。4.3 图片懒加载与加载占位菜单页的图片数量一般有几十张如果一次性全部加载首屏速度和流量都很难看。原生懒加载是最舒服的方案——给img标签加loadinglazy就能获得浏览器级懒加载支持完全不用自己写判断代码。但原生懒加载有一个体验问题图片进入视口后才开始加载在加载完成前有一个空白缺口文字内容已经渲染出来了图片位置却是空的。餐饮菜单这种“图片引导决策”的页面这种情况会影响浏览心情。我采用的方案是背景色占位加模糊过渡给图片容器设定一个固定高宽和浅灰背景色图片加载完成后从透明度0过渡到1。这样即使加载有延迟也不会出现白色空洞感视觉上是平滑的“内容浮出”效果。还有一点容易被忽略菜品图片的URL必须加上尺寸参数不能直接丢原图。我的CDN配置里菜单图统一请求750px宽度的裁剪图原始高清图只用于后台编辑界面。这样一张图从几百KB降到几十KB滚动时明显更流畅。5. 多端适配与二次开发从小屏幕到大屏幕从静态到动态5.1 响应式布局的几个关键断点这个项目上线目标是H5但实际使用场景并不只有手机顾客可能用平板看菜单也可能在电脑上打开链接浏览。我做了三个断点的适配方案断点目标设备布局处理 480px小屏手机单列卡片导航Tab平铺可横滑480px - 768px平板卡片改为两列网格导航Tab居中 768px桌面浏览器整体宽度限制在960px左右留白导航固定在顶部独立横栏这里的关键是不要一上来就写媒体查询而是先想清楚“大屏本质上缺的是什么”。手机上占据半屏的顶部公告在大屏上可以改成一行不换行的小字手机上靠滑动切换的分类Tab在大屏上可以直接全部平铺让用户一眼看到所有分类。响应式不只是改宽度更是改信息的呈现密度。CSS方面我用了比较老派但足够用的方式优先写移动端样式然后通过min-width媒体查询逐步升级。逻辑简单不容易写出互相覆盖的复杂网络。media (min-width: 480px) { .dish-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; } } media (min-width: 768px) { .menu-page { max-width: 960px; margin: 0 auto; } .category-nav { position: static; display: flex; justify-content: center; } }5.2 把静态JSON换成后端API第一版菜单数据是静态JSON文件写死在项目里的好处是部署简单、启动快速坏处是一旦菜品价格变动就要重新改文件、部署、刷新浏览器缓存。所以项目跑通以后我立刻加了一层薄薄的Node接口。接口设计非常朴素只有两个GET /api/menu返回完整菜单数据公告、分类、菜品POST /api/order接收前端购物车提交的订单数据前端从静态JSON切换到接口只改了一处数据获取逻辑async function loadMenu() { try { const res await fetch(/api/menu); if (!res.ok) throw new Error(菜单加载失败); menuData await res.json(); renderAll(); } catch (err) { // 接口异常时回退到内置兜底数据 menuData fallbackData; renderAll(); } }项目里必须有一个兜底数据fallback data这是我的硬性习惯。做菜单类项目接口返回慢或者后端服务挂了顾客打开页面是一片空白那是致命的。内置一份默认菜单兜底虽然可能不是最新价格但至少页面能打开、菜单能看、电话能联系上。5.3 加购、购物车与后台联动设想菜单页的加购功能是这一类项目里最能提升转化率的环节。我的实现是点击“”按钮时把对应菜品信息写入浏览器本地存储底部购物车栏实时更新数量。本地存储的好处是顾客即使误刷新页面已选的菜还在不需要重新挑一遍。购物车数据用本地存储保存核心逻辑如下function addToCart(dishId) { const cart JSON.parse(localStorage.getItem(cart) || []); const existing cart.find(item item.dishId dishId); if (existing) { existing.quantity 1; } else { cart.push({ dishId, quantity: 1 }); } localStorage.setItem(cart, JSON.stringify(cart)); updateCartBar(); }结算时把购物车数据POST到后端接口门店后台收到订单后再人工确认。这套流程没有接入支付原因很简单小门店的支付链路牵扯到商户资质、对账、退款复杂度比菜单展示高一个量级。先用人工确认的方式跑起来等单量上来并且老板觉得需要自动化了再接入支付模块也不迟。6. 踩坑记录上线后真实用户磨出来的细节6.1 长菜名换行与卡片高度抖动第一个让我印象深刻的bug来自一道“素食牛油果烤南瓜羽衣甘蓝能量沙拉碗”名字特别长在部分手机上一行放不下折行后整个卡片高度被撑开下面的卡片跟着错位。设计稿里根本不会出现这种极端数据但真实菜单里就是会有。我的解决办法分两步先给菜品名称限制最多显示两行超出部分用省略号再给图片容器设定固定的宽高比让卡片高度不依赖文字内容。这两步之后无论菜名多长卡片高度都保持稳定列表滚动不会再跳来跳去。6.2 多规格菜品的价格展示策略轻食店有一道“烤鸡胸配时蔬”分“半份”和“整份”两个规格价格不同。最初的数据模型里每个菜品只有一个price字段遇到这种多规格的直接无解。后来我把价格字段升级成了两种类型{ price: 28, priceType: fixed, specs: [] }, { priceType: range, priceRange: 22 - 32, specs: [ { name: 半份, price: 22 }, { name: 整份, price: 32 } ] }前端遇到固定价格就显示¥28遇到区间价格就显示¥22 - ¥32顾客点进详情再选具体规格。列表页不会因为价格格式问题炸掉详情页又能提供完整选择。6.3 图片压缩的“度”到底在哪里图片优化是上线后最容易被忽略的体验优化项。我在第一次压图时走了极端把所有图片压到20KB以下结果菜品图糊成一片顾客根本看不清食材严重拉低下单意愿。后来反复试找到一条经验线宽度750px的菜品图质量60%到70%体积约30KB到50KB清晰度完全够用格式优先用WebP兼容性不够时回退到jpg图标和装饰元素尽量用CSS实现少用图片这个“度”不是固定的要结合菜品的真实颜色丰富度来调。生菜和鸡胸这种大色块内容压缩率高一点也不会太难看但如果有带细碎纹理的食材比如烤痕、芝麻粒压缩率过高就会完全失真。上线前我给每个类目的图片都抽了三张做真机预览确认视觉可接受才放行。6.4 版本号与缓存更新H5页面部署最头疼的问题是缓存顾客的手机浏览器可能把旧版本页面缓存了好几天店家改了菜单价格顾客打开还是旧价格引起纠纷。解决方式是在引用CSS和JS文件时加上版本号参数。我用的方式很简单不用复杂的打包工具而是在HTML里手动给资源链接加版本参数。link relstylesheet href/css/menu.css?v20240915 / script src/js/menu.js?v20240915/script数据接口层面给菜单接口加一个Cache-Control: no-cache响应头让浏览器每次重新验证数据是否更新而不是直接读缓存。对菜单这种“价格一变就是大事”的场景宁可多加载一秒也绝不能让顾客看到过期价格。7. 写在最后这个项目留给我的三个经验菜单项目做下来最大的体会是技术细节再复杂也都是一行行能查到的代码真正的工程难点在于要不要做、做到什么程度、怎么给别人维护。第一给这种小体量项目做技术选型时先问客户“你最怕什么”。这家店主最怕的是单子乱、平台抽成高、菜单改不动所以我全部方案都围绕这三个痛点展开。如果只从技术视角出发很容易上一堆花哨功能最后发现没解决核心问题。第二交付不能只给代码一定要给一份“配置说明书”。菜单项目上线后日常维护人是店主不会写代码所以我写了一份只含“改价格”“加菜品”“换图片”三个章节的文档每章都用截图加文字说明每一步操作不超过三步。有人觉得这种文档浪费时间但我认为这才是项目真正能运转起来的关键。第三上线只是一个开始下一步要持续观察真实用户的行为数据。我当时在上线后每隔几天就统计一次哪些菜品被看但没下单哪个分类的用户停留时间短加购到下单的转化率是多少。这些数据才是菜单下一轮迭代的依据——可能是调整排序、优化描述、替换主图也可能需要把某些菜挪到更显眼的位置。“Menu Adobe”这个名字的另一个寓意是用最基础的材料也能盖出像样的房子。希望这篇文章里的实现细节和踩坑记录能帮你少走几步弯路把属于自己的那份数字菜单稳稳地搭起来。
阅读完成 · 觉得有帮助?