Dark Dwarf Blog background

js-module

JavaScript 模块化

1. 模块化的演进

a.a. 全局作用域问题

在 ES6 模块出现之前,如果在 HTML 中引入多个 JavaScript 文件,它们会共享同一个全局作用域。这会导致变量命名冲突和不可预期的行为。

<!-- index.html -->
<script src="one.js"></script>
<script src="two.js"></script>
// one.js
const myVar = 'Hello from one.js';

// two.js
console.log(myVar); // 输出 "Hello from one.js"
// one.js 中定义的 myVar 污染了全局作用域

这种“污染”使得项目在变得复杂时,维护变得异常困难。

b.b. IIFE:早期的模块模式

为了解决全局作用域问题,开发者们采用了一种被称为“模块模式”的技巧,其核心是 IIFE (Immediately Invoked Function Expression),即立即执行函数表达式。

IIFE 本质上是一个定义后立即执行的匿名函数,它创建了一个私有作用域

// one.js
const Formatter = (() => {
  // greeting 和 farewell 都是私有变量,不会泄露到全局
  const greeting = 'Hello';
  const farewell = 'Goodbye';

  const sayHello = (name) => `${greeting}, ${name}!`;
  const sayGoodbye = (name) => `${farewell}, ${name}!`;

  // 通过返回一个对象,选择性地“暴露”公共接口
  return {
    sayHello,
    // sayGoodbye 没有被返回,因此是完全私有的
  };
})();

// two.js
// 只能访问 Formatter 对象上暴露的 sayHello 方法
console.log(Formatter.sayHello('Odinite')); // "Hello, Odinite!"
// console.log(Formatter.sayGoodbye('Odinite')); // TypeError: Formatter.sayGoodbye is not a function

通过 IIFE,我们可以手动控制哪些变量和函数是私有的,哪些是公开的。这是 ES6 模块诞生前最流行的模块化方案。

2. ES6 模块 (ESM)

a.a. 概述

ES6 在语言层面引入了真正的模块系统,通常称为 ES 模块(ESM)。它带来了几个核心优势:

  1. 独立作用域:每个模块文件都有自己独立的顶级作用域,而不是全局作用域。
  2. 明确的依赖关系:通过 importexport 关键字,我们可以清晰地声明模块之间的依赖关系。
  3. 自动延迟执行:浏览器加载模块脚本时,会自动应用 defer 行为,确保在文档解析完毕后按顺序执行。

b.b. exportimport 语法

ESM 提供了两种导出/导入机制:命名(Named)默认(Default)

i.i. 命名导出

一个模块可以有多个命名导出。导出时可以使用 export 关键字,或者在文件末尾用 {} 统一导出。

// utils.js

// 方式一:在声明时直接导出
export const PI = 3.14159;
export function add(a, b) {
  return a + b;
}

// 方式二:在文件末尾统一导出
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
export { subtract, multiply };

b.b. 命名导入

使用命名导入时,必须使用与导出时完全相同的名称,并用 {} 包裹。

// main.js
import { PI, add, subtract } from './utils.js';

console.log(PI); // 3.14159
console.log(add(5, 3)); // 8
console.log(subtract(5, 3)); // 2

// 也可以使用 `as` 关键字给导入的成员起一个别名
import { multiply as mul } from './utils.js';
console.log(mul(5, 3)); // 15

注意:import { ... } 语法是模块的特殊语法,它不是对象解构。

c.c. 默认导出

每个模块最多只能有一个默认导出。它通常用于导出一个模块的主要功能,比如一个类或一个函数。

// logger.js
function log(message) {
  console.log(`[LOG] ${message}`);
}

// 导出这个函数作为默认值
export default log;

d.d. 默认导入

导入默认导出的值时,不需要使用 {},并且可以为其指定任意名称

// main.js
import myLogger from './logger.js'; // 可以是任意合法的变量名

myLogger('Application started.'); // "[LOG] Application started."

一个模块可以同时拥有命名导出和默认导出。

// formatter.js
export default function format(text) { // 默认导出
  return text.toUpperCase();
}

export const version = '1.0'; // 命名导出

导入时,默认导入的名称必须在命名导入之前。

// main.js
import toUpperCase, { version } from './formatter.js';

console.log(toUpperCase('hello')); // "HELLO"
console.log(`Version: ${version}`); // "Version: 1.0"

4. 在 HTML 中使用模块

要让浏览器以模块方式加载 JavaScript,我们需要在 <script> 标签中添加 type="module"

只需要链接项目的入口文件(Entry Point)。浏览器会自动分析 import 语句,加载所有相关的依赖模块。

<!DOCTYPE html>
<html>
<head>
  <!-- 不再需要链接所有 js 文件 -->
  <!-- <script src="utils.js"></script> -->
  <!-- <script src="logger.js"></script> -->
  
  <!-- 只需要链接入口文件,并声明 type="module" -->
  <script type="module" src="main.js"></script>
</head>
<body>
  <h1>ESM Demo</h1>
</body>
</html>

5. CommonJS

后端开发会使用 CommonJS (CJS) 模块系统,它是 Node.js 传统的模块标准。

特性ES 模块 (ESM)CommonJS (CJS)
语法import / exportrequire() / module.exports
加载方式异步加载 (顶层 await 可用)同步加载 (读取本地文件)
环境浏览器和现代 Node.js传统 Node.js
this 指向模块顶层 thisundefined模块顶层 this 指向 module.exports
// ESM 示例
import fs from 'fs';
export const myVar = 42;

// CJS 示例
const fs = require('fs');
module.exports.myVar = 42;

对于前端开发,ESM 是标准。对于后端 Node.js 开发,虽然 CJS 仍广泛使用,但 ESM 正在成为未来的趋势。