设计稿是 #4A7C6F,页面量出来是 #4B7C6F:HEX、HSL、透明度、canvas 来回转的七个坑
上周和设计对颜色,设计稿上写的是 hsl(164, 25%, 39%),我在页面里量出来是 #4B7C6F,设计给的品牌色却是 #4A7C6F,差了一个 R。两个人对着屏幕看了半天,肉眼根本看不出区别,但 UI 自动化截图对比一跑就报差异。
查下来不是谁写错了,而是颜色在 HEX、RGB、HSL、透明度、canvas 之间来回转的时候,每一步都可能丢一点精度。我把碰到的和顺手测到的坑整理成七条,代码都在 Node 25.8 和 Chrome 154 里实跑过,输出直接贴在注释里。
1. HSL 取整之后,转不回原来的 HEX
这就是开头那个问题。#4A7C6F 精确的 HSL 是 hsl(164.4, 25.25%, 38.82%),大多数工具和设计软件展示时会取整成 hsl(164, 25%, 39%):

展示没问题,但拿取整后的值再转回去,就不是原来那个颜色了。在 Chrome 里直接验证:
const d = document.createElement('div')
document.body.appendChild(d)
for (const c of ['hsl(164.4 25.25% 38.82%)', 'hsl(164, 25%, 39%)']) {
d.style.color = c
console.log(c, '->', getComputedStyle(d).color)
}
// hsl(164.4 25.25% 38.82%) -> rgb(74, 124, 111)
// hsl(164, 25%, 39%) -> rgb(75, 124, 111)
我又在 Node 里把 RGB 空间按步长 5 扫了一遍(14 万个颜色),HSL 取整到整数后再转回 RGB:
function rgbToHsl({ r, g, b }) {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
const l = (max + min) / 2
if (max === min) return { h: 0, s: 0, l }
const d = max - min
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
let h
if (max === r) h = (g - b) / d + (g < b ? 6 : 0)
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
return { h: h * 60, s, l }
}
function hslToRgb({ h, s, l }) {
const k = n => (n + h / 30) % 12
const a = s * Math.min(l, 1 - l)
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255) }
}
let bad = 0, total = 0
for (let r = 0; r < 256; r += 5)
for (let g = 0; g < 256; g += 5)
for (let b = 0; b < 256; b += 5) {
total++
const hsl = rgbToHsl({ r, g, b })
const back = hslToRgb({
h: Math.round(hsl.h),
s: Math.round(hsl.s * 100) / 100,
l: Math.round(hsl.l * 100) / 100,
})
if (back.r !== r || back.g !== g || back.b !== b) bad++
}
console.log(bad, total)
// 125180 140608 ← 89% 的颜色往返后变了,单通道最多差 3
89%。HSL 整数精度(360 × 101 × 101 ≈ 367 万种)本身就比 RGB(1677 万种)少,取整必然有损。
结论很简单:HEX 或 RGB 才是"源数据",HSL 只用来展示和调色。设计规范、主题变量里存 HEX,需要 HSL 的地方现场算,不要拿展示用的 HSL 再转回去存。
2. 8 位 HEX 的透明度在最后,Android 在最前
CSS 支持 #RRGGBBAA 和简写 #RGBA,透明度在最后两位。Android 的颜色资源和不少客户端用的是 #AARRGGBB,透明度在最前面。同一个字符串,两边读出来是完全不同的颜色:
function parseHex(hex) {
let h = hex.replace(/^#/, '')
if (h.length === 3 || h.length === 4) h = [...h].map(c => c + c).join('')
if (h.length !== 6 && h.length !== 8) throw new Error('bad hex: ' + hex)
const n = i => parseInt(h.slice(i, i + 2), 16)
return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) / 255 : 1 }
}
console.log(parseHex('#abc')) // { r: 170, g: 187, b: 204, a: 1 }
console.log(parseHex('#4a7c6f80')) // { r: 74, g: 124, b: 111, a: 0.50196 }
// 客户端同学给的 #804a7c6f(AARRGGBB),按 CSS 规则读:
console.log(parseHex('#804a7c6f')) // { r: 128, g: 74, b: 124, a: 0.435 } ← 变成了紫色
跨端共用一套色值时,最好约定只传 6 位 HEX,透明度单独一个字段。
3. 透明度 0.5 存成 HEX 之后不是 0.5
8 位 HEX 的透明度只有 256 档,0.5 × 255 = 127.5,四舍五入成 128(0x80),读回来是 128 / 255:
for (const a of [0.5, 0.3, 0.1]) {
const byte = Math.round(a * 255)
console.log(a, '->', byte.toString(16), '->', (byte / 255).toFixed(5))
}
// 0.5 -> 80 -> 0.50196
// 0.3 -> 4d -> 0.30196
// 0.1 -> 1a -> 0.10196
更容易让人困惑的是,Chrome 的 getComputedStyle 会把它显示成 0.5:
d.style.color = '#4A7C6F80'
getComputedStyle(d).color // "rgba(74, 124, 111, 0.5)"
页面上看是 0.5,存下来的其实是 0.50196。如果你的单测拿 === 0.5 去比,就会时好时坏。比较透明度一律用误差:Math.abs(a - b) < 1 / 255。
4. getComputedStyle 不一定返回 rgb()
以前大家写取色逻辑,默认 getComputedStyle(el).color 一定是 rgb() 或 rgba(),然后用正则拆三个数。现在不成立了:
for (const c of ['#4a7c6f', 'rebeccapurple', 'transparent',
'oklch(0.55 0.05 170)', 'color(display-p3 1 0 0)']) {
d.style.color = c
console.log(c, '->', getComputedStyle(d).color)
}
// #4a7c6f -> rgb(74, 124, 111)
// rebeccapurple -> rgb(102, 51, 153)
// transparent -> rgba(0, 0, 0, 0)
// oklch(0.55 0.05 170) -> oklch(0.55 0.05 170) ← 原样返回
// color(display-p3 1 0 0) -> color(display-p3 1 0 0) ← 原样返回
只要项目里有人开始用 oklch() 或 P3 色域写主题色,/rgba?\((\d+),\s*(\d+),\s*(\d+)/ 这种正则就会直接返回 null。canvas 的 fillStyle 读回来也一样,oklch() 原样返回。
稳一点的取法是画到 1×1 的 canvas 上读像素,拿到的一定是 sRGB 的 0~255(但看下一条)。
5. canvas 读回来的像素,低透明度时会变色
canvas 内部用的是预乘 alpha(颜色先乘透明度再存),透明度越低,能保留的颜色精度越少:
const c = document.createElement('canvas')
c.width = c.height = 1
const x = c.getContext('2d')
x.fillStyle = 'rgba(74,124,111,0.1)'
x.fillRect(0, 0, 1, 1)
console.log([...x.getImageData(0, 0, 1, 1).data]) // [78, 128, 108, 26]
x.clearRect(0, 0, 1, 1)
x.fillStyle = 'rgba(74,124,111,0.02)'
x.fillRect(0, 0, 1, 1)
console.log([...x.getImageData(0, 0, 1, 1).data]) // [51, 102, 102, 5]
透明度 0.1 时三个通道各偏了 3~4;到 0.02 时 (74,124,111) 已经变成了 (51,102,102)。用 canvas 做取色器、或者"把图片里的半透明像素还原成原色"时,这个误差是绕不过去的。
6. 半透明叠在不同底色上,是不同的实色
设计稿上写"主色 50% 透明度",深色模式下看起来就是另一个颜色。要给出等效的实色,得按底色算:
const over = (fg, a, bg) => ({
r: Math.round(fg.r * a + bg.r * (1 - a)),
g: Math.round(fg.g * a + bg.g * (1 - a)),
b: Math.round(fg.b * a + bg.b * (1 - a)),
})
const brand = { r: 74, g: 124, b: 111 }
over(brand, 0.5, { r: 255, g: 255, b: 255 }) // #a5beb7(白底)
over(brand, 0.5, { r: 30, g: 30, b: 30 }) // #344d47(深色底)
同一个 rgba(74,124,111,0.5),白底上是浅灰绿,深色底上是墨绿。如果某个地方需要"看起来固定的颜色"(比如标签背景要和截图对齐),直接用算好的实色,不要用半透明。
7. 两个颜色取平均,别在 sRGB 里直接平均
做渐变中点、做图表的中间色时,最直觉的写法是三通道分别取平均。但 sRGB 的数值不是线性亮度,直接平均会偏暗:
const toLin = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 }
const fromLin = v => Math.round(255 * (v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055))
// 红 #ff0000 和 绿 #00ff00 的中点
// sRGB 直接平均:#808000(发脏的橄榄色)
// 线性空间平均:#bcbc00
console.log(fromLin((toLin(255) + toLin(0)) / 2)) // 188 = 0xbc
#808000 和 #bcbc00 差得很明显。CSS 渐变现在可以写 linear-gradient(in oklab, red, lime) 让浏览器换个空间插值,自己算的话至少先转线性再平均。
同一套线性化公式还用来算 WCAG 对比度。顺手测了几个常用灰:
| 颜色(白底) | 对比度 | 正文 4.5:1 |
|---|---|---|
| #777777 | 4.48 | 不达标 |
| #767676 | 4.54 | 达标 |
| #999999 | 2.85 | 不达标 |
| #4A7C6F | 4.77 | 达标 |
#777 和 #767676 只差 1,一个过一个不过。很多人觉得"浅灰字挺好看",但 #999 在白底上连 3:1 都不到。
小结
- 存 HEX/RGB,HSL 只做展示,取整后不要再转回去。
- 8 位 HEX 的透明度位置,CSS 在后、Android 在前。
- 透明度只有 256 档,比较时带误差。
getComputedStyle可能返回oklch(),别只认rgb()。- canvas 低透明度读回会失真;半透明叠不同底色是不同实色。
- 颜色插值和对比度都要先线性化。
日常查一个 HEX 对应的 RGB、HSL,我用的是 福兮的颜色转换工具,在浏览器本地算,输一个 HEX 直接出 RGB 和 HSL。注意它和大多数工具一样,HSL 是取整展示的,就是第 1 条说的情况:拿来看没问题,要往回转请用原始 HEX。它目前只支持 HEX 作为输入,8 位透明度、oklch 这类需要自己用上面的代码处理。
更多推荐



所有评论(0)