第一次做图片镂空文字效果,是在某个活动页的需求里:一张氛围感很强的主视觉,几行大标题像被刻刀挖空,露出底下更深一层的海报细节。乍一看以为是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显示在页面上,污染画布还能正常显示,但一旦你的产品需要导出海报、生成分享图,这就是致命问题。
解决方式分两步走。第一步,图片请求加上crossOrigin='anonymous'属性,而且注意:这个属性必须在给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 lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.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 id="myCanvas" width="800" height="500"></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 style="position: relative; width: 800px; height: 500px;"> <!-- 底层:文字镂空后露出的图 --> <canvas id="bottomCanvas" width="800" height="500" style="position: absolute; inset: 0;"></canvas> <!-- 上层:被挖空的图 --> <canvas id="topCanvas" width="800" height="500" style="position: absolute; inset: 0;"></canvas> </div>const 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反复操作,最好的做法是拆成多个离屏图层,各自挖好、叠好,最后一次性合成到一个主画布上。这样不仅效果干净,后续改动也灵活得多。