本文主要阐述JavaScript模块化的发展和模块化的相关基础。本文翻译和参考自Tyler McGinnis
的《JavaScript Modules: From IIFEs to CommonJS to ES6 Modules》

JavaScript:从IIFEs,到CommonJS,再到ES6的Modules

英文原文

模块的组成

一个模块其实可以3个部分:依赖项(也叫做“引入”)、代码、导出。

  1. Dependencies (Imports):依赖项(也叫做“引入”):当一个模块需要另一个模块时,它可以将该模块作为依赖项导入。例如,每当要创建React组件时,都需要导入react模块。如果你想使用像lodash这样的库,你需要导入lodash模块。

  2. Code:代码:在确定了模块所需的依赖项之后,下一部分是模块的实际代码。

  3. Exports:导出:导出是模块的“接口”。无论您从模块导出的是什么,都可以从任何人那里导入该模块。

JS的模块发展史:

IIFE(立即执行函数)-》CommonJS -》ES6 Module

一、IIFE:

形式:
1
2
3
(function () {
console.log('Pronounced IF-EE') ;
})()
优点和缺点:

IIFE模块模式有哪些好处?首先,我们避免将所有内容转储到全局命名空间。这将有助于变量冲突并使我们的代码更加私密。它有任何缺点吗?确实如此。我们在全局命名空间APP上仍有1个项目。如果偶然的另一个库使用相同的命名空间,我们就遇到了麻烦。其次,你会注意到index.html文件中<script>标签的顺序。如果您没有按照现在的确切顺序编写脚本,则应用程序将会中断。

二、CommonJS:

形式:
var users = ["Tyler", "Sarah", "Dan"] 

function getUsers() { 
    return users 
} 

module.exports.getUsers = getUsers

module.exports实际上是一个对象,getUsers是它的一个属性,因此可以这样写:

var users = ["Tyler", "Sarah", "Dan"] 

function getUsers() { 
    return users 
} 

module.exports = { 
    getUsers: getUsers 
}

无论我们有多少方法,我们都可以将它们添加到exports对象中:

// users.js 
var users = ["Tyler", "Sarah", "Dan"] 

module.exports = { 
    getUsers: function () { return users }, 
    sortUsers: function () { return users.sort() }, 
    firstUser: function () { return users[0] } 
}

导入模块:

var users = require('./users') 
users.getUsers() // ["Tyler", "Sarah", "Dan"] 
users.sortUsers() // ["Dan", "Sarah", "Tyler"] 
users.firstUser() // ["Tyler"]
优点和缺点:

如果您以前使用过Node,CommonJS应该看起来很熟悉。原因是因为Node使用(大部分)CommonJS规范来实现模块。因此,使用Node,您可以使用之前看到的CommonJS require和module.exports语法获得开箱即用的模块。但是,与Node不同,浏览器不支持CommonJS。事实上,不仅浏览器不支持CommonJS,而且开箱即用,CommonJS不是一个很好的浏览器解决方案,因为它同步加载模块。在浏览器的领域,异步加载器是王道。

总而言之,CommonJS存在两个问题。首先,浏览器不理解它。其次,它同步加载模块,这在浏览器中将是一个糟糕的用户体验。如果我们能解决这两个问题,我们就会处于良好的状态。那么,如果它对浏览器来说甚至不好,那么花时间谈论CommonJS有什么意义呢?那么有一个解决方案,它被称为模块捆绑器。

题外话:模块捆绑器(Module Bundlers)

JavaScript模块捆绑器的作用是检查您的代码库,查看所有导入和导出,然后智能地将所有模块捆绑到一个浏览器可以理解的文件中。然后,不是在index.html文件中包含所有脚本,而是担心它们进入的顺序,而是包含捆绑器为您创建的单个bundle.js文件。

app.js --->  |               |
users.js  ->  | Bundler | -> bundle.js 
dom.js ---> |               |

那么捆绑器实际上如何工作呢?这是一个非常大的问题,而且我自己并不完全了解,但这是通过Webpack(一种流行的模块捆绑器)运行我们的简单代码之后的输出。

三、ES6 Module

形式:
// utils.js

// Not exported
function once(fn, context) {
  var result
  return function() {
    if(fn) {
      result = fn.apply(context || this, arguments)
      fn = null
    }
    return result
  }
}

// Exported
export function first (arr) {
  return arr[0]
}

