JavaScript 模块化与工程化

2024-09-04 前端

引言

JavaScript 诞生于 1995 年,最初只是浏览器中的一门"小脚本语言",用于完成表单验证、动画特效等简单任务。彼时,一个网页可能只有几十行 JavaScript 代码,模块化根本不是问题。但随着 Web 应用从"网页"进化到"应用"(SPA、PWA、SSR),代码量从几十行膨胀到几十万行,模块化与工程化成为前端开发的基石。

本文将系统梳理 JavaScript 模块化的演进历史,深入剖析 CommonJS、ES Module 两大主流规范,详解 Webpack、Vite、Rollup、esbuild 等构建工具的原理与用法,并涵盖 Babel、TypeScript、代码分割、环境管理和工程化最佳实践。每个知识点均配有完整可运行的代码示例。


1. 模块化演进历史

1.1 全局变量模式(远古时代)

最早的 JavaScript 代码全部写在全局作用域中,通过全局变量共享数据。

<!-- a.js -->
<script>
  var name = 'Alice';
  function greet() {
    console.log('Hello, ' + name);
  }
</script>

<!-- b.js -->
<script>
  var name = 'Bob'; // 覆盖了 Alice!
  function greet() {
    console.log('Hi, ' + name);
  }
</script>

问题:全局命名冲突、依赖关系隐式、无作用域隔离。

1.2 命名空间模式

通过对象字面量将代码分组,减少全局变量数量。

// namespace.js
var MyApp = {
  config: { apiUrl: '/api' },
  utils: {
    formatDate: function (date) {
      return date.toISOString().slice(0, 10);
    },
  },
  models: {},
  views: {},
};

// 使用
MyApp.utils.formatDate(new Date()); // '2024-09-04'

进步:减少全局变量数。不足:仍无法避免内部属性被外部篡改,依赖顺序仍靠手动管理。

1.3 IIFE 模块模式(立即执行函数)

利用 JavaScript 的函数作用域实现私有变量,是模块化的雏形。

IIFE 变体——揭示模块模式(Revealing Module Pattern):

var Counter = (function () {
  var count = 0;
  function increment() {
    count++;
  }
  function decrement() {
    count--;
  }
  function getValue() {
    return count;
  }
  // 在 return 时选择暴露哪些
  return {
    increment: increment,
    decrement: decrement,
    getValue: getValue,
  };
})();

1.4 AMD(Asynchronous Module Definition)

随着浏览器端代码日益复杂,需要异步加载模块。AMD 规范的代表实现是 RequireJS。

// 定义模块 — define(id?, dependencies?, factory)
define('math', ['dependency'], function (dep) {
  function add(a, b) {
    return a + b;
  }
  function subtract(a, b) {
    return a - b;
  }
  return { add: add, subtract: subtract };
});

// 使用模块
require(['math'], function (math) {
  console.log(math.add(2, 3)); // 5
});

优点:异步加载、依赖前置声明。
缺点:语法冗余、回调嵌套、不适用于服务器端。

1.5 CommonJS(Node.js 原生)

Node.js 诞生后,服务端需要同步加载模块,CommonJS 应运而生。

// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = { add, subtract };

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

特点:同步加载、运行时执行、模块缓存、适用于服务器。后面有专门章节详解。

1.6 UMD(Universal Module Definition)

UMD 是一种兼容浏览器和 Node.js 的"万能"模块格式,本质上是 AMD + CommonJS + 全局变量的组合。

// umd-module.js
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD (RequireJS)
    define(['lodash'], factory);
  } else if (typeof module === 'object' && module.exports) {
    // CommonJS (Node.js)
    module.exports = factory(require('lodash'));
  } else {
    // 浏览器全局变量
    root.MyModule = factory(root._);
  }
})(this, function (_) {
  var myModule = {
    sayHello: function (name) {
      return 'Hello, ' + name;
    },
    toUpper: function (str) {
      return _.toUpper(str);
    },
  };
  return myModule;
});

1.7 ES Module(ES2015 标准)

ECMAScript 2015(ES6)从语言层面定义了模块系统,成为 JavaScript 的官方标准。后面有专门章节详解。

演进总结表

