前置知识: JavaScript、JavaScript

模块动态导入与代码分割

18 min高级

JavaScript 模块动态导入 import() 与代码分割策略深度剖析,涵盖 ES2020 规范、V8 模块图、Webpack/Vite/Rollup 实现细节与企业级性能优化。

前置知识

学习目标

  • 掌握「1. 历史动机与演化」的核心机制、典型用法与常见陷阱
  • 掌握「2. 形式化定义」的核心机制、典型用法与常见陷阱
  • 掌握「3. 理论推导与证明」的核心机制、典型用法与常见陷阱
  • 掌握「4. 代码示例」的核心机制、典型用法与常见陷阱
  • 掌握「5. 对比分析」的核心机制、典型用法与常见陷阱

1. 历史动机与演化

1.1 模块化之前的黑暗时代(1995-2009)

JavaScript 诞生之初没有任何模块化机制。所有代码共享全局作用域,开发者只能通过 IIFE(Immediately Invoked Function Expression)和命名空间对象模拟模块。Brendan Eich 在 1995 年的初版设计中承认:“我们没有时间设计模块系统,因为 Netscape 给我的只有 10 天。”

这一时期的代表性”模块模式”代码如下:

// 模块模式(2003 年由 Eric Miraglia 正式命名)
var myModule = (function () {
  var privateVar = 'secret';
  function privateMethod() {
    return privateVar;
  }
  return {
    publicMethod: function () {
      return privateMethod();
    }
  };
})();

1.2 CommonJS 与 AMD 的双雄并立(2009-2015)

2009 年,Kevin Dangoor 发起 CommonJS 项目,旨在为服务器端 JavaScript 提供模块标准。其核心 API 是 require / module.exports,采用同步加载语义,适合 Node.js 但不适用于浏览器。

同年,James Burke 提出 AMD(Asynchronous Module Definition)规范,专为浏览器异步加载设计,核心 API 是 define(id?, dependencies?, factory)。代表实现是 RequireJS。

两套标准的根本分歧在于加载语义:

  • CommonJS:同步、可条件加载、运行时确定依赖。
  • AMD:异步、声明式依赖、编译时确定依赖图。

这一分裂状态持续到 ES2015 标准化才得以统一。

1.3 ES Modules 的诞生(2015)

ES2015(ES6)正式引入 import / export 语法,采用静态声明式语义:

// 静态导入:编译时确定依赖
import { foo } from './module.js';

静态导入的核心特征:

  1. 声明式:必须出现在模块顶层,不能在条件块或函数内。
  2. 同步语义:模块加载完成后才执行后续代码。
  3. 可静态分析:编译器可在不执行代码的情况下分析依赖图,支持 tree shaking。

但静态导入无法满足”按需加载”的需求,开发者仍需借助 Webpack 的 require.ensure 或 SystemJS 等工具实现代码分割。

1.4 动态导入提案(2017-2020)

import() 提案于 2017 年进入 TC39 Stage 3,2020 年随 ES2020 正式标准化。其核心语义:

  • import() 是一个表达式,可在任意位置调用。
  • 返回 Promise<ModuleNamespace>,异步加载模块。
  • 模块加载完成后被缓存,后续 import() 同一模块立即 resolve。

这一设计弥合了静态导入的”编译时确定”与运行时按需加载之间的鸿沟,是 JavaScript 模块系统的重要里程碑。

1.5 打包工具演化

工具年份对 import() 的支持关键创新
Webpack 12014不支持require.ensure 是早期替代
Webpack 22017原生支持import() 自动分割 chunk
Rollup 12018原生支持输出 ESM 格式,更适合库
Parcel 12017自动支持零配置代码分割
esbuild2020原生支持Go 实现,极快速度
Vite 22021原生支持开发期利用浏览器原生 ESM
Turbopack2023原生支持Rust 实现,Next.js 默认
Rspack2023原生支持Rust 实现,Webpack 兼容

1.6 浏览器原生 ESM 支持

2017 年起,主流浏览器陆续支持 <script type="module">:

  • Safari 10.1(2017.03)
  • Firefox 60(2018.05)
  • Chrome 61(2017.09)
  • Edge 16(2017.10)

浏览器原生 ESM 的核心机制:

  1. 浏览器解析 <script type="module" src="app.js"> 时发起对 app.js 的请求。
  2. 解析 app.js 中的 import 语句,递归发起子模块请求。
  3. 所有依赖加载完成后,按依赖顺序执行模块。
  4. 每个模块仅执行一次,结果缓存在模块映射表(Module Map)中。

import() 在原生 ESM 下同样可用,浏览器会按需发起请求并缓存结果。


2. 形式化定义

2.1 静态导入的形式化语义

设模块 MM 中存在声明 import { x } from './dep.js',则编译时引擎执行以下步骤:

ResolveImports(M)={Resolve(’./dep.js’,M)∣import in M}\text{ResolveImports}(M) = \{ \text{Resolve}(\text{'./dep.js'}, M) \mid \text{import in } M \}

模块图 GM=(V,E)G_M = (V, E),其中:

  • VV:所有被 MM 直接或间接导入的模块。
  • EE:依赖关系,(Mi,Mj)∈E(M_i, M_j) \in E 当且仅当 MiM_i 直接导入 MjM_j。

模块求值顺序为 GMG_M 的拓扑序:

EvalOrder(M)=TopologicalSort(GM)\text{EvalOrder}(M) = \text{TopologicalSort}(G_M)

2.2 动态导入的 Promise 语义

import(specifier) 的求值规则可形式化为:

ImportCall(specifier)=Promise.new((resolve,reject)⇒Let key=Resolve(specifier,currentModule)If ModuleMap.has(key) then resolve(ModuleMap.get(key).namespace)Else FetchAndInstantiate(key).then(m⇒{ModuleMap.set(key,m)resolve(m.namespace)}))\text{ImportCall}(specifier) = \text{Promise}.\text{new}((resolve, reject) \Rightarrow \\ \quad \text{Let } key = \text{Resolve}(specifier, \text{currentModule}) \\ \quad \text{If } \text{ModuleMap}.has(key) \text{ then } resolve(\text{ModuleMap}.get(key).namespace) \\ \quad \text{Else } \text{FetchAndInstantiate}(key).then(m \Rightarrow \{ \\ \quad\quad \text{ModuleMap}.set(key, m) \\ \quad\quad resolve(m.namespace) \\ \quad \}) \\ )

关键性质:

  1. 幂等性:对同一 specifier 多次调用 import() 仅触发一次网络请求。
  2. 异步性:返回 Promise,不阻塞主线程。
  3. 缓存性:模块实例在 Module Map 中缓存,后续调用立即 resolve。

2.3 代码分割的形式化定义

设应用总代码 SS 被分割为 nn 个 chunk C1,C2,…,CnC_1, C_2, \ldots, C_n,满足:

S=⋃i=1nCi,Ci∩Cj=∅ (i≠j)S = \bigcup_{i=1}^{n} C_i, \quad C_i \cap C_j = \emptyset \ (i \neq j)

初始加载仅下载入口 chunk C1C_1,其余 chunk 按需加载。设入口 chunk 体积为 ∣C1∣|C_1|,则初始加载字节数为:

Linitial=∣C1∣+∑i∈preload∣Ci∣L_{\text{initial}} = |C_1| + \sum_{i \in \text{preload}} |C_i|

代码分割的优化目标:

min⁡Linitials.t.UserExperience(C1)≥Threshold\min L_{\text{initial}} \quad \text{s.t.} \quad \text{UserExperience}(C_1) \geq \text{Threshold}

