前端模块化发展之路

188 阅读17分钟

模块化是什么?

        在我们学习程序开发中,为了提升代码的复用性,我们首先接触到了方法的概念,方法是实现某一功能的最小单元,由若干条代码语句组成。虽然项目的复杂,我们需要进行更高粒度的代码复用,为此又抽象了模块的概念。通常模块由一组变量、函数等组成,共同实现了一个特定功能或一组相关的功能。在前端开发中,模块系统的出现是为了解决代码组织和依赖管理的问题。

前端模块系统发展历程

        在简单介绍了模块的概念,我们了解一下前端开发中,模块的发展历程:

  1. 无模块时代:早期JavaScript没有内建的模块系统,开发者通常会将所有的代码放在一个或几个大文件中,或者使用命名空间来减少全局变量的冲突。这种方式容易造成代码混乱,难以维护。

  2. CommonJS:CommonJS是服务器端JavaScript模块化的标准,Node.js就是基于这个标准实现的。它允许使用require来同步加载模块,使用exportsmodule.exports来导出模块。但由于它是同步加载的,不适合在浏览器端直接使用。

  3. AMD (Asynchronous Module Definition) : AMD是前端模块化的一个早期解决方案,它允许异步加载模块,解决了浏览器端的模块加载问题。代表库是RequireJS。AMD使用define函数定义模块,使用require函数加载模块。

  4. CMD (Common Module Definition) : CMD是另一种前端模块化的方案,与AMD类似,但它更接近CommonJS的语法。代表库是Sea.js。CMD推崇依赖就近,延迟执行。

  5. UMD (Universal Module Definition) : UMD是一种同时兼容AMD、CommonJS以及全局变量的模块化方案,可以让模块运行在浏览器和Node.js环境中。

  6. ES6 Modules:ES6(ECMAScript 2015)为JavaScript带来了原生的模块系统。ES6模块使用importexport关键字,提供了一种静态的、可编译的模块语法。ES6模块的设计思想是尽量静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

  7. 打包工具:随着前端工程化的推进,出现了各种模块打包工具,如Webpack、Rollup和Parcel。这些工具支持模块化开发,可以将散落的模块打包成一个或几个文件,以便在浏览器中使用。它们也支持模块热替换(Hot Module Replacement)等高级功能,极大地提高了开发效率。

  8. 现代框架和组件化: 现代的前端框架,如React、Vue和Angular,都支持模块化开发,并且推动了组件化开发的趋势。组件是模块的一种特殊形式,它通常包含模板、样式和逻辑,可以独立开发和测试。

CommonJS

        在 CommonJS 定义的模块系统中,每个文件被视为一个独立的模块。这意味着定义在文件中的变量、函数和类在文件外部是不可见的,除非它们被显式地导出。同样,为了使用其他模块的功能,需要导入这些模块。

image.png

        以下是 CommonJS 的一些核心概念和特点:

  1. 模块定义:每个文件是一个模块,拥有自己的作用域。在一个模块中,你可以通过 require 函数加载其他模块,通过 exports 对象或 module.exports 来导出当前模块的属性和方法。

  2. 同步加载:CommonJS 模块是同步加载的,这意味着在加载模块时,JavaScript 运行时会阻塞,直到模块加载完成。这种加载方式适合服务器环境,因为模块文件通常已经存在于本地硬盘上。

  3. 模块缓存:模块首次加载后会被缓存,后续的 require 调用会直接从缓存中获取模块的导出对象,以提高效率。

  4. 包管理:CommonJS 还定义了包(package)的概念,它是一个包含多个模块的文件夹,通常包含一个 package.json 文件,用于描述包的元数据,例如名称、版本、依赖等。

  5. 环境适应性:CommonJS 被设计为可以在不同的 JavaScript 环境中使用,如 Node.js、SpiderMonkey 等。