阶段 模式 加载方式 适用范围
1995–2005 全局变量 同步 浏览器
2005–2010 IIFE / 命名空间 同步 浏览器
2010–2015 AMD (RequireJS) 异步 浏览器
2009–至今 CommonJS 同步 Node.js
2010–至今 UMD 通用 浏览器 + Node
2015–至今 ES Module 静态/异步 浏览器 + Node

2. CommonJS

CommonJS 是 Node.js 默认的模块系统,也是 npm 生态的基石。

2.1 module.exports 与 exports

每个文件都是一个模块,模块内部有一个 module 对象,module.exports 决定导出的内容。

// ----- greeter.js -----
function greet(name) {
  return `Hello, ${name}!`;
}

const version = '1.0.0';

// 方式一:给 module.exports 赋值
module.exports = {
  greet: greet,
  version: version,
};

// 方式二:给 exports 添加属性
// exports.greet = greet;
// exports.version = version;

// 注意:不能直接 exports = { ... },这会切断 exports 与 module.exports 的引用关系
// 以下写法是**错误**的:
// exports = { greet, version }; // ❌ — 不会导出任何内容
// ----- app.js -----
const greeter = require('./greeter');
console.log(greeter.greet('Alice')); // Hello, Alice!
console.log(greeter.version);         // 1.0.0

exports vs module.exports:exports 初始时是 module.exports 的引用。给 exports 添加属性等价于给 module.exports 添加属性。但直接给 exports 重新赋值会切断引用。

// 等价
exports.a = 1;
module.exports.a = 1;

// 不等价
exports = { a: 1 };  // ❌ module.exports 仍然是 {}
module.exports = { a: 1 }; // ✅

2.2 require() 同步加载

require() 是同步操作,模块代码在 require 调用时同步执行并返回结果。

// ----- timer.js -----
console.log('Timer module is loading...');
const start = Date.now();

function elapsed() {
  return Date.now() - start;
}

module.exports = { elapsed };

// ----- main.js -----
console.log('Before require');
const timer = require('./timer'); // 同步阻塞,直到 timer.js 执行完毕
console.log('After require');
console.log(timer.elapsed()); // 毫秒数

输出顺序:

Before require
Timer module is loading...
After require
0 (或很小的数值)

2.3 模块缓存

Node.js 会缓存已加载的模块,同一个模块多次 require 只会执行一次。

// ----- counter.js -----
let count = 0;
module.exports = {
  increment: () => ++count,
  getCount: () => count,
};

// ----- cache-demo.js -----
const c1 = require('./counter');
const c2 = require('./counter'); // 从缓存中取,不重新执行

c1.increment(); // count = 1
c1.increment(); // count = 2

console.log(c2.getCount()); // 2 — 因为 c1 和 c2 指向同一个模块实例
console.log(c1 === c2);     // true

可以通过 delete require.cache[modulePath] 清除指定模块的缓存(通常只在开发热更新场景下使用)。

2.4 循环依赖处理

当两个或多个模块相互引用时,CommonJS 通过"返回当前已导出的部分"来处理。

// ----- a.js -----
console.log('a.js 开始执行');
const b = require('./b');
console.log('a.js 中, b.done =', b.done);
exports.done = true;
console.log('a.js 执行完毕');

// ----- b.js -----
console.log('b.js 开始执行');
const a = require('./a');
console.log('b.js 中, a.done =', a.done);
exports.done = true;
console.log('b.js 执行完毕');

// ----- main.js -----
console.log('main.js 开始');
const a = require('./a');
console.log('main.js 中, a.done =', a.done);

执行 node main.js 输出:

main.js 开始
a.js 开始执行
b.js 开始执行
b.js 中, a.done = undefined  ← 关键!a 还没执行完,只能拿到部分导出的对象
b.js 执行完毕
a.js 中, b.done = true
a.js 执行完毕
main.js 中, a.done = true

循环依赖是设计上应避免的模式。如果无法避免,注意在加载顺序上做适当安排。

2.5 CommonJS 与 ES Module 的互操作

Node.js 支持两种模块系统并存,但它们的互操作有一些规则。

在 ESM 中引入 CJS(推荐方式):