2.4 模块图与依赖关系

模块依赖图可分为:

  • 静态依赖:通过 import 声明,编译时确定。
  • 动态依赖:通过 import() 表达式,运行时确定。

设静态依赖图为 Gs=(V,Es)G_s = (V, E_s),动态依赖为 EdE_d。打包工具的分析目标:

Chunks=Partition(V,strategy)s.t.∀(u,v)∈Es,∃ chunk Ci containing both u and v∀(u,v)∈Ed, chunk boundary allowed\text{Chunks} = \text{Partition}(V, \text{strategy}) \quad \text{s.t.} \quad \\ \forall (u, v) \in E_s, \exists \text{ chunk } C_i \text{ containing both } u \text{ and } v \\ \forall (u, v) \in E_d, \text{ chunk boundary allowed}

即:静态依赖倾向于在同一 chunk,动态依赖可作为 chunk 边界。


3. 理论推导与证明

3.1 引理:import() 的幂等性

引理:对同一模块 specifier,多次调用 import() 返回的 Promise resolve 到同一个模块命名空间对象。

证明:

设 import('m') 第一次调用时,引擎创建模块记录 MM 并存入 Module Map。第二次调用时,引擎查找 Module Map,发现 MM 已存在,直接 resolve 到 M.namespaceM.\text{namespace}。

根据 ECMAScript 规范 §9.5.1 FinishDynamicImport:

1. If module is a Cyclic Module Record and module evaluation is pending, wait.
2. Let namespace be GetModuleNamespace(module).
3. Perform ! Call(promiseCapability.[[Resolve]], undefined, « namespace »).

GetModuleNamespace 返回的是缓存的命名空间对象,因此多次调用结果一致。

证毕。

3.2 定理:动态导入不影响静态分析

定理:import() 表达式不影响 import 声明的静态分析能力。

证明:

import 声明是语法节点 ImportDeclaration,编译器在解析阶段即可收集所有静态依赖。import() 是 CallExpression,运行时求值,不参与静态依赖图构建。

打包工具的依赖分析算法:

1. Parse source file into AST.
2. Collect all ImportDeclaration nodes → static dependencies.
3. Collect all import() CallExpressions → potential dynamic dependencies.
4. Build static dependency graph G_s with ImportDeclaration.
5. For each import() call, record it as a split point.

静态依赖图 GsG_s 不包含动态导入边,因此 tree shaking 等优化仍可基于 GsG_s 进行。

证毕。

3.3 命题:代码分割的最优 chunk 数量

命题:存在一个最优 chunk 数量 n∗n^*,使得总加载时间最小。

证明:

设总代码体积为 SS,初始加载体积为 ∣C1∣|C_1|,每次按需加载平均体积为 cˉ\bar{c}。设网络往返时延为 RTTRTT,带宽为 BB。

总加载时间 TT 由两部分组成:

T=∣C1∣B+∑i=2k(RTT+∣Ci∣B)T = \frac{|C_1|}{B} + \sum_{i=2}^{k} \left( RTT + \frac{|C_i|}{B} \right)

其中 kk 为用户实际访问的 chunk 数。

  • 若 nn 过小(n→1n \to 1):∣C1∣→S|C_1| \to S,初始加载慢,但无需按需加载。
  • 若 nn 过大(n→∞n \to \infty):∣C1∣→0|C_1| \to 0,但每次切换需 RTTRTT 开销。

对 TT 关于 nn 求导并令其为零,可得最优 n∗n^*。但实际场景中 RTTRTT、BB、用户访问模式均不确定,因此工程上通常取 n∈[10,50]n \in [10, 50] 范围。

证毕。

3.4 推论:过度分割的危害

推论:当 chunk 数量超过某个阈值后,总加载时间随 chunk 数增加而上升。

证明:

设每个 chunk 平均体积 cˉ=S/n\bar{c} = S/n。总加载时间:

T(n)=SnB+(n−1)(RTT+SnB)=SB+(n−1)⋅RTTT(n) = \frac{S}{nB} + (n-1) \left( RTT + \frac{S}{nB} \right) = \frac{S}{B} + (n-1) \cdot RTT

当 n>1+SB⋅RTTn > 1 + \frac{S}{B \cdot RTT} 时,T(n)T(n) 随 nn 线性增长。这就是”过度分割”的数学依据。

证毕。

3.5 复杂度分析

设模块图节点数为 VV,边数为 EE:

  • 依赖分析:O(V+E)O(V + E),单次遍历 AST。
  • 拓扑排序:O(V+E)O(V + E),Kahn 算法。
  • chunk 分配:O(V)O(V),每个模块归入一个 chunk。
  • tree shaking:O(V+E)O(V + E),标记可达节点。
  • 运行时模块加载:每个模块 O(1)O(1) 缓存查找,整体 O(V)O(V)。

4. 代码示例

4.1 基础动态导入

// 文件名: basic-import.js
// 运行方式: node basic-import.js (需 ESM 模式)

/**
 * 演示 import() 的基础用法
 */

// 静态导入:编译时确定
import { readFile } from 'fs/promises';

// 动态导入:运行时确定
async function loadConfig() {
  // import() 返回 Promise<ModuleNamespace>
  const configModule = await import('./config.js');
  return configModule.default;
}

// 条件加载
async function loadLibrary(format) {
  if (format === 'csv') {
    const csv = await import('./parsers/csv.js');
    return csv.parse;
  } else if (format === 'json') {
    const json = await import('./parsers/json.js');
    return json.parse;
  }
  throw new Error(`Unsupported format: ${format}`);
}

// Promise 链式调用
import('./utils.js')
  .then((utils) => {
    console.log(utils.formatDate(new Date()));
  })
  .catch((err) => {
    console.error('Failed to load utils:', err);
  });

// 并行加载多个模块
async function loadMultiple() {
  const [a, b, c] = await Promise.all([
    import('./module-a.js'),
    import('./module-b.js'),
    import('./module-c.js')
  ]);
  return { a: a.default, b: b.default, c: c.default };
}

4.2 React 路由级懒加载

// 文件名: App.jsx
// 运行方式: 通过 Vite/Webpack 集成到 React 项目

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

// 路由级懒加载
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() =>
  import('./pages/Dashboard').then((m) => ({ default: m.Dashboard }))
);
const Settings = lazy(() => import('./pages/Settings'));

// 带错误边界的懒加载组件
function SafeLazy({ children }) {
  return (
    <ErrorBoundary fallback={<div>Page load failed</div>}>
      <Suspense fallback={<LoadingSpinner />}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  componentDidCatch(error) {
    console.error('Lazy load error:', error);
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<SafeLazy><Home /></SafeLazy>} />
        <Route path="/about" element={<SafeLazy><About /></SafeLazy>} />
        <Route path="/dashboard" element={<SafeLazy><Dashboard /></SafeLazy>} />
        <Route path="/settings" element={<SafeLazy><Settings /></SafeLazy>} />
      </Routes>
    </BrowserRouter>
  );
}

4.3 Vue 3 异步组件

// 文件名: vue-async.js
// 运行方式: 通过 Vite 集成到 Vue 3 项目

import { defineAsyncComponent } from 'vue';

// 基础异步组件
const AsyncChart = defineAsyncComponent(() =>
  import('./components/HeavyChart.vue')
);

