阅读 3511

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 个步骤:

  1. 路径分析
  2. 文件定位
  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);
};
复制代码

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

  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.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 模块化实现,都会对模块进行缓存,以此减少二次加载的开销。

参考文章: ES modules: A cartoon deep-dive

扫一扫关注迅雷前端公众号

关注下面的标签,发现更多相似文章
评论