小白也能看懂!从前端模块化发展到各类模块化规范🫠🫠🫠

简历缺少有技术深度的项目吗?最近在做开源,实现一个脚手架,涉及广泛的工程化知识

如果你感兴趣参与贡献,或者想加入社区聊聊技术、工作、八卦,可以添加我的联系方式:Tongxx_yj。

GitHub 链接:github.com/xun082/crea...

背景

尽管模块化已经成为一种被广泛应用的编程方法,但是很多人对于模块化的分类和原理还比较模糊。

有些人认为模块化就是将代码按照功能进行划分 ,但是并不知道如何将划分后的模块组合在一起形成一个完整的程序。

还有些人认为模块化就是使用某些工具或框架来实现代码的拆分和组合 ,但是并不知道这些工具或框架的原理和实现方式。

这篇文章就从头到尾梳理一下模块化的本质和原理,明确模块化的设计目的和优点,让大家对不同的模块化实现方式有更深刻的认知。

先来说说通过这篇文章能学到什么:

模块化基本原理与发展历程。

不同的模块化实现方式与特点: CommonJS、ES6、AMD、CMD、UMD。

模块化介绍

什么是模块化

模块化是一种开发思想和理论,它的核心在于将代码按照功能进行划分,使得每个功能代码都成为独立的模块,从而提高开发效率,降低维护成本。但是,模块化并不包含具体的实现方式或者技术,只是一种抽象的概念。

具体的实现方式可以有很多种,例如:CommonJS、AMD、ES6 模块等等,每种实现方式都有自己的特点和优缺点。不同的实现方式可以在不同的场景下使用,但是它们都是服务于模块化这个思想和理论的。

模块化发展历程

为提高大家对主题的全面认识。我们可以从模块化概念的发展历程开始,解其起源、内在逻辑、演进轨迹和优劣比较。

全局变量和函数

在早期的前端开发中,开发者通常使用全局变量和函数来组织代码。这种方式存在着命名冲突、代码耦合度高等问题,不利于大型项目的开发和维护。

js 复制代码
// 使用全局函数方式实现计算器
// 定义一个全局函数add,用于两数相加
function add(a, b) {
  return a + b;
}

// 定义一个全局函数subtract,用于两数相减
function subtract(a, b) {
  return a - b;
}

// 调用全局函数进行计算
console.log(add(5, 3)); // 输出:8
console.log(subtract(5, 3)); // 输出:2

命名空间模式

为了避免全局变量带来的命名冲突问题,开发者开始使用命名空间模式,将相关功能的变量和函数打包到一个命名空间下。这种方式可以减少全局变量的数量,但并没有解决模块之间的依赖和加载顺序的问题。

在 JavaScript 中,可以使用对象来模拟命名空间:

js 复制代码
// 创建一个命名空间对象
var MyNamespace = {
    // 可以在命名空间中定义变量、函数等
    version: '1.0',
    
    // 定义一个函数
    sayHello: function() {
        console.log('Hello from MyNamespace! Version ' + this.version);
    },
    
    // 定义另一个命名空间
    SubNamespace: {
        description: 'This is a sub-namespace',
        
        // 定义一个函数
        sayHi: function() {
            console.log('Hi from SubNamespace! ' + this.description);
        }
    }
};

// 调用命名空间中的函数
MyNamespace.sayHello();
MyNamespace.SubNamespace.sayHi();

🌟🌟🌟 IIFE 模块模式

随着 JavaScript 闭包 特性的发展,立即调用函数表达式(Immediately Invoked Function Expression,IIFE)模式开始流行。通过使用 IIFE,可以创建私有作用域,避免全局污染,并且可以通过返回对象或函数来暴露公共接口,实现简单的模块化。

js 复制代码
var myModule = (function() {
  // 私有变量和函数
  var privateVar = 'I am private';
  
  function privateFunction() {
    console.log('This is a private function');
  }
  
  // 返回公共接口
  return {
    publicVar: 'I am public',
    
    publicFunction: function() {
      console.log('This is a public function');
      // 在公共函数中可以访问私有变量和函数
      console.log(privateVar);
      privateFunction();
    }
  };
})();

// 访问模块的公共部分
console.log(myModule.publicVar); // 输出: I am public
myModule.publicFunction(); // 输出: This is a public function
                           // 输出: I am private
                           // 输出: This is a private function

再进一步,就是如何实现模块依赖。 我们通过下面的例子理解一下模块依赖的实现:

js 复制代码
// 模块C 依赖模块A和模块B
var ModuleC = (function(ModuleA, ModuleB) {
    // 使用依赖模块A和模块B的功能
    ModuleA.publicFuncA();
    ModuleB.publicFuncB();
    
    // 其他私有变量和函数
    var privateVarC = 'I am private in ModuleC';
    
    function privateFuncC() {
        console.log('Private function in ModuleC');
    }
    
    // 公共方法和变量
    return {
        publicVarC: 'I am public in ModuleC',
        publicFuncC: function() {
            privateFuncC();
            console.log('Public function in ModuleC');
        }
    };
})(ModuleA, ModuleB);