// ----- cjs-module.cjs -----
module.exports = {
  name: 'CommonJS Module',
  greet(name) {
    return `Hi, ${name}`;
  },
};

// ----- esm-module.mjs -----
import cjsModule from './cjs-module.cjs';
// 或者命名空间导入
import * as cjsNS from './cjs-module.cjs';

console.log(cjsModule.name);  // CommonJS Module
console.log(cjsModule.greet('Alice')); // Hi, Alice

默认情况下,CJS 的 module.exports 会被当作 ESM 的 default export。命名空间导入 (import *) 会将 CJS 的所有键作为命名导出。

在 CJS 中引入 ESM(必须用动态 import):

// ----- esm-module.mjs -----
export const name = 'ES Module';
export function greet(name) {
  return `Hello, ${name}`;
}

// ----- cjs-app.cjs -----
async function main() {
  const esm = await import('./esm-module.mjs');
  console.log(esm.name);          // ES Module
  console.log(esm.greet('Bob'));  // Hello, Bob
}
main();

注意:在 CommonJS 中不能使用 require() 加载 ESM 模块,必须使用动态的 import()。


3. ES Module(ESM)

ES Module 是 ECMAScript 官方标准,目前所有现代浏览器和 Node.js(≥12)都已支持。

3.1 export 语法

命名导出(Named Export)

// ----- math.js -----
// 方式一:声明时直接导出
export const PI = 3.14159;
export function add(a, b) {
  return a + b;
}
export class Calculator {
  multiply(a, b) {
    return a * b;
  }
}

// 方式二:声明后统一导出
const E = 2.71828;
function subtract(a, b) {
  return a - b;
}
export { E, subtract };

// 方式三:重命名导出
function internalFn() {}
export { internalFn as publicFn };

默认导出(Default Export)

每个模块最多有一个默认导出。

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

// 也可以导出对象/类
export default class Logger {
  info(msg) {
    console.log(`[INFO] ${msg}`);
  }
}

// 或混合
const version = '1.0';
export default version;
export const author = 'Alice';

聚合导出(Re-export / Barrel Export)

常用于创建"入口文件"统一导出多个模块。

// ----- utils/index.js -----
// 直接聚合导出(不引入当前作用域)
export { default as formatDate } from './formatDate';
export { validateEmail } from './validate';
export { isNumeric } from './isNumeric';

// 重新导出所有命名导出
export * from './stringUtils';

// 重新导出并排除某个
export * from './numbers';
export { add } from './math'; // 如果 numbers 中也有 add,这里会报冲突。可用 as 解决

3.2 import 语法

命名导入

// 导入特定命名
import { PI, add, Calculator } from './math';

// 导入并重命名
import { add as addNumbers } from './math';

// 导入所有命名(命名空间导入)
import * as math from './math';
console.log(math.PI);

// 导入默认 + 命名
import log, { author } from './logger';

默认导入

import log from './logger';
import Logger from './logger';
import { default as log } from './logger'; // 等价

动态 import()

import() 是一个类似函数的表达式,返回 Promise,用于按需加载。

// ----- 按需加载模块 -----
async function loadChart() {
  try {
    const chartModule = await import('./charts/barChart.js');
    const chart = new chartModule.BarChart('#container');
    chart.render(data);
  } catch (err) {
    console.error('模块加载失败', err);
  }
}

// 或在事件回调中使用
button.addEventListener('click', async () => {
  const { showToast } = await import('./toast.js');
  showToast('加载成功!');
});

import.meta

import.meta 是一个对象,包含当前模块的元信息。

// ----- 获取当前模块 URL -----
console.log(import.meta.url);
// 浏览器: https://example.com/js/app.js
// Node.js: file:///home/user/project/src/app.js

// ----- Node.js 中获取当前文件路径 -----
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// ----- import.meta.resolve (Stage 4) -----
// 解析模块标识符的绝对 URL,不实际加载
// const specifier = await import.meta.resolve('./utils.js');

3.3 静态分析 vs 动态执行

ES Module 最大的设计特点是静态结构——import 和 export 必须在模块的顶层作用域,不能放在条件语句或函数中。

