前端构建系统的阐述

241 阅读15分钟

目录

  1. 构建步骤
    1. 转译
    2. 打包
      1. 代码分割
      2. 树摇
      3. 静态资源
    3. 压缩
  2. 开发工具
    1. 元框架
    2. 源映射
    3. 热重载
    4. 单一代码库
  3. 趋势

开发者编写JavaScript;浏览器运行JavaScript。从根本上讲,前端开发中不需要构建步骤。那么,为什么现代前端中会有构建步骤呢?

随着前端代码库的扩大,以及开发者的工作效率变得越来越重要,直接将JavaScript源代码发送到客户端会导致两个主要问题:

  1. 不支持的语言特性(Unsupported Language Features):由于JavaScript在浏览器中运行,并且有许多版本的浏览器,因此您使用的每个语言特性都会减少可以执行您JavaScript的客户端数量。此外,像JSX这样的语言扩展不是有效的JavaScript,无法在任何浏览器中运行。

  2. 性能(Performance):浏览器必须单独请求每个JavaScript文件。在大型代码库中,这可能导致成千上万的HTTP请求来渲染单个页面。在过去,HTTP/2出现之前,这还会导致成千上万的TLS握手。

    此外,在加载所有JavaScript之前,可能需要进行多个顺序的网络往返。例如,如果 index.js 导入了 page.js,而 page.js 导入了 button.js,则需要三个顺序的网络往返才能完全加载JavaScript。这被称为瀑布问题。

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

前端构建系统处理源代码并生成一个或多个优化后的JavaScript文件,以便发送到浏览器。生成的可分发文件通常对人类来说是不可读的。

1. 构建步骤(Build Steps)

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

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

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

1.1. 转译(Transpilation )

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

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

CommonJS模块(CJS)也必须转译为浏览器兼容的模块系统。在2018年,浏览器对ES6模块(ESM)的广泛支持后,通常建议转译为ESM。ESM更容易优化和树摇,因为它的导入和导出是静态定义的。

当前常用的转译器包括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。Polyfill在运行时执行,并在执行主应用程序逻辑之前实现任何缺失的语言特性。然而,这会增加运行时成本,并且某些语言特性无法被polyfill。请参见core-js

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

1.2. 打包(Bundling)

打包解决了需要进行许多网络请求和水fall问题。打包工具将多个JavaScript源文件连接成一个单一的JavaScript输出文件,称为bundle,而不改变应用程序行为。浏览器可以通过单个往返网络请求高效加载该bundle。

当前常用的打包工具包括Webpack、Parcel、Rollup、esbuild和Turbopack。

  1. Webpack(2014)在2016年获得了显著的流行,后来成为标准打包工具。与当时常用的Browserify(通常与Gulp任务运行器一起使用)不同,Webpack首创了“加载器”,在导入时转换源文件,使Webpack能够协调整个构建管道。

    加载器允许开发者在JavaScript文件中透明地导入静态资源,将所有源文件和静态资源组合成一个单一的依赖图。使用Gulp时,每种类型的静态资源都必须作为单独的任务构建。Webpack还支持代码分割,简化了其设置和配置。

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

  2. Rollup(2016)利用了ES6模块的广泛浏览器支持及其带来的优化,即树摇。它产生的bundle大小远小于Webpack,导致Webpack后来采用了类似的优化。Rollup是一个用JavaScript编写的单线程打包工具,性能仅比Webpack稍好。

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

  4. Esbuild(2020)是一个为并行性和最佳性能设计的打包工具,用Go编写。它的性能比Webpack、Rollup和Parcel快数十倍。Esbuild实现了基本的转译器和压缩器。然而,它的功能不如其他打包工具丰富,提供的插件API有限,不能直接修改AST。相反,可以在传递给esbuild之前对文件进行转换。

  5. Turbopack(2022)是一个快速的Rust打包工具,支持增量重建。该项目由Vercel构建,并由Webpack的创建者领导。目前处于测试阶段,可能在Next.js中被选用。

如果您只有很少的模块或网络延迟非常低(例如在本地),跳过打包步骤是合理的。一些开发服务器也选择不为开发服务器打包模块。

1.2.1. 代码分割(Code Splitting)

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

将bundle分割成几个较小的bundle,或称为代码分割,可以解决这个问题。常见的方法是将每个页面分割为一个单独的bundle。随着HTTP/2的出现,共享依赖项也可以被拆分到自己的bundle中,以避免重复,而成本很小。此外,较大的模块可以拆分为单独的bundle并按需懒加载。

在代码分割之后,每个bundle的文件大小大大减少,但现在需要额外的网络往返,这可能重新引入水fall问题。代码分割是一种权衡。

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

1.2.2. 树摇(Tree Shaking)

一个bundle由多个模块组成,每个模块包含一个或多个导出。通常,给定的bundle只会使用其导入模块的子集的导出。打包工具可以在一个称为树摇的过程中删除未使用的模块导出。这优化了bundle的大小,提高了加载和解析时间。

树摇依赖于对源文件的静态分析,因此当静态分析变得更加困难时,它会受到阻碍。两个主要因素影响树摇的效率:

  1. 模块系统(Module System):ES6模块具有静态导出和导入,而CommonJS模块具有动态导出和导入。因此,打包工具在树摇ES6模块时能够更具攻击性和效率。

  2. 副作用(Side Effects)package.jsonsideEffects属性声明模块在导入时是否具有副作用。当存在副作用时,由于静态分析的限制,未使用的模块和未使用的导出可能无法被树摇。

1.2.3. 静态资源(Static assets)

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

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

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

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

1.3. 压缩(Minification)

压缩解决了不必要的大文件的问题。压缩器在不影响文件行为的情况下减少文件大小。对于JavaScript代码和CSS资源,压缩器可以缩短变量、消除空白和注释、消除死代码,并优化语言特性使用。对于其他静态资源,压缩器可以执行文件大小优化。压缩器通常在构建过程的最后对一个bundle进行运行。

当前常用的JavaScript压缩器有Terser、esbuild和SWC。Terser是从不再维护的uglify-es分叉而来的。它用JavaScript编写,速度相对较慢。EsbuildSWC,前面提到的,除了其他功能外,还实现了压缩器,速度比Terser快。

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

2. 开发工具(Developer Tooling)

上述基本前端构建管道足以创建一个优化的生产可分发文件。存在几种工具类别,可以增强基本构建管道并改善开发者体验。

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. 热重载(Hot Reload)

开发服务器通常提供热重载功能,该功能在源代码发生更改时自动重建新的bundle并重新加载浏览器。虽然这比手动重建和重新加载要好得多,但仍然有些慢,并且在重新加载时会丢失所有客户端状态。

[热模块替换(Hot Module Replacement](webpack.js.org/concepts/ho…

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

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

2.4. 单一代码库(Monorepos)

在拥有多个团队或多个应用程序的组织中,前端可能被拆分为多个JavaScript包,但保留在一个单一的代码库中。在这种架构中,每个包都有自己的构建步骤,它们共同形成一个包的依赖图。应用程序位于依赖图的根部。

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

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

幸运的是,存在一些专门为前端设计的单一代码库工具。不幸的是,它们缺乏Bazel等工具的灵活性和鲁棒性,尤其是在密封执行方面。

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

3. 趋势(Trends)

更现代的构建工具是用编译语言编写的,并强调性能。2019年,前端构建非常缓慢,但现代工具大大加快了这一过程。然而,现代工具的功能集较小,有时与库不兼容,因此遗留代码库通常无法轻松切换到这些工具。

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

转载:Exposition of Frontend Build Systems