[TOC]

模块模式

思想

把逻辑分块,各自封装,相互独立,每个块自行决定对外暴露什么,同时自行决定引入执行哪些外部代码。

模块标识符

字符串或模块文件的实际路径。

模块依赖

模块系统的核心是管理依赖。主要做两件事:(1)检视一个模块正常运行时需要加载哪些模块;(2)通过标识符搜索模块并加载

模块加载

先把依赖全都加载完才能运行当前模块,加载依赖时可能会递归地评估并加载所有依赖,也可能发送请求并等待网络返回。只有整个依赖图都加载完毕,才可以执行入口模块。

入口

就是代码执行的起点。
模块加载是“阻塞的”,这意味着前置操作必须完成才能执行后续操作。每个模块在自己的代码到达浏览器之后完成加载,此时其依赖已经加载并初始化。

异步依赖

按需加载。让JavaScript通知模块系统在必要时加载新模块,并在模块加载完成后提供回调。

动态依赖

在运行时动态地添加依赖,如条件结构中。有可能阻塞代码的执行,因为只有模块加载后才会继续。
动态依赖可以支持更复杂的依赖关系,但代价是增加了对模块进行静态分析的难度。

静态分析

模块中包含的发送到浏览器的 JavaScript 代码经常会被静态分析,分析工具会检查代码结构并在不实际执行代码的情况下推断其行为。对静态分析友好的模块系统可以让模块打包系统更容易将代码处理为较少的文件。它还将支持在智能编辑器里智能自动完成。
更复杂的模块行为,例如动态依赖,会导致静态分析更困难。不同的模块系统和模块加载器具有不同层次的复杂度。至于模块的依赖,额外的复杂度会导致相关工具更难预测模块在执行时到底需要哪些依赖。

循环依赖

所有模块系统都支持循环依赖。只要恰当地封装模块,使它们没有副作用,加载顺序就应该不会影响应用程序的运行。
加载器会执行深度优先的依赖加载。

凑合的模块系统

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

var myModule = (function(){
  return {
    name:"Javascript"
  };
})();

完全暴露API

通过把模块的返回值赋给一个变量,就相当于为模块创建了命名空间。上面的例子中的模块的命名空间就是myModule,被封装的数据都必须以它为前缀进行访问,如myModule.name

泄露模块模式

除了可以直接在返回对象中定义属性和方法,还有一种模式叫“泄露模块模式”。这种模式只返回一个对象,它的属性是对函数私有数据和成员的引用:

