JS 模块化

226 阅读12分钟

JS 模块化

主讲:云隐

课程目标

  • 了解熟悉 JS 模块化的发展与变迁;
  • 掌握不同模块化方案的原理与实现;
  • 通过对相关开放型面试题目分析的理解,能够顺畅应对相关面试题;
  • 初步了解整体前端模块化、工程化的脉络;

不得不说的历史

背景

JS 本身就是为了满足简单的页面设计:页面动画 + 表单提交,并无模块化 or 命名空间的概念;

JS 的模块化需求日益增长;

幼年期:无模块化

  1. 开始需要在页面中加载不同的 js:例如动画、表单、格式化等等;
  2. 多种 js 文件被分在不同的文件中;
  3. 不同的文件又被同一个模板引用;
<script src="jquery.js"></script>
<script src="main.js"></script>
<script src="dep1.js"></script>
  • 认可:文件分离拆分是最基础的模块化(第一步);
  • 问题出现:污染全局作用域 => 不利于大型项目的开发以及多人团队的共建;

追问:script 标签的参数 - asyncdefer

<script src="jquery.js" async></script>
<script src="jquery.js" defer></script>
  • 总结:
    • 普通 - 解析到立即阻塞,立刻下载执行当前 script
    • async - 解析到标签开始异步下载,下载完成后开始执行并且阻塞渲染,执行完成之后继续渲染;
    • defer - 解析到标签开始异步下载,解析完之后开始执行;

image-20220404225840948.png

  • 兼容性 > IE9
  • 问题可以被引导到:
    1. 浏览器渲染原理;
    2. 同步异步原理;
    3. 模块化加载原理;

成长期:模块化的雏形 - IIFE(语法侧的优化)

  • IIFE - 立即执行函数;
  • 本质:作用域的把控

例子:

// 定义一个全局变量
let count = 0;

// 代码块1
const increase = () => ++count;

// 代码块2
const reset = () => {
  count = 0;
};

increase();
reset();

利用函数的块级作用域 - 隔离区:

() => {
  let count = 0;
  // ...
};

仅定义了一个函数,如果立即执行:

(() => {
  let count = 0;
  // ...
})();

初步实现了一个最最最最简单的模块;

尝试去定义一个最简单的模块:

const iifeModule = (() => {
  let count = 0;

  return {
    increase: () => ++count,
    reset: () => {
      count = 0;
    }
  };
})();

iifeModule.increase();
iifeModule.reset();

追问:独立模块本身的额外依赖,如何优化 IIFE 相关代码?

优化 1:依赖其他模块的 IIFE

const iifeModule = ((dependencyModule1, dependencyModule2) => {
  let count = 0;

  // dependencyModule1.xxx
  // dependencyModule2.xxx
  return {
    increase: () => ++count,
    reset: () => {
      count = 0;
    }
  };
})(dependencyModule1, dependencyModule2);

iifeModule.increase();
iifeModule.reset();

面试题 1:【早期经典问题】了解早期 jquery 的依赖处理以及模块加载方案吗?(了解传统 IIFE 是如何解决多方依赖的问题?)

答:IIFE 加传参调配

实际上,jquery 等框架其实应用了 revealing 的写法:在写法的思想上更强调的是,API 是定义在函数中,而仅仅是暴露接口的一个思想;

const iifeModule = ((dependencyModule1, dependencyModule2) => {
  let count = 0;
  const increase = () => ++count;
  const reset = () => {
    count = 0;
  };

  return {
    increase,
    reset
  };
})(dependencyModule1, dependencyModule2);

iifeModule.increase();
iifeModule.reset();

重点是想问:揭示模式 revealing => 上层无需了解底层实现,仅关注抽象(也就是 return 暴露出的接口);

追问:

  1. 继续模块化横向展开;
  2. 转向框架:jquery|vue|react 模块化细节;
  3. 转向设计模式;

成熟期:

CJS - CommonJS

  • node.js 制定;

  • 特征:

    • 通过 module + exports 去对外暴露接口;

    • 通过 require 来调用其他模块;

  • 模块组织方式

例子:

  • main.js 文件:
// 引入部分
const dependencyModule1 = require('./dependencyModule1');
const dependencyModule2 = require('./dependencyModule2');

// 处理部分
let count = 0;
const increase = () => ++count;
const reset = () => {
  count = 0;
};
// 做一些跟引入依赖相关事宜...

// 暴露接口部分
exports.increase = increase;
exports.reset = reset;

// 或者
module.exports = {
  increase,
  reset
};
  • 模块使用方式:
const { increase, reset } = require('./main.js');

increase();
reset();

可能被问到的问题:在模块化里面对其他模块进行模块化加载

实际执行处理:把 IIFECommonJS 整合到一起

