1. 颜色换算在真实项目里到底解决什么问题
前端开发做到一定年头,你一定会碰到颜色格式打架的情况。设计师在 Figma 里给你一个#3B82F6,后端接口返回的却是rgb(59, 130, 246),而某个取色器插件复制出来的是hsv(217, 76%, 96%)。三种写法描述的是同一个颜色,但如果你要在代码里做颜色渐变、主题切换、明暗调节、对比度计算,就必须先把它们统一到同一个色彩空间里。
这就是颜色值换算要解决的核心问题:在不同色彩模型之间建立可逆的数学映射,让程序能够理解、比较和操作颜色。
具体来说,这套换算能力通常服务于以下几类场景:
- 主题系统:用户选一个主色,系统自动生成 hover 态、active 态、禁用态等一整套色板,靠的就是对 HSV 的明度和饱和度做增减。
- 颜色选择器:面板上拖动的是 HSV 滑块,但输入框里显示的是十六进制,底层还要输出 RGB 给 canvas 用。
- 数据可视化:根据数值大小在色带上插值,需要在 RGB 或 HSV 空间做线性过渡。
- 无障碍对比度检测:计算前景色和背景色的相对亮度,判断是否满足可读性标准,这要求先把任意格式的颜色转成 RGB 再算亮度。
适合阅读这篇内容的人:有一定 JavaScript 基础、做过界面开发、被颜色格式转换折磨过或者即将要做的同学。哪怕你只是想知道#FF0000和hsv(0, 100%, 100%)为什么是同一个颜色,往下看也能搞明白。
我下面会把三种格式的换算逻辑全部拆开,给出可以直接复制使用的函数,再补充一批实际项目里踩过的坑。代码不依赖任何第三方库,纯 JavaScript 实现,浏览器和 Node 环境都能跑。
2. 三种颜色模型的核心原理与选型逻辑
2.1 RGB:面向屏幕的加色模型
RGB 是最贴近硬件的一种表示方式。屏幕上的每个像素由红、绿、蓝三个子像素组成,每个通道的取值范围通常是 0 到 255,三个通道叠加出各种颜色。三个都是 0 就是黑色,都是 255 就是白色,这就是所谓的加色模型。
为什么是 0 到 255?因为一个通道用 8 位二进制存储,2 的 8 次方等于 256,能表示 0 到 255 共 256 个级别。三个通道组合起来就是 256 的三次方,约 1677 万种颜色,这也是常说的“真彩色”。
RGB 的优点是直观、计算简单,做通道级别的加减乘除非常方便。缺点是它不符合人类对颜色的感知方式。你问一个人“把这个蓝色调亮一点”,他在 RGB 里很难直接说出该改哪个通道;而在 HSV 里,只需要把 V 调高就行。
2.2 HSV:面向人的颜色描述方式
HSV 把颜色拆成三个维度:色相(Hue)、饱和度(Saturation)、明度(Value)。
- 色相 H:用角度表示,0 到 360 度。0 度是红色,120 度是绿色,240 度是蓝色,转一圈回到红色。它回答的是“这是什么颜色”。
- 饱和度 S:0% 到 100%。0% 是灰色,100% 是最鲜艳的状态。它回答的是“这个颜色有多纯”。
- 明度 V:0% 到 100%。0% 是黑色,100% 是最亮。它回答的是“这个颜色有多亮”。
这种表示方式跟人脑理解颜色的方式高度一致。设计师说“我要一个偏暖的、淡一点的橙色”,翻译成 HSV 就是 H 在 30 度左右、S 中等偏低、V 偏高。所以在做颜色选择器、主题生成、调色板算法时,HSV 是首选的操作空间。
注意:HSV 和 HSB 是同一个东西,只是叫法不同。有些工具里写 HSB,B 指的是 Brightness,和 Value 含义完全一样,不要被绕进去。
2.3 十六进制颜色码:紧凑的文本表示
十六进制颜色码本质上是 RGB 的另一种写法,只是把三个十进制通道值换成了两位十六进制。比如rgb(59, 130, 246),59 转十六进制是3B,130 是82,246 是F6,拼起来就是#3B82F6。
它还有几种简写和扩展形式:
| 格式 | 含义 | 示例 |
|---|---|---|
#RGB | 三位简写,每位重复一次 | #F00等于#FF0000 |
#RRGGBB | 标准六位 | #FF0000 |
#RRGGBBAA | 八位,末两位是透明度 | #FF000080 |
#RGBA | 四位简写 | #F008 |
十六进制的好处是紧凑、无歧义、便于在 CSS 和配置文件里书写。缺点是它不便于数学运算,你想把颜色调亮 10%,在十六进制字符串上没法直接操作,必须先转成数值。
2.4 为什么换算要以 RGB 为中转站
三种格式两两之间都能直接换算,但实际工程里最稳妥的做法是所有转换都经过 RGB 中转。原因有三点:
第一,RGB 到 HSV 和 HSV 到 RGB 的公式是成熟且可逆的,数学上很干净。第二,十六进制和 RGB 之间只是进制转换,几乎没有精度损失。第三,如果为每一对格式都写一条直接转换路径,会有六条路径,维护成本高且容易出错;以 RGB 为中心只需要四条路径,逻辑清晰。
所以整体架构就是:十六进制 ↔ RGB ↔ HSV,任意两个格式之间的转换都拆成两步走。
3. 核心换算函数的完整实现与逐行解析
3.1 十六进制与 RGB 的互转
先处理最简单的一对。十六进制转 RGB 的关键是处理三位简写和可能的#前缀,同时做好容错。
function hexToRgb(hex) { // 去掉可能存在的 # 前缀 let value = hex.replace(/^#/, ''); // 处理三位或四位简写,每位重复一次 if (value.length === 3 || value.length === 4) { value = value .split('') .map((char) => char + char) .join(''); } // 此时应该是六位或八位 if (value.length !== 6 && value.length !== 8) { throw new Error('无效的十六进制颜色值: ' + hex); } const r = parseInt(value.slice(0, 2), 16); const g = parseInt(value.slice(2, 4), 16); const b = parseInt(value.slice(4, 6), 16); // 如果有八位,末两位是透明度 const a = value.length === 8 ? parseInt(value.slice(6, 8), 16) / 255 : 1; return { r, g, b, a }; }这段代码里有两个容易忽略的点。一是parseInt的第二个参数必须写 16,否则像08这样的值在某些老引擎里会被当成八进制解析,虽然现代引擎已经默认按十进制处理,但显式写出来更安全。二是四位简写#RGBA的处理,很多人只记得三位简写,忘了四位的情况。
反过来,RGB 转十六进制要注意补零和取整:
function rgbToHex(r, g, b, a) { const toHex = (n) => { // 先四舍五入再限制范围,防止越界 const clamped = Math.round(Math.min(255, Math.max(0, n))); return clamped.toString(16).padStart(2, '0'); }; let hex = '#' + toHex(r) + toHex(g) + toHex(b); // 如果传了透明度且不为 1,追加两位 if (a !== undefined && a < 1) { const alphaHex = Math.round(Math.min(1, Math.max(0, a)) * 255) .toString(16) .padStart(2, '0'); hex += alphaHex; } return hex.toUpperCase(); }padStart(2, '0')是关键,因为toString(16)对小于 16 的数只会输出一位,比如 5 会变成"5"而不是"05",不补零就会拼出错误的颜色。另外Math.round不能省,因为 HSV 转 RGB 时经常产生小数,直接toString(16)会得到带小数点的乱码。
3.2 RGB 转 HSV 的数学推导
RGB 转 HSV 的核心是找出三个通道里的最大值和最小值,通过它们的差值和位置关系推导出色相。
function rgbToHsv(r, g, b) { // 归一化到 0-1 const rn = r / 255; const gn = g / 255; const bn = b / 255; const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn); const delta = max - min; let h = 0; // 色相计算:根据最大值出现在哪个通道分情况 if (delta !== 0) { if (max === rn) { h = 60 * (((gn - bn) / delta) % 6); } else if (max === gn) { h = 60 * ((bn - rn) / delta + 2); } else { h = 60 * ((rn - gn) / delta + 4); } } // 处理负角度 if (h < 0) h += 360; // 饱和度:delta 为 0 时是灰色,饱和度为 0 const s = max === 0 ? 0 : delta / max; // 明度就是最大值 const v = max; return { h: Math.round(h), s: Math.round(s * 100), v: Math.round(v * 100), }; }这里有几个细节值得展开说。
为什么色相公式要分三种情况?因为色相本质上是一个角度,它描述的是颜色在色环上的位置。当红色通道最大时,颜色在红到黄到品红这个区间,色相由绿蓝差值决定;绿色最大时在绿到青到黄区间;蓝色最大时在蓝到品红到青区间。三个公式分别对应色环上的三段,加上% 6和+2、+4的偏移,正好拼成完整的 360 度。
为什么delta === 0时色相设为 0?因为当三个通道相等时,颜色是纯灰,没有色相可言。这时候色相取任何值都不影响最终颜色,取 0 是约定俗成的做法。
饱和度的分母为什么是 max 而不是 255?因为 HSV 里的饱和度是相对于当前明度的。一个很暗的红色rgb(128, 0, 0),它的饱和度是 100%,而不是 50%。如果分母用 255,就会算成 50%,那就变成另一个模型 HSB 的变体了。
3.3 HSV 转 RGB 的分段处理
反向转换稍微复杂一点,因为要处理色相落在六个扇区的情况。
function hsvToRgb(h, s, v) { // 归一化 const hn = ((h % 360) + 360) % 360; // 处理负角度和超过 360 的情况 const sn = Math.min(1, Math.max(0, s / 100)); const vn = Math.min(1, Math.max(0, v / 100)); const c = vn * sn; // 色度 const x = c * (1 - Math.abs(((hn / 60) % 2) - 1)); // 中间值 const m = vn - c; // 明度调整量 let rn = 0, gn = 0, bn = 0; // 根据色相所在扇区分配 c 和 x if (hn < 60) { [rn, gn, bn] = [c, x, 0]; } else if (hn < 120) { [rn, gn, bn] = [x, c, 0]; } else if (hn < 180) { [rn, gn, bn] = [0, c, x]; } else if (hn < 240) { [rn, gn, bn] = [0, x, c]; } else if (hn < 300) { [rn, gn, bn] = [x, 0, c]; } else { [rn, gn, bn] = [c, 0, x]; } return { r: Math.round((rn + m) * 255), g: Math.round((gn + m) * 255), b: Math.round((bn + m) * 255), }; }这套公式的思路是:先把颜色拆成“色度 c”和“明度调整量 m”两部分。色度代表颜色的鲜艳程度,m 代表整体的亮度基底。六个扇区分别对应色环上红黄、黄绿、绿青、青蓝、蓝品、品红六段,每段里 c 和 x 的分配方式不同。最后统一加上 m 再乘以 255,就得到了 RGB 值。
((h % 360) + 360) % 360这个写法是为了处理负数色相。比如传进来 -30 度,-30 % 360在 JavaScript 里是 -30,再加 360 变成 330,再取模还是 330,正好对应正确的角度。这个技巧在处理角度循环时非常常用。
3.4 统一入口函数的设计
实际项目里,调用方往往不知道输入是什么格式,所以需要一个智能解析函数:
function parseColor(input) { if (typeof input === 'string') { const trimmed = input.trim().toLowerCase(); // 十六进制 if (trimmed.startsWith('#')) { return hexToRgb(trimmed); } // rgb() 或 rgba() const rgbMatch = trimmed.match( /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/ ); if (rgbMatch) { return { r: parseInt(rgbMatch[1], 10), g: parseInt(rgbMatch[2], 10), b: parseInt(rgbMatch[3], 10), a: rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1, }; } // hsv() 或 hsva() const hsvMatch = trimmed.match( /hsva?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?\s*(?:,\s*([\d.]+)\s*)?\)/ ); if (hsvMatch) { const rgb = hsvToRgb( parseFloat(hsvMatch[1]), parseFloat(hsvMatch[2]), parseFloat(hsvMatch[3]) ); rgb.a = hsvMatch[4] !== undefined ? parseFloat(hsvMatch[4]) : 1; return rgb; } } throw new Error('无法识别的颜色格式: ' + input); }有了这个入口,后面做颜色混合、亮度调整、对比度计算时,就不用关心输入格式了,统一转成 RGB 再处理。
实操心得:正则里的
\s*不要省。用户手写的颜色值里经常有多余空格,比如rgb( 59 , 130 , 246 ),没有容错就会解析失败。另外%号在 HSV 里是可选的,有些工具输出hsv(217, 76, 96)不带百分号,正则里用%?兼容两种情况。
4. 实际项目中的典型应用与参数计算
4.1 主题色板的自动生成
这是颜色换算最高频的落地场景。给定一个品牌主色,需要生成从浅到深一整套色阶。做法是把主色转成 HSV,固定色相不变,只调整饱和度和明度。
function generatePalette(baseColor, steps = 9) { const rgb = parseColor(baseColor); const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); const palette = []; for (let i = 0; i < steps; i++) { // 从浅到深:明度从 95% 降到 20% const ratio = i / (steps - 1); const v = 95 - ratio * 75; // 饱和度在中间达到最高,两端略低,避免过灰或过艳 const s = hsv.s * (0.4 + 0.6 * Math.sin(ratio * Math.PI)); const stepRgb = hsvToRgb(hsv.h, s, v); palette.push(rgbToHex(stepRgb.r, stepRgb.g, stepRgb.b)); } return palette; }这里用了一个正弦函数来控制饱和度曲线,让中间色阶最鲜艳,两端稍微收敛。如果直接用线性变化,最浅的颜色会显得发灰,最深的颜色会显得发闷。这个曲线是我试过好几版之后觉得视觉上最舒服的。
参数选择上,明度从 95% 到 20% 覆盖了从接近白到接近黑的完整范围,步数 9 对应常见的 50 到 900 色阶命名。如果你只需要 5 个色阶,把 steps 改成 5 就行,曲线会自动重新分布。
4.2 颜色对比度计算与无障碍检测
WCAG 标准里定义了对比度公式,要求正文文字和背景的对比度至少达到 4.5:1。计算对比度需要先求相对亮度,而相对亮度必须基于 RGB 的线性化值。
function relativeLuminance(r, g, b) { // 先归一化 const channel = (value) => { const v = value / 255; // sRGB 线性化 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; const R = channel(r); const G = channel(g); const B = channel(b); return 0.2126 * R + 0.7152 * G + 0.0722 * B; } function contrastRatio(color1, color2) { const c1 = parseColor(color1); const c2 = parseColor(color2); const l1 = relativeLuminance(c1.r, c1.g, c1.b); const l2 = relativeLuminance(c2.r, c2.g, c2.b); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); }那几个系数 0.2126、0.7152、0.0722 不是随便定的,它们来自人眼对红绿蓝三种光的不同敏感度。人眼对绿色最敏感,所以绿色权重最高;对蓝色最不敏感,权重最低。Math.pow里的 2.4 和 0.055 是 sRGB 标准规定的伽马校正参数,目的是把屏幕存储的非线性值还原成线性光强。
注意:做对比度检测时,如果颜色带透明度,必须先把它和背景色做混合,得到实际显示的颜色再计算。直接拿带 alpha 的颜色算亮度是没有意义的。
4.3 颜色插值与渐变生成
在两个颜色之间做过渡,直接在 RGB 空间线性插值会有一个问题:中间会经过灰色区域,看起来发脏。比如从红到绿插值,RGB 空间的中点大约是rgb(128, 128, 0),是一个暗黄色,而不是鲜艳的橙色。
更好的做法是在 HSV 空间插值,但色相要沿着最短路径走:
function interpolateHsv(color1, color2, t) { const c1 = parseColor(color1); const c2 = parseColor(color2); const hsv1 = rgbToHsv(c1.r, c1.g, c1.b); const hsv2 = rgbToHsv(c2.r, c2.g, c2.b); let h1 = hsv1.h; let h2 = hsv2.h; // 让色相差不超过 180 度,走最短路径 let diff = h2 - h1; if (diff > 180) { h1 += 360; } else if (diff < -180) { h2 += 360; } const h = h1 + (h2 - h1) * t; const s = hsv1.s + (hsv2.s - hsv1.s) * t; const v = hsv1.v + (hsv2.v - hsv1.v) * t; const rgb = hsvToRgb(h, s, v); return rgbToHex(rgb.r, rgb.g, rgb.b); }色相最短路径的处理是关键。假设从 350 度插值到 10 度,直接线性插值会经过 180 度(青色),绕了大半圈;而实际上这两个颜色只差 20 度,应该直接跨过 0 度。代码里通过判断差值是否超过 180 度来决定是否给其中一个加 360,保证插值走的是短弧。
4.4 明暗调节与状态色生成
按钮的 hover 态通常比默认态暗一点,active 态再暗一点。用 HSV 做这件事非常自然:
function darken(color, amount) { const rgb = parseColor(color); const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); // amount 是 0-1 的暗化比例 const newV = Math.max(0, hsv.v * (1 - amount)); const result = hsvToRgb(hsv.h, hsv.s, newV); return rgbToHex(result.r, result.g, result.b); } function lighten(color, amount) { const rgb = parseColor(color); const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); // 提亮时同时降低一点饱和度,避免过曝 const newV = Math.min(100, hsv.v + (100 - hsv.v) * amount); const newS = hsv.s * (1 - amount * 0.3); const result = hsvToRgb(hsv.h, newS, newV); return rgbToHex(result.r, result.g, result.b); }提亮时降低饱和度的做法,是因为明度接近 100% 时,高饱和度会让颜色显得刺眼。稍微降一点饱和度,视觉上更柔和。这个 0.3 的系数是经验值,你可以根据品牌调性调整。
5. 常见问题排查与避坑速查
5.1 精度丢失与取整时机
颜色换算里最容易出问题的地方就是取整。RGB 通道是整数,但 HSV 转 RGB 的过程中会产生小数。如果在中间步骤就取整,误差会累积,来回转换几次后颜色就偏了。
正确的做法是:中间计算全部用浮点数,只在最终输出时取整。比如rgbToHsv里我先把 RGB 归一化到 0-1 再算,最后才把 H、S、V 取整。hsvToRgb里也是最后才Math.round。
但这里有个矛盾:如果rgbToHsv输出了取整后的 H、S、V,再转回 RGB 时就会有偏差。比如rgb(59, 130, 246)转成 HSV 是hsv(217, 76, 96),再转回 RGB 是rgb(59, 130, 245),蓝色通道差 1。这是取整导致的正常误差,肉眼完全看不出来,但如果你的业务要求严格可逆,就需要保留小数。
我的建议是:对外展示的 HSV 值取整,内部计算用的 HSV 值保留小数。可以写两个版本的函数,或者加一个precision参数控制。
5.2 色相边界值的处理
色相在 0 度和 360 度是同一个颜色,但数值上差了 360。做色相插值、色相排序、色相区间判断时,如果不处理这个环绕关系,就会出 bug。
比如判断一个颜色是不是红色系,你可能会写h >= 350 || h <= 10。但如果色相是 355 和 5 两个颜色,它们的平均色相按(355 + 5) / 2 = 180算是青色,完全错了。正确的做法是把其中一个加 360 再平均,得到(355 + 365) / 2 = 360,再取模 360 得到 0,才是正确的红色。
5.3 透明度混合的正确姿势
带 alpha 的颜色在计算对比度、亮度之前,必须先和背景混合。混合公式是:
结果 = 前景 * alpha + 背景 * (1 - alpha)function blendWithBackground(fg, bg) { const f = parseColor(fg); const b = parseColor(bg); const alpha = f.a !== undefined ? f.a : 1; return { r: Math.round(f.r * alpha + b.r * (1 - alpha)), g: Math.round(f.g * alpha + b.g * (1 - alpha)), b: Math.round(f.b * alpha + b.b * (1 - alpha)), a: 1, }; }很多人忘了这一步,直接拿半透明色去算对比度,结果当然不准。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 转换后颜色明显偏暗 | 取整时机过早,中间步骤丢了精度 | 检查是否在归一化前就取整 |
| 十六进制少一位 | 通道值小于 16 没补零 | 确认用了padStart(2, '0') |
| 色相插值经过奇怪颜色 | 没处理色相环绕 | 检查是否走了最短路径 |
| 对比度计算结果异常 | 没做透明度混合 | 先和背景混合再算亮度 |
| 三位简写解析错误 | 没处理简写展开 | 检查是否把每位重复了一次 |
| HSV 转 RGB 出现负数 | 输入 S 或 V 超出 0-100 | 加 clamp 限制范围 |
| 灰色算出色相 | delta 为 0 时没特殊处理 | 色相设为 0 即可 |
5.5 几个我踩过的坑
坑一:parseInt不写进制。早期代码里写parseInt(value.slice(0, 2)),遇到08、09这种值在某些环境下会被当八进制,结果完全错误。虽然现代浏览器已经修正了这个行为,但显式写parseInt(x, 16)是好习惯。
坑二:HSV 的 S 和 V 用 0-1 还是 0-100。不同库的约定不一样。CSS 的hsl()里 S 和 L 用百分比,但很多取色器输出 0-1 的小数。我的做法是函数内部统一用 0-1,对外接口用 0-100,在入口和出口做转换。这样内部计算不会因为百分比而多乘多除。
坑三:色相为 360 度。有些工具会输出hsv(360, 100%, 100%),虽然等价于 0 度,但如果你的代码里用h === 0判断红色,就会漏掉这种情况。入口处统一用((h % 360) + 360) % 360归一化,能省掉很多边界判断。
坑四:八位十六进制的 alpha 顺序。#RRGGBBAA和#AARRGGBB是两种不同的约定,前者是 CSS 标准,后者是某些移动端框架的用法。解析时一定要确认清楚,否则透明度会跑到红色通道上去。
6. 性能优化与工程化建议
6.1 缓存高频转换结果
颜色换算本身计算量很小,单次调用在微秒级别。但在数据可视化场景里,如果每帧要对几千个数据点做颜色映射,累积起来就很可观了。
一个实用的优化是加一层 Map 缓存:
const hsvCache = new Map(); function cachedHsvToRgb(h, s, v) { const key = `${h},${s},${v}`; if (hsvCache.has(key)) { return hsvCache.get(key); } const result = hsvToRgb(h, s, v); // 限制缓存大小,防止内存无限增长 if (hsvCache.size > 10000) { hsvCache.clear(); } hsvCache.set(key, result); return result; }取整后的 HSV 组合数量是有限的(360 × 101 × 101 约 367 万种),但实际用到的远小于这个数。缓存命中率在色带映射场景里通常很高。
6.2 批量转换的预处理
如果要对一个固定色板反复做转换,更好的做法是在初始化时一次性把所有颜色转成统一的内部表示,后续直接查表。比如把整个色板预计算成 RGB 数组,渲染时直接取用,避免在渲染循环里做转换。
6.3 类型安全与参数校验
生产环境里,颜色值可能来自用户输入、接口返回、配置文件,格式五花八门。入口函数一定要做严格校验,对非法输入给出明确错误,而不是静默返回一个黑色或透明色。静默失败会让排查变得非常困难。
function assertValidRgb(r, g, b) { const isValid = (n) => Number.isFinite(n) && n >= 0 && n <= 255; if (!isValid(r) || !isValid(g) || !isValid(b)) { throw new RangeError(`RGB 值超出范围: ${r}, ${g}, ${b}`); } }用Number.isFinite而不是isNaN,因为isNaN对字符串"abc"也会返回 true,而Number.isFinite只对真正的数字返回 true,更严格。
6.4 单元测试该覆盖哪些边界
颜色换算函数的测试用例,除了常规值,一定要覆盖这些边界:
- 纯黑
#000000、纯白#FFFFFF - 三原色
#FF0000、#00FF00、#0000FF - 灰色系列,验证色相为 0、饱和度为 0
- 三位简写
#F00和六位#FF0000结果一致 - 带透明度的八位值
- 色相 0 度和 360 度结果一致
- 来回转换的误差在可接受范围内
我一般会写一个往返测试:随机生成 1000 个 RGB 值,转成 HSV 再转回来,断言每个通道的误差不超过 1。这个测试能抓出绝大多数精度问题。
颜色换算这件事,代码量不大,但细节密度很高。把上面这些函数和注意事项吃透,基本能覆盖日常开发里九成以上的颜色处理需求。剩下的那些极端情况,等你真遇到了再回来查速查表就行。