var foo = (function(){
  var name = "foo";
  var fun = function(){
    console.log("javascript");
  };
  return {
    name,
    fun
  })();

与完全暴露公共API相比,泄露模块模式可以选择性的泄露函数的私有数据和成员,如:

var foo = (function(){
    var name="js";
    var getName = function(){
        console.log(name);
    };
    var setName = function(str){
        name = str;
        console.log(name);
    };
    return {
        getName,
        setName,
    };
})();
foo.getName();  //js

这样写的话,就能更容易地看出foo就跟这个函数作用域是绑定了。

命名空间嵌套

在模块内部定义模块,这样可以实现空间嵌套。

var Foo = (function() {
  return {
      bar: 'baz'
  };
})();
Foo.baz = (function() {
  return {
    qux: function() {
      console.log('baz');
      }
    };
})();
console.log(Foo.bar); // 'baz'
Foo.baz.qux(); // 'baz'

使用外部值

将外部值作为参数传给IIFE

扩展模块

先创建命名空间和对象实例,然后扩展。

// 原始的 Foo
var Foo = (function(bar) {
    var bar = 'baz';
  return {
      bar: bar
  };
})();
// 扩展 Foo
var Foo = (function(FooModule) {
  FooModule.baz = function() {
      console.log(FooModule.bar);
  }
    return FooModule;
})(Foo||{});
console.log(Foo.bar); // 'baz'
Foo.baz(); // 'baz'

模块系统需要实现的功能

  • 管理依赖和排序
  • 动态加载依赖
  • 添加异步加载
  • 添加循环依赖

ES6之前的模块加载器

CommonJS

特点
  1. 主要用于服务器端实现模块化代码组织,能够一次性把所有模块都加载到内存,因为所有的模块都存放在本地硬盘,可以同步加载完成。
  2. require()指定依赖,module.exports =定义自己的公共API
  3. 调用require() 意味着模块会原封不动地加载进来,赋值给变量不是必须的。
  4. 无论一个模块在 require() 中被引用多少次,模块永远是单例
  5. 在 CommonJS 中,模块加载是模块系统执行的同步操作。require之后的代码需等待模块加载之后才执行
  6. Browserify——一个运行在Node.js环境下的模块打包工具,他可以将CommomJS模块打包为浏览器可以运行的单个文件。这意味着客户端的代码也可以遵循CommomJS标准来编写了。

导出

一般只有一个导出出口

//导出字符串
module.exports = "foo";

//导出类
class A{}
module.exports = A;
--------
var A = require("./moduleA");
var a = new A();

//导出对象(公共接口)
module.exports = {
  a:"A",
  b:"B"
};

导入
//moduleA.js
modules.export = function(){
  console.log("hello");
}

//index.js
const moduleA = require("./moduleA");
moduleA();  //hello

异步模块定义(AMD)

特点
  1. 其模块定义系统以浏览器为目标执行环境,需考虑网络延迟,所以采用了异步方式加载模块,模块的加载不影响后面语句的运行。
  2. 按需获取依赖,并在依赖加载完成后立即执行依赖它们的模块。
  3. 实现的核心使用函数包装模块定义。
    1. 这样可以防止声明全局变量,并允许加载器库控制何时加载模块。
    2. 包装函数也便于模块代码的移植,因为包装函数内部的所有模块代码使用的都是原生JavaScript 结构
    3. 包装模块的函数是全局 define 的参数,它是由 AMD 加载器库的实现定义的。
  4. AMD模块使用字符串标识指定自己的依赖
    // ID 为'moduleA'的模块定义。moduleA 依赖 moduleB,
    // moduleB 会异步加载
    define('moduleA', ['moduleB'], function(moduleB) {
    return {
       stuff: moduleB.doStuff();
    };
    });
    
    在moduleB加载完成后,回调函数会被调用,回调函数体内都是加载了moduleB之后才能执行的代码。

通用模块定义(UMD)

为了统一 CommonJS 和 AMD 生态系统,通用模块定义(UMD,Universal Module Definition)规范应运而生。UMD 可用于创建这两个系统都可以使用的模块代码。本质上,UMD 定义的模块会在启动时检测要使用哪个模块系统,然后进行适当配置,并把所有逻辑包装在一个立即调用的函数表达式(IIFE)中。虽然这种组合并不完美,但在很多场景下足以实现两个生态的共存。

//calculator.js
(function(global,main){
  //根据当前环境采取不同的导出方式
  if(typeof define === 'function' && define.amd){
    //AMD
    define(···);
  }else if(typeof exports === 'object'){
    //CommomJS
    module.exports = ···;
  }else{
    //非模块化环境
    global.add = ···;
  }
}(this.function(){
  //定义模块主体
  return {···};
}));

可以根据导出需要调整条件结构。

ES6模块

模块标签及定义

  1. 在网页中嵌入模块

    <script type="module">
    //模块代码
    </script>
    
  2. 在网页中加载外部模块

    <script type="module" src="path/to/myModule.js"></script>
    

    与传统脚本不同,所有模块都会像 <script defer> 加载的脚本一样按顺序执行。解析到 <script type="module"> 标签后会立即下载模块文件,但执行会延迟到文档解析完成。无论对嵌入的模块代码,还是引入的外部模块文件,都是这样。
    <script type="module"> 在页面中出现的顺序就是它们执行的顺序。
    <script defer> 一样,修改模块标签的位置,无论是在 <head> 还是在 <body> 中,只会影响文件什么时候加载,而不会影响模块什么时候加载(延迟到文档解析完成,按时顺序执行)。

嵌入的模块定义代码不能使用 import 加载到其他模块。只有通过外部文件加载的模块才可以使用import加载。因此,嵌入模块只适合作为入口模块。

模块加载

通过浏览器原生加载或者和第三方加载器和构建工具一起加载。

模块行为

ECMAScript 6模块借用了 CommonJS 和 AMD 的很多优秀特性。下面简单列举一些。

  • 模块代码只在加载后执行。
  • 模块只能加载一次。
  • 模块是单例。
  • 模块可以定义公共接口,其他模块可以基于这个公共接口观察和交互。
  • 模块可以请求加载其他模块。
  • 支持循环依赖。

ES6 模块系统也增加了一些新行为。

  • ES6 模块默认在严格模式下执行。
  • ES6 模块不共享全局命名空间。
  • 模块顶级 this 的值是 undefined (常规脚本中是 window )。
  • 模块中的 var 声明不会添加到 window 对象。
  • ES6 模块是异步加载和执行的。

浏览器运行时在知道应该把某个文件当成模块时,会有条件地按照上述 ECMAScript 6 模块行为来施加限制。与 <script type="module"> 关联或者通过 import 语句加载的 JavaScript文件会被认定为模块。

模块导出

命名导出

//行内命名导出
export const foo = "foo";

//以对象导出
const foo = "foo";
const bar = "bar";
export {foo,bar as mybar};   //用as指定别名

默认导出

const foo = 'foo';
export default foo;
//等价于
const foo = 'foo';
export {foo as default};

//默认导出和命名导出可以共存
const foo = 'foo';
const bar = 'bar';
export default foo;
export {bar};
//export { foo as default, bar };

默认导出只能有一个。

模块导入

模块标识符可以是相对于当前模块的相对路径,也可以是指向模块文件的绝对路径。它必须是纯字符串,不能是动态计算的结果。例如,不能是拼接的字符串。
如果在浏览器中通过标识符原生加载模块,则文件必须带有.js 扩展名,不然可能无法正确解析。不过,如果是通过构建工具或第三方模块加载器打包或解析的 ES6 模块,则可能不需要包含文件扩展名。
命名导出和默认导出的区别也反映在它们的导入上。命名导出可以使用 * 批量获取并赋值给保存导出集合的别名,而无须列出每个标识符:

const foo = 'foo', bar = 'bar', baz = 'baz';
export { foo, bar, baz }
import * as Foo from './foo.js';
console.log(Foo.foo); // foo
console.log(Foo.bar); // bar
console.log(Foo.baz); // baz

要指名导入,需要把标识符放在 import 子句中。使用 import 子句可以为导入的值指定别名:

import { foo, bar, baz as myBaz } from './foo.js';
console.log(foo); // foo
console.log(bar); // bar
console.log(myBaz); // baz

默认导出就好像整个模块就是导出的值一样。可以使用 default 关键字并提供别名来导入。也可
以不使用大括号,此时指定的标识符就是默认导出的别名:

// 等效
import { default as foo } from './foo.js';
import foo from './foo.js';

如果模块同时导出了命名导出和默认导出,则可以在 import 语句中同时取得它们。可以依次列出
特定导出的标识符来取得,也可以使用 * 来取得:

import foo, { bar, baz } from './foo.js';
import { default as foo, bar, baz } from './foo.js';
import foo, * as Foo from './foo.js';   //foo是默认导出,Foo是命名导出

默认导出的foo必须写在大括号前面,而不能顺序颠倒,否则会提示语法错误。

模块转移导出

一般是专门用来集合所有页面或组件的入口文件。

导入和导出的嵌套
//foo.js
export const baz = 'origin:foo';
//bar.js
export * from './foo.js';
export const baz = 'origin:bar';
//main.js
import { baz } from './bar.js';
console.log(baz); // origin:bar

需要注意的是,更接近入口的依赖可能把转移的值给“重写”了,所以这种写法需要注意导出名称是否冲突。例子中baz就发生了冲突。

export from
//把外部模块foo.js的foo和bar作为自己的导出,并为bar创建了别名
export {foo,bar as myBar} from './foo.js';
//外部模块foo.js的默认导出(命名导出)可以重用为当前模块的默认导出
export {default} from './foo.js';
export { foo as default } from './foo.js';

工作者模块

const moduleWorker = new Worker('moduleWorker.js', { type: 'module' });

CommomJS和ES6 Module的区别

动态与静态

它们本质的区别在于前者对模块依赖的解决是“动态的”,而后者是“静态”的。在这里“动态”是指模块依赖关系的建立发生在代码运行阶段,而“静态”则是模块依赖关系建立发生在代码编译阶段。
例子:
CommomJS:

//moduleA.js
module.exports = {name:"moduleA"};

//moduleB.js
const name = require('./moduleA.js').name;


if(loadcondition){
    const A = require('./moduleA.js');
}

从上面的例子可以看出,CommomJS的require()可以当做普通函数来用,它就是用来返回一个对象。并且模块路径可以动态指定,支持传入一个表达式。
ES6 Module:

//moduleA.js
export const name = 'moduleA';

//moduleB.js
import {name} from './modduleA.js';

ES6 Module的导入导出都必须是声明式的,它不支持导入的路径是一个表达式,并且导入导出语句必须位于顶层作用域(比如不能放在if语句中)。

ES6 Module相比于CommomJS的优势:

  1. 死代码检测和排除。我们可以用静态分析工具检测出哪些模块没有被调用过,通过静态分析可以在打包时去掉这些未曾使用过的模块,以减少打包资源体积。因为ES6在编译阶段就能确定哪些模块会被用到,哪些会被用到,但CommomJS则不行,因为无法确定哪些模块之后会被调用,哪些不会。
  2. 模块变量类型检查。JavaScript属于动态类型语言,不会在代码执行前检查类型错误(比如对一个字符串类型的值进行函数调用)。ES6 Module的静态模块结构有助于确保模块之间传递的值或接口类型是正确的。
  3. 编译器优化。在CommomJS等动态模块系统中,无论采用哪种方式,本质上导入的都是一个对象,而ES6 Module支持直接导入变量,减少了引用层级,程序效率更高。

值拷贝和动态映射

在导入一个模块时,对于CommomJS来说获取的是一份导出值的拷贝;而ES6 Module中则是值的动态映射,并且这个映射是只读的。
CommomJS中的值拷贝:

//moduleA.js
var count = 0;
module.exports = {
  count:count,
  add:function(a,b){
    count += 1;
    return a+b;
  }
}

//index.js
var count = require('./moduleA.js').count;
var add = require('./moduleA.js').add;

console.log(count);     //0  (这里的count是对moduleA.js的count的值拷贝)
add(2,3);               
console.log(count);     //0  (改变的是moduleA.js里的count,对index.js里的拷贝值count没影响)

count += 1;
console.log(count)     //拷贝值可以被更改

require返回的是一个拷贝,如果是原始类型,那么就完全没有关系了,如果是对象或者引用了模块中的变量的函数,就会对源模块造成改变,比如例子里的add函数,它在函数体内引用了moduleA作用域的count,所以依然可以保持关系,并能够改变模块里的变量count(不是返回的对象里的属性count)。

ES6 Module:

//moduleA.js
let count = 0;
const add = function(a,b){
  count++;
  return a+b;
};
export {count,add};

//index.js
import {count,add} from './moduleA.js';
console.log(count);     //0  (对moduleA.js的count的映射)
add(2,3);               
console.log(count);     //1  (实时反映)

count += 1;    //不可更改,会抛出SyntaxError:"count" is read-only

循环依赖

ES6 Module动态映射的特性使得它能更好地支持循环依赖。
CommomJS:

//foo.js
const bar = require('./bar.js');
let foo = function(sign){
  console.log(sign,bar);
  bar('bar in foo');
}
module.exports = foo;


//bar.js
const foo = require('./foo.js');
let bar = function(sign){
  console.log(sign,foo);
  foo('foo in bar');
}
module.exports = bar;

//index.js
const foo = require('./foo.js');
foo('index');


//输出结果
//index ƒ (sign){console.log(sign,foo);foo('foo in bar');}
//bar in foo {}
//Uncaught TypeError:foo is not a function

实际执行顺序:

  1. index.js导入foo模块,开始执行foo.js的代码
  2. foo.js导入bar模块,开始执行bar.js的代码
  3. 此时,bar.js中又对foo进行require,这里就产生了循环依赖。需要注意的是,执行权并没有再交给foo.js,而是直接导出其值,也就是module.exports(应该通过判断依赖路径上有没有foo,有就不会再转交执行权,不然会死循环)。但此时的foo上未被赋值,导出值在此时的默认值是空对象,如bar函数中的打印结果所示:bar in foo {},并且无法作为函数执行。再根据CommomJS导出值是值拷贝的特点,在bar函数体内的foo就始终是空对象。
  4. bar.js执行完毕,执行权交给foo.js
  5. foo.js顺着require语句之后继续执行,调用bar()
  6. 接着执行权回到index.js,foo函数执行。

webpack打包后的bundle中有这样一段代码:

/******/     // The module cache
/******/     var __webpack_module_cache__ = {};
/******/     
/******/     // The require function
/******/     function __webpack_require__(moduleId) {
/******/         // Check if module is in cache
/******/         var cachedModule = __webpack_module_cache__[moduleId];
/******/         if (cachedModule !== undefined) {
/******/             return cachedModule.exports;
/******/         }
/******/         // Create a new module (and put it into the cache)
/******/         var module = __webpack_module_cache__[moduleId] = {
/******/             // no module.id needed
/******/             // no module.loaded needed
/******/             exports: {}
/******/         };
/******/     
/******/         // Execute the module function
/******/         __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/     
/******/         // Return the exports of the module
/******/         return module.exports;
/******/     }
/******/

当index.js引用了foo.js之后,就相当于执行了这个webpack_require函数,从webpack_module_cache中根据moduleId来判断缓存中有没有这个模块,如果有(值不为undefined),则取出并返回exports对象,否则Create a new module (and put it into the cache)。foo.js尚未能执行到给foo赋值和返回exports对象这一步,执行权就交给了bar.js,这里也经过了一个require过程,bar.js模块自然是缓存里尚未加载的。在bar.js中,它再次require了foo,但此时因为foo模块已经在缓存里了(在index.js导入foo.js时),所以直接在缓存里取“foo”,但这不过是一个空对象而已。

问:缓存里什么时候被填入真正的模块?

ES6 Module:
利用ES6模块的动态映射特点实现循环依赖
index.html

//index.html
<!DOCTYPE html>
<html>
    <head>
        <title>import</title>
    </head>
    <script type = "module" src="./index.js"></script>
</html>

index.js

import {foo} from './foo.js'
foo('index',Date.now());

foo.js

import {bar} from './bar.js';
export const foo = function(sign){
    console.log(sign,Date.now());
    bar('bar in foo');
}

bar.js

import {foo} from './foo.js';
let invocked = false;
export const bar = function(sign){
    if(!invocked){
        invocked = true;
        console.log(sign,Date.now());
        foo('foo in bar');
    }
}

输出:

index 1635230460943
bar in foo 1635230460943
foo in bar 1635230460943

代码执行顺序:

  1. index.js作为入口导入了foo.js,此时开始执行foo.js中的代码
  2. 从foo.js中导入了bar.js,执行权交给bar.js
  3. 在bar.js中一直执行到其结束,完成bar函数的定义。注意,此时foo.js还没执行完,foo的值依然是undefined
  4. 执行权回到foo.js继续执行到其结束,完成foo函数的定义。由于ES6模块的动态映射特性,此时bar.js中的foo的值也从undefined变成了foo.js中定义的foo函数,这是与CommomJS在解决循环依赖时本质的区别,CommomJS中导入的是值拷贝,不会随着被夹在模块中的原有值的变化而变化
  5. 执行权回到index.js并调用foo函数,此时会依次执行foo()—>bar()—>foo()。

向后兼容

ECMAScript 模块的兼容是个渐进的过程,能够同时兼容支持和不支持的浏览器对早期采用者是有价值的。对于想要尽可能在浏览器中原生使用 ECMAScript 6 模块的用户,可以提供两个版本的代码:基于模块的版本与基于脚本的版本。如果嫌麻烦,可以使用第三方模块系统(如 SystemJS)或在构建时将 ES6 模块进行转译,这都是不错的方案。

第一种方案涉及在服务器上检查浏览器的用户代理,与支持模块的浏览器名单进行匹配,然后基于匹配结果决定提供哪个版本的 JavaScript 文件。这个方法不太可靠,而且比较麻烦,不推荐。

更好、更优雅的方案是利用脚本的 type 属性和 nomodule 属性。
浏览器在遇到