ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Canvas drawImage 图片与视频绘制:时序、跨域与高清适配

Canvas drawImage 图片与视频绘制:时序、跨域与高清适配 做交互式白板、在线批注、视频封面截取这类需求时canvas的drawImage几乎是绕不开的一环。它看起来简单——不就是把一张图贴到画布上吗但真正上手你就会发现图片不显示、视频画出来是黑的、导出时报跨域错误、在高分屏上糊成一片这些问题会一个接一个冒出来。这篇内容就围绕canvas 绘制图片和视频这件事展开把drawImage的三种调用形态、图片加载时序、video 元素作为绘制源的边界条件、canvas 被污染后的排查链路以及高清屏适配与性能取舍一条条拆开讲清楚。不管你是刚开始接触 canvas 的新手还是已经写过几块画布、却总在某个环节卡住的老手这里应该都能找到能直接抄走的东西。1. drawImage 的三种参数形态先搞清源区域和目标区域drawImage是 CanvasRenderingContext2D 上唯一一个能把外部图像资源搬进画布的方法。所谓外部图像资源覆盖范围比很多人想象的要广HTMLImageElement、HTMLVideoElement、HTMLCanvasElement、ImageBitmap、OffscreenCanvas、VideoFrame这些都可以作为它的第一个参数。正因为来源多样它被设计成了三个重载参数个数分别是 3、5、9。1.1 三参数版本省事但拉伸变形往往从这里开始三参数的形式是drawImage(image, dx, dy)把整张图按照它自身的原始尺寸绘制到画布坐标(dx, dy)的位置。dx、dy是目标区域左上角的坐标不是中心点这一点必须记牢——很多人习惯性地把中心点当作定位基准画出来偏了一半才发现问题。这个版本最容易被误用。假设你有一张 4000×3000 的相机原图直接drawImage(img, 0, 0)贴到一个 800×600 的画布上结果是画布只能显示原图左上角那一小块剩下全被裁掉。反过来如果原图只有 100×100画到 800×600 的画布上就是左上角孤零零一个小方块。所以三参数版本只适合原图尺寸和目标尺寸本来就匹配的场景比如离屏 canvas 之间的像素级拷贝。1.2 五参数版本整图缩放适合做等比铺满五参数的形式是drawImage(image, dx, dy, dWidth, dHeight)多出来的两个参数控制目标区域的宽高。也就是说它把整张图缩放到dWidth × dHeight的矩形里再放到(dx, dy)。这里有个高频坑缩放不等于等比缩放。如果你给的dWidth和dHeight跟原图宽高比不一致图像会被硬生生拉扁或抻长。做头像、封面这类视觉要求高的场景绝对不能让比例失控。常见的cover效果短边铺满、长边裁掉多余部分就必须自己算// cover把 srcW×srcH 的图塞进 dstW×dstH 且不变形 function drawCover(ctx, img, dx, dy, dstW, dstH) { const scale Math.max(dstW / img.width, dstH / img.height); const drawW img.width * scale; const drawH img.height * scale; // 让图像中心对齐目标区域中心 const offsetX dx (dstW - drawW) / 2; const offsetY dy (dstH - drawH) / 2; ctx.drawImage(img, offsetX, offsetY, drawW, drawH); }这段代码的逻辑是先算出短边铺满所需的缩放倍数Math.max再按缩放后的实际尺寸反推居中偏移。整个过程不需要裁剪多余部分会被画布自身边界自然裁掉。把Math.max换成Math.min就变成contain效果整图完整可见、四周留白。1.3 九参数版本源裁剪与目标缩放解耦九参数是drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)。前四个参数描述从源图上切哪一块source后四个描述画到目标位置的哪个矩形destination。参数名的前缀就是最好的记忆线索s开头属于源d开头属于目标。裁剪和缩放两件事在这里被彻底解耦这让很多效果变得干净利落。比如我要从视频里截取右上角的画中画区域当作小窗显示// 从视频 0.1 起、宽 640 高 360 的位置切 320×180画到画布 100,100 处放大到 480×270 ctx.drawImage(video, 0.1 * video.videoWidth, 0.1 * video.videoHeight, 320, 180, 100, 100, 480, 270);需要强调的是sx、sy、sWidth、sHeight允许用小数但实际采样时会被取整处理做像素级对齐时建议自己先算好整数。另外如果源区域超出原图边界超出部分会被当成透明处理——这在有些场景下是免费的安全裁剪但在需要校验数据的场景里就要自己加边界判断。调用形态源区域目标区域典型用途三参数整图原尺寸离屏 canvas 像素拷贝、尺寸已知的贴图五参数整图自定义宽高头像、封面、等比缩放九参数自定义矩形自定义矩形局部裁剪、放大镜、画中画、切片1.4 关于「9. 绘制」这个编号的一点说明标题里的9.通常是系列文章的第几讲跟九参数没有关系。但我顺手提一句如果你是按第几篇来组织笔记的建议在正文开头写清楚本篇依赖的前置知识比如第 8 讲讲的是路径与变换否则后面回看时容易断片。这种小习惯在写技术笔记时特别值钱。2. 图片不显示先查加载时序再查 decodedrawImage最常见的失效表现不是报错而是静默地什么都不画。打开控制台干干净净画布上也是一片空白。九成以上的原因是图片还没加载完成你就已经调用了drawImage。2.1 Image 对象的异步本质与 onload 的必要性new Image()创建出来的对象给它赋src只是发起了一个资源请求它不会阻塞后面的代码。也就是说const img new Image(); img.src cover.png; ctx.drawImage(img, 0, 0); // 大概率什么都没画出来浏览器执行到第三行时cover.png可能还在网络传输中甚至还没开始解析。此时img虽然是一个合法对象但内部没有可用的像素数据drawImage会静默跳过。正确写法是等onloadconst img new Image(); img.onload () { ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight); }; img.onerror (e) { // 一定要处理错误分支否则失败时你连日志都没有 console.error(图片加载失败, e); }; img.src cover.png;这里我用的是naturalWidth/naturalHeight而不是width/height。区别在于如果你通过 CSS 或属性设置过图片显示尺寸width会返回那个被改动过的值而naturalWidth永远返回图片的固有像素尺寸。做 canvas 绘制时你要的是原始像素所以用natural系列。另外要提醒一句onload必须写在src赋值之前。虽然大多数浏览器在资源已缓存的情况下也能触发但这不是规范保证的行为跨浏览器时非常容易翻车。写代码时把赋值src放到最后一行是成本最低的保险。2.2 decode()让加载完成这件事变得可等待onload的问题在于它是回调式的事件模型多个图片、多层依赖时很容易写成回调地狱。现代浏览器提供了HTMLImageElement.decode()它返回一个 Promise并且在 Promise 兑现后图片不仅下载完成而且已经解码成可直接绘制的位图async function loadImage(url) { const img new Image(); img.src url; await img.decode(); return img; } const img await loadImage(cover.png); ctx.drawImage(img, 0, 0);decode()相比onload的优势在于onload触发时图片可能还处于下载完成但尚未解码的状态此时drawImage会触发一次同步解码阻塞主线程。而decode()把解码这一步显式放在了 Promise 前面绘制时就不会再有额外的解码开销。对于需要连续绘制、追求帧率稳定的场景这个差别很实在。提示decode()在部分较老的浏览器上不存在可以用img.decode ? img.decode() : new Promise(r img.onload r)做一次能力检测降级这样新旧环境都能跑。2.3 多图并发绘制的编排一个看板类页面常常要同时加载十几张图。逐个await会串行化请求白白浪费时间。这里用Promise.all并发const urls [a.png, b.png, c.png]; const imgs await Promise.all(urls.map(loadImage)); imgs.forEach((img, i) { ctx.drawImage(img, i * 120, 0, 100, 100); });有个细节值得注意Promise.all是全部成功才算成功任何一张图 404 都会让整个批次失败。如果页面对单张图失败容忍度较高应该用Promise.allSettled然后对失败的项做占位图处理const results await Promise.allSettled(urls.map(loadImage)); results.forEach((r, i) { if (r.status fulfilled) { ctx.drawImage(r.value, i * 120, 0, 100, 100); } else { drawPlaceholder(ctx, i * 120, 0, 100, 100); // 画一个灰底占位 } });注意allSettled打乱不了顺序results的下标和urls严格对应所以占位图能精准落到对应位置。这个特性在做图片列表时非常实用。3. 把 video 直接喂给 drawImage从静态帧到连续画面视频绘制最让人惊喜的一点是drawImage的第一个参数可以直接传HTMLVideoElement浏览器会把它当前播放到的那一帧当作一张位图交给你。这意味着把视频渲染进 canvas几乎不需要任何额外的解码代码。3.1 视频作为绘制源的两个前置条件第一个前提是视频元素必须有当前帧。判断标准是video.readyState 2也就是HAVE_CURRENT_DATA。如果低于这个值说明元数据、首帧数据都还没到位这时候drawImage传进去会抛出InvalidStateError注意这里和图片不同图片是静默不画视频直接抛异常。第二个前提是视频必须可解码。出于自动播放策略的限制很多浏览器要求视频处于静音或用户交互后才开始播放所以初次进页面时video.paused可能是true。但这不影响绘制——只要readyState达标即使视频处于暂停状态你依然可以把当前帧画到画布上这正是视频封面截图的实现基础。3.2 requestAnimationFrame 逐帧绘制才是正确节奏想要把视频搬进 canvas 连续播放直觉写法是用setInterval每 33 毫秒画一次。能跑但节奏很难和视频自身的刷新对齐容易产生抖动和撕裂。规范做法是跟着requestAnimationFrame走const video document.querySelector(#src); const canvas document.querySelector(#dst); const ctx canvas.getContext(2d); function renderLoop() { if (video.readyState 2) { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); // 如果想要原尺寸ctx.drawImage(video, 0, 0) } requestAnimationFrame(renderLoop); } video.addEventListener(loadeddata, () { requestAnimationFrame(renderLoop); });这里监听loadeddata而不是loadedmetadata因为前者保证首帧数据已经可用。让 rAF 循环和视频播放节奏自然对齐画面会明显更顺滑。另外不要在 rAF 回调里做重复的尺寸计算把canvas.width、video.videoWidth这类值缓存在循环外部能省下不少无谓的读取开销。3.3 首帧封面、暂停截图与跳转时序做视频封面时很多人的做法是video.currentTime 0然后立刻绘制——结果画出来是黑的。原因在于currentTime赋值只代表请求跳到这个时间点实际跳转完成后会触发seeked事件只有这个事件之后drawImage拿到的才是目标帧。function captureAt(video, time) { return new Promise((resolve) { const onSeeked () { video.removeEventListener(seeked, onSeeked); resolve(); }; video.addEventListener(seeked, onSeeked); video.currentTime time; }); } await captureAt(video, 2.5); ctx.drawImage(video, 0, 0, canvas.width, canvas.height);提示如果time正好等于视频当前时间某些浏览器不会触发seekedPromise 就会一直挂着。稳妥的写法是加一个超时兜底或者先判断Math.abs(video.currentTime - time) 0.01时直接 resolve。视频这块还有个小经验从视频里抽帧做封面的场景选择 1 到 3 秒之间的时间点通常能避开片头的纯黑帧动画拿到内容更完整的画面。这个规律我是踩了好几次截出来是纯黑图之后才总结出来的。4. canvas 被污染之后导出失败背后的完整排查链路图片和视频都能画了接下来多半会想做导出——把画布内容保存成图片。这时候一个经典的报错会跳出来Failed to execute toDataURL on HTMLCanvasElement: Tainted canvases may not be exported.这个坑不排查清楚会卡很久。4.1 污染tainted到底是怎么被触发的canvas 有一个安全机制只要画布上绘制过跨域获取的媒体资源这块画布就会被标记为已污染。被污染的 canvas 一切读操作都会被禁止包括toDataURL、toBlob、getImageData。写入操作不受影响——你照样能接着往上画。触发条件很简单图片或视频资源的来源域和当前页面的域不一致并且没有携带正确的跨域许可信息。注意图片能正常显示不代表 canvas 没被污染。很多同学习惯性认为图都画出来了说明没问题直到调toDataURL才炸。这是一条非常隐蔽的坑。4.2 crossOrigin 属性的设置时机比它的值更关键解决跨域绘制的第一步是给图片元素设crossOriginconst img new Image(); img.crossOrigin anonymous; // 必须在 src 之前 img.src https://other-domain.com/pic.png;这里最关键的一点是crossOrigin必须在src赋值之前设置。如果你先赋了src再回头改crossOrigin浏览器已经按非跨域模式发起了请求这个属性改了也无效图片依旧会污染画布。crossOrigin的值有两个合法选项anonymous和use-credentials。前者表示不发凭据cookie、客户端证书等适合公开资源后者表示携带凭据需要服务端把Access-Control-Allow-Credentials设为true。绝大多数静态资源场景用anonymous就够了。视频元素同理也需要在设置src之前设crossOrigin。4.3 服务端不配合时的绕行方案只设置前端属性是不够的。跨域请求还需要服务端返回Access-Control-Allow-Origin响应头。如果这个资源不是你控制的服务端不给这个头那么无论前端怎么设置都没用。一个完整的排查顺序应该是打开开发者工具的 Network 面板找到那张图片或视频的请求。看请求头里有没有Origin也就是浏览器有没有把它当跨域请求发。看响应头里有没有Access-Control-Allow-Origin值是否为*或你的域名。都没有的话问题在服务端或中间层前端解决不了。如果资源确实由自己团队控制让后端加上响应头是最干净的方案。如果资源属于第三方、无法改动那只能考虑在服务端做一层代理把资源下载后再以同源方式提供给前端。这个方案会增加服务端压力和版权层面的注意事项需要提前评估不要想当然地直接上。现象可能的原因排查动作图片能画但导出报错canvas 已被污染检查 crossOrigin 与响应头设了 crossOrigin 仍然污染属性设置晚于 src把设置语句挪到 src 之前设了 crossOrigin 后图片加载失败服务端未返回许可头查看 Network 响应头视频绘制抛 InvalidStateErrorreadyState 不足监听 loadeddata 后再绘制注意被污染的 canvas 是一次性的。一旦被污染后续即使再画上同源资源也不会恢复干净状态。唯一的办法是重新创建一块 canvas从头绘制。5. 高清屏、缩放与性能让画面不糊、不掉帧前四个部分解决的是能不能画出来这一段解决的是画得好不好看、跑得稳不稳。5.1 devicePixelRatio 与物理像素的换算在手机和视网膜屏上CSS 像素和物理像素不是 1:1 的关系。假设设备像素比是 3你给 canvas 设了 CSS 宽高各 300px如果不同步调整画布的width/height属性浏览器会把 300×300 的逻辑像素拉伸到 900×900 的物理像素上结果就是整体发虚。正确姿势是双轨制——CSS 负责显示尺寸width/height属性负责绘制分辨率const dpr window.devicePixelRatio || 1; const cssW 300, cssH 200; canvas.width Math.round(cssW * dpr); canvas.height Math.round(cssH * dpr); canvas.style.width cssW px; canvas.style.height cssH px; ctx.scale(dpr, dpr); // 之后所有绘制按 CSS 像素坐标写ctx.scale(dpr, dpr)这一行很关键。它让后续的所有绘制指令都在逻辑像素坐标系里工作你写drawImage(img, 10, 10)时不用手动乘 dpr。但要注意scale是有累积效应的如果画布被反复 resize缩放矩阵会被叠加必须每次都先ctx.setTransform(1, 0, 0, 1, 0, 0)重置。窗口尺寸变化时还要监听resize重新执行一遍上面的流程。同时要处理devicePixelRatio在跨屏拖动时发生变化的情况现代浏览器提供了matchMedia来监听const mql window.matchMedia((resolution: ${dpr}dppx)); mql.addEventListener(change, handleDprChange);这个细节知道的人不多但在双屏办公、外接显示器的场景下非常实用。5.2 视频逐帧绘制的性能账要提前算视频逐帧绘制的开销不小。按 60fps 计算每秒钟要执行 60 次drawImage如果画布是 1920×1080 且带 dpr 放大实际处理的可能是 4K 级别的像素量。这种强度下随便加一点滤镜或getImageData帧率立刻掉下来。几个可以直接用的优化手段降低目标分辨率。如果最终展示区域只有 480 宽就没必要先画到 1920 再缩回来。把 canvas 的width/height按实际展示尺寸乘以 dpr 设置是最直接有效的优化。能用 CSS 就别用 canvas。如果只是要显示视频不涉及逐帧图像处理直接用video标签性能最好别硬套 canvas。避免在循环里创建对象。像{x, y, w, h}这种临时对象在 60fps 循环里每秒创建几十次会给垃圾回收带来压力。能把数值缓存成基本类型的就缓存。按需渲染。视频暂停时没必要继续跑 rAF 循环。可以用requestVideoFrameCallback部分浏览器支持替代 rAF它只在视频真正有新帧时才回调能省不少空转。5.3 离屏 canvas 与 willReadFrequently 的选择频繁读取像素getImageData的场景建议在创建上下文时加上willReadFrequently: trueconst ctx canvas.getContext(2d, { willReadFrequently: true });这个提示会让浏览器把 canvas 放在 CPU 内存里而不是 GPU 显存里。GPU 上读像素需要往返搬运一次调用可能耗时十几毫秒放到内存里读取则快得多。代价是绘制性能会下降所以这个选项只适合读多写少的场景别默认加上。需要做图像合成、滤镜链时离屏 canvasOffscreenCanvas或者隐藏的canvas是标准做法先在离屏画布上把多张图叠好再一次性drawImage到主画布上。这样能减少主画布的绘制次数也避免中间状态被用户看到。6. 从画布反取内容导出、缩略图与九宫格拉伸绘制是入导出是出。这一节聊聊从画布里取内容的几种方式以及一个挺有意思的延展玩法。6.1 toDataURL 与 toBlob 该怎么选toDataURL返回的是 base64 编码的字符串用起来最省事直接塞给img.src或下载链接就行。但它有两个明显缺点一是 base64 会让数据体积比原始二进制大约三分之一二是字符串拼接和编码本身是同步操作大画布上调用会明显卡顿。toBlob返回Blob对象是异步的不会阻塞主线程而且可以直接配合URL.createObjectURL生成临时链接canvas.toBlob((blob) { const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download capture.png; a.click(); URL.revokeObjectURL(url); // 用完记得释放 }, image/png, 0.92);第三个参数是质量系数只对image/jpeg和image/webp这类有损格式生效image/png会忽略它。实测下来截图类场景用image/png照片类场景用image/jpeg或image/webp更划算。提示URL.createObjectURL产生的地址如果不主动revokeObjectURL对应的 Blob 会一直留在内存里。批量导出时这是内存泄漏的常见来源。6.2 用 drawImage 实现九宫格拉伸的等价效果九宫格拉伸nine-patch原本是移动端资源的一种设计一张图切成 3×3 的九块四个角保持原始尺寸四条边只在一个方向上拉伸中间区域双向拉伸。它的价值在于用一张小图适配任意尺寸的容器圆角按钮、气泡框这类 UI 元素用得特别多。canvas 里没有现成的九宫格 API但用九参数drawImage完全可以手搓出来。思路是把目标区域也切成九块逐块绘制function drawNinePatch(ctx, img, dx, dy, dw, dh, inset) { const { top: t, right: r, bottom: b, left: l } inset; const sw img.width, sh img.height; // 四个角原尺寸不拉伸 ctx.drawImage(img, 0, 0, l, t, dx, dy, l, t); ctx.drawImage(img, sw - r, 0, r, t, dx dw - r, dy, r, t); ctx.drawImage(img, 0, sh - b, l, b, dx, dy dh - b, l, b); ctx.drawImage(img, sw - r, sh - b, r, b, dx dw - r, dy dh - b, r, b); // 上下边横向拉伸 ctx.drawImage(img, l, 0, sw - l - r, t, dx l, dy, dw - l - r, t); ctx.drawImage(img, l, sh - b, sw - l - r, b, dx l, dy dh - b, dw - l - r, b); // 左右边纵向拉伸 ctx.drawImage(img, 0, t, l, sh - t - b, dx, dy t, l, dh - t - b); ctx.drawImage(img, sw - r, t, r, sh - t - b, dx dw - r, dy t, r, dh - t - b); // 中心双向拉伸 ctx.drawImage(img, l, t, sw - l - r, sh - t - b, dx l, dy t, dw - l - r, dh - t - b); }九次调用每次负责一块逻辑非常清晰。这个实现的关键收益是四个角永远保持原始像素不会因为拉伸而变形。圆角按钮放大到任意尺寸圆角依旧是圆角不会变成椭圆。有意思的是这套思路不仅适用于 UI 贴图在做视频画面拼接、多路画面排布时同样能用——把不同尺寸的画面按九宫格的方式排布到一块大画布上边缘区域做拉伸填充主体区域保持原比例。实测性能上九次drawImage对现代浏览器来说几乎可以忽略即使是在 rAF 循环里调用也不会成为瓶颈。真正需要注意的反而是inset参数的取值如果容器的最终尺寸小于左右 inset 之和中间区域宽度会变成负数绘制结果会错乱。加一行判断兜底是必要的if (dw l r || dh t b) { // 容器太小退化成整体缩放避免出现负宽高 ctx.drawImage(img, dx, dy, dw, dh); return; }6.3 导出前的最后一道检查导出之前建议做一次状态确认画布宽高是否为 0、是否曾被污染、是否在 dpr 缩放状态下直接导出。尤其是最后一条——如果导出时画布还带着ctx.scale(dpr, dpr)的变换矩阵toDataURL拿到的是物理像素尺寸的图比预期大了一倍很多人在这一刻才发现自己设的尺寸和导出结果对不上。稳妥的做法是导出前调用ctx.save()保存状态、setTransform重置矩阵、读取数据、再ctx.restore()。从drawImage的三个重载到图片加载的onload与decode之争再到视频抽帧的seeked时序、跨域污染的四步排查、dpr 适配的双轨制最后落到导出和九宫格拉伸这条链路基本覆盖了 canvas 处理图片与视频的完整生命周期。我个人在实际项目里的体会是这个 API 的难点从来不在参数本身而在参数之外的时序、跨域和像素比。真要少踩坑与其背参数表不如在第一次上手就把 Network 面板、readyState和devicePixelRatio这三处盯死剩下的事情会顺很多。
返回列表