最近会产出webpack的一列入门、进阶知识,今天的主题是模块化。主要介绍CommonJS、ES6 Moudle、AMD、UMD模块化导入、导出方式及区别,模块化导入导出的原理。
一、CommonJS
现在我们所说的CommonJS其实是Node.js中的版本,而并非它的原始版本。CommonJS为服务端而生,浏览器环境不能运行,直到Browserify出现(一个运行在Node.js环境的模块化打包工具,它可以将CommonJS模块打包为浏览器环境可以运行的单个文件,可以NPM直接下载 )。
npm install -g browserify
1、特点
CommonJS中规定每个文件是一个模块,每一个模块都会形成一个属于自己作用域,所有的变量即函数只有自己能访问,对于外是不可见的
// module1.js
var name = 'module1.js'
// index.js
var name = 'index.js';
require('./module1');
console.log(name); //index.js
2、导出
在CommonJS中通过**module.exports**可以导出模块中的内容,如:
module.exports = {
name: 'module1',
add: function (a, b){
return a + b;
}
}
CommonJS模块内部会有一个module对象用于存放当前模块的信息,也就是node环境,我们来打印一下这个对象。
Module {
id: '.',
exports: {
name: 'module1',
add: [Function: add]
},
parent: null,
filename: '...',
loaded: false,
children: [],
paths:[]
}
果然就module.exports是我们要导出的内容,其实Module对象的exports属性默认为一个空对象。
CommonJS也支持另一种简化的导出方式直接使用exports ,如:
exports.add = function (a, b) { return a + b;}
注意:此种方式只能使用属性访问符.的方式导出,不能直接赋值,比如:
exports = { // 这种方式不允许,导出的模块为空对象
add: function (a, b){
return a + b;
}
}
那么为什么导出的会是一个空对象呢?
因为CommonJS模块规范,在导出的本质就是最终导出**moudle.exports** 这个属性,使用exports之所以可以导出是因为,exports 拿到的是module.exports的引用,而直接给exports赋值相当于改变了exports指向的地址,最终导出的是module.exports的默认值{}
整个过程可以看成在如下代码:
var module = {
exports: {},
}
var exports = module.exports;// 最终导出module.exports
module.exports 和exprots 同时使用,也是在开发中需要避免的情况,如果同时使用了,那么导出的只会是module.exports指向的内容,epxorts指向内容丢失,无论二者顺序如何。
module.exports = {
name:'a'
}
exports.add = function () {}
// 导出内容为{name: 'a'}
exports.add = function () {}
module.exports = {
name:'a'
}
// 导出内容为{name: 'a'}
2、导入
CommonJS中使用require进行模块导入,在导入时有以下两种情况:
-
require的模块是第一次被加载。这时会首先执行该模块,然后导出内容。
-
require的模块曾被加载过。这时该模块的代码不会再次执行,而是直接导出上次执行后的结果。
// module1.js console.log('module1 is run'); module.exports = { add: function(a, b){ return a + b; } }
// index.js// 第一次导入 const firstModule1 = require('./module1'); const sum1 = firstModule1.add(1, 3) console.log(sum1)// 第二次导入 const secondModule1 = require('./module1'); const sum2 = secondModule1.add(3, 3) console.log(sum2) // module1 is run // 4 // 6
module对象的loaded属性用一个布尔值,记录了这个模块是否被加载过
另外require函数还可以接收表达式,借助这个特性我们可以动态的指定模块加载路径。
moduleNames = ['module1.js', 'module2.js', 'module3.js'];
moduleNames.forEach(ele => {
require('./' + ele)
})
二、ES6 Module
前面我们已经说过CommonJS为服务端而生,主要应用于Node环境,浏览器不认识,但是对于前端工程师来说,我们主要编码环境就是浏览器,如果使用CommonJS还需要browserify来转换,比较麻烦。直到2015年6月,有TC39标准委员会正式发布了ES6(ECMA Script 6.0),从此JavaScirpt本身就具备了模块化这一特性。
ES6 Module同样是将每个文件视为一个模块,每个模块拥有自身的作用域,与CommonJS不同的是导入、导出方式,并且ES6 Module会采用严格模式 'use strict' .
1、导出
ES6 Module 使用export 命令导出模块,导出有两种形式:
- 命名导出
- 默认导出
命名导出:一个模块可以有多个命名导出,两种不同的写法,但效果相同
写法1:
export const name = 'tylor';
export const age = 20;
写法2
const name = 'tylor';
const age = 20;
export {name, age}
使用命名导出时,可以使用as关键字对变量重命名,如:
const add = function (a, b) {
return a + b;
}
export {add as func} //导入的变量就是func
默认导出:默认导出只能有一个,如:
export default {
name : 'tylor',
add : function (a, b){
return a + b;
}
}
2、导入
ES6 Module使用import语法导入模块。
加载命名导出的模块时,import后面要跟一对{}来将导入的变量名包裹起来,并且{}内的变量名需要与导出的变量名完全一致,当然也可以通过as关键字对导入的变量重命名。
import {name, age as age1} from './module1.js'
在导出多个变量是,可以用整体导入的方式,如:
import * as moduleName from './module2.js'
加载默认导出时,import关键字后不用添加{}直接跟变量名,这个名字的指定是任意的
// moduel1.js
export default {
name : 'tylor',
add : function (a, b){
return a + b;
}
}
// index.jsimport module1 from './module1.js'
// 可以这样理解
import {default as module1} from './module.js'
当然,二者可以连用,需要注意默认导出的内容在导入时必须在{}前,顺序不能颠倒,否则会提示语法错误
// moduel1.js
export default { // 默认导出
add : function (a, b){
return a + b;
}
}
const name = 'tylor';
const age = 20;
export {name, age} // 命名导出
// index.js
import module1,{name , age} from './module1.js'
命名导出和导入的复合写法,在工程中有时需要把一个模块块导入后立即导出,比如专门用来集合所有页面或者组件的入口文件。
export {name, age} from './module1.js'
默认导出和导入没有复合写法,只能拆开来写
import {name, age} from './mdoule1.js'
export default { name, age}
CommonJS VS ES6 Module区别
- 动态和静态:CommonJS的对于模块依赖的解决是‘动态的’,ES6 Module是‘静态的’;这里的动态指模块的依赖关系发生在
代码运行阶段,而静态是指模块的依赖关系发生在代码的代码编译阶段。 - 值拷贝和动态映射:CommonJS导入时获取的是导出模块
值的拷贝,而ES6 Module则是值的映射,并且这个映射是只读的。 - 循环依赖(两个模块相互导入):CommonJS不能解决循环依赖,而ES6 Module根据它的导出的数据是_
动态映射的特性+锁_,可以很好的解决循环依赖
三、非模块化文件
如果你维护的是一个几年前的项目,里面极有可能含有非模块化文件,比如bootstrap、jquery等,那么如何打包这种文件呢?
直接引入
import './jQuery.min.js'
因为一般来说像这种类库都会将其接口暴露在全局($、JQuery这样的关键字会直接挂载到window对象上),因此无论是利用<script src=''/>引入,还是用webpack打包效果都是一样的;但是如果引入的模块是隐式的将变量暴露在全局,如:
var obj = {
name: '张三'
};
通过webpack打包时就无法挂载到全局,webpack在打包时会为每一个文件包裹一层函数作用域来避免污染全局变量,那么此时obj就会是这个文件内的局部变量。
四、AMD - Asynchronous Module Definition(异步模块定义)
与CommonJS、ES6 Module最大的区别在于他的加载模块的方式是异步的,不会阻塞文件代码的运行
// AMD定义(导出)- define
define(Id, [relyModuleName] , function (a, b) {
return function (a, b) {
console.log('sum =' + relyModuleName.add(a, b));
}
} )
第一个参数:Id,相当于自己的模块名;
第二个参数:relyModuleName,依赖的模块名;
第三个参数:function(){} , 模块导出值,可以是函数或者对象;函数导出函数返回值,对象导出对象本身。
// AMD导入 - require
require('moduleName', function (add) {
add(1,2);
})
第一个参数:指定的加载模块名;
第二个参数:是当前模块加载完成后执行的回调函数
AMD的设计理念很好,异步非阻塞,但是与前面介绍的两种模块化标准想比,语法比较沉重,容易造成回调地狱,虽然webpack支持AMD,但因此也目前的应用中越来越少
五、UMD - Universal Module Definition (通用模块标准)
在日常很多工程会用到两种以上模块化标准,如果是一个框架开发者可能就要考虑多种方式都支持的情况。
UMD严格上讲不是一种模块化标准,而是多种标准的集成。
(fucntion (global, main) {
if( typeof define === 'function' && defined.amd) {
// AMD
define(..);
}else if( typeof exports === 'object') {
// CommonJS
mdoule.exports = ...;
}else {
global.add = ...;
}
})(this,fucntion () {
// 模块主体
return {}
})
AMD定义的模块无法使用CommonJS和ES6 Module,但UMD一般先判断AMD环境,webpack用到最多一般是Commonjs,webpack同时支持AMD和Commonjs,所以在webpack打包需要将UMD中模块的判断调整一下位置,先判断CommonJS即可,避免将CommonJS模块使用AMD方式导出、导入。
六、加载NPM模块
javascript不像java、c++、Python等语言一样都有自己的标准库,当开发者需要解决url解析、data转换时都需要自己手动封装,而npm提供可以让开发者在其平台上找到由其他人发布的函数库,并安装到自己的本地库。这就是npm作为包管理器的作用。当然你也可以自己封装npm模块上传到npm平台,通过这种方式与别人共享代码。
npm官网地址:https://www.npmjs.com/
下载npm方式 (前提是本地已经安装的npm和node)
npm install packageName --save-dev (简写 -D)
// 开发环境依赖:安装在node_modules、记录在package.json的DevDependencies字段
npm install packgageName --save (简写 -S)
// 开发和生产环境公用依赖:安装到node_modules、记录在package.json的dependencies
npm install packgageName
// 与 npm install packageName --save效果相同
npm install packgageName -g
// 安装到全局
加载npm模块只需要引入包的名字即可
import _ from 'lodash';
当webpack打包时解析到这条语句时会自动去node_modules文件夹寻找,那么实际打包的过程中具体加载的是哪个文件呢?
每一个npm模块都有一个入口文件,就是加载该模块的入口文件。这个入口被维护在package.json文件的main字段中。
// package.json
{
...
main: 'lodash.js';
...
}
实际上webpack在打包时加载的是node_modules/lodash/lodash.js这个文件,我们还可以自定义指定webpack打包时加载的文件,使用/这种形式,如:
import 'lodash/fp/all.js'
这样webpack最终就会打包node_modules/lodash/fp/all.js这个文件,这样不会打包lodash全部的库,而是我们用到的一部分,通过这种方式可以减小打包资源的体积,提高webpack打包时的速度,进而提升网页加载性能。
七、模块打包原理
在日常开发中webpack会打包很多个模块,那么webpack是如何将他们有序的组织到一起的呢?下面我们以一个小例子来探究一下其内部原理。
// index.js
const calculator = require('./calculator.js');
const sum = calculator.add(1, 3);
console.log('sum:' + sum); // 4
// calculator.js
module.exports = {
add: function (a, b) {
return a + b;
}
}
经过webpack打包后会形成下面这样的代码
//
(function (modules) {
var installedModules = {}; // 缓存
function __webapck_require__ (moduleId){...}; // require 函数
return __webpack_require__(__webpack_require__.s = 0); // 入口函数的加载
})({
// modules: 以键值对的方式存放被打包的模块
"0": function (module, exprots, __webpack_require__){
// 打包入口
module.exports = __webapck_require__('3qiv')
},
"3qiv": function (module, exports, __webpack_require__){
//index.js内容
},
"jkzz": function (module, exports, __webpack_require__){
// calculator.js
}
});
这是一个webpack打包后的结果bundle.js,可以清晰的知道他们是如何具有依赖关系的,
上述代码主要以下分为几个部分:
- 立即执行函数:最外层,用来封闭作用域
- installModules:每个模块第一次加载时执行,再次加载直接出第一加载的值
- __webpack_require__:对模块加载的实现,在浏览器中可以直接调用__webpack_require__(moduleId)来实现导入
- mdoules:工程中所有产生依赖关系的模块都会以key-value的形式存储在这里,key为一个简短的hash值,value为一个匿名函数包裹的每个模块的实体
Bundle.js在浏览器环境的执行顺序
-
最外层的匿名函数会初始化浏览器的执行环境
-
加载入口模块:每个bundle.js都有一个入口模块,比如案例的index.js
-
执行模块代码:执行到modue.exports记录下导出值,遇到require函数暂时交出执行权,进入(__webpack_require__函数体)执行内部逻辑
-
判断installedModules是否有值:有则直接返回,没有回到第三步,执行模块代码
-
所有依赖执行完毕,执行权回到入口模块。
**总结:**第三步与第四步是递归的关系,根据Commonjs特性第一次导入模块都会执行其代码的内部逻辑,在依赖关系比较多时通过第三步与第四步不断循环,直到加载(执行)完毕所有依赖(js文件);并且webpack为每个模块只是创造了一个可以导入导出的环境,不会修改每个js文件的执行顺序,因此代码的执行顺序不会改变并且与模块的加载顺序一致(这里指的是我们导入模块的顺序,至于每个模块的内部顺序则会先执行到最后一个依赖文件),这就是Webpack模块打包的原理:加载所有依赖文件、执行顺序与导入顺序一致。