Skip to content

How we optimized package imports in Next.js

A Note to Our Readers

While maintaining fidelity to the source material, this translation incorporates explanatory content and localized expressions, informed by the translator's domain expertise. These thoughtful additions aim to enhance readers' comprehension of the original text's core messages. For any inquiries about the content, we welcome you to engage in the discussion section or consult the source text for reference.

性能提升:冷启动快40%,构建速度快28%

在最新版本的 Next.js 中,我们对包导入进行了优化,本地开发的性能和生产环境下的冷启动速度都有显著提升,特别是在使用大型图标或组件库以及其他包含数百甚至数千个模块重导出的依赖项时,显著更为明显。本文将解释为什么需要这项改进,我们是如何迭代到当前解决方案的,以及我们看到了哪些性能提升。

What is a barrel file?

javascript 中,桶文件是一种将多个模块从单个文件中分组和导出的方法。它通过提供一个集中的访问位置,使得导入这些分组模块变得更加容易。例如,假设我们在 utils/ 目录中有三个模块(module1.jsmodule2.jsmodule3.js)。我们可以在同一目录(utils/)中创建一个名为 index.js 的桶文件,index.js 文件作为对外的入口文件,重新导出(暴露) module1module2module3 三个内部模块。

js
export { default as module1 } from './module1';
export { default as module2 } from './module2';
export { default as module3 } from './module3';

现在,对于消费 utils 模块的开发者来说,不需要像以下方式单独导入每个模块:

js
import module1 from './utils/module1';
import module2 from './utils/module2';
import module3 from './utils/module3';

我们可以使用桶文件集中导入所有模块,无需了解桶文件 内部的结构

js
import { module1, module2, module3 } from './utils';

桶文件可以通过提供相关模块的易用接口,改善代码组织可维护性。因此,它在 javascript 包中被广泛使用,尤其是在图标和组件库,同时一些广受使用的图标库和组件库的入口桶文件中会包含多达 10,000 个重导出。

What's the problem with barrel files?

javascript 运行时中,每一个 require(...)import '...' 都有隐藏的性能开销。如果你只想使用桶文件中的一个导出,而该桶文件中导入了数千个其他模块的引用,你可能并不需要这些不相关的依赖引用。不幸的是,你仍然需要支付导入所有未使用模块的性能代价(编译优化)。

对于许多流行的 react 包来说,仅仅导入它们就需要 200~800 毫秒。在 极端情况 下,甚至可能需要几秒钟,这会严重影响到 本地开发的效率构建性能。尤其是在 serverless 环境中,每次启动应用程序时,都需要重新导入所有内容。

Can’t we tree-shake it?

tree shake 是打包工具(如 webpackrollupparcelesbuild 等)的特性,而非 javascript 运行时特性。如果库被标记为 外部依赖,它就像一个黑盒。打包工具无法对该黑盒内部进行优化,因为依赖项将在运行时被要求。如果选择将库与应用程序代码一起打包,只要导入没有副作用(package.json 中的 sideEffects),树摇就会生效。然而,这将花费更多时间来编译所有模块,分析整个模块图,然后进行树摇。这会导致构建明显变慢。

Our First Attempt: modularizeImports

我们在 Next.js 中尝试的第一种方法是 modularizeImports。该选项允许你配置导出名称与其被桶文件隐藏的原始模块路径之间的映射关系。

例如,如果包 my-libbarrel 文件 index.js 如下:

js
export { default as module1 } from './module1';
export { default as module2 } from './module2';
export { default as module3 } from './module3';

我们可以配置编译器 my-lib/,作用是用来告知 Next.js,需要将用户的导入 import { module2 } from 'my-lib' 在编译阶段重写为 import module2 from 'my-lib/module2'

这意味着我们可以绕过桶文件,直接在消费者模块中导出目标模块,而不需要加载不必要的模块。这种方法使构建时间和运行时都变快了。

