CommoneJS规定每个文件是一个模块。将一个JavaScript文件直接通过script标签引入页面中,和封装成CommonJS模块最大的不同在于:前者的顶层作用域是全局作用域,在进行变量及函数声明时会污染全局环境;而后者会形成一个属于模块自身的作用域,所有的变量及函数只有自己能访问,对外是不可见的。
导出是一个模块向外暴露自身的唯一方式。在CommonJS中,通过module.exports可以导出模块中的内容,如:
module.exports = {
name: 'commonJS_exports.js',
add: function(a, b){
return a + b;
}
}
为了书写方便,CommonJS也支持另一种简化的导出方式:直接使用exports。效果和上面一样:
exports.name = 'commonJS_exports.js';
exports.add = function(a, b){
return a + b;
}
注意:导出时不要把module.exports 与 exports混用,下面举一个错误的示例:
exports.add = function(a, b){
return a + b;
}
module.exports = {
name: 'commonJS_exports.js'
}
上面的代码先通过exports导出add属性,然后将module.exports重新赋值为另外一个对象。这会导致原本拥有的add属性的对象丢失了,最后导出的只有name。因此建议一个模块中的导出方式要么使用module.exports,要么使用exports,不要混着一起用。
在实际使用中,为了提高可读性,应该将module.exports及exports语句放在模块的末尾。
在CommonJS中使用require进行模块导入。如:
commonJS_exports.js导出代码:
console.log('...hello, 我是commonJS_exports.js....start..')
//1、第一种写法
module.exports = {
name: 'commonJS_exports.js',
add: function(a, b){
return a + b;
}
}
PageModule.vue页面中导入代码:
//1、测试CommonJS的exports和require
var comObj = require('../api/module/commonJS_exports');
console.log('...name: ', comObj.name);
try{
console.log('8 + 9 = ', comObj.add(8, 9));
}catch(e){
console.log(e);
}
另外,如果在页面中对同一模块进行多次导入,则该模块只会在第一次导入时执行,后面的导入不会执行,而是直接导出上次执行后得到的结果。示例如下:
var comObj = require('../api/module/commonJS_exports');
//再调用一次导入,发现导入模块不会再次执行,而是直接导出上次执行后得到的结果
require('../api/module/commonJS_exports');
console.log('...name: ', comObj.name);
try{
console.log('8 + 9 = ', comObj.add(8, 9));
}catch(e){
console.log(e);
}
我们看到控制台打印结果如下,导入模块果然只执行了一次:
....test CommonJS 的导入...
...name: commonJS_exports.js
8 + 9 = 17
在module对象中有一个属性loaded用于记录该模块是否被加载过,它的默认值为false,当模块第一次被加载和执行过后会设置为true,后面再次加载时检查到module.loaded为true, 则不会再次执行模块代码。 require函数可以接收表达式,借助这个特性我们可以动态地指定模块加载路径
const moduleNames = ['foo.js', 'bar.js'];
moduleNames.forEach(name=>{
require('./' + name);
})
2015年6月,发布的ES6才添加了模块这一特性。ES6 Module也是将每个文件作为一个模块,每个模块拥有自身的作用域,不同的是导入、导出语句。import和export也作为保留关键字在ES6版本中加入了进来(CommonJS中的module并不属于关键字)。
在ES6 Module中使用export命令来导出模块。export有两种导出形式:
//第一种导出方式:命名导出
//1.1 命名导出第一种写法
export const name = 'es6_export.js';
export const add = function(a, b) { return a + b; }
// //1.2 命名导出第二种写法
// const name = 'es6_export.js'
// const add = function(a, b){ return a + b; }
// export { name, add };
第一种写法是将变量的声明和导出写在一行;第二种写法则是先进行变量声明,然后再用同一个export语句导出。两种写法的效果是一样的。在使用命名导出时,还可以通过as关键字对变量重命名。如:
const name = 'es6_export.js'
const add = function(a, b){ return a + b; }
export { name, add as getSum }; //在导入时即为name和getSum
//第二种导出方式:默认导出
export default{
name: 'es6_export',
add: function(a, b){
return a + b;
}
}
我们可以将export default理解为对外输出了一个名为default的变量,因此不需要像“命名导出”一样进行变量声明,直接导出即可。
//导出字符串
export default 'this is es6_export.js file '
//导出class
export default class {...}
//导出匿名函数
export default function(){ ... }
ES6 Module中使用import语法导入模块。
const name = 'es6_export.js'
const add = function(a, b){ return a + b; }
export { name, add };
// import {name, add } from '../api/module/es6_export.js'; //命名导出第一种导入方式
// import * as esObj from '../api/module/es6_export.js'; //命名导出第二种别名整体导入方式
import {name, add as getSum } from '../api/module/es6_export.js'; //命名导出第三种别名导入方式
// //命名导出第一种导入方式
// console.log('name: ', name);
// console.log('12 + 21: ', add(12, 21));
// //命名导出第二种别名导入方式
// console.log('name: ', esObj.name);
// console.log('12 + 21: ', esObj.add(12, 21));
//命名导出第三种别名导入方式
console.log('name: ', name);
console.log('12 + 21: ', getSum(12, 21));
加载带有命名导出的模块时,import后面要跟一对大括号来将导入的变量名包裹起来,并且这些变量需要与导出的变量名完全一致。导入变量的效果相当于在当前作用域下声明了这些变量(name和add),并且不可对其进行更改,也就是所有导入的变量都是只读的。
另外和命名导出类似,我们可以通过as关键字对到导入的变量重命名。在导入多个变量时,我们还可以采用整体导入的方式,这种import * as <myModule>导入方式可以把所有导入的变量作为属性添加到<myModule>对象中,从而减少了对当前作用域的影响。
//第二种导出方式:默认导出
export default{
name: 'es6_export.js',
add: function(a, b){
return a + b;
}
}
import esObj from '../api/module/es6_export.js';
//默认命名导出的导入测试
console.log('name: ', esObj.name);
console.log('12 + 21: ', esObj.add(12, 21));
对于默认导出来说,import后面直接跟变量名,并且这个名字可以自由指定(比如这里时esObj), 它指代了es6_export.js中默认导出的值。从原理上可以这样去理解:
import { default as esObj } from '../api/module/es6_export';
注意:默认导出自定义变量名和 命名导出整体起别名有点像,但是命名导出整体起别名必须是在import 后面是 * as 别名,而默认导出是import后面直接跟自定义变量名。
最后我们看一下两种导入方式混合起来的例子:
import React, {Component} from 'react'
这里的React对应的是该模块的默认导出,而Component则是其命名导出中的一个变量。注意:这里的React必须写在大括号前面,而不能顺序颠倒,否则会引起提示语法错误。
在工程中,有时需要把某一个模块导入之后立即导出,比如专门用来集合所有页面或组件的入口文件。此时可以采用复合形式的写法:
export {name, add} from '../api/module/es6_export.js'
不过,上面的复合写法目前只支持“命名导出”方式暴露出来的变量。
默认导出则没有对应的复合形式,只能将导入和导出拆开写:
import esObj from '../api/module/es6_export.js'
export default esObj
上面我们分别介绍CommonJS和ES6 Module两种形式的模块定义,在实际开发中我们经常会将二者混用,下面对比一下它们的特性:
CommonJS和ES6 Module最本质的区别在于前者对模块依赖的解决是“动态的”,而后者是“静态的”。这里“动态”的含义是, 模块依赖关系的建立发生在代码运行阶段;而“静态”则是模块依赖关系的建立发生在代码编译阶段。
我们先看一个CommonJS的例子:
// commonJS_exports.js
module.exports = { name: 'commonJS_exports' }
//PageModule.vue
const name = require('../api/module/commonJS_exports').name;
当模块PageModule.vue加载模块commonJS_exports.js时,会执行commonJS_exports.js中的代码,并将其module.exports对象作为require函数的返回值返回。并且require的模块路径可以动态指定,支持传入一个表达式,我们甚至可以通过if语句判断是否加载某个模块。因此,在CommonJS模块被执行前,并没有办法确定明确的依赖关系,模块的导入、导出发生在代码的运行阶段。 同样的例子,我们再对比看下ES6 Module的写法:
//es6_export.js
export const name = 'es6_export.js';
//PageModule.vue
import { name } from '../api/module/es6_export.js'
ES6 Module的导入、导出语句都是声明式,它不支持导入的路径是一个表达式,并且导入、导出语句必须位于模块的顶层作用域(比如不能放在if语句中)。
因此我们说,ES6 Module是一种静态的模块结构,在ES6代码的编译阶段就可以分析出模块的依赖关系。它相比于CommonJS来说具备以下几点优势:
在导入一个模块时,对于CommonJS来说获取的是一份导出值的拷贝;而在ES6 Module中则是值的动态映射,并且这个映射是只读的。例子:
//commonJS_exports.js
var count = 0;
module.exports = {
count: count,
add: function(a, b){
count+=1;
return a + b;
}
}
//PageModule.vue
var count = require('../api/module/commonJS_exports.js').count;
var add = require('../api/module/commonJS_exports.js').add;
console.log(count); //0 这里的count是对commonJS_exports.js中count值的拷贝
add(2, 3);
console.log(count); //0 commonJS_exports.js中变量值的改变不会对这里的拷贝值造成影响
count += 1;
console.log(count); //1 拷贝的值可以更改
PageModule.vue中的count是对commonJS_exports.js中count的一份值拷贝,因此在调用函数时,虽然更改了原本calculator.js中count的值,但是并不会对PageModule.vue中导入时创建的副本造成影响。另一方面,在CommonJS中允许对导入的值进行更改。我们可以在PageModule.vue更改count和add, 将其赋予新值。同样,由于是值的拷贝,这些操作不会影响calculator.js本身。
下面我们使用ES6 Module将上面的例子进行改写:
//es6_export.js
let count = 0;
const add = function(a, b){
count += 1;
return a + b;
}
export { count, add }
import {name, add, count } from '../api/module/es6_export';
console.log(count); //0, 对es6_export.js中的count值的映射
add(2, 3);
console.log(count); //1 实时反映es6_export.js中count值的变化
// count += 1; //不可更改,会抛出ReferenceError: count is not defined
上面的例子展示了ES6 Module中导入的变量其实是对原有值的动态映射。PageModule.vue中的count是对calculator.js中的count值的实时反映,当我们通过调用add函数更改了calculator.js中的count值时,PageModule.vue中count的值也随之变化。
我们不可以对ES6 Module导入的变量进行更改,可以将这种映射关系理解为一面镜子,从镜子里我们可以实时观察到原有的事物,但是并不可以操作镜子中的影像。
循环依赖是指模块A依赖于B, 同时模块B依赖于模块A。一般来说工程中应该尽量避免循环依赖的产生,因为从软件设计的角度来说,单向的依赖关系更加清晰,而循环依赖则会带来一定的复杂度。而在实际开发中,循环依赖有时会在我们不经意间产生,因为当工程的复杂度上升到足够规模时,就容易出现隐藏的循环依赖关系。
简单来说,A和B两个模块之间是否存在直接的循环依赖关系是很容易被发现的。但实际情况往往是A依赖于B,B依赖于C,C依赖于D,最后绕了一圈,D又依赖于A。当中间模块太多时就很难发现A和B之间存在着隐式的循环依赖。
因此,如何处理循环依赖是开发者必须要面对的问题。
//bar.js
const foo = require('./foo.js');
console.log('value of foo: ', foo);
module.exports = 'This is bar.js';
//foo.js
const bar = require('./bar.js');
console.log('value of bar: ', bar);
module.exports = 'This is foo.js';
//PageModule.vue
require('../api/module/foo.js');
/*
打印结果:
value of foo: {}
value of bar: This is bar.js
* */
为什么foo的值是一个空对象呢?让我们从头梳理一下代码的实际执行顺寻:
由上面可以看出,尽管循环依赖的模块均被执行了,但模块导入的值并不是我们想要的。我们再从Webpack的实现角度来看,将上面例子打包后,bundle中有这样一段代码非常重要:
//The require function
function __webpack_require__(moduleId){
if(installedModules[moduleId]){
return installedModules[moduleId].exports;
}
//Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
}
//...
}
当PageModule.vue引用了foo.js之后,相当于执行了这个__webpack_require__函数,初始化了一个module对象并放入installedModules中。当bar.js再次引用foo.js时,又执行了该函数,但这次是直接从installedModules里面取值,此时它的module.exports是一个空对象。这就解释了上面再第3步看到的现象。
//bar_es6.js
import foo from './foo_es6.js';
console.log('value of foo: ', foo);
export default 'This is bar_es6.js';
//foo_es6.js
import bar from './bar_es6.js';
console.log('value of bar: ', bar);
export default 'This is foo_es6.js';
//PageModule.vue
import foo_es6 from '../api/module/foo_es6.js';
/* 打印结果:
value of foo: undefined
value of bar: This is bar_es6.js
* */
很遗憾,在bar_es6.js中同样无法得到foo_es6.js正确的导出值,只不过和CommonJS默认导出一个空对象不同,这里获取到的是undefined。
上面我们谈到,在导入一个模块时,CommonJS获取到的时值的拷贝,ES6 Module则是动态映射,
//bar_es6_2.js
import foo from './foo_es6_2.js';
let invoked = false;
function bar(invoker){
if (!invoked){
invoked = true;
console.log(invoker + ' invokes bar_es6_2.js');
foo('bar_es6_2.js');
}
}
export default bar;
//foo_es6_2.js
import bar from './bar_es6_2.js'
function foo(invoker){
console.log(invoker + ' invokes foo_es6_2.js');
bar('foo_es6_2.js');
}
export default foo;
import foo_es6_2 from '../api/module/foo_es6_2.js'
foo_es6_2('PageModule.vue');
/* 打印结果:
PageModule.vue invokes foo_es6_2.js
foo_es6_2.js invokes bar_es6_2.js
bar_es6_2.js invokes foo_es6_2.js
* */
可以看到,foo_es6_2.js和bar_es6_2.js这一对循环依赖的模块均获取到了正确的导出值。下面我们分析一下代码的执行过程:
由上面的例子可以看出,ES6 Module的特性使其可以更好的支持循环依赖,只是需要由开发者来保证导入的值被使用时已经设置好正确的导出值。
面对工程中成百上千个模块,webpack究竟时如何将它们有序的组织在一起,并按照我们预想的顺序运行在浏览器上的呢?下面我们将从原理上进行探究。
还是用前面的例子:
//commonJS_exports.js
module.exports = {
add: function(a, b){
return a + b;
}
}
//PageModule.vue
const comObj = require('../api/module/commonJS_exports');
const sum = comObj.add(2, 3);
console.log('sum: ', sum);
上面的代码经过Webpack打包后将会成为如下的形式(为了易读性这里只展示代码的答题结构):
//立即执行匿名函数
(function(modules){
//模块缓存
var installedModules = {};
//实现require
function __webpack_require__(moduleId){
//...
}
//执行入口模块的加载
return __webpack_require__(__webpack__require__.s == 0);
})({
//modules: 以key-value的形式存储所有被打包的模块
0: function(module, exports, __webpack_require__){
//打包入口
module.exports = __webpack_require__("3qiv");
},
"3qiv": function(module, exports, __webpack_require__){
//PageModule.vue 内容
},
jkzz: function(module, exports){
//commonJS_exports.js 内容
}
})
这是一个最简单的Webpack打包结果(bundle),但已经可以清晰地展示出它是如何将具有依赖关系的模块串联在一起的。上面的bundle分为以下几个部分:
接下来我们看看一个bundle是如何在浏览器中执行的:
不难看出,第3步和第4步时一个递归的过程,Webpack为每个模块创造了一个可以导出和导入模块的环境,但本质上并没有修改代码的执行逻辑,因此代码执行的顺序于模块加载的顺序时完全一致的,这就时Webpack模块打包的奥秘。
本文测试截图:
下载测试DEMO:https://github.com/xiaotanit/tan_vue,如果你觉得可以,请顺手点个星^_^。
npm install , npm run serve 后,在浏览器输入测试地址看效果:
http://localhost:8080/pageModule //端口可能会变化
参考书籍:《Webpack实战:入门、进阶与调优》--- 居玉皓