去年秋天接了个活儿,一家做轻食外卖的店找到我,说想把菜单墙上那一大张花花绿绿的图片换掉,做成手机能打开、能滑动、能直接加购物车的页面。前后折腾了一个多月,最后落地的这套东西我给它起了个名字叫“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. 前端实现:从设计稿到可点击的H5
4.1 页面结构:头部、分类区、菜品区
拿到设计稿以后,我先把页面拆成了三个核心区域:顶部公告、中间分类导航、菜品列表区。这个结构不复杂,但有一个布局上的关键点——分类导航要吸顶,菜品列表要能独立滚动。
实现时用的是很传统的方案:整个页面是一个主滚动容器,分类导航是position: sticky粘性定位。具体结构如下:
<div class="menu-page"> <header class="store-notice">午市高峰期出餐约15分钟</header> <nav class="category-nav" id="categoryNav"> <button>function renderDishes(categoryId) { const dishes = menuData.dishes.filter(d => d.categoryId === categoryId); const html = dishes.map(d => ` <div class="dish-card ${d.soldOut ? 'sold-out' : ''}"> <img class="dish-img" src="${d.image}" alt="${d.name}" loading="lazy" /> <div class="dish-info"> <h3>${d.name}</h3> <p>${d.desc}</p> <div class="dish-tags">${renderTags(d.tags)}</div> <div class="dish-bottom"> <span class="dish-price">¥${d.price.toFixed(2)}</span> <button class="add-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标签加loading="lazy"就能获得浏览器级懒加载支持,完全不用自己写判断代码。
但原生懒加载有一个体验问题:图片进入视口后才开始加载,在加载完成前有一个空白缺口,文字内容已经渲染出来了,图片位置却是空的。餐饮菜单这种“图片引导决策”的页面,这种情况会影响浏览心情。
我采用的方案是背景色占位加模糊过渡:给图片容器设定一个固定高宽和浅灰背景色,图片加载完成后从透明度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 rel="stylesheet" href="/css/menu.css?v=20240915" /> <script src="/js/menu.js?v=20240915"></script>数据接口层面,给菜单接口加一个Cache-Control: no-cache响应头,让浏览器每次重新验证数据是否更新而不是直接读缓存。对菜单这种“价格一变就是大事”的场景,宁可多加载一秒,也绝不能让顾客看到过期价格。
7. 写在最后:这个项目留给我的三个经验
菜单项目做下来,最大的体会是:技术细节再复杂,也都是一行行能查到的代码,真正的工程难点在于要不要做、做到什么程度、怎么给别人维护。
第一,给这种小体量项目做技术选型时,先问客户“你最怕什么”。这家店主最怕的是单子乱、平台抽成高、菜单改不动,所以我全部方案都围绕这三个痛点展开。如果只从技术视角出发,很容易上一堆花哨功能,最后发现没解决核心问题。
第二,交付不能只给代码,一定要给一份“配置说明书”。菜单项目上线后,日常维护人是店主,不会写代码,所以我写了一份只含“改价格”“加菜品”“换图片”三个章节的文档,每章都用截图加文字说明,每一步操作不超过三步。有人觉得这种文档浪费时间,但我认为这才是项目真正能运转起来的关键。
第三,上线只是一个开始,下一步要持续观察真实用户的行为数据。我当时在上线后每隔几天就统计一次:哪些菜品被看但没下单,哪个分类的用户停留时间短,加购到下单的转化率是多少。这些数据才是菜单下一轮迭代的依据——可能是调整排序、优化描述、替换主图,也可能需要把某些菜挪到更显眼的位置。
“Menu Adobe”这个名字的另一个寓意是:用最基础的材料,也能盖出像样的房子。希望这篇文章里的实现细节和踩坑记录,能帮你少走几步弯路,把属于自己的那份数字菜单稳稳地搭起来。