JavaScript 知识体系之模块系统

现代 JavaScript 开发毋庸置疑会遇到代码量大和广泛使用第三方库的问题。解决这个问题的方案通 常需要把代码拆分成很多部分,然后再通过某种方式将它们连接起来。

在 ECMAScript 6 模块规范出现之前,虽然浏览器原生不支持模块的行为,但迫切需要这样的行为。 ECMAScript 同样不支持模块,因此希望使用模块模式的库或代码库必须基于 JavaScript 的语法和词法特 性“伪造”出类似模块的行为。

因为 JavaScript 是异步加载的解释型语言,所以得到广泛应用的各种模块实现也表现出不同的形态。 这些不同的形态决定了不同的结果,但最终它们都实现了经典的模块模式

前端模块系统发展历程

1. Stage 1 – 文件划分方式

最早我们会基于文件划分的方式实现模块化,也就是 Web 最原始的模块系统。

具体做法是将每个功能及其相关状态数据各自单独放到不同的 JS 文件中,约定每个文件是一个独立的模块。

使用某个模块将这个模块引入到页面中,一个 script 标签对应一个模块,然后直接调用模块中的成员(变量 / 函数)。

目录结构

└─ stage-1
    ├── module-a.js
    ├── module-b.js
    └── index.html

module-a.js

// module-a.js
function foo() {
  console.log("moduleA#foo");
}

module-b.js

// module-b.js
var data = "something";

index.html

<!DOCTYPE html>
<html>
  <head>
      
    <meta charset="UTF-8" />
      
    <title>Stage 1</title>
  </head>
  <body>
      
    <script src="module-a.js"></script>
    <script src="module-b.js"></script>
      
    <script>
      // 直接使用全局成员
      foo(); // 可能存在命名冲突
      console.log(data);
      data = "other"; // 数据可能会被修改
    </script>
  </body>
</html>

缺点:

  • 模块直接在全局工作,大量模块成员污染全局作用域;
  • 没有私有空间,所有模块内的成员都可以在模块外部被访问或者修改;
  • 一旦模块增多,容易产生命名冲突;
  • 无法管理模块与模块之间的依赖关系;
  • 在维护的过程中也很难分辨每个成员所属的模块。

总之,这种原始“模块化”的实现方式完全依靠约定实现,一旦项目规模变大,这种约定就会暴露出种种问题,非常不可靠,所以我们需要尽可能解决这个过程中暴露出来的问题。

2. Stage 2 – 命名空间方式

约定每个模块只暴露一个全局对象,所有模块成员都挂载到这个全局对象中,

具体做法是在第一阶段的基础上,通过将每个模块“包裹”为一个全局对象的形式实现,

这种方式就好像是为模块内的成员添加了“命名空间”,所以我们又称之为命名空间方式。

module-a.js

// module-a.js
window.moduleA = {
  method1: function () {
    console.log("moduleA#method1");
  },
};

module-b.js

// module-b.js
window.moduleB = {
  data: 'something'
  method1: function () {
    console.log('moduleB#method1')
  }
}

index.html

<!DOCTYPE html>
<html>
  <head>
      
    <meta charset="UTF-8" />
      
    <title>Stage 2</title>
  </head>
  <body>
      
    <script src="module-a.js"></script>
      
    <script src="module-b.js"></script>
      
    <script>
      moduleA.method1();
      moduleB.method1(); // 模块成员可以被修改
      moduleA.data = "foo";
    </script>
  </body>
</html>

这种命名空间的方式只是解决了命名冲突的问题,但是其它问题依旧存在。

3. Stage 3 – IIFE

为按照模块模式提供必要的封装,ES6 之前的模块有时候会使用函数作用域和立即调用函数表达式 (IIFE,Immediately Invoked Function Expression)将模块定义封装在匿名闭包中

以这样的方式实现了私有空间。

具体做法是将每个模块成员都放在一个立即执行函数所形成的私有作用域中,对于需要暴露给外部的成员,通过挂到全局对象上的方式实现。

module-a.js

// module-a.js
(function () {
  var name = "module-a";
  function method1() {
    console.log(name + "#method1");
  }
  window.moduleA = {
    method1: method1,
  };
})();

module-b.js

// module-b.js
(function () {
  var name = "module-b";
  function method1() {
    console.log(name + "#method1");
  }
  window.moduleB = {
    method1: method1,
  };
})();

index.html

