上一篇文章 中,我们聊到了第三方 HTML 暗黑模式的适配方案,并实现了一套基于 HSL 的颜色转换。但是随着研究的深入,我发现 HSL 方案还是有一些问题。

回顾一下我们之前的需求:第三方 HTML 的内容中的颜色多种多样,怎么把它们转换成深色模式下看起来都很舒服的颜色并且还尽量保持原来的语义?

HSL 的局限性

为什么深色模式适配不能直接取反?

最直觉的想法:RGB 直接取反不就可以了吗

1
2
3
4
// 看起来很合理?
function invertRGB(r: number, g: number, b: number) {
return [255 - r, 255 - g, 255 - b];
}

试试黑色:

1
2
#000000 → 取反 → #FFFFFF
黑色变白色,完美!✓

再试试红色:

1
2
#FF0000 → 取反 → #00FFFF
红色变成了青色...

问题来了:本来一般用于表示警告的红色变成了青色,语义丢失了。

直接取反有两个致命问题:

  1. 颜色的”含义”丢了(红色不再是红色)
  2. 没考虑亮度(有些颜色在深色背景上根本看不清)

中间层的思路

既然 RGB 三个通道混在一起不好控制,那就拆开。

1
2
3
4
5
RGB:r, g, b → 三个值混在一起,牵一发动全身

中间层:色相, 饱和度, 亮度 → 各管各的,互不影响

深色模式的颜色

这就是 HSL 的思路:把颜色拆成色相(Hue)、饱和度(Saturation)、亮度(Lightness)三个独立的维度。

想调亮度只改 L 就行,色相和饱和度不变。红色还是红色,只是亮了或暗了。

听起来不错,但 HSL 有个容易被忽略的问题。

为什么 HSL 的亮度看起来不真实

HSL 的 L 是”数学上的亮度”,不是”人眼看起来的亮度”。

什么意思呢?看这个例子:





示例文本


示例文本

按 HSL 的判断,它们的亮度一样,都是 50%。但眼睛会告诉我们:黄色特别亮,而蓝色相对暗很多。

这就是 HSL 的问题:亮度不均匀。HSL 的 L 是纯数学计算值,不是人眼感知到的亮度。同样的 L 值,不同色相看起来亮度差很多,而且同一个颜色,增减同样的 L 值,颜色变化也是不均匀的。

选择更合适的色彩空间

既然 HSL 也不完美,那有没有更好的解决方案呢?随着调研的深入,我发现原来现在已经有这么多 Web 上可用的色彩空间了:

色彩空间 特点 问题
RGB/HEX 与设备硬件直接对应 通道耦合,无法独立控制亮度
HSL/HSV 表达直观,易于调整 不具备感知均匀性
CIE Lab/LCH 工业标准,印刷常用 实现复杂,存在色域裁剪问题
CAM16/HCT 感知模型更完整 Material Design 3 标准 模型复杂,缺乏原生 CSS 支持
Oklab/OKLCH 近似感知均匀,结构简单 ✅ 简单、准确、CSS 原生支持

从工程角度看,一个可行的方案至少需要满足:

亮度维度与人眼感知尽可能一致
色相和饱和度调整相对独立
能在 Web 环境中直接使用或低成本实现

在这些约束下,Oklab color space 提供了一个更平衡的选择。它由 Björn Ottosson 在 2020 年提出,目标是构建一个在计算成本和感知一致性之间折中的色彩空间。OKLCH 是它的极坐标形式,L(亮度)、C(彩度)、H(色相),和 HSL 的概念类似,但 L 是真正的”感知亮度”。

在 OKLCH 里:

1
2
黄色 oklch(0.97, 0.21, 110) ← L ≈ 0.97,确实很亮
蓝色 oklch(0.45, 0.31, 264) ← L ≈ 0.45,确实暗

目前,CSS Color Level 4 已经把 oklch() 写进了标准,Chrome 111+、Firefox 113+、Safari 15.4+ 也都原生支持了。
综上考虑,OKLCH 是目前更合适的方案。

实现步骤

一、色彩空间转换

要把 HEX 颜色转成 OKLCH,需要经过这样的一条链路:

1
sRGB → Linear RGB → LMS → Oklab → OKLCH

sRGB → Linear RGB:显示器的颜色值不是线性的,有个 Gamma 曲线。#808080 看起来是 50% 灰,但实际光强度只有 22% 左右。颜色运算要在真实光强度上做,所以先去掉 Gamma。

Linear RGB → LMS:LMS 对应人眼的三种视锥细胞(长波、中波、短波),是 Oklab “感知均匀” 的基础。

LMS → Oklab → OKLCH:Oklab 是笛卡尔坐标 (L, a, b),OKLCH 是极坐标 (L, C, H),后者更直观。

