全面了解 JavaScript 中的模块机制-Module

252 阅读7分钟

全面了解 JavaScript 中的模块机制-Module

为什么需要模块-Module

历史上,JavaScript 一直没有模块 Module 体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。

为什么要拆分?
如果将所有前端代码维护在一个文件中,那么基本没有可读性和可维护性可言。

Module 是什么

那么很快地就可以讲出 Module 的定义:

它的出现就是为了拆分一个大文件,那么每个 Module 就是一个小文件,可以相互依赖,最终结果是拼装成一个大文件。

这样模块化的思想就很简单:把逻辑分块,各自封装,相互独立,每个块自行决定对外暴露什么,这个思想是所有 JavaScript Module 的基础。

早期的几种实现

  1. 全局 Function 模式:将不同的功能封装成不同的函数
function foo() {
  console.log("foo");
}

function bar() {
  console.log("bar");
}
  1. 全局 Namespace 模式,解决了命名冲突的问题,但是存在数据安全的问题,外部可以直接修改模块内部数据
window.__Module = {
  foo: function() {
    console.log("foo");
  }

  bar: function () {
    console.log("bar");
  }
}

const module = window.__Module
module.foo() // foo
  1. IIFE 模式,通过匿名立即执行函数来隔离代码,解决私有化问题
(function (window) {
  foo: function() {
    console.log("foo");
  }

  bar: function () {
    console.log("bar");
  }
  window.__Module = {
    foo,
    bar
  }
})();
const module = window.__Module
module.foo() // foo

目前有哪几种常用的 Module

各种 Module 出现的原因
由于在 ECMAScript(ESM) 模块规范出现之前,原生浏览器并不支持 Module 的行为,而开发者们迫切的需要这样的行为。因此希望使用模块模式的库或代码库基于 JavaScript 的语法和词法特点“伪造”除了各种类似 Module 的行为。

下面的图表可以简单概括下各种常用 Module 实现的适用场景和特点:

Module适用场景特点
CommonJs(CJS)服务器,Node.js 使用了轻微修改版本的 CommonJs以服务器端为目标环境,不考虑网络延迟,一次性将所有模块都加载到内存中
AMD浏览器以浏览器为目标环境,考虑网络延迟
通用模块定义 UMD创建 (AMD,CJS) 都可以使用的模块代码启动时检测使用哪个模块系统,返回需要的逻辑包装。
ECMAScript(ESM)原生浏览器支持的模块加载方式原生浏览器支持,同时吸收了 AMD 和 CJS 的一些优点,是集大成者

CommonJs(CJS)

Node.js 的模块系统,就是参照 CommonJS 规范实现的,CommonJS 对模块的加载时同步的。同步就意味着有一个模块需要请求等待的话,那么整个程序都要被阻塞住(因为 JavaScript 是单线程语言)。

2009 年,美国程序员 Ryan Dahl 创造了 node.js 项目,将 javascript 语言用于服务器端编程。 这标志”Javascript 模块化编程”正式诞生。前端的复杂程度有限,没有模块也是可以的,但是在服务器端,一定要有模块,与操作系统和其他应用程序互动,否则根本没法编程。

常用写法

var moduleB = require('./moduleB');

module.exports = {
  stuff: moduleB.doStuff();
}

源码解读

精简后的源码大致如下:

function Module(id = "", parent) {
  // 模块文件的绝对路径
  this.id = id;
  // 模块所在的目录
  this.path = path.dirname(id);
  // 导出内容
  this.exports = {};
  // 将该模块添加到父模块的children数组中
  updateChildren(parent, this, false);
  this.filename = null;
  // 是否加载完成
  this.loaded = false;
  // 引用的字模块,即在当前模块中通过require加载的模块
  this.children = [];
}

// 用于包裹模块代码
Module.wrap = function (script) {
  return (
    "(function (exports, require, module, __filename, __dirname) {" +
    script +
    "\n})"
  );
};

// 模块缓存
Module._cache = Object.create(null);
Module._load = function (request, parent) {
  // Module.resolveFilename 用于将 require 的模块地址解析成能定为到模块的绝对路径
  const filename = Module._resolveFilename(requset, parent);

  const cachedModule = Module._cache[filename];
  // 命中缓存直接返回
  if (cachedModule) {
    return cachedModule.exports;
  }
  // 未命中缓存,则创建一个新的模块实例
  const module = new Module(filename, parent);
  // 将该模块记录到模块缓存中
  Module._cache[filename] = module;
  // 获取到模块代码内容
  const content = fs.readFileSync(filename, "utf8");
  // 将参数传入并执行模块代码,可以理解诶eval,单源码中的实现并不是eval,而是更安全的执行方式
  runInthisContext(Module.wrap(content))(
    module.exports,
    module.require,
    module,
    filename,
    module.path
  );
  // 将该模块标识为加载完成
  module.loaded = true;
  // 最后返回执行完该模块内代码后的模块到处内容
  return module.exports;
};

// 模块原型上的require方法
Module.prototype.require = function (id) {
  return Module._load(id, this);
};

特性