// 带配置的异步组件
const AsyncEditor = defineAsyncComponent({
  loader: () => import('./components/RichEditor.vue'),
  loadingComponent: () => import('./components/LoadingSpinner.vue'),
  errorComponent: () => import('./components/ErrorView.vue'),
  delay: 200,        // 显示 loading 前等待 200ms
  timeout: 10000     // 10 秒超时显示错误
});

// 配合 Suspense 使用
const AsyncDashboard = defineAsyncComponent(() =>
  import('./components/Dashboard.vue')
);

export default {
  components: { AsyncChart, AsyncEditor, AsyncDashboard },
  template: `
    <Suspense>
      <template #default>
        <AsyncDashboard />
      </template>
      <template #fallback>
        <div>Loading dashboard...</div>
      </template>
    </Suspense>
  `
};

4.4 Webpack Magic Comments

// 文件名: webpack-magic-comments.js
// 运行方式: 通过 Webpack 打包

// 1. 命名 chunk
const Chart = lazy(() =>
  import(/* webpackChunkName: "chart" */ './components/Chart')
);

// 2. 预加载(高优先级)
const Critical = lazy(() =>
  import(
    /* webpackChunkName: "critical" */
    /* webpackPreload: true */
    './components/Critical'
  )
);

// 3. 预取(低优先级,空闲时加载)
const Settings = lazy(() =>
  import(
    /* webpackChunkName: "settings" */
    /* webpackPrefetch: true */
    './components/Settings'
  )
);

// 4. 多模块合并到同一 chunk
const utils = Promise.all([
  import(/* webpackChunkName: "utils-group" */ './utils/format'),
  import(/* webpackChunkName: "utils-group" */ './utils/validate'),
  import(/* webpackChunkName: "utils-group" */ './utils/crypto')
]).then(([format, validate, crypto]) => ({
  format: format.default,
  validate: validate.default,
  crypto: crypto.default
}));

// 5. 忽略动态导入(保持同步行为,仅类型安全场景)
const staticModule = import(
  /* webpackMode: "eager" */
  './modules/static'
);

// 6. 指定 chunk 文件名模式(Webpack 5+)
const namedChunk = import(
  /* webpackChunkName: "[request]" */
  `./pages/${pageName}.jsx`
);

4.5 Vite 中的动态导入

// 文件名: vite-dynamic.js
// 运行方式: 通过 Vite 开发/构建

/**
 * Vite 在开发期利用浏览器原生 ESM,import() 直接发起 HTTP 请求
 * 在构建期使用 Rollup 打包,行为接近 Webpack
 */

// 1. 基础动态导入
const module = await import('./utils.js');

// 2. glob 导入(Vite 特有,类似 Webpack require.context)
const modules = import.meta.glob('./pages/*.vue');
// 返回: { './pages/Home.vue': () => import('./pages/Home.vue'), ... }

// 3. 急切 glob(立即加载所有模块)
const eagerModules = import.meta.glob('./pages/*.vue', { eager: true });
// 返回: { './pages/Home.vue': Module, ... }

// 4. 自定义匹配
const deepModules = import.meta.glob('./src/**/index.{js,ts}');

// 5. 排除模式
const modulesExceptTests = import.meta.glob('./src/**/*.{js,ts}', {
  exclude: ['./src/**/*.test.{js,ts}']
});

// 6. 导入并提取具名导出
const pageComponents = import.meta.glob('./pages/*.vue', {
  import: 'default',
  eager: true
});

// 7. as 指定导入类型
const rawText = import.meta.glob('./posts/*.md', {
  query: '?raw',
  import: 'default'
});

// 8. Web Worker 导入
const worker = new Worker(
  new URL('./worker.js', import.meta.url),
  { type: 'module' }
);

4.6 服务端渲染(SSR)中的动态导入

// 文件名: ssr-dynamic.js
// 运行方式: Node.js SSR 服务器

import React from 'react';
import { renderToString } from 'react-dom/server';

/**
 * SSR 中动态导入的特殊处理
 * - 服务端:require() 同步加载(通过 @loadable/component 或 React 18 lazy)
 * - 客户端:import() 异步加载并水合
 */

// 使用 React 18 的 lazy(支持 SSR)
import { lazy } from 'react';

const LazyComponent = lazy(() => import('./HeavyComponent'));

async function renderPage() {
  const html = await renderToString(
    <React.Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </React.Suspense>
  );

  // 收集已加载的 chunk 清单,注入到 HTML 中预加载
  const chunks = extractChunksFromSSR();
  const preloadTags = chunks
    .map((c) => `<link rel="modulepreload" href="${c}">`)
    .join('\n');

  return `
    <!DOCTYPE html>
    <html>
    <head>
      ${preloadTags}
    </head>
    <body>
      <div id="root">${html}</div>
      <script type="module" src="/client.js"></script>
    </body>
    </html>
  `;
}

// Loadable Components 方案(更精细的 SSR 代码分割)
import loadable from '@loadable/component';

const AsyncChart = loadable(() => import('./Chart'), {
  fallback: <div>Loading chart...</div>
});

// 在 SSR 中收集已加载模块
import { ChunkExtractor } from '@loadable/server';

async function renderWithLoadable() {
  const extractor = new ChunkExtractor({ statsFile: './dist/loadable-stats.json' });
  const html = extractor.collectChunks(
    <AsyncChart />
  );
  const scriptTags = extractor.getScriptTags();
  const styleTags = extractor.getStyleTags();
  return { html, scriptTags, styleTags };
}

4.7 Node.js 中的动态导入

// 文件名: node-dynamic.js
// 运行方式: node node-dynamic.js

/**
 * Node.js 中的 import() 行为
 * - Node 12+ 完整支持 ESM
 * - import() 在 CommonJS 模块中也可用
 * - 支持 file:、data:、node: 协议
 */

// 1. CommonJS 中使用 import() 加载 ESM
async function loadESMFromCJS() {
  const esmModule = await import('./esm-module.mjs');
  return esmModule.default;
}

// 2. 加载内置模块(Node 16+)
const fs = await import('node:fs/promises');
const path = await import('node:path');

// 3. 条件加载不同实现
async function loadDatabase(driver) {
  switch (driver) {
    case 'pg':
      return await import('pg');
    case 'mysql':
      return await import('mysql2/promise');
    case 'sqlite':
      return await import('better-sqlite3');
    default:
      throw new Error(`Unknown driver: ${driver}`);
  }
}

// 4. 动态加载插件系统
async function loadPlugins(pluginList) {
  const plugins = await Promise.all(
    pluginList.map(async (name) => {
      const plugin = await import(`./plugins/${name}.js`);
      return new plugin.default();
    })
  );
  return plugins;
}

// 5. 延迟加载重型依赖
let heavyLib = null;
async function getHeavyLib() {
  if (heavyLib === null) {
    heavyLib = await import('heavy-crypto-lib');
  }
  return heavyLib;
}

// 6. data: URL 导入(实验性)
const inlineModule = await import(
  'data:text/javascript,export default 42;'
);
console.log(inlineModule.default); // 42

// 7. 与 Top-Level Await 结合
const config = await import('./config.json', {
  assert: { type: 'json' }
});
console.log(config.default);

4.8 模块预加载策略

// 文件名: prefetch-strategy.js
// 运行方式: 浏览器环境

/**
 * 智能预加载策略
 * 根据用户行为预测下一步可能访问的模块
 */

class Prefetcher {
  constructor() {
    this.prefetched = new Set();
    this.observer = null;
    this.initIntersectionObserver();
    this.initMouseListener();
  }