然而,这种配置需要用户了解库的内部目录结构,并大量手动配置。面对数百万个版本各异的 npm 包,这种解决方案对用户的心智成本太高,无法高效扩展。同时如果打包工具为流行库提供默认配置而不锁定库的版本,当库的内部结构将来发生变化时,这种导入转换将变得无效。我们需要一个更好的解决方案。

New Solution: optimizePackageImports

为了解决配置 modularizeImports 选项的痛点,我们在 Next.js 13.5 中引入了一个新的 optimizePackageImports 选项,减低用户的心智成本,实现自动化优化。

首先,你可以配置要优化的包:

js
module.exports = {
  experimental: {
    optimizePackageImports: ['my-lib']
  }
};

启用此选项后,Next.js 将分析 my-lib 的入口文件,判断是否为桶文件。如果是,它将分析入口模块,并自动处理 所有导入引用原始模块路径 之间的 映射关系,类似于 modularizeImports 的工作方式。

这个过程比树摇更加高效,因为它只需对入口桶文件进行一次扫描。它还可以递归 处理 嵌套的桶文件通配符导出export * from),并在遇到 非桶文件时停止处理

由于这个新选项不依赖于包的内部实现,我们 预配置一个常用库列表,可以立即受益,例如 lucide-react@headlessui/react

未来,我们正在探索自动判断哪些包应该被选择加入的方法。目前,随着社区和我们的团队发现更多待优化的包,这个列表将不断扩展。

Measuring Performance Improvements

我们在本地开发速度、生产构建时间以及冷启动方面都看到了显著改进。

Local Development

M2 MacBook Air 上的本地基准测试中,使用最流行的图标或组件库时,我们看到了 15% ~ 70% 的开发时间上的提升:

  • @mui/material: 7.1s (2225 modules) -> 2.9s (735 modules) (-4.2s)

  • recharts: 5.1s (1485 modules) -> 3.9s (1317 modules) (-1.2s)

  • @material-ui/core: 6.3s (1304 modules) -> 4.4s (596 modules) (-1.9s)

  • react-use: 5.3s (607 modules) -> 4.4s (337 modules) (-0.9s)

  • lucide-react: 5.8s (1583 modules) -> 3s (333 modules) (-2.8s)

  • @material-ui/icons: 10.2s (11738 modules) -> 2.9s (632 modules) (-7.3s)

  • @tabler/icons-react: 4.5s (4998 modules) -> 3.9s (349 modules) (-0.6s)

  • rxjs: 4.3s (770 modules) -> 3.3s (359 modules) (-1.0s)

这些时间节省不仅体现在本地开发的初始启动上,还影响热模块替换(HMR)的速度,使本地开发感觉更加流畅。如果使用多个有大量子模块的库,这些数字会迅速累加。

Production Builds

在使用 lucide-react@headlessui/reactNext.js App Router 页面基准测试中,在 M2 MacBook Air 上,由于不再需要进行 模块解析tree shakenext build 运行速度提高了约 28%

Faster Cold Boots

在本地环境中,当渲染一个使用 lucide-react@headlessui/react 的简单路由时,Node.js 服务器启动速度提高了约 10%

Vercel 等无服务器环境中,这减少了 部署代码大小Node.js require 调用次数。结合 Next.js 13.5 中的其他改进,我们测量到冷启动速度最高提升 40%

Recursive Barrel Files

我们做的 最后一次优化 是处理递归桶文件,将它们优化为单个模块。为了测试,我们创建了一个包含 4 层、总共 10export * 表达式的 模块,总计 410(10,000) 个模块。

先前编译这个递归 barrel 包需要约 30 秒。优化之后,只需约 7 秒。一些拥有超过 100,000模块 看到了 90% 更快的重载。

Conclusion

我们建议升级到最新版本的 Next.js,在 本地开发速度生产冷启动 方面可以看到显著的性能提升。你还可以考虑添加这个 ESLint 规则,以防止导入桶文件。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)