前端构建工具的发展

871 阅读15分钟

一、模块化定义与产生

模块化发展历程回顾: 

  • 2009 年,Kevin Dangoor 发起了 ServerJS 项目,后更名为 CommonJS,其目标是指定浏览器外的 JS API 规范(例如 FS、Stream、Buffer 等)以及模块规范 Modules/1.0。这一规范也成为同年发布的 NodeJS 中的模块定义的参照规范。 
  • 2011 年,RequireJS 1.0 版本发布,作为客户端的模块加载器,提供了异步加载模块的能力。作者在之后提交了 CommonJS 的 Module/Transfer/C 提案,这一提案最终发展为了独立的 AMD 规范。 
  • 2013 年,面向浏览器端模块的打包工具Browserify发布。 
  • 2014 年,跨平台的前后端兼容的模块化定义语法 UMD发布。 
  • 2014 年,Sebastian McKenzie 发布了将 ES6 语法转换为 ES5 语法的工具 6to5,并在之后更名为Babel。 
  • 2014 年,Guy Bedford 对外发布了 SystemJS 和 jspm 工具,用于简化模块加载和处理包管理。 
  • 2014 年,打包工具 Webpack 发布了第一个稳定版本。 
  • 2015 年,ES6(ES2015)规范正式发布,第一次从语言规范上定义了 JS 中的模块化。 
  • 2015 年,Rich Harris 发布的 Rollup 项目,基于 ES6 模块化,提供了 Tree Shaking 的功能。
  • CommonJS :是为了解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块它自身的命名空间中执行。在 CommonJS 出现之前,一个 JS 类库只能通过暴露全局对象的方式,供其他 JS 文件使用。CommonJS 作为非浏览器端的 JS 规范,它的基本要素如下:

    • 模块定义:一个模块即是一个 JS 文件,代码中自带 module 指向当前模块对象;自带 exports=module.exports,且 exports 只能是对象,用于添加导出的属性和方法;自带 require 方法用于引用其他模块。
    • 模块引用:通过引用 require() 函数来实现模块的引用,参数可以是相对路径也可以是绝对路径。在绝对路径的情况下,会按照 node_modules 规则递归查找,在解析失败的情况下,会抛出异常。
    • 模块加载:require() 的执行过程是同步的。执行时即进入到被依赖模块的执行上下文中,执行完毕后再执行依赖模块的后续代码。
  • AMD:CommonJS 的 Modules/1.0 规范从一开始就注定了只能用于服务端,不能用于浏览器端。这一方面是因为模块文件中没有函数包裹,变量直接暴露到全局;另一方面则因为浏览器端的文件需要经过网络下载,不适合同步的依赖加载方式,因此出现了适用于浏览器端的模块化规范 AMD。 AMD 规范的基本要素如下:

    • 模块定义:通过define(id?, dependencies?, factory) 函数定义模块。id 为模块标识,dependencies 为依赖的模块,factory 为工厂函数。factory 传入的参数与 dependencies 对应,若不传 dependencies,则 factory 需要默认传入 require、exports,以及 module,或只传入 require,但使用 return 做导出。
    • 模块引用:最早需要通过 require([id], callback) 方式引用,之后也支持了类似 CommonJS 的 var a = require('a') 的写法。
  • UMD:UMD 本质上是兼容 CommonJS 与 AMD 这两种规范的代码语法糖,通过判断执行上下文中是否包含 define 或 module 来包装模块代码,适用于需要跨前后端的模块。

  • ES Module:ECMA 规范组织在 2015 年 6 月发布的 ES6 版本中,首次提出了 JS 标准的模块化概念,具体要素如下:

    • 模块定义:模块内支持两种导出方式,一种通过 export 关键字导出任意个数的变量,另一种通过 export default 导出,一个模块中只能包含一个 default 的导出类型。
    • 模块引用:通过 import 关键字引用其他模块。引用方式分为静态引用和动态引用。静态引用格式为import importClause from ModuleSpecifier,import 表达式需要写在文件最外层上下文中;动态引用的方式则是 import(),返回 promise 对象。

模块化的构建工具

  • RequireJS:正如前面介绍的,RequireJS 的核心功能是支持 AMD 风格的模块化代码运行。
  • Browserify:与前者不同,Browserify 的目标是让 CommonJS 风格的代码也运行在浏览器端,除了提供语法糖外,还提供了一些经过处理后且在浏览器端运行的 NodeJS 的核心模块。
  • Babel:Babel 的定位一直是 Transformer,即语法转换器,它承担着将 ES6、JSX 等语法转换为 ES5 语法的核心功能,被广泛地运用于其他构建工具中。
  • SystemJS:SystemJS 是兼容各种模块化规范的运行时工具。
  • Webpack:Webpack 一方面兼容各种模块化规范的标识方法,另一方面将模块化的概念延伸到其他类型的文件中,创造性地打造了一种完全基于模块的新的构建体系。
  • Rollup:Rollup 在诞生之初率先实现了 Tree Shaking 功能,以及天然支持 ES6 模块的打包。虽然这些主要功能在 Webpack 发展的后续版本中也逐步支持,但其简单的 API 仍然广受许多库开发者的青睐。

