教程

JavaScript 颜色操作:库与技术

阅读约 7 分钟

在 Web 应用中,颜色并非一个从设计文件粘贴过来就一成不变的静态值。颜色在悬停时变亮、按下时变暗、根据无障碍对比度要求进行调整、在状态之间动画过渡,并适应用户选择的主题。所有这些都需要以编程方式操作颜色——即在运行时解析、转换和生成颜色值的能力。

本文涵盖 JavaScript 原生处理颜色的方式、常见变换背后的数学原理、三大主流库的对比,以及如何在零依赖的情况下构建一个最小化的颜色工具库。


在 JavaScript 中解析 Hex、RGB 和 HSL

JavaScript 没有内置的颜色类型。颜色以字符串形式传入——"#FF5733""rgb(255, 87, 51)""hsl(11, 100%, 60%)"——在进行任何数学运算之前,你必须自行解析它们。

解析十六进制色码

十六进制颜色是三个(或四个,含 alpha 通道)单字节整数以 16 进制编码的紧凑表示。解析它只需切分字符串并调用 parseInt

function parseHex(hex) {
  // 规范化:去除 # 并展开简写(#F53 → #FF5533)
  const clean = hex.replace('#', '');
  const full = clean.length === 3
    ? clean.split('').map(c => c + c).join('')
    : clean;

  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
  };
}

parseHex('#FF5733'); // { r: 255, g: 87, b: 51 }
parseHex('#F53');    // { r: 255, g: 85, b: 51 }

反向操作——将整数转回十六进制字符串——使用带零填充的 toString(16)

function toHex({ r, g, b }) {
  return '#' + [r, g, b]
    .map(v => Math.round(v).toString(16).padStart(2, '0'))
    .join('')
    .toUpperCase();
}

toHex({ r: 255, g: 87, b: 51 }); // '#FF5733'

解析 RGB 字符串

来自 DOM 或 CSS-in-JS 的 RGB 字符串通常格式为 "rgb(255, 87, 51)"。使用正则表达式提取三个值:

function parseRgb(str) {
  const match = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!match) throw new Error(`Invalid RGB: ${str}`);
  return {
    r: parseInt(match[1]),
    g: parseInt(match[2]),
    b: parseInt(match[3]),
  };
}

parseRgb('rgb(255, 87, 51)');      // { r: 255, g: 87, b: 51 }
parseRgb('rgba(255, 87, 51, 0.5)'); // { r: 255, g: 87, b: 51 }

解析 HSL 字符串

HSL 字符串——"hsl(11, 100%, 60%)"——需要提取度数和百分比值:

function parseHsl(str) {
  const match = str.match(/hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%/);
  if (!match) throw new Error(`Invalid HSL: ${str}`);
  return {
    h: parseFloat(match[1]),
    s: parseFloat(match[2]),
    l: parseFloat(match[3]),
  };
}

RGB 与 HSL 之间的转换

大多数颜色数学运算在 RGB(用于混色)或 HSL/HSV(用于直觉性调整)中进行。在两者之间转换是你需要掌握的第一项技能:

function rgbToHsl({ r, g, b }) {
  const rn = r / 255, gn = g / 255, bn = b / 255;
  const max = Math.max(rn, gn, bn);
  const min = Math.min(rn, gn, bn);
  const l = (max + min) / 2;
  const d = max - min;

  if (d === 0) return { h: 0, s: 0, l: Math.round(l * 100) };

  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  let h;
  switch (max) {
    case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
    case gn: h = ((bn - rn) / d + 2) / 6; break;
    default: h = ((rn - gn) / d + 4) / 6;
  }

  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  };
}

rgbToHsl({ r: 255, g: 87, b: 51 }); // { h: 11, s: 100, l: 60 }

颜色数学:变亮、变暗、降饱和

将颜色转为 HSL 后,调整它就是简单的算术运算。HSL 的三个轴直接对应设计师最常要求的调整方式。

变亮与变暗

增大或减小 l(明度)通道是最直接的方式:

function lighten(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.l = Math.min(100, hsl.l + amount);
  return hslToHex(hsl);
}

function darken(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.l = Math.max(0, hsl.l - amount);
  return hslToHex(hsl);
}

lighten('#FF5733', 15); // 更亮的珊瑚红
darken('#FF5733', 15);  // 更暗的橙红

这种方法适用于小幅调整。若要生成完整的 50—950 阶梯(如 Tailwind CSS 所做的那样),数学计算会更为复杂,因为 HSL 中感知明度并非线性——色调生成器通过感知加权分布处理这一问题。