// Exported
export function last (arr) {
  return arr[arr.length - 1]
}

现在,为了导入firstlast,您有几个不同的选择。一种是导入从utils.js导出的所有内容:

import * as utils from './utils' 
utils.first([1,2,3]) // 1 
utils.last([1,2,3]) // 3

但是,如果我们不想导入模块导出的所有内容呢?在这个例子中,如果我们想先导入但不是最后导入怎么办?这是你可以使用所谓的命名导入(它看起来像解构,但它不是)

import { first } from './utils' 
first([1,2,3]) // 1

ES模块的优点是,您不仅可以指定多个导出,还可以指定默认(default)导出:

// leftpad.js

export default function leftpad (str, len, ch) {
  var pad = '';
  while (true) {
    if (len & 1) pad += ch;
    len >>= 1;
    else break;
  }
  return pad + str;
}

使用默认导出时,会更改导入该模块的方式。您只需使用 import name from './path',而不是使用*语法或使用命名导入:

import leftpad from './leftpad'

现在,如果您有一个默认模块导出 default ,但也导出其他常规导出怎么办?好吧,你会按照你的期望去做。

// utils.js

function once(fn, context) {
  var result
  return function() {
    if(fn) {
      result = fn.apply(context || this, arguments)
      fn = null
    }
    return result
  }
}

// regular export
export function first (arr) {
  return arr[0]
}

// regular export
export function last (arr) {
  return arr[arr.length - 1]
}

// default export
export default function leftpad (str, len, ch) {
  var pad = '';
  while (true) {
    if (len & 1) pad += ch;
    len >>= 1;
    else break;
  }
  return pad + str;
}

以下导入代码中,leftpad 是默认导出,firstlast 只是常规导出。

import leftpad, { first, last } from './utils'
ES模块的有趣之处在于,它们现在是JavaScript原生的,现代浏览器支持它们而不使用捆绑器。 若在浏览器中使用CommonJS,我们需要使用像Webpack这样的捆绑器,然后在bundle.js文件中包含一个脚本。使用ES模块,在现代浏览器中,我们需要做的就是包含我们的主文件(在本例中为dom.js)并在脚本选项卡中添加一个type ='module'属性。
<script type=module src='dom.js'></script>

Tree Shaking

我们在上面没有介绍的CommonJS模块和ES模块之间还有一个区别。使用CommonJS,您可以在任何地方,甚至是有条件地使用模块。

if (pastTheFold === true) { 
    require('./parallax') 
}

由于ES模块是静态的,因此import语句必须始终位于模块的顶层。您无法有条件地导入它们。因此下面的代码是错误的:

if (pastTheFold === true) { 
    import './parallax' // "import' and 'export' may only appear at the top level" 
}

做出这个设计决策的原因是因为通过强制模块是静态的,加载器可以静态地分析模块树,找出实际使用的代码,并从捆绑中删除未使用的代码。换句话说,因为ES模块强制您在模块顶部声明import语句,所以Bundler可以快速理解您的依赖树。当它理解你的依赖树时,它可以看到没有使用的代码并从bundle中删除它。这称为Tree Shaking或Dead Code Elimination。


番外:CommonJS、AMD和CMD

CommonJS和AMD两者都是js模块规范,两者都用require方法引入模块。

commonjs是同步加载模块的。用require(模块名)来引入并执行模块。在定义模块时,是通过“module.exports = 对象/函数”来把模块暴露出去的,其中module表示当前模块,exports属性是对外的接口。而为了方便,还定义了一个exports变量,它指向module.exports。相当于在最前面加上一句var exports=module.exports。可以给exports变量添加对象:exports.对象名 等价于 module.exports.对象名。commonjs是同步的,而在服务器(nodeji)的模块又是在本地的,所以commonjs适用于服务器端的js。

AMD是异步记载模块的。通过require([模块1,模块2…],callback)来引入模块。其中第一个参数是一个数组,是要加载模块的集合,第二个参数是一个回调,只有成功加载前面数组中的模块,才会执行回调,回调中的内容是要使用模块进行的一些操作。amd通过defined关键字来定义模块。由于浏览器需要加载服务器的模块时,需要异步加载(不然会阻塞渲染),所以一般浏览器端使用amd规范。

CMD规范:是commonjs和amd的结合。即遵循amd规范,又遵循commonjs规范。与amd的区别是:cmd是延后执行,推崇依赖就近;而amd是前置执行,推崇依赖前置。