小探一下前端模块化导入:再对比一下 CJS 和 ESM

347 阅读10分钟

模块化导入的背景与需求

代码可维护性与复用性

随着前端应用程序日益复杂,模块化成为提升代码组织、可维护性和复用性的关键技术。模块化允许我们开发者可以将代码分割成独立的、可管理的部分,每个部分负责特定的功能。这不仅提高了代码的可读性和可维护性,还促进了团队协和,减少了代码冲突的可能性。

全局明明空间污染问题

在模块化出现之前,JavaScript文件通过<script> 标签直接引入,所有变量和函数都暴露在全局命名空间中。这种方式简单直接,但随着项目规模的扩大,容易引起命名冲突导致代码维护困难。模块化通过创建私有作用域,封装模块内部的变量和函数,避免了全局命名空间的污染,减少了冲突。

依赖管理的挑战

在复杂的前端项目中,模块之间的依赖关系往往非常复杂。手动管理这些依赖关系不仅容易出错,还会导致代码难以维护。模块化通过明确的导入和导出机制,使得依赖关系清晰可见。自动化工具可以更有效的处理依赖关系,提升代码的可维护性和可靠性。

背景总结

模块化说到底就是解决了因代码量的增多,而引发的代码维护性和可读性下降等问题。有了模块化之后,开发工作能够按照不同模块分别开展,而且依赖管理相关的方法也可被抽取出来实现复用,可以提高开发效率和代码质量,让整个前端项目结构清晰,开发和维护更加有序、高效。

主要的模块系统

CommonJS(CJS)

CommonJS(CJS) 是一种同步加载模块的规范,最早由服务器端(Node.js)开发者提出。CJS 是为了让 Node 可以有类似其他后端语言如 Java、Python 的模块系统。使代码组织和复用更加便捷。CJS 的出现主要是为了解决服务器端模块化需求,特别是在 Node.js 中实现高效的模块加载和管理。

使用方法

// math.cjs
function add(a, b) {
    return a + b;
};

module.exports = { add }; // 模块导出使用 module.exports 或 exports 对象

// app.cjs
const math = require('./math.cjs'); // 同步加载:模块在运行时通过 require 函数同步加载,适合服务器端环境
console.log(math.add(2, 3)) // 输出 5

主要应用场景

  1. Node.js 环境: CJS 是 Node.js 默认的模块系统,几乎所有的 Node.js 模块(尤其是早期模块)都采用 CJS。
  2. 旧的前端项目: 一些早期采用模块化的前端项目仍在使用 CJS,尤其是通过打包工具(如 Browserify)进行模块打包的项目

Asynchronous Module Definition(AMD)

Asynchronous Module Definition(AMD) 是一种异步加载模块的规范,主要为了解决浏览器环境中模块加载的性能问题。AMD 由 RequireJS 等库广泛应用,就是为了通过异步加载模块,优化前端应用的性能和用户体验。AMD 的出现填补了浏览器端模块化的空白,使得前端开发能够更加模块化和高效。

使用方法

// index.js
require.config({
    baseUrl: 'js/lib',
    paths: {
        lodash: 'lodash.min.js' // 实际路径 js/lib/lodash.min.js
    }
})

// math.js
define(['lodash'], function (_) { // 引入 lodash 依赖
    function add(a, b) {
        return _.add(6, 4); // 使用 lodash 依赖
    }
    
     return { add }
})

require(['math'], function (math) {
    console.log(math.add(2, 3))
})

主要应用场景

旧的浏览器项目: 在 RequireJS 等库的支持下,AMD 被广泛应用于需要异步加载模块的浏览器项目。

Universal Module Definition(UMD)

Universal Module Definition(UMD) 是一种兼容多种模块系统的规范,为了在使模块能够在不同环境(浏览器环境、Node 环境)中通用。UMD 通过检测当前还进个,选择合适的模块加载方式(如 CJS、AMD 或全局变量),提高了模块的通用性和兼容性。UMD 的出现使为了解决跨环境模块化需求,尤其是在构建需要再多种环境运行的库和框架时提供便利。

使用方法

// 自动检测运行环境,根据当前运行环境选择合适的模块加载方式
// math.js
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD 环境
        define(['lodash'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // CommonJS 环境
        module.exports = factory(require('lodash'));
    } else {
        // 浏览器全局环境
        root.math = factory(root._);
    }
}(typeof self !== 'undefined' ? self : this, function (lodash) {
    function add(a, b) {
        return lodash.add(a, b);
    }

    return {
        add: add
    };
}));

