ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

代码表白十款特效实测:单文件HTML与Canvas粒子交付避坑

代码表白十款特效实测:单文件HTML与Canvas粒子交付避坑 代码表白这四个字被短视频刷屏之后我陆陆续续帮朋友、也帮同事的弟弟妹妹改过二十多个版本从最朴素的 C 语言控制台爱心到满屏飞舞的 canvas 粒子。折腾这么多轮下来最深的体会是翻车的从来不是技术难度而是打开方式。很多人花三天调特效最后把一个 html 文件直接丢进聊天框对方点开一片空白故事就到此结束了。所以我这篇不打算堆截图而是把十款我实测能跑、也真的被别人用过的代码表白小特效讲透——每一款用什么技术栈、核心代码长什么样、参数怎么调、在什么设备上会崩以及最后怎么打包成一个点一下就能看的东西。网页零基础的人能照着抄写过几年后端的人也能在粒子数量和性能这块找到可以深挖的空间。1. 先解决对方打开的那三秒再谈特效好不好看我把这件事拆成两个完全独立的工程问题一个是视觉问题特效够不够浪漫另一个是交付问题对方能不能顺利看到。绝大多数教程只讲第一个而实际翻车率最高的恰恰是第二个。你做的爱心粒子再漂亮如果对方点开之后加载了六秒钟还在转圈或者干脆白屏那这六秒里她的情绪已经从惊喜滑到这人是不是发错文件了。1.1 一条底线单文件、不联网、三秒内出画面我给自己定的硬标准是整个作品必须是一个 HTML 文件双击就能打开断网也能看三秒内必须出现第一帧画面。这条标准直接否决掉了几种看起来很酷但很容易死的做法。第一种是引用外部 CDN 的动画库。你在自己电脑上有网跑得好好的对方在电梯里、地铁上、或者公司内网打开外部资源请求超时就白屏。第二种是把图片放在自己的图床上链接一旦失效或者被限速照片墙就变成一排破碎的占位图。第三种是把代码放到代码托管平台让对方先注册再打开——没有人会为了一份告白去走注册流程。正确的做法是CSS 和 JS 直接内联在style和script里图片全部转成 base64 内嵌字体用系统自带的。这样产出的文件通常在 1 到 5 MB 之间通过聊天软件发过去就是普通文件对方点开就是成品。我一般会把总大小控制在 8 MB 以内超过这个体积部分聊天软件在移动网络下会提示文件过大建议在 Wi-Fi 下下载这个提示本身就是一种劝退。1.2 为什么我从不建议直接甩一个仓库链接技术圈的人有个思维惯性东西放到托管平台上链接一发多优雅。但你面对的人大概率不写代码对她来说那是一个陌生的网站、一个全是英文的文件列表、一个不知道点哪里的绿色按钮。而且很多平台的移动端网页会强制弹出在应用内打开的引导层把内容整个盖住。我的替代方案是把 HTML 文件重命名成一个有意义的名字比如给你看个小东西.html然后用聊天软件直接发文件。对方点开后是本地渲染加载速度取决于文件体积而不是网速体验最接近打开一个离线小应用。这个细节听起来很小但它把能不能看到这个变量彻底消掉了。2. 十款表白特效的实测清单难度、体积与适用场景对照先把十款我实际做过的东西列出来。下面这张表是我自己用的选型表你在动手之前先扫一眼避免选了一个和你手上素材完全不匹配的方向。素材决定技术路线不是反过来。编号特效名称技术栈上手难度单文件体积最稳的使用场景1爱心粒子聚合Canvas 2D 参数方程中20 KB电脑、手机通吃最通用2打字机情书纯 JS CSS极低10 KB文字内容多、想慢慢说33D 信封开卡CSS transform 3D低15 KB需要仪式感的开场4心跳节奏爱心纯 CSS keyframes极低6 KB想极简、只有一句话5纪念日计时JS Date 数字滚动低12 KB恋爱周年、生日倒计时6图片网格翻转墙CSS grid 关键帧低视图片而定有合照素材、想一次放多张7字符雨告白Canvas 字符串池中18 KB科技感、程序员自嘲梗8手指跟随拖尾事件监听 对象池中22 KB交互感强适合当面演示9Python 呼吸 GIFPillow 逐帧合成中低500 KB 起需要发到聊天界面直接播放10C 语言控制台爱心数学不等式 ASCII低编译后极小对方也是程序员、懂这个梗2.1 五款零依赖方案是最容易一次成功的打字机情书、3D 信封、心跳爱心、纪念日计时、图片网格翻转这五款的共同特点是不依赖任何渲染引擎用浏览器原生能力就能跑。它们的另一个共同优点是文件极小你甚至可以手写不需要装任何开发环境。这五款里我做得最多的是图片网格翻转墙。原因很现实绝大多数人手上最珍贵的素材是照片而照片本身就是内容动画只要负责把它们一张一张亮出来就够了不需要额外的视觉轰炸。相比之下纯代码特效如果内容不够扎实很容易变成技术炫技但空洞。2.2 需要渲染引擎的五款效果上限高但要调参数爱心粒子、字符雨、拖尾光点这三款都要上 Canvas。Canvas 的好处是能把成千上万个元素画到同一个位图上性能远好于用几千个 DOM 节点代价是所有细节都要你自己算包括动画缓动、边界处理、设备像素比适配。Python 的 GIF 和 C 的控制台爱心属于另一条路线它们不依赖浏览器适合发原文件或者当面在终端里跑给对方看。这张表里我特意标了单文件体积这一列因为它是新手最容易忽略、却直接决定成败的指标。体积超过 10 MB 之后传输和首次打开的体验都会明显变差。3. 爱心粒子特效参数方程、聚合速度与拖尾强度爱心粒子是所有代码表白里出场率最高的一个也是我见过最多人做砸的一个。做砸的典型症状是粒子糊成一团看不出是心或者粒子从四周飞过来像一堆苍蝇再或者拖尾太重屏幕上留下一片紫红色的雾气。问题不在代码量而在三个参数。3.1 心形轮廓从哪来一个参数方程就够了要把几百个点摆成一个心形不需要手绘坐标用经典的参数方程采样就行x 16 · sin³t y 13 · cos t − 5 · cos 2t − 2 · cos 3t − cos 4t其中 t 从 0 取到 2π均匀取样。取样点数量决定了轮廓的精细度900 个点足够在手机上看到平滑的边缘1800 个点在电脑屏幕上更细腻。注意 y 轴要取负号因为 Canvas 的坐标系原点在左上角y 向下为正不取负的话你会得到一颗倒过来的心。这个方程的采样点分布是不均匀的——心尖和两侧的弧线处点会密一些上方凹陷处会稀一些这是数学本身的特性不是 bug反而让心形看起来更有层次。如果你想让它更均匀可以按弧长重新参数化但对这个场景完全没必要属于过度工程。3.2 一份可以双击就跑的完整实现下面这份代码是我从多个项目里精简出来的版本单文件、无依赖、自动适配屏幕直接保存成.html就能用。我在里面同时做了移动端粒子数量降级和拖尾控制这两点后面会详细解释。!DOCTYPE html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width,initial-scale1,viewport-fitcover title给你/title style html, body { margin: 0; height: 100%; background: #0b0b12; overflow: hidden; } canvas { display: block; width: 100vw; height: 100vh; } /style /head body canvas idc/canvas script const cv document.getElementById(c); const ctx cv.getContext(2d); let W, H, DPR; function resize() { // 设备像素比封顶 2再高的倍率只增加像素、不增加观感 DPR Math.min(window.devicePixelRatio || 1, 2); W cv.clientWidth; H cv.clientHeight; cv.width W * DPR; cv.height H * DPR; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } resize(); window.addEventListener(resize, resize); // 用心形参数方程生成目标点 function heartPoints(count) { const pts []; for (let i 0; i count; i) { const t Math.PI * 2 * i / count; const x 16 * Math.pow(Math.sin(t), 3); const y 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t); pts.push({ x: x, y: -y }); } return pts; } // 小屏降级粒子上限直接砍一半这是低端机流畅的关键 const COUNT Math.min(window.innerWidth, window.innerHeight) 768 ? 900 : 1800; const targets heartPoints(COUNT); let parts []; function build() { const s Math.min(W, H) / 42; // 缩放系数让心形始终占屏幕高度的一半左右 parts targets.map((t, i) ({ tx: W / 2 t.x * s, ty: H / 2 t.y * s, x: W / 2 (Math.random() - 0.5) * W * 2.5, y: H / 2 (Math.random() - 0.5) * H * 2.5, r: Math.random() * 1.6 0.8, hue: 330 Math.random() * 30 })); } build(); window.addEventListener(resize, () { resize(); build(); }); let frames 0; function loop() { frames; // 拖尾用半透明底色覆盖而不是每帧清空 ctx.globalCompositeOperation source-over; ctx.fillStyle rgba(11,11,18,0.22); ctx.fillRect(0, 0, W, H); ctx.globalCompositeOperation lighter; for (let i 0; i parts.length; i) { const p parts[i]; // 0.045 是聚合速度抖动让粒子保持活着的感觉 p.x (p.tx - p.x) * 0.045 (Math.random() - 0.5) * 0.6; p.y (p.ty - p.y) * 0.045 (Math.random() - 0.5) * 0.6; // 前 1.5 秒不画让粒子先飞散一会儿再显形 if (frames 20) continue; ctx.beginPath(); ctx.fillStyle hsl(${p.hue}, 90%, ${55 Math.random() * 12}%); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); } requestAnimationFrame(loop); } loop(); /script /body /html3.3 真正影响观感的三个参数代码里能改的数字很多但决定好不好看的只有三个我把它们单独拎出来说。聚合系数 0.045这个数越大粒子越快贴到目标位置越小聚合成心的过程越长。低于 0.02 的时候粒子会飘很久都不成形观众会以为页面卡住了高于 0.1又变成啪的一下突然出现没有过程感。0.04 到 0.06 是我测下来最有慢慢聚拢感觉的区间。拖尾透明度 0.22fillRect里的 alpha 值。它控制上一帧残留多久消失。0.22 大约是六到八帧的残留能形成轻微的光尾。如果你改成 0.05屏幕会迅速积累成一片亮紫色糊状这是新手最常犯的错改成 1.0 又完全没有运动感像静止的图。粒子半径与色相抖动半径我用Math.random() * 1.6 0.8也就是 0.8 到 2.4 像素之间随机取值大小不一让画面有纵深感。色相固定在 330 到 360 之间粉红到正红饱和度 90%亮度在 55% 上下随机浮动——亮度抖动能模拟出闪烁的呼吸感比固定颜色生动得多。注意globalCompositeOperation设为lighter之后粒子重叠的地方会叠加变亮这是这类粒子特效好看的关键但它也会让颜色更容易过曝所以亮度上限不要超过 70%。4. 图片网格动画把照片墙做成会自己翻开的告白如果你手上有一组合照我认为图片网格翻转的性价比远高于纯代码粒子。原因很简单照片是真实的信息动画只需要负责节奏不需要承担内容这个重担。这一款我在好几个朋友的纪念日场景里用过反馈都很稳。4.1 CSS grid 加关键帧错位延迟制造呼吸感核心思路是每个格子做一次轻微的 3D 翻转加缩放同时给每个格子设置不同的延迟让它们像多米诺骨牌一样依次亮起来。div classgrid figure style--i:0img srcdata:image/jpeg;base64,... alt/figure figure style--i:1img srcdata:image/jpeg;base64,... alt/figure figure style--i:2img srcdata:image/jpeg;base64,... alt/figure /div.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 16px; perspective: 900px; /* 透视距离决定翻转的立体感 */ } .grid figure { margin: 0; aspect-ratio: 1 / 1; /* 全部裁成正方形视觉上才整齐 */ overflow: hidden; border-radius: 12px; transform: rotateY(180deg) scale(0.9); opacity: 0; animation: flip 0.7s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; animation-delay: calc(var(--i) * 120ms); will-change: transform, opacity; } .grid figure img { width: 100%; height: 100%; object-fit: cover; /* 裁切而不是拉伸避免人脸变形 */ display: block; } keyframes flip { to { transform: rotateY(0deg) scale(1); opacity: 1; } }这里有两个参数值得说明。错位延迟 120 毫秒是节奏的关键太小了九张图会同时翻视觉上像闪了一下太大了等到最后一张要两秒多观众会失去耐心。如果格子超过十二个我会把延迟降到 80 毫秒保证全部亮完控制在 1.2 秒内。透视距离 900px决定翻转的立体强度低于 500 会有明显的广角畸变边缘的图片会被拉歪。4.2 图片处理这一步最容易被跳过但它决定成片质感我见过最多的问题不是代码写错而是素材没处理。九张照片来自不同设备、不同比例、不同亮度直接塞进网格里会花得像拼贴画。我的处理流程是这样的先统一裁成正方形并且主体居中这一步用任何图片工具都能做然后把长边压到 800 像素、质量压到 75% 左右导出单张控制在 150 KB 以内最后把整体亮度调到一个大致接近的区间。为什么要压得这么狠因为 base64 编码会把体积放大三分之一左右。九张 150 KB 的图内嵌之后HTML 文件大约会到 2 MB 上下这个体积通过聊天软件发送和打开都很顺畅。如果你偷懒直接内嵌九张手机原图文件轻松突破 30 MB整个方案就崩了。4.3 让动画在进入视口时才触发如果照片墙在页面下方而动画在页面加载时就开始播等对方滚动到那里的时候早就播完了。这时候用IntersectionObserver更合适等元素进入可视区域再手动加上动画类。const io new IntersectionObserver((entries) { entries.forEach((e) { if (e.isIntersecting) { e.target.classList.add(play); io.unobserve(e.target); // 触发过一次就取消观察避免来回滚动重复播放 } }); }, { threshold: 0.2 }); // 元素露出 20% 就开始不用等完全进入 document.querySelectorAll(.grid figure).forEach((el) io.observe(el));配合把 CSS 里的animation改成.play类下的声明就实现了滚到哪翻到哪。threshold: 0.2这个值我试过几轮0.5 会让动画在小屏上显得迟滞0.1 又容易在元素刚露出一个角时就触发。5. Python 与 C 的两条低成本高杀伤路线不是所有场景都需要浏览器。有两种情况我反而推荐离开网页一是对方也是写代码的二是你想把成品直接发到聊天界面里就能看而不是让对方下载文件再打开。5.1 C 语言控制台爱心三十行以内的经典不等式这段代码在程序员圈子里流传了很久核心是把平面上的点代入一个不等式判断是否在心形内部。它之所以好用是因为整个实现没有图形库依赖编译出来只有几 KB。#include stdio.h int main(void) { /* 用不等式 (x^2 y^2 - 1)^3 - x^2 * y^3 0 判定点是否在心形内 */ for (double y 1.4; y -1.4; y - 0.08) { for (double x -1.4; x 1.4; x 0.035) { double a x * x y * y - 1.0; putchar(a * a * a - x * x * y * y * y 0.0 ? * : ); } putchar(\n); } puts(for you); return 0; }这里有一个非常容易踩的坑控制台字符不是正方形。终端里一个字符的高度大约是宽度的两倍所以纵向步长要取横向的两倍左右才能让心形不被压扁。上面代码里y - 0.08、x 0.035比例差不多就是 2.3 比 1实际渲染出来接近正圆心的轮廓。我最早写的版本两个方向步长相等输出来的东西像个倒立的洋葱。另一个坑是中文输出。在部分 Windows 终端里源码文件保存为 UTF-8 而终端默认编码不是 UTF-8puts出来的中文会变成乱码。稳妥的做法是把提示文字写成英文或者在程序开头调用系统 API 把终端编码切成 UTF-8。告白这种场合出现乱码的代价太大了所以我一般直接用英文简单一句反而更干净。5.2 Python 生成会呼吸的 GIF正放加倒放的小心机如果目标是发到聊天界面里对方直接看到动图那 GIF 是最省事的格式。用 Pillow 逐帧合成代码量不到四十行而且可以做出一颗会呼吸的爱心——技巧就是把正放帧和倒放帧拼在一起。from PIL import Image, ImageDraw import math W H 480 FRAMES 60 SCALE 13 def heart_xy(t): x 16 * math.sin(t) ** 3 y 13 * math.cos(t) - 5 * math.cos(2 * t) - 2 * math.cos(3 * t) - math.cos(4 * t) return x, y def render(p): p 从 0 到 1代表一个呼吸周期内的进度 img Image.new(RGB, (W, H), (10, 8, 20)) d ImageDraw.Draw(img) n 260 for i in range(n): t 2 * math.pi * i / n hx, hy heart_xy(t) s 1 0.06 * math.sin(2 * math.pi * p) # 缩放呼吸 px W / 2 hx * SCALE * s py H / 2 - hy * SCALE * s 20 r 1.4 1.1 * p color (255, int(60 80 * p), int(110 60 * p)) # 颜色同步变化 d.ellipse([px - r, py - r, px r, py r], fillcolor) return img frames [render(i / (FRAMES - 1)) for i in range(FRAMES)] looped frames frames[::-1] # 正放 倒放 无缝循环 looped[0].save( love.gif, save_allTrue, append_imageslooped[1:], duration40, # 每帧 40 毫秒 loop0, # 0 表示无限循环 optimizeTrue )duration40配合 60 帧正放、60 帧倒放一个完整循环大约 4.8 秒节奏不紧不慢。optimizeTrue会做调色板和帧间差分优化同样的画面能把体积压掉三四成。如果你要在图里写中文需要显式用ImageFont.truetype加载系统中文字体文件Pillow 的默认字体不支持中文会直接显示成方框。5.3 终端流和网页流分别适合谁我的判断标准很简单需要交互就用网页只需要观看就用终端或动图。终端爱心的杀伤力在于懂的人才懂——如果对方不写代码看到一堆星号组成的心感受和看到一张打印出来的图案没区别。但如果对方是同行你在她电脑上打开终端敲两行命令那个瞬间的分量远大于一个网页。反过来如果你想做手指滑动会有光点跟随的效果那必须上网页终端做不到。6. 手机上翻车的六个细节一次完整的白屏排查记录去年有一次我做完一个粒子特效自己电脑上跑得完美发给对方之后收到的回复是打开是黑的。那次排查花了我一个多小时过程值得完整记下来因为它基本覆盖了这类项目在移动端会遇到的绝大多数问题。6.1 先分清是打不开还是打开了没画面这是排查的第一步也是最容易被跳过的一步。打不开通常是文件传输损坏、格式识别失败、或者聊天软件出于安全策略不让你直接预览。打开了没画面才是代码问题。当时对方的情况是后者页面背景能显示但心形不出现。怎么在没有远程调试的前提下判断我的做法是在页面里挂一个全局错误捕获把报错信息直接画在页面上。这在无法连接调试器的场景里非常有用script window.onerror function (msg, src, line) { var box document.createElement(div); box.style.cssText position:fixed;left:0;bottom:0;z-index:9999;background:#c00;color:#fff; font:12px/1.4 monospace;padding:6px 8px;white-space:pre-wrap;max-width:100%; box.textContent ERR: msg line: line; document.body.appendChild(box); }; /script把这段放在所有脚本之前任何运行时错误都会以红条形式出现在页面底部。对方截个图给你问题基本就定位了一半。6.2 二分法注释代码比盯着代码看有效得多确认是代码问题之后我把页面里的脚本按功能块拆成三段设备像素比适配、粒子生成、动画循环。先注释掉动画循环发现页面能显示静止的粒子再打开循环问题复现。到这里就能确定是每帧执行的代码里出了问题而不是初始化阶段。后来定位到的原因是我在动画循环里用了一个只在桌面浏览器支持的特性来读取容器尺寸移动端拿到的是undefined导致缩放系数算出来是 NaN所有粒子坐标都变成了 NaN画布上自然什么都没有。修复方式很简单缓存容器尺寸并只在resize事件里更新不在每帧读取。这个排查链路的价值在于不要靠猜靠缩小范围。注释掉一半代码看问题是否还在一次就能砍掉一半可能性。三次之后基本就锁定了。6.3 那些看起来不像 bug 的 bug除了上面这类硬错误还有几个不会报错、但会毁掉体验的细节我把它们整理成一张表动手前对照检查一遍。现象真正的原因处理方式页面比屏幕高一点点出现滚动条移动端浏览器地址栏会占用视口高度用 100vh 之外的方式取高度或直接用height: 100%一路传下来画面发虚、边缘模糊画布尺寸没乘设备像素比画布实际尺寸乘 DPR再用setTransform缩放上下文低端机像幻灯片粒子数量按电脑标准配置按屏幕短边判断小屏把数量砍到一半手指滑动时页面跟着滚触摸事件没阻止默认行为在触摸处理里调用preventDefault打开后一片纯色几秒后才出现首帧渲染前的空窗期太长先把静态的最后一帧画出来再启动动画深色背景上文字看不清对比度不足文字加深色描边或加一层半透明压暗层第三行那个低端机上像幻灯片的问题我一开始以为是性能问题要重构后来发现只要把粒子数从 2400 降到 900帧率立刻就上去了。粒子特效的开销和粒子数是线性关系所以降级策略一定要有而且要在最开始就设计进去不要等到翻车了再补。7. 交付前的最后一遍过场以及几个我反复踩的坑东西做完了不代表能用。我从几次失败里总结出一套交付前的走场流程每次都会照着跑一遍五分钟能省掉一个晚上的返工。7.1 三个必须实测的场景缺一个都可能翻车第一个场景是断网打开。把电脑网线拔了、无线关掉重新双击文件看能不能正常显示。这一步能揪出所有外部资源依赖包括字体、图标、图片链接。第二个场景是手机竖屏打开横屏和竖屏的布局差异比想象中大尤其图片网格在竖屏下容易变成一列很难看。第三个场景是从聊天软件里保存到手机再打开这一步会暴露文件命名的问题——如果文件名里有特殊符号或者太长部分系统会截断或者直接打不开。我一般会把文件命名为纯中文、不超过十个字比如给你的小礼物.html。不要用空格、括号、井号这些字符它们在部分传输链路上会被转义导致文件后缀丢失对方点开变成一堆文本。7.2 别把成品只留在电脑桌面上有个朋友做过一件事辛苦做完的作品只存在笔记本桌面结果当天电脑送修只能重新做一遍。我现在的习惯是三重备份——本地存一份云盘存一份再往手机里传一份。听起来有点小题大做但这类东西你大概率只会给对方看一次错过那个时间点再补一个版本味道就完全不一样了。另外我会额外导出两样东西一个是把关键帧截成图片万一 HTML 在对方设备上真的打不开可以直接发图另一个是准备一段十秒左右的录屏作为备用方案。技术手段越多越要有降级预案这和线上系统做兜底是一个道理。7.3 最后说一个我个人的判断标准我做完十款特效、也帮别人改过很多版之后慢慢形成了一个判断一个好的表白特效代码量应该不重要内容必须重要。做得最成功的那几次靠的都不是粒子数量或者动画复杂度而是一句只有你们两个人才懂的话、一张构图很普通但时间点很特殊的照片、或者一个具体到某年某月某日的计时数字。反过来我见过花了两千行代码做的 3D 场景打开之后是一句通用情话看完没有任何感觉。技术在这里的作用是放大表达不是替代表达。所以在动手之前我建议先花半小时想清楚我想说的那句话是什么再去选一款特效。顺序反了做出来的东西就只是一个能跑的 Demo。至于具体选哪款我自己的习惯是素材多就选图片网格话多就选打字机想安静一点就选粒子爱心对方是同行就写个控制台爱心。四选一基本不会错。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进