1. 灵感:一个两端门槛差得太远的场景
我是个大学生,学校离家一千多公里。
中秋和国庆挨着,假期看着不短,可来回的车票加上路上的时间,真正能在家待的不到两天。我妈打电话来问「中秋回不回来」,我说「看看吧」——其实这句「看看吧」,就已经是答案了。
中秋那天晚上,宿舍里其他三个人都走了。我一个人点了外卖,坐在书桌前跟我爸妈视频:他们那边是一桌菜,我这边是一个外卖盒。聊了不到十分钟,我妈说「你先吃吧,菜要凉了」。
挂了视频想再跟家里说点什么,打开微信又不知道从哪下手:
-
打视频? 长辈那边得先找眼镜、再点两下接听,画面还抖,聊三句就变成「信号不好,你说啥」。我奶奶到现在都不会自己接微信视频,每次都得我爸在旁边帮她点。
-
发语音? 六条三十秒的语音躺在聊天记录里,长辈得一条一条点;第二天想再听一遍,翻半天找不到了。
而「给家里看一眼今晚的月亮」这件事,其实特别自然——中秋的月亮,本来就是给远方的人看的。 那天晚上操场上有不少人,都在举着手机拍月亮。
所以我想做的是:录一段话、拍一张今晚的月亮,生成一封「月光家书」;长辈点开就能听到孩子的声音、看见孩子的此刻,然后用同样简单的方式录一段回话。
做的时候我慢慢发现,真正的难点不在「发祝福」,在两端的使用门槛差得太远:
-
年轻人这边要快——不注册、不装 App、不登录,打开就能录。
-
长辈那边要更简单——字要大、按钮要大、没有一步需要思考,最好一辈子只需要点三个按钮。
微信当然能发语音,但语音是碎片:没有仪式感,装不下照片,也不适合长辈反复找出来听。这两件事的差别,就是我这次想填上的空。
2. 我做了什么
「月光家书」——一个纯前端的中秋情感互动页面,打包成一个单文件 HTML。
四个页面,用 display 切换,没有路由、没有后端、没有一行外部依赖。
① 首页
一句话说清这是什么,两个按钮直接分岔:「
写一封家书」给年轻人,「
我是长辈,听家书」给长辈。首页不解释功能,只负责把人分流到对的那条路上。
② 写家书(三步)
顶部三个步骤点显示进度,中间只做一件事:
-
第一段:录音。 一个大麦克风图标 + 计时器(
00:00格式),点一下开始录,最长 60 秒自动停。录的时候按钮变红并脉冲呼吸,录完出现播放器可以试听,然后自动进入第二步——不让人多做一次「下一步」的判断。 -
第二段:照片。 2×2 虚线格子,点一下直接调后置摄像头。最多 4 张,至少 1 张才能继续(不够就把按钮置灰,不弹窗讲道理)。已放的缩略图右上角有小叉,点一下删掉。
-
第三段:生成。 先给一张家书预览卡(月亮 + 「月光家书」+ 「来自:XXX」+ 照片),再让填名字,最后点「
生成家书」。旁边永远有一个「重新写」,一键清空回到第一步。
③ 收家书
一个信封、两个按钮:「
打开最新家书」和「
开启大字模式」。
这里我加了一个不起眼但很重要的细节:长辈点过一次大字模式,下次打开还是大字。 偏好存在本地了——不能让长辈每次进来重新找一遍那个按钮。
④ 家书展示
家书卡片:月亮 + 「月光家书」+「来自:XXX」+ 音频播放器 + 照片网格 + 「写于 9月28日 20:15」。
下面一张「
回一句」卡片,长辈可以录一段回话,录完点「
保存回话」,页面提示「回话已保存,孩子能听到了
」。底部一个「
回到首页」。
它长什么样
-
纯原生 HTML + CSS + JavaScript,零依赖、零后端,就是一个 HTML 文件
-
双击就能打开,不用装环境、不用起服务、断网也能用
-
数据全部存在浏览器本地,不上传任何服务器——一家人的声音和照片,我觉得不该经过别人的服务器
-
手机上打开同一个文件就能正常用
3. 几个我比较满意的技术取舍
① 录音格式不能写死。
MediaRecorder 在不同浏览器上支持的容器不一样:iOS Safari 给的是 audio/mp4,安卓和桌面 Chrome 给的是 audio/webm;codecs=opus。写死一个格式,必然有一半设备录出来是坏的。
我的做法是先探测一圈,取第一个浏览器认的:
录完立刻 URL.createObjectURL(blob) 生成可播放地址——这样试听不用等转码,点开就响。
② 照片先压再存,不然 localStorage 一定会炸。
手机拍一张原图 3~5 MB,FileReader.readAsDataURL 转成 base64 还要再涨 33%。四张就是二十多兆,而 localStorage 一般只给 5 MB。
所以存之前先过一遍 canvas:等比缩到最长边 1280px,JPEG 质量 0.82。肉眼几乎看不出差别,单张从 4 MB 掉到 200 KB 上下。
③ 存不下的时候,不假装存下了。
这是我觉得最该做对的一处。
base64 的照片和音频加起来仍然可能超配额,而 localStorage.setItem 超限是抛异常的——一个没接住的异常,用户看到的就是「点了生成没反应」。
所以我写了三级降级:
关键是名字和时间永远保住——这两个丢了,这封家书就不知道是谁、什么时候写的了。
同时,本次会话里照片和音频还留在内存变量里,所以刚生成完的家书照样能看能听,只是关掉页面再打开会变成纯文字。而且我会明说一句「内容太大,已只保存文字信息」,不让人对着空白猜。
④ 长辈的回话单独存一个键。
一开始我把回话塞进家书对象里一起存。问题很快就暴露了:如果写回话的时候正好超限,降级逻辑会把家书本身的照片冲掉——长辈回一句话,把孩子的照片弄丢了。
所以回话单独存 moonletter.reply.v1,写失败最多是回话没留下,动不到家书本体。
⑤ 没有家书的时候,也得能演示。
长辈第一次点「打开最新家书」,本地什么都没有。这时候如果弹一句「暂无数据」,这个页面在他眼里就是坏的——他不知道接下来该干嘛。
所以我内置了一封演示家书,而且完全离线生成:
-
照片是两张内联 SVG,用渐变画的月亮夜景(一张挂在屋顶上方,一张落在水面上);
-
音频是我用
Float32Array直接合成的一段五声音阶小旋律,手写了 16bit WAV 的文件头,再用btoa转成 dataURL。
整段代码不依赖任何外部资源,打开就有声音、有画面。合成失败也做了兜底,退回成「只有照片没有录音」的文案,不影响主流程。
⑥ 长辈的按钮,一个都不能小。
-
所有主按钮
min-height: 56px,点击时scale(.96)缩放反馈——按下去要有回应,长辈才知道自己按到了。 -
进度条本身只有 8px 高,但用伪元素把触摸区域上下各撑开 18px,手指按得中。
-
照片格子右上角那个删除叉只有 30px,同样用伪元素把可点区域扩到 44px 以上。
-
「大字模式」不是加个
zoom完事:全站字号用em写,切换时只改body的font-size,文字、按钮、间距跟着一起放大,不会出现「字大了但按钮还是小的」。 -
适配上加了
viewport-fit=cover和env(safe-area-inset-bottom),刘海屏底部按钮不会被系统横条压住。
4. 我是怎么用 TraeCode 做的
整个东西基本是 TraeCode 一路对话做下来的。摘几条我实际发过去的需求:
开局定基调:
做一个移动端网页「月光家书」,中秋主题的情感互动页面。深蓝夜空背景(#0a0e27 到 #1a1f3a 渐变),带细碎星星(用 CSS radial-gradient 做),右上角一个发光的月亮(radial-gradient 做球体 + box-shadow 做光晕,带呼吸动画)。主色调暖黄 #ffe082 / #ffb300 配白字。所有卡片用 rgba(255,255,255,.06) 半透明背景 + 圆角 18px + backdrop-filter 模糊。整体感觉:安静、温暖、有仪式感,不要花哨。
定页面结构:
用 display 切换做四个页面,单文件:首页、写家书(三步:录音 / 照片 / 预览生成)、收家书、家书展示页。写家书用步骤点显示进度。
定录音交互:
点击开始录音,用 MediaRecorder API 录音,最长 60 秒。录音时显示计时器(00:00 格式),按钮变成「停止录音」并带脉冲动画。录音完成后显示音频播放器:圆形播放/暂停按钮 + 进度条 + 时间显示,可以点击进度条跳转。录音完成后自动进入第二步。
定长辈友好:
移动端适配:viewport meta 加 user-scalable=no,所有点击区域至少 44px 高。
定数据存储:
家书数据用 localStorage 存。注意 base64 照片可能较大,如果超限就只存文字信息,照片用内存变量。
收尾约束:
不要用任何外部库,纯原生实现。不要省略任何代码,完整输出。
做的时候最费劲的地方
第一个坎:录音在 iOS 和安卓上不是同一件事。
一开始只写了 new MediaRecorder(stream),安卓上正常,到 iOS 上 onstop 里拿到的 Blob 类型是空的,播放器加载不出来。后来加了格式探测(见上面第 3 节①),两端才都稳。
第二个坎:演示音频到底要不要做。
我一开始想跳过——「演示家书有照片就行了」。试了一下就发现不行:长辈点开一封家书,播放器是个摆设,那这个页面在他眼里就是坏的。
最后还是决定手写 WAV 编码。写完其实只有四十行左右,但换来的是打开就有声音。
第三个坎:自动跳步和「想再听一遍」打架。
需求写的是「录音完成后自动进入第二步」。真用起来会发现:录了 40 秒的人,通常想先听一遍自己说得怎么样。1.8 秒就把他推走,体验是断的。
最后的处理是:自动跳步保留,但步骤点做成可点击的。被推走的人点一下「1」就回到录音页,试听区和「重新录一段」都还在原处。既保证了流程顺畅,也没有把人锁在流程里。
第四个坎:连点两次「开始录音」。
手机上手指重,很容易在麦克风权限弹窗还没消失的时候再点一下,结果开出两路录音,Blob 里混进两段音。
加了个 starting 标志位:从 getUserMedia 发起那一刻起就锁住,直到拿到流或报错才放开。同一类问题还有一处——重录之前要先 pause() 掉正在播放的试听,不然新旧两段声音会叠在一起。
5. 成果展示
1. 首页
深蓝夜空 + 右上角呼吸的月亮 + 两个分岔按钮。一张图能说清「这是给谁用的」。
2. 录音中(红色脉冲按钮 + 计时器)
核心动作。注意按钮是红的、带呼吸光晕,计时器在走。
3. 录音试听播放器
圆形播放键 + 进度条 + 时间,进度条拉到中间,看得出进度是活的。
4. 照片 2×2 网格(放了 2 张 + 2 个虚线占位)
虚实对比一眼看懂「还能放几张」。
5. 家书预览 + 生成
月亮 +「月光家书」+ 照片缩略图 + 名字输入框。
6. 家书展示页 + 回话录音
一屏里同时有「孩子的家书」和「长辈回一句」——这是整个作品的核心闭环。
7. 大字模式前后对比(左:普通字号 / 右:大字模式)
同一个页面切一次字号,字、按钮、间距一起放大。这是「给长辈用」最直接的证据,别省。
8. 录屏 GIF:完整走一遍录音 → 照片 → 生成

点开始录音 → 说两句 → 停止 → 试听 → 自动跳到照片页。活动允许 GIF,而录音这个核心交互,一张静态图说不清楚。
最后
做完这个我最直接的感受是:「让长辈也能用」不是一句文案,是一堆很具体的尺寸。
44px 的可点区域、按下去要有回弹、字大了按钮也得跟着大、上次开过大字模式下次要记得……这些东西一个都不「酷」,但少一个,长辈就会在某一步停下来。
中秋的月亮每年都一样,能把它寄回去的方式,可以更好一点。
如果你也是在外地上学、中秋回不去的那种人,可以拿去录一段。它不联网、不上传、不注册,所有东西都在你自己的手机里。
做完那天晚上我给我妈发了一次。她没回文字,回了一条语音——「听到了,你那边月亮也挺圆的」。








