首頁那顆土星旁邊的字卡(碎念、手記那些),以前一直是浮在 3D 畫面上面的 DOM。這週 Chrome 157 正式推出 HTML-in-Canvas,雖然只有 157 以上吃得到,但遲早都會支援,做好 fallback 就好,所以我一開始就打定主意把它們搬進土星那張 canvas。現在字跟衛星由同一個 worker 在同一幀畫出來,首頁閒置時主執行緒的工作少了將近七成。

145ms/s
主執行緒總工作
原 460,−69%
24ms/s
樣式重算
原 310,−92%
157+
支援的瀏覽器
Chrome/Edge,其他走 DOM

上面的數字是在 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">,子元素加 drawablelayoutsubtree
2Dctx.drawElementImage(el, x, y)同名
WebGLgl.texElementSubImage2D(...)texElementImage2D
WebGPUdevice.queue.drawElementImageToTexture(...)copyElementImageToTexture
重畫時機paint 事件,要重拍就呼叫 canvas.requestPaint()
3D 裡的點擊canvas.updateElementGeometry(el, { canvasTransform })
給 worker 用主執行緒 captureElementImage(el) 拍下快照,transfer 過去

舊名在 Chrome 158 裡已經是 undefined,照舊教學寫會直接沒反應。偵測的寫法是設一次屬性,看它有沒有反射回來:

ts
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:

html
<canvas id="c" width="600" height="300" content="drawable">
  <div id="card" drawable>
    <h2>手記 NOTES</h2>
    <a href="/notes">最新一篇</a>
  </div>
</canvas>
ts
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 下載下來看,一樣是舊名。

它假設 canvas 在主執行緒

它會把元素 appendChild 進 renderer.domElement,再掛 paint 事件。但我首頁的星空和土星跑在 加 上,worker 拿不到 DOM。就算那個 PR 合併了,它還是從 domElement 拍快照,在我的架構裡那條路根本不會走到。

所以上傳的流程得自己寫:

paint 事件:captureElementImage(字卡)postMessage(快照,transfer)drawElementImageToTexture → 貼圖跟衛星同一幀畫出來字卡在畫面上的位置updateElementGeometry(點擊落點)主執行緒worker(WebGPU)主執行緒worker(WebGPU)

目的地我用 three 的 render target 的貼圖,r187 剛好新增了 renderer.initRenderTarget(),可以先把 GPU 貼圖配好再寫進去,大約 40 行就取代了 HTMLTexture。核心是這段:

ts
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 分鐘(將近兩圈)一次都沒遇到。
0:00 / 0:00
刻意讓主執行緒卡住 3.5 秒:左邊 DOM 標註凍結在原地,右邊畫進 canvas 的標註跟著衛星走

另外還有代價:畫進去的標註要點得到,canvas 上面的三層(.horbit、<main>、z-index 10 的內容容器)都得讓滑鼠穿過去,這個改動動到滿深的地方。

主執行緒真正卡在哪#

13% 太少了,我想知道剩下的時間花在哪。用 錄效能追蹤,打開樣式失效追蹤、CPU 降速 4 倍,再把每次歸到觸發它的元素,一個一個拿掉來量:

首頁閒置,CPU 降速 4 倍(每秒 ms)主執行緒樣式重算
原本(DOM 標註)580–660420–494
標註搬進 canvas560–570360–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),所以滑鼠移上去、換主題、字卡內容更新時,只重拍變了的那幾張:

ts
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 ms145 ms(−69%)
樣式重算310 ms24 ms(−92%)
腳本60 ms35 ms

主執行緒每幀只剩算「哪顆衛星亮著」,值沒變就不送。上線後在正式站確認過:Chrome 158 走 HTML-in-Canvas,Edge 148 照舊是 DOM,兩邊都沒有錯誤。Firefox、Safari、只有 WebGL2 的裝置、3D 沒跑起來的情況,也都是 DOM 版。

字變成貼圖之後#

效能做完,我更好奇的其實是另一件事:字現在是 GPU 上的一張貼圖,能不能做出 DOM 疊層做不到的效果,讓它真正融進這個宇宙?我列了五個點子,各做了一版試作。這部分還在試作分支上,還沒上線。

換頁時,字被吸進衛星

原本點下字卡,字先消失,再把土星收進那顆衛星。現在字跟著一起被吸進去,越收越細長,最後一小段才淡掉。反過來,按上一頁回到首頁時,每個標註從自己那顆衛星「吐」出來,先細長、再展開回原形,而且跟著衛星彈出的順序一個接一個出來。

0:00 / 0:00
點下字卡,字跟土星一起被吸進衛星,按上一頁回來,標註再從各自的衛星吐出來(試作中)
訊號解碼式浮現

字卡出現時,像無線電訊號收進來一樣從衛星那一點往外推開,推到的邊緣帶紅藍錯位和掃描線,大約半秒解完。實際速度很快,下面是放大 2 倍、放慢 5 倍的版本。

0:00 / 0:00
訊號解碼式浮現,放大 2 倍、放慢 5 倍(試作中)
發光

英文小標周圍一圈柔和的金光,跟衛星的光暈同一種質感。原本想直接接星空的 ,結果完全沒作用:光暈落在畫布透明的地方,透明度是 0,輸出那一步整個被吃掉。最後改成在字這一層自己做高斯模糊。

光照

字卡朝光源那側稍亮、另一側稍暗,跟土星的受光方向一致。效果很淡,放大比對才看得出來,但看起來比較真實。

最開始其實還有一個「黑洞重力透鏡」:黑洞主題下,字經過黑洞附近時跟背後的星星一起被拉彎。技術上最有趣,因為透鏡的計算本來就有,字只要改成在偏折過的位置取樣。

但實際看起來太彎了,有點抽象,而且字會離開自己的衛星一段距離,點擊範圍還停在沒彎之前的位置。我最後把它拿掉,換成「吐出來」的版本:被吸進去是因為引力,那回來時倒著吐出來,剛好接得上。回到首頁、土星長出來時,每個標註在自己那顆衛星彈出的時候,從那一點先細長、再展開回原形,大約 0.7 秒。

第一版直接沿用衛星彈出的進度,但那只有 0.25 秒,標註又是從透明淡入,看起來跟沒做一樣。後來改成標註自己跑一段動畫,出來時就是不透明的,只靠形狀從一點展開。回來時通常只有 10px 的小標註,所以吐出來比吸進去含蓄,放大放慢才看得清楚:

0:00 / 0:00
標註從衛星吐出來,放大 2 倍、放慢 4 倍(試作中)

這些效果只有 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,已經在分支上動工了,做完再另外寫。