module 对象

        CommonJS 规定,每个模块内部,都有一个 module 对象,这个对象的 exports 属性就是对外的接口。加载某个模块时,其实就是加载该模块的 module.exports 属性。

        Node内部提供一个Module构建函数。所有模块都是Module的实例。

function Module(id, parent) {

this.id = id;

this.exports = {};

this.parent = parent;

// ...

}

        module对象有以下属性:

● module.id 模块的识别符,通常是带有绝对路径的模块文件名。

● module.filename 模块的文件名,带有绝对路径。

● module.loaded 返回一个布尔值,表示模块是否已经完成加载。

● module.parent 返回一个对象,表示调用该模块的模块。

● module.children 返回一个数组,表示该模块要用到的其他模块。

● module.exports 表示模块对外输出的值。

        通过 module.parent 可以判断当前文件是否为入口文件。比如node something.js,那么 mode.parent 就是null。如果是在脚本之中调用,比如require('./something.js'),那么 module.parent 就是调用它的模块。

module.exports

        module.exports属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取module.exports变量。

        例如,你可以将 module.exports 设置为一个函数或者一个对象:

// myModule1.js
module.exports = function() {
    console.log('This is a function as a module.exports');
};
// myModule2.js
module.exports = {
    myFunction: function() {
        console.log('Hello from myFunction!');
    },
    myVariable: 'Hello from myVariable!'
};

        要导入 myModule2.js 中定义的模块,你可以在另一个 JavaScript 文件中使用 require 函数,并传入模块的路径。以下是如何导入 myModule2.js 的示例:

// anotherModule.js
const myModule = require('./myModule'); // 假设 anotherModule.js 和 myModule.js 在同一目录下

// 现在可以调用 myModule 中的 myFunction 函数
myModule.myFunction(); // 输出:Hello from myFunction!

// 也可以访问 myVariable 属性
console.log(myModule.myVariable); // 输出:Hello from myVariable!

exports 变量

为了方便,Node为每个模块提供一个exports变量,指向module.exports。这等同在每个模块头部,有一行这样的命令。

var exports = module.exports;

        MyModules2.js中的代码可以改为:

exports.MyFunction = function() {
    console.log('Hello from myFunction!');
};

exports.myVariable = 'Hello from myVariable!';

        导入保持不变。

require 命令

        require命令用于加载模块文件。

        require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。

// example.js

var invisible = function () {

console.log("invisible");

}

exports.message = "hi";

exports.say = function () {

console.log(message);

}
var example = require('./example.js');

console.log(example)

// {

// message: "hi",

// say: [Function]

// }

文件加载规则

        require命令用于加载文件,后缀名默认为.js。

var foo = require('foo');

// 等同于

var foo = require('foo.js');

        根据参数的不同格式,require命令去不同路径寻找模块文件。

1.  如果参数字符串以“/”开头,则表示加载的是一个位于绝对路径的模块文件。比如,require('/home/marco/foo.js')将加载/home/marco/foo.js

2.  如果参数字符串以“./”开头,则表示加载的是一个位于相对路径(跟当前执行脚本的位置相比)的模块文件。比如,require('./circle')将加载当前脚本同一目录的circle.js

3.  如果参数字符串不以“./“或”/“开头,则表示加载的是一个默认提供的核心模块(位于Node的系统安装目录中),或者一个位于各级node_modules目录的已安装模块(全局安装或局部安装)。

        举例来说,脚本/home/user/projects/foo.js执行了require('bar.js')命令,Node会依次搜索以下文件。

/usr/local/lib/node/bar.js

/home/user/projects/node_modules/bar.js

/home/user/node_modules/bar.js

/home/node_modules/bar.js

/node_modules/bar.js

4.  如果参数字符串不以“./“或”/“开头,而且是一个路径,比如 require('example-module/path/to/file'),则将先找到example-module的位置,然后再以它为参数,找到后续路径。

5.  如果指定的模块文件没有发现,Node会尝试为文件名添加.js、.json、.node后,再去搜索。.js件会以文本格式的JavaScript脚本文件解析,.json文件会以JSON格式的文本文件解析,.node文件会以编译后的二进制文件解析。

