跳到主要内容

前端工程化:对于构建工具链的简单思考

· 阅读需 20 分钟
不如怀念
Web 前端工程师 (Web Front-end Engineer)

最后更新于 2022-08-06 00:21:00

前端工程化是在做与业务开发完全不同的事情,旨在解决软件工程领域与开发者密切相关的问题,通常会将其与基建开发、DevOps 放在一起讨论。前端开发是复杂的,其结合了 HTML/CSS/JavaScript 3 种语言,甚至还有很多其超集,没有开箱即用的工具链,不像 Java Web 开发、Android 开发等等有官方或者商业领域非常成熟的工具可以利用,一切都源于开源社区的从 0 开始构建。正因如此,前端工程化领域百花齐放,开放与创新展现的淋漓尽致,这也是前端开发者了解学习软件工程的机会。

这里对于前端开发中比较重要的一个环节,即构建工具链,谈谈这么多年来了解与使用了多种工具后的简单思考。怎么理解构建工具链?也许可以想想前端开发中编译、打包、静态资源压缩、静态语法检查、热更新、代码 Lint、测试、类型文件生成……

任务运行器与打包工具

在 16、17 年刚开始接触前端构建工具时,最先尝试的则是 gulp.js,当时也了解到 grunt.js 但没有真正的使用过,主要是它们是同类东西,具有可替代性;后来,将 gulp.js 与 webpack(v3) 配合起来用,主要原因还是前者只能处理单个文件,后者可以将多个文件合并(bundle);再后来,迁移到 webpack(v4) 后替代了 gulp.js 这部分的任务,直接将 gulp.js 移出工具链。

gulp.js(grunt.js) -> gulp.js + webpack(v3) -> webpack(v4)

以上是针对 Web 应用开发的构建工具的选择变化,即由任务运行器(Task Runner)向打包工具(Bundler)的转变,这些工具的目标是一致的:为 Web 开发建立一个完整的自动化工作流(automate workflow)。那么,为何会发生这样的转变呢?工具是为开发者服务的,Web 前端开发者对于 Web 应用的优化着力点则在于将多个分散的小文件合并为一个大文件,较少 HTTP 请求次数从而提高加载性能以改善用户体验。换句话说,像 gulp.js 这样的任务运行器更大程度上解决的是开发体验问题,可以自动将 JS/CSS 的超集语言(例如 Less/Sass/CoffeeScript)进行编译,对静态资源进行压缩等等;而打包工具在此基础之上,更加契合了前端领域的 Web 应用优化法则,帮开发者包办一切,只需编码即可交付上线。孰好孰坏?孰优孰劣?这不是一个绝对的问题,特定场景下用更适合的工具解决问题即可,但显然,当下 webpack 是可以解决大多数问题的最佳选择。

当然,同一时代下,打包工具不仅只有 webpack,还有 rollup(后续会提到)、Parcel 诸如此类的工具,所以前面说这个领域是百花齐放,开放与创新并存。每个工具都有自己的特点和优势,这里我重点讲讲我自己实际用过的一些工具。

后来,开发一些工具库的时候,尝试了 rollup 这个打包工具。先说一个比较有趣的观点:

  • Web App -> webpack
  • JS Lib -> rollup

这个意思就是打包 Web 应用选 webpack,打包工具库用 rollup。无论这个观点合不合理,但这是一个值得我去尝试 rollup 的理由。鉴于历史原因,一般工具库的构建产物需要包含多种格式(UMD/CJS/ESM),一番体验过后,rollup 可以轻松完成这个任务,反观 webpack 就不是很好处理了。另一方面,rollup 的生态中似乎处理 JS 的工具要多一些,反而处理静态资源,尤其是 CSS、HTML 的优秀工具就很匮乏了,而 webpack 的生态足够繁荣,针对 JS/CSS/HTML 都有相关的优秀工具。说到这里,实际上刚开始提到的观点多少还是有点道理的,两个工具的生态解决问题的侧重点不同。

任务运行器和打包工具的区别是前者处理单个文件,后者处理并合并所有文件,而真正完成编译、压缩、语法检查等等核心任务的则是其它工具(比如 Babel/ESLint/Prettier/TypeScript),通常会以相应的打包工具的插件方式来使用。

插件