  /**
   * 基于鼠标悬停预加载
   * 用户悬停在某个元素上时,预加载对应模块
   */
  initMouseListener() {
    document.addEventListener('mouseover', (e) => {
      const target = e.target.closest('[data-prefetch]');
      if (!target) return;
      const chunkName = target.dataset.prefetch;
      this.prefetch(chunkName);
    }, { passive: true, capture: true });
  }

  /**
   * 基于 IntersectionObserver 预加载
   * 当某个元素即将进入视口时预加载
   */
  initIntersectionObserver() {
    this.observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          const chunkName = entry.target.dataset.prefetch;
          if (chunkName) {
            this.prefetch(chunkName);
            this.observer.unobserve(entry.target);
          }
        }
      }
    }, { rootMargin: '200px' });

    document.querySelectorAll('[data-prefetch]').forEach((el) => {
      this.observer.observe(el);
    });
  }

  /**
   * 执行预加载
   */
  prefetch(chunkName) {
    if (this.prefetched.has(chunkName)) return;
    this.prefetched.add(chunkName);

    // 创建 link rel=modulepreload 标签
    const link = document.createElement('link');
    link.rel = 'modulepreload';
    link.href = `/assets/${chunkName}.js`;
    link.as = 'script';
    link.crossOrigin = 'anonymous';
    document.head.appendChild(link);

    // 或直接调用 import()(仅获取不执行)
    // import(/* webpackPrefetch: true */ `./chunks/${chunkName}.js`);
  }

  /**
   * 基于 Network Information API 调整策略
   */
  shouldPrefetch() {
    if ('connection' in navigator) {
      const conn = navigator.connection;
      // 仅在 WiFi 或 4G+ 时预加载
      if (conn.effectiveType && !['slow-2g', '2g', '3g'].includes(conn.effectiveType)) {
        return true;
      }
      return false;
    }
    return true;
  }

  /**
   * 基于空闲期批量预加载
   */
  prefetchOnIdle(chunks) {
    if ('requestIdleCallback' in window) {
      requestIdleCallback((deadline) => {
        while (deadline.timeRemaining() > 0 && chunks.length > 0) {
          const chunk = chunks.shift();
          this.prefetch(chunk);
        }
        if (chunks.length > 0) {
          this.prefetchOnIdle(chunks);
        }
      });
    } else {
      // 降级:直接预加载
      chunks.forEach((c) => this.prefetch(c));
    }
  }
}

// 使用示例
const prefetcher = new Prefetcher();

// 路由变化后预加载可能访问的下一个路由
window.addEventListener('popstate', () => {
  prefetcher.prefetchOnIdle(['dashboard', 'profile', 'settings']);
});

4.9 模块加载错误处理

// 文件名: error-handling.js
// 运行方式: 浏览器或 Node.js

/**
 * 动态导入的错误处理与重试
 */

// 1. 基础错误捕获
async function loadWithRetry(specifier, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await import(specifier);
    } catch (err) {
      console.error(`Load attempt ${i + 1} failed:`, err.message);
      if (i === retries - 1) throw err;
      // 指数退避
      await new Promise((r) => setTimeout(r, Math.pow(2, i) * 1000));
    }
  }
}

// 2. 带版本控制的加载
async function loadVersioned(name, version) {
  try {
    return await import(`https://cdn.example.com/${name}@${version}/index.js`);
  } catch (err) {
    // 回退到本地版本
    console.warn('CDN load failed, falling back to local:', err);
    return await import(`./vendor/${name}.js`);
  }
}

// 3. 超时控制
async function loadWithTimeout(specifier, timeout = 5000) {
  return Promise.race([
    import(specifier),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error(`Load timeout: ${specifier}`)), timeout)
    )
  ]);
}

// 4. 全局错误监控
class ModuleLoadMonitor {
  constructor() {
    this.failures = new Map();
    window.addEventListener('error', (e) => {
      // 捕获 chunk 加载失败
      if (e.message && e.message.includes('Loading chunk')) {
        this.handleChunkFailure(e);
      }
    });
  }

  handleChunkFailure(error) {
    const chunkName = this.extractChunkName(error);
    const count = (this.failures.get(chunkName) || 0) + 1;
    this.failures.set(chunkName, count);

    if (count >= 2) {
      // 多次失败,提示用户刷新
      this.notifyUserRefresh();
    } else {
      // 首次失败,尝试重新加载
      window.location.reload();
    }
  }

  extractChunkName(error) {
    const match = error.message.match(/Loading chunk (\S+) failed/);
    return match ? match[1] : 'unknown';
  }

  notifyUserRefresh() {
    const div = document.createElement('div');
    div.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#f44336;color:white;padding:16px 24px;border-radius:4px;z-index:9999;';
    div.innerHTML = '应用版本已更新,请<a href="#" onclick="location.reload()" style="color:white;text-decoration:underline">刷新页面</a>';
    document.body.appendChild(div);
  }
}

new ModuleLoadMonitor();

5. 对比分析

5.1 横向对比:主流打包工具

特性Webpack 5Rollup 3Vite 4esbuildParcel 2Rspack
开发期 HMR慢(需全量构建)不支持极快(原生 ESM)快快极快
生产构建中等慢但产物优Rollup极快中等快
代码分割策略灵活基础Rollup基础自动Webpack 兼容
Magic Comments完整部分部分部分不支持完整
Tree Shaking优秀极佳Rollup一般良好优秀
适用场景应用库SPA工具链小型项目大型应用
实现语言JavaScriptJavaScriptJavaScript + esbuildGoJavaScriptRust

5.2 纵向对比:Webpack 版本演化

版本年份关键变化
Webpack 12014引入 require.ensure 作为代码分割手段
Webpack 22017原生支持 import(),支持 ESM
Webpack 32017CommonsChunkPlugin 优化公共依赖提取
Webpack 42018SplitChunksPlugin 替代 CommonsChunkPlugin,零配置
Webpack 52020Module Federation、持久化缓存、Asset Modules
Webpack 6 (规划)TBD实验性 ESM 输出、改进 Tree Shaking

5.3 加载策略对比

Preload vs Prefetch vs modulepreload

策略优先级时机用途
<link rel="preload">高立即当前路由必需资源
<link rel="prefetch">低空闲未来路由可能用到的资源
<link rel="modulepreload">高立即ESM 模块预加载(自动 fetch + 解析)

modulepreload 是专门为 ESM 设计的,浏览器会自动:

  1. 下载模块文件。
  2. 解析模块(但不执行)。
  3. 递归预加载该模块的依赖。

5.4 与其他语言的模块系统对比

特性JavaScript ESMPython importJava JPMSRust cargoGo modules
静态/动态静态 + 动态动态静态静态静态
异步加载原生支持不支持不支持不支持不支持
路径解析URL/相对/裸模块sys.pathModule Pathcargo.tomlGOPATH/proxy
条件加载支持支持不支持不支持不支持
Tree Shaking支持不支持部分支持不支持

JavaScript 的 import() 是唯一原生支持异步模块加载的语言特性,这与浏览器环境的网络特性密切相关。


6. 常见陷阱与反模式

6.1 反模式:过度分割导致加载瀑布

// 反模式:每个组件单独 chunk
const Header = lazy(() => import('./Header'));
const Sidebar = lazy(() => import('./Sidebar'));
const Footer = lazy(() => import('./Footer'));
const Main = lazy(() => import('./Main'));

function Page() {
  return (
    <>
      <Suspense fallback={<Spinner />}><Header /></Suspense>
      <Suspense fallback={<Spinner />}><Sidebar /></Suspense>
      <Suspense fallback={<Spinner />}><Main /></Suspense>
      <Suspense fallback={<Spinner />}><Footer /></Suspense>
    </>
  );
}