// 使用模块C
ModuleC.publicFuncC(); // 输出:Private function in ModuleA
                       //      Public function in ModuleA
                       //      Private function in ModuleB
                       //      Public function in ModuleB
                       //      Private function in ModuleC
                       //      Public function in ModuleC

可以看到依赖的模块通过参数传入,使得函数内部得以调用依赖,至此,现代模块化的雏形出现了。

模块化规范

IIFE 之后,随着模块化的发展,出现了更先进和更方便的模块化解决方案,例如 CommonJS、AMD、CMD、UMD 和 ES6 模块。

在了解了模块化的概念和发展历程之后,我们来学习一下这些模块化规范吧!

模块化规范

CommonJS

CommonJS 的提出是为要解决 JS 在服务端编程环境中模块化的空白。此前,JS 主要被用于浏览器环境,而服务端环境编程主要依赖 PHP、Python 等语言。随着 Node.js 出现(采用的 CommonJS 规范),JS 才开始被广泛用于服务端编程。

同步加载

CommonJS 模块是同步加载的,因此适合在服务端(如 Node.js)使用,因为文件系统的访问是即时的。但在浏览器中,同步加载可能会导致性能问题 ,因为它会阻塞页面的渲染直到模块加载完成

模块导出

模块通过 module.exportsexports 对象导出其公共 API。其他模块通过 require 函数导入这些模块。

js 复制代码
// 定义模块 math.js
const add = (a, b) => a + b;
module.exports = add;

// 导入模块
const add = require('./math.js');
console.log(add(2, 3)); // 输出: 5

模块缓存

CommonJS 模块在首次被加载后会被缓存。因此无论 require 函数被调用多少次,对应模块都只会被加载一次。后续的 require 调用将返回第一次加载时缓存的导出对象。

js 复制代码
// math.js
console.log('模块被加载了!');
module.exports = {
  add: (a, b) => a + b
};

// app.js
const math = require('./math'); // 控制台输出:模块被加载了!
const mathAgain = require('./math'); // 控制台不再输出,直接从缓存中加载模块

如果想要再次执行 math.js,需要清除缓存,其中 Node.js 的缓存机制是通过 require.cache 实现的,可以通过删除 require.cache 对象上对应模块路径的属性来实现清除。

js 复制代码
const math = require('./math'); // 控制台输出:模块被加载了!

// 获取模块的完整路径
const modulePath = require.resolve('./math');

// 检查缓存中是否存在该模块
if (require.cache[modulePath]) {
  // 删除缓存
  delete require.cache[modulePath];
}

const mathAgain = require('./math'); // 控制台输出:模块被加载了!

循环依赖处理

CommonJS 模块系统能够处理模块间的循环依赖。当发生循环依赖时,require 函数返回的是在模块执行到当前位置时已经加载的部分。虽然这允许一定程度上的循环依赖,但可能导致依赖的一部分未被加载完成就被另一部分使用,因此应当避免复杂的循环依赖。

我们可以通过以下示例进一步观察循环依赖的现象:

js 复制代码
// a.js
console.log('a 开始加载');
exports.done = false;
const b = require('./b');
console.log('在 a 中,b.done = %j', b.done);
exports.done = true;
console.log('a 完成加载');

// b.js
console.log('b 开始加载');
exports.done = false;
const a = require('./a');
console.log('在 b 中,a.done = %j', a.done);
exports.done = true;
console.log('b 完成加载');

// app.js
const a = require('./a');
const b = require('./b');
console.log('在 app 中,a.done = %j, b.done = %j', a.done, b.done);

// a 开始加载
// b 开始加载
// 在 b 中,a.done = false
// b 完成加载
// 在 a 中,b.done = true
// a 完成加载
// 在 app 中,a.done = true, b.done = true

运用于浏览器

前面说到 CommonJS 的同步加载问题导致在浏览器环境不适用,为了解决这个问题,社区开发了各种工具和打包器,如 Browserify、Webpack 等,这些工具可以将 CommonJS 模块转换成浏览器可以直接使用的形式。这种转换过程通常包括将所有模块及其依赖捆绑成一个或多个文件 ,并将 CommonJS 的同步加载转换为适合浏览器的异步加载模式。

这使得开发者可以不必担心浏览器环境的限制。同时,这也促进了前后端代码共享的可能性,因为同一套代码理论上可以同时在服务器(Node.js)和浏览器端运行,只要适当地处理平台特有的API和环境差异。

相关的配置方式可以参考文章:www.ruanyifeng.com/blog/2015/0...

