Webpack基础(二):深入浅出模块化

630 阅读14分钟

上一节我们将了一些模块化的思维,比如AMDCommonJSCMDES Module等,在讲打包原理前,我们先熟悉下这些模块是怎样的,这一节我们将挑选几个常用的来说。

CommonJS

它是我们常说的CJS,于09年提出的包含模块、文件、IO、控制台在内的一套标准。我们熟悉的Node.js的实现中就是采用的它作为标准的一部分,并在这个基础上做了一些调整。

因此我们常说的CommonJS也是Node的这一部分,对于它原本的设计就是为服务端而产生的,而后来出现了Browserify打包工具,可以将运行在Node环境中的模块打包成浏览器可以运行的单个文件,那么CommonJS也可以用于客户端的代码。

在CommonJS中规定文件即模块。那么它和直接使用script标签引入js文件的区别就是,直接用script标签引入我们知道会造成全局变量污染的问题,而CommonJS会形成一个属于模块自身的作用域,里面的全部变量和函数只有自己可以访问,因此可以避免全局变量污染问题,我们可以测试一下,如下代码所示:

// module1.js
var moduleName = 'module1'
// module2.js
var moduleName = 'module2'
require('./module1.js')
console.log('当前的模块是:', moduleName)

上面我们建了两个文件,文件即模块嘛,所以一个叫module1.js,另一个就叫module2.js,它们各自有一个变量叫moduleName,当模块2引入模块1的时候,如果是直接用script标签引入来做的话,那么就是被模块1给覆盖,而CommonJS的这种方式则不会受到影响,输出的是当前的模块是:module2,因此可以证明它们有各自的作用域。

那么为什么会是这样?

结合Node的源码可以看出,因为会将模块代码打包成自执行函数,整个模块的代码则被自执行函数给包裹起来,那么对外则是隔离的,如上一节看到的jQuery就是这样做的。然后又会问你为什么能直接访问requireexports__dirname这些呢?因为这些在打包成自执行函数的时候,就已经生成好,并且当做参数传进来,在Node中,就有一个叫Module的构造函数,里面定义了很多属性,比如exportsidparentchilrenloadedpath等属性。大体代码如下:

(function (exports, require, module, __filename, __dirname) {})

导出

如果你这个模块想让其它模块共用,比如一些公共逻辑和方法,那么我们就需要导出,在CommonJS中通过module.exports来导出这个模块。

module.exports = {
  add: function(a, b) {
    return a + b
  },
  subtract: function(a, b) {
    return a - b
  },
  multiply: function(a, b) {
    return a*b
  }
}

其内部会有一个module对象存放这个模块的一些信息,其中module.exports是用来指定该模块对外暴露的内容,并且我们还可以直接使用exports来导出,如下:

exports.add = function(a, b) {
  return a + b
}
exports.subtract = function(a, b) {
  return a - b
}
exports.multiply = function(a, b) {
  return a * b
}

效果是一样的,其实内部将exports指向了module.exports,可以理解为内部是如下:

var module = {
  exports: {} // 开始是空对象
}
var exports = module.exports
// exports.add 等价于 module.exports = { add: () => {} }

那么我们在使用的时候,就不能直接给exports赋值,这样会导致的问题是失效,因为你改变了exports的引用,也就添加不到module.exports中去了。

// 错误写法
exports = {
  add: () => {}
}

// 也不要混合使用,如下
exports.add = function(a, b) {
  return a + b
}

module.exports = {
  subtract: (a, b) => {
    return a - b
  }
}

// 混合使用会导致的问题是在exports中添加的add会被丢失,在后面module.exports被重新赋值了,因此导出的是最后那个

除了这些外,导出的代码我们不一定要写在末尾,在它之后的代码一样会执行,但是为了提升阅读性,建议都放在最后。

导入

模块的导入则是使用require,我们先看一下代码:

// module1.js
module.exports = {
  add: (a, b) => { return a + b }
}

