随着前端代码库变得越来越大,并且开发人员的人体工程学变得越来越重要,将 JavaScript 源代码直接发送给客户端会导致两个主要问题:
-
不支持的语言功能:由于 JavaScript 在浏览器中运行,并且存在许多不同版本的浏览器,因此您使用的每种语言功能都会减少可以执行 JavaScript 的客户端数量。此外,像 JSX 这样的语言扩展不是有效的 JavaScript,并且不会在任何浏览器中运行。
-
性能:浏览器必须单独请求每个 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 更容易优化和进行树更新,因为其导入和导出是静态定义的。
目前常用的转译器有 Babel、SWC 和 TypeScript Compiler。
-
Babel (2014) 是标准转译器:用 JavaScript 编写的慢速单线程转译器。许多需要转译的框架和库都是通过 Babel 插件来实现的,要求 Babel 成为构建过程的一部分。然而,Babel 很难调试并且经常会令人困惑。
-
SWC (2020) 是一个用 Rust 编写的快速多线程转译器。它声称比 Babel 快 20 倍;因此,它被较新的框架和构建工具使用。它支持转译 TypeScript 和 JSX。如果您的应用程序不需要 Babel,SWC 是一个更好的选择。
-
TypeScript Compiler (tsc) 还支持转译 TypeScript 和 JSX。它是 TypeScript 的参考实现,也是唯一功能齐全的 TypeScript 类型检查器。然而,它非常慢。虽然 TypeScript 应用程序必须使用 TypeScript 编译器进行类型检查,但对于其构建步骤,替代转译器的性能会更高。
如果您的代码是纯 JavaScript 并使用 ES6 模块,也可以跳过转译步骤。
针对不支持的语言功能子集的替代解决方案是 polyfill。 Polyfill 在运行时执行,并在执行主应用程序逻辑之前实现任何缺少的语言功能。但是,这会增加运行时成本,并且某些语言功能无法进行多填充。请参阅 core-js。
所有捆绑器本质上也是转译器,因为它们解析多个 JavaScript 源文件并发出新的捆绑 JavaScript 文件。这样做时,他们可以选择在发出的 JavaScript 文件中使用哪些语言功能。一些捆绑器还能够解析 TypeScript 和 JSX 源文件。如果您的应用程序有简单的转译需求,您可能不需要单独的转译器。
1.2.绑定
绑定解决了发出大量网络请求的需要和瀑布问题。绑定器将多个 JavaScript 源文件连接成一个 JavaScript 输出文件(称为捆绑包),而不改变应用程序行为。浏览器可以在单个往返网络请求中有效地加载该包。
目前常用的绑定器有 Webpack、Parcel、Rollup、esbuild 和 Turbopack。
-
Webpack(2014)在 2016 年左右获得了极大的流行,后来成为标准捆绑器。与当时通常与 Gulp 任务运行器一起使用的 Browserify 不同,Webpack 开创了“加载器”,可以在导入时转换源文件,从而允许 Webpack 编排整个构建管道。
加载器允许开发人员透明地将静态资源导入 JavaScript 文件中,将所有源文件和静态资源组合到单个依赖关系图中。使用 Gulp 时,每种类型的静态资源都必须作为单独的任务构建。 Webpack 还支持开箱即用的代码拆分,从而简化了其设置和配置。
Webpack 速度慢且是单线程,用 JavaScript 编写。它具有高度可配置性,但其许多配置选项可能会令人困惑。
-
Rollup (2016) 利用了 ES6 模块的广泛浏览器支持及其启用的优化,即树摇动。它产生的包大小比 Webpack 小得多,导致 Webpack 后来采用了类似的优化。 Rollup 是一个用 JavaScript 编写的单线程打包器,性能仅比 Webpack 稍高一些。
-
Parcel (2018) 是一个低配置捆绑器,旨在“开箱即用”,为构建过程的所有步骤和开发人员工具需求提供合理的默认配置。它是多线程的,并且比 Webpack 和 Rollup 快得多。 Parcel 2 在底层使用 SWC。
-
Esbuild (2020) 是一个为并行性和最佳性能而设计的捆绑器,用 Go 编写。它的性能比 Webpack、Rollup 和 Parcel 提高了数十倍。 Esbuild 实现了一个基本的转译器和一个压缩器。然而,它的功能不如其他捆绑器,提供的插件 API 有限,无法直接修改 AST。无需使用 esbuild 插件修改源文件,而是可以在将文件传递到 esbuild 之前对其进行转换。
-
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.摇树
一个包由多个模块组成,每个模块包含一个或多个导出。通常,给定的包只会使用它导入的模块的导出子集。捆绑器可以在称为树摇动的过程中删除其模块中未使用的导出。这优化了包的大小,缩短了加载和解析时间。
Tree Shaking 依赖于源文件的静态分析,因此当静态分析变得更具挑战性时,它就会受到阻碍。影响摇树效率的两个主要因素:
-
模块系统:ES6 模块具有静态导出和导入,而 CommonJS 模块具有动态导出和导入。因此,捆绑器在摇树 ES6 模块时能够更加积极和高效。
-
副作用:
package.json
的sideEffects
属性声明模块在导入时是否有副作用。当存在副作用时,由于静态分析的限制,未使用的模块和未使用的导出可能不会进行树摇动。
1.2.3.静态资源
静态资产(例如 CSS、图像和字体)通常在捆绑步骤中添加到可分发文件中。它们还可以在缩小步骤中针对文件大小进行优化。
在 Webpack 之前,静态资产是作为独立的构建任务在构建管道中与源代码分开构建的。要加载静态资源,应用程序必须通过其在可分发文件中的最终路径来引用它们。因此,围绕 URL 约定(例如 /assets/css/banner.jpg
和 /assets/fonts/Inter.woff2
)仔细组织资源是很常见的。
Webpack“加载器”允许从 JavaScript 导入静态资产,将代码和静态资产统一到单个依赖关系图中。在捆绑过程中,Webpack 将静态资源导入替换为可分发文件中的最终路径。此功能使静态资产能够与其源代码中的关联组件一起组织,并为静态分析创造了新的可能性,例如检测不存在的资产。
重要的是要认识到静态资产(非 JavaScript 或转换为 JavaScript 文件)的导入不是 JavaScript 语言的一部分。它需要一个配置为支持该资产类型的捆绑器。幸运的是,Webpack 之后的捆绑器也采用了“loader”模式,使得这个功能变得司空见惯。
1.3.压缩
压缩解决了不必要的大文件问题。缩小器可以减小文件的大小而不影响其行为。对于 JavaScript 代码和 CSS 资产,压缩器可以缩短变量、消除空格和注释、消除死代码并优化语言功能的使用。对于其他静态资源,压缩器可以执行文件大小优化。压缩器通常在构建过程结束时在捆绑包上运行。
目前常用的几个 JavaScript 压缩器是 Terser、esbuild 和 SWC。 Terser 是从无人维护的 uglify-es 分叉出来的。它是用 JavaScript 编写的,速度有点慢。前面提到的 Esbuild 和 SWC 除了其他功能之外还实现了压缩器,并且比 Terser 更快。
目前常用的几个 CSS 压缩器是 cssnano、csso 和 Lightning CSS。 Cssnano 和 CSSo 是用 JavaScript 编写的纯 CSS 压缩器,因此速度有些慢。 Lightning CSS 是用 Rust 编写的,据称比 cssnano 快 100 倍。 Lightning CSS 还支持 CSS 转换和捆绑。
2. 开发者工具
上述基本前端构建管道足以创建优化的生产可分发文件。有几类工具可以增强基本的构建管道并改善开发人员的体验。
2.1.元框架
前端领域因选择要使用的“正确”包的挑战而臭名昭著。例如,在上面列出的五个捆绑程序中,您应该选择哪一个?
元框架提供了一组精心挑选的软件包,包括构建工具,可以协同并支持专门的应用程序范例。例如,Next.js 专注于服务器端渲染(SSR),Remix 专注于渐进增强。
元框架通常提供一个预配置的构建系统,无需您将其拼接在一起。他们的构建系统具有适用于生产和开发服务器的配置。
与元框架一样,Vite 等构建工具为生产和开发提供预配置的构建系统。与元框架不同,它们不强制采用专门的应用程序范例。它们适用于通用前端应用程序。
2.2.源图Sourcemaps
构建管道发出的可分配信息对于大多数人来说是难以辨认的。这使得调试发生的任何错误变得困难,因为它们的回溯指向难以辨认的代码。
Sourcemaps 通过将可分发文件中的代码映射回其在源代码中的原始位置来解决此问题。浏览器和分类工具(例如 Sentry)使用源映射来恢复和显示原始源代码。在生产中,源映射通常对浏览器隐藏,仅上传到分类工具以避免公开源代码。
构建管道的每个步骤都可以发出源映射。如果使用多个构建工具来构建管道,则源映射将形成一条链(例如 source.js
-> transpiler.map
-> bundler.map
-> minifier.map
然而,大多数工具无法解释源映射链;他们期望可分发文件中的每个文件最多有一个源映射。源映射链必须扁平化为单个源映射。预配置的构建系统将解决这个问题(参见Vite的 combineSourcemaps
功能)。
2.3.热加载
开发服务器通常提供热重载功能,该功能可以在源代码更改时自动重建新的包并重新加载浏览器。虽然大大优于手动重建和重新加载,但它仍然有点慢,并且所有客户端状态在重新加载时都会丢失。
热模块替换通过替换正在运行的应用程序中更改的包(就地更新)来改进热重载。这保留了未更改模块的客户端状态,并减少了代码更改和更新应用程序之间的延迟。
但是,每次代码更改都会触发导入它的所有包的重建。这具有相对于包大小的线性时间复杂度。因此,在大型应用中,由于重新捆绑成本不断增加,热模块更换可能会变得缓慢。
目前由 Vite 倡导的无捆绑模式通过不捆绑开发服务器来解决这一问题。相反,Vite 直接向浏览器提供 ESM 模块,每个模块对应一个源文件。在此范例中,每次代码更改都会触发前端中的单个模块替换。这导致相对于应用程序大小而言,刷新时间复杂度接近恒定。但是,如果您有很多模块,则初始页面加载可能需要更长的时间。
2.4.莫诺回购Monorepo
在拥有多个团队或多个应用程序的组织中,前端可能会分为多个 JavaScript 包,但保留在单个存储库中。在这种架构中,每个包都有自己的构建步骤,它们一起形成包的依赖关系图。应用程序驻留在依赖图的根部。
Monorepo 工具协调依赖图的构建。它们通常提供增量重建、并行性和远程缓存等功能。借助这些功能,大型代码库可以享受小型代码库的构建时间。
更广泛的行业标准 monorepo 工具(例如 Bazel)支持广泛的语言、复杂的构建图和密封执行。然而,前端 JavaScript 是最难与这些工具完全集成的生态系统之一,目前几乎没有现有技术。
幸运的是,有几种专门为前端设计的 monorepo 工具。不幸的是,它们缺乏 Bazel 等人的灵活性和稳健性,尤其是封闭式执行。
目前常用的前端专用 monorepo 工具是 Nx 和 Turborepo。 Nx 更加成熟且功能丰富,而 Turborepo 是 Vercel 生态系统的一部分。过去,Lerna 是将多个 JavaScript 包链接在一起并将其发布到 NPM 的标准工具。 2022 年,Nx 团队接管了 Lerna,Lerna 现在在底层使用 Nx 来为构建提供动力。
3. 趋势
较新的构建工具是用编译语言编写的,并且强调性能。 2019 年的前端构建速度非常慢,但现代工具大大加快了速度。然而,现代工具的功能集较小,有时与库不兼容,因此遗留代码库通常无法轻松切换到它们。
Next.js 兴起后,服务器端渲染 (SSR) 变得更加流行。 SSR 不会给前端构建系统带来任何根本差异。 SSR 应用程序还必须向浏览器提供 JavaScript,因此它们执行相同的构建步骤。
附:
1、Monorepo 是 mono-repository 的缩写,指的是将多个项目的代码存储在同一个代码仓库中的一种方式。与之相对的是 polyrepo,即每个项目都有各自独立的代码仓库。Monorepo 并不是一个新的概念,很多大型科技公司,如 Google、Facebook、Microsoft 等,早已采用这种代码管理方式。
在 Monorepo 中,所有项目共享同一个代码库,可能会包含多个不同的应用程序、库和服务。每个项目在代码库中都有自己独立的目录结构,但所有项目共享同一套版本控制系统和代码管理工具。这种方法可以促进代码的重用、统一管理依赖以及更高效的协作。
文章来源:https://sunsetglow.net/posts/frontend-build-systems.html