JavaScript 模块化解析

WEB 前端模块化都有什么?

随着 JavasScript 语言逐渐发展,JavaScript 应用从简单的表单验证,到复杂的网站交互,再到服务端,移动端,PC 客户端的语言支持。JavaScript 应用领域变的越来越广泛,工程代码变得越来越庞大,代码的管理变得越来越困难,于是乎 JavaScript 模块化方案在社区中应声而起,其中一些优秀的模块化方案,逐渐成为 JavaScript 的语言规范,下面我们就 JavaScript 模块化这个话题展开讨论,本文的主要包含以几部分内容。

  • 什么是模块
  • 为什么需要模块化
  • JavaScript 模块化之 CommonJS
  • JavaScript 模块化之 AMD
  • JavaScript 模块化之 CMD
  • JavaScript 模块化之 ES Module
  • 总结
  1. 根据平台划分
平台 规范 特性
浏览器 AMD、CMD 存在网络瓶颈,使用异步加载
非浏览器 CommonJS 直接操作 IO,同步加载
特性 规范
同步加载 CommonJS
异步加载 AMD、CMD
规范 约束条件 代表作
AMD 依赖前置 requirejs
CMD 就近依赖 seajs

什么是模块

模块,又称构件,是能够单独命名并独立地完成一定功能的程序语句的集合 (即程序代码和数据结构的集合体)。它具有两个基本的特征:外部特征和内部特征。外部特征是指模块跟外部环境联系的接口 (即其他模块或程序调用该模块的方式,包括有输入输出参数、引用的全局变量) 和模块的功能,内部特征是指模块的内部环境具有的特点 (即该模块的局部数据和程序代码)。简而言之,模块就是一个具有独立作用域,对外暴露特定功能接口的代码集合。

为什么需要模块化

首先让我们回到过去,看看原始 JavaScript 模块文件的写法。

// add.jsfunctionadd(a, b) {
  return a + b;
}
// decrease.jsfunctiondecrease(a, b) {
  return a - b;
}

// formula.jsfunctionsquare_difference(a, b) {
  return add(a, b) * decrease(a, b);
}

上面我们在三个 JavaScript 文件里面,实现了几个功能函数。其中,第三个功能函数需要依赖第一个和第二个 JavaScript 文件的功能函数,所以我们在使用的时候,一般会这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
    <html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script src="add.js"></script>
<script src="decrease.js"></script>
<script src="formula.js"></script>
<!--使用-->
<script>
var result = square_difference(3, 4);
</script>
</body>
</html>