// index.js
const module1 = require('./module1.js')
console.log(module1.add(1,2)) // 3

上面代码所示,我们建了两个文件,分别是module1.jsindex.js,导出的是一个对象,里面有个add方法,然后调用这个方法,require方法接收了一个参数是模块路径,它还可以使用表达式,这样可以动态地指定模块加载路径。

使用require一个模块的时候,如果是第一次加载该模块,则会执行该模块的内容,然后导出内容,第二次再加载的话,就会使用缓存了,其模块对象内部有个loaded的标识符用于记录该模块是否被加载过,默认为false,如上面说的,Node中有一个 Module的构造函数,它构造出来的模块对象里面其中就包括了loaded属性。通过使用缓存,解决了循环依赖的问题,这个后面会继续说。

当我们调用require的时候,加载一个模块在Node中做了哪些事情呢?

大致有如下步骤:

  • 如果有缓存,则取缓存
  • 如果是内置模块,则直接返回
  • 如果是以./或者/或者../开头,会根据它的父模块来确定绝对路径,当x为文件名的时候,会去找xx.jsx.jsonx.node。如果x为目录,会找到目录下的package.json中的main字段配置
  • 如果模块不带路径,会根据父模块来确定可能安装的目录,依次在目录中加载x文件
  • 找到就缓存起来

对这部分感兴趣的,可以去阅读一下Node的源码,其中源码中有一个方法叫Module._findPath,主要就是做这部分事情的。

ES6 Module

先看代码实现

// module.js
export default {
  add: function (a, b) {
    return a + b
  }
}

// index.js
import module from './module.js'
console.log(module.add(1, 2)) // 3

ES6 Module也是将每个文件作为一个模块,每个模块拥有自身的作用域,不同的是导入和导出的语句不一样,并且它会默认开启严格模式。

导出

在ES6 Module中使用export语法来导出模块。它的导出有两种方式:命名导出默认导出

命名导出的写法如下:

// 声明和导出一体
export const moduleName = 'moduleA'
export const version = '1.0.0'
export const add = function (a, b) { return a + b }

// 或者先声明,后导出
const moduleName = 'moduleA'
export const version = '1.0.0'
const add = function (a, b) { return a + b }
export { moduleName, version,add }

导出的时候,我们还可以将导出的名字通过as来重命名,比如

export { moduleName as name, add } // 导入的时候就是name和add

默认导出的方式如下:

export default {
  moduleName: 'moduleA',
  version: '1.0.0',
  add: function(a, b) { return a + b}
}

export default可以理解为对外输出了一个default的变量,默认导出的方式比较常用,比如我们在写Vue文件里面的script的时候会使用到。

导入

在ES6 Module中使用import语法来导入模块。写法如下:

// index.js
import { add } from './module.js'
console.log(add(1, 2)) // 3

可以看出,导入带有命名导出的模块的时候,import后面要跟一个大括号来将导出的变量名(名要一致)包裹,导入相当于当前作用域下声明了这些变量(add),是只读的。和导出一样,也可以通过as来重命名变量,比如:

import { add as sumFn } from './module.js'

当有多个命名导出的需要导入,我们可以使用*来统一导入,配合使用as,这样相当于一个对象一样,使用导出的变量,减少命名冲突和污染。

那么默认导出的方式导入则是如下:

import module from './module.js'
module.add(1, 2) // 3

其实前面我们说的export default可以理解为对外输出了一个default的变量,那么可以把上面的代码看成:

import { default as module } from './module.js'

React中,我们经常会混合使用两种导入,比如:

import React, { Component } from 'react';

这里React要放到大括号前面,否则会报语法错误。

CommonJS 与 ES6 Module的区别

静态和动态

CommonJS的模块依赖关系的建立是在运行时的,并且可以使用表达式动态去加载模块,这样在模块执行前是无法确定明确的依赖关系,需要等到运行的时候才知道。