// ✅ 正确 — 顶层作用域
import { add } from './math.js';

// ❌ 错误 — 不能在条件语句中
if (needMath) {
  import { add } from './math.js'; // SyntaxError
}

// ✅ 动态导入则用 import()
if (needMath) {
  const { add } = await import('./math.js');
}

// ❌ 错误 — 不能在函数中
function load() {
  export const x = 1; // SyntaxError
}

静态分析带来的优势:

  1. Tree Shaking:构建工具可在编译时确定哪些导出被使用,移除未使用的代码
  2. 更快的查找:导入导出关系的确定在解析阶段完成,不需运行代码
  3. 循环依赖更可靠:ESM 使用"实时绑定"(live bindings),导入的是变量引用而非值拷贝

3.4 Tree Shaking 原理

Tree Shaking 依赖于 ES Module 的静态结构。构建工具(如 Webpack、Rollup)在打包时分析模块依赖图,移除没有被使用的导出。

// ----- math.js -----
export function add(a, b) {
  return a + b;
}
export function subtract(a, b) {
  return a - b;
}
export function multiply(a, b) {
  return a * b;
}
export function divide(a, b) {
  return a / b;
}

// ----- app.js -----
import { add } from './math.js';
console.log(add(2, 3));

在 Tree Shaking 后,subtract、multiply、divide 被移除,最终打包产物只包含 add 函数。

实际生效条件:

  1. 使用 ES Module 语法(import/export)
  2. 在 package.json 中设置 "sideEffects": false 或精确指定有副作用的文件
  3. 构建工具配置正确的 production 模式
// package.json
{
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfills.js"
  ]
}

3.5 严格模式

ES Module 自动启用严格模式(strict mode),无法取消。

// 在 ESM 中,以下行为都会报错
x = 10;                // ReferenceError: x is not defined
delete x;              // SyntaxError
function fn(a, a) {}   // SyntaxError: duplicate parameter
with (obj) { ... }     // SyntaxError

这也意味着 this 在模块顶层是 undefined 而不是 window。

3.6 浏览器中的 ESM

现代浏览器原生支持 <script type="module">。

<!-- 作为入口模块 -->
<script type="module" src="/src/main.js"></script>

<!-- 内联模块 -->
<script type="module">
  import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
  console.log('ES Module 已加载');
</script>

<!-- 常规 script vs module 对比 -->
<script src="lib.js"></script>                   <!-- 默认 defer=false,阻塞渲染 -->
<script type="module" src="app.js"></script>     <!-- 默认 defer,不阻塞渲染 -->

关键特性:

  • 模块默认是 defer 的,按顺序执行,不阻塞 HTML 解析
  • 模块代码只执行一次
  • 模块使用 CORS 策略,跨域需设置响应头 Access-Control-Allow-Origin
  • 模块可以引用其他模块,使用相对路径或绝对 URL
  • 不支持裸模块名(bare import specifier),如 import _ from 'lodash' — 浏览器不知道该去哪里找,需要 importmap

importmap(浏览器端解决 bare import 的方案):

<script type="importmap">
{
  "imports": {
    "lodash": "https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js",
    "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
  }
}
</script>
<script type="module">
  import _ from 'lodash';
  import { createApp } from 'vue';
  console.log(_.join(['Hello', 'World'], ' '));
</script>

3.7 Node.js 中的 ESM

在 Node.js 中使用 ESM,有两种方式:

方式一:文件扩展名

  • .mjs — 始终视为 ESM
  • .cjs — 始终视为 CommonJS
  • .js — 取决于 package.json 中的 type 字段

方式二:package.json type 字段

{
  "name": "my-project",
  "type": "module",  // 所有 .js 文件视为 ESM
  "exports": {
    ".": "./src/index.js",
    "./utils": "./src/utils.js"
  }
}

如果 type: "module",但某个文件需要保持 CommonJS,使用 .cjs 扩展名。

Node.js ESM 与 CJS 差异速查:

特性 CommonJS ES Module
默认扩展名 .js (type: commonjs) .mjs 或 .js (type: module)
require() ✅ ❌
import ❌ ✅
__dirname ✅ ❌ (可用 import.meta.url 计算)
__filename ✅ ❌
顶层 await ❌ ✅
JSON 导入 require('./data.json') import data from './data.json' (需 flag)
循环依赖 返回部分导出 Live binding,更可靠