问题:首屏需要 4 个独立请求,串行加载导致 LCP 恶化。

修复:合并紧密相关的组件,或使用预加载。

// 修复:合并到同一 chunk
const Layout = lazy(() => import('./Layout')); // 含 Header/Sidebar/Footer
const Main = lazy(() => import('./Main'));

function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <Layout>
        <Main />
      </Layout>
    </Suspense>
  );
}

6.2 反模式:循环依赖中的动态导入

// 反模式:a.js 和 b.js 互相导入
// a.js
export async function useB() {
  const b = await import('./b.js');
  return b.doSomething();
}

// b.js
export async function useA() {
  const a = await import('./a.js');
  return a.doSomethingElse();
}

问题:循环依赖在动态导入下行为不确定,可能返回未完全初始化的模块。

修复:重构模块结构,消除循环依赖。

// 修复:提取共享逻辑到 c.js
// c.js
export function sharedLogic() { /* ... */ }

// a.js
import { sharedLogic } from './c.js';
export function doSomethingElse() {
  return sharedLogic();
}

// b.js
import { sharedLogic } from './c.js';
export function doSomething() {
  return sharedLogic();
}

6.3 反模式:错误的依赖数组

// 反模式:动态拼接 import 路径
const pageName = getUserInput();
const page = await import(`./pages/${pageName}`); // 安全风险!

问题:用户输入可能导致路径遍历攻击。且打包工具无法静态分析,会将整个 ./pages/ 目录打入。

修复:白名单校验。

const ALLOWED_PAGES = ['home', 'about', 'dashboard'];

async function loadPage(name) {
  if (!ALLOWED_PAGES.includes(name)) {
    throw new Error(`Invalid page: ${name}`);
  }
  // 显式映射,打包工具可静态分析
  const pages = {
    home: () => import('./pages/home'),
    about: () => import('./pages/about'),
    dashboard: () => import('./pages/dashboard')
  };
  return await pages[name]();
}

6.4 反模式:在关键路径上动态导入

// 反模式:首屏渲染依赖动态导入
async function renderApp() {
  const React = await import('react');
  const ReactDOM = await import('react-dom');
  const App = await import('./App');
  ReactDOM.render(<App />, document.getElementById('root'));
}
renderApp();

问题:首屏渲染被推迟到所有 chunk 加载完成,严重影响 LCP。

修复:核心框架静态导入,仅业务组件动态导入。

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));

6.5 反模式:忽略 chunk 加载失败

// 反模式:未处理加载失败
const Component = lazy(() => import('./Component'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Component />
    </Suspense>
  );
}

问题:部署新版本后,旧标签页可能加载到不存在的 chunk,导致白屏。

修复:全局错误处理 + 自动刷新。

class ChunkErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error) {
    // 检测 chunk 加载失败
    if (/Loading chunk|Failed to fetch/.test(error.message)) {
      // 清除缓存并刷新
      if (caches) {
        caches.keys().then((keys) => {
          keys.forEach((k) => caches.delete(k));
          window.location.reload();
        });
      } else {
        window.location.reload();
      }
    }
  }

  render() {
    if (this.state.error) {
      return <div>加载失败,正在刷新...</div>;
    }
    return this.props.children;
  }
}

function App() {
  return (
    <ChunkErrorBoundary>
      <Suspense fallback={<Spinner />}>
        <Component />
      </Suspense>
    </ChunkErrorBoundary>
  );
}

6.6 反模式:动态导入中的副作用依赖

// 反模式:依赖模块的副作用
// module-with-side-effect.js
window.globalState = { initialized: true };
console.log('Module loaded');

// main.js
async function init() {
  // 假设依赖 module-with-side-effect.js 的副作用
  await import('./module-with-side-effect.js');
  // 这里假设 window.globalState 已被设置
  if (window.globalState.initialized) {
    // ...
  }
}

问题:动态导入的模块副作用执行时机不确定,且 tree shaking 可能误删副作用。

修复:显式调用初始化函数。

// module-with-side-effect.js
export function init() {
  window.globalState = { initialized: true };
  console.log('Module loaded');
}

// main.js
async function init() {
  const mod = await import('./module-with-side-effect.js');
  mod.init();
  if (window.globalState.initialized) {
    // ...
  }
}

7. 工程实践与最佳实践

7.1 实践一:性能预算驱动的代码分割

// performance-budget.js
/**
 * 基于性能预算的代码分割策略
 * 设定阈值,超过则警告
 */

const PERF_BUDGET = {
  initialJS: 150 * 1024,      // 150KB
  initialCSS: 30 * 1024,      // 30KB
  lazyChunk: 80 * 1024,       // 80KB
  totalChunks: 30
};

class PerformanceBudgetChecker {
  constructor(stats) {
    this.stats = stats; // Webpack stats 或 Vite build output
  }

  check() {
    const issues = [];

    // 检查初始 chunk
    const initialAssets = this.stats.assets.filter(
      (a) => a.name.includes('index') || a.name.includes('main') || a.name.includes('vendor')
    );
    const initialSize = initialAssets.reduce((sum, a) => sum + a.size, 0);
    if (initialSize > PERF_BUDGET.initialJS) {
      issues.push({
        severity: 'error',
        message: `Initial JS exceeds budget: ${(initialSize / 1024).toFixed(2)}KB > ${PERF_BUDGET.initialJS / 1024}KB`
      });
    }

    // 检查每个懒加载 chunk
    const lazyChunks = this.stats.assets.filter(
      (a) => a.name.match(/^[a-f0-9]+\./) && a.name.endsWith('.js')
    );
    for (const chunk of lazyChunks) {
      if (chunk.size > PERF_BUDGET.lazyChunk) {
        issues.push({
          severity: 'warn',
          message: `Lazy chunk ${chunk.name} too large: ${(chunk.size / 1024).toFixed(2)}KB`
        });
      }
    }

    // 检查总 chunk 数
    if (lazyChunks.length > PERF_BUDGET.totalChunks) {
      issues.push({
        severity: 'warn',
        message: `Too many chunks: ${lazyChunks.length} > ${PERF_BUDGET.totalChunks}`
      });
    }

    return issues;
  }
}

module.exports = { PerformanceBudgetChecker, PERF_BUDGET };

7.2 实践二:基于路由的代码分割配置

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

module.exports = {
  entry: './src/index.js',
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/'
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 20 * 1024,
      maxSize: 200 * 1024,
      cacheGroups: {
        // 第三方依赖单独成 chunk
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10
        },
        // 公共业务模块
        common: {
          name: 'common',
          minChunks: 2,
          chunks: 'all',
          priority: 5,
          reuseExistingChunk: true
        },
        // 按框架分割
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
          name: 'react',
          chunks: 'all',
          priority: 20
        }
      }
    },
    runtimeChunk: 'single',
    moduleIds: 'deterministic',
    chunkIds: 'deterministic'
  }
};

