本文已参与「新人创作礼」活动,一起开启掘金创作之路。
JavaScript模块化
发展
个人把JavaScript模块化的发展分为了三个时期。
前期
最初的JavaScript仅仅用于前端页面的交互,所以其实并没有模块化的概念,我们可以直接通过在html中定义script标签并设置src来引入其他JavsScript文件。
<script src="a.js"></script>
<script src="b.js"></script>
这样引入script标签存在一个十分大的弊端,就是污染全局作用域,例如我在a.js和b.js都使用let定义了一个相同名字的变量,那么就会报错。
中期
IIFE 也就是立即调用的函数表达式 ,早期 IIFE 的出现是为了弥补 JS只有全局作用域(global scope)、函数作用域(function scope)而没有块级作用域的缺陷。
我们知道ES6是引入了块级作用域的概念,但是ES5没有。
早期为了实现作用域的隔离呢,只有 function 才能实现作用域隔离,因此如果要将一段代码中的变量、函数等的定义隔离出来,只能将这段代码封装到一个函数中。
后期(成熟)
由于Nodejs的出现,JavaScript也可以在后端大展拳脚了,所以必须有一个标准的模块化方案来支持JavaScript后端模块的开发,CommonJS变出现了,随后AMD、UMD和ES6 Moudle陆续出现,解决了JavaScript前后端应用模块化的问题。
CommonJS
是 node.js 制定,可运行服务端,规范是每一个文件都是一个模块,有单独的作用域、变量和方法等,并且对其他文件是不可见的,这也是独立性的体现。
特征
- 通过module和exports对外暴露需要导出的内容,通过require导入其他模块暴露的内容
- 文件可以被重复引用、加载。第一次加载时会被缓存,之后再引用就直接读取缓存
- 加载某个模块时,module.exports 输出的是值的拷贝,一旦这个值被输出,模块内再发生变化不会影响已经输出的值。
module对象
Node内部提供一个Module构建函数。所有模块都是Module的实例。
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
// ...
}
常用的属性含义:
module.id 模块的识别符,通常是带有绝对路径的模块文件名。
module.filename 模块的文件名,带有绝对路径。
module.loaded 返回一个布尔值,表示模块是否已经完成加载。
module.parent 返回一个对象,表示调用该模块的模块。
module.children 返回一个数组,表示该模块要用到的其他模块。
module.exports 表示模块对外输出的值。
示例:
a.js
let a = 1;
function sayHi() {
console.log("Hello World!");
}
module.exports = {
a,
sayHi,
};
main.js
let moduleA = require("./a.js");
console.log(moduleA);//{ a: 1, sayHi: [Function: sayHi] }
exports变量
为了方便,Node为每个模块提供一个exports变量,指向module.exports。
exports.area = function (r) {
return Math.PI * r * r;
};
exports.circumference = function (r) {
return 2 * Math.PI * r;
};
注意,不能直接将exports变量指向一个值,因为这样等于切断了exports与module.exports的联系,因为最后导出的仍然还是module.exports。
如果你觉得,exports与module.exports之间的区别很难分清,一个简单的处理方法,就是放弃使用exports,只使用module.exports。
require(XXX)加载规则
(1)如果参数字符串以“/”开头,则表示加载的是一个位于绝对路径的模块文件。比如,require('/home/marco/foo.js')将加载/home/marco/foo.js。
(2)如果参数字符串以“./”开头,则表示加载的是一个位于相对路径(跟当前执行脚本的位置相比)的模块文件。比如,require('./circle')将加载当前脚本同一目录的circle.js。
(3)如果参数字符串不以“./“或”/“开头,则表示加载的是一个默认提供的核心模块(位于Node的系统安装目录中),或者一个位于各级node_modules目录的已安装模块(全局安装或局部安装)。
举例来说,脚本/home/user/projects/foo.js执行了require('bar.js')命令,Node会依次搜索以下文件。
/usr/local/lib/node/bar.js
/home/user/projects/node_modules/bar.js
/home/user/node_modules/bar.js
/home/node_modules/bar.js
/node_modules/bar.js
这样设计的目的是,使得不同的模块可以将所依赖的模块本地化。
(4)如果参数字符串不以“./“或”/“开头,而且是一个路径,比如require('example-module/path/to/file'),则将先找到example-module的位置,然后再以它为参数,找到后续路径。
(5)如果指定的模块文件没有发现,Node会尝试为文件名添加.js、.json、.node后,再去搜索。.js件会以文本格式的JavaScript脚本文件解析,.json文件会以JSON格式的文本文件解析,.node文件会以编译后的二进制文件解析。
(6)如果想得到require命令加载的确切文件名,使用require.resolve()方法。
CommonJS缺点
- 它的模块加载器由 Node.js 提供,依赖了 Node.js 本身的功能实现。如果 CommonJS 模块直接放到浏览器中无法执行。
- CommonJS 约定以同步的方式进行模块加载,运行时加载,这种加载机制放到浏览器端,会带来明显的性能问题。它会产生大量同步的模块请求,浏览器要等待响应返回后才能继续解析模块。即,模块请求会造成浏览器 JS 解析过程的阻塞,导致页面加载速度缓慢。
AMD
CommonJS的主要问题是运行时同步加载,这使得它并不适合浏览器端的模块加载。
AMD全称为Asynchronous Module Definition,即异步模块定义规范,是开源社区早期为浏览器提供的,它采用同时并发加载所依赖的模块,当所有依赖模块都加载完成之后,再执行当前模块的回调函数。这种加载方式和浏览器环境的性能需求刚好吻合。
由于没有得到浏览器的原生支持,AMD 规范仍然需要由第三方的 loader 来实现。不过 AMD 规范使用起来稍显复杂,代码阅读和书写都比较困难。因此,现在AMD用的并不多,感兴趣的可以自行查询资料。
UMD
UMD 的全称是Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是CommonJS、AMD,还是非模块化的环境(当时 ES6 Module 还未被提出)。
UMD先判断是否支持Node.js的模块(exports)是否存在,存在则使用Node.js模块模式。在判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块。
ES6 Moudle
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。
CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
// CommonJS模块
let { stat, exists, readfile } = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;
上面代码的实质是整体加载fs模块(即加载fs的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。
ES6 模块不是对象,而是通过export命令显式指定输出的代码,再通过import命令输入。
// ES6模块
import { stat, exists, readFile } from 'fs';
上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。
所以用一句话描述CommonJS和ES6 Moudle的区别就是:CommonJs是运行时加载模块,ES6 Moudle是编译时加载模块。
由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
export命令
// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;
等价于
// profile.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
export { firstName, lastName, year };
export命令除了输出变量,还可以输出函数或类(class)。
export function multiply(x, y) {
return x * y;
};
export class Person {
//……
}
通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
function v1() { ... }
function v2() { ... }
export {
v1 as streamV1,
v2 as streamV2
};
需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
// 报错
export 1;
// 报错
var m = 1;
export m;
// 报错
function f() {}
export f;
正确写法
// 写法一
export var m = 1;
// 写法二
var m = 1;
export {m};
// 写法三
var n = 1;
export {n as m};
// 正确
export function f() {};
// 正确
function f() {}
export {f};
另外,export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值,这与CommonJS值是值拷贝的特点正好相反。
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);
上面代码输出变量foo,值为bar,500 毫秒之后变成baz。
最后,export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,import`命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。
//错误写法
function foo() {
export default 'bar' // SyntaxError
}
foo()
import命令
使用export命令定义了模块的对外接口以后,其他 JS 文件就可以通过import命令加载这个模块。
// main.js
import { firstName, lastName, year } from './profile.js';
function setName(element) {
element.textContent = firstName + ' ' + lastName;
}
上面代码的import命令,用于加载profile.js文件,并从中输入变量。import命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。
如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
import { lastName as surname } from './profile.js';
import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。
import {a} from './xxx.js'
a = {}; // Syntax Error : 'a' is read-only;
上面代码中,脚本加载了变量a,对其重新赋值就会报错,因为a是一个只读的接口。但是,如果a是一个对象,改写a的属性是允许的。
import {a} from './xxx.js'
a.foo = 'hello'; // 合法操作
import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径。如果不带有路径,只是一个模块名,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。若只给出vue,axios这样的包名,则会自动到node_modules中加载。
整体加载
除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。
import * as circle from './circle';
console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));
注意,模块整体加载所在的那个对象(上例是circle),应该是可以静态分析的,所以不允许运行时改变。
exprot default
从前面的例子可以看出,使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意阅读文档,去了解模块有哪些属性和方法。
为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。
例如我们在使用 axios时,可以直接自定义名称,并且不需要使用花括号:
import axios from "axios";
axios.get('/users')
.then(res => {
console.log(res.data);
});
如果我们不想使用axios来命令,我们想使用http:
import http from "axios";
http.get('/users')
.then(res => {
console.log(res.data);
});|
这使得我们使用第三方模块时,更加方便,只需要了解模块的功能,而不需要关系模块的具体导出。