前端不能忘记的模块化演进史

2 阅读7分钟

模块化的进化史

早期 Javascript 当中的模块化就是以文件的划分进行实现的,这也就是 Web 中最原始的模块系统

文件划分

将每个功能和相关数据状态分别放在单独的文件里,约定每一个文件就是一个单独的模块,使用每个模块,直接调用这个模块的成员

// a.js
var name = 'module-a'

function method1 () {
  console.log(name + '#method1')
}

function method2 () {
  console.log(name + '#method2')
}

//  b.js 
var name = 'module-b'

function method1 () {
  console.log(name + '#method1')
}

function method2 () {
  console.log(name + '#method2')
}
<script src="a.js"></script>
<script src="b.js"></script>
<script>
  // 命名冲突
  method1()
  // 模块成员可以被修改
  name = 'foo'
</script>

我们可以发现 a 和 b 两者都定义了相同的方法,并且可以通过 name = 'foo' 去改变 name 的值,所以我们可以得出以下问题:

  • 污染全局作用域
  • 命名冲突问题
  • 无法管理模块依赖关系

总的来说,早期模块化完全依靠于约定(规范)

命名空间方式(匿名空间)

每个模块只暴露一个全局对象,所有模块成员都挂载到这个对象上,通过将每个模块包裹成一个全局对象的方式实现,类似于为每个模块添加命名空间的感觉

// module a 
var moduleA = {
  name: 'module-a',
  method1: function () {
    console.log(this.name + '#method1')
  },
  method2: function () {
    console.log(this.name + '#method2')
  }
}

// module b 
var moduleB = {
  name: 'module-b',
  method1: function () {
    console.log(this.name + '#method1')
  },
  method2: function () {
    console.log(this.name + '#method2')
  }
}
<script src="a.js"></script>
<script src="b.js"></script>
<script>
  moduleA.method1()
  moduleB.method1()
  // 模块成员可以被修改
  moduleA.name = 'foo'
</script>

这种模块化可以解决命名冲突的问题,但是这种方式仍然没有私有空间,模块成员仍然可以外部被修改,另外依赖关系也没得到解决

  • 命名冲突问题
  • 无法管理模块依赖关系

IIFE(立即执行表达式)

使用IIFE(立即执行表达式)为模块提供四有空间,对于需要向外暴露的成员,挂载到全局对象上的方式实现

