UMD


全称:Universal Module Definition。提供一个前后端跨平台的解决方案(支持AMD与CommonJS模块方式)。UMD的实现很简单:


1、先判断是否支持Node.js模块格式(exports是否存在),存在则使用Node.js模块格式。
2、再判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块。
3、前两个都不存在,则将模块公开到全局(window或global)。


这里举例一个jQuery使用的,按照如上方式实现的代码:


// if the module has no dependencies, the above pattern can be simplified to
(function (root, factory) {
 if (typeof define === 'function' && define.amd) {
 // AMD. Register as an anonymous module.
define([], factory);
} else if (typeof exports === 'object') {
 // Node. Does not work with strict CommonJS, but
 // only CommonJS-like environments that support module.exports,
 // like Node.
 module.exports = factory();
} else {
 // Browser globals (root is window)
root.returnExports = factory();
}
}(this, function () {

 // Just return a value to define the module export.
 // This example returns an object, but the module
 // can return a function as the exported value.
 return {};
}));


CommonJS


先在缓存中查找是否已经加载过该模块,如果有,直接返回;如果没有,路径解析(相对路径,或者加载第三方模块) + 模块查找(路径指向的可能是文件也可能是目录)


循环引用问题


// a.js
exports.loaded = false

console.log('before require b in a')
const b = require('./b')
console.log('before require b in a')

module.exports = {
  loaded: true,
  bWasLoaded: b.loaded
}

// b.js
exports.loaded = false
console.log('before require a in b')
const a = require('./a') // a 已经加载过了,故不会继续执行a模块中require('./b')后面的代码,所以执行这一行之后会直接执行当前文件的下一行
console.log('after require a in b')

module.exports = {
  loaded: true,
  aWasLoaded: a.loaded
}

// index.js
const a = require('./a')
const b = require('./b')

console.log(a)
console.log(b)


运行index.js文件之后,打印出来的结果如下:


// before require b in a
// before require a in b
// after require a in b
// before require b in a
// { loaded: true, bWasLoaded: true }
// { loaded: true, aWasLoaded: false }


简单分析一下,在执行a模块导入模块b时,在模块b中发现需要导入模块a,此时require机制会先查找模块a是否已经加载过,发现已经加载过了而且模块a导出了exports.loaded = false(模块a的第一行代码)。此时模块b中的require('./a')立即返回,而且返回值为{loaded: false},最终模块b导出{loaded: true, aWasLoaded: false},同时也作为模块a中require('./b')的返回值,因此模块a最终导出{loaded: true, bWasLoaded: true}。


ES Module


ES6提出了新的JavaScript模块标准,在浏览器还不完全支持该标准以前,需要借助babel编译。经过babel编译之后的ES模块和CommonJS模块很像,然后编译后的每个模块再经过webpack包装,生成最终的bundle。需要注意的是,将ES Module语法编译成CommonJS规范的代码牺牲了ES Module自身的静态分析特性。


Node.js加载CommonJS模块时会阻塞主线程,路径解析、模块加载、执行的过程中间不会发生中断require传入的模块路径可以使用变量,因为加载依赖的模块时已经在执行当前模块的代码,可以通过执行代码动态获得模块路径。然而在ES Module中,模块加载和执行的步骤分为三步:


1、构造。确定模块文件位置下载并将所有的文件解析为模块记录(一种数据结构),模块记录在后面会转化为模块实例(包含代码和状态,状态指的是在任何时刻变量的实际值),加载过程主要是将所有模块下载之后然后转化为模块记录
2、实例化。找到存放模块导出值的内存空间,然后将导入变量和导出变量同时指向这些内存空间(这也解释了为什么导出变量和导入变量的值是保持同步的,两者指向的是同一个地址空间),这个过程也称为链接,CommonJS则与此不同,CommonJS是将模块导出的对象复制了一份然后由其他模块引入。
3、执行。执行模块代码,给第二步中的内存空间赋值。


所有模块路径分析都发生在模块代码执行之前,意味着模块路径中不能出现变量。而对于需要使用变量的场景,目前也有类似的提案,就是import函数,对于通过import导入的模块会以该模块为入口点创建一个新的模块依赖图。


下面具体介绍ES Module的加载和执行过程:


在浏览器中,ES模块的加载过程


定义ES模块的方法在浏览器和Node中有所区别,在浏览器中通过以下方式进行声明:


<script type="module"></script>


而在Node中则使用.mjs文件后缀。


