也能做!Canvas梦幻树生长动画从0到上线(附完整代码避坑指南)
小白也能做!Canvas梦幻树生长动画从0到上线(附完整代码避坑指南)
开篇:设计师能玩儿的,咱前端凭啥不行?
说真的,每次看到Dribbble上那些花里胡哨的动画效果,我心里都犯嘀咕——这玩意儿是不是得让UI设计师用After Effects做半个月,然后丢给个GIF给我们前端省事?直到有天半夜两点,我刷到一个会生长的树状动画,树枝像有生命一样分叉、延展,还带着那种朦朦胧胧的光晕效果,我当场就睡不着了。
凭啥认为这是设计师专属? Canvas就在那儿躺着呢,2D上下文API一直都是咱们前端的标准装备,只是大部分人拿它画个柱状图、饼图就觉得完事了。太浪费了,真的。这玩意儿能干的事多了去了,生成艺术、粒子系统、甚至是简单的游戏引擎,核心都是这块"破布"。
本文我要做的,就是把这个看起来高大上的"梦幻树"效果彻底扒光,从数学原理到代码实现,从性能优化到移动端适配,全部给你整明白。别怕,真·有手就行,只要你写过几行JavaScript,看完这篇文章明天就能在你的个人博客里挂上一棵会动的树。
更重要的是,我会把我自己踩过的坑、走过的弯路、甚至是一些"这代码明明看着对为啥就是不出效果"的玄学问题,全部倒出来。不是那种官方文档式的照本宣科,而是实打实的血泪经验。
缘起:那天半夜我刷到了啥?
事情是这样的。上周三凌晨一点半,我本该在睡觉,结果手贱点开了某个技术分享站。首页Banner里,一棵树从屏幕底部慢慢长出来,枝干分叉,然后开出一朵朵发光的小花,整个过程丝滑得像德芙。我第一反应是:“这得用WebGL吧?Three.js?shader代码是不是得写几百行?”
结果右键检查元素——淦,就是个canvas标签。没有WebGL,没有引入任何重型库,纯粹的2D Context,甚至连图片资源都没有,全是代码生成的。
那一刻我意识到自己陷入了一个误区:总觉得要出视觉效果就得往上堆技术栈,恨不得把GPU计算、物理引擎全搬出来。其实这个树效果的核心原理异常朴素:递归+随机数。就是高中数学那个递归,加上Math.random(),没了。
但朴素不代表简单。要让树看起来像树,而不是像电路板或者鸡爪子,里面的门道还是不少的。比如树枝的生长角度怎么控制才能自然?颜色怎么过渡才梦幻而不是杀马特?这些细节才是决定成品质感的分水岭。
Canvas这破布到底是啥?别被名字吓到
先给完全没接触过Canvas的同学(或者说接触过但只是画过 rectangle 的同学)快速补个课。
Canvas翻译成中文叫"画布",这个名字挺误导人的,让人觉得它像Photoshop一样是个图像编辑工具。实际上它更像是一张位图(Bitmap),你可以通过JavaScript在上面绘制像素。一旦画上去,那就是死的像素点,不是可编辑的矢量对象。这跟SVG有本质区别——SVG是基于DOM的,画出来的每个形状都是元素,可以单独绑定事件、修改属性;Canvas则是一次性涂鸦,画完拉倒。
// 基础 setup,每个 Canvas 项目都这么开始
const canvas = document.getElementById('treeCanvas');
const ctx = canvas.getContext('2d');
// 高清屏适配,这行代码不写,在手机上糊成一坨
const dpr = window.devicePixelRatio || 1;
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);
// 现在 ctx 就是画笔了,所有绘制操作都通过它
注意上面那段高清适配代码,这就是第一个坑。如果你不处理devicePixelRatio,在Retina屏上画出来的线条全是虚的,锯齿严重得让人怀疑人生。很多初学者直接设置canvas.width = window.innerWidth,然后在MacBook上一看,效果感人。
说白了就是在画分形,但加点随机就_MAGIC_
好了,核心原理时间。这棵树本质上是个分形(Fractal)。啥是分形?就是那种"整体和局部形状相似"的图形,比如雪花、海岸线、云朵。树也是典型的分形结构——大枝干分叉成小枝干,小枝干再分叉成更小的,循环往复。
最基础的分形树长这样:一条线段,末端分出两条线段,然后每条新线段的末端再分出两条,递归下去。如果用纯代码描述,核心就是个递归函数:
function drawBranch(startX, startY, length, angle, depth) {
if (depth === 0) return;
const endX = startX + length * Math.cos(angle);
const endY = startY + length * Math.sin(angle);
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
// 递归画左右两个分支
drawBranch(endX, endY, length * 0.7, angle - 0.5, depth - 1);
drawBranch(endX, endY, length * 0.7, angle + 0.5, depth - 1);
}
但这样画出来的是什么?是一把叉子,还是特别规整的那种。自然界没有这么对称的树,所以在每个分叉点引入随机因素才是关键。长度随机、角度随机、甚至连分叉的数量都可以随机(有时候分出3个枝也不是不行)。
而且真实的树生长是有过程的,不是瞬间出现的。这就涉及到动画实现。你不能一次性递归到depth=0,而是要一帧一帧地"长"出来。这就引出了下一个关键点:requestAnimationFrame。
动画流畅的秘诀:别再用setInterval了,求你了
我见过太多人做动画还在用setInterval或者setTimeout,然后跑过来问:“为啥我的动画一顿一顿的?”
兄弟,setInterval的定时器是不准确的。浏览器的事件循环机制决定了setInterval的回调可能不会被精准执行,尤其是当主线程忙碌的时候(比如你在挖煤…哦不,在解析JSON)。更重要的是,setInterval不管你屏幕的刷新率,硬着头皮往上画,结果就是掉帧。
requestAnimationFrame(以下简称rAF)是浏览器专门给动画准备的API。它跟屏幕刷新率同步(通常是60fps,高刷屏可能是120fps or 144fps),而且当页面不可见(比如切换了标签页)时,它会自动暂停,省电。这多贴心啊。
let startTime = null;
function animate(timestamp) {
if (!startTime) startTime = timestamp;
const progress = timestamp - startTime;
// 清屏,准备画新一帧
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 根据 progress 计算当前应该画到第几层了
const currentDepth = Math.min(maxDepth, Math.floor(progress / 500));
drawTree(currentDepth);
if (currentDepth < maxDepth) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
这里有个细节:清屏操作。很多人习惯用fillRect(0,0,width,height)然后用白色填充来"清屏",这在大多数情况下没问题,但如果你想做那种"拖尾效果"(就是旧帧慢慢淡出而不是完全消失),fillRect就力不从心了。而对于我们这种需要完全重绘的场景,clearRect是更好的选择,性能稍微好那么一丢丢,虽然现代浏览器优化后差别不大,但养成好习惯嘛。
递归:自己调用自己,代码看着晕但效果酷
递归这东西,对新手来说挺反直觉的。函数在执行过程中调用自己?不会死循环吗?会,所以一定要有终止条件(base case)。
在我们的树生长动画里,递归的终止条件就是深度(depth)归零,或者长度小于某个阈值。每一层递归,树枝的长度会衰减(通常是乘以0.7或0.8),角度会偏移。
但这里有个坑:JavaScript的调用栈深度是有限的。一般来说,几千层递归就会报"Maximum call stack size exceeded"。虽然我们画树不太可能用到几千层(通常7-10层就够了,不然密得看不见),但如果你要做那种无限生长的效果,就得考虑用迭代+队列(Queue)的方式来代替递归,也就是所谓的BFS(广度优先搜索)模式。
不过对于初学者,递归方式最直观,代码也最简洁。我们先搞定递归版,后面再聊优化。
L-System?算了算了,咱土法炼钢更实在
如果你去搜"程序化生成树",十有八九会跳出来L-System(Lindenmayer System)这个概念。这是个正经的数学形式化语言,用字符串替换规则来模拟植物生长。比如规则是:F -> FF+[+F-F-F]-[-F+F+F],然后不断迭代,最后把字符串解析成绘图指令。
老实讲,L-System很强大,但对于咱们这种小demo来说太重了。你需要先实现一个字符串迭代器,然后做个语法解析器,还要考虑状态栈(因为方括号代表分支的开始和结束)。等你把这一套撸完,热情都消磨光了。
我的建议是:手搓法(Ad-hoc)。直接写逻辑判断,哪根枝往左偏,哪根往右偏,全用硬编码的随机数控制。虽然不够"学术",但可控性极高,你想让树长成啥样就能长成啥样,不用受制于L-System的语法规则。
比如你想要一个"风吹效果",让树整体往右倾斜,在L-System里你得改产生式规则;在手搓代码里,你只需要在计算角度时加个windOffset变量,完事。
// 手搓法的核心:一个配置对象走天下
const config = {
branchAngle: Math.PI / 4, // 分叉角度基础值
angleRandom: 0.2, // 角度随机波动范围
lengthDecay: 0.75, // 长度衰减系数
minLength: 5, // 最小枝长度,小于就停止递归
growthSpeed: 2, // 每帧生长的像素数
windForce: 0 // 风力偏移量,可动态调整
};
function drawBranch(x, y, length, angle, depth) {
if (length < config.minLength || depth <= 0) {
// 画叶子,如果到了末梢
drawLeaf(x, y);
return;
}
// 风力影响,让整棵树可以随风摇摆
const windOffset = Math.sin(Date.now() / 1000 + depth) * config.windForce;
const actualAngle = angle + windOffset;
const endX = x + length * Math.cos(actualAngle);
const endY = y + length * Math.sin(actualAngle);
// 根据深度控制线条粗细,越细的枝颜色越浅
ctx.lineWidth = depth * 0.8;
ctx.strokeStyle = `hsl(30, 60%, ${20 + depth * 5}%)`; // 棕色系
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(endX, endY);
ctx.stroke();
// 随机分叉,有时候分2个,有时候分3个,更自然
const numBranches = Math.random() > 0.8 ? 3 : 2;
for (let i = 0; i < numBranches; i++) {
// 给每个分支不同的随机角度偏移
const angleOffset = (Math.random() - 0.5) * config.angleRandom * 2;
const newAngle = actualAngle - config.branchAngle * (numBranches - 1) / 2 +
config.branchAngle * i + angleOffset;
const newLength = length * config.lengthDecay * (0.9 + Math.random() * 0.2);
drawBranch(endX, endY, newLength, newAngle, depth - 1);
}
}
看到没,随心所欲。你想要3个分枝就3个,想要角度随机就随机,完全不用管什么文法产生式。
开撸!先让一根棍子在屏幕上长起来
理论说得差不多了,咱们开始正经写代码。第一步很简单:别想着直接画一整棵树,先让一根主干从屏幕底部升起来。
// 初始化设置,这部分前面讲过了,但完整代码还是要给
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
// 树的状态管理
const treeState = {
growing: true,
progress: 0, // 当前生长进度,0到1之间
maxDepth: 10, // 最大递归深度
baseLength: 120, // 主干初始长度
startX: canvas.width / 2,
startY: canvas.height,
startAngle: -Math.PI / 2 // 向上生长,-90度
};
function drawTrunk() {
const currentLength = treeState.baseLength * treeState.progress;
ctx.beginPath();
ctx.moveTo(treeState.startX, treeState.startY);
ctx.lineTo(
treeState.startX + Math.cos(treeState.startAngle) * currentLength,
treeState.startY + Math.sin(treeState.startAngle) * currentLength
);
ctx.lineWidth = 12; // 主干粗一点
ctx.lineCap = 'round'; // 这行很重要,让线头变圆,不会又尖又锯齿
ctx.strokeStyle = '#5D4037'; // 深棕色
ctx.stroke();
}
function animate() {
if (treeState.growing) {
treeState.progress += 0.01; // 每帧长1%
if (treeState.progress >= 1) {
treeState.progress = 1;
treeState.growing = false;
// 主干长完了,开始长分支,这里先留个钩子
}
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawTrunk();
if (treeState.growing) {
requestAnimationFrame(animate);
}
}
animate();
跑一下这段代码,你会看到一根粗线从屏幕底部长到中央。这就是地基。下一步是让这根主干顶端分叉。
给树枝整点性格:歪七扭八才自然
自然界没有完美的对称。如果每个分叉角度都一样,树看起来就像雪花或者电路板。混乱才是美。
我们要在几个维度上引入随机性:
1. 角度随机:基础分叉角度是45度,但可以在±20%范围内波动
2. 长度随机:子枝干长度是父枝的0.7倍,但可以有±10%的浮动
3. 粗细渐变:从根部到梢部,线条逐渐变细
4. 长度衰减非线性:有时候走得更慢,有时候更快
还有一个关键技巧:单亲继承。不要每个新枝都完全独立计算,而是让子枝的某些属性(比如弯曲方向)继承父枝的倾向。如果父枝向右偏了,子枝也倾向于向右偏,这样整个树会有统一的"姿态",而不是东倒西歪像炸开了一样。
class Branch {
constructor(x, y, length, angle, depth, parentBias = 0) {
this.x = x;
this.y = y;
this.length = length;
this.angle = angle;
this.depth = depth;
this.parentBias = parentBias; // 继承父枝的弯曲倾向
this.children = [];
this.grown = false;
this.currentLength = 0; // 用于动画,当前显示的长度
}
grow() {
if (this.currentLength < this.length) {
this.currentLength += 2; // 每帧长2像素
return true; // 还在生长中
}
if (!this.grown && this.depth > 0) {
this.spawnChildren();
this.grown = true;
}
// 递归让子枝生长
let growing = false;
for (let child of this.children) {
if (child.grow()) growing = true;
}
return growing;
}
spawnChildren() {
const numChildren = 2 + Math.floor(Math.random() * 2); // 2或3个分支
const baseAngle = Math.PI / 3; // 60度基础分叉角
for (let i = 0; i < numChildren; i++) {
// 分叉角度分布均匀,但加上随机偏移和父枝倾向
const angleOffset = (i - (numChildren - 1) / 2) * baseAngle;
const randomOffset = (Math.random() - 0.5) * 0.4; // ±0.2弧度随机
const newAngle = this.angle + angleOffset + randomOffset + this.parentBias * 0.3;
// 长度衰减,越往上越短,也有随机
const lengthDecay = 0.7 + Math.random() * 0.1;
const newLength = this.length * lengthDecay;
// 计算子枝起点(当前枝的末端)
const endX = this.x + Math.cos(this.angle) * this.length;
const endY = this.y + Math.sin(this.angle) * this.length;
// 子枝继承一部分父枝的弯曲倾向,并加上自己的随机倾向
const newBias = this.parentBias * 0.5 + (Math.random() - 0.5) * 0.3;
this.children.push(new Branch(endX, endY, newLength, newAngle, this.depth - 1, newBias));
}
}
draw(ctx) {
const endX = this.x + Math.cos(this.angle) * this.currentLength;
const endY = this.y + Math.sin(this.angle) * this.currentLength;
ctx.beginPath();
ctx.moveTo(this.x, this.y);
ctx.lineTo(endX, endY);
// 根据深度控制颜色和粗细,营造立体感
const thickness = Math.max(1, this.depth * 1.2);
ctx.lineWidth = thickness;
ctx.lineCap = 'round';
// HSL颜色:深度越大(越靠近根部),颜色越深
const lightness = 20 + (10 - this.depth) * 3; // 30%到20%之间
ctx.strokeStyle = `hsl(25, 40%, ${lightness}%)`;
ctx.stroke();
// 递归绘制子枝
for (let child of this.children) {
child.draw(ctx);
}
}
}
// 使用方式
const root = new Branch(canvas.width/2, canvas.height, 120, -Math.PI/2, 10);
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const stillGrowing = root.grow();
root.draw(ctx);
if (stillGrowing) {
requestAnimationFrame(animate);
}
}
animate();
看到这段代码里的parentBias了吗?这就是让树有"性格"的关键。如果没有这个参数,每次分叉都是完全独立的随机方向,树会长得很乱;有了这个继承机制,整棵树会有一种自然的整体弯曲感,就像被风吹拂或者向光生长那样。
配色别用RGB了,HSL才是梦幻感的爹
如果你还在用ctx.strokeStyle = '#FF6B6B'这种RGB十六进制写法来搞梦幻效果,那我只能说…也不是不行,但很折磨。想要那种柔和、会呼吸、会渐变的颜色,**HSL(色相、饱和度、亮度)**才是正解。
HSL的好处是参数意义明确:H(Hue)控制是什么颜色(0是红,120是绿,240是蓝),S(Saturation)控制鲜艳程度,L(Lightness)控制明暗。特别是做 Glow(发光)效果时,调整L值比调整RGB的三个分量直观多了。
梦幻色的秘诀通常是:低饱和度(20%-50%)、高亮度(60%-80%)、色相在连续范围内渐变。比如从青蓝(180)过渡到紫(280),营造出那种极光的感觉。
// 梦幻配色生成器
function getDreamColor(depth, time = 0) {
// 深度越小(越靠末梢),色相越偏蓝紫
const baseHue = 200 + (10 - depth) * 8; // 200-280之间,青到紫
const hueShift = Math.sin(time / 1000) * 20; // 色相随时间缓慢流动
const saturation = 50 + Math.random() * 20; // 50%-70%,不会太艳
const lightness = 60 + Math.sin(depth + time/500) * 15; // 呼吸灯效果
return `hsl(${baseHue + hueShift}, ${saturation}%, ${lightness}%)`;
}
// 在绘制时应用
ctx.strokeStyle = getDreamColor(this.depth, Date.now());
而且HSL配合ctx.shadowBlur和ctx.shadowColor做发光效果简直绝配:
ctx.shadowBlur = 15;
ctx.shadowColor = ctx.strokeStyle; // 阴影颜色跟线条颜色一致,营造发光感
ctx.stroke();
ctx.shadowBlur = 0; // 画完记得重置,不然影响其他元素
注意:shadowBlur是个性能杀手,在移动端尤其明显。如果你发现帧率掉到20fps以下,第一个要优化的就是减少shadowBlur的使用,或者只在最末梢的枝条上使用。
树叶飘落?先别急,让树能长起来就不错了
我知道你很急,想加上粒子效果、树叶抖动、甚至还有背景星空。但我建议先按住躁动的双手,确保核心的树生长逻辑完美无缺再说。
如果你现在就想加叶子,最简单的做法是在depth === 0(末梢)的位置画一些小圆点:
drawLeaf(x, y) {
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${100 + Math.random() * 60}, 70%, 60%)`; // 绿色系
ctx.fill();
}
但即便如此,也建议先完成主干生长,再回头细化叶子。做这种视觉效果,迭代开发比一次性追求完美更靠谱。先跑起来,再美化。
性能警告:别把手机搞成暖手宝
说到这儿我得严肃点。Canvas动画是吃性能大户,尤其是递归绘制+阴影效果+全屏尺寸。在电脑上可能跑满60fps,到了手机上直接变成PPT,顺便把你的iPhone变成取暖器。
几个保命优化技巧:
清屏优化:前面说了用clearRect。
减少绘制调用: ctx.stroke()每次调用都会有 overhead,如果能用beginPath画多条线然后一次性stroke,性能会更好。但对于我们这种树形结构,很难合并路径,所以退而求其次:如果帧率不够,降低递归深度。
避免在循环内创建对象:JavaScript的垃圾回收机制会导致卡顿(GC pause)。如果你在每一帧都new很多对象,内存忽上忽下,动画就会抽搐。
// 错误示范:每帧都创建新对象
function animate() {
const particles = []; // 糟糕,每帧都新建数组
// ...
}
// 正确做法:对象池
const particlePool = [];
function getParticle() {
return particlePool.pop() || {};
}
function recycleParticle(p) {
particlePool.push(p);
}
降帧策略:在移动端或者低性能设备上,可以主动把帧率降到30fps,人眼其实不太容易察觉,但性能压力减半:
let lastTime = 0;
function animate(timestamp) {
if (timestamp - lastTime < 33) { // 每33ms画一帧,约30fps
requestAnimationFrame(animate);
return;
}
lastTime = timestamp;
// ... 绘制逻辑
}
离屏Canvas:如果树是静态的(长完之后不再变化),可以把最终状态画到一个内存中的canvas(offscreen canvas),然后每一帧只是把这张图片drawImage到主canvas上,而不是重新递归计算整棵树。这招对于复杂背景或者静态元素特别有效。
// 创建离屏canvas
const offscreen = document.createElement('canvas');
offscreen.width = canvas.width;
offscreen.height = canvas.height;
const offCtx = offscreen.getContext('2d');
// 只在初始化时画一次
drawTree(offCtx);
// 动画循环里直接复制
function animate() {
ctx.drawImage(offscreen, 0, 0);
// 只更新动态部分,比如飘落的叶子
}
那些年我踩过的坑:树为啥长得像章鱼?
好了,到了吐血分享环节。我敢说下面这些坑你肯定也会遇到。
坑1:坐标系搞反了,树往下长或者横着长
Canvas的坐标系是:原点(0,0)在左上角,X轴向右,Y轴向下。所以如果你想让树向上生长,角度应该是-Math.PI/2(-90度),而不是Math.PI/2。我一开始搞反了,树直接往屏幕底下钻,跟土行孙似的。
还有就是Math.cos和Math.sin的参数是弧度不是角度,如果你传了90 expecting向上,它会给你算出一个奇怪的方向。
坑2:线条又尖又锯齿,看着像针管
默认的ctx.lineCap是’butt’(平头),线条端点是垂直于线段的平切,看起来特别生硬。加上ctx.lineCap = 'round'瞬间圆润,配合ctx.lineJoin = 'round'(线段连接处也圆角),质感提升十个档次。
坑3:动画播到一半卡住,刷新又好了
多半是递归里的终止条件写错了,或者cancelAnimationFrame用岔了。如果你在外部调用了cancelAnimationFrame但传错了ID,动画其实没停,然后你又启动了一个新的rAF循环,结果两个循环在争抢绘制,运气好能看到树发疯似的乱抖,运气差直接卡死。
建议用一个全局的animationId变量管理:
let animationId = null;
function startAnimation() {
if (animationId) cancelAnimationFrame(animationId);
function loop() {
// ... 绘制
animationId = requestAnimationFrame(loop);
}
animationId = requestAnimationFrame(loop);
}
function stopAnimation() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
}
坑4:树太对称,像假花
前面提过的随机性不足问题。记住:完美的对称只存在于数学公式,不存在于自然界。哪怕你引入0.1弧度的随机偏移,效果都会自然很多。
坑5:颜色太艳,像东北花棉袄
HSL的饱和度别拉太高,50%以下比较安全。还有就是别用纯黑纯白,稍微偏点色温(比如黑里加点蓝)会显得更高级。这是我从UI设计师朋友那儿偷来的经验。
高级骚操作:让树跟着音乐蹦迪
基础效果已经够炫了,但如果你想彻底秀翻全场,可以加上音频响应。
基本原理是用Web Audio API的AnalyserNode提取音乐频谱数据,然后把不同频率的能量值映射到树的属性上——比如低频(鼓点)控制树的整体摇摆幅度,高频(镲片)控制叶子的闪烁频率。
// 音频初始化
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
// 假设你已经把audio元素连接到了analyser
// audioSource.connect(analyser);
function getAudioData() {
analyser.getByteFrequencyData(dataArray);
// 简单处理:取平均值作为整体强度
const average = dataArray.reduce((a, b) => a + b) / bufferLength;
return average / 255; // 归一化到0-1
}
// 在绘制时应用
function animate() {
const audioLevel = getAudioData();
config.windForce = audioLevel * 0.5; // 音乐越强,树摇摆越厉害
// 也可以让颜色随音乐变化
const hueShift = audioLevel * 60;
ctx.strokeStyle = `hsl(${200 + hueShift}, 50%, 70%)`;
// ... 绘制树
}
注意Web Audio API需要用户交互(点击)才能触发,不能页面一加载就自动播放,这是浏览器的安全策略。
另外,暂停和重播功能其实就三行代码的事,但很多人会想复杂了:
let isPaused = false;
let growthProgress = 0;
canvas.addEventListener('click', () => {
isPaused = !isPaused;
if (!isPaused) animate();
});
function animate() {
if (isPaused) return;
// ... 绘制逻辑
growthProgress += 0.01;
if (growthProgress < 1) requestAnimationFrame(animate);
}
导出视频/GIF:想把动画发朋友圈?用canvas.toBlob()或者canvas.toDataURL()把每一帧导出成图片,然后用ffmpeg.js或者后端工具合成视频。实时录屏的话,可以用MediaRecorder API捕获canvas流:
const stream = canvas.captureStream();
const mediaRecorder = new MediaRecorder(stream);
const chunks = [];
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
// 生成下载链接
};
mediaRecorder.start();
// ... 动画播放
setTimeout(() => mediaRecorder.stop(), 5000); // 录5秒
这玩意到底能干啥用?给客户演示神器
你可能会问:花这么大功夫搞棵树,除了发朋友圈骗赞,业务上能用吗?
太能了。
首页加载动画:传统的loading spinner看吐了,让用户盯着一棵慢慢生长的树等3秒,体验好十倍。心理学上这叫"感知性能"——用户觉得有趣的时候,等待时间会变短。
404页面:“您访问的页面像这棵树一样还在生长中”——诗意又解嘲。
数据可视化:虽然是2D,但抽象成树状图展示层级数据(比如文件系统、组织架构),比D3.js轻量多了。
SaaS产品的科技感动效:当客户说"要有科技感、要有生命力"这种抽象需求时,甩出这个效果,大概率能过稿。比那些旋转的立方体强多了。
而且Canvas是浏览器原生支持的,不需要引入React、Vue、Three.js这些重型依赖,一个HTML文件就能跑,部署到CDN上加载速度飞快。这在某些对性能要求苛刻的场景(比如嵌入式设备、老旧浏览器)反而是优势。
尾声:别卷WebGL了,2D也能玩出花
写到最后我想说的是,现在前端圈有点太卷WebGL了,动不动就要上Three.js、Shader、粒子系统。但其实Canvas 2D Context的能力被严重低估了。配合好的算法和审美,2D完全可以做出让人哇塞的效果,而且调试成本低、兼容性好、代码量少。
下次你可以试试:
- 让树在生长过程中开花(在末梢用粒子效果模拟花瓣飘落)
- 让树的形状向鼠标靠近(类似磁铁吸引,但柔和一点)
- 甚至让树长成公司logo的轮廓(把分叉角度按预设路径调整)
脑洞比代码重要。就像这次,谁能想到简单的递归+随机数+渐变色,就能造出梦幻感呢?
代码已经全给你了,去试试吧。搞砸了也没关系,反正除了你的浏览器,没人会嘲笑你。搞成功了记得发给我看看,让我也哇塞一下。
(完)

&spm=1001.2101.3001.5002&articleId=157446338&d=1&t=3&u=5ef1c856fc974e9bbc77de374f56323e)
2835

被折叠的 条评论
为什么被折叠?