ES6 Module的模块依赖关系的建立是编译时的,它不支持表达式的导入方式,并且导入和导出语句必须位于模块的顶级作用域,不能放在if中,因此属于一种静态的模块结构,那么我们就可以利用这个来分析出依赖关系,可以做如下的事情:

  • 检查无用的代码:tree-sharking就是干这个的,将无用的代码清除,就是利用了ES6 Module静态结构的特性分析出哪个模块没有被使用,从而可以减少打包的体积。
  • 模块变量的类型检测:静态的模块结构有利于确保模块之间传递的值或者接口类型是正确的。
  • 编译器优化:CommonJS这种动态结构,无论采用哪种导出,本质上是导出的对象,比如module对象,而静态的模块结构就是直输出,减少了引用层级,效率更高。

值拷贝与引用

导入一个模块的时候,CommonJS导入的是导出值的一个值的拷贝,而且是浅拷贝ES6 Module则是值的引用,并且只读。

也就是说,在CommonJS中,我们导入一个模块后,我们修改这个模块内的内容,不会影响到模块里面,我们导入的是一个副本,相对于单独拷贝了一份一样,因此允许对导入的值进行修改。

代码测试如下:

// module.js
var base = 1
module.exports = {
  base,
  add: (a, b) => a + b + base
}

// index.js
var base = require('./module.js').base
var add = require('./module.js').add

console.log(base) // 1
console.log(add(1,2))// 4
base = 2
console.log(add(1,2))// 4

那么如果我们想要动态的导出内容,我们可以用函数去动态获取,导出这个函数。

ES6 Module中导入的变量其实是对原有值的引用,当导出的模块里面的内容变化的时候,导入的也会一起变,但是我们不能修改导入的,因为是只读,你可以想象一下导入和导出的内容好比镜子里的你,你动镜子里的你也会动,而镜子里的你不会主动去动,因为它对你而言只是映射。

循环依赖

当模块一依赖了模块二,模块二依赖了模块一的时候,就会形成循环依赖。这种循环依赖还很容易发现,但是实际场景中,可能有很多模块,最后依赖形成闭环,就很难发现了,所以很容易导致一些问题。

CommonJS的循环依赖情况下的表现

// module1.js
let module2 = require('./module2.js') 
console.log('模块2:', module2)
module.exports = '我是模块1'

// module2.js
let module1 = require('./module1.js')
console.log('模块1:',  module1)
module.exports = '我是模块2'

// index.js
require('./module1.js')

在控制台中运行得到结果如下

5.png

可以看出输出的结果不是我们想要的模块1:我是模块1,而是一个空对象,并且还警告了明确告知我们循环依赖了。

为什么会这样?

我们先看看Webpack中打包完后的结果的代码(删掉了不需要此次关注的部分)

/******/ (function(modules) { // webpackBootstrap
/******/ 	// The module cache
/******/ 	var installedModules = {};
/******/
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/
/******/ 		// Check if module is in cache
/******/ 		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: {}
/******/ 		};
/******/
/******/ 		// Execute the module function
/******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ 		// Flag the module as loaded
/******/ 		module.l = true;
/******/
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/
/******/ 	// Load entry module and return exports
/******/ 	return __webpack_require__(__webpack_require__.s = "./test1/src/index.js");
/******/ })({
		"./test1/src/index.js": (function(module, __webpack_exports__, __webpack_require__) {}),
    "./test1/src/utils/add.js": (function(module, __webpack_exports__, __webpack_require__){})
});

当我们在index.js中引用了module1.js的时候,对于Webpack打包后的代码而言,等于是执行了__webpack_require__方法,module1.js被首次加载,并存入到了installedModules中,然后当module2.js再次加载module1.js的时候,就直接去内存中取,因为module1.js还没有执行到module.exports,所有返回的是{},从这个方面解释了为什么是空对象了。

在Node中,前面说过,Module构造出来的对象里有个loaded,记录是否被加载过,加载过就取缓存,那和__webpack_require__方式一样。

