乐于分享
好东西不私藏

蹭饭图生成器 2.0 技术文档

蹭饭图生成器 2.0 技术文档

蹭饭图生成器是一个纯前端应用:React + TypeScript + Vite 构建,部署在 Cloudflare Pages 上,后端只有几个轻量的 Pages Functions 接口和一个 D1 数据库。你的名单数据只存在你自己的浏览器里,服务器永远不接触它。

排版引擎

画布是这款产品的主体,它的排版引擎也是改动最密集的部分。下面几节几乎每一节都对应一次真实的返工。

■ 会呼吸的画布

画布的边界不是固定的。它每一帧都等于「地图本体与全部卡片的内容包围盒,向四周各留 18 像素」。卡片向外拖,画布跟着长;卡片向内收,画布跟着缩。左右两侧的留白可以不一样多——因为边界贴的是内容,不是地图的中轴线。

这个设计取代了一个更「聪明」的旧方案:按内容宽度算出合适的留白。旧方案有一个隐蔽的 bug——当卡片超出预估宽度时,留白会被算成负数,卡片直接溢出画布。包围盒方案在数学上免疫这类问题,因为它不依赖任何「预估」。

■ 拖动时不漂移的坐标系

拖动卡片时画布会实时扩大,而画布的扩大会改变「屏幕像素」和「画布坐标」之间的换算比例。如果每一帧都用最新比例换算,拖动的位移会被反复放大——实测拖 60 个屏幕像素,落库变成了 228 个画布像素,卡片越拖越快、最后飞出去。修复方法是在手指/鼠标按下的那一刻锁定当时的比例,整个拖动过程都用这个固定坐标系换算。你感觉不到它的存在,这正是它该有样子。

■ 辅助线与吸附

拖动卡片时出现的对齐辅助线,吸附阈值是 6 个画布像素,约等于屏幕上的 5 像素。横向上,被拖卡片的左缘、中线、右缘会分别对同侧卡片的三种边缘求最近距离,外加一个「回到列位」的候选;纵向上,候选来自左右两侧的所有卡片——曾经有一版只在同侧列里找候选,于是出现了「左边卡片的省份和右边卡片的省份之间没有辅助线」的问题,被一位用户准确地指了出来。

辅助线本身有 16 像素的引导窗口:拖得足够慢才能看到它。这不是缺陷,而是刻意为之——吸附应该奖励精细操作,而不是在快速拖动时不停闪烁。

■ 四种排版方式,切换零跳变

一列、两列、竖版、自定义,四种排版共享同一套数据:每种自动模式负责算出「基准位置」,你的拖动只记录一个「相对基准的偏移」。因此从两列切到自定义时卡片原地不动,从自定义切回一列时偏移清零、回到整齐的列——切换是模式切换,不是数据迁移。竖版模式下卡片按质心经度自北向南排序、流式分行整体居中,画布向下扩展。

■ 一个省,可以拆成任意多张卡片

卡片拆分的数据结构是一张映射表:省份名 → 若干张卡片,每张卡片是一个有序的学生名单。极端情况下一个省 20 个人可以拆成 20 张卡。卡片在系统里的键从「广东省」变成「广东省#1」「广东省#2」,拖动位置、对齐方式、尺寸都按键独立存储;而地图填色时按「#」前的真省份去重,所以拆得再碎,地图上的高亮也不会丢。拆分还可以按城市进行,同城卡片的标题自动变成「广东省 · 深圳」,此时学生行里重复的城市名会被剥掉——标题和行内文案共用同一份派生数据,永远不会出现「标题说单一城市、行内却另算一套」的不一致。

■ 统一宽度,一个按钮

「以最宽的卡片为准统一所有卡片宽度」听起来简单,实现时要分别处理三种布局分支:分列模式替换两侧的宽度数组;竖版模式在换行完成后统一放宽(放宽不影响行数);西南角落的零散区用同一个最大值,放不下时照旧回退到分列。三种分支共用一次取最大值,保证任何布局下结果一致。

■ 人数小块

卡片角落的人数统计默认是关的——所有装饰性元素在这款产品里都默认关,由你主动开启。它的位置有一个「自动」档:卡片左对齐时它站到右上,右对齐时站到左上,永远避开标题文字所在的一侧。拆分的卡片按真实省份合并计数。