降饱和

s(饱和度)通道向 0 递减,可将任意颜色变为灰色:

function desaturate(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.s = Math.max(0, hsl.s - amount);
  return hslToHex(hsl);
}

function grayscale(hex) {
  return desaturate(hex, 100);
}

desaturate('#FF5733', 50); // 低饱和度的橙色
grayscale('#FF5733');      // 与原色明度相同的纯灰色

混合两种颜色

在 RGB 空间中进行线性插值是最简单的混合方式:

function mix(hex1, hex2, weight = 0.5) {
  const c1 = parseHex(hex1);
  const c2 = parseHex(hex2);
  return toHex({
    r: Math.round(c1.r * weight + c2.r * (1 - weight)),
    g: Math.round(c1.g * weight + c2.g * (1 - weight)),
    b: Math.round(c1.b * weight + c2.b * (1 - weight)),
  });
}

mix('#FF5733', '#FFFFFF', 0.7); // 70% 珊瑚红,30% 白色 → 色调
mix('#FF5733', '#000000', 0.7); // 70% 珊瑚红,30% 黑色 → 阴影

在互补色之间进行 RGB 混合会产生感知上偏平的结果。为获得更好的中间色,chroma.js 等库提供了在 OKLCH 或 Lab 空间中进行混合的功能。

对比度(WCAG)

WCAG 对比度是根据相对亮度计算的,而非原始通道值。亮度计算需要进行伽马校正——即 sRGB 值存储时所应用的编码的逆运算:

function relativeLuminance({ r, g, b }) {
  const linearize = (v) => {
    const sRGB = v / 255;
    return sRGB <= 0.04045
      ? sRGB / 12.92
      : Math.pow((sRGB + 0.055) / 1.055, 2.4);
  };
  const R = linearize(r), G = linearize(g), B = linearize(b);
  return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}