<!DOCTYPE html>
<html>
  <head>
      
    <meta charset="UTF-8" />
      
    <title>Stage 2</title>
  </head>
  <body>
      
    <script src="module-a.js"></script>
      
    <script src="module-b.js"></script>
      
    <script>
      moduleA.method1();
      moduleB.method1(); // 模块成员可以被修改
      moduleA.data = "foo";
    </script>
  </body>
</html>

这种方式带来了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问,
但也只是解决了

  • 全局作用域污染
  • 命名冲突的问题

其他问题依旧存在

4. Stage 4 - IIFE 依赖参数

在 IIFE 的基础之上,我们还可以利用 IIFE 参数作为依赖声明使用,这使得每一个模块之间的依赖关系变得更加明显。

module-a.js

// module-a.js
(function ($) {
  // 通过参数明显表明这个模块的依赖
  var name = "module-a";
  function method1() {
    console.log(name + "#method1");
    $("body").animate({ margin: "200px" });
    return "123456";
  }

  window.moduleA = {
    method1,
  };
})(jQuery);

module-b.js

// module-a.js
(function ($, moduleA) {
  // 通过参数明显表明这个模块的依赖
  var name = "module-b";
  function method1() {
    console.log(name + "#method1");
    $("body").animate({ margin: "200px" });
  }
  function method2() {
    //  console.log(name + '#method2');
    console.log(moduleA.method1());
  }
  window.moduleB = {
    method1,
    method2,
  };
})(jQuery, moduleA);

index.html

<!DOCTYPE html>
<html>
  <head>
      
    <title>Evolution</title>
  </head>
  <body>
      
    <script src="https://unpkg.com/jquery"></script>
    <script src="module-a.js"></script>
    <script src="module-b.js"></script>
    <script>
      // moduleA.method1();
      moduleB.method2();
    </script>
  </body>
</html>

但也只是解决了

  • 全局作用域污染
  • 命名冲突
  • 无法管理模块与模块之间的依赖关系;的问题。

模块加载的问题

以上 4 个阶段是早期的开发者在没有工具和规范的情况下对模块化的落地方式,这些方式确实解决了很多在前端领域实现模块化的问题,但是仍然存在一些没有解决的问题。

<!DOCTYPE html>

<html>
  <head>
      
    <title>Evolution</title>
  </head>

  <body>
      
    <script src="https://unpkg.com/jquery"></script>

      
    <script src="module-a.js"></script>

      
    <script src="module-b.js"></script>

      
    <script>
      moduleA.method1();

      moduleB.method1();
    </script>
  </body>
</html>

最明显的问题就是:模块的加载。

在这几种方式中虽然都解决了模块代码的组织问题,但模块加载的问题却被忽略了,我们都是通过 script 标签的方式直接在页面中引入的这些模块,这意味着模块的加载并不受代码的控制,时间久了维护起来会十分麻烦。

试想一下,如果你的代码需要用到某个模块,如果 HTML 中忘记引入这个模块,又或是代码中移除了某个模块的使用,而 HTML 还忘记删除该模块的引用,都会引起很多问题和不必要的麻烦。

更为理想的方式应该是在页面中引入一个 JS 入口文件,其余用到的模块可以通过代码控制,按需加载进来。

模块加载器的出现

CommonJS

它是 Node.js 中遵循的模块规范,该规范约定,一个文件就是一个模块,每个模块都有单独的作用域,

通过module.exports,exports 导出成员,再通过 require 函数载入模块。但是如果我们想要在浏览器端直接使用这个规范,那就会出现一些新的问题。

Node.js 的模块加载机制中: CommonJS 约定的是以同步的方式加载模块

因为 Node.js 执行机制是在启动时加载模块,执行过程中只是使用模块,所以这种方式不会有问题。

但是如果要在浏览器端使用同步的加载模式,就会引起大量的同步模式请求,导致应用运行效率低下

小结:

  • Node.js 实现。
  • 在安装模块时用于 服务器端
  • 没有 runtime/async 模块
  • 通过 require 导入模块
  • 通过 module.exports 导出模块
  • 当你导入时,你会获得一个对象
  • 无法使用 tree shaking,因为当你 import 时会得到一个对象
  • 因为你得到的是一个对象,所以属性查找在运行时进行,无法静态分析
  • 你总是会得到一个对象的副本,因此模块本身**不会实时更改
  • 循环依赖管理不优雅
  • 语法简单

AMD(Asynchronous Module Definition) 异步模块定义

