1. 模块化的前世今生

2009年我刚接触前端开发时,项目里的JavaScript文件总是乱成一锅粥。全局变量满天飞,函数命名得像绕口令,项目超过3000行代码就开始原地爆炸。直到模块化概念出现,就像给混乱的代码世界带来了收纳整理师。

现代模块化的核心目标是实现代码隔离依赖管理按需加载。就像把乐高积木分装在不同盒子里,需要时取出组装。目前主流的四种方案各有特色,我们通过代码示例来感受它们的差异。

2. ES Modules详解

2.1 现代浏览器的原生支持

// utils.mjs (技术栈:现代浏览器/Node.js 14+)
export const formatPrice = (num) => `¥${num.toFixed(2)}`;

export default class Calculator {
  static add(a, b) {
    return a + b * (this.taxRate || 1);
  }

  static setTaxRate(rate) {
    this.taxRate = 1 + rate;
  }
}

// main.mjs
import Calc, { formatPrice } from './utils.mjs';

Calc.setTaxRate(0.13); // 设置13%税率
console.log(formatPrice(Calc.add(100, 50))); // ¥169.50

2.2 运行原理解密

  • 静态分析:打包工具可以通过import/export语句进行tree-shaking
  • 实时绑定:导出的变量是引用而不是拷贝,这和我们熟悉的CommonJS不同
  • 严格模式:模块自动启用严格模式,避免意外的全局变量

3. CommonJS:Node.js的模块化初心

3.1 服务器端的同步哲学

// db.js (技术栈:Node.js)
const mysql = require('mysql');

const pool = mysql.createPool({
  connectionLimit: 10,
  host: 'localhost'
});

module.exports.query = (sql) => {
  return new Promise((resolve, reject) => {
    pool.query(sql, (err, results) => {
      err ? reject(err) : resolve(results);
    });
  });
};

// app.js
const { query } = require('./db');

async function getUser() {
  try {
    const users = await query('SELECT * FROM users LIMIT 1');
    return users[0];
  } catch (err) {
    console.error('数据库炸了:', err);
    throw err;
  }
}

3.2 有趣的循环依赖问题

当A模块加载B,而B又反过来加载A时,CommonJS的处理方式往往让新手困惑。这种情况下,B拿到的A模块是未完成初始化的中间状态。

4. AMD:浏览器时代的异步先驱

4.1 RequireJS实战演示

// 配置require.config.js
require.config({
  baseUrl: '/src',
  paths: {
    'jquery': 'lib/jquery-3.6.0.min'
  }
});

// math.js
define(['jquery'], function($) {
  // 展示页面加载动画
  $('#loading').fadeIn(); 
  
  return {
    sum: function(arr) {
      return arr.reduce((a, b) => a + b, 0);
    }
  };
});

// main.js
require(['math'], function(math) {
  console.log(math.sum([1, 2, 3])); // 6
  $('#loading').fadeOut();
});

4.2 依赖前置的利与弊

AMD的强制依赖声明虽然保证了执行顺序,但在开发大型应用时,配置文件的维护会成为技术债的重灾区。曾经在一个电商项目中,我们的require.config.js文件长达800多行,每次添加新模块都要胆战心惊。

5. UMD:通用模块的万能胶

5.1 实现跨环境兼容

// logger.umd.js
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' 
    ? module.exports = factory()
    : typeof define === 'function' && define.amd 
      ? define(factory)
      : (global.logger = factory());
})(this, function() {
  let logLevel = 'info';

  return {
    debug: function(msg) {
      if (logLevel === 'debug') console.debug(`[DEBUG] ${msg}`);
    },
    setLevel: function(level) {
      logLevel = level;
    }
  };
});

// 浏览器使用
logger.setLevel('debug');
logger.debug('测试信息');

// CommonJS使用
const logger = require('./logger.umd');
logger.debug('服务器日志');

// AMD环境
define(['logger.umd'], function(logger) {
  logger.debug('AMD模块调用');
});

5.2 实际项目中的妥协方案

在给开源库打包时,UMD几乎是标配。但要注意打包体积会增大20%-30%,建议配合代码压缩工具使用。

6. 四大方案对比擂台

6.1 特性对照表

特性 ES Modules CommonJS AMD UMD
加载方式 静态 同步 异步 兼容
浏览器支持 原生 不直接 需要加载器 需要加载器
Node.js支持 .mjs扩展名 原生 需转换 需转换
Tree-shaking 支持 有限 不支持 不支持
动态导入 import() require require 视情况

6.2 选择困难症指南

  • 企业级后台系统:推荐ES Modules + Webpack,利用代码拆分优化首屏加载
  • Node.js服务端:CommonJS仍然是主流选择
  • 需要兼容IE11的老项目:AMD + RequireJS仍是可靠选项
  • 开源工具库开发:UMD确保最大兼容性

7. 踩坑合集与最佳实践

  1. ES Modules的.mjs陷阱:在Node.js中使用时,建议在package.json中明确设置"type": "module"
  2. AMD配置地狱:采用分模块配置,避免单个配置文件过大
  3. 循环依赖处理:尽量通过代码重构避免,必要时用工厂函数延迟加载
  4. Webpack的魔法注释/* webpackChunkName: "lodash" */ 实现按需加载

8. 未来趋势展望

随着Vite、Snowpack等基于ESM的构建工具崛起,模块化方案的界限正在模糊。Deno的横空出世更是直接采用ES Modules作为默认标准,Node.js也在逐步实现ESM和CJS的无缝互操作。