■ 八向调整大小,对侧边缘不动

选中卡片后,四条边、四个角都能拖拽调整。拖东边和南边很直观;拖西边和北边时有一个几何细节——对侧的边缘必须钉在原地。实现上,新宽度由反向位移算出,卡片整体同时做一个补偿平移;松手时这个平移量并入卡片的持久化偏移。另外有一个克制的设计:向东、向南拖且卡片从未被手动定位过时,系统只改尺寸,不会悄悄把卡片切换成「自定义位置」模式。尺寸永远不会小于内容的自然尺寸——只能放大,不会裁掉任何人的名字;缩回到自然尺寸附近时,这条覆盖记录会自动删除。

■ 老师块与海外块

这两个特殊块共享同一套拖动交互(桌面直接拖、触屏先选中再拖、导出前自动清除选中态),但限幅方向相反:老师块左锚定,海外块右锚定。这里也有一个用真实数据换来的教训:老师块的纵向偏移曾有一个 ±300 像素的硬编码限幅,一位用户的旧数据恰好停在 -295,于是向上拖不动、松手被夹回 -300,看起来就像「弹回原位」。现在的限幅由布局实测值动态推导,存储层只留一个数量级 sanity cap——硬编码的边界,在长期迭代中总会被真实数据顶到。

后来加入的画布装饰元素(自由文本框与图片)复用了这套拖动,但做了一个关键差异:装饰被限幅在画布之内、不会撑大画布。能撑大画布的是内容,装饰只是内容之上的点缀。

■ 引线与分割线

从地图指向卡片的引线可以是虚线弧线,端点被夹在「卡片包围盒上离锚点最近的位置」,所以卡片变大变小,线永远贴在边上。同校合并时组与组之间的分割线更讲究一点:它画在两组的行隙里,但几何中点并不等于视觉中点——文字在基线之上伸展约 0.8 倍字号,之下只有约 0.25 倍,画在几何中点的线会贴着下一行字。所以分割线上移了 0.27 倍字号,让上下留白在视觉上相等。它的粗细、颜色、虚线样式与卡片边框完全同参,深浅随主题变化;线宽用 non-scaling-stroke 固定为 1 像素,导出 4000 像素宽的高清图时不会跟着变粗。

分割线的显示规则也修过一次:最初只在「相邻两条都是多人合并组」时画线,但真实名单里多人组往往被单人条目夹着,条件永远不触发。现在只要两侧任意一边是合并组就画线——多人组被「围」出来,全单人的卡片不画,因为不存在分不清的问题。

面板与交互

■ 展开与收缩的动画

录入页的五个分区(班级信息、学生名单、老师名单、画布风格、排版设计)共用同一个折叠组件。动画用的是 CSS 的 grid-template-rows:内容行高在 0fr 和 1fr 之间过渡,配合透明度,200 毫秒,全程没有任何 JavaScript 测量高度——不需要先渲染一遍量出高度再动画,也就不存在测量带来的卡顿和闪烁。

有趣的是展开和收缩的观感并不对称:展开时内容立即以完整布局出现,你的眼睛被新出现的内容吸引,几乎注意不到高度变化;收缩时内容消失,视线只剩下正在合拢的缝隙,动画的存在感反而更强。两个方向跑的是同一条过渡曲线,差别只在你的注意力落在哪里。折叠容器本身的 overflow 处理迭代了三版:最初固定 overflow-hidden,展开后里面的字体下拉浮层被裁掉一截;改成 visible 之后,行内容又把窄屏的右边界撑破。最终的写法是按轴向拆开——横向 clip 保住边界,纵向 visible 放行浮层。CSS 规范里 overflow-x: hidden 会把另一轴的 visible 也算成 auto,所以必须用 clip 而不是 hidden,这是查规范才确认的细节。

■ 折叠态仍然有信息量

分区收起时,标题右侧保留一行浅色摘要:「已填 28 人」「暖阳 · 一列 · 13px」。收起不等于消失,扫一眼就知道里面的状态。常驻操作(比如老师的显示开关)放在折叠按钮之外的独立区域,点开它不会误触展开。移动端的默认展开策略翻转过一次:最初默认折叠次要分区,后来按你的要求改为宁可长、也全部展开——组件为此保留了 mobileOpen 参数,默认策略可以随时再翻盘。