先做基础转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const SRGB_THRESHOLD = 0.04045;
const SRGB_LINEAR_SLOPE = 12.92;
const SRGB_GAMMA = 2.4;
const SRGB_OFFSET = 0.055;
const SRGB_SCALE = 1.055;

// sRGB → Linear RGB
function srgbToLinear(value: number): number {
const normalized = value / 255;
if (normalized <= SRGB_THRESHOLD) {
return normalized / SRGB_LINEAR_SLOPE;
}
return Math.pow((normalized + SRGB_OFFSET) / SRGB_SCALE, SRGB_GAMMA);
}

// Linear RGB → sRGB
function linearToSrgb(value: number): number {
const clamped = Math.max(0, Math.min(1, value));
if (clamped <= SRGB_THRESHOLD / SRGB_LINEAR_SLOPE) {
return Math.round(clamped * SRGB_LINEAR_SLOPE * 255);
}
return Math.round(
(SRGB_SCALE * Math.pow(clamped, 1 / SRGB_GAMMA) - SRGB_OFFSET) * 255,
);
}

这里有个细节:sRGB 线性化的阈值。WCAG 2.x 文档里写的是 0.03928,但这其实是个历史遗留的错误值。正确的值是 0.04045,来自 IEC 61966-2-1:1999 标准。可以参考 W3C 的 这篇文章 了解细节。

接下来是 Oklab 转换,这部分涉及到一些矩阵运算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
interface LinearRGB {
r: number;
g: number;
b: number;
}
interface Oklab {
L: number;
a: number;
b: number;
}
interface Oklch {
L: number;
C: number;
H: number;
}

// Linear RGB → Oklab
function linearRgbToOklab(rgb: LinearRGB): Oklab {
const { r, g, b } = rgb;

// Linear RGB → LMS
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;

// 立方根变换(关键步骤,实现感知均匀化)
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);

// LMS → Oklab
return {
L: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
};
}

// Oklab → Linear RGB
function oklabToLinearRgb(oklab: Oklab): LinearRGB {
const { L, a, b } = oklab;

const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.291485548 * b;

const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;

return {
r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
};
}

// Oklab ↔ OKLCH(笛卡尔坐标 ↔ 极坐标)
function oklabToOklch(oklab: Oklab): Oklch {
const { L, a, b } = oklab;
const C = Math.sqrt(a * a + b * b);
let H = (Math.atan2(b, a) * 180) / Math.PI;
if (H < 0) H += 360;
return { L, C, H };
}

function oklchToOklab(oklch: Oklch): Oklab {
const { L, C, H } = oklch;
const hRad = (H * Math.PI) / 180;
return { L, a: C * Math.cos(hRad), b: C * Math.sin(hRad) };
}

这些矩阵系数来自 Björn Ottosson 的 这篇论文

有了这些基础函数,就可以组装出 HEX ↔ OKLCH 的转换了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function hexToOklch(hex: string): Oklch {
const rgb = parseHex(hex);
const linear: LinearRGB = {
r: srgbToLinear(rgb.r),
g: srgbToLinear(rgb.g),
b: srgbToLinear(rgb.b),
};
return oklabToOklch(linearRgbToOklab(linear));
}

export function oklchToHex(oklch: Oklch): string {
const oklab = oklchToOklab(oklch);
const linear = oklabToLinearRgb(oklab);
return rgbToHex({
r: linearToSrgb(linear.r),
g: linearToSrgb(linear.g),
b: linearToSrgb(linear.b),
});
}

二、亮度映射

转换解决了,下一步是亮度映射。

核心思路很简单:只有浅色元素需要变深,深色元素保持原样。深色代码块、深色卡片这类元素,本身就是深色设计,在 Dark Mode 下保持原样即可。唯一需要处理的边界情况是:如果深色元素的亮度和页面背景(L ≈ 0.08)太接近(差值 < 0.10),就略微提亮,确保不会和背景融合。

亮度转换最简单的做法:

1
2
// 深色模式:亮度不够就拉到 0.5
l = Math.max(l, 0.5);

但是这样粗暴处理可能会导致一些颜色产生突变,视觉上会有明显的断层。

同时还有另一个问题:深色范围 [0.20, 0.40] 比浅色范围 [0.5, 1.0] 的可用亮度要少很多。如果用一条线性函数做映射,高明度浅色(L > 0.90,比如白色、浅灰)会被压到 0.20–0.25 的狭窄区间,和页面背景差值不够。

但换个角度想:L = 0.90 和 L = 0.98 在 Light Mode 下的视觉差异本来就不大 —— 它们都是”接近白色”。真正需要保持层次感的是中等浅色区域,比如 #c8e6c9(浅绿,L ≈ 0.80)和 #ef9a9a(浅红,L ≈ 0.72),这些颜色在 UI 里承载着明确的语义。

