浅谈前端构建工具

199 阅读13分钟

现在前端项目的开发过程离不开构建工具帮助,我们应该去了解一下构建工具发展历史、底层基本原理,处理一些问题的时候往往能起到事半功倍效果。伴随对以下问题的思考,开始构建之旅。

  1. 前端为什么需要构建工具?
  2. 什么原因导致构建工具演变?
  3. 我的项目更适合使用哪个构建工具?

什么是构建?

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

虽然市面上存在着五花八门的构建工具,但它们的最终目标都是一致的,那就是转换开发环境下的代码为生产环境中的代码。围绕着这个终极目标,不同的构建工具基于各种场景并侧重实现了对应的功能特性。(如文件打包、代码压缩、code splitting、tree shaking、hot module replacement等功能)

前端构建工具演变历程

从什么时候开始需要构建,到今天构建工具层出不穷各领风骚。这个过程我们到底经历了哪些故事?我们又可以依靠哪些工具来实现我们不同时期的目标?这一切离不开前端工程的模块化的演进史~

屏幕快照 2023-03-23 下午5.57.50.png

# 刀耕火种-无模块化时代

JS内联外联

前端代码是否必须通过构建才可以在浏览器中运行呢? 当然不是。同学是否记得刚开始学习前端时的情景,只需要按格式写几个HTML标签,之后再插入一段简单的JS代码,打开浏览器Hellp World就展示在页面上。

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

可以看到,这段内联JS代码并没有经过任何构建工具的处理就能够成功运行在浏览器之中。这样的代码在小型项目之中还算勉强可以维护,但当项目进入真正的实战开发,代码规模开始急速扩张后,大量逻辑混杂在一个文件之中就变得让人难以直视。早期的前端项目组织如下:

<html>
  <head></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的内联外联组织代码,将不同的代码放在不同的文件中, 但是这也仅仅解决了代码组织混乱的问题,还存在很多问题:

  • 大量的全局变量,代码之间的依赖是不透明的,任何代码都可能改变了全局变量。
  • 脚本的引入需要依赖特定的顺序。

后续出现过一些IIFE、命名空间等解决方案,但是从本质上都没有解决依赖全局变量通信的问题。在这样的背景下,JS模块化成为迫切需要的能力。然而,由于JS的先天不足,模块化并不存在于JS最初的设计之中,因此不同的模块化方式开始逐渐涌现。JS模块化也成为前端走上工程化道路的关键因素

社区模块化时代

AMD/CMD - 异步模块加载

为了解决浏览器端JS模块化的问题, 出现了通过引入相关工具库的方式来解决这一问题。出现了两种应用比较广的规范及其相关库:AMD (RequireJs) 和 CMD(Sea.js) 。AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。相关写法

Require.js

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

Sea.js

define(function(require, exports, module) { 
    var $ = require('jquery'); 
    $('#header').hide(); 
});

两种模块化规范实现的原理基本上是一致的,只不过各自坚持的理念不同。两者都是以异步的方式获取当前模块所需的模块,不同的地方在于AMD在获取到相关模块后立即执行,CMD则是在用到相关模块的位置再执行的。

AMD/CMD解决问题:

  1. 手动维护代码引用顺序。从此不再需要手动调整HTML文件中的脚本顺序,依赖数组会自动侦测模块间的依赖关系,并自动化的插入页面。
  2. 全局变量污染问题。将模块内容在函数内实现,利用闭包导出的变量通信, 不会存在全局变量污染的问题

从内外联JS到AMD/CMD模块化前端代码的出现,前端开始逐步走出了刀耕火种的时代。不过到目前为止,这些工程本质上都可以直接运行于浏览器之中,前端工程化还处于萌芽阶段,各式各样的前端自动化构建工具都还未出场。

NodeJS兴起

在Google Chrome推出V8引擎后,基于其高性能和平台独立的特性,Nodejs这个JS运行时也现世了。至此,JS打破了浏览器的限制,拥有了文件读写的能力。Nodejs 不仅在服务器领域占据一席之地,也将前端工程化带进了正轨。

在这个背景下,第一批基于Node.js的构建工具出现了。

Grunt

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

Grunt的I/O操作比较“呆板”,每个任务执行结束后都会将文件写入磁盘,下个任务执行时再将文件从磁盘中读出,这样的操作会产生一些问题:

  1. 运行速度较慢
  2. 硬件压力大

Gulp

Gulp最大特点是引入了流的概念,同时提供了一系列常用的插件去处理流,流可以在插件之间传递。同时Gulp设计简单,既可以单独使用,也可以结合别的工具一起使用。

const { src, dest } = require('gulp'); 
// gulp提供的一系列api 
// src 读取文件 
// dest 写入文件 
const babel = require('gulp-babel'); 
exports.default = function() { 
    return src('src/*.js') 
    .pipe(babel()) 
    .pipe(dest('output/')); 
}

Browserify