Node.js 中实现 __dirname 和 __filename:

import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

console.log(__dirname); // 与 CJS 中的 __dirname 行为一致

顶层 await:

// 只能在 ESM 模块中使用
const response = await fetch('https://api.example.com/config');
const config = await response.json();
export default config;

4. 打包工具

4.1 Webpack

Webpack 是目前最成熟的打包工具,核心概念包括 entry、output、loader、plugin。

基本配置

Loader 的作用

Loader 让 Webpack 能处理非 JavaScript 文件。常见 loader:

Loader 用途
babel-loader 转译 ES6+ 到 ES5
css-loader 解析 CSS 中的 @import 和 url()
style-loader 将 CSS 注入到 DOM 的 <style> 标签
sass-loader 编译 SCSS/SASS
ts-loader 编译 TypeScript
file-loader 处理图片/字体文件
url-loader 小文件转 Base64 data URL

Plugin 的作用

Plugin 用于执行更广泛的任务,如打包优化、资源管理、环境变量注入。

// 常用 plugins
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 生成 HTML
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); // 提取 CSS 文件
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); // 压缩 CSS
const { DefinePlugin } = require('webpack'); // 定义全局变量

new DefinePlugin({
  'process.env.API_URL': JSON.stringify('https://api.example.com'),
}),

HMR(Hot Module Replacement)

开发时只替换变化的模块,不刷新页面,保留应用状态。

// webpack.config.js (dev)
module.exports = {
  devServer: {
    hot: true,  // 启用 HMR
    port: 3000,
    historyApiFallback: true, // SPA 路由支持
  },
};

模块需要自己声明是否接受 HMR:

if (module.hot) {
  module.hot.accept('./component.js', () => {
    console.log('Component updated!');
    render(); // 重新渲染
  });
}

4.2 Vite

Vite 利用浏览器原生 ESM 实现极速开发体验。

基本原理

  • 开发环境:基于 ESM 的 Dev Server,按需编译,无需打包
  • 生产环境:使用 Rollup 打包以获得更好的 Tree Shaking 和代码优化

基本配置

Vite vs Webpack 对比

特性 Vite Webpack
开发服务器启动 秒级(无需打包) 慢(需全量打包)
HMR 速度 模块级(仅编译变更文件) 模块级(但需重新打包)
生产构建 基于 Rollup 自带打包器
配置复杂度 简洁(内置常见 loader) 繁琐(需手动配置 loader)
插件生态 丰富但不如 Webpack 最丰富
兼容性 需要现代浏览器(开发时) 更好(通过 loader 转译)
适用场景 新项目、Vue/React SPA 大型复杂项目、需要精细控制

4.3 Rollup

Rollup 专注于 ES Module 打包,以卓越的 Tree Shaking 能力著称,是库打包的首选。

4.4 esbuild

esbuild 是用 Go 语言编写的极速构建工具,速度是传统工具的 10-100 倍。

// esbuild 使用示例(CLI)
// $ esbuild src/index.js --bundle --outfile=dist/bundle.js --minify

// 编程式使用
const esbuild = require('esbuild');

esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  outfile: 'dist/bundle.js',
  minify: true,
  sourcemap: true,
  target: ['chrome58', 'firefox57', 'safari11', 'edge16'],
  loader: {
    '.js': 'jsx',
    '.ts': 'ts',
    '.css': 'css',
  },
}).then(result => {
  console.log('构建成功!');
}).catch(() => process.exit(1));

esbuild 通常作为底层工具被 Vite、Snowpack、Turbopack 等使用,而非直接代替 Webpack 作为应用打包工具。

4.5 对比表

工具 语言 速度 核心优势 适用场景
Webpack JS 中等 生态最丰富、配置灵活 大型应用、需要精细控制
Vite Go + JS 极快 开发服务器秒启动、HMR 快 现代 SPA 开发
Rollup JS 中等 Tree Shaking 最佳、多格式输出 库/组件库打包
esbuild Go 最快 构建速度极快 作为基础设施、简单项目
Turbopack Rust 最快 Rust 原生、增量构建 (发展初期)下一代 Webpack
Parcel JS 快 零配置 小型项目、快速原型