// 复合使用 - IIFE 和 CommonJS
(function (thisValue, exports, require, module) {
  const dependencyModule1 = require('./dependencyModule1');
  const dependencyModule2 = require('./dependencyModule2');

  // 业务逻辑...
}.call(thisValue, exports, require, module));

// 为什么一些开源项目为何要把全局、指针以及框架本身引用作为参数?
// 例如:
(function (window, $, undefined) {
  const _show = function () {
    $('#app').val('hi zhaowa');
  };
  window.webShow = _show;
})(window, jQuery);

// 考察的是阻断思路
// 可以分开回答:
// window - 1. 全局作用域转化成局部作用域,提升执行效率 2. 编译时优化
(function (c) {})(window); // window 会被优化成 c,里面所有的执行或者变化,随着执行完毕会被跟 c 一起被销毁,提高了压缩成本和回收成本
// jquery - 1. 从全局转化为局部后,可以独立定制复写和挂载 2. 防止全局串扰
// undefined - 防止重写,保证里面的 undefined 是正统的,不会被改写
  • 优点:CommonJS 率先在服务端实现了,从框架层面解决依赖、全局变量污染的问题;
  • 缺点:主要针对了服务端的解决方案,对于异步拉取依赖的处理整合不是很完美;
  • 新的问题 —— 异步依赖;

AMD 规范

  • 通过异步加载 + 允许制定回调函数;
  • 经典实现框架是:require.js

新增定义方式:define + require

// 通过 define 来定义一个模块,然后 require 进行加载
/*
  define
  params: 模块名,依赖模块,工厂方法
*/
define(id, [depends], callback);

require([module], callback);

模块定义方式:

define('amdModule', ['dependencyModule1', 'dependencyModule2'], (dependencyModule1, dependencyModule2) => {
  // 业务逻辑
  // 处理部分
  let count = 0;
  const increase = () => ++count;
  const reset = () => {
    count = 0;
  };

  return {
    increase,
    reset
  };
});

引入模块方式:

require(['amdModule'], amdModule => {
  amdModule.increase();
});

面试题 2:如果在 AMDmodule 中想兼容已有代码,怎么办?

define('amdModule', [], require => {
  // 引入部分
  const dependencyModule1 = require('./dependencyModule1');
  const dependencyModule2 = require('./dependencyModule2');

  // 处理部分
  let count = 0;
  const increase = () => ++count;
  const reset = () => {
    count = 0;
  };
  // 做一些跟引入依赖相关事宜...

  return {
    increase,
    reset
  };
});

面试题 3:AMD 中使用 revealing

define('amdModule', [], (require, exports, module) => {
  // 引入部分
  const dependencyModule1 = require('./dependencyModule1');
  const dependencyModule2 = require('./dependencyModule2');

  // 处理部分
  let count = 0;
  const increase = () => ++count;
  const reset = () => {
    count = 0;
  };
  // 做一些跟引入依赖相关事宜...

  exports.increase = increase();
  exports.reset = reset();
});

define('amdModule', [], require => {
  const otherModule = require('amdModule');
  otherModule.increase();
  otherModule.reset();
});