下面详细的分析下代码的执行过程,整体过一下:

  • 首先index.js通过require导入module1的内容,把执行权交给了module1
  • 进入到module1中后,第一行require导入module2module1的执行权交给了module2,注意module1没有执行完,module.exports还是{}
  • 进入到module2中后,第一行require导入module1,此时形成了循环依赖。继续往下执行,因为上面说的module1module.exports为空对象,则打印模块1:{},最后导出我是模块2
  • 执行完module2,执行权交回给module1,继续往下执行,打印模块2:我是模块2,最后导出我是模块1
  • module1执行完 ,执行权交回给index文件

我们再来看看ES6 Module的表现

// module1.js
import module2 from './module2.js'
console.log('模块2:', module2)
export default '我是模块1'

// module2.js
import module1 from './module1.js'
console.log('模块1:',  module1)
export default '我是模块2'

// index.js
import module1 from './module1.js'

执行的结果就是:

7.png

结果和CommonJS一样。

那么它与CommonJS在解决循环依赖的本质区别就是可以利用的第二点,也就是CommonJS利用的是值拷贝的方式导入,而ES6 Module则是引用,可以动态映射。

要解决这个问题我们可以使用函数包裹,因为函数有提升的作用,但是不要写函数表达式,因为函数表达式不具有提升作用。

代码如下:

// module1.js
import { module2 } from './module2.js'
console.log('模块2:', module2())
function module1() {
    return '我是模块1'
}

export { module1 }

// module2.js
import { module1 } from './module1.js'
console.log('模块1:', module1())
function module2() {
    return '我是模块2' 
}
export { module2 }

// index.js
import module1 from './module1.js'

执行结果:

8.png

其它模块化

旧项目文件

我们经常会维护一些旧的项目,这些项目并没有使用模块化,那么我们一般是使用script标签引入,比如jQuery。在Webpack打包这种文件,我们只需通过import ./jquery.min.js即可,需要注意的是,Webpack打包时,会为每个文件包一层函数作用域用于避免全局污染,因此如果是想挂载到全局,需要注意这一点。

AMD

它是异步模块定义的缩写,用于支持浏览器模块化的标准,它加载模块的方式是异步的。使用如下:

// module.js
define('sum',['calculator'], function(math) {
  return function(a, b) { return a + b }
})

// index.js
require(['sum'], function(sum) {
  sum(1,2) // 3
})

在AMD中使用define函数来定义模块,接收三个参数,分别是当前模块的模块名、当前模块的依赖、描述模块的导出内容(可以是函数和对象类型,函数需要返回值)。

加载模块则是使用require函数,接收两个参数,分别是指定加载的模块、当加载完后执行的回调函数。

异步加载模块的好处就是非阻塞,不会阻塞后面代码的执行。

缺点就是语法更复杂,而且加载顺序并不清晰,容易造成回调地狱。

UMD

当我们遇到比较复杂的模块化的时候,就可能需要兼容多种模块化方式,使用它是一种通用模块标准,它会根据当前全局对象中的值判断目前所处哪种模块环境。优先级则是先判断AMD环境,然后是CommonJS,以及非模块环境。具体代码如下:

// module.js
(function (global, main) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(...)
  } else if (typeof exports === 'object') {
      // CommonJS
      module.exports = {...}
  } else {
    	// 非模块环境
    	global.xxx = ....
  }
})(this, function() {
  // 定义模块主体
  return ....
})

我们也可以自己把顺序替换,优先CommonJS的方式导出。

总结

上面主要学习了比较常用的CommonJSES6 Module这两种模块化标准,以及其它的一些模块化标准,下面将通过表格总结一下主要的两种模块化标准。

CommonJSES6 Module
导入require('moduleName')import moduleName from 'module'
导出module.exports= {}export default {}
模块关系的建立在运行时在编译时
模块结构动态静态
导入值的类型静态类型,值拷贝(浅拷贝),能修改动态类型,值引用,不能修改
导入的次数随意require,除第一次外,后续的从缓存中取在头部导入