■ 字体下拉弹层

字体选择弹层渲染在页面顶层(portal),不再被任何折叠面板或侧栏裁剪;宽度跟随内容自适应,保证每款字体的全称完整显示,同时右边界被钳制在屏幕内;下方空间不足时自动向上展开。城市选择的下拉有一次典型的组件库坑:它是 portal 到 body 的浮层,而宿主是模态对话框——对话框的滚动锁把对话框子树之外的滚轮事件全部禁掉了,浮层正好在子树之外。修复是一行参数,让浮层建立自己的滚动作用域。

导出管线

导出是整个产品中工程密度最高的模块。它在一个月里经历了三次大的返工,每一次都来自真实的失败日志。

■ 超清的定义

导出图的宽度不少于 4000 像素,与你的屏幕分辨率无关——手机屏幕再小,导出的图也一样清楚。这是刻意的设计:蹭饭图的生命周期在转发里,不在你的屏幕上。下载走 Blob URL 而不是 dataURL,因为几兆字节的 dataURL 直接下载会把浏览器卡死。

■ 一次 12.5 倍的提速

曾经有用户问:为什么导出要将近 10 秒?日志里有两段贴着整千的耗时:「画布字体就绪 +4004ms」「SVG 序列化 +4167ms」。贴着超时上限的整千数字几乎一定是兜底被打满,而不是真的在干活。

第一段的根因:导出前等待的是 document.fonts.ready——它等的是文档里所有挂起的字体,包括根本没用到、还在懒加载的,于是永远等不完,每次导出都把 4000 毫秒的安全超时等满。修复是只显式加载画布实际用到的几个字体家族,并加一层会话级记忆:首次上限 2.5 秒,之后 400 毫秒兜底确认。第二段的根因:渲染库每次导出都重新抓取并 base64 内嵌全部字体子集,约 10MB。修复是把字体嵌入 CSS 做会话级缓存,之后的导出直接注入。

改完后同一张图的总耗时从 9586 毫秒降到 769 毫秒。字体的准备工作后来又往前挪了一步:分片加载和渲染引擎预热在录入名单的空当就悄悄做完,点下「导出为图片」时大半的路已经走完。

■ 假回退,与真回退

主渲染路径是把画布 DOM 序列化成 SVG 再栅格化,序列化过程要给每个元素逐条内联计算样式。画布内嵌着整幅中国地图、数千个 path 元素,在 Safari 上这一步 60 秒也不返回——超时后系统回退到位图导出,但位图回退用的是同一个引擎的同一套样式内联,于是又等满 90 秒超时,彻底失败。回退路径和主路径死于同一个原因,等于没有回退。

真正的修复是分层渲染:地图 SVG 用浏览器原生的 XMLSerializer 直接序列化——它是自包含矢量,属性即样式,不需要逐元素内联,耗时从分钟级降到纳秒级;名单卡片等 HTML 覆盖层把地图换成同尺寸占位块后单独栅格化;背景单独渲染;三层在画布上合成。Safari 直接走这条路,其他浏览器在主路径失败时也会落到这条路。两条路径的导出色逐像素一致。这个案例后来被记进了开发笔记:设计回退时先问一句——如果主路径因 X 失败,回退路径是否也死于 X?

■ 一枚 404 校徽如何杀死导出

个别学校不在校徽库里,图片请求 404,图像元素加载失败,渲染库以一个裸的 Event 对象 reject——日志里只有 [object Event],整张图导出失败。修复分两层:渲染前清点所有图像,加载失败的移除、有缓存的改内联,导出从此不被单张图片绑架;错误格式化统一走一层「Event → 可读错误」的转换,提取出失败资源的地址,日志里再也看不到 [object Event]。裸 Event reject 是调试地狱,任何把 DOM 加载错误直接抛出来的库,入口处都必须包这层转换。

■ 导出进程终端

导出时那扇黑色小窗口固定尺寸、支持滚动、内容不可复制——它存在的意义不是让你读懂,而是让你确认程序没有卡住:加载渲染引擎、克隆画布、嵌入字体、逐层栅格化,每一步都在明处。给你看的日志是克制过的简略版;同一时刻,更详细的技术日志在本地持续累积,你提交反馈时可以一键附上。

