前端进阶-模块化你真的了解吗

1,054 阅读10分钟

本篇模块化将从以下几点进行说明

为什么需要模块

CommonJs模块化的特点与实现

CommonJs的弊端

不太重要的AMD以及CMD

ES6模块化

为什么需要模块化

在笔者看来,需要模块化的几个重要原因有:

  1. 解耦。在没有模块化之前,业务逻辑之间耦合度会非常高。不便于代码优化。比如:我们将一个项目比作一个机器人,如果没有模块化,客户可能需要一个没有手臂的机器人,这个时候,只能重新开发。但是如果我们将手臂,腿,头等部件都做成一个个的零件,客户需要什么我们拼接就完事。
  2. 避免命名冲突。在没有模块化以前,所有的代码都是在同一个上下文中初始化,在多人协作的时候,很容易出现命名冲突。
  3. 相互独立且方便维护。就好比计算机网络层次一样,我们完全不用管其他模块的代码是怎么实现的,我们只需要维护好自己模块中的引入与输出。当我们要解决的问题出现更好的方案的时候,我们只需要更改我们自己的模块,不需要告知其他人。例如:我在ES6之前,写了一个ajax请求,但是由于当时技术受限,我只能采用回调的形式进行请求成功后的执行。但是ES6之后我有了Promise,那我就对我这个模块进行了一个升级,但是由于我提供对外的方法名并没有发生改变,因此也未对其他人影响。他们只需要维护好自己的模块的引用于输出。
CommonJs模块化的特点与实现

CommonJs通过require的方式引入,通过module.exports的方式暴露出去。

CommonJs的主要特点有以下几点:

  1. 所有的文件都是一个模块,也就是所有的模块其实都是一个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 = []; // 子节点
    }
    

    image-20210522102436199

  2. 缓存优先。也就是一个文件只有在第一次引用的时候,才会去加载

    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;
     }
    // .....剔除无关代码
    }
    
  3. 在代码运行时期同步加载。我们从堆栈中保存的变量信息中可以看出,test只有在执行到require('./test.js')的时候,才会去加载,在这之前都是undefined。 关于谷歌浏览器调试node程序:Nodejs 使用 Chrome DevTools 调试 --inspect-brk

    image-20210522104307936

    image-20210522104355173

  4. 通过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;
    };
    
  5. 所有的代码都运行在模块作用域。不会污染全局作用域。所以在编译之前,他需要做的就是将模块作用域包装起来。我们知道,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});',
];

img

  1. 输出的是一个值拷贝,而不是引用拷贝

    // 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的弊端
  1. 在我们上面描述的第三点中,可以看出,CommonJs他是运行时期加载,并且是同步加载,会阻塞后面的继续执行。这就导致了CommonJs它并不能被用于客户端。原因就是:如果CommonJs工作在服务端,所有文件都是存在于服务器磁盘上的,当同步执行的时候,我们需要等待的时间就是磁盘读取文件的时间,速度是非常快的。但是要是工作在客户端,首先当加载到一个模块的时候,我需要先去服务器请求这个文件回来,假设服务器带宽1M,你的文件是1M大小,那就需要好久时间才能请求回来,这个时候,他阻塞了后面的执行,就会导致白屏时间过长。

  2. 循环引用问题。

    // 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不同在于:

  1. ES6可以用在服务器以及客户端

  2. ES6的模块引用分析,发生在编译过程,这里的编译过程又分为以下几种

    如果是不是用webpack第三方插件打包的时候,它是在创建执行上下文时期进行分析。想要了解执行上下文的可以去看:深入JS之执行上下文。从下图中就可以看出,我们还并没有开始执行到import {test as byeL}的时候,作用域下就已经有了一个模块叫做byeL。这也就是所谓的编译时期加载,就是在代码块进入执行栈执行的时候,会先创建并初始化执行上下文,在执行上下文中,如果有import的话,就率先进行解析。这就导致我们的import xxxx from xxx不能写在判断语句或者函数内的原因,如果写在判断语句或者函数内,就是在运行时期才会去加载分析,这与ES6的规范相背驰。

    image-20210522145953396

    如果是用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 */])
        })
      ]);
    
  3. ES6输出的引用,当被引用的模块内部数据发生改变的时候,会影响到当前引用他的模块中的值。

    image-20210522145953396

从图上就可以看出,其实是Module中存储的是一个对象,这个对象存储了模块导出的变量等,这个对象存在于堆上,这就导致了,当其他地方改变了这个堆上的数据,那么其他的模块也会感知到这个变化。