JavaScript高级程序设计笔记 26

第 26 章 模块

学习目标:理解 JavaScript 模块化的动机、早期模块方案和 ES6 模块系统,掌握导入、导出、循环依赖与加载行为。

1. 本章核心脉络

模块化解决的是大型 JavaScript 程序如何拆分、复用、组织和加载代码的问题。没有模块系统时,脚本之间共享全局作用域,容易命名冲突、依赖混乱、加载顺序脆弱。

本章主线:

  • 模块模式的基本概念。
  • ES6 之前的模块方案:CommonJS、AMD、UMD。
  • ES6 模块:importexport、静态分析、异步加载、浏览器支持。

2. 理解模块模式

2.1 模块是什么

模块是一个相对独立的代码单元,通常具备:

  • 私有作用域。
  • 明确的依赖。
  • 明确的导出接口。
  • 可被其他模块复用。

模块化的价值:

  • 降低全局污染。
  • 提升可维护性。
  • 让依赖关系可追踪。
  • 方便测试和复用。
  • 支持构建工具做静态分析和优化。

2.2 模块标识符

模块标识符用于定位模块。

常见形式:

import util from "./util.js";
import React from "react";

可能是:

  • 相对路径。
  • 绝对路径。
  • 包名。
  • URL。

2.3 模块依赖

模块依赖描述“当前模块需要哪些其他模块”。

清晰依赖的好处:

  • 更容易理解代码关系。
  • 更容易打包和摇树优化。
  • 更容易发现无用依赖。
  • 更容易拆分和测试。

2.4 模块加载

模块加载通常包含:

  • 解析模块标识符。
  • 下载或读取模块文件。
  • 解析依赖。
  • 执行模块代码。
  • 缓存模块结果。

不同模块系统的加载时机不同:

  • CommonJS:运行时同步加载。
  • AMD:浏览器环境异步加载。
  • ES6 模块:静态声明,浏览器异步加载和执行。

2.5 入口

入口模块是应用启动的起点。构建工具会从入口开始追踪依赖图。

// main.js
import { createApp } from "./app.js";

createApp();

2.6 异步依赖与动态依赖

异步依赖可以按需加载代码:

const module = await import("./dialog.js");
module.openDialog();

动态导入适合:

  • 路由懒加载。
  • 大型功能按需加载。
  • 管理首屏包体积。

2.7 静态分析

ES6 模块的 importexport 是静态语法,必须位于模块顶层。这让引擎和构建工具可以在执行前分析依赖关系。

静态分析带来的能力:

  • 提前发现导入导出错误。
  • Tree shaking。
  • 更好的循环依赖处理。
  • 更高效的加载计划。

2.8 循环依赖

循环依赖是模块 A 依赖 B,B 又依赖 A。

// a.js
import { b } from "./b.js";
export const a = "a";

// b.js
import { a } from "./a.js";
export const b = "b";

循环依赖不一定错误,但容易导致初始化时机问题。实践中应尽量避免复杂循环,可以通过提取公共模块、依赖倒置等方式拆开。

3. 凑合的模块系统

在正式模块标准出现前,开发者常用立即调用函数表达式创建私有作用域。

const counter = (function () {
  let value = 0;

  return {
    increment() {
      value += 1;
    },
    getValue() {
      return value;
    },
  };
})();

优点:

  • 避免直接污染全局变量。
  • 可以模拟私有变量。

缺点:

  • 依赖关系不清晰。
  • 加载顺序仍需手动控制。
  • 无法被工具可靠静态分析。

4. ES6 之前的模块加载器

4.1 CommonJS

CommonJS 主要用于 Node.js。

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

module.exports = { add };
const { add } = require("./math");

特点:

  • 使用 require() 导入。
  • 使用 module.exportsexports 导出。
  • 同步加载。
  • 运行时确定依赖。
  • 模块会被缓存。

适合服务端文件系统环境,不天然适合浏览器同步网络加载。

4.2 AMD

AMD 主要面向浏览器异步加载。

define(["dep"], function (dep) {
  return {
    run() {
      dep.doSomething();
    },
  };
});

特点:

  • 异步加载依赖。
  • 适合早期浏览器环境。
  • 语法相对繁琐。

4.3 UMD

