浏览器标签页全是一个图标:我用名字哈希生成 favicon,让每个项目都有身份标识

起因

同时开三四个项目的时候,浏览器标签页大概是这样的:

ini 复制代码
[图标] 复发性流产专科病历 - localhost:5173
[图标] 门诊随访系统 - localhost:5174
[图标] PACS 影像工作站 - localhost:5175

图标是同一个------要么是框架默认的,要么就是那个 favicon.ico 找不到时的小地球。切换标签页时只能靠读标题,而标题前半截往往还是 Vite + React + TS。

我想要的其实很少:

  1. 零配置 。名字就在 index.html 的 <title> 里,不想再填一遍。
  2. 稳定。同一个项目永远是同一个颜色,不会每次重启都换一副面孔。
  3. 能看清。色块里要写字,字不能糊。
  4. 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> 里的 &amp;、&#x4e2d; 会先解码,否则 R&amp;D Console 会取到 R(碰巧),而 &#x590d; 会取到 &。


二、用起来

插件基于 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 的团队,收益接近零。这个包的适用场景非常窄:本地同时开一堆项目,且不想为每个项目手画图标。

最后

整个包的核心其实就两句话:

  1. 名字 → 哈希 → 色相和亮度,所以稳定;
  2. 亮度区间写死、彩度卡在 sRGB 边界内,所以对比度可控。

如果你也在同时开五六个标签页、被同一个默认图标晃得眼花,可以试试。仓库在 flycran/unplugin-favicon-identity,颜色相关的推导和边界情况都有测试兜着,遇到不符合预期的配色欢迎直接提 issue 带着 debug 输出。


相关推荐
全栈项目管理程序猿1 小时前
ArcGIS JS 基础教程(32):体元系列 - VoxelSlice 体元切片
前端·javascript
拖孩3 小时前
AI 帮我写的小程序「厨菜记」上线满月了,挣了两百块!!!
前端·后端·微信小程序
用户360055579003 小时前
Kestrel_VLLM - 13 · 为什么排除 MoE FFN Tiling 与 Tensor Core
前端
Hilaku4 小时前
AI 流式输出都用 SSE,什么时候必须换 WebSocket?
前端·javascript·程序员
奇牙coding4 小时前
Codex CLI 升级后多文件编辑静默失效:config.yaml 枚举变更排查与修复指南
java·前端·javascript·ai
小羊没烦恼!4 小时前
Ajax与JSON的一些总结
android·java·前端·数据库·mysql·算法·okhttp
程序员老赵4 小时前
Docker 部署 Filestash:轻松搭建浏览器文件管理平台
前端·docker·开源
夏天要喝冰可乐4 小时前
给 23 万字修仙小说造个阅读站:浏览器内打包 EPUB 分页阅读 + 三主题实战
前端·产品·全栈
茶底世界之下4 小时前
林间步道要不要加暗角?用 Awaking 把强度收在20%
前端·ios·图片资源