前端模块化及自动化构建工具发展学习总结

783 阅读8分钟

我们从模块化的发展历程开始

自调用函数

JavaScript诞生之初,网络设备性能还很差,网速很慢,将所有交互都放在后端的话用户体验很差,因此急需一门语言来处理简单的前端交互,如表单非空校验之类的,js就因此诞生

一开始js能做的事情并不多,代码量很少,将代码直接写到 script 标签或者一个单独的js文件里即可

// index.html
<script>
var name = 'doge'
var age = 18
</script>

// index.html
<script src="./main.js"></script>

到后来 ajax 诞生,前端能做的事情越来越多,代码量大增,单个 js 文件会导致文件过大;开发者们便根据功能划分了不同的 js 文件,以便复用和维护

// index.html
<script src="./main.js"></script>
<script src="./foo.js"></script>
<script src="./bar.js"></script>

但是早期的 js 是只有函数作用域的,文件里声明的变量都是在全局生效的,这就导致了全局变量的污染,多人开发维护变得困难

于是大家就想到了利用函数作用域和闭包的特性,既保留了私有变量,又暴露出了功能接口

var module1 = (function(){
    var foo = 1;
  
    function getFoo(){
      return foo;
    };
    function changeFoo(param){
      foo = param
    };
  
    return {
        getFoo: getFoo,
        changeFoo: changeFoo
    };
})();

这就是模块化的雏形,但是用这种方式存在很多问题

  • 需要手动排列模块的加载顺序来保证模块间的相互引用
  • 依赖关系不清晰,应用复杂之后难以维护
  • 仍然有全局变量污染问题

Commonjs / AMD / CMD

到 2009 年,Nodejs 发布,将 js 带到服务端,并将服务端 js 的模块化规范 Commonjs 发扬光大

var foo = require('./foo');
module.exports = {
  bar:1
}

开发者们想把这种模块化迁移到浏览器端,但浏览器不能直接识别 commonjs 的语法,且 commonjs 是同步加载文件的,用于服务器时,依靠磁盘读取模块,影响不大,而用于浏览器时则需要靠请求获取模块,会造成阻塞的问题,因此出现了两个分支 AMD (Asynchronous Module Definition) 和 CMD (Common Module Definition) 规范,还有他们各自的实现方案 RequirejsSeajs

// AMD
define(['Module1'], function (module1) {
    var result1 = module1.exec();
    return {
      result1: result1,
    }
}); 

// CMD
define(function (requie, exports, module) {
    var module1 = require('Module1');
    var result1 = module1.exec();
    module.exports = {
      result1: result1,
    }
});

这两种实现都是通过动态创建 script 标签来实现异步加载 js 模块,最大的不同在于 AMD 推崇 依赖前置 ,模块加载完就执行依赖包,CMD 推崇 依赖就近 ,下载后不执行,require 调用时才执行依赖包

虽然 AMD 和 CMD 解决了前端模块化的问题,但这类方案都是通过 “在线编译” 的方式来组织模块的,当用户访问页面后开始下载依赖包,下载好后再进行模块的依赖分析确定加载和执行顺序,这种方式存在以下问题

  • 在线组织依赖包会延长页面加载的时间
  • 加载过程中还会发出大量 http 请求,而 http1.x 协议的队头阻塞和浏览器并行请求限制问题会导致页面性能降低

bundle 类的构建工具

为了解决这些问题,出现了各种打包工具,最有代表性的是 2011 年推出的 broswerify 和 2012 年发布的 webpack ,他们可以在代码部署上线前就将模块依赖组织好,并将大量依赖包合并成少数几个,以此减少 http 请求的数量,提升页面性能;

当代码量很多的时候,会出单个打包产物过大的问题,webpack 提供了 代码拆分 (Code Splitting) 的功能,可以将产物包分成多个,比如将不常更新的第三方库和常更新的业务代码分开打包,利用浏览器缓存第三方库依赖包,以此提高访问速度;通过代码拆分还可以实现按需加载,提高首屏访问速度

在打包工具还在发展的过程中,开发者们逐渐不满足于仅仅打包,希望将代码压缩等重复性的工作都交给工具解决,这就是自动化构建工具的产生,代表工具依旧有 webpack ,还有 2012 年发布的 grunt ,及2013年发布的 gulp

gulp 是编程式的,链式调用,写配置像是写业务代码一样

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

function css() {
  return src('client/templates/*.less')
    .pipe(less())
    .pipe(minifyCSS())
    .pipe(dest('build/css'))
}  

webpack 不止于打包,还加入到了自动化构建的行列里;它是配置式的,除了主流程的配置,最主要的就是loaderplugin ;loader 用于解析非 js 模块,而 plugin 用于实现压缩优化,代码拆分等 loader 无法实现的内容;