```

这样的管理方式会造成以下几个问题:

- 模块的引入顺序可能会出错
- 会污染全局变量
- 模块之间的依赖关系不明显

基于上述的原因,就有了对上述问题的解决方案,即是 JavaScript 模块化规范,目前主流的有 CommonJS,AMD,CMD,ES6 Module 这四种规范。

## Javascript 模块化之 CommonJS

CommonJS 规范的主要内容有,一个单独的文件就是一个模块。每一个模块都是一个单独的作用域,模块必须通过 module.exports 导出对外的变量或接口,通过 require() 来导入其他模块的输出到当前模块作用域中,下面讲述一下 NodeJs 中 CommonJS 的模块化机制。

### 使用方式

// 模块定义 add.jsmodule.eports.add = function(a, b) {
return a + b;
};

// 模块定义 decrease.jsmodule.exports.decrease = function(a, b) {
return a - b;
};

// formula.js,模块使用,利用 require() 方法加载模块,require 导出的即是 module.exports 的内容const add = require("./add.js").add;
const decrease = require("./decrease.js").decrease;
module.exports.square_difference = function(a, b) {
return add(a, b) * decrease(a, b);
};


### exports 和 module.exports

exports 和 module.exports 是指向同一个东西的变量,即是 module.exports = exports = {},所以你也可以这样导出模块

//add.js
exports.add = function(a, b) {
return a + b;
};


但是如果直接修改 exports 的指向是无效的,例如:

// add.js
exports = function(a, b) {
return a + b;
};
// main.jsvar add = require("./add.js");


此时得到的 add 是一个空对象,因为 require 导入的是,对应模块的 module.exports 的内容,在上面的代码中,虽然一开始 exports = module.exports,但是当执行如下代码的时候,其实就将 exports 指向了 function,而 module.exports 的内容并没有改变,所以这个模块的导出为空对象。

exports = function(a, b) {
return a + b;
};


### CommonJS 在 NodeJs 中的模块加载机制

以下根据 [NodeJs 中 CommonJS 模块加载源码](https://link.juejin.im?target=https%3A%2F%2Fgithub.com%2Fnodejs%2Fnode%2Fblob%2Fmaster%2Flib%2Finternal%2Fmodules%2Fcjs%2Floader.js) 来分析 NodeJS 中模块的加载机制。

在 NodeJs 中引入模块 (require),需要经历如下 3 个步骤:

1. 路径分析
2. 文件定位
3. 编译执行

与前端浏览器会缓存静态脚本文件以提高性能一样,NodeJs 对引入过的模块都会进行缓存,以减少二次引入时的开销。不同的是,浏览器仅缓存文件,而在 NodeJs 中缓存的是编译和执行后的对象。

#### 路径分析 + 文件定位

其流程如下图所示:
#### 模块编译

在定位到文件后,首先会检查该文件是否有缓存,有的话直接读取缓存,否则,会新创建一个 Module 对象,其定义如下:

functionModule(id, parent) {
this.id = id; // 模块的识别符,通常是带有绝对路径的模块文件名。this.exports = {}; // 表示模块对外输出的值this.parent = parent; // 返回一个对象,表示调用该模块的模块。if (parent && parent.children) {
this.parent.children.push(this);
}
this.filename = null;
this.loaded = false; // 返回一个布尔值,表示模块是否已经完成加载。this.childrent = []; // 返回一个数组,表示该模块要用到的其他模块。
}


require 操作代码如下所示:

Module.prototype.require = function(id) {
// 检查模块标识符if (typeof id !== "string") {
thrownew ERR_INVALID_ARG_TYPE("id", "string", id);
}
if (id === "") {
thrownew ERR_INVALID_ARG_VALUE("id", id, "must be a non-empty string");
}
// 调用模块加载方法return Module._load(id, this, /* isMain */false);
};


接下来是解析模块路径,判断是否有缓存,然后生成 Module 对象:

Module._load = function(request, parent, isMain) {
if (parent) {
debug("Module._load REQUEST %s parent: %s", request, parent.id);
}

// 解析文件名var filename = Module._resolveFilename(request, parent, isMain);

var cachedModule = Module._cache[filename];

// 判断是否有缓存,有的话返回缓存对象的 exportsif (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}

// 判断是否为原生核心模块,是的话从内存加载if (NativeModule.nonInternalExists(filename)) {
debug("load native module %s", request);
return NativeModule.require(filename);
}

// 生成模块对象varmodule = new Module(filename, parent);

if (isMain) {
process.mainModule = module;
module.id = ".";
}

// 缓存模块对象
Module._cache[filename] = module;

// 加载模块
tryModuleLoad(module, filename);

returnmodule.exports;
};


tryModuleLoad 的代码如下所示:

functiontryModuleLoad(module, filename) {
  var threw = true;
  try {
    // 调用模块实例load方法module.load(filename);
    threw = false;
  } finally {
    if (threw) {
      // 如果加载出错,则删除缓存delete Module._cache[filename];
    }
  }
}
1
模块对象执行载入操作 module.load 代码如下所示:
Module.prototype.load = function(filename) {
  debug("load %j for module %j", filename, this.id);

  assert(!this.loaded);
  this.filename = filename;

  // 解析路径this.paths = Module._nodeModulePaths(path.dirname(filename));

  // 判断扩展名,并且默认为 .js 扩展var extension = path.extname(filename) || ".js";

  // 判断是否有对应格式文件的处理函数, 没有的话,扩展名改为 .jsif (!Module._extensions[extension]) extension = ".js";

  // 调用相应的文件处理方法,并传入模块对象
  Module._extensions[extension](this, filename);
  this.loaded = true;

  // 处理 ES Moduleif (experimentalModules) {
    if (asyncESM === undefined) lazyLoadESM();
    const ESMLoader = asyncESM.ESMLoader;
    const url = pathToFileURL(filename);
    const urlString = `${url}`;
    const exports = this.exports;
    if (ESMLoader.moduleMap.has(urlString) !== true) {
      ESMLoader.moduleMap.set(
        urlString,
        new ModuleJob(ESMLoader, url, async () => {
          const ctx = createDynamicModule(["default"], url);
          ctx.reflect.exports.default.set(exports);
          return ctx;
        })
      );
    } else {
      const job = ESMLoader.moduleMap.get(urlString);
      if (job.reflect) job.reflect.exports.default.set(exports);
    }
  }
};
1
2

在这里同步读取模块,再执行编译操作:
Module._extensions[".js"] = function(module, filename) {
  // 同步读取文件var content = fs.readFileSync(filename, "utf8");

  // 编译代码module._compile(stripBOM(content), filename);
};
1
2
3
4

编译过程主要做了以下的操作:

1. 将 JavaScript 代码用函数体包装,隔离作用域,例如:
exports.add = (function(a, b) {
  return a + b;
}

`
会被转换为

(
  function(exports, require, modules, __filename, __dirname) {
    exports.add = function(a, b) {
      return a + b;
    };
  }
);
  1. 执行函数,注入模块对象的 exports 属性,require 全局方法,以及对象实例,filename, dirname,然后执行模块的源码。

  2. 返回模块对象 exports 属性。

JavaScript 模块化之 AMD

AMD, Asynchronous Module Definition,即异步模块加载机制,它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句都定义在一个回调函数中,等到依赖加载完成之后,这个回调函数才会运行。

AMD 的诞生,就是为了解决这两个问题:

  1. 实现 JavaScript 文件的异步加载,避免网页失去响应
  2. 管理模块之间的依赖性,便于代码的编写和维护

    // 模块定义
    define(id?: String, dependencies?: String[], factory: Function|Object);

id 是模块的名字,它是可选的参数。

dependencies 指定了所要依赖的模块列表,它是一个数组,也是可选的参数。每个依赖的模块的输出都将作为参数一次传入 factory 中。如果没有指定 dependencies,那么它的默认值是 [“require”, “exports”, “module”]。

factory 是最后一个参数,它包裹了模块的具体实现,它是一个函数或者对象。如果是函数,那么它的返回值就是模块的输出接口或值,如果是对象,此对象应该为模块的输出值。

举个例子:

// 模块定义,add.js
define(function() {
  let add = function(a, b) {
    return a + b;
  };
  return add;
});

// 模块定义,decrease.js
define(function() {
  let decrease = function(a, b) {
    return a - b;
  };
  return decrease;
});

// 模块定义,square.js
define(["./add", "./decrease"], function(add, decrease) {
  let square = function(a, b) {
    return add(a, b) * decrease(a, b);
  };
  return square;
});

// 模块使用,主入口文件 main.jsrequire(["square"], function(math) {
  console.log(square(6, 3));
});

这里用实现了 AMD 规范的 RequireJS 来分析,RequireJS 源码较为复杂,这里只对异步模块加载原理做一个分析。在加载模块的过程中, RequireJS 会调用如下函数:

/**
 *
 * @param {Object} context the require context to find state.
 * @param {String} moduleName the name of the module.
 * @param {Object} url the URL to the module.
 */
req.load = function(context, moduleName, url) {
  var config = (context && context.config) || {},
    node;
  // 判断是否为浏览器if (isBrowser) {
    // 根据模块名称和 url 创建一个 Script 标签
    node = req.createNode(config, moduleName, url);

    node.setAttribute("data-requirecontext", context.contextName);
    node.setAttribute("data-requiremodule", moduleName);

    // 对不同的浏览器 Script 标签事件监听做兼容处理if (
      node.attachEvent &&
      !(
        node.attachEvent.toString &&
        node.attachEvent.toString().indexOf("[native code") < 0
      ) &&
      !isOpera
    ) {
      useInteractive = true;

      node.attachEvent("onreadystatechange", context.onScriptLoad);
    } else {
      node.addEventListener("load", context.onScriptLoad, false);
      node.addEventListener("error", context.onScriptError, false);
    }

    // 设置 Script 标签的 src 属性为模块路径
    node.src = url;

    if (config.onNodeCreated) {
      config.onNodeCreated(node, config, moduleName, url);
    }

    currentlyAddingScript = node;

    // 将 Script 标签插入到页面中if (baseElement) {
      head.insertBefore(node, baseElement);
    } else {
      head.appendChild(node);
    }
    currentlyAddingScript = null;

    return node;
  } elseif (isWebWorker) {
    try {
      //In a web worker, use importScripts. This is not a very//efficient use of importScripts, importScripts will block until//its script is downloaded and evaluated. However, if web workers//are in play, the expectation is that a build has been done so//that only one script needs to be loaded anyway. This may need//to be reevaluated if other use cases become common.// Post a task to the event loop to work around a bug in WebKit// where the worker gets garbage-collected after calling// importScripts(): https://webkit.org/b/153317
      setTimeout(function() {}, 0);
      importScripts(url);

      //Account for anonymous modules
      context.completeLoad(moduleName);
    } catch (e) {
      context.onError(
        makeError(
          "importscripts",
          "importScripts failed for " + moduleName + " at " + url,
          e,
          [moduleName]
        )
      );
    }
  }
};

// 创建异步 Script 标签
req.createNode = function(config, moduleName, url) {
  var node = config.xhtml
    ? document.createElementNS("http://www.w3.org/1999/xhtml", "html:script")
    : document.createElement("script");
  node.type = config.scriptType || "text/javascript";
  node.charset = "utf-8";
  node.async = true;
  return node;
};

可以看出,这里主要是根据模块的 Url,创建了一个异步的 Script 标签,并将模块 id 名称添加到的标签的 data-requiremodule 上,再将这个 Script 标签添加到了 html 页面中。同时为 Script 标签的 load 事件添加了处理函数,当该模块文件被加载完毕的时候,就会触发 context.onScriptLoad。我们在 onScriptLoad 添加断点,可以看到页面结构如下图所示:
由图可以看到,Html 中添加了一个 Script 标签,这也就是异步加载模块的原理。

JavaScript 模块化之 CMD

CMD (Common Module Definition) 通用模块定义,CMD 在浏览器端的实现有 SeaJS, 和 RequireJS 一样,SeaJS 加载原理也是动态创建异步 Script 标签。二者的区别主要是依赖写法上不同,AMD 推崇一开始就加载所有的依赖,而 CMD 则推崇在需要用的地方才进行依赖加载。

// ADM 在执行以下代码的时候,RequireJS 会首先分析依赖数组,然后依次加载,直到所有加载完毕再执行回到函数
define(["add", "decrease"], function(add, decrease) {
  let result1 = add(9, 7);
  let result2 = decrease(9, 7);
  console.log(result1 * result2);
});

// CMD 在执行以下代码的时候, SeaJS 会首先用正则匹配出代码里面所有的 require 语句,拿到依赖,然后依次加载,加载完成再执行回调函数
define(function(require) {
  let add = require("add");
  let result1 = add(9, 7);
  let add = require("decrease");
  let result2 = decrease(9, 7);
  console.log(result1 * result2);
});

