模块化的前世今生(上)

1,331 阅读5分钟

随着各种脚手架和编译工具的流行,我们习惯一键初始化,照葫芦画瓢地粘贴飘逸的代码,而可能忽略了一些精妙的设计和有趣的发展历史,其中就包括模块化。

  • window
  • module bundlers
  • module loaders
  • webpack, browserify, requirejs
  • AMD vs CommonJS vs ES6 Modules
  • define, require, module.exports, import, export

如果这些词让你眼花缭乱,那不妨读读这篇文章吧。

什么是模块化

当你打开一本书时,你会看到目录里清晰地划分着章节,同样地,一个庞大的代码库,也需要通过拆分成一个个“模块”,来增强维护性和复用度。而一个优秀的模块,必须是高度自治的,独立的,可以被轻松地的添加,移除而丝毫不影响整个项目。

模块化的发展史

“类”是模块化的一种具体形式,而我们知道,javascript 并不天生支持类,但我们可以利用函数,因为函数是 javacript 中唯一可以创建作用域的途径。

土办法

1. 匿名函数闭包

(function() {
    var number = [1, 2, 3];
    
    var min = function () {
        return number.sort()[0];
    };
})();

我们可以把一段代码包装到一个匿名函数中,这个函数中有它自己的运行环境,然后我们立即执行它。但是我们发现,外部无法使用其中的方法,同时这个函数里也可能误改外部的变量。

2. 挂在在全局空间上

像jQuery这样的库就是将 $ 挂载在了 window。和上面的实现方式类似。

(function (global) {
   var jQuery = function() {};
   
   window.jQuery = window.$ = jQuery;
})(window);

window 作为“桥梁”,让挂在在上面的各个属性(命名空间)可以相互使用了。

3. 对象接口

我们也可以通过创建一个对象的方式,同时暴露多个方法。

var Utils = (function() {
   var min = function() {};
   var max = function() {};
   
   return {
       min,
       max
   }
}());

Utils.min();

上述的3种方式都是通过创建匿名函数获得了私有的作用域空间,然后再将要暴露的挂载在一个全局空间上。

但是它们有一些弊端。例如你开发了一个模块,需要使用其他模块,你必须非常清楚模块之间的调用关系,并且将他们按正确的顺序在你的代码之前加载执行。另外,很有可能发成命名冲突,比如2个模块用了同一个名字。

那么是否有什么方法不通过全局空间呢?答案是,CommonJS 和 AMD。

CommonJS 规范

CommonJS 是一种模块化规范,并非官方提出,它最初是由 Mozilla 工程师提出的,后来被 Node.js 采用。

一个 CommonJS 模块,通过module.exports暴露接口,以require的方式被引用。

a.js 定义一个模块

function myModule() {}

module.exports = myModule;

b.js 调用一个模块

var myModule = require('a.js');

值得注意的是,CommonJS 是一种规范,而 Node.js "支持了"这种规范,因此上面的代码可以运行在 Node.js 之中,因为那有 requiremodule.exports,而浏览器端,并不存在。

为什么这个模块化规范没有浏览器端支持呢?因为它的设计就是针对服务端的,并且它是同步加载模块的。当你require多个模块时,它们一个一个的被加载。这样在服务端,从磁盘上读取是可以的,但是在浏览器中,模块需要通过网络加载,而加载过程将会阻塞JS线程的执行。这就不合理了。

尽管如此,你还是可以通过 Browserify 这样的工具将 CommonJs 格式的代码转换成浏览器端可运行的代码,模块的挂在原理是

var module = {
  exports: {}
};

(function(module, exports) {
  exports.myModule = function () { ... };
}(module, module.exports))

var myModule = module.exports.myModule;

除此之外 Browserify 还通过解析语法树,找到 require的文件,将所有依赖按顺序打包进一个 bundle.js,最终在浏览器端运行。

AMD 规范

既然 CommonJS 是同步的,那么如果你想异步加载一个模块,该怎么办呢?答案是,通过 AMD,全称是 Asynchronous Module Definition。

a.js 定义一个模块

define([], function () {
    return {
        ...
    }
}

b.js 调用一个模块

define(['myModule'], funciton (myModule) {
    ...
});

AMD 规范是针对浏览器端进行设计的。并不支持 io, 文件系统,和服务端的一些特性。尽管 AMD 规范是针对浏览器端设计的,但浏览器端并没有原生“支持”它,因此你还是需要依赖像RequireJs这样的模块化工具。

UMD 规范

UMD(Universal Module Definition) 规范是一种“包装”,将上述的3种都包装在一起了,先判断是否支持AMD(define 是否存在),再判断是否支持 CommonJS(exports是否存在);前两个都不存在,则将模块挂载到全局(window 或 global),常见的是

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        //AMD
        define(['myModule'], factory);
    } else if (typeof exports === 'object') {
        //CommonJS
        module.exports = factory(require('myModule');
    } else {
        root.returnExports = factory(root.myModule);
    }
})(this, (myModule) => {
   ...
   return {}
});

ES6 Module

终于,ECMAScript 制定了正统的模块化方案,并在 ES6 中引入。这就是现在大家熟悉的 exportimport

ES6 模块化既有简明的语法,又能解决异步加载模块问题,同时还对循环依赖有更好的支持。另外和 CommonJS 规范不同的是:

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

什么意思,2段代码解释一下:

差异1

// lib.js
var counter = 1;

function increment() {
    counter++;
}
module.exports = {
    counter,
    increment,
}

// main.js
var counter = require('./lib.js');
counter.increment();

console.log(counter); // 1
// lib.js
export let counter = 1;

export function increment() {}

// main.js
import * as Counter from './lib.js'

Counter.increment();
console.log(Counter.counter); // 2

差异2 将和编译工具一起阐述。

虽然 ES6 Module 是官方出品,但是它的可用场景还不是很“普遍”,因为 Node 端有 CommonJS 规范,和 ES6 Module 并不兼容,在 node v13.1.0 版本下,只有开启 --experimental-modules 才能运行,同时对模块的定义有一定的要求。而在浏览器端,import 语句只能在声明了 type="module" 的 script 的标签中使用。真实的场景下,也需要通过编译工具的优化。

在了解了有哪些模块化规范之后,下一篇,我们再来聊聊“治理”模块的工具吧。

今天先到这里。