前端构建系统阐述[译]


开发人员编写 JavaScript 代码;浏览器执行 JavaScript 代码。从根本上说,在前端开发中并不需要构建步骤。那么,为什么现代前端开发中会有构建步骤呢?

随着前端代码库的不断增长,以及开发人员工效变得越来越重要,直接将 JavaScript 源代码发送给客户端会导致两个主要问题:

1. 不支持的语言特性: 因为 JavaScript 在浏览器中运行,而且市面上有多种版本的浏览器,你使用的每一种语言特性,都会减少能够执行你的 JavaScript 的客户端数量。此外,像 JSX 这样的语言扩展不是有效的 JavaScript,不能在任何浏览器中运行。

2. 性能问题: 浏览器必须单独请求每个 JavaScript 文件。在大型代码库中,这可能导致需要数千个 HTTP 请求来渲染一个页面。在过去,HTTP/2 出现之前,这还会导致数千次 TLS 握手。

此外,可能还需要几次连续的网络往返才能加载完所有的 JavaScript。例如,如果 index.js 导入了 page.js,而 page.js 又导入了 button.js,那么就需要三次连续的网络往返才能完全加载 JavaScript。这被称为瀑布流问题。

源文件也可能因为长变量名和空白缩进字符而变得不必要地大,增加了带宽使用和网络加载时间。

前端构建系统处理源代码,并输出一个或多个为发送到浏览器而优化的 JavaScript 文件。生成的分发版通常对人类来说是难以理解的。

1. 构建步骤

前端构建系统通常包括三个步骤:转译、打包和压缩。

一些应用程序可能不需要所有这三个步骤。例如,较小的代码库可能不需要打包或压缩,开发服务器也可能为了性能而跳过打包和 / 或压缩。也可以添加其他自定义步骤。

一些工具实现了多个构建步骤。特别是打包器通常实现了所有三个步骤,一个打包器单独使用可能就足以构建简单的应用程序。复杂的应用程序可能需要每个构建步骤的专门工具,以提供更大的功能集。

1.1. 转译

转译解决了不支持的语言特性问题,它通过将使用现代 JavaScript 标准编写的 JavaScript 转换为较旧版本的 JavaScript 标准。如今,ES6/ES2015 是一个常见的转译目标。

框架和工具也可能引入转译步骤。例如,JSX 语法必须转译为 JavaScript。如果一个库提供了 Babel 插件,那通常意味着它需要一个转译步骤。此外,像 TypeScript、CoffeeScript 和 Elm 这样的语言必须转译为 JavaScript。

CommonJS 模块(CJS)也必须转译为浏览器兼容的模块系统。自 2018 年浏览器普遍支持 ES6 模块(ESM)以来,通常推荐转译为 ESM。ESM 更容易优化和摇树(tree-shake),因为它的导入和导出是静态定义的。

现在常用的转译器有 Babel、SWC 和 TypeScript 编译器。

  1. Babel(2014 年发布)是标准的转译器:一个用 JavaScript 编写的慢速单线程转译器。许多需要转译的框架和库都通过 Babel 插件来进行转译,这要求 Babel 成为构建过程的一部分。然而,Babel 很难调试,而且常常令人困惑。

  2. SWC(2020 年发布)是一个用 Rust 编写的快速多线程转译器。它声称比 Babel 快 20 倍;因此,它被较新的框架和构建工具所使用。它支持转译 TypeScript 和 JSX。如果你的应用程序不需要 Babel,SWC 是更好的选择。

  3. TypeScript 编译器(tsc)也支持转译 TypeScript 和 JSX。它是 TypeScript 的参考实现,也是唯一的完全功能的 TypeScript 类型检查器。然而,它非常慢。虽然 TypeScript 应用程序必须使用 TypeScript 编译器进行类型检查,但在构建步骤中,使用另一种转译器会更加高效。

如果你的代码是纯 JavaScript 并且使用 ES6 模块,也可以跳过转译步骤。

对于不支持语言特性的一个子集,一个替代解决方案是 polyfill。Polyfills 在运行时执行,实现任何缺失的语言特性,然后再执行主应用程序逻辑。然而,这增加了运行时成本,有些语言特性无法 polyfill。参见 core-js

