Webpack-模块化

1,953 阅读11分钟

最近会产出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.exportsexprots 同时使用,也是在开发中需要避免的情况,如果同时使用了,那么导出的只会是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模块打包的原理:加载所有依赖文件、执行顺序与导入顺序一致