JavaScript 模块化解析
栏目: JavaScript · 发布时间: 6年前
内容简介:作者:原文地址:随着 JavasScript 语言逐渐发展,JavaScript 应用从简单的表单验证,到复杂的网站交互,再到服务端,移动端,PC 客户端的语言支持。JavaScript 应用领域变的越来越广泛,工程代码变得越来越庞大,代码的管理变得越来越困难,于是乎 JavaScript 模块化方案在社区中应声而起,其中一些优秀的模块化方案,逐渐成为 JavaScript 的语言规范,下面我们就 JavaScript 模块化这个话题展开讨论,本文的主要包含以几部分内容。
作者: zhijs from 迅雷前端
原文地址: JavaScript 模块化解析
随着 JavasScript 语言逐渐发展,JavaScript 应用从简单的表单验证,到复杂的网站交互,再到服务端,移动端,PC 客户端的语言支持。JavaScript 应用领域变的越来越广泛,工程代码变得越来越庞大,代码的管理变得越来越困难,于是乎 JavaScript 模块化方案在社区中应声而起,其中一些优秀的模块化方案,逐渐成为 JavaScript 的语言规范,下面我们就 JavaScript 模块化这个话题展开讨论,本文的主要包含以几部分内容。
- 什么是模块
- 为什么需要模块化
- JavaScript 模块化之 CommonJS
- JavaScript 模块化之 AMD
- JavaScript 模块化之 CMD
- JavaScript 模块化之 ES Module
- 总结
什么是模块
模块,又称构件,是能够单独命名并独立地完成一定功能的程序语句的集合 (即程序代码和数据结构的集合体)。它具有两个基本的特征:外部特征和内部特征。外部特征是指模块跟外部环境联系的接口 (即其他模块或程序调用该模块的方式,包括有输入输出参数、引用的全局变量) 和模块的功能,内部特征是指模块的内部环境具有的特点 (即该模块的局部数据和程序代码)。简而言之,模块就是一个具有独立作用域,对外暴露特定功能接口的代码集合。
为什么需要模块化
首先让我们回到过去,看看原始 JavaScript 模块文件的写法。
// 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 文件的功能函数,所以我们在使用的时候,一般会这样写:
<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.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
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; }; 复制代码
CommonJS 在 NodeJs 中的模块加载机制
以下根据 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); }; 复制代码
编译过程主要做了以下的操作:
- 将 JavaScript 代码用函数体包装,隔离作用域,例如:
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 属性。
JavaScript 模块化之 AMD
AMD, Asynchronous Module Definition,即异步模块加载机制,它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句都定义在一个回调函数中,等到依赖加载完成之后,这个回调函数才会运行。
AMD 的诞生,就是为了解决这两个问题:
- 实现 JavaScript 文件的异步加载,避免网页失去响应
- 管理模块之间的依赖性,便于代码的编写和维护
// 模块定义 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 标签,这也就是异步加载模块的原理。
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.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 可以有多个,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,可以用任意变量去承接 复制代码
ES Module 模块加载和导出过程
以如下代码为例子:
// 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 复制代码
在模块加载模块的过程中,主要经历以下几个步骤:
构建 (Construction)
这个过程执行查找,下载,并将文件转化为模块记录 (Module record)。所谓的模块记录是指一个记录了对应模块的语法树,依赖信息,以及各种属性和方法 (这里不是很明白)。同样也是在这个过程对模块记录进行了缓存的操作,下图是一个模块记录表:
下图是缓存记录表:
实例化 (Instantiation)
这个过程会在内存中开辟一个存储空间 (此时还没有填充值),然后将该模块所有的 export 和 import 了该模块的变量指向这个内存,这个过程叫做链接。其写入 export 示意图如下所示:
然后是链接 import,其示意图如下所示:
赋值(Evaluation)
这个过程会执行模块代码,并用真实的值填充上一阶段开辟的内存空间,此过程后 import 链接到的值就是 export 导出的真实值。
根据上面的过程我们可以知道。ES Module 模块 export 和 import 其实指向的是同一块内存,但有一个点需要注意的是,import 处不能对这块内存的值进行修改,而 export 可以,其示意图如下:
总结
本文主要对目前主流的 JavaScript 模块化方案 CommonJs,AMD,CMD, ES Module 进行了学习和了解,并对其中最有代表性的模块化实现 (NodeJs,RequireJS,SeaJS,ES6) 做了一个简单的分析。对于服务端的模块而言,由于其模块都是存储在本地的,模块加载方便,所以通常是采用同步读取文件的方式进行模块加载。而对于浏览器而言,其模块一般是存储在远程网络上的,模块的下载是一个十分耗时的过程,所以通常是采用动态异步脚本加载的方式加载模块文件。另外,无论是客户端还是服务端的 JavaScript 模块化实现,都会对模块进行缓存,以此减少二次加载的开销。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- Android模块化改造以及模块化通信框架
- Laravel 模块化开发模块 – Caffienate
- 前端模块化架构设计与实现(二|模块接口设计)
- 模块化编程的实践者 disconver 更新了用户模块
- ASP.NET Core模块化前后端分离快速开发框架介绍之4、模块化实现思路
- JavaScript模块化
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Java和Android开发学习指南(第二版)
Budi Kurniawan / 李强 / 人民邮电出版社 / 2016-3 / 69.00元
本书是Java语言学习指南,特别针对使用Java进行Android应用程序开发展开了详细介绍。 全书共50章,分为两大部分。第1部分(第1章到第22章)主要介绍Java语言基础知识及其功能特性。第2部分(第23章到第50章)主要介绍如何有效地构建Android应用程序。 本书适合任何想要学习Java语言的读者阅读,特别适合想要成为Android应用程序开发人员的读者学习参考。一起来看看 《Java和Android开发学习指南(第二版)》 这本书的介绍吧!