JavaScript 模块化:从历史演变到原生ESM

1. 什么是JavaScript模块化?

模块化是一种将大型代码库拆分为独立、可重用单元的软件开发实践。对于JavaScript而言,模块化解决了以下核心问题:

  • 命名空间污染:避免全局变量冲突
  • 代码复用:便于共享和重用代码
  • 依赖管理:明确声明和加载依赖关系
  • 可维护性:代码结构更清晰,便于团队协作

2. JavaScript模块化的发展历程

时间线:无模块化 → CommonJS → AMD → UMD → ES Modules

2.1 早期方案(无模块化)

// 全局函数污染
function util1() {}
function util2() {} // 可能覆盖已有函数

// IIFE模式(立即执行函数表达式)
var myModule = (function() {
  var privateVar = 'hidden';
  return {
    publicMethod: function() {}
  };
})();

2.2 CommonJS(Node.js环境)

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

// app.js
const math = require('./math.js');
console.log(math.add(2, 3)); // 5

特点:同步加载,主要用于服务器端

2.3 AMD(异步模块定义)

// math.js
define([], function() {
  return {
    add: function(a, b) { return a + b; },
    multiply: function(a, b) { return a * b; }
  };
});

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

特点:异步加载,主要用于浏览器端

2.4 UMD(通用模块定义)

// 兼容AMD、CommonJS和全局变量
(function(root, factory) {
  if (typeof define === 'function' && define.amd) {
    define(['exports'], factory);
  } else if (typeof exports === 'object') {
    factory(exports);
  } else {
    factory(root);
  }
})(this, function(exports) {
  exports.add = function(a, b) { return a + b; };
  exports.multiply = function(a, b) { return a * b; };
});

3. ES Modules(ESM)详解

3.1 术语澄清:ESM = ES模块

重要说明:ESM和ES模块是同一个概念的不同称呼,全称是ECMAScript Modules(ES模块)。它们是JavaScript语言标准的一部分(ES6/ES2015引入)。

3.2 基本语法

// 导出方式
export const name = 'value'; // 命名导出
export default function() {}; // 默认导出
export { name1, name2 }; // 导出列表
export { original as alias }; // 重命名导出

// 导入方式
import defaultExport from './module.js'; // 默认导入
import { namedExport } from './module.js'; // 命名导入
import * as module from './module.js'; // 命名空间导入
import './module.js'; // 仅执行副作用

3.3 ESM的核心特性

  1. 静态结构

    // 编译时确定依赖关系(以下代码会报错)
    if (condition) {
      import module from './module.js'; // SyntaxError!
    }
    
    // 正确的动态导入方式(返回Promise)
    if (condition) {
      const module = await import('./module.js');
    }
    
  2. 严格模式

    // 模块代码自动启用严格模式
    // 无需使用 'use strict'
    
  3. 作用域隔离

    // 模块内的顶级变量不会污染全局
    var local = 'I am local';
    
  4. 延迟执行与缓存

    // 模块只执行一次,多次导入返回相同引用
    import './module.js'; // 执行一次
    import './module.js'; // 使用缓存
    

4. ESM与传统模块系统的区别

4.1 历史演变到原生ESM的原因和过程

4.1.1 演变背景和需求

为什么需要原生ESM?

  1. 标准化的需求

    • 之前JavaScript没有官方的模块系统,社区出现了CommonJS、AMD等多种方案
    • 缺乏统一标准导致工具链复杂、学习成本高
    • 不同环境(浏览器/Node.js)需要不同的模块格式
  2. 浏览器环境的挑战

    // CommonJS在浏览器中无法直接使用(同步加载)
    const fs = require('fs'); // 浏览器中没有require函数
    
    // AMD虽然支持浏览器,但语法复杂
    define(['dep1', 'dep2'], function(dep1, dep2) {
      // 回调地狱问题
    });
    
  3. 静态分析的局限性

    // CommonJS的动态require
    const moduleName = condition ? 'moduleA' : 'moduleB';
    const module = require(moduleName); // 运行时才能确定依赖
    
    // 这使得工具难以进行静态分析和优化
    // 无法实现完美的tree shaking
    
4.1.2 演变过程

第一阶段:标准化提案(ES6/ES2015)

// 2015年,ES6引入ES Modules提案
// 核心思想:静态模块系统,编译时确定依赖关系

// 提案示例
import { readFile } from 'fs';
export function processFile() { /* ... */ }

第二阶段:浏览器逐步实现

<!-- Chrome 61+(2017年9月)开始支持原生ESM -->
<script type="module">
  import { add } from './math.js';
  console.log(add(1, 2));
</script>

第三阶段:Node.js支持

# Node.js 12+ 支持ESM
# 方式1:使用.mjs扩展名
node app.mjs