1. 运行时加载

通过require() 在运行时才执行模块内的代码,并加载至本文件中。

2. CommonJS 对模块的加载是同步的。

如果模块加载需要的时间很长,将会阻塞页面的渲染,使整个应用陷入卡顿状态。因此,浏览器端的模块,只能采用异步加载。这就是 AMD 规范诞生的背景。

3. 缓存机制

由上面代码可知,在执行 require() 方法时,会判断是否已经加载过相同模块,如果已经加载完成将直接返回缓存内容

4. 循环引用

同样的,在上面代码可知,只有在第一加载该模块时才会执行一次代码,其他时候直接返回模块中的代码。

5. 模块是值拷贝

在引入模块的文件中修改原始值,将不会印象原模块中的私有值。引用类型由于使用的是值拷贝,因此在修改引用类型时,将会影响原模块中的私有值。

// a.js
let num = 1;
let user = { name: "LH" };

exports.num = num;
exports.user = user;

exports.addNum = () => {
  num += 1;
}; // 修改变量 num 的值
exports.getNum = () => {
  console.log("模块内部 num 变量:", num);
};

exports.setName = () => {
  user.name = "op_chen";
}; // 修改变量 user 中的属性
exports.getName = () => {
  console.log("模块内部 user 变量中的 name:", user.name);
};
// b.js
const a = require("./a.cjs");

console.log("A 模块 num:", a.num); // 打印, A 模块 num: 1
console.log("A 模块 user.name:", a.user.name); // 打印, A 模块 user.name: LH

a.addNum(); // 修改模块内部 num 变量的值
a.setName(); // 修改模块内部 user 的 name 属性值

console.log("A 模块 num:", a.num); // 打印, A 模块 num: 1
console.log("A 模块 user.name:", a.user.name); // 打印, A 模块 user.name: clj

a.getNum(); // 打印, 模块内部 num 变量: 2
a.getName(); //  打印, 模块内部user 变量中的 name: clj

AMD

AMD 的一般策略是让模块声明自己的依赖,而运行在浏览器中的模块系统会按需依赖,并在依赖加载完成后立即执行依赖他们的函数。

AMD 实现的核心是用函数包装模块定义。这样可以防止声明全局变量,并允许加载器控制何时加载模块。

// ID为 'moduleA'的模块定义。modulesA依赖moduleB
// moduleB会异步加载
definde("moduleA", ["moduleB"], function (moduleB) {
  return {
    stuff: moduleB.doStuff(),
  };
});

UMD

为了统一 CommonJSAMD 生态系统,通用模块定义规范应运而生。 UMD 可用于创建这个两个系统都可以使用的模块代码。

本质上,UMD 定义的模块会在启动时检查要使用哪个模块,然后适当配置,并把所有逻辑包装在有一个立即调用的函数式里。

下面是一个依赖的 UMD 模块定义的示例:

(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    // amd 注册为匿名模块
    define(["moduleB"], factory);
  } else if (typeof module === "object" && module.exports) {
    // Node。不支持严格CommonJS
    module.exports === factory(require("moduleB"));
  } else {
    // 浏览器全局上下文
    window.returnExports = factory(window.moduleB);
  }
})(this, function () {
  // 以魔种方式使用moduleB
  return {};
});

UMD 应用由构建工具自动生成。开发者只要专注于模块的内容,而不必关系这些样板代码。

ECMAScript(ESM)

ECMAScript6 吸收了 AMDCJS 的许多优秀特性,随着 ECMAScript6 模块规范得到越来越广泛的支持,其他模块引入模式可能会走向没落。

常用写法

import { foo } from "./js/export.js";
function foo2() {
  console.log("foo");
}
export default foo2;

原理解读

  1. 构造:从入口文件出发,根据 import 一层层解析,每个模块都会生成一个模块记录Module Record
  • 首先会查找相关依赖,形成一个依赖关系树(AST)
  • 其次还需要加载import对应模块的资源,而为了尽量不阻塞线程,这一步实际上又是异步进行加载的。
  • 当然 ESM 也 存在缓存,他会被记录在模块映射Module Map中。这点和 CJS同理。
  1. 实例化: 在内存中存储所有export出来的数据,同时将所有import指向对应的内存块中。
  2. 求值:执行所有模块的最外层代码。

特性

1. 运行前加载(编译时加载)

在构造和实例化阶段就开始解析模块,因此可以支持 Webpack 实现 Tree Shaking

2. 缓存机制

同样的和CJS一样有缓存机制,一个模块只会加载一次

3. 导出的是引用地址

// a-es.mjs
export let num = 1;

export const addNum = () => {
  num += 1;
};

export const getNum = () => {
  console.log("getNum:", num);
};
// b-es.mjs
import { num, addNum, getNum } from "./a-es.mjs";

console.log(num); //1
addNum();
console.log(num); //2
getNum(); //2

4. 导出的值不允许被修改

import a from "./a.mjs";

a = {}; // 报错, 无法赋值
a.age = 1; // 这个是可以的,对对象的属性进行修改会影响整个使用此模块的文件