function contrastRatio(hex1, hex2) {
  const L1 = relativeLuminance(parseHex(hex1));
  const L2 = relativeLuminance(parseHex(hex2));
  const lighter = Math.max(L1, L2);
  const darker = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

contrastRatio('#FF5733', '#FFFFFF'); // ~3.0 — 普通文本未通过 WCAG AA
contrastRatio('#000000', '#FFFFFF'); // 21.0 — 最高对比度

WCAG AA 要求普通文本 4.5:1,大号文本 3:1。使用色彩转换器检查任意颜色对。


主流库对比:chroma.js、culori、tinycolor2

对于简单调整之外的任何需求,使用专用库能节省大量时间,并避免颜色数学中的边界情况 bug。

chroma.js

体积: 约 13KB(gzip 压缩)| 成熟度: 2013 年,持续维护中 | 许可证: BSD

chroma.js 是最广为人知的 JavaScript 颜色库。它的 API 流畅且支持链式调用:

import chroma from 'chroma-js';

// 解析任意格式
const color = chroma('#FF5733');

// 调整
color.darken(1).hex();      // '#D93B10'
color.lighten(1).hex();     // '#FF8066'
color.saturate(0.5).hex();  // '#FF4719'
color.desaturate(0.5).hex();// '#F2653F'

// 转换
color.rgb();  // [255, 87, 51]
color.hsl();  // [11, 1, 0.6]
color.lab();  // [52.8, 47.1, 44.7]
color.oklch();// [0.63, 0.19, 27.5]

// 在不同色彩空间中混合
chroma.mix('#FF5733', '#3B82F6', 0.5, 'rgb');   // 在 RGB 中混合(中间色偏平)
chroma.mix('#FF5733', '#3B82F6', 0.5, 'oklch'); // 在 OKLCH 中混合(鲜艳中间色)

// 生成色阶
const scale = chroma.scale(['#FFF5F5', '#FF5733', '#7F0000'])
  .mode('oklch')
  .colors(9);

// 对比度
chroma.contrast('#FF5733', '#FFFFFF'); // 3.0

当你需要一个文档完善、设置简单、颜色空间混合支持良好(包括 OKLCH)的综合性库时,chroma.js 是合适的选择。

culori

体积: 约 6KB(gzip 压缩,支持 tree-shaking)| 成熟度: 2019 年,持续维护中 | 许可证: MIT

culori 是一个为 tree-shaking 设计的现代函数式库。每个操作都是独立函数——你只导入你需要的部分:

import { parse, formatHex, oklch, interpolate, formatCss } from 'culori';

// 解析
const color = parse('#FF5733'); // { mode: 'rgb', r: 1, g: 0.34, b: 0.2 }

// 转换为 OKLCH
const inOklch = oklch(color); // { mode: 'oklch', l: 0.63, c: 0.19, h: 27.5 }

// 调整明度
const lighter = { ...inOklch, l: inOklch.l + 0.1 };
formatHex(lighter); // '#FF8469'

// 用于渐变/动画的插值
const gradient = interpolate(['#FF5733', '#3B82F6'], 'oklch');
formatCss(gradient(0.5)); // CSS 格式的中间色

// 生成色阶
import { samples } from 'culori';
const stops = samples(5).map(t => formatHex(gradient(t)));
// ['#FF5733', '#E85E56', '#9B6DE3', '#6186F0', '#3B82F6']

culori 基于带有 mode 属性的普通对象运作,这使得序列化颜色、将其存储在状态中或通过网络传输都非常简便。对于关注包体大小且启用了 tree-shaking 的现代 TypeScript 项目,它是最佳选择。

tinycolor2

体积: 约 5KB(gzip 压缩)| 成熟度: 2012 年,稳定(更新较少)| 许可证: MIT

tinycolor2 是体积最小、兼容性最强的解析器——它几乎能接受任何颜色字符串格式,包括 CSS 命名颜色,且无需额外配置:

import tinycolor from 'tinycolor2';

// 解析几乎任何格式
tinycolor('red').toHexString();         // '#FF0000'
tinycolor('hsl(11, 100%, 60%)').toHexString(); // '#FF5733'
tinycolor('#F53').toHexString();        // '#FF5533'

// 调整
tinycolor('#FF5733').lighten(15).toHexString();    // '#FF8966'
tinycolor('#FF5733').darken(15).toHexString();     // '#C92B04'
tinycolor('#FF5733').desaturate(30).toHexString(); // '#EB6643'
tinycolor('#FF5733').spin(180).toHexString();      // '#33AEff'(互补色)

// 可读性/对比度
tinycolor.readability('#FF5733', '#FFFFFF'); // 3.0
tinycolor.isReadable('#FF5733', '#FFFFFF');  // false(未通过 WCAG AA)
tinycolor.isReadable('#FF5733', '#FFFFFF', { level: 'AA', size: 'large' }); // true

// 配色方案
tinycolor('#FF5733').triad();         // [TinyColor, TinyColor, TinyColor]
tinycolor('#FF5733').analogous();     // 6 种类似色
tinycolor('#FF5733').complement();    // 单个互补色

当项目需要可靠地解析用户输入的颜色字符串(可能格式各异)并进行基本操作,而不想引入更大依赖时,tinycolor2 是合适的选择。

库对比汇总

特性 chroma.js culori tinycolor2
包体大小 ~13KB ~6KB(支持 tree-shaking) ~5KB
色彩空间 RGB、HSL、Lab、LCH、OKLCH 20+ 个,包括 OKLCH、P3 RGB、HSL、HSV
API 风格 流畅/链式 函数式 面向对象
TypeScript 社区类型 内置 社区类型
颜色混合 RGB、HSL、Lab、OKLCH 任意色彩空间 仅 RGB
最适用于 综合使用、数据可视化 现代 TS 包 解析、简单操作

从零构建颜色工具库

对于仅需要十六进制解析、变亮/变暗和对比度检查的生产应用,零依赖工具库通常是更好的架构选择。以下是完整实现:

// color-utils.js

export function parseHex(hex) {
  const clean = hex.replace('#', '');
  const full = clean.length === 3
    ? clean.split('').map(c => c + c).join('')
    : clean;
  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
  };
}

export function toHex({ r, g, b }) {
  return '#' + [r, g, b]
    .map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'))
    .join('').toUpperCase();
}

function rgbToHsl({ r, g, b }) {
  const rn = r / 255, gn = g / 255, bn = b / 255;
  const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
  const l = (max + min) / 2;
  const d = max - min;
  if (d === 0) return { h: 0, s: 0, l };
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  let h;
  switch (max) {
    case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
    case gn: h = ((bn - rn) / d + 2) / 6; break;
    default: h = ((rn - gn) / d + 4) / 6;
  }
  return { h, s, l };
}

function hslToRgb({ h, s, l }) {
  if (s === 0) {
    const v = Math.round(l * 255);
    return { r: v, g: v, b: v };
  }
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  const p = 2 * l - q;
  const hue2rgb = (p, q, t) => {
    if (t < 0) t += 1;
    if (t > 1) t -= 1;
    if (t < 1/6) return p + (q - p) * 6 * t;
    if (t < 1/2) return q;
    if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
    return p;
  };
  return {
    r: Math.round(hue2rgb(p, q, h + 1/3) * 255),
    g: Math.round(hue2rgb(p, q, h) * 255),
    b: Math.round(hue2rgb(p, q, h - 1/3) * 255),
  };
}

