
第一次做图片镂空文字效果是在某个活动页的需求里一张氛围感很强的主视觉几行大标题像被刻刀挖空露出底下更深一层的海报细节。乍一看以为是PS切片做的蒙版等自己用原生Canvas复刻的时候才发现这个效果原理朴素得惊人核心全靠一个叫globalCompositeOperation的属性。如果你也处于“前端基础还行、Canvas只停留在画个圆”的阶段这篇文章应该能帮你把这效果彻底吃透——从原理、完整代码到各种诡异到离谱的坑我会用最直白的方式讲清楚。整个过程不依赖任何框架原生JavaScript就能跑你只需要一个能打开浏览器的电脑。1. 先搞懂原理一张图片怎么被“挖”出文字洞1.1 Canvas绘图要先建立的三个基础认知很多小白写Canvas代码时晕头转向是因为没分清几个基础概念。第一个canvas元素本身有两个宽高一个是canvas标签上的width和height属性代表画布的实际像素尺寸另一个是CSS样式里的width和height代表画布在页面上的显示尺寸。如果你只改了CSS尺寸没改画布属性那图片和文字就会被拉伸变形这是新手最常见的问题之一。第二个认知是上下文对象。canvas.getContext(2d)拿到的ctx就像一张工作台所有绘制指令都是往这张工作台上画。它跟普通DOM操作完全不同绘制是不可见的画面只存在于画布像素里你需要靠控制台或页面展示来确认结果。第三个认知是坐标体系。Canvas的坐标系以左上角为原点水平向右是X轴垂直向下是Y轴。文字居中、图片裁剪、描边位置……所有定位都要靠着套坐标系来算。这部分不复杂但理解到位能少踩很多坑。1.2 核心属性globalCompositeOperation 到底在干嘛globalCompositeOperation是Canvas 2D上下文里用来控制“新绘制的图形”和“画布上已有图形”之间混合方式的属性。它决定了当你在电视屏幕这块画布上落笔时新笔迹与旧画面之间发生什么关系。我用一个生活化类比来解释把画布想象成一块玻璃上面已经刷了一层颜料。默认状态下你在上面再加一笔新颜料会直接覆盖旧颜料这叫source-over。如果切换成destination-out情况就反过来了——新笔迹覆盖到的区域旧颜料会被刮掉直接露出透明玻璃。至于笔迹本身的颜色是什么完全不重要重要的是你“画过的形状”会变成橡皮擦把这一块的内容擦除掉。这正是图片镂空文字效果的底层逻辑先把图片画满画布再把文字当橡皮擦把文字形状对应的区域擦成透明。等到Canvas元素叠加在页面上时透明的文字部分自然会透出位于Canvas底下的页面背景。如果页面背景是一张更深层的图片就形成了双层透图效果。1.3 方案对比为什么不用clip为什么不用遮罩很多人在网上搜到这个需求时还会看到另外两种方案。第一种是ctx.clip()裁剪路径。这种方案是先设置文字路径把后续所有绘制都限制在这个路径范围内。它能实现的效果是“只显示文字区域内的图片内容”跟镂空正好互补。第二种是用CSSmask属性做遮罩用一张文字图片作为遮罩层也能做出类似效果。既然有这么多方案为什么我推荐用globalCompositeOperation因为它最直接一行属性切换就能对已绘制的内容进行像素级操作而且后续叠加动画、渐变镂空、动态更新字内容都非常方便。 clip 的问题在于裁剪是“一次性设定”如果之后还想重新绘制其他区域需要保存和恢复状态逻辑更绕。CSS mask 的问题在于它是纯样式方案没法根据用户输入、动画帧做精细控制而且老浏览器的兼容性也有风险。方案实现思路上手难度灵活度适用场景destination-out把文字区域擦透明⭐低高图片上挖洞、透出底图clip()裁剪只保留文字区域内的内容⭐⭐中中文字内填充图片纹理CSS mask用文字图片做遮罩⭐低低静态效果、纯展示页面如果你只是做一个静态、不带交互的镂空效果CSS mask其实更快。但如果要做动态变化、用户交互、或者进阶玩法那destination-out这条路绕不开。2. 动手前的三道坎跨域、加载和字体2.1 图片跨域没配置CORS画布会被“污染”你可能本地测试没问题一上线换了CDN域名代码就报错炸裂。这是Canvas的跨域安全机制在发挥作用浏览器为了防止恶意脚本读取到跨域图片的像素数据规定未配置跨域资源共享CORS的图片一旦绘制到Canvas上这个画布就变成“污染画布”。被污染的画布会失去两个能力一是canvas.toDataURL()导出图片会抛安全错误二是ctx.getImageData()读取像素会抛安全错误。如果只是把Canvas显示在页面上污染画布还能正常显示但一旦你的产品需要导出海报、生成分享图这就是致命问题。解决方式分两步走。第一步图片请求加上crossOriginanonymous属性而且注意这个属性必须在给img.src赋值之前设置。第二步图片所在服务器需要返回Access-Control-Allow-Origin响应头。如果你用的是CDN去CDN控制台配置CORS规则如果图片来自你自己的接口服务端设置好响应头就行。还有一个调试细节本地双击HTML文件用file://协议打开时跨域策略也容易触发建议本地起一个静态服务器比如npx serve或者用VS Code的Live Server插件。2.2 图片加载时机别在图片还没到的时候就画新手最容易犯的错是写完代码发现画布一片空白或者只有文字没有图片。原因十有八九是图片还没加载完成就开始绘制了。new Image()创建出来的对象一旦设置了src浏览器就开始异步加载图片资源。如果你的代码紧接着调用ctx.drawImage(img, 0, 0)此时图片的complete状态还是false自然画了个寂寞。正确写法是把绘制逻辑放进img.onload回调里等图片真正加载完成后再开始操作。也可以用async/await封装一个加载函数但核心逻辑不变都是要在图片资源就绪后才绘制。还有一种更保险的写法是监听window.load事件这个事件会在页面所有资源加载完成后触发能覆盖到图片异步加载的情况。2.3 字体为什么不生效fillText在你字体加载完之前就执行了图片的问题解决了接下来是文字的坑。很多小白发现ctx.font bold 80px 楷体设置了半天画出来的文字依然是默认字体。原因是浏览器在Canvas上使用指定字体时要求这个字体必须先被加载到浏览器中。普通情况下页面上如果没有其他元素使用这个字体字体文件可能还没加载完fillText就已经执行了Canvas只能退回默认字体渲染。解决办法是使用document.fonts.ready它返回一个Promise等页面所有字体加载完成后再执行Canvas绘制await document.fonts.ready; // 字体已就绪可以开始绘制 ctx.font bold 80px 楷体; ctx.fillText(你好, 100, 100);如果使用第三方字体库还可以用原生API单独加载指定字体document.fonts.load(80px 楷体)。从字体加载的角度看这里的关键在于“先确保字体资源就绪再做绘制”顺序一定要对。3. 手写核心代码5分钟跑通基础版镂空3.1 基础版单画布直接挖空我把完整的基础版本地Demo写在下面代码里每一步都有注释。你需要准备一张本地图片替换img.src的路径即可。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / style body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f5f0e8; /* 透过镂空露出的底色 */ } canvas { display: block; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); } /style /head body canvas idmyCanvas width800 height500/canvas script const canvas document.getElementById(myCanvas); const ctx canvas.getContext(2d); // 需要确保图片支持跨域参见上文 2.1 节 const img new Image(); img.crossOrigin anonymous; img.src your-image.jpg; img.onload function () { // 第一步先把图片铺满整个画布 ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // 第二步切换混合模式之后的绘制都等于“橡皮擦” ctx.globalCompositeOperation destination-out; // 第三步配置文字样式开始挖空 ctx.font bold 120px Microsoft YaHei, sans-serif; ctx.textAlign center; ctx.textBaseline middle; // 颜色随意橡皮擦模式下只有透明度起作用 ctx.fillStyle #000000; ctx.fillText(挖空文字, canvas.width / 2, canvas.height / 2); // 第四步把混合模式恢复避免影响后续操作 ctx.globalCompositeOperation source-over; }; /script /body /html运行这个页面你应该能看到一张照片照片正中央的文字区域透明直接露出下面body的背景色。如果背景色是深色海报底部图视觉上就像文字被雕刻穿了一样。步骤顺序是绝对核心必须先把drawImage执行完再切换globalCompositeOperation。如果先设置了destination-out再画图片那整张图片都会被清除画面一片空白。3.2 进阶版双画布制造“文字透底图”基础版镂空露出来的是页面背景有时候我们想让文字区域透出另一张图比如你在做活动主视觉时背景是一张灯光夜景文字挖空后露出的底层是同一张夜景里更亮的局部。这时就可以用双画布方案。思路是底层放一个Canvas画布A负责显示图片A上层放一个Canvas画布B负责显示图片B并挖空文字。关键点在于Canvas画布B本身是全透明的它挖掉文字后文字区域依然透明透过它能看到下层画布A的对应位置。但是非文字区域如果你不绘制任何内容画布B也是透明的那上层完全不遮挡下层效果就没了。所以正确做法是画布B上需要先绘制一张覆盖整个区域的上层图片图片B然后在这个画布上用destination-out挖掉文字文字区域的透明部分就会露出底层画布A的内容。div styleposition: relative; width: 800px; height: 500px; !-- 底层文字镂空后露出的图 -- canvas idbottomCanvas width800 height500 styleposition: absolute; inset: 0;/canvas !-- 上层被挖空的图 -- canvas idtopCanvas width800 height500 styleposition: absolute; inset: 0;/canvas /divconst bottomCanvas document.getElementById(bottomCanvas); const topCanvas document.getElementById(topCanvas); const bottomCtx bottomCanvas.getContext(2d); const topCtx topCanvas.getContext(2d); function drawBottom() { const img new Image(); img.crossOrigin anonymous; img.src bottom-image.jpg; img.onload function () { bottomCtx.drawImage(img, 0, 0, bottomCanvas.width, bottomCanvas.height); }; } function drawTop() { const img new Image(); img.crossOrigin anonymous; img.src top-image.jpg; img.onload function () { // 1. 先画上层图 topCtx.drawImage(img, 0, 0, topCanvas.width, topCanvas.height); // 2. 挖掉文字区域 topCtx.globalCompositeOperation destination-out; topCtx.font bold 120px Microsoft YaHei, sans-serif; topCtx.textAlign center; topCtx.textBaseline middle; topCtx.fillStyle #000000; topCtx.fillText(双重影像, topCanvas.width / 2, topCanvas.height / 2); topCtx.globalCompositeOperation source-over; }; } drawBottom(); drawTop();这里要提醒一个细节两张Canvas的CSS定位要绝对重叠position: absolute加上相同尺寸父容器需要position: relative。不然两者错位效果就完全乱了。3.3 高清屏适配三个数字解决发虚问题测试时你可能发现同一个Canvas在普通屏幕上显示正常在MacBook或高分屏上文字边缘发虚、图片模糊。这背后的原因是物理像素和CSS像素的映射关系高分屏的devicePixelRatio大于1一个CSS像素对应多个物理像素。而Canvas默认的绘图尺寸是按CSS像素处理的浏览器把有限的物理像素拉伸到更大的显示区域自然就糊了。解决方案是让Canvas的实际像素尺寸等于“CSS尺寸 × 设备像素比”然后用ctx.scale(dpr, dpr)让整个坐标系跟着放大function setupHiDPICanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }使用这个函数之后后续的绘制逻辑可以继续以CSS像素为单位写坐标比如ctx.fillText(你好, 400, 250)完全不用手动换算。需要注意如果你的图片本身分辨率不够大强行放大到dpr倍数依然会模糊这时候需要准备2倍或3倍尺寸的设计图。4. 避坑指南常见问题与排查思路4.1 字没显示出来多半是绘制顺序错了我见过至少有三次有人按下面的顺序操作先设置globalCompositeOperation destination-out再调用drawImage画图片最后fillText。结果整个画布干干净净啥都没有。原因很简单当混合模式已经变成橡皮擦你再画图片等于把图片在画布上直接擦掉了连绘制的机会都没有。绘制的固定套路是先画底层内容再切混合模式最后画挖空形状。可以把“切到destination-out”理解为“开启橡皮擦状态”之后画上去的所有内容都会清除已有画面。一旦忘记切回来后续的任意绘制都会继续影响画面导致莫名其妙的透明区域。建议每次挖空操作结束后立即执行ctx.globalCompositeOperation source-over恢复到默认状态。这样即使代码后面还有别的绘制也不会影响到其他区域。4.2 图片跨域导致的安全报错如果你把图片从本地改成线上地址页面马上报错Failed to execute toDataURL on HTMLCanvasElement: Tainted canvases may not be exported.或者Unable to get image data from canvas because the canvas has been tainted by cross-origin data.这基本可以断定是跨域问题。排查步骤我总结成一句话先确认crossOrigin属性有没有在img.src之前设置再看服务端有没有返回CORS响应头最后检查本地是否用了file://协议。如果你用file://打开可以先执行npx serve或 VS Code的Live Server。开发阶段跨域白名单里还要确认是否带了正确的端口。4.3 文字边缘出现奇怪的重影第一次实现镂空文字效果时我以为只画一次就结束了但屏幕上文字边缘总有一层淡淡的轮廓。后来发现原因我在resize事件里反复触发了绘制逻辑每次触发都重新drawImage又重复fillText。由于Canvas没有自动清除上次内容的机制文字边缘的透明区域被反复挖空多次叠加后形成深浅不一的残影。解决办法是每次重新绘制前用ctx.clearRect(0, 0, canvas.width, canvas.height)把整块画布清空再按顺序绘制。如果是动画场景更合理的做法是建立离屏Canvas缓存底图动画时先drawImage离屏画布再挖字避免重复解码大图。4.4 文字像是被裁掉了一部分文字超出画布边界后会被直接裁掉只露出画布内的部分。这种情况常见于文字太长、字号太大或者文字定位坐标计算错误。用ctx.textAlign center和ctx.textBaseline middle可以让文字围绕某个基准点居中但如果你没有把基准点放在画布可见区域文字照样会“跑出”画面。还有一个容易忽略的是ctx.font的字体栈问题如果你设置的字体在当前系统不存在Canvas会直接使用默认字体这会导致文字宽度变化、布局错位。通过ctx.measureText(text).width可以先测量文字宽度再根据画布宽度动态调整字号这里能适配不同长度的标题。4.5 一个容易被忽略的样式问题canvas和div的占位差异canvas默认是inline元素浏览器会在它下面留出几像素的空白间隙这是inline元素底部的基线对齐导致的。如果你发现Canvas和背景对不齐或者底部有白边加上display: block;就能解决。另一个常见问题是父容器高度塌陷。如果父元素没有设置高度而Canvas使用绝对定位脱离文档流父容器高度就变为0背景透出来时位置对不上。建议父容器显式设置高度或使用flex布局撑开。5. 玩法扩展反向镂空、渐变挖空和动效实战5.1 反向镂空让文字区域保留图片有时候我们想要的效果不是挖空文字而是让文字里填充图片。比如做一张“用山川纹理填充一句话”的海报这时可以用destination-in实现。destination-in的逻辑是只保留旧画布中与“新绘制区域”重叠的部分其他地方全部清除。代码里只需要把destination-out换成destination-in先画文字再画图片但注意绘制的顺序会因为混合模式而改变。更稳妥的写法是先fillText画出文字形状然后切换成source-in或者destination-in具体取决于你是把文字当背景还是当新图形再把图片画上去实现文字内填充图片的效果。这种“反向镂空”在营销海报上特别常用能做出很高级的质感同一套原理换个参数就能玩出不同花样。5.2 半透明镂空与渐隐过渡globalCompositeOperation destination-out还有一个容易忽略的特性它不只是“完全透明”或“完全不透明”这两极实际上是基于alpha通道的。如果你用ctx.fillStyle rgba(0, 0, 0, 0.5)填充文字那么文字区域会被挖掉一半透明度透过半透明的文字能看到底层画面形成一种玻璃质感的叠加效果。更进一步你可以用ctx.createLinearGradient设置一个从透明到不透明的渐变色让镂空文字产生渐隐的过渡。比如文字左边完全挖空右边逐渐变淡视觉层次感很强。需要注意的是在destination-out模式下fillStyle的RGB通道不影响结果只有alpha通道起作用。也就是说rgba(255, 0, 0, 0.5)和rgba(0, 0, 255, 0.5)挖出来的效果完全一样。5.3 配合动画文字逐字出现如果想让镂空文字逐字出现或者让文字像被打字机一样一个接一个显现可以用requestAnimationFrame做一个逐帧循环。每次绘制前清空画布按当前帧数决定绘制哪几个字符或绘制进度配合缓动函数就能做出很顺滑的效果。这里要特别提醒性能问题逐帧重绘时如果每次都重新drawImage一张大图低端设备容易卡顿。建议把图片预先绘制到一张离屏Canvas上动画循环里只drawImage那张离屏画布再把文字当橡皮擦挖一遍。离屏Canvas的代码很简单const offCanvas document.createElement(canvas); offCanvas.width canvas.width; offCanvas.height canvas.height; const offCtx offCanvas.getContext(2d); offCtx.drawImage(img, 0, 0, offCanvas.width, offCanvas.height); function renderFrame() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(offCanvas, 0, 0); ctx.globalCompositeOperation destination-out; ctx.font bold 100px sans-serif; ctx.fillText(currentText, 100, 250); ctx.globalCompositeOperation source-over; }这样每次循环只需要做一次内存画布拷贝和一次文字绘制性能开销小得多。5.4 调试技巧用“底纹”快速确认镂空是否生效镂空效果调试起来容易“看不见”如果底层背景和Canvas图片颜色很接近你根本看不出文字被挖空了。我的习惯是先把页面背景调成一个亮眼的高对比色比如纯红色这样只要文字区域露出红色底基本能确认镂空生效。确认后再把背景换成最终的素材图。在控制台里可以用ctx.getImageData(x, y, 1, 1).data检查某个像素的alpha值是否为0这是验证透明区域的终极手段。定位到文字中心点取一个像素如果alpha为0说明挖空成功如果不为0说明文字没画到那个位置或者混合模式设置失败。提示检查像素时注意Canvas像素坐标跟CSS坐标的差异如果你做了dpr适配需要把CSS坐标乘上devicePixelRatio再传给getImageData。我在实际项目里做了不下十几版镂空效果最深的一个体会是这个效果看着小但背后牵扯到Canvas像素处理、跨域安全、字体加载、高清屏适配等一堆基础概念。每踩一个坑你对Canvas的理解就会加深一层。如果你也是刚开始接触这块建议上手先跑通基础版再慢慢加花样遇到问题就用上面的排查思路逐个击破。最后分享一个小技巧文字在镂空后如果想加投影或边框不要盯着同一个Canvas反复操作最好的做法是拆成多个离屏图层各自挖好、叠好最后一次性合成到一个主画布上。这样不仅效果干净后续改动也灵活得多。