模块化的演变过程
文件划分
最初的文件划分方式比较粗糙,年纪小的开发同学可能没有感受过。请看下面的小例子。
- 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不隶属于致力于ECMAScript的Ecma国际的工作组 TC39,但是TC39的一些成员参与了这个项目。
在2013年5月,Node.js包管理器npm的作者Isaac Z. Schlueter,宣布Node.js已经废弃了CommonJS,Node.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中我们传入了两个参数,分别是module和module.exports。可以看到exports是module.exports的简写。
从如上代码中我们可以分析出如下几点:
- 当
exports.xxx与module.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);
可以看到打印内容如下,与我们预期相符。
模块的加载机制
模块在第一次加载后被缓存。这意味着(类似其他缓存)每次调用 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为什么还会有AMD和CMD呢,此处就涉及到一个加载时机的问题了。NodeJS在运行的时候是基于硬盘的同步加载,加载的很快。浏览器是基于网络的,如果网络请求时间过久,那么浏览器端就会长期处于等待状态,这种情况对用户来说是不友好的。
为了解决这个问题,出现了AMD。
AMD是Asynchronous Module Definition的缩写,也就是"异步模块定义"。require.js是AMD的实现者,下面简单介绍一下使用。
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则会在真正去调用的时候去执行模块内的代码。可以在该链接中去进行比对,我已经准备了测试文件。
使用方法可以参考如下链接:
两者的主要区别如下:(引用链接)
- 定位有差异。RequireJS 想成为浏览器端的模块加载器,同时也想成为 Rhino / Node 等环境的模块加载器。Sea.js 则专注于 Web 浏览器端,同时通过 Node 扩展的方式可以很方便跑在 Node 环境中。
- 遵循的规范不同。RequireJS 遵循 AMD(异步模块定义)规范,Sea.js 遵循 CMD (通用模块定义)规范。规范的不同,导致了两者 API 不同。Sea.js 更贴近 CommonJS Modules/1.1 和 Node Modules 规范。
- 推广理念有差异。RequireJS 在尝试让第三方类库修改自身来支持 RequireJS,目前只有少数社区采纳。Sea.js 不强推,采用自主封装的方式来“海纳百川”,目前已有较成熟的封装策略。
- 对开发调试的支持有差异。Sea.js 非常关注代码的开发调试,有 nocache、debug 等用于调试的插件。RequireJS 无这方面的明显支持。
- 插件机制不同。RequireJS 采取的是在源码中预留接口的形式,插件类型比较单一。Sea.js 采取的是通用事件机制,插件类型更丰富。
ESModule
背景
ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。其模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,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…