Web特效025---用 Canvas 2D 做Web特效的定义与边界:这支画笔能做到哪一步
Canvas 2D 常被说成网页上的一支画笔,可真正开始做特效时,问题很快就来了:它画完的圆为什么不能像 DOM 元素一样直接拖动?擦开一层雾面玻璃,为什么只画一张模糊图片还不够?画布尺寸、设备像素比和每帧重绘,又会把性能推到哪一步?本文以"擦开带有水珠的雾面玻璃,露出清晰产品画面"为贯穿案例,先界定 Canvas 2D 是什么、负责什么,再用路径、图片、离屏画布、合成模式和低分辨率遮罩拆解这个效果。我们还会解释即时模式带来的对象管理成本,为什么大面积模糊与逐像素更新不能随手塞进动画循环,以及何时该让 DOM、CSS 或 WebGL 接手。你可以从文章开头的资源链接体验完整案例,再对照文中的小段代码理解它如何工作。读完之后,你不仅能判断一支 Canvas 2D 画笔能否完成眼前的特效,也能为真实网页选择合适的渲染层次,而不是把所有视觉需求都硬塞进同一张画布。

| 本文资源 | 地址 |
|---|---|
| 配套案例:Web特效025 雾面玻璃擦拭效果 | 查看与获取案例 |
一、先给这支画笔划边界:Canvas 2D 到底是什么
1. getContext('2d') 给你的不是一个图形对象列表
页面里的 <canvas> 是一个绘图区域,getContext('2d') 返回的是向这块区域发出二维绘制命令的接口。它知道当前填充色、线宽、变换等绘制状态,却不会替你保存"这里有一颗可点击的水珠"这样的业务对象。
html
<canvas id="effect" width="800" height="450"></canvas>
<script>
const canvas = document.querySelector('#effect');
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1b9b9c';
ctx.fillRect(40, 40, 120, 80);
</script>
这段代码把一个矩形写入画布的像素。fillRect() 返回后,你不能通过 ctx.getRectangle(0) 找回它,因为并不存在这样的矩形节点。DOM 中的按钮可以作为节点保存、设置样式和监听点击;Canvas 中的矩形需要你自己保存坐标、尺寸和命中规则。
2. "画完就留下像素"意味着移动时要重新组织画面
如果矩形要向右移动,不能只修改"矩形节点的 left"。最直接的做法是保存它的位置,清理上一帧,再以新位置绘制。这里的 x 是以画布像素为单位的横坐标。
javascript
const box = { x: 40, y: 40, width: 120, height: 80 };
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#1b9b9c';
ctx.fillRect(box.x, box.y, box.width, box.height);
}
box.x += 20;
render();
这叫"即时模式":每条绘制命令执行时就影响目标位图,后续的对象状态和重绘顺序由应用自己管理。它很适合大量短命粒子、统一渲染的光斑和擦拭痕迹;如果画面里是十几个需要独立选中、输入文字和无障碍语义的控件,DOM 往往更省事。
3. CSS 大小与实际像素是两套尺寸
画布可能在 CSS 中显示为 800×450,但内部只有 800×450 个绘制像素。在设备像素比为 2 的屏幕上,细线可能被放大得发虚。通常要让绘制缓冲区按显示尺寸和设备像素比设置,并让代码继续用 CSS 像素思考布局。
javascript
function resizeCanvas(canvas, ctx) {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { width: rect.width, height: rect.height };
}
const size = resizeCanvas(canvas, ctx);
ctx.fillRect(20, 20, 100, 40); // 坐标仍按 CSS 像素理解
注意:给 canvas.width 或 canvas.height 赋值会清空画布并重置绘制状态,所以应在尺寸变化后重新设置变换并重新绘制。Canvas 2D 的第一条边界不是"能不能画",而是"你是否愿意自己管理像素、尺寸与重绘"。
二、它擅长画什么:路径、图片、像素和合成
1. 路径适合连续形状,不等于可编辑对象
beginPath()、moveTo()、quadraticCurveTo() 与 stroke() 能快速画出流线、光带和笔刷边缘。比如下面把三个点连成一条弯曲的擦拭轨迹;坐标单位仍是当前画布坐标系中的像素。
javascript
ctx.beginPath();
ctx.moveTo(80, 220);
ctx.quadraticCurveTo(240, 120, 440, 210);
ctx.lineWidth = 36;
ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(255,255,255,.7)';
ctx.stroke();
路径定义了这一笔如何画,绘制完成后并不会自动变成可拖拽的元素。你可以把路径保存在 Path2D 中并用 isPointInPath() 做几何命中,但对象列表、层级和交互状态仍要自己设计。
2. drawImage() 把位图接进同一套绘制流程
案例中一张清晰图片作为底图,一张模糊后的图片作为玻璃表面的雾层。drawImage() 既能画整张图,也能指定源区域和目标区域;绘制前要确保图片已经加载。
javascript
const photo = new Image();
photo.src = '/scene.png';
photo.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(photo, 0, 0, canvas.width, canvas.height);
};
这里 canvas.width 和 canvas.height 是绘制缓冲区像素;若代码已经用 ctx.setTransform(dpr, ...) 转换为 CSS 像素坐标,应改用上节 size.width、size.height 作目标尺寸。跨域图片若没有正确配置 CORS,画面可以显示,但随后读取像素或导出画布可能触发安全限制;"能画上去"不等于"能读取回来"。
3. 合成模式决定"新的一笔怎样作用于旧画面"
平常 source-over 会把新颜色覆盖到已有画面上。雾面玻璃案例需要相反的动作:在上层雾图中沿指针轨迹"挖洞",露出下层清晰图片。destination-out 会按新图形的不透明度擦除已有画布内容。
javascript
ctx.drawImage(frostLayer, 0, 0);
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(pointerX, pointerY, 80, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
假如雾层画布下方没有清晰底图,这一笔只会擦成透明,不会凭空生成清晰图像。合成模式是 Canvas 2D 很强的一面:遮罩、拖尾、局部揭晓和发光叠加都能由它完成;同时它也要求你明确"哪一层是源,哪一层是目标"。
三、用雾面玻璃案例看清:Canvas 2D 能做到哪一步
1. 先分层:清晰底图、雾层和交互不是同一件事
这个案例不是让 Canvas 每帧重新绘制整张产品图。底层放清晰图片,顶层 Canvas 保存雾气与水珠;顶层局部变透明时,底图自然透出来。下面是教学版结构,正式案例由 Vue 组件创建相同的两层。
html
<div class="scene">
<img class="scene__photo" src="/scene.png" alt="">
<canvas class="scene__fog" width="800" height="450"></canvas>
</div>
<style>
.scene { position: relative; width: 800px; height: 450px; overflow: hidden; }
.scene__photo, .scene__fog { position: absolute; inset: 0; width: 100%; height: 100%; }
.scene__photo { object-fit: cover; }
</style>
把图片、雾层和输入边界分开,才能单独替换产品图、调整雾的浓度,或把效果封装成网页组件。如果想直接体验"擦过---露出---回雾",可以打开文章开头资源表中的 025 配套案例。
2. 只生成一次雾层,别在每一帧反复模糊图片
雾气可以由"模糊且略降饱和的底图 + 半透明浅色遮罩 + 少量凝结水珠"组合得到。真正耗时的模糊与水珠绘制先放入离屏画布,只在图片或参数发生变化时重建;动画帧只复用这张缓存。
javascript
const frostLayer = document.createElement('canvas');
frostLayer.width = 800;
frostLayer.height = 450;
const fog = frostLayer.getContext('2d');
fog.filter = 'blur(18px) saturate(.7)';
fog.drawImage(photo, -20, -20, 840, 490); // 外扩绘制,减少边缘缺色
fog.filter = 'none';
fog.fillStyle = 'rgba(225,236,232,.32)';
fog.fillRect(0, 0, 800, 450);
上面 photo 是已加载图片,800×450 是示例画布的内部像素尺寸。正式案例还在缓存层绘制细小折射水珠,水珠属于"雾层",所以擦开时它们会一起消失。它们并不是漂浮在 DOM 上的独立小组件。
3. 全屏擦拭为什么需要"每块区域自己的时间"
如果每一次鼠标移动都存一颗圆形笔触,再给笔触数组设置数量上限,擦得越快,旧笔触就越快被新笔触挤掉。我们在调试案例时遇到过这个问题。解决方式不是不断提高数组上限,而是把低分辨率遮罩分成网格,为每格保存最后一次擦拭时间;该格按固定时长保持清晰、再逐渐回雾。
javascript
const maskWidth = 200, maskHeight = 113; // 教学版约为 800×450 的四分之一
const touchedAt = new Float64Array(maskWidth * maskHeight);
const holdMs = 5000, fadeMs = 2500;
function alphaAt(index, now) {
const last = touchedAt[index];
if (!last) return 0;
const age = now - last;
if (age < holdMs) return 255;
return Math.round(255 * Math.max(0, 1 - (age - holdMs) / fadeMs));
}
每次笔刷经过,只更新覆盖网格的 touchedAt;渲染时把这些透明度写入一张小遮罩,再放大到雾层画布上执行 destination-out。这不是说小网格没有代价,而是把每帧处理规模从整张高清画布降低到较小的二维数组。擦得快慢只改变新覆盖的区域,不再改变旧区域的消失时长。
四、画笔的第一条边界:像素不是会自己行动的对象
1. 要点击某颗水珠,先保存它的逻辑数据
一张雾层画布里可以有几百颗水珠,但 Canvas 不知道"第 37 颗水珠"在哪里。若产品需求要求点击某颗水珠弹出信息,应用必须保存水珠中心、半径、编号等数据,并自己做命中计算。
javascript
const drops = [{ id: 'a', x: 180, y: 90, radius: 12 }];
function hitDrop(x, y) {
return drops.find(drop =>
Math.hypot(x - drop.x, y - drop.y) <= drop.radius
) ?? null;
}
x、y 和 radius 必须在同一坐标系里。若指针事件给的是视口坐标,先减去画布的 getBoundingClientRect();如果内部像素尺寸与 CSS 尺寸不同,再按比例换算。坐标没有对齐,命中区就会偏移。
2. 已经画下的东西无法直接"改属性",需要选择重绘范围
让一颗水珠移动 10 像素,改的是你保存的 drop.x,而不是画布中的"水珠节点"。对简单动画,可以每帧清屏后按数组顺序重画;当背景很复杂时,也可以只重画受影响的区域,但必须处理对象重叠和边缘羽化。
javascript
function drawDrops(ctx, drops) {
for (const drop of drops) {
ctx.beginPath();
ctx.arc(drop.x, drop.y, drop.radius, 0, Math.PI * 2);
ctx.fill();
}
}
drops[0].x += 10;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawDrops(ctx, drops);
这段只演示对象状态与重绘关系;真实页面若还有背景和其他图层,清屏后也要按顺序补画。多个对象之间的遮挡与合成由绘制顺序决定,不像 DOM 那样天然有一棵可查询的元素树。
3. 复杂交互界面不要全部画进 Canvas
按钮、输入框、可复制的标题和屏幕阅读器需要的语义,如果一股脑画成像素,会额外付出命中、焦点、键盘操作和无障碍支持的成本。更实际的方案是让 Canvas 负责视觉特效,让 DOM 负责真正的界面。下面的监听示例保留原生按钮的点击语义,画布只响应指针:
javascript
const button = document.querySelector('#show-details');
button.addEventListener('click', () => {
document.querySelector('#details').hidden = false;
});
canvas.addEventListener('pointermove', event => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * canvas.width / rect.width;
const y = (event.clientY - rect.top) * canvas.height / rect.height;
console.log({ x, y });
});
这正是"边界"的意思:Canvas 2D 能画出按钮的外观,却不自动拥有 HTML 按钮的行为、语义和输入机制。把它用在擅长的视觉层,通常比要求它包揽整页更可靠。
五、画笔的第二条边界:分辨率与重绘成本
1. 画布越大,每帧搬运的像素越多
1920×1080 约有 207 万个像素,单张 RGBA 缓冲区按 4 字节计算约占 8 MB;如果同时有清晰底图、雾层缓存、遮罩和显示画布,占用与读写量会继续叠加。移动端再按设备像素比放大,成本可能成倍增长。因而应给 DPR 设置合理上限,并在尺寸变化时统一更新画布。
javascript
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
这里的上限 2 不是通用标准,而是性能与清晰度的一个起点。若案例需要大量粒子或模糊,低性能设备可以进一步降低内部渲染分辨率;若要精细绘制文字,则应重新评估清晰度损失。
2. 静态内容缓存,动态内容才进动画循环
如果雾层的模糊、水珠和底图在交互过程中都不变,没必要每帧重新执行滤镜和水珠路径绘制。把静态结果保存在离屏画布,交互帧只复制缓存并应用遮罩。下面用 dirty 标记控制什么时候需要一帧:
javascript
let dirty = false;
function requestRender() {
if (dirty) return;
dirty = true;
requestAnimationFrame(render);
}
function render() {
dirty = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(frostLayer, 0, 0);
// 此处再应用当前擦拭遮罩
}
requestRender();
这段适合静态或按需更新的图层。025 案例在"回雾"期间仍需连续动画帧,但完全恢复后就可以停下;下次指针进入再启动。成本取决于画布大小、模糊与像素处理次数,不取决于"用了 Canvas"这一个标签。
3. 先测量,再决定是否换渲染方案
假如画面里有几千条同时更新的粒子、全屏逐像素扭曲或多轮复杂滤镜,Canvas 2D 也许还能跑,但 CPU 端绘制与像素读写可能成为瓶颈。先测帧耗时,确认瓶颈在哪一步,再考虑 WebGL 或 WebGPU;不要只因"效果很酷"就提前换工具。
javascript
let last = performance.now();
function sampleFrame(now) {
const deltaMs = now - last;
last = now;
if (deltaMs > 32) console.warn('这一帧超过约 32ms:', deltaMs);
requestAnimationFrame(sampleFrame);
}
requestAnimationFrame(sampleFrame);
这个数值只能提示卡顿,不能证明卡在 Canvas 绘制,也可能是脚本、布局、图片解码或其他任务。进一步用浏览器性能面板分别看脚本执行、绘制、合成与内存。Canvas 2D 的边界应由目标设备和实测结果确定,而不是凭 API 名字猜测。
六、什么时候继续画,什么时候把笔交给别的工具
1. 先按对象特性选层,不按"高级"与"低级"排序
如果对象数量少、每个都要单独点击和承载语义,先考虑 DOM/CSS;如果是大量二维短命笔触、统一绘制的遮罩、图片合成或水珠纹理,Canvas 2D 很合适;如果核心工作是海量几何、着色器计算或复杂三维场景,再考虑 WebGL。它们不是逐级替代关系,而是面向不同控制范围的接口。
javascript
function chooseLayer({ semanticUI, many2DPixels, gpuShader }) {
if (semanticUI) return 'DOM / CSS';
if (gpuShader) return 'WebGL';
if (many2DPixels) return 'Canvas 2D';
return 'DOM / CSS';
}
console.log(chooseLayer({
semanticUI: false,
many2DPixels: true,
gpuShader: false
})); // Canvas 2D
这是帮助讨论的简化判断,不是自动选型器。同一网页可以同时有 DOM 按钮、Canvas 2D 雾层和 WebGL 背景。关键是把每一层的输入、输出和生命周期说清楚。
2. 做成网页组件时,入口与清理必须成对
把 025 案例放进 Vue 或其他组件体系时,画布绘制逻辑可以独立成函数,组件负责挂载、指针事件和销毁。即使不用框架,下面这段原生 JavaScript 也展示了"绑定---解绑"要成对出现:
javascript
function mountPointer(canvas, onPoint) {
function move(event) {
const rect = canvas.getBoundingClientRect();
onPoint({
x: (event.clientX - rect.left) * canvas.width / rect.width,
y: (event.clientY - rect.top) * canvas.height / rect.height
});
}
canvas.addEventListener('pointermove', move);
return () => canvas.removeEventListener('pointermove', move);
}
const unmountPointer = mountPointer(canvas, point => console.log(point));
// 组件卸载时调用 unmountPointer()
正式案例还要取消未完成的动画帧,并在图片加载回调、窗口尺寸监听等位置清理资源。真正可复用的"画笔"不是一堆能画出画面的 API,而是可以明确接入、更新与退出的模块。
3. 回到案例:这支笔能画到哪一步
025 案例表明 Canvas 2D 足以完成雾层缓存、凝结水珠、局部擦除和按区域回雾。清晰产品图交给底层图片元素,圆形笔刷跟着 Pointer Events 走,复杂的界面语义不塞进效果画布。即便未来把水珠换成雨痕、把底图换成建筑模型或产品剖视图,分层与边界判断仍能复用。
javascript
const effectConfig = {
brushRadius: 100, // 画布逻辑像素
holdMs: 5000, // 最后一次擦过后保持清晰
fadeMs: 2500 // 随后逐渐回雾
};
function revealAlpha(elapsedMs) {
if (elapsedMs < effectConfig.holdMs) return 1;
return Math.max(0, 1 -
(elapsedMs - effectConfig.holdMs) / effectConfig.fadeMs);
}
这段把效果最关键的时间边界明确成参数:无论鼠标移动多快,同一位置都按自己的最后擦拭时间恢复。Canvas 2D 的能力边界也由此清晰起来:它能高效地把二维绘制命令变成像素,并提供路径、图像、合成与像素处理;但对象管理、语义交互和性能策略,需要我们在画布外设计。选择它,不是因为所有网页特效都必须用 Canvas,而是因为当前这个效果的视觉任务确实适合这支画笔。
七、关于八荒启
八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。
我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。
八荒启,专为交互动画而生
让复杂事物可探索、可操作、可反馈
如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。
- 官方网站:https://bahuangqi.com
- 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
- 定制服务:可通过官网提交需求或联系人工客服进行评估
感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。