# 用动态 import() 做代码拆分

> 动态 import() 如何把 PWA 的 JavaScript 拆成按需加载的分块、按路由、组件与第三方库拆分各省下什么，以及 import() 在哪里被禁止。

代码拆分把 JavaScript 包切开，让浏览器在启动时只下载、解析和执行某个路由需要的代码；`import()` 是按需加载其余部分的语言特性，它是一个返回 Promise 的表达式，解析为模块命名空间对象。在 PWA 中这项技术缩短了到首次交互的时间，也让预缓存保持小巧，因为每个分块独立缓存、独立更新。

## 工作原理

静态 `import` 声明会被提升并在导入方模块的任何代码运行之前求值，所以每个静态依赖都在启动图里。`import(specifier)` 是表达式：打包器把目标模块输出为单独文件，浏览器在表达式执行时抓取它，Promise 以该模块的导出兑现（`javascript.operators.import`：Chrome 63、Firefox 67、Safari 11.1）。已加载并链接的模块只求值一次，并按解析后的 URL 缓存，所以重复的 `import()` 调用返回同一个命名空间而不发请求；只有求值失败会被缓存为失败，网络失败在下一次调用时重试（[import()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import)，developer.mozilla.org）。

### 在哪里拆

- **按路由。** 每个路由的视图是它自己的分块，路由匹配时加载。在应用型 PWA 里这从启动中去掉的字节最多，框架路由器拿到懒加载组件时做的就是这件事。
- **按组件。** 在交互时才出现的重型组件（编辑器、图表、地图）首次使用时加载，先立即渲染一个轻量占位。
- **按第三方库。** 第三方依赖单独成块，因为它们比应用代码变化得少，只改应用的一次部署让第三方分块的缓存条目继续有效。

代价是使用那一刻的延迟：点击时才请求的分块要经过一次网络往返才到达，除非 Service Worker 已预缓存它，或页面已为它发出 `<link rel="modulepreload">`。首次绘制之后预取可能用到的分块，启动和交互都能保持快速。

### import() 不可用的地方

`import()` 在窗口、专用 worker 和共享 worker 中可用，在 Service Worker 和 worklet 中抛出 `TypeError`。需要可选代码的 Service Worker 必须把它打包进去，或者用 `importScripts()` 加载，后者是同步的，且在经典 worker 中只允许在初次求值期间调用。

### 支持位置

动态 `import()` 自 Chrome 63、Firefox 67、Safari 11.1 和 Edge 79 起存在于所有引擎（BCD `javascript.operators.import`），打包器（webpack、Rollup、Vite、Parcel、esbuild）默认都把它当作拆分点。唯一的缺口是 Service Worker 作用域，那是规范规则而不是浏览器滞后。

## 示例

示例从常见的路由拆分走到按需组件，最后是能力探测。

### 路由首次匹配时加载它的模块

路由器把路径映射到动态导入。分块在首次导航到该路由时抓取，之后来自模块缓存。

```js
const routes = {
  '/': () => import('./views/home.js'),
  '/settings': () => import('./views/settings.js'),
  '/editor': () => import('./views/editor.js'),
};

async function navigate(path) {
  const load = routes[path] ?? routes['/'];
  const view = await load();
  view.render(document.querySelector('#app'));
}
```

在 `load` 之后对下一个可能的路由做一次预取（例如用户悬停在「编辑」按钮上时执行 `routes['/editor']()`）可以隐藏那次往返，又不把分块加进启动。

### 交互时加载重型组件

占位立即渲染；图表库只在用户打开面板时加载，catch 让离线失败不至于弄坏页面。

```js
button.addEventListener('click', async () => {
  button.disabled = true;
  try {
    const { renderChart } = await import('./chart.js');
    renderChart(document.querySelector('#chart'), data);
  } catch (err) {
    showMessage('图表无法加载。请检查网络连接后重试。');
    button.disabled = false;
  }
});
```

首次失败后的第二次点击会重新抓取，因为网络失败不会被模块表缓存。

### 检测动态 import 并保留打包的兜底

没有 `import()` 的引擎比「支持位置」一节列出的更老，但必须服务它们的构建可以给它们发单一的打包文件。测试把 `import()` 包进 `new Function()`，这样不认识该语法的解析器不会拒绝整个脚本。

```js
function supportsDynamicImport() {
  if (!('noModule' in HTMLScriptElement.prototype)) {
    return false; // 完全没有模块脚本：也没有 import()
  }
  try {
    new Function('return import("data:text/javascript,")');
    return true;
  } catch {
    return false; // 有模块但没有 import()：改为加载单一的旧版包
  }
}

if (supportsDynamicImport()) {
  import('./app.modern.js');
} else {
  const script = document.createElement('script');
  script.src = '/app.legacy.js';
  document.head.append(script);
}
```

`noModule` 检查在解析探测之前排除了比模块脚本更老的引擎；`data:` URL 让探测不发出真实请求，而函数只被构造、不被执行。

:::observed
Chrome DevTools 的 Coverage 面板（命令菜单：**Show Coverage**）记录一次会话中每个脚本和样式表的哪些字节真正运行过，并以 **Total Bytes**、**Unused Bytes** 和一个 **Usage Visualization** 条列出（[Coverage: find unused JavaScript and CSS](https://developer.chrome.com/docs/devtools/coverage)，developer.chrome.com）。开着 coverage 重新加载，比较拆分某个路由前后主包的 **Unused Bytes** 列，就是拆分从启动中去掉了多少的直接度量；某个分块只在用户打开面板之后才出现在列表里，则证实动态导入按预期触发了。
:::

## 另请参阅

- [import()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import)（developer.mozilla.org）
- [Reduce JavaScript payloads with code splitting](https://web.dev/articles/reduce-javascript-payloads-with-code-splitting)（web.dev）
- [Coverage: find unused JavaScript and CSS](https://developer.chrome.com/docs/devtools/coverage)（developer.chrome.com）
- [Core Web Vitals（LCP、INP 与 CLS）](/zh/reference/performance/core-web-vitals/)
- [预缓存策略](/zh/reference/performance/precaching/)
- [资源提示（preload 与 preconnect）](/zh/reference/performance/resource-hints/)