有段时间,因为朋友生日赶上大家都在忙,没能凑到一块儿聚餐。我答应给他补一个“有点参与感”的祝福,想来想去,与其发一条复制粘贴的贺词,不如做一个打开就能看到氛围、还能顺手点点烟花的小页面。
于是这个项目就在我本地诞生了,目录名就叫happy-birthday,页面里第一行核心标题只有四个字:生日快乐!
这是一篇完整的实操记录。我会把前后端都不依赖的静态生日祝福卡片怎么做、怎么调、怎么在手机上顺利打开分享,以及我在实际调试中踩过的坑,全部讲清楚。适合想给朋友做一张有记忆点电子贺卡的人,也适合刚接触前端、想找个轻量小项目练手的人。不需要会写后端,也不需要懂框架,会复制代码、会改文字,就能做出一个效果不差的版本。
1. 内容整体设计与思路拆解
1.1 先想清楚这张“电子贺卡”要解决什么问题
“生日快乐”四个字,做成纯文字发出去也能表达心意,所以这页面的存在理由必须是:让祝福更有仪式感、更直观、也更好分享。普通文本消息在聊天框里停留几秒就沉了,而一张打开后能看到柔和背景、动态光点、点击后放烟花的页面,起码值得朋友多看一会儿。
我更在意的是收卡人打开时的第一感受。所以页面的信息层级应该非常清楚:第一眼是“生日快乐”,第二眼是有一种“被认真对待”的视觉氛围,第三眼是可以动手玩一下的互动点,最后是落款和日期。整张卡片不用承载太多信息,多一个没用的按钮都会让体验变杂。
这个设计思路放到具体实现里,就变成了这样的结构:
- 顶部一行小字,降低开场压力,比如“送给我最重要的朋友”
- 中间主标题,用大字号和暖色突出“生日快乐!”
- 背景有缓慢漂浮的暖色光点,不干扰阅读
- 页面上只有一个核心按钮,点击后视觉上产生烟花爆散
- 底部有一行署名和日期,表示这不是网络下载的通用模板
这样用户在5秒内就能明白页面在说什么,也知道下一步可以做什么。
1.2 为什么我坚持不引入框架和动画库
如果只看“做一个带烟花的网页”,很多人会直接想到引一个Canvas动画库或者CSS特效库,但我最后选择了一个外部依赖都不用的方案。
原因很实际:静态卡片的使用寿命可能就几十次,朋友看完基本不会再访问第二遍,引入框架和动效库虽然写起来快,却会带来几个麻烦。首先是加载,移动端网络不稳定时,几十KB的文件也要多等一会儿;其次是可维护性,以后想改文案和颜色,得在一堆依赖配置里翻;最后是分享问题,把整个依赖都塞进一个文件里是最省事的,不管对方用什么设备打开,都不会因为跑某条资源失败而白屏。
所有效果只用三种基础技术:
- HTML负责语义结构,比如标题、按钮、落款;
- CSS负责排版、字体、渐变背景和柔和阴影;
- JavaScript负责Canvas背景粒子、点击爆炸和读取URL参数。
这套组合的优势是页面可以是一个独立HTML文件,用户收到后不用安装任何东西,浏览器打开就能看。如果朋友想在电脑上留档,把这个文件直接存到桌面,双击也能正常展示。
1.3 我按什么顺序安排内容区块
这个页面并不是一刀切从上往下铺内容,我是按“打开页面的一瞬间,视线应该先落在哪里”来安排区块的。
寿星打开页面的前两秒最需要被抓住,时间再久就会失去耐心。所以主标题“生日快乐!”占最大字号,也是整张卡片里视觉权重最高的部分。在这之前我放了一行很小的引语,用来柔化它的开场方式:它不是系统弹窗,也不是群发消息,而是专门做给这位朋友看的。
主标题看完,人会自然往下扫,于是我在下方放了一段祝福文案,不用长,五六行以内,因为手机屏幕本来就有限,长篇文章在阅读压力上不如短小真诚的文字。祝福文案下面才是那个互动按钮,文案用“送一份小烟花”这样具体的动作来引导操作。
最后把落款和日期放在最低处。如果对方看不到最后也没关系,前面的祝福已经完整表达了,落款只是锦上添花。
图表化的内容比一大段话更容易看清,所以我把每个区块的作用整理成这样一个表格:
| 区块 | 位置 | 作用 | 设计情绪 |
|---|---|---|---|
| 引导语 | 页面顶部 | 热身,让寿星知道这是专门给他做的 | 温柔、克制 |
| 主标题区 | 页面中间偏上 | 直接输出核心祝福 | 醒目、温暖 |
| 祝福正文 | 主标题下方 | 补充想说的话 | 真诚、口语化 |
| 互动按钮 | 正文下方 | 鼓励点按,增加参与感 | 惊喜、轻快 |
| 落款日期 | 页面底部 | 标明作者和日期,留作纪念 | 安静、简洁 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 主标题里的js_title_inner到底怎么用
画页面骨架时,我最初写的结构非常普通:
html复制<h1>生日快乐!</h1>
但实际做到一半,发现这个主标题不能只是死板的静态文本。我希望它可以根据我放到链接里的姓名参数自动变化,比如朋友收到的是xxx.html?name=阿远,打开后就能显示“阿远,生日快乐!”
要实现这种“结构固定、内容可换”的效果,就必须把变化的那部分文字单独包裹起来。我最终使用了这样一段标记:
html复制<h1 class="card-title">
<span class="js_title_inner">生日快乐</span>!
</h1>
注意这里我故意把js_title_inner作为类名,而不是给一个看起来很普通的name-text。原因有一点历史习惯:js_前缀代表这部分将来要交给JavaScript处理,我翻代码时一眼就能知道哪些地方是纯样式,哪些地方被脚本接管,不用逐行去猜。
用独立的span包住祝福词,好处非常多。如果寿星的名字很长,CSS可以单独控制这个span的font-size和white-space,不至于影响后面那个感叹号的位置。如果未来想加打字机效果,也可以只对这个span做操作,整个页面不会一起跟着动。
以后如果你还想做其他场景的卡片,比如中秋、新年,只需要保留这个替换逻辑,改掉文字内容就能复用。页面结构始终稳定,变的只是数据。
2.2 背景配色是怎么调出“暖而不俗”的感觉的
生日页面最容易翻车的就是颜色:大红大紫拼在一起,乍看热闹,时间长了眼睛累,拍照也容易过曝。我想要的是暖色调,但不是那种扑面而来的俗艳。
最终背景我选择了类似奶茶和奶油之间的组合:底部是偏暖的#fdf6ec,中部过渡到浅杏色#f9e1c4,顶部再叠一层淡金色#f7d9a1。整体就像傍晚光线打在奶油蛋糕上,亮度适中,主标题的深棕褐色#5b3a29在上面读起来也会清楚很多。
字体颜色上,我没用纯黑,纯黑在这种暖背景上会显得太重,我用了偏暖的深棕#5b4636,会更像一个整体。辅助文字则再淡一档,比如#a0826c,保证能看清又不抢主标题的注意力。
要让这个背景产生柔和层次,我用了CSS渐变:
css复制body {
background: linear-gradient(160deg, #fdf6ec 0%, #f9e1c4 50%, #f7d9a1 100%);
min-height: 100vh;
}
这里有一个细节:渐变方向我选的是160deg,不是常见的180deg。180deg是从上到下,纵深感太平;160deg加了一点点斜向角度,视觉重心会从左上走到右下,看起来像有一束光穿过整个屏幕,画面更耐看。
2.3 光点粒子的数量不是越多越好
背景里的漂浮光点,是这张页面氛围感的重要来源。数量上我做了严格限制:普通手机屏幕上同时活动的粒子最多维持在60个左右,加上点击烟花后额外出现的粒子,总量也不超过160个。
为什么不多放几百个?因为每一帧都要对这堆粒子做位置更新和绘制,粒子数量越大,CPU占用就越高。中低端安卓手机上,一旦掉帧,动画就会变成一顿一顿的,原本想营造的氛围反而被打断。我把粒子数量做成了可配置项,放在脚本顶部:
javascript复制const PARTICLE_COUNT = 60;
const BURST_COUNT = 14;
const BURST_SPREAD = 10;
PARTICLE_COUNT是基础漂浮粒子数,BURST_COUNT是点击按钮后生成多少朵小花,BURST_SPREAD是每朵小花包含的粒子数量。一把取14×10,爆炸瞬间屏幕上也就多出140个粒子,计算压力很小,效果却很明显。
如果你想在自己项目里直接用,就按我上面的三个参数起步,不要一开始就调大。先跑稳,再根据自己手机性能逐步增加数量。
2.4 按钮的交互位置和文案节奏
互动按钮的文案,我测试过三版,从“点击这里”到“送TA一朵烟花”,最后定成“送一份小烟花”。原因是“点击这里”完全是功能语言,没有任何情绪;“送TA一朵烟花”里有个TA,在直接发给本人的场景下会显得奇怪;最后“送一份小烟花”既能指代页面效果,又像用户在对寿星表达心意。
按钮放在屏幕下方但不要贴着边缘,要给拇指留出操作空间。我用了一个底部留白较多的布局,加上按钮自身的margin-top,避免用户在单手拿手机时不小心碰到。按钮样式也不能设计得太像系统弹窗,不然会让人本能地警惕,我用圆角、柔和渐变和轻微阴影,让它看起来像是页面自然生长出来的一部分。
3. 实操过程与核心环节实现
3.1 页面骨架搭建
整个项目最重要的一次落地,是把设计稿变成真实页面。下面是整套页面代码的基础骨架,这一部分承载了所有文字的语义结构。
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="一份专门为你准备的生日祝福页面" />
<title>生日快乐!</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<canvas id="bgCanvas" aria-hidden="true"></canvas>
<main class="birthday-card">
<p class="intro">送给最特别的你</p>
<h1 class="card-title">
<span class="js_title_inner" id="wishText">生日快乐</span>!
</h1>
<p class="message">
又是新的一岁<br />
愿你手边常有甜,眼里常有光<br />
今天只想祝你开开心心
</p>
<button id="fireworksBtn" class="fireworks-btn" type="button">送一份小烟花</button>
<footer class="card-footer">
<span id="signName">来自你的朋友</span>
<time id="sendDate"></time>
</footer>
</main>
<script src="script.js"></script>
</body>
</html>
这里有几个容易被忽略的点。给canvas加aria-hidden="true"是为了让屏幕阅读器直接跳过装饰性元素,否则读屏软件会把canvas里绘制的每一帧误读成内容,对普通用户没有影响,对有障碍的用户影响很大。
主标题我依然保留了h1,整张卡片只有一个h1,这是符合语义的。很多新手会把“首页”“名字”“按钮”全都设成h1,这不影响视觉效果,但搜索和系统理解页面结构时会出现混乱。每个页面只有一个最重要的主标题,其他都是副标题或段落结构,这个习惯值得从小项目开始养成。
3.2 CSS样式的关键参数
光有骨架没有任何视觉效果,要把页面调成“打开就想多看两秒”的程度,至少需要这样几层CSS。
先定义根变量和控制基础排版:
css复制:root {
--color-bg-top: #fdf6ec;
--color-bg-mid: #f9e1c4;
--color-bg-bottom: #f7d9a1;
--color-text: #5b3a29;
--color-text-light: #a0826c;
--color-card: rgba(255, 255, 255, 0.5);
--radius-card: 28px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
min-height: 100%;
}
body {
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
-apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(
160deg,
var(--color-bg-top) 0%,
var(--color-bg-mid) 50%,
var(--color-bg-bottom) 100%
);
color: var(--color-text);
overflow-x: hidden;
}
很多页面做出来缺乏质感,多半是因为没用根变量。颜色写死在每个class里,后期想统一调整要改几十处;用:root集中管理,以后想改成春季主题,只需要改最顶部几个色值,效率提高不少。
为了确保卡片在内容少时也能垂直居中,在内容多时不至于顶部顶死,我采用了一个同时匹配两种情况的做法:
css复制.birthday-card {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 48px 24px;
text-align: center;
backdrop-filter: blur(2px);
}
backdrop-filter: blur(2px)是一个容易被忽略的细节。因为Canvas背景粒子是动态的,直接叠在上面的文字如果没有一点点背景隔离,字母边缘会被粒子图案干扰。只做一个2px的模糊,再配合卡片自己的半透明效果,就能在不“糊掉氛围”的前提下提高文字可读性。
主标题的字体大小要适配不同尺寸屏幕:
css复制.card-title {
font-size: clamp(48px, 12vw, 88px);
font-weight: 800;
letter-spacing: 0.02em;
margin-bottom: 24px;
text-shadow: 0 8px 24px rgba(91, 58, 41, 0.18);
}
.card-title .js_title_inner {
display: inline-block;
white-space: nowrap;
}
这段代码最关键的是clamp(48px, 12vw, 88px)。它的意思是:字号最小不写成小于48px,最大不写成大于88px,中间则根据屏幕宽度的12%来自动伸缩。如果写成font-size: 12vw,在特别宽的电脑显示器上字会大得离谱;写成固定64px,又会在窄屏手机上超出边框。clamp是三段式生长的智能字号,非常适合这种卡片类页面。
按钮和底部落款需要给用户清晰的层次:
css复制.fireworks-btn {
border: none;
margin: 36px auto 12px;
padding: 14px 30px;
border-radius: 999px;
background: linear-gradient(135deg, #ff9a76, #ff6f61);
color: #fff;
font-size: 18px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 12px 30px rgba(255, 111, 97, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.fireworks-btn:active {
transform: scale(0.96);
box-shadow: 0 6px 16px rgba(255, 111, 97, 0.28);
}
.card-footer {
margin-top: 40px;
color: var(--color-text-light);
font-size: 14px;
line-height: 1.8;
}
按钮按下时有一个scale(0.96)的缩放在视觉上会非常跟手。做这类微交互,并不是为了炫技,而是让用户明确知道“我的点击确实被响应了”。它对观感的提升,往往比堆很多复杂特效还明显。
3.3 Canvas粒子动画的完整实现
背景粒子绘制这部分,我会把所有变量和函数拆开讲,方便你和自己项目里的代码对照。
脚本开头的核心配置:
javascript复制const canvas = document.getElementById("bgCanvas");
const ctx = canvas.getContext("2d");
const CONFIG = {
particleCount: 60, // 常驻粒子数量
burstCount: 14, // 每朵烟花的粒子组数
burstGap: 8, // 每朵烟花的扩散数量
baseRadius: 2.2, // 粒子基础半径
baseAlpha: 0.45, // 粒子基本透明度
colors: ["#ff9a76", "#ffd3a5", "#fff4e0", "#ff6f61", "#fdba9a"]
};
let width = 0;
let height = 0;
let particles = [];
let bursts = [];
let ticks = 0;
常驻粒子主要负责氛围,不产生视觉冲击。它们散落在整个屏幕上,从底部慢慢往上升,透明度逐渐变化,像悬浮的尘埃和烛光。这里我没让它们做匀速直线运动,而是给每个粒子加了一个随机的水平波动,否则所有粒子同时上浮会显得像下雪回放,机械感太重。
画布核心代码:
javascript复制function resizeCanvas() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + "px";
canvas.style.height = height + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function initParticles() {
particles = [];
for (let i = 0; i < CONFIG.particleCount; i++) {
particles.push(createParticle());
}
}
function createParticle() {
return {
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.25,
vy: -0.18 - Math.random() * 0.25,
radius: (Math.random() * 1.5 + 1) * CONFIG.baseRadius,
alpha: Math.random() * 0.3 + 0.15,
color: CONFIG.colors[Math.floor(Math.random() * CONFIG.colors.length)]
};
}
关于dpr的缩放,很多人第一次写Canvas动画都会漏掉。屏幕上的CSS像素和Canvas绘制像素不一定是一比一。如果直接把canvas.width设置成window.innerWidth,在Retina屏上画面的边缘会发虚、模糊。我做了两倍draw缩放,同时又用Math.min(..., 2)把上限卡在2,因为超过2倍之后人眼已经分不出变化,反而白白增加内存消耗。
每帧更新和绘制放在同一个函数里:
javascript复制function loop() {
ctx.clearRect(0, 0, width, height);
drawParticles();
drawBursts();
ticks++;
requestAnimationFrame(loop);
}
function drawParticles() {
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
p.x += p.vx + Math.sin((ticks + i) * 0.02) * 0.08;
p.y += p.vy;
p.alpha *= 0.994;
if (p.y < -20 || p.alpha < 0.02) {
particles[i] = createParticle();
particles[i].y = height + 10;
continue;
}
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = Math.max(0.05, p.alpha);
ctx.fill();
}
ctx.globalAlpha = 1;
}
这里给粒子上升时叠加了一个正弦扰动Math.sin((ticks + i) * 0.02) * 0.08,让粒子路径更接近摇曳的烛光,不呆板。紧接着判断粒子是否飘出屏幕顶部:如果飘出去,就把它重新创建到底部,这样能保证画面长期有粒子循环,不需要额外维护对象池。
点击按钮触发烟花的逻辑要写在DOM事件监听里:
javascript复制const fireworksBtn = document.getElementById("fireworksBtn");
fireworksBtn.addEventListener("click", () => {
for (let i = 0; i < CONFIG.burstCount; i++) {
spawnBurst(
width / 2 + (Math.random() - 0.5) * 120,
height / 2 - 40 + (Math.random() - 0.5) * 80
);
}
});
function spawnBurst(x, y) {
for (let j = 0; j < CONFIG.burstGap * 2; j++) {
const angle = (Math.PI * 2 * j) / (CONFIG.burstGap * 2);
const speed = 1.2 + Math.random() * 2.6;
const vx = Math.cos(angle) * speed;
const vy = Math.sin(angle) * speed;
const life = 90 + Math.random() * 40;
let startLife = life;
bursts.push({
x: x,
y: y,
vx: vx,
vy: vy,
life: life,
startLife: startLife,
radius: 1.2 + Math.random() * 2.4,
color: CONFIG.colors[Math.floor(Math.random() * CONFIG.colors.length)]
});
}
}
为了让爆炸的粒子不是正圆形扩散,angle按圆均分,但速度加了随机偏移seed;范围在1.2到3.8之间。粒子的寿命从初始值向0递减,透明度根据寿命比例计算,越到后面颜色越淡,直到消失,这样能模拟烟花慢慢坠落的轨迹。
绘制爆炸粒子:
javascript复制function drawBursts() {
for (let i = bursts.length - 1; i >= 0; i--) {
const b = bursts[i];
b.x += b.vx;
b.y += b.vy;
b.vy += 0.03; // 模拟轻微重力
b.vx *= 0.985;
b.vy *= 0.985;
b.life--;
const progress = b.life / b.startLife;
ctx.globalAlpha = Math.max(0, progress) * 0.85;
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius * (0.6 + progress * 0.4), 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
if (b.life <= 0) {
bursts.splice(i, 1);
}
}
ctx.globalAlpha = 1;
}
我在处理爆炸粒子时给了非常轻的vy += 0.03,这是重力,所有爆炸出来的粒子最终会下落,画面才符合物理直觉。如果取消这一段,粒子会保持原方向飞散,看起来会像雪花炸开而不是烟花。
页面加载时要先把画布尺寸算出来并执行并发动画:
javascript复制function startAnimation() {
resizeCanvas();
initParticles();
loop();
}
startAnimation();
window.addEventListener("resize", () => {
resizeCanvas();
});
如果用手机打开页面后旋转屏幕,尺寸也会跟着调整。如果觉得旋转屏幕时画面闪烁,还可以加一个防抖:
javascript复制let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(resizeCanvas, 150);
});
3.4 根据URL参数自动替换寿星名字
页面上做的是通用祝福,但使用时我想让每个人收到的文字都不一样。比如二维码扫到页面后,链接是这样的:
code复制https://your-domain.com/birthday.html?name=阿远
打开之后,主标题里的js_title_inner就显示“阿远生日快乐”。这样同一个页面可以反复复用,不用每次生成新页面。
JavaScript部分的实现很短:
javascript复制function readNameFromUrl() {
const params = new URLSearchParams(window.location.search);
const name = params.get("name");
if (!name) return;
const wish = document.getElementById("wishText");
if (wish) {
wish.textContent = "生日快乐," + name;
}
}
readNameFromUrl();
这里至少有两个大家容易忽略的问题。
第一个,要注意编码。如果链接里的中文没有经过encodeURIComponent编码,部分浏览器可能自动处理,但很多场景下会出现乱码。让对方复制分享时,最好先确认链接中的汉字变成了类似%E9%98%BF%E8%BF%9C的形态,或者直接用工具生成带参数二维码,让工具自动编码。
第二个,不要用innerHTML拼接这个参数值。如果参数里带了类似script的片段,用innerHTML会让浏览器把它当成标签执行,非常危险。我自己坚持使用textContent,无论外部传入什么文本,都只会被当作纯文字显示,安全又省事。
落款日期我也用JavaScript自动填上:
javascript复制const dateEl = document.getElementById("sendDate");
if (dateEl) {
const d = new Date();
dateEl.textContent = `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
}
这样不用每次手动改页面底部日期,当天打开读取当天,记录翻开这一页的真实时间,也自带一点纪念感。
3.5 把页面打包成单文件方便分享
如果在线的部署还没准备好,也可以直接把页面变成一个单文件发送。做法是把CSS放进页面的<style>标签里,把JavaScript用<script>包裹,最后生成一个独立的happy-birthday.html。
我实际交付给朋友的就是这样一个单文件版本。打开时无需联网,把文件复制到任意一台电脑的浏览器都能跑。如果你打算长期使用,我建议保留一个“可编辑源文件”版本,CSS和JS都拆着放;真正对外发送时再合成单文件。
做“单文件化”时需要注意:原CSS文件里如果有相对路径引用的图片或字体,合入单文件后需要改成base64路径或者直接放弃引外部图片,因为文件一拆开发出去,那些外部资源就丢了。粒子动画和纯色渐变能自己生成,不依赖外部图,所以我合成时完全没有障碍。
4. 常见问题与排查技巧实录
做这类页面时,真正影响体验的问题往往不在“代码能不能跑”,而在“手机上打开后到底好看不好看”。我在测试时就遇到过好几个比较典型的坑,整理成一个表格,方便对照排查。
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| 部分安卓手机打开背景模糊 | Canvas的dpr没有自适应缩放 | 按照window.devicePixelRatio做像素缩放,上限设2 |
| 手机点按没有反馈 | 按钮没有去掉默认样式或没写:active |
设置cursor:pointer,用:active做缩小反馈 |
| 粒子运动一顿一顿 | 粒子数量太多或动画没有使用requestAnimationFrame |
限制粒子数量,统一用raf绘制 |
| 主角名显示为乱码 | URL里的中文没有编码或解析方式错误 | 用URLSearchParams解析,生成链接时先编码 |
| 动画服务在线部署后不动 | 部署时没有上传JS文件,或JS文件路径不对 | 确认本地和线上目录结构一致;单文件版更省心 |
| 背景粒子盖过文字 | 画布层级错误,或者文字没有z-index |
Canvas设置position:fixed,文字外层z-index至少为2 |
下面挑几个影响最大的问题做详细说明。
4.1 低端安卓机上动画掉帧,画面卡顿
一开始我把背景粒子数量设成180个,按钮点击出来的爆炸组数也很大。在iPhone上运行很顺,但换到一台几年前的安卓机上就明显掉帧,画面像慢动作回放。
排查思路是打开安卓机自带的“开发者选项”,里面能看到GPU渲染模式的分析图。掉帧时柱状图会连续拉高。我接着在不同数量的粒子配置下反复测,最终把常驻粒子数稳定在60个、爆炸组数控制在14组左右,流畅度才算合格。
不要只盯着自己开发用的电脑和手机测试。卡片类的页面,将来可能被转发到各种各样的设备上,性能设计时宁可保守一点。
4.2 手机上标题字挤成两行,破坏第一印象
原文案里我说过“生日快乐”四个字要放在标题,后来加参数变成“生日快乐,阿远”时,在中屏手机上极易换行或被截断。这时不只是调字号,还要考虑怎么让组合标题自然断开。
我最终选择让祝福内容和名字在同一span中,这样可以用white-space: nowrap强制不换行,再用CSS让它自己在空间不足时缩放。即便如此,如果名字太长还是可能超过屏幕宽度。所以我在实际使用中给分享链接加了一个建议:名字控制在四个字以内,超过四个字的昵称会显得拥挤。
如果你的寿星有很多字的朋友外号,可以重新规划显示形态,改成“阿远”作为落款,主标题只保留“生日快乐”,这样更不容易产生布局问题。
4.3 链接在电脑打开正常,转发到手机却等很久才显示
原因是页面里包含了一个大体积字体或外部图片资源,手机网络差时加载不出来。我的静态页面没有这类问题,但如果别人照做并加了自己的照片背景,就一定要小心。
解决办法可以是:图片压缩后再放进去,采用loading="lazy",或者在CSS里给背景图片加多套@media查询,只在小屏设备加载小尺寸图。如果完全不想碰图片,直接用Canvas做的渐变粒子背景是最轻的。
4.4 分享给朋友后无法自动全屏,看起来像浏览器网页
除非把项目包装成PWA或使用浏览器全屏接口,否则用普通网页打开时,浏览器界面的地址栏和底栏一定存在。如果介意这点,可以让朋友使用“添加到主屏幕”的功能,再次打开时就能模拟原生应用的独立全屏体验。
实现PWA需要在页面里增加manifest.json和service worker,对一次性的生日页面来说,性价比不高,我没做全屏这步。但它对技术尝试者倒是个很合适的小白练手项目,有兴趣可以继续往这个方向加功能。
4.5 代码写完后,反复改文字太麻烦
万一你过几天内要做三个人的生日卡,却不想复制三份代码,最佳方式是做一个极简配置区:
javascript复制const CONFIG = {
title: "生日快乐,阿远",
message: "新的一岁,愿望都会实现。",
sign: "来自你的朋友"
};
页面加载时,所有文字都从这个配置对象里读取。下次给另一个朋友做,只需改这个对象,不用翻CSS和HTML寻找碎文字。早期我把文字散落在地价和script四处,改起来特别痛苦,改成统一配置后省了很多事。
5. 后续还能怎么扩展
核心页面已经跑通,如果你想继续玩下去,有几个低成本高回报的扩展方向。
第一个方向是加背景音乐。在场景允许的情况下,页面打开后点按一次按钮再播放音乐,比一进来就自动播放更符合浏览器限制,也不会给对方带来惊吓。音频文件建议压成mp3格式并控制在1MB以内,否则分享时打开速度会明显变慢。
第二个方向是导出成海报图片。利用浏览器端html2canvas库,可以把当前贺卡页面转成一张图片,对方想保存时就点“存成图片”,生成一张刚好适合发朋友圈的九宫格素材。注意Canvas粒子背景如果被截到图片里,需要在截取前把粒子效果临时停掉或者改成静态渐变,否则生成出来的图片会有较多噪点。
第三个方向是做一个小后台,记录哪些朋友在哪天收到了祝福。不过这需要引入服务器和数据库,复杂度高不少,如果只是私人使用,强烈不建议为了生日卡片单独做。真到了那种规模,更应该用现成的日历提醒服务或者表单工具去管理。
我自己最常用的是这样一个工作流:提前一天在配置区改好寿星名字和落款,把单文件放在托管目录里,再用工具生成带名字参数的短链接,转发给共同好友。对方打开链接时看到自己名字出现在标题上,会明显感觉到这条祝福是专门为他做的。
在整个调试过程中我体会最深的一点是:不要小看一个只有四个字的页面。越是简单的核心文案,越需要花心思处理它的排版、节奏和细节。跑通这个项目之后,我可以复用它应付很多场合,不只是生日,朋友搬家、升职、纪念日,只要改一行标题、一句祝福文案、一种配色,就又能变成一张新的卡片。
最后再分享一个小技巧:完成页面后,把它发到你自己的手机微信文件传输助手里,再用微信内置浏览器打开试一遍。因为大多数收卡人最终都会通过聊天工具打开链接,微信内浏览器对部分CSS和JavaScript行为有兼容性差异,自己先体验过才不会翻车。这个习惯我保持了好几年,帮我提前发现了不少只在真机里才会出现的问题。
