山形动画组件架构设计
Mountain Component Architecture
程序生成的山形动画组件,使用 Web Worker + OffscreenCanvas 实现高性能渲染,支持高 DPR 设备的清晰显示。
📁 文件结构
mountain/
├── README.md # 架构说明(本文件)
├── index.ts # 统一导出
│
├── canvas-mountain.tsx # React 组件(主线程)
├── mountain.worker.ts # Worker 调度器
│
├── mountain.ts # Mountain 数据模型类 ⭐️
├── mountain-renderer.ts # MountainRenderer 渲染器类 ⭐️
│
├── config.ts # 配置常量
├── types.ts # 类型定义
└── renderer.ts # Canvas 渲染函数核心文件说明
mountain.ts: 数据模型,负责山形数据的生成和管理- 点生成(使用 fBm 噪声)
- 线段生成(带阴影效果)
- 预渲染状态管理
- ImageBitmap 缓存
mountain-renderer.ts: 渲染器,负责使用 Mountain 数据进行渲染- 管理 Mountain 实例
- 动画状态管理
- 渲染调度
- 性能统计
renderer.ts: 纯函数,提供基础的 Canvas 绘制功能
🎯 核心特性
- ✅ 高性能渲染:Web Worker + OffscreenCanvas,不阻塞主线程
- ✅ 高 DPR 支持:自动检测设备像素比,在高分辨率屏幕上保持清晰
- ✅ 智能缓存:预渲染 41 个旋转角度的 ImageBitmap,滚动时 O(1) 切换
- ✅ 流畅动画:逐点增量渲染的初始动画,60 FPS 流畅体验
- ✅ 程序生成:基于 fBm 噪声的随机山形,每个 seed 生成独特图案
- ✅ 响应式设计:自动适配不同尺寸,保持 16:9 比例不变形
🏗️ 架构设计
核心理念
MVC 模式 + 关注点分离: - Model (Mountain 类):数据模型,负责数据生成和存储 - View (MountainRenderer 类):视图渲染器,负责渲染和动画 - Controller (Worker 调度器):控制器,负责消息路由
三层架构
数据流向
📦 Mountain 类(数据模型)
设计原则
- 单一职责:只负责数据生成和存储,不涉及渲染
- 数据封装:所有数据(点、线段、预渲染状态)都封装在类内部
- 只读接口:对外提供 getter 方法,保护内部数据
- 资源管理:自动管理 ImageBitmap 资源,防止内存泄漏
核心方法
class Mountain {
// 构造函数
constructor(config: MountainConfig)
// 生成数据(异步)
async generate(): Promise<void>
// 获取数据(只读)
getPoints(): readonly Point[]
getLines(): readonly PrecomputedLine[][]
getDrawArea(): { drawWidth, drawHeight, offsetX, offsetY }
getRotationConstants(): RotationConstants | null
getPreRenderState(): PreRenderState | null
// 清理资源
dispose(): void
}数据生成流程
Mountain 类的 generate() 方法执行完整的数据生成流程,所有数据在这一步生成完毕。
详细步骤说明:
- 计算绘制区域:确保山形始终以 16:9 的比例显示,避免变形。
const aspectRatio = width / height; if (aspectRatio > 16 / 9) { // 宽度过大,裁剪左右 drawHeight = height; drawWidth = height * (16 / 9); offsetX = (width - drawWidth) / 2; } else { // 高度过大,裁剪上下 drawWidth = width; drawHeight = width / (16 / 9); offsetY = (height - drawHeight) / 2; }
性能特征:
| 步骤 | 典型耗时 | 主要开销 |
| 计算绘制区域 | < 1ms | 简单数学计算 |
| 生成 fBm 噪声 | < 1ms | 创建函数闭包 |
| 生成点数据 | 5-10ms | 噪声采样、数组操作 |
| 预计算线段 | 10-20ms | 大量线段计算、随机数生成 |
| 初始化预渲染 | < 1ms | 创建对象 |
| 总计 | 20-30ms | 一次性开销 |
📦 MountainRenderer 类(渲染器)
设计原则
- 单一职责:只负责渲染和动画,数据由 Mountain 提供
- 状态分离:动画状态独立于数据状态
- 生命周期管理:完整的初始化、更新、清理方法
- 性能优化:智能缓存和渲染模式切换
核心方法
class MountainRenderer {
// 静态方法:判断是否需要重新生成
static needsRegeneration(
oldConfig: MountainRendererConfig,
newConfig: MountainRendererConfig
): boolean
// 构造函数
constructor(
ctx: OffscreenCanvasRenderingContext2D,
canvasWidth: number,
canvasHeight: number,
config: MountainRendererConfig
)
// 初始化(创建 Mountain 并生成数据)
async initialize(): Promise<void>
// 开始动画
startAnimation(): void
// 停止动画
stopAnimation(): void
// 更新滚动进度
updateScrollProgress(progress: number): void
// 清理资源
dispose(): void
// 获取 Mountain 实例(调试用)
getMountain(): Mountain | null
}渲染模式
// 初始动画模式:逐点增量渲染
renderIncremental() {
drawMountainIncremental(ctx, lines, renderedPointsCount, ...)
}
// 高性能模式:使用 ImageBitmap 缓存
renderWithBitmapCache() {
const bitmap = preRenderState.bitmaps.get(nearestStep);
if (bitmap) {
ctx.drawImage(bitmap, 0, 0); // O(1) 操作
}
}🔄 消息流程
1. 初始化流程
当组件首次挂载时,主线程向 Worker 发送初始化消息。
2. 参数更新与数据生成
当组件的 props 发生变化时(如 seed、尺寸、偏移量),触发数据重新生成。
3. 滚动进度更新
用户滚动页面时,主线程实时同步滚动进度到 Worker。
4. 预渲染流程(后台)
初始动画完成后,Worker 在空闲时逐步预渲染各个旋转角度的 ImageBitmap。
🎨 渲染流程
初始动画(逐点渲染)
组件首次加载时,通过 RAF 循环逐点渲染,营造山形”生长”的动画效果。
关键代码片段:
private animationLoop = (): void => {
// 1. 增加已渲染点数
this.animationState.renderedPointsCount += this.animationState.animationSpeed;
// 2. 判断是否完成
if (this.animationState.renderedPointsCount >= totalPoints) {
this.animationState.renderedPointsCount = totalPoints;
this.animationState.isAnimating = false;
// 3. 渲染最后一帧
this.renderFrame();
this.logAnimationStats(currentTime, totalPoints);
self.postMessage({ type: "animationComplete" });
return;
}
// 4. 继续渲染
this.renderFrame();
this.animationState.animationRafId = requestAnimationFrame(this.animationLoop);
};渲染模式决策
根据当前状态(初始动画 vs 滚动)和缓存可用性,智能选择最优的渲染模式。
关键代码片段:
private renderFrame(): void {
const isFullyRendered = renderedPointsCount >= totalPoints;
// 模式 1: 高性能缓存模式(最快)
if (isFullyRendered && preRenderState?.bitmaps.size > 0) {
this.renderWithBitmapCache();
}
// 模式 2: 增量渲染模式(初始动画)
else {
this.renderIncremental();
}
}
private renderWithBitmapCache(): void {
const stepSize = 1 / ROTATION_CONFIG.CACHE_STEPS;
const nearestStep = Math.round(currentScrollProgress / stepSize) * stepSize;
const bitmap = preRenderState.bitmaps.get(nearestStep);
if (bitmap) {
// O(1) 操作:直接绘制 GPU 纹理
this.ctx.drawImage(bitmap, 0, 0);
} else {
// 降级到实时渲染,并优先预渲染这个角度
this.renderFull();
prioritizeAngle(nearestStep, preRenderState);
}
// 继续预渲染剩余角度
if (!preRenderState.isPreRendering && preRenderState.queue.length > 0) {
scheduleNextPreRender(preRenderState, this.createDrawMountainCallback());
}
}🖥️ 高 DPR 支持
为了在高分辨率屏幕(Retina、4K 等)上保持清晰的显示效果,组件实现了完整的 DPR(Device Pixel Ratio)支持。
DPR 处理原理
三层 DPR 处理
关键实现细节
1. 主线程:Canvas 尺寸设置
// canvas-mountain.tsx
const dpr = window.devicePixelRatio || 1;
// 设置实际像素尺寸(高分辨率)
canvas.width = width * dpr;
canvas.height = height * dpr;
// 设置 CSS 显示尺寸(保持界面大小)
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// 传递给 Worker
worker.postMessage({ type: "init", canvas: offscreen, dpr });2. Worker:应用 DPR 缩放
// mountain.worker.ts
const canvas = data.canvas;
const ctx = canvas.getContext("2d");
const dpr = data.dpr;
// 应用 DPR 缩放变换
ctx.scale(dpr, dpr);
// 之后所有绘图都使用逻辑坐标
ctx.strokeStyle = "white";
ctx.moveTo(100, 100); // 逻辑坐标,自动映射到 200, 200(DPR=2)3. 预渲染:高分辨率 ImageBitmap
// mountain.ts - initializePreRenderState
const dpr = this.config.dpr ?? 1;
// 创建高分辨率临时 canvas
const tempCanvas = new OffscreenCanvas(width * dpr, height * dpr);
const tempCtx = tempCanvas.getContext("2d");
// 应用 DPR 缩放
tempCtx.scale(dpr, dpr);
// 渲染时使用逻辑坐标
drawMountainFull(tempCtx, lines, progress, ...);
// 转换为高分辨率 ImageBitmap
const bitmap = await createImageBitmap(tempCanvas);4. 使用缓存:正确绘制
// mountain-renderer.ts - renderWithBitmapCache
const bitmap = preRenderState.bitmaps.get(nearestStep);
if (bitmap) {
// ⚠️ 必须指定目标尺寸为逻辑尺寸
// bitmap 是高分辨率的,需要缩放到逻辑坐标空间
this.ctx.drawImage(bitmap, 0, 0, this.canvasWidth, this.canvasHeight);
}DPR 变化处理
支持动态 DPR 变化(例如拖动窗口到不同 DPI 的显示器):
// canvas-mountain.tsx - 参数更新时
useEffect(() => {
const dpr = window.devicePixelRatio || 1;
// 发送新的 DPR 给 Worker
worker.postMessage({
type: "updateParams",
width,
height,
dpr, // 可能变化的 DPR
...
});
}, [width, height, ...]);
// mountain.worker.ts - 处理 DPR 变化
if (newDpr !== dpr) {
dpr = newDpr;
// 调整 canvas 尺寸
canvas.width = width * dpr;
canvas.height = height * dpr;
// 重新应用缩放(调整尺寸会重置 transform)
ctx.scale(dpr, dpr);
}性能影响
| DPR | Canvas 像素数 | 内存占用 | 渲染性能 | 视觉效果 |
| 1.0 | 1× (基准) | 1× | 最快 | 普通 |
| 2.0 | 4× | 4× | 稍慢 (~10%) | 清晰 |
| 3.0 | 9× | 9× | 较慢 (~30%) | 极清晰 |
优化策略:
- ✅ 使用 ImageBitmap 缓存,缓存命中时 DPR 对性能影响极小
- ✅ GPU 加速的 drawImage 操作,即使是高分辨率也能快速绘制
- ✅ 初始动画完成后预渲染,避免实时渲染大量像素
避免的常见错误
// ❌ 错误 1:不设置 CSS 尺寸
canvas.width = width * dpr;
canvas.height = height * dpr;
// 结果:界面显示变大
// ✅ 正确
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// ❌ 错误 2:不应用 scale 变换
canvas.width = width * dpr;
ctx.moveTo(100, 100); // 实际会画在 (100, 100) 物理像素,看起来很小
// 结果:绘制内容缩小
// ✅ 正确
ctx.scale(dpr, dpr);
ctx.moveTo(100, 100); // 自动映射到 (200, 200) 物理像素(DPR=2)
// ❌ 错误 3:drawImage 不指定尺寸
const bitmap = createImageBitmap(tempCanvas); // 高分辨率
ctx.drawImage(bitmap, 0, 0); // 按实际像素尺寸绘制
// 结果:图像放大
// ✅ 正确
ctx.drawImage(bitmap, 0, 0, width, height); // 指定逻辑尺寸
// ❌ 错误 4:transferControlToOffscreen 后修改尺寸
canvas.transferControlToOffscreen();
canvas.width = newWidth; // 抛出错误
// 结果:Cannot resize canvas after call to transferControlToOffscreen
// ✅ 正确:在 Worker 内部修改
const canvas = data.canvas; // OffscreenCanvas
canvas.width = newWidth * dpr;
ctx.scale(dpr, dpr);🚀 性能优化
优化策略总览
1. 数据层面优化
点距离过滤:生成点时跳过距离过近的点,减少冗余数据。
动态线条数量:根据点的深度(y 坐标)动态调整线条数量,远处少、近处多,符合透视效果。
预计算:所有复杂计算在数据生成阶段完成,渲染时只需简单绘制。
- 线段起点、终点、颜色、缩放比例全部预计算
- 旋转系数(
MULTIPLIER_FACTOR,BASE_FACTOR)预计算 - 绘制区域(16:9 裁剪、居中偏移)预计算
2. 渲染层面优化
ImageBitmap 缓存:预渲染不同旋转角度为 GPU 纹理,滚动时直接复用。
性能对比:
| 渲染方式 | 时间复杂度 | 典型耗时 | 适用场景 |
| 实时渲染 | O(n) | 5-15ms | 初始动画、缓存未命中 |
| ImageBitmap 缓存 | O(1) | < 1ms | 滚动浏览(缓存命中) |
增量渲染:初始动画时,每帧只绘制新增的点,而不是重绘全部。
// 每帧只绘制新增部分
for (let i = startPoint; i < pointsToRender; i++) {
// 绘制第 i 个点的所有线段
}
// 而不是每次都从 0 开始绘制3. 架构层面优化
Web Worker + OffscreenCanvas:所有渲染在后台线程运行。
智能重新生成:通过 needsRegeneration 判断是否需要重新生成数据。
资源管理:自动清理 ImageBitmap,防止内存泄漏。
dispose(): void {
// 1. 停止动画
this.stopAnimation();
// 2. 清理 ImageBitmap(GPU 资源)
if (this.mountain) {
this.mountain.dispose(); // 内部调用 bitmap.close()
this.mountain = null;
}
// 3. 清理状态
this.animationState = null;
}📝 模块职责
每个模块都有明确的单一职责,模块之间通过清晰的接口交互。
详细职责说明
1. canvas-mountain.tsx(React 组件)
职责:作为主线程的唯一入口,负责 React 生命周期和 Worker 通信。
- ✅ 接收和验证 props(seed, size, offset, speed, scrollProgress)
- ✅ 检测设备像素比(DPR),设置 canvas 实际尺寸和 CSS 尺寸
- ✅ 创建和管理 Worker 实例
- ✅ 转移 OffscreenCanvas 控制权到 Worker(仅一次)
- ✅ 监听 motion value 变化,同步滚动进度
- ✅ 处理 Worker 响应消息(error, animationComplete)
- ✅ 参数更新时同步 DPR 和尺寸变化
- ❌ 不涉及任何渲染逻辑
- ❌ 不涉及任何数据生成
依赖:types.ts(消息类型)
2. mountain.worker.ts(调度器)
职责:作为 Worker 线程的消息中心,纯粹的路由和调度。
- ✅ 接收和解析主线程消息(init, updateParams, updateScrollProgress)
- ✅ 初始化时应用 DPR 缩放变换(ctx.scale)
- ✅ 参数更新时在 Worker 内部调整 OffscreenCanvas 尺寸
- ✅ 判断是否需要重新生成数据(needsRegeneration)
- ✅ 创建、更新、销毁 MountainRenderer 实例
- ✅ 错误捕获和上报
- ✅ unload 事件时清理资源
- ❌ 不涉及数据生成逻辑
- ❌ 不涉及渲染逻辑
依赖:mountain-renderer.ts, types.ts
3. mountain.ts(数据模型 - Model)
职责:纯粹的数据生成和存储,不涉及渲染。
- ✅ 生成 fBm 噪声函数(xNoise, hNoise)
- ✅ 生成点数据(generatePoints → samplePoints → scalePoints → centerPoints)
- ✅ 预计算线段数据(precomputeLines,含阴影和动态数量)
- ✅ 管理预渲染状态存储(queue, bitmaps, tempCanvas)
- ✅ 初始化预渲染时创建高分辨率临时 canvas(应用 DPR)
- ✅ 提供只读 getter(getLines, getDrawArea, getRotationConstants, getPreRenderState)
- ✅ 资源管理(dispose 清理 ImageBitmap)
- ✅ 工具函数(generateOptimalPreRenderOrder, prioritizeAngle)
- ❌ 不涉及 Canvas 绘制
- ❌ 不涉及动画控制
- ❌ 不涉及预渲染调度(由 MountainRenderer 负责)
依赖:config.ts, types.ts
4. mountain-renderer.ts(渲染器 - View)
职责:使用 Mountain 数据进行渲染和动画控制。
- ✅ 创建和管理 Mountain 实例(传递 DPR 配置)
- ✅ 初始化动画状态(renderedPointsCount, animationSpeed, RAF ID)
- ✅ 管理 RAF 动画循环(animationLoop)
- ✅ 渲染模式决策(renderFrame → renderIncremental / renderWithBitmapCache)
- ✅ 使用 ImageBitmap 缓存时指定正确的逻辑尺寸(避免放大)
- ✅ 预渲染调度(scheduleNextPreRender, renderSingleAngleToBitmap)
- ✅ 处理滚动进度更新(updateScrollProgress)
- ✅ 性能统计收集(帧时间、最长帧、FPS)
- ✅ 资源清理(stopAnimation, dispose)
- ❌ 不生成数据,只读取 Mountain 数据
依赖:mountain.ts, config.ts, types.ts, renderer.ts
5. renderer.ts(渲染函数)
职责:纯函数,提供底层 Canvas 2D 绘制功能。
- ✅
drawMountainIncremental: 增量渲染(指定点范围) - ✅
drawMountainFull: 完整渲染(所有点) - ✅ 接收预计算数据,直接绘制
- ✅ 应用旋转偏移(scrollProgress)
- ✅ 无状态、无副作用、可测试
- ❌ 不涉及数据生成
- ❌ 不涉及动画控制
依赖:types.ts
6. config.ts(配置常量)
职责:集中管理所有配置常量。
- ✅
NOISE_CONFIG: 噪声参数(频率、持续性、振幅) - ✅
SCALE_CONFIG: 透视缩放参数 - ✅
SHADING_CONFIG: 阴影和颜色配置 - ✅
OPTIMIZATION_CONFIG: 性能优化参数 - ✅
POINT_GENERATION_CONFIG: 点生成参数 - ✅
ROTATION_CONFIG: 旋转效果和缓存配置 - ✅
calculateRotationConstants: 旋转系数计算函数
依赖:无
7. types.ts(类型定义)
职责:定义所有数据结构和消息类型。
- ✅
Point: 点数据结构 - ✅
PrecomputedLine: 预计算线段 - ✅
WorkerMessage: 主线程 → Worker 消息(含 DPR 字段) - ✅
WorkerResponse: Worker → 主线程响应 - ✅ 其他接口和类型
依赖:无
🎯 使用示例
基础使用
import { CanvasMountain } from "./mountain";
<CanvasMountain
seed="mountain-1"
xOffset={0}
yOffset={0}
speed={1}
scrollProgress={scrollProgressMotionValue}
debug={false}
/>高级使用(自定义)
import { Mountain, MountainRenderer } from "./mountain";
// 1. 单独使用 Mountain 数据模型
const mountain = new Mountain({
seed: "custom-mountain",
width: 1920,
height: 1080,
xOffset: 0,
yOffset: 0,
debug: true,
});
await mountain.generate();
const points = mountain.getPoints();
const lines = mountain.getLines();
// 使用数据做自定义处理...
// 2. 使用 MountainRenderer
const renderer = new MountainRenderer(ctx, width, height, {
seed: "custom-mountain",
width: 1920,
height: 1080,
xOffset: 0,
yOffset: 0,
speed: 2,
debug: true,
});
await renderer.initialize();
renderer.startAnimation();
// 更新滚动
renderer.updateScrollProgress(0.5);
// 获取 Mountain 实例
const mountain2 = renderer.getMountain();
// 清理
renderer.dispose();🔧 配置说明
所有配置常量在 config.ts 中,包括:
- NOISE_CONFIG: 噪声参数
- SCALE_CONFIG: 透视缩放
- SHADING_CONFIG: 阴影和颜色
- OPTIMIZATION_CONFIG: 性能优化
- POINT_GENERATION_CONFIG: 点生成
- ROTATION_CONFIG: 旋转效果
🐛 调试
启用 debug 模式可以看到详细的性能统计:
<CanvasMountain debug={true} ... />输出包括: - 📊 点生成统计 - 📊 线段数量统计 - 📊 初始渲染性能(FPS、最长帧) - 🎨 预渲染进度和性能
🎓 设计思想
这个架构的核心思想是:
- MVC 模式:Model (Mountain) + View (MountainRenderer) + Controller (Worker)
- 单一职责:每个类只做一件事
- 数据与视图分离:Mountain 负责数据,MountainRenderer 负责渲染
- 面向对象:用类封装复杂的状态和行为
- 智能缓存:根据需要决定重新计算
- 性能优先:Worker + OffscreenCanvas + ImageBitmap
- 可维护性:清晰的文件结构和导出
架构演进
从最初的函数式编程到现在的 MVC 模式,架构经历了三个主要阶段的演进。
各阶段对比:
代码量统计:
| 模块 | 代码行数 | 主要职责 | 依赖关系 |
mountain.worker.ts | ~150 行 | 消息路由、生命周期管理 | 调用 Mountain 和 Renderer |
Mountain 类 | ~500 行 | 数据生成、存储 | 独立,无外部依赖 |
MountainRenderer 类 | ~550 行 | 渲染逻辑、动画控制、预渲染调度 | 依赖 Mountain 提供数据 |
renderer.ts | ~80 行 | 纯函数,Canvas 绘制 | 被 Renderer 使用 |
config.ts | ~100 行 | 配置常量 | 被所有模块使用 |
types.ts | ~130 行 | 类型定义 | 被所有模块使用 |
通过这种设计: - ✅ 单一职责:每个类只做一件事,边界清晰 - ✅ 易于测试:Mountain 可以独立测试数据生成,Renderer 可以 mock Mountain 测试渲染 - ✅ 可复用性:Mountain 可以在其他渲染器中使用,Renderer 可以渲染不同的 Mountain - ✅ 易于维护:修改数据生成不影响渲染,修改渲染不影响数据 - ✅ 易于扩展:可以轻松添加新的数据模型或渲染器