前端模块化

前端模块化


前端 模块化 commonjs esm

CommonJS

CommonJS 中规定每个文件是一个模块。将一个 JavaScript 文件直接通过 script 标签插入页面中与封装成 CommonJS 模块最大的不同在于,前者的顶层作用域是全局作用域,在进行变量及函数声明时会污染全局环境;而后者会形成一个属于模块自身的作用域,所有的变量及函数只有自己能访问,对外是不可见的。

1. 模块

// a.js
var name = "a";
// index.js
var name = "index";
require("./a.js");
console.log(name); // index
// 可见每个模块是拥有各自的作用域的,互不干扰

2. 导出

导出是一个模块向外暴露自身的唯一方式。在 CommonJS 中,通过 module.exports 可以导出模块中的内容

module.exports = {
  name: "aa",
  add: function (a, b) {
    return a + b;
  },
};

CommonJS 模块内部会有一个 module 对象用于存放当前模块的信息,可以理解成在每个模块的最开始定义了以下对象:

var module = {...}
// 模块自身逻辑
module.exports = {...}

module.exports 用来指定该模块要对外暴露哪些内容,在上面的代码中我们导出了一个对象,包含 name 和 add 两个属性。为了书写方便,CommonJS 也支持另一种简化的导出方式—直接使用 exports。

exports.name = "aa";
exports.add = function (a, b) {
  return a + b;
};

在实现效果上,这段代码和上面的 module.exports 没有任何不同。其内在机制是将 exports 指向了 module.exports,而 module.exports 在初始化时是一个空对象。我们可以简单地理解为,CommonJS 在每个模块的首部默认添加了以下代码:

var module = {
  exports: {},
};
var exports = module.exports;

因此,为 exports.add 赋值相当于在 module.exports 对象上添加了一个属性。在使用 exports 时要注意一个问题,即不要直接给 exports 赋值,否则会导致其失效。如:

exports = {
  name: "aa",
};

上面代码中,由于对 exports 进行了赋值操作,使其指向了新的对象,module.exports 却仍然是原来的空对象,因此 name 属性并不会被导出。

注:module.exports 与 exports 混用。因为可能会被覆盖。还有实际使用时,为了提高可读性,应该将 module.exports 及 exports 语句放在模块的末尾。

3. 导入

在 CommonJS 中使用 require 进行模块导入,如:

// 在index.js中导入了a模块,并调用了它的add函数。

// a.js
module.exports = {
  add: function (a, b) {
    return a + b;
  },
};

// index.js
const fn = require("./a.js");
const sum = fn.add(2, 3);
console.log(sum); // 5

当我们 require 一个模块时会有两种情况:

1.require 的模块是第一次被加载。这时会首先执行该模块,然后导出内容。

2.require 的模块曾被加载过。这时该模块的代码不会再次执行,而是直接导出上次执行后得到的结果。

//ps: 如果想要清除缓存, 需要手动调用清除方法
delete require.cache[require.resolve("./a.js")];

我们前面提到,模块会有一个 module 对象用来存放其信息,这个对象中有一个属性 loaded 用于记录该模块是否被加载过。它的值默认为 false,当模块第一次被加载和执行过后会置为 true,后面再次加载时检查到 module.loaded 为 true,则不会再次执行模块代码。

有时我们加载一个模块,不需要获取其导出的内容,只是想要通过执行它而产生某种作用,比如把它的接口挂在全局对象上,此时直接使用 require 即可。

require("./task.js");

另外,require 函数可以接收表达式,借助这个特性我们可以动态地指定模块加载路径。

const moduleNames = ["foo.js", "bar.js"];
moduleNames.forEach((name) => {
  require("./" + name);
});

ES6 Module

// a.js
export default {
  name: "aa",
  add: function (a, b) {
    return a + b;
  },
};

// index.js
import fn from "./a.js";
const sum = fn.add(2, 3);
console.log(sum); // 5

