资讯动态

Canvas图像处理核心:从像素数据操作到生产级导出

发布时间:2026/10/9 15:33:39 来源:尧图企业网站定制
1. 这不是“画布”是网页里的实时图像处理引擎很多人第一次看到 Canvas 标签下意识觉得“哦就是个能画画的白板”。我带过十几期前端入门班八成学员在学完前两周都还停留在“用 moveTo lineTo 画个歪歪扭扭的三角形”阶段——然后就卡住了。其实 Canvas 的真实身份根本不是美术工具而是一个运行在浏览器里的轻量级图像处理管线。它不渲染 DOM 元素而是直接操作像素它不依赖 CSS 布局却能每秒重绘 60 次以上它没有“图层”概念但你可以用 save/restore 实现比 Photoshop 更灵活的状态快照。你标题里写的“图像”两个字才是关键落点Canvas 的核心价值从来不在画线、画圆这些基础图形上而在于对图像数据的读取、变换、合成与输出。比如某高校数字媒体实验室做的一个课堂 Demo只用 87 行 JS 就实现了实时摄像头画面的灰度化边缘检测动态阈值二值化全程不调用任何第三方库所有计算都在 canvas.getContext(2d) 的 imageData 上完成。这背后不是“怎么画”而是“怎么把一张图拆成数组、改完再塞回去”。所以本篇不讲“如何画五角星”只聚焦一件事把图像当数据来操作。适合刚写完第一个 HTML 页面、知道 img 标签但没碰过 getImageData 的人也适合会写 Vue 组件却搞不清 canvas 为什么不能用 v-model 的中级开发者。如果你的目标是做出带滤镜的上传预览、实现网页版简易 PS、或者给 WebGL 打前站那这条路线你绕不开。2. 内容整体设计与思路拆解为什么必须从“图像数据”切入2.1 放弃“绘图思维”建立“内存帧缓冲”认知初学者最大的认知陷阱是把 canvas 当成 SVG 或 div 那样的“声明式容器”。SVG 里写circle cx50 cy50 r20/浏览器就记住这个圆div 里加stylebackground:red样式就挂在那里。但 canvas 是命令式即时渲染你调用fillRect(0,0,100,100)浏览器立刻在当前帧画一块红方块画完就忘——它不会保存“这里有个红色方块”的状态。这种设计不是缺陷而是为性能让路。Canvas 的底层本质是一块可被 JavaScript 直接读写的内存区域framebuffer就像老式显卡的显存。你调用getImageData(x,y,w,h)拿到的不是图片对象而是一个Uint8ClampedArray数组里面按 RGBA 顺序排列着每个像素的 0-255 数值。比如一个 2×2 的纯红小图其 imageData.data 长度是 2×2×416内容是[255,0,0,255, 255,0,0,255, 255,0,0,255, 255,0,0,255]。理解这一点才能明白为什么“画图”要分三步走先clearRect清空上一帧否则新旧图像会叠加再drawImage或putImageData写入新数据最后浏览器在下一帧刷新时把整块内存刷到屏幕上。这不是编程习惯问题而是硬件渲染逻辑的映射。2.2 路线设计逻辑从“加载图像”到“修改像素”再到“导出结果”整个学习路径必须严格遵循数据流向而不是功能罗列。我见过太多教程一上来就教arc()画圆结果学员学到createPattern()就懵了——因为中间缺了最关键的一环图像数据如何进入 canvas所以本路线强制拆解为三个不可跳过的阶段图像加载与准备阶段解决“怎么把一张图放进 canvas”这个看似简单实则坑多的问题。重点不是img.src x.jpg这一行代码而是img.onload的时机陷阱、跨域限制的绕过方案CORS 属性设置、以及drawImage九种参数组合的实际意义比如drawImage(img, sx,sy,sw,sh, dx,dy,dw,dh)中前四参数控制裁剪源图后四参数控制缩放绘制到 canvas 上的位置和尺寸。像素级操作阶段这是真正的核心战场。必须亲手写一遍灰度转换公式gray 0.299*R 0.587*G 0.114*B并对比ctx.fillStyle gray这种 CSS 灰度的欺骗性——后者只是视觉模拟前者才是真正改变每个像素的 RGB 值。这里要引入Uint8ClampedArray的内存特性它的索引是线性的第 i 个像素的 R 值在data[i*4]G 在data[i*41]B 在data[i*42]A 在data[i*43]。很多初学者循环写成for(let i0; idata.length; i)却忘了data.length是像素总数的 4 倍导致越界或漏处理。结果导出与复用阶段学完修改像素必须立刻解决“改完怎么用”的问题。toDataURL(image/png)生成 base64 字符串只是起点更要掌握toBlob(callback, type, quality)的异步回调机制避免阻塞主线程以及如何把 Blob 转成 URL 供img标签显示或fetch上传。某次实际项目中我们需将用户上传的证件照自动裁切为 1寸/2寸两种规格并生成下载链接就是靠canvas.toBlob()生成两个不同尺寸的 Blob再用URL.createObjectURL()创建临时地址——整个过程不经过服务器纯前端完成。2.3 为什么跳过“动画”和“游戏开发”先守住图像基本盘网上大量 Canvas 教程把“做个小球弹跳”当入门案例这严重误导初学者。小球动画的核心是requestAnimationFrame的时间控制和物理公式跟图像处理毫无关系。而你的标题明确指向“图像”说明需求场景更可能是电商商品图批量加水印、教育平台手写公式识别预处理、或是医疗影像的简易对比度调节。这些场景的共性是单帧图像处理精度 多帧动画流畅度。所以本路线刻意弱化requestAnimationFrame只在最后“性能优化”环节提一句当你要对视频流逐帧处理时才需要它。现在先把一张静态图的每个像素都摸透比让十个球同时弹跳更重要。我试过让学员先花三天只练getImageData → 修改 → putImageData这个闭环后续学滤镜、混合模式时理解速度直接提升两倍——因为肌肉记忆已经形成。3. 核心细节解析与实操要点从加载到导出的硬核细节3.1 图像加载跨域、时机、尺寸失真的三重雷区Canvas 加载外部图片90% 的报错都集中在这三个点。别信“只要图片能正常显示在页面上canvas 就能画”的说法这是最大误区。提示Canvas 对跨域图片有严格限制。即使图片在img标签里显示正常一旦用drawImage()绘制到 canvas 上再调用getImageData()就会触发SecurityError: Failed to execute getImageData on CanvasRenderingContext2D: The canvas has been tainted by cross-origin data.跨域问题的实操解法第一步检查图片资源是否支持 CORS。打开浏览器开发者工具 Network 面板找到图片请求看 Response Headers 里是否有Access-Control-Allow-Origin: *或具体域名。没有那就不能直接用。第二步服务端配置最优解。如果是自己部署的图片服务在 Nginx 配置里加add_header Access-Control-Allow-Origin *;Apache 加Header set Access-Control-Allow-Origin *。注意*不允许携带凭证如 cookies若需认证必须指定具体域名。第三步前端妥协方案仅限开发测试。如果图片来自第三方且无法改服务端可用代理中转本地起个 Express 服务用axios.get(imgUrl, { responseType: arraybuffer })获取二进制数据再用res.set(Content-Type, image/jpeg).send(buffer)返回此时前端请求自己的代理地址就无跨域问题。但生产环境严禁此法延迟高且增加服务器负担。时机陷阱的避坑要点// ❌ 错误示范img.src 设置后立即 drawImage const img new Image(); img.src photo.jpg; ctx.drawImage(img, 0, 0); // 此时 img 还没加载完canvas 画出来是空白 // ✅ 正确流程必须等 onload 触发 const img new Image(); img.crossOrigin anonymous; // 关键声明跨域请求 img.onload () { ctx.drawImage(img, 0, 0); // 此刻才能安全调用 getImageData const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); }; img.src photo.jpg; // src 必须在 onload 绑定后设置注意img.crossOrigin anonymous必须在img.src赋值前设置否则无效。另外onload只在图片首次加载时触发如果src重复赋值相同 URL可能触发缓存而不触发 onload此时要用img.decode()强制解码。尺寸失真问题的根源与修复 Canvas 的width和height属性HTML 属性定义的是绘图表面的像素数而 CSS 的width/height样式属性定义的是显示尺寸。两者不一致就会拉伸失真。例如canvas idmyCanvas width400 height300 stylewidth:200px;height:150px;/canvas此时 canvas 实际有 400×300 像素的绘图空间但被 CSS 压缩到 200×150 显示所有绘制内容都会模糊。正确做法是方案一推荐CSS 尺寸与 HTML 属性完全一致即stylewidth:400px;height:300px;方案二用 JS 动态设置适配响应式function resizeCanvas() { const dpr window.devicePixelRatio || 1; canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.scale(dpr, dpr); // 让绘图坐标系匹配高分屏 } window.addEventListener(resize, resizeCanvas); resizeCanvas(); // 初始化3.2 像素操作Uint8ClampedArray 的内存布局与高效遍历getImageData().data返回的Uint8ClampedArray是 Canvas 图像处理的命脉。它的“Clamped”特性意味着当你给某个元素赋值data[i] 300它会自动钳制为 255赋值-10则变为 0。这省去了手动判断边界但新手常忽略其线性索引规则。像素索引计算公式必须烂熟于心设 canvas 宽度为w高度为h目标像素坐标为(x, y)x 从左到右y 从上到下该像素在data数组中的起始索引为index (y * w x) * 4对应的四个值为data[index]→ R 值0-255data[index1]→ G 值0-255data[index2]→ B 值0-255data[index3]→ A 值0-2550 为完全透明高效遍历的三种写法对比// 方法一传统 for 循环最清晰推荐新手 const {data, width, height} ctx.getImageData(0,0,w,h); for (let y 0; y height; y) { for (let x 0; x width; x) { const i (y * width x) * 4; const r data[i], g data[i1], b data[i2]; // 处理逻辑... } } // 方法二单层 for 循环性能略优适合大图 const len data.length; // len width * height * 4 for (let i 0; i len; i 4) { const r data[i], g data[i1], b data[i2]; // 注意此处丢失了 x,y 坐标若需坐标相关操作如径向渐变不适用 } // 方法三TypedArray 方法函数式但创建新数组有开销 const rArr new Uint8Array(width * height); const gArr new Uint8Array(width * height); // ... 分离通道适合需要独立处理 R/G/B 的场景实操心得灰度化必须手写别用 CSS filter网上常见“用ctx.filter grayscale(100%)实现灰度”这是伪需求。ctx.filter是 CSS Filter 的 canvas 版本它只影响后续绘制的图形不会改变已存在的像素数据。也就是说你drawImage()画完原图再设filter然后getImageData()拿到的还是原始 RGB 值真正灰度化必须操作data数组function grayscale(imageData) { const {data} imageData; for (let i 0; i data.length; i 4) { const r data[i], g data[i1], b data[i2]; // 加权平均法人眼对绿色最敏感 const gray 0.299 * r 0.587 * g 0.114 * b; data[i] data[i1] data[i2] gray; // RGBgray } return imageData; } // 使用ctx.putImageData(grayscale(ctx.getImageData(0,0,w,h)), 0, 0);3.3 导出与复用toDataURL 与 toBlob 的生产级选择toDataURL()简单粗暴但toBlob()才是生产环境的标配。原因很现实base64 字符串体积比原始二进制大 33%一张 2MB 的 JPG 转成 base64 后变成 2.6MB传输慢、内存占用高且无法流式处理。toBlob 的完整工作流// 步骤1生成 Blob canvas.toBlob( (blob) { // 步骤2创建 Object URL浏览器内存中的临时地址 const url URL.createObjectURL(blob); // 步骤3用于 img 显示 const img document.createElement(img); img.src url; document.body.appendChild(img); // 步骤4用于下载模拟点击 const a document.createElement(a); a.href url; a.download processed-image.png; // 下载文件名 a.click(); // 步骤5清理内存重要否则内存泄漏 URL.revokeObjectURL(url); }, image/png, // MIME type 0.92 // quality仅对 image/jpeg/webp 有效 );注意toBlob是异步的回调函数在后台线程执行完毕后才触发。不要试图在回调外访问blob变量。质量参数的实测经验image/jpegquality0.92是黄金平衡点。低于 0.8 会出现明显色块高于 0.95 文件体积激增但肉眼难辨提升。image/webp现代浏览器支持同质量下体积比 JPEG 小 25%-30%。quality0.85即可媲美 JPEG 的 0.92。image/png无损压缩quality参数无效忽略即可。导出尺寸控制技巧 有时你需要导出固定尺寸如 100×100 头像但用户上传的是 2000×1500 大图。直接canvas.width100; canvas.height100;会导致 canvas 清空。正确做法是// 创建临时 canvas 用于缩放 const tempCanvas document.createElement(canvas); tempCanvas.width 100; tempCanvas.height 100; const tempCtx tempCanvas.getContext(2d); // 将原 canvas 内容缩放到 tempCanvas tempCtx.drawImage( originalCanvas, 0, 0, originalCanvas.width, originalCanvas.height, // 源区域全图 0, 0, 100, 100 // 目标区域填满 tempCanvas ); // 再从 tempCanvas 导出 tempCanvas.toBlob(callback, image/jpeg, 0.92);4. 实操过程与核心环节实现一个完整的证件照处理 Demo4.1 需求分析从模糊需求到可执行步骤假设我们要做一个“在线证件照处理工具”核心功能是用户上传照片 → 自动识别人脸区域简化版手动框选→ 裁切为标准尺寸如 295×413 像素→ 添加纯色背景蓝底/白底→ 导出高清 PNG。这不是理论练习而是某地政务服务平台的真实需求片段。我们将用纯 Canvas 实现不依赖 face-api.js 等大库。分解为 5 个原子操作图片上传与预加载解决跨域、尺寸适配交互式裁剪框拖拽、缩放、旋转裁切并填充背景核心图像操作高清导出适配打印 DPI批量处理为后续扩展埋点4.2 关键环节代码实现与原理注释环节1鲁棒的图片加载器class ImageLoader { constructor(canvas) { this.canvas canvas; this.ctx canvas.getContext(2d); } // 主入口处理 file input 或 url async load(source) { let img; if (source instanceof File) { // 处理文件上传 const url URL.createObjectURL(source); img await this._loadFromUrl(url); URL.revokeObjectURL(url); // 立即释放内存 } else if (typeof source string) { img await this._loadFromUrl(source); } this._fitToCanvas(img); return img; } async _loadFromUrl(url) { return new Promise((resolve, reject) { const img new Image(); img.crossOrigin anonymous; img.onload () resolve(img); img.onerror (e) reject(new Error(Failed to load ${url}: ${e.message})); img.src url; }); } // 将图片等比缩放到 canvas 内居中显示 _fitToCanvas(img) { const {canvas, ctx} this; const scale Math.min( canvas.width / img.width, canvas.height / img.height ); const w img.width * scale; const h img.height * scale; const x (canvas.width - w) / 2; const y (canvas.height - h) / 2; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, x, y, w, h); } }实操心得URL.createObjectURL()创建的地址必须手动revoke否则浏览器不会释放内存。曾有个项目因忘记这行用户连续上传 10 张大图后页面直接卡死。环节2裁剪框交互精简版我们不实现复杂的手势只做最核心的“拖拽平移”和“鼠标滚轮缩放”class Cropper { constructor(canvas, img) { this.canvas canvas; this.ctx canvas.getContext(2d); this.img img; this.offsetX 0; this.offsetY 0; this.scale 1; this.isDragging false; this.lastX 0; this.lastY 0; this.initEvents(); } initEvents() { this.canvas.addEventListener(mousedown, (e) { this.isDragging true; this.lastX e.offsetX; this.lastY e.offsetY; }); this.canvas.addEventListener(mousemove, (e) { if (this.isDragging) { this.offsetX e.offsetX - this.lastX; this.offsetY e.offsetY - this.lastY; this.lastX e.offsetX; this.lastY e.offsetY; this.render(); } }); this.canvas.addEventListener(wheel, (e) { e.preventDefault(); const zoom e.deltaY 0 ? 0.9 : 1.1; // 滚轮向下缩小 this.scale * zoom; // 缩放中心点为鼠标位置 const rect this.canvas.getBoundingClientRect(); const centerX e.clientX - rect.left; const centerY e.clientY - rect.top; this.offsetX centerX - (centerX - this.offsetX) * zoom; this.offsetY centerY - (centerY - this.offsetY) * zoom; this.render(); }); this.canvas.addEventListener(mouseup, () this.isDragging false); } render() { const {ctx, canvas, img, offsetX, offsetY, scale} this; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(offsetX, offsetY); ctx.scale(scale, scale); ctx.drawImage(img, 0, 0); ctx.restore(); // 绘制裁剪框固定 295×413居中 const cropW 295, cropH 413; ctx.strokeStyle #007bff; ctx.lineWidth 2; ctx.strokeRect( (canvas.width - cropW) / 2, (canvas.height - cropH) / 2, cropW, cropH ); } }注意ctx.save()/restore()是状态管理的基石。每次translate和scale都会改变坐标系restore()确保下次strokeRect在绝对坐标系中绘制。环节3裁切与背景填充核心图像操作// 假设用户确认裁剪获取裁剪区域 function cropAndFillBackground(canvas, cropX, cropY, cropW, cropH, bgColor #ffffff) { // 步骤1创建临时 canvas 存放裁切结果 const tempCanvas document.createElement(canvas); tempCanvas.width cropW; tempCanvas.height cropH; const tempCtx tempCanvas.getContext(2d); // 步骤2绘制背景色 tempCtx.fillStyle bgColor; tempCtx.fillRect(0, 0, cropW, cropH); // 步骤3将原 canvas 的指定区域绘制到 tempCanvas // 注意cropX/cropY 是相对于 canvas 左上角的坐标 tempCtx.drawImage( canvas, cropX, cropY, cropW, cropH, // 源区域从 canvas 上截取 0, 0, cropW, cropH // 目标区域填满 tempCanvas ); return tempCanvas; // 返回处理后的 canvas } // 使用示例 // const resultCanvas cropAndFillBackground( // originalCanvas, // (canvas.width - 295) / 2, // 裁剪框 X // (canvas.height - 413) / 2, // 裁剪框 Y // 295, 413, // #007bff // 蓝底 // );这里的关键洞察是背景填充必须在drawImage之前完成。因为drawImage是“覆盖绘制”如果先画图再填色背景会被盖住。fillRect绘制纯色是最快的操作比用putImageData填充整个数组高效得多。环节4高清导出适配打印政务系统要求打印分辨率为 300 DPI而屏幕通常是 96 DPI。这意味着同样 295×413 像素的图在 300 DPI 下物理尺寸只有约 2.5×3.5 厘米太小。我们需要放大像素function exportForPrint(canvas, dpi 300) { const screenDpi 96; const scale dpi / screenDpi; // 300/96 ≈ 3.125 const w canvas.width * scale; const h canvas.height * scale; const printCanvas document.createElement(canvas); printCanvas.width w; printCanvas.height h; const printCtx printCanvas.getContext(2d); // 关键关闭图像平滑避免放大后模糊 printCtx.imageSmoothingEnabled false; printCtx.drawImage( canvas, 0, 0, canvas.width, canvas.height, 0, 0, w, h ); return printCanvas; } // 最终导出 const printReady exportForPrint(resultCanvas, 300); printReady.toBlob( (blob) { const url URL.createObjectURL(blob); // 触发下载... }, image/png );提示imageSmoothingEnabled false是印刷级输出的关键。默认开启时canvas 会对缩放图像进行双线性插值产生柔化效果关闭后使用最近邻插值保持像素锐利适合文字和线条。4.3 性能优化大图处理不卡顿的实战技巧当处理 4000×3000 的原始照片时getImageData()可能返回 4800 万字节4000×3000×4的数据遍历一次就要几十毫秒UI 直接卡死。解决方案不是换语言而是用浏览器原生能力分流Web Worker 分离像素计算// main.js const worker new Worker(pixel-worker.js); worker.postMessage({ imageData: ctx.getImageData(0,0,w,h).data, // 传递 ArrayBuffer width: w, height: h, operation: grayscale }); worker.onmessage (e) { const {data} e.data; const newImageData new ImageData(new Uint8ClampedArray(data), w, h); ctx.putImageData(newImageData, 0, 0); }; // pixel-worker.js self.onmessage (e) { const {data, width, height, operation} e.data; const dataArray new Uint8ClampedArray(data); if (operation grayscale) { for (let i 0; i dataArray.length; i 4) { const r dataArray[i], g dataArray[i1], b dataArray[i2]; const gray 0.299*r 0.587*g 0.114*b; dataArray[i] dataArray[i1] dataArray[i2] gray; } } self.postMessage({data: dataArray.buffer}, [dataArray.buffer]); // 传输 ArrayBuffer };注意postMessage传递ArrayBuffer时必须用transferable选项第二个参数否则是深拷贝失去性能优势。GPU 加速备选方案WebGL 简易入门Canvas 2D 是 CPU 渲染WebGL 是 GPU 渲染。对于简单滤镜如灰度、卷积WebGL 可提速 5-10 倍。虽然学习曲线陡峭但一个最小可行的灰度 Shader 只有 20 行// vertex shader attribute vec2 a_position; void main() { gl_Position vec4(a_position, 0.0, 1.0); } // fragment shader precision mediump float; uniform sampler2D u_image; varying vec2 v_texCoord; void main() { vec4 color texture2D(u_image, v_texCoord); float gray 0.299*color.r 0.587*color.g 0.114*color.b; gl_FragColor vec4(gray, gray, gray, color.a); }这已超出 HTML5 基础范畴但值得知道Canvas 图像处理的终极形态是走向 WebGL。5. 常见问题与排查技巧实录踩过的坑比教程还多5.1 安全错误SecurityError的 7 种触发场景与对应解法Canvas 的跨域限制是新手头号敌人。以下是我在实际项目中记录的全部触发场景及验证方法场景触发条件验证方式解决方案1. 图片无 CORS 响应头img srchttps://cdn.example.com/photo.jpg且响应头无Access-Control-Allow-OriginNetwork 面板查看图片请求的 Response Headers联系 CDN 服务商开启 CORS或换用支持 CORS 的图床2. crossOrigin 属性设置过晚img.src x.jpg; img.crossOrigin anonymous;控制台报错The crossorigin attribute must be set before loading the image严格按new Image() → set crossOrigin → set src顺序3. 本地文件协议file://直接双击 HTML 文件打开图片路径为file:///path/to/img.jpg浏览器地址栏显示file://本地开发必须用http-server或 VS Code Live Server 启动 HTTP 服务4. Canvas 被污染后未重置先画了跨域图再画同域图仍报错canvas.toDataURL()报错即使第二张图是同域污染不可逆必须创建新 canvas 或刷新页面5. Data URL 图片被误判img.src data:image/png;base64,...控制台无报错但getImageData仍失败Data URL 默认同源无需crossOrigin删掉即可6. 服务端重定向导致 CORS 失效请求https://api.com/img302 跳转到https://cdn.com/img后者无 CORSNetwork 面板看到两次请求第二次无 CORS 头服务端避免跨域重定向或 CDN 配置 CORS7. Service Worker 拦截并修改响应头SW 拦截图片请求但未添加 CORS 头检查 SW 的fetch事件监听器在 SW 中对图片响应手动添加response.headers.append(Access-Control-Allow-Origin, *)提示快速验证是否跨域污染用这行代码console.log(canvas.toDataURL().slice(0,50))。如果报错说明已污染如果返回data:image/png;base64,...说明安全。5.2 图像模糊、锯齿、颜色失真的 5 个隐藏开关Canvas 渲染质量受多个隐式属性控制它们默认开启却常被忽略属性默认值问题表现设置建议原理imageSmoothingEnabledtrue图片缩放后发虚、文字边缘模糊处理图标/线条时设为false关闭双线性插值用最近邻算法保持像素锐利lineWidth1.0绘制细线如 1px 边框在高分屏上显示为 2px高分屏下设为1 / devicePixelRatio线宽是 CSS 像素非物理像素shadowBlur0开启阴影后图像整体变糊仅在需要阴影时设置用完立即ctx.shadowBlur 0模糊计算消耗 GPU且影响后续所有绘制globalCompositeOperationsource-over多次putImageData叠加导致颜色变深明确设置所需模式如copy替代多次putImageData混合模式决定像素如何叠加copy直接替换而非混合imageRendering(CSS)autoCanvas 元素本身被 CSS 缩放时模糊canvas { image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges; }CSS 层面的渲染提示强制像素艺术风格实操案例修复高分屏 1px 边框const dpr window.devicePixelRatio || 1; ctx.lineWidth 1 / dpr; // 逻辑宽度 1px物理宽度 1 像素 ctx.scale(dpr, dpr); // 坐标系放大使 1 单位 1 物理像素

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