插件机制是一个程序具备可扩展性和灵活性的关键,各种任务运行器和打包工具均依赖于插件增强自身的能力,所以一个工具的社区生态中优秀插件足够多是非常重要的。这里秉承一个理念:一个工具只解决最关键的一个问题,多个工具组合起来就可以解决特定领域的一类问题,而像 webpack 和 rollup 这样的工具就是用来组合各种各样工具的容器。

以插件的方式解决问题很好,不同的工具解决同样的问题不用重复实现,避免造轮子,减轻了工具维护者的负担,也促进了前端工具生态的繁荣,可以有很多人以不同的方式解决同样的问题(例如 Terser 与 UglifyJS 都可以用来压缩和混淆 JS 代码)。

有人曾想过从 webpack 迁移到 rollup,但以我自身的体验为例,举个例子可以说明,插件有好处亦有坏处。webpack 的生态非常繁荣,当你尝试迁移到 rollup 时想找到对应的替代品,这个过程可能会让你非常沮丧,ESLint 工具可以帮助我们做代码 Lint,webpack 社区中有一个非常优秀的插件(fork-ts-checker-webpack-plugin(v6))可以将 ESLint 集成到 webpack 工作流中,但 rollup 中似乎找不到一个满意的插件(@rollup/plugin-eslint 已经很久没有维护,且不支持 ESLint 8.x)。这个时候,你是不是会抛出一个疑问:一个工具(比如 ESLint)作为独立的工具库避免了造轮子,但为了适配 webpack、rollup 等众多的打包工具,都需要一一编写插件并持续维护,反而又造了一批轮子?

的确,插件是一个解决问题的好方式,但也有其劣势,每当出现一个新的打包工具,那么就需要复制实现一遍现有打包工具的插件。另一方面,我们也可以思考,将所有的工具集成到打包工具中是否有必要?实际上我最近就面临这个问题。

当我基于 rollup 工具链构建一个用来开发 UI 组件库的工作流时,庞大的第三方依赖让 rollup 在开发模式(watch)下增量编译非常缓慢(4s 以上),经过调试发现是 @rollup/plugin-node-resolve 插件耗费了大量的时间,这也是 rollup 与 webpakc 对待第三方模块实现有所不同的显著差异,基本上无解;另一方面,为了做 TypeScript 的类型检查,这又进一步减缓了增量编译的速度。为了解决庞大依赖项对增量编译的速度巨大影响,尝试换用用 Go 编写的 esbuild 打包工具,在这个迁移过程中对于 CSS 的处理则耗费了很多时间(因为 esbuild 主要还是专注于处理 JS 代码),对于 TypeScript 也不做类型检查。那么,当我们为了解决一个收益很高的问题而迁移到另一个打包工具时,一些收益较低的特性面临不被支持的问题(比如 TypeScript 类型检查),实际上我们可以将辅助性的工具与打包工具解耦,用任务运行器来与打包工具的工作流并行运行即可解决,这也是我所采用的方案。

rollup + Core(plugin) + Util(plugin) -> rollup + Core(plugin) + Util(task)

借助 concurrently 这个命令行工具,我们可以将一些辅助性的工具与打包工具并行运行,这样就可以实现开发模式下用 esbuild,生产模式下用 rollup,还能具备 TypeScript 类型检查的能力:

# development
concurrently --kill-others "node esbuild-watch.cjs" "tsc --noEmit --watch"

# production
concurrently --kill-others "rollup --config" "tsc --noEmit --watch"

实际上,开发模式使用 esbuild,生产模式使用 rollup 正是打包工具 vite 的底层架构设计。当然,这种方案有其性能劣势,本应该开启一个文件系统的 watch 服务即可,但这里可能会利用诸如 chokidar 这样的 npm 包开启多个 watch 服务,也许操作系统底层对该场景做了优化,但没有深究也就不得而知了。

所以说,当我们使用打包工具替代任务运行器时,刚开始是方便了,但也加强你对该工具的依赖性,后续很难迁移到其它工具链。没有最好的工具,只有更优的方案,满足自己的需求即可。

当然,对于利用插件来组合各种工具解决各类问题的方案,显然有部分人觉得是不满意的,每种工具都有特定的配置项,组合的工具越多,配置起来就越复杂。Rome 试图用一套工具来解决目前前端构建工具领域典型的几个环节的问题,替代 Babel/ESLint/webpack/Prettier/Jest 等等,值得关注。

用高性能语言解决性能问题