ES6 Module 也是将每个文件作为一个模块,每个模块拥有自身的作用域,不同的是导入、导出语句。

import和export也作为保留关键字在ES6版本中加入了进来(CommonJS中的module并不属于关键字)

ES6 Module 会自动采用严格模式,

这在ES5(ECMAScript 5.0)中是一个可选项。以前我们可以通过选择是否在文件开始时加上“use strict”来控制严格模式,在ES6 Module中不管开头是否有“usestrict”,都会采用严格模式。如果将原本是CommonJS的模块或任何未开启严格模式的代码改写为ES6 Module要注意这点。

1. 导出

在 ES6 Module 中使用 export 命令来导出模块。export 有两种形式:

命名导出
默认导出

一个模块可以有多个命名导出。它有两种不同的写法:

// 写法1
export const name = "aa";
export const add = function () {};

// 写法2
const name = "aa";
const add = function () {};
export { name, add };

// 在使用命名导出时,可以通过as关键字对变量重命名。如
export { name, add as getSum };

与命名导出不同,模块的默认导出只能有一个

export defalut { // 不能有2个export defalut
    name: 'aa',
    add: function(){}
}

我们可以将 export default 理解为对外输出了一个名为 default 的变量,因此不需要像命名导出一样进行变量声明,直接导出值即可。

// 导出字符串
export defalut 'aaa'
// 导出 class
export defalut class {...}
// 导出匿名函数
export defalut function(){}

2. 导入

ES6 Module 中使用 import 语法导入模块。首先我们来看如何加载带有命名导出的模块,请看下面的例子:

// a.js
const name = "aa";
const add = function (a, b) {
  return a + b;
};
export { name, add };

// index.js
import { name, add } from "./a.js";
add(2, 3);

// 与命名导出类似,我们可以通过as关键字可以对导入的变量重命名
import { name, add as getSum } from "./a.js";
getSum(2, 3);

// 在导入多个变量时,我们还可以采用整体导入的方式。如:
import * as cal from "./a.js";
cal.getSum(2, 3);

加载带有命名导出的模块时,import 后面要跟一对大括号来将导入的变量名包裹起来,并且这些变量名需要与导出的变量名完全一致。导入变量的效果相当于在当前作用域下声明了这些变量(name 和 add),并且不可对其进行更改,也就是所有导入的变量都是只|读的。

CommonJS 和 ES6 Module 的区别

1.导出方式的不同
2.动态与静态
3.值拷贝与动态映射
4.循环依赖

1. 导出方式的不同

在 ESM 中,导入导出有两种方式:

1.具名导出/导入: Named Import/Export
2.默认导出/导入: Default Import/Export
// Named export/import
export { sum };
import { sum } from "sum";

// Default export/import
export default sum;
import sum from "sum";

而在 CommonJS 中,导入导出的方法只有一种:

module.exports = sum;

而所谓的 exports 仅仅是 module.exports 的引用而已

// 实际上的 exports
exports = module.exports;

// 以下两个是等价的
exports.a = 3;
module.exports.a = 3;

e.g.

// hello.js
exports.a = 3
module.exports.b = 4

// index.js
const hello = require('./hello')
console.log(hello)

//结果
{
    a: 3,
    b: 4
}
// hello.js
exports.a = 3;
module.exports = { b: 4 };

// index.js
const hello = require("./hello");
console.log(hello);

//结果
{
  b: 4;
}

2. 动态与静态

CommonJS 与 ES6 Module 最本质的区别在于前者对模块依赖的解决是“动态的”,而后者是“静态的”。在这里“动态”的含义是,模块依赖关系的建立发生在代码运行阶段;而“静态”则是模块依赖关系的建立发生在代码编译阶段

先看一个 CommonJS 的例子:

// calculator.js
module.exports = {
  name: "calculator",
};
// index.js
const name = require("./calculator.js").name;

