浅谈 JS 模块化的发展史

241 阅读7分钟

文章要点 :

  • JS模块化的发展与变迁
  • 不同模块化方案的原理与实现
  • 相关面试题小结
  • 初步了解整体前端模块化、工程化的脉络

发展历史 :

背景

JS本身简单的页面设计:页面动画 + 表单提交 (表单校验/提交逻辑) , 并无模块化 or 命名空间的概念

JS的模块化需求日益增长

一、幼年期: 无模块化

  1. 开始需要在页面中增加一些不同的JS文件: 动画、表单、格式化
  2. 多种JS文件被分在不同文件中
  3. 不同的文件又被同一个模板引用

通过 <script> 标签引入各个文件,把每个文件看成是一个模块,每个模块的接口通常是暴露在全局作用域下的

<script src="jquery.js"></script>
<script src="main.js"></script>
<script src="dep1.js"></script>

认可 :

  • 文件分离是最基础的

问题出现 :

  • 如果通过这种方式做模块化,当项目变得越来越大时,很容易造成全局变量冲突,项目也会变得越来越难以管理。
  • 污染全局作用域 => 不利于大型项目的开发及多人团队的共建

二、成长期: 模块化的雏形 - IIFE - 立即执行函数 (语法侧的优化)

利用作用域的把控
// 定义一个全局变量
let count = 0;
// 代码块1
const increase = () => ++count;
// 代码块2
const reset = () => {
  count = 0;
}

increase();
reset();

此时两次调用函数使用的是同一个变量 count

如果利用函数作用域, 局部定义 count, 使得 count 作为局部变量, 不会污染全局

(() => {
  let count = 0;
  // ...
})

定义一个函数并立即执行

(() => {
  let count = 0;
  // ...
})();

就初步实现了一个最简单的模块化

  • 尝试定义一个最简单的模块
    const iifeModule = (() => {
      // 所有内容都在私有作用域
      let count = 0;
      // 将方法暴露出去
      return {
        increase: () => ++count;
        reset: () => {
          count = 0;
        }
      }
    })();
    // 调用里面的方法
    iifeModule.increase();  
    iifeModule.reset();
    

追问:有额外依赖时,如何优化IIFE相关代码?

  • 优化后的代码如下 :
    // 将额外依赖作为参数传进立即执行函数中
    const iifeModule = ((dependencyModule1, dependencyModule2) => {
      let count = 0;
      // 将方法暴露出去
      return {
        increase: () => ++count;
        reset: () => {
          count = 0;
        }
      }
    })(dependencyModule1, dependencyModule2); // 调用时也要将依赖作为参数传入
    
    // 调用里面的方法
    iifeModule.increase();
    iifeModule.reset();
    

面试1 :你了解早期 jQuery 的依赖处理以及模块加载方案吗?/ 你了解传统 IIFE 是如何解决多方依赖的问题吗? 答:IIFE传参调配

  • 实际上,jQuery等框架其实应用了 revealing ( 揭示模式 ) 写法:
    // 将额外依赖作为参数传进立即执行函数中
    const iifeModule = ((dependencyModule1, dependencyModule2) => {
      let count = 0;
      const increase = () => ++count;
      const reset = () => {
          count = 0;
      }
      // 将方法暴露出去
      return {
        increase, reset
      }
    })(dependencyModule1, dependencyModule2); // 调用时也要将依赖作为参数传入
    
    // 调用里面的方法
    iifeModule.increase();
    iifeModule.reset();
    

  • 小栗子:
  1. 创建 a.js, 将函数内的属性和方法通过 modA 对象暴露出去
 (function() {
     var num = 100
     var flag = true

     function af1() {
         console.log('a.js里面的af1');
     }

     function af2() {
         console.log('a.js里面的af2');
     }

     // 暴露内容为一个挂载到 window 上的对象
     window.modA = {
         num: num,
         flag: flag,
         af1: af1,
         af2: af2
     }
 })()
  1. 创建 b.js, 引入 modA, 并将函数内的属性和方法通过 modB 对象暴露出去
(function(modA) {
    var num = 200
    var flag = false

    function bf1() {
        console.log('b.js里面的bf1');
    }

    function bf2() {
        console.log('b.js里面的bf2');
    }

    // 暴露内容为一个挂载到 window 上的对象
    window.modB = {
        num: num,
        flag: flag,
        bf1: bf1,
        bf2: bf2
    }

    // 控制台打印 modA 里面的值
    modA.af1() // a.js里面的af1
    console.log(modA.flag); // true
})(modA)
  1. 创建 c.js, 整合 modA 和 modB
