页面总览与分层架构
先看全局,再拆细节。理解了这个页面是怎么"叠"出来的,后面的每一课才有落点。
你将要做的这个页面,本质上是 8 层透明的平面叠在一起。最底层是雨,最上层是点击时炸开的裂纹。每一层只负责一件事,互不干扰。
为什么必须分层
如果把这些效果全画在同一个 Canvas 里,会有三个致命问题:
- 性能:每帧都要重绘全部内容,雨滴一动整页都在重算。
- 维护:想改雨的密度,却可能影响碎片的位置。
- 语义:文字如果也画在 Canvas 里,搜索引擎和屏幕阅读器都读不到。
分层之后,每一层可以独立开关、独立调参、独立优化。这是所有复杂视觉页面的通用做法。
pointer-events: none,让鼠标事件穿透到最底层。否则装饰层会挡住所有点击。HTML 骨架
不需要复杂结构,只要按顺序把每一层写出来。
整个 HTML 只有四类东西:画布、装饰 div、内容 main、进度条。
<body>
<!-- 第一层:雨 -->
<canvas id="rain"></canvas>
<!-- 第二层:环境光晕 -->
<div class="ambient"></div>
<!-- 第五层:全页玻璃碎片(用 SVG 画) -->
<div class="glass-layer">
<svg viewBox="0 0 1200 900" preserveAspectRatio="xMidYMid slice">
<g id="shardGroup"></g>
</svg>
</div>
<!-- 第六、七层:光带与噪点 -->
<div class="sheen"></div>
<div class="grain"></div>
<!-- 第八层:点击破碎画布 -->
<canvas id="crackCanvas"></canvas>
<!-- 进度条 -->
<div class="progress"><span id="bar"></span></div>
<!-- 内容(唯一会被搜索引擎读到的一层) -->
<main>
<section class="hero">...</section>
<section class="chapter">...</section>
...
</main>
</body>
两个关键细节
1. 为什么玻璃层用 SVG 而不是 Canvas
碎片是静态的——生成一次就不再变化。用 SVG 画一次,浏览器会把它缓存成矢量,滚动时零成本重绘。而 Canvas 每帧都要重画。静态图形用 SVG,动态图形用 Canvas,这是基本分工。
2. SVG 的 viewBox 与 preserveAspectRatio
viewBox="0 0 1200 900" 定义了一个虚拟坐标系。preserveAspectRatio="xMidYMid slice" 的意思是:保持比例铺满容器,超出部分裁掉,相当于 CSS 的 object-fit: cover。
meet,SVG 会在容器里缩到能完整显示,四周留白。碎片就会缩成中间一小块,务必用 slice。CSS 分层与混合模式
所有装饰层都用同一套"固定定位 + 满屏 + 穿透点击"的模板。
装饰层的通用模板
/* 所有装饰层的共同特征 */
.deco-layer {
position: fixed; /* 固定在视口,不随滚动移动 */
inset: 0; /* top/right/bottom/left 全为 0 = 铺满 */
z-index: 5; /* 层级 */
pointer-events: none; /* 鼠标穿透,绝不挡点击 */
}
/* 玻璃层额外需要:发光叠加 */
.glass-layer {
mix-blend-mode: screen; /* 与下方叠加时变亮 */
}
mix-blend-mode 是什么
它决定一个元素如何与它下方的内容混合颜色。常用的几种:
| 值 | 效果 | 适用场景 |
|---|---|---|
screen | 只保留亮的部分,越叠越亮 | 发光、玻璃反光、裂纹 |
overlay | 增强对比,暗的更暗亮的更亮 | 颗粒噪点 |
multiply | 越叠越暗 | 阴影、暗角 |
difference | 反色差 | 特殊色彩实验 |
动手试一下
下面这个演示展示了 screen 和 normal 的区别。拖动滑块让光斑移动,观察亮度的叠加方式。
Canvas 与一场雨
所有动态图形都从 Canvas 开始。这一课我们用最少的代码画一场真实的雨。
Canvas 的三个基本功
// 1. 拿到画布和画笔
const canvas = document.getElementById('rain');
const ctx = canvas.getContext('2d');
// 2. 设置画布尺寸(注意 devicePixelRatio,否则高分屏会糊)
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
ctx.scale(dpr, dpr); // 之后所有坐标都按 CSS 像素算
// 3. 画一条线
ctx.beginPath();
ctx.moveTo(10, 10);
ctx.lineTo(10, 40);
ctx.strokeStyle = 'rgba(205,169,93,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
雨滴的数据结构
每滴雨是一个对象,包含位置、长度、速度、透明度:
function makeDrop(initial) {
return {
x: Math.random() * (W + 260) - 130, // 横向位置(略宽于屏幕)
y: initial ? Math.random() * H // 初始时随机散布
: -Math.random() * 280 - 20,// 之后从顶部之外出现
len: Math.random() * 22 + 8, // 雨丝长度
speed: Math.random() * 6.5 + 4, // 下落速度
alpha: Math.random() * 0.30 + 0.05, // 透明度
width: Math.random() < 0.82 ? 1 : 1.7 // 粗细(多数细,少数粗)
};
}
动画循环
不要用 setInterval。用 requestAnimationFrame,它和显示器刷新同步,最流畅也最省电。
function drawRain() {
ctx.clearRect(0, 0, W, H); // 每帧先清屏
for (let i = 0; i < drops.length; i++) {
const d = drops[i];
// 画一条斜线(略微倾斜 = 有风的雨)
ctx.beginPath();
ctx.moveTo(d.x, d.y);
ctx.lineTo(d.x - d.len * 0.16, d.y + d.len);
ctx.strokeStyle = `rgba(148,184,226,${d.alpha})`;
ctx.lineWidth = d.width;
ctx.stroke();
// 移动
d.y += d.speed;
d.x -= d.speed * 0.16;
// 出屏后回收再利用(避免频繁创建对象)
if (d.y > H + 30) {
const nd = makeDrop(false);
Object.assign(d, nd);
}
}
requestAnimationFrame(drawRain); // 预约下一帧
}
splice 掉再 push 新的,直接修改原对象属性。减少垃圾回收压力,长跑页面也不会卡。极坐标与玻璃碎片
这是整个页面最核心的数学。理解了这个,你就能生成任何放射状的结构。
直角坐标 vs 极坐标
平时我们用 (x, y) 定位:向右多少、向下多少。但画放射状图形时,用极坐标更自然:用一个角度和一个距离定位。
// 极坐标 → 直角坐标
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius;
// 例:围绕中心画 16 个点
for (let i = 0; i < 16; i++) {
const angle = (i / 16) * Math.PI * 2; // 均匀分角
const x = cx + Math.cos(angle) * 200;
const y = cy + Math.sin(angle) * 200;
// 在这里画点
}
从"点"到"碎片网格"
一圈点不够,我们要画好几圈,每圈半径不同,形成一个同心网格。相邻两圈的四个点,就围成一块碎片。
const RINGS = [16, 62, 138, 236, 356, 502, 680, 902, 1180]; // 每圈半径
const SEG = 16; // 每圈切几段
// 第一步:算出所有网格顶点
const grid = [];
for (let r = 0; r < RINGS.length; r++) {
const row = [];
const baseR = RINGS[r];
const twist = (Math.random() - 0.5) * 0.55; // 每圈整体错开一点
for (let i = 0; i < SEG; i++) {
// 基础角度 + 随机抖动
const a0 = (i / SEG) * Math.PI * 2 + twist;
const da = (Math.random() - 0.5) * (Math.PI * 2 / SEG) * 1.05;
// 基础半径 + 随机抖动
const dr = baseR * (Math.random() - 0.5) * 0.34;
const rr = Math.max(4, baseR + dr);
const aa = a0 + da;
row.push([cx + Math.cos(aa) * rr, cy + Math.sin(aa) * rr]);
}
grid.push(row);
}
// 第二步:相邻四个顶点 → 一块碎片
for (let ri = 0; ri < grid.length - 1; ri++) {
for (let si = 0; si < SEG; si++) {
const sj = (si + 1) % SEG; // 环形,末尾接回开头
const p1 = grid[ri][si]; // 内圈当前
const p2 = grid[ri][sj]; // 内圈下一个
const p3 = grid[ri + 1][sj]; // 外圈下一个
const p4 = grid[ri + 1][si]; // 外圈当前
// 这四个点组成一块碎片(一个多边形)
// 画出来就是一块玻璃
}
}
参数方程画一颗心
不需要任何图片,用一行数学公式就能画出一颗完美的心。
公式本身
这是一条经典的心形参数方程,参数 t 从 0 走到 2π,就能描出完整的心形轮廓:
x = 16 · sin³(t)
y = 13 · cos(t) − 5 · cos(2t) − 2 · cos(3t) − cos(4t)
把它翻译成代码:
const N = 70; // 采样点数量,越大越平滑
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const t = (i / N) * Math.PI * 2;
const sx = Math.sin(t);
const xx = 16 * sx * sx * sx; // 16·sin³(t)
const yy = 13 * Math.cos(t)
- 5 * Math.cos(2 * t)
- 2 * Math.cos(3 * t)
- Math.cos(4 * t);
// 缩放并移动到目标位置
const px = cx + xx * scale;
const py = cy - yy * scale; // 注意 y 轴向下,所以取负
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.stroke();
为什么要减 6
原始公式算出的心,中心不在原点,而是略微偏上。为了让心的中心正好落在你指定的 (cx, cy),通常会在 y 方向上减去一个常数:
const py = cy + (-yy - 6) * scale;
// ^^^^^^^ 让心居中
(16sin³t, 13cost−5cos2t−2cos3t−cos4t) 粘进去,你能实时看到这条曲线。想改造型,直接改系数即可。缓动函数:让运动自然
现实中没有东西是匀速运动的。缓动函数决定了"速度怎么变化"。
三种常见的缓动
| 名称 | 公式 | 效果 |
|---|---|---|
线性 | t | 匀速,机械感 |
easeOut | 1 - (1-t)² | 先快后慢,像刹车 |
easeIn | t² | 先慢后快,像起步 |
easeInOut | t < 0.5 ? 2t² : 1-(-2t+2)²/2 | 两头慢中间快 |
怎么用
缓动函数的输入是进度(0 到 1),输出是经过缓动后的进度:
// 一个碎片从起点飞到终点
const t = elapsed / duration; // 原始进度 0 → 1
const e = 1 - Math.pow(1 - t, 2.2); // easeOut 缓动
// 用缓动后的进度插值
const x = startX + (endX - startX) * e;
const y = startY + (endY - startY) * e;
为什么指数是 2.2 而不是 2
指数越大,减速越明显。2 是标准 easeOut,2.2 稍微更"黏"一点,碎片飞到末端时会更慢,显得更有质感。这是可以随便调的——指数 1 就是线性,3 就非常慢。
e² × 常数。多试几个值,凭手感选。粒子系统:碎片飞散
碎片飞散看似复杂,其实只需要三个参数:起点、终点、时间。
粒子对象的完整结构
{
x: 100, y: 200, // 起点
tx: 250, ty: 80, // 终点
rot: 0.5, // 初始旋转角
vr: 3.2, // 旋转速度
gravity: 32, // 重力系数
delay: 40, // 延迟出现(毫秒)
verts: [...] // 多边形顶点(碎片形状)
}
渲染逻辑
const sp = (age - p.delay) / 760; // 进度 0 → 1
if (sp <= 0 || sp >= 1) continue;
const e = 1 - Math.pow(1 - sp, 2.2); // easeOut 缓动
// 位置:起点 → 终点 插值
const x = p.x + (p.tx - p.x) * e;
// y 加个二次项 = 模拟重力下坠
const y = p.y + (p.ty - p.y) * e + e * e * p.gravity;
// 旋转
const rot = p.rot + p.vr * e;
// 透明度:从 1 淡到 0
const alpha = 1 - sp;
e * e * gravity? e 本身是 0→1,平方后前期很小后期很大,正好模拟"越到后面掉得越快"的自由落体感。如果只乘一次 e,会像一条斜线,不自然。碎片形状从哪来
每片碎片是一个不规则多边形。生成方法是:绕原点撒几个角度,每个角度上取随机半径:
const verts = [];
const n = 3 + Math.floor(Math.random() * 3); // 3~5 个顶点
for (let v = 0; v < n; v++) {
const a = (v / n) * Math.PI * 2 + rand(-0.5, 0.5); // 角度抖动
const r = size * rand(0.5, 1.2); // 半径抖动
verts.push({
x: Math.cos(a) * r,
y: Math.sin(a) * r
});
}
裂纹生长:递归分叉
一条裂纹,就是一条"方向不断偏移的折线"。分叉,就是在这条线上再长出新的折线。
生成一条裂纹
function makeBranch(sx, sy, angle, scale, tier) {
const points = [{ x: sx, y: sy }]; // 起点
let px = sx, py = sy;
let a = angle;
// 一级裂纹段数多,二级、三级更少
const segs = tier === 0 ? 5 + Math.floor(Math.random() * 4)
: tier === 1 ? 3 + Math.floor(Math.random() * 3)
: 2 + Math.floor(Math.random() * 3);
// 一级裂纹更长
let len = (tier === 0 ? 34 + Math.random() * 58
: tier === 1 ? 22 + Math.random() * 36
: 14 + Math.random() * 22) * scale;
for (let s = 0; s < segs; s++) {
a += (Math.random() - 0.5) * 0.85; // 方向随机偏
px += Math.cos(a) * len;
py += Math.sin(a) * len;
points.push({ x: px, y: py });
len *= 0.64 + Math.random() * 0.30; // 越走越短
}
return { points, ... };
}
从父分支上分叉
关键在于:用 Math.atan2 求出父分支某一小段的方向,然后偏转一个角度,就是子分支的起始方向。
// 从父分支倒数第一个或第二个点分叉
const idx = parent.points.length - 1 - Math.floor(Math.random() * 2);
const p = parent.points[idx];
const prev = parent.points[idx - 1];
// atan2 求两点之间的方向
const dir = Math.atan2(p.y - prev.y, p.x - prev.x);
// 方向左右随机偏 1.3 弧度(约 75°)
const subAngle = dir + (Math.random() - 0.5) * 2.6;
// 生成子分支
const sub = makeBranch(p.x, p.y, subAngle, 0.7, 1);
atan2(y, x) 和 atan(y/x) 的区别: atan 只能返回 −90° 到 90°,无法区分左上和右下。而 atan2 接收两个参数,能返回完整的 −180° 到 180°,这是求方向必须用它。Web Audio:合成玻璃碎裂声
不需要任何音频文件,用代码合成出真实的碎裂声。
真实玻璃碎裂 = 三种声音的叠加
| 层 | 频率 | 作用 | 生成方式 |
|---|---|---|---|
| 噪声爆裂 | 3800–6600 Hz | "咔嚓"的主体 | 随机数 + 带通滤波 |
| 低频冲击 | 190 → 48 Hz | 沉下去的"咚" | 正弦波快速下滑 |
| 高频叮 | 5000–9200 Hz | 碎片落地的清脆余响 | 几个短促正弦波 |
第一步:拿到音频上下文
let actx = null;
function initAudio() {
if (!actx) {
const AC = window.AudioContext || window.webkitAudioContext;
actx = new AC();
}
// 浏览器策略:必须由用户交互后才能播放
if (actx.state === 'suspended') actx.resume();
}
window.onload。第二步:生成噪声
const dur = 0.26;
const sr = actx.sampleRate;
const n = Math.floor(sr * dur);
// 创建一个缓冲区,填入随机数
const buf = actx.createBuffer(1, n, sr);
const data = buf.getChannelData(0);
for (let i = 0; i < n; i++) {
const t = i / n;
// 随机值 (-1 ~ 1) × 衰减
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - t, 2.4);
}
const src = actx.createBufferSource();
src.buffer = buf;
第三步:滤波 + 音量包络
// 带通滤波器:只留下 3800~6600Hz 的声音
const bp = actx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 5000;
bp.Q.value = 0.75;
// 高通滤波器:滤掉 1500Hz 以下
const hp = actx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 1500;
// 音量包络:瞬间拉起,快速衰减
const g = actx.createGain();
const now = actx.currentTime;
g.gain.setValueAtTime(0.0001, now);
g.gain.exponentialRampToValueAtTime(0.085, now + 0.004);
g.gain.exponentialRampToValueAtTime(0.0001, now + dur);
// 连接起来
src.connect(bp);
bp.connect(hp);
hp.connect(g);
g.connect(actx.destination);
src.start(now);
src.stop(now + dur);
exponentialRampToValueAtTime 在数学上不能从 0 开始(指数曲线没有 0 值)。用一个极小的正数代替,听感上完全一样。第四步:低频冲击
const lo = actx.createOscillator();
lo.type = 'sine';
lo.frequency.setValueAtTime(190, now);
lo.frequency.exponentialRampToValueAtTime(48, now + 0.13);
const lg = actx.createGain();
lg.gain.setValueAtTime(0.07, now);
lg.gain.exponentialRampToValueAtTime(0.0001, now + 0.15);
lo.connect(lg);
lg.connect(actx.destination);
lo.start(now);
lo.stop(now + 0.16);
交互与滚动
怎么区分点击和拖拽,怎么让滚动驱动动画,都在这一课。
区分点击与拖拽
直接监听 click 会误判——用户滚动时手指滑动,浏览器也会触发 click。正确做法是组合 pointerdown 和 pointerup:
let downX = 0, downY = 0, downTime = 0;
window.addEventListener('pointerdown', e => {
downX = e.clientX;
downY = e.clientY;
downTime = Date.now();
});
window.addEventListener('pointerup', e => {
// 位移距离
const dist = Math.hypot(e.clientX - downX, e.clientY - downY);
// 按下时长
const dt = Date.now() - downTime;
// 位移小 + 时间短 = 真点击
if (dist < 12 && dt < 900) {
spawnCrack(e.clientX, e.clientY);
}
});
| 判断条件 | 含义 | 建议值 |
|---|---|---|
dist < 12 | 手指/鼠标几乎没移动 | 10~15 像素 |
dt < 900 | 按下时间足够短 | 700~1000 毫秒 |
滚动驱动动画
不要直接在 scroll 事件里改样式——滚动事件触发频率极高,会卡。正确做法是用 requestAnimationFrame 节流:
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const max = document.body.scrollHeight - window.innerHeight;
const p = max > 0 ? window.scrollY / max : 0; // 0 → 1 的进度
// 进度条宽度
bar.style.width = (p * 100) + '%';
// 玻璃层淡出
const hp = Math.min(window.scrollY / (window.innerHeight * 0.9), 1);
glassLayer.style.opacity = (1 - hp * 0.94).toFixed(3);
ticking = false;
});
}
window.addEventListener('scroll', onScroll, { passive: true });
{ passive: true } 是什么? 告诉浏览器"这个监听器不会调用 preventDefault()",浏览器就可以立即滚动而不等待 JS。能显著提升滚动流畅度。Intersection Observer:元素进入视口才触发
文字逐行浮现,不是监听滚动算位置,而是用 IntersectionObserver:
const io = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('in'); // 触发 CSS 过渡
io.unobserve(entry.target); // 触发一次就取消监听
}
});
}, {
threshold: 0.15,
rootMargin: '0px 0px -8% 0px'
});
document.querySelectorAll('.lines').forEach(el => io.observe(el));
性能与可访问性
让页面跑得顺、看得舒服,是专业和业余的分界线。
性能四招
left、top、width 会触发重排,非常昂贵。appendChild,每次都会触发重排。用 DocumentFragment 先攒起来,最后一次性插入。while (arr.length > 4) arr.shift() 控制上限,永远只保留最近几组。可访问性
有一类用户对动效敏感(前庭功能障碍),系统里开启"减少动效"后,页面应该尊重这个选择:
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
/* 所有依赖动画出现的元素,直接显示 */
.lines p,
.final,
.sign {
opacity: 1 !important;
transform: none !important;
}
/* 关闭纯装饰的动画层 */
#rain, .sheen {
display: none;
}
}
JS 里也要判断:
const reduceMotion =
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduceMotion) {
initRain();
requestAnimationFrame(drawRain);
}
// 否则不启动动画
aria-hidden="true",屏幕阅读器会跳过它们;② 颜色对比度至少 4.5:1,正文才看得清。响应式要点
| 技术 | 用途 | 示例 |
|---|---|---|
clamp() | 字号自适应 | font-size: clamp(1rem, 2.6vw, 1.3rem) |
max() | 内边距自适应 | padding: 16vh max(9vw, ...) |
svh | 移动端真实视口高度 | min-height: 100svh |
pointer 事件 | 鼠标/触摸/手写笔通用 | pointerdown / pointerup |
100vh 会溢出。用 100svh(small viewport height)取地址栏展开时的真实高度。学习路径与清单
知道了所有零件,接下来是组装顺序。别一次全上,按阶段来。
四阶段学习路径
Math.random() 控制随机性。最小练习路径
不要一上来就做完整页面。按这个顺序,每个练习都只加一个新东西:
常见坑清单
| 坑 | 正确做法 |
|---|---|
用 setInterval 做动画 | 用 requestAnimationFrame |
改 left/top 做动画 | 改 transform: translate() |
| 装饰层挡住点击 | 加 pointer-events: none |
| 高分屏画面模糊 | 处理 devicePixelRatio |
| SVG 留白 | 用 preserveAspectRatio="slice" |
| 音频自动播放失败 | 在用户交互后初始化 |
| 滚动事件卡顿 | 用 rAF 节流 + passive: true |
| 忽略减少动效设置 | 加 prefers-reduced-motion 判断 |
推荐工具
- Desmos —— 实时验证参数方程,desmos.com/calculator
- Easings.net —— 缓动函数可视化对比
- Chrome DevTools —— Performance 面板看帧率,Rendering 面板看重绘
- MDN —— Canvas、Web Audio、CSS 的权威文档
动手,是唯一的路。
所有代码都在这个页面里,打开控制台,改一个数字,看看会发生什么。