这背后有一个 19 世纪的心理物理学定律在起作用:Weber-Fechner 定律。人眼对亮度变化的感知是对数关系 —— 在暗处,你对微小的亮度差异非常敏感;在亮处,同样的差异你几乎感觉不到。就像在漆黑的房间里点亮一支蜡烛,感觉天翻地覆;但在正午的阳光下点同一支蜡烛,几乎注意不到。

基于这个原理,我们把映射分成两段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 三次贝塞尔曲线(简化版,只用 y 坐标控制点)
function cubicBezier(t: number, p1: number, p2: number): number {
const t2 = t * t;
const t3 = t2 * t;
const mt = 1 - t;
const mt2 = mt * mt;
const mt3 = mt2 * mt;

return mt3 * 0 + 3 * mt2 * t * p1 + 3 * mt * t2 * p2 + t3 * 1;
}

function mapLightness(L: number): number {
if (L <= 0.85) {
// 中等浅色 [0.5, 0.85] → [0.28, 0.40],保留层次
const t = (L - 0.5) / 0.35;
return cubicBezier(t, 0.4, 0.38, 0.32, 0.28);
} else {
// 高明度 [0.85, 1.0] → [0.20, 0.28],压缩但可见
const t = (L - 0.85) / 0.15;
return cubicBezier(t, 0.28, 0.26, 0.22, 0.2);
}
}
区间 含义 映射到 设计考量
[0.50, 0.85] 中等浅色(语义色、标签等) [0.28, 0.40] 分配 0.12 空间,保留层次
[0.85, 1.00] 高明度(白色、浅灰等结构性背景) [0.20, 0.28] 分配 0.08 空间,确保可见即可

贝塞尔曲线的 S 形特性天然符合 Weber-Fechner 定律 —— 在低亮度端(映射目标区域)保留更多层次,在高亮度端适度压缩。控制点的选择原则是:段 1 的起点 0.40 要和页面背景(L=0.08)有足够对比,终点 0.28 要和段 2 平滑衔接;段 2 的终点 0.20 是最暗的映射目标,和页面背景差值 0.12,超过最小可辨识阈值 0.10。

三、语义色检测与色度补偿

分段映射解决了亮度问题,但还有一个容易忽略的维度:色度(Chroma)

#ffebee 是 Material Design 的浅粉色,通常用于错误提示背景。它在 OKLCH 里的值是 L=0.95, C=0.02, H≈20。注意色度只有 0.02 —— 这是一个几乎没有颜色感的“带一点粉的白色”。

降低明度后,这么低的色度在深色区域里根本看不出是红色,只会呈现为灰黑色,会导致语义丢失。

解决方案是:降低明度时,同时提升色度以保持可辨识性。但不能对所有颜色都这么做 —— 比如纯灰色提升色度会变成彩色。只对语义色(红、绿、黄、蓝)做增强:

1
2
3
4
5
6
7
8
9
10
11
// 识别语义色的色相范围
const SEMANTIC_RANGES = {
error: { hMin: 0, hMax: 40 }, // 红色(含 340°-360°)
success: { hMin: 130, hMax: 170 }, // 绿色
warning: { hMin: 50, hMax: 90 }, // 黄色/橙色
info: { hMin: 230, hMax: 270 }, // 蓝色
};

// 色度增强:明度降低越多,色度补偿越大
const boost = Math.sqrt(originalL / newL);
const newC = Math.min(C * boost, 0.18);

用平方根是因为明度比例可能很大(比如 0.95/0.22 ≈ 4.3),直接乘会导致色度过高。平方根让增长更温和(√4.3 ≈ 2.1),同时设置了 0.18 的上限防止过饱和。

四、Helmholtz-Kohlrausch 效应补偿

亮度和色度都处理好了,在深色背景上测试时又发现一个问题:某些高饱和的文字颜色看起来特别刺眼,尤其是红色和青色。

这和一个叫 Helmholtz-Kohlrausch(H-K)效应 的视觉现象有关:高饱和度的颜色看起来比相同亮度的低饱和色更亮,即使物理亮度相同。你可能有过类似体验——红色的霓虹灯看起来总比白色的”更亮”,即使它们的实际功率相同。

这个效应的强度因色相而异。红色和青色最明显,黄色和蓝色相对弱一些。在深色背景上,高亮度 + 高饱和度的文字会产生明显的视觉不适。

