← 返回《裂痕》
01

页面总览与分层架构

先看全局,再拆细节。理解了这个页面是怎么"叠"出来的,后面的每一课才有落点。

你将要做的这个页面,本质上是 8 层透明的平面叠在一起。最底层是雨,最上层是点击时炸开的裂纹。每一层只负责一件事,互不干扰。

z: 0雨Canvas 绘制的下落雨丝
z: 1环境光晕随滚动上下起伏的底色光
z: 2文字内容真正的正文,唯一的语义层
z: 5玻璃碎片层覆盖全页的 SVG 多边形网格
z: 6流动光带缓慢移动的玻璃反光
z: 7颗粒噪点细密杂色,模拟玻璃表面
z: 8破碎画布点击时炸开的裂纹与爱心碎片

为什么必须分层

如果把这些效果全画在同一个 Canvas 里,会有三个致命问题:

  • 性能:每帧都要重绘全部内容,雨滴一动整页都在重算。
  • 维护:想改雨的密度,却可能影响碎片的位置。
  • 语义:文字如果也画在 Canvas 里,搜索引擎和屏幕阅读器都读不到。

分层之后,每一层可以独立开关、独立调参、独立优化。这是所有复杂视觉页面的通用做法。

i
核心原则:纯装饰层一定要加 pointer-events: none,让鼠标事件穿透到最底层。否则装饰层会挡住所有点击。
02

HTML 骨架

不需要复杂结构,只要按顺序把每一层写出来。

整个 HTML 只有四类东西:画布、装饰 div、内容 main、进度条。

index.html
<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。
03

CSS 分层与混合模式

所有装饰层都用同一套"固定定位 + 满屏 + 穿透点击"的模板。

装饰层的通用模板

style.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 的区别。拖动滑块让光斑移动,观察亮度的叠加方式。

交互演示 · 混合模式
50%
观察:screen 模式下两个光斑重叠处会明显变亮;normal 模式则只是简单的覆盖。
04

Canvas 与一场雨

所有动态图形都从 Canvas 开始。这一课我们用最少的代码画一场真实的雨。

Canvas 的三个基本功

canvas-basics.js
// 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();

雨滴的数据结构

每滴雨是一个对象,包含位置、长度、速度、透明度:

rain.js
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,它和显示器刷新同步,最流畅也最省电。

loop.js
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 新的,直接修改原对象属性。减少垃圾回收压力,长跑页面也不会卡。
实时演示 · 可控的雨
110
0.16
05

极坐标与玻璃碎片

这是整个页面最核心的数学。理解了这个,你就能生成任何放射状的结构。

直角坐标 vs 极坐标

平时我们用 (x, y) 定位:向右多少、向下多少。但画放射状图形时,用极坐标更自然:用一个角度和一个距离定位。

polar.js
// 极坐标 → 直角坐标
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;
  // 在这里画点
}

从"点"到"碎片网格"

一圈点不够,我们要画好几圈,每圈半径不同,形成一个同心网格。相邻两圈的四个点,就围成一块碎片。

shards.js
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];   // 外圈当前

    // 这四个点组成一块碎片(一个多边形)
    // 画出来就是一块玻璃
  }
}
i
为什么 RINGS 越往后差越大? 16→62 只差 46,902→1180 差了 278。因为越靠外,同等的角度变化对应的弧长越长,碎片自然应该更大。这符合真实玻璃碎裂的规律:中心碎得细,边缘块头大。
实时演示 · 碎片网格生成
8
16
0.34
试试:把抖动拉到 0,网格变得规则;拉到 100,碎片彻底破碎。这就是随机性的力量。
06

参数方程画一颗心

不需要任何图片,用一行数学公式就能画出一颗完美的心。

公式本身

这是一条经典的心形参数方程,参数 t 从 0 走到 2π,就能描出完整的心形轮廓:

heart-equation.txt
x = 16 · sin³(t)

y = 13 · cos(t) − 5 · cos(2t) − 2 · cos(3t) − cos(4t)

把它翻译成代码:

heart.js
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 方向上减去一个常数:

center-fix.js
const py = cy + (-yy - 6) * scale;
//               ^^^^^^^ 让心居中
i
验证方法:打开 Desmos,把 (16sin³t, 13cost−5cos2t−2cos3t−cos4t) 粘进去,你能实时看到这条曲线。想改造型,直接改系数即可。
实时演示 · 心形参数
7
6
70
试试:把采样点调到 10,心形变成多边形;调到 200,曲线非常光滑。这就是"离散采样"的本质。
07

缓动函数:让运动自然

现实中没有东西是匀速运动的。缓动函数决定了"速度怎么变化"。

三种常见的缓动

名称公式效果
线性t匀速,机械感
easeOut1 - (1-t)²先快后慢,像刹车
easeInt²先慢后快,像起步
easeInOutt < 0.5 ? 2t² : 1-(-2t+2)²/2两头慢中间快

怎么用

缓动函数的输入是进度(0 到 1),输出是经过缓动后的进度:

easing.js
// 一个碎片从起点飞到终点
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 就非常慢。

✓
经验值:粒子飞散用 2 ~ 2.5,界面元素出现用 3 ~ 4,下落重力用 e² × 常数。多试几个值,凭手感选。
实时演示 · 缓动曲线对比
2.2
观察:三条曲线分别代表线性、当前指数、以及一个对比指数。指数越大,末端越慢。
08

粒子系统:碎片飞散

碎片飞散看似复杂,其实只需要三个参数:起点、终点、时间。

粒子对象的完整结构

particle.js
{
  x: 100, y: 200,          // 起点
  tx: 250, ty: 80,         // 终点
  rot: 0.5,                // 初始旋转角
  vr: 3.2,                 // 旋转速度
  gravity: 32,             // 重力系数
  delay: 40,               // 延迟出现(毫秒)
  verts: [...]             // 多边形顶点(碎片形状)
}

渲染逻辑

render-particle.js
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;
i
为什么要 e * e * gravity? e 本身是 0→1,平方后前期很小后期很大,正好模拟"越到后面掉得越快"的自由落体感。如果只乘一次 e,会像一条斜线,不自然。

碎片形状从哪来

每片碎片是一个不规则多边形。生成方法是:绕原点撒几个角度,每个角度上取随机半径:

shard-shape.js
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
  });
}
实时演示 · 点击生成碎片
26
180
36
操作:在画布内点击任意位置,看看碎片怎么飞。拖动参数改变效果。
09

裂纹生长:递归分叉

一条裂纹,就是一条"方向不断偏移的折线"。分叉,就是在这条线上再长出新的折线。

生成一条裂纹

branch.js
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 求出父分支某一小段的方向,然后偏转一个角度,就是子分支的起始方向。

fork.js
// 从父分支倒数第一个或第二个点分叉
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);
i
atan2(y, x) 和 atan(y/x) 的区别: atan 只能返回 −90° 到 90°,无法区分左上和右下。而 atan2 接收两个参数,能返回完整的 −180° 到 180°,这是求方向必须用它。
实时演示 · 点击生成裂纹
14
68%
140
操作:在画布内点击,观察裂纹如何从一点放射生长并分叉。
10

Web Audio:合成玻璃碎裂声

不需要任何音频文件,用代码合成出真实的碎裂声。

真实玻璃碎裂 = 三种声音的叠加

层频率作用生成方式
噪声爆裂3800–6600 Hz"咔嚓"的主体随机数 + 带通滤波
低频冲击190 → 48 Hz沉下去的"咚"正弦波快速下滑
高频叮5000–9200 Hz碎片落地的清脆余响几个短促正弦波

第一步:拿到音频上下文

audio-setup.js
let actx = null;

function initAudio() {
  if (!actx) {
    const AC = window.AudioContext || window.webkitAudioContext;
    actx = new AC();
  }
  // 浏览器策略:必须由用户交互后才能播放
  if (actx.state === 'suspended') actx.resume();
}
!
浏览器自动播放策略:页面加载时音频是禁止的,必须在用户点击、触摸等交互之后才能播放。所以音频初始化一定要写在事件处理函数里,不能写在 window.onload。

第二步:生成噪声

noise.js
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;

