Skip to content
博客next-intl 的预编译(Ahead-of-time compilation)

next-intl 的预编译(Ahead-of-time compilation)

2026年1月28日 · 作者 Jan Amann

简要说明: 只需在你的 next.config.ts 中切换一个 precompile 标志,就能立刻减少约 9KB 压缩后的 JavaScript 包大小,同时提升应用的运行时性能。

现在,向一个应用添加 next-intl 并用单一客户端翻译的代价约为~4KB 压缩后的 JavaScript。

我们是如何走到这里的

当 React Server Components 宣布时,我非常激动。

我立刻看到了 next-intl 的潜力,因为它可以通过将更多的工作转移到服务器端来提升用户的性能表现——使客户端解析和执行的 JavaScript 量减少。

但我也得承认,我有些过于兴奋了。对于高度交互的应用来说,这从来都不是一个真正可行的选项,而当我在处理对性能极其敏感的应用时,我几乎会固执地避免在客户端进行翻译。

虽然这种做法能取得不错的效果,但通常需要借助一些技巧,比如大量使用甜甜圈组件(donut components)和其他复杂的模式。

这几乎像在玩“地板是熔岩”的游戏,想尽办法避免让客户端处理翻译,最终避免将 ICU 解析器打包发送给客户端。我知道你们中有人也这么做过。

不同的视角

大约三年前,我和 Jan Nicklas 开启了一场关于如何最优利用新的 Server Components 范式来做国际化的讨论。

在某个时刻,他和我分享了他想在原型中实现的一个想法:icu-to-json,这是一个全新的思路,用于从运行时剥离 ICU 解析器。我一直想把它集成到 next-intl 中,但很长一段时间,这在架构上看起来是一个很大的挑战。

不过,大约两个月前,我发布了 useExtracted 的第一个实现,它恰好提供了最终实现这个功能所需的基础设施。

我为处理 .po 文件以及后续支持的自定义格式实现的 loader,看起来非常适合在构建时预编译消息。

于是,工作就开始了。

预编译的挑战

当编译一条简单的消息,如 Hello {name}!,你可能得到如下的 AST 结构:

[
  {"type": 0, "value": "Hello "},
  {"type": 1, "value": "name"},
  {"type": 0, "value": "!"}
]

这里,0 表示字符串节点,1 表示参数。

虽然编译工作已经完成,但 AST 数据结构的大小远大于我们最初的消息文本。因此,尽管这能避免运行时的解析工作,却增加了包的体积。


另一种方法是将消息编译成函数模块,像这样:

messages/en.js
function hello(name) {
  return `Hello ${name}!`;
}

但问题是,这类函数不能跨越 RSC 桥传递给客户端组件。采用这种方式的库不得不将生成的函数直接导入组件中,以避免跨桥传递。

这反过来导致所有基于 locale 的消息变体都会被打包进你的代码,失去了按语言拆分消息的可能性。

next-intl 被像 Ethereum.org 这样使用 67 种语言的网站采用,所以基于函数的方式对我们来说根本不是选项。

精简的 AST

从架构上讲,预编译的 AST 更适合 next-intl——但它们太大了。

Jan Nicklas 原型的核心思路是对 AST 进行压缩,主要避免使用对象属性,转而用带有位置项的数组。我采纳了他的想法,并进一步压缩了体积。

它看起来是这样:

compile('Hello {name}!');

… 变为:

["Hello ", ["name"], "!"]

然后我们可以用一个极简运行时对这个 AST 进行求值:

// "Hello World!"
format(compiled, 'en', {name: 'World'});

最棒的是,这对纯字符串没有任何开销:

"Welcome!" → "Welcome!"

根据我的经验,这类纯字符串通常占据应用大部分消息的比例,所以这是一个非常重要的优化。

同时,这个方法也支持更复杂的消息和所有 next-intl 支持的 ICU 特性:

compile(
  'You have {count, plural, =0 {no followers yet} one {one follower} other {# followers}}.'
);

… 变成:

["You have ", ["count", 2, {
  "=0": "no followers yet",
  "one": "one follower",
  "other": [0, " followers"]
}], "."]

当然,也支持富文本:

compile('Hello <b>World</b>');

… 变成:

["Hello ", ["b", "World"]]

极简运行时

那么这个 format 函数究竟是什么?

format(compiled, 'en', {count: 2});

它是一个极简运行时,可以高效地计算优化后的 AST,调用原生的 Intl API 来格式化日期、数字等。

而这部分代码的大小约为~650 字节(压缩后)。

所以如果你切换到预编译,这会立即减少约 9KB 压缩后的 JavaScript 体积,服务器和客户端的包都能减小。使用带有单一客户端翻译的 next-intl,整体成本缩减到约 4KB 压缩后。

另外,消息格式化的速度也会大幅提升,因为运行时不再需要进行解析,评估优化后的 AST 开销极小。

立即开启预编译

好消息是,这一切都内建在 next-intl 中,无需学习新 API。

只需开启一个开关:

next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
 
const withNextIntl = createNextIntlPlugin({
  experimental: {
    messages: {
      path: './messages',
      locales: 'infer',
      format: 'json',
 
      // 启用预编译
      precompile: true
    }
  }
});
 
export default withNextIntl();

如果你还没接触过 messages 配置项,它之前是随 useExtracted 发布的,现在预编译也用得上它。

就这些。你现有对 useTranslationsuseExtracted 的调用将自动受益于此优化,无需改动代码。

但有一点需要注意

需要指出的是,预编译不支持 t.raw

因为消息在构建时已被解析,编译时无法判断你是否会在运行时调用 t.raw,所以预编译消息不支持这个特性。

不过,请稍作回顾。

历史上,t.raw 是用来支持消息中的原始 HTML 内容。不过事实证明,对于长文本内容这很难用,且有更好的替代方案:

  1. 本地内容使用 MDX:比如版权声明页和类似页面,将本地化内容组织到如 content.en.mdxcontent.es.mdx 文件中,管理起来要简单得多。
  2. 远程内容使用 CMS:内容管理系统通常包含一种可移植格式,可以以与 HTML 解耦的方式表达富文本,方便你将内容用于移动端等多种场景(参考例如 Sanity 的 Portable Text)。

另一种传统上t.raw被(滥)用的场景是处理消息数组。推荐的模式一直是为每条字符串使用单独消息(参见消息数组)。此模式还有个好处是可以被静态分析支持。

相关地,新引入的 useExtracted 也不支持 t.raw,因为它本身就不符合这套范式。

因此,建议如果想用上预编译功能,迁移到上述替代方案。如果你严重依赖 t.raw ,当然也可以暂时关闭此优化。

未来某个版本中,t.raw 可能会因这个 API 的缺点而被彻底弃用——但这一点还在讨论中。

准备好试试了吗?

如果你也对预编译感兴趣,我非常希望听到你的反馈。

在你的项目内用 next-intl@4.8 试试,然后通过反馈告诉我。请注意该功能目前处于实验阶段,可能会有变动。

最后一点,next-intl 性能优化的终极目标是实现消息的自动摇树优化(tree shaking)。希望今年稍晚能分享更多相关进展!

— Jan

PS:如果你对更多技术细节感兴趣,我写过一篇RFC,详细描述了该功能的设计决策和权衡。


Let’s keep in touch: