做者: zhijs from 迅雷前端javascript
原文地址:JavaScript 模块化解析 html
随着 JavasScript 语言逐渐发展,JavaScript 应用从简单的表单验证,到复杂的网站交互,再到服务端,移动端,PC 客户端的语言支持。JavaScript 应用领域变的愈来愈普遍,工程代码变得愈来愈庞大,代码的管理变得愈来愈困难,因而乎 JavaScript 模块化方案在社区中应声而起,其中一些优秀的模块化方案,逐渐成为 JavaScript 的语言规范,下面咱们就 JavaScript 模块化这个话题展开讨论,本文的主要包含以几部份内容。前端
模块,又称构件,是可以单独命名并独立地完成必定功能的程序语句的集合 (即程序代码和数据结构的集合体)。它具备两个基本的特征:外部特征和内部特征。外部特征是指模块跟外部环境联系的接口 (即其余模块或程序调用该模块的方式,包括有输入输出参数、引用的全局变量) 和模块的功能,内部特征是指模块的内部环境具备的特色 (即该模块的局部数据和程序代码)。简而言之,模块就是一个具备独立做用域,对外暴露特定功能接口的代码集合。java
首先让咱们回到过去,看看原始 JavaScript 模块文件的写法。node
// add.js
function add(a, b) {
return a + b;
}
// decrease.js
function decrease(a, b) {
return a - b;
}
// formula.js
function square_difference(a, b) {
return add(a, b) * decrease(a, b);
}
复制代码
上面咱们在三个 JavaScript 文件里面,实现了几个功能函数。其中,第三个功能函数须要依赖第一个和第二个 JavaScript 文件的功能函数,因此咱们在使用的时候,通常会这样写:git
<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>
复制代码
这样的管理方式会形成如下几个问题:github
基于上述的缘由,就有了对上述问题的解决方案,便是 JavaScript 模块化规范,目前主流的有 CommonJS,AMD,CMD,ES6 Module 这四种规范。web
CommonJS 规范的主要内容有,一个单独的文件就是一个模块。每个模块都是一个单独的做用域,模块必须经过 module.exports 导出对外的变量或接口,经过 require() 来导入其余模块的输出到当前模块做用域中,下面讲述一下 NodeJs 中 CommonJS 的模块化机制。数组
// 模块定义 add.js
module.eports.add = function(a, b) {
return a + b;
};
// 模块定义 decrease.js
module.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 是指向同一个东西的变量,便是 module.exports = exports = {},因此你也能够这样导出模块浏览器
//add.js
exports.add = function(a, b) {
return a + b;
};
复制代码
可是若是直接修改 exports 的指向是无效的,例如:
// add.js
exports = function(a, b) {
return a + b;
};
// main.js
var add = require("./add.js");
复制代码
此时获得的 add 是一个空对象,由于 require 导入的是,对应模块的 module.exports 的内容,在上面的代码中,虽然一开始 exports = module.exports,可是当执行以下代码的时候,其实就将 exports 指向了 function,而 module.exports 的内容并无改变,因此这个模块的导出为空对象。
exports = function(a, b) {
return a + b;
};
复制代码
如下根据 NodeJs 中 CommonJS 模块加载源码 来分析 NodeJS 中模块的加载机制。
在 NodeJs 中引入模块 (require),须要经历以下 3 个步骤:
与前端浏览器会缓存静态脚本文件以提升性能同样,NodeJs 对引入过的模块都会进行缓存,以减小二次引入时的开销。不一样的是,浏览器仅缓存文件,而在 NodeJs 中缓存的是编译和执行后的对象。
其流程以下图所示:
在定位到文件后,首先会检查该文件是否有缓存,有的话直接读取缓存,不然,会新建立一个 Module 对象,其定义以下:
function Module(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") {
throw new ERR_INVALID_ARG_TYPE("id", "string", id);
}
if (id === "") {
throw new 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];
// 判断是否有缓存,有的话返回缓存对象的 exports
if (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
// 判断是否为原生核心模块,是的话从内存加载
if (NativeModule.nonInternalExists(filename)) {
debug("load native module %s", request);
return NativeModule.require(filename);
}
// 生成模块对象
var module = new Module(filename, parent);
if (isMain) {
process.mainModule = module;
module.id = ".";
}
// 缓存模块对象
Module._cache[filename] = module;
// 加载模块
tryModuleLoad(module, filename);
return module.exports;
};
复制代码
tryModuleLoad 的代码以下所示:
function tryModuleLoad(module, filename) {
var threw = true;
try {
// 调用模块实例load方法
module.load(filename);
threw = false;
} finally {
if (threw) {
// 若是加载出错,则删除缓存
delete Module._cache[filename];
}
}
}
复制代码
模块对象执行载入操做 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";
// 判断是否有对应格式文件的处理函数, 没有的话,扩展名改成 .js
if (!Module._extensions[extension]) extension = ".js";
// 调用相应的文件处理方法,并传入模块对象
Module._extensions[extension](this, filename);
this.loaded = true;
// 处理 ES Module
if (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);
}
}
};
复制代码
在这里同步读取模块,再执行编译操做:
Module._extensions[".js"] = function(module, filename) {
// 同步读取文件
var content = fs.readFileSync(filename, "utf8");
// 编译代码
module._compile(stripBOM(content), filename);
};
复制代码
编译过程主要作了如下的操做:
exports.add = (function(a, b) {
return a + b;
}
复制代码
会被转换为
(
function(exports, require, modules, __filename, __dirname) {
exports.add = function(a, b) {
return a + b;
};
}
);
复制代码
执行函数,注入模块对象的 exports 属性,require 全局方法,以及对象实例,__filename, __dirname,而后执行模块的源码。
返回模块对象 exports 属性。
AMD, Asynchronous Module Definition,即异步模块加载机制,它采用异步方式加载模块,模块的加载不影响它后面语句的运行。全部依赖这个模块的语句都定义在一个回调函数中,等到依赖加载完成以后,这个回调函数才会运行。
AMD 的诞生,就是为了解决这两个问题:
// 模块定义
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.js
require(["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;
} else if (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 标签,这也就是异步加载模块的原理。
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);
});
复制代码
ES Module 是在 ECMAScript 6 中引入的模块化功能。模块功能主要由两个命令构成,分别是 export 和 import。export 命令用于规定模块的对外接口,import 命令用于输入其余模块提供的功能。
其使用方式以下:
// 模块定义 add.js
export function add(a, b) {
return a + b;
}
// 模块使用 main.js
import { add } from "./add.js";
console.log(add(1, 2)); // 3
复制代码
下面讲述几个较为重要的点。
在一个文件或模块中,export 能够有多个,export default 仅有一个, export 相似于具名导出,而 default 相似于导出一个变量名为 default 的变量。同时在 import 的时候,对于 export 的变量,必需要用具名的对象去承接,而对于 default,则能够任意指定变量名,例如:
// a.js
export var a = 2;
export var b = 3 ;
// main.js 在导出的时候必需要用具名变量 a, b 且以解构的方式获得导出变量
import {a, b} from 'a.js' // √ a= 2, b = 3
import a from 'a.js' // x
// b.js export default 方式
const a = 3
export default a // 注意不能 export default const a = 3 ,由于这里 default 就至关于一个变量名
// 导出
import b form 'b.js' // √
import c form 'b.js' // √ 由于 b 模块导出的是 default,对于导出的default,能够用任意变量去承接
复制代码
以以下代码为例子:
// counter.js
export let count = 5
// display.js
export function render() {
console.log('render')
}
// main.js
import { counter } from './counter.js';
import { render } from './display.js'
......// more code
复制代码
在模块加载模块的过程当中,主要经历如下几个步骤:
这个过程执行查找,下载,并将文件转化为模块记录 (Module record)。所谓的模块记录是指一个记录了对应模块的语法树,依赖信息,以及各类属性和方法 (这里不是很明白)。一样也是在这个过程对模块记录进行了缓存的操做,下图是一个模块记录表:
下图是缓存记录表:
这个过程会在内存中开辟一个存储空间 (此时尚未填充值),而后将该模块全部的 export 和 import 了该模块的变量指向这个内存,这个过程叫作连接。其写入 export 示意图以下所示:
而后是连接 import,其示意图以下所示:
这个过程会执行模块代码,并用真实的值填充上一阶段开辟的内存空间,此过程后 import 连接到的值就是 export 导出的真实值。
根据上面的过程咱们能够知道。ES Module 模块 export 和 import 其实指向的是同一块内存,但有一个点须要注意的是,import 处不能对这块内存的值进行修改,而 export 能够,其示意图以下:
本文主要对目前主流的 JavaScript 模块化方案 CommonJs,AMD,CMD, ES Module 进行了学习和了解,并对其中最有表明性的模块化实现 (NodeJs,RequireJS,SeaJS,ES6) 作了一个简单的分析。对于服务端的模块而言,因为其模块都是存储在本地的,模块加载方便,因此一般是采用同步读取文件的方式进行模块加载。而对于浏览器而言,其模块通常是存储在远程网络上的,模块的下载是一个十分耗时的过程,因此一般是采用动态异步脚本加载的方式加载模块文件。另外,不管是客户端仍是服务端的 JavaScript 模块化实现,都会对模块进行缓存,以此减小二次加载的开销。
参考文章: ES modules: A cartoon deep-dive