5. Babel 与 TypeScript

5.1 Babel

Babel 是 JavaScript 编译器,将 ES6+ 代码转换为向后兼容的版本。

@babel/preset-env

智能预设,根据目标环境自动确定需要的转译和 polyfill。

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      targets: {
        browsers: ['> 1%', 'last 2 versions', 'not dead'],
        // 或指定 Node.js 版本
        // node: '14',
      },
      useBuiltIns: 'usage',    // 按需引入 polyfill
      corejs: 3,               // core-js 版本
      modules: false,          // 保留 ES Module 语法(让 Webpack 做 Tree Shaking)
    }],
  ],
  plugins: [
    // 如果有需要,添加单独插件
    '@babel/plugin-transform-runtime', // 复用辅助函数,减小体积
  ],
};

Polyfill 方案

方案 配置 说明
useBuiltIns: 'entry' 在入口手动 import 'core-js' 引入目标环境缺失的所有 polyfill
useBuiltIns: 'usage' 自动检测使用 根据代码中用到的 API 按需引入
useBuiltIns: false 不处理 需要自己管理 polyfill
@babel/plugin-transform-runtime 作为插件 不污染全局,适合库开发

5.2 TypeScript

TypeScript 是为 JavaScript 添加类型系统的超集。

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,           // 生成 .d.ts 类型声明
    "sourceMap": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "jsx": "react-jsx"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Babel vs TypeScript Compiler

特性 @babel/preset-typescript tsc (TypeScript Compiler)
类型检查 ❌(只移除类型语法) ✅
编译速度 快(仅转译) 慢(类型检查 + 转译)
生成 .d.ts ❌ ✅
转译能力 ✅(Babel 生态) 有限
工作流 Babel + 单独 tsc --noEmit 单工具

推荐工作流:

// package.json
{
  "scripts": {
    "build:types": "tsc --emitDeclarationOnly",  // 仅生成类型声明
    "build:js": "babel src --out-dir dist",       // Babel 转译
    "build": "npm run build:types && npm run build:js",
    "type-check": "tsc --noEmit"                  // 单独类型检查
  }
}

或者在 Vite 中通过 vite-plugin-checker 实现并行类型检查:

// vite.config.js
import checker from 'vite-plugin-checker';

export default defineConfig({
  plugins: [
    checker({ typescript: true }), // 在独立线程中运行 tsc --noEmit
  ],
});

6. 代码分割

代码分割(Code Splitting)将代码拆分成多个小块,按需加载,减少首屏加载体积。

6.1 动态 import() 实现懒加载

// ----- 图片懒加载模块 -----
// heavy-image-gallery.js
export function initGallery(containerId) {
  console.log(`初始化图片画廊: ${containerId}`);
  // ... 大量的库代码
}

// ----- 按需加载 -----
document.getElementById('show-gallery').addEventListener('click', async () => {
  // import() 返回 Promise,Webpack/Vite 会自动分割代码
  const { initGallery } = await import('./heavy-image-gallery.js');
  initGallery('gallery-container');
});

6.2 React.lazy + Suspense

React 16.6 引入了 React.lazy 和 Suspense。

// ----- App.jsx -----
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 懒加载组件
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));