在上面介绍 CommonJS 的部分时我们提到过,当模块 A 加载模块 B 时(在上面的例子中是 index.js 加载 calculator.js),会执行 B 中的代码,并将其 module.exports 对象作为 require 函数的返回值进行返回。

并且require的模块路径可以动态指定,支持传入一个表达式,我们甚至可以通过if语句判断是否加载某个模块。

因此,在CommonJS模块被执行前,并没有办法确定明确的依赖关系,模块的导入、导出发生在代码的运行阶段。

同样的例子,让我们再对比看下 ES6 Module 的写法:

// calculator.js
export const name = "calculator";

// index.js
import { name } from "./calculator.js";

ES6 Module 的导入、导出语句都是声明式的,它不支持导入的路径是一个表达式,并且导入、导出语句必须位于模块的顶层作用域(比如不能放在 if 语句中)。因此我们说,ES6 Module 是一种静态的模块结构,在 ES6 代码的编译阶段就可以分析出模块的依赖关系。 ES6 Module 它相比于 CommonJS 来说具备以下几点优势:

1.死代码检测和排除。(tree shaking)

我们可以用静态分析工具检测出哪些模块没有被调用过。
比如,在引入工具类库时,工程中往往只用到了其中一部分组件或接口,但有可能会将其代码完整地加载进来。
未被调用到的模块代码永远不会被执行,也就成为了死代码。
通过静态分析可以在打包时去掉这些未曾使用过的模块,以减小打包资源体积。

2.模块变量类型检查。

JavaScript属于动态类型语言,不会在代码执行前检查类型错误(比如对一个字符串类型的值进行函数调用)。
ES6 Module的静态模块结构有助于确保模块之间传递的值或接口类型是正确的。

3.编译器优化

在CommonJS等动态模块系统中,无论采用哪种方式,本质上导入的都是一个对象,
而ES6 Module支持直接导入变量,减少了引用层级,程序效率更高。

3.值拷贝与动态映射

在导入一个模块时,对于 CommonJS 来说获取的是一份导出值的拷贝;而在 ES6 Module 中则是值的动态映射,并且这个映射是只读的。

可以将commonjs理解为浅拷贝,esm理解为赋值

上面的话直接理解起来可能比较困难,首先让我们来看一个例子,了解一下什么是 CommonJS 中的值拷贝。

// calculator.js
var count = 0;
module.exports = {
  count,
  add: function (a, b) {
    count += 1;
    return a + b;
  },
};

// index.js
var count = require("./calculator.js").count;
var add = require("./calculator.js").add;

console.log(count); // 0(这里的count是对 calculator.js 中 count 值的拷贝)
add(2, 3);
console.log(count); // 0(calculator.js中变量值的改变不会对这里的拷贝值造成影响)

count += 1;
console.log(count); // 1(拷贝的值可以更改)

index.js 中的 count 是对 calculator.js 中 cou”nt 的一份值拷贝,因此在调用 add 函数时,虽然更改了原本 calculator.js 中 count 的值,但是并不会对 index.js 中导入时创建的副本造成影响。

另一方面,在 CommonJS 中允许对导入的值进行更改。我们可以在 index.js 更改 count 和 add,将其赋予新值。同样,由于是值的拷贝,这些操作不会影响 calculator.js 本身。

下面我们使用 ES6 Module 将上面的例子进行改写:

// calculator.js
let count = 0;
const add = function (a, b) {
  count += 1;
  return a + b;
};
export { count, add };

// index.js
import { count, add } from "./calculator.js";

console.log(count); // 0(对 calculator.js 中 count 值的引用)
add(2, 3);
console.log(count); // 1(因为是引用,所以和calculator.js中的count值保持一致)

count += 1; // 不可更改,会报错 SyntaxError: 'count' is read-only

上面的例子展示了 ES6 Module 中导入的变量其实是对原有值的动态映射。index.js 中的 count 是对 calculator.js 中的 count 值的实时反映,当我们通过调用 add 函数更改了 calculator.js 中 count 值时,index.js 中 count 的值也随之变化。

