做用户系统的人大概率都遇到过这个场景:注册页旁边的默认头像,要么是一张灰扑扑的剪影,要么是随机字母,用户看一眼就想换掉。我前后折腾过字母头像、纯色几何块、甚至自己画的一套动物图标,最后落在 DiceBear 的 Blobs 上,一用就是两年多。这套风格最讨巧的地方在于它没有人像特征——没有五官、没有发型、没有性别或年龄暗示,只有几块不规则但边缘圆润的斑块摆在画布里。它不会冒犯任何人,也不会显得敷衍,放在深色顶栏、浅色卡片、圆角按钮里都不违和。这篇文章想聊的,是这套头像风格预设里那些真正会影响观感的东西:配色方案怎么搭、参数之间怎么互相牵制、官方没给的那个“动画开关”在工程上该怎么自己补,以及我把它接进真实项目时踩过的一堆坑。不管你是刚接需求的前端,还是在做服务端批量生成的后端,下面这些东西应该都能直接抄。
1. 先把 Blobs 的可调项摸清楚
1.1 一张表看懂它到底开放了哪些参数
很多人第一次用 Blobs,是在官方那个预览页面上拖滑块。拖的时候很爽,回到代码里就懵了:预览页那些开关,到底对应哪些字段?我把常用的几个参数整理成一张表,参数名、类型、取值范围和实际作用都写清楚,代码里直接照着填就行。需要说明的是,下面这些是风格层自身暴露的选项,另外还有一组所有风格通用的参数(seed、size、背景相关),两者是叠加关系,不是二选一。
| 参数名 | 类型 | 默认值 | 取值范围 | 它到底改了什么 |
|---|---|---|---|---|
seed | string | 随机 | 任意字符串 | 决定斑块形状组合与本体配色 |
size | number | 128 | 一般 16 到 256 | 输出 SVG 的宽高属性 |
flip | boolean | false | true / false | 整个图形水平镜像 |
rotate | number | 0 | 0 到 360 | 图形绕中心旋转的角度 |
scale | number | 100 | 0 到 200 | 图形相对画布的缩放百分比 |
radius | number | 0 | 0 到 50 | 画布圆角,单位是百分比 |
translateX | number | 0 | -100 到 100 | 图形水平偏移,按画布宽度百分比 |
translateY | number | 0 | -100 到 100 | 图形垂直偏移,按画布高度百分比 |
backgroundColor | string[] | 无 | 六位十六进制色值数组 | 背景色,可给一组 |
backgroundType | string | solid | solid / linear / radial | 背景是纯色还是渐变 |
backgroundRotation | number | 0 | 0 到 360 | 渐变方向 |
randomizeIds | boolean | false | true / false | 给内部元素 id 加随机后缀 |
看这张表有个细节容易被忽略:radius的 50 不是“50 像素”,是“50%”。也就是说不管你把size设成 64 还是 256,radius: 50出来的永远是正圆。这套百分比坐标系的思路贯穿了 Blobs 的所有几何参数,理解这一点,后面调参就不会再出现“我在 128 上试好的位置,换成 256 就歪了”这种事。
1.2 seed 决定的东西比你以为的多
seed是这套东西的灵魂。同一个 seed、同一组参数、同一个版本,出来的 SVG 是字节级一致的——这一点在服务端做缓存时非常关键,你可以把 seed 当成一个天然的、无需存储的“内容指纹”。但它的作用范围比多数人想的大:它不只决定那几块斑块的形状组合和相对位置,还决定斑块本体的颜色。你给一个完全相同的参数组合,只换 seed,出来的可能是浅蓝配浅粉,也可能是薄荷绿配杏黄,风格差得挺远。
这就带来一个现实问题:如果你想让某个用户固定一个“看起来比较正经”的头像,光靠试是不行的,因为你不一定能撞上喜欢的配色。我早期的做法是批量跑一百个 seed,肉眼扫一遍,挑出几个观感稳的做白名单,按用户 ID 取模分配。听起来很土,但在没有更好办法的情况下,这是最省事的。后来我改成“接受它”,因为 Blobs 的调色板整体饱和度都不高,一百个里也挑不出几个扎眼的,反而省了挑拣的功夫。
另外一个必须提醒的点:seed 的确定性是“跨请求一致”,不是“跨版本一致”。大版本升级之后,内部实现动过,同一个 seed 出来的图形会变。如果你的项目里已经给几万用户分配好了头像,升级前一定要把版本号锁死在依赖里,不然某天发版,整个站的头像集体换脸,用户会以为进错站了。
1.3 为什么几何参数是百分比而不是像素
这个设计决策值得单独说两句,因为它直接决定了你能不能把这套东西做成一套设计规范。scale、translateX、translateY、radius全部按画布比例计算,意味着同一组参数在任何尺寸下呈现的比例关系完全一致。你在 32 像素的小图标上把斑块放大到 110%,在 512 像素的海报上依然是 110% 的放大感,不需要为不同尺寸准备两套参数。
这个特性在响应式场景里特别好用。举一个我实际做过的例子:社区评论区的头像在移动端是 40 像素,桌面端是 48 像素,如果参数按像素算,我就要维护两份配置;按百分比算,一套配置从 24 到 256 全部通吃。我当时的做法是把整套参数写成一个常量对象,尺寸作为唯一变量传进去,整个链路上只改一个数字,非常省心。
顺带说一句size,它只影响 SVG 的 width 和 height 属性,不影响 viewBox。也就是说你把 size 设成 64,拿到的 SVG 文件里 viewBox 还是原始的,你在 CSS 里把它强行拉到 256 也不会糊。这个特性让“小尺寸生成、大尺寸显示”成为可能,但反过来,如果你要导出 PNG,就必须在导出环节把尺寸给够,因为位图是实打实的像素,拉伸必糊。
2. 配色方案:从背景到斑块的三层控制
2.1 backgroundColor 和三种背景类型的组合逻辑
背景这块只有两个参数,但组合出来的效果差别很大。backgroundColor是个数组,官方允许给多个颜色;backgroundType决定这些颜色怎么用。我的实测结论是:当类型为solid时,数组里的颜色会被当成候选池,按 seed 挑一个用;当类型为linear或radial时,数组里的颜色会被当作渐变的色标依次铺开,给两个就是两段渐变,给三个就是三段。
这个机制意味着一个很容易踩的坑:你在solid模式下给了五个颜色,以为是“随机五种背景”,结果发现同一个用户刷新几次颜色是稳定的,换了用户才变——这是符合预期的,因为它是按 seed 挑的,不是按请求挑的。但如果你给了五个颜色、类型设成linear,那五个颜色会一起出现在一个渐变里,观感可能完全不是你想要的。我见过有同学把一组品牌色直接丢进数组,结果出来一条彩虹渐变,只能推翻重来。
所以我的建议很简单:solid模式下,数组里只放一个颜色,需要多样性就靠 seed 本身;linear和radial模式下,老老实实给两个颜色,最多三个,超过三个的渐变在 48 像素的尺寸下根本看不出层次,只会显得脏。
还有一个细节:色值不要带#。写backgroundColor: ['#eef2ff']是无效的,必须写backgroundColor: ['eef2ff']。这个规则在 HTTP 接口里同样适用,而且多个颜色之间用英文逗号分隔,不能有空格。
2.2 一套可以直接抄的场景化配色清单
配色这件事没有标准答案,但有一些经过验证的组合可以少走弯路。下面这几组是我在几个不同项目里沉淀下来的,都跑过实际用户,直接抄问题不大。判断标准很简单:把头像缩到 40 像素,眯着眼睛看,背景和斑块之间要有区分度,但斑块不能比背景还抢眼——毕竟它是默认头像,不是视觉焦点。
| 使用场景 | 背景色值 | 背景类型 | 搭配要点 |
|---|---|---|---|
| 后台列表、表格内的账号列 | f1f5f9 | solid | 中性浅灰,和白底表格有轻微分界 |
| 社区评论、动态流 | ffffff | solid | 纯白,让斑块自己说话 |
| 协作工具的成员头像 | eef2ff | solid | 带一点蓝调,和专业感贴合 |
| 品牌感较强的落地页 | eef2ff+fdf2f8 | linear,旋转 45 | 冷调过渡到暖调,柔和 |
| 儿童或教育类产品 | ecfeff+fef9c3 | radial | 从中心向外发散,活泼 |
| 深色模式的界面 | 0f172a | solid | 深底浅斑块,对比要重新调 |
| 名片、海报、印刷物料 | fafaf9 | solid | 米白,比纯白更有纸感 |
深色模式那一行需要单独强调。Blobs 的斑块本体颜色是偏浅的,放在白底上很舒服,一旦背景换成深色,斑块和背景的明度差就反过来了,整体会显得发灰、没精神。我在深色主题下的处理不是改背景色,而是给整个 SVG 加一层 CSS 滤镜把亮度和饱和度提上去,具体做法在 2.3 里讲。
2.3 斑块颜色不听话时的三种绕行方案
前面说过,斑块本体的颜色是跟着 seed 走的,官方没有开放一个“指定斑块颜色”的字段。这在做品牌定制的时候很要命——客户要求“所有头像必须是我们的主色系”,你总不能让人家换 seed 换到满意为止。我试过三种方案,各有各的适用边界。
第一种是 CSS 滤镜,最省事。给容器加filter: hue-rotate(-25deg) saturate(1.15),色相整体偏移,饱和度微调。优点是不碰 SVG 本身,一行 CSS 搞定,还能配合深色模式写媒体查询。缺点是它作用于整张图,包括背景,所以如果背景是渐变,渐变的方向感和颜色也会一起被拧,需要重新调。另外色相偏移对不同 seed 的效果不一致,有的 seed 偏完很好看,有的偏完发绿,属于“整体可控、局部随机”。
第二种是拿到 SVG 字符串之后做后处理,把内部的填充色替换掉。Blobs 输出的 path 上有明确的填充属性,用正则把色值统一替换成目标色是可行的。这种方案的好处是精确,你想让斑块变成品牌色,它就真的是品牌色,背景不受影响。坏处是它是“字符串级别的修改”,一旦上游改了输出结构,正则就可能匹配不到,属于脆弱实现。我的做法是把它包在一个带断言的函数里:替换完检查一下还有没有残留的旧色值,有就抛错,至少在 CI 阶段能发现。
第三种是双层叠加,用一个伪元素盖一层半透明的色块,配合混合模式让颜色渗透下去。这个方案最不侵入,但可控性也最差,适合那种“只要氛围对、颜色差一点无所谓”的场景,比如个人主页的背景装饰。真正的产品级头像我不建议用这个,因为混合模式在不同浏览器里的表现有细微差异,你很难保证十个用户看到的是同一个颜色。
3. 动画开关:Blobs 没有的那一个,怎么自己补上
3.1 为什么官方没给这套风格做动画
先说结论:Blobs 的输出是静态 SVG,选项表里没有动画开关。这一点和官方库里少数几个带animated参数的风格不一样,那些风格之所以能动,是因为它们的构成有明确的语义部件——眼睛、嘴巴、肢体,每个部件可以单独编排关键帧。Blobs 不是这么组织的,它就是几个 path 拼在一起,没有“这是头、这是手”的结构信息。
从工程角度看,给这种无结构的路径做形变动画,要么把 path 的d属性在两个形状之间插值(要求两边的命令数和点数完全一致,但 Blobs 每次生成的 path 数量都可能不同),要么用滤镜做噪波扰动(性能开销大,小尺寸下还会糊成一团)。两条路都不划算。所以官方的取舍其实很合理:这套风格定位是“静态身份标识”,不是“虚拟角色”,动起来反而破坏它那种沉稳的抽象感。
但需求是真实存在的。很多场景下,一个完全静止的默认头像会显得页面“死”了,尤其是在加载态、等待态、AI 助手对话这种需要传达“正在处理”的氛围里。所以下面的三套做法,是我在自己项目里补上这个开关的实际方案,从轻到重排列。
3.2 定时换 seed:最轻量的“活体”头像
思路很直白:既然 seed 决定图形,那就让 seed 随时间变化。用户 ID 拼上一个时间片,每过若干秒换一次,头像就会像呼吸一样缓慢地变换形态。实现上就是一个定时器加一个递增的计数器,把seed:${userId}-${tick}`` 传进去重新生成。
这里有几个参数必须调,不调效果会很糟。刷新间隔,我试过 2 秒、5 秒、8 秒、15 秒,最后定在 8 到 10 秒。低于 5 秒会显得焦躁,像在闪;高于 15 秒用户根本注意不到变化,等于白做。配合的过渡效果也很关键,直接替换 SVG 是硬切,观感很差,正确做法是准备好两层容器,新图先渲染在底层,再用透明度过渡把上层淡出,视觉上就是一次柔和的溶解。
function useBreathingSeed(userId, intervalMs = 9000) { const [tick, setTick] = useState(0); useEffect(() => { const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduced) return; const id = setInterval(() => setTick((t) => t + 1), intervalMs); return () => clearInterval(id); }, [intervalMs]); return `${userId}-${tick}`; }那段prefers-reduced-motion的判断不要省。系统层面开了“减少动态效果”的用户,是真的会因为持续变化的元素感到不适,这不是矫情。我一开始没加,后来收到反馈才补上,补上之后代码只多了三行,但这类细节决定了你做的到底是玩具还是产品。
还有一个隐藏成本要提前想清楚:每次换 seed 都是一次重新生成。如果你用的是本地库,一秒钟几十个头像同时在换,主线程会有压力,尤其在低端机型上。控制手段有两个,一是把间隔拉长,二是只让“当前视口内”的头像参与动画,滚出屏幕就停掉定时器,用IntersectionObserver判断即可。我做过一个两百多个头像的列表页,不做这个优化的时候滚动明显掉帧,加上之后恢复流畅。
3.3 CSS 动效与 SVG 内联动效的分工
如果你不想让图形本身变,只是想让画面有点动静,那用 CSS 就够,而且成本低得多。注意一个关键区别:当 SVG 是以<img src="...">或者背景图的形式引入时,它是一个封闭的文档,外部的 CSS 只能作用于整个元素,动不了里面的路径;只有当 SVG 被内联进 DOM,你才能选中内部的元素做精细控制。
前者能做的是整体变换,比如极缓慢的旋转、轻微的缩放呼吸、或者一点点上下浮动。这类动效对 Blobs 其实挺合适,因为它本来就是个抽象形状,缓慢转个 3 度、缩放到 102%,会给人一种“它是活的”的错觉,但又不至于让人分心。实现上就是一个 keyframes,注意缓动函数用ease-in-out,时长给到 6 秒以上,快一点都不行。
@keyframes blob-drift { 0%, 100% { transform: rotate(-2deg) scale(1); } 50% { transform: rotate(2deg) scale(1.03); } } .blob-avatar { animation: blob-drift 9s ease-in-out infinite; transform-origin: center; } @media (prefers-reduced-motion: reduce) { .blob-avatar { animation: none; } }后者,也就是内联 SVG 的方案,可以做到更细的层次,比如让每一块斑块以不同的相位缓慢浮动,产生一种“细胞质在流动”的感觉。做法是给每个 path 包一层<g>,分别加animation-delay。但这里有个实际问题:Blobs 输出的 path 数量随 seed 变化,你没法写死“第几个 path 延迟多少”,只能靠:nth-child写几条通用规则,效果比较随机。我的建议是别在这上面花太多时间,收益和投入不成正比。
4. 实战:把它接进真实项目里的完整链路
4.1 直连接口还是本地生成,怎么选
这套东西有两种接入方式,选错了后期会很别扭。第一种是走公开的 HTTP 接口,拼一个 URL 就能拿到 SVG,参数直接挂在查询串上。优点是零依赖,服务端渲染、静态页面、甚至设计稿里贴链接都能用。第二种是装本地依赖,用createAvatar在代码里生成。优点是可控,可以拿到字符串做后处理,可以做批量导出,也不受网络影响。
我的判断标准是看生成频率和定制深度。如果只是页面上几十个默认头像,接口完全够用,浏览器还能顺便吃到 CDN 缓存。但如果你需要按用户 ID 稳定生成、需要做颜色替换、需要在构建时预生成一批静态资源,那就必须用本地生成。另外还有一个隐性因素:接口的地址里带版本号,如果你哪天想统一升级,改 URL 就行;本地依赖则要改package.json,两者都需要评估,但冲击面不一样。
接口调用的几个细节值得记一下。路径形如/9.x/blobs/svg,版本号在路径里,风格名在版本号后面。参数用标准查询串,backgroundColor传十六进制不带#,多个颜色用逗号分隔。flip这类布尔参数传true或false字符串。如果要拿位图,把结尾的svg换成png,但要意识到位图接口是按请求实时渲染的,尺寸越大响应越慢,不建议在列表页里大量使用。
4.2 本地生成的一个可复用封装
本地生成的代码其实很短,难的是把它封装成一个团队里谁都能用的组件。我现在的做法是把所有设计参数收敛到一个常量里,组件只暴露 seed 和尺寸两个变量,这样设计规范改动的时候只改一处。
import { useMemo } from 'react'; import { createAvatar } from '@dicebear/core'; import { blobs } from '@dicebear/collection'; const PRESET = { radius: 50, scale: 110, backgroundColor: ['eef2ff'], backgroundType: 'solid', }; export function BlobAvatar({ seed, size = 48, alt = '' }) { const svg = useMemo( () => createAvatar(blobs, { ...PRESET, seed, size }).toString(), [seed, size] ); return ( <span role="img" aria-label={alt || '用户头像'} style={{ width: size, height: size, display: 'inline-block' }} dangerouslySetInnerHTML={{ __html: svg }} /> ); }dangerouslySetInnerHTML这个名字看着吓人,但这里的内容是本地库生成的、由固定结构和数值组成的 SVG,不涉及用户输入拼接,风险可控。真正需要注意的是seed的来源——如果它来自 URL 参数,最好先做一次白名单过滤或者哈希,别把任意字符串原样塞进去。
useMemo的依赖数组也要留意,我见过有同学只写了seed忘了size,结果切换尺寸时头像不变形,排查了半天。另外这个组件在长列表里会有性能压力,因为每次挂载都要跑一次生成逻辑。列表场景我一般会加一层模块级的Map做缓存,键是seed + size,命中就直接复用字符串。
4.3 服务端批量生成与缓存策略
如果项目里已经有几万个用户,每次请求都实时生成是不现实的,正确做法是离线预生成或者边缘缓存。我做过一个方案是:用户注册时生成一次,存进对象存储,文件名为 seed 的哈希,数据库里只存这个键。之后所有地方都走静态资源,完全绕开生成逻辑。
批量导出的脚本没什么花哨的,一个循环加写文件就够。关键是文件名要用哈希而不是原始 seed,因为 seed 里可能有中文、空格、斜杠,直接当文件名会出问题。下面这个脚本可以直接改成你自己的。
// scripts/gen-avatars.mjs import { mkdir, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { createAvatar } from '@dicebear/core'; import { blobs } from '@dicebear/collection'; const seeds = ['user-1001', 'user-1002', 'user-1003']; await mkdir('out', { recursive: true }); for (const seed of seeds) { const avatar = createAvatar(blobs, { seed, size: 256, radius: 50, scale: 110, backgroundColor: ['eef2ff'], }); const key = createHash('md5').update(seed).digest('hex').slice(0, 12); await writeFile(`out/${key}.svg`, avatar.toString()); }缓存策略上有两个点容易被忽略。第一是缓存键必须把参数算进去,不能只用 seed。我见过一个线上事故,设计调整了scale之后,因为缓存键没变,一半用户看到的还是老图,另一半是新图,视觉上非常割裂。第二是版本号也要进缓存键,这样将来升级库的时候,只要改一个常量,缓存自然失效,不会出现新旧混排。
如果你确实需要位图而不是 SVG(比如要嵌进邮件模板,或者给某些不支持的客户端),那就得用转换工具在服务端转。这个环节一定要把scale参数给足,通常给到 2 到 4 倍再缩下来,边缘才干净。直接按目标像素生成,圆润的斑块边缘会出现明显的锯齿,尤其在 40 像素这种小尺寸上特别明显。
5. 常见问题与排查实录
5.1 一张速查表覆盖八成问题
下面这些是我和同事在几个项目里真实遇到过的,按“症状—原因—处理”整理成表,遇到问题先扫一眼,能省不少时间。
| 症状 | 大概率原因 | 处理办法 |
|---|---|---|
| 背景色完全没生效 | 色值带了# | 去掉井号,只留六位 |
| 背景变成了彩虹渐变 | 在渐变模式下给了多个颜色 | 渐变色标控制在两个 |
| 同一个人头像过段时间变了 | 依赖版本被自动升级 | 锁定版本号 |
| 页面里多个 SVG 互相干扰 | 内部 id 重复 | 打开randomizeIds |
| 头像被裁掉一块 | scale太大或位移过多 | 降scale或回正位移 |
| 圆角变圆了但图形贴边 | radius: 50后画布变小 | 配合scale收一点 |
| 转成图片后边缘发虚 | 生成尺寸不足 | 提高导出倍率 |
| 深色下头像发灰 | 斑块与背景明度接近 | 提亮或换深色底 |
| data URI 图片裂开 | 内部#未编码 | 用标准编码函数处理 |
那个randomizeIds的问题值得展开说一下。Blobs 生成的 SVG 里带内部 id,同一页面上如果有十几个头像,id 就重复了。平时的纯静态展示看不出问题,但一旦你用了渐变(背景类型非solid),不同头像的渐变定义可能互相串,表现出来就是某几个头像的背景突然变成别人的颜色。这个 bug 特别隐蔽,因为它是概率性的,跟渲染顺序有关。打开randomizeIds之后每个 SVG 的 id 带随机后缀,问题就消失了。
5.2 文档里不会写的几个实操心得
第一个心得跟 seed 的清洗有关。很多人喜欢直接用用户昵称当 seed,中文昵称问题不大,但如果昵称里有空格、emoji、或者特殊符号,在不同环境下可能产生编码差异,导致同一个人在不同端看到的头像不一样。我现在的做法是统一先做一次哈希再当 seed 用,比如md5(userId).slice(0, 10),这样得到的种子稳定、安全、长度可控,也不会有任何编码问题。这个改动的成本极低,但能避免一类很难排查的诡异现象。
第二个心得跟视觉一致性有关。Blobs 在不同 seed 下,斑块占画布的比例差别其实不小,有的 seed 出来的图形几乎撑满画布,有的则缩在中间一小块。混在一起展示的时候,会有一种“有的头像大、有的头像小”的错觉。解决办法是给容器统一加背景色和圆角,用容器来规范视觉边界,而不是靠图形的自然边界。这一招我在两个项目里都用过,效果立竿见影。
第三个心得是别过度定制。我最初接手这个需求的时候,花了整整两天调整参数,试图让每一个头像都“恰到好处”。后来发现,默认头像的本质是“不惹眼但有辨识度”,用户真正会盯着看的时间不超过半秒。把时间投在配色体系、尺寸响应、缓存策略这些工程问题上,收益远大于抠那 5% 的缩放。
我在实际项目里的体会是,Blobs 这类抽象头像真正的价值不在于“好看”,而在于它把一件容易引争议的事情变得没有争议——没有人会因为你给他分配了一个斑块而觉得被冒犯,也没有人会去讨论这个头像“像不像自己”。它就是一个安静的、稳定的、可计算的标识符。把它接进系统的时候,把版本锁死、把种子哈希、把缓存键算全,剩下的交给它自己发挥就行。