ES6 模块与 CommonJS 模块

216 阅读9分钟

导出和导入

ES6

关键字:importexport

// 导出
// es6-module.js

export const myVar = '导出变量';

export function myFunction() {
  console.log('导出函数');
}

export default function() {
  console.log('默认导出');
}
// 导入
// es6-test.js

// 导入单个或多个变量或函数
import { myVar, myFunction } from './es6-module.js';

// 导入默认变量或函数
import defaultFunction from './es6-module.js'

console.log(myVar)
myFunction()
defaultFunction()

因为一个 js 文件中只能有一个 export default,所以 import default 的时候不需要加大括号

CommonJS

关键字:require()module.exports

// 导出
// commonJS-module.js

const myVar = '导出变量';

function myFunction() {
  console.log('导出函数');
}

module.exports = { myVar, myFunction };
// 导入
// commonJS-test.js

// 导入单个或多个变量或函数
const { myVar, myFunction } = require('./commonJS-module.js');

console.log(myVar);
myFunction();

注意:

CommonJS 模块只能在 Node.js 环境中执行,不能直接允许在浏览器环境。虽然我们在开发的时候会使用部分 CommonJS 的相关代码,但是在构建和打包过程中会利用Webpack、Vite 或 Rollup 等工具将 CommonJS 模块转换为浏览器兼容的 ES6 模块。

加载方式

编译时加载和运行时加载

编译时加载

编译时加载是指在代码编译(或转译)阶段就确定并加载所需的资源或模块。在这个过程中,编译器或构建工具会分析代码中的依赖关系,并将这些依赖在编译时就整合到最终的输出文件中。

  • 优点:

    • 可以在编译阶段就进行依赖管理和优化,例如通过代码拆分、树摇(tree shaking)等技术来减少最终打包文件的大小。

    • 编译时加载通常意味着代码在运行时能够更快地获取到所需的资源,因为所有依赖资源都已经在编译阶段准备好了。

  • 缺点:

    • 需要一个构建步骤来预处理代码

    • 如果依赖关系在编译后发生变化(例如,动态引入的模块),则可能需要重新编译整个项目。

运行时加载

运行时加载是指在代码执行过程中,根据实际需要动态地加载所需的资源或模块。这通常通过某种形式的动态加载机制(如 require 函数、import() 函数等)来实现。

  • 优点:

    • 可以根据需要动态地加载和卸载资源,从而节省内存和带宽。

    • 可以在不重新编译整个项目的情况下更新或替换部分代码。

  • 缺点:

    • 如果加载的资源过多或过大,可能会影响应用的启动时间和性能。

在实际开发中,编译时加载和运行时加载通常会结合使用。例如,在构建大型应用时,可能会使用编译时加载来预加载大部分核心资源,而使用运行时加载来动态加载一些非核心或按需加载的资源。

ES6

编译时加载

  • ES6 模块是在编译时加载的。这意味着,在代码执行之前,模块的依赖关系就已经被确定,并且只加载需要的模块部分。

  • 由于模块是在编译时加载的,因此可以进行静态优化,提高代码的执行效率。

JavaScript 是一种解释型、直译式脚本语言,浏览器会直接解释源代码并运行,因此 JavaScript 不需要编译,那为什么说 ES6 模块是在编译时加载的呢?

当 JavaScript 代码包含 ES6 模块时,通常需要使用构建工具(如 Webpack、Rollup 等)或现代 JavaScript 引擎(如 V8 引擎在 Chrome 浏览器中的实现)的模块加载器来处理这些模块。这些工具或引擎在加载和执行模块之前,会首先解析 importexport 语句,确定需要加载哪些模块,并生成一个模块加载计划。这个计划包括了模块的加载顺序和依赖关系,确保了模块在执行之前已经被正确加载和解析。

这个过程之所以被称为“编译时加载”,是因为它发生在代码执行之前的阶段,并且涉及到模块依赖关系的静态分析和解析。

什么是静态优化?

静态优化发生在程序编译时,因此可以对整个程序进行全局分析,识别潜在的性能问题。如模块合并、树摇、常量折叠、代码拆分等。

异步加载(在模块解析阶段)

  • 虽然 ES6 模块的 import 语句在语法上是同步的(即在编写代码时看起来像是同步加载),但实际上在模块解析阶段,ES6 模块系统具有异步加载的能力。它有一个独立的模块依赖解析阶段,可以在代码执行前完成模块的加载和解析。

静态接口

  • ES6 模块不是对象,而是通过 export 命令显式指定输出的代码。这些输出在编译时就已经确定,形成一个静态接口。

  • 在导入模块时,只加载指定的方法或属性,其他部分不会被加载。

// es6-module.js

function A() {
    //...
}
function B() {
    //...
}
function C() {
    //...
}

export function D() {
    //...
}

export default {
    A, B, C
}
// test.js

import test from ./es6-module.js

test.A()

假设开启了树摇,B C D 在编译时会被移除吗?

答案:B 和 C 被包含在默认导出的对象中,虽然并未被直接使用,但是由于它们被包含在一个被使用的对象中,树摇机制不会单独移除它们,因为默认导出的对象是一个整体。D 会被移除。

动态只读引用

  • ES6 模块总是以引用的形式传递导出的数据,无论是基本类型还是复杂类型。这意味着无论何时何地修改了某个已导出的值,所有使用该值的地方都将看到最新的状态变化。

  • 不过需要注意的是,ES6 模块提供的引用是只读的,不允许直接修改导入的内容