// 加载指示器
function PageLoader() {
  return <div className="page-loader">Loading...</div>;
}

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
          <Route path="/dashboard" element={<DashboardPage />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

export default App;

Webpack 打包时,每个 lazy() 的组件会被分割成独立的 chunk(代码块)。

6.3 路由级代码分割

// 配合 React Router v6
import { lazy, Suspense } from 'react';
import { Routes, Route, Outlet } from 'react-router-dom';

const Users = lazy(() => import('./pages/Users'));
const UserDetail = lazy(() => import('./pages/UserDetail'));
const Settings = lazy(() => import('./pages/Settings'));

function SuspenseLayout() {
  return (
    <div>
      <header>导航栏</header>
      <Suspense fallback={<div>加载中...</div>}>
        <Outlet /> {/* 路由出口,懒加载子路由 */}
      </Suspense>
    </div>
  );
}

function AppRoutes() {
  return (
    <Routes>
      <Route element={<SuspenseLayout />}>
        <Route path="/users" element={<Users />} />
        <Route path="/users/:id" element={<UserDetail />} />
        <Route path="/settings" element={<Settings />} />
      </Route>
    </Routes>
  );
}

6.4 预加载与预获取

<link rel="preload"> 和 <link rel="prefetch"> 可以帮助浏览器在空闲时提前加载资源。

Webpack 中的魔法注释:

// 预获取(prefetch)— 浏览器空闲时加载,用于"将来可能用到"的资源
const AboutPage = import(/* webpackPrefetch: true */ './pages/AboutPage');

// 预加载(preload)— 当前页面需要立即用到的资源
const ChartLib = import(/* webpackPreload: true */ './lib/chart');

// 自定义 chunk 名称
const LazyComponent = import(/* webpackChunkName: "dashboard" */ './Dashboard');

// 在构建工具中也会自动注入对应的 <link>

Vite 中的预加载:

// Vite 自动处理动态 import 的预加载,也可手动配置
// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 将第三方库自动分割
            if (id.includes('lodash')) return 'vendor_lodash';
            if (id.includes('react')) return 'vendor_react';
          }
        },
      },
    },
  },
});

浏览器原生:

<!-- 预加载关键 CSS -->
<link rel="preload" href="/styles/critical.css" as="style">

<!-- 预获取非关键 JS -->
<link rel="prefetch" href="/scripts/analytics.js">

<!-- 预加载字体 -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>

7. 环境变量与配置管理

7.1 .env 文件

在项目根目录使用 .env 文件管理不同环境的配置。

# .env — 所有环境共享
APP_NAME=MyApp
VERSION=1.0.0

# .env.development — 开发环境
API_URL=http://localhost:3000/api
DEBUG=true

# .env.production — 生产环境
API_URL=https://api.example.com
DEBUG=false

# .env.local — 本地覆盖,不提交到 Git
# (已在 .gitignore 中忽略)
DB_PASSWORD=local_dev_only

7.2 Vite 的环境变量

Vite 使用 import.meta.env 暴露环境变量。

// Vite 中环境变量必须以 VITE_ 开头才会暴露给客户端
console.log(import.meta.env.VITE_API_URL);
console.log(import.meta.env.MODE);    // development | production
console.log(import.meta.env.PROD);    // boolean
console.log(import.meta.env.DEV);     // boolean
console.log(import.meta.env.SSR);     // boolean
// vite.config.js
import { defineConfig, loadEnv } from 'vite';

export default defineConfig(({ mode }) => {
  // 加载当前模式的 env
  const env = loadEnv(mode, process.cwd(), '');

  return {
    define: {
      // 自定义全局常量
      __APP_VERSION__: JSON.stringify(env.APP_VERSION),
    },
    server: {
      proxy: {
        '/api': {
          target: env.API_URL,
        },
      },
    },
  };
});

7.3 Webpack 的环境变量

Webpack 使用 DefinePlugin 或 EnvironmentPlugin。

// webpack.config.js
const webpack = require('webpack');

module.exports = (env, argv) => {
  const isDev = argv.mode === 'development';

  return {
    plugins: [
      new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify(argv.mode),
        'process.env.API_URL': JSON.stringify(
          isDev ? 'http://localhost:3000/api' : 'https://api.example.com'
        ),
        __DEV__: isDev,
      }),

      // 简写:自动读取 .env 中的变量
      new webpack.EnvironmentPlugin(['NODE_ENV', 'API_URL']),
    ],
  };
};

在代码中直接使用:

if (process.env.NODE_ENV === 'development') {
  console.log('开发模式');
}

const API_BASE = process.env.API_URL;

7.4 不同环境配置策略

