JavaScript模块管理机制

在之前的项目开发中一直使用RequireJS进行模块化管理,在NodeJS中使用的是CommonJS规范的模块管理,在Vue-cli中使用ES6内置的模块管理。恰好昨天面试题有一问提到他们之间的区别,之前并没有太深入这些知识,回答的不是很好,这里整理一下。

<!--more-->

参考:

1. CommonJS

NodeJS中,使用的是CommonJS模块规范,此外我们还使用npm来安装和管理模块,避免重复造轮子。

1.1. 使用方法

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);

1.2. 模块查找

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中的模块加载和运行都是同步进行的。

1.3. module.exports和exports

初始使用NodeJS中的模块时,难免对于module.exportsexports的用法产生疑惑,因此在这里总结一下

  • 牢记这一点:整个文件导出的模块是 module.exports
  • module.exportsexports在初始时指向同一个对象的地址,因此可以使用exports向模块对象上增加属性和方法,
  • module.exports如果指向了另外一个地址,则exports的修改全部无效了,因为最后导出的是module.exports

可以理解为exportsmodule.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}

如果还有疑问,可以移步:

1.4. 循环依赖

如果两个模块相互引用,就会产生循环依赖的情况,我们来看看CommonJS中是如何处理的。参考:

下面代码从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.doneb.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可能并不是最终导出的值
};

2. AMD

参考: AMD规范

需要注意的是CommonJS是同步加载的,也就是说加载模块时会阻塞后续代码的执行,对于服务器端来讲问题不大,因为服务端的模块文件都保存在本地磁盘上;但是对于浏览器而言网络传输的效率是不容忽视的,所以才有了AMD浏览器模块规范。

为了提高页面性能,一般的处理办法是异步延迟加载脚本,这正是AMD全称中Asynchronous的含义,在AMD中,模块将被异步加载,模块加载不影响后面语句的运行。所有依赖某些模块的语句均放置在回调函数中。

2.1. 使用方法

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中的模块是依赖前置的。

2.2. 模块查找

之前写过RequireJS使用心得,这里简单回顾一下,关于具体的使用方式就不展开了。

  • 基础路径baseUrl
    • 相对路径
      • 没有配置data-main,则baseurl为引入require.js的html文档所在路径
      • 已配置data-main,则baseurldata-main指向模块所处的路径
      • 显示调用require.config()进行配置
    • 绝对路径
  • 模块路径paths
    • 具体文件名
    • 文件夹
  • 模块IDmodule ID
    • 常规,不带.js

2.3. 循环依赖

在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)
        }
    }
});

3. CMD

3.1. 使用方法

// 定义模块 a.js
define(function(require, exports, module) {
  // 正确写法
  module.exports = {
    foo: 'bar',
    doSomething: function() {}
  };
});

// 获取模块 a 的接口
var a = require('./a');
// 调用模块 a 的方法
a.doSomething();

3.2. CMD与AMD的区别

参考:

总结一下,主要差异为

  • 从模块运行顺序来说,AMD 是提前执行,CMD 是延迟执行

  • 从代码风格来说,CMD 推崇依赖就近,AMD 推崇依赖前置

4. ES6中的模块

4.1. 使用方法

ES6中新增importexport,用于处理ES6模块

  • import,用于引入模块

    • 导入整个模块import * as myModule from "my-module"
    • 按需导入模块的单个或多个API如mport {myMember} from "my-module
  • export,用于导出模块,export包括

    • 命名导出,命名导出对导出多个值很有用。在导入期间,必须使用相应对象的相同名称。

    • 默认导出,可以使用任何名称导入默认导出,每个脚本只能有一个默认导出

    • 注意不能使用var,let或const作为默认导出。

ES6与CommonJSAMD最大的区别在于引入模块的方式,既可以引入整个模块,也可以引入某块的某一部分!

4.2. 运行机制

ES6模块中的值属于动态只读引用

  • 只读,即不允许修改引入变量的值,import的变量是只读的,不论是基本数据类型还是复杂数据类型。
  • 引用,模块导出的就不再是生成输出对象的拷贝,而是动态关联模块中的引用,当模块中的值改变,会影响当前文件中的值,不论是基本数据类型还是复杂数据类型。

因此,当发生循环加载时,ES6模块是动态引用。只要两个模块之间存在某个引用,代码就能够执行。

4.3. 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 命令会有变量声明提前的效果,通过模块循环引用可以看出其效果

参考

5. 模块打包原理

本文主要整理了JavaScript中几种模块规范,那么就不得不再了解一下前端工程化中模块打包的原理。

在requireJS中,模块仍旧是以script标签的形式进行管理,这样在引用模块时,不可避免地需要发送多个HTTP请求加载文件。requireJS提供了一个r.js的工具,用于合并模块。

5.1. 打包流程

事实上目前更流行的是如webpack之类的工具,接下来以webpack为例,展示模块打包的过程和原理。从启动webpack构建到输出结果经历了一系列过程:

  • 解析webpack配置参数,合并从shell传入和webpack.config.js文件里配置的参数,生产最后的配置结果。
  • 从配置的entry入口文件开始解析文件构建AST语法树,找出每个文件所依赖的文件,递归下去。
  • 在解析文件递归的过程中根据文件类型和loader配置找出合适的loader用来对文件进行转换。
  • 递归完后得到每个文件的最终结果,根据entry配置生成代码块chunk。
  • 输出所有chunk到文件系统。

最终,全部模块被打包成单个文件,为了处理各个模块之间的依赖,webpack在输出chunk时,内置了一个模块管理模具,并实现了module.exportsrequire()等API。

5.2. tree-shaking

ES6 module 有以下特点:

  • 只能作为模块顶层的语句出现
  • import 的模块名只能是字符串常量
  • import binding 是 immutable

ES6模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,基于这个特点,可以实现Tree-shakingTree-shaking本质是消除无用的js代码,在静态分析时,通过分析程序流,判断哪些变量未被使用、引用,进而删除未被使用的代码。

但事实上,实现tree-shaking并不是想象中的那么美满,详情可以参考:你的Tree-Shaking并没什么卵用,文中介绍了为什么在实际打包过程中,tree-shaking的作用是有限的:babel和uglify等代码将代码编译为”有副作用的“

  • 可以通过一些配置方法尽力保证编译的代码无副作用,尽管我们自己的项目代码很少有与业务无关的...
  • 第三方库文件往往内置了打包好的文件,除非我们手动再次配置编译,否则无法真正tree-shaking这些库文件,这也是为什么一些组件库按需加载还需要单独的loader来实现

6. 小结

我对于模块的理解就是:按功能将独立的逻辑,封装成可重用的代码块,然后对外提供模块的调用接口。归根结底,是为了更加规范更加方便的写代码和维护代码,实现Don't repeat yourself~

在学习过程中实现了简易的AMD和CMD模块系统,代码放在github上了。

本文整理了JavaScript中几种模块规范,了解各自对应的使用方法和一些特性,以及各个模块对应的差异,然后整理了如循环依赖、模块打包、tree-shaking等特性。

只有了解模块的机制,才能真正做到DRY。在此之后,就可以去了解促进前端飞速发展的另外一个工具:npm模块管理工具了。