# 方式2:在package.json中设置
{
  "type": "module"
}

第四阶段:生态系统迁移

// 混合使用示例(过渡期)
// package.json中同时支持两种格式
{
  "main": "dist/index.cjs",     // CommonJS入口
  "exports": {
    "import": "./dist/index.mjs", // ESM入口
    "require": "./dist/index.cjs" // CommonJS入口
  }
}

4.2 详细对比

特性 ES Modules CommonJS AMD
加载方式 静态编译时分析 动态同步加载 动态异步加载
浏览器支持 原生支持(现代浏览器) 不支持(需打包) 通过RequireJS
Node.js支持 .mjspackage.json type字段 原生支持(.js 需要转换
树摇优化 完美支持(静态结构) 有限支持(需工具分析) 有限支持
循环依赖 支持(静态引用) 支持(动态引用) 支持
顶层this undefined exports对象 视情况而定
执行时机 异步延迟执行 同步立即执行 异步按需执行
缓存机制 模块实例单例 模块结果缓存 模块实例缓存

4.3 实际代码演变示例

从CommonJS到ESM的迁移示例

// CommonJS版本 (math.cjs)
const PI = 3.14159;

function circleArea(radius) {
  return PI * radius * radius;
}

function add(a, b) {
  return a + b;
}

module.exports = {
  circleArea,
  add,
  PI
};

// 使用
const math = require('./math.cjs');
console.log(math.circleArea(5));
// ESM版本 (math.mjs)
export const PI = 3.14159;

export function circleArea(radius) {
  return PI * radius * radius;
}

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

// 或者使用默认导出
export default {
  circleArea,
  add,
  PI
};

// 使用方式1:命名导入
import { circleArea, PI } from './math.mjs';

// 使用方式2:默认导入
import math from './math.mjs';
console.log(math.circleArea(5));

5. 原生ESM vs 编译/打包后的ESM

5.1 原生ESM(浏览器直接运行)

<!-- 浏览器中直接使用ESM -->
<script type="module">
  import { add } from './utils/math.js';
  console.log(add(1, 2)); // 3
</script>

原生ESM的限制

// 1. 必须使用完整路径或URL
import lodash from 'lodash'; // 错误:需要完整的URL或相对路径
import lodash from './node_modules/lodash/lodash.js'; // 正确

// 2. 必须包含文件扩展名
import component from './component'; // 错误:缺少扩展名
import component from './component.js'; // 正确

// 3. CORS限制
import module from 'https://cdn.example.com/module.js'; // 需要CORS支持

5.2 打包前后内容对比

5.2.1 简单模块打包示例

打包前的源代码结构

// src/math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const PI = 3.14159;

// src/utils.js
export const formatNumber = (num) => `Number: ${num}`;

// src/index.js
import { add, PI } from './math.js';
import { formatNumber } from './utils.js';

console.log(formatNumber(add(2, 3)));
console.log(`PI value: ${PI}`);

打包后的代码(Webpack默认配置)

// dist/main.js
(() => {
  "use strict";
  
  // Webpack将模块包装为函数,模块ID作为参数
  var __webpack_modules__ = {
    
    // math.js 被转换为函数
    "./src/math.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      // 标记为ES模块
      __webpack_require__.r(__webpack_exports__);
      
      // 导出绑定
      __webpack_require__.d(__webpack_exports__, {
        "add": () => (add),
        "subtract": () => (subtract),
        "PI": () => (PI)
      });
      
      // 原始模块代码
      const add = (a, b) => a + b;
      const subtract = (a, b) => a - b;
      const PI = 3.14159;
    }),
    
    // utils.js
    "./src/utils.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        "formatNumber": () => (formatNumber)
      });
      
      const formatNumber = (num) => `Number: ${num}`;
    }),
    
    // index.js
    "./src/index.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      __webpack_require__.r(__webpack_exports__);
      
      // 导入转换为__webpack_require__调用
      var _math_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/math.js");
      var _utils_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./src/utils.js");
      
      // 原始代码
      console.log((0,_utils_js__WEBPACK_IMPORTED_MODULE_1__.formatNumber)((0,_math_js__WEBPACK_IMPORTED_MODULE_0__.add)(2, 3)));
      console.log(`PI value: ${_math_js__WEBPACK_IMPORTED_MODULE_0__.PI}`);
    })
  };
  
  // Webpack的require实现
  var __webpack_require__ = (function() {
    // 模块缓存
    var __webpack_module_cache__ = {};
    
    function require(moduleId) {
      if (__webpack_module_cache__[moduleId]) {
        return __webpack_module_cache__[moduleId].exports;
      }
      var module = __webpack_module_cache__[moduleId] = {
        exports: {}
      };
      __webpack_modules__[moduleId](module, module.exports, require);
      return module.exports;
    }
    
    // 其他辅助函数...
    require.d = (exports, definition) => {
      for(var key in definition) {
        if(require.o(definition, key) && !require.o(exports, key)) {
          Object.defineProperty(exports, key, {
            enumerable: true,
            get: definition[key]
          });
        }
      }
    };
    
    require.r = (exports) => {
      if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
      }
      Object.defineProperty(exports, '__esModule', { value: true });
    };
    
    return require;
  })();
  
  // 入口点执行
  __webpack_require__("./src/index.js");
})();
5.2.2 包含外部依赖的打包示例

