1. 模块化概念

对于一个复杂的应用程序,与其将所有代码都地放在一个文件当中,不如按照一定的语法,遵循确定的规则(规范)拆分成几个互相独立的文件。这些文件应该具有原子特性,也就是说,其内部完成共同的或者类似的逻辑,通过对外暴露一些数据或调用方法,与外部完成整合。 模块化主要体现了以下原则:

  • 可复用性
  • 可组合性
  • 中心化
  • 独立性

开发过程中,可能遇到的问题:

  • 变量/函数命名冲突
  • 繁琐的文件依赖

使用模块化就可以解决这些问题,使用模块化的好处如下:

  • 模块的版本管理: 通过别名等配置,配合构建工具,可以轻松实现模块的版本管理
  • 提高可维护性: 模块化可以实现每个文件的职责单一,非常有利于代码的维护。
  • 前端性能优化: 对于前端开发来说,异步加载模块对于页面性能非常有益。
  • 跨环境共享模块: CMD 模块定义规范与 NodeJS 的模块规范非常相近,所以通过 Sea.JS 的NodeJS 版本,可以方便的实现模块的跨服务器和浏览器共享。

2. 模块化规范

目前Web端主流的模块化标准:

  • CommonJS
  • AMD
  • CMD
  • UMD
  • ES6

下面就来看看这些常见的模块化规范

(1) CommonJS

Node.js 对前端的发展具有极大的促进作用,它带来的 CommonJS 模块化规范如下: 在 Node.js中,每一个文件就是一个模块,具有单独的作用域,对其他文件是不可见的。 CommonJS 规定每个文件就是一个模块,有独立的作用域。每个模块内部,都有一个 module 对象,代表当前模块。通过它来导出 API,它有以下属性:

  • id 模块的识别符,通常是带有绝对路径的模块文件名
  • filename 模块的文件名,带有绝对路径;
  • loaded 返回一个布尔值,表示模块是否已经完成加载;
  • parent 返回一个对象,表示调用该模块的模块;
  • children 返回一个数组,表示该模块要用到的其他模块;
  • exports 表示模块对外输出的值

CommonJS 的规范有以下特点:

  • 文件即模块,文件内所有代码都运行在独立的作用域,因此不会污染全局空间。
  • 模块可以被多次引用、加载。在第一次被加载时,会被缓存,之后都从缓存中直接读取结果。
  • 加载某个模块,就是引入该模块的 module.exports 属性。
  • module.exports 属性输出的是值的拷贝,一旦这个值被输出,模块内再发生变化不会影响到输出的值。
  • 模块加载顺序按照代码引入的顺序。

导出:

model.exports.TestModule = function() {
  console.log('exports')
}

exports.TestModule = function() {
  console.log('exports')
}

上面两种方式结果是一样的,module.exportsexports的区别可以理解为exportsmodule.exports的引用,如果在exports调用之前调用了exports = .., 那么就无法再通过exports来导出模块内容,除非通过exports = module.exports重新设置exports的指向。 导入:

// 如果没有后缀,会自动按照.js\.json和.node的次序进行补齐查找
const myModule = require('./MyModule')

加载过程:

  • 优先从缓存中加载
  • 如果缓存中没有,检查是否是核心模块,如果是直接加载
  • 如果不是核心模块,检查是否是文件模块,解析路径,根据解析出的路径定位文件,然后执行并加载
  • 如果以上都不是,沿当前路径向上逐级递归,直到根目录的node modules目录

(2) AMD