第三步:滤波 + 音量包络

filter-gain.js
// 带通滤波器:只留下 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);
!
为什么用 0.0001 而不是 0? exponentialRampToValueAtTime 在数学上不能从 0 开始(指数曲线没有 0 值)。用一个极小的正数代替,听感上完全一样。

第四步:低频冲击

thump.js
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);
实时演示 · 合成碎裂声
5000
0.08
0.07
多点击几次:每次的频率都是随机的,所以听起来永远不重复——这正是合成声音相对于音频文件的最大优势。
11

交互与滚动

怎么区分点击和拖拽,怎么让滚动驱动动画,都在这一课。

区分点击与拖拽

直接监听 click 会误判——用户滚动时手指滑动,浏览器也会触发 click。正确做法是组合 pointerdown 和 pointerup:

click-detect.js
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 节流:

scroll-throttle.js
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:

observer.js
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));
实时演示 · 点击判定
在这里点击 或 按住拖动
状态:等待操作…
12

性能与可访问性

让页面跑得顺、看得舒服,是专业和业余的分界线。

性能四招

1
只动 transform 和 opacity
这两个属性变化时,浏览器不需要重新计算布局(reflow),直接交给 GPU 处理。改 left、top、width 会触发重排,非常昂贵。
2
批量插入 DOM
几百个碎片如果一个个 appendChild,每次都会触发重排。用 DocumentFragment 先攒起来,最后一次性插入。
3
数组封顶
用户疯狂点击时,裂纹对象会无限累积。用 while (arr.length > 4) arr.shift() 控制上限,永远只保留最近几组。
4
对象池 / 复用
雨滴出屏后不要销毁重建,直接修改原对象属性。减少 GC(垃圾回收)压力,长时间运行不卡顿。

可访问性

有一类用户对动效敏感(前庭功能障碍),系统里开启"减少动效"后,页面应该尊重这个选择:

a11y.css
@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 里也要判断:

a11y.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 陷阱:手机浏览器地址栏会占用视口高度,100vh 会溢出。用 100svh(small viewport height)取地址栏展开时的真实高度。
13

学习路径与清单

知道了所有零件,接下来是组装顺序。别一次全上,按阶段来。

四阶段学习路径

A
阶段一 · 地基(1~2 周)
HTML 标签与语义、CSS 选择器与盒模型、Flex 布局、position 定位、z-index 层级、JavaScript 变量与函数、DOM 增删改查。
B
阶段二 · 图形(2~3 周)
Canvas 2D 绘图 API、requestAnimationFrame、极坐标与三角函数、SVG 基础、Math.random() 控制随机性。
C
阶段三 · 动画(2~3 周)
缓动函数、粒子系统的位置插值、裂纹递归分叉、CSS transition 与 keyframes、滚动驱动动画、IntersectionObserver。
D
阶段四 · 进阶(2~4 周)
Web Audio 合成、CSS 混合模式、性能优化(GPU 加速、对象池、批量 DOM)、可访问性、响应式细节。

最小练习路径

不要一上来就做完整页面。按这个顺序,每个练习都只加一个新东西:

1
一个会动的小点
Canvas + requestAnimationFrame,让点从左滑到右。
2
一百个点从中心炸开
极坐标 + 缓动函数,让点飞散并淡出。
3
点击屏幕生成一圈裂纹
事件监听 + 折线生长。
4
用 SVG 拼一块碎玻璃
多边形 + 随机顶点。
5
覆盖全页的碎片网格
position: fixed + mix-blend-mode。
6
点击时画一颗心然后碎掉
心形方程 + 径向分层切割。
7
给碎裂加声音
Web Audio 合成三层声音。
8
加滚动淡出和鼠标视差
滚动节流 + mousemove 倾斜。

常见坑清单

坑正确做法
用 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 的权威文档
i
最后的建议:不要试图一次看懂全部。挑一个你最想做的效果,先把它做出来,哪怕只有 20 行代码。跑通了,再加下一个。所有复杂的页面都是这样一层层长出来的。

动手,是唯一的路。

所有代码都在这个页面里,打开控制台,改一个数字,看看会发生什么。