打包前的源代码

// src/app.js
import _ from 'lodash';
import React from 'react';

const numbers = [1, 2, 3, 4];
const sum = _.sum(numbers);

function App() {
  return React.createElement('div', null, `Sum: ${sum}`);
}

export default App;

打包后的代码(简化版)

// 假设lodash和react作为外部依赖
(() => {
  "use strict";
  
  // 外部依赖映射
  var __webpack_require__ = (function() {
    // ... 省略webpack运行时代码
  })();
  
  // 定义外部依赖的获取方式
  __webpack_require__.e = function(moduleId) {
    // 动态加载chunk的代码
  };
  
  // 模块定义
  __webpack_modules__ = {
    "./src/app.js": ((module, exports, __webpack_require__) => {
      // 外部模块通过全局变量或AMD/CommonJS方式引入
      var _ = __webpack_require__(/*! lodash */ "lodash");
      var React = __webpack_require__(/*! react */ "react");
      
      const numbers = [1, 2, 3, 4];
      const sum = _.sum(numbers);
      
      function App() {
        return React.createElement('div', null, `Sum: ${sum}`);
      }
      
      module.exports = App;
    }),
    
    // 外部模块定义
    "lodash": ((module) => {
      // 假设lodash通过script标签引入,作为全局变量
      module.exports = window._;
    }),
    
    "react": ((module) => {
      module.exports = window.React;
    })
  };
  
  // 执行入口
  var app = __webpack_require__("./src/app.js");
  // 可能将app挂载到全局或传递给其他模块
})();
5.2.3 Tree Shaking效果示例

打包前的源代码

// src/utils.js
export const usedFunction = () => 'I am used';
export const unusedFunction = () => 'I am not used';
export const anotherUsedFunction = () => 'I am also used';

// src/index.js
import { usedFunction, anotherUsedFunction } from './utils.js';
console.log(usedFunction());
console.log(anotherUsedFunction());
// 注意:unusedFunction没有被导入

打包后的代码(经过Tree Shaking)

(() => {
  "use strict";
  
  // 只有被使用的函数被包含
  const usedFunction = () => 'I am used';
  const anotherUsedFunction = () => 'I am also used';
  
  // unusedFunction被完全移除,不包含在bundle中
  
  console.log(usedFunction());
  console.log(anotherUsedFunction());
})();

5.3 现代构建工具(Vite)的差异

开发模式下(原生ESM)

// Vite开发服务器返回的代码基本保持原样
// 只是添加了HMR(热更新)相关的代码

// 原始代码
import { add } from './math.js';
console.log(add(1, 2));

// Vite处理后(开发模式)
import { add } from './math.js?t=123456789'; // 添加时间戳防止缓存
console.log(add(1, 2));

// 添加HMR支持
if (import.meta.hot) {
  import.meta.hot.accept();
}

生产模式下(打包优化)

// Vite使用Rollup进行生产构建
// 最终代码会被打包、压缩、优化

// 生产构建后的代码(经过压缩和优化)
(()=>{"use strict";const o=(o,t)=>o+t;console.log(o(1,2))})();

5.4 关键差异总结

方面 原生ESM 编译/打包后
代码结构 保持原始模块结构 模块被合并、转换
加载方式 浏览器按需加载各个模块 一次加载整个或分块bundle
路径解析 需要完整URL/路径 可以处理模块别名、node_modules
开发体验 快速启动,无需打包 需要构建时间
生产优化 有限(依赖HTTP/2) 代码分割、压缩、tree shaking
兼容性 现代浏览器 通过polyfill支持旧浏览器

6.总结

ES Modules代表了JavaScript模块化的未来方向,它提供了:

  • 标准的语言级模块语法
  • 更好的静态分析能力
  • 原生浏览器支持
  • 改进的开发体验

在实际开发中,虽然原生ESM在浏览器中可用,但生产环境通常仍需要构建工具进行:

  • 代码转换(兼容旧浏览器)
  • 依赖优化(tree shaking)
  • 性能优化(代码分割、压缩)
  • 开发者体验(热更新、错误提示)

随着工具链的演进,现代构建工具(如Vite)已能充分利用原生ESM的优势,提供更快的开发启动速度和更好的开发体验,同时保持生产环境的优化能力。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