所有打包器本质上也是转译器,因为它们解析多个 JavaScript 源文件并输出一个新的打包 JavaScript 文件。在这样做的过程中,它们可以选择在输出的 JavaScript 文件中使用哪些语言特性。一些打包器还能够解析 TypeScript 和 JSX 源文件。如果你的应用程序有简单的转译需求,你可能不需要单独的转译器。

1.2. 打包

打包解决了需要进行多次网络请求和瀑布流问题的需求。打包器将多个 JavaScript 源文件合并成一个单一的 JavaScript 输出文件,称为打包文件,而不会改变应用程序的行为。打包文件可以由浏览器在一个单一的往返网络请求中高效加载。

现在常用的打包器有 Webpack、Parcel、Rollup、esbuild 和 Turbopack。

  1. Webpack(2014 年发布)在 2016 年左右获得了显著的流行度,后来成为标准的打包器。与当时常用的 Browserify 不同,Browserify 通常与 Gulp 任务运行器一起使用,Webpack 开创了 “加载器” 的概念,这些加载器在导入时转换源文件,允许 Webpack 协调整个构建流水线。

    加载器允许开发者在 JavaScript 文件中透明地(简单、直接的)导入静态资源,将所有源文件和静态资源合并到一个依赖图中。使用 Gulp 时,每种类型的静态资源都需要作为单独的任务构建。Webpack 还支持开箱即用的代码拆分,简化了其设置和配置。

    Webpack 是慢速且单线程的,用 JavaScript 编写。它高度可配置,但其许多配置选项可能令人困惑。

  2. Rollup(2016 年发布)利用了 ES6 模块的广泛浏览器支持和它所启用的优化,即摇树。它产生的打包文件大小远小于 Webpack,导致 Webpack 后来采用了类似的优化。Rollup 是一个用 JavaScript 编写的单线程打包器,性能略高于 Webpack。

  3. Parcel(2018 年发布)是一个低配置打包器,设计为 “开箱即用”,为构建过程的所有步骤和开发工具需求提供合理的默认配置。它是多线程的,比 Webpack 和 Rollup 快得多。Parcel 2 在后台使用 SWC。

  4. Esbuild(2020 年发布)是一个为并行性和最佳性能而构建的打包器,用 Go 语言编写。它的性能比 Webpack、Rollup 和 Parcel 高出几十倍。Esbuild 实现了基本的转译器功能,同时也包含了一个压缩器。然而,它的功能没有其他打包工具那么丰富,提供的插件 API 较为有限,无法直接修改抽象语法树(AST)。与其使用 esbuild 插件来修改源文件,更常见的做法是在将文件传递给 esbuild 之前先对其进行转换。

  5. Turbopack(2022 年发布)是一个快速的 Rust 打包工具,支持增量重建。这个项目由 Vercel 公司开发,由 Webpack 的创建者领导。它目前处于测试阶段,可以在 Next.js 中选择使用。

如果你的模块非常少或者网络延迟非常低(例如在本地主机上),合理地跳过打包步骤是可行的。一些开发服务器也选择不为开发服务器打包模块。

1.2.1. 代码拆分

默认情况下,客户端 React 应用程序被转换为一个单一的打包文件。对于具有许多页面和功能的大应用程序,打包文件可能非常大,抵消了打包的原始性能优势。

将打包文件分成几个较小的打包文件,或称为代码拆分,解决了这个问题。一种常见的方法是将每个页面拆分成一个单独的打包文件。有了 HTTP/2,共享依赖项也可以被分解到它们自己的打包文件中,以避免重复,几乎没有成本。此外,大型模块可能会拆分成一个单独的打包文件,并按需延迟加载。

代码拆分后,每个打包文件的文件大小大大减少,但现在需要额外的网络往返,可能会重新引入瀑布流问题。代码拆分是一种权衡。

