JavaScript中的模块系统

486 阅读8分钟

模块化编程,是强调将计算机程序的功能分离成独立的、可相互改变的“模”的软件设计技术,它使得每个模块都包含着执行预期功能的一个唯一方面所必需的所有东西。通过模块化编程,我们可以进行模块复用,从而减少重复代码,同时,降低了代码耦合,方便调试和维护。

ES6之前

在ES6前,浏览器原生不支持模块,所以出现了伪造模块的行为。

如果不使用模块,会出现相同变量名的覆盖等问题。

例如,有如下代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
</body>
<script src="./add1.js"></script>
<script src="./add2.js"></script>
<script src="./main.js"></script>
</html>
// add1.js
var a = 1;
function add1(num){  	// 返回 输入 + 1
    return a + num;
}
console.log(add1(1)); 
// add2.js
var a = 2;
function add2(num){ 	// 返回 输入 + 2
    return a + num;
}
console.log(add2(2)); 
// main.js
console.log(add1(3));	// 希望返回 4

前两个js文件中定义了两个函数,add1add2 ,功能就是分别返回输入值加1、加2。

在html文件中,依次导入了这三个js文件。结果,main.js 打印4,结果打印了5。

这是因为,js是在全局环境中依次执行代码的,a的值被重新赋值。

立即执行函数

上面代码之所以出现这种情况,是因为,变量a 定义在全局作用域里。在ES6之前,除了全局作用域,还有函数作用域,借助函数作用域,我们可以实现初版的模块系统。

// add1.js
var add1 = (function(){ // 新加代码
    var a = 1;
    function add1(num){  	// 返回 输入 + 1
        return a + num;
    }
	console.log(add1(1)); 
    
    return add1 // 新加代码
})() // 新加代码

// add2.js
var add2 = (function(){ // 新加代码
    var a = 2;
    function add2(num){ 	// 返回 输入 + 2
        return a + num;
    }
    console.log(add2(2)); 
    
    return add2 // 新加代码
})() // 新加代码


// main.js
console.log(add1(3));	// 希望返回 4

这里,不再将代码直接执行,而是将代码放到了一个立即执行函数中,并返回了定义的函数。

立即执行函数:

(function(){
 ......
})()

这样,就可以通过立即执行函数返回值来访问到定义函数

如果需要定义多个接口,可以返回一个对象。

原理:闭包。

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。

闭包让你可以在一个内层函数中访问到其外层函数的作用域。

CommonJS

CommonJS 规范概述了同步声明依赖的模块定义。

CommonJS 模块语法不能在浏览器直接运行

Node.js 的模块系统使用了CommonJS 规范。

CommonJS是提供给服务器端使用的规范。例如,使用Node.js开发一个服务器程序,就可以使用CommonJS 规范,因为Node.js实现了这一规范。

语法require()指定依赖,使用exports 对象定义自己的公共API

// ./moduelA.js
var moduleA = require('./moduleB') // 获取依赖
var add = moduleA.add

module.exports = { // 定义API 
    add:add,  
}

值得注意的是,这段代码是同步运行的,因为CommonJS 是为服务器设计的,所以没有考虑网络延时。

原理:

每一个module都是一个Module实例。

  • module.id 模块的识别符,通常是带有绝对路径的模块文件名。
  • module.filename 模块的文件名,带有绝对路径。
  • module.loaded 模块是否已经完成加载。
  • module.parent 调用该模块的模块。
  • module.children 该模块要用到的其他模块。
  • module.exports 模块对外输出的值。

module之间,通过parent children ,形成依赖图。

而在,require中,有一个cache对象用来存储加载的模块id->module字典。执行require()方法时,会从cache取值。

AMD

CommonJS 以服务器端为目标环境,能够一次性把所有模块都加载到内存,而异步模块定义(AMD,Asynchronous Module Definition)的模块定义系统则以浏览器为目标执行环境,这需要考虑网络延迟的问题。

由于AMD不是ES规范,所以是不能直接在浏览器中使用的,需要借助第三方包require.js

// src/math.js
// 定义模块
define('math', function () { // 第二个参数如果是依赖列表,回调函数可以通过入参获取模块
    'use strict';
    let add = function (x, y) {
        return x + y;
    };
    return {
        add: add, 
    }
});
// src/index.js
// 使用模块
require(['math'], function (math) {
    alert(math.add(1, 1));
})
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
</body>
<script src="./js/require.js" async="true" data-main="./src/index.js"></script>
<-- 加载require工具包,并设置./src/index.js为入口文件 -->
</html>

运行过程:

  1. ./js/require.js加载完后,会去请求./src/index.js
  2. index.js文件接收到后,开始执行,发现需要模块math,于是创建一个异步函数,去请求math.js文件。
  3. math.js文件接收到后,开始执行。define方法检查参数,发现没有依赖,于是执行函数,并将返回值(模块api)与math绑定。
  4. 执行回调函数。输入值为过程3返回值(模块api)。

原理:

  1. require: 获取依赖后执行函数。
  2. define: 用来定义模块,模块与模块间的依赖关系。

ES6模块

ECMAScript 6 模块是作为一整块JavaScript 代码而存在的。带有type="module"属性的

<script type="module">
</script>

<script type="module" src="path/to/myModule.js"></script>

模块代码会在文本解析完成后按顺序执行。即,普通脚本会先于模块代码执行。

原理:原生支持。

webpack与模块系统

不知道大家在开发项目时有没有发现,我们写代码时用的时ES6的模块。但是使用webpack打包后,似乎并没有使用type="module"属性

image-20210309225307220

