前端构建工具简史

2,029 阅读12分钟

序言

现代前端项目的开发过程中已经离不开构建工具的帮助了,无论你的项目是新是旧,你一定都多多少少接触过相关工具,它们可能是webpack/Rollup这种当前主流的打包构建工具,也可能是Vite/Snowpack这种新兴的bundless构建工具,异或是Grunt/Gulp/Browserify这种传统的构建工具。然而,与直接深入研究这些令人眼花缭乱的构建工具是如何配置的相比,我们更需要思考的是:

  1. 前端项目为什么需要构建?
  2. 为什么会有这么多的构建工具?
  3. 我的项目应该使用哪个构建工具?

如果你对上述几个问题有所疑问的话,那么不妨花几分钟时间读一下这篇文章。

一、什么是构建?

首先,我们先对构建做一个定义:

构建就是把我们在开发时编写的代码,转换成生产时部署的代码。

诚然这个定义并不是最准确的,但一定是最简单明了的。没错,虽然市面上存在着五花八门的构建工具,但它们的最终目标都是一致的,那就是转换开发环境下的代码为生产环境中的代码。围绕着这个终极目标,不同的构建工具又加入并侧重实现了了不同的功能作为卖点。(如文件打包、代码压缩、code splitting、tree shaking、hot module replacement等等功能)

不过需要明白的是,这些功能的多少并不是我们选择一个构建工具时的唯一判断标准,也并非构建工具更新迭代的主要原因。要想了解构建工具为什么会更新迭代,它们究竟是如何演变成现在这样的,未来可能会去往何处,我们还要围绕着前端的模块化说起。

二、刀耕火种 - JS内联外联

前端构建是必须的吗?当然不是!

不知还有多少同学记得刚开始学习前端时的情景,只需要按格式写几个HTML标签,之后再插入一段简单的JS代码,打开浏览器Hellp World就展示在我们的界面之上了。

<html>
  <head>
    <title>Hello World</title>
  </head>
  <body>
    <div id="root"/>
    <script type="text/javascript">
      document.getElementById('root').innerText = 'Hello World'
    </script>
  </body>
</html>

可以看到,这段内联JS代码并没有经过任何构建工具的处理就能够成功运行在浏览器之中。这样的代码在不超过几百行代码的小型学习项目之中还算勉强可以维护,但当项目进入真正的实战开发,代码规模开始急速扩张后,大量逻辑混杂在一个文件之中就变得让人难以忍受了起来。因此,大部分最最初级的前端项目代码也是如下形式组织的。

<html>
  <head>
    <title>JQuery</title>
  </head>
  <body>
    <div id="root"/>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script type="text/javascript">
      $(document).ready(function(){
        $('#root')[0].innerText = 'Hello World'
      })
    </script>
  </body>
</html>

这种外联引用JS的方式使得代码逻辑得以按照文件进行拆分,例如JQuery本身的代码将会放在JQuery文件之中,我们的项目代码可以放在自己创建的文件之中。

然而,单纯的外联引用JS也只是对代码组织混乱问题的掩盖而已:大量的全局变量充斥在我们的项目之中,代码引用时需要依赖特定顺序,这些都使得代码的维护变得更加复杂。

<html>
  <head>
    <title>JQuery</title>
  </head>
  <body>
    <div id="root"/>
    <script type="text/javascript">
    	// undefined!!!此时JQuery还未加载,$变量还未定义
      console.log($)
    </script>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
  </body>
</html>

此后出现了如IIFE、命名空间等手段来控制代码的复杂度,但这些手段本质上还是无法解决依靠代码间全局变量通信的弊端。在这样的背景下,JS模块化成为了让前端走上工程化道路正轨的唯一选择。然而,由于JS的先天不足,模块化并不存在于JS最初的设计之中,因此不同的模块化方式开始逐渐涌现。

三、 AMD/CMD - 异步模块加载

为了解决浏览器端JS模块化问题,最先被接受的方式是引入相关工具库来实现。其中,RequireJs所提出并推广的AMD(Asynchronous Module Definition)规范是当时最为广泛采纳的方案。

define(id?, dependencies?, factory);

define("mycode", ["jquery"], function ($) {
  $(document).ready(function(){
    $('#root')[0].innerText = 'Hello World'
  })
  return $
})

AMD规范采用了依赖前置的方式,使用时将所有依赖放入dependecies数组之中,RequireJS在执行过程中会先加载并执行dependecies数组之中所传入的文件,接下来将这些文件的返回值作为factory函数的参数并执行该函数。

