JS模块化方案详解(CommonJS、AMD、CMD、ESModule)

1,193 阅读12分钟

模块化的演变过程

文件划分

最初的文件划分方式比较粗糙,年纪小的开发同学可能没有感受过。请看下面的小例子。

  • index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Modular evolution stage 1</title>
</head>
<body>
  <script src="module-a.js"></script>
  <script src="module-b.js"></script>
  <script>
    // 命名冲突
    method1()
    // 模块成员可以被修改
    name = 'foo'
  </script>
</body>
</html>
  • module-a.js
// module a 相关状态数据和功能函数

var name = 'module-a'

function method1 () {
  console.log(name + '#method1')
}

function method2 () {
  console.log(name + '#method2')
}
  • module-b.js
// module b 相关状态数据和功能函数

var name = 'module-b'

function method1 () {
  console.log(name + '#method1')
}

function method2 () {
  console.log(name + '#method2')
}

做法就是将每个功能及其相关状态数据各自单独放到不同的文件中,约定每个文件就是一个独立的模块,使用某个模块就是将这个模块引入到页面中,然后直接调用模块中的成员(变量 / 函数)

缺点如下:

  • 所有模块都直接在全局工作,没有私有空间,所有成员都可以在模块外部被访问或者修改

  • 模块一旦多了过后,容易产生命名冲突,例子中name变量就会被覆盖,遇到问题很难定位原因

  • 另外无法管理模块与模块之间的依赖关系,需要人为去按顺序引入

命名空间方式

看下面例子

  • index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Modular evolution stage 2</title>
</head>
<body>
  <script src="module-a.js"></script>
  <script src="module-b.js"></script>
  <script>
    moduleA.method1()
    moduleB.method1()
    // 模块成员可以被修改
    moduleA.name = 'foo'
  </script>
</body>
</html>
  • module-a.js
// module a 相关状态数据和功能函数
var moduleA = {
  name: 'module-a',

  method1: function () {
    console.log(this.name + '#method1')
  },

  method2: function () {
    console.log(this.name + '#method2')
  }
}
  • module-b.js
// module b 相关状态数据和功能函数

var moduleB = {
  name: 'module-b',

  method1: function () {
    console.log(this.name + '#method1')
  },

  method2: function () {
    console.log(this.name + '#method2')
  }
}

每个模块只暴露一个全局对象,所有模块成员都挂载到这个对象中,具体做法就是在第一阶段的基础上,通过将每个模块「包裹」为一个全局对象的形式实现,有点类似于为模块内的成员添加了「命名空间」的感觉。通过「命名空间」减小了命名冲突的可能,但是同样没有私有空间,所有模块成员也可以在模块外部被访问或者修改,而且也无法管理模块之间的依赖关系。

IIFE(Immediately-Invoked Function Expression)

看如下例子

  • index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Modular evolution stage 3</title>
</head>
<body>
  <h1>模块化演变(第三阶段)</h1>
  <h2>使用立即执行函数表达式(IIFE:Immediately-Invoked Function Expression)为模块提供私有空间</h2>
  <script src="module-a.js"></script>
  <script src="module-b.js"></script>
  <script>
    moduleA.method1()
    moduleB.method1()
    // 模块私有成员无法访问
    console.log(moduleA.name) // => undefined
  </script>
</body>
</html>
  • module-a.js
// module a 相关状态数据和功能函数