6.  如果想得到require命令加载的确切文件名,使用require.resolve()方法。

目录加载规则

        除了导入文件外,我们可能还会导入一个完整的目录。这时可以为该目录设置一个入口文件,让require方法可以通过这个入口文件,加载整个项目。

        在目录中放置一个package.json文件,并且将入口文件写入main字段。如下:

// package.json

{

"name" : "some-library",

"main" : "./lib/some-library.js"

}

        require发现参数字符串指向一个目录以后,会自动查看该目录的package.json文件,然后加载main字段指定的入口文件。如果package.json文件没有main字段,或者根本就没有package.json文件,则会加载该目录下的index.js文件或index.node文件。

模块缓存

        第一次加载某个模块时,Node会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的module.exports属性。

require('./example.js');

require('./example.js').message = "hello";

require('./example.js').message

// "hello"

        上面代码中,连续三次使用require命令,加载同一个模块。第二次加载的时候,为输出的对象添加了一个message属性。但是第三次加载的时候,这个message属性依然存在,这就证明require命令并没有重新加载模块文件,而是输出了缓存。

        如果想要多次执行某个模块,可以让该模块输出一个函数,然后每次require这个模块的时候,重新执行一下输出的函数。

        所有缓存的模块保存在require.cache之中,如果想删除模块的缓存,可以像下面这样写。

// 删除指定模块的缓存

delete require.cache[moduleName];

// 删除所有模块的缓存

Object.keys(require.cache).forEach(function(key) {

delete require.cache[key];

})

AMD(了解)

        首先,我们需要解释一下为什么不能在浏览器中直接使用 CommonJS,最主要的原因浏览器主要使用异步加载的方式,倘若我们的某段代码依赖某个模块,但是由于网络原因,这段代码先于模块被浏览器加载,此时便会导致问题。

        而AMD则要解决的是问题则是,依赖当前模块的代码需要在模块加载之后才去执行。

        Demo 目录结构如下:

index.html
js
    - index.js
    - main.js
lib
    - jquery.js

        1.首先需要引入 require.js 并指定入口文件:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <!-- 1、引入 requre.js -->
  <!-- 2、data-main 指定入口文件 -->
  <script src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js" data-main="js/man.js"></script>
</head>
<body>
  <h1>Hello Require.js</h1>
  <button class="btn">按钮</button>
</body>
</html>

         配置 require.js 并加载AMD模块

// 添加 require.js 的配置
require.config({
  // 当前模块化解析的基本路径
  baseUrl: 'js',
  // 模块的映射,paths 是基于baseUrl
  paths: {
    jquery: '../lib/jquery'
  }
})

// 需要调用入口文件
// 使用 requireJs 的方式
require(['./index']);

        定义 AMD 模块

define(['jquery'], function($) {
  'use strict';

  // 模块代码
  function myFunction() {
    $('body').css('background-color', 'blue');
  }

  // 暴露公共接口
  return {
    myFunction: myFunction
  };
});

image.png

CMD(了解)

        CMD(Common Module Definition)是另一种 JavaScript 模块定义规范,它是由国内的玉伯(Yehuda Katz)提出的,主要被用于 Sea.js 这个模块加载器中。CMD 规范与 AMD 和 CommonJS 有所不同,它更接近于 Node.js 的模块风格,但是在浏览器中使用时采用了异步加载的方式。

        1、引入 Sea.js

首先,你需要在你的 HTML 文件中引入 Sea.js。你可以从 Sea.js 的官方网站或 CDN 下载最新版本。

<script src="https://cdnjs.cloudflare.com/ajax/libs/sea.js/3.0.3/sea.js"></script>

        2、配置 Sea.js

        在引入 Sea.js 之后,你可以配置它,比如设置模块的路径、定义别名等。