// package.json——多环境脚本
{
  "scripts": {
    "dev": "vite",                              // 开发
    "build:dev": "vite build --mode development", // 开发环境构建
    "build:staging": "vite build --mode staging", // 预发布构建
    "build:prod": "vite build --mode production", // 生产构建

    "analyze": "ANALYZE=true vite build",        // 分析构建产物
    "build:legacy": "vite build --mode legacy"   // 兼容旧浏览器
  }
}
// 创建不同环境的构建配置
// vite.config.js
export default defineConfig(({ mode }) => {
  const configs = {
    development: {
      sourcemap: true,
      minify: false,
    },
    staging: {
      sourcemap: true,
      minify: 'esbuild',
    },
    production: {
      sourcemap: false,
      minify: 'terser',
    },
    legacy: {
      target: ['ie >= 11'],
      plugins: [legacyPlugin()],
    },
  };

  return {
    ...configs[mode] || configs.development,
    // 公共配置
    plugins: [vue(), ...(configs[mode]?.plugins || [])],
  };
});

8. 工程化最佳实践

8.1 ESLint + Prettier 代码规范

ESLint 负责代码质量与逻辑错误,Prettier 负责代码格式。

// .eslintrc.json
{
  "env": {
    "browser": true,
    "es2021": true,
    "node": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier"  // 必须放在最后,覆盖 ESLint 的格式规则
  ],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": "latest",
    "sourceType": "module"
  },
  "plugins": ["react", "@typescript-eslint"],
  "rules": {
    "no-console": "warn",
    "react/react-in-jsx-scope": "off",
    "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
  }
}
// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "tabWidth": 2,
  "printWidth": 100,
  "arrowParens": "always",
  "endOfLine": "lf"
}

集成到 VS Code:

// .vscode/settings.json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
}

8.2 Husky + lint-staged Git 钩子

在提交代码前自动检查和格式化。

# 安装
npx husky-init
npm install lint-staged --save-dev
// package.json
{
  "scripts": {
    "prepare": "husky install",
    "lint-staged": "lint-staged"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,css,scss}": ["prettier --write"]
  }
}
# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged
# .husky/commit-msg — 配合 commitlint 校验提交信息
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no -- commitlint --edit $1
// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2, 'always',
      ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore', 'perf'],
    ],
  },
};

8.3 包管理

三种主流包管理器对比:

特性 npm yarn pnpm
安装速度 慢 中等 快(硬链接 + 全局存储)
磁盘占用 高 高 低(共享存储)
锁文件 package-lock.json yarn.lock pnpm-lock.yaml
workspace 支持 ✅ ✅ ✅(原生支持)
严格模式 ❌ ❌ ✅(幽灵依赖隔离)

pnpm 的优势:

# pnpm 使用内容寻址的文件系统,相同版本的包只存一份
# 所有项目共享,通过硬链接引用

# 安装依赖
pnpm install

# 添加依赖
pnpm add lodash

# 添加开发依赖
pnpm add -D typescript

# monorepo 工作区
pnpm -F @project/ui build
# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'

8.4 monorepo 方案简介

Monorepo 将多个相关项目放在同一个仓库中管理。

常见工具:

工具 特点
pnpm workspace 原生支持、轻量
Turborepo 增量构建、缓存、智能任务调度
Nx 完整 monorepo 解决方案,含生成器、依赖图
Changesets 版本管理与发布

Turborepo 示例:

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [".env"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],  // 依赖项目先构建
      "outputs": ["dist/**", ".next/**"],
      "cache": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

8.5 CI/CD 基础

GitHub Actions 示例:


总结

JavaScript 模块化经历了从全局变量到原生 ES Module 的漫长演进。在当今的前端工程化实践中:

  1. 模块规范选择:新项目优先使用 ES Module;Node.js 后端项目可混用 CJS 和 ESM
  2. 构建工具选择:SPA 应用选 Vite;大型复杂项目选 Webpack;库/组件库选 Rollup
  3. TypeScript:强烈推荐使用,搭配 ESLint + Prettier 规范代码质量
  4. 代码分割:利用动态 import() 和路由级分割优化性能
  5. 工程化基建:ESLint、Husky、CI/CD 是项目质量的基础保障
  6. Monorepo:多包项目优先考虑 pnpm workspace + Turborepo

模块化和工程化不是目的,而是手段——最终目标是为用户交付高质量、高性能的 Web 应用。


参考资源