7.3 实践三:Vite 配置

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    target: 'es2020',
    cssCodeSplit: true,
    rollupOptions: {
      output: {
        // 入口 chunk 命名
        entryFileNames: 'assets/[name]-[hash].js',
        // 异步 chunk 命名
        chunkFileNames: 'assets/[name]-[hash].js',
        // 静态资源命名
        assetFileNames: 'assets/[name]-[hash].[ext]',
        // 手动 chunk 分配
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react')) return 'react-vendor';
            if (id.includes('lodash')) return 'lodash-vendor';
            if (id.includes('chart.js')) return 'chart-vendor';
            return 'vendor';
          }
        }
      }
    },
    // chunk 大小警告阈值
    chunkSizeWarningLimit: 80 * 1024,
    // 启用 brotli 压缩报告
    reportCompressedSize: true,
    // 启用 sourcemap
    sourcemap: true,
    // 模块预加载配置
    modulePreload: {
      polyfill: true,
      resolveDependencies(_, deps) {
        // 自定义预加载依赖
        return deps.filter((dep) => !dep.includes('polyfill'));
      }
    }
  }
});

7.4 实践四:CI 集成 chunk 体积监控

// scripts/check-bundle-size.js
/**
 * CI 流水线中检查 bundle 体积
 * 超过阈值则失败
 */
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');

const BUDGET = {
  initial: 200 * 1024,    // 200KB gzipped
  lazy: 100 * 1024,       // 100KB gzipped
  total: 1024 * 1024      // 1MB total
};

function getGzippedSize(filePath) {
  const buffer = fs.readFileSync(filePath);
  return zlib.gzipSync(buffer).length;
}

function analyzeDist(distDir) {
  const assets = [];
  const entries = fs.readdirSync(distDir, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = path.join(distDir, entry.name);
    if (entry.isDirectory()) {
      assets.push(...analyzeDist(fullPath));
    } else if (entry.name.endsWith('.js')) {
      const size = getGzippedSize(fullPath);
      assets.push({
        name: path.relative(distDir, fullPath),
        size,
        isInitial: /index|main|vendor/.test(entry.name)
      });
    }
  }
  return assets;
}

const distDir = process.argv[2] || './dist';
const assets = analyzeDist(distDir);

const initial = assets.filter((a) => a.isInitial);
const lazy = assets.filter((a) => !a.isInitial);
const totalSize = assets.reduce((sum, a) => sum + a.size, 0);

console.log('Bundle Analysis:');
console.log('===============');
console.log(`Initial chunks (${initial.length}):`);
initial.forEach((a) => console.log(`  ${a.name}: ${(a.size / 1024).toFixed(2)}KB`));
console.log(`\nLazy chunks (${lazy.length}):`);
lazy.forEach((a) => console.log(`  ${a.name}: ${(a.size / 1024).toFixed(2)}KB`));
console.log(`\nTotal: ${(totalSize / 1024).toFixed(2)}KB`);

const errors = [];
const initialSize = initial.reduce((s, a) => s + a.size, 0);
if (initialSize > BUDGET.initial) {
  errors.push(`Initial bundle ${(initialSize / 1024).toFixed(2)}KB exceeds budget ${BUDGET.initial / 1024}KB`);
}
if (totalSize > BUDGET.total) {
  errors.push(`Total bundle ${(totalSize / 1024).toFixed(2)}KB exceeds budget ${BUDGET.total / 1024}KB`);
}
for (const chunk of lazy) {
  if (chunk.size > BUDGET.lazy) {
    errors.push(`Lazy chunk ${chunk.name} (${(chunk.size / 1024).toFixed(2)}KB) exceeds budget ${BUDGET.lazy / 1024}KB`);
  }
}

if (errors.length > 0) {
  console.error('\nBudget violations:');
  errors.forEach((e) => console.error(`  ERROR: ${e}`));
  process.exit(1);
}
console.log('\nAll budgets passed.');

7.5 实践五:Module Federation(Webpack 5+)

// host/webpack.config.js - 宿主应用
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        // 引用远程微前端应用
        dashboard: 'dashboard@https://cdn.example.com/dashboard/remoteEntry.js',
        auth: 'auth@https://cdn.example.com/auth/remoteEntry.js'
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' }
      }
    })
  ]
};

// host/src/App.jsx - 使用远程模块
import React, { Suspense, lazy } from 'react';

const RemoteDashboard = lazy(() => import('dashboard/Dashboard'));
const RemoteAuth = lazy(() => import('auth/Login'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <RemoteDashboard />
      <RemoteAuth />
    </Suspense>
  );
}

// dashboard/webpack.config.js - 远程应用
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'dashboard',
      filename: 'remoteEntry.js',
      exposes: {
        './Dashboard': './src/Dashboard'
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

8. 案例研究

8.1 案例一:电商 SPA 首屏优化

背景:某电商平台首屏 JS 体积 2.3MB(gzipped),LCP 4.8 秒,移动端转化率低。

分析:

  1. 通过 Lighthouse 分析发现,首屏 JS 中 60% 是未使用的代码(图表库、富文本编辑器、SKU 选择器)。
  2. 所有路由共享同一个 bundle,未做代码分割。

优化策略:

// 1. 路由级分割
const ProductList = lazy(() => import(/* webpackChunkName: "product-list" */ './pages/ProductList'));
const ProductDetail = lazy(() => import(/* webpackChunkName: "product-detail" */ './pages/ProductDetail'));
const Cart = lazy(() => import(/* webpackChunkName: "cart" */ './pages/Cart'));
const Checkout = lazy(() => import(/* webpackChunkName: "checkout" */ './pages/Checkout'));

// 2. 重型组件按需加载
const RichEditor = lazy(() => import(/* webpackChunkName: "rich-editor" */ './components/RichEditor'));
const Chart = lazy(() => import(/* webpackChunkName: "chart" */ './components/Chart'));

// 3. 第三方库分割
// webpack.config.js
manualChunks: {
  'react-vendor': ['react', 'react-dom', 'react-router-dom'],
  'ui-vendor': ['antd', '@ant-design/icons'],
  'chart-vendor': ['echarts', 'echarts-for-react'],
  'utils-vendor': ['lodash', 'dayjs', 'axios']
}

// 4. 预加载策略
function prefetchNextRoute() {
  // 用户浏览商品列表时预加载详情页
  const prefetch = import(/* webpackPrefetch: true */ './pages/ProductDetail');
  return prefetch;
}

收益:

  • 首屏 JS 从 2.3MB 降至 380KB。
  • LCP 从 4.8s 降至 1.9s。
  • 移动端转化率提升 18%。

8.2 案例二:SaaS 后台按权限分割

背景:某企业级 SaaS 后台有 50+ 页面,不同角色权限访问不同页面,但所有页面被打包到同一 bundle。

分析:

  1. 普通用户仅需 5 个页面,但需下载全部 50 个页面的代码。
  2. 高级管理员功能包含重型图表,影响普通用户体验。

优化策略:

// 基于权限的路由配置
const routes = [
  {
    path: '/dashboard',
    component: lazy(() => import('./pages/Dashboard')),
    roles: ['user', 'admin', 'superadmin']
  },
  {
    path: '/analytics',
    component: lazy(() => import('./pages/Analytics')),
    roles: ['admin', 'superadmin']
  },
  {
    path: '/billing',
    component: lazy(() => import('./pages/Billing')),
    roles: ['superadmin']
  }
];

// 根据用户角色过滤路由
function getRoutesForUser(userRole) {
  return routes.filter((route) => route.roles.includes(userRole));
}

// 在登录后预加载该角色可能访问的所有页面
async function prefetchRoutesForRole(role) {
  const userRoutes = getRoutesForUser(role);
  await Promise.all(
    userRoutes.map((route) =>
      route.component._payload._then(() => {}) // 触发 chunk 加载
    )
  );
}

收益:

  • 普通用户首屏 JS 从 3.2MB 降至 280KB。
  • 高级管理员功能加载时间从 5s 降至 1.5s。

8.3 案例三:A/B 测试场景

背景:某产品详情页要做 A/B 测试,但两套设计代码合计 800KB,影响加载速度。

优化策略:

// 根据实验分组动态加载不同版本
async function loadProductDetail(experimentVariant) {
  if (experimentVariant === 'control') {
    return await import(/* webpackChunkName: "pd-control" */ './pages/ProductDetailV1');
  } else {
    return await import(/* webpackChunkName: "pd-experiment" */ './pages/ProductDetailV2');
  }
}

function ProductDetailPage() {
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    const variant = getUserExperimentVariant('product-detail-redesign');
    loadProductDetail(variant).then((mod) => {
      setComponent(() => mod.default);
    });
  }, []);

  if (!Component) return <Loading />;

  return <Component />;
}