<script>
  seajs.config({
    alias: {
      jquery: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js'
    }
  });
</script>

        3、定义 CMD 模块

        你可以使用 define 函数来定义一个模块。这个函数接受一个工厂函数作为参数,工厂函数中的 requireexports 和 module 参数与 Node.js 中类似。

define(function(require, exports, module) {
  'use strict';

  // 引入依赖
  var $ = require('jquery');

  // 模块代码
  function myFunction() {
    $('body').css('background-color', 'blue');
  }

  // 暴露公共接口
  module.exports = {
    myFunction: myFunction
  };
});

        4、加载 CMD 模块

        使用 seajs.use 函数来加载一个或多个模块。这个函数接受一个回调函数作为参数,回调函数中的参数对应于加载的模块。

        例如,加载上面定义的模块:

seajs.use(['./myModule'], function(myModule) {
  'use strict';

  // 使用模块
  myModule.myFunction();
});

UMD(了解)

        UMD(Universal Module Definition)是一种旨在使 JavaScript 模块能够在客户端和服务器端环境中都能运行的模块定义方式。UMD 模块兼容 AMD、CommonJS 和全局变量三种使用方式。这意味着,无论你的代码在浏览器中运行还是在 Node.js 环境中运行,UMD 模块都能正常工作。

        1、定义 UMD 模块

        UMD 模块通常以一个包装器函数开始,这个函数检查当前环境,并决定如何导出模块。以下是一个 UMD 模块的例子,它依赖于 jQuery:

(function(root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD. Register as an anonymous module.
    define(['jquery'], factory);
  } else if (typeof module === 'object' && module.exports) {
    // Node. Does not work with strict CommonJS, but
    // only CommonJS-like environments that support module.exports,
    // like Node.
    module.exports = factory(require('jquery'));
  } else {
    // Browser globals (root is window)
    root.returnExports = factory(root.jQuery);
  }
}(typeof self !== 'undefined' ? self : this, function($) {
  'use strict';

  // 模块代码
  function myFunction() {
    $('body').css('background-color', 'blue');
  }

  // 暴露公共接口
  return {
    myFunction: myFunction
  };
}));

        2、使用 UMD 模块

        在 AMD 环境中使用

        在 AMD 环境中,你可以像使用其他 AMD 模块一样使用 UMD 模块:

require(['umdModule'], function(umdModule) {
  'use strict';

  // 使用模块
  umdModule.myFunction();
});

        在 CommonJS 环境中使用

        在 Node.js 或其他 CommonJS 环境中,你可以使用 require 来引入 UMD 模块:

const umdModule = require('umdModule');

// 使用模块
umdModule.myFunction();

        在浏览器环境中使用

        在浏览器环境中,你可以将 UMD 模块的脚本直接包含在 HTML 中,并通过全局变量来访问模块:

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="path/to/umdModule.js"></script>
<script>
  // 使用模块
  window.returnExports.myFunction();
</script>

ES6 Module

        AMD(异步模块定义)、CMD(通用模块定义)和 UMD(通用模块定义)是早期为了解决 JavaScript 模块化问题而出现的方案。随着 JavaScript 语言的发展,ES Module(ECMAScript 模块)已经成为官方的标准模块系统,逐渐取代了这些旧的模块系统。

        相比于上述几个模块化方案,ES Module 具备如下几个优点:

  1. 标准化:ES Module 是 ECMAScript 的官方标准,得到了所有现代浏览器和 JavaScript 环境的支持。这意味着开发者可以使用统一的、跨平台的模块系统,而无需依赖于特定的库或工具。
  2. 原生支持:现代 JavaScript 引擎原生支持 ES Module,这意味着模块的导入和导出可以在不依赖任何第三方库或工具的情况下工作。这简化了开发和部署过程,并提高了性能。
  3. 静态解析:ES Module 使用静态导入语法,这使得模块的依赖关系在编译时就可以确定,而不是在运行时。这为编译器提供了更多的优化机会,例如树摇(tree-shaking)和死代码消除。
  4. 更好的语法:ES Module 提供了更简洁、更清晰的语法来导入和导出模块。例如,使用 import 和 export 关键字,而不是 AMD 的 define 函数或 CommonJS 的 require 和 module.exports
  5. 异步加载:ES Module 支持异步加载模块,通过 <script type="module"> 标签,浏览器可以在不影响主线程的情况下异步加载和执行模块。
  6. 更好的兼容性:随着 Node.js 对 ES Module 的支持,ES Module 现在可以在服务器端和客户端环境中无缝工作,提供了真正的通用解决方案。
  7. 生态系统支持:随着 ES Module 的普及,越来越多的 JavaScript 库和框架开始支持或迁移到 ES Module。这意味着新的项目和库更有可能使用 ES Module,从而推动整个生态系统向 ES Module 迁移。

        了解了 ES Module 的优点后,接下来详细介绍一下相关语法。