面试题 4:手写兼容 CJS & AMD(如何判断 CJSAMD,也就是后续 UMD 的出现;

/* 
  判断关键
  step1:object 还是 function
  step2:有没有 exports?
  step3:是不是 define?
*/
define('amdModule', [], (require, exports, module) => {
  // 引入部分
  const dependencyModule1 = require('./dependencyModule1');
  const dependencyModule2 = require('./dependencyModule2');

  // 处理部分
  let count = 0;
  const increase = () => ++count;
  const reset = () => {
    count = 0;
  };
  // 做一些跟引入依赖相关事宜...

  exports.increase = increase();
  exports.reset = reset();
})(
  // 目标:一次性区分 CJS or AMD
  typeof module === 'object' && module.exports && typeof define !== 'function'
    ? // 是 CJS
      factory => (module.exports = factory(require, exports, module))
    : // 是 AMD
      define
);

AMD 总结:

  • 优点:适合在浏览器中加载异步模块,可以并行加载多个模块;

  • 缺点:会有引入成本,不能按需加载;

CMD 规范

  • 按需加载,就近依赖;
  • 主要应用的框架 sea.js
define('module', (require, exports, module) => {
  let $ = require('jquery');
  // jquery 相关逻辑

  let dependencyModule1 = require('./dependencyModule1');
  // dependencyModule1 相关逻辑
});
  • 优点:按需加载,依赖就近;

  • 缺点:依赖于打包,加载逻辑存在于每个模块中,扩大了模块体积,同时功能上依赖编译;

面试题 5:AMD & CMD 区别?

答:依赖就近,按需加载;

ES6 模块化 - 走向新时代

  • 新增定义:
    • 引入关键字 —— import
    • 导出关键字 —— export

模块引入、导出和定义的地方:

// 引入区域
import dependencyModule1 from './dependencyModule1.js';
import dependencyModule2 from './dependencyModule2.js';

// 实现代码逻辑...
let count = 0;

// 单独导出
export const increase = () => ++count;
export const reset = () => {
  count = 0;
};

// 导出区域
export default {
  increase,
  reset
};

模板引入的地方:

<script type="module" src="esModule.js"></script>

nodeJs 中:

import { increase, reset } from './esModule.mjs';
increase();
reset();

import esModule from './esModule.mjs';
esModule.increase();
esModule.reset();

面试题 6:1、性能方法 - 按需加载;2、动态模块

考察:export promise

ES11 原生解决方案:

import('./esModule.js').then(dynamicEsModule => {
  dynamicEsModule.increase();
});
  • 优点(重要性):通过一种最终统一各端的形态,整合了 js 模块化的通用方案;

  • 缺点(局限性):本质上还是浏览器运行时的依赖分析;

解决模块化的新思路 - 前端工程化

遗留

根本问题:运行时进行依赖分析(前端的模块化处理方案依赖于运行时分析);

解决方案:线下执行;

追问:可否简述,实现一个编译时依赖处理的思路?

主模板文件:

<!doctype html>
	<script src="main.js"></script>

  <script>
    // 给构建工具一个标识位
    require.config(__FRAME_CONFIG__);
  </script>

  <script>
    require(['a', 'e'], () => {
      // 业务处理
    })
  </script>
</html>

依赖的文件:

define('a', () => {
  let b = require('b');
  let c = require('c');
});

工程化实现

步骤 1:扫描依赖关系表

{
  "a": ["b", "c"],
  "b": ["d"],
  "e": []
}

步骤 2:根据依赖关系重新生成模板

<!doctype html>
	<script src="main.js"></script>

  <script>
    // 构建工具生成数据
    require.config({
      "deps": {
        a: ['b', 'c'],
        b: ['d'],
        e: []
      }
    })
  </script>

  <script>
    require(['a', 'e'], () => {
      // 业务处理
    })
  </script>
</html>

步骤 3:执行工具,采用模块化方案解决模块化处理依赖

define('a', ['b', 'c'], () => {
  // 执行代码
  export.run = () => {}
})

优点:

  1. 构建时生成配置,运行时执行;
  2. 最终转化成可执行的依赖处理;
  3. 可以拓展;

前端模块化的完全体

完全体:webpack 为核心的前端工程化 + mvvm 框架的组件化 + 设计模式

CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。CommonJS 是单个值导出,ES6 Module 可以导出多个 CommonJS 是动态语法可以写在判断里,ES6 Module 静态语法只能写在顶层,CommonJSthis 是当前模块,ES6 Modulethisundefined

原始图:www.processon.com/diagraming/…

JS 模块化.png

补充知识点

WEB 开发的早期,为了团队协作和代码维护的方便,许多开发者会选择将 JavaScript 代码分开写在不同的文件里面,然后通过多个 script 标签来加载它们。

<script src="./a.js"></script>
<script src="./b.js"></script>
<script src="./c.js"></script>

虽然每个代码块处在不同的⽂件中,但最终所有 JS 变量还是会处在同⼀个 全局作用域 下,这时候就需要额外注意由于作用域 变量提升 所带来的问题。

<script>
  // a.js
  var num = 1;
  setTimeout(() => console.log(num), 1000);
</script>

<script>
  // b.js
  var num = 2;
</script>

在这个例子中,我们分别加载了两个 script 标签,两段 JS 都声明了 num 变量。第⼀段脚本的本意本来是希望在 1s 后打印自己声明的 num 变量 1。但最终运行结果却打印了第二段脚本中的 num 变量的结果 2 。虽然两段代码写在不同的文件中,但是因为运行时声明变量都在全局下,最终产生了冲突。

同时,如果代码之间有依赖关系的话,需要额外关注脚本加载的顺序。如果文件依赖顺序有改动,就需要在 html 手动变更加载标签的顺序,非常麻烦。

要解决这样的问题,我们就需要将这些脚本文件【模块化】:

  • 每个模块都要有自己的 【变量作用域】,两个模块之间的内部变量不会产生冲突。
  • 不同模块之间保留相互 导入和导出 的方式方法,模块间能够相互通信。模块的执行与加载遵循⼀定的规范,能保证彼此之间的依赖关系。

主流的编程语言都有处理模块的关键词,在这些语言中,模块与模块之间的内部变量相互不受影响。同时,也可以通过关键字进行模块定义,引入和导出等等,例如 Java 里的 module 关键词,python 中的 import

但是 JavaScript 这门语言在 Ecmascript6 规范之前并没有语言层面的模块导入导出关键词及相关规范。为了解决这样的问题,不同的 JS 运行环境分别有着自己的解决方案。

CommonJS 规范初探(同步)

Node.js 就是⼀个基于 V8 引擎,事件驱动 I/O 的服务端 JS 运行环境,在 2009 年刚推出时,它就实现了⼀套名为 CommonJS 的模块化规范。

CommonJS 规范⾥,每个 JS 文件就是一个 模块(module ,每个模块内部可以使用 require 函数和 module.exports 对象来对模块进行导入和导出。

// ⼀个比较简单的 CommonJS 模块
const moduleA = require('./moduleA'); // 获取相邻的相对路径 ./moduleA 文件导出的结果
module.exports = moduleA; // 导出当前模块内部 moduleA 的值

下面这三个模块稍微复杂⼀些,它们都是合法的 CommonJS 模块:

  • ./moduleB.js
var m = new Date().getTime();
module.exports = m;
  • ./moduleA.js
console.log('moduleA start');

let m = require('./moduleB'); // 同步的

console.log('moduleA-moduleB >>> ', m);
console.log('typeof m >>> ', typeof m); // number

setTimeout(() => {
  console.log('moduleA-moduleB setTimeout >>> ', m);
  console.log('moduleA end');
}, 2000);
  • ./index.js
require('./moduleA');
let m = require('./moduleB');

console.log('index moduleB >>> ', m);

/* 
输出结果:(输出的时间都是相同的)
  moduleA start
  moduleA-moduleB >>>  1620613530897
  index moduleB >>>  1620613530897

  此处间隔 2s 后
  moduleA-moduleB setTimeout >>>  1620613530897
  moduleA end
*/
  • index.js 代表的模块通过执行 require 函数,分别加载了相对路径为 ./moduleA./moduleB 的两个模块,同时输出 moduleB 模块的结果。
  • moduleA.js 文件内也通过 require 函数加载了 moduleB.js 模块,在 2s 后也输出了加载进来的结果。
  • moduleB.js 文件内部相对来说就简单的多,仅仅定义了一个时间戳,然后直接通过 module.exports 导出。

如果想不缓存 moduleB 中的数据,可以返回一个函数,每次导入的时候执行此函数:

  • ./moduleB.js
module.exports = function () {
  return new Date().getTime();
};
  • ./moduleA.js
console.log('moduleA start');

let m = require('./moduleB'); // 同步的

console.log('moduleA-moduleB >>> ', m());
console.log('moduleA middle');

setTimeout(() => {
  console.log('moduleA-moduleB setTimeout >>> ', m());
  console.log('moduleA end');
}, 2000);

// ========== ./moduleB.js ==========
module.exports = function () {
  return new Date().getTime();
};
  • ./index.js
require('./moduleA');
let m = require('./moduleB');

console.log('index moduleB >>> ', m());

/* 
输出结果:(输出的时间不同)
  moduleA start
  moduleA-moduleB >>>  1625424765901
  moduleA middle
  index moduleB >>>  1625424765902

  此处间隔 1s 后
  moduleA-moduleB setTimeout >>>  1625424767903
  moduleA end
*/

它们之间的 物理关系逻辑关系 如下图:

image-20211122040547887.png

在装有 Node.js 的机器上,我们可以直接执行 node index.js 查看输出的结果。我们可以发现,无论执行多少次,最终输出的两行结果均相同。

CommonJS-2.png

虽然这个例子非常简单,但是我们却可以发现 CommonJS 完美的解决了最开始我们提出的痛点:

  1. 模块之间内部即使有相同的变量名,它们运行时没有冲突。这说明它有处理模块变量作用域的能力。上面这个例子中三个模块中均有 m 变量,但是并没有冲突。
  2. moduleB 通过 module.exports 导出了⼀个内部变量,而它在 moduleA index 模块中能被加载。这说明它有导入和导出模块的方式,同时能够处理基本的依赖关系
  3. 我们在不同的模块加载了 moduleB 两次,我们得到了相同的结果。这说明它保证了模块单例

但是,这样的 CommonJS 模块只能在 Node.js 环境中才能运行,直接在其他环境中运行这样的代码模块就会报错。这是因为只有 node 才会在解析 JS 的过程中提供⼀个 require 方法,这样当解析器执行代码时,发现有模块调用了 require 函数,就会通过参数找到对应模块的物理路径,通过系统调用从硬盘读取文件内容,解析这段内容最终拿到导出结果并返回。而其他运行环境并不⼀定会在解析时提供这么⼀个 require 方法,也就不能直接运行这样的模块了。

从它的执行过程也能看出来 CommonJS 是⼀个 同步加载模块 的模块化规范,每当⼀个模块 require ⼀个子模块时,都会停止当前模块的解析直到子模块读取解析并加载。

基于 CommonJS 中的导入和导出

1、CommonJS 中的导出

console.log('cal.js');

module.exports = {
  name: 'cal',
  add: function (a, b) {
    return a + b;
  }
};

// 为了书写方便,可以像下面简写
exports.name = 'cal';
exports.add = function (a, b) {
  return a + b;
};

上面两段代码,在实现效果上没有任何的不同。

**其内在机制是将 exports 指向了 module.exports,而 module.exports 在初始化时是一个空对象。**我们可以简单的理解为,CommonJS 在每个模块的首部默认添加可以下代码:

var module = {
  exports: {}
};

var exports = module.exports;

因此,为 exports.add 赋值,相当于在 module.exports 对象上添加了一个属性。

**【注意】:**在使用 exports 时要注意一个问题,即不要直接给 exports 赋值,否则导致其失效,如:

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

上面代码中,由于对 exports 进行了赋值操作,使其指向了一个新的对象,module.exports 却仍然是原来的空对象,因此 add 属性并不会被导出,例如下面这个代码:

var module = {
  exports: {}
};

var exports = module.exports;
console.log(exports === module.exports); // true

exports = {
  add: function (a, b) {
    return a + b;
  }
};
console.log(exports === module.exports); // false,注意这个地方是 false

**【注意】:**另一个需要注意的问题,module.exportsexports 不要混用。

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

module.exports = {
  name: 'cal'
};

上面代码中的 module.exports 指向了另一个对象,导致之前的 add 属性被丢失,所以最后导出只有 name 属性。

module.exportsexports 后面的代码依旧会执行,但不建议这样写。

2、CommonJS 中的导入

使用 require 导入模块时,有下面两种情况:

  1. require 的模块是第一次被加载,这时会首先执行该模块,然后导出内容;
  2. require 的模块曾被加载过,这时该模块的代码不会再次执行,而是直接导出上次执行后的结果;

例如:

  • ./cal.js
console.log('cal.js');

module.exports = {
  name: 'cal',
  add: function (a, b) {
    return a + b;
  }
};

// 为了书写方便,可以像下面简写
// exports.name = "cal";
// exports.add = function (a, b) {
//   return a + b;
// };
  • ./moduleA.js
// 第一次 require
const add = require('./cal').add;
console.log('add function moduleA >>> ', add(1, 2));
  • ./moduleB.js
// 第二次 require
const name = require('./cal').name;
console.log('name >>> ', name);

const add = require('./cal').add;
console.log('add function moduleB >>> ', add(1, 2));
  • ./index.js
require('./moduleA');
require('./moduleB');

此时的结果如下:cal.js 只会输出一次;

image-20220213004339509.png

从结果上可以看出,尽管我们 require 两次,但其内部代码只会执行一遍。

  • ./cal.js
console.log('cal.js');

let obj = {
  name: 'cal',
  num: 0,
  add: function (a, b) {
    console.log(obj.num);
    obj.num += a + b;
    return obj.num;
  }
};

module.exports = obj;
  • ./moduleA.js
// 第一次 require
const add = require('./cal').add;
console.log('add function moduleA >>> ', add(1, 2));
  • ./moduleB.js
// 第二次 require
const name = require('./cal').name;
console.log('name >>> ', name);

const add = require('./cal').add;
console.log('add function moduleB >>> ', add(1, 2));
  • ./index.js
require('./moduleA');
require('./moduleB');

此时的结果如下:cal.js 只会输出一次;

image-20220213005019671.png

其原理大致如下:

在模块中,有一个 module 对象用来存储信息,这个对象中有一个属性 loaded 用于记录该模块是否被加载过,它的默认值是 false,当模块第一次被加载和执行过后会变成 true

如果后面再次加载时,检测到 loadedtrue,则不会次再执行模块代码。

注意点:

  • 有时我们加载一个模块,不需要获取其导出的内容,只是想要通过执行它而产生某种作用,比如把它的接口挂载到全局对象上,此时直接使用 require 即可;
require('./task.js');
  • 另外,require 可以接收表达式,可以动态加载指定的模块;
const moduleNames = ['foo.js', 'bar.js'];
moduleNames.forEach(name => {
  require('./' + name);
});

适合 WEB 开发的 AMD 模块化规范(异步)

另⼀个为 WEB 开发者所熟知的 JS 运行环境就是浏览器了。浏览器并没有提供像 Node.js 里⼀样的 require 方法。不过,受到 CommonJS 模块化规范的启发,WEB 端还是逐渐发展起来了 AMDSystemJS 规范等适合浏览器端运行的 JS 模块化开发规范。

AMD 全称 Asynchronous module definition,意为 异步的模块定义 ,不同于 CommonJS 规范的同步加载,AMD 正如其名所有模块默认都是异步加载,这也是早期为了满足 web 开发的需要,因为如果在 web 端也使用同步加载,那么页面在解析脚本文件的过程中可能使页面暂停响应。

AMD 模块的定义与 CommonJS 稍有不同,上面这个例子的三个模块分别改成 AMD 规范就类似这样:

  • ./moduleB.js
define(function (require, factory) {
  return Date.now();
});
  • ./moduleA.js
define(['moduleB'], function (moduleB) {
  setTimeout(() => {
    console.log('moduleA in moduleB >>>> ', moduleB);
  }, 1000);
  for (let i = 0; i < 1000000000; i++) {}
  return 'moduleA ==> ' + new Date().getTime();
});
  • ./index.js
require(['moduleA', 'moduleB'], function (moduleA, moduleB) {
  console.log('moduleA ===== ', moduleA);
  console.log('moduleB ===== ', moduleB);
});

/* 
  输出结果:
  moduleA =====  moduleA ==> 1644684881863
  moduleB =====  1644684896320
  moduleA in moduleB >>>>  1644684896320
*/

我们可以对比看到,AMD 规范也支持文件级别的模块,模块 ID 默认为文件名,在这个模块文件中,我们需要使用 define 函数来定义⼀个模块,在回调函数中接受定义组件内容。这个回调函数接受⼀个 require 方法,能够在组件内部加载其他模块,这里我们分别传入模块 ID,就能加载对应文件内的 AMD 模块。不同于 CommonJS 的是,这个回调函数的返回值即是模块导出结果

差异比较大的地方在于我们的入口模块,我们定义好了 moduleAmoduleB 之后,入口处需要加载进来它们,于是乎就需要使用 AMD 提供的 require 函数,第⼀个参数写明入口模块的依赖列表,第二个参数作为回调参数依次会传入前面依赖的导出值,所以这里我们在 index.js 中只需要在回调函数中打印 moduleB 传入的值即可。

Node.js 里我们直接通过 node ./index.js 来查看模块输出结果,在 WEB 端我们就需要使用⼀个 html 文件,同时在里面加载这个入口模块。这里我们再加入⼀个 index.html 作为浏览器中的启动入口。

如果想要使用 AMD 规范,我们还需要添加⼀个符合 AMD 规范的加载器脚本在页面中,符合 AMD 规范实现的库很多,比较有名的就是 require.js

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <!--  此处必须加载 require.js 之类的 AMD 模块化库之后才可以继续加载模块 -->
    <script src="./require.js"></script>

    <!-- 只需要加载入口模块即可 -->
    <script src="./index.js"></script>
  </body>
</html>

使用 AMD 规范改造项目之后的关系如下图,在物理关系里多了两个文件,但是模块间的逻辑关系仍与之前相同。

image-20210901024845954.png

启动静态服务之后我们打开浏览器中的控制台,无论我们刷新多少次页面,同 Node.js 的例子⼀样,输出的结果均相同。同时我们还能看到,虽然我们只加载了 index.js 也就是入口模块,但当使用到 moduleAmoduleB 的时候,浏览器就会发请求去获取对应模块的内容。

AMD-2.png

从结果上来看,AMDCommonJS ⼀样,都完美的解决了上面说的 变量作用域依赖关系 之类的问题。但是 AMD 这种默认异步,在回调函数中定义模块内容,相对来说使用起来就会麻烦⼀些。

同样的,AMD 的模块也不能直接运行在 Node 端,因为内部的 define 函数,require 函数都必须配合在浏览器中加载 require.js 这类 AMD 库才能使用。

能同时被 CommonJS 规范和 AMD 规范加载的 UMD 模块

有时候我们写的模块需要同时运行在浏览器端和 Node.js 里面,这也就需要我们分别写⼀份 AMD 模块和 CommonJS 模块来运行在各自环境,这样如果每次模块内容有改动还得去两个地方分别进行更改,就比较麻烦。

// ⼀个返回随机数的模块,浏览器使用的 AMD 模块
// ========== math.js ==========
define(function () {
  return function () {
    return Math.random();
  };
});

// ⼀个返回随机数的模块,Node.js 使用的 CommonJS 模块
module.exports = function () {
  return Math.random();
};

基于这样的问题,UMD(Universal Module Definition) 作为⼀种 同构(isomorphic 的模块化解决方案出现,它能够让我们只需要在⼀个地方定义模块内容,并同时兼容 AMDCommonJS 语法。

写⼀个 UMD 模块也非常简单,我们只需要判断⼀下这些模块化规范的特征值,判断出当前究竟在哪种模块化规范的环境下,然后把模块内容用检测出的模块化规范的语法导出即可。

(function (self, factory) {
  if (typeof module === 'object' && typeof module.exports === 'object') {
    // 当前环境是 CommonJS 规范环境
    module.exports = factory();
  } else if (typeof define === 'function' && define.amd) {
    // 当前环境是 AMD 规范环境
    define(factory);
  } else {
    // 什么环境都不是,直接挂在全局对象上
    self.umdModule = factory();
  }
})(this, function () {
  return function () {
    return Math.random();
  };
});

上面就是⼀种定义 UMD 模块的方式,我们可以看到首先他会检测当前加载模块的规范究竟是什么。如果 module.exports 在当前环境中为对象,那么肯定为 CommonJS,我们就能用 module.exports 导出模块内容。如果当前环境中有 define 函数并且 define.amdtrue ,那我们就可以使用 AMDdefine 函数来定义⼀个模块。最后,即使没检测出来当前环境的模块化规范,我们也可以直接把模块内容挂载在全局对象上,这样也能加载到模块导出的结果。

ESModule 规范

前面我们说到的 CommonJS 规范和 AMD 规范有这么几个特点:

  1. 语言上层的运行环境中实现的模块化规范,模块化规范由环境自己定义。
  2. 相互之间不能共用模块。例如不能在 Node.js 运行 AMD 模块,不能直接在浏览器运行 CommonJS 模块。

使用 babel 编译:

  • 安装依赖:
yarn add webpack webpack-cli

yarn add babel-loader @babel/core @babel/preset-env
  • webpack.config.js
const path = require('path');

module.exports = {
  mode: 'none',
  entry: './index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index_bundle.js',
    publicPath: 'dist/'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  }
};
  • 配置 package.json
{
  "name": "index",
  "version": "1.0.0",
  "description": "test",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@babel/core": "^7.14.0",
    "@babel/preset-env": "^7.14.1",
    "babel-loader": "^8.2.2",
    "webpack": "^5.37.0",
    "webpack-cli": "^4.7.0"
  }
}
  • 执行编译:
yarn build

EcmaScript 2015 也就是我们常说的 ES6 之后,JS 有了语言层面的模块化导入导出关键词与语法以及与之匹配的 ESModule 规范。使用 ESModule 规范,我们可以通过 importexport 两个关键词来对模块进行导入与导出。

还是之前的例子,使用 ESModule 规范和新的关键词就需要这样定义:

  • ./moduleB.js
var num = 0;
var m = new Date().getTime();
const update = () => {
  num += 5;
};

export { num, m, update };
  • ./moduleA.js
import { num, m, update } from './moduleB';
import * as moduleB from './moduleB';

console.log('moduleB ======== ', moduleB);
/* 
  moduleB 导出的是一个对象
  moduleB ========  Object [Module] { num: [Getter], m: [Getter], update: [Getter] }
*/
console.log('typeof moduleB ========= ', typeof moduleB); // object

setTimeout(() => {
  console.log('moduleA >>> ', m);
}, 1000);
console.log('moduleA in num ==== ', num);

update();
  • ./index.js
import './moduleA';
import { m, num } from './moduleB';

console.log('index >>> ', m);
console.log('index in num ====  >>> ', num);

/* 
  输出结果:
    moduleA in num ====  0
    index >>>  1645292589260
    index in num ====  >>>  5
    moduleA >>>  1645292589260
*/

ESModuleCommonJSAMD 最大的区别在于,ESModule 是由 JS 解释器实现,而后两者是在宿主环境中运行时实现ESModule 导入实际上是在语法层面新增了⼀个语句,而 AMDCommonJS 加载模块实际上是调用了 require 函数。

// 这是⼀个新的语法,我们没办法兼容,如果浏览器无法解析就会报语法错误
import moduleA from './moduleA';

// 我们只需要新增加一个 require 函数,就可以首先保证 AMD 或 CommonJS 模块不报语法错误
function require() {}
const moduleA = require('./moduleA');

ESModule 规范支持通过这些方式导入导出代码,具体使用哪种情况得根据如何导出来决定:

import { var1, var2 } from "./moduleA";
import * as vars from "./moduleB";
import m from "./moduleC";

export default {
  var1: 1,
  var2: 2
};

export const var1 = 1;

const obj = {
  var1,
  var2
};

export default obj;

这里又⼀个地方需要额外指出,import { var1 } from "./moduleA" 这里的括号并不代表获取结果是个对象,虽然与 ES6 之后的对象解构语法非常相似。

// 【错误方式】这些用法都是错误的,这里不能使用对象默认值,对象 key 为变量这些语法
import { var1 = 1 } from "./moduleA"
import { [test]: a } from "./moduleA";

// 【正确方式】这个才是 ESModule 导入语句种正确的重命名方式
import { var1 as customVar1 } from "./moduleA";

// 【正确方式】这些用法都是合理的,因为 CommonJS 导出的就是个对象,我们可以用操作对象的方式来操作导出结果
const { var1 = 1 } = require("./moduleA");
const { [test]: var1 = a } = require("./moduleA");

// 【错误方式】这种用法是错误的,因为对象不能这么使用
const { var1 as customVar1 } = require("./moduleA");

用⼀张图来表示各种模块规范语法和它们所处环境之间的关系:

image-20210901022943017.png

每个 JS 的运行环境都有⼀个解析器,否则这个环境也不会认识 JS 语法。它的作用就是用 ECMAScript 的规范去解释 JS 语法,也就是处理和执行语言本身的内容,例如按照逻辑正确执行 var a = "123"function func() { console.log("hahaha") } 之类的内容。

在解析器的上层,每个运行环境都会在解释器的基础上封装⼀些环境相关的 API。例如 Node.js 中的 global 对象、process 对象,浏览器中的 window 对象,document 对象等等。这些运行环境的 API 受到各自规范的影响,例如浏览器端的 W3C 规范,它们规定了 window 对象和 document 对象上的 API 内容,以使得我们能让 document.getElementById 这样的 API 在所有浏览器上运行正常。

事实上,类似于 setTimeoutconsole 这样的 API,大部分也不是 JS Core 层面的,只不过是所有运行环境实现了相似的结果。

setTimeoutES7 规范之后才进入 JS Core 层面,在这之前都是浏览器和 Node.js 等环境进行实现。

console 类似 promise,有自己的规范,但实际上也是环境自己进行实现的,这也就是为什么 Node.jsconsole.log 是异步的而浏览器是同步的⼀个原因。同时,早期的 Node.js 版本是可以使用 sys.puts 来代替 console.log 来输出至 stdout 的。

**ESModule 就属于 JS Core 层面的规范,而 AMDCommonJS 是运行环境的规范。**所以,想要使运行环境支持 ESModule 其实是比较简单的,只需要升级自己环境中的 JS Core 解释引擎到足够的版本,引擎层面就能认识这种语法,从而不认为这是个 语法错误(syntax error ,运行环境中只需要做⼀些兼容工作即可。

Node.jsV12 版本之后才可以使用 ESModule 规范的模块,在 V12 没进入 LTS 之前,我们需要加上 --experimental-modulesflag 才能使用这样的特性,也就是通过 node --experimental-modules index.js 来执行。浏览器端 Chrome 61 之后的版本可以开启支持 ESModule 的选项,只需要通过 import export 这样的标签加载即可。

这也就是说,如果想在 Node.js 环境中使用 ESModule,就需要升级 Node.js 到高版本,这相对来说比较容易,毕竟服务端 Node.js 版本控制在开发人员自己手中。但浏览器端具有分布式的特点,是否能使用这种高版本特性取决于用户访问时的版本,而且这种解释器语法层面的内容无法像 AMD 那样在运行时进行兼容,所以想要直接使用就会比较麻烦。

后模块化时代

通过前面的分析我们可以看出来,使用 ESModule 的模块明显更符合 JS 开发的历史进程,因为任何⼀个支持 JS 的环境,随着对应解释器的升级,最终⼀定会支持 ESModule 的标准。但是,WEB 端受制于用户使用的浏览器版本,我们并不能随心所欲的随时使用 JS 的最新特性。为了能让我们的新代码也运行在用户的老浏览器中,社区涌现出了越来越多的工具,它们能静态将高版本规范的代码编译为低版本规范的代码,最为大家所熟知的就是 babel

它把 JS Core 中高版本规范的语法,也能按照相同语义在静态阶段转化为低版本规范的语法,这样即使是早期的浏览器,它们内置的 JS 解释器也能看懂。

image-20210509002545166.png

然后,不幸的是,对于模块化相关的 importexport 关键字,babel 最终会将它编译为包含 requireexportsCommonJS 规范。点击链接在线查看编译结果

import './anotherModule';
import a from './moduleA';

export default a;

babel 编译之后的代码:

'use strict';

Object.defineProperty(exports, '__esModule', {
  value: true
});
exports.default = void 0;

require('./anotherModule');

var _moduleA = _interopRequireDefault(require('./moduleA'));

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

var _default = _moduleA.default;
exports.default = _default;

**这就造成了另外一个问题,这样带有模块化关键词的模块,编译之后还是没办法直接运行在浏览器中,因为浏览器端并不能运行 CommonJS 的模块。**为了能在 WEB 端直接使用 CommonJS 规范的模块,除了编译之外,我们还需要一个步骤叫做 打包(bundle

所以打包工具比如 webpack/rollup,编译工具 babel 它们之间的区别和作用就很清楚了:

  • 打包工具(webpack)主要处理的是 JS 不同版本间模块化的区别;
  • 编译工具(babel)主要处理的是 JS 版本间语义的问题;

如果使用了 ESModule:必须使用 webpackbabel

如果是 AMDCommonJS:只用 webpack