// es6-module.js

export let count = 0;
// test.js

import { count } from "./es6-module";

let num = count++; // 只读报错:Assignment to constant variable.
console.log("num:", num);
console.log("count:", count);

正确写法:

// es6-module.js

export let count = 0;

export function add() {
    count++
}    
// test1.js

import { count, add } from "./es6-module";

console.log("count1:", count); // 0
add()
console.log("count2:", count); // 1

假设还有个 test2.js 文件

// test2.js

import { count, add } from "./es6-module";

console.log("count3:", count); // 1

对于 ES6 模块而言,它保持了对导出内容的真实引用,这使得 ES6 模块能够在多个地方共享同一个实例的同时保证一致性。因此可以认为 ES6 模块是引用传递

CommonJS

运行时加载

  • CommonJS 模块是在运行时加载的。这意味着,当模块被 require 函数引入时,模块的代码才会被执行,并且模块对象是在代码执行后才生成的。

  • 由于模块是在运行时加载的,因此无法在编译时进行静态优化。

同步加载

  • CommonJS 模块使用 require 函数进行同步加载。这意味着,在引入模块时,程序会等待模块加载完成后再继续执行。

模块对象

  • CommonJS 模块实际上是一个对象,模块的属性和方法都是这个对象的成员。

  • 当模块被加载时,它的所有方法和属性都会被加载到内存中,形成一个完整的对象。

缓存机制

  • CommonJS 模块在第一次加载后会被缓存起来,后续再引入相同的模块时,会直接返回缓存中的模块对象,而不会重新执行模块代码。
// commonJS-module.js

console.log('commonJS-module.js 加载...')

let count = 0;

function add() {
    count++
}

function getCount() {
    return count
}

/*
创建一个 module.exports 对象,
该对象包含 count(初始化为0),add 函数和 getCount 函数
*/
module.exports = { count, add, getCount };
// test.js

// 获取 module.exports 对象的一个引用,并赋值给 test 变量
const test = require('./commonJS-module');

console.log("count1", test.getCount()); // 0

console.log("count2", test.count); // 0

test.add();

console.log("count3", test.getCount()); // 1

/*
尽管 count 在模块内部被修改了,
但直接通过 test.count 访问的值仍然是 0,
因为 test.count 的值在初始化时就已经确定,
并不会因为 add 函数的调用而更新。
*/
console.log("count4", test.count); // 0

/*
由于模块已经被缓存,Node.js 不会重新执行 commonJS-module.js。
相反,它返回同一个 module.exports 对象的另一个引用。
因此,test 和 test2 引用的是同一个对象。
*/
const test2 = require('./commonJS-module');

console.log("count5", test.count); // 0

console.log("count6", test.getCount()); // 1

/*
直接修改 test2.count 的值,
这实际上是修改了 module.exports.coun t属性的值
(但请注意,这不会影响 getCount 函数内部使用的 count 变量)
*/
test2.count++

// test2.count 现在是 1,因为我们刚刚增加了它
console.log("count7", test2.count); // 1

/*
尽管 module.exports.count(即 test2.count)被修改为 1,
但 getCount 函数仍然返回模块内部维护的 count 值(1),
这个值是通过 add 函数增加的,
而不是通过直接修改 module.exports.count。
*/
console.log("count8", test2.getCount()); // 1

image.png

可以看到:"commonJS-module.js 加载..." 只被打印了一次,count5 值是 1。

这是因为 CommonJS 模块在第一次加载后会被缓存,当 require('./commonJS-module.js') 第二次被调用时,Node.js 不会重新加载和执行 commonJS-module.js 文件,而是直接返回缓存中的模块对象。

对象引用

ES6

上面已经说了,ES6 模块是引用传递,即

  • 无论何时何地修改了某个已导出的值,所有使用该值的地方都将看到最新的状态变化。(导出的值只读的,其实无法修改)

  • 能够在多个地方共享同一个实例的同时保证一致性

CommonJS

  • 对于基本数据类型,是值传递

  • 对于引用数据类型,传递的是指向该对象内存地址的引用。即

    • 如果你在一个模块中导出了一个对象,并且另一个模块引入了这个对象,那么两个模块实际上是共享同一个对象实例的。

    • 这意味着如果一个模块修改了这个对象的内容,所有持有该对象引用的地方都会看到这些变化。

    • 这里更像是引用传递,但严格意义上讲,CommonJS 仍然实现了值传递,只是传递的是对象的引用而不是对象本身

  • 特殊情况:module.exports 被重新赋值

    • 无论是被重新赋值为基本数据类型还是引用数据类型,任何之前已经通过 require() 获取到该模块的代码将不会受到新赋值的影响,因为它们持有的是对旧对象的引用。

    • 只有新的 require() 调用才会得到更新后的 module.exports

// 导出基本数据类型
module.exports = 1;

// 导出引用数据类型
module.exports = { message: 'Hello' };

// 重新赋值
module.exports = { message: 'Hello' };
setTimeout(() => {
    module.exports = { message: 'Hi' }
}, 1000);

循环依赖处理

ES6

编译时会进行循环依赖处理,即将模块中的循环依赖转换成静态的拓扑结构。这使得 ES6 模块能够更好地处理循环依赖的情况。

CommonJS

无法很好地处理循环依赖。在循环依赖的情况下,CommonJS 模块只输出已经执行的部分,还未执行的部分不会输出。这可能会导致一些不可预见的问题。