起因
同时开三四个项目的时候,浏览器标签页大概是这样的:
ini
[图标] 复发性流产专科病历 - localhost:5173
[图标] 门诊随访系统 - localhost:5174
[图标] PACS 影像工作站 - localhost:5175
图标是同一个------要么是框架默认的,要么就是那个 favicon.ico 找不到时的小地球。切换标签页时只能靠读标题,而标题前半截往往还是 Vite + React + TS。
我想要的其实很少:
- 零配置 。名字就在
index.html的<title>里,不想再填一遍。 - 稳定。同一个项目永远是同一个颜色,不会每次重启都换一副面孔。
- 能看清。色块里要写字,字不能糊。
- dev 和 build 行为一致。最烦的是开发时好好的,产物里图标没了。
于是有了 unplugin-favicon-identity:一块圆角色块,写项目名首字,颜色由名字算出来。
这篇文章只聊颜色:为什么不能用 HSL,以及 oklch() 超出 sRGB 时浏览器到底做了什么。构建器那层胶水代码没什么好讲的,装好就能用。
一、颜色:先把"稳定"和"可读"变成可证明的性质
1. 用 sha256 当随机源,而不是随机数
颜色只依赖名字,不依赖时间、不依赖进程:
ts
export function hashRatio(input: string, salt = ''): number {
const digest = createHash('sha256').update(`${input}\u0000${salt}`).digest()
return digest.readUInt32BE(0) / 2 ** 32
}
const hue = Math.floor(hashRatio(name) * 360)
const lightness = min + (max - min) * hashRatio(name, 'lightness')
两个细节:
salt不是装饰。色相和亮度各取一个哈希小数,如果都用同一个值,颜色会退化到一条对角线上,分布很难看。加不同的 salt 才能拿到两个互不相关的小数。- 不落盘缓存。sha256 是微秒级,加个缓存文件反而引入"参数改了、旧缓存还在用"的问题。内存里只在名字变化时重算就够了。
2. 亮度必须定死,所以 HSL 直接出局
这是我一开始走弯路的地方。直觉上是"随机一个色相,随机一个亮度,饱和度给个固定值",但 HSL 的 l 不是感知亮度:
hsl(60 100% 50%)是黄色,hsl(240 100% 50%)是蓝色,两者l相同;- 但它们的 WCAG 相对亮度差了接近一个数量级。同一个
l=44%下,黄绿的 Y 大约 0.43,蓝紫只有 0.06。
也就是说,用 HSL 的话,你写死的是"某个数",而不是"看起来多亮"。想让白字始终能读,就得每次生成完颜色再去算对比度,然后再回头调整------变成一个需要迭代的循环。
OKLab 的 L 是感知亮度,而且和相对亮度 Y 近似单调(大致 Y ≈ L³)。L 定死,对比度就基本定死,区间可以直接写死在代码里:
ts
export const TEXT_STYLES = {
light: { color: '#ffffff', lightness: [0.48, 0.53] },
dark: { color: '#111111', lightness: [0.64, 0.75] },
}
3. 真正的坑:超色域的 oklch(),浏览器裁的是亮度
这一条我一开始判断错了,后来用 Chromium 实测才改过来。
OKLCH 的彩度没有硬上限,oklch(0.53 0.5 200) 这种写法语法上完全合法。超出 sRGB 之后,我原本以为浏览器会"保持色相和亮度、把彩度降下来"------也就是常说的 gamut mapping。
实际不是。浏览器做的是把 linear RGB 的三个分量直接钳到 [0, 1]。
后果是亮度跟着漂:
| 请求 | 实际渲染 |
|---|---|
oklch(0.53 0.28 200) |
L ≈ 0.655 |
oklch(0.53 0.50 200) |
L ≈ 0.682 |
请求 L=0.53,渲染出来是 0.68。这直接摧毁了上一节的整个假设:一旦出界,写死的亮度区间就管不住对比度了。更糟的是它不报错、不警告,你只会觉得"这个颜色怎么有点发白"。
解法是别让它出界:对给定 (L, h),二分求出 sRGB 能表示的最大彩度,再乘 0.99 留一点余量。
ts
export function maxChroma(lightness: number, hue: number): number {
const angle = (hue * Math.PI) / 180
const cosine = Math.cos(angle)
const sine = Math.sin(angle)
let low = 0
let high = 1
for (let i = 0; i < 16; i++) {
const mid = (low + high) / 2
const [r, g, b] = oklabToLinearRgb(lightness, mid * cosine, mid * sine)
if (r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1) low = mid
else high = mid
}
return low
}
二分 16 次的误差是 1e-5,而彩度只输出到小数点后 3 位;而且二分始终向下收敛(只有完全在域内才抬 low),所以结果必定落在色域内。
这里还有个附加收益,也是很多"程序生成配色"看起来发灰的原因:彩度上限不是常数,各色相差得非常远。
同一亮度下,青色大约只能到 C=0.09,品红能到 0.29。如果偷懒统一给 C=0.1,品红系会灰得没法看;统一给 0.2,青色系直接出界、亮度开始漂。取各自的边界,才是"这个色相在该亮度下最鲜艳的版本"。
ts
const chroma = Math.floor(maxChroma(lightness, hue) * 0.99 * 1000) / 1000
Math.floor 也是为了保底:浮点误差再小,也不给你机会压线。
4. 对比度不"事后校验",直接写成测试
对比度在生成时就确定了,这件事需要一个可验证的保证,而不是"我算了一下差不多"。
所以测试不是抽样几个颜色看看,而是穷举 360 个色相 × 亮度区间的三个端点,断言最差值:
ts
it.each(['light', 'dark'] as const)('%s 预设下穷举所有色相都不低于 4.5:1', (textColor) => {
const [min, max] = TEXT_STYLES[textColor].lightness
let worst = Number.POSITIVE_INFINITY
let worstAt: OklchColor = { lightness: 0, chroma: 0, hue: 0 }
for (const lightness of [min, (min + max) / 2, max]) {
for (let hue = 0; hue < 360; hue++) {
const color = { lightness, chroma: maxChroma(lightness, hue) * 0.99, hue }
const ratio = contrastWithText(color, textColor)
if (ratio < worst) {
worst = ratio
worstAt = color
}
}
}
expect(worst, `最差 ${worst.toFixed(2)}:1 出现在 ${formatOklch(worstAt)}`)
.toBeGreaterThanOrEqual(4.5)
})
实测最差情况:
| 预设 | 文字色 | 背景亮度区间 | 穷举后的最差对比度 |
|---|---|---|---|
light |
#ffffff |
0.48 ~ 0.53 | 5.20 : 1 |
dark |
#111111 |
0.64 ~ 0.75 | 4.76 : 1 |
这里有个小设计我觉得挺值:失败信息里带上了最差颜色的 oklch 值 。将来有人调区间把测试搞挂了,expect 会直接告诉你"最差 4.32:1 出现在 oklch(0.530 0.131 192)",不用自己去写脚本复现。
另外还有一个分布测试:200 个名字按 30° 分 12 个桶,断言每桶都非空。哈希铺不开的时候(比如误用了 str.length 之类)它能立刻发现。
5. 首字不是 name[0]
"👨👩👧 家庭" 取首字,用 [...name][0] 拿到的是半个 ZWJ 序列,渲染出来是一个断开的部件。按字素簇切才对:
ts
const segmenter = typeof Intl.Segmenter === 'function'
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null
export function firstChar(input: string): string {
const name = input.trim()
if (!name) return '?'
if (!segmenter) return [...name][0] ?? '?'
for (const { segment } of segmenter.segment(name)) return segment
return '?'
}
👍🏽、🇨🇳 这类组合同样不会被拆散。
名字的来源也是有优先级的:text 选项 > 页面里的 <title> > package.json 的 name。<title> 里的 &、中 会先解码,否则 R&D Console 会取到 R(碰巧),而 复 会取到 &。
二、用起来
插件基于 unplugin,Vite 与 webpack 各有一个入口。dev 下图标由 dev server 直接返回,构建时输出到产物根目录------行为一致,不用为两种模式分别配置。
bash
npm i -D unplugin-favicon-identity
Vite:
ts
import faviconIdentity from 'unplugin-favicon-identity/vite'
export default defineConfig({
plugins: [faviconIdentity()],
})
webpack:
ts
import HtmlWebpackPlugin from 'html-webpack-plugin'
import faviconIdentity from 'unplugin-favicon-identity/webpack'
export default {
plugins: [new HtmlWebpackPlugin({ template: 'index.html' }), faviconIdentity()],
}
选项不多,因为大部分情况不该需要配置:
| 选项 | 默认值 | 说明 |
|---|---|---|
text |
<title> → 目录名 |
取首字的文字 |
bgColor |
由名字派生 | 任意 CSS 颜色 |
textColor |
'light' |
决定文字色,也决定背景亮度区间 |
render |
内置圆角模板 | 自定义 SVG |
debug |
false |
终端打印名字、颜色、对比度 |
调试的时候开 debug,能直接看到自己项目拿到的颜色和对比度:
scss
🎨 [favicon-identity] 门诊随访系统 → oklch(0.483 0.190 13)(对比度 7.19:1)
自定义模板会拿到 char / background / foreground,形状随你。唯一的要求是:别改 foreground 的颜色------那是保证对比度的那一半。
如果不想让图标进生产产物,只在开发时加载即可:
ts
export default defineConfig(({ command }) => ({
plugins: [command === 'serve' && faviconIdentity()],
}))
颜色计算本身也是导出的纯函数,可以单独用:
ts
import { createIdentityColor, formatOklch, contrastWithText } from 'unplugin-favicon-identity'
const color = createIdentityColor('门诊随访系统', 'light')
formatOklch(color) // 'oklch(0.483 0.190 13)'
contrastWithText(color, 'light') // 7.19
三、几个明确的取舍
写出来是为了省得别人装完才发现:
- 只输出 SVG 。现代浏览器没问题,但一些截图/预览工具、部分邮件客户端不认 SVG favicon。真需要
.ico的话,这个包帮不了你。 - 只有首字 。名字很长时信息量有限------它解决的是"区分",不是"标识"。想放 logo 就用
render自己画,或者干脆别用。 - 不支持 webpack 4。
- 颜色是"身份",不是"品牌" 。如果你的项目已经有品牌色,传
bgColor覆盖就行;但那样也就放弃了"对比度由生成过程保证"这一条,需要自己确认文字看得清。 - 项目不多的话,它没什么用。开两个项目、或者本来就有各自 favicon 的团队,收益接近零。这个包的适用场景非常窄:本地同时开一堆项目,且不想为每个项目手画图标。
最后
整个包的核心其实就两句话:
- 名字 → 哈希 → 色相和亮度,所以稳定;
- 亮度区间写死、彩度卡在 sRGB 边界内,所以对比度可控。
如果你也在同时开五六个标签页、被同一个默认图标晃得眼花,可以试试。仓库在 flycran/unplugin-favicon-identity,颜色相关的推导和边界情况都有测试兜着,遇到不符合预期的配色欢迎直接提 issue 带着 debug 输出。