CommonJS 以服务器端为目标环境,能够一次性把所有模块都加载到内存,

异步模块定义(AMD, Asynchronous Module Definition)的模块定义系统则以浏览器为目标执行环境,这需要考虑网络延迟的问题。

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

AMD 模块实现的核心是用函数包装模块定义。这样可以防止声明全局变量,并允许加载器库控制 何时加载模块。包装函数也便于模块代码的移植,因为包装函数内部的所有模块代码使用的都是原生 JavaScript 结构。包装模块的函数是全局 define 的参数,它是由 AMD 加载器库的实现定义的。

AMD 模块可以使用字符串标识符指定自己的依赖,而 AMD 加载器会在所有依赖模块加载完毕后 立即调用模块工厂函数。与 CommonJS 不同,AMD 支持可选地为模块指定字符串标识符

// moduleB 会异步加载 26 define('moduleA', ['moduleB'], function(moduleB) {
   return {
    stuff: moduleB.doStuff();
}; });

AMD 也支持 requireexports 对象,通过它们可以在 AMD 模块工厂函数内部定义 CommonJS 风格的模块。这样可以像请求模块一样请求它们,但 AMD 加载器会将它们识别为原生 AMD 结构,而不是模块定义:

define("moduleA", ["require", "exports"], function (require, exports) {
  var moduleB = require("moduleB");
  exports.stuff = moduleB.doStuff();
});

动态依赖也是通过这种方式支持的:

define("moduleA", ["require"], function (require) {
  if (condition) {
    var moduleB = require("moduleB");
  }
});

异步模块定义规范。同期还推出了一个非常出名的库,叫做 Require.js,它除了实现了 AMD 模块化规范,本身也是一个非常强大的模块加载器。

使用起来相对复杂,而且当项目中模块划分过于细致时,就会出现同一个页面对 js 文件的请求次数过多的情况,从而导致效率降低

小结:

  • RequireJS 实现
  • 当你在 客户端(浏览器)环境 中,异步加载模块时使用
  • 通过 require 实现导入
  • 语法复杂

UMD(Universal Module Definition) 通用模块定义

为了统一 CommonJS 和 AMD 生态系统,通用模块定义(UMD,Universal Module Definition)规范 应运而生。

UMD 可用于创建这两个系统都可以使用的模块代码。

本质上,UMD 定义的模块会在启动时 检测要使用哪个模块系统,然后进行适当配置,并把所有逻辑包装在一个立即调用的函数表达式(IIFE) 中。

虽然这种组合并不完美,但在很多场景下足以实现两个生态的共存。

下面是只包含一个依赖的 UMD 模块定义的示例(来源为 GitHub 上的 UMD 仓库):

(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    // AMD。注册为匿名模块
    define(["moduleB"], factory);
  } else if (typeof module === "object" && module.exports) {
    // Node。不支持严格CommonJS
    // 但可以在 Node 这样支持 module.exports 的
    // 类 CommonJS 环境下使用
    module.exports = factory(require(" moduleB "));
  } else {
    // 浏览器全局上下文(root 是 window) root.returnExports = factory(root. moduleB);
  }
})(this, function (moduleB) {
  // 以某种方式使用moduleB
  // 将返回值作为模块的导出
  // 这个例子返回了一个对象
  // 但是模块也可以返回函数作为导出值 return {};
});

此模式有支持严格 CommonJS 和浏览器全局上下文的变体。不应该期望手写这个包装函数,它应该 由构建工具自动生成。开发者只需专注于模块的内由容,而不必关心这些样板代码。


小结:

  • CommonJS + AMD 的组合(即 CommonJS 的语法 + AMD 的异步加载)
  • 可以用于 AMD/CommonJS 环境
  • UMD 还支持全局变量定义。因此,UMD 模块能够在 客户端和服务器 上工作。

ECMAScript modules

小结:

  • 用于 服务器/客户端

  • 支持模块的 Runtime/static loading

  • 当你 import 时,获得是 绑定值(实际值)

  • 通过 import 导入,通过 export 导出

  • Static analyzing (静态分析)

  • 由于 ES6 支持 静态分析 ,因此 Tree Shaking 是可行的

  • 始终获取 实际值 ,以便 实时更改模块本身

  • 比 CommonJS 有更好的循环依赖管理

模块化的标准规范

尽管上面介绍的这些方式和标准都已经实现了模块化,但是都仍然存在一些让开发者难以接受的问题。