ES6

ES6 模块标准是 JS 官方的模块化解决方案,旨在创建一个在浏览器和服务器上都通用的模块化标准。以下是 ES6 模块的一些关键特性:

静态导入和导出

ES6 模块使用 importexport 语句进行模块的导入和导出。这些操作是静态的,意味着它们不能放在条件语句、循环或任何函数内部。这样设计使得模块结构在编译时就可以确定,允许 JS 引擎优化加载和编译。

导出

js 复制代码
// 导出单个功能
export const square = x => x * x;

// 导出多个功能
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;

// 默认导出
export default function() {
  console.log("This is the default export");
}

导入

js 复制代码
import { square, add } from './math.js'; // 明确指定要导入的名称
import myDefaultFunction from './myModule.js'; // 可以用任意名称引用该导出的值

动态导入

ES6 模块支持通过 import() 函数动态地导入模块。这对于条件加载模块、按需加载和代码拆分非常有用。import() 返回一个 Promise 对象,使得可以在异步操作中使用。

js 复制代码
console.log('Running')

setTimeout(() => {
  import("./module.js").then((module) => {
    module.doSomething();
  });
}, 1000);

循环依赖处理

CommonJS 不同,ES6 模块的导入(import)和导出(export)是静态的,如果使用import从一个模块加载变量,定义的变量会成为一个指向被加载模块的引用(不是缓存),因此目标模块未被加载时,获取不到相关的值。

代码示例

假设有两个模块,a.jsb.js,它们互相依赖:

js 复制代码
// a.js
import { b } from './b.js';
console.log('a.js', b);
export const a = 'A from a.js';

// b.js
import { a } from './a.js';
console.log('b.js', a);
export const b = 'B from b.js';

// console.log('b.js', a);
// ReferenceError: Cannot access 'a' before initialization

解决办法就是将 a、b 变为函数,即可在 import 前被定义(函数提升)。

js 复制代码
// a.js
import { b } from "./b.js";
console.log("a.js", b());
function a() {
  return "a";
}
export { a };

// b.js
import { a } from "./a.js";
console.log("b.js", a());
function b() {
  return "b";
}
export { b };

// b.js a
// a.js b

Tree Shaking 优化

树摇优化是一种通过删除未使用的代码来减少最终打包文件大小的技术。ES6 模块的静态结构使得构建工具(如 Webpack)能够在编译时静态分析代码,识别和移除那些被导出但在应用中未被使用的代码。

假设有一个模块 math.js,其中定义了多个数学相关的函数,但在应用中只使用了其中一部分,

js 复制代码
// math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// app.js
import { add } from './math.js';

console.log(add(1, 2));

在这个例子中,尽管 math.js 模块导出了两个函数,但 app.js 只使用了 add 函数。借助于树摇优化,构建过程能够识别到 subtract 函数未被使用,因此在最终的打包文件中,这些未被引用的导出将被移除。

js 复制代码
// commonjs
!(function () {
  var o,
    n = {
      609: function (o) {
        console.log("模块被加载了!"),
          (o.exports = {
            add: function (o, n) {
              return o + n;
            },
            delete: function (o, n) {
              return o - n;
            },
          });
      },
    },
    r = {};
  (o = (function o(t) {
    var e = r[t];
    if (void 0 !== e) return e.exports;
    var u = (r[t] = { exports: {} });
    return n[t](u, u.exports, o), u.exports;
  })(609)),
    console.log(o(1, 2));
})();
//# sourceMappingURL=bundle.js.map

// es6
!function(){"use strict";console.log(3)}();

AMD

AMD(Asynchronous Module Definition)规范实现了模块依赖的异步加载。AMD 是针对浏览器环境设计的,特别关注在页面加载后按需加载模块,从而减少页面初始加载时间,并提高 Web 应用的性能和用户体验。

定义模块

js 复制代码
define('math', [], function() {
  var add = function(x, y) {
    return x + y;
  };

  var subtract = function(x, y) {
    return x - y;
  };

  // 导出API
  return {
    add: add,
    subtract: subtract
  };
});

其中,define 的第一个参数是模块的ID(可选),第二个参数是一个数组,声明了模块的依赖,第三个参数是一个工厂方法,返回模块的导出对象。

使用模块

js 复制代码
require(['math'], function(math) {
  console.log(math.add(5, 3)); // 输出: 8
  console.log(math.subtract(5, 3)); // 输出: 2
});

其中,require 的第一个参数是数组,表示要加载的模块;第二个参数是回调函数,在加载完成后执行。

虽然随着 ES6 的普及,AMD 不再是首选的模块化解决方案,但 AMD 及其实现(require.jscurl.js)在现有项目中仍然广泛使用,尤其是在那些需要细粒度控制模块加载顺序和时间的应用中。

