首頁那顆土星旁邊的字卡(碎念、手記那些),以前一直是浮在 3D 畫面上面的 DOM。這週 Chrome 157 正式推出 HTML-in-Canvas,雖然只有 157 以上吃得到,但遲早都會支援,做好 fallback 就好,所以我一開始就打定主意把它們搬進土星那張 canvas。現在字跟衛星由同一個 worker 在同一幀畫出來,首頁閒置時主執行緒的工作少了將近七成。
上面的數字是在 Chrome 158、CPU 降速 4 倍(模擬慢手機)、首頁閒置 10 秒量的,而且是 dev 模式,正式版的絕對數字會不一樣,比例應該差不多。
HTML-in-Canvas 是什麼#
我覺得這是近幾年少數真的會改變 Web 開發方式的平台更新。上一次有這種感覺是 WebGPU(Chrome 113,2023 年),它讓瀏覽器能直接用現代 GPU。HTML-in-Canvas 補的是另一半,讓 GPU 畫得出「真正的 HTML」。WebMCP 也是最近的大新聞,但那是讓 AI 代理操作網站,跟畫面怎麼畫是兩回事。總之很樂見這種東西出現,它解掉的是一個存在了十幾年的老問題。
以前只有兩條路#
在這之前,canvas 跟 HTML 是兩個世界。想在 canvas(2D、WebGL、WebGPU)裡放一段有排版的文字或一個按鈕,只有兩種做法,各有各的代價:
| 做法 | 做法說明 | 代價 |
|---|---|---|
| 把字畫成像素 | fillText(),或把字做成貼圖、SDF 字型 | 螢幕閱讀器讀不到,要另外維護一份隱形的 DOM。不能 Ctrl+F 找、不能選取複製、沒有右鍵選單,瀏覽器翻譯和拼字檢查都失效。多國語言的換行、斷字、直排得自己帶一套排版引擎 |
| 把 DOM 疊在 canvas 上 | 每幀用 JavaScript 算 3D 座標,移動 HTML 元素 | 跟 canvas 不在同一幀,鏡頭一動就飄、會抖。HTML 永遠蓋在整張 canvas 上面,不能被 3D 物件擋住、不能套 shader。每幀改樣式吃主執行緒 |
我的首頁用的是第二種,這篇後面量到的問題全都來自它。
現在多了第三條#
HTML-in-Canvas 讓瀏覽器把一般的 DOM 元素「拍」成一張圖,交給 canvas 去畫。重點是元素本身還留在 DOM 裡,所以:
- 瀏覽器本來就有的功能全部保留:點擊、Tab 鍵焦點、螢幕閱讀器、文字選取、Ctrl+F、翻譯、表單自動填入,都照常運作。
- 排版交還給瀏覽器:CSS、字型、換行、多國語言、
:hover這些樣式變化,畫進去的就是瀏覽器排好的結果,不用自己寫排版。 - 畫的時候是 GPU 貼圖:可以放進 3D 場景、被物件遮住、跟著透視變形、套 shader 做扭曲或光暈,這些 DOM 疊層做不到。
- 可以交給 worker:主執行緒拍快照,畫的工作可以在 worker 的 OffscreenCanvas 裡做,跟其他 3D 內容同一幀出來,不再飄。
- 點擊位置跟得上:畫在哪裡可以告訴瀏覽器,點擊和無障礙的位置就對到畫出來的地方。
提案裡列的用途包括:圖表的圖例和座標軸、創作工具裡的文字框、遊戲選單、3D 場景裡的螢幕或看板、用 shader 處理 HTML,以及把 HTML 匯出成圖片或影片。Babylon.js、PlayCanvas、luma.gl 都已經有對應的寫法。
它也有限制:基於隱私,跨網域的圖片或 iframe、系統色、拼字檢查的紅線、已造訪連結的顏色都不會被畫出來。另外文字沒有,看起來會比原本的 DOM 稍微淡一點。
先確認瀏覽器跟 API#
正式版改過名字#
這個 API 經過 之後改了名字,我查資料時看到的教學、文章、甚至框架的實作,很多都還是舊寫法:
| 用途 | 正式版寫法 | 舊寫法 |
|---|---|---|
| 開啟 | <canvas content="drawable">,子元素加 drawable | layoutsubtree |
| 2D | ctx.drawElementImage(el, x, y) | 同名 |
| WebGL | gl.texElementSubImage2D(...) | texElementImage2D |
| WebGPU | device.queue.drawElementImageToTexture(...) | copyElementImageToTexture |
| 重畫時機 | paint 事件,要重拍就呼叫 canvas.requestPaint() | |
| 3D 裡的點擊 | canvas.updateElementGeometry(el, { canvasTransform }) | |
| 給 worker 用 | 主執行緒 captureElementImage(el) 拍下快照,transfer 過去 |
舊名在 Chrome 158 裡已經是 undefined,照舊教學寫會直接沒反應。偵測的寫法是設一次屬性,看它有沒有反射回來:
function supportsHtmlInCanvas(): boolean {
const canvas = document.createElement('canvas');
canvas.setAttribute('content', 'drawable');
return canvas.content === 'drawable' && typeof canvas.captureElementImage === 'function';
}TypeScript 的 lib.dom 還沒有這些 API,型別要自己宣告,而且全部標成可有可無,不支援的瀏覽器上它們都不存在。
最簡單的用法#
先看 2D 的版本,整個 API 的骨架就在這裡。canvas 加上 content="drawable",要畫的子元素加上 drawable:
<canvas id="c" width="600" height="300" content="drawable">
<div id="card" drawable>
<h2>手記 NOTES</h2>
<a href="/notes">最新一篇</a>
</div>
</canvas>const canvas = document.querySelector<HTMLCanvasElement>('#c')!;
const ctx = canvas.getContext('2d')!;
const card = document.querySelector<HTMLElement>('#card')!;
// 元素的內容或樣式變了(包括 :hover)瀏覽器就會發 paint,在這裡重畫
canvas.addEventListener('paint', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawElementImage(card, 40, 60);
});
canvas.requestPaint(); // 要每幀動的話,在 paint 裡再呼叫一次幾個規則:
drawable是一個獨立的繪製單位。 它底下的子孫會一起被畫進去,但子孫裡如果又有drawable,那個會被排除,要另外畫。- 2D 會自動對上點擊位置。
drawElementImage的參數加上 canvas 目前的變形,瀏覽器就知道元素畫在哪,點擊、getBoundingClientRect()、放大鏡都跟著對。 - WebGL/WebGPU 要自己告訴瀏覽器。 貼圖被 shader 貼到哪裡瀏覽器不知道,得把矩陣交給
updateElementGeometry()。不更新的話,東西轉走了,點擊還落在原地。clearElementGeometry()則是讓它點不到,並在無障礙樹裡標成畫面外。
本機沒有能測的瀏覽器#
我伺服器上的 Edge 是 148,apt 的穩定版也才 156,都不支援。Edge 157 只有 beta,要 sudo 裝,最後用 npx @puppeteer/browsers install chrome@beta 裝了 Chrome for Testing 158,放在家目錄、不需要 sudo。伺服器沒有螢幕,所以用 xvfb 開有視窗的模式,加上 Vulkan 的參數,才拿得到真的 WebGPU。
three.js 的 HTMLTexture 不能用#
three.js 從 r184 起就有 HTMLTexture,照理說直接拿來用就好。我讀了專案裡 r186 的原始碼,有兩個問題:
它會設 layoutsubtree,呼叫 texElementImage2D 和 copyElementImageToTexture,這些在 157 以後都不存在了。改名的修正在 three.js PR #34732,作者正是寫 Chrome 157 公告的那位 Chrome DevRel,但到我做的時候還沒人 review。我順便把剛發布的 r187 下載下來看,一樣是舊名。
它會把元素 appendChild 進 renderer.domElement,再掛 paint 事件。但我首頁的星空和土星跑在 加 上,worker 拿不到 DOM。就算那個 PR 合併了,它還是從 domElement 拍快照,在我的架構裡那條路根本不會走到。
所以上傳的流程得自己寫:
目的地我用 three 的 render target 的貼圖,r187 剛好新增了 renderer.initRenderTarget(),可以先把 GPU 貼圖配好再寫進去,大約 40 行就取代了 HTMLTexture。核心是這段:
const rt = new THREE.RenderTarget(texW, texH, { depthBuffer: false });
renderer.initRenderTarget(rt);
const gpuTexture = renderer.backend.get(rt.texture).texture;
device.queue.drawElementImageToTexture(
{ source: image }, // 主執行緒傳過來的 ElementImage
{ texture: gpuTexture, size: { width: texW, height: texH } },
);
image.close(); // 用完要釋放image.close() 是從那個 PR 學來的,我原型裡一開始沒寫,每次 paint 都會留下一份沒釋放的快照。
原型:字卡繞著土星轉#
動站上的程式碼之前,我先在暫存區做了一個獨立原型,完整模擬首頁的架構:worker 裡的 three.js WebGPU 畫一顆土星,幾張 DOM 字卡繞著它轉。
右邊那張卡離鏡頭近,有透視、比較大,滑鼠停在上面所以邊框變成琥珀色,這代表 CSS 的 :hover 會即時進到貼圖裡。背後那張被土星擋住,是深度測試自然做到的。點擊也對:對投影後的位置點下去,elementFromPoint 回傳的是正確的卡,被擋住的卡清掉幾何之後,同一個位置只會點到 canvas。
原型也踩到幾個坑:
- 貼圖的色彩空間和 UV。 一開始底色變亮偏藍、字上下顛倒,要處理色彩空間,平面的 也要上下翻。
- 偶發的錯誤。
drawElementImageToTexture大約 9 次會有 1 次丟出「No context found for ElementImage」。元素剛放進去、還沒畫過的那一刻拍不到,catch 住之後再呼叫requestPaint()重拍就好。 - Tab 焦點跑到土星背後。 鍵盤焦點會落在被擋住的那張卡上,這得另外決定行為。
- 遠處的字比較軟。 卡片離鏡頭遠時貼圖被縮小,要用 2 倍解析度拍。
先搬小標註:好處只有一個#
原型可行,我開了一個新的 worktree,第一步先拿首頁五個常駐小標註(WORK、NOTES 那些)試水溫,看實際接進站上到底有什麼好處,滑過去才出現的字卡暫時維持 DOM。跟 ?hic=0(強制走原本的 DOM 版)對照之後,結果有點出乎意料:
- 主執行緒卡住時,標註還貼著衛星。 這是唯一明顯的好處。我刻意讓主執行緒卡住 3.5 秒,衛星在 worker 裡照常繞:DOM 標註凍結在原地,點跟字分家,畫進 canvas 的標註全程跟著衛星走。
- 主執行緒只少了 13%,樣式重算少 20%。
- 「被星球真實遮住」在首頁幾乎用不到。 最內圈軌道的遠側只擦過星球上緣,標註又一律往遠離土星的方向長,我等了 2 分鐘(將近兩圈)一次都沒遇到。
另外還有代價:畫進去的標註要點得到,canvas 上面的三層(.horbit、<main>、z-index 10 的內容容器)都得讓滑鼠穿過去,這個改動動到滿深的地方。
主執行緒真正卡在哪#
13% 太少了,我想知道剩下的時間花在哪。用 錄效能追蹤,打開樣式失效追蹤、CPU 降速 4 倍,再把每次歸到觸發它的元素,一個一個拿掉來量:
| 首頁閒置,CPU 降速 4 倍(每秒 ms) | 主執行緒 | 樣式重算 |
|---|---|---|
| 原本(DOM 標註) | 580–660 | 420–494 |
| 標註搬進 canvas | 560–570 | 360–400 |
| 再把字卡拿掉 | 約 175 | 約 50 |
大頭是字卡,不是標註。字卡佔了樣式重算的將近九成,其中約六成來自一個很蠢的原因:閒置時會輪流提示,字卡的 opacity 每幀都在變,而 CSS 寫了 transition: opacity 0.3s,所以每寫一次就取消舊轉場、建立新轉場。
這部分不需要 HTML-in-Canvas 也能修,我先把淡入淡出改成在 JavaScript 裡算,所有瀏覽器都受惠。剩下的那一半,是每幀替十幾個元素寫 inline style 本身,這只有把字卡也交給 canvas 畫才拿得掉。這也說明了為什麼只搬小標註省得少:真正每幀被改樣式的大戶,是字卡。
把字卡也搬進去#
修完轉場,接著就是把字卡也搬進去。受惠的暫時只有 Chrome 157 以上,但支援只會越來越多,fallback 做好,今天先拿到好處的是用新版 Chrome 的人,之後就是所有人。
原則是「畫面跟現在一模一樣」,所以正式版跟原型有一個關鍵差別:字不進 3D 場景。它畫在整張圖的最上層、用螢幕座標、對齊裝置像素,快照一個像素對一個像素貼上去,字不會被重新取樣而糊掉。反正首頁幾乎遇不到真實遮擋,換來的是跟 DOM 版分不出來的字。
位置、淡入淡出、什麼時候讓位,我抽成一個純函式的模組(lib/orbitLabels),DOM 版和 worker 共用同一套,兩邊算出來一定一樣。
每一行要各自對齊#
字卡裡的每一行會隨衛星的位置對齊:衛星在右邊時靠左、在左邊時靠右、在下面時置中,而且是連續變化的。如果照舊每幀改每一行的 translate,又會觸發樣式重算加重拍快照,等於白做。
我的做法是整張字卡只拍一次,連同每一行的上緣和寬度一起送給 worker。worker 畫的時候把快照切成橫條,每一行是同一張貼圖裡的一個子區塊,各自往左右挪,外圍那一圈(鍵盤焦點的外框畫在那裡)不挪。這樣字卡內容沒變就不用重拍,每幀只動頂點。
什麼時候要重拍,交給 paint 事件決定。它會帶上這次有變的元素(changedElements),所以滑鼠移上去、換主題、字卡內容更新時,只重拍變了的那幾張:
canvas.addEventListener('paint', (e) => {
const changed = e.changedElements;
const out = [];
let missed = false;
for (const el of labels) {
if (changed?.length && !changed.includes(el)) continue;
try {
out.push({ image: canvas.captureElementImage(el), w: el.offsetWidth, h: el.offsetHeight });
} catch {
missed = true; // 剛放進去還沒畫過,下一幀再拍
}
}
if (out.length) worker.postMessage({ type: 'labels', labels: out }, out.map((l) => l.image));
if (missed) canvas.requestPaint();
});ElementImage 是可以 transfer 的,丟給 worker 不用複製,worker 那邊上傳完就 close()。
顏色差了三成#
第一版畫出來,小字比 DOM 版暗了大約三成。追下去有兩件事:
- 快照是,我卻當成預乘過,透明度乘了兩次。
- three 把畫面輸出到 canvas 時,會先除掉透明度、轉成 sRGB、再乘回去。DOM 的字疊在 canvas 上,是在 sRGB 的數值上混色,如果在線性空間混,淺色字的反鋸齒邊緣會變細。
所以疊字那一步要照 three 的輸出規則反推:先算出 canvas 上原本的 sRGB 數值,在這個數值上跟字混,再轉回輸出要的線性值。驗證用一塊已知顏色的半透明底:白色 50% 疊上去,預期 134.5,實際 132。
上下兩張一張是 DOM 版、一張是畫進 canvas 的,放大之後幾乎分不出來。唯一的差別是 DOM 的字會落在小數像素上,canvas 版對齊整數像素,邊緣稍微銳利一點。
點擊和鍵盤#
worker 每幀算出字卡在畫面上的框,傳回主執行緒交給 updateElementGeometry,點擊就落在畫出來的位置。框跟上一幀一樣就不傳。
鍵盤和螢幕閱讀器改走頁面上一份看不見的連結,Tab 順序跟以前一樣。畫進 canvas 的字卡標成 aria-hidden,不會被讀兩次。焦點外框畫在 canvas 裡的字卡上。
有一個小地方差點漏掉:為了畫焦點外框,快照外圍多留了一圈,結果滑鼠的點擊範圍也大了 7px。後來改成只有鍵盤聚焦時才把外圍算進去,滑鼠的點擊範圍就跟 DOM 版完全一樣。
結果#
| 每秒主執行緒(Chrome 158,CPU 降速 4 倍,首頁閒置) | DOM 版(?hic=0) | 畫進 canvas |
|---|---|---|
| 總工作 | 460 ms | 145 ms(−69%) |
| 樣式重算 | 310 ms | 24 ms(−92%) |
| 腳本 | 60 ms | 35 ms |
主執行緒每幀只剩算「哪顆衛星亮著」,值沒變就不送。上線後在正式站確認過:Chrome 158 走 HTML-in-Canvas,Edge 148 照舊是 DOM,兩邊都沒有錯誤。Firefox、Safari、只有 WebGL2 的裝置、3D 沒跑起來的情況,也都是 DOM 版。
字變成貼圖之後#
效能做完,我更好奇的其實是另一件事:字現在是 GPU 上的一張貼圖,能不能做出 DOM 疊層做不到的效果,讓它真正融進這個宇宙?我列了五個點子,各做了一版試作。這部分還在試作分支上,還沒上線。
原本點下字卡,字先消失,再把土星收進那顆衛星。現在字跟著一起被吸進去,越收越細長,最後一小段才淡掉。反過來,按上一頁回到首頁時,每個標註從自己那顆衛星「吐」出來,先細長、再展開回原形,而且跟著衛星彈出的順序一個接一個出來。
字卡出現時,像無線電訊號收進來一樣從衛星那一點往外推開,推到的邊緣帶紅藍錯位和掃描線,大約半秒解完。實際速度很快,下面是放大 2 倍、放慢 5 倍的版本。
英文小標周圍一圈柔和的金光,跟衛星的光暈同一種質感。原本想直接接星空的 ,結果完全沒作用:光暈落在畫布透明的地方,透明度是 0,輸出那一步整個被吃掉。最後改成在字這一層自己做高斯模糊。
字卡朝光源那側稍亮、另一側稍暗,跟土星的受光方向一致。效果很淡,放大比對才看得出來,但看起來比較真實。
最開始其實還有一個「黑洞重力透鏡」:黑洞主題下,字經過黑洞附近時跟背後的星星一起被拉彎。技術上最有趣,因為透鏡的計算本來就有,字只要改成在偏折過的位置取樣。
但實際看起來太彎了,有點抽象,而且字會離開自己的衛星一段距離,點擊範圍還停在沒彎之前的位置。我最後把它拿掉,換成「吐出來」的版本:被吸進去是因為引力,那回來時倒著吐出來,剛好接得上。回到首頁、土星長出來時,每個標註在自己那顆衛星彈出的時候,從那一點先細長、再展開回原形,大約 0.7 秒。
第一版直接沿用衛星彈出的進度,但那只有 0.25 秒,標註又是從透明淡入,看起來跟沒做一樣。後來改成標註自己跑一段動畫,出來時就是不透明的,只靠形狀從一點展開。回來時通常只有 10px 的小標註,所以吐出來比吸進去含蓄,放大放慢才看得清楚:
這些效果只有 Chrome 157 以上看得到,所以只能是加分,功能不能靠它。之後收成正式版時,吸入吐出和解碼會在 prefers-reduced-motion 時關掉。
要注意的地方#
- 只能當加分。 目前只有 Chrome/Edge 157 以上支援,一定要有 DOM 版的退回路徑。我的偵測失敗、WebGL2、3D 沒跑起來,或網址帶
?hic=0,都走 DOM。 - 別照舊教學寫。 先確認是正式版的命名,
layoutsubtree、texElementImage2D那一套在 158 已經不存在。 - three.js 的
HTMLTexture先別用。 截至 r187 還是舊命名,而且 canvas 在 worker 裡的話,修正後也用不到。 - 快照要 close。
ElementImage用完不釋放會一直累積。 - 拍不到要重拍。 偶發的「No context found for ElementImage」catch 住,
requestPaint()下一幀再拍。 - 先量再搬。 搬進 canvas 之前,先確定主執行緒的時間真的花在那些元素上。我第一步只搬小標註,省下的就只有 13%。
這次最讓我有感的,其實是那段主執行緒卡住的對照影片:字只要交給 worker 畫,主執行緒再忙都拖不動它,「吸進衛星、吐出來」也是在 worker 裡跑,完全不受影響。換頁轉場卡頓的原因正是同一個:新頁面掛上去時佔住主執行緒,而轉場的點也在主執行緒畫。所以我接下來照同樣的做法,把轉場的點和彗尾也搬進 worker,已經在分支上動工了,做完再另外寫。
- HTML-in-CanvasChrome 157 正式版公告概念說明WebGPU 用法WICG 提案
- three.jsPR #34732:HTMLTexture 改用正式版命名