首页 / 资讯中心 / 文章详情

Canvas图片镂空文字效果:从绘制原理到工程避坑指南

Canvas图片镂空文字效果:从绘制原理到工程避坑指南 ★ FEATURED ARTICLE
先交代一个背景为什么我会去研究这个效果。之前做一个活动页面的视觉时设计师给的效果图里整张产品大图上压了一层近黑的遮罩产品名像被刻刀挖出来一样透出底下图片的原始色彩。第一反应是这东西得上 PS 切图后来发现 Canvas 几十行代码就能做而且做出来是矢量的、可动态变化的、能直接导出高清图片。这个效果在圈内就叫“图片镂空文字”核心只有一个 APIglobalCompositeOperation。这篇是写给刚接触 Canvas 的小白前端的。我会从图层原理讲起到跑通完整 Demo再把我实际上踩过的五个坑一个个拆开说最后给点进阶玩法。你在很多教程里大概只会看到“设置globalCompositeOperation等于destination-out再fillText”这么一句但真正让它稳定好用藏在后面的像素密度、字体加载、跨域这些问题才是这篇的重点。1. 先看效果再看原理镂空字到底是怎么“抠”出来的1.1 一个图层级的效果拆解“图片镂空文字”听起来唬人拆开看其实就是三层结构最底层一张完整的背景图片通常是产品图、风景图、人物图中间层一个半透明或不透明的色块压住整张图片用来制造明暗对比最上层文字区域里的像素被“拿掉”留下一个个字母或汉字的形状窗口透过窗口能看到最底下的图片原色。你可以把这个过程理解成做实体刻字先把照片平放在桌上盖上一张黑色卡纸然后用刻刀沿着文字轮廓把卡纸抠掉。最后你看到的是黑色卡纸上被抠出来的字以及从字缝里透出来的照片。这种视觉在品牌 Banner、个人博客 Hero 区、作品集封面、电商大促海报里特别常见。它不需要设计师单独切图前端拿到任意一张高分辨率图片写上文案就能即时生成这正好是 Canvas 的强项。1.2 为什么用 Canvas 而不是 CSS mask 或 SVG先澄清一点这个效果不是只有 Canvas 能做。CSS 的mask属性、SVG 的clipPath配合text都能实现。那为什么我最终推荐 Canvas方案优点缺点Canvas像素级控制、可动态绘图、导出图片方便、浏览器兼容性强需要自己处理高清屏、字体加载、文本换行CSS mask写法最简单几行样式就能出效果部分移动端 WebView 对mask的支持有幺蛾子且动态切换和导出成品图很麻烦SVG clipPath矢量清晰、文本排版能力强和位图交互、逐帧动画、导出 PNG 这类操作绕来绕去不适合快速出图实际项目里Canvas 最大的优势在于“后面还能怎么用”。你可以在镂空文字的基础上加动画、加鼠标交互、改透明度、导出成宣传图一套代码全搞定。CSS mask 做静态没问题一旦需求变成“文字内部透出的图片要缓慢滚动”就明显不如 Canvas 灵活了。1.3 理解 Canvas 像素覆盖与globalCompositeOperationCanvas 不像 PS 那样有图层面板它是一个巨大的像素矩阵。每次调用drawImage、fillRect、fillText都是在往这块像素矩阵上画画。至于新画的像素和旧像素怎么叠加由一个叫globalCompositeOperation的属性控制。默认情况下它是source-over意思就是“新内容直接盖在旧内容上面”这也是大多数人认识的 Canvas 行为。而destination-out的逻辑完全不同新绘制的区域会把旧像素“擦掉”让那些位置变成透明。更精确地说公式大概是旧像素保留值 (1 - 新绘制像素的alpha) * 旧像素也就是说如果你用完全不透明的文字去触碰画布文字覆盖过的旧像素全部归零变透明如果你用半透明颜色去画旧像素会被擦掉对应比例。这个细节在后面讲“文字太细/遮罩太浅”时很有用。有了这个认识实现镂空文字的顺序就非常清晰了先画图片再画遮罩色块把合成模式改成destination-out在遮罩层上画文字文字区域被擦除。顺序一旦错了效果就会很怪。比如你先画遮罩再画图片图片会把遮罩盖得严严实实或者你在画图片前就开了destination-out那第一步就把透明的画布“擦”了个寂寞。Canvas 的绘制顺序决定了最终的每一处像素。2. 第一版跑通图片、字体、镂空的完整 Demo2.1 最简环境不需要框架不需要打包工具一个 HTML 文件就能跑。先准备一张本地图片比如product.jpg放到和 HTML 同目录下这样第一步就不用担心跨域问题——跨域这个坑我放在第 3 章专门讲。给canvas一个固定显示尺寸比如1200x500这是最简单的起步方式。后续第 3 章会告诉你为什么在 Retina 屏上这样写会糊。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleCanvas 图片镂空文字 Demo/title style #poster { display: block; width: 1200px; height: 500px; margin: 40px auto; /* 后续会讲到高清屏适配这里先不设画布物理宽高 */ } /style /head body canvas idposter/canvas script // 下面这段是核心代码直接粘贴运行 const canvas document.getElementById(poster); const ctx canvas.getContext(2d); // 给画布设置内部像素尺寸 canvas.width 1200; canvas.height 500; const img new Image(); // 换成你自己的图片路径建议和 HTML 同目录 img.src ./product.jpg; img.onload () { // 第一步绘制背景图片拉伸铺满整个画布 ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // 第二步画遮罩色块相当于盖一张半透明卡纸 ctx.fillStyle rgba(0, 0, 0, 0.85); ctx.fillRect(0, 0, canvas.width, canvas.height); // 第三步开启“擦除模式” ctx.globalCompositeOperation destination-out; // 第四步绘制文字文字区域会被挖空 ctx.font 900 120px PingFang SC, Microsoft YaHei, sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(DEV STORE, canvas.width / 2, canvas.height / 2); // 第五步恢复默认合成模式防止后续绘制受影响 ctx.globalCompositeOperation source-over; }; /script /body /html2.2 代码逐句拆解这段代码就是整个效果的核心骨架。我挨句说下背后的意图。img.onload很重要。很多人一开始会直接在new Image()之后马上drawImage结果画布上什么都没有。图片是异步加载的没等onload触发就绘制等于画了个寂寞。后面所有依赖图片内容的操作都应该放在onload回调里。ctx.drawImage(img, 0, 0, canvas.width, canvas.height)这行用了四参数版本意思是把图片强行拉伸填满整个画布。如果你想保持原始宽高比可以先把图片等比缩放再居中绘制我建议第一版先跑通再看第 4 章怎么精细处理。fillStyle配合fillRect画出遮罩层。这里我用了rgba(0, 0, 0, 0.85)也就是 85% 不透明度的黑色。遮罩透明度直接决定镂空文字的醒目程度这一步后面也会展开说。ctx.font一次性定义了字重、字号和字体族。需要注意Canvas 的字体设置必须写在一行里不能像 CSS 那样拆成font-size和font-family两个属性。而且如果使用自定义 Web 字体这里会有一个“字体还没加载完就绘制”的坑第 3 章会说解法。textAlign和textBaseline分别控制水平、垂直对齐方式都设为center和middle后fillText的坐标点就是文字的正中心做居中排版最方便。最后把globalCompositeOperation恢复成source-over。这一步经常被忽略后面会讲它带来的诡异现象。2.3 为什么顺序不能乱如果把destination-out设置提前例如在fillRect之前开启那遮罩层会被擦掉吗不会因为擦除的是“已有的旧像素”。设置成destination-out之后后面绘制的所有内容都会变成橡皮擦。如果在drawImage之前就开启图片存在吗这时候画布还是空的“擦除”自然没效果。所以说白了顺序就是像素叠加的时间线。图片先落到底层遮罩压上去最后雕刻文字。这样镂空部分露出的才是图片而不是网页背景色。3. 避坑指南这五个坑基本小白都会踩这一节是整篇最有价值的部分。我从实际调试里把这些坑一个个捞出来每个都附带现象、原因和解决方案。3.1 高清屏Retina模糊先处理像素密度现象同一个 Demo 在普通 Windows 屏幕上看还好放到 MacBook 或高分屏手机上文字边缘发虚图片像蒙了一层雾。原因CSS 里width: 1200px决定的只是画布在页面上占据的逻辑尺寸而canvas.width 1200决定的是画布内部的物理像素数量。在高分屏下一个 CSS 像素实际上由多个物理像素组成浏览器为了让 1200×500 的画布铺满物理区域就硬把 1200 个像素放大渲染结果当然是糊。解法const dpr window.devicePixelRatio || 1; // 物理像素 逻辑像素 * 设备像素比 canvas.width 1200 * dpr; canvas.height 500 * dpr; // 但 CSS 里仍然让它显示为 1200x500 canvas.style.width 1200px; canvas.style.height 500px; // 缩放 Canvas 的绘图坐标系之后所有绘制仍然按逻辑像素写 ctx.scale(dpr, dpr);加上ctx.scale(dpr, dpr)之后后面所有drawImage、fillText的坐标和尺寸都不用改仍然按 1200×500 来写非常省心。3.2 跨域图片导致 Canvas 被“污染”现象图片画出来了效果也正常但一执行canvas.toDataURL()就报错Tainted canvases may not be exported。原因只要画布上绘制过跨域图片浏览器就会默认这张画布被污染为了保证安全不允许你导出图像数据。如果你做的是静态长图生成工具这个问题会直接卡死功能。解法分两步。第一步加载图片前设置const img new Image(); // 必须设置在 src 之前 img.crossOrigin anonymous; img.src https://example.com/image.jpg;第二步保证图片服务器返回了Access-Control-Allow-Origin响应头。如果后端是 CDN 或对象存储一般可以在控制台配置如果服务器不是你管的那就只能通过同域代理转发图片把跨域变成同域。3.3 自定义字体没加载成系统字体现象我在代码里清清楚楚写了font: 900 120px Inter结果画出来的却是宋体或微软雅黑效果和设计师稿完全不是一回事。原因字体加载是异步的。Web 字体可能还没下载完Canvas 就开始绘制了此时它只能回退到系统字体。这个问题在 CSS 里不明显因为页面渲染有一个字体回退的纠正过程但 Canvas 画出来的像素一旦落地字体加载完成后也不会自动重绘。解法// 先让浏览器加载目标字体 if (document.fonts document.fonts.load) { await document.fonts.load(900 120px Inter); await document.fonts.ready; } // 字体加载完再执行绘制 ctx.font 900 120px Inter, sans-serif; ctx.fillText(DEV STORE, canvas.width / 2, canvas.height / 2);如果用了多个字体或字号最好把常用的几条规则都load一遍。注意await必须放在异步函数或async回调里如果整体代码是img.onload启动的把onload写成async就可以。3.4globalCompositeOperation忘记还原后续绘制互相干扰现象镂空效果做出来了但你在后面再画一个普通矩形或按钮发现它们全部消失或变成透明怎么调都不对。原因globalCompositeOperation是一个有记忆的状态。你把它设成destination-out之后如果不改回来后续每一个绘制操作都会进入“擦除模式”把画布上已有的内容擦掉。这种 bug 特别隐蔽因为你只看当前一帧有点像一旦重新绘制就很奇怪。解法如果不希望被状态污染用save和restore包裹也是不错的选择。下面的写法更清晰效果也一样ctx.save(); ctx.globalCompositeOperation destination-out; ctx.fillText(DEV STORE, ...); ctx.restore();save()会存下当前所有状态restore()一次性恢复。我的项目习惯是凡是修改了合成模式、阴影、变换矩阵且这段代码和后续绘制逻辑边界明显就用save/restore包起来少一次手滑少十分钟排查。3.5 文字过细或遮罩过浅镂空效果看不清现象同样的代码英文粗体效果好得很换成中文笔画多的字或者用的字体字重不足镂空区域变得若隐若现遮罩盖得不够深文字和图片混在一起。原因镂空文字能看清依赖的是“文字窗口”和“周围色块”之间足够的明暗反差。如果笔画太细镂空出来的像素面积太小视觉信息密度不够如果遮罩透明度低于 70%中间的明暗差也不足以形成“刻字”的感觉。解法字体优先选择 700 以上字重font里写900或bold字号尽量放大至少 60px 起步字越小越容易糊遮罩用rgba(0, 0, 0, 0.75)以上或者干脆用不透明色块再靠背景图片的边界给画面增加层次如果文字笔画确实细可以先给文字加一层描边弥补这个我在第 4 章会讲具体方式。4. 效果细节打磨居中、多行文字、描边和阴影的取舍4.1 精确居中的几种方式最简单的方式就是第 2 章用过的textAlign: center和textBaseline: middle然后fillText坐标直接写画布中心。这个组合适配绝大多数单行文字场景。但还有一种场景你想让文字左边对齐某条边又想让它垂直居中或者在一张多区域广告图上精确控制每个文字的锚点。这时可以借助measureText量出宽度const metrics ctx.measureText(DEV STORE); const textWidth metrics.width; // 假设要让文字水平居中那么起始 x 坐标就是 const startX (canvas.width - textWidth) / 2; ctx.fillText(DEV STORE, startX, canvas.height / 2);measureText还返回实际像素宽度在需要做“文字到底有没有超出画布”这类校验时最有用。Canvas 里没有getBBox之类的 DOM 概念所有宽度判断都靠它。4.2 多行文字的分行处理Canvas 的fillText不支持自动换行也不会识别\n来断行。想输出多行文字思路是按\n拆分然后逐行绘制每行的高度递增。function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) { const lines String(text).split(\n); lines.forEach((line, index) { ctx.fillText(line, x, y index * lineHeight); }); } // 使用 drawWrappedText(ctx, 第一行\n第二行, canvas.width / 2, canvas.height / 2, 600, 70);如果你还需要“按宽度自动折行”也就是文字太长自动在单词或汉字之间断开那得先二分字符宽度判断再切割代码会长一些。对于小白阶段建议先在服务端或编辑器里排好文案再传给 Canvas手动用\n控制每行长度就行。我做过几个海报生成项目都是这个方案稳定且好维护。4.3 给镂空字加描边strokeText的正确时机需求经常会变成“镂空字外面还要有一圈边线不然字在亮色区域里辨识度不够”。这时候顺序和合成模式要一起处理好。正确的顺序是// 第一步挖空文字 ctx.save(); ctx.globalCompositeOperation destination-out; ctx.font 900 120px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(DEV STORE, canvas.width / 2, canvas.height / 2); ctx.restore(); // 第二步恢复普通绘制模式画描边 ctx.strokeStyle #ffffff; ctx.lineWidth 4; ctx.font 900 120px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.strokeText(DEV STORE, canvas.width / 2, canvas.height / 2);关键点在于先打开destination-out用fillText把文字区域掏空然后恢复source-over再用strokeText画描边。这样描边线是画在遮罩层表面的不会把镂空区域重新填死。如果你顺序反了先strokeText再fillText挖空描边会被连同文字一起擦掉一部分边缘断断续续。4.4 阴影在destination-out下的奇怪行为这绝对是新手最容易忽略的坑。想象你在做镂空字的同时加一个文字阴影ctx.shadowColor rgba(255, 255, 255, 0.8); ctx.shadowBlur 20; ctx.globalCompositeOperation destination-out; ctx.fillText(DEV STORE, ...);结果你会发现不仅文字被挖掉了文字周围的阴影区域也被挖掉了一片整个镂空区域边缘出现一层朦胧的“半透明白洞”视觉上非常脏。原因在destination-out模式下fillText绘制的内容包含阴影和模糊产生的半透明像素这些像素也会作为“雕刻刀”的一部分参与擦除。也就是说你想要的阴影效果根本没机会显示反而变成了多余的空洞。我的处理建议如果一定需要阴影就不要在同一个 Canvas 上用destination-out完成。更稳妥的是用两层 Canvas一层负责输出镂空基底另一层叠加阴影光晕或者干脆放弃阴影改用描边和渐变来替代层次感。项目里 90% 的情况下你会觉得描边比阴影更干净。5. 进阶玩法让镂空字活起来、能导出5.1 动态效果图片流动的文字窗口镂空字的底层图片不一定要静止。我们可以让图片在文字窗口后面缓慢滚动视觉上像是透过窗户看外面的风景。思路很简单把绘制抽成一个函数用requestAnimationFrame持续更新drawImage的裁剪位置。drawImage的九参数版本可以从原图中截取一块绘制到画布上let offset 0; function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 从原图上截取一块移动的窗口 ctx.drawImage( img, offset, 0, 600, canvas.height, // 原图裁剪区域 0, 0, canvas.width, canvas.height // 绘制到画布的整块 ); // 遮罩 镂空文字的代码和之前一致 ctx.fillStyle rgba(0, 0, 0, 0.85); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.globalCompositeOperation destination-out; ctx.font 900 120px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(DEV STORE, canvas.width / 2, canvas.height / 2); ctx.globalCompositeOperation source-over; offset 1; if (offset img.width - 600) offset 0; requestAnimationFrame(render); }requestAnimationFrame每帧执行时都会先清空画布再重新绘制图片和遮罩。这样文字窗口是静止的底层图片缓缓扫过一种高级的动态海报就出来了。注意offset递增的幅度决定了滚动速度1 到 2 是比较柔和的感觉。5.2 交互鼠标移动改变遮罩透明度另一个很常见的交互鼠标移动到文字区域附近时遮罩透明度降低让图片更清晰地透出来移开后恢复原状。实现上只需监听mousemove更新一个全局变量然后在每一帧重新渲染时使用新的透明度。let hoverStrength 0.85; canvas.addEventListener(mousemove, (event) { const rect canvas.getBoundingClientRect(); const x event.clientX - rect.left; const y event.clientY - rect.top; // 判断是否在文字中心范围附近这只是一个简单示例 const dist Math.hypot(x - canvas.width / 2, y - canvas.height / 2); if (dist 150) { hoverStrength 0.35; } else { hoverStrength 0.85; } render(); });render函数里把fillStyle中的透明度改成${hoverStrength}就行。这种处理方式的好处是镂空区域本身不变只有周围遮罩的深浅在变化产生一种“灯光聚焦文字”的感觉。5.3 导出成品图把 Canvas 转成图片下载活动页面做完往往还要生成一张宣传图或海报图供下载。Canvas 原生支持导出function downloadPoster() { // 第 3 章说过跨域图片不解决这里会报 tainted 错误 const url canvas.toDataURL(image/png); const link document.createElement(a); link.download poster.png; link.href url; link.click(); }有一个细节值得注意canvas.toDataURL()导出的尺寸是画布的物理像素尺寸。如果做过第 3 章的 DPR 适配导出的图片物理分辨率会更大反而更适合直接做宣传图。导出的 PNG 是完全透明的镂空区域吗是的透明部分在图片里会以透明通道保存放到任何背景色上都能继续融合。如果希望导出的是白色背景图可以在导出前临时把背景画成白色导出后再重新绘制回原样。这个花活经常用在海报工具里保存下来的图片贴到文档里不会黑乎乎一片。我个人在实际操作中的体会是Canvas 做镂空效果方向本身不难难的是把上面这些边缘问题一次性考虑周全。先把一个基础 Demo 跑通再把 DPR、字体、跨域、合成模式这几个点按顺序处理好后面加动效、加导出都是顺水推舟的事。如果这篇文章能帮你少走几条弯路那目的就达到了。
阅读完成 · 觉得有帮助?
咨询建站