UMD 试图同时兼容 CommonJS、AMD 和全局变量方式。

适合库作者在多环境分发同一份代码。缺点是模板代码复杂,现代项目通常由构建工具处理兼容输出。

4.4 模块加载器的没落

随着 ES6 模块成为标准,浏览器、Node.js 和构建工具都逐渐围绕 ESM 建立生态。CommonJS 等方案仍存在,但新项目更推荐优先使用 ES 模块。

5. ES6 模块

5.1 模块标签及定义

浏览器中通过 type="module" 使用模块:

<script type="module" src="./main.js"></script>

模块脚本特点:

  • 默认启用严格模式。
  • 拥有独立作用域。
  • 默认延迟执行,类似 defer
  • 支持 importexport

5.2 模块加载

ES 模块会先构建依赖图,再按依赖顺序执行。浏览器会异步下载模块及其依赖,不阻塞 HTML 解析。

注意:

  • 模块只执行一次。
  • 同一个模块被多处导入时共享同一份实例。
  • 顶层 thisundefined

5.3 导出

命名导出:

export const name = "Alice";

export function sayHi() {
  console.log("hi");
}

统一导出:

const name = "Alice";
function sayHi() {}

export { name, sayHi };

重命名导出:

export { sayHi as greet };

默认导出:

export default function createApp() {}

每个模块只能有一个默认导出。

5.4 导入

命名导入:

import { name, sayHi } from "./user.js";

重命名导入:

import { sayHi as greet } from "./user.js";

命名空间导入:

import * as user from "./user.js";

默认导入:

import createApp from "./app.js";

混合导入:

import createApp, { version } from "./app.js";

5.5 转移导出

模块可以重新导出其他模块的内容,常用于统一出口文件。

export { add } from "./math.js";
export { formatDate } from "./date.js";

也可以全部转移:

export * from "./constants.js";

常见目录结构:

components/
  Button.js
  Dialog.js
  index.js
// index.js
export { default as Button } from "./Button.js";
export { default as Dialog } from "./Dialog.js";

5.6 动态导入

import() 返回 Promise:

button.addEventListener("click", async () => {
  const { openDialog } = await import("./dialog.js");
  openDialog();
});

适合代码分割和懒加载。

5.7 工作者模块

Worker 也可以使用模块形式:

const worker = new Worker("./worker.js", {
  type: "module",
});

这样 worker 内部也能使用 import

5.8 向后兼容

可通过 nomodule 为不支持模块的浏览器提供降级脚本:

<script type="module" src="modern.js"></script>
<script nomodule src="legacy.js"></script>

6. CommonJS 与 ES Module 对比

对比项 CommonJS ES Module
导入语法 require() import
导出语法 module.exports export
加载时机 运行时 静态解析
加载方式 通常同步 支持异步
适用环境 Node.js 传统生态 浏览器与现代 Node.js
Tree shaking 较困难 更友好
绑定关系 值拷贝倾向 实时绑定

7. 面试高频问题

7.1 为什么需要模块化?

模块化可以隔离作用域、声明依赖、复用代码、降低全局污染,让大型项目更容易维护和构建。

7.2 ES Module 为什么有利于 Tree shaking?

因为 ESM 的导入导出是静态语法,构建工具能在执行前分析哪些导出被使用,从而移除未使用代码。

7.3 import 和 require 有什么区别?

import 是 ESM 的静态语法,通常位于顶层,支持静态分析。require() 是 CommonJS 的运行时函数,可以在条件语句中调用,但不利于静态分析。

7.4 默认导出和命名导出怎么选?

一个模块只有一个主要能力时,可以用默认导出。一个模块提供多个明确能力时,命名导出更清晰,也更利于重构和自动导入。

7.5 动态 import 有什么用?

动态 import() 可以按需加载模块,常用于路由懒加载、弹窗、图表、编辑器等非首屏功能,能减少初始加载体积。

8. 复习清单

9. 一句话总结

第 26 章的核心是 JavaScript 从“全局脚本拼接”走向“标准模块系统”的过程;现代开发应优先理解和使用 ES Module,因为它让依赖关系、加载过程和构建优化都变得更可靠。

posted @ 2024-05-17 10:25  Li_pk  阅读(7)  评论(0)    收藏  举报