我一直很喜欢 Microsoft Fluent Emoji。

倒不是因为它比系统 emoji 更「正确」,而是它有一种很微妙的产品感:柔和、统一、立体,但又不会像某些贴纸包一样过分抢戏。放在文章里,尤其是中文文章里,它经常能让一个句子变得更轻一点。

但问题也在这里。

如果只是偶尔写一个 🙂,系统 emoji 已经够用了。真正让我想做 rehype-fluent-emoji 的原因,是我想在 MDX 文章里稳定地使用 Fluent Emoji,同时保留文字本身应该有的能力。

比如可以复制。

比如不会破坏行高。

比如代码块里的 emoji 不应该被替换。

比如构建产物不要突然藏着一堆运行时副作用。

这些听起来都很小,但如果你真的在一篇文章里把 emoji 当作文字的一部分使用,这些小事会一起决定它到底是一个增强,还是一个麻烦。

不只是换成图片

最粗暴的方式当然很简单:扫描文本,把 emoji 换成 <img>

这样视觉上很快就能看到效果,图片也能加载出来,但它立刻会带来几个问题。

首先,复制出来的内容不再自然。你选中一段文字,本来应该得到原本的 Unicode emoji,结果中间混进图片节点,体验就变得很奇怪。

其次,语义也会变得暧昧。emoji 本来是文本的一部分,不一定是插图,也不一定需要被读屏器当成一张图片读出来。

再其次,样式很容易失控。文章里的图片通常会被统一加上圆角、最大宽度、懒加载、灯箱等等处理。如果 emoji 也变成普通图片,它就很容易被这些文章图片规则误伤。

所以这个插件做的事情不是「把 emoji 变成图片」,而是更具体一点:

保留 Unicode emoji 作为文本层,再在上面放一层 Fluent Emoji 的视觉层。

这样复制时拿到的仍然是真正的 emoji,阅读时看到的是统一的 Fluent 风格。文字和视觉各做各的事,边界会清楚很多。

rehype 适合做这件事

我一开始就不太想把它做成客户端脚本。

文章已经在构建时经过 MDX、remark、rehype 的管线了,emoji 替换这种事天然适合放在那里完成。HTML 生成出来之后,浏览器只需要正常渲染,不需要再等一段 JavaScript 扫描 DOM。

这也符合我对内容站的偏好:能在构建时完成的,就不要拖到客户端。

如果你想直接试一下,可以去 GitHub 看源码,也可以去 npmx.dev 看包信息和安装方式。最直接的安装命令是:

Terminal
pnpm add -D rehype-fluent-emoji

rehype-fluent-emoji 做的事大概是这样:

import rehypeFluentEmoji from 'rehype-fluent-emoji'
export default {
markdown: {
rehypePlugins: [
[
rehypeFluentEmoji,
{
assetBase: '/emoji',
style: '3d',
},
],
],
},
}

它会在 HAST 里找出普通文本节点中的 emoji,并跳过 codeprescriptstyle 这些不应该被改写的路径。

最后生成出来的结构大致是一个带有文本层和视觉层的 span。你看见的是 Fluent Emoji,但 DOM 里仍然保留原本的 Unicode 字符。

这也是它和很多「emoji 转图片」方案最大的差别。

复制这件小事

复制行为是我最在意的部分。

因为 emoji 在文章里不是装饰素材,它是文字。一个人复制我文章里的一句话,粘贴到别处后,应该得到同一句话,而不是丢掉里面的表情。

这件事做到最后会有点绕。

如果直接把 Unicode emoji 设置成透明,再用背景图显示 Fluent Emoji,普通状态看起来没问题,但选中文本时又会暴露出浏览器自己的选区行为。尤其是不同浏览器对 ::selection、透明文字、背景图的处理并不完全一致。

最后比较稳的方式,是把根元素、文本层、视觉层都分开:

  • 文本层保留 Unicode,用来参与选择和复制。
  • 视觉层负责显示 Fluent Emoji。
  • 选中状态通过共享 CSS 继续让 Unicode 字形保持透明,但不抢页面自己的选区背景。

这听起来像是在给一个小表情做过度设计,但我觉得这恰好是插件应该做的事。

一个好的内容插件不应该只在 demo 里好看,也应该在复制、选择、阅读、搜索这些普通动作里不露馅。

资产也应该有边界

另一个我很在意的点,是资产下载。

Fluent Emoji 的图片总得从某个地方来。最方便的做法,是插件运行时发现缺什么就下载什么,甚至自动写入 public/emoji

但我不喜欢这个方向。

因为 rehype 插件本质上应该负责转换内容,而不是在你构建文章时悄悄联网、写文件、改变项目目录。尤其是在 CI、SSR、边缘环境这些场景里,隐式副作用会很烦。

所以现在的边界更清楚:

Terminal
rehype-fluent-emoji sync content --out public/emoji --style 3d

显式执行同步命令,把文章里用到的 emoji 资产下载到指定目录。

然后渲染时只需要告诉插件:

{
assetBase: '/emoji'
}

也就是说,插件只负责生成指向资产的 URL。资产在哪里、什么时候同步、是否放 CDN,都交给使用者自己决定。

这种 API 看起来少了一点「自动」,但我更喜欢。它不神秘,也不替你做多余的决定。