(function(modA, modB) {
    modA.af2(); // a.js里面的af2
    console.log(modA.num); // 100
    modB.bf1(); // b.js里面的bf1
    console.log(modB.flag); // false
})(modA, modB)
  1. 创建 index.html 文件并引入 js 文件
 <script src="a.js"></script>
 <script src="b.js"></script>
 <script src="c.js"></script>
  • 控制台打印结果如下:

    image-20211228160442875.png

    其中, 前两句由 b.js 中的语句控制, 其余由 c.js 中的语句控制。


三、成熟期:CJS - CommonJS

node.js 制定

特征:

  • 通过 module + exports 去对外暴露接口
  • 通过 require 来调用其他模块

模块组织方式 :

  • main.js 文件
     // 引入依赖
     const dependencyModule1 = require(./dependencyModule1)
     const dependencyModule2 = require(./dependencyModule2)
    
     // 处理部分
     let count = 0;
     const increase = () => ++count;
     const reset = () => {
         count = 0;
     }
     // 暴露出去
     exports.increase = increase;
     exports.reset = reset;
    
     module.exports = {
       increase, reset
     }
     ```
    
    
  • 模块使用方式 :
    // 引入
    const { increase, reset } = require(./main.js)
    // 调用
    increase();
    reset();
    

可能被问到的面试问题 : 实际执行处理如何实现? (情形 : SDK - 减少依赖包)

(function() {
  const dependencyModule1 = require(./dependencyModule1)
  const dependencyModule2 = require(./dependencyModule2)

  // 业务逻辑...
}).call(thisValue, exports, require, module);
  • 为什么可以用 .call() 来减少依赖包?

  • 答: 通过 call(),您能够使用属于另一个对象的方法 :

    var person = {
        fullName: function() {
            return this.firstName + " " + this.lastName;
        }
    }
    var person = {
        firstName:"Bill",
        lastName: "Gates",
    }
    person.fullName.call(person);  // 将返回 "Bill Gates"
    
  • 优点:CommonJS 率先在服务器端实现了,从框架层面解决了依赖、全局变量污染的问题

  • 缺点:主要针对服务器端(只能同步引入,无法解决异步问题)

  • 追问: 为什么服务器端不考虑异步问题?
  • 答: 服务器端文件存在于硬盘中或者云盘中, 读取很快(如: node 中的 fs)

新的问题 - 如何解决异步依赖? ==> AMD规范

四、AMD规范

通过异步加载 + 允许制定回调函数

经典实现框架是: require.js

  • 新增定义方式 :

    // 通过 define 定义一个模块, require进行加载
    /* 
    define
    params: 模块名, 依赖模块, 工厂方法
     */
    define(id, [depends], callback);
    require([module], callback)
    
  • 模块定义方式 :

    define('amdModule', ['dependencyModule1', 'dependencyModule2'], (dependencyModule1, dependencyModule2) => {
      // 业务逻辑
      // 处理部分
      let count = 0;
      const increase = () => ++count;
      const reset = () => {
        count = 0;
      }
      return {
        increase, reset
      }
    })
    
  • 引入模块:

    require(['amdModule'], amdModule => {
      amdModule.increase();
    })
    

面试题2 : 如果在AMDModule中想兼容已有代码, 怎么办?

// callback 函数中处理
define('amdModule', [], require => { // 利用 require 兼容已有代码
  // 引入依赖
  const dependencyModule1 = require(./dependencyModule1)
  const dependencyModule2 = require(./dependencyModule2)

  // 处理部分
  let count = 0;
  const increase = () => ++count;
  const reset = () => {
      count = 0;
  }

  //做一些和引入依赖相关的事宜

  // 暴露出去
  return {
    increase, reset
  }
})

面试题3 : 如果在AMDModule中想使用revealing, 怎么办?

// callback 函数中处理
define('amdModule', [], (require, export, module) => {
  // 引入依赖
  const dependencyModule1 = require(./dependencyModule1)
  const dependencyModule2 = require(./dependencyModule2)

  // 处理部分
  let count = 0;
  const increase = () => ++count;
  const reset = () => {
      count = 0;
  }

  //做一些和引入依赖相关的事宜

  // 暴露出去
  exports.increase = increase();
  exports.reset = reset();
})

define('amdModule', [], require => {
  const otherModule = require('amdModule');
  otherModule.increase();
  otherModule.reset();
})

面试题4 : 兼容AMD&CJS/如何判断CJS和AMD

==> UMD 出现

// callback 函数中处理
(define('amdModule', [], (require, export, module) => {
  // 引入依赖
  const dependencyModule1 = require(./dependencyModule1)
  const dependencyModule2 = require(./dependencyModule2)

  // 处理部分
  let count = 0;
  const increase = () => ++count;
  const reset = () => {
      count = 0;
  }

  //做一些和引入依赖相关的事宜

  // 暴露出去
  export.increase = increase();
  export.reset = reset();
}))(
  // 目标: 一次性区分 CommonJS or AMD
  typeof module === "object" && module.exports && typeof define !== "function" ? // 是 CommonJS 
  factory => module.exports = factory(require, exports, module)
  : // 是AMD
  define
)
  • 优点: 适合在浏览器中加载异步模块, 可以并行加载多个依赖
  • 缺点: 会有引入成本, 不能按需加载

==> CMD 出现

五、CMD 规范

按需加载 主要应用框架 sea.js

  • 示例代码 :
    define('module', (require, exports, module) => {
    // 使用的时候再加载依赖
      let $ = require('jquery');
      // jquery相关逻辑
    
      let dependencyModule1 = require('./dependecyModule1');
      // dependencyModule1相关逻辑
    })
    
  • 优点: 按需加载, 依赖就近
  • 缺点: 依赖于打包, 加载逻辑存在于每个模块中, 扩大模块体积

面试题5: AMD&CMD区别?

1.对于依赖的模块, AMD 是提前执行, CMD 是延迟执行.
2.CMD 推崇依赖就近 (何时用何时写), AMD 推崇依赖前置 (所有依赖必须一开始就写好).
3.AMD 的 API 默认是一个当多个用, CMD 的 API 严格区分, 推崇职责单一 (比如 AMD 里, require 分 全局 require局部 require, 都叫 require. CMD 里, 没有全局 require, 而是根据模块系统的完备性, 提供 seajs.use 来实现模块系统的加载启动. CMD 里, 每个 API 都简单纯粹)

六、ES6 模块化

EcmaScript6 标准增加了JavaScript语言层面的模块体系定义

  • 新增定义:

    • 引入关键字 —— import
    • 导出关键字 —— export
  • 模块引入、导出和定义的地方:

    • 创建 esModule.js 文件
    // 引入区域
    import dependencyModule1 from './dependencyModule1.js';
    import dependencyModule2 from './dependencyModule2.js';
    
    // 实现代码逻辑
    let count = 0;
    export const increase = () => ++count;
    export const reset = () => {
      count = 0;
    }
    
    // 导出区域
    export default {
      increase, reset
    }
    
  • 模板引入的地方

    <script type="module" src="esModule.js"></script>
    
  • node中:

    import { increase, reset } from './esModule.js';
    increase();
    reset();
    
    import esModule from './esModule.js';
    esModule.increase();
    esModule.reset();
    

面试题6:动态模块

考察:export promise

  • ES11原生解决方案:
    import('./esModule.js').then(dynamicEsModule => {
      dynamicEsModule.increase();
    })
    
  • 或者手写一个 promise
  • 优点(重要性):通过一种最统一的形态整合了js的模块化
  • 缺点(局限性):本质上还是运行时的依赖分析

解决模块化的新思路 - 前端工程化

背景

根本问题 - 运行时进行依赖分析

前端的模块化处理方案依赖于运行时分析

  • 解决方案:线下执行

    grunt gulp webpack

    <!doctype html>
      <script src="main.js"></script>
      <script>
        // 给构建工具一个标识位
        require.config(__FRAME_CONFIG__);
      </script>
      <script>
        require(['a', 'e'], () => {
          // 业务处理
        })
      </script>
    </html>
    
    define('a', () => {
      let b = require('b');
      let c = require('c');
    
      export.run = () {
        // run
      }
    })
    
工程化实现
  • step1: 扫描依赖关系表:

    {
      a: ['b', 'c'],
      b: ['d'],
      e: []
    }
    
  • step2: 重新生成依赖数据模板

    <!doctype html>
      <script src="main.js"></script>
      <script>
        // 构建工具生成数据
        require.config({
          "deps": {
            a: ['b', 'c'],
            b: ['d'],
            e: []
          }
        })
      </script>
      <script>
        require(['a', 'e'], () => {
          // 业务处理
        })
      </script>
    </html>
    
  • step3: 执行工具,采用模块化方案解决模块化处理依赖

    define('a', ['b', 'c'], () => {
      // 执行代码
      export.run = () => {}
    })
    

优点:

  1. 构建时生成配置,运行时执行
  2. 最终转化成执行处理依赖
  3. 可以拓展

七、完全体 webpack为核心的工程化 + mvvm框架组件化 + 设计模式