JavaScript 模块化之 ES Module

ES Module 是在 ECMAScript 6 中引入的模块化功能。模块功能主要由两个命令构成,分别是 export 和 import。export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。

其使用方式如下:

// 模块定义 add.jsexportfunctionadd(a, b) {
  return a + b;
}

// 模块使用 main.jsimport { add } from"./add.js";
console.log(add(1, 2)); // 3

下面讲述几个较为重要的点。

export 和 export default

在一个文件或模块中,export 可以有多个,export default 仅有一个, export 类似于具名导出,而 default 类似于导出一个变量名为 default 的变量。同时在 import 的时候,对于 export 的变量,必须要用具名的对象去承接,而对于 default,则可以任意指定变量名,例如:

// a.jsexportvar a = 2;
 exportvar b = 3 ;
// main.js 在导出的时候必须要用具名变量 a, b 且以解构的方式得到导出变量import {a, b} from'a.js'// √ a= 2, b = 3import a from'a.js'// x// b.js export default 方式const a = 3exportdefault a // 注意不能 export default const a = 3 ,因为这里 default 就相当于一个变量名// 导出import b form 'b.js'// √import c form 'b.js'// √ 因为 b 模块导出的是 default,对于导出的default,可以用任意变量去承接

ES Module 模块加载和导出过程

以如下代码为例子:

// counter.jsexportlet count = 5// display.jsexportfunctionrender() {
   console.log('render')
 }
 // main.jsimport { counter } from'./counter.js';
 import { render } from'./display.js'
 ......// more code

在模块加载模块的过程中,主要经历以下几个步骤:

构建 (Construction)

这个过程执行查找,下载,并将文件转化为模块记录 (Module record)。所谓的模块记录是指一个记录了对应模块的语法树,依赖信息,以及各种属性和方法 (这里不是很明白)。同样也是在这个过程对模块记录进行了缓存的操作,下图是一个模块记录表:

下图是缓存记录表:

实例化 (Instantiation)

这个过程会在内存中开辟一个存储空间 (此时还没有填充值),然后将该模块所有的 export 和 import 了该模块的变量指向这个内存,这个过程叫做链接。其写入 export 示意图如下所示:
然后是链接 import,其示意图如下所示:

赋值(Evaluation)

这个过程会执行模块代码,并用真实的值填充上一阶段开辟的内存空间,此过程后 import 链接到的值就是 export 导出的真实值。

根据上面的过程我们可以知道。ES Module 模块 export 和 import 其实指向的是同一块内存,但有一个点需要注意的是,import 处不能对这块内存的值进行修改,而 export 可以,其示意图如下:

ES6 模块与 CommonJS 模块的差异

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。?CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
    ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
    编译时加载: ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。
    CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

CMD与AMD区别

  • AMD和CMD最大的区别是对依赖模块的执行时机处理不同,而不是加载的时机或者方式不同,二者皆为异步加载模块。
  • AMD依赖前置,js可以方便知道依赖模块是谁,立即加载;
  • 而CMD就近依赖,需要使用把模块变为字符串解析一遍才知道依赖了那些模块,这也是很多人诟病CMD的一点,牺牲性能来带来开发的便利性,实际上解析模块用的时间短到可以忽略。

总结

本文主要对目前主流的 JavaScript 模块化方案 CommonJs,AMD,CMD, ES Module 进行了学习和了解,并对其中最有代表性的模块化实现 (NodeJs,RequireJS,SeaJS,ES6) 做了一个简单的分析。对于服务端的模块而言,由于其模块都是存储在本地的,模块加载方便,所以通常是采用同步读取文件的方式进行模块加载。而对于浏览器而言,其模块一般是存储在远程网络上的,模块的下载是一个十分耗时的过程,所以通常是采用动态异步脚本加载的方式加载模块文件。另外,无论是客户端还是服务端的 JavaScript 模块化实现,都会对模块进行缓存,以此减少二次加载的开销。