export function lighten(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, l: Math.min(1, hsl.l + amount / 100) }));
}

export function darken(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, l: Math.max(0, hsl.l - amount / 100) }));
}

export function desaturate(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, s: Math.max(0, hsl.s - amount / 100) }));
}

function linearize(v) {
  const sRGB = v / 255;
  return sRGB <= 0.04045 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
}

export function relativeLuminance(hex) {
  const { r, g, b } = parseHex(hex);
  return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}

export function contrastRatio(hex1, hex2) {
  const L1 = relativeLuminance(hex1);
  const L2 = relativeLuminance(hex2);
  const lighter = Math.max(L1, L2);
  const darker = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

export function isWcagAA(foreground, background, largeText = false) {
  const ratio = contrastRatio(foreground, background);
  return ratio >= (largeText ? 3.0 : 4.5);
}

export function bestTextColor(background) {
  const L = relativeLuminance(background);
  return L > 0.179 ? '#000000' : '#FFFFFF';
}

使用方式:

import { lighten, darken, contrastRatio, bestTextColor } from './color-utils.js';

const brand = '#FF5733';
const hover = darken(brand, 10);       // 悬停时的深色
const light = lighten(brand, 30);      // 浅色调背景
const text = bestTextColor(brand);     // 品牌色上的黑色或白色文本

console.log(contrastRatio(text, brand)); // 应 ≥ 4.5

运行时颜色的性能考量

积极缓存

颜色计算是引用透明的——相同的十六进制输入总是产生相同的输出。一个简单的记忆化包装器可以避免在每次渲染时重复计算:

function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn(...args));
    return cache.get(key);
  };
}

const lightenCached = memoize(lighten);
const contrastCached = memoize(contrastRatio);

优先使用 CSS 自定义属性而非 JavaScript

对于动态主题,不要在每次状态变化时用 JavaScript 重新计算颜色。一次性计算调色盘,将结果写入 CSS 自定义属性,让 CSS 处理每个组件:

function applyTheme(brandHex) {
  const root = document.documentElement;
  root.style.setProperty('--brand', brandHex);
  root.style.setProperty('--brand-dark', darken(brandHex, 10));
  root.style.setProperty('--brand-light', lighten(brandHex, 30));
  root.style.setProperty('--on-brand', bestTextColor(brandHex));
}

// 只在品牌色变化时调用一次,而非每次渲染
applyTheme('#FF5733');

避免在渲染循环中解析字符串

在每秒运行 60 次的 React 渲染函数中解析 "rgb(255, 87, 51)" 是低效的。只解析一次,存储结果,并在组件树中传递结构化颜色对象而非原始字符串。

// 低效:每次调用都解析
const color = chroma(colorString).darken(1).hex();

// 更好:一次解析,多次变换
const parsed = chroma(colorString);
const darkVariant = parsed.darken(1).hex();
const lightVariant = parsed.lighten(1).hex();

对于大规模的运行时颜色生成——随鼠标移动实时更新的颜色选择器,或包含数百种计算颜色的可视化——culori 是最佳选择,因为其函数式、无状态的架构和紧凑的包体大小使每次调用的开销最小。


核心要点

  • JavaScript 没有原生颜色类型——在进行任何数学运算之前,必须将十六进制、RGB 和 HSL 字符串解析为结构化对象。
  • HSL 算术(调整 l 控制明度,调整 s 控制饱和度)直接覆盖了最常见的设计变换需求。
  • WCAG 对比度计算需要进行伽马校正的亮度计算——不要用明度通道的数学来近似替代。
  • chroma.js 是功能最全面的库,颜色空间支持出色(包括 OKLCH 混合);culori 是现代 TypeScript 项目的最佳 tree-shakeable 选择;tinycolor2 是处理用户输入场景中兼容性最强的解析器。
  • 对于只需要解析、变亮/变暗和对比度计算的应用,零依赖工具库是可行方案——完整实现不足 100 行。
  • 积极缓存计算出的颜色,并将结果写入 CSS 自定义属性,而非在每次渲染时重新计算。
  • 使用色彩转换器直观验证颜色变换,使用色调生成器从单个品牌十六进制值生成完整的 50—950 Tailwind 兼容色阶。

相关颜色

相关品牌

相关工具