模块动态导入与代码分割
JavaScript 模块动态导入 import() 与代码分割策略深度剖析,涵盖 ES2020 规范、V8 模块图、Webpack/Vite/Rollup 实现细节与企业级性能优化。
前置知识
- JavaScript 模块化:建议先完成前一篇的学习
学习目标
- 掌握「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';
静态导入的核心特征:
- 声明式:必须出现在模块顶层,不能在条件块或函数内。
- 同步语义:模块加载完成后才执行后续代码。
- 可静态分析:编译器可在不执行代码的情况下分析依赖图,支持 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 1 | 2014 | 不支持 | require.ensure 是早期替代 |
| Webpack 2 | 2017 | 原生支持 | import() 自动分割 chunk |
| Rollup 1 | 2018 | 原生支持 | 输出 ESM 格式,更适合库 |
| Parcel 1 | 2017 | 自动支持 | 零配置代码分割 |
| esbuild | 2020 | 原生支持 | Go 实现,极快速度 |
| Vite 2 | 2021 | 原生支持 | 开发期利用浏览器原生 ESM |
| Turbopack | 2023 | 原生支持 | Rust 实现,Next.js 默认 |
| Rspack | 2023 | 原生支持 | 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 的核心机制:
- 浏览器解析
<script type="module" src="app.js">时发起对app.js的请求。 - 解析
app.js中的import语句,递归发起子模块请求。 - 所有依赖加载完成后,按依赖顺序执行模块。
- 每个模块仅执行一次,结果缓存在模块映射表(Module Map)中。
import() 在原生 ESM 下同样可用,浏览器会按需发起请求并缓存结果。
2. 形式化定义
2.1 静态导入的形式化语义
设模块 中存在声明 import { x } from './dep.js',则编译时引擎执行以下步骤:
模块图 ,其中:
- :所有被 直接或间接导入的模块。
- :依赖关系, 当且仅当 直接导入 。
模块求值顺序为 的拓扑序:
2.2 动态导入的 Promise 语义
import(specifier) 的求值规则可形式化为:
关键性质:
- 幂等性:对同一 specifier 多次调用
import()仅触发一次网络请求。 - 异步性:返回 Promise,不阻塞主线程。
- 缓存性:模块实例在 Module Map 中缓存,后续调用立即 resolve。
2.3 代码分割的形式化定义
设应用总代码 被分割为 个 chunk ,满足:
初始加载仅下载入口 chunk ,其余 chunk 按需加载。设入口 chunk 体积为 ,则初始加载字节数为:
代码分割的优化目标:
2.4 模块图与依赖关系
模块依赖图可分为:
- 静态依赖:通过
import声明,编译时确定。 - 动态依赖:通过
import()表达式,运行时确定。
设静态依赖图为 ,动态依赖为 。打包工具的分析目标:
即:静态依赖倾向于在同一 chunk,动态依赖可作为 chunk 边界。
3. 理论推导与证明
3.1 引理:import() 的幂等性
引理:对同一模块 specifier,多次调用 import() 返回的 Promise resolve 到同一个模块命名空间对象。
证明:
设 import('m') 第一次调用时,引擎创建模块记录 并存入 Module Map。第二次调用时,引擎查找 Module Map,发现 已存在,直接 resolve 到 。
根据 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.
静态依赖图 不包含动态导入边,因此 tree shaking 等优化仍可基于 进行。
证毕。
3.3 命题:代码分割的最优 chunk 数量
命题:存在一个最优 chunk 数量 ,使得总加载时间最小。
证明:
设总代码体积为 ,初始加载体积为 ,每次按需加载平均体积为 。设网络往返时延为 ,带宽为 。
总加载时间 由两部分组成:
其中 为用户实际访问的 chunk 数。
- 若 过小():,初始加载慢,但无需按需加载。
- 若 过大():,但每次切换需 开销。
对 关于 求导并令其为零,可得最优 。但实际场景中 、、用户访问模式均不确定,因此工程上通常取 范围。
证毕。
3.4 推论:过度分割的危害
推论:当 chunk 数量超过某个阈值后,总加载时间随 chunk 数增加而上升。
证明:
设每个 chunk 平均体积 。总加载时间:
当 时, 随 线性增长。这就是”过度分割”的数学依据。
证毕。
3.5 复杂度分析
设模块图节点数为 ,边数为 :
- 依赖分析:,单次遍历 AST。
- 拓扑排序:,Kahn 算法。
- chunk 分配:,每个模块归入一个 chunk。
- tree shaking:,标记可达节点。
- 运行时模块加载:每个模块 缓存查找,整体 。
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 5 | Rollup 3 | Vite 4 | esbuild | Parcel 2 | Rspack |
|---|---|---|---|---|---|---|
| 开发期 HMR | 慢(需全量构建) | 不支持 | 极快(原生 ESM) | 快 | 快 | 极快 |
| 生产构建 | 中等 | 慢但产物优 | Rollup | 极快 | 中等 | 快 |
| 代码分割策略 | 灵活 | 基础 | Rollup | 基础 | 自动 | Webpack 兼容 |
| Magic Comments | 完整 | 部分 | 部分 | 部分 | 不支持 | 完整 |
| Tree Shaking | 优秀 | 极佳 | Rollup | 一般 | 良好 | 优秀 |
| 适用场景 | 应用 | 库 | SPA | 工具链 | 小型项目 | 大型应用 |
| 实现语言 | JavaScript | JavaScript | JavaScript + esbuild | Go | JavaScript | Rust |
5.2 纵向对比:Webpack 版本演化
| 版本 | 年份 | 关键变化 |
|---|---|---|
| Webpack 1 | 2014 | 引入 require.ensure 作为代码分割手段 |
| Webpack 2 | 2017 | 原生支持 import(),支持 ESM |
| Webpack 3 | 2017 | CommonsChunkPlugin 优化公共依赖提取 |
| Webpack 4 | 2018 | SplitChunksPlugin 替代 CommonsChunkPlugin,零配置 |
| Webpack 5 | 2020 | Module 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 设计的,浏览器会自动:
- 下载模块文件。
- 解析模块(但不执行)。
- 递归预加载该模块的依赖。
5.4 与其他语言的模块系统对比
| 特性 | JavaScript ESM | Python import | Java JPMS | Rust cargo | Go modules |
|---|---|---|---|---|---|
| 静态/动态 | 静态 + 动态 | 动态 | 静态 | 静态 | 静态 |
| 异步加载 | 原生支持 | 不支持 | 不支持 | 不支持 | 不支持 |
| 路径解析 | URL/相对/裸模块 | sys.path | Module Path | cargo.toml | GOPATH/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 秒,移动端转化率低。
分析:
- 通过 Lighthouse 分析发现,首屏 JS 中 60% 是未使用的代码(图表库、富文本编辑器、SKU 选择器)。
- 所有路由共享同一个 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。
分析:
- 普通用户仅需 5 个页面,但需下载全部 50 个页面的代码。
- 高级管理员功能包含重型图表,影响普通用户体验。
优化策略:
// 基于权限的路由配置
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:
- 主 chunk:包含
main.js、a.js、b.js(静态依赖,必须在同一 chunk)。 - 异步 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 规范与标准
- TC39 import() 提案:https://github.com/tc39/proposal-dynamic-import - 完整的提案文档与讨论历史。
- ECMAScript 模块规范:https://tc39.es/ecma262/#sec-modules - 关注 §9.5 Dynamic Import。
- HTML Living Standard:https://html.spec.whatwg.org/ - 关注
<script type="module">与 modulepreload。 - WHATWG Import Assertions:https://github.com/tc39/proposal-import-attributes - JSON 模块等扩展。
11.2 工具文档
- Webpack 5 Code Splitting:https://webpack.js.org/guides/code-splitting/ - 官方代码分割指南。
- Rollup Code Splitting:https://rollupjs.org/guide/en/#code-splitting - Rollup 的分割策略。
- Vite Build Optimization:https://vitejs.dev/guide/build.html - Vite 构建优化配置。
- esbuild Code Splitting:https://esbuild.github.io/api/#splitting - esbuild 的分割能力。
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 实战资源
- web.dev 代码分割指南:https://web.dev/reduce-javascript-payloads-with-code-splitting/ - Google 官方最佳实践。
- Bundle Analyzer:https://github.com/webpack-contrib/webpack-bundle-analyzer - 可视化 bundle 组成。
- Lighthouse CI:https://github.com/GoogleChrome/lighthouse-ci - 自动化性能监控。
- Bundlephobia:https://bundlephobia.com/ - 评估 npm 包体积。
11.6 进阶研究方向
- Module Federation 进阶:研究跨应用共享状态、动态版本协商、A/B 测试集成。
- HTTP/3 与 103 Early Hints:研究新协议对模块预加载的优化。
- Web Bundles:研究打包后的 Web Bundle 格式,解决模块加载瀑布问题。
- Import Maps:研究浏览器原生模块映射,绕过打包工具直接使用 npm 包。
- React Server Components:研究 RSC 模型下代码分割的新范式。
附录 A:术语表
| 术语 | 英文 | 释义 |
|---|---|---|
| 动态导入 | Dynamic Import | 运行时通过 import() 加载模块 |
| 代码分割 | Code Splitting | 将代码拆分为多个按需加载的 chunk |
| 模块图 | Module Graph | 模块间的依赖关系图 |
| 模块记录 | Module Record | ECMAScript 规范中模块的内部表示 |
| 树摇 | Tree Shaking | 移除未使用代码的优化技术 |
| 块 | Chunk | 打包工具的输出单元 |
| 入口块 | Entry Chunk | 包含应用入口的 chunk |
| 异步块 | Async Chunk | 通过 import() 加载的 chunk |
| 公共块 | Common Chunk | 多个 chunk 共享的依赖 |
| 模块预加载 | Module Preload | 提前加载未来需要的模块 |
| 模块联邦 | Module Federation | Webpack 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);