RequireJS的代码加载能力实际上是依靠动态生成并插入script标签完成的,只是将这个过程由手工变为了自动。(有兴趣的同学可以阅读RequireJS原理分析

RequireJS的引入解决了此前手动外联JS文件时存在的两个痛点:

  1. 手动维护代码引用顺序。RequireJS要求用户明确每个模块的依赖,通过dependecies数组自动侦测建立模块间的依赖关系,最终帮助用户自动化按需按序插入Script标签。
  2. 全局变量污染。RequireJS通过将模块文件封装在函数之中执行,只对外提供返回值的方式(实际上就是闭包)隐藏了模块文件中大量存在的全局变量。

但是AMD规范的执行时机(factory执行前先执行所有依赖的模块)以及依赖前置声明(必须先声明依赖,破坏了就近声明原则)引起了社区的争论,在多次向RequireJS提出意见但并未被采纳后,玉伯自己写出了Sea.js及其遵守的CMD(Common Module Definition)规范。(玉伯相关文章前端模块化开发那点历史

define(factory);

define(function (require, exports, module) {
  var $ = require("jquery");
  $(document).ready(function(){
    $('#root')[0].innerText = 'Hello World'
  })
  exports.rootText = 'Hello World'
});

在CMD规范中,一个模块就是一个文件。与AMD规范不同,CMD模块不需要提前声明,只需要在使用时调用require该模块才会被执行。不过其基本实现原理与AMD类似,这里不再赘述。随着时代的发展,AMD/CMD现在已经逐渐被掩埋在历史的尘埃之中了。

从内外联JS到AMD/CMD模块化前端代码的出现,前端开始逐步走出了刀耕火种的时代。不过到目前为止,这些工程本质上都可以直接运行于浏览器之中,前端工程化还处于萌芽阶段,各式各样的前端自动化构建工具都还未出场。(注意:这里所说的构建工具是不包括当时用来实现压缩代码、文件合并等功能的JAVA程序或Shell脚本)

四、番外1 - NodeJS兴起

其实早在前端自动化构建工具成型之前,前端项目就已经有了构建的需求,例如代码压缩、部分文件或图片合并等,只不过这功能都需要开发者手动或通过其它语言/脚本完成。

时间回到2009年,在Google Chrome推出V8引擎后,基于其高性能和平台独立的特性,Ryan Dahl开发出了Node.js这个JavaScript运行时。自此,JS终于挣脱了浏览器的束缚,开始拥有了文件操作的能力。Node.js不仅让JS在服务器领域崭露头角拥有了一席之地,更重要的是它将前端真正带入了现代工程化的道路。

此后不久,第一批基于Node.js的构建工具便开始展露头脚,其中以Grunt这个自动化构建工具最为广泛使用。

Grunt

Grunt主要能够帮助我们自动处理一些需要反复重复的任务,例如代码压缩、编译、单元测试、linting等工作。

// Gruntfile.js
module.exports = function(grunt) {
  // 功能配置
  grunt.initConfig({
    // lint检查配置
    jshint: {
      files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],
      options: {
        globals: {
          jQuery: true
        }
      }
    },
    // 文件变化时侦测并自动执行相关任务
    watch: {
      files: ['<%= jshint.files %>'],
      tasks: ['jshint']
    }
  });
	
  // 加载相关任务插件
  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-watch');
  
	// 默认执行的任务列表
  grunt.registerTask('default', ['jshint']);
};

可以看到,Grunt是配置驱动的,开发者需要做的就是了解各种插件的功能,然后把配置整合到 Gruntfile.js 中。不过这种方式在任务非常多的情况下配置复杂度会急剧上升,代码看起来比较混乱。另外Grunt的I/O操作也是其弊病之一,在Grunt中每一个的任务结束后都会将文件写入磁盘,下一个任务开始后再从磁盘中读取相关文件,运行速度较慢。

Gulp

为了解决Grunt在使用中的问题,基于流(streaming)的自动化构建工具Gulp应运而生。Gulp最大特点是引入了流的概念,同时提供了一系列常用的插件去处理流,流可以在插件之间传递。这使得它本身被设计的非常简单,但却拥有强大的功能,既可以单独完成构建,也可以和其他工具搭配使用。

// gulpfile.js
const { src, dest } = require('gulp');
const babel = require('gulp-babel');

exports.default = function() {
  // 将src文件夹下的所有js文件取出,经过Babel转换后放入output文件夹之中
  return src('src/*.js')
    .pipe(babel())
    .pipe(dest('output/'));
}

Node.js的兴起带来了Grunt/Gulp这类自动化构建工具,前端工程化开始逐步走上了正轨。

五、CommonJS - 同步模块加载

随着Node.js的兴起,其所遵循的CommonJS模块化规范也被大部分开发者所接受并成为了当时的主流。CommonJS所使用的require语法是同步的,当开发者使用require加载一个模块的时候,必须要等这个模块加载完后,才会执行后面的代码。同步加载的特性在Node.js所运行的服务器端只需要从本地硬盘中读取文件,速度还是比较快的,然而在浏览器端由于存在着网络的延迟,这样的加载方式很容易使得页面进入无响应状态。因此,想要在浏览器中使用CommonJS是行不通的。

Browserify

难道开发者需要在一个项目中同时使用不同的模块化规范吗?大可不必!有需求必然就有市场,browserify的出现就是致力于帮助开发者在浏览器端也能使用CommonJS。

var browserify = require('browserify')
var b = browserify()
var fs = require('fs')

// 添加入口文件
b.add('./browser/main.js')
// 打包所有模块至一个文件之中并输出bundle
b.bundle().pipe(fs.createWriteStream('./bundle.js'))

browserify在运行时会分析AST从而得到每个模块间存在的依赖关系,生成一个依赖字典。之后包装每个模块,传入依赖字典以及自己实现的 export 和 require 函数,最终生成一个可以在浏览器环境中执行的JS文件。这个过程通常被称为打包。

打包本身是一个比较模糊的比喻,可以为想象我们在饭馆吃饭后的打包。我们当然可以将所有的剩菜全部放入一个塑料盒中打包回家,但是大多数情况下我们会根据菜肴的类别将可能会‘窜味’的菜放入不同的塑料盒之中。

一般来说我们将多个文件中的模块化代码集中起来合并在一个文件中就可以称为打包。不过由于这种将整个工程的代码放入一个文件中提供给浏览器执行的方式会因为单文件过大而导致网页加载过慢,因此代码分割、动态加载的需求开始日渐兴盛,最终形成了将多个文件中的模块化代码集中起来合并在多个文件中的打包方式。

不过由于browserify职责单一,只负责js模块合并打包,同时其代码风格类似于管道函数,与gulp契合度较高,因此开发者常常将它们结合起来使用。Gulp+browserify的构建模式在一段时期内几乎是前端公认的工程化标配。

var browserify = require('browserify');
var gulp = require('gulp');
// vinyl-source-stream可以作为两者粘合剂
// 使用它将browserify生成的文件转换成gulp支持的stream
var source = require('vinyl-source-stream');
 
gulp.task('browserify', function() {
    return browserify('./src/javascript/app.js')
        .bundle()
        // Pass desired output filename to vinyl-source-stream
        .pipe(source('bundle.js'))
        // Start piping stream to tasks!
        .pipe(gulp.dest('./build/'));
});

六、ESM - 规范出现

在经历了AMD/CMD/CommonJS等等模块化规范多年的割据混战之后,在2015年JavaScript官方的模块化标准ESM(ECMAScript module)终于姗姗来迟。与之前的规范不同的是ESM规范本身只阐述了应该如何将文件解析为模块记录,如何实例化和对该模块求值,但并没有对文件获取的方式做出要求,因此ESM同时支持同步和异步的模块加载方式。(推荐阅读ES modules: A cartoon deep-dive,这可能是目前最通俗、最详细、最精确的ESM讲解了)

Webpack

这里不得不提到目前最主流的打包构建工具Webpack。Webpack的理念更偏向于工程化,在刚推出时并没有马上火起来,因为当时的前端开发并没有太复杂,有一些 mvc 框架但都是昙花一现,前端的技术栈在 requireJs/sea.js、grunt/gulp、browserify、webpack 这几个工具之间抉择。

伴随着MVC框架以及ESM的出现与兴起,webpack2顺势发布,宣布支持AMD\CommonJS\ESM、css/less/sass/stylus、babel、TypeScript、JSX、Angular 2 组件和 vue 组件。从来没有一个如此大而全的工具支持如此多的功能,几乎能够解决目前所有构建相关的问题。SPA应用的复杂度使得webpack搭配React/Vue/Angular成为最佳选择,至此webpack真正成为了前端工程化的核心。

Webpack主要可以分为3个部分:

  1. 主流程:启动构建,读取参数,加载插件,之后从入口文件出发寻找依赖模块并递归地对其进行编译处理,最终打包并输出构建结果。
  2. loader:根据规则处理并转换其所匹配文件中的代码。
  3. Plugin:webpack依赖于Tapable做事件分发,首先在插件中注册钩子,之后在主流程的不同阶段触发不同的钩子从而执行不同的插件。

Webpack是基于配置的,一个简单的配置文件示例如下所示。

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
    // SPA入口文件
    entry: 'src/js/index.js',
    output: {
      filename: 'bundle.js'
    }
    // 模块匹配和处理 大部分都是做编译处理
    module: {
        rules: [
  					// babel转换语法
            { test: /\.js$/, use: 'babel-loader' },
            //...
        ]
    },
    plugins: [
      	// 根据模版创建html文件
        new HtmlWebpackPlugin({ template: './src/index.html' }),
      	//...
    ],
    //...
}

