likes
comments
collection
share

JavaScript 模块化规范

作者站长头像
站长
· 阅读数 16

前言

随着时代的发展,浏览器的性能得到了极大地提升,用户对访问网页的要求也越来越高,网站的编码复杂度也越来越高。随之而来的就是层出不穷的各种框架和工具库,如:React、Vue、Angular 等等。前端项目体积越来越大,这时候就要考虑使用模块化规范去管理项目代码了。

什么是模块化?

模块化是指解决一个复杂问题时,自顶向下逐层把系统划分成若干模块的过程,有多种属性,分别反映其内部特性。

模块化是一种处理复杂系统分解为更好的可管理模块的方式。

模块化用来分割,组织和打包软件。每个模块完成一个特定的子功能,所有的模块按某种方法组装起来,成为一个整体,完成整个系统所要求的功能。

模块具有以下几种基本属性:接口、功能、逻辑、状态。功能、状态与接口反映模块的外部特性,逻辑反映它的内部特性。

在系统的结构中,模块是可组合、分解和更换的单元。模块化是一种处理复杂系统,将其分解成为更好的可管理模块的方式,它可以通过在不同组件设定不同的功能,把一个问题分解成多个小的独立、互相作用的组件,来处理复杂、大型的软件。

以上来自百度百科的模块化解释,说简单点模块化就是把一个复杂的功能或系统分解成一个又一个独立的子功能,每个子功能只负责处理一类事情,这个独立出来的子功能就可以叫做一个模块。

为什么要模块化

使用模块化规则管理项目可以减少全局变量的污染减少命名冲突、可以实现低耦合高内聚的代码、只要实现了相应的接口就可以随时替换模块来更新和升级、可以提高代码的复用性和可维护性、可以根据需要来选择加载合适的模块。

模块化规范

JavaScript 常见的模块化规范有:

  • CommonJS
  • AMD
  • CMD
  • UMD
  • ESM

CommonJS

Node.js 就是采用的 CommonJS 规范。

Node 应用由模块组成,每个文件就是一个模块,有自己的作用域,在一个文件里面定义的变量、函数、类,都是私有的,对其他文件是不可见的。

在服务端,模块的加载是运行时同步加载的。

在客户端,模块需要提前编译打包处理。

需要注意的是 CommonJS 模块输出的是一个值的拷贝。

所以 CommonJS 是一种适用于 服务端 的模块化规范,它不太适合在 浏览器 上使用。

CommonJS 的特点

  1. 所有的代码都运行在模块作用域中,不会污染全局作用域;
  2. 模块可以多次被加载,但是只会在第一次加载时运行一次,然后运行的结果就会被缓存,后续再加载的时候就会直接读取缓存的结果,要想让模块再次运行,必须清除缓存才行;
  3. 模块加载的顺序,按照其在代码中出现的顺序,即 CommonJS 的模块加载语法是同步的;
  4. 关于模块顶层的 this 指向问题,在 CommonJS 顶层,this 指向当前模块;

CommonJS 的基本语法

以下会使用 伪代码 的形式,展示 CommonJS 的基本语法。

模块的导出

CommonJS 进行模块的暴露需要使用 module.exports

// 文件名: lib.js
const counter = 3

function incCounter() {
  counter++
}

module.exports = {
  counter: counter,
  incCounter: incCounter,
}
模块的导入

CommonJS 进行模块的加载需要使用 require 关键字,加上文件路径:

const mod = require('./lib');
console.log(mod.counter);  // 3

mod.incCounter();
console.log(mod.counter); // 3 CommonJS 模块输出的是一个值的拷贝
模块的原理

上面有说到,CommonJS 规范定义,每个文件就是一个模块。

CommonJS 规范规定,每个模块内部,module 变量代表当前模块。

这个变量是一个对象,它的 exports 属性(即 module.exports )是对外的接口。

加载某个模块,其实是加载该模块的 module.exports 属性。

使用 reqiure 第一次加载一个模块的时候,就会在内存中生成一个对象。大概长这个样子:

{
  id: '...', // 模块名
  exports: { ... }, // 模块输出的接口
  loaded: true, // 是否执行完毕
  ...
}
循环加载

循环加载是指在 A 模块中依赖了 B 模块,B 模块中又依赖了 A 模块的情况。

CommonJS 模块的特性就是加载时执行,当脚本被 reqiure 的时候,就会全部执行。

一旦出现某个模块被 循环加载 ,就只输出已经执行的部分,还未执行的部分不会输出。我们看一个官方的例子,首先定义 a.js 如下:

// 文件名:a.js

exports.done = false;
const b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');