AMD 环境中使用


// main.js
require.config({
    paths: {
        'lodash': 'lib/lodash.min'
    }
});

require(['math'], function(math) {
    console.log(math.add(2, 3));  // 输出: 5
});

CommonJS 环境中使用

// main.js
const math = require('./math');
const _ = require('lodash');

console.log(math.add(2, 3));  // 输出: 5

浏览器环境中使用

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>UMD Example with Lodash</title>
    <script src="lib/lodash.min.js"></script>
    <script src="math.js"></script>
</head>
<body>
    <h1>UMD Example with Lodash</h1>
    <script>
        console.log(math.add(2, 3));  // 输出: 5
    </script>
</body>
</html>

主要应用场景

  1. 跨环境库和框架: UMD被涉及为兼容多种模块系统,使得库和框架能够在浏览器和 Node.js 环境中无缝运行
  2. 需要在浏览器和 Node.js 中通用的库: 例如,许多第三方库(如 Underscore ) 采用 UMD,以确保在各种环境中均可使用

ECMAScript Modules(ESM)

ECMAScript Modules(ESM) 是 JavaScript 官方标准的模块化系统,于 ES6(ECMAScript2015) 中正式引入。ESM 为了统一前端和后端的模块化方式,支持静态分析和现代优化技术,如 tree shaking 和代码分割。ESM 的出现是为了取代 CJS 和 AMD,提供一个更现代化、标准化的模块系统,适应日益复杂的前端开发需求

使用方法

// math.js
import _ from 'lodash';

export function add(a, b) {
    return _.add(a, b);
}

// main.js
import { add } from './math.js';

console.log(add(2, 3));  // 输出: 5

主要应用场景

  1. 现代前端项目: 几乎所有现代前端框架(如 React、Vue、Angular)。
  2. 支持 ESM 的构建工具: 如 Webpack、Rollup、Vite 都全面支持 ESM。
  3. Node.js 环境: 从 Node v8 支持 ESM,允许在服务器端使用 ESM 语法。

CommonJS 与 ESM 的深入对比

引入方式的不同

上面已经介绍过了,就不赘述了,一个require一个import

不同的加载方式

  1. CJS 的同步加载

    • CJS 使用requier进行模块加载,这种方式是同步的,即在模块加载完成之前,程序会阻塞执行(但得益于服务端读取文件的快速,可以忽略)
    • 适用于服务端,如 Node.js,因为同步加载效率较高。
  2. ESM 的异步加载

    • ESM 使用import语句进行模块加载,设计为异步,以避免阻塞主线程,特别适合浏览器环境。
    • 支持动态导入import(),返回一个 Promise,允许在运行时异步加载模块。
    • 在 Node.js 中,ESM 也是异步加载的。
  3. 打包工具的影响

    • 打包工具如 Webpack 或 Rollup 在构建时同步处理 ESM 模块,生成一个或几个打包文件
    • 虽然打包文件可能在浏览器中同步加载,但模块内部的 ESM 行为仍然时异步的。
    • 打包工具优化模块依赖,提高交付效率,但不改变 ESM 的异步本质。
  4. 模块解析与优化

    • CJS 基于文件路径解析模块,适合同步环境。
    • ESM 基于 URL 解析模块,适合异步环境,并且其静态的importexport 语句使得 tree shaking 更有效。

值的引用和值的拷贝

  • CommonJS:导入的的是值的拷贝(Copy)。
  • ESM:导入的是值的引用

CommonJS的值拷贝行为

特点

  • 使用require导入模块
  • 导入的变量(如数字、字符串)是拷贝,不会随着模块内部的值的变化而更新。
  • 如果导入的是对象或数组等引用类型,它们的引用会保持同步

示例 1:导入基本类型(拷贝行为)

// cjsModule.js
// 定义一个变量和一个修改函数

 let count = 0;
 function increamentCount() {
     count++;
 }
 
 module.exports = {
     count,
     incrementCount
 }
// cjsTest.js
// 导入模块
const { count, incrementCount } = requier('./cjsModule');

// 初始值
console.log(‘初始值’, count) // 输出 0

// 调用模块的函数修改 count
incrementCount();

// 再次查看 count
console.log('更新后的count', count); // 输出 0(未更新)

分析

  • count 是一个数字(基本类型),被拷贝到使用方。
  • incrementCount 修改了模块内部的count,但导入方的count不会随之更新,因为他是一个拷贝的值。