不过,这种大而全的配置模式有着配置繁琐这个非常明显的缺点,因此很快就引起了一些小型项目开发者的厌烦。对于这部分开发者而言,他们更倾向于选择另一个小而精巧的Rollup作为自己的打包工具。

Rollup

Rollup完全基于ESM模块规范进行打包(也可以通过插件支持CommonJS),率先提出了Tree-Shaking的概念(后来webpack也跟进了,简单说就是由于ESM模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,因此打包工具有机会在编译时分析出无用代码并且将其去除),再加上其配置简单,易于上手,成为了目前最流行的JS库打包工具。

import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';

export default {
  // 入口文件
  input: 'src/main.js',
  output: {
    file: 'bundle.js',
    // 输出模块规范
    format: 'esm'
  },
  plugins: [
    // 转换commonjs模块为ESM
    resolve(),
    // babel转换语法
    babel({
      exclude: 'node_modules/**'
    })
  ]
}

Rollup专注于纯javascript,对于其它非JS资源的打包、热更新等特性的支持都不如webpack,因此在前端项目工程中使用的不多。

七、番外2 - 构建提速

随着前端工程化的壮大,目前越来越多庞大前端工程的打包时间不断提升,这些工程的打包时间动辄几分钟甚至十几分钟以上,这也使得打包工具的性能被越来越多的人所关注。虽然V8引擎为JS的运行提供了强大的性能支持,但本质上还是无法摆脱JS解释型语言的性能桎梏。为了进一步提升构建性能,近年来出现了一些底层不使用JS编写的打包构建工具,其中以Esbuild最为著名。

