
先交代一個背景為什么我會去研究這個效果。之前做一個活動頁面的視覺時設(shè)計師給的效果圖里整張產(chǎn)品大圖上壓了一層近黑的遮罩產(chǎn)品名像被刻刀挖出來一樣透出底下圖片的原始色彩。第一反應(yīng)是這東西得上 PS 切圖后來發(fā)現(xiàn) Canvas 幾十行代碼就能做而且做出來是矢量的、可動態(tài)變化的、能直接導(dǎo)出高清圖片。這個效果在圈內(nèi)就叫“圖片鏤空文字”核心只有一個 APIglobalCompositeOperation。這篇是寫給剛接觸 Canvas 的小白前端的。我會從圖層原理講起到跑通完整 Demo再把我實際上踩過的五個坑一個個拆開說最后給點進階玩法。你在很多教程里大概只會看到“設(shè)置globalCompositeOperation等于destination-out再fillText”這么一句但真正讓它穩(wěn)定好用藏在后面的像素密度、字體加載、跨域這些問題才是這篇的重點。1. 先看效果再看原理鏤空字到底是怎么“摳”出來的1.1 一個圖層級的效果拆解“圖片鏤空文字”聽起來唬人拆開看其實就是三層結(jié)構(gòu)最底層一張完整的背景圖片通常是產(chǎn)品圖、風(fēng)景圖、人物圖中間層一個半透明或不透明的色塊壓住整張圖片用來制造明暗對比最上層文字區(qū)域里的像素被“拿掉”留下一個個字母或漢字的形狀窗口透過窗口能看到最底下的圖片原色。你可以把這個過程理解成做實體刻字先把照片平放在桌上蓋上一張黑色卡紙然后用刻刀沿著文字輪廓把卡紙摳掉。最后你看到的是黑色卡紙上被摳出來的字以及從字縫里透出來的照片。這種視覺在品牌 Banner、個人博客 Hero 區(qū)、作品集封面、電商大促海報里特別常見。它不需要設(shè)計師單獨切圖前端拿到任意一張高分辨率圖片寫上文案就能即時生成這正好是 Canvas 的強項。1.2 為什么用 Canvas 而不是 CSS mask 或 SVG先澄清一點這個效果不是只有 Canvas 能做。CSS 的mask屬性、SVG 的clipPath配合text都能實現(xiàn)。那為什么我最終推薦 Canvas方案優(yōu)點缺點Canvas像素級控制、可動態(tài)繪圖、導(dǎo)出圖片方便、瀏覽器兼容性強需要自己處理高清屏、字體加載、文本換行CSS mask寫法最簡單幾行樣式就能出效果部分移動端 WebView 對mask的支持有幺蛾子且動態(tài)切換和導(dǎo)出成品圖很麻煩SVG clipPath矢量清晰、文本排版能力強和位圖交互、逐幀動畫、導(dǎo)出 PNG 這類操作繞來繞去不適合快速出圖實際項目里Canvas 最大的優(yōu)勢在于“后面還能怎么用”。你可以在鏤空文字的基礎(chǔ)上加動畫、加鼠標(biāo)交互、改透明度、導(dǎo)出成宣傳圖一套代碼全搞定。CSS mask 做靜態(tài)沒問題一旦需求變成“文字內(nèi)部透出的圖片要緩慢滾動”就明顯不如 Canvas 靈活了。1.3 理解 Canvas 像素覆蓋與globalCompositeOperationCanvas 不像 PS 那樣有圖層面板它是一個巨大的像素矩陣。每次調(diào)用drawImage、fillRect、fillText都是在往這塊像素矩陣上畫畫。至于新畫的像素和舊像素怎么疊加由一個叫g(shù)lobalCompositeOperation的屬性控制。默認(rèn)情況下它是source-over意思就是“新內(nèi)容直接蓋在舊內(nèi)容上面”這也是大多數(shù)人認(rèn)識的 Canvas 行為。而destination-out的邏輯完全不同新繪制的區(qū)域會把舊像素“擦掉”讓那些位置變成透明。更精確地說公式大概是舊像素保留值 (1 - 新繪制像素的alpha) * 舊像素也就是說如果你用完全不透明的文字去觸碰畫布文字覆蓋過的舊像素全部歸零變透明如果你用半透明顏色去畫舊像素會被擦掉對應(yīng)比例。這個細(xì)節(jié)在后面講“文字太細(xì)/遮罩太淺”時很有用。有了這個認(rèn)識實現(xiàn)鏤空文字的順序就非常清晰了先畫圖片再畫遮罩色塊把合成模式改成destination-out在遮罩層上畫文字文字區(qū)域被擦除。順序一旦錯了效果就會很怪。比如你先畫遮罩再畫圖片圖片會把遮罩蓋得嚴(yán)嚴(yán)實實或者你在畫圖片前就開了destination-out那第一步就把透明的畫布“擦”了個寂寞。Canvas 的繪制順序決定了最終的每一處像素。2. 第一版跑通圖片、字體、鏤空的完整 Demo2.1 最簡環(huán)境不需要框架不需要打包工具一個 HTML 文件就能跑。先準(zhǔn)備一張本地圖片比如product.jpg放到和 HTML 同目錄下這樣第一步就不用擔(dān)心跨域問題——跨域這個坑我放在第 3 章專門講。給canvas一個固定顯示尺寸比如1200x500這是最簡單的起步方式。后續(xù)第 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; /* 后續(xù)會講到高清屏適配這里先不設(shè)畫布物理寬高 */ } /style /head body canvas idposter/canvas script // 下面這段是核心代碼直接粘貼運行 const canvas document.getElementById(poster); const ctx canvas.getContext(2d); // 給畫布設(shè)置內(nèi)部像素尺寸 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); // 第二步畫遮罩色塊相當(dāng)于蓋一張半透明卡紙 ctx.fillStyle rgba(0, 0, 0, 0.85); ctx.fillRect(0, 0, canvas.width, canvas.height); // 第三步開啟“擦除模式” ctx.globalCompositeOperation destination-out; // 第四步繪制文字文字區(qū)域會被挖空 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); // 第五步恢復(fù)默認(rèn)合成模式防止后續(xù)繪制受影響 ctx.globalCompositeOperation source-over; }; /script /body /html2.2 代碼逐句拆解這段代碼就是整個效果的核心骨架。我挨句說下背后的意圖。img.onload很重要。很多人一開始會直接在new Image()之后馬上drawImage結(jié)果畫布上什么都沒有。圖片是異步加載的沒等onload觸發(fā)就繪制等于畫了個寂寞。后面所有依賴圖片內(nèi)容的操作都應(yīng)該放在onload回調(diào)里。ctx.drawImage(img, 0, 0, canvas.width, canvas.height)這行用了四參數(shù)版本意思是把圖片強行拉伸填滿整個畫布。如果你想保持原始寬高比可以先把圖片等比縮放再居中繪制我建議第一版先跑通再看第 4 章怎么精細(xì)處理。fillStyle配合fillRect畫出遮罩層。這里我用了rgba(0, 0, 0, 0.85)也就是 85% 不透明度的黑色。遮罩透明度直接決定鏤空文字的醒目程度這一步后面也會展開說。ctx.font一次性定義了字重、字號和字體族。需要注意Canvas 的字體設(shè)置必須寫在一行里不能像 CSS 那樣拆成font-size和font-family兩個屬性。而且如果使用自定義 Web 字體這里會有一個“字體還沒加載完就繪制”的坑第 3 章會說解法。textAlign和textBaseline分別控制水平、垂直對齊方式都設(shè)為center和middle后fillText的坐標(biāo)點就是文字的正中心做居中排版最方便。最后把globalCompositeOperation恢復(fù)成source-over。這一步經(jīng)常被忽略后面會講它帶來的詭異現(xiàn)象。2.3 為什么順序不能亂如果把destination-out設(shè)置提前例如在fillRect之前開啟那遮罩層會被擦掉嗎不會因為擦除的是“已有的舊像素”。設(shè)置成destination-out之后后面繪制的所有內(nèi)容都會變成橡皮擦。如果在drawImage之前就開啟圖片存在嗎這時候畫布還是空的“擦除”自然沒效果。所以說白了順序就是像素疊加的時間線。圖片先落到底層遮罩壓上去最后雕刻文字。這樣鏤空部分露出的才是圖片而不是網(wǎng)頁背景色。3. 避坑指南這五個坑基本小白都會踩這一節(jié)是整篇最有價值的部分。我從實際調(diào)試?yán)锇堰@些坑一個個撈出來每個都附帶現(xiàn)象、原因和解決方案。3.1 高清屏Retina模糊先處理像素密度現(xiàn)象同一個 Demo 在普通 Windows 屏幕上看還好放到 MacBook 或高分屏手機上文字邊緣發(fā)虛圖片像蒙了一層霧。原因CSS 里width: 1200px決定的只是畫布在頁面上占據(jù)的邏輯尺寸而canvas.width 1200決定的是畫布內(nèi)部的物理像素數(shù)量。在高分屏下一個 CSS 像素實際上由多個物理像素組成瀏覽器為了讓 1200×500 的畫布鋪滿物理區(qū)域就硬把 1200 個像素放大渲染結(jié)果當(dāng)然是糊。解法const dpr window.devicePixelRatio || 1; // 物理像素 邏輯像素 * 設(shè)備像素比 canvas.width 1200 * dpr; canvas.height 500 * dpr; // 但 CSS 里仍然讓它顯示為 1200x500 canvas.style.width 1200px; canvas.style.height 500px; // 縮放 Canvas 的繪圖坐標(biāo)系之后所有繪制仍然按邏輯像素寫 ctx.scale(dpr, dpr);加上ctx.scale(dpr, dpr)之后后面所有drawImage、fillText的坐標(biāo)和尺寸都不用改仍然按 1200×500 來寫非常省心。3.2 跨域圖片導(dǎo)致 Canvas 被“污染”現(xiàn)象圖片畫出來了效果也正常但一執(zhí)行canvas.toDataURL()就報錯Tainted canvases may not be exported。原因只要畫布上繪制過跨域圖片瀏覽器就會默認(rèn)這張畫布被污染為了保證安全不允許你導(dǎo)出圖像數(shù)據(jù)。如果你做的是靜態(tài)長圖生成工具這個問題會直接卡死功能。解法分兩步。第一步加載圖片前設(shè)置const img new Image(); // 必須設(shè)置在 src 之前 img.crossOrigin anonymous; img.src https://example.com/image.jpg;第二步保證圖片服務(wù)器返回了Access-Control-Allow-Origin響應(yīng)頭。如果后端是 CDN 或?qū)ο蟠鎯σ话憧梢栽诳刂婆_配置如果服務(wù)器不是你管的那就只能通過同域代理轉(zhuǎn)發(fā)圖片把跨域變成同域。3.3 自定義字體沒加載成系統(tǒng)字體現(xiàn)象我在代碼里清清楚楚寫了font: 900 120px Inter結(jié)果畫出來的卻是宋體或微軟雅黑效果和設(shè)計師稿完全不是一回事。原因字體加載是異步的。Web 字體可能還沒下載完Canvas 就開始繪制了此時它只能回退到系統(tǒng)字體。這個問題在 CSS 里不明顯因為頁面渲染有一個字體回退的糾正過程但 Canvas 畫出來的像素一旦落地字體加載完成后也不會自動重繪。解法// 先讓瀏覽器加載目標(biāo)字體 if (document.fonts document.fonts.load) { await document.fonts.load(900 120px Inter); await document.fonts.ready; } // 字體加載完再執(zhí)行繪制 ctx.font 900 120px Inter, sans-serif; ctx.fillText(DEV STORE, canvas.width / 2, canvas.height / 2);如果用了多個字體或字號最好把常用的幾條規(guī)則都load一遍。注意await必須放在異步函數(shù)或async回調(diào)里如果整體代碼是img.onload啟動的把onload寫成async就可以。3.4globalCompositeOperation忘記還原后續(xù)繪制互相干擾現(xiàn)象鏤空效果做出來了但你在后面再畫一個普通矩形或按鈕發(fā)現(xiàn)它們?nèi)肯Щ蜃兂赏该髟趺凑{(diào)都不對。原因globalCompositeOperation是一個有記憶的狀態(tài)。你把它設(shè)成destination-out之后如果不改回來后續(xù)每一個繪制操作都會進入“擦除模式”把畫布上已有的內(nèi)容擦掉。這種 bug 特別隱蔽因為你只看當(dāng)前一幀有點像一旦重新繪制就很奇怪。解法如果不希望被狀態(tài)污染用save和restore包裹也是不錯的選擇。下面的寫法更清晰效果也一樣ctx.save(); ctx.globalCompositeOperation destination-out; ctx.fillText(DEV STORE, ...); ctx.restore();save()會存下當(dāng)前所有狀態(tài)restore()一次性恢復(fù)。我的項目習(xí)慣是凡是修改了合成模式、陰影、變換矩陣且這段代碼和后續(xù)繪制邏輯邊界明顯就用save/restore包起來少一次手滑少十分鐘排查。3.5 文字過細(xì)或遮罩過淺鏤空效果看不清現(xiàn)象同樣的代碼英文粗體效果好得很換成中文筆畫多的字或者用的字體字重不足鏤空區(qū)域變得若隱若現(xiàn)遮罩蓋得不夠深文字和圖片混在一起。原因鏤空文字能看清依賴的是“文字窗口”和“周圍色塊”之間足夠的明暗反差。如果筆畫太細(xì)鏤空出來的像素面積太小視覺信息密度不夠如果遮罩透明度低于 70%中間的明暗差也不足以形成“刻字”的感覺。解法字體優(yōu)先選擇 700 以上字重font里寫900或bold字號盡量放大至少 60px 起步字越小越容易糊遮罩用rgba(0, 0, 0, 0.75)以上或者干脆用不透明色塊再靠背景圖片的邊界給畫面增加層次如果文字筆畫確實細(xì)可以先給文字加一層描邊彌補這個我在第 4 章會講具體方式。4. 效果細(xì)節(jié)打磨居中、多行文字、描邊和陰影的取舍4.1 精確居中的幾種方式最簡單的方式就是第 2 章用過的textAlign: center和textBaseline: middle然后fillText坐標(biāo)直接寫畫布中心。這個組合適配絕大多數(shù)單行文字場景。但還有一種場景你想讓文字左邊對齊某條邊又想讓它垂直居中或者在一張多區(qū)域廣告圖上精確控制每個文字的錨點。這時可以借助measureText量出寬度const metrics ctx.measureText(DEV STORE); const textWidth metrics.width; // 假設(shè)要讓文字水平居中那么起始 x 坐標(biāo)就是 const startX (canvas.width - textWidth) / 2; ctx.fillText(DEV STORE, startX, canvas.height / 2);measureText還返回實際像素寬度在需要做“文字到底有沒有超出畫布”這類校驗時最有用。Canvas 里沒有g(shù)etBBox之類的 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, 第一行\(zhòng)n第二行, canvas.width / 2, canvas.height / 2, 600, 70);如果你還需要“按寬度自動折行”也就是文字太長自動在單詞或漢字之間斷開那得先二分字符寬度判斷再切割代碼會長一些。對于小白階段建議先在服務(wù)端或編輯器里排好文案再傳給 Canvas手動用\n控制每行長度就行。我做過幾個海報生成項目都是這個方案穩(wěn)定且好維護。4.3 給鏤空字加描邊strokeText的正確時機需求經(jīng)常會變成“鏤空字外面還要有一圈邊線不然字在亮色區(qū)域里辨識度不夠”。這時候順序和合成模式要一起處理好。正確的順序是// 第一步挖空文字 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(); // 第二步恢復(fù)普通繪制模式畫描邊 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);關(guān)鍵點在于先打開destination-out用fillText把文字區(qū)域掏空然后恢復(fù)source-over再用strokeText畫描邊。這樣描邊線是畫在遮罩層表面的不會把鏤空區(qū)域重新填死。如果你順序反了先strokeText再fillText挖空描邊會被連同文字一起擦掉一部分邊緣斷斷續(xù)續(xù)。4.4 陰影在destination-out下的奇怪行為這絕對是新手最容易忽略的坑。想象你在做鏤空字的同時加一個文字陰影ctx.shadowColor rgba(255, 255, 255, 0.8); ctx.shadowBlur 20; ctx.globalCompositeOperation destination-out; ctx.fillText(DEV STORE, ...);結(jié)果你會發(fā)現(xiàn)不僅文字被挖掉了文字周圍的陰影區(qū)域也被挖掉了一片整個鏤空區(qū)域邊緣出現(xiàn)一層朦朧的“半透明白洞”視覺上非常臟。原因在destination-out模式下fillText繪制的內(nèi)容包含陰影和模糊產(chǎn)生的半透明像素這些像素也會作為“雕刻刀”的一部分參與擦除。也就是說你想要的陰影效果根本沒機會顯示反而變成了多余的空洞。我的處理建議如果一定需要陰影就不要在同一個 Canvas 上用destination-out完成。更穩(wěn)妥的是用兩層 Canvas一層負(fù)責(zé)輸出鏤空基底另一層疊加陰影光暈或者干脆放棄陰影改用描邊和漸變來替代層次感。項目里 90% 的情況下你會覺得描邊比陰影更干凈。5. 進階玩法讓鏤空字活起來、能導(dǎo)出5.1 動態(tài)效果圖片流動的文字窗口鏤空字的底層圖片不一定要靜止。我們可以讓圖片在文字窗口后面緩慢滾動視覺上像是透過窗戶看外面的風(fēng)景。思路很簡單把繪制抽成一個函數(shù)用requestAnimationFrame持續(xù)更新drawImage的裁剪位置。drawImage的九參數(shù)版本可以從原圖中截取一塊繪制到畫布上let offset 0; function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 從原圖上截取一塊移動的窗口 ctx.drawImage( img, offset, 0, 600, canvas.height, // 原圖裁剪區(qū)域 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每幀執(zhí)行時都會先清空畫布再重新繪制圖片和遮罩。這樣文字窗口是靜止的底層圖片緩緩掃過一種高級的動態(tài)海報就出來了。注意offset遞增的幅度決定了滾動速度1 到 2 是比較柔和的感覺。5.2 交互鼠標(biāo)移動改變遮罩透明度另一個很常見的交互鼠標(biāo)移動到文字區(qū)域附近時遮罩透明度降低讓圖片更清晰地透出來移開后恢復(fù)原狀。實現(xiàn)上只需監(jiān)聽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函數(shù)里把fillStyle中的透明度改成${hoverStrength}就行。這種處理方式的好處是鏤空區(qū)域本身不變只有周圍遮罩的深淺在變化產(chǎn)生一種“燈光聚焦文字”的感覺。5.3 導(dǎo)出成品圖把 Canvas 轉(zhuǎn)成圖片下載活動頁面做完往往還要生成一張宣傳圖或海報圖供下載。Canvas 原生支持導(dǎo)出function downloadPoster() { // 第 3 章說過跨域圖片不解決這里會報 tainted 錯誤 const url canvas.toDataURL(image/png); const link document.createElement(a); link.download poster.png; link.href url; link.click(); }有一個細(xì)節(jié)值得注意canvas.toDataURL()導(dǎo)出的尺寸是畫布的物理像素尺寸。如果做過第 3 章的 DPR 適配導(dǎo)出的圖片物理分辨率會更大反而更適合直接做宣傳圖。導(dǎo)出的 PNG 是完全透明的鏤空區(qū)域嗎是的透明部分在圖片里會以透明通道保存放到任何背景色上都能繼續(xù)融合。如果希望導(dǎo)出的是白色背景圖可以在導(dǎo)出前臨時把背景畫成白色導(dǎo)出后再重新繪制回原樣。這個花活經(jīng)常用在海報工具里保存下來的圖片貼到文檔里不會黑乎乎一片。我個人在實際操作中的體會是Canvas 做鏤空效果方向本身不難難的是把上面這些邊緣問題一次性考慮周全。先把一個基礎(chǔ) Demo 跑通再把 DPR、字體、跨域、合成模式這幾個點按順序處理好后面加動效、加導(dǎo)出都是順?biāo)浦鄣氖?。如果這篇文章能幫你少走幾條彎路那目的就達到了。