资源仓库也被重做了

说到资产,其实这里还有另一层工作。

这个插件最早可以直接使用 shuding/fluentui-emoji-unicode 整理好的资源。那个项目把 Microsoft Fluent Emoji 从 CLDR 文件夹名整理成扁平 CDN 资源,同时支持两种 URL:

👋_color.svg
1f44b_color.svg

也就是说,你既可以用 emoji 字形做文件名,也可以用 Unicode 码点做文件名。

但后来我在自己的 withxat/fluentui-emoji-unicode fork 里开了一个 webp 分支,把它从「整理资源」继续往前推了一步,变成一个更适合长期使用的 WebP CDN 仓库。

这里的取舍很明确。

首先,只保留 Unicode 码点 URL:

1f44b_3d.webp

不再生成 👋_color.svg 这类 emoji 字形路径。

因为 URL 应该尽量可预测。emoji 放在 URL 里虽然很可爱,但不同客户端、CDN、复制环境对 Unicode 路径的处理并不总是一致。码点命名更无聊,但也更稳定。

其次,所有资源都统一成 WebP。原本 Fluent Emoji 里会混用 PNG 和 SVG,不同风格对应不同格式。现在构建时会用 sharp 把 PNG 和 SVG 都转成 .webp,SVG 会先以 512px、density: 300 栅格化,再用质量 90 输出 WebP。

最后生成的大约是 11,030 个 Unicode-only WebP 文件。

这里还有一个小细节:文件名里会剥掉 fe0f,也就是 Variation Selector-16。

比如:

1f44b fe0f -> 1f44b
1f3f3 fe0f 200d 26a7 fe0f -> 1f3f3-200d-26a7

fe0f 用来要求 emoji 呈现形式,但对资源识别来说通常是噪音。去掉它之后,1f44b1f44b fe0f 会稳定落到同一个 URL 上。

这个 fork 还把上游 microsoft/fluentui-emoji 放进了 vendor/fluentui-emoji submodule,构建脚本也从 Node 脚本换成了 Bun + TypeScript。半年后要重新生成一次资源时,不需要再回忆「先 clone 哪个仓库、复制哪个脚本、再跑哪个命令」,只要 git submodule update --init,然后 bun run build

工具链听起来不像产品功能,但它决定了这个资源仓库以后还能不能维护下去。

README 里也同时给了两个 CDN 入口:

  • jsDelivr:cdn.jsdelivr.net/gh/withxat/fluentui-emoji-unicode@webp/assets/...
  • Raw GitHub:raw.githubusercontent.com/withxat/fluentui-emoji-unicode/webp/assets/...

前者适合平时走 CDN,后者适合想直接指向 GitHub 原始文件的场景。

技术链路大概就是这样:

microsoft/fluentui-emoji (submodule)
-> metadata.json -> Unicode 码点
-> sharp (PNG/SVG -> WebP)
-> 过滤 fe0f,只保留码点文件名
assets/{unicode}_{style}.webp
-> jsDelivr / GitHub Raw CDN

所以这件事不只是「把图片换成 WebP」。更准确地说,是把资源仓库从「整理文件」变成了「定义稳定的 CDN 契约」。

颜色、Safari 和现实世界

Fluent Emoji 有不同风格,常见的是 colorflathigh-contrast3d

一开始我当然更想用 color SVG,毕竟 SVG 听起来很优雅。但真实浏览器会提醒你:优雅不代表一定稳。

在 Safari 里,部分 Fluent Emoji 的 color SVG 看起来会发虚。排查后发现,这和 SVG 内部比较复杂的滤镜渲染有关,不是简单调一下 displaybackground-size 就能完全解决。

最后我在自己的站点里用了 3d 风格的 WebP。

这不是因为 WebP 更「高级」,而是它在这个场景里更可靠。文章里的 emoji 很小,读者也不会把它当成矢量插画放大看。相比理论上的格式优雅,我更关心它在真实页面里是不是清楚、稳定、体积小,而且别抢戏。

工程里经常会遇到这种选择:你原本以为自己在选一个技术上更漂亮的答案,最后发现真正好的答案是那个少出问题的答案。

在这个站点里的用法

现在这个站点的文章构建会先同步 emoji 资产:

{
"emoji:sync": "rehype-fluent-emoji sync content --out public/emoji --style 3d"
}

然后 MDX 管线里使用 rehype-fluent-emoji 渲染文章里的 emoji。

我还写了一篇很普通的 Fluent Emoji 渲染测试,专门看它在中文段落、列表、链接、代码块里的表现。它不是功能介绍,更像是一个「别把文章搞坏」的巡检页。

如果一颗 emoji 能安静地待在句子里,既不撑开行高,也不污染复制结果,还能在不同浏览器里保持稳定,那它就完成任务了。

Outro

这个插件本身并不复杂,但它挺能代表我喜欢的一类工程问题。

表面上只是「把 emoji 变漂亮」,真正做起来却会碰到语义、复制、选择、构建副作用、资产管理、浏览器渲染差异这些细节。

而我喜欢的解法也很明确:视觉上可以更有趣,底层行为要尽量普通。

普通不是坏事。

一个 emoji 插件最理想的状态,就是你看到它时觉得可爱,复制它时什么都没有坏掉,删除它时文章还是那篇文章。