由 Next.js 推广的文件系统路由器优化了代码拆分的权衡。Next.js 为每个页面创建单独的打包文件,只包括该页面导入的代码。加载页面会并行预加载该页面使用的所有打包文件。这优化了打包文件大小,而没有重新引入瀑布流问题。文件系统路由器通过为每个页面创建一个入口点(pages/**/*.jsx),而不是传统客户端 React 应用程序的单个入口点(index.jsx)来实现这一点。

1.2.2. 摇树

一个打包文件(bundle)由多个模块组成,每个模块包含一个或多个导出。通常,一个特定的打包文件只会使用它所导入模块的部分导出。打包工具可以通过一个称为 “树摇”(tree shaking) 的过程来移除模块中未使用的导出。这种方式优化了打包文件的大小,从而改善了加载和解析时间。

摇树依赖于源文件的静态分析,因此当静态分析变得更加具有挑战性时,就会受到阻碍。两个影响摇树的效率主要因素:

1. 模块系统: ES6 模块具有静态导出和导入,而 CommonJS 模块具有动态导出和导入。因此,打包器在摇树 ES6 模块时可以更加积极和高效。

2. 副作用: package.json 文件中的 sideEffects 属性声明一个模块在导入时是否有副作用。当存在副作用时,由于静态分析的限制,未使用的模块和未使用的导出可能无法被摇树掉。

1.2.3. 静态资源

静态资源,如 CSS、图像和字体,通常在打包步骤中被添加到分发版中。它们也可能在压缩步骤中被优化以减小文件大小。

在 Webpack 之前,静态资源是作为独立的构建任务与源代码分开构建的。要加载静态资源,应用程序必须通过其在分发版中的最终路径引用它们。因此,通常需要围绕 URL 约定(例如 /assets/css/banner.jpg/assets/fonts/Inter.woff2)仔细组织资源。

Webpack 的 “加载器” 允许从 JavaScript 导入静态资源,将代码和静态资源统一到一个依赖图中。在打包过程中,Webpack 用分发版中的最终路径替换静态资源导入。这个功能使得静态资源可以与源代码中的相关组件一起组织,并为静态分析创造了新的可能性,例如检测不存在的资源。

重要的是要认识到,导入静态资源(非 JavaScript 或转译为 JavaScript 的文件)不是 JavaScript 语言的一部分。它需要一个配置了支持该资源类型的打包工具。幸运的是,继 Webpack 之后的打包器也采用了 “加载器(loaders)” 模式,使这个功能变得普遍。

1.3. 压缩

压缩解决了不必要的大文件问题。压缩器在不影响其行为的情况下减小文件的大小。对于 JavaScript 代码和 CSS 资源,压缩器可以缩短变量名、消除空白和注释、删除无用代码,并优化语言特性的使用。对于其他静态资源,压缩器可以执行文件大小优化。压缩器通常在构建过程结束时对打包文件进行处理。

目前常用的几个 JavaScript 压缩器是 Terser、esbuild 和 SWC。Terser 是从不再维护的 uglify-es 分叉出来的。它用 JavaScript 编写,速度有点慢。Esbuild 和之前提到的 SWC 除了其他功能外还实现了压缩器,并且比 Terser 快。

目前常用的几个 CSS 压缩器是 cssnano、csso 和 Lightning CSS。Cssnanocsso 是用 JavaScript 编写的纯 CSS 压缩器,因此速度有点慢。Lightning CSS 是用 Rust 编写的,声称比 cssnano 快 100 倍。Lightning CSS 还支持 CSS 转换和打包。

2. 开发者工具

上面描述的基本前端构建流程足以创建一个优化的生产分发包。还有几类工具可以增强基本构建流程并改善开发体验。

2.1. 元框架(Meta-Frameworks)

前端领域以其选择 “正确” 包的挑战而闻名。例如,在上述列出的五个打包器中,你应该选择哪一个?

元框架提供了一组已经选定的包,包括构建工具,它们协同工作并实现专门的应用程序范式。例如,Next.js 专注于服务器端渲染(SSR),而 Remix 专门用于渐进增强。

元框架通常提供一个预配置的构建系统,无需您自己组合一个。它们的构建系统具有适用于生产和开发服务器的配置。

