用正則解析顏色在 oklch 時代已經失效。canvas 有兩種用法只有一種對,半透明要逐層疊,還有一個能讓壞版本也通過的反向驗證陷阱。
發布於 2026-09-06・約 5 分鐘
我寫了一支腳本掃全站的文字對比度,找不符合 WCAG AA 的地方。
第一版報告說某個按鈕的對比是 1.53,等於文字幾乎看不見。用眼睛看,那顆按鈕好得很。
實際值是 10.83。
從 v4 開始,Tailwind 產生的顏色大量是 oklch() 和 oklab():
oklch()
oklab()
--color-emerald-400: oklch(0.765 0.177 163.223);
如果你的顏色解析是「用正則抓出三個數字當 RGB」,這裡會抓到 0.765、0.177、163.223,然後把 0.765 當成 R。
0.765
0.177
163.223
於是一個明亮的翠綠色被解讀成接近純黑。 白色 85% 亮度也會被解析成接近黑色。
這不是「解析失敗」,是「解析成一個完全合理但完全錯誤的顏色」,所以下游的計算一路順暢,最後給你一個看起來很正常的比值。
「用 canvas 讓瀏覽器自己轉色」是對的方向,但我第一次寫的是錯的那種:
// ❌ 壞的 const ctx = document.createElement('canvas').getContext('2d') ctx.fillStyle = 'oklch(0.765 0.177 163.223)' const hex = ctx.fillStyle // Chrome 回傳的還是原字串
Chrome 不會把 oklch/oklab 正規化成 hex,fillStyle 讀回來還是同一串文字。於是你以為轉好了,實際上又回到正則那條路。
oklch
oklab
fillStyle
正確的是真的畫一個像素再讀回來,強制光柵化成 sRGB:
// ✅ 對的 const ctx = document.createElement('canvas').getContext('2d') function toRGB(color) { ctx.fillStyle = color ctx.fillRect(0, 0, 1, 1) const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data return [r, g, b] }
任何瀏覽器認得的色彩語法都會通過這條路:hex、rgb、hsl、oklch、color-mix()、CSS 變數算出來的值。因為你問的不是「這個字串怎麼解析」,而是「這個顏色畫出來是什麼」。
color-mix()
寫完對比計算器一定要先驗證再用。最直覺的驗證是:
console.assert(ratio('#fff', '#000') === 21)
但壞掉的版本這題也會過,因為 #fff 和 #000 是 hex,正則解析得了。
#fff
#000
所以驗證一定要餵一個已知的 oklch 值:
// emerald-400 應該轉出約 (0, 212, 146) const [r, g, b] = toRGB('oklch(0.765 0.177 163.223)') console.assert(Math.abs(r - 0) < 4 && Math.abs(g - 212) < 4 && Math.abs(b - 146) < 4, `oklch 解析錯誤,得到 ${r},${g},${b}`)
只測 hex 和 rgb 等於沒測。 這條一般化的說法是:驗證用例要涵蓋你實際會遇到的輸入分佈,不是最好寫的那幾個。我用最好寫的那兩個驗證過,然後帶著一個壞掉的工具跑完全站。
第二個坑。文字底下如果是一張半透明的卡片:
.card { background: rgba(255, 255, 255, 0.06); }
只讀這一層會拿到一個幾乎透明的白色,算出來的對比接近 1,變成一堆假警報。
要往上走,把每一層疊起來:
function effectiveBg(el) { let [r, g, b] = [255, 255, 255] // 最底層假設是白 const layers = [] for (let n = el; n; n = n.parentElement) { const bg = getComputedStyle(n).backgroundColor const m = bg.match(/rgba?\(([^)]+)\)/) if (!m) continue const parts = m[1].split(',').map(Number) const a = parts.length > 3 ? parts[3] : 1 if (a === 0) continue layers.unshift({ rgb: parts.slice(0, 3), a }) if (a === 1) break // 不透明就到底了 } for (const l of layers) { r = l.rgb[0] * l.a + r * (1 - l.a) g = l.rgb[1] * l.a + g * (1 - l.a) b = l.rgb[2] * l.a + b * (1 - l.a) } return [r, g, b] }
getComputedStyle 回傳的 backgroundColor 一定是 rgb() 或 rgba() 格式,這裡用正則是安全的。文字顏色那邊就不行,因為作者寫什麼它就回什麼。
getComputedStyle
backgroundColor
rgb()
rgba()
第三個坑。有些人的作法是遇到 background-image 就放棄,因為漸層底下的顏色確實不好算。
background-image
我的 body 本身就是一個漸層。這條規則讓全站 100% 被判為無法量測,而報告上顯示的是「0 個問題」。
body
零個問題跟沒有量到,在報告上長得一模一樣。
務實的處理是分開看:background-image 是漸層的話,取漸層的兩個端點各算一次,用比較差的那個;是照片的話才真的放棄,並且明確標記為「未量測」而不是「通過」。
逐頁 page.goto() 很慢。同源的話可以在一個頁面裡開多個 iframe 載入不同路徑,直接讀 contentDocument:
page.goto()
contentDocument
const frame = document.createElement('iframe') frame.src = '/travel/' document.body.appendChild(frame) await new Promise(r => frame.onload = r) const doc = frame.contentDocument // 直接對 doc 裡的元素做上面那些量測
33 個工具頁這樣量,時間從幾分鐘降到十幾秒。
disabled 控制項。 WCAG 對它們本來就豁免。我第一次掃出 20 顆按鈕未達標,13 顆是輸入前灰掉的主要 CTA。
句子中的行內連結,不受 Target Size Minimum 約束。 頁尾那種「關於我們/隱私權政策」寫在句子裡的連結,WCAG 2.2 有明確豁免,維持 16px 高是正確的,不需要撐成 44px。
12px 中文的抗鋸齒會讓字心達不到宣告的顏色。我有一頁的文字色算下來大約 9:1,從截圖量只有 4.0。
所以它抓「明顯不及格」有效,不能拿來精算 4.5 附近的值。落在門檻附近時要回去算宣告色的相對亮度,而不是相信像素。
順帶一個實用發現:Tailwind 的 zinc-500 對 zinc-950 是 4.12,差一點點不過;zinc-600 是 2.58 更不行;要跳到 zinc-400 才有 7.76。中間沒有級距。 placeholder 這種常用 zinc-500 的地方,要嘛接受不過,要嘛跳兩級,沒有折衷方案。
標籤:無障礙、WCAG、Tailwind、oklch、色彩、前端