本篇模块化将从以下几点进行说明
为什么需要模块
CommonJs模块化的特点与实现
CommonJs的弊端
不太重要的AMD以及CMD
ES6模块化
为什么需要模块化
在笔者看来,需要模块化的几个重要原因有:
解耦
。在没有模块化之前,业务逻辑之间耦合度会非常高。不便于代码优化。比如:我们将一个项目比作一个机器人,如果没有模块化,客户可能需要一个没有手臂的机器人,这个时候,只能重新开发。但是如果我们将手臂,腿,头等部件都做成一个个的零件,客户需要什么我们拼接就完事。避免命名冲突
。在没有模块化以前,所有的代码都是在同一个上下文中初始化,在多人协作的时候,很容易出现命名冲突。相互独立且方便维护
。就好比计算机网络层次一样,我们完全不用管其他模块的代码是怎么实现的,我们只需要维护好自己模块中的引入与输出。当我们要解决的问题出现更好的方案的时候,我们只需要更改我们自己的模块,不需要告知其他人。例如:我在ES6之前,写了一个ajax请求,但是由于当时技术受限,我只能采用回调的形式进行请求成功后的执行。但是ES6之后我有了Promise,那我就对我这个模块进行了一个升级,但是由于我提供对外的方法名并没有发生改变,因此也未对其他人影响。他们只需要维护好自己的模块的引用于输出。
CommonJs模块化的特点与实现
CommonJs通过require的方式引入,通过module.exports的方式暴露出去。
CommonJs的主要特点有以下几点:
-
所有的文件都是一个模块,也就是所有的模块其实都是一个Module的实例。
const moduleParentCache = new SafeWeakMap(); function Module(id = '', parent) { // 源码位置在 // 源码位置在:https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js this.id = id; this.path = path.dirname(id); this.exports = {}; // 到最后实例化的时候,会将实例化之后的this赋值给module,因此就有了module.exports,并且将this.exports赋值给一个变量exports。这也就是为啥exports === module.exports的原因,具体代码见第四条讲解 moduleParentCache.set(this, parent); // 缓存 updateChildren(parent, this, false); // 更新子节点 this.filename = null; // 文件名称 this.loaded = false; // 是否被加载过 this.children = []; // 子节点 }
-
缓存优先
。也就是一个文件只有在第一次引用的时候,才会去加载Module._load = function(request, parent, isMain) {// 源码位置在:https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js //....剔除无关代码 const filename = relativeResolveCache[relResolveCacheIdentifier]; if (filename !== undefined) { // 如果文件存在的话 const cachedModule = Module._cache[filename]; // MOdule._cache保存的就是缓存的文件 if (cachedModule !== undefined) { // 如果有缓存 updateChildren(parent, cachedModule, true); if (!cachedModule.loaded) // 有缓存但是没有被加载 return getExportsForCircularRequire(cachedModule); // 加载 return cachedModule.exports; // 最后返回模块的exports,也就是module.exports } delete relativeResolveCache[relResolveCacheIdentifier]; } } // ...剔除无关代码 Module._cache[filename] = module; // 保存当前模块到缓存目录中去。 if (parent !== undefined) { relativeResolveCache[relResolveCacheIdentifier] = filename; } // .....剔除无关代码 }
-
在代码运行时期同步加载
。我们从堆栈中保存的变量信息中可以看出,test只有在执行到require('./test.js')
的时候,才会去加载,在这之前都是undefined
。 关于谷歌浏览器调试node程序:Nodejs 使用 Chrome DevTools 调试 --inspect-brk -
通过module.exoprts或者exports输出
。这是因为node在编译的时候,会在编译你所写的代码的时候定义一个exports,将这个exports的指向了Module实例的exports的内存地址,并且将这个Module的实例的this赋值给module。我们在第一条已经知道所有的Module实例中都有一个exports的属性。Module.prototype._compile = function(content, filename) { // 源码位置在:https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js // 剔除无用的代码 let result; const exports = this.exports; // 这个this指向的就是Module的实例. const thisValue = exports; const module = this; // 将this赋值给module if (requireDepth === 0) statCache = new SafeMap(); if (inspectorWrapper) { result = inspectorWrapper(compiledWrapper, thisValue, exports, require, module, filename, dirname); } else { // ReflectApply是 Reflect.apply(),该方法与ES5中Function.prototype.apply()方法类似:调用一个方法并且显式地指定 this 变量和参数列表(arguments) ,参数列表可以是数组,或类似数组的对象。 //Reflect.apply(target, thisArgument, argumentsList) target目标函数 thisArgument taeget调用时,绑定的this argumentsList 入参 result = ReflectApply(compiledWrapper, thisValue, [exports, require, module, filename, dirname]); } hasLoadedAnyUserCJSModule = true; if (requireDepth === 0) statCache = null; return result; };
-
所有的代码都运行在模块作用域。不会污染全局作用域
。所以在编译之前,他需要做的就是将模块作用域包装起来。我们知道,JS中只有在ES6以后才有块级作用域,在ES6之前,只有全局作用域
与函数作用域
。在不使用闭包的前提下,函数内部的作用域可以相当于一个块级作用域。因此在编译之前他会在你所写的文件外封装一个function。
let wrap = function(script) { // 源码位置在:https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js
return Module.wrapper[0] + script + Module.wrapper[1];
};
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];
-
输出的是一个值拷贝,而不是引用拷贝
。// a.js var x = 10; const arr = []; function changeX() { x = 20; }, function pushData(item) { arr.push(item); }, module.exports = { x, arr, }; // b.js const a = require('./a.js'); console.log(a);
所谓的值拷贝就是:我已经引用的模块并不会受到模块自身内部值的改变的影响。
如上图。我a.js向外暴露了x,我在b.js中引入了。在我b.js引入a.js之后,a.js中可能有一些操作更改了a.js中x的值,如图上的
changeX
方法,这个时候,他只会影响a.js模块内部的x的值,并不会影响已经在b.js中已经引入的a模块中的x的值。但是有一个问题是,如果是暴露的是引用类型的,例如a.js中的arr,他如果发生改变是会影响大b.js已经引入的a模块中的arr的值。那这就不是与
值拷贝
发生了冲突吗?这个问题的原因是因为:暴露出去的都是浅拷贝,知识拷贝了栈上的地址,并没有去拷贝堆上的数据,因为基础类型都是存在于栈上的,因此呢就不会受影响,但是引用类型,栈上知识存储的指针,这个指针指向了堆内存中详细的数据,当数据发生改变的时候,因为内存都是指向了同一个堆内存,所以会受到影响。关于堆栈和存储可以查看:JS系列之数据类型,判断方式以及存储位置
CommonJs的弊端
-
在我们上面描述的第三点中,可以看出,CommonJs他是运行时期加载,并且是同步加载,会阻塞后面的继续执行。这就导致了CommonJs它并不能被用于客户端。原因就是:如果CommonJs工作在服务端,所有文件都是存在于服务器磁盘上的,当同步执行的时候,我们需要等待的时间就是磁盘读取文件的时间,速度是非常快的。但是要是工作在客户端,首先当加载到一个模块的时候,我需要先去服务器请求这个文件回来,假设服务器带宽1M,你的文件是1M大小,那就需要好久时间才能请求回来,这个时候,他阻塞了后面的执行,就会导致白屏时间过长。
-
循环引用问题。
// a.js const b = require('./b.js'); console.log(b); // b.js const a = require('./a.js'); console.log(a);
我们在
CommonJs模块化的特点与实现
第二点中已经说明了,缓存优先。那么就导致一个问题,假设入口是a.js,那么执行他的时候,发现require(./b.js)
,这个时候他去加载b.js阻塞后面的运行,此时加载b.js
的时候,发现b.js
又引用了a.js
,那么久去加载a.js
,因为a.js
已经读取了,所以就会优先使用缓存,但是因为缓存的文件不完整,导致后面的console.log(b)
以及console.log(a)
并不会被执行。
不太重要的AMD以及CMD
在ES6之前,因CommonJs不能用于客户端,因此就催生了各种各样的前端模块化方案,其中最主要的有两个,一个是AMD,他通过define(id?, dependencies?, factory)来定义一个模块 ,它要在声明模块的时候指定所有的依赖 dependencies ,这个依赖的引用是异步的,最后接受的是一个回调函数。通过require引入
define("module", ["other1", "other1"], function(m1, m2) {
// ... do something
return something;
});
require(["module", "../file"], function(module, file) { /* ... */ });
CMD与AMD实现方案非常相似,仅有部分出入,那就是CMD倡导的是依赖后置,在运行的时候去加载,而不是在加载完成之后再去执行回调运行。
define(function(require, exports, module) {
var $ = require('something');
exports.something = ...;
module.exports = ...;
})
ES6模块化
ES6模块化通过 import xxx from xxx
或者 import {xxx} from xxx
的方式引用,通过export
或者export default
的方式导出。
ES6的模块化,是JS原生支持的,并不需要安装其余依赖就可以直接使用。
ES6模块化与CommonJs不同在于:
-
ES6可以用在服务器以及客户端
-
ES6的模块引用分析,发生在编译过程,这里的编译过程又分为以下几种
如果是不是用webpack第三方插件打包的时候,它是在创建执行上下文时期进行分析。想要了解执行上下文的可以去看:深入JS之执行上下文。从下图中就可以看出,我们还并没有开始执行到
import {test as byeL}
的时候,作用域下就已经有了一个模块叫做byeL
。这也就是所谓的编译时期加载,就是在代码块进入执行栈执行的时候,会先创建并初始化执行上下文,在执行上下文中,如果有import的话,就率先进行解析
。这就导致我们的import xxxx from xxx
不能写在判断语句或者函数内的原因,如果写在判断语句或者函数内,就是在运行时期才会去加载分析,这与ES6的规范相背驰。如果是用webpack等第三方打包的话,那么它就是在webpack将源代码编译的时候,会将其转化成ES5的CommoJs模式。我们之前提过一个CommonJs的弊端就是同步加载,可能你觉得转化成ES5的CommonJs模式岂不是要等?那就错了,现在都是单页面,webpack会把所有文件打包到同一个文件中去(前提是你没有设置打包到不同文件)。所以所有的脚本文件都是在第一次全部加载请求回来了。具体转义如下:
示例代码:
// a.js export const byeL = 123; //b.js import { byeL } from './a.js';
转义:
(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 = 1); }) /************************************************************************/ ([ /* 0 */ (function (module, __webpack_exports__, __webpack_require__) { // 这个有没有很熟悉,可以往前看CommobJs编译的时候,也是需要封装成这样的一个函数。 "use strict"; const byeL = 123; __webpack_exports__["byeL"] = i; }), /* 1 */ (function (module, __webpack_exports__, __webpack_require__) { "use strict"; Object.defineProperty(__webpack_exports__, "__esModule", { value: true }); var __WEBPACK_IMPORTED_MODULE_0__a__ = __webpack_require__(0); console.log(__WEBPACK_IMPORTED_MODULE_0__a__["a" /* byeL */], __WEBPACK_IMPORTED_MODULE_0__a__["b" /* j */]) }) ]);
-
ES6输出的引用,当被引用的模块内部数据发生改变的时候,会影响到当前引用他的模块中的值。
从图上就可以看出,其实是Module中存储的是一个对象,这个对象存储了模块导出的变量等,这个对象存在于堆上,这就导致了,当其他地方改变了这个堆上的数据,那么其他的模块也会感知到这个变化。