Claude Code Canvas开发指南:HiDPI、RAF、Pointer事件与验证
用Claude Code构建可靠Canvas界面,覆盖HiDPI、requestAnimationFrame、Pointer Events、状态管理与截图验证。
为什么Canvas开发容易翻车
Canvas适合做自定义图表、图片标注、粒子效果、教学动画、小游戏和产品配置器。它的自由度很高,因为你可以直接控制每一帧如何绘制;但它也不像普通DOM那样自动处理布局、输入和状态。如果只让Claude Code“做一个很酷的Canvas动画”,很容易得到一个桌面宽度可看、手机上崩掉的演示。
实际发布时,问题通常不在“有没有画出来”,而在细节:HiDPI屏幕上是否模糊,requestAnimationFrame是否被重复启动,触摸和手写笔是否能用,父容器变窄时是否横向滚动,截图里是不是只有黑色背景。Canvas越靠近正文、广告、购买按钮或咨询CTA,这些问题越会影响转化。
建议把本文和Claude Code动画实现指南、Claude Code Three.js 3D指南、Claude Code数据可视化指南一起看。官方资料可以参考Claude Code Docs、MDN Canvas API、requestAnimationFrame、Pointer Events和Playwright截图文档。
给Claude Code的提示词
好的提示词要先限定边界,而不是先描述视觉效果。HiDPI可以理解为高像素密度屏幕:同样的CSS尺寸,在真实设备上可能需要更多内部像素来绘制。如果只设置CSS宽度,不设置canvas.width和canvas.height,线条会被浏览器放大,最终显得发虚。
请实现一个Canvas 2D示例。
要求:
- 区分CSS像素和Canvas内部像素,并支持devicePixelRatio
- 使用requestAnimationFrame绘制,dt需要设置最大值
- 使用Pointer Events统一mouse、touch、pen输入
- 将绘制状态集中到state,render(ctx)只负责绘图
- 使用ResizeObserver跟随容器变化
- 375px手机宽度不能出现横向滚动
- 增加Playwright验证:可见性、非空像素、截图、移动端宽度
- 最后列出修改文件、风险、失败场景和手动检查项
这个提示词会让Claude Code生成“可维护的绘制系统”,而不是一次性的动效。对团队项目来说,这比单纯追求炫酷更重要。
结构图
在写代码前,先把输入、状态、更新、绘制和验证分开。这个图也可以直接放进给Claude Code的上下文里。
Pointer Events
|
v
input handler ---> state update ---> update(dt)
|
ResizeObserver ---> resize(dpr) v
render(ctx)
|
v
Playwright checks
render(ctx)不要注册事件,不要启动新的循环,也不要偷偷改DOM。它应该只从state读取数据并绘制。这样以后添加撤销、橡皮擦、截图测试或WebGL fallback时,代码不会互相缠在一起。
适用场景
第一个场景是内容站和运营后台的数据可视化。实时轨迹、密集散点、地图覆盖层、音频波形和动态教学图,不一定适合普通图表库。Claude Code可以快速生成绘制循环,但你仍然要检查空数据、加载中、移动端和CTA位置。
第二个场景是图片标注工具。截图审阅、课程批注、设计稿反馈都需要线条、矩形、箭头、标签和撤销。Pointer Events在这里很关键,因为用户可能用鼠标、手指或手写笔操作,支持pressure的设备还可以把笔压映射到线宽。
第三个场景是教学互动和轻量游戏。物理演示、英语单词训练、打字练习、粒子模拟都适合每帧更新状态。风险是生命周期管理:页面切换后动画循环没有取消,用户看不见问题,但手机会继续耗电。
第四个场景是产品页的交互预览。比如拖动查看产品颜色、比较参数、用粒子反馈点击状态。越接近购买或咨询流程,越要避免Canvas把按钮推到首屏之外。
可运行示例
下面的示例可以作为canvas-demo.html直接打开。重点是ctx.setTransform(dpr, 0, 0, dpr, 0, 0),它会在每次resize时重置缩放,避免ctx.scale反复叠加。
<style>
body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #111827; }
canvas { width: min(100%, 720px); aspect-ratio: 16 / 9; display: block; background: #020617; border: 1px solid #374151; border-radius: 8px; touch-action: none; }
</style>
<canvas id="demo" aria-label="Canvas particle demo"></canvas>
<script type="module">
const canvas = document.querySelector("#demo");
const ctx = canvas.getContext("2d");
const state = { width: 1, height: 1, dpr: 1, last: 0, pointer: { x: 0, y: 0, down: false }, dots: [] };
function resize() {
const rect = canvas.getBoundingClientRect();
state.width = Math.max(1, rect.width);
state.height = Math.max(1, rect.height);
state.dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(state.width * state.dpr);
canvas.height = Math.round(state.height * state.dpr);
ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
}
function point(event) {
const rect = canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top, pressure: event.pressure || 0.5 };
}
function emit(x, y, pressure = 0.5) {
for (let i = 0; i < 8; i += 1) {
const angle = Math.random() * Math.PI * 2;
const speed = 90 + Math.random() * 180;
state.dots.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1, size: 4 + pressure * 8 });
}
state.dots = state.dots.slice(-360);
}
canvas.addEventListener("pointerdown", (event) => {
canvas.setPointerCapture(event.pointerId);
const p = point(event);
state.pointer = { x: p.x, y: p.y, down: true };
emit(p.x, p.y, p.pressure);
});
canvas.addEventListener("pointermove", (event) => {
const events = event.getCoalescedEvents ? event.getCoalescedEvents() : [event];
for (const item of events) {
const p = point(item);
state.pointer.x = p.x;
state.pointer.y = p.y;
if (state.pointer.down) emit(p.x, p.y, p.pressure);
}
});
canvas.addEventListener("pointerup", () => (state.pointer.down = false));
canvas.addEventListener("pointercancel", () => (state.pointer.down = false));
function frame(now) {
const dt = state.last ? Math.min((now - state.last) / 1000, 0.033) : 0;
state.last = now;
for (const dot of state.dots) {
dot.vy += 220 * dt;
dot.x += dot.vx * dt;
dot.y += dot.vy * dt;
dot.life -= dt;
}
state.dots = state.dots.filter((dot) => dot.life > 0);
ctx.clearRect(0, 0, state.width, state.height);
ctx.fillStyle = "#020617";
ctx.fillRect(0, 0, state.width, state.height);
for (const dot of state.dots) {
ctx.fillStyle = `rgba(56,189,248,${dot.life})`;
ctx.beginPath();
ctx.arc(dot.x, dot.y, dot.size * dot.life, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "#e5e7eb";
ctx.fillText(`dpr ${state.dpr.toFixed(2)} / dots ${state.dots.length}`, 16, 24);
requestAnimationFrame(frame);
}
new ResizeObserver(resize).observe(canvas);
resize();
requestAnimationFrame(frame);
</script>
状态管理与移动端
Canvas不会保存“线条元素”。你需要在JavaScript里保存工具、颜色、线宽、点序列、撤销和重做。让Claude Code把状态更新写成小函数,再让绘制函数只读取状态,会比把所有逻辑塞进pointermove更可靠。
移动端最常见的问题是固定宽度和高度缺失。width: 800px会在手机上产生横向滚动;只有width: 100%但没有aspect-ratio或父容器高度,Canvas可能变成几乎不可见的一条线。发布前要把Canvas和正文、代码块、广告位、相关文章、CTA一起看,而不是只看单个组件截图。
Playwright验证
DOM断言无法证明Canvas真的画出了内容。至少要检查可见性、尺寸、交互后的非空像素和截图。
import { expect, test } from "@playwright/test";
test("canvas renders on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/canvas-demo");
const canvas = page.locator("canvas").first();
await expect(canvas).toBeVisible();
const box = await canvas.boundingBox();
expect(box?.width ?? 0).toBeLessThanOrEqual(390);
const paintedPixels = await canvas.evaluate((node) => {
const context = node.getContext("2d");
if (!context) return 0;
const data = context.getImageData(0, 0, node.width, node.height).data;
let painted = 0;
for (let i = 3; i < data.length; i += 4) if (data[i] > 0) painted += 1;
return painted;
});
expect(paintedPixels).toBeGreaterThan(1000);
await expect(canvas).toHaveScreenshot("canvas-mobile.png", { maxDiffPixelRatio: 0.03 });
});
第一次可以用--update-snapshots生成基准图,之后在评审中正常运行。失败时,把截图、视口、DPR和最近的CSS diff一起交给Claude Code分析。
常见失败
不要只改CSS尺寸而不改内部像素;不要在每次resize时重复ctx.scale;不要只监听mousemove;不要忘记取消动画循环;不要把Canvas固定成桌面宽度;不要只看截图而不做非空像素检查。尤其是内容站,Canvas旁边通常还有广告、文章正文和CTA。任何一个元素被挤出首屏,都会让用户少走一步。
变现与实测结果
Canvas可以帮助变现:互动图解能提升理解,标注工具能连接模板或课程,产品预览能引导咨询或购买。但它必须服务内容,而不是抢走内容。团队如果要把这些流程做成规范,可以通过Claude Code培训与咨询把提示词、代码审查和Playwright验证一起固化。
按本文流程实际尝试后,最有价值的是让Claude Code单独做一次“失败场景审查”。这一步能提前发现固定宽度、ctx.scale叠加、触摸输入不可用、手机横向滚动等问题。Canvas演示不一定要最炫,但必须稳定、清晰、可验证,才适合发布到真实页面。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。