模块导出

        在 ES Module(ECMAScript 模块)中,模块导出是通过 export 语句来实现的。模块导出分为两种类型,命名导出及默认导出。

// 命名导出

export const foo = 'foo';

        默认导出使用 default 关键字将一个值声明为默认导出,每个模块只能有一个默认导出。

// 默认导出

const foo = 'foo';

export default foo;

        ES6 支持在一个模块中同时定义命名导出和默认导出。

const foo = 'foo';

export default foo;

export const bar = 'bar';

模块导入

        ES Modeul 模块导入同样分为两种类型,命名导入及默认导入。

// 命名导入(针对命名导出)

import { foo } from 'foo.js';
// 默认导入(针对默认导出)

import foo from './foo.js';
// 针对命名导出结合默认导出的场景

import foo, { bar} from './foo.js';

应用模块

浏览器

        在浏览器中使用 ES Module,你需要在 HTML 中使用 <script type="module"> 标签来引入模块脚本。浏览器会异步加载这些模块,并且遵循 CORS(跨源资源共享)策略。

<!DOCTYPE html>
<html>
<head>
  <title>ES Module in Browser</title>
</head>
<body>
  <script type="module">
    import { myFunction } from './myModule.js';

    myFunction();
  </script>
</body>
</html>
export function myFunction() {
  console.log('Hello, ES Module in Browser!');
}

        浏览器会解析 <script type="module"> 标签中的 import 语句,并异步加载 myModule.js 文件。模块加载完成后,会调用 myFunction

Node.js

        在 Node.js 中,你可以通过以下方式使用 ES Module:

        导入模块

// 使用 import 语句导入模块
import { myFunction } from './myModule.js';

myFunction();

        导出模块

// 使用 export 语句导出模块
export function myFunction() {
  console.log('Hello, ES Module in Node.js!');
}

        在 Node.js 中,你需要确保文件扩展名为 .mjs 或者在你的 package.json 文件中设置 "type": "module",这样 Node.js 才会以 ES Module 的方式解析 .js 文件。

{
  "name": "my-app",
  "type": "module",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  }
}

注意事项

  • 在浏览器中,ES Module 默认是异步加载的,而在 Node.js 中,ES Module 是同步加载的,除非使用动态导入(import())。
  • 浏览器中的 ES Module 需要遵循 CORS 策略,这意味着模块文件必须来自允许跨源请求的服务器。
  • 在 Node.js 中,你可以使用 import 和 export 语句,但是在旧版本的 Node.js 中,你可能需要使用 Babel 等转译器来支持 ES Module。

总结

        在学习了前端的模块化发展历程之后,想必你一定有了深刻见解。很多前端开发一开始学习前端就接触 Vue、React 等框架,以及 Webpack、Vite 等构建工具,对于模块化的理解不够深刻导致面对框架及构建工具的模块化处理,显得不知所措。

        由于构建工具以及框架内容较多,后面将出单独的文章做出分享,如有疑问欢迎在评论区探讨。