随着Node.js的兴起,CommonJS模块化规范 成为了当时的主流规范。但是我们知道CommonJS所使用的require语法是同步的,当代码执行到require方法的时候,必须要等这个模块加载完后,才会执行后面的代码。这种方式在服务端是可行的, 这是因为服务器只需要从本地磁盘中读取文件,速度还是很快的,但是在浏览器端,我们通过网络请求获取文件,网络环境以及文件大小都可能使页面无响应。

browserify 致力于打包产出在浏览器端可以运行的CommonJS规范的JS代码。

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

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

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

ESM-规范出现

在经历了AMD/CMD/CommonJS等模块化规范多年的割据混战之后,在2015年JavaScript官方的模块化标准ESM(ECMAScript module)终于姗姗来迟。与之前的规范不同的是ESM规范本身只阐述了应该如何将文件解析为模块记录,如何实例化和对该模块求值,但并没有对文件获取的方式做出要求,因此ESM同时支持同步和异步的模块加载方式。

Webpack

其实在ESM标准出现之前, 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真正成为了前端工程化的核心。

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

webpack要兼顾各种方案的支持, 也暴露出其缺点:

  • 配置往往非常繁琐,开发人员心智负担大。
  • webpack为了支持cjs和esm,自己做了polyfill,导致产物代码很“丑”。

在webpack出现两年后, rollup诞生了~。

Rollup

rollup 是一款面向未来的构建工具,完全基于ESM模块规范进行打包,率先提出了Tree-Shaking的概念。并且配置简单,易于上手,成为了目前最流行的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: [
    resolve(),  // 转换commonjs模块为ESM
    babel({ // babel转换语法
      exclude: 'node_modules/**'
    })
  ]
}

rollup 基于esm, 实现了强大的 Tree-Shaking 功能, 使得构建产物足够的简洁、体积足够的小。但是要考虑浏览器的兼容性问题的话, 往往需要配合额外的polyfill库。

ESM-规范原生支持

Esbuild

在实际开发过程中,随着项目规模逐渐庞大,前端工程的启动和打包的时间也不断上升,一些工程动辄几分钟甚至十几分钟,漫长的等待,真的让人绝望。这使得打包工具的性能被越来越多的人关注。

esbuild是一个非常新的模块打包工具,它提供了类似webpack资源打包的能力,但是拥有着超高的性能。

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))

屏幕快照 2023-03-23 下午7.29.22.png

根据官方提供的性能对比, 我们可以看到性能足有百倍的提升,为什么会这么快~?

语言优势

  • esBuild是选择 Go 语言编写的,而在esBuild之前,前端构建工具都是基于Node,使用JS进行编写。JavaScript是一门解释性脚本语言,即使V8引擎做了大量优化(JWT 即时编译),本质上还是无法打破性能的瓶颈。而 Go 是一种编译型语言,在编译阶段就已经将源码转译为机器码,启动时只需要直接执行这些机器码即可。
  • Go 天生具有多线程运行能力,而 JavaScript 本质上是一门单线程语言。esBuild进过精心的设计,将代码parse、代码生成等过程实现完全并行处理

性能至上原则

  • esBuild 只提供现代Web应用最小的功能集合,所以其架构复杂度相对较小,更容易将性能做到极致
  • 在webpack、rollup这类工具中, 我们习惯于使用多种第三方工作来增强工程能力。比如:babel、eslint、less等。在代码经过多个工具流转的过程中,存在着很多性能上的浪费, 比如:多次进行代码-> AST 、AST->代码的转换。esBuild 对此类工具完全进行了定制化重写,舍弃部分可维护性,追求极致的编译性能。

虽然esBuild性能非常高,但是其提供的功能很基础,不适合直接用到生产环境,更适合作为底层的模块构建工具,在它基础上进行二次封装。

Vite

vite 是下一代前端开发与构建工具,提供 noBundle 的开发服务,并内置丰富的功能, 无需复杂配置。

vite 在开发环境和生产环境分别做了不同的处理,在开发环境中底层基于 esBuild 进行提速, 在生产环境中使用rollup进行打包。

为什么vite开发服务这么快

屏幕快照 2023-03-23 下午7.30.08.png

传统bundle based服务

  • 无论是webpack还是rollup提供给开发者使用的服务,都是基于构建结果的。
  • 基于构建结果提供服务,意味着提供服务前一定要构建结束,随着项目膨胀,等待时间也会逐渐变长。

noBundle服务

  • 对于vite、snowpack这类工具,提供的都是No Bundle服务,无需等待构建,直接提供服务。
  • 对于项目中的第三方依赖, 仅在初次启动和依赖变化时重构建,会执行一个依赖预构建的过程。由于是基于esBuild做的构建,所以非常快。
  • 对于项目代码,则会依赖于浏览器的ESM的支持,直接按需访问,不必全量构建。

为什么在生产环境中构建使用rollup?

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

export default defineConfig({
  resolve:{
    alias:{
     '@':resolve('src')
    }
  },
  plugins: [vue()]
})

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深入结合带来的问题会相对少一些。)