CC 咖啡猫的工作空间 Coding Space

模块化与包管理

从全局函数到 ES Module,模块化是前端工程化的基石。包管理工具的演进则围绕「依赖安装速度」「磁盘空间」「幽灵依赖」三个核心问题不断迭代。


1. 模块化演进

1.1 历史脉络

全局函数(2005-)
  └─ 命名冲突、依赖顺序手动维护、污染全局

IIFE(2008-)
  └─ 闭包隔离、仍无法声明依赖关系

CommonJS(2009-)
  └─ 服务端模块化、同步加载、不支持浏览器

AMD/RequireJS(2010-)
  └─ 浏览器异步模块定义、define/require、回调嵌套

CMD/Sea.js(2011-)
  └─ 按需执行、就近定义、玉伯出品

UMD(2012-)
  └─ CJS + AMD + 全局变量三合一兼容

ES Module(2015-)
  └─ 语言标准、静态分析、Tree Shaking 基础

1.2 设计动机分析

方案 核心问题 解决方案 代价
全局函数 命名冲突 命名空间(const App = {} 不强制、易覆盖
IIFE 变量泄露全局 闭包 + 立即执行 依赖顺序手动保证
CJS 浏览器不支持 同步 require + 模块作用域 不适合前端异步场景
AMD 异步加载 define 提前声明依赖 语法复杂、体积大
UMD 多种环境兼容 运行时环境检测 模板代码冗余
ESM 标准缺失 语言级支持 老旧浏览器需转译

2. CommonJS

2.1 require 加载流程

 require('./module.js')
   ↓ 1. 路径分析
     尝试: ./module.js → ./module.json → ./module.node → ./module/index.js
     查找过程: 当前目录 → node_modules → 全局 node_modules
   ↓ 2. 文件定位
     定位到绝对路径 /path/to/module.js
   ↓ 3. 检查缓存(require.cache)
     命中 → 直接返回 module.exports
   ↓ 4. 编译执行
     Node 将模块代码包裹在函数中:
     (function(exports, require, module, __filename, __dirname) {
       // 模块源码
     })
   ↓ 5. 执行后 module.exports 返回
   ↓ 6. 写入缓存 require.cache[resolvedPath] = module

Node 模块包装器

// 实际执行时的包裹
(function(exports, require, module, __filename, __dirname) {
  // 你的代码在此
  const a = require('./a');
  module.exports = { a };
});

2.2 module.exports vs exports

// exports 是 module.exports 的引用
const module = { exports: {} };
const exports = module.exports;  // 引用

// ✅ 正确:给 exports 添加属性
exports.a = 1;
exports.fn = () => {};

// ❌ 错误:重新赋值 exports 会断开引用
exports = { a: 1 };  // module.exports 仍然是空对象

// ✅ 正确做法:直接赋值 module.exports
module.exports = { a: 1 };

2.3 循环引用处理

// a.js
console.log('a 开始');
const b = require('./b');  // → 暂停 a.js 执行,进入 b.js
console.log('b 中的 val:', b.val);
module.exports.val = 'A';
console.log('a 结束');

// b.js
console.log('b 开始');
const a = require('./a');  // → 从缓存中获取 a 的「当前已执行的 exports」
console.log('a 中的 val:', a.val);  // 这里 a.val = {}(尚未赋值)
module.exports.val = 'B';
console.log('b 结束');

// 执行结果:
// a 开始
// b 开始
// a 中的 val: {}          ← 关键:a 尚未执行完,exports 为初始空对象
// b 结束
// b 中的 val: B
// a 结束

规则:CJS 循环引用返回的是已执行部分的 module.exports,未执行的导出部分是不可见的。

2.4 require.cache

// 查看所有缓存
console.log(Object.keys(require.cache));

// 删除缓存(强制重新加载模块)
delete require.cache[require.resolve('./module.js')];
const freshModule = require('./module.js');

// 缓存 key 是模块的绝对路径
require.resolve('./foo.js')  // → /Users/.../foo.js

2.5 运行时加载特性

CJS 的 require运行时加载

// 条件加载(运行时决定)
let lib;
if (process.env.NODE_ENV === 'production') {
  lib = require('./prod-lib');
} else {
  lib = require('./dev-lib');
}

// 动态 key
const lang = getLanguage();
const i18n = require(`./i18n/${lang}`);

// 变量路径(运行时解析,Tree Shaking 无法分析)
const name = 'module-' + getVersion();
const mod = require('./' + name);

3. ES Module

3.1 静态编译时分析

ESM 的 import/export 必须在模块顶层声明,不能嵌套在条件块中:

// ✅ 顶层声明
import { readFile } from 'fs';

// ❌ 语法错误:不能条件导入
if (true) { import { a } from './a'; }

// ❌ 语法错误:不能在函数内
function load() { import { b } from './b'; }

静态分析的意义

  1. 构建工具在编译时即可确定模块依赖图,无需执行代码
  2. 支持 Tree Shaking:标记未使用的导出,安全删除
  3. 支持循环引用的正确解析(比 CJS 更可靠)
  4. 支持 import() 代码分割(构建时已知分割点)

3.2 导入导出语法

具名导入导出

// export
export const a = 1;
export function fn() {}
export class Klass {}
export { a as alias };

// import
import { a, fn, Klass } from './module';
import { a as renamed } from './module';
import * as All from './module';  // 命名空间导入

默认导入导出

export default function() {}       // 匿名导出
export default { a: 1, b: 2 };     // 对象默认导出

import anyName from './module';    // 默认导入(任意命名)
import defaultExport, { named } from './module';

动态 import

// import() 返回 Promise
const module = await import('./lazy-module');
const { namedExport } = await import('./foo');

// 模板变量路径
const page = 'home';
const PageModule = await import(`./pages/${page}.js`);

import.meta

// 当前模块的元信息
console.log(import.meta.url);       // 模块文件 URL
console.log(import.meta.resolve);   // 解析模块路径(实验性)

// Vite 环境变量
const isDev = import.meta.env.DEV;

3.3 浏览器端

<!-- 标记 type="module" -->
<script type="module">
  import { createApp } from './app.js';
  createApp();
</script>

<!-- nomodule 降级 -->
<script type="module" src="main.js"></script>
<script nomodule src="main-legacy.js"></script>

ESM 在浏览器中的行为

  • defer 是默认行为(延迟执行至文档解析完毕)
  • 每个模块只执行一次(防止重复副作用)
  • CORS 限制:跨域模块需要服务端设置 Access-Control-Allow-Origin
  • 裸模块路径需要处理(浏览器不认识 import 'lodash'

3.4 ESM 与 CJS 互操作

// 从 CJS 导入 ESM(Node 环境)
// cjs-module.js
module.exports = { a: 1 };

// esm-module.mjs
import cjs from './cjs-module';   // ✅ 默认导入获取 module.exports
import { a } from './cjs-module'; // ✅ 具名导入由 Node 静态分析确定

__esModule 兼容

// 被 Babel/webpack 转译后的 CJS 导出 ESM 时:
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = {};

// 互操作逻辑:
// - 如果检测到 __esModule 标记,default 导出需要 .default 访问
// - 如果未标记,整个 module.exports 当作 default 导出
// - webpack 的 interopRequireDefault 辅助函数实现此逻辑
// webpack 生成的 interop 辅助函数
function interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

CJS 如何加载 ESM

// Node 22+ 通过 createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const esm = await require('./esm-module.mjs');  // ❌ 错误: require 不能加载 ESM

// ✅ 正确:使用动态 import()
async function loadEsm() {
  const esm = await import('./esm-module.mjs');
  return esm;
}

4. 动态 import 与代码分割

4.1 import() 语义

const module = await import('./async-module.js');

// 返回的 module 等同于 import * as module from '...'
// 包含: module.default, module.namedExport

作为代码分割触发器的原理

  • Webpack/Rollup 在编译时看到 import() 调用,自动将其作为一个 分割点(split point)
  • import() 引用的模块(及其依赖)被提取为独立的 async chunk
  • 运行时通过 JSONP / 动态 script 标签加载该 chunk

4.2 预加载指令

// webpack 魔法注释
import(/* webpackChunkName: "admin" */ './admin.js');
import(/* webpackPrefetch: true */ './analytics.js');
import(/* webpackPreload: true */ './critical-lib.js');

// 等价于添加 <link rel="prefetch" href="...">
指令 加载时机 优先级 场景
prefetch 浏览器空闲时加载 下个页面需要
preload 父 chunk 加载时并行加载 当前页面即将需要

5. 包管理

5.1 npm

语义版本

 ^1.2.3  → >=1.2.3  <2.0.0(允许兼容更新)
 ~1.2.3  → >=1.2.3  <1.3.0(只允许补丁版本)
  1.2.3  → =1.2.3(精确版本)
    *    → 任意版本
符号 锁定范围 安全风险 适用场景
^ 大版本不变 npm audit 扫描补丁漏洞 大多数依赖
~ 中间版本不变 严格兼容要求
精确 固定版本 最低 生产关键依赖
* 不锁定 最高 开发期试验

package-lock.json

 lockfileVersion: 2
 ├── packages["node_modules/react"]:
 │   version: 18.2.0
 │   resolved: https://registry.npmjs.org/react-18.2.0.tgz
 │   integrity: sha512-...
 │   dependencies:
 │     loose-envify: ^1.1.0  ← 递归锁定传递依赖
 └── ...

作用

  • 锁定所有依赖的精确版本(包括传递依赖)
  • 确保团队成员和 CI 环境安装的依赖树完全一致
  • 通过 integrity 字段校验包完整性

hoisting(依赖提升)

 项目依赖: A → B@1.0, C → B@2.0

 无 hoisting:              hoisting 后:
 node_modules/             node_modules/
 ├── A/                     ├── B@1.0/         ← 提升
 │   └── node_modules/      ├── A/
 │       └── B@1.0/         └── C/
 └── C/                         └── B@2.0/    ← 嵌套保留
     └── B@2.0/

幽灵依赖(Phantom Dependency):

// package.json 未声明 dependency,但通过 hoisting 可以 require 到
const react = require('react');  // 如果 react 被其他依赖提升到顶层

这是 hoisting 算法引入的最大问题:你可以使用未在 package.json 中声明的依赖,导致项目隐式依赖、可复现性受损。

5.2 yarn

workspaces

// package.json(根目录)
{
  "private": true,
  "workspaces": ["packages/*"]
}
 packages/
 ├── ui/         → package.json name: @myapp/ui
 ├── server/     → package.json name: @myapp/server
 └── web/        → package.json name: @myapp/web

 安装后:
 node_modules/
 ├── @myapp/ui/  → 软链接到 packages/ui
 └── react/      → hoisted

Workspaces 解决的问题

  • 复用一个 node_modules,节省磁盘空间
  • 通过软链接实现本地包间引用(开发时实时同步)
  • npx 命令在所有 workspace 中执行

Plug'n'Play(PnP)

 yarn install 后不生成 node_modules,而是:
   ├── .pnp.cjs          ← JavaScript 文件,包含依赖查找表
   └── .pnp.data.json    ← 元数据

PnP 原理

// .pnp.cjs 简化版
const locatorToZip = new Map([
  ['lodash@4.17.21', '/path/to/.yarn/cache/lodash-4.17.21.zip'],
]);

function resolveToUnqualified(packageName, issuer) {
  const locator = findLocator(packageName);
  return `${locatorToZip.get(locator)}/node_modules/${packageName}/`;
}

PnP 优点

  • node_modules,安装速度提升 70%+
  • 使用 Zip 存储,极大减少文件数(几万个文件 → 几十个 zip)
  • 严格依赖声明:未在 package.json 声明的包无法 require(幽灵依赖消除)
  • 更快的模块解析:不需要 I/O 操作遍历文件夹

PnP 代价

  • 兼容性问题:某些工具和原生模块需要额外适配
  • IDE 需要集成 PnP SDK
  • 调试时无法直接查看 node_modules 源码

5.3 pnpm

核心架构:硬链接 + 软链接

 全局存储 ~/.pnpm-store/
 └── v3/files/00/... (content-addressable)

 项目 node_modules/
 ├── .pnpm/                    ← 内部目录
 │   ├── react@18.2.0/node_modules/
 │   │   ├── react → 硬链接到 store
 │   │   └── loose-envify → 软链接到 .pnpm/loose-envify@1.4.0
 │   └── loose-envify@1.4.0/node_modules/
 │       └── loose-envify → 硬链接到 store
 └── react → 软链接到 .pnpm/react@18.2.0/node_modules/react

分层架构

 三层结构:
 1. Store(硬盘):content-addressable,所有版本只存一份
 2. .pnpm(项目):嵌套的 node_modules 结构,将每个版本的依赖分别装好
 3. 顶层(项目):软链接到 .pnpm 中的具体版本

为什么能消除幽灵依赖

 传统 npm:   node_modules/react  ← 可以 require
 pnpm:       node_modules/react  ← 软链接到 .pnpm/react@18.2.0
             但子依赖的依赖不会在顶层出现
             只有 package.json 声明的依赖在 node_modules/ 顶层

pnpm 的 node_modules 中顶层只有 package.json 声明过的依赖,而非 hoisting 后所有传递依赖,因此『未声明无法导入』。

磁盘空间优化

# 同一个包的不同版本在 store 中只存一次差异
pnpm store status    # 检查 store 完整性
pnpm store prune     # 清理未被引用的包
  • 硬链接:文件在磁盘上只有一份物理 copy,所有项目指向同一 inode
  • 内容寻址:文件以内容 hash 命名,确保唯一性
  • 跨项目共享:不同项目使用同一个包的相同版本时,共享 store

5.4 Bun

架构:Zig 编写 + JavaScriptCore 引擎。

 Bun 包管理 = npm registry 客户端 + 全局缓存 + 极致并行(256 并发请求)
   ├── 安装速度: 比 npm 快 30 倍
   ├── 无 lockfile 争用: 使用 bun.lockb(二进制格式)
   └── 原生 TypeScript: 安装时无需 tsc 编译

为什么快

  1. Zig 可直接操控内存分配和系统调用,无 GC 停顿
  2. 256 个并发 HTTP 请求下载包
  3. 使用 mmap 加速包文件解压
  4. 全局缓存策略(与 pnpm 类似但更激进)
  5. 内置 JSX/TypeScript 解析,无需额外转译层

5.5 Bun 的原生 TypeScript 支持

bun run index.ts      # 直接运行 TypeScript,无需 ts-node
bun build ./src/index.ts --outdir ./dist
bun test              # 内置 test runner(兼容 Jest API)

内部实现:Bun 使用 Zig 实现的 TypeScript 解析器(基于 swc 的 Rust bindings),解析速度是 tsc 的 20 倍+,但只做语法转译(transpile),不做类型检查(type-check)。


6. Monorepo

6.1 工具对比

工具 构建缓存 增量计算 依赖图调度 核心语言
npm workspaces JS
Yarn workspaces JS
Lerna 拓扑排序 JS
Turborepo LRU + 远程 文件 hash 任务依赖图 Go
Nx LRU + 远程 文件 hash + 计算缓存 任务依赖图 + 项目图 TS/Go
Rush 本地缓存 基于 pnpm 拓扑排序 TS
Bazel 内容寻址 + 远程 细粒度 构建图(沙盒) Java/Starlark

6.2 Turborepo 核心原理

 turbo run build --cache-dir=.turbo

 每次任务执行:
   1. 计算输入 hash(源文件 hash + 环境变量 + 配置文件)
   2. 检查本地缓存(.turbo/cache/)
   3. 未命中 → 执行 → 产物 hash 存入缓存
   4. 命中 → 从缓存恢复产物(replay)

 远程缓存: 多 CI 共享缓存(S3/Vercel)
// turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],    // 依赖包的 build 任务
      "inputs": ["src/**/*.ts"],  // 影响 hash 的文件
      "outputs": ["dist/**"],     // 缓存产物
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**/*.ts", "test/**/*.ts"],
    },
  }
}

为什么 Turborepo 比 Lerna 快

  • Go 实现,直接系统调用
  • 细粒度的 hash-based 缓存(而非全量重新执行)
  • 任务级并行(根据依赖拓扑自动确定并行度)

6.3 Nx 的核心差异化

 Nx 比 Turborepo 多了:
   ├── 项目图(Project Graph):基于 AST 分析的依赖关系(自动发现)
   ├── affected 命令:根据 git diff 自动计算受影响的包
   ├── 计算缓存 + 分布式任务执行
   └── 代码生成器(CLI 脚手架)
nx affected:test --base=main  # 只测试受本次提交影响的项目
nx graph                      # 可视化项目依赖图

依赖图自动推导

 Nx 通过分析 package.json / tsconfig 路径映射 / import 语句
 自动构建出完整的项目依赖图,用于增量构建和 affected 检测