JavaScript模块管理机制
在之前的项目开发中一直使用RequireJS
进行模块化管理,在NodeJS
中使用的是CommonJS
规范的模块管理,在Vue-cli
中使用ESM
模块管理。恰好昨天面试题有一问提到他们之间的区别,之前并没有太深入这些知识,回答的不是很好,这里整理一下。
参考:
CommonJS
在NodeJS
中,使用的是CommonJS
模块规范,此外我们还使用npm
来安装和管理模块,避免重复造轮子。
使用方法
CommonJS
的一个模块就是一个脚本文件。require
命令第一次加载该脚本时就会执行整个脚本,然后在内存中生成一个对象。
- 使用
module.exports
导出模块 - 使用
require
引入模块
定义模块
function add(...args) {
var sum = 0;
for (var v of args){
sum += v;
}
console.log(sum);
}
// 导出方式1
module.exports = {
add
};
// 导出方式2
exports.add = add;
引入模块
var math = require("./module1");
math.add(1, 2, 3, 4);
模块查找
NodeJS
中的require
到底是怎么样的查找并加载的呢?参考require()源码解读。
一个require(X)
表达式,可能存在下面几种情况:
- 如果X是内置模块(比如
require("http")
)- 返回该模块
- 不再继续执行
- 如果 X 以
./
或者/
或者../
开头- 根据 X 所在的父模块,确定 X 的绝对路径。
- 将 X 当成文件,依次查找
X
,X.js
,X.json
,X.node
,只要其中有一个存在,就返回该文件,不再继续执行。
- 将 X 当成目录,依次查找
X/package.json
,X/index.js
,X/index.json
,X/index.node
,只要其中有一个存在,就返回该文件,不再继续执行 - 如果 X 不带路径(加载第三方模块比如
require("gulp")
)- 根据 X 所在的父模块,确定 X 可能的安装目录
- 依次在每个目录中,将 X 当成文件名或目录名加载
- 抛出
not found
可以看见加载规则还是比较复杂的,require
会尝试尽可能去成功加载对应的文件,在内部源码中是Module
类实现的,这里暂时就没有深入了解了。
需要注意的是CommonJS中的模块加载和运行都是同步进行的。
module.exports和exports
初始使用NodeJS
中的模块时,难免对于module.exports
和exports
的用法产生疑惑,因此在这里总结一下
- 牢记这一点:整个文件导出的模块是
module.exports
module.exports
和exports
在初始时指向同一个对象的地址,因此可以使用exports
向模块对象上增加属性和方法,module.exports
如果指向了另外一个地址,则exports
的修改全部无效了,因为最后导出的是module.exports
可以理解为exports
是module.exports
的一个快捷方式,
- 如果没有修改
module.exports
的指向,则二者导出效果相同; - 如果修改了
module.exports
的指向,则最后导出的是module.exports
指向的对象引用
module.exports = {x: 100}
module.exports.y = 200
exports.z = 300 // 此处模块导出的是{x:100, y:200, z:300}
module.exports = {a:1} // 当重新为module.exports赋值后,exports导出的数据都会丢失,模块最后导出的只有{a:1}
如果还有疑问,可以移步:
循环依赖
如果两个模块相互引用,就会产生循环依赖的情况,我们来看看CommonJS中是如何处理的。参考:
- node官网提供的例子
- nodejs模块循环引用讲解
下面代码从main.js
开始执行,那么会输出什么结果呢?
// a.js
exports.done = false;
var b = require("./b.js");
console.log("在 a.js 之中,b.done = %j", b.done);
exports.done = true;
console.log("a.js 执行完毕");
// b.js
exports.done = false;
var a = require("./a.js");
console.log("在 b.js 之中,a.done = %j", a.done);
exports.done = true;
console.log("b.js 执行完毕");
// main.js
var a = require("./a.js");
var b = require("./b.js");
console.log("在 main.js 之中, a.done=%j, b.done=%j", a.done, b.done);
然后执行main.js
进行测试,控制台依次输出
在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true
现在来解释一下这个输出
- 在main.js中首先加载a.js,然后直接执行a.js(CommonJS模块的加载时运行特性)
- a.js首先导出了
exports.done=false
,然后加载了b.js,此时会立即执行b.js(同上),等待b.js执行完成并将控制流程转交给a.js - b.js首先导出了
exports.done = false
,然后加载了a.js,此时产生了循环依赖,由于a.js已经执行了一部分,这个时候并不是去重复执行a.js,而是读取a.js模块对象的exports属性,由于此时a.js并没有执行完,因此这个时候访问到的done
属性仍为false - 接着继续执行b.js,导出
exports.done=true
,当b.js结束后,控制权交还给a.js - a.js继续执行,导出
exports.done=true
,然后将控制权转交给main.js - main.js继续执行,需要加载b.js,由于b.js已经执行,此时并不会再次执行b.js,而是直接读取b.js模块的exports对象
- 由于a.js和b.js模块均已执行,此时输出
a.done
和b.done
均为true
上面代码输出也可以得出结论
- 在b.js之中,a.js没有执行完毕,只执行了第一行。
- main.js执行到第二行时,不会再次执行b.js,而是输出缓存的b.js的执行结果,即它的第四行
CommonJS 模块的重要特性是加载时执行,即脚本代码在require的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。
由于 CommonJS 模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,两者可能会有差异。基于这个机制,在引入模块时,必须非常小心,注意下面的实例代码
var a = require('a'); // 安全的写法
var foo = require('a').foo; // 危险的写法
exports.good = function (arg) {
return a.foo('good', arg); // 使用的是 a.foo 的最新值
};
exports.bad = function (arg) {
return foo('bad', arg); // 使用的是一个部分加载时的值,此时foo可能并不是最终导出的值
};
AMD
参考: AMD规范
需要注意的是CommonJS
是同步加载的,也就是说加载模块时会阻塞后续代码的执行,对于服务器端来讲问题不大,因为服务端的模块文件都保存在本地磁盘上;但是对于浏览器而言网络传输的效率是不容忽视的,所以才有了AMD
浏览器模块规范。
为了提高页面性能,一般的处理办法是异步延迟加载脚本,这正是AMD
全称中Asynchronous的含义,在AMD
中,模块将被异步加载,模块加载不影响后面语句的运行。所有依赖某些模块的语句均放置在回调函数中。
使用方法
RequireJS
是AMD的一个实现,在RequireJS
中:使用define()
导出模块,使用require()
引入模块
// 导出模块 lib1.js
define([], function() {
return {
test: function() {
console.log("this is test in lib1.js");
},
test2: function() {
console.log("this is test 2 in lib1.js");
}
}
});
// 引入模块 index.js
require(["lib1"], function (lib1) {
lib1.test();
});
可以看见,lib1
模块的全部方法都会被require
引入并作为模块的方法进行调用。
异步加载模块带来的问题是:在浏览器中,必须等待依赖的模块加载成功,对应的声明模块才能够执行。换句话说,AMD
中的模块是__依赖前置__的。
模块查找
之前写过RequireJS使用心得,这里简单回顾一下,关于具体的使用方式就不展开了。
- 基础路径
baseUrl
- 相对路径
- 没有配置
data-main
,则baseurl
为引入require.js
的html文档所在路径 - 已配置
data-main
,则baseurl
为data-main
指向模块所处的路径 - 显示调用
require.config()
进行配置
- 没有配置
- 绝对路径
- 相对路径
- 模块路径
paths
- 具体文件名
- 文件夹
- 模块ID
module ID
- 常规,不带
.js
- 常规,不带
循环依赖
在requireJS中,如果a、b模块产生了循环依赖,那么在这种情况下当b的模块函数被调用时,将会提示模块a undefined。解决方法是b可以在模块已经定义好后用require()方法再获取,需要把require作为依赖注入进来。
即本来的写法是:
// b.js
define(['a'],function (a) {
return {
test: function () {
console.log(a.done) // a is undefined
}
}
});
requireJS推荐依赖前置,一般说来无需使用require()去获取一个模块,而是应当使用注入到模块函数参数中的依赖。
现在为了解决循环依赖带来的问题,首先要引入require的依赖,使用require()方法去获取模块a。
// b.js
define(['require','a'],function (require, a) {
return {
loading: function () {
var a = require('a')
console.log(a.done)
}
}
});
CMD
使用方法
// 定义模块 a.js
define(function(require, exports, module) {
// 正确写法
module.exports = {
foo: 'bar',
doSomething: function() {}
};
});
// 获取模块 a 的接口
var a = require('./a');
// 调用模块 a 的方法
a.doSomething();
CMD与AMD的区别
参考:
总结一下,主要差异为
从模块运行顺序来说,AMD 是提前执行,CMD 是延迟执行
从代码风格来说,CMD 推崇依赖就近,AMD 推崇依赖前置
ESM模块
使用方法
在ES6
中新增import
和export
,用于处理ES6模块
import,用于引入模块
- 导入整个模块
import * as myModule from "my-module"
- 按需导入模块的单个或多个API如
mport {myMember} from "my-module
- 导入整个模块
export,用于导出模块,export包括
命名导出,命名导出对导出多个值很有用。在导入期间,必须使用相应对象的相同名称。
默认导出,可以使用任何名称导入默认导出,每个脚本只能有一个默认导出
注意不能使用var,let或const作为默认导出。
ES6与CommonJS
和AMD
最大的区别在于引入模块的方式,既可以引入整个模块,也可以引入某块的某一部分!
运行机制
ES6模块中的值属于动态只读引用。
- 只读,即不允许修改引入变量的值,import的变量是只读的,不论是基本数据类型还是复杂数据类型。
- 引用,模块导出的就不再是生成输出对象的拷贝,而是动态关联模块中的引用,当模块中的值改变,会影响当前文件中的值,不论是基本数据类型还是复杂数据类型。
因此,当发生循环加载时,ES6模块是动态引用。只要两个模块之间存在某个引用,代码就能够执行。
ES6模块和CommonJS的区别
主要有下面两个区别
- ES6 模块输出的是值的引用,输出接口动态绑定,而 CommonJS 输出的是值的浅拷贝
- ES6 模块编译时执行,而 CommonJS 模块总是在运行时加载
关于第一点
在 CommonJS 模块中,如果你 require 了一个模块,那就相当于你执行了该文件的代码并最终获取到模块输出的 module.exports
对象的一份拷贝,如果在模块文件中存在异步的数据,则需要使用函数延时执行,需要再次require对应模块,从而获得更新数据的拷贝
- CommonJS 模块中 require 引入模块的位置不同会对输出结果产生影响,并且会生成值的拷贝
- CommonJS 模块重复引入的模块并不会重复执行,再次获取模块只会获得之前获取到的模块的拷贝
在 ES6 模块中就不再是生成输出对象的拷贝,而是动态关联模块中的引用。当模块中的值改变,会影响当前文件中的值,这在修改基础变量时可以明显体现出来,以下面的代码为例
/* CommonJS */
// counter.js
var count = 1
module.exports = count
// index.js
let count = require('./test2')
count += 1 // 值的拷贝,修改现在的count不会影响模块中的count值
console.log(count) // 2
/* ES6 */
// counter.js
let counter = 1;
export function add() {count += 1}
export default counter;
// index.js
import count, {add} from './counter'
add() //
console.log(count) // 变量的引用,因此可以直接获取修改后的结果2
count += 1 // error,无法直接对导出的模块进行修改
console.log(count)
关于第二点
ES6 模块编译时执行会导致有以下两个特点:
- import 命令会被 JavaScript 引擎静态分析,优先于模块内的其他内容执行,即在文件中的任何位置引入 import 模块都会被提前到文件顶部
- export 命令会有变量声明提前的效果,通过模块循环引用可以看出其效果
参考
模块打包原理
本文主要整理了JavaScript中几种模块规范,那么就不得不再了解一下前端工程化中模块打包的原理。
在requireJS中,模块仍旧是以script标签的形式进行管理,这样在引用模块时,不可避免地需要发送多个HTTP请求加载文件。requireJS提供了一个r.js
的工具,用于合并模块。
打包流程
事实上目前更流行的是如webpack
之类的工具,接下来以webpack
为例,展示模块打包的过程和原理。从启动webpack构建到输出结果经历了一系列过程:
- 解析webpack配置参数,合并从shell传入和webpack.config.js文件里配置的参数,生产最后的配置结果。
- 从配置的entry入口文件开始解析文件构建AST语法树,找出每个文件所依赖的文件,递归下去。
- 在解析文件递归的过程中根据文件类型和loader配置找出合适的loader用来对文件进行转换。
- 递归完后得到每个文件的最终结果,根据entry配置生成代码块chunk。
- 输出所有chunk到文件系统。
最终,全部模块被打包成单个文件,为了处理各个模块之间的依赖,webpack在输出chunk时,内置了一个模块管理模具,并实现了module.exports
、require()
等API。
tree-shaking
ES6 module 有以下特点:
- 只能作为模块顶层的语句出现
- import 的模块名只能是字符串常量
- import binding 是
immutable
的
ES6模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,基于这个特点,可以实现Tree-shaking
。Tree-shaking
本质是消除无用的js代码,在静态分析时,通过分析程序流,判断哪些变量未被使用、引用,进而删除未被使用的代码。
但事实上,实现tree-shaking
并不是想象中的那么美满,详情可以参考:你的Tree-Shaking并没什么卵用,文中介绍了为什么在实际打包过程中,tree-shaking
的作用是有限的:babel和uglify等代码将代码编译为”有副作用的“
- 可以通过一些配置方法尽力保证编译的代码无副作用,尽管我们自己的项目代码很少有与业务无关的...
- 第三方库文件往往内置了打包好的文件,除非我们手动再次配置编译,否则无法真正
tree-shaking
这些库文件,这也是为什么一些组件库按需加载还需要单独的loader来实现
小结
我对于模块的理解就是:按功能将独立的逻辑,封装成可重用的代码块,然后对外提供模块的调用接口。归根结底,是为了更加规范更加方便的写代码和维护代码,实现Don't repeat yourself
~
在学习过程中实现了简易的AMD和CMD模块系统,代码放在github上了。
本文整理了JavaScript中几种模块规范,了解各自对应的使用方法和一些特性,以及各个模块对应的差异,然后整理了如循环依赖、模块打包、tree-shaking等特性。
只有了解模块的机制,才能真正做到DRY
。在此之后,就可以去了解促进前端飞速发展的另外一个工具:npm
模块管理工具了。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。