随着技术的发展,JavaScript 的标准逐渐走向完善,可以说,如今的前端模块化已经发展得非常成熟了,而且对前端模块化规范的最佳实践方式也基本实现了统一。

  • 在 Node.js 环境中,我们遵循 CommonJS 规范来组织模块。
  • 在浏览器环境中,我们遵循 ES Modules 规范。

![](https://img-blog.csdnimg.cn/img_convert/8c112850436aa9c048f03325cac078e4.png#align=left&display=inline&height=427&margin=[object Object]&originHeight=854&originWidth=1280&size=0&status=done&style=none&width=640)

随着技术的发展,JavaScript 的标准逐渐走向完善,前端模块化规范的最佳实践方式也基本实现了统一。

在最新的 Node.js 提案中表示,Node 环境也会逐渐趋向于 ES Modules 规范,也就是说,应该重点掌握 ES Modules 规范。

因为 CommonJS 属于内置模块系统,所以在 Node.js 环境中使用时不存在环境支持问题,只需要直接遵循标准使用 requiremodule 即可。

CommJs 模块规范

  • JavaScript 社区发起,在 Node.js 中应用并推广
  • 后续也影响到浏览器端的 JavaScript
    • eg: webpack
exports
// lib.js
exports.hello = "hello";

exports.add = function (a, b) {
  return a + b;
};

exports.obj = { name: "a", age: 20 };

setTimeout(() => {
  console.log("============exports========================");
  console.log(exports);
  console.log("====================================");
}, 3000);
// index.js

const lib = require("./lib.js");

console.log("===============可以取到lib的东西=====================");
console.log(lib);

lib.modify = "modify";
console.log("====================================");

exports导出的对象 与 外部变量-lib 是同一个引用。 因为 外部变量-lib 可以修改 lib.js的内容

module.exports
// lib.js
exports.hello = "hello";

exports.add = function (a, b) {
  return a + b;
};

exports.obj = { name: "a", age: 20 };

module.exports = function min(a, b) {
  return a - b;
};

setTimeout(() => {
  console.log(
    "============exports===还是能打印出原来的内容=====================",
  );
  console.log(exports);
  console.log("====================================");
}, 3000);
// index.js

const lib = require("./lib.js");

console.log("===============只打印出 min函数=====================");
// 只打印出 min函数
console.log(lib);
console.log("====================================");

**如果,同时存在module.exports``exports**

  • module.exports会覆盖exports,但是exports 的引用还是存在
  • 外部引用会使用module.exports 的内容

这是什么原理呢? 欢迎各位看官在评论区交流

ES Module 模块规范

ES6 最大的一个改进就是引入了模块规范。这个规范全方位简化了之前出现的模块加载器,原生浏 览器支持意味着加载器及其他预处理都不再必要。从很多方面看,ES6 模块系统是集 AMD 和 CommonJS 之大成者。

下面是一个代码示例

首先需要启动一个服务器才能在 html 中 正常使用 ES Modules, 我们使用 http-server

$ npm i -y

$ npm install http-server

$ 加入下面两个文件的代码

$ http-server
import 同步导入

module-a.js

function methodA() {
  return "123456";
}

export { methodA };

index.html

<!DOCTYPE html>
<html>
  <head>
    <title>Evolution</title>
  </head>
  <body>
      
    <script type="module">
      import { methodA } from "./module-a.js";

      console.log(methodA());
    </script>
  </body>
</html>

那么import的原理是什么呢?

import() 异步导入

详细内容请参考

小结

  • 模块导出: export ,export default
  • 模块导入: 静态导入-import ,动态导入-import()

食用 webpack 如何打包出各种模块

食用 rollUp 如何打包出各种模块

总结

好了,JavaScript 模块系统的浅要解析就到这里,下面我们来简单总结下文章的主要内容:

  • 首先我们简要分析了前端模块化的发展历程

    • 手工时代
      • 文件划分方式方式
      • 命名空间方式
      • 匿名立即执行函数的方式
    • 模块加载器时代
      • CommonJS
      • AMD
      • UMD
    • 模块化规范标准时代
      • CommonJS
      • ES Modules
  • 然后我们分析了模块加载器以及模块化规范标准的常见使用方式

以上就是本篇文章所讲的主要内容,文章浅陋,欢迎各位看官评论区留下的你的见解!

觉得有收获的同学欢迎点赞,关注一波!

20210601205044

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值