Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

开发者编写 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 文件,便于传输给浏览器。最终的可分发文件通常是人类难以阅读的。

构建步骤

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

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

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

转译

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

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

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

目前常用的转译器有 Babel、SWC 和 TypeScript Compiler。

  1. Babel(2014)是标准的转译器:一个用 JavaScript 编写的单线程转译器,速度较慢。许多需要转译的框架和库通过 Babel 插件实现,因此 Babel 必须成为构建过程的一部分。然而,Babel 难以调试且常常令人困惑。

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

  3. 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。

  1. Webpack(2014)在 2016 年左右获得了巨大的人气,后来成为标准的打包工具。与当时流行的 Browserify 不同,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 之前对源文件进行转换,而不是使用 esbuild 插件修改源文件。

  5. 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 大小,提升了加载和解析时间。

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

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

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

静态资源

静态资源,如 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