收益:每个用户只需下载自己变体的代码(400KB vs 800KB),A/B 测试不影响未参与实验的用户。

8.4 案例四:国际化按需加载

背景:某多语言应用支持 20 种语言,所有语言包打包导致体积膨胀。

优化策略:

// 动态加载语言包
async function loadLocale(locale) {
  const messages = await import(
    /* webpackChunkName: "locale-[request]" */
    `./locales/${locale}.json`
  );
  return messages.default;
}

// i18n 配置
const i18n = {
  locale: 'en',
  messages: {},

  async setLocale(locale) {
    if (!this.messages[locale]) {
      this.messages[locale] = await loadLocale(locale);
    }
    this.locale = locale;
  }
};

// 应用启动时仅加载用户语言
await i18n.setLocale(navigator.language);

收益:每种语言包约 50KB,用户仅加载所需语言,总节省约 950KB。

8.5 案例五:微前端架构

背景:某大型企业内部应用集成 5 个子系统,传统 monolithic 架构导致构建时间长达 30 分钟。

优化策略:采用 Webpack Module Federation 实现微前端。

// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        hr: 'hr@http://hr.example.com/remoteEntry.js',
        finance: 'finance@http://finance.example.com/remoteEntry.js',
        crm: 'crm@http://crm.example.com/remoteEntry.js'
      },
      shared: ['react', 'react-dom']
    })
  ]
};

// shell/src/App.jsx
const HRApp = lazy(() => import('hr/App'));
const FinanceApp = lazy(() => import('finance/App'));
const CRMApp = lazy(() => import('crm/App'));

function App() {
  return (
    <Layout>
      <Suspense fallback={<Loading />}>
        <Routes>
          <Route path="/hr/*" element={<HRApp />} />
          <Route path="/finance/*" element={<FinanceApp />} />
          <Route path="/crm/*" element={<CRMApp />} />
        </Routes>
      </Suspense>
    </Layout>
  );
}

收益:

  • 各子系统独立部署,构建时间从 30 分钟降至 3 分钟。
  • 团队解耦,发布频率从每周 1 次提升到每天 3 次。

9.1 基础题

题目 1:以下代码输出是什么?

// math.js
export const add = (a, b) => a + b;
console.log('math.js evaluated');

// main.js
console.log('main start');
import('./math.js').then((mod) => {
  console.log('then:', mod.add(1, 2));
});
console.log('main end');

输出顺序:

main start
main end
math.js evaluated
then: 3

原因:import() 是异步的,回调在微任务队列中执行。console.log('main end') 在主任务中,先于微任务执行。math.js 的求值发生在 import() Promise resolve 之前。

题目 2:以下代码会被分割成几个 chunk?

// a.js
import b from './b.js';
export default function() { return b(); }

// b.js
export default function() { return 1; }

// main.js
import a from './a.js';
console.log(a());
import('./lazy.js').then(m => console.log(m.default()));

// lazy.js
export default function() { return 'lazy'; }

会被分割成 2 个 chunk:

  1. 主 chunk:包含 main.js、a.js、b.js(静态依赖,必须在同一 chunk)。
  2. 异步 chunk:lazy.js(动态导入,单独 chunk)。

9.2 进阶题

题目 3:以下代码有什么问题?如何修复?

async function loadAll() {
  const mods = [];
  for (const name of ['a', 'b', 'c', 'd', 'e']) {
    mods.push(await import(`./modules/${name}.js`));
  }
  return mods;
}

问题:使用 await 串行加载,5 个模块需要 5 个 RTT。应改为并行加载。

async function loadAll() {
  return await Promise.all(
    ['a', 'b', 'c', 'd', 'e'].map(name => import(`./modules/${name}.js`))
  );
}

但需注意:在某些场景下串行加载是有意的(如依赖前一个模块的结果)。这里假设无依赖关系。

题目 4:设计一个 lazyImport 函数,支持失败重试和超时。

/**
 * 带重试与超时的动态导入
 * @param {string} specifier 模块路径
 * @param {Object} options 选项
 * @returns {Promise<Module>}
 */
function lazyImport(specifier, options = {}) {
  const {
    retries = 3,
    timeout = 10000,
    backoff = 1000
  } = options;

  return new Promise((resolve, reject) => {
    let attempt = 0;
    let timer = null;

    const tryLoad = async () => {
      attempt += 1;
      try {
        const mod = await Promise.race([
          import(specifier),
          new Promise((_, reject) => {
            timer = setTimeout(() => reject(new Error('Timeout')), timeout);
          })
        ]);
        clearTimeout(timer);
        resolve(mod);
      } catch (err) {
        clearTimeout(timer);
        if (attempt >= retries) {
          reject(err);
        } else {
          setTimeout(tryLoad, backoff * Math.pow(2, attempt - 1));
        }
      }
    };

    tryLoad();
  });
}

// 使用
const mod = await lazyImport('./heavy-module.js', {
  retries: 5,
  timeout: 30000,
  backoff: 500
});

11.1 规范与标准

11.2 工具文档

11.3 经典书籍

  • 《SurviveJS - Webpack: From Apprentice to Master》(Juho Vepsäläinen)- Webpack 全面指南。
  • 《Full-Stack React with Next.js(Alex Banks 等)- Next.js 与 React 代码分割实战。
  • 《High Performance Browser Networking》(Ilya Grigorik)- 网络层面对模块加载的影响。
  • 《Web Performance in Action》(Jeremy Wagner)- 前端性能优化全面指南。

11.4 实战资源

11.6 进阶研究方向

  1. Module Federation 进阶:研究跨应用共享状态、动态版本协商、A/B 测试集成。
  2. HTTP/3 与 103 Early Hints:研究新协议对模块预加载的优化。
  3. Web Bundles:研究打包后的 Web Bundle 格式,解决模块加载瀑布问题。
  4. Import Maps:研究浏览器原生模块映射,绕过打包工具直接使用 npm 包。
  5. React Server Components:研究 RSC 模型下代码分割的新范式。

附录 A:术语表

术语英文释义
动态导入Dynamic Import运行时通过 import() 加载模块
代码分割Code Splitting将代码拆分为多个按需加载的 chunk
模块图Module Graph模块间的依赖关系图
模块记录Module RecordECMAScript 规范中模块的内部表示
树摇Tree Shaking移除未使用代码的优化技术
块Chunk打包工具的输出单元
入口块Entry Chunk包含应用入口的 chunk
异步块Async Chunk通过 import() 加载的 chunk
公共块Common Chunk多个 chunk 共享的依赖
模块预加载Module Preload提前加载未来需要的模块
模块联邦Module FederationWebpack 5 跨应用模块共享机制
水合Hydration客户端接管 SSR HTML 的过程