// module a
;(function () {
  var name = 'module-a'
  
  function method1 () {
    console.log(name + '#method1')
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleA = {
    method1: method1,
    method2: method2
  }
})()


// module 
;(function () {
  var name = 'module-b'
  
  function method1 () {
    console.log(name + '#method1')
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleB = {
    method1: method1,
    method2: method2
  }
})()
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
  moduleA.method1()
  moduleB.method1()
  // 模块私有成员无法访问
  console.log(moduleA.name) // => undefined
</script>

我们可以看出 IIFE 有了模块的私有空间,但依赖关系也没得到解决

模块化规范

模块化的贯彻执行离不开相应的约定,即规范

Commonjs

提到模块化规范,我第一个想到的就是 Commonjs 规范,它是 Nodejs 提出的一个标准,我们在 Nodejs 中必须遵守 Commonjs 规范

  • 一个文件就是一个模块
  • 每个模块都有单独的作用域
  • 通过 module.exports 导出成员
  • 通过 require 函数载入模块

多次导入同一个模块,只会加载一次模块文件,第一次加载会放入缓存,剩下的导入的其实是缓存

由于 CommonJS 模块加载是同步的,因此可以在非顶级代码块中使用reruire 导入模块

if (loadCondition) {
  require('./moduleA.js') // 动态加载
}

那么如果 Commonjs 中存在循环引用,那么该怎么解决?

// a.js
const b = require('./b');
console.log('Print b in module a =>', b);
exports.name = 'a';

// b.js
const a = require('./a');
console.log('Print a in module b =>', a);
exports.name = 'b';

Node(v15.9.0)中运行上面的代码会报错,“RangeError: Maximum call stack size exceeded”,由于循环调用,最终栈溢出了

所以,模块系统必须要解决循环依赖的问题。规范中没有说明解决循环依赖的标准方法,但是声明了出现循环依赖时应该返回怎样的结果。即下面的代码中,运行 a.js,最终 b.js 中可以正确获取到导致循环依赖的 require 语句之前的导出结果

// a.js
exports.val = 100; //此处应该被正确导出,因为它在发生循环依赖的 require 语句之前
const b = require('./b'); //发生循环依赖
console.log('Print b in module a =>', b);
exports.name = 'a';

// b.js
const a = require('./a');
console.log('Print a in module b =>', a);
exports.name = 'b';

// 运行 a.js 后的打印结果
Print a in module b => { val: 100 }
Print b in module a => { name: 'b' }

但我们发现 Nodejs 中可以使用 Commonjs,而在浏览器中却一直没有遵循这个规范?

Nodejs 是启动时就去加载模块,执行过程中不需要去加载,所以 Commonjs 在 Node 中不会有问题。但是在浏览器使用 Commonjs,必然会使效率低下,因为每次页面加载,都会导致有大量的同步请求出现,所以在早期的前端模块中,并没有选择 Commonjs 规范,而是结合浏览器的特点,重新设计了一个规范,那就是 AMD

AMD

AMD 是 Asynchronous Module Definition 的简称,即“异步模块定义”,是从 CommonJS 讨论中诞生的。

AMD 优先照顾浏览器的模块加载场景,使用了异步加载和回调的方式。

浏览器端异步加载库 Require.js 实现的就是 AMD 规范,内部提供了 define 方法来导入或导出模块

所谓异步加载,就是指同时并发加载所依赖的模块,当所有依 赖模块都加载完成之后,再执行当前模块的回调函数。这种加载方式和浏览器环境的性能需求刚好吻合。

AMD 规范定义了一个全局函数 define,通过它就可以定义和引用模块,下列代码定义了 moduleA模块,它依赖于 moduleB 和 moduleC,并暴露了一个对象

define('moduleA', ['moduleB','moduleC'], function (moduleB,moduleC) {
  // 模块内部的代码

  // 导出的内容
  return {
    stuff: moduleB.dostuff(),
    stuff1: moduleC.doStuff1()
  }
});

我们可以看到每次使用 AMD 规范,我们都需要调用 define,并写一些操作模块的代码,这会导致我们的代码复杂度提高,如果模块分得太细致的话,JS文件请求就会过于频繁,从而导致页面效率特别低下,所以我觉得 AMD 只是前端模块化演进的一步,它是一种妥协的方式,而不是最终的解决方案,但它也是很有意义的,它在当时前端的模块化提供了标准

另外,在 AMD 中,则是在 define 引入依赖时,依赖模块就已经加载和执行了。我们可以将AMD总结为类似提前执行,所有的模块都是在最前面声明依赖时执行,而不是在代码中用到的地方执行。

除此之外,还有 Sea.js,它实现的是 CMD

CMD

CMD 有 Commonjs 和 AMD 的影子,它诞生的想法就是希望 CMD 写出来的代码像 Commonjs 类似,从而减轻开发者的学习成本,但后续被 require 兼容了

define(function(require, exports) {
  var util = require('./util.js'); //在这里加载模块

  exports.init = function() {
      //...
  };
});

ES Module

ECMAScript 是 JavaScript 的语言标准,目的是在各大浏览器中建立统一的规范。ES 2015 中提出了模块化的定义,如今各主流浏览器都对它进行了原生支持,也就代表着我们的模块语法可以直接在浏览器上跑啦

ES Module 的语法相信前端小伙伴们都很熟悉,如下是基本的几种导入导出语法。

import { xxx } from './utils/helper';
import randomSquare from './utils/time';

那么 ES Module 跟 Commonjs 的区别是什么?网上的很多文章,包括阮一峰写的 ES6 Module 中都有提到:CommonJS 模块是运行时加载,而ES6 模块是编译时输出接口

es-modules-a-cartoon-deep-dive 这篇文章中作者对 ES Module 在浏览器中的实现原理进行了解析,总结而言,就是在模块代码执行前,会有一个编译阶段,将所有的 import 和 export 指向对应变量的存储空间。

这也就是为什么在引入模块时无法使用变量的原因,因为编译阶段是做静态解析,代码不会运行,所以是无法获取到变量的值的。

import mod from `${path}/foo.js` // error

但有时候我们确实需要根据不同情况加载不同的模块代码,ES Module 提供了另一种方法, dynamic import(动态加载)来解决这个问题,示例如下:

import('/modules/a.js')
  .then((module) => {
    // Do something with the module.
  });

“运行时”与“编译时”的区别就是,一个返回的是值的拷贝,而另一个返回的是值的引用

我们先看看 commonjs

// lib.js
let counter = 3;
function incCounter() {
  counter++;
}

module.exports = {
  counter,
  incCounter,
};

// main.js
let mod = require('./lib');

console.log(mod.counter);  // 3
mod.incCounter();
console.log(mod.counter); // 3

main.js 中调用 mod.incCounter() 方法会改变 lib.js 中定义的 counter 值,可这对 module.exports 毫无影响,最终两次打印的结果都是 3

我们再看看 es-module

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}

// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

打印的结果是 3,4,这说明 import 进来的 counter 就是 lib.js 中定义的 counter。