通过模块文件中的import语句确定依赖模块,import语句中的模块路径目前在node和浏览器解析方法有区别,目前浏览器仅支持url格式的模块路径。而且在浏览器中递归分析依赖模块(需要解析模块路径,然后下载文件再提取依赖模块)比较耗时


模块映射建立模块路径和模块记录之间的映射
1、缓存已经加载过的模块,方便不同模块导入同一模块时直接使用缓存过的模块
2、在模块代码执行阶段确保多次被导入的模块代码只执行一次




实例化阶段:根据模块记录初始化模块实例,ES Module静态分析可以在不需要执行代码的情况下建立模块依赖关系图
模块环境记录:跟踪每个导出变量和内存空间的对应关系
在实例化阶段每个模块导出的函数声明都会在这个阶段进行初始化
采用深度优先后序遍历方法,从依赖关系最底层的模块(不依赖其他任何模块)开始,构造一个完整的由所有模块实例组成的依赖关系图


live绑定导出模块可以修改导出的变量值,然后导入该变量的模块获取到的值相应也会更新,导入模块不能直接修改该变量的值,但如果导入的变量是对象类型,导入变量的模块可以修改对象上的属性值。这和CommonJS的机制不同,CommonJS当遇到循环引用时,在导入没有执行完成的模块时,由于该模块已经加载过但是并没有执行完成,其他模块在导入时只能获取在执行导入语句时该模块已经导出的对象,还没有导出的属性值为undefined,而且由于模块的缓存机制并不会再次执行导入的模块。




代码执行阶段:执行每个模块中的代码,会给前面的导入和导出变量指向的内存空间赋值。


需要注意的细节


  1. ES Module模块导入导出语法是静态的,需要在模块的顶层作用域中导入或者导出变量
  2. ES Module都是单例的
  3. 模块导出的变量不仅仅是简单或者引用的值,意味着模块内部修改了这个变量值,外部导入的值会相应发生更新。export default除外。


export {foo as bar} // 只对外界暴露bar变量,foo对外界不可见


export default和export {... as default}区别:前者只是对值进行绑定,导入模块拿到的是导出时变量的值,如果后面值发生变化,导入模块中不会更新。而后者是对变量标识符进行绑定,意味着导出模块修改变量的值,相应的导入模块也会进行更新。


export {ff} from 'bar'
export {ff as foo} from 'bar'
export * from 'bar'
// 以上语法从bar模块中导入,再导出供其他模块使用。ff变量在当前模块中不可用。


任何导入的变量在导入的模块中都是不可修改的,一旦修改导入变量的值,则会报TypeError


import {a as A} from 'a' // 当前模块不能访问变量a

import foo from 'Foo'
import {default as foo} as 'Foo'
// 上面两种语法是等价的。

import * as foo from 'foo'
// 如果某个模块有默认导出,其他模块在使用此语法进行导入时可以通过foo.default访问到默认导出的内容。

import "foo" // 这种语法也是合法的,它会加载、编译并执行foo模块。


bare import


ES6中还支持bare import(这种导入也称为直接导入),也就是import 'path/to/module'语法,这里的path/to/module对应的模块没有导出任何变量,导入的目的是为了执行模块内部的代码(一般会执行一些副作用,比如监听事件等等)。


不同规范的模块相互导入


CommonJS模块导入ESM


require函数返回一个对象,ES Module中导出的变量、函数或者类(非默认导出)会以变量名称作为返回对象上的key的形式导出,默认导出的变量则作为对象上的default属性值进行导出。同时导入的对象还具有__esModule属性,值为true,用以标识导入的模块遵循ES Module规范。举例:


// esm.js
export const name = 'esm'
export default function fn() {}
// cjs.js
const e = require('./esm.js')
e // {name: 'esm', default: [object Function]}
e.__esModule // true


ESM导入CommonJS模块


import语法可以获取模块导出的变量以及默认导出。当使用ES Module的import语法导入CommonJS模块时,如果CommonJS导出的是一个对象,则对象的属性值可以通过import {key1, key2, ...} from 'path/to/cjs'的方式获取;如果是获取模块的默认导出,也就是使用import defaultExport from 'path/to/cjs',这时候defaultExport的值就是CommonJS模块的exports对象。举例:


// cjs.js
module.exports = {name: 'cjs'}
// esm.js
import {name, default as defaultExport} from './cjs'
name // 'cjs'
defaultExport // {name: 'cjs'}