在 Astro 构建期将 emoji 换成 Fluent Emoji
同一个 ,在不同设备不同操作系统上长得不一样。
为了稳定的页面设计,我想让它们统一为圆润可爱的 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 除了难以满足设计预期,还实质上增加了交流中的摩擦:不同设备的人看到不同的图案会理解为不同的意思。虽然经常上网冲浪的我们早已完全掌握这套语言,但不同文化不同国家的人还是很难互相理解。