对于 require.js 的使用,可以参考文章:www.ruanyifeng.com/blog/2012/1...

CMD

CMD(Common Module Definition)由玉伯提出,与 AMD 有很多相似之处。以下是 CMD 的特点:

  • 就近依赖CMD 推崇依赖就近原则,即仅在需要使用某个模块时再去require它。
  • 延迟执行:模块的加载是异步的,但定义的模块会延迟执行,直到需要时才执行。
  • 简单易用 :通过 define 定义模块,require加载模块,API简单,易于理解和使用。

CMDAMD(异步模块定义)是当时两个主要的模块化规范。相比之下,AMD 规范(如 RequireJS 实现)更强调依赖前置,即一开始就定义所有的依赖;而 CMD 规范(如 Sea.js 实现)则允许就近定义依赖,更加灵活。

下面是相关的示例代码:

js 复制代码
// 定义模块
define(function(require, exports, module) {
    // CMD规范推荐的是就近依赖,需要时再require
    var $ = require('jquery');
    var add = function(a, b) {
        return a + b;
    };
    var subtract = function(a, b) {
        return a - b;
    };

    // 通过exports对象导出API
    exports.add = add;
    exports.subtract = subtract;
});

// 使用模块
define(function(require, exports, module) {
    var math = require('./math.js');
    var result = math.add(1, 2);
    console.log(result); // 输出: 3
});

对于 sea.js 的使用可以查阅官方文档:seajs.github.io/seajs/docs/

UMD

UMD(Universal Module Definition) 旨在兼容 AMDCMDCommonJS,同时也支持在全局变量模式下使用。UMD 的主要目的是使模块可以在各种环境中运行,包括浏览器和服务器端,以及无模块管理器的环境中。

实现原理

UMD 模式包含以下部分:

  1. 环境检测 :首先,UMD 会检测当前 JS 运行环境,以确定使用何种模块化机制。
  2. 模块定义 :根据环境检测的结果,UMD 会使用相应环境的模块定义方式(如 define 函数用于 AMDCMDexportsmodule.exports 用于 CommonJS)来定义模块。
  3. 全局备选 :如果 UMD 检测到既不是 AMD 也不是 CommonJS 环境,则会将模块暴露为一个全局变量。这确保了在传统的浏览器脚本标签中也能使用 UMD 模块

下面是相关的示例代码:

js 复制代码
(function(root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define([], factory);
    } else if (typeof define === 'function' && define.cmd) {
        // CMD (Sea.js)
        define(function(require, exports, module) {
            module.exports = factory();
        });
    } else if (typeof module === 'object' && module.exports) {
        // CommonJS (Node)
        module.exports = factory();
    } else {
        // Browser globals (root is window)
        root.exampleModule = factory();
    }
}(typeof self !== 'undefined' ? self : this, function() {
    // Module functionality
    function helloWorld() {
        return "Hello, world!";
    }

    // Return module's public API
    return {
        helloWorld: helloWorld
    };
}));

UMD 的主要优势在于其跨环境的兼容性,这对于开源项目和库特别有用,因为它们通常需要在多种环境下工作。同样的,虽然它不如 ES6 那样广泛使用,但 UMD 依然在确保向后兼容性和跨环境兼容性方面扮演着重要角色。

总结

本文为大家梳理了前端模块化的发展历程和现代模块化方案的特点,限于篇幅,不同模块化的区别和更深入的使用方式并没有具体讲述,对于部分内容会在后续独立总结一下。

在平时的开发中,主要掌握好 CommonJSES6 模块即可。目前看来 ES6 模块最为广泛应用,但在一些特定情况下(如兼容旧浏览器或特定 Node.js 应用),CommonJS 和其他模块化标准仍然会被使用。不过,我认为随着时间的推移和技术的更新,ES6 模块标准的使用将越来越普遍,成为现代 JS 开发中的主流模块化方案。

相关推荐
k0933几秒前
sourceTree回滚版本到某次提交
开发语言·前端·javascript
EricWang135821 分钟前
[OS] 项目三-2-proc.c: exit(int status)
服务器·c语言·前端
September_ning22 分钟前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人31 分钟前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
番茄小酱00133 分钟前
Expo|ReactNative 中实现扫描二维码功能
javascript·react native·react.js
子非鱼9211 小时前
【Ajax】跨域
javascript·ajax·cors·jsonp
超雄代码狂1 小时前
ajax关于axios库的运用小案例
前端·javascript·ajax
长弓三石1 小时前
鸿蒙网络编程系列44-仓颉版HttpRequest上传文件示例
前端·网络·华为·harmonyos·鸿蒙
小马哥编程1 小时前
【前端基础】CSS基础
前端·css
嚣张农民1 小时前
推荐3个实用的760°全景框架
前端·vue.js·程序员