做设计工具开发的人电脑里基本都攒着一堆颜色相关的小脚本取色器、对比度检查、渐变生成、色板导出……我最早也是这个状态函数东一个西一个散落在四五个项目里。直到后来越用越别扭同一套配色逻辑在 Chrome 和 Safari 里渲染结果不一样调出来的色板打印到真实 UI 上又总觉得“差点意思”。于是去年我把这些散装逻辑彻底收拢重写成了一个独立的颜色引擎就是现在说的 Chromatix7。一句话说清楚Chromatix7 是一只面向 Web 端设计工具场景的颜色计算与调色板生成库核心覆盖色彩空间转换、感知均匀调色、对比度可访问性分析和色域裁剪适合做 UI 设计软件、CSS 工具链、数据可视化选色这一类场景的开发者拿去二次开发或直接内嵌。对前端工程师和独立开发者来说这项目最有价值的不是某个 API 写得有多花哨而是它把最容易出错的色彩转换、无障碍判断和自动调色连成了一条能验证、可可视化的完整链路。下面我把整个项目的定位、算法思路、实操过程和踩坑记录一次讲透。1. 项目定位第七次重写为什么叫 Chromatix71.1 它到底解决的是什么问题很多人觉得颜色工具不就是“给个十六进制再给你几个相近色”吗实际做起来完全不是这么回事。真实设计工具里一个很小的取色器背后要同时回答这些问题用户从 sRGB 色值编辑、切换到 Display P3 后数值怎么重算用户导出的 CSS 色值在低端显示器上有没有偏色两个颜色放在一起对比度够不够给深色模式下生成的辅助色会不会发灰发脏这些需求单拆开看都不难难点在它们要共用一套统一的数据模型。我做之前的版本时会遇到一个典型矛盾调色板模块自己维护 HSL 色轮对比度模块独立计算亮度导出模块又单独做了一次 sRGB 转十六进制。每个模块单独测试都没问题一旦组合使用同一个颜色在不同模块里的中间值就开始互相打架。Chromatix7 的最核心诉求就是终结这种混乱所有模块共用同一条色彩转换管线输入任意色值内部统一转成感知均匀的色彩空间后再计算最后只在输出边界转换回 CSS 色值。这样的好处是调色板、对比度、色域裁剪、可视化看到的都是同一个颜色在不同维度下的投影而不是彼此咬合不上的几套副本。1.2 从第一版到第七版我改了什么“Chromatix7”这个 7 不是营销噱头而是实打实第七次重写。前几版的历史基本就是一部“从能用走向用得住”的过程v1 是只改 HSL 色相的简单调色脚本生成出来的色板经常出现两个颜色肉眼看几乎一样的情况v2 补了 sRGB 与线性 RGB 的互转开始能做亮度感知的排序但整体还是“哄眼睛”阶段v3 把计算逻辑塞进了浏览器主线程一生成 40 个颜色的渐变色板页面就开始掉帧这才第一次意识到性能是功能的一部分v4 引入了 Lab 空间终于有了“感知均匀”的底子可 Lab 在低亮度区间的表现不太理想调深色时会出现意外的色相偏移v5 用比较成熟的 CIEDE2000 替换了简单距离公式色差判断终于不那么闹心了v6 加入了色域检测与裁剪算是把“溢出颜色怎么降级”这个烂摊子正式收拾起来v7 就是现在这版换上了 OKLab 作为主计算空间把重计算迁移到 Web Worker同时把整套 API 收敛成便于二次开发的形式。如果你正处于“我要不要重写一个轮子”的犹豫期我的一点体会是真正值得动手重写的信号不是旧代码写得丑而是数据在模块之间来回转换太多次、你已经开始不确定某一层的输出到底算哪种色彩空间了。那才是重写架构的合理理由而不是单纯追求代码整洁。1.3 为什么选 Web 技术而不是原生桌面工具在设计阶段我也纠结过要不要走原生路线比如 Rust 核心加 WebView 壳或者打包成 Electron。后来想了想Chromatix7 的目标用户是前端、独立开发者和设计工具团队这些人最顺手的消费方式就是 npm 安装、浏览器里直接预览。颜色计算本身不是重负载瓶颈其实在色彩管理数据和渲染可视化上TypeScript 完全扛得住。真到需要更暴力计算的时候浏览器也给了明确出口Web Worker 做并行计算Canvas 做逐像素可视化后续如果某个色彩映射算法成了瓶颈还可以把那段代码用 WASM 单独编译。这样既不牺牲跨端能力也为性能预留了升级通道。用一句话概括我当时的判断颜色引擎的复杂度不在“算得快”而在“转得准”和“算得对”这类问题用 Web 栈比用原生栈开发调试效率高得多。2. 核心技术与算法拆解2.1 你先要理解“感知均匀”这件事做颜色工具绕不开一个核心概念感知均匀。简单说就是两个颜色之间数值距离的远近应当与人眼感受到的差别大小基本一致。为什么这一点如此重要因为如果你在一个不均匀的空间里做插值、调色板生成、色差判断就会出现“明明数值差 30看起来却一模一样数值差 5看起来却像完全不同的两种颜色”的怪事。用生活里的例子类比一下人眼对暗部细节的敏感度远高于亮部对绿色区间的区分度也明显高于蓝色区间。所以在传统的 RGB 立方体里随手拉一条渐变线中间点往往发灰或发青因为这条直线并不会经过我们感知上“等间距”的颜色路径。传统 HSL 也有类似问题它的 S 和 L 并不对应感知属性所谓“更饱和一点”有时候只是把数值拉大观感却未必更鲜亮。感知均匀的空间就是要重新组织坐标让“横向移动一个单位”这种操作在任意位置都对应近似一致的主观感受。舒适这一点最经典的是 CIE Lab 和后来又出现的 OKLab。我在 Chromatix7 里把 OKLab 作为主计算空间原因很实际它比 Lab 的色相保持更稳定尤其在蓝色系和暗色系区间不会一调亮度色相就跟着乱跑。色调、饱和度、亮度观念都是从这类空间中提取出来的——色调是角度、饱和度是坐标到原点距离的导数、亮度是垂直轴的位置。后面所有调色逻辑、色差计算全都依附在这套坐标解释之上。2.2 色彩空间转换从 sRGB 到 OKLab 的路径Chromatix7 的输入五花八门十六进制、CSS 的rgb()、oklch()、图片像素数据里的线性值。为了统一管理我在库内部规定了一条标准管线先把任意输入换算成标准 sRGB 的 0 到 1 浮点值对 sRGB 分量做伽马展开得到线性 RGB用 D65 白点对应的矩阵把线性 RGB 转到 XYZ再由 XYZ 转入 OKLab 或 Lab后续所有感知类计算都在这个空间内完成。第 2 步是很多人栽跟头的地方。sRGB 的存储值并不是线性亮度它是经过伽马编码的。如果你拿着rgb(128, 128, 128)的正中间值去做亮度混合会得到约 21.6% 的相对亮度而不是你以为的 50%。这个误差在对比度计算、透明混合、渐变插值里都会被放大。Chromatix7 里专门保留了一个linearize入口就是为了让所有依赖亮度方程的模块都走同一套伽马展开逻辑。这里贴一段我用 TypeScript 写的核心转换函数细节做了简化但结构沿用自项目里的真实实现// sRGB 编码值(0~1) - 线性值 export function srgbToLinear(c: number): number { const c1 c / 1.0; if (c1 0.04045) return c1 / 12.92; return Math.pow((c1 0.055) / 1.055, 2.4); } // 线性值 - sRGB 编码值 export function linearToSrgb(c: number): number { if (c 0.0031308) return 12.92 * c; return 1.055 * Math.pow(c, 1 / 2.4) - 0.055; } // 线性 sRGB - XYZ(D65)矩阵是标准常量 export function srgbLinearToXyz(r: number, g: number, b: number): [number, number, number] { return [ 0.4124564 * r 0.3575761 * g 0.1804375 * b, 0.2126729 * r 0.7151522 * g 0.0721750 * b, 0.0193339 * r 0.1191920 * g 0.9503041 * b, ]; }XYZ 是一个绝对的颜色坐标体系相当于设备无关的颜色锚点。但 XYZ 本身在感知均匀性上表现一般所以继续向前转入 OKLab。完整的 OKLab 公式会用到一组带三次方根的非线性压缩完整函数比较长我封装成xyzToOklab(x, y, z)直接用。工程里关键点是矩阵常量必须保持统一白点必须一直是 D65不能前一次转 RGB 用 D65、后一次转 XYZ 又用 D50一旦混用整个色板的色温都会漂移。2.3 调色板生成与和谐配色不是转色轮那么简单很多人做自动调色直接让人家的色轮转 30 度、60 度、120 度拿三色四色。这在简单场景能应付但也只是“能看”。Chromatix7 的调色板生成器在内部其实是这么工作的先把基准颜色丢到 OKLab 空间提取它的色相角度和亮度然后按你选用的配色规则——互补、近似色、三角、四分散——与参考色相角生成一组候选色相。然后不是简单把基准色复制过去而是对每个候选色相做一次亮度约束在保持一致或呈现明确渐变的前提下根据实际亮度对色相做轻微补偿。举一个具体例子。用户选了一个偏蓝的深色作为基准希望生成包含“相似蓝”的旁近色。如果在 HSL 里做可能会得到好几条饱和度完全一样的蓝色放到界面上没有任何层次感。Chromatix7 会先计算基准色在 OKLab 中的感知亮度和色度候选色相生成后再逐个调整亮度确保整个色板看起来像“同一个色系里的自然过渡”而不是“同一片颜色的复制粘贴”。这就是感知均匀空间带来的直接好处。调色板最终输出也很有讲究。我提供了一个harmonizeWithContrast(target, base, minRatio)方法专门处理这种问题某个候选色和基准色对比度不达标时会先在亮度轴方向搜索最接近的解再检查色相偏转量会不会引起可见的颜色畸变。这比单纯调高文字亮度或加粗字号要好得多因为最终视觉效果是“整组颜色和谐且可读”。2.4 对比度计算与无障碍检查的细节对比度在 Chromatin7 里不是独立功能而是贯穿在调色板建议和 UI 控件配色检查里的基础能力。标准 WCAG 对比度公式是(L1 0.05) / (L2 0.05)其中 L1 和 L2 是两种颜色的相对亮度。注意这个 L 是线性亮度经过特定加权后的结果权重分别是 0.2126、0.7152、0.0722对应标准观察者的红绿蓝敏感度。所以千万别用Math.abs(brightness1 - brightness2)这类简单做法颜色不同通道对亮度的贡献天差地别直接用十六进制做算术是比不出来对比度的。实际项目中团队经常只测文字和背景这对组合但 UI 里的图标、边框、输入框 placeholder 其实也有可访问性要求。我给 Chromatin7 的定义是用途推荐最小对比度说明正文文字4.5 : 1WCAG AA 级别大号文字≥18pt 或 14pt 加粗3 : 1字号增大后阈值可放宽UI 组件边界 / 图标3 : 1即使不是文字也建议满足纯装饰元素无硬性要求但要避免影响相邻内容这套分级不只服务无障碍场景也帮生成器定边界。比如我需要建议某个按钮文字颜色就用目标对比度反解一个最小亮度差再去候选色里选最接近的那个。这里要提醒一句WCAG 的对比度公式并不是完美的感知模型两个同为 4.5:1 的组合在深色背景和浅背景上的主观可读性会有差别所以我在库里预留了 APCA 风格的备选评估入口供进阶场景使用。3. 实操记录关键模块是怎么一步步实现的3.1 工程骨架与类型设计Chromatix7 是一个纯 TypeScript 库零运行时依赖。目录结构大概是这样src/ core/ // 色彩转换基础包括 srgb、xyz、lab、oklab harmony/ // 调色板生成与和谐配色 contrast/ // 对比度计算与无障碍检查 gamut/ // 色域检测与裁剪 worker/ // Web Worker 入口与协议定义 visualization/ // Canvas 渲染辅助类型设计上我思前想后最终没有用一个大类Color把所有东西都包进去而是主张数据不可变、分段组合。内部到处传递的是一个轻量结构export interface OklabColor { mode: oklab; l: number; // 0 ~ 1感知亮度 a: number; // 绿-红轴 b: number; // 蓝-黄轴 }每个函数都是纯函数进来一个颜色返回一个新颜色。这样单元测试异常好写也方便做快照测试。工具 API 按“我要算什么”来组织而不是按“颜色存在哪儿”来组织理解成本低很多。3.2 核心转色函数实现附带代码转色是基础写不稳后面全是雷。我用 OKLab 作为主空间完整转换代码确实很长这里展示一个简化但可行版本的核心路径方便你看思路// 线性 sRGB - OKLab这个是社区常见实现的精简版 export function srgbLinearToOklab(r: number, g: number, b: number): OklabColor { // 线性 RGB 转 LMS 锥响应 let l 0.4122214708 * r 0.5363325363 * g 0.0514459929 * b; let m 0.2119034982 * r 0.6806995451 * g 0.1073969566 * b; let s 0.0883024619 * r 0.2817188376 * g 0.6299787005 * b; // 非线性压缩类似对数但更稳定 l Math.cbrt(l); m Math.cbrt(m); s Math.cbrt(s); return { mode: oklab, l: 0.2104542553 * l 0.7936177850 * m - 0.0040720468 * s, a: 1.9779984951 * l - 2.4285922050 * m 0.4505937099 * s, b: 0.0259040371 * l 0.7827717662 * m - 0.8086757660 * s, }; }实际工程里完整版还要处理 sRGB 编码到线性的前序步骤以及 OKLab 反变换回 sRGB 的完整通路。但通过这些代码你应该能感受到所有的计算最后就是几组线性变换加一个非线性核。这个模式贯穿整个库所以封装矩阵常量和转换工具类就非常重要。我在core/matrix.ts里维护全部标准矩阵测试里预先埋好参考值比如纯红色在 OKLab 下大约等于(l0.627, a0.225, b0.126)这个量级。每次改动后跑一遍快照能防止转色函数在重构中被悄悄改坏。3.3 把计算丢给 Web Worker批量处理调色板v3 的教训让我意识到调色板生成这活儿虽然单个不算重可一旦要做“从全色域网格里搜索满足对比度的候选色”计算量就是指数级增长。一个 360 色相 × 20 亮度 × 20 色度的网格差不多 14 万个颜色每个颜色还要转 OKLab、算对比度、判断是否可读主线程跑一次肯定会掉帧。Chromatix7 的解决方案是标准 Web Worker。主线程把待处理色值数组一次性派发出去Worker 算完把结果传回再配合 Transferable Objects 转移 ArrayBuffer避免结构化克隆的拷贝开销。大致是这样// 主线程侧 const worker new Worker(new URL(./color.worker.ts, import.meta.url), { type: module, }); worker.postMessage({ type: build-palette, base: [0.75, 0.1, 0.2], count: 8, rule: analogous, }); worker.onmessage (ev) { const { palette } ev.data; renderPalette(palette); };Worker 里是一个状态机专门接收这类独立任务。这样做的另一个好处是以后可以不加额外成本地扩展成 worker 池多核并行跑不同配色规则。我实测过单线程生成 200 个颜色的和谐色板大约需要 120ms同样任务丢进 Worker 后从用户点击到看到结果差不多只要 8ms渲染时间甚至可以忽略不计。对交互式取色器来说这个差异就是“能用”和“好用”的差别。3.4 在浏览器里做色彩模拟与可视化库不该只在控制台里输出数值设计工具用户需要直观看到颜色。Chromatin7 在可视化层面做了两件事色觉模拟和色域截面预览。色觉模拟的做法是给 RGB 各通道套一组模拟矩阵分别模拟第一色弱、第二色弱和第三色弱三类常见色觉异常。它不能百分之百还原某个人的视觉但足够让设计者在出图前判断“文字层会不会消失”“两张按钮的区分度是不是只靠颜色本身”。这个功能用一句话总结就是颜色信息丢了哪条通道界面信息还够不够用。色域截面预览则是在 Canvas 上画一个色度图再叠加显示某个样式色值换算后在设备上能显示的范围。实现上我用逐像素写 ImageData先把 Canvas 坐标映射回 OKLab 坐标判断是否在目标色域内在的像素填充颜色不在的填灰色。这个可视化过程也要传给 Worker 计算不然拖动滑块时会有明显延迟。最终用户看到的结果是某个颜色在 sRGB 下很鲜艳但在更窄的色域里直接变成死灰色。这个功能对做户外屏幕、低成本硬件界面的人来说非常救命。4. 常见问题与排查技巧实录4.1 大坑清单排查合集我在开发过程中记录了不少真实问题整理成了一张表贴在这里当速查手册症状根因排查与解法同一色值在 Safari 和 Chrome 里显示不同浏览器对 CSS Color 4 和 P3 色域的支持不一致明确输出色域导出前统一转成 sRGB 或显式声明color(display-p3 ...)对比度计算总感觉偏高直接用十六进制或 RGB 的亮度平均值算没有做伽马展开所有对比度计算前调用srgbToLinear()线性亮度算完再回编码值生成的旁近色发灰在 RGB 空间直接插值中间点经过伽马编码后偏灰改在 linear RGB 或 OKLab 里插值蓝色渐变出现明显色带蓝色通道感知区分度低8bit 色深不够输出 P3 色深或采用误差扩散抖动色板预览里至少提供高位深渲染色相旋转后深色区出现偏紫HSL 空间暗部色调不稳定全部色相逻辑迁移到 OKLab用感知色相角计算批量生成时主线程卡顿大量网格颜色计算阻塞渲染计算迁移到 Web Worker用 transferable 对象减少拷贝排查这类颜色问题我的第一个建议永远是先把所有中间值打印出来看它在每个色彩空间里的实际数值长什么样。很多问题其实在第一次转换时就已经错了只是被后面的矩阵运算和伽马编码掩盖了。4.2 色域裁剪是躲不掉的一课颜色工程里最诡诈的一件事就是你算出来的颜色“物理上存在”但屏幕显示不出来。比如你在 OKLab 空间调整了一个蓝色的饱和度把它变得特别鲜艳但这个颜色已经超出了 sRGB 的三角色域。此时如果你不管三七二十一直接把通道值映射到 0 到 1得到的往往是严重失真的颜色。Chromatinx7 里专门有一套裁剪策略。我在超色域时不会简单把 XYZ 或 OKLab 分量硬夹到边界而是优先在“色度”维度收缩把彩度降下来直到落回色域内。因为从观感上讲降低饱和度比改变亮度或严重偏色更接近“颜色变淡了”的主观体验。如果目标色域特别窄、收缩后依然溢出就再做一次亮度微调。整套策略我封装成gamutClip(color, space, strategy)第三个参数可以选chroma-reduction或luma-preserving分别对应不同场景。这个坑是 v6 才正式补上的之前直接夹值的版本做出来的深蓝总是发黑、亮橙总是发红看上去总有层奇怪的“脏色”。还有一个容易被忽视的点不要滥用 Display P3。在 P3 显示器上做设计、导出 sRGB 时很多颜色在转换后已经溢出肉眼看到的是一个毫无层次的亮块。Chromatinx7 里默认把“调色建议”都限制在 sRGB 内只有用户显式要求扩展色域时才输出 P3 色值。因为这个原因自动生成的色板总是更安全、更耐看。4.3 给想自己造颜色工具轮子的人几点建议如果你也有类似想法想自己造一套颜色工具库我根据这半年的实操给你几条掏心窝的建议。第一别急着动手写算法先写测试用例。色彩转换领域有大量现成参考数据比如各种标准色卡在不同色彩空间下的标准值拿这些基准值当快照每次接口改动跑一遍。否则等你手写了几百行矩阵以后某处符号错了根本定位不到。第二尽量在一个感知均匀的空间里完成所有可视化和计算不要碰到一个需求就随缘切换。Chromatix7 之所以最终选定 OKLab就是因为它把调色、对比度参考、色域判断都统一到了同一套坐标系里维护成本直线下降。第三一定要做一个能看的预览 Demo。颜色功能如果不可视化你根本不知道输出到底有没有“get 到”用户需要的那种感觉。我在项目里写了一个简单的 HTML 演示页每次调完算法就去拖动几个真实色板模拟深色模式、浅色模式、色觉异常三种视角这个用户视角的检查比任何 linter 都有用。说实话这个项目真正帮到我的不是提供了多精确的 32 位浮点色彩计算而是让我把“颜色到底是怎么回事”这件事想透了。以前我调色板全凭感觉现在我能明确说出每个决策背后用的是哪个空间、哪条公式、哪条亮度和色度的折算路径。最后再分享一个小习惯色彩工具一定要保留“导出中文字面值”的能力比如把 OKLab 数值直接翻译成oklch(65% 0.15 250)这样的 CSS 写法很多调试问题瞬间就能看出是不是亮度轴或色相角出了问题。这个习惯给了我非常多调试上的正反馈也希望你能在自己的工具里试试看。