数据与隐私

■ 数据只在你手里

名单、主题、字体设置、卡片位置,全部存在浏览器的 localStorage 里,不上传任何服务器。这带来一条严格的开发现律:每加一个新字段,必须同时检查四个地方——类型定义、空数据默认值、旧数据迁移、ZIP/分享的序列化。漏掉任何一处,某个老用户的画布就会在升级后丢一块内容。

■ 渲染级匿名

「名字一键隐私」是渲染层的开关,不是数据改写:打开后地图和导出图显示「姓 + 同学」,录入界面仍显示全名,原始名单一个字不动。随时开关,零成本反悔。取姓用 Array.from 按码点切分,生僻字和emoji 姓氏不会被切坏。空姓名的处理也在这里:没填名字的学生在图上不占位、不显示「(未命名)」,连名字后面的间隙都会条件性地收掉;录入侧保留占位,方便你辨认是哪一行。

■ 全量备份

导出 ZIP 时,名单数据是一份 JSON,图片(校徽、毛笔字、装饰图)抽离成 zip 内的独立文件,JSON 里只留相对路径引用;导入时按引用重建,引用不到的图片直接丢弃而不是报错。备份的核心是数据完整性,所以 zip 里不含渲染好的图——图随时可以重新导出。

性能与缓存

■ 拆包:让每次更新只下载 250KB

早期版本每次发版,整个 696KB 的入口包哈希都会变,所有用户要重新下载全部代码。排查后发现包里真正常变的只有业务代码:城市映射表、React 框架、第三方库几乎不变。现在产物被拆成四组:静态数据、React 核心、其他框架代码、业务入口。改一行业务代码重新构建,只有入口包的哈希变化,其余三组纹丝不动——实测每次更新只需重下约 250KB,省了约 65%。582KB 的地图数据从一开始就不在包里,而是作为静态文件带长缓存单独加载。

拆包最大的坑不是漏拆而是误拆:我们曾经把组件库单独拆成一组,结果它和其余库互相引用,构建直接报循环 chunk 错误。Excel 解析、zip 打包、图像渲染这三个大库则始终保持在懒加载通道里,绝不被拆包规则误并入首屏。

■ 校徽:一次访问,永久缓存

2986 所高校的校徽全部自托管在自己的服务器上,文件名带内容哈希,HTTP 缓存策略是 immutable——只要有一个访客加载过某枚校徽,它就在 CDN 边缘节点上永久驻留,之后的访客直接从最近的节点读取。校徽接口本身也带「浏览器 1 小时、CDN 1 天、回源后 7 天内可用陈旧副本」的三级缓存。这套体系上线前,校徽来自第三方站点的实时爬取;在对方服务器承压之后,我们把全部资产收归自有,这也是对产品责任的一次补课。

■ 版本更新检测

每次发版,入口文件的哈希都会变。页面加载时把当前哈希与本地存储的上一个哈希比对,不同(或从没存过)就显示更新进度界面。进度条是有意的「慢」:它按随机小步走到 92% 就放慢脚步,等页面真正加载完成再跳到 100%——假进度不可怕,可怕的是进度条跑到 92% 然后卡在那里,那比没有进度条更让人焦虑。调试用的 code-path 属性在生产包里完全剔除(省了 56KB),需要排障时访问 /debug 这个独立入口。

■ 镜像兜底

Cloudflare 在国内偶发抽风。页面内置了镜像兜底:静态资源加载失败、或 12 秒还没启动完成,就从备用的镜像服务器重新注入入口脚本——注意是「页面不动、资源换源」,入口模块从镜像加载后,它内部的相对引用自然全部走镜像,不需要逐文件处理。兜底有明确的终止态:失败只提示一次,无限重试比白屏更消耗信任。跨域加载模块脚本和字体强制要求 CORS,这是镜像服务器配置里最容易漏的一项,被写进了部署教程的醒目位置。

后端

■ 从 KV 到 D1

后端存储经历过一次整体迁移。最初所有数据都在 Cloudflare KV 里,直到免费额度被烧穿——排查发现最大的写入来源不是用户数据,而是限流计数本身:每个 API 请求的限流都是一次「读 + 写」,限流系统成了最烧钱的模块。另一个隐蔽杀手是错误上报的聚合,一次「读-改-写」就是两次 KV 操作,一个循环报错就能把每天 1000 次的写额度打光。额度烧穿的那个上午,所有日志写入静默失败,而站点看起来一切正常——免费额度的存储故障表现为「部分操作静默失败」,不是整站 503。