上面的代码,首先输出一个 done 变量,然后开始加载 b.js。

注意,此时 a.js 就会停在这里,等待 b.js 执行完,才会继续执行后面的代码。

再定义 b.js 代码:

// 文件名:b.js

exports.done = false;
const a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');

跟 a.js 类似,b.js 导出一个变量后,在第二行就开始加载 a.js ,发生了循环依赖。

然后系统就会去内存对象的 exports 中取 done 变量的值,可是因为 a.js 没有执行完,所以只取到刚开始输出的值false。

接着b.js继续执行后面的代码,执行完毕后,再把执行权交还给 a.js ,执行完后面剩下的代码。为了验证这个过程,新建一个main.js:

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

最后执行main.js结果为:

在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true

由于 CommonJS 模块遇到循环加载时,输出的是当前已经执行那部分的值,而不是代码全部执行后的值,两者可能会有差异。

所以,输入变量的时候,必须非常小心!

AMD

AMD 规范( require.js )不同于CommonJS 规范是同步加载模块的,AMD 规范是非同步加载模块,允许指定回调函数。

由于 Node.js 主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以 CommonJS 规范比较适用,但是如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用 AMD 规范,此外AMD规范比 CommonJS 规范在浏览器端实现要来着早。

AMD 的特点

  1. 异步加载模块;
  2. 依赖前置,在定义模块的时候,需要提前声明其所依赖的模块;
  3. 指定回调函数,等所有依赖加载完毕后,才会执行回调函数。

AMD 的基本语法

以下会使用 伪代码 的形式,展示 AMD 的基本语法。

模块的导出

AMD 模块的导出分两种,一种是没有依赖,一种是有依赖

定义没有依赖的模块:

define(function(){
   return 模块
})

define({
  // 模块
})

定义有依赖的模块:

define(['module1', 'module2'], function(m1, m2){
   return 模块
})
模块的导入

AMD 进行模块的加载也需要使用 require 关键字,加上需要导入的 模块名称回调函数

require(['module1', 'module2'], function(m1, m2){
   使用m1/m2
})
循环加载

AMD 规范中出现了 循环加载 的时候,需要使用局部的 require 模块进行包裹,以避免出现问题:

a.js:

// 文件名: a.js

define(['require'], (require) => {
    console.log('a')
    const b = require(['b'], (b) => {
        console.log('a.js b.name', b.name)
    })
    return {
        name: 'a',
        fn: () => console.log('a fn')
    }
})

b.js:

// 文件名: b.js

define(['require'], (require) => {
    console.log('b')
    const a = require(['a'], (a) => {
        console.log('b.js a.name', a.name)
    })
    return {
        name: 'b'
    }
})

main.js

require(['a'], (a) => {
    a.fn()
})

输出:

a
a fn
b
a.js b.name b
b.js a.name a

CMD

CMD 规范专门用于浏览器端,模块的加载是异步的,模块使用的时候才会加载执行。

CMD 规范整合了 CommonJS 和 AMD 规范的特点。

在 Sea.js 中,所有 JavaScript 模块都遵循 CMD 模块定义规范。

CMD 的特点

  1. 异步加载模块;
  2. 依赖就近原则,在使用某个模块的时候,才会去进行加载。

基本语法

模块的导入和导出

模块的导出

定义没有依赖的模块:

define(function(require, exports, module){
  exports.xxx = value
  module.exports = value
})

定义有依赖的模块:

define(function(require, exports, module){
  //引入依赖模块(同步)
  const module2 = require('./module2')
  //引入依赖模块(异步)
  require.async('./module3', (m3) => {})
  //暴露模块
  exports.xxx = value
})

模块的导入

引入使用模块:

define(function (require) {
  var m1 = require('./module1')
  var m4 = require('./module4')
  m1.show()
  m4.show()
})

ESM

ESM (ECMA Script Module,ES标准模块),就是 ES6 定义的模块化规范。

ES6 的模块化设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

CommonJS、AMD、UMD 模块,都只能在运行时确定这些东西。

比如 CommonJS 模块就是对象,输入时必须查找对象属性。

ESM 的特点

  1. 只能作为模块顶层的语句出现;
  2. import 的模块名只能是字符串常量;
  3. import binding 是 immutable 的。

ESM 的基础语法

export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。

模块的导出

a.js:

// 文件名: a.js
export const a1 = 5
export const a2 = 10
export const sum = (x, y) => x + y

const obj = {
    name: 'es'
}
export { obj }
export const _a3 = 15

/* export {
    a1, 
    a2, 
    sum, 
    obj,
    _a3
} */

const a = 20
export default a
模块的导入

b.js:

// b.js
import aaa, { a1, a2, sum, obj, _a3 as a3 } from './a.js'
import * as mod from './a.js' // default & a1, a2 ,a3, ...

console.log(a1) // 5
console.log(a2) // 10
console.log(sum(a1, a2)) // 15
console.log(obj) // { name: 'es' }
console.log(_a3) // error
console.log(a3) // 15
console.log(aaa) // 20

循环加载

ES6 模块是动态引用,如果使用 import 加载一个变量,变量不会被缓存,真正取值的时候就能取到最终的值。

可以看下下面这个例子:

// even.js
import { odd } from './odd'
export let counter = 0;
export function even(n) {
  counter++;
  return n === 0 || odd(n - 1);
}

// odd.js
import { even } from './even';
export function odd(n) {
  return n !== 0 && even(n - 1);
}

上面代码中 even.js 里面的函数 even 有一个参数n,只要不等于 0,就会减去 1,传入加载的 odd() 。

odd.js 也会做类似操作。

运行上面这段代码,结果如下:

> import * as m from './even.js';
> m.even(10);
true
> m.counter
6

上面代码中,参数 n 从 10 变为 0 的过程中,even() 一共会执行 6 次,所以变量 counter 等于 6。

在这个例子中,我们可以看到 even.js 中输出的 counter 变量值会随着模块内部的变化而变化。

由于模块化方案的加载方式的不同,导致它们对待循环加载的不同处理。

ESM 和 CommonJS 的差异

ESM 和 CommonJS 有两个重大差异

  1. CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
  2. CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

第一个差异 我们用 ESM 模块重写之前 CommonJS 模块的加载机制例子:

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

主要还是 ES6 模块的运行机制与 CommonJS 不一样。

ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

第二个差异 是因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成,而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

其次,就是 this 关键词,在 ES6 模块顶层,this 指向 undefined,而 CommonJS 模块的顶层的this指向当前模块。

然后,在 ES6 模块中可以直接加载 CommonJS 模块,但是只能整体加载,不能加载单一的输出项:

// 正确
import packageMain from 'commonjs-package';

// 报错
import { method } from 'commonjs-package';

Node.js 对 ES6 模块的处理就比较麻烦了,因为它有自己的 CommonJS 模块规范,与 ES6 模块格式是不兼容的。

目前两个模块方案是分开处理的,从 v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。

NodeJS 要求 ES6 模块使用 mjs 后缀文件名,只要 NodeJS 遇到 mjs 结尾的文件,就认定是ES6模块。

除了修改文件的后缀,当然也可以在项目的 package.json 文件中,指定type字段为 module。

尽管如此,require 命令不能加载 mjs 文件,只有 import 命令才可以加载 mjs 文件。

反过来 mjs文件 里面也不能使用 require 命令,必须使用 import 所以在平时开发当中,ES6 模块与 CommonJS 模块尽量不要混用。

UMD

UMD 全称 Universal Module Definition , 即 JavaScript 的通用模块定义规范。

它可以让你的代码运行在 客户端 和 服务端 环境中(夸张点说就是可以让你的代码在所有环境中运行)。

实际上 UMD 规范就是将 AMD 和 CommonJS 整合起来,使得代码在 客户端 和 服务端都能正常运行。

UMD 一般作为构建工具的产出,编写代码的时候目前主流的还是 ESM 和 CommonJS 。

UMD 的原理

一般在 库/项目 进行 build 构建后的到的产物中,我们会经常看到类是的代码:

(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' 
        ? module.exports = factory(require('vue'), require('uuid'), require('echarts')) 
        : typeof define === 'function' && define.amd 
            ? define(['vue', 'uuid', 'echarts'], factory) 
                :(global = typeof globalThis !== 'undefined' 
                    ? globalThis 
                    : global || self, global.imoocDatav = factory(global.vue, global.uuid, global.Echarts));
}(this, (function (vue, uuid, Echarts) { ... })));

实际上这段代码就是通过判断 exports 和 define 着些对象或者方法是否存在,如果存在则就会使用对应的模块规范。

总结

其实关于模块化的内容在很早之前学习的时候就整理过,不过当时并没有写成文章,而是选择在 github 中创建了个仓库,通过 issues 做的记录。

然后现在翻出来复习的同时,也增加了一些之前没了解到的知识点,说起来之前面试的时候还被问到过模块化的内容,不过间隔太久了,所以基本都忘记了,只凭借的印象回答了点内容。

现在前端开发基本上都是模块化的了,所以最少需要对主流的模块化规范有一定的了解才行。

如果文本对您有帮助,那么可以给咱一个赞吗?🥺