补偿策略:对高亮度(L > 0.6)且高饱和度(C > 0.08)的文字,根据色相降低饱和度。
公式:C’ = C × (1 - k × √((L - 0.6) / 0.4)),其中 k 是补偿系数,红色/青色系用更大的 k。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function applyHKCompensation(C, L, H) {
// 只对高亮度高饱和度的颜色进行补偿
if (L <= 0.6 || C <= 0.08) {
return C;
}

// H-K 效应在不同色相上的强度不同
// 红色(H≈30)和青色(H≈210)效应最强
// 黄色(H≈100)和蓝色(H≈260)效应较弱
let hkFactor = 0.12; // 基础补偿系数

// 红色系增强 (H: 0-50, 340-360)
if ((H >= 0 && H <= 50) || (H >= 340 && H <= 360)) {
hkFactor = 0.18;
}
// 青色系增强 (H: 170-230)
else if (H >= 170 && H <= 230) {
hkFactor = 0.16;
}
// 绿色和紫色中等 (H: 100-170, 270-340)
else if ((H >= 100 && H <= 170) || (H >= 270 && H <= 340)) {
hkFactor = 0.14;
}

// 计算补偿量:亮度越高,补偿越多
const lightnessFactor = Math.sqrt((L - 0.6) / 0.4);
const reductionFactor = 1 - hkFactor * lightnessFactor;

// 限制最大补偿,避免颜色过于灰暗
const compensatedC = C * Math.max(reductionFactor, 0.7);

if (compensatedC < C) {
stats.hkCompensated++;
}

return compensatedC;
}

五、APCA 对比度保障

做完亮度映射和色度调整,还需要一道兜底:确保文字和背景之间有足够的对比度

传统方案一般会采用 WCAG 2.x 的对比度算法,但这个算法有个已知问题:它对深色背景上的浅色文字给出的对比度值偏高,导致实际阅读体验不如数值显示的那么好。WCAG 3.0 草案提出了新的 APCA(Advanced Perceptual Contrast Algorithm),由 Andrew Somers 主导设计。APCA 的核心改进是区分了“浅底深字”和“深底浅字”两种模式,使用不同的幂次系数,在深色背景场景下的准确性明显优于 WCAG 2.x,更符合人眼感知。

最后一步,对文字颜色做 APCA 对比度检测。如果 |Lc| < 60(正文的推荐阈值),就迭代微调文字的亮度,直到达标:

1
2
3
while (Math.abs(calculateAPCA(textRgb, bgRgb)) < 60) {
newL += 0.02; // 逐步提亮文字,只调整必要的量,避免过度
}
场景 APCA 推荐阈值
正文 (14–16px) |Lc| ≥ 60
大标题 (24px+) |Lc| ≥ 45
非文本元素 |Lc| ≥ 30

六、性能优化:缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class ColorCache {
private cache = new Map<string, string>();
private maxSize = 1000;

// 量化颜色,合并相似色
private quantize(hex: string): string {
const rgb = parseHex(hex);
const quantized = [rgb.r, rgb.g, rgb.b].map(
(v) => Math.round(v / 32) * 32,
);
return `${quantized[0]},${quantized[1]},${quantized[2]}`;
}

get(hex: string): string | undefined {
const key = this.quantize(hex);
const result = this.cache.get(key);
if (result) {
// LRU:访问过的移到最后
this.cache.delete(key);
this.cache.set(key, result);
}
return result;
}

set(hex: string, result: string): void {
const key = this.quantize(hex);
if (this.cache.size >= this.maxSize) {
// 满了就删最老的
const firstKey = this.cache.keys().next().value;
if (firstKey) this.cache.delete(firstKey);
}
this.cache.set(key, result);
}
}

这里有个技巧:quantize 把颜色量化到 8 级(256/32=8),相近的颜色会被合并成同一个 key。视觉上 #FF0000 和 #FF0808 几乎没区别,没必要分开算。

效果与演示

我做了一个交互式的在线演示工具,支持粘贴任意 HTML 内容实时预览转换效果,包括色度增强、H-K 补偿等过程数据的展示。感兴趣可以试试:

https://lab.thjiang.com/dark-mode/index.html

CSS 原生方案与未来

如果你的场景不需要适配第三方 HTML,CSS 原生方案可能就够了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* OKLCH 颜色直接定义 */
.tag {
background: oklch(0.7 0.15 145);
}

/* 相对颜色语法:基于已有颜色计算新值 */
.dark .tag {
background: oklch(from var(--color) calc(0.95 - l) c h);
}

/* light-dark() 函数:一行搞定亮暗两套色 */
.tag {
color: light-dark(oklch(0.3 0.1 250), oklch(0.85 0.1 250));
}
特性 Chrome Firefox Safari
oklch() 111+ 113+ 15.4+
相对颜色 from 119+ 128+ 16.4+
light-dark() 123+ 120+ 17.5+

能枚举所有颜色的场景下,用 CSS 原生方案更简洁。但对于邮件客户端、富文本编辑器这类需要处理任意第三方 HTML 的场景,颜色无法预知,只能在运行时做自动转换,这种时候就是前述的算法发挥的空间了。


参考资料