现在的分工是:结构化、写多查多的数据全部在 D1 数据库(反馈、日志、统计、错误聚合、分享),限流计数改为 isolate 内存滑窗,零存储写入;KV 彻底下线。迁移保持了接口契约完全不变,前端零改动。过期数据没有定时任务来清——免费层没有 Cron——而是在每次写入时顺手删掉过期行,访问时惰性删除,存储永远有界。

■ 防护:四道闸门,按便宜到贵排序

所有公开写入接口共用同一套防护顺序:同源校验(Origin/Referer 白名单,成本为零)→ 体积闸门(Content-Length 预检 + 实读上限 + 字段逐项截断)→ 限流(全局每分钟 + 单 IP 每分钟)→ 内容清洗与聚合。错误上报的聚合格外值得一提:每种错误按「类型 + 消息 + 堆栈首帧 + 版本」算出 FNV-1a 签名,同签名只累加计数,新签名每天限 200 种——防止随机消息撑爆存储。你永远不应该让最贵的检查跑在最前面。

■ 反馈板:一个迷你 issue 系统

反馈板的数据模型是一台五态状态机(待处理、进行中、已完成、暂不处理、已关闭)加一条对话流水。创建反馈时服务端返回一个一次性凭证,只在你提交的那一瞬间下发一次——之后你凭它评论会被标记「作者」徽标,且只有作者在已完结状态下评论才会自动把帖子重新打开;访客评论不影响状态。管理员的回复、你的追问、所有人的评论都在同一条流水里,公开接口绝不返回凭证字段。列表按「倒序时间戳 + 随机后缀」做主键,字典序即时间序,不需要索引。

观测体系

■ 使用日志

你勾选「附带我的使用日志」时上传的那份日志,记录范围是「上次上传之后至今」;从未上传过,就是「第一次访问至今」。它在本地跨会话累积,容量 200KB,超限时从头部丢弃最旧的 20% 并插入截断标记。内容有四类:面包屑(启动参数、页面路由、网络状态、可见性切换)、控制台输出、脚本错误(带堆栈前两帧)、关键操作的技术细节(导出开始/成功/失败/取消及各阶段耗时)。网络请求只记「方法 + 路径 + 状态码 + 耗时」,路径里的参数一律剥离——校名这样的名单数据永远不会出现在日志里。

埋点遵循一条原则:只记离散决策(开关、按钮、选择、导入导出结果),不记连续值(滑块拖动、取色过程、逐键输入)。日志容量有限时,噪音比缺失更可怕。这套体系的验收标准是拿真实 bug 走一遍:「自定义颜色用不了」这种反馈来了,能不能直接从日志读出答案?读不出,就继续补钩子。

■ 错误自动上报与噪音治理

页面发生任何脚本错误都会自动上报:窗口级错误(捕获阶段才能拿到资源加载失败,因为资源错误不冒泡)加未处理的 Promise 拒绝,sendBeacon 优先、keepalive fetch 兜底,绝不带页面参数和 hash——分享数据在 hash 里。第三方分析脚本自身的内部异常被三层拦截(页面层拦截、上报管道过滤、日志管道过滤),否则错误监控系统会被别人的 bug 淹没,真 bug 反而被挤出每会话 6 条的额度。你的匿名标识复用反馈昵称,行为分析平台据此做自定义用户标记——看到一条反馈,就能按名字找到对应的操作录屏,而这一切都是匿名的。

写在后面

这份文档里的每一个细节,背后几乎都有一条用户反馈、一次线上事故,或者一张截图。工具还在按同样的方式继续生长:你感知到的,我们都会记下来,想清楚,再写出来。

完整源码以 CC BY-NC-SA 4.0 协议开放在 GitHub(署名、非商用、相同方式共享),欢迎查阅与指正。

立即生成你们班的蹭饭图

map.linkbrain.top

蹭饭图生成器的著作权归 © 2026 赤峰二中2026届&海南大学人工智能2026级张新越 所有。

编辑:张新越监制:李翼安测试:刘轩博 商航语