二、bundle类的构建工具

任务式构建工具

任务式构建工具发展历程回顾: 2012 年,Ben Alman 发布了基于任务的构建工具Grunt。 2013 年,Eric Schoffstall 发布了流式的构建工具 Gulp

随着 NodeJS 和 npm 的发布,大量的前端工具包发布到 npm 仓库,开发者通过简单的命令行指令就可以方便地下载和使用,前端的工程化也在这一时期开始蓬勃发展。其中一种趋势就是,使用自动化的任务式构建工具来替代手工执行各种处理命令。

Grunt 和 Gulp 这两种任务式的构建工具的基本组成包括:核心的处理工具(grunt-cli/gulp-cli)、配置文件(Gruntfile/Gulpfile),以及一系列常用的任务插件(Clean、Watch、Copy、Concat、Uglify、CssMin、Spritesmith......)。在项目里通过编写配置文件,就可以定义工作流程中的各种自动化构建处理,例如在发生变更时,通过 Watch 插件监控文件,从而自动执行代码的检查与压缩等。

Grunt vs Gulp

这两种工具的差异性主要体现在:

  • 读写速度:Gulp 在处理任务的过程中基于 NodeJS 的数据流,本质上是操读写内存,而 Grunt 则是基于临时文件,因此在读写速度上 Gulp 要快于Grunt。
  • 社区使用规模:截止编写课程的时间点,在 npmjs.com 的周下载量方面,Gulp 为 1,200,000+,约是 Grunt 的两倍。而在插件数量方面,Grunt 社区提供了超过 6000 个不同功能的插件,而 Gulp 社区的插件数量则是 4000 多个。
  • 配置文件的易用性:相比描述不同插件配置信息的 Gruntfile 而言,使用 pipe 函数描述任务处理过程的方式通常更易于阅读,但编写时需要对数据流有更深入的理解。

webpack:用于现代JavaScript应用程序的静态模块打包器