Vite 这样的构建工具,与元框架一样,为生产和开发提供了预配置的构建系统。与元框架不同,它们不强制使用专门的应用程序范式。它们适用于通用的前端应用程序。

2.2. 源映射(Sourcemaps)

构建流水线输出的分发版对大多数人来说是不可读的。这使得调试任何发生的错误变得困难,因为它们的追踪信息指向了不可读的代码。

源映射通过将分发版中的代码映射回源代码中的原始位置来解决这个问题。浏览器和故障排除工具(例如 Sentry)使用源映射来恢复并显示原始源代码。在生产中,源映射通常对浏览器隐藏,并且只上传到故障排除工具以避免公开源代码。

构建流程的每一步都可以输出一个源映射。如果使用多个构建工具构建流水线,源映射将形成一个链(例如 source.js -> transpiler.map -> bundler.map -> minifier.map)。要确定对应于压缩代码的源代码,必须遍历源映射链。

然而,大多数工具无法解释源映射链;它们期望分发版中每个文件最多有一个源映射。必须将源映射链展平为单个源映射。预配置的构建系统将解决这个问题(见 Vite 的 combineSourcemaps 函数)。

2.3. 热重载

开发服务器通常提供热重载功能,它在源代码更改时自动重新构建一个新的打包文件并重新加载浏览器。虽然比手动重建和重新加载要好得多,但它仍然有点慢,并且在重新加载时所有客户端状态都会丢失。

热模块替换通过在运行中的应用中替换更改的打包文件来改进热重载,这是一种就地更新。它保留了未更改模块的客户端状态,并减少了代码更改和更新应用程序之间的延迟。

然而,每次代码更改都会触发导入它的所有打包文件的重建。这具有与打包文件大小成线性时间复杂度的关系。因此,在大型应用程序中,由于不断增长的重新打包成本,热模块替换可能会变慢。

无打包范式,目前由 Vite 倡导,通过不对开发服务器进行打包来应对这个问题。相反,Vite 直接将 ESM 模块,每个模块对应于一个源文件,提供给浏览器。在这种范式中,每次代码更改都会触发前端的单个模块替换。这导致相对于应用程序大小的刷新时间复杂度几乎是恒定的。然而,如果你有很多模块,初始页面加载可能需要花费更长的时间。

2.4. 单一代码库(Monorepos)

在拥有多个团队或多个应用程序的组织中,前端代码可能会被分割成多个 JavaScript 包,但这些包仍放在一个代码库里。在这种架构中,每个包都有自己的构建步骤,并且它们共同形成一个包的依赖图。应用程序位于依赖图的顶端。

单一代码库工具协调构建依赖图。它们通常提供增量重建、并行性和远程缓存等功能。有了这些功能,大型代码库可以享受小型代码库的构建时间。

更广泛的行业标准单一代码库工具,如 Bazel,支持广泛的语言、复杂的构建图和隔离执行。然而,JavaScript 前端是最难完全集成这些工具的生态系统之一,目前几乎没有先例。

幸运的是,存在几个专门为前端设计的单一代码库工具。不幸的是,它们缺乏 Bazel 等的灵活性和健壮性,最明显的是隔离执行。

目前常用的前端专用单一代码库工具是 NxTurborepo。Nx 更成熟、功能更丰富,而 Turborepo 是 Vercel 生态系统的一部分。过去,Lerna 是将多个 JavaScript 包链接在一起并发布到 NPM 的标准工具。2022 年,Nx 团队接管了 Lerna,Lerna 现在在底层使用 Nx 来驱动构建。

3. 趋势

较新的构建工具是用编译语言编写的,并强调性能。前端构建在 2019 年非常慢,但现代工具已经大大加快了速度。然而,现代工具具有较小的功能集,有时与库不兼容,因此旧代码库通常无法轻松切换到它们。

服务器端渲染(SSR)在 Next.js 崛起后变得更受欢迎。SSR 没有为前端构建系统引入任何根本性的差异。SSR 应用程序也必须向浏览器提供 JavaScript,因此它们执行相同的构建步骤。

原文链接

Exposition of Frontend Build Systems 。更多信息请点击链接获取。