在 Astro 构建期将 emoji 换成 Fluent Emoji

约 2 分钟

同一个 🌸,在不同设备不同操作系统上长得不一样。

为了稳定的页面设计,我想让它们统一为圆润可爱的 Fluent Emoji。曾经在用 WordPress 的时候实现过一个动态替换的方案。但换用 Astro 后,这件事必然需要在幕后做好,保持页面中尽可能的 0 JS 依赖。

构建期替换

Markdown 渲染成 HTML 的那一步,把 emoji 字符换成 <img src="/emoji/xxx.svg">。静态站的好处就是不用考虑什么 API,生成的产物都是模板代码的意图,而同样的功能在 WordPress 中直到 2022 年的某个版本才实现了钩子。

按码位命名的素材,不需要映射表

Fluent Emoji 开源的 SVG 文件名就是 Unicode 码位:

1f600.svg        → 😀
2764.svg         → ❤️(变体选择符被去掉了)
1f468-200d-1f469-200d-1f467.svg → 👨‍👩‍👧(组合序列用连字符连接)

这意味着不需要维护任何「字符 → 文件名」的对照表,运行时把字符拆成码位、拼成文件名就能直接找文件。

不过截至目前的 Fluent Emoji 的完成度已经稍显落后了 :没有 ZWJ 家庭 👨‍👩‍👧,没有旗帜,没有肤色变体。而且常见的 👋 👍 直接缺失。这意味着凑不齐的字符必须能优雅 fallback。

实现是三个部分

一、resolver:按需把 SVG 搬进 public/

export function resolveEmoji(char) {
	const cps = [...char].map((c) => c.codePointAt(0).toString(16));

	// 依次尝试:完整码位序列 → 去掉变体选择符 FE0F → 只用首码位
	const candidates = [cps.join('-'), cps.filter((c) => c !== 'fe0f').join('-'), cps[0]].filter(Boolean);

	for (const name of candidates) {
		const dst = path.join(EMOJI_PUBLIC_DIR, `${name}.svg`);
		if (exists(dst)) return name;                 // 已经搬过了
		const src = path.join(EMOJI_SOURCE_DIR, `${name}.svg`);
		if (exists(src)) { fs.copyFileSync(src, dst); return name; }   // 首次遇到,搬一份
	}
	return null;   // 没有素材,调用方保留原字符
}

为什么要有「去掉 FE0F」这一步:❤️ 是 2764 FE0F 两个码位,但素材库里只有 2764.svg。只按完整序列找会全部落空。

为什么要按需拷贝:24MB 全塞进仓库没必要。真正用到的就几个,边用边搬。

二、rehype 插件:处理 Markdown 正文

Astro 的 Markdown 管线是 unified,在 rehype 阶段拿到的是 HTML AST,遍历文本节点,遇到 emoji 就换成一个 <img> 节点:

const EMOJI_CHAR = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u20e3]/u;
const SKIP_TAGS = new Set(['pre', 'code', 'script', 'style', 'textarea']);
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

生成出来的节点长这样:

<img src="/emoji/1f338.svg" alt="🌸" class="emoji" loading="lazy" decoding="async" draggable="false">

三、<Emoji> 组件:处理 .astro 模板

rehype 插件只能看见 Markdown,模板里的字符它是看不到的。所以模板里用一个组件:

---
const name = resolveEmoji(char);
---
{name ? <img class:list={['emoji', className]} src={`/emoji/${name}.svg`} alt={char} {...rest} /> : char}

两个不动手就很难想到的坑

坑 1:\p{Emoji} 会把数字变成 emoji

曾经用 /\p{Emoji}/u 判断,结果文章里每个数字都被当成 emoji 去查素材。因为在 Unicode 里 0-9、#、* 都带 Emoji 属性(它们是 emoji 键盘序列的一部分)。

正确的属性是 \p{Extended_Pictographic},只匹配图形字符。

坑 2:必须按「字素」分段

不能逐字符遍历。👨‍👩‍👧 在码位层面是五个字符,逐字符处理会被拆成三个独立的人。用 Intl.Segmenter 按 grapheme 分段,组合序列才会保持完整:

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
for (const { segment } of segmenter.segment(text)) { /* segment 是一个完整字素 */ }

🥺 关于不统一的 emoji

不统一的 emoji 除了难以满足设计预期,还实质上增加了交流中的摩擦:不同设备的人看到不同的图案会理解为不同的意思。虽然经常上网冲浪的我们早已完全掌握这套语言,但不同文化不同国家的人还是很难互相理解。