示例 2:导入引用类型(保持同步)

// cjsModule.js
// 定义一个对象和一个修改函数
const counter = { value: 0 };

function incrementCounter() {
    counter.value++
}

module.export = {
    counter,
    incrementCounter
}
// cjsTest.js
// 导入模块

const { counter, incrementCounter } = require('./cjsModule');

// 初始值
console.log('Initial counter value:', counter.value); // 输出 0

// 调用模块的函数修改 counter
incrementCounter();

// 再次查看 counter.value
console.log('Updated counter value:', counter.value); // 输出 1(已更新!)

分析

  • counter 是一个对象(引用类型),导入方和模块内部共享同一个引用。
  • 当模块内的counter.value被修改时,导入方可以立即反应变化

ESM的引用行为

特点

  • 使用import 导入模块
  • 导入的所有导出值,无论是基本类型还是引用类型,都是引用
  • 模块的任何修改都会实时反映在导入方。

示例 1:导入基本类型(引用行为)

// esmModule.js
// 定义一个变量和一个修改函数
export let count = 0;

export function incrementCount() {
  count++;
}
// esmTest.js
// 导入模块
import { count, incrementCount } from './esmModule.js';

// 初始值
console.log('Initial count:', count); // 输出 0

// 调用模块的函数修改 count
incrementCount();

// 再次查看 count
console.log('Updated count:', count); // 输出 1(已更新!)

分析

  • count 是一个引用,直接指向模块内部的变量
  • incrementCount修改模块的count值,导入方的count 立即反映变化

示例 2:导入引用类型(保持同步)

// esmModule.js
// 定义一个对象和一个修改函数
export const counter = { value: 0 };

export function incrementCounter() {
  counter.value++;
}
// esmTest.js
// 导入模块
import { counter, incrementCounter } from './esmModule.js';

// 初始值
console.log('Initial counter value:', counter.value); // 输出 0

// 调用模块的函数修改 counter
incrementCounter();

// 再次查看 counter.value
console.log('Updated counter value:', counter.value); // 输出 1(已更新!)

分析

  • 和 CommonJS 一样,引用类型(如对象)的导入保持同步。
  • 但不同的是,ESM 对所有导出都是引用,包括基本类型。

总结

特性CommonJS(CJS)ECMAScript Module(ESM)
导入语法const mod = require('./mode')import mode from './mod'
导出语法module.exports = { foo };exports.foo = foo;export default fooexport const foo = foo
****
加载时机运行时加载: 依赖是在代码执行时动态解析的编译时加载: 依赖关系在编译阶段就被解析
导入类型值拷贝(基本类型),引用(引用类型)引用,所有导入均实时同步
是否支持 Tree-shaking不支持:所有导出的代码都会被引入支持:未使用的导出代码可以被打包工具剔除
兼容性Node.js 原生支持Node.js 12+ 和现代浏览器原生支持
动态导入动态导入可以直接用require支持通过import()实现动态导入
异步加载不支持: require是同步加载支持: 通过import()可以实现异步加载
作用域模块内是独立作用域模块内是独立作用域
适用场景Node.js 后端开发,老旧工具链前端开发,现代工具链,浏览器环境
输出文件结构module.exports 是整个导出的对象多个命名导入和默认导出,静态结构
性能模块加载时同步的,适合后端静态加载 + 异步支持,适合现代前端需求

ESM一统江湖

在现代 JavaScript 的江湖里,ESM(ECMAScript Module)早已挑起了“一统江湖”的大梁,从前端到后端,ESM 正以迅雷不及掩耳盗铃之势的速度接管模块化的地位,逐步取代CommonJS(CJS)

  • 标准出身,根正苗红,ESM 是 ECMAScript 的官方标准,由 TC39 提出,从 ES6 开始引入,正统性无可置疑
  • 原生支持,现代浏览器原生支持<script type="module">
  • Node.js 自 12+ 版本全面支持,通过.mjs 或者package.json"type": "module" 声明
  • ESM 的静态加载特性让打包工具(如 Rollup、Webpack)能够在编译阶段解析模块依赖关系。
  • 支持 Tree-Shaking,未使用的代码可以被打包工具剔除,生成更小的产物。
  • 支持 import() 动态导入,适合懒加载和按需加载场景。
  • 前后端统一模块规范,减少开发的心智负担。

主要也是面试的时候被问过几次,就看了一下,如果文中有不对的地方,欢迎指正