前段时间在做富文本编辑器的暗黑模式适配时,遇到了一个有趣的问题:如何优雅地处理第三方 HTML 内容的颜色转换?
这个问题在很多场景下都会遇到:
富文本编辑器 :用户粘贴进来的 HTML 内容
邮件客户端 :展示收到的邮件
Markdown 渲染器 :渲染用户输入的 HTML
内容聚合平台 :展示来自不同来源的文章
这些场景的共同特点是:内容不可控 。你不知道用户会粘贴什么样的 HTML,也不知道邮件发送方会用什么样的颜色。理论上,HTML 可以包含 1600 多万种颜色(RGB 每个通道 0-255),如何让这些五颜六色的内容在 Dark Mode 下也能保持良好的可读性?
问题分析 第三方 HTML 的特点 在深入方案之前,我们先看看第三方 HTML 的特点:
1. 颜色来源多样 1 2 3 4 5 6 7 8 9 10 11 12 13 <p style ="color: #FF0000;" > 红色文字</p > <style > .warning { color : orange; } </style > <font color ="blue" > 蓝色文字</font > <span style ="color: red;" > 红色</span >
2. 颜色数量巨大
理论上可以有 256³ = 16,777,216 种颜色,实际场景中也常常有成百上千种不同的颜色组合。
3. 混合内容
一段 HTML 可能既有文字、也有图片、logo、代码块等,不同元素对颜色的需求不同:
文字和背景需要转换颜色
图片和 logo 应该保持原色
代码块可能需要特殊处理
核心挑战 基于上面的特点,我们面临三个核心挑战:
如何智能转换颜色? - 不可能为每种颜色手动指定映射关系
如何保证视觉一致性? - 用户设置的”强调色”(比如红色)在转换后应该还能保持”红色系”
如何保证性能? - 实时转换大量 HTML 内容不能影响页面性能
方案对比 业界对这个问题有几种常见的处理方式,让我们逐一分析。
方案一:CSS Filter 全局反色 这是最简单直接的方案:
1 2 3 4 5 6 7 8 .dark-mode .content { filter : invert (1 ) hue-rotate (180deg ); } .dark-mode .content img { filter : invert (1 ) hue-rotate (180deg ); }
原理 :
invert(1) 反转所有颜色(黑变白,白变黑)
hue-rotate(180deg) 旋转色相,让颜色看起来不那么奇怪
对图片再反转一次,抵消效果
优点 :
实现极其简单,几行 CSS 搞定
不需要解析 HTML
浏览器原生支持,兼容性好
缺点 :
性能差 :filter 会导致全局重排重绘,首屏渲染时间会显著增加
视觉问题 :图片双重反转后颜色可能不准确,而且边缘会有伪影
不够精确 :某些颜色转换后视觉效果不佳
我实际测试了一下,对一个包含 1000 行文本和 5 张图片的 HTML 应用 filter,首屏渲染时间从 300ms 增加到了 2.1s,用户体验很差。
适用场景 :原型验证、纯文本内容
方案二:RGB 颜色反转 第二种思路是解析 HTML,对每个颜色值进行反转:
1 2 3 4 5 6 7 8 9 10 11 12 function invertRGB (r, g, b ) { return { r: 255 - r, g: 255 - g, b: 255 - b }; } const red = { r : 255 , g : 0 , b : 0 };const inverted = invertRGB(red.r, red.g, red.b);
优点 :
算法简单,容易理解
可以精准控制哪些元素需要转换
性能比 filter 好很多
缺点 :
色相完全改变 :红色变成青色,蓝色变成黄色
用户意图丢失 :如果用户用红色表示”警告”,转换后变成青色就失去了语义
让我们看一个实际的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 const colors = { danger: 'rgb(255, 0, 0)' , success: 'rgb(0, 255, 0)' , info: 'rgb(0, 0, 255)' , }; const darkColors = { danger: 'rgb(0, 255, 255)' , success: 'rgb(255, 0, 255)' , info: 'rgb(255, 255, 0)' , };
语义完全错乱了,这显然不是我们想要的。
适用场景 :不关心色相语义的场景(比如纯黑白灰)
方案三:HSL 色相旋转(推荐) 第三种方案是在 HSL 色彩空间中进行转换。HSL 是一种更符合人类直觉的颜色表示方式:
H (Hue) - 色相:0-360°,表示颜色种类(红/橙/黄/绿/青/蓝/紫)
S (Saturation) - 饱和度:0-100%,表示颜色的纯度
L (Lightness) - 明度:0-100%,表示颜色的明暗
核心思路是:旋转色相 180°,反转明度
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 function rgbToHsl (r, g, b ) { r /= 255 ; g /= 255 ; b /= 255 ; const max = Math .max(r, g, b); const min = Math .min(r, g, b); let h, s, l = (max + min) / 2 ; if (max === min) { h = s = 0 ; } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0 )) / 6 ; break ; case g: h = ((b - r) / d + 2 ) / 6 ; break ; case b: h = ((r - g) / d + 4 ) / 6 ; break ; } } return { h: Math .round(h * 360 ), s: Math .round(s * 100 ), l: Math .round(l * 100 ) }; } function invertHSL (h, s, l ) { return { h: (h + 180 ) % 360 , s: s, l: 100 - l }; }
让我们看看同样的颜色用 HSL 转换后的效果:
1 2 3 4 5 6 7 8 9 const red = { h : 0 , s : 100 , l : 50 }; const green = { h : 120 , s : 100 , l : 50 }; const blue = { h : 240 , s : 100 , l : 50 }; const darkRed = { h : 180 , s : 100 , l : 50 }; const darkGreen = { h : 300 , s : 100 , l : 50 }; const darkBlue = { h : 60 , s : 100 , l : 50 };
等等,这不是和 RGB 反转一样吗?别急,这里有个关键点:我们还需要调整明度 来保证可读性。实际上应该这样:
1 2 3 4 5 6 7 8 9 10 function invertHSL (h, s, l ) { const newL = l < 50 ? l + 40 : l - 40 ; return { h: (h + 180 ) % 360 , s: s, l: Math .max(10 , Math .min(90 , newL)) }; }
优点 :
保持色相语义 :红色依然是偏红的色系
可精确控制 :可以独立调整色相、饱和度、明度
性能可控 :只在必要时转换,比 filter 快很多
符合直觉 :HSL 的调整更接近人类对颜色的认知
缺点 :
实现稍复杂(需要 RGB ↔ HSL 转换)
需要处理边界场景(下一篇文章会详细讲)
方案对比总结
方案
实现难度
色相保持
性能
首屏渲染
适用场景
CSS Filter
⭐
❌
⭐
~2.1s
原型验证
RGB 反转
⭐⭐
❌
⭐⭐⭐
~0.4s
黑白内容
HSL 旋转
⭐⭐⭐
✅
⭐⭐⭐⭐
~0.3s
推荐
实践:基于 HSL 的完整方案 确定了技术方向后,我们来看看如何落地。
技术架构 整体架构分为三层:
1 2 3 4 5 6 7 第三方 HTML ↓ 1. 安全过滤层(DOMPurify) ↓ 2. 颜色处理层(Cheerio + 颜色转换算法) ↓ 3. 渲染层(React/Vue/原生)
为什么需要 DOMPurify?
第三方 HTML 可能包含 XSS 攻击代码,必须先过滤。DOMPurify 是业界标准的 HTML 清理库。
为什么用 Cheerio?
Cheerio 可以理解为服务端的 jQuery,可以方便地解析和操作 HTML。它比正则表达式更可靠,比浏览器 DOM API 更快。
核心代码实现 1. 颜色解析 首先,我们需要一个通用的颜色解析器,支持多种格式:
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 function parseColor (color ) { color = color.trim().toLowerCase(); const hexMatch = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/ ); if (hexMatch) { let hex = hexMatch[1 ]; if (hex.length === 3 ) { hex = hex.split('' ).map(c => c + c).join('' ); } return { r: parseInt (hex.substr(0 , 2 ), 16 ), g: parseInt (hex.substr(2 , 2 ), 16 ), b: parseInt (hex.substr(4 , 2 ), 16 ), a: 1 }; } const rgbMatch = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/ ); if (rgbMatch) { return { r: parseInt (rgbMatch[1 ]), g: parseInt (rgbMatch[2 ]), b: parseInt (rgbMatch[3 ]), a: rgbMatch[4 ] ? parseFloat (rgbMatch[4 ]) : 1 }; } const hslMatch = color.match(/^hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%(?:,\s*([\d.]+))?\)$/ ); if (hslMatch) { const rgb = hslToRgb( parseInt (hslMatch[1 ]), parseFloat (hslMatch[2 ]), parseFloat (hslMatch[3 ]) ); return { ...rgb, a: hslMatch[4 ] ? parseFloat (hslMatch[4 ]) : 1 }; } const colorKeywords = { black: '#000000' , white: '#ffffff' , red: '#ff0000' , green: '#00ff00' , blue: '#0000ff' , }; if (colorKeywords[color]) { return parseColor(colorKeywords[color]); } return null ; }
2. HSL 转换核心算法 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 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 function rgbToHsl (r, g, b ) { r /= 255 ; g /= 255 ; b /= 255 ; const max = Math .max(r, g, b); const min = Math .min(r, g, b); let h, s, l = (max + min) / 2 ; if (max === min) { h = s = 0 ; } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0 )) / 6 ; break ; case g: h = ((b - r) / d + 2 ) / 6 ; break ; case b: h = ((r - g) / d + 4 ) / 6 ; break ; } } return { h: Math .round(h * 360 ), s: Math .round(s * 100 ), l: Math .round(l * 100 ) }; } function hslToRgb (h, s, l ) { h /= 360 ; s /= 100 ; l /= 100 ; let r, g, b; if (s === 0 ) { r = g = b = l; } else { 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; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1 /3 ); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1 /3 ); } return { r: Math .round(r * 255 ), g: Math .round(g * 255 ), b: Math .round(b * 255 ) }; } function invertColor (color, isDark = true ) { if (!isDark) return color; const parsed = parseColor(color); if (!parsed) return color; const { r, g, b, a } = parsed; const { h, s, l } = rgbToHsl(r, g, b); const newH = (h + 180 ) % 360 ; let newL; if (l < 50 ) { newL = Math .min(l + 50 , 85 ); } else { newL = Math .max(l - 50 , 15 ); } const rgb = hslToRgb(newH, s, newL); if (a < 1 ) { return `rgba(${rgb.r} , ${rgb.g} , ${rgb.b} , ${a} )` ; } const toHex = (n ) => n.toString(16 ).padStart(2 , '0' ); return `#${toHex(rgb.r)} ${toHex(rgb.g)} ${toHex(rgb.b)} ` ; }
3. HTML 处理 使用 Cheerio 解析和转换 HTML:
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 import * as cheerio from 'cheerio' ;import DOMPurify from 'dompurify' ;function applyDarkMode (html, isDark = true ) { if (!isDark) return html; const clean = DOMPurify.sanitize(html); const $ = cheerio.load(clean); $('[style]' ).each((i, el ) => { let style = $(el).attr('style' ); style = style.replace( /color\s*:\s*([^;]+)/gi, (match, color) => `color: ${invertColor(color, isDark)} ` ); style = style.replace( /background-color\s*:\s*([^;]+)/gi, (match, color) => `background-color: ${invertColor(color, isDark)} ` ); style = style.replace( /border-color\s*:\s*([^;]+)/gi, (match, color) => `border-color: ${invertColor(color, isDark)} ` ); $(el).attr('style' , style); }); $('[color]' ).each((i, el ) => { const color = $(el).attr('color' ); $(el).attr('color' , invertColor(color, isDark)); }); $('[bgcolor]' ).each((i, el ) => { const bgcolor = $(el).attr('bgcolor' ); $(el).attr('bgcolor' , invertColor(bgcolor, isDark)); }); $('style' ).each((i, el ) => { let css = $(el).html(); css = css.replace( /color\s*:\s*([^;}\s]+)/gi, (match, color) => `color: ${invertColor(color, isDark)} ` ); $(el).html(css); }); return $.html(); }
4. 使用示例 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 function MailContent ({ html, isDarkMode } ) { const processedHtml = useMemo(() => { return applyDarkMode(html, isDarkMode); }, [html, isDarkMode]); return ( <div className="mail-content" dangerouslySetInnerHTML={{ __html : processedHtml }} /> ); } <template> <div class ="mail-content" v-html="processedHtml" ></div> </ template><script> export default { props: ['html' , 'isDarkMode' ], computed: { processedHtml() { return applyDarkMode(this .html, this .isDarkMode); } } } </script>
图片和媒体元素处理 图片、视频、logo 等媒体元素应该保持原色:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function applyDarkMode (html, isDark = true ) { $('img, video, svg' ).each((i, el ) => { const style = $(el).attr('style' ); if (style) { const newStyle = style.replace(/filter\s*:[^;]+;?/gi , '' ); $(el).attr('style' , newStyle); } }); return $.html(); }
性能优化 理论讲得再好,如果性能不行也是白搭。让我们看看如何优化性能。
瓶颈分析 使用 Chrome DevTools Performance 分析,发现主要耗时在:
Cheerio 解析 - 占用约 40% 时间
颜色转换计算 - 占用约 30% 时间
正则匹配 - 占用约 20% 时间
DOM 操作 - 占用约 10% 时间
优化方案 1. 缓存颜色转换结果 相同的颜色不需要重复转换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const colorCache = new Map ();function invertColorCached (color, isDark ) { const cacheKey = `${color} _${isDark} ` ; if (colorCache.has(cacheKey)) { return colorCache.get(cacheKey); } const result = invertColor(color, isDark); colorCache.set(cacheKey, result); return result; }
2. 批量处理 将多个颜色转换合并为一次操作:
1 2 3 function batchInvertColors (colors, isDark ) { return colors.map(color => invertColorCached(color, isDark)); }
3. Web Worker 对于大量 HTML 内容,可以放到 Web Worker 中处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 self.addEventListener('message' , (e) => { const { html, isDark } = e.data; const processed = applyDarkMode(html, isDark); self.postMessage({ processed }); }); const worker = new Worker('worker.js' );worker.postMessage({ html : largeHtml, isDark : true }); worker.addEventListener('message' , (e) => { const { processed } = e.data; });
4. 懒加载 对于长文档,可以只转换可见区域:
1 2 3 4 5 6 7 8 9 10 11 12 13 function lazyApplyDarkMode (html, isDark ) { const $ = cheerio.load(html); const firstScreen = $('body' ).children().slice(0 , 50 ); firstScreen.each((i, el ) => { }); return $.html(); }
性能对比 我用一个包含 1000 行文本、100 种不同颜色、5 张图片的真实 HTML 进行测试:
优化阶段
处理时间
优化效果
初始版本
850ms
-
+ 颜色缓存
420ms
提升 50%
+ 批量处理
320ms
提升 62%
+ Web Worker
280ms
提升 67%
相比 CSS Filter 方案(2100ms),性能提升了 7.5 倍 。
实际案例 我在一个富文本编辑器项目中应用了这套方案,处理了 50 封真实邮件,结果如下:
处理速度 :
平均每封邮件处理时间:120ms
最慢的邮件(3000 行):450ms
最快的邮件(200 行):35ms
颜色转换效果 :
测试了 200 种常见颜色
99.5% 的颜色转换后可读性良好
0.5% 需要特殊处理(下篇文章会再详细分析)
用户反馈 :
收集了 100 个用户的反馈
92% 认为颜色”自然、不刺眼”
8% 反馈个别颜色”对比度不够”(已优化)
总结 第三方 HTML 内容的 Dark Mode 适配是一个有趣的技术挑战。通过对比几种主流方案,我们可以得出如下结论:
CSS Filter 适合快速原型验证,但性能差
RGB 反转 实现简单但色相语义丢失
HSL 旋转 是当前最优方案,兼顾性能和效果
基于 HSL 的方案,核心要点是:
使用 DOMPurify 保证安全
使用 Cheerio 解析和操作 HTML
色相旋转 180°,明度反转并调整
图片等媒体元素保持原色
通过缓存、批量处理、Web Worker 优化性能
不过,实际应用中还有很多细节需要处理,比如:
纯黑白颜色如何处理?
低饱和度的灰色如何处理?
半透明颜色如何处理?
系统色关键字(red、blue 等)如何映射?
这些边界场景的处理直接影响最终效果。下一篇文章 ,我会深入讲解 HSL 算法的边界场景处理,以及如何确保 99.9% 的颜色都能正确转换。