;(function () {
  var name = 'module-a'
  
  function method1 () {
    console.log(name + '#method1')
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleA = {
    method1: method1,
    method2: method2
  }
})()

  • module-b.js
// module b 相关状态数据和功能函数

;(function () {
  var name = 'module-b'
  
  function method1 () {
    console.log(name + '#method1')
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleB = {
    method1: method1,
    method2: method2
  }
})()

具体做法就是将每个模块成员都放在一个函数提供的私有作用域中,对于需要暴露给外部的成员,通过挂在到全局对象上的方式实现,有了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问。

IIFE 添加依赖

代码如下

  • index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Modular evolution stage 4</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>

  • module-a.js
// module a 相关状态数据和功能函数

;(function ($) {
  var name = 'module-a'
  
  function method1 () {
    console.log(name + '#method1')
    $('body').animate({ margin: '200px' })
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleA = {
    method1: method1,
    method2: method2
  }
})(jQuery)

  • module-b.js
// module b 相关状态数据和功能函数

;(function () {
  var name = 'module-b'
  
  function method1 () {
    console.log(name + '#method1')
  }
  
  function method2 () {
    console.log(name + '#method2')
  }

  window.moduleB = {
    method1: method1,
    method2: method2
  }
})()

与上一个阶段相比,这个阶段很明显的优势就是依赖关系可以很直观的看出来。

模块化解决方案

CommonJS

背景

CommonJS是一个项目,其目标是为JavaScript在网页浏览器之外创建模块约定。创建这个项目的主要原因是当时缺乏普遍可接受形式的JavaScript脚本模块单元,模块在与运行JavaScript脚本的常规网页浏览器所提供的不同的环境下可以重复使用。

这个项目由Mozilla工程师Kevin Dangoor于2009年1月发起,最初名为ServerJS。在2009年8月,这个项目被改名为CommonJS来展示其API的广泛的应用性。有关规定在一个开放进程中被创建和认可,一个规定只有在已经被多个实现完成之后才被认为是最终的。 CommonJS不隶属于致力于ECMAScriptEcma国际的工作组 TC39,但是TC39的一些成员参与了这个项目。

在2013年5月,Node.js包管理器npm的作者Isaac Z. Schlueter,宣布Node.js已经废弃了CommonJSNode.js核心开发者应避免使用它。

虽然大佬们已经决定弃用CommonJS,但是目前工作中还是有大量的CommonJS代码,下面我们来介绍一下CommonJS的使用。

使用

模块的导出

CommonJS中提供了两种导出方式

  • module.exports
  • exports

看一下如下的_require函数,为了便于理解,此处我也同样引入了包导入的概念。

function _require(/* ... */) {
  const module = { exports: {} };
  ((module, exports) => {
    // 模块代码在这里。 在本例中,定义一个函数。
    function someFunc() {}
    // exports = someFunc;
    // 此时,exports 不再是 module.exports 的快捷方式,这个模块仍然会导出一个空的默认对象。
    // exports.a = 1;
    exports = {};
    module.exports = someFunc;
    // 此时,模块现在将导出 someFunc,而不是默认对象。
  })(module, module.exports);
  return module.exports;
}

IIFE中我们传入了两个参数,分别是modulemodule.exports。可以看到exportsmodule.exports的简写。

从如上代码中我们可以分析出如下几点:

  • exports.xxxmodule.export = xxx同时存在时,exports.xxx无效
# m.js
exports.name = 'm';
module.exports = 'module';

# test.js
const m = require('./m.js')
console.log(m); // module
  • 当有多个exports.xxx存在时,他们的值同时赋值到一个变量上。
# m.js
exports.name = 'marvin'
exports.age = 18

# test.js
const m = require('./m.js')
console.log(m) // {age: 'marvin, age: 18}
  • 直接改变export的指向,不会对导出内容产生影响,先导出后改变指向除外。
# m.js
exports.name = 'marvin';
exports = {age: 18}

# test.js
const m = require('./m.js')
console.log(m) // {name: 'marvin}

模块的导入

CommonJS中,导入模块需要使用require关键字。

此处细节比较多,官方有提供一段伪代码,需要的同学可以去查看。

简单描述一下常用的查找顺序

  • 如果是核心模块,如fs等,则直接加载并返回该模块
  • 如果以/开头,则去文件系统的根路径下去查找模块
  • 如果是以./或者../这种相对路径的,则会以相对路径去查找模块
    • 先去判断路径是否是一个目录,判断目录中是否存在pacakge.json,若存在并且存在main节点,则想在相应js文件,否则加载将文件名设置为index走下一步
    • 查找确切的文件名,如果找不到确切的文件名,Node.js 将尝试加载所需的文件名,并添加扩展名:.js.json,最后是 .node
    • .js 文件被解释为 JavaScript 文本文件,而 .json 文件被解析为 JSON 文本文件。.node 文件被解释为加载了 process.dlopen() 的编译插件模块。
  • node_modules目录中去查找模块
    • 此处要注意一下,不是只会在本目录中查找,我们可以在模块中打印module.paths,即可以看到查找顺序,如下是我测试项目的查找顺序。
    '/Users/marvin/Documents/Git/test-modules/node_modules',
    '/Users/marvin/Documents/Git/node_modules',
    '/Users/marvin/Documents/node_modules',
    '/Users/marvin/node_modules',
    '/Users/node_modules',
    '/node_modules'
    
    • node_modules中的查找顺序与上一步相同

下面代码测试了一下模块内部的查找顺序,因为.node没有用过,此处就暂不进行测试。

主要测试文件如下,同名文件内容完全相同 index.js

module.exports = {name:'js'}

index.json

{"name":"json"}

main.js

module.exports = {name:'main'}

package.json

{
  "name": "main",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

目录结构如下

.
├── index.js
└── node_modules
    ├── js
    │   ├── index.js
    │   └── index.json
    ├── json
    │   └── index.json
    └── main
        ├── index.js
        ├── index.json
        ├── main.js
        └── package.json

测试文件内容如下:

const js = require("js");
const json = require("json");
const main = require("main");

console.log("js", js);
console.log("json", json);
console.log("main", main);

可以看到打印内容如下,与我们预期相符。

image.png

模块的加载机制

模块在第一次加载后被缓存。这意味着(类似其他缓存)每次调用 require('foo') 都会返回完全相同的对象(如果解析为相同的文件)。

# cache.js
const cache = {
  name: "cache",
  arr: [],
  age: 18,
};

module.exports = cache;

# test.js
console.log("cache1", cache);
cache.name = "othercache";
cache.age = 28;
cache.arr.push(1);
console.log("cache2", cache);
const cache3 = require("./cache");
console.log("cache3", cache3);

// cache1 { name: 'cache', arr: [], age: 18 }
// cache2 { name: 'othercache', arr: [ 1 ], age: 28 }
// cache3 { name: 'othercache', arr: [ 1 ], age: 28 }

上面导出的是一个对象,我们可以简单的认为在导出的时候,module为我们在堆内存中创建了一个地址0x0001,每次require(./cache)的时候都会去堆内存中取出该地址中对应的数据。

这个地方有一个小坑,就是如果在require的同时去结构module,那么改变基本类型就不会影响到cache模块中的值,可以看一下如下的测试代码。

let { age, name, arr } = require("./cache");
console.log(name, age, arr);
age = 28;
console.log(age);
arr.push(1);
const cache3 = require("./cache");
console.log("cache3", cache3);

// cache 18 []
// 28
// cache3 { name: 'cache', arr: [ 1 ], age: 18 }

有可能会用到的小技巧

// 通过测试 require.main === module 来确定文件是被直接运行。
console.log(require.main === module);
// 通过查看 require.main.filename 就可以得到当前应用的入口点。
console.log(require.main.filename);
// __dirname 当前模块的目录名
// __filename 当前模块的文件名

AMD

背景

大家一定会好奇,既然有了CommonJS为什么还会有AMDCMD呢,此处就涉及到一个加载时机的问题了。NodeJS在运行的时候是基于硬盘的同步加载,加载的很快。浏览器是基于网络的,如果网络请求时间过久,那么浏览器端就会长期处于等待状态,这种情况对用户来说是不友好的。

为了解决这个问题,出现了AMD

AMDAsynchronous Module Definition的缩写,也就是"异步模块定义"。require.jsAMD的实现者,下面简单介绍一下使用。

require.js

以官方的demo为例。

├── app
│   ├── main.js
│   └── messages.js
├── app.js
├── index.html
└── lib
    ├── print.js
    └── require.js
  • index.html 可以看到代码中除了指定require.js路径外,还指定了一个入口文件即data-main=app,此配置会加载并执行根目录下的app.js文件
<!DOCTYPE html>
<html>
    <head>
        <script data-main="app" src="lib/require.js"></script>
    </head>
    <body>
        <h1>Hello World</h1>
    </body>
</html>
  • app.js文件,此处先对路径进行注册,baseUrl是默认查找的路径,一般来讲像jQuery等三方库会存放到这个目录中,paths里面配置了其他的路径映射。requirejs(['app/main'])会去请求并执行app路径下的main.js文件。
// For any third party dependencies, like jQuery, place them in the lib folder.

// Configure loading modules from the lib directory,
// except for 'app' ones, which are in a sibling
// directory.
requirejs.config({
    baseUrl: 'lib',
    paths: {
        app: '../app'
    }
});

// Start loading the main app file. Put all of
// your application logic in there.
requirejs(['app/main']);

  • main.js文件, 此文件中定义了要执行的代码,会先加载同级目录下的message.js文件,然后去加载lib目录下的print文件。等两个文件的内容全部加载成功之后会执行下面的print(messages.getHello());
define(function (require) {
  // Load any app-specific modules
  // with a relative require call,
  // like:
  var messages = require("./messages");
  console.log("message", message);

  // Load library/vendor modules using
  // full IDs, like:
  var print = require("print");

  print(messages.getHello());
});
  • message.js
define(function () {
    return {
        getHello: function () {
            return 'Hello World';
        }
    };
});

  • print.js
define(function () {
    return function print(msg) {
        console.log(msg);
    };
});

CMD

背景

CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。

AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。这个地方重点说一下这部分,一直以为两者的区别是是否会动态的去请求资源,后来实际测试才发现,在整个代码块中,只要有require就会去请求网络资源,区别在于require.js在网络请求执行成功后就会去执行js代码,而sea.js则会在真正去调用的时候去执行模块内的代码。可以在该链接中去进行比对,我已经准备了测试文件。

使用方法可以参考如下链接:

github.com/seajs/seajs…

两者的主要区别如下:(引用链接

  1. 定位有差异。RequireJS 想成为浏览器端的模块加载器,同时也想成为 Rhino / Node 等环境的模块加载器。Sea.js 则专注于 Web 浏览器端,同时通过 Node 扩展的方式可以很方便跑在 Node 环境中。
  2. 遵循的规范不同。RequireJS 遵循 AMD(异步模块定义)规范,Sea.js 遵循 CMD (通用模块定义)规范。规范的不同,导致了两者 API 不同。Sea.js 更贴近 CommonJS Modules/1.1 和 Node Modules 规范。
  3. 推广理念有差异。RequireJS 在尝试让第三方类库修改自身来支持 RequireJS,目前只有少数社区采纳。Sea.js 不强推,采用自主封装的方式来“海纳百川”,目前已有较成熟的封装策略。
  4. 对开发调试的支持有差异。Sea.js 非常关注代码的开发调试,有 nocache、debug 等用于调试的插件。RequireJS 无这方面的明显支持。
  5. 插件机制不同。RequireJS 采取的是在源码中预留接口的形式,插件类型比较单一。Sea.js 采取的是通用事件机制,插件类型更丰富。

ESModule

背景

ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:exportimportexport命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

使用

导出

export var name = 'foo module'

export function hello () {
  console.log('hello')
}

export class Person {}

// 等价于如下代码

var name = 'foo module'

function hello () {
  console.log('hello')
}

class Person {}

export { name, hello, Person }
// 默认导出方式

export default 'default value'

// 等价于
var name = 'default value'
export default name;

导入

import defaultVal, {name, hello, Person}  from './foo.js'
// 等价于
import {default as defaultVal, name, hello, Person} from './foo.js'

此处要注意大括号中的内容不是对导出对象的解构,只是一种语法规则而已。

上面已经简单介绍了 commonjs导出的是模块值的拷贝,简单理解下来就是引用地址的拷贝。

esmodule中导出的内容是变量的引用,看一下下面的列子。

// foo.js
let a = 1;
let b = {bar: '3'}
let defaultVal = {defaultVal: 'defaultVal'}
setTimeout(() => {
  a = 2;
  b = {foo: '4'}
  defaultVal = {defaultVal: 'defaultVal-change'}
}, 1000);

export {a,b}
export default defaultVal;
// main.js
import defaultVal, {default as defaultVal2, a, b} from './foo.js';

console.log('defaultVal', defaultVal); // defaultVal {defaultVal: "defaultVal"}
console.log('defaultVal2', defaultVal2); // defaultVal2 {defaultVal: "defaultVal"}
console.log('a', a); // a 1
console.log('b', b); // b {bar: "3"}

setTimeout(() => {
  console.log('defaultVal-change', defaultVal); // defaultVal-change {defaultVal: "defaultVal"}
	console.log('defaultVal2-change', defaultVal2); // defaultVal2-change {defaultVal: "defaultVal"}
	console.log('a-change', a); // a-change 2
	console.log('b-change', b); // b-change {foo: "4"}
}, 2000);

根据以上测试可以得出结论, 通过export导出的是变量的指向,当指向改变时,值也会同时变化,但是export default导出的内容是不会变化的。

本文所用代码地址:github.com/huomarvin/t…