// webpack.config.js
module.exports = {
  mode: "production",
  /** webpack打包入口 */
  entry: path.join(__dirname, "../src/app.tsx"),
  output: {},
  resolve: {},
  /** 配置如何处理项目中的不同类型的模块 */
  module: {
    rules: [
      {
        test: /\.(j|t)sx?$/,
        exclude: /node_modules/,
        loader: "babel-loader",
      },
    ],
  },
  /** 插件配置 */
  plugins: [],
  /** 开发服务器配置 */
  devServer: {},
};

最后 webpack 在自动化构建工具的竞争中胜出,是现在的主流,但它也有一些问题,比如

  • 配置复杂
  • 随项目内容增多,构建逐渐变慢

大型项目构建慢,这也是 bundle 类构建工具的通病,因为这些工具的思想都是先递归循环依赖包,组建依赖树,优化依赖树后生成可运行的部署包,这一打包的步骤在开发阶段也要不断重复运行,随项目复杂导致开发效率降低

Es module

2015 年 es6 正式发布,带来了官方的模块化规范 es module;

<script type="module" src="./foo.js"></script>
import {} from '/foo.js'
const bar = {}
export default bar 

esm 有以下几个特点

  • 异步加载,等同于打开了 <script> 标签的 defer 属性,页面渲染完成后执行

  • 代码是在模块作用域之中运行,而不是在全局作用域运行,this 为 undefined 而不是 window

  • 静态化,编译时就确定模块的依赖关系,输出需要的接口

js的运行分为两个阶段:预编译期(预处理)与执行期,esm在预编译时组织模块关系

  • 可以进行模块的部分导出

  • 导出的是值的引用(会随原始值变化而变化)

可以对比 commonjs

  • 运行时组织依赖关系,确定导出的内容
  • 导出的是一个完整的对象
  • 导出的是一个值的拷贝(不会随原始值变化而变化)

当在使用模块进行开发时,其实是在构建一张依赖关系图,esm将这个过程分为以下三个步骤

  1. 构建:查找,下载,然后把所有文件解析成模块记录。
  2. 实例化:为所有模块分配内存空间(此刻还没有填充值),然后依照导出、导入语句把模块指向对应的内存地址。这个过程称为链接(Linking)。
  3. 运行:运行代码,从而把内存空间填充为真实值。

这三个步骤可以独立运行,避免阻塞;构建模块的过程中还会生成模块映射,避免重复解析下载,具体可以看 这篇文章

2844683888-5acc4b80a4c5d_fix732.png

相比于 commonjs ,基于 es module 静态化和可以部分导出的特性,我们可以很方便的优化掉依赖中不需要使用的代码

2015 发布的 Rollup 就是一个基于 esm 的专注于类库的打包工具,通过 esm 的特性实现 tree-shaking

bundleless类的构建工具

2018 年 5 月 Firefox 60 发布 所有主流浏览器支持 es module,这让浏览器自己处理模块化关系成为可能,因此出现了一批新的构建工具如 vite ,snowpack ,在开发环境下它们以原生 esm 的方式提供源码,以提高开发效率,这里以 vite 为例

vite 的官网 上其实也很清楚的解释了为什么 vite 比 webpack 快,这里做个总结

vite 将应用中的模块区分为 依赖(第三方库) 和 源码(jsx/css/..)

  • 启动快

    • 对于依赖,vite 使用 esbuild 进行预构建(转换 commonjs/umd 为 esm ,将内部依赖复杂的模块合并为单个模块),esbuild 使用 Go 编写,比以 JavaScript 编写的打包器预构建依赖快 10-100 倍

    • 对于源码,只有屏幕上实际使用的才会被处理

    18214510-6d0a67518cbf236d.png

    18214510-600629aebf7176f8.png

  • 更新快

    • 传统构建工具修改后需要重新打包构建,而Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失活,通常只需要替换被更新的模块本身
    • Vite 同时利用 HTTP 头来加速整个页面的重新加载:源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求

大体流程:vite 服务器启动 => 以 html 为入口使用 esbuild 预构建依赖 => 在浏览器输入链接访问index.html 入口 => 浏览器发出 esm 依赖请求 => vite 服务器将源码 (vue/jsx/tsx) 编译为浏览器可识别的 js 返回 => 浏览器根据返回的文件继续请求相关依赖,直至依赖关系构建完成,执行代码

由于在依赖关系构建的过程中仍然需要发送大量 http 请求,因此生产环境仍然需要打包发布以获得最佳体验,vite使用 rollup 进行 js 打包

最后放一张一年内各工具的 npm 下载量,可以看到 webpack 还是绝对的主流,vite 有了渐渐抬头的趋势

18214510-b494ab84e83d7384.png

参考