做者: 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