由于 Node.is 运行于服务器上,所有的文件一般都已经存在了本地硬盘中,不需要额外的网络请求去异步加载,因而 CommonJS 规范加载模块是同步的。只有加载完成,才执行后续操作。但是,如果放在浏览器环境中,需要从服务器端获取模块文件,此时再采用同步的方式,显然就不合适了。这时候,社区上推出了 AMD 规范。 AMD 规范,全称为: Asynchronous Module Definition,它的模块化标准是异步的,完全贴合浏览器的。它规定了如何定义模块,如何对外输出,如何引入依赖。这一切都需要代码去实现,因此一个著名的库一 requirejs 应运而生,requirejs 实现很简单: 通过 define 方法,将代码定义为模块;通过 require 方法,实现代码的模块加载。 AMD规范重要特性就是异步加载。所谓异步加载,就是指同时并发加载所依赖的模块,当所有依赖模块都加载完成之后,再执行当前模块的回调函数。这种加载方式和浏览器环境的性能需求刚好吻台。 AMD 规范只定义了一个全局函数 define,通过它就可以定义和引用模块,它有 3 个参数:

define(id?, dependencies?, factory)
  • id 为模块的名称,该参数是可选的。如果没有提供该参数,模块的名字应该默认为模块加载器请求的指定脚本的名字,如果提供了该参数,模块名必须是“顶级”的和绝对的 (不允许相对名字)。
  • dependencies 是个数组,它定义了所依赖的模块。依赖模块必须根据模块的工厂函数优先级执行,并且执行的结果应该按照依赖数组中的位置顺序以参数的形式传入 (定义中模块的) 工厂函数中。
  • factory 为模块初始化要执行的函数或对象。如果是函数,那么该函数是单例模式,只会被执行一次,如果是对象,此对象应该为模块的输出值。

看一个例子,创建一个名为“alpha”的模块,依赖了 require、exports、beta 3 个模块,并导出了verb 函数。

define(
  "alpha",
  ["require", "exports", "beta"],
  function(require, exports, beta) {
    exports.verb = function() {
      return beta.verb();
    }
  }
);

(3) CMD

CMD 规范整合了 CommonJS 和 AMD 规范的特点。它的全称为: Common Module Definition. 类似 require.js,CMD 规范的实现为 sea.js。 CMD 定义模块也是通过一个全局函数 define 来实现的,但只有一个参数,该参数既可以是函数也 可以是对象:

define(factory);

如果这个参数是对象,那么模块导出的就是对象;如果这个参数为函数,那么这个函数会被传入 3个参数 require 、exports 和 module。

define(function(require, exports, beta) {
  //...  
});
  1. require 是一个函数,通过调用它可以引用其他模块,也可以调用 require.async 函数来异步调用模块。
  2. exports 是一个对象,当定义模块的时候,需要通过向参数 exports 添加属性来导出模块 API.
  3. module 是一个对象,它包含 3 个属性:
    1. uri,模块完整的 URI 路径;
    2. dependencies,模块的依赖;
    3. exports,模块需要被导出的 API,作用同第二个参数 exports。

下面来看一个例子,定义一个 increment 模块,引用 math 模块的 add 函数,经过封装后导出成increment 函数:

define(function(require, exports, module) {
  var add = require( 'math').add;
    exports.increment = function(val) {
        return add(val1);
  };
    module.id = "increment";
});

CMD 最大的特点就是懒加载,不需要在定义模块的时候声明依赖,可以在模块执行时动态加载依赖。当然还有一点不同,那就是 CMD 同时支持同步加载模块和异步加载模块。用一句话来形容就是,它整合了 CommonJS 和 AMD 规范的特点。遵循 CMD 规范的代表开源项是 sea.js ,它的实现和 requirejs 没有本质差别。 AMD 和 CMD 的两个主要区别如下:

  • AMD 需要异步加载模块,而 CMD 在 require 依赖的时候,可以通过同步的形式 (require)也可以通过异步的形式 (require.async)。
  • CMD 遵循依赖就近原则,AMD 遵循依赖前置原则。也就是说,在 AMD 中,我们需要把模所需要的依赖都提前在依赖数组中声明。而在 CMD 中,我们只需要在具体代码逻辑内,使用化赖前,把依赖的模块 require 进来。

(4) UMD