我们不可以对ES6 Module导入的变量进行更改,可以将这种映射关系理解为一面镜子,
从镜子里我们可以实时观察到原有的事物,但是并不可以操纵镜子中的影像。

4. 循环依赖

循环依赖是指模块 A 依赖于模块 B,同时模块 B 依赖于模块 A

一般来说工程中应该尽量避免循环依赖的产生

ES6 Module 的特性使其可以更好地支持循环依赖,只是需要由开发者来保证当导入的值被使用时已经设置好正确的导出值。

CommonJS 和 ES6 Module 分别如何处理循环依赖

当工程的复杂度上升到足够规模时,就容易出现隐藏的循环依赖关系。
简单来说,A和B两个模块之间是否存在直接的循环依赖关系是很容易被发现的。
但实际情况往往是A依赖于B,B依赖于C,C依赖于D,最后绕了一大圈,D又依赖于A。
当中间模块太多时就很难发现A和B之间存在着隐式的循环依赖。

e.g.

目录结构:

├── main.js
├── bar.js
├── aaa.js

commonjs 循环依赖的处理情况

// main.js
const bar = require('./bar.js')
console.log('当前是main.js内:', bar) // {}
module.exports = '在main.js内'

// bar.js
const main = require('./main.js')
console.log('当前是bar.js内:', main)
module.exports = 'bar.js内'

// 执行 node ./main.js  , 输出:
当前是bar.js内: {}  // 解析:执行到bar.js内时,main.js还没有执行完,就没有东西导出,会默认导出空对象
当前是main.js内: bar.js内

es6 module 循环依赖的处理情况

// main.js
import bar from './bar.js'
console.log('当前是main.js内:', bar)
export default '在main.js内'

// bar.js
import main from './main.js'
console.log('当前是bar.js内:', main)
export default 'bar.js内'

// 执行 webpack-dev-server xx 启服务去执行  入口是main.js  , 输出:
当前是bar.js内: undefined  // 解析:编译到bar.js内时,main.js还没有编译完,就没有东西编译映射到main这个变量上去,就是undefined
当前是main.js内: bar.js内

解决方法

1.用 es6 module

因为他的值的动态映射的,可以得到变更的状态

2.用一个 setTimeout,确保模块解析完了

// main.js
import bar from './bar.js'
console.log('当前是main.js内:', bar)
export default '在main.js内'

// bar.js
import main from './main.js'
console.log('当前是bar.js内:', main)
setTimeout(() => {
  console.log('当前是bar.js内:', main) // 这里能输出,因为main.js已经解析完了,(export default '在main.js内')这一行解析完了
})
export default 'bar.js内'

// 执行 webpack-dev-server xx 启服务去执行  入口是main.js  , 输出:
当前是bar.js内: undefined
当前是main.js内: bar.js内
当前是bar.js内: 在main.js内

其他类型模块

前面我们介绍的主要是 CommonJS 和 ES6 Module,除此之外在开发中还有可能遇到其他类型的模块。有些如 AMD、UMD 等标准目前使用的场景已经不多,但当遇到这类模块时仍然需要知道如何去处理。

1. 非模块化文件

非模块化文件指的是并不遵循任何一种模块标准的文件。如果你维护的是一个几年前的项目,那么极有可能里面会有非模块化文件,最常见的就是在 script 标签中引入的 jQuery 及其各种插件。

如何使用 Webpack 打包这类文件呢?其实只要直接引入即可,如:

import "./jquery.min.js";

这句代码会直接执行 jquery.min.js,一般来说 jQuery 这类库都是将其接口绑定在全局,因此无论是从 script 标签引入,还是使用 Webpack 打包的方式引入,其最终效果是一样的。

但假如我们引入的非模块化文件是以隐式全局变量声明的方式暴露其接口的,则会发生问题。如:

// 通过在顶层作用域声明变量的方式暴露接口
var calculator = {
  // ...
};

由于 Webpack 在打包时会为每一个文件包装一层函数作用域来避免全局污染,上面的代码将无法把 calculator 对象挂在全局,因此这种以隐式全局变量声明需要格外注意。

2. AMD

AMD 是英文 Asynchronous Module Definition(异步模块定义)的缩写,它是由 JavaScript 社区提出的专注于支持浏览器端模块化的标准。从名字就可以看出它与 CommonJS 和 ES6 Module 最大的区别在于它加载模块的方式是异步的。下面的例子展示了如何定义一个 AMD 模块。

define("getSum", ["calculator"], function (math) {
  return function (a, b) {
    console.log("sum" + calculator.add(a, b));
  };
});

在 AMD 中使用 define 函数来定义模块,它可以接受 3 个参数。

第1个参数是当前模块的id,相当于模块名;
第2个参数是当前模块的依赖,比如上面我们定义的getSum模块需要引入calculator模块作为依赖;
第3个参数用来描述模块的导出值,可以是函数或对象。如果是函数则导出的是函数的返回值;如果是对象则直接导出对象本身。

和 CommonJS 类似,AMD 也使用 require 函数来加载模块,只不过采用异步的形式。

require(["getSum"], function (getSum) {
  getSum(2, 3);
});

require 的第 1 个参数指定了加载的模块,第 2 个参数是当加载完成后执行的回调函数。

通过 AMD 这种形式定义模块的好处在于其模块加载是非阻塞性的,当执行到 require 函数时并不会停下来去执行被加载的模块,而是继续执行 require 后面的代码,这使得模块加载操作并不会阻塞浏览器。 尽管 AMD 的设计理念很好,但缺点是:

与同步加载的模块标准相比其语法要更加冗长。
另外其异步加载的方式并不如同步显得清晰,并且容易造成回调地狱(callback hell)。

在目前的实际应用中已经用得越来越少,大多数开发者还是会选择 CommonJS 或 ES6 Module 的形式。

3. UMD

我们已经介绍了很多的模块形式,CommonJS、ES6 Module、AMD 及非模块化文件,在很多时候工程中会用到其中两种形式甚至更多。有时对于一个库或者框架的开发者来说,如果面向的使用群体足够庞大,就需要考虑支持各种模块形式。

严格来说,UMD 并不能说是一种模块标准,不如说它是一组模块形式的集合更准确。UMD 的全称是 Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是 CommonJS、AMD,还是非模块化的环境(当时 ES6 Module 还未被提出)。

e.g.

// calculator
(function(global, main) {
    // 根据当前环境采取不同的导出方式
    if (typeof define === 'function' && defind.amd) {
        // AMD
        define(...)
    } else if (typeof exports === 'object') {
        // CommonJS
        module.exports = ...
    } else {
        global.add = ...
    }
})(this, function() {
    // 定义模块主体
    return {...}
})

可以看出,UMD 其实就是根据当前全局对象中的值判断目前处于哪种模块环境。当前环境是 AMD,就以 AMD 的形式导出;当前环境是 CommonJS,就以 CommonJS 的形式导出。

需要注意的问题是,UMD 模块一般都最先判断 AMD 环境,也就是全局下是否有 define 函数,而通过 AMD 定义的模块是无法使用 CommonJS 或 ES6 Module 的形式正确引入的。

  • 在 Webpack 中,由于它同时支持 AMD 及 CommonJS,也许工程中的所有模块都是 CommonJS,而 UMD 标准却发现当前有 AMD 环境,并使用了 AMD 方式导出,这会使得模块导入时出错。
  • 当需要这样做时,我们可以更改 UMD 模块中判断的顺序,使其以 CommonJS 的形式导出即可。

内容转载自 https://juejin.cn/post/6959360215674257415

© 2025 Niko Xie