安装画框,而不是照片。
图标需要一个框架来承载,以及一张图片来展示。`@web-portfolio/icons` 和 `@web-portfolio/icons-sanity` 的存在是为了让开发者只需负责加载框架部分,而图片的来源则可以由其他地方决定。

这最初是一个问题,而不是一个计划:图标应该是内容还是代码?作品集的技能列表的变化频率比其组件要高——一项新的技能意味着一个新的图标,因此,将图标视为 Sanity 可以存储的内容,并且可以在不重新部署的情况下进行编辑,似乎是显而易见的选择。但事实并非如此。
“图标作为内容”的实际应用结果。
第一个版本将图标以原始SVG代码的形式存储在纯文本字段中,因为这是“图标作为内容”的显而易见的方式:编辑器粘贴内容,前端将其渲染。但这也正是它不再显而易见的地方。
每个粘贴进来的图标都来自不同的地方——有的带有清晰的双色路径,有的包含预设的填充颜色以及一个嵌套的`<g>`标签,其中充满了编辑器无法看到或删除的内联样式。有些图标具有24x24的视口大小,有些是512x512,还有一些根本没有指定视口。因此,不存在一种统一的形状可以用来构建组件。
因此,该组件试图强制执行某些属性,例如大小、颜色或描边,而正是这些因素导致了不一致性,而不是改善。 `currentColor` 只能在原始代码中没有硬编码填充值的情况下才能生效,并且即使设置了自定义的 `stroke-width`,一旦图标本身设置了自己的值,该设置也无法生效。 每个新复制粘贴的图标都需要单独的处理方式,这完全违背了内容字段的设计初衷,即不应需要任何代码修改。
一个真正的内容字段,不应该每次被使用时都需要修改代码。
在其底层,真正危险的部分在于:该字段使用了`dangerouslySetInnerHTML`方法进行渲染,直接将粘贴的内容注入到DOM中——这不仅存在格式问题,更是一个真正的安全风险。试图通过修改来规避这个问题,只会不断增加代码量,而没有触及根本问题:该字段的值仍然只是用户粘贴的内容。
仅通过代码修改无法解决问题。
即使采用其他开发者处理图标的方式,也无法解决内容显示的问题。字体图标将框架和图片绑定到一个类名中(例如:`<i class="fa fa-docker">`),一个简单的拼写错误就会导致图像不显示。精灵图也是类似的做法,但通过 ID 来实现。内联 SVG 组件的集成最为困难:图片被嵌入到导入图中,因此没有注册表,也没有任何字段。虽然像 lucide-react 这样的第三方库在开发者体验方面做得不错,但 `import { Docker }` 仍然需要修改代码,只是方式更方便。
每种方法都是在代码中编写图标的合理方式。 但它们都不能在不修改代码的情况下改变显示的图片,而这正是最初的目的。
安装它:一个简单的相框,只为一张照片而生。
@web-portfolio/icons 组件解决了这个问题。`<Icon>` 是一个框架,它的 `name` 属性从一个由 devicon、Material Symbols 和 Simple Icons 通过 SVGO 进行整合构建的注册包中获取图片。 `<Icon>` 无法区分字面量和变量,因此相同的调用方式适用于两者。
import { Icon } from '@web-portfolio/icons'
<Icon name="docker" size={32} /> // a literal picture
<Icon name={skill.icon} size={32} /> // a picture from a CMS field`@web-portfolio/icons-sanity` 所需的启动方式是:一张图片仅仅是一个字符串,因此它可以由编辑人员选择,而不是由开发人员手动输入。它的 `iconRef` schema 类型将一个字段转换为 Sanity Studio 中的可搜索网格——`IconPickerInput` 仅会将它在同一个注册表中找到的图片写回。开发人员的工作从来不是选择图片,而是负责搭建框架。
同时,我们也应该考虑成本:由于两个软件包都包含相同的注册表,从 Sanity 发送到前端的唯一数据就是那个字符串——平均约为 7 个字节,而普通图标的实际标记大约为 955 个字节(对于复杂的徽标,甚至可能达到几 KB)。相比之下,将原始 SVG 保存在 CMS 字段中(这是该插件存在的目的),每次获取都会增加 130-285 倍的成本。图标字体和精灵图可以通过引用名称来避免这个问题,而内联 SVG 组件和基于导入的库则无法做到这一点,除非首先构建与此软件包相同的精确注册表。
相同的修改解决了常见问题:在设计部门批准图标之前就已发布。今天,将最接近的图片挂载到名为 "link" 的位置,当真正的标识正式发布后,只需更改该值即可,无需重新导入,也不需要创建新的组件。或者,将其通过 CMS 字段进行路由,这样负责相关内容的团队(例如设计、市场部门或本周负责活动的团队)可以直接在 Studio 中自行替换图片,而无需进行任何代码修改。
占位符图片与新功能一同发布。后续的更改仅限于图片的替换。
它真正胜出的地方。
按照相同的四个评估标准,将其分为三个问题:编写它的体验如何?运行时需要多少资源?当图标需要更改时,如果修改者不是开发人员会发生什么?
Writing it
icons + icons-sanity | @web-portfolio/icons | Third-party library | Inline SVG | SVG sprite | Icon font | |
|---|---|---|---|---|---|---|
| Adding an icon in code | 相同的调用,字符串可以来自一个字段。 | <Icon name="docker"/> — 一个属性。 | 导入 {Docker},编辑器自动补全。 | 每个图标对应一个新组件文件,通过复制粘贴创建。 | #docker - 与盲打存在相同风险。 | fa-docker:未检查该字形是否存在。 |
| Type safety / autocomplete | Picker 只会生成真正的密钥,这是 Studio 的承诺,而不是一种类型。 | 名称是一个简单的字符串,只有输入错误才会触发警告。 | 命名导出 — 一个拼写错误导致构建失败。 | 每个图标都是一个独立的、具有名称的导入。 | 一个ID字符串,存在相同的问题。 | 一个类名字符串,但没有检查它是否存在。 |
| Styling — color, stroke, size | 相同的属性,相同的组件。 | `<Icon>` 组件的尺寸、颜色和描边属性。 | 支持设置尺寸、颜色和描边宽度的属性。 | 它的准确性取决于谁粘贴的。 | `fill: currentColor` 的用法很简洁。 | 颜色是继承的,而字体的粗细是固定的。 |
Running it
icons + icons-sanity | @web-portfolio/icons | Third-party library | Inline SVG | SVG sprite | Icon font | |
|---|---|---|---|---|---|---|
| Bundle impact | 与单独购买图标的价格相同。 | 一个注册表,包含所有633个图标。 | 每个图标消耗树的资源。 | 仅限进口图标产品发货。 | 将所有图标都放在一张图表中。 | 无论如何,整个字体文件都会被加载。 |
| Runtime selection | 同样,通过选择器。 | 无需接线。 | 自行构建地图。 | 需要一个手动名称映射表。 | 已进行身份验证。 | 已使用字符串作为键。 |
Living with it
icons + icons-sanity | @web-portfolio/icons | Third-party library | Inline SVG | SVG sprite | Icon font | |
|---|---|---|---|---|---|---|
| Selecting from dynamic data | 内容更新时无需重新部署,保持系统稳定运行。 | 名称的来源可以来自任何地方,但没有任何方法能让你完美地确定它。 | 相同之处在于,每个图标都是一个组件,而不是一个查找键。 | 该图标是一个特定的导入,而不是可以随意替换的值。 | 相同 - 一个不带选择器的 ID 字符串。 | 一个字符串——它不会显示任何选择器。 |
| Non-developer control | IconPickerInput 组件已经内置了选择器功能。 | 没有可编辑的区域,但开发者仍在修改属性。 | 是的,这是一个代码级别的导入。 | 更改图标意味着修改代码。 | 仍然需要有人来开发那个选择器。 | 需要一个手工制作的拾取器,该拾取器能够识别字形名称。 |
| Adding a brand-new icon | 相同的限制:管理员直接粘贴的原始 SVG 代码是唯一的解决方案。 | 初始化源,运行生成注册表命令,重新发布。 | 你只能获得库提供的内容,除此之外没有其他。 | 粘贴SVG代码,完成——无需共享文件进行操作。 | 编辑精灵文件,重新部署。 | 重新生成字体文件,重新构建,重新部署。 |
| Consistency across sources | 相同的注册表项,Studio也标记了一个已过时的值。 | 多个图标集合已整合到一个注册表中。 | 内部设计保持一致,但通常不包含品牌标识。 | 除非有人强制执行,否则不会有任何纪律。 | 它的质量仅取决于制作它的材料。 | 一个家庭,一种视觉语言,通过构建而成。 |
| License & provenance | 相同的归属,未做任何修改。 | 每个来源仅需在注册表和README文件中注明一次。 | 一个许可证适用于整个软件包,通常非常明确。 | 未追踪 — 来源信息会保留在粘贴者那里,如果有人进行了粘贴。 | 通常,一旦图标被复制到一个文件中,跟踪就会停止。 | 无论字体包的许可协议如何规定,通常情况下,价格并非按字形计算。 |
Bundle impact, @web-portfolio/icons row: the ✕ is a client-side cost only. Render <Icon> in a server component and the registry never reaches the browser at all.
权衡取舍,不回避问题。
上面两行描述了真正的缺点:包的大小和类型安全。包的大小是一个问题,因为 `registry.generated.ts` 文件将所有图标都放在一个对象中,而不是分成可以被打包工具精简的独立部分——因此,`<Icon>` 组件会引入全部 633 个图标,即使页面只使用其中一个。一个简单的基于导入的库可以通过放弃运行时选择来避免这个问题,但这违背了将图标存储为内容的目的。解决方案(如上脚注所述):在服务器端渲染 `<Icon>` 组件,这样 `registry` 就不会到达浏览器——大小差异就会消失。这对于一般而言也是一种理想的默认设置,尤其是在现在图标可以作为内容而不是代码的情况下:值只在请求时存在,因此没有理由将其发送到浏览器。以这种方式渲染,`<Icon>` 组件与内联 SVG 和精简的导入具有相同的优势,并且不需要额外的 JavaScript 代码,而优于网络字体(仍然需要自己的文件)和精灵图(将所有内容打包在一起,或者需要单独的请求)。
类型安全也遵循同样的原则。如果手动输入一个名称,它仅仅是文本——任何拼写错误都无法被发现。`icons-sanity`的选取器只允许编辑器选择已存在的名称,但这个保证存在于Studio界面中,而不是底层结构中——因此,仍然可能从Studio外部引入拼写错误。在下一次小版本发布中,计划在结构层面上强制执行类型安全。
您应该在什么情况下使用它。
这适用于基于CMS的项目,例如作品集、机构网站或营销页面,其中“哪个图标”是一个内容选择问题,而不是一个技术问题。如果代码库之外的内容从未被修改,那么直接使用经过精简的库即可:因为图片本身始终保持不变,所以一开始就没有必要构建任何框架。
如果框架安装正确,更换图片就变得非常简单——只需取出一个,再放入另一个。框架始终固定在墙上:无需新的孔洞,也避免了倾斜的风险。构建注册表和选择器的真正价值在于此:`<Icon name>`(图标名称)、注册表以及 `IconPickerInput` 不会再次改变,只会传递不同的名称。
安装画框,将画交给收货人,然后继续处理发货事宜。