webpack的发展:2012年3月10日,Webpack 诞生了,但它是怎么流行起来了,2013年,用于构建用户界面的JavaScript库React的开源,React 是 Facebook 在 2012年内部使用的一个框架,同年 Facebook 收购了 Instagram,所以 Instagram 也是用的 React ,Instagram是一个图片的社交网站,图片还是高清的,对页面性能要求那是相当高的,在往前走,到了 2014 OSCON 大会 (OSCON 是动物书 O'Reilly 组织的),Instagram 的前端团队分享了他们对前端页面加载性能优化,其中很重要的一件就是用到的 Webpack 的 「code splitting」,当时引起了很大的轰动,之后大家纷纷使用 Webpack,并给Webpack 贡献了无数的 plugins ,loader,所以大家看到 2014年后 Webpack 发展非常迅猛,版本更新非常快,最后这些 plugins 也模糊了 module bundler 和 tasks 的界限,于是把前端 tasks,workflow工具 grunt gulp 取代了。前端开发工程化的飞速发展及产生对应的工具解决方案,使webpack构建工具得到广泛的应用。

webpack的基本工作流程,Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  • 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  • 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  • 确定入口:根据配置中的 entry 找出所有的入口文件;
  • 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  • 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  • 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  • 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,webpack插件(一个包含apply方法的JavaScript对象)在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

webpack的流行及其强大的优点也成为每个前端开发人不得不学习的技能,但是也不可否认随着业务发展前端项目越来越复杂,构建速度构建缓慢而且配置相对复杂,在这个背景及各大前端组件库React、Vue的发展,也衍生出了基于不同前端界面构建库的脚手架。React出了create-react-app,Vue出了vue-cli,脚手架内置了webpack开发中常用的配置,实现了0配置就可搭建一个前端工程。

rollup:基于ES moudle的模块化工具

Rollup编译ES6模块,提出了Tree-shaking,根据ES module静态语法的特性,删除未被实际使用的代码。相比于webpack,rollup可以生成轻量、快速以及低复杂度的library和应用程序。

支持导出多种规范的语法:通过配置output.format: 源码构建输出的格式
iife: 自执行函数, 可通过 script 标签加载
amd: 浏览器端的模块规范, 可通过 RequireJS 可加载
cjs: Node 默认的模块规范, 可通过 Webpack 加载
umd: 兼容 IIFE, AMD, CJS 三种模块规范
es: ES module 规范, 可用 Webpack, Rollup 加载

Rollup VS Webpack

webpack打包后的文件

rollup打包后的文件

webpack致力于复杂SPA的模块化构建,优势在于:

  1. 通过loader处理各种各样的资源依赖
  2. HMR模块热替换
  3. 按需加载
  4. 提取公共模块

rollup致力于打造性能出众的类库,有如下优势:

  1. 编译出来的代码可读性好
  2. rollup打包后生成的bundle内容十分干净,没有什么多余的代码,只是将各个模块按照依赖顺序拼接起来,所有模块构建在一个函数内(Scope Hoisting), 执行效率更高。相比webpack(webpack打包后会生成__webpack_require__等runtime代码),rollup拥有无可比拟的性能优势,这是由依赖处理方式决定的,编译时依赖处理(rollup)自然比运行时依赖处理(webpack)性能更好
  3. 对于ES模块依赖库,rollup会静态分析代码中的 import,并将排除任何未实际使用的代码:tree-shaking
  4. 支持程序流分析,能更加正确的判断项目本身的代码是否有副作用(配合tree-shaking)
  5. 支持导出es模块文件(webpack不支持导出es模块) 但是模块过于静态化,HMR很难实现

通过以上的对比可以得出,构建App应用时,webpack比较合适,如果是类库(纯js项目),rollup更加适合。

webpack构建App的优势体现在以下几方面:

  1. 强大的插件生态,主流前端框架都有对应的loader
  2. 面向App的特性支持,比如之前提到的HMR按需加载公共模块提取等都是开发App应用必要的特性
  3. 简化Web开发各个环节,包括图片自动base64,[资源缓存](https://www.zhihu.com/search?q=%E8%B5%84%E6%BA%90%E7%BC%93%E5%AD%98&search_source=Entity&hybrid_search_source=Entity&hybrid_search_extra=%7B%22sourceType%22%3A%22article%22%2C%22sourceId%22%3A75717476%7D)(chunkId),按路由做代码拆分,懒加载
  4. 可靠的依赖模块处理,不像rollup那样仅面向ES module,面临cjs的问题(webpack通过__webpack_require__实现各种类型的模块依赖问题)

rollup的优势在于构建高性能的bundle,这正是类库所需要的。

Parcel:极速零配置

Parcel使用worker进程去启用多核编译,同时有文件系统缓存,即使在重启构建后也能快速再编译。内置html、babel、ts、less、sass、vue等功能,无需配置,而且不同于webpack只能将js文件作为入口,在parcel中万物皆资源,不需要webpack复杂配置,运行parcel xxx.html命令即可起一个自带热更新的server开发vue、react项目。

总结:webpack、rollup、parcel这些工具的思想都是递归循环依赖,然后组装成依赖树,优化完依赖树后生成代码。虽然parcel利用了多核、wepack支持多线程,但是打包大型项目依然会很慢。

三、基于浏览器ES模块的构建工具

仅打包屏幕中用到的资源,而不用打包整个项目,开发时的体验相比于 bundle类的工具只能用极速来形容。bundleless类运行时打包工具的启动速度是毫秒级的,因为不需要打包任何内容,只需要起两个server,一个用于页面加载,另一个用于HMR的WebSocket,当浏览器发出原生的ES module请求,server收到请求只需编译当前文件后返回给浏览器不需要管依赖。

vite原理分析

vite在启动服务器后,会预先以所有 html 为入口,使用 esbuild 编译一遍,把所有的 node_modules 下的依赖编译并缓存起来,例如vue缓存为单个文件。

当打开在浏览器中输入链接,渲染index.html文件的时候,利用浏览器自带的ES module来请求文件。

vite 收到一个src/main.js的 http 文件请求,使用esbuild开始编译main.js,这里不进行main.js里面的依赖编译。


浏览器获取到并编译main.js后,再次发出 2 个请求,一个是 vue 的请求,因为前面已经说了 vue 被预先缓存下来,直接返回缓存给浏览器,另一个是App.vue文件,这个需要@vitejs/plugin-vue来编译,编译完成后返回结果给浏览器(@vitejs/plugin-vue会在脚手架创建模板的时候自动配置)。

因为是基于浏览器的ES module,所以编译过程中需要把一些 CommonJsUMD 的模块都转成 ESM

Vite 同时利用 HTTP 头来加速整个页面的重新加载(再次让浏览器为我们做更多事情):源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求,即使缓存失效只要服务没有被杀死,编译结果依然保存在程序内存中也会很快返回。

为什么基于bundless生产环境依然会构建依赖方式打包

由于嵌套导入会导致发送大量的网络请求,即使使用HTTP2.x(多路复用、首部压缩),在生产环境中发布未打包的ESM仍然性能低下。因此,对比在开发环境Vite使用esbuild来构建依赖,生产环境Vite则使用了更加成熟的Rollup来完成整个打包过程。因为esbuild虽然快,但针对应用级别的代码分割、CSS处理仍然不够稳定,同时也未能兼容一些未提供ESM的SDK。为了在生产环境中获得最佳的加载性能,仍然需要对代码进行tree-shaking、懒加载以及chunk分割,以获得更好的缓存。