或许我们应该分析打包结果来进一步了解webpack。

我们创建一个简单的项目。

  1. 运行npm init -y生成packge.json

  2. 使用npmyarn 在dev环境安装webpackwebpack-cli

  3. 创建src目录

    1. 创建并编辑add-cs.js文件

      function add(a, b) {
          return "common js:" + (a + b)
      }
      module.exports = {
          add
      }
      
    2. 创建并编辑add-es.js文件。

      export function add(a, b) {
          return "es module:" + (a + b);
      }
      
    3. 创建并编辑main.js文件。

      let add_cs = require('./add-cs')
      import { add } from './add-es'
      
      console.log(add(1, 2))
      console.log(add_cs.add(3, 4))
      
  4. 在根目录下创建配置文件 webpack.config.js。

    const path = require('path')
    
    module.exports = {
        mode: 'development',
        entry: path.resolve(__dirname, './src/main.js'),
        output: {
            path: path.resolve(__dirname, 'dist'),
            filename: 'js/[name].js'
        },
    }
    

main.js里分别使用ES6的模块和cms模块写法,调用了两个对应的文件,并执行了输出。配置文件指定main.js为入口,dist/js/main.js为出口。

执行:webpack --config ./webpack.config.js 进行打包。

dist/js/main.js中,我们可以看到打包结果。下面,开始逐段分析打包结果。

总体

(() => {
	// 段落1
    // 段落2
    // 段落3
    // 段落4
})();

代码总体结构是一个立即执行函数,这是为了避免污染全局变量。

段落1

var __webpack_modules__ = ({
  "./src/add-cs.js":
    ((module) => {
      eval(eval1);
    }),

  "./src/add-es.js":
    ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      "use strict";
      eval(eval2);
    }),

  "./src/main.js":
    ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      "use strict";
      eval(eval3);
    })
});

var __webpack_module_cache__ = {};

这一段定义了两个变量:

__webpack_modules__ 模块字典,键为模块路径,值为一个函数,函数后面进行分析。

__webpack_module_cache__ 模块缓存字典。

段落2

function __webpack_require__(moduleId) {
  if (__webpack_module_cache__[moduleId]) {  // 如果 缓存里 有 id为moduleId 的模块
    return __webpack_module_cache__[moduleId].exports;    // 从 缓存字典中返回  exports
  }
  var module = __webpack_module_cache__[moduleId] = {  // 否则, 使用id定义缓存字典,exports为空
    exports: {}
  };

  __webpack_modules__[moduleId](module, module.exports, __webpack_require__)
  return module.exports;
}

这里定义了__webpack_require__函数,函数接收模块id,返回值为模块对象的exports属性。

  1. 对于存在__webpack_module_cache__中的模块,直接返回exports属性。
  2. 对于不在__webpack_module_cache__中的模块,在__webpack_module_cache__中定义一个空对象module,并module作为参数,执行__webpack_modules__对应moduleId的函数。最后,返回对象module.exports

段落3

(() => {
  __webpack_require__.d = (exports, definition) => { 
    for (var key in definition) {
      if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
        Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
      }
    }
  };
})();

(() => { 
  __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
})();

(() => { // r函数 标记函数:接收 exports 对象。 在 exports 对象上添加属性 __esMoudle 为 true 
  __webpack_require__.r = (exports) => {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
    }
    Object.defineProperty(exports, '__esModule', { value: true });
  };
})();

这里使用立即执行函数,在__webpack_require__上定义了三个函数d o r

d 将对象definition上的属性,定义在exports 上。

o 判断对象obj上,是否存在属性prop

r 给对象exports添加属性Symbol.toStringTag__esModule

段落4

var __webpack_exports__ = __webpack_require__("./src/main.js");

调用__webpack_require__

eval

通过上述代码分析,我们知道了webpack通过__webpack_require__ 加载依赖,对于不在__webpack_module_cache__中的模块回去执行__webpack_modules__里的方法,而这些方法使用eval执行了一串代码串。下面,我将代码串改成了容易看懂的格式。

首先,我们需要明确,这三段代码有输入参数:

__unused_webpack_module__ module对象 值为{exports:{}}

__webpack_exports__ __unused_webpack_module__exports属性

__webpack_require__ 函数__webpack_require__

// **eval1**
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(
    __webpack_exports__,
    {
        "add": () => (add)
    }
);
function add(a, b) {
    return "es module:" + (a + b);
}
//# sourceURL=webpack://webpacktest/./src/add-es.js?

// **eval2**
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(
    __webpack_exports__,
    {
        "add": () => (add)
    }
);
function add(a, b) {
    return "es module:" + (a + b);
}
//# sourceURL=webpack://webpacktest/./src/add-es.js?"

这两段代码做的几乎一样,但是,我们的原代码的模块导出不见了。

可以推测,webpack对代码进行了分析,将模块导出部分,重构成了d函数的实现。

// **eval3**
__webpack_require__.r(__webpack_exports__);
var _add_es__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/add-es.js");
let add_cs = __webpack_require__("./src/add-cs.js");

console.log((0, _add_es__WEBPACK_IMPORTED_MODULE_0__.add)(1, 2))
console.log(add_cs.add(3, 4))
//# sourceURL=webpack://webpacktest/./src/main.js?

第三段代码也就顺理成章了,模块导入的代码被替换为__webpack_require__函数。

所以webpack的模块系统是这样工作的。

  1. 分析原代码,将原代码中的模块导入都替换为__webpack_require__函数,导出替换为d函数。
  2. 替换完成后,生成了代码串,根据文件路径放到__webpack_modules__ 中。