开发者编写JavaScript代码,而浏览器运行JavaScript代码。从根本上说,前端开发不需要构建步骤。那么,为什么现代前端需要构建步骤呢?
随着前端代码库越来越庞大,以及开发者体验越来越重要,直接将JavaScript源码传输给客户端会带来两个主要问题:
- 不支持的语言特性:由于JavaScript在浏览器中运行,而浏览器种类繁多、版本各异,每增加一种语言特性,能运行你JavaScript的客户端数量就会减少。此外,像JSX这样的语言扩展不是有效的JavaScript,任何浏览器都无法运行。
- 性能问题:浏览器必须单独请求每个JavaScript文件。在一个大型代码库中,这可能导致成千上万次的HTTP请求来渲染一个页面。在HTTP/2之前,这还会导致成千上万次的TLS握手。
另外,可能需要几次连续的网络往返才能加载所有JavaScript。例如,如果
index.js
导入page.js
,而page.js
又导入button.js
,那么需要三次连续的网络往返才能完全加载JavaScript。这被称为瀑布问题。 源文件由于长变量名和空白缩进字符等原因,也可能不必要地变大,增加带宽使用和网络加载时间。
前端构建系统处理源代码并生成一个或多个优化后的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 Compiler进行类型检查,但在构建步骤中,使用其他转译器会更高效。
如果你的代码是纯JavaScript并且使用ES6模块,可以跳过转译步骤。
对于某些不支持的语言特性,另一个解决方案是polyfill。polyfill在运行时执行,实现在执行主应用程序逻辑之前任何缺失的语言特性。然而,这增加了运行时开销,有些语言特性无法用polyfill实现。参见core-js。
所有打包工具本质上都是转译器,因为它们解析多个JavaScript源文件并生成一个新的打包JavaScript文件。在此过程中,它们可以选择在生成的JavaScript文件中使用哪些语言特性。有些打包工具还可以解析TypeScript和JSX源文件。如果你的应用程序有简单的转译需求,可能不需要单独的转译器。
打包
打包解决了需要进行多次网络请求和瀑布问题。打包工具将多个JavaScript源文件连接成一个JavaScript输出文件,称为bundle,而不改变应用程序行为。该bundle可以通过浏览器在一次网络往返请求中高效加载。
目前常用的打包工具有Webpack、Parcel、Rollup、esbuild和Turbopack。
- Webpack(2014)在2016年左右获得了巨大的人气,后来成为标准的打包工具。与当时流行的Browserify不同,Webpack开创了“加载器”这一概念,通过导入转换源文件,使Webpack能够协调整个构建流程。 加载器允许开发者在JavaScript文件中透明地导入静态资源,将所有源文件和静态资源组合成一个依赖关系图。使用Gulp时,每种类型的静态资源必须作为单独的任务进行构建。Webpack还支持开箱即用的代码分割,简化了其设置和配置。 Webpack速度较慢且是单线程的,用JavaScript编写。它高度可配置,但其众多配置选项可能令人困惑。
- Rollup(2016)利用了ES6模块在浏览器中的广泛支持以及它带来的优化,尤其是树摇。它生成的bundle大小远小于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中选择使用。
如果你的模块很少或网络延迟很低(例如在本地环境中),可以跳过打包步骤。一些开发服务器在开发服务器中也选择不打包模块。
代码拆分
默认情况下,客户端React应用会被转换为一个bundle。对于有很多页面和功能的大型应用,bundle可能非常大,抵消了打包的原始性能优势。
通过将bundle拆分成多个较小的bundle,或称为代码拆分,解决了这个问题。一种常见的方法是将每个页面拆分为一个单独的bundle。在HTTP/2下,共享依赖项也可以被分解到它们自己的bundle中,以避免重复,几乎没有成本。此外,大型模块可以拆分为单独的bundle,并按需延迟加载。
代码拆分后,每个bundle的文件大小大大减小,但现在需要额外的网络往返,从而可能重新引入瀑布式加载问题。代码拆分是一个权衡。
文件系统路由器,由Next.js流行起来,优化了代码拆分的权衡。Next.js为每个页面创建单独的bundle,只包括该页面导入的代码。在加载一个页面时,会并行预加载该页面使用的所有bundle。这优化了bundle大小而不会重新引入瀑布式加载问题。文件系统路由器通过为每个页面创建一个入口点(pages/**/*.jsx
),而不是传统客户端React应用的单个入口点(index.jsx
)来实现这一点。
摇树
一个bundle由多个模块组成,每个模块包含一个或多个导出。通常,一个给定的bundle只使用其导入模块的一个子集。打包工具可以在摇树过程中移除未使用的模块和导出。这样优化了bundle大小,提升了加载和解析时间。
摇树依赖于对源文件的静态分析,因此当静态分析变得更加困难时,摇树的效率会受到影响。两个主要因素影响摇树的效率:
- 模块系统: ES6模块具有静态导入和导出,而CommonJS模块具有动态导入和导出。因此,打包工具在摇树ES6模块时可以更加积极和高效。
- 副作用:
package.json
的sideEffects
属性声明了一个模块在导入时是否具有副作用。当存在副作用时,由于静态分析的限制,未使用的模块和导出可能无法被摇树。
静态资源
静态资源,如CSS、图片和字体,通常在打包步骤中被添加到可分发文件中。它们也可能在压缩步骤中被优化文件大小。
在Webpack之前,静态资源在构建管道中与源代码分开构建,作为一个独立的构建任务。为了加载静态资源,应用必须通过它们在可分发文件中的最终路径引用它们。因此,常常需要根据URL约定仔细组织资源(例如 /assets/css/banner.jpg
和 /assets/fonts/Inter.woff2
)。
Webpack的 loader 允许从JavaScript中导入静态资源,将代码和静态资源统一到一个依赖图中,简化了它们的组织和加载。尽管如此,将静态资源捆绑在JavaScript文件中会增加bundle大小,最好将静态资源分离。
代码压缩
代码压缩主要是解决文件过大的问题。压缩工具可以在不改变代码功能的情况下,减少文件的大小。对于JavaScript和CSS等代码,压缩工具可以缩短变量名、去除空白和注释、删除无用代码,并优化语言特性使用。对于其他静态资源,压缩工具也能优化文件大小。通常,压缩工具会在构建过程的最后一步运行。
目前常用的JavaScript压缩工具包括Terser、esbuild和SWC。Terser是从不再维护的uglify-es分支出来的,用JavaScript编写,因此速度较慢。而esbuild和SWC除了压缩功能外,还有其他功能,并且速度比Terser更快。
常用的CSS压缩工具有cssnano、csso和Lightning CSS。cssnano和csso是纯CSS压缩工具,用JavaScript编写,因此速度较慢。Lightning CSS则是用Rust编写的,声称速度比cssnano快100倍。此外,Lightning CSS还支持CSS转换和打包功能。
开发工具
基本的前端构建管道可以生成优化的生产发布版。然而,有许多工具可以增强基本构建管道,提升开发体验。
元框架
前端领域在选择合适的工具包时常常令人困惑。例如,上述五种打包工具中,你应该选择哪一种?
元框架提供了一组经过精选的工具包,包括构建工具,它们可以协同工作,实现特定的应用模式。例如,Next.js专注于服务器端渲染(SSR),而Remix则专注于渐进增强。
元框架通常提供预配置的构建系统,省去了自己拼凑的麻烦。它们的构建系统既有生产环境的配置,也有开发服务器的配置。
与元框架类似,Vite等构建工具也提供预配置的构建系统,适用于生产和开发环境。不同的是,它们不强制特定的应用模式,适用于一般的前端应用。
源映射(Sourcemaps)
构建管道生成的发布版对大多数人来说是难以阅读的。这使得调试错误变得困难,因为错误的追踪指向的是不可读的代码。
源映射解决了这个问题,将发布版中的代码映射回其原始源码位置。浏览器和调试工具(如Sentry)使用源映射来恢复并显示原始源码。在生产环境中,源映射通常对浏览器隐藏,只上传到调试工具,以避免公开源码。
构建管道的每一步都可以生成源映射。如果使用多个构建工具,源映射将形成一个链条(例如:source.js -> transpiler.map -> bundler.map -> minifier.map)。要找到压缩代码对应的源码,必须遍历源映射链条。
然而,大多数工具无法解释源映射链条;它们最多只期望每个文件有一个源映射。因此,源映射链条必须被压平成一个源映射。预配置的构建系统会解决这个问题(如Vite的combineSourcemaps函数)。
热重载(Hot Reload)
开发服务器通常提供热重载功能,当源代码改变时,自动重新构建新包并重新加载浏览器。虽然这比手动重建和重新加载要好得多,但仍然有点慢,并且所有客户端状态在重新加载时都会丢失。
模块热替换(Hot Module Replacement)改进了热重载,通过在运行的应用程序中替换更改的包进行原位更新。这保留了未更改模块的客户端状态,并减少了代码更改到应用更新之间的延迟。
然而,每次代码更改都会触发导入它的所有包的重建。这使得重建时间相对于包大小呈线性增长。因此,在大型应用中,模块热替换可能会因为重建成本的增加而变慢。
Vite倡导的无打包开发服务器模式则不打包开发服务器,而是直接向浏览器提供每个源码文件对应的ESM模块。在这种模式下,每次代码更改只触发一个模块在前端的替换。这样,刷新时间复杂度相对于应用大小几乎是恒定的。然而,如果模块很多,初始页面加载时间可能会变长。
单一仓库(Monorepos)
在拥有多个团队或多个应用的组织中,前端可能会被拆分成多个JavaScript包,但保留在一个仓库中。在这种架构下,每个包都有自己的构建步骤,共同形成包的依赖图。应用程序位于依赖图的根部。
单一仓库工具负责协调依赖图的构建。它们通常提供增量重建、并行处理和远程缓存等功能。通过这些功能,大型代码库也能享受小型代码库的构建时间。
标准的单一仓库工具如Bazel,支持多种语言、复杂的构建图和隔离执行。然而,前端JavaScript生态系统是最难完全整合到这些工具中的,目前几乎没有先例。
幸运的是,针对前端的单一仓库工具存在,但它们缺乏Bazel等工具的灵活性和稳健性,特别是隔离执行。
目前常用的前端单一仓库工具是Nx和Turborepo。Nx更成熟,功能更丰富,而Turborepo是Vercel生态系统的一部分。过去,Lerna是将多个JavaScript包链接在一起并发布到NPM的标准工具。2022年,Nx团队接管了Lerna,现在Lerna在后台使用Nx进行构建。
趋势
最后,来说一说前端构建的趋势。
较新的构建工具使用编译语言编写,注重性能。2019年前端构建非常慢,但现代工具大大加快了速度。然而,现代工具的功能较少,有时与库不兼容,因此旧代码库往往难以轻松切换到它们。
服务器端渲染(SSR)在Next.js兴起后变得更受欢迎。SSR对前端构建系统没有引入任何根本性的不同。SSR应用也必须向浏览器提供JavaScript,因此它们执行相同的构建步骤。
本文译自:https://sunsetglow.net/posts/frontend-build-systems.html