上周和设计对颜色,设计稿上写的是 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%):

HEX 转 RGB / HSL 的结果

展示没问题,但拿取整后的值再转回去,就不是原来那个颜色了。在 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
#7777774.48不达标
#7676764.54达标
#9999992.85不达标
#4A7C6F4.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 这类需要自己用上面的代码处理。

Logo

一站式 AI 云服务平台

更多推荐