Esbuild

Esbuild底层使用go语言并大量使用了其高并发的特性,在速度上可以说是完胜目前市面上所有的JS打包构建工具。

ESBuild

Esbuild支持ES6/CommonJS规范、Tree Shaking、TypeScript、JSX等功能特性。提供了JS API/Go API/CLI多种调用方式。

// JS API调用
require('esbuild').build({
  entryPoints: ['app.jsx'],
  bundle: true,
  outfile: 'out.js',
}).catch(() => process.exit(1))

不过目前Esbuild还很年轻,没有达到1.0版本,并且其打包构建与Rollup类似更关注于JS本身,所以并不适合单独使用在前端项目的生产环境之中,但这并不妨碍其极具发展潜力的事实。

八、ESM - 浏览器支持

随着ESM规范逐渐成熟,目前其已经被各大主流浏览器支持,ESM模块化已经成为了浏览器的内置功能,因此JS代码实际上已经不需要打包了。以下HTML文件就利用了浏览器支持ESM的特性,可以看到现阶段import导入模块已经可以不进行任何转译打包就直接运行在新版的chrome/firefox/edge/safari等浏览器中。

<!DOCTYPE html>
<head>
  <title>ESModule</title>
</head>
<body>
  <div id="root"/>
  <script type="importmap">
    {
      "imports": {
        "react": "https://cdn.pika.dev/react",
        "react-dom": "https://cdn.pika.dev/react-dom"
      }
    }
  </script>
  <script type="module">
    // 支持 bare import
    import React from 'react'
    import ReactDOM from 'react-dom'
  
    ReactDOM.render('Hello!', document.getElementById('root'))
  </script>
</body>

这使得市面上出现了Vite、Snowpack等主打budless(不打包)概念的开发构建工具。

Vite

Vite是EVAN YOU开发的下一代前端开发与构建工具,它的构建过程在开发模式下和生产模式下使用了不同的手段。

在开发模式下