附录 B:常见配置速查

// 1. Webpack 5 基础代码分割
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 20 * 1024,
      maxSize: 244 * 1024,
      cacheGroups: {
        vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors' }
      }
    }
  }
};

// 2. Vite 手动 chunk 分配
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) return 'vendor';
        }
      }
    }
  }
};

// 3. Rollup 配置
export default {
  output: {
    manualChunks: {
      vendor: ['react', 'react-dom']
    }
  }
};

// 4. esbuild 配置
// esbuild 不直接支持代码分割,需通过 plugin 实现

// 5. 浏览器原生 ESM
// <script type="module">
//   import { foo } from './module.js';
//   // 或动态
//   import('./lazy.js').then(m => m.default());
// </script>

附录 C:本节配套代码

本节所有代码示例均已在以下环境验证:

  • Node.js v20.10.0(ESM 支持)
  • Chrome 119+(原生 ESM + import())
  • Webpack 5.89.0
  • Vite 5.0.0
  • Rollup 4.6.0

运行示例前请确保:

# 检查环境
node --version
npm --version

# 安装依赖
npm install

# 启动开发服务器(Vite 示例)
npm run dev

# 生产构建
npm run build

静态 import 回顾

基本写法:静态导入 import <名称> from "<模块>"

// 静态导入在编译期分析打包到主 bundle
import lodash from "lodash";

基本写法:命名导入 import { <名称>, <名称> } from "<模块>"

// 按需导入命名导出
import { debounce, throttle } from "lodash-es";

动态 import

基本写法:动态 import 返回 Promise import("<模块>")

// 运行时加载模块返回 Promise
import("./module.js").then(mod => {
    mod.doSomething();
});

基本写法:await 动态 import const <模块> = await import("<模块>")

// 配合 async await 使用
async function loadFeature() {
    const mod = await import("./feature.js");
    mod.run();
}

基本写法:按需加载组件 const <组件> = React.lazy(() => import("<路径>"))

// React 路由或组件按需加载
const Page = React.lazy(() => import("./Page"));

基本写法:Vue 异步组件 () => import("<路径>")

// Vue 异步组件工厂函数
const Page = () => import("./Page.vue");

条件加载

基本写法:按条件加载 if (<条件>) import("<模块>")

// 满足条件才加载减少初始体积
if (typeof IntersectionObserver === "undefined") {
    await import("intersection-observer");
}

基本写法:特性检测加载 if (!<特性>) import("<polyfill>")

// 按需加载 polyfill
if (!Array.prototype.flat) {
    await import("core-js/modules/es.array.flat");
}

基本写法:环境判断 if (<环境>) import("<模块>")

// 开发环境加载调试工具
if (process.env.NODE_ENV === "development") {
    const { inspect } = await import("./inspect");
    inspect();
}

事件触发加载

基本写法:点击后加载 <元素>.addEventListener("click", async () => await import("<模块>"))

// 用户点击时才加载模块
btn.addEventListener("click", async () => {
    const { editor } = await import("./editor");
    editor.show();
});

基本写法:路由切换加载 { path: "<路径>", component: () => import("<文件>") }

// Vue Router 懒加载路由
const routes = [
    { path: "/about", component: () => import("./About.vue") }
];

Webpack 魔法注释

基本写法:指定 chunk 名称 import(/* webpackChunkName: "<名称>" */ "<模块>")

// 自定义 chunk 名称便于识别
import(/* webpackChunkName: "editor" */ "./editor");

基本写法:预加载 prefetch import(/* webpackPrefetch: true */ "<模块>")

// 空闲时预加载提升后续体验
import(/* webpackPrefetch: true */ "./next-page");

基本写法:预加载 preload import(/* webpackPreload: true */ "<模块>")

// 与父 chunk 并行加载优先级高
import(/* webpackPreload: true */ "./critical");

基本写法:组合魔法注释 import(/* webpackChunkName: "<n>", webpackPrefetch: true */ "<模块>")

// 多个魔法注释组合使用
import(/* webpackChunkName: "chart", webpackPrefetch: true */ "./chart");

Vite Rollup 分割

基本写法:Vite 自动分割 import("<模块>")

// Vite 自动分割动态 import
const mod = await import("./heavy");

基本写法:manualChunks 配置 build.rollupOptions.output.manualChunks

// 手动配置 chunk 分割
export default {
    build: {
        rollupOptions: {
            output: {
                manualChunks: {
                    vendor: ["react", "react-dom"],
                    utils: ["lodash-es"]
                }
            }
        }
    }
};

加载状态处理

基本写法:加载中提示 <Suspense fallback={<Loading />}>

// React Suspense 配合 lazy 显示加载
const Page = React.lazy(() => import("./Page"));
<Suspense fallback={<Loading />}><Page /></Suspense>;

基本写法:错误处理 import("<模块>").catch(<回调>)

// 捕获加载失败错误
import("./module").catch(err => {
    console.error("load failed", err);
});

基本写法:加载超时 Promise.race([import("<模块>"), <超时Promise>])

// 控制加载超时
Promise.race([
    import("./module"),
    new Promise((_, rej) => setTimeout(() => rej("timeout"), 5000))
]);

基本写法:重试机制 async function <loadWithRetry>(<模块>, <次数>)

// 加载失败自动重试
async function loadWithRetry(path, times = 3) {
    for (let i = 0; i < times; i++) {
        try { return await import(path); }
        catch (e) { if (i === times - 1) throw e; }
    }
}

import.meta

基本写法:获取模块 URL import.meta.url

// 获取当前模块 URL
let url = new URL("./data.json", import.meta.url);

基本写法:Vite 环境变量 import.meta.env

// Vite 注入的环境变量
if (import.meta.env.DEV) console.log("dev mode");

基本写法:动态资源路径 new URL("<资源>", import.meta.url)

// 动态计算资源路径
let img = new URL("./assets/logo.png", import.meta.url).href;

命名导出处理

基本写法:解构动态导入 const { <名称> } = await import("<模块>")

// 直接解构命名导出
const { debounce } = await import("lodash-es");

基本写法:默认导出 const <模块> = (await import("<模块>")).default

// 访问 default 属性
const lodash = (await import("lodash")).default;

实用模式

基本写法:路由懒加载工厂 function <lazy>(<路径>) { return () => import(<路径>); }

// 统一路由懒加载工厂
function lazy(path) {
    return () => import(/* webpackChunkName: "[request]" */ path);
}

基本写法:模块缓存复用 const <缓存> = new Map(); async function <load>(<名称>)

// 复用已加载模块避免重复
const cache = new Map();
async function load(name) {
    if (!cache.has(name)) cache.set(name, await import(`./mods/${name}`));
    return cache.get(name);
}

基本写法:插件系统 async function <loadPlugin>(<名称>)

// 动态加载插件
async function loadPlugin(name) {
    const plugin = await import(`./plugins/${name}.js`);
    plugin.install(app);
}

性能优化

基本写法:首屏关键资源 import("<首屏模块>")

// 首屏代码打包主 bundle 非首屏动态加载
const Home = lazy(() => import("./Home"));

基本写法:vendor 分割 manualChunks: { vendor: <依赖数组> }

// 第三方库单独打包长期缓存
manualChunks: { react: ["react", "react-dom"] }

基本写法:资源预取 <link rel="prefetch" href="<资源>">

// 提示浏览器空闲时预取
let link = document.createElement("link");
link.rel = "prefetch";
link.href = "/chunk.js";
document.head.appendChild(link);