当前端的项目日益复杂,庞大的代码库使用 JS 编写的构建工具处理起来显得有些力不从心,毕竟 JS 是一个脚本语言,有性能上的劣势。这个时候用一些高性能语言编写构建工具来解决前端工具链中存在的性能问题成为了一个有趣的方案,当前的代表作是 esbuild(用 Go 编写) 和 swc(用 Rust 编写)。为什么说它有趣呢?作为 Web 开发者,应该很少会有人想到利用一些比 JS 较为低级的高性能语言去编写工具,这个有很高的学习成本;另一方面,这些工具也确实以非常强的性能优势很好的解决了前端工具链中的性能问题,值得尝试。

以第三方语言编写的工具,目前有一个劣势就是能参与到社区生态中贡献的开发者群体将占比很小,对比 esbuild 和 rollup、webpack 这些用 JS 编写的工具的代码库贡献者数量就有一个非常直观的感受。其次,这些工具利用性能优势解决了核心性能问题,其它问题还依赖于前端社区中现有的工具来完成,而不是重复造轮子(实际上也不现实),而 esbuild 的插件机制实际上还处于实验阶段,而正如官方文档中所说的,用 JS 编写的插件将不具备 Go 的性能优势,esbuild 的方案就是暴露一些耗费性能的程序 API(用 Go 实现)供 JS 代码调用,以达到一个折衷的效果,既支持引入前端生态的现有工具,也不太会降低性能。

如何定义工具

另一方面,前端工程化所做的一切应该是致力于提高开发者体验、让流程标准化、自动化,从而提高开发效率和保证质量。那么,在这个过程中,所引入的一些工具链不应该成为开发者的“绊脚石”,应该结合团队实际情况选择合适的工具和方案,当团队成员都在迎合工具时,实际上已经违背了前端工程化的初衷了。

这里其实可以以前端一个比较有争论的工具来举例,即 TypeScript。首先,TypeScript 在前端领域的应用是越来越广泛了,这是一个趋势,说明其在前端生态中有一定的重要性和作用。一般来说,有一部分人认为应该用 TypeScript,可以提高代码质量,而另一部分人则认为不应该用 TypeScript,因为会降低编码效率。当把 TypeScript 引入编译工具链时,一般来说有两种选择,一种是类 Babel 方案(不做类型检查),而另一种就是官方 tsc 方案,出于简单性后者会更方便一些,但后者会做类型检查更加严格,这就意味着开发过程中你要解决所有的错误才能让代码编译通过(这在一些场景下实际上是开发者的噩梦,至少我是这样觉得的并有所体会)。简单来说,引入 TypeScript 的目的是提高代码质量,但以什么样的方式和程度去实践这个事情其实是有争议的。对于体验过早期前端开发的人来说,我觉得 TypeScript 主要是解决了两个问题,第一个就是类型定义所带来的代码智能提示功能(以前是需要边写边查文档的),第二个就是类型检查可以帮助我们提前发现问题并修复(但这里并不代表开发者需要迎合严格的类型检查,应该更多的是作为一种参考信息)。由于前端开发(尤其是业务开发)有其复杂性,不适合生搬硬套类 Java 这种严格的静态类型语言标准,将 TypeScript 作为工具引入,而不是为了迎合 TypeScript 的标准花费精力解决额外的事情(或者说历史遗留问题)。对于 TypeScript 来说,更大的一个作用就是可以用来生成类型定义文件(.d.ts),官方提供的命令行工具(tsc --emitDeclarationOnly)会在发生类型检查错误时报错中断,这就使其很难集成在 CI 中(例如 npm 包发布前自动生成类型定义文件),期望官方能提供一个忽略类型检查错误的命令行标志位,但奈何没有(在查找资料的过程中发现有很多开发者有类似的需求),无奈之下只能以 JS 脚本的方式去运行该命令同时忽略掉类型检查错误以集成在 CI 中。

所以说,前端生态中,很多优秀的工具官方推荐或者默认是选择不做类型检查的编译方案的(类 Babel),我想这也是有原因的,且是被大多数人所认同的。工程化实施的过程中,引入任何工具链的最终效果应该是“帮助”开发者,而不是带来“绊脚石”的副作用,完美的类型检查是我们所追求的,但投入产出比是需要重点考虑的。

结语

随着 wasm、ES Module、HTTP2/HTTP3 等的推广与普及,前端工程化领域还在不断演进,Bundle 与 Bundle less 将如何发展值得期待。

参考资源