Vite启动开发模式时会根据项目中的依赖项使用Esbuild对其进行预构建,这主要是为了:

  1. 将CommonJS模块转换为ESM模块
  2. 将每个依赖项打包构建为一个文件,防止lodash-es这种库在ESM引入时产生瀑布请求(大量瞬时并发的文件get请求)

这些依赖项变动较少,在预构建后会被缓存至.vite目录中。

之后,Vite对项目代码并不做处理就直接启动开发服务器,利用浏览器对ESM的支持就可以打开项目。

可以看到在开发模式下,Vite能够做到如此快速主要得益于以下方面:

  1. 对于需要打包构建或编译转换的文件使用了Esbuild,Esbuild基于go语言编写,其性能是传统JS打包工具的百倍。
  2. 依赖预构建使得占据代码大小的大部分依赖项能够缓存下来,仅在初次启动和依赖变化时重构建,节省依赖构建时间。
  3. 直接输出ESM至浏览器,省去了传统开发构建工具在DEV Server启动前的代码打包时间。

Bundle dev VS ESM dev

在生产模式下

  1. 考虑到浏览器兼容性以及实际网络中使用ESM可能会造成RTT时间过长,所以仍然需要打包构建。
  2. 又因为Esbuild虽然快速,但其一方面还未到1.0的稳定版本,另一方面对代码分割和css处理等支持较弱,所以目前仍然使用Rollup进行生产环境的实际打包构建,不过未来很有可能改为esbuild。

与其说Vite是与Webpack类似的打包构建工具,倒不如将其想象为一个原生ESM开发服务器,只负责将浏览器ESM模式下无法解析的文件通过第三方库转换为ESM,例如将CommonJS转为ESM,将CSS转为ESM。其大多数真正的文件转换和打包构建能力都是借助于Esbuild或Rollup的。

可以看出,虽然Vite具备了下一代开发构建工具的潜力,但是目前其开发与生产环境打包过程的不同可能会带来项目在开发环境与线上表现不一致的问题。

Snowpack

SnowPack是Pika团队开发的轻量且快速的前端构建工具。该团队旨在让Web应用提速90%。SnowPack与Vite大部分功能和思想都非常相似(两者互相学习,如vite依赖预构建学习了snowpack,snowpack的HMR学习了vite)。不过,相比Vite的实用主义,Snowpack更理想化,技术至上一些。

两者的不同主要有以下两点:

  1. SnowPack支持Streaming Imports。

Streaming Imports原理非常简单,就是将本地npm包依赖转换为远程CDN中的npm包依赖。

// Original file
import "react"
 
// After compile (Streaming Imports)
import "https://pkg.snowpack.dev/react"

这样做的好处有以下几点:

  • 由于远程的npm包已经经过了打包编译,开发构建工具无需对依赖进行处理,节约了构建时间
  • 开发者无需在本地下载安装依赖项项目就可以运行
  • 远程npm包分布在CDN边缘节点中,用户页面在打开时会就近下载依赖,节约项目加载时间

之所以SnowPack能够想到并使用这种模式是因为Pika团队之前开发了一个叫做SkyPack的项目,该项目就是将npm包打包编译压缩后传至CDN中供开发者使用。

  1. SnowPack在生产模式中默认使用了Esbuild,不打包直接输出ESM,与开发模式行为保持一致。不过它支持用户选择其它打包构建工具如Rollup和Webpack。(注意:选择多不意味着一定就是好的,Snowpack选择多但每个模式都存在一些问题,而Vite与rollup深入结合带来的问题会相对少一些。)

九、总结

本文沿着前端模块化出现更迭的时间线,讲述了前端构建工具在每个阶段所扮演的角色与其发挥的作用,旨在让读者能够在目前琳琅满目的构建工具中抓住其发展更迭的主要脉络,粗略了解到其发展走向,从而对其做出更好的选择。

最后,我们对文中所出现的相关技术工具做一个简单的汇总。

RequireJSSea.jsGruntGulpBrowserify
运行时模块化AMDCMD
编译时模块化✅CommonJS
自动化构建配置式文件流
项目工程化支持一般一般一般,要与Gulp结合
WebpackRollupEsbuildViteSnowpack
运行时模块化✅ESM✅ESM
编译时模块化✅支持全部规范✅ESM✅ESM
自动化构建
项目工程化支持✅优一般,无HMR等一般,不单独使用✅良✅良

此外,文中只提到了一些具有代表性的工具,在前端构建的发展过程中,还有许许多多其它的工具或基于这些工具二次封装的工具,但是万变不离其宗,只要我们掌握了规律便可快速的理分辨理解该构建工具的优劣。