UMD (Universal Module Definition,统一模块定义)其实并不是模块管理规范,而是带有前后端同构思想的模块封装工具。 UMD 是一组同时支持 AMD 和 CommonJS 的模式,它旨在使代码无论执行代码的环境如何都能正常工作,通过 UMD 可以在合适的环境选择对应的模块规范。比如在 Node.js 环境中采用 CommonJS 模块管理,在浏览器环境且支持 AMD 的情况下采用 AMD 模块,否则导出为全局函数。 它的实现原理如下:

  1. 先判断是否支持 Node.js 模块格式 (exports 是否存在),存在则使用 Node.js 模块格式:
  2. 再判断是否支持 AMD (define 是否存在),存在则使用 AMD 方式加载模块;
  3. 若前两个都不存在,则将模块公开到全局 (window 或 Global)

实现代码如下:

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    define([], factory);
  } else if (typeof exports === 'object') {
    module.exports,
    module.exports = factory();
  } else {
    root.returnExports = factory();
  }
}(this, function () {
  // 模块内容定义
  return {};
}));

UMD的特点如下:优点

  • 小而简洁;
  • 适用于服务器端和客户端;

缺点

  • 不容易正确配置;

(5) ES模块

通过上面的例子,你可能会发现,使用 UMD、AMD、CMD 的代码会变得难以编写和理解。于是在 2015 年,负责 ECMAScript 规范的 TC39 委员会将模块添加为 JavaScript 的内置功能,这些模块称为 ECMAScript模块,简称 ES 模块。 模块和经典 JavaScript 脚本略有不同:

  • 块默认启用**严格模式,**比如分配给未声明的变量会报错:
<script type="module">
  a = 5; 
</script>
  • 模块有一个词法顶级作用域。 这意味着,例如,运行 var foo = 42; 在模块内不会创建名为 foo 的全局变量,可通过浏览器中的 window.foo 访问,尽管在经典JavaScript脚本中会出现这种情况;
<script>
  alert(this); {/* 全局对象 */}
</script>

<script type="module">
  alert(this); {/* undefined */}
</script>
  • 模块中的 this 并不引用全局 this,而是 undefined。 (如果需要访问全局 this,可以使用 globalThis);
<script>
  alert(this); {/* 全局对象 */}
</script>

<script type="module">
  alert(this); {/* undefined */}
</script>
  • 新的静态导入和导出语法仅在模块中可用,并不适用于经典脚本。
  • 顶层 await 在模块中可用,但在经典 JavaScript 脚本中不可用;
  • await 不能在模块中的任何地方用作变量名,经典脚本中的变量可以在异步函数之外命名为 await;
  • JavaScript 会提升 import 语句。因此,可以在模块中的任何位置定义它们。

CommonJS 和 AMD 都是在运行时确定依赖关系,即运行时加载,CommonJS 加载的是拷贝。而 ES 模块是在编译时就确定依赖关系,所有加载的其实都是引用,这样做的好处是可以执行静态分析和类型检查。

语法
1) 导出

当导出模块代码时,需要在其前面添加 export 关键词。导出内容可以是变量、函数或类。任何未导出的代码都是模块私有的,无法在该模块之被外访问。ES 模块支持两种类型的导出:

  • 命名导出:
export const first = 'JavaScript';
export function func() {
    return true;
}

当然,我们也可以先定义需要导出的变量/函数,最后统一导出这些变量/函数:

const first = 'JavaScript';
const second = 'TypeScript';
function func() {
    return true;
}
export {first, second, func};
  • 默认导出:
function func() {
    return true;
}

export default func;

当然,也可以直接默认导出:

export default function func() {
    return true;
}

默认导出可以省略变量/函数/类名,在导入时可以为其指定任意名称:

// 导出
export default function () {
  console.log('foo');
}
// 导入
import customName from './module';

注意: 导入默认模块时不需要大括号,导出默认的变量或方法可以有名字,但是对外是无效的。export default 在一个模块文件中只能使用一次。 可以使用 as 关键字来重命名需要暴露出的变量或方法,经过重命名后同一变量可以多次暴露出去:

const first = 'test';
export { first as second };
2) 导入

使用命名导出的模块,可以通过以下方式来导入:

import { first, second, func } from './module';

使用默认导出的模块,可以通过以下方式来引入,导入名称可以自定义,无论导出的名称是什么:

import customName from './module.js';

导入模块位置可以是相对路径也可以是绝对路径,.js扩展名是可以省略的,如果不带路径而只是模块名,则需要通过配置文件告诉引擎查找的位置:

import {firstName, lastName} from './module';

可以使用 as 关键字来将导入的变量/函数重命名:

import { fn as fn1 } from './profile';

在 ES 模块中,默认导入和命名导入是可以同时使用的,比如在 React 组件中:

import React, {usestate, useEffect} from 'react';

const Comp = () => {
    return <React.Fragment>...</React.Fragment> 
}

export default Comp;

可以使用 as 关键字来加载整个模块,用于从另一个模块中导入所有命名导出,会忽略默认导出:

import * as circle from './circle';
console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));
3) 动态导入

上面我们介绍的都是静态导入,使用静态 import 时,整个模块需要先下载并执行,然后主代码才能执行。有时我们不想预先加载模块,而是按需加载,仅在需要时才加载。这可以提高初始加载时的性能,动态 import 使这成为可能:

<script type="module">
  (async () => {
    const moduleSpecifier = './lib.mjs';
    const {repeat, shout} = await import(moduleSpecifier);
    repeat('hello');
    // → 'hello hello'
    shout('Dynamic import in action');
    // → 'DYNAMIC IMPORT IN ACTION!'
  })();
</script>
4) 其他用法

可以使用以下方式来先导入后导出模块内容:

export { foo, bar } from './module';

上面的代码就等同于:

import { foo, bar } from './module';
export { foo, boo};

另一个与模块相关的新功能是import.meta,它是一个给 JavaScript 模块暴露特定上下文的元数据属性的对象。它包含了这个模块的信息,比如说这个模块的 URL。 默认情况下,图像是相对于 HTML 文档中的当前 URL 加载的。import.meta.url可以改为加载相对于当前模块的图像:

function loadThumbnail(relativePath) {
  const url = new URL(relativePath, import.meta.url);
  const image = new Image();
  image.src = url;
  return image;
}

const thumbnail = loadThumbnail('../img/thumbnail.png');
container.append(thumbnail);
在浏览器使用

如果想在浏览器中使用原生 ES 模块方案,只需要在 script 标签上添加 type="module" 属性。通过该属性,浏览器知道这个文件是以模块化的方式运行的。而对于不支持的浏览器,需要通过 nomodule 属性来指定某脚本为 fallback 方案:

<script type="module">
  import module1 from './module1'
</script>
<script nomodule src="fallback.js"></script>

支持 type="module" 的浏览器会忽略带有 nomodule 属性的脚本。使用 type="module" 的另一个作用就是进行 ES Next 兼容性的嗅探。因为支持 ES 模块化的浏览器,都支持 ES Promise 等特性。 由于默认情况下模块是延迟的,因此可能还希望以延迟方式加载 nomodule 脚本:

<script nomodule defer src="fallback.js"></script>
在Node.js使用

上面提到,Node.js 使用的是 CommonJS 模块规范,它也是支持 ES 模块的。在 Node.js 13 之前,ES 模块是一项实验性技术,因此,可以通过使用 .mjs 扩展名保存模块并通过标志访问它来使用模块。 从 Node.js 13 开始,可以通过以下两种方式使用模块:

  • 使用.mjs扩展名保存模块;
  • 在最近的文件夹中创建一个 type="module"package.json 文件。

那如何在小于等于 12 版本的 Node.js 中使用 ES 模块呢?可以在执行脚本启动时加上 --experimental-modules,不过这一用法要求相应的文件后缀名必须为 .mjs

node --experimental-modules module1.mjs
import module1 